From 454e80a8f862fc4fcec03eda5b77f1497b2a8539 Mon Sep 17 00:00:00 2001 From: Maxfield Luke Date: Thu, 30 Jul 2026 01:36:18 -0400 Subject: [PATCH] Initial commit Fresh repository on git.elektrine.com/elektrine/elektrine. --- .credo.exs | 170 + .dockerignore | 71 + .env.example | 292 + .forgejo/README.md | 41 + .forgejo/workflows/ci.yml | 129 + .forgejo/workflows/docker-deploy.yml | 373 + .forgejo/workflows/smoke.yml | 21 + .formatter.exs | 12 + .github/ISSUE_TEMPLATE/bug_report.yml | 61 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 45 + .github/dependabot.yml | 36 + .github/pull_request_template.md | 35 + .github/workflows/ci.yml | 119 + .github/workflows/docker-deploy.yml | 314 + .gitignore | 85 + AGENTS.md | 127 + CODE_OF_CONDUCT.md | 56 + CONTRIBUTING.md | 66 + LICENSE | 663 + README.md | 206 + SECURITY.md | 30 + apps/README.md | 44 + apps/arblarg/.formatter.exs | 4 + apps/arblarg/.gitignore | 24 + apps/arblarg/README.md | 20 + apps/arblarg/lib/arblarg.ex | 138 + apps/arblarg/lib/arblarg/application.ex | 20 + apps/arblarg/lib/arblarg_web.ex | 96 + .../lib/arblarg_web/components/chat/call.ex | 368 + .../components/chat/sender_badge.ex | 46 + .../arblarg_web/components/platform/e_nav.ex | 50 + .../components/social/content_journey.ex | 11 + .../components/social/embedded_post.ex | 11 + .../arblarg_web/components/ui/image_modal.ex | 11 + .../admin/chat_messages_controller.ex | 410 + .../controllers/admin/chat_messages_html.ex | 19 + .../chat_messages.html.heex | 389 + .../view_chat_message.html.heex | 228 + .../api/conversation_controller.ex | 907 + .../controllers/api/ext_chat_controller.ex | 423 + .../controllers/api/message_controller.ex | 473 + .../controllers/api/server_controller.ex | 301 + .../controllers/api/webhook_controller.ex | 255 + .../arblarg_web/live/chat_live/bootstrap.ex | 104 + .../chat_live/components/channel_modal.ex | 207 + .../live/chat_live/components/group_modal.ex | 272 + .../live/chat_live/handle_formatter.ex | 108 + .../lib/arblarg_web/live/chat_live/index.ex | 3604 ++++ .../live/chat_live/index.html.heex | 2873 +++ .../operations/call_info_operations.ex | 103 + .../chat_live/operations/call_operations.ex | 497 + .../operations/context_menu_operations.ex | 102 + .../operations/conversation_operations.ex | 417 + .../operations/direct_message_operations.ex | 174 + .../operations/emoji_gif_operations.ex | 43 + .../operations/group_channel_operations.ex | 672 + .../live/chat_live/operations/helpers.ex | 757 + .../chat_live/operations/member_operations.ex | 322 + .../operations/message_info_operations.ex | 413 + .../operations/message_operations.ex | 1099 ++ .../chat_live/operations/thread_operations.ex | 319 + .../chat_live/operations/ui_operations.ex | 260 + .../operations/voice_channel_operations.ex | 242 + .../lib/arblarg_web/live/chat_live/router.ex | 195 + .../lib/arblarg_web/live/chat_live/state.ex | 159 + .../lib/arblarg_web/optional_module.ex | 14 + apps/arblarg/mix.exs | 52 + apps/arblarg/test/arblarg_test.exs | 10 + .../operations/emoji_gif_operations_test.exs | 21 + apps/arblarg/test/test_helper.exs | 1 + apps/atomine/lib/atomine.ex | 5 + apps/atomine/lib/atomine/attestation.ex | 80 + apps/atomine/lib/atomine/attestations.ex | 656 + apps/atomine/lib/atomine/credit_account.ex | 40 + .../lib/atomine/credit_earning_policy.ex | 344 + .../lib/atomine/credit_ledger_entry.ex | 45 + apps/atomine/lib/atomine/credit_policy.ex | 77 + apps/atomine/lib/atomine/credit_spend.ex | 43 + apps/atomine/lib/atomine/credits.ex | 244 + .../lib/atomine/live_proof_recheck_worker.ex | 16 + apps/atomine/lib/atomine/personhood.ex | 927 + apps/atomine/lib/atomine/proof.ex | 204 + apps/atomine/lib/atomine/scoring.ex | 228 + apps/atomine/lib/atomine/trust_session.ex | 118 + apps/atomine/mix.exs | 39 + .../test/atomine/attestations_test.exs | 147 + apps/atomine/test/atomine/credits_test.exs | 74 + apps/atomine/test/atomine/personhood_test.exs | 680 + apps/atomine/test/test_helper.exs | 2 + apps/elektrine/.gitignore | 24 + apps/elektrine/README.md | 24 + apps/elektrine/assets/css/animations.css | 99 + apps/elektrine/assets/css/app.css | 40 + apps/elektrine/assets/css/base.css | 610 + apps/elektrine/assets/css/components.css | 391 + apps/elektrine/assets/css/effects.css | 444 + apps/elektrine/assets/css/email.css | 245 + apps/elektrine/assets/css/utilities.css | 541 + apps/elektrine/assets/js/admin_security.js | 361 + apps/elektrine/assets/js/app.js | 594 + apps/elektrine/assets/js/blinkenlights.js | 233 + apps/elektrine/assets/js/cursor_glow.js | 209 + apps/elektrine/assets/js/e_nav.js | 57 + apps/elektrine/assets/js/email_raw.js | 43 + apps/elektrine/assets/js/error_page.js | 110 + apps/elektrine/assets/js/form_helpers.js | 105 + apps/elektrine/assets/js/hashtag_links.js | 77 + .../assets/js/hooks/analytics_hooks.js | 231 + .../assets/js/hooks/backup_codes_printer.js | 69 + apps/elektrine/assets/js/hooks/call_hooks.js | 415 + .../js/hooks/chat_context_menu_hooks.js | 199 + .../assets/js/hooks/chat_e2ee_crypto.js | 312 + .../assets/js/hooks/chat_e2ee_hook.js | 1298 ++ .../assets/js/hooks/chat_e2ee_messages.js | 92 + apps/elektrine/assets/js/hooks/chat_hooks.js | 625 + .../js/hooks/chat_voice_recorder_hook.js | 167 + .../js/hooks/email_compose_shortcuts_hook.js | 264 + apps/elektrine/assets/js/hooks/email_hooks.js | 739 + .../js/hooks/email_iframe_resize_hook.js | 126 + .../assets/js/hooks/email_shortcut_helpers.js | 37 + .../assets/js/hooks/file_explorer_hook.js | 347 + apps/elektrine/assets/js/hooks/form_hooks.js | 488 + apps/elektrine/assets/js/hooks/index.js | 211 + .../assets/js/hooks/kairo_graph_hook.js | 442 + apps/elektrine/assets/js/hooks/kairo_hooks.js | 213 + .../js/hooks/mailbox_private_auth_forms.js | 112 + .../js/hooks/mailbox_private_auth_payloads.js | 68 + .../js/hooks/mailbox_private_compose_hook.js | 198 + .../js/hooks/mailbox_private_content.js | 301 + .../assets/js/hooks/mailbox_private_crypto.js | 435 + .../js/hooks/mailbox_private_messages_hook.js | 242 + .../js/hooks/mailbox_private_storage_hooks.js | 538 + .../assets/js/hooks/markdown_hooks.js | 308 + apps/elektrine/assets/js/hooks/nerve_hooks.js | 275 + .../assets/js/hooks/notification_hooks.js | 75 + .../js/hooks/notification_visibility.js | 124 + .../assets/js/hooks/paige_search_hook.js | 73 + .../assets/js/hooks/passkey_hooks.js | 278 + .../assets/js/hooks/portal_dropdowns.js | 272 + .../assets/js/hooks/presence_hooks.js | 133 + .../assets/js/hooks/profile_hooks.js | 279 + .../js/hooks/profile_link_reorder_hook.js | 64 + .../assets/js/hooks/proof_graph_dom.js | 18 + .../assets/js/hooks/proof_graph_hook.js | 599 + .../assets/js/hooks/proof_graph_paints.js | 215 + .../assets/js/hooks/proof_graph_styles.js | 119 + .../assets/js/hooks/static_site_hooks.js | 86 + .../assets/js/hooks/timeline_hooks.js | 714 + .../assets/js/hooks/timeline_media_hooks.js | 157 + .../js/hooks/timeline_preservation_hooks.js | 306 + .../js/hooks/timeline_session_continuity.js | 112 + .../assets/js/hooks/timeline_status_hooks.js | 195 + apps/elektrine/assets/js/hooks/ui_hooks.js | 535 + .../elektrine/assets/js/hooks/vault_crypto.js | 178 + apps/elektrine/assets/js/hooks/vault_hooks.js | 304 + .../assets/js/hooks/vault_session.js | 100 + .../assets/js/hooks/voice_channel_hook.js | 403 + .../assets/js/hooks/web_push_hook.js | 98 + apps/elektrine/assets/js/ip_lookup.js | 120 + apps/elektrine/assets/js/live_clock.js | 28 + apps/elektrine/assets/js/markdown_editor.js | 258 + apps/elektrine/assets/js/markdown_helpers.js | 178 + apps/elektrine/assets/js/markdown_toolbar.js | 281 + apps/elektrine/assets/js/modal_controls.js | 17 + .../assets/js/notification_system.js | 523 + apps/elektrine/assets/js/profile_static.js | 644 + apps/elektrine/assets/js/ringtone_player.js | 225 + apps/elektrine/assets/js/tagline_cycler.js | 24 + apps/elektrine/assets/js/theme.js | 171 + apps/elektrine/assets/js/utils/clipboard.js | 152 + apps/elektrine/assets/js/utils/dropdown.js | 49 + .../assets/js/utils/email_selection.js | 79 + .../assets/js/utils/form_submission.js | 19 + apps/elektrine/assets/js/utils/index.js | 9 + .../assets/js/utils/overlay_portal.js | 85 + apps/elektrine/assets/js/utils/spinner.js | 20 + .../assets/js/utils/user_hover_card_scroll.js | 26 + apps/elektrine/assets/js/webrtc_client.js | 638 + apps/elektrine/assets/package-lock.json | 1204 ++ apps/elektrine/assets/package.json | 19 + apps/elektrine/assets/scripts/check_js.mjs | 154 + apps/elektrine/assets/tailwind.config.mjs | 90 + .../elektrine/assets/vendor/heroicons/LICENSE | 21 + .../assets/vendor/heroicons/SOURCE.md | 9 + .../optimized/16/solid/academic-cap.svg | 4 + .../16/solid/adjustments-horizontal.svg | 3 + .../16/solid/adjustments-vertical.svg | 3 + .../16/solid/archive-box-arrow-down.svg | 4 + .../optimized/16/solid/archive-box-x-mark.svg | 4 + .../optimized/16/solid/archive-box.svg | 4 + .../optimized/16/solid/arrow-down-circle.svg | 3 + .../optimized/16/solid/arrow-down-left.svg | 3 + .../16/solid/arrow-down-on-square-stack.svg | 4 + .../16/solid/arrow-down-on-square.svg | 3 + .../optimized/16/solid/arrow-down-right.svg | 3 + .../optimized/16/solid/arrow-down-tray.svg | 4 + .../optimized/16/solid/arrow-down.svg | 3 + .../optimized/16/solid/arrow-left-circle.svg | 3 + .../16/solid/arrow-left-end-on-rectangle.svg | 4 + .../solid/arrow-left-start-on-rectangle.svg | 3 + .../optimized/16/solid/arrow-left.svg | 3 + .../optimized/16/solid/arrow-long-down.svg | 3 + .../optimized/16/solid/arrow-long-left.svg | 3 + .../optimized/16/solid/arrow-long-right.svg | 3 + .../optimized/16/solid/arrow-long-up.svg | 3 + .../16/solid/arrow-path-rounded-square.svg | 3 + .../optimized/16/solid/arrow-path.svg | 3 + .../optimized/16/solid/arrow-right-circle.svg | 3 + .../16/solid/arrow-right-end-on-rectangle.svg | 4 + .../solid/arrow-right-start-on-rectangle.svg | 3 + .../optimized/16/solid/arrow-right.svg | 3 + .../16/solid/arrow-top-right-on-square.svg | 4 + .../16/solid/arrow-trending-down.svg | 3 + .../optimized/16/solid/arrow-trending-up.svg | 3 + .../16/solid/arrow-turn-down-left.svg | 3 + .../16/solid/arrow-turn-down-right.svg | 3 + .../16/solid/arrow-turn-left-down.svg | 3 + .../optimized/16/solid/arrow-turn-left-up.svg | 3 + .../16/solid/arrow-turn-right-down.svg | 3 + .../16/solid/arrow-turn-right-up.svg | 3 + .../optimized/16/solid/arrow-turn-up-left.svg | 3 + .../16/solid/arrow-turn-up-right.svg | 3 + .../optimized/16/solid/arrow-up-circle.svg | 3 + .../optimized/16/solid/arrow-up-left.svg | 3 + .../16/solid/arrow-up-on-square-stack.svg | 4 + .../optimized/16/solid/arrow-up-on-square.svg | 3 + .../optimized/16/solid/arrow-up-right.svg | 3 + .../optimized/16/solid/arrow-up-tray.svg | 4 + .../heroicons/optimized/16/solid/arrow-up.svg | 3 + .../optimized/16/solid/arrow-uturn-down.svg | 3 + .../optimized/16/solid/arrow-uturn-left.svg | 3 + .../optimized/16/solid/arrow-uturn-right.svg | 3 + .../optimized/16/solid/arrow-uturn-up.svg | 3 + .../optimized/16/solid/arrows-pointing-in.svg | 3 + .../16/solid/arrows-pointing-out.svg | 3 + .../optimized/16/solid/arrows-right-left.svg | 3 + .../optimized/16/solid/arrows-up-down.svg | 3 + .../optimized/16/solid/at-symbol.svg | 3 + .../optimized/16/solid/backspace.svg | 3 + .../heroicons/optimized/16/solid/backward.svg | 3 + .../optimized/16/solid/banknotes.svg | 4 + .../heroicons/optimized/16/solid/bars-2.svg | 3 + .../optimized/16/solid/bars-3-bottom-left.svg | 3 + .../16/solid/bars-3-bottom-right.svg | 3 + .../optimized/16/solid/bars-3-center-left.svg | 3 + .../heroicons/optimized/16/solid/bars-3.svg | 3 + .../heroicons/optimized/16/solid/bars-4.svg | 3 + .../optimized/16/solid/bars-arrow-down.svg | 3 + .../optimized/16/solid/bars-arrow-up.svg | 3 + .../optimized/16/solid/battery-0.svg | 3 + .../optimized/16/solid/battery-100.svg | 4 + .../optimized/16/solid/battery-50.svg | 4 + .../heroicons/optimized/16/solid/beaker.svg | 3 + .../optimized/16/solid/bell-alert.svg | 4 + .../optimized/16/solid/bell-slash.svg | 4 + .../optimized/16/solid/bell-snooze.svg | 3 + .../heroicons/optimized/16/solid/bell.svg | 3 + .../heroicons/optimized/16/solid/bold.svg | 3 + .../optimized/16/solid/bolt-slash.svg | 3 + .../heroicons/optimized/16/solid/bolt.svg | 3 + .../optimized/16/solid/book-open.svg | 3 + .../optimized/16/solid/bookmark-slash.svg | 3 + .../optimized/16/solid/bookmark-square.svg | 3 + .../heroicons/optimized/16/solid/bookmark.svg | 3 + .../optimized/16/solid/briefcase.svg | 4 + .../heroicons/optimized/16/solid/bug-ant.svg | 3 + .../optimized/16/solid/building-library.svg | 3 + .../optimized/16/solid/building-office-2.svg | 3 + .../optimized/16/solid/building-office.svg | 3 + .../16/solid/building-storefront.svg | 3 + .../heroicons/optimized/16/solid/cake.svg | 3 + .../optimized/16/solid/calculator.svg | 3 + .../16/solid/calendar-date-range.svg | 4 + .../optimized/16/solid/calendar-days.svg | 4 + .../heroicons/optimized/16/solid/calendar.svg | 3 + .../heroicons/optimized/16/solid/camera.svg | 4 + .../optimized/16/solid/chart-bar-square.svg | 3 + .../optimized/16/solid/chart-bar.svg | 3 + .../optimized/16/solid/chart-pie.svg | 4 + .../solid/chat-bubble-bottom-center-text.svg | 3 + .../16/solid/chat-bubble-bottom-center.svg | 3 + .../16/solid/chat-bubble-left-ellipsis.svg | 3 + .../16/solid/chat-bubble-left-right.svg | 4 + .../optimized/16/solid/chat-bubble-left.svg | 3 + .../solid/chat-bubble-oval-left-ellipsis.svg | 3 + .../16/solid/chat-bubble-oval-left.svg | 3 + .../optimized/16/solid/check-badge.svg | 3 + .../optimized/16/solid/check-circle.svg | 3 + .../heroicons/optimized/16/solid/check.svg | 3 + .../16/solid/chevron-double-down.svg | 3 + .../16/solid/chevron-double-left.svg | 3 + .../16/solid/chevron-double-right.svg | 3 + .../optimized/16/solid/chevron-double-up.svg | 3 + .../optimized/16/solid/chevron-down.svg | 3 + .../optimized/16/solid/chevron-left.svg | 3 + .../optimized/16/solid/chevron-right.svg | 3 + .../optimized/16/solid/chevron-up-down.svg | 3 + .../optimized/16/solid/chevron-up.svg | 3 + .../optimized/16/solid/circle-stack.svg | 5 + .../16/solid/clipboard-document-check.svg | 4 + .../16/solid/clipboard-document-list.svg | 4 + .../optimized/16/solid/clipboard-document.svg | 4 + .../optimized/16/solid/clipboard.svg | 3 + .../heroicons/optimized/16/solid/clock.svg | 3 + .../optimized/16/solid/cloud-arrow-down.svg | 3 + .../optimized/16/solid/cloud-arrow-up.svg | 3 + .../heroicons/optimized/16/solid/cloud.svg | 3 + .../16/solid/code-bracket-square.svg | 3 + .../optimized/16/solid/code-bracket.svg | 3 + .../optimized/16/solid/cog-6-tooth.svg | 3 + .../optimized/16/solid/cog-8-tooth.svg | 3 + .../heroicons/optimized/16/solid/cog.svg | 3 + .../optimized/16/solid/command-line.svg | 3 + .../optimized/16/solid/computer-desktop.svg | 3 + .../heroicons/optimized/16/solid/cpu-chip.svg | 4 + .../optimized/16/solid/credit-card.svg | 4 + .../optimized/16/solid/cube-transparent.svg | 3 + .../heroicons/optimized/16/solid/cube.svg | 3 + .../16/solid/currency-bangladeshi.svg | 3 + .../optimized/16/solid/currency-dollar.svg | 4 + .../optimized/16/solid/currency-euro.svg | 3 + .../optimized/16/solid/currency-pound.svg | 3 + .../optimized/16/solid/currency-rupee.svg | 3 + .../optimized/16/solid/currency-yen.svg | 3 + .../optimized/16/solid/cursor-arrow-rays.svg | 3 + .../16/solid/cursor-arrow-ripple.svg | 5 + .../16/solid/device-phone-mobile.svg | 4 + .../optimized/16/solid/device-tablet.svg | 4 + .../heroicons/optimized/16/solid/divide.svg | 4 + .../16/solid/document-arrow-down.svg | 3 + .../optimized/16/solid/document-arrow-up.svg | 3 + .../optimized/16/solid/document-chart-bar.svg | 3 + .../optimized/16/solid/document-check.svg | 3 + .../solid/document-currency-bangladeshi.svg | 3 + .../16/solid/document-currency-dollar.svg | 4 + .../16/solid/document-currency-euro.svg | 3 + .../16/solid/document-currency-pound.svg | 3 + .../16/solid/document-currency-rupee.svg | 3 + .../16/solid/document-currency-yen.svg | 3 + .../optimized/16/solid/document-duplicate.svg | 4 + .../16/solid/document-magnifying-glass.svg | 4 + .../optimized/16/solid/document-minus.svg | 3 + .../optimized/16/solid/document-plus.svg | 3 + .../optimized/16/solid/document-text.svg | 3 + .../heroicons/optimized/16/solid/document.svg | 3 + .../16/solid/ellipsis-horizontal-circle.svg | 3 + .../16/solid/ellipsis-horizontal.svg | 3 + .../optimized/16/solid/ellipsis-vertical.svg | 3 + .../optimized/16/solid/envelope-open.svg | 3 + .../heroicons/optimized/16/solid/envelope.svg | 4 + .../heroicons/optimized/16/solid/equals.svg | 3 + .../optimized/16/solid/exclamation-circle.svg | 3 + .../16/solid/exclamation-triangle.svg | 3 + .../optimized/16/solid/eye-dropper.svg | 3 + .../optimized/16/solid/eye-slash.svg | 4 + .../heroicons/optimized/16/solid/eye.svg | 4 + .../optimized/16/solid/face-frown.svg | 3 + .../optimized/16/solid/face-smile.svg | 3 + .../heroicons/optimized/16/solid/film.svg | 3 + .../optimized/16/solid/finger-print.svg | 3 + .../heroicons/optimized/16/solid/fire.svg | 3 + .../heroicons/optimized/16/solid/flag.svg | 3 + .../optimized/16/solid/folder-arrow-down.svg | 3 + .../optimized/16/solid/folder-minus.svg | 3 + .../optimized/16/solid/folder-open.svg | 3 + .../optimized/16/solid/folder-plus.svg | 3 + .../heroicons/optimized/16/solid/folder.svg | 3 + .../heroicons/optimized/16/solid/forward.svg | 3 + .../heroicons/optimized/16/solid/funnel.svg | 3 + .../heroicons/optimized/16/solid/gif.svg | 3 + .../heroicons/optimized/16/solid/gift-top.svg | 4 + .../heroicons/optimized/16/solid/gift.svg | 4 + .../optimized/16/solid/globe-alt.svg | 3 + .../optimized/16/solid/globe-americas.svg | 3 + .../16/solid/globe-asia-australia.svg | 3 + .../16/solid/globe-europe-africa.svg | 3 + .../heroicons/optimized/16/solid/h1.svg | 3 + .../heroicons/optimized/16/solid/h2.svg | 3 + .../heroicons/optimized/16/solid/h3.svg | 3 + .../optimized/16/solid/hand-raised.svg | 3 + .../optimized/16/solid/hand-thumb-down.svg | 3 + .../optimized/16/solid/hand-thumb-up.svg | 3 + .../heroicons/optimized/16/solid/hashtag.svg | 3 + .../heroicons/optimized/16/solid/heart.svg | 3 + .../optimized/16/solid/home-modern.svg | 3 + .../heroicons/optimized/16/solid/home.svg | 3 + .../optimized/16/solid/identification.svg | 3 + .../optimized/16/solid/inbox-arrow-down.svg | 4 + .../optimized/16/solid/inbox-stack.svg | 4 + .../heroicons/optimized/16/solid/inbox.svg | 3 + .../optimized/16/solid/information-circle.svg | 3 + .../heroicons/optimized/16/solid/italic.svg | 3 + .../heroicons/optimized/16/solid/key.svg | 3 + .../heroicons/optimized/16/solid/language.svg | 3 + .../heroicons/optimized/16/solid/lifebuoy.svg | 3 + .../optimized/16/solid/light-bulb.svg | 3 + .../optimized/16/solid/link-slash.svg | 3 + .../heroicons/optimized/16/solid/link.svg | 4 + .../optimized/16/solid/list-bullet.svg | 3 + .../optimized/16/solid/lock-closed.svg | 3 + .../optimized/16/solid/lock-open.svg | 3 + .../16/solid/magnifying-glass-circle.svg | 4 + .../16/solid/magnifying-glass-minus.svg | 4 + .../16/solid/magnifying-glass-plus.svg | 4 + .../optimized/16/solid/magnifying-glass.svg | 3 + .../heroicons/optimized/16/solid/map-pin.svg | 3 + .../heroicons/optimized/16/solid/map.svg | 3 + .../optimized/16/solid/megaphone.svg | 3 + .../optimized/16/solid/microphone.svg | 4 + .../optimized/16/solid/minus-circle.svg | 3 + .../heroicons/optimized/16/solid/minus.svg | 3 + .../heroicons/optimized/16/solid/moon.svg | 3 + .../optimized/16/solid/musical-note.svg | 3 + .../optimized/16/solid/newspaper.svg | 4 + .../optimized/16/solid/no-symbol.svg | 3 + .../optimized/16/solid/numbered-list.svg | 3 + .../optimized/16/solid/paint-brush.svg | 3 + .../optimized/16/solid/paper-airplane.svg | 3 + .../optimized/16/solid/paper-clip.svg | 3 + .../optimized/16/solid/pause-circle.svg | 3 + .../heroicons/optimized/16/solid/pause.svg | 3 + .../optimized/16/solid/pencil-square.svg | 4 + .../heroicons/optimized/16/solid/pencil.svg | 3 + .../optimized/16/solid/percent-badge.svg | 3 + .../16/solid/phone-arrow-down-left.svg | 4 + .../16/solid/phone-arrow-up-right.svg | 4 + .../optimized/16/solid/phone-x-mark.svg | 4 + .../heroicons/optimized/16/solid/phone.svg | 3 + .../heroicons/optimized/16/solid/photo.svg | 3 + .../optimized/16/solid/play-circle.svg | 3 + .../optimized/16/solid/play-pause.svg | 3 + .../heroicons/optimized/16/solid/play.svg | 3 + .../optimized/16/solid/plus-circle.svg | 3 + .../heroicons/optimized/16/solid/plus.svg | 3 + .../heroicons/optimized/16/solid/power.svg | 3 + .../16/solid/presentation-chart-bar.svg | 3 + .../16/solid/presentation-chart-line.svg | 3 + .../heroicons/optimized/16/solid/printer.svg | 3 + .../optimized/16/solid/puzzle-piece.svg | 3 + .../heroicons/optimized/16/solid/qr-code.svg | 9 + .../16/solid/question-mark-circle.svg | 3 + .../optimized/16/solid/queue-list.svg | 3 + .../heroicons/optimized/16/solid/radio.svg | 3 + .../optimized/16/solid/receipt-percent.svg | 3 + .../optimized/16/solid/receipt-refund.svg | 3 + .../optimized/16/solid/rectangle-group.svg | 3 + .../optimized/16/solid/rectangle-stack.svg | 3 + .../optimized/16/solid/rocket-launch.svg | 4 + .../heroicons/optimized/16/solid/rss.svg | 3 + .../heroicons/optimized/16/solid/scale.svg | 3 + .../heroicons/optimized/16/solid/scissors.svg | 4 + .../optimized/16/solid/server-stack.svg | 4 + .../heroicons/optimized/16/solid/server.svg | 4 + .../heroicons/optimized/16/solid/share.svg | 3 + .../optimized/16/solid/shield-check.svg | 3 + .../optimized/16/solid/shield-exclamation.svg | 3 + .../optimized/16/solid/shopping-bag.svg | 3 + .../optimized/16/solid/shopping-cart.svg | 3 + .../optimized/16/solid/signal-slash.svg | 3 + .../heroicons/optimized/16/solid/signal.svg | 5 + .../heroicons/optimized/16/solid/slash.svg | 3 + .../heroicons/optimized/16/solid/sparkles.svg | 3 + .../optimized/16/solid/speaker-wave.svg | 4 + .../optimized/16/solid/speaker-x-mark.svg | 3 + .../optimized/16/solid/square-2-stack.svg | 4 + .../optimized/16/solid/square-3-stack-3d.svg | 5 + .../optimized/16/solid/squares-2x2.svg | 3 + .../optimized/16/solid/squares-plus.svg | 3 + .../heroicons/optimized/16/solid/star.svg | 3 + .../optimized/16/solid/stop-circle.svg | 3 + .../heroicons/optimized/16/solid/stop.svg | 3 + .../optimized/16/solid/strikethrough.svg | 3 + .../heroicons/optimized/16/solid/sun.svg | 3 + .../heroicons/optimized/16/solid/swatch.svg | 4 + .../optimized/16/solid/table-cells.svg | 3 + .../heroicons/optimized/16/solid/tag.svg | 3 + .../heroicons/optimized/16/solid/ticket.svg | 3 + .../heroicons/optimized/16/solid/trash.svg | 3 + .../heroicons/optimized/16/solid/trophy.svg | 3 + .../heroicons/optimized/16/solid/truck.svg | 5 + .../heroicons/optimized/16/solid/tv.svg | 4 + .../optimized/16/solid/underline.svg | 3 + .../optimized/16/solid/user-circle.svg | 3 + .../optimized/16/solid/user-group.svg | 3 + .../optimized/16/solid/user-minus.svg | 3 + .../optimized/16/solid/user-plus.svg | 3 + .../heroicons/optimized/16/solid/user.svg | 3 + .../heroicons/optimized/16/solid/users.svg | 3 + .../heroicons/optimized/16/solid/variable.svg | 3 + .../optimized/16/solid/video-camera-slash.svg | 3 + .../optimized/16/solid/video-camera.svg | 3 + .../optimized/16/solid/view-columns.svg | 3 + .../optimized/16/solid/viewfinder-circle.svg | 3 + .../heroicons/optimized/16/solid/wallet.svg | 3 + .../heroicons/optimized/16/solid/wifi.svg | 3 + .../heroicons/optimized/16/solid/window.svg | 3 + .../optimized/16/solid/wrench-screwdriver.svg | 4 + .../heroicons/optimized/16/solid/wrench.svg | 3 + .../heroicons/optimized/16/solid/x-circle.svg | 3 + .../heroicons/optimized/16/solid/x-mark.svg | 3 + .../optimized/20/solid/academic-cap.svg | 3 + .../20/solid/adjustments-horizontal.svg | 3 + .../20/solid/adjustments-vertical.svg | 3 + .../20/solid/archive-box-arrow-down.svg | 3 + .../optimized/20/solid/archive-box-x-mark.svg | 4 + .../optimized/20/solid/archive-box.svg | 4 + .../optimized/20/solid/arrow-down-circle.svg | 3 + .../optimized/20/solid/arrow-down-left.svg | 3 + .../20/solid/arrow-down-on-square-stack.svg | 3 + .../20/solid/arrow-down-on-square.svg | 3 + .../optimized/20/solid/arrow-down-right.svg | 3 + .../optimized/20/solid/arrow-down-tray.svg | 4 + .../optimized/20/solid/arrow-down.svg | 3 + .../optimized/20/solid/arrow-left-circle.svg | 3 + .../20/solid/arrow-left-end-on-rectangle.svg | 4 + .../20/solid/arrow-left-on-rectangle.svg | 4 + .../solid/arrow-left-start-on-rectangle.svg | 4 + .../optimized/20/solid/arrow-left.svg | 3 + .../optimized/20/solid/arrow-long-down.svg | 3 + .../optimized/20/solid/arrow-long-left.svg | 3 + .../optimized/20/solid/arrow-long-right.svg | 3 + .../optimized/20/solid/arrow-long-up.svg | 3 + .../20/solid/arrow-path-rounded-square.svg | 3 + .../optimized/20/solid/arrow-path.svg | 3 + .../optimized/20/solid/arrow-right-circle.svg | 3 + .../20/solid/arrow-right-end-on-rectangle.svg | 4 + .../20/solid/arrow-right-on-rectangle.svg | 4 + .../solid/arrow-right-start-on-rectangle.svg | 4 + .../optimized/20/solid/arrow-right.svg | 3 + .../optimized/20/solid/arrow-small-down.svg | 3 + .../optimized/20/solid/arrow-small-left.svg | 3 + .../optimized/20/solid/arrow-small-right.svg | 3 + .../optimized/20/solid/arrow-small-up.svg | 3 + .../20/solid/arrow-top-right-on-square.svg | 4 + .../20/solid/arrow-trending-down.svg | 3 + .../optimized/20/solid/arrow-trending-up.svg | 3 + .../20/solid/arrow-turn-down-left.svg | 3 + .../20/solid/arrow-turn-down-right.svg | 3 + .../20/solid/arrow-turn-left-down.svg | 3 + .../optimized/20/solid/arrow-turn-left-up.svg | 3 + .../20/solid/arrow-turn-right-down.svg | 3 + .../20/solid/arrow-turn-right-up.svg | 3 + .../optimized/20/solid/arrow-turn-up-left.svg | 3 + .../20/solid/arrow-turn-up-right.svg | 3 + .../optimized/20/solid/arrow-up-circle.svg | 3 + .../optimized/20/solid/arrow-up-left.svg | 3 + .../20/solid/arrow-up-on-square-stack.svg | 3 + .../optimized/20/solid/arrow-up-on-square.svg | 3 + .../optimized/20/solid/arrow-up-right.svg | 3 + .../optimized/20/solid/arrow-up-tray.svg | 4 + .../heroicons/optimized/20/solid/arrow-up.svg | 3 + .../optimized/20/solid/arrow-uturn-down.svg | 3 + .../optimized/20/solid/arrow-uturn-left.svg | 3 + .../optimized/20/solid/arrow-uturn-right.svg | 3 + .../optimized/20/solid/arrow-uturn-up.svg | 3 + .../optimized/20/solid/arrows-pointing-in.svg | 3 + .../20/solid/arrows-pointing-out.svg | 3 + .../optimized/20/solid/arrows-right-left.svg | 3 + .../optimized/20/solid/arrows-up-down.svg | 3 + .../optimized/20/solid/at-symbol.svg | 3 + .../optimized/20/solid/backspace.svg | 3 + .../heroicons/optimized/20/solid/backward.svg | 3 + .../optimized/20/solid/banknotes.svg | 3 + .../heroicons/optimized/20/solid/bars-2.svg | 3 + .../optimized/20/solid/bars-3-bottom-left.svg | 3 + .../20/solid/bars-3-bottom-right.svg | 3 + .../optimized/20/solid/bars-3-center-left.svg | 3 + .../heroicons/optimized/20/solid/bars-3.svg | 3 + .../heroicons/optimized/20/solid/bars-4.svg | 3 + .../optimized/20/solid/bars-arrow-down.svg | 3 + .../optimized/20/solid/bars-arrow-up.svg | 3 + .../optimized/20/solid/battery-0.svg | 3 + .../optimized/20/solid/battery-100.svg | 4 + .../optimized/20/solid/battery-50.svg | 4 + .../heroicons/optimized/20/solid/beaker.svg | 3 + .../optimized/20/solid/bell-alert.svg | 4 + .../optimized/20/solid/bell-slash.svg | 3 + .../optimized/20/solid/bell-snooze.svg | 3 + .../heroicons/optimized/20/solid/bell.svg | 3 + .../heroicons/optimized/20/solid/bold.svg | 3 + .../optimized/20/solid/bolt-slash.svg | 4 + .../heroicons/optimized/20/solid/bolt.svg | 3 + .../optimized/20/solid/book-open.svg | 3 + .../optimized/20/solid/bookmark-slash.svg | 3 + .../optimized/20/solid/bookmark-square.svg | 3 + .../heroicons/optimized/20/solid/bookmark.svg | 3 + .../optimized/20/solid/briefcase.svg | 4 + .../heroicons/optimized/20/solid/bug-ant.svg | 3 + .../optimized/20/solid/building-library.svg | 3 + .../optimized/20/solid/building-office-2.svg | 3 + .../optimized/20/solid/building-office.svg | 3 + .../20/solid/building-storefront.svg | 3 + .../heroicons/optimized/20/solid/cake.svg | 3 + .../optimized/20/solid/calculator.svg | 3 + .../20/solid/calendar-date-range.svg | 4 + .../optimized/20/solid/calendar-days.svg | 4 + .../heroicons/optimized/20/solid/calendar.svg | 3 + .../heroicons/optimized/20/solid/camera.svg | 3 + .../optimized/20/solid/chart-bar-square.svg | 3 + .../optimized/20/solid/chart-bar.svg | 3 + .../optimized/20/solid/chart-pie.svg | 4 + .../solid/chat-bubble-bottom-center-text.svg | 3 + .../20/solid/chat-bubble-bottom-center.svg | 3 + .../20/solid/chat-bubble-left-ellipsis.svg | 3 + .../20/solid/chat-bubble-left-right.svg | 4 + .../optimized/20/solid/chat-bubble-left.svg | 3 + .../solid/chat-bubble-oval-left-ellipsis.svg | 3 + .../20/solid/chat-bubble-oval-left.svg | 3 + .../optimized/20/solid/check-badge.svg | 3 + .../optimized/20/solid/check-circle.svg | 3 + .../heroicons/optimized/20/solid/check.svg | 3 + .../20/solid/chevron-double-down.svg | 3 + .../20/solid/chevron-double-left.svg | 3 + .../20/solid/chevron-double-right.svg | 3 + .../optimized/20/solid/chevron-double-up.svg | 3 + .../optimized/20/solid/chevron-down.svg | 3 + .../optimized/20/solid/chevron-left.svg | 3 + .../optimized/20/solid/chevron-right.svg | 3 + .../optimized/20/solid/chevron-up-down.svg | 3 + .../optimized/20/solid/chevron-up.svg | 3 + .../optimized/20/solid/circle-stack.svg | 3 + .../20/solid/clipboard-document-check.svg | 4 + .../20/solid/clipboard-document-list.svg | 4 + .../optimized/20/solid/clipboard-document.svg | 4 + .../optimized/20/solid/clipboard.svg | 3 + .../heroicons/optimized/20/solid/clock.svg | 3 + .../optimized/20/solid/cloud-arrow-down.svg | 3 + .../optimized/20/solid/cloud-arrow-up.svg | 3 + .../heroicons/optimized/20/solid/cloud.svg | 3 + .../20/solid/code-bracket-square.svg | 3 + .../optimized/20/solid/code-bracket.svg | 3 + .../optimized/20/solid/cog-6-tooth.svg | 3 + .../optimized/20/solid/cog-8-tooth.svg | 3 + .../heroicons/optimized/20/solid/cog.svg | 4 + .../optimized/20/solid/command-line.svg | 3 + .../optimized/20/solid/computer-desktop.svg | 3 + .../heroicons/optimized/20/solid/cpu-chip.svg | 4 + .../optimized/20/solid/credit-card.svg | 3 + .../optimized/20/solid/cube-transparent.svg | 3 + .../heroicons/optimized/20/solid/cube.svg | 3 + .../20/solid/currency-bangladeshi.svg | 3 + .../optimized/20/solid/currency-dollar.svg | 4 + .../optimized/20/solid/currency-euro.svg | 3 + .../optimized/20/solid/currency-pound.svg | 3 + .../optimized/20/solid/currency-rupee.svg | 3 + .../optimized/20/solid/currency-yen.svg | 3 + .../optimized/20/solid/cursor-arrow-rays.svg | 3 + .../20/solid/cursor-arrow-ripple.svg | 5 + .../20/solid/device-phone-mobile.svg | 4 + .../optimized/20/solid/device-tablet.svg | 3 + .../heroicons/optimized/20/solid/divide.svg | 3 + .../20/solid/document-arrow-down.svg | 3 + .../optimized/20/solid/document-arrow-up.svg | 3 + .../optimized/20/solid/document-chart-bar.svg | 3 + .../optimized/20/solid/document-check.svg | 3 + .../solid/document-currency-bangladeshi.svg | 3 + .../20/solid/document-currency-dollar.svg | 3 + .../20/solid/document-currency-euro.svg | 3 + .../20/solid/document-currency-pound.svg | 3 + .../20/solid/document-currency-rupee.svg | 3 + .../20/solid/document-currency-yen.svg | 3 + .../optimized/20/solid/document-duplicate.svg | 4 + .../20/solid/document-magnifying-glass.svg | 4 + .../optimized/20/solid/document-minus.svg | 3 + .../optimized/20/solid/document-plus.svg | 3 + .../optimized/20/solid/document-text.svg | 3 + .../heroicons/optimized/20/solid/document.svg | 3 + .../20/solid/ellipsis-horizontal-circle.svg | 3 + .../20/solid/ellipsis-horizontal.svg | 3 + .../optimized/20/solid/ellipsis-vertical.svg | 3 + .../optimized/20/solid/envelope-open.svg | 3 + .../heroicons/optimized/20/solid/envelope.svg | 4 + .../heroicons/optimized/20/solid/equals.svg | 3 + .../optimized/20/solid/exclamation-circle.svg | 3 + .../20/solid/exclamation-triangle.svg | 3 + .../optimized/20/solid/eye-dropper.svg | 3 + .../optimized/20/solid/eye-slash.svg | 4 + .../heroicons/optimized/20/solid/eye.svg | 4 + .../optimized/20/solid/face-frown.svg | 3 + .../optimized/20/solid/face-smile.svg | 3 + .../heroicons/optimized/20/solid/film.svg | 3 + .../optimized/20/solid/finger-print.svg | 3 + .../heroicons/optimized/20/solid/fire.svg | 3 + .../heroicons/optimized/20/solid/flag.svg | 3 + .../optimized/20/solid/folder-arrow-down.svg | 3 + .../optimized/20/solid/folder-minus.svg | 3 + .../optimized/20/solid/folder-open.svg | 3 + .../optimized/20/solid/folder-plus.svg | 3 + .../heroicons/optimized/20/solid/folder.svg | 3 + .../heroicons/optimized/20/solid/forward.svg | 3 + .../heroicons/optimized/20/solid/funnel.svg | 3 + .../heroicons/optimized/20/solid/gif.svg | 3 + .../heroicons/optimized/20/solid/gift-top.svg | 3 + .../heroicons/optimized/20/solid/gift.svg | 4 + .../optimized/20/solid/globe-alt.svg | 3 + .../optimized/20/solid/globe-americas.svg | 3 + .../20/solid/globe-asia-australia.svg | 3 + .../20/solid/globe-europe-africa.svg | 3 + .../heroicons/optimized/20/solid/h1.svg | 3 + .../heroicons/optimized/20/solid/h2.svg | 3 + .../heroicons/optimized/20/solid/h3.svg | 3 + .../optimized/20/solid/hand-raised.svg | 3 + .../optimized/20/solid/hand-thumb-down.svg | 3 + .../optimized/20/solid/hand-thumb-up.svg | 3 + .../heroicons/optimized/20/solid/hashtag.svg | 3 + .../heroicons/optimized/20/solid/heart.svg | 3 + .../optimized/20/solid/home-modern.svg | 3 + .../heroicons/optimized/20/solid/home.svg | 3 + .../optimized/20/solid/identification.svg | 3 + .../optimized/20/solid/inbox-arrow-down.svg | 4 + .../optimized/20/solid/inbox-stack.svg | 4 + .../heroicons/optimized/20/solid/inbox.svg | 3 + .../optimized/20/solid/information-circle.svg | 3 + .../heroicons/optimized/20/solid/italic.svg | 3 + .../heroicons/optimized/20/solid/key.svg | 3 + .../heroicons/optimized/20/solid/language.svg | 4 + .../heroicons/optimized/20/solid/lifebuoy.svg | 3 + .../optimized/20/solid/light-bulb.svg | 3 + .../optimized/20/solid/link-slash.svg | 3 + .../heroicons/optimized/20/solid/link.svg | 4 + .../optimized/20/solid/list-bullet.svg | 3 + .../optimized/20/solid/lock-closed.svg | 3 + .../optimized/20/solid/lock-open.svg | 3 + .../20/solid/magnifying-glass-circle.svg | 4 + .../20/solid/magnifying-glass-minus.svg | 4 + .../20/solid/magnifying-glass-plus.svg | 4 + .../optimized/20/solid/magnifying-glass.svg | 3 + .../heroicons/optimized/20/solid/map-pin.svg | 3 + .../heroicons/optimized/20/solid/map.svg | 3 + .../optimized/20/solid/megaphone.svg | 3 + .../optimized/20/solid/microphone.svg | 4 + .../optimized/20/solid/minus-circle.svg | 3 + .../optimized/20/solid/minus-small.svg | 3 + .../heroicons/optimized/20/solid/minus.svg | 3 + .../heroicons/optimized/20/solid/moon.svg | 3 + .../optimized/20/solid/musical-note.svg | 3 + .../optimized/20/solid/newspaper.svg | 4 + .../optimized/20/solid/no-symbol.svg | 3 + .../optimized/20/solid/numbered-list.svg | 3 + .../optimized/20/solid/paint-brush.svg | 3 + .../optimized/20/solid/paper-airplane.svg | 3 + .../optimized/20/solid/paper-clip.svg | 3 + .../optimized/20/solid/pause-circle.svg | 3 + .../heroicons/optimized/20/solid/pause.svg | 3 + .../optimized/20/solid/pencil-square.svg | 4 + .../heroicons/optimized/20/solid/pencil.svg | 3 + .../optimized/20/solid/percent-badge.svg | 3 + .../20/solid/phone-arrow-down-left.svg | 3 + .../20/solid/phone-arrow-up-right.svg | 3 + .../optimized/20/solid/phone-x-mark.svg | 3 + .../heroicons/optimized/20/solid/phone.svg | 3 + .../heroicons/optimized/20/solid/photo.svg | 3 + .../optimized/20/solid/play-circle.svg | 3 + .../optimized/20/solid/play-pause.svg | 3 + .../heroicons/optimized/20/solid/play.svg | 3 + .../optimized/20/solid/plus-circle.svg | 3 + .../optimized/20/solid/plus-small.svg | 3 + .../heroicons/optimized/20/solid/plus.svg | 3 + .../heroicons/optimized/20/solid/power.svg | 3 + .../20/solid/presentation-chart-bar.svg | 3 + .../20/solid/presentation-chart-line.svg | 3 + .../heroicons/optimized/20/solid/printer.svg | 3 + .../optimized/20/solid/puzzle-piece.svg | 3 + .../heroicons/optimized/20/solid/qr-code.svg | 3 + .../20/solid/question-mark-circle.svg | 3 + .../optimized/20/solid/queue-list.svg | 3 + .../heroicons/optimized/20/solid/radio.svg | 3 + .../optimized/20/solid/receipt-percent.svg | 3 + .../optimized/20/solid/receipt-refund.svg | 3 + .../optimized/20/solid/rectangle-group.svg | 3 + .../optimized/20/solid/rectangle-stack.svg | 3 + .../optimized/20/solid/rocket-launch.svg | 4 + .../heroicons/optimized/20/solid/rss.svg | 4 + .../heroicons/optimized/20/solid/scale.svg | 3 + .../heroicons/optimized/20/solid/scissors.svg | 4 + .../optimized/20/solid/server-stack.svg | 4 + .../heroicons/optimized/20/solid/server.svg | 4 + .../heroicons/optimized/20/solid/share.svg | 3 + .../optimized/20/solid/shield-check.svg | 3 + .../optimized/20/solid/shield-exclamation.svg | 3 + .../optimized/20/solid/shopping-bag.svg | 3 + .../optimized/20/solid/shopping-cart.svg | 3 + .../optimized/20/solid/signal-slash.svg | 3 + .../heroicons/optimized/20/solid/signal.svg | 4 + .../heroicons/optimized/20/solid/slash.svg | 3 + .../heroicons/optimized/20/solid/sparkles.svg | 3 + .../optimized/20/solid/speaker-wave.svg | 4 + .../optimized/20/solid/speaker-x-mark.svg | 3 + .../optimized/20/solid/square-2-stack.svg | 4 + .../optimized/20/solid/square-3-stack-3d.svg | 5 + .../optimized/20/solid/squares-2x2.svg | 3 + .../optimized/20/solid/squares-plus.svg | 3 + .../heroicons/optimized/20/solid/star.svg | 3 + .../optimized/20/solid/stop-circle.svg | 3 + .../heroicons/optimized/20/solid/stop.svg | 3 + .../optimized/20/solid/strikethrough.svg | 3 + .../heroicons/optimized/20/solid/sun.svg | 3 + .../heroicons/optimized/20/solid/swatch.svg | 3 + .../optimized/20/solid/table-cells.svg | 3 + .../heroicons/optimized/20/solid/tag.svg | 3 + .../heroicons/optimized/20/solid/ticket.svg | 3 + .../heroicons/optimized/20/solid/trash.svg | 3 + .../heroicons/optimized/20/solid/trophy.svg | 3 + .../heroicons/optimized/20/solid/truck.svg | 4 + .../heroicons/optimized/20/solid/tv.svg | 4 + .../optimized/20/solid/underline.svg | 3 + .../optimized/20/solid/user-circle.svg | 3 + .../optimized/20/solid/user-group.svg | 3 + .../optimized/20/solid/user-minus.svg | 3 + .../optimized/20/solid/user-plus.svg | 3 + .../heroicons/optimized/20/solid/user.svg | 3 + .../heroicons/optimized/20/solid/users.svg | 3 + .../heroicons/optimized/20/solid/variable.svg | 3 + .../optimized/20/solid/video-camera-slash.svg | 3 + .../optimized/20/solid/video-camera.svg | 3 + .../optimized/20/solid/view-columns.svg | 3 + .../optimized/20/solid/viewfinder-circle.svg | 3 + .../heroicons/optimized/20/solid/wallet.svg | 3 + .../heroicons/optimized/20/solid/wifi.svg | 3 + .../heroicons/optimized/20/solid/window.svg | 3 + .../optimized/20/solid/wrench-screwdriver.svg | 4 + .../heroicons/optimized/20/solid/wrench.svg | 3 + .../heroicons/optimized/20/solid/x-circle.svg | 3 + .../heroicons/optimized/20/solid/x-mark.svg | 3 + .../optimized/24/outline/academic-cap.svg | 3 + .../24/outline/adjustments-horizontal.svg | 3 + .../24/outline/adjustments-vertical.svg | 3 + .../24/outline/archive-box-arrow-down.svg | 3 + .../24/outline/archive-box-x-mark.svg | 3 + .../optimized/24/outline/archive-box.svg | 3 + .../24/outline/arrow-down-circle.svg | 3 + .../optimized/24/outline/arrow-down-left.svg | 3 + .../24/outline/arrow-down-on-square-stack.svg | 3 + .../24/outline/arrow-down-on-square.svg | 3 + .../optimized/24/outline/arrow-down-right.svg | 3 + .../optimized/24/outline/arrow-down-tray.svg | 3 + .../optimized/24/outline/arrow-down.svg | 3 + .../24/outline/arrow-left-circle.svg | 3 + .../outline/arrow-left-end-on-rectangle.svg | 3 + .../24/outline/arrow-left-on-rectangle.svg | 3 + .../outline/arrow-left-start-on-rectangle.svg | 3 + .../optimized/24/outline/arrow-left.svg | 3 + .../optimized/24/outline/arrow-long-down.svg | 3 + .../optimized/24/outline/arrow-long-left.svg | 3 + .../optimized/24/outline/arrow-long-right.svg | 3 + .../optimized/24/outline/arrow-long-up.svg | 3 + .../24/outline/arrow-path-rounded-square.svg | 3 + .../optimized/24/outline/arrow-path.svg | 3 + .../24/outline/arrow-right-circle.svg | 3 + .../outline/arrow-right-end-on-rectangle.svg | 3 + .../24/outline/arrow-right-on-rectangle.svg | 3 + .../arrow-right-start-on-rectangle.svg | 3 + .../optimized/24/outline/arrow-right.svg | 3 + .../optimized/24/outline/arrow-small-down.svg | 3 + .../optimized/24/outline/arrow-small-left.svg | 3 + .../24/outline/arrow-small-right.svg | 3 + .../optimized/24/outline/arrow-small-up.svg | 3 + .../24/outline/arrow-top-right-on-square.svg | 3 + .../24/outline/arrow-trending-down.svg | 3 + .../24/outline/arrow-trending-up.svg | 3 + .../24/outline/arrow-turn-down-left.svg | 3 + .../24/outline/arrow-turn-down-right.svg | 3 + .../24/outline/arrow-turn-left-down.svg | 3 + .../24/outline/arrow-turn-left-up.svg | 3 + .../24/outline/arrow-turn-right-down.svg | 3 + .../24/outline/arrow-turn-right-up.svg | 3 + .../24/outline/arrow-turn-up-left.svg | 3 + .../24/outline/arrow-turn-up-right.svg | 3 + .../optimized/24/outline/arrow-up-circle.svg | 3 + .../optimized/24/outline/arrow-up-left.svg | 3 + .../24/outline/arrow-up-on-square-stack.svg | 3 + .../24/outline/arrow-up-on-square.svg | 3 + .../optimized/24/outline/arrow-up-right.svg | 3 + .../optimized/24/outline/arrow-up-tray.svg | 3 + .../optimized/24/outline/arrow-up.svg | 3 + .../optimized/24/outline/arrow-uturn-down.svg | 3 + .../optimized/24/outline/arrow-uturn-left.svg | 3 + .../24/outline/arrow-uturn-right.svg | 3 + .../optimized/24/outline/arrow-uturn-up.svg | 3 + .../24/outline/arrows-pointing-in.svg | 3 + .../24/outline/arrows-pointing-out.svg | 3 + .../24/outline/arrows-right-left.svg | 3 + .../optimized/24/outline/arrows-up-down.svg | 3 + .../optimized/24/outline/at-symbol.svg | 3 + .../optimized/24/outline/backspace.svg | 3 + .../optimized/24/outline/backward.svg | 3 + .../optimized/24/outline/banknotes.svg | 3 + .../heroicons/optimized/24/outline/bars-2.svg | 3 + .../24/outline/bars-3-bottom-left.svg | 3 + .../24/outline/bars-3-bottom-right.svg | 3 + .../24/outline/bars-3-center-left.svg | 3 + .../heroicons/optimized/24/outline/bars-3.svg | 3 + .../heroicons/optimized/24/outline/bars-4.svg | 3 + .../optimized/24/outline/bars-arrow-down.svg | 3 + .../optimized/24/outline/bars-arrow-up.svg | 3 + .../optimized/24/outline/battery-0.svg | 3 + .../optimized/24/outline/battery-100.svg | 3 + .../optimized/24/outline/battery-50.svg | 3 + .../heroicons/optimized/24/outline/beaker.svg | 3 + .../optimized/24/outline/bell-alert.svg | 3 + .../optimized/24/outline/bell-slash.svg | 3 + .../optimized/24/outline/bell-snooze.svg | 3 + .../heroicons/optimized/24/outline/bell.svg | 3 + .../heroicons/optimized/24/outline/bold.svg | 3 + .../optimized/24/outline/bolt-slash.svg | 3 + .../heroicons/optimized/24/outline/bolt.svg | 3 + .../optimized/24/outline/book-open.svg | 3 + .../optimized/24/outline/bookmark-slash.svg | 3 + .../optimized/24/outline/bookmark-square.svg | 3 + .../optimized/24/outline/bookmark.svg | 3 + .../optimized/24/outline/briefcase.svg | 3 + .../optimized/24/outline/bug-ant.svg | 3 + .../optimized/24/outline/building-library.svg | 3 + .../24/outline/building-office-2.svg | 3 + .../optimized/24/outline/building-office.svg | 3 + .../24/outline/building-storefront.svg | 3 + .../heroicons/optimized/24/outline/cake.svg | 3 + .../optimized/24/outline/calculator.svg | 3 + .../24/outline/calendar-date-range.svg | 3 + .../optimized/24/outline/calendar-days.svg | 3 + .../optimized/24/outline/calendar.svg | 3 + .../heroicons/optimized/24/outline/camera.svg | 4 + .../optimized/24/outline/chart-bar-square.svg | 3 + .../optimized/24/outline/chart-bar.svg | 3 + .../optimized/24/outline/chart-pie.svg | 4 + .../chat-bubble-bottom-center-text.svg | 3 + .../24/outline/chat-bubble-bottom-center.svg | 3 + .../24/outline/chat-bubble-left-ellipsis.svg | 3 + .../24/outline/chat-bubble-left-right.svg | 3 + .../optimized/24/outline/chat-bubble-left.svg | 3 + .../chat-bubble-oval-left-ellipsis.svg | 3 + .../24/outline/chat-bubble-oval-left.svg | 3 + .../optimized/24/outline/check-badge.svg | 3 + .../optimized/24/outline/check-circle.svg | 3 + .../heroicons/optimized/24/outline/check.svg | 3 + .../24/outline/chevron-double-down.svg | 3 + .../24/outline/chevron-double-left.svg | 3 + .../24/outline/chevron-double-right.svg | 3 + .../24/outline/chevron-double-up.svg | 4 + .../optimized/24/outline/chevron-down.svg | 3 + .../optimized/24/outline/chevron-left.svg | 3 + .../optimized/24/outline/chevron-right.svg | 3 + .../optimized/24/outline/chevron-up-down.svg | 3 + .../optimized/24/outline/chevron-up.svg | 3 + .../optimized/24/outline/circle-stack.svg | 3 + .../24/outline/clipboard-document-check.svg | 3 + .../24/outline/clipboard-document-list.svg | 3 + .../24/outline/clipboard-document.svg | 3 + .../optimized/24/outline/clipboard.svg | 3 + .../heroicons/optimized/24/outline/clock.svg | 3 + .../optimized/24/outline/cloud-arrow-down.svg | 3 + .../optimized/24/outline/cloud-arrow-up.svg | 3 + .../heroicons/optimized/24/outline/cloud.svg | 3 + .../24/outline/code-bracket-square.svg | 3 + .../optimized/24/outline/code-bracket.svg | 3 + .../optimized/24/outline/cog-6-tooth.svg | 4 + .../optimized/24/outline/cog-8-tooth.svg | 4 + .../heroicons/optimized/24/outline/cog.svg | 3 + .../optimized/24/outline/command-line.svg | 3 + .../optimized/24/outline/computer-desktop.svg | 3 + .../optimized/24/outline/cpu-chip.svg | 3 + .../optimized/24/outline/credit-card.svg | 3 + .../optimized/24/outline/cube-transparent.svg | 3 + .../heroicons/optimized/24/outline/cube.svg | 3 + .../24/outline/currency-bangladeshi.svg | 3 + .../optimized/24/outline/currency-dollar.svg | 3 + .../optimized/24/outline/currency-euro.svg | 3 + .../optimized/24/outline/currency-pound.svg | 3 + .../optimized/24/outline/currency-rupee.svg | 3 + .../optimized/24/outline/currency-yen.svg | 3 + .../24/outline/cursor-arrow-rays.svg | 3 + .../24/outline/cursor-arrow-ripple.svg | 3 + .../24/outline/device-phone-mobile.svg | 3 + .../optimized/24/outline/device-tablet.svg | 3 + .../heroicons/optimized/24/outline/divide.svg | 3 + .../24/outline/document-arrow-down.svg | 3 + .../24/outline/document-arrow-up.svg | 3 + .../24/outline/document-chart-bar.svg | 3 + .../optimized/24/outline/document-check.svg | 3 + .../outline/document-currency-bangladeshi.svg | 3 + .../24/outline/document-currency-dollar.svg | 3 + .../24/outline/document-currency-euro.svg | 3 + .../24/outline/document-currency-pound.svg | 3 + .../24/outline/document-currency-rupee.svg | 3 + .../24/outline/document-currency-yen.svg | 3 + .../24/outline/document-duplicate.svg | 3 + .../24/outline/document-magnifying-glass.svg | 3 + .../optimized/24/outline/document-minus.svg | 3 + .../optimized/24/outline/document-plus.svg | 3 + .../optimized/24/outline/document-text.svg | 3 + .../optimized/24/outline/document.svg | 3 + .../24/outline/ellipsis-horizontal-circle.svg | 3 + .../24/outline/ellipsis-horizontal.svg | 3 + .../24/outline/ellipsis-vertical.svg | 3 + .../optimized/24/outline/envelope-open.svg | 3 + .../optimized/24/outline/envelope.svg | 3 + .../heroicons/optimized/24/outline/equals.svg | 3 + .../24/outline/exclamation-circle.svg | 3 + .../24/outline/exclamation-triangle.svg | 3 + .../optimized/24/outline/eye-dropper.svg | 3 + .../optimized/24/outline/eye-slash.svg | 3 + .../heroicons/optimized/24/outline/eye.svg | 4 + .../optimized/24/outline/face-frown.svg | 3 + .../optimized/24/outline/face-smile.svg | 3 + .../heroicons/optimized/24/outline/film.svg | 3 + .../optimized/24/outline/finger-print.svg | 3 + .../heroicons/optimized/24/outline/fire.svg | 4 + .../heroicons/optimized/24/outline/flag.svg | 3 + .../24/outline/folder-arrow-down.svg | 3 + .../optimized/24/outline/folder-minus.svg | 3 + .../optimized/24/outline/folder-open.svg | 3 + .../optimized/24/outline/folder-plus.svg | 3 + .../heroicons/optimized/24/outline/folder.svg | 3 + .../optimized/24/outline/forward.svg | 3 + .../heroicons/optimized/24/outline/funnel.svg | 3 + .../heroicons/optimized/24/outline/gif.svg | 3 + .../optimized/24/outline/gift-top.svg | 3 + .../heroicons/optimized/24/outline/gift.svg | 3 + .../optimized/24/outline/globe-alt.svg | 3 + .../optimized/24/outline/globe-americas.svg | 3 + .../24/outline/globe-asia-australia.svg | 3 + .../24/outline/globe-europe-africa.svg | 3 + .../heroicons/optimized/24/outline/h1.svg | 3 + .../heroicons/optimized/24/outline/h2.svg | 3 + .../heroicons/optimized/24/outline/h3.svg | 3 + .../optimized/24/outline/hand-raised.svg | 3 + .../optimized/24/outline/hand-thumb-down.svg | 3 + .../optimized/24/outline/hand-thumb-up.svg | 3 + .../optimized/24/outline/hashtag.svg | 3 + .../heroicons/optimized/24/outline/heart.svg | 3 + .../optimized/24/outline/home-modern.svg | 3 + .../heroicons/optimized/24/outline/home.svg | 3 + .../optimized/24/outline/identification.svg | 3 + .../optimized/24/outline/inbox-arrow-down.svg | 3 + .../optimized/24/outline/inbox-stack.svg | 3 + .../heroicons/optimized/24/outline/inbox.svg | 3 + .../24/outline/information-circle.svg | 3 + .../heroicons/optimized/24/outline/italic.svg | 3 + .../heroicons/optimized/24/outline/key.svg | 3 + .../optimized/24/outline/language.svg | 3 + .../optimized/24/outline/lifebuoy.svg | 3 + .../optimized/24/outline/light-bulb.svg | 3 + .../optimized/24/outline/link-slash.svg | 3 + .../heroicons/optimized/24/outline/link.svg | 3 + .../optimized/24/outline/list-bullet.svg | 3 + .../optimized/24/outline/lock-closed.svg | 3 + .../optimized/24/outline/lock-open.svg | 3 + .../24/outline/magnifying-glass-circle.svg | 3 + .../24/outline/magnifying-glass-minus.svg | 3 + .../24/outline/magnifying-glass-plus.svg | 3 + .../optimized/24/outline/magnifying-glass.svg | 3 + .../optimized/24/outline/map-pin.svg | 4 + .../heroicons/optimized/24/outline/map.svg | 3 + .../optimized/24/outline/megaphone.svg | 3 + .../optimized/24/outline/microphone.svg | 3 + .../optimized/24/outline/minus-circle.svg | 3 + .../optimized/24/outline/minus-small.svg | 3 + .../heroicons/optimized/24/outline/minus.svg | 3 + .../heroicons/optimized/24/outline/moon.svg | 3 + .../optimized/24/outline/musical-note.svg | 3 + .../optimized/24/outline/newspaper.svg | 3 + .../optimized/24/outline/no-symbol.svg | 3 + .../optimized/24/outline/numbered-list.svg | 3 + .../optimized/24/outline/paint-brush.svg | 3 + .../optimized/24/outline/paper-airplane.svg | 3 + .../optimized/24/outline/paper-clip.svg | 3 + .../optimized/24/outline/pause-circle.svg | 3 + .../heroicons/optimized/24/outline/pause.svg | 3 + .../optimized/24/outline/pencil-square.svg | 3 + .../heroicons/optimized/24/outline/pencil.svg | 3 + .../optimized/24/outline/percent-badge.svg | 3 + .../24/outline/phone-arrow-down-left.svg | 3 + .../24/outline/phone-arrow-up-right.svg | 3 + .../optimized/24/outline/phone-x-mark.svg | 3 + .../heroicons/optimized/24/outline/phone.svg | 3 + .../heroicons/optimized/24/outline/photo.svg | 3 + .../optimized/24/outline/play-circle.svg | 4 + .../optimized/24/outline/play-pause.svg | 3 + .../heroicons/optimized/24/outline/play.svg | 3 + .../optimized/24/outline/plus-circle.svg | 3 + .../optimized/24/outline/plus-small.svg | 3 + .../heroicons/optimized/24/outline/plus.svg | 3 + .../heroicons/optimized/24/outline/power.svg | 3 + .../24/outline/presentation-chart-bar.svg | 3 + .../24/outline/presentation-chart-line.svg | 3 + .../optimized/24/outline/printer.svg | 3 + .../optimized/24/outline/puzzle-piece.svg | 3 + .../optimized/24/outline/qr-code.svg | 4 + .../24/outline/question-mark-circle.svg | 3 + .../optimized/24/outline/queue-list.svg | 3 + .../heroicons/optimized/24/outline/radio.svg | 3 + .../optimized/24/outline/receipt-percent.svg | 3 + .../optimized/24/outline/receipt-refund.svg | 3 + .../optimized/24/outline/rectangle-group.svg | 3 + .../optimized/24/outline/rectangle-stack.svg | 3 + .../optimized/24/outline/rocket-launch.svg | 3 + .../heroicons/optimized/24/outline/rss.svg | 3 + .../heroicons/optimized/24/outline/scale.svg | 3 + .../optimized/24/outline/scissors.svg | 3 + .../optimized/24/outline/server-stack.svg | 3 + .../heroicons/optimized/24/outline/server.svg | 3 + .../heroicons/optimized/24/outline/share.svg | 3 + .../optimized/24/outline/shield-check.svg | 3 + .../24/outline/shield-exclamation.svg | 3 + .../optimized/24/outline/shopping-bag.svg | 3 + .../optimized/24/outline/shopping-cart.svg | 3 + .../optimized/24/outline/signal-slash.svg | 3 + .../heroicons/optimized/24/outline/signal.svg | 3 + .../heroicons/optimized/24/outline/slash.svg | 3 + .../optimized/24/outline/sparkles.svg | 3 + .../optimized/24/outline/speaker-wave.svg | 3 + .../optimized/24/outline/speaker-x-mark.svg | 3 + .../optimized/24/outline/square-2-stack.svg | 3 + .../24/outline/square-3-stack-3d.svg | 3 + .../optimized/24/outline/squares-2x2.svg | 3 + .../optimized/24/outline/squares-plus.svg | 3 + .../heroicons/optimized/24/outline/star.svg | 3 + .../optimized/24/outline/stop-circle.svg | 4 + .../heroicons/optimized/24/outline/stop.svg | 3 + .../optimized/24/outline/strikethrough.svg | 3 + .../heroicons/optimized/24/outline/sun.svg | 3 + .../heroicons/optimized/24/outline/swatch.svg | 3 + .../optimized/24/outline/table-cells.svg | 3 + .../heroicons/optimized/24/outline/tag.svg | 4 + .../heroicons/optimized/24/outline/ticket.svg | 3 + .../heroicons/optimized/24/outline/trash.svg | 3 + .../heroicons/optimized/24/outline/trophy.svg | 3 + .../heroicons/optimized/24/outline/truck.svg | 3 + .../heroicons/optimized/24/outline/tv.svg | 3 + .../optimized/24/outline/underline.svg | 3 + .../optimized/24/outline/user-circle.svg | 3 + .../optimized/24/outline/user-group.svg | 3 + .../optimized/24/outline/user-minus.svg | 3 + .../optimized/24/outline/user-plus.svg | 3 + .../heroicons/optimized/24/outline/user.svg | 3 + .../heroicons/optimized/24/outline/users.svg | 3 + .../optimized/24/outline/variable.svg | 3 + .../24/outline/video-camera-slash.svg | 3 + .../optimized/24/outline/video-camera.svg | 3 + .../optimized/24/outline/view-columns.svg | 3 + .../24/outline/viewfinder-circle.svg | 3 + .../heroicons/optimized/24/outline/wallet.svg | 3 + .../heroicons/optimized/24/outline/wifi.svg | 3 + .../heroicons/optimized/24/outline/window.svg | 3 + .../24/outline/wrench-screwdriver.svg | 3 + .../heroicons/optimized/24/outline/wrench.svg | 4 + .../optimized/24/outline/x-circle.svg | 3 + .../heroicons/optimized/24/outline/x-mark.svg | 3 + .../optimized/24/solid/academic-cap.svg | 5 + .../24/solid/adjustments-horizontal.svg | 3 + .../24/solid/adjustments-vertical.svg | 3 + .../24/solid/archive-box-arrow-down.svg | 4 + .../optimized/24/solid/archive-box-x-mark.svg | 4 + .../optimized/24/solid/archive-box.svg | 4 + .../optimized/24/solid/arrow-down-circle.svg | 3 + .../optimized/24/solid/arrow-down-left.svg | 3 + .../24/solid/arrow-down-on-square-stack.svg | 4 + .../24/solid/arrow-down-on-square.svg | 3 + .../optimized/24/solid/arrow-down-right.svg | 3 + .../optimized/24/solid/arrow-down-tray.svg | 3 + .../optimized/24/solid/arrow-down.svg | 3 + .../optimized/24/solid/arrow-left-circle.svg | 3 + .../24/solid/arrow-left-end-on-rectangle.svg | 3 + .../24/solid/arrow-left-on-rectangle.svg | 3 + .../solid/arrow-left-start-on-rectangle.svg | 3 + .../optimized/24/solid/arrow-left.svg | 3 + .../optimized/24/solid/arrow-long-down.svg | 3 + .../optimized/24/solid/arrow-long-left.svg | 3 + .../optimized/24/solid/arrow-long-right.svg | 3 + .../optimized/24/solid/arrow-long-up.svg | 3 + .../24/solid/arrow-path-rounded-square.svg | 3 + .../optimized/24/solid/arrow-path.svg | 3 + .../optimized/24/solid/arrow-right-circle.svg | 3 + .../24/solid/arrow-right-end-on-rectangle.svg | 3 + .../24/solid/arrow-right-on-rectangle.svg | 3 + .../solid/arrow-right-start-on-rectangle.svg | 3 + .../optimized/24/solid/arrow-right.svg | 3 + .../optimized/24/solid/arrow-small-down.svg | 3 + .../optimized/24/solid/arrow-small-left.svg | 3 + .../optimized/24/solid/arrow-small-right.svg | 3 + .../optimized/24/solid/arrow-small-up.svg | 3 + .../24/solid/arrow-top-right-on-square.svg | 3 + .../24/solid/arrow-trending-down.svg | 3 + .../optimized/24/solid/arrow-trending-up.svg | 3 + .../24/solid/arrow-turn-down-left.svg | 3 + .../24/solid/arrow-turn-down-right.svg | 3 + .../24/solid/arrow-turn-left-down.svg | 3 + .../optimized/24/solid/arrow-turn-left-up.svg | 3 + .../24/solid/arrow-turn-right-down.svg | 3 + .../24/solid/arrow-turn-right-up.svg | 3 + .../optimized/24/solid/arrow-turn-up-left.svg | 3 + .../24/solid/arrow-turn-up-right.svg | 3 + .../optimized/24/solid/arrow-up-circle.svg | 3 + .../optimized/24/solid/arrow-up-left.svg | 3 + .../24/solid/arrow-up-on-square-stack.svg | 4 + .../optimized/24/solid/arrow-up-on-square.svg | 3 + .../optimized/24/solid/arrow-up-right.svg | 3 + .../optimized/24/solid/arrow-up-tray.svg | 3 + .../heroicons/optimized/24/solid/arrow-up.svg | 3 + .../optimized/24/solid/arrow-uturn-down.svg | 3 + .../optimized/24/solid/arrow-uturn-left.svg | 3 + .../optimized/24/solid/arrow-uturn-right.svg | 3 + .../optimized/24/solid/arrow-uturn-up.svg | 3 + .../optimized/24/solid/arrows-pointing-in.svg | 3 + .../24/solid/arrows-pointing-out.svg | 3 + .../optimized/24/solid/arrows-right-left.svg | 3 + .../optimized/24/solid/arrows-up-down.svg | 3 + .../optimized/24/solid/at-symbol.svg | 3 + .../optimized/24/solid/backspace.svg | 3 + .../heroicons/optimized/24/solid/backward.svg | 3 + .../optimized/24/solid/banknotes.svg | 5 + .../heroicons/optimized/24/solid/bars-2.svg | 3 + .../optimized/24/solid/bars-3-bottom-left.svg | 3 + .../24/solid/bars-3-bottom-right.svg | 3 + .../optimized/24/solid/bars-3-center-left.svg | 3 + .../heroicons/optimized/24/solid/bars-3.svg | 3 + .../heroicons/optimized/24/solid/bars-4.svg | 3 + .../optimized/24/solid/bars-arrow-down.svg | 3 + .../optimized/24/solid/bars-arrow-up.svg | 3 + .../optimized/24/solid/battery-0.svg | 3 + .../optimized/24/solid/battery-100.svg | 3 + .../optimized/24/solid/battery-50.svg | 4 + .../heroicons/optimized/24/solid/beaker.svg | 3 + .../optimized/24/solid/bell-alert.svg | 4 + .../optimized/24/solid/bell-slash.svg | 4 + .../optimized/24/solid/bell-snooze.svg | 3 + .../heroicons/optimized/24/solid/bell.svg | 3 + .../heroicons/optimized/24/solid/bold.svg | 3 + .../optimized/24/solid/bolt-slash.svg | 3 + .../heroicons/optimized/24/solid/bolt.svg | 3 + .../optimized/24/solid/book-open.svg | 3 + .../optimized/24/solid/bookmark-slash.svg | 3 + .../optimized/24/solid/bookmark-square.svg | 3 + .../heroicons/optimized/24/solid/bookmark.svg | 3 + .../optimized/24/solid/briefcase.svg | 4 + .../heroicons/optimized/24/solid/bug-ant.svg | 3 + .../optimized/24/solid/building-library.svg | 5 + .../optimized/24/solid/building-office-2.svg | 3 + .../optimized/24/solid/building-office.svg | 3 + .../24/solid/building-storefront.svg | 4 + .../heroicons/optimized/24/solid/cake.svg | 3 + .../optimized/24/solid/calculator.svg | 3 + .../24/solid/calendar-date-range.svg | 4 + .../optimized/24/solid/calendar-days.svg | 4 + .../heroicons/optimized/24/solid/calendar.svg | 3 + .../heroicons/optimized/24/solid/camera.svg | 4 + .../optimized/24/solid/chart-bar-square.svg | 3 + .../optimized/24/solid/chart-bar.svg | 3 + .../optimized/24/solid/chart-pie.svg | 4 + .../solid/chat-bubble-bottom-center-text.svg | 3 + .../24/solid/chat-bubble-bottom-center.svg | 3 + .../24/solid/chat-bubble-left-ellipsis.svg | 3 + .../24/solid/chat-bubble-left-right.svg | 4 + .../optimized/24/solid/chat-bubble-left.svg | 3 + .../solid/chat-bubble-oval-left-ellipsis.svg | 3 + .../24/solid/chat-bubble-oval-left.svg | 3 + .../optimized/24/solid/check-badge.svg | 3 + .../optimized/24/solid/check-circle.svg | 3 + .../heroicons/optimized/24/solid/check.svg | 3 + .../24/solid/chevron-double-down.svg | 4 + .../24/solid/chevron-double-left.svg | 4 + .../24/solid/chevron-double-right.svg | 4 + .../optimized/24/solid/chevron-double-up.svg | 4 + .../optimized/24/solid/chevron-down.svg | 3 + .../optimized/24/solid/chevron-left.svg | 3 + .../optimized/24/solid/chevron-right.svg | 3 + .../optimized/24/solid/chevron-up-down.svg | 3 + .../optimized/24/solid/chevron-up.svg | 3 + .../optimized/24/solid/circle-stack.svg | 6 + .../24/solid/clipboard-document-check.svg | 4 + .../24/solid/clipboard-document-list.svg | 4 + .../optimized/24/solid/clipboard-document.svg | 5 + .../optimized/24/solid/clipboard.svg | 3 + .../heroicons/optimized/24/solid/clock.svg | 3 + .../optimized/24/solid/cloud-arrow-down.svg | 3 + .../optimized/24/solid/cloud-arrow-up.svg | 3 + .../heroicons/optimized/24/solid/cloud.svg | 3 + .../24/solid/code-bracket-square.svg | 3 + .../optimized/24/solid/code-bracket.svg | 3 + .../optimized/24/solid/cog-6-tooth.svg | 3 + .../optimized/24/solid/cog-8-tooth.svg | 3 + .../heroicons/optimized/24/solid/cog.svg | 4 + .../optimized/24/solid/command-line.svg | 3 + .../optimized/24/solid/computer-desktop.svg | 3 + .../heroicons/optimized/24/solid/cpu-chip.svg | 4 + .../optimized/24/solid/credit-card.svg | 4 + .../optimized/24/solid/cube-transparent.svg | 3 + .../heroicons/optimized/24/solid/cube.svg | 3 + .../24/solid/currency-bangladeshi.svg | 3 + .../optimized/24/solid/currency-dollar.svg | 4 + .../optimized/24/solid/currency-euro.svg | 3 + .../optimized/24/solid/currency-pound.svg | 3 + .../optimized/24/solid/currency-rupee.svg | 3 + .../optimized/24/solid/currency-yen.svg | 3 + .../optimized/24/solid/cursor-arrow-rays.svg | 3 + .../24/solid/cursor-arrow-ripple.svg | 3 + .../24/solid/device-phone-mobile.svg | 4 + .../optimized/24/solid/device-tablet.svg | 4 + .../heroicons/optimized/24/solid/divide.svg | 3 + .../24/solid/document-arrow-down.svg | 4 + .../optimized/24/solid/document-arrow-up.svg | 4 + .../optimized/24/solid/document-chart-bar.svg | 4 + .../optimized/24/solid/document-check.svg | 4 + .../solid/document-currency-bangladeshi.svg | 3 + .../24/solid/document-currency-dollar.svg | 3 + .../24/solid/document-currency-euro.svg | 4 + .../24/solid/document-currency-pound.svg | 3 + .../24/solid/document-currency-rupee.svg | 3 + .../24/solid/document-currency-yen.svg | 3 + .../optimized/24/solid/document-duplicate.svg | 4 + .../24/solid/document-magnifying-glass.svg | 5 + .../optimized/24/solid/document-minus.svg | 4 + .../optimized/24/solid/document-plus.svg | 4 + .../optimized/24/solid/document-text.svg | 4 + .../heroicons/optimized/24/solid/document.svg | 4 + .../24/solid/ellipsis-horizontal-circle.svg | 3 + .../24/solid/ellipsis-horizontal.svg | 3 + .../optimized/24/solid/ellipsis-vertical.svg | 3 + .../optimized/24/solid/envelope-open.svg | 4 + .../heroicons/optimized/24/solid/envelope.svg | 4 + .../heroicons/optimized/24/solid/equals.svg | 3 + .../optimized/24/solid/exclamation-circle.svg | 3 + .../24/solid/exclamation-triangle.svg | 3 + .../optimized/24/solid/eye-dropper.svg | 3 + .../optimized/24/solid/eye-slash.svg | 5 + .../heroicons/optimized/24/solid/eye.svg | 4 + .../optimized/24/solid/face-frown.svg | 3 + .../optimized/24/solid/face-smile.svg | 3 + .../heroicons/optimized/24/solid/film.svg | 3 + .../optimized/24/solid/finger-print.svg | 3 + .../heroicons/optimized/24/solid/fire.svg | 3 + .../heroicons/optimized/24/solid/flag.svg | 3 + .../optimized/24/solid/folder-arrow-down.svg | 3 + .../optimized/24/solid/folder-minus.svg | 3 + .../optimized/24/solid/folder-open.svg | 3 + .../optimized/24/solid/folder-plus.svg | 3 + .../heroicons/optimized/24/solid/folder.svg | 3 + .../heroicons/optimized/24/solid/forward.svg | 3 + .../heroicons/optimized/24/solid/funnel.svg | 3 + .../heroicons/optimized/24/solid/gif.svg | 3 + .../heroicons/optimized/24/solid/gift-top.svg | 4 + .../heroicons/optimized/24/solid/gift.svg | 3 + .../optimized/24/solid/globe-alt.svg | 3 + .../optimized/24/solid/globe-americas.svg | 3 + .../24/solid/globe-asia-australia.svg | 4 + .../24/solid/globe-europe-africa.svg | 3 + .../heroicons/optimized/24/solid/h1.svg | 3 + .../heroicons/optimized/24/solid/h2.svg | 3 + .../heroicons/optimized/24/solid/h3.svg | 3 + .../optimized/24/solid/hand-raised.svg | 3 + .../optimized/24/solid/hand-thumb-down.svg | 3 + .../optimized/24/solid/hand-thumb-up.svg | 3 + .../heroicons/optimized/24/solid/hashtag.svg | 3 + .../heroicons/optimized/24/solid/heart.svg | 3 + .../optimized/24/solid/home-modern.svg | 4 + .../heroicons/optimized/24/solid/home.svg | 4 + .../optimized/24/solid/identification.svg | 3 + .../optimized/24/solid/inbox-arrow-down.svg | 4 + .../optimized/24/solid/inbox-stack.svg | 4 + .../heroicons/optimized/24/solid/inbox.svg | 3 + .../optimized/24/solid/information-circle.svg | 3 + .../heroicons/optimized/24/solid/italic.svg | 3 + .../heroicons/optimized/24/solid/key.svg | 3 + .../heroicons/optimized/24/solid/language.svg | 3 + .../heroicons/optimized/24/solid/lifebuoy.svg | 3 + .../optimized/24/solid/light-bulb.svg | 4 + .../optimized/24/solid/link-slash.svg | 3 + .../heroicons/optimized/24/solid/link.svg | 3 + .../optimized/24/solid/list-bullet.svg | 3 + .../optimized/24/solid/lock-closed.svg | 3 + .../optimized/24/solid/lock-open.svg | 3 + .../24/solid/magnifying-glass-circle.svg | 4 + .../24/solid/magnifying-glass-minus.svg | 3 + .../24/solid/magnifying-glass-plus.svg | 3 + .../optimized/24/solid/magnifying-glass.svg | 3 + .../heroicons/optimized/24/solid/map-pin.svg | 3 + .../heroicons/optimized/24/solid/map.svg | 3 + .../optimized/24/solid/megaphone.svg | 3 + .../optimized/24/solid/microphone.svg | 4 + .../optimized/24/solid/minus-circle.svg | 3 + .../optimized/24/solid/minus-small.svg | 3 + .../heroicons/optimized/24/solid/minus.svg | 3 + .../heroicons/optimized/24/solid/moon.svg | 3 + .../optimized/24/solid/musical-note.svg | 3 + .../optimized/24/solid/newspaper.svg | 4 + .../optimized/24/solid/no-symbol.svg | 3 + .../optimized/24/solid/numbered-list.svg | 3 + .../optimized/24/solid/paint-brush.svg | 3 + .../optimized/24/solid/paper-airplane.svg | 3 + .../optimized/24/solid/paper-clip.svg | 3 + .../optimized/24/solid/pause-circle.svg | 3 + .../heroicons/optimized/24/solid/pause.svg | 3 + .../optimized/24/solid/pencil-square.svg | 4 + .../heroicons/optimized/24/solid/pencil.svg | 3 + .../optimized/24/solid/percent-badge.svg | 3 + .../24/solid/phone-arrow-down-left.svg | 4 + .../24/solid/phone-arrow-up-right.svg | 4 + .../optimized/24/solid/phone-x-mark.svg | 3 + .../heroicons/optimized/24/solid/phone.svg | 3 + .../heroicons/optimized/24/solid/photo.svg | 3 + .../optimized/24/solid/play-circle.svg | 3 + .../optimized/24/solid/play-pause.svg | 3 + .../heroicons/optimized/24/solid/play.svg | 3 + .../optimized/24/solid/plus-circle.svg | 3 + .../optimized/24/solid/plus-small.svg | 3 + .../heroicons/optimized/24/solid/plus.svg | 3 + .../heroicons/optimized/24/solid/power.svg | 3 + .../24/solid/presentation-chart-bar.svg | 3 + .../24/solid/presentation-chart-line.svg | 3 + .../heroicons/optimized/24/solid/printer.svg | 3 + .../optimized/24/solid/puzzle-piece.svg | 3 + .../heroicons/optimized/24/solid/qr-code.svg | 3 + .../24/solid/question-mark-circle.svg | 3 + .../optimized/24/solid/queue-list.svg | 3 + .../heroicons/optimized/24/solid/radio.svg | 3 + .../optimized/24/solid/receipt-percent.svg | 3 + .../optimized/24/solid/receipt-refund.svg | 3 + .../optimized/24/solid/rectangle-group.svg | 3 + .../optimized/24/solid/rectangle-stack.svg | 3 + .../optimized/24/solid/rocket-launch.svg | 4 + .../heroicons/optimized/24/solid/rss.svg | 3 + .../heroicons/optimized/24/solid/scale.svg | 3 + .../heroicons/optimized/24/solid/scissors.svg | 4 + .../optimized/24/solid/server-stack.svg | 4 + .../heroicons/optimized/24/solid/server.svg | 4 + .../heroicons/optimized/24/solid/share.svg | 3 + .../optimized/24/solid/shield-check.svg | 3 + .../optimized/24/solid/shield-exclamation.svg | 3 + .../optimized/24/solid/shopping-bag.svg | 3 + .../optimized/24/solid/shopping-cart.svg | 3 + .../optimized/24/solid/signal-slash.svg | 3 + .../heroicons/optimized/24/solid/signal.svg | 3 + .../heroicons/optimized/24/solid/slash.svg | 3 + .../heroicons/optimized/24/solid/sparkles.svg | 3 + .../optimized/24/solid/speaker-wave.svg | 4 + .../optimized/24/solid/speaker-x-mark.svg | 3 + .../optimized/24/solid/square-2-stack.svg | 4 + .../optimized/24/solid/square-3-stack-3d.svg | 5 + .../optimized/24/solid/squares-2x2.svg | 3 + .../optimized/24/solid/squares-plus.svg | 3 + .../heroicons/optimized/24/solid/star.svg | 3 + .../optimized/24/solid/stop-circle.svg | 3 + .../heroicons/optimized/24/solid/stop.svg | 3 + .../optimized/24/solid/strikethrough.svg | 3 + .../heroicons/optimized/24/solid/sun.svg | 3 + .../heroicons/optimized/24/solid/swatch.svg | 4 + .../optimized/24/solid/table-cells.svg | 3 + .../heroicons/optimized/24/solid/tag.svg | 3 + .../heroicons/optimized/24/solid/ticket.svg | 3 + .../heroicons/optimized/24/solid/trash.svg | 3 + .../heroicons/optimized/24/solid/trophy.svg | 3 + .../heroicons/optimized/24/solid/truck.svg | 5 + .../heroicons/optimized/24/solid/tv.svg | 4 + .../optimized/24/solid/underline.svg | 3 + .../optimized/24/solid/user-circle.svg | 3 + .../optimized/24/solid/user-group.svg | 4 + .../optimized/24/solid/user-minus.svg | 3 + .../optimized/24/solid/user-plus.svg | 3 + .../heroicons/optimized/24/solid/user.svg | 3 + .../heroicons/optimized/24/solid/users.svg | 3 + .../heroicons/optimized/24/solid/variable.svg | 3 + .../optimized/24/solid/video-camera-slash.svg | 3 + .../optimized/24/solid/video-camera.svg | 3 + .../optimized/24/solid/view-columns.svg | 3 + .../optimized/24/solid/viewfinder-circle.svg | 3 + .../heroicons/optimized/24/solid/wallet.svg | 3 + .../heroicons/optimized/24/solid/wifi.svg | 3 + .../heroicons/optimized/24/solid/window.svg | 3 + .../optimized/24/solid/wrench-screwdriver.svg | 5 + .../heroicons/optimized/24/solid/wrench.svg | 3 + .../heroicons/optimized/24/solid/x-circle.svg | 3 + .../heroicons/optimized/24/solid/x-mark.svg | 3 + apps/elektrine/assets/vendor/topbar.js | 165 + apps/elektrine/lib/elektrine.ex | 9 + .../lib/elektrine/account_identifiers.ex | 58 + apps/elektrine/lib/elektrine/accounts.ex | 1716 ++ .../accounts/account_deletion_request.ex | 63 + .../elektrine/accounts/account_endorsement.ex | 55 + .../accounts/account_import_worker.ex | 228 + .../lib/elektrine/accounts/account_note.ex | 45 + .../lib/elektrine/accounts/account_notes.ex | 68 + .../accounts/account_subscription.ex | 55 + .../lib/elektrine/accounts/app_password.ex | 223 + .../lib/elektrine/accounts/authentication.ex | 911 + .../elektrine/accounts/blocked_users_cache.ex | 56 + .../lib/elektrine/accounts/blocking.ex | 424 + .../elektrine/accounts/built_in_subdomain.ex | 12 + .../lib/elektrine/accounts/cached.ex | 107 + .../lib/elektrine/accounts/capabilities.ex | 420 + .../elektrine/accounts/client_app_setting.ex | 39 + .../elektrine/accounts/client_app_settings.ex | 65 + .../elektrine/accounts/connected_account.ex | 84 + .../lib/elektrine/accounts/endorsements.ex | 93 + .../lib/elektrine/accounts/invite_code.ex | 100 + .../lib/elektrine/accounts/invite_code_use.ex | 22 + .../lib/elektrine/accounts/moderation.ex | 521 + .../elektrine/accounts/mute_expire_worker.ex | 36 + .../lib/elektrine/accounts/muting.ex | 159 + .../elektrine/accounts/passkey_credential.ex | 82 + .../lib/elektrine/accounts/passkeys.ex | 409 + .../accounts/recovery_email_verification.ex | 511 + .../lib/elektrine/accounts/storage.ex | 394 + .../lib/elektrine/accounts/subscriptions.ex | 182 + .../lib/elektrine/accounts/tracking.ex | 192 + .../lib/elektrine/accounts/trust_level.ex | 405 + .../lib/elektrine/accounts/trust_level_log.ex | 28 + .../lib/elektrine/accounts/trusted_device.ex | 82 + .../lib/elektrine/accounts/two_factor.ex | 121 + apps/elektrine/lib/elektrine/accounts/user.ex | 1489 ++ .../elektrine/accounts/user_activity_stats.ex | 74 + .../lib/elektrine/accounts/user_block.ex | 36 + .../lib/elektrine/accounts/user_mute.ex | 37 + .../lib/elektrine/accounts/user_session.ex | 108 + .../elektrine/accounts/username_history.ex | 24 + .../elektrine/acme/wildcard_renewal_worker.ex | 148 + apps/elektrine/lib/elektrine/activitypub.ex | 2243 +++ .../lib/elektrine/activitypub/activity.ex | 56 + .../activitypub/activity_delivery_worker.ex | 282 + .../lib/elektrine/activitypub/actor.ex | 160 + .../activitypub/actor_refresh_worker.ex | 82 + .../lib/elektrine/activitypub/builder.ex | 1340 ++ .../collection_count_sync_worker.ex | 101 + .../activitypub/collection_fetcher.ex | 316 + .../activitypub/community_fetcher.ex | 69 + .../lib/elektrine/activitypub/containment.ex | 145 + .../lib/elektrine/activitypub/delivery.ex | 34 + .../elektrine/activitypub/delivery_cleanup.ex | 44 + .../activitypub/delivery_cleanup_worker.ex | 38 + .../activitypub/delivery_retry_worker.ex | 22 + .../activitypub/domain_delivery_health.ex | 68 + .../elektrine/activitypub/domain_migration.ex | 353 + .../elektrine/activitypub/domain_throttler.ex | 227 + .../activitypub/federation_cleanup_worker.ex | 73 + .../activitypub/federation_helpers.ex | 168 + .../activitypub/federation_load_guard.ex | 59 + .../activitypub/fetch_remote_poll_service.ex | 58 + .../activitypub/fetch_remote_poll_worker.ex | 69 + .../lib/elektrine/activitypub/fetcher.ex | 1123 ++ .../lib/elektrine/activitypub/group_follow.ex | 25 + .../lib/elektrine/activitypub/handler.ex | 327 + .../activitypub/handlers/announce_handler.ex | 445 + .../activitypub/handlers/block_handler.ex | 62 + .../activitypub/handlers/create_handler.ex | 926 + .../activitypub/handlers/delete_handler.ex | 108 + .../activitypub/handlers/flag_handler.ex | 329 + .../activitypub/handlers/follow_handler.ex | 419 + .../activitypub/handlers/like_handler.ex | 424 + .../activitypub/handlers/move_handler.ex | 168 + .../activitypub/handlers/update_handler.ex | 413 + .../lib/elektrine/activitypub/helpers.ex | 503 + .../elektrine/activitypub/http_signature.ex | 158 + .../activitypub/inbound_delivery_policy.ex | 223 + .../lib/elektrine/activitypub/inbox_queue.ex | 292 + .../activitypub/inbox_rate_limiter.ex | 144 + .../activitypub/incoming_activity_worker.ex | 160 + .../lib/elektrine/activitypub/instance.ex | 356 + .../lib/elektrine/activitypub/instances.ex | 235 + .../lib/elektrine/activitypub/key_manager.ex | 83 + .../lib/elektrine/activitypub/lemmy_api.ex | 721 + .../lib/elektrine/activitypub/lemmy_cache.ex | 173 + .../activitypub/lemmy_cache_worker.ex | 43 + .../activitypub/lemmy_comment_backfill.ex | 168 + .../elektrine/activitypub/local_references.ex | 272 + .../lib/elektrine/activitypub/mastodon_api.ex | 944 + .../lib/elektrine/activitypub/mentions.ex | 136 + .../lib/elektrine/activitypub/mrf.ex | 164 + .../activitypub/mrf/anti_followbot_policy.ex | 85 + .../activitypub/mrf/anti_link_spam_policy.ex | 92 + .../mrf/anti_mention_spam_policy.ex | 68 + .../elektrine/activitypub/mrf/emoji_policy.ex | 102 + .../mrf/force_bot_unlisted_policy.ex | 51 + .../activitypub/mrf/hashtag_policy.ex | 53 + .../activitypub/mrf/hellthread_policy.ex | 138 + .../activitypub/mrf/inline_quote_policy.ex | 56 + .../activitypub/mrf/keyword_policy.ex | 207 + .../activitypub/mrf/mention_policy.ex | 35 + .../activitypub/mrf/no_empty_policy.ex | 48 + .../mrf/no_placeholder_text_policy.ex | 24 + .../activitypub/mrf/normalize_policy.ex | 42 + .../activitypub/mrf/object_age_policy.ex | 125 + .../lib/elektrine/activitypub/mrf/policy.ex | 50 + .../activitypub/mrf/quiet_reply_policy.ex | 34 + .../mrf/quote_to_link_tag_policy.ex | 37 + .../mrf/reject_non_public_policy.ex | 44 + .../activitypub/mrf/remote_report_policy.ex | 61 + .../activitypub/mrf/simple_policy.ex | 445 + .../activitypub/mrf/user_allow_list_policy.ex | 67 + .../lib/elektrine/activitypub/mrf/utils.ex | 180 + .../activitypub/mrf/vocabulary_policy.ex | 43 + .../lib/elektrine/activitypub/nodeinfo.ex | 250 + .../activitypub/nodeinfo_fetcher_worker.ex | 305 + .../lib/elektrine/activitypub/normalizer.ex | 1369 ++ .../activitypub/object_deliveries.ex | 83 + .../elektrine/activitypub/object_delivery.ex | 34 + .../elektrine/activitypub/object_validator.ex | 549 + .../lib/elektrine/activitypub/outbox.ex | 1109 ++ .../lib/elektrine/activitypub/pipeline.ex | 221 + .../activitypub/process_activity_worker.ex | 421 + .../lib/elektrine/activitypub/publisher.ex | 291 + .../activitypub/reachability_worker.ex | 81 + .../activitypub/refresh_counts_worker.ex | 890 + .../lib/elektrine/activitypub/relay.ex | 630 + .../activitypub/relay_subscription.ex | 82 + .../lib/elektrine/activitypub/remote_fetch.ex | 191 + .../elektrine/activitypub/replies_fetcher.ex | 868 + .../activitypub/replies_ingest_worker.ex | 44 + .../activitypub/reply_fetch_policy.ex | 130 + .../elektrine/activitypub/request_replay.ex | 46 + .../activitypub/request_replay_cache.ex | 118 + .../lib/elektrine/activitypub/side_effects.ex | 117 + .../activitypub/signature_retry_worker.ex | 251 + .../activitypub/signature_verification.ex | 479 + .../lib/elektrine/activitypub/signing_key.ex | 212 + .../submitted_link_repair_worker.ex | 183 + .../activitypub/thread_backfill_worker.ex | 121 + .../lib/elektrine/activitypub/tombstone.ex | 26 + .../lib/elektrine/activitypub/tombstones.ex | 157 + .../elektrine/activitypub/undo_resolver.ex | 157 + .../lib/elektrine/activitypub/user_block.ex | 23 + .../lib/elektrine/activitypub/visibility.ex | 72 + apps/elektrine/lib/elektrine/admin.ex | 305 + .../lib/elektrine/admin/announcement.ex | 72 + .../elektrine/admin/announcement_dismissal.ex | 28 + .../lib/elektrine/api/import_rate_limiter.ex | 14 + .../lib/elektrine/api/media_rate_limiter.ex | 14 + .../lib/elektrine/api/rate_limiter.ex | 20 + .../lib/elektrine/api/search_rate_limiter.ex | 14 + .../elektrine/api/timeline_rate_limiter.ex | 14 + .../lib/elektrine/api/write_rate_limiter.ex | 14 + apps/elektrine/lib/elektrine/app_cache.ex | 921 + apps/elektrine/lib/elektrine/application.ex | 118 + apps/elektrine/lib/elektrine/async.ex | 66 + .../lib/elektrine/atomine_proof_bundle.ex | 232 + .../lib/elektrine/atomine_proof_graph.ex | 401 + apps/elektrine/lib/elektrine/audit_log.ex | 91 + .../elektrine/auth/api_token_revocation.ex | 24 + .../lib/elektrine/auth/rate_limiter.ex | 25 + apps/elektrine/lib/elektrine/bluesky.ex | 1773 ++ .../lib/elektrine/bluesky/finch_client.ex | 11 + .../lib/elektrine/bluesky/inbound.ex | 484 + .../lib/elektrine/bluesky/inbound_event.ex | 26 + .../elektrine/bluesky/inbound_poll_worker.ex | 20 + .../lib/elektrine/bluesky/managed.ex | 519 + .../lib/elektrine/bluesky/outbound_worker.ex | 163 + apps/elektrine/lib/elektrine/booking.ex | 363 + .../lib/elektrine/booking/booking.ex | 94 + .../elektrine/booking/confirmation_worker.ex | 78 + .../elektrine/booking/expire_holds_worker.ex | 25 + .../lib/elektrine/booking/notifier.ex | 101 + apps/elektrine/lib/elektrine/booking/page.ex | 116 + .../lib/elektrine/caddy_tls_domain_cache.ex | 26 + apps/elektrine/lib/elektrine/calendar.ex | 313 + .../lib/elektrine/calendar/availability.ex | 325 + .../lib/elektrine/calendar/calendar.ex | 45 + .../elektrine/lib/elektrine/calendar/event.ex | 159 + .../lib/elektrine/calendar/icalendar.ex | 620 + .../lib/elektrine/calendar/recurrence.ex | 353 + apps/elektrine/lib/elektrine/calls.ex | 354 + apps/elektrine/lib/elektrine/calls/call.ex | 50 + .../lib/elektrine/calls/transport.ex | 118 + apps/elektrine/lib/elektrine/captcha.ex | 217 + .../lib/elektrine/components/copy_button.ex | 43 + .../components/experimental_notice.ex | 47 + .../elektrine/components/loaders/skeleton.ex | 171 + .../lib/elektrine/components/report_modal.ex | 267 + .../elektrine/components/trust_level_badge.ex | 98 + .../lib/elektrine/components/user/avatar.ex | 343 + .../components/user/username_effects.ex | 244 + .../components/user/verification_badge.ex | 55 + apps/elektrine/lib/elektrine/constants.ex | 156 + .../lib/elektrine/database/index_repair.ex | 69 + .../lib/elektrine/dav/rate_limiter.ex | 23 + .../elektrine/lib/elektrine/db/write_guard.ex | 67 + apps/elektrine/lib/elektrine/developer.ex | 854 + .../lib/elektrine/developer/api_token.ex | 375 + .../lib/elektrine/developer/data_export.ex | 227 + .../developer/exports/account_exporter.ex | 546 + .../developer/exports/chat_exporter.ex | 191 + .../developer/exports/email_exporter.ex | 147 + .../developer/exports/social_exporter.ex | 250 + .../lib/elektrine/developer/webhook.ex | 170 + .../elektrine/developer/webhook_delivery.ex | 58 + .../developer/workers/export_worker.ex | 280 + .../workers/webhook_delivery_worker.ex | 37 + apps/elektrine/lib/elektrine/discord.ex | 180 + apps/elektrine/lib/elektrine/domains.ex | 707 + apps/elektrine/lib/elektrine/drive.ex | 1287 ++ .../lib/elektrine/drive/file_share.ex | 69 + .../lib/elektrine/drive/stored_file.ex | 72 + .../lib/elektrine/drive/stored_folder.ex | 51 + .../lib/elektrine/email_addresses.ex | 49 + apps/elektrine/lib/elektrine/emojis.ex | 427 + .../lib/elektrine/emojis/custom_emoji.ex | 91 + apps/elektrine/lib/elektrine/encryption.ex | 219 + .../lib/elektrine/encryption/key_cache.ex | 120 + apps/elektrine/lib/elektrine/friends.ex | 405 + .../lib/elektrine/friends/friend_request.ex | 32 + apps/elektrine/lib/elektrine/home_blog.ex | 79 + apps/elektrine/lib/elektrine/http/backoff.ex | 211 + .../lib/elektrine/http/safe_fetch.ex | 230 + apps/elektrine/lib/elektrine/input.ex | 24 + apps/elektrine/lib/elektrine/internal_api.ex | 11 + apps/elektrine/lib/elektrine/ip_lookup.ex | 130 + apps/elektrine/lib/elektrine/job_queue.ex | 50 + .../lib/elektrine/job_queue_monitor.ex | 209 + .../lib/elektrine/jobs/attachment_uploader.ex | 124 + .../auto_promote_eligible_users_worker.ex | 22 + ...deactivate_expired_announcements_worker.ex | 22 + .../lib/elektrine/jobs/email_recategorizer.ex | 95 + .../profile_custom_domain_health_worker.ex | 18 + ...lculate_recent_discussion_scores_worker.ex | 31 + .../elektrine/jobs/reply_later_processor.ex | 63 + .../lib/elektrine/jobs/stale_call_cleanup.ex | 71 + apps/elektrine/lib/elektrine/link_archive.ex | 298 + .../lib/elektrine/link_archive/archive.ex | 67 + .../elektrine/link_archive/fetch_worker.ex | 198 + .../elektrine/lib/elektrine/mail/telemetry.ex | 54 + .../lib/elektrine/mail_auth/rate_limiter.ex | 84 + .../lib/elektrine/mail_client_settings.ex | 125 + apps/elektrine/lib/elektrine/mailer.ex | 18 + .../lib/elektrine/mailer/delivery_worker.ex | 56 + apps/elektrine/lib/elektrine/markdown.ex | 97 + apps/elektrine/lib/elektrine/markers.ex | 141 + .../elektrine/lib/elektrine/markers/marker.ex | 33 + apps/elektrine/lib/elektrine/media_proxy.ex | 365 + apps/elektrine/lib/elektrine/messaging.ex | 1287 ++ .../elektrine/messaging/arblarg_profiles.ex | 379 + .../lib/elektrine/messaging/arblarg_sdk.ex | 2794 +++ .../lib/elektrine/messaging/auto_mod_rule.ex | 40 + .../elektrine/messaging/channel_categories.ex | 228 + .../elektrine/messaging/channel_category.ex | 33 + .../elektrine/messaging/chat_conversation.ex | 97 + .../chat_conversation_encryption_key.ex | 31 + .../chat_conversation_key_recipient.ex | 55 + .../messaging/chat_conversation_member.ex | 8 + .../elektrine/messaging/chat_conversations.ex | 1176 ++ .../messaging/chat_encryption_device.ex | 73 + .../lib/elektrine/messaging/chat_message.ex | 567 + .../elektrine/messaging/chat_message_pin.ex | 29 + .../elektrine/messaging/chat_message_pins.ex | 355 + .../messaging/chat_message_reaction.ex | 45 + .../lib/elektrine/messaging/chat_messages.ex | 2015 ++ .../messaging/chat_moderation_action.ex | 35 + .../chat_remote_encryption_device.ex | 81 + .../lib/elektrine/messaging/chat_thread.ex | 86 + .../lib/elektrine/messaging/chat_threads.ex | 665 + .../messaging/chat_user_hidden_message.ex | 26 + .../elektrine/messaging/chat_user_timeout.ex | 24 + .../lib/elektrine/messaging/chat_webhook.ex | 106 + .../lib/elektrine/messaging/chat_webhooks.ex | 352 + .../lib/elektrine/messaging/community_ban.ex | 61 + .../elektrine/messaging/community_flair.ex | 45 + .../elektrine/messaging/community_search.ex | 196 + .../messaging/conversation_member.ex | 25 + .../elektrine/messaging/federated_boost.ex | 24 + .../elektrine/messaging/federated_dislike.ex | 24 + .../lib/elektrine/messaging/federated_like.ex | 24 + .../lib/elektrine/messaging/federation.ex | 227 + .../elektrine/messaging/federation/actors.ex | 265 + .../messaging/federation/attachments.ex | 118 + .../elektrine/messaging/federation/auth.ex | 383 + .../messaging/federation/builders.ex | 1116 ++ .../elektrine/messaging/federation/config.ex | 815 + .../messaging/federation/contexts.ex | 680 + .../messaging/federation/delivery.ex | 710 + .../federation/direct_message_state.ex | 555 + .../messaging/federation/direct_messages.ex | 173 + .../messaging/federation/discovery.ex | 1608 ++ .../messaging/federation/dispatch.ex | 232 + .../elektrine/messaging/federation/egress.ex | 489 + .../elektrine/messaging/federation/errors.ex | 96 + .../messaging/federation/event_router.ex | 110 + .../messaging/federation/event_tracking.ex | 90 + .../messaging/federation/extension_events.ex | 512 + .../elektrine/messaging/federation/inbound.ex | 271 + .../elektrine/messaging/federation/ingress.ex | 362 + .../federation/ingress_rate_limiter.ex | 158 + .../messaging/federation/maintenance.ex | 60 + .../messaging/federation/mirror_broadcasts.ex | 199 + .../messaging/federation/mirror_events.ex | 1297 ++ .../elektrine/messaging/federation/mirrors.ex | 701 + .../messaging/federation/peer_policies.ex | 407 + .../elektrine/messaging/federation/peers.ex | 311 + .../messaging/federation/protocol.ex | 328 + .../messaging/federation/public_discovery.ex | 89 + .../messaging/federation/publisher.ex | 484 + .../messaging/federation/request_auth.ex | 256 + .../elektrine/messaging/federation/runtime.ex | 215 + .../messaging/federation/snapshots.ex | 1229 ++ .../elektrine/messaging/federation/state.ex | 975 + .../messaging/federation/transport.ex | 287 + .../elektrine/messaging/federation/utils.ex | 721 + .../messaging/federation/validation.ex | 892 + .../messaging/federation/visibility.ex | 408 + .../messaging/federation/voice_calls.ex | 583 + .../federation_account_presence_state.ex | 44 + .../messaging/federation_call_session.ex | 71 + .../messaging/federation_discovered_peer.ex | 82 + .../elektrine/messaging/federation_event.ex | 53 + .../messaging/federation_extension_event.ex | 43 + .../messaging/federation_invite_state.ex | 63 + .../messaging/federation_membership_state.ex | 53 + .../messaging/federation_outbox_event.ex | 61 + .../federation_outbox_retry_worker.ex | 17 + .../messaging/federation_outbox_worker.ex | 51 + .../messaging/federation_peer_policy.ex | 51 + .../messaging/federation_read_cursor.ex | 45 + .../messaging/federation_request_replay.ex | 43 + .../messaging/federation_retention_worker.ex | 17 + .../federation_room_presence_state.ex | 48 + .../messaging/federation_session_client.ex | 995 + .../messaging/federation_session_websock.ex | 248 + .../messaging/federation_stream_position.ex | 22 + .../messaging/link_preview_broadcast.ex | 67 + .../elektrine/messaging/member_schema_base.ex | 129 + .../lib/elektrine/messaging/membership.ex | 651 + .../elektrine/messaging/membership_authz.ex | 54 + .../elektrine/messaging/message_reaction.ex | 88 + .../lib/elektrine/messaging/moderation.ex | 644 + .../elektrine/messaging/moderation_action.ex | 109 + .../elektrine/messaging/moderation_tools.ex | 714 + .../lib/elektrine/messaging/moderator_note.ex | 29 + .../lib/elektrine/messaging/rate_limiter.ex | 208 + .../lib/elektrine/messaging/reactions.ex | 183 + .../lib/elektrine/messaging/reference_peer.ex | 323 + .../messaging/reference_peer_protocol.ex | 475 + .../reference_peer_session_server.ex | 644 + .../lib/elektrine/messaging/room_acl.ex | 578 + .../lib/elektrine/messaging/server.ex | 80 + .../lib/elektrine/messaging/server_member.ex | 56 + .../lib/elektrine/messaging/servers.ex | 706 + .../lib/elektrine/messaging/slash_commands.ex | 80 + .../messaging/social_conversation.ex | 343 + .../lib/elektrine/messaging/social_message.ex | 1004 + .../messaging/sync_remote_counts_worker.ex | 53 + .../messaging/user_hidden_message.ex | 24 + .../messaging/user_post_timestamp.ex | 26 + .../lib/elektrine/messaging/user_timeout.ex | 44 + .../lib/elektrine/messaging/user_warning.ex | 40 + .../lib/elektrine/messaging/voice_channels.ex | 96 + apps/elektrine/lib/elektrine/notifications.ex | 1393 ++ .../notifications/federation_notifications.ex | 174 + .../elektrine/notifications/notification.ex | 83 + apps/elektrine/lib/elektrine/oauth.ex | 177 + apps/elektrine/lib/elektrine/oauth/app.ex | 362 + .../lib/elektrine/oauth/authorization.ex | 226 + apps/elektrine/lib/elektrine/oauth/scopes.ex | 184 + apps/elektrine/lib/elektrine/oauth/token.ex | 414 + apps/elektrine/lib/elektrine/oidc.ex | 261 + .../lib/elektrine/oidc/signing_key.ex | 37 + apps/elektrine/lib/elektrine/own_root.ex | 248 + apps/elektrine/lib/elektrine/paths.ex | 372 + .../elektrine/lib/elektrine/platform/e_nav.ex | 333 + .../lib/elektrine/platform/e_nav_component.ex | 199 + .../lib/elektrine/platform/integrations.ex | 13 + .../lib/elektrine/platform/module_provider.ex | 14 + .../elektrine/platform/module_providers.ex | 52 + .../lib/elektrine/platform/modules.ex | 213 + .../platform/runtime_config_validator.ex | 288 + apps/elektrine/lib/elektrine/privacy.ex | 331 + apps/elektrine/lib/elektrine/profiles.ex | 2336 +++ .../profiles/analytics_retention_worker.ex | 17 + .../lib/elektrine/profiles/custom_domain.ex | 91 + .../lib/elektrine/profiles/custom_domains.ex | 369 + .../lib/elektrine/profiles/follow.ex | 67 + .../elektrine/profiles/per_site_identities.ex | 100 + .../elektrine/profiles/per_site_identity.ex | 106 + .../lib/elektrine/profiles/profile_link.ex | 315 + .../elektrine/profiles/profile_site_visit.ex | 59 + .../lib/elektrine/profiles/profile_view.ex | 52 + .../lib/elektrine/profiles/profile_widget.ex | 60 + .../public_site_analytics_rollup_worker.ex | 33 + .../lib/elektrine/profiles/site_page_visit.ex | 61 + .../lib/elektrine/profiles/site_session.ex | 83 + .../elektrine/profiles/static_site_deploy.ex | 63 + .../profiles/static_site_deployment.ex | 137 + .../elektrine/profiles/static_site_file.ex | 109 + .../lib/elektrine/profiles/user_badge.ex | 147 + .../lib/elektrine/profiles/user_profile.ex | 524 + .../elektrine/lib/elektrine/proxy_protocol.ex | 168 + apps/elektrine/lib/elektrine/pubsub_topics.ex | 140 + apps/elektrine/lib/elektrine/push.ex | 739 + .../lib/elektrine/push/device_token.ex | 52 + .../lib/elektrine/push/web_push_client.ex | 197 + .../lib/elektrine/push/web_subscription.ex | 87 + apps/elektrine/lib/elektrine/rate_limiter.ex | 355 + apps/elektrine/lib/elektrine/release.ex | 98 + apps/elektrine/lib/elektrine/repo.ex | 5 + apps/elektrine/lib/elektrine/reports.ex | 680 + .../elektrine/lib/elektrine/reports/report.ex | 76 + apps/elektrine/lib/elektrine/rss.ex | 294 + apps/elektrine/lib/elektrine/rss/feed.ex | 88 + .../lib/elektrine/rss/fetch_feed_worker.ex | 187 + apps/elektrine/lib/elektrine/rss/item.ex | 47 + apps/elektrine/lib/elektrine/rss/parser.ex | 355 + .../lib/elektrine/rss/scheduler_worker.ex | 25 + .../lib/elektrine/rss/subscription.ex | 36 + apps/elektrine/lib/elektrine/runtime_env.ex | 57 + .../lib/elektrine/runtime_secrets.ex | 68 + apps/elektrine/lib/elektrine/search.ex | 1175 ++ apps/elektrine/lib/elektrine/search/cached.ex | 120 + .../lib/elektrine/search/domain_rule.ex | 45 + .../lib/elektrine/search/domain_rules.ex | 94 + .../elektrine/search/paige_rate_limiter.ex | 49 + .../lib/elektrine/search/rate_limiter.ex | 38 + .../lib/elektrine/secrets/backfill.ex | 166 + .../lib/elektrine/secrets/encrypted_string.ex | 92 + .../elektrine/security/content_validator.ex | 195 + .../lib/elektrine/security/file_path.ex | 49 + .../elektrine/security/safe_external_url.ex | 52 + .../lib/elektrine/security/url_validator.ex | 496 + .../lib/elektrine/security_alerts.ex | 315 + .../lib/elektrine/security_alerts/cache.ex | 24 + .../social/attachment_cleanup_worker.ex | 64 + .../lib/elektrine/social/conversations.ex | 1348 ++ .../social/engagement_count_repair.ex | 181 + .../social/engagement_count_repair_worker.ex | 30 + .../lib/elektrine/social/engagement_counts.ex | 41 + .../social/expired_data_cleanup_worker.ex | 61 + .../elektrine/lib/elektrine/social/hashtag.ex | 39 + .../lib/elektrine/social/link_preview.ex | 102 + .../lib/elektrine/social/message_stat.ex | 49 + .../lib/elektrine/social/message_stats.ex | 84 + .../lib/elektrine/social/messages.ex | 2604 +++ apps/elektrine/lib/elektrine/social/poll.ex | 81 + .../lib/elektrine/social/poll_option.ex | 27 + .../lib/elektrine/social/poll_vote.ex | 28 + .../lib/elektrine/social/post_hashtag.ex | 20 + .../lib/elektrine/social/scrobble.ex | 73 + .../lib/elektrine/social/scrobbles.ex | 88 + apps/elektrine/lib/elektrine/static_sites.ex | 1215 ++ .../static_sites/github_deploy_worker.ex | 199 + .../static_sites/request_resolver.ex | 120 + apps/elektrine/lib/elektrine/strings.ex | 16 + apps/elektrine/lib/elektrine/subscriptions.ex | 1231 ++ .../lib/elektrine/subscriptions/product.ex | 230 + .../subscriptions/registration_checkout.ex | 67 + .../elektrine/subscriptions/stripe_client.ex | 9 + .../subscriptions/stripe_client/live.ex | 20 + .../elektrine/subscriptions/subscription.ex | 102 + apps/elektrine/lib/elektrine/system.ex | 191 + apps/elektrine/lib/elektrine/system/config.ex | 43 + .../lib/elektrine/telemetry/events.ex | 237 + apps/elektrine/lib/elektrine/text_helpers.ex | 296 + apps/elektrine/lib/elektrine/theme.ex | 456 + apps/elektrine/lib/elektrine/time.ex | 11 + .../lib/elektrine/timeline/rate_limiter.ex | 32 + apps/elektrine/lib/elektrine/uploads.ex | 1619 ++ apps/elektrine/lib/elektrine/user_notifier.ex | 147 + .../lib/elektrine/utils/safe_convert.ex | 178 + apps/elektrine/lib/elektrine/utils/slug.ex | 60 + apps/elektrine/lib/elektrine/vault.ex | 86 + .../lib/elektrine/vault/master_key.ex | 107 + apps/elektrine/lib/elektrine/web_index.ex | 313 + .../lib/elektrine/web_index/crawl_worker.ex | 12 + .../lib/elektrine/web_index/crawler.ex | 314 + .../lib/elektrine/web_index/document.ex | 56 + .../lib/elektrine/web_index/extractor.ex | 117 + .../lib/elektrine/web_index/fetcher.ex | 78 + .../elektrine/lib/elektrine/web_index/host.ex | 35 + .../lib/elektrine/web_index/provider.ex | 32 + .../lib/elektrine/web_index/robots.ex | 126 + .../elektrine/web_index/scheduler_worker.ex | 22 + .../lib/elektrine/web_index/sitemap_worker.ex | 12 + .../lib/elektrine/webhook/rate_limiter.ex | 16 + .../activitypub.backfill_signing_keys.ex | 109 + .../mix/tasks/activitypub.migrate_domain.ex | 82 + .../activitypub.repair_submitted_links.ex | 31 + apps/elektrine/lib/mix/tasks/admin.make.ex | 44 + .../lib/mix/tasks/analytics.seed_site_load.ex | 274 + .../lib/mix/tasks/arblarg.conformance.ex | 113 + apps/elektrine/lib/mix/tasks/badge.grant.ex | 101 + apps/elektrine/lib/mix/tasks/badge.revoke.ex | 40 + .../mix/tasks/cleanup_duplicate_mailboxes.ex | 122 + .../lib/mix/tasks/discussions.demo_posts.ex | 225 + .../mix/tasks/elektrine.db.repair_indexes.ex | 53 + .../lib/mix/tasks/email.inbound_dlq.ex | 172 + .../lib/mix/tasks/email.pubsub_test.ex | 34 + .../mix/tasks/email.recalculate_storage.ex | 64 + .../lib/mix/tasks/email.recategorize.ex | 172 + .../lib/mix/tasks/email.seed_plaintext.ex | 258 + .../lib/mix/tasks/email.seed_realistic.ex | 829 + apps/elektrine/lib/mix/tasks/email.test.ex | 106 + .../lib/mix/tasks/fix_username_duplicates.ex | 161 + .../tasks/messaging.arblarg.sync_artifacts.ex | 49 + .../messaging.federation.interop_smoke.ex | 193 + .../tasks/messaging.federation.load_test.ex | 400 + .../mix/tasks/migrate_email_attachments.ex | 143 + .../lib/mix/tasks/secrets.backfill.ex | 28 + .../social.backfill_lemmy_comment_votes.ex | 53 + .../tasks/social.recalculate_reply_counts.ex | 69 + .../tasks/social.repair_engagement_counts.ex | 48 + apps/elektrine/lib/mix/tasks/test_2fa.ex | 43 + .../lib/mix/tasks/users.import_existing.ex | 87 + apps/elektrine/mix.exs | 153 + apps/elektrine/priv/gettext/default.pot | 3159 ++++ apps/elektrine/priv/gettext/errors.pot | 109 + .../priv/gettext/zh/LC_MESSAGES/default.po | 3154 ++++ .../priv/gettext/zh/LC_MESSAGES/errors.po | 102 + apps/elektrine/priv/repo/baseline_schema.sql | 15533 ++++++++++++++++ .../priv/repo/generate_activitypub_keys.exs | 35 + .../priv/repo/migrations/.formatter.exs | 4 + .../20250511044001_create_users_table.exs | 14 + .../20250512000001_create_mailboxes.exs | 16 + .../20250512000002_create_email_messages.exs | 28 + ...50513061546_create_temporary_mailboxes.exs | 17 + ...13064926_make_mailbox_user_id_nullable.exs | 22 + ...pdate_messages_for_temporary_mailboxes.exs | 16 + .../20250529050458_add_avatar_to_users.exs | 9 + ...809_add_hey_features_to_email_messages.exs | 43 + ...20250608202829_create_approved_senders.exs | 20 + ...dd_spam_and_archived_to_email_messages.exs | 13 + .../20250615002423_import_existing_users.exs | 12 + ...615202324_add_username_change_tracking.exs | 9 + ...20250615235823_add_admin_role_to_users.exs | 9 + .../20250616000949_add_banned_to_users.exs | 11 + .../20250622162141_create_email_aliases.exs | 20 + ...0622164337_add_forwarding_to_mailboxes.exs | 12 + ...50622165252_make_target_email_nullable.exs | 9 + ...10_add_alias_username_conflict_indexes.exs | 11 + ...3020_add_attachments_to_email_messages.exs | 12 + ...65910_create_account_deletion_requests.exs | 21 + ...628155713_add_two_factor_auth_to_users.exs | 12 + .../20250630003407_create_invite_codes.exs | 21 + ...20250630003424_create_invite_code_uses.exs | 15 + .../20250630011535_create_system_config.exs | 23 + ...20250706183626_create_rejected_senders.exs | 20 + ...184034_add_user_to_temporary_mailboxes.exs | 11 + ...12172013_add_bluesky_identity_to_users.exs | 18 + ...250720223936_add_github_token_to_users.exs | 12 + ...20250726184803_remove_screener_feature.exs | 15 + .../20250817001342_create_siem_tables.exs | 201 + ...1916_create_threat_intelligence_tables.exs | 273 + .../20250817210439_add_cybersecurity_news.exs | 90 + ...22_add_underground_threat_intelligence.exs | 305 + .../20250902065912_create_subscriptions.exs | 19 + ...3172225_create_user_profiles_and_links.exs | 50 + ...04070740_add_location_to_user_profiles.exs | 9 + ...1431_add_favicon_and_title_to_profiles.exs | 10 + ...add_advanced_customization_to_profiles.exs | 17 + ...0250905060932_add_ip_tracking_to_users.exs | 17 + ...20250906055118_create_platform_updates.exs | 19 + .../20250906081641_create_audit_logs.exs | 25 + .../20250907053034_create_announcements.exs | 23 + ...7074626_create_announcement_dismissals.exs | 17 + ...250907193037_add_display_name_to_users.exs | 9 + ...0907232526_add_recovery_email_to_users.exs | 13 + .../20250909041315_add_locale_to_users.exs | 9 + ...163235_remove_temporary_mailbox_system.exs | 52 + ...20250911070737_create_username_history.exs | 21 + ...5836_add_storage_tracking_to_mailboxes.exs | 14 + ...0912173431_update_storage_limit_to_5mb.exs | 8 + ...250913160643_remove_bluesky_from_users.exs | 18 + ...163916_remove_custom_css_from_profiles.exs | 13 + ...03149_add_privacy_settings_to_profiles.exs | 10 + ...20250914005156_create_messaging_system.exs | 85 + .../20250914031911_add_hidden_messages.exs | 18 + ...250914073452_add_hash_to_conversations.exs | 11 + ...50914073519_add_hash_to_email_messages.exs | 11 + ...0250914074416_populate_existing_hashes.exs | 28 + ...14081434_remove_username_change_fields.exs | 9 + ...250914091730_add_username_to_mailboxes.exs | 26 + .../20250914172837_create_user_blocks.exs | 17 + ...0911_add_last_password_change_to_users.exs | 9 + ...1003_add_index_to_last_password_change.exs | 7 + .../20250915070107_add_moderation_system.exs | 36 + ...16052139_update_storage_limit_to_500mb.exs | 8 + .../20250916171831_create_follows.exs | 16 + .../20250918055140_create_reports.exs | 52 + ...20250918063443_add_suspension_to_users.exs | 15 + ...165958_add_social_features_to_messages.exs | 45 + ...23_remove_duplicate_user_follows_table.exs | 8 + .../20250919043723_add_link_previews.exs | 30 + .../20250919050445_add_hashtag_system.exs | 37 + ...919124426_add_discussion_voting_system.exs | 30 + ...802_add_cross_context_promotion_fields.exs | 31 + .../20250919132803_add_titles_to_messages.exs | 13 + ...d_unique_constraint_to_community_names.exs | 11 + ...313_add_promoted_from_community_fields.exs | 10 + ...ead_message_id_to_conversation_members.exs | 11 + .../20250923055335_create_notifications.exs | 45 + ...se_insensitive_unique_index_to_aliases.exs | 9 + ...case_insensitive_unique_index_to_users.exs | 11 + ...000_fix_duplicate_aliases_before_index.exs | 33 + ...924000001_add_social_identity_to_users.exs | 62 + ...0250924000002_grandfather_user_handles.exs | 73 + ...350_regenerate_missing_handles_and_ids.exs | 115 + ...151252_migrate_email_attachments_to_s3.exs | 20 + ...26045456_add_privacy_settings_to_users.exs | 29 + ...926052516_set_default_privacy_settings.exs | 23 + ...me_email_notification_fields_to_notify.exs | 11 + .../20250927050233_add_community_flairs.exs | 28 + .../20250927065000_update_reply_counts.exs | 27 + .../20250927070000_add_vote_indexes.exs | 13 + .../20250927071000_add_community_rules.exs | 9 + .../20250927153135_add_pinned_to_messages.exs | 13 + .../20250927153154_create_community_bans.exs | 19 + ...20250927161505_rename_email_categories.exs | 33 + .../20250927195300_rename_feed_to_digest.exs | 17 + .../20250930065342_create_app_passwords.exs | 20 + ...30145634_add_flagged_to_email_messages.exs | 9 + ...153632_allow_null_to_in_email_messages.exs | 9 + ...30163613_add_deleted_to_email_messages.exs | 12 + ...0171320_add_answered_to_email_messages.exs | 11 + .../20251004205738_create_profile_views.exs | 23 + ...251005051754_add_user_storage_tracking.exs | 14 + ...05052144_remove_mailbox_storage_fields.exs | 10 + ...01_update_user_storage_limits_to_500mb.exs | 13 + ...5145316_add_media_metadata_to_messages.exs | 9 + ..._file_sizes_to_avatars_and_backgrounds.exs | 15 + ...move_animated_title_from_user_profiles.exs | 9 + ...4404_add_case_insensitive_handle_index.exs | 11 + ..._case_insensitive_community_name_index.exs | 17 + .../20251008000912_add_timezone_to_users.exs | 9 + ...0251008012454_add_time_format_to_users.exs | 9 + ...move_notify_on_group_invite_from_users.exs | 9 + ...51008150636_add_encryption_to_messages.exs | 12 + ...50659_add_encryption_to_email_messages.exs | 13 + ...1008181136_add_email_protocol_tracking.exs | 14 + .../20251009063632_add_user_status.exs | 13 + .../20251009190614_add_last_seen_to_users.exs | 11 + ...31206_add_username_effects_to_profiles.exs | 16 + .../20251010035439_create_user_badges.exs | 21 + ...5441_add_typewriter_effect_to_profiles.exs | 10 + .../20251010035442_create_profile_widgets.exs | 22 + ...50458_add_typewriter_title_to_profiles.exs | 9 + ...46_add_link_display_style_and_sections.exs | 13 + ...9_add_container_background_to_profiles.exs | 10 + ...0017_add_container_pattern_to_profiles.exs | 10 + ...0645_add_pattern_animation_to_profiles.exs | 10 + ...611_add_visibility_toggles_to_profiles.exs | 11 + ...134141_add_text_background_to_profiles.exs | 9 + ...10134855_update_default_font_to_system.exs | 13 + ...51010135407_add_visible_to_user_badges.exs | 9 + ...8_add_pattern_opacity_to_user_profiles.exs | 9 + .../20251010150620_add_verified_to_users.exs | 9 + ...40_rename_early_adopter_to_beta_tester.exs | 11 + ...51010204959_remove_font_family_default.exs | 13 + ...11035521_add_avatar_effect_to_profiles.exs | 9 + ...51011050154_add_tick_color_to_profiles.exs | 9 + ...738_add_comprehensive_privacy_controls.exs | 19 + ...23_set_defaults_for_new_privacy_fields.exs | 22 + ...251011212159_create_forwarded_messages.exs | 23 + ...add_hide_share_button_to_user_profiles.exs | 9 + ...541_add_extend_layout_to_user_profiles.exs | 9 + ..._hide_community_posts_to_user_profiles.exs | 9 + ...link_highlight_effect_to_user_profiles.exs | 9 + ...756_add_thumbnail_url_to_profile_links.exs | 9 + ...551_add_display_style_to_profile_links.exs | 9 + ..._add_highlight_effect_to_profile_links.exs | 9 + ...rcular_links_position_to_user_profiles.exs | 9 + ...17051314_consolidate_conversation_type.exs | 54 + ...3439_add_show_qr_code_to_user_profiles.exs | 9 + ...remove_show_qr_code_from_user_profiles.exs | 9 + ...1017160525_drop_approved_senders_table.exs | 9 + ...0251017160604_cleanup_orphaned_indexes.exs | 21 + .../20251018050726_add_post_types_support.exs | 68 + ..._add_comprehensive_moderation_features.exs | 99 + .../20251018162421_add_onboarding_fields.exs | 20 + .../20251018234059_add_contacts_system.exs | 35 + ...019041333_add_email_signature_to_users.exs | 9 + ...251021165630_create_email_unsubscribes.exs | 22 + .../20251025131913_create_vpn_tables.exs | 82 + ...dd_bandwidth_quota_to_vpn_user_configs.exs | 16 + ...738_add_rate_limit_to_vpn_user_configs.exs | 10 + .../20251026025047_add_trust_level_system.exs | 69 + ...26043133_remove_siem_intel_news_tables.exs | 44 + ...052454_remove_github_fields_from_users.exs | 12 + ...add_minimum_trust_level_to_vpn_servers.exs | 11 + ...ange_vpn_server_foreign_key_to_cascade.exs | 23 + ...027022325_add_notification_preferences.exs | 11 + ...n_and_comment_notification_preferences.exs | 10 + ...28160556_add_email_performance_indexes.exs | 77 + ...00_add_gallery_to_post_type_constraint.exs | 25 + .../20251102020000_create_post_views.exs | 22 + ...20251102040000_add_performance_indexes.exs | 81 + ...00_add_preferred_email_domain_to_users.exs | 9 + .../20251106000000_create_trusted_devices.exs | 21 + ...251108000001_create_activitypub_tables.exs | 154 + ...te_activitypub_keys_for_existing_users.exs | 14 + ...251108000003_make_follower_id_nullable.exs | 12 + ...onversation_id_nullable_for_federation.exs | 12 + ...251108000005_make_followed_id_nullable.exs | 10 + .../20251108000006_create_post_boosts.exs | 19 + ...251108000007_create_activitypub_relays.exs | 26 + ...anually_approve_followers_default_true.exs | 22 + .../20251109200000_drop_relays_table.exs | 24 + .../20251109203000_create_federated_likes.exs | 17 + .../20251110000001_create_lists.exs | 45 + ...add_published_at_to_activitypub_actors.exs | 17 + .../20251111120000_create_custom_emojis.exs | 21 + ...000001_add_content_warning_to_messages.exs | 13 + ...02_add_federation_to_message_reactions.exs | 20 + ...0_add_community_support_to_activitypub.exs | 48 + ..._add_federated_source_to_conversations.exs | 19 + ..._regenerate_activitypub_keys_for_lemmy.exs | 21 + ...0001_change_activitypub_fields_to_text.exs | 25 + ...ake_message_reactions_user_id_nullable.exs | 11 + .../20251203150755_add_federated_dislikes.exs | 23 + .../20251203150938_add_federated_boosts.exs | 17 + .../20251206171650_create_group_follows.exs | 18 + ...000000_add_missing_performance_indexes.exs | 48 + ...1213071259_extend_email_message_fields.exs | 16 + ...21_add_activitypub_performance_indexes.exs | 41 + ...20251216160458_drop_old_username_index.exs | 7 + ...070133_enhance_recommendation_tracking.exs | 64 + ...1218053438_add_processed_to_activities.exs | 22 + ...251218055225_add_background_job_tables.exs | 51 + .../20251218172906_add_oban_jobs_table.exs | 11 + ...223045423_add_email_restriction_fields.exs | 18 + ...61418_add_unique_actor_username_domain.exs | 130 + .../20251225205440_create_device_tokens.exs | 28 + .../20251228000001_fix_duplicate_handles.exs | 39 + .../20251228000002_add_calls_table.exs | 27 + ...251228000003_add_friend_requests_table.exs | 21 + .../20251229015026_create_chat_messages.exs | 87 + ...51229020637_migrate_chat_messages_data.exs | 119 + .../20251230180851_create_calendars.exs | 86 + ...1230181000_extend_contacts_for_carddav.exs | 77 + .../20251231174204_add_pgp_support.exs | 49 + ...02051253_add_quote_support_to_messages.exs | 28 + .../20260110225401_create_email_threads.exs | 15 + ...0110225402_add_jmap_fields_to_messages.exs | 16 + ...60110225403_create_jmap_state_tracking.exs | 15 + ...0260110225404_create_email_submissions.exs | 21 + ...3859_add_profile_mode_to_user_profiles.exs | 26 + .../20260112200000_add_email_features.exs | 172 + ...60113024544_create_rss_and_saved_items.exs | 97 + ...0000_add_registered_via_onion_to_users.exs | 11 + ...0260117120000_create_user_integrations.exs | 23 + ...5247_add_composite_performance_indexes.exs | 65 + ...0260125082934_add_category_to_messages.exs | 12 + .../20260125085508_create_api_tokens.exs | 23 + .../20260125090103_create_data_exports.exs | 29 + ...125232349_add_draft_status_to_messages.exs | 15 + .../20260126072102_add_lemmy_counts_cache.exs | 20 + .../20260127034957_create_signing_keys.exs | 31 + ..._migrate_existing_keys_to_signing_keys.exs | 12 + ...27090905_add_mrf_policies_to_instances.exs | 30 + ...60127091426_create_relay_subscriptions.exs | 35 + ...60127100000_create_remote_interactions.exs | 36 + ...127100100_add_voters_tracking_to_polls.exs | 16 + ...200_add_emoji_url_to_message_reactions.exs | 13 + ...27100300_add_reachability_to_instances.exs | 15 + .../20260127100400_create_hashtag_follows.exs | 15 + .../20260127120000_create_oauth_tables.exs | 56 + ...260128142101_add_nodeinfo_to_instances.exs | 11 + ...0260128144518_drop_subscriptions_table.exs | 23 + ...60129145416_create_passkey_credentials.exs | 36 + ...203150001_create_billing_subscriptions.exs | 32 + ...203160001_create_subscription_products.exs | 27 + ...260205100001_add_pgp_wkd_hash_to_users.exs | 12 + ...260209020000_remove_ai_pentest_feature.exs | 66 + ...15110000_create_password_vault_entries.exs | 19 + .../20260215123000_create_user_mutes.exs | 17 + ...260215124000_create_developer_webhooks.exs | 22 + ...0260215130000_create_messaging_servers.exs | 45 + ...federation_fields_to_messaging_servers.exs | 37 + ...ssaging_federation_event_stream_tables.exs | 44 + ...essaging_federation_outbox_and_archive.exs | 65 + ...216190000_create_api_token_revocations.exs | 16 + ...260218090000_create_email_suppressions.exs | 23 + ...260218123000_add_bluesky_bridge_fields.exs | 25 + ...218133000_add_bluesky_inbound_tracking.exs | 25 + ...20000_create_chat_user_hidden_messages.exs | 20 + ...ate_messaging_federation_peer_policies.exs | 23 + ...000_add_arblarg_replay_and_idempotency.exs | 104 + ...000_backfill_server_channel_visibility.exs | 17 + ...3101500_create_password_vault_settings.exs | 14 + ...260223143000_add_arbp_chat_projections.exs | 99 + ...260224083500_create_imap_subscriptions.exs | 15 + ..._messages_activitypub_url_lookup_index.exs | 21 + ...00_add_hot_path_feed_and_email_indexes.exs | 62 + ..._activitypub_actor_type_hot_path_index.exs | 17 + ...00_create_developer_webhook_deliveries.exs | 28 + ...0000_create_email_category_preferences.exs | 45 + ...8100000_add_arbp_fast_path_projections.exs | 34 + ..._messaging_federation_discovered_peers.exs | 31 + ..._presence_expiry_and_membership_states.exs | 40 + ..._messaging_federation_discovered_peers.exs | 17 + ...0308133000_create_email_custom_domains.exs | 24 + ...dd_dkim_fields_to_email_custom_domains.exs | 13 + ...im_constraints_on_email_custom_domains.exs | 11 + ...ate_messaging_federation_invite_states.exs | 33 + ...ssaging_federation_event_ids_by_origin.exs | 25 + .../20260310120000_hash_recovery_tokens.exs | 62 + ...0310153000_add_private_mailbox_storage.exs | 16 + ...int_host_and_client_mtu_to_vpn_servers.exs | 11 + ...canonical_activitypub_refs_to_messages.exs | 54 + ...ll_messages_canonical_activitypub_refs.exs | 25 + ...314120000_create_jmap_email_tombstones.exs | 15 + ...260314133000_create_jmap_email_changes.exs | 17 + ...malize_invite_codes_case_insensitively.exs | 38 + ...170000_add_stripe_customer_id_to_users.exs | 34 + ..._time_support_to_subscription_products.exs | 13 + ...14195000_create_registration_checkouts.exs | 24 + ...ormalize_post_views_for_trust_tracking.exs | 65 + ...ing_federation_account_presence_states.exs | 34 + ...add_federated_fields_to_community_bans.exs | 20 + ...saging_federation_room_presence_states.exs | 35 + ...ate_messaging_federation_call_sessions.exs | 41 + ...16143000_create_profile_custom_domains.exs | 24 + ...20260320074000_add_db_hot_path_indexes.exs | 62 + ...ption_fields_to_registration_checkouts.exs | 13 + ...322194500_create_dns_zones_and_records.exs | 49 + ...20260322200000_add_dns_zone_soa_fields.exs | 14 + ...2203000_remove_dns_verification_fields.exs | 10 + ...ub_instances_and_enforce_ci_uniqueness.exs | 49 + ...dd_oidc_fields_to_oauth_authorizations.exs | 13 + ...0260323123000_create_oidc_signing_keys.exs | 18 + ...124000_add_oidc_fields_to_oauth_tokens.exs | 10 + ...143000_add_admin_email_message_indexes.exs | 17 + ...0260327121000_add_managed_dns_services.exs | 40 + ...d_mail_hardening_fields_to_dns_records.exs | 15 + .../20260331110000_create_personal_files.exs | 33 + ...31123000_add_expires_at_to_file_shares.exs | 11 + .../20260331150000_create_stored_folders.exs | 15 + ...add_access_and_password_to_file_shares.exs | 12 + ...402110000_add_theme_overrides_to_users.exs | 9 + ...04121000_hash_oauth_secrets_and_tokens.exs | 68 + ...04133000_add_auth_valid_after_to_users.exs | 9 + ...00_hash_export_and_registration_tokens.exs | 51 + .../20260404180000_create_dns_query_stats.exs | 26 + ...0000_add_link_preview_to_chat_messages.exs | 11 + ...60405143000_create_profile_site_visits.exs | 23 + ...407120000_add_force_https_to_dns_zones.exs | 9 + ..._chat_conversations_from_social_spaces.exs | 149 + ...hat_foreign_keys_to_chat_conversations.exs | 71 + ...at_moderation_tables_and_safety_checks.exs | 168 + ...54500_delete_legacy_chat_conversations.exs | 88 + ...0260411120000_add_mastodon_poll_fields.exs | 12 + ...messages_reply_to_inserted_at_id_index.exs | 14 + ...d_federation_and_imap_hot_path_indexes.exs | 28 + ...0_add_built_in_subdomain_mode_to_users.exs | 9 + .../20260420110000_create_notes.exs | 18 + .../20260420121500_create_note_shares.exs | 19 + ...0421103000_add_protocol_to_vpn_servers.exs | 11 + ...w_multiple_vpn_protocols_per_public_ip.exs | 8 + .../20260422163800_rename_drive_tables.exs | 121 + ...2165500_rename_social_messaging_tables.exs | 19 + ...70500_restore_conversations_table_name.exs | 13 + ..._conversations_to_social_conversations.exs | 13 + .../20260422174500_rename_email_tables.exs | 93 + ...add_email_mailboxes_username_ci_unique.exs | 10 + ...0_add_encrypted_payload_to_note_shares.exs | 9 + ...00_add_pastebin_options_to_note_shares.exs | 12 + ...00_add_burn_after_read_to_drive_shares.exs | 9 + ...60425133000_add_hourly_dns_query_stats.exs | 53 + ...25143000_add_alias_rules_engine_fields.exs | 22 + ..._global_email_unsubscribe_unique_index.exs | 25 + ..._community_bans_for_chat_conversations.exs | 11 + ...vitypub_ref_indexes_to_social_messages.exs | 23 + ...literal_legacy_activitypub_ref_indexes.exs | 30 + ...90000_add_hot_path_social_feed_indexes.exs | 73 + ...000_add_broader_social_surface_indexes.exs | 145 + ...20260428120000_create_site_page_visits.exs | 23 + .../20260428121000_add_site_sessions.exs | 36 + .../20260430031000_create_atomine_proofs.exs | 36 + ...000_add_claim_fields_to_atomine_proofs.exs | 11 + ...033000_add_live_mode_to_atomine_proofs.exs | 18 + ...01090000_create_atomine_trust_sessions.exs | 31 + ...260501091000_create_connected_accounts.exs | 30 + ...0502120000_create_atomine_attestations.exs | 34 + ...121000_add_global_dns_proof_uniqueness.exs | 10 + .../20260503000000_update_oban_to_v14.exs | 11 + .../20260503120000_create_atomine_credits.exs | 66 + ...000_add_chat_messages_search_index_gin.exs | 17 + ...60503134000_add_chat_client_encryption.exs | 67 + ...000_add_chat_remote_encryption_devices.exs | 29 + ...170000_add_activitypub_request_replays.exs | 32 + ..._add_encrypted_password_vault_metadata.exs | 9 + ...dd_chat_encryption_device_trust_fields.exs | 20 + ...0_add_mailbox_category_filter_settings.exs | 10 + ...0000_backfill_chat_conversation_hashes.exs | 15 + ...tivitypub_actor_profile_fields_to_text.exs | 50 + ...0_ensure_fast_federated_timeline_index.exs | 23 + .../20260510120000_encrypt_notes_content.exs | 72 + ...11500_create_external_email_deliveries.exs | 29 + ...13000_expand_external_email_deliveries.exs | 48 + ...20000_create_external_email_ops_tables.exs | 36 + ...21000_create_internal_email_deliveries.exs | 53 + ...or_site_analytics_and_oban_queue_stats.exs | 29 + ...6110000_rename_password_vault_to_nerve.exs | 109 + ...e_session_rollup_indexes_for_analytics.exs | 27 + ...global_atomine_proof_claim_grant_index.exs | 13 + ...0061000_create_static_site_deployments.exs | 21 + ...hook_fields_to_static_site_deployments.exs | 21 + ...atomine_shield_to_gate_in_dns_metadata.exs | 29 + ...efault_built_in_subdomain_mode_to_path.exs | 15 + ...e_existing_platform_profile_subdomains.exs | 16 + ...0_add_spam_filter_enabled_to_mailboxes.exs | 9 + ...00_add_auto_reply_enabled_to_mailboxes.exs | 9 + ...add_oban_and_timeline_hot_path_indexes.exs | 82 + ...00_add_failing_since_to_custom_domains.exs | 13 + ...0000_backfill_orphaned_thread_messages.exs | 47 + .../20260621000000_create_uptime_monitors.exs | 36 + .../20260621000100_create_uptime_checks.exs | 17 + ...20260621000200_create_uptime_incidents.exs | 21 + ...00_add_domain_identity_to_oauth_grants.exs | 20 + ...100_create_profile_per_site_identities.exs | 28 + ...23000200_hash_and_encrypt_share_tokens.exs | 122 + ...20260623000300_encrypt_webhook_secrets.exs | 88 + ...23000400_hash_email_unsubscribe_tokens.exs | 45 + ...3000500_hash_and_encrypt_device_tokens.exs | 89 + .../20260628000000_create_kairo_tables.exs | 44 + ...60628120000_create_account_master_keys.exs | 18 + ...0628130000_add_kairo_source_encryption.exs | 10 + ...260629120000_add_kairo_content_at_rest.exs | 9 + .../20260629140000_drop_notes_tables.exs | 43 + ...e_engagement_counts_to_social_messages.exs | 74 + ...0630001000_create_social_message_stats.exs | 67 + ..._guard_boosts_against_deleted_messages.exs | 46 + ...30001200_create_activitypub_tombstones.exs | 22 + ...0_create_activitypub_object_deliveries.exs | 23 + ...60701000100_create_social_thread_mutes.exs | 19 + ...1000200_add_group_key_to_notifications.exs | 11 + .../20260701000300_create_api_markers.exs | 17 + .../20260701000400_create_social_filters.exs | 20 + ...701000500_add_expiration_to_user_mutes.exs | 11 + .../20260701000600_create_account_notes.exs | 28 + ...20260701000700_create_bookmark_folders.exs | 22 + ...01000800_create_web_push_subscriptions.exs | 25 + ...00_create_suggested_account_dismissals.exs | 18 + ...0701001000_create_account_endorsements.exs | 38 + ...701001100_create_account_subscriptions.exs | 36 + ...60701001200_create_client_app_settings.exs | 16 + ...20260701001300_create_social_scrobbles.exs | 20 + ...701001400_add_birthday_fields_to_users.exs | 25 + ...dd_account_migration_metadata_to_users.exs | 10 + ...notification_privacy_settings_to_users.exs | 10 + ...account_list_privacy_settings_to_users.exs | 11 + ...01_create_messaging_channel_categories.exs | 22 + ...0260704100002_create_chat_message_pins.exs | 17 + .../20260704120001_create_chat_threads.exs | 28 + ...4120002_add_thread_id_to_chat_messages.exs | 11 + .../20260704130001_create_chat_webhooks.exs | 20 + ...130002_add_webhook_id_to_chat_messages.exs | 11 + .../20260704130003_add_is_bot_to_users.exs | 9 + ...4140000_kairo_dedup_and_project_nilify.exs | 39 + ...ile_editing_deploy_link_image_features.exs | 67 + ...60704160000_create_search_domain_rules.exs | 15 + .../20260704170000_create_user_sessions.exs | 24 + ...ages_community_actor_uri_id_desc_index.exs | 26 + ...000000_add_nameserver_set_to_dns_zones.exs | 9 + ...000_create_social_recommendation_items.exs | 34 + ...e_session_referrer_index_for_analytics.exs | 19 + ...0_create_public_site_analytics_rollups.exs | 47 + ...000_dedupe_hashtags_by_normalized_name.exs | 88 + ...ocial_messages_remote_actor_feed_index.exs | 26 + ...00_add_federated_timeline_view_indexes.exs | 37 + .../20260710010000_create_web_index.exs | 62 + ...encrypted_raw_source_to_email_messages.exs | 9 + ...20260712090000_add_theme_mode_to_users.exs | 17 + .../20260725120000_create_link_archives.exs | 40 + .../20260725130000_create_booking_pages.exs | 65 + .../priv/repo/seed_fediverse_posts.exs | 279 + .../repo/seed_platform_demo_screenshots.exs | 808 + apps/elektrine/priv/repo/seed_test_emails.exs | 221 + apps/elektrine/priv/repo/seeds.exs | 2630 +++ .../priv/repo/seeds_invite_codes.exs | 41 + .../priv/repo/test_recategorizer.exs | 209 + apps/elektrine/priv/static/favicon.ico | Bin 0 -> 152 bytes .../priv/static/fonts/Geist-Black.ttf | Bin 0 -> 90780 bytes .../priv/static/fonts/Geist-BlackItalic.ttf | Bin 0 -> 92616 bytes .../priv/static/fonts/Geist-Bold.ttf | Bin 0 -> 90852 bytes .../priv/static/fonts/Geist-BoldItalic.ttf | Bin 0 -> 92628 bytes .../priv/static/fonts/Geist-ExtraBold.ttf | Bin 0 -> 90796 bytes .../static/fonts/Geist-ExtraBoldItalic.ttf | Bin 0 -> 92688 bytes .../priv/static/fonts/Geist-ExtraLight.ttf | Bin 0 -> 90772 bytes .../static/fonts/Geist-ExtraLightItalic.ttf | Bin 0 -> 92644 bytes .../fonts/Geist-Italic-cyrillic-ext.woff2 | Bin 0 -> 7800 bytes .../static/fonts/Geist-Italic-cyrillic.woff2 | Bin 0 -> 15960 bytes .../static/fonts/Geist-Italic-latin-ext.woff2 | Bin 0 -> 17892 bytes .../static/fonts/Geist-Italic-latin.woff2 | Bin 0 -> 31008 bytes .../fonts/Geist-Italic-vietnamese.woff2 | Bin 0 -> 8696 bytes .../priv/static/fonts/Geist-Italic.ttf | Bin 0 -> 92492 bytes .../priv/static/fonts/Geist-Light.ttf | Bin 0 -> 90752 bytes .../priv/static/fonts/Geist-LightItalic.ttf | Bin 0 -> 92616 bytes .../priv/static/fonts/Geist-Medium.ttf | Bin 0 -> 90832 bytes .../priv/static/fonts/Geist-MediumItalic.ttf | Bin 0 -> 92608 bytes .../priv/static/fonts/Geist-Regular.ttf | Bin 0 -> 90768 bytes .../priv/static/fonts/Geist-SemiBold.ttf | Bin 0 -> 90896 bytes .../static/fonts/Geist-SemiBoldItalic.ttf | Bin 0 -> 92636 bytes .../priv/static/fonts/Geist-Thin.ttf | Bin 0 -> 90660 bytes .../priv/static/fonts/Geist-ThinItalic.ttf | Bin 0 -> 92648 bytes .../static/fonts/Geist-cyrillic-ext.woff2 | Bin 0 -> 7420 bytes .../priv/static/fonts/Geist-cyrillic.woff2 | Bin 0 -> 15084 bytes .../priv/static/fonts/Geist-latin-ext.woff2 | Bin 0 -> 16512 bytes .../priv/static/fonts/Geist-latin.woff2 | Bin 0 -> 29400 bytes .../priv/static/fonts/Geist-vietnamese.woff2 | Bin 0 -> 8004 bytes .../priv/static/fonts/GeistMono-Black.ttf | Bin 0 -> 101444 bytes .../static/fonts/GeistMono-BlackItalic.ttf | Bin 0 -> 104716 bytes .../priv/static/fonts/GeistMono-Bold.ttf | Bin 0 -> 101380 bytes .../static/fonts/GeistMono-BoldItalic.ttf | Bin 0 -> 104528 bytes .../priv/static/fonts/GeistMono-ExtraBold.ttf | Bin 0 -> 101348 bytes .../fonts/GeistMono-ExtraBoldItalic.ttf | Bin 0 -> 104504 bytes .../static/fonts/GeistMono-ExtraLight.ttf | Bin 0 -> 101632 bytes .../fonts/GeistMono-ExtraLightItalic.ttf | Bin 0 -> 104712 bytes .../fonts/GeistMono-Italic-cyrillic-ext.woff2 | Bin 0 -> 6616 bytes .../fonts/GeistMono-Italic-cyrillic.woff2 | Bin 0 -> 14116 bytes .../fonts/GeistMono-Italic-latin-ext.woff2 | Bin 0 -> 16440 bytes .../static/fonts/GeistMono-Italic-latin.woff2 | Bin 0 -> 25516 bytes .../fonts/GeistMono-Italic-symbols2.woff2 | Bin 0 -> 5884 bytes .../fonts/GeistMono-Italic-vietnamese.woff2 | Bin 0 -> 8620 bytes .../priv/static/fonts/GeistMono-Italic.ttf | Bin 0 -> 104520 bytes .../priv/static/fonts/GeistMono-Light.ttf | Bin 0 -> 101500 bytes .../static/fonts/GeistMono-LightItalic.ttf | Bin 0 -> 104740 bytes .../priv/static/fonts/GeistMono-Medium.ttf | Bin 0 -> 101552 bytes .../static/fonts/GeistMono-MediumItalic.ttf | Bin 0 -> 104696 bytes .../priv/static/fonts/GeistMono-Regular.ttf | Bin 0 -> 101464 bytes .../priv/static/fonts/GeistMono-SemiBold.ttf | Bin 0 -> 101452 bytes .../static/fonts/GeistMono-SemiBoldItalic.ttf | Bin 0 -> 104608 bytes .../priv/static/fonts/GeistMono-Thin.ttf | Bin 0 -> 101624 bytes .../static/fonts/GeistMono-ThinItalic.ttf | Bin 0 -> 104732 bytes .../static/fonts/GeistMono-cyrillic-ext.woff2 | Bin 0 -> 6176 bytes .../static/fonts/GeistMono-cyrillic.woff2 | Bin 0 -> 12940 bytes .../static/fonts/GeistMono-latin-ext.woff2 | Bin 0 -> 14696 bytes .../priv/static/fonts/GeistMono-latin.woff2 | Bin 0 -> 23128 bytes .../static/fonts/GeistMono-symbols2.woff2 | Bin 0 -> 5812 bytes .../static/fonts/GeistMono-vietnamese.woff2 | Bin 0 -> 7696 bytes .../GeistPixel-Regular-VariableFont_ELSH.ttf | Bin 0 -> 3656708 bytes .../static/fonts/GeistPixel-latin-ext.woff2 | Bin 0 -> 9168 bytes .../priv/static/fonts/GeistPixel-latin.woff2 | Bin 0 -> 16736 bytes .../static/images/android-chrome-192x192.png | Bin 0 -> 312 bytes .../priv/static/images/favicon-16x16.png | Bin 0 -> 296 bytes .../priv/static/images/favicon-32x32.png | Bin 0 -> 296 bytes apps/elektrine/priv/static/images/favicon.ico | Bin 0 -> 4286 bytes .../priv/static/images/home/.gitkeep | 0 .../priv/static/images/home/buttons/.gitkeep | 0 .../static/images/home/buttons/button-01.gif | Bin 0 -> 504 bytes .../static/images/home/buttons/button-02.gif | Bin 0 -> 69834 bytes .../static/images/home/buttons/button-03.gif | Bin 0 -> 427 bytes .../static/images/home/buttons/button-04.gif | Bin 0 -> 3500 bytes .../static/images/home/buttons/button-05.gif | Bin 0 -> 1063 bytes .../static/images/home/buttons/button-06.gif | Bin 0 -> 21009 bytes .../static/images/home/buttons/button-07.gif | Bin 0 -> 5439 bytes .../static/images/home/buttons/button-08.gif | Bin 0 -> 12472 bytes .../priv/static/images/home/poster-01.jpg | Bin 0 -> 1266086 bytes apps/elektrine/priv/static/images/mark.png | Bin 0 -> 2170 bytes .../elektrine/priv/static/openapi/ext-v1.yaml | 1186 ++ apps/elektrine/priv/static/robots.txt | 5 + apps/elektrine/priv/static/sw.js | 74 + apps/elektrine/rel/env.sh.eex | 13 + apps/elektrine/rel/overlays/bin/migrate | 5 + apps/elektrine/rel/overlays/bin/migrate.bat | 1 + apps/elektrine/rel/overlays/bin/server | 5 + apps/elektrine/rel/overlays/bin/server.bat | 2 + .../elektrine/account_identifiers_test.exs | 18 + .../accounts/account_import_worker_test.exs | 115 + .../elektrine/accounts/account_notes_test.exs | 47 + .../elektrine/accounts/app_password_test.exs | 297 + .../elektrine/accounts/capabilities_test.exs | 108 + .../accounts/connected_account_test.exs | 57 + .../elektrine/accounts/domain_blocks_test.exs | 53 + .../elektrine/accounts/invite_codes_test.exs | 344 + .../test/elektrine/accounts/muting_test.exs | 93 + .../test/elektrine/accounts/passkeys_test.exs | 186 + .../recovery_email_verification_test.exs | 353 + .../test/elektrine/accounts/storage_test.exs | 45 + .../elektrine/accounts/trust_level_test.exs | 178 + .../elektrine/accounts/two_factor_test.exs | 222 + .../test/elektrine/accounts/user_test.exs | 265 + .../test/elektrine/accounts_privacy_test.exs | 238 + .../test/elektrine/accounts_status_test.exs | 144 + .../test/elektrine/accounts_test.exs | 292 + .../acme/wildcard_renewal_worker_test.exs | 73 + .../activity_delivery_worker_test.exs | 21 + .../activitypub/actor_secrets_test.exs | 48 + .../activitypub/actor_url_security_test.exs | 71 + .../activitypub/boundary_guardrails_test.exs | 40 + .../activitypub/collection_fetcher_test.exs | 219 + .../activitypub/community_fetcher_test.exs | 139 + .../activitypub/domain_migration_test.exs | 101 + .../fetch_remote_post_replies_test.exs | 38 + .../fetch_remote_user_timeline_test.exs | 96 + .../elektrine/activitypub/fetcher_test.exs | 597 + .../activitypub/get_or_fetch_actor_test.exs | 125 + .../elektrine/activitypub/handler_test.exs | 303 + .../handlers/announce_handler_test.exs | 270 + .../handlers/create_handler_test.exs | 526 + .../handlers/delete_handler_test.exs | 128 + .../handlers/flag_handler_test.exs | 133 + .../handlers/follow_handler_test.exs | 509 + .../handlers/like_handler_test.exs | 400 + .../handlers/move_handler_test.exs | 145 + .../handlers/update_handler_test.exs | 338 + .../elektrine/activitypub/helpers_test.exs | 108 + .../activitypub/http_signature_test.exs | 85 + .../elektrine/activitypub/instances_test.exs | 35 + .../elektrine/activitypub/lemmy_api_test.exs | 88 + .../lemmy_comment_backfill_test.exs | 109 + .../activitypub/mastodon_api_test.exs | 312 + .../elektrine/activitypub/mentions_test.exs | 48 + .../mrf/anti_followbot_policy_test.exs | 105 + .../mrf/hellthread_policy_test.exs | 211 + .../activitypub/mrf/keyword_policy_test.exs | 229 + .../mrf/pleroma_policy_batch_test.exs | 329 + .../activitypub/mrf/simple_policy_test.exs | 421 + .../nodeinfo_fetcher_worker_security_test.exs | 30 + .../elektrine/activitypub/normalizer_test.exs | 414 + .../activitypub/object_validator_test.exs | 214 + .../elektrine/activitypub/outbox_test.exs | 834 + .../elektrine/activitypub/pipeline_test.exs | 19 + .../process_activity_worker_test.exs | 263 + .../elektrine/activitypub/publisher_test.exs | 92 + .../refresh_counts_worker_test.exs | 222 + .../test/elektrine/activitypub/relay_test.exs | 281 + .../activitypub/replies_fetcher_test.exs | 154 + .../replies_ingest_worker_test.exs | 28 + .../activitypub/reply_fetch_policy_test.exs | 38 + .../signature_retry_worker_test.exs | 125 + .../signature_verification_test.exs | 226 + .../thread_backfill_worker_test.exs | 25 + .../activitypub/undo_resolver_test.exs | 85 + apps/elektrine/test/elektrine/admin_test.exs | 216 + .../elektrine/atomine_proof_graph_test.exs | 87 + .../test/elektrine/bluesky_inbound_test.exs | 307 + .../test/elektrine/bluesky_managed_test.exs | 546 + .../elektrine/test/elektrine/bluesky_test.exs | 1038 ++ .../elektrine/booking_confirmation_test.exs | 223 + .../elektrine/test/elektrine/booking_test.exs | 334 + .../elektrine/calendar/availability_test.exs | 345 + .../calendar/availability_zone_test.exs | 183 + .../elektrine/calendar/busy_blocks_test.exs | 115 + .../test/elektrine/calendar/calendar_test.exs | 382 + .../elektrine/calendar/icalendar_test.exs | 382 + .../test/elektrine/calls/transport_test.exs | 44 + apps/elektrine/test/elektrine/calls_test.exs | 150 + .../elektrine/test/elektrine/captcha_test.exs | 17 + .../test/elektrine/db/write_guard_test.exs | 60 + .../exports/account_exporter_test.exs | 173 + .../webhook_delivery_worker_test.exs | 10 + .../test/elektrine/developer_exports_test.exs | 185 + .../test/elektrine/developer_tokens_test.exs | 165 + .../elektrine/developer_webhooks_test.exs | 179 + .../elektrine/test/elektrine/discord_test.exs | 68 + .../elektrine/test/elektrine/domains_test.exs | 124 + apps/elektrine/test/elektrine/drive_test.exs | 242 + apps/elektrine/test/elektrine/emojis_test.exs | 89 + .../test/elektrine/encrypted_search_test.exs | 498 + .../test/elektrine/encryption_test.exs | 262 + .../elektrine/test/elektrine/friends_test.exs | 165 + .../test/elektrine/home_blog_test.exs | 50 + .../test/elektrine/http/safe_fetch_test.exs | 30 + apps/elektrine/test/elektrine/input_test.exs | 14 + .../test/elektrine/job_queue_test.exs | 75 + .../test/elektrine/link_archive_test.exs | 303 + .../elektrine/mail_client_settings_test.exs | 31 + .../elektrine/mailer/delivery_worker_test.exs | 49 + .../elektrine/test/elektrine/markers_test.exs | 104 + .../messaging/activitypub_ref_lookup_test.exs | 200 + .../arblarg_extension_conformance_test.exs | 91 + .../messaging/arblarg_golden_vectors_test.exs | 86 + .../messaging/arblarg_profiles_test.exs | 79 + .../elektrine/messaging/arblarg_sdk_test.exs | 486 + .../messaging/channel_categories_test.exs | 274 + .../chat_conversations_credits_test.exs | 53 + .../messaging/chat_conversations_test.exs | 30 + .../messaging/chat_message_pins_test.exs | 199 + .../messaging/chat_messages_test.exs | 599 + .../elektrine/messaging/chat_threads_test.exs | 258 + .../messaging/chat_webhooks_test.exs | 289 + .../messaging/custom_domain_aliases_test.exs | 140 + .../federated_message_normalization_test.exs | 179 + .../messaging/federation/config_test.exs | 29 + .../messaging/federation_dm_test.exs | 405 + .../messaging/federation_errors_test.exs | 16 + .../federation_ingress_rate_limiter_test.exs | 184 + .../federation_outbox_worker_test.exs | 10 + .../federation_peer_discovery_test.exs | 535 + .../messaging/federation_peer_policy_test.exs | 146 + .../messaging/federation_pins_test.exs | 296 + .../messaging/federation_protocol_test.exs | 23 + .../messaging/federation_remote_join_test.exs | 129 + .../federation_room_participation_test.exs | 210 + .../federation_session_websock_test.exs | 118 + .../elektrine/messaging/federation_test.exs | 3882 ++++ .../messaging/federation_threads_test.exs | 464 + .../messaging/messages_mentions_test.exs | 66 + .../elektrine/messaging/messages_test.exs | 87 + .../messaging/moderation_tools_test.exs | 769 + .../messaging/reference_peer_interop_test.exs | 316 + .../test/elektrine/messaging/servers_test.exs | 241 + .../messaging/slash_commands_test.exs | 44 + .../social_message_security_test.exs | 82 + .../messaging/voice_channels_test.exs | 206 + .../elektrine/messaging_encryption_test.exs | 320 + .../federation_notifications_test.exs | 54 + .../test/elektrine/notifications_test.exs | 413 + .../test/elektrine/oauth/app_test.exs | 56 + apps/elektrine/test/elektrine/paths_test.exs | 28 + .../test/elektrine/platform/modules_test.exs | 78 + .../runtime_config_validator_test.exs | 250 + .../elektrine/test/elektrine/privacy_test.exs | 228 + .../elektrine/profile_custom_domains_test.exs | 243 + .../profile_per_site_identities_test.exs | 88 + .../profiles/profile_widget_test.exs | 36 + .../profiles/static_site_deployment_test.exs | 62 + .../test/elektrine/profiles_test.exs | 885 + .../elektrine/push/web_push_client_test.exs | 88 + apps/elektrine/test/elektrine/push_test.exs | 428 + .../elektrine/test/elektrine/reports_test.exs | 210 + .../elektrine/rss/fetch_feed_worker_test.exs | 10 + .../test/elektrine/rss/parser_test.exs | 674 + apps/elektrine/test/elektrine/rss_test.exs | 518 + .../test/elektrine/runtime_env_test.exs | 64 + .../elektrine/search/domain_rules_test.exs | 94 + .../search/paige_rate_limiter_test.exs | 31 + apps/elektrine/test/elektrine/search_test.exs | 89 + .../test/elektrine/secrets/backfill_test.exs | 171 + .../secrets/encrypted_string_test.exs | 40 + .../security/safe_external_url_test.exs | 38 + .../elektrine/security/url_validator_test.exs | 44 + .../test/elektrine/security_alerts_test.exs | 111 + .../social/engagement_counts_test.exs | 18 + .../expired_data_cleanup_worker_test.exs | 87 + .../github_deploy_worker_security_test.exs | 58 + .../test/elektrine/static_sites_test.exs | 606 + .../elektrine/test/elektrine/strings_test.exs | 17 + .../test/elektrine/subscriptions_test.exs | 427 + apps/elektrine/test/elektrine/theme_test.exs | 42 + .../elektrine/test/elektrine/uploads_test.exs | 241 + apps/elektrine/test/elektrine/vault_test.exs | 79 + .../elektrine/web_index/extractor_test.exs | 43 + .../test/elektrine/web_index/fetcher_test.exs | 27 + .../test/elektrine/web_index/robots_test.exs | 33 + .../test/elektrine/web_index_test.exs | 150 + apps/elektrine/test/support/channel_case.ex | 28 + apps/elektrine/test/support/conn_case.ex | 38 + apps/elektrine/test/support/data_case.ex | 58 + apps/elektrine/test/support/feature_case.ex | 147 + .../support/fixtures/accounts_fixtures.ex | 58 + .../test/support/fixtures/email_fixtures.ex | 55 + .../test/support/fixtures/social_fixtures.ex | 150 + apps/elektrine/test/test_helper.exs | 30 + apps/elektrine_dns/README.md | 21 + apps/elektrine_dns/lib/elektrine/dns.ex | 2293 +++ .../lib/elektrine/dns/application.ex | 36 + .../lib/elektrine/dns/authority.ex | 26 + .../lib/elektrine/dns/domain_health.ex | 352 + .../lib/elektrine/dns/generators/bluesky.ex | 40 + .../lib/elektrine/dns/generators/mail.ex | 242 + .../lib/elektrine/dns/generators/turn.ex | 40 + .../lib/elektrine/dns/generators/vpn.ex | 80 + .../lib/elektrine/dns/generators/web.ex | 27 + .../lib/elektrine/dns/health_monitor.ex | 214 + .../lib/elektrine/dns/mail_security.ex | 201 + .../lib/elektrine/dns/managed_records.ex | 744 + .../elektrine_dns/lib/elektrine/dns/packet.ex | 451 + .../dns/profile_wildcard_bootstrap.ex | 33 + apps/elektrine_dns/lib/elektrine/dns/query.ex | 580 + .../lib/elektrine/dns/query_stat.ex | 46 + .../lib/elektrine/dns/query_stats_buffer.ex | 93 + .../elektrine_dns/lib/elektrine/dns/record.ex | 650 + .../lib/elektrine/dns/recursive.ex | 667 + .../lib/elektrine/dns/recursive_cache.ex | 97 + .../lib/elektrine/dns/recursive_transport.ex | 43 + .../lib/elektrine/dns/request_guard.ex | 112 + .../lib/elektrine/dns/service_binding.ex | 245 + .../lib/elektrine/dns/tcp_server.ex | 145 + .../lib/elektrine/dns/udp_server.ex | 85 + apps/elektrine_dns/lib/elektrine/dns/zone.ex | 98 + .../lib/elektrine/dns/zone_cache.ex | 124 + .../lib/elektrine/dns/zone_change_listener.ex | 124 + .../lib/elektrine/dns/zone_service_config.ex | 60 + apps/elektrine_dns/lib/elektrine_dns_web.ex | 69 + .../components/platform/e_nav.ex | 48 + .../controllers/api/dns_controller.ex | 420 + .../elektrine_dns_web/live/dns_live/index.ex | 2644 +++ .../elektrine.dns.ensure_profile_wildcard.ex | 30 + apps/elektrine_dns/mix.exs | 43 + .../elektrine/dns/authority_cache_test.exs | 223 + .../elektrine/dns/health_monitor_test.exs | 93 + .../elektrine/dns/managed_records_test.exs | 609 + .../test/elektrine/dns/packet_test.exs | 170 + .../elektrine/dns/profile_wildcard_test.exs | 168 + .../elektrine/dns/query_stats_buffer_test.exs | 58 + .../test/elektrine/dns/query_test.exs | 663 + .../dns/record_health_check_test.exs | 89 + .../test/elektrine/dns/recursive_test.exs | 418 + .../test/elektrine/dns/request_guard_test.exs | 78 + .../dns/zone_change_listener_test.exs | 17 + .../elektrine/dns/zone_verification_test.exs | 213 + .../test/elektrine/dns_analytics_test.exs | 142 + .../test/elektrine/dns_context_test.exs | 612 + apps/elektrine_dns/test/test_helper.exs | 1 + apps/elektrine_email/.gitignore | 22 + apps/elektrine_email/README.md | 34 + apps/elektrine_email/lib/elektrine/email.ex | 595 + .../lib/elektrine/email/alias.ex | 482 + .../lib/elektrine/email/aliases.ex | 263 + .../lib/elektrine/email/attachment_storage.ex | 420 + .../lib/elektrine/email/auto_replies.ex | 189 + .../lib/elektrine/email/auto_reply.ex | 162 + .../lib/elektrine/email/blocked_sender.ex | 71 + .../lib/elektrine/email/blocked_senders.ex | 137 + .../lib/elektrine/email/cache.ex | 253 + .../lib/elektrine/email/cache_hooks.ex | 42 + .../lib/elektrine/email/cached.ex | 370 + .../lib/elektrine/email/categorizer.ex | 791 + .../elektrine/email/category_preference.ex | 87 + .../elektrine/email/category_preferences.ex | 191 + .../lib/elektrine/email/contact.ex | 184 + .../lib/elektrine/email/contact_group.ex | 23 + .../lib/elektrine/email/contacts.ex | 256 + .../lib/elektrine/email/custom_domain.ex | 84 + .../email/custom_domain_health_worker.ex | 25 + .../lib/elektrine/email/custom_domains.ex | 582 + .../lib/elektrine/email/custom_folders.ex | 205 + .../lib/elektrine/email/deliverability.ex | 123 + .../lib/elektrine/email/dkim.ex | 507 + .../lib/elektrine/email/export.ex | 84 + .../lib/elektrine/email/exports.ex | 320 + .../lib/elektrine/email/external_delivery.ex | 588 + .../email/external_delivery_alerts.ex | 65 + .../email/external_delivery_attempt.ex | 39 + .../email/external_delivery_control.ex | 123 + .../external_delivery_maintenance_worker.ex | 21 + .../external_delivery_metric_snapshot.ex | 74 + .../email/external_delivery_worker.ex | 138 + .../email/external_domain_throttle.ex | 92 + .../lib/elektrine/email/filter.ex | 238 + .../lib/elektrine/email/filters.ex | 193 + .../lib/elektrine/email/folder.ex | 52 + .../lib/elektrine/email/folders.ex | 1076 ++ .../lib/elektrine/email/forwarded_message.ex | 39 + .../lib/elektrine/email/haraka_adapter.ex | 95 + .../lib/elektrine/email/haraka_admin.ex | 245 + .../lib/elektrine/email/haraka_client.ex | 640 + .../lib/elektrine/email/header_decoder.ex | 38 + .../lib/elektrine/email/header_sanitizer.ex | 403 + .../lib/elektrine/email/imap_subscription.ex | 24 + .../lib/elektrine/email/imap_subscriptions.ex | 125 + .../elektrine/email/inbound_authentication.ex | 81 + .../lib/elektrine/email/inbound_routing.ex | 378 + .../lib/elektrine/email/internal_delivery.ex | 254 + .../email/internal_delivery_attempt.ex | 35 + .../email/internal_delivery_worker.ex | 110 + .../lib/elektrine/email/internal_origin.ex | 103 + .../lib/elektrine/email/label.ex | 33 + .../lib/elektrine/email/labels.ex | 190 + .../lib/elektrine/email/list_types.ex | 206 + .../lib/elektrine/email/mailbox.ex | 499 + .../lib/elektrine/email/mailbox_adapter.ex | 232 + .../lib/elektrine/email/mailbox_encryption.ex | 429 + .../lib/elektrine/email/mailboxes.ex | 410 + .../lib/elektrine/email/message.ex | 490 + .../lib/elektrine/email/messages.ex | 1776 ++ .../elektrine/email/mime_body_extractor.ex | 105 + .../lib/elektrine/email/payload_sanitizer.ex | 26 + .../lib/elektrine/email/pgp.ex | 1050 ++ .../lib/elektrine/email/pgp_key_cache.ex | 45 + .../lib/elektrine/email/processing.ex | 192 + .../lib/elektrine/email/rate_limiter.ex | 463 + .../lib/elektrine/email/receiver.ex | 1006 + .../lib/elektrine/email/safe_sender.ex | 70 + .../lib/elektrine/email/safe_senders.ex | 135 + .../lib/elektrine/email/sanitizer.ex | 479 + .../lib/elektrine/email/search.ex | 244 + .../lib/elektrine/email/send_email_worker.ex | 241 + .../lib/elektrine/email/sender.ex | 2539 +++ .../lib/elektrine/email/sender_class.ex | 192 + .../lib/elektrine/email/suppression.ex | 59 + .../lib/elektrine/email/suppressions.ex | 264 + .../lib/elektrine/email/system_delivery.ex | 116 + .../elektrine/email/system_delivery_worker.ex | 23 + .../lib/elektrine/email/template.ex | 33 + .../lib/elektrine/email/templates.ex | 99 + .../lib/elektrine/email/unsubscribe.ex | 38 + .../lib/elektrine/email/unsubscribes.ex | 214 + .../lib/elektrine/email/vcard.ex | 595 + .../lib/elektrine/email_config.ex | 89 + .../lib/elektrine/imap/append_parser.ex | 272 + .../lib/elektrine/imap/commands.ex | 313 + .../lib/elektrine/imap/commands/append.ex | 317 + .../lib/elektrine/imap/commands/auth.ex | 393 + .../lib/elektrine/imap/commands/idle.ex | 150 + .../lib/elektrine/imap/commands/mailbox.ex | 464 + .../lib/elektrine/imap/commands/message.ex | 690 + .../lib/elektrine/imap/commands/search.ex | 178 + .../lib/elektrine/imap/commands/shared.ex | 40 + .../lib/elektrine/imap/folders.ex | 187 + .../lib/elektrine/imap/helpers.ex | 1240 ++ .../lib/elektrine/imap/idle_tracker.ex | 157 + .../lib/elektrine/imap/rate_limiter.ex | 38 + .../lib/elektrine/imap/recent_state.ex | 84 + .../lib/elektrine/imap/recent_tracker.ex | 109 + .../lib/elektrine/imap/response.ex | 759 + .../lib/elektrine/imap/server.ex | 673 + .../lib/elektrine/imap/supervisor.ex | 76 + .../lib/elektrine/jmap/email_change.ex | 89 + .../lib/elektrine/jmap/email_submission.ex | 198 + .../lib/elektrine/jmap/email_tombstone.ex | 45 + .../lib/elektrine/jmap/jmap.ex | 955 + .../lib/elektrine/jmap/state.ex | 123 + .../lib/elektrine/jmap/thread.ex | 296 + .../lib/elektrine/mail/socket.ex | 161 + .../lib/elektrine/pop3/connection_tracker.ex | 170 + .../lib/elektrine/pop3/rate_limiter.ex | 38 + .../lib/elektrine/pop3/server.ex | 1223 ++ .../lib/elektrine/pop3/supervisor.ex | 76 + .../lib/elektrine/smtp/rate_limiter.ex | 39 + .../lib/elektrine/smtp/send_rate_limiter.ex | 162 + .../lib/elektrine/smtp/server.ex | 1433 ++ .../lib/elektrine/smtp/supervisor.ex | 68 + .../elektrine_email/haraka_inbound_worker.ex | 160 + .../lib/elektrine_email/mcp_tools.ex | 548 + .../lib/elektrine_email/platform.ex | 27 + .../lib/elektrine_email_web.ex | 95 + .../components/email/display.ex | 379 + .../components/email/sidebar.ex | 452 + .../components/platform/elektrine_nav.ex | 50 + .../controllers/admin/aliases_controller.ex | 273 + .../controllers/admin/aliases_html.ex | 9 + .../admin/aliases_html/aliases.html.heex | 273 + .../aliases_html/forwarded_messages.html.heex | 496 + .../admin/custom_domains_controller.ex | 100 + .../controllers/admin/custom_domains_html.ex | 107 + .../custom_domains.html.heex | 693 + .../controllers/admin/haraka_html.ex | 0 .../controllers/admin/mailboxes_controller.ex | 224 + .../controllers/admin/mailboxes_html.ex | 9 + .../admin/mailboxes_html/mailboxes.html.heex | 373 + .../controllers/admin/messages_controller.ex | 695 + .../controllers/admin/messages_html.ex | 27 + .../admin/messages_html/messages.html.heex | 947 + .../messages_html/user_messages.html.heex | 286 + .../messages_html/view_message.html.heex | 186 + .../messages_html/view_user_message.html.heex | 166 + .../admin/system_emails_controller.ex | 56 + .../controllers/admin/system_emails_html.ex | 7 + .../admin/system_emails_html/new.html.heex | 104 + .../controllers/api/alias_controller.ex | 223 + .../controllers/api/email_controller.ex | 709 + .../api/ext_contacts_controller.ex | 178 + .../controllers/api/ext_email_controller.ex | 546 + .../controllers/api/mailbox_controller.ex | 130 + .../controllers/attachment_controller.ex | 224 + .../dav/address_book_controller.ex | 432 + .../controllers/email_controller.ex | 611 + .../controllers/email_html.ex | 28 + .../controllers/email_html/print.html.heex | 312 + .../controllers/haraka_webhook_controller.ex | 2112 +++ .../controllers/jmap/api_controller.ex | 1099 ++ .../controllers/jmap/blob_controller.ex | 325 + .../jmap/event_source_controller.ex | 128 + .../controllers/jmap/session_controller.ex | 102 + .../controllers/unsubscribe_controller.ex | 111 + .../controllers/unsubscribe_html.ex | 10 + .../unsubscribe_html/confirmed.html.heex | 34 + .../unsubscribe_html/resubscribed.html.heex | 30 + .../unsubscribe_html/show.html.heex | 17 + .../controllers/wkd_controller.ex | 96 + .../email_preference_helpers.ex | 11 + .../live/contacts_live/index.ex | 145 + .../live/contacts_live/index.html.heex | 682 + .../operations/contact_operations.ex | 332 + .../live/email_live/compose.ex | 2313 +++ .../live/email_live/compose.html.heex | 627 + .../live/email_live/email_helpers.ex | 719 + .../live/email_live/index.ex | 745 + .../live/email_live/index.html.heex | 1893 ++ .../email_live/operations/alias_operations.ex | 215 + .../email_live/operations/bulk_operations.ex | 435 + .../operations/compose_operations.ex | 157 + .../operations/contact_operations.ex | 148 + .../operations/message_operations.ex | 373 + .../operations/navigation_operations.ex | 60 + .../operations/reply_later_operations.ex | 115 + .../operations/search_operations.ex | 41 + .../operations/selection_operations.ex | 86 + .../live/email_live/operations/tab_content.ex | 382 + .../live/email_live/raw.ex | 90 + .../live/email_live/raw.html.heex | 117 + .../live/email_live/router.ex | 196 + .../live/email_live/search.ex | 166 + .../live/email_live/search.html.heex | 287 + .../live/email_live/settings.ex | 378 + .../email_live/settings/content_settings.ex | 625 + .../email_live/settings/domain_settings.ex | 757 + .../email_live/settings/filter_settings.ex | 888 + .../live/email_live/settings/helpers.ex | 24 + .../email_live/settings/sender_settings.ex | 263 + .../live/email_live/show.ex | 488 + .../live/email_live/show.html.heex | 698 + .../live/unsubscribe_live/confirmed.ex | 59 + .../live/unsubscribe_live/show.ex | 74 + .../live/user_settings_email.ex | 1119 ++ .../plugs/email_ownership_guard.ex | 128 + .../elektrine_email_web/plugs/jmap_auth.ex | 148 + .../lib/elektrine_email_web/storage_email.ex | 108 + .../elektrine_email_web/user_auth_email.ex | 19 + .../elektrine_email_web/user_error_helpers.ex | 96 + .../user_settings_email_controller.ex | 55 + apps/elektrine_email/mix.exs | 59 + .../test/elektrine/email/alias_test.exs | 303 + .../email/attachment_storage_test.exs | 121 + .../elektrine/email/auto_replies_test.exs | 54 + .../test/elektrine/email/categorizer_test.exs | 491 + .../email/category_preferences_test.exs | 80 + .../elektrine/email/custom_domains_test.exs | 394 + .../test/elektrine/email/dkim_test.exs | 29 + .../test/elektrine/email/exports_test.exs | 34 + .../test/elektrine/email/filters_test.exs | 971 + .../elektrine/email/haraka_adapter_test.exs | 37 + .../elektrine/email/haraka_client_test.exs | 215 + .../elektrine/email/header_decoder_test.exs | 24 + .../elektrine/email/header_sanitizer_test.exs | 370 + .../email/inbound_authentication_test.exs | 75 + .../elektrine/email/inbound_routing_test.exs | 319 + .../elektrine/email/mailbox_adapter_test.exs | 50 + .../email/mailbox_private_storage_test.exs | 463 + .../test/elektrine/email/mailbox_test.exs | 22 + .../email/message_operations_test.exs | 1025 + .../email/mime_body_extractor_test.exs | 57 + .../test/elektrine/email/pgp_receive_test.exs | 447 + .../test/elektrine/email/pgp_test.exs | 627 + .../email/platform_email_headers_test.exs | 98 + .../elektrine/email/plus_addressing_test.exs | 42 + .../elektrine/email/rate_limiter_test.exs | 327 + .../test/elektrine/email/sanitizer_test.exs | 723 + .../email/send_email_worker_test.exs | 65 + .../elektrine/email/sender_class_test.exs | 184 + .../email/sender_credit_gate_test.exs | 129 + .../elektrine/email/sender_pipeline_test.exs | 617 + .../email/sender_rate_limit_test.exs | 49 + .../email/sender_validation_test.exs | 240 + .../elektrine/email/suppressions_test.exs | 70 + .../elektrine/email/system_delivery_test.exs | 53 + .../elektrine/email/unsubscribes_test.exs | 89 + .../test/elektrine/email/vcard_test.exs | 314 + .../elektrine/email_basic_functions_test.exs | 505 + .../test/elektrine/email_encryption_test.exs | 540 + .../test/elektrine/email_test.exs | 311 + .../test/elektrine/imap/commands_test.exs | 875 + .../test/elektrine/imap/helpers_test.exs | 175 + .../elektrine/imap/response_security_test.exs | 88 + .../mail/protocol_auth_rate_limiter_test.exs | 46 + .../mail/protocol_transcript_test.exs | 1162 ++ .../elektrine/mail_auth/rate_limiter_test.exs | 82 + .../pop3/connection_tracker_test.exs | 57 + .../components/email/display_test.exs | 96 + .../live/email_live/email_helpers_test.exs | 82 + .../user_error_helpers_test.exs | 48 + .../fixtures/email/css_preamble_plain.txt | 7 + .../fixtures/imap_client_transcripts.json | 32 + .../elektrine_email/test/support/data_case.ex | 58 + .../test/support/fixtures/README.md | 2 + .../support/fixtures/accounts_fixtures.ex | 58 + .../test/support/fixtures/email_fixtures.ex | 56 + .../test/support/fixtures/pop3-test-cert.pem | 19 + .../test/support/fixtures/pop3-test-key.pem | 28 + .../test/support/fixtures/social_fixtures.ex | 150 + apps/elektrine_email/test/test_helper.exs | 6 + apps/elektrine_nerve/.gitignore | 22 + apps/elektrine_nerve/README.md | 29 + apps/elektrine_nerve/lib/elektrine/nerve.ex | 124 + .../lib/elektrine/nerve/nerve_entry.ex | 179 + .../lib/elektrine/nerve/nerve_settings.ex | 85 + .../lib/elektrine/nerve/payloads.ex | 50 + .../lib/elektrine_nerve_web.ex | 64 + .../api/nerve_controller.ex | 196 + .../lib/elektrine_nerve_web/api/response.ex | 61 + .../components/platform/e_nav.ex | 46 + .../lib/elektrine_nerve_web/nerve_live.ex | 577 + apps/elektrine_nerve/mix.exs | 54 + .../test/elektrine/nerve_test.exs | 253 + .../elektrine_nerve/test/support/data_case.ex | 36 + .../support/fixtures/accounts_fixtures.ex | 53 + apps/elektrine_nerve/test/test_helper.exs | 10 + apps/elektrine_social/.gitignore | 22 + apps/elektrine_social/README.md | 24 + apps/elektrine_social/lib/elektrine/social.ex | 1864 ++ .../lib/elektrine/social/bookmark_folder.ex | 39 + .../lib/elektrine/social/bookmark_folders.ex | 62 + .../lib/elektrine/social/bookmarks.ex | 440 + .../lib/elektrine/social/boosts.ex | 383 + .../elektrine/social/creator_satisfaction.ex | 93 + .../lib/elektrine/social/cross_posting.ex | 371 + .../lib/elektrine/social/discussions.ex | 363 + .../lib/elektrine/social/drafts.ex | 386 + .../lib/elektrine/social/federated_feeds.ex | 403 + .../lib/elektrine/social/feed_policy.ex | 193 + .../lib/elektrine/social/feed_query.ex | 270 + .../social/fetch_link_preview_worker.ex | 142 + .../lib/elektrine/social/filter.ex | 75 + .../lib/elektrine/social/filters.ex | 214 + .../lib/elektrine/social/hashtag_extractor.ex | 315 + .../lib/elektrine/social/hashtag_follow.ex | 27 + .../lib/elektrine/social/hashtag_follows.ex | 118 + .../lib/elektrine/social/hashtags.ex | 169 + .../lib/elektrine/social/home_feed.ex | 165 + .../lib/elektrine/social/home_feed_cache.ex | 94 + .../social/home_feed_fanout_worker.ex | 51 + .../social/home_feed_invalidation_worker.ex | 75 + .../lib/elektrine/social/likes.ex | 402 + .../elektrine/social/link_preview_fetcher.ex | 238 + .../lib/elektrine/social/link_preview_job.ex | 66 + .../social/link_preview_refresh_worker.ex | 93 + .../elektrine/social/link_preview_worker.ex | 141 + .../lib/elektrine/social/list.ex | 27 + .../lib/elektrine/social/list_member.ex | 39 + .../lib/elektrine/social/lists.ex | 498 + .../lib/elektrine/social/media_attachments.ex | 415 + .../lib/elektrine/social/message_policy.ex | 86 + .../lib/elektrine/social/message_vote.ex | 24 + .../elektrine/social/notification_policy.ex | 206 + .../lib/elektrine/social/oembed.ex | 280 + .../lib/elektrine/social/polls.ex | 462 + .../lib/elektrine/social/post_boost.ex | 22 + .../lib/elektrine/social/post_dismissal.ex | 36 + .../lib/elektrine/social/post_like.ex | 21 + .../lib/elektrine/social/post_view.ex | 52 + .../elektrine/social/recommendation_item.ex | 53 + .../social/recommendation_refresh_worker.ex | 59 + .../lib/elektrine/social/recommendations.ex | 1718 ++ .../lib/elektrine/social/saved_item.ex | 46 + .../social/scheduled_post_publisher_worker.ex | 20 + .../lib/elektrine/social/status_reactions.ex | 113 + .../social/suggested_account_dismissal.ex | 30 + .../lib/elektrine/social/suggested_follows.ex | 160 + .../lib/elektrine/social/thread_mute.ex | 22 + .../lib/elektrine/social/thread_mutes.ex | 62 + .../social/timeline_load_verifier.ex | 264 + .../elektrine/social/timeline_pagination.ex | 67 + .../social/timeline_relationships.ex | 267 + .../lib/elektrine/social/views.ex | 327 + .../lib/elektrine/social/votes.ex | 580 + .../moderation/iftas_blocklist.ex | 311 + .../moderation/iftas_blocklist_worker.ex | 50 + .../lib/elektrine_social/platform.ex | 20 + .../elektrine_social/remote_user/metrics.ex | 246 + .../remote_user/metrics_worker.ex | 39 + .../remote_user/outbox_sync.ex | 332 + .../remote_user/outbox_sync_worker.ex | 35 + .../lib/elektrine_social_web.ex | 99 + .../components/platform/e_nav.ex | 48 + .../components/social/content_journey.ex | 63 + .../components/social/embedded_post.ex | 363 + .../components/social/fediverse_follow.ex | 286 + .../components/social/follow_button.ex | 87 + .../components/social/poll.ex | 123 + .../components/social/poll_display.ex | 551 + .../components/social/post_actions.ex | 1013 + .../components/social/post_reactions.ex | 283 + .../components/social/post_utilities.ex | 1129 ++ .../components/social/remote_post_shared.ex | 426 + .../components/social/reply_item.ex | 656 + .../components/social/rss_item.ex | 219 + .../components/social/timeline_post.ex | 2668 +++ .../social/timeline_post_ancestors.ex | 767 + .../components/social/timeline_post_boost.ex | 117 + .../components/social/timeline_post_card.ex | 58 + .../social/timeline_post_compact.ex | 153 + .../components/social/timeline_post_footer.ex | 236 + .../components/social/timeline_post_media.ex | 440 + .../components/social/timeline_stream_post.ex | 425 + .../components/ui/image_modal.ex | 378 + .../components/user/hover_card.ex | 415 + .../controllers/activitypub/actor_request.ex | 145 + .../activitypub/signature_actor_verifier.ex | 192 + .../controllers/activitypub_controller.ex | 1121 ++ .../controllers/api/ext_social_controller.ex | 328 + .../controllers/api/social_controller.ex | 1210 ++ .../external_interaction_controller.ex | 116 + .../controllers/media_proxy_controller.ex | 214 + .../controllers/nodeinfo_controller.ex | 305 + .../controllers/webfinger_controller.ex | 454 + .../live/discussions_live/community.ex | 1640 ++ .../live/discussions_live/community.html.heex | 2175 +++ .../live/discussions_live/index.ex | 3729 ++++ .../live/discussions_live/index.html.heex | 1937 ++ .../operations/flair_operations.ex | 172 + .../operations/member_operations.ex | 217 + .../operations/moderation_operations.ex | 610 + .../operations/post_operations.ex | 1309 ++ .../operations/sort_helpers.ex | 61 + .../operations/ui_operations.ex | 302 + .../operations/voting_operations.ex | 272 + .../live/discussions_live/post.ex | 1316 ++ .../live/discussions_live/post.html.heex | 1277 ++ .../post_operations/moderation_operations.ex | 632 + .../post_operations/reply_operations.ex | 335 + .../post_operations/ui_operations.ex | 241 + .../post_operations/voting_operations.ex | 320 + .../live/discussions_live/post_router.ex | 190 + .../live/discussions_live/router.ex | 307 + .../live/discussions_live/session_context.ex | 246 + .../live/discussions_live/settings.ex | 182 + .../live/discussions_live/settings.html.heex | 302 + .../live/filters_live/index.ex | 270 + .../live/filters_live/index.html.heex | 239 + .../live/gallery_live/index.ex | 1695 ++ .../live/gallery_live/index.html.heex | 772 + .../live/global_feed_page.ex | 25 + .../live/hashtag_live/show.ex | 986 + .../live/hashtag_live/show.html.heex | 350 + .../live/list_live/index.ex | 327 + .../live/list_live/index.html.heex | 380 + .../live/list_live/show.ex | 926 + .../live/list_live/show.html.heex | 659 + .../live/remote_post_live/access_policy.ex | 94 + .../ancestor_context_components.ex | 222 + .../remote_post_live/cached_post_fields.ex | 343 + .../live/remote_post_live/counts.ex | 146 + .../remote_post_live/detail_components.ex | 153 + .../live/remote_post_live/detail_state.ex | 332 + .../remote_post_live/discussion_source.ex | 166 + .../live/remote_post_live/display_counts.ex | 136 + .../live/remote_post_live/interactions.ex | 767 + .../live/remote_post_live/navigation.ex | 106 + .../live/remote_post_live/polls.ex | 81 + .../quick_reply_components.ex | 316 + .../reply_author_components.ex | 256 + .../live/remote_post_live/show.ex | 5893 ++++++ .../live/remote_post_live/show.html.heex | 1840 ++ .../live/remote_post_live/submitted_links.ex | 289 + .../live/remote_post_live/surface_helpers.ex | 626 + .../threaded_comment_components.ex | 906 + .../live/remote_post_live/threading.ex | 289 + .../live/remote_user_live/actor_lookup.ex | 78 + .../community_post_operations.ex | 179 + .../live/remote_user_live/counts.ex | 290 + .../remote_user_live/follow_operations.ex | 99 + .../live/remote_user_live/media_modal.ex | 59 + .../post_interaction_operations.ex | 1092 ++ .../live/remote_user_live/post_sorting.ex | 228 + .../live/remote_user_live/post_state.ex | 433 + .../remote_user_live/reaction_surfaces.ex | 167 + .../live/remote_user_live/reply_operations.ex | 162 + .../live/remote_user_live/show.ex | 580 + .../live/remote_user_live/show.html.heex | 2083 +++ .../live/remote_user_live/timeline_loader.ex | 505 + .../live/timeline_live/index.ex | 2494 +++ .../live/timeline_live/index.html.heex | 1721 ++ .../operations/bookmark_folder_operations.ex | 203 + .../live/timeline_live/operations/helpers.ex | 1192 ++ .../operations/image_operations.ex | 240 + .../operations/navigation_operations.ex | 145 + .../operations/post_operations.ex | 1751 ++ .../operations/reply_operations.ex | 425 + .../operations/social_operations.ex | 479 + .../operations/tracking_operations.ex | 186 + .../timeline_live/operations/ui_operations.ex | 98 + .../operations/voting_operations.ex | 951 + .../live/timeline_live/post.ex | 1319 ++ .../live/timeline_live/post.html.heex | 858 + .../timeline_live/reply_context_previews.ex | 296 + .../live/timeline_live/router.ex | 168 + .../live/videos_live/index.ex | 907 + .../live/videos_live/index.html.heex | 343 + .../mix/tasks/social.verify_timeline_load.ex | 67 + apps/elektrine_social/mix.exs | 51 + ...ccount_subscription_notifications_test.exs | 90 + .../social/blocked_instances_test.exs | 219 + .../social/bookmark_folders_test.exs | 36 + .../test/elektrine/social/bookmarks_test.exs | 311 + .../test/elektrine/social/boosts_test.exs | 266 + .../test/elektrine/social/drafts_test.exs | 248 + .../test/elektrine/social/filters_test.exs | 138 + .../social/hashtag_extractor_test.exs | 39 + .../test/elektrine/social/hashtags_test.exs | 33 + .../home_feed_cache_invalidation_test.exs | 274 + .../test/elektrine/social/likes_test.exs | 276 + .../link_preview_refresh_worker_test.exs | 40 + .../elektrine/social/link_preview_test.exs | 118 + .../elektrine/social/media_metadata_test.exs | 114 + .../social/notification_policy_test.exs | 271 + .../test/elektrine/social/poll_test.exs | 294 + .../recommendation_refresh_worker_test.exs | 43 + .../elektrine/social/recommendations_test.exs | 342 + .../social/remote_user/outbox_sync_test.exs | 75 + .../social/remote_user_metrics_test.exs | 68 + .../elektrine/social/reply_counts_test.exs | 42 + .../elektrine/social/status_pins_test.exs | 88 + .../social/timeline_load_verifier_test.exs | 47 + .../test/elektrine/social/views_test.exs | 169 + .../test/elektrine/social/votes_test.exs | 314 + .../moderation/iftas_blocklist_test.exs | 84 + .../components/social/poll_display_test.exs | 68 + .../components/social/post_reactions_test.exs | 85 + .../flair_operations_test.exs | 61 + .../feed_display_toggles_test.exs | 66 + .../timeline_live/post_operations_test.exs | 132 + .../test/support/data_case.ex | 58 + .../support/fixtures/accounts_fixtures.ex | 58 + .../test/support/fixtures/email_fixtures.ex | 55 + .../test/support/fixtures/social_fixtures.ex | 150 + apps/elektrine_social/test/test_helper.exs | 6 + apps/elektrine_uptime/README.md | 21 + apps/elektrine_uptime/lib/elektrine/uptime.ex | 346 + .../lib/elektrine/uptime/application.ex | 12 + .../lib/elektrine/uptime/check.ex | 30 + .../uptime/check_scheduler_worker.ex | 35 + .../lib/elektrine/uptime/check_worker.ex | 52 + .../lib/elektrine/uptime/checker.ex | 234 + .../lib/elektrine/uptime/checker/behaviour.ex | 17 + .../lib/elektrine/uptime/email.ex | 165 + .../lib/elektrine/uptime/incident.ex | 27 + .../lib/elektrine/uptime/monitor.ex | 161 + .../lib/elektrine/uptime/notifier.ex | 105 + .../lib/elektrine_uptime_web.ex | 69 + .../live/uptime_live/index.ex | 335 + .../live/uptime_live/index.html.heex | 422 + apps/elektrine_uptime/mix.exs | 43 + .../elektrine/uptime/check_worker_test.exs | 65 + .../test/elektrine/uptime/checker_test.exs | 188 + .../test/elektrine/uptime/notifier_test.exs | 159 + .../test/elektrine/uptime/uptime_test.exs | 188 + .../live/uptime_live_test.exs | 117 + apps/elektrine_uptime/test/test_helper.exs | 13 + .../test_support/data_case.ex | 58 + apps/elektrine_vpn/.gitignore | 22 + apps/elektrine_vpn/README.md | 22 + apps/elektrine_vpn/lib/elektrine/vpn.ex | 1549 ++ .../lib/elektrine/vpn/connection_log.ex | 36 + .../lib/elektrine/vpn/health_monitor.ex | 135 + .../lib/elektrine/vpn/peer_cache.ex | 73 + .../elektrine/vpn/self_hosted_reconciler.ex | 171 + .../lib/elektrine/vpn/self_hosted_server.ex | 37 + .../vpn/self_hosted_shadowsocks_server.ex | 137 + .../elektrine_vpn/lib/elektrine/vpn/server.ex | 146 + .../lib/elektrine/vpn/shadowsocks_adapter.ex | 236 + .../elektrine/vpn/stale_node_reaper_worker.ex | 55 + .../lib/elektrine/vpn/stats_aggregator.ex | 155 + .../lib/elektrine/vpn/user_config.ex | 69 + .../lib/elektrine/vpn/wire_guard_adapter.ex | 98 + .../lib/elektrine_vpn/platform.ex | 34 + apps/elektrine_vpn/lib/elektrine_vpn_web.ex | 96 + .../components/platform/elektrine_nav.ex | 46 + .../controllers/admin/vpn_controller.ex | 311 + .../controllers/admin/vpn_html.ex | 31 + .../confirm_delete_vpn_server.html.heex | 160 + .../admin/vpn_html/edit_vpn_server.html.heex | 401 + .../vpn_html/edit_vpn_user_config.html.heex | 143 + .../admin/vpn_html/new_vpn_server.html.heex | 432 + .../admin/vpn_html/vpn_dashboard.html.heex | 312 + .../admin/vpn_html/vpn_users.html.heex | 257 + .../controllers/api/vpn_controller.ex | 290 + .../controllers/vpn_api_controller.ex | 467 + .../live/page_live/vpn_policy.ex | 196 + .../elektrine_vpn_web/live/vpn_live/index.ex | 269 + .../live/vpn_live/index.html.heex | 434 + .../tasks/elektrine.vpn.reap_stale_nodes.ex | 122 + apps/elektrine_vpn/mix.exs | 55 + .../vpn/shadowsocks_adapter_test.exs | 144 + .../vpn/stale_node_reaper_worker_test.exs | 72 + .../elektrine/vpn/wire_guard_adapter_test.exs | 25 + .../elektrine_vpn/test/elektrine/vpn_test.exs | 497 + apps/elektrine_vpn/test/support/data_case.ex | 58 + apps/elektrine_vpn/test/test_helper.exs | 6 + apps/elektrine_web/.gitignore | 22 + apps/elektrine_web/README.md | 20 + apps/elektrine_web/lib/elektrine_web.ex | 128 + .../lib/elektrine_web/account_settings.ex | 67 + .../lib/elektrine_web/admin_security.ex | 656 + .../lib/elektrine_web/atomine_gate.ex | 558 + .../lib/elektrine_web/atomine_pow.ex | 70 + .../lib/elektrine_web/canonical_url.ex | 50 + .../elektrine_web/channels/call_channel.ex | 437 + .../elektrine_web/channels/mobile_channel.ex | 1046 ++ .../lib/elektrine_web/channels/presence.ex | 212 + .../lib/elektrine_web/channels/user_socket.ex | 110 + .../elektrine_web/channels/voice_channel.ex | 189 + .../lib/elektrine_web/client_ip.ex | 365 + .../components/core_components.ex | 616 + .../components/datetime/local_time.ex | 147 + .../elektrine_web/components/emoji_picker.ex | 259 + .../components/layout/announcement.ex | 113 + .../elektrine_web/components/layout/header.ex | 31 + .../components/layout/navigation.ex | 35 + .../lib/elektrine_web/components/layouts.ex | 664 + .../components/layouts/admin.html.heex | 203 + .../components/layouts/app.html.heex | 812 + .../components/layouts/profile.html.heex | 284 + .../components/layouts/root.html.heex | 78 + .../components/notification/bell.ex | 48 + .../components/platform/e_nav.ex | 96 + .../components/platform/elektrine_nav.ex | 24 + .../components/presence/helpers.ex | 51 + .../components/profile/account_extras.ex | 170 + .../components/profile/containers.ex | 226 + .../components/profile/modals.ex | 140 + .../components/social/fediverse_follow.ex | 21 + .../components/social/post_actions.ex | 33 + .../components/social/post_reactions.ex | 13 + .../components/social/post_utilities.ex | 216 + .../components/social/youtube_preview.ex | 144 + .../components/ui/action_toolbar.ex | 18 + .../lib/elektrine_web/components/ui/badge.ex | 234 + .../elektrine_web/components/ui/brand_icon.ex | 229 + .../lib/elektrine_web/components/ui/button.ex | 172 + .../lib/elektrine_web/components/ui/card.ex | 166 + .../elektrine_web/components/ui/dropdown.ex | 75 + .../components/ui/empty_state.ex | 130 + .../components/ui/floating_panel.ex | 19 + .../lib/elektrine_web/components/ui/form.ex | 266 + .../lib/elektrine_web/components/ui/icon.ex | 57 + .../components/ui/image_modal.ex | 13 + .../elektrine_web/components/ui/loading.ex | 242 + .../lib/elektrine_web/components/ui/modal.ex | 189 + .../components/ui/pill_switcher.ex | 48 + .../components/ui/section_header.ex | 75 + .../elektrine_web/components/ui/stats_row.ex | 26 + .../lib/elektrine_web/components/ui/table.ex | 112 + .../components/ui/uplot_chart.ex | 170 + .../components/user/hover_card.ex | 13 + .../components/user/username_effects.ex | 4 + .../components/user/verification_badge.ex | 4 + .../admin/announcements_controller.ex | 74 + .../controllers/admin/announcements_html.ex | 12 + .../admin/communities_controller.ex | 421 + .../controllers/admin/communities_html.ex | 11 + .../admin/deletion_requests_controller.ex | 225 + .../admin/deletion_requests_html.ex | 11 + .../admin/email_delivery_controller.ex | 124 + .../controllers/admin/email_delivery_html.ex | 22 + .../admin/email_delivery_html/index.html.heex | 339 + .../controllers/admin/haraka_controller.ex | 15 + .../controllers/admin/haraka_html.ex | 43 + .../admin/haraka_html/index.html.heex | 339 + .../admin/invite_codes_controller.ex | 150 + .../controllers/admin/invite_codes_html.ex | 12 + .../admin/moderation_controller.ex | 216 + .../controllers/admin/moderation_html.ex | 11 + .../admin/monitoring_controller.ex | 559 + .../controllers/admin/monitoring_html.ex | 14 + .../controllers/admin/security_controller.ex | 194 + .../controllers/admin/security_html.ex | 9 + .../admin/subscriptions_controller.ex | 152 + .../controllers/admin/subscriptions_html.ex | 12 + .../controllers/admin/users_controller.ex | 1001 + .../controllers/admin/users_html.ex | 14 + .../admin_audit_logs_controller.ex | 45 + .../controllers/admin_audit_logs_html.ex | 8 + .../admin_audit_logs_html/index.html.heex | 176 + .../controllers/admin_controller.ex | 467 + .../elektrine_web/controllers/admin_html.ex | 490 + .../admin_html/account_lookup.html.heex | 306 + .../admin_html/active_users.html.heex | 197 + .../admin_html/announcements.html.heex | 186 + .../controllers/admin_html/ban.html.heex | 89 + .../admin_html/communities.html.heex | 398 + .../admin_html/content_moderation.html.heex | 449 + .../admin_html/dashboard.html.heex | 918 + .../admin_html/deletion_requests.html.heex | 223 + .../controllers/admin_html/edit.html.heex | 737 + .../admin_html/edit_announcement.html.heex | 135 + .../admin_html/edit_invite_code.html.heex | 277 + .../admin_html/edit_product.html.heex | 244 + .../controllers/admin_html/elevate.html.heex | 88 + .../admin_html/imap_users.html.heex | 183 + .../admin_html/invite_codes.html.heex | 338 + .../controllers/admin_html/new.html.heex | 100 + .../admin_html/new_announcement.html.heex | 134 + .../admin_html/new_invite_code.html.heex | 147 + .../admin_html/new_product.html.heex | 241 + .../admin_html/operations.html.heex | 407 + .../admin_html/pop3_users.html.heex | 183 + .../admin_html/show_community.html.heex | 405 + .../show_deletion_request.html.heex | 208 + .../admin_html/subscriptions.html.heex | 310 + .../admin_html/two_factor_status.html.heex | 229 + .../admin_html/unsubscribe_stats.html.heex | 186 + .../controllers/admin_html/users.html.heex | 579 + .../api/account_birthday_controller.ex | 103 + .../api/account_credential_controller.ex | 181 + .../api/account_directory_controller.ex | 143 + .../controllers/api/account_json.ex | 296 + .../api/account_note_controller.ex | 48 + .../api/account_registration_controller.ex | 156 + .../api/account_relationship_controller.ex | 638 + .../api/account_search_controller.ex | 277 + .../api/account_status_controller.ex | 124 + .../api/announcement_controller.ex | 106 + .../controllers/api/app_controller.ex | 158 + .../api/atomine_attestation_controller.ex | 136 + .../controllers/api/auth_controller.ex | 243 + .../controllers/api/backup_controller.ex | 132 + .../controllers/api/bookmark_controller.ex | 61 + .../api/bookmark_folder_controller.ex | 75 + .../controllers/api/calendar_controller.ex | 321 + .../controllers/api/chat_compat_controller.ex | 282 + .../api/custom_emoji_controller.ex | 36 + .../controllers/api/device_controller.ex | 124 + .../api/direct_conversation_controller.ex | 294 + .../api/domain_block_controller.ex | 49 + .../controllers/api/export_controller.ex | 274 + .../controllers/api/favourite_controller.ex | 60 + .../controllers/api/filter_controller.ex | 148 + .../api/follow_request_controller.ex | 81 + .../api/global_search_controller.ex | 146 + .../controllers/api/instance_controller.ex | 335 + .../controllers/api/kairo_controller.ex | 304 + .../controllers/api/list_controller.ex | 223 + .../controllers/api/marker_controller.ex | 42 + .../controllers/api/mcp_controller.ex | 136 + .../api/media_attachment_controller.ex | 201 + .../controllers/api/meta_controller.ex | 531 + .../api/notification_controller.ex | 541 + .../api/outgoing_follow_request_controller.ex | 56 + .../controllers/api/pagination_headers.ex | 60 + .../api/password_reset_controller.ex | 88 + .../controllers/api/poll_controller.ex | 90 + .../controllers/api/poll_json.ex | 41 + .../controllers/api/preference_controller.ex | 37 + .../controllers/api/preferences_controller.ex | 27 + .../controllers/api/proof_controller.ex | 310 + .../api/push_subscription_controller.ex | 102 + .../api/relationship_import_controller.ex | 198 + .../controllers/api/relationship_json.ex | 77 + .../controllers/api/report_controller.ex | 338 + .../elektrine_web/controllers/api/response.ex | 67 + .../api/scheduled_status_controller.ex | 208 + .../controllers/api/scrobble_controller.ex | 82 + .../controllers/api/search_controller.ex | 204 + .../controllers/api/settings_controller.ex | 498 + .../controllers/api/static_site_controller.ex | 364 + .../api/status_action_controller.ex | 396 + .../controllers/api/status_json.ex | 650 + .../controllers/api/status_pin_controller.ex | 68 + .../api/status_reaction_controller.ex | 152 + .../controllers/api/status_read_controller.ex | 333 + .../controllers/api/suggestion_controller.ex | 91 + .../controllers/api/tag_controller.ex | 172 + .../controllers/api/timeline_controller.ex | 96 + .../controllers/api/trend_controller.ex | 210 + .../two_factor_authentication_controller.ex | 171 + .../controllers/api/user_controller.ex | 108 + .../controllers/api/utility_controller.ex | 306 + .../controllers/api/webhook_controller.ex | 318 + .../controllers/autoconfig_controller.ex | 253 + .../bluesky_identity_controller.ex | 42 + .../controllers/caddy_tls_controller.ex | 162 + .../controllers/captcha_controller.ex | 20 + .../controllers/changeset_json.ex | 13 + .../connected_account_controller.ex | 248 + .../controllers/dav/calendar_controller.ex | 558 + .../controllers/dav/drive_controller.ex | 379 + .../controllers/dav/principal_controller.ex | 37 + .../controllers/dav_controller.ex | 74 + .../controllers/drive_controller.ex | 55 + .../controllers/drive_share_controller.ex | 155 + .../elektrine_web/controllers/error_html.ex | 17 + .../controllers/error_html/403.html.heex | 52 + .../controllers/error_html/404.html.heex | 52 + .../controllers/error_html/413.html.heex | 61 + .../controllers/error_html/500.html.heex | 52 + .../elektrine_web/controllers/error_json.ex | 26 + .../controllers/fallback_controller.ex | 83 + .../controllers/health_controller.ex | 12 + .../internal_acme_dns_controller.ex | 134 + .../controllers/internal_dns_controller.ex | 14 + .../internal_edge_proxy_controller.ex | 23 + .../controllers/link_controller.ex | 106 + .../controllers/locale_controller.ex | 62 + .../controllers/mail_security_controller.ex | 69 + .../messaging_federation_controller.ex | 779 + .../controllers/nerve_extension_controller.ex | 392 + .../controllers/oidc_client_controller.ex | 188 + .../controllers/oidc_client_html.ex | 131 + .../oidc_client_html/edit.html.heex | 69 + .../oidc_client_html/index.html.heex | 133 + .../oidc_client_html/new.html.heex | 29 + .../controllers/oidc_controller.ex | 624 + .../controllers/oidc_grant_controller.ex | 33 + .../controllers/oidc_grant_html.ex | 5 + .../oidc_grant_html/index.html.heex | 79 + .../elektrine_web/controllers/oidc_html.ex | 31 + .../controllers/oidc_html/authorize.html.heex | 59 + .../controllers/own_root_controller.ex | 102 + .../controllers/page_controller.ex | 100 + .../elektrine_web/controllers/page_html.ex | 8 + .../controllers/page_html/canary.html.heex | 88 + .../controllers/passkey_controller.ex | 141 + .../controllers/password_reset_controller.ex | 264 + .../controllers/password_reset_html.ex | 10 + .../password_reset_html/edit.html.heex | 37 + .../password_reset_html/new.html.heex | 44 + .../private_attachment_controller.ex | 85 + .../controllers/profile_controller.ex | 973 + .../elektrine_web/controllers/profile_html.ex | 31 + .../controllers/recovery_email_controller.ex | 51 + .../controllers/recovery_email_html.ex | 10 + .../recovery_email_html/error.html.heex | 39 + .../recovery_email_html/success.html.heex | 40 + .../registration_payment_controller.ex | 57 + .../controllers/registration_payment_html.ex | 9 + .../registration_payment_html/show.html.heex | 60 + .../controllers/sitemap_controller.ex | 175 + .../elektrine_web/controllers/sitemap_html.ex | 5 + .../controllers/sitemap_xml/index.xml.eex | 11 + .../controllers/stripe_webhook_controller.ex | 91 + .../controllers/two_factor_controller.ex | 222 + .../controllers/two_factor_html.ex | 8 + .../controllers/two_factor_html/new.html.heex | 64 + .../user_registration_controller.ex | 246 + .../controllers/user_session_controller.ex | 282 + .../controllers/user_settings_controller.ex | 668 + .../controllers/user_settings_html.ex | 5 + .../user_settings_html/delete.html.heex | 55 + .../user_settings_html/edit.html.heex | 267 + .../edit_password.html.heex | 111 + .../two_factor_manage.html.heex | 157 + .../two_factor_new_codes.html.heex | 46 + .../two_factor_setup.html.heex | 222 + .../lib/elektrine_web/dav/properties.ex | 164 + .../lib/elektrine_web/dav/response_helpers.ex | 356 + .../lib/elektrine_web/endpoint.ex | 108 + .../lib/elektrine_web/gettext.ex | 25 + .../lib/elektrine_web/github_oidc.ex | 76 + .../lib/elektrine_web/github_webhooks.ex | 45 + .../lib/elektrine_web/html_helpers.ex | 1102 ++ .../lib/elektrine_web/json_log_formatter.ex | 64 + .../live/admin_live/badge_management.ex | 176 + .../admin_live/badge_management.html.heex | 290 + .../live/admin_live/bluesky_bridge.ex | 585 + .../elektrine_web/live/admin_live/emojis.ex | 278 + .../live/admin_live/emojis.html.heex | 334 + .../live/admin_live/federation.ex | 1517 ++ .../live/admin_live/messaging_federation.ex | 614 + .../elektrine_web/live/admin_live/relays.ex | 803 + .../live/admin_live/reports_dashboard.ex | 1297 ++ .../elektrine_web/live/announcement_cache.ex | 20 + .../live/atomine_proofs_live/show.ex | 115 + .../live/atomine_proofs_live/show.html.heex | 305 + .../lib/elektrine_web/live/auth_hooks.ex | 550 + .../lib/elektrine_web/live/auth_live/login.ex | 129 + .../live/auth_live/password_reset.ex | 122 + .../live/auth_live/password_reset_edit.ex | 104 + .../elektrine_web/live/auth_live/register.ex | 434 + .../live/auth_live/two_factor.ex | 84 + .../elektrine_web/live/booking_live/cancel.ex | 71 + .../elektrine_web/live/booking_live/manage.ex | 360 + .../elektrine_web/live/booking_live/show.ex | 282 + .../elektrine_web/live/calendar_live/index.ex | 228 + .../live/calendar_live/index.html.heex | 543 + .../operations/calendar_operations.ex | 353 + .../live/components/report_modal.ex | 253 + .../lib/elektrine_web/live/drive_live.ex | 880 + .../elektrine_web/live/drive_live.html.heex | 1058 ++ .../lib/elektrine_web/live/e_nav_live.ex | 82 + .../lib/elektrine_web/live/friends_live.ex | 669 + .../elektrine_web/live/friends_live.html.heex | 682 + .../live/helpers/post_state_helpers.ex | 330 + .../live/hooks/current_path_hook.ex | 22 + .../live/hooks/notification_count_hook.ex | 152 + .../live/hooks/platform_module_hook.ex | 21 + .../elektrine_web/live/hooks/presence_hook.ex | 271 + .../elektrine_web/live/hooks/timezone_hook.ex | 44 + .../elektrine_web/live/kairo_live/index.ex | 1706 ++ .../live/link_archive_live/index.ex | 153 + .../lib/elektrine_web/live/nav_badge_live.ex | 87 + .../live/notification_helpers.ex | 254 + .../elektrine_web/live/notifications_live.ex | 782 + .../live/notifications_live.html.heex | 442 + .../lib/elektrine_web/live/onboarding_live.ex | 498 + .../lib/elektrine_web/live/page_live/about.ex | 122 + .../elektrine_web/live/page_live/contact.ex | 56 + .../live/page_live/dev_flash_test.ex | 72 + .../lib/elektrine_web/live/page_live/faq.ex | 128 + .../lib/elektrine_web/live/page_live/home.ex | 668 + .../lib/elektrine_web/live/page_live/legal.ex | 125 + .../elektrine_web/live/page_live/privacy.ex | 286 + .../lib/elektrine_web/live/page_live/terms.ex | 167 + .../elektrine_web/live/post_interactions.ex | 122 + .../live/profile_live/analytics.ex | 32 + .../live/profile_live/analytics.html.heex | 259 + .../live/profile_live/design_sections.ex | 472 + .../profile_live/design_theme_sections.ex | 290 + .../live/profile_live/domain_analytics.ex | 664 + .../profile_live/domain_analytics.html.heex | 522 + .../live/profile_live/domains.ex | 1217 ++ .../elektrine_web/live/profile_live/edit.ex | 2398 +++ .../live/profile_live/edit.html.heex | 2407 +++ .../live/profile_live/edit_sections.ex | 452 + .../live/profile_live/effects_sections.ex | 374 + .../elektrine_web/live/profile_live/show.ex | 945 + .../live/profile_live/show.html.heex | 2385 +++ .../lib/elektrine_web/live/proofs_live.ex | 953 + .../live/recovery_email_live/error.ex | 60 + .../live/recovery_email_live/success.ex | 62 + .../lib/elektrine_web/live/search_live.ex | 2110 +++ .../live/settings_live/app_passwords.ex | 465 + .../live/settings_live/delete_account.ex | 72 + .../live/settings_live/edit_password.ex | 133 + .../live/settings_live/master_password.ex | 393 + .../live/settings_live/passkey_manage.ex | 405 + .../elektrine_web/live/settings_live/rss.ex | 272 + .../live/settings_live/two_factor_manage.ex | 216 + .../live/settings_live/two_factor_setup.ex | 318 + .../lib/elektrine_web/live/storage_live.ex | 515 + .../elektrine_web/live/storage_live.html.heex | 456 + .../lib/elektrine_web/live/subscribe_live.ex | 552 + .../elektrine_web/live/user_settings_live.ex | 2124 +++ .../live/user_settings_live.html.heex | 3280 ++++ .../lib/elektrine_web/mail_autoconfig.ex | 17 + .../lib/elektrine_web/mcp/protocol.ex | 173 + .../lib/elektrine_web/mcp/tool_registry.ex | 350 + .../lib/elektrine_web/mcp/tools.ex | 357 + .../lib/elektrine_web/optional_module.ex | 14 + .../elektrine_web/platform/integrations.ex | 718 + .../platform/module_delegates.ex | 9 + .../lib/elektrine_web/platform_access.ex | 324 + .../elektrine_web/plugs/activitypub_accept.ex | 14 + .../plugs/activitypub_rate_limit.ex | 43 + .../plugs/activitypub_request_guard.ex | 43 + .../lib/elektrine_web/plugs/api_auth.ex | 266 + .../lib/elektrine_web/plugs/api_rate_limit.ex | 176 + .../plugs/block_private_upload_paths.ex | 38 + .../lib/elektrine_web/plugs/cache_raw_body.ex | 89 + .../plugs/conditional_live_reloader.ex | 13 + .../elektrine_web/plugs/csrf_error_handler.ex | 85 + .../lib/elektrine_web/plugs/dav_auth.ex | 163 + .../lib/elektrine_web/plugs/dav_rate_limit.ex | 55 + .../lib/elektrine_web/plugs/dns_edge_proxy.ex | 180 + .../lib/elektrine_web/plugs/enforce_https.ex | 52 + .../plugs/ensure_http_signature_plug.ex | 118 + .../plugs/http_signature_plug.ex | 43 + .../elektrine_web/plugs/internal_api_auth.ex | 147 + .../lib/elektrine_web/plugs/locale.ex | 127 + .../plugs/messaging_federation_auth.ex | 142 + .../elektrine_web/plugs/notification_count.ex | 19 + .../elektrine_web/plugs/optional_delegate.ex | 30 + .../lib/elektrine_web/plugs/pat_auth.ex | 259 + .../lib/elektrine_web/plugs/profile_csp.ex | 42 + .../plugs/profile_custom_domain.ex | 142 + .../elektrine_web/plugs/profile_subdomain.ex | 246 + .../plugs/receive_only_email_domain_host.ex | 34 + .../elektrine_web/plugs/request_telemetry.ex | 49 + .../plugs/require_module_access.ex | 23 + .../plugs/require_platform_module.ex | 21 + .../elektrine_web/plugs/runtime_session.ex | 20 + .../elektrine_web/plugs/security_headers.ex | 175 + .../lib/elektrine_web/plugs/site_challenge.ex | 91 + .../elektrine_web/plugs/site_page_tracking.ex | 116 + .../elektrine_web/plugs/static_site_plug.ex | 363 + .../lib/elektrine_web/plugs/timezone_plug.ex | 20 + .../lib/elektrine_web/plugs/tor_aware.ex | 21 + .../plugs/webdav_method_override.ex | 45 + .../lib/elektrine_web/post_navigation.ex | 33 + .../lib/elektrine_web/profile_navigation.ex | 64 + .../lib/elektrine_web/profile_theme.ex | 193 + .../elektrine_web/lib/elektrine_web/router.ex | 1962 ++ .../lib/elektrine_web/routes/chat.ex | 209 + .../lib/elektrine_web/routes/dns.ex | 47 + .../lib/elektrine_web/routes/email.ex | 250 + .../lib/elektrine_web/routes/nerve.ex | 30 + .../lib/elektrine_web/routes/social.ex | 252 + .../lib/elektrine_web/routes/uptime.ex | 11 + .../lib/elektrine_web/routes/vpn.ex | 65 + .../lib/elektrine_web/safe_live_navigation.ex | 80 + .../elektrine_web/scrubbers/remote_content.ex | 58 + .../lib/elektrine_web/session_config.ex | 112 + .../lib/elektrine_web/telemetry.ex | 138 + .../elektrine_web/telemetry_measurements.ex | 63 + .../lib/elektrine_web/url_helpers.ex | 61 + .../lib/elektrine_web/user_auth.ex | 756 + .../lib/elektrine_web/web_search.ex | 188 + apps/elektrine_web/mix.exs | 142 + .../priv/canary/canary-public-key.asc | 13 + apps/elektrine_web/priv/canary/current.md | 42 + apps/elektrine_web/priv/canary/current.md.asc | 7 + .../priv/nerve-extension/background.js | 1064 ++ .../priv/nerve-extension/content.js | 1793 ++ .../priv/nerve-extension/launcher.js | 261 + .../priv/nerve-extension/lib/api.js | 199 + .../priv/nerve-extension/lib/crypto.js | 214 + .../priv/nerve-extension/lib/kairo_capture.js | 58 + .../priv/nerve-extension/lib/storage.js | 179 + .../priv/nerve-extension/lib/theme.js | 64 + .../priv/nerve-extension/manager.html | 107 + .../priv/nerve-extension/manifest.json | 41 + .../priv/nerve-extension/options.html | 60 + .../priv/nerve-extension/options.js | 395 + .../priv/nerve-extension/package-lock.json | 10 + .../priv/nerve-extension/package.json | 5 + .../priv/nerve-extension/popup.html | 41 + .../priv/nerve-extension/popup.js | 722 + .../priv/nerve-extension/styles.css | 693 + .../elektrine/components/user/avatar_test.exs | 53 + .../test/elektrine_web/atomine_pow_test.exs | 61 + .../channels/call_channel_test.exs | 235 + .../channels/voice_channel_test.exs | 139 + .../test/elektrine_web/client_ip_test.exs | 266 + .../components/emoji_picker_test.exs | 51 + .../elektrine_web/components/layouts_test.exs | 272 + .../components/presence_helpers_test.exs | 91 + .../components/social/post_actions_test.exs | 170 + .../components/social/post_utilities_test.exs | 373 + .../social/youtube_preview_test.exs | 36 + .../components/ui/image_modal_test.exs | 60 + .../components/ui/uplot_chart_test.exs | 40 + .../components/user/hover_card_test.exs | 109 + .../components/user/username_effects_test.exs | 69 + .../controllers/activitypub_test.exs | 2379 +++ .../admin/announcements_controller_test.exs | 73 + .../admin/chat_messages_controller_test.exs | 151 + .../admin/communities_controller_test.exs | 61 + .../admin/custom_domains_controller_test.exs | 116 + .../deletion_requests_controller_test.exs | 180 + .../controllers/admin/elevate_page_test.exs | 82 + .../admin/email_delivery_controller_test.exs | 303 + .../admin/haraka_controller_test.exs | 213 + .../admin/invite_codes_controller_test.exs | 201 + .../admin/messages_controller_test.exs | 268 + .../admin/moderation_controller_test.exs | 61 + .../admin/monitoring_controller_test.exs | 266 + .../admin/users_controller_test.exs | 156 + .../controllers/admin/vpn_controller_test.exs | 48 + .../api/account_birthday_controller_test.exs | 70 + .../account_credential_controller_test.exs | 330 + .../api/account_directory_controller_test.exs | 122 + .../account_registration_controller_test.exs | 137 + .../account_relationship_controller_test.exs | 616 + ...nt_relationship_follow_controller_test.exs | 133 + .../api/account_search_controller_test.exs | 423 + .../api/account_status_controller_test.exs | 265 + .../api/announcement_controller_test.exs | 115 + .../controllers/api/app_controller_test.exs | 191 + .../atomine_attestation_controller_test.exs | 87 + .../controllers/api/auth_controller_test.exs | 297 + .../api/backup_controller_test.exs | 204 + .../api/bookmark_controller_test.exs | 89 + .../api/chat_compat_controller_test.exs | 138 + .../api/conversation_controller_test.exs | 323 + .../api/custom_emoji_controller_test.exs | 52 + .../direct_conversation_controller_test.exs | 267 + .../controllers/api/dns_controller_test.exs | 196 + .../api/domain_block_controller_test.exs | 66 + .../controllers/api/email_controller_test.exs | 148 + .../api/export_controller_test.exs | 147 + .../api/ext_v1_controller_test.exs | 937 + .../api/favourite_controller_test.exs | 84 + .../api/filter_controller_test.exs | 59 + .../api/instance_controller_test.exs | 174 + .../controllers/api/kairo_controller_test.exs | 363 + .../controllers/api/list_controller_test.exs | 237 + .../controllers/api/mcp_controller_test.exs | 539 + .../api/media_attachment_controller_test.exs | 221 + .../api/notification_controller_test.exs | 684 + .../api/password_reset_controller_test.exs | 192 + .../controllers/api/poll_controller_test.exs | 150 + .../api/preference_controller_test.exs | 48 + .../api/preferences_controller_test.exs | 59 + .../api/push_subscription_controller_test.exs | 90 + .../relationship_import_controller_test.exs | 232 + .../api/report_controller_test.exs | 505 + .../api/scheduled_status_controller_test.exs | 122 + .../api/scrobble_controller_test.exs | 95 + .../api/search_controller_test.exs | 130 + .../api/server_controller_test.exs | 149 + .../api/settings_controller_test.exs | 303 + .../api/social_controller_test.exs | 355 + .../api/status_action_controller_test.exs | 341 + .../api/status_pin_controller_test.exs | 71 + .../api/status_reaction_controller_test.exs | 153 + .../api/status_read_controller_test.exs | 523 + .../api/suggestion_controller_test.exs | 101 + .../controllers/api/tag_controller_test.exs | 177 + .../api/timeline_controller_test.exs | 243 + .../controllers/api/trend_controller_test.exs | 106 + ..._factor_authentication_controller_test.exs | 129 + .../api/utility_controller_test.exs | 354 + .../controllers/api/vpn_controller_test.exs | 84 + .../api/vpn_registration_controller_test.exs | 72 + .../api/webhook_controller_test.exs | 234 + .../controllers/arblarg_conformance_test.exs | 520 + .../autoconfig_controller_test.exs | 113 + .../controllers/caddy_tls_controller_test.exs | 303 + .../connected_account_controller_test.exs | 38 + .../dav/address_book_controller_test.exs | 443 + .../dav/calendar_controller_test.exs | 657 + .../controllers/dav/drive_controller_test.exs | 127 + .../controllers/drive_controller_test.exs | 135 + .../drive_share_controller_test.exs | 219 + .../controllers/email_controller_test.exs | 419 + .../controllers/email_delivery_smoke_test.exs | 289 + .../controllers/error_html_test.exs | 18 + .../controllers/error_json_test.exs | 12 + .../external_interaction_controller_test.exs | 80 + .../haraka_webhook_controller_test.exs | 1535 ++ .../controllers/health_controller_test.exs | 17 + .../impersonation_controller_test.exs | 64 + .../internal_acme_dns_controller_test.exs | 104 + .../internal_dns_controller_test.exs | 49 + .../controllers/jmap_controller_test.exs | 670 + .../controllers/link_controller_test.exs | 77 + .../mail_security_controller_test.exs | 51 + .../media_proxy_controller_test.exs | 68 + .../messaging_federation_controller_test.exs | 1261 ++ .../messaging_federation_rate_limit_test.exs | 196 + .../nerve_extension_controller_test.exs | 211 + .../oidc_client_controller_test.exs | 124 + .../controllers/oidc_controller_test.exs | 363 + .../oidc_grant_controller_test.exs | 53 + .../controllers/own_root_controller_test.exs | 304 + .../controllers/page_controller_test.exs | 64 + .../password_reset_controller_test.exs | 348 + .../private_attachment_controller_test.exs | 207 + .../controllers/profile_controller_test.exs | 494 + .../recovery_email_controller_test.exs | 156 + .../registration_payment_controller_test.exs | 137 + .../unsubscribe_controller_test.exs | 17 + .../user_registration_controller_test.exs | 242 + .../user_session_controller_test.exs | 67 + .../user_settings_controller_test.exs | 121 + .../controllers/user_settings_html_test.exs | 17 + .../controllers/vpn_api_controller_test.exs | 97 + .../controllers/wkd_controller_test.exs | 130 + .../test/elektrine_web/features/auth_test.exs | 35 + .../test/elektrine_web/features/chat_test.exs | 19 + .../elektrine_web/features/email_test.exs | 19 + .../elektrine_web/features/home_page_test.exs | 35 + .../elektrine_web/features/profile_test.exs | 84 + .../elektrine_web/features/timeline_test.exs | 41 + .../elektrine_web/html_helpers_actor_test.exs | 330 + .../live/admin_live/badge_management_test.exs | 43 + .../live/admin_live/emojis_test.exs | 68 + .../live/admin_live/federation_test.exs | 157 + .../live/admin_live/relays_test.exs | 50 + .../admin_live/reports_dashboard_test.exs | 149 + .../live/app_passwords_live_test.exs | 149 + .../live/atomine_proofs_live/show_test.exs | 78 + .../live/auth_live/register_security_test.exs | 18 + .../elektrine_web/live/auth_pow_copy_test.exs | 87 + .../live/authenticated_routes_live_test.exs | 95 + .../elektrine_web/live/booking_live_test.exs | 176 + .../live/booking_manage_live_test.exs | 228 + .../operations/calendar_operations_test.exs | 59 + .../live/chat_live/index_test.exs | 33 + .../operations/call_info_operations_test.exs | 36 + .../chat_live/operations/helpers_test.exs | 77 + .../message_info_operations_test.exs | 116 + .../live/chat_live/router_test.exs | 58 + .../live/chat_live_index_test.exs | 687 + .../live/chat_live_threads_test.exs | 175 + .../live/community_post_test.exs | 474 + .../live/discussions_index_live_test.exs | 650 + .../operations/member_operations_test.exs | 27 + .../live/discussions_settings_test.exs | 58 + .../elektrine_web/live/drive_live_test.exs | 152 + .../live/email_compose_live_test.exs | 117 + .../compose_paragraph_spacing_test.exs | 66 + .../live/email_live/compose_test.exs | 252 + .../live/email_live/email_helpers_test.exs | 33 + .../live/email_live/index_test.exs | 318 + .../live/email_live/search_test.exs | 50 + .../email_live/settings_operations_test.exs | 174 + .../email_live/show_private_mailbox_test.exs | 90 + .../live/email_live/show_test.exs | 180 + .../email_live/show_thread_context_test.exs | 213 + .../elektrine_web/live/friends_live_test.exs | 112 + .../elektrine_web/live/gallery_live_test.exs | 361 + .../live/hashtag_live_show_test.exs | 101 + .../live/helpers/post_state_helpers_test.exs | 37 + .../elektrine_web/live/kairo_live_test.exs | 225 + .../live/link_archive_live_test.exs | 108 + .../elektrine_web/live/list_live_test.exs | 252 + .../elektrine_web/live/locale_live_test.exs | 35 + .../live/master_password_live_test.exs | 137 + .../elektrine_web/live/nerve_live_test.exs | 174 + .../live/notifications_live_test.exs | 324 + .../live/page_live/home_test.exs | 29 + .../live/page_live/static_pages_test.exs | 55 + .../elektrine_web/live/paige_live_test.exs | 490 + .../live/passkey_manage_live_test.exs | 70 + .../live/profile_live/edit_security_test.exs | 183 + .../live/profile_live/show_security_test.exs | 22 + .../profile_live_domain_analytics_test.exs | 128 + .../live/profile_live_domains_test.exs | 86 + .../elektrine_web/live/proofs_live_test.exs | 170 + .../live/remote_post_live/seo_test.exs | 45 + .../live/remote_post_live_show_test.exs | 2317 +++ .../remote_user_live/follow_button_test.exs | 238 + .../live/remote_user_live/seo_test.exs | 69 + .../live/remote_user_live/show_test.exs | 262 + .../elektrine_web/live/storage_live_test.exs | 54 + .../live/subscribe_live_test.exs | 73 + .../live/timeline_filters_test.exs | 1873 ++ .../operations/image_operations_test.exs | 61 + .../operations/reply_operations_test.exs | 18 + .../operations/social_operations_test.exs | 51 + .../reply_context_previews_test.exs | 85 + .../live/timeline_post_detail_test.exs | 639 + .../live/user_invite_codes_live_test.exs | 128 + .../live/user_settings_live_test.exs | 404 + .../live/user_settings_pgp_test.exs | 299 + .../elektrine_web/live/videos_live_test.exs | 312 + .../platform/admin_subset_test.exs | 118 + .../platform/shared_routes_subset_test.exs | 88 + .../elektrine_web/platform_access_test.exs | 51 + .../plugs/api_rate_limit_test.exs | 147 + .../elektrine_web/plugs/dav_auth_test.exs | 319 + .../plugs/dns_edge_proxy_test.exs | 183 + .../plugs/email_ownership_guard_test.exs | 37 + .../plugs/enforce_https_test.exs | 43 + .../plugs/http_signature_plug_test.exs | 493 + .../plugs/optional_delegate_test.exs | 46 + .../plugs/profile_custom_domain_test.exs | 85 + .../plugs/profile_subdomain_test.exs | 374 + .../receive_only_email_domain_host_test.exs | 34 + .../plugs/require_platform_module_test.exs | 59 + .../plugs/security_headers_test.exs | 28 + .../plugs/site_challenge_test.exs | 75 + .../plugs/static_site_plug_test.exs | 451 + .../test/elektrine_web/presence_test.exs | 255 + .../elektrine_web/profile_navigation_test.exs | 55 + .../safe_live_navigation_test.exs | 47 + .../test/elektrine_web/user_auth_test.exs | 500 + .../test/elektrine_web/web_search_test.exs | 231 + .../test/support/channel_case.ex | 23 + apps/elektrine_web/test/support/conn_case.ex | 38 + apps/elektrine_web/test/support/data_case.ex | 58 + .../support/fixtures/accounts_fixtures.ex | 59 + .../test/support/fixtures/email_fixtures.ex | 55 + .../test/support/fixtures/social_fixtures.ex | 150 + apps/elektrine_web/test/test_helper.exs | 14 + apps/kairo/lib/kairo.ex | 721 + apps/kairo/lib/kairo/project.ex | 73 + apps/kairo/lib/kairo/source.ex | 278 + apps/kairo/lib/kairo/url_fetch_worker.ex | 471 + apps/kairo/mix.exs | 54 + .../test/kairo/url_fetch_worker_test.exs | 409 + apps/kairo/test/kairo_test.exs | 665 + apps/kairo/test/support/accounts_fixtures.ex | 28 + apps/kairo/test/support/data_case.ex | 36 + apps/kairo/test/test_helper.exs | 2 + apps/paige/lib/paige.ex | 509 + apps/paige/lib/paige/application.ex | 11 + apps/paige/lib/paige/http.ex | 93 + apps/paige/lib/paige/kind.ex | 25 + apps/paige/lib/paige/provider.ex | 10 + apps/paige/lib/paige/providers/brave.ex | 237 + .../paige/lib/paige/providers/duck_duck_go.ex | 191 + apps/paige/lib/paige/providers/github.ex | 112 + apps/paige/lib/paige/providers/hacker_news.ex | 112 + apps/paige/lib/paige/providers/wiby.ex | 126 + apps/paige/lib/paige/providers/wikipedia.ex | 101 + apps/paige/lib/paige/result.ex | 21 + apps/paige/lib/paige/scraper_throttle.ex | 60 + apps/paige/mix.exs | 33 + apps/paige/test/paige/http_test.exs | 71 + apps/paige/test/paige/providers_test.exs | 367 + .../test/paige/scraper_providers_test.exs | 115 + .../test/paige/scraper_throttle_test.exs | 17 + apps/paige/test/paige_test.exs | 326 + apps/paige/test/test_helper.exs | 1 + clients/README.md | 8 + clients/nerve-extension/background.js | 1064 ++ clients/nerve-extension/content.js | 1793 ++ clients/nerve-extension/launcher.js | 261 + clients/nerve-extension/lib/api.js | 215 + clients/nerve-extension/lib/crypto.js | 214 + clients/nerve-extension/lib/kairo_capture.js | 58 + clients/nerve-extension/lib/storage.js | 179 + clients/nerve-extension/lib/theme.js | 68 + clients/nerve-extension/manager.html | 107 + clients/nerve-extension/manifest.json | 41 + clients/nerve-extension/options.html | 60 + clients/nerve-extension/options.js | 395 + clients/nerve-extension/package-lock.json | 10 + clients/nerve-extension/package.json | 5 + clients/nerve-extension/popup.html | 41 + clients/nerve-extension/popup.js | 722 + clients/nerve-extension/styles.css | 706 + config/config.exs | 522 + config/dev.exs | 277 + config/prod.exs | 25 + config/runtime.exs | 1147 ++ config/runtime/bluesky.exs | 58 + config/runtime/dns.exs | 231 + config/runtime/mail_protocols.exs | 72 + config/runtime/messaging_federation.exs | 121 + config/runtime/stripe.exs | 7 + config/runtime/uploads.exs | 84 + config/runtime/webrtc.exs | 111 + config/test.exs | 172 + deploy/caddy/Caddyfile | 80 + deploy/caddy/Caddyfile.baremetal | 84 + .../caddy/Caddyfile.baremetal.external-certs | 96 + .../Caddyfile.baremetal.wildcard-external | 96 + deploy/caddy/Caddyfile.external-certs | 99 + deploy/caddy/Caddyfile.wildcard-external | 92 + deploy/caddy/Dockerfile | 1 + deploy/docker/Dockerfile | 245 + deploy/docker/compose.core.yml | 101 + deploy/docker/compose.full.yml | 394 + deploy/docker/compose.magpie-network.yml | 17 + deploy/docker/docker-entrypoint.sh | 127 + deploy/docker/elektrine-compose.service | 16 + deploy/docker/initdb/010-extensions.sql | 2 + deploy/docker/start.sh | 347 + deploy/generated/.gitkeep | 1 + deploy/onion/torrc | 9 + deploy/terraform/vpn-fleet/.gitignore | 13 + deploy/terraform/vpn-fleet/README.md | 138 + .../examples/digitalocean/.terraform.lock.hcl | 26 + .../vpn-fleet/examples/digitalocean/main.tf | 33 + .../examples/digitalocean/variables.tf | 41 + .../examples/hetzner/.terraform.lock.hcl | 23 + .../vpn-fleet/examples/hetzner/main.tf | 33 + .../vpn-fleet/examples/hetzner/variables.tf | 39 + .../multi-provider/.terraform.lock.hcl | 70 + .../vpn-fleet/examples/multi-provider/main.tf | 74 + .../multi-provider/terraform.tfvars.example | 28 + .../examples/multi-provider/variables.tf | 37 + .../vpn-fleet/modules/digitalocean/main.tf | 73 + .../vpn-fleet/modules/digitalocean/outputs.tf | 4 + .../modules/digitalocean/variables.tf | 42 + .../vpn-fleet/modules/hetzner/main.tf | 61 + .../vpn-fleet/modules/hetzner/outputs.tf | 4 + .../vpn-fleet/modules/hetzner/variables.tf | 42 + .../vpn-fleet/modules/user-data/main.tf | 36 + .../vpn-fleet/modules/user-data/outputs.tf | 5 + .../user-data/templates/cloud-init.yaml.tftpl | 26 + .../vpn-fleet/modules/user-data/variables.tf | 50 + .../terraform/vpn-fleet/modules/vultr/main.tf | 59 + .../vpn-fleet/modules/vultr/outputs.tf | 4 + .../vpn-fleet/modules/vultr/variables.tf | 37 + docs/addons/onion.md | 43 + docs/api/external-api.md | 264 + docs/arblarg-1.0.md | 1101 ++ docs/architecture/composable-platform.md | 34 + docs/architecture/dns-module.md | 76 + docs/clients/mcp.md | 57 + docs/clients/nerve-extension.md | 12 + docs/clients/static-site-github-deploys.md | 17 + docs/security/warrant-canary.md | 38 + docs/self-hosting/README.md | 58 + docs/self-hosting/baremetal.md | 4 + docs/self-hosting/caddy.md | 130 + docs/self-hosting/core.md | 48 + docs/self-hosting/deploy-runbook.md | 127 + docs/self-hosting/docker.md | 457 + docs/self-hosting/mail.md | 350 + docs/self-hosting/turn.md | 60 + docs/self-hosting/vpn.md | 137 + elixir_buildpack.config | 2 + env/caddy.env.example | 44 + env/core.env.example | 162 + env/dev.env.example | 232 + env/mail.env.example | 63 + env/onion.env.example | 17 + env/presets/bluesky.env | 10 + env/presets/dns.env | 15 + env/presets/mail.env | 20 + env/presets/s3.env | 15 + env/presets/tor.env | 13 + env/presets/turn.env | 13 + env/presets/vpn.env | 16 + env/presets/wildcard-tls.env | 13 + env/vpn.env.example | 20 + external/arblarg/README.md | 16 + external/arblarg/benchmarks/latest.json | 26 + external/arblarg/interop/smoke.json | 22 + external/arblarg/schemas/v1/ban.upsert.json | 172 + .../arblarg/schemas/v1/dm.call.accept.json | 171 + external/arblarg/schemas/v1/dm.call.end.json | 181 + .../arblarg/schemas/v1/dm.call.invite.json | 192 + .../arblarg/schemas/v1/dm.call.reject.json | 174 + .../arblarg/schemas/v1/dm.call.signal.json | 193 + .../arblarg/schemas/v1/dm.message.create.json | 262 + external/arblarg/schemas/v1/envelope.json | 110 + .../arblarg/schemas/v1/invite.upsert.json | 178 + .../arblarg/schemas/v1/membership.upsert.json | 132 + .../arblarg/schemas/v1/message.create.json | 194 + .../arblarg/schemas/v1/message.delete.json | 55 + .../arblarg/schemas/v1/message.update.json | 190 + .../v1/moderation.action.recorded.json | 104 + .../v1/permission.overwrite.upsert.json | 111 + external/arblarg/schemas/v1/pin.upsert.json | 92 + .../arblarg/schemas/v1/presence.update.json | 109 + external/arblarg/schemas/v1/reaction.add.json | 109 + .../arblarg/schemas/v1/reaction.remove.json | 109 + external/arblarg/schemas/v1/read.cursor.json | 105 + .../schemas/v1/role.assignment.upsert.json | 105 + external/arblarg/schemas/v1/role.upsert.json | 102 + .../arblarg/schemas/v1/server.upsert.json | 18 + .../arblarg/schemas/v1/thread.archive.json | 76 + .../arblarg/schemas/v1/thread.upsert.json | 103 + external/arblarg/schemas/v1/typing.start.json | 101 + external/arblarg/schemas/v1/typing.stop.json | 96 + .../arblarg/test_vectors/v1/error_cases.json | 55 + .../test_vectors/v1/event_message_create.json | 47 + .../test_vectors/v1/extension_events.json | 343 + .../test_vectors/v1/request_signature.json | 17 + external/haraka-elektrine | 1 + mise.toml | 4 + mix.exs | 70 + mix.lock | 98 + phoenix_static_buildpack.config | 1 + priv/repo/seeds.exs | 8 + release_builder/README.md | 67 + release_builder/config/config.exs | 72 + release_builder/config/runtime.exs | 4 + .../lib/elektrine_release_builder.ex | 3 + release_builder/mix.exs | 57 + release_builder/module_selection.exs | 89 + scripts/acme/dns_elektrine.sh | 268 + scripts/acme/issue_elektrine_wildcard_cert.sh | 239 + scripts/acme/setup_haraka_mail_tls.sh | 523 + scripts/check_design_consistency.sh | 100 + scripts/check_legacy_marker_budget.sh | 27 + scripts/check_maintainability_budgets.sh | 168 + scripts/check_tracked_generated_artifacts.sh | 24 + scripts/deploy/build_and_push_image.sh | 64 + scripts/deploy/configure_docker_source_ips.sh | 177 + .../deploy/configure_haraka_wildcard_tls.sh | 613 + scripts/deploy/deploy_pushed_image.sh | 101 + scripts/deploy/docker_deploy.sh | 1026 + scripts/deploy/doctor.sh | 583 + scripts/deploy/explain_deploy.sh | 103 + scripts/deploy/generate_env.sh | 239 + scripts/deploy/prune_old_images.sh | 179 + scripts/deploy/render_docker_compose.sh | 172 + scripts/deploy/self_host.sh | 321 + scripts/lib/deploy_simplify.sh | 157 + scripts/lib/module_selection.sh | 125 + scripts/onion/sync-onion-key-secrets.sh | 29 + scripts/release/deploy_release.sh | 91 + scripts/vpn/README.md | 155 + scripts/vpn/bootstrap-node.sh | 273 + scripts/vpn/elektrine-vpn-agent.env.example | 35 + scripts/vpn/elektrine-vpn-agent.service | 19 + scripts/vpn/elektrine-vpn-agent.sh | 366 + scripts/vpn/gen-cloud-init.sh | 106 + 4228 files changed, 650609 insertions(+) create mode 100644 .credo.exs create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .forgejo/README.md create mode 100644 .forgejo/workflows/ci.yml create mode 100644 .forgejo/workflows/docker-deploy.yml create mode 100644 .forgejo/workflows/smoke.yml create mode 100644 .formatter.exs create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docker-deploy.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 apps/README.md create mode 100644 apps/arblarg/.formatter.exs create mode 100644 apps/arblarg/.gitignore create mode 100644 apps/arblarg/README.md create mode 100644 apps/arblarg/lib/arblarg.ex create mode 100644 apps/arblarg/lib/arblarg/application.ex create mode 100644 apps/arblarg/lib/arblarg_web.ex create mode 100644 apps/arblarg/lib/arblarg_web/components/chat/call.ex create mode 100644 apps/arblarg/lib/arblarg_web/components/chat/sender_badge.ex create mode 100644 apps/arblarg/lib/arblarg_web/components/platform/e_nav.ex create mode 100644 apps/arblarg/lib/arblarg_web/components/social/content_journey.ex create mode 100644 apps/arblarg/lib/arblarg_web/components/social/embedded_post.ex create mode 100644 apps/arblarg/lib/arblarg_web/components/ui/image_modal.ex create mode 100644 apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_controller.ex create mode 100644 apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html.ex create mode 100644 apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html/chat_messages.html.heex create mode 100644 apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html/view_chat_message.html.heex create mode 100644 apps/arblarg/lib/arblarg_web/controllers/api/conversation_controller.ex create mode 100644 apps/arblarg/lib/arblarg_web/controllers/api/ext_chat_controller.ex create mode 100644 apps/arblarg/lib/arblarg_web/controllers/api/message_controller.ex create mode 100644 apps/arblarg/lib/arblarg_web/controllers/api/server_controller.ex create mode 100644 apps/arblarg/lib/arblarg_web/controllers/api/webhook_controller.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/bootstrap.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/components/channel_modal.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/components/group_modal.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/handle_formatter.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/index.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/index.html.heex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/call_info_operations.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/call_operations.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/context_menu_operations.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/conversation_operations.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/direct_message_operations.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/emoji_gif_operations.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/group_channel_operations.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/helpers.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/member_operations.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/message_info_operations.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/message_operations.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/thread_operations.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/ui_operations.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/voice_channel_operations.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/router.ex create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/state.ex create mode 100644 apps/arblarg/lib/arblarg_web/optional_module.ex create mode 100644 apps/arblarg/mix.exs create mode 100644 apps/arblarg/test/arblarg_test.exs create mode 100644 apps/arblarg/test/arblarg_web/live/chat_live/operations/emoji_gif_operations_test.exs create mode 100644 apps/arblarg/test/test_helper.exs create mode 100644 apps/atomine/lib/atomine.ex create mode 100644 apps/atomine/lib/atomine/attestation.ex create mode 100644 apps/atomine/lib/atomine/attestations.ex create mode 100644 apps/atomine/lib/atomine/credit_account.ex create mode 100644 apps/atomine/lib/atomine/credit_earning_policy.ex create mode 100644 apps/atomine/lib/atomine/credit_ledger_entry.ex create mode 100644 apps/atomine/lib/atomine/credit_policy.ex create mode 100644 apps/atomine/lib/atomine/credit_spend.ex create mode 100644 apps/atomine/lib/atomine/credits.ex create mode 100644 apps/atomine/lib/atomine/live_proof_recheck_worker.ex create mode 100644 apps/atomine/lib/atomine/personhood.ex create mode 100644 apps/atomine/lib/atomine/proof.ex create mode 100644 apps/atomine/lib/atomine/scoring.ex create mode 100644 apps/atomine/lib/atomine/trust_session.ex create mode 100644 apps/atomine/mix.exs create mode 100644 apps/atomine/test/atomine/attestations_test.exs create mode 100644 apps/atomine/test/atomine/credits_test.exs create mode 100644 apps/atomine/test/atomine/personhood_test.exs create mode 100644 apps/atomine/test/test_helper.exs create mode 100644 apps/elektrine/.gitignore create mode 100644 apps/elektrine/README.md create mode 100644 apps/elektrine/assets/css/animations.css create mode 100644 apps/elektrine/assets/css/app.css create mode 100644 apps/elektrine/assets/css/base.css create mode 100644 apps/elektrine/assets/css/components.css create mode 100644 apps/elektrine/assets/css/effects.css create mode 100644 apps/elektrine/assets/css/email.css create mode 100644 apps/elektrine/assets/css/utilities.css create mode 100644 apps/elektrine/assets/js/admin_security.js create mode 100644 apps/elektrine/assets/js/app.js create mode 100644 apps/elektrine/assets/js/blinkenlights.js create mode 100644 apps/elektrine/assets/js/cursor_glow.js create mode 100644 apps/elektrine/assets/js/e_nav.js create mode 100644 apps/elektrine/assets/js/email_raw.js create mode 100644 apps/elektrine/assets/js/error_page.js create mode 100644 apps/elektrine/assets/js/form_helpers.js create mode 100644 apps/elektrine/assets/js/hashtag_links.js create mode 100644 apps/elektrine/assets/js/hooks/analytics_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/backup_codes_printer.js create mode 100644 apps/elektrine/assets/js/hooks/call_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/chat_context_menu_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/chat_e2ee_crypto.js create mode 100644 apps/elektrine/assets/js/hooks/chat_e2ee_hook.js create mode 100644 apps/elektrine/assets/js/hooks/chat_e2ee_messages.js create mode 100644 apps/elektrine/assets/js/hooks/chat_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/chat_voice_recorder_hook.js create mode 100644 apps/elektrine/assets/js/hooks/email_compose_shortcuts_hook.js create mode 100644 apps/elektrine/assets/js/hooks/email_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/email_iframe_resize_hook.js create mode 100644 apps/elektrine/assets/js/hooks/email_shortcut_helpers.js create mode 100644 apps/elektrine/assets/js/hooks/file_explorer_hook.js create mode 100644 apps/elektrine/assets/js/hooks/form_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/index.js create mode 100644 apps/elektrine/assets/js/hooks/kairo_graph_hook.js create mode 100644 apps/elektrine/assets/js/hooks/kairo_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/mailbox_private_auth_forms.js create mode 100644 apps/elektrine/assets/js/hooks/mailbox_private_auth_payloads.js create mode 100644 apps/elektrine/assets/js/hooks/mailbox_private_compose_hook.js create mode 100644 apps/elektrine/assets/js/hooks/mailbox_private_content.js create mode 100644 apps/elektrine/assets/js/hooks/mailbox_private_crypto.js create mode 100644 apps/elektrine/assets/js/hooks/mailbox_private_messages_hook.js create mode 100644 apps/elektrine/assets/js/hooks/mailbox_private_storage_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/markdown_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/nerve_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/notification_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/notification_visibility.js create mode 100644 apps/elektrine/assets/js/hooks/paige_search_hook.js create mode 100644 apps/elektrine/assets/js/hooks/passkey_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/portal_dropdowns.js create mode 100644 apps/elektrine/assets/js/hooks/presence_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/profile_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/profile_link_reorder_hook.js create mode 100644 apps/elektrine/assets/js/hooks/proof_graph_dom.js create mode 100644 apps/elektrine/assets/js/hooks/proof_graph_hook.js create mode 100644 apps/elektrine/assets/js/hooks/proof_graph_paints.js create mode 100644 apps/elektrine/assets/js/hooks/proof_graph_styles.js create mode 100644 apps/elektrine/assets/js/hooks/static_site_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/timeline_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/timeline_media_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/timeline_preservation_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/timeline_session_continuity.js create mode 100644 apps/elektrine/assets/js/hooks/timeline_status_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/ui_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/vault_crypto.js create mode 100644 apps/elektrine/assets/js/hooks/vault_hooks.js create mode 100644 apps/elektrine/assets/js/hooks/vault_session.js create mode 100644 apps/elektrine/assets/js/hooks/voice_channel_hook.js create mode 100644 apps/elektrine/assets/js/hooks/web_push_hook.js create mode 100644 apps/elektrine/assets/js/ip_lookup.js create mode 100644 apps/elektrine/assets/js/live_clock.js create mode 100644 apps/elektrine/assets/js/markdown_editor.js create mode 100644 apps/elektrine/assets/js/markdown_helpers.js create mode 100644 apps/elektrine/assets/js/markdown_toolbar.js create mode 100644 apps/elektrine/assets/js/modal_controls.js create mode 100644 apps/elektrine/assets/js/notification_system.js create mode 100644 apps/elektrine/assets/js/profile_static.js create mode 100644 apps/elektrine/assets/js/ringtone_player.js create mode 100644 apps/elektrine/assets/js/tagline_cycler.js create mode 100644 apps/elektrine/assets/js/theme.js create mode 100644 apps/elektrine/assets/js/utils/clipboard.js create mode 100644 apps/elektrine/assets/js/utils/dropdown.js create mode 100644 apps/elektrine/assets/js/utils/email_selection.js create mode 100644 apps/elektrine/assets/js/utils/form_submission.js create mode 100644 apps/elektrine/assets/js/utils/index.js create mode 100644 apps/elektrine/assets/js/utils/overlay_portal.js create mode 100644 apps/elektrine/assets/js/utils/spinner.js create mode 100644 apps/elektrine/assets/js/utils/user_hover_card_scroll.js create mode 100644 apps/elektrine/assets/js/webrtc_client.js create mode 100644 apps/elektrine/assets/package-lock.json create mode 100644 apps/elektrine/assets/package.json create mode 100644 apps/elektrine/assets/scripts/check_js.mjs create mode 100644 apps/elektrine/assets/tailwind.config.mjs create mode 100644 apps/elektrine/assets/vendor/heroicons/LICENSE create mode 100644 apps/elektrine/assets/vendor/heroicons/SOURCE.md create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/academic-cap.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/adjustments-horizontal.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/adjustments-vertical.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box-x-mark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-on-square-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-on-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-tray.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-end-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-start-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-path-rounded-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-path.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-end-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-start-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-top-right-on-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-trending-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-trending-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-down-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-down-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-left-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-left-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-right-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-right-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-up-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-up-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-on-square-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-on-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-tray.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-pointing-in.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-pointing-out.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-right-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-up-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/at-symbol.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/backspace.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/backward.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/banknotes.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-2.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-bottom-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-bottom-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-center-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-4.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-arrow-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-0.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-100.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-50.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/beaker.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-alert.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-snooze.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bold.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bolt-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bolt.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/book-open.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/briefcase.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bug-ant.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-library.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-office-2.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-office.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-storefront.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cake.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calculator.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar-date-range.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar-days.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/camera.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-bar-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-bar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-pie.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-bottom-center-text.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-bottom-center.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left-ellipsis.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-oval-left-ellipsis.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-oval-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check-badge.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-up-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/circle-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document-check.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document-list.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clock.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud-arrow-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/code-bracket-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/code-bracket.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog-6-tooth.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog-8-tooth.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/command-line.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/computer-desktop.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cpu-chip.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/credit-card.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cube-transparent.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cube.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-bangladeshi.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-dollar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-euro.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-pound.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-rupee.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-yen.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cursor-arrow-rays.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cursor-arrow-ripple.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/device-phone-mobile.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/device-tablet.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/divide.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-arrow-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-chart-bar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-check.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-bangladeshi.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-dollar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-euro.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-pound.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-rupee.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-yen.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-duplicate.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-magnifying-glass.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-text.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-horizontal-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-horizontal.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-vertical.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/envelope-open.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/envelope.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/equals.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/exclamation-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/exclamation-triangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye-dropper.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/face-frown.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/face-smile.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/film.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/finger-print.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/fire.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/flag.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-open.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/forward.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/funnel.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gif.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gift-top.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gift.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-alt.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-americas.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-asia-australia.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-europe-africa.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h1.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h2.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h3.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-raised.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-thumb-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-thumb-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hashtag.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/heart.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/home-modern.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/home.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/identification.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/information-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/italic.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/key.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/language.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lifebuoy.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/light-bulb.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/link-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/link.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/list-bullet.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lock-closed.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lock-open.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/map-pin.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/map.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/megaphone.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/microphone.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/minus-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/moon.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/musical-note.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/newspaper.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/no-symbol.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/numbered-list.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paint-brush.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paper-airplane.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paper-clip.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pause-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pause.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pencil-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pencil.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/percent-badge.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-arrow-down-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-arrow-up-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-x-mark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/photo.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play-pause.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/plus-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/power.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/presentation-chart-bar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/presentation-chart-line.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/printer.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/puzzle-piece.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/qr-code.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/question-mark-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/queue-list.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/radio.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/receipt-percent.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/receipt-refund.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rectangle-group.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rectangle-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rocket-launch.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rss.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/scale.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/scissors.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/server-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/server.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/share.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shield-check.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shield-exclamation.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shopping-bag.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shopping-cart.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/signal-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/signal.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/sparkles.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/speaker-wave.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/speaker-x-mark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/square-2-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/square-3-stack-3d.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/squares-2x2.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/squares-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/star.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/stop-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/stop.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/strikethrough.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/sun.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/swatch.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/table-cells.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/tag.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ticket.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/trash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/trophy.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/truck.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/tv.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/underline.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-group.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/users.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/variable.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/video-camera-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/video-camera.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/view-columns.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/viewfinder-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wallet.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wifi.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/window.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wrench-screwdriver.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wrench.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/x-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/x-mark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/academic-cap.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/adjustments-horizontal.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/adjustments-vertical.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box-x-mark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-on-square-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-on-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-tray.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-end-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-start-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-path-rounded-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-path.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-end-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-start-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-top-right-on-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-trending-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-trending-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-down-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-down-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-left-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-left-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-right-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-right-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-up-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-up-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-on-square-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-on-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-tray.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-pointing-in.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-pointing-out.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-right-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-up-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/at-symbol.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/backspace.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/backward.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/banknotes.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-2.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-bottom-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-bottom-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-center-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-4.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-arrow-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-0.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-100.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-50.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/beaker.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-alert.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-snooze.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bold.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bolt-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bolt.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/book-open.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/briefcase.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bug-ant.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-library.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-office-2.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-office.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-storefront.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cake.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calculator.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar-date-range.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar-days.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/camera.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-bar-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-bar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-pie.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-bottom-center-text.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-bottom-center.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left-ellipsis.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-oval-left-ellipsis.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-oval-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check-badge.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-up-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/circle-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document-check.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document-list.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clock.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud-arrow-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/code-bracket-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/code-bracket.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog-6-tooth.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog-8-tooth.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/command-line.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/computer-desktop.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cpu-chip.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/credit-card.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cube-transparent.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cube.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-bangladeshi.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-dollar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-euro.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-pound.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-rupee.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-yen.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cursor-arrow-rays.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cursor-arrow-ripple.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/device-phone-mobile.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/device-tablet.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/divide.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-arrow-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-chart-bar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-check.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-bangladeshi.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-dollar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-euro.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-pound.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-rupee.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-yen.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-duplicate.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-magnifying-glass.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-text.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-horizontal-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-horizontal.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-vertical.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/envelope-open.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/envelope.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/equals.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/exclamation-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/exclamation-triangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye-dropper.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/face-frown.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/face-smile.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/film.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/finger-print.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/fire.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/flag.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-open.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/forward.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/funnel.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gif.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gift-top.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gift.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-alt.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-americas.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-asia-australia.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-europe-africa.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h1.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h2.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h3.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-raised.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-thumb-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-thumb-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hashtag.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/heart.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/home-modern.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/home.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/identification.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/information-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/italic.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/key.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/language.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lifebuoy.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/light-bulb.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/link-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/link.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/list-bullet.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lock-closed.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lock-open.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/map-pin.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/map.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/megaphone.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/microphone.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus-small.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/moon.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/musical-note.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/newspaper.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/no-symbol.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/numbered-list.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paint-brush.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paper-airplane.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paper-clip.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pause-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pause.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pencil-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pencil.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/percent-badge.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-arrow-down-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-arrow-up-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-x-mark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/photo.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play-pause.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus-small.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/power.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/presentation-chart-bar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/presentation-chart-line.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/printer.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/puzzle-piece.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/qr-code.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/question-mark-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/queue-list.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/radio.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/receipt-percent.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/receipt-refund.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rectangle-group.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rectangle-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rocket-launch.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rss.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/scale.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/scissors.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/server-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/server.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/share.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shield-check.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shield-exclamation.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shopping-bag.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shopping-cart.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/signal-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/signal.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/sparkles.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/speaker-wave.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/speaker-x-mark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/square-2-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/square-3-stack-3d.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/squares-2x2.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/squares-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/star.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/stop-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/stop.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/strikethrough.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/sun.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/swatch.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/table-cells.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/tag.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ticket.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/trash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/trophy.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/truck.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/tv.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/underline.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-group.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/users.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/variable.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/video-camera-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/video-camera.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/view-columns.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/viewfinder-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wallet.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wifi.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/window.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wrench-screwdriver.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wrench.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/x-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/x-mark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/academic-cap.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/adjustments-horizontal.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/adjustments-vertical.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box-x-mark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-on-square-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-on-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-tray.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-end-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-start-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-path-rounded-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-path.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-end-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-start-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-top-right-on-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-trending-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-trending-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-down-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-down-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-left-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-left-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-right-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-right-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-up-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-up-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-on-square-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-on-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-tray.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-pointing-in.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-pointing-out.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-right-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-up-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/at-symbol.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/backspace.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/backward.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/banknotes.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-2.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-bottom-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-bottom-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-center-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-4.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-arrow-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-0.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-100.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-50.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/beaker.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-alert.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-snooze.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bold.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bolt-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bolt.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/book-open.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/briefcase.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bug-ant.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-library.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-office-2.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-office.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-storefront.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cake.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calculator.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar-date-range.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar-days.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/camera.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-bar-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-bar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-pie.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-bottom-center-text.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-bottom-center.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left-ellipsis.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-oval-left-ellipsis.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-oval-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check-badge.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-up-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/circle-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document-check.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document-list.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clock.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud-arrow-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/code-bracket-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/code-bracket.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog-6-tooth.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog-8-tooth.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/command-line.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/computer-desktop.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cpu-chip.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/credit-card.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cube-transparent.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cube.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-bangladeshi.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-dollar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-euro.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-pound.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-rupee.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-yen.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cursor-arrow-rays.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cursor-arrow-ripple.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/device-phone-mobile.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/device-tablet.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/divide.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-arrow-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-chart-bar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-check.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-bangladeshi.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-dollar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-euro.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-pound.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-rupee.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-yen.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-duplicate.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-magnifying-glass.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-text.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-horizontal-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-horizontal.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-vertical.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/envelope-open.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/envelope.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/equals.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/exclamation-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/exclamation-triangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye-dropper.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/face-frown.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/face-smile.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/film.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/finger-print.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/fire.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/flag.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-open.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/forward.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/funnel.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gif.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gift-top.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gift.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-alt.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-americas.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-asia-australia.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-europe-africa.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h1.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h2.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h3.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-raised.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-thumb-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-thumb-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hashtag.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/heart.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/home-modern.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/home.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/identification.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/information-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/italic.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/key.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/language.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lifebuoy.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/light-bulb.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/link-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/link.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/list-bullet.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lock-closed.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lock-open.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/map-pin.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/map.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/megaphone.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/microphone.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus-small.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/moon.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/musical-note.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/newspaper.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/no-symbol.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/numbered-list.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paint-brush.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paper-airplane.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paper-clip.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pause-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pause.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pencil-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pencil.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/percent-badge.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-arrow-down-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-arrow-up-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-x-mark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/photo.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play-pause.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus-small.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/power.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/presentation-chart-bar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/presentation-chart-line.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/printer.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/puzzle-piece.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/qr-code.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/question-mark-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/queue-list.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/radio.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/receipt-percent.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/receipt-refund.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rectangle-group.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rectangle-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rocket-launch.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rss.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/scale.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/scissors.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/server-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/server.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/share.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shield-check.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shield-exclamation.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shopping-bag.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shopping-cart.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/signal-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/signal.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/sparkles.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/speaker-wave.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/speaker-x-mark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/square-2-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/square-3-stack-3d.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/squares-2x2.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/squares-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/star.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/stop-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/stop.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/strikethrough.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/sun.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/swatch.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/table-cells.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/tag.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ticket.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/trash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/trophy.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/truck.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/tv.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/underline.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-group.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/users.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/variable.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/video-camera-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/video-camera.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/view-columns.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/viewfinder-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wallet.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wifi.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/window.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wrench-screwdriver.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wrench.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/x-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/x-mark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/academic-cap.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/adjustments-horizontal.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/adjustments-vertical.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box-x-mark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-on-square-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-on-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-tray.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-end-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-start-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-path-rounded-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-path.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-end-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-start-on-rectangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-top-right-on-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-trending-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-trending-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-down-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-down-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-left-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-left-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-right-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-right-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-up-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-up-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-on-square-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-on-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-tray.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-pointing-in.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-pointing-out.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-right-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-up-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/at-symbol.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/backspace.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/backward.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/banknotes.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-2.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-bottom-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-bottom-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-center-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-4.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-arrow-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-0.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-100.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-50.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/beaker.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-alert.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-snooze.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bold.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bolt-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bolt.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/book-open.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/briefcase.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bug-ant.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-library.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-office-2.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-office.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-storefront.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cake.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calculator.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar-date-range.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar-days.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/camera.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-bar-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-bar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-pie.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-bottom-center-text.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-bottom-center.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left-ellipsis.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-oval-left-ellipsis.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-oval-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check-badge.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-up-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/circle-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document-check.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document-list.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clock.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud-arrow-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/code-bracket-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/code-bracket.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog-6-tooth.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog-8-tooth.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/command-line.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/computer-desktop.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cpu-chip.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/credit-card.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cube-transparent.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cube.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-bangladeshi.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-dollar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-euro.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-pound.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-rupee.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-yen.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cursor-arrow-rays.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cursor-arrow-ripple.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/device-phone-mobile.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/device-tablet.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/divide.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-arrow-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-chart-bar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-check.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-bangladeshi.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-dollar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-euro.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-pound.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-rupee.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-yen.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-duplicate.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-magnifying-glass.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-text.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-horizontal-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-horizontal.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-vertical.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/envelope-open.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/envelope.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/equals.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/exclamation-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/exclamation-triangle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye-dropper.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/face-frown.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/face-smile.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/film.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/finger-print.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/fire.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/flag.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-open.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/forward.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/funnel.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gif.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gift-top.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gift.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-alt.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-americas.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-asia-australia.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-europe-africa.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h1.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h2.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h3.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-raised.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-thumb-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-thumb-up.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hashtag.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/heart.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/home-modern.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/home.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/identification.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox-arrow-down.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/information-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/italic.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/key.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/language.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lifebuoy.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/light-bulb.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/link-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/link.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/list-bullet.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lock-closed.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lock-open.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/map-pin.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/map.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/megaphone.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/microphone.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus-small.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/moon.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/musical-note.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/newspaper.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/no-symbol.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/numbered-list.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paint-brush.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paper-airplane.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paper-clip.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pause-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pause.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pencil-square.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pencil.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/percent-badge.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-arrow-down-left.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-arrow-up-right.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-x-mark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/photo.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play-pause.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus-small.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/power.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/presentation-chart-bar.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/presentation-chart-line.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/printer.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/puzzle-piece.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/qr-code.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/question-mark-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/queue-list.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/radio.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/receipt-percent.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/receipt-refund.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rectangle-group.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rectangle-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rocket-launch.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rss.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/scale.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/scissors.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/server-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/server.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/share.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shield-check.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shield-exclamation.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shopping-bag.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shopping-cart.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/signal-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/signal.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/sparkles.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/speaker-wave.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/speaker-x-mark.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/square-2-stack.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/square-3-stack-3d.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/squares-2x2.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/squares-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/star.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/stop-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/stop.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/strikethrough.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/sun.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/swatch.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/table-cells.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/tag.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ticket.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/trash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/trophy.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/truck.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/tv.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/underline.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-group.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-minus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-plus.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/users.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/variable.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/video-camera-slash.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/video-camera.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/view-columns.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/viewfinder-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wallet.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wifi.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/window.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wrench-screwdriver.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wrench.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/x-circle.svg create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/x-mark.svg create mode 100644 apps/elektrine/assets/vendor/topbar.js create mode 100644 apps/elektrine/lib/elektrine.ex create mode 100644 apps/elektrine/lib/elektrine/account_identifiers.ex create mode 100644 apps/elektrine/lib/elektrine/accounts.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/account_deletion_request.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/account_endorsement.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/account_import_worker.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/account_note.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/account_notes.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/account_subscription.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/app_password.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/authentication.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/blocked_users_cache.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/blocking.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/built_in_subdomain.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/cached.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/capabilities.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/client_app_setting.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/client_app_settings.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/connected_account.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/endorsements.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/invite_code.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/invite_code_use.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/moderation.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/mute_expire_worker.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/muting.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/passkey_credential.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/passkeys.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/recovery_email_verification.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/storage.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/subscriptions.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/tracking.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/trust_level.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/trust_level_log.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/trusted_device.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/two_factor.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/user.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/user_activity_stats.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/user_block.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/user_mute.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/user_session.ex create mode 100644 apps/elektrine/lib/elektrine/accounts/username_history.ex create mode 100644 apps/elektrine/lib/elektrine/acme/wildcard_renewal_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/activity.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/activity_delivery_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/actor.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/actor_refresh_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/builder.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/collection_count_sync_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/collection_fetcher.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/community_fetcher.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/containment.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/delivery.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/delivery_cleanup.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/delivery_cleanup_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/delivery_retry_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/domain_delivery_health.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/domain_migration.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/domain_throttler.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/federation_cleanup_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/federation_helpers.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/federation_load_guard.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/fetch_remote_poll_service.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/fetch_remote_poll_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/fetcher.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/group_follow.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/handler.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/announce_handler.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/block_handler.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/create_handler.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/delete_handler.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/flag_handler.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/follow_handler.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/like_handler.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/move_handler.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/update_handler.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/helpers.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/http_signature.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/inbound_delivery_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/inbox_queue.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/inbox_rate_limiter.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/incoming_activity_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/instance.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/instances.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/key_manager.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/lemmy_api.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/lemmy_cache.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/lemmy_cache_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/lemmy_comment_backfill.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/local_references.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mastodon_api.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mentions.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/anti_followbot_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/anti_link_spam_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/anti_mention_spam_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/emoji_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/force_bot_unlisted_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/hashtag_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/hellthread_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/inline_quote_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/keyword_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/mention_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/no_empty_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/no_placeholder_text_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/normalize_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/object_age_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/quiet_reply_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/quote_to_link_tag_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/reject_non_public_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/remote_report_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/simple_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/user_allow_list_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/utils.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/vocabulary_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/nodeinfo.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/nodeinfo_fetcher_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/normalizer.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/object_deliveries.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/object_delivery.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/object_validator.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/outbox.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/pipeline.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/process_activity_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/publisher.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/reachability_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/refresh_counts_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/relay.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/relay_subscription.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/remote_fetch.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/replies_fetcher.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/replies_ingest_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/reply_fetch_policy.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/request_replay.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/request_replay_cache.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/side_effects.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/signature_retry_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/signature_verification.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/signing_key.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/submitted_link_repair_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/thread_backfill_worker.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/tombstone.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/tombstones.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/undo_resolver.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/user_block.ex create mode 100644 apps/elektrine/lib/elektrine/activitypub/visibility.ex create mode 100644 apps/elektrine/lib/elektrine/admin.ex create mode 100644 apps/elektrine/lib/elektrine/admin/announcement.ex create mode 100644 apps/elektrine/lib/elektrine/admin/announcement_dismissal.ex create mode 100644 apps/elektrine/lib/elektrine/api/import_rate_limiter.ex create mode 100644 apps/elektrine/lib/elektrine/api/media_rate_limiter.ex create mode 100644 apps/elektrine/lib/elektrine/api/rate_limiter.ex create mode 100644 apps/elektrine/lib/elektrine/api/search_rate_limiter.ex create mode 100644 apps/elektrine/lib/elektrine/api/timeline_rate_limiter.ex create mode 100644 apps/elektrine/lib/elektrine/api/write_rate_limiter.ex create mode 100644 apps/elektrine/lib/elektrine/app_cache.ex create mode 100644 apps/elektrine/lib/elektrine/application.ex create mode 100644 apps/elektrine/lib/elektrine/async.ex create mode 100644 apps/elektrine/lib/elektrine/atomine_proof_bundle.ex create mode 100644 apps/elektrine/lib/elektrine/atomine_proof_graph.ex create mode 100644 apps/elektrine/lib/elektrine/audit_log.ex create mode 100644 apps/elektrine/lib/elektrine/auth/api_token_revocation.ex create mode 100644 apps/elektrine/lib/elektrine/auth/rate_limiter.ex create mode 100644 apps/elektrine/lib/elektrine/bluesky.ex create mode 100644 apps/elektrine/lib/elektrine/bluesky/finch_client.ex create mode 100644 apps/elektrine/lib/elektrine/bluesky/inbound.ex create mode 100644 apps/elektrine/lib/elektrine/bluesky/inbound_event.ex create mode 100644 apps/elektrine/lib/elektrine/bluesky/inbound_poll_worker.ex create mode 100644 apps/elektrine/lib/elektrine/bluesky/managed.ex create mode 100644 apps/elektrine/lib/elektrine/bluesky/outbound_worker.ex create mode 100644 apps/elektrine/lib/elektrine/booking.ex create mode 100644 apps/elektrine/lib/elektrine/booking/booking.ex create mode 100644 apps/elektrine/lib/elektrine/booking/confirmation_worker.ex create mode 100644 apps/elektrine/lib/elektrine/booking/expire_holds_worker.ex create mode 100644 apps/elektrine/lib/elektrine/booking/notifier.ex create mode 100644 apps/elektrine/lib/elektrine/booking/page.ex create mode 100644 apps/elektrine/lib/elektrine/caddy_tls_domain_cache.ex create mode 100644 apps/elektrine/lib/elektrine/calendar.ex create mode 100644 apps/elektrine/lib/elektrine/calendar/availability.ex create mode 100644 apps/elektrine/lib/elektrine/calendar/calendar.ex create mode 100644 apps/elektrine/lib/elektrine/calendar/event.ex create mode 100644 apps/elektrine/lib/elektrine/calendar/icalendar.ex create mode 100644 apps/elektrine/lib/elektrine/calendar/recurrence.ex create mode 100644 apps/elektrine/lib/elektrine/calls.ex create mode 100644 apps/elektrine/lib/elektrine/calls/call.ex create mode 100644 apps/elektrine/lib/elektrine/calls/transport.ex create mode 100644 apps/elektrine/lib/elektrine/captcha.ex create mode 100644 apps/elektrine/lib/elektrine/components/copy_button.ex create mode 100644 apps/elektrine/lib/elektrine/components/experimental_notice.ex create mode 100644 apps/elektrine/lib/elektrine/components/loaders/skeleton.ex create mode 100644 apps/elektrine/lib/elektrine/components/report_modal.ex create mode 100644 apps/elektrine/lib/elektrine/components/trust_level_badge.ex create mode 100644 apps/elektrine/lib/elektrine/components/user/avatar.ex create mode 100644 apps/elektrine/lib/elektrine/components/user/username_effects.ex create mode 100644 apps/elektrine/lib/elektrine/components/user/verification_badge.ex create mode 100644 apps/elektrine/lib/elektrine/constants.ex create mode 100644 apps/elektrine/lib/elektrine/database/index_repair.ex create mode 100644 apps/elektrine/lib/elektrine/dav/rate_limiter.ex create mode 100644 apps/elektrine/lib/elektrine/db/write_guard.ex create mode 100644 apps/elektrine/lib/elektrine/developer.ex create mode 100644 apps/elektrine/lib/elektrine/developer/api_token.ex create mode 100644 apps/elektrine/lib/elektrine/developer/data_export.ex create mode 100644 apps/elektrine/lib/elektrine/developer/exports/account_exporter.ex create mode 100644 apps/elektrine/lib/elektrine/developer/exports/chat_exporter.ex create mode 100644 apps/elektrine/lib/elektrine/developer/exports/email_exporter.ex create mode 100644 apps/elektrine/lib/elektrine/developer/exports/social_exporter.ex create mode 100644 apps/elektrine/lib/elektrine/developer/webhook.ex create mode 100644 apps/elektrine/lib/elektrine/developer/webhook_delivery.ex create mode 100644 apps/elektrine/lib/elektrine/developer/workers/export_worker.ex create mode 100644 apps/elektrine/lib/elektrine/developer/workers/webhook_delivery_worker.ex create mode 100644 apps/elektrine/lib/elektrine/discord.ex create mode 100644 apps/elektrine/lib/elektrine/domains.ex create mode 100644 apps/elektrine/lib/elektrine/drive.ex create mode 100644 apps/elektrine/lib/elektrine/drive/file_share.ex create mode 100644 apps/elektrine/lib/elektrine/drive/stored_file.ex create mode 100644 apps/elektrine/lib/elektrine/drive/stored_folder.ex create mode 100644 apps/elektrine/lib/elektrine/email_addresses.ex create mode 100644 apps/elektrine/lib/elektrine/emojis.ex create mode 100644 apps/elektrine/lib/elektrine/emojis/custom_emoji.ex create mode 100644 apps/elektrine/lib/elektrine/encryption.ex create mode 100644 apps/elektrine/lib/elektrine/encryption/key_cache.ex create mode 100644 apps/elektrine/lib/elektrine/friends.ex create mode 100644 apps/elektrine/lib/elektrine/friends/friend_request.ex create mode 100644 apps/elektrine/lib/elektrine/home_blog.ex create mode 100644 apps/elektrine/lib/elektrine/http/backoff.ex create mode 100644 apps/elektrine/lib/elektrine/http/safe_fetch.ex create mode 100644 apps/elektrine/lib/elektrine/input.ex create mode 100644 apps/elektrine/lib/elektrine/internal_api.ex create mode 100644 apps/elektrine/lib/elektrine/ip_lookup.ex create mode 100644 apps/elektrine/lib/elektrine/job_queue.ex create mode 100644 apps/elektrine/lib/elektrine/job_queue_monitor.ex create mode 100644 apps/elektrine/lib/elektrine/jobs/attachment_uploader.ex create mode 100644 apps/elektrine/lib/elektrine/jobs/auto_promote_eligible_users_worker.ex create mode 100644 apps/elektrine/lib/elektrine/jobs/deactivate_expired_announcements_worker.ex create mode 100644 apps/elektrine/lib/elektrine/jobs/email_recategorizer.ex create mode 100644 apps/elektrine/lib/elektrine/jobs/profile_custom_domain_health_worker.ex create mode 100644 apps/elektrine/lib/elektrine/jobs/recalculate_recent_discussion_scores_worker.ex create mode 100644 apps/elektrine/lib/elektrine/jobs/reply_later_processor.ex create mode 100644 apps/elektrine/lib/elektrine/jobs/stale_call_cleanup.ex create mode 100644 apps/elektrine/lib/elektrine/link_archive.ex create mode 100644 apps/elektrine/lib/elektrine/link_archive/archive.ex create mode 100644 apps/elektrine/lib/elektrine/link_archive/fetch_worker.ex create mode 100644 apps/elektrine/lib/elektrine/mail/telemetry.ex create mode 100644 apps/elektrine/lib/elektrine/mail_auth/rate_limiter.ex create mode 100644 apps/elektrine/lib/elektrine/mail_client_settings.ex create mode 100644 apps/elektrine/lib/elektrine/mailer.ex create mode 100644 apps/elektrine/lib/elektrine/mailer/delivery_worker.ex create mode 100644 apps/elektrine/lib/elektrine/markdown.ex create mode 100644 apps/elektrine/lib/elektrine/markers.ex create mode 100644 apps/elektrine/lib/elektrine/markers/marker.ex create mode 100644 apps/elektrine/lib/elektrine/media_proxy.ex create mode 100644 apps/elektrine/lib/elektrine/messaging.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/arblarg_profiles.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/arblarg_sdk.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/auto_mod_rule.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/channel_categories.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/channel_category.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_conversation.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_conversation_encryption_key.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_conversation_key_recipient.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_conversation_member.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_conversations.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_encryption_device.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_message.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_message_pin.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_message_pins.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_message_reaction.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_messages.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_moderation_action.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_remote_encryption_device.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_thread.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_threads.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_user_hidden_message.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_user_timeout.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_webhook.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_webhooks.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/community_ban.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/community_flair.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/community_search.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/conversation_member.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federated_boost.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federated_dislike.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federated_like.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/actors.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/attachments.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/auth.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/builders.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/config.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/contexts.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/delivery.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/direct_message_state.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/direct_messages.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/discovery.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/dispatch.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/egress.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/errors.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/event_router.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/event_tracking.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/extension_events.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/inbound.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/ingress.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/ingress_rate_limiter.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/maintenance.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/mirror_broadcasts.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/mirror_events.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/mirrors.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/peer_policies.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/peers.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/protocol.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/public_discovery.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/publisher.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/request_auth.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/runtime.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/snapshots.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/state.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/transport.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/utils.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/validation.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/visibility.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/voice_calls.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_account_presence_state.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_call_session.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_discovered_peer.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_event.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_extension_event.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_invite_state.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_membership_state.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_outbox_event.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_outbox_retry_worker.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_outbox_worker.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_peer_policy.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_read_cursor.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_request_replay.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_retention_worker.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_room_presence_state.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_session_client.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_session_websock.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_stream_position.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/link_preview_broadcast.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/member_schema_base.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/membership.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/membership_authz.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/message_reaction.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/moderation.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/moderation_action.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/moderation_tools.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/moderator_note.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/rate_limiter.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/reactions.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/reference_peer.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/reference_peer_protocol.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/reference_peer_session_server.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/room_acl.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/server.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/server_member.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/servers.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/slash_commands.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/social_conversation.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/social_message.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/sync_remote_counts_worker.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/user_hidden_message.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/user_post_timestamp.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/user_timeout.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/user_warning.ex create mode 100644 apps/elektrine/lib/elektrine/messaging/voice_channels.ex create mode 100644 apps/elektrine/lib/elektrine/notifications.ex create mode 100644 apps/elektrine/lib/elektrine/notifications/federation_notifications.ex create mode 100644 apps/elektrine/lib/elektrine/notifications/notification.ex create mode 100644 apps/elektrine/lib/elektrine/oauth.ex create mode 100644 apps/elektrine/lib/elektrine/oauth/app.ex create mode 100644 apps/elektrine/lib/elektrine/oauth/authorization.ex create mode 100644 apps/elektrine/lib/elektrine/oauth/scopes.ex create mode 100644 apps/elektrine/lib/elektrine/oauth/token.ex create mode 100644 apps/elektrine/lib/elektrine/oidc.ex create mode 100644 apps/elektrine/lib/elektrine/oidc/signing_key.ex create mode 100644 apps/elektrine/lib/elektrine/own_root.ex create mode 100644 apps/elektrine/lib/elektrine/paths.ex create mode 100644 apps/elektrine/lib/elektrine/platform/e_nav.ex create mode 100644 apps/elektrine/lib/elektrine/platform/e_nav_component.ex create mode 100644 apps/elektrine/lib/elektrine/platform/integrations.ex create mode 100644 apps/elektrine/lib/elektrine/platform/module_provider.ex create mode 100644 apps/elektrine/lib/elektrine/platform/module_providers.ex create mode 100644 apps/elektrine/lib/elektrine/platform/modules.ex create mode 100644 apps/elektrine/lib/elektrine/platform/runtime_config_validator.ex create mode 100644 apps/elektrine/lib/elektrine/privacy.ex create mode 100644 apps/elektrine/lib/elektrine/profiles.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/analytics_retention_worker.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/custom_domain.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/custom_domains.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/follow.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/per_site_identities.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/per_site_identity.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/profile_link.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/profile_site_visit.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/profile_view.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/profile_widget.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/public_site_analytics_rollup_worker.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/site_page_visit.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/site_session.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/static_site_deploy.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/static_site_deployment.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/static_site_file.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/user_badge.ex create mode 100644 apps/elektrine/lib/elektrine/profiles/user_profile.ex create mode 100644 apps/elektrine/lib/elektrine/proxy_protocol.ex create mode 100644 apps/elektrine/lib/elektrine/pubsub_topics.ex create mode 100644 apps/elektrine/lib/elektrine/push.ex create mode 100644 apps/elektrine/lib/elektrine/push/device_token.ex create mode 100644 apps/elektrine/lib/elektrine/push/web_push_client.ex create mode 100644 apps/elektrine/lib/elektrine/push/web_subscription.ex create mode 100644 apps/elektrine/lib/elektrine/rate_limiter.ex create mode 100644 apps/elektrine/lib/elektrine/release.ex create mode 100644 apps/elektrine/lib/elektrine/repo.ex create mode 100644 apps/elektrine/lib/elektrine/reports.ex create mode 100644 apps/elektrine/lib/elektrine/reports/report.ex create mode 100644 apps/elektrine/lib/elektrine/rss.ex create mode 100644 apps/elektrine/lib/elektrine/rss/feed.ex create mode 100644 apps/elektrine/lib/elektrine/rss/fetch_feed_worker.ex create mode 100644 apps/elektrine/lib/elektrine/rss/item.ex create mode 100644 apps/elektrine/lib/elektrine/rss/parser.ex create mode 100644 apps/elektrine/lib/elektrine/rss/scheduler_worker.ex create mode 100644 apps/elektrine/lib/elektrine/rss/subscription.ex create mode 100644 apps/elektrine/lib/elektrine/runtime_env.ex create mode 100644 apps/elektrine/lib/elektrine/runtime_secrets.ex create mode 100644 apps/elektrine/lib/elektrine/search.ex create mode 100644 apps/elektrine/lib/elektrine/search/cached.ex create mode 100644 apps/elektrine/lib/elektrine/search/domain_rule.ex create mode 100644 apps/elektrine/lib/elektrine/search/domain_rules.ex create mode 100644 apps/elektrine/lib/elektrine/search/paige_rate_limiter.ex create mode 100644 apps/elektrine/lib/elektrine/search/rate_limiter.ex create mode 100644 apps/elektrine/lib/elektrine/secrets/backfill.ex create mode 100644 apps/elektrine/lib/elektrine/secrets/encrypted_string.ex create mode 100644 apps/elektrine/lib/elektrine/security/content_validator.ex create mode 100644 apps/elektrine/lib/elektrine/security/file_path.ex create mode 100644 apps/elektrine/lib/elektrine/security/safe_external_url.ex create mode 100644 apps/elektrine/lib/elektrine/security/url_validator.ex create mode 100644 apps/elektrine/lib/elektrine/security_alerts.ex create mode 100644 apps/elektrine/lib/elektrine/security_alerts/cache.ex create mode 100644 apps/elektrine/lib/elektrine/social/attachment_cleanup_worker.ex create mode 100644 apps/elektrine/lib/elektrine/social/conversations.ex create mode 100644 apps/elektrine/lib/elektrine/social/engagement_count_repair.ex create mode 100644 apps/elektrine/lib/elektrine/social/engagement_count_repair_worker.ex create mode 100644 apps/elektrine/lib/elektrine/social/engagement_counts.ex create mode 100644 apps/elektrine/lib/elektrine/social/expired_data_cleanup_worker.ex create mode 100644 apps/elektrine/lib/elektrine/social/hashtag.ex create mode 100644 apps/elektrine/lib/elektrine/social/link_preview.ex create mode 100644 apps/elektrine/lib/elektrine/social/message_stat.ex create mode 100644 apps/elektrine/lib/elektrine/social/message_stats.ex create mode 100644 apps/elektrine/lib/elektrine/social/messages.ex create mode 100644 apps/elektrine/lib/elektrine/social/poll.ex create mode 100644 apps/elektrine/lib/elektrine/social/poll_option.ex create mode 100644 apps/elektrine/lib/elektrine/social/poll_vote.ex create mode 100644 apps/elektrine/lib/elektrine/social/post_hashtag.ex create mode 100644 apps/elektrine/lib/elektrine/social/scrobble.ex create mode 100644 apps/elektrine/lib/elektrine/social/scrobbles.ex create mode 100644 apps/elektrine/lib/elektrine/static_sites.ex create mode 100644 apps/elektrine/lib/elektrine/static_sites/github_deploy_worker.ex create mode 100644 apps/elektrine/lib/elektrine/static_sites/request_resolver.ex create mode 100644 apps/elektrine/lib/elektrine/strings.ex create mode 100644 apps/elektrine/lib/elektrine/subscriptions.ex create mode 100644 apps/elektrine/lib/elektrine/subscriptions/product.ex create mode 100644 apps/elektrine/lib/elektrine/subscriptions/registration_checkout.ex create mode 100644 apps/elektrine/lib/elektrine/subscriptions/stripe_client.ex create mode 100644 apps/elektrine/lib/elektrine/subscriptions/stripe_client/live.ex create mode 100644 apps/elektrine/lib/elektrine/subscriptions/subscription.ex create mode 100644 apps/elektrine/lib/elektrine/system.ex create mode 100644 apps/elektrine/lib/elektrine/system/config.ex create mode 100644 apps/elektrine/lib/elektrine/telemetry/events.ex create mode 100644 apps/elektrine/lib/elektrine/text_helpers.ex create mode 100644 apps/elektrine/lib/elektrine/theme.ex create mode 100644 apps/elektrine/lib/elektrine/time.ex create mode 100644 apps/elektrine/lib/elektrine/timeline/rate_limiter.ex create mode 100644 apps/elektrine/lib/elektrine/uploads.ex create mode 100644 apps/elektrine/lib/elektrine/user_notifier.ex create mode 100644 apps/elektrine/lib/elektrine/utils/safe_convert.ex create mode 100644 apps/elektrine/lib/elektrine/utils/slug.ex create mode 100644 apps/elektrine/lib/elektrine/vault.ex create mode 100644 apps/elektrine/lib/elektrine/vault/master_key.ex create mode 100644 apps/elektrine/lib/elektrine/web_index.ex create mode 100644 apps/elektrine/lib/elektrine/web_index/crawl_worker.ex create mode 100644 apps/elektrine/lib/elektrine/web_index/crawler.ex create mode 100644 apps/elektrine/lib/elektrine/web_index/document.ex create mode 100644 apps/elektrine/lib/elektrine/web_index/extractor.ex create mode 100644 apps/elektrine/lib/elektrine/web_index/fetcher.ex create mode 100644 apps/elektrine/lib/elektrine/web_index/host.ex create mode 100644 apps/elektrine/lib/elektrine/web_index/provider.ex create mode 100644 apps/elektrine/lib/elektrine/web_index/robots.ex create mode 100644 apps/elektrine/lib/elektrine/web_index/scheduler_worker.ex create mode 100644 apps/elektrine/lib/elektrine/web_index/sitemap_worker.ex create mode 100644 apps/elektrine/lib/elektrine/webhook/rate_limiter.ex create mode 100644 apps/elektrine/lib/mix/tasks/activitypub.backfill_signing_keys.ex create mode 100644 apps/elektrine/lib/mix/tasks/activitypub.migrate_domain.ex create mode 100644 apps/elektrine/lib/mix/tasks/activitypub.repair_submitted_links.ex create mode 100644 apps/elektrine/lib/mix/tasks/admin.make.ex create mode 100644 apps/elektrine/lib/mix/tasks/analytics.seed_site_load.ex create mode 100644 apps/elektrine/lib/mix/tasks/arblarg.conformance.ex create mode 100644 apps/elektrine/lib/mix/tasks/badge.grant.ex create mode 100644 apps/elektrine/lib/mix/tasks/badge.revoke.ex create mode 100644 apps/elektrine/lib/mix/tasks/cleanup_duplicate_mailboxes.ex create mode 100644 apps/elektrine/lib/mix/tasks/discussions.demo_posts.ex create mode 100644 apps/elektrine/lib/mix/tasks/elektrine.db.repair_indexes.ex create mode 100644 apps/elektrine/lib/mix/tasks/email.inbound_dlq.ex create mode 100644 apps/elektrine/lib/mix/tasks/email.pubsub_test.ex create mode 100644 apps/elektrine/lib/mix/tasks/email.recalculate_storage.ex create mode 100644 apps/elektrine/lib/mix/tasks/email.recategorize.ex create mode 100644 apps/elektrine/lib/mix/tasks/email.seed_plaintext.ex create mode 100644 apps/elektrine/lib/mix/tasks/email.seed_realistic.ex create mode 100644 apps/elektrine/lib/mix/tasks/email.test.ex create mode 100644 apps/elektrine/lib/mix/tasks/fix_username_duplicates.ex create mode 100644 apps/elektrine/lib/mix/tasks/messaging.arblarg.sync_artifacts.ex create mode 100644 apps/elektrine/lib/mix/tasks/messaging.federation.interop_smoke.ex create mode 100644 apps/elektrine/lib/mix/tasks/messaging.federation.load_test.ex create mode 100644 apps/elektrine/lib/mix/tasks/migrate_email_attachments.ex create mode 100644 apps/elektrine/lib/mix/tasks/secrets.backfill.ex create mode 100644 apps/elektrine/lib/mix/tasks/social.backfill_lemmy_comment_votes.ex create mode 100644 apps/elektrine/lib/mix/tasks/social.recalculate_reply_counts.ex create mode 100644 apps/elektrine/lib/mix/tasks/social.repair_engagement_counts.ex create mode 100644 apps/elektrine/lib/mix/tasks/test_2fa.ex create mode 100644 apps/elektrine/lib/mix/tasks/users.import_existing.ex create mode 100644 apps/elektrine/mix.exs create mode 100644 apps/elektrine/priv/gettext/default.pot create mode 100644 apps/elektrine/priv/gettext/errors.pot create mode 100644 apps/elektrine/priv/gettext/zh/LC_MESSAGES/default.po create mode 100644 apps/elektrine/priv/gettext/zh/LC_MESSAGES/errors.po create mode 100644 apps/elektrine/priv/repo/baseline_schema.sql create mode 100644 apps/elektrine/priv/repo/generate_activitypub_keys.exs create mode 100644 apps/elektrine/priv/repo/migrations/.formatter.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250511044001_create_users_table.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250512000001_create_mailboxes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250512000002_create_email_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250513061546_create_temporary_mailboxes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250513064926_make_mailbox_user_id_nullable.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250513072500_update_messages_for_temporary_mailboxes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250529050458_add_avatar_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250608202809_add_hey_features_to_email_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250608202829_create_approved_senders.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250609031912_add_spam_and_archived_to_email_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250615002423_import_existing_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250615202324_add_username_change_tracking.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250615235823_add_admin_role_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250616000949_add_banned_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250622162141_create_email_aliases.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250622164337_add_forwarding_to_mailboxes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250622165252_make_target_email_nullable.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250622180610_add_alias_username_conflict_indexes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250622233020_add_attachments_to_email_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250624065910_create_account_deletion_requests.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250628155713_add_two_factor_auth_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250630003407_create_invite_codes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250630003424_create_invite_code_uses.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250630011535_create_system_config.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250706183626_create_rejected_senders.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250706184034_add_user_to_temporary_mailboxes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250712172013_add_bluesky_identity_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250720223936_add_github_token_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250726184803_remove_screener_feature.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250817001342_create_siem_tables.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250817191916_create_threat_intelligence_tables.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250817210439_add_cybersecurity_news.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250817212722_add_underground_threat_intelligence.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250902065912_create_subscriptions.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250903172225_create_user_profiles_and_links.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250904070740_add_location_to_user_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250904171431_add_favicon_and_title_to_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250905054459_add_advanced_customization_to_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250905060932_add_ip_tracking_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250906055118_create_platform_updates.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250906081641_create_audit_logs.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250907053034_create_announcements.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250907074626_create_announcement_dismissals.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250907193037_add_display_name_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250907232526_add_recovery_email_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250909041315_add_locale_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250909163235_remove_temporary_mailbox_system.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250911070737_create_username_history.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250912165836_add_storage_tracking_to_mailboxes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250912173431_update_storage_limit_to_5mb.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250913160643_remove_bluesky_from_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250913163916_remove_custom_css_from_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250914003149_add_privacy_settings_to_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250914005156_create_messaging_system.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250914031911_add_hidden_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250914073452_add_hash_to_conversations.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250914073519_add_hash_to_email_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250914074416_populate_existing_hashes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250914081434_remove_username_change_fields.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250914091730_add_username_to_mailboxes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250914172837_create_user_blocks.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250915010911_add_last_password_change_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250915011003_add_index_to_last_password_change.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250915070107_add_moderation_system.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250916052139_update_storage_limit_to_500mb.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250916171831_create_follows.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250918055140_create_reports.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250918063443_add_suspension_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250918165958_add_social_features_to_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250918173523_remove_duplicate_user_follows_table.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250919043723_add_link_previews.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250919050445_add_hashtag_system.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250919124426_add_discussion_voting_system.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250919130802_add_cross_context_promotion_fields.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250919132803_add_titles_to_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250920125417_add_unique_constraint_to_community_names.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250920154313_add_promoted_from_community_fields.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250920190238_add_last_read_message_id_to_conversation_members.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250923055335_create_notifications.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250923155428_add_case_insensitive_unique_index_to_aliases.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250923155500_add_case_insensitive_unique_index_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250923160000_fix_duplicate_aliases_before_index.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250924000001_add_social_identity_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250924000002_grandfather_user_handles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250924065350_regenerate_missing_handles_and_ids.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250925151252_migrate_email_attachments_to_s3.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250926045456_add_privacy_settings_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250926052516_set_default_privacy_settings.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250926053327_rename_email_notification_fields_to_notify.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250927050233_add_community_flairs.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250927065000_update_reply_counts.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250927070000_add_vote_indexes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250927071000_add_community_rules.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250927153135_add_pinned_to_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250927153154_create_community_bans.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250927161505_rename_email_categories.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250927195300_rename_feed_to_digest.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250930065342_create_app_passwords.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250930145634_add_flagged_to_email_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250930153632_allow_null_to_in_email_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250930163613_add_deleted_to_email_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20250930171320_add_answered_to_email_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251004205738_create_profile_views.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251005051754_add_user_storage_tracking.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251005052144_remove_mailbox_storage_fields.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251005053001_update_user_storage_limits_to_500mb.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251005145316_add_media_metadata_to_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251005152310_add_file_sizes_to_avatars_and_backgrounds.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251005153018_remove_animated_title_from_user_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251006034404_add_case_insensitive_handle_index.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251006034431_add_case_insensitive_community_name_index.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251008000912_add_timezone_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251008012454_add_time_format_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251008014427_remove_notify_on_group_invite_from_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251008150636_add_encryption_to_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251008150659_add_encryption_to_email_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251008181136_add_email_protocol_tracking.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251009063632_add_user_status.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251009190614_add_last_seen_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251010031206_add_username_effects_to_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251010035439_create_user_badges.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251010035441_add_typewriter_effect_to_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251010035442_create_profile_widgets.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251010050458_add_typewriter_title_to_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251010103546_add_link_display_style_and_sections.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251010104829_add_container_background_to_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251010110017_add_container_pattern_to_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251010110645_add_pattern_animation_to_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251010133611_add_visibility_toggles_to_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251010134141_add_text_background_to_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251010134855_update_default_font_to_system.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251010135407_add_visible_to_user_badges.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251010135908_add_pattern_opacity_to_user_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251010150620_add_verified_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251010173540_rename_early_adopter_to_beta_tester.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251010204959_remove_font_family_default.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251011035521_add_avatar_effect_to_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251011050154_add_tick_color_to_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251011063738_add_comprehensive_privacy_controls.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251011150823_set_defaults_for_new_privacy_fields.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251011212159_create_forwarded_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251012163307_add_hide_share_button_to_user_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251012172541_add_extend_layout_to_user_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251015150616_add_hide_community_posts_to_user_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251015165346_add_link_highlight_effect_to_user_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251016063756_add_thumbnail_url_to_profile_links.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251016151551_add_display_style_to_profile_links.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251016152338_add_highlight_effect_to_profile_links.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251016154738_add_circular_links_position_to_user_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251017051314_consolidate_conversation_type.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251017063439_add_show_qr_code_to_user_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251017070808_remove_show_qr_code_from_user_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251017160525_drop_approved_senders_table.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251017160604_cleanup_orphaned_indexes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251018050726_add_post_types_support.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251018124609_add_comprehensive_moderation_features.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251018162421_add_onboarding_fields.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251018234059_add_contacts_system.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251019041333_add_email_signature_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251021165630_create_email_unsubscribes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251025131913_create_vpn_tables.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251026013643_add_bandwidth_quota_to_vpn_user_configs.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251026014738_add_rate_limit_to_vpn_user_configs.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251026025047_add_trust_level_system.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251026043133_remove_siem_intel_news_tables.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251026052454_remove_github_fields_from_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251026081602_add_minimum_trust_level_to_vpn_servers.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251026090202_change_vpn_server_foreign_key_to_cascade.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251027022325_add_notification_preferences.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251027023409_add_discussion_and_comment_notification_preferences.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251028160556_add_email_performance_indexes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251101202100_add_gallery_to_post_type_constraint.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251102020000_create_post_views.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251102040000_add_performance_indexes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251102050000_add_preferred_email_domain_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251106000000_create_trusted_devices.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251108000001_create_activitypub_tables.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251108000002_generate_activitypub_keys_for_existing_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251108000003_make_follower_id_nullable.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251108000004_make_conversation_id_nullable_for_federation.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251108000005_make_followed_id_nullable.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251108000006_create_post_boosts.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251108000007_create_activitypub_relays.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251109164600_set_manually_approve_followers_default_true.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251109200000_drop_relays_table.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251109203000_create_federated_likes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251110000001_create_lists.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251110000002_add_published_at_to_activitypub_actors.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251111120000_create_custom_emojis.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251113000001_add_content_warning_to_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251113000002_add_federation_to_message_reactions.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251116000000_add_community_support_to_activitypub.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251117000001_add_federated_source_to_conversations.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251117000002_regenerate_activitypub_keys_for_lemmy.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251118000001_change_activitypub_fields_to_text.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251119000001_make_message_reactions_user_id_nullable.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251203150755_add_federated_dislikes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251203150938_add_federated_boosts.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251206171650_create_group_follows.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251209000000_add_missing_performance_indexes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251213071259_extend_email_message_fields.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251213170721_add_activitypub_performance_indexes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251216160458_drop_old_username_index.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251217070133_enhance_recommendation_tracking.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251218053438_add_processed_to_activities.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251218055225_add_background_job_tables.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251218172906_add_oban_jobs_table.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251223045423_add_email_restriction_fields.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251224161418_add_unique_actor_username_domain.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251225205440_create_device_tokens.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251228000001_fix_duplicate_handles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251228000002_add_calls_table.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251228000003_add_friend_requests_table.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251229015026_create_chat_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251229020637_migrate_chat_messages_data.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251230180851_create_calendars.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251230181000_extend_contacts_for_carddav.exs create mode 100644 apps/elektrine/priv/repo/migrations/20251231174204_add_pgp_support.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260102051253_add_quote_support_to_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260110225401_create_email_threads.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260110225402_add_jmap_fields_to_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260110225403_create_jmap_state_tracking.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260110225404_create_email_submissions.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260111043859_add_profile_mode_to_user_profiles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260112200000_add_email_features.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260113024544_create_rss_and_saved_items.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260115100000_add_registered_via_onion_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260117120000_create_user_integrations.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260118215247_add_composite_performance_indexes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260125082934_add_category_to_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260125085508_create_api_tokens.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260125090103_create_data_exports.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260125232349_add_draft_status_to_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260126072102_add_lemmy_counts_cache.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260127034957_create_signing_keys.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260127035230_migrate_existing_keys_to_signing_keys.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260127090905_add_mrf_policies_to_instances.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260127091426_create_relay_subscriptions.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260127100000_create_remote_interactions.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260127100100_add_voters_tracking_to_polls.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260127100200_add_emoji_url_to_message_reactions.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260127100300_add_reachability_to_instances.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260127100400_create_hashtag_follows.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260127120000_create_oauth_tables.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260128142101_add_nodeinfo_to_instances.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260128144518_drop_subscriptions_table.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260129145416_create_passkey_credentials.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260203150001_create_billing_subscriptions.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260203160001_create_subscription_products.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260205100001_add_pgp_wkd_hash_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260209020000_remove_ai_pentest_feature.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260215110000_create_password_vault_entries.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260215123000_create_user_mutes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260215124000_create_developer_webhooks.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260215130000_create_messaging_servers.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260215141000_add_federation_fields_to_messaging_servers.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260215152000_add_messaging_federation_event_stream_tables.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260215165000_add_messaging_federation_outbox_and_archive.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260216190000_create_api_token_revocations.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260218090000_create_email_suppressions.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260218123000_add_bluesky_bridge_fields.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260218133000_add_bluesky_inbound_tracking.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260222120000_create_chat_user_hidden_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260222130000_create_messaging_federation_peer_policies.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260222143000_add_arblarg_replay_and_idempotency.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260223010000_backfill_server_channel_visibility.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260223101500_create_password_vault_settings.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260223143000_add_arbp_chat_projections.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260224083500_create_imap_subscriptions.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260224105000_add_messages_activitypub_url_lookup_index.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260224112000_add_hot_path_feed_and_email_indexes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260224124500_add_activitypub_actor_type_hot_path_index.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260303110000_create_developer_webhook_deliveries.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260305100000_create_email_category_preferences.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260308100000_add_arbp_fast_path_projections.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260308113000_create_messaging_federation_discovered_peers.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260308120000_add_arbp_presence_expiry_and_membership_states.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260308121000_add_continuity_fields_to_messaging_federation_discovered_peers.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260308133000_create_email_custom_domains.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260308173000_add_dkim_fields_to_email_custom_domains.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260308174500_relax_dkim_constraints_on_email_custom_domains.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260308210000_create_messaging_federation_invite_states.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260309101500_scope_messaging_federation_event_ids_by_origin.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260310120000_hash_recovery_tokens.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260310153000_add_private_mailbox_storage.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260310170000_add_endpoint_host_and_client_mtu_to_vpn_servers.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260311103000_add_canonical_activitypub_refs_to_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260311112000_backfill_messages_canonical_activitypub_refs.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260314120000_create_jmap_email_tombstones.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260314133000_create_jmap_email_changes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260314153000_normalize_invite_codes_case_insensitively.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260314170000_add_stripe_customer_id_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260314193000_add_one_time_support_to_subscription_products.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260314195000_create_registration_checkouts.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260314203000_normalize_post_views_for_trust_tracking.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260315100000_create_messaging_federation_account_presence_states.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260315113000_add_federated_fields_to_community_bans.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260315133000_create_messaging_federation_room_presence_states.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260316091500_create_messaging_federation_call_sessions.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260316143000_create_profile_custom_domains.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260320074000_add_db_hot_path_indexes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260320100000_add_redemption_fields_to_registration_checkouts.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260322194500_create_dns_zones_and_records.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260322200000_add_dns_zone_soa_fields.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260322203000_remove_dns_verification_fields.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260323033500_dedupe_activitypub_instances_and_enforce_ci_uniqueness.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260323120000_add_oidc_fields_to_oauth_authorizations.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260323123000_create_oidc_signing_keys.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260323124000_add_oidc_fields_to_oauth_tokens.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260326143000_add_admin_email_message_indexes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260327121000_add_managed_dns_services.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260329173000_add_dnssec_and_mail_hardening_fields_to_dns_records.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260331110000_create_personal_files.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260331123000_add_expires_at_to_file_shares.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260331150000_create_stored_folders.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260331151000_add_access_and_password_to_file_shares.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260402110000_add_theme_overrides_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260404121000_hash_oauth_secrets_and_tokens.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260404133000_add_auth_valid_after_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260404143000_hash_export_and_registration_tokens.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260404180000_create_dns_query_stats.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260405120000_add_link_preview_to_chat_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260405143000_create_profile_site_visits.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260407120000_add_force_https_to_dns_zones.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260409130000_split_chat_conversations_from_social_spaces.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260409143000_repoint_chat_foreign_keys_to_chat_conversations.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260409153000_create_chat_moderation_tables_and_safety_checks.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260409154500_delete_legacy_chat_conversations.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260411120000_add_mastodon_poll_fields.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260412214000_add_messages_reply_to_inserted_at_id_index.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260416143000_add_federation_and_imap_hot_path_indexes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260418165000_add_built_in_subdomain_mode_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260420110000_create_notes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260420121500_create_note_shares.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260421103000_add_protocol_to_vpn_servers.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260421120000_allow_multiple_vpn_protocols_per_public_ip.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260422163800_rename_drive_tables.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260422165500_rename_social_messaging_tables.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260422170500_restore_conversations_table_name.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260422171500_rename_conversations_to_social_conversations.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260422174500_rename_email_tables.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260425000000_add_email_mailboxes_username_ci_unique.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260425120000_add_encrypted_payload_to_note_shares.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260425121000_add_pastebin_options_to_note_shares.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260425122000_add_burn_after_read_to_drive_shares.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260425133000_add_hourly_dns_query_stats.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260425143000_add_alias_rules_engine_fields.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260425151000_add_global_email_unsubscribe_unique_index.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260425153000_allow_community_bans_for_chat_conversations.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260425193000_add_legacy_activitypub_ref_indexes_to_social_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260425210000_add_literal_legacy_activitypub_ref_indexes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260426190000_add_hot_path_social_feed_indexes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260426193000_add_broader_social_surface_indexes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260428120000_create_site_page_visits.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260428121000_add_site_sessions.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260430031000_create_atomine_proofs.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260430032000_add_claim_fields_to_atomine_proofs.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260430033000_add_live_mode_to_atomine_proofs.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260501090000_create_atomine_trust_sessions.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260501091000_create_connected_accounts.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260502120000_create_atomine_attestations.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260502121000_add_global_dns_proof_uniqueness.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260503000000_update_oban_to_v14.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260503120000_create_atomine_credits.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260503132000_add_chat_messages_search_index_gin.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260503134000_add_chat_client_encryption.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260503135000_add_chat_remote_encryption_devices.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260503170000_add_activitypub_request_replays.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260503171000_add_encrypted_password_vault_metadata.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260503172000_add_chat_encryption_device_trust_fields.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260503173000_add_mailbox_category_filter_settings.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260504120000_backfill_chat_conversation_hashes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260507120000_change_activitypub_actor_profile_fields_to_text.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260509120000_ensure_fast_federated_timeline_index.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260510120000_encrypt_notes_content.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260513011500_create_external_email_deliveries.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260513013000_expand_external_email_deliveries.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260513020000_create_external_email_ops_tables.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260513021000_create_internal_email_deliveries.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260516100000_add_db_indexes_for_site_analytics_and_oban_queue_stats.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260516110000_rename_password_vault_to_nerve.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260517043000_add_site_session_rollup_indexes_for_analytics.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260518120000_add_global_atomine_proof_claim_grant_index.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260520061000_create_static_site_deployments.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260520062000_add_github_webhook_fields_to_static_site_deployments.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260523214500_rename_atomine_shield_to_gate_in_dns_metadata.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260601000100_default_built_in_subdomain_mode_to_path.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260603180000_restore_existing_platform_profile_subdomains.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260604120000_add_spam_filter_enabled_to_mailboxes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260604121000_add_auto_reply_enabled_to_mailboxes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260606170000_add_oban_and_timeline_hot_path_indexes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260613000000_add_failing_since_to_custom_domains.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260620000000_backfill_orphaned_thread_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260621000000_create_uptime_monitors.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260621000100_create_uptime_checks.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260621000200_create_uptime_incidents.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260623000000_add_domain_identity_to_oauth_grants.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260623000100_create_profile_per_site_identities.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260623000200_hash_and_encrypt_share_tokens.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260623000300_encrypt_webhook_secrets.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260623000400_hash_email_unsubscribe_tokens.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260623000500_hash_and_encrypt_device_tokens.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260628000000_create_kairo_tables.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260628120000_create_account_master_keys.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260628130000_add_kairo_source_encryption.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260629120000_add_kairo_content_at_rest.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260629140000_drop_notes_tables.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260630000000_add_remote_engagement_counts_to_social_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260630001000_create_social_message_stats.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260630001100_guard_boosts_against_deleted_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260630001200_create_activitypub_tombstones.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701000000_create_activitypub_object_deliveries.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701000100_create_social_thread_mutes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701000200_add_group_key_to_notifications.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701000300_create_api_markers.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701000400_create_social_filters.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701000500_add_expiration_to_user_mutes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701000600_create_account_notes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701000700_create_bookmark_folders.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701000800_create_web_push_subscriptions.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701000900_create_suggested_account_dismissals.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701001000_create_account_endorsements.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701001100_create_account_subscriptions.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701001200_create_client_app_settings.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701001300_create_social_scrobbles.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701001400_add_birthday_fields_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701001500_add_account_migration_metadata_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701001600_add_notification_privacy_settings_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260701001700_add_account_list_privacy_settings_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260704100001_create_messaging_channel_categories.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260704100002_create_chat_message_pins.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260704120001_create_chat_threads.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260704120002_add_thread_id_to_chat_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260704130001_create_chat_webhooks.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260704130002_add_webhook_id_to_chat_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260704130003_add_is_bot_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260704140000_kairo_dedup_and_project_nilify.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260704150000_add_profile_editing_deploy_link_image_features.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260704160000_create_search_domain_rules.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260704170000_create_user_sessions.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260705000000_add_social_messages_community_actor_uri_id_desc_index.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260706000000_add_nameserver_set_to_dns_zones.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260707000000_create_social_recommendation_items.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260708061000_add_site_session_referrer_index_for_analytics.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260708062000_create_public_site_analytics_rollups.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260708094000_dedupe_hashtags_by_normalized_name.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260708100000_add_social_messages_remote_actor_feed_index.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260710000000_add_federated_timeline_view_indexes.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260710010000_create_web_index.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260712010000_add_encrypted_raw_source_to_email_messages.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260712090000_add_theme_mode_to_users.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260725120000_create_link_archives.exs create mode 100644 apps/elektrine/priv/repo/migrations/20260725130000_create_booking_pages.exs create mode 100644 apps/elektrine/priv/repo/seed_fediverse_posts.exs create mode 100644 apps/elektrine/priv/repo/seed_platform_demo_screenshots.exs create mode 100644 apps/elektrine/priv/repo/seed_test_emails.exs create mode 100644 apps/elektrine/priv/repo/seeds.exs create mode 100644 apps/elektrine/priv/repo/seeds_invite_codes.exs create mode 100644 apps/elektrine/priv/repo/test_recategorizer.exs create mode 100644 apps/elektrine/priv/static/favicon.ico create mode 100644 apps/elektrine/priv/static/fonts/Geist-Black.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-BlackItalic.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-Bold.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-BoldItalic.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-ExtraBold.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-ExtraBoldItalic.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-ExtraLight.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-ExtraLightItalic.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-Italic-cyrillic-ext.woff2 create mode 100644 apps/elektrine/priv/static/fonts/Geist-Italic-cyrillic.woff2 create mode 100644 apps/elektrine/priv/static/fonts/Geist-Italic-latin-ext.woff2 create mode 100644 apps/elektrine/priv/static/fonts/Geist-Italic-latin.woff2 create mode 100644 apps/elektrine/priv/static/fonts/Geist-Italic-vietnamese.woff2 create mode 100644 apps/elektrine/priv/static/fonts/Geist-Italic.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-Light.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-LightItalic.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-Medium.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-MediumItalic.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-Regular.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-SemiBold.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-SemiBoldItalic.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-Thin.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-ThinItalic.ttf create mode 100644 apps/elektrine/priv/static/fonts/Geist-cyrillic-ext.woff2 create mode 100644 apps/elektrine/priv/static/fonts/Geist-cyrillic.woff2 create mode 100644 apps/elektrine/priv/static/fonts/Geist-latin-ext.woff2 create mode 100644 apps/elektrine/priv/static/fonts/Geist-latin.woff2 create mode 100644 apps/elektrine/priv/static/fonts/Geist-vietnamese.woff2 create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Black.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-BlackItalic.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Bold.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-BoldItalic.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-ExtraBold.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-ExtraBoldItalic.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-ExtraLight.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-ExtraLightItalic.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Italic-cyrillic-ext.woff2 create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Italic-cyrillic.woff2 create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Italic-latin-ext.woff2 create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Italic-latin.woff2 create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Italic-symbols2.woff2 create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Italic-vietnamese.woff2 create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Italic.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Light.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-LightItalic.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Medium.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-MediumItalic.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Regular.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-SemiBold.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-SemiBoldItalic.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Thin.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-ThinItalic.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-cyrillic-ext.woff2 create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-cyrillic.woff2 create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-latin-ext.woff2 create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-latin.woff2 create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-symbols2.woff2 create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-vietnamese.woff2 create mode 100644 apps/elektrine/priv/static/fonts/GeistPixel-Regular-VariableFont_ELSH.ttf create mode 100644 apps/elektrine/priv/static/fonts/GeistPixel-latin-ext.woff2 create mode 100644 apps/elektrine/priv/static/fonts/GeistPixel-latin.woff2 create mode 100644 apps/elektrine/priv/static/images/android-chrome-192x192.png create mode 100644 apps/elektrine/priv/static/images/favicon-16x16.png create mode 100644 apps/elektrine/priv/static/images/favicon-32x32.png create mode 100644 apps/elektrine/priv/static/images/favicon.ico create mode 100644 apps/elektrine/priv/static/images/home/.gitkeep create mode 100644 apps/elektrine/priv/static/images/home/buttons/.gitkeep create mode 100644 apps/elektrine/priv/static/images/home/buttons/button-01.gif create mode 100644 apps/elektrine/priv/static/images/home/buttons/button-02.gif create mode 100644 apps/elektrine/priv/static/images/home/buttons/button-03.gif create mode 100644 apps/elektrine/priv/static/images/home/buttons/button-04.gif create mode 100644 apps/elektrine/priv/static/images/home/buttons/button-05.gif create mode 100644 apps/elektrine/priv/static/images/home/buttons/button-06.gif create mode 100644 apps/elektrine/priv/static/images/home/buttons/button-07.gif create mode 100644 apps/elektrine/priv/static/images/home/buttons/button-08.gif create mode 100644 apps/elektrine/priv/static/images/home/poster-01.jpg create mode 100644 apps/elektrine/priv/static/images/mark.png create mode 100644 apps/elektrine/priv/static/openapi/ext-v1.yaml create mode 100644 apps/elektrine/priv/static/robots.txt create mode 100644 apps/elektrine/priv/static/sw.js create mode 100755 apps/elektrine/rel/env.sh.eex create mode 100755 apps/elektrine/rel/overlays/bin/migrate create mode 100755 apps/elektrine/rel/overlays/bin/migrate.bat create mode 100755 apps/elektrine/rel/overlays/bin/server create mode 100755 apps/elektrine/rel/overlays/bin/server.bat create mode 100644 apps/elektrine/test/elektrine/account_identifiers_test.exs create mode 100644 apps/elektrine/test/elektrine/accounts/account_import_worker_test.exs create mode 100644 apps/elektrine/test/elektrine/accounts/account_notes_test.exs create mode 100644 apps/elektrine/test/elektrine/accounts/app_password_test.exs create mode 100644 apps/elektrine/test/elektrine/accounts/capabilities_test.exs create mode 100644 apps/elektrine/test/elektrine/accounts/connected_account_test.exs create mode 100644 apps/elektrine/test/elektrine/accounts/domain_blocks_test.exs create mode 100644 apps/elektrine/test/elektrine/accounts/invite_codes_test.exs create mode 100644 apps/elektrine/test/elektrine/accounts/muting_test.exs create mode 100644 apps/elektrine/test/elektrine/accounts/passkeys_test.exs create mode 100644 apps/elektrine/test/elektrine/accounts/recovery_email_verification_test.exs create mode 100644 apps/elektrine/test/elektrine/accounts/storage_test.exs create mode 100644 apps/elektrine/test/elektrine/accounts/trust_level_test.exs create mode 100644 apps/elektrine/test/elektrine/accounts/two_factor_test.exs create mode 100644 apps/elektrine/test/elektrine/accounts/user_test.exs create mode 100644 apps/elektrine/test/elektrine/accounts_privacy_test.exs create mode 100644 apps/elektrine/test/elektrine/accounts_status_test.exs create mode 100644 apps/elektrine/test/elektrine/accounts_test.exs create mode 100644 apps/elektrine/test/elektrine/acme/wildcard_renewal_worker_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/activity_delivery_worker_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/actor_secrets_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/actor_url_security_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/boundary_guardrails_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/collection_fetcher_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/community_fetcher_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/domain_migration_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/fetch_remote_post_replies_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/fetch_remote_user_timeline_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/fetcher_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/get_or_fetch_actor_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/handler_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/handlers/announce_handler_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/handlers/create_handler_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/handlers/delete_handler_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/handlers/flag_handler_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/handlers/follow_handler_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/handlers/like_handler_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/handlers/move_handler_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/handlers/update_handler_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/helpers_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/http_signature_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/instances_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/lemmy_api_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/lemmy_comment_backfill_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/mastodon_api_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/mentions_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/mrf/anti_followbot_policy_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/mrf/hellthread_policy_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/mrf/keyword_policy_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/mrf/pleroma_policy_batch_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/mrf/simple_policy_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/nodeinfo_fetcher_worker_security_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/normalizer_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/object_validator_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/outbox_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/pipeline_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/process_activity_worker_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/publisher_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/refresh_counts_worker_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/relay_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/replies_fetcher_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/replies_ingest_worker_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/reply_fetch_policy_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/signature_retry_worker_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/signature_verification_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/thread_backfill_worker_test.exs create mode 100644 apps/elektrine/test/elektrine/activitypub/undo_resolver_test.exs create mode 100644 apps/elektrine/test/elektrine/admin_test.exs create mode 100644 apps/elektrine/test/elektrine/atomine_proof_graph_test.exs create mode 100644 apps/elektrine/test/elektrine/bluesky_inbound_test.exs create mode 100644 apps/elektrine/test/elektrine/bluesky_managed_test.exs create mode 100644 apps/elektrine/test/elektrine/bluesky_test.exs create mode 100644 apps/elektrine/test/elektrine/booking_confirmation_test.exs create mode 100644 apps/elektrine/test/elektrine/booking_test.exs create mode 100644 apps/elektrine/test/elektrine/calendar/availability_test.exs create mode 100644 apps/elektrine/test/elektrine/calendar/availability_zone_test.exs create mode 100644 apps/elektrine/test/elektrine/calendar/busy_blocks_test.exs create mode 100644 apps/elektrine/test/elektrine/calendar/calendar_test.exs create mode 100644 apps/elektrine/test/elektrine/calendar/icalendar_test.exs create mode 100644 apps/elektrine/test/elektrine/calls/transport_test.exs create mode 100644 apps/elektrine/test/elektrine/calls_test.exs create mode 100644 apps/elektrine/test/elektrine/captcha_test.exs create mode 100644 apps/elektrine/test/elektrine/db/write_guard_test.exs create mode 100644 apps/elektrine/test/elektrine/developer/exports/account_exporter_test.exs create mode 100644 apps/elektrine/test/elektrine/developer/webhook_delivery_worker_test.exs create mode 100644 apps/elektrine/test/elektrine/developer_exports_test.exs create mode 100644 apps/elektrine/test/elektrine/developer_tokens_test.exs create mode 100644 apps/elektrine/test/elektrine/developer_webhooks_test.exs create mode 100644 apps/elektrine/test/elektrine/discord_test.exs create mode 100644 apps/elektrine/test/elektrine/domains_test.exs create mode 100644 apps/elektrine/test/elektrine/drive_test.exs create mode 100644 apps/elektrine/test/elektrine/emojis_test.exs create mode 100644 apps/elektrine/test/elektrine/encrypted_search_test.exs create mode 100644 apps/elektrine/test/elektrine/encryption_test.exs create mode 100644 apps/elektrine/test/elektrine/friends_test.exs create mode 100644 apps/elektrine/test/elektrine/home_blog_test.exs create mode 100644 apps/elektrine/test/elektrine/http/safe_fetch_test.exs create mode 100644 apps/elektrine/test/elektrine/input_test.exs create mode 100644 apps/elektrine/test/elektrine/job_queue_test.exs create mode 100644 apps/elektrine/test/elektrine/link_archive_test.exs create mode 100644 apps/elektrine/test/elektrine/mail_client_settings_test.exs create mode 100644 apps/elektrine/test/elektrine/mailer/delivery_worker_test.exs create mode 100644 apps/elektrine/test/elektrine/markers_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/activitypub_ref_lookup_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/arblarg_extension_conformance_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/arblarg_golden_vectors_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/arblarg_profiles_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/arblarg_sdk_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/channel_categories_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/chat_conversations_credits_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/chat_conversations_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/chat_message_pins_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/chat_messages_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/chat_threads_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/chat_webhooks_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/custom_domain_aliases_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/federated_message_normalization_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/federation/config_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/federation_dm_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/federation_errors_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/federation_ingress_rate_limiter_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/federation_outbox_worker_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/federation_peer_discovery_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/federation_peer_policy_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/federation_pins_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/federation_protocol_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/federation_remote_join_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/federation_room_participation_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/federation_session_websock_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/federation_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/federation_threads_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/messages_mentions_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/messages_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/moderation_tools_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/reference_peer_interop_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/servers_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/slash_commands_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/social_message_security_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging/voice_channels_test.exs create mode 100644 apps/elektrine/test/elektrine/messaging_encryption_test.exs create mode 100644 apps/elektrine/test/elektrine/notifications/federation_notifications_test.exs create mode 100644 apps/elektrine/test/elektrine/notifications_test.exs create mode 100644 apps/elektrine/test/elektrine/oauth/app_test.exs create mode 100644 apps/elektrine/test/elektrine/paths_test.exs create mode 100644 apps/elektrine/test/elektrine/platform/modules_test.exs create mode 100644 apps/elektrine/test/elektrine/platform/runtime_config_validator_test.exs create mode 100644 apps/elektrine/test/elektrine/privacy_test.exs create mode 100644 apps/elektrine/test/elektrine/profile_custom_domains_test.exs create mode 100644 apps/elektrine/test/elektrine/profile_per_site_identities_test.exs create mode 100644 apps/elektrine/test/elektrine/profiles/profile_widget_test.exs create mode 100644 apps/elektrine/test/elektrine/profiles/static_site_deployment_test.exs create mode 100644 apps/elektrine/test/elektrine/profiles_test.exs create mode 100644 apps/elektrine/test/elektrine/push/web_push_client_test.exs create mode 100644 apps/elektrine/test/elektrine/push_test.exs create mode 100644 apps/elektrine/test/elektrine/reports_test.exs create mode 100644 apps/elektrine/test/elektrine/rss/fetch_feed_worker_test.exs create mode 100644 apps/elektrine/test/elektrine/rss/parser_test.exs create mode 100644 apps/elektrine/test/elektrine/rss_test.exs create mode 100644 apps/elektrine/test/elektrine/runtime_env_test.exs create mode 100644 apps/elektrine/test/elektrine/search/domain_rules_test.exs create mode 100644 apps/elektrine/test/elektrine/search/paige_rate_limiter_test.exs create mode 100644 apps/elektrine/test/elektrine/search_test.exs create mode 100644 apps/elektrine/test/elektrine/secrets/backfill_test.exs create mode 100644 apps/elektrine/test/elektrine/secrets/encrypted_string_test.exs create mode 100644 apps/elektrine/test/elektrine/security/safe_external_url_test.exs create mode 100644 apps/elektrine/test/elektrine/security/url_validator_test.exs create mode 100644 apps/elektrine/test/elektrine/security_alerts_test.exs create mode 100644 apps/elektrine/test/elektrine/social/engagement_counts_test.exs create mode 100644 apps/elektrine/test/elektrine/social/expired_data_cleanup_worker_test.exs create mode 100644 apps/elektrine/test/elektrine/static_sites/github_deploy_worker_security_test.exs create mode 100644 apps/elektrine/test/elektrine/static_sites_test.exs create mode 100644 apps/elektrine/test/elektrine/strings_test.exs create mode 100644 apps/elektrine/test/elektrine/subscriptions_test.exs create mode 100644 apps/elektrine/test/elektrine/theme_test.exs create mode 100644 apps/elektrine/test/elektrine/uploads_test.exs create mode 100644 apps/elektrine/test/elektrine/vault_test.exs create mode 100644 apps/elektrine/test/elektrine/web_index/extractor_test.exs create mode 100644 apps/elektrine/test/elektrine/web_index/fetcher_test.exs create mode 100644 apps/elektrine/test/elektrine/web_index/robots_test.exs create mode 100644 apps/elektrine/test/elektrine/web_index_test.exs create mode 100644 apps/elektrine/test/support/channel_case.ex create mode 100644 apps/elektrine/test/support/conn_case.ex create mode 100644 apps/elektrine/test/support/data_case.ex create mode 100644 apps/elektrine/test/support/feature_case.ex create mode 100644 apps/elektrine/test/support/fixtures/accounts_fixtures.ex create mode 100644 apps/elektrine/test/support/fixtures/email_fixtures.ex create mode 100644 apps/elektrine/test/support/fixtures/social_fixtures.ex create mode 100644 apps/elektrine/test/test_helper.exs create mode 100644 apps/elektrine_dns/README.md create mode 100644 apps/elektrine_dns/lib/elektrine/dns.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/application.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/authority.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/domain_health.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/generators/bluesky.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/generators/mail.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/generators/turn.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/generators/vpn.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/generators/web.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/health_monitor.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/mail_security.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/managed_records.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/packet.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/profile_wildcard_bootstrap.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/query.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/query_stat.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/query_stats_buffer.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/record.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/recursive.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/recursive_cache.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/recursive_transport.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/request_guard.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/service_binding.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/tcp_server.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/udp_server.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/zone.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/zone_cache.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/zone_change_listener.ex create mode 100644 apps/elektrine_dns/lib/elektrine/dns/zone_service_config.ex create mode 100644 apps/elektrine_dns/lib/elektrine_dns_web.ex create mode 100644 apps/elektrine_dns/lib/elektrine_dns_web/components/platform/e_nav.ex create mode 100644 apps/elektrine_dns/lib/elektrine_dns_web/controllers/api/dns_controller.ex create mode 100644 apps/elektrine_dns/lib/elektrine_dns_web/live/dns_live/index.ex create mode 100644 apps/elektrine_dns/lib/mix/tasks/elektrine.dns.ensure_profile_wildcard.ex create mode 100644 apps/elektrine_dns/mix.exs create mode 100644 apps/elektrine_dns/test/elektrine/dns/authority_cache_test.exs create mode 100644 apps/elektrine_dns/test/elektrine/dns/health_monitor_test.exs create mode 100644 apps/elektrine_dns/test/elektrine/dns/managed_records_test.exs create mode 100644 apps/elektrine_dns/test/elektrine/dns/packet_test.exs create mode 100644 apps/elektrine_dns/test/elektrine/dns/profile_wildcard_test.exs create mode 100644 apps/elektrine_dns/test/elektrine/dns/query_stats_buffer_test.exs create mode 100644 apps/elektrine_dns/test/elektrine/dns/query_test.exs create mode 100644 apps/elektrine_dns/test/elektrine/dns/record_health_check_test.exs create mode 100644 apps/elektrine_dns/test/elektrine/dns/recursive_test.exs create mode 100644 apps/elektrine_dns/test/elektrine/dns/request_guard_test.exs create mode 100644 apps/elektrine_dns/test/elektrine/dns/zone_change_listener_test.exs create mode 100644 apps/elektrine_dns/test/elektrine/dns/zone_verification_test.exs create mode 100644 apps/elektrine_dns/test/elektrine/dns_analytics_test.exs create mode 100644 apps/elektrine_dns/test/elektrine/dns_context_test.exs create mode 100644 apps/elektrine_dns/test/test_helper.exs create mode 100644 apps/elektrine_email/.gitignore create mode 100644 apps/elektrine_email/README.md create mode 100644 apps/elektrine_email/lib/elektrine/email.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/alias.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/aliases.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/attachment_storage.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/auto_replies.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/auto_reply.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/blocked_sender.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/blocked_senders.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/cache.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/cache_hooks.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/cached.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/categorizer.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/category_preference.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/category_preferences.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/contact.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/contact_group.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/contacts.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/custom_domain.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/custom_domain_health_worker.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/custom_domains.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/custom_folders.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/deliverability.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/dkim.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/export.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/exports.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/external_delivery.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/external_delivery_alerts.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/external_delivery_attempt.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/external_delivery_control.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/external_delivery_maintenance_worker.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/external_delivery_metric_snapshot.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/external_delivery_worker.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/external_domain_throttle.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/filter.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/filters.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/folder.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/folders.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/forwarded_message.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/haraka_adapter.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/haraka_admin.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/haraka_client.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/header_decoder.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/header_sanitizer.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/imap_subscription.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/imap_subscriptions.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/inbound_authentication.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/inbound_routing.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/internal_delivery.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/internal_delivery_attempt.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/internal_delivery_worker.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/internal_origin.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/label.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/labels.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/list_types.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/mailbox.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/mailbox_adapter.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/mailbox_encryption.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/mailboxes.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/message.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/messages.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/mime_body_extractor.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/payload_sanitizer.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/pgp.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/pgp_key_cache.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/processing.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/rate_limiter.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/receiver.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/safe_sender.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/safe_senders.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/sanitizer.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/search.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/send_email_worker.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/sender.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/sender_class.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/suppression.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/suppressions.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/system_delivery.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/system_delivery_worker.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/template.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/templates.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/unsubscribe.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/unsubscribes.ex create mode 100644 apps/elektrine_email/lib/elektrine/email/vcard.ex create mode 100644 apps/elektrine_email/lib/elektrine/email_config.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/append_parser.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/commands.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/commands/append.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/commands/auth.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/commands/idle.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/commands/mailbox.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/commands/message.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/commands/search.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/commands/shared.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/folders.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/helpers.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/idle_tracker.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/rate_limiter.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/recent_state.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/recent_tracker.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/response.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/server.ex create mode 100644 apps/elektrine_email/lib/elektrine/imap/supervisor.ex create mode 100644 apps/elektrine_email/lib/elektrine/jmap/email_change.ex create mode 100644 apps/elektrine_email/lib/elektrine/jmap/email_submission.ex create mode 100644 apps/elektrine_email/lib/elektrine/jmap/email_tombstone.ex create mode 100644 apps/elektrine_email/lib/elektrine/jmap/jmap.ex create mode 100644 apps/elektrine_email/lib/elektrine/jmap/state.ex create mode 100644 apps/elektrine_email/lib/elektrine/jmap/thread.ex create mode 100644 apps/elektrine_email/lib/elektrine/mail/socket.ex create mode 100644 apps/elektrine_email/lib/elektrine/pop3/connection_tracker.ex create mode 100644 apps/elektrine_email/lib/elektrine/pop3/rate_limiter.ex create mode 100644 apps/elektrine_email/lib/elektrine/pop3/server.ex create mode 100644 apps/elektrine_email/lib/elektrine/pop3/supervisor.ex create mode 100644 apps/elektrine_email/lib/elektrine/smtp/rate_limiter.ex create mode 100644 apps/elektrine_email/lib/elektrine/smtp/send_rate_limiter.ex create mode 100644 apps/elektrine_email/lib/elektrine/smtp/server.ex create mode 100644 apps/elektrine_email/lib/elektrine/smtp/supervisor.ex create mode 100644 apps/elektrine_email/lib/elektrine_email/haraka_inbound_worker.ex create mode 100644 apps/elektrine_email/lib/elektrine_email/mcp_tools.ex create mode 100644 apps/elektrine_email/lib/elektrine_email/platform.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/components/email/display.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/components/email/sidebar.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/components/platform/elektrine_nav.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/aliases_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/aliases_html.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/aliases_html/aliases.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/aliases_html/forwarded_messages.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/custom_domains_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/custom_domains_html.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/custom_domains_html/custom_domains.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/haraka_html.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/mailboxes_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/mailboxes_html.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/mailboxes_html/mailboxes.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/messages_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/messages_html.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/messages_html/messages.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/messages_html/user_messages.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/messages_html/view_message.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/messages_html/view_user_message.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/system_emails_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/system_emails_html.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/system_emails_html/new.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/api/alias_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/api/email_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/api/ext_contacts_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/api/ext_email_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/api/mailbox_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/attachment_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/dav/address_book_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/email_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/email_html.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/email_html/print.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/haraka_webhook_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/jmap/api_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/jmap/blob_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/jmap/event_source_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/jmap/session_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/unsubscribe_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/unsubscribe_html.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/unsubscribe_html/confirmed.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/unsubscribe_html/resubscribed.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/unsubscribe_html/show.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/wkd_controller.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/email_preference_helpers.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/contacts_live/index.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/contacts_live/index.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/contacts_live/operations/contact_operations.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/compose.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/compose.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/email_helpers.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/index.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/index.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/alias_operations.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/bulk_operations.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/compose_operations.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/contact_operations.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/message_operations.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/navigation_operations.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/reply_later_operations.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/search_operations.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/selection_operations.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/tab_content.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/raw.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/raw.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/router.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/search.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/search.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/settings.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/settings/content_settings.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/settings/domain_settings.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/settings/filter_settings.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/settings/helpers.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/settings/sender_settings.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/show.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/show.html.heex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/unsubscribe_live/confirmed.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/unsubscribe_live/show.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/user_settings_email.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/plugs/email_ownership_guard.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/plugs/jmap_auth.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/storage_email.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/user_auth_email.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/user_error_helpers.ex create mode 100644 apps/elektrine_email/lib/elektrine_email_web/user_settings_email_controller.ex create mode 100644 apps/elektrine_email/mix.exs create mode 100644 apps/elektrine_email/test/elektrine/email/alias_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/attachment_storage_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/auto_replies_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/categorizer_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/category_preferences_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/custom_domains_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/dkim_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/exports_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/filters_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/haraka_adapter_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/haraka_client_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/header_decoder_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/header_sanitizer_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/inbound_authentication_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/inbound_routing_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/mailbox_adapter_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/mailbox_private_storage_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/mailbox_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/message_operations_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/mime_body_extractor_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/pgp_receive_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/pgp_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/platform_email_headers_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/plus_addressing_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/rate_limiter_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/sanitizer_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/send_email_worker_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/sender_class_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/sender_credit_gate_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/sender_pipeline_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/sender_rate_limit_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/sender_validation_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/suppressions_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/system_delivery_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/unsubscribes_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email/vcard_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email_basic_functions_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email_encryption_test.exs create mode 100644 apps/elektrine_email/test/elektrine/email_test.exs create mode 100644 apps/elektrine_email/test/elektrine/imap/commands_test.exs create mode 100644 apps/elektrine_email/test/elektrine/imap/helpers_test.exs create mode 100644 apps/elektrine_email/test/elektrine/imap/response_security_test.exs create mode 100644 apps/elektrine_email/test/elektrine/mail/protocol_auth_rate_limiter_test.exs create mode 100644 apps/elektrine_email/test/elektrine/mail/protocol_transcript_test.exs create mode 100644 apps/elektrine_email/test/elektrine/mail_auth/rate_limiter_test.exs create mode 100644 apps/elektrine_email/test/elektrine/pop3/connection_tracker_test.exs create mode 100644 apps/elektrine_email/test/elektrine_email_web/components/email/display_test.exs create mode 100644 apps/elektrine_email/test/elektrine_email_web/live/email_live/email_helpers_test.exs create mode 100644 apps/elektrine_email/test/elektrine_email_web/user_error_helpers_test.exs create mode 100644 apps/elektrine_email/test/fixtures/email/css_preamble_plain.txt create mode 100644 apps/elektrine_email/test/fixtures/imap_client_transcripts.json create mode 100644 apps/elektrine_email/test/support/data_case.ex create mode 100644 apps/elektrine_email/test/support/fixtures/README.md create mode 100644 apps/elektrine_email/test/support/fixtures/accounts_fixtures.ex create mode 100644 apps/elektrine_email/test/support/fixtures/email_fixtures.ex create mode 100644 apps/elektrine_email/test/support/fixtures/pop3-test-cert.pem create mode 100644 apps/elektrine_email/test/support/fixtures/pop3-test-key.pem create mode 100644 apps/elektrine_email/test/support/fixtures/social_fixtures.ex create mode 100644 apps/elektrine_email/test/test_helper.exs create mode 100644 apps/elektrine_nerve/.gitignore create mode 100644 apps/elektrine_nerve/README.md create mode 100644 apps/elektrine_nerve/lib/elektrine/nerve.ex create mode 100644 apps/elektrine_nerve/lib/elektrine/nerve/nerve_entry.ex create mode 100644 apps/elektrine_nerve/lib/elektrine/nerve/nerve_settings.ex create mode 100644 apps/elektrine_nerve/lib/elektrine/nerve/payloads.ex create mode 100644 apps/elektrine_nerve/lib/elektrine_nerve_web.ex create mode 100644 apps/elektrine_nerve/lib/elektrine_nerve_web/api/nerve_controller.ex create mode 100644 apps/elektrine_nerve/lib/elektrine_nerve_web/api/response.ex create mode 100644 apps/elektrine_nerve/lib/elektrine_nerve_web/components/platform/e_nav.ex create mode 100644 apps/elektrine_nerve/lib/elektrine_nerve_web/nerve_live.ex create mode 100644 apps/elektrine_nerve/mix.exs create mode 100644 apps/elektrine_nerve/test/elektrine/nerve_test.exs create mode 100644 apps/elektrine_nerve/test/support/data_case.ex create mode 100644 apps/elektrine_nerve/test/support/fixtures/accounts_fixtures.ex create mode 100644 apps/elektrine_nerve/test/test_helper.exs create mode 100644 apps/elektrine_social/.gitignore create mode 100644 apps/elektrine_social/README.md create mode 100644 apps/elektrine_social/lib/elektrine/social.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/bookmark_folder.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/bookmark_folders.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/bookmarks.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/boosts.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/creator_satisfaction.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/cross_posting.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/discussions.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/drafts.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/federated_feeds.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/feed_policy.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/feed_query.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/fetch_link_preview_worker.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/filter.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/filters.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/hashtag_extractor.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/hashtag_follow.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/hashtag_follows.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/hashtags.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/home_feed.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/home_feed_cache.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/home_feed_fanout_worker.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/home_feed_invalidation_worker.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/likes.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/link_preview_fetcher.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/link_preview_job.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/link_preview_refresh_worker.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/link_preview_worker.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/list.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/list_member.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/lists.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/media_attachments.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/message_policy.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/message_vote.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/notification_policy.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/oembed.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/polls.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/post_boost.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/post_dismissal.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/post_like.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/post_view.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/recommendation_item.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/recommendation_refresh_worker.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/recommendations.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/saved_item.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/scheduled_post_publisher_worker.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/status_reactions.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/suggested_account_dismissal.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/suggested_follows.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/thread_mute.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/thread_mutes.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/timeline_load_verifier.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/timeline_pagination.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/timeline_relationships.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/views.ex create mode 100644 apps/elektrine_social/lib/elektrine/social/votes.ex create mode 100644 apps/elektrine_social/lib/elektrine_social/moderation/iftas_blocklist.ex create mode 100644 apps/elektrine_social/lib/elektrine_social/moderation/iftas_blocklist_worker.ex create mode 100644 apps/elektrine_social/lib/elektrine_social/platform.ex create mode 100644 apps/elektrine_social/lib/elektrine_social/remote_user/metrics.ex create mode 100644 apps/elektrine_social/lib/elektrine_social/remote_user/metrics_worker.ex create mode 100644 apps/elektrine_social/lib/elektrine_social/remote_user/outbox_sync.ex create mode 100644 apps/elektrine_social/lib/elektrine_social/remote_user/outbox_sync_worker.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/platform/e_nav.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/content_journey.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/embedded_post.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/fediverse_follow.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/follow_button.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/poll.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/poll_display.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/post_actions.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/post_reactions.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/post_utilities.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/remote_post_shared.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/reply_item.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/rss_item.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/timeline_post.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/timeline_post_ancestors.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/timeline_post_boost.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/timeline_post_card.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/timeline_post_compact.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/timeline_post_footer.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/timeline_post_media.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/timeline_stream_post.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/ui/image_modal.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/user/hover_card.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/activitypub/actor_request.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/activitypub/signature_actor_verifier.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/activitypub_controller.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/api/ext_social_controller.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/api/social_controller.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/external_interaction_controller.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/media_proxy_controller.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/nodeinfo_controller.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/webfinger_controller.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/community.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/community.html.heex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/index.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/index.html.heex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/operations/flair_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/operations/member_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/operations/moderation_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/operations/post_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/operations/sort_helpers.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/operations/ui_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/operations/voting_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/post.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/post.html.heex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/post_operations/moderation_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/post_operations/reply_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/post_operations/ui_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/post_operations/voting_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/post_router.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/router.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/session_context.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/settings.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/settings.html.heex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/filters_live/index.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/filters_live/index.html.heex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/gallery_live/index.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/gallery_live/index.html.heex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/global_feed_page.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/hashtag_live/show.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/hashtag_live/show.html.heex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/list_live/index.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/list_live/index.html.heex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/list_live/show.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/list_live/show.html.heex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/access_policy.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/ancestor_context_components.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/cached_post_fields.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/counts.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/detail_components.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/detail_state.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/discussion_source.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/display_counts.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/interactions.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/navigation.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/polls.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/quick_reply_components.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/reply_author_components.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/show.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/show.html.heex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/submitted_links.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/surface_helpers.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/threaded_comment_components.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/threading.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/actor_lookup.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/community_post_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/counts.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/follow_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/media_modal.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/post_interaction_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/post_sorting.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/post_state.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/reaction_surfaces.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/reply_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/show.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/show.html.heex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/timeline_loader.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/index.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/index.html.heex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/bookmark_folder_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/helpers.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/image_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/navigation_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/post_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/reply_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/social_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/tracking_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/ui_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/voting_operations.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/post.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/post.html.heex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/reply_context_previews.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/router.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/videos_live/index.ex create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/videos_live/index.html.heex create mode 100644 apps/elektrine_social/lib/mix/tasks/social.verify_timeline_load.ex create mode 100644 apps/elektrine_social/mix.exs create mode 100644 apps/elektrine_social/test/elektrine/social/account_subscription_notifications_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/blocked_instances_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/bookmark_folders_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/bookmarks_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/boosts_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/drafts_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/filters_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/hashtag_extractor_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/hashtags_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/home_feed_cache_invalidation_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/likes_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/link_preview_refresh_worker_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/link_preview_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/media_metadata_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/notification_policy_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/poll_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/recommendation_refresh_worker_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/recommendations_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/remote_user/outbox_sync_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/remote_user_metrics_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/reply_counts_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/status_pins_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/timeline_load_verifier_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/views_test.exs create mode 100644 apps/elektrine_social/test/elektrine/social/votes_test.exs create mode 100644 apps/elektrine_social/test/elektrine_social/moderation/iftas_blocklist_test.exs create mode 100644 apps/elektrine_social/test/elektrine_social_web/components/social/poll_display_test.exs create mode 100644 apps/elektrine_social/test/elektrine_social_web/components/social/post_reactions_test.exs create mode 100644 apps/elektrine_social/test/elektrine_social_web/live/discussions_live/flair_operations_test.exs create mode 100644 apps/elektrine_social/test/elektrine_social_web/live/timeline_live/feed_display_toggles_test.exs create mode 100644 apps/elektrine_social/test/elektrine_social_web/live/timeline_live/post_operations_test.exs create mode 100644 apps/elektrine_social/test/support/data_case.ex create mode 100644 apps/elektrine_social/test/support/fixtures/accounts_fixtures.ex create mode 100644 apps/elektrine_social/test/support/fixtures/email_fixtures.ex create mode 100644 apps/elektrine_social/test/support/fixtures/social_fixtures.ex create mode 100644 apps/elektrine_social/test/test_helper.exs create mode 100644 apps/elektrine_uptime/README.md create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime.ex create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/application.ex create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/check.ex create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/check_scheduler_worker.ex create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/check_worker.ex create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/checker.ex create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/checker/behaviour.ex create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/email.ex create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/incident.ex create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/monitor.ex create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/notifier.ex create mode 100644 apps/elektrine_uptime/lib/elektrine_uptime_web.ex create mode 100644 apps/elektrine_uptime/lib/elektrine_uptime_web/live/uptime_live/index.ex create mode 100644 apps/elektrine_uptime/lib/elektrine_uptime_web/live/uptime_live/index.html.heex create mode 100644 apps/elektrine_uptime/mix.exs create mode 100644 apps/elektrine_uptime/test/elektrine/uptime/check_worker_test.exs create mode 100644 apps/elektrine_uptime/test/elektrine/uptime/checker_test.exs create mode 100644 apps/elektrine_uptime/test/elektrine/uptime/notifier_test.exs create mode 100644 apps/elektrine_uptime/test/elektrine/uptime/uptime_test.exs create mode 100644 apps/elektrine_uptime/test/elektrine_uptime_web/live/uptime_live_test.exs create mode 100644 apps/elektrine_uptime/test/test_helper.exs create mode 100644 apps/elektrine_uptime/test_support/data_case.ex create mode 100644 apps/elektrine_vpn/.gitignore create mode 100644 apps/elektrine_vpn/README.md create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn.ex create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/connection_log.ex create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/health_monitor.ex create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/peer_cache.ex create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/self_hosted_reconciler.ex create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/self_hosted_server.ex create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/self_hosted_shadowsocks_server.ex create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/server.ex create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/shadowsocks_adapter.ex create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/stale_node_reaper_worker.ex create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/stats_aggregator.ex create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/user_config.ex create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/wire_guard_adapter.ex create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn/platform.ex create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web.ex create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/components/platform/elektrine_nav.ex create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/admin/vpn_controller.ex create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/admin/vpn_html.ex create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/admin/vpn_html/confirm_delete_vpn_server.html.heex create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/admin/vpn_html/edit_vpn_server.html.heex create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/admin/vpn_html/edit_vpn_user_config.html.heex create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/admin/vpn_html/new_vpn_server.html.heex create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/admin/vpn_html/vpn_dashboard.html.heex create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/admin/vpn_html/vpn_users.html.heex create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/api/vpn_controller.ex create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/vpn_api_controller.ex create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/live/page_live/vpn_policy.ex create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/live/vpn_live/index.ex create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/live/vpn_live/index.html.heex create mode 100644 apps/elektrine_vpn/lib/mix/tasks/elektrine.vpn.reap_stale_nodes.ex create mode 100644 apps/elektrine_vpn/mix.exs create mode 100644 apps/elektrine_vpn/test/elektrine/vpn/shadowsocks_adapter_test.exs create mode 100644 apps/elektrine_vpn/test/elektrine/vpn/stale_node_reaper_worker_test.exs create mode 100644 apps/elektrine_vpn/test/elektrine/vpn/wire_guard_adapter_test.exs create mode 100644 apps/elektrine_vpn/test/elektrine/vpn_test.exs create mode 100644 apps/elektrine_vpn/test/support/data_case.ex create mode 100644 apps/elektrine_vpn/test/test_helper.exs create mode 100644 apps/elektrine_web/.gitignore create mode 100644 apps/elektrine_web/README.md create mode 100644 apps/elektrine_web/lib/elektrine_web.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/account_settings.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/admin_security.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/atomine_gate.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/atomine_pow.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/canonical_url.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/channels/call_channel.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/channels/mobile_channel.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/channels/presence.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/channels/user_socket.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/channels/voice_channel.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/client_ip.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/core_components.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/datetime/local_time.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/emoji_picker.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/layout/announcement.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/layout/header.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/layout/navigation.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/layouts.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/layouts/admin.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/layouts/app.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/layouts/profile.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/layouts/root.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/notification/bell.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/platform/e_nav.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/platform/elektrine_nav.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/presence/helpers.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/profile/account_extras.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/profile/containers.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/profile/modals.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/social/fediverse_follow.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/social/post_actions.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/social/post_reactions.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/social/post_utilities.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/social/youtube_preview.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/action_toolbar.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/badge.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/brand_icon.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/button.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/card.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/dropdown.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/empty_state.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/floating_panel.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/form.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/icon.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/image_modal.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/loading.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/modal.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/pill_switcher.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/section_header.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/stats_row.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/table.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/uplot_chart.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/user/hover_card.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/user/username_effects.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/components/user/verification_badge.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/announcements_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/announcements_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/communities_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/communities_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/deletion_requests_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/deletion_requests_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/email_delivery_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/email_delivery_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/email_delivery_html/index.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/haraka_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/haraka_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/haraka_html/index.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/invite_codes_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/invite_codes_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/moderation_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/moderation_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/monitoring_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/monitoring_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/security_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/security_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/subscriptions_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/subscriptions_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/users_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/users_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_audit_logs_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_audit_logs_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_audit_logs_html/index.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/account_lookup.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/active_users.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/announcements.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/ban.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/communities.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/content_moderation.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/dashboard.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/deletion_requests.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/edit.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/edit_announcement.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/edit_invite_code.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/edit_product.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/elevate.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/imap_users.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/invite_codes.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/new.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/new_announcement.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/new_invite_code.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/new_product.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/operations.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/pop3_users.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/show_community.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/show_deletion_request.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/subscriptions.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/two_factor_status.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/unsubscribe_stats.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/users.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_birthday_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_credential_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_directory_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_json.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_note_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_registration_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_relationship_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_search_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_status_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/announcement_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/app_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/atomine_attestation_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/auth_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/backup_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/bookmark_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/bookmark_folder_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/calendar_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/chat_compat_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/custom_emoji_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/device_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/direct_conversation_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/domain_block_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/export_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/favourite_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/filter_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/follow_request_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/global_search_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/instance_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/kairo_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/list_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/marker_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/mcp_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/media_attachment_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/meta_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/notification_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/outgoing_follow_request_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/pagination_headers.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/password_reset_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/poll_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/poll_json.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/preference_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/preferences_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/proof_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/push_subscription_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/relationship_import_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/relationship_json.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/report_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/response.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/scheduled_status_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/scrobble_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/search_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/settings_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/static_site_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/status_action_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/status_json.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/status_pin_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/status_reaction_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/status_read_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/suggestion_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/tag_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/timeline_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/trend_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/two_factor_authentication_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/user_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/utility_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/webhook_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/autoconfig_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/bluesky_identity_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/caddy_tls_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/captcha_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/changeset_json.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/connected_account_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/dav/calendar_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/dav/drive_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/dav/principal_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/dav_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/drive_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/drive_share_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/error_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/error_html/403.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/error_html/404.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/error_html/413.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/error_html/500.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/error_json.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/fallback_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/health_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/internal_acme_dns_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/internal_dns_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/internal_edge_proxy_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/link_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/locale_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/mail_security_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/messaging_federation_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/nerve_extension_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_client_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_client_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_client_html/edit.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_client_html/index.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_client_html/new.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_grant_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_grant_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_grant_html/index.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_html/authorize.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/own_root_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/page_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/page_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/page_html/canary.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/passkey_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/password_reset_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/password_reset_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/password_reset_html/edit.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/password_reset_html/new.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/private_attachment_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/profile_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/profile_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/recovery_email_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/recovery_email_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/recovery_email_html/error.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/recovery_email_html/success.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/registration_payment_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/registration_payment_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/registration_payment_html/show.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/sitemap_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/sitemap_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/sitemap_xml/index.xml.eex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/stripe_webhook_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/two_factor_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/two_factor_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/two_factor_html/new.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_registration_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_session_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_settings_controller.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_settings_html.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_settings_html/delete.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_settings_html/edit.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_settings_html/edit_password.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_settings_html/two_factor_manage.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_settings_html/two_factor_new_codes.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_settings_html/two_factor_setup.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/dav/properties.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/dav/response_helpers.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/endpoint.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/gettext.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/github_oidc.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/github_webhooks.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/html_helpers.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/json_log_formatter.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/badge_management.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/badge_management.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/bluesky_bridge.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/emojis.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/emojis.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/federation.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/messaging_federation.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/relays.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/reports_dashboard.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/announcement_cache.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/atomine_proofs_live/show.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/atomine_proofs_live/show.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/auth_hooks.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/auth_live/login.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/auth_live/password_reset.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/auth_live/password_reset_edit.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/auth_live/register.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/auth_live/two_factor.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/booking_live/cancel.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/booking_live/manage.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/booking_live/show.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/calendar_live/index.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/calendar_live/index.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/calendar_live/operations/calendar_operations.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/components/report_modal.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/drive_live.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/drive_live.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/e_nav_live.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/friends_live.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/friends_live.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/helpers/post_state_helpers.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/hooks/current_path_hook.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/hooks/notification_count_hook.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/hooks/platform_module_hook.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/hooks/presence_hook.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/hooks/timezone_hook.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/kairo_live/index.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/link_archive_live/index.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/nav_badge_live.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/notification_helpers.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/notifications_live.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/notifications_live.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/onboarding_live.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/page_live/about.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/page_live/contact.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/page_live/dev_flash_test.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/page_live/faq.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/page_live/home.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/page_live/legal.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/page_live/privacy.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/page_live/terms.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/post_interactions.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/analytics.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/analytics.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/design_sections.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/design_theme_sections.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/domain_analytics.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/domain_analytics.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/domains.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/edit.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/edit.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/edit_sections.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/effects_sections.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/show.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/show.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/proofs_live.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/recovery_email_live/error.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/recovery_email_live/success.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/search_live.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/settings_live/app_passwords.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/settings_live/delete_account.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/settings_live/edit_password.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/settings_live/master_password.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/settings_live/passkey_manage.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/settings_live/rss.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/settings_live/two_factor_manage.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/settings_live/two_factor_setup.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/storage_live.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/storage_live.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/subscribe_live.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/user_settings_live.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/live/user_settings_live.html.heex create mode 100644 apps/elektrine_web/lib/elektrine_web/mail_autoconfig.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/mcp/protocol.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/mcp/tool_registry.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/mcp/tools.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/optional_module.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/platform/integrations.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/platform/module_delegates.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/platform_access.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/activitypub_accept.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/activitypub_rate_limit.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/activitypub_request_guard.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/api_auth.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/api_rate_limit.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/block_private_upload_paths.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/cache_raw_body.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/conditional_live_reloader.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/csrf_error_handler.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/dav_auth.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/dav_rate_limit.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/dns_edge_proxy.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/enforce_https.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/ensure_http_signature_plug.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/http_signature_plug.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/internal_api_auth.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/locale.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/messaging_federation_auth.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/notification_count.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/optional_delegate.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/pat_auth.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/profile_csp.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/profile_custom_domain.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/profile_subdomain.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/receive_only_email_domain_host.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/request_telemetry.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/require_module_access.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/require_platform_module.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/runtime_session.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/security_headers.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/site_challenge.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/site_page_tracking.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/static_site_plug.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/timezone_plug.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/tor_aware.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/webdav_method_override.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/post_navigation.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/profile_navigation.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/profile_theme.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/router.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/routes/chat.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/routes/dns.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/routes/email.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/routes/nerve.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/routes/social.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/routes/uptime.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/routes/vpn.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/safe_live_navigation.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/scrubbers/remote_content.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/session_config.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/telemetry.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/telemetry_measurements.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/url_helpers.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/user_auth.ex create mode 100644 apps/elektrine_web/lib/elektrine_web/web_search.ex create mode 100644 apps/elektrine_web/mix.exs create mode 100644 apps/elektrine_web/priv/canary/canary-public-key.asc create mode 100644 apps/elektrine_web/priv/canary/current.md create mode 100644 apps/elektrine_web/priv/canary/current.md.asc create mode 100644 apps/elektrine_web/priv/nerve-extension/background.js create mode 100644 apps/elektrine_web/priv/nerve-extension/content.js create mode 100644 apps/elektrine_web/priv/nerve-extension/launcher.js create mode 100644 apps/elektrine_web/priv/nerve-extension/lib/api.js create mode 100644 apps/elektrine_web/priv/nerve-extension/lib/crypto.js create mode 100644 apps/elektrine_web/priv/nerve-extension/lib/kairo_capture.js create mode 100644 apps/elektrine_web/priv/nerve-extension/lib/storage.js create mode 100644 apps/elektrine_web/priv/nerve-extension/lib/theme.js create mode 100644 apps/elektrine_web/priv/nerve-extension/manager.html create mode 100644 apps/elektrine_web/priv/nerve-extension/manifest.json create mode 100644 apps/elektrine_web/priv/nerve-extension/options.html create mode 100644 apps/elektrine_web/priv/nerve-extension/options.js create mode 100644 apps/elektrine_web/priv/nerve-extension/package-lock.json create mode 100644 apps/elektrine_web/priv/nerve-extension/package.json create mode 100644 apps/elektrine_web/priv/nerve-extension/popup.html create mode 100644 apps/elektrine_web/priv/nerve-extension/popup.js create mode 100644 apps/elektrine_web/priv/nerve-extension/styles.css create mode 100644 apps/elektrine_web/test/elektrine/components/user/avatar_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/atomine_pow_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/channels/call_channel_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/channels/voice_channel_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/client_ip_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/components/emoji_picker_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/components/layouts_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/components/presence_helpers_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/components/social/post_actions_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/components/social/post_utilities_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/components/social/youtube_preview_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/components/ui/image_modal_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/components/ui/uplot_chart_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/components/user/hover_card_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/components/user/username_effects_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/activitypub_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/announcements_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/chat_messages_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/communities_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/custom_domains_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/deletion_requests_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/elevate_page_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/email_delivery_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/haraka_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/invite_codes_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/messages_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/moderation_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/monitoring_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/users_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/vpn_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/account_birthday_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/account_credential_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/account_directory_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/account_registration_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/account_relationship_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/account_relationship_follow_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/account_search_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/account_status_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/announcement_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/app_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/atomine_attestation_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/auth_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/backup_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/bookmark_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/chat_compat_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/conversation_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/custom_emoji_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/direct_conversation_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/dns_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/domain_block_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/email_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/export_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/ext_v1_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/favourite_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/filter_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/instance_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/kairo_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/list_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/mcp_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/media_attachment_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/notification_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/password_reset_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/poll_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/preference_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/preferences_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/push_subscription_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/relationship_import_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/report_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/scheduled_status_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/scrobble_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/search_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/server_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/settings_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/social_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/status_action_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/status_pin_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/status_reaction_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/status_read_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/suggestion_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/tag_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/timeline_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/trend_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/two_factor_authentication_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/utility_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/vpn_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/vpn_registration_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/webhook_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/arblarg_conformance_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/autoconfig_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/caddy_tls_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/connected_account_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/dav/address_book_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/dav/calendar_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/dav/drive_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/drive_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/drive_share_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/email_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/email_delivery_smoke_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/error_html_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/error_json_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/external_interaction_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/haraka_webhook_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/health_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/impersonation_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/internal_acme_dns_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/internal_dns_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/jmap_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/link_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/mail_security_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/media_proxy_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/messaging_federation_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/messaging_federation_rate_limit_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/nerve_extension_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/oidc_client_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/oidc_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/oidc_grant_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/own_root_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/page_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/password_reset_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/private_attachment_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/profile_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/recovery_email_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/registration_payment_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/unsubscribe_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/user_registration_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/user_session_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/user_settings_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/user_settings_html_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/vpn_api_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/wkd_controller_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/features/auth_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/features/chat_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/features/email_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/features/home_page_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/features/profile_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/features/timeline_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/html_helpers_actor_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/admin_live/badge_management_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/admin_live/emojis_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/admin_live/federation_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/admin_live/relays_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/admin_live/reports_dashboard_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/app_passwords_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/atomine_proofs_live/show_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/auth_live/register_security_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/auth_pow_copy_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/authenticated_routes_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/booking_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/booking_manage_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/calendar_live/operations/calendar_operations_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/chat_live/index_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/chat_live/operations/call_info_operations_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/chat_live/operations/helpers_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/chat_live/operations/message_info_operations_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/chat_live/router_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/chat_live_index_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/chat_live_threads_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/community_post_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/discussions_index_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/discussions_live/operations/member_operations_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/discussions_settings_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/drive_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_compose_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/compose_paragraph_spacing_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/compose_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/email_helpers_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/index_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/search_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/settings_operations_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/show_private_mailbox_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/show_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/show_thread_context_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/friends_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/gallery_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/hashtag_live_show_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/helpers/post_state_helpers_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/kairo_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/link_archive_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/list_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/locale_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/master_password_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/nerve_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/notifications_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/page_live/home_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/page_live/static_pages_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/paige_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/passkey_manage_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/profile_live/edit_security_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/profile_live/show_security_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/profile_live_domain_analytics_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/profile_live_domains_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/proofs_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/remote_post_live/seo_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/remote_post_live_show_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/remote_user_live/follow_button_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/remote_user_live/seo_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/remote_user_live/show_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/storage_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/subscribe_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/timeline_filters_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/timeline_live/operations/image_operations_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/timeline_live/operations/reply_operations_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/timeline_live/operations/social_operations_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/timeline_live/reply_context_previews_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/timeline_post_detail_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/user_invite_codes_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/user_settings_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/user_settings_pgp_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/live/videos_live_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/platform/admin_subset_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/platform/shared_routes_subset_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/platform_access_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/api_rate_limit_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/dav_auth_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/dns_edge_proxy_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/email_ownership_guard_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/enforce_https_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/http_signature_plug_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/optional_delegate_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/profile_custom_domain_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/profile_subdomain_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/receive_only_email_domain_host_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/require_platform_module_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/security_headers_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/site_challenge_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/static_site_plug_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/presence_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/profile_navigation_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/safe_live_navigation_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/user_auth_test.exs create mode 100644 apps/elektrine_web/test/elektrine_web/web_search_test.exs create mode 100644 apps/elektrine_web/test/support/channel_case.ex create mode 100644 apps/elektrine_web/test/support/conn_case.ex create mode 100644 apps/elektrine_web/test/support/data_case.ex create mode 100644 apps/elektrine_web/test/support/fixtures/accounts_fixtures.ex create mode 100644 apps/elektrine_web/test/support/fixtures/email_fixtures.ex create mode 100644 apps/elektrine_web/test/support/fixtures/social_fixtures.ex create mode 100644 apps/elektrine_web/test/test_helper.exs create mode 100644 apps/kairo/lib/kairo.ex create mode 100644 apps/kairo/lib/kairo/project.ex create mode 100644 apps/kairo/lib/kairo/source.ex create mode 100644 apps/kairo/lib/kairo/url_fetch_worker.ex create mode 100644 apps/kairo/mix.exs create mode 100644 apps/kairo/test/kairo/url_fetch_worker_test.exs create mode 100644 apps/kairo/test/kairo_test.exs create mode 100644 apps/kairo/test/support/accounts_fixtures.ex create mode 100644 apps/kairo/test/support/data_case.ex create mode 100644 apps/kairo/test/test_helper.exs create mode 100644 apps/paige/lib/paige.ex create mode 100644 apps/paige/lib/paige/application.ex create mode 100644 apps/paige/lib/paige/http.ex create mode 100644 apps/paige/lib/paige/kind.ex create mode 100644 apps/paige/lib/paige/provider.ex create mode 100644 apps/paige/lib/paige/providers/brave.ex create mode 100644 apps/paige/lib/paige/providers/duck_duck_go.ex create mode 100644 apps/paige/lib/paige/providers/github.ex create mode 100644 apps/paige/lib/paige/providers/hacker_news.ex create mode 100644 apps/paige/lib/paige/providers/wiby.ex create mode 100644 apps/paige/lib/paige/providers/wikipedia.ex create mode 100644 apps/paige/lib/paige/result.ex create mode 100644 apps/paige/lib/paige/scraper_throttle.ex create mode 100644 apps/paige/mix.exs create mode 100644 apps/paige/test/paige/http_test.exs create mode 100644 apps/paige/test/paige/providers_test.exs create mode 100644 apps/paige/test/paige/scraper_providers_test.exs create mode 100644 apps/paige/test/paige/scraper_throttle_test.exs create mode 100644 apps/paige/test/paige_test.exs create mode 100644 apps/paige/test/test_helper.exs create mode 100644 clients/README.md create mode 100644 clients/nerve-extension/background.js create mode 100644 clients/nerve-extension/content.js create mode 100644 clients/nerve-extension/launcher.js create mode 100644 clients/nerve-extension/lib/api.js create mode 100644 clients/nerve-extension/lib/crypto.js create mode 100644 clients/nerve-extension/lib/kairo_capture.js create mode 100644 clients/nerve-extension/lib/storage.js create mode 100644 clients/nerve-extension/lib/theme.js create mode 100644 clients/nerve-extension/manager.html create mode 100644 clients/nerve-extension/manifest.json create mode 100644 clients/nerve-extension/options.html create mode 100644 clients/nerve-extension/options.js create mode 100644 clients/nerve-extension/package-lock.json create mode 100644 clients/nerve-extension/package.json create mode 100644 clients/nerve-extension/popup.html create mode 100644 clients/nerve-extension/popup.js create mode 100644 clients/nerve-extension/styles.css create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/prod.exs create mode 100644 config/runtime.exs create mode 100644 config/runtime/bluesky.exs create mode 100644 config/runtime/dns.exs create mode 100644 config/runtime/mail_protocols.exs create mode 100644 config/runtime/messaging_federation.exs create mode 100644 config/runtime/stripe.exs create mode 100644 config/runtime/uploads.exs create mode 100644 config/runtime/webrtc.exs create mode 100644 config/test.exs create mode 100644 deploy/caddy/Caddyfile create mode 100644 deploy/caddy/Caddyfile.baremetal create mode 100644 deploy/caddy/Caddyfile.baremetal.external-certs create mode 100644 deploy/caddy/Caddyfile.baremetal.wildcard-external create mode 100644 deploy/caddy/Caddyfile.external-certs create mode 100644 deploy/caddy/Caddyfile.wildcard-external create mode 100644 deploy/caddy/Dockerfile create mode 100644 deploy/docker/Dockerfile create mode 100644 deploy/docker/compose.core.yml create mode 100644 deploy/docker/compose.full.yml create mode 100644 deploy/docker/compose.magpie-network.yml create mode 100755 deploy/docker/docker-entrypoint.sh create mode 100644 deploy/docker/elektrine-compose.service create mode 100644 deploy/docker/initdb/010-extensions.sql create mode 100755 deploy/docker/start.sh create mode 100644 deploy/generated/.gitkeep create mode 100644 deploy/onion/torrc create mode 100644 deploy/terraform/vpn-fleet/.gitignore create mode 100644 deploy/terraform/vpn-fleet/README.md create mode 100644 deploy/terraform/vpn-fleet/examples/digitalocean/.terraform.lock.hcl create mode 100644 deploy/terraform/vpn-fleet/examples/digitalocean/main.tf create mode 100644 deploy/terraform/vpn-fleet/examples/digitalocean/variables.tf create mode 100644 deploy/terraform/vpn-fleet/examples/hetzner/.terraform.lock.hcl create mode 100644 deploy/terraform/vpn-fleet/examples/hetzner/main.tf create mode 100644 deploy/terraform/vpn-fleet/examples/hetzner/variables.tf create mode 100644 deploy/terraform/vpn-fleet/examples/multi-provider/.terraform.lock.hcl create mode 100644 deploy/terraform/vpn-fleet/examples/multi-provider/main.tf create mode 100644 deploy/terraform/vpn-fleet/examples/multi-provider/terraform.tfvars.example create mode 100644 deploy/terraform/vpn-fleet/examples/multi-provider/variables.tf create mode 100644 deploy/terraform/vpn-fleet/modules/digitalocean/main.tf create mode 100644 deploy/terraform/vpn-fleet/modules/digitalocean/outputs.tf create mode 100644 deploy/terraform/vpn-fleet/modules/digitalocean/variables.tf create mode 100644 deploy/terraform/vpn-fleet/modules/hetzner/main.tf create mode 100644 deploy/terraform/vpn-fleet/modules/hetzner/outputs.tf create mode 100644 deploy/terraform/vpn-fleet/modules/hetzner/variables.tf create mode 100644 deploy/terraform/vpn-fleet/modules/user-data/main.tf create mode 100644 deploy/terraform/vpn-fleet/modules/user-data/outputs.tf create mode 100644 deploy/terraform/vpn-fleet/modules/user-data/templates/cloud-init.yaml.tftpl create mode 100644 deploy/terraform/vpn-fleet/modules/user-data/variables.tf create mode 100644 deploy/terraform/vpn-fleet/modules/vultr/main.tf create mode 100644 deploy/terraform/vpn-fleet/modules/vultr/outputs.tf create mode 100644 deploy/terraform/vpn-fleet/modules/vultr/variables.tf create mode 100644 docs/addons/onion.md create mode 100644 docs/api/external-api.md create mode 100644 docs/arblarg-1.0.md create mode 100644 docs/architecture/composable-platform.md create mode 100644 docs/architecture/dns-module.md create mode 100644 docs/clients/mcp.md create mode 100644 docs/clients/nerve-extension.md create mode 100644 docs/clients/static-site-github-deploys.md create mode 100644 docs/security/warrant-canary.md create mode 100644 docs/self-hosting/README.md create mode 100644 docs/self-hosting/baremetal.md create mode 100644 docs/self-hosting/caddy.md create mode 100644 docs/self-hosting/core.md create mode 100644 docs/self-hosting/deploy-runbook.md create mode 100644 docs/self-hosting/docker.md create mode 100644 docs/self-hosting/mail.md create mode 100644 docs/self-hosting/turn.md create mode 100644 docs/self-hosting/vpn.md create mode 100644 elixir_buildpack.config create mode 100644 env/caddy.env.example create mode 100644 env/core.env.example create mode 100644 env/dev.env.example create mode 100644 env/mail.env.example create mode 100644 env/onion.env.example create mode 100644 env/presets/bluesky.env create mode 100644 env/presets/dns.env create mode 100644 env/presets/mail.env create mode 100644 env/presets/s3.env create mode 100644 env/presets/tor.env create mode 100644 env/presets/turn.env create mode 100644 env/presets/vpn.env create mode 100644 env/presets/wildcard-tls.env create mode 100644 env/vpn.env.example create mode 100644 external/arblarg/README.md create mode 100644 external/arblarg/benchmarks/latest.json create mode 100644 external/arblarg/interop/smoke.json create mode 100644 external/arblarg/schemas/v1/ban.upsert.json create mode 100644 external/arblarg/schemas/v1/dm.call.accept.json create mode 100644 external/arblarg/schemas/v1/dm.call.end.json create mode 100644 external/arblarg/schemas/v1/dm.call.invite.json create mode 100644 external/arblarg/schemas/v1/dm.call.reject.json create mode 100644 external/arblarg/schemas/v1/dm.call.signal.json create mode 100644 external/arblarg/schemas/v1/dm.message.create.json create mode 100644 external/arblarg/schemas/v1/envelope.json create mode 100644 external/arblarg/schemas/v1/invite.upsert.json create mode 100644 external/arblarg/schemas/v1/membership.upsert.json create mode 100644 external/arblarg/schemas/v1/message.create.json create mode 100644 external/arblarg/schemas/v1/message.delete.json create mode 100644 external/arblarg/schemas/v1/message.update.json create mode 100644 external/arblarg/schemas/v1/moderation.action.recorded.json create mode 100644 external/arblarg/schemas/v1/permission.overwrite.upsert.json create mode 100644 external/arblarg/schemas/v1/pin.upsert.json create mode 100644 external/arblarg/schemas/v1/presence.update.json create mode 100644 external/arblarg/schemas/v1/reaction.add.json create mode 100644 external/arblarg/schemas/v1/reaction.remove.json create mode 100644 external/arblarg/schemas/v1/read.cursor.json create mode 100644 external/arblarg/schemas/v1/role.assignment.upsert.json create mode 100644 external/arblarg/schemas/v1/role.upsert.json create mode 100644 external/arblarg/schemas/v1/server.upsert.json create mode 100644 external/arblarg/schemas/v1/thread.archive.json create mode 100644 external/arblarg/schemas/v1/thread.upsert.json create mode 100644 external/arblarg/schemas/v1/typing.start.json create mode 100644 external/arblarg/schemas/v1/typing.stop.json create mode 100644 external/arblarg/test_vectors/v1/error_cases.json create mode 100644 external/arblarg/test_vectors/v1/event_message_create.json create mode 100644 external/arblarg/test_vectors/v1/extension_events.json create mode 100644 external/arblarg/test_vectors/v1/request_signature.json create mode 120000 external/haraka-elektrine create mode 100644 mise.toml create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 phoenix_static_buildpack.config create mode 100644 priv/repo/seeds.exs create mode 100644 release_builder/README.md create mode 100644 release_builder/config/config.exs create mode 100644 release_builder/config/runtime.exs create mode 100644 release_builder/lib/elektrine_release_builder.ex create mode 100644 release_builder/mix.exs create mode 100644 release_builder/module_selection.exs create mode 100755 scripts/acme/dns_elektrine.sh create mode 100755 scripts/acme/issue_elektrine_wildcard_cert.sh create mode 100755 scripts/acme/setup_haraka_mail_tls.sh create mode 100755 scripts/check_design_consistency.sh create mode 100755 scripts/check_legacy_marker_budget.sh create mode 100755 scripts/check_maintainability_budgets.sh create mode 100755 scripts/check_tracked_generated_artifacts.sh create mode 100755 scripts/deploy/build_and_push_image.sh create mode 100755 scripts/deploy/configure_docker_source_ips.sh create mode 100755 scripts/deploy/configure_haraka_wildcard_tls.sh create mode 100755 scripts/deploy/deploy_pushed_image.sh create mode 100755 scripts/deploy/docker_deploy.sh create mode 100755 scripts/deploy/doctor.sh create mode 100755 scripts/deploy/explain_deploy.sh create mode 100755 scripts/deploy/generate_env.sh create mode 100755 scripts/deploy/prune_old_images.sh create mode 100755 scripts/deploy/render_docker_compose.sh create mode 100755 scripts/deploy/self_host.sh create mode 100644 scripts/lib/deploy_simplify.sh create mode 100755 scripts/lib/module_selection.sh create mode 100755 scripts/onion/sync-onion-key-secrets.sh create mode 100755 scripts/release/deploy_release.sh create mode 100644 scripts/vpn/README.md create mode 100755 scripts/vpn/bootstrap-node.sh create mode 100644 scripts/vpn/elektrine-vpn-agent.env.example create mode 100644 scripts/vpn/elektrine-vpn-agent.service create mode 100755 scripts/vpn/elektrine-vpn-agent.sh create mode 100755 scripts/vpn/gen-cloud-init.sh diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..b828da6 --- /dev/null +++ b/.credo.exs @@ -0,0 +1,170 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "src/", + "test/", + "web/", + "apps/*/lib/", + "apps/*/src/", + "apps/*/test/", + "apps/*/web/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: true, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: %{ + enabled: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [if_nested_deeper_than: 99, if_called_more_often_than: 999]}, + {Credo.Check.Design.TagFIXME, []}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 2]}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 120]}, + {Credo.Check.Refactor.FilterCount, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.FunctionArity, [max_arity: 20]}, + {Credo.Check.Refactor.LongQuoteBlocks, [max_line_count: 1_000]}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, [max_nesting: 20]}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.Dbg, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.StructFieldAmount, []}, + {Credo.Check.Warning.UnsafeExec, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedMapOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.WrongTestFileExtension, []} + ], + disabled: [] + } + } + ] +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2c7f7aa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,71 @@ +# This file excludes paths from the Docker build context. +# +# By default, Docker's build context includes all files (and folders) in the +# current directory. Even if a file isn't copied into the container it is still sent to +# the Docker daemon. +# +# There are multiple reasons to exclude files from the build context: +# +# 1. Prevent nested folders from being copied into the container (ex: exclude +# /assets/node_modules when copying /assets) +# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) +# 3. Avoid sending files containing sensitive information +# +# More information on using .dockerignore is available here: +# https://docs.docker.com/engine/reference/builder/#dockerignore-file + +.dockerignore + +# Ignore git, but keep git HEAD and refs to access current commit hash if needed: +# +# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat +# d0b8727759e1e0e7aa3d41707d12376e373d5ecc +.git +!.git/HEAD +!.git/refs + +# Common development/test artifacts +/cover/ +/doc/ +/test/ +/tmp/ +.elixir_ls + +# Environment files and local secrets must never be sent to the Docker daemon. +.env +.env.* +!.env.example +env/*.env +env/*.env.* + +# Private key and certificate material +*.key +*.pem +*.p12 +*.pfx +*.crt +*.csr +*.asc +/certs/ +/secrets/ + +# Mix artifacts +/_build/ +/deps/ +apps/**/_build/ +apps/**/deps/ +*.ez + +# Generated on crash by the VM +erl_crash.dump + +# Static artifacts - These should be fetched and built inside the Docker image +/assets/node_modules/ +/priv/static/assets/ +/priv/static/cache_manifest.json +apps/**/assets/node_modules/ +apps/**/priv/static/assets/ +apps/**/priv/static/cache_manifest.json + +# Local release artifacts +/_deploy_release/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..491a208 --- /dev/null +++ b/.env.example @@ -0,0 +1,292 @@ +# Advanced example env file. +# For the easiest first setup, run: +# scripts/deploy/self_host.sh init --domain example.com --email admin@example.com +# Copy this file to `.env.production` only if you want the larger kitchen-sink template. + +# ----------------------------------------------------------------------------- +# Core domains / hostnames +# ----------------------------------------------------------------------------- + +PRIMARY_DOMAIN=example.com + +# Optional overrides for split-domain setups. If unset, deploy tooling derives +# them from PRIMARY_DOMAIN automatically. +# PHX_HOST=example.com +# INSTANCE_DOMAIN=example.com +# EMAIL_DOMAIN=example.com +# EMAIL_RAW_SOURCE_MAX_BYTES=10485760 +# SUPPORTED_DOMAINS=example.com +# PROFILE_BASE_DOMAINS=example.com + +# Optional DNS branding overrides. If unset, the DNS service derives these from +# PRIMARY_DOMAIN automatically. +# DNS_NAMESERVERS=ns1.example.com,ns2.example.com +# DNS_SOA_RNAME=admin.example.com + +# ----------------------------------------------------------------------------- +# Phoenix runtime +# ----------------------------------------------------------------------------- + +PHX_SERVER=true +PORT=8080 + +# Optional runtime overrides. +# FORCE_SSL=false +# TRUSTED_PROXY_CIDRS=172.30.0.0/24 +# PROXY_PROTOCOL_TRUSTED_CIDRS=203.0.113.10/32 +# CADDY_ASK_TRUSTED_CIDRS=172.30.0.0/24 +# ELEKTRINE_AUTO_CONFIGURE_DOCKER_SOURCE_IPS=true +# CADDY_TRUSTED_PROXY_CIDRS=203.0.113.10/32 +# CADDY_PROXY_PROTOCOL_ALLOWED_CIDRS=203.0.113.10/32 +# EXTRA_CHECK_ORIGINS=http://203.0.113.10 +# ATOMINE_DM_CREDIT_GATE_ENABLED=false +# ATOMINE_EMAIL_CREDIT_GATE_ENABLED=false + +# Require NetBird for login/admin surfaces and restrict admin. at Caddy. +# NETBIRD_ENABLED=true +# Set this to your exact NetBird peer IP(s) or private network CIDR(s), not all CGNAT. +# NETBIRD_ALLOWED_CIDRS=100.64.12.34/32 +# CADDY_ADMIN_HOST=admin.example.com + +# ----------------------------------------------------------------------------- +# Database / secrets +# ----------------------------------------------------------------------------- + +# DB_PASSWORD= + +# For the default Docker stack, `DB_PASSWORD` is enough. Set `DATABASE_URL` +# only when you use an external Postgres instance or non-Docker deploy. +# DATABASE_URL=ecto://elektrine:@postgres/elektrine_prod +# TLS is enabled by default for external DATABASE_URL connections. Only set this +# false for an internal Docker/private-network database that does not support TLS. +# DATABASE_SSL_ENABLED=false + +# Minimal root secret for self-hosting. Elektrine derives internal API keys, +# salts, and other internal-only secrets from this automatically. +# ELEKTRINE_MASTER_SECRET= + +# Optional explicit override. If omitted, Elektrine derives it from +# ELEKTRINE_MASTER_SECRET. +# SECRET_KEY_BASE= + +# ----------------------------------------------------------------------------- +# Modules / deploy profiles +# ----------------------------------------------------------------------------- + +ELEKTRINE_ENABLED_MODULES=all +# Advanced build override only. Usually leave this unset. +# ELEKTRINE_RELEASE_MODULES=chat,social,nerve,atomine +DOCKER_PROFILES=caddy dns email tor turn bluesky vpn + +# After each successful docker_deploy up, prune old app image tags on the host +# so deploys do not fill the disk. Keep the N newest tags of the app image. +# ELEKTRINE_IMAGE_KEEP_COUNT=3 +# ELEKTRINE_IMAGE_REPO=ghcr.io/atomine-elektrine/elektrine +# ELEKTRINE_PRUNE_DANGLING_IMAGES=true +# ELEKTRINE_PRUNE_BUILD_CACHE=true +# ELEKTRINE_SKIP_IMAGE_PRUNE=false + +# ----------------------------------------------------------------------------- +# Database pool tuning +# ----------------------------------------------------------------------------- + +# Advanced DB tuning only. +# POOL_SIZE=10 +# APP_POOL_SIZE=10 +# WORKER_POOL_SIZE=5 +# MAIL_POOL_SIZE=5 +# DNS_POOL_SIZE=3 +# VPN_POOL_SIZE=2 +# MIGRATION_POOL_SIZE=2 +# DB_QUEUE_TARGET_MS=2000 +# DB_QUEUE_INTERVAL_MS=5000 +# DB_TIMEOUT_MS=30000 +# DB_POOL_TIMEOUT_MS=15000 +# DB_CONNECT_TIMEOUT_MS=15000 +# POSTGRES_EXTENSIONS=vector + +# ----------------------------------------------------------------------------- +# Federation / remote enrichment +# ----------------------------------------------------------------------------- + +# Advanced federation/remoting only. +# MESSAGING_FEDERATION_ENABLED=true +# MESSAGING_FEDERATION_IDENTITY_KEY_ID=default +# MESSAGING_FEDERATION_OFFICIAL_RELAY_OPERATOR=Community-operated +# MESSAGING_FEDERATION_OFFICIAL_RELAYS_JSON='[]' +# MESSAGING_FEDERATION_PEERS_JSON='[]' + +# Optional Paige search providers. +# PAIGE_BRAVE_API_KEY= +# PAIGE_GITHUB_TOKEN= +# Comma-separated first-party HTML scrapers: wiby,duckduckgo (default: wiby). +# PAIGE_SCRAPERS=wiby,duckduckgo + +# Paige's independent PostgreSQL-backed index. Seeds are comma-separated URLs; +# same-host links are discovered up to PAIGE_INDEX_MAX_DEPTH. +# PAIGE_INDEX_ENABLED=true +# PAIGE_INDEX_SEEDS=https://example.com/,https://hexdocs.pm/elixir/ +# PAIGE_INDEX_MAX_DEPTH=2 +# PAIGE_INDEX_RECRAWL_SECONDS=604800 +# PAIGE_INDEX_BATCH_SIZE=100 +# OBAN_QUEUE_CRAWLER=2 + +# Optional IFTAS CARIAD denylist sync. Set either a full URL or an API key. +# IFTAS_BLOCKLIST_ENABLED=true +# IFTAS_BLOCKLIST_THRESHOLD=66 +# IFTAS_BLOCKLIST_API_KEY= +# IFTAS_BLOCKLIST_URL= + +# ----------------------------------------------------------------------------- +# Optional but recommended crypto secrets +# ----------------------------------------------------------------------------- + +# Optional explicit overrides. If omitted, Elektrine derives them from +# ELEKTRINE_MASTER_SECRET. +# ENCRYPTION_MASTER_SECRET= +# ENCRYPTION_KEY_SALT= +# ENCRYPTION_SEARCH_SALT= + +# ----------------------------------------------------------------------------- +# Email / Haraka integration +# ----------------------------------------------------------------------------- + +# Enable only if you actually use the `email` module/profile. +# USE_LOCAL_EMAIL=false +# EMAIL_AUTO_SUPPRESSION=true +# HARAKA_ASYNC_INGEST=true +# MAIL_SERVICE_HOST=mail.example.com +# IMAP_HOST=mail.example.com +# SMTP_HOST=mail.example.com + +# Optional overrides. If omitted, Elektrine derives these from your domain and +# ELEKTRINE_MASTER_SECRET automatically. +# HARAKA_BASE_URL=https://mail.example.com +# CUSTOM_DOMAIN_MX_HOST=mail.example.com +# INTERNAL_API_KEY= +# HARAKA_HTTP_API_KEY= +# PHOENIX_API_KEY= +# HARAKA_INTERNAL_SIGNING_SECRET= +# EMAIL_RECEIVER_WEBHOOK_SECRET= + +# CUSTOM_DOMAIN_MX_PRIORITY=10 +# CUSTOM_DOMAIN_SPF_INCLUDE=_spf.example.com +# CUSTOM_DOMAIN_DKIM_SELECTOR=default +# CUSTOM_DOMAIN_DKIM_SYNC_ENABLED=true +# CUSTOM_DOMAIN_HARAKA_BASE_URL=https://mail.example.com +# CUSTOM_DOMAIN_HARAKA_API_KEY= +# CUSTOM_DOMAIN_HARAKA_TIMEOUT_MS=10000 +# CUSTOM_DOMAIN_HARAKA_DKIM_PATH=/api/v1/dkim/domains +# CUSTOM_DOMAIN_DMARC_POLICY=quarantine +# CUSTOM_DOMAIN_DMARC_RUA=mailto:dmarc@example.com +# CUSTOM_DOMAIN_DMARC_ADKIM=s +# CUSTOM_DOMAIN_DMARC_ASPF=s + +# ----------------------------------------------------------------------------- +# Edge / certificates / anti-abuse +# ----------------------------------------------------------------------------- + +ACME_EMAIL=admin@example.com +# Minimal wildcard setup for large/dynamic username subdomains: +# CADDY_MANAGED_SITE_1="example.com *.example.com" +# Leave CADDY_MANAGED_SITE_2 unset unless you have a second disjoint site list. +# CADDY_MANAGED_SITE_2="alt.example.net *.alt.example.net" +# Deploy tooling auto-selects the wildcard-external Caddyfile when wildcard +# sites and mounted cert paths are present. For raw `docker compose` usage, +# set `CADDY_CONFIG_PATH` explicitly. +# With the stock Caddyfile, keep these lists to explicit hosts only. +# CADDY_MANAGED_SITE_1="example.com mail.example.com imap.example.com pop.example.com smtp.example.com" +# CADDY_MANAGED_SITE_2="alt.example.net mail.alt.example.net imap.alt.example.net pop.alt.example.net smtp.alt.example.net" +# CADDY_TLS_MOUNT_DIR=/opt/elektrine/certs +# CADDY_MANAGED_SITE_1_CERT_PATH=/opt/elektrine/certs/example.com.fullchain.pem +# CADDY_MANAGED_SITE_1_KEY_PATH=/opt/elektrine/certs/example.com.key.pem +# CADDY_MANAGED_SITE_2_CERT_PATH=/opt/elektrine/certs/alt.example.net.fullchain.pem +# CADDY_MANAGED_SITE_2_KEY_PATH=/opt/elektrine/certs/alt.example.net.key.pem +# CADDY_ORIGIN_UPSTREAM=app_proxy:8080 +# MAIL_TLS_CERT_PATH=/opt/elektrine/certs/mail.crt +# MAIL_TLS_KEY_PATH=/opt/elektrine/certs/mail.key +# MAIL_TLS_MOUNT_DIR=/opt/elektrine/certs +# IMAP_TLS_CERT_PATH=/opt/elektrine/certs/imap.crt +# IMAP_TLS_KEY_PATH=/opt/elektrine/certs/imap.key +# POP3_TLS_CERT_PATH=/opt/elektrine/certs/pop.crt +# POP3_TLS_KEY_PATH=/opt/elektrine/certs/pop.key +# TURN_HOST defaults to PRIMARY_DOMAIN when the `turn` profile is enabled. +# TURN_PORT=3478 +# TURN_MIN_PORT=49160 +# TURN_MAX_PORT=49200 +# TURN_EXTERNAL_IP= +# TURN_RELAY_IP= +# TURN_SHARED_SECRET= +# ATOMINE_POW_DIFFICULTY=20 +# ATOMINE_POW_SKIP_VERIFICATION=false +# STRIPE_SECRET_KEY= +# STRIPE_WEBHOOK_SECRET= +# DNS_RECURSIVE_ENABLED=true + +# ----------------------------------------------------------------------------- +# VPN +# ----------------------------------------------------------------------------- + +# Single-server self-hosted WireGuard (recommended for most installs): +# VPN_SELFHOST_ENDPOINT_HOST=vpn.example.com +# VPN_SELFHOST_PUBLIC_IP=203.0.113.10 +# VPN_SELFHOST_PRIVATE_KEY=optional; auto-generated and persisted by the vpn container when omitted +# VPN_SELFHOST_ADDRESS=10.8.0.1/24 +# VPN_SELFHOST_LISTEN_PORT=51820 +# VPN_SELFHOST_ENDPOINT_PORT=51820 +# VPN_SELFHOST_PUBLIC_KEY=optional override; normally derived automatically from the private key +# VPN_SELFHOST_INTERNAL_IP_RANGE=10.8.0.0/24 +# VPN_SELFHOST_DNS_SERVERS=1.1.1.1, 1.0.0.1 +# VPN_SELFHOST_WG_INTERFACE=wg0 + +# Multi-server fleet bootstrap: +# VPN_FLEET_REGISTRATION_KEY=replace-with-base64-registration-key +# Private supernet each node's /24 is carved from. The default holds 256 nodes; +# widen it BEFORE nodes claim ranges, since changing it later strands existing +# allocations. +# VPN_WG_SUPERNET=10.8.0.0/16 + +# ----------------------------------------------------------------------------- +# Onion / Tor +# ----------------------------------------------------------------------------- + +# ELEKTRINE_ENABLE_TOR=true +# ONION_TLS_ENABLED=true +# ONION_TLS_PORT=8443 +# ONION_TLS_CERTFILE=/data/certs/live/onion-cert.pem +# ONION_TLS_KEYFILE=/data/certs/live/onion-key.pem +# ONION_HOST= +# ONION_HS_SECRET_KEY_B64= +# ONION_HS_PUBLIC_KEY_B64= + +# ----------------------------------------------------------------------------- +# Bluesky +# ----------------------------------------------------------------------------- + +# BLUESKY_ENABLED=false +# BLUESKY_INBOUND_ENABLED=false +# BLUESKY_SERVICE_URL=https://bsky.social +# BLUESKY_TIMEOUT_MS=12000 +# BLUESKY_MAX_CHARS=300 +# BLUESKY_INBOUND_LIMIT=50 +# BLUESKY_MANAGED_ENABLED=false +# BLUESKY_MANAGED_SERVICE_URL=http://bluesky_pds:3000 +# BLUESKY_MANAGED_DOMAIN=bsky.example.com +# BLUESKY_MANAGED_ADMIN_PASSWORD= +# BLUESKY_PDS_PORT=3000 +# BLUESKY_PDS_JWT_SECRET= +# BLUESKY_PDS_ROTATION_KEY_HEX= + +# ----------------------------------------------------------------------------- +# Optional object storage (Magpie / S3-compatible) +# ----------------------------------------------------------------------------- + +# Leave these unset to keep uploads on local disk. +# S3_ACCESS_KEY_ID= +# S3_SECRET_ACCESS_KEY= +# Local Magpie examples use HTTP loopback. Use HTTPS for any remote object store. +# S3_ENDPOINT=127.0.0.1:8090 +# S3_BUCKET_NAME=elektrine-uploads +# S3_PUBLIC_URL=http://127.0.0.1:8090/elektrine-uploads +# S3_SCHEME=http:// +# S3_PORT=8090 diff --git a/.forgejo/README.md b/.forgejo/README.md new file mode 100644 index 0000000..2377e2d --- /dev/null +++ b/.forgejo/README.md @@ -0,0 +1,41 @@ +# Forgejo Actions + +Workflows in this directory run on the self-hosted Forgejo instance +(`https://git.elektrine.com`) via `forgejo-runner`. + +| Workflow | Purpose | +|----------|---------| +| `smoke.yml` | Minimal runner health check | +| `ci.yml` | Quality gate (format, compile, credo, tests) | +| `docker-deploy.yml` | Build image + SSH deploy (port of `.github/workflows/docker-deploy.yml`) | + +## Runner + +- Host: Forgejo droplet (`git.elektrine.com`) +- Path: `/opt/forgejo-runner` +- Labels: `ubuntu-latest`, `ubuntu-22.04`, `docker` + +## Repo secrets / variables + +Set under **Settings → Actions** on `elektrine/elektrine`: + +**Secrets** + +- `DEPLOY_SSH_KEY` — private key for production deploy (required) +- `REGISTRY_USERNAME` / `REGISTRY_TOKEN` — optional Forgejo package credentials + for optional registry push and Haraka image pulls. Legacy aliases + `GHCR_USERNAME` / `GHCR_TOKEN` still work. + + The main Elektrine app image is **built on the runner and transferred over + SSH** (`docker save` → `rsync` → `docker load`). Production deploy no longer + requires a registry pull of the app image, which avoids + `unauthorized: authRequired.Verify` on `git.elektrine.com`. + +**Variables** + +- `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_HOST_KEY` +- Optional: `DEPLOY_PORT`, `DEPLOY_PATH`, `DEPLOY_BRANCH` +- Optional: `REGISTRY` (default `git.elektrine.com`), `IMAGE_NAME`, `DOCKER_BUILD_*_DOMAIN` + +GitHub Actions under `.github/workflows/` remain for the GitHub remote until +you cut over production deploys fully. diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..c8e7f29 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,129 @@ +# Port of .github/workflows/ci.yml for Forgejo Actions. +# Prefer data.forgejo.org action mirrors where available. +name: Quality Checks + +on: + pull_request: + branches: [main] + workflow_call: + inputs: + allow_format_failure: + description: Allow formatting check to fail without failing the workflow + required: false + type: boolean + default: false + allow_test_failure: + description: Allow the test suite to fail without blocking deployment + required: false + type: boolean + default: true + workflow_dispatch: + inputs: + allow_format_failure: + description: Allow formatting check to fail without failing the workflow + required: false + type: boolean + default: false + allow_test_failure: + description: Allow the test suite to fail without blocking deployment + required: false + type: boolean + default: true + +env: + MIX_ENV: test + ELIXIR_VERSION: '1.19.5' + OTP_VERSION: '28.1.1' + DB_HOST: localhost + DB_NAME: elektrine_test + DB_USER: postgres + DB_PASSWORD: postgres + +jobs: + quality_checks: + name: Code Quality (Format + Compile) + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: elektrine_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d elektrine_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + - name: Set up Elixir + uses: https://github.com/erlef/setup-beam@v1 + with: + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} + + - name: Restore deps cache + uses: https://data.forgejo.org/actions/cache@v4 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}- + + - name: Install dependencies + run: mix deps.get + + - name: Install JavaScript dependencies + run: npm --prefix apps/elektrine/assets ci + + - name: Build assets + run: cd apps/elektrine && mix assets.setup && mix assets.build + + - name: Verify formatting + id: format_check + continue-on-error: ${{ inputs.allow_format_failure }} + run: mix format --check-formatted + + - name: Check generated artifacts are not tracked + run: scripts/check_tracked_generated_artifacts.sh + + - name: Check JavaScript assets + run: npm --prefix apps/elektrine/assets run check + + - name: Run JavaScript dependency audit + run: npm --prefix apps/elektrine/assets audit --omit=dev + + - name: Emit warning when formatting is allowed to fail + if: ${{ inputs.allow_format_failure && steps.format_check.outcome == 'failure' }} + run: echo "::warning::Formatting check failed, but deploy can continue." + + - name: Compile with warnings treated as errors + run: mix compile --warnings-as-errors + + - name: Run Credo + run: mix credo --strict + + - name: Run tests + id: test_check + continue-on-error: ${{ inputs.allow_test_failure != false }} + run: mix test + + - name: Emit warning when tests are allowed to fail + if: ${{ inputs.allow_test_failure != false && steps.test_check.outcome == 'failure' }} + run: echo "::warning::Test suite failed, but deploy can continue." + + - name: Audit Nerve extension dependencies + run: npm --prefix clients/nerve-extension audit --omit=dev + + - name: Run dependency audit + run: mix deps.audit + + - name: Run Hex package retirement audit + run: mix hex.audit diff --git a/.forgejo/workflows/docker-deploy.yml b/.forgejo/workflows/docker-deploy.yml new file mode 100644 index 0000000..e8f957d --- /dev/null +++ b/.forgejo/workflows/docker-deploy.yml @@ -0,0 +1,373 @@ +# Production deploy for self-hosted Forgejo Actions (no setup-beam / node24 actions). +# +# The app image is built on the runner and transferred over SSH (docker save/load), +# so production does not need to pull from the Forgejo package registry. +# +# Secrets: DEPLOY_SSH_KEY +# Optional secrets (registry push + Haraka pulls only): +# REGISTRY_USERNAME + REGISTRY_TOKEN (or legacy GHCR_USERNAME / GHCR_TOKEN) +# Variables: DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_HOST_KEY, REGISTRY, IMAGE_NAME, DOCKER_BUILD_* +name: Deploy Docker Images + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + repair_indexes: + description: Rebuild all PostgreSQL indexes during deploy + required: false + default: false + type: boolean + +env: + REGISTRY: ${{ vars.REGISTRY || 'git.elektrine.com' }} + IMAGE_NAME: ${{ vars.IMAGE_NAME || 'elektrine/elektrine' }} + +jobs: + build_and_deploy: + name: Build, push, and deploy + runs-on: ubuntu-latest + env: + BUILD_PRIMARY_DOMAIN: ${{ vars.DOCKER_BUILD_PRIMARY_DOMAIN || 'elektrine.com' }} + BUILD_EMAIL_DOMAIN: ${{ vars.DOCKER_BUILD_EMAIL_DOMAIN || 'elektrine.com' }} + BUILD_SUPPORTED_DOMAINS: ${{ vars.DOCKER_BUILD_SUPPORTED_DOMAINS || 'elektrine.com' }} + BUILD_PROFILE_BASE_DOMAINS: ${{ vars.DOCKER_BUILD_PROFILE_BASE_DOMAINS || 'elektrine.com' }} + ELEKTRINE_ENABLED_MODULES: all + ELEKTRINE_RELEASE_MODULES: all + DEPLOY_HOST: ${{ vars.DEPLOY_HOST }} + DEPLOY_USER: ${{ vars.DEPLOY_USER }} + DEPLOY_PORT: ${{ vars.DEPLOY_PORT || '22' }} + DEPLOY_PATH: ${{ vars.DEPLOY_PATH || '/opt/elektrine' }} + DEPLOY_BRANCH: ${{ vars.DEPLOY_BRANCH || 'main' }} + DEPLOY_SSH_HOST_KEY: ${{ vars.DEPLOY_SSH_HOST_KEY }} + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DOCKER_PROFILES: caddy dns email tor turn bluesky vpn + DEPLOY_COMPOSE_OVERRIDES: ${{ vars.DEPLOY_COMPOSE_OVERRIDES || '' }} + REPAIR_INDEXES_ON_DEPLOY: ${{ github.event.inputs.repair_indexes || vars.REPAIR_INDEXES_ON_DEPLOY || 'false' }} + # Optional registry credentials (push/Haraka only). + REG_USER: ${{ secrets.REGISTRY_USERNAME || secrets.GHCR_USERNAME }} + REG_PASS: ${{ secrets.REGISTRY_TOKEN || secrets.GHCR_TOKEN }} + concurrency: + group: deploy-production + cancel-in-progress: false + + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + - name: Build image + id: build + run: | + set -euo pipefail + image="${REGISTRY}/${IMAGE_NAME}" + tag_sha="${image}:${GITHUB_SHA}" + tag_latest="${image}:latest" + docker build \ + -f deploy/docker/Dockerfile \ + --build-arg RELEASE_NAME=elektrine \ + --build-arg ELEKTRINE_RELEASE_MODULES="${ELEKTRINE_RELEASE_MODULES}" \ + --build-arg PRIMARY_DOMAIN="${BUILD_PRIMARY_DOMAIN}" \ + --build-arg EMAIL_DOMAIN="${BUILD_EMAIL_DOMAIN}" \ + --build-arg SUPPORTED_DOMAINS="${BUILD_SUPPORTED_DOMAINS}" \ + --build-arg PROFILE_BASE_DOMAINS="${BUILD_PROFILE_BASE_DOMAINS}" \ + -t "$tag_sha" \ + -t "$tag_latest" \ + . + echo "target_image=${tag_sha}" >> "$GITHUB_OUTPUT" + echo "Built ${tag_sha}" + + - name: Optional registry push + run: | + set -euo pipefail + if [ -z "${REG_USER:-}" ] || [ -z "${REG_PASS:-}" ]; then + echo "No registry credentials; skipping push (image will ship over SSH)." + exit 0 + fi + REG_USER="$(printf '%s' "$REG_USER" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + REG_PASS="$(printf '%s' "$REG_PASS" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + if ! printf '%s\n' "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin; then + echo "::warning::Registry login failed; continuing with SSH image transfer only." + exit 0 + fi + image="${REGISTRY}/${IMAGE_NAME}" + if ! docker push "${image}:${GITHUB_SHA}"; then + echo "::warning::docker push ${image}:${GITHUB_SHA} failed; continuing with SSH transfer." + exit 0 + fi + docker push "${image}:latest" || echo "::warning::docker push latest failed" + + - name: Package image for SSH transfer + env: + TARGET_IMAGE: ${{ steps.build.outputs.target_image }} + run: | + set -euo pipefail + mkdir -p /tmp + docker save "$TARGET_IMAGE" | gzip -1 > /tmp/elektrine-image.tar.gz + ls -lh /tmp/elektrine-image.tar.gz + + - name: Validate deploy settings + run: | + set -euo pipefail + missing=() + [ -n "${DEPLOY_HOST:-}" ] || missing+=(DEPLOY_HOST) + [ -n "${DEPLOY_USER:-}" ] || missing+=(DEPLOY_USER) + [ -n "${DEPLOY_SSH_HOST_KEY:-}" ] || missing+=(DEPLOY_SSH_HOST_KEY) + [ -n "${DEPLOY_SSH_KEY:-}" ] || missing+=(DEPLOY_SSH_KEY) + if [ "${#missing[@]}" -gt 0 ]; then + echo "::error::Missing deploy settings: ${missing[*]}" + exit 1 + fi + + - name: Install SSH key + run: | + set -euo pipefail + install -m 700 -d ~/.ssh + # Secret may arrive with literal \n — normalize to real newlines + printf '%s\n' "$DEPLOY_SSH_KEY" | sed 's/\r$//' | sed 's/\\n/\n/g' > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + : > ~/.ssh/known_hosts + if [ -n "${DEPLOY_SSH_HOST_KEY:-}" ]; then + printf '%s\n' "$DEPLOY_SSH_HOST_KEY" | sed 's/\r$//' | sed 's/\\n/\n/g' >> ~/.ssh/known_hosts + fi + # Always pin live host keys so BatchMode never prompts + ssh-keyscan -p "${DEPLOY_PORT:-22}" -T 5 -t ed25519,rsa \ + "${DEPLOY_HOST}" 2>/dev/null >> ~/.ssh/known_hosts || true + if [ -n "${DEPLOY_HOST:-}" ]; then + ip=$(getent ahostsv4 "$DEPLOY_HOST" 2>/dev/null | awk '{print $1; exit}' || true) + if [ -n "$ip" ]; then + ssh-keyscan -p "${DEPLOY_PORT:-22}" -T 5 -t ed25519,rsa "$ip" 2>/dev/null >> ~/.ssh/known_hosts || true + fi + fi + chmod 600 ~/.ssh/known_hosts + echo "known_hosts:"; cat ~/.ssh/known_hosts + ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null + + - name: Upload image archive + run: | + set -euo pipefail + RSYNC_RSH="ssh -p ${DEPLOY_PORT} -i ${HOME}/.ssh/id_ed25519 -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${HOME}/.ssh/known_hosts" + ssh -p "$DEPLOY_PORT" \ + -i "$HOME/.ssh/id_ed25519" \ + -o BatchMode=yes \ + -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$HOME/.ssh/known_hosts" \ + "$DEPLOY_USER@$DEPLOY_HOST" \ + "mkdir -p '${DEPLOY_PATH}/.deploy' && chmod 700 '${DEPLOY_PATH}/.deploy'" + rsync -az -e "$RSYNC_RSH" \ + /tmp/elektrine-image.tar.gz \ + "$DEPLOY_USER@$DEPLOY_HOST:${DEPLOY_PATH}/.deploy/elektrine-image.tar.gz" + + - name: Deploy over SSH + env: + TARGET_IMAGE: ${{ steps.build.outputs.target_image }} + DEPLOY_REF: ${{ github.sha }} + # Haraka image rolled by Elektrine deploy (sibling stack on same host). + HARAKA_IMAGE: ${{ vars.HARAKA_IMAGE || 'git.elektrine.com/elektrine/elektrine-haraka' }} + HARAKA_IMAGE_TAG: ${{ vars.HARAKA_IMAGE_TAG || 'latest' }} + run: | + set -euo pipefail + + # Optional registry creds for Haraka pulls only (main app image is loaded from archive). + if [ -n "${REG_USER:-}" ] && [ -n "${REG_PASS:-}" ]; then + REG_USER="$(printf '%s' "$REG_USER" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + REG_PASS="$(printf '%s' "$REG_PASS" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + else + REG_USER="" + REG_PASS="" + fi + + remote_env="$(mktemp)" + chmod 600 "$remote_env" + { + printf 'export DEPLOY_PATH=%q\n' "$DEPLOY_PATH" + printf 'export DEPLOY_REF=%q\n' "$DEPLOY_REF" + printf 'export DEPLOY_BRANCH=%q\n' "$DEPLOY_BRANCH" + printf 'export TARGET_IMAGE=%q\n' "$TARGET_IMAGE" + printf 'export ELEKTRINE_ENABLED_MODULES=%q\n' "$ELEKTRINE_ENABLED_MODULES" + printf 'export ELEKTRINE_RELEASE_MODULES=%q\n' "$ELEKTRINE_RELEASE_MODULES" + printf 'export DOCKER_PROFILES=%q\n' "$DOCKER_PROFILES" + printf 'export DEPLOY_COMPOSE_OVERRIDES=%q\n' "$DEPLOY_COMPOSE_OVERRIDES" + printf 'export REPAIR_INDEXES_ON_DEPLOY=%q\n' "$REPAIR_INDEXES_ON_DEPLOY" + printf 'export REG_USER=%q\n' "$REG_USER" + printf 'export REG_PASS=%q\n' "$REG_PASS" + printf 'export HARAKA_IMAGE=%q\n' "$HARAKA_IMAGE" + printf 'export HARAKA_IMAGE_TAG=%q\n' "$HARAKA_IMAGE_TAG" + printf 'export REGISTRY=%q\n' "${REGISTRY:-git.elektrine.com}" + printf 'export IMAGE_ARCHIVE=%q\n' "${DEPLOY_PATH}/.deploy/elektrine-image.tar.gz" + } >"$remote_env" + + remote_env_path="/tmp/elektrine-deploy-env.$$" + scp -P "$DEPLOY_PORT" \ + -i "$HOME/.ssh/id_ed25519" \ + -o BatchMode=yes \ + -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$HOME/.ssh/known_hosts" \ + "$remote_env" \ + "$DEPLOY_USER@$DEPLOY_HOST:$remote_env_path" + rm -f "$remote_env" + + ssh -p "$DEPLOY_PORT" \ + -i "$HOME/.ssh/id_ed25519" \ + -o BatchMode=yes \ + -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$HOME/.ssh/known_hosts" \ + "$DEPLOY_USER@$DEPLOY_HOST" \ + "bash -s -- $remote_env_path" <<'EOF' + set -euo pipefail + + ENV_FILE="${1:?missing remote env file}" + # shellcheck disable=SC1090 + source "$ENV_FILE" + rm -f "$ENV_FILE" + + export REGISTRY_USER="${REG_USER:-}" + export REGISTRY_TOKEN="${REG_PASS:-}" + export REGISTRY="${REGISTRY:-git.elektrine.com}" + export HARAKA_IMAGE="${HARAKA_IMAGE:-git.elektrine.com/elektrine/elektrine-haraka}" + export HARAKA_IMAGE_TAG="${HARAKA_IMAGE_TAG:-latest}" + # Prefer local image (loaded from archive); never force a registry pull of ELEKTRINE_IMAGE. + export ELEKTRINE_PULL_POLICY=never + + if docker info >/dev/null 2>&1; then + DOCKER=(docker) + elif sudo -n docker info >/dev/null 2>&1; then + DOCKER=(sudo -n docker) + else + echo "No Docker access for deploy user" >&2 + exit 1 + fi + + IMAGE_ARCHIVE="${IMAGE_ARCHIVE:?missing IMAGE_ARCHIVE}" + test -s "$IMAGE_ARCHIVE" + + echo "Loading $TARGET_IMAGE from $IMAGE_ARCHIVE" + gzip -dc "$IMAGE_ARCHIVE" | "${DOCKER[@]}" load + "${DOCKER[@]}" image inspect "$TARGET_IMAGE" >/dev/null + rm -f "$IMAGE_ARCHIVE" + + # Optional login only helps Haraka (and other private pulls). Failures are non-fatal. + if [ -n "${REG_USER:-}" ] && [ -n "${REG_PASS:-}" ]; then + echo "Logging into $REGISTRY as $REG_USER (optional, for Haraka)" + if [ "${#DOCKER[@]}" -gt 1 ]; then + "${DOCKER[@]}" login "$REGISTRY" -u "$REG_USER" --password-stdin <<<"$REG_PASS" \ + || echo "Warn: registry login failed; Haraka pull may be skipped" >&2 + else + printf '%s\n' "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin \ + || echo "Warn: registry login failed; Haraka pull may be skipped" >&2 + fi + fi + + if [ ! -f "$DEPLOY_PATH/.env.production" ]; then + echo "Remote env file missing: $DEPLOY_PATH/.env.production" >&2 + exit 1 + fi + + cd "$DEPLOY_PATH" + git fetch --all --tags || true + + if [ -z "$(git status --porcelain)" ]; then + current_branch="$(git branch --show-current || true)" + if [ "$current_branch" != "$DEPLOY_BRANCH" ]; then + git checkout "$DEPLOY_BRANCH" || true + fi + git merge --ff-only "origin/$DEPLOY_BRANCH" || true + else + echo "Skipping checkout sync for $DEPLOY_PATH: local changes present" + fi + + if git rev-parse --verify "${DEPLOY_REF}^{commit}" >/dev/null 2>&1; then + REF="$DEPLOY_REF" + else + REF="origin/${DEPLOY_BRANCH}" + fi + + WORKTREE_PATH="$DEPLOY_PATH/.deploy-worktree.$(date +%s).$$" + OUTPUT_FILE="$DEPLOY_PATH/deploy/generated/generated.docker.yml" + CADDY_OUTPUT_FILE="$DEPLOY_PATH/deploy/generated/generated.Caddyfile" + trap 'git -C "$DEPLOY_PATH" worktree remove --force "$WORKTREE_PATH" >/dev/null 2>&1 || rm -rf "$WORKTREE_PATH"' EXIT + + git worktree add --force --detach "$WORKTREE_PATH" "$REF" + WORKTREE_ENV_FILE="$WORKTREE_PATH/.env.production" + if [ -r "$DEPLOY_PATH/.env.production" ]; then + ln -sfn "$DEPLOY_PATH/.env.production" "$WORKTREE_ENV_FILE" + else + umask 077 + sudo -n cat "$DEPLOY_PATH/.env.production" > "$WORKTREE_ENV_FILE" + fi + cd "$WORKTREE_PATH" + + EXTRA_OVERRIDE_ARGS=() + for override_file in $DEPLOY_COMPOSE_OVERRIDES; do + [ -z "$override_file" ] && continue + case "$override_file" in + /*) resolved_override="$override_file" ;; + *) resolved_override="$WORKTREE_PATH/$override_file" ;; + esac + if [ -f "$resolved_override" ]; then + EXTRA_OVERRIDE_ARGS+=(--compose-override "$resolved_override") + fi + done + + PROFILE_ARGS=() + for profile in $DOCKER_PROFILES; do + PROFILE_ARGS+=(--profile "$profile") + done + + REPAIR_INDEX_ARGS=() + case "$REPAIR_INDEXES_ON_DEPLOY" in + 1|true|TRUE|yes|YES) REPAIR_INDEX_ARGS+=(--repair-indexes) ;; + esac + + # Image already loaded locally — do not --pull (that re-hits the registry). + ELEKTRINE_IMAGE="$TARGET_IMAGE" \ + COMPOSE_PROJECT_NAME="docker" \ + COMPOSE_PROJECT_DIRECTORY="$DEPLOY_PATH/deploy/docker" \ + CADDY_RENDERED_CONFIG_PATH="$CADDY_OUTPUT_FILE" \ + bash scripts/deploy/docker_deploy.sh \ + --modules "$ELEKTRINE_ENABLED_MODULES" \ + --env-file "$WORKTREE_ENV_FILE" \ + --output "$OUTPUT_FILE" \ + --skip-build \ + "${REPAIR_INDEX_ARGS[@]}" \ + "${EXTRA_OVERRIDE_ARGS[@]}" \ + "${PROFILE_ARGS[@]}" + + expected_image="$TARGET_IMAGE" + expected_containers=(elektrine_app elektrine_worker) + case " $DOCKER_PROFILES " in *" email "*) expected_containers+=(elektrine_mail) ;; esac + case " $DOCKER_PROFILES " in *" dns "*) expected_containers+=(elektrine_dns) ;; esac + case " $DOCKER_PROFILES " in *" vpn "*) expected_containers+=(elektrine_vpn) ;; esac + + for container in "${expected_containers[@]}"; do + if ! "${DOCKER[@]}" inspect "$container" >/dev/null 2>&1; then + echo "Expected container $container was not created" >&2 + exit 1 + fi + actual_image="$("${DOCKER[@]}" inspect "$container" --format '{{.Config.Image}}')" + if [ "$actual_image" != "$expected_image" ]; then + echo "Container $container is running $actual_image, expected $expected_image" >&2 + exit 1 + fi + done + EOF + + - name: Health check + run: | + set -euo pipefail + ssh -p "$DEPLOY_PORT" \ + -i "$HOME/.ssh/id_ed25519" \ + -o BatchMode=yes \ + -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$HOME/.ssh/known_hosts" \ + "$DEPLOY_USER@$DEPLOY_HOST" "bash -s" </dev/null 2>&1; then DOCKER_CMD="docker"; else DOCKER_CMD="sudo -n docker"; fi + \$DOCKER_CMD ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' | head -40 + echo "--- haraka ---" + \$DOCKER_CMD ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' | grep -i haraka || true + EOF diff --git a/.forgejo/workflows/smoke.yml b/.forgejo/workflows/smoke.yml new file mode 100644 index 0000000..8e7d35a --- /dev/null +++ b/.forgejo/workflows/smoke.yml @@ -0,0 +1,21 @@ +# Minimal job to verify the Forgejo runner is online. +name: Smoke + +on: + push: + branches: [main] + paths: + - '.forgejo/workflows/smoke.yml' + workflow_dispatch: + +jobs: + hello: + name: Hello from Forgejo Actions + runs-on: ubuntu-latest + steps: + - name: Runner info + run: | + echo "Hello from Forgejo Actions" + uname -a + id + df -h . diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..4427620 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,12 @@ +[ + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["apps/*/priv/*/migrations"], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: [ + "*.{heex,ex,exs}", + "config/**/*.{heex,ex,exs}", + "apps/*/mix.exs", + "apps/*/{lib,test}/**/*.{heex,ex,exs}", + "apps/*/priv/*/seeds.exs" + ] +] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..4edb2fd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,61 @@ +name: Bug report +description: Report a reproducible defect in any umbrella app +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting. Please include enough detail to reproduce. + + - type: dropdown + id: app + attributes: + label: Affected app + options: + - apps/elektrine + - apps/elektrine_web + - apps/elektrine_email + - apps/elektrine_social + - apps/elektrine_vpn + - apps/elektrine_nerve + - multiple apps + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs / stack traces + render: shell + + - type: input + id: version + attributes: + label: Commit SHA or branch + placeholder: e.g. main @ abc1234 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..373e7b0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security report + url: mailto:security@elektrine.com + about: Please do not report vulnerabilities in public issues. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..59671e7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,45 @@ +name: Feature request +description: Propose an enhancement for one or more umbrella apps +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: dropdown + id: app + attributes: + label: Target app + options: + - apps/elektrine + - apps/elektrine_web + - apps/elektrine_email + - apps/elektrine_social + - apps/elektrine_vpn + - apps/elektrine_nerve + - multiple apps + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem statement + description: What pain point are you trying to solve? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + + - type: textarea + id: impact + attributes: + label: Expected impact + description: Performance, security, UX, maintenance, migration notes. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0186bc8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,36 @@ +version: 2 +updates: + # Elixir dependencies + - package-ecosystem: "mix" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + elixir-minor: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 3 + + # npm (if you have assets) + - package-ecosystem: "npm" + directory: "/apps/elektrine/assets" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + npm-minor: + patterns: + - "*" + update-types: + - "minor" + - "patch" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..705e66b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,35 @@ +## Summary + +Describe what changed and why. + +## Scope + +- [ ] `apps/elektrine` +- [ ] `apps/elektrine_web` +- [ ] `apps/arblarg` +- [ ] `apps/elektrine_email` +- [ ] `apps/elektrine_social` +- [ ] `apps/elektrine_vpn` +- [ ] `apps/elektrine_nerve` +- [ ] `apps/elektrine_dns` +- [ ] Other (describe below) + +## Validation + +- [ ] `mix check` +- [ ] Added or updated tests for behavior changes + +## Breaking Changes + +- [ ] None +- [ ] Yes (describe migration or rollout steps) + +## Security / Privacy + +- [ ] No sensitive data added to repo +- [ ] Secrets are still environment-driven +- [ ] New external dependencies reviewed + +## Notes for Reviewers + +Any context that helps review quickly. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ab4b1f2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,119 @@ +name: Quality Checks + +on: + pull_request: + branches: [main] + workflow_call: + inputs: + allow_format_failure: + description: Allow formatting check to fail without failing the workflow + required: false + type: boolean + default: false + allow_test_failure: + description: Allow the test suite to fail without blocking deployment + required: false + type: boolean + default: true +env: + MIX_ENV: test + ELIXIR_VERSION: '1.19.5' + OTP_VERSION: '28.1.1' + DB_HOST: localhost + DB_NAME: elektrine_test + DB_USER: postgres + DB_PASSWORD: postgres + +jobs: + quality_checks: + name: Code Quality (Format + Compile) + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: elektrine_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d elektrine_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Set up Elixir + uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 + with: + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} + + - name: Restore deps cache + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}- + + - name: Install dependencies + run: mix deps.get + + - name: Install JavaScript dependencies + run: npm --prefix apps/elektrine/assets ci + + - name: Build assets + # Feature (Wallaby) tests exercise the app in a real browser and need + # the compiled CSS/JS bundle in priv/static. + run: cd apps/elektrine && mix assets.setup && mix assets.build + + - name: Verify formatting + id: format_check + continue-on-error: ${{ inputs.allow_format_failure }} + run: mix format --check-formatted + + - name: Check generated artifacts are not tracked + run: scripts/check_tracked_generated_artifacts.sh + + - name: Check JavaScript assets + run: npm --prefix apps/elektrine/assets run check + + - name: Run JavaScript dependency audit + run: npm --prefix apps/elektrine/assets audit --omit=dev + + - name: Emit warning when formatting is allowed to fail + if: ${{ inputs.allow_format_failure && steps.format_check.outcome == 'failure' }} + run: echo "::warning::Formatting check failed, but deploy can continue." + + - name: Compile with warnings treated as errors + run: mix compile --warnings-as-errors + + - name: Run Credo + run: mix credo --strict + + - name: Run tests + id: test_check + # Always continue: tests still run and report, but red suites do not + # block Quality Gate / deploy. workflow_call may pass allow_test_failure=false + # to re-enable fail-closed behavior. + continue-on-error: ${{ inputs.allow_test_failure != false }} + run: mix test + + - name: Emit warning when tests are allowed to fail + if: ${{ inputs.allow_test_failure != false && steps.test_check.outcome == 'failure' }} + run: echo "::warning::Test suite failed, but deploy can continue." + + - name: Audit Nerve extension dependencies + run: npm --prefix clients/nerve-extension audit --omit=dev + + - name: Run dependency audit + run: mix deps.audit + + - name: Run Hex package retirement audit + run: mix hex.audit diff --git a/.github/workflows/docker-deploy.yml b/.github/workflows/docker-deploy.yml new file mode 100644 index 0000000..b1f7237 --- /dev/null +++ b/.github/workflows/docker-deploy.yml @@ -0,0 +1,314 @@ +name: Deploy Docker Images + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + repair_indexes: + description: Rebuild all PostgreSQL indexes during deploy + required: false + default: false + type: boolean + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + quality_gate: + name: Quality Gate + uses: ./.github/workflows/ci.yml + with: + allow_format_failure: false + allow_test_failure: true + + build_and_publish_image: + name: Build and Publish Image + needs: quality_gate + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + BUILD_PRIMARY_DOMAIN: ${{ vars.DOCKER_BUILD_PRIMARY_DOMAIN || secrets.DOCKER_BUILD_PRIMARY_DOMAIN || 'example.com' }} + BUILD_EMAIL_DOMAIN: ${{ vars.DOCKER_BUILD_EMAIL_DOMAIN || secrets.DOCKER_BUILD_EMAIL_DOMAIN || 'example.com' }} + BUILD_SUPPORTED_DOMAINS: ${{ vars.DOCKER_BUILD_SUPPORTED_DOMAINS || secrets.DOCKER_BUILD_SUPPORTED_DOMAINS || 'example.com' }} + BUILD_PROFILE_BASE_DOMAINS: ${{ vars.DOCKER_BUILD_PROFILE_BASE_DOMAINS || secrets.DOCKER_BUILD_PROFILE_BASE_DOMAINS || 'example.com' }} + ELEKTRINE_ENABLED_MODULES: all + ELEKTRINE_RELEASE_MODULES: all + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Log in to GHCR + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,prefix=,format=long + + - name: Build and push image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + file: deploy/docker/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + RELEASE_NAME=elektrine + ELEKTRINE_RELEASE_MODULES=${{ env.ELEKTRINE_RELEASE_MODULES || env.ELEKTRINE_ENABLED_MODULES }} + PRIMARY_DOMAIN=${{ env.BUILD_PRIMARY_DOMAIN }} + EMAIL_DOMAIN=${{ env.BUILD_EMAIL_DOMAIN }} + SUPPORTED_DOMAINS=${{ env.BUILD_SUPPORTED_DOMAINS }} + PROFILE_BASE_DOMAINS=${{ env.BUILD_PROFILE_BASE_DOMAINS }} + + deploy_to_docker: + name: Deploy Application + needs: build_and_publish_image + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + env: + # Non-secret deploy settings live in repository Variables. + # Only the private key is a Secret. + DEPLOY_HOST: ${{ vars.DEPLOY_HOST }} + DEPLOY_USER: ${{ vars.DEPLOY_USER }} + DEPLOY_PORT: ${{ vars.DEPLOY_PORT || '22' }} + DEPLOY_PATH: ${{ vars.DEPLOY_PATH || '/opt/elektrine' }} + DEPLOY_BRANCH: ${{ vars.DEPLOY_BRANCH || 'main' }} + DEPLOY_SSH_HOST_KEY: ${{ vars.DEPLOY_SSH_HOST_KEY }} + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + ELEKTRINE_ENABLED_MODULES: all + ELEKTRINE_RELEASE_MODULES: all + DOCKER_PROFILES: caddy dns email tor turn bluesky vpn + DEPLOY_COMPOSE_OVERRIDES: ${{ vars.DEPLOY_COMPOSE_OVERRIDES || '' }} + REPAIR_INDEXES_ON_DEPLOY: ${{ github.event.inputs.repair_indexes || vars.REPAIR_INDEXES_ON_DEPLOY || 'false' }} + TARGET_IMAGE: ghcr.io/${{ github.repository }}:${{ github.sha }} + DEPLOY_REF: ${{ github.sha }} + concurrency: + group: deploy-production + cancel-in-progress: false + + steps: + - name: Validate deploy settings + run: | + missing=() + [ -n "${DEPLOY_HOST}" ] || missing+=(DEPLOY_HOST) + [ -n "${DEPLOY_USER}" ] || missing+=(DEPLOY_USER) + [ -n "${DEPLOY_SSH_HOST_KEY}" ] || missing+=(DEPLOY_SSH_HOST_KEY) + [ -n "${DEPLOY_SSH_KEY}" ] || missing+=(DEPLOY_SSH_KEY) + + if [ "${#missing[@]}" -gt 0 ]; then + printf '::error::Missing deploy settings: %s. Use repository Variables for host/user/path/host-key; DEPLOY_SSH_KEY must remain a Secret.\n' "${missing[*]}" + exit 1 + fi + + if [[ "$DEPLOY_HOST" == *@* ]]; then + echo "::warning::DEPLOY_HOST should be a hostname only. Using the user/host split from DEPLOY_HOST." + echo "DEPLOY_USER=${DEPLOY_HOST%@*}" >> "$GITHUB_ENV" + echo "DEPLOY_HOST=${DEPLOY_HOST##*@}" >> "$GITHUB_ENV" + fi + + - name: Install SSH key + run: | + install -m 700 -d ~/.ssh + printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + + { + printf '%s\n' "$DEPLOY_SSH_HOST_KEY" + while IFS= read -r line; do + case "$line" in + ''|'#'*) continue ;; + esac + + host_field="${line%% *}" + rest="${line#* }" + if [[ "$host_field" == *@* ]]; then + printf '%s %s\n' "${host_field##*@}" "$rest" + fi + done <<< "$DEPLOY_SSH_HOST_KEY" + } > ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + - name: Deploy over SSH + run: | + ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \ + bash -s -- "$DEPLOY_PATH" "$DEPLOY_REF" "$DEPLOY_BRANCH" "$TARGET_IMAGE" "$ELEKTRINE_ENABLED_MODULES" "$ELEKTRINE_RELEASE_MODULES" "$DOCKER_PROFILES" "$DEPLOY_COMPOSE_OVERRIDES" "$REPAIR_INDEXES_ON_DEPLOY" <<'EOF' + set -euo pipefail + + DEPLOY_PATH="$1" + DEPLOY_REF="$2" + DEPLOY_BRANCH="$3" + TARGET_IMAGE="$4" + ELEKTRINE_ENABLED_MODULES="$5" + ELEKTRINE_RELEASE_MODULES="$6" + DOCKER_PROFILES="$7" + DEPLOY_COMPOSE_OVERRIDES="$8" + REPAIR_INDEXES_ON_DEPLOY="$9" + + if docker info >/dev/null 2>&1; then + DEPLOY_PREFIX="" + elif sudo -n docker info >/dev/null 2>&1; then + DEPLOY_PREFIX="sudo -n" + else + echo "No Docker access for deploy user" + exit 1 + fi + + env_grep() { + local pattern="$1" + local env_file="$2" + + if [ -r "$env_file" ]; then + grep -q "$pattern" "$env_file" + else + sudo -n grep -q "$pattern" "$env_file" + fi + } + + if [ ! -f "$DEPLOY_PATH/.env.production" ]; then + echo "Remote env file is missing: $DEPLOY_PATH/.env.production" >&2 + exit 1 + fi + + if [ ! -r "$DEPLOY_PATH/.env.production" ] && ! sudo -n test -r "$DEPLOY_PATH/.env.production"; then + deploy_user_name="$(id -un)" + echo "Remote env file is not readable by $deploy_user_name and sudo cannot read it: $DEPLOY_PATH/.env.production" >&2 + echo "Fix ownership/permissions, for example: sudo chown $deploy_user_name:$deploy_user_name $DEPLOY_PATH/.env.production && sudo chmod 600 $DEPLOY_PATH/.env.production" >&2 + exit 1 + fi + + if [[ " $DOCKER_PROFILES " == *" turn "* ]]; then + if ! env_grep '^TURN_SHARED_SECRET=.' "$DEPLOY_PATH/.env.production"; then + echo "TURN profile is enabled, but TURN_SHARED_SECRET is missing from $DEPLOY_PATH/.env.production" >&2 + echo "Add TURN_SHARED_SECRET to the remote .env.production file." >&2 + exit 1 + fi + fi + + cd "$DEPLOY_PATH" + git fetch --all --tags + + if [ -z "$(git status --porcelain)" ]; then + current_branch="$(git branch --show-current || true)" + if [ "$current_branch" != "$DEPLOY_BRANCH" ]; then + git checkout "$DEPLOY_BRANCH" + fi + git merge --ff-only "origin/$DEPLOY_BRANCH" + else + echo "Skipping checkout sync for $DEPLOY_PATH: local changes present" + fi + + WORKTREE_PATH="$DEPLOY_PATH/.deploy-worktree.$(date +%s).$$" + + OUTPUT_FILE="$DEPLOY_PATH/deploy/generated/generated.docker.yml" + CADDY_OUTPUT_FILE="$DEPLOY_PATH/deploy/generated/generated.Caddyfile" + trap 'git -C "$DEPLOY_PATH" worktree remove --force "$WORKTREE_PATH" >/dev/null 2>&1 || rm -rf "$WORKTREE_PATH"' EXIT + + git worktree add --force --detach "$WORKTREE_PATH" "$DEPLOY_REF" + WORKTREE_ENV_FILE="$WORKTREE_PATH/.env.production" + if [ -r "$DEPLOY_PATH/.env.production" ]; then + ln -sfn "$DEPLOY_PATH/.env.production" "$WORKTREE_ENV_FILE" + else + umask 077 + sudo -n cat "$DEPLOY_PATH/.env.production" > "$WORKTREE_ENV_FILE" + fi + cd "$WORKTREE_PATH" + + EXTRA_OVERRIDE_ARGS=() + for override_file in $DEPLOY_COMPOSE_OVERRIDES; do + case "$override_file" in + /*) resolved_override="$override_file" ;; + *) resolved_override="$WORKTREE_PATH/$override_file" ;; + esac + if [ ! -f "$resolved_override" ]; then + case " $DOCKER_PROFILES " in + *" $override_file "*) + echo "Ignoring DEPLOY_COMPOSE_OVERRIDES entry '$override_file' because it is already enabled in DOCKER_PROFILES." + continue + ;; + *) + case "$override_file" in + bluesky|caddy|dns|email|tor|turn|vpn) + echo "Treating DEPLOY_COMPOSE_OVERRIDES entry '$override_file' as a Docker profile. Prefer setting DOCKER_PROFILES instead." + DOCKER_PROFILES="$DOCKER_PROFILES $override_file" + continue + ;; + esac + ;; + esac + + echo "Compose override not found: $resolved_override" + exit 1 + fi + EXTRA_OVERRIDE_ARGS+=(--compose-override "$resolved_override") + done + + PROFILE_ARGS=() + for profile in $DOCKER_PROFILES; do + PROFILE_ARGS+=(--profile "$profile") + done + + REPAIR_INDEX_ARGS=() + case "$REPAIR_INDEXES_ON_DEPLOY" in + 1|true|TRUE|yes|YES) REPAIR_INDEX_ARGS+=(--repair-indexes) ;; + esac + + ELEKTRINE_IMAGE="$TARGET_IMAGE" COMPOSE_PROJECT_NAME="docker" COMPOSE_PROJECT_DIRECTORY="$DEPLOY_PATH/deploy/docker" CADDY_RENDERED_CONFIG_PATH="$CADDY_OUTPUT_FILE" bash scripts/deploy/docker_deploy.sh \ + --modules "$ELEKTRINE_ENABLED_MODULES" \ + --env-file "$WORKTREE_ENV_FILE" \ + --output "$OUTPUT_FILE" \ + --pull \ + --skip-build \ + "${REPAIR_INDEX_ARGS[@]}" \ + "${EXTRA_OVERRIDE_ARGS[@]}" \ + "${PROFILE_ARGS[@]}" + + expected_image="$TARGET_IMAGE" + expected_containers=(elektrine_app elektrine_worker) + case " $DOCKER_PROFILES " in *" email "*) expected_containers+=(elektrine_mail) ;; esac + case " $DOCKER_PROFILES " in *" dns "*) expected_containers+=(elektrine_dns) ;; esac + case " $DOCKER_PROFILES " in *" vpn "*) expected_containers+=(elektrine_vpn) ;; esac + + for container in "${expected_containers[@]}"; do + if ! ${DEPLOY_PREFIX} docker inspect "$container" >/dev/null 2>&1; then + echo "Expected container $container was not created" >&2 + exit 1 + fi + + actual_image="$(${DEPLOY_PREFIX} docker inspect "$container" --format '{{.Config.Image}}')" + + if [ "$actual_image" != "$expected_image" ]; then + echo "Container $container is running $actual_image, expected $expected_image" >&2 + exit 1 + fi + done + EOF + + - name: Health check + run: | + ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "bash -s" </dev/null 2>&1; then + DOCKER_CMD="docker" + else + DOCKER_CMD="sudo -n docker" + fi + cd "$DEPLOY_PATH" + COMPOSE_FILE="$DEPLOY_PATH/deploy/generated/generated.docker.yml" + \$DOCKER_CMD compose --project-directory "$DEPLOY_PATH/deploy/docker" --env-file "$DEPLOY_PATH/.env.production" -f "\$COMPOSE_FILE" --profile caddy --profile dns --profile email --profile tor --profile turn --profile bluesky --profile vpn ps || \$DOCKER_CMD ps + EOF diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f3c635 --- /dev/null +++ b/.gitignore @@ -0,0 +1,85 @@ +# ============================================================================= +# Elixir / Phoenix + +# Build artifacts +/_build/ +*.ez +erl_crash.dump +# Dependencies +/deps/ +/.fetch +# Documentation +/doc/ +# Test and coverage +/cover/ +/tmp/ +/apps/*/tmp/ +# Package tarball +elektrine-*.tar +# Assets +# Built assets +/apps/*/priv/static/assets/ +/apps/*/priv/static/cache_manifest.json +/apps/*/priv/static/uploads/ +/apps/*/priv/static/**/*.gz +/apps/*/priv/static/**/*-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].* +# Node.js +/apps/*/assets/node_modules/ +/apps/*/assets/.sass-cache/ +/apps/*/assets/npm-debug.log* +/apps/*/assets/yarn-debug.log* +/apps/*/assets/yarn-error.log* +npm-debug.log* +# Environment / Secrets +.env +.env.local +.env.*.local +.env.production +.env.* +!.env.example +# Generated / Runtime Files +# Rate limiting ETS tables +/apps/*/priv/imap_rate_limit_*.ets +# ACME certificates +/apps/*/priv/acme/ +# Export files +/apps/*/priv/exports/ +# Wallaby screenshots +/screenshots/ +# Deploy-time generated configs +/deploy/docker/generated*.yml +/deploy/docker/generated.Caddyfile +/deploy/docker/generated.mongooseim.toml +/deploy/generated/* +!/deploy/generated/.gitkeep +# IDE / Editor +**/.claude/settings.local.json +# macOS Finder metadata +.DS_Store +# Editor / temporary files +*.tmp +*.temp +*.swp +*.swo +*~ +# External Projects (separate repos) +# ARBP conformance badge artifacts (generated by mix arbp.conformance) +/external/arblarg/conformance/badges/ +# Tooling caches / misc +/.elixir_ls/ +.mix +.hex +_deploy_release +/certs/ +/generated.mongooseim.toml +_build_opencode/ +apps/elektrine_web/screenshots/ +# local / agent worktrees and caches (do not ship) +.claude/worktrees/ +.claude/settings.local.json +_build/ +deps/ +**/node_modules/ +.elixir_ls/ +!.env*.example +.claude/**/worktrees/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..114d73e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,127 @@ +# Working in this repo + +Elektrine is an Elixir umbrella app. See `README.md` for the app layout and +what each umbrella app under `apps/` owns. + +## Git commits + +- Do **not** add AI attribution of any kind: no `Co-Authored-By: Claude ...`, + no `Claude-Session:` trailers, no "🤖 Generated with ..." lines, in commit + messages or PR bodies. +- Use [Conventional Commits](https://www.conventionalcommits.org): + `type(scope): subject`. Types in use: `feat`, `fix`, `refactor`, `docs`, + `test`, `chore`. Scope is the app or feature area — `mail`, `email`, `vpn`, + `calendar`, `booking`, `link-archive`, `portal`, `web`, `ui`. +- Subject in the imperative, no trailing period. The body explains **why** the + change exists, not a restatement of the diff. +- Each commit should compile on its own. Watch for files that tie features + together — `router.ex` and `config/config.exs` are routinely touched by + several features at once, so a commit that splits them can leave an + intermediate revision that does not build. +- Do not commit or push unless asked. + +## Before you push + +Pushing `main` to the `github` remote **deploys to production** +(`.github/workflows/docker-deploy.yml` runs on push to `main`). + +- `allow_format_failure: false` — unformatted code blocks the deploy. +- `allow_test_failure: true` — failing tests do **not** block it. Run + `mix test` yourself; CI will not stop a broken suite from shipping. +- `main` tracks `origin/main`, a local forge at `ssh://localhost:2222`. The + `github` remote is the one that deploys. A bare `git push` is not a deploy. + +## Verifying changes + +Always run the checks that CI runs before considering a change done — do not +report work as finished until format, compile, and credo pass. + +After editing a file, run the fast per-file checks first: + +- `mix format ` — format the file. +- `mix credo --strict ` — lint just that file (fast). + +Then, before finishing, run the broader gate: + +- `mix format --check-formatted` — CI fails if anything is unformatted. +- `mix compile --warnings-as-errors` — warnings fail the build; fix them. +- `mix credo --strict` — style/consistency must pass (whole project). +- `mix check` — the full CI gate (all of the above plus generated-artifact + + legacy-marker checks, asset check, dep/hex audits, and `test`). Run this for + anything non-trivial. + +Three budget guards fail in ways that are easy to misread: + +- `scripts/check_maintainability_budgets.sh` errors when a budgeted path no + longer exists, so moving or deleting a budgeted file must update the budget + in the *same* commit. +- `scripts/check_design_consistency.sh` caps raw `btn`/`card` markup, arbitrary + type sizes, off-convention radii, raw hex, and inline styles — per app, for + every `*_web` tree. Lower the budget when you remove instances, so the + surface cannot grow back. A new `*_web` app fails the check until it gets a + `check_app` line. +- `scripts/check_legacy_marker_budget.sh` caps `legacy|backward|compat| + deprecated` markers across `apps`, `config`, `scripts`, and `deploy`. + +## Tests + +- Run the whole suite from the umbrella root: `mix test`. +- Run one app's tests from its dir: `cd apps/elektrine_web && mix test`. +- Run a single file/line: `cd apps/ && mix test test/path/to_test.exs:42`. +- Prefer adding a regression test with any bug fix. +- Running one app's tests can poison `_build/test` for the umbrella; if the + root suite then behaves oddly, `rm -rf _build/test/lib/elektrine`. + +## Conventions + +- Match the style and idioms of the surrounding code in each app. +- Keep domain logic in `apps/elektrine`; keep web/LiveView code in + `apps/elektrine_web`. Don't reach across app boundaries casually. +- Two exceptions, both because `apps/elektrine` is the only app every other + app depends on: UI components shared by more than one product app live in + `apps/elektrine/lib/elektrine/components` (avatar, skeleton, badges), and the + frontend assets live in `apps/elektrine/assets`. Import those modules + directly — do not add a re-export shim in `apps/elektrine_web`. +- The `ElektrineWeb.Components.UI` kit is the design system and stays in + `apps/elektrine_web`. Buttons and cards come from it, not hand-written + markup; use the `text-3xs`/`text-2xs` and radius tokens rather than + arbitrary values. +- Shared text formatting lives in `Elektrine.TextHelpers` — `truncate/2`, + `truncate_to/2`, `abbreviate_count/1`, `time_ago_compact/1`. Call those + rather than re-deriving them privately, so the same value renders the same + way in every app. + +## Federation security + +- `Elektrine.ActivityPub.SignatureVerification` is the **only** inbound HTTP + signature verifier. Every entrance — the inbox plug and the signature retry + queue — goes through `verify/2`; callers differ only in the options they + pass. Never add a second verifier or a path that checks the signature + without also checking required signed headers, timestamp freshness, digest + against the raw body, and replay. A partial verifier reachable from anywhere + becomes the way in. +- `Elektrine.ActivityPub.HTTPSignature` signs outbound requests only. +- Only key-resolution failures are retryable + (`SignatureVerification.retryable_error?/1`). A signature that verified but + broke a constraint has been rejected on the merits — do not queue it. +- Retried requests store the raw body and the arrival time. The activity is + re-parsed from those bytes *after* the digest check, so what gets processed + is what the signature covered, and freshness is judged against arrival + rather than when the job happens to run. +- `Elektrine.ActivityPub.InboundDeliveryPolicy.permit/4` is the one audience + gate. Any new inbox entrance applies it — it is what stops a peer pushing + content at an inbox it has no relationship with. + +## Mail and Haraka + +Haraka is a separate deployment (`elektrine-haraka`) that owns inbound SMTP on +port 25 and outbound delivery; Elektrine owns mailboxes, JMAP, submission, and +the webhooks Haraka calls. Two layouts are supported and each has its own TLS +entry point, which write the same `compose.override.yml` — use exactly one per +Haraka deployment: + +- same host: `scripts/deploy/configure_haraka_wildcard_tls.sh` (the default; + `docker_deploy.sh` keeps it configured) +- separate host: `scripts/acme/setup_haraka_mail_tls.sh` + +See `docs/self-hosting/mail.md` before changing either. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..77cd88e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,56 @@ +# Code of Conduct + +## Our Pledge + +We as members, contributors, and maintainers pledge to make participation in +our community a harassment-free experience for everyone, regardless of age, +body size, visible or invisible disability, ethnicity, sex characteristics, +gender identity and expression, level of experience, education, socioeconomic +status, nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness. +- Being respectful of differing opinions and experiences. +- Giving and gracefully accepting constructive feedback. +- Taking responsibility, apologizing, and learning from mistakes. +- Focusing on what is best for the overall community. + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery. +- Trolling, insulting or derogatory comments, and personal or political attacks. +- Public or private harassment. +- Publishing others' private information without explicit permission. +- Other conduct that could reasonably be considered inappropriate in a + professional setting. + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior they deem inappropriate, threatening, offensive, or +harmful. + +## Scope + +This Code of Conduct applies within all project spaces and also applies when an +individual is officially representing the project in public spaces. + +## Enforcement + +Report unacceptable behavior to the maintainers via the security/reporting +process in `SECURITY.md`. + +All reports will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 2.1: +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0103ad7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,66 @@ +# Contributing + +Thanks for contributing to Elektrine. + +## Scope + +This repository is an umbrella project. All apps in `apps/` are open source +under the same root `LICENSE`: + +- `apps/elektrine` +- `apps/elektrine_web` +- `apps/arblarg` +- `apps/elektrine_email` +- `apps/elektrine_social` +- `apps/elektrine_vpn` +- `apps/elektrine_nerve` +- `apps/elektrine_dns` + +## License + +This repository is licensed under `AGPL-3.0-only` (see `LICENSE`). +By contributing, you agree that your contributions are provided under that same license. + +## Development Setup + +1. Install Elixir/Erlang and PostgreSQL. +2. Clone the repo. +3. Copy `env/dev.env.example` to `.env` and set local values. +4. Run setup: + +```bash +mix setup +``` + +5. Start the app: + +```bash +cd apps/elektrine +mix phx.server +``` + +## Running Checks + +Run these before opening a PR: + +```bash +mix check +``` + +## Pull Requests + +1. Keep PRs focused and small. +2. Include tests for behavior changes. +3. Update docs when behavior/config changes. +4. Fill out the PR template. + +## Commit Style + +Use clear, imperative commit messages, for example: + +- `Add federation outbox retry worker` +- `Fix sequence gap recovery for messaging events` + +## Security + +Do not open public issues for vulnerabilities. Use the process in `SECURITY.md`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e638dd3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,663 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by this +License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders +of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of this +License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in +detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero +General Public License "or any later version" applies to it, you have +the option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever +published by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that +proxy's public statement of acceptance of a version permanently +authorizes you to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE +COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR +CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT +NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES +SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE +WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN +ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these +terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public + License along with this program. If not, see + . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. For more information on this, and how to apply and follow +the GNU AGPL, see . diff --git a/README.md b/README.md new file mode 100644 index 0000000..52a081d --- /dev/null +++ b/README.md @@ -0,0 +1,206 @@ +# Elektrine + +Elektrine is an Elixir umbrella app. Shared runtime and persistence live in +`apps/elektrine`, the Phoenix host app lives in `apps/elektrine_web`, and the +product areas are split into their own umbrella apps. + +## Layout + +- `apps/`: umbrella apps +- `clients/`: client-side artifacts that are not shipped with the server release +- `config/`: shared compile-time and runtime config +- `deploy/`: Docker, Caddy, onion, and VPN deploy assets +- `docs/`: architecture, protocol, API, and self-hosting docs +- `env/`: example environment files +- `release_builder/`: module-aware release build project +- `scripts/`: release, deploy, and ops helpers + +## Main apps + +- `apps/elektrine`: shared domain logic, `Repo`, supervisors, accounts, uploads, notifications, calendar, module selection, frontend assets, and the cross-app UI components in `lib/elektrine/components` +- `apps/elektrine_web`: endpoint, router, plugs, layouts, the `ElektrineWeb.Components.UI` design-system kit, and the account/admin shell +- `apps/arblarg`: chat facade plus LiveViews, JSON APIs, and PAT APIs +- `apps/elektrine_social`: timeline, communities, federation, social controllers, and LiveViews +- `apps/elektrine_email`: mailbox, contacts, mail protocols, JMAP, WKD, and mail web routes +- `apps/elektrine_vpn`: WireGuard management and VPN UI/API +- `apps/elektrine_nerve`: Nerve domain and extracted routes +- `apps/elektrine_dns`: managed DNS runtime, DNS API routes, and DNS LiveViews +- `apps/elektrine_uptime`: uptime monitors, check history, incidents, and dashboard routes +- `apps/atomine`: proofs, personhood, account trust, and credit policy domain logic +- `apps/kairo`: capture, indexing, and graph-oriented knowledge features +- `apps/paige`: search provider integration and ranking support + +Requests enter through `ElektrineWeb.Router`, pass through shared plugs and +module guards, and then land in the controller or LiveView mounted for the +selected feature set. Persistence stays centralized in `Elektrine.Repo`. + +## Identity Provider + +Elektrine exposes an OpenID Connect-compatible identity provider on top of the +existing OAuth tables: + +- discovery: `/.well-known/openid-configuration` +- browser consent: `/oauth/authorize` +- public keys: `/oauth/jwks` +- token exchange: `/oauth/token` +- user info: `/oauth/userinfo` +- app management UI: `/account/developer/oidc/clients` +- grant review UI: `/account/developer/oidc/grants` +- dynamic registration: `POST /oauth/register` while signed in + +Register OAuth apps with `openid`, `profile`, and `email` scopes to use this +flow. The current implementation supports the authorization code flow and +issues `RS256` `id_token`s for confidential clients. + +## Module Selection + +Normal deployments use one public module switch: + +- `ELEKTRINE_ENABLED_MODULES` controls which product modules are turned on +- release builds default to that same module list +- `ELEKTRINE_RELEASE_MODULES` still exists as an advanced override when you need + to compile more modules than you expose at runtime + +Supported module ids are `chat`, `social`, `email`, `nerve`, `vpn`, `dns`, +`uptime`, `atomine`, and `kairo`. `proofs` and `personhood` are accepted aliases +for `atomine` in the deploy scripts. + +In normal use, set `ELEKTRINE_ENABLED_MODULES` and leave +`ELEKTRINE_RELEASE_MODULES` unset. + +## Local Development + +From the repo root: + +```bash +mix setup +``` + +Start the web app: + +```bash +cd apps/elektrine +mix phx.server +``` + +Useful commands: + +```bash +mix compile +mix check +mix test +mix test apps/elektrine/test +mix test apps/elektrine_web/test +mix test apps/elektrine_email/test +mix test apps/elektrine_social/test +mix test apps/elektrine_vpn/test +mix test apps/elektrine_nerve/test +mix test apps/elektrine_dns/test +``` + +Frontend assets live under `apps/elektrine/assets`. + +## Build And Deploy + +The supported path for hosted or subset installs is the release builder: + +```bash +scripts/release/deploy_release.sh --modules email,vpn +``` + +This builds assets, selects the requested feature apps, and writes the release +to `_deploy_release/`. `deploy/docker/Dockerfile` uses the same path. + +For Docker deployments, use the wrapper instead of invoking `docker compose` +against `deploy/docker/compose.full.yml` directly: + +```bash +scripts/deploy/docker_deploy.sh +``` + +The wrapper renders a module-aware Compose file first, sets +`ELEKTRINE_ENABLED_MODULES`, derives release modules from it by default, sets +mail runtime flags, and removes POP3, IMAP, and SMTP port bindings when +`email` is not selected. With no module or profile arguments, it deploys all +standard modules and Docker profiles; pass `--modules` or `--profile` only for a +smaller stack. + +When `vpn` is selected, the Docker deploy also enables the bundled `vpn` +service automatically. That service runs the Docker-managed WireGuard node, +creates or restores its private key, derives the public key, and reconciles +peer state in-app. + +## Self-hosting + +The self-hosting docs are split by profile: + +- `core`: Phoenix app and Postgres only +- `mail`: Elektrine mail protocols; production SMTP edge/delivery still needs a separate Haraka deployment +- `dns`: optional authoritative DNS service enabled through the Docker `dns` profile +- `vpn`: optional Docker-managed WireGuard on the same stack, with optional fleet mode +- `addons`: Caddy edge, TURN, Bluesky PDS, onion hosting, and client artifacts + +Start with: + +- `docs/api/external-api.md` +- `docs/self-hosting/README.md` +- `docs/self-hosting/docker.md` +- `docs/self-hosting/core.md` +- `docs/self-hosting/caddy.md` +- `docs/self-hosting/mail.md` +- `docs/architecture/dns-module.md` +- `docs/self-hosting/turn.md` +- `docs/self-hosting/vpn.md` +- `docs/addons/onion.md` +- `docs/clients/nerve-extension.md` +- `docs/clients/static-site-github-deploys.md` + +## Email Deployment + +The `email` module in this repo is the mailbox/product side of the mail stack. +Production internet mail also needs +[`elektrine/elektrine-haraka`](https://git.elektrine.com/elektrine/elektrine-haraka), +which owns the SMTP edge and delivery pipeline. + +This repo owns the mailbox product: UI, aliases, contacts, JMAP, WKD, message +storage, optional IMAP/POP3/SMTP submission listeners, and the Phoenix endpoints +that receive mail webhooks. `elektrine-haraka` owns inbound SMTP on port 25, +outbound delivery, Redis-backed queueing, and the worker that posts cleaned +inbound message data back into Phoenix. + +If you enable the `email` module for production, run `elektrine-haraka` +alongside Elektrine and configure `HARAKA_BASE_URL`, an outbound Haraka API key, +and an inbound webhook key. Both stacks live on the same host as separate Docker +projects (`/opt/elektrine` and `/opt/elektrine-haraka`). + +**Deploy split** + +- `elektrine-haraka` CI builds and pushes + `git.elektrine.com/elektrine/elektrine-haraka` (no SSH deploy). +- Elektrine deploy rolls the app stack, then pulls that image and restarts the + Haraka compose services (`maybe_roll_haraka_stack`), and applies wildcard TLS + via `configure_haraka_wildcard_tls.sh`. + +Optional env for the roll: `HARAKA_IMAGE`, `HARAKA_IMAGE_TAG` (default `latest`), +`HARAKA_DEPLOY_DIR`, `HARAKA_ROLL_ON_DEPLOY=false` to skip. + +## Bluesky Integration + +Bluesky support is part of the social stack. + +- Outbound sync mirrors local public posts to Bluesky and keeps linked post + state in sync for create, edit, delete, like, repost, and follow events. + These jobs run through Oban, so failed outbound calls do not block the local + action. +- Inbound sync, when `BLUESKY_INBOUND_ENABLED=true`, polls notifications for + connected accounts and turns replies, mentions, quotes, likes, and reposts on + mirrored posts into local notifications. It can also store timeline items + locally. +- Managed mode, when `BLUESKY_MANAGED_ENABLED=true`, talks to a + Bluesky-compatible PDS admin API to create per-user accounts, issue app + passwords, and store the linkage. Without managed mode, users connect their + own Bluesky identifier and app password. + +## License + +This repository is AGPL-3.0-only unless a file says otherwise. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b123f4f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Security Policy + +## Supported Versions + +Security fixes are currently provided for: + +- `main` branch (latest commit) + +## Reporting a Vulnerability + +Please do not file public GitHub issues for security vulnerabilities. + +Instead: + +1. Email maintainers at `security@elektrine.com`. +2. Include reproduction steps, impact, and affected components/apps. +3. If possible, include a minimal proof of concept. + +You should receive an initial response within 72 hours. + +## Disclosure Process + +1. We acknowledge receipt and assess severity. +2. We prepare and validate a fix. +3. We coordinate disclosure timing with the reporter. +4. We publish a fix and release notes. + +## Scope Notes + +This policy applies to all umbrella apps under `apps/`. diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 0000000..0475f36 --- /dev/null +++ b/apps/README.md @@ -0,0 +1,44 @@ +# Umbrella Apps + +Elektrine is organized as an Elixir umbrella project. + +## Apps + +- [`elektrine`](./elektrine/README.md) - Core domain logic, persistence, runtime services +- [`elektrine_web`](./elektrine_web/README.md) - Phoenix endpoint, router, shared layout, and common components +- [`arblarg`](./arblarg/README.md) - Chat facade, LiveViews, JSON APIs, and PAT APIs +- [`elektrine_email`](./elektrine_email/README.md) - Mailbox domain, mail protocols, JMAP, WKD, and web routes +- [`elektrine_social`](./elektrine_social/README.md) - Social/timeline features +- [`elektrine_vpn`](./elektrine_vpn/README.md) - VPN features +- [`elektrine_nerve`](./elektrine_nerve/README.md) - Password nerve domain and route macros +- [`elektrine_dns`](./elektrine_dns/README.md) - Managed DNS runtime, PAT API, and DNS LiveViews +- [`elektrine_uptime`](./elektrine_uptime/README.md) - Uptime monitors, check history, incidents, and dashboard routes +- [`atomine`](./atomine) - Proofs, personhood, account trust, and credit policy domain logic +- [`kairo`](./kairo) - Capture, indexing, and graph-oriented knowledge features +- [`paige`](./paige) - Search provider integration and ranking support + +## Running app-focused tests + +From the umbrella root: + +```bash +mix test apps/elektrine/test +mix test apps/elektrine_web/test +mix test apps/elektrine_email/test +mix test apps/elektrine_social/test +mix test apps/elektrine_vpn/test +mix test apps/elektrine_nerve/test +mix test apps/elektrine_dns/test +mix test apps/elektrine_uptime/test +mix test apps/atomine/test +mix test apps/kairo/test +mix test apps/paige/test +``` + +## Protocol docs + +The current Arblarg messaging federation spec lives at `../docs/arblarg-1.0.md`. + +## License + +All umbrella apps are covered by the root repository `LICENSE`. diff --git a/apps/arblarg/.formatter.exs b/apps/arblarg/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/apps/arblarg/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/apps/arblarg/.gitignore b/apps/arblarg/.gitignore new file mode 100644 index 0000000..8b0b58a --- /dev/null +++ b/apps/arblarg/.gitignore @@ -0,0 +1,24 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Temporary files, for example, from tests. +/tmp/ + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +elektrine_chat-*.tar + diff --git a/apps/arblarg/README.md b/apps/arblarg/README.md new file mode 100644 index 0000000..723b909 --- /dev/null +++ b/apps/arblarg/README.md @@ -0,0 +1,20 @@ +# Arblarg + +Chat application boundary for the Elektrine umbrella. + +## What lives here + +- Chat APIs used by web and mobile clients +- Chat LiveViews, JSON APIs, PAT APIs, and admin Arblarg views +- Chat-specific components and web helpers in `ArblargWeb.*` and `ElektrineWeb.*` + +## Notes + +- The public facade is `Arblarg`. +- Domain calls still delegate to `Elektrine.Messaging` today. +- Uses shared layouts and components from `elektrine_web`. +- Router-mounted modules in this app still use the shared `ElektrineWeb.*` namespace where that keeps route integration simple. + +## License + +AGPL-3.0-only (see `../../LICENSE`). diff --git a/apps/arblarg/lib/arblarg.ex b/apps/arblarg/lib/arblarg.ex new file mode 100644 index 0000000..3bd7b63 --- /dev/null +++ b/apps/arblarg/lib/arblarg.ex @@ -0,0 +1,138 @@ +defmodule Arblarg do + @moduledoc """ + Chat application facade. + + This module is the primary API surface for chat use-cases consumed by + `elektrine_web` and other clients. It delegates to `Elektrine.Messaging` + during migration so call sites can move first without behavior changes. + """ + + alias Elektrine.Messaging + + # Conversations and discovery + defdelegate list_conversations(user_id, opts \\ []), to: Messaging + defdelegate user_has_conversations?(user_id), to: Messaging + defdelegate get_conversation!(id, user_id), to: Messaging + defdelegate get_conversation_unread_count(conversation_id, user_id), to: Messaging + defdelegate get_conversation_for_chat!(id, user_id), to: Messaging + defdelegate get_conversation_for_chat_by_hash!(hash, user_id), to: Messaging + defdelegate get_conversation_by_hash(hash), to: Messaging + defdelegate search_public_conversations(query, current_user_id, limit \\ 10), to: Messaging + defdelegate list_public_channels(opts \\ []), to: Messaging + defdelegate list_public_groups(opts \\ []), to: Messaging + defdelegate join_conversation(conversation_id, user_id), to: Messaging + defdelegate leave_conversation(conversation_id, user_id), to: Messaging + defdelegate pin_conversation(conversation_id, user_id), to: Messaging + defdelegate unpin_conversation(conversation_id, user_id), to: Messaging + defdelegate update_conversation(conversation, attrs), to: Messaging + defdelegate delete_conversation(conversation_id), to: Messaging + defdelegate clear_history_for_user(conversation_id, user_id), to: Messaging + defdelegate create_dm_conversation(user1_id, user2_id), to: Messaging + defdelegate create_group_conversation(creator_id, attrs, member_ids \\ []), to: Messaging + defdelegate create_channel(creator_id, attrs), to: Messaging + + defdelegate add_member_to_conversation( + conversation_id, + user_id, + role \\ "member", + added_by_user_id \\ nil + ), + to: Messaging + + defdelegate remove_member_from_conversation(conversation_id, user_id), to: Messaging + defdelegate update_member_role(conversation_id, user_id, new_role), to: Messaging + defdelegate update_member_role(conversation_id, user_id, new_role, actor_user_id), to: Messaging + defdelegate get_conversation_member(conversation_id, user_id), to: Messaging + defdelegate get_conversation_members(conversation_id), to: Messaging + + # Messages and reactions + defdelegate get_conversation_messages(conversation_id, user_id, opts \\ []), to: Messaging + defdelegate get_messages(conversation_id, user_id, opts \\ []), to: Messaging + + defdelegate create_text_message( + conversation_id, + sender_id, + content, + reply_to_id \\ nil, + opts \\ [] + ), + to: Messaging + + defdelegate create_media_message( + conversation_id, + sender_id, + media_urls, + content \\ nil, + media_metadata \\ %{} + ), + to: Messaging + + defdelegate create_voice_message(conversation_id, sender_id, audio_url, duration, mime_type), + to: Messaging + + defdelegate delete_message(message_id, user_id, is_admin \\ false), to: Messaging + defdelegate admin_delete_message(message_id, admin_user), to: Messaging + defdelegate add_reaction(message_id, user_id, emoji), to: Messaging + defdelegate pin_message(message_id, user_id), to: Messaging + defdelegate unpin_message(message_id, user_id), to: Messaging + + defdelegate create_chat_text_message(conversation_id, sender_id, content, opts \\ []), + to: Messaging + + defdelegate create_chat_media_message( + conversation_id, + sender_id, + media_urls, + content \\ nil, + media_metadata \\ %{} + ), + to: Messaging + + defdelegate create_chat_voice_message( + conversation_id, + sender_id, + audio_url, + duration, + mime_type + ), + to: Messaging + + defdelegate edit_chat_message(message_id, user_id, new_content), to: Messaging + defdelegate delete_chat_message(message_id, user_id, is_admin \\ false), to: Messaging + defdelegate add_chat_reaction(message_id, user_id, emoji), to: Messaging + defdelegate remove_chat_reaction(message_id, user_id, emoji), to: Messaging + + defdelegate search_messages_in_conversation(conversation_id, user_id, query, opts \\ []), + to: Messaging + + # Read state and unread counters + defdelegate mark_as_read(conversation_id, user_id), to: Messaging + defdelegate update_last_read_message(conversation_id, user_id, message_id), to: Messaging + defdelegate get_read_status_for_messages(message_ids, conversation_id), to: Messaging + defdelegate get_unread_count(user_id), to: Messaging + defdelegate get_conversation_unread_counts(conversation_ids, user_id), to: Messaging + defdelegate get_batch_last_message_read_status(message_info_list), to: Messaging + + defdelegate mark_chat_messages_read(conversation_id, user_id, up_to_message_id \\ nil), + to: Messaging + + defdelegate get_all_chat_unread_counts(user_id), to: Messaging + + # Search and moderation + defdelegate list_servers(user_id, opts \\ []), to: Messaging + defdelegate list_public_servers(user_id, opts \\ []), to: Messaging + defdelegate list_public_directory_servers(opts \\ []), to: Messaging + defdelegate get_server(server_id, user_id), to: Messaging + defdelegate get_server_member(server_id, user_id), to: Messaging + defdelegate create_server(creator_id, attrs), to: Messaging + defdelegate create_server_channel(server_id, creator_id, attrs), to: Messaging + defdelegate join_server(server_id, user_id), to: Messaging + + defdelegate search_users(query, current_user_id, opts \\ []), to: Messaging + defdelegate timeout_user(user_id, created_by_id, duration_seconds, opts \\ []), to: Messaging + defdelegate remove_timeout(user_id, conversation_id \\ nil), to: Messaging + defdelegate user_timed_out?(user_id, conversation_id \\ nil), to: Messaging + + defdelegate log_moderation_action(action, target_user_id, performed_by_id, opts \\ []), + to: Messaging +end diff --git a/apps/arblarg/lib/arblarg/application.ex b/apps/arblarg/lib/arblarg/application.ex new file mode 100644 index 0000000..44ad4f7 --- /dev/null +++ b/apps/arblarg/lib/arblarg/application.ex @@ -0,0 +1,20 @@ +defmodule Arblarg.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + # Starts a worker by calling: Arblarg.Worker.start_link(arg) + # {Arblarg.Worker, arg} + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: Arblarg.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/apps/arblarg/lib/arblarg_web.ex b/apps/arblarg/lib/arblarg_web.ex new file mode 100644 index 0000000..e33630b --- /dev/null +++ b/apps/arblarg/lib/arblarg_web.ex @@ -0,0 +1,96 @@ +defmodule ArblargWeb do + @moduledoc """ + Shared web entrypoints for chat surfaces extracted from elektrine_web. + """ + + def controller do + quote do + use Phoenix.Controller, + formats: [:html, :json], + layouts: [html: ElektrineWeb.Layouts] + + use Gettext, backend: ElektrineWeb.Gettext + + import Plug.Conn + + alias Elektrine.Utils.SafeConvert + + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {ElektrineWeb.Layouts, :app} + + on_mount ElektrineWeb.Live.AnnouncementCache + + import ElektrineWeb.Live.NotificationHelpers + + unquote(html_helpers()) + + # Presence events are handled by ElektrineWeb's PresenceHook where it is + # mounted; these no-op fallbacks keep stray client events from crashing + # LiveViews that don't track presence. + def handle_event(event, _params, socket) + when event in [ + "user_activity", + "auto_away_timeout", + "device_detected", + "connection_changed" + ] do + {:noreply, socket} + end + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + use Gettext, backend: ElektrineWeb.Gettext + + import Phoenix.HTML + import ElektrineWeb.CoreComponents + import Elektrine.Components.User.Avatar + import Elektrine.Components.Loaders.Skeleton + import ArblargWeb.Components.UI.ImageModal + + alias Elektrine.Utils.SafeConvert + alias Phoenix.LiveView.JS + + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: ElektrineWeb.Endpoint, + router: ElektrineWeb.Router, + statics: ElektrineWeb.static_paths() + end + end + + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/apps/arblarg/lib/arblarg_web/components/chat/call.ex b/apps/arblarg/lib/arblarg_web/components/chat/call.ex new file mode 100644 index 0000000..c159f22 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/components/chat/call.ex @@ -0,0 +1,368 @@ +defmodule ArblargWeb.Components.Chat.Call do + @moduledoc false + use Phoenix.Component + + attr :user, :map, default: nil + attr :conversation_id, :string, default: nil + attr :remote_handle, :string, default: nil + + def call_buttons(assigns) do + ~H""" +
+ + + +
+ """ + end + + attr :call, :map, required: true + attr :show, :boolean, default: false + + def incoming_call_modal(assigns) do + ~H""" + + """ + end + + attr :call, :map, required: true + attr :show, :boolean, default: false + attr :audio_enabled, :boolean, default: true + attr :video_enabled, :boolean, default: true + attr :is_caller, :boolean, default: false + attr :call_status, :string, default: "connecting" + + def active_call_overlay(assigns) do + ~H""" +
+
+ +
+ +
+
+
+ <.user_avatar user={if @is_caller, do: @call.callee, else: @call.caller} size="sm" /> +
+

+ {if @is_caller, do: @call.callee.username, else: @call.caller.username} +

+

+ {if @call_status == "connected", do: "00:00", else: "Connecting..."} +

+
+
+
+
+ +
+ +
+ <.user_avatar user={if @is_caller, do: @call.callee, else: @call.caller} size="2xl" /> +
+

+ {if @is_caller, do: @call.callee.username, else: @call.caller.username} +

+

+ {if @call_status == "connected", do: "00:00", else: "Connecting..."} +

+
+
+ <.spinner :if={@call_status == "connecting"} size="sm" /> + + + {String.capitalize(@call_status)} +
+
+
+ +
+
+ <.spinner size="sm" /> + {String.capitalize(@call_status)}... +
+
+ +
+
+ + Connected +
+
+ +
+
+ + + + + +
+
+
+ """ + end + + attr :user, :map, required: true + attr :size, :string, default: "md" + + defp user_avatar(assigns) do + size_classes = %{ + "sm" => "w-8 h-8", + "md" => "w-12 h-12", + "lg" => "w-16 h-16", + "2xl" => "w-24 h-24" + } + + assigns = + assign(assigns, :size_class, Map.get(size_classes, assigns.size, size_classes["md"])) + + ~H""" +
+
+ + + {String.first(@user.username) |> String.upcase()} + +
+
+ """ + end + + attr :size, :string, default: "md", values: ["xs", "sm", "md", "lg"] + attr :class, :string, default: nil + + defp spinner(assigns) do + ~H""" + "w-3 h-3" + "sm" -> "w-4 h-4" + "lg" -> "w-8 h-8" + _ -> "w-6 h-6" + end, + @class + ]} + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + fill="currentColor" + aria-hidden="true" + role="status" + aria-label="Loading" + > + + + """ + end +end diff --git a/apps/arblarg/lib/arblarg_web/components/chat/sender_badge.ex b/apps/arblarg/lib/arblarg_web/components/chat/sender_badge.ex new file mode 100644 index 0000000..195d884 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/components/chat/sender_badge.ex @@ -0,0 +1,46 @@ +defmodule ArblargWeb.Components.Chat.SenderBadge do + @moduledoc """ + Small badge shown next to automated chat message authors. + + Webhook-authored messages hydrate a sender-shaped map with `webhook: true` + (see `Elektrine.Messaging.ChatMessages`); bot user accounts carry an + `is_bot` flag. Both render a small "APP"/"BOT" badge so automated authors + are visually distinct from people. + + Usage in message templates, next to the sender name: + + <.sender_badge sender={message_sender(message)} /> + + Renders nothing for regular human senders. + """ + use Phoenix.Component + + attr :sender, :map, default: nil + + def sender_badge(assigns) do + assigns = assign(assigns, :label, badge_label(assigns.sender)) + + ~H""" + + {@label} + + """ + end + + @doc """ + Returns "APP" for webhook senders, "BOT" for bot users, nil otherwise. + """ + def badge_label(sender) when is_map(sender) do + cond do + Map.get(sender, :webhook) == true -> "APP" + Map.get(sender, :is_bot) == true -> "BOT" + true -> nil + end + end + + def badge_label(_sender), do: nil +end diff --git a/apps/arblarg/lib/arblarg_web/components/platform/e_nav.ex b/apps/arblarg/lib/arblarg_web/components/platform/e_nav.ex new file mode 100644 index 0000000..70eabe6 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/components/platform/e_nav.ex @@ -0,0 +1,50 @@ +defmodule ArblargWeb.Components.Platform.ENav do + @moduledoc """ + Chat-owned wrapper for the shared platform navigation renderer. + """ + + use Phoenix.Component + use Gettext, backend: ElektrineWeb.Gettext + + alias Elektrine.Platform.ENav, as: PlatformENav + alias Elektrine.Platform.ENavComponent + + attr :active_tab, :string, required: true + attr :class, :string, default: "mb-6" + attr :current_user, :any, default: nil + attr :badge_counts, :map, default: nil + + def e_nav(assigns) do + assigns = assign_new(assigns, :badge_counts, fn -> nil end) + + badge_counts = + assigns.badge_counts || PlatformENav.notification_badge_counts(assigns.current_user) + + assigns = + assigns + |> assign(:items, nav_items(assigns.current_user, badge_counts)) + |> assign(:secondary_items, secondary_items(assigns.current_user, badge_counts)) + + ENavComponent.render(assigns) + end + + defp nav_items(current_user, badge_counts) do + PlatformENav.primary_items() + |> Enum.map( + &Map.update!(&1, :label, fn label -> Gettext.gettext(ElektrineWeb.Gettext, label) end) + ) + |> Enum.filter(&PlatformENav.visible?(&1, current_user)) + |> PlatformENav.with_badge_counts(badge_counts) + end + + defp secondary_items(nil, _badge_counts), do: [] + + defp secondary_items(current_user, badge_counts) do + PlatformENav.secondary_items() + |> Enum.map( + &Map.update!(&1, :label, fn label -> Gettext.gettext(ElektrineWeb.Gettext, label) end) + ) + |> Enum.filter(&PlatformENav.visible?(&1, current_user)) + |> PlatformENav.with_badge_counts(badge_counts) + end +end diff --git a/apps/arblarg/lib/arblarg_web/components/social/content_journey.ex b/apps/arblarg/lib/arblarg_web/components/social/content_journey.ex new file mode 100644 index 0000000..aa94904 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/components/social/content_journey.ex @@ -0,0 +1,11 @@ +defmodule ArblargWeb.Components.Social.ContentJourney do + @moduledoc false + + alias ArblargWeb.OptionalModule + + @component_module :"Elixir.ElektrineSocialWeb.Components.Social.ContentJourney" + + def content_journey(assigns) do + OptionalModule.call(:social, @component_module, :content_journey, [assigns], "") + end +end diff --git a/apps/arblarg/lib/arblarg_web/components/social/embedded_post.ex b/apps/arblarg/lib/arblarg_web/components/social/embedded_post.ex new file mode 100644 index 0000000..d9811e2 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/components/social/embedded_post.ex @@ -0,0 +1,11 @@ +defmodule ArblargWeb.Components.Social.EmbeddedPost do + @moduledoc false + + alias ArblargWeb.OptionalModule + + @component_module :"Elixir.ElektrineSocialWeb.Components.Social.EmbeddedPost" + + def embedded_post(assigns) do + OptionalModule.call(:social, @component_module, :embedded_post, [assigns], "") + end +end diff --git a/apps/arblarg/lib/arblarg_web/components/ui/image_modal.ex b/apps/arblarg/lib/arblarg_web/components/ui/image_modal.ex new file mode 100644 index 0000000..612bbf0 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/components/ui/image_modal.ex @@ -0,0 +1,11 @@ +defmodule ArblargWeb.Components.UI.ImageModal do + @moduledoc false + + alias ArblargWeb.OptionalModule + + @component_module :"Elixir.ElektrineSocialWeb.Components.UI.ImageModal" + + def image_modal(assigns) do + OptionalModule.call(:social, @component_module, :image_modal, [assigns], "") + end +end diff --git a/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_controller.ex b/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_controller.ex new file mode 100644 index 0000000..07dce7f --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_controller.ex @@ -0,0 +1,410 @@ +defmodule ArblargWeb.Admin.ChatMessagesController do + @moduledoc """ + Controller for admin chat message viewing and management. + """ + + use ArblargWeb, :controller + + alias Elektrine.{Messaging, Repo} + import Ecto.Query + + plug :put_layout, html: {ElektrineWeb.Layouts, :admin} + plug :assign_timezone_and_format + + defp assign_timezone_and_format(conn, _opts) do + current_user = conn.assigns[:current_user] + + timezone = + if current_user && current_user.timezone, do: current_user.timezone, else: "Etc/UTC" + + time_format = + if current_user && current_user.time_format, do: current_user.time_format, else: "12" + + conn + |> assign(:timezone, timezone) + |> assign(:time_format, time_format) + end + + def index(conn, params) do + search_query = params |> Map.get("search", "") |> String.trim() + + conversation_type_filter = + parse_conversation_type_filter(Map.get(params, "conversation_type")) + + protocol_filter = parse_protocol_filter(Map.get(params, "protocol")) + page = SafeConvert.parse_page(params) + per_page = 50 + offset = (page - 1) * per_page + + query = + base_chat_messages_query() + |> maybe_filter_conversation_type(conversation_type_filter) + |> maybe_filter_protocol(protocol_filter) + |> maybe_search(search_query) + + total_count = + query + |> exclude(:order_by) + |> select([m, ...], m.id) + |> Repo.aggregate(:count, :id) + + messages = + query + |> order_by([m], desc: m.inserted_at) + |> limit(^per_page) + |> offset(^offset) + |> preload([_m, c, s], conversation: c, sender: s) + |> Repo.all() + |> Messaging.ChatMessage.decrypt_messages() + |> Enum.map(&Map.put(&1, :protocol_kind, protocol_kind(&1))) + + stats = %{ + local: Enum.count(messages, &(&1.protocol_kind == "local")), + federated: Enum.count(messages, &(&1.protocol_kind == "federated")), + mirrored: Enum.count(messages, &(&1.protocol_kind == "mirrored")) + } + + total_pages = ceil(total_count / per_page) + page_range = pagination_range(page, total_pages) + + log_admin_chat_messages_index( + conn, + messages, + page, + search_query, + conversation_type_filter, + protocol_filter + ) + + render(conn, :chat_messages, + messages: messages, + search_query: search_query, + conversation_type_filter: conversation_type_filter, + protocol_filter: protocol_filter, + page_results_count: length(messages), + stats: stats, + current_page: page, + total_pages: total_pages, + total_count: total_count, + page_range: page_range + ) + end + + def view(conn, %{"id" => message_id}) do + case SafeConvert.parse_id(message_id) do + {:ok, id} -> + case Messaging.get_chat_message(id) do + nil -> + not_found(conn) + + %{deleted_at: deleted_at} when not is_nil(deleted_at) -> + not_found(conn) + + message -> + message = + message + |> Repo.preload(:conversation) + |> Messaging.ChatMessage.decrypt_content() + |> Map.put(:protocol_kind, protocol_kind(message)) + + log_admin_chat_message_view(conn, message, "html", "admin_arblarg_messages") + + render(conn, :view_chat_message, message: message) + end + + {:error, _} -> + not_found(conn) + end + end + + def view_raw(conn, %{"id" => message_id}) do + case SafeConvert.parse_id(message_id) do + {:ok, id} -> + case Messaging.get_chat_message(id) do + nil -> + conn + |> put_resp_content_type("text/plain") + |> send_resp(404, "Chat message not found") + + %{deleted_at: deleted_at} when not is_nil(deleted_at) -> + conn + |> put_resp_content_type("text/plain") + |> send_resp(404, "Chat message not found") + + message -> + message = + message + |> Repo.preload(:conversation) + |> Messaging.ChatMessage.decrypt_content() + |> Map.put(:protocol_kind, protocol_kind(message)) + + log_admin_chat_message_view(conn, message, "raw", "admin_arblarg_messages") + + conn + |> put_resp_content_type("text/plain") + |> send_resp(200, get_raw_chat_message_content(message)) + end + + {:error, _} -> + conn + |> put_resp_content_type("text/plain") + |> send_resp(404, "Chat message not found") + end + end + + defp not_found(conn) do + conn + |> put_flash(:error, "Chat message not found") + |> redirect(to: ~p"/pripyat/arblarg/messages") + end + + defp base_chat_messages_query do + from(m in Messaging.ChatMessage, + left_join: c in Messaging.ChatConversation, + on: m.conversation_id == c.id, + left_join: s in Elektrine.Accounts.User, + on: m.sender_id == s.id, + where: is_nil(m.deleted_at) + ) + end + + defp maybe_search(query, ""), do: query + + defp maybe_search(query, search_query) do + search_term = "%#{search_query}%" + + from([m, c, s] in query, + where: + ilike(fragment("COALESCE(?, '')", m.content), ^search_term) or + ilike(fragment("COALESCE(?, '')", s.username), ^search_term) or + ilike(fragment("COALESCE(?, '')", s.handle), ^search_term) or + ilike(fragment("COALESCE(?, '')", c.name), ^search_term) or + ilike(fragment("COALESCE(?, '')", m.federated_source), ^search_term) or + ilike(fragment("COALESCE(?, '')", m.origin_domain), ^search_term) + ) + end + + defp maybe_filter_conversation_type(query, "dm"), do: where(query, [_m, c, _s], c.type == "dm") + + defp maybe_filter_conversation_type(query, "group"), + do: where(query, [_m, c, _s], c.type == "group") + + defp maybe_filter_conversation_type(query, "channel"), + do: where(query, [_m, c, _s], c.type == "channel") + + defp maybe_filter_conversation_type(query, "all"), do: query + defp maybe_filter_conversation_type(query, _), do: query + + defp maybe_filter_protocol(query, "local"), + do: + where( + query, + [m, _c, _s], + is_nil(m.federated_source) and is_nil(m.origin_domain) and m.is_federated_mirror == false + ) + + defp maybe_filter_protocol(query, "mirrored"), + do: where(query, [m, _c, _s], m.is_federated_mirror == true) + + defp maybe_filter_protocol(query, "federated"), + do: + where( + query, + [m, _c, _s], + not is_nil(m.federated_source) or not is_nil(m.origin_domain) or + m.is_federated_mirror == true + ) + + defp maybe_filter_protocol(query, "all"), do: query + defp maybe_filter_protocol(query, _), do: query + + defp parse_conversation_type_filter("dm"), do: "dm" + defp parse_conversation_type_filter("group"), do: "group" + defp parse_conversation_type_filter("channel"), do: "channel" + defp parse_conversation_type_filter(_), do: "all" + + defp parse_protocol_filter("local"), do: "local" + defp parse_protocol_filter("federated"), do: "federated" + defp parse_protocol_filter("mirrored"), do: "mirrored" + defp parse_protocol_filter(_), do: "all" + + defp protocol_kind(message) do + cond do + message.is_federated_mirror -> "mirrored" + present?(message.federated_source) or present?(message.origin_domain) -> "federated" + true -> "local" + end + end + + defp present?(value), do: Elektrine.Strings.present?(value) + + defp get_raw_chat_message_content(message) do + conversation = Map.get(message, :conversation) + sender = Map.get(message, :sender) + + media_urls = + message.media_urls + |> List.wrap() + |> Enum.join("\n") + |> case do + "" -> "(No media URLs)" + value -> value + end + + media_metadata = + case message.media_metadata do + value when is_map(value) and map_size(value) > 0 -> Jason.encode!(value, pretty: true) + _ -> "(No media metadata)" + end + + """ + =============================================================================== + ARBLARG CHAT MESSAGE + =============================================================================== + + MESSAGE FIELDS: + --------------- + ID: #{message.id} + Message Type: #{message.message_type || "text"} + Protocol Kind: #{message.protocol_kind} + Conversation ID: #{message.conversation_id || "N/A"} + Sender ID: #{message.sender_id || "N/A"} + Reply To ID: #{message.reply_to_id || "(None)"} + Federated Source: #{message.federated_source || "(None)"} + Origin Domain: #{message.origin_domain || "(None)"} + Mirrored: #{message.is_federated_mirror || false} + Edited At: #{message.edited_at || "(Never)"} + Inserted At: #{message.inserted_at} + Updated At: #{message.updated_at} + + CONVERSATION: + ------------- + #{if conversation do + """ + Name: #{conversation.name || "(No name)"} + Type: #{conversation.type || "(Unknown)"} + Public: #{conversation.is_public || false} + Hash: #{conversation.hash || "(No hash)"} + """ + else + "Conversation not loaded" + end} + + SENDER: + ------- + #{if sender do + """ + Username: #{sender.username} + Handle: #{sender.handle || "(No handle)"} + Is Admin: #{sender.is_admin || false} + """ + else + "Sender not loaded" + end} + + CONTENT: + -------- + #{message.content || "(No text content)"} + + MEDIA URLS: + ----------- + #{media_urls} + + MEDIA METADATA: + --------------- + #{media_metadata} + + RAW STRUCT: + ----------- + #{inspect(message, pretty: true, limit: :infinity)} + + =============================================================================== + END OF ARBLARG CHAT MESSAGE + =============================================================================== + """ + end + + defp log_admin_chat_messages_index( + conn, + messages, + page, + search_query, + conversation_type_filter, + protocol_filter + ) do + case conn.assigns[:current_user] do + %{id: admin_id, is_admin: true, username: admin_username} -> + Elektrine.AuditLog.log( + admin_id, + "view_chat_messages", + "chat_message_list", + details: %{ + route_context: "admin_arblarg_messages", + page: page, + search_query: search_query, + conversation_type_filter: conversation_type_filter, + protocol_filter: protocol_filter, + result_count: length(messages), + message_ids: Enum.map(messages, & &1.id), + viewer_username: admin_username + }, + ip_address: get_remote_ip(conn), + user_agent: get_req_header(conn, "user-agent") |> List.first() + ) + + _ -> + :ok + end + end + + defp log_admin_chat_message_view(conn, message, view_format, route_context) do + case conn.assigns[:current_user] do + %{id: admin_id, is_admin: true, username: admin_username} -> + Elektrine.AuditLog.log( + admin_id, + "view_chat_message", + "chat_message", + target_user_id: message.sender_id, + resource_id: message.id, + details: %{ + view_format: view_format, + route_context: route_context, + message_type: message.message_type, + conversation_id: message.conversation_id, + conversation_type: message.conversation && message.conversation.type, + protocol_kind: message.protocol_kind, + federated_source: message.federated_source, + origin_domain: message.origin_domain, + mirrored: message.is_federated_mirror || false, + viewer_username: admin_username + }, + ip_address: get_remote_ip(conn), + user_agent: get_req_header(conn, "user-agent") |> List.first() + ) + + _ -> + :ok + end + end + + defp get_remote_ip(conn) do + ElektrineWeb.ClientIP.client_ip(conn) + end + + defp pagination_range(_current_page, total_pages) when total_pages <= 7 do + 1..total_pages//1 |> Enum.to_list() + end + + defp pagination_range(current_page, total_pages) do + cond do + current_page <= 4 -> + 1..7//1 |> Enum.to_list() + + current_page >= total_pages - 3 -> + (total_pages - 6)..total_pages//1 |> Enum.to_list() + + true -> + (current_page - 3)..(current_page + 3)//1 |> Enum.to_list() + end + end +end diff --git a/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html.ex b/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html.ex new file mode 100644 index 0000000..c9c3cf0 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html.ex @@ -0,0 +1,19 @@ +defmodule ArblargWeb.Admin.ChatMessagesHTML do + @moduledoc """ + View helpers and templates for admin chat message views. + """ + + use ArblargWeb, :html + + embed_templates "chat_messages_html/*" + + def truncate_content(nil), do: "" + + def truncate_content(content) when is_binary(content) do + if String.length(content) > 150 do + String.slice(content, 0, 150) <> "..." + else + content + end + end +end diff --git a/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html/chat_messages.html.heex b/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html/chat_messages.html.heex new file mode 100644 index 0000000..5494e1a --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html/chat_messages.html.heex @@ -0,0 +1,389 @@ +
+ <% search_active = @search_query != "" %> + <% conversation_type_filter = assigns[:conversation_type_filter] || "all" %> + <% protocol_filter = assigns[:protocol_filter] || "all" %> + <% page_results_count = assigns[:page_results_count] || length(@messages) %> + <% stats = assigns[:stats] || %{local: 0, federated: 0, mirrored: 0} %> + <% base_params = + %{ + "conversation_type" => conversation_type_filter, + "protocol" => protocol_filter + } + |> then(fn params -> + if search_active, do: Map.put(params, "search", @search_query), else: params + end) %> + <% base_params_without_search = Map.delete(base_params, "search") %> + + <.card class="panel-card" body_class="p-0"> + <:body> +
+
+
+ Arblarg +
+ +

+ Chat Message Console +

+ +

+ Admin view for chat traffic across DMs, groups, and channels. Powered by Arblarg. +

+
+ +
+ Page {@current_page} + of {@total_pages} +
+
+ + + +
+
+
+ On This Page +
+
{page_results_count}
+
+ +
+
+ Local +
+
{stats.local}
+
+ +
+
+ Federated +
+
{stats.federated}
+
+ +
+
+ Mirrored +
+
{stats.mirrored}
+
+ +
+
+ Total Matches +
+
{@total_count}
+
+
+ + <.card class="panel-card" body_class="p-0"> + <:body> +
+
+ Filters +
+ +

Search Messages

+
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ <.button type="submit" variant="primary"> + <.icon name="hero-magnifying-glass" class="h-4 w-4" /> Search + + <%= if search_active or conversation_type_filter != "all" or protocol_filter != "all" do %> + <.button + href={~p"/pripyat/arblarg/messages?#{base_params_without_search}"} + variant="ghost" + data-search-clear="true" + > + Reset Search + + <% end %> +
+
+ + <%= if search_active do %> +
+ Showing results for {@search_query} +
+ <% end %> +
+ + + + <%= if @messages == [] do %> +
+ <.icon + name="hero-chat-bubble-left-right" + class="mx-auto mb-3 h-10 w-10 text-base-content/30" + /> +

No chat messages found

+

+ Adjust your filters or search query. +

+
+ <% else %> + <.card class="panel-card" body_class="p-0"> + <:body> +
+
+
+
+ Results +
+ +

Messages

+
+ +
+ {page_results_count} on this page +
+
+
+ +
+ + +
+ <%= for message <- @messages do %> +
+
+
+

+ {truncate_content(message.content || "(No text content)")} +

+

+ {message.conversation && (message.conversation.name || "(No name)")} • {message.conversation && + String.upcase(message.conversation.type || "unknown")} +

+

+ @{message.sender && message.sender.handle} +

+
+
+ <.button + href={~p"/pripyat/arblarg/messages/#{message.id}/view"} + variant="ghost" + size="xs" + title="View message" + > + <.icon name="hero-eye" class="h-3 w-3" /> + + <.button + href={~p"/pripyat/arblarg/messages/#{message.id}/raw"} + variant="ghost" + size="xs" + target="_blank" + title="View raw payload" + > + <.icon name="hero-code-bracket" class="h-3 w-3" /> + +
+
+
+ ID: {message.id} + + <.local_time + datetime={message.inserted_at} + format="datetime" + timezone={@timezone} + time_format={@time_format} + /> + +
+
+ <% end %> +
+
+ + + <% end %> + + <%= if @total_pages > 1 do %> +
+
+ <%= if @current_page > 1 do %> + <.button + href={ + ~p"/pripyat/arblarg/messages?#{Map.put(base_params, "page", @current_page - 1)}" + } + variant="default" + class="join-item" + > + « + + <% end %> + + <%= for page_num <- @page_range do %> + <.button + href={~p"/pripyat/arblarg/messages?#{Map.put(base_params, "page", page_num)}"} + variant={if page_num == @current_page, do: "primary", else: "ghost"} + class="join-item" + > + {page_num} + + <% end %> + + <%= if @current_page < @total_pages do %> + <.button + href={ + ~p"/pripyat/arblarg/messages?#{Map.put(base_params, "page", @current_page + 1)}" + } + variant="default" + class="join-item" + > + » + + <% end %> +
+ +
+ {@total_count} total message{if @total_count != 1, do: "s"} +
+
+ <% end %> +
diff --git a/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html/view_chat_message.html.heex b/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html/view_chat_message.html.heex new file mode 100644 index 0000000..359d434 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html/view_chat_message.html.heex @@ -0,0 +1,228 @@ +
+ <.card class="panel-card" body_class="p-0"> + <:body> +
+
+
+ Arblarg +
+ +

Chat Message

+ +

+ Inspect a single message, its delivery metadata, and raw payload for moderation or debugging work. +

+
+ +
+ <.button + href={~p"/pripyat/arblarg/messages/#{@message.id}/raw"} + variant="ghost" + target="_blank" + > + <.icon name="hero-code-bracket" class="h-4 w-4" /> View Raw + + <.button href={~p"/pripyat/arblarg/messages"} variant="ghost"> + <.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Messages + +
+
+ + + + <.card class="panel-card" body_class="p-0"> + <:body> +
+
+ Delivery +
+ +

Message Info

+
+ +
+
+
+
+ Message ID +
+
#{@message.id}
+
+ +
+
+ Type +
+
+ {@message.message_type} +
+
+ +
+
+ Sender +
+
+ <.link + href={~p"/pripyat/users/#{@message.sender_id}/edit"} + class="link link-primary" + > + {@message.sender && @message.sender.username} (@{@message.sender && + @message.sender.handle}) + +
+
+ +
+
+ Conversation +
+
+ {@message.conversation && (@message.conversation.name || "(No name)")} +
+
+ +
+
+ Conversation Type +
+
+ {@message.conversation && String.upcase(@message.conversation.type || "unknown")} +
+
+
+ +
+
+
+ Created +
+
+ <.local_time + datetime={@message.inserted_at} + format="datetime" + timezone={@timezone} + time_format={@time_format} + /> +
+
+ +
+
+ Edited At +
+
{@message.edited_at || "(Never)"}
+
+ +
+
+ Protocol +
+
+ "badge badge-warning badge-sm" + "federated" -> "badge badge-success badge-sm" + _ -> "badge badge-info badge-sm" + end + }> + {String.capitalize(@message.protocol_kind)} + +
+
+ +
+
+ Origin Domain +
+
{@message.origin_domain || "(Local)"}
+
+ +
+
+ Federated Source +
+
{@message.federated_source || "(None)"}
+
+ +
+
+ Mirrored +
+
+ {if @message.is_federated_mirror, do: "Yes", else: "No"} +
+
+
+
+ + + + <.card class="panel-card" body_class="p-0"> + <:body> +
+
+ Body +
+ +

Content

+
+ +
+ <%= if @message.content && String.trim(@message.content) != "" do %> +
+ {@message.content} +
+ <% else %> +
+ No text content on this message. +
+ <% end %> +
+ + + + <.card class="panel-card" body_class="p-0"> + <:body> +
+
+ Attachments +
+ +

Media & Metadata

+
+ +
+
+ Media URLs +
+ + <%= if @message.media_urls && @message.media_urls != [] do %> +
+ <%= for media_url <- @message.media_urls do %> + + <% end %> +
+ <% else %> +

No media URLs.

+ <% end %> +
+ +
+
+ Media Metadata +
+
{if is_map(@message.media_metadata), do: Jason.encode!(@message.media_metadata, pretty: true), else: inspect(@message.media_metadata, pretty: true)}
+
+ + +
diff --git a/apps/arblarg/lib/arblarg_web/controllers/api/conversation_controller.ex b/apps/arblarg/lib/arblarg_web/controllers/api/conversation_controller.ex new file mode 100644 index 0000000..05bf6eb --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/controllers/api/conversation_controller.ex @@ -0,0 +1,907 @@ +defmodule ArblargWeb.API.ConversationController do + @moduledoc """ + API controller for chat conversations. + """ + use ArblargWeb, :controller + + alias Elektrine.Messaging + alias Elektrine.Uploads + + action_fallback ElektrineWeb.FallbackController + + @doc """ + GET /api/conversations + Lists all conversations for the current user. + """ + def index(conn, params) do + user = conn.assigns[:current_user] + limit = parse_int(params["limit"], 50) + + conversations = Messaging.list_chat_conversations(user.id, limit: limit) + + conn + |> put_status(:ok) + |> json(%{ + conversations: Enum.map(conversations, &format_conversation(&1, user.id)) + }) + end + + @doc """ + GET /api/conversations/:id + Gets a specific conversation with recent messages. + """ + def show(conn, %{"id" => id}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn conversation_id -> + case Messaging.get_conversation!(conversation_id, user.id) do + {:ok, conversation} -> + {conv_data, messages} = format_conversation_with_messages(conversation, user.id) + + conn + |> put_status(:ok) + |> json(%{conversation: conv_data, messages: messages}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Conversation not found"}) + + {:error, :not_member} -> + conn + |> put_status(:forbidden) + |> json(%{error: "You are not a member of this conversation"}) + end + end) + end + + @doc """ + POST /api/conversations + Creates a new conversation (DM, group, or channel). + + Params: + - type: "dm" | "group" | "channel" (required) + - user_id: Target user ID (required for DM) + - name: Conversation name (required for group/channel) + - description: Optional description + - member_ids: List of user IDs to add (for group) + - is_public: Whether group/channel is public (default false) + """ + def create(conn, %{"type" => "dm", "user_id" => target_user_id}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, target_user_id, fn parsed_target_user_id -> + case Messaging.create_dm_conversation(user.id, parsed_target_user_id) do + {:ok, conversation} -> + conn + |> put_status(:created) + |> json(%{ + message: "Conversation created", + conversation: format_conversation(conversation, user.id) + }) + + {:error, :self_dm} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Cannot create conversation with yourself"}) + + {:error, :user_not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "User not found"}) + + {:error, :privacy_blocked} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Cannot message this user due to privacy settings"}) + + {:error, :rate_limited} -> + conn + |> put_status(:too_many_requests) + |> json(%{error: "Too many conversations created. Please try again later."}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to create conversation: #{inspect(reason)}"}) + end + end) + end + + def create(conn, %{"type" => "group"} = params) do + user = conn.assigns[:current_user] + + attrs = %{ + name: params["name"], + description: params["description"], + avatar_url: params["avatar_url"], + is_public: params["is_public"] == true + } + + member_ids = params["member_ids"] || [] + + case Messaging.create_chat_group_conversation(user.id, attrs, member_ids) do + {:ok, conversation} -> + conn + |> put_status(:created) + |> json(%{ + message: "Group created", + conversation: format_conversation(conversation, user.id) + }) + + {:error, :limit_reached} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Maximum group limit reached"}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Validation failed", errors: format_errors(changeset)}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to create group: #{inspect(reason)}"}) + end + end + + def create(conn, %{"type" => "channel"} = params) do + user = conn.assigns[:current_user] + + attrs = %{ + name: params["name"], + description: params["description"], + avatar_url: params["avatar_url"], + # Channels are public by default + is_public: params["is_public"] != false + } + + case Messaging.create_channel(user.id, attrs) do + {:ok, conversation} -> + conn + |> put_status(:created) + |> json(%{ + message: "Channel created", + conversation: format_conversation(conversation, user.id) + }) + + {:error, :limit_reached} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Maximum channel limit reached"}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Validation failed", errors: format_errors(changeset)}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to create channel: #{inspect(reason)}"}) + end + end + + def create(conn, _params) do + conn + |> put_status(:bad_request) + |> json(%{error: "Missing required parameter: type (dm, group, or channel)"}) + end + + @doc """ + PUT /api/conversations/:id + Updates a conversation (name, description, etc.). + """ + def update(conn, %{"id" => id} = params) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn conversation_id -> + # Check if user has permission to update + case Messaging.get_conversation_member(conversation_id, user.id) do + nil -> + conn + |> put_status(:not_found) + |> json(%{error: "Conversation not found"}) + + member when member.role not in ["owner", "admin"] -> + conn + |> put_status(:forbidden) + |> json(%{error: "Only owners and admins can update conversations"}) + + _member -> + # Get the conversation and update it + case Messaging.get_conversation!(conversation_id, user.id) do + {:ok, conversation} -> + attrs = + %{} + |> maybe_put(:name, params["name"]) + |> maybe_put(:description, params["description"]) + |> maybe_put(:avatar_url, params["avatar_url"]) + + case Messaging.update_conversation(conversation, attrs) do + {:ok, updated} -> + conn + |> put_status(:ok) + |> json(%{ + message: "Conversation updated", + conversation: format_conversation(updated, user.id) + }) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Validation failed", errors: format_errors(changeset)}) + end + + {:error, _} -> + conn + |> put_status(:not_found) + |> json(%{error: "Conversation not found"}) + end + end + end) + end + + @doc """ + DELETE /api/conversations/:id + Deletes a conversation (owner only) or leaves it. + """ + def delete(conn, %{"id" => id}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn conversation_id -> + case Messaging.get_conversation_member(conversation_id, user.id) do + nil -> + conn + |> put_status(:not_found) + |> json(%{error: "Conversation not found"}) + + member -> + if member.role == "owner" do + # Owner deletes the entire conversation + case Messaging.delete_conversation(conversation_id) do + {:ok, _} -> + conn + |> put_status(:ok) + |> json(%{message: "Conversation deleted"}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to delete: #{inspect(reason)}"}) + end + else + # Non-owner leaves the conversation + case Messaging.remove_member_from_conversation(conversation_id, user.id) do + {:ok, _} -> + conn + |> put_status(:ok) + |> json(%{message: "Left conversation"}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to leave: #{inspect(reason)}"}) + end + end + end + end) + end + + @doc """ + POST /api/conversations/:id/join + Joins a public conversation. + """ + def join(conn, %{"conversation_id" => id}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn conversation_id -> + case Messaging.join_conversation(conversation_id, user.id) do + {:ok, :pending} -> + conn + |> put_status(:accepted) + |> json(%{message: "Join request sent"}) + + {:ok, _member} -> + {:ok, conversation} = Messaging.get_conversation!(conversation_id, user.id) + + conn + |> put_status(:ok) + |> json(%{ + message: "Joined conversation", + conversation: format_conversation(conversation, user.id) + }) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Conversation not found"}) + + {:error, :not_public} -> + conn + |> put_status(:forbidden) + |> json(%{error: "This conversation is not public"}) + + {:error, :not_public_channel} -> + conn + |> put_status(:forbidden) + |> json(%{error: "This conversation is not public"}) + + {:error, :must_join_server} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Join the server first before joining this channel"}) + + {:error, :already_member} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Already a member of this conversation"}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to join: #{inspect(reason)}"}) + end + end) + end + + @doc """ + POST /api/conversations/:id/leave + Leaves a conversation. + """ + def leave(conn, %{"conversation_id" => id}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn conversation_id -> + case Messaging.remove_member_from_conversation(conversation_id, user.id) do + {:ok, _} -> + conn + |> put_status(:ok) + |> json(%{message: "Left conversation"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Conversation not found or not a member"}) + + {:error, :owner_cannot_leave} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Owners must transfer ownership or delete the conversation"}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to leave: #{inspect(reason)}"}) + end + end) + end + + @doc """ + POST /api/conversations/:id/read + Marks conversation as read. + """ + def mark_read(conn, %{"conversation_id" => id}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn conversation_id -> + case Messaging.mark_as_read(conversation_id, user.id) do + {:ok, _} -> + conn + |> put_status(:ok) + |> json(%{message: "Marked as read"}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to mark as read: #{inspect(reason)}"}) + end + end) + end + + @doc """ + GET /api/conversations/:id/members + Lists members of a conversation. + """ + def members(conn, %{"conversation_id" => id}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn conversation_id -> + # Verify user is a member + case Messaging.get_conversation_member(conversation_id, user.id) do + nil -> + conn + |> put_status(:forbidden) + |> json(%{error: "Not a member of this conversation"}) + + _member -> + members = Messaging.get_conversation_members(conversation_id) + + conn + |> put_status(:ok) + |> json(%{members: Enum.map(members, &format_member/1)}) + end + end) + end + + @doc """ + GET /api/conversations/:id/remote-join-requests + Lists pending remote join requests for a locally authoritative channel. + """ + def pending_remote_join_requests(conn, %{"conversation_id" => id}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn conversation_id -> + case manage_conversation_membership_permission(conversation_id, user.id) do + :ok -> + requests = Messaging.list_pending_remote_join_requests(conversation_id) + + conn + |> put_status(:ok) + |> json(%{requests: Enum.map(requests, &format_remote_join_request/1)}) + + {:error, status, error} -> + conn + |> put_status(status) + |> json(%{error: error}) + end + end) + end + + @doc """ + POST /api/conversations/:id/remote-join-requests/approve + Approves a pending remote join request. + """ + def approve_remote_join_request( + conn, + %{"conversation_id" => id, "remote_actor_id" => remote_actor_id} + ) do + review_remote_join_request(conn, id, remote_actor_id, :approve) + end + + @doc """ + POST /api/conversations/:id/remote-join-requests/decline + Declines a pending remote join request. + """ + def decline_remote_join_request( + conn, + %{"conversation_id" => id, "remote_actor_id" => remote_actor_id} + ) do + review_remote_join_request(conn, id, remote_actor_id, :decline) + end + + @doc """ + POST /api/conversations/:id/members + Adds a member to a conversation. + """ + def add_member(conn, %{"conversation_id" => id, "user_id" => target_user_id}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn conversation_id -> + with_valid_id(conn, target_user_id, fn parsed_target_user_id -> + # Check if user has permission to add members + case Messaging.get_conversation_member(conversation_id, user.id) do + nil -> + conn + |> put_status(:not_found) + |> json(%{error: "Conversation not found"}) + + member when member.role not in ["owner", "admin", "moderator"] -> + conn + |> put_status(:forbidden) + |> json(%{error: "You don't have permission to add members"}) + + _member -> + role = "member" + + case Messaging.add_member_to_conversation( + conversation_id, + parsed_target_user_id, + role, + user.id + ) do + {:ok, new_member} -> + conn + |> put_status(:created) + |> json(%{ + message: "Member added", + member: format_member(new_member) + }) + + {:error, :user_not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "User not found"}) + + {:error, :already_member} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "User is already a member"}) + + {:error, :privacy_blocked} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Cannot add user due to privacy settings"}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to add member: #{inspect(reason)}"}) + end + end + end) + end) + end + + def add_member(conn, _params) do + conn + |> put_status(:bad_request) + |> json(%{error: "Missing required parameter: user_id"}) + end + + @doc """ + DELETE /api/conversations/:id/members/:user_id + Removes a member from a conversation. + """ + def remove_member(conn, %{"conversation_id" => id, "user_id" => target_user_id}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn conversation_id -> + with_valid_id(conn, target_user_id, fn target_id -> + # Check if user has permission to remove members + case Messaging.get_conversation_member(conversation_id, user.id) do + nil -> + conn + |> put_status(:not_found) + |> json(%{error: "Conversation not found"}) + + member + when member.role not in ["owner", "admin", "moderator"] and user.id != target_id -> + conn + |> put_status(:forbidden) + |> json(%{error: "You don't have permission to remove members"}) + + _member -> + case Messaging.remove_member_from_conversation(conversation_id, target_id) do + {:ok, _} -> + conn + |> put_status(:ok) + |> json(%{message: "Member removed"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Member not found"}) + + {:error, :owner_cannot_leave} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Cannot remove the owner"}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to remove member: #{inspect(reason)}"}) + end + end + end) + end) + end + + # Private helpers + + defp review_remote_join_request(conn, conversation_id, remote_actor_id, decision) + when decision in [:approve, :decline] do + user = conn.assigns[:current_user] + + with {:ok, parsed_conversation_id} <- parse_id(conversation_id), + {:ok, parsed_remote_actor_id} <- parse_remote_actor_id(remote_actor_id), + :ok <- manage_conversation_membership_permission(parsed_conversation_id, user.id) do + review_result = + case decision do + :approve -> + Messaging.approve_remote_join_request( + parsed_conversation_id, + parsed_remote_actor_id, + user.id + ) + + :decline -> + Messaging.decline_remote_join_request( + parsed_conversation_id, + parsed_remote_actor_id, + user.id + ) + end + + case review_result do + {:ok, request} -> + message = + if decision == :approve do + "Remote join request approved" + else + "Remote join request declined" + end + + conn + |> put_status(:ok) + |> json(%{message: message, request: format_remote_join_request(request)}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Remote join request not found"}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to review remote join request: #{inspect(reason)}"}) + end + else + :error -> + invalid_id_response(conn) + + {:error, :invalid_remote_actor_id} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Missing or invalid remote_actor_id"}) + + {:error, status, error} -> + conn + |> put_status(status) + |> json(%{error: error}) + end + end + + defp parse_remote_actor_id(remote_actor_id) when is_integer(remote_actor_id), + do: + if(remote_actor_id > 0, + do: {:ok, remote_actor_id}, + else: {:error, :invalid_remote_actor_id} + ) + + defp parse_remote_actor_id(remote_actor_id) when is_binary(remote_actor_id) do + case Integer.parse(remote_actor_id) do + {parsed_remote_actor_id, ""} when parsed_remote_actor_id > 0 -> + {:ok, parsed_remote_actor_id} + + _ -> + {:error, :invalid_remote_actor_id} + end + end + + defp parse_remote_actor_id(_remote_actor_id), do: {:error, :invalid_remote_actor_id} + + defp manage_conversation_membership_permission(conversation_id, user_id) do + case Messaging.get_conversation_member(conversation_id, user_id) do + nil -> + {:error, :not_found, "Conversation not found"} + + member when member.role not in ["owner", "admin", "moderator"] -> + {:error, :forbidden, "You don't have permission to manage members"} + + _member -> + :ok + end + end + + defp format_conversation(conversation, current_user_id) do + # Get unread count if not already loaded + unread_count = + case Map.get(conversation, :unread_count) do + nil -> Messaging.get_conversation_unread_count(conversation.id, current_user_id) + count -> count + end + + %{ + id: conversation.id, + name: conversation.name, + description: conversation.description, + type: conversation.type, + server_id: Map.get(conversation, :server_id), + avatar_url: conversation.avatar_url, + is_public: conversation.is_public, + member_count: conversation.member_count, + hash: conversation.hash, + channel_topic: Map.get(conversation, :channel_topic), + channel_position: Map.get(conversation, :channel_position, 0), + creator_id: conversation.creator_id, + last_message_at: conversation.last_message_at, + unread_count: unread_count, + last_message: format_last_message(conversation), + inserted_at: conversation.inserted_at, + updated_at: conversation.updated_at + } + end + + defp format_conversation_with_messages(conversation, current_user_id) do + base = format_conversation(conversation, current_user_id) + + members = + case Map.get(conversation, :members) do + nil -> [] + members -> Enum.map(members, &format_member/1) + end + + # Fetch messages from messages table + messages = + case Messaging.get_messages(conversation.id, current_user_id, limit: 50) do + {:ok, msgs} -> Enum.map(msgs, &format_message/1) + {:error, _} -> [] + end + + conv_data = Map.put(base, :members, members) + {conv_data, messages} + end + + defp format_last_message(%{last_message: nil}), do: nil + defp format_last_message(%{last_message: %Ecto.Association.NotLoaded{}}), do: nil + defp format_last_message(%{last_message: message}), do: format_message(message) + defp format_last_message(_), do: nil + + defp format_message(message) do + %{ + id: message.id, + content: message.content, + message_type: message.message_type, + media_urls: attachment_urls_for_message(message), + sender_id: message.sender_id, + sender: format_user(message.sender), + reply_to_id: message.reply_to_id, + like_count: message.like_count || 0, + reply_count: message.reply_count || 0, + edited_at: message.edited_at, + deleted_at: message.deleted_at, + created_at: message.inserted_at + } + end + + defp attachment_urls_for_message(message) do + Enum.map(message.media_urls || [], &Uploads.attachment_url(&1, %{type: "dm"})) + end + + defp format_remote_join_request(request) when is_map(request) do + %{ + remote_actor_id: request[:remote_actor_id] || request["remote_actor_id"], + actor_uri: request[:actor_uri] || request["actor_uri"], + origin_domain: request[:origin_domain] || request["origin_domain"], + role: request[:role] || request["role"], + state: request[:state] || request["state"], + handle: request[:handle] || request["handle"], + display_name: request[:display_name] || request["display_name"], + display_label: request[:display_label] || request["display_label"], + avatar_url: request[:avatar_url] || request["avatar_url"], + requested_at: maybe_iso8601(request[:requested_at] || request["requested_at"]), + updated_at: maybe_iso8601(request[:updated_at] || request["updated_at"]), + metadata: request[:metadata] || request["metadata"] || %{} + } + end + + defp format_remote_join_request(_request), do: %{} + + defp maybe_iso8601(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime) + defp maybe_iso8601(value), do: value + + @doc """ + POST /api/conversations/:conversation_id/upload + Uploads a media file for a conversation. + """ + def upload_media(conn, %{"conversation_id" => conversation_id, "file" => upload}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, conversation_id, fn conv_id -> + # Verify user is a member + case Messaging.get_conversation_member(conv_id, user.id) do + nil -> + conn + |> put_status(:forbidden) + |> json(%{error: "Not a member of this conversation"}) + + _member -> + case Elektrine.Uploads.upload_chat_attachment(upload, user.id) do + {:ok, metadata} -> + url = Elektrine.Uploads.attachment_url(metadata.key, %{type: "dm"}) + + conn + |> put_status(:created) + |> json(%{ + url: url, + key: metadata.key, + filename: metadata.filename, + content_type: metadata.content_type, + size: metadata.size + }) + + {:error, {:file_too_large, msg}} -> + conn + |> put_status(:request_entity_too_large) + |> json(%{error: msg}) + + {:error, {:invalid_file_type, msg}} -> + conn + |> put_status(:unsupported_media_type) + |> json(%{error: msg}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Upload failed: #{inspect(reason)}"}) + end + end + end) + end + + def upload_media(conn, %{"conversation_id" => _conversation_id}) do + conn + |> put_status(:bad_request) + |> json(%{error: "Missing file parameter"}) + end + + defp format_member(member) do + %{ + id: member.id, + user_id: member.user_id, + role: member.role, + joined_at: member.joined_at, + last_read_at: member.last_read_at, + user: format_user(member.user) + } + end + + defp format_user(nil), do: nil + defp format_user(%Ecto.Association.NotLoaded{}), do: nil + + defp format_user(user) do + %{ + id: user.id, + username: user.username, + avatar: user.avatar + } + end + + defp format_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp with_valid_id(conn, value, fun) when is_function(fun, 1) do + case parse_id(value) do + {:ok, id} -> fun.(id) + :error -> invalid_id_response(conn) + end + end + + defp invalid_id_response(conn) do + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid id"}) + end + + defp parse_id(value) when is_integer(value) and value > 0, do: {:ok, value} + + defp parse_id(value) when is_binary(value) do + case Integer.parse(value) do + {int, ""} when int > 0 -> {:ok, int} + _ -> :error + end + end + + defp parse_id(_value), do: :error + + defp parse_int(nil, default), do: default + defp parse_int(value, _default) when is_integer(value), do: value + + defp parse_int(value, default) when is_binary(value) do + case Integer.parse(value) do + {int, ""} -> int + _ -> default + end + end +end diff --git a/apps/arblarg/lib/arblarg_web/controllers/api/ext_chat_controller.ex b/apps/arblarg/lib/arblarg_web/controllers/api/ext_chat_controller.ex new file mode 100644 index 0000000..14453b6 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/controllers/api/ext_chat_controller.ex @@ -0,0 +1,423 @@ +defmodule ArblargWeb.API.ExtChatController do + @moduledoc """ + External API controller for chat access. + """ + + use ArblargWeb, :controller + + import Ecto.Query, warn: false + + alias Elektrine.Accounts + alias Elektrine.Messaging + alias Elektrine.Messaging.{ChatConversation, ChatConversationMember} + alias Elektrine.Messaging.ChatMessages + alias Elektrine.Repo + alias Elektrine.Social.Conversation + alias Elektrine.Uploads + alias ElektrineWeb.API.Response + + @chat_types ~w(dm group channel) + @default_limit 20 + @max_limit 100 + @default_message_limit 50 + + @doc """ + GET /api/ext/v1/chat/conversations + """ + def index(conn, params) do + user = conn.assigns.current_user + limit = parse_positive_int(params["limit"], @default_limit) |> min(@max_limit) + + conversations = list_chat_conversations(user.id, limit) + + Response.ok( + conn, + %{conversations: Enum.map(conversations, &format_conversation(&1, user.id))}, + %{pagination: %{limit: limit, total_count: length(conversations)}} + ) + end + + @doc """ + GET /api/ext/v1/chat/conversations/:id + """ + def show(conn, %{"id" => id}) do + user = conn.assigns.current_user + + with {:ok, conversation_id} <- parse_id(id), + {:ok, conversation} <- fetch_chat_conversation(conversation_id, user.id) do + page = + ChatMessages.get_conversation_messages(conversation.id, user.id, + limit: @default_message_limit + ) + + Response.ok( + conn, + %{ + conversation: + format_conversation(conversation, user.id, latest_message(conversation, user.id)), + messages: Enum.map(page.messages, &format_message/1) + }, + %{pagination: pagination_meta(page, @default_message_limit)} + ) + else + {:error, :invalid_id} -> + Response.error(conn, :bad_request, "invalid_id", "Invalid conversation id") + + {:error, :not_found} -> + Response.error(conn, :not_found, "not_found", "Conversation not found") + end + end + + @doc """ + GET /api/ext/v1/chat/conversations/:id/messages + """ + def messages(conn, %{"id" => id} = params) do + user = conn.assigns.current_user + limit = parse_positive_int(params["limit"], @default_message_limit) |> min(@max_limit) + + with {:ok, conversation_id} <- parse_id(id), + {:ok, before_id} <- parse_optional_id(params["before_id"]), + {:ok, after_id} <- parse_optional_id(params["after_id"]), + {:ok, conversation} <- fetch_chat_conversation(conversation_id, user.id) do + page = + ChatMessages.get_conversation_messages(conversation.id, user.id, + limit: limit, + before_id: before_id, + after_id: after_id + ) + + Response.ok( + conn, + %{messages: Enum.map(page.messages, &format_message/1)}, + %{pagination: pagination_meta(page, limit)} + ) + else + {:error, :invalid_id} -> + Response.error(conn, :bad_request, "invalid_id", "Invalid conversation or message id") + + {:error, :not_found} -> + Response.error(conn, :not_found, "not_found", "Conversation not found") + end + end + + @doc """ + POST /api/ext/v1/chat/conversations/:id/messages + """ + def create(conn, %{"id" => id} = params) do + user = conn.assigns.current_user + source = Map.get(params, "message", params) + message_type = source["message_type"] || "text" + + with {:ok, conversation_id} <- parse_id(id), + {:ok, conversation} <- fetch_chat_conversation(conversation_id, user.id), + {:ok, reply_to_id} <- parse_optional_id(source["reply_to_id"]) do + result = + case message_type do + "text" -> + opts = if is_nil(reply_to_id), do: [], else: [reply_to_id: reply_to_id] + + Messaging.create_chat_text_message( + conversation.id, + user.id, + source["content"] || "", + opts + ) + + "image" -> + Messaging.create_chat_media_message( + conversation.id, + user.id, + normalize_media_urls(source["media_urls"]), + source["content"], + source["media_metadata"] || %{} + ) + + "file" -> + Messaging.create_chat_media_message( + conversation.id, + user.id, + normalize_media_urls(source["media_urls"]), + source["content"], + source["media_metadata"] || %{} + ) + + "voice" -> + Messaging.create_chat_voice_message( + conversation.id, + user.id, + List.first(normalize_media_urls(source["media_urls"])), + source["duration"] || 0, + source["mime_type"] || "audio/webm" + ) + + _ -> + {:error, :invalid_message_type} + end + + case result do + {:ok, message} -> + Response.created(conn, %{message: format_message(message)}) + + {:error, :empty_message} -> + Response.error( + conn, + :unprocessable_entity, + "empty_message", + "Message content cannot be empty" + ) + + {:error, :rate_limited} -> + Response.error(conn, :too_many_requests, "rate_limited", "Message rate limit exceeded") + + {:error, :timed_out} -> + Response.error( + conn, + :forbidden, + "timed_out", + "User is timed out from this conversation" + ) + + {:error, :invalid_message_type} -> + Response.error( + conn, + :bad_request, + "invalid_message_type", + "Invalid message type. Use: text, image, file, or voice" + ) + + {:error, %Ecto.Changeset{} = changeset} -> + Response.error( + conn, + :unprocessable_entity, + "validation_failed", + "Message failed validation", + errors_on(changeset) + ) + + {:error, reason} -> + Response.error( + conn, + :unprocessable_entity, + "message_send_failed", + "Failed to send message", + inspect(reason) + ) + end + else + {:error, :invalid_id} -> + Response.error(conn, :bad_request, "invalid_id", "Invalid conversation or message id") + + {:error, :not_found} -> + Response.error(conn, :not_found, "not_found", "Conversation not found") + end + end + + defp list_chat_conversations(user_id, limit) do + from(conversation in ChatConversation, + join: membership in ChatConversationMember, + on: membership.conversation_id == conversation.id and membership.user_id == ^user_id, + where: is_nil(membership.left_at) and conversation.type in ^@chat_types, + order_by: [ + desc: membership.pinned, + desc: conversation.last_message_at, + desc: conversation.updated_at + ], + limit: ^limit, + preload: [members: [user: [:profile]]] + ) + |> Repo.all() + |> Enum.reject(&blocked_dm?(&1, user_id)) + end + + defp fetch_chat_conversation(conversation_id, user_id) do + case Messaging.get_conversation_for_chat!(conversation_id, user_id) do + {:ok, %ChatConversation{type: type} = conversation} when type in @chat_types -> + if blocked_dm?(conversation, user_id) do + {:error, :not_found} + else + {:ok, conversation} + end + + {:ok, _conversation} -> + {:error, :not_found} + + {:error, :not_found} -> + {:error, :not_found} + end + end + + defp latest_message(conversation, user_id) do + conversation.id + |> ChatMessages.get_messages(user_id: user_id, limit: 1) + |> List.first() + end + + defp blocked_dm?(%ChatConversation{type: "dm", members: members}, user_id) + when is_list(members) do + case Enum.find(members, fn member -> + member.user_id != user_id and is_nil(member.left_at) + end) do + %{user_id: other_user_id} -> + Accounts.user_blocked?(user_id, other_user_id) or + Accounts.user_blocked?(other_user_id, user_id) + + _ -> + false + end + end + + defp blocked_dm?(_conversation, _user_id), do: false + + defp format_conversation(conversation, current_user_id) do + format_conversation( + conversation, + current_user_id, + latest_message(conversation, current_user_id) + ) + end + + defp format_conversation(conversation, current_user_id, latest_message) do + members = + conversation.members + |> List.wrap() + |> Enum.filter(&is_nil(&1.left_at)) + |> Enum.map(&format_member/1) + + %{ + id: conversation.id, + type: conversation.type, + name: Conversation.display_name(conversation, current_user_id), + description: conversation.description, + avatar_url: Conversation.avatar_url(conversation, current_user_id), + is_public: conversation.is_public, + member_count: length(members), + last_message_at: conversation.last_message_at, + latest_message: format_message_preview(latest_message), + members: members + } + end + + defp format_member(member) do + %{ + id: member.id, + user_id: member.user_id, + role: member.role, + joined_at: member.joined_at, + last_read_at: member.last_read_at, + user: format_user(member.user) + } + end + + defp format_message_preview(nil), do: nil + + defp format_message_preview(message) do + %{ + id: message.id, + content: message.content, + message_type: message.message_type, + sender: format_user(message.sender), + inserted_at: message.inserted_at, + edited_at: message.edited_at + } + end + + defp format_message(message) do + %{ + id: message.id, + content: message.content, + message_type: message.message_type, + media_urls: attachment_urls_for_message(message), + media_metadata: message.media_metadata || %{}, + conversation_id: message.conversation_id, + sender_id: message.sender_id, + sender: format_user(message.sender), + reply_to_id: message.reply_to_id, + reply_to: format_reply_to(message.reply_to), + edited_at: message.edited_at, + deleted_at: message.deleted_at, + inserted_at: message.inserted_at + } + end + + defp attachment_urls_for_message(message) do + Enum.map(message.media_urls || [], &Uploads.attachment_url(&1, %{type: "dm"})) + end + + defp format_reply_to(nil), do: nil + defp format_reply_to(%Ecto.Association.NotLoaded{}), do: nil + + defp format_reply_to(message) do + %{ + id: message.id, + content: message.content, + sender: format_user(message.sender) + } + end + + defp format_user(nil), do: nil + defp format_user(%Ecto.Association.NotLoaded{}), do: nil + + defp format_user(user) do + %{ + id: user.id, + username: user.username, + handle: user.handle, + display_name: user.display_name, + avatar: user.avatar + } + end + + defp pagination_meta(page, limit) do + %{ + limit: limit, + has_more_older: page.has_more_older, + has_more_newer: page.has_more_newer, + oldest_id: page.oldest_id, + newest_id: page.newest_id + } + end + + defp parse_id(value) when is_integer(value) and value > 0, do: {:ok, value} + + defp parse_id(value) when is_binary(value) do + case Integer.parse(value) do + {int, ""} when int > 0 -> {:ok, int} + _ -> {:error, :invalid_id} + end + end + + defp parse_id(_value), do: {:error, :invalid_id} + + defp parse_optional_id(nil), do: {:ok, nil} + defp parse_optional_id(""), do: {:ok, nil} + + defp parse_optional_id(value) do + parse_id(value) + end + + defp parse_positive_int(value, _default) when is_integer(value) and value > 0, do: value + + defp parse_positive_int(value, default) when is_binary(value) do + case Integer.parse(value) do + {int, ""} when int > 0 -> int + _ -> default + end + end + + defp parse_positive_int(_value, default), do: default + + defp normalize_media_urls(urls) when is_list(urls), do: urls + defp normalize_media_urls(_urls), do: [] + + defp errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + interpolate_error(message, opts) + end) + end + + defp interpolate_error(message, opts) do + Enum.reduce(opts, message, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end +end diff --git a/apps/arblarg/lib/arblarg_web/controllers/api/message_controller.ex b/apps/arblarg/lib/arblarg_web/controllers/api/message_controller.ex new file mode 100644 index 0000000..b2ed9de --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/controllers/api/message_controller.ex @@ -0,0 +1,473 @@ +defmodule ArblargWeb.API.MessageController do + @moduledoc """ + API controller for chat messages. + + Uses the new ChatMessages module for DM/group/channel messages, + separate from timeline posts which remain in the Messages module. + """ + use ArblargWeb, :controller + + alias Elektrine.Messaging + alias Elektrine.Uploads + + action_fallback ElektrineWeb.FallbackController + + @doc """ + GET /api/conversations/:conversation_id/messages + Lists messages for a conversation with pagination. + + Query params: + - before_id: Get messages before this ID (for loading older) + - after_id: Get messages after this ID (for loading newer) + - limit: Number of messages to return (default 50, max 100) + """ + def index(conn, %{"conversation_id" => conversation_id} = params) do + user = conn.assigns[:current_user] + + with_valid_id(conn, conversation_id, fn conv_id -> + # Verify user is a member + case Messaging.get_conversation_member(conv_id, user.id) do + nil -> + conn + |> put_status(:forbidden) + |> json(%{error: "Not a member of this conversation"}) + + _member -> + opts = + [ + limit: min(parse_int(params["limit"], 50), 100), + before_id: parse_int(params["before_id"], nil) + ] + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + + case Messaging.get_messages(conv_id, user.id, opts) do + {:ok, messages} -> + conn + |> put_status(:ok) + |> json(%{ + messages: Enum.map(messages, &format_message/1), + has_more: length(messages) >= Keyword.get(opts, :limit, 50) + }) + + {:error, _reason} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Unable to load messages"}) + end + end + end) + end + + @doc """ + POST /api/conversations/:conversation_id/messages + Sends a new message in a conversation. + + Params: + - content: Message text (required) + - reply_to_id: ID of message being replied to (optional) + - message_type: "text" | "image" | "file" (default: "text") + - media_urls: Array of media URLs (for image/file messages) + """ + def create(conn, %{"conversation_id" => conversation_id} = params) do + user = conn.assigns[:current_user] + + with_valid_id(conn, conversation_id, fn conv_id -> + # Verify user is a member + case Messaging.get_conversation_member(conv_id, user.id) do + nil -> + conn + |> put_status(:forbidden) + |> json(%{error: "Not a member of this conversation"}) + + member when member.role == "readonly" -> + conn + |> put_status(:forbidden) + |> json(%{error: "You don't have permission to send messages"}) + + _member -> + content = params["content"] || "" + reply_to_id = parse_int(params["reply_to_id"], nil) + message_type = params["message_type"] || "text" + + result = + case message_type do + "text" -> + opts = if reply_to_id, do: [reply_to_id: reply_to_id], else: [] + Messaging.create_chat_text_message(conv_id, user.id, content, opts) + + "image" -> + media_urls = params["media_urls"] || [] + media_metadata = params["media_metadata"] || %{} + + Messaging.create_chat_media_message( + conv_id, + user.id, + media_urls, + content, + media_metadata + ) + + "file" -> + media_urls = params["media_urls"] || [] + media_metadata = params["media_metadata"] || %{} + + Messaging.create_chat_media_message( + conv_id, + user.id, + media_urls, + content, + media_metadata + ) + + "voice" -> + audio_url = List.first(params["media_urls"] || []) + duration = params["duration"] || 0 + mime_type = params["mime_type"] || "audio/webm" + + Messaging.create_chat_voice_message( + conv_id, + user.id, + audio_url, + duration, + mime_type + ) + + _ -> + {:error, :invalid_message_type} + end + + case result do + {:ok, message} -> + conn + |> put_status(:created) + |> json(%{ + message: format_message(message) + }) + + {:error, :empty_message} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Message content cannot be empty"}) + + {:error, :rate_limited} -> + conn + |> put_status(:too_many_requests) + |> json(%{error: "You're sending messages too quickly. Please slow down."}) + + {:error, :timed_out} -> + conn + |> put_status(:forbidden) + |> json(%{error: "You are currently timed out from this conversation"}) + + {:error, :invalid_message_type} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid message type. Use: text, image, file, or voice"}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Validation failed", errors: format_errors(changeset)}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to send message: #{inspect(reason)}"}) + end + end + end) + end + + @doc """ + PUT /api/messages/:id + Edits a message. + + Params: + - content: New message content (required) + """ + def update(conn, %{"id" => id, "content" => new_content}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn message_id -> + case Messaging.edit_chat_message(message_id, user.id, new_content) do + {:ok, message} -> + conn + |> put_status(:ok) + |> json(%{ + message: format_message(message) + }) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Message not found"}) + + {:error, :unauthorized} -> + conn + |> put_status(:forbidden) + |> json(%{error: "You cannot edit this message"}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to edit message: #{inspect(reason)}"}) + end + end) + end + + def update(conn, %{"id" => _id}) do + conn + |> put_status(:bad_request) + |> json(%{error: "Missing required parameter: content"}) + end + + @doc """ + DELETE /api/messages/:id + Deletes a message (soft delete). + """ + def delete(conn, %{"id" => id}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn message_id -> + # Check if user is admin (for admin delete) + is_admin = user.admin == true + + case Messaging.delete_chat_message(message_id, user.id, is_admin) do + {:ok, _message} -> + conn + |> put_status(:ok) + |> json(%{message: "Message deleted"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Message not found"}) + + {:error, :unauthorized} -> + conn + |> put_status(:forbidden) + |> json(%{error: "You can only delete your own messages"}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to delete message: #{inspect(reason)}"}) + end + end) + end + + @doc """ + POST /api/messages/:id/reactions + Adds a reaction to a message. + + Params: + - emoji: The emoji to react with (required) + """ + def add_reaction(conn, %{"message_id" => id, "emoji" => emoji}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn message_id -> + case Messaging.add_chat_reaction(message_id, user.id, emoji) do + {:ok, reaction} -> + conn + |> put_status(:created) + |> json(%{ + message: "Reaction added", + reaction: format_reaction(reaction) + }) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Message not found"}) + + {:error, :rate_limited} -> + conn + |> put_status(:too_many_requests) + |> json(%{error: "You're adding reactions too quickly"}) + + {:error, :already_exists} -> + # Toggle behavior - remove the reaction + case Messaging.remove_chat_reaction(message_id, user.id, emoji) do + {:ok, _} -> + conn + |> put_status(:ok) + |> json(%{message: "Reaction removed"}) + + {:error, _} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to toggle reaction"}) + end + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to add reaction: #{inspect(reason)}"}) + end + end) + end + + def add_reaction(conn, %{"message_id" => _id}) do + conn + |> put_status(:bad_request) + |> json(%{error: "Missing required parameter: emoji"}) + end + + @doc """ + DELETE /api/messages/:id/reactions/:emoji + Removes a reaction from a message. + """ + def remove_reaction(conn, %{"message_id" => id, "emoji" => emoji}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn message_id -> + # URL decode emoji (emojis may be encoded) + decoded_emoji = URI.decode(emoji) + + case Messaging.remove_chat_reaction(message_id, user.id, decoded_emoji) do + {:ok, _} -> + conn + |> put_status(:ok) + |> json(%{message: "Reaction removed"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Reaction not found"}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to remove reaction: #{inspect(reason)}"}) + end + end) + end + + # Private helpers + defp with_valid_id(conn, value, fun) when is_function(fun, 1) do + case parse_id(value) do + {:ok, id} -> fun.(id) + :error -> invalid_id_response(conn) + end + end + + defp invalid_id_response(conn) do + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid id"}) + end + + defp parse_id(value) when is_integer(value) and value > 0, do: {:ok, value} + + defp parse_id(value) when is_binary(value) do + case Integer.parse(value) do + {int, ""} when int > 0 -> {:ok, int} + _ -> :error + end + end + + defp parse_id(_value), do: :error + + defp format_message(message) do + %{ + id: message.id, + content: message.content, + message_type: message.message_type, + media_urls: attachment_urls_for_message(message), + media_metadata: message.media_metadata || %{}, + conversation_id: message.conversation_id, + sender_id: message.sender_id, + sender: format_user(message.sender), + webhook_id: Map.get(message, :webhook_id), + reply_to_id: message.reply_to_id, + reply_to: format_reply_to(message.reply_to), + like_count: Map.get(message, :like_count, 0) || 0, + reply_count: Map.get(message, :reply_count, 0) || 0, + reactions: format_reactions(message.reactions), + edited_at: message.edited_at, + deleted_at: message.deleted_at, + created_at: message.inserted_at + } + end + + defp attachment_urls_for_message(message) do + Enum.map(message.media_urls || [], &Uploads.attachment_url(&1, %{type: "dm"})) + end + + defp format_reply_to(nil), do: nil + defp format_reply_to(%Ecto.Association.NotLoaded{}), do: nil + + defp format_reply_to(message) do + %{ + id: message.id, + content: message.content, + sender_id: message.sender_id, + sender: format_user(message.sender) + } + end + + defp format_reactions(nil), do: [] + defp format_reactions(%Ecto.Association.NotLoaded{}), do: [] + + defp format_reactions(reactions) when is_list(reactions) do + # Return individual reactions for the iOS client + Enum.map(reactions, fn reaction -> + # Handle both ChatMessageReaction (chat_message_id) and MessageReaction (message_id) + message_id = + cond do + Map.has_key?(reaction, :chat_message_id) -> reaction.chat_message_id + Map.has_key?(reaction, :message_id) -> reaction.message_id + true -> nil + end + + %{ + id: reaction.id, + emoji: reaction.emoji, + user_id: reaction.user_id, + message_id: message_id + } + end) + end + + defp format_reaction(reaction) do + %{ + id: reaction.id, + message_id: reaction.message_id, + user_id: reaction.user_id, + emoji: reaction.emoji, + created_at: reaction.inserted_at + } + end + + defp format_user(nil), do: nil + defp format_user(%Ecto.Association.NotLoaded{}), do: nil + + defp format_user(user) do + %{ + id: user.id, + username: user.username, + avatar: user.avatar, + is_bot: Map.get(user, :is_bot, false) == true, + webhook: Map.get(user, :webhook, false) == true + } + end + + defp format_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end + + defp parse_int(nil, default), do: default + defp parse_int(value, _default) when is_integer(value), do: value + + defp parse_int(value, default) when is_binary(value) do + case Integer.parse(value) do + {int, ""} -> int + _ -> default + end + end +end diff --git a/apps/arblarg/lib/arblarg_web/controllers/api/server_controller.ex b/apps/arblarg/lib/arblarg_web/controllers/api/server_controller.ex new file mode 100644 index 0000000..45cbb64 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/controllers/api/server_controller.ex @@ -0,0 +1,301 @@ +defmodule ArblargWeb.API.ServerController do + @moduledoc """ + API controller for community servers and server channels. + """ + use ArblargWeb, :controller + + alias Elektrine.Messaging + + action_fallback ElektrineWeb.FallbackController + + @doc """ + GET /api/servers + Lists all servers for the current user. Supports `scope=discover` (or `discover=true`) for joinable public server discovery. + """ + def index(conn, params) do + user = conn.assigns[:current_user] + limit = parse_int(params["limit"], 50) + query = params["query"] + + servers = + if discover_scope?(params) do + Messaging.list_public_servers(user.id, limit: limit, query: query) + else + Messaging.list_servers(user.id, limit: limit) + end + + conn + |> put_status(:ok) + |> json(%{servers: Enum.map(servers, &format_server_summary(&1, user.id))}) + end + + @doc """ + GET /api/servers/:id + Shows a server with its channels. + """ + def show(conn, %{"id" => id}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn server_id -> + case Messaging.get_server(server_id, user.id) do + {:ok, server} -> + conn + |> put_status(:ok) + |> json(%{ + server: format_server_summary(server, user.id), + channels: Enum.map(server.channels, &format_channel/1) + }) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Server not found"}) + end + end) + end + + @doc """ + POST /api/servers + Creates a new server and a default #general channel. + """ + def create(conn, params) do + user = conn.assigns[:current_user] + + attrs = %{ + name: params["name"], + description: params["description"], + icon_url: params["icon_url"], + is_public: parse_bool(params["is_public"], false) + } + + case Messaging.create_server(user.id, attrs) do + {:ok, server} -> + conn + |> put_status(:created) + |> json(%{ + message: "Server created", + server: format_server_summary(server, user.id), + channels: Enum.map(server.channels, &format_channel/1) + }) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Validation failed", errors: format_errors(changeset)}) + + {:error, :already_member} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to initialize server membership"}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to create server: #{inspect(reason)}"}) + end + end + + @doc """ + POST /api/servers/:server_id/join + Joins a public server. + """ + def join(conn, %{"server_id" => server_id}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, server_id, fn parsed_server_id -> + case Messaging.join_server(parsed_server_id, user.id) do + {:ok, _member} -> + case Messaging.get_server(parsed_server_id, user.id) do + {:ok, server} -> + conn + |> put_status(:ok) + |> json(%{ + message: "Joined server", + server: format_server_summary(server, user.id), + channels: Enum.map(server.channels, &format_channel/1) + }) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Server not found"}) + end + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Server not found"}) + + {:error, :not_public} -> + conn + |> put_status(:forbidden) + |> json(%{error: "This server is private"}) + + {:error, :already_member} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Already a member of this server"}) + + {:error, :federation_sync_unavailable} -> + conn + |> put_status(:service_unavailable) + |> json(%{error: "This federated server is temporarily unavailable to join"}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to join server: #{inspect(reason)}"}) + end + end) + end + + @doc """ + POST /api/servers/:server_id/channels + Creates a new channel in the server. + """ + def create_channel(conn, %{"server_id" => server_id} = params) do + user = conn.assigns[:current_user] + + attrs = %{ + name: params["name"], + description: params["description"], + channel_topic: params["channel_topic"], + channel_position: parse_int(params["channel_position"], nil) + } + + with_valid_id(conn, server_id, fn parsed_server_id -> + case Messaging.create_server_channel(parsed_server_id, user.id, attrs) do + {:ok, channel} -> + conn + |> put_status(:created) + |> json(%{ + message: "Channel created", + channel: format_channel(channel) + }) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Server not found"}) + + {:error, :unauthorized} -> + conn + |> put_status(:forbidden) + |> json(%{error: "You don't have permission to create channels in this server"}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Validation failed", errors: format_errors(changeset)}) + + {:error, reason} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to create channel: #{inspect(reason)}"}) + end + end) + end + + defp with_valid_id(conn, value, fun) when is_function(fun, 1) do + case parse_id(value) do + {:ok, id} -> fun.(id) + :error -> invalid_id_response(conn) + end + end + + defp invalid_id_response(conn) do + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid id"}) + end + + defp parse_id(value) when is_integer(value) and value > 0, do: {:ok, value} + + defp parse_id(value) when is_binary(value) do + case Integer.parse(value) do + {int, ""} when int > 0 -> {:ok, int} + _ -> :error + end + end + + defp parse_id(_value), do: :error + + defp format_server_summary(server, current_user_id) do + role = + case Messaging.get_server_member(server.id, current_user_id) do + nil -> nil + member -> member.role + end + + %{ + id: server.id, + name: server.name, + description: server.description, + icon_url: server.icon_url, + is_public: server.is_public, + member_count: server.member_count, + creator_id: server.creator_id, + origin_domain: server.origin_domain, + federation_id: server.federation_id, + is_federated_mirror: server.is_federated_mirror, + role: role, + inserted_at: server.inserted_at, + updated_at: server.updated_at + } + end + + defp format_channel(channel) do + %{ + id: channel.id, + name: channel.name, + description: channel.description, + type: channel.type, + channel_topic: channel.channel_topic, + channel_position: channel.channel_position, + server_id: channel.server_id, + inserted_at: channel.inserted_at, + updated_at: channel.updated_at + } + end + + defp format_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end + + defp parse_bool(nil, default), do: default + defp parse_bool(value, _default) when is_boolean(value), do: value + + defp parse_bool(value, default) when is_binary(value) do + case String.downcase(value) do + "true" -> true + "1" -> true + "false" -> false + "0" -> false + _ -> default + end + end + + defp parse_int(nil, default), do: default + defp parse_int(value, _default) when is_integer(value), do: value + + defp parse_int(value, default) when is_binary(value) do + case Integer.parse(value) do + {int, ""} -> int + _ -> default + end + end + + defp discover_scope?(params) do + scope = + case params["scope"] do + value when is_binary(value) -> value |> String.trim() |> String.downcase() + _ -> nil + end + + parse_bool(params["discover"], false) or scope in ["discover", "public"] + end +end diff --git a/apps/arblarg/lib/arblarg_web/controllers/api/webhook_controller.ex b/apps/arblarg/lib/arblarg_web/controllers/api/webhook_controller.ex new file mode 100644 index 0000000..eaca45b --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/controllers/api/webhook_controller.ex @@ -0,0 +1,255 @@ +defmodule ArblargWeb.API.WebhookController do + @moduledoc """ + API controller for incoming chat webhooks. + + Management endpoints (list/create/update/rotate/deactivate/delete) run on + the authenticated API surface and require the `manage_webhooks` permission + on the target conversation. + + The execute endpoint (`POST /api/webhooks/:id/:token`) is unauthenticated: + the webhook token is the credential. Unknown ids and bad tokens both + return 404 to avoid oracle behavior. + """ + use ArblargWeb, :controller + + alias Elektrine.Messaging + + action_fallback ElektrineWeb.FallbackController + + @doc """ + GET /api/conversations/:conversation_id/webhooks + Lists the webhooks of a conversation. + """ + def index(conn, %{"conversation_id" => conversation_id}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, conversation_id, fn conv_id -> + case Messaging.list_chat_webhooks(conv_id, user.id) do + {:ok, webhooks} -> + json(conn, %{webhooks: Enum.map(webhooks, &format_webhook/1)}) + + {:error, reason} -> + handle_error(conn, reason) + end + end) + end + + @doc """ + POST /api/conversations/:conversation_id/webhooks + Creates a webhook. The response includes the plaintext token exactly once. + """ + def create(conn, %{"conversation_id" => conversation_id} = params) do + user = conn.assigns[:current_user] + + with_valid_id(conn, conversation_id, fn conv_id -> + attrs = %{ + "name" => params["name"], + "avatar_url" => params["avatar_url"] + } + + case Messaging.create_chat_webhook(conv_id, user.id, attrs) do + {:ok, webhook} -> + conn + |> put_status(:created) + |> json(%{webhook: format_webhook(webhook)}) + + {:error, reason} -> + handle_error(conn, reason) + end + end) + end + + @doc """ + PUT /api/webhooks/:id + Renames a webhook and/or updates its avatar. + """ + def update(conn, %{"id" => id} = params) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn webhook_id -> + case Messaging.update_chat_webhook(webhook_id, user.id, params) do + {:ok, webhook} -> + json(conn, %{webhook: format_webhook(webhook)}) + + {:error, reason} -> + handle_error(conn, reason) + end + end) + end + + @doc """ + POST /api/webhooks/:id/rotate + Rotates a webhook token. The response includes the new plaintext token + exactly once. + """ + def rotate(conn, %{"id" => id}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn webhook_id -> + case Messaging.rotate_chat_webhook_token(webhook_id, user.id) do + {:ok, webhook} -> + json(conn, %{webhook: format_webhook(webhook)}) + + {:error, reason} -> + handle_error(conn, reason) + end + end) + end + + @doc """ + POST /api/webhooks/:id/deactivate + Deactivates a webhook without deleting it. + """ + def deactivate(conn, %{"id" => id}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn webhook_id -> + case Messaging.deactivate_chat_webhook(webhook_id, user.id) do + {:ok, webhook} -> + json(conn, %{webhook: format_webhook(webhook)}) + + {:error, reason} -> + handle_error(conn, reason) + end + end) + end + + @doc """ + DELETE /api/webhooks/:id + Deletes a webhook. + """ + def delete(conn, %{"id" => id}) do + user = conn.assigns[:current_user] + + with_valid_id(conn, id, fn webhook_id -> + case Messaging.delete_chat_webhook(webhook_id, user.id) do + {:ok, _webhook} -> + json(conn, %{success: true}) + + {:error, reason} -> + handle_error(conn, reason) + end + end) + end + + @doc """ + POST /api/webhooks/:id/:token + Executes a webhook: posts a message into the webhook's conversation. + + No session or API token required -- the webhook token is the credential. + Body: {"content": "...", "username": optional, "avatar_url": optional}. + """ + def execute(conn, %{"id" => id, "token" => token} = params) do + case Messaging.execute_chat_webhook(id, token, params) do + {:ok, message} -> + conn + |> put_status(:created) + |> json(%{id: message.id, conversation_id: message.conversation_id}) + + {:error, reason} -> + handle_error(conn, reason) + end + end + + # Error mapping + + defp handle_error(conn, :not_found) do + conn |> put_status(:not_found) |> json(%{error: "Not found"}) + end + + defp handle_error(conn, :unauthorized) do + conn + |> put_status(:forbidden) + |> json(%{error: "You do not have permission to manage webhooks here"}) + end + + defp handle_error(conn, :unsupported_conversation) do + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Webhooks are only supported in channels and groups"}) + end + + defp handle_error(conn, :webhook_limit_reached) do + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Webhook limit reached for this conversation"}) + end + + defp handle_error(conn, :webhook_inactive) do + conn |> put_status(:forbidden) |> json(%{error: "Webhook is inactive"}) + end + + defp handle_error(conn, :rate_limited) do + conn + |> put_status(:too_many_requests) + |> json(%{error: "Rate limit exceeded. Please slow down."}) + end + + defp handle_error(conn, :invalid_content) do + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Content is required and must be at most 4000 characters"}) + end + + defp handle_error(conn, :invalid_override) do + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Invalid username or avatar_url override"}) + end + + defp handle_error(conn, %Ecto.Changeset{} = changeset) do + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Validation failed", errors: format_errors(changeset)}) + end + + defp handle_error(conn, _reason) do + conn |> put_status(:internal_server_error) |> json(%{error: "Something went wrong"}) + end + + # Serialization + + defp format_webhook(webhook) do + base = %{ + id: webhook.id, + conversation_id: webhook.conversation_id, + name: webhook.name, + avatar_url: webhook.avatar_url, + active: webhook.active, + creator_id: webhook.creator_id, + created_at: webhook.inserted_at + } + + # Plaintext token is only present right after create/rotate. + case webhook.token do + nil -> base + token -> Map.put(base, :token, token) + end + end + + defp format_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end + + defp with_valid_id(conn, value, fun) do + case parse_id(value) do + {:ok, id} -> fun.(id) + :error -> conn |> put_status(:bad_request) |> json(%{error: "Invalid id"}) + end + end + + defp parse_id(value) when is_integer(value) and value > 0, do: {:ok, value} + + defp parse_id(value) when is_binary(value) do + case Integer.parse(value) do + {int, ""} when int > 0 -> {:ok, int} + _ -> :error + end + end + + defp parse_id(_value), do: :error +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/bootstrap.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/bootstrap.ex new file mode 100644 index 0000000..c6f62c0 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/bootstrap.ex @@ -0,0 +1,104 @@ +defmodule ArblargWeb.ChatLive.Bootstrap do + @moduledoc false + + alias ArblargWeb.ChatLive.Operations.Helpers + alias ArblargWeb.ChatLive.State + + def initialize_socket(socket, opts) do + cached_conversations = Keyword.fetch!(opts, :cached_conversations) + cached_unread = Keyword.fetch!(opts, :cached_unread) + cached_servers = Keyword.fetch!(opts, :cached_servers) + chat_attachment_limit = Keyword.fetch!(opts, :chat_attachment_limit) + user_token = Keyword.fetch!(opts, :user_token) + custom_emojis = Keyword.fetch!(opts, :custom_emojis) + federation_preview = Keyword.fetch!(opts, :federation_preview) + loading_conversations = Keyword.fetch!(opts, :loading_conversations) + + filtered_cached_conversations = + Helpers.scope_conversations_to_server(cached_conversations, nil) + + socket + |> Phoenix.Component.assign(:page_title, "Chat") + |> Phoenix.Component.assign(:ui, %State.UI{}) + |> Phoenix.Component.assign(:search, %State.Search{user_results: []}) + |> Phoenix.Component.assign(:call, %State.Call{}) + |> Phoenix.Component.assign(:form, %State.Form{}) + |> Phoenix.Component.assign(:context_menu, %State.ContextMenu{}) + |> Phoenix.Component.assign(:message, %State.Message{}) + |> Phoenix.Component.assign( + :conversation, + %State.Conversation{ + list: cached_conversations, + selected: nil, + filtered: filtered_cached_conversations, + last_message_read_status: %{}, + unread_count: cached_unread, + unread_counts: %{} + } + ) + |> Phoenix.Component.assign(:joined_servers, cached_servers) + |> Phoenix.Component.assign(:active_server_id, nil) + |> Phoenix.Component.assign(:server_categories, []) + |> Phoenix.Component.assign(:collapsed_category_ids, MapSet.new()) + |> Phoenix.Component.assign(:pinned_messages, []) + |> Phoenix.Component.assign(:threads, %State.Threads{}) + |> Phoenix.Component.assign(:voice, %State.Voice{}) + |> Phoenix.Component.assign(:moderation, %State.Moderation{}) + |> Phoenix.Component.assign(:browse, %State.Browse{}) + |> Phoenix.Component.assign(:profile, %State.Profile{}) + |> Phoenix.Component.assign(:messages, []) + |> Phoenix.Component.assign(:uploaded_files, []) + |> Phoenix.Component.assign(:can_send_messages, true) + |> Phoenix.LiveView.allow_upload(:chat_attachments, + accept: + ~w(.jpg .jpeg .png .gif .webp .heic .heif .avif .pdf .doc .docx .xls .xlsx .txt .mp3 .wav .webm), + max_entries: 5, + max_file_size: chat_attachment_limit + ) + |> Phoenix.LiveView.allow_upload(:server_icon_upload, + accept: ~w(.jpg .jpeg .png .gif .webp), + max_entries: 1, + max_file_size: 5 * 1024 * 1024 + ) + |> Phoenix.LiveView.allow_upload(:group_avatar_upload, + accept: ~w(.jpg .jpeg .png .gif .webp), + max_entries: 1, + max_file_size: 5 * 1024 * 1024 + ) + |> Phoenix.LiveView.allow_upload(:channel_avatar_upload, + accept: ~w(.jpg .jpeg .png .gif .webp), + max_entries: 1, + max_file_size: 5 * 1024 * 1024 + ) + |> Phoenix.Component.assign(:user_token, user_token) + |> Phoenix.Component.assign(:show_mobile_search, false) + |> Phoenix.Component.assign(:show_report_modal, false) + |> Phoenix.Component.assign(:report_type, nil) + |> Phoenix.Component.assign(:report_id, nil) + |> Phoenix.Component.assign(:report_metadata, %{}) + |> Phoenix.Component.assign(:has_more_older_messages, false) + |> Phoenix.Component.assign(:has_more_newer_messages, false) + |> Phoenix.Component.assign(:oldest_message_id, nil) + |> Phoenix.Component.assign(:processed_call_events, MapSet.new()) + |> Phoenix.Component.assign(:newest_message_id, nil) + |> Phoenix.Component.assign(:loading_older_messages, false) + |> Phoenix.Component.assign(:loading_newer_messages, false) + |> Phoenix.Component.assign(:initial_messages_loading, false) + |> Phoenix.Component.assign(:first_unread_message_id, nil) + |> Phoenix.Component.assign(:show_image_modal, false) + |> Phoenix.Component.assign(:modal_image_url, nil) + |> Phoenix.Component.assign(:modal_images, []) + |> Phoenix.Component.assign(:modal_image_index, 0) + |> Phoenix.Component.assign(:modal_post, nil) + |> Phoenix.Component.assign(:public_server_search_results, []) + |> Phoenix.Component.assign(:public_group_search_results, []) + |> Phoenix.Component.assign(:custom_emojis, custom_emojis) + |> Phoenix.Component.assign(:federation_preview, federation_preview) + |> Phoenix.Component.assign(:federation_presence, %{}) + |> Phoenix.Component.assign(:chat_e2ee_devices, []) + |> Phoenix.Component.assign(:room_presence_timer_ref, nil) + |> Phoenix.Component.assign(:pending_remote_join_requests, []) + |> Phoenix.Component.assign(:loading_conversations, loading_conversations) + |> Phoenix.Component.assign(:refresh_conversations_scheduled, false) + end +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/components/channel_modal.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/components/channel_modal.ex new file mode 100644 index 0000000..f4d58ea --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/components/channel_modal.ex @@ -0,0 +1,207 @@ +defmodule ArblargWeb.ChatLive.Components.ChannelModal do + @moduledoc false + use ArblargWeb, :live_component + + attr :uploads, :map, default: %{} + attr :categories, :list, default: [] + + def render(assigns) do + ~H""" + + """ + end + + def handle_event("close_modal", _params, socket) do + send(self(), {:close_channel_modal}) + {:noreply, socket} + end + + def handle_event("create_channel", %{"channel" => channel_params}, socket) do + send(self(), {:create_channel, channel_params}) + {:noreply, socket} + end + + def handle_event("validate_upload", _params, socket) do + {:noreply, socket} + end +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/components/group_modal.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/components/group_modal.ex new file mode 100644 index 0000000..14165b9 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/components/group_modal.ex @@ -0,0 +1,272 @@ +defmodule ArblargWeb.ChatLive.Components.GroupModal do + @moduledoc false + use ArblargWeb, :live_component + import Elektrine.Components.User.Avatar + alias ArblargWeb.ChatLive.HandleFormatter + + attr :group_name, :string, default: "" + attr :group_description, :string, default: "" + attr :group_is_public, :boolean, default: false + attr :search_query, :string, default: "" + attr :search_results, :list, default: [] + attr :selected_users, :list, default: [] + attr :uploads, :map, default: %{} + + def render(assigns) do + ~H""" + + """ + end + + def handle_event("close_modal", _params, socket) do + send(self(), {:close_group_modal}) + {:noreply, socket} + end + + def handle_event("create_group", %{"group" => group_params}, socket) do + send(self(), {:create_group, group_params, socket.assigns.selected_users}) + {:noreply, socket} + end + + def handle_event("search_users", %{"query" => query}, socket) do + send(self(), {:search_users_for_group, query}) + {:noreply, assign(socket, :search_query, query)} + end + + def handle_event("search_users", %{"value" => query}, socket) do + send(self(), {:search_users_for_group, query}) + {:noreply, assign(socket, :search_query, query)} + end + + def handle_event("update_group_form", %{"group" => group_params}, socket) do + send(self(), {:update_group_form, group_params}) + {:noreply, socket} + end + + def handle_event("update_group_form", %{"_target" => ["group", "is_public"]} = params, socket) do + # Handle checkbox toggle - when unchecked, the value won't be in params + group_params = Map.get(params, "group", %{}) + group_params = Map.put(group_params, "is_public", Map.has_key?(group_params, "is_public")) + + send(self(), {:update_group_form, group_params}) + {:noreply, socket} + end + + def handle_event("update_group_form", params, socket) do + # Fallback for other form updates + group_params = Map.get(params, "group", %{}) + send(self(), {:update_group_form, group_params}) + {:noreply, socket} + end + + def handle_event("validate_upload", _params, socket) do + {:noreply, socket} + end + + def handle_event("toggle_user_selection", %{"user_id" => user_id}, socket) do + send(self(), {:toggle_user_selection, user_id}) + {:noreply, socket} + end +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/handle_formatter.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/handle_formatter.ex new file mode 100644 index 0000000..cbb0ab4 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/handle_formatter.ex @@ -0,0 +1,108 @@ +defmodule ArblargWeb.ChatLive.HandleFormatter do + @moduledoc false + + alias Elektrine.Messaging.Federation + + def at_handle(user), do: "@" <> handle(user) + + def handle(%Ecto.Association.NotLoaded{}), do: unknown_handle() + def handle(nil), do: unknown_handle() + + def handle(user) when is_map(user) do + identifier = + first_present([ + map_get(user, :remote_handle), + map_get(user, :handle), + map_get(user, :username) + ]) + + fallback_domain = + first_present([ + map_get(user, :domain), + map_get(user, :origin_domain) + ]) + + case identifier do + nil -> unknown_handle() + value -> normalize_handle(value, fallback_domain) + end + end + + def handle(value) when is_binary(value), do: normalize_handle(value, nil) + def handle(_), do: unknown_handle() + + def domain(user) do + case String.split(handle(user), "@", parts: 2) do + [_local, domain] -> domain + _ -> local_domain() + end + end + + def local_domain do + case Federation.local_domain() do + domain when is_binary(domain) -> + case String.trim(domain) do + "" -> Elektrine.Domains.default_user_handle_domain() + value -> String.downcase(value) + end + + _ -> + Elektrine.Domains.default_user_handle_domain() + end + end + + defp normalize_handle(value, fallback_domain) when is_binary(value) do + cleaned = + value + |> String.trim() + |> String.trim_leading("@") + + case String.split(cleaned, "@", parts: 2) do + [local, domain] when local != "" and domain != "" -> + local <> "@" <> normalize_domain(domain) + + [local] when local != "" -> + local <> "@" <> normalize_domain(fallback_domain || local_domain()) + + _ -> + unknown_handle() + end + end + + defp normalize_handle(_value, fallback_domain), + do: unknown_handle(fallback_domain || local_domain()) + + defp normalize_domain(domain) when is_binary(domain) do + domain + |> String.trim() + |> String.trim_leading("@") + |> String.downcase() + |> case do + "" -> local_domain() + value -> value + end + end + + defp normalize_domain(_), do: local_domain() + + defp first_present(values) do + Enum.find_value(values, fn + value when is_binary(value) -> + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + + _ -> + nil + end) + end + + defp map_get(map, key) when is_map(map) do + Map.get(map, key) || Map.get(map, Atom.to_string(key)) + end + + defp unknown_handle(domain \\ local_domain()) do + "unknown@" <> normalize_domain(domain) + end +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/index.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/index.ex new file mode 100644 index 0000000..81a7c67 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/index.ex @@ -0,0 +1,3604 @@ +defmodule ArblargWeb.ChatLive.Index do + use ArblargWeb, :live_view + require Logger + + alias Elektrine.Accounts.User + alias Elektrine.Calls + alias Elektrine.Calls.Transport, as: CallTransport + alias Elektrine.Constants + alias Elektrine.Messaging, as: Messaging + alias Elektrine.Messaging.ChatMessage + alias Elektrine.Messaging.Federation.VoiceCalls + alias Elektrine.Messaging.RoomACL + alias Elektrine.Social.Message + alias Elektrine.Uploads + import Elektrine.Components.User.Avatar + import Elektrine.Components.User.UsernameEffects + import ElektrineWeb.Components.Social.YoutubePreview, only: [youtube_preview: 1] + import ArblargWeb.Components.Social.ContentJourney + import ArblargWeb.Components.Chat.Call + import ArblargWeb.Components.Chat.SenderBadge, only: [sender_badge: 1] + import ArblargWeb.Components.Platform.ENav + import ArblargWeb.Components.Social.EmbeddedPost + import ElektrineWeb.Live.NotificationHelpers + + # Import operation modules + alias ArblargWeb.ChatLive.Bootstrap + alias ArblargWeb.ChatLive.HandleFormatter + + alias ArblargWeb.ChatLive.Operations.{ + CallInfoOperations, + Helpers, + MessageInfoOperations, + ThreadOperations, + VoiceChannelOperations + } + + @room_presence_heartbeat_ms 30_000 + + @impl true + def mount(_params, session, socket) do + case socket.assigns[:current_user] do + %User{} = user -> + mount_authenticated_user(socket, session, user) + + _unauthenticated -> + {:ok, redirect(socket, to: Elektrine.Paths.login_path())} + end + end + + defp mount_authenticated_user(socket, session, user) do + # Set locale from session or user preference + locale = session["locale"] || (user && user.locale) || "en" + Gettext.put_locale(ElektrineWeb.Gettext, locale) + + if connected?(socket) do + # Subscribe to user's personal channel for notifications + Phoenix.PubSub.subscribe(Elektrine.PubSub, "user:#{user.id}") + # Subscribe to global conversation events + Phoenix.PubSub.subscribe(Elektrine.PubSub, "conversations:all") + end + + # Admins get higher upload limits + chat_attachment_limit = + if user.is_admin, + do: Constants.max_chat_attachment_size_admin(), + else: Constants.max_chat_attachment_size() + + # Load cached conversations to prevent flicker on refresh + # This returns cached data instantly if available, only hits DB on cache miss + cached_conversations = get_cached_conversations(user.id) + cached_unread = get_cached_unread_count(user.id) + cached_servers = Messaging.list_servers(user.id) + + custom_emojis = load_custom_emojis() + federation_preview = build_federation_preview() + + loading_conversations = + !Enum.empty?(cached_conversations) || Messaging.user_has_conversations?(user.id) + + # Initialize with cached data to prevent flicker + socket = + Bootstrap.initialize_socket(socket, + cached_conversations: cached_conversations, + cached_unread: cached_unread, + cached_servers: cached_servers, + chat_attachment_limit: chat_attachment_limit, + user_token: Helpers.generate_user_token(user.id), + custom_emojis: custom_emojis, + federation_preview: federation_preview, + loading_conversations: loading_conversations + ) + |> restore_active_call_state(user.id) + + # Load conversations async after connection + if connected?(socket) do + send(self(), :load_conversations) + + if socket.assigns.call.active_call do + send(self(), :resume_active_call) + end + end + + {:ok, socket} + end + + @impl true + def handle_params( + %{"conversation_id" => conversation_identifier}, + _url, + %{assigns: %{live_action: :join}} = socket + ) do + # Handle join link - first try to find by hash, then by ID + conversation_id = + case Messaging.get_conversation_by_hash(conversation_identifier) do + %{id: id} -> + id + + nil -> + # Fallback to ID lookup when handle is absent + case Integer.parse(conversation_identifier) do + {id, ""} -> id + _ -> nil + end + end + + if conversation_id do + case Messaging.join_conversation(conversation_id, socket.assigns.current_user.id) do + {:ok, :pending} -> + {:noreply, + socket + |> notify_info("Join request sent") + |> push_navigate(to: Elektrine.Paths.chat_root_path())} + + {:ok, _} -> + # Successfully joined, trigger conversation refresh and redirect + {:noreply, + socket + |> maybe_schedule_conversation_refresh(100) + |> notify_info("Successfully joined!") + |> push_navigate(to: Elektrine.Paths.chat_path(conversation_identifier))} + + {:error, :already_member} -> + # Already a member, just go to the conversation using the original identifier + {:noreply, + socket + |> push_navigate(to: Elektrine.Paths.chat_path(conversation_identifier))} + + {:error, :not_public_channel} -> + {:noreply, + socket + |> notify_error("This is a private group or channel - you need an invitation to join") + |> push_navigate(to: Elektrine.Paths.chat_root_path())} + + {:error, _} -> + {:noreply, + socket + |> notify_error("Unable to join this conversation") + |> push_navigate(to: Elektrine.Paths.chat_root_path())} + end + else + # Invalid conversation identifier + {:noreply, + socket + |> notify_error("Invalid chat invite link") + |> push_navigate(to: Elektrine.Paths.chat_root_path())} + end + end + + def handle_params(%{"conversation_id" => conversation_identifier}, _url, socket) do + user_id = socket.assigns.current_user.id + + case Messaging.get_conversation_for_chat_by_hash!(conversation_identifier, user_id) do + {:ok, conversation} -> + {:noreply, open_conversation_or_redirect(socket, conversation)} + + {:error, :not_found} -> + case Integer.parse(conversation_identifier) do + {conversation_id, ""} -> + case Messaging.get_conversation_for_chat!(conversation_id, user_id) do + {:ok, conversation} -> + # If accessed by ID instead of hash, redirect to canonical hash URL. + if conversation_identifier != conversation.hash && conversation.hash do + {:noreply, push_navigate(socket, to: Elektrine.Paths.chat_path(conversation))} + else + {:noreply, open_conversation_or_redirect(socket, conversation)} + end + + {:error, :not_found} -> + {:noreply, + socket + |> notify_error("Chat not found") + |> push_navigate(to: Elektrine.Paths.chat_root_path())} + end + + _ -> + {:noreply, + socket + |> notify_error("Invalid chat") + |> push_navigate(to: Elektrine.Paths.chat_root_path())} + end + end + end + + def handle_params(params, _url, socket) do + show_new_chat = params["composer"] == "message" + + # Clear selected conversation when navigating to /chat without a conversation_id + {:noreply, + socket + |> cancel_room_presence_timer() + |> assign(:conversation, %{socket.assigns.conversation | selected: nil}) + |> assign(:initial_messages_loading, false) + |> assign(:ui, %{socket.assigns.ui | show_new_chat: show_new_chat}) + |> assign(:federation_presence, %{})} + end + + @impl true + def handle_event(event_name, params, socket) do + # Delegate ALL events to the router + ArblargWeb.ChatLive.Router.route_event(event_name, params, socket) + end + + # All event handlers now in operation modules via Router + + # Handle messages from components + # Load conversations asynchronously after mount + def handle_info(:load_conversations, socket) do + user = socket.assigns.current_user + + # Get all conversations but filter out Timeline and Community channels from chat view + all_conversations = Messaging.list_chat_conversations(user.id) + + conversations_filtered = + Enum.reject(all_conversations, &(&1.type in ["timeline", "community"])) + + unread_count = Messaging.get_unread_count(user.id) + + # Calculate unread counts and sort conversations + unread_counts = Helpers.calculate_unread_counts(conversations_filtered, user.id) + + conversations = + Helpers.sort_conversations_by_unread(conversations_filtered, unread_counts, user.id) + + # Calculate read status for last messages + last_message_read_status = Helpers.calculate_last_message_read_status(conversations, user.id) + joined_servers = Messaging.list_servers(user.id) + + {:noreply, + socket + |> assign(:conversation, %{ + socket.assigns.conversation + | list: conversations, + filtered: + refresh_conversation_filter( + conversations, + socket.assigns.search.conversation_query, + user.id, + socket.assigns[:active_server_id] + ), + last_message_read_status: last_message_read_status, + unread_count: unread_count, + unread_counts: unread_counts + }) + |> assign(:joined_servers, joined_servers) + |> assign(:loading_conversations, false) + |> VoiceChannelOperations.sync_voice_channels(conversations)} + end + + def handle_info({:update_group_form, group_params}, socket) do + updated_form = %{ + socket.assigns.form + | group_name: Map.get(group_params, "name", socket.assigns.form.group_name), + group_description: + Map.get(group_params, "description", socket.assigns.form.group_description), + group_is_public: + Map.get(group_params, "is_public", socket.assigns.form.group_is_public) == "true" + } + + {:noreply, assign(socket, :form, updated_form)} + end + + # Handle messages from components + def handle_info({:search_users, query}, socket) do + if String.length(query) >= 2 do + results = + query + |> Messaging.search_users(socket.assigns.current_user.id) + |> maybe_add_remote_dm_search_result(query) + + {:noreply, assign(socket, :search_results, results)} + else + {:noreply, assign(socket, :search_results, [])} + end + end + + def handle_info({:show_direct_search}, socket) do + updated_ui = + socket.assigns.ui + |> Map.put(:show_create_group, false) + |> Map.put(:show_create_channel, false) + |> Map.put(:show_browse_channels, false) + + {:noreply, + socket + |> assign(:ui, updated_ui)} + end + + def handle_info({:start_dm, %{"remote_handle" => remote_handle}}, socket) + when is_binary(remote_handle) and remote_handle != "" do + current_user_id = socket.assigns.current_user.id + + case Messaging.create_remote_dm_conversation(current_user_id, remote_handle) do + {:ok, conversation} -> + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_new_chat, false)) + |> assign(:search, %{socket.assigns.search | query: "", results: []}) + |> push_navigate(to: Elektrine.Paths.chat_path(conversation))} + + {:error, :invalid_remote_handle} -> + {:noreply, notify_error(socket, "Use handle format user@domain")} + + {:error, :unknown_peer} -> + {:noreply, + notify_error( + socket, + "That domain could not be reached through federation discovery" + )} + + {:error, :rate_limited} -> + {:noreply, + socket + |> notify_error( + "You are creating too many conversations. Please wait a moment and try again." + ) + |> assign(:ui, Map.put(socket.assigns.ui, :show_profile_modal, false))} + + {:error, reason} -> + error_message = Elektrine.Privacy.privacy_error_message(reason) + + {:noreply, + socket + |> notify_error(error_message) + |> assign(:ui, Map.put(socket.assigns.ui, :show_profile_modal, false))} + end + end + + def handle_info({:start_dm, %{"user_id" => user_id_str}}, socket) do + handle_info({:start_dm, user_id_str}, socket) + end + + def handle_info({:start_dm, user_id_str}, socket) when is_binary(user_id_str) do + case parse_positive_int(user_id_str) do + {:ok, user_id} -> + start_local_dm(socket, user_id) + + :error -> + {:noreply, + socket + |> notify_error("Failed to start chat") + |> assign(:ui, Map.put(socket.assigns.ui, :show_profile_modal, false))} + end + end + + def handle_info({:show_create_group}, socket) do + updated_ui = + socket.assigns.ui + |> Map.put(:show_group_modal, true) + |> Map.put(:show_channel_modal, false) + |> Map.put(:show_new_chat, false) + + {:noreply, + socket + |> assign(:ui, updated_ui)} + end + + def handle_info({:show_create_channel}, socket) do + case selected_server_id(socket) do + nil -> + {:noreply, + socket + |> notify_error("Select a server first, then create channels inside it")} + + _server_id -> + updated_ui = + socket.assigns.ui + |> Map.put(:show_channel_modal, true) + |> Map.put(:show_group_modal, false) + |> Map.put(:show_new_chat, false) + + {:noreply, + socket + |> assign(:ui, updated_ui)} + end + end + + def handle_info({:show_browse_modal}, socket) do + public_servers = Messaging.list_public_servers(socket.assigns.current_user.id) + public_groups = Messaging.list_chat_public_groups() + + updated_ui = + socket.assigns.ui + |> Map.put(:show_browse_modal, true) + |> Map.put(:show_group_modal, false) + |> Map.put(:show_channel_modal, false) + |> Map.put(:show_new_chat, false) + + {:noreply, + socket + |> assign(:ui, updated_ui) + |> assign(:search, %{socket.assigns.search | browse_query: ""}) + |> assign(:browse, %{ + socket.assigns.browse + | tab: "servers", + public_servers: public_servers, + public_channels: [], + public_groups: public_groups, + filtered_servers: public_servers, + filtered_channels: [], + filtered_groups: public_groups + })} + end + + def handle_info({:close_group_modal}, socket) do + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_group_modal, false)) + |> clear_upload_entries(:group_avatar_upload)} + end + + def handle_info({:close_channel_modal}, socket) do + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_channel_modal, false)) + |> clear_upload_entries(:channel_avatar_upload)} + end + + def handle_info({:create_group, group_params, selected_users}, socket) do + name = Map.get(group_params, "name", "") + description = Map.get(group_params, "description", "") + is_public = Map.get(group_params, "is_public") == "true" + + if Elektrine.Strings.present?(name) and selected_users != [] do + member_ids = Enum.map(selected_users, & &1.id) + + case consume_entity_image_upload(socket, :group_avatar_upload) do + {:ok, avatar_url} -> + case Messaging.create_chat_group_conversation( + socket.assigns.current_user.id, + %{ + name: String.trim(name), + description: String.trim(description), + avatar_url: avatar_url, + is_public: is_public + }, + member_ids + ) do + {:ok, conversation} -> + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_group_modal, false)) + |> assign(:form, %{socket.assigns.form | selected_users: []}) + |> assign(:search, %{socket.assigns.search | query: "", results: []}) + |> notify_info("Group created successfully!") + |> push_navigate(to: Elektrine.Paths.chat_path(conversation))} + + {:ok, conversation, failed_count} -> + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_group_modal, false)) + |> assign(:form, %{socket.assigns.form | selected_users: []}) + |> assign(:search, %{socket.assigns.search | query: "", results: []}) + |> notify_warning( + "Group created but #{failed_count} user(s) could not be added due to their privacy settings" + ) + |> push_navigate(to: Elektrine.Paths.chat_path(conversation))} + + {:error, :group_limit_exceeded} -> + {:noreply, + socket + |> notify_error("You've reached the maximum limit of 20 groups")} + + {:error, _changeset} -> + {:noreply, + socket + |> notify_error("Failed to create group. Please try again.")} + end + + {:error, reason} -> + {:noreply, notify_error(socket, reason)} + end + else + {:noreply, + socket + |> notify_error("Please provide a group name and select at least one member.")} + end + end + + def handle_info({:create_channel, channel_params}, socket) do + name = Map.get(channel_params, "name", "") + description = Map.get(channel_params, "description", "") + topic = Map.get(channel_params, "channel_topic", "") + is_private = parse_checkbox_value(Map.get(channel_params, "is_private")) + category_id = parse_optional_positive_int(Map.get(channel_params, "category_id")) + + channel_type = + if Map.get(channel_params, "channel_type") == "voice_channel", + do: "voice_channel", + else: "channel" + + with server_id when is_integer(server_id) <- selected_server_id(socket), + true <- Elektrine.Strings.present?(name), + {:ok, avatar_url} <- consume_entity_image_upload(socket, :channel_avatar_upload) do + attrs = %{ + name: String.trim(name), + description: normalize_optional_text(description), + channel_topic: normalize_optional_text(topic), + avatar_url: avatar_url, + category_id: category_id, + type: channel_type, + is_public: !is_private + } + + case Messaging.create_server_channel(server_id, socket.assigns.current_user.id, attrs) do + {:ok, %{type: "voice_channel"}} -> + # Voice channels have no text timeline to navigate to. + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_channel_modal, false)) + |> notify_info("Voice channel created successfully!") + |> maybe_schedule_conversation_refresh(100)} + + {:ok, channel} -> + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_channel_modal, false)) + |> notify_info("Channel created successfully!") + |> push_navigate(to: Elektrine.Paths.chat_path(channel))} + + {:error, :unauthorized} -> + {:noreply, + socket + |> notify_error("You don't have permission to create channels in this server")} + + {:error, :not_found} -> + {:noreply, + socket + |> notify_error("Server not found")} + + {:error, _changeset} -> + {:noreply, + socket + |> notify_error("Failed to create channel. Please try again.")} + end + else + nil -> + {:noreply, + socket + |> notify_error("Select a server first, then create channels inside it")} + + false -> + {:noreply, + socket + |> notify_error("Please provide a channel name.")} + + {:error, reason} -> + {:noreply, notify_error(socket, reason)} + end + end + + def handle_info({:search_users_for_group, query}, socket) do + search_results = + if String.length(query) >= 2 do + Elektrine.Accounts.search_users(query, socket.assigns.current_user.id) + else + [] + end + + {:noreply, + socket + |> assign(:search, %{socket.assigns.search | query: query, results: search_results})} + end + + def handle_info({:toggle_user_selection, user_id_str}, socket) do + case parse_positive_int(user_id_str) do + {:ok, user_id} -> + selected_users = socket.assigns.form.selected_users + + updated_users = + case Enum.find_index(selected_users, &(&1.id == user_id)) do + nil -> + case Enum.find(socket.assigns.search.results, &(&1.id == user_id)) do + %User{} = user -> [user | selected_users] + nil -> selected_users + end + + index -> + List.delete_at(selected_users, index) + end + + {:noreply, assign(socket, :form, %{socket.assigns.form | selected_users: updated_users})} + + :error -> + {:noreply, socket} + end + end + + def handle_info({:send_message, message_content}, socket) do + trimmed_content = String.trim(message_content) + + if trimmed_content != "" do + case socket.assigns.conversation.selected do + nil -> + {:noreply, socket} + + conversation -> + reply_to_id = + if socket.assigns.message.reply_to, do: socket.assigns.message.reply_to.id, else: nil + + case Messaging.create_text_message( + conversation.id, + socket.assigns.current_user.id, + trimmed_content, + reply_to_id + ) do + {:ok, _message} -> + {:noreply, + socket + |> assign(:message, %{socket.assigns.message | new_message: "", reply_to: nil}) + |> push_event("clear_message_input", %{})} + + {:error, _} -> + {:noreply, + socket + |> notify_error("Failed to send message")} + end + end + else + {:noreply, socket} + end + end + + def handle_info({:react_to_message, message_id_str, emoji}, socket) do + case parse_positive_int(message_id_str) do + {:ok, message_id} -> + case Messaging.add_chat_reaction(message_id, socket.assigns.current_user.id, emoji) do + {:ok, _reaction} -> {:noreply, socket} + {:error, _} -> {:noreply, socket} + end + + :error -> + {:noreply, socket} + end + end + + def handle_info({:load_conversation_messages, conversation_id}, socket) do + if socket.assigns.conversation.selected && + socket.assigns.conversation.selected.id == conversation_id do + user_id = socket.assigns.current_user.id + + data = Messaging.get_conversation_messages(conversation_id, user_id, limit: 50) + messages = data.messages |> Enum.reverse() |> Helpers.dedupe_messages() + + first_unread_message_id = + Helpers.find_first_unread_message(messages, conversation_id, user_id) + + # Mark conversation as read after UI settles. + Process.send_after(self(), {:mark_conversation_read, conversation_id}, 200) + + message_ids = Enum.map(messages, & &1.id) + + if message_ids != [] do + # Load read receipts asynchronously to keep initial message paint snappy. + Process.send_after(self(), {:load_read_status, message_ids, conversation_id}, 150) + end + + user_ids = + socket.assigns.conversation.selected.members + |> Enum.map(& &1.user_id) + |> Enum.uniq() + + timeout_status = Helpers.load_timeout_status(user_ids, conversation_id) + + updated_socket = + socket + |> assign(:messages, messages) + |> assign(:moderation, %{ + socket.assigns.moderation + | user_timeout_status: timeout_status + }) + |> assign(:first_unread_message_id, first_unread_message_id) + |> assign(:has_more_older_messages, data.has_more_older) + |> assign(:has_more_newer_messages, data.has_more_newer) + |> assign(:oldest_message_id, data.oldest_id) + |> assign(:newest_message_id, data.newest_id) + |> assign(:loading_older_messages, false) + |> assign(:loading_newer_messages, false) + |> assign(:initial_messages_loading, false) + |> push_event("restore_conversation_scroll", %{conversation_id: conversation_id}) + + {:noreply, updated_socket} + else + {:noreply, socket} + end + end + + def handle_info({:chat_e2ee_devices_changed, conversation_id}, socket) do + if socket.assigns.conversation.selected && + socket.assigns.conversation.selected.id == conversation_id do + {:noreply, + assign( + socket, + :chat_e2ee_devices, + Messaging.list_chat_encryption_devices_for_conversation(conversation_id) + )} + else + {:noreply, socket} + end + end + + def handle_info({:scroll_to_unread, _message_id}, socket) do + # Send event to scroll to the unread indicator + {:noreply, + push_event(socket, "scroll_to_element", %{ + element_id: "unread-indicator", + position: "top-third" + })} + end + + # Scroll handler for media messages - ensures images have time to load + def handle_info({:ensure_scroll_after_media, _message_id}, socket) do + # Trigger scroll to bottom - the client-side handler includes retries for image loading + {:noreply, push_event(socket, "scroll_to_bottom", %{})} + end + + # Async load read status for faster initial render + def handle_info({:load_read_status, message_ids, conversation_id}, socket) do + # Only load if still viewing the same conversation + if socket.assigns.conversation.selected && + socket.assigns.conversation.selected.id == conversation_id do + read_status = Messaging.get_read_status_for_messages(message_ids, conversation_id) + + {:noreply, assign(socket, :message, %{socket.assigns.message | read_status: read_status})} + else + {:noreply, socket} + end + end + + def handle_info({:mark_conversation_read, conversation_id}, socket) do + # Mark conversation as read after user has had time to see it + if socket.assigns.conversation.selected && + socket.assigns.conversation.selected.id == conversation_id do + user_id = socket.assigns.current_user.id + + # Update last read message ID to the last message in the conversation + read_result = + if socket.assigns[:messages] && socket.assigns.messages != [] do + last_message = List.last(socket.assigns.messages) + Messaging.update_last_read_message(conversation_id, user_id, last_message.id) + else + Messaging.mark_as_read(conversation_id, user_id) + end + + # Mark any related notifications as read + # Chat notifications use source_type "message", so clear all message notifications in this conversation + if socket.assigns[:messages] && socket.assigns.messages != [] do + message_ids = Enum.map(socket.assigns.messages, & &1.id) + # This broadcasts :notification_updated and notification_count_updated automatically + Elektrine.Notifications.mark_as_read_by_sources(user_id, "message", message_ids) + end + + # Update unread counts and clear unread indicator + updated_unread_counts = + Map.put(socket.assigns.conversation.unread_counts, conversation_id, 0) + + if read_status_saved?(read_result) do + broadcast_chat_read_status(conversation_id, user_id) + end + + {:noreply, + socket + |> assign(:conversation, %{ + socket.assigns.conversation + | unread_counts: updated_unread_counts + }) + |> assign(:first_unread_message_id, nil)} + else + {:noreply, socket} + end + end + + def handle_info({:reply_to_message, message_id_str}, socket) do + reply_to_message = + case parse_positive_int(message_id_str) do + {:ok, message_id} -> Enum.find(socket.assigns.messages, &(&1.id == message_id)) + :error -> nil + end + + {:noreply, assign(socket, :message, %{socket.assigns.message | reply_to: reply_to_message})} + end + + def handle_info({:user_read_messages, user_id}, socket) do + # User (possibly current user or another) read messages, update read status immediately + + # Update conversation list read status + conversations = socket.assigns.conversation.list + + last_message_read_status = + Helpers.calculate_last_message_read_status(conversations, socket.assigns.current_user.id) + + socket = + assign( + socket, + :conversation, + Map.put(socket.assigns.conversation, :last_message_read_status, last_message_read_status) + ) + + # Also update message read status if viewing a conversation + socket = + if socket.assigns.conversation.selected do + conversation_id = socket.assigns.conversation.selected.id + message_ids = Enum.map(socket.assigns.messages, & &1.id) + + # Fetch fresh read status + read_status = + if message_ids != [] do + Messaging.get_read_status_for_messages(message_ids, conversation_id) + else + %{} + end + + # If the current user read messages, also clear the unread divider + socket = + socket + |> assign(:message, %{socket.assigns.message | read_status: read_status}) + + if user_id == socket.assigns.current_user.id do + assign(socket, :first_unread_message_id, nil) + else + socket + end + else + socket + end + + {:noreply, socket} + end + + def handle_info( + {:user_read_messages_in_conversation, + %{conversation_id: conversation_id, reader_id: _reader_id}}, + socket + ) do + # Someone read messages in a conversation - update conversation list read status + conversations = socket.assigns.conversation.list + + last_message_read_status = + Helpers.calculate_last_message_read_status(conversations, socket.assigns.current_user.id) + + socket = + assign( + socket, + :conversation, + Map.put(socket.assigns.conversation, :last_message_read_status, last_message_read_status) + ) + + socket = + if socket.assigns.conversation.selected && + socket.assigns.conversation.selected.id == conversation_id do + message_ids = Enum.map(socket.assigns.messages, & &1.id) + + read_status = + if message_ids != [] do + Messaging.get_read_status_for_messages(message_ids, conversation_id) + else + %{} + end + + assign(socket, :message, %{socket.assigns.message | read_status: read_status}) + else + socket + end + + {:noreply, socket} + end + + def handle_info({:cancel_reply}, socket) do + {:noreply, assign(socket, :message, %{socket.assigns.message | reply_to: nil})} + end + + def handle_info({:timeout_added, timeout}, socket) do + # Update timeout status in assigns + updated_status = Map.put(socket.assigns.moderation.user_timeout_status, timeout.user_id, true) + {:noreply, assign(socket, :user_timeout_status, updated_status)} + end + + def handle_info({:timeout_removed, timeout}, socket) do + # Update timeout status in assigns + updated_status = + Map.put(socket.assigns.moderation.user_timeout_status, timeout.user_id, false) + + {:noreply, assign(socket, :user_timeout_status, updated_status)} + end + + def handle_info({:user_kicked, %{user_id: user_id, conversation_id: conversation_id}}, socket) do + if socket.assigns.conversation.selected && + socket.assigns.conversation.selected.id == conversation_id do + # Refresh the conversation to remove the kicked member from the member list + updated_conversation = + socket.assigns.conversation.selected + |> Map.update!(:members, fn members -> + Enum.reject(members, fn member -> + member.user_id == user_id && is_nil(member.left_at) + end) + end) + + # Update timeout status map to remove kicked user + updated_timeout_status = Map.delete(socket.assigns.moderation.user_timeout_status, user_id) + + {:noreply, + socket + |> assign(:selected_conversation, updated_conversation) + |> assign(:user_timeout_status, updated_timeout_status)} + else + {:noreply, socket} + end + end + + def handle_info({:member_joined, %{user_id: user_id, conversation_id: conversation_id}}, socket) do + # Refresh conversation list when someone joins + socket = maybe_schedule_conversation_refresh(socket, 50) + + if socket.assigns.conversation.selected && + socket.assigns.conversation.selected.id == conversation_id do + # Refresh the conversation to show the new member + case Messaging.get_conversation!(conversation_id, socket.assigns.current_user.id) do + {:ok, updated_conversation} -> + # Update timeout status for the new member + timeout_status = + Map.put( + socket.assigns.moderation.user_timeout_status, + user_id, + Messaging.user_timed_out?(user_id, conversation_id) + ) + + {:noreply, + socket + |> assign(:selected_conversation, updated_conversation) + |> assign(:user_timeout_status, timeout_status)} + + {:error, _} -> + {:noreply, socket} + end + else + {:noreply, socket} + end + end + + def handle_info({:added_to_conversation, %{conversation_id: _conversation_id}}, socket) do + {:noreply, maybe_schedule_conversation_refresh(socket, 50)} + end + + def handle_info({:conversation_activity, %{conversation_id: _conversation_id}}, socket) do + {:noreply, maybe_schedule_conversation_refresh(socket, 50)} + end + + def handle_info({:member_left, %{user_id: _user_id, conversation_id: conversation_id}}, socket) do + if socket.assigns.conversation.selected && + socket.assigns.conversation.selected.id == conversation_id do + # Refresh the conversation to show updated member list + case Messaging.get_conversation!(conversation_id, socket.assigns.current_user.id) do + {:ok, updated_conversation} -> + {:noreply, assign(socket, :selected_conversation, updated_conversation)} + + {:error, _} -> + {:noreply, socket} + end + else + {:noreply, socket} + end + end + + def handle_info({:kicked_from_conversation, %{conversation_id: conversation_id}}, socket) do + # If the current user was kicked, redirect them away from the conversation + if socket.assigns.conversation.selected && + socket.assigns.conversation.selected.id == conversation_id do + {:noreply, + socket + |> notify_error("You have been removed from this conversation") + |> push_navigate(to: Elektrine.Paths.chat_root_path())} + else + # Refresh conversations list to remove the conversation they were kicked from + all_conversations = Messaging.list_chat_conversations(socket.assigns.current_user.id) + conversations = Enum.reject(all_conversations, &(&1.type in ["timeline", "community"])) + {:noreply, assign(socket, :conversations, conversations)} + end + end + + def handle_info({:conversation_deleted, conversation_id}, socket) do + # If viewing the deleted conversation, redirect to main chat + if socket.assigns.conversation.selected && + socket.assigns.conversation.selected.id == conversation_id do + {:noreply, + socket + |> notify_info("This conversation has been disbanded") + |> push_navigate(to: Elektrine.Paths.chat_root_path())} + else + # Remove from conversations list + conversations = Enum.reject(socket.assigns.conversation.list, &(&1.id == conversation_id)) + {:noreply, assign(socket, :conversations, conversations)} + end + end + + def handle_info({:report_submitted, _reportable_type, _reportable_id}, socket) do + {:noreply, + socket + |> assign(:show_report_modal, false) + |> assign(:report_type, nil) + |> assign(:report_id, nil) + |> assign(:report_metadata, %{}) + |> notify_info("Report submitted successfully. Our team will review it shortly.")} + end + + def handle_info(:refresh_conversations, socket) do + # Refresh conversation list after a small delay for database consistency + all_conversations = Messaging.list_chat_conversations(socket.assigns.current_user.id) + # Filter out Timeline and Community channels from chat view + conversations_filtered = + Enum.reject(all_conversations, &(&1.type in ["timeline", "community"])) + + unread_count = Messaging.get_unread_count(socket.assigns.current_user.id) + + unread_counts = + Helpers.calculate_unread_counts(conversations_filtered, socket.assigns.current_user.id) + + # Sort conversations with unread ones first + conversations = + Helpers.sort_conversations_by_unread( + conversations_filtered, + unread_counts, + socket.assigns.current_user.id + ) + + # Calculate read status for last messages + last_message_read_status = + Helpers.calculate_last_message_read_status(conversations, socket.assigns.current_user.id) + + joined_servers = Messaging.list_servers(socket.assigns.current_user.id) + + # Re-filter conversations if search is active + filtered_conversations = + refresh_conversation_filter( + conversations, + socket.assigns.search.conversation_query, + socket.assigns.current_user.id, + socket.assigns[:active_server_id] + ) + + {:noreply, + socket + |> assign(:conversation, %{ + socket.assigns.conversation + | list: conversations, + filtered: filtered_conversations, + unread_count: unread_count, + unread_counts: unread_counts, + last_message_read_status: last_message_read_status + }) + |> assign(:joined_servers, joined_servers) + |> assign(:refresh_conversations_scheduled, false) + |> VoiceChannelOperations.sync_voice_channels(conversations)} + end + + @impl true + def handle_info(:clear_typing, socket) do + # Clear own typing indicator + case socket.assigns.conversation.selected do + nil -> + {:noreply, socket} + + conversation -> + Phoenix.PubSub.broadcast_from( + Elektrine.PubSub, + self(), + "conversation:#{conversation.id}", + {:user_stopped_typing, socket.assigns.current_user.id} + ) + + Elektrine.Messaging.Federation.publish_typing_stopped( + conversation.id, + socket.assigns.current_user.id + ) + + {:noreply, assign(socket, :typing_timer, nil)} + end + end + + def handle_info({:user_typing, user_id, username}, socket) do + # Add user to typing list if not already present + typing_users = socket.assigns.message.typing_users + current_time = System.system_time(:second) + + # Clean up stale typing indicators (older than 5 seconds) + typing_users = + Enum.filter(typing_users, fn u -> + current_time - u.started_at < 5 + end) + + if Enum.any?(typing_users, fn u -> u.id == user_id end) do + # User already in typing list, just update the timestamp + updated_typing = + Enum.map(typing_users, fn u -> + if u.id == user_id do + %{id: user_id, username: username, started_at: current_time} + else + u + end + end) + + {:noreply, + assign(socket, :message, %{socket.assigns.message | typing_users: updated_typing})} + else + # Add new user to typing list + new_typing_user = %{id: user_id, username: username, started_at: current_time} + + {:noreply, + assign(socket, :message, %{ + socket.assigns.message + | typing_users: [new_typing_user | typing_users] + })} + end + end + + def handle_info({:user_stopped_typing, user_id}, socket) do + # Remove user from typing list + typing_users = Enum.reject(socket.assigns.message.typing_users, fn u -> u.id == user_id end) + {:noreply, assign(socket, :message, %{socket.assigns.message | typing_users: typing_users})} + end + + def handle_info({:new_message_notification, message}, socket) do + # Keep this path light: update unread state locally and debounce full refreshes. + if socket.assigns.conversation.selected && + socket.assigns.conversation.selected.id == message.conversation_id do + {:noreply, maybe_schedule_conversation_refresh(socket, 300)} + else + unread_counts = socket.assigns.conversation.unread_counts || %{} + updated_unread_counts = Map.update(unread_counts, message.conversation_id, 1, &(&1 + 1)) + + socket = + socket + |> assign(:conversation, %{ + socket.assigns.conversation + | unread_count: (socket.assigns.conversation.unread_count || 0) + 1, + unread_counts: updated_unread_counts + }) + |> update_conversation_preview(message) + |> maybe_schedule_conversation_refresh(500) + + {:noreply, socket} + end + end + + @impl true + def handle_info({:new_email, message}, socket) do + # Handle email notifications while in chat + payload = + if private_email?(message) do + %{ + from: "Encrypted sender", + subject: "Encrypted message" + } + else + %{ + from: extract_email_address(message.from || "Unknown"), + subject: String.slice(message.subject || "No Subject", 0, 100) + } + end + + socket = push_event(socket, "new_email", payload) + + {:noreply, socket} + end + + @impl true + # Chat message handlers (from ChatMessages module for DMs/groups/channels) + def handle_info({:new_chat_message, message}, socket) do + if socket.assigns.conversation.selected && + socket.assigns.conversation.selected.id == message.conversation_id do + if Enum.any?(socket.assigns.messages, &(&1.id == message.id)) do + {:noreply, socket} + else + # ChatMessage is already decrypted from the broadcast + messages = Helpers.dedupe_messages(socket.assigns.messages ++ [message]) + + # Update read status for the new message + current_read_status = socket.assigns.message.read_status || %{} + updated_read_status = Map.put(current_read_status, message.id, []) + + # If this is not my message, mark it as read immediately + if message.sender_id != socket.assigns.current_user.id do + user_id = socket.assigns.current_user.id + + if read_status_saved?( + Messaging.mark_chat_messages_read(message.conversation_id, user_id, message.id) + ) do + broadcast_chat_read_status(message.conversation_id, user_id) + end + end + + updated_unread_counts = + Map.put(socket.assigns.conversation.unread_counts || %{}, message.conversation_id, 0) + + updated_socket = + socket + |> assign(:messages, messages) + |> assign(:message, %{socket.assigns.message | read_status: updated_read_status}) + |> assign(:conversation, %{ + socket.assigns.conversation + | unread_counts: updated_unread_counts + }) + |> update_conversation_preview(message) + + # Scroll to bottom + {:noreply, push_event(updated_socket, "scroll_to_bottom", %{})} + end + else + {:noreply, maybe_schedule_conversation_refresh(socket, 200)} + end + end + + def handle_info(:room_presence_heartbeat, socket) do + {:noreply, refresh_room_presence_tracking(socket)} + end + + def handle_info(:resume_active_call, socket) do + {:noreply, maybe_resume_active_call(socket)} + end + + def handle_info(info, socket) do + case ThreadOperations.route_info(info, socket) do + {:handled, result} -> + result + + :unhandled -> + case MessageInfoOperations.route_info(info, socket) do + {:handled, result} -> + result + + :unhandled -> + case CallInfoOperations.route_info(info, socket) do + {:handled, result} -> + result + + :unhandled -> + case VoiceChannelOperations.route_info(info, socket) do + {:handled, result} -> result + :unhandled -> {:noreply, socket} + end + end + end + end + end + + defp chat_overlay_panels(assigns) do + ~H""" + + <%= if @ui.show_server_modal do %> + + <% end %> + + + <%= if @ui.show_category_modal do %> + + <% end %> + + + <%= if @ui.show_settings_modal && @conversation.selected do %> + + <% end %> + + + <%= if @ui.show_edit_modal && @conversation.selected do %> + + <% end %> + + + <%= if @ui.show_add_members_modal && @conversation.selected do %> + + <% end %> + + + <%= if @ui.show_message_search_modal && @conversation.selected do %> + + <% end %> + + + <%= if @context_menu.conversation do %> +
+ <% member = + Enum.find( + @context_menu.conversation.members, + &(&1.user_id == @current_user.id and is_nil(&1.left_at)) + ) %> + + + <%= if member && member.pinned do %> + + <% else %> + + <% end %> + + + + +
+ + + + + + <%= if @context_menu.conversation.type != "dm" do %> + + <% end %> +
+ <% end %> + + + <%= if @context_menu.message do %> +
+ <%= if @context_menu.selected_text do %> + + <% end %> + + + + + + + + <%= if @conversation.selected && @conversation.selected.type == "channel" && @context_menu.message.message_type != "system" do %> + <% existing_thread = thread_for_message(@threads, @context_menu.message.id) %> + <%= if existing_thread do %> + + <% else %> + + <% end %> + <% end %> + + + <%= if Map.get(@context_menu.message, :is_pinned, false) do %> + + <% else %> + + <% end %> + + <%= if @context_menu.message.sender_id == @current_user.id do %> +
+ + + + <% end %> + + <%= if @current_user.is_admin do %> +
+ +
+ Admin Actions +
+ + + + + <%= if is_integer(@context_menu.message.sender_id) do %> + + + + + + <% end %> + <% end %> +
+ <% end %> + + + <%= if @ui.show_profile_modal && @profile_user do %> + + <% end %> + + + <%= if @ui.show_browse_modal do %> + + <% end %> + + + <%= if assigns[:show_member_management] && @ui.show_member_management do %> + + <% end %> + + + <%= if assigns[:show_moderation_log] && @ui.show_moderation_log do %> + + <% end %> + + + <%= if @show_report_modal do %> + <.live_component + module={Elektrine.Components.ReportModal} + id="report-modal" + reporter_id={@current_user.id} + reportable_type={@report_type} + reportable_id={@report_id} + additional_metadata={@report_metadata} + /> + <% end %> + + <%= if @call.incoming_call do %> + <.incoming_call_modal call={@call.incoming_call} show={@ui.show_incoming_call} /> + <% end %> + + <%= if @call.active_call do %> + <.active_call_overlay + call={@call.active_call} + show={true} + audio_enabled={@call.audio_enabled} + video_enabled={@call.video_enabled} + call_status={@call.status} + is_caller={@call.active_call.caller_id == @current_user.id} + /> + <% end %> + + <.image_modal + show={@show_image_modal} + image_url={@modal_image_url} + images={@modal_images} + image_index={@modal_image_index} + post={nil} + timezone={@timezone} + time_format={@time_format} + current_user={nil} + is_liked={false} + like_count={nil} + /> + """ + end + + @impl true + def terminate(_reason, socket) do + # Clean up any active calls when user disconnects + if socket.assigns.call && socket.assigns.call.active_call do + case socket.assigns.call.active_call do + %{source: :federated, id: session_id} -> + VoiceCalls.end_session(session_id, socket.assigns.current_user.id, "disconnected") + Elektrine.Messaging.Federation.publish_dm_call_end(session_id) + + %{id: call_id} -> + Calls.end_call(call_id) + end + end + + :ok + end + + defp maybe_schedule_conversation_refresh(socket, delay_ms) do + if socket.assigns[:refresh_conversations_scheduled] do + socket + else + Process.send_after(self(), :refresh_conversations, delay_ms) + assign(socket, :refresh_conversations_scheduled, true) + end + end + + # Voice channels have no text timeline; they are joined from the sidebar + # instead of being opened as a conversation. + defp open_conversation_or_redirect(socket, %{type: "voice_channel"}) do + socket + |> notify_info("Voice channels are joined from the sidebar") + |> push_navigate(to: Elektrine.Paths.chat_root_path()) + end + + defp open_conversation_or_redirect(socket, conversation), + do: open_conversation(socket, conversation) + + defp can_write_to_conversation?(conversation_id, user_id) do + RoomACL.authorize_local_user_action(conversation_id, user_id, :write) == :ok + end + + defp open_conversation(socket, conversation) do + conversation_id = conversation.id + active_server_id = conversation_server_id(conversation) || socket.assigns[:active_server_id] + + federation_presence = + presence_map_for_conversation(conversation, socket.assigns.current_user.id) + + current_unread_counts = socket.assigns.conversation.unread_counts || %{} + updated_unread_counts = Map.put(current_unread_counts, conversation_id, 0) + + current_member = + Enum.find( + conversation.members, + &(&1.user_id == socket.assigns.current_user.id and is_nil(&1.left_at)) + ) + + can_send = + current_member && + Elektrine.Social.ConversationMember.can_send_messages?(current_member) && + can_write_to_conversation?(conversation_id, socket.assigns.current_user.id) + + updated_socket = + socket + |> maybe_update_conversation_subscriptions(conversation_id) + |> assign(:conversation, %{ + socket.assigns.conversation + | selected: conversation, + unread_counts: updated_unread_counts, + filtered: + refresh_conversation_filter( + socket.assigns.conversation.list, + socket.assigns.search.conversation_query, + socket.assigns.current_user.id, + active_server_id + ) + }) + |> assign(:active_server_id, active_server_id) + |> assign(:server_categories, Helpers.server_categories(active_server_id)) + |> assign(:federation_presence, federation_presence) + |> assign( + :chat_e2ee_devices, + Messaging.list_chat_encryption_devices_for_conversation(conversation_id) + ) + |> assign(:messages, []) + |> assign(:pinned_messages, Messaging.list_pinned_chat_messages(conversation_id)) + |> assign(:threads, %ArblargWeb.ChatLive.State.Threads{ + list: + if(conversation.type == "channel", + do: Messaging.list_chat_threads(conversation_id), + else: [] + ) + }) + |> assign(:message, %{ + socket.assigns.message + | read_status: %{}, + typing_users: [] + }) + |> assign(:moderation, %{ + socket.assigns.moderation + | user_timeout_status: %{} + }) + |> assign(:can_send_messages, can_send) + |> assign(:first_unread_message_id, nil) + |> assign(:typing_timer, nil) + |> assign(:has_more_older_messages, false) + |> assign(:has_more_newer_messages, false) + |> assign(:oldest_message_id, nil) + |> assign(:newest_message_id, nil) + |> assign(:loading_older_messages, false) + |> assign(:loading_newer_messages, false) + |> assign(:initial_messages_loading, true) + + updated_socket = + if connected?(updated_socket) do + send(self(), {:load_conversation_messages, conversation_id}) + refresh_room_presence_tracking(updated_socket) + else + updated_socket + end + + updated_socket + end + + defp maybe_update_conversation_subscriptions(socket, conversation_id) do + if connected?(socket) do + previous_conversation_id = + socket.assigns.conversation.selected && socket.assigns.conversation.selected.id + + if is_integer(previous_conversation_id) and previous_conversation_id != conversation_id do + Phoenix.PubSub.unsubscribe(Elektrine.PubSub, "conversation:#{previous_conversation_id}") + Phoenix.PubSub.unsubscribe(Elektrine.PubSub, "chat:#{previous_conversation_id}") + end + + if previous_conversation_id != conversation_id do + Phoenix.PubSub.subscribe(Elektrine.PubSub, "conversation:#{conversation_id}") + Phoenix.PubSub.subscribe(Elektrine.PubSub, "chat:#{conversation_id}") + end + end + + socket + end + + # Group messages that share an author when they arrive close together, so + # follow-up lines can drop the repeated avatar/name header. A new header is + # forced by a date change, a different sender, a system message, or a gap + # longer than @message_group_gap_seconds. + @message_group_gap_seconds 300 + + defp messages_with_date_separators(messages) when is_list(messages) do + {entries, _prev} = + Enum.reduce(messages, {[], nil}, fn message, {acc, previous} -> + current_date = message_inserted_date(message) + + show_separator = + is_nil(previous) or Date.compare(current_date, previous.date) != :eq + + grouped = + not show_separator and not is_nil(previous) and + message.message_type != "system" and previous.type != "system" and + message_sender_key(message) == previous.key and + within_group_gap?(message, previous.inserted_at) + + current = %{ + date: current_date, + key: message_sender_key(message), + type: message.message_type, + inserted_at: message.inserted_at + } + + {[{message, show_separator, grouped} | acc], current} + end) + + Enum.reverse(entries) + end + + defp messages_with_date_separators(_), do: [] + + # Stable per-author key: local users key by id, federated senders by handle. + defp message_sender_key(message) do + case local_sender_id(message) do + id when is_integer(id) -> {:local, id} + _ -> {:remote, message_sender_name(message)} + end + end + + defp within_group_gap?(%{inserted_at: current}, previous) + when not is_nil(current) and not is_nil(previous) do + case {to_naive(current), to_naive(previous)} do + {%NaiveDateTime{} = c, %NaiveDateTime{} = p} -> + diff = NaiveDateTime.diff(c, p) + diff >= 0 and diff <= @message_group_gap_seconds + + _ -> + false + end + end + + defp within_group_gap?(_, _), do: false + + defp to_naive(%NaiveDateTime{} = naive), do: naive + defp to_naive(%DateTime{} = datetime), do: DateTime.to_naive(datetime) + defp to_naive(_), do: nil + + defp read_status_saved?(:ok), do: true + defp read_status_saved?({:ok, _}), do: true + defp read_status_saved?(_), do: false + + defp broadcast_chat_read_status(conversation_id, user_id) do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{conversation_id}", + {:user_read_messages, user_id} + ) + + conversation_id + |> Messaging.get_conversation_members() + |> Enum.each(fn member -> + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{member.user_id}", + {:user_read_messages_in_conversation, + %{conversation_id: conversation_id, reader_id: user_id}} + ) + end) + end + + defp message_inserted_date(%{inserted_at: %NaiveDateTime{} = inserted_at}), + do: NaiveDateTime.to_date(inserted_at) + + defp message_inserted_date(%{inserted_at: %DateTime{} = inserted_at}), + do: DateTime.to_date(inserted_at) + + defp message_inserted_date(_), do: Date.utc_today() + + defp update_conversation_preview(socket, message) do + conversations = socket.assigns.conversation.list + + {updated_conversations, conversation_found?} = + Enum.map_reduce(conversations, false, fn conversation, found? -> + if conversation.id == message.conversation_id do + last_message_at = + case message.inserted_at do + %DateTime{} = datetime -> datetime + %NaiveDateTime{} = naive_datetime -> DateTime.from_naive!(naive_datetime, "Etc/UTC") + _ -> conversation.last_message_at + end + + {%{conversation | messages: [message], last_message_at: last_message_at}, true} + else + {conversation, found?} + end + end) + + if conversation_found? do + sorted_conversations = + Helpers.sort_conversations_by_unread( + updated_conversations, + socket.assigns.conversation.unread_counts || %{}, + socket.assigns.current_user.id + ) + + assign(socket, :conversation, %{ + socket.assigns.conversation + | list: sorted_conversations, + filtered: + refresh_conversation_filter( + sorted_conversations, + socket.assigns.search.conversation_query, + socket.assigns.current_user.id, + socket.assigns[:active_server_id] + ) + }) + else + socket + end + end + + defp refresh_conversation_filter(conversations, query, current_user_id, active_server_id) do + scoped_conversations = Helpers.scope_conversations_to_server(conversations, active_server_id) + + case String.trim(query || "") do + "" -> + scoped_conversations + + search_query -> + Helpers.filter_conversations(scoped_conversations, search_query, current_user_id) + end + end + + defp get_cached_conversations(user_id) do + case Elektrine.AppCache.get_conversations(user_id, fn -> + all_conversations = Messaging.list_chat_conversations(user_id) + Enum.reject(all_conversations, &(&1.type in ["timeline", "community"])) + end) do + {:ok, conversations} -> + conversations + + {:error, reason} -> + Logger.warning( + "failed to fetch cached conversations for user #{user_id}: #{inspect(reason)}" + ) + + all_conversations = Messaging.list_chat_conversations(user_id) + Enum.reject(all_conversations, &(&1.type in ["timeline", "community"])) + end + end + + defp get_cached_unread_count(user_id) do + case Elektrine.AppCache.get_chat_unread_count(user_id, fn -> + Messaging.get_unread_count(user_id) + end) do + {:ok, unread_count} -> + unread_count + + {:error, reason} -> + Logger.warning( + "failed to fetch cached unread count for user #{user_id}: #{inspect(reason)}" + ) + + Messaging.get_unread_count(user_id) + end + end + + defp build_federation_preview do + config = Application.get_env(:elektrine, :messaging_federation, []) + + %{ + enabled: Keyword.get(config, :enabled, false), + relay_operator: + config + |> Keyword.get(:official_relay_operator, "Community-operated") + |> normalize_relay_operator_label(), + official_relays: + config + |> Keyword.get(:official_relays, []) + |> Enum.map(&normalize_official_relay/1) + |> Enum.reject(&is_nil/1) + } + end + + defp normalize_official_relay(relay) when is_binary(relay) do + case String.trim(relay) do + "" -> nil + url -> %{url: url, name: nil} + end + end + + defp normalize_official_relay(relay) when is_map(relay) do + url = relay[:url] || relay["url"] + name = relay[:name] || relay["name"] + + if Elektrine.Strings.present?(url) do + normalized_name = + if Elektrine.Strings.present?(name) do + String.trim(name) + else + nil + end + + %{url: String.trim(url), name: normalized_name} + else + nil + end + end + + defp normalize_official_relay(_), do: nil + + defp normalize_relay_operator_label(value) do + value + |> to_string() + |> String.trim() + |> case do + "" -> + "Community-operated" + + label -> + label + end + end + + # Delegate helper functions for use in templates + defdelegate conversation_name(conversation, current_user_id), to: Helpers + defdelegate format_duration(seconds), to: Helpers + defdelegate popular_emojis(), to: Helpers + defdelegate conversation_admin?(conversation, user), to: Helpers + defdelegate format_reactions(reactions), to: Helpers + defdelegate user_reacted?(reactions, emoji, user_id), to: Helpers + defdelegate linkify_urls(text), to: Helpers + defdelegate render_reaction_emoji(emoji), to: Helpers + defdelegate thread_for_message(threads, message_id), to: ThreadOperations + + defp route_label(conversation, current_user_id) do + name = Helpers.conversation_name(conversation, current_user_id) |> to_string() + + case conversation do + %{type: "channel"} -> + name + |> String.trim() + |> case do + "" -> "#channel" + "#" <> _ = prefixed -> prefixed + trimmed -> "#" <> trimmed + end + + _ -> + name + end + end + + defp conversation_type_label("group"), do: "Group" + defp conversation_type_label("channel"), do: "Channel" + defp conversation_type_label("dm"), do: "Direct Message" + + defp conversation_type_label(type) when is_binary(type), + do: type |> String.replace("_", " ") |> String.capitalize() + + defp conversation_type_label(_), do: "Chat" + + defp conversation_type_label_lower(type), do: conversation_type_label(type) |> String.downcase() + + defp remote_conversation?(conversation) when is_map(conversation) do + Map.get(conversation, :is_federated_mirror, false) || + Messaging.remote_dm_conversation?(conversation) + end + + defp remote_conversation?(_), do: false + + # Helper to get display content for either Message or ChatMessage structs + # Use __struct__ field matching to avoid cyclic dependency issues at compile time + defp message_display_content(%{__struct__: Elektrine.Social.Message} = msg), + do: Message.display_content(msg) + + defp message_display_content(%{__struct__: Elektrine.Messaging.ChatMessage} = msg), + do: ChatMessage.display_content(msg) + + defp message_display_content(message) when is_map(message) do + content = + message + |> map_message_value(:content) + |> fallback_message_text(message) + |> normalize_message_text() + + if content != "", do: content, else: fallback_message_label(message) + end + + defp fallback_message_text(nil, message), do: map_message_value(message, :body) + defp fallback_message_text("", message), do: map_message_value(message, :body) + defp fallback_message_text(content, _message), do: content + + defp fallback_message_label(message) do + client_encrypted_payload = map_message_value(message, :client_encrypted_payload) + message_type = map_message_value(message, :message_type) + media_urls = map_message_value(message, :media_urls) || [] + + cond do + is_map(client_encrypted_payload) -> + "Encrypted message" + + message_type == "voice" -> + "Voice message" + + message_type == "image" -> + "Photo" + + message_type == "file" -> + "File" + + message_type == "system" -> + "[System message]" + + is_list(media_urls) and media_urls != [] -> + "[Attachment]" + + true -> + "" + end + end + + defp normalize_message_text(nil), do: "" + + defp normalize_message_text(text) when is_binary(text) do + text + |> String.trim() + end + + defp normalize_message_text(text) when is_atom(text), do: Atom.to_string(text) + defp normalize_message_text(text) when is_integer(text), do: Integer.to_string(text) + defp normalize_message_text(text) when is_float(text), do: Float.to_string(text) + defp normalize_message_text(_), do: "" + + defp map_message_value(map, key) when is_map(map) do + Map.get(map, key) || Map.get(map, Atom.to_string(key)) + end + + defp message_sender(message) when is_map(message) do + case Map.get(message, :sender) do + %Ecto.Association.NotLoaded{} -> + %{} + + sender when is_map(sender) -> + sender + + _ -> + %{} + end + end + + defp message_sender(_), do: %{} + + defp sender_name(sender) when is_map(sender), do: HandleFormatter.handle(sender) + + defp message_sender_name(message), do: message |> message_sender() |> sender_name() + + defp message_sender_tag(message), do: "@" <> message_sender_name(message) + + defp user_at_handle(user), do: HandleFormatter.at_handle(user) + defp user_domain(user), do: HandleFormatter.domain(user) + + # Only federated senders should surface an @domain suffix; local users are + # implied to be on this instance, so the suffix is just noise for them. + defp sender_federated?(message) do + user = message_sender(message) + user != %{} and user_domain(user) != HandleFormatter.local_domain() + end + + # Id of the current user's most recent non-system message, used to anchor the + # single "Seen by" receipt instead of a per-message tick. + defp last_own_message_id(messages, user_id) when is_list(messages) do + messages + |> Enum.reduce(nil, fn message, acc -> + if message.sender_id == user_id and message.message_type != "system" do + message.id + else + acc + end + end) + end + + defp last_own_message_id(_, _), do: nil + + defp local_sender_id(message) when is_map(message) do + case Map.get(message, :sender_id) do + id when is_integer(id) -> id + _ -> nil + end + end + + defp local_sender_id(_), do: nil + + defp local_sender?(message), do: is_integer(local_sender_id(message)) + + defp sender_timeout?(message, timeout_status) when is_map(timeout_status) do + case local_sender_id(message) do + id when is_integer(id) -> Map.get(timeout_status, id, false) + _ -> false + end + end + + defp sender_timeout?(_message, _timeout_status), do: false + + defp sender_loaded?(%Ecto.Association.NotLoaded{}), do: false + defp sender_loaded?(nil), do: false + defp sender_loaded?(sender) when is_map(sender), do: true + defp sender_loaded?(_), do: false + + # Load custom emojis that are visible in the picker + defp load_custom_emojis do + Elektrine.Emojis.list_picker_emojis() + end + + defp filter_custom_emojis(emojis, query) when is_binary(query) and query != "" do + query = String.downcase(query) + Enum.filter(emojis, fn e -> String.contains?(String.downcase(e.shortcode), query) end) + end + + defp filter_custom_emojis(emojis, _), do: emojis + + defp selected_server_id(socket) do + case socket.assigns.conversation.selected do + %{server_id: server_id} when is_integer(server_id) -> + server_id + + _ -> + case socket.assigns[:active_server_id] do + server_id when is_integer(server_id) -> server_id + _ -> nil + end + end + end + + defp conversation_server_id(%{server_id: server_id}) when is_integer(server_id), do: server_id + defp conversation_server_id(_), do: nil + + defp presence_map_for_conversation(%{type: "channel", id: conversation_id}, user_id) + when is_integer(conversation_id) and is_integer(user_id) do + conversation_id + |> Messaging.list_visible_room_presence_states(user_id) + |> Map.new(fn state -> {state.remote_actor_id, state} end) + end + + defp presence_map_for_conversation(_, _), do: %{} + + defp remote_presence_online_count(presence_map) when is_map(presence_map) do + presence_map + |> Map.values() + |> Enum.count(fn state -> + status = state[:status] || state["status"] + status in ["online", "idle", "dnd"] + end) + end + + defp remote_presence_online_count(_), do: 0 + + defp refresh_room_presence_tracking(socket) do + socket = cancel_room_presence_timer(socket) + + case socket.assigns.conversation.selected do + %{type: "channel", id: conversation_id} when is_integer(conversation_id) -> + publish_room_presence(conversation_id, socket.assigns.current_user.id, socket) + + timer_ref = + Process.send_after(self(), :room_presence_heartbeat, @room_presence_heartbeat_ms) + + assign(socket, :room_presence_timer_ref, timer_ref) + + _ -> + assign(socket, :room_presence_timer_ref, nil) + end + end + + defp cancel_room_presence_timer(socket) do + case socket.assigns[:room_presence_timer_ref] do + timer_ref when is_reference(timer_ref) -> + Process.cancel_timer(timer_ref) + assign(socket, :room_presence_timer_ref, nil) + + _ -> + assign(socket, :room_presence_timer_ref, nil) + end + end + + defp publish_room_presence(conversation_id, user_id, _socket) + when is_integer(conversation_id) and is_integer(user_id) do + Elektrine.Messaging.Federation.publish_room_presence_update( + conversation_id, + user_id, + "online", + [] + ) + end + + defp normalize_optional_text(nil), do: nil + + defp normalize_optional_text(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp normalize_optional_text(_), do: nil + + defp restore_active_call_state(socket, user_id) when is_integer(user_id) do + case latest_active_call_for_user(user_id) do + nil -> + socket + + {:local, %{} = call} -> + assign(socket, :call, %{ + socket.assigns.call + | active_call: call, + incoming_call: nil, + status: call_status(call), + audio_enabled: true, + video_enabled: call.call_type == "video" + }) + + {:federated, %{} = session} -> + call = VoiceCalls.ui_call(session) + + assign(socket, :call, %{ + socket.assigns.call + | active_call: call, + incoming_call: nil, + status: call_status(call), + audio_enabled: true, + video_enabled: call.call_type == "video" + }) + end + end + + defp latest_active_call_for_user(user_id) do + local_call = Calls.get_active_call(user_id) + federated_call = VoiceCalls.get_active_session_for_local_user(user_id) + + case {local_call, federated_call} do + {nil, nil} -> + nil + + {%{} = local, nil} -> + {:local, local} + + {nil, %{} = federated} -> + {:federated, federated} + + {%{} = local, %{} = federated} -> + if DateTime.compare(naive_or_utc(local.updated_at), naive_or_utc(federated.updated_at)) in [ + :gt, + :eq + ] do + {:local, local} + else + {:federated, federated} + end + end + end + + defp maybe_resume_active_call(socket) do + case socket.assigns.call.active_call do + nil -> + socket + + %{} = call -> + transport = CallTransport.descriptor_for_user(socket.assigns.current_user.id, call.id) + + push_event(socket, "resume_call", %{ + call_id: call.id, + call_type: call.call_type, + ice_servers: transport["ice_servers"], + transport: transport, + user_token: socket.assigns.user_token, + user_id: socket.assigns.current_user.id, + initiator: call_initiator?(call, socket.assigns.current_user.id) + }) + end + end + + defp call_initiator?(%{source: :federated, caller_id: caller_id}, current_user_id) + when is_integer(caller_id) and is_integer(current_user_id), + do: caller_id == current_user_id + + defp call_initiator?(%{caller_id: caller_id}, current_user_id) + when is_integer(caller_id) and is_integer(current_user_id), + do: caller_id == current_user_id + + defp call_initiator?(_call, _current_user_id), do: false + + defp call_status(%{status: status}) when status in ["initiated", "ringing"], do: "connecting" + defp call_status(%{status: "active"}), do: "connected" + defp call_status(%{status: status}) when is_binary(status), do: status + defp call_status(_call), do: "connecting" + + defp naive_or_utc(%NaiveDateTime{} = value), do: DateTime.from_naive!(value, "Etc/UTC") + defp naive_or_utc(%DateTime{} = value), do: value + + defp parse_checkbox_value(value) when is_list(value) do + Enum.any?(value, &parse_checkbox_value/1) + end + + defp parse_checkbox_value(value) do + value in [true, "true", "on", "1", 1] + end + + defp consume_entity_image_upload(socket, upload_name) do + upload_results = + consume_uploaded_entries(socket, upload_name, fn %{path: path}, entry -> + upload = %Plug.Upload{ + path: path, + content_type: entry.client_type, + filename: entry.client_name + } + + case Uploads.upload_avatar(upload, socket.assigns.current_user.id) do + {:ok, metadata} -> + {:ok, %{ok: true, url: Uploads.avatar_url(metadata.key)}} + + {:error, reason} -> + {:ok, %{ok: false, reason: reason}} + end + end) + + case Enum.find(upload_results, &(!&1.ok)) do + %{reason: reason} -> + {:error, upload_error_message(reason)} + + nil -> + uploaded_url = + upload_results + |> Enum.find_value(fn + %{ok: true, url: url} when is_binary(url) and url != "" -> url + _ -> nil + end) + + {:ok, uploaded_url} + end + end + + defp clear_upload_entries(socket, upload_name) do + refs = + case socket.assigns[:uploads] && socket.assigns.uploads[upload_name] do + %{entries: entries} when is_list(entries) -> Enum.map(entries, & &1.ref) + _ -> [] + end + + Enum.reduce(refs, socket, fn ref, acc -> + cancel_upload(acc, upload_name, ref) + end) + end + + defp upload_error_message({_, message}) when is_binary(message), do: message + + defp upload_error_message(reason) do + "Image upload failed: #{inspect(reason)}" + end + + defp maybe_add_remote_dm_search_result(results, query) + when is_list(results) and is_binary(query) do + case normalize_remote_dm_handle_query(query) do + {:ok, remote_handle} -> + already_present? = + Enum.any?(results, fn user -> + user_handle = Map.get(user, :handle) || Map.get(user, "handle") + String.downcase(to_string(user_handle || "")) == remote_handle + end) + + if already_present? do + results + else + [remote_dm_search_result(remote_handle) | results] + end + + :error -> + results + end + end + + defp maybe_add_remote_dm_search_result(results, _query), do: results + + defp start_local_dm(socket, user_id) do + current_user_id = socket.assigns.current_user.id + + case Messaging.create_dm_conversation(current_user_id, user_id) do + {:ok, conversation} -> + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_new_chat, false)) + |> assign(:search, %{socket.assigns.search | query: "", results: []}) + |> push_navigate(to: Elektrine.Paths.chat_path(conversation))} + + {:error, :rate_limited} -> + {:noreply, + socket + |> notify_error( + "You are creating too many conversations. Please wait a moment and try again." + ) + |> assign(:ui, Map.put(socket.assigns.ui, :show_profile_modal, false))} + + {:error, reason} -> + error_message = Elektrine.Privacy.privacy_error_message(reason) + + {:noreply, + socket + |> notify_error(error_message) + |> assign(:ui, Map.put(socket.assigns.ui, :show_profile_modal, false))} + end + end + + defp parse_positive_int(value) do + case Integer.parse(to_string(value)) do + {int, ""} when int > 0 -> {:ok, int} + _ -> :error + end + end + + defp parse_optional_positive_int(value) do + case parse_positive_int(value) do + {:ok, int} -> int + :error -> nil + end + end + + defp normalize_remote_dm_handle_query(handle) when is_binary(handle) do + normalized = + handle + |> String.trim() + |> String.trim_leading("@") + |> String.downcase() + + case Regex.run(~r/^([a-z0-9_]{1,64})@([a-z0-9.-]+\.[a-z]{2,})$/, normalized) do + [_, username, domain] -> {:ok, "#{username}@#{domain}"} + _ -> :error + end + end + + defp remote_dm_search_result(remote_handle) do + [username, _domain] = String.split(remote_handle, "@", parts: 2) + + %{ + id: nil, + username: username, + handle: remote_handle, + display_name: "@#{remote_handle}", + avatar: nil, + remote_handle: remote_handle + } + end + + defp social_link_preview_success?(preview) do + social_link_preview?(preview) and Map.get(preview, :status) == "success" + end + + defp social_link_preview?(%{__struct__: :"Elixir.Elektrine.Social.LinkPreview"}), do: true + + defp social_link_preview?(_), do: false + + @doc false + def safe_chat_image_url(url) when is_binary(url) do + trimmed = String.trim(url) + + cond do + trimmed == "" -> + nil + + Regex.match?(~r/[\x00-\x1F\x7F]/, trimmed) -> + nil + + String.starts_with?(trimmed, "uploads/") -> + "/" <> trimmed + + String.starts_with?(trimmed, ["/uploads/", "/api/private-attachments/"]) -> + trimmed + + true -> + ElektrineWeb.HtmlHelpers.safe_external_image_url(trimmed) + end + end + + def safe_chat_image_url(_), do: nil + + defp private_email?(%{client_encrypted_payload: payload}) when is_map(payload), do: true + defp private_email?(_message), do: false + + defp extract_email_address(email_string) when is_binary(email_string) do + case Regex.run(~r/<([^>]+)>/, email_string) do + [_, email] -> String.trim(email) + nil -> String.trim(email_string) + end + end + + defp extract_email_address(_email_string), do: "Unknown" + + defp get_emojis_for_category("Smileys"), + do: + ~w(😀 😃 😄 😁 😆 😅 🤣 😂 🙂 🙃 😉 😊 😇 🥰 😍 🤩 😘 😗 ☺️ 😚 😙 🥲 😋 😛 😜 🤪 😝 🤑 🤗 🤭 🤫 🤔 🤐 🤨 😐 😑 😶 😏 😒 🙄 😬 🤥 😌 😔 😪 🤤 😴 😷) + + defp get_emojis_for_category("Gestures"), + do: ~w(👋 🤚 🖐️ ✋ 🖖 👌 🤌 🤏 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 🖕 👇 ☝️ 👍 👎 ✊ 👊 🤛 🤜 👏 🙌 👐 🤲 🤝 🙏 ✍️ 💪) + + defp get_emojis_for_category("Hearts"), do: ~w(❤️ 🧡 💛 💚 💙 💜 🖤 🤍 🤎 💔 ❣️ 💕 💞 💓 💗 💖 💘 💝 💟 ♥️) + + defp get_emojis_for_category("Animals"), + do: ~w(🐶 🐱 🐭 🐹 🐰 🦊 🐻 🐼 🐨 🐯 🦁 🐮 🐷 🐸 🐵 🙈 🙉 🙊 🐒 🐔 🐧 🐦 🐤 🐣 🐥 🦆 🦅 🦉 🦇 🐺 🐗 🐴 🦄 🐝) + + defp get_emojis_for_category("Food"), + do: ~w(🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🍓 🫐 🍈 🍒 🍑 🥭 🍍 🥥 🥝 🍅 🍆 🥑 🥦 🥬 🥒 🌽 🥕 🥔 🍠 🥐 🥖 🍞 🥨 🍳 🥚 🧀) + + defp get_emojis_for_category(_), do: Helpers.popular_emojis() +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/index.html.heex b/apps/arblarg/lib/arblarg_web/live/chat_live/index.html.heex new file mode 100644 index 0000000..99c1eed --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/index.html.heex @@ -0,0 +1,2873 @@ +<% active_server_id = + case @active_server_id do + server_id when is_integer(server_id) -> + server_id + + _ -> + case @conversation.selected do + %{server_id: server_id} when is_integer(server_id) -> server_id + _ -> nil + end + end %> +<% active_server = + if is_integer(active_server_id) do + Enum.find(@joined_servers, &(&1.id == active_server_id)) + else + nil + end %> +<% chat_e2ee_member_ids = + case @conversation.selected do + %{members: members, type: type} when type in ["dm", "group"] -> + members + |> Enum.filter(&is_nil(&1.left_at)) + |> Enum.map(& &1.user_id) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + + _ -> + [] + end %> +<% chat_e2ee_devices = @chat_e2ee_devices || [] %> +<% chat_e2ee_device_user_ids = + chat_e2ee_devices + |> Enum.map(& &1.user_id) + |> Enum.uniq() %> +<% chat_e2ee_remote_devices = Enum.filter(chat_e2ee_devices, &Map.get(&1, :recipient_handle)) %> +<% chat_e2ee_local_ready = + @conversation.selected && @conversation.selected.type in ["dm", "group"] && + is_nil(@conversation.selected.federated_source) && chat_e2ee_member_ids != [] && + Enum.all?(chat_e2ee_member_ids, &(&1 in chat_e2ee_device_user_ids)) %> +<% chat_e2ee_remote_dm_ready = + @conversation.selected && @conversation.selected.type == "dm" && + is_binary(@conversation.selected.federated_source) && + @current_user && @current_user.id in chat_e2ee_device_user_ids && + chat_e2ee_remote_devices != [] %> +<% chat_e2ee_ready = + (chat_e2ee_local_ready || chat_e2ee_remote_dm_ready) && chat_e2ee_devices != [] && + length(chat_e2ee_devices) <= 64 %> +<% chat_e2ee_status = + cond do + is_nil(@conversation.selected) -> + "no_conversation" + + @conversation.selected.type not in ["dm", "group"] -> + "not_applicable" + + length(chat_e2ee_devices) > 64 -> + "too_many_devices" + + @current_user && @current_user.id not in chat_e2ee_device_user_ids -> + "registering_device" + + @conversation.selected.type == "dm" && is_binary(@conversation.selected.federated_source) && + chat_e2ee_remote_devices == [] -> + "waiting_for_remote_keys" + + is_binary(@conversation.selected.federated_source) -> + "ready" + + not Enum.all?(chat_e2ee_member_ids, &(&1 in chat_e2ee_device_user_ids)) -> + "waiting_for_member_keys" + + true -> + "ready" + end %> +<% chat_overlay_active = + @show_image_modal || @show_report_modal || @show_mobile_search || @context_menu.conversation || + @context_menu.message || + Enum.any?( + [ + :show_new_chat, + :show_create_group, + :show_create_channel, + :show_group_modal, + :show_channel_modal, + :show_server_modal, + :show_browse_channels, + :show_settings_modal, + :show_edit_modal, + :show_add_members_modal, + :show_message_search_modal, + :show_emoji_picker, + :show_profile_modal, + :show_member_management, + :show_moderation_log, + :show_browse_modal + ], + &Map.get(@ui, &1) + ) %> +
+ + <.e_nav + active_tab="chat" + class="mb-3 lg:mb-6" + current_user={@current_user} + badge_counts={@e_nav_badge_counts} + /> + + + + + + + + +
+ + + + +
+
+ +
+
+ + + +
+ + +
+
+ +
+
+ +
+ + Viewing: {active_server.name} + +
+
+ + +
+ +
+ <.icon name="hero-plus" class="w-4 h-4 text-secondary" /> +
+

New

+ <.icon + name="hero-chevron-down" + class="w-4 h-4 text-base-content/50 transition-transform group-open/new:rotate-180" + /> +
+
+ + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+ + + <%= if @search.conversation_query != "" and @search.user_results != [] do %> +
+
+ +
+ <%= for user <- @search.user_results do %> + <% is_remote_user = + is_binary(Map.get(user, :remote_handle)) and Map.get(user, :remote_handle) != "" %> + + <% end %> +
+ <% end %> + + + <%= if @search.conversation_query != "" and @public_server_search_results != [] do %> +
+
+ +
+ <%= for server <- @public_server_search_results do %> + <% server_is_remote = + is_binary(server.origin_domain) and server.origin_domain != "" %> + + <% end %> +
+ <% end %> + + + <%= if @search.conversation_query != "" and @public_group_search_results != [] do %> +
+
+ +
+ <%= for group <- @public_group_search_results do %> + + <% end %> +
+ <% end %> + + + <%= if @loading_conversations do %> +
+ <%= for _i <- 1..5 do %> +
+
+
+
+
+
+
+
+ <% end %> +
+ <% else %> + <%= for entry <- + Helpers.sidebar_entries( + @conversation.filtered, + @server_categories, + @collapsed_category_ids, + active_server_id + ) do %> + <%= case entry do %> + <% {:category, category, collapsed?} -> %> + + <% {:conversation, %{type: "voice_channel"} = conversation} -> %> + <% voice_occupants = Map.get(@voice.occupants, conversation.id, []) %> + <% voice_joined = @voice.joined_id == conversation.id %> +
+ + <%= if voice_occupants != [] do %> +
+ <%= for occupant <- voice_occupants do %> +
+ + {String.first(occupant.display_name || occupant.username || "?")} + + + {occupant.display_name || occupant.username} + + <%= if occupant.muted do %> + <.icon + name="hero-speaker-x-mark" + class="w-3 h-3 text-error flex-shrink-0" + /> + <% end %> +
+ <% end %> +
+ <% end %> +
+ <% {:conversation, conversation} -> %> + <% is_selected = + @conversation.selected && @conversation.selected.id == conversation.id %> + <% remote_conversation = remote_conversation?(conversation) %> + <% unread_count = Map.get(@conversation.unread_counts, conversation.id, 0) %> +
+ <.link + patch={Elektrine.Paths.chat_path(conversation)} + class={[ + "flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-all relative outline-none focus:outline-none focus-visible:outline-none active:opacity-90 select-none [&:active]:!outline-none [&:focus]:!outline-none border-b overflow-visible", + if(is_selected, + do: "bg-secondary/10 text-base-content border-secondary/30", + else: + if(unread_count > 0, + do: + "bg-base-200/70 hover:bg-base-200 border-base-300 text-base-content", + else: "hover:bg-base-200 border-base-300 text-base-content" + ) + ) + ]} + > + + +
+
+

0, + do: "font-semibold text-sm truncate text-base-content", + else: "font-medium text-sm truncate text-base-content" + ) + ) + }> + {route_label(conversation, @current_user.id)} +

+ +
+ <%= if unread_count > 0 do %> +
+ {unread_count} +
+ <% end %> + <%= if conversation.last_message_at do %> + + <.local_time + datetime={conversation.last_message_at} + format="relative" + timezone={@timezone} + time_format={@time_format} + /> + + <% end %> +
+
+ + <%= if remote_conversation do %> +
+ + From another server + +
+ <% end %> + + <%= if conversation.messages != [] do %> + <% last_message = List.first(conversation.messages) %> + <% last_message_encrypted_payload = + Map.get(last_message, :client_encrypted_payload) || + Map.get(last_message, "client_encrypted_payload") %> + <%= if last_message.sender_id == @current_user.id do %> + <% read_status = + Map.get( + @conversation.last_message_read_status || %{}, + conversation.id + ) %> +
0, + do: + "text-xs sm:text-sm text-base-content flex items-center gap-1 min-w-0 font-medium", + else: + "text-xs sm:text-sm text-base-content/70 flex items-center gap-1 min-w-0" + ) + ) + }> + <%= if read_status && read_status.is_read do %> + + ✓✓ + + <% else %> + + ✓ + + <% end %> + + You: + <%= if last_message_encrypted_payload do %> + + Encrypted message + + <% else %> + {message_display_content(last_message)} + <% end %> + +
+ <% else %> +

0, + do: + "text-xs sm:text-sm text-base-content truncate font-medium", + else: "text-xs sm:text-sm text-base-content/70 truncate" + ) + ) + }> + <%= if last_message_encrypted_payload do %> + + Encrypted message + + <% else %> + {message_display_content(last_message)} + <% end %> +

+ <% end %> + <% else %> +

+ No messages yet +

+ <% end %> +
+ + + + + <% member = + Enum.find( + conversation.members, + &(&1.user_id == @current_user.id and is_nil(&1.left_at)) + ) %> + <%= if member && member.pinned do %> +
+ <.icon + name="hero-bookmark-solid" + class={ + if(is_selected, + do: "w-3 h-3 text-accent", + else: "w-3 h-3 text-error" + ) + } + /> +
+ <% end %> + +
+ <% end %> + <% end %> + <% end %> + + + <%= if !@loading_conversations && @conversation.filtered == [] do %> +
+ <%= if @search.conversation_query != "" do %> + <.icon + name="hero-magnifying-glass" + class="w-12 h-12 mx-auto text-base-content/70 mb-4" + /> +

No chats found

+

Try a different search term

+ <% else %> + <.icon + name="hero-chat-bubble-left-right" + class="w-12 h-12 mx-auto text-base-content/70 mb-4" + /> +

No chats yet

+

+ Start a direct message, create a group, or make a channel +

+ <% end %> +
+ <% end %> +
+
+
+ + +
+ <%= if @conversation.selected do %> + +
+
+ + + + + +
+

+ {route_label(@conversation.selected, @current_user.id)} +

+ +
+ + <%= if @conversation.selected.type == "dm" do %> + <% other_user = + Enum.find(@conversation.selected.members, fn member -> + member.user_id != @current_user.id and is_nil(member.left_at) + end) %> + <% remote_handle = Messaging.remote_dm_handle(@conversation.selected) %> + <%= if (other_user && other_user.user) || is_binary(remote_handle) do %> + <.call_buttons + user={other_user && other_user.user} + conversation_id={@conversation.selected.id} + remote_handle={remote_handle} + /> + <% end %> + <% end %> + + <%= if @conversation.selected.type == "channel" do %> + + <% end %> + + +
+
+ + + <%= if @threads.show_panel && @conversation.selected.type == "channel" do %> +
+
+ <%= if @threads.active do %> + +
+

{@threads.active.title}

+

+ {@threads.active.message_count} {if @threads.active.message_count == 1, + do: "reply", + else: "replies"} + <%= if @threads.active.archived_at do %> + · archived + <% end %> +

+
+ <%= if @threads.active.archived_at do %> + + <% else %> + + <% end %> + <% else %> + <.icon name="hero-chat-bubble-left-right" class="w-4 h-4 text-secondary" /> +

Threads

+ <% end %> + +
+ + <%= if @threads.active do %> + +
+ <% root = @threads.active.root_message %> + <% thread_entries = + if(root && is_nil(root.deleted_at), do: [{root, true}], else: []) ++ + Enum.map(@threads.messages, &{&1, false}) %> + <%= for {message, is_root} <- thread_entries do %> +
+
+ <.user_avatar user={message_sender(message)} size="xs" /> +
+
+
+ {message_sender_name(message)} + <.sender_badge sender={message_sender(message)} /> + + <%= if is_root do %> + original message + <% end %> +
+

+ {Phoenix.HTML.raw(linkify_urls(message_display_content(message) || ""))} +

+
+
+ <% end %> + <%= if @threads.messages == [] do %> +

+ No replies yet. Start the thread below. +

+ <% end %> +
+ + <%= if is_nil(@threads.active.archived_at) do %> +
+ + +
+ <% else %> +
+ This thread is archived. +
+ <% end %> + <% else %> + +
+ <%= if @threads.list == [] do %> +

+ No active threads. Use a message's context menu to start one. +

+ <% end %> + <%= for thread <- @threads.list do %> + + <% end %> + + + <%= if @threads.show_archived do %> + <%= if @threads.archived == [] do %> +

No archived threads.

+ <% end %> + <%= for thread <- @threads.archived do %> + + <% end %> + <% end %> +
+ <% end %> +
+ <% end %> + + +
+ + <%= if @loading_older_messages do %> +
+ <.spinner size="sm" /> + Loading older messages... +
+ <% end %> + + + <%= if @has_more_older_messages && !@loading_older_messages do %> +
+ +
+ <% end %> + + <%= if @initial_messages_loading do %> +
+ <.spinner size="sm" /> + Loading messages... +
+ <% end %> + + + <%= if @pinned_messages != [] do %> +
+ + <%= if @ui.show_pinned_panel do %> +
+ <%= for message <- @pinned_messages do %> +
+ + +
+ <% end %> +
+ <% end %> +
+ <% end %> + + <% last_own_message_id = last_own_message_id(@messages, @current_user.id) %> + <%= for {message, show_date_separator, grouped_with_previous} <- messages_with_date_separators(@messages) do %> + <% grouped = grouped_with_previous and message.id != @first_unread_message_id %> + + <%= if show_date_separator do %> +
+
+ + <% message_date = message_inserted_date(message) %> + <%= if Date.compare(message_date, Date.utc_today()) == :eq do %> + Today + <% else %> + <%= if Date.diff(Date.utc_today(), message_date) == 1 do %> + Yesterday + <% else %> + <.local_time + datetime={message.inserted_at} + format="date" + timezone={@timezone} + time_format={@time_format} + /> + <% end %> + <% end %> + +
+
+ <% end %> + + + <%= if @first_unread_message_id && message.id == @first_unread_message_id do %> +
+
+ New messages +
+
+ <% end %> + + <%!-- System messages (call logs, etc.) --%> + <%= if message.message_type == "system" do %> +
+
+ <%= if message.media_metadata["call_type"] do %> + <.icon + name={ + if message.media_metadata["call_type"] == "video", + do: "hero-video-camera", + else: "hero-phone" + } + class="w-3 h-3" + /> + <% end %> + {message.content} +
+
+ <% else %> + <%!-- Regular user messages --%> +
+ +
+ <%= if grouped do %> + + <% else %> + <%= if local_sender?(message) do %> + + <% else %> +
+
+ <.user_avatar user={message_sender(message)} size="sm" /> +
+
+ <% end %> + <% end %> +
+ + +
+ + <%= unless grouped do %> +
+ <%= if @conversation.selected.type != "dm" or message.sender_id != @current_user.id do %> + <%= if local_sender?(message) do %> + + <% else %> + {message_sender_tag(message)} + <% end %> + <.sender_badge sender={message_sender(message)} /> + <%= if sender_timeout?(message, @moderation.user_timeout_status) do %> + + <.icon name="hero-clock" class="w-2 h-2 mr-1" /> Timed Out + + <% end %> + <% end %> + + <%= if message.edited_at do %> + edited + <% end %> +
+ <% end %> + + +
+ + <%= if message.reply_to && sender_loaded?(message.reply_to.sender) do %> + <% reply_content = message_display_content(message.reply_to) || "" %> + + <% end %> + + + <%= if Map.get(message, :is_pinned, false) do %> +
+ <.icon name="hero-bookmark-solid" class="w-3 h-3" /> + Pinned +
+ <% end %> + + + <% client_encrypted_payload = + Map.get(message, :client_encrypted_payload) || + Map.get(message, "client_encrypted_payload") %> + <%= if client_encrypted_payload do %> +

+ Decrypting encrypted message... +

+ <% end %> + <%= if is_nil(client_encrypted_payload) && message.content && message.content != "" do %> +

+ {Phoenix.HTML.raw(linkify_urls(message_display_content(message)))} +

+ <% end %> + + + <%= if Map.get(message, :shared_message_id) && Map.get(message, :shared_message) do %> + <.embedded_post + message={message} + shared_message={message.shared_message} + class="mt-2" + /> + <% end %> + + + <%= if message.message_type == "voice" && message.media_urls && message.media_urls != [] do %> +
+
+ <.icon + name="hero-microphone" + class="w-5 h-5 text-secondary flex-shrink-0" + /> + + <%= if message.media_metadata["duration"] do %> + + {div(message.media_metadata["duration"], 60)}:{rem( + message.media_metadata["duration"], + 60 + ) + |> Integer.to_string() + |> String.pad_leading(2, "0")} + + <% end %> +
+
+ <% end %> + + + <%= if message.message_type != "voice" && message.media_urls && message.media_urls != [] do %> + <% uploaded_image_urls = + message.media_urls + |> Enum.filter(&String.match?(&1, ~r/\.(jpg|jpeg|png|gif|webp)$/i)) + |> Enum.map(&Uploads.attachment_url(&1, @conversation.selected)) %> +
+ <%= for media_url <- message.media_urls do %> + <%= if String.match?(media_url, ~r/\.(jpg|jpeg|png|gif|webp)$/i) do %> + <% resolved_media_url = + Uploads.attachment_url(media_url, @conversation.selected) %> + + <% else %> + + <.icon name="hero-document" class="w-5 h-5" /> + + {Path.basename(media_url)} + + <.icon name="hero-arrow-down-tray" class="w-4 h-4 opacity-50" /> + + <% end %> + <% end %> +
+ <% end %> + + + <%= if (message.media_urls == nil || message.media_urls == []) do %> + <% image_urls = + message.content + |> Message.extract_image_urls() + |> ElektrineWeb.HtmlHelpers.safe_external_image_urls() %> + <%= if image_urls != [] do %> +
+ <%= for {image_url, index} <- Enum.with_index(image_urls) do %> + + <% end %> +
+ <% end %> + <% end %> + + + <% youtube_url = Message.extract_youtube_embed_url(message.content) %> + <%= if youtube_url do %> + <.youtube_preview + url={youtube_url} + wrapper_class="mt-2 rounded-lg overflow-hidden" + /> + <% end %> + + + <% link_preview = Map.get(message, :link_preview) %> + <% link_preview_url = + if social_link_preview_success?(link_preview), + do: ElektrineWeb.HtmlHelpers.safe_external_href(link_preview.url) %> + <%= if !youtube_url && link_preview_url do %> + + <%= if preview_image_url = + ElektrineWeb.HtmlHelpers.safe_external_image_url( + link_preview.image_url + ) do %> +
+ {link_preview.title +
+ <% end %> +
+ <%= if link_preview.title do %> +

+ {link_preview.title} +

+ <% end %> + <%= if link_preview.description do %> +

+ {link_preview.description} +

+ <% end %> +
+ <%= if favicon_url = + ElektrineWeb.HtmlHelpers.safe_external_image_url( + link_preview.favicon_url + ) do %> + + <% end %> + + {URI.parse(link_preview_url).host} + +
+
+
+ <% end %> + + + <.content_journey message={message} context="chat" /> + + + <%= if message.reactions != [] do %> +
+ <%= for {emoji, count, users} <- format_reactions(message.reactions) do %> + + <% end %> +
+ <% end %> + + + <%= if @conversation.selected.type == "channel" do %> + <% message_thread = thread_for_message(@threads, message.id) %> + <%= if message_thread do %> + + <% end %> + <% end %> +
+
+ + + +
+ + + <%= if message.sender_id == @current_user.id and message.id == last_own_message_id do %> + <% readers = Map.get(@message.read_status, message.id, []) %> + <%= if readers != [] do %> +
+ Seen by +
+ <%= for reader <- Enum.take(readers, 5) do %> + + {String.first(reader.username || "?")} + + <% end %> +
+ <%= if length(readers) > 5 do %> + +{length(readers) - 5} + <% end %> +
+ <% end %> + <% end %> + <% end %> + <% end %> +
+ + + <%= if @ui.show_emoji_picker do %> +
+ +
+ +
+ + + <%= if @search.emoji_query == "" do %> +
+ <%= if !Enum.empty?(@custom_emojis) do %> + + <% end %> + + + + + +
+ <% end %> + + +
+ <%= if @search.emoji_query != "" do %> + + <%= for emoji <- filter_custom_emojis(@custom_emojis, @search.emoji_query) do %> + + <% end %> + <% else %> + + <%= if @search.emoji_tab == "Custom" do %> + <%= for emoji <- @custom_emojis do %> + + <% end %> + <%= if Enum.empty?(@custom_emojis) do %> +

+ No custom emojis +

+ <% end %> + <% else %> + <%= for emoji <- get_emojis_for_category(@search.emoji_tab) do %> + + <% end %> + <% end %> + <% end %> +
+
+ <% end %> + + +
+
+ + <%= if @message.reply_to && sender_loaded?(@message.reply_to.sender) do %> +
+
+ Replying to + {user_at_handle(@message.reply_to.sender)}: + + {message_display_content(@message.reply_to)} + +
+ +
+ <% end %> + + + + + + <%= if @message.typing_users != [] do %> +
+ + <%= case length(@message.typing_users) do %> + <% 1 -> %> + {hd(@message.typing_users).username} is typing + <% 2 -> %> + {Enum.at(@message.typing_users, 0).username} and {Enum.at( + @message.typing_users, + 1 + ).username} are typing + <% _ -> %> + {Enum.at(@message.typing_users, 0).username} and {length( + @message.typing_users + ) - 1} others are typing + <% end %> + + <.spinner size="sm" variant="dots" /> +
+ <% end %> + + <%= if @conversation.selected.type in ["dm", "group"] do %> +
+ + +
+ <% end %> + + <%= if Map.get(@moderation.user_timeout_status, @current_user.id, false) do %> +
+ <.icon name="hero-clock" class="w-5 h-5 mx-auto mb-2 text-warning" /> +

You are currently timed out

+

+ You cannot send messages or reactions in this chat +

+
+ <% else %> + <.form + for={%{}} + phx-submit="send_message" + phx-change="validate_upload" + class="space-y-2" + data-chat-e2ee-ready={if chat_e2ee_ready, do: "true", else: "false"} + data-message-loading={if @message.loading_messages, do: "true", else: "false"} + data-has-uploads={ + if @uploads.chat_attachments.entries == [], do: "false", else: "true" + } + > + + <%= for entry <- @uploads.chat_attachments.entries do %> +
+
+ <%= if String.starts_with?(entry.client_type, "image/") do %> + <.icon name="hero-photo" class="w-6 h-6" /> + <% else %> + <%= if String.starts_with?(entry.client_type, "audio/") do %> + <.icon name="hero-microphone" class="w-6 h-6" /> + <% else %> + <.icon name="hero-document" class="w-6 h-6" /> + <% end %> + <% end %> +
+
+

{entry.client_name}

+ + +
+ +
+ <% end %> + + +
+ <%= if @can_send_messages do %> + + + + + + + + + + + + + + + + + <% else %> + + <% end %> +
+ <%= if @message.new_message && String.length(@message.new_message) > 3600 do %> + + <% end %> + + <% end %> +
+
+ <% else %> + +
+
+
+ <.icon name="hero-chat-bubble-left-right" class="w-8 h-8" /> +
+

+ Select a chat to start messaging +

+

+ Choose a channel, group, or direct message from the sidebar. +

+
+ + +
+
+
+ <% end %> +
+ + + <%= if @conversation.selected do %> + + <% end %> +
+ + + <%= if @ui.show_new_chat do %> + + <% end %> + + + <%= if @ui.show_group_modal do %> + <.live_component + module={ArblargWeb.ChatLive.Components.GroupModal} + id="group-modal" + search_query={@search.query} + search_results={@search.results} + selected_users={@form.selected_users} + group_name={@form.group_name} + group_description={@form.group_description} + group_is_public={@form.group_is_public} + uploads={@uploads} + /> + <% end %> + + + <%= if @ui.show_channel_modal do %> + <.live_component + module={ArblargWeb.ChatLive.Components.ChannelModal} + id="channel-modal" + uploads={@uploads} + categories={@server_categories} + /> + <% end %> + + <.chat_overlay_panels {assigns} /> + + + <%= if @voice.joined_id do %> +
+ <.icon name="hero-speaker-wave" class="w-5 h-5 text-success flex-shrink-0" /> +
+

+ Voice connected +

+

{@voice.joined_name}

+
+ + +
+ <% end %> +
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/call_info_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/call_info_operations.ex new file mode 100644 index 0000000..d70ea29 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/call_info_operations.ex @@ -0,0 +1,103 @@ +defmodule ArblargWeb.ChatLive.Operations.CallInfoOperations do + @moduledoc false + + import Phoenix.Component, only: [assign: 3] + import Phoenix.LiveView, only: [push_event: 3] + import ElektrineWeb.Live.NotificationHelpers, only: [notify_info: 2] + + @doc false + def route_info(info, socket) do + case info do + {:incoming_call, call} -> {:handled, handle_incoming_call(socket, call)} + {:call_rejected, call} -> {:handled, handle_call_rejected(socket, call)} + {:call_ended, call} -> {:handled, handle_call_ended(socket, call)} + {:call_missed, call} -> {:handled, handle_call_missed(socket, call)} + _ -> :unhandled + end + end + + def handle_incoming_call(socket, call) do + # Deduplicate - ignore if already showing this call + if socket.assigns.call && socket.assigns.call.incoming_call && + socket.assigns.call.incoming_call.id == call.id do + {:noreply, socket} + else + call = + if local_call_with_loaded_users?(call) do + call + else + maybe_reload_local_call(call) + end + + socket = + socket + |> assign(:call, %{socket.assigns.call | incoming_call: call, status: "ringing"}) + |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, true)) + |> push_event("play_incoming_ringtone", %{}) + + {:noreply, socket} + end + end + + def handle_call_rejected(socket, call), + do: handle_call_terminal_event(socket, call, "call_rejected", "Call was rejected") + + def handle_call_ended(socket, call), + do: handle_call_terminal_event(socket, call, "call_ended", "Call ended") + + def handle_call_missed(socket, call), + do: handle_call_terminal_event(socket, call, "call_missed", "Call timed out") + + defp handle_call_terminal_event(socket, call, event_type, message) do + event_key = {event_type, call.id} + + if MapSet.member?(socket.assigns.processed_call_events, event_key) do + {:noreply, socket} + else + has_incoming = + socket.assigns.call && socket.assigns.call.incoming_call && + socket.assigns.call.incoming_call.id == call.id + + has_active = + socket.assigns.call && socket.assigns.call.active_call && + socket.assigns.call.active_call.id == call.id + + processed = MapSet.put(socket.assigns.processed_call_events, event_key) + + if has_incoming || has_active do + cleared_call_state = reset_call_state(socket.assigns.call) + + socket = + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false)) + |> assign(:call, cleared_call_state) + |> assign(:processed_call_events, processed) + |> push_event("stop_ringtone", %{}) + |> notify_info(message) + + {:noreply, socket} + else + {:noreply, assign(socket, :processed_call_events, processed)} + end + end + end + + defp reset_call_state(call_state) do + call_state + |> Map.put(:incoming_call, nil) + |> Map.put(:active_call, nil) + |> Map.put(:status, nil) + |> Map.put(:audio_enabled, true) + |> Map.put(:video_enabled, true) + end + + defp local_call_with_loaded_users?(%{source: :federated}), do: true + + defp local_call_with_loaded_users?(call) do + Ecto.assoc_loaded?(call.caller) and Ecto.assoc_loaded?(call.callee) + end + + defp maybe_reload_local_call(call) do + Elektrine.Calls.get_call_with_users(call.id) || call + end +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/call_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/call_operations.ex new file mode 100644 index 0000000..92dc0e6 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/call_operations.ex @@ -0,0 +1,497 @@ +defmodule ArblargWeb.ChatLive.Operations.CallOperations do + @moduledoc "Handles voice/video call operations: initiate, answer, reject, end, audio/video toggle.\nExtracted from ChatLive.Home.\n" + import Phoenix.LiveView + import Phoenix.Component + import ElektrineWeb.Live.NotificationHelpers + alias ArblargWeb.ChatLive.Operations.Helpers + alias Elektrine.Calls + alias Elektrine.Calls.Transport, as: CallTransport + alias Elektrine.Messaging.Federation + alias Elektrine.Messaging.Federation.VoiceCalls + + def handle_event( + "initiate_call", + %{"call_type" => call_type, "conversation_id" => conversation_id_str} = params, + socket + ) do + case parse_optional_integer(conversation_id_str) do + {:ok, conversation_id} -> + case remote_call_target(params, conversation_id, socket) do + {:remote, remote_handle, remote_conversation_id} -> + initiate_federated_call(socket, remote_handle, remote_conversation_id, call_type) + + :local -> + initiate_local_call(socket, params, conversation_id, call_type) + + {:error, :invalid_remote_handle} -> + {:noreply, notify_error(socket, "Invalid remote call target")} + end + + {:error, :invalid_integer} -> + {:noreply, notify_error(socket, "Invalid call request")} + end + end + + def handle_event("answer_call", %{"call_id" => call_id}, socket) do + case parse_integer(call_id) do + {:ok, call_id} -> + answer_call(socket, call_id) + + {:error, :invalid_integer} -> + {:noreply, notify_error(socket, "Invalid call request")} + end + end + + def handle_event("reject_call", %{"call_id" => call_id}, socket) do + case parse_integer(call_id) do + {:ok, call_id} -> + reject_call(socket, call_id) + + {:error, :invalid_integer} -> + {:noreply, notify_error(socket, "Invalid call request")} + end + end + + def handle_event("end_call", %{"call_id" => call_id}, socket) do + case parse_integer(call_id) do + {:ok, call_id} -> + end_call(socket, call_id) + + {:error, :invalid_integer} -> + {:noreply, notify_error(socket, "Invalid call request")} + end + end + + def handle_event("toggle_audio", _params, socket) do + {:noreply, socket} + end + + def handle_event("toggle_video", _params, socket) do + {:noreply, socket} + end + + def handle_event("audio_toggled", %{"enabled" => enabled}, socket) do + {:noreply, assign(socket, :call, %{socket.assigns.call | audio_enabled: truthy?(enabled)})} + end + + def handle_event("video_toggled", %{"enabled" => enabled}, socket) do + {:noreply, assign(socket, :call, %{socket.assigns.call | video_enabled: truthy?(enabled)})} + end + + def handle_event("call_error", %{"error" => error}, socket) do + maybe_fail_active_call(socket) + + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false)) + |> assign(:call, reset_call_state(socket.assigns.call)) + |> push_event("stop_ringtone", %{}) + |> notify_error("Call error: #{error}")} + end + + def handle_event("call_started", _params, socket) do + {:noreply, assign(socket, :call, %{socket.assigns.call | status: "connecting"})} + end + + def handle_event("remote_stream_ready", _params, socket) do + {:noreply, socket} + end + + def handle_event("call_answered", _params, socket) do + {:noreply, assign(socket, :call, %{socket.assigns.call | status: "connecting"})} + end + + def handle_event("call_connected", _params, socket) do + {:noreply, assign(socket, :call, %{socket.assigns.call | status: "connected"})} + end + + def handle_event("call_ended", params, socket) do + maybe_finish_active_call(socket, params["reason"]) + + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false)) + |> assign(:call, reset_call_state(socket.assigns.call)) + |> push_event("stop_ringtone", %{})} + end + + def handle_event("call_ended_by_user", _params, socket) do + maybe_end_active_call(socket) + + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false)) + |> assign(:call, reset_call_state(socket.assigns.call)) + |> push_event("stop_ringtone", %{})} + end + + def handle_event("call_rejected", _params, socket) do + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false)) + |> assign(:call, reset_call_state(socket.assigns.call)) + |> push_event("stop_ringtone", %{}) + |> notify_info("Call was rejected")} + end + + defp reset_call_state(call_state) do + %{ + call_state + | active_call: nil, + incoming_call: nil, + status: nil, + audio_enabled: true, + video_enabled: true + } + end + + defp initiate_local_call(socket, params, conversation_id, call_type) do + case parse_integer(params["user_id"]) do + {:ok, callee_id} -> + caller_id = socket.assigns.current_user.id + + case VoiceCalls.local_user_busy_reason(caller_id) do + :federated_call_active -> + {:noreply, notify_error(socket, "You're already in another call")} + + _ -> + case Calls.initiate_call(caller_id, callee_id, call_type, conversation_id) do + {:ok, call} -> + full_call = Calls.get_call_with_users(call.id) || call + transport = call_transport(full_call.id, socket) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{full_call.callee_id}", + {:incoming_call, full_call} + ) + + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false)) + |> assign(:call, call_state_for(socket.assigns.call, full_call, "calling")) + |> push_event("start_call", %{ + call_id: full_call.id, + call_type: full_call.call_type, + ice_servers: transport["ice_servers"], + transport: transport, + user_token: user_token(socket), + user_id: caller_id + })} + + {:error, :caller_already_in_call} -> + {:noreply, notify_error(socket, "You're already in a call")} + + {:error, :callee_already_in_call} -> + {:noreply, notify_error(socket, "This user is already in another call")} + + {:error, :rate_limit_exceeded} -> + {:noreply, + notify_error(socket, "You're starting calls too quickly. Try again shortly.")} + + {:error, :invalid_conversation} -> + {:noreply, + notify_error(socket, "Call can only be started from your shared DM conversation")} + + {:error, reason} -> + error_msg = Elektrine.Privacy.privacy_error_message(reason) + {:noreply, notify_error(socket, error_msg)} + end + end + + {:error, :invalid_integer} -> + {:noreply, notify_error(socket, "Invalid call request")} + end + end + + defp initiate_federated_call(socket, _remote_handle, conversation_id, call_type) + when is_integer(conversation_id) do + caller_id = socket.assigns.current_user.id + + with {:ok, session} <- + VoiceCalls.start_outbound_session(caller_id, conversation_id, call_type), + :ok <- Federation.publish_dm_call_invite(session.id) do + full_call = VoiceCalls.ui_call(session) + transport = call_transport(full_call.id, socket) + + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false)) + |> assign(:call, call_state_for(socket.assigns.call, full_call, "calling")) + |> push_event("start_call", %{ + call_id: full_call.id, + call_type: full_call.call_type, + ice_servers: transport["ice_servers"], + transport: transport, + user_token: user_token(socket), + user_id: caller_id + })} + else + {:error, :remote_call_already_active} -> + {:noreply, notify_error(socket, "You already have an active remote call in this DM")} + + {:error, :local_call_already_active} -> + {:noreply, notify_error(socket, "You're already in another call")} + + {:error, :invalid_remote_call} -> + {:noreply, notify_error(socket, "Call can only be started from a federated DM")} + + {:error, reason} -> + {:noreply, notify_error(socket, "Failed to start remote call: #{format_reason(reason)}")} + end + end + + defp answer_call(socket, call_id) do + case current_call_source(socket, call_id) do + :federated -> + case VoiceCalls.accept_session(call_id, socket.assigns.current_user.id) do + {:ok, session} -> + full_call = socket.assigns.call.incoming_call || VoiceCalls.ui_call(session) + transport = call_transport(call_id, socket) + + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false)) + |> assign(:call, call_state_for(socket.assigns.call, full_call, "connecting")) + |> push_event("stop_ringtone", %{}) + |> push_event("answer_call", %{ + call_id: call_id, + ice_servers: transport["ice_servers"], + transport: transport, + user_token: user_token(socket), + user_id: socket.assigns.current_user.id + })} + + {:error, _reason} -> + {:noreply, notify_error(socket, "Failed to answer call")} + end + + _ -> + case Calls.answer_call(call_id) do + {:ok, call} -> + full_call = + socket.assigns.call.incoming_call || socket.assigns.call.active_call || + Calls.get_call_with_users(call.id) + + transport = call_transport(call_id, socket) + + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false)) + |> assign(:call, call_state_for(socket.assigns.call, full_call, "connecting")) + |> push_event("stop_ringtone", %{}) + |> push_event("answer_call", %{ + call_id: call_id, + ice_servers: transport["ice_servers"], + transport: transport, + user_token: user_token(socket), + user_id: socket.assigns.current_user.id + })} + + {:error, _reason} -> + {:noreply, notify_error(socket, "Failed to answer call")} + end + end + end + + defp reject_call(socket, call_id) do + if current_call_source(socket, call_id) == :federated do + _ = VoiceCalls.reject_session(call_id, socket.assigns.current_user.id) + else + _ = Calls.reject_call(call_id) + end + + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false)) + |> assign(:call, reset_call_state(socket.assigns.call)) + |> push_event("stop_ringtone", %{}) + |> push_event("reject_call", %{call_id: call_id, user_token: user_token(socket)})} + end + + defp end_call(socket, call_id) do + if current_call_source(socket, call_id) == :federated do + _ = VoiceCalls.end_session(call_id, socket.assigns.current_user.id) + else + _ = Calls.end_call(call_id) + end + + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false)) + |> assign(:call, reset_call_state(socket.assigns.call)) + |> push_event("stop_ringtone", %{})} + end + + defp call_state_for(current_call_state, call, status) do + %{ + current_call_state + | active_call: call, + incoming_call: nil, + status: status, + audio_enabled: true, + video_enabled: not is_nil(call) and call.call_type == "video" + } + end + + defp current_call_source(socket, call_id) do + active_call = socket.assigns.call.active_call + incoming_call = socket.assigns.call.incoming_call + + cond do + is_map(active_call) and active_call.id == call_id -> + call_source(active_call) + + is_map(incoming_call) and incoming_call.id == call_id -> + call_source(incoming_call) + + match?(%{source: :federated}, active_call) or match?(%{source: :federated}, incoming_call) -> + :federated + + true -> + :local + end + end + + defp call_source(%{source: source}) when not is_nil(source), do: source + defp call_source(_call), do: :local + + defp maybe_fail_active_call(socket) do + case socket.assigns.call.active_call do + %{source: :federated, id: session_id} -> + _ = VoiceCalls.fail_session(session_id, socket.assigns.current_user.id) + _ = Federation.publish_dm_call_end(session_id) + + %{id: call_id} -> + _ = Calls.update_call_status(call_id, "failed") + + _ -> + :ok + end + end + + defp maybe_finish_active_call(socket, reason) do + case normalize_call_end_reason(reason) do + :failed -> maybe_fail_active_call(socket) + :rejected -> maybe_reject_active_call(socket) + :missed -> maybe_miss_active_call(socket) + :ended -> maybe_end_active_call(socket) + end + end + + defp normalize_call_end_reason(reason) when reason in ["failed", "network_offline"], do: :failed + defp normalize_call_end_reason("failed: " <> _reason), do: :failed + defp normalize_call_end_reason("rejected"), do: :rejected + defp normalize_call_end_reason("missed"), do: :missed + defp normalize_call_end_reason(_reason), do: :ended + + defp maybe_reject_active_call(socket) do + case socket.assigns.call.active_call do + %{source: :federated, id: session_id} -> + _ = VoiceCalls.reject_session(session_id, socket.assigns.current_user.id) + + %{id: call_id} -> + _ = Calls.reject_call(call_id) + + _ -> + :ok + end + end + + defp maybe_miss_active_call(socket) do + case socket.assigns.call.active_call do + %{source: :federated, id: session_id} -> + _ = VoiceCalls.end_session(session_id, socket.assigns.current_user.id, "missed") + + %{id: call_id} -> + _ = Calls.miss_call(call_id) + + _ -> + :ok + end + end + + defp maybe_end_active_call(socket) do + case socket.assigns.call.active_call do + %{source: :federated, id: session_id} -> + _ = VoiceCalls.end_session(session_id, socket.assigns.current_user.id) + + %{id: call_id} -> + _ = Calls.end_call(call_id) + + _ -> + :ok + end + end + + defp remote_call_target(params, conversation_id, _socket) do + remote_handle = normalize_remote_handle(params["remote_handle"]) + + cond do + is_binary(remote_handle) and is_integer(conversation_id) -> + {:remote, remote_handle, conversation_id} + + is_binary(remote_handle) -> + {:error, :invalid_remote_handle} + + true -> + :local + end + end + + defp normalize_remote_handle(handle) when is_binary(handle) do + case String.trim(handle) do + "" -> nil + trimmed -> trimmed + end + end + + defp normalize_remote_handle(_handle), do: nil + + defp format_reason(reason) when is_atom(reason), do: Atom.to_string(reason) + defp format_reason(reason) when is_binary(reason), do: reason + defp format_reason(_reason), do: "unknown" + + defp parse_integer(value) when is_binary(value) do + case Integer.parse(value) do + {parsed, ""} -> {:ok, parsed} + _ -> {:error, :invalid_integer} + end + end + + defp parse_integer(value) when is_integer(value) do + {:ok, value} + end + + defp parse_integer(_value) do + {:error, :invalid_integer} + end + + defp parse_optional_integer(nil) do + {:ok, nil} + end + + defp parse_optional_integer("") do + {:ok, nil} + end + + defp parse_optional_integer(value) do + parse_integer(value) + end + + defp truthy?(value) when value in [true, "true", 1, "1"] do + true + end + + defp truthy?(_value) do + false + end + + defp user_token(socket) do + socket.assigns[:user_token] || Helpers.generate_user_token(socket.assigns.current_user.id) + end + + defp call_transport(call_id, socket) when is_integer(call_id) do + CallTransport.descriptor_for_user(socket.assigns.current_user.id, call_id) + end +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/context_menu_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/context_menu_operations.ex new file mode 100644 index 0000000..65014f4 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/context_menu_operations.ex @@ -0,0 +1,102 @@ +defmodule ArblargWeb.ChatLive.Operations.ContextMenuOperations do + @moduledoc """ + Handles context menu operations: show/hide conversation and message context menus. + Extracted from ChatLive.Home. + """ + + import Phoenix.Component + + def handle_event( + "show_context_menu", + %{"conversation_id" => conversation_id, "x" => x, "y" => y}, + socket + ) do + conversation = find_by_id(socket.assigns.conversation.list, conversation_id) + + {:noreply, + assign(socket, :context_menu, %{ + socket.assigns.context_menu + | conversation: conversation, + selected_text: nil, + position: %{x: x, y: y} + })} + end + + def handle_event("show_context_menu", %{"conversation_id" => conversation_id}, socket) do + conversation = find_by_id(socket.assigns.conversation.list, conversation_id) + + {:noreply, + assign(socket, :context_menu, %{ + socket.assigns.context_menu + | conversation: conversation, + selected_text: nil + })} + end + + def handle_event("hide_context_menu", _params, socket) do + {:noreply, + assign(socket, :context_menu, %{ + socket.assigns.context_menu + | conversation: nil, + selected_text: nil + })} + end + + def handle_event( + "show_message_context_menu", + %{"message_id" => message_id, "x" => x, "y" => y} = params, + socket + ) do + message = find_by_id(socket.assigns.messages, message_id) + + {:noreply, + assign(socket, :context_menu, %{ + socket.assigns.context_menu + | message: message, + selected_text: selected_text(params), + position: %{x: x, y: y} + })} + end + + def handle_event("show_message_context_menu", %{"message_id" => message_id}, socket) do + message = find_by_id(socket.assigns.messages, message_id) + + {:noreply, + assign(socket, :context_menu, %{ + socket.assigns.context_menu + | message: message, + selected_text: nil + })} + end + + def handle_event("hide_message_context_menu", _params, socket) do + {:noreply, + assign(socket, :context_menu, %{ + socket.assigns.context_menu + | message: nil, + selected_text: nil + })} + end + + defp find_by_id(items, id) when is_integer(id) do + Enum.find(items, &(&1.id == id)) + end + + defp find_by_id(items, id) do + case Integer.parse(to_string(id)) do + {parsed_id, ""} when parsed_id > 0 -> Enum.find(items, &(&1.id == parsed_id)) + _ -> nil + end + end + + defp selected_text(%{"selected_text" => text}) when is_binary(text) do + text + |> String.trim() + |> case do + "" -> nil + value -> value + end + end + + defp selected_text(_params), do: nil +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/conversation_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/conversation_operations.ex new file mode 100644 index 0000000..342ba43 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/conversation_operations.ex @@ -0,0 +1,417 @@ +defmodule ArblargWeb.ChatLive.Operations.ConversationOperations do + @moduledoc """ + Handles conversation management: selection, search, pin, settings, edit, delete, leave. + Extracted from ChatLive.Home. + """ + + import Phoenix.LiveView + import Phoenix.Component + import ElektrineWeb.Live.NotificationHelpers + + use Phoenix.VerifiedRoutes, + endpoint: ElektrineWeb.Endpoint, + router: ElektrineWeb.Router + + alias Elektrine.Accounts + alias Elektrine.Messaging, as: Messaging + + def handle_event("select_conversation", %{"id" => conversation_id}, socket) do + conversation_ref = + socket.assigns.conversation.list + |> Enum.find(&(Integer.to_string(&1.id) == conversation_id)) + |> case do + nil -> conversation_id + conversation -> conversation + end + + {:noreply, push_patch(socket, to: Elektrine.Paths.chat_path(conversation_ref))} + end + + def handle_event("search_conversations", params, socket) do + query = Map.get(params, "query") || Map.get(params, "value") || "" + + scoped_conversations = + ArblargWeb.ChatLive.Operations.Helpers.scope_conversations_to_server( + socket.assigns.conversation.list, + socket.assigns[:active_server_id] + ) + + filtered = + if query == "" do + scoped_conversations + else + ArblargWeb.ChatLive.Operations.Helpers.filter_conversations( + scoped_conversations, + query, + socket.assigns.current_user.id + ) + end + + # Also search for users and public servers/groups when query is 2+ characters + {user_results, public_server_results, public_group_results} = + if String.length(query) >= 2 do + users = + query + |> Accounts.search_users(socket.assigns.current_user.id) + |> maybe_add_remote_handle_result(query) + + servers = + Messaging.list_public_servers(socket.assigns.current_user.id, query: query, limit: 10) + + public_conversations = + Messaging.search_public_chat_conversations(query, socket.assigns.current_user.id) + + groups = Enum.filter(public_conversations, &(&1.type == "group")) + {users, servers, groups} + else + {[], [], []} + end + + {:noreply, + socket + |> assign(:conversation, %{socket.assigns.conversation | filtered: filtered}) + |> assign(:search, %{ + socket.assigns.search + | conversation_query: query, + user_results: user_results + }) + |> assign(:public_server_search_results, public_server_results) + |> assign(:public_group_search_results, public_group_results)} + end + + def handle_event("clear_selection", _params, socket) do + {:noreply, push_patch(socket, to: Elektrine.Paths.chat_root_path())} + end + + def handle_event("pin_conversation", %{"conversation_id" => conversation_id}, socket) do + user_id = socket.assigns.current_user.id + + with {:ok, conversation_id} <- parse_positive_int(conversation_id), + {:ok, _} <- Messaging.pin_conversation(conversation_id, user_id) do + {:noreply, socket} + else + _ -> {:noreply, notify_error(socket, "Failed to pin chat")} + end + end + + def handle_event("unpin_conversation", %{"conversation_id" => conversation_id}, socket) do + user_id = socket.assigns.current_user.id + + with {:ok, conversation_id} <- parse_positive_int(conversation_id), + {:ok, _} <- Messaging.unpin_conversation(conversation_id, user_id) do + {:noreply, socket} + else + _ -> {:noreply, notify_error(socket, "Failed to unpin chat")} + end + end + + def handle_event("mark_as_read", %{"conversation_id" => conversation_id_str}, socket) do + case parse_positive_int(conversation_id_str) do + {:ok, conversation_id} -> + user_id = socket.assigns.current_user.id + + case Messaging.mark_as_read(conversation_id, user_id) do + {:ok, _} -> + updated_unread_counts = + Map.put(socket.assigns.conversation.unread_counts, conversation_id, 0) + + {:noreply, + socket + |> assign(:conversation, %{ + socket.assigns.conversation + | unread_counts: updated_unread_counts, + unread_count: Messaging.get_unread_count(user_id) + }) + |> assign(:context_menu, %{socket.assigns.context_menu | conversation: nil}) + |> assign(:first_unread_message_id, nil) + |> notify_info("Marked as read")} + + {:error, :unauthorized} -> + {:noreply, + socket + |> assign(:context_menu, %{socket.assigns.context_menu | conversation: nil}) + |> notify_error("You are not a member of this chat")} + + {:error, _} -> + {:noreply, + socket + |> assign(:context_menu, %{socket.assigns.context_menu | conversation: nil}) + |> notify_error("Failed to mark as read")} + end + + :error -> + {:noreply, + socket + |> assign(:context_menu, %{socket.assigns.context_menu | conversation: nil}) + |> notify_error("Failed to mark as read")} + end + end + + def handle_event("clear_history", %{"conversation_id" => conversation_id_str}, socket) do + case parse_positive_int(conversation_id_str) do + {:ok, conversation_id} -> + user_id = socket.assigns.current_user.id + + case Messaging.clear_history_for_user(conversation_id, user_id) do + {:ok, :cleared} -> + selected_conversation = socket.assigns.conversation.selected + + socket = + if selected_conversation && selected_conversation.id == conversation_id do + socket + |> assign(:messages, []) + |> assign(:oldest_message_id, nil) + |> assign(:newest_message_id, nil) + |> assign(:has_more_older_messages, false) + |> assign(:has_more_newer_messages, false) + |> assign(:first_unread_message_id, nil) + else + socket + end + + Process.send_after(self(), :refresh_conversations, 50) + + {:noreply, + socket + |> assign(:context_menu, %{socket.assigns.context_menu | conversation: nil}) + |> notify_info("History cleared")} + + {:error, :unauthorized} -> + {:noreply, + socket + |> assign(:context_menu, %{socket.assigns.context_menu | conversation: nil}) + |> notify_error("You are not a member of this chat")} + + {:error, _} -> + {:noreply, + socket + |> assign(:context_menu, %{socket.assigns.context_menu | conversation: nil}) + |> notify_error("Failed to clear history")} + end + + :error -> + {:noreply, + socket + |> assign(:context_menu, %{socket.assigns.context_menu | conversation: nil}) + |> notify_error("Failed to clear history")} + end + end + + def handle_event("show_settings", _params, socket) do + updated_ui = Map.put(socket.assigns.ui, :show_settings_modal, true) + {:noreply, assign(socket, :ui, updated_ui)} + end + + def handle_event("hide_settings", _params, socket) do + updated_ui = Map.put(socket.assigns.ui, :show_settings_modal, false) + {:noreply, assign(socket, :ui, updated_ui)} + end + + def handle_event("show_edit_conversation", _params, socket) do + updated_ui = Map.put(socket.assigns.ui, :show_edit_modal, true) + {:noreply, assign(socket, :ui, updated_ui)} + end + + def handle_event("hide_edit_conversation", _params, socket) do + updated_ui = Map.put(socket.assigns.ui, :show_edit_modal, false) + {:noreply, assign(socket, :ui, updated_ui)} + end + + def handle_event("update_conversation", params, socket) do + conversation = socket.assigns.conversation.selected + conversation_params = Map.get(params, "conversation", params) + name = Map.get(conversation_params, "name", conversation.name) + description = Map.get(conversation_params, "description", conversation.description) + + visibility_attrs = + cond do + conversation.type == "channel" and is_integer(conversation.server_id) -> + is_private = parse_checkbox_value(Map.get(conversation_params, "is_private")) + %{is_public: !is_private} + + conversation.type in ["channel", "group"] -> + is_public = parse_checkbox_value(Map.get(conversation_params, "is_public")) + %{is_public: is_public} + + true -> + %{} + end + + attrs = + %{ + name: normalize_optional_text(name), + description: normalize_optional_text(description) + } + |> Map.merge(visibility_attrs) + + case Messaging.update_conversation(conversation, attrs) do + {:ok, _} -> + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_edit_modal, false)) + |> notify_info("Chat updated")} + + {:error, _} -> + {:noreply, notify_error(socket, "Failed to update chat")} + end + end + + def handle_event("delete_conversation", %{"conversation_id" => conversation_id}, socket) do + with {:ok, conversation_id} <- parse_positive_int(conversation_id), + {:ok, conversation} <- + Messaging.get_conversation!(conversation_id, socket.assigns.current_user.id), + {:ok, _} <- Messaging.delete_conversation(conversation) do + {:noreply, + socket + |> push_patch(to: Elektrine.Paths.chat_root_path()) + |> notify_info("Chat deleted")} + else + _ -> + {:noreply, notify_error(socket, "Failed to delete chat")} + end + end + + def handle_event("leave_conversation", %{"conversation_id" => conversation_id}, socket) do + case parse_positive_int(conversation_id) do + {:ok, conversation_id} -> do_leave_conversation(conversation_id, socket) + :error -> {:noreply, notify_error(socket, "Failed to leave chat")} + end + end + + def handle_event("leave_conversation", _params, socket) do + # Handle case when triggered without conversation_id (use selected conversation) + case socket.assigns.conversation.selected do + nil -> + {:noreply, notify_error(socket, "No chat selected")} + + conversation -> + do_leave_conversation(conversation.id, socket) + end + end + + def handle_event("view_profile", %{"user_id" => user_id}, socket) do + ElektrineWeb.ProfileNavigation.navigate(socket, %{"user_id" => user_id}) + end + + def handle_event("view_profile", %{"handle" => handle}, socket) do + ElektrineWeb.ProfileNavigation.navigate(socket, %{"handle" => handle}) + end + + def handle_event("share_conversation", _params, socket) do + conversation = socket.assigns.conversation.selected + + share_url = + ElektrineWeb.Endpoint.url() <> + Elektrine.Paths.chat_join_path(conversation.hash || conversation.id) + + {:noreply, + push_event(socket, "copy_to_clipboard", %{text: share_url, type: "chat invite link"})} + end + + def handle_event("show_conversation_info", _params, socket) do + updated_ui = Map.put(socket.assigns.ui, :show_conversation_info, true) + {:noreply, assign(socket, :ui, updated_ui)} + end + + # Private helpers + + defp parse_positive_int(value) when is_integer(value) and value > 0, do: {:ok, value} + + defp parse_positive_int(value) when is_binary(value) do + case Integer.parse(value) do + {id, ""} when id > 0 -> {:ok, id} + _ -> :error + end + end + + defp parse_positive_int(_value), do: :error + + defp do_leave_conversation(conversation_id, socket) do + user_id = socket.assigns.current_user.id + + case Messaging.leave_conversation(conversation_id, user_id) do + {:ok, _} -> + {:noreply, + socket + |> push_patch(to: Elektrine.Paths.chat_root_path()) + |> notify_info("Left chat")} + + {:error, :owner_must_transfer} -> + {:noreply, notify_error(socket, "Transfer ownership before leaving")} + + {:error, :not_a_member} -> + {:noreply, notify_error(socket, "You are not a member of this chat")} + + {:error, _} -> + {:noreply, notify_error(socket, "Failed to leave chat")} + end + end + + defp maybe_add_remote_handle_result(results, query) + when is_list(results) and is_binary(query) do + case normalize_remote_handle(query) do + {:ok, remote_handle} -> + already_present? = + Enum.any?(results, fn user -> + user_handle = Map.get(user, :handle) || Map.get(user, "handle") + String.downcase(to_string(user_handle || "")) == remote_handle + end) + + if already_present? do + results + else + [remote_search_result(remote_handle) | results] + end + + :error -> + results + end + end + + defp maybe_add_remote_handle_result(results, _query), do: results + + defp normalize_remote_handle(handle) when is_binary(handle) do + normalized = + handle + |> String.trim() + |> String.trim_leading("@") + |> String.downcase() + + case Regex.run(~r/^([a-z0-9_]{1,64})@([a-z0-9.-]+\.[a-z]{2,})$/, normalized) do + [_, username, domain] -> {:ok, "#{username}@#{domain}"} + _ -> :error + end + end + + defp remote_search_result(remote_handle) do + [username, _domain] = String.split(remote_handle, "@", parts: 2) + + %{ + id: nil, + username: username, + handle: remote_handle, + display_name: "@#{remote_handle}", + avatar: nil, + remote_handle: remote_handle + } + end + + defp normalize_optional_text(nil), do: nil + + defp normalize_optional_text(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp normalize_optional_text(value), do: value + + defp parse_checkbox_value(value) when is_list(value) do + Enum.any?(value, &parse_checkbox_value/1) + end + + defp parse_checkbox_value(value) do + value in [true, "true", "on", "1", 1] + end +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/direct_message_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/direct_message_operations.ex new file mode 100644 index 0000000..1f70037 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/direct_message_operations.ex @@ -0,0 +1,174 @@ +defmodule ArblargWeb.ChatLive.Operations.DirectMessageOperations do + @moduledoc """ + Handles direct messaging: starting DMs, searching users, blocking. + Extracted from ChatLive.Home. + """ + + import Phoenix.LiveView + import Phoenix.Component + import ElektrineWeb.Live.NotificationHelpers + + use Phoenix.VerifiedRoutes, + endpoint: ElektrineWeb.Endpoint, + router: ElektrineWeb.Router + + alias Elektrine.Accounts + alias Elektrine.Accounts.User + alias Elektrine.Messaging, as: Messaging + alias Elektrine.Repo + + def handle_event("start_dm", %{"remote_handle" => remote_handle}, socket) + when is_binary(remote_handle) and remote_handle != "" do + current_user_id = socket.assigns.current_user.id + + case Messaging.create_remote_dm_conversation(current_user_id, remote_handle) do + {:ok, conversation} -> + {:noreply, + socket + |> assign( + :ui, + Map.merge(socket.assigns.ui, %{show_new_chat: false, show_profile_modal: false}) + ) + |> push_patch(to: Elektrine.Paths.chat_path(conversation))} + + {:error, :invalid_remote_handle} -> + {:noreply, notify_error(socket, "Use handle format user@domain")} + + {:error, :unknown_peer} -> + {:noreply, + notify_error( + socket, + "That domain could not be reached through federation discovery" + )} + + {:error, :rate_limited} -> + {:noreply, notify_error(socket, "You are creating chats too quickly")} + + {:error, _reason} -> + {:noreply, notify_error(socket, "Failed to start chat")} + end + end + + def handle_event("start_dm", %{"user_id" => user_id}, socket) do + with {:ok, user_id} <- parse_positive_int(user_id), + {:ok, conversation} <- + Messaging.create_dm_conversation(socket.assigns.current_user.id, user_id) do + {:noreply, + socket + |> assign( + :ui, + Map.merge(socket.assigns.ui, %{show_new_chat: false, show_profile_modal: false}) + ) + |> push_patch(to: Elektrine.Paths.chat_path(conversation))} + else + _ -> {:noreply, notify_error(socket, "Failed to start chat")} + end + end + + def handle_event("search_users", params, socket) do + query = Map.get(params, "query") || Map.get(params, "value") || "" + + results = + if String.length(query) >= 2 do + query + |> Accounts.search_users(socket.assigns.current_user.id) + |> maybe_add_remote_handle_result(query) + else + [] + end + + {:noreply, assign(socket, :search, %{socket.assigns.search | query: query, results: results})} + end + + def handle_event("block_user", %{"user_id" => user_id}, socket) do + with {:ok, user_id} <- parse_positive_int(user_id), + {:ok, _} <- Accounts.block_user(socket.assigns.current_user.id, user_id) do + {:noreply, notify_info(socket, "User blocked")} + else + _ -> {:noreply, notify_error(socket, "Failed to block user")} + end + end + + def handle_event("unblock_user", %{"user_id" => user_id}, socket) do + with {:ok, user_id} <- parse_positive_int(user_id), + {:ok, _} <- Accounts.unblock_user(socket.assigns.current_user.id, user_id) do + {:noreply, notify_info(socket, "User unblocked")} + else + _ -> {:noreply, notify_error(socket, "Failed to unblock user")} + end + end + + def handle_event("show_user_profile", %{"user_id" => user_id}, socket) do + with {:ok, user_id} <- parse_positive_int(user_id), + %User{} = user <- Repo.get(User, user_id) do + user_with_profile = Repo.preload(user, :profile) + + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_profile_modal, true)) + |> assign(:profile_user, user_with_profile)} + else + _ -> {:noreply, notify_error(socket, "User not found")} + end + end + + def handle_event("hide_profile_modal", _params, socket) do + {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_profile_modal, false))} + end + + defp parse_positive_int(value) do + case Integer.parse(to_string(value)) do + {int, ""} when int > 0 -> {:ok, int} + _ -> :error + end + end + + defp maybe_add_remote_handle_result(results, query) + when is_list(results) and is_binary(query) do + case normalize_remote_handle(query) do + {:ok, remote_handle} -> + already_present? = + Enum.any?(results, fn user -> + user_handle = Map.get(user, :handle) || Map.get(user, "handle") + String.downcase(to_string(user_handle || "")) == remote_handle + end) + + if already_present? do + results + else + [remote_search_result(remote_handle) | results] + end + + :error -> + results + end + end + + defp maybe_add_remote_handle_result(results, _query), do: results + + defp normalize_remote_handle(handle) when is_binary(handle) do + normalized = + handle + |> String.trim() + |> String.trim_leading("@") + |> String.downcase() + + case Regex.run(~r/^([a-z0-9_]{1,64})@([a-z0-9.-]+\.[a-z]{2,})$/, normalized) do + [_, username, domain] -> {:ok, "#{username}@#{domain}"} + _ -> :error + end + end + + defp remote_search_result(remote_handle) do + [username, _domain] = String.split(remote_handle, "@", parts: 2) + + %{ + id: nil, + username: username, + handle: remote_handle, + display_name: "@#{remote_handle}", + avatar: nil, + remote_handle: remote_handle + } + end +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/emoji_gif_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/emoji_gif_operations.ex new file mode 100644 index 0000000..a22fd6f --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/emoji_gif_operations.ex @@ -0,0 +1,43 @@ +defmodule ArblargWeb.ChatLive.Operations.EmojiGifOperations do + @moduledoc """ + Handles emoji picker operations. + Extracted from ChatLive.Home. + """ + + import Phoenix.Component + + def handle_event("toggle_emoji_picker", _params, socket) do + {:noreply, + assign( + socket, + :ui, + Map.put(socket.assigns.ui, :show_emoji_picker, !socket.assigns.ui.show_emoji_picker) + )} + end + + def handle_event("insert_emoji", %{"emoji" => emoji}, socket) do + current_message = socket.assigns.message.new_message + updated_message = "#{current_message}#{emoji}" + + {:noreply, + socket + |> assign(:message, %{socket.assigns.message | new_message: updated_message}) + |> assign(:ui, Map.put(socket.assigns.ui, :show_emoji_picker, false))} + end + + def handle_event("emoji_search", %{"value" => query}, socket) do + {:noreply, assign(socket, :search, %{socket.assigns.search | emoji_query: query})} + end + + def handle_event("emoji_search", %{"emoji_query" => query}, socket) do + {:noreply, assign(socket, :search, %{socket.assigns.search | emoji_query: query})} + end + + def handle_event("emoji_search", %{"value" => query}, socket) do + {:noreply, assign(socket, :search, %{socket.assigns.search | emoji_query: query})} + end + + def handle_event("emoji_tab", %{"tab" => tab}, socket) do + {:noreply, assign(socket, :search, %{socket.assigns.search | emoji_tab: tab})} + end +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/group_channel_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/group_channel_operations.ex new file mode 100644 index 0000000..8e67401 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/group_channel_operations.ex @@ -0,0 +1,672 @@ +defmodule ArblargWeb.ChatLive.Operations.GroupChannelOperations do + @moduledoc """ + Handles group and channel creation, browsing, and joining. + Extracted from ChatLive.Home. + """ + + import Phoenix.LiveView + import Phoenix.Component + import ElektrineWeb.Live.NotificationHelpers + + use Phoenix.VerifiedRoutes, + endpoint: ElektrineWeb.Endpoint, + router: ElektrineWeb.Router + + alias ArblargWeb.ChatLive.Operations.Helpers + alias Elektrine.Messaging, as: Messaging + alias Elektrine.Uploads + + def handle_event("toggle_new_chat", _params, socket) do + show_new_chat = !socket.assigns.ui.show_new_chat + + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_new_chat, show_new_chat)) + |> assign(:search, %{socket.assigns.search | query: "", results: []})} + end + + def handle_event("show_create_group", _params, socket) do + updated_ui = + socket.assigns.ui + |> Map.put(:show_group_modal, true) + |> Map.put(:show_new_chat, false) + + {:noreply, + socket + |> assign(:ui, updated_ui) + |> assign(:form, %{socket.assigns.form | selected_users: []}) + |> assign(:search, %{socket.assigns.search | query: "", results: []})} + end + + def handle_event("show_create_server", _params, socket) do + updated_ui = + socket.assigns.ui + |> Map.put(:show_server_modal, true) + |> Map.put(:show_group_modal, false) + |> Map.put(:show_channel_modal, false) + |> Map.put(:show_browse_modal, false) + |> Map.put(:show_new_chat, false) + + {:noreply, assign(socket, :ui, updated_ui)} + end + + def handle_event("hide_create_server", _params, socket) do + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_server_modal, false)) + |> clear_upload_entries(:server_icon_upload)} + end + + def handle_event("show_create_channel", _params, socket) do + case selected_server_id(socket) do + nil -> + {:noreply, + socket + |> notify_error("Select a server first, then create channels inside it")} + + _server_id -> + {:noreply, + assign( + socket, + :ui, + socket.assigns.ui + |> Map.put(:show_channel_modal, true) + |> Map.put(:show_new_chat, false) + )} + end + end + + def handle_event("show_create_category", _params, socket) do + case selected_server_id(socket) do + nil -> + {:noreply, + socket + |> notify_error("Select a server first, then create categories inside it")} + + _server_id -> + {:noreply, + assign( + socket, + :ui, + socket.assigns.ui + |> Map.put(:show_category_modal, true) + |> Map.put(:show_new_chat, false) + )} + end + end + + def handle_event("hide_create_category", _params, socket) do + {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_category_modal, false))} + end + + def handle_event("create_channel_category", params, socket) do + category_params = Map.get(params, "category", %{}) + name = params["name"] || category_params["name"] + + with server_id when is_integer(server_id) <- selected_server_id(socket), + true <- Elektrine.Strings.present?(name) do + case Messaging.create_channel_category(server_id, socket.assigns.current_user.id, %{ + name: String.trim(name) + }) do + {:ok, _category} -> + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_category_modal, false)) + |> assign(:server_categories, Helpers.server_categories(server_id)) + |> notify_info("Category created!")} + + {:error, :unauthorized} -> + {:noreply, + socket + |> notify_error("You don't have permission to manage channels in this server")} + + {:error, :not_found} -> + {:noreply, notify_error(socket, "Server not found")} + + {:error, _} -> + {:noreply, notify_error(socket, "Failed to create category")} + end + else + nil -> + {:noreply, + notify_error(socket, "Select a server first, then create categories inside it")} + + false -> + {:noreply, notify_error(socket, "Please enter a category name")} + end + end + + def handle_event("toggle_category_collapse", %{"category_id" => category_id}, socket) do + case parse_positive_int(category_id) do + {:ok, category_id} -> + collapsed = socket.assigns.collapsed_category_ids + + updated = + if MapSet.member?(collapsed, category_id) do + MapSet.delete(collapsed, category_id) + else + MapSet.put(collapsed, category_id) + end + + {:noreply, assign(socket, :collapsed_category_ids, updated)} + + :error -> + {:noreply, socket} + end + end + + def handle_event("create_group", params, socket) do + name = params["name"] + selected_users = socket.assigns.form.selected_users + + if not Elektrine.Strings.present?(name) || Enum.empty?(selected_users) do + {:noreply, notify_error(socket, "Please enter a name and select at least one user")} + else + case Messaging.create_chat_group_conversation( + socket.assigns.current_user.id, + %{name: name}, + selected_users + ) do + {:ok, conversation} -> + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_group_modal, false)) + |> push_patch(to: Elektrine.Paths.chat_path(conversation)) + |> notify_info("Chat created!")} + + {:error, _} -> + {:noreply, notify_error(socket, "Failed to create chat")} + end + end + end + + def handle_event("create_server", params, socket) do + server_params = Map.get(params, "server", %{}) + name = Map.get(server_params, "name", "") + description = Map.get(server_params, "description", "") + is_public = parse_checkbox_value(Map.get(server_params, "is_public")) + + with true <- Elektrine.Strings.present?(name), + {:ok, icon_url} <- consume_entity_image_upload(socket, :server_icon_upload) do + attrs = %{ + name: String.trim(name), + description: normalize_optional_text(description), + icon_url: icon_url, + is_public: is_public + } + + case Messaging.create_server(socket.assigns.current_user.id, attrs) do + {:ok, server} -> + joined_servers = Messaging.list_servers(socket.assigns.current_user.id) + server_channel = default_server_channel(server.channels) + + base_socket = + socket + |> assign(:joined_servers, joined_servers) + |> assign(:active_server_id, server.id) + |> assign(:ui, Map.put(socket.assigns.ui, :show_server_modal, false)) + |> notify_info("Server created!") + + case server_channel do + nil -> + {:noreply, push_patch(base_socket, to: Elektrine.Paths.chat_root_path())} + + channel -> + {:noreply, push_patch(base_socket, to: Elektrine.Paths.chat_path(channel))} + end + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, notify_error(socket, first_changeset_error(changeset))} + + {:error, _} -> + {:noreply, notify_error(socket, "Failed to create server")} + end + else + false -> + {:noreply, notify_error(socket, "Please enter a server name")} + + {:error, reason} -> + {:noreply, notify_error(socket, reason)} + end + end + + def handle_event("create_channel", params, socket) do + channel_params = Map.get(params, "channel", %{}) + name = params["name"] || channel_params["name"] + description = params["description"] || channel_params["description"] + topic = params["channel_topic"] || channel_params["channel_topic"] + is_private = parse_checkbox_value(params["is_private"] || channel_params["is_private"]) + + with server_id when is_integer(server_id) <- selected_server_id(socket), + true <- Elektrine.Strings.present?(name) do + attrs = %{ + name: String.trim(name), + description: normalize_optional_text(description), + channel_topic: normalize_optional_text(topic), + is_public: !is_private + } + + case Messaging.create_server_channel(server_id, socket.assigns.current_user.id, attrs) do + {:ok, channel} -> + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_channel_modal, false)) + |> push_patch(to: Elektrine.Paths.chat_path(channel)) + |> notify_info("Channel created!")} + + {:error, :unauthorized} -> + {:noreply, + socket + |> notify_error("You don't have permission to create channels in this server")} + + {:error, :not_found} -> + {:noreply, notify_error(socket, "Server not found")} + + {:error, _} -> + {:noreply, notify_error(socket, "Failed to create channel")} + end + else + nil -> + {:noreply, notify_error(socket, "Select a server first, then create channels inside it")} + + false -> + {:noreply, notify_error(socket, "Please enter a channel name")} + end + end + + def handle_event("cancel_create", _params, socket) do + updated_ui = + socket.assigns.ui + |> Map.put(:show_group_modal, false) + |> Map.put(:show_channel_modal, false) + |> Map.put(:show_category_modal, false) + |> Map.put(:show_server_modal, false) + |> Map.put(:show_new_chat, false) + + {:noreply, + socket + |> assign(:ui, updated_ui) + |> clear_upload_entries(:server_icon_upload) + |> clear_upload_entries(:group_avatar_upload) + |> clear_upload_entries(:channel_avatar_upload) + |> assign(:form, %{socket.assigns.form | selected_users: []})} + end + + def handle_event("toggle_user_selection", %{"user_id" => user_id}, socket) do + case parse_positive_int(user_id) do + {:ok, user_id} -> + selected = socket.assigns.form.selected_users + + updated = + if user_id in selected do + List.delete(selected, user_id) + else + [user_id | selected] + end + + {:noreply, assign(socket, :form, %{socket.assigns.form | selected_users: updated})} + + :error -> + {:noreply, socket} + end + end + + def handle_event("show_browse_modal", _params, socket) do + public_servers = Messaging.list_public_servers(socket.assigns.current_user.id) + public_groups = Messaging.list_chat_public_groups() + + updated_ui = + socket.assigns.ui + |> Map.put(:show_browse_modal, true) + |> Map.put(:show_new_chat, false) + + {:noreply, + socket + |> assign(:ui, updated_ui) + |> assign(:search, %{socket.assigns.search | browse_query: ""}) + |> assign(:browse, %{ + socket.assigns.browse + | tab: "servers", + public_servers: public_servers, + public_channels: [], + public_groups: public_groups, + filtered_servers: public_servers, + filtered_channels: [], + filtered_groups: public_groups + })} + end + + def handle_event("hide_browse_modal", _params, socket) do + {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_browse_modal, false))} + end + + def handle_event("browse_tab", %{"tab" => tab}, socket) do + normalized_tab = if tab in ["servers", "groups"], do: tab, else: "servers" + updated_browse = %{socket.assigns.browse | tab: normalized_tab} + {:noreply, assign(socket, :browse, updated_browse)} + end + + def handle_event("browse_search", %{"search" => query}, socket) do + search_query = String.trim(query || "") + normalized_query = String.downcase(search_query) + + filtered_servers = + if normalized_query == "" do + socket.assigns.browse.public_servers + else + Enum.filter(socket.assigns.browse.public_servers, fn server -> + String.contains?(String.downcase(server.name || ""), normalized_query) || + String.contains?(String.downcase(server.description || ""), normalized_query) || + String.contains?(String.downcase(server.origin_domain || ""), normalized_query) + end) + end + + filtered_groups = + if normalized_query == "" do + socket.assigns.browse.public_groups + else + Enum.filter(socket.assigns.browse.public_groups, fn g -> + String.contains?(String.downcase(g.name || ""), normalized_query) || + String.contains?(String.downcase(g.description || ""), normalized_query) + end) + end + + {:noreply, + socket + |> assign(:search, %{socket.assigns.search | browse_query: search_query}) + |> assign(:browse, %{ + socket.assigns.browse + | filtered_servers: filtered_servers, + filtered_channels: [], + filtered_groups: filtered_groups + })} + end + + def handle_event("join_conversation", %{"conversation_id" => conversation_id}, socket) do + {:noreply, push_patch(socket, to: Elektrine.Paths.chat_join_path(conversation_id))} + end + + def handle_event("join_group", %{"group_id" => group_id}, socket) do + case parse_positive_int(group_id) do + {:ok, conversation_id} -> + case Messaging.join_conversation(conversation_id, socket.assigns.current_user.id) do + {:ok, :pending} -> + {:noreply, notify_info(socket, "Join request sent")} + + {:ok, _} -> + {:noreply, + socket + |> push_patch(to: Elektrine.Paths.chat_path(conversation_id)) + |> notify_info("Joined chat")} + + {:error, _} -> + {:noreply, notify_error(socket, "Failed to join chat")} + end + + :error -> + {:noreply, notify_error(socket, "Failed to join chat")} + end + end + + def handle_event("join_channel", %{"channel_id" => channel_id}, socket) do + _ = channel_id + + {:noreply, + notify_error(socket, "Channels are server-scoped. Join the server to access its channels.")} + end + + def handle_event("filter_server", %{"server_id" => server_id}, socket) do + case parse_server_id(server_id) do + {:ok, parsed_server_id} -> + {:noreply, + socket + |> apply_server_scope(parsed_server_id) + |> push_patch(to: Elektrine.Paths.chat_root_path())} + + :error -> + {:noreply, notify_error(socket, "Invalid server")} + end + end + + def handle_event("select_server", %{"server_id" => server_id}, socket) do + case parse_server_id(server_id) do + {:ok, parsed_server_id} -> + socket = apply_server_scope(socket, parsed_server_id) + + case first_server_channel_identifier(socket.assigns.conversation.list, parsed_server_id) do + nil -> + {:noreply, + socket + |> assign(:conversation, %{socket.assigns.conversation | selected: nil}) + |> push_patch(to: Elektrine.Paths.chat_root_path())} + + conversation_identifier -> + {:noreply, push_patch(socket, to: Elektrine.Paths.chat_path(conversation_identifier))} + end + + :error -> + {:noreply, notify_error(socket, "Invalid server")} + end + end + + def handle_event("clear_server_scope", _params, socket) do + {:noreply, + socket + |> apply_server_scope(nil) + |> push_patch(to: Elektrine.Paths.chat_root_path())} + end + + def handle_event("join_server", %{"server_id" => server_id}, socket) do + current_user_id = socket.assigns.current_user.id + + case parse_server_id(server_id) do + {:ok, parsed_server_id} -> + case Messaging.join_server(parsed_server_id, current_user_id) do + {:ok, _member} -> + navigate_to_joined_server(socket, parsed_server_id, "Joined server") + + {:error, :already_member} -> + navigate_to_joined_server(socket, parsed_server_id, nil) + + {:error, :not_public} -> + {:noreply, notify_error(socket, "This server is private")} + + {:error, :not_found} -> + {:noreply, notify_error(socket, "Server not found")} + + {:error, _reason} -> + {:noreply, notify_error(socket, "Failed to join server")} + end + + :error -> + {:noreply, notify_error(socket, "Invalid server")} + end + end + + defp navigate_to_joined_server(socket, server_id, success_message) do + case Messaging.get_server(server_id, socket.assigns.current_user.id) do + {:ok, server} -> + server_channel = default_server_channel(server.channels) + + cond do + server_channel -> + {:noreply, + socket + |> apply_server_scope(server_id) + |> assign(:ui, Map.put(socket.assigns.ui, :show_browse_modal, false)) + |> maybe_notify_info(success_message) + |> push_patch(to: Elektrine.Paths.chat_path(server_channel))} + + success_message -> + {:noreply, + socket + |> apply_server_scope(server_id) + |> assign(:ui, Map.put(socket.assigns.ui, :show_browse_modal, false)) + |> notify_info(success_message) + |> notify_warning("Joined server, but it has no channels yet") + |> push_patch(to: Elektrine.Paths.chat_root_path())} + + true -> + {:noreply, + socket + |> apply_server_scope(server_id) + |> assign(:ui, Map.put(socket.assigns.ui, :show_browse_modal, false)) + |> notify_warning("This server has no channels yet") + |> push_patch(to: Elektrine.Paths.chat_root_path())} + end + + {:error, :not_found} -> + {:noreply, notify_error(socket, "Server not found")} + end + end + + defp parse_server_id(server_id) when is_integer(server_id) and server_id > 0, + do: {:ok, server_id} + + defp parse_server_id(server_id), do: parse_positive_int(server_id) + + defp parse_positive_int(value) do + case Integer.parse(to_string(value)) do + {int, ""} when int > 0 -> {:ok, int} + _ -> :error + end + end + + defp first_server_channel_identifier(conversations, server_id) + when is_list(conversations) and is_integer(server_id) do + conversations + |> Enum.filter(&(&1.type == "channel" and &1.server_id == server_id)) + |> Enum.sort_by(&{&1.channel_position || 0, &1.id}) + |> List.first() + |> case do + nil -> nil + conversation -> conversation.hash || conversation.id + end + end + + defp first_server_channel_identifier(_conversations, _server_id), do: nil + + defp default_server_channel(channels) when is_list(channels) do + channels + |> Enum.filter(&(&1.type == "channel")) + |> List.first() + end + + defp default_server_channel(_), do: nil + + defp maybe_notify_info(socket, nil), do: socket + defp maybe_notify_info(socket, message), do: notify_info(socket, message) + + defp apply_server_scope(socket, active_server_id) do + scoped_conversations = + Helpers.scope_conversations_to_server(socket.assigns.conversation.list, active_server_id) + + filtered_conversations = + case String.trim(socket.assigns.search.conversation_query || "") do + "" -> + scoped_conversations + + query -> + Helpers.filter_conversations( + scoped_conversations, + query, + socket.assigns.current_user.id + ) + end + + socket + |> assign(:active_server_id, active_server_id) + |> assign(:server_categories, Helpers.server_categories(active_server_id)) + |> assign(:conversation, %{socket.assigns.conversation | filtered: filtered_conversations}) + end + + defp selected_server_id(socket) do + case socket.assigns.conversation.selected do + %{server_id: server_id} when is_integer(server_id) -> server_id + _ -> socket.assigns[:active_server_id] + end + end + + defp normalize_optional_text(nil), do: nil + + defp normalize_optional_text(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp normalize_optional_text(_), do: nil + + defp parse_checkbox_value(value) when is_list(value) do + Enum.any?(value, &parse_checkbox_value/1) + end + + defp parse_checkbox_value(value) do + value in [true, "true", "on", "1", 1] + end + + defp first_changeset_error(%Ecto.Changeset{} = changeset) do + case changeset.errors do + [{_field, {message, opts}} | _] -> + Enum.reduce(opts, message, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + + _ -> + "Failed to create server" + end + end + + defp consume_entity_image_upload(socket, upload_name) do + upload_results = + consume_uploaded_entries(socket, upload_name, fn %{path: path}, entry -> + upload = %Plug.Upload{ + path: path, + content_type: entry.client_type, + filename: entry.client_name + } + + case Uploads.upload_avatar(upload, socket.assigns.current_user.id) do + {:ok, metadata} -> + {:ok, %{ok: true, url: Uploads.avatar_url(metadata.key)}} + + {:error, reason} -> + {:ok, %{ok: false, reason: reason}} + end + end) + + case Enum.find(upload_results, &(!&1.ok)) do + %{reason: reason} -> + {:error, upload_error_message(reason)} + + nil -> + uploaded_url = + upload_results + |> Enum.find_value(fn + %{ok: true, url: url} when is_binary(url) and url != "" -> url + _ -> nil + end) + + {:ok, uploaded_url} + end + end + + defp clear_upload_entries(socket, upload_name) do + refs = + case socket.assigns[:uploads] && socket.assigns.uploads[upload_name] do + %{entries: entries} when is_list(entries) -> Enum.map(entries, & &1.ref) + _ -> [] + end + + Enum.reduce(refs, socket, fn ref, acc -> + cancel_upload(acc, upload_name, ref) + end) + end + + defp upload_error_message({_, message}) when is_binary(message), do: message + + defp upload_error_message(reason) do + "Image upload failed: #{inspect(reason)}" + end +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/helpers.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/helpers.ex new file mode 100644 index 0000000..9200d6a --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/helpers.ex @@ -0,0 +1,757 @@ +defmodule ArblargWeb.ChatLive.Operations.Helpers do + @moduledoc """ + Helper functions for ChatLive module. + Contains utility functions for formatting, filtering, and calculating various states. + """ + + alias Elektrine.Messaging, as: Messaging + alias Elektrine.Messaging.ChatMessage + alias Elektrine.Social.{Conversation, Message} + + @doc """ + Get the display name for a conversation based on the current user. + """ + def conversation_name(conversation, current_user_id) do + Conversation.display_name(conversation, current_user_id) + end + + @doc """ + Format reactions for display, grouping by emoji. + """ + def format_reactions(%Ecto.Association.NotLoaded{}), do: [] + + def format_reactions(reactions) when is_list(reactions) do + reactions + |> Enum.group_by(& &1.emoji) + |> Enum.map(fn {emoji, grouped_reactions} -> + users = Enum.map(grouped_reactions, &reaction_actor_label/1) + {emoji, length(grouped_reactions), users} + end) + end + + def format_reactions(_), do: [] + + @doc """ + Renders reaction text safely before replacing known custom emoji shortcodes. + """ + def render_reaction_emoji(emoji) when is_binary(emoji) do + emoji + |> Phoenix.HTML.html_escape() + |> Phoenix.HTML.safe_to_string() + |> ElektrineWeb.HtmlHelpers.render_custom_emojis() + end + + def render_reaction_emoji(_), do: "" + + @doc """ + Removes duplicate messages while preserving the first occurrence order. + """ + def dedupe_messages(messages) when is_list(messages) do + Enum.uniq_by(messages, & &1.id) + end + + def dedupe_messages(_), do: [] + + @doc """ + Check if a specific user reacted to a message with a specific emoji. + """ + def user_reacted?(%Ecto.Association.NotLoaded{}, _emoji, _user_id), do: false + + def user_reacted?(reactions, emoji, user_id) when is_list(reactions) do + Enum.any?(reactions, fn reaction -> + reaction.emoji == emoji and reaction.user_id == user_id + end) + end + + def user_reacted?(_, _emoji, _user_id), do: false + + @doc """ + Filter conversations based on search query. + Searches by conversation name, last message content, and member usernames. + """ + def filter_conversations(conversations, query, current_user_id) do + search_term = String.downcase(String.trim(query)) + + Enum.filter(conversations, fn conversation -> + # Search by conversation name + name_match = + conversation + |> conversation_name(current_user_id) + |> String.downcase() + |> String.contains?(search_term) + + # Search by last message content + message_match = + case conversation.messages do + [last_message | _] -> + last_message + |> message_display_content() + |> String.downcase() + |> String.contains?(search_term) + + [] -> + false + end + + # Search by member usernames (for groups/channels) + member_match = + if conversation.type != "dm" do + Enum.any?(conversation.members || [], fn member -> + if member.left_at == nil and member.user do + username_match = + (member.user.handle || member.user.username) + |> String.downcase() + |> String.contains?(search_term) + + display_name_match = + if member.user.display_name do + member.user.display_name + |> String.downcase() + |> String.contains?(search_term) + else + false + end + + username_match or display_name_match + else + false + end + end) + else + false + end + + name_match or message_match or member_match + end) + end + + @doc """ + Scope visible channel conversations to the currently selected server. + DMs and group conversations stay visible across servers. + """ + def scope_conversations_to_server(conversations, active_server_id) + when is_list(conversations) do + case active_server_id do + server_id when is_integer(server_id) -> + Enum.filter(conversations, fn conversation -> + not server_channel_type?(conversation.type) or is_nil(conversation.server_id) or + conversation.server_id == server_id + end) + + _ -> + Enum.filter(conversations, fn conversation -> + not server_channel_type?(conversation.type) or is_nil(conversation.server_id) + end) + end + end + + def scope_conversations_to_server(_conversations, _active_server_id), do: [] + + @doc """ + Whether a conversation type lives inside a server's channel list (text or + voice channels). + """ + def server_channel_type?(type), do: type in ["channel", "voice_channel"] + + @doc """ + Loads the channel categories of a server (empty list when no server is + selected). + """ + def server_categories(server_id) when is_integer(server_id) do + Messaging.list_channel_categories(server_id) + end + + def server_categories(_server_id), do: [] + + @doc """ + Builds the sidebar entry list for the conversation panel. + + Without an active server this is just the conversations in their given + order. With an active server the server's channels are grouped under their + category headers: uncategorized channels first, then each category (by + position) with its channels ordered by channel position. Non-channel + conversations (DMs, groups) follow at the bottom. Channels under a + collapsed category are skipped. + + Returns entries shaped as `{:conversation, conversation}` or + `{:category, category, collapsed?}`. + """ + def sidebar_entries(conversations, categories, collapsed_category_ids, active_server_id) + when is_integer(active_server_id) do + {channels, others} = + Enum.split_with( + conversations, + &(server_channel_type?(&1.type) and &1.server_id == active_server_id) + ) + + category_ids = MapSet.new(categories, & &1.id) + + {categorized, uncategorized} = + Enum.split_with(channels, fn channel -> + is_integer(channel.category_id) and MapSet.member?(category_ids, channel.category_id) + end) + + channels_by_category = Enum.group_by(categorized, & &1.category_id) + + category_entries = + Enum.flat_map(categories, fn category -> + collapsed? = MapSet.member?(collapsed_category_ids, category.id) + header = {:category, category, collapsed?} + + if collapsed? do + [header] + else + channel_entries = + channels_by_category + |> Map.get(category.id, []) + |> sort_channels() + |> Enum.map(&{:conversation, &1}) + + [header | channel_entries] + end + end) + + Enum.map(sort_channels(uncategorized), &{:conversation, &1}) ++ + category_entries ++ Enum.map(others, &{:conversation, &1}) + end + + def sidebar_entries(conversations, _categories, _collapsed_category_ids, _active_server_id) do + Enum.map(conversations, &{:conversation, &1}) + end + + defp sort_channels(channels) do + Enum.sort_by(channels, &{&1.channel_position || 0, &1.id}) + end + + @doc """ + Load timeout status for multiple users in a conversation. + Returns a map of user_id => is_timed_out (boolean). + + Uses batch query for efficiency (single DB query instead of N queries). + """ + def load_timeout_status(user_ids, conversation_id) do + Elektrine.Messaging.Moderation.users_timed_out(user_ids, conversation_id) + end + + @doc """ + Check if user is admin of a specific conversation. + System admins can moderate any conversation. + """ + def conversation_admin?(conversation, user) do + cond do + # No user or conversation + is_nil(user) or is_nil(conversation) -> + false + + # System admins can moderate any conversation + user.is_admin -> + true + + # Check if user is conversation admin + conversation.members != nil -> + member = + Enum.find(conversation.members, fn m -> + m.user_id == user.id && is_nil(m.left_at) + end) + + not is_nil(member) and member.role == "admin" + + # Default case + true -> + false + end + end + + @doc """ + Check if user is admin of the current conversation (for event handlers). + Accepts a socket and checks the selected_conversation assign. + """ + def conversation_admin_socket?(socket) do + conversation = Map.get(socket.assigns, :selected_conversation) + current_user = Map.get(socket.assigns, :current_user) + conversation_admin?(conversation, current_user) + end + + @doc """ + Format duration in seconds to human readable string. + """ + def format_duration(seconds) when is_integer(seconds) do + cond do + seconds < 60 -> "#{seconds} seconds" + seconds < 3600 -> "#{div(seconds, 60)} minutes" + seconds < 86_400 -> "#{div(seconds, 3600)} hours" + true -> "#{div(seconds, 86400)} days" + end + end + + def format_duration(_), do: "" + + @doc """ + Popular emoji list for emoji picker. + """ + def popular_emojis do + [ + "😀", + "😃", + "😄", + "😁", + "😆", + "😅", + "😂", + "🤣", + "😊", + "😇", + "🙂", + "🙃", + "😉", + "😌", + "😍", + "🥰", + "😘", + "😗", + "😙", + "😚", + "😋", + "😛", + "😝", + "😜", + "🤪", + "🤨", + "🧐", + "🤓", + "😎", + "🤩", + "🥳", + "😏", + "😒", + "😞", + "😔", + "😟", + "😕", + "🙁", + "☹️", + "😣", + "😖", + "😫", + "😩", + "🥺", + "😢", + "😭", + "😤", + "😠", + "😡", + "🤬", + "🤯", + "😳", + "🥵", + "🥶", + "😱", + "😨", + "😰", + "😥", + "😓", + "🤗", + "🤔", + "🤭", + "🤫", + "🤥", + "😶", + "😐", + "😑", + "😬", + "🙄", + "😯", + "👍", + "👎", + "👌", + "✌️", + "🤞", + "🤟", + "🤘", + "🤙", + "👈", + "👉", + "👆", + "🖕", + "👇", + "☝️", + "👋", + "🤚", + "🖐", + "✋", + "🖖", + "👏", + "🙌", + "🤲", + "🙏", + "✍️", + "💪", + "🦾", + "🦿", + "🦵", + "🦶", + "👂", + "❤️", + "🧡", + "💛", + "💚", + "💙", + "💜", + "🖤", + "🤍", + "🤎", + "💔", + "❣️", + "💕", + "💞", + "💓", + "💗", + "💖", + "💘", + "💝", + "💟", + "☮️", + "🔥", + "⭐", + "🌟", + "✨", + "⚡", + "☄️", + "💥", + "💯", + "💢", + "💨" + ] + end + + @doc """ + Find the first unread message in a conversation for a user. + Used for scroll positioning. + + Optimized to use a single database query for member data. + """ + def find_first_unread_message(messages, conversation_id, user_id) do + # Early exit if no messages + if Enum.empty?(messages) do + nil + else + # Single query to get member data with last read info + member = Messaging.get_conversation_member(conversation_id, user_id) + + case member do + nil -> + # User is not a member + nil + + %{last_read_message_id: last_id} when not is_nil(last_id) -> + # User has a last read message ID + find_first_unread_by_message_id(messages, last_id, user_id, member.last_read_at) + + %{last_read_at: last_read_at} when not is_nil(last_read_at) -> + # User has a last read timestamp but no message ID + find_first_unread_by_timestamp(messages, last_read_at, user_id) + + _ -> + # User has never read any messages, find first message from other users + find_first_message_from_others(messages, user_id) + end + end + end + + # Find first unread message after a known last-read message ID + defp find_first_unread_by_message_id(messages, last_id, user_id, last_read_at) do + last_read_index = Enum.find_index(messages, fn msg -> msg.id == last_id end) + + case last_read_index do + nil -> + # Last read message not in current list, fall back to timestamp + if last_read_at do + find_first_unread_by_timestamp(messages, last_read_at, user_id) + else + find_first_message_from_others(messages, user_id) + end + + index -> + # Find the next message after the last read one from other users + messages + |> Enum.drop(index + 1) + |> Enum.find(fn message -> message.sender_id != user_id end) + |> case do + nil -> nil + message -> message.id + end + end + end + + # Find first message after a timestamp from other users + defp find_first_unread_by_timestamp(messages, last_read_at, user_id) do + cutoff = to_naive_datetime(last_read_at) + + messages + |> Enum.find(fn message -> + message.sender_id != user_id && + compare_message_time(message.inserted_at, cutoff) == :gt + end) + |> case do + nil -> nil + message -> message.id + end + end + + # Find the first message from any other user + defp find_first_message_from_others(messages, user_id) do + messages + |> Enum.find(fn message -> message.sender_id != user_id end) + |> case do + nil -> nil + message -> message.id + end + end + + defp reaction_actor_label(reaction) do + cond do + is_map(reaction.user) and not match?(%Ecto.Association.NotLoaded{}, reaction.user) -> + reaction.user.handle || reaction.user.username || "user" + + is_map(reaction.remote_actor) and + not match?(%Ecto.Association.NotLoaded{}, reaction.remote_actor) -> + username = reaction.remote_actor.username || "remote" + domain = reaction.remote_actor.domain + + if is_binary(domain) and domain != "" do + "#{username}@#{domain}" + else + username + end + + true -> + "user" + end + end + + defp message_display_content(%Message{} = message), do: Message.display_content(message) + defp message_display_content(%ChatMessage{} = message), do: ChatMessage.display_content(message) + + defp message_display_content(message) when is_map(message) do + content = + message + |> map_message_value(:content) + |> fallback_message_text(message) + |> normalize_message_text() + + if content != "", do: content, else: fallback_message_label(message) + end + + defp message_display_content(_), do: "" + + defp fallback_message_text(nil, message), do: map_message_value(message, :body) + defp fallback_message_text("", message), do: map_message_value(message, :body) + defp fallback_message_text(content, _message), do: content + + defp fallback_message_label(message) do + client_encrypted_payload = map_message_value(message, :client_encrypted_payload) + message_type = map_message_value(message, :message_type) + media_urls = map_message_value(message, :media_urls) || [] + + cond do + is_map(client_encrypted_payload) -> + "Encrypted message" + + message_type == "voice" -> + "Voice message" + + message_type == "image" -> + "Photo" + + message_type == "file" -> + "File" + + message_type == "system" -> + "[System message]" + + is_list(media_urls) and media_urls != [] -> + "[Attachment]" + + true -> + "" + end + end + + defp normalize_message_text(nil), do: "" + + defp normalize_message_text(text) when is_binary(text) do + text + |> String.trim() + end + + defp normalize_message_text(text) when is_atom(text), do: Atom.to_string(text) + defp normalize_message_text(text) when is_integer(text), do: Integer.to_string(text) + defp normalize_message_text(text) when is_float(text), do: Float.to_string(text) + defp normalize_message_text(_), do: "" + + defp map_message_value(map, key) when is_map(map) do + Map.get(map, key) || Map.get(map, Atom.to_string(key)) + end + + defp compare_message_time(_message_time, nil), do: :lt + + defp compare_message_time(%NaiveDateTime{} = message_time, %NaiveDateTime{} = cutoff) do + NaiveDateTime.compare(message_time, cutoff) + end + + defp compare_message_time(%DateTime{} = message_time, %NaiveDateTime{} = cutoff) do + message_time + |> DateTime.to_naive() + |> NaiveDateTime.compare(cutoff) + end + + defp compare_message_time(_message_time, _cutoff), do: :lt + + defp to_naive_datetime(%NaiveDateTime{} = datetime), do: datetime + defp to_naive_datetime(%DateTime{} = datetime), do: DateTime.to_naive(datetime) + defp to_naive_datetime(_), do: nil + + @doc """ + Calculate unread counts for each conversation using a single batch query. + Returns a map of conversation_id => unread_count. + """ + def calculate_unread_counts(conversations, user_id) do + conversation_ids = Enum.map(conversations, & &1.id) + Messaging.get_conversation_unread_counts(conversation_ids, user_id) + end + + @doc """ + Sort conversations: pinned first, then unread, then by last activity. + """ + def sort_conversations_by_unread(conversations, unread_counts, user_id) do + Enum.sort(conversations, fn conv1, conv2 -> + # Check if conversation is pinned for this user + pinned1 = + Enum.find(conv1.members, fn m -> m.user_id == user_id end) + |> then(fn m -> m && m.pinned end) || false + + pinned2 = + Enum.find(conv2.members, fn m -> m.user_id == user_id end) + |> then(fn m -> m && m.pinned end) || false + + unread1 = Map.get(unread_counts, conv1.id, 0) > 0 + unread2 = Map.get(unread_counts, conv2.id, 0) > 0 + + cond do + # Pinned conversations always come first + pinned1 != pinned2 -> + pinned1 + + # Among pinned or unpinned, unread comes first + unread1 != unread2 -> + unread1 + + # Same pinned and unread status - sort by last message time + true -> + NaiveDateTime.compare( + conv1.last_message_at || conv1.updated_at, + conv2.last_message_at || conv2.updated_at + ) == :gt + end + end) + end + + @doc """ + Calculate read status for the last message in each conversation using a single batch query. + Returns a map of conversation_id => %{is_read: boolean, reader_count: integer}. + """ + def calculate_last_message_read_status(conversations, user_id) do + # Collect all messages where current user is the sender + message_info_list = + conversations + |> Enum.flat_map(fn conversation -> + case conversation.messages do + [last_message | _] + when not is_nil(last_message.sender_id) and last_message.sender_id == user_id -> + [{conversation.id, last_message.id, last_message.inserted_at}] + + _ -> + [] + end + end) + + # Batch query for all read statuses + Messaging.get_batch_last_message_read_status(message_info_list) + end + + @doc """ + Filter conversations to remove DMs with a specific blocked user. + """ + def filter_blocked_conversations(conversations, _current_user_id, blocked_user_id) do + Enum.filter(conversations, fn conversation -> + case conversation.type do + "dm" -> + # Check if this DM is with the blocked user + not Enum.any?(conversation.members, fn member -> + member.user_id == blocked_user_id and is_nil(member.left_at) + end) + + _ -> + # Keep all non-DM conversations + true + end + end) + end + + @doc """ + Get user's communities for discussion creation. + """ + def get_user_communities(user_id) do + Messaging.list_chat_conversations(user_id) + |> Enum.filter(&(&1.type == "community")) + end + + @doc """ + Convert URLs in text to clickable links. + """ + def linkify_urls(text) when is_binary(text) do + url_pattern = ~r/(https?:\/\/[^\s&<>]+)/ + + text + |> Phoenix.HTML.html_escape() + |> Phoenix.HTML.safe_to_string() + |> then(fn escaped_text -> + Regex.replace(url_pattern, escaped_text, fn _, url -> + # Remove trailing punctuation that might not be part of the URL + clean_url = String.replace(url, ~r/[.!?,;:]$/, "") + # URL is already escaped, just use it directly + # Use underline and opacity for better contrast in chat bubbles + "#{clean_url}" + end) + end) + end + + def linkify_urls(text), do: text + + @doc """ + Generate Phoenix Token for user authentication in channels. + """ + def generate_user_token(user_id) do + user = Elektrine.Accounts.get_user!(user_id) + + Phoenix.Token.sign(ElektrineWeb.Endpoint, "user socket", %{ + "user_id" => user.id, + "password_changed_at" => password_changed_at_unix(user), + "auth_valid_after" => auth_valid_after_unix(user) + }) + end + + defp password_changed_at_unix(user) do + case user.last_password_change do + %DateTime{} = changed_at -> DateTime.to_unix(changed_at, :second) + _ -> 0 + end + end + + defp auth_valid_after_unix(user) do + case user.auth_valid_after do + %DateTime{} = valid_after -> DateTime.to_unix(valid_after, :second) + _ -> 0 + end + end +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/member_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/member_operations.ex new file mode 100644 index 0000000..2ec1ef5 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/member_operations.ex @@ -0,0 +1,322 @@ +defmodule ArblargWeb.ChatLive.Operations.MemberOperations do + @moduledoc """ + Handles member management: add, kick, promote, demote, timeout. + Extracted from ChatLive.Home. + """ + + import Phoenix.Component + import ElektrineWeb.Live.NotificationHelpers + + use Phoenix.VerifiedRoutes, + endpoint: ElektrineWeb.Endpoint, + router: ElektrineWeb.Router + + alias Elektrine.Messaging, as: Messaging + + def handle_event("show_add_members", _params, socket) do + pending_remote_join_requests = + load_pending_remote_join_requests( + socket.assigns.conversation.selected, + socket.assigns.current_user + ) + + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_add_members_modal, true)) + |> assign(:pending_remote_join_requests, pending_remote_join_requests)} + end + + def handle_event("hide_add_members", _params, socket) do + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_add_members_modal, false)) + |> assign(:pending_remote_join_requests, [])} + end + + def handle_event("add_member_to_conversation", %{"user_id" => user_id}, socket) do + conversation = socket.assigns.conversation.selected + + with {:ok, user_id} <- parse_positive_int(user_id), + result <- + Messaging.add_member_to_conversation( + conversation.id, + user_id, + "member", + socket.assigns.current_user.id + ) do + case result do + {:ok, _} -> + {:noreply, + socket + |> assign(:ui, Map.put(socket.assigns.ui, :show_add_members_modal, false)) + |> notify_info("Member added")} + + {:error, :blocked} -> + {:noreply, notify_error(socket, "You have blocked this user or they have blocked you")} + + {:error, :privacy_restricted} -> + {:noreply, notify_error(socket, "This user's privacy settings prevent this action")} + + {:error, :unauthorized} -> + {:noreply, notify_error(socket, "You don't have permission to add members")} + + {:error, _} -> + {:noreply, notify_error(socket, "Failed to add member")} + end + else + :error -> {:noreply, notify_error(socket, "Failed to add member")} + end + end + + def handle_event("approve_remote_join_request", %{"remote_actor_id" => remote_actor_id}, socket) do + review_remote_join_request(socket, remote_actor_id, :approve) + end + + def handle_event("decline_remote_join_request", %{"remote_actor_id" => remote_actor_id}, socket) do + review_remote_join_request(socket, remote_actor_id, :decline) + end + + def handle_event("kick_member", %{"user_id" => user_id}, socket) do + conversation = socket.assigns.conversation.selected + + # Route through the facade /3 so the actor (current user) is passed for the + # authorization check and type-routing stays consistent. + with {:ok, user_id} <- parse_positive_int(user_id), + result <- + Messaging.remove_member_from_conversation( + conversation.id, + user_id, + socket.assigns.current_user.id + ) do + case result do + {:ok, _} -> + {:noreply, notify_info(socket, "Member removed")} + + {:error, :unauthorized} -> + {:noreply, notify_error(socket, "You don't have permission to remove members")} + + {:error, _} -> + {:noreply, notify_error(socket, "Failed to remove member")} + end + else + :error -> {:noreply, notify_error(socket, "Failed to remove member")} + end + end + + def handle_event("promote_member", %{"user_id" => user_id}, socket) do + conversation = socket.assigns.conversation.selected + + with {:ok, user_id} <- parse_positive_int(user_id), + result <- + Messaging.update_member_role( + conversation.id, + user_id, + "admin", + socket.assigns.current_user.id + ) do + case result do + {:ok, _} -> + {:noreply, notify_info(socket, "Member promoted to admin")} + + {:error, :unauthorized} -> + {:noreply, notify_error(socket, "You don't have permission to promote members")} + + {:error, :cannot_modify_creator} -> + {:noreply, notify_error(socket, "The conversation owner's role can't be changed")} + + {:error, _} -> + {:noreply, notify_error(socket, "Failed to promote member")} + end + else + :error -> {:noreply, notify_error(socket, "Failed to promote member")} + end + end + + def handle_event("demote_member", %{"user_id" => user_id}, socket) do + conversation = socket.assigns.conversation.selected + + with {:ok, user_id} <- parse_positive_int(user_id), + result <- + Messaging.update_member_role( + conversation.id, + user_id, + "member", + socket.assigns.current_user.id + ) do + case result do + {:ok, _} -> + {:noreply, notify_info(socket, "Member demoted")} + + {:error, :unauthorized} -> + {:noreply, notify_error(socket, "You don't have permission to demote members")} + + {:error, :cannot_modify_creator} -> + {:noreply, notify_error(socket, "The conversation owner's role can't be changed")} + + {:error, _} -> + {:noreply, notify_error(socket, "Failed to demote member")} + end + else + :error -> {:noreply, notify_error(socket, "Failed to demote member")} + end + end + + def handle_event("timeout_user", params, socket) do + conversation_id = socket.assigns.conversation.selected.id + + with {:ok, user_id} <- parse_positive_int(params["user_id"]), + {:ok, duration} <- parse_positive_int(params["duration"]), + result <- + Messaging.timeout_user( + conversation_id, + user_id, + socket.assigns.current_user.id, + duration + ) do + case result do + {:ok, _} -> + {:noreply, notify_info(socket, "User timed out")} + + {:error, :unauthorized} -> + {:noreply, notify_error(socket, "You don't have permission to timeout users")} + + {:error, _} -> + {:noreply, notify_error(socket, "Failed to timeout user")} + end + else + :error -> {:noreply, notify_error(socket, "Failed to timeout user")} + end + end + + def handle_event("remove_timeout_user", %{"user_id" => user_id}, socket) do + conversation_id = socket.assigns.conversation.selected.id + + with {:ok, user_id} <- parse_positive_int(user_id), + result <- Messaging.remove_timeout(conversation_id, user_id) do + case result do + {:ok, _} -> + {:noreply, notify_info(socket, "Timeout removed")} + + {:error, _} -> + {:noreply, notify_error(socket, "Failed to remove timeout")} + end + else + :error -> {:noreply, notify_error(socket, "Failed to remove timeout")} + end + end + + def handle_event("kick_user", %{"user_id" => user_id}, socket) do + handle_event("kick_member", %{"user_id" => user_id}, socket) + end + + def handle_event("show_member_management", _params, socket) do + {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_member_management, true))} + end + + def handle_event("hide_member_management", _params, socket) do + {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_member_management, false))} + end + + def handle_event("show_moderation_log", _params, socket) do + {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_moderation_log, true))} + end + + def handle_event("hide_moderation_log", _params, socket) do + {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_moderation_log, false))} + end + + defp review_remote_join_request(socket, remote_actor_id, decision) + when decision in [:approve, :decline] do + conversation = socket.assigns.conversation.selected + + with true <- can_manage_remote_join_requests?(conversation, socket.assigns.current_user), + {parsed_remote_actor_id, ""} <- Integer.parse(remote_actor_id) do + result = + case decision do + :approve -> + Messaging.approve_remote_join_request( + conversation.id, + parsed_remote_actor_id, + socket.assigns.current_user.id + ) + + :decline -> + Messaging.decline_remote_join_request( + conversation.id, + parsed_remote_actor_id, + socket.assigns.current_user.id + ) + end + + case result do + {:ok, _request} -> + updated_requests = + load_pending_remote_join_requests(conversation, socket.assigns.current_user) + + message = + if decision == :approve do + "Remote join approved" + else + "Remote join declined" + end + + {:noreply, + socket + |> assign(:pending_remote_join_requests, updated_requests) + |> notify_info(message)} + + {:error, :not_found} -> + {:noreply, notify_error(socket, "Remote join request not found")} + + {:error, _reason} -> + {:noreply, notify_error(socket, "Failed to review remote join request")} + end + else + false -> + {:noreply, notify_error(socket, "You don't have permission to review remote joins")} + + _ -> + {:noreply, notify_error(socket, "Invalid remote join request")} + end + end + + defp load_pending_remote_join_requests( + %{type: "channel", is_federated_mirror: false, id: conversation_id} = conversation, + current_user + ) do + if can_manage_remote_join_requests?(conversation, current_user) do + Messaging.list_pending_remote_join_requests(conversation_id) + else + [] + end + end + + defp load_pending_remote_join_requests(_conversation, _current_user), do: [] + + defp can_manage_remote_join_requests?(conversation, current_user) do + current_member = + case conversation do + %{members: members} when is_list(members) -> + Enum.find(members, fn member -> + member.user_id == Map.get(current_user, :id) and is_nil(member.left_at) + end) + + _ -> + nil + end + + Map.get(current_user, :is_admin, false) or + (!!current_member && current_member.role in ["owner", "admin", "moderator"]) + end + + defp parse_positive_int(value) when is_integer(value) and value > 0, do: {:ok, value} + + defp parse_positive_int(value) when is_binary(value) do + case Integer.parse(value) do + {parsed, ""} when parsed > 0 -> {:ok, parsed} + _ -> :error + end + end + + defp parse_positive_int(_), do: :error +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/message_info_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/message_info_operations.ex new file mode 100644 index 0000000..058de21 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/message_info_operations.ex @@ -0,0 +1,413 @@ +defmodule ArblargWeb.ChatLive.Operations.MessageInfoOperations do + @moduledoc false + + @doc false + def route_info(info, socket) do + case info do + {:chat_message_updated, message} -> + {:handled, handle_chat_message_updated(socket, message)} + + {:chat_message_deleted, message_id} -> + {:handled, handle_chat_message_deleted(socket, message_id)} + + {:chat_reaction_added, message_id, reaction} -> + {:handled, handle_chat_reaction_added(socket, message_id, reaction)} + + {:chat_reaction_removed, message_id, user_id, emoji} -> + {:handled, handle_chat_reaction_removed(socket, message_id, user_id, emoji)} + + {:chat_reaction_removed, message_id, _user_id, emoji, remote_actor_id} -> + {:handled, handle_chat_reaction_removed(socket, message_id, nil, emoji, remote_actor_id)} + + {:chat_remote_read_receipt, receipt} when is_map(receipt) -> + {:handled, handle_chat_remote_read_receipt(socket, receipt)} + + {:chat_remote_read_cursor, cursor} when is_map(cursor) -> + {:handled, handle_chat_remote_read_cursor(socket, cursor)} + + {:federation_presence_update, payload} when is_map(payload) -> + {:handled, handle_federation_presence_update(socket, payload)} + + {:message_pinned, message} -> + {:handled, handle_message_pinned(socket, message)} + + {:message_unpinned, message} -> + {:handled, handle_message_unpinned(socket, message)} + + {:reaction_added, reaction} -> + {:handled, handle_reaction_added(socket, reaction)} + + {:reaction_removed, reaction} -> + {:handled, handle_reaction_removed(socket, reaction)} + + {:message_link_preview_updated, updated_message} -> + {:handled, handle_message_link_preview_updated(socket, updated_message)} + + {:notification_count_updated, new_count} -> + {:handled, handle_notification_count_updated(socket, new_count)} + + _ -> + :unhandled + end + end + + def handle_chat_message_updated(socket, message) do + if selected_conversation_matches?(socket, message.conversation_id) do + messages = + Enum.map(socket.assigns.messages, fn msg -> + if msg.id == message.id, do: message, else: msg + end) + + {:noreply, Phoenix.Component.assign(socket, :messages, messages)} + else + {:noreply, socket} + end + end + + def handle_chat_message_deleted(socket, message_id) do + messages = Enum.reject(socket.assigns.messages, fn msg -> msg.id == message_id end) + {:noreply, Phoenix.Component.assign(socket, :messages, messages)} + end + + def handle_chat_reaction_added(socket, message_id, reaction) do + messages = + Enum.map(socket.assigns.messages, fn message -> + if message.id == message_id do + existing_reactions = Map.get(message, :reactions, []) || [] + + already_exists = + Enum.any?(existing_reactions, fn existing -> + existing.emoji == reaction.emoji and + existing.user_id == reaction.user_id and + existing.remote_actor_id == reaction.remote_actor_id + end) + + updated_reactions = + if already_exists, do: existing_reactions, else: existing_reactions ++ [reaction] + + %{message | reactions: updated_reactions} + else + message + end + end) + + {:noreply, Phoenix.Component.assign(socket, :messages, messages)} + end + + def handle_chat_reaction_removed(socket, message_id, user_id, emoji) do + messages = + Enum.map(socket.assigns.messages, fn message -> + if message.id == message_id do + reactions = + (Map.get(message, :reactions, []) || []) + |> Enum.reject(fn reaction -> + reaction.emoji == emoji and reaction.user_id == user_id + end) + + %{message | reactions: reactions} + else + message + end + end) + + {:noreply, Phoenix.Component.assign(socket, :messages, messages)} + end + + def handle_chat_reaction_removed(socket, message_id, _user_id, emoji, remote_actor_id) do + messages = + Enum.map(socket.assigns.messages, fn message -> + if message.id == message_id do + reactions = + (Map.get(message, :reactions, []) || []) + |> Enum.reject(fn reaction -> + reaction.emoji == emoji and reaction.remote_actor_id == remote_actor_id + end) + + %{message | reactions: reactions} + else + message + end + end) + + {:noreply, Phoenix.Component.assign(socket, :messages, messages)} + end + + def handle_chat_remote_read_receipt(socket, receipt) when is_map(receipt) do + message_id = receipt[:message_id] || receipt["message_id"] + + if is_integer(message_id) and Enum.any?(socket.assigns.messages, &(&1.id == message_id)) do + current_read_status = socket.assigns.message.read_status || %{} + current_readers = Map.get(current_read_status, message_id, []) + + remote_reader = %{ + user_id: nil, + remote_actor_id: receipt[:remote_actor_id] || receipt["remote_actor_id"], + username: receipt[:username] || receipt["username"] || "@remote", + avatar: receipt[:avatar] || receipt["avatar"] + } + + updated_readers = + current_readers + |> Enum.reject(fn reader -> + is_integer(reader[:remote_actor_id]) and + reader[:remote_actor_id] == remote_reader.remote_actor_id + end) + |> Kernel.++([remote_reader]) + + updated_read_status = Map.put(current_read_status, message_id, updated_readers) + + {:noreply, + Phoenix.Component.assign(socket, :message, %{ + socket.assigns.message + | read_status: updated_read_status + })} + else + {:noreply, socket} + end + end + + def handle_chat_remote_read_cursor(socket, cursor) when is_map(cursor) do + read_through_message_id = + cursor[:read_through_message_id] || cursor["read_through_message_id"] || + cursor[:message_id] || cursor["message_id"] + + remote_actor_id = cursor[:remote_actor_id] || cursor["remote_actor_id"] + + if is_integer(read_through_message_id) and is_integer(remote_actor_id) do + current_read_status = socket.assigns.message.read_status || %{} + + remote_reader = %{ + user_id: nil, + remote_actor_id: remote_actor_id, + username: cursor[:username] || cursor["username"] || "@remote", + avatar: cursor[:avatar] || cursor["avatar"] + } + + updated_read_status = + Enum.reduce(socket.assigns.messages, current_read_status, fn message, acc -> + if is_integer(message.id) and message.id <= read_through_message_id do + readers = + acc + |> Map.get(message.id, []) + |> Enum.reject(fn reader -> + is_integer(reader[:remote_actor_id]) and + reader[:remote_actor_id] == remote_reader.remote_actor_id + end) + |> Kernel.++([remote_reader]) + + Map.put(acc, message.id, readers) + else + acc + end + end) + + {:noreply, + Phoenix.Component.assign(socket, :message, %{ + socket.assigns.message + | read_status: updated_read_status + })} + else + {:noreply, socket} + end + end + + def handle_federation_presence_update(socket, payload) when is_map(payload) do + conversation_id = payload[:conversation_id] || payload["conversation_id"] + selected_conversation_id = get_in(socket.assigns, [:conversation, :selected, :id]) + + if is_integer(conversation_id) and selected_conversation_id == conversation_id do + remote_actor_id = payload[:remote_actor_id] || payload["remote_actor_id"] + + if is_integer(remote_actor_id) do + existing = socket.assigns[:federation_presence] || %{} + + presence_entry = %{ + conversation_id: conversation_id, + remote_actor_id: remote_actor_id, + handle: payload[:handle] || payload["handle"] || "@remote", + label: payload[:label] || payload["label"] || "@remote", + avatar_url: payload[:avatar_url] || payload["avatar_url"], + status: payload[:status] || payload["status"] || "offline", + activities: payload[:activities] || payload["activities"] || [], + updated_at: payload[:updated_at] || payload["updated_at"] || DateTime.utc_now() + } + + {:noreply, + Phoenix.Component.assign( + socket, + :federation_presence, + Map.put(existing, remote_actor_id, presence_entry) + )} + else + {:noreply, socket} + end + else + {:noreply, socket} + end + end + + def handle_message_pinned(socket, message) do + if selected_conversation_matches?(socket, message.conversation_id) do + messages = + Enum.map(socket.assigns.messages, fn msg -> + if msg.id == message.id do + %{ + msg + | is_pinned: true, + pinned_at: message.pinned_at, + pinned_by_id: message.pinned_by_id + } + else + msg + end + end) + + pinned_messages = + [message | Enum.reject(socket.assigns[:pinned_messages] || [], &(&1.id == message.id))] + + {:noreply, + socket + |> Phoenix.Component.assign(:messages, messages) + |> Phoenix.Component.assign(:pinned_messages, pinned_messages)} + else + {:noreply, socket} + end + end + + def handle_message_unpinned(socket, message) do + if selected_conversation_matches?(socket, message.conversation_id) do + messages = + Enum.map(socket.assigns.messages, fn msg -> + if msg.id == message.id do + %{msg | is_pinned: false, pinned_at: nil, pinned_by_id: nil} + else + msg + end + end) + + pinned_messages = + Enum.reject(socket.assigns[:pinned_messages] || [], &(&1.id == message.id)) + + {:noreply, + socket + |> Phoenix.Component.assign(:messages, messages) + |> Phoenix.Component.assign(:pinned_messages, pinned_messages)} + else + {:noreply, socket} + end + end + + def handle_reaction_added(socket, reaction) do + if selected_conversation_matches?(socket, reaction.message.conversation_id) do + messages = + Enum.map(socket.assigns.messages, fn msg -> + if msg.id == reaction.message.id do + existing_reaction = + Enum.find(msg.reactions, fn r -> + r.user_id == reaction.user_id and r.emoji == reaction.emoji + end) + + if existing_reaction do + msg + else + %{msg | reactions: msg.reactions ++ [reaction]} + end + else + msg + end + end) + + {:noreply, Phoenix.Component.assign(socket, :messages, messages)} + else + {:noreply, socket} + end + end + + def handle_reaction_removed(socket, reaction) do + if selected_conversation_matches?(socket, reaction.message.conversation_id) do + messages = + Enum.map(socket.assigns.messages, fn msg -> + if msg.id == reaction.message.id do + reactions = Enum.reject(msg.reactions, &(&1.id == reaction.id)) + %{msg | reactions: reactions} + else + msg + end + end) + + {:noreply, Phoenix.Component.assign(socket, :messages, messages)} + else + {:noreply, socket} + end + end + + def handle_message_link_preview_updated(socket, updated_message) do + if selected_conversation_matches?(socket, updated_message.conversation_id) do + messages = + Enum.map(socket.assigns.messages, fn msg -> + if msg.id == updated_message.id do + %{msg | link_preview: updated_message.link_preview} + else + msg + end + end) + + {:noreply, + socket + |> Phoenix.Component.assign(:messages, messages) + |> update_conversation_latest_preview(updated_message)} + else + {:noreply, socket} + end + end + + def handle_notification_count_updated(socket, new_count) do + {:noreply, Phoenix.Component.assign(socket, :notification_count, new_count)} + end + + defp selected_conversation_matches?(socket, conversation_id) when is_integer(conversation_id) do + socket.assigns.conversation.selected && + socket.assigns.conversation.selected.id == conversation_id + end + + defp selected_conversation_matches?(_socket, _conversation_id), do: false + + defp update_conversation_latest_preview(socket, updated_message) do + conversation_state = socket.assigns.conversation + + updated_list = + Enum.map( + conversation_state.list, + &maybe_update_conversation_preview_message(&1, updated_message) + ) + + updated_filtered = + Enum.map( + conversation_state.filtered, + &maybe_update_conversation_preview_message(&1, updated_message) + ) + + Phoenix.Component.assign(socket, :conversation, %{ + conversation_state + | list: updated_list, + filtered: updated_filtered + }) + end + + defp maybe_update_conversation_preview_message(conversation, updated_message) do + if conversation.id == updated_message.conversation_id do + case conversation.messages do + [%{id: message_id} = latest_message | rest] when message_id == updated_message.id -> + %{ + conversation + | messages: [%{latest_message | link_preview: updated_message.link_preview} | rest] + } + + _ -> + conversation + end + else + conversation + end + end +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/message_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/message_operations.ex new file mode 100644 index 0000000..175c93a --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/message_operations.ex @@ -0,0 +1,1099 @@ +defmodule ArblargWeb.ChatLive.Operations.MessageOperations do + @moduledoc "Handles all message-related operations: sending, editing, deleting, reactions, pagination.\nExtracted from ChatLive.Home.\n" + import Phoenix.LiveView + import Phoenix.Component + import ElektrineWeb.Live.NotificationHelpers + alias ArblargWeb.ChatLive.Operations.Helpers + alias Elektrine.Accounts.Storage + alias Elektrine.Messaging, as: Messaging + alias Elektrine.Messaging.RoomACL + alias Elektrine.Messaging.SlashCommands + alias Elektrine.Uploads + @doc "Load older messages in the conversation (pagination upward).\n" + def handle_event("load_older_messages", _, socket) do + if socket.assigns.loading_older_messages || !socket.assigns.has_more_older_messages do + {:noreply, socket} + else + conversation = socket.assigns.conversation.selected + conversation_id = conversation.id + user_id = socket.assigns.current_user.id + socket = assign(socket, :loading_older_messages, true) + + data = + Messaging.get_conversation_messages( + conversation_id, + user_id, + limit: 50, + before_id: socket.assigns.oldest_message_id + ) + + older_messages = + Enum.reverse(data.messages) + + message_data = data + new_messages = Helpers.dedupe_messages(older_messages ++ socket.assigns.messages) + new_message_ids = Enum.map(older_messages, & &1.id) + + new_read_status = + if new_message_ids != [] do + Messaging.get_read_status_for_messages(new_message_ids, conversation_id) + else + %{} + end + + {:noreply, + socket + |> assign(:messages, new_messages) + |> assign(:message, %{ + socket.assigns.message + | read_status: Map.merge(socket.assigns.message.read_status, new_read_status) + }) + |> assign(:has_more_older_messages, message_data.has_more_older) + |> assign(:oldest_message_id, message_data.oldest_id || socket.assigns.oldest_message_id) + |> assign(:loading_older_messages, false) + |> push_event("maintain_scroll_position", %{})} + end + end + + def handle_event("load_newer_messages", _, socket) do + if socket.assigns.loading_newer_messages || !socket.assigns.has_more_newer_messages do + {:noreply, socket} + else + conversation = socket.assigns.conversation.selected + conversation_id = conversation.id + user_id = socket.assigns.current_user.id + socket = assign(socket, :loading_newer_messages, true) + + data = + Messaging.get_conversation_messages( + conversation_id, + user_id, + limit: 50, + after_id: socket.assigns.newest_message_id + ) + + newer_messages = + Enum.reverse(data.messages) + + message_data = data + new_messages = Helpers.dedupe_messages(socket.assigns.messages ++ newer_messages) + new_message_ids = Enum.map(newer_messages, & &1.id) + + new_read_status = + if new_message_ids != [] do + Messaging.get_read_status_for_messages(new_message_ids, conversation_id) + else + %{} + end + + {:noreply, + socket + |> assign(:messages, new_messages) + |> assign(:message, %{ + socket.assigns.message + | read_status: Map.merge(socket.assigns.message.read_status, new_read_status) + }) + |> assign(:has_more_newer_messages, message_data.has_more_newer) + |> assign(:newest_message_id, message_data.newest_id || socket.assigns.newest_message_id) + |> assign(:loading_newer_messages, false)} + end + end + + def handle_event("scroll_to_newest", _, socket) do + {:noreply, push_event(socket, "scroll_to_bottom", %{})} + end + + def handle_event("register_chat_encryption_device", params, socket) do + case Messaging.register_chat_encryption_device(socket.assigns.current_user.id, params) do + {:ok, _device} -> + broadcast_chat_e2ee_devices_changed(socket) + socket = refresh_chat_e2ee_devices(socket) + {:reply, %{ok: true}, socket} + + {:error, _changeset} -> + {:reply, %{ok: false, error: "invalid_device"}, socket} + end + end + + def handle_event("chat_e2ee_key", params, socket) do + conversation_id = parse_int(Map.get(params, "conversation_id")) + device_id = Map.get(params, "device_id") + key_uid = Map.get(params, "key_uid") + + with %{id: selected_id} <- socket.assigns.conversation.selected, + true <- conversation_id == selected_id, + {:ok, wrapped_key} <- + Messaging.get_wrapped_chat_key( + conversation_id, + socket.assigns.current_user.id, + device_id, + key_uid + ) do + {:reply, %{ok: true, wrapped_key: wrapped_key}, socket} + else + _ -> {:reply, %{ok: false, error: "key_not_found"}, socket} + end + end + + def handle_event("chat_typing", _params, socket) do + {:noreply, broadcast_typing(socket)} + end + + def handle_event("send_client_encrypted_message", params, socket) do + case socket.assigns.conversation.selected do + nil -> + {:reply, %{ok: false, error: "no_conversation"}, socket} + + conversation -> + reply_to_id = socket.assigns.message.reply_to && socket.assigns.message.reply_to.id + + attrs = %{ + "encrypted_payload" => Map.get(params, "encrypted_payload"), + "key_packages" => Map.get(params, "key_packages", []), + "search_index" => Map.get(params, "search_index", []) + } + + case Messaging.create_client_encrypted_chat_text_message( + conversation.id, + socket.assigns.current_user.id, + attrs, + reply_to_id: reply_to_id + ) do + {:ok, message} -> + socket = put_sent_message(socket, conversation, message) + {:reply, %{ok: true}, socket} + + {:error, reason} -> + {:reply, %{ok: false, error: to_string(reason)}, socket} + end + end + end + + def handle_event("send_message", %{"message" => message_content}, socket) do + trimmed_content = String.trim(message_content) + + case ensure_can_send_current_conversation(socket) do + :ok -> + upload_results = + consume_uploaded_entries(socket, :chat_attachments, fn %{path: path}, entry -> + user_id = socket.assigns.current_user.id + + upload_struct = %Plug.Upload{ + path: path, + content_type: entry.client_type, + filename: entry.client_name + } + + case Uploads.upload_chat_attachment(upload_struct, user_id) do + {:ok, metadata} -> + {:ok, + %{ + url: metadata.key, + name: metadata.filename, + type: metadata.content_type, + size: metadata.size + }} + + {:error, _reason} -> + {:ok, %{upload_error: true, name: entry.client_name}} + end + end) + + {failed_uploads, uploaded_files} = + Enum.split_with(upload_results, fn upload -> + Map.get(upload, :upload_error, false) + end) + + socket = + if failed_uploads != [] do + notify_error( + socket, + "One or more attachments failed to upload. Try a supported image format (JPG, PNG, WEBP, HEIC, AVIF) or a smaller file." + ) + else + socket + end + + case maybe_apply_slash_command(trimmed_content, uploaded_files, socket) do + {:halt, socket} -> + {:noreply, socket} + + {:send, resolved_content, socket} -> + has_content = resolved_content != "" || !Enum.empty?(uploaded_files) + + if has_content do + case socket.assigns.conversation.selected do + nil -> + {:noreply, socket} + + conversation -> + if Map.get( + socket.assigns.moderation.user_timeout_status, + socket.assigns.current_user.id, + false + ) do + {:noreply, + notify_error(socket, "You are currently timed out and cannot send messages")} + else + reply_to_id = + if socket.assigns.message.reply_to do + socket.assigns.message.reply_to.id + else + nil + end + + socket = + assign(socket, :message, %{socket.assigns.message | loading_messages: true}) + + result = + if Enum.empty?(uploaded_files) do + Messaging.create_text_message( + conversation.id, + socket.assigns.current_user.id, + resolved_content, + reply_to_id + ) + else + media_urls = Enum.map(uploaded_files, & &1.url) + + content = + if resolved_content != "" do + resolved_content + else + nil + end + + media_metadata = + uploaded_files + |> Enum.map(fn file -> + {file.url, + %{size: file.size, filename: file.name, content_type: file.type}} + end) + |> Map.new() + + Messaging.create_media_message( + conversation.id, + socket.assigns.current_user.id, + media_urls, + content, + media_metadata + ) + end + + case result do + {:ok, message} -> + if !Enum.empty?(uploaded_files) do + Storage.update_user_storage(socket.assigns.current_user.id) + end + + if socket.assigns[:typing_timer] do + Process.cancel_timer(socket.assigns.typing_timer) + end + + Phoenix.PubSub.broadcast_from( + Elektrine.PubSub, + self(), + "conversation:#{conversation.id}", + {:user_stopped_typing, socket.assigns.current_user.id} + ) + + Elektrine.Messaging.Federation.publish_typing_stopped( + conversation.id, + socket.assigns.current_user.id + ) + + message_with_sender = Elektrine.Repo.preload(message, sender: [:profile]) + conversations = socket.assigns.conversation.list + + updated_conversations = + Enum.map(conversations, fn conv -> + if conv.id == conversation.id do + last_message_at = + DateTime.from_naive!(message.inserted_at, "Etc/UTC") + + %{ + conv + | messages: [message_with_sender], + last_message_at: last_message_at + } + else + conv + end + end) + + last_message_read_status = + Helpers.calculate_last_message_read_status( + updated_conversations, + socket.assigns.current_user.id + ) + + unread_counts = + Helpers.calculate_unread_counts( + updated_conversations, + socket.assigns.current_user.id + ) + + sorted_conversations = + Helpers.sort_conversations_by_unread( + updated_conversations, + unread_counts, + socket.assigns.current_user.id + ) + + scoped_conversations = + Helpers.scope_conversations_to_server( + sorted_conversations, + socket.assigns[:active_server_id] + ) + + filtered_conversations = + if socket.assigns.search.conversation_query != "" do + Helpers.filter_conversations( + scoped_conversations, + socket.assigns.search.conversation_query, + socket.assigns.current_user.id + ) + else + scoped_conversations + end + + updated_messages = + Helpers.dedupe_messages(socket.assigns.messages ++ [message]) + + updated_read_status = + Map.put(socket.assigns.message.read_status || %{}, message.id, []) + + updated_socket = + socket + |> assign(:messages, updated_messages) + |> assign(:newest_message_id, message.id) + |> assign(:has_more_newer_messages, false) + |> assign(:message, %{ + socket.assigns.message + | new_message: "", + reply_to: nil, + loading_messages: false, + read_status: updated_read_status + }) + |> assign(:typing_timer, nil) + |> assign(:conversation, %{ + socket.assigns.conversation + | list: sorted_conversations, + filtered: filtered_conversations, + last_message_read_status: last_message_read_status + }) + + updated_socket = + if message.media_urls && message.media_urls != [] do + Process.send_after( + self(), + {:ensure_scroll_after_media, message.id}, + 50 + ) + + Process.send_after( + self(), + {:ensure_scroll_after_media, message.id}, + 200 + ) + + Process.send_after( + self(), + {:ensure_scroll_after_media, message.id}, + 500 + ) + + Process.send_after( + self(), + {:ensure_scroll_after_media, message.id}, + 1000 + ) + + Process.send_after( + self(), + {:ensure_scroll_after_media, message.id}, + 1500 + ) + + updated_socket + else + push_event(updated_socket, "scroll_to_bottom", %{}) + end + + {:noreply, updated_socket} + + {:error, :rate_limited} -> + {:noreply, + socket + |> assign(:message, %{socket.assigns.message | loading_messages: false}) + |> notify_error("Sending too fast! Please slow down.")} + + {:error, reason} -> + error_message = message_send_error_message(reason) + + {:noreply, + socket + |> assign(:message, %{socket.assigns.message | loading_messages: false}) + |> notify_error(error_message)} + end + end + end + else + {:noreply, socket} + end + end + + {:error, reason} -> + {:noreply, notify_error(socket, message_send_error_message(reason))} + end + end + + def handle_event("update_message", %{"message" => message_content}, socket) do + socket = + if Elektrine.Strings.present?(message_content), do: broadcast_typing(socket), else: socket + + {:noreply, assign(socket, :message, %{socket.assigns.message | new_message: message_content})} + end + + def handle_event("handle_keydown", %{"key" => "Enter"}, socket) do + trimmed_content = String.trim(socket.assigns.message.new_message) + + if trimmed_content != "" do + handle_event("send_message", %{"message" => trimmed_content}, socket) + else + {:noreply, socket} + end + end + + def handle_event("handle_keydown", _params, socket) do + {:noreply, socket} + end + + def handle_event("stop_typing", _params, socket) do + case socket.assigns.conversation.selected do + nil -> + {:noreply, socket} + + conversation -> + if socket.assigns[:typing_timer] do + Process.cancel_timer(socket.assigns.typing_timer) + end + + Phoenix.PubSub.broadcast_from( + Elektrine.PubSub, + self(), + "conversation:#{conversation.id}", + {:user_stopped_typing, socket.assigns.current_user.id} + ) + + Elektrine.Messaging.Federation.publish_typing_stopped( + conversation.id, + socket.assigns.current_user.id + ) + + {:noreply, assign(socket, :typing_timer, nil)} + end + end + + def handle_event( + "react_to_message", + %{"message_id" => message_id_str, "emoji" => emoji}, + socket + ) do + with {:ok, message_id} <- parse_positive_int(message_id_str), + false <- + Map.get( + socket.assigns.moderation.user_timeout_status, + socket.assigns.current_user.id, + false + ) do + case Messaging.add_chat_reaction(message_id, socket.assigns.current_user.id, emoji) do + {:ok, _reaction} -> {:noreply, socket} + {:error, _} -> {:noreply, socket} + end + else + true -> + {:noreply, + notify_error(socket, "You are currently timed out and cannot react to messages")} + + :error -> + {:noreply, socket} + end + end + + def handle_event("delete_message", %{"message_id" => message_id}, socket) do + if socket.assigns.current_user do + case parse_positive_int(message_id) do + {:ok, message_id} -> + case Messaging.delete_chat_message(message_id, socket.assigns.current_user.id) do + {:ok, _deleted_message} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_info("Message deleted")} + + {:error, :unauthorized} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_error("You can only delete your own messages")} + + {:error, :not_found} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_error("Message not found")} + + {:error, _} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_error("Failed to delete message")} + end + + :error -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_error("Failed to delete message")} + end + else + {:noreply, notify_error(socket, "Unauthorized")} + end + end + + def handle_event("delete_message_admin", %{"message_id" => message_id}, socket) do + if Helpers.conversation_admin_socket?(socket) do + case parse_positive_int(message_id) do + {:ok, message_id} -> + case Messaging.delete_chat_message(message_id, socket.assigns.current_user.id, true) do + {:ok, deleted_message} -> + conversation_id = + socket.assigns.conversation.selected && socket.assigns.conversation.selected.id + + Messaging.log_moderation_action( + "delete_message", + deleted_message.sender_id, + socket.assigns.current_user.id, + conversation_id: conversation_id, + reason: "Admin message deletion", + details: %{message_id: message_id, message_content: deleted_message.content} + ) + + {:noreply, + socket + |> hide_message_context_menu() + |> notify_info("Message deleted")} + + {:error, :not_found} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_info("Message not found")} + + {:error, :already_deleted} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_info("Message already deleted")} + + {:error, _} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_error("Failed to delete message")} + end + + :error -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_error("Failed to delete message")} + end + else + {:noreply, + socket + |> hide_message_context_menu() + |> notify_error("Unauthorized")} + end + end + + def handle_event("validate_upload", _params, socket) do + {:noreply, socket} + end + + def handle_event("cancel_upload", %{"ref" => ref, "upload_name" => upload_name}, socket) do + upload_atom = + case upload_name do + "chat_attachments" -> :chat_attachments + "server_icon_upload" -> :server_icon_upload + "group_avatar_upload" -> :group_avatar_upload + "channel_avatar_upload" -> :channel_avatar_upload + _ -> :chat_attachments + end + + {:noreply, cancel_upload(socket, upload_atom, ref)} + end + + def handle_event("cancel_upload", %{"ref" => ref}, socket) do + {:noreply, cancel_upload(socket, :chat_attachments, ref)} + end + + def handle_event("scroll_to_message", %{"message_id" => message_id}, socket) do + {:noreply, socket |> push_event("scroll_to_message", %{message_id: message_id})} + end + + def handle_event("search_messages", params, socket) do + query = Map.get(params, "query") || Map.get(params, "value") || "" + search_tokens = Map.get(params, "search_tokens", []) + + if String.length(query) >= 2 and socket.assigns.conversation.selected do + case Messaging.search_messages_in_conversation( + socket.assigns.conversation.selected.id, + socket.assigns.current_user.id, + query, + search_tokens: search_tokens + ) do + {:ok, results} -> + {:noreply, + assign(socket, :search, %{ + socket.assigns.search + | message_query: query, + message_results: results + })} + + {:error, _} -> + {:noreply, + assign(socket, :search, %{ + socket.assigns.search + | message_query: query, + message_results: [] + })} + end + else + {:noreply, + assign(socket, :search, %{ + socket.assigns.search + | message_query: query, + message_results: [] + })} + end + end + + def handle_event("cancel_reply", _params, socket) do + {:noreply, assign(socket, :message, %{socket.assigns.message | reply_to: nil})} + end + + def handle_event("reply_to_message", %{"message_id" => message_id}, socket) do + message = + case parse_positive_int(message_id) do + {:ok, message_id} -> Enum.find(socket.assigns.messages, &(&1.id == message_id)) + :error -> nil + end + + {:noreply, + socket + |> hide_message_context_menu() + |> assign(:message, %{socket.assigns.message | reply_to: message})} + end + + def handle_event("copy_message", %{"message_id" => message_id}, socket) do + message = + case parse_positive_int(message_id) do + {:ok, message_id} -> Enum.find(socket.assigns.messages, &(&1.id == message_id)) + :error -> nil + end + + socket = hide_message_context_menu(socket) + + if message do + {:noreply, + push_event(socket, "copy_to_clipboard", %{text: message.content, type: "message"})} + else + {:noreply, socket} + end + end + + def handle_event("pin_message", %{"message_id" => message_id}, socket) do + case parse_positive_int(message_id) do + {:ok, message_id} -> + case Messaging.pin_chat_message(message_id, socket.assigns.current_user.id) do + {:ok, _message} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_info("Message pinned")} + + {:error, :unauthorized} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_error("Only moderators can pin messages")} + + {:error, :pin_limit_reached} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_error("Pin limit reached for this conversation")} + + {:error, :already_pinned} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_info("Message is already pinned")} + + {:error, _} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_error("Failed to pin message")} + end + + :error -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_error("Failed to pin message")} + end + end + + def handle_event("unpin_message", %{"message_id" => message_id}, socket) do + case parse_positive_int(message_id) do + {:ok, message_id} -> + case Messaging.unpin_chat_message(message_id, socket.assigns.current_user.id) do + {:ok, _message} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_info("Message unpinned")} + + {:error, :unauthorized} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_error("Only moderators can unpin messages")} + + {:error, _} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_error("Failed to unpin message")} + end + + :error -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_error("Failed to unpin message")} + end + end + + def handle_event( + "send_voice_message", + %{"audio_data" => audio_data, "duration" => duration, "mime_type" => mime_type}, + socket + ) do + case socket.assigns.conversation.selected do + nil -> + {:noreply, socket} + + conversation -> + if Map.get( + socket.assigns.moderation.user_timeout_status, + socket.assigns.current_user.id, + false + ) do + {:noreply, notify_error(socket, "You are currently timed out and cannot send messages")} + else + case Base.decode64(audio_data) do + {:ok, audio_binary} -> + extension = + case mime_type do + "audio/webm" -> "webm" + "audio/mp4" -> "m4a" + "audio/ogg" -> "ogg" + _ -> "webm" + end + + filename = "voice_#{System.system_time(:second)}.#{extension}" + + case Uploads.upload_voice_message( + audio_binary, + filename, + mime_type, + socket.assigns.current_user.id + ) do + {:ok, metadata} -> + case Messaging.create_voice_message( + conversation.id, + socket.assigns.current_user.id, + metadata.key, + duration, + mime_type + ) do + {:ok, _message} -> + {:noreply, socket} + + {:error, :rate_limited} -> + {:noreply, notify_error(socket, "Sending too fast! Please slow down.")} + + {:error, _reason} -> + {:noreply, notify_error(socket, "Failed to send voice message")} + end + + {:error, _reason} -> + {:noreply, notify_error(socket, "Failed to upload voice message")} + end + + :error -> + {:noreply, notify_error(socket, "Invalid audio data")} + end + end + end + end + + def handle_event("voice_recording_error", %{"error" => error}, socket) do + {:noreply, notify_error(socket, error)} + end + + defp refresh_chat_e2ee_devices(socket) do + case socket.assigns.conversation.selected do + %{id: conversation_id} -> + assign( + socket, + :chat_e2ee_devices, + Messaging.list_chat_encryption_devices_for_conversation(conversation_id) + ) + + _ -> + socket + end + end + + defp broadcast_chat_e2ee_devices_changed(socket) do + case socket.assigns.conversation.selected do + %{id: conversation_id} -> + Phoenix.PubSub.broadcast_from( + Elektrine.PubSub, + self(), + "conversation:#{conversation_id}", + {:chat_e2ee_devices_changed, conversation_id} + ) + + _ -> + :ok + end + end + + defp put_sent_message(socket, conversation, message) do + message = Elektrine.Repo.preload(message, sender: [:profile]) + unread_counts = Map.put(socket.assigns.conversation.unread_counts || %{}, conversation.id, 0) + + if socket.assigns[:typing_timer] do + Process.cancel_timer(socket.assigns.typing_timer) + end + + Phoenix.PubSub.broadcast_from( + Elektrine.PubSub, + self(), + "conversation:#{conversation.id}", + {:user_stopped_typing, socket.assigns.current_user.id} + ) + + Elektrine.Messaging.Federation.publish_typing_stopped( + conversation.id, + socket.assigns.current_user.id + ) + + conversations = + update_sent_conversation_preview(socket.assigns.conversation.list, conversation, message) + + last_message_read_status = + Helpers.calculate_last_message_read_status(conversations, socket.assigns.current_user.id) + + sorted_conversations = + Helpers.sort_conversations_by_unread( + conversations, + unread_counts, + socket.assigns.current_user.id + ) + + scoped_conversations = + Helpers.scope_conversations_to_server( + sorted_conversations, + socket.assigns[:active_server_id] + ) + + filtered_conversations = + if socket.assigns.search.conversation_query != "" do + Helpers.filter_conversations( + scoped_conversations, + socket.assigns.search.conversation_query, + socket.assigns.current_user.id + ) + else + scoped_conversations + end + + socket + |> assign(:messages, Helpers.dedupe_messages(socket.assigns.messages ++ [message])) + |> assign(:newest_message_id, message.id) + |> assign(:has_more_newer_messages, false) + |> assign(:message, %{ + socket.assigns.message + | new_message: "", + reply_to: nil, + loading_messages: false, + read_status: Map.put(socket.assigns.message.read_status || %{}, message.id, []) + }) + |> assign(:typing_timer, nil) + |> assign(:conversation, %{ + socket.assigns.conversation + | list: sorted_conversations, + filtered: filtered_conversations, + unread_counts: unread_counts, + last_message_read_status: last_message_read_status + }) + |> push_event("clear_message_input", %{}) + |> push_event("scroll_to_bottom", %{}) + end + + defp update_sent_conversation_preview(conversations, conversation, message) + when is_list(conversations) do + Enum.map(conversations, fn current -> + if current.id == conversation.id do + %{ + current + | messages: [message], + last_message_at: message_datetime(message, current.last_message_at) + } + else + current + end + end) + end + + defp update_sent_conversation_preview(_, _conversation, _message), do: [] + + defp message_datetime(%{inserted_at: %DateTime{} = datetime}, _fallback), do: datetime + + defp message_datetime(%{inserted_at: %NaiveDateTime{} = naive_datetime}, _fallback), + do: DateTime.from_naive!(naive_datetime, "Etc/UTC") + + defp message_datetime(_message, fallback), do: fallback + + defp broadcast_typing(socket) do + case socket.assigns.conversation.selected do + nil -> + socket + + conversation -> + should_broadcast = + case socket.assigns[:last_typing_broadcast] do + nil -> true + last_time -> System.system_time(:millisecond) - last_time > 2000 + end + + socket = + if should_broadcast do + Phoenix.PubSub.broadcast_from( + Elektrine.PubSub, + self(), + "conversation:#{conversation.id}", + {:user_typing, socket.assigns.current_user.id, + socket.assigns.current_user.handle || socket.assigns.current_user.username} + ) + + Elektrine.Messaging.Federation.publish_typing_started( + conversation.id, + socket.assigns.current_user.id + ) + + assign(socket, :last_typing_broadcast, System.system_time(:millisecond)) + else + socket + end + + if socket.assigns[:typing_timer] do + Process.cancel_timer(socket.assigns.typing_timer) + end + + timer = Process.send_after(self(), :clear_typing, 3000) + assign(socket, :typing_timer, timer) + end + end + + defp parse_int(value) when is_integer(value), do: value + + defp parse_int(value) when is_binary(value) do + case Integer.parse(value) do + {integer, ""} -> integer + _ -> nil + end + end + + defp parse_int(_), do: nil + + defp parse_positive_int(value) do + case parse_int(value) do + integer when is_integer(integer) and integer > 0 -> {:ok, integer} + _ -> :error + end + end + + defp hide_message_context_menu(socket) do + assign(socket, :context_menu, %{ + socket.assigns.context_menu + | message: nil, + selected_text: nil + }) + end + + defp ensure_can_send_current_conversation(socket) do + case socket.assigns.conversation.selected do + %{id: conversation_id} -> + RoomACL.authorize_local_user_action( + conversation_id, + socket.assigns.current_user.id, + :write + ) + + _ -> + {:error, :no_conversation} + end + end + + defp message_send_error_message(:unauthorized), do: "You cannot send messages in this chat." + + defp message_send_error_message(:not_authorized_for_room), + do: "You cannot send messages in this chat." + + defp message_send_error_message(:no_conversation), do: "Select a chat before sending." + + defp message_send_error_message(reason), do: Elektrine.Privacy.privacy_error_message(reason) + + defp maybe_apply_slash_command("", _uploaded_files, socket) do + {:send, "", socket} + end + + defp maybe_apply_slash_command(content, uploaded_files, socket) do + if uploaded_files != [] or not String.starts_with?(content, "/") do + {:send, content, socket} + else + conversation = socket.assigns.conversation.selected + user = socket.assigns.current_user + + case SlashCommands.process(content, + conversation: conversation, + endpoint_url: ElektrineWeb.Endpoint.url(), + user_display: user.display_name, + user_handle: user.handle, + username: user.username + ) do + {:send, resolved_content} -> {:send, resolved_content, socket} + {:noop, info_message} -> {:halt, notify_info(socket, info_message)} + {:error, error_message} -> {:halt, notify_error(socket, error_message)} + end + end + end +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/thread_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/thread_operations.ex new file mode 100644 index 0000000..fa099ec --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/thread_operations.ex @@ -0,0 +1,319 @@ +defmodule ArblargWeb.ChatLive.Operations.ThreadOperations do + @moduledoc """ + Handles chat thread operations: creating threads from messages, the + right-hand thread panel (thread list + single-thread view with composer), + archiving, and the PubSub events that keep open panels in sync. + """ + + import Phoenix.Component + import ElektrineWeb.Live.NotificationHelpers + + alias ArblargWeb.ChatLive.State + alias Elektrine.Messaging + + ## Events + + def handle_event("toggle_thread_panel", _params, socket) do + threads = socket.assigns.threads + + if threads.show_panel do + {:noreply, assign(socket, :threads, %{threads | show_panel: false})} + else + {:noreply, + assign(socket, :threads, refresh_thread_lists(%{threads | show_panel: true}, socket))} + end + end + + def handle_event("close_thread_panel", _params, socket) do + threads = socket.assigns.threads + + {:noreply, + assign(socket, :threads, %{threads | show_panel: false, active: nil, messages: []})} + end + + def handle_event("back_to_thread_list", _params, socket) do + threads = socket.assigns.threads + + {:noreply, assign(socket, :threads, %{threads | active: nil, messages: [], composer: ""})} + end + + def handle_event("toggle_archived_threads", _params, socket) do + threads = socket.assigns.threads + show_archived = !threads.show_archived + + archived = + if show_archived and selected_conversation_id(socket) do + Messaging.list_chat_threads(selected_conversation_id(socket), :archived) + else + threads.archived + end + + {:noreply, + assign(socket, :threads, %{threads | show_archived: show_archived, archived: archived})} + end + + def handle_event("create_thread", %{"message_id" => message_id}, socket) do + user_id = socket.assigns.current_user.id + + case Messaging.create_chat_thread_from_message(message_id, user_id) do + {:ok, thread} -> + {:noreply, + socket + |> hide_message_context_menu() + |> open_thread(thread) + |> notify_info("Thread created")} + + {:error, :thread_exists} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_info("This message already has a thread")} + + {:error, :unauthorized} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_error("You don't have permission to create threads here")} + + {:error, :unsupported_conversation_type} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_error("Threads are only available in channels")} + + {:error, _} -> + {:noreply, + socket + |> hide_message_context_menu() + |> notify_error("Failed to create thread")} + end + end + + def handle_event("open_thread", %{"thread_id" => thread_id}, socket) do + with {:ok, thread_id} <- parse_positive_int(thread_id), + %{} = thread <- Messaging.get_chat_thread(thread_id), + true <- thread.conversation_id == selected_conversation_id(socket) do + {:noreply, open_thread(socket, thread)} + else + _ -> {:noreply, notify_error(socket, "Thread not found")} + end + end + + def handle_event("archive_thread", %{"thread_id" => thread_id}, socket) do + with {:ok, thread_id} <- parse_positive_int(thread_id), + {:ok, _thread} <- + Messaging.archive_chat_thread(thread_id, socket.assigns.current_user.id) do + {:noreply, notify_info(socket, "Thread archived")} + else + {:error, :unauthorized} -> + {:noreply, notify_error(socket, "You don't have permission to archive this thread")} + + _ -> + {:noreply, notify_error(socket, "Failed to archive thread")} + end + end + + def handle_event("unarchive_thread", %{"thread_id" => thread_id}, socket) do + with {:ok, thread_id} <- parse_positive_int(thread_id), + {:ok, _thread} <- + Messaging.unarchive_chat_thread(thread_id, socket.assigns.current_user.id) do + {:noreply, notify_info(socket, "Thread reopened")} + else + {:error, :unauthorized} -> + {:noreply, notify_error(socket, "You don't have permission to reopen this thread")} + + _ -> + {:noreply, notify_error(socket, "Failed to reopen thread")} + end + end + + def handle_event("update_thread_composer", %{"message" => value}, socket) do + threads = socket.assigns.threads + {:noreply, assign(socket, :threads, %{threads | composer: value})} + end + + def handle_event("send_thread_message", %{"message" => content}, socket) do + threads = socket.assigns.threads + content = String.trim(content || "") + + cond do + is_nil(threads.active) -> + {:noreply, socket} + + content == "" -> + {:noreply, socket} + + true -> + case Messaging.create_chat_thread_message( + threads.active.id, + socket.assigns.current_user.id, + content + ) do + {:ok, _message} -> + {:noreply, assign(socket, :threads, %{socket.assigns.threads | composer: ""})} + + {:error, :thread_archived} -> + {:noreply, notify_error(socket, "This thread is archived")} + + {:error, :unauthorized} -> + {:noreply, notify_error(socket, "You can't send messages here")} + + {:error, _} -> + {:noreply, notify_error(socket, "Failed to send message")} + end + end + end + + ## PubSub info routing (chained before the generic message info router) + + def route_info(info, socket) do + case info do + {:thread_created, thread} -> + {:handled, handle_thread_upserted(socket, thread)} + + {:thread_updated, thread} -> + {:handled, handle_thread_upserted(socket, thread)} + + {:thread_archived, thread} -> + {:handled, handle_thread_upserted(socket, thread)} + + {:new_thread_message, message} -> + {:handled, handle_new_thread_message(socket, message)} + + _ -> + :unhandled + end + end + + ## Helpers used by the template + + @doc """ + Returns the thread rooted at `message_id`, if any (active or archived). + """ + def thread_for_message(%State.Threads{} = threads, message_id) do + Enum.find(threads.list ++ threads.archived, &(&1.root_message_id == message_id)) + end + + def thread_for_message(_threads, _message_id), do: nil + + ## Internal + + defp handle_thread_upserted(socket, thread) do + if thread.conversation_id == selected_conversation_id(socket) do + threads = socket.assigns.threads + + active = + if threads.active && threads.active.id == thread.id do + thread + else + threads.active + end + + list = upsert_sorted(threads.list, thread, is_nil(thread.archived_at)) + + archived = + if threads.show_archived do + upsert_sorted(threads.archived, thread, not is_nil(thread.archived_at)) + else + threads.archived + end + + {:noreply, + assign(socket, :threads, %{threads | list: list, archived: archived, active: active})} + else + {:noreply, socket} + end + end + + defp handle_new_thread_message(socket, message) do + threads = socket.assigns.threads + + if threads.active && threads.active.id == message.thread_id do + messages = + if Enum.any?(threads.messages, &(&1.id == message.id)) do + threads.messages + else + threads.messages ++ [message] + end + + {:noreply, assign(socket, :threads, %{threads | messages: messages})} + else + {:noreply, socket} + end + end + + defp open_thread(socket, thread) do + threads = socket.assigns.threads + messages = Messaging.list_chat_thread_messages(thread.id) + + assign(socket, :threads, %{ + threads + | show_panel: true, + active: thread, + messages: messages, + composer: "", + list: refresh_active_list(socket) + }) + end + + defp refresh_thread_lists(threads, socket) do + case selected_conversation_id(socket) do + nil -> + threads + + conversation_id -> + archived = + if threads.show_archived do + Messaging.list_chat_threads(conversation_id, :archived) + else + threads.archived + end + + %{threads | list: Messaging.list_chat_threads(conversation_id), archived: archived} + end + end + + defp refresh_active_list(socket) do + case selected_conversation_id(socket) do + nil -> socket.assigns.threads.list + conversation_id -> Messaging.list_chat_threads(conversation_id) + end + end + + defp upsert_sorted(threads, thread, belongs?) do + without = Enum.reject(threads, &(&1.id == thread.id)) + + if belongs? do + [thread | without] + |> Enum.sort_by(&(&1.last_activity_at || &1.inserted_at), {:desc, DateTime}) + else + without + end + end + + defp selected_conversation_id(socket) do + case socket.assigns.conversation.selected do + %{id: id} -> id + _ -> nil + end + end + + defp parse_positive_int(value) when is_integer(value) and value > 0, do: {:ok, value} + + defp parse_positive_int(value) when is_binary(value) do + case Integer.parse(value) do + {int, ""} when int > 0 -> {:ok, int} + _ -> :error + end + end + + defp parse_positive_int(_value), do: :error + + defp hide_message_context_menu(socket) do + assign(socket, :context_menu, %{ + socket.assigns.context_menu + | message: nil, + selected_text: nil + }) + end +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/ui_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/ui_operations.ex new file mode 100644 index 0000000..b3e0d09 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/ui_operations.ex @@ -0,0 +1,260 @@ +defmodule ArblargWeb.ChatLive.Operations.UIOperations do + @moduledoc """ + Handles UI state: modals, dropdowns, search, emoji picker, etc. + Extracted from ChatLive.Home. + """ + + import Phoenix.LiveView + import Phoenix.Component + + @overlay_ui_keys [ + :show_new_chat, + :show_create_group, + :show_create_channel, + :show_group_modal, + :show_channel_modal, + :show_category_modal, + :show_server_modal, + :show_browse_channels, + :show_settings_modal, + :show_edit_modal, + :show_add_members_modal, + :show_message_search_modal, + :show_emoji_picker, + :show_profile_modal, + :show_member_management, + :show_moderation_log, + :show_browse_modal + ] + + def handle_event("close_dropdown", _params, socket) do + # Just acknowledge the event - dropdown will close automatically + {:noreply, socket} + end + + def handle_event("toggle_mobile_search", _params, socket) do + {:noreply, assign(socket, :show_mobile_search, !socket.assigns.show_mobile_search)} + end + + def handle_event("close_chat_overlay", _params, socket) do + ui = Enum.reduce(@overlay_ui_keys, socket.assigns.ui, &Map.put(&2, &1, false)) + + context_menu = %{ + socket.assigns.context_menu + | conversation: nil, + message: nil, + selected_text: nil + } + + {:noreply, + socket + |> assign(:ui, ui) + |> assign(:context_menu, context_menu) + |> assign(:show_mobile_search, false) + |> assign(:show_report_modal, false) + |> assign(:report_type, nil) + |> assign(:report_id, nil) + |> assign(:report_metadata, %{}) + |> assign(:show_image_modal, false) + |> assign(:modal_image_url, nil) + |> assign(:modal_images, []) + |> assign(:modal_image_index, 0) + |> assign(:modal_post, nil)} + end + + def handle_event("stop_event", _params, socket) do + {:noreply, socket} + end + + def handle_event("navigate_to_origin", %{"url" => url}, socket) do + ElektrineWeb.SafeLiveNavigation.noreply(socket, url) + end + + def handle_event("navigate_to_embedded_post", %{"url" => url}, socket) do + ElektrineWeb.SafeLiveNavigation.noreply(socket, url) + end + + def handle_event("show_message_search", _params, socket) do + {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_message_search_modal, true))} + end + + def handle_event("hide_message_search", _params, socket) do + {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_message_search_modal, false))} + end + + def handle_event("toggle_pinned_panel", _params, socket) do + show_pinned_panel = !socket.assigns.ui.show_pinned_panel + + {:noreply, + assign(socket, :ui, Map.put(socket.assigns.ui, :show_pinned_panel, show_pinned_panel))} + end + + def handle_event("ignore", _params, socket) do + {:noreply, socket} + end + + def handle_event("view_original_context", %{"message_id" => message_id}, socket) do + {:noreply, push_event(socket, "scroll_to_message", %{message_id: message_id})} + end + + def handle_event("show_report_modal", %{"type" => type, "id" => id}, socket) do + case parse_positive_int(id) do + {:ok, id} -> + {:noreply, + socket + |> assign(:show_report_modal, true) + |> assign(:report_type, type) + |> assign(:report_id, id)} + + :error -> + {:noreply, put_flash(socket, :error, "Invalid report target")} + end + end + + def handle_event("close_report_modal", _params, socket) do + {:noreply, + socket + |> assign(:show_report_modal, false) + |> assign(:report_type, nil) + |> assign(:report_id, nil) + |> assign(:report_metadata, %{})} + end + + # Image Modal Operations + def handle_event( + "open_image_modal", + %{"images" => images_json, "index" => index} = params, + socket + ) do + with {:ok, images} when is_list(images) <- Jason.decode(images_json), + {:ok, index_int} <- parse_non_negative_int(index) do + url = params["url"] || Enum.at(images, index_int, List.first(images)) + + {:noreply, + socket + |> assign(:show_image_modal, true) + |> assign(:modal_image_url, url) + |> assign(:modal_images, images) + |> assign(:modal_image_index, index_int) + |> assign(:modal_post, modal_message(params["message_id"], socket))} + else + _ -> {:noreply, put_flash(socket, :error, "Unable to open image")} + end + end + + def handle_event("close_image_modal", _params, socket) do + {:noreply, + socket + |> assign(:show_image_modal, false) + |> assign(:modal_image_url, nil) + |> assign(:modal_images, []) + |> assign(:modal_image_index, 0) + |> assign(:modal_post, nil)} + end + + def handle_event("next_image", _params, socket) do + total = length(socket.assigns.modal_images) + + if total > 0 do + new_index = rem(socket.assigns.modal_image_index + 1, total) + new_url = Enum.at(socket.assigns.modal_images, new_index) + + {:noreply, + socket + |> assign(:modal_image_index, new_index) + |> assign(:modal_image_url, new_url)} + else + {:noreply, socket} + end + end + + def handle_event("prev_image", _params, socket) do + total = length(socket.assigns.modal_images) + + if total > 0 do + new_index = rem(socket.assigns.modal_image_index - 1 + total, total) + new_url = Enum.at(socket.assigns.modal_images, new_index) + + {:noreply, + socket + |> assign(:modal_image_index, new_index) + |> assign(:modal_image_url, new_url)} + else + {:noreply, socket} + end + end + + def handle_event("next_media_post", _params, socket) do + navigate_to_media_message(socket, :next) + end + + def handle_event("prev_media_post", _params, socket) do + navigate_to_media_message(socket, :prev) + end + + defp navigate_to_media_message(socket, direction) do + modal_post = socket.assigns[:modal_post] + messages = socket.assigns[:messages] || [] + + if is_nil(modal_post) or Enum.empty?(messages) do + {:noreply, socket} + else + # Find messages with media + media_messages = + Enum.filter(messages, fn msg -> + media_urls = msg.media_urls || [] + media_urls != [] + end) + + # Find current message index in media_messages + current_index = Enum.find_index(media_messages, fn msg -> msg.id == modal_post.id end) + + if is_nil(current_index) do + {:noreply, socket} + else + # Calculate new index + total = length(media_messages) + + new_index = + case direction do + :next -> rem(current_index + 1, total) + :prev -> rem(current_index - 1 + total, total) + end + + new_message = Enum.at(media_messages, new_index) + new_images = new_message.media_urls || [] + new_url = List.first(new_images) + + {:noreply, + socket + |> assign(:modal_post, new_message) + |> assign(:modal_images, new_images) + |> assign(:modal_image_index, 0) + |> assign(:modal_image_url, new_url)} + end + end + end + + defp modal_message(nil, _socket), do: nil + + defp modal_message(message_id, socket) do + case parse_positive_int(message_id) do + {:ok, message_id} -> Enum.find(socket.assigns.messages, fn msg -> msg.id == message_id end) + :error -> nil + end + end + + defp parse_positive_int(value) do + case Integer.parse(to_string(value)) do + {int, ""} when int > 0 -> {:ok, int} + _ -> :error + end + end + + defp parse_non_negative_int(value) do + case Integer.parse(to_string(value)) do + {int, ""} when int >= 0 -> {:ok, int} + _ -> :error + end + end +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/voice_channel_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/voice_channel_operations.ex new file mode 100644 index 0000000..bcb8b05 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/voice_channel_operations.ex @@ -0,0 +1,242 @@ +defmodule ArblargWeb.ChatLive.Operations.VoiceChannelOperations do + @moduledoc """ + Voice channel operations for the chat LiveView: joining/leaving voice + channels, mute toggling, and sidebar occupancy driven by Phoenix Presence + on the `voice:` topics. + + The LiveView only orchestrates: the WebRTC mesh itself lives in the + `VoiceChannel` JS hook, which talks to `ElektrineWeb.VoiceChannel` over the + user socket (same transport style as 1:1 calls). + """ + + import Phoenix.LiveView + import Phoenix.Component + import ElektrineWeb.Live.NotificationHelpers + + alias ArblargWeb.ChatLive.Operations.Helpers + alias Elektrine.Calls.Transport, as: CallTransport + alias Elektrine.Messaging.VoiceChannels + alias ElektrineWeb.Presence + + def handle_event("join_voice_channel", %{"conversation_id" => conversation_id}, socket) do + user_id = socket.assigns.current_user.id + + with {:ok, conversation_id} <- parse_positive_int(conversation_id), + :ok <- + VoiceChannels.authorize_join(conversation_id, user_id, occupant_ids(conversation_id)) do + conversation = find_conversation(socket, conversation_id) + transport = CallTransport.descriptor_for_user(user_id, conversation_id) + + {:noreply, + socket + |> assign(:voice, %{ + socket.assigns.voice + | joined_id: conversation_id, + joined_name: (conversation && conversation.name) || "Voice channel", + muted: false + }) + |> push_event("voice_join", %{ + conversation_id: conversation_id, + user_id: user_id, + user_token: user_token(socket), + ice_servers: transport["ice_servers"], + transport: transport + })} + else + {:error, :channel_full} -> + {:noreply, notify_error(socket, "This voice channel is full")} + + {:error, :already_joined} -> + {:noreply, notify_error(socket, "You're already connected to this voice channel")} + + {:error, :remote_mirror} -> + {:noreply, + notify_error(socket, "Voice channels on remote servers can't be joined from here")} + + {:error, :not_found} -> + {:noreply, notify_error(socket, "Voice channel not found")} + + _ -> + {:noreply, notify_error(socket, "You don't have access to this voice channel")} + end + end + + def handle_event("leave_voice_channel", _params, socket) do + {:noreply, + socket + |> assign(:voice, reset_connection(socket.assigns.voice)) + |> push_event("voice_leave", %{})} + end + + def handle_event("toggle_voice_mute", _params, socket) do + {:noreply, push_event(socket, "voice_toggle_mute", %{})} + end + + # Confirmations and errors reported back by the VoiceChannel JS hook. + + def handle_event("voice_joined", _params, socket) do + {:noreply, socket} + end + + # Sent by the hook after a LiveView reconnect while a media session is + # still running, so the connected bar reflects reality again. + def handle_event("voice_rejoined", %{"conversation_id" => conversation_id} = params, socket) do + case parse_positive_int(conversation_id) do + {:ok, conversation_id} -> + conversation = find_conversation(socket, conversation_id) + + {:noreply, + assign(socket, :voice, %{ + socket.assigns.voice + | joined_id: conversation_id, + joined_name: (conversation && conversation.name) || "Voice channel", + muted: params["muted"] == true + })} + + _ -> + {:noreply, socket} + end + end + + def handle_event("voice_left", _params, socket) do + {:noreply, assign(socket, :voice, reset_connection(socket.assigns.voice))} + end + + def handle_event("voice_mute_changed", %{"muted" => muted}, socket) do + {:noreply, assign(socket, :voice, %{socket.assigns.voice | muted: muted == true})} + end + + def handle_event("voice_error", params, socket) do + {:noreply, + socket + |> assign(:voice, reset_connection(socket.assigns.voice)) + |> notify_error(voice_error_message(params["reason"]))} + end + + @doc """ + Handles presence diffs broadcast on `voice:` topics. + + Returns `{:handled, result}` or `:unhandled` (same contract as the other + `route_info` operation modules). + """ + def route_info( + %Phoenix.Socket.Broadcast{topic: "voice:" <> conversation_id, event: "presence_diff"}, + socket + ) do + case parse_positive_int(conversation_id) do + {:ok, conversation_id} -> + occupants = + Map.put( + socket.assigns.voice.occupants, + conversation_id, + occupants_for(conversation_id) + ) + + {:handled, + {:noreply, assign(socket, :voice, %{socket.assigns.voice | occupants: occupants})}} + + _ -> + {:handled, {:noreply, socket}} + end + end + + def route_info(%Phoenix.Socket.Broadcast{topic: "voice:" <> _rest}, socket) do + {:handled, {:noreply, socket}} + end + + def route_info(_info, _socket), do: :unhandled + + @doc """ + Subscribes the LiveView to the presence topics of every voice channel in + the conversation list and refreshes occupant snapshots. Call whenever the + conversation list is (re)loaded. + """ + def sync_voice_channels(socket, conversations) when is_list(conversations) do + voice_ids = + conversations + |> Enum.filter(&(&1.type == "voice_channel")) + |> MapSet.new(& &1.id) + + subscribed = socket.assigns.voice.subscribed_ids + + if connected?(socket) do + Enum.each(MapSet.difference(voice_ids, subscribed), fn id -> + Phoenix.PubSub.subscribe(Elektrine.PubSub, "voice:#{id}") + end) + + Enum.each(MapSet.difference(subscribed, voice_ids), fn id -> + Phoenix.PubSub.unsubscribe(Elektrine.PubSub, "voice:#{id}") + end) + end + + occupants = Map.new(voice_ids, fn id -> {id, occupants_for(id)} end) + + assign(socket, :voice, %{ + socket.assigns.voice + | subscribed_ids: voice_ids, + occupants: occupants + }) + end + + def sync_voice_channels(socket, _conversations), do: socket + + @doc """ + Occupant maps (user_id, username, display_name, avatar, muted) for one + voice channel, ordered by join time. + """ + def occupants_for(conversation_id) do + "voice:#{conversation_id}" + |> Presence.list() + |> Enum.flat_map(fn {_key, %{metas: metas}} -> Enum.take(metas, 1) end) + |> Enum.sort_by(&{Map.get(&1, :joined_at, 0), Map.get(&1, :user_id, 0)}) + |> Enum.map( + &%{ + user_id: Map.get(&1, :user_id), + username: Map.get(&1, :username), + display_name: Map.get(&1, :display_name) || Map.get(&1, :username), + avatar: Map.get(&1, :avatar), + muted: Map.get(&1, :muted, false) + } + ) + end + + defp occupant_ids(conversation_id) do + conversation_id + |> occupants_for() + |> Enum.map(& &1.user_id) + end + + defp find_conversation(socket, conversation_id) do + Enum.find(socket.assigns.conversation.list, &(&1.id == conversation_id)) + end + + defp reset_connection(voice_state) do + %{voice_state | joined_id: nil, joined_name: nil, muted: false} + end + + defp voice_error_message("channel_full"), do: "This voice channel is full" + defp voice_error_message("already_joined"), do: "You're already connected in another tab" + + defp voice_error_message("unauthorized"), + do: "You don't have access to this voice channel" + + defp voice_error_message(reason) when is_binary(reason) and reason != "", + do: "Voice channel error: #{reason}" + + defp voice_error_message(_reason), do: "Voice channel connection failed" + + defp parse_positive_int(value) when is_integer(value) and value > 0, do: {:ok, value} + + defp parse_positive_int(value) when is_binary(value) do + case Integer.parse(value) do + {parsed, ""} when parsed > 0 -> {:ok, parsed} + _ -> {:error, :invalid_id} + end + end + + defp parse_positive_int(_value), do: {:error, :invalid_id} + + defp user_token(socket) do + socket.assigns[:user_token] || Helpers.generate_user_token(socket.assigns.current_user.id) + end +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/router.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/router.ex new file mode 100644 index 0000000..5869f35 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/router.ex @@ -0,0 +1,195 @@ +defmodule ArblargWeb.ChatLive.Router do + @moduledoc """ + Routes handle_event calls to the appropriate operation module. + This module acts as a dispatcher to keep the main LiveView clean. + """ + + require Logger + + alias ArblargWeb.ChatLive.Operations.{ + CallOperations, + ContextMenuOperations, + ConversationOperations, + DirectMessageOperations, + EmojiGifOperations, + GroupChannelOperations, + MemberOperations, + MessageOperations, + ThreadOperations, + UIOperations, + VoiceChannelOperations + } + + @event_handlers %{ + "send_message" => MessageOperations, + "send_client_encrypted_message" => MessageOperations, + "register_chat_encryption_device" => MessageOperations, + "chat_e2ee_key" => MessageOperations, + "chat_typing" => MessageOperations, + "update_message" => MessageOperations, + "stop_typing" => MessageOperations, + "handle_keydown" => MessageOperations, + "validate_upload" => MessageOperations, + "cancel_upload" => MessageOperations, + "load_older_messages" => MessageOperations, + "load_newer_messages" => MessageOperations, + "scroll_to_newest" => MessageOperations, + "scroll_to_message" => MessageOperations, + "react_to_message" => MessageOperations, + "cancel_reply" => MessageOperations, + "reply_to_message" => MessageOperations, + "copy_message" => MessageOperations, + "delete_message" => MessageOperations, + "delete_message_admin" => MessageOperations, + "search_messages" => MessageOperations, + "pin_message" => MessageOperations, + "unpin_message" => MessageOperations, + "send_voice_message" => MessageOperations, + "voice_recording_error" => MessageOperations, + "toggle_thread_panel" => ThreadOperations, + "close_thread_panel" => ThreadOperations, + "back_to_thread_list" => ThreadOperations, + "toggle_archived_threads" => ThreadOperations, + "create_thread" => ThreadOperations, + "open_thread" => ThreadOperations, + "archive_thread" => ThreadOperations, + "unarchive_thread" => ThreadOperations, + "update_thread_composer" => ThreadOperations, + "send_thread_message" => ThreadOperations, + "select_conversation" => ConversationOperations, + "search_conversations" => ConversationOperations, + "clear_selection" => ConversationOperations, + "pin_conversation" => ConversationOperations, + "unpin_conversation" => ConversationOperations, + "mark_as_read" => ConversationOperations, + "clear_history" => ConversationOperations, + "show_settings" => ConversationOperations, + "hide_settings" => ConversationOperations, + "show_edit_conversation" => ConversationOperations, + "hide_edit_conversation" => ConversationOperations, + "update_conversation" => ConversationOperations, + "delete_conversation" => ConversationOperations, + "leave_conversation" => ConversationOperations, + "view_profile" => ConversationOperations, + "share_conversation" => ConversationOperations, + "show_conversation_info" => ConversationOperations, + "toggle_new_chat" => GroupChannelOperations, + "show_create_group" => GroupChannelOperations, + "show_create_server" => GroupChannelOperations, + "hide_create_server" => GroupChannelOperations, + "show_create_channel" => GroupChannelOperations, + "show_create_category" => GroupChannelOperations, + "hide_create_category" => GroupChannelOperations, + "create_channel_category" => GroupChannelOperations, + "toggle_category_collapse" => GroupChannelOperations, + "create_group" => GroupChannelOperations, + "create_server" => GroupChannelOperations, + "create_channel" => GroupChannelOperations, + "cancel_create" => GroupChannelOperations, + "toggle_user_selection" => GroupChannelOperations, + "show_browse_modal" => GroupChannelOperations, + "hide_browse_modal" => GroupChannelOperations, + "browse_tab" => GroupChannelOperations, + "browse_search" => GroupChannelOperations, + "join_conversation" => GroupChannelOperations, + "join_group" => GroupChannelOperations, + "join_channel" => GroupChannelOperations, + "join_server" => GroupChannelOperations, + "filter_server" => GroupChannelOperations, + "select_server" => GroupChannelOperations, + "clear_server_scope" => GroupChannelOperations, + "show_add_members" => MemberOperations, + "hide_add_members" => MemberOperations, + "add_member_to_conversation" => MemberOperations, + "approve_remote_join_request" => MemberOperations, + "decline_remote_join_request" => MemberOperations, + "kick_member" => MemberOperations, + "promote_member" => MemberOperations, + "demote_member" => MemberOperations, + "timeout_user" => MemberOperations, + "remove_timeout_user" => MemberOperations, + "kick_user" => MemberOperations, + "show_member_management" => MemberOperations, + "hide_member_management" => MemberOperations, + "show_moderation_log" => MemberOperations, + "hide_moderation_log" => MemberOperations, + "start_dm" => DirectMessageOperations, + "search_users" => DirectMessageOperations, + "block_user" => DirectMessageOperations, + "unblock_user" => DirectMessageOperations, + "show_user_profile" => DirectMessageOperations, + "hide_profile_modal" => DirectMessageOperations, + "initiate_call" => CallOperations, + "answer_call" => CallOperations, + "reject_call" => CallOperations, + "end_call" => CallOperations, + "toggle_audio" => CallOperations, + "toggle_video" => CallOperations, + "audio_toggled" => CallOperations, + "video_toggled" => CallOperations, + "call_error" => CallOperations, + "call_started" => CallOperations, + "remote_stream_ready" => CallOperations, + "call_answered" => CallOperations, + "call_connected" => CallOperations, + "call_ended" => CallOperations, + "call_ended_by_user" => CallOperations, + "call_rejected" => CallOperations, + "join_voice_channel" => VoiceChannelOperations, + "leave_voice_channel" => VoiceChannelOperations, + "toggle_voice_mute" => VoiceChannelOperations, + "voice_joined" => VoiceChannelOperations, + "voice_rejoined" => VoiceChannelOperations, + "voice_left" => VoiceChannelOperations, + "voice_mute_changed" => VoiceChannelOperations, + "voice_error" => VoiceChannelOperations, + "close_dropdown" => UIOperations, + "toggle_mobile_search" => UIOperations, + "close_chat_overlay" => UIOperations, + "stop_event" => UIOperations, + "navigate_to_origin" => UIOperations, + "navigate_to_embedded_post" => UIOperations, + "show_message_search" => UIOperations, + "hide_message_search" => UIOperations, + "toggle_pinned_panel" => UIOperations, + "ignore" => UIOperations, + "view_original_context" => UIOperations, + "show_report_modal" => UIOperations, + "close_report_modal" => UIOperations, + "open_image_modal" => UIOperations, + "close_image_modal" => UIOperations, + "next_image" => UIOperations, + "prev_image" => UIOperations, + "next_media_post" => UIOperations, + "prev_media_post" => UIOperations, + "show_context_menu" => ContextMenuOperations, + "hide_context_menu" => ContextMenuOperations, + "show_message_context_menu" => ContextMenuOperations, + "hide_message_context_menu" => ContextMenuOperations, + "toggle_emoji_picker" => EmojiGifOperations, + "insert_emoji" => EmojiGifOperations, + "emoji_search" => EmojiGifOperations, + "emoji_tab" => EmojiGifOperations + } + + @doc false + def handler_for(event_name), do: Map.get(@event_handlers, event_name) + + @doc false + def event_handlers, do: @event_handlers + + @doc """ + Routes an event to the appropriate operation module based on the event name. + Returns {:noreply, socket} tuple. + """ + def route_event(event_name, params, socket) do + case handler_for(event_name) do + nil -> + Logger.warning("Unknown event in ChatLive.Index: #{event_name}") + {:noreply, socket} + + handler_module -> + handler_module.handle_event(event_name, params, socket) + end + end +end diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/state.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/state.ex new file mode 100644 index 0000000..a6a287a --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/live/chat_live/state.ex @@ -0,0 +1,159 @@ +defmodule ArblargWeb.ChatLive.State do + @moduledoc """ + State management structs for the Chat LiveView. + + This module defines structs to group related assigns and reduce assign bloat. + """ + + defmodule UI do + @moduledoc """ + UI state including modal visibility and picker states. + """ + defstruct show_new_chat: false, + show_create_group: false, + show_create_channel: false, + show_group_modal: false, + show_channel_modal: false, + show_category_modal: false, + show_pinned_panel: false, + show_server_modal: false, + show_browse_channels: false, + show_settings_modal: false, + show_edit_modal: false, + show_add_members_modal: false, + show_message_search_modal: false, + show_emoji_picker: false, + show_profile_modal: false, + show_member_management: false, + show_moderation_log: false, + show_browse_modal: false, + show_incoming_call: false + end + + defmodule Search do + @moduledoc """ + Search-related state for users, messages, and emojis. + """ + defstruct query: "", + results: [], + conversation_query: "", + message_query: "", + message_results: [], + browse_query: "", + user_results: [], + emoji_query: "", + emoji_tab: "Smileys" + end + + defmodule Call do + @moduledoc """ + Voice/video call state. + """ + defstruct active_call: nil, + incoming_call: nil, + audio_enabled: true, + video_enabled: true, + status: "connecting" + end + + defmodule Form do + @moduledoc """ + Form input state for group/channel creation and editing. + """ + defstruct group_name: "", + group_description: "", + group_is_public: false, + edit_name: "", + edit_description: "", + selected_users: [] + end + + defmodule ContextMenu do + @moduledoc """ + Context menu state for conversations and messages. + """ + defstruct conversation: nil, + message: nil, + selected_text: nil, + position: %{x: 0, y: 0} + end + + defmodule Message do + @moduledoc """ + Message composition and display state. + """ + defstruct new_message: "", + reply_to: nil, + uploaded_files: [], + loading_messages: false, + typing_users: [], + read_status: %{} + end + + defmodule Conversation do + @moduledoc """ + Conversation list and selection state. + """ + defstruct list: [], + selected: nil, + filtered: [], + last_message_read_status: %{}, + unread_count: 0, + unread_counts: %{} + end + + defmodule Threads do + @moduledoc """ + Thread state for the selected conversation: the thread list, the thread + open in the right-hand panel (with its messages), and panel visibility. + """ + defstruct list: [], + archived: [], + show_panel: false, + show_archived: false, + active: nil, + messages: [], + composer: "" + end + + defmodule Voice do + @moduledoc """ + Voice channel state: the channel the user is connected to, local mute + state, live occupant lists per voice channel (from presence), and the + set of voice presence topics this LiveView is subscribed to. + """ + defstruct joined_id: nil, + joined_name: nil, + muted: false, + occupants: %{}, + subscribed_ids: MapSet.new() + end + + defmodule Moderation do + @moduledoc """ + Content moderation state. + """ + defstruct log: [], + user_timeout_status: %{} + end + + defmodule Browse do + @moduledoc """ + Browse servers/channels/groups state. + """ + defstruct tab: "servers", + public_servers: [], + public_channels: [], + public_groups: [], + filtered_servers: [], + filtered_channels: [], + filtered_groups: [] + end + + defmodule Profile do + @moduledoc """ + User profile modal state. + """ + defstruct user: nil + end +end diff --git a/apps/arblarg/lib/arblarg_web/optional_module.ex b/apps/arblarg/lib/arblarg_web/optional_module.ex new file mode 100644 index 0000000..306e885 --- /dev/null +++ b/apps/arblarg/lib/arblarg_web/optional_module.ex @@ -0,0 +1,14 @@ +defmodule ArblargWeb.OptionalModule do + @moduledoc false + + alias Elektrine.Platform.Modules + + def call(module_id, module, function, args, fallback) do + if Modules.compiled?(module_id) and Modules.enabled?(module_id) and + Code.ensure_loaded?(module) and function_exported?(module, function, length(args)) do + apply(module, function, args) + else + fallback + end + end +end diff --git a/apps/arblarg/mix.exs b/apps/arblarg/mix.exs new file mode 100644 index 0000000..56d9505 --- /dev/null +++ b/apps/arblarg/mix.exs @@ -0,0 +1,52 @@ +defmodule Arblarg.MixProject do + use Mix.Project + + def project do + [ + app: :arblarg, + version: "0.1.0", + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + elixir: "~> 1.19", + elixirc_paths: elixirc_paths(Mix.env()), + elixirc_options: elixirc_options(), + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger], + mod: {Arblarg.Application, []} + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + defp elixirc_options do + if Mix.env() == :test, do: [ignore_module_conflict: true], else: [] + end + + defp deps do + [ + internal_dep(:elektrine), + internal_dep(:elektrine_web), + {:phoenix, "== 1.8.9"}, + {:phoenix_html, "== 4.3.0"}, + {:phoenix_live_view, "== 1.1.30"}, + {:jason, "== 1.4.5"} + ] + end + + defp internal_dep(app) do + if Mix.Project.umbrella?() do + {app, in_umbrella: true} + else + {app, path: "../#{app}"} + end + end +end diff --git a/apps/arblarg/test/arblarg_test.exs b/apps/arblarg/test/arblarg_test.exs new file mode 100644 index 0000000..21f7385 --- /dev/null +++ b/apps/arblarg/test/arblarg_test.exs @@ -0,0 +1,10 @@ +defmodule ArblargTest do + use ExUnit.Case + + test "exposes chat facade functions" do + functions = Arblarg.__info__(:functions) + + assert {:create_dm_conversation, 2} in functions + assert {:create_chat_text_message, 3} in functions + end +end diff --git a/apps/arblarg/test/arblarg_web/live/chat_live/operations/emoji_gif_operations_test.exs b/apps/arblarg/test/arblarg_web/live/chat_live/operations/emoji_gif_operations_test.exs new file mode 100644 index 0000000..707b710 --- /dev/null +++ b/apps/arblarg/test/arblarg_web/live/chat_live/operations/emoji_gif_operations_test.exs @@ -0,0 +1,21 @@ +defmodule ArblargWeb.ChatLive.Operations.EmojiGifOperationsTest do + use ExUnit.Case, async: true + + alias ArblargWeb.ChatLive.Operations.EmojiGifOperations + alias Phoenix.LiveView.Socket + + test "emoji_search accepts keyup payloads with value" do + socket = %Socket{ + assigns: %{__changed__: %{}, search: %{emoji_query: "", emoji_tab: "Smileys"}} + } + + assert {:noreply, updated_socket} = + EmojiGifOperations.handle_event( + "emoji_search", + %{"key" => "y", "value" => "cry"}, + socket + ) + + assert updated_socket.assigns.search.emoji_query == "cry" + end +end diff --git a/apps/arblarg/test/test_helper.exs b/apps/arblarg/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/apps/arblarg/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/apps/atomine/lib/atomine.ex b/apps/atomine/lib/atomine.ex new file mode 100644 index 0000000..0c3041b --- /dev/null +++ b/apps/atomine/lib/atomine.ex @@ -0,0 +1,5 @@ +defmodule Atomine do + @moduledoc """ + Boundary module for the Atomine personhood/anti-bot proof application. + """ +end diff --git a/apps/atomine/lib/atomine/attestation.ex b/apps/atomine/lib/atomine/attestation.ex new file mode 100644 index 0000000..ec6ec49 --- /dev/null +++ b/apps/atomine/lib/atomine/attestation.ex @@ -0,0 +1,80 @@ +defmodule Atomine.Attestation do + @moduledoc """ + Portable anti-bot attestation issued by Atomine. + + Attestations cover proof-of-effort receipts, passkey-bound continuity receipts, + and anonymous effort token MVPs. Domain/web identity claims remain represented by + `Atomine.Proof` because they are durable public discovery claims. + """ + + use Ecto.Schema + import Ecto.Changeset + + @kinds ~w(pow_receipt anonymous_effort_token passkey_receipt) + @statuses ~w(issued redeemed expired revoked) + + schema "atomine_attestations" do + field :public_id, :string + belongs_to :user, Elektrine.Accounts.User + belongs_to :passkey_credential, Elektrine.Accounts.PasskeyCredential + field :kind, :string + field :status, :string, default: "issued" + field :issuer, :string + field :subject, :string + field :subject_hash, :string + field :artifact_hash, :string + field :artifact, :string + field :difficulty, :integer + field :metadata, :map, default: %{} + field :issued_at, :utc_datetime + field :expires_at, :utc_datetime + field :redeemed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + def kinds, do: @kinds + def statuses, do: @statuses + + def changeset(attestation, attrs) do + attestation + |> cast(attrs, [ + :public_id, + :user_id, + :passkey_credential_id, + :kind, + :status, + :issuer, + :subject, + :subject_hash, + :artifact_hash, + :artifact, + :difficulty, + :metadata, + :issued_at, + :expires_at, + :redeemed_at + ]) + |> validate_required([ + :public_id, + :kind, + :status, + :issuer, + :artifact_hash, + :artifact, + :issued_at, + :expires_at + ]) + |> validate_inclusion(:kind, @kinds) + |> validate_inclusion(:status, @statuses) + |> validate_length(:public_id, max: 120) + |> validate_length(:issuer, max: 255) + |> validate_length(:subject_hash, max: 128) + |> validate_length(:artifact_hash, max: 128) + |> validate_number(:difficulty, greater_than_or_equal_to: 0, less_than_or_equal_to: 64) + |> unique_constraint(:public_id) + |> unique_constraint(:artifact_hash) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:passkey_credential_id) + end +end diff --git a/apps/atomine/lib/atomine/attestations.ex b/apps/atomine/lib/atomine/attestations.ex new file mode 100644 index 0000000..73a305e --- /dev/null +++ b/apps/atomine/lib/atomine/attestations.ex @@ -0,0 +1,656 @@ +defmodule Atomine.Attestations do + @moduledoc """ + Portable anti-bot attestations. + + This module intentionally separates three low-friction signals from durable + DNS/web identity claims: + + * proof-of-work receipts: signed proof that local compute was spent + * passkey receipts: signed proof that an authenticated account has passkey continuity + * anonymous effort tokens: MVP bearer tokens that can later be replaced by blind tokens + """ + + import Ecto.Query, warn: false + + alias Atomine.Attestation + alias Elektrine.Accounts.PasskeyCredential + alias Elektrine.Accounts.User + alias Elektrine.Repo + + @pow_challenge_prefix "Atomine pow challenge v1" + @receipt_prefix "Atomine receipt v1" + @anonymous_token_prefix "Atomine anonymous effort token v1" + @default_pow_difficulty 18 + @default_pow_ttl_seconds 300 + @default_receipt_ttl_seconds 7 * 24 * 60 * 60 + @default_token_ttl_seconds 24 * 60 * 60 + @gate_version "atomine-gate-v1" + @gate_layers ~w(pow browser_instrumentation) + @browser_instrumentation_checks ~w( + layout.getComputedStyle + canvas.toDataURL + event.isTrusted + navigator.webdriver + dom.querySelector + ) + + @doc "Returns public issuer metadata for external verifiers." + def issuer_metadata(endpoint_base \\ nil) do + endpoint_base = normalize_endpoint_base(endpoint_base) + + %{ + issuer: issuer(), + protocol: "atomine-attestations", + version: "v1", + signing_alg: "hmac-sha256-dev", + artifacts: ["pow_receipt", "gate_proof", "anonymous_effort_token", "passkey_receipt"], + privacy: %{ + gate_proof: "challenge-bound layer results stored as summarized metadata", + anonymous_effort_token: "bearer-token-mvp", + blind_tokens: "planned" + }, + endpoints: %{ + pow_challenge: endpoint(endpoint_base, "/api/atomine/pow/challenge"), + pow_receipts: endpoint(endpoint_base, "/api/atomine/pow/receipts"), + anonymous_tokens: endpoint(endpoint_base, "/api/atomine/anonymous-tokens"), + spend_anonymous_token: endpoint(endpoint_base, "/api/atomine/anonymous-tokens/spend"), + verify_artifact: endpoint(endpoint_base, "/api/atomine/artifacts/verify") + } + } + end + + @doc "Issues a stateless Atomine proof challenge." + def issue_pow_challenge(opts \\ []) do + now = now() + difficulty = public_pow_difficulty(Keyword.get(opts, :difficulty)) + ttl_seconds = Keyword.get(opts, :ttl_seconds, @default_pow_ttl_seconds) + + payload = %{ + "kind" => "pow_challenge", + "issuer" => issuer(), + "difficulty" => difficulty, + "nonce" => random_token(18), + "issued_at" => encode_time(now), + "expires_at" => encode_time(DateTime.add(now, ttl_seconds, :second)) + } + + {:ok, + %{ + "challenge" => sign_statement(@pow_challenge_prefix, payload), + "difficulty" => difficulty, + "expires_at" => payload["expires_at"], + "gate" => %{ + "version" => @gate_version, + "layers" => @gate_layers, + "browser_instrumentation" => %{ + "required_checks" => @browser_instrumentation_checks + } + } + }} + end + + @doc "Verifies a PoW solution and issues a portable signed receipt." + def issue_pow_receipt(attrs) when is_map(attrs) do + with {:ok, challenge_payload} <- + parse_statement(Map.get(attrs, "challenge"), @pow_challenge_prefix), + :ok <- ensure_not_expired(challenge_payload), + :ok <- ensure_minimum_difficulty(challenge_payload["difficulty"]), + solution when is_binary(solution) and solution != "" <- Map.get(attrs, "solution"), + true <- + valid_pow_solution?( + Map.get(attrs, "challenge"), + solution, + challenge_payload["difficulty"] + ) do + challenge_hash = artifact_hash(Map.get(attrs, "challenge")) + + use_challenge_once(challenge_hash, fn -> + subject = Map.get(attrs, "subject") + + issue_receipt( + "pow_receipt", + subject, + challenge_payload["difficulty"], + %{ + "challenge_hash" => challenge_hash, + "solution_hash" => artifact_hash(solution) + }, + user_id: Map.get(attrs, :user_id) + ) + end) + else + false -> {:error, :invalid_pow_solution} + nil -> {:error, :missing_pow_solution} + {:error, reason} -> {:error, reason} + _ -> {:error, :invalid_pow_solution} + end + end + + @doc "Issues a bearer anonymous effort token after a valid PoW solution." + def issue_anonymous_effort_token(attrs) when is_map(attrs) do + with {:ok, challenge_payload} <- + parse_statement(Map.get(attrs, "challenge"), @pow_challenge_prefix), + :ok <- ensure_not_expired(challenge_payload), + :ok <- ensure_minimum_difficulty(challenge_payload["difficulty"]), + solution when is_binary(solution) and solution != "" <- Map.get(attrs, "solution"), + true <- + valid_pow_solution?( + Map.get(attrs, "challenge"), + solution, + challenge_payload["difficulty"] + ), + :ok <- validate_gate_proof(Map.get(attrs, "gate_proof"), Map.get(attrs, "challenge")) do + challenge_hash = artifact_hash(Map.get(attrs, "challenge")) + + use_challenge_once(challenge_hash, fn -> + now = now() + public_id = public_id("aet") + + payload = %{ + "id" => public_id, + "kind" => "anonymous_effort_token", + "issuer" => issuer(), + "difficulty" => challenge_payload["difficulty"], + "issued_at" => encode_time(now), + "expires_at" => encode_time(DateTime.add(now, @default_token_ttl_seconds, :second)), + "nonce" => random_token(24) + } + + token = sign_statement(@anonymous_token_prefix, payload) + + insert_attestation(%{ + public_id: public_id, + kind: "anonymous_effort_token", + status: "issued", + issuer: issuer(), + artifact: token, + artifact_hash: artifact_hash(token), + difficulty: challenge_payload["difficulty"], + issued_at: now, + expires_at: decode_time!(payload["expires_at"]), + metadata: + %{"challenge_hash" => challenge_hash} + |> maybe_put_gate_proof_metadata(Map.get(attrs, "gate_proof")) + }) + end) + else + false -> {:error, :invalid_pow_solution} + nil -> {:error, :missing_pow_solution} + {:error, reason} -> {:error, reason} + _ -> {:error, :invalid_pow_solution} + end + end + + @doc "Redeems an anonymous effort token once." + def redeem_anonymous_effort_token(token, attrs \\ %{}) + + def redeem_anonymous_effort_token(token, attrs) when is_binary(token) and is_map(attrs) do + with {:ok, payload} <- parse_statement(token, @anonymous_token_prefix), + :ok <- ensure_not_expired(payload), + :ok <- validate_token_spend_attrs(attrs), + %Attestation{} <- Repo.get_by(Attestation, artifact_hash: artifact_hash(token)) do + Repo.transaction(fn -> + attestation = + Attestation + |> where([a], a.artifact_hash == ^artifact_hash(token)) + |> lock("FOR UPDATE") + |> Repo.one!() + + cond do + attestation.status == "redeemed" -> + Repo.rollback(:already_redeemed) + + DateTime.compare(attestation.expires_at, now()) == :lt -> + update_status(attestation, "expired") + Repo.rollback(:expired) + + true -> + case attestation + |> Attestation.changeset(%{ + status: "redeemed", + redeemed_at: now(), + metadata: spend_metadata(attestation.metadata, attrs, payload) + }) + |> Repo.update() do + {:ok, updated} -> updated + {:error, changeset} -> Repo.rollback(changeset) + end + end + end) + |> case do + {:ok, result} -> {:ok, result} + {:error, reason} -> {:error, reason} + end + else + nil -> {:error, :unknown_token} + {:error, reason} -> {:error, reason} + end + end + + def redeem_anonymous_effort_token(_, _), do: {:error, :invalid_token} + + @doc "Issues a passkey-bound continuity receipt for an authenticated user." + def issue_passkey_receipt(%User{id: user_id}, passkey_credential_id) do + with {id, ""} <- Integer.parse(to_string(passkey_credential_id)), + %PasskeyCredential{user_id: ^user_id} = credential <- Repo.get(PasskeyCredential, id) do + subject = "passkey:#{credential_fingerprint(credential)}" + + issue_receipt( + "passkey_receipt", + subject, + nil, + %{ + "passkey_credential_id" => credential.id, + "passkey_name" => credential.name, + "passkey_fingerprint" => credential_fingerprint(credential) + }, + user_id: user_id, + passkey_credential_id: credential.id + ) + else + nil -> {:error, :passkey_not_found} + :error -> {:error, :invalid_passkey_id} + _ -> {:error, :passkey_not_found} + end + end + + def issue_passkey_receipt(_, _), do: {:error, :invalid_user} + + @doc "Verifies any Atomine receipt/token statement signature and expiry." + def verify_artifact(artifact) when is_binary(artifact) do + cond do + String.starts_with?(artifact, @receipt_prefix) -> + verify_signed_artifact(artifact, @receipt_prefix) + + String.starts_with?(artifact, @anonymous_token_prefix) -> + verify_signed_artifact(artifact, @anonymous_token_prefix) + + true -> + {:error, :unsupported_artifact} + end + end + + def verify_artifact(_), do: {:error, :invalid_artifact} + + defp verify_signed_artifact(artifact, prefix) do + with {:ok, payload} <- parse_statement(artifact, prefix), + :ok <- ensure_not_expired(payload) do + persisted = Repo.get_by(Attestation, artifact_hash: artifact_hash(artifact)) + + {:ok, + %{ + payload: payload, + persisted: !is_nil(persisted), + status: persisted && persisted.status + }} + end + end + + defp issue_receipt(kind, subject, difficulty, metadata, opts) do + now = now() + public_id = public_id("rcpt") + expires_at = DateTime.add(now, @default_receipt_ttl_seconds, :second) + + payload = %{ + "id" => public_id, + "kind" => kind, + "issuer" => issuer(), + "subject_hash" => subject_hash(subject), + "difficulty" => difficulty, + "issued_at" => encode_time(now), + "expires_at" => encode_time(expires_at), + "nonce" => random_token(24) + } + + receipt = sign_statement(@receipt_prefix, payload) + + insert_attestation(%{ + public_id: public_id, + user_id: Keyword.get(opts, :user_id), + passkey_credential_id: Keyword.get(opts, :passkey_credential_id), + kind: kind, + status: "issued", + issuer: issuer(), + subject: subject, + subject_hash: subject_hash(subject), + artifact: receipt, + artifact_hash: artifact_hash(receipt), + difficulty: difficulty, + issued_at: now, + expires_at: expires_at, + metadata: metadata + }) + end + + defp insert_attestation(attrs) do + %Attestation{} + |> Attestation.changeset(attrs) + |> Repo.insert() + end + + defp use_challenge_once(challenge_hash, fun) + when is_binary(challenge_hash) and is_function(fun, 0) do + Repo.transaction(fn -> + Repo.query!("SELECT pg_advisory_xact_lock(hashtext($1))", [challenge_hash]) + + if challenge_used?(challenge_hash) do + Repo.rollback(:challenge_already_used) + else + case fun.() do + {:ok, attestation} -> attestation + {:error, reason} -> Repo.rollback(reason) + end + end + end) + |> case do + {:ok, attestation} -> {:ok, attestation} + {:error, :challenge_already_used} -> {:error, :challenge_already_used} + {:error, reason} -> {:error, reason} + end + end + + defp challenge_used?(challenge_hash) do + Attestation + |> where([a], a.kind in ["pow_receipt", "anonymous_effort_token"]) + |> where([a], fragment("?->>?", a.metadata, "challenge_hash") == ^challenge_hash) + |> Repo.exists?() + end + + defp update_status(attestation, status) do + attestation + |> Attestation.changeset(%{status: status}) + |> Repo.update() + end + + defp validate_token_spend_attrs(attrs) do + audience = Map.get(attrs, "audience") + nonce = Map.get(attrs, "nonce") + + cond do + not is_nil(audience) and not valid_spend_field?(audience, 500) -> + {:error, :invalid_audience} + + not is_nil(nonce) and not valid_spend_field?(nonce, 200) -> + {:error, :invalid_nonce} + + true -> + :ok + end + end + + defp valid_spend_field?(value, max_length) when is_binary(value) do + value = String.trim(value) + value != "" and String.length(value) <= max_length + end + + defp valid_spend_field?(_value, _max_length), do: false + + defp spend_metadata(metadata, attrs, payload) do + spend = + %{ + "token_id" => payload["id"], + "spent_at" => encode_time(now()) + } + |> maybe_put_trimmed("audience", Map.get(attrs, "audience")) + |> maybe_put_trimmed("nonce", Map.get(attrs, "nonce")) + + metadata + |> ensure_map() + |> Map.put("spend", spend) + end + + defp maybe_put_trimmed(map, key, value) when is_binary(value) do + case String.trim(value) do + "" -> map + trimmed -> Map.put(map, key, trimmed) + end + end + + defp maybe_put_trimmed(map, _key, _value), do: map + + defp ensure_map(value) when is_map(value), do: value + defp ensure_map(_value), do: %{} + + defp validate_gate_proof(nil, _challenge), do: {:error, :missing_gate_proof} + + defp validate_gate_proof(proof, challenge) when is_map(proof) and is_binary(challenge) do + browser_layer = Map.get(proof, "browser_instrumentation") + checks = if is_map(browser_layer), do: Map.get(browser_layer, "checks"), else: nil + check_names = gate_check_names(checks) + + cond do + Map.get(proof, "version") != @gate_version -> + {:error, :invalid_gate_proof} + + not Enum.all?(@gate_layers, &(&1 in Map.get(proof, "layers", []))) -> + {:error, :invalid_gate_proof} + + not is_map(browser_layer) -> + {:error, :invalid_gate_proof} + + Map.get(browser_layer, "challenge_hash") != artifact_hash(challenge) -> + {:error, :invalid_gate_proof} + + not is_list(checks) -> + {:error, :invalid_gate_proof} + + not Enum.all?(@browser_instrumentation_checks, &(&1 in check_names)) -> + {:error, :invalid_gate_proof} + + not Enum.all?(checks, &gate_check_ok?/1) -> + {:error, :invalid_gate_proof} + + true -> + :ok + end + end + + defp validate_gate_proof(_proof, _challenge), do: {:error, :invalid_gate_proof} + + defp gate_check_names(checks) when is_list(checks) do + checks + |> Enum.map(fn + check when is_map(check) -> Map.get(check, "name") + _ -> nil + end) + |> Enum.reject(&is_nil/1) + end + + defp gate_check_names(_checks), do: [] + + defp gate_check_ok?(%{"ok" => true, "duration_ms" => duration}) + when is_integer(duration) and duration >= 0, + do: true + + defp gate_check_ok?(_check), do: false + + defp maybe_put_gate_proof_metadata(metadata, proof) when is_map(proof) do + browser_layer = Map.get(proof, "browser_instrumentation", %{}) + checks = Map.get(browser_layer, "checks", []) + + Map.put(metadata, "gate_proof", %{ + "version" => Map.get(proof, "version"), + "layers" => Map.get(proof, "layers", []), + "browser_instrumentation" => %{ + "challenge_hash" => Map.get(browser_layer, "challenge_hash"), + "checks" => Enum.map(checks, &gate_check_summary/1), + "signals" => Map.get(browser_layer, "signals", %{}) + } + }) + end + + defp maybe_put_gate_proof_metadata(metadata, _proof), do: metadata + + defp gate_check_summary(check) when is_map(check) do + %{ + "name" => Map.get(check, "name"), + "ok" => Map.get(check, "ok"), + "duration_ms" => Map.get(check, "duration_ms") + } + end + + defp gate_check_summary(_check), do: %{} + + defp valid_pow_solution?(challenge, solution, difficulty) do + bits = normalize_difficulty(difficulty) + + digest = :crypto.hash(:sha256, challenge <> ":" <> solution) + leading_zero_bits(digest) >= bits + end + + defp leading_zero_bits(binary), do: leading_zero_bits(binary, 0) + + defp leading_zero_bits(<<0, rest::binary>>, acc), do: leading_zero_bits(rest, acc + 8) + + defp leading_zero_bits(<>, acc) do + acc + leading_zero_bits_in_byte(byte) + end + + defp leading_zero_bits(<<>>, acc), do: acc + + defp leading_zero_bits_in_byte(byte) do + Enum.find_value(7..0//-1, 8, fn shift -> + if Bitwise.band(byte, Bitwise.bsl(1, shift)) != 0, do: 7 - shift + end) + end + + defp sign_statement(prefix, payload) do + encoded_payload = payload |> Jason.encode!() |> Base.url_encode64(padding: false) + signature = sign(encoded_payload) + prefix <> " payload=" <> encoded_payload <> " sig=" <> signature + end + + defp parse_statement(statement, prefix) when is_binary(statement) do + with true <- String.starts_with?(statement, prefix <> " "), + %{"payload" => encoded_payload, "sig" => signature} <- parse_fields(statement, prefix), + true <- secure_compare(sign(encoded_payload), signature), + {:ok, json} <- Base.url_decode64(encoded_payload, padding: false), + {:ok, payload} <- Jason.decode(json) do + {:ok, payload} + else + false -> {:error, :invalid_signature} + :error -> {:error, :invalid_payload} + {:error, reason} -> {:error, reason} + _ -> {:error, :invalid_statement} + end + end + + defp parse_statement(_, _), do: {:error, :invalid_statement} + + defp parse_fields(statement, prefix) do + statement + |> String.replace_prefix(prefix <> " ", "") + |> String.split(" ", trim: true) + |> Map.new(fn field -> + case String.split(field, "=", parts: 2) do + [key, value] -> {key, value} + [key] -> {key, ""} + end + end) + end + + defp ensure_not_expired(%{"expires_at" => expires_at}) do + case DateTime.from_iso8601(expires_at) do + {:ok, expires_at, _} -> + if DateTime.compare(expires_at, now()) == :lt, do: {:error, :expired}, else: :ok + + _ -> + {:error, :invalid_expiry} + end + end + + defp ensure_not_expired(_), do: {:error, :missing_expiry} + + defp decode_time!(value) do + {:ok, time, _} = DateTime.from_iso8601(value) + time + end + + defp encode_time(%DateTime{} = time), do: DateTime.to_iso8601(time) + + defp ensure_minimum_difficulty(value) do + if normalize_difficulty(value) >= configured_pow_difficulty() do + :ok + else + {:error, :pow_difficulty_too_low} + end + end + + defp public_pow_difficulty(requested) do + requested + |> normalize_difficulty() + |> max(configured_pow_difficulty()) + end + + defp configured_pow_difficulty do + :elektrine + |> Application.get_env(:atomine_pow, []) + |> Keyword.get(:difficulty, @default_pow_difficulty) + |> normalize_difficulty() + end + + defp normalize_difficulty(value) when is_integer(value), do: value |> max(0) |> min(64) + + defp normalize_difficulty(value) when is_binary(value) do + case Integer.parse(value) do + {parsed, _} -> normalize_difficulty(parsed) + :error -> @default_pow_difficulty + end + end + + defp normalize_difficulty(_), do: @default_pow_difficulty + + defp normalize_endpoint_base(nil), do: nil + + defp normalize_endpoint_base(""), do: nil + defp normalize_endpoint_base(value) when is_binary(value), do: String.trim_trailing(value, "/") + defp normalize_endpoint_base(_), do: nil + + defp endpoint(nil, path), do: path + defp endpoint(base, path), do: base <> path + + defp public_id(prefix), do: prefix <> "_" <> random_token(18) + + defp random_token(bytes), + do: bytes |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false) + + defp subject_hash(nil), do: nil + defp subject_hash(""), do: nil + defp subject_hash(subject), do: artifact_hash("subject:" <> to_string(subject)) + + defp artifact_hash(value) do + :crypto.hash(:sha256, to_string(value)) + |> Base.url_encode64(padding: false) + end + + defp credential_fingerprint(%PasskeyCredential{credential_id: credential_id}) do + credential_id + |> Base.url_encode64(padding: false) + |> artifact_hash() + end + + defp sign(payload), + do: + :crypto.mac(:hmac, :sha256, signing_secret(), payload) |> Base.url_encode64(padding: false) + + defp signing_secret do + System.get_env("ATOMINE_ATTESTATION_SECRET") || Elektrine.RuntimeSecrets.secret_key_base() || + if Elektrine.RuntimeEnv.dev_or_test?() do + "atomine-dev-attestation-secret" + else + raise "attestation signing secret is not configured" + end + end + + defp issuer do + System.get_env("ATOMINE_ISSUER") || Elektrine.ActivityPub.instance_url() || "atomine.local" + end + + defp secure_compare(left, right) when is_binary(left) and is_binary(right) do + byte_size(left) == byte_size(right) and Plug.Crypto.secure_compare(left, right) + end + + defp secure_compare(_, _), do: false + + defp now, do: DateTime.utc_now() |> DateTime.truncate(:second) +end diff --git a/apps/atomine/lib/atomine/credit_account.ex b/apps/atomine/lib/atomine/credit_account.ex new file mode 100644 index 0000000..8ef637a --- /dev/null +++ b/apps/atomine/lib/atomine/credit_account.ex @@ -0,0 +1,40 @@ +defmodule Atomine.CreditAccount do + @moduledoc "Per-user Atomine Credit balance." + + use Ecto.Schema + import Ecto.Changeset + + @credit_types ~w(atomine_credit) + + schema "atomine_credit_accounts" do + belongs_to :user, Elektrine.Accounts.User + field :credit_type, :string + field :balance, :integer, default: 0 + field :lifetime_earned, :integer, default: 0 + field :lifetime_spent, :integer, default: 0 + field :metadata, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + def credit_types, do: @credit_types + + def changeset(account, attrs) do + account + |> cast(attrs, [ + :user_id, + :credit_type, + :balance, + :lifetime_earned, + :lifetime_spent, + :metadata + ]) + |> validate_required([:user_id, :credit_type, :balance, :lifetime_earned, :lifetime_spent]) + |> validate_inclusion(:credit_type, @credit_types) + |> validate_number(:balance, greater_than_or_equal_to: 0) + |> validate_number(:lifetime_earned, greater_than_or_equal_to: 0) + |> validate_number(:lifetime_spent, greater_than_or_equal_to: 0) + |> unique_constraint([:user_id, :credit_type]) + |> foreign_key_constraint(:user_id) + end +end diff --git a/apps/atomine/lib/atomine/credit_earning_policy.ex b/apps/atomine/lib/atomine/credit_earning_policy.ex new file mode 100644 index 0000000..d0aa97c --- /dev/null +++ b/apps/atomine/lib/atomine/credit_earning_policy.ex @@ -0,0 +1,344 @@ +defmodule Atomine.CreditEarningPolicy do + @moduledoc "Policy for converting anti-abuse proof systems into Atomine Credit grants." + + import Ecto.Query, warn: false + + alias Atomine.{CreditLedgerEntry, Credits, Proof} + alias Elektrine.Accounts.User + alias Elektrine.Repo + + @pow_daily_grant 1 + @pow_daily_claim_limit 20 + @credit_earning_actions ~w(verified_proof proof_of_work) + + @daily_earning_caps %{ + 0 => 20, + 1 => 50, + 2 => 100, + 3 => 250 + } + + @balance_caps %{ + 0 => 25, + 1 => 100, + 2 => 250, + 3 => 1_000 + } + + @proof_grants %{ + "dns" => 10, + "web" => 8, + "social" => 5, + "passkey" => 5, + "payment" => 15, + "vouch" => 10, + "manual" => 10 + } + + @doc "Returns the proof-of-X earning systems for product/UI discovery." + def earning_paths do + [ + %{ + key: "proof_of_personhood", + label: "Proof of personhood/control", + status: :active, + summary: "Verify DNS, web, social, GitHub, passkey, payment, or reviewed proof signals.", + reward: "5-15 Identity Credits per verified proof, once per proof." + }, + %{ + key: "proof_of_stake", + label: "Proof of stake", + status: :planned, + summary: "Lock stake for higher action capacity; abusive use can be slashed.", + reward: "Planned." + }, + %{ + key: "proof_of_work", + label: "Proof of work", + status: :active, + summary: "Spend computational or delivery-cost work to earn small temporary capacity.", + reward: + "#{@pow_daily_grant} Identity Credit per run, up to #{@pow_daily_claim_limit} per day." + }, + %{ + key: "proof_of_reputation", + label: "Proof of reputation", + status: :planned, + summary: "Clean account age, low reports, accepted messages, and good delivery history.", + reward: "Planned." + }, + %{ + key: "proof_of_service", + label: "Proof of service", + status: :planned, + summary: "Contribute useful network, moderation, or support work.", + reward: "Planned." + } + ] + end + + @doc "Returns the one-time Atomine Credit grant for a verified proof kind." + def verified_proof_grant_amount(kind), do: Map.get(@proof_grants, to_string(kind), 0) + + @doc "Returns the daily Atomine Credit grant for a proof-of-work claim." + def proof_of_work_grant_amount, do: @pow_daily_grant + + @doc "Returns the maximum proof-of-work credit claims per day." + def proof_of_work_daily_claim_limit, do: @pow_daily_claim_limit + + @doc "Grants Atomine Credits once when an eligible proof is verified." + def grant_for_verified_proof(%Proof{} = proof) do + amount = verified_proof_grant_amount(proof.kind) + + cond do + proof.claim_type != "positive" -> + {:ok, :not_eligible} + + proof.status != "verified" -> + {:ok, :not_verified} + + amount <= 0 -> + {:ok, :not_eligible} + + true -> + canonical_key = canonical_proof_key(proof) + reference_id = safe_reference_id(canonical_key) + + grant_once_per_claim( + proof.user_id, + amount, + "verified_proof:#{proof.kind}", + action: "verified_proof", + reference_type: "atomine_proof_claim", + reference_id: reference_id, + metadata: %{ + "proof_kind" => proof.kind, + "proof_subject" => proof.subject, + "proof_method" => proof.verification_method, + "proof_id" => proof.id, + "canonical_key" => canonical_key + } + ) + end + end + + @doc "Grants one proof-of-work Atomine Credit, capped per day." + def grant_for_proof_of_work(user_id, opts \\ []) when is_integer(user_id) do + date = Keyword.get(opts, :date, Date.utc_today()) + claims_today = proof_of_work_claims_today(user_id, date) + + if claims_today >= @pow_daily_claim_limit do + {:ok, :daily_claim_limit_reached} + else + claim_number = claims_today + 1 + + grant_once_per_claim( + user_id, + @pow_daily_grant, + "proof_of_work", + action: "proof_of_work", + date: date, + reference_type: "atomine_pow_claim", + reference_id: "#{user_id}:#{Date.to_iso8601(date)}:#{claim_number}", + metadata: %{ + "date" => Date.to_iso8601(date), + "difficulty" => Keyword.get(opts, :difficulty), + "claim_number" => claim_number, + "daily_claim_limit" => @pow_daily_claim_limit + } + ) + end + end + + defp grant_once_per_claim(user_id, amount, reason, opts) do + reference_type = Keyword.fetch!(opts, :reference_type) + reference_id = Keyword.fetch!(opts, :reference_id) + + cond do + existing_user_grant?(user_id, reason, reference_type, reference_id) -> + {:ok, :already_granted} + + existing_global_proof_claim?(reference_type, reference_id) -> + {:ok, :already_rewarded} + + true -> + case check_earning_caps(user_id, amount, Keyword.get(opts, :date, Date.utc_today())) do + :ok -> + Credits.grant_once( + user_id, + :atomine_credit, + amount, + reason, + opts + ) + + {:error, reason} -> + {:ok, reason} + end + end + end + + defp existing_user_grant?(user_id, reason, reference_type, reference_id) do + CreditLedgerEntry + |> where([e], e.user_id == ^user_id) + |> where([e], e.credit_type == "atomine_credit") + |> where([e], e.reason == ^reason) + |> where([e], e.reference_type == ^reference_type) + |> where([e], e.reference_id == ^to_string(reference_id)) + |> where([e], e.amount > 0) + |> Repo.exists?() + end + + defp existing_global_proof_claim?("atomine_proof_claim", reference_id) do + CreditLedgerEntry + |> where([e], e.credit_type == "atomine_credit") + |> where([e], e.reference_type == "atomine_proof_claim") + |> where([e], e.reference_id == ^to_string(reference_id)) + |> where([e], e.amount > 0) + |> Repo.exists?() + end + + defp existing_global_proof_claim?(_reference_type, _reference_id), do: false + + defp check_earning_caps(user_id, amount, date) do + %{daily: daily_cap, balance: balance_cap} = credit_caps(user_id) + + cond do + cap_exceeded?(daily_cap, earned_today(user_id, date), amount) -> + {:error, :daily_earning_cap_reached} + + cap_exceeded?(balance_cap, Credits.balance(user_id, :atomine_credit), amount) -> + {:error, :balance_cap_reached} + + true -> + :ok + end + end + + defp cap_exceeded?(nil, _current, _amount), do: false + defp cap_exceeded?(cap, current, amount), do: current + amount > cap + + defp earned_today(user_id, date) do + {start_at, end_at} = day_bounds(date) + + CreditLedgerEntry + |> where([e], e.user_id == ^user_id) + |> where([e], e.credit_type == "atomine_credit") + |> where([e], e.amount > 0) + |> where([e], e.action in ^@credit_earning_actions) + |> where([e], e.inserted_at >= ^start_at and e.inserted_at < ^end_at) + |> select([e], coalesce(sum(e.amount), 0)) + |> Repo.one() + end + + defp proof_of_work_claims_today(user_id, date) do + {start_at, end_at} = day_bounds(date) + + CreditLedgerEntry + |> where([e], e.user_id == ^user_id) + |> where([e], e.credit_type == "atomine_credit") + |> where([e], e.reason == "proof_of_work") + |> where([e], e.reference_type == "atomine_pow_claim") + |> where([e], e.amount > 0) + |> where([e], e.inserted_at >= ^start_at and e.inserted_at < ^end_at) + |> select([e], count(e.id)) + |> Repo.one() + end + + defp day_bounds(date) do + start_at = DateTime.new!(date, ~T[00:00:00], "Etc/UTC") |> DateTime.truncate(:second) + {start_at, DateTime.add(start_at, 1, :day)} + end + + defp credit_caps(user_id) do + case Repo.get(User, user_id) do + %User{is_admin: true} -> + %{daily: nil, balance: nil} + + %User{trust_level: trust_level} -> + tier = trust_level |> Kernel.||(0) |> min(3) |> max(0) + %{daily: Map.fetch!(@daily_earning_caps, tier), balance: Map.fetch!(@balance_caps, tier)} + + _ -> + %{daily: Map.fetch!(@daily_earning_caps, 0), balance: Map.fetch!(@balance_caps, 0)} + end + end + + defp canonical_proof_key(%Proof{kind: "dns", subject: subject}) do + "dns:#{canonical_dns_subject(subject)}" + end + + defp canonical_proof_key(%Proof{kind: "web"} = proof) do + "web:#{canonical_url(proof.evidence_url || proof.subject)}" + end + + defp canonical_proof_key(%Proof{verification_method: "oauth", metadata: metadata}) do + provider = metadata_value(metadata, "provider") + provider_account_id = metadata_value(metadata, "provider_account_id") + + "social:oauth:#{String.downcase(provider)}:#{provider_account_id}" + end + + defp canonical_proof_key(%Proof{kind: "social", subject: subject}) do + "social:#{canonical_url(subject)}" + end + + defp canonical_proof_key(%Proof{kind: kind, subject: subject}) do + "#{kind}:#{subject |> to_string() |> String.trim() |> String.downcase()}" + end + + defp canonical_dns_subject(subject) do + subject + |> to_string() + |> String.trim() + |> String.trim_trailing(".") + |> String.downcase() + end + + defp canonical_url(value) do + value = String.trim(to_string(value)) + + case URI.parse(value) do + %URI{host: host} = uri when is_binary(host) -> + scheme = uri.scheme |> to_string() |> String.downcase() + host = String.downcase(host) + port = canonical_port(uri.scheme, uri.port) + path = canonical_path(uri.path) + + [scheme, "://", host, port, path] + |> IO.iodata_to_binary() + |> String.trim_trailing("/") + + _ -> + value + end + end + + defp canonical_port("http", 80), do: "" + defp canonical_port("https", 443), do: "" + defp canonical_port(_scheme, nil), do: "" + defp canonical_port(_scheme, port), do: ":#{port}" + + defp canonical_path(nil), do: "/" + defp canonical_path(""), do: "/" + defp canonical_path(path), do: URI.decode(path) + + defp metadata_value(metadata, key) when is_map(metadata) do + metadata + |> Map.get(key, "") + |> to_string() + end + + defp metadata_value(_metadata, _key), do: "" + + defp safe_reference_id(value) do + value = to_string(value) + + if String.length(value) <= 200 do + value + else + hash = :crypto.hash(:sha256, value) |> Base.url_encode64(padding: false) + "sha256:#{hash}" + end + end +end diff --git a/apps/atomine/lib/atomine/credit_ledger_entry.ex b/apps/atomine/lib/atomine/credit_ledger_entry.ex new file mode 100644 index 0000000..da322c1 --- /dev/null +++ b/apps/atomine/lib/atomine/credit_ledger_entry.ex @@ -0,0 +1,45 @@ +defmodule Atomine.CreditLedgerEntry do + @moduledoc "Immutable Atomine Credit accounting event." + + use Ecto.Schema + import Ecto.Changeset + + alias Atomine.CreditAccount + + schema "atomine_credit_ledger_entries" do + belongs_to :user, Elektrine.Accounts.User + field :credit_type, :string + field :amount, :integer + field :reason, :string + field :action, :string + field :reference_type, :string + field :reference_id, :string + field :metadata, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + def changeset(entry, attrs) do + entry + |> cast(attrs, [ + :user_id, + :credit_type, + :amount, + :reason, + :action, + :reference_type, + :reference_id, + :metadata + ]) + |> validate_required([:user_id, :credit_type, :amount, :reason]) + |> validate_inclusion(:credit_type, CreditAccount.credit_types()) + |> validate_length(:reason, min: 1, max: 120) + |> validate_length(:action, max: 120) + |> validate_length(:reference_type, max: 120) + |> validate_length(:reference_id, max: 255) + |> validate_change(:amount, fn :amount, amount -> + if amount == 0, do: [amount: "must not be zero"], else: [] + end) + |> foreign_key_constraint(:user_id) + end +end diff --git a/apps/atomine/lib/atomine/credit_policy.ex b/apps/atomine/lib/atomine/credit_policy.ex new file mode 100644 index 0000000..a9a93b8 --- /dev/null +++ b/apps/atomine/lib/atomine/credit_policy.ex @@ -0,0 +1,77 @@ +defmodule Atomine.CreditPolicy do + @moduledoc "Policy helpers that translate account trust into Atomine Credit costs." + + alias Atomine.Credits + alias Elektrine.Accounts.Capabilities + + @first_dm_action "first_dm" + @send_email_action "send_email" + @first_dm_cost 1 + @send_email_cost 1 + + @doc "Returns whether the first-DM credit gate is enabled." + def dm_gate_enabled?, do: Capabilities.credit_gate_enabled?(:dm) + + @doc "Returns whether the outbound email credit gate is enabled." + def email_gate_enabled?, do: Capabilities.credit_gate_enabled?(:email) + + @doc "Returns current built-in action pricing for UI/API discovery." + def action_prices, do: Capabilities.action_prices() + + @doc "Spends Atomine Credits for a first DM against an arbitrary audience." + def spend_first_dm_credit(sender_id, recipient, opts \\ []) + + def spend_first_dm_credit(sender_id, recipient_id, opts) when is_integer(recipient_id) do + opts = Keyword.put(opts, :local_recipient_id, recipient_id) + spend_first_dm_credit(sender_id, "user:#{recipient_id}", opts) + end + + def spend_first_dm_credit(sender_id, audience, opts) when is_binary(audience) do + case Capabilities.first_dm_credit_requirement(sender_id, opts) do + :free -> + :ok + + :required -> + case Credits.spend( + sender_id, + :atomine_credit, + @first_dm_cost, + @first_dm_action, + audience, + idempotency_key: "#{@first_dm_action}:#{sender_id}:#{audience}" + ) do + {:ok, _spend} -> :ok + {:error, :insufficient_credits} -> {:error, :insufficient_dm_credits} + {:error, reason} -> {:error, reason} + end + + {:error, reason} -> + {:error, reason} + end + end + + @doc "Spends Atomine Credits when an external outbound send needs them." + def spend_email_credit(sender_id, audience, opts \\ []) when is_binary(audience) do + case Capabilities.email_credit_requirement(sender_id, opts) do + :free -> + :ok + + :required -> + case Credits.spend( + sender_id, + :atomine_credit, + @send_email_cost, + @send_email_action, + audience, + idempotency_key: Keyword.get(opts, :idempotency_key) + ) do + {:ok, _spend} -> :ok + {:error, :insufficient_credits} -> {:error, :insufficient_email_credits} + {:error, reason} -> {:error, reason} + end + + {:error, reason} -> + {:error, reason} + end + end +end diff --git a/apps/atomine/lib/atomine/credit_spend.ex b/apps/atomine/lib/atomine/credit_spend.ex new file mode 100644 index 0000000..2fdadbb --- /dev/null +++ b/apps/atomine/lib/atomine/credit_spend.ex @@ -0,0 +1,43 @@ +defmodule Atomine.CreditSpend do + @moduledoc "One Atomine Credit spend against an action and audience." + + use Ecto.Schema + import Ecto.Changeset + + alias Atomine.CreditAccount + + schema "atomine_credit_spends" do + belongs_to :user, Elektrine.Accounts.User + field :credit_type, :string + field :amount, :integer + field :action, :string + field :audience, :string + field :idempotency_key, :string + field :metadata, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + def changeset(spend, attrs) do + spend + |> cast(attrs, [ + :user_id, + :credit_type, + :amount, + :action, + :audience, + :idempotency_key, + :metadata + ]) + |> validate_required([:user_id, :credit_type, :amount, :action, :audience]) + |> validate_inclusion(:credit_type, CreditAccount.credit_types()) + |> validate_number(:amount, greater_than: 0) + |> validate_length(:action, min: 1, max: 120) + |> validate_length(:audience, min: 1, max: 500) + |> validate_length(:idempotency_key, max: 255) + |> unique_constraint([:user_id, :credit_type, :action, :idempotency_key], + name: :atomine_credit_spends_idempotency_index + ) + |> foreign_key_constraint(:user_id) + end +end diff --git a/apps/atomine/lib/atomine/credits.ex b/apps/atomine/lib/atomine/credits.ex new file mode 100644 index 0000000..4f5eddb --- /dev/null +++ b/apps/atomine/lib/atomine/credits.ex @@ -0,0 +1,244 @@ +defmodule Atomine.Credits do + @moduledoc """ + Atomine Credits are scarce balances used for anti-abuse gates. + + Credits are granted by trusted signals and spent by risky actions. This module + is intentionally ledger-backed so abuse review can trace where capacity came + from and where it was consumed. + """ + + import Ecto.Query, warn: false + + alias Atomine.{CreditAccount, CreditLedgerEntry, CreditSpend} + alias Elektrine.Repo + + @type credit_type :: String.t() | atom() + + @doc "Returns supported credit type identifiers." + def credit_types, do: CreditAccount.credit_types() + + @doc "Returns the current balance for a user and credit type." + def balance(user_id, credit_type) do + case normalize_credit_type(credit_type) do + {:ok, credit_type} -> + CreditAccount + |> where([a], a.user_id == ^user_id and a.credit_type == ^credit_type) + |> select([a], a.balance) + |> Repo.one() + |> Kernel.||(0) + + {:error, _reason} -> + 0 + end + end + + @doc "Grants credits and records a positive ledger entry." + def grant(user_id, credit_type, amount, reason, opts \\ []) + + def grant(user_id, credit_type, amount, reason, opts) + when is_integer(user_id) and is_integer(amount) and amount > 0 and is_binary(reason) do + with {:ok, credit_type} <- normalize_credit_type(credit_type) do + Repo.transaction(fn -> + account = get_or_insert_account!(user_id, credit_type) + + account + |> CreditAccount.changeset(%{ + balance: account.balance + amount, + lifetime_earned: account.lifetime_earned + amount + }) + |> Repo.update!() + + %CreditLedgerEntry{} + |> CreditLedgerEntry.changeset(%{ + user_id: user_id, + credit_type: credit_type, + amount: amount, + reason: reason, + action: Keyword.get(opts, :action), + reference_type: Keyword.get(opts, :reference_type), + reference_id: reference_id(opts), + metadata: Keyword.get(opts, :metadata, %{}) + }) + |> Repo.insert!() + end) + end + end + + def grant(_user_id, credit_type, _amount, _reason, _opts) do + with {:ok, _credit_type} <- normalize_credit_type(credit_type), do: {:error, :invalid_grant} + end + + @doc "Grants credits once for a stable reference." + def grant_once(user_id, credit_type, amount, reason, opts \\ []) + + def grant_once(user_id, credit_type, amount, reason, opts) do + with {:ok, credit_type} <- normalize_credit_type(credit_type) do + case existing_grant(user_id, credit_type, reason, opts) do + %CreditLedgerEntry{} = ledger_entry -> + {:ok, ledger_entry} + + nil -> + grant(user_id, credit_type, amount, reason, opts) + end + end + end + + @doc "Spends credits once for an action and audience." + def spend(user_id, credit_type, amount, action, audience, opts \\ []) + + def spend(user_id, credit_type, amount, action, audience, opts) + when is_integer(user_id) and is_integer(amount) and amount > 0 and is_binary(action) and + is_binary(audience) do + with {:ok, credit_type} <- normalize_credit_type(credit_type) do + Repo.transaction(fn -> + idempotency_key = Keyword.get(opts, :idempotency_key) + + case existing_spend(user_id, credit_type, action, idempotency_key) do + %CreditSpend{} = spend -> + spend + + nil -> + spend_fresh!(user_id, credit_type, amount, action, audience, opts) + end + end) + end + end + + def spend(_user_id, credit_type, _amount, _action, _audience, _opts) do + with {:ok, _credit_type} <- normalize_credit_type(credit_type), do: {:error, :invalid_spend} + end + + @doc "Returns recent ledger entries for a user." + def list_ledger_entries(user_id, opts \\ []) do + limit = opts |> Keyword.get(:limit, 50) |> min(200) + + CreditLedgerEntry + |> where([e], e.user_id == ^user_id) + |> order_by([e], desc: e.inserted_at, desc: e.id) + |> limit(^limit) + |> Repo.all() + end + + @doc "Returns credit accounts for a user ordered by credit type." + def list_accounts(user_id) do + CreditAccount + |> where([a], a.user_id == ^user_id) + |> order_by([a], asc: a.credit_type) + |> Repo.all() + end + + defp spend_fresh!(user_id, credit_type, amount, action, audience, opts) do + account = lock_account(user_id, credit_type) + + if is_nil(account) or account.balance < amount do + Repo.rollback(:insufficient_credits) + end + + account + |> CreditAccount.changeset(%{ + balance: account.balance - amount, + lifetime_spent: account.lifetime_spent + amount + }) + |> Repo.update!() + + spend = + %CreditSpend{} + |> CreditSpend.changeset(%{ + user_id: user_id, + credit_type: credit_type, + amount: amount, + action: action, + audience: audience, + idempotency_key: Keyword.get(opts, :idempotency_key), + metadata: Keyword.get(opts, :metadata, %{}) + }) + |> Repo.insert!() + + %CreditLedgerEntry{} + |> CreditLedgerEntry.changeset(%{ + user_id: user_id, + credit_type: credit_type, + amount: -amount, + reason: "spend:#{action}", + action: action, + reference_type: "atomine_credit_spend", + reference_id: to_string(spend.id), + metadata: %{"audience" => audience} + }) + |> Repo.insert!() + + spend + end + + defp existing_spend(_user_id, _credit_type, _action, nil), do: nil + defp existing_spend(_user_id, _credit_type, _action, ""), do: nil + + defp existing_spend(user_id, credit_type, action, idempotency_key) do + Repo.get_by(CreditSpend, + user_id: user_id, + credit_type: credit_type, + action: action, + idempotency_key: idempotency_key + ) + end + + defp existing_grant(user_id, credit_type, reason, opts) do + reference_type = Keyword.get(opts, :reference_type) + reference_id = reference_id(opts) + + if is_nil(reference_type) or is_nil(reference_id) do + nil + else + Repo.get_by(CreditLedgerEntry, + user_id: user_id, + credit_type: credit_type, + reason: reason, + reference_type: reference_type, + reference_id: reference_id + ) + end + end + + defp get_or_insert_account!(user_id, credit_type) do + case lock_account(user_id, credit_type) do + %CreditAccount{} = account -> + account + + nil -> + %CreditAccount{} + |> CreditAccount.changeset(%{user_id: user_id, credit_type: credit_type}) + |> Repo.insert!(on_conflict: :nothing, conflict_target: [:user_id, :credit_type]) + + lock_account(user_id, credit_type) + end + end + + defp lock_account(user_id, credit_type) do + CreditAccount + |> where([a], a.user_id == ^user_id and a.credit_type == ^credit_type) + |> lock("FOR UPDATE") + |> Repo.one() + end + + defp normalize_credit_type(value) when is_atom(value), + do: value |> Atom.to_string() |> normalize_credit_type() + + defp normalize_credit_type(value) when is_binary(value) do + credit_type = String.trim(value) + + if credit_type in CreditAccount.credit_types() do + {:ok, credit_type} + else + {:error, :invalid_credit_type} + end + end + + defp normalize_credit_type(_value), do: {:error, :invalid_credit_type} + + defp reference_id(opts) do + case Keyword.get(opts, :reference_id) do + nil -> nil + value -> to_string(value) + end + end +end diff --git a/apps/atomine/lib/atomine/live_proof_recheck_worker.ex b/apps/atomine/lib/atomine/live_proof_recheck_worker.ex new file mode 100644 index 0000000..a6e8b99 --- /dev/null +++ b/apps/atomine/lib/atomine/live_proof_recheck_worker.ex @@ -0,0 +1,16 @@ +defmodule Atomine.LiveProofRecheckWorker do + @moduledoc """ + Periodically rechecks due live proofs. + """ + + use Oban.Worker, queue: :default, max_attempts: 1 + + @default_limit 100 + + @impl Oban.Worker + def perform(%Oban.Job{args: args}) do + limit = Map.get(args || %{}, "limit", @default_limit) + _results = Atomine.Personhood.recheck_due_live_proofs(limit) + :ok + end +end diff --git a/apps/atomine/lib/atomine/personhood.ex b/apps/atomine/lib/atomine/personhood.ex new file mode 100644 index 0000000..0494ee0 --- /dev/null +++ b/apps/atomine/lib/atomine/personhood.ex @@ -0,0 +1,927 @@ +defmodule Atomine.Personhood do + @moduledoc """ + Proof-of-personhood and anti-bot scoring context. + + This app owns personhood proof lifecycle and scoring. Other apps can consume + `personhood_score/1` or `sufficiently_human?/2` when deciding whether to raise + limits, allow sensitive actions, or reduce anti-bot friction. + """ + + import Ecto.Query, warn: false + + alias Atomine.{CreditEarningPolicy, Proof} + alias Atomine.Scoring + alias Atomine.TrustSession + alias Elektrine.Accounts.ConnectedAccount + alias Elektrine.Accounts.User + alias Elektrine.HTTP.SafeFetch + alias Elektrine.Repo + + @live_check_interval_days 30 + @live_stale_after_days 45 + @max_web_proof_bytes 256 * 1024 + @proof_statement_version "v1" + + @proof_weights %{ + "web" => 20, + "dns" => 25, + "social" => 15, + "vouch" => 30, + "payment" => 25, + "passkey" => 20, + "manual" => 100 + } + + @doc "Returns default score weights by proof kind." + def proof_weights, do: @proof_weights + + @doc "Lists trust sessions newest first. Pass `:user_id`, `:merchant_id`, or `:status` to filter." + def list_trust_sessions(opts \\ []) do + TrustSession + |> maybe_filter(:user_id, Keyword.get(opts, :user_id)) + |> maybe_filter(:merchant_id, Keyword.get(opts, :merchant_id)) + |> maybe_filter(:status, Keyword.get(opts, :status)) + |> order_by([s], desc: s.inserted_at) + |> Repo.all() + end + + @doc "Gets a trust session by its public session id." + def get_trust_session(public_id) when is_binary(public_id) do + Repo.get_by(TrustSession, public_id: public_id) + end + + def get_trust_session(_), do: nil + + @doc "Creates a guest trust session for checkout/signup/external flows." + def create_trust_session(attrs) when is_map(attrs) do + attrs + |> stringify_keys() + |> put_default_session_fields(nil) + |> insert_trust_session() + end + + @doc "Creates a trust session attached to a known Elektrine user." + def create_trust_session(%User{} = user, attrs) when is_map(attrs) do + attrs + |> stringify_keys() + |> put_default_session_fields(user) + |> insert_trust_session() + end + + @doc "Updates a trust session with a fresh decision or step-up state." + def update_trust_session(%TrustSession{} = session, attrs) when is_map(attrs) do + session + |> TrustSession.changeset(stringify_keys(attrs)) + |> Repo.update() + end + + @doc "Marks a trust session completed and records the final decision." + def complete_trust_session(%TrustSession{} = session, attrs \\ %{}) when is_map(attrs) do + attrs = + attrs + |> stringify_keys() + |> Map.put_new("status", "completed") + |> Map.put_new("completed_at", now()) + + update_trust_session(session, attrs) + end + + @doc "Lists a user's proofs newest first." + def list_proofs(user_id) when is_integer(user_id) do + Proof + |> where([p], p.user_id == ^user_id) + |> order_by([p], desc: p.inserted_at) + |> Repo.all() + end + + def list_proofs(_), do: [] + + @doc "Lists proofs needing human/admin review." + def list_pending_proofs do + Proof + |> where([p], p.status == "pending") + |> order_by([p], asc: p.inserted_at) + |> Repo.all() + end + + @doc "Lists live proofs whose scheduled recheck is due." + def list_due_live_proofs(limit \\ 100) do + now = now() + + Proof + |> where( + [p], + p.claim_type == "positive" and p.proof_mode == "live" and p.status == "verified" and + not is_nil(p.next_check_at) and p.next_check_at <= ^now + ) + |> order_by([p], asc: p.next_check_at) + |> limit(^normalize_limit(limit)) + |> Repo.all() + end + + @doc "Rechecks due live proofs and returns per-proof results." + def recheck_due_live_proofs(limit \\ 100) do + limit + |> list_due_live_proofs() + |> Enum.map(fn proof -> {proof, check_proof(proof)} end) + end + + def get_proof!(id), do: Repo.get!(Proof, id) + + @doc "Creates a pending proof with a public challenge string." + def create_proof(%User{} = user, attrs) when is_map(attrs) do + attrs = stringify_keys(attrs) + kind = attrs |> Map.get("kind", "") |> normalize_kind() + + proof_mode = attrs |> Map.get("proof_mode", "snapshot") |> normalize_proof_mode() + subject = attrs |> Map.get("subject") |> normalize_subject_for_kind(kind) + default_method = default_method_for_kind(kind, subject) + + verification_method = + attrs |> Map.get("verification_method", default_method) |> normalize_method() + + challenge = Map.get(attrs, "challenge") || generate_challenge(user, kind, subject) + + %Proof{} + |> Proof.changeset(%{ + user_id: user.id, + kind: kind, + claim_type: "positive", + proof_mode: proof_mode, + live_status: initial_live_status(proof_mode), + verification_method: verification_method, + subject: subject, + status: "pending", + challenge: challenge, + evidence_url: Map.get(attrs, "evidence_url"), + score_weight: Map.get(attrs, "score_weight") || Map.get(@proof_weights, kind, 0), + metadata: proof_metadata(Map.get(attrs, "metadata"), verification_method, challenge) + }) + |> Repo.insert() + end + + @doc "Creates a hosted negative assertion, such as `I am not on Twitter`." + def create_negative_assertion(%User{} = user, attrs) when is_map(attrs) do + attrs = stringify_keys(attrs) + kind = attrs |> Map.get("kind", "social") |> normalize_kind() + + challenge = + Map.get(attrs, "challenge") || generate_challenge(user, kind, Map.get(attrs, "subject")) + + %Proof{} + |> Proof.changeset(%{ + user_id: user.id, + kind: kind, + claim_type: "negative", + proof_mode: "snapshot", + live_status: nil, + verification_method: "none", + subject: Map.get(attrs, "subject"), + status: "asserted", + challenge: challenge, + evidence_url: Map.get(attrs, "evidence_url"), + score_weight: 0, + metadata: proof_metadata(Map.get(attrs, "metadata"), "none", challenge) + }) + |> Repo.insert() + end + + @doc "Creates or refreshes a verified proof from a connected OAuth/OIDC account." + def verify_connected_account_proof(%ConnectedAccount{} = connected_account) do + subject = connected_account_subject(connected_account) + + attrs = %{ + user_id: connected_account.user_id, + kind: "social", + claim_type: "positive", + proof_mode: "live", + live_status: "active", + verification_method: "oauth", + subject: subject, + status: "verified", + challenge: "OAuth account verified for #{subject}", + evidence_url: connected_account.profile_url, + score_weight: Map.get(@proof_weights, "social", 15), + checked_at: now(), + last_seen_at: now(), + next_check_at: DateTime.add(now(), @live_check_interval_days, :day), + stale_at: DateTime.add(now(), @live_stale_after_days, :day), + verified_at: now(), + metadata: %{ + "provider" => connected_account.provider, + "provider_account_id" => connected_account.provider_account_id, + "username" => connected_account.username, + "display_name" => connected_account.display_name, + "connected_account_id" => connected_account.id + } + } + + case Repo.get_by(Proof, + user_id: connected_account.user_id, + kind: "social", + subject: subject, + status: "verified" + ) do + %Proof{} = proof -> + proof + |> Proof.changeset(attrs) + |> Repo.update() + |> grant_verified_proof_credits() + + nil -> + %Proof{} + |> Proof.changeset(attrs) + |> Repo.insert() + |> grant_verified_proof_credits() + end + end + + @doc "Marks a proof verified and makes its weight count toward personhood score." + def verify_proof(%Proof{} = proof, reviewer \\ nil, notes \\ nil) do + now = now() + + proof + |> Proof.changeset(%{ + status: "verified", + checked_at: now, + last_seen_at: live_timestamp(proof, now), + next_check_at: next_check_at(proof, now), + stale_at: stale_at(proof, now), + live_status: verified_live_status(proof), + failed_check_count: 0, + verified_at: now, + rejected_at: nil, + revoked_at: nil, + reviewed_by_user_id: reviewer_id(reviewer), + review_notes: notes + }) + |> Repo.update() + |> grant_verified_proof_credits() + end + + @doc "Rejects a pending proof without contributing score." + def reject_proof(%Proof{} = proof, reviewer \\ nil, notes \\ nil) do + proof + |> Proof.changeset(%{ + status: "rejected", + checked_at: now(), + live_status: rejected_live_status(proof), + rejected_at: now(), + verified_at: nil, + revoked_at: nil, + reviewed_by_user_id: reviewer_id(reviewer), + review_notes: notes + }) + |> Repo.update() + end + + @doc "Revokes a previously accepted proof." + def revoke_proof(%Proof{} = proof, reviewer \\ nil, notes \\ nil) do + proof + |> Proof.changeset(%{ + status: "revoked", + live_status: revoked_live_status(proof), + revoked_at: now(), + verified_at: nil, + rejected_at: nil, + reviewed_by_user_id: reviewer_id(reviewer), + review_notes: notes + }) + |> Repo.update() + end + + @doc "Deletes a proof owned by a user." + def delete_proof(%Proof{} = proof) do + Repo.delete(proof) + end + + @doc "Marks a live proof stale after an overdue or failed recheck." + def mark_live_stale(proof, notes \\ nil) + + def mark_live_stale(%Proof{proof_mode: "live"} = proof, notes) do + proof + |> Proof.changeset(%{ + live_status: "stale", + failed_check_count: proof.failed_check_count + 1, + checked_at: now(), + review_notes: notes || proof.review_notes + }) + |> Repo.update() + end + + def mark_live_stale(%Proof{} = proof, _notes), do: {:ok, proof} + + @doc "Marks a live proof inactive when its snippet can no longer be found." + def mark_live_inactive(proof, notes \\ nil) + + def mark_live_inactive(%Proof{proof_mode: "live"} = proof, notes) do + proof + |> Proof.changeset(%{ + live_status: "inactive", + failed_check_count: proof.failed_check_count + 1, + checked_at: now(), + review_notes: notes || proof.review_notes + }) + |> Repo.update() + end + + def mark_live_inactive(%Proof{} = proof, _notes), do: {:ok, proof} + + @doc "Checks a DNS or public page proof and verifies it when the challenge is published." + def check_proof(%Proof{claim_type: "negative"}), do: {:error, :not_checkable} + + def check_proof(%Proof{status: status}) when status in ["rejected", "revoked"], + do: {:error, :closed} + + def check_proof(%Proof{verification_method: "dns"} = proof) do + case dns_challenge_present?(proof) do + true -> verify_proof(proof, nil, "DNS TXT record matched") + false -> mark_check_failed(proof, "DNS TXT record did not contain the challenge") + {:error, reason} -> mark_check_failed(proof, "DNS check failed: #{inspect(reason)}") + end + end + + def check_proof(%Proof{verification_method: "page"} = proof) do + case web_challenge_present?(proof) do + true -> verify_proof(proof, nil, "Web page contained challenge") + false -> mark_check_failed(proof, "Web page did not contain the challenge") + {:error, reason} -> mark_check_failed(proof, "Web check failed: #{inspect(reason)}") + end + end + + def check_proof(%Proof{verification_method: "github_gist"} = proof) do + case github_gist_challenge_present?(proof) do + true -> verify_proof(proof, nil, "GitHub gist contained challenge") + false -> mark_check_failed(proof, "GitHub gist did not contain the challenge") + {:error, reason} -> mark_check_failed(proof, "GitHub gist check failed: #{inspect(reason)}") + end + end + + def check_proof(%Proof{}), do: {:error, :manual_review_required} + + @doc "Returns a detailed composite personhood score breakdown." + def personhood_breakdown(user_or_id), do: Scoring.breakdown(user_or_id) + + @doc "Returns the capped composite personhood score for a user." + def personhood_score(user_or_id), do: personhood_breakdown(user_or_id).score + + @doc "Returns a coarse label for UI and policy decisions." + def personhood_level(user_or_id) do + personhood_breakdown(user_or_id).level + end + + @doc "Returns whether a user has enough personhood score for a policy gate." + def sufficiently_human?(user_or_id, minimum_score \\ 40) when is_integer(minimum_score) do + personhood_score(user_or_id) >= minimum_score + end + + @doc "Returns the claim snippet users should publish for page-based proofs." + def page_snippet(%Proof{} = proof), do: proof.challenge + + @doc "Returns DNS TXT record instructions for DNS-based proofs." + def dns_txt_record(%Proof{verification_method: "dns"} = proof) do + {"_atomine", proof.challenge} + end + + def dns_txt_record(%Proof{}), do: nil + + @doc "Returns the DNS TXT host users should publish for a DNS proof." + def dns_txt_host(%Proof{verification_method: "dns", subject: subject}) + when is_binary(subject) do + "_atomine.#{String.trim(subject)}" + end + + def dns_txt_host(%Proof{}), do: nil + + defp generate_challenge(%User{} = user, kind, subject) do + token = :crypto.strong_rand_bytes(18) |> Base.url_encode64(padding: false) + handle = user.handle || user.username || "user-#{user.id}" + subject = subject || "" + payload = signed_proof_payload(user.id, handle, kind, subject, token) + signature = sign_proof_payload(payload) + + Enum.join( + [ + "Atomine identity claim", + @proof_statement_version, + "user=#{encode_proof_field("@#{handle}")}", + "user_id=#{user.id}", + "kind=#{encode_proof_field(kind)}", + "subject=#{encode_proof_field(subject)}", + "nonce=#{encode_proof_field(token)}", + "sig=#{encode_proof_field(signature)}" + ], + " " + ) + end + + defp stringify_keys(attrs) do + Map.new(attrs, fn {key, value} -> {to_string(key), value} end) + end + + defp put_default_session_fields(attrs, user) do + score_breakdown = if user, do: personhood_breakdown(user), else: nil + score = Map.get(attrs, "score") || (score_breakdown && score_breakdown.score) || 0 + decision = Map.get(attrs, "decision") || default_decision(score) + + attrs + |> maybe_put_user_id(user) + |> Map.put_new("context", "checkout") + |> Map.put_new("status", default_session_status(decision)) + |> Map.put_new("decision", decision) + |> Map.put_new("recommended_step_up", default_step_up(decision, score)) + |> Map.put_new("score", score) + |> Map.put_new("level", Map.get(attrs, "level") || score_level(score)) + |> Map.put_new("signals", %{}) + |> Map.put_new("metadata", %{}) + |> Map.put_new("expires_at", DateTime.add(now(), 30, :minute)) + end + + defp maybe_put_user_id(attrs, %User{id: user_id}), do: Map.put_new(attrs, "user_id", user_id) + defp maybe_put_user_id(attrs, nil), do: attrs + + defp insert_trust_session(attrs) do + %TrustSession{} + |> TrustSession.changeset(attrs) + |> Repo.insert() + end + + defp maybe_filter(query, _field, nil), do: query + + defp maybe_filter(query, field, value) do + where(query, [s], field(s, ^field) == ^value) + end + + defp default_decision(score) when score >= 75, do: "allow" + defp default_decision(score) when score >= 40, do: "step_up" + defp default_decision(score) when score >= 15, do: "step_up" + defp default_decision(_), do: "review" + + defp default_session_status("allow"), do: "completed" + defp default_session_status("step_up"), do: "step_up" + defp default_session_status(_), do: "pending" + + defp default_step_up("step_up", score) when score < 40, do: "passkey" + defp default_step_up("step_up", _score), do: "email" + defp default_step_up("review", _score), do: "proof" + defp default_step_up(_, _score), do: "none" + + defp score_level(score), do: Scoring.level(score) |> Atom.to_string() + + defp connected_account_subject(%ConnectedAccount{} = connected_account) do + "oauth:#{connected_account.provider}:#{connected_account.provider_account_id}" + end + + defp dns_challenge_present?(proof) do + host = dns_txt_host(proof) + + with true <- is_binary(host) and host != "", + true <- proof_statement_valid?(proof), + {:ok, records} <- lookup_txt_records(host) do + Enum.any?(records, &String.contains?(&1, proof.challenge)) + else + false -> {:error, :invalid_dns_subject} + {:error, reason} -> {:error, reason} + end + end + + defp lookup_txt_records(host) do + host + |> String.trim_trailing(".") + |> String.to_charlist() + |> :inet_res.lookup(:in, :txt) + |> case do + [] -> {:ok, []} + records -> {:ok, Enum.map(records, &txt_record_to_string/1)} + end + rescue + error -> {:error, error} + end + + defp txt_record_to_string(record) when is_list(record) do + record + |> List.flatten() + |> to_string() + end + + defp txt_record_to_string(record), do: to_string(record) + + defp web_challenge_present?(proof) do + url = proof.evidence_url || proof.subject + + with true <- proof_statement_valid?(proof), + {:ok, uri} <- safe_web_proof_uri(url), + {:ok, body} <- fetch_web_proof(uri) do + String.contains?(body, proof.challenge) + end + end + + defp github_gist_challenge_present?(proof) do + with true <- proof_statement_valid?(proof), + {:ok, username} <- github_profile_username(proof.subject), + {:ok, body} <- fetch_github_user_gists(username) do + String.contains?(body, proof.challenge) + end + end + + defp github_profile_username(url) when is_binary(url) do + uri = URI.parse(String.trim(url)) + host = if is_binary(uri.host), do: String.downcase(uri.host), else: nil + path_parts = uri.path |> to_string() |> String.split("/", trim: true) + + case {uri.scheme, host, path_parts} do + {scheme, "github.com", [username | _]} when scheme in ["http", "https"] -> + {:ok, username} + + _ -> + {:error, :not_github_profile_url} + end + end + + defp github_profile_username(_), do: {:error, :not_github_profile_url} + + defp fetch_github_user_gists(username) do + start_app(:inets) + start_app(:ssl) + + url = ~c"https://api.github.com/users/#{username}/gists?per_page=100" + + headers = [ + {~c"user-agent", ~c"Elektrine-Atomine-Proofs"}, + {~c"accept", ~c"application/vnd.github+json"} + ] + + request = {url, headers} + http_options = [timeout: 5_000] + options = [body_format: :binary] + + case :httpc.request(:get, request, http_options, options) do + {:ok, {{_, status, _}, _headers, body}} when status in 200..299 -> + decode_user_gists_body(body) + + {:ok, {{_, status, _}, _headers, _body}} -> + {:error, {:github_status, status}} + + {:error, reason} -> + {:error, reason} + end + end + + defp decode_user_gists_body(body) do + case Jason.decode(body) do + {:ok, decoded} when is_list(decoded) -> + contents = + decoded + |> Enum.flat_map(&gist_file_raw_urls/1) + |> Enum.flat_map(&fetch_gist_raw_file/1) + |> Enum.join("\n") + + {:ok, contents} + + {:error, reason} -> + {:error, reason} + + _ -> + {:error, :invalid_gist_response} + end + end + + defp gist_file_raw_urls(%{"files" => files}) when is_map(files) do + files + |> Map.values() + |> Enum.map(fn file -> Map.get(file, "raw_url") end) + |> Enum.filter(&is_binary/1) + end + + defp gist_file_raw_urls(_), do: [] + + defp fetch_gist_raw_file(raw_url) do + with {:ok, uri} <- safe_web_proof_uri(raw_url), + "gist.githubusercontent.com" <- String.downcase(uri.host), + {:ok, body} <- fetch_web_proof(uri) do + [body] + else + _ -> [] + end + end + + defp safe_web_proof_uri(url) when is_binary(url) do + uri = URI.parse(String.trim(url)) + + cond do + uri.scheme not in ["http", "https"] -> {:error, :unsupported_url_scheme} + is_nil(uri.host) or uri.host == "" -> {:error, :missing_host} + blocked_proof_host?(uri.host) -> {:error, :blocked_private_host} + true -> {:ok, uri} + end + end + + defp safe_web_proof_uri(_), do: {:error, :invalid_url} + + defp blocked_proof_host?(host) do + host = host |> String.trim_trailing(".") |> String.downcase() + + cond do + host in ["localhost", "localhost.localdomain"] -> + true + + String.ends_with?(host, ".local") -> + true + + true -> + host + |> resolve_host_addresses() + |> case do + {:ok, addresses} -> Enum.any?(addresses, &blocked_address?/1) + {:error, _reason} -> true + end + end + end + + defp resolve_host_addresses(host) do + host_chars = String.to_charlist(host) + + addresses = + [:inet, :inet6] + |> Enum.flat_map(fn family -> + case :inet.getaddrs(host_chars, family) do + {:ok, values} -> values + {:error, _reason} -> [] + end + end) + |> Enum.uniq() + + case addresses do + [] -> {:error, :host_not_resolved} + values -> {:ok, values} + end + end + + defp blocked_address?({10, _, _, _}), do: true + defp blocked_address?({127, _, _, _}), do: true + defp blocked_address?({0, _, _, _}), do: true + defp blocked_address?({169, 254, _, _}), do: true + defp blocked_address?({172, second, _, _}) when second in 16..31, do: true + defp blocked_address?({192, 168, _, _}), do: true + defp blocked_address?({100, second, _, _}) when second in 64..127, do: true + defp blocked_address?({192, 0, 0, _}), do: true + defp blocked_address?({192, 0, 2, _}), do: true + defp blocked_address?({198, 18, _, _}), do: true + defp blocked_address?({198, 19, _, _}), do: true + defp blocked_address?({198, 51, 100, _}), do: true + defp blocked_address?({203, 0, 113, _}), do: true + defp blocked_address?({224, _, _, _}), do: true + defp blocked_address?({a, _, _, _}) when a >= 225, do: true + defp blocked_address?({0, 0, 0, 0, 0, 0, 0, 1}), do: true + defp blocked_address?({0, 0, 0, 0, 0, 0, 0, 0}), do: true + defp blocked_address?({0xFE80, _, _, _, _, _, _, _}), do: true + defp blocked_address?({first, _, _, _, _, _, _, _}) when first in 0xFC00..0xFDFF, do: true + defp blocked_address?({0x2001, 0, _, _, _, _, _, _}), do: true + defp blocked_address?({0x2001, 0x0DB8, _, _, _, _, _, _}), do: true + defp blocked_address?(_address), do: false + + defp fetch_web_proof(uri) do + request = Finch.build(:get, URI.to_string(uri), [{"user-agent", "Elektrine-Atomine-Proofs"}]) + + case SafeFetch.request(request, Elektrine.Finch, + receive_timeout: 5_000, + pool_timeout: 5_000, + max_body_bytes: @max_web_proof_bytes + ) do + {:ok, %Finch.Response{status: status, body: body}} when status in 200..299 -> {:ok, body} + {:ok, %Finch.Response{status: status}} -> {:error, {:http_status, status}} + {:error, reason} -> {:error, reason} + end + end + + defp start_app(app) do + case Application.ensure_all_started(app) do + {:ok, _started} -> :ok + {:error, {:already_started, ^app}} -> :ok + {:error, _reason} -> :ok + end + end + + defp proof_statement_valid?(%Proof{challenge: challenge} = proof) when is_binary(challenge) do + case parse_signed_proof_statement(challenge) do + {:ok, fields} -> + with :ok <- signed_fields_match_proof(fields, proof) do + payload = + signed_proof_payload( + fields["user_id"], + fields["user"] |> String.trim_leading("@"), + fields["kind"], + fields["subject"], + fields["nonce"] + ) + + if secure_compare(sign_proof_payload(payload), fields["sig"]) do + true + else + {:error, :invalid_proof_signature} + end + end + + :older -> + true + + {:error, reason} -> + {:error, reason} + end + end + + defp proof_statement_valid?(%Proof{}), do: {:error, :invalid_proof_statement} + + defp parse_signed_proof_statement("Atomine identity claim " <> rest) do + parse_signed_proof_statement_fields(rest) + end + + defp parse_signed_proof_statement("Atomine personhood proof " <> rest) do + parse_signed_proof_statement_fields(rest) + end + + defp parse_signed_proof_statement(_challenge), do: :older + + defp parse_signed_proof_statement_fields(rest) do + case String.split(rest, " ", trim: true) do + [@proof_statement_version | encoded_fields] -> + fields = + Map.new(encoded_fields, fn field -> + case String.split(field, "=", parts: 2) do + [key, value] -> {key, decode_proof_field(value)} + [key] -> {key, ""} + end + end) + + if Enum.all?(~w(user user_id kind subject nonce sig), &present_field?(fields, &1)) do + {:ok, fields} + else + {:error, :invalid_proof_statement} + end + + _ -> + {:error, :unsupported_proof_statement} + end + end + + defp signed_fields_match_proof(fields, proof) do + cond do + fields["user_id"] != to_string(proof.user_id) -> {:error, :proof_user_mismatch} + fields["kind"] != proof.kind -> {:error, :proof_kind_mismatch} + fields["subject"] != proof.subject -> {:error, :proof_subject_mismatch} + true -> :ok + end + end + + defp present_field?(fields, key) do + case Map.get(fields, key) do + value when is_binary(value) -> String.trim(value) != "" + _ -> false + end + end + + defp signed_proof_payload(user_id, handle, kind, subject, nonce) do + Enum.join( + [ + "atomine-proof:#{@proof_statement_version}", + "user_id=#{user_id}", + "user=#{handle}", + "kind=#{kind}", + "subject=#{subject}", + "nonce=#{nonce}" + ], + "\n" + ) + end + + defp sign_proof_payload(payload) do + :crypto.mac(:hmac, :sha256, proof_signing_secret(), payload) + |> Base.url_encode64(padding: false) + end + + defp proof_signing_secret do + Elektrine.RuntimeSecrets.secret_key_base() || + if Elektrine.RuntimeEnv.dev_or_test?() do + "atomine-dev-proof-signing-secret" + else + raise "proof signing secret is not configured" + end + end + + defp encode_proof_field(value), do: value |> to_string() |> URI.encode_www_form() + + defp decode_proof_field(value) do + URI.decode_www_form(value) + rescue + ArgumentError -> value + end + + defp secure_compare(left, right) when is_binary(left) and is_binary(right) do + byte_size(left) == byte_size(right) and Plug.Crypto.secure_compare(left, right) + end + + defp secure_compare(_, _), do: false + + defp mark_check_failed(%Proof{proof_mode: "live"} = proof, notes) do + proof + |> mark_live_stale(notes) + |> case do + {:ok, updated} -> {:error, {:not_found, updated}} + {:error, changeset} -> {:error, changeset} + end + end + + defp mark_check_failed(%Proof{} = proof, notes) do + proof + |> Proof.changeset(%{checked_at: now(), review_notes: notes}) + |> Repo.update() + |> case do + {:ok, updated} -> {:error, {:not_found, updated}} + {:error, changeset} -> {:error, changeset} + end + end + + defp normalize_kind(kind) when is_binary(kind), do: kind |> String.trim() |> String.downcase() + defp normalize_kind(_), do: "" + + defp normalize_subject_for_kind(subject, "dns") when is_binary(subject) do + subject |> String.trim() |> String.trim_trailing(".") |> String.downcase() + end + + defp normalize_subject_for_kind(subject, _kind) when is_binary(subject), + do: String.trim(subject) + + defp normalize_subject_for_kind(subject, _kind), do: subject + + defp normalize_method(method) when is_binary(method), + do: method |> String.trim() |> String.downcase() + + defp normalize_method(_), do: "manual" + + defp normalize_proof_mode("live"), do: "live" + defp normalize_proof_mode(_), do: "snapshot" + + defp normalize_limit(limit) when is_integer(limit), do: limit |> max(1) |> min(500) + defp normalize_limit(_), do: 100 + + defp default_method_for_kind("dns", _subject), do: "dns" + defp default_method_for_kind("web", _subject), do: "page" + defp default_method_for_kind("social", subject), do: social_method_for_subject(subject) + defp default_method_for_kind(_kind, _subject), do: "manual" + + defp social_method_for_subject(subject) do + case github_profile_username(subject) do + {:ok, _username} -> "github_gist" + {:error, _reason} -> "page" + end + end + + defp proof_metadata(metadata, verification_method, challenge) when is_map(metadata) do + Map.merge( + %{"verification_snippet" => challenge, "verification_method" => verification_method}, + metadata + ) + end + + defp proof_metadata(_metadata, verification_method, challenge) do + %{"verification_snippet" => challenge, "verification_method" => verification_method} + end + + defp initial_live_status("live"), do: "stale" + defp initial_live_status(_), do: nil + + defp verified_live_status(%Proof{proof_mode: "live"}), do: "active" + defp verified_live_status(%Proof{} = proof), do: proof.live_status + + defp grant_verified_proof_credits({:ok, %Proof{} = proof}) do + _ = CreditEarningPolicy.grant_for_verified_proof(proof) + {:ok, proof} + end + + defp grant_verified_proof_credits(result), do: result + + defp rejected_live_status(%Proof{proof_mode: "live"}), do: "inactive" + defp rejected_live_status(%Proof{} = proof), do: proof.live_status + + defp revoked_live_status(%Proof{proof_mode: "live"}), do: "inactive" + defp revoked_live_status(%Proof{} = proof), do: proof.live_status + + defp live_timestamp(%Proof{proof_mode: "live"}, now), do: now + defp live_timestamp(%Proof{} = proof, _now), do: proof.last_seen_at + + defp next_check_at(%Proof{proof_mode: "live"}, now), + do: DateTime.add(now, @live_check_interval_days, :day) + + defp next_check_at(%Proof{} = proof, _now), do: proof.next_check_at + + defp stale_at(%Proof{proof_mode: "live"}, now), + do: DateTime.add(now, @live_stale_after_days, :day) + + defp stale_at(%Proof{} = proof, _now), do: proof.stale_at + + defp reviewer_id(%User{id: id}), do: id + defp reviewer_id(id) when is_integer(id), do: id + defp reviewer_id(_), do: nil + + defp now, do: DateTime.utc_now() |> DateTime.truncate(:second) +end diff --git a/apps/atomine/lib/atomine/proof.ex b/apps/atomine/lib/atomine/proof.ex new file mode 100644 index 0000000..8b5dde1 --- /dev/null +++ b/apps/atomine/lib/atomine/proof.ex @@ -0,0 +1,204 @@ +defmodule Atomine.Proof do + @moduledoc """ + A proof signal used to establish that an account belongs to a real person. + + Proofs are intentionally separate from `Accounts.User.verified`; they are + anti-bot/personhood signals that can be combined into a score. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias Elektrine.Security.SafeExternalURL + + @kinds ~w(web dns social vouch payment passkey manual) + @claim_types ~w(positive negative) + @proof_modes ~w(snapshot live) + @live_statuses ~w(active stale inactive) + @verification_methods ~w(page dns github_gist email oauth manual none) + @statuses ~w(pending asserted verified rejected revoked) + + schema "atomine_proofs" do + belongs_to :user, Elektrine.Accounts.User + field :kind, :string + field :claim_type, :string, default: "positive" + field :proof_mode, :string, default: "snapshot" + field :live_status, :string + field :verification_method, :string, default: "manual" + field :subject, :string + field :status, :string, default: "pending" + field :challenge, :string + field :evidence_url, :string + field :score_weight, :integer, default: 0 + field :metadata, :map, default: %{} + field :checked_at, :utc_datetime + field :last_seen_at, :utc_datetime + field :next_check_at, :utc_datetime + field :stale_at, :utc_datetime + field :failed_check_count, :integer, default: 0 + field :verified_at, :utc_datetime + field :rejected_at, :utc_datetime + field :revoked_at, :utc_datetime + belongs_to :reviewed_by_user, Elektrine.Accounts.User + field :review_notes, :string + + timestamps(type: :utc_datetime) + end + + def kinds, do: @kinds + def claim_types, do: @claim_types + def proof_modes, do: @proof_modes + def live_statuses, do: @live_statuses + def verification_methods, do: @verification_methods + def statuses, do: @statuses + + def changeset(proof, attrs) do + proof + |> cast(attrs, [ + :user_id, + :kind, + :claim_type, + :proof_mode, + :live_status, + :verification_method, + :subject, + :status, + :challenge, + :evidence_url, + :score_weight, + :metadata, + :checked_at, + :last_seen_at, + :next_check_at, + :stale_at, + :failed_check_count, + :verified_at, + :rejected_at, + :revoked_at, + :reviewed_by_user_id, + :review_notes + ]) + |> normalize_string(:kind) + |> normalize_string(:claim_type) + |> normalize_string(:proof_mode) + |> normalize_string(:live_status) + |> normalize_string(:verification_method) + |> normalize_string(:status) + |> normalize_string(:evidence_url) + |> normalize_subject() + |> empty_to_nil(:evidence_url) + |> validate_required([ + :user_id, + :kind, + :claim_type, + :proof_mode, + :verification_method, + :subject, + :status, + :challenge, + :score_weight + ]) + |> validate_inclusion(:kind, @kinds) + |> validate_inclusion(:claim_type, @claim_types) + |> validate_inclusion(:proof_mode, @proof_modes) + |> validate_inclusion(:live_status, @live_statuses) + |> validate_inclusion(:verification_method, @verification_methods) + |> validate_inclusion(:status, @statuses) + |> validate_status_matches_claim_type() + |> validate_live_status_matches_mode() + |> validate_length(:subject, min: 1, max: 500) + |> validate_length(:challenge, min: 16, max: 2_000) + |> validate_length(:evidence_url, max: 2_000) + |> validate_evidence_url() + |> validate_length(:review_notes, max: 5_000) + |> validate_number(:score_weight, greater_than_or_equal_to: 0, less_than_or_equal_to: 100) + |> validate_number(:failed_check_count, greater_than_or_equal_to: 0) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:reviewed_by_user_id) + |> unique_constraint(:subject, + name: :atomine_proofs_active_dns_subject_unique, + message: "domain already has an active DNS proof" + ) + |> unique_constraint(:subject, + name: :atomine_proofs_active_subject_unique, + message: "already has an active proof for this subject" + ) + end + + defp empty_to_nil(changeset, field) do + update_change(changeset, field, fn + "" -> nil + value -> value + end) + end + + defp validate_evidence_url(changeset) do + case get_change(changeset, :evidence_url) do + nil -> + changeset + + evidence_url -> + case SafeExternalURL.normalize_href(evidence_url) do + {:ok, safe_url} -> + put_change(changeset, :evidence_url, safe_url) + + {:error, _reason} -> + add_error(changeset, :evidence_url, "must be a safe http:// or https:// URL") + end + end + end + + defp validate_live_status_matches_mode(changeset) do + proof_mode = get_field(changeset, :proof_mode) + live_status = get_field(changeset, :live_status) + + cond do + proof_mode == "live" and is_nil(live_status) -> + add_error(changeset, :live_status, "is required for live proofs") + + proof_mode == "snapshot" and not is_nil(live_status) -> + add_error(changeset, :live_status, "must be empty for snapshot proofs") + + true -> + changeset + end + end + + defp validate_status_matches_claim_type(changeset) do + claim_type = get_field(changeset, :claim_type) + status = get_field(changeset, :status) + + cond do + claim_type == "negative" and status == "verified" -> + add_error(changeset, :status, "negative assertions cannot be verified") + + claim_type == "negative" and get_field(changeset, :score_weight) != 0 -> + add_error(changeset, :score_weight, "negative assertions cannot affect score") + + true -> + changeset + end + end + + defp normalize_string(changeset, field) do + update_change(changeset, field, fn + value when is_binary(value) -> value |> String.trim() |> String.downcase() + value -> value + end) + end + + defp normalize_subject(changeset) do + kind = get_field(changeset, :kind) + + update_change(changeset, :subject, fn + value when is_binary(value) and kind == "dns" -> + value |> String.trim() |> String.trim_trailing(".") |> String.downcase() + + value when is_binary(value) -> + String.trim(value) + + value -> + value + end) + end +end diff --git a/apps/atomine/lib/atomine/scoring.ex b/apps/atomine/lib/atomine/scoring.ex new file mode 100644 index 0000000..8005690 --- /dev/null +++ b/apps/atomine/lib/atomine/scoring.ex @@ -0,0 +1,228 @@ +defmodule Atomine.Scoring do + @moduledoc """ + Composite personhood scoring. + + The score intentionally mixes proof strength, account age, security posture, + platform trust, and penalties. A single proof can help, but mature and healthy + accounts score better than freshly-created accounts with one weak signal. + """ + + import Ecto.Query, warn: false + + alias Atomine.{Attestation, Proof} + alias Elektrine.Accounts.User + alias Elektrine.Repo + + @max_score 100 + @diversity_bonus_per_kind 5 + @max_diversity_bonus 15 + + @doc "Returns a detailed personhood score breakdown for a user." + def breakdown(%User{} = user) do + proofs = verified_proofs(user.id) + + positive = %{ + proofs: proof_score(proofs), + proof_diversity: proof_diversity_bonus(proofs), + portable_effort: portable_effort_score(user.id), + continuity: continuity_score(user.id), + account_age: account_age_score(user), + security: security_score(user), + account_history: account_history_score(user), + platform_trust: platform_trust_score(user) + } + + penalties = %{ + account_restrictions: account_restriction_penalty(user), + onion_registration: onion_registration_penalty(user), + proof_rejections: proof_rejection_penalty(user.id) + } + + raw_score = Enum.sum(Map.values(positive)) - Enum.sum(Map.values(penalties)) + score = raw_score |> max(0) |> min(@max_score) + + %{ + score: score, + raw_score: raw_score, + level: level(score), + positive: positive, + penalties: penalties, + verified_proof_count: length(proofs), + verified_proof_kinds: proofs |> Enum.map(& &1.kind) |> Enum.uniq() |> Enum.sort() + } + end + + def breakdown(user_id) when is_integer(user_id) do + case Repo.get(User, user_id) do + %User{} = user -> breakdown(user) + nil -> empty_breakdown() + end + end + + def breakdown(_), do: empty_breakdown() + + def level(score) when score >= 75, do: :high + def level(score) when score >= 40, do: :medium + def level(score) when score >= 15, do: :low + def level(_), do: :unknown + + defp verified_proofs(user_id) do + Proof + |> where([p], p.user_id == ^user_id and p.claim_type == "positive" and p.status == "verified") + |> Repo.all() + end + + defp proof_score(proofs) do + if Enum.any?(proofs, &(&1.kind == "manual" and &1.score_weight >= 100)) do + 100 + else + proofs + |> Enum.reduce(0, fn proof, total -> total + weighted_proof_score(proof) end) + |> min(65) + end + end + + defp weighted_proof_score(%Proof{proof_mode: "live", live_status: "active"} = proof), + do: proof.score_weight + 5 + + defp weighted_proof_score(%Proof{proof_mode: "live", live_status: "stale"} = proof), + do: div(proof.score_weight, 2) + + defp weighted_proof_score(%Proof{proof_mode: "live", live_status: "inactive"}), do: 0 + + defp weighted_proof_score(%Proof{} = proof), do: proof.score_weight + + defp proof_diversity_bonus(proofs) do + proofs + |> Enum.map(& &1.kind) + |> Enum.uniq() + |> length() + |> Kernel.-(1) + |> max(0) + |> Kernel.*(@diversity_bonus_per_kind) + |> min(@max_diversity_bonus) + end + + defp portable_effort_score(user_id) do + user_id + |> active_attestations_query("pow_receipt") + |> Repo.all() + |> Enum.reduce(0, fn attestation, total -> total + effort_weight(attestation.difficulty) end) + |> min(15) + end + + defp continuity_score(user_id) do + if Repo.exists?(active_attestations_query(user_id, "passkey_receipt")), do: 15, else: 0 + end + + defp active_attestations_query(user_id, kind) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + from a in Attestation, + where: + a.user_id == ^user_id and a.kind == ^kind and a.status == "issued" and + a.expires_at >= ^now + end + + defp effort_weight(difficulty) when is_integer(difficulty) and difficulty >= 24, do: 5 + defp effort_weight(difficulty) when is_integer(difficulty) and difficulty >= 20, do: 3 + defp effort_weight(difficulty) when is_integer(difficulty) and difficulty >= 16, do: 1 + defp effort_weight(_), do: 0 + + defp account_age_score(%User{inserted_at: inserted_at}) do + days = account_age_days(inserted_at) + + cond do + days >= 365 -> 20 + days >= 180 -> 16 + days >= 90 -> 12 + days >= 30 -> 8 + days >= 7 -> 4 + true -> 0 + end + end + + defp security_score(user) do + recovery_email_score = if user.recovery_email_verified, do: 5, else: 0 + two_factor_score = if user.two_factor_enabled, do: 10, else: 0 + passkey_score = if has_verified_kind?(user.id, "passkey"), do: 5, else: 0 + + recovery_email_score + two_factor_score + passkey_score + end + + defp account_history_score(user) do + login_score = + cond do + user.login_count >= 50 -> 10 + user.login_count >= 10 -> 6 + user.login_count >= 3 -> 3 + true -> 0 + end + + seen_score = if user.last_seen_at || user.last_login_at, do: 5, else: 0 + login_score + seen_score + end + + defp platform_trust_score(user) do + cond do + user.is_admin -> 15 + user.verified -> 10 + user.trust_level >= 3 -> 12 + user.trust_level >= 2 -> 8 + user.trust_level >= 1 -> 4 + true -> 0 + end + end + + defp account_restriction_penalty(user) do + banned = if user.banned, do: 100, else: 0 + suspended = if user.suspended, do: 50, else: 0 + email_restricted = if user.email_sending_restricted, do: 15, else: 0 + banned + suspended + email_restricted + end + + defp onion_registration_penalty(%User{registered_via_onion: true}), do: 5 + defp onion_registration_penalty(_), do: 0 + + defp proof_rejection_penalty(user_id) do + rejected_count = + Proof + |> where([p], p.user_id == ^user_id and p.status in ["rejected", "revoked"]) + |> Repo.aggregate(:count) + + min(rejected_count * 10, 30) + end + + defp has_verified_kind?(user_id, kind) do + Repo.exists?( + from p in Proof, + where: + p.user_id == ^user_id and p.kind == ^kind and p.claim_type == "positive" and + p.status == "verified" + ) + end + + defp account_age_days(%DateTime{} = inserted_at) do + DateTime.diff(DateTime.utc_now(), inserted_at, :day) + end + + defp account_age_days(%NaiveDateTime{} = inserted_at) do + inserted_at + |> DateTime.from_naive!("Etc/UTC") + |> account_age_days() + end + + defp account_age_days(_), do: 0 + + defp empty_breakdown do + %{ + score: 0, + raw_score: 0, + level: :unknown, + positive: %{}, + penalties: %{}, + verified_proof_count: 0, + verified_proof_kinds: [] + } + end +end diff --git a/apps/atomine/lib/atomine/trust_session.ex b/apps/atomine/lib/atomine/trust_session.ex new file mode 100644 index 0000000..fe3a351 --- /dev/null +++ b/apps/atomine/lib/atomine/trust_session.ex @@ -0,0 +1,118 @@ +defmodule Atomine.TrustSession do + @moduledoc """ + A short-lived trust decision session for checkout, signup, and other external flows. + + Sessions can be attached to an Elektrine user or created for a guest/external + subject first, then upgraded later if the customer creates an account. + """ + + use Ecto.Schema + import Ecto.Changeset + + @contexts ~w(checkout signup account_action api_access community_access seller_onboarding manual) + @statuses ~w(pending step_up completed expired cancelled) + @decisions ~w(allow step_up review block) + @step_ups ~w(passkey email proof manual none) + @levels ~w(unknown low medium high) + + schema "atomine_trust_sessions" do + field :public_id, :string + belongs_to :user, Elektrine.Accounts.User + field :context, :string + field :merchant_id, :string + field :external_subject, :string + field :status, :string, default: "pending" + field :decision, :string, default: "review" + field :recommended_step_up, :string + field :score, :integer, default: 0 + field :level, :string, default: "unknown" + field :signals, :map, default: %{} + field :metadata, :map, default: %{} + field :expires_at, :utc_datetime + field :completed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + def contexts, do: @contexts + def statuses, do: @statuses + def decisions, do: @decisions + def step_ups, do: @step_ups + def levels, do: @levels + + def changeset(session, attrs) do + session + |> cast(attrs, [ + :public_id, + :user_id, + :context, + :merchant_id, + :external_subject, + :status, + :decision, + :recommended_step_up, + :score, + :level, + :signals, + :metadata, + :expires_at, + :completed_at + ]) + |> normalize_string(:context) + |> normalize_string(:status) + |> normalize_string(:decision) + |> normalize_string(:recommended_step_up) + |> normalize_string(:level) + |> normalize_optional_string(:merchant_id) + |> normalize_optional_string(:external_subject) + |> put_public_id() + |> validate_required([:public_id, :context, :status, :decision, :score, :level]) + |> validate_inclusion(:context, @contexts) + |> validate_inclusion(:status, @statuses) + |> validate_inclusion(:decision, @decisions) + |> validate_inclusion(:recommended_step_up, @step_ups) + |> validate_inclusion(:level, @levels) + |> validate_number(:score, greater_than_or_equal_to: 0, less_than_or_equal_to: 100) + |> validate_required_subject() + |> foreign_key_constraint(:user_id) + |> unique_constraint(:public_id) + end + + defp validate_required_subject(changeset) do + if get_field(changeset, :user_id) || present?(get_field(changeset, :external_subject)) do + changeset + else + add_error(changeset, :external_subject, "or user is required") + end + end + + defp put_public_id(changeset) do + case get_field(changeset, :public_id) do + value when is_binary(value) and value != "" -> changeset + _ -> put_change(changeset, :public_id, generate_public_id()) + end + end + + defp generate_public_id do + "ats_" <> (:crypto.strong_rand_bytes(18) |> Base.url_encode64(padding: false)) + end + + defp normalize_string(changeset, field) do + update_change(changeset, field, fn + value when is_binary(value) -> value |> String.trim() |> String.downcase() + value -> value + end) + end + + defp normalize_optional_string(changeset, field) do + update_change(changeset, field, fn + value when is_binary(value) -> value |> String.trim() |> blank_to_nil() + value -> value + end) + end + + defp blank_to_nil(""), do: nil + defp blank_to_nil(value), do: value + + defp present?(value), do: is_binary(value) and String.trim(value) != "" +end diff --git a/apps/atomine/mix.exs b/apps/atomine/mix.exs new file mode 100644 index 0000000..1714cd7 --- /dev/null +++ b/apps/atomine/mix.exs @@ -0,0 +1,39 @@ +defmodule Atomine.MixProject do + use Mix.Project + + def project do + [ + app: :atomine, + version: "0.1.0", + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + elixir: "~> 1.19", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [extra_applications: [:logger, :crypto]] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + defp deps do + [ + internal_dep(:elektrine) + ] + end + + defp internal_dep(app) do + if Mix.Project.umbrella?() do + {app, in_umbrella: true} + else + {app, path: "../#{app}"} + end + end +end diff --git a/apps/atomine/test/atomine/attestations_test.exs b/apps/atomine/test/atomine/attestations_test.exs new file mode 100644 index 0000000..239caa3 --- /dev/null +++ b/apps/atomine/test/atomine/attestations_test.exs @@ -0,0 +1,147 @@ +defmodule Atomine.AttestationsTest do + use Elektrine.DataCase, async: true + + alias Atomine.Attestation + alias Atomine.Attestations + alias Elektrine.Repo + + describe "issuer_metadata/1" do + test "describes public verifier endpoints" do + metadata = Attestations.issuer_metadata("https://issuer.example") + + assert metadata.protocol == "atomine-attestations" + assert metadata.version == "v1" + assert "gate_proof" in metadata.artifacts + assert "anonymous_effort_token" in metadata.artifacts + + assert metadata.endpoints.verify_artifact == + "https://issuer.example/api/atomine/artifacts/verify" + + assert metadata.endpoints.spend_anonymous_token == + "https://issuer.example/api/atomine/anonymous-tokens/spend" + end + end + + describe "anonymous effort tokens" do + test "can be spent once against an audience" do + {:ok, challenge} = Attestations.issue_pow_challenge(difficulty: 0) + + {:ok, token_attestation} = + Attestations.issue_anonymous_effort_token(%{ + "challenge" => challenge["challenge"], + "solution" => "any-solution", + "gate_proof" => gate_proof(challenge["challenge"]) + }) + + assert {:ok, redeemed} = + Attestations.redeem_anonymous_effort_token(token_attestation.artifact, %{ + "audience" => "https://shop.example/signup", + "nonce" => "checkout-123" + }) + + assert redeemed.status == "redeemed" + assert redeemed.metadata["spend"]["audience"] == "https://shop.example/signup" + assert redeemed.metadata["spend"]["nonce"] == "checkout-123" + + persisted = Repo.get!(Attestation, token_attestation.id) + assert persisted.metadata["spend"]["audience"] == "https://shop.example/signup" + + assert {:error, :already_redeemed} = + Attestations.redeem_anonymous_effort_token(token_attestation.artifact, %{ + "audience" => "https://other.example" + }) + end + + test "stores valid gate proof metadata with the issued token" do + {:ok, challenge} = Attestations.issue_pow_challenge(difficulty: 0) + + {:ok, token_attestation} = + Attestations.issue_anonymous_effort_token(%{ + "challenge" => challenge["challenge"], + "solution" => "any-solution", + "gate_proof" => gate_proof(challenge["challenge"]) + }) + + assert token_attestation.metadata["gate_proof"]["version"] == "atomine-gate-v1" + + assert token_attestation.metadata["gate_proof"]["layers"] == [ + "pow", + "browser_instrumentation" + ] + + checks = token_attestation.metadata["gate_proof"]["browser_instrumentation"]["checks"] + + assert Enum.map(checks, & &1["name"]) == [ + "layout.getComputedStyle", + "canvas.toDataURL", + "event.isTrusted", + "navigator.webdriver", + "dom.querySelector" + ] + end + + test "rejects invalid gate proof when provided" do + {:ok, challenge} = Attestations.issue_pow_challenge(difficulty: 0) + + assert {:error, :invalid_gate_proof} = + Attestations.issue_anonymous_effort_token(%{ + "challenge" => challenge["challenge"], + "solution" => "any-solution", + "gate_proof" => %{ + "version" => "atomine-gate-v1", + "layers" => ["pow", "browser_instrumentation"], + "browser_instrumentation" => %{ + "challenge_hash" => "wrong", + "checks" => [] + } + } + }) + end + + test "requires gate proof for anonymous effort tokens" do + {:ok, challenge} = Attestations.issue_pow_challenge(difficulty: 0) + + assert {:error, :missing_gate_proof} = + Attestations.issue_anonymous_effort_token(%{ + "challenge" => challenge["challenge"], + "solution" => "any-solution" + }) + end + + test "rejects invalid spend audiences" do + {:ok, challenge} = Attestations.issue_pow_challenge(difficulty: 0) + + {:ok, token_attestation} = + Attestations.issue_anonymous_effort_token(%{ + "challenge" => challenge["challenge"], + "solution" => "any-solution", + "gate_proof" => gate_proof(challenge["challenge"]) + }) + + assert {:error, :invalid_audience} = + Attestations.redeem_anonymous_effort_token(token_attestation.artifact, %{ + "audience" => "" + }) + end + end + + defp gate_proof(challenge) do + %{ + "version" => "atomine-gate-v1", + "layers" => ["pow", "browser_instrumentation"], + "browser_instrumentation" => %{ + "challenge_hash" => sha256_base64url(challenge), + "checks" => + Enum.map( + ~w(layout.getComputedStyle canvas.toDataURL event.isTrusted navigator.webdriver dom.querySelector), + &%{"name" => &1, "ok" => true, "duration_ms" => 1} + ), + "signals" => %{"user_agent_hash" => sha256_base64url("test-browser")} + } + } + end + + defp sha256_base64url(value) do + :crypto.hash(:sha256, value) |> Base.url_encode64(padding: false) + end +end diff --git a/apps/atomine/test/atomine/credits_test.exs b/apps/atomine/test/atomine/credits_test.exs new file mode 100644 index 0000000..7f98da9 --- /dev/null +++ b/apps/atomine/test/atomine/credits_test.exs @@ -0,0 +1,74 @@ +defmodule Atomine.CreditsTest do + use Elektrine.DataCase, async: true + + import Elektrine.AccountsFixtures + + alias Atomine.{CreditAccount, CreditLedgerEntry, Credits, CreditSpend} + alias Elektrine.Repo + + describe "grant/5" do + test "creates an account and records a positive ledger entry" do + user = user_fixture() + + assert {:ok, ledger_entry} = Credits.grant(user.id, :atomine_credit, 3, "verified_passkey") + + assert ledger_entry.amount == 3 + assert ledger_entry.reason == "verified_passkey" + assert Credits.balance(user.id, :atomine_credit) == 3 + + account = Repo.get_by!(CreditAccount, user_id: user.id, credit_type: "atomine_credit") + assert account.balance == 3 + assert account.lifetime_earned == 3 + assert account.lifetime_spent == 0 + end + end + + describe "spend/6" do + test "debits the balance and records spend plus ledger rows" do + user = user_fixture() + assert {:ok, _} = Credits.grant(user.id, "atomine_credit", 2, "test_grant") + + assert {:ok, spend} = + Credits.spend(user.id, :atomine_credit, 1, "first_dm", "user:#{user.id + 1}") + + assert spend.amount == 1 + assert spend.action == "first_dm" + assert Credits.balance(user.id, :atomine_credit) == 1 + + assert Repo.aggregate(CreditSpend, :count) == 1 + + assert [-1, 2] = + CreditLedgerEntry + |> order_by([e], desc: e.inserted_at, desc: e.id) + |> select([e], e.amount) + |> Repo.all() + end + + test "rejects spends without enough credits" do + user = user_fixture() + + assert {:error, :insufficient_credits} = + Credits.spend(user.id, :atomine_credit, 1, "first_dm", "user:999") + + assert Credits.balance(user.id, :atomine_credit) == 0 + assert Repo.aggregate(CreditSpend, :count) == 0 + end + + test "idempotency key prevents duplicate debits" do + user = user_fixture() + assert {:ok, _} = Credits.grant(user.id, :atomine_credit, 1, "test_grant") + + opts = [idempotency_key: "first_dm:#{user.id}:user:999"] + + assert {:ok, first_spend} = + Credits.spend(user.id, :atomine_credit, 1, "first_dm", "user:999", opts) + + assert {:ok, second_spend} = + Credits.spend(user.id, :atomine_credit, 1, "first_dm", "user:999", opts) + + assert second_spend.id == first_spend.id + assert Credits.balance(user.id, :atomine_credit) == 0 + assert Repo.aggregate(CreditSpend, :count) == 1 + end + end +end diff --git a/apps/atomine/test/atomine/personhood_test.exs b/apps/atomine/test/atomine/personhood_test.exs new file mode 100644 index 0000000..3fa3487 --- /dev/null +++ b/apps/atomine/test/atomine/personhood_test.exs @@ -0,0 +1,680 @@ +defmodule Atomine.PersonhoodTest do + use Elektrine.DataCase, async: true + + alias Atomine.{CreditEarningPolicy, Credits, Personhood, Proof} + alias Elektrine.Accounts + alias Elektrine.Repo + + import Elektrine.AccountsFixtures + + describe "create_proof/2" do + test "creates a pending proof with a generated challenge" do + user = user_fixture() + + assert {:ok, proof} = + Personhood.create_proof(user, %{ + kind: "web", + subject: "https://example.com/.well-known/elektrine.txt", + evidence_url: "https://example.com/.well-known/elektrine.txt" + }) + + assert proof.status == "pending" + assert proof.kind == "web" + assert proof.claim_type == "positive" + assert proof.proof_mode == "snapshot" + assert proof.live_status == nil + assert proof.verification_method == "page" + assert proof.score_weight == 20 + assert proof.challenge =~ "Atomine identity claim" + assert proof.challenge =~ "kind=web" + assert proof.challenge =~ "sig=" + assert proof.metadata["verification_snippet"] == proof.challenge + assert Personhood.page_snippet(proof) == proof.challenge + end + + test "rejects unsafe evidence URLs" do + user = user_fixture() + + for evidence_url <- [ + "javascript:alert(1)", + "https://user:pass@example.com/proof", + "https://example.com\r\nLocation:https://evil.test", + "//example.com/proof" + ] do + assert {:error, changeset} = + Personhood.create_proof(user, %{ + kind: "manual", + subject: "manual-#{System.unique_integer([:positive])}", + evidence_url: evidence_url + }) + + assert %{evidence_url: ["must be a safe http:// or https:// URL"]} = + errors_on(changeset) + end + end + + test "creates signed challenge statements" do + user = user_fixture() + + assert {:ok, proof} = + Personhood.create_proof(user, %{ + kind: "dns", + subject: "example.com" + }) + + assert proof.challenge =~ "Atomine identity claim v1" + assert proof.challenge =~ "user=%40" + assert proof.challenge =~ "subject=example.com" + assert proof.challenge =~ "nonce=" + assert proof.challenge =~ "sig=" + end + + test "signs DNS claims with the normalized subject" do + user = user_fixture() + + assert {:ok, proof} = + Personhood.create_proof(user, %{ + kind: "dns", + subject: " Example.COM. " + }) + + assert proof.subject == "example.com" + assert proof.challenge =~ "subject=example.com" + end + + test "prevents duplicate active proofs for the same subject" do + user = user_fixture() + attrs = %{kind: "dns", subject: "example.com"} + + assert {:ok, _proof} = Personhood.create_proof(user, attrs) + assert {:error, changeset} = Personhood.create_proof(user, attrs) + assert %{subject: ["already has an active proof for this subject"]} = errors_on(changeset) + end + + test "prevents active DNS proofs for a domain already claimed by another user" do + first_user = user_fixture() + second_user = user_fixture() + + assert {:ok, _proof} = + Personhood.create_proof(first_user, %{kind: "dns", subject: "Example.COM."}) + + assert {:error, changeset} = + Personhood.create_proof(second_user, %{kind: "dns", subject: "example.com"}) + + assert %{subject: ["domain already has an active DNS proof"]} = errors_on(changeset) + end + + test "creates DNS claims with TXT record instructions" do + user = user_fixture() + {:ok, proof} = Personhood.create_proof(user, %{kind: "dns", subject: "example.com"}) + + assert proof.verification_method == "dns" + assert {"_atomine", challenge} = Personhood.dns_txt_record(proof) + assert challenge == proof.challenge + end + + test "creates live proofs that are stale until first successful check" do + user = user_fixture() + + {:ok, proof} = + Personhood.create_proof(user, %{ + kind: "web", + subject: "https://example.com/profile", + proof_mode: "live" + }) + + assert proof.proof_mode == "live" + assert proof.live_status == "stale" + assert proof.next_check_at == nil + end + + test "uses GitHub gist verification for GitHub profile URLs" do + user = user_fixture() + + assert {:ok, proof} = + Personhood.create_proof(user, %{ + kind: "social", + subject: "https://github.com/businessfunk" + }) + + assert proof.verification_method == "github_gist" + assert proof.challenge =~ "kind=social" + + assert proof.challenge =~ "subject=https%3A%2F%2Fgithub.com%2Fbusinessfunk" + end + + test "falls back to page verification for other social URLs" do + user = user_fixture() + + assert {:ok, proof} = + Personhood.create_proof(user, %{ + kind: "social", + subject: "https://social.example/@human" + }) + + assert proof.verification_method == "page" + end + + test "creates negative assertions that cannot affect personhood score" do + user = user_fixture() + + assert {:ok, assertion} = + Personhood.create_negative_assertion(user, %{ + kind: "social", + subject: "https://twitter.com/not-me" + }) + + assert assertion.claim_type == "negative" + assert assertion.status == "asserted" + assert assertion.verification_method == "none" + assert assertion.score_weight == 0 + assert Personhood.personhood_score(user) == 0 + end + end + + describe "review lifecycle" do + test "verified proofs count toward score and revoked proofs stop counting" do + user = user_fixture() + reviewer = user_fixture() + + {:ok, proof} = Personhood.create_proof(user, %{kind: "dns", subject: "example.com"}) + assert Personhood.personhood_score(user) == 0 + assert Personhood.personhood_level(user) == :unknown + + assert {:ok, verified} = Personhood.verify_proof(proof, reviewer, "TXT record matched") + assert verified.status == "verified" + assert verified.checked_at + assert verified.verified_at + assert verified.reviewed_by_user_id == reviewer.id + assert Personhood.personhood_score(user) == 25 + assert Personhood.personhood_level(user) == :low + + assert {:ok, revoked} = Personhood.revoke_proof(verified, reviewer, "domain changed hands") + assert revoked.status == "revoked" + assert revoked.revoked_at + assert Personhood.personhood_score(user) == 0 + end + + test "verified proofs grant Atomine Credits once" do + user = user_fixture() + + {:ok, proof} = Personhood.create_proof(user, %{kind: "dns", subject: "example.com"}) + assert Credits.balance(user.id, :atomine_credit) == 0 + + assert {:ok, verified} = Personhood.verify_proof(proof) + assert Credits.balance(user.id, :atomine_credit) == 10 + + assert {:ok, _verified_again} = Personhood.verify_proof(verified) + assert Credits.balance(user.id, :atomine_credit) == 10 + + assert [%{amount: 10, reason: "verified_proof:dns"}] = + Credits.list_ledger_entries(user.id, limit: 1) + end + + test "deleting and recreating the same DNS proof does not grant twice" do + user = user_fixture() + + {:ok, proof} = Personhood.create_proof(user, %{kind: "dns", subject: "Example.COM."}) + assert {:ok, verified} = Personhood.verify_proof(proof) + assert Credits.balance(user.id, :atomine_credit) == 10 + + assert {:ok, _deleted} = Personhood.delete_proof(verified) + + {:ok, recreated} = Personhood.create_proof(user, %{kind: "dns", subject: "example.com"}) + assert {:ok, _verified_again} = Personhood.verify_proof(recreated) + + assert Credits.balance(user.id, :atomine_credit) == 10 + + assert [%{reason: "verified_proof:dns"}] = + Credits.list_ledger_entries(user.id) + |> Enum.filter(&(&1.reason == "verified_proof:dns")) + end + + test "profile URL variants share one credit reward" do + user = user_fixture() + + {:ok, proof} = + Personhood.create_proof(user, %{ + kind: "social", + subject: "https://github.com/businessfunk?utm_source=test#readme" + }) + + assert {:ok, verified} = Personhood.verify_proof(proof) + assert Credits.balance(user.id, :atomine_credit) == 5 + + assert {:ok, _deleted} = Personhood.delete_proof(verified) + + {:ok, recreated} = + Personhood.create_proof(user, %{ + kind: "social", + subject: "https://github.com/businessfunk/" + }) + + assert {:ok, _verified_again} = Personhood.verify_proof(recreated) + assert Credits.balance(user.id, :atomine_credit) == 5 + end + + test "a DNS claim already rewarded globally does not reward another account" do + first_user = user_fixture() + second_user = user_fixture() + + {:ok, proof} = + Personhood.create_proof(first_user, %{kind: "dns", subject: "shared.example"}) + + assert {:ok, verified} = Personhood.verify_proof(proof) + assert Credits.balance(first_user.id, :atomine_credit) == 10 + + assert {:ok, _deleted} = Personhood.delete_proof(verified) + + {:ok, second_proof} = + Personhood.create_proof(second_user, %{kind: "dns", subject: "shared.example"}) + + assert {:ok, _second_verified} = Personhood.verify_proof(second_proof) + assert Credits.balance(second_user.id, :atomine_credit) == 0 + end + + test "low-trust accounts cannot exceed the daily proof earning cap" do + user = user_fixture() + + {:ok, dns_proof} = + Personhood.create_proof(user, %{kind: "dns", subject: "daily-cap.example"}) + + {:ok, web_proof} = + Personhood.create_proof(user, %{ + kind: "web", + subject: "https://daily-cap.example/proof" + }) + + {:ok, social_proof} = + Personhood.create_proof(user, %{ + kind: "social", + subject: "https://social.example/daily-cap" + }) + + assert {:ok, _dns} = Personhood.verify_proof(dns_proof) + assert {:ok, _web} = Personhood.verify_proof(web_proof) + assert {:ok, _social} = Personhood.verify_proof(social_proof) + + assert Credits.balance(user.id, :atomine_credit) == 18 + end + + test "proof-of-work credits can be claimed up to 20 times per day" do + user = user_fixture() + today = Date.utc_today() + + for _ <- 1..20 do + assert {:ok, _entry} = CreditEarningPolicy.grant_for_proof_of_work(user.id, date: today) + end + + assert {:ok, :daily_claim_limit_reached} = + CreditEarningPolicy.grant_for_proof_of_work(user.id, date: today) + + assert Credits.balance(user.id, :atomine_credit) == 20 + end + + test "live proofs can become active, stale, and inactive" do + user = user_fixture() + + {:ok, proof} = + Personhood.create_proof(user, %{ + kind: "dns", + subject: "live.example.com", + proof_mode: "live" + }) + + assert {:ok, active} = Personhood.verify_proof(proof) + assert active.status == "verified" + assert active.live_status == "active" + assert active.last_seen_at + assert active.next_check_at + assert active.stale_at + assert active.failed_check_count == 0 + assert Personhood.personhood_score(user) == 30 + + assert {:ok, stale} = Personhood.mark_live_stale(active, "check timed out") + assert stale.live_status == "stale" + assert stale.failed_check_count == 1 + assert Personhood.personhood_score(user) == 12 + + assert {:ok, inactive} = Personhood.mark_live_inactive(stale, "snippet missing") + assert inactive.live_status == "inactive" + assert inactive.failed_check_count == 2 + assert Personhood.personhood_score(user) == 0 + end + + test "manual proof can satisfy a high anti-bot gate" do + user = user_fixture() + {:ok, proof} = Personhood.create_proof(user, %{kind: "manual", subject: "admin-review"}) + {:ok, _verified} = Personhood.verify_proof(proof) + + assert Personhood.personhood_score(user.id) == 100 + assert Personhood.personhood_level(user.id) == :high + assert Personhood.sufficiently_human?(user.id, 75) + end + + test "check_proof reports unsupported self-check proof types" do + user = user_fixture() + {:ok, proof} = Personhood.create_proof(user, %{kind: "manual", subject: "admin-review"}) + + assert {:error, :manual_review_required} = Personhood.check_proof(proof) + end + + test "check_proof records failed web checks without verifying" do + user = user_fixture() + + {:ok, proof} = + Personhood.create_proof(user, %{ + kind: "web", + subject: "ftp://example.com/proof" + }) + + assert {:error, {:not_found, checked}} = Personhood.check_proof(proof) + assert checked.status == "pending" + assert checked.checked_at + assert checked.review_notes =~ "Web check failed" + assert Personhood.personhood_score(user) == 0 + end + + test "check_proof blocks private web proof hosts" do + user = user_fixture() + + {:ok, proof} = + Personhood.create_proof(user, %{ + kind: "web", + subject: "http://10.0.0.1/proof" + }) + + assert {:error, {:not_found, checked}} = Personhood.check_proof(proof) + assert checked.status == "pending" + assert checked.review_notes =~ "blocked_private_host" + end + + test "check_proof reports failed live checks instead of returning success" do + user = user_fixture() + + {:ok, proof} = + Personhood.create_proof(user, %{ + kind: "web", + subject: "ftp://example.com/proof", + proof_mode: "live" + }) + + assert {:error, {:not_found, checked}} = Personhood.check_proof(proof) + assert checked.status == "pending" + assert checked.live_status == "stale" + assert checked.failed_check_count == 1 + assert checked.checked_at + assert checked.review_notes =~ "Web check failed" + assert Personhood.personhood_score(user) == 0 + end + + test "lists live proofs due for scheduled recheck" do + user = user_fixture() + + {:ok, proof} = + Personhood.create_proof(user, %{ + kind: "dns", + subject: "due.example.com", + proof_mode: "live" + }) + + {:ok, verified} = Personhood.verify_proof(proof) + + due_at = DateTime.utc_now() |> DateTime.add(-60, :second) |> DateTime.truncate(:second) + + verified + |> Proof.changeset(%{next_check_at: due_at}) + |> Repo.update!() + + assert [%Proof{id: id}] = Personhood.list_due_live_proofs() + assert id == verified.id + end + + test "check_proof rejects tampered signed statements" do + user = user_fixture() + + {:ok, proof} = + Personhood.create_proof(user, %{ + kind: "web", + subject: "https://example.com/proof" + }) + + {:ok, proof} = + proof + |> Proof.changeset(%{challenge: String.replace(proof.challenge, "kind=web", "kind=dns")}) + |> Repo.update() + + assert {:error, {:not_found, checked}} = Personhood.check_proof(proof) + assert checked.status == "pending" + assert checked.review_notes =~ "proof_kind_mismatch" + end + + test "check_proof rejects non-GitHub profile URLs for GitHub gist verification" do + user = user_fixture() + + {:ok, proof} = + Personhood.create_proof(user, %{ + kind: "social", + subject: "https://social.example/businessfunk", + verification_method: "github_gist" + }) + + assert {:error, {:not_found, checked}} = Personhood.check_proof(proof) + assert checked.status == "pending" + assert checked.review_notes =~ "not_github_profile_url" + end + end + + describe "composite scoring" do + test "combines proof strength, diversity, age, security, history, and trust" do + user = + user_fixture() + |> backdate_user(200) + |> update_user!(%{ + login_count: 25, + last_seen_at: DateTime.utc_now() |> DateTime.truncate(:second), + recovery_email: "human@example.com", + recovery_email_verified: true, + two_factor_enabled: true, + trust_level: 2 + }) + + {:ok, dns} = Personhood.create_proof(user, %{kind: "dns", subject: "example.com"}) + + {:ok, social} = + Personhood.create_proof(user, %{kind: "social", subject: "https://social.example/@human"}) + + {:ok, passkey} = + Personhood.create_proof(user, %{kind: "passkey", subject: "credential:primary"}) + + {:ok, _} = Personhood.verify_proof(dns) + {:ok, _} = Personhood.verify_proof(social) + {:ok, _} = Personhood.verify_proof(passkey) + + breakdown = Personhood.personhood_breakdown(user) + + assert breakdown.score == 100 + assert breakdown.level == :high + assert breakdown.positive.proofs == 60 + assert breakdown.positive.proof_diversity == 10 + assert breakdown.positive.account_age == 16 + assert breakdown.positive.security == 20 + assert breakdown.positive.account_history == 11 + assert breakdown.positive.platform_trust == 8 + assert breakdown.verified_proof_kinds == ["dns", "passkey", "social"] + end + + test "applies account health and rejected proof penalties" do + user = + user_fixture() + |> backdate_user(30) + |> update_user!(%{ + banned: true, + email_sending_restricted: true, + registered_via_onion: true + }) + + {:ok, web} = Personhood.create_proof(user, %{kind: "web", subject: "https://site.example"}) + + {:ok, social} = + Personhood.create_proof(user, %{kind: "social", subject: "https://social.example/@bot"}) + + {:ok, _} = Personhood.verify_proof(web) + {:ok, _} = Personhood.reject_proof(social) + + breakdown = Personhood.personhood_breakdown(user) + + assert breakdown.positive.proofs == 20 + assert breakdown.positive.account_age == 8 + assert breakdown.penalties.account_restrictions == 115 + assert breakdown.penalties.onion_registration == 5 + assert breakdown.penalties.proof_rejections == 10 + assert breakdown.score == 0 + assert breakdown.level == :unknown + end + end + + describe "list_pending_proofs/0" do + test "returns pending proofs oldest first" do + user = user_fixture() + {:ok, pending} = Personhood.create_proof(user, %{kind: "web", subject: "https://a.test"}) + + {:ok, verified} = + Personhood.create_proof(user, %{kind: "social", subject: "https://b.test"}) + + {:ok, _verified} = Personhood.verify_proof(verified) + + assert [^pending] = Personhood.list_pending_proofs() + assert %Proof{} = Repo.get!(Proof, pending.id) + end + end + + describe "trust sessions" do + test "creates a guest checkout trust session" do + assert {:ok, session} = + Personhood.create_trust_session(%{ + context: "checkout", + merchant_id: "shop_123", + external_subject: "buyer@example.com", + signals: %{"email_verified" => true} + }) + + assert session.public_id =~ "ats_" + assert session.user_id == nil + assert session.context == "checkout" + assert session.merchant_id == "shop_123" + assert session.external_subject == "buyer@example.com" + assert session.status == "pending" + assert session.decision == "review" + assert session.recommended_step_up == "proof" + assert session.score == 0 + assert session.level == "unknown" + assert session.expires_at + assert Personhood.get_trust_session(session.public_id).id == session.id + end + + test "creates a known-user trust session from the user's personhood score" do + user = user_fixture() |> backdate_user(120) + {:ok, proof} = Personhood.create_proof(user, %{kind: "dns", subject: "example.com"}) + {:ok, _proof} = Personhood.verify_proof(proof) + + assert {:ok, session} = + Personhood.create_trust_session(user, %{ + context: "checkout", + merchant_id: "shop_456" + }) + + assert session.user_id == user.id + assert session.score >= 15 + assert session.decision == "step_up" + assert session.status == "step_up" + assert session.recommended_step_up == "passkey" + assert session.level == "low" + end + + test "updates and completes trust sessions" do + {:ok, session} = + Personhood.create_trust_session(%{ + context: "signup", + external_subject: "new@example.com" + }) + + assert {:ok, stepped_up} = + Personhood.update_trust_session(session, %{ + status: "step_up", + decision: "step_up", + recommended_step_up: "passkey", + score: 22, + level: "low" + }) + + assert stepped_up.status == "step_up" + assert stepped_up.recommended_step_up == "passkey" + + assert {:ok, completed} = + Personhood.complete_trust_session(stepped_up, %{ + decision: "allow", + recommended_step_up: "none", + score: 55, + level: "medium" + }) + + assert completed.status == "completed" + assert completed.decision == "allow" + assert completed.completed_at + end + + test "lists trust sessions with filters" do + user = user_fixture() + {:ok, user_session} = Personhood.create_trust_session(user, %{context: "checkout"}) + + {:ok, guest_session} = + Personhood.create_trust_session(%{ + context: "signup", + merchant_id: "shop_filter", + external_subject: "guest@example.com" + }) + + assert [^user_session] = Personhood.list_trust_sessions(user_id: user.id) + assert [^guest_session] = Personhood.list_trust_sessions(merchant_id: "shop_filter") + assert guest_session in Personhood.list_trust_sessions(status: "pending") + end + end + + describe "connected account proofs" do + test "creates a verified OAuth proof from a reusable connected account" do + user = user_fixture() + + {:ok, connected_account} = + Accounts.upsert_connected_account(user, %{ + provider: "github", + provider_account_id: "12345", + username: "octo", + display_name: "Octo", + profile_url: "https://github.com/octo" + }) + + assert {:ok, proof} = Personhood.verify_connected_account_proof(connected_account) + assert proof.status == "verified" + assert proof.verification_method == "oauth" + assert proof.subject == "oauth:github:12345" + assert proof.evidence_url == "https://github.com/octo" + assert proof.metadata["connected_account_id"] == connected_account.id + assert Personhood.personhood_score(user) > 0 + end + end + + defp backdate_user(user, days) do + inserted_at = DateTime.utc_now() |> DateTime.add(-days, :day) |> DateTime.truncate(:second) + + user + |> Ecto.Changeset.change(inserted_at: inserted_at) + |> Repo.update!() + end + + defp update_user!(user, attrs) do + user + |> Ecto.Changeset.change(attrs) + |> Repo.update!() + end +end diff --git a/apps/atomine/test/test_helper.exs b/apps/atomine/test/test_helper.exs new file mode 100644 index 0000000..b9db890 --- /dev/null +++ b/apps/atomine/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(Elektrine.Repo, :manual) diff --git a/apps/elektrine/.gitignore b/apps/elektrine/.gitignore new file mode 100644 index 0000000..09b88f8 --- /dev/null +++ b/apps/elektrine/.gitignore @@ -0,0 +1,24 @@ +# Elixir build/test artifacts +/_build/ +/deps/ +/cover/ +/.elixir_ls/ +/tmp/ +erl_crash.dump + +# Asset build artifacts +/assets/node_modules/ +/assets/.sass-cache/ +/assets/npm-debug.log* +/assets/yarn-debug.log* +/assets/yarn-error.log* +/priv/static/assets/ +/priv/static/cache_manifest.json +/priv/static/uploads/ +/priv/static/**/*.gz +/priv/static/**/*-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].* + +# Generated runtime data +/priv/acme/ +/priv/exports/ +/priv/imap_rate_limit_*.ets diff --git a/apps/elektrine/README.md b/apps/elektrine/README.md new file mode 100644 index 0000000..e75fc44 --- /dev/null +++ b/apps/elektrine/README.md @@ -0,0 +1,24 @@ +# Elektrine Core + +Core domain app for the Elektrine umbrella. + +## What lives here + +- Accounts, auth, and privacy settings +- Shared persistence and runtime services consumed by the other umbrella apps +- ActivityPub domain logic and delivery workers +- Calendar, contacts, profiles, notifications, and uploads +- Module selection and shared platform wiring + +This app still carries a large share of the cross-product domain code. Other +apps depend on it for `Elektrine.Repo`, shared schemas, and shared services. + +## Notes + +- `elektrine` is the main dependency for the feature apps. +- Migrations for core domain tables live under `apps/elektrine/priv/repo/migrations`. +- Chat call sites should prefer `Arblarg` over reaching into `Elektrine.Messaging` directly. + +## License + +AGPL-3.0-only (see `../../LICENSE`). diff --git a/apps/elektrine/assets/css/animations.css b/apps/elektrine/assets/css/animations.css new file mode 100644 index 0000000..8ec8ad7 --- /dev/null +++ b/apps/elektrine/assets/css/animations.css @@ -0,0 +1,99 @@ +/* ========================================================================== + ANIMATIONS - All @keyframes and animation classes + ========================================================================== */ + +/* -------------------------------------------------------------------------- + REDUCED MOTION - Respect user preferences + -------------------------------------------------------------------------- */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + .marquee-container { + animation: none !important; + } +} + +/* -------------------------------------------------------------------------- + KEYFRAMES + -------------------------------------------------------------------------- */ + +@keyframes fade-in { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes highlight-fade { + 0% { background-color: rgba(59, 130, 246, 0.2); } + 100% { background-color: transparent; } +} + +@keyframes marquee { + from { transform: translate3d(0, 0, 0); } + to { transform: translate3d(-50%, 0, 0); } +} + +@keyframes cyber-glow { + 0% { box-shadow: 0 0 8px rgba(18, 159, 184, 0.55), 0 0 12px rgba(18, 159, 184, 0.26); } + 50% { box-shadow: 0 0 12px rgba(44, 92, 224, 0.46), 0 0 16px rgba(18, 159, 184, 0.34); } + 100% { box-shadow: 0 0 8px rgba(18, 159, 184, 0.55), 0 0 12px rgba(18, 159, 184, 0.26); } +} + +@keyframes blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} + +/* -------------------------------------------------------------------------- + ANIMATION CLASSES + -------------------------------------------------------------------------- */ + +.animate-fade-in { + animation: fade-in 0.15s ease-out; +} + +.highlight-message { + animation: highlight-fade 2s ease-in-out; +} + +.rainbow-glow-hover:hover, +.rainbow-glow-hover:focus { + animation: cyber-glow 2s ease-in-out infinite; +} + +/* -------------------------------------------------------------------------- + MARQUEE + -------------------------------------------------------------------------- */ + +.marquee-viewport:hover .marquee-container, +.marquee-viewport:focus-within .marquee-container { + animation-play-state: paused; +} + +.marquee-container { + --marquee-gap: 0.75rem; + --marquee-duration: 40s; + display: flex; + align-items: center; + width: max-content; + min-width: max-content; + will-change: transform; + transform: translate3d(0, 0, 0); + backface-visibility: hidden; + animation: marquee var(--marquee-duration) linear infinite; +} + +.marquee-content { + display: flex; + align-items: center; + gap: var(--marquee-gap); + flex: 0 0 auto; + padding-right: var(--marquee-gap); +} diff --git a/apps/elektrine/assets/css/app.css b/apps/elektrine/assets/css/app.css new file mode 100644 index 0000000..7b3ee1f --- /dev/null +++ b/apps/elektrine/assets/css/app.css @@ -0,0 +1,40 @@ +/* ========================================================================== + ELEKTRINE - Main Stylesheet + ========================================================================== + + Structure: + - base.css : Reset, fonts, theme variables, grid backgrounds + - components.css: Buttons, inputs, cards, navbar, dropdowns, etc. + - animations.css: All @keyframes and animation classes + - email.css : Email content styling + - effects.css : Username, avatar, link, and visual effects + - utilities.css : Helpers, touch targets, typography, chat, misc + + ========================================================================== */ + +/* Tailwind & DaisyUI */ +@import "tailwindcss"; +@plugin "daisyui" { + /* DaisyUI's radial-progress support emits an @property rule that Lightning CSS warns about. */ + exclude: properties, radialprogress; +} +@config "../tailwind.config.mjs"; + +/* Design tokens: extra type-scale steps below text-xs. + Font-size only (no line-height sub-token) so these behave exactly like the + text-[11px] / text-[10px] arbitrary values they replace. */ +@theme { + --text-2xs: 0.6875rem; + --text-3xs: 0.625rem; +} + +/* Vendor styles */ +@import "../node_modules/uplot/dist/uPlot.min.css"; + +/* App styles */ +@import "./base.css"; +@import "./components.css"; +@import "./animations.css"; +@import "./email.css"; +@import "./effects.css"; +@import "./utilities.css"; diff --git a/apps/elektrine/assets/css/base.css b/apps/elektrine/assets/css/base.css new file mode 100644 index 0000000..020b31f --- /dev/null +++ b/apps/elektrine/assets/css/base.css @@ -0,0 +1,610 @@ +/* ========================================================================== + BASE - Reset, Fonts, Theme Variables + ========================================================================== */ + +:root { + color-scheme: dark; +} + +html { + color-scheme: dark; + overflow-x: clip; +} + +body { + color: var(--color-base-content, #e5e2e1); + background-color: var(--color-base-100, #121214); + isolation: isolate; +} + +::selection { + background: var(--theme-selection-bg, rgba(95, 135, 184, 0.28)); + color: var(--theme-text-inverse, #ffffff); +} + +* { + scrollbar-width: auto; + scrollbar-color: var(--theme-scrollbar-thumb, rgba(95, 135, 184, 0.28)) transparent; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background-color: var(--theme-scrollbar-thumb, rgba(95, 135, 184, 0.28)); + border-radius: 999px; +} + +::-webkit-scrollbar-thumb:hover { + background-color: var(--theme-scrollbar-thumb-hover, rgba(95, 135, 184, 0.42)); +} + +[phx-click], +[phx-submit], +button:not(:disabled), +a[href], +.btn, +.cursor-pointer { + cursor: pointer; +} + +.search-clear-input { + padding-right: 2rem; +} + +.search-clear-auto[hidden] { + display: none !important; +} + +.search-clear-auto { + align-items: center; + background: transparent; + border: 0; + display: inline-flex; + justify-content: center; + line-height: 1; + margin: 0; + padding: 0; +} + +.search-clear-inline { + margin-left: 0.5rem; +} + +.search-clear-auto:focus-visible { + border-radius: 0.25rem; + outline: 2px solid currentColor; + outline-offset: 2px; +} + +.search-clear-overlay { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + z-index: 2; +} + +/* ========================================================================== + FONT SYSTEM - Self-hosted Geist (text), Geist Mono (mono), Geist Pixel (display) + ========================================================================== */ + +@font-face { + font-family: 'Geist'; + font-weight: 100 900; + font-style: italic; + font-display: swap; + src: url('/fonts/Geist-Italic-cyrillic-ext.woff2') format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} + +@font-face { + font-family: 'Geist'; + font-weight: 100 900; + font-style: italic; + font-display: swap; + src: url('/fonts/Geist-Italic-cyrillic.woff2') format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +@font-face { + font-family: 'Geist'; + font-weight: 100 900; + font-style: italic; + font-display: swap; + src: url('/fonts/Geist-Italic-vietnamese.woff2') format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} + +@font-face { + font-family: 'Geist'; + font-weight: 100 900; + font-style: italic; + font-display: swap; + src: url('/fonts/Geist-Italic-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Geist'; + font-weight: 100 900; + font-style: italic; + font-display: swap; + src: url('/fonts/Geist-Italic-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Geist'; + font-weight: 100 900; + font-style: normal; + font-display: swap; + src: url('/fonts/Geist-cyrillic-ext.woff2') format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} + +@font-face { + font-family: 'Geist'; + font-weight: 100 900; + font-style: normal; + font-display: swap; + src: url('/fonts/Geist-cyrillic.woff2') format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +@font-face { + font-family: 'Geist'; + font-weight: 100 900; + font-style: normal; + font-display: swap; + src: url('/fonts/Geist-vietnamese.woff2') format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} + +@font-face { + font-family: 'Geist'; + font-weight: 100 900; + font-style: normal; + font-display: swap; + src: url('/fonts/Geist-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Geist'; + font-weight: 100 900; + font-style: normal; + font-display: swap; + src: url('/fonts/Geist-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Geist Mono'; + font-weight: 100 900; + font-style: italic; + font-display: swap; + src: url('/fonts/GeistMono-Italic-cyrillic-ext.woff2') format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} + +@font-face { + font-family: 'Geist Mono'; + font-weight: 100 900; + font-style: italic; + font-display: swap; + src: url('/fonts/GeistMono-Italic-cyrillic.woff2') format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +@font-face { + font-family: 'Geist Mono'; + font-weight: 100 900; + font-style: italic; + font-display: swap; + src: url('/fonts/GeistMono-Italic-symbols2.woff2') format('woff2'); + unicode-range: U+2000-2001, U+2004-2008, U+200A, U+23B8-23BD, U+2500-259F; +} + +@font-face { + font-family: 'Geist Mono'; + font-weight: 100 900; + font-style: italic; + font-display: swap; + src: url('/fonts/GeistMono-Italic-vietnamese.woff2') format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} + +@font-face { + font-family: 'Geist Mono'; + font-weight: 100 900; + font-style: italic; + font-display: swap; + src: url('/fonts/GeistMono-Italic-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Geist Mono'; + font-weight: 100 900; + font-style: italic; + font-display: swap; + src: url('/fonts/GeistMono-Italic-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Geist Mono'; + font-weight: 100 900; + font-style: normal; + font-display: swap; + src: url('/fonts/GeistMono-cyrillic-ext.woff2') format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} + +@font-face { + font-family: 'Geist Mono'; + font-weight: 100 900; + font-style: normal; + font-display: swap; + src: url('/fonts/GeistMono-cyrillic.woff2') format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +@font-face { + font-family: 'Geist Mono'; + font-weight: 100 900; + font-style: normal; + font-display: swap; + src: url('/fonts/GeistMono-symbols2.woff2') format('woff2'); + unicode-range: U+2000-2001, U+2004-2008, U+200A, U+23B8-23BD, U+2500-259F; +} + +@font-face { + font-family: 'Geist Mono'; + font-weight: 100 900; + font-style: normal; + font-display: swap; + src: url('/fonts/GeistMono-vietnamese.woff2') format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} + +@font-face { + font-family: 'Geist Mono'; + font-weight: 100 900; + font-style: normal; + font-display: swap; + src: url('/fonts/GeistMono-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Geist Mono'; + font-weight: 100 900; + font-style: normal; + font-display: swap; + src: url('/fonts/GeistMono-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Geist Pixel'; + font-weight: 400; + font-style: normal; + font-display: swap; + src: url('/fonts/GeistPixel-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Geist Pixel'; + font-weight: 400; + font-style: normal; + font-display: swap; + src: url('/fonts/GeistPixel-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@layer base { + html { + font-family: 'Geist', ui-sans-serif, system-ui, -apple-system, sans-serif; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + font-weight: 600; + } +} + +/* ========================================================================== + GRID BACKGROUNDS + ========================================================================== */ + +:root { + --theme-grid-size: 40px; + --theme-grid-color: color-mix(in srgb, var(--color-base-content, #d1d5db) 7%, transparent); + --theme-grid-x-color: color-mix(in srgb, var(--color-primary, #8a7cc2) 5%, transparent); + --theme-grid-glow-color: color-mix(in srgb, var(--color-primary, #8a7cc2) 12%, transparent); + --theme-grid-glow-x-color: color-mix(in srgb, var(--color-primary, #8a7cc2) 7%, transparent); +} + +/* Grid glow overlay - cursor-focused grid highlight */ +#cursor-glow-overlay, +#blinkenlights-container { --top-chrome-height: 0px; clip-path: inset(var(--top-chrome-height) 0 0 0); +} #cursor-glow-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: -1; contain: strict; background-size: var(--theme-grid-size) var(--theme-grid-size); background-image: linear-gradient(var(--theme-grid-glow-color) 1px, transparent 1px), linear-gradient(90deg, var(--theme-grid-glow-color) 1px, transparent 1px), linear-gradient( 45deg, transparent calc(50% - 0.6px), var(--theme-grid-glow-x-color) 50%, transparent calc(50% + 0.6px) ), linear-gradient( -45deg, transparent calc(50% - 0.6px), var(--theme-grid-glow-x-color) 50%, transparent calc(50% + 0.6px) ); opacity: calc(var(--cursor-opacity, 0) * 0.78); filter: saturate(1.08) brightness(1.12); will-change: opacity, mask-image, -webkit-mask-image; mask-image: radial-gradient( var(--cursor-radius, 300px) circle at var(--cursor-x, -1000px) var(--cursor-y, -1000px), rgba(0, 0, 0, 0.76) 0%, rgba(0, 0, 0, 0.56) 36%, rgba(0, 0, 0, 0.30) 62%, transparent 84% ); -webkit-mask-image: radial-gradient( var(--cursor-radius, 300px) circle at var(--cursor-x, -1000px) var(--cursor-y, -1000px), rgba(0, 0, 0, 0.76) 0%, rgba(0, 0, 0, 0.56) 36%, rgba(0, 0, 0, 0.30) 62%, transparent 84% ); +} body.bg-base-100 { background-color: var(--color-base-100); background-size: var(--theme-grid-size) var(--theme-grid-size); background-attachment: fixed; background-position: 0 0; background-image: linear-gradient(var(--theme-grid-color) 1px, transparent 1px), linear-gradient(90deg, var(--theme-grid-color) 1px, transparent 1px), linear-gradient( 45deg, transparent calc(50% - 0.6px), var(--theme-grid-x-color) 50%, transparent calc(50% + 0.6px) ), linear-gradient( -45deg, transparent calc(50% - 0.6px), var(--theme-grid-x-color) 50%, transparent calc(50% + 0.6px) ); +} /* Grid color variants */ +body[data-grid="purple"], body.grid-purple { --theme-grid-color: color-mix(in srgb, var(--color-primary, #8a7cc2) 8%, transparent); --theme-grid-x-color: color-mix(in srgb, var(--color-primary, #8a7cc2) 6%, transparent); --theme-grid-glow-color: color-mix(in srgb, var(--color-primary, #8a7cc2) 15%, transparent); --theme-grid-glow-x-color: color-mix(in srgb, var(--color-primary, #8a7cc2) 9%, transparent); +} body[data-grid="cyan"], body.grid-cyan { --theme-grid-color: color-mix(in srgb, var(--color-info, #60a5fa) 8%, transparent); --theme-grid-x-color: color-mix(in srgb, var(--color-info, #60a5fa) 6%, transparent); --theme-grid-glow-color: color-mix(in srgb, var(--color-info, #60a5fa) 15%, transparent); --theme-grid-glow-x-color: color-mix(in srgb, var(--color-info, #60a5fa) 9%, transparent); +} body[data-grid="red"], body.grid-red { --theme-grid-color: color-mix(in srgb, var(--color-error, #ef4444) 8%, transparent); --theme-grid-x-color: color-mix(in srgb, var(--color-error, #ef4444) 6%, transparent); --theme-grid-glow-color: color-mix(in srgb, var(--color-error, #ef4444) 15%, transparent); --theme-grid-glow-x-color: color-mix(in srgb, var(--color-error, #ef4444) 9%, transparent); +} body[data-grid="green"], body.grid-green { --theme-grid-color: color-mix(in srgb, var(--color-success, #22c55e) 8%, transparent); --theme-grid-x-color: color-mix(in srgb, var(--color-success, #22c55e) 6%, transparent); --theme-grid-glow-color: color-mix(in srgb, var(--color-success, #22c55e) 15%, transparent); --theme-grid-glow-x-color: color-mix(in srgb, var(--color-success, #22c55e) 9%, transparent); +} body[data-grid="orange"], body.grid-orange { --theme-grid-color: color-mix(in srgb, var(--color-warning, #f59e0b) 8%, transparent); --theme-grid-x-color: color-mix(in srgb, var(--color-warning, #f59e0b) 6%, transparent); --theme-grid-glow-color: color-mix(in srgb, var(--color-warning, #f59e0b) 15%, transparent); --theme-grid-glow-x-color: color-mix(in srgb, var(--color-warning, #f59e0b) 9%, transparent); +} .admin-layout { color: var(--color-base-content); background-color: var(--color-base-100); background-image: none !important; min-height: 100vh; +} .admin-layout > main { position: relative; background-image: none !important; +} .admin-page { display: flex; flex-direction: column; gap: 1.5rem; +} @media (min-width: 640px) { .admin-page { gap: 2rem; } +} .admin-page > :first-child { margin-bottom: 0 !important; +} .admin-page > :first-child:not(.card):not(.panel-card):not(.alert) { background-color: var(--surface-panel-bg-fallback) !important; background-image: none !important; border: 1px solid var(--surface-panel-border); border-radius: 0.875rem; box-shadow: var(--surface-panel-shadow); padding: 1.25rem; +} @media (min-width: 640px) { .admin-page > :first-child:not(.card):not(.panel-card):not(.alert) { padding: 1.5rem 1.75rem; } +} .admin-page > :first-child:not(.card):not(.panel-card):not(.alert) h1 { letter-spacing: -0.02em; +} .admin-page > :first-child:not(.card):not(.panel-card):not(.alert) p { color: color-mix(in srgb, var(--color-base-content) 70%, transparent); +} .admin-page > .card.panel-card, +.admin-page > section.card.panel-card { margin-top: 0 !important; margin-bottom: 0 !important; +} body[data-grid="blue"], body.grid-blue { --theme-grid-color: color-mix(in srgb, var(--color-secondary, #60a5fa) 8%, transparent); --theme-grid-x-color: color-mix(in srgb, var(--color-secondary, #60a5fa) 6%, transparent); --theme-grid-glow-color: color-mix(in srgb, var(--color-secondary, #60a5fa) 15%, transparent); --theme-grid-glow-x-color: color-mix(in srgb, var(--color-secondary, #60a5fa) 9%, transparent); +} body[data-grid="pink"], body.grid-pink { --theme-grid-color: color-mix(in srgb, var(--color-accent, #ec4899) 8%, transparent); --theme-grid-x-color: color-mix(in srgb, var(--color-accent, #ec4899) 6%, transparent); --theme-grid-glow-color: color-mix(in srgb, var(--color-accent, #ec4899) 15%, transparent); --theme-grid-glow-x-color: color-mix(in srgb, var(--color-accent, #ec4899) 9%, transparent); +} .bg-grid { background-size: var(--theme-grid-size) var(--theme-grid-size); background-image: linear-gradient(var(--theme-grid-color) 1px, transparent 1px), linear-gradient(90deg, var(--theme-grid-color) 1px, transparent 1px), linear-gradient( 45deg, transparent calc(50% - 0.6px), var(--theme-grid-x-color) 50%, transparent calc(50% + 0.6px) ), linear-gradient( -45deg, transparent calc(50% - 0.6px), var(--theme-grid-x-color) 50%, transparent calc(50% + 0.6px) ); +} .network-grid { --theme-grid-size: 50px; background-size: var(--theme-grid-size) var(--theme-grid-size); background-image: linear-gradient(var(--theme-grid-color) 1px, transparent 1px), linear-gradient(90deg, var(--theme-grid-color) 1px, transparent 1px), linear-gradient( 45deg, transparent calc(50% - 0.6px), var(--theme-grid-x-color) 50%, transparent calc(50% + 0.6px) ), linear-gradient( -45deg, transparent calc(50% - 0.6px), var(--theme-grid-x-color) 50%, transparent calc(50% + 0.6px) ); +} /* ========================================================================== BLINKENLIGHTS - Retro mainframe-style blinking lights grid ========================================================================== */ /* Hide regular grid when blinkenlights are active */ +body.has-blinkenlights { background-image: none !important; +} #blinkenlights-container { position: fixed; top: 0; left: 0; width: 100%; height: calc(100vh + 100px); height: calc(100dvh + 100px); pointer-events: none; z-index: -1; +} .blinkenlight { position: absolute; width: 3px; height: 3px; border-radius: 50%; background-color: rgba(150, 150, 150, 0.5); transition: opacity 0.15s; +} .blinkenlight.active { animation: blinken-mono var(--blinken-duration, 6s) var(--blinken-delay, 0s) infinite ease-in-out; +} .blinkenlight.finding { background-color: rgba(255, 255, 255, 1) !important; box-shadow: 0 0 6px rgba(255, 255, 255, 0.8); +} .blinkenlight.action { background-color: rgba(220, 220, 220, 1) !important; box-shadow: 0 0 4px rgba(220, 220, 220, 0.6); +} @keyframes blinken-mono { 0%, 75%, 100% { background-color: rgba(150, 150, 150, 0.5); } 80%, 95% { background-color: rgba(255, 255, 255, 0.9); box-shadow: 0 0 4px rgba(255, 255, 255, 0.5); } +} .footer-brandmark { align-items: center; display: inline-flex; gap: 0.65rem; opacity: 0.94; filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.24)); +} .footer-brandmark-icon { flex: none; height: auto; opacity: 0.88; width: clamp(1.9rem, 8vw, 3.6rem); +} .footer-wordmark-glow { -webkit-text-stroke: 1px rgba(255, 255, 255, 0.08); filter: drop-shadow(0 0 14px rgba(138, 124, 194, 0.14)) drop-shadow(0 4px 14px rgba(0, 0, 0, 0.2)); opacity: 0.92; +} .footer-wordmark-glow:hover { opacity: 1 !important; +} @media (max-width: 639px) { .footer-brandmark { gap: 0.45rem; opacity: 1; } .footer-brandmark-icon { opacity: 0.96; width: clamp(1.7rem, 10vw, 2.4rem); } .footer-wordmark-glow { -webkit-text-stroke-width: 1.15px; filter: drop-shadow(0 0 16px rgba(138, 124, 194, 0.18)) drop-shadow(0 3px 10px rgba(0, 0, 0, 0.28)); opacity: 1; } +} + +:root { + --theme-text-inverse: #ffffff; + --theme-selection-bg: color-mix(in srgb, var(--color-base-content, #d1d5db) 18%, transparent); + --theme-scrollbar-thumb: color-mix(in srgb, var(--color-base-content, #d1d5db) 26%, transparent); + --theme-scrollbar-thumb-hover: color-mix(in srgb, var(--color-base-content, #d1d5db) 38%, transparent); + + --theme-surface-interactive-shadow: 0 1px 2px color-mix(in srgb, #0f172a 8%, transparent), 0 2px 8px color-mix(in srgb, #0f172a 5%, transparent); + --theme-surface-hover-shadow: 0 4px 12px color-mix(in srgb, #000000 10%, transparent); + --theme-navbar-shadow: 0 12px 28px -24px color-mix(in srgb, #08101e 72%, transparent); + --theme-modal-backdrop: color-mix(in srgb, #0a0e14 56%, transparent); + + --theme-link-color: color-mix(in srgb, var(--color-primary, #8a7cc2) 88%, #ffffff 12%); + --theme-link-color-hover: color-mix(in srgb, var(--color-primary, #8a7cc2) 72%, #000000 28%); + --theme-email-wrapper-bg: #ffffff; + --theme-email-wrapper-text: color-mix(in srgb, var(--color-base-content, #e4e7eb) 26%, #000000 74%); + + --theme-btn-ghost-text: color-mix(in srgb, var(--color-base-content, #e4e7eb) 88%, var(--color-primary, #8a7cc2) 12%); + --theme-btn-ghost-bg: color-mix(in srgb, var(--color-base-300, #202833) 84%, var(--color-primary, #8a7cc2) 16%); + --theme-btn-outline-border: color-mix(in srgb, var(--color-base-300, #202833) 84%, var(--color-base-content, #e4e7eb) 16%); + --theme-btn-outline-bg: color-mix(in srgb, var(--color-base-200, #151b22) 92%, var(--color-base-100, #101419) 8%); + --theme-btn-neutral-bg: color-mix(in srgb, var(--color-base-300, #202833) 90%, var(--color-base-200, #151b22) 10%); + --theme-btn-neutral-border: color-mix(in srgb, var(--color-base-300, #202833) 82%, var(--color-base-content, #e4e7eb) 18%); + --theme-btn-neutral-bg-hover: color-mix(in srgb, var(--color-base-300, #202833) 78%, var(--color-base-200, #151b22) 22%); + --theme-btn-neutral-border-hover: color-mix(in srgb, var(--color-base-300, #202833) 74%, var(--color-base-content, #e4e7eb) 26%); + --theme-input-border: color-mix(in srgb, var(--color-base-300, #202833) 72%, var(--theme-text-inverse) 28%); + --theme-input-highlight: color-mix(in srgb, var(--color-primary, #8a7cc2) 28%, transparent); + --theme-input-highlight-strong: color-mix(in srgb, var(--color-primary, #8a7cc2) 42%, transparent); + --theme-input-highlight-shadow: color-mix(in srgb, var(--color-primary, #8a7cc2) 12%, transparent); + --theme-input-inset-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 2.5%, transparent); + --theme-navbar-border: color-mix(in srgb, var(--color-base-300, #202833) 84%, var(--color-base-content, #e4e7eb) 16%); + --theme-nav-link: color-mix(in srgb, var(--color-base-content, #e4e7eb) 88%, var(--color-primary, #8a7cc2) 12%); + --theme-error-badge-bg: var(--color-error, #ef4444); + --theme-error-badge-border: var(--color-error, #ef4444); + --theme-table-row-hover: color-mix(in srgb, var(--color-base-200, #151b22) 90%, var(--color-base-300, #202833) 10%); + + --theme-chat-error-soft: color-mix(in srgb, var(--color-error, #dc2626) 20%, transparent); + --theme-chat-error-text-soft: color-mix(in srgb, var(--color-error, #dc2626) 55%, var(--theme-text-inverse) 45%); + --theme-chat-error-text-strong: color-mix(in srgb, var(--color-error, #dc2626) 35%, var(--theme-text-inverse) 65%); + --theme-chat-error-border-50: color-mix(in srgb, var(--color-error, #dc2626) 50%, transparent); + --theme-chat-error-button-overlay: linear-gradient(135deg, color-mix(in srgb, var(--color-error, #dc2626) 24%, transparent), color-mix(in srgb, var(--color-error, #dc2626) 10%, transparent) 55%, transparent 100%); + --theme-chat-error-button-overlay-hover: linear-gradient(135deg, color-mix(in srgb, var(--color-error, #dc2626) 32%, transparent), color-mix(in srgb, var(--color-error, #dc2626) 14%, transparent) 55%, transparent 100%); + --theme-chat-error-button-border: color-mix(in srgb, var(--color-error, #dc2626) 52%, transparent); + --theme-chat-error-button-border-hover: color-mix(in srgb, var(--color-error, #dc2626) 64%, transparent); + --theme-chat-error-button-text: color-mix(in srgb, var(--theme-text-inverse) 98%, var(--color-error, #dc2626) 2%); + --theme-message-selected-bg: color-mix(in srgb, var(--color-primary, #8a7cc2) 10%, transparent); + --theme-message-selected-border: color-mix(in srgb, var(--color-primary, #8a7cc2) 45%, transparent); + --theme-message-selected-shadow: color-mix(in srgb, var(--color-primary, #8a7cc2) 18%, transparent); + --theme-message-selected-bg-hover: color-mix(in srgb, var(--color-primary, #8a7cc2) 15%, transparent); + --theme-message-selected-border-hover: color-mix(in srgb, var(--color-primary, #8a7cc2) 62%, transparent); + --theme-message-selected-shadow-hover: color-mix(in srgb, var(--color-primary, #8a7cc2) 26%, transparent); + + --theme-effect-rainbow: linear-gradient(90deg, #ff0000 0%, #ff7f00 16.67%, #ffff00 33.33%, #00ff00 50%, #0000ff 66.67%, #4b0082 83.33%, #9400d3 100%); + --theme-effect-fire: linear-gradient(45deg, #ff0000, #ff7f00, #ffff00, #ff7f00, #ff0000); + --theme-effect-holographic: linear-gradient(90deg, #ff00ff 0%, #00ffff 25%, #00ff00 50%, #ffff00 75%, #ff00ff 100%); + --theme-effect-chrome: linear-gradient(90deg, #888888, #ffffff, #cccccc, #888888, #ffffff); + --theme-effect-retro: linear-gradient(180deg, #ff00ff 0%, #00ffff 100%); + --theme-effect-gold-frame: linear-gradient(135deg, #c41e3a 0%, #8b0000 50%, #c41e3a 100%); + --theme-effect-gold-surface: linear-gradient(45deg, #ffd700 0%, #ffed4e 25%, #ffa500 50%, #ffed4e 75%, #ffd700 100%); + --theme-effect-gold-frame-shadow: color-mix(in srgb, #c41e3a 60%, transparent); + --theme-effect-gold-inner-glow: color-mix(in srgb, #ffd700 30%, transparent); + --theme-effect-gold-outer-glow: color-mix(in srgb, #ffd700 80%, transparent); + --theme-effect-gold-outer-accent: color-mix(in srgb, #ffa500 40%, transparent); + --theme-effect-gold-top-highlight: color-mix(in srgb, #ffffff 50%, transparent); + --theme-effect-gold-bottom-shadow: color-mix(in srgb, #8b0000 30%, transparent); + --theme-effect-fire-shadow: drop-shadow(0 0 4px color-mix(in srgb, #ff6400 60%, transparent)) drop-shadow(0 0 8px color-mix(in srgb, #ff3200 40%, transparent)); + --theme-effect-ice-color: #a0d8f1; + --theme-effect-ice-highlight: color-mix(in srgb, var(--theme-effect-ice-color) 78%, #ffffff 22%); + --theme-effect-ice-shadow: 0 0 10px color-mix(in srgb, #add8e6 80%, transparent), 0 0 20px color-mix(in srgb, #87cefa 60%, transparent), 0 0 30px color-mix(in srgb, #6495ed 40%, transparent), 2px 2px 4px color-mix(in srgb, #ffffff 30%, transparent); + --theme-effect-chrome-shadow: drop-shadow(1px 1px 2px color-mix(in srgb, #000000 50%, transparent)); + --theme-effect-retro-shadow: drop-shadow(2px 2px 0px #ff00ff) drop-shadow(2px 2px 0px #00ffff); + --theme-effect-outline-stroke: var(--glow-color, var(--color-primary, #8a7cc2)); + --theme-effect-shadow-color: var(--shadow-color, #000000); + --theme-effect-glitch-cyan: #00fffc; + --theme-effect-glitch-magenta: #fc00ff; + --theme-effect-glitch-yellow: #fffc00; + --theme-avatar-accent-color: var(--avatar-accent, var(--color-primary, #8a7cc2)); + --theme-avatar-accent-light-color: var(--avatar-accent-light, color-mix(in srgb, var(--theme-avatar-accent-color) 72%, #ffffff 28%)); + --theme-link-accent-color: var(--link-accent, var(--color-primary, #8a7cc2)); + --theme-link-border-shadow: color-mix(in srgb, var(--theme-link-accent-color) 20%, transparent); + --theme-link-shine: linear-gradient(45deg, transparent 30%, color-mix(in srgb, #ffffff 30%, transparent) 50%, transparent 70%); + --theme-image-zoom-overlay: color-mix(in srgb, #000000 40%, transparent); +} + +html[data-theme="light"], +:root[data-theme="light"], +[data-theme="light"], +[data-theme=light] { + color-scheme: light; + --color-primary: var(--theme-override-color-primary, #4f7098) !important; + --color-primary-content: var(--theme-override-color-primary-content, #ffffff) !important; + --color-secondary: var(--theme-override-color-secondary, #a8662d) !important; + --color-secondary-content: var(--theme-override-color-secondary-content, #ffffff) !important; + --color-accent: var(--theme-override-color-accent, #62779f) !important; + --color-accent-content: var(--theme-override-color-accent-content, #ffffff) !important; + --color-neutral: #27313c !important; + --color-neutral-content: #ffffff !important; + --color-base-100: var(--theme-override-color-base-100, #f5f7fa) !important; + --color-base-200: var(--theme-override-color-base-200, #edf1f5) !important; + --color-base-300: var(--theme-override-color-base-300, #d2dae4) !important; + --color-base-content: var(--theme-override-color-base-content, #17202a) !important; + --color-info: var(--theme-override-color-info, #3f6fa8) !important; + --color-info-content: var(--theme-override-color-info-content, #ffffff) !important; + --color-success: var(--theme-override-color-success, #3f7a52) !important; + --color-success-content: var(--theme-override-color-success-content, #ffffff) !important; + --color-warning: var(--theme-override-color-warning, #9a5b18) !important; + --color-warning-content: var(--theme-override-color-warning-content, #ffffff) !important; + --color-error: var(--theme-override-color-error, #a34b48) !important; + --color-error-content: var(--theme-override-color-error-content, #ffffff) !important; + --surface-panel-bg-fallback: color-mix(in srgb, var(--color-base-200) 72%, #ffffff 28%); + --surface-panel-bg: color-mix(in srgb, var(--color-base-200) 72%, #ffffff 28%); + --surface-panel-border: color-mix(in srgb, var(--color-base-300) 78%, var(--color-primary) 22%); + --surface-panel-shadow: 0 18px 42px -32px rgba(31, 48, 68, 0.28), 0 1px 2px rgba(31, 48, 68, 0.05); + --surface-floating-bg-fallback: #fdfefe; + --surface-floating-bg: #fdfefe; + --surface-floating-border: color-mix(in srgb, var(--color-base-300) 78%, var(--color-primary) 22%); + --surface-floating-shadow: 0 22px 50px -30px rgba(31, 48, 68, 0.34), 0 2px 8px rgba(31, 48, 68, 0.08); + --surface-modal-bg-fallback: #ffffff; + --surface-modal-border: color-mix(in srgb, var(--color-base-300) 78%, var(--color-primary) 22%); + --surface-modal-shadow: 0 30px 70px -36px rgba(31, 48, 68, 0.42), 0 2px 10px rgba(31, 48, 68, 0.08); + --theme-navbar-border: color-mix(in srgb, var(--color-base-300) 88%, var(--color-primary) 12%); + --theme-navbar-shadow: 0 14px 34px -28px rgba(31, 48, 68, 0.38); + --theme-modal-backdrop: color-mix(in srgb, #263442 42%, transparent); + --theme-link-color: color-mix(in srgb, var(--color-primary) 90%, #000000 10%); + --theme-link-color-hover: color-mix(in srgb, var(--color-primary) 72%, #000000 28%); + --theme-btn-neutral-bg: color-mix(in srgb, var(--color-base-200) 64%, var(--color-base-300) 36%); + --theme-btn-neutral-border: color-mix(in srgb, var(--color-base-300) 72%, var(--color-base-content) 28%); + --theme-btn-neutral-bg-hover: color-mix(in srgb, var(--color-base-200) 42%, var(--color-base-300) 58%); + --theme-btn-neutral-border-hover: color-mix(in srgb, var(--color-base-300) 58%, var(--color-base-content) 42%); + --theme-btn-ghost-text: color-mix(in srgb, var(--color-base-content) 88%, var(--color-primary) 12%); + --theme-btn-ghost-bg: color-mix(in srgb, var(--color-base-200) 78%, var(--color-primary) 22%); + --theme-btn-outline-border: color-mix(in srgb, var(--color-base-300) 68%, var(--color-primary) 32%); + --theme-btn-outline-bg: color-mix(in srgb, var(--color-base-200) 88%, #ffffff 12%); + --theme-input-bg: color-mix(in srgb, var(--color-base-200) 90%, var(--color-primary) 10%); + --theme-input-border: color-mix(in srgb, var(--color-base-300) 68%, var(--color-primary) 32%); + --theme-input-highlight: color-mix(in srgb, var(--color-primary) 24%, transparent); + --theme-input-highlight-strong: color-mix(in srgb, var(--color-primary) 38%, transparent); + --theme-input-highlight-shadow: color-mix(in srgb, var(--color-primary) 10%, transparent); + --theme-table-row-hover: color-mix(in srgb, var(--color-base-200) 78%, var(--color-primary) 22%); +} + +[data-theme-show] { + display: none; +} + +html[data-theme="light"] [data-theme-show="light"], +html[data-theme="dark"] [data-theme-show="dark"] { + display: inline-flex; +} + +html[data-theme="dark"], +:root[data-theme="dark"], +[data-theme="dark"], +[data-theme=dark] { + color-scheme: dark; + --color-primary: var(--theme-override-color-primary, #5f87b8) !important; + --color-secondary: var(--theme-override-color-secondary, #c9853f) !important; + --color-accent: var(--theme-override-color-accent, #7d99bb) !important; + --color-neutral: #171c22 !important; + --color-neutral-content: #e4e7eb !important; + --color-base-100: var(--theme-override-color-base-100, #121214) !important; + --color-base-200: var(--theme-override-color-base-200, #1a1a1d) !important; + --color-base-300: var(--theme-override-color-base-300, #2a2a31) !important; + --color-base-content: var(--theme-override-color-base-content, #e5e2e1) !important; + --color-info: var(--theme-override-color-info, #6f95c4) !important; + --color-success: var(--theme-override-color-success, #6f8b74) !important; + --color-warning: var(--theme-override-color-warning, #c99152) !important; + --color-error: var(--theme-override-color-error, #a56b68) !important; + + --surface-panel-bg: var(--color-base-200); + --surface-floating-bg: var(--color-base-300); + --surface-panel-bg-fallback: color-mix(in srgb, var(--color-base-200) 92%, #222228 8%); + --surface-panel-border: color-mix(in srgb, var(--color-base-300) 72%, var(--color-primary) 28%); + --surface-panel-shadow: 0 24px 54px -36px rgba(2, 12, 30, 0.88), 0 0 0 1px color-mix(in srgb, var(--color-primary) 10%, transparent); + --surface-floating-bg-fallback: color-mix(in srgb, var(--color-base-200) 88%, #25252c 12%); + --surface-floating-border: color-mix(in srgb, var(--color-base-300) 80%, var(--color-base-content) 20%); + --surface-floating-shadow: 0 26px 58px -34px rgba(2, 12, 30, 0.92), 0 0 0 1px color-mix(in srgb, var(--color-base-content) 8%, transparent); + --surface-modal-bg-fallback: color-mix(in srgb, var(--color-base-200) 84%, #2a2a31 16%); + --surface-modal-border: color-mix(in srgb, var(--color-base-300) 62%, var(--color-primary) 38%); + --surface-modal-shadow: 0 32px 72px -34px rgba(1, 9, 26, 0.96), 0 0 0 1px color-mix(in srgb, var(--color-primary) 12%, transparent); + --theme-navbar-border: color-mix(in srgb, var(--color-base-300) 68%, var(--color-primary) 32%); + --theme-navbar-shadow: 0 18px 42px -28px rgba(2, 12, 30, 0.84); + --theme-modal-backdrop: color-mix(in srgb, #020611 72%, transparent); + --theme-btn-neutral-bg: color-mix(in srgb, var(--color-base-300) 92%, #232329 8%); + --theme-btn-neutral-border: color-mix(in srgb, var(--color-base-300) 68%, var(--color-primary) 32%); + --theme-btn-neutral-bg-hover: color-mix(in srgb, var(--color-base-300) 84%, #2a2a31 16%); + --theme-btn-neutral-border-hover: color-mix(in srgb, var(--color-base-300) 56%, var(--color-primary) 44%); + --theme-input-border: color-mix(in srgb, var(--color-base-300) 60%, var(--color-info) 40%); + --theme-input-highlight: color-mix(in srgb, var(--color-primary) 34%, transparent); + --theme-input-highlight-strong: color-mix(in srgb, var(--color-primary) 52%, transparent); + --theme-input-highlight-shadow: color-mix(in srgb, var(--color-primary) 18%, transparent); + --theme-table-row-hover: color-mix(in srgb, var(--color-base-200) 88%, #26262d 12%); + --theme-link-color: color-mix(in srgb, var(--color-primary) 88%, #f1f5f9 12%); + --theme-link-color-hover: color-mix(in srgb, var(--color-primary) 92%, #ffffff 8%); + --color-primary-content: var(--theme-override-color-primary-content, #ffffff) !important; + --color-secondary-content: var(--theme-override-color-secondary-content, #ffffff) !important; + --color-accent-content: var(--theme-override-color-accent-content, #ffffff) !important; + --color-info-content: var(--theme-override-color-info-content, #ffffff) !important; + --color-success-content: var(--theme-override-color-success-content, #ffffff) !important; + --color-warning-content: var(--theme-override-color-warning-content, #ffffff) !important; + --color-error-content: var(--theme-override-color-error-content, #ffffff) !important; +} + +.footer-wordmark-glow { + filter: drop-shadow(0 0 14px rgba(95, 135, 184, 0.11)) + drop-shadow(0 4px 14px rgba(0, 0, 0, 0.2)); +} + +@media (max-width: 639px) { + .footer-wordmark-glow { + filter: drop-shadow(0 0 16px rgba(95, 135, 184, 0.12)) + drop-shadow(0 3px 10px rgba(0, 0, 0, 0.28)); + } +} + +/* Home poster page: the global scrollbar styling uses a transparent + track, which Firefox paints as a near-black strip; next to the + full-bleed hero it reads as a stray vertical line (invisible on + dark pages, obvious here). Hide the scrollbar on this page — wheel, + keyboard, and touch scrolling are unaffected — and drop daisyUI's + reserved root gutter so no strip is left behind. */ +:root:has(#home-poster-art) { + scrollbar-gutter: auto; + scrollbar-width: none; + background: #05070a; +} + +:root:has(#home-poster-art)::-webkit-scrollbar { + display: none; +} diff --git a/apps/elektrine/assets/css/components.css b/apps/elektrine/assets/css/components.css new file mode 100644 index 0000000..871c198 --- /dev/null +++ b/apps/elektrine/assets/css/components.css @@ -0,0 +1,391 @@ +/* ========================================================================== COMPONENTS - Buttons, Inputs, Cards, Navbar, etc. ========================================================================== */ /* Shared icon behavior for consistent look across pages. Explicit text-* / opacity-* icon classes keep full control. */ +.ui-icon { color: currentColor; vertical-align: middle; transition: color 0.15s ease, opacity 0.15s ease; +} /* -------------------------------------------------------------------------- BUTTONS -------------------------------------------------------------------------- */ .btn-primary { --btn-color: color-mix(in srgb, var(--color-base-300) 72%, var(--color-primary) 28%); --btn-fg: var(--color-primary-content); +} .vote-score-pending { display: none !important; +} .vote-score--empty { display: none; +} .vote-up-button.phx-click-loading + .vote-score .vote-score-current { display: none !important; +} .vote-up-button.phx-click-loading + .vote-score .vote-score-pending { display: inline !important; +} .vote-up-button.phx-click-loading + .vote-score.vote-score--empty { display: inline; +} .btn-secondary { --btn-color: color-mix(in srgb, var(--color-base-300) 72%, var(--color-secondary) 28%); --btn-fg: var(--color-secondary-content); +} .btn-accent { --btn-color: color-mix(in srgb, var(--color-base-300) 72%, var(--color-accent) 28%); --btn-fg: var(--color-accent-content); +} .btn-info { --btn-color: color-mix(in srgb, var(--color-base-300) 72%, var(--color-info) 28%); --btn-fg: var(--color-info-content); +} .btn-success { --btn-color: color-mix(in srgb, var(--color-base-300) 72%, var(--color-success) 28%); --btn-fg: var(--color-success-content); +} .btn-warning { --btn-color: color-mix(in srgb, var(--color-base-300) 72%, var(--color-warning) 28%); --btn-fg: var(--color-warning-content); +} .btn-error { --btn-color: color-mix(in srgb, var(--color-base-300) 72%, var(--color-error) 28%); --btn-fg: var(--color-error-content); +} html[data-theme="light"] .btn-primary:not(.btn-outline) { --btn-color: var(--color-primary); +} html[data-theme="light"] .btn-secondary:not(.btn-outline) { --btn-color: var(--color-secondary); +} html[data-theme="light"] .btn-accent:not(.btn-outline) { --btn-color: var(--color-accent); +} html[data-theme="light"] .btn-info:not(.btn-outline) { --btn-color: var(--color-info); +} html[data-theme="light"] .btn-success:not(.btn-outline) { --btn-color: var(--color-success); +} html[data-theme="light"] .btn-warning:not(.btn-outline) { --btn-color: var(--color-warning); +} html[data-theme="light"] .btn-error:not(.btn-outline) { --btn-color: var(--color-error); +} .btn-ghost { --btn-color: var(--theme-btn-ghost-bg); --btn-fg: var(--theme-btn-ghost-text); --btn-rest-fg: var(--theme-btn-ghost-text); +} .btn-outline { --btn-color: var(--theme-btn-outline-border); --btn-fg: var(--color-base-content); --btn-rest-fg: var(--color-base-content); +} +/* Outline + color variants: understated at rest (normal base-content text + a + subtle dark themed border, mixed from base-300 so it stays muted on the dark + theme), then on hover fill with the same muted accent the solid buttons use, + text in the accent's content color. This keeps them at the same low saturation + as the rest of the buttons. The accent only reads strongly on hover. + Plain .btn-outline stays neutral; two-class selectors win regardless of order. */ +.btn-outline.btn-primary { --btn-color: color-mix(in srgb, var(--color-base-300) 70%, var(--color-primary) 30%); --btn-fg: var(--color-primary-content); color: var(--color-base-content); +} .btn-outline.btn-primary:hover, .btn-outline.btn-primary:focus-visible { color: var(--color-primary-content); +} .btn-outline.btn-secondary { --btn-color: color-mix(in srgb, var(--color-base-300) 70%, var(--color-secondary) 30%); --btn-fg: var(--color-secondary-content); color: var(--color-base-content); +} .btn-outline.btn-secondary:hover, .btn-outline.btn-secondary:focus-visible { color: var(--color-secondary-content); +} .btn-outline.btn-accent { --btn-color: color-mix(in srgb, var(--color-base-300) 70%, var(--color-accent) 30%); --btn-fg: var(--color-accent-content); color: var(--color-base-content); +} .btn-outline.btn-accent:hover, .btn-outline.btn-accent:focus-visible { color: var(--color-accent-content); +} .btn-outline.btn-info { --btn-color: color-mix(in srgb, var(--color-base-300) 70%, var(--color-info) 30%); --btn-fg: var(--color-info-content); color: var(--color-base-content); +} .btn-outline.btn-info:hover, .btn-outline.btn-info:focus-visible { color: var(--color-info-content); +} .btn-outline.btn-success { --btn-color: color-mix(in srgb, var(--color-base-300) 70%, var(--color-success) 30%); --btn-fg: var(--color-success-content); color: var(--color-base-content); +} .btn-outline.btn-success:hover, .btn-outline.btn-success:focus-visible { color: var(--color-success-content); +} .btn-outline.btn-warning { --btn-color: color-mix(in srgb, var(--color-base-300) 70%, var(--color-warning) 30%); --btn-fg: var(--color-warning-content); color: var(--color-base-content); +} .btn-outline.btn-warning:hover, .btn-outline.btn-warning:focus-visible { color: var(--color-warning-content); +} .btn-outline.btn-error { --btn-color: color-mix(in srgb, var(--color-base-300) 70%, var(--color-error) 30%); --btn-fg: var(--color-error-content); color: var(--color-base-content); +} .btn-outline.btn-error:hover, .btn-outline.btn-error:focus-visible { color: var(--color-error-content); +} .btn-soft, +.btn-neutral { --btn-color: var(--theme-btn-neutral-bg); --btn-fg: var(--color-base-content); --btn-rest-fg: var(--color-base-content); +} .btn-surface { border-color: var(--surface-panel-border); background-color: var(--surface-panel-bg-fallback) !important; color: var(--color-base-content); +} .btn-surface:hover, +.btn-surface:focus-visible { border-color: color-mix(in srgb, var(--color-base-content) 15%, var(--surface-panel-border) 85%); background-color: color-mix(in srgb, var(--surface-panel-bg-fallback) 82%, var(--color-base-300) 18%) !important; +} .load-more-button { --btn-color: color-mix(in srgb, var(--theme-btn-neutral-bg) 72%, var(--color-primary) 28%); --btn-fg: var(--color-base-content); border-color: color-mix(in srgb, var(--theme-btn-neutral-border) 70%, var(--color-primary) 30%) !important; background-color: color-mix(in srgb, var(--theme-btn-neutral-bg) 84%, var(--color-primary) 16%) !important; color: color-mix(in srgb, var(--color-base-content) 90%, var(--color-primary) 10%) !important; background-image: none !important; box-shadow: var(--theme-surface-interactive-shadow); +} .load-more-button:hover, +.load-more-button:focus-visible { border-color: color-mix(in srgb, var(--theme-btn-neutral-border-hover) 48%, var(--color-primary) 52%) !important; background-color: color-mix(in srgb, var(--theme-btn-neutral-bg-hover) 72%, var(--color-primary) 28%) !important; color: var(--color-base-content) !important; box-shadow: var(--theme-surface-hover-shadow); +} .load-more-button:disabled, +.load-more-button.btn-disabled { opacity: 0.6; box-shadow: none; +} /* -------------------------------------------------------------------------- BADGES -------------------------------------------------------------------------- */ .badge { --app-badge-accent: var(--color-base-content); border-color: color-mix(in srgb, var(--app-badge-accent) 6%, var(--theme-btn-outline-border) 94%) !important; border-radius: 9999px; background-color: color-mix(in srgb, var(--app-badge-accent) 5%, var(--theme-btn-ghost-bg) 95%) !important; color: color-mix(in srgb, var(--app-badge-accent) 18%, var(--theme-btn-ghost-text) 82%) !important; background-image: none !important; box-shadow: none; text-shadow: none; font-weight: 500; +} .badge-outline { border-color: color-mix(in srgb, var(--app-badge-accent) 10%, var(--theme-btn-outline-border) 90%) !important; background-color: color-mix(in srgb, var(--app-badge-accent) 4%, var(--theme-btn-outline-bg) 96%) !important; color: color-mix(in srgb, var(--app-badge-accent) 20%, var(--theme-btn-ghost-text) 80%) !important; +} .badge-ghost { border-color: transparent !important; background-color: color-mix(in srgb, var(--color-base-content) 3%, var(--theme-btn-ghost-bg) 97%) !important; color: color-mix(in srgb, var(--color-base-content) 76%, transparent) !important; +} .badge-neutral { --app-badge-accent: var(--color-neutral, var(--color-base-content)); +} .badge-primary { --app-badge-accent: var(--color-primary); +} .badge-secondary { --app-badge-accent: var(--color-secondary); +} .badge-accent { --app-badge-accent: var(--color-accent); +} .badge-info { --app-badge-accent: var(--color-info); +} .badge-success { --app-badge-accent: var(--color-success); +} .badge-warning { --app-badge-accent: var(--color-warning); +} .badge-error { --app-badge-accent: var(--color-error); +} html[data-theme="dark"] .btn, +:root[data-theme="dark"] .btn, +[data-theme="dark"] .btn, +[data-theme=dark] .btn { --depth: 0; --btn-noise: none; box-shadow: none; text-shadow: none; background-image: none; +} html[data-theme="dark"] .btn:hover, +html[data-theme="dark"] .btn:active, +html[data-theme="dark"] .btn:focus-visible, +:root[data-theme="dark"] .btn:hover, +:root[data-theme="dark"] .btn:active, +:root[data-theme="dark"] .btn:focus-visible, +[data-theme="dark"] .btn:hover, +[data-theme="dark"] .btn:active, +[data-theme="dark"] .btn:focus-visible, +[data-theme=dark] .btn:hover, +[data-theme=dark] .btn:active, +[data-theme=dark] .btn:focus-visible { box-shadow: none; text-shadow: none; background-image: none; +} .btn:disabled, +.btn.btn-disabled { opacity: 0.45; +} .menu a .ui-icon:not([class*="text-"]):not([class*="opacity-"]), +.menu button .ui-icon:not([class*="text-"]):not([class*="opacity-"]), +.navbar a .ui-icon:not([class*="text-"]):not([class*="opacity-"]), +.navbar [role="button"] .ui-icon:not([class*="text-"]):not([class*="opacity-"]) { opacity: 0.88; +} .menu a:hover .ui-icon:not([class*="text-"]):not([class*="opacity-"]), +.menu button:hover .ui-icon:not([class*="text-"]):not([class*="opacity-"]), +.navbar a:hover .ui-icon:not([class*="text-"]):not([class*="opacity-"]), +.navbar [role="button"]:hover .ui-icon:not([class*="text-"]):not([class*="opacity-"]) { opacity: 1; +} /* Primary - violet tint */ /* -------------------------------------------------------------------------- MOBILE TOUCH TARGETS - Minimum 44x44px for accessibility -------------------------------------------------------------------------- */ @media (max-width: 640px) { /* Increase touch targets on mobile */ .btn-xs { min-height: 2.5rem; min-width: 2.5rem; padding-left: 0.75rem; padding-right: 0.75rem; } .btn-sm { min-height: 2.75rem; min-width: 2.75rem; } .btn:not(.btn-xs):not(.btn-sm) { min-height: 2.75rem; min-width: 2.75rem; } /* Filter pills on mobile */ .filter-pill { min-height: 2.75rem; padding: 0.5rem 1rem; } /* Dropdown items */ .dropdown-content li button, .dropdown-content li a, .menu li button, .menu li a { min-height: 2.75rem; padding: 0.75rem 1rem; } +} /* Loading state */ +.btn-loading { pointer-events: none; opacity: 0.7; +} .btn-loading .btn-text { visibility: hidden; +} .btn-loading .btn-spinner { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); +} /* -------------------------------------------------------------------------- INPUTS & FORMS -------------------------------------------------------------------------- */ .input, +.textarea, +.select { border: 1px solid var(--theme-input-border) !important; background-color: var(--theme-input-bg, color-mix(in srgb, var(--color-base-200) 78%, var(--color-base-100) 22%)) !important; color: var(--color-base-content) !important; box-shadow: var(--theme-input-inset-shadow) !important; +} .input:focus, +.textarea:focus, +.select:focus { outline: 2px solid var(--theme-input-highlight) !important; outline-offset: 2px !important; border-color: var(--theme-input-highlight-strong) !important; box-shadow: 0 0 0 1px var(--theme-input-highlight-shadow) !important; +} .input-bordered:focus, +.input-bordered:focus-within { outline: 2px solid var(--theme-input-highlight) !important; border-color: var(--theme-input-highlight-strong) !important; +} .checkbox:focus, +.radio:focus, +.toggle:focus { outline: 2px solid color-mix(in srgb, var(--color-primary) 50%, transparent) !important; outline-offset: 2px !important; +} .input:hover, +.textarea:hover, +.select:hover { border-color: color-mix(in srgb, var(--color-base-300) 55%, white 45%) !important; +} html[data-theme="light"] .input:hover, +html[data-theme="light"] .textarea:hover, +html[data-theme="light"] .select:hover { border-color: color-mix(in srgb, var(--color-base-300) 52%, var(--color-primary) 48%) !important; +} html[data-theme="light"] :is(.checkbox, .radio, .toggle):not(:checked) { border-color: color-mix(in srgb, var(--color-base-300) 68%, var(--color-base-content) 32%); +} .input::placeholder, +.textarea::placeholder { color: color-mix(in srgb, var(--color-base-content) 48%, transparent 52%) !important; +} .select option { background-color: var(--color-base-200); color: var(--color-base-content); +} /* Icon visibility fix */ +.input:focus ~ .icon, +.input:focus-within ~ .icon, +.relative .input:focus ~ [class*="icon"], +.relative .input:focus ~ svg { opacity: 1 !important; visibility: visible !important; display: flex !important; +} .input { position: relative; z-index: 1; +} /* -------------------------------------------------------------------------- SURFACES -------------------------------------------------------------------------- */ .card, +.panel-card, +.social-page-card { border: 1px solid transparent; border-color: var(--surface-panel-border); background-color: var(--color-base-200) !important; background-image: none !important; box-shadow: var(--surface-panel-shadow); +} /* -------------------------------------------------------------------------- CARDS -------------------------------------------------------------------------- */ .card { transition: background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; +} .panel-card { border-color: var(--surface-panel-border); background-color: var(--surface-panel-bg-fallback) !important; position: relative; +} .panel-card.sticky { contain: none; transform: none; +} #timeline-session-continuity { --timeline-sidebar-gap: var(--app-sticky-sidebar-gap, 1.5rem); +} .timeline-sidebar-scroll { contain: none; transform: none; +} .timeline-sidebar-scroll { max-height: calc(100vh - var(--timeline-sidebar-gap)); overflow-y: auto; scrollbar-gutter: stable; +} .panel-card > .card-body { position: relative; +} .card-interactive:focus-visible, +[data-post-id]:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--color-primary); +} .surface-subtle { border: 1px solid var(--surface-panel-border); background-color: var(--color-base-200, #151b22) !important; background-color: color-mix(in srgb, var(--surface-panel-bg-fallback) 78%, var(--color-base-100) 22%) !important; background-image: none !important; box-shadow: none; +} .surface-muted { border: 1px solid var(--surface-panel-border); background-color: color-mix(in srgb, var(--color-base-200) 84%, transparent); box-shadow: none; +} .surface-soft { border: 1px solid var(--surface-panel-border); background-color: color-mix(in srgb, var(--color-base-100) 65%, var(--surface-panel-bg-fallback) 35%); box-shadow: none; +} .surface-interactive { border: 1px solid var(--surface-panel-border); background-color: var(--surface-panel-bg-fallback) !important; box-shadow: var(--surface-panel-shadow); transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease; +} .surface-interactive:hover, +.surface-interactive:focus-visible { background-color: color-mix(in srgb, var(--surface-panel-bg-fallback) 82%, var(--color-base-300) 18%) !important; border-color: color-mix(in srgb, var(--color-base-content) 15%, var(--surface-panel-border) 85%); +} /* -------------------------------------------------------------------------- ALERTS & FLASH MESSAGES -------------------------------------------------------------------------- */ #flash-group { perspective: 1000px; +} .alert.app-flash { position: relative; align-items: flex-start; gap: 0.75rem; transform-origin: top right; animation: flash-slide-in 0.2s ease-out; +} .app-flash__dismiss { margin-left: auto; align-self: flex-start; +} .alert.app-flash.is-dismissing { animation: app-flash-exit var(--flash-exit, 260ms) cubic-bezier(0.35, 0.05, 0.68, 0.17) forwards; pointer-events: none; +} @keyframes app-flash-exit { from { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } to { opacity: 0; transform: translate3d(24px, -4px, 0) scale(0.95); } +} .flash-message { position: fixed !important; right: 1rem !important; width: 20rem !important; max-width: calc(100vw - 2rem) !important; z-index: 9999 !important; pointer-events: auto !important; animation: flash-slide-in 0.3s ease-out; +} @keyframes flash-slide-in { from { opacity: 0; transform: translateX(1rem); } to { opacity: 1; transform: translateX(0); } +} .alert { border: 1px solid color-mix(in srgb, var(--color-base-300) 68%, white 32%); background-color: color-mix(in srgb, var(--color-base-200) 84%, var(--color-base-100) 16%); color: var(--color-base-content); box-shadow: none; flex-wrap: wrap; min-width: 0; overflow-wrap: anywhere; +} .alert > * { min-width: 0; +} .app-flash--info, +.app-flash--success, +.app-flash--loading { --flash-accent: color-mix(in srgb, var(--color-success) 90%, var(--color-base-content) 10%); --flash-accent-soft: color-mix(in srgb, var(--color-success) 24%, var(--color-base-200) 76%); --flash-tint-start: color-mix(in srgb, var(--color-success) 16%, var(--color-base-200) 84%); --flash-tint-mid: color-mix(in srgb, var(--color-success) 6%, var(--color-base-200) 94%); --flash-border: color-mix(in srgb, var(--color-success) 42%, var(--color-base-300) 58%); --flash-icon: color-mix(in srgb, var(--color-success) 96%, var(--color-base-content) 4%); +} .app-flash--warning { --flash-accent: color-mix(in srgb, var(--color-warning) 90%, var(--color-base-content) 10%); --flash-accent-soft: color-mix(in srgb, var(--color-warning) 24%, var(--color-base-200) 76%); --flash-tint-start: color-mix(in srgb, var(--color-warning) 16%, var(--color-base-200) 84%); --flash-tint-mid: color-mix(in srgb, var(--color-warning) 6%, var(--color-base-200) 94%); --flash-border: color-mix(in srgb, var(--color-warning) 42%, var(--color-base-300) 58%); --flash-icon: color-mix(in srgb, var(--color-warning) 72%, var(--color-base-content) 28%); +} .app-flash--error { --flash-accent: color-mix(in srgb, var(--color-error) 92%, var(--color-base-content) 8%); --flash-accent-soft: color-mix(in srgb, var(--color-error) 25%, var(--color-base-200) 75%); --flash-tint-start: color-mix(in srgb, var(--color-error) 20%, var(--color-base-200) 80%); --flash-tint-mid: color-mix(in srgb, var(--color-error) 8%, var(--color-base-200) 92%); --flash-border: color-mix(in srgb, var(--color-error) 48%, var(--color-base-300) 52%); --flash-icon: color-mix(in srgb, var(--color-error) 72%, var(--color-error-content) 28%); +} .alert.app-flash { border-color: var(--flash-border); background-color: color-mix(in srgb, var(--flash-tint-start) 72%, var(--color-base-200) 28%); color: color-mix(in srgb, var(--flash-accent) 38%, var(--color-base-content) 62%); +} .alert.app-flash > svg, +.alert.app-flash .flash-message__icon { color: var(--flash-icon); +} .alert-info { border-color: color-mix(in srgb, var(--color-info) 22%, var(--color-base-300) 78%); background-color: color-mix(in srgb, var(--color-info) 10%, var(--color-base-200) 90%); color: color-mix(in srgb, var(--color-info) 36%, var(--color-base-content) 64%); +} .alert-success { border-color: color-mix(in srgb, var(--color-success) 24%, var(--color-base-300) 76%); background-color: color-mix(in srgb, var(--color-success) 10%, var(--color-base-200) 90%); color: color-mix(in srgb, var(--color-success) 34%, var(--color-base-content) 66%); +} .alert-warning { border-color: color-mix(in srgb, var(--color-warning) 26%, var(--color-base-300) 74%); background-color: color-mix(in srgb, var(--color-warning) 11%, var(--color-base-200) 89%); color: color-mix(in srgb, var(--color-warning) 38%, var(--color-base-content) 62%); +} .alert-error { border-color: color-mix(in srgb, var(--color-error) 24%, var(--color-base-300) 76%); background-color: color-mix(in srgb, var(--color-error) 10%, var(--color-base-200) 90%); color: color-mix(in srgb, var(--color-error) 36%, var(--color-base-content) 64%); +} .system-announcement { --announcement-accent: var(--color-info); border: 1px solid var(--surface-panel-border); border-left: 4px solid var(--announcement-accent); background-color: var(--surface-panel-bg-fallback) !important; box-shadow: var(--surface-panel-shadow); color: var(--color-base-content); +} .system-announcement__icon { background-color: color-mix(in srgb, var(--announcement-accent) 14%, var(--color-base-200) 86%); color: var(--announcement-accent); +} .system-announcement--info { --announcement-accent: var(--color-info); +} .system-announcement--warning { --announcement-accent: var(--color-warning); +} /* -------------------------------------------------------------------------- SHORTCUT OVERLAYS -------------------------------------------------------------------------- */ .shortcut-overlay { background-color: var(--theme-modal-backdrop); color: var(--color-base-content); +} .shortcut-dialog { border: 1px solid var(--surface-modal-border); background-color: var(--surface-modal-bg-fallback) !important; background-image: none !important; color: var(--color-base-content); box-shadow: var(--surface-modal-shadow); +} .shortcut-floating { border: 1px solid var(--surface-floating-border); background-color: var(--surface-floating-bg-fallback) !important; background-image: none !important; color: var(--color-base-content); box-shadow: var(--surface-floating-shadow); +} .shortcut-heading { color: color-mix(in srgb, var(--color-primary) 72%, var(--color-base-content) 28%); +} .shortcut-muted { color: color-mix(in srgb, var(--color-base-content) 62%, transparent); +} .shortcut-panel { border: 1px solid var(--surface-panel-border); background-color: color-mix(in srgb, var(--surface-panel-bg-fallback) 74%, var(--color-base-100) 26%) !important; color: var(--color-base-content); +} .shortcut-divider { border-color: var(--surface-panel-border); +} .shortcut-dialog .kbd, +.shortcut-panel .kbd, +.shortcut-floating .kbd { border-color: color-mix(in srgb, var(--color-primary) 42%, var(--surface-panel-border) 58%); background-color: color-mix(in srgb, var(--color-primary) 14%, var(--color-base-200) 86%); color: color-mix(in srgb, var(--color-primary) 64%, var(--color-base-content) 36%); box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--color-primary) 18%, transparent); +} .system-announcement--maintenance { --announcement-accent: color-mix(in srgb, var(--color-base-content) 55%, var(--color-base-300) 45%); +} .system-announcement--feature { --announcement-accent: var(--color-success); +} .system-announcement--urgent { --announcement-accent: var(--color-error); +} /* -------------------------------------------------------------------------- NAVBAR -------------------------------------------------------------------------- */ .navbar { background-color: var(--surface-panel-bg-fallback) !important; background-image: none !important; border-bottom: 1px solid var(--theme-navbar-border) !important; box-shadow: var(--theme-navbar-shadow); +} .app-navbar.navbar { background-color: var(--color-base-200) !important; background-image: none !important; +} .navbar .navbar-start, +.navbar .navbar-center, +.navbar .navbar-end, +.navbar .dropdown { overflow: visible; +} .e-nav .e-nav-link, +.e-nav .e-nav-link:hover, +.e-nav .e-nav-link:focus, +.e-nav .e-nav-link:focus-visible, +.e-nav .e-nav-link:active, +.e-nav .e-nav-link:visited { text-decoration: none !important; box-shadow: none; +} .e-nav .e-nav-link::-moz-focus-inner { border: 0; +} .e-nav .e-nav-scroll { scrollbar-width: none; -ms-overflow-style: none; +} .e-nav .e-nav-scroll::-webkit-scrollbar { display: none; +} .e-nav .e-nav-link { color: var(--theme-nav-link); +} .e-nav .e-nav-link:hover, +.e-nav .e-nav-link:focus-visible { color: var(--color-base-content); +} .navbar .badge-error, +.indicator .badge-error { background-color: var(--theme-error-badge-bg) !important; border-color: var(--theme-error-badge-border) !important; color: var(--theme-text-inverse) !important; +} /* -------------------------------------------------------------------------- DROPDOWNS -------------------------------------------------------------------------- */ .floating-panel, +.floating-menu, +.dropdown-content { border: 1px solid transparent; border-color: var(--surface-floating-border); background-color: var(--surface-floating-bg-fallback) !important; background-image: none !important; box-shadow: var(--surface-floating-shadow); +} .floating-panel { position: relative; isolation: isolate; +} .user-hover-card-panel { background-color: var(--surface-floating-bg-fallback) !important; background-image: none !important; opacity: 1 !important; +} .dropdown-content { z-index: 100; +} .dropdown { position: relative; +} .dropdown .dropdown-content { position: absolute; visibility: hidden; opacity: 0; transform: translateY(-4px) scale(0.98); transition: opacity 0.2s ease, visibility 0.2s ease, transform 0.2s ease; +} .navbar .dropdown-content, +.navbar .menu.dropdown-content { z-index: 200; +} .dropdown:focus-within .dropdown-content, +.dropdown:focus .dropdown-content, +.dropdown-open .dropdown-content { visibility: visible; opacity: 1; transform: translateY(0) scale(1); +} .dropdown[data-portal-dropdown-root]:not([data-portal-dropdown-open="true"]):focus-within .dropdown-content[data-portal-dropdown-menu], +.dropdown[data-portal-dropdown-root]:not([data-portal-dropdown-open="true"]):focus .dropdown-content[data-portal-dropdown-menu] { visibility: hidden !important; opacity: 0 !important; pointer-events: none !important; transform: translateY(-4px) scale(0.98) !important; +} /* Timeline cards need higher stacking when menus are open */ +.timeline-post-card { position: relative; overflow: visible !important; z-index: 1; isolation: isolate; contain: none; transform: none !important; +} .timeline-post-card:hover, +.timeline-post-card:focus-within, +.timeline-post-card:has(.timeline-reaction-dropdown[open]) { z-index: 430 !important; +} .timeline-post-card > .card-body, +.timeline-post-card .timeline-post-card-body { overflow: visible; +} .timeline-post-card--dense { border-color: color-mix(in srgb, var(--surface-panel-border) 84%, var(--color-base-content) 6%) !important; border-radius: 0.5rem !important; background-color: color-mix(in srgb, var(--color-base-200) 88%, var(--color-base-100) 12%) !important; backdrop-filter: none; box-shadow: none; +} .timeline-post-card--dense:hover, +.timeline-post-card--dense:focus-within { background-color: color-mix(in srgb, var(--color-base-200) 78%, var(--color-base-300) 22%) !important; border-color: color-mix(in srgb, var(--surface-panel-border) 72%, var(--color-primary) 18%) !important; +} .timeline-post-card--dense.timeline-post-card--reply { border-left-width: 1px !important; border-left-color: color-mix(in srgb, var(--surface-panel-border) 84%, var(--color-base-content) 6%) !important; +} .timeline-post-card--dense.timeline-post-card--reply:hover, +.timeline-post-card--dense.timeline-post-card--reply:focus-within { border-left-width: 1px !important; border-left-color: color-mix(in srgb, var(--surface-panel-border) 72%, var(--color-primary) 18%) !important; +} .timeline-post-card--dense .timeline-post-dense-content .post-content { font-size: 0.9375rem; line-height: 1.35; +} .timeline-post-card--dense .timeline-post-dense-content h3.post-content { margin-bottom: 0.25rem !important; font-size: 1rem; line-height: 1.3; +} .timeline-post-card--dense .timeline-post-dense-content :where(.mb-3) { margin-bottom: 0.5rem !important; +} .timeline-post-card--dense .timeline-post-dense-content :where(.mt-3) { margin-top: 0.5rem !important; +} .timeline-post-card--dense .timeline-post-dense-content :where(.p-3) { padding: 0.625rem !important; +} .timeline-post-card--dense .timeline-post-dense-footer > div { justify-content: flex-start; border-top: 0; padding-top: 0.125rem; +} .timeline-post-card--dense .timeline-post-dense-footer .btn { min-height: 1.75rem; height: 1.75rem; +} .timeline-post-dropdown { position: relative; z-index: 90; overflow: visible; +} .timeline-post-dropdown .dropdown-content, +.timeline-post-card .timeline-post-dropdown-menu { z-index: 260; position: absolute; +} .timeline-post-dropdown-menu { width: min(18rem, calc(100vw - 1rem)); max-width: calc(100vw - 1rem); overflow: hidden; +} .timeline-post-dropdown-menu :where(li > a, li > button, li > details > summary) { min-width: 0; white-space: normal; overflow-wrap: anywhere; word-break: break-word; line-height: 1.25; +} .timeline-post-dropdown-menu :where(svg, .ui-icon) { flex-shrink: 0; +} .timeline-reaction-dropdown { position: relative; z-index: 90; overflow: visible; +} .timeline-reaction-dropdown[open] { z-index: 430 !important; +} .timeline-reaction-dropdown .dropdown-content { width: max-content; max-width: calc(100vw - 1rem); overflow: visible; +} .timeline-reaction-dropdown[open] .dropdown-content { z-index: 430 !important; +} .thread-context-card[role="button"] { cursor: pointer; +} .thread-context-card[role="button"]:focus-visible { outline: 2px solid color-mix(in srgb, var(--color-primary) 55%, transparent); outline-offset: -2px; +} +.timeline-thread-current-card { box-shadow: 0 10px 28px color-mix(in srgb, var(--color-base-content) 6%, transparent); +} .timeline-inline-reply-target { position: relative; +} .timeline-inline-reply-target__card { display: block; +} .timeline-thread-replies { position: relative; margin-left: 0.65rem; padding-left: 2.05rem; overflow: visible; +} .timeline-thread-replies__rail { display: block; pointer-events: none; position: absolute; left: 0.78rem; top: -0.65rem; bottom: 0.65rem; width: 2px; border-radius: 9999px; background: linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 34%, var(--surface-panel-border) 66%), color-mix(in srgb, var(--color-base-content) 12%, transparent)); box-shadow: none; z-index: 0; +} .timeline-thread-replies--stub::before { bottom: 0; +} .timeline-thread-reply-row { position: relative; overflow: visible; z-index: 1; +} .timeline-thread-reply-node { display: block; pointer-events: none; position: absolute; left: -1.34rem; top: 0.95rem; width: 10px; height: 10px; border-radius: 9999px; background: color-mix(in srgb, var(--color-base-200) 84%, var(--color-base-100) 16%); border: 2px solid color-mix(in srgb, var(--color-primary) 50%, var(--surface-panel-border) 50%); box-shadow: none; z-index: 3; +} .timeline-thread-reply-elbow { display: block; pointer-events: none; position: absolute; left: -1.08rem; top: 1.22rem; width: 0.88rem; height: 2px; border-radius: 9999px; background: color-mix(in srgb, var(--color-primary) 32%, var(--surface-panel-border) 68%); z-index: 2; +} .timeline-thread-reply-card { position: relative; z-index: 1; border: 1px solid color-mix(in srgb, var(--surface-panel-border) 82%, var(--color-base-content) 6%) !important; border-radius: 0.5rem !important; background: color-mix(in srgb, var(--color-base-200) 86%, var(--color-base-100) 14%) !important; box-shadow: none; +} .timeline-thread-reply-card:hover, +.timeline-thread-reply-card:focus-within { background: color-mix(in srgb, var(--color-base-200) 76%, var(--color-base-300) 24%) !important; border-color: color-mix(in srgb, var(--surface-panel-border) 72%, var(--color-primary) 18%) !important; box-shadow: none; +} .timeline-thread-comment-card { position: relative; border-color: color-mix(in srgb, var(--surface-panel-border) 82%, var(--color-base-content) 6%) !important; border-radius: 0.5rem !important; background: color-mix(in srgb, var(--color-base-200) 86%, var(--color-base-100) 14%) !important; backdrop-filter: none; box-shadow: none; +} .timeline-thread-comment-card:hover, +.timeline-thread-comment-card:focus-within { background: color-mix(in srgb, var(--color-base-200) 76%, var(--color-base-300) 24%) !important; border-color: color-mix(in srgb, var(--surface-panel-border) 72%, var(--color-primary) 18%) !important; box-shadow: none; +} .timeline-thread-inline-form { margin-left: 0.875rem; +} .timeline-thread-tree-node { position: relative; overflow: visible; +} .timeline-thread-tree-node--nested { margin-left: 1.05rem; padding-left: 1.7rem; overflow: visible; z-index: 1; +} .timeline-thread-tree-node--nested::before { content: ""; position: absolute; left: 0.62rem; top: -0.3rem; bottom: -0.3rem; width: 2px; border-radius: 9999px; background: color-mix(in srgb, var(--color-primary) 32%, var(--surface-panel-border) 68%); box-shadow: none; pointer-events: none; z-index: 1; +} .timeline-thread-tree-node--nested::after { content: ""; position: absolute; left: 0.62rem; top: 1.15rem; width: 0.95rem; height: 2px; border-radius: 9999px; background: color-mix(in srgb, var(--color-primary) 32%, var(--surface-panel-border) 68%); pointer-events: none; z-index: 1; +} .timeline-thread-tree-card { border: 1px solid color-mix(in srgb, var(--surface-panel-border) 82%, var(--color-base-content) 6%); background: color-mix(in srgb, var(--color-base-200) 86%, var(--color-base-100) 14%); +} .timeline-thread-reply-row:hover, +.timeline-thread-reply-row:focus-within, +.timeline-thread-reply-card:hover, +.timeline-thread-reply-card:focus-within, +.timeline-thread-tree-node:hover, +.timeline-thread-tree-node:focus-within, +.timeline-thread-tree-node--nested:hover, +.timeline-thread-tree-node--nested:focus-within, +.timeline-thread-tree-card:hover, +.timeline-thread-tree-card:focus-within { z-index: 320; +} .timeline-post-card:has([data-reaction-picker-root][open]), +.timeline-thread-tree-node:has([data-reaction-picker-root][open]), +.timeline-thread-reply-row:has([data-reaction-picker-root][open]), +.timeline-thread-reply-card:has([data-reaction-picker-root][open]), +.timeline-thread-comment-card:has([data-reaction-picker-root][open]), +.timeline-thread-tree-card:has([data-reaction-picker-root][open]), +.timeline-thread-preview-item:has([data-reaction-picker-root][open]), +.thread-context-card:has([data-reaction-picker-root][open]) { z-index: 430 !important; +} .timeline-thread-preview-list { position: relative; margin-top: 0.5rem; padding-left: 1.55rem; +} .timeline-thread-preview-list::before { content: ""; position: absolute; left: 0.62rem; top: 0.35rem; bottom: 0.3rem; width: 2px; border-radius: 9999px; background: linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 22%, var(--surface-panel-border) 78%), color-mix(in srgb, var(--color-base-content) 8%, transparent)); +} .timeline-thread-preview-item { position: relative; background: var(--color-base-100); +} .timeline-thread-preview-item::before { content: ""; position: absolute; left: -0.82rem; top: 0.9rem; width: 0.58rem; height: 2px; border-radius: 9999px; background: color-mix(in srgb, var(--color-primary) 18%, var(--surface-panel-border) 82%); +} .timeline-thread-preview-list--flush { padding-left: 0; +} .timeline-thread-preview-list--flush::before { display: none; +} .timeline-thread-preview-item--flush::before { display: none; +} @media (max-width: 640px) { + .timeline-thread-replies { margin-left: 0.2rem; padding-left: 1.15rem; + } .timeline-thread-replies__rail { left: 0.34rem; top: -0.65rem; width: 2px; + } .timeline-thread-reply-node { left: -0.88rem; top: 0.95rem; width: 8px; height: 8px; + } .timeline-thread-reply-elbow { left: -0.68rem; top: 1.17rem; width: 0.45rem; + } .timeline-thread-inline-form { margin-left: 0.25rem; + } .timeline-thread-tree-node--nested { margin-left: 0.4rem; padding-left: 0.9rem; + } .timeline-thread-tree-node--nested::before { left: 0.34rem; width: 2px; + } .timeline-thread-tree-node--nested::after { left: 0.34rem; top: 1.12rem; width: 0.5rem; height: 2px; + } .timeline-thread-preview-list { padding-left: 1rem; + } .timeline-thread-preview-list::before { left: 0.34rem; + } .timeline-thread-preview-item::before { left: -0.58rem; width: 0.4rem; + } +} /* -------------------------------------------------------------------------- MODALS -------------------------------------------------------------------------- */ .modal, +.modal.modal-open { background: var(--theme-modal-backdrop); +} .modal::backdrop { background: var(--theme-modal-backdrop); +} .modal-backdrop { background: var(--theme-modal-backdrop) !important; +} .modal-surface, +.modal-box { border: 1px solid transparent; border-color: var(--surface-modal-border); background-color: var(--surface-modal-bg-fallback) !important; background-image: none !important; box-shadow: var(--surface-modal-shadow); +} .modal-box { position: relative; isolation: isolate; border-radius: 0.75rem !important; animation: modal-pop 0.2s ease-out; max-width: min(32rem, calc(100dvw - 2rem)); +} .modal-action { flex-wrap: wrap; +} @keyframes modal-pop { from { opacity: 0; transform: scale(0.96) translateY(8px); } to { opacity: 1; transform: scale(1) translateY(0); } +} /* -------------------------------------------------------------------------- ANALYTICS CHARTS -------------------------------------------------------------------------- */ .analytics-chart { color: var(--color-base-content); min-width: 0; +} .analytics-uplot { min-height: 18rem; overflow: visible; width: 100%; +} .analytics-uplot .uplot { background: transparent; color: var(--color-base-content); font-family: inherit; max-width: 100%; +} .analytics-uplot .u-title { display: none; +} .analytics-uplot .u-wrap { background: transparent; +} .analytics-uplot .u-under, .analytics-uplot .u-over { border-radius: 0.75rem; +} .analytics-uplot .u-axis { color: color-mix(in srgb, var(--color-base-content) 62%, transparent); font-size: 11px; +} .analytics-uplot .u-legend { background: color-mix(in srgb, var(--color-base-200) 88%, var(--color-base-100) 12%); border: 1px solid var(--surface-panel-border); border-radius: 0.75rem; color: var(--color-base-content); font-size: 12px; margin: 0 0 0.75rem 0; padding: 0.45rem 0.65rem; +} .analytics-uplot .u-legend th, .analytics-uplot .u-legend td { border: 0 !important; padding: 0.15rem 0.4rem; +} .analytics-uplot .u-legend .u-marker { border-radius: 9999px; +} .analytics-uplot .u-cursor-x, .analytics-uplot .u-cursor-y { border-color: color-mix(in srgb, var(--color-base-content) 32%, transparent) !important; +} .analytics-uplot .u-cursor-pt { box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-base-100) 88%, transparent); +} /* -------------------------------------------------------------------------- MENUS -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- BADGES & TABS -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- AVATARS -------------------------------------------------------------------------- */ .avatar:hover, +.avatar img:hover, +[class*="avatar"]:hover { transform: none !important; +} /* -------------------------------------------------------------------------- LINKS -------------------------------------------------------------------------- */ a:focus-visible { outline: none; border-radius: 0.25rem; box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 50%, transparent); +} .table :where(thead, tbody) :where(tr:not(:last-child)), +.table tr { border-bottom-color: var(--theme-navbar-border); +} .table :where(thead, tbody) :where(tr:hover), +table tr:hover { background-color: var(--theme-table-row-hover); +} /* -------------------------------------------------------------------------- PROSE (Markdown content) -------------------------------------------------------------------------- */ .prose table { display: block; overflow-x: auto; white-space: nowrap; max-width: 100%; +} .prose img { max-width: 100% !important; height: auto !important; display: block !important; margin: 0 auto !important; +} .prose pre { overflow-x: auto; max-width: 100%; +} .prose code { word-wrap: break-word; +} .prose > * { max-width: 100%; overflow-wrap: break-word; word-wrap: break-word; +} .prose table td, +.prose table th { min-width: auto; white-space: normal; +} /* Vertical rhythm — Tailwind preflight zeroes these margins and there is no typography plugin, so without these rules markdown paragraphs render with no gap between them */ .prose p, +.prose ul, +.prose ol, +.prose blockquote, +.prose pre, +.prose table { margin-top: 0.75em; margin-bottom: 0.75em; +} .prose h1, +.prose h2, +.prose h3, +.prose h4, +.prose h5, +.prose h6 { margin-top: 1.25em; margin-bottom: 0.5em; font-weight: 600; line-height: 1.3; +} .prose h1 { font-size: 1.5em; +} .prose h2 { font-size: 1.25em; +} .prose h3 { font-size: 1.125em; +} .prose ul { list-style-type: disc; padding-left: 1.5em; +} .prose ol { list-style-type: decimal; padding-left: 1.5em; +} .prose li { margin-top: 0.25em; margin-bottom: 0.25em; +} .prose blockquote { border-left: 3px solid color-mix(in srgb, var(--color-base-content) 25%, transparent); padding-left: 1em; +} .prose hr { margin-top: 1.5em; margin-bottom: 1.5em; border-color: color-mix(in srgb, var(--color-base-content) 15%, transparent); +} .prose > :first-child { margin-top: 0; +} .prose > :last-child { margin-bottom: 0; +} + +/* daisyUI 5.6 parity: keep chrome text as it looked on 5.5. + 5.6 paints active/focused menu items solid neutral (a dark blue-gray in our + palette) and dims inactive tab text to a hardcoded 50% mix. Menu colors are + themable via the new variables; the tab dim has no variable, so that one is + a rule (ours is unlayered and wins over daisyUI's layered rule). Checked + buttons also stay base-content instead of switching to primary. Disabled + states keep daisyUI's 5.6 styling. */ + +.tabs > .tab:not(.tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"], .tab-disabled, [disabled]) { + color: var(--color-base-content); +} + +.btn:where(:checked) { + color: var(--color-base-content); +} + +/* Success flash on a copy trigger. `JS.transition` adds this for two seconds + and removes it again, so the state lives here rather than in a hook holding + a setTimeout. Its own class rather than daisyUI's `btn-success` because the + triggers are ghost buttons and daisyUI's layered rules would win. */ +.copy-flash, +.copy-flash:hover { + background-color: var(--color-success); + color: var(--color-success-content); +} diff --git a/apps/elektrine/assets/css/effects.css b/apps/elektrine/assets/css/effects.css new file mode 100644 index 0000000..a589e7a --- /dev/null +++ b/apps/elektrine/assets/css/effects.css @@ -0,0 +1,444 @@ +/* ========================================================================== + EFFECTS - Username, Avatar, Link, and Visual Effects + ========================================================================== */ + +/* -------------------------------------------------------------------------- + USERNAME EFFECTS + -------------------------------------------------------------------------- */ + +.username-effect-glow { + text-shadow: + 0 0 2px color-mix(in srgb, var(--theme-effect-outline-stroke) 70%, transparent), + 0 0 5px color-mix(in srgb, var(--theme-effect-outline-stroke) 50%, transparent), + 0 0 10px color-mix(in srgb, var(--theme-effect-outline-stroke) 30%, transparent); + padding: 0 4px; + margin: 0 -4px; +} + +.username-effect-neon { + color: var(--theme-effect-outline-stroke); + text-shadow: + 0 0 5px var(--theme-effect-outline-stroke), + 0 0 10px var(--theme-effect-outline-stroke), + 0 0 20px var(--theme-effect-outline-stroke), + 0 0 40px var(--theme-effect-outline-stroke); + animation: neon-pulse var(--animation-duration, 2s) ease-in-out infinite; +} + +@keyframes neon-pulse { + 0%, 100% { + opacity: 1; + text-shadow: + 0 0 5px var(--theme-effect-outline-stroke), + 0 0 10px var(--theme-effect-outline-stroke), + 0 0 20px var(--theme-effect-outline-stroke), + 0 0 40px var(--theme-effect-outline-stroke); + } + 50% { + opacity: 0.8; + text-shadow: + 0 0 3px var(--theme-effect-outline-stroke), + 0 0 7px var(--theme-effect-outline-stroke), + 0 0 15px var(--theme-effect-outline-stroke), + 0 0 30px var(--theme-effect-outline-stroke); + } +} + +.username-effect-rainbow { + background: var(--theme-effect-rainbow); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + background-size: 200% 100%; + animation: rainbow-slide var(--animation-duration, 3s) linear infinite; +} + +@keyframes rainbow-slide { + 0% { background-position: 0% 50%; } + 100% { background-position: 200% 50%; } +} + +.username-effect-shadow { + text-shadow: 3px 3px 6px var(--theme-effect-shadow-color); +} + +.username-effect-gradient { + background: linear-gradient(90deg, var(--gradient-from, var(--color-primary)), var(--gradient-to, var(--color-secondary))); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.username-effect-glitch { + position: relative; + animation: glitch var(--animation-duration, 1s) infinite; +} + +@keyframes glitch { + 0% { text-shadow: 0.05em 0 0 var(--theme-effect-glitch-cyan), -0.03em -0.04em 0 var(--theme-effect-glitch-magenta), 0.025em 0.04em 0 var(--theme-effect-glitch-yellow); } + 15% { text-shadow: 0.05em 0 0 var(--theme-effect-glitch-cyan), -0.03em -0.04em 0 var(--theme-effect-glitch-magenta), 0.025em 0.04em 0 var(--theme-effect-glitch-yellow); } + 16% { text-shadow: -0.05em -0.025em 0 var(--theme-effect-glitch-cyan), 0.025em 0.035em 0 var(--theme-effect-glitch-magenta), -0.05em -0.05em 0 var(--theme-effect-glitch-yellow); } + 49% { text-shadow: -0.05em -0.025em 0 var(--theme-effect-glitch-cyan), 0.025em 0.035em 0 var(--theme-effect-glitch-magenta), -0.05em -0.05em 0 var(--theme-effect-glitch-yellow); } + 50% { text-shadow: 0.05em 0.035em 0 var(--theme-effect-glitch-cyan), 0.03em 0 0 var(--theme-effect-glitch-magenta), 0 -0.04em 0 var(--theme-effect-glitch-yellow); } + 99% { text-shadow: 0.05em 0.035em 0 var(--theme-effect-glitch-cyan), 0.03em 0 0 var(--theme-effect-glitch-magenta), 0 -0.04em 0 var(--theme-effect-glitch-yellow); } + 100% { text-shadow: -0.05em 0 0 var(--theme-effect-glitch-cyan), -0.025em -0.04em 0 var(--theme-effect-glitch-magenta), -0.04em -0.025em 0 var(--theme-effect-glitch-yellow); } +} + +.username-effect-fire { + background: var(--theme-effect-fire); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + background-size: 200% 100%; + animation: fire-flicker var(--animation-duration, 2s) ease-in-out infinite; + filter: var(--theme-effect-fire-shadow); +} + +.username-effect-ice { + color: var(--theme-effect-ice-color); + text-shadow: var(--theme-effect-ice-shadow); + animation: ice-shimmer var(--animation-duration, 3s) ease-in-out infinite; +} + +@keyframes ice-shimmer { + 0%, 100% { filter: brightness(1); } + 50% { filter: brightness(1.3); } +} + +.username-effect-chrome { + background: var(--theme-effect-chrome); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + background-size: 200% 100%; + filter: var(--theme-effect-chrome-shadow); +} + +.username-effect-holographic { + background: var(--theme-effect-holographic); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + background-size: 300% 100%; + animation: holographic-shift var(--animation-duration, 3s) linear infinite; + filter: brightness(1.2); +} + +@keyframes holographic-shift { + 0% { background-position: 0% 50%; } + 100% { background-position: 300% 50%; } +} + +.username-effect-outline { + color: transparent; + -webkit-text-stroke: 2px var(--theme-effect-outline-stroke); + text-stroke: 2px var(--theme-effect-outline-stroke); +} + +.username-effect-double { + position: relative; +} + +.username-effect-double::after { + content: attr(data-text); + position: absolute; + left: 3px; + top: 3px; + z-index: -1; + color: var(--theme-effect-shadow-color); + opacity: 0.5; +} + +.username-effect-retro { + background: var(--theme-effect-retro); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + filter: var(--theme-effect-retro-shadow); + font-weight: bold; + letter-spacing: 2px; +} + +.username-effect-pixelated { + image-rendering: pixelated; + -ms-interpolation-mode: nearest-neighbor; + font-weight: 700; + text-shadow: + 1px 1px 0 var(--theme-effect-outline-stroke), + 2px 2px 0 var(--theme-effect-shadow-color); +} + +/* Animation speed modifiers */ +.username-animation-slow { --animation-duration: 4s; } +.username-animation-normal { --animation-duration: 2s; } +.username-animation-fast { --animation-duration: 1s; } + +/* Typewriter */ +.typewriter-cursor::after { + content: '|'; + animation: blink 1s infinite; + margin-left: 2px; +} + +/* -------------------------------------------------------------------------- + AVATAR EFFECTS + -------------------------------------------------------------------------- */ + +.avatar-effect-wrapper { + position: relative; + display: inline-block; +} + +.avatar-effect-glow { + animation: avatar-glow-pulse 2s ease-in-out infinite; +} + +@keyframes avatar-glow-pulse { + 0%, 100% { + filter: drop-shadow(0 0 8px var(--theme-avatar-accent-color)) drop-shadow(0 0 12px var(--theme-avatar-accent-color)); + } + 50% { + filter: drop-shadow(0 0 16px var(--theme-avatar-accent-color)) drop-shadow(0 0 24px var(--theme-avatar-accent-color)); + } +} + +.avatar-effect-rainbow::before { + content: ''; + position: absolute; + inset: -4px; + border-radius: inherit; + background: var(--theme-effect-rainbow); + background-size: 400% 400%; + animation: rainbow-rotate 3s linear infinite; + z-index: -1; +} + +@keyframes rainbow-rotate { + 0% { background-position: 0% 50%; } + 100% { background-position: 400% 50%; } +} + +.avatar-effect-fire::before { + content: ''; + position: absolute; + inset: -4px; + border-radius: inherit; + background: var(--theme-effect-fire); + background-size: 300% 300%; + animation: fire-flicker 2s ease-in-out infinite; + z-index: -1; + filter: blur(2px); +} + +@keyframes fire-flicker { + 0%, 100% { opacity: 0.8; background-position: 0% 50%; } + 25% { opacity: 1; background-position: 50% 50%; } + 50% { opacity: 0.7; background-position: 100% 50%; } + 75% { opacity: 0.9; background-position: 50% 50%; } +} + +.avatar-effect-ice { + animation: avatar-ice-shimmer 3s ease-in-out infinite; +} + +@keyframes avatar-ice-shimmer { + 0%, 100% { + filter: drop-shadow(0 0 8px var(--theme-effect-ice-color)) drop-shadow(0 0 12px var(--theme-effect-ice-highlight)) brightness(1); + } + 50% { + filter: drop-shadow(0 0 16px var(--theme-effect-ice-color)) drop-shadow(0 0 24px var(--theme-effect-ice-highlight)) brightness(1.2); + } +} + +.avatar-effect-sparkle { + position: relative; +} + +.avatar-effect-sparkle::after { + content: ''; + position: absolute; + font-size: 20px; + animation: sparkle-float 3s ease-in-out infinite; + pointer-events: none; +} + +@keyframes sparkle-float { + 0%, 100% { top: -10px; right: -10px; opacity: 0; } + 50% { top: -20px; right: 0px; opacity: 1; } +} + +.avatar-effect-holographic::before { + content: ''; + position: absolute; + inset: -4px; + border-radius: inherit; + background: var(--theme-effect-holographic); + background-size: 400% 400%; + animation: holographic-shift 3s linear infinite; + z-index: -1; + opacity: 0.8; +} + +.avatar-effect-gold-frame { + position: relative; +} + +.avatar-effect-gold-frame::before { + content: ''; + position: absolute; + inset: -6px; + border-radius: inherit; + background: var(--theme-effect-gold-frame); + z-index: -1; + box-shadow: 0 0 20px var(--theme-effect-gold-frame-shadow), inset 0 0 20px var(--theme-effect-gold-inner-glow); +} + +.avatar-effect-gold-frame::after { + content: ''; + position: absolute; + inset: -3px; + border-radius: inherit; + background: var(--theme-effect-gold-surface); + z-index: -1; + box-shadow: 0 0 30px var(--theme-effect-gold-outer-glow), 0 0 40px var(--theme-effect-gold-outer-accent), inset 0 2px 4px var(--theme-effect-gold-top-highlight), inset 0 -2px 4px var(--theme-effect-gold-bottom-shadow); + animation: imperial-shimmer 3s ease-in-out infinite; +} + +@keyframes imperial-shimmer { + 0%, 100% { filter: brightness(1) saturate(1); } + 50% { filter: brightness(1.2) saturate(1.3); } +} + +.avatar-effect-pulse { + animation: avatar-pulse 2s ease-in-out infinite; +} + +@keyframes avatar-pulse { + 0%, 100% { + transform: scale(1); + box-shadow: 0 0 0 0 color-mix(in srgb, var(--theme-avatar-accent-color) 70%, transparent); + } + 50% { + transform: scale(1.05); + box-shadow: 0 0 0 10px color-mix(in srgb, var(--theme-avatar-accent-color) 0%, transparent); + } +} + +.avatar-effect-rotate-border::before { + content: ''; + position: absolute; + inset: -4px; + border-radius: inherit; + background: conic-gradient(from 0deg, var(--theme-avatar-accent-color), var(--theme-avatar-accent-light-color), var(--theme-avatar-accent-color)); + animation: rotate-border 3s linear infinite; + z-index: -1; +} + +@keyframes rotate-border { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* -------------------------------------------------------------------------- + IMAGE ZOOM TRIGGER + -------------------------------------------------------------------------- */ + +.image-zoom-trigger { + position: relative; + cursor: zoom-in; + display: block; +} + +.image-zoom-trigger::after { + content: ''; + position: absolute; + inset: 0; + background: var(--theme-image-zoom-overlay); + opacity: 0; + transition: opacity 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + border-radius: inherit; +} + +.image-zoom-trigger::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 40px; + height: 40px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='2' stroke='white'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607ZM10.5 7.5v6m3-3h-6'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 1; + pointer-events: none; +} + +.image-zoom-trigger:hover::after, +.image-zoom-trigger:hover::before { + opacity: 1; +} + +.image-zoom-trigger img { + transition: opacity 0.2s ease; +} + +/* Variant for rounded images (avatars) */ +.image-zoom-trigger-rounded::after { + border-radius: 9999px; +} + +/* -------------------------------------------------------------------------- + LINK EFFECTS + -------------------------------------------------------------------------- */ + +.link-effect-glow { + box-shadow: 0 0 15px var(--theme-link-accent-color), 0 0 30px var(--theme-link-accent-color); + animation: link-glow-pulse 2s ease-in-out infinite; +} + +@keyframes link-glow-pulse { + 0%, 100% { box-shadow: 0 0 15px var(--theme-link-accent-color), 0 0 25px var(--theme-link-accent-color); } + 50% { box-shadow: 0 0 20px var(--theme-link-accent-color), 0 0 35px var(--theme-link-accent-color); } +} + +.link-effect-pulse { + animation: link-pulse-scale 2s ease-in-out infinite; +} + +@keyframes link-pulse-scale { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); } +} + +.link-effect-border { + border-width: 3px !important; + border-color: var(--theme-link-accent-color) !important; + box-shadow: 0 0 0 3px var(--theme-link-border-shadow); +} + +.link-effect-shine { + position: relative; + overflow: hidden; +} + +.link-effect-shine::after { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: var(--theme-link-shine); + animation: shine-sweep 3s ease-in-out infinite; +} + +@keyframes shine-sweep { + 0% { transform: translateX(-100%) translateY(-100%) rotate(45deg); } + 100% { transform: translateX(100%) translateY(100%) rotate(45deg); } +} diff --git a/apps/elektrine/assets/css/email.css b/apps/elektrine/assets/css/email.css new file mode 100644 index 0000000..2612da9 --- /dev/null +++ b/apps/elektrine/assets/css/email.css @@ -0,0 +1,245 @@ +/* ========================================================================== + EMAIL - Email content styling + ========================================================================== */ + +/* -------------------------------------------------------------------------- + EMAIL SIDEBAR + -------------------------------------------------------------------------- */ + +.email-sidebar-layout { + --email-sidebar-gap: var(--app-sticky-sidebar-gap, 1.5rem); +} + +.email-sidebar-scroll { + contain: none; + transform: none; +} + +@media (min-width: 1024px) { + .email-sidebar-scroll { + max-height: calc(100vh - var(--email-sidebar-gap, 1.5rem)); + overflow-y: auto; + scrollbar-gutter: stable; + } +} + +/* -------------------------------------------------------------------------- + EMAIL IFRAME + -------------------------------------------------------------------------- */ + +#email-iframe { + overflow-x: auto !important; + overflow-y: auto !important; + border: 0 !important; + width: 100% !important; + max-width: 100% !important; + box-sizing: border-box !important; +} + +.email-content-iframe { + display: block; + width: 100%; + border: 0; + background: var(--theme-email-wrapper-bg); +} + +/* -------------------------------------------------------------------------- + EMAIL WRAPPER (Alternative styling) + -------------------------------------------------------------------------- */ + +.email-content { + font-family: 'Geist', ui-sans-serif, system-ui, -apple-system, sans-serif; + font-size: 14px; + line-height: 1.5; + color: var(--theme-email-wrapper-text); + word-wrap: break-word; + max-width: 100%; +} + +.email-content img { + max-width: 100% !important; + height: auto !important; + display: block; + margin: 0.5rem 0; +} + +.email-content table { + max-width: 100%; + border-collapse: collapse; +} + +.email-content table td, +.email-content table th { + word-wrap: break-word; + overflow-wrap: break-word; +} + +.email-content a { + color: var(--theme-link-color) !important; + text-decoration: underline; + padding: 2px 4px; + min-height: 32px; + display: inline-block; +} + +.email-content a:hover { + color: var(--theme-link-color-hover) !important; +} + +/* -------------------------------------------------------------------------- + EMAIL CARDS & INTERFACE + -------------------------------------------------------------------------- */ + +.message-card { + transition-property: border-color, box-shadow; + transition-duration: 140ms; + transition-timing-function: ease; +} + +.message-card:hover, +.message-card:focus-within { + border-color: color-mix(in srgb, var(--color-primary) 45%, transparent); + box-shadow: var(--theme-surface-interactive-shadow); +} + +.message-card .message-sender, +.message-card .message-subject, +.message-card .message-preview, +.message-card .message-attachment-indicator, +.message-card .message-unread-dot, +.message-card .message-timestamp, +.message-card .message-checkbox, +.message-card .message-quick-actions { + transition: color 140ms ease, opacity 140ms ease, transform 140ms ease, box-shadow 140ms ease; +} + +.message-card:hover .message-sender, +.message-card:focus-within .message-sender, +.message-card:hover .message-subject, +.message-card:focus-within .message-subject { + color: color-mix(in srgb, var(--color-base-content) 95%, transparent); +} + +.message-card:hover .message-preview, +.message-card:focus-within .message-preview, +.message-card:hover .message-attachment-indicator, +.message-card:focus-within .message-attachment-indicator, +.message-card:hover .message-timestamp, +.message-card:focus-within .message-timestamp { + color: color-mix(in srgb, var(--color-base-content) 72%, transparent); +} + +.message-card:hover .message-checkbox, +.message-card:focus-within .message-checkbox { + transform: scale(1.03); +} + +.message-card:hover .message-unread-dot, +.message-card:focus-within .message-unread-dot { + transform: scale(1.15); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-secondary) 16%, transparent); +} + +@media (min-width: 640px) { + .message-card .message-quick-actions { + opacity: 0.78; + transform: translateY(2px); + } + + .message-card:hover .message-quick-actions, + .message-card:focus-within .message-quick-actions { + opacity: 1; + transform: translateY(0); + } +} + +.email-card { + transition: all 0.2s ease; + border: 1px solid transparent; +} + +.email-card:hover { + transform: translateY(-1px); + box-shadow: var(--theme-surface-hover-shadow); + border-color: color-mix(in srgb, var(--color-primary) 35%, transparent); +} + +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +@media (prefers-reduced-motion: reduce) { + .message-card, + .message-card .message-sender, + .message-card .message-subject, + .message-card .message-preview, + .message-card .message-attachment-indicator, + .message-card .message-unread-dot, + .message-card .message-timestamp, + .message-card .message-checkbox, + .message-card .message-quick-actions { + transition: none !important; + transform: none !important; + } +} + +/* -------------------------------------------------------------------------- + EMAIL RESPONSIVE + -------------------------------------------------------------------------- */ + +@media (max-width: 768px) { + .email-content { + font-size: 16px; + } + + .email-content table { + font-size: 14px; + } + + .email-content * { + max-width: 100% !important; + box-sizing: border-box !important; + } + + .email-content table { + width: 100% !important; + min-width: 0 !important; + display: block !important; + overflow-x: auto !important; + } + + .email-content tbody, + .email-content thead, + .email-content tr { + display: block !important; + width: 100% !important; + } + + .email-content td, + .email-content th { + display: block !important; + width: 100% !important; + white-space: normal !important; + word-wrap: break-word !important; + overflow-wrap: break-word !important; + } + + .email-content img { + width: 100% !important; + max-width: 100% !important; + height: auto !important; + } + + .email-content div[width], + .email-content div[style*="width"] { + width: 100% !important; + max-width: 100% !important; + } + + .email-content * { + min-width: 0 !important; + } +} diff --git a/apps/elektrine/assets/css/utilities.css b/apps/elektrine/assets/css/utilities.css new file mode 100644 index 0000000..19fa9cb --- /dev/null +++ b/apps/elektrine/assets/css/utilities.css @@ -0,0 +1,541 @@ +/* ========================================================================== + UTILITIES - Helpers, Touch Targets, Typography, Chat, Misc + ========================================================================== */ + +/* -------------------------------------------------------------------------- + AUTO-SIZING TEXTAREAS + -------------------------------------------------------------------------- */ + +/* Grow a textarea with its content. The element's own `min-height`/`max-height` + still bound it, so each composer keeps the size it declares. + + This replaces the height maths the chat hooks used to run on every keystroke + (measure scrollHeight against a min/max parsed out of the style attribute, + write height back in pixels). The browser does it here, which also means the + height is right again after LiveView resets the value — the hooks had to + re-apply it by hand on submit, on `clear_message_input`, and on `updated`. + + Where `field-sizing` is unsupported the textarea falls back to its `rows` and + `min-height` and scrolls, which is what it did before any of this ran. */ +.textarea-autosize { + field-sizing: content; +} + +/* -------------------------------------------------------------------------- + STICKY SIDEBARS + -------------------------------------------------------------------------- */ + +:root { + --app-sticky-sidebar-gap: 1.5rem; +} + +.app-sticky-sidebar { + position: sticky !important; + top: var(--app-sticky-sidebar-gap, 1.5rem); + align-self: flex-start; + contain: none; + transform: none; +} + +.app-sticky-sidebar .panel-card { + contain: none; + transform: none; +} + +/* A rail taller than the viewport would otherwise be unreachable — sticky pins + its top, so the overflow has nowhere to go. + + No `scrollbar-gutter: stable` here: it reserves the gutter whether or not a + scrollbar is showing, which set this rail ~15px further from its column than + the same rail on Timeline and Communities. Those pages don't scroll their + sidebar at all, so the gutter was a visible difference between feeds that are + meant to look alike. A rail that does overflow gets a scrollbar the ordinary + way, and the shift that costs is the rarer case. */ +.app-sticky-sidebar-scroll { + max-height: calc(100vh - var(--app-sticky-sidebar-gap, 1.5rem)); + overflow-y: auto; + scrollbar-width: thin; +} + +/* -------------------------------------------------------------------------- + SOCIAL PAGES + -------------------------------------------------------------------------- */ + +.social-page-wrap { + max-width: 80rem; + margin-inline: auto; + padding-inline: 1rem; + padding-bottom: 0.75rem; +} + +@media (min-width: 640px) { + .social-page-wrap { + padding-inline: 1.5rem; + } +} + +@media (min-width: 1024px) { + .social-page-wrap { + padding-inline: 2rem; + } +} + +.social-page-rail { + width: 100%; + max-width: 48rem; + margin-inline: auto; +} + +.social-page-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.remote-user-sticky-follow { + isolation: isolate; + position: sticky; + top: 1.5rem; + width: 100%; + z-index: 900; +} + +#remote-profile-infinite-scroll-person { + position: relative; + z-index: 0; +} + +.social-page-wrap, +.social-page-rail, +.card, +.card-body, +.panel-card, +.modal-box, +.modal-action, +#editor-container.flex { + min-width: 0; +} + +.card-body > *, +.modal-box > *, +.modal-action > *, +#editor-container.flex > * { + min-width: 0; + max-width: 100%; +} + +/* -------------------------------------------------------------------------- + PROFILE THEME UTILITIES + -------------------------------------------------------------------------- */ + +.profile-theme-scope[data-profile-custom="true"] { + --color-primary: var(--profile-accent); + --color-primary-content: var(--profile-button-text); + --color-base-100: var(--profile-bg); + --color-base-200: color-mix(in srgb, var(--profile-bg) 78%, var(--profile-text) 22%); + --color-base-300: color-mix(in srgb, var(--profile-bg) 62%, var(--profile-text) 38%); + --color-base-content: var(--profile-text); + --surface-panel-bg-fallback: color-mix(in srgb, var(--color-base-200) 88%, var(--profile-accent) 12%); + --surface-panel-border: color-mix(in srgb, var(--color-base-300) 72%, var(--profile-accent) 28%); + --surface-panel-shadow: 0 18px 40px -34px rgba(0, 0, 0, 0.45), + 0 0 0 1px color-mix(in srgb, var(--profile-accent) 10%, transparent); + --theme-btn-ghost-text: color-mix(in srgb, var(--profile-text) 86%, var(--profile-accent) 14%); + --theme-btn-ghost-bg: color-mix(in srgb, var(--color-base-200) 84%, var(--profile-accent) 16%); + --theme-btn-outline-border: color-mix(in srgb, var(--color-base-300) 76%, var(--profile-accent) 24%); + --theme-btn-outline-bg: color-mix(in srgb, var(--color-base-200) 86%, var(--color-base-100) 14%); + --theme-link-color: var(--profile-accent); + --theme-link-color-hover: color-mix(in srgb, var(--profile-accent) 82%, var(--profile-text) 18%); + --theme-input-border: color-mix(in srgb, var(--color-base-300) 68%, var(--profile-text) 32%); + --theme-input-highlight: color-mix(in srgb, var(--profile-accent) 28%, transparent); + --theme-input-highlight-strong: color-mix(in srgb, var(--profile-accent) 42%, transparent); + --theme-input-highlight-shadow: color-mix(in srgb, var(--profile-accent) 12%, transparent); + --theme-table-row-hover: color-mix(in srgb, var(--color-base-200) 82%, var(--profile-accent) 18%); +} + +.profile-overlay-surface { + background-color: color-mix(in srgb, var(--profile-bg, var(--color-base-100)) 30%, transparent); +} + +.profile-overlay-surface-strong { + background-color: color-mix(in srgb, var(--profile-bg, var(--color-base-100)) 60%, transparent); +} + +.profile-tooltip-surface { + background-color: color-mix(in srgb, var(--profile-bg, var(--color-base-100)) 80%, transparent); + color: var(--profile-text, var(--color-base-content)); +} + +.profile-modal-surface { + background-color: color-mix(in srgb, var(--profile-bg, var(--color-base-100)) 88%, var(--color-base-100) 12%) !important; + border-color: color-mix(in srgb, var(--profile-accent, var(--color-primary)) 28%, transparent) !important; + color: var(--profile-text, var(--color-base-content)) !important; +} + +.profile-modal-row { + color: var(--profile-text, var(--color-base-content)); +} + +.profile-modal-row:hover { + background-color: color-mix(in srgb, var(--profile-accent, var(--color-primary)) 14%, transparent); +} + +.profile-text-muted { + color: color-mix(in srgb, var(--profile-text, var(--color-base-content)) 70%, transparent); +} + +.profile-text-subtle { + color: color-mix(in srgb, var(--profile-text, var(--color-base-content)) 55%, transparent); +} + +.profile-text-faint { + color: color-mix(in srgb, var(--profile-text, var(--color-base-content)) 40%, transparent); +} + +.profile-divider-soft { + background-color: color-mix(in srgb, var(--profile-text, var(--color-base-content)) 20%, transparent); +} + +/* -------------------------------------------------------------------------- + CHAT + -------------------------------------------------------------------------- */ + +#chat-container a, +#chat-container a:active, +#chat-container a:focus, +#chat-container button, +#chat-container button:active, +#chat-container button:focus { + -webkit-tap-highlight-color: transparent; + outline: none !important; + box-shadow: none !important; +} + +#chat-container a:focus-visible, +#chat-container button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-primary) 50%, transparent) !important; + outline-offset: 2px !important; + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--color-base-100) 80%, transparent), + 0 0 0 4px color-mix(in srgb, var(--color-primary) 24%, transparent) !important; +} + +#chat-container a { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +#chat-container .badge-error { + background-color: var(--color-error) !important; + border-color: var(--color-error) !important; + color: var(--theme-text-inverse) !important; +} + +#chat-container .text-error { + color: var(--theme-chat-error-text-soft) !important; +} + +#chat-container .bg-error { + background-color: var(--color-error) !important; + color: var(--theme-text-inverse) !important; +} + +#chat-container .bg-error\/20 { + background-color: var(--theme-chat-error-soft) !important; +} + +#chat-container .border-error { + border-color: var(--color-error) !important; +} + +#chat-container .btn-error { + background: + var(--theme-chat-error-button-overlay), + linear-gradient( + 135deg, + color-mix(in srgb, var(--color-base-100) 96%, transparent), + color-mix(in srgb, var(--color-base-200) 90%, transparent) + ) !important; + border-color: var(--theme-chat-error-button-border) !important; + color: var(--theme-chat-error-button-text) !important; +} + +#chat-container .btn-error:hover { + background: + var(--theme-chat-error-button-overlay-hover), + linear-gradient( + 135deg, + color-mix(in srgb, var(--color-base-100) 96%, transparent), + color-mix(in srgb, var(--color-base-200) 90%, transparent) + ) !important; + border-color: var(--theme-chat-error-button-border-hover) !important; +} + +#chat-container .hover\:text-error:hover { + color: var(--theme-chat-error-text-strong) !important; +} + +#chat-container .hover\:border-error\/50:hover { + border-color: var(--theme-chat-error-border-50) !important; +} + +#chat-container .progress-error::-webkit-progress-value { + background-color: var(--color-error) !important; +} + +#chat-container .progress-error::-moz-progress-bar { + background-color: var(--color-error) !important; +} + + +/* -------------------------------------------------------------------------- + TEXT SELECTION + -------------------------------------------------------------------------- */ + +.post-content, +.message-content { + user-select: text; + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + overflow-wrap: anywhere; + word-break: break-word; +} + +.message-selected, +.card.message-selected, +div.message-selected { + background-color: var(--theme-message-selected-bg) !important; + border-color: var(--theme-message-selected-border) !important; + box-shadow: 0 0 0 1px var(--theme-message-selected-shadow) !important; +} + +.message-selected:hover, +.card.message-selected:hover, +div.message-selected:hover { + background-color: var(--theme-message-selected-bg-hover) !important; + border-color: var(--theme-message-selected-border-hover) !important; + box-shadow: 0 0 0 1px var(--theme-message-selected-shadow-hover) !important; +} + +/* -------------------------------------------------------------------------- + MARKDOWN EDITOR + -------------------------------------------------------------------------- */ + +#editor-container.flex { + display: flex; + gap: 0.5rem; +} + +#editor-container.flex #editor-write { + flex: 1 1 0; +} + +#editor-container.flex #editor-preview { + flex: 1 1 0; +} + +#editor-container.flex textarea { + border-radius: 0.5rem !important; + border: 1px solid var(--color-base-300) !important; +} + +#editor-container.flex #editor-preview > div { + border-radius: 0.5rem !important; + border: 1px solid var(--color-base-300) !important; + height: 100%; +} + +#preview-content { + line-height: 1.6; +} + +#preview-content h1 { font-size: 1.875rem; font-weight: 700; margin: 1rem 0; } +#preview-content h2 { font-size: 1.5rem; font-weight: 600; margin: 0.75rem 0; } +#preview-content h3 { font-size: 1.25rem; font-weight: 600; margin: 0.5rem 0; } + +#preview-content blockquote { + border-left: 4px solid var(--color-primary); + padding-left: 1rem; + margin: 0.5rem 0; + font-style: italic; +} + +#preview-content pre { + background: var(--color-base-200); + padding: 0.75rem; + border-radius: 0.375rem; + overflow-x: auto; + margin: 0.5rem 0; +} + +#preview-content code { + background: var(--color-base-200); + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-weight: 500; +} + +/* -------------------------------------------------------------------------- + CUSTOM EMOJI + -------------------------------------------------------------------------- */ + +.custom-emoji { + height: 1.5em; + width: auto; + vertical-align: -0.3em; + display: inline-block; + object-fit: contain; + margin: 0 0.1em; +} + +.custom-emoji.inline-emoji { + height: 1.2em; + vertical-align: -0.2em; +} + +.message-content .custom-emoji { + height: 1.5em; +} + +/* -------------------------------------------------------------------------- + LAYOUT STABILITY + -------------------------------------------------------------------------- */ + +.post-actions-container { + contain: layout; +} + +.timeline-stream-entry { + position: relative; + z-index: 1; + overflow: visible; + content-visibility: auto; + contain-intrinsic-size: auto 34rem; +} + +.timeline-stream-entry:hover, +.timeline-stream-entry:focus-within, +.timeline-stream-entry:has(.timeline-reaction-dropdown[open]) { + content-visibility: visible; + z-index: 220; +} + +.timeline-feed-region, +.timeline-load-more-zone, +[data-infinite-scroll-sentinel] { + overflow-anchor: none; +} + +.timeline-post-card, +.timeline-stream-entry, +[data-post-id] { + overflow-anchor: auto; +} + +/* -------------------------------------------------------------------------- + E-NAV (product modes toolbar) + Compact single-row toolbar for mid-size desktop screens: primary modes + scroll horizontally, secondary modes live in the More menu. + -------------------------------------------------------------------------- */ + +.e-nav .e-nav-scroll { + scrollbar-width: none; + -ms-overflow-style: none; + -webkit-overflow-scrolling: touch; +} + +.e-nav .e-nav-scroll::-webkit-scrollbar { + display: none; +} + +/* Edge fades hinting at overflowed tabs; classes toggled by e_nav.js. */ +.e-nav .e-nav-scroll.e-nav-fade-start { + -webkit-mask-image: linear-gradient(to right, transparent, #000 1.75rem); + mask-image: linear-gradient(to right, transparent, #000 1.75rem); +} + +.e-nav .e-nav-scroll.e-nav-fade-end { + -webkit-mask-image: linear-gradient(to left, transparent, #000 1.75rem); + mask-image: linear-gradient(to left, transparent, #000 1.75rem); +} + +.e-nav .e-nav-scroll.e-nav-fade-start.e-nav-fade-end { + -webkit-mask-image: linear-gradient( + to right, + transparent, + #000 1.75rem, + #000 calc(100% - 1.75rem), + transparent + ); + mask-image: linear-gradient( + to right, + transparent, + #000 1.75rem, + #000 calc(100% - 1.75rem), + transparent + ); +} + +.e-nav .e-nav-more { + position: relative; +} + +.e-nav .e-nav-more-menu { + max-height: min(70vh, 28rem); + overflow-y: auto; +} + +.e-nav .e-nav-more-menu a, +.e-nav .e-nav-more-menu a:hover, +.e-nav .e-nav-more-menu a:focus, +.e-nav .e-nav-more-menu a:focus-visible, +.e-nav .e-nav-more-menu a:active, +.e-nav .e-nav-more-menu a:visited { + text-decoration: none !important; +} + +/* -------------------------------------------------------------------------- + MOBILE RESPONSIVE + -------------------------------------------------------------------------- */ + +@media (max-width: 640px) { + iframe { + max-width: 100% !important; + } + + #editor-container.flex { + flex-direction: column; + } + + #profile-container { + overflow-x: hidden; + } + + h1, h2, h3 { + word-wrap: break-word; + overflow-wrap: break-word; + hyphens: auto; + } + + .username-effect-glow, + .username-effect-neon { + --glow-intensity: calc(var(--glow-intensity, 10px) * 0.7); + } + + .card-body { + overflow-x: hidden; + } + + .truncate.min-w-0 { + overflow: hidden; + } + + .timeline-post-card > .card-body, + .timeline-post-card .timeline-post-card-body { + overflow: visible; + } +} diff --git a/apps/elektrine/assets/js/admin_security.js b/apps/elektrine/assets/js/admin_security.js new file mode 100644 index 0000000..6091374 --- /dev/null +++ b/apps/elektrine/assets/js/admin_security.js @@ -0,0 +1,361 @@ +const ADMIN_PREFIX = '/pripyat' +const ADMIN_SECURITY_PREFIX = '/pripyat/security' +const ADMIN_SECURITY_EXEMPT_PATHS = new Set(['/pripyat/stop-impersonation']) +const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']) + +function bufferToBase64URL(buffer) { + const bytes = new Uint8Array(buffer) + let binary = '' + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]) + } + + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +} + +function base64URLToBuffer(base64url) { + const padding = '='.repeat((4 - (base64url.length % 4)) % 4) + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') + padding + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + + return bytes.buffer +} + +function getCsrfToken() { + return document.querySelector("meta[name='csrf-token']")?.getAttribute('content') || '' +} + +function parseActionPath(action) { + try { + const url = new URL(action || window.location.pathname, window.location.origin) + return url.pathname + } catch (_error) { + return null + } +} + +function resolveFormMethod(form) { + const override = form.querySelector("input[name='_method']") + const method = override?.value || form.getAttribute('method') || form.method || 'GET' + return method.toUpperCase() +} + +function isSensitiveAdminPath(path) { + return Boolean(path) && + path.startsWith(ADMIN_PREFIX) && + !path.startsWith(ADMIN_SECURITY_PREFIX) && + !ADMIN_SECURITY_EXEMPT_PATHS.has(path) +} + +function shouldInterceptForm(form) { + if (!form || form.dataset.adminResignSkip === 'true') return false + if (form.hasAttribute('phx-submit') || form.hasAttribute('data-phx-submit')) return false + + const method = resolveFormMethod(form) + if (!MUTATING_METHODS.has(method)) return false + + const path = parseActionPath(form.getAttribute('action') || window.location.pathname) + return isSensitiveAdminPath(path) +} + +async function parseJsonResponse(response) { + let body = {} + try { + body = await response.json() + } catch (_error) { + body = {} + } + + if (!response.ok) { + throw new Error(body.error || 'Admin security request failed.') + } + + return body +} + +async function postJson(path, payload) { + const response = await fetch(path, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-csrf-token': getCsrfToken() + }, + credentials: 'same-origin', + body: JSON.stringify(payload) + }) + + return parseJsonResponse(response) +} + +async function createPasskeyAssertion(challengeData) { + if (!window.PublicKeyCredential) { + throw new Error('Passkeys are not supported by this browser.') + } + + const publicKey = { + challenge: base64URLToBuffer(challengeData.challenge_b64), + rpId: challengeData.rp_id, + timeout: challengeData.timeout, + userVerification: challengeData.user_verification || 'preferred', + allowCredentials: (challengeData.allow_credentials || []).map((credential) => ({ + type: credential.type, + id: base64URLToBuffer(credential.id), + transports: credential.transports + })) + } + + const credential = await navigator.credentials.get({ publicKey }) + const response = credential.response + + const assertion = { + id: credential.id, + rawId: bufferToBase64URL(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bufferToBase64URL(response.clientDataJSON), + authenticatorData: bufferToBase64URL(response.authenticatorData), + signature: bufferToBase64URL(response.signature) + } + } + + if (response.userHandle) { + assertion.response.userHandle = bufferToBase64URL(response.userHandle) + } + + return assertion +} + +function upsertGrantInput(form, grantToken) { + let input = form.querySelector("input[name='_admin_action_grant']") + if (!input) { + input = document.createElement('input') + input.type = 'hidden' + input.name = '_admin_action_grant' + form.appendChild(input) + } + + input.value = grantToken +} + +function notifyAdminSecurityError(message) { + if (window.showNotification) { + window.showNotification(message, 'error') + } else { + window.alert(message) + } +} + +function requestFormSubmit(form, submitter) { + if (typeof form.requestSubmit === 'function') { + if (submitter) { + form.requestSubmit(submitter) + } else { + form.requestSubmit() + } + + return + } + + const submitEvent = new Event('submit', { bubbles: true, cancelable: true }) + + if (form.dispatchEvent(submitEvent)) { + HTMLFormElement.prototype.submit.call(form) + } +} + +function bindSensitiveConfirm(submitter, form) { + if (!submitter) return + if (submitter.dataset.adminResignConfirmBound === 'true') return + if (!submitter.hasAttribute('data-confirm')) return + + submitter.dataset.adminResignConfirmBound = 'true' + + submitter.addEventListener( + 'click', + (event) => { + if (submitter.form !== form) return + if (!window.confirm(submitter.getAttribute('data-confirm') || 'Are you sure?')) { + event.preventDefault() + event.stopImmediatePropagation() + return + } + + event.preventDefault() + event.stopImmediatePropagation() + requestFormSubmit(form, submitter) + }, + true + ) +} + +async function signFormAction(form) { + const method = resolveFormMethod(form) + const path = parseActionPath(form.getAttribute('action') || window.location.pathname) + + if (!path) { + throw new Error('Unable to resolve admin action path.') + } + + const startData = await postJson('/pripyat/security/action/start', { method, path }) + const assertion = await createPasskeyAssertion(startData) + + const finishData = await postJson('/pripyat/security/action/finish', { + intent_token: startData.intent_token, + challenge: startData.challenge_b64, + assertion + }) + + if (!finishData.grant_token) { + throw new Error('Missing action grant token.') + } + + return finishData.grant_token +} + +function bindSensitiveForm(form) { + if (!shouldInterceptForm(form) || form.dataset.adminResignBound === 'true') return + + form.dataset.adminResignBound = 'true' + + form + .querySelectorAll("button[type='submit'][data-confirm], input[type='submit'][data-confirm]") + .forEach((submitter) => bindSensitiveConfirm(submitter, form)) + + form.addEventListener('submit', async (event) => { + if (form.dataset.adminResignSkip === 'true') { + delete form.dataset.adminResignSkip + return + } + + if (form.dataset.adminResignInFlight === 'true') return + + event.preventDefault() + form.dataset.adminResignInFlight = 'true' + + try { + const grantToken = await signFormAction(form) + upsertGrantInput(form, grantToken) + form.dataset.adminResignSkip = 'true' + delete form.dataset.adminResignInFlight + requestFormSubmit(form) + } catch (error) { + delete form.dataset.adminResignInFlight + notifyAdminSecurityError(error.message || 'Passkey confirmation failed.') + } + }) +} + +function resolveLinkMethod(link) { + return (link.dataset.adminActionMethod || 'GET').toUpperCase() +} + +function appendGrantToken(href, grantToken) { + const url = new URL(href, window.location.origin) + url.searchParams.set('_admin_action_grant', grantToken) + + if (url.origin === window.location.origin) { + return `${url.pathname}${url.search}${url.hash}` + } + + return url.toString() +} + +async function signLinkAction(link) { + const method = resolveLinkMethod(link) + const path = parseActionPath(link.getAttribute('href') || link.href) + + if (!path) { + throw new Error('Unable to resolve admin action path.') + } + + const startData = await postJson('/pripyat/security/action/start', { method, path }) + const assertion = await createPasskeyAssertion(startData) + + const finishData = await postJson('/pripyat/security/action/finish', { + intent_token: startData.intent_token, + challenge: startData.challenge_b64, + assertion + }) + + if (!finishData.grant_token) { + throw new Error('Missing action grant token.') + } + + return appendGrantToken(link.href, finishData.grant_token) +} + +function bindSensitiveLink(link) { + if (!link || link.dataset.adminActionBound === 'true') return + + const path = parseActionPath(link.getAttribute('href') || link.href) + if (!isSensitiveAdminPath(path)) return + + link.dataset.adminActionBound = 'true' + + link.addEventListener('click', async (event) => { + if (event.defaultPrevented || event.button !== 0) return + + event.preventDefault() + + const openInNewTab = link.target === '_blank' || event.metaKey || event.ctrlKey || event.shiftKey + const popup = openInNewTab ? window.open('about:blank', '_blank', 'noopener') : null + + try { + const signedHref = await signLinkAction(link) + + if (popup) { + popup.location = signedHref + } else { + window.location.href = signedHref + } + } catch (error) { + if (popup) popup.close() + notifyAdminSecurityError(error.message || 'Passkey confirmation failed.') + } + }) +} + +function bindAdminElevation(root = document) { + const container = root.querySelector('[data-admin-elevation="true"]') + const button = root.querySelector('#admin-elevate-passkey') + if (!container || !button || button.dataset.adminElevationBound === 'true') return + + button.dataset.adminElevationBound = 'true' + + button.addEventListener('click', async () => { + button.disabled = true + + try { + const returnTo = container.dataset.returnTo || '/pripyat' + const startData = await postJson('/pripyat/security/elevate/start', { return_to: returnTo }) + const assertion = await createPasskeyAssertion(startData) + + const finishData = await postJson('/pripyat/security/elevate/finish', { + intent_token: startData.intent_token, + challenge: startData.challenge_b64, + assertion + }) + + window.location.href = finishData.redirect_to || returnTo + } catch (error) { + button.disabled = false + notifyAdminSecurityError(error.message || 'Admin elevation failed.') + } + }) +} + +export function initAdminSecurity(root = document) { + const forms = root.querySelectorAll ? root.querySelectorAll('form') : [] + const links = root.querySelectorAll ? root.querySelectorAll('[data-admin-action-sign="true"]') : [] + forms.forEach(bindSensitiveForm) + links.forEach(bindSensitiveLink) + bindAdminElevation(root) +} diff --git a/apps/elektrine/assets/js/app.js b/apps/elektrine/assets/js/app.js new file mode 100644 index 0000000..de60ae8 --- /dev/null +++ b/apps/elektrine/assets/js/app.js @@ -0,0 +1,594 @@ +/** + * Main Application Entry Point + * Initializes Phoenix LiveView and all UI modules + */ + +// Phoenix and LiveView core +import "phoenix_html" +import { Socket } from "phoenix" +import { LiveSocket } from "phoenix_live_view" +import topbar from "../vendor/topbar" + +// Import hooks +import { Hooks } from "./hooks" +import { detectDevice } from "./hooks/presence_hooks" +import { initTimezoneDetectors } from "./hooks/form_hooks" +import { initBackupCodesPrinters } from "./hooks/backup_codes_printer" +import { initPrivateMailboxAuthForms } from "./hooks/mailbox_private_auth_forms" + +// Import utilities +import { + initEmailSelection, + initDropdownManagement, + initClipboardHandlers +} from "./utils" +import { submitFormPreservingEvents } from "./utils/form_submission" + +// Import UI modules +import { initModalControls } from "./modal_controls" +import { initMarkdownToolbar } from "./markdown_toolbar" +import { initFormHelpers } from "./form_helpers" +import { initEmailRaw } from "./email_raw" +import "./hashtag_links" // self-initializes +import { initCursorGlow, destroyCursorGlow } from "./cursor_glow" +import { initBlinkenlights, checkBlinkenlights } from "./blinkenlights" +import { initMarkdownEditor } from "./markdown_editor" +import { initLiveClock } from "./live_clock" +import { initIpLookup } from "./ip_lookup" +import { initTaglineCycler } from "./tagline_cycler" +import { initProfileStatic } from "./profile_static" +import { initENav } from "./e_nav" +import { initAdminSecurity } from "./admin_security" +import { initThemeToggle, syncThemeControls } from "./theme" + +// Import shared modules +import { + showNotification, + showLoadingNotification, + showUndoNotification, + showConfirmNotification, + showKeyboardShortcuts +} from "./notification_system" +import { insertMarkdownFormat, toggleMarkdownPreview, markdownToHtml } from "./markdown_helpers" + +// ============================================================================ +// Global Exports (for inline scripts and non-module code) +// ============================================================================ + +window.showNotification = showNotification +window.showLoadingNotification = showLoadingNotification +window.showUndoNotification = showUndoNotification +window.showConfirmNotification = showConfirmNotification +window.showKeyboardShortcuts = showKeyboardShortcuts +window.insertMarkdownFormat = insertMarkdownFormat +window.toggleMarkdownPreview = toggleMarkdownPreview +window.markdownToHtml = markdownToHtml + +// ============================================================================ +// LiveSocket Configuration +// ============================================================================ + +const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") + +const liveSocket = new LiveSocket("/live", Socket, { + longPollFallbackMs: 10000, + params: { _csrf_token: csrfToken, ...detectDevice() }, + hooks: Hooks, + heartbeatIntervalMs: 15000 +}) + +// ============================================================================ +// Progress Bar Configuration +// ============================================================================ + +function currentThemePrimary() { + const primary = getComputedStyle(document.documentElement) + .getPropertyValue("--color-primary") + .trim() + + return primary || "#8a7cc2" +} + +function currentThemePrimaryShadow() { + const primary = currentThemePrimary() + + if (primary.startsWith("#")) { + let hex = primary.slice(1) + + if (hex.length === 3) { + hex = hex.split("").map((char) => char + char).join("") + } + + if (hex.length === 6) { + const red = parseInt(hex.slice(0, 2), 16) + const green = parseInt(hex.slice(2, 4), 16) + const blue = parseInt(hex.slice(4, 6), 16) + + return `rgba(${red}, ${green}, ${blue}, 0.28)` + } + } + + return primary +} + +function syncTopbarTheme() { + topbar.config({ + barColors: { 0: currentThemePrimary() }, + shadowColor: currentThemePrimaryShadow() + }) +} + +window.addEventListener("phx:page-loading-start", () => { + syncTopbarTheme() + topbar.show(300) +}) +window.addEventListener("phx:page-loading-stop", () => { + topbar.hide() + checkBlinkenlights() + initProfileStatic() + initENav() + initAutoDismissFlashes() + initTimezoneDetectors() + initBackupCodesPrinters() + initPrivateMailboxAuthForms() + syncCursorGlowForRoute() + initAutoSearchClearButtons() + initAdminSecurity() + syncThemeControls() +}) + +window.addEventListener("elektrine:theme-changed", syncTopbarTheme) + +// ============================================================================ +// Global Event Handlers +// ============================================================================ + +// Scroll to top (used by new posts button) +window.addEventListener("scroll-to-top", () => { + window.scrollTo({ top: 0, behavior: "instant" }) +}) + +function initAutoDismissFlashes(rootCandidate = document) { + const root = + rootCandidate && typeof rootCandidate.querySelectorAll === 'function' ? rootCandidate : document + + root.querySelectorAll('[data-flash-auto-dismiss="true"]').forEach((flashEl) => { + bindFlashDismissButton(flashEl) + scheduleFlashAutoDismiss(flashEl) + }) +} + +const flashAutoDismissTimers = new WeakMap() + +function clearFlashAutoDismissTimer(flashEl) { + const timerId = flashAutoDismissTimers.get(flashEl) + if (timerId) { + window.clearTimeout(timerId) + flashAutoDismissTimers.delete(flashEl) + } +} + +function dismissFlashElement(flashEl) { + if (!flashEl || flashEl.dataset.flashDismissed === 'true') return + + flashEl.dataset.flashDismissed = 'true' + clearFlashAutoDismissTimer(flashEl) + flashEl.classList.add('is-dismissing') + + const exitMsRaw = parseInt(flashEl.dataset.flashExitMs || '260', 10) + const exitMs = Number.isFinite(exitMsRaw) ? exitMsRaw : 260 + + window.setTimeout(() => { + if (flashEl.parentNode) { + flashEl.remove() + } + }, Math.max(exitMs, 0) + 40) +} + +function bindFlashDismissButton(flashEl) { + if (flashEl.dataset.flashDismissBound === 'true') return + + const dismissBtn = flashEl.querySelector('[data-flash-dismiss="true"]') + if (!dismissBtn) return + + dismissBtn.addEventListener('click', () => { + dismissFlashElement(flashEl) + }) + + flashEl.dataset.flashDismissBound = 'true' +} + +function scheduleFlashAutoDismiss(flashEl) { + if (flashEl.dataset.flashTimerInitialized === 'true') return + flashEl.dataset.flashTimerInitialized = 'true' + + const timeoutMsRaw = parseInt(flashEl.dataset.flashAutoDismissMs || '5000', 10) + const timeoutMs = Number.isFinite(timeoutMsRaw) ? timeoutMsRaw : 5000 + + const timerId = window.setTimeout(() => { + if (!document.body.contains(flashEl)) { + clearFlashAutoDismissTimer(flashEl) + return + } + + const dismissBtn = flashEl.querySelector('[data-flash-dismiss="true"]') + if (dismissBtn) { + dismissBtn.click() + } else { + dismissFlashElement(flashEl) + } + }, timeoutMs) + + flashAutoDismissTimers.set(flashEl, timerId) +} + +window.dismissFlashElement = dismissFlashElement +window.initAutoDismissFlashes = initAutoDismissFlashes + +function shouldEnableCursorGlow(pathname = window.location.pathname) { + return !/^\/chat(?:\/|$)/.test(pathname) +} + +function syncCursorGlowForRoute() { + if (shouldEnableCursorGlow()) { + initCursorGlow() + } else { + destroyCursorGlow() + } +} + +const SEARCH_CLEAR_SELECTOR = '[data-search-clear]' +const SEARCH_INPUT_SELECTOR = [ + '[data-search-input]', + 'input[type="search"]', + 'input[name="q"]', + 'input[name="query"]', + 'input[name$="[query]"]', + 'input[name="search"]' +].join(', ') + +let searchClearButtonsInitialized = false +let searchClearInputIdCounter = 0 +let searchClearObserverInitialized = false + +function isClearableSearchInput(candidate) { + if (!(candidate instanceof HTMLInputElement)) return false + if (candidate.disabled || candidate.readOnly) return false + + const inputType = (candidate.getAttribute('type') || 'text').toLowerCase() + return inputType === 'text' || inputType === 'search' +} + +function ensureSearchInputId(input) { + if (!input.id) { + searchClearInputIdCounter += 1 + input.id = `search-clear-input-${searchClearInputIdCounter}` + } + + return input.id +} + +function getSearchInputScopes(input) { + return [ + input.closest('label.input'), + input.parentElement + ].filter(Boolean) +} + +function hasLocalSearchClearControl(input) { + const inputId = ensureSearchInputId(input) + + return getSearchInputScopes(input).some((scope) => { + const controls = scope.querySelectorAll(`${SEARCH_CLEAR_SELECTOR}, .search-clear-auto`) + + return Array.from(controls).some((control) => { + if (control === input) return false + + const target = control.getAttribute('data-search-clear-target') + return !target || target === `#${inputId}` + }) + }) +} + +function getSearchClearPlacement(input) { + const labelContainer = input.closest('label.input') + if (labelContainer && labelContainer.contains(input)) { + return { mode: 'label', container: labelContainer } + } + + const parent = input.parentElement + if (!parent) return null + + if (parent.classList.contains('join')) { + return { mode: 'join', container: parent } + } + + if (parent.classList.contains('relative')) { + return { mode: 'overlay', container: parent } + } + + if (parent.classList.contains('flex') || parent.tagName === 'FORM') { + return { mode: 'inline', container: parent } + } + + return { mode: 'overlay', container: parent } +} + +function updateAutoSearchClearButton(input, button) { + button.hidden = !isClearableSearchInput(input) || input.value === '' +} + +function getSearchClearTrailingWidth(input) { + const parent = input.parentElement + if (!parent) return 0 + + let trailingWidth = 0 + let sibling = input.nextElementSibling + + while (sibling) { + if (!sibling.hidden) { + trailingWidth += sibling.getBoundingClientRect().width + } + + sibling = sibling.nextElementSibling + } + + return trailingWidth +} + +function searchClearIconSize(input) { + if (input.classList.contains('input-xs')) return '12' + if (input.classList.contains('input-sm')) return '12' + if (input.classList.contains('input-lg')) return '20' + return '16' +} + +function createSearchClearIcon(input) { + const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') + const size = searchClearIconSize(input) + + icon.setAttribute('xmlns', 'http://www.w3.org/2000/svg') + icon.setAttribute('viewBox', '0 0 24 24') + icon.setAttribute('fill', 'none') + icon.setAttribute('stroke', 'currentColor') + icon.setAttribute('stroke-width', '1.8') + icon.setAttribute('width', size) + icon.setAttribute('height', size) + icon.setAttribute('aria-hidden', 'true') + + path.setAttribute('stroke-linecap', 'round') + path.setAttribute('stroke-linejoin', 'round') + path.setAttribute('d', 'M6 18 18 6M6 6l12 12') + + icon.appendChild(path) + + return icon +} + +function clearSearchInput(clearTrigger) { + const input = resolveSearchInput(clearTrigger) + + if (!isClearableSearchInput(input) || input.value === '') return input + + input.value = '' + input.dispatchEvent(new Event('input', { bubbles: true })) + return input +} + +function createAutoSearchClearButton(input) { + const placement = getSearchClearPlacement(input) + if (!placement) return null + + const inputId = ensureSearchInputId(input) + const button = document.createElement('button') + button.type = 'button' + button.setAttribute('aria-label', 'Clear search') + button.setAttribute('title', 'Clear search') + button.setAttribute('data-search-clear', 'true') + button.setAttribute('data-search-clear-target', `#${inputId}`) + button.setAttribute('data-search-clear-mode', 'auto') + button.appendChild(createSearchClearIcon(input)) + + if (placement.mode === 'label') { + button.className = 'search-clear-auto text-base-content/60 hover:text-base-content shrink-0' + placement.container.appendChild(button) + } else if (placement.mode === 'join') { + placement.container.classList.add('relative') + input.classList.add('search-clear-input') + button.className = + 'search-clear-auto search-clear-overlay text-base-content/60 hover:text-base-content' + button.style.right = `${getSearchClearTrailingWidth(input) + 8}px` + placement.container.appendChild(button) + } else if (placement.mode === 'inline') { + button.className = + 'search-clear-auto search-clear-inline text-base-content/60 hover:text-base-content' + input.insertAdjacentElement('afterend', button) + } else { + placement.container.classList.add('relative') + input.classList.add('search-clear-input') + button.className = + 'search-clear-auto search-clear-overlay text-base-content/60 hover:text-base-content' + placement.container.appendChild(button) + } + + const syncButton = () => updateAutoSearchClearButton(input, button) + input.addEventListener('input', syncButton) + input.addEventListener('change', syncButton) + + updateAutoSearchClearButton(input, button) + + return button +} + +function initAutoSearchClearButtons(rootCandidate = document) { + const root = + rootCandidate && typeof rootCandidate.querySelectorAll === 'function' ? rootCandidate : document + + root.querySelectorAll(SEARCH_INPUT_SELECTOR).forEach((input) => { + if (!isClearableSearchInput(input)) return + if (input.dataset.searchClearEnhanced === 'true') return + if (hasLocalSearchClearControl(input)) return + + const button = createAutoSearchClearButton(input) + if (!button) return + + input.dataset.searchClearEnhanced = 'true' + }) +} + +function initSearchClearObserver() { + if (searchClearObserverInitialized) return + if (!document.body) return + + searchClearObserverInitialized = true + + const observer = new MutationObserver(() => initAutoSearchClearButtons(document)) + observer.observe(document.body, { childList: true, subtree: true }) +} + +function resolveSearchInput(clearTrigger) { + const explicitSelector = clearTrigger.getAttribute('data-search-clear-target') + if (explicitSelector) return document.querySelector(explicitSelector) + + const activeElement = document.activeElement + if (isClearableSearchInput(activeElement)) return activeElement + + const scopes = [ + clearTrigger.closest('label'), + clearTrigger.parentElement, + clearTrigger.closest('form') + ].filter(Boolean) + + for (const scope of scopes) { + const input = scope.querySelector(SEARCH_INPUT_SELECTOR) + if (isClearableSearchInput(input)) return input + } + + return null +} + +function initSearchClearButtons() { + if (searchClearButtonsInitialized) return + searchClearButtonsInitialized = true + + document.addEventListener('pointerdown', (event) => { + const clearTrigger = event.target.closest(SEARCH_CLEAR_SELECTOR) + if (!clearTrigger) return + + clearSearchInput(clearTrigger) + }) + + document.addEventListener('click', (event) => { + const clearTrigger = + event.target.closest(`${SEARCH_CLEAR_SELECTOR}[data-search-clear-mode="auto"]`) + + if (!clearTrigger) return + + event.preventDefault() + + const input = clearSearchInput(clearTrigger) || resolveSearchInput(clearTrigger) + if (!isClearableSearchInput(input)) return + + const form = input.form || clearTrigger.closest('form') + + if (form && !form.hasAttribute('phx-change') && !input.hasAttribute('phx-change')) { + submitFormPreservingEvents(form) + } + + input.focus() + }) +} + +initSearchClearButtons() +initAutoSearchClearButtons() +initSearchClearObserver() + +// ============================================================================ +// DOM Ready Initialization +// ============================================================================ + +document.addEventListener('DOMContentLoaded', () => { + // Initialize utility handlers + initEmailSelection() + initDropdownManagement() + initClipboardHandlers() + initAutoDismissFlashes() + initTimezoneDetectors() + initBackupCodesPrinters() + initPrivateMailboxAuthForms() + syncCursorGlowForRoute() + initAutoSearchClearButtons() + initSearchClearObserver() + + // Initialize UI modules + initBlinkenlights() + initMarkdownEditor() + initLiveClock() + initTaglineCycler() + initModalControls() + initMarkdownToolbar() + initFormHelpers() + initEmailRaw() + initIpLookup() + initProfileStatic() + initENav() + initAdminSecurity() + initThemeToggle() + + // Auto-print functionality + const body = document.querySelector('body[data-auto-print="true"]') + if (body) { + window.print() + } + + // Error page actions + const goBackBtn = document.querySelector('[data-action="go-back"]') + if (goBackBtn) { + goBackBtn.addEventListener('click', () => window.history.back()) + } + + const reloadBtn = document.querySelector('[data-action="reload-page"]') + if (reloadBtn) { + reloadBtn.addEventListener('click', () => window.location.reload()) + } +}) + +// ============================================================================ +// Connect LiveSocket +// ============================================================================ + +function shouldConnectLiveSocket() { + // data-phx-main covers full LiveView pages; data-phx-session also matches + // live_render islands embedded in controller-rendered pages (e.g. the + // navbar notification badge), which need the socket too. + return Boolean(document.querySelector('[data-phx-main], [data-phx-session]')) +} + +if (shouldConnectLiveSocket()) { + liveSocket.connect() +} + +// Ensure LiveSocket reconnects when returning from static pages (e.g., profile subdomains) +// The socket may disconnect when on non-LiveView pages, so we reconnect on visibility change +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible" && shouldConnectLiveSocket() && !liveSocket.isConnected()) { + liveSocket.connect() + } +}) + +// Also attempt reconnection on page focus (backup for browsers that don't fire visibilitychange) +window.addEventListener("focus", () => { + if (shouldConnectLiveSocket() && !liveSocket.isConnected()) { + liveSocket.connect() + } + syncCursorGlowForRoute() +}) + +window.addEventListener("popstate", syncCursorGlowForRoute) + +// Expose for debugging +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket diff --git a/apps/elektrine/assets/js/blinkenlights.js b/apps/elektrine/assets/js/blinkenlights.js new file mode 100644 index 0000000..54a1656 --- /dev/null +++ b/apps/elektrine/assets/js/blinkenlights.js @@ -0,0 +1,233 @@ +// Blinkenlights - Functional status lights that respond to system activity +// Inspired by mainframe front panels - lights indicate actual system activity + +let container = null +let lights = [] +let resizeHandler = null +let activityLevel = 0 // 0 = idle, 1 = light activity, 2 = moderate, 3 = heavy +let sitePopularity = 0 // 0-100 based on active users + +const GRID_SPACING = 30 +const LIGHT_DENSITY = 0.5 +const COLORS = ['color-red', 'color-green', 'color-blue', 'color-amber', 'color-purple'] + +// Deterministic hash for consistent "random" pattern +function hash(x, y) { + const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453 + return n - Math.floor(n) +} + +function syncTopChromeHeight() { + if (!container) return + + const appNavbar = document.querySelector('.app-navbar') + const topChromeHeight = appNavbar ? appNavbar.getBoundingClientRect().height : 0 + + container.style.setProperty('--top-chrome-height', `${topChromeHeight}px`) +} + +export function initBlinkenlights() { + const path = window.location.pathname + const showOnPages = ['/'] + + if (!showOnPages.some(p => path === p || path.startsWith(p + '/'))) { + destroyBlinkenlights() + return + } + + if (!window.matchMedia('(pointer: fine)').matches) return + + const nextContainer = document.getElementById('blinkenlights-container') + if (!nextContainer) return + + if (container === nextContainer && resizeHandler) return + + container = nextContainer + + document.body.classList.add('has-blinkenlights') + container.hidden = false + syncTopChromeHeight() + + createLights() + + let resizeTimeout + resizeHandler = () => { + clearTimeout(resizeTimeout) + resizeTimeout = setTimeout(() => { + syncTopChromeHeight() + createLights() + }, 300) + } + window.addEventListener('resize', resizeHandler) + + // Listen for site popularity updates + window.addEventListener('phx:site-activity', handleSiteActivity) + window.addEventListener('site:activity', handleSiteActivity) +} + +function handleSiteActivity(event) { + const activeUsers = event.detail?.active_users || 0 + const recentActions = event.detail?.recent_actions || 0 + + // Calculate popularity (0-100) based on users and activity + sitePopularity = Math.min(100, activeUsers * 5 + recentActions) + + // Update activity on existing lights (don't rebuild grid to avoid visual shift) + updateActivityLights() + + // Flash some lights for recent actions + if (recentActions > 0) { + flashLights(Math.min(recentActions, 10), 'action') + } +} + +function setActivityLevel(level) { + activityLevel = level + updateActivityLights() +} + +function updateActivityLights() { + if (!lights.length) return + + // Base ratio from activity level + const baseRatio = [0.02, 0.08, 0.15, 0.3][activityLevel] || 0.02 + + // Add popularity bonus (0-100 maps to 0-0.3 additional ratio) + const popularityBonus = (sitePopularity / 100) * 0.3 + + // Combined ratio (cap at 0.6 so not every light is on) + const activeRatio = Math.min(0.6, baseRatio + popularityBonus) + + lights.forEach((light, i) => { + light.classList.remove('active', 'finding', 'action') + + // Deterministic activation based on light index + if (hash(i, i * 7) < activeRatio) { + light.classList.add('active') + // Deterministic timing based on index + const speedFactor = 1 - (sitePopularity / 200) + const baseTime = hash(i * 3, i * 5) + const duration = activityLevel >= 2 ? 0.5 + baseTime * 1.5 : (2 + baseTime * 4) * speedFactor + light.style.setProperty('--blinken-duration', `${duration}s`) + light.style.setProperty('--blinken-delay', `${hash(i * 11, i * 13) * 2}s`) + } + }) +} + +function flashLights(count, type = 'pulse') { + if (!lights.length) return + + // Flash random lights briefly + const toFlash = [] + for (let i = 0; i < count && i < lights.length; i++) { + const idx = Math.floor(Math.random() * lights.length) + toFlash.push(lights[idx]) + } + + toFlash.forEach(light => { + light.classList.add(type) + setTimeout(() => light.classList.remove(type), 300 + Math.random() * 400) + }) +} + +function getPageHeight() { + // Use viewport height + extra buffer to ensure lights reach the bottom + return window.innerHeight + 100 +} + +function createLights() { + if (!container) return + + const width = window.innerWidth + const height = getPageHeight() + const cols = Math.ceil(width / GRID_SPACING) + 1 + const rows = Math.ceil(height / GRID_SPACING) + 1 + const cacheKey = `blinkenlights_${cols}_${rows}` + + // Check cache for this viewport size + const cached = localStorage.getItem(cacheKey) + if (cached && container.dataset.cacheKey === cacheKey) { + // Already showing correct grid + return + } + + container.innerHTML = '' + lights = [] + container.dataset.cacheKey = cacheKey + + const fragment = document.createDocumentFragment() + const positions = cached ? JSON.parse(cached) : [] + + if (positions.length > 0) { + // Restore from cache + positions.forEach(([x, y]) => { + const light = document.createElement('div') + light.className = 'blinkenlight' + light.style.left = `${x}px` + light.style.top = `${y}px` + fragment.appendChild(light) + lights.push(light) + }) + } else { + // Generate and cache + const newPositions = [] + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + if (hash(col, row) > LIGHT_DENSITY) continue + + const x = col * GRID_SPACING + const y = row * GRID_SPACING + newPositions.push([x, y]) + + const light = document.createElement('div') + light.className = 'blinkenlight' + light.style.left = `${x}px` + light.style.top = `${y}px` + fragment.appendChild(light) + lights.push(light) + } + } + localStorage.setItem(cacheKey, JSON.stringify(newPositions)) + } + + container.appendChild(fragment) + updateActivityLights() +} + +export function destroyBlinkenlights() { + if (resizeHandler) { + window.removeEventListener('resize', resizeHandler) + resizeHandler = null + } + window.removeEventListener('phx:site-activity', handleSiteActivity) + window.removeEventListener('site:activity', handleSiteActivity) + + if (container) { + container.innerHTML = '' + container.hidden = true + lights = [] + container = null + } + document.body.classList.remove('has-blinkenlights') +} + +export function checkBlinkenlights() { + const path = window.location.pathname + const showOnPages = ['/'] + + if (showOnPages.some(p => path === p || path.startsWith(p + '/'))) { + if (!container) initBlinkenlights() + } else { + destroyBlinkenlights() + } +} + +// Expose API for manual control +window.blinkenlights = { + flash: (count = 5, type = 'pulse') => flashLights(count, type), + setActivity: (level) => setActivityLevel(level), + setPopularity: (level) => { sitePopularity = Math.min(100, Math.max(0, level)); updateActivityLights() }, + finding: () => flashLights(8, 'finding'), + action: () => flashLights(3, 'action'), + simulate: (users) => handleSiteActivity({ detail: { active_users: users, recent_actions: Math.floor(users / 2) }}) +} diff --git a/apps/elektrine/assets/js/cursor_glow.js b/apps/elektrine/assets/js/cursor_glow.js new file mode 100644 index 0000000..a1f1490 --- /dev/null +++ b/apps/elektrine/assets/js/cursor_glow.js @@ -0,0 +1,209 @@ +/** + * Cursor Glow Effect + * + * Subtly brightens grid lines near the cursor position. + * Uses smooth interpolation and fade to avoid abrupt snapping. + */ + +let rafId = null +let overlay = null +let initialized = false + +const OFFSCREEN = -1000 +const BASE_RADIUS = 300 +const RADIUS_BOOST = 110 + +const state = { + targetX: OFFSCREEN, + targetY: OFFSCREEN, + currentX: OFFSCREEN, + currentY: OFFSCREEN, + velocity: 0, + radius: BASE_RADIUS, + opacity: 0, + hovering: false +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function resetState() { + state.targetX = OFFSCREEN + state.targetY = OFFSCREEN + state.currentX = OFFSCREEN + state.currentY = OFFSCREEN + state.velocity = 0 + state.radius = BASE_RADIUS + state.opacity = 0 + state.hovering = false +} + +function applyOverlayState() { + if (!overlay) return + overlay.style.setProperty('--cursor-x', `${state.currentX.toFixed(2)}px`) + overlay.style.setProperty('--cursor-y', `${state.currentY.toFixed(2)}px`) + overlay.style.setProperty('--cursor-radius', `${state.radius.toFixed(2)}px`) + overlay.style.setProperty('--cursor-opacity', state.opacity.toFixed(3)) +} + +function tick() { + if (!overlay) { + rafId = null + return + } + + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + const radiusLerp = reducedMotion ? 1 : 0.18 + const opacityLerp = reducedMotion ? 1 : state.hovering ? 0.28 : 0.16 + + // Keep the glow anchored to the pointer to avoid a trailing/laggy feel. + if (state.targetX !== OFFSCREEN && state.targetY !== OFFSCREEN) { + state.currentX = state.targetX + state.currentY = state.targetY + } + + const targetRadius = BASE_RADIUS + clamp(state.velocity * 2, 0, RADIUS_BOOST) + state.radius += (targetRadius - state.radius) * radiusLerp + state.velocity *= reducedMotion ? 0 : 0.82 + + const targetOpacity = state.hovering ? 0.85 : 0 + state.opacity += (targetOpacity - state.opacity) * opacityLerp + + applyOverlayState() + + const isSettled = + Math.abs(state.targetX - state.currentX) < 0.01 && + Math.abs(state.targetY - state.currentY) < 0.01 && + Math.abs(targetOpacity - state.opacity) < 0.01 && + Math.abs(targetRadius - state.radius) < 0.5 + + if (isSettled) { + if (!state.hovering) { + state.currentX = OFFSCREEN + state.currentY = OFFSCREEN + state.targetX = OFFSCREEN + state.targetY = OFFSCREEN + state.velocity = 0 + state.radius = BASE_RADIUS + state.opacity = 0 + applyOverlayState() + } + + rafId = null + return + } + + rafId = requestAnimationFrame(tick) +} + +function ensureAnimationFrame() { + if (!rafId) { + rafId = requestAnimationFrame(tick) + } +} + +function activateAt(x, y, velocity = 0) { + if (!overlay) return + + if (state.targetX !== OFFSCREEN && state.targetY !== OFFSCREEN) { + state.velocity = Math.max(velocity, Math.hypot(x - state.targetX, y - state.targetY)) + } else { + state.velocity = velocity + } + + state.targetX = x + state.targetY = y + state.hovering = true + + // Sync immediately so the glow never appears one frame behind the cursor. + state.currentX = state.targetX + state.currentY = state.targetY + applyOverlayState() + + ensureAnimationFrame() +} + +function updateCursorPosition(e) { + if (!overlay) return + + const velocity = + state.targetX !== OFFSCREEN && state.targetY !== OFFSCREEN + ? Math.hypot(e.clientX - state.targetX, e.clientY - state.targetY) + : 0 + + activateAt(e.clientX, e.clientY, velocity) +} + +function handleMouseLeave() { + state.hovering = false + ensureAnimationFrame() +} + +function handleMouseEnter(e) { + activateAt(e.clientX, e.clientY, 0) +} + +function handleMouseDown(e) { + // Keep glow active while clicking controls even if no move event follows. + activateAt(e.clientX, e.clientY, 8) +} + +function handleWindowBlur() { + state.hovering = false + ensureAnimationFrame() +} + +function handleWindowFocus() { + if (state.targetX !== OFFSCREEN && state.targetY !== OFFSCREEN) { + activateAt(state.targetX, state.targetY, 0) + } +} + +export function initCursorGlow() { + if (!window.matchMedia('(pointer: fine)').matches) return + + const nextOverlay = document.getElementById('cursor-glow-overlay') + if (!nextOverlay) { + destroyCursorGlow() + return + } + + overlay = nextOverlay + + if (initialized) { + resetState() + applyOverlayState() + return + } + + resetState() + applyOverlayState() + document.addEventListener('mousemove', updateCursorPosition, { passive: true }) + document.addEventListener('mouseleave', handleMouseLeave, { passive: true }) + document.addEventListener('mouseenter', handleMouseEnter, { passive: true }) + document.addEventListener('mousedown', handleMouseDown, { passive: true }) + window.addEventListener('blur', handleWindowBlur, { passive: true }) + window.addEventListener('focus', handleWindowFocus, { passive: true }) + initialized = true +} + +export function destroyCursorGlow() { + if (!initialized) return + + document.removeEventListener('mousemove', updateCursorPosition) + document.removeEventListener('mouseleave', handleMouseLeave) + document.removeEventListener('mouseenter', handleMouseEnter) + document.removeEventListener('mousedown', handleMouseDown) + window.removeEventListener('blur', handleWindowBlur) + window.removeEventListener('focus', handleWindowFocus) + + overlay = null + if (rafId) { + cancelAnimationFrame(rafId) + rafId = null + } + + resetState() + initialized = false +} diff --git a/apps/elektrine/assets/js/e_nav.js b/apps/elektrine/assets/js/e_nav.js new file mode 100644 index 0000000..6f53c60 --- /dev/null +++ b/apps/elektrine/assets/js/e_nav.js @@ -0,0 +1,57 @@ +/** + * E-nav (product modes toolbar) scroll behavior. + * + * The primary tabs scroll horizontally with the scrollbar hidden, so on + * narrow screens two things need JS help: + * - the active tab is centered into view on load (it can otherwise sit + * offscreen when it lives far down the list), and + * - edge fade classes signal that more tabs exist past either edge. + */ + +const SCROLLER_SELECTOR = ".e-nav .e-nav-scroll" + +// Small tolerance so sub-pixel scroll positions don't flicker the fades. +const EDGE_TOLERANCE = 4 + +let resizeBound = false + +function updateOverflowFades(scroller) { + const maxScroll = scroller.scrollWidth - scroller.clientWidth + scroller.classList.toggle("e-nav-fade-start", scroller.scrollLeft > EDGE_TOLERANCE) + scroller.classList.toggle( + "e-nav-fade-end", + scroller.scrollLeft < maxScroll - EDGE_TOLERANCE + ) +} + +function centerActiveTab(scroller) { + const active = scroller.querySelector('[aria-current="page"]') + if (!active) return + + const scrollerRect = scroller.getBoundingClientRect() + const activeRect = active.getBoundingClientRect() + const activeLeft = activeRect.left - scrollerRect.left + scroller.scrollLeft + + scroller.scrollLeft = activeLeft - (scroller.clientWidth - activeRect.width) / 2 +} + +export function initENav() { + document.querySelectorAll(SCROLLER_SELECTOR).forEach((scroller) => { + if (scroller.dataset.eNavInit !== "true") { + scroller.dataset.eNavInit = "true" + scroller.addEventListener("scroll", () => updateOverflowFades(scroller), { + passive: true + }) + centerActiveTab(scroller) + } + + updateOverflowFades(scroller) + }) + + if (!resizeBound) { + resizeBound = true + window.addEventListener("resize", () => { + document.querySelectorAll(SCROLLER_SELECTOR).forEach(updateOverflowFades) + }) + } +} diff --git a/apps/elektrine/assets/js/email_raw.js b/apps/elektrine/assets/js/email_raw.js new file mode 100644 index 0000000..d82047a --- /dev/null +++ b/apps/elektrine/assets/js/email_raw.js @@ -0,0 +1,43 @@ +// Email raw view functions +export function initEmailRaw() { + // Handle download original email + document.addEventListener('click', (e) => { + if (e.target.closest('[data-action="download-original"]')) { + e.preventDefault(); + const rawContent = document.getElementById('raw-email'); + if (rawContent) { + const blob = new Blob([rawContent.textContent], { type: 'message/rfc822' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'email.eml'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + } + + // Handle copy raw email + if (e.target.closest('[data-action="copy-raw-email"]')) { + e.preventDefault(); + const rawContent = document.getElementById('raw-email'); + if (rawContent) { + navigator.clipboard.writeText(rawContent.textContent).then(() => { + const btn = e.target.closest('button'); + const originalText = btn.innerHTML; + btn.innerHTML = 'Copied!'; + setTimeout(() => { + btn.innerHTML = originalText; + }, 2000); + }); + } + } + + // Handle print + if (e.target.closest('[data-action="print"]')) { + e.preventDefault(); + window.print(); + } + }); +} \ No newline at end of file diff --git a/apps/elektrine/assets/js/error_page.js b/apps/elektrine/assets/js/error_page.js new file mode 100644 index 0000000..e04ca23 --- /dev/null +++ b/apps/elektrine/assets/js/error_page.js @@ -0,0 +1,110 @@ +/** + * Error Page Bundle + * Lightweight JS for standalone error pages (404, 403, 413, 500). + * These pages don't use LiveView, so they need a separate entry point. + */ + +const GRID_SPACING = 40 +const LIGHT_DENSITY = 0.5 +const BASE_ACTIVE_RATIO = 0.02 +const LIGHT_OFFSET = -1 + +let resizeHandler = null + +// Deterministic hash for consistent "random" pattern. +function hash(x, y) { + const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453 + return n - Math.floor(n) +} + +function getPageHeight() { + return window.innerHeight + 100 +} + +function createLights(container) { + const width = window.innerWidth + const height = getPageHeight() + const cols = Math.ceil(width / GRID_SPACING) + 1 + const rows = Math.ceil(height / GRID_SPACING) + 1 + const cacheKey = `error_blinkenlights_${cols}_${rows}` + + container.innerHTML = '' + + const fragment = document.createDocumentFragment() + let positions = [] + + try { + const cached = localStorage.getItem(cacheKey) + if (cached) { + positions = JSON.parse(cached) + } + } catch (_) { + positions = [] + } + + if (!positions.length) { + const generatedPositions = [] + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + if (hash(col, row) > LIGHT_DENSITY) continue + generatedPositions.push([col * GRID_SPACING + LIGHT_OFFSET, row * GRID_SPACING + LIGHT_OFFSET]) + } + } + positions = generatedPositions + try { + localStorage.setItem(cacheKey, JSON.stringify(generatedPositions)) + } catch (_) { + // Ignore storage errors (private mode/storage quotas). + } + } + + positions.forEach(([x, y], i) => { + const light = document.createElement('div') + light.className = 'blinkenlight' + light.style.left = `${x}px` + light.style.top = `${y}px` + + if (hash(i, i * 7) < BASE_ACTIVE_RATIO) { + light.classList.add('active') + const baseTime = hash(i * 3, i * 5) + const duration = 2 + (baseTime * 4) + light.style.setProperty('--blinken-duration', `${duration}s`) + light.style.setProperty('--blinken-delay', `${hash(i * 11, i * 13) * 2}s`) + } + + fragment.appendChild(light) + }) + + container.appendChild(fragment) +} + +function initErrorBlinkenlights() { + const container = document.getElementById('blinkenlights-container') + if (!container) return + if (!window.matchMedia('(pointer: fine)').matches) return + + document.body.classList.add('has-blinkenlights') + createLights(container) + + if (!resizeHandler) { + let resizeTimeout + resizeHandler = () => { + clearTimeout(resizeTimeout) + resizeTimeout = setTimeout(() => createLights(container), 300) + } + window.addEventListener('resize', resizeHandler) + } +} + +// Button action handlers +function initErrorActions() { + const goBack = document.querySelector('[data-action="go-back"]') + if (goBack) goBack.addEventListener('click', () => history.back()) + + const reload = document.querySelector('[data-action="reload-page"]') + if (reload) reload.addEventListener('click', () => location.reload()) +} + +// Initialize +initErrorBlinkenlights() +initErrorActions() diff --git a/apps/elektrine/assets/js/form_helpers.js b/apps/elektrine/assets/js/form_helpers.js new file mode 100644 index 0000000..57b14c3 --- /dev/null +++ b/apps/elektrine/assets/js/form_helpers.js @@ -0,0 +1,105 @@ +import { submitFormPreservingEvents } from './utils/form_submission' +import { copyToClipboard } from './utils/clipboard' + +// Form helper functions for various UI interactions +export function initFormHelpers() { + // Handle copy to clipboard + document.addEventListener('click', (e) => { + const copyBtn = e.target.closest('[data-copy-to-clipboard]'); + if (copyBtn) { + e.preventDefault(); + const text = copyBtn.dataset.copyToClipboard; + copyToClipboard(text).then((copied) => { + if (!copied) return; + + const originalText = copyBtn.textContent; + copyBtn.textContent = 'Copied!'; + setTimeout(() => { + copyBtn.textContent = originalText; + }, 2000); + }); + } + }); + + // Handle select all checkboxes + document.addEventListener('change', (e) => { + if (e.target.matches('[data-select-all]')) { + const targetClass = e.target.dataset.selectAll; + const checkboxes = document.querySelectorAll(`.${targetClass}`); + checkboxes.forEach(cb => cb.checked = e.target.checked); + } + }); + + // Handle form submit confirmations + document.addEventListener('submit', (e) => { + const form = e.target; + const confirmMessage = form.dataset.submitConfirm; + if (confirmMessage && !confirm(confirmMessage)) { + e.preventDefault(); + } + }); + + // Handle toggle visibility + document.addEventListener('click', (e) => { + const toggleBtn = e.target.closest('[data-toggle-visibility]'); + if (toggleBtn) { + e.preventDefault(); + const targetId = toggleBtn.dataset.toggleVisibility; + const target = document.getElementById(targetId); + if (target) { + target.classList.toggle('hidden'); + } + } + }); + + // Handle auto-submit toggles + document.addEventListener('change', (e) => { + if (e.target.matches('[data-auto-submit]')) { + const targetId = e.target.dataset.autoSubmit; + const targetInput = document.getElementById(targetId); + if (targetInput) { + targetInput.value = e.target.checked ? 'true' : 'false'; + } + // Submit the form + const form = e.target.closest('form'); + if (form) { + submitFormPreservingEvents(form); + } + } + }); + + // Handle auto-submit selects (replaces onchange="this.form.submit()") + document.addEventListener('change', (e) => { + if (e.target.matches('select[data-submit-on-change]')) { + const form = e.target.closest('form'); + if (form) { + submitFormPreservingEvents(form); + } + } + }); + + // Handle dialog modal open/close (replaces onclick="document.getElementById('x').showModal()") + document.addEventListener('click', (e) => { + const openBtn = e.target.closest('[data-open-modal]'); + if (openBtn) { + const modal = document.getElementById(openBtn.dataset.openModal); + if (modal) modal.showModal(); + } + + const closeBtn = e.target.closest('[data-close-modal]'); + if (closeBtn) { + const modal = document.getElementById(closeBtn.dataset.closeModal); + if (modal) modal.close(); + } + }); + + // Handle external links + document.addEventListener('click', (e) => { + const externalLink = e.target.closest('[data-external-link]'); + if (externalLink) { + e.preventDefault(); + const url = externalLink.dataset.externalLink; + window.open(url, '_blank'); + } + }); +} diff --git a/apps/elektrine/assets/js/hashtag_links.js b/apps/elektrine/assets/js/hashtag_links.js new file mode 100644 index 0000000..09301c2 --- /dev/null +++ b/apps/elektrine/assets/js/hashtag_links.js @@ -0,0 +1,77 @@ +// Convert hashtags to clickable links without affecting spacing +export function initHashtagLinks() { + if (window.__elektrineHashtagObserver) { + window.__elektrineHashtagObserver.disconnect(); + window.__elektrineHashtagObserver = null; + } + + // Function to convert hashtags in text content + function makeHashtagsClickable() { + // Find all post content elements + const postContents = document.querySelectorAll('[data-hashtag-content]'); + + postContents.forEach(element => { + if (element.dataset.hashtagProcessed) return; // Skip if already processed + + const originalText = element.textContent; + const hashtagRegex = /#(\w+)/g; + const fragment = document.createDocumentFragment(); + let lastIndex = 0; + let foundHashtag = false; + + originalText.replace(hashtagRegex, (match, hashtag, offset) => { + foundHashtag = true; + fragment.appendChild(document.createTextNode(originalText.slice(lastIndex, offset))); + + const normalizedHashtag = hashtag.toLowerCase(); + const link = document.createElement("a"); + link.href = `/hashtag/${encodeURIComponent(normalizedHashtag)}`; + link.className = "text-primary hover:underline font-medium"; + link.textContent = match; + fragment.appendChild(link); + lastIndex = offset + match.length; + + return match; + }); + + if (foundHashtag) { + fragment.appendChild(document.createTextNode(originalText.slice(lastIndex))); + element.replaceChildren(fragment); + element.dataset.hashtagProcessed = 'true'; + } + }); + } + + // Process hashtags on page load + makeHashtagsClickable(); + + // Process hashtags when new content is added (LiveView updates) + const observer = new MutationObserver((mutations) => { + let shouldProcess = false; + mutations.forEach((mutation) => { + if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { + shouldProcess = true; + } + }); + + if (shouldProcess) { + setTimeout(makeHashtagsClickable, 10); // Small delay to ensure DOM is ready + } + }); + + // Observe the main timeline for changes + const timelineContainer = document.querySelector('[data-timeline-container]'); + if (timelineContainer) { + observer.observe(timelineContainer, { + childList: true, + subtree: true + }); + window.__elektrineHashtagObserver = observer; + } +} + +// Auto-initialize when DOM is ready +document.addEventListener('DOMContentLoaded', initHashtagLinks); + +// Also initialize on LiveView page loads +document.addEventListener('phx:page-loading-stop', initHashtagLinks); diff --git a/apps/elektrine/assets/js/hooks/analytics_hooks.js b/apps/elektrine/assets/js/hooks/analytics_hooks.js new file mode 100644 index 0000000..a1a4c02 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/analytics_hooks.js @@ -0,0 +1,231 @@ +import uPlot from "uplot"; + +const COMPACT_NUMBER = new Intl.NumberFormat(undefined, { + notation: "compact", + maximumFractionDigits: 1, +}); + +const FULL_NUMBER = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 1, +}); + +const ACCENTS = { + primary: { stroke: "#8a7cc2", fill: "rgba(138, 124, 194, 0.16)" }, + secondary: { stroke: "#c7796b", fill: "rgba(199, 121, 107, 0.15)" }, + info: { stroke: "#5f87b8", fill: "rgba(95, 135, 184, 0.15)" }, + success: { stroke: "#34d399", fill: "rgba(52, 211, 153, 0.13)" }, +}; + +function parsePayload(el) { + try { + return JSON.parse(el.dataset.chart || "{}"); + } catch (_error) { + return {}; + } +} + +function cssVar(name, fallback) { + const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return value || fallback; +} + +function chartWidth(el) { + const style = getComputedStyle(el); + const padding = parseFloat(style.paddingLeft || 0) + parseFloat(style.paddingRight || 0); + const width = (el.getBoundingClientRect().width || el.clientWidth || 640) - padding; + + return Math.max(Math.floor(width), 320); +} + +function formatNumber(value) { + if (!Number.isFinite(value)) return "0"; + return Math.abs(value) >= 1000 ? COMPACT_NUMBER.format(value) : FULL_NUMBER.format(value); +} + +function formatTime(value, granularity) { + const date = new Date(value * 1000); + + if (granularity === "hour") { + return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + } + + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function periodLabel(granularity) { + return granularity === "hour" ? "current hour" : "current day"; +} + +function chartData(points) { + const rows = Array.isArray(points) ? points : []; + + const normalized = rows + .map((point) => ({ + x: Number(point.x), + y: Number(point.y), + partial: point.partial === true, + })) + .filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y)) + .sort((a, b) => a.x - b.x); + + const hasPartial = normalized.some((point) => point.partial); + + if (!hasPartial) { + return { + hasPartial, + data: [ + normalized.map((point) => point.x), + normalized.map((point) => point.y), + ], + }; + } + + const partialValues = new Array(normalized.length).fill(null); + + normalized.forEach((point, index) => { + if (!point.partial) return; + + partialValues[index] = point.y; + + for (let previousIndex = index - 1; previousIndex >= 0; previousIndex--) { + if (!normalized[previousIndex].partial) { + partialValues[previousIndex] = normalized[previousIndex].y; + break; + } + } + }); + + return { + hasPartial, + data: [ + normalized.map((point) => point.x), + normalized.map((point) => (point.partial ? null : point.y)), + partialValues, + ], + }; +} + +function chartOptions(el, payload, hasPartial) { + const accent = ACCENTS[payload.accent] || ACCENTS.primary; + const axisColor = cssVar("--color-base-content", "#e4e7eb"); + const gridColor = "rgba(228, 231, 235, 0.11)"; + const unit = payload.unit || "value"; + const granularity = payload.granularity || "day"; + const series = [ + { + label: "Time", + value: (_u, value) => (value == null ? "" : formatTime(value, granularity)), + }, + { + label: unit, + stroke: accent.stroke, + fill: accent.fill, + width: 2.5, + points: { size: 5, width: 2, stroke: accent.stroke, fill: cssVar("--color-base-100", "#101419") }, + value: (_u, value) => (value == null ? "" : `${formatNumber(value)} ${unit}`), + }, + ]; + + if (hasPartial) { + series.push({ + label: `${unit} (${periodLabel(granularity)})`, + stroke: accent.stroke, + width: 2.5, + dash: [6, 4], + points: { size: 6, width: 2.5, stroke: accent.stroke, fill: accent.fill }, + value: (_u, value) => (value == null ? "" : `${formatNumber(value)} ${unit}`), + }); + } + + return { + width: chartWidth(el), + height: Number(payload.height) || 260, + padding: [10, 10, 2, 2], + cursor: { + drag: { x: false, y: false }, + points: { size: 6, width: 2 }, + }, + legend: { + show: true, + live: true, + }, + scales: { + x: { time: true }, + y: { + range: (_u, _min, max) => [0, Math.max(1, max * 1.16)], + }, + }, + axes: [ + { + stroke: axisColor, + grid: { stroke: gridColor, width: 1 }, + ticks: { stroke: gridColor, width: 1 }, + values: (_u, values) => values.map((value) => formatTime(value, granularity)), + }, + { + stroke: axisColor, + grid: { stroke: gridColor, width: 1 }, + ticks: { stroke: gridColor, width: 1 }, + values: (_u, values) => values.map(formatNumber), + }, + ], + series, + }; +} + +export const UPlotChart = { + mounted() { + this.rawPayload = null; + this.chart = null; + this.chartHeight = 260; + this.resizeFrame = null; + this.renderChart(); + + this.resizeObserver = new ResizeObserver(() => { + if (this.resizeFrame) cancelAnimationFrame(this.resizeFrame); + + this.resizeFrame = requestAnimationFrame(() => { + if (this.chart) { + this.chart.setSize({ width: chartWidth(this.el), height: this.chartHeight }); + } + }); + }); + + this.resizeObserver.observe(this.el); + }, + + updated() { + this.renderChart(); + }, + + destroyed() { + if (this.resizeFrame) cancelAnimationFrame(this.resizeFrame); + if (this.resizeObserver) this.resizeObserver.disconnect(); + if (this.chart) this.chart.destroy(); + }, + + renderChart() { + const rawPayload = this.el.dataset.chart || "{}"; + + if (rawPayload === this.rawPayload && this.chart) return; + + this.rawPayload = rawPayload; + + if (this.chart) { + this.chart.destroy(); + this.chart = null; + } + + this.el.innerHTML = ""; + + const payload = parsePayload(this.el); + const preparedData = chartData(payload.points); + const data = preparedData.data; + + if (data[0].length === 0) return; + + const options = chartOptions(this.el, payload, preparedData.hasPartial); + this.chartHeight = options.height; + this.chart = new uPlot(options, data, this.el); + }, +}; diff --git a/apps/elektrine/assets/js/hooks/backup_codes_printer.js b/apps/elektrine/assets/js/hooks/backup_codes_printer.js new file mode 100644 index 0000000..5dc388c --- /dev/null +++ b/apps/elektrine/assets/js/hooks/backup_codes_printer.js @@ -0,0 +1,69 @@ +export function initBackupCodesPrinter(element) { + if (!element || element.dataset.backupCodesPrinterInitialized === 'true') return + + const printButton = element.querySelector('[data-action="print"]') + if (!printButton) return + + printButton.addEventListener('click', () => { + const codes = JSON.parse(element.dataset.codes || '[]') + printBackupCodes(codes) + }) + + element.dataset.backupCodesPrinterInitialized = 'true' +} + +export function initBackupCodesPrinters(rootCandidate = document) { + const root = + rootCandidate && typeof rootCandidate.querySelectorAll === 'function' ? rootCandidate : document + + root.querySelectorAll('[data-backup-codes-printer]').forEach((element) => { + initBackupCodesPrinter(element) + }) +} + +function printBackupCodes(codes) { + const printWindow = window.open('', '_blank') + + const printContent = ` + + + Elektrine Backup Codes + + + +
+

Elektrine Two-Factor Authentication

+

Backup Codes

+

Generated on: ${new Date().toLocaleDateString()}

+
+ +
+ Important: Keep these codes safe and secure. Each code can only be used once to access your account if you lose your authenticator device. +
+ +
+ ${codes.map(code => `
${escapeHtml(String(code))}
`).join('')} +
+ + + ` + + printWindow.document.write(printContent) + printWindow.document.close() + printWindow.print() +} + +function escapeHtml(value) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} diff --git a/apps/elektrine/assets/js/hooks/call_hooks.js b/apps/elektrine/assets/js/hooks/call_hooks.js new file mode 100644 index 0000000..e88a428 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/call_hooks.js @@ -0,0 +1,415 @@ +/** + * LiveView hooks for WebRTC calling functionality. + */ + +import { WebRTCClient } from '../webrtc_client' +import { Socket } from 'phoenix' +import { globalRingtone } from '../ringtone_player' + +function createSocket(userToken) { + const socket = new Socket("/socket", { params: { token: userToken } }) + socket.connect() + return socket +} + +function mapCallError(error) { + if (!error) { + return "Unable to start call." + } + + const message = error.message || "Unable to start call." + + if (error.name === "NotAllowedError" || message.includes("Permission denied")) { + return "Microphone/camera access denied. Please allow access in your browser settings and try again." + } + + if (error.name === "NotFoundError") { + return "No microphone or camera found. Please connect a device and try again." + } + + if (message.includes("secure origin")) { + return "Calls require HTTPS. Please use a secure connection." + } + + return message +} + +function setMediaElementsFromStream(stream) { + if (!stream) { + return + } + + const hasVideo = stream.getVideoTracks().length > 0 + + if (hasVideo) { + const videoElement = document.getElementById("remote-video") + if (videoElement) { + videoElement.srcObject = stream + videoElement.play().catch(() => {}) + } + return + } + + const audioElement = document.getElementById("remote-audio") + if (audioElement) { + audioElement.srcObject = stream + audioElement.play().catch(() => {}) + } +} + +/** + * Hook for initiating and managing outgoing calls. + */ +export const CallInitiator = { + mounted() { + this.handleEvent("start_call", ({ call_id, call_type, ice_servers, transport, user_token, user_id }) => { + this.startCall(call_id, call_type, ice_servers, transport, user_token, user_id) + }) + + this.handleEvent("resume_call", ({ call_id, call_type, ice_servers, transport, user_token, user_id, initiator }) => { + this.resumeCall(call_id, call_type, ice_servers, transport, user_token, user_id, initiator) + }) + }, + + async startCall(callId, callType, iceServers, transport, userToken, userId) { + this.cleanup() + + try { + globalRingtone.playOutgoing() + + const socket = createSocket(userToken) + const client = new WebRTCClient(socket, callId, userId, iceServers, transport) + + this.attachClient(client) + + await client.initialize() + await client.startCall(callType) + this.setLocalStream(client.getLocalStream()) + + this.pushEvent("call_started", {}) + } catch (error) { + globalRingtone.stop() + this.pushEvent("call_error", { error: mapCallError(error) }) + + if (this.client) { + this.client.endCall() + } + } + }, + + async resumeCall(callId, callType, iceServers, transport, userToken, userId, initiator) { + this.cleanup() + + try { + const socket = createSocket(userToken) + const client = new WebRTCClient(socket, callId, userId, iceServers, transport) + + this.attachClient(client) + + await client.initialize() + await client.resumeCall(callType, Boolean(initiator)) + this.setLocalStream(client.getLocalStream()) + } catch (error) { + this.pushEvent("call_error", { error: mapCallError(error) }) + + if (this.client) { + this.client.endCall() + } + } + }, + + setLocalStream(stream) { + const videoElement = document.getElementById("local-video") + if (videoElement) { + videoElement.srcObject = stream + videoElement.play().catch(() => {}) + } + }, + + setRemoteStream(stream) { + setMediaElementsFromStream(stream) + }, + + attachClient(client) { + this.client = client + window.activeCallClient = client + + client.onRemoteStream((stream) => { + this.pushEvent("remote_stream_ready", {}) + this.setRemoteStream(stream) + }) + + client.onCallEnded((reason) => { + if (reason !== "rejected") { + globalRingtone.playEnded() + } else { + globalRingtone.stop() + } + + this.pushEvent("call_ended", { reason }) + this.cleanup() + }) + + client.onConnectionEstablished(() => { + globalRingtone.stop() + globalRingtone.playConnected() + this.pushEvent("call_connected", {}) + }) + }, + + cleanup() { + globalRingtone.stop() + + if (this.client) { + this.client.cleanup() + this.client = null + } + + window.activeCallClient = null + }, + + destroyed() { + this.cleanup() + } +} + +/** + * Hook for receiving and answering incoming calls. + */ +export const CallReceiver = { + mounted() { + this.handleEvent("answer_call", ({ call_id, ice_servers, transport, user_token, user_id }) => { + this.answerCall(call_id, ice_servers, transport, user_token, user_id) + }) + + this.handleEvent("reject_call", ({ call_id, user_token }) => { + this.rejectCall(call_id, user_token) + }) + + this.handleEvent("play_incoming_ringtone", () => { + globalRingtone.playIncoming() + }) + + this.handleEvent("stop_ringtone", () => { + globalRingtone.stop() + }) + }, + + async answerCall(callId, iceServers, transport, userToken, userId) { + this.cleanup() + + try { + globalRingtone.stop() + + const socket = createSocket(userToken) + const client = new WebRTCClient(socket, callId, userId, iceServers, transport) + + this.client = client + window.activeCallClient = client + + client.onRemoteStream((stream) => { + this.pushEvent("remote_stream_ready", {}) + this.setRemoteStream(stream) + }) + + client.onCallEnded((reason) => { + if (reason !== "rejected") { + globalRingtone.playEnded() + } else { + globalRingtone.stop() + } + + this.pushEvent("call_ended", { reason }) + this.cleanup() + }) + + client.onConnectionEstablished(() => { + globalRingtone.playConnected() + this.pushEvent("call_connected", {}) + }) + + await client.initialize() + client.channel.push("ready_to_receive", {}) + this.pushEvent("call_answered", {}) + } catch (error) { + globalRingtone.stop() + this.pushEvent("call_error", { error: mapCallError(error) }) + + if (this.client) { + this.client.endCall() + } + } + }, + + async rejectCall(callId, userToken) { + this.cleanup() + + try { + globalRingtone.stop() + + const socket = createSocket(userToken) + const client = new WebRTCClient(socket, callId, null, [], null) + + await client.initialize() + client.rejectCall() + this.pushEvent("call_rejected", {}) + } catch (_error) { + globalRingtone.stop() + } + }, + + setRemoteStream(stream) { + setMediaElementsFromStream(stream) + }, + + cleanup() { + globalRingtone.stop() + + if (this.client) { + this.client.cleanup() + this.client = null + } + + window.activeCallClient = null + }, + + destroyed() { + this.cleanup() + } +} + +/** + * Hook for managing active call controls. + */ +export const CallControls = { + mounted() { + this.handleToggleAudio = (e) => { + e.preventDefault() + const client = window.activeCallClient + + if (client) { + const enabled = client.toggleAudio() + this.pushEvent("audio_toggled", { enabled }) + } + } + + this.handleToggleVideo = (e) => { + e.preventDefault() + const client = window.activeCallClient + + if (client) { + const enabled = client.toggleVideo() + this.pushEvent("video_toggled", { enabled }) + } + } + + this.handleEndCall = (e) => { + e.preventDefault() + const client = window.activeCallClient + + if (client) { + client.endCall() + } + + this.pushEvent("call_ended_by_user", {}) + } + + this.audioButton = this.el.querySelector('[data-action="toggle-audio"]') + this.videoButton = this.el.querySelector('[data-action="toggle-video"]') + this.endButton = this.el.querySelector('[data-action="end-call"]') + + this.audioButton?.addEventListener("click", this.handleToggleAudio) + this.videoButton?.addEventListener("click", this.handleToggleVideo) + this.endButton?.addEventListener("click", this.handleEndCall) + }, + + destroyed() { + this.audioButton?.removeEventListener("click", this.handleToggleAudio) + this.videoButton?.removeEventListener("click", this.handleToggleVideo) + this.endButton?.removeEventListener("click", this.handleEndCall) + } +} + +/** + * Hook for displaying video streams. + */ +export const VideoDisplay = { + mounted() { + const localVideo = this.el.querySelector("#local-video") + const remoteVideo = this.el.querySelector("#remote-video") + + if (localVideo) { + localVideo.muted = true + localVideo.autoplay = true + localVideo.playsInline = true + } + + if (remoteVideo) { + remoteVideo.autoplay = true + remoteVideo.playsInline = true + } + + window.videoDisplay = this.el + }, + + destroyed() { + window.videoDisplay = null + } +} + +/** + * Hook for displaying call duration timer. + */ +export const CallTimer = { + mounted() { + this.startTime = null + this.timerInterval = null + + const status = this.el.dataset.status + + if (status === "connected") { + this.startTimer() + } + }, + + updated() { + const status = this.el.dataset.status + + if (status === "connected" && !this.timerInterval) { + this.startTimer() + } else if (status !== "connected" && this.timerInterval) { + this.stopTimer() + } + }, + + startTimer() { + this.startTime = Date.now() + this.timerInterval = setInterval(() => { + const elapsed = Math.floor((Date.now() - this.startTime) / 1000) + this.el.textContent = this.formatDuration(elapsed) + }, 1000) + }, + + stopTimer() { + if (this.timerInterval) { + clearInterval(this.timerInterval) + this.timerInterval = null + } + }, + + formatDuration(seconds) { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = seconds % 60 + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}` + } + + return `${minutes}:${String(secs).padStart(2, '0')}` + }, + + destroyed() { + this.stopTimer() + } +} diff --git a/apps/elektrine/assets/js/hooks/chat_context_menu_hooks.js b/apps/elektrine/assets/js/hooks/chat_context_menu_hooks.js new file mode 100644 index 0000000..df3caed --- /dev/null +++ b/apps/elektrine/assets/js/hooks/chat_context_menu_hooks.js @@ -0,0 +1,199 @@ +import { copyToClipboard } from '../utils/clipboard' + +function selectedTextWithin(element) { + const selection = window.getSelection() + + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return '' + + const text = selection.toString().trim() + if (!text) return '' + + for (let index = 0; index < selection.rangeCount; index++) { + const range = selection.getRangeAt(index) + const container = range.commonAncestorContainer + const node = container.nodeType === Node.TEXT_NODE ? container.parentElement : container + + if (node && element.contains(node)) { + return text + } + } + + return '' +} + +function contextMenuPosition(x, y) { + const margin = 8 + const estimatedWidth = 224 + const estimatedHeight = 360 + const maxX = Math.max(margin, window.innerWidth - estimatedWidth - margin) + const maxY = Math.max(margin, window.innerHeight - estimatedHeight - margin) + + return { + x: Math.min(Math.max(x, margin), maxX), + y: Math.min(Math.max(y, margin), maxY) + } +} + +export const ContextMenu = { + mounted() { + const conversationId = this.el.dataset.conversationId + + this.contextMenuHandler = (e) => { + e.preventDefault() + const position = contextMenuPosition(e.clientX, e.clientY) + this.pushEvent("hide_message_context_menu", {}) + this.pushEvent("show_context_menu", { + conversation_id: parseInt(conversationId), + x: position.x, + y: position.y + }) + } + this.el.addEventListener("contextmenu", this.contextMenuHandler) + + this.customEventHandler = (e) => { + const { conversation_id, x, y } = e.detail + const position = contextMenuPosition(x, y) + this.pushEvent("hide_message_context_menu", {}) + this.pushEvent("show_context_menu", { + conversation_id: conversation_id, + x: position.x, + y: position.y + }) + } + this.el.addEventListener("phx:show_context_menu", this.customEventHandler) + + this.clickHandler = (e) => { + const contextMenu = document.querySelector('[phx-click-away="hide_context_menu"]') + if (contextMenu && !contextMenu.contains(e.target)) { + this.pushEvent("hide_context_menu", {}) + } + } + + document.addEventListener("click", this.clickHandler) + + this.scrollHandler = () => { + this.pushEvent("hide_context_menu", {}) + } + this.el.addEventListener("scroll", this.scrollHandler) + }, + + destroyed() { + this.el.removeEventListener("contextmenu", this.contextMenuHandler) + this.el.removeEventListener("phx:show_context_menu", this.customEventHandler) + document.removeEventListener("click", this.clickHandler) + this.el.removeEventListener("scroll", this.scrollHandler) + } +} + +export const MessageContextMenu = { + mounted() { + const messageId = this.el.dataset.messageId + const senderId = this.el.dataset.senderId + + this.contextMenuHandler = (e) => { + e.preventDefault() + const selectedText = selectedTextWithin(this.el) + const position = contextMenuPosition(e.clientX, e.clientY) + this.pushEvent("hide_context_menu", {}) + this.pushEvent("show_message_context_menu", { + message_id: parseInt(messageId), + sender_id: parseInt(senderId), + selected_text: selectedText, + x: position.x, + y: position.y + }) + } + this.el.addEventListener("contextmenu", this.contextMenuHandler) + + this.customEventHandler = (e) => { + const { message_id, sender_id, x, y } = e.detail + const position = contextMenuPosition(x, y) + this.pushEvent("hide_context_menu", {}) + this.pushEvent("show_message_context_menu", { + message_id: message_id, + sender_id: sender_id, + x: position.x, + y: position.y + }) + } + this.el.addEventListener("phx:show_message_context_menu", this.customEventHandler) + + this.clickHandler = (e) => { + const contextMenu = document.querySelector('[phx-click-away="hide_message_context_menu"]') + if (contextMenu && !contextMenu.contains(e.target)) { + this.pushEvent("hide_message_context_menu", {}) + } + } + + document.addEventListener("click", this.clickHandler) + + this.scrollHandler = () => { + this.pushEvent("hide_message_context_menu", {}) + } + this.el.addEventListener("scroll", this.scrollHandler) + + this.keyHandler = (e) => { + if (e.key === "Escape") { + this.pushEvent("hide_message_context_menu", {}) + } + } + document.addEventListener("keydown", this.keyHandler) + }, + + destroyed() { + this.el.removeEventListener("contextmenu", this.contextMenuHandler) + this.el.removeEventListener("phx:show_message_context_menu", this.customEventHandler) + document.removeEventListener("click", this.clickHandler) + document.removeEventListener("keydown", this.keyHandler) + this.el.removeEventListener("scroll", this.scrollHandler) + } +} + +// Opens the message context menu from an explicit button (hover action bar / +// touch), positioned at the button rather than the cursor. Reuses the same +// custom DOM event the right-click handler dispatches. +export const MessageMenuButton = { + mounted() { + this.clickHandler = (e) => { + e.preventDefault() + e.stopPropagation() + const target = document.getElementById(this.el.dataset.menuTarget) + if (!target) return + const rect = this.el.getBoundingClientRect() + target.dispatchEvent(new CustomEvent("phx:show_message_context_menu", { + detail: { + message_id: parseInt(this.el.dataset.messageId), + sender_id: parseInt(this.el.dataset.senderId), + x: rect.right, + y: rect.bottom + } + })) + } + this.el.addEventListener("click", this.clickHandler) + }, + + destroyed() { + this.el.removeEventListener("click", this.clickHandler) + } +} + +export const CopyChatMessage = { + mounted() { + this.copyHandler = () => { + const text = this.el.dataset.copyContent || '' + const type = this.el.dataset.copyType || 'message' + copyToClipboard(text, type) + + const hideEvent = this.el.dataset.hideEvent + if (hideEvent) { + this.pushEvent(hideEvent, {}) + } + } + + this.el.addEventListener('click', this.copyHandler) + }, + + destroyed() { + this.el.removeEventListener('click', this.copyHandler) + } +} diff --git a/apps/elektrine/assets/js/hooks/chat_e2ee_crypto.js b/apps/elektrine/assets/js/hooks/chat_e2ee_crypto.js new file mode 100644 index 0000000..24027a9 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/chat_e2ee_crypto.js @@ -0,0 +1,312 @@ +export const CHAT_E2EE_STORAGE_PREFIX = 'elektrine:chat-e2ee:v1' +export const CHAT_E2EE_MAX_DEVICES = 64 +export const textEncoder = new TextEncoder() +export const textDecoder = new TextDecoder() + +const CHAT_E2EE_DB_NAME = 'elektrine-chat-e2ee' +const CHAT_E2EE_DB_VERSION = 1 +const CHAT_E2EE_STORE_NAME = 'secrets' +let chatE2EEDatabasePromise = null + +export function cryptoAvailable() { + return Boolean(window.crypto?.subtle && window.crypto?.getRandomValues) +} + +export function parseJson(value, fallback) { + if (!value) return fallback + + try { + return JSON.parse(value) + } catch (_err) { + return fallback + } +} + +export function stableJson(value) { + if (Array.isArray(value)) return JSON.stringify(value.map(item => stableJsonValue(item))) + if (!value || typeof value !== 'object') return JSON.stringify(value) + + return JSON.stringify(stableJsonValue(value)) +} + +function stableJsonValue(value) { + if (Array.isArray(value)) return value.map(item => stableJsonValue(item)) + if (!value || typeof value !== 'object') return value + + return Object.keys(value) + .sort() + .reduce((acc, key) => { + acc[key] = stableJsonValue(value[key]) + return acc + }, {}) +} + +function openChatE2EEDatabase() { + if (chatE2EEDatabasePromise) return chatE2EEDatabasePromise + + chatE2EEDatabasePromise = new Promise((resolve, reject) => { + const request = indexedDB.open(CHAT_E2EE_DB_NAME, CHAT_E2EE_DB_VERSION) + + request.onupgradeneeded = () => { + const db = request.result + if (!db.objectStoreNames.contains(CHAT_E2EE_STORE_NAME)) { + db.createObjectStore(CHAT_E2EE_STORE_NAME) + } + } + + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error || new Error('Could not open chat key storage')) + }) + + return chatE2EEDatabasePromise +} + +export async function secureStorageGet(key) { + const db = await openChatE2EEDatabase() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(CHAT_E2EE_STORE_NAME, 'readonly') + const request = transaction.objectStore(CHAT_E2EE_STORE_NAME).get(key) + request.onsuccess = () => resolve(request.result ?? null) + request.onerror = () => reject(request.error || new Error('Could not read chat key storage')) + }) +} + +export async function secureStorageSet(key, value) { + const db = await openChatE2EEDatabase() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(CHAT_E2EE_STORE_NAME, 'readwrite') + const request = transaction.objectStore(CHAT_E2EE_STORE_NAME).put(value, key) + request.onsuccess = () => resolve() + request.onerror = () => reject(request.error || new Error('Could not write chat key storage')) + }) +} + +export async function secureStorageDelete(key) { + const db = await openChatE2EEDatabase() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(CHAT_E2EE_STORE_NAME, 'readwrite') + const request = transaction.objectStore(CHAT_E2EE_STORE_NAME).delete(key) + request.onsuccess = () => resolve() + request.onerror = () => reject(request.error || new Error('Could not delete chat key storage')) + }) +} + +export function bytesToBase64(bytes) { + const array = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes) + let binary = '' + + for (let offset = 0; offset < array.length; offset += 0x8000) { + binary += String.fromCharCode(...array.subarray(offset, offset + 0x8000)) + } + + return btoa(binary) +} + +export function base64ToBytes(value) { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index) + } + + return bytes +} + +function bytesToBase64Url(bytes) { + return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') +} + +export function randomBytes(length) { + const bytes = new Uint8Array(length) + window.crypto.getRandomValues(bytes) + return bytes +} + +export function randomId(prefix) { + if (window.crypto?.randomUUID) { + return `${prefix}${window.crypto.randomUUID()}` + } + + return `${prefix}${bytesToBase64Url(randomBytes(18))}` +} + +export function arrayBufferFromBytes(bytes) { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) +} + +export async function sha256Base64Url(value) { + const digest = await window.crypto.subtle.digest('SHA-256', textEncoder.encode(value)) + return bytesToBase64Url(digest) +} + +export function extractSearchKeywords(text) { + const stopWords = new Set([ + 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had', 'her', 'was', + 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now', + 'old', 'see', 'two', 'way', 'who', 'boy', 'did', 'its', 'let', 'put', 'say', 'she', + 'too', 'use' + ]) + + const lower = text.toLowerCase() + const hashtags = lower.match(/#[a-z0-9_]+/g) || [] + const words = lower + .replace(/[^a-z0-9_\s#]/g, ' ') + .split(/\s+/) + .filter(word => word.length >= 3 && !stopWords.has(word)) + + return Array.from(new Set([...hashtags, ...words])) +} + +export function stableDevices(devices) { + return [...devices] + .map(device => ({ + user_id: Number.isInteger(Number(device.user_id)) ? Number(device.user_id) : null, + recipient_handle: device.recipient_handle || null, + origin_domain: device.origin_domain || null, + device_id: String(device.device_id || ''), + public_key: device.public_key || {}, + fingerprint: device.fingerprint || null, + signing_public_key: device.signing_public_key || null, + device_signature: device.device_signature || null + })) + .sort((left, right) => { + const leftOwner = left.recipient_handle || String(left.user_id) + const rightOwner = right.recipient_handle || String(right.user_id) + if (leftOwner !== rightOwner) return leftOwner.localeCompare(rightOwner) + return left.device_id.localeCompare(right.device_id) + }) +} + +export async function devicesHash(devices) { + return sha256Base64Url(JSON.stringify(stableDevices(devices))) +} + +export async function importRsaPublicKey(publicKeyPayload) { + const key = publicKeyPayload?.key + + if (!key || publicKeyPayload.algorithm !== 'RSA-OAEP-SHA256') { + throw new Error('Invalid chat public key') + } + + return window.crypto.subtle.importKey( + 'spki', + arrayBufferFromBytes(base64ToBytes(key)), + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['encrypt'] + ) +} + +export async function importRsaPrivateKey(privateKeyBase64) { + return window.crypto.subtle.importKey( + 'pkcs8', + arrayBufferFromBytes(base64ToBytes(privateKeyBase64)), + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['decrypt'] + ) +} + +export async function importStoredRsaPrivateKey(privateKeyBase64) { + return window.crypto.subtle.importKey( + 'pkcs8', + arrayBufferFromBytes(base64ToBytes(privateKeyBase64)), + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['decrypt'] + ) +} + +export async function importEcdsaPublicKey(publicKeyPayload) { + const key = publicKeyPayload?.key + + if (!key || publicKeyPayload.algorithm !== 'ECDSA-P256-SHA256') { + throw new Error('Invalid chat signing key') + } + + return window.crypto.subtle.importKey( + 'spki', + arrayBufferFromBytes(base64ToBytes(key)), + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'] + ) +} + +export async function importEcdsaPrivateKey(privateKeyBase64) { + return window.crypto.subtle.importKey( + 'pkcs8', + arrayBufferFromBytes(base64ToBytes(privateKeyBase64)), + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'] + ) +} + +export async function importStoredEcdsaPrivateKey(privateKeyBase64) { + return window.crypto.subtle.importKey( + 'pkcs8', + arrayBufferFromBytes(base64ToBytes(privateKeyBase64)), + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'] + ) +} + +export async function importAesKey(rawKeyBytes, usages) { + return window.crypto.subtle.importKey( + 'raw', + arrayBufferFromBytes(rawKeyBytes), + { name: 'AES-GCM' }, + false, + usages + ) +} + +export async function importHmacKey(rawKeyBytes) { + return window.crypto.subtle.importKey( + 'raw', + arrayBufferFromBytes(rawKeyBytes), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ) +} + +export function signingPublicKeyPayload(device) { + if (device.signing_public_key?.key) return device.signing_public_key + if (device.signing_public_key) { + return { version: 1, algorithm: 'ECDSA-P256-SHA256', key: device.signing_public_key } + } + return null +} + +function devicePublicKeyPayload(device) { + if (device.public_key?.key) return device.public_key + if (device.public_key) { + return { version: 1, algorithm: 'RSA-OAEP-SHA256', key: device.public_key } + } + return null +} + +export function deviceFingerprintPayload(device) { + return { + purpose: 'elektrine-chat-e2ee-device', + version: 1, + device_id: String(device.device_id || ''), + key_algorithm: device.key_algorithm || 'RSA-OAEP-SHA256', + public_key: devicePublicKeyPayload(device), + signing_public_key: signingPublicKeyPayload(device) + } +} + +export function deviceSignaturePayload(device, fingerprint) { + return { + ...deviceFingerprintPayload(device), + fingerprint + } +} diff --git a/apps/elektrine/assets/js/hooks/chat_e2ee_hook.js b/apps/elektrine/assets/js/hooks/chat_e2ee_hook.js new file mode 100644 index 0000000..de4a11c --- /dev/null +++ b/apps/elektrine/assets/js/hooks/chat_e2ee_hook.js @@ -0,0 +1,1298 @@ +// Chat-specific LiveView hook + +import { + CHAT_E2EE_MAX_DEVICES, + CHAT_E2EE_STORAGE_PREFIX, + arrayBufferFromBytes, + base64ToBytes, + bytesToBase64, + cryptoAvailable, + deviceFingerprintPayload, + deviceSignaturePayload, + devicesHash, + extractSearchKeywords, + importAesKey, + importEcdsaPrivateKey, + importEcdsaPublicKey, + importHmacKey, + importRsaPrivateKey, + importRsaPublicKey, + importStoredEcdsaPrivateKey, + importStoredRsaPrivateKey, + parseJson, + randomBytes, + randomId, + secureStorageDelete, + secureStorageGet, + secureStorageSet, + sha256Base64Url, + signingPublicKeyPayload, + stableDevices, + stableJson, + textDecoder, + textEncoder +} from './chat_e2ee_crypto' +import { + chatE2EEUnavailableLabel, + chatE2EEUnavailableTitle, + conversationKeyPreparationMessage, + encryptedSendFailureMessage, + encryptedSubmitBlockedMessage, + waitingForMemberKeysMessage +} from './chat_e2ee_messages' + +export const ChatE2EE = { + mounted() { + this.devicePromise = null + this.lastTypingAt = 0 + this.lastDeviceRegistrationAt = 0 + this.searchTimer = null + this.unavailableConversationKeys = new Set() + this.conversationKeyRequests = new Map() + this.sentPlaintexts = new Map() + this.preparedConversationKey = null + this.prepareConversationKeyPromise = null + this.preparingDeviceCount = 0 + this.cachedDevice = null + this.currentConversationId = this.conversationId() + + this.submitHandler = event => this.handleSubmit(event) + this.inputHandler = event => this.handleInput(event) + this.toggleHandler = event => this.handleToggle(event) + + this.el.addEventListener('submit', this.submitHandler, true) + this.el.addEventListener('input', this.inputHandler, true) + this.el.addEventListener('click', this.toggleHandler, true) + + this.observer = new MutationObserver(() => this.decryptVisibleMessages()) + this.observer.observe(this.el, { childList: true, subtree: true }) + + this.ensureDeviceRegistered() + this.updateInputMode() + this.updateToggleState() + this.updateStatusMessage() + this.syncEncryptedModeAfterPatch() + this.maybePrepareEnabledConversationKey() + this.decryptVisibleMessages() + }, + + updated() { + this.resetConversationKeyCacheIfChanged() + this.ensureDeviceRegistered() + this.updateInputMode() + this.updateToggleState() + this.updateStatusMessage() + this.syncEncryptedModeAfterPatch() + this.maybePrepareEnabledConversationKey() + this.decryptVisibleMessages() + }, + + destroyed() { + this.el.removeEventListener('submit', this.submitHandler, true) + this.el.removeEventListener('input', this.inputHandler, true) + this.el.removeEventListener('click', this.toggleHandler, true) + + if (this.observer) { + this.observer.disconnect() + } + + if (this.searchTimer) { + clearTimeout(this.searchTimer) + } + }, + + userId() { + const userId = Number(this.el.dataset.userId) + return Number.isInteger(userId) && userId > 0 ? userId : null + }, + + conversationId() { + const conversationId = Number(this.el.dataset.conversationId) + return Number.isInteger(conversationId) && conversationId > 0 ? conversationId : null + }, + + resetConversationKeyCacheIfChanged() { + const conversationId = this.conversationId() + + if (this.currentConversationId === conversationId) return + + this.currentConversationId = conversationId + this.preparedConversationKey = null + this.prepareConversationKeyPromise = null + this.preparingDeviceCount = 0 + }, + + devices() { + return parseJson(this.el.dataset.chatE2eeDevices, []) + }, + + serverE2EEReady() { + return this.el.dataset.chatE2eeReady === 'true' + }, + + localDevice() { + if (!this.userId()) return null + return this.cachedDevice + }, + + localDeviceAdvertised() { + const deviceId = this.localDevice()?.device_id + const userId = this.userId() + + if (!deviceId || !userId) return false + + return this.devices().some(device => Number(device.user_id) === userId && device.device_id === deviceId) + }, + + localDeviceSetupRequired() { + return cryptoAvailable() && Boolean(this.userId()) && Boolean(this.conversationId()) && !this.localDeviceAdvertised() + }, + + e2eeStatus() { + return this.el.dataset.chatE2eeStatus || 'unknown' + }, + + memberIds() { + return parseJson(this.el.dataset.chatE2eeMemberIds, []) + .map(id => Number(id)) + .filter(id => Number.isInteger(id) && id > 0) + }, + + e2eeReady() { + return this.e2eeCapable() && this.e2eeEnabled() + }, + + e2eeCapable() { + if (!cryptoAvailable() || !this.serverE2EEReady() || !this.conversationId()) { + return false + } + + const devices = this.devices() + return devices.length > 0 && devices.length <= CHAT_E2EE_MAX_DEVICES && this.localDeviceAdvertised() + }, + + e2eeEnabled() { + const conversationId = this.conversationId() + if (!conversationId || !this.userId()) return false + + return localStorage.getItem(this.enabledStorageKey(conversationId)) === 'true' + }, + + setE2EEEnabled(enabled) { + const conversationId = this.conversationId() + if (!conversationId || !this.userId()) return + + localStorage.setItem(this.enabledStorageKey(conversationId), enabled ? 'true' : 'false') + + if (!enabled) { + this.preparedConversationKey = null + this.preparingDeviceCount = 0 + } + }, + + updateInputMode() { + const form = this.messageForm() + const textarea = this.messageInput() + const encryptedMode = this.e2eeReady() + + if (!form || !textarea) return + + if (encryptedMode) { + form.removeAttribute('phx-change') + textarea.removeAttribute('phx-change') + } else { + form.setAttribute('phx-change', 'validate_upload') + textarea.setAttribute('phx-change', 'update_message') + } + + this.el.querySelectorAll('input[type="file"]').forEach(input => { + input.disabled = encryptedMode + }) + + const submitButton = form.querySelector('button[type="submit"]') + if (encryptedMode && submitButton) { + submitButton.disabled = false + } + + this.syncEncryptedSubmitButton() + }, + + syncEncryptedSubmitButton() { + const form = this.messageForm() + const textarea = this.messageInput() + const submitButton = form?.querySelector('button[type="submit"]') + + if (!form || !textarea || !submitButton) return + + const fileInput = form.querySelector('input[type="file"]') + const hasUploads = form.dataset.hasUploads === 'true' || (fileInput && fileInput.files.length > 0) + const loading = form.dataset.messageLoading === 'true' + + if (loading) { + submitButton.disabled = true + return + } + + if (this.e2eeReady() && this.prepareConversationKeyPromise) { + submitButton.disabled = true + return + } + + if (!this.e2eeCapable()) { + submitButton.disabled = !textarea.value.trim() && !hasUploads + return + } + + if (!this.e2eeReady()) { + submitButton.disabled = !textarea.value.trim() && !hasUploads + return + } + + submitButton.disabled = !textarea.value.trim() || hasUploads + }, + + syncEncryptedModeAfterPatch() { + const sync = () => { + this.updateInputMode() + this.updateToggleState() + this.updateStatusMessage() + } + + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(() => window.requestAnimationFrame(sync)) + } else { + setTimeout(sync, 0) + } + }, + + updateToggleState() { + const toggle = this.el.querySelector('[data-chat-e2ee-toggle="true"]') + if (!toggle) return + + const capable = this.e2eeCapable() + const enabled = capable && this.e2eeEnabled() + const label = toggle.querySelector('[data-chat-e2ee-toggle-label]') + + toggle.disabled = !capable + toggle.classList.toggle('btn-secondary', enabled) + toggle.classList.toggle('btn-ghost', !enabled) + toggle.title = capable + ? (enabled + ? 'Encrypted chat is ready and enabled for this browser.' + : 'Encrypted chat is ready. Click to turn it on for this browser.') + : chatE2EEUnavailableTitle(this.e2eeStatus(), this.localDeviceSetupRequired()) + + if (label) { + label.textContent = capable + ? (enabled ? 'Encrypted chat on' : 'Encrypted chat ready') + : chatE2EEUnavailableLabel(this.e2eeStatus(), this.localDeviceSetupRequired()) + } + }, + + statusElement() { + return this.el.querySelector('[data-chat-e2ee-status-message="true"]') + }, + + setE2EEStatusMessage(message) { + const element = this.statusElement() + if (!element) return + + if (!message) { + element.textContent = '' + element.classList.add('hidden') + return + } + + element.textContent = message + element.classList.remove('hidden') + }, + + updateStatusMessage() { + if (!this.conversationId() || !this.userId()) { + this.setE2EEStatusMessage(null) + return + } + + if (!cryptoAvailable()) { + this.setE2EEStatusMessage('Encrypted chat is unavailable because this browser does not support the required crypto APIs.') + return + } + + if (this.e2eeCapable()) { + if (this.e2eeEnabled() && this.prepareConversationKeyPromise) { + this.setE2EEStatusMessage(this.conversationKeyPreparationMessage()) + return + } + + this.setE2EEStatusMessage( + this.e2eeEnabled() + ? 'Ready: encrypted messages will be encrypted before they leave this browser.' + : 'Ready: click the encrypted chat button to turn encrypted sending on for this browser.' + ) + return + } + + if (this.localDeviceSetupRequired()) { + this.setE2EEStatusMessage( + this.localDevice()?.device_id + ? 'Registering this browser for encrypted chat. Encrypted sending will unlock automatically.' + : 'Generating this browser\'s encrypted chat keys. Encrypted sending will unlock automatically.' + ) + return + } + + switch (this.e2eeStatus()) { + case 'registering_device': + this.setE2EEStatusMessage('Generating and registering this browser\'s chat encryption keys...') + break + case 'waiting_for_remote_keys': + this.setE2EEStatusMessage('Waiting for the other side to register encryption keys. Ask them to open this chat, then try again.') + break + case 'waiting_for_member_keys': + this.setE2EEStatusMessage(waitingForMemberKeysMessage(this.memberIds(), this.devices())) + break + case 'too_many_devices': + this.setE2EEStatusMessage('Encrypted chat is unavailable because this conversation has too many active devices.') + break + default: + this.setE2EEStatusMessage(null) + break + } + }, + + notifyE2EE(message, type = 'info') { + if (typeof window.showNotification !== 'function') return + + window.showNotification(message, type, { + title: 'Encrypted chat', + duration: type === 'error' ? 7000 : 5000 + }) + }, + + handleToggle(event) { + const emojiButton = event.target.closest('[phx-click="insert_emoji"]') + if (emojiButton && this.el.contains(emojiButton) && this.e2eeReady()) { + this.handleEncryptedEmojiInsert(event, emojiButton) + return + } + + const toggle = event.target.closest('[data-chat-e2ee-toggle="true"]') + if (!toggle || !this.el.contains(toggle)) return + + event.preventDefault() + event.stopPropagation() + + if (!this.e2eeCapable()) { + this.updateStatusMessage() + this.notifyE2EE(chatE2EEUnavailableTitle(this.e2eeStatus(), this.localDeviceSetupRequired()), 'warning') + return + } + + const enabled = !this.e2eeEnabled() + + this.setE2EEEnabled(enabled) + this.updateInputMode() + this.updateToggleState() + this.updateStatusMessage() + + if (enabled) { + this.prepareConversationKeyPackages() + } + }, + + handleEncryptedEmojiInsert(event, button) { + const emoji = button.getAttribute('phx-value-emoji') + const textarea = this.messageInput() + + if (!emoji || !textarea) return + + event.preventDefault() + event.stopImmediatePropagation() + + const start = textarea.selectionStart ?? textarea.value.length + const end = textarea.selectionEnd ?? start + const nextValue = `${textarea.value.slice(0, start)}${emoji}${textarea.value.slice(end)}` + const cursorPosition = start + emoji.length + + textarea.value = nextValue + textarea.focus() + textarea.setSelectionRange(cursorPosition, cursorPosition) + textarea.dispatchEvent(new Event('input', { bubbles: true })) + this.syncEncryptedSubmitButton() + + this.pushEvent('toggle_emoji_picker', {}) + }, + + messageForm() { + const input = this.messageInput() + return input?.closest('form') || null + }, + + messageInput() { + return this.el.querySelector('#message-input') + }, + + async ensureDeviceRegistered() { + if (!cryptoAvailable() || !this.userId() || this.devicePromise) return this.devicePromise + if (Date.now() - this.lastDeviceRegistrationAt < 300000) return null + + this.devicePromise = this.loadOrCreateDevice() + .then(device => new Promise(resolve => { + this.pushEvent('register_chat_encryption_device', { + device_id: device.device_id, + public_key: { + version: 1, + algorithm: 'RSA-OAEP-SHA256', + key: device.public_key + }, + key_algorithm: 'RSA-OAEP-SHA256', + fingerprint: device.fingerprint, + signing_public_key: { + version: 1, + algorithm: 'ECDSA-P256-SHA256', + key: device.signing_public_key + }, + device_signature: { + version: 1, + algorithm: 'ECDSA-P256-SHA256', + signature: device.device_signature + }, + label: device.label + }, reply => { + if (reply?.ok) { + this.lastDeviceRegistrationAt = Date.now() + this.updateStatusMessage() + } else { + const message = 'Could not register this browser for encrypted chat. Try refreshing the chat.' + this.setE2EEStatusMessage(message) + this.notifyE2EE(message, 'error') + } + + resolve({ device, reply }) + }) + })) + .catch(error => { + const message = 'Could not set up encrypted chat keys in this browser. Try refreshing the chat.' + console.warn('Chat E2EE device registration failed', error) + this.setE2EEStatusMessage(message) + this.notifyE2EE(message, 'error') + return null + }) + .finally(() => { + this.devicePromise = null + }) + + return this.devicePromise + }, + + async loadOrCreateDevice() { + const key = this.deviceStorageKey() + const stored = await this.loadSecureJson(key) + + if (stored?.device_id && (stored?.private_key_crypto || stored?.private_key) && stored?.public_key) { + const upgraded = await this.upgradeDeviceTrustMaterial(stored) + this.cachedDevice = upgraded + return upgraded + } + + const keyPair = await window.crypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256' + }, + true, + ['encrypt', 'decrypt'] + ) + + const [publicKey, privateKey] = await Promise.all([ + window.crypto.subtle.exportKey('spki', keyPair.publicKey), + window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey) + ]) + + const device = await this.upgradeDeviceTrustMaterial({ + device_id: randomId('web-'), + public_key: bytesToBase64(publicKey), + private_key_crypto: await importStoredRsaPrivateKey(bytesToBase64(privateKey)), + key_algorithm: 'RSA-OAEP-SHA256', + label: this.deviceLabel() + }) + + await secureStorageSet(key, device) + this.cachedDevice = device + return device + }, + + async loadSecureJson(key) { + try { + const stored = await secureStorageGet(key) + if (stored) return stored + } catch (_error) { + // Fall back to localStorage once so existing browsers can migrate out of it. + } + + const storedJson = parseJson(localStorage.getItem(key), null) + if (storedJson) { + await secureStorageSet(key, storedJson) + localStorage.removeItem(key) + } + + return storedJson + }, + + async upgradeDeviceTrustMaterial(device) { + device = await this.migrateDevicePrivateKeys(device) + + if (device.signing_private_key_crypto && device.signing_public_key && device.fingerprint && device.device_signature) { + return device + } + + const signingKeyPair = await window.crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'] + ) + + const [signingPublicKey, signingPrivateKey] = await Promise.all([ + window.crypto.subtle.exportKey('spki', signingKeyPair.publicKey), + window.crypto.subtle.exportKey('pkcs8', signingKeyPair.privateKey) + ]) + + const upgraded = { + ...device, + signing_public_key: bytesToBase64(signingPublicKey), + signing_private_key_crypto: await importStoredEcdsaPrivateKey(bytesToBase64(signingPrivateKey)) + } + + upgraded.fingerprint = await this.deviceFingerprint(upgraded) + upgraded.device_signature = await this.signDeviceFingerprint(upgraded, upgraded.fingerprint) + + await secureStorageSet(this.deviceStorageKey(), upgraded) + localStorage.removeItem(this.deviceStorageKey()) + return upgraded + }, + + async migrateDevicePrivateKeys(device) { + let migrated = { ...device } + let changed = false + + if (!migrated.private_key_crypto && migrated.private_key) { + migrated.private_key_crypto = await importStoredRsaPrivateKey(migrated.private_key) + delete migrated.private_key + changed = true + } + + if (!migrated.signing_private_key_crypto && migrated.signing_private_key) { + migrated.signing_private_key_crypto = await importStoredEcdsaPrivateKey(migrated.signing_private_key) + delete migrated.signing_private_key + changed = true + } + + if (changed) { + await secureStorageSet(this.deviceStorageKey(), migrated) + localStorage.removeItem(this.deviceStorageKey()) + } + + return migrated + }, + + async deviceFingerprint(device) { + return sha256Base64Url(stableJson(deviceFingerprintPayload(device))) + }, + + async signDeviceFingerprint(device, fingerprint) { + const signingKey = device.signing_private_key_crypto || await importEcdsaPrivateKey(device.signing_private_key) + const signature = await window.crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + signingKey, + textEncoder.encode(stableJson(deviceSignaturePayload(device, fingerprint))) + ) + + return bytesToBase64(signature) + }, + + deviceStorageKey() { + return `${CHAT_E2EE_STORAGE_PREFIX}:user:${this.userId()}:device` + }, + + deviceLabel() { + const userAgent = navigator.userAgent || 'browser' + return userAgent.length > 80 ? userAgent.slice(0, 80) : userAgent + }, + + async ensureDevicesTrusted(devices) { + await Promise.all(stableDevices(devices).map(device => this.ensureDeviceTrusted(device))) + }, + + untrustedDeviceMessage() { + return 'Encrypted chat was paused because a participant device key changed. Turn encrypted chat off and verify the participant before continuing.' + }, + + async ensureDeviceTrusted(device) { + const fingerprint = await this.verifiedDeviceFingerprint(device) + const key = this.deviceTrustStorageKey(device) + const trusted = await this.loadSecureJson(key) + + if (trusted?.fingerprint && trusted.fingerprint !== fingerprint) { + throw new Error('untrusted_device') + } + + if (!trusted?.fingerprint) { + await secureStorageSet(key, { + fingerprint, + device_id: device.device_id, + trusted_at: new Date().toISOString() + }) + localStorage.removeItem(key) + } + }, + + async verifiedDeviceFingerprint(device) { + const computed = await this.deviceFingerprint(device) + const advertised = device.fingerprint || computed + + if (advertised !== computed) { + throw new Error('untrusted_device') + } + + const signingKeyPayload = signingPublicKeyPayload(device) + const signaturePayload = device.device_signature + const signature = signaturePayload?.signature || signaturePayload + + if (signingKeyPayload && signature) { + const signingKey = await importEcdsaPublicKey(signingKeyPayload) + const valid = await window.crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + signingKey, + base64ToBytes(signature), + textEncoder.encode(stableJson(deviceSignaturePayload(device, advertised))) + ) + + if (!valid) { + throw new Error('untrusted_device') + } + } + + return advertised + }, + + deviceTrustStorageKey(device) { + const owner = device.recipient_handle + ? `remote:${device.origin_domain || ''}:${device.recipient_handle}` + : `local:${device.user_id || ''}` + + return `${CHAT_E2EE_STORAGE_PREFIX}:trust:${owner}:device:${device.device_id}` + }, + + handleInput(event) { + if (event.target === this.messageInput()) { + this.syncEncryptedSubmitButton() + } + + if (event.target?.name === 'query' && event.target.closest('#message-search-form')) { + this.scheduleEncryptedSearch(event.target.value) + } + }, + + pushTypingEvent(value) { + if (!value.trim()) return + + const now = Date.now() + if (now - this.lastTypingAt < 2000) return + + this.lastTypingAt = now + this.pushEvent('chat_typing', {}) + }, + + scheduleEncryptedSearch(query) { + if (this.searchTimer) { + clearTimeout(this.searchTimer) + } + + this.searchTimer = setTimeout(async () => { + if (!this.conversationId() || query.trim().length < 2) return + + const tokens = await this.searchTokensForKnownKeys(query) + if (tokens.length === 0) return + + this.pushEvent('search_messages', { query, search_tokens: tokens }) + }, 150) + }, + + async handleSubmit(event) { + const form = event.target + const textarea = this.messageInput() + + if (!form || textarea?.closest('form') !== form || !this.e2eeReady()) { + return + } + + event.preventDefault() + event.stopImmediatePropagation() + + const content = textarea.value.trim() + const submitButton = form.querySelector('button[type="submit"]') + const fileInput = form.querySelector('input[type="file"]') + const hasUploads = form.dataset.hasUploads === 'true' || (fileInput && fileInput.files.length > 0) + + if (submitButton) { + submitButton.disabled = true + } + + if (!this.e2eeReady()) { + const message = this.localDeviceSetupRequired() + ? 'Encrypted chat is still setting up this browser. Your message was not sent yet; try again when it says ready.' + : chatE2EEUnavailableTitle(this.e2eeStatus(), this.localDeviceSetupRequired()) + + this.setE2EEStatusMessage(message) + this.notifyE2EE(message, 'warning') + + if (submitButton) { + submitButton.disabled = false + } + + return + } + + if (!content || content.startsWith('/') || hasUploads) { + const message = encryptedSubmitBlockedMessage(content, hasUploads) + console.warn(message) + this.setE2EEStatusMessage(message) + this.notifyE2EE(message, 'warning') + + if (submitButton) { + submitButton.disabled = false + } + + return + } + + try { + this.setE2EEStatusMessage('Encrypting and sending message...') + const encryptedMessage = await this.encryptMessage(content) + this.cacheSentPlaintext(encryptedMessage.payload.encrypted_payload, content) + + let sendSettled = false + const sendTimeout = setTimeout(() => { + if (sendSettled) return + + sendSettled = true + this.forgetSentPlaintext(encryptedMessage.payload.encrypted_payload) + const message = 'Encrypted chat send timed out. Check your connection and try again.' + this.setE2EEStatusMessage(message) + this.notifyE2EE(message, 'error') + + if (submitButton) { + submitButton.disabled = false + } + }, 15000) + + this.pushEvent('send_client_encrypted_message', encryptedMessage.payload, reply => { + if (sendSettled) return + + sendSettled = true + clearTimeout(sendTimeout) + + if (reply?.ok) { + if (encryptedMessage.devicesHash) { + this.markPackagesSent(encryptedMessage.keyUid, encryptedMessage.devicesHash) + } + + textarea.value = '' + textarea.dispatchEvent(new Event('input', { bubbles: true })) + this.syncEncryptedSubmitButton() + this.updateStatusMessage() + } else { + this.forgetSentPlaintext(encryptedMessage.payload.encrypted_payload) + const message = encryptedSendFailureMessage(reply?.error) + console.warn('Encrypted chat send failed', reply) + this.setE2EEStatusMessage(message) + this.notifyE2EE(message, 'error') + } + + if (submitButton && !reply?.ok) { + submitButton.disabled = false + } + }) + } catch (error) { + const message = error?.message === 'untrusted_device' + ? this.untrustedDeviceMessage() + : 'Could not encrypt this message. Make sure everyone has opened this chat so their keys can register, then try again.' + console.warn('Could not encrypt chat message', error) + this.setE2EEStatusMessage(message) + this.notifyE2EE(message, 'error') + if (submitButton) { + submitButton.disabled = false + } + } + }, + + conversationKeyPreparationMessage() { + const deviceCount = this.preparingDeviceCount || this.devices().length + return conversationKeyPreparationMessage(deviceCount) + }, + + maybePrepareEnabledConversationKey() { + if (!this.e2eeReady() || this.preparedConversationKey || this.prepareConversationKeyPromise) return + + this.prepareConversationKeyPackages() + }, + + async prepareConversationKeyPackages() { + if (!this.e2eeReady()) return null + + if (this.prepareConversationKeyPromise) { + return this.prepareConversationKeyPromise + } + + const devices = this.devices() + + this.prepareConversationKeyPromise = (async () => { + await this.ensureDevicesTrusted(devices) + const { keyUid, rawKeyBytes, packagesNeeded, hash } = await this.conversationKeyForDevices(devices) + + if (!packagesNeeded) { + this.preparedConversationKey = { keyUid, hash, keyPackages: [] } + return this.preparedConversationKey + } + + if (this.preparedConversationKey?.keyUid === keyUid && this.preparedConversationKey?.hash === hash) { + return this.preparedConversationKey + } + + this.preparingDeviceCount = devices.length + this.setE2EEStatusMessage(this.conversationKeyPreparationMessage()) + this.syncEncryptedSubmitButton() + + const keyPackages = await this.wrapConversationKey(rawKeyBytes, devices) + this.preparedConversationKey = { keyUid, hash, keyPackages } + + return this.preparedConversationKey + })() + .catch(error => { + const message = error?.message === 'untrusted_device' + ? this.untrustedDeviceMessage() + : 'Could not prepare encrypted chat keys. Try turning encrypted chat off and on again.' + console.warn('Could not prepare encrypted chat keys', error) + this.setE2EEStatusMessage(message) + this.notifyE2EE(message, 'error') + return null + }) + .finally(() => { + this.prepareConversationKeyPromise = null + this.preparingDeviceCount = 0 + this.syncEncryptedSubmitButton() + this.updateStatusMessage() + }) + + return this.prepareConversationKeyPromise + }, + + async encryptMessage(content) { + const devices = this.devices() + await this.ensureDevicesTrusted(devices) + const { keyUid, rawKeyBytes, packagesNeeded, hash } = await this.conversationKeyForDevices(devices) + const aesKey = await importAesKey(rawKeyBytes, ['encrypt']) + const iv = randomBytes(12) + const ciphertext = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + aesKey, + textEncoder.encode(content) + ) + + let keyPackages = [] + + if (packagesNeeded) { + let prepared = this.preparedConversationKey + + if (this.prepareConversationKeyPromise) { + prepared = await this.prepareConversationKeyPromise + } + + if (prepared?.keyUid === keyUid && prepared?.hash === hash) { + keyPackages = prepared.keyPackages + } else { + this.preparingDeviceCount = devices.length + this.setE2EEStatusMessage(this.conversationKeyPreparationMessage()) + + try { + keyPackages = await this.wrapConversationKey(rawKeyBytes, devices) + this.preparedConversationKey = { keyUid, hash, keyPackages } + } finally { + this.preparingDeviceCount = 0 + } + } + } + + const localKeyPackages = keyPackages.filter(keyPackage => Number.isInteger(keyPackage.user_id)) + const federatedKeyPackages = keyPackages.filter(keyPackage => keyPackage.recipient_handle) + + const payload = { + version: 1, + content_algorithm: 'AES-256-GCM', + key_uid: keyUid, + iv: bytesToBase64(iv), + ciphertext: bytesToBase64(ciphertext) + } + + if (federatedKeyPackages.length > 0) { + payload.federated_key_packages = federatedKeyPackages + } + + if (localKeyPackages.length > 0) { + payload.local_key_packages = localKeyPackages + } + + return { + keyUid, + devicesHash: packagesNeeded ? hash : null, + payload: { + encrypted_payload: payload, + key_packages: localKeyPackages, + search_index: await this.searchTokensForKey(rawKeyBytes, content) + } + } + }, + + async conversationKeyForDevices(devices) { + const conversationId = this.conversationId() + const hash = await devicesHash(devices) + const activeKey = await this.loadSecureJson(this.activeKeyStorageKey(conversationId)) + + if (activeKey?.key_uid && activeKey.devices_hash === hash) { + const rawKeyBytes = await this.loadRawConversationKey(conversationId, activeKey.key_uid) + + if (rawKeyBytes) { + return { + keyUid: activeKey.key_uid, + rawKeyBytes, + packagesNeeded: activeKey.packages_sent_hash !== hash, + hash + } + } + } + + const keyUid = randomId('key-') + const rawKeyBytes = randomBytes(32) + await this.storeRawConversationKey(conversationId, keyUid, rawKeyBytes) + await this.storeActiveConversationKey(conversationId, keyUid, hash, null) + + return { keyUid, rawKeyBytes, packagesNeeded: true, hash } + }, + + async wrapConversationKey(rawKeyBytes, devices) { + return Promise.all(stableDevices(devices).map(async device => { + const publicKey = await importRsaPublicKey(device.public_key) + const encryptedKey = await window.crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + publicKey, + arrayBufferFromBytes(rawKeyBytes) + ) + + return { + ...(Number.isInteger(device.user_id) ? { user_id: device.user_id } : {}), + ...(device.recipient_handle ? { recipient_handle: device.recipient_handle } : {}), + ...(device.origin_domain ? { origin_domain: device.origin_domain } : {}), + device_id: device.device_id, + wrapped_key: { + version: 1, + key_algorithm: 'RSA-OAEP-SHA256', + encrypted_key: bytesToBase64(encryptedKey) + } + } + })) + }, + + async searchTokensForKnownKeys(query) { + const conversationId = this.conversationId() + if (!conversationId) return [] + + const keyUids = (await this.loadSecureJson(this.keyListStorageKey(conversationId))) || [] + const tokenSets = await Promise.all(keyUids.map(async keyUid => { + const rawKeyBytes = await this.loadRawConversationKey(conversationId, keyUid) + return rawKeyBytes ? this.searchTokensForKey(rawKeyBytes, query) : [] + })) + + return Array.from(new Set(tokenSets.flat())) + }, + + async searchTokensForKey(rawKeyBytes, text) { + const keywords = extractSearchKeywords(text) + if (keywords.length === 0) return [] + + const hmacKey = await importHmacKey(rawKeyBytes) + const tokens = await Promise.all(keywords.map(async keyword => { + const digest = await window.crypto.subtle.sign( + 'HMAC', + hmacKey, + textEncoder.encode(`chat-search:v1:${keyword}`) + ) + + return bytesToBase64(digest) + })) + + return Array.from(new Set(tokens)) + }, + + decryptVisibleMessages() { + if (!cryptoAvailable() || !this.conversationId()) return + + this.el + .querySelectorAll('[data-chat-encrypted-message="true"], [data-chat-encrypted-preview="true"]') + .forEach(element => { + if (element.dataset.decrypted === 'true' || element.dataset.decrypted === 'failed') return + if (element.dataset.decrypting === 'true') return + + element.dataset.decrypting = 'true' + this.decryptMessageElement(element).finally(() => { + delete element.dataset.decrypting + }) + }) + }, + + async decryptMessageElement(element) { + const payload = parseJson(element.dataset.payload, null) + const conversationId = Number(element.dataset.conversationId || this.conversationId()) + const keyUid = payload?.key_uid || element.dataset.keyUid + + const sentPlaintext = this.sentPlaintextForPayload(payload) + if (sentPlaintext) { + element.textContent = sentPlaintext + this.showEncryptedMessageElement(element) + element.dataset.decrypted = 'true' + return + } + + if (!payload || !conversationId || !keyUid) { + this.showEncryptedMessageElement(element) + return + } + + try { + const rawKeyBytes = await this.rawConversationKey(conversationId, keyUid, payload) + const aesKey = await importAesKey(rawKeyBytes, ['decrypt']) + const plaintext = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: base64ToBytes(payload.iv) }, + aesKey, + arrayBufferFromBytes(base64ToBytes(payload.ciphertext)) + ) + + element.textContent = textDecoder.decode(plaintext) + this.showEncryptedMessageElement(element) + element.dataset.decrypted = 'true' + } catch (error) { + if (error?.code !== 'key_not_found' && error?.message !== 'key_not_found') { + console.warn('Could not decrypt chat message', error) + } + + element.textContent = 'Encrypted message' + this.showEncryptedMessageElement(element) + element.dataset.decrypted = 'failed' + } + }, + + showEncryptedMessageElement(element) { + element.classList.remove('opacity-0', 'opacity-80') + }, + + sentPlaintextCacheKey(payload) { + if (!payload?.key_uid || !payload?.ciphertext) return null + return `${payload.key_uid}:${payload.ciphertext}` + }, + + cacheSentPlaintext(payload, plaintext) { + const key = this.sentPlaintextCacheKey(payload) + if (!key || !plaintext) return + + this.sentPlaintexts.set(key, plaintext) + + if (this.sentPlaintexts.size > 50) { + this.sentPlaintexts.delete(this.sentPlaintexts.keys().next().value) + } + + setTimeout(() => this.sentPlaintexts.delete(key), 5 * 60 * 1000) + }, + + forgetSentPlaintext(payload) { + const key = this.sentPlaintextCacheKey(payload) + if (key) this.sentPlaintexts.delete(key) + }, + + sentPlaintextForPayload(payload) { + const key = this.sentPlaintextCacheKey(payload) + return key ? this.sentPlaintexts.get(key) : null + }, + + async rawConversationKey(conversationId, keyUid, payload = null) { + const stored = await this.loadRawConversationKey(conversationId, keyUid) + if (stored) return stored + + const cacheKey = `${conversationId}:${keyUid}` + if (this.unavailableConversationKeys.has(cacheKey)) { + throw new Error('key_not_found') + } + + const pendingRequest = this.conversationKeyRequests.get(cacheKey) + if (pendingRequest) return pendingRequest + + const request = (async () => { + const device = await this.loadOrCreateDevice() + const inlinePackage = this.inlineKeyPackageForDevice(payload, device.device_id) + + if (inlinePackage?.wrapped_key?.encrypted_key) { + const rawKeyBytes = await this.decryptWrappedConversationKey(device, inlinePackage.wrapped_key) + await this.storeRawConversationKey(conversationId, keyUid, rawKeyBytes) + return rawKeyBytes + } + + const wrappedKey = await new Promise((resolve, reject) => { + this.pushEvent( + 'chat_e2ee_key', + { + conversation_id: conversationId, + device_id: device.device_id, + key_uid: keyUid + }, + reply => { + if (reply?.ok && reply.wrapped_key) { + resolve(reply.wrapped_key) + } else { + const error = new Error(reply?.error || 'Wrapped key not found') + error.code = reply?.error + reject(error) + } + } + ) + }) + + const rawKeyBytes = await this.decryptWrappedConversationKey(device, wrappedKey) + + await this.storeRawConversationKey(conversationId, keyUid, rawKeyBytes) + + const hash = await devicesHash(this.devices()) + await this.storeActiveConversationKey(conversationId, keyUid, hash, hash) + + return rawKeyBytes + })() + .catch(error => { + if (error?.code === 'key_not_found' || error?.message === 'key_not_found') { + this.unavailableConversationKeys.add(cacheKey) + } + + throw error + }) + .finally(() => { + this.conversationKeyRequests.delete(cacheKey) + }) + + this.conversationKeyRequests.set(cacheKey, request) + return request + }, + + inlineKeyPackageForDevice(payload, deviceId) { + const packages = [ + ...(payload?.local_key_packages || payload?.localKeyPackages || []), + ...(payload?.key_packages || payload?.keyPackages || []), + ...(payload?.federated_key_packages || payload?.federatedKeyPackages || []) + ] + + return packages.find(keyPackage => keyPackage?.device_id === deviceId) + }, + + async decryptWrappedConversationKey(device, wrappedKey) { + const privateKey = device.private_key_crypto || await importRsaPrivateKey(device.private_key) + const rawKey = await window.crypto.subtle.decrypt( + { name: 'RSA-OAEP' }, + privateKey, + arrayBufferFromBytes(base64ToBytes(wrappedKey.encrypted_key)) + ) + + return new Uint8Array(rawKey) + }, + + async loadRawConversationKey(conversationId, keyUid) { + const storageKey = this.rawKeyStorageKey(conversationId, keyUid) + const cacheKey = this.rawConversationKeyCacheKey(conversationId, keyUid) + + if (!this.rawConversationKeyCache) this.rawConversationKeyCache = new Map() + if (this.rawConversationKeyCache.has(cacheKey)) return this.rawConversationKeyCache.get(cacheKey) + + let key = null + + try { + key = await secureStorageGet(storageKey) + } catch (_error) { + key = null + } + + if (key) { + const bytes = base64ToBytes(key) + this.rawConversationKeyCache.set(cacheKey, bytes) + await secureStorageDelete(storageKey) + return bytes + } + + const storedKey = localStorage.getItem(storageKey) + if (storedKey) { + const bytes = base64ToBytes(storedKey) + localStorage.removeItem(storageKey) + this.rawConversationKeyCache.set(cacheKey, bytes) + return bytes + } + + return null + }, + + async storeRawConversationKey(conversationId, keyUid, rawKeyBytes) { + if (!this.rawConversationKeyCache) this.rawConversationKeyCache = new Map() + this.rawConversationKeyCache.set(this.rawConversationKeyCacheKey(conversationId, keyUid), rawKeyBytes) + + await secureStorageDelete(this.rawKeyStorageKey(conversationId, keyUid)) + localStorage.removeItem(this.rawKeyStorageKey(conversationId, keyUid)) + + const listKey = this.keyListStorageKey(conversationId) + const keyUids = (await this.loadSecureJson(listKey)) || [] + if (!keyUids.includes(keyUid)) { + await secureStorageSet(listKey, [...keyUids, keyUid]) + localStorage.removeItem(listKey) + } + }, + + async storeActiveConversationKey(conversationId, keyUid, devicesHashValue, packagesSentHash) { + const key = this.activeKeyStorageKey(conversationId) + await secureStorageSet(key, { + key_uid: keyUid, + devices_hash: devicesHashValue, + packages_sent_hash: packagesSentHash + }) + localStorage.removeItem(key) + }, + + markPackagesSent(keyUid, devicesHashValue) { + const conversationId = this.conversationId() + if (!conversationId) return + + this.storeActiveConversationKey(conversationId, keyUid, devicesHashValue, devicesHashValue).catch(() => null) + }, + + rawKeyStorageKey(conversationId, keyUid) { + return `${CHAT_E2EE_STORAGE_PREFIX}:user:${this.userId()}:conversation:${conversationId}:key:${keyUid}` + }, + + rawConversationKeyCacheKey(conversationId, keyUid) { + return `${conversationId}:${keyUid}` + }, + + activeKeyStorageKey(conversationId) { + return `${CHAT_E2EE_STORAGE_PREFIX}:user:${this.userId()}:conversation:${conversationId}:active-key` + }, + + keyListStorageKey(conversationId) { + return `${CHAT_E2EE_STORAGE_PREFIX}:user:${this.userId()}:conversation:${conversationId}:keys` + }, + + enabledStorageKey(conversationId) { + return `${CHAT_E2EE_STORAGE_PREFIX}:user:${this.userId()}:conversation:${conversationId}:enabled` + } +} diff --git a/apps/elektrine/assets/js/hooks/chat_e2ee_messages.js b/apps/elektrine/assets/js/hooks/chat_e2ee_messages.js new file mode 100644 index 0000000..d71656c --- /dev/null +++ b/apps/elektrine/assets/js/hooks/chat_e2ee_messages.js @@ -0,0 +1,92 @@ +export function waitingForMemberKeysMessage(memberIds, devices) { + const deviceUserIds = new Set( + devices + .map(device => Number(device.user_id)) + .filter(id => Number.isInteger(id) && id > 0) + ) + const missingCount = memberIds.filter(id => !deviceUserIds.has(id)).length + + if (missingCount <= 1) { + return 'Waiting for one active member to register encryption keys. They need to open this chat once.' + } + + return `Waiting for ${missingCount} active members to register encryption keys. They need to open this chat once.` +} + +export function chatE2EEUnavailableLabel(status, setupRequired) { + if (setupRequired) { + return 'Setting up this browser' + } + + switch (status) { + case 'registering_device': + return 'Registering this browser' + case 'waiting_for_remote_keys': + return 'Waiting for remote keys' + case 'waiting_for_member_keys': + return 'Waiting for member keys' + case 'too_many_devices': + return 'Too many devices' + case 'not_applicable': + return 'Encrypted chat not supported here' + default: + return 'Encrypted chat not ready' + } +} + +export function chatE2EEUnavailableTitle(status, setupRequired) { + if (setupRequired) { + return 'This browser is still registering its own encryption keys. Encrypted sending will unlock automatically.' + } + + switch (status) { + case 'registering_device': + return 'This browser is registering an encryption device. Try again in a moment.' + case 'waiting_for_remote_keys': + return 'The remote participant has not advertised compatible chat encryption keys yet.' + case 'waiting_for_member_keys': + return 'Every active member needs at least one registered chat encryption device.' + case 'too_many_devices': + return 'This conversation has too many devices for the simple E2EE mode.' + case 'not_applicable': + return 'Optional client-side E2EE is currently supported for DMs and groups.' + default: + return 'Encrypted chat is not ready for this conversation yet.' + } +} + +export function encryptedSubmitBlockedMessage(content, hasUploads) { + if (!content) { + return 'Type a message before sending encrypted chat.' + } + + if (hasUploads) { + return 'Encrypted chat only supports plain text right now. Remove attachments or turn encrypted chat off.' + } + + return 'Encrypted chat only supports plain text right now. Turn it off to use commands.' +} + +export function encryptedSendFailureMessage(error) { + switch (error) { + case 'missing_key_packages': + return 'Encrypted chat keys were not ready. Wait for the chat to finish key setup, then try again.' + case 'invalid_key_recipient': + return 'Encrypted chat keys changed while sending. Wait for the chat to resync, then try again.' + case 'invalid_key_package': + case 'invalid_encrypted_payload': + return 'This encrypted message could not be sent because its encrypted payload was invalid.' + case 'no_conversation': + return 'Select a chat before sending an encrypted message.' + case 'blocked': + return 'This message was not sent because one of you has blocked the other.' + case 'privacy_restricted': + return 'This message was not sent because this user is not accepting direct messages.' + default: + return 'Encrypted message could not be sent. Make sure everyone has opened this chat so their keys can register, then try again.' + } +} + +export function conversationKeyPreparationMessage(deviceCount) { + return `Preparing encryption keys for ${deviceCount} device${deviceCount === 1 ? '' : 's'}...` +} diff --git a/apps/elektrine/assets/js/hooks/chat_hooks.js b/apps/elektrine/assets/js/hooks/chat_hooks.js new file mode 100644 index 0000000..740b524 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/chat_hooks.js @@ -0,0 +1,625 @@ +// Chat-specific LiveView hooks + +import { submitFormPreservingEvents } from "../utils/form_submission" + +// Enter-to-submit for a composer textarea. +// +// Sizing is not here: `.textarea-autosize` (field-sizing: content) grows the +// element, so nothing in this hook measures or writes a height. +export const AutoExpandTextarea = { + mounted() { + this.timeouts = [] + this.sendingMessage = false + this.lastSentContent = "" + this.lastSentTime = 0 + this.lastLiveUpdateValue = null + this.liveUpdateEvent = this.el.dataset.liveUpdateEvent + this.submitOnEnter = this.el.dataset.submitOnEnter !== 'false' + + const queueTimeout = (callback, delay) => { + const timer = setTimeout(() => { + this.timeouts = this.timeouts.filter((timeout) => timeout !== timer) + callback() + }, delay) + + this.timeouts.push(timer) + return timer + } + + this.queueTimeout = queueTimeout + + const handleInput = () => { + if (this.liveUpdateEvent && this.el.value !== this.lastLiveUpdateValue) { + this.lastLiveUpdateValue = this.el.value + this.pushEvent(this.liveUpdateEvent, { value: this.el.value }) + } + } + + this.handleInput = handleInput + + this.el.addEventListener('input', handleInput) + this.el.addEventListener('change', handleInput) + this.onPaste = () => queueTimeout(handleInput, 10) + this.el.addEventListener('paste', this.onPaste) + + // Handle keyboard shortcuts + this.onKeyDown = (e) => { + // Shift+Enter inserts a newline, and Enter does too where the composer + // opted out of submit-on-enter. Both are the browser's default. + if (e.key !== "Enter" || e.shiftKey || !this.submitOnEnter) return + + // Send message on Enter (without Shift) + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + + const content = this.el.value.trim() + const now = Date.now() + + // Enhanced duplicate prevention + if (this.sendingMessage || + !content || + (content === this.lastSentContent && (now - this.lastSentTime) < 500)) { + return + } + + const form = this.el.closest("form") + if (form) { + this.sendingMessage = true + this.lastSentContent = content + this.lastSentTime = now + + const submitBtn = form.querySelector('button[type="submit"]') + submitFormPreservingEvents(form, submitBtn?.disabled ? null : submitBtn) + + // Disable the submit button temporarily after LiveView receives the submit. + if (submitBtn) { + submitBtn.disabled = true + } + + // Reset after submit + queueTimeout(() => { + this.el.value = "" + this.sendingMessage = false + if (submitBtn) { + submitBtn.disabled = false + } + }, 100) + } + } + } + + this.el.addEventListener("keydown", this.onKeyDown) + + const reenableSubmit = () => { + const submitBtn = this.el + .closest("form") + ?.querySelector('button[type="submit"]') + + if (submitBtn) submitBtn.disabled = false + } + + // Handle clear message input event + this.handleEvent("clear_message_input", () => { + this.sendingMessage = false + this.el.value = "" + this.el.focus() + reenableSubmit() + }) + + // Handle reset textarea event (for explicit resets) + this.handleEvent("reset_textarea", ({id}) => { + if (this.el.id !== id) return + + this.sendingMessage = false + this.el.value = "" + reenableSubmit() + }) + + // Listen for form submit to clear the textarea + const form = this.el.closest("form") + if (form) { + this.form = form + this.onFormSubmit = () => { + queueTimeout(() => { + this.el.value = "" + }, 100) + } + + form.addEventListener('submit', this.onFormSubmit) + } + }, + + updated() { + // Reset sending state + this.sendingMessage = false + }, + + destroyed() { + if (this.handleInput) { + this.el.removeEventListener('input', this.handleInput) + this.el.removeEventListener('change', this.handleInput) + } + if (this.onPaste) this.el.removeEventListener('paste', this.onPaste) + if (this.onKeyDown) this.el.removeEventListener('keydown', this.onKeyDown) + if (this.form && this.onFormSubmit) this.form.removeEventListener('submit', this.onFormSubmit) + + ;(this.timeouts || []).forEach((timer) => clearTimeout(timer)) + this.timeouts = [] + } +} + +// Enter-to-submit and draft preservation for the chat composer. +// +// Sizing is `.textarea-autosize` (field-sizing: content), so this hook no +// longer measures scrollHeight or writes a height on every keystroke. +export const SimpleChatInput = { + mounted() { + this.form = this.el.closest("form") + this.awaitingSubmitClear = false + this.valueBeforeUpdate = "" + this.wasFocusedBeforeUpdate = false + + this.el.rows = 1 + + this.handleInput = () => { + if (this.awaitingSubmitClear) { + this.awaitingSubmitClear = false + } + } + + this.el.addEventListener("input", this.handleInput) + + // Enter key handling + this.handleKeydown = (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + if (this.form && this.el.value.trim()) { + this.awaitingSubmitClear = true + const submitter = this.form.querySelector('button[type="submit"]') + submitFormPreservingEvents(this.form, submitter?.disabled ? null : submitter) + } + } + } + + this.el.addEventListener("keydown", this.handleKeydown) + + if (this.form) { + this.handleFormSubmit = () => { + this.awaitingSubmitClear = true + } + + this.form.addEventListener("submit", this.handleFormSubmit) + } + + this.handleEvent("clear_message_input", () => { + this.awaitingSubmitClear = false + this.el.value = "" + this.el.focus() + }) + + this.focusComposer = () => { + const activeElement = document.activeElement + const activeTag = activeElement?.tagName + const activeIsTypingTarget = activeTag === "INPUT" || activeTag === "TEXTAREA" || activeTag === "SELECT" + const overlayActive = + document.getElementById("chat-keyboard-shortcuts")?.dataset.activeOverlay === "true" + + if ( + window.matchMedia("(min-width: 640px)").matches && + !activeIsTypingTarget && + !overlayActive + ) { + this.el.focus({ preventScroll: true }) + } + } + + setTimeout(this.focusComposer, 0) + }, + + beforeUpdate() { + this.valueBeforeUpdate = this.el.value + this.wasFocusedBeforeUpdate = document.activeElement === this.el + }, + + updated() { + if (this.awaitingSubmitClear) { + if (this.el.value === "") { + this.awaitingSubmitClear = false + } + + return + } + + // Keep local draft if an unrelated patch tries to shorten it while typing. + if ( + this.wasFocusedBeforeUpdate && + this.valueBeforeUpdate && + this.el.value.length < this.valueBeforeUpdate.length + ) { + const cursorPosition = this.el.selectionStart + this.el.value = this.valueBeforeUpdate + + if (cursorPosition !== null) { + const safePosition = Math.min(cursorPosition, this.el.value.length) + this.el.setSelectionRange(safePosition, safePosition) + } + } + }, + + destroyed() { + if (this.handleInput) { + this.el.removeEventListener("input", this.handleInput) + } + + if (this.handleKeydown) { + this.el.removeEventListener("keydown", this.handleKeydown) + } + + if (this.form && this.handleFormSubmit) { + this.form.removeEventListener("submit", this.handleFormSubmit) + } + } +} + +export const ChatKeyboardShortcuts = { + mounted() { + this.handleKeydown = (event) => { + if (event.key !== "Escape" || event.defaultPrevented) return + if (this.el.dataset.activeOverlay !== "true") return + + event.preventDefault() + this.pushEvent("close_chat_overlay", {}) + } + + document.addEventListener("keydown", this.handleKeydown) + }, + + destroyed() { + if (this.handleKeydown) { + document.removeEventListener("keydown", this.handleKeydown) + } + } +} + +export const MessageList = { + mounted() { + const container = this.el + this.isLoadingOlder = false + this.initialScrollDone = false + this.isRestoringScroll = false + this.currentConversationId = container.dataset.conversationId + this.scrollPositions = window.elektrineChatScrollPositions || new Map() + window.elektrineChatScrollPositions = this.scrollPositions + + // Check if user is near bottom (within 150px) + const isNearBottom = () => { + return container.scrollHeight - container.scrollTop - container.clientHeight < 150 + } + + const maxScrollTop = () => Math.max(0, container.scrollHeight - container.clientHeight) + + const saveCurrentScrollPosition = () => { + if (!this.currentConversationId) return + if (this.isRestoringScroll) return + + this.scrollPositions.set(String(this.currentConversationId), { + top: container.scrollTop, + atBottom: isNearBottom() + }) + } + + // Smoothly scroll to bottom + const scrollToBottom = (behavior = 'smooth') => { + requestAnimationFrame(() => { + container.scrollTo({ + top: container.scrollHeight, + behavior: behavior + }) + saveCurrentScrollPosition() + }) + } + + const scrollElementInContainer = (element, block = 'center', behavior = 'smooth') => { + const containerRect = container.getBoundingClientRect() + const elementRect = element.getBoundingClientRect() + const elementTop = elementRect.top - containerRect.top + container.scrollTop + let targetTop + + if (block === 'top-third') { + targetTop = elementTop - container.clientHeight / 3 + } else if (block === 'start') { + targetTop = elementTop + } else { + targetTop = elementTop - container.clientHeight / 2 + elementRect.height / 2 + } + + container.scrollTo({ + top: Math.max(0, Math.min(targetTop, maxScrollTop())), + behavior + }) + saveCurrentScrollPosition() + } + + this.restoreConversationScroll = (conversationId = this.currentConversationId) => { + const savedPosition = conversationId && this.scrollPositions.get(String(conversationId)) + const restoreToken = Symbol('restore-scroll') + this.restoreToken = restoreToken + this.isRestoringScroll = true + + const restore = () => { + if (savedPosition && !savedPosition.atBottom) { + container.scrollTo({ + top: Math.min(savedPosition.top, maxScrollTop()), + behavior: 'auto' + }) + saveCurrentScrollPosition() + } else { + scrollToBottom('auto') + } + } + + restore() + + ;[50, 120, 250, 500].forEach(delay => { + setTimeout(restore, delay) + }) + + setTimeout(() => { + if (this.restoreToken !== restoreToken) return + + this.isRestoringScroll = false + this.initialScrollDone = true + saveCurrentScrollPosition() + }, 600) + } + + // Show/hide "jump to bottom" button when scrolled up + const updateJumpButton = () => { + const hasScrolledUp = !isNearBottom() + const jumpBtn = document.getElementById('jump-to-bottom') + if (jumpBtn) { + if (hasScrolledUp && this.initialScrollDone) { + jumpBtn.classList.remove('hidden') + } else { + jumpBtn.classList.add('hidden') + } + } + } + + // Function to check scroll position and load more messages + const checkScrollPosition = () => { + const scrollTop = container.scrollTop + + // Update jump button visibility + updateJumpButton() + + // Load older messages when scrolled near top (within 100px) + if (scrollTop < 100 && !this.isLoadingOlder) { + this.isLoadingOlder = true + this.pushEvent("load_older_messages", {}) + } + + // Reset loading flag when scroll position changes + if (scrollTop > 200) { + this.isLoadingOlder = false + } + } + + // Add scroll event listener with debouncing + let scrollTimeout + container.addEventListener('scroll', () => { + saveCurrentScrollPosition() + clearTimeout(scrollTimeout) + scrollTimeout = setTimeout(checkScrollPosition, 100) + }) + + // Handle maintaining scroll position after loading older messages + this.handleEvent("maintain_scroll_position", () => { + const prevHeight = container.scrollHeight + requestAnimationFrame(() => { + const newHeight = container.scrollHeight + const heightDiff = newHeight - prevHeight + container.scrollTop += heightDiff + this.isLoadingOlder = false + }) + }) + + // Handle scroll to specific message + this.handleEvent("scroll_to_message", ({message_id}) => { + const messageEl = document.getElementById(`message-${message_id}`) + if (messageEl) { + scrollElementInContainer(messageEl, 'center', 'smooth') + messageEl.classList.add('highlight-message') + setTimeout(() => messageEl.classList.remove('highlight-message'), 2000) + this.initialScrollDone = true + } + }) + + // Handle image loading - re-scroll if user is at bottom + let pendingImageLoads = 0 + + const handleImageLoad = () => { + pendingImageLoads = Math.max(0, pendingImageLoads - 1) + + // Only re-scroll if user is still near bottom and initial scroll is done + if (this.initialScrollDone && isNearBottom()) { + scrollToBottom('auto') + + // Do one more scroll after a delay to catch any dimension changes + setTimeout(() => { + if (isNearBottom()) { + scrollToBottom('auto') + } + }, 100) + } + } + + const imagesFromNode = (node) => { + if (node.nodeType !== Node.ELEMENT_NODE) return [] + + const images = node.tagName === 'IMG' ? [node] : [] + return images.concat(Array.from(node.querySelectorAll?.('img') || [])) + } + + // Add listeners only within the changed subtree instead of rescanning the full chat. + const addImageListeners = (root = container) => { + const images = root === container ? container.querySelectorAll('img') : imagesFromNode(root) + images.forEach(img => { + if (!img.dataset.listenerAdded) { + img.dataset.listenerAdded = 'true' + + if (!img.complete) { + pendingImageLoads++ + img.addEventListener('load', handleImageLoad, { once: true }) + img.addEventListener('error', handleImageLoad, { once: true }) + } + } + }) + } + + // Initial image listener setup + addImageListeners() + + // Scroll to the latest messages on initial load. + // Server-driven unread scrolling can still override this afterward. + if (this.currentConversationId) { + this.restoreConversationScroll() + } + + // Add image listeners when new content is added + const imageObserver = new MutationObserver((mutations) => { + mutations.forEach(mutation => { + mutation.addedNodes.forEach(node => addImageListeners(node)) + }) + }) + + imageObserver.observe(container, { + childList: true, + subtree: true + }) + + this.imageObserver = imageObserver + + this.handleEvent("restore_conversation_scroll", ({conversation_id}) => { + if (String(conversation_id) === String(this.currentConversationId)) { + this.initialScrollDone = false + this.restoreConversationScroll(conversation_id) + } + }) + + // Handle scroll to bottom (server controls initial scroll) + this.handleEvent("scroll_to_bottom", () => { + // Aggressive initial scroll to handle images + const doScroll = () => { + const wasNearBottom = isNearBottom() + scrollToBottom('auto') + return wasNearBottom + } + + // Immediate scroll + doScroll() + + // Re-scroll multiple times to catch images loading + // This is necessary because images load asynchronously + const scrollAttempts = [50, 100, 200, 300, 500, 800, 1200, 1800] + scrollAttempts.forEach(delay => { + setTimeout(() => { + // Only keep scrolling if we were at/near bottom + // This prevents fighting with user if they scrolled up + if (isNearBottom()) { + scrollToBottom('auto') + } + }, delay) + }) + + // Mark done after all attempts + setTimeout(() => { + this.initialScrollDone = true + }, 2000) + }) + + // Handle scroll to element (for unread indicator) + this.handleEvent("scroll_to_element", ({element_id, position}) => { + setTimeout(() => { + const element = document.getElementById(element_id) + if (element) { + const block = position === 'top-third' ? 'center' : 'start' + scrollElementInContainer(element, position === 'top-third' ? 'top-third' : block, 'smooth') + setTimeout(() => { + this.initialScrollDone = true + }, 500) + } + }, 100) + }) + + // Watch for new messages being added (after initial scroll) + const observer = new MutationObserver((mutations) => { + // Only process after initial scroll is complete + if (!this.initialScrollDone) return + + // Check if new messages were added (not just timestamp updates) + const hasNewMessages = mutations.some(mutation => { + return Array.from(mutation.addedNodes).some(node => { + if (node.nodeType !== 1) return false + + // Check if this is an actual message element + if (node.id?.startsWith('message-') && !node.id?.includes('bubble') && !node.id?.includes('local-time')) { + return true + } + + // Check for containers with message children + const hasMessageChild = node.querySelector?.('[id^="message-"]:not([id*="bubble"]):not([id*="local-time"])') + return hasMessageChild ? true : false + }) + }) + + if (hasNewMessages) { + // Add image listeners to any new images + mutations.forEach(mutation => { + mutation.addedNodes.forEach(node => addImageListeners(node)) + }) + + // Only auto-scroll if user is near bottom (standard messenger UX) + if (isNearBottom()) { + requestAnimationFrame(() => { + scrollToBottom('smooth') + }) + } else { + // User scrolled up - show jump button instead + updateJumpButton() + } + } + }) + + observer.observe(container, { + childList: true, + subtree: true + }) + + this.observer = observer + }, + + updated() { + // When conversation changes (detected by data-conversation-id change) + const newConversationId = this.el.dataset.conversationId + if (newConversationId && newConversationId !== this.currentConversationId) { + // Conversation switched - reset state + this.currentConversationId = newConversationId + this.initialScrollDone = false + this.isLoadingOlder = false + + // The imageObserver will automatically catch new images in the new conversation + // No need to manually call addImageListeners - MutationObserver handles it + this.restoreConversationScroll(newConversationId) + } + }, + + destroyed() { + if (this.observer) { + this.observer.disconnect() + } + if (this.imageObserver) { + this.imageObserver.disconnect() + } + } +} diff --git a/apps/elektrine/assets/js/hooks/chat_voice_recorder_hook.js b/apps/elektrine/assets/js/hooks/chat_voice_recorder_hook.js new file mode 100644 index 0000000..6b3e4f2 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/chat_voice_recorder_hook.js @@ -0,0 +1,167 @@ +export const VoiceRecorder = { + mounted() { + this.mediaRecorder = null + this.audioChunks = [] + this.isRecording = false + this.recordingTimer = null + this.recordingSeconds = 0 + this.maxDuration = 120 + + const recordBtn = this.el + const timerEl = document.getElementById('voice-timer') + const cancelBtn = document.getElementById('voice-cancel') + const sendBtn = document.getElementById('voice-send') + const recordingIndicator = document.getElementById('voice-recording-indicator') + this.cancelBtn = cancelBtn + this.sendBtn = sendBtn + + const updateUI = (recording) => { + if (recordingIndicator) { + recordingIndicator.classList.toggle('hidden', !recording) + } + recordBtn.classList.toggle('text-error', recording) + recordBtn.classList.toggle('animate-pulse', recording) + } + + const formatTime = (seconds) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + this.audioChunks = [] + + const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4' + this.mediaRecorder = new MediaRecorder(stream, { mimeType }) + + this.mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + this.audioChunks.push(event.data) + } + } + + this.mediaRecorder.onstop = () => { + stream.getTracks().forEach((track) => track.stop()) + } + + this.mediaRecorder.start(100) + this.isRecording = true + this.recordingSeconds = 0 + updateUI(true) + + this.recordingTimer = setInterval(() => { + this.recordingSeconds++ + if (timerEl) { + timerEl.textContent = formatTime(this.recordingSeconds) + } + + if (this.recordingSeconds >= this.maxDuration) { + sendRecording() + } + }, 1000) + } catch (_err) { + this.pushEvent('voice_recording_error', { error: 'Microphone access denied' }) + } + } + + const stopRecording = () => { + if (this.mediaRecorder && this.isRecording) { + this.mediaRecorder.stop() + this.isRecording = false + clearInterval(this.recordingTimer) + updateUI(false) + if (timerEl) timerEl.textContent = '0:00' + } + } + + const cancelRecording = () => { + stopRecording() + this.audioChunks = [] + this.recordingSeconds = 0 + } + + const sendRecording = async () => { + if (!this.isRecording && this.audioChunks.length === 0) return + + if (this.isRecording) { + this.mediaRecorder.stop() + this.isRecording = false + clearInterval(this.recordingTimer) + updateUI(false) + + await new Promise((resolve) => setTimeout(resolve, 100)) + } + + if (this.audioChunks.length === 0) return + + const mimeType = this.mediaRecorder?.mimeType || 'audio/webm' + const audioBlob = new Blob(this.audioChunks, { type: mimeType }) + const duration = this.recordingSeconds + const reader = new FileReader() + + reader.onload = () => { + const base64 = reader.result.split(',')[1] + this.pushEvent('send_voice_message', { + audio_data: base64, + duration: duration, + mime_type: mimeType + }) + } + + reader.readAsDataURL(audioBlob) + this.audioChunks = [] + this.recordingSeconds = 0 + if (timerEl) timerEl.textContent = '0:00' + } + + this.recordClickHandler = () => { + if (this.isRecording) { + sendRecording() + } else { + startRecording() + } + } + recordBtn.addEventListener('click', this.recordClickHandler) + + if (cancelBtn) { + this.cancelClickHandler = cancelRecording + cancelBtn.addEventListener('click', this.cancelClickHandler) + } + + if (sendBtn) { + this.sendClickHandler = sendRecording + sendBtn.addEventListener('click', this.sendClickHandler) + } + + this.escapeKeyHandler = (event) => { + if (event.key === 'Escape' && this.isRecording) { + cancelRecording() + } + } + document.addEventListener('keydown', this.escapeKeyHandler) + }, + + destroyed() { + if (this.recordClickHandler) { + this.el.removeEventListener('click', this.recordClickHandler) + } + if (this.cancelBtn && this.cancelClickHandler) { + this.cancelBtn.removeEventListener('click', this.cancelClickHandler) + } + if (this.sendBtn && this.sendClickHandler) { + this.sendBtn.removeEventListener('click', this.sendClickHandler) + } + if (this.escapeKeyHandler) { + document.removeEventListener('keydown', this.escapeKeyHandler) + } + if (this.recordingTimer) { + clearInterval(this.recordingTimer) + } + if (this.mediaRecorder && this.isRecording) { + this.mediaRecorder.stop() + } + } +} diff --git a/apps/elektrine/assets/js/hooks/email_compose_shortcuts_hook.js b/apps/elektrine/assets/js/hooks/email_compose_shortcuts_hook.js new file mode 100644 index 0000000..56dac7a --- /dev/null +++ b/apps/elektrine/assets/js/hooks/email_compose_shortcuts_hook.js @@ -0,0 +1,264 @@ +import { + closeExistingShortcutModals, + isActiveEmailShortcutHelpOwner, + isEditableTarget, + registerEmailShortcutHelp, +} from "./email_shortcut_helpers" + +// Keyboard shortcuts for email compose page +export const EmailComposeKeyboardShortcuts = { + mounted() { + this.unregisterShortcutHelp = registerEmailShortcutHelp(this, () => this.showShortcutsHelp()) + + this.keyHandler = (e) => { + const key = e.key.toLowerCase() + const ctrl = e.ctrlKey || e.metaKey + + // Don't interfere with dropdowns + if (e.target.closest('.dropdown.dropdown-open')) { + return + } + + // Handle shortcuts + switch (key) { + case 'enter': + // Ctrl/Cmd + Enter to send email + if (ctrl) { + e.preventDefault() + const submitBtn = document.querySelector('button[type="submit"]') + if (submitBtn && !submitBtn.disabled) { + submitBtn.click() + } + } + break + + case 'escape': + // Don't trigger if typing in input/textarea + if (isEditableTarget(e.target)) { + return + } + e.preventDefault() + // Go back + const backBtn = document.querySelector('a[href*="/email"]') + if (backBtn) { + window.location.href = backBtn.href + } + break + + case 'g': + // Don't trigger if typing + if (isEditableTarget(e.target)) { + return + } + if (!ctrl) { + e.preventDefault() + this.showGotoMenu() + } + break + + case '?': + // Don't trigger if typing + if (isEditableTarget(e.target)) { + return + } + e.preventDefault() + this.showShortcutsHelp() + break + } + } + + document.addEventListener('keydown', this.keyHandler) + + this.handleEvent("show-keyboard-shortcuts", () => { + if (isActiveEmailShortcutHelpOwner(this)) { + this.showShortcutsHelp() + } + }) + }, + + destroyed() { + if (this.keyHandler) { + document.removeEventListener('keydown', this.keyHandler) + } + if (this.gotoMenuCleanup) { + this.gotoMenuCleanup() + } + if (this.shortcutsModalCleanup) { + this.shortcutsModalCleanup() + } + if (this.unregisterShortcutHelp) { + this.unregisterShortcutHelp() + } + }, + + showGotoMenu() { + if (this.gotoMenuCleanup) { + this.gotoMenuCleanup() + } + + const menu = document.createElement('div') + menu.className = 'shortcut-floating fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-lg p-6 z-50' + menu.innerHTML = ` +

Go to...

+
+ + + +
+
Press Escape to close
+ ` + + document.body.appendChild(menu) + + const handleGotoKey = (e) => { + const key = e.key.toLowerCase() + + if (['escape', 'i', 's', 't'].includes(key)) { + e.preventDefault() + e.stopImmediatePropagation() + } + + if (key === 'escape') { + cleanup() + } else if (key === 'i') { + window.location.href = '/email?tab=inbox' + cleanup() + } else if (key === 's') { + window.location.href = '/email?tab=sent' + cleanup() + } else if (key === 't') { + window.location.href = '/email?tab=search' + cleanup() + } + } + + let timeoutId = null + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = null + } + document.removeEventListener('keydown', handleGotoKey, true) + if (menu.parentNode) { + menu.parentNode.removeChild(menu) + } + if (this.gotoMenuCleanup === cleanup) { + this.gotoMenuCleanup = null + } + } + + this.gotoMenuCleanup = cleanup + + menu.addEventListener('click', (e) => { + const button = e.target.closest('[data-goto]') + if (button) { + const destination = button.dataset.goto + window.location.href = '/email?tab=' + destination + cleanup() + } + }) + + document.addEventListener('keydown', handleGotoKey, true) + + timeoutId = setTimeout(cleanup, 5000) + }, + + showShortcutsHelp() { + closeExistingShortcutModals() + + if (this.shortcutsModalCleanup) { + this.shortcutsModalCleanup() + } + + const modal = document.createElement('div') + modal.dataset.emailShortcutsModal = 'true' + modal.className = 'shortcut-overlay fixed inset-0 z-50 flex items-center justify-center p-4' + modal.innerHTML = ` +
+
+

Keyboard Shortcuts

+ +
+ +
+
+

Navigation

+
+
+ Go to inbox + g i +
+
+ Go to sent + g s +
+
+ Go to search + g t +
+
+ Cancel/Go back + Esc +
+
+
+ +
+

Actions

+
+
+ Send email + Ctrl + Enter +
+
+
+
+ +
+
+ Show this help + ? +
+
+
+ ` + + document.body.appendChild(modal) + + const escHandler = (e) => { + if (e.key === 'Escape') { + cleanup() + } + } + + const cleanup = () => { + document.removeEventListener('keydown', escHandler) + if (modal.parentNode) { + modal.parentNode.removeChild(modal) + } + if (this.shortcutsModalCleanup === cleanup) { + this.shortcutsModalCleanup = null + } + } + + this.shortcutsModalCleanup = cleanup + + modal.addEventListener('click', (e) => { + if (e.target === modal || e.target.closest('[data-close]')) { + cleanup() + } + }) + + document.addEventListener('keydown', escHandler) + } +} diff --git a/apps/elektrine/assets/js/hooks/email_hooks.js b/apps/elektrine/assets/js/hooks/email_hooks.js new file mode 100644 index 0000000..8ec0d59 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/email_hooks.js @@ -0,0 +1,739 @@ +// Email-related LiveView hooks + +import { + closeExistingShortcutModals, + isActiveEmailShortcutHelpOwner, + isEditableTarget, + registerEmailShortcutHelp, +} from "./email_shortcut_helpers" + +export const KeyboardShortcuts = { + mounted() { + this.setupKeyboardShortcuts() + this.unregisterShortcutHelp = registerEmailShortcutHelp(this, () => this.showShortcutsHelp()) + + // Listen for server event to show keyboard shortcuts + this.handleEvent("show-keyboard-shortcuts", () => { + if (isActiveEmailShortcutHelpOwner(this)) { + this.showShortcutsHelp() + } + }) + + // Listen for scroll-to-top event when navigating between tabs + this.handleEvent("scroll-to-top", () => { + window.scrollTo({ + top: 0, + behavior: 'smooth' + }) + }) + + this.handleEvent("focus-search-input", () => { + this.focusSearchInput() + }) + }, + + setupKeyboardShortcuts() { + // Track selected message for keyboard navigation + this.selectedMessageIndex = -1 + this.messages = [] + + // Update message list when DOM changes + this.updateMessageList() + + // Store the bound handler so we can remove it later + this.keyHandler = (e) => { + // Don't interfere when typing in inputs, textareas, or contenteditable elements + if (isEditableTarget(e.target) || e.target.closest('.dropdown.dropdown-open')) { + return + } + + // Handle shortcuts + this.handleKeyboardShortcut(e) + } + + document.addEventListener('keydown', this.keyHandler) + + // Update message list when new messages are added + const observer = new MutationObserver(() => { + this.updateMessageList() + }) + + // Observe the entire document for changes + const messageContainer = document.getElementById('message-list') || document.body + observer.observe(messageContainer, { + childList: true, + subtree: true + }) + + this.observer = observer + }, + + destroyed() { + if (this.observer) { + this.observer.disconnect() + } + if (this.keyHandler) { + document.removeEventListener('keydown', this.keyHandler) + } + if (this.gotoMenuCleanup) { + this.gotoMenuCleanup() + } + if (this.unregisterShortcutHelp) { + this.unregisterShortcutHelp() + } + }, + + updateMessageList() { + // Look for messages in the document, not just within this.el + this.messages = Array.from(document.querySelectorAll('[id^="message-"]')) + if (this.selectedMessageIndex >= this.messages.length) { + this.selectedMessageIndex = this.messages.length - 1 + } + }, + + handleKeyboardShortcut(e) { + const key = e.key.toLowerCase() + const ctrl = e.ctrlKey || e.metaKey + + + // Gmail-style shortcuts + switch (key) { + case 'c': + if (!ctrl) { + e.preventDefault() + this.navigateToCompose() + } + break + + case 'g': + if (!ctrl) { + e.preventDefault() + this.showGotoMenu() + } + break + + case '/': + e.preventDefault() + this.focusSearch() + break + + case '?': + e.preventDefault() + this.showShortcutsHelp() + break + + case 'j': + if (!ctrl) { + e.preventDefault() + this.selectNextMessage() + } + break + + case 'k': + if (!ctrl) { + e.preventDefault() + this.selectPrevMessage() + } + break + + case 'enter': + if (this.selectedMessageIndex >= 0) { + e.preventDefault() + this.openSelectedMessage() + } + break + + case 'e': + if (!ctrl && this.selectedMessageIndex >= 0) { + e.preventDefault() + this.archiveSelectedMessage() + } + break + + case 'r': + if (!ctrl && this.selectedMessageIndex >= 0) { + e.preventDefault() + this.replyToSelectedMessage() + } + break + + case 'f': + if (!ctrl && this.selectedMessageIndex >= 0) { + e.preventDefault() + this.forwardSelectedMessage() + } + break + + case '#': + if (this.selectedMessageIndex >= 0) { + e.preventDefault() + this.deleteSelectedMessage() + } + break + + case '!': + if (this.selectedMessageIndex >= 0) { + e.preventDefault() + this.markSpamSelectedMessage() + } + break + } + }, + + showGotoMenu() { + if (this.gotoMenuCleanup) { + this.gotoMenuCleanup() + } + + // Show a temporary goto menu + const menu = document.createElement('div') + menu.className = 'shortcut-floating fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-lg p-6 z-50' + menu.innerHTML = ` +

Go to...

+
+ + + + + +
+
Press Escape to close
+ ` + + document.body.appendChild(menu) + + const handleGotoKey = (e) => { + const key = e.key.toLowerCase() + + if (['escape', 'i', 's', 't', 'a', 'p'].includes(key)) { + e.preventDefault() + e.stopImmediatePropagation() + } + + if (key === 'escape') { + cleanup() + } else if (key === 'i') { + window.location.href = '/email?tab=inbox' + cleanup() + } else if (key === 's') { + window.location.href = '/email?tab=sent' + cleanup() + } else if (key === 't') { + window.location.href = '/email?tab=search' + cleanup() + } else if (key === 'a') { + window.location.href = '/email?tab=archive' + cleanup() + } else if (key === 'p') { + window.location.href = '/email?tab=spam' + cleanup() + } + } + + let timeoutId = null + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = null + } + document.removeEventListener('keydown', handleGotoKey, true) + if (menu.parentNode) { + menu.parentNode.removeChild(menu) + } + if (this.gotoMenuCleanup === cleanup) { + this.gotoMenuCleanup = null + } + } + + this.gotoMenuCleanup = cleanup + + // Handle goto navigation + menu.addEventListener('click', (e) => { + const button = e.target.closest('[data-goto]') + if (button) { + const destination = button.dataset.goto + this.navigateTo(destination) + cleanup() + } + }) + + document.addEventListener('keydown', handleGotoKey, true) + + // Auto-close after 5 seconds + timeoutId = setTimeout(cleanup, 5000) + }, + + navigateTo(destination) { + // Use LiveView event to navigate to tabs + const tabMap = { + inbox: 'inbox', + sent: 'sent', + search: 'search', + archive: 'archive', + spam: 'spam' + } + + if (tabMap[destination]) { + // Send event to LiveView to switch tabs + this.pushEvent('switch_tab', { tab: tabMap[destination] }) + } + }, + + navigateToCompose() { + // Use LiveView event to navigate to compose + this.pushEvent('navigate_to_compose', {}) + }, + + focusSearch() { + // Switch to search tab and focus input + this.pushEvent('switch_tab', { tab: 'search', focus_search: true }) + }, + + focusSearchInput() { + const focusInput = () => { + const input = + document.getElementById('email-search-input') || + document.getElementById('email-index-search-input') || + document.querySelector('[data-search-input]') + + if (input) { + input.focus() + input.select?.() + } + } + + requestAnimationFrame(() => { + focusInput() + setTimeout(focusInput, 75) + }) + }, + + selectNextMessage() { + if (this.messages.length === 0) return + + // Clear previous selection + this.clearMessageSelection() + + this.selectedMessageIndex = Math.min(this.selectedMessageIndex + 1, this.messages.length - 1) + this.highlightSelectedMessage() + }, + + selectPrevMessage() { + if (this.messages.length === 0) return + + // Clear previous selection + this.clearMessageSelection() + + this.selectedMessageIndex = Math.max(this.selectedMessageIndex - 1, 0) + this.highlightSelectedMessage() + }, + + clearMessageSelection() { + this.messages.forEach(msg => { + msg.classList.remove('ring-2', 'ring-primary', 'ring-offset-2') + }) + }, + + highlightSelectedMessage() { + if (this.selectedMessageIndex >= 0 && this.selectedMessageIndex < this.messages.length) { + const selectedMsg = this.messages[this.selectedMessageIndex] + selectedMsg.classList.add('ring-2', 'ring-primary', 'ring-offset-2') + selectedMsg.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + }, + + openSelectedMessage() { + if (this.selectedMessageIndex >= 0 && this.selectedMessageIndex < this.messages.length) { + const selectedMsg = this.messages[this.selectedMessageIndex] + const openLink = selectedMsg.querySelector('.message-open-link') + if (openLink) { + openLink.click() + } else { + selectedMsg.click() + } + } + }, + + archiveSelectedMessage() { + // Archive functionality - trigger archive event + if (this.selectedMessageIndex >= 0 && this.selectedMessageIndex < this.messages.length) { + const selectedMsg = this.messages[this.selectedMessageIndex] + const messageId = selectedMsg.id.replace('message-', '') + // Trigger archive event on the LiveView component + this.pushEvent('archive_message', { message_id: messageId }) + } + }, + + replyToSelectedMessage() { + // Reply functionality - open compose with reply + if (this.selectedMessageIndex >= 0 && this.selectedMessageIndex < this.messages.length) { + const selectedMsg = this.messages[this.selectedMessageIndex] + const messageId = selectedMsg.id.replace('message-', '') + this.pushEvent('open_compose', { mode: 'reply', message_id: messageId }) + } + }, + + forwardSelectedMessage() { + // Forward functionality - open compose with forward + if (this.selectedMessageIndex >= 0 && this.selectedMessageIndex < this.messages.length) { + const selectedMsg = this.messages[this.selectedMessageIndex] + const messageId = selectedMsg.id.replace('message-', '') + this.pushEvent('open_compose', { mode: 'forward', message_id: messageId }) + } + }, + + deleteSelectedMessage() { + if (this.selectedMessageIndex >= 0 && this.selectedMessageIndex < this.messages.length) { + const selectedMsg = this.messages[this.selectedMessageIndex] + const messageId = selectedMsg.id.replace('message-', '') + this.pushEvent('delete_message', { message_id: messageId }) + } + }, + + markSpamSelectedMessage() { + // Mark as spam functionality + if (this.selectedMessageIndex >= 0 && this.selectedMessageIndex < this.messages.length) { + const selectedMsg = this.messages[this.selectedMessageIndex] + const messageId = selectedMsg.id.replace('message-', '') + // Trigger spam event on the LiveView component + this.pushEvent('mark_spam', { message_id: messageId }) + } + }, + + showShortcutsHelp() { + closeExistingShortcutModals() + + if (window.showKeyboardShortcuts) { + window.showKeyboardShortcuts() + } + } +} + +// Keyboard shortcuts for email show/view page +export const EmailShowKeyboardShortcuts = { + mounted() { + this.unregisterShortcutHelp = registerEmailShortcutHelp(this, () => this.showShortcutsHelp()) + + this.keyHandler = (e) => { + // Don't interfere when typing in inputs, textareas, or contenteditable elements + if (isEditableTarget(e.target) || e.target.closest('.dropdown.dropdown-open')) { + return + } + + const key = e.key.toLowerCase() + const ctrl = e.ctrlKey || e.metaKey + + // Handle shortcuts + switch (key) { + case 'c': + if (!ctrl) { + e.preventDefault() + window.location.href = '/email/compose' + } + break + + case 'g': + if (!ctrl) { + e.preventDefault() + this.showGotoMenu() + } + break + + case 'r': + if (!ctrl) { + e.preventDefault() + const replyBtn = document.querySelector('button[phx-click="reply"]') + if (replyBtn) replyBtn.click() + } + break + + case 'a': + if (!ctrl) { + e.preventDefault() + const replyAllBtn = document.querySelector('button[phx-click="reply_all"]') + if (replyAllBtn) replyAllBtn.click() + } + break + + case 'f': + if (!ctrl) { + e.preventDefault() + const forwardBtn = document.querySelector('button[phx-click="forward"]') + if (forwardBtn) forwardBtn.click() + } + break + + case 'v': + if (!ctrl) { + e.preventDefault() + const rawLink = document.querySelector('a[href*="/raw"]') + if (rawLink) { + window.location.href = rawLink.href + } + } + break + + case 'l': + if (!ctrl) { + e.preventDefault() + const replyLaterBtn = document.querySelector('button[phx-click="show_reply_later_modal"]') + if (replyLaterBtn) replyLaterBtn.click() + } + break + + case 'escape': + e.preventDefault() + window.location.href = '/email' + break + + case '?': + e.preventDefault() + this.showShortcutsHelp() + break + } + } + + document.addEventListener('keydown', this.keyHandler) + + this.handleEvent("show-keyboard-shortcuts", () => { + if (isActiveEmailShortcutHelpOwner(this)) { + this.showShortcutsHelp() + } + }) + + // Also attach FileDownloader functionality + this.handleEvent("download-file", ({ url, filename }) => { + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + }) + }, + + destroyed() { + if (this.keyHandler) { + document.removeEventListener('keydown', this.keyHandler) + } + if (this.gotoMenuCleanup) { + this.gotoMenuCleanup() + } + if (this.shortcutsModalCleanup) { + this.shortcutsModalCleanup() + } + if (this.unregisterShortcutHelp) { + this.unregisterShortcutHelp() + } + }, + + showShortcutsHelp() { + closeExistingShortcutModals() + + if (this.shortcutsModalCleanup) { + this.shortcutsModalCleanup() + } + + const modal = document.createElement('div') + modal.dataset.emailShortcutsModal = 'true' + modal.className = 'shortcut-overlay fixed inset-0 z-50 flex items-center justify-center p-4' + modal.innerHTML = ` +
+
+

Keyboard Shortcuts

+ +
+ +
+
+

Navigation

+
+
+ Compose new message + c +
+
+ Go to menu + g +
+
+ Back to inbox + Esc +
+
+ View raw email + v +
+
+
+ +
+

Actions

+
+
+ Reply + r +
+
+ Reply all + a +
+
+ Forward + f +
+
+ Reply later + l +
+
+
+
+ +
+
+ Show this help + ? +
+
+
+ ` + + document.body.appendChild(modal) + + const escHandler = (e) => { + if (e.key === 'Escape') { + cleanup() + } + } + + const cleanup = () => { + document.removeEventListener('keydown', escHandler) + if (modal.parentNode) { + modal.parentNode.removeChild(modal) + } + if (this.shortcutsModalCleanup === cleanup) { + this.shortcutsModalCleanup = null + } + } + + this.shortcutsModalCleanup = cleanup + + // Close on click outside or close button + modal.addEventListener('click', (e) => { + if (e.target === modal || e.target.closest('[data-close]')) { + cleanup() + } + }) + + // Close on Escape key + document.addEventListener('keydown', escHandler) + }, + + showGotoMenu() { + if (this.gotoMenuCleanup) { + this.gotoMenuCleanup() + } + + // Show a temporary goto menu + const menu = document.createElement('div') + menu.className = 'shortcut-floating fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-lg p-6 z-50' + menu.innerHTML = ` +

Go to...

+
+ + + + + +
+
Press Escape to close
+ ` + + document.body.appendChild(menu) + + const handleGotoKey = (e) => { + const key = e.key.toLowerCase() + + if (['escape', 'i', 's', 't', 'a', 'p'].includes(key)) { + e.preventDefault() + e.stopImmediatePropagation() + } + + if (key === 'escape') { + cleanup() + } else if (key === 'i') { + window.location.href = '/email?tab=inbox' + cleanup() + } else if (key === 's') { + window.location.href = '/email?tab=sent' + cleanup() + } else if (key === 't') { + window.location.href = '/email?tab=search' + cleanup() + } else if (key === 'a') { + window.location.href = '/email?tab=archive' + cleanup() + } else if (key === 'p') { + window.location.href = '/email?tab=spam' + cleanup() + } + } + + let timeoutId = null + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = null + } + document.removeEventListener('keydown', handleGotoKey, true) + if (menu.parentNode) { + menu.parentNode.removeChild(menu) + } + if (this.gotoMenuCleanup === cleanup) { + this.gotoMenuCleanup = null + } + } + + this.gotoMenuCleanup = cleanup + + // Handle goto navigation + menu.addEventListener('click', (e) => { + const button = e.target.closest('[data-goto]') + if (button) { + const destination = button.dataset.goto + window.location.href = '/email?tab=' + destination + cleanup() + } + }) + + document.addEventListener('keydown', handleGotoKey, true) + + // Auto-close after 5 seconds + timeoutId = setTimeout(cleanup, 5000) + } +} diff --git a/apps/elektrine/assets/js/hooks/email_iframe_resize_hook.js b/apps/elektrine/assets/js/hooks/email_iframe_resize_hook.js new file mode 100644 index 0000000..cc17a96 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/email_iframe_resize_hook.js @@ -0,0 +1,126 @@ +// Hook to automatically resize email content iframe based on content height + +export const EmailIframeResize = { + mounted() { + this.iframe = this.el + // Initialize maxHeight from current iframe height to prevent shrinking + this.maxHeight = this.iframe.offsetHeight || 600 + this.resizeTimers = [] + this.imageListeners = [] + this.mutationObserver = null + + // Resize when iframe loads + this.loadHandler = () => { + // Resize immediately + this.resizeIframe() + this.bindContentResizeWatchers() + + // Check again after brief delays to catch images loading + this.scheduleResize(100) + this.scheduleResize(300) + this.scheduleResize(600) + this.scheduleResize(1000) + this.scheduleResize(2000) + } + + this.iframe.addEventListener('load', this.loadHandler) + }, + + scheduleResize(delay) { + const timer = setTimeout(() => this.resizeIframe(), delay) + this.resizeTimers.push(timer) + }, + + bindContentResizeWatchers() { + this.cleanupContentResizeWatchers() + + try { + const iframeDoc = this.iframe.contentDocument || this.iframe.contentWindow.document + if (!iframeDoc) return + + iframeDoc.querySelectorAll('img').forEach((image) => { + const listener = () => this.resizeIframe() + image.addEventListener('load', listener, { once: true }) + image.addEventListener('error', listener, { once: true }) + this.imageListeners.push({ image, listener }) + }) + + if (iframeDoc.fonts && typeof iframeDoc.fonts.ready?.then === 'function') { + iframeDoc.fonts.ready.then(() => this.resizeIframe()).catch(() => {}) + } + + this.mutationObserver = new MutationObserver(() => this.resizeIframe()) + this.mutationObserver.observe(iframeDoc.documentElement, { + childList: true, + subtree: true, + attributes: true, + characterData: true, + }) + } catch (_error) { + // Cross-origin restrictions - iframe will use scheduled height checks. + } + }, + + cleanupContentResizeWatchers() { + this.imageListeners.forEach(({ image, listener }) => { + image.removeEventListener('load', listener) + image.removeEventListener('error', listener) + }) + this.imageListeners = [] + + if (this.mutationObserver) { + this.mutationObserver.disconnect() + this.mutationObserver = null + } + }, + + resizeIframe() { + try { + const iframeDoc = this.iframe.contentDocument || this.iframe.contentWindow.document + + if (iframeDoc && iframeDoc.body) { + // Get the full content height including all elements + const body = iframeDoc.body + const html = iframeDoc.documentElement + + const contentHeight = Math.max( + body.scrollHeight, + body.offsetHeight, + html.clientHeight, + html.scrollHeight, + html.offsetHeight + ) + + // Set a minimum height and add some padding + const minHeight = 200 + const newHeight = Math.max(contentHeight, minHeight) + + // Only grow, never shrink (prevents resize-on-scroll issues) + if (newHeight > this.maxHeight) { + this.maxHeight = newHeight + this.iframe.style.height = `${newHeight}px` + } + } + } catch (e) { + // Cross-origin restrictions - iframe will use default height + } + }, + + updated() { + // When LiveView updates the element, ensure we maintain the max height + // Re-apply the height in case LiveView reset it + if (this.maxHeight > 0) { + this.iframe.style.height = `${this.maxHeight}px` + } + }, + + destroyed() { + if (this.loadHandler) { + this.iframe.removeEventListener('load', this.loadHandler) + } + + this.resizeTimers.forEach((timer) => clearTimeout(timer)) + this.resizeTimers = [] + this.cleanupContentResizeWatchers() + } +} diff --git a/apps/elektrine/assets/js/hooks/email_shortcut_helpers.js b/apps/elektrine/assets/js/hooks/email_shortcut_helpers.js new file mode 100644 index 0000000..80c7371 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/email_shortcut_helpers.js @@ -0,0 +1,37 @@ +export function isEditableTarget(target) { + if (!(target instanceof Element)) return false + + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') { + return true + } + + if (target.contentEditable === 'true' || target.isContentEditable) { + return true + } + + return Boolean( + target.closest( + 'input, textarea, select, [contenteditable="true"], [contenteditable=""], [role="textbox"], .ProseMirror, .ql-editor' + ) + ) +} + +const SHORTCUT_MODAL_SELECTOR = '[data-email-shortcuts-modal], #keyboard-shortcuts-modal' + +export function closeExistingShortcutModals() { + document.querySelectorAll(SHORTCUT_MODAL_SELECTOR).forEach((modal) => modal.remove()) +} + +export function registerEmailShortcutHelp(owner, callback) { + window.activeEmailShortcutHelp = { owner, callback } + + return () => { + if (window.activeEmailShortcutHelp?.owner === owner) { + delete window.activeEmailShortcutHelp + } + } +} + +export function isActiveEmailShortcutHelpOwner(owner) { + return window.activeEmailShortcutHelp?.owner === owner +} diff --git a/apps/elektrine/assets/js/hooks/file_explorer_hook.js b/apps/elektrine/assets/js/hooks/file_explorer_hook.js new file mode 100644 index 0000000..b26c8bf --- /dev/null +++ b/apps/elektrine/assets/js/hooks/file_explorer_hook.js @@ -0,0 +1,347 @@ +export const FileExplorer = { + mounted() { + this.searchInput = this.el.querySelector('#file-explorer-search') + this.interactiveSelector = 'a, button, input, select, textarea, summary, label, [data-row-ignore]' + this.treeLayout = this.el.querySelector('[data-tree-layout]') + this.treePane = this.el.querySelector('[data-tree-pane]') + this.treeResizer = this.el.querySelector('[data-tree-resizer]') + this.activeRowIndex = -1 + this.anchorRowIndex = null + this.draggingTokens = null + this.draggingRow = null + this.lastDropTarget = null + + this.applyTreeWidth = (width) => { + if (!this.treeLayout) return + const clamped = Math.max(224, Math.min(width, 480)) + this.treeLayout.style.setProperty('--files-tree-width', `${clamped}px`) + window.localStorage?.setItem('files-tree-width', String(clamped)) + } + + this.restoreTreeWidth = () => { + const savedWidth = window.localStorage?.getItem('files-tree-width') + if (!savedWidth) return + + const parsed = Number(savedWidth) + if (Number.isFinite(parsed)) this.applyTreeWidth(parsed) + } + + this.refreshRows = () => { + this.rows = Array.from(this.el.querySelectorAll('[data-row-token]')) + + this.rows.forEach((row, index) => { + row.dataset.rowIndex = String(index) + row.tabIndex = index === this.activeRowIndex ? 0 : -1 + }) + + if (this.rows.length === 0) { + this.activeRowIndex = -1 + this.anchorRowIndex = null + } else if (this.activeRowIndex < 0 || this.activeRowIndex >= this.rows.length) { + this.setActiveRow(0, false) + } + } + + this.setActiveRow = (index, focus = true) => { + if (!this.rows || this.rows.length === 0) return + + const boundedIndex = Math.max(0, Math.min(index, this.rows.length - 1)) + this.activeRowIndex = boundedIndex + + this.rows.forEach((row, rowIndex) => { + row.tabIndex = rowIndex === boundedIndex ? 0 : -1 + }) + + if (focus) { + this.rows[boundedIndex].focus() + } + } + + this.setSelection = (tokens) => { + this.pushEvent('set_selection', { tokens }) + } + + this.getSelectedTokens = () => { + return this.rows + .filter(row => row.dataset.rowSelected === 'true') + .map(row => row.dataset.rowToken) + } + + this.openRow = (row) => { + if (!row || !row.dataset.rowOpenEvent) return + + const payload = {} + if (row.dataset.rowOpenPath) payload.path = row.dataset.rowOpenPath + if (row.dataset.rowOpenId) payload.id = row.dataset.rowOpenId + this.pushEvent(row.dataset.rowOpenEvent, payload) + } + + this.selectRangeTo = (targetIndex) => { + if (!this.rows || this.rows.length === 0) return + + const anchor = this.anchorRowIndex == null ? this.activeRowIndex : this.anchorRowIndex + const start = Math.min(anchor, targetIndex) + const end = Math.max(anchor, targetIndex) + const tokens = this.rows.slice(start, end + 1).map(row => row.dataset.rowToken) + this.setSelection(tokens) + } + + this.onClick = (event) => { + const row = event.target.closest('[data-row-token]') + if (!row || row.contains(event.target.closest(this.interactiveSelector))) return + + const rowIndex = Number(row.dataset.rowIndex || 0) + this.setActiveRow(rowIndex) + + if (event.shiftKey) { + this.selectRangeTo(rowIndex) + } else if (event.metaKey || event.ctrlKey) { + this.anchorRowIndex = rowIndex + this.pushEvent('toggle_select', { token: row.dataset.rowToken }) + } else { + this.anchorRowIndex = rowIndex + this.setSelection([row.dataset.rowToken]) + } + } + + this.onDoubleClick = (event) => { + const row = event.target.closest('[data-row-token]') + if (!row || row.contains(event.target.closest(this.interactiveSelector))) return + + this.openRow(row) + } + + this.clearDropTarget = () => { + if (!this.lastDropTarget) return + this.lastDropTarget.classList.remove('bg-primary/15', 'ring-1', 'ring-primary/30') + this.lastDropTarget = null + } + + this.onDragStart = (event) => { + const row = event.target.closest('[draggable="true"][data-row-token]') + if (!row || event.target.closest(this.interactiveSelector)) { + event.preventDefault() + return + } + + const rowToken = row.dataset.rowToken + const selectedTokens = row.dataset.rowSelected === 'true' ? this.getSelectedTokens() : [] + this.draggingTokens = selectedTokens.length > 0 ? selectedTokens : [rowToken] + this.draggingRow = row + row.classList.add('opacity-60') + + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', this.draggingTokens.join(',')) + } + } + + this.onDragEnd = () => { + if (this.draggingRow) this.draggingRow.classList.remove('opacity-60') + this.draggingTokens = null + this.draggingRow = null + this.clearDropTarget() + } + + this.onDragOver = (event) => { + if (!this.draggingTokens || this.draggingTokens.length === 0) return + const target = event.target.closest('[data-tree-folder-drop-path]') + if (!target) return + + event.preventDefault() + if (this.lastDropTarget && this.lastDropTarget !== target) this.clearDropTarget() + this.lastDropTarget = target + target.classList.add('bg-primary/15', 'ring-1', 'ring-primary/30') + } + + this.onDragLeave = (event) => { + const target = event.target.closest('[data-tree-folder-drop-path]') + if (!target || target !== this.lastDropTarget) return + + const related = event.relatedTarget + if (related instanceof HTMLElement && target.contains(related)) return + this.clearDropTarget() + } + + this.onDrop = (event) => { + if (!this.draggingTokens || this.draggingTokens.length === 0) return + const target = event.target.closest('[data-tree-folder-drop-path]') + if (!target) return + + event.preventDefault() + const folderPath = target.dataset.treeFolderDropPath || '' + this.pushEvent('drag_move_items', { tokens: this.draggingTokens, folder: folderPath }) + this.onDragEnd() + } + + this.onResizeStart = (event) => { + if (!this.treeLayout || !this.treeResizer) return + + event.preventDefault() + const startX = event.clientX + const startWidth = this.treePane?.getBoundingClientRect().width || 288 + + const onPointerMove = moveEvent => { + const nextWidth = startWidth + (moveEvent.clientX - startX) + this.applyTreeWidth(nextWidth) + } + + const onPointerUp = () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + document.body.classList.remove('select-none', 'cursor-col-resize') + } + + document.body.classList.add('select-none', 'cursor-col-resize') + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + } + + this.onKeyDown = (event) => { + const target = event.target + const isTypingTarget = + target instanceof HTMLElement && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' || + target.isContentEditable) + + if (event.key === '/' && !isTypingTarget && !event.metaKey && !event.ctrlKey && !event.altKey) { + event.preventDefault() + this.focusSearch() + return + } + + if ((event.key.toLowerCase() === 'f' && (event.metaKey || event.ctrlKey)) && !event.altKey) { + event.preventDefault() + this.focusSearch() + return + } + + if (event.key.toLowerCase() === 'n' && !isTypingTarget && !event.metaKey && !event.ctrlKey && !event.altKey) { + event.preventDefault() + this.pushEvent('toggle_new_folder', {}) + return + } + + if (event.key === 'Escape' && !isTypingTarget) { + this.pushEvent('cancel_new_folder', {}) + return + } + + if (isTypingTarget || !this.rows || this.rows.length === 0) return + + if (event.key === 'ArrowDown') { + event.preventDefault() + const nextIndex = this.activeRowIndex < 0 ? 0 : this.activeRowIndex + 1 + + if (event.shiftKey) { + this.setActiveRow(nextIndex) + this.selectRangeTo(this.activeRowIndex) + } else { + this.anchorRowIndex = Math.max(0, Math.min(nextIndex, this.rows.length - 1)) + this.setActiveRow(nextIndex) + } + + return + } + + if (event.key === 'ArrowUp') { + event.preventDefault() + const nextIndex = this.activeRowIndex < 0 ? 0 : this.activeRowIndex - 1 + + if (event.shiftKey) { + this.setActiveRow(nextIndex) + this.selectRangeTo(this.activeRowIndex) + } else { + this.anchorRowIndex = Math.max(0, nextIndex) + this.setActiveRow(nextIndex) + } + + return + } + + if (event.key === 'Home') { + event.preventDefault() + this.anchorRowIndex = 0 + this.setActiveRow(0) + return + } + + if (event.key === 'End') { + event.preventDefault() + this.anchorRowIndex = this.rows.length - 1 + this.setActiveRow(this.rows.length - 1) + return + } + + if (event.key === 'Enter') { + event.preventDefault() + this.openRow(this.rows[this.activeRowIndex]) + return + } + + if (event.key === 'F2') { + event.preventDefault() + const row = this.rows[this.activeRowIndex] + if (!row) return + + if (row.dataset.rowKind === 'folder') { + this.pushEvent('open_manage_folder', { path: row.dataset.rowOpenPath }) + this.pushEvent('start_rename', { type: 'folder', path: row.dataset.rowOpenPath }) + } else { + this.pushEvent('open_manage_file', { id: row.dataset.rowOpenId }) + this.pushEvent('start_rename', { type: 'file', id: row.dataset.rowOpenId }) + } + + return + } + + if (event.key === ' ') { + event.preventDefault() + const row = this.rows[this.activeRowIndex] + if (row) { + this.anchorRowIndex = this.activeRowIndex + this.pushEvent('toggle_select', { token: row.dataset.rowToken }) + } + } + } + + this.el.addEventListener('click', this.onClick) + this.el.addEventListener('dblclick', this.onDoubleClick) + this.el.addEventListener('dragstart', this.onDragStart) + this.el.addEventListener('dragend', this.onDragEnd) + this.el.addEventListener('dragover', this.onDragOver) + this.el.addEventListener('dragleave', this.onDragLeave) + this.el.addEventListener('drop', this.onDrop) + if (this.treeResizer) this.treeResizer.addEventListener('pointerdown', this.onResizeStart) + window.addEventListener('keydown', this.onKeyDown) + this.restoreTreeWidth() + this.refreshRows() + }, + + updated() { + this.searchInput = this.el.querySelector('#file-explorer-search') + this.refreshRows() + }, + + destroyed() { + if (this.onClick) this.el.removeEventListener('click', this.onClick) + if (this.onDoubleClick) this.el.removeEventListener('dblclick', this.onDoubleClick) + if (this.onDragStart) this.el.removeEventListener('dragstart', this.onDragStart) + if (this.onDragEnd) this.el.removeEventListener('dragend', this.onDragEnd) + if (this.onDragOver) this.el.removeEventListener('dragover', this.onDragOver) + if (this.onDragLeave) this.el.removeEventListener('dragleave', this.onDragLeave) + if (this.onDrop) this.el.removeEventListener('drop', this.onDrop) + if (this.treeResizer && this.onResizeStart) this.treeResizer.removeEventListener('pointerdown', this.onResizeStart) + if (this.onKeyDown) window.removeEventListener('keydown', this.onKeyDown) + }, + + focusSearch() { + if (!this.searchInput) return + + this.searchInput.focus() + const length = this.searchInput.value.length + this.searchInput.setSelectionRange(length, length) + } +} diff --git a/apps/elektrine/assets/js/hooks/form_hooks.js b/apps/elektrine/assets/js/hooks/form_hooks.js new file mode 100644 index 0000000..a13bf37 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/form_hooks.js @@ -0,0 +1,488 @@ +/** + * Form Hooks + * Hooks for form inputs, tag inputs, and autocomplete functionality. + */ +import { spinnerSvg } from '../utils/spinner' + +const ATOMINE_MIN_DIFFICULTY = 0 +const ATOMINE_MAX_DIFFICULTY = 30 +const ATOMINE_PROGRESS_INTERVAL = 1000 + +/** + * FormSubmit Hook + * Shows loading spinner on form submit buttons when the form is submitted. + * Attach to forms with regular (non-LiveView) submissions. + */ +export const FormSubmit = { + mounted() { + this.submitResetTimer = null + this.onSubmit = (e) => { + if (e.defaultPrevented) return + + const submitBtn = this.el.querySelector('button[type="submit"], button:not([type])') + if (submitBtn) { + // Store original content + const originalContent = submitBtn.innerHTML + + // Get loading text from data attribute or use original + const loadingText = submitBtn.dataset.loadingText || submitBtn.textContent.trim() + + // Replace with spinner and loading text without injecting text as HTML. + submitBtn.innerHTML = spinnerSvg() + const loadingTextElement = document.createElement('span') + loadingTextElement.textContent = loadingText + submitBtn.appendChild(loadingTextElement) + submitBtn.disabled = true + submitBtn.classList.add('pointer-events-none') + + // Restore after timeout (in case of network issues) + this.submitResetTimer = setTimeout(() => { + submitBtn.innerHTML = originalContent + submitBtn.disabled = false + submitBtn.classList.remove('pointer-events-none') + this.submitResetTimer = null + }, 30000) + } + } + + this.el.addEventListener('submit', this.onSubmit) + }, + + destroyed() { + if (this.onSubmit) this.el.removeEventListener('submit', this.onSubmit) + if (this.submitResetTimer) clearTimeout(this.submitResetTimer) + } +} + +/** + * Tag Input Hook + * Sends input value to LiveView on change + */ +export const TagInputHook = { + mounted() { + this.handleEvent("clear-tag-input", ({ field }) => { + if (field === this.el.dataset.field) { + this.el.value = '' + } + }) + + this.onInput = (e) => { + this.pushEvent("update_tag_input", { + field: this.el.dataset.field, + value: e.target.value + }) + } + + this.el.addEventListener('input', this.onInput) + }, + + destroyed() { + if (this.onInput) this.el.removeEventListener('input', this.onInput) + } +} + +/** + * Suggestion Dropdown Hook + * Handles mousedown events on suggestions (fires before blur) + */ +export const SuggestionDropdown = { + mounted() { + this.onMouseDown = (e) => { + const suggestionEl = e.target.closest('[data-suggestion-email]') + if (suggestionEl) { + const email = suggestionEl.dataset.suggestionEmail + const field = suggestionEl.dataset.suggestionField + + const inputId = field + '-tag-input' + const inputEl = document.getElementById(inputId) + if (inputEl) inputEl.value = '' + + this.pushEvent("select_suggestion", { field, email }) + e.preventDefault() + } + } + + this.el.addEventListener('mousedown', this.onMouseDown) + }, + + destroyed() { + if (this.onMouseDown) this.el.removeEventListener('mousedown', this.onMouseDown) + } +} + +export function detectAndSaveTimezone(element = null) { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone + document.cookie = `detected_timezone=${timezone}; path=/; max-age=31536000; SameSite=Lax` + + if (element) { + element.dataset.lastSent = timezone + } +} + +export function initTimezoneDetectors(rootCandidate = document) { + const root = + rootCandidate && typeof rootCandidate.querySelectorAll === 'function' ? rootCandidate : document + + root.querySelectorAll('[data-timezone-detector]').forEach((element) => { + detectAndSaveTimezone(element) + }) +} + +/** + * VPN Download Hook + * Handles downloading WireGuard configuration files + */ +export const VPNDownload = { + mounted() { + this.handleEvent("download_config", ({ filename, content }) => { + const blob = new Blob([content], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + + if (window.showNotification) { + window.showNotification('VPN configuration downloaded', 'info', 'Success!') + } + }) + } +} + +/** + * Security check hook + * Solves configured anti-abuse layers before submitting. + */ +const POW_STATUS_CLASSES = { + progress: 'text-base-content/60', + success: 'text-success', + error: 'text-error' +} + +export const AtominePow = { + mounted() { + this.form = this.el.closest('form') || document.getElementById('register-form') + this.statusEl = this.el.querySelector('[data-atomine-pow-status]') + this.difficulty = normalizeDifficulty(this.el.dataset.difficulty) + this.inFlight = null + this.submittingWithToken = false + this.handleSubmit = (event) => this.onSubmit(event) + + if (this.form) { + this.resetSubmissionState() + this.form.addEventListener('submit', this.handleSubmit, true) + } + }, + + async onSubmit(event) { + if (!this.form) return + + if (this.form?.dataset.atominePowSkip === 'true') { + delete this.form.dataset.atominePowSkip + this.submittingWithToken = false + return + } + + event.preventDefault() + event.stopImmediatePropagation() + + if (this.submittingWithToken) return + + try { + await this.ensureToken() + this.submittingWithToken = true + this.form.dataset.atominePowSkip = 'true' + this.requestSubmit(event.submitter) + } catch (_error) { + this.setResponseToken('') + this.setStatus('Security check failed. Please try again.', 'error') + } + }, + + updated() { + this.statusEl = this.el.querySelector('[data-atomine-pow-status]') + if (!this.inFlight) this.resetSubmissionState() + }, + + resetSubmissionState() { + this.submittingWithToken = false + if (this.form) delete this.form.dataset.atominePowSkip + this.setResponseToken('') + }, + + ensureToken() { + if (!this.inFlight) { + this.inFlight = this.solveAndIssueToken().finally(() => { + this.inFlight = null + }) + } + + return this.inFlight + }, + + responseInput() { + return this.form?.querySelector('input[name="atomine_pow_token"]') + }, + + responseInputs() { + return Array.from(this.form?.querySelectorAll('input[name="atomine_pow_token"]') || []) + }, + + responseToken() { + return this.responseInputs().find((input) => input.value)?.value || '' + }, + + setResponseToken(token) { + this.responseInputs().forEach((input) => { + input.value = token + }) + }, + + ensureResponseInput(form) { + let input = form.querySelector('input[name="atomine_pow_token"]') + + if (!input) { + input = document.createElement('input') + input.type = 'hidden' + input.name = 'atomine_pow_token' + form.appendChild(input) + } + + input.id = 'atomine-pow-token' + return input + }, + + async solveAndIssueToken() { + if (!window.crypto?.subtle) { + throw new Error('Web Crypto is unavailable') + } + + this.ensureResponseInput(this.form) + this.setStatus('Preparing the security check...') + + const challengeResponse = await postJson('/api/atomine/pow/challenge', { + difficulty: this.difficulty + }) + + const challenge = challengeResponse.challenge + const difficulty = normalizeDifficulty(challengeResponse.difficulty ?? this.difficulty) + this.setStatus('Checking this browser...') + const gateProof = await collectGateProof(challenge) + + this.setStatus('Running a short work check...') + + const solution = await solvePow(challenge, difficulty, (attempts) => { + this.setStatus(`Still working... ${attempts.toLocaleString()} attempts tried`) + }) + + this.setStatus('Finishing the check...') + + const tokenResponse = await postJson('/api/atomine/anonymous-tokens', { + challenge, + solution, + gate_proof: gateProof + }) + + if (!tokenResponse.token) { + throw new Error('Missing Atomine token') + } + + this.setResponseToken(tokenResponse.token) + this.setStatus('Check complete. Submitting...', 'success') + }, + + requestSubmit(submitter) { + if (typeof this.form.requestSubmit === 'function') { + this.form.requestSubmit(submitter || undefined) + } else { + this.form.submit() + } + }, + + setStatus(message, state = 'progress') { + if (!this.statusEl) return + this.statusEl.textContent = message + this.statusEl.classList.remove(...Object.values(POW_STATUS_CLASSES)) + this.statusEl.classList.add(POW_STATUS_CLASSES[state] || POW_STATUS_CLASSES.progress) + }, + + destroyed() { + if (this.form && this.handleSubmit) { + this.form.removeEventListener('submit', this.handleSubmit, true) + } + } +} + +async function postJson(url, body) { + const response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body) + }) + + const json = await response.json().catch(() => ({})) + if (!response.ok) { + throw new Error(json.error || 'request failed') + } + + return json +} + +async function collectGateProof(challenge) { + const checks = [] + + checks.push(measureBrowserCheck('layout.getComputedStyle', () => { + const probe = document.createElement('div') + probe.style.cssText = 'position:absolute;left:-9999px;width:37px;height:11px;padding:3px;display:block;' + document.body.appendChild(probe) + const style = window.getComputedStyle(probe) + const ok = style.display === 'block' && style.width === '37px' && style.paddingLeft === '3px' + probe.remove() + return { ok } + })) + + checks.push(measureBrowserCheck('canvas.toDataURL', () => { + const canvas = document.createElement('canvas') + canvas.width = 16 + canvas.height = 16 + const ctx = canvas.getContext('2d') + if (!ctx) return { ok: false } + ctx.fillStyle = '#1f6feb' + ctx.fillRect(0, 0, 16, 16) + ctx.fillStyle = '#ffffff' + ctx.fillText('A', 3, 12) + const data = canvas.toDataURL('image/png') + return { ok: data.startsWith('data:image/png;base64,'), bytes: data.length } + })) + + checks.push(measureBrowserCheck('event.isTrusted', () => { + let syntheticTrusted = null + const button = document.createElement('button') + button.type = 'button' + button.addEventListener('click', (event) => { + syntheticTrusted = event.isTrusted + }, { once: true }) + button.dispatchEvent(new MouseEvent('click', { bubbles: true })) + return { ok: syntheticTrusted === false, synthetic_trusted: syntheticTrusted } + })) + + checks.push(measureBrowserCheck('navigator.webdriver', () => { + const webdriver = navigator.webdriver === true + return { ok: !webdriver, webdriver } + })) + + checks.push(measureBrowserCheck('dom.querySelector', () => { + const id = `atomine-gate-${Math.random().toString(36).slice(2)}` + const probe = document.createElement('span') + probe.id = id + probe.dataset.atomineBrowserProof = 'true' + document.body.appendChild(probe) + const ok = document.querySelector(`#${id}`)?.dataset.atomineBrowserProof === 'true' + probe.remove() + return { ok } + })) + + if (checks.some((check) => !check.ok)) { + throw new Error('browser instrumentation failed') + } + + return { + version: 'atomine-gate-v1', + layers: ['pow', 'browser_instrumentation'], + browser_instrumentation: { + challenge_hash: await sha256Base64Url(challenge), + checks, + signals: { + user_agent_hash: await sha256Base64Url(navigator.userAgent || ''), + languages: Array.isArray(navigator.languages) ? navigator.languages.slice(0, 5) : [], + hardware_concurrency: navigator.hardwareConcurrency || null, + device_memory: navigator.deviceMemory || null, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || null + } + } + } +} + +function measureBrowserCheck(name, fn) { + const startedAt = performance.now() + + try { + const result = fn() || {} + return { + name, + ok: result.ok === true, + duration_ms: Math.max(0, Math.round(performance.now() - startedAt)), + ...result + } + } catch (error) { + return { + name, + ok: false, + duration_ms: Math.max(0, Math.round(performance.now() - startedAt)), + error: error?.name || 'Error' + } + } +} + +async function sha256Base64Url(value) { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)) + const bytes = new Uint8Array(digest) + let binary = '' + + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') +} + +async function solvePow(challenge, difficulty, onProgress) { + const encoder = new TextEncoder() + let nonce = 0 + + while (true) { + const solution = String(nonce) + const digest = await crypto.subtle.digest('SHA-256', encoder.encode(`${challenge}:${solution}`)) + + if (leadingZeroBits(new Uint8Array(digest)) >= difficulty) { + return solution + } + + nonce += 1 + + if (nonce % ATOMINE_PROGRESS_INTERVAL === 0) { + if (onProgress) onProgress(nonce) + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } +} + +function normalizeDifficulty(value) { + const parsed = Number.parseInt(value ?? '18', 10) + if (Number.isNaN(parsed)) return 18 + return Math.min(ATOMINE_MAX_DIFFICULTY, Math.max(ATOMINE_MIN_DIFFICULTY, parsed)) +} + +function leadingZeroBits(bytes) { + let total = 0 + + for (const byte of bytes) { + if (byte === 0) { + total += 8 + continue + } + + for (let bit = 7; bit >= 0; bit -= 1) { + if ((byte & (1 << bit)) === 0) { + total += 1 + } else { + return total + } + } + } + + return total +} diff --git a/apps/elektrine/assets/js/hooks/index.js b/apps/elektrine/assets/js/hooks/index.js new file mode 100644 index 0000000..1b4aab5 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/index.js @@ -0,0 +1,211 @@ +/** + * Central export for all LiveView hooks + * Organized by feature area for maintainability. + */ + +// Chat hooks +import { + AutoExpandTextarea, + SimpleChatInput, + MessageList, + ChatKeyboardShortcuts, +} from "./chat_hooks"; +import { ChatE2EE } from "./chat_e2ee_hook"; +import { + ContextMenu, + MessageContextMenu, + MessageMenuButton, + CopyChatMessage, +} from "./chat_context_menu_hooks"; +import { VoiceRecorder } from "./chat_voice_recorder_hook"; + +// Email hooks +import { + KeyboardShortcuts, + EmailShowKeyboardShortcuts, +} from "./email_hooks"; +import { EmailComposeKeyboardShortcuts } from "./email_compose_shortcuts_hook"; +import { EmailIframeResize } from "./email_iframe_resize_hook"; + +// Markdown hooks +import { ReplyMarkdownEditor } from "./markdown_hooks"; + +// Notification hooks +import { NotificationHandler } from "./notification_hooks"; +import { NotificationVisibility } from "./notification_visibility"; +import { WebPushManager } from "./web_push_hook"; + +// UI hooks +import { + PreserveFocus, + PreserveSearchFocus, + FlashAutoDismiss, + TimelineReply, + ScrollToTop, + RemoteProfileStickyFollow, + ImageFallback, +} from "./ui_hooks"; +import { FileExplorer } from "./file_explorer_hook"; +import { PaigeSearch } from "./paige_search_hook"; + +// Call hooks +import { + CallInitiator, + CallReceiver, + CallControls, + VideoDisplay, + CallTimer, +} from "./call_hooks"; +import { VoiceChannel } from "./voice_channel_hook"; + +// Profile hooks +import { + TypewriterHook, + TabTitleTypewriter, + VideoBackground, + StatusSelector, +} from "./profile_hooks"; +import { ProofGraph } from "./proof_graph_hook"; +import { UPlotChart } from "./analytics_hooks"; + +// Timeline/Feed hooks +import { + PostClick, + ManualPagination, +} from "./timeline_hooks"; +import { UserHoverCard, ImageModal } from "./timeline_media_hooks"; +import { + PreserveStreamAnchor, + PreserveQueuedPostsButtonScroll, +} from "./timeline_preservation_hooks"; +import { SessionContinuity } from "./timeline_session_continuity"; +import { AnimatedCount, RemoteFollowButton } from "./timeline_status_hooks"; + +// Form/Utility hooks +import { + FormSubmit, + TagInputHook, + SuggestionDropdown, + VPNDownload, + AtominePow, +} from "./form_hooks"; + +// Password manager hooks +import { Nerve } from "./nerve_hooks"; + +// Account-password encrypted data vault +import { VaultManager } from "./vault_hooks"; +import { KairoVault } from "./kairo_hooks"; +import { KairoGraph } from "./kairo_graph_hook"; + +// Mailbox private storage hooks +import { + MailboxPrivateStorage, +} from "./mailbox_private_storage_hooks"; +import { PrivateMailboxCompose } from "./mailbox_private_compose_hook"; +import { PrivateMailboxMessages } from "./mailbox_private_messages_hook"; + +// Passkey hooks +import { PasskeyRegister, PasskeyAuth } from "./passkey_hooks"; + +// Presence hooks +import { ActivityTracker } from "./presence_hooks"; + +// Static site and profile-builder hooks +import { DragDrop, CodeEditor } from "./static_site_hooks"; +import { ProfileLinkReorder } from "./profile_link_reorder_hook"; + +// Export all hooks as a single object +export const Hooks = { + // Chat + AutoExpandTextarea, + SimpleChatInput, + MessageList, + ContextMenu, + MessageContextMenu, + MessageMenuButton, + ChatKeyboardShortcuts, + CopyChatMessage, + VoiceRecorder, + ChatE2EE, + + // Email + KeyboardShortcuts, + EmailIframeResize, + EmailShowKeyboardShortcuts, + EmailComposeKeyboardShortcuts, + + // Markdown + ReplyMarkdownEditor, + + // Notifications + NotificationHandler, + NotificationVisibility, + WebPushManager, + + // UI + PreserveFocus, + PreserveSearchFocus, + FlashAutoDismiss, + FileExplorer, + PaigeSearch, + TimelineReply, + ScrollToTop, + RemoteProfileStickyFollow, + ImageFallback, + + // Calls + CallInitiator, + CallReceiver, + CallControls, + VideoDisplay, + CallTimer, + VoiceChannel, + + // Profile + TypewriterHook, + TabTitleTypewriter, + VideoBackground, + StatusSelector, + ProofGraph, + UPlotChart, + + // Timeline/Feed + PostClick, + AnimatedCount, + RemoteFollowButton, + ManualPagination, + PreserveStreamAnchor, + PreserveQueuedPostsButtonScroll, + UserHoverCard, + ImageModal, + SessionContinuity, + + // Form/Utility + FormSubmit, + TagInputHook, + SuggestionDropdown, + VPNDownload, + AtominePow, + Nerve, + VaultManager, + KairoVault, + KairoGraph, + MailboxPrivateStorage, + PrivateMailboxCompose, + PrivateMailboxMessages, + + // Static Site + DragDrop, + CodeEditor, + ProfileLinkReorder, + + // Presence + ActivityTracker, + + // Passkey + PasskeyRegister, + PasskeyAuth, + + // (intentionally no product-specific hooks here) +}; diff --git a/apps/elektrine/assets/js/hooks/kairo_graph_hook.js b/apps/elektrine/assets/js/hooks/kairo_graph_hook.js new file mode 100644 index 0000000..2b4a855 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/kairo_graph_hook.js @@ -0,0 +1,442 @@ +/** + * KairoGraph: a force-directed graph of Kairo sources. + * + * The LiveView serves a `data-graph` payload of `{nodes, edges}` where each node + * is a source (file) and an edge connects two sources that share a tag (edge + * `weight` = number of shared tags). Layout is a Fruchterman-Reingold force + * simulation whose per-frame displacement is capped by a cooling "temperature", + * so it converges quickly and then stops. + * + * Rendering is done on a : the whole scene is cleared and repainted each + * frame, which avoids the repaint trails and per-element cost of animating + * hundreds of SVG nodes. Click a node to open that source; drag to reposition, + * scroll to zoom, drag the background to pan. + */ + +const GRAVITY = 0.04 // pull toward center each frame +const COOLING = 0.95 // temperature decay per frame +const MIN_TEMP = 0.4 // below this the simulation is considered settled +const DRAG_THRESHOLD = 4 // px before a press counts as a drag, not a click + +const EDGE_COLOR = "148, 163, 184" // slate-400 as "r, g, b" + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function parsePayload(el) { + const raw = el.dataset.graph + if (!raw) return null + try { + const parsed = JSON.parse(raw) + if (!parsed || !Array.isArray(parsed.nodes)) return null + return parsed + } catch (_error) { + return null + } +} + +export const KairoGraph = { + mounted() { + this.lastPayload = null + this.loop = this.loop.bind(this) + this.frame = null + this.drawFrame = null + this.render() + }, + + updated() { + if ((this.el.dataset.graph || "") !== this.lastPayload) this.render() + }, + + destroyed() { + this.teardown() + }, + + render() { + const graph = parsePayload(this.el) + this.teardown() + this.lastPayload = this.el.dataset.graph || "" + + this.dpr = window.devicePixelRatio || 1 + this.labelColor = getComputedStyle(this.el).color || "#64748b" + + this.canvas = document.createElement("canvas") + this.canvas.style.display = "block" + this.canvas.style.width = "100%" + this.canvas.style.height = "100%" + this.canvas.style.cursor = "grab" + this.canvas.style.touchAction = "none" + this.el.appendChild(this.canvas) + this.ctx = this.canvas.getContext("2d") + + this.scale = 1 + this.tx = 0 + this.ty = 0 + this.hovered = null + this.running = false + this.resize() + + if (!graph || graph.nodes.length === 0) { + this.nodes = [] + this.edges = [] + this.drawEmpty() + this.bindEvents() + return + } + + this.buildScene(graph) + this.bindEvents() + this.kick() + }, + + resize() { + this.width = Math.max(this.el.clientWidth, 320) + this.height = Math.max(this.el.clientHeight, 360) + this.canvas.width = Math.round(this.width * this.dpr) + this.canvas.height = Math.round(this.height * this.dpr) + }, + + buildScene(graph) { + const cx = this.width / 2 + const cy = this.height / 2 + const ring = Math.min(this.width, this.height) * 0.34 + const count = graph.nodes.length + + this.k = Math.sqrt((this.width * this.height) / Math.max(count, 1)) * 0.55 + this.temp = Math.min(this.width, this.height) * 0.12 + + this.nodes = graph.nodes.map((node, index) => { + const angle = (index / count) * Math.PI * 2 + const jitter = ((index * 37) % 40) - 20 + return { + ...node, + degree: 0, + x: cx + Math.cos(angle) * (ring + jitter), + y: cy + Math.sin(angle) * (ring + jitter), + dx: 0, + dy: 0 + } + }) + + this.nodeById = new Map(this.nodes.map((node) => [node.id, node])) + this.edges = (graph.edges || []) + .map((edge) => ({ + weight: edge.weight || 1, + source: this.nodeById.get(edge.source), + target: this.nodeById.get(edge.target) + })) + .filter((edge) => edge.source && edge.target) + + this.neighbors = new Map(this.nodes.map((node) => [node.id, new Set([node.id])])) + this.edges.forEach((edge) => { + edge.source.degree += 1 + edge.target.degree += 1 + this.neighbors.get(edge.source.id).add(edge.target.id) + this.neighbors.get(edge.target.id).add(edge.source.id) + }) + + this.nodes.forEach((node) => { + node.r = 5 + Math.sqrt(node.degree) * 1.6 + }) + + this.showAllLabels = count <= 45 + }, + + bindEvents() { + this.onPointerDown = (event) => { + this.canvas.setPointerCapture(event.pointerId) + const node = this.nodeAt(event) + if (node) { + this.dragNode = node + this.dragMoved = false + this.pointerStart = { x: event.clientX, y: event.clientY } + } else { + this.panning = true + this.panStart = { x: event.clientX, y: event.clientY } + this.canvas.style.cursor = "grabbing" + } + } + + this.onPointerMove = (event) => { + if (this.dragNode) { + if ( + Math.abs(event.clientX - this.pointerStart.x) > DRAG_THRESHOLD || + Math.abs(event.clientY - this.pointerStart.y) > DRAG_THRESHOLD + ) { + this.dragMoved = true + } + const p = this.toLogical(event) + this.dragNode.x = p.x + this.dragNode.y = p.y + this.kick() + } else if (this.panning) { + this.tx += event.clientX - this.panStart.x + this.ty += event.clientY - this.panStart.y + this.panStart = { x: event.clientX, y: event.clientY } + this.requestDraw() + } else { + const node = this.nodeAt(event) + const id = node ? node.id : null + this.canvas.style.cursor = node ? "pointer" : "grab" + if (id !== this.hovered) { + this.hovered = id + this.requestDraw() + } + } + } + + this.onPointerUp = (event) => { + if (this.canvas.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId) + } + if (this.dragNode && !this.dragMoved && this.dragNode.ref != null) { + this.pushEvent("select_source", { id: String(this.dragNode.ref) }) + } + this.dragNode = null + this.panning = false + this.canvas.style.cursor = "grab" + } + + this.onPointerCancel = (event) => { + if (this.canvas.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId) + } + this.dragNode = null + this.panning = false + this.canvas.style.cursor = "grab" + } + + this.onWheel = (event) => { + event.preventDefault() + const rect = this.canvas.getBoundingClientRect() + const px = event.clientX - rect.left + const py = event.clientY - rect.top + const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1 + const next = clamp(this.scale * factor, 0.25, 4) + this.tx = px - ((px - this.tx) * next) / this.scale + this.ty = py - ((py - this.ty) * next) / this.scale + this.scale = next + this.requestDraw() + } + + this.canvas.addEventListener("pointerdown", this.onPointerDown) + this.canvas.addEventListener("pointermove", this.onPointerMove) + this.canvas.addEventListener("pointerup", this.onPointerUp) + this.canvas.addEventListener("pointercancel", this.onPointerCancel) + this.canvas.addEventListener("wheel", this.onWheel, { passive: false }) + + this.resizeObserver = new ResizeObserver(() => { + const w = Math.max(this.el.clientWidth, 320) + const h = Math.max(this.el.clientHeight, 360) + if (w === this.width && h === this.height) return + this.resize() + this.kick() + }) + this.resizeObserver.observe(this.el) + }, + + toLogical(event) { + const rect = this.canvas.getBoundingClientRect() + return { + x: (event.clientX - rect.left - this.tx) / this.scale, + y: (event.clientY - rect.top - this.ty) / this.scale + } + }, + + nodeAt(event) { + const p = this.toLogical(event) + let best = null + let bestDist = Infinity + this.nodes.forEach((node) => { + const d = Math.hypot(node.x - p.x, node.y - p.y) + const hit = node.r + 4 / this.scale + if (d <= hit && d < bestDist) { + best = node + bestDist = d + } + }) + return best + }, + + kick() { + this.temp = Math.max(this.temp, Math.min(this.width, this.height) * 0.06) + if (!this.running) { + this.running = true + this.frame = window.requestAnimationFrame(this.loop) + } + }, + + // One-off repaint when the simulation is settled (hover, pan, zoom). + requestDraw() { + if (this.running || this.drawFrame !== null) return + this.drawFrame = window.requestAnimationFrame(() => { + this.drawFrame = null + if (!this.running) this.draw() + }) + }, + + loop() { + this.frame = null + if (!this.ctx) return + this.simulate() + this.draw() + this.temp *= COOLING + if (this.temp > MIN_TEMP || this.dragNode) { + this.frame = window.requestAnimationFrame(this.loop) + } else { + this.running = false + this.draw() // final frame with labels + } + }, + + simulate() { + const nodes = this.nodes + const k = this.k + const cx = this.width / 2 + const cy = this.height / 2 + + nodes.forEach((node) => { + node.dx = 0 + node.dy = 0 + }) + + for (let i = 0; i < nodes.length; i += 1) { + for (let j = i + 1; j < nodes.length; j += 1) { + const a = nodes[i] + const b = nodes[j] + let dx = a.x - b.x + let dy = a.y - b.y + let dist = Math.hypot(dx, dy) + if (dist < 0.5) { + dx = ((i % 7) - 3) * 0.1 + 0.05 + dy = ((j % 7) - 3) * 0.1 + 0.05 + dist = Math.hypot(dx, dy) + } + const rep = (k * k) / dist + const fx = (dx / dist) * rep + const fy = (dy / dist) * rep + a.dx += fx + a.dy += fy + b.dx -= fx + b.dy -= fy + } + } + + this.edges.forEach((edge) => { + const a = edge.source + const b = edge.target + const dx = a.x - b.x + const dy = a.y - b.y + const dist = Math.hypot(dx, dy) || 0.5 + const att = (dist * dist) / k + const fx = (dx / dist) * att + const fy = (dy / dist) * att + a.dx -= fx + a.dy -= fy + b.dx += fx + b.dy += fy + }) + + nodes.forEach((node) => { + node.dx += (cx - node.x) * GRAVITY + node.dy += (cy - node.y) * GRAVITY + }) + + // Integrate, capping per-frame movement by temperature, and keep nodes + // inside the viewport so the area-tuned layout fills the frame at scale 1 + // instead of expanding off-screen. + const m = 24 + nodes.forEach((node) => { + if (this.dragNode === node) return + const disp = Math.hypot(node.dx, node.dy) || 1 + const limit = Math.min(disp, this.temp) + node.x = clamp(node.x + (node.dx / disp) * limit, m, this.width - m) + node.y = clamp(node.y + (node.dy / disp) * limit, m, this.height - m) + }) + }, + + draw() { + const ctx = this.ctx + if (!ctx) return + + ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0) + ctx.clearRect(0, 0, this.width, this.height) + ctx.translate(this.tx, this.ty) + ctx.scale(this.scale, this.scale) + + const active = this.hovered + const lit = active ? this.neighbors.get(active) : null + + // Edges. + ctx.lineCap = "round" + this.edges.forEach((edge) => { + const on = !active || edge.source.id === active || edge.target.id === active + const alpha = active ? (on ? 0.7 : 0.04) : 0.22 + ctx.strokeStyle = `rgba(${EDGE_COLOR}, ${alpha})` + ctx.lineWidth = clamp(edge.weight, 1, 4) + ctx.beginPath() + ctx.moveTo(edge.source.x, edge.source.y) + ctx.lineTo(edge.target.x, edge.target.y) + ctx.stroke() + }) + + // Nodes. + this.nodes.forEach((node) => { + const on = !active || lit.has(node.id) + ctx.globalAlpha = on ? 1 : 0.2 + ctx.beginPath() + ctx.arc(node.x, node.y, node.r, 0, Math.PI * 2) + ctx.fillStyle = node.color || "#9ca3af" + ctx.fill() + ctx.lineWidth = 1.5 + ctx.strokeStyle = "rgba(0, 0, 0, 0.28)" + ctx.stroke() + }) + ctx.globalAlpha = 1 + + // Labels only at rest (or zoomed in / hovered neighborhood), cheap to draw + // on canvas with no trails, but skipping them keeps motion frames light. + const showLabels = !this.running || this.scale > 1.4 || active + if (showLabels) { + ctx.fillStyle = this.labelColor + ctx.textAlign = "center" + ctx.textBaseline = "top" + ctx.font = "10px ui-sans-serif, system-ui, sans-serif" + const zoomedIn = this.scale > 1.4 + this.nodes.forEach((node) => { + const show = (!this.running && this.showAllLabels) || zoomedIn || (active && lit.has(node.id)) + if (!show || !node.label) return + ctx.globalAlpha = active && !lit.has(node.id) ? 0.2 : 0.85 + ctx.fillText(node.label, node.x, node.y + node.r + 2) + }) + ctx.globalAlpha = 1 + } + }, + + drawEmpty() { + const ctx = this.ctx + ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0) + ctx.clearRect(0, 0, this.width, this.height) + ctx.fillStyle = `rgba(${EDGE_COLOR}, 0.8)` + ctx.textAlign = "center" + ctx.textBaseline = "middle" + ctx.font = "14px ui-sans-serif, system-ui, sans-serif" + ctx.fillText("No sources to graph yet.", this.width / 2, this.height / 2) + }, + + teardown() { + this.running = false + if (this.frame !== null) window.cancelAnimationFrame(this.frame) + if (this.drawFrame !== null) window.cancelAnimationFrame(this.drawFrame) + this.frame = null + this.drawFrame = null + if (this.resizeObserver) this.resizeObserver.disconnect() + this.resizeObserver = null + this.dragNode = null + this.panning = false + if (this.canvas && this.canvas.parentNode) this.canvas.remove() + this.canvas = null + this.ctx = null + } +} diff --git a/apps/elektrine/assets/js/hooks/kairo_hooks.js b/apps/elektrine/assets/js/hooks/kairo_hooks.js new file mode 100644 index 0000000..a609476 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/kairo_hooks.js @@ -0,0 +1,213 @@ +/** + * KairoVault - zero-knowledge encryption/decryption for Kairo sources. + * Encrypted rows decrypt on demand under the Kairo subkey of the unlocked + * encrypted data key, which can be unlocked inline for the tab. The composer can also + * encrypt new notes client-side before they are pushed to the server, so the + * plaintext body is never persisted. + */ + +import { decryptValue, encryptValue, unwrapWithSecret } from "./vault_crypto" +import * as vaultSession from "./vault_session" + +const FEATURE = "kairo" +const AAD = { purpose: "elektrine-kairo-source" } + +export const KairoVault = { + mounted() { + this.encryptSavePending = false + this.unlockPending = false + this.unsubscribe = vaultSession.subscribe(() => this.renderLockState()) + this.onRootClick = (event) => this.onClick(event) + this.onRootKeydown = (event) => this.onKeydown(event) + this.el.addEventListener("click", this.onRootClick) + this.el.addEventListener("keydown", this.onRootKeydown) + this.renderLockState() + }, + + updated() { + // LiveView re-rendered the contents (e.g. selecting a source), which resets + // the locked-hint to its server default; re-apply the lock state. + this.renderLockState() + }, + + destroyed() { + this.unsubscribe && this.unsubscribe() + this.el.removeEventListener("click", this.onRootClick) + this.el.removeEventListener("keydown", this.onRootKeydown) + }, + + renderLockState() { + const hint = this.el.querySelector("[data-kairo-locked-hint]") + if (hint) hint.classList.toggle("hidden", vaultSession.isUnlocked()) + }, + + onClick(event) { + const unlock = event.target.closest("[data-kairo-master-unlock]") + if (unlock) { + event.preventDefault() + this.unlockMaster() + return + } + + const decrypt = event.target.closest("[data-kairo-decrypt]") + if (decrypt) { + event.preventDefault() + this.decryptRow(decrypt) + return + } + + const encryptSave = event.target.closest("[data-kairo-encrypt-save]") + if (encryptSave) { + event.preventDefault() + this.saveEncryptedNote(encryptSave) + } + }, + + onKeydown(event) { + if (event.key !== "Enter" || !event.target.closest("[data-kairo-master-unlock-input]")) return + event.preventDefault() + this.unlockMaster() + }, + + async unlockMaster() { + if (this.unlockPending) return + + const input = this.el.querySelector("[data-kairo-master-unlock-input]") + const error = this.el.querySelector("[data-kairo-master-error]") + const wrapped = this.wrappedDek() + const setError = (message) => { + if (!error) return + error.textContent = message || "" + error.classList.toggle("hidden", !message) + } + + if (!wrapped) { + return setError("Set up account-password encryption first.") + } + + if (!input || input.value.trim() === "") { + return setError("Enter your account password.") + } + + const button = this.el.querySelector("[data-kairo-master-unlock]") + this.unlockPending = true + if (button) { + button.disabled = true + button.setAttribute("aria-busy", "true") + } + + try { + const mdk = await unwrapWithSecret(wrapped, input.value) + vaultSession.unlock(mdk) + input.value = "" + setError("") + // The vault-change subscription re-renders the lock state. + } catch (_error) { + setError( + "Incorrect account password. If you just reset it, recover encrypted data at /account/encrypted-data." + ) + } finally { + this.unlockPending = false + if (button) { + button.disabled = false + button.removeAttribute("aria-busy") + } + } + }, + + wrappedDek() { + const raw = this.el.dataset.kairoMasterWrappedDek + if (!raw) return null + try { + return JSON.parse(raw) + } catch (_error) { + return null + } + }, + + async saveEncryptedNote(button) { + if (this.encryptSavePending) return + + const form = this.el.querySelector("#kairo-note-form") + if (!form) return + + const error = this.el.querySelector("[data-kairo-encrypt-error]") + const setError = (message) => { + if (!error) return + error.textContent = message || "" + error.classList.toggle("hidden", !message) + } + + if (!vaultSession.isUnlocked()) { + return setError("Enter your account password to save encrypted notes.") + } + + const field = (name) => { + const input = form.querySelector(`[name="note[${name}]"]`) + return input ? input.value : "" + } + + const content = field("content") + const title = field("title") + if (content.trim() === "" && title.trim() === "") { + return setError("Add a title or some content first.") + } + + this.encryptSavePending = true + button.disabled = true + button.setAttribute("aria-busy", "true") + + try { + const key = await vaultSession.featureKey(FEATURE) + const payload = await encryptValue(content, key, AAD) + + this.pushEvent( + "save_encrypted_note", + { + note: { title, tags: field("tags"), project_id: field("project_id") }, + payload + }, + (reply) => { + this.finishEncryptedSave() + if (reply && reply.ok) { + setError("") + } else { + setError((reply && reply.error) || "Could not save the encrypted note.") + } + } + ) + } catch (_error) { + this.finishEncryptedSave() + setError("Encryption failed in this browser.") + } + }, + + finishEncryptedSave() { + this.encryptSavePending = false + const button = this.el.querySelector("[data-kairo-encrypt-save]") + if (!button) return + button.disabled = false + button.removeAttribute("aria-busy") + }, + + async decryptRow(button) { + const output = button.parentElement.querySelector("[data-kairo-output]") + if (!output) return + + if (!vaultSession.isUnlocked()) { + output.textContent = "Enter your account password to read this." + output.classList.remove("hidden") + return + } + + try { + const payload = JSON.parse(button.dataset.kairoPayload) + const key = await vaultSession.featureKey(FEATURE) + output.textContent = await decryptValue(payload, key, AAD) + output.classList.remove("hidden") + } catch (_error) { + output.textContent = "Could not decrypt this source." + output.classList.remove("hidden") + } + } +} diff --git a/apps/elektrine/assets/js/hooks/mailbox_private_auth_forms.js b/apps/elektrine/assets/js/hooks/mailbox_private_auth_forms.js new file mode 100644 index 0000000..ccb9a8f --- /dev/null +++ b/apps/elektrine/assets/js/hooks/mailbox_private_auth_forms.js @@ -0,0 +1,112 @@ +import { submitFormPreservingEvents } from "../utils/form_submission" +import { + readMailboxFields, + readPasswordUpdateContext, + readVaultFields, + passwordUpdateValidationError, + shouldHandlePasswordUpdate +} from "./mailbox_private_auth_payloads" +import { unwrapWithSecret, wrapWithSecret } from "./vault_crypto" +import { + VERIFY_TEXT, + cacheLoginPassword, + encoder, + notify, + unwrapMailboxPrivateKey, + wrapBytes +} from "./mailbox_private_storage_hooks" + +function bindPrivateMailboxLoginForm(form) { + if (!form || form.dataset.privateMailboxLoginBound === "true") return + + form.addEventListener("submit", () => { + const passwordInput = form.querySelector("input[name='user[password]']") + const password = passwordInput?.value || "" + + if (password.trim() !== "") { + cacheLoginPassword(password) + } + }) + + form.dataset.privateMailboxLoginBound = "true" +} + +function bindPrivateMailboxPasswordForm(form) { + if (!form || form.dataset.privateMailboxPasswordBound === "true") return + + form.addEventListener("submit", async (event) => { + if (form.dataset.privateMailboxRewrapSubmitting === "true") { + return + } + + const context = readPasswordUpdateContext(form) + + if (!shouldHandlePasswordUpdate(context)) return + if (context.currentPassword.trim() === "" || context.newPassword.trim() === "") return + + const vaultFields = readVaultFields(form) + const mailboxFields = readMailboxFields(form) + const validationError = passwordUpdateValidationError(context, vaultFields, mailboxFields) + + if (validationError) { + event.preventDefault() + notify(validationError) + return + } + + try { + event.preventDefault() + + if (context.vaultConfigured) { + const mdk = await unwrapWithSecret(context.vaultWrappedDek, context.currentPassword) + vaultFields.wrappedDekField.value = JSON.stringify( + await wrapWithSecret(mdk, context.newPassword) + ) + vaultFields.wrappedDekRecoveryField.value = JSON.stringify(context.vaultWrappedDekRecovery) + } + + if (context.shouldRewrapMailbox) { + const privateKeyBytes = await unwrapMailboxPrivateKey( + context.wrappedKeyPayload, + context.verifierPayload, + context.currentPassword + ) + const nextWrappedKey = await wrapBytes(privateKeyBytes, context.newPassword, "account_password") + const nextVerifier = await wrapBytes( + encoder.encode(VERIFY_TEXT), + context.newPassword, + "account_password", + "verifier" + ) + + mailboxFields.wrappedKeyField.value = JSON.stringify(nextWrappedKey) + mailboxFields.verifierField.value = JSON.stringify(nextVerifier) + mailboxFields.unlockModeField.value = "account_password" + } + + cacheLoginPassword(context.newPassword) + + form.dataset.privateMailboxRewrapSubmitting = "true" + submitFormPreservingEvents(form) + } catch (_error) { + notify( + "Could not update encrypted data for the new password. Check your current password and try again." + ) + } + }) + + form.dataset.privateMailboxPasswordBound = "true" +} + +export function initPrivateMailboxAuthForms(rootCandidate = document) { + const root = + rootCandidate && typeof rootCandidate.querySelectorAll === "function" ? rootCandidate : document + + root + .querySelectorAll("[data-private-mailbox-login-form='true']") + .forEach((form) => bindPrivateMailboxLoginForm(form)) + + root + .querySelectorAll("[data-private-mailbox-password-form='true']") + .forEach((form) => bindPrivateMailboxPasswordForm(form)) +} diff --git a/apps/elektrine/assets/js/hooks/mailbox_private_auth_payloads.js b/apps/elektrine/assets/js/hooks/mailbox_private_auth_payloads.js new file mode 100644 index 0000000..5e23cac --- /dev/null +++ b/apps/elektrine/assets/js/hooks/mailbox_private_auth_payloads.js @@ -0,0 +1,68 @@ +import { parsePayload } from "./mailbox_private_crypto" + +function value(form, selector) { + return form.querySelector(selector)?.value || "" +} + +export function readPasswordUpdateContext(form) { + const vaultConfigured = form.dataset.vaultConfigured === "true" + const configured = form.dataset.privateMailboxConfigured === "true" + const unlockMode = form.dataset.privateMailboxUnlockMode || "separate_passphrase" + + return { + vaultConfigured, + configured, + unlockMode, + shouldRewrapMailbox: configured && unlockMode === "account_password", + currentPassword: value(form, "input[name='user[current_password]']"), + newPassword: value(form, "input[name='user[password]']"), + vaultWrappedDek: parsePayload(form.dataset.vaultWrappedDek), + vaultWrappedDekRecovery: parsePayload(form.dataset.vaultWrappedDekRecovery), + wrappedKeyPayload: parsePayload(form.dataset.privateMailboxWrappedKey), + verifierPayload: parsePayload(form.dataset.privateMailboxVerifier) + } +} + +export function readVaultFields(form) { + return { + wrappedDekField: form.querySelector("input[name='user[vault_wrapped_dek]']"), + wrappedDekRecoveryField: form.querySelector( + "input[name='user[vault_wrapped_dek_recovery]']" + ) + } +} + +export function readMailboxFields(form) { + return { + wrappedKeyField: form.querySelector("input[name='user[private_mailbox_wrapped_private_key]']"), + verifierField: form.querySelector("input[name='user[private_mailbox_verifier]']"), + unlockModeField: form.querySelector("input[name='user[private_mailbox_unlock_mode]']") + } +} + +export function shouldHandlePasswordUpdate(context) { + return context.vaultConfigured || (context.configured && context.unlockMode === "account_password") +} + +export function passwordUpdateValidationError(context, vaultFields, mailboxFields) { + if (context.vaultConfigured && (!context.vaultWrappedDek || !context.vaultWrappedDekRecovery)) { + return "Encrypted data update is unavailable. Reload and try again." + } + + if (context.vaultConfigured && (!vaultFields.wrappedDekField || !vaultFields.wrappedDekRecoveryField)) { + return "Encrypted data update fields are missing. Reload and try again." + } + + if (context.shouldRewrapMailbox && (!context.wrappedKeyPayload || !context.verifierPayload)) { + return "Private mailbox rewrap data is unavailable. Reload and try again." + } + + if ( + context.shouldRewrapMailbox && + (!mailboxFields.wrappedKeyField || !mailboxFields.verifierField || !mailboxFields.unlockModeField) + ) { + return "Private mailbox password update fields are missing. Reload and try again." + } + + return null +} diff --git a/apps/elektrine/assets/js/hooks/mailbox_private_compose_hook.js b/apps/elektrine/assets/js/hooks/mailbox_private_compose_hook.js new file mode 100644 index 0000000..6e0e14b --- /dev/null +++ b/apps/elektrine/assets/js/hooks/mailbox_private_compose_hook.js @@ -0,0 +1,198 @@ +import { + buildForwardBody, + buildReplyBody, + escapeHtml, + formatAttachmentMeta, + payloadString, + prefixedSubject +} from "./mailbox_private_content" +import { + decryptAttachmentPayload, + decryptMessagePayload, + getStoredPrivateKey, + maybeSetValue, + parsePayload +} from "./mailbox_private_storage_hooks" + +export const PrivateMailboxCompose = { + mounted() { + this.mailboxId = this.el.dataset.privateMailboxId + this.mode = this.el.dataset.privateComposeMode + this.messageEnvelope = parsePayload(this.el.dataset.privateOriginalPayload) + this.attachments = parsePayload(this.el.dataset.privateOriginalAttachments) || {} + this.metadata = { + from: this.el.dataset.privateOriginalFrom || "", + to: this.el.dataset.privateOriginalTo || "", + cc: this.el.dataset.privateOriginalCc || "", + bcc: this.el.dataset.privateOriginalBcc || "", + status: this.el.dataset.privateOriginalStatus || "", + insertedAt: this.el.dataset.privateOriginalInsertedAt || "" + } + + this.onMailboxChange = () => this.applyPrefill() + window.addEventListener("elektrine:private-mailbox-unlocked", this.onMailboxChange) + window.addEventListener("elektrine:private-mailbox-locked", this.onMailboxChange) + + this.applyPrefill() + }, + + updated() { + this.applyPrefill() + }, + + destroyed() { + window.removeEventListener("elektrine:private-mailbox-unlocked", this.onMailboxChange) + window.removeEventListener("elektrine:private-mailbox-locked", this.onMailboxChange) + }, + + get subjectInput() { + return document.querySelector("input[name='email[subject]']") + }, + + get hiddenBodyInput() { + return document.querySelector("[data-private-compose-hidden-body]") + }, + + get previewElement() { + return document.querySelector("[data-private-compose-preview]") + }, + + get privateForwardAttachmentsInput() { + return document.querySelector("#private-forward-attachments") + }, + + get attachmentsPreview() { + return document.querySelector("[data-private-compose-attachments-preview]") + }, + + renderLockedAttachments() { + if (!this.attachmentsPreview || this.mode !== "forward") return + + const hasPrivateAttachments = Object.values(this.attachments).some((attachment) => + Boolean(attachment?.private_encrypted_payload) + ) + + if (!hasPrivateAttachments) return + + this.attachmentsPreview.innerHTML = ` +
+ Unlock the mailbox in this tab to include protected attachments in the forwarded message. +
+ ` + }, + + async applyPrefill() { + if (!this.mailboxId || !this.messageEnvelope) return + + if (!getStoredPrivateKey(this.mailboxId)) { + this.renderLockedAttachments() + if (this.privateForwardAttachmentsInput) { + this.privateForwardAttachmentsInput.value = "" + } + return + } + + try { + const payload = await decryptMessagePayload(this.messageEnvelope, this.mailboxId) + if (!payload) return + + const originalSubject = typeof payload.subject === "string" ? payload.subject : "" + const subject = prefixedSubject(this.mode, originalSubject) + const metadata = { + ...this.metadata, + from: payloadString(payload, "from") || this.metadata.from, + to: payloadString(payload, "to") || this.metadata.to, + cc: payloadString(payload, "cc") || this.metadata.cc, + bcc: payloadString(payload, "bcc") || this.metadata.bcc + } + + maybeSetValue(this.subjectInput, subject, (currentValue) => + currentValue.includes("Encrypted message") + ) + + const decryptedAttachments = await this.decryptForwardAttachments() + + const body = + this.mode === "forward" + ? buildForwardBody(payload, metadata, decryptedAttachments) + : buildReplyBody(payload, metadata) + + const protectedForwardAttachments = decryptedAttachments.filter((attachment) => attachment.data) + + if (this.hiddenBodyInput) { + this.hiddenBodyInput.value = body + } + + if (this.previewElement) { + this.previewElement.textContent = body + } + + if (this.privateForwardAttachmentsInput) { + this.privateForwardAttachmentsInput.value = JSON.stringify(protectedForwardAttachments) + } + + this.renderForwardAttachments(decryptedAttachments) + } catch (_error) { + this.renderLockedAttachments() + } + }, + + async decryptForwardAttachments() { + const attachments = Object.entries(this.attachments || {}) + if (attachments.length === 0) return [] + + const decrypted = await Promise.all( + attachments.map(async ([attachmentId, attachment]) => { + if (!attachment?.private_encrypted_payload) { + return { + attachment_id: attachmentId, + filename: attachment.filename || "attachment", + content_type: attachment.content_type || "application/octet-stream", + size: attachment.size || 0 + } + } + + const payload = await decryptAttachmentPayload(attachment.private_encrypted_payload, this.mailboxId) + + return { + attachment_id: attachmentId, + filename: payload.filename || "attachment", + content_type: payload.content_type || "application/octet-stream", + size: payload.size || 0, + encoding: payload.encoding || "base64", + data: payload.data || "" + } + }) + ) + + return decrypted + }, + + renderForwardAttachments(attachments) { + if (!this.attachmentsPreview || this.mode !== "forward") return + + if (attachments.length === 0) { + this.attachmentsPreview.innerHTML = "" + return + } + + this.attachmentsPreview.innerHTML = attachments + .map( + (attachment) => ` +
+
+
+ +
+
+

${escapeHtml(attachment.filename || "attachment")}

+

${escapeHtml(formatAttachmentMeta(attachment))}

+
+
+
Will be forwarded
+
+ ` + ) + .join("") + } +} diff --git a/apps/elektrine/assets/js/hooks/mailbox_private_content.js b/apps/elektrine/assets/js/hooks/mailbox_private_content.js new file mode 100644 index 0000000..a62481c --- /dev/null +++ b/apps/elektrine/assets/js/hooks/mailbox_private_content.js @@ -0,0 +1,301 @@ +function htmlToText(html) { + if (!html) return "" + + const doc = new DOMParser().parseFromString(html, "text/html") + return (doc.body?.textContent || "").replace(/\s+/g, " ").trim() +} + +export function previewText(payload) { + const text = (payload.text_body || htmlToText(payload.html_body || "") || "").trim() + if (!text) return "Encrypted mailbox content" + return text.length > 160 ? `${text.slice(0, 160)}...` : text +} + +export function bodyText(payload) { + const text = (payload.text_body || "").trim() + if (text) return text + + const htmlText = htmlToText(payload.html_body || "") + return htmlText || "Encrypted mailbox content" +} + +export function payloadString(payload, field) { + const value = payload?.[field] + return typeof value === "string" ? value : "" +} + +export function escapeHtml(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") +} + +export function buildSandboxedEmailHtml(content) { + const csp = [ + "default-src 'none'", + "img-src data: cid: https:", + "media-src data: cid: https:", + "style-src 'unsafe-inline' https:", + "font-src data: https:", + "connect-src 'none'", + "frame-src 'none'", + "child-src 'none'", + "object-src 'none'", + "base-uri 'none'", + "form-action 'none'" + ].join("; ") + + return ` + + + + + + + + +${content || ""} +` +} + +const HTML_FRAGMENT_PATTERN = + /<\/?(?:html|head|body|table|thead|tbody|tfoot|tr|th|td|div|p|span|br|a|img|style|section|article|h[1-6])\b/i +const ENCODED_HTML_FRAGMENT_PATTERN = + /<\s*(?:!doctype\b|\/?\s*(?:html|head|body|table|thead|tbody|tfoot|tr|th|td|div|p|span|br|a|img|style|section|article|h[1-6])\b)/gi + +function maybeDecodeEntityEncodedHtml(content) { + if (HTML_FRAGMENT_PATTERN.test(content)) return content + + const matches = content.match(ENCODED_HTML_FRAGMENT_PATTERN) || [] + if (matches.length < 2) return content + + const textarea = document.createElement("textarea") + textarea.innerHTML = content + return textarea.value +} + +function removeUnsafeElement(element) { + if (element && typeof element.remove === "function") { + element.remove() + } +} + +function isDangerousUrl(value) { + if (typeof value !== "string") return false + + const normalized = value.replace(/[\u0000-\u001F\u007F\s]+/g, "").toLowerCase() + return ( + normalized.startsWith("javascript:") || + normalized.startsWith("vbscript:") || + normalized.startsWith("data:text/html") + ) +} + +function scrubSrcset(value) { + if (typeof value !== "string") return "" + + return value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => { + const [candidate] = entry.split(/\s+/, 1) + return candidate && !isDangerousUrl(candidate) + }) + .join(", ") +} + +export function sanitizeProtectedHtml(content) { + if (typeof content !== "string" || content.trim() === "") return "" + + const doc = new DOMParser().parseFromString( + maybeDecodeEntityEncodedHtml(content), + "text/html" + ) + + doc + .querySelectorAll( + "script, iframe, frame, frameset, object, embed, applet, form, input, textarea, select, button, meta[http-equiv], base" + ) + .forEach(removeUnsafeElement) + + doc.querySelectorAll("link").forEach((element) => { + const href = element.getAttribute("href") || "" + const rel = (element.getAttribute("rel") || "").toLowerCase() + const blockedRel = + /(?:^|\s)(?:dns-prefetch|import|modulepreload|preconnect|prefetch|preload)(?:\s|$)/.test( + rel + ) + + if (isDangerousUrl(href) || blockedRel) { + removeUnsafeElement(element) + } + }) + + doc.querySelectorAll("*").forEach((element) => { + Array.from(element.attributes).forEach((attribute) => { + const name = attribute.name.toLowerCase() + const value = attribute.value || "" + + if (name.startsWith("on")) { + element.removeAttribute(attribute.name) + return + } + + if ((name === "src" || name === "href" || name === "poster") && isDangerousUrl(value)) { + element.removeAttribute(attribute.name) + return + } + + if (name === "srcset") { + const scrubbed = scrubSrcset(value) + if (scrubbed) { + element.setAttribute(attribute.name, scrubbed) + } else { + element.removeAttribute(attribute.name) + } + return + } + + if (name === "style") { + const scrubbedStyle = value + .replace(/url\(\s*(['"]?)\s*javascript\s*:[^;>]*\1\s*\)/gi, "none") + .replace(/url\(\s*(['"]?)\s*vbscript\s*:[^;>]*\1\s*\)/gi, "none") + .replace(/url\(\s*(['"]?)\s*data\s*:\s*text\/html[^;>]*\1\s*\)/gi, "none") + .replace(/expression\s*\([^)]*\)/gi, "") + + if (scrubbedStyle.trim() === "") { + element.removeAttribute(attribute.name) + } else { + element.setAttribute(attribute.name, scrubbedStyle) + } + } + }) + }) + + const headStyles = Array.from(doc.head?.querySelectorAll("style") || []) + .map((element) => element.outerHTML) + .join("\n") + + return [headStyles, doc.body?.innerHTML || ""].filter(Boolean).join("\n") +} + +export function downloadBytes(filename, contentType, data) { + const binaryString = atob(data) + const bytes = new Uint8Array(binaryString.length) + + for (let index = 0; index < binaryString.length; index += 1) { + bytes[index] = binaryString.charCodeAt(index) + } + + const blob = new Blob([bytes], { type: contentType || "application/octet-stream" }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + + link.href = url + link.download = filename || "attachment" + link.style.display = "none" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} + +function formatBytes(size) { + const value = Number(size || 0) + if (value < 1024) return `${value} bytes` + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB` + return `${(value / (1024 * 1024)).toFixed(1)} MB` +} + +export function formatAttachmentMeta(payload, sizeOverride = null) { + const size = Number.isFinite(sizeOverride) ? sizeOverride : payload.size || 0 + const formattedSize = formatBytes(Math.max(size, 0)) + return `${formattedSize} • ${payload.content_type || "application/octet-stream"}` +} + +function formatDateForQuote(rawValue) { + const value = rawValue ? new Date(rawValue) : null + if (!value || Number.isNaN(value.getTime())) return "" + + return value.toLocaleString(undefined, { + weekday: "short", + month: "short", + day: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit" + }) +} + +function quoteMessageBody(body) { + return (body || "") + .split("\n") + .map((line) => `> ${line}`) + .join("\n") +} + +export function prefixedSubject(mode, subject) { + const original = typeof subject === "string" ? subject.trim() : "" + + if (mode === "forward") { + return original.startsWith("Fwd: ") ? original : `Fwd: ${original}` + } + + return original.startsWith("Re: ") ? original : `Re: ${original}` +} + +export function buildReplyBody(payload, metadata) { + const dateText = formatDateForQuote(metadata.insertedAt) + const senderText = metadata.status === "sent" ? "you" : metadata.from + + return `\n\nOn ${dateText}, ${senderText} wrote:\n${quoteMessageBody(bodyText(payload))}\n` +} + +export function buildForwardBody(payload, metadata, attachments) { + const attachmentBlock = + attachments.length === 0 + ? "" + : `\nAttachments:\n${attachments + .map((attachment) => `- ${attachment.filename} (${attachment.size || 0} bytes)`) + .join("\n")}\n` + + return `\n\n---------- Forwarded message ----------\nFrom: ${metadata.from}\nTo: ${metadata.to}\nDate: ${formatDateForQuote(metadata.insertedAt)}\nSubject: ${payload.subject || ""}${attachmentBlock}\n${bodyText(payload)}\n` +} + +export function restoreAttachmentPlaceholder(element) { + const filenameEl = element.querySelector("[data-private-attachment-filename]") + if (filenameEl?.dataset.privateAttachmentFilenamePlaceholder) { + filenameEl.textContent = filenameEl.dataset.privateAttachmentFilenamePlaceholder + } + + const metaEl = element.querySelector("[data-private-attachment-meta]") + if (metaEl?.dataset.privateAttachmentMetaPlaceholder) { + metaEl.textContent = metaEl.dataset.privateAttachmentMetaPlaceholder + } + + const labelEl = element.querySelector("[data-private-attachment-download-label]") + if (labelEl?.dataset.privateAttachmentDownloadLabelPlaceholder) { + labelEl.textContent = labelEl.dataset.privateAttachmentDownloadLabelPlaceholder + } +} diff --git a/apps/elektrine/assets/js/hooks/mailbox_private_crypto.js b/apps/elektrine/assets/js/hooks/mailbox_private_crypto.js new file mode 100644 index 0000000..96471c2 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/mailbox_private_crypto.js @@ -0,0 +1,435 @@ +/** + * Crypto, encoding, and key-management helpers for the private mailbox. + * + * Extracted from mailbox_private_storage_hooks.js to keep that file within its + * maintainability budget. This module owns the low-level primitives (base64, + * scrypt key wrapping, RSA envelope decryption, master-key wrapping) and the + * in-memory imported-key cache; the hook in mailbox_private_storage_hooks.js + * drives them. Public names are re-exported from that module so existing + * importers keep working unchanged. + */ + +import { scryptAsync } from "@noble/hashes/scrypt.js" +import * as vaultSession from "./vault_session" + +export const MASTER_MODE = "master" +const MASTER_FEATURE = "email" +const WRAP_ALGORITHM = "AES-GCM" +export const VERIFY_TEXT = "elektrine-private-mailbox-v1" +const MESSAGE_AAD = new TextEncoder().encode("ElektrineMailboxStorageV1") +const ATTACHMENT_AAD = new TextEncoder().encode("ElektrineMailboxAttachmentV1") +const DEFAULT_SCRYPT = { n: 16384, r: 8, p: 1 } +export const encoder = new TextEncoder() +const decoder = new TextDecoder() +const importedPrivateKeys = new Map() + +function stableJson(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return JSON.stringify(value) + + return JSON.stringify( + Object.keys(value) + .sort() + .reduce((acc, key) => { + acc[key] = value[key] + return acc + }, {}) + ) +} + +function encodedStableJson(value) { + return encoder.encode(stableJson(value)) +} + +function randomBytes(length) { + const bytes = new Uint8Array(length) + crypto.getRandomValues(bytes) + return bytes +} + +function bytesToBase64(bytes) { + let binary = "" + + bytes.forEach((byte) => { + binary += String.fromCharCode(byte) + }) + + return btoa(binary) +} + +function base64ToBytes(value) { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + + return bytes +} + +function concatBytes(first, second) { + const combined = new Uint8Array(first.length + second.length) + combined.set(first, 0) + combined.set(second, first.length) + return combined +} + +export function parsePayload(raw) { + if (!raw) return null + + try { + return JSON.parse(raw) + } catch (_error) { + return null + } +} + +function isValidUnlockMode(value) { + return value === "account_password" || value === "separate_passphrase" +} + +export function isKnownUnlockMode(value) { + return isValidUnlockMode(value) || value === MASTER_MODE +} + +export function unlockModeFromPayload(...payloads) { + for (const payload of payloads) { + if (!payload || typeof payload !== "object") continue + + const unlockMode = payload.unlock_mode || payload.unlockMode + if (isKnownUnlockMode(unlockMode)) { + return unlockMode + } + } + + return null +} + +export function notify(message, type = "error", title = "Mailbox") { + if (typeof window.showNotification === "function") { + window.showNotification(message, type, title) + } else { + console.error(`[Mailbox] ${message}`) + } +} + +export function cacheLoginPassword(password) { + void password +} + +export function clearCachedLoginPassword() { + return null +} + +export function getCachedLoginPassword() { + return null +} + +export function getStoredPrivateKey(mailboxId) { + if (!mailboxId) return null + return importedPrivateKeys.get(mailboxId) || null +} + +function storePrivateKey(mailboxId, privateKey) { + if (!mailboxId) return + importedPrivateKeys.set(mailboxId, privateKey) +} + +export function clearPrivateKey(mailboxId) { + if (!mailboxId) return + importedPrivateKeys.delete(mailboxId) +} + +export function dispatchMailboxEvent(name, mailboxId) { + window.dispatchEvent( + new CustomEvent(name, { + detail: { mailboxId } + }) + ) +} + +function chunkString(value, size) { + const parts = [] + + for (let index = 0; index < value.length; index += size) { + parts.push(value.slice(index, index + size)) + } + + return parts.join("\n") +} + +function bytesToPem(bytes, label) { + const body = chunkString(bytesToBase64(bytes), 64) + return `-----BEGIN ${label}-----\n${body}\n-----END ${label}-----` +} + +async function deriveWrappingKey(passphrase, salt, params) { + const keyBytes = await scryptAsync(encoder.encode(passphrase), salt, { + N: params.n, + r: params.r, + p: params.p, + dkLen: 32 + }) + + return crypto.subtle.importKey("raw", keyBytes, { name: WRAP_ALGORITHM }, false, [ + "encrypt", + "decrypt" + ]) +} + +function keyWrapAADContext(unlockMode, kind) { + return { + purpose: "elektrine-private-mailbox-key-wrap", + version: 2, + kind, + algorithm: WRAP_ALGORITHM, + kdf: "scrypt", + unlock_mode: unlockMode + } +} + +function wrapParams(payload, iv) { + if (Number(payload.version) >= 2) { + return { name: WRAP_ALGORITHM, iv, additionalData: encodedStableJson(payload.aad_context) } + } + + return { name: WRAP_ALGORITHM, iv } +} + +export async function wrapBytes(bytes, passphrase, unlockMode = null, kind = "private_key") { + const salt = randomBytes(16) + const iv = randomBytes(12) + const key = await deriveWrappingKey(passphrase, salt, DEFAULT_SCRYPT) + const aadContext = keyWrapAADContext(unlockMode, kind) + const ciphertext = await crypto.subtle.encrypt( + { name: WRAP_ALGORITHM, iv, additionalData: encodedStableJson(aadContext) }, + key, + bytes + ) + + const payload = { + version: 2, + algorithm: WRAP_ALGORITHM, + kdf: "scrypt", + aad_context: aadContext, + n: DEFAULT_SCRYPT.n, + r: DEFAULT_SCRYPT.r, + p: DEFAULT_SCRYPT.p, + salt: bytesToBase64(salt), + iv: bytesToBase64(iv), + ciphertext: bytesToBase64(new Uint8Array(ciphertext)) + } + + if (isValidUnlockMode(unlockMode)) { + payload.unlock_mode = unlockMode + } + + return payload +} + +async function unwrapBytes(payload, passphrase) { + const salt = base64ToBytes(payload.salt) + const iv = base64ToBytes(payload.iv) + const ciphertext = base64ToBytes(payload.ciphertext) + const key = await deriveWrappingKey(passphrase, salt, payload) + const plaintext = await crypto.subtle.decrypt(wrapParams(payload, iv), key, ciphertext) + return new Uint8Array(plaintext) +} + +// --- shared account-password vault mode ------------------------------------ +// Instead of deriving a wrapping key directly from the account password, shared +// vault mode wraps the mailbox key with the email subkey of the encrypted data +// key held in the shared vault session. One account-password unlock therefore +// unlocks this mailbox too. + +async function masterEmailKey() { + return vaultSession.featureKey(MASTER_FEATURE) +} + +function masterAADContext(kind) { + return { + purpose: "elektrine-private-mailbox-key-wrap", + version: 2, + kind, + algorithm: WRAP_ALGORITHM, + kdf: MASTER_MODE, + unlock_mode: MASTER_MODE + } +} + +export async function wrapBytesWithMasterKey(bytes, kind = "private_key") { + const key = await masterEmailKey() + const iv = randomBytes(12) + const aadContext = masterAADContext(kind) + const ciphertext = await crypto.subtle.encrypt( + { name: WRAP_ALGORITHM, iv, additionalData: encodedStableJson(aadContext) }, + key, + bytes + ) + + return { + version: 2, + algorithm: WRAP_ALGORITHM, + kdf: MASTER_MODE, + unlock_mode: MASTER_MODE, + aad_context: aadContext, + iv: bytesToBase64(iv), + ciphertext: bytesToBase64(new Uint8Array(ciphertext)) + } +} + +async function unwrapBytesWithMasterKey(payload) { + const key = await masterEmailKey() + const iv = base64ToBytes(payload.iv) + const ciphertext = base64ToBytes(payload.ciphertext) + const plaintext = await crypto.subtle.decrypt(wrapParams(payload, iv), key, ciphertext) + return new Uint8Array(plaintext) +} + +export async function generateMailboxKeypair() { + const keypair = await crypto.subtle.generateKey( + { + name: "RSA-OAEP", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256" + }, + true, + ["encrypt", "decrypt"] + ) + + const publicKey = new Uint8Array(await crypto.subtle.exportKey("spki", keypair.publicKey)) + const privateKey = new Uint8Array(await crypto.subtle.exportKey("pkcs8", keypair.privateKey)) + + return { + publicKeyPem: bytesToPem(publicKey, "PUBLIC KEY"), + privateKey + } +} + +async function importStoredPrivateKey(mailboxId) { + return getStoredPrivateKey(mailboxId) +} + +async function decryptEnvelope(envelope, mailboxId, aad) { + const privateKey = await importStoredPrivateKey(mailboxId) + if (!privateKey) return null + + const wrappedKey = base64ToBytes(envelope.encrypted_key) + const contentKeyBytes = await crypto.subtle.decrypt({ name: "RSA-OAEP" }, privateKey, wrappedKey) + const contentKey = await crypto.subtle.importKey( + "raw", + contentKeyBytes, + { name: "AES-GCM" }, + false, + ["decrypt"] + ) + + const ciphertext = base64ToBytes(envelope.ciphertext) + const tag = base64ToBytes(envelope.tag) + const iv = base64ToBytes(envelope.iv) + + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv, additionalData: envelopeAAD(envelope, aad) }, + contentKey, + concatBytes(ciphertext, tag) + ) + + return JSON.parse(new TextDecoder().decode(plaintext)) +} + +function envelopeAAD(envelope, fallbackAAD) { + if (Number(envelope.version) >= 2 && envelope.aad_context) { + return encodedStableJson(envelope.aad_context) + } + + return fallbackAAD +} + +export async function decryptMessagePayload(envelope, mailboxId) { + return decryptEnvelope(envelope, mailboxId, MESSAGE_AAD) +} + +export async function decryptAttachmentPayload(envelope, mailboxId) { + return decryptEnvelope(envelope, mailboxId, ATTACHMENT_AAD) +} + +export async function unwrapMailboxPrivateKey(wrappedKeyPayload, verifierPayload, passphrase) { + const verifierBytes = await unwrapBytes(verifierPayload, passphrase) + const verifierText = decoder.decode(verifierBytes) + + if (verifierText !== VERIFY_TEXT) { + throw new Error("invalid-passphrase") + } + + return unwrapBytes(wrappedKeyPayload, passphrase) +} + +async function importAndStorePrivateKey(mailboxId, privateKeyBytes) { + const privateKey = await crypto.subtle.importKey( + "pkcs8", + privateKeyBytes, + { name: "RSA-OAEP", hash: "SHA-256" }, + false, + ["decrypt"] + ) + storePrivateKey(mailboxId, privateKey) + dispatchMailboxEvent("elektrine:private-mailbox-unlocked", mailboxId) +} + +export async function unlockMailbox(mailboxId, wrappedKeyPayload, verifierPayload, passphrase) { + const privateKeyBytes = await unwrapMailboxPrivateKey( + wrappedKeyPayload, + verifierPayload, + passphrase + ) + await importAndStorePrivateKey(mailboxId, privateKeyBytes) +} + +export async function unlockMailboxWithMaster(mailboxId, wrappedKeyPayload, verifierPayload) { + const verifierBytes = await unwrapBytesWithMasterKey(verifierPayload) + + if (decoder.decode(verifierBytes) !== VERIFY_TEXT) { + throw new Error("invalid-master-key") + } + + const privateKeyBytes = await unwrapBytesWithMasterKey(wrappedKeyPayload) + await importAndStorePrivateKey(mailboxId, privateKeyBytes) +} + +export function lockedStatusText(unlockMode) { + if (unlockMode === MASTER_MODE) { + return "Mailbox locked. Enter your account password to continue." + } + + if (unlockMode === "account_password") { + return "Mailbox locked. Enter your account password to continue." + } + + return "Mailbox locked." +} + +export function unlockSecretLabel(unlockMode) { + if (unlockMode === MASTER_MODE) return "account password" + return unlockMode === "account_password" ? "account password" : "mailbox passphrase" +} + +export function unlockSecretPlaceholder(unlockMode) { + if (unlockMode === MASTER_MODE) return "Account password" + return unlockMode === "account_password" ? "Account password" : "Mailbox passphrase" +} + +export function maybeSetValue(field, nextValue, matcher) { + if (!field) return + + const currentValue = field.value || "" + const shouldReplace = + currentValue.trim() === "" || + currentValue === field.dataset.privateMailboxLastApplied || + (typeof matcher === "function" && matcher(currentValue)) + + if (!shouldReplace) return + + field.value = nextValue + field.dataset.privateMailboxLastApplied = nextValue +} diff --git a/apps/elektrine/assets/js/hooks/mailbox_private_messages_hook.js b/apps/elektrine/assets/js/hooks/mailbox_private_messages_hook.js new file mode 100644 index 0000000..b07efb6 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/mailbox_private_messages_hook.js @@ -0,0 +1,242 @@ +import { + bodyText, + buildSandboxedEmailHtml, + downloadBytes, + formatAttachmentMeta, + payloadString, + previewText, + restoreAttachmentPlaceholder, + sanitizeProtectedHtml +} from "./mailbox_private_content" +import { + decryptAttachmentPayload, + decryptMessagePayload, + getStoredPrivateKey, + notify, + parsePayload +} from "./mailbox_private_storage_hooks" + +const decryptedAttachments = new WeakMap() + +export const PrivateMailboxMessages = { + mounted() { + this.mailboxId = this.el.dataset.privateMailboxId + this.onMailboxChange = () => this.renderPrivateContent() + this.onClick = (event) => this.handleClick(event) + + window.addEventListener("elektrine:private-mailbox-unlocked", this.onMailboxChange) + window.addEventListener("elektrine:private-mailbox-locked", this.onMailboxChange) + this.el.addEventListener("click", this.onClick) + + this.renderPrivateContent() + }, + + updated() { + this.mailboxId = this.el.dataset.privateMailboxId + this.renderPrivateContent() + }, + + destroyed() { + window.removeEventListener("elektrine:private-mailbox-unlocked", this.onMailboxChange) + window.removeEventListener("elektrine:private-mailbox-locked", this.onMailboxChange) + this.el.removeEventListener("click", this.onClick) + }, + + async handleClick(event) { + const downloadButton = event.target.closest("[data-private-attachment-download]") + if (!downloadButton) return + + event.preventDefault() + + const attachmentElement = downloadButton.closest("[data-private-attachment='true']") + if (!attachmentElement || !this.mailboxId || !getStoredPrivateKey(this.mailboxId)) { + notify("Unlock your mailbox before downloading protected attachments.") + return + } + + try { + const payload = await this.decryptAttachmentElement(attachmentElement) + if (!payload?.data) { + notify("This protected attachment could not be decrypted.") + return + } + + downloadBytes(payload.filename, payload.content_type, payload.data) + } catch (_error) { + notify("Failed to decrypt this protected attachment.") + } + }, + + async decryptAttachmentElement(element) { + const cached = decryptedAttachments.get(element) + if (cached) return cached + + const envelope = parsePayload(element.dataset.privateAttachmentPayload) + if (!envelope) return null + + const payload = await decryptAttachmentPayload(envelope, this.mailboxId) + if (payload) { + decryptedAttachments.set(element, payload) + } + + return payload + }, + + async renderPrivateContent() { + const messageElements = Array.from(this.el.querySelectorAll("[data-private-message='true']")) + const attachmentElements = Array.from( + this.el.querySelectorAll("[data-private-attachment='true']") + ) + const standaloneAddressElements = Array.from( + this.el.querySelectorAll("[data-private-address-payload]") + ) + + if (!this.mailboxId || !getStoredPrivateKey(this.mailboxId)) { + this.restorePlaceholders(messageElements) + standaloneAddressElements.forEach((element) => this.restoreAddressPlaceholder(element)) + attachmentElements.forEach((element) => restoreAttachmentPlaceholder(element)) + return + } + + await Promise.all( + messageElements.map(async (element) => { + const envelope = parsePayload(element.dataset.privateMessagePayload) + if (!envelope) return + + try { + const payload = await decryptMessagePayload(envelope, this.mailboxId) + if (!payload) return + + const subjectEl = element.querySelector("[data-private-subject]") + if (subjectEl) { + const subject = typeof payload.subject === "string" ? payload.subject.trim() : "" + subjectEl.textContent = subject || "(No Subject)" + } + + const previewEl = element.querySelector("[data-private-preview]") + if (previewEl) { + previewEl.textContent = previewText(payload) + } + + element.querySelectorAll("[data-private-address]").forEach((addressEl) => { + const field = addressEl.dataset.privateAddress + const value = payloadString(payload, field).trim() + + if (value) { + addressEl.textContent = value + } + }) + + const bodyEl = element.querySelector("[data-private-body]") + const iframe = element.querySelector("[data-private-html-body-iframe]") + const iframeContainer = element.querySelector("[data-private-html-body-container]") + + if (bodyEl) { + bodyEl.textContent = bodyText(payload) + } + + if (iframe && iframeContainer && typeof payload.html_body === "string" && payload.html_body.trim() !== "") { + iframe.srcdoc = buildSandboxedEmailHtml(sanitizeProtectedHtml(payload.html_body)) + iframeContainer.classList.remove("hidden") + + if (bodyEl) { + bodyEl.classList.add("hidden") + } + } else if (iframe && iframeContainer) { + iframe.srcdoc = "" + iframeContainer.classList.add("hidden") + + if (bodyEl) { + bodyEl.classList.remove("hidden") + } + } + } catch (_error) { + this.restorePlaceholderForElement(element) + } + }) + ) + + await Promise.all( + standaloneAddressElements.map(async (element) => { + const envelope = parsePayload(element.dataset.privateAddressPayload) + if (!envelope) return + + try { + const payload = await decryptMessagePayload(envelope, this.mailboxId) + const value = payloadString(payload, element.dataset.privateAddress).trim() + + if (value) { + element.textContent = value + } + } catch (_error) { + this.restoreAddressPlaceholder(element) + } + }) + ) + + await Promise.all( + attachmentElements.map(async (element) => { + try { + const payload = await this.decryptAttachmentElement(element) + if (!payload) return + + const filenameEl = element.querySelector("[data-private-attachment-filename]") + if (filenameEl) { + filenameEl.textContent = payload.filename || "attachment" + } + + const metaEl = element.querySelector("[data-private-attachment-meta]") + if (metaEl) { + metaEl.textContent = formatAttachmentMeta(payload) + } + + const labelEl = element.querySelector("[data-private-attachment-download-label]") + if (labelEl) { + labelEl.textContent = "Download" + } + } catch (_error) { + restoreAttachmentPlaceholder(element) + } + }) + ) + }, + + restorePlaceholders(elements) { + elements.forEach((element) => this.restorePlaceholderForElement(element)) + }, + + restorePlaceholderForElement(element) { + const subjectEl = element.querySelector("[data-private-subject]") + if (subjectEl && subjectEl.dataset.privateSubjectPlaceholder) { + subjectEl.textContent = subjectEl.dataset.privateSubjectPlaceholder + } + + const previewEl = element.querySelector("[data-private-preview]") + if (previewEl && previewEl.dataset.privatePreviewPlaceholder) { + previewEl.textContent = previewEl.dataset.privatePreviewPlaceholder + } + + element.querySelectorAll("[data-private-address]").forEach((addressEl) => { + this.restoreAddressPlaceholder(addressEl) + }) + + const bodyEl = element.querySelector("[data-private-body]") + if (bodyEl && bodyEl.dataset.privateBodyPlaceholder) { + bodyEl.textContent = bodyEl.dataset.privateBodyPlaceholder + bodyEl.classList.remove("hidden") + } + + const iframe = element.querySelector("[data-private-html-body-iframe]") + const iframeContainer = element.querySelector("[data-private-html-body-container]") + if (iframe && iframeContainer) { + iframe.srcdoc = "" + iframeContainer.classList.add("hidden") + } + }, + + restoreAddressPlaceholder(addressEl) { + if (addressEl.dataset.privateAddressPlaceholder) { + addressEl.textContent = addressEl.dataset.privateAddressPlaceholder + } + } +} diff --git a/apps/elektrine/assets/js/hooks/mailbox_private_storage_hooks.js b/apps/elektrine/assets/js/hooks/mailbox_private_storage_hooks.js new file mode 100644 index 0000000..8068eea --- /dev/null +++ b/apps/elektrine/assets/js/hooks/mailbox_private_storage_hooks.js @@ -0,0 +1,538 @@ +/** + * MailboxPrivateStorage hook: drives the lock/unlock/setup UI for a mailbox's + * zero-knowledge private storage. The crypto and key-management primitives live + * in ./mailbox_private_crypto; the public names that other modules import from + * here are re-exported below so their imports keep working unchanged. + */ + +import { unwrapWithSecret } from "./vault_crypto" +import * as vaultSession from "./vault_session" +import { + MASTER_MODE, + VERIFY_TEXT, + cacheLoginPassword, + clearCachedLoginPassword, + clearPrivateKey, + dispatchMailboxEvent, + encoder, + generateMailboxKeypair, + getCachedLoginPassword, + getStoredPrivateKey, + isKnownUnlockMode, + lockedStatusText, + notify, + parsePayload, + unlockMailbox, + unlockMailboxWithMaster, + unlockModeFromPayload, + unlockSecretLabel, + unlockSecretPlaceholder, + wrapBytes, + wrapBytesWithMasterKey +} from "./mailbox_private_crypto" + +export { + VERIFY_TEXT, + cacheLoginPassword, + decryptAttachmentPayload, + decryptMessagePayload, + encoder, + getStoredPrivateKey, + maybeSetValue, + notify, + parsePayload, + unwrapMailboxPrivateKey, + wrapBytes +} from "./mailbox_private_crypto" + + +export const MailboxPrivateStorage = { + mounted() { + this.captureElements() + this.bindEvents() + this.unsubscribeVault = vaultSession.subscribe(() => this.onVaultChange()) + this.renderLockState() + this.renderSetupModeState() + void this.maybeAutoUnlock() + }, + + updated() { + this.captureElements() + this.renderLockState() + this.renderSetupModeState() + void this.maybeAutoUnlock() + }, + + destroyed() { + if (this.onClick) this.el.removeEventListener("click", this.onClick) + if (this.onChange) this.el.removeEventListener("change", this.onChange) + if (this.unsubscribeVault) this.unsubscribeVault() + }, + + onVaultChange() { + // The shared account-password vault locked or unlocked: setup gating and + // auto-unlock both depend on it. + this.renderSetupModeState() + if (vaultSession.isUnlocked()) { + this.autoUnlockFailed = false + this.renderLockState() + void this.maybeAutoUnlock() + } else if (this.unlockMode === MASTER_MODE && this.mailboxId) { + clearPrivateKey(this.mailboxId) + this.renderLockState() + } + }, + + captureElements() { + this.mailboxId = this.el.dataset.privateMailboxId + this.configured = this.el.dataset.privateMailboxConfigured === "true" + this.wrappedKeyPayload = parsePayload(this.el.dataset.privateMailboxWrappedKey) + this.verifierPayload = parsePayload(this.el.dataset.privateMailboxVerifier) + const datasetUnlockMode = this.el.dataset.privateMailboxUnlockMode + + this.unlockMode = + (isKnownUnlockMode(datasetUnlockMode) ? datasetUnlockMode : null) || + unlockModeFromPayload(this.wrappedKeyPayload, this.verifierPayload) || + "separate_passphrase" + this.statusElements = Array.from(this.el.querySelectorAll("[data-private-mailbox-status]")) + this.lockedContentElements = Array.from( + this.el.querySelectorAll("[data-private-mailbox-locked-content]") + ) + this.unlockedContentElements = Array.from( + this.el.querySelectorAll("[data-private-mailbox-unlocked-content]") + ) + this.passphraseInput = this.el.querySelector("[data-private-mailbox-passphrase]") + this.setupForm = this.el.querySelector("[data-private-mailbox-setup-form]") + this.setupModeInput = this.el.querySelector("[data-private-mailbox-setup-mode]") + this.setupAccountPasswordInput = this.el.querySelector("[data-private-mailbox-account-password]") + this.accountPasswordFields = this.el.querySelector("[data-private-mailbox-account-password-fields]") + this.masterFields = this.el.querySelector("[data-private-mailbox-master-fields]") + this.masterUnlockFields = this.el.querySelector("[data-private-mailbox-master-unlock-fields]") + this.masterConfigured = this.el.dataset.privateMailboxMasterConfigured === "true" + this.masterWrappedDek = parsePayload(this.el.dataset.privateMailboxMasterWrappedDek) + this.customPassphraseFields = this.el.querySelector( + "[data-private-mailbox-custom-passphrase-fields]" + ) + this.setupPassphraseInput = this.el.querySelector("[data-private-mailbox-setup-passphrase]") + this.setupConfirmInput = this.el.querySelector("[data-private-mailbox-setup-passphrase-confirm]") + this.wrappedKeyInput = this.el.querySelector("[data-private-mailbox-wrapped-key-input]") + this.publicKeyInput = this.el.querySelector("[data-private-mailbox-public-key-input]") + this.verifierInput = this.el.querySelector("[data-private-mailbox-verifier-input]") + this.autoUnlockSuppressed = this.autoUnlockSuppressed === true + + if (this.passphraseInput) { + this.passphraseInput.placeholder = unlockSecretPlaceholder(this.unlockMode) + } + }, + + setStatusText(text) { + this.statusElements.forEach((element) => { + element.textContent = text + }) + }, + + renderUnlockPanels(unlocked) { + this.lockedContentElements.forEach((element) => { + element.classList.toggle("hidden", unlocked) + }) + + this.unlockedContentElements.forEach((element) => { + element.classList.toggle("hidden", !unlocked) + }) + }, + + bindEvents() { + if (this.onClick) this.el.removeEventListener("click", this.onClick) + if (this.onChange) this.el.removeEventListener("change", this.onChange) + + this.onClick = async (event) => { + const unlockButton = event.target.closest("[data-private-mailbox-unlock]") + if (unlockButton) { + event.preventDefault() + await this.handleUnlock() + return + } + + const lockButton = event.target.closest("[data-private-mailbox-lock]") + if (lockButton) { + event.preventDefault() + this.handleLock() + return + } + + const setupButton = event.target.closest("[data-private-mailbox-setup-submit]") + if (setupButton) { + event.preventDefault() + await this.handleSetup() + return + } + + const masterUnlockButton = event.target.closest("[data-private-mailbox-master-unlock]") + if (masterUnlockButton) { + event.preventDefault() + await this.handleMasterVaultUnlock() + } + } + + this.onChange = (event) => { + const setupMode = event.target.closest("[data-private-mailbox-setup-mode]") + if (!setupMode) return + + this.renderSetupModeState() + } + + this.el.addEventListener("click", this.onClick) + this.el.addEventListener("change", this.onChange) + }, + + currentSetupMode() { + const selectedMode = this.setupModeInput?.value + + if (isKnownUnlockMode(selectedMode)) { + return selectedMode + } + + return "account_password" + }, + + renderSetupModeState() { + const mode = this.currentSetupMode() + + if (this.accountPasswordFields) { + const hidden = mode !== "account_password" + this.accountPasswordFields.classList.toggle("hidden", hidden) + + if (this.setupAccountPasswordInput) { + this.setupAccountPasswordInput.toggleAttribute("required", !hidden) + } + } + + if (this.customPassphraseFields) { + const hidden = mode !== "separate_passphrase" + this.customPassphraseFields.classList.toggle("hidden", hidden) + + if (this.setupPassphraseInput) { + this.setupPassphraseInput.toggleAttribute("required", !hidden) + } + + if (this.setupConfirmInput) { + this.setupConfirmInput.toggleAttribute("required", !hidden) + } + } + + if (this.masterFields) { + this.masterFields.classList.toggle("hidden", mode !== MASTER_MODE) + } + + if (this.masterUnlockFields) { + // Show the inline unlock only when encrypted data is enabled but the + // account-password vault is locked for this tab; once unlocked, setup can proceed. + const showUnlock = mode === MASTER_MODE && this.masterConfigured && !vaultSession.isUnlocked() + this.masterUnlockFields.classList.toggle("hidden", !showUnlock) + } + }, + + // The dedicated master-unlock field (settings page) or, on pages that only + // render one unlock field (the inbox), the shared passphrase input. + masterPassphraseInput() { + return ( + this.el.querySelector("[data-private-mailbox-master-unlock-input]") || + this.el.querySelector("[data-private-mailbox-passphrase]") + ) + }, + + async handleMasterVaultUnlock() { + const input = this.masterPassphraseInput() + const passphrase = input?.value || "" + + if (!this.masterWrappedDek) { + this.setMasterError("Set up account-password encrypted data first.") + return + } + + if (passphrase.trim() === "") { + this.setMasterError("Enter your account password.") + return + } + + try { + const mdk = await unwrapWithSecret(this.masterWrappedDek, passphrase) + vaultSession.unlock(mdk) + if (input) input.value = "" + this.setMasterError("") + // The vault-change subscription re-renders setup state and auto-unlocks a + // configured master mailbox. + } catch (_error) { + this.setMasterError( + "Incorrect account password. If you just reset it, recover encrypted data at /account/encrypted-data." + ) + } + }, + + setMasterError(message) { + this.el.querySelectorAll("[data-private-mailbox-master-error]").forEach((el) => { + el.textContent = message || "" + el.classList.toggle("hidden", !message) + }) + }, + + // True when an auto-unlock is about to run for this panel (the encrypted-data vault + // is already unlocked in this tab). Used to render a quiet "unlocking" state + // instead of flashing the passphrase prompt on load, then swapping it out. + autoUnlockPending() { + return ( + !this.autoUnlockSuppressed && + !this.autoUnlockFailed && + this.configured && + this.unlockMode === MASTER_MODE && + !!this.wrappedKeyPayload && + !!this.verifierPayload && + vaultSession.isUnlocked() + ) + }, + + renderLockState() { + const hasUnlockedKey = this.mailboxId && getStoredPrivateKey(this.mailboxId) + + if (hasUnlockedKey) { + this.setStatusText("Mailbox unlocked in memory for this tab.") + this.renderUnlockPanels(true) + return + } + + if (!this.configured) { + this.setStatusText("Private storage not configured.") + this.renderUnlockPanels(false) + return + } + + if (this.autoUnlockPending()) { + this.setStatusText("Unlocking private mailbox…") + this.renderUnlockPanels(true) + return + } + + this.setStatusText(lockedStatusText(this.unlockMode)) + this.renderUnlockPanels(false) + }, + + async maybeAutoUnlock() { + if ( + this.autoUnlockSuppressed || + !this.configured || + !this.mailboxId || + !this.wrappedKeyPayload || + !this.verifierPayload || + getStoredPrivateKey(this.mailboxId) + ) { + return + } + + if (this.unlockMode === MASTER_MODE) { + if (!vaultSession.isUnlocked()) return + + try { + await unlockMailboxWithMaster( + this.mailboxId, + this.wrappedKeyPayload, + this.verifierPayload + ) + this.autoUnlockFailed = false + this.renderLockState() + } catch (_error) { + // encrypted data key mismatch or transient error; fall back to the manual prompt + this.autoUnlockFailed = true + this.renderLockState() + } + + return + } + + if (this.unlockMode !== "account_password") { + return + } + + const cachedPassword = getCachedLoginPassword() + if (!cachedPassword) return + + try { + await unlockMailbox( + this.mailboxId, + this.wrappedKeyPayload, + this.verifierPayload, + cachedPassword + ) + + if (this.passphraseInput) { + this.passphraseInput.value = "" + } + + this.renderLockState() + } catch (_error) { + clearCachedLoginPassword() + this.renderLockState() + } + }, + + async handleUnlock() { + if (!this.mailboxId || !this.wrappedKeyPayload || !this.verifierPayload) { + notify("Private mailbox storage is not configured yet.") + return + } + + if (this.unlockMode === MASTER_MODE) { + // If encrypted data is locked for this tab, unlock it inline with the + // account password before unwrapping the mailbox key. + if (!vaultSession.isUnlocked()) { + const masterInput = this.masterPassphraseInput() + const masterPassphrase = masterInput?.value || "" + + if (!this.masterWrappedDek) { + notify("Set up account-password encrypted data first.") + return + } + + if (masterPassphrase.trim() === "") { + notify("Enter your account password to unlock this mailbox.") + return + } + + try { + const mdk = await unwrapWithSecret(this.masterWrappedDek, masterPassphrase) + vaultSession.unlock(mdk) + if (masterInput) masterInput.value = "" + } catch (_error) { + notify( + "Incorrect account password. If you just reset it, recover encrypted data at /account/encrypted-data." + ) + return + } + } + + try { + await unlockMailboxWithMaster(this.mailboxId, this.wrappedKeyPayload, this.verifierPayload) + this.autoUnlockSuppressed = false + this.renderLockState() + notify("Mailbox unlocked for this tab.", "success") + } catch (_error) { + notify( + "This mailbox was wrapped with an older encrypted-data key. Reset private mailbox storage in Account Settings, then set it up again." + ) + } + + return + } + + const passphrase = this.passphraseInput?.value || "" + + if (passphrase.trim() === "") { + notify(`Enter your ${unlockSecretLabel(this.unlockMode)} first.`) + return + } + + try { + await unlockMailbox(this.mailboxId, this.wrappedKeyPayload, this.verifierPayload, passphrase) + if (this.unlockMode === "account_password") { + cacheLoginPassword(passphrase) + } + + this.autoUnlockSuppressed = false + if (this.passphraseInput) this.passphraseInput.value = "" + this.renderLockState() + notify("Mailbox unlocked for this tab.", "success") + } catch (_error) { + notify( + `Could not unlock the mailbox. Check your ${unlockSecretLabel(this.unlockMode)} and try again.` + ) + } + }, + + handleLock() { + if (!this.mailboxId) return + + clearPrivateKey(this.mailboxId) + this.autoUnlockSuppressed = true + dispatchMailboxEvent("elektrine:private-mailbox-locked", this.mailboxId) + if (this.passphraseInput) this.passphraseInput.value = "" + this.renderLockState() + }, + + async handleSetup() { + const unlockMode = this.currentSetupMode() + const accountPassword = this.setupAccountPasswordInput?.value || "" + const passphrase = this.setupPassphraseInput?.value || "" + const confirmation = this.setupConfirmInput?.value || "" + + if (!this.setupForm || !this.mailboxId) { + notify("Mailbox setup is unavailable right now.") + return + } + + if (unlockMode === MASTER_MODE) { + if (!vaultSession.isUnlocked()) { + notify( + this.masterConfigured + ? "Enter your account password above first, then enable private storage." + : "Set up account-password encrypted data first." + ) + return + } + + try { + const { publicKeyPem, privateKey } = await generateMailboxKeypair() + const wrappedPrivateKey = await wrapBytesWithMasterKey(privateKey, "private_key") + const verifier = await wrapBytesWithMasterKey(encoder.encode(VERIFY_TEXT), "verifier") + + this.wrappedKeyInput.value = JSON.stringify(wrappedPrivateKey) + this.publicKeyInput.value = publicKeyPem + this.verifierInput.value = JSON.stringify(verifier) + this.setupForm.requestSubmit() + } catch (_error) { + notify("Could not generate mailbox keys.") + } + + return + } + + if (unlockMode === "account_password") { + if (accountPassword.trim() === "") { + notify("Enter your current password to enable private mailbox storage.") + return + } + } else { + if (passphrase.length < 12) { + notify("Use a mailbox passphrase with at least 12 characters.") + return + } + + if (passphrase !== confirmation) { + notify("Mailbox passphrase confirmation does not match.") + return + } + } + + try { + const { publicKeyPem, privateKey } = await generateMailboxKeypair() + const wrappingSecret = unlockMode === "account_password" ? accountPassword : passphrase + const wrappedPrivateKey = await wrapBytes(privateKey, wrappingSecret, unlockMode) + const verifier = await wrapBytes( + encoder.encode(VERIFY_TEXT), + wrappingSecret, + unlockMode, + "verifier" + ) + + this.wrappedKeyInput.value = JSON.stringify(wrappedPrivateKey) + this.publicKeyInput.value = publicKeyPem + this.verifierInput.value = JSON.stringify(verifier) + + if (unlockMode === "account_password") { + cacheLoginPassword(accountPassword) + } + + this.setupForm.requestSubmit() + } catch (_error) { + notify("Could not generate mailbox keys in this browser.") + } + } +} diff --git a/apps/elektrine/assets/js/hooks/markdown_hooks.js b/apps/elektrine/assets/js/hooks/markdown_hooks.js new file mode 100644 index 0000000..aab2ada --- /dev/null +++ b/apps/elektrine/assets/js/hooks/markdown_hooks.js @@ -0,0 +1,308 @@ +import { sanitizeMarkdownHref } from '../markdown_helpers' + +export const ReplyMarkdownEditor = { + mounted() { + // Focus on the textarea when mounted (FocusOnMount functionality) + this.el.focus() + + // Setup markdown toolbar functionality + this.setupReplyToolbars() + this.setupReplyPreview() + this.setupReplyTabs() + }, + + destroyed() { + // Clean up event listeners + const toolbar = document.getElementById('reply-formatting-toolbar') + if (toolbar && this.toolbarClickHandler) { + toolbar.removeEventListener('click', this.toolbarClickHandler) + } + + if (this.tabClickHandler) { + document.removeEventListener('click', this.tabClickHandler) + } + + if (this.inputHandler) { + this.el.removeEventListener('input', this.inputHandler) + } + }, + + setupReplyTabs() { + // Handle tab switching for reply editor + // Remove existing listener if present + if (this.tabClickHandler) { + document.removeEventListener('click', this.tabClickHandler) + } + + this.tabClickHandler = (e) => { + const tab = e.target.closest('[data-reply-editor-tab]') + if (!tab) return + + e.preventDefault() + const mode = tab.dataset.replyEditorTab + + // Update tab active states + document.querySelectorAll('[data-reply-editor-tab]').forEach(t => { + t.classList.remove('tab-active') + }) + tab.classList.add('tab-active') + + const writeDiv = document.getElementById('reply-editor-write') + const previewDiv = document.getElementById('reply-editor-preview') + const container = document.getElementById('reply-editor-container') + + switch(mode) { + case 'write': + writeDiv.classList.remove('hidden') + previewDiv.classList.add('hidden') + container.classList.remove('grid', 'grid-cols-2', 'gap-2') + break + case 'preview': + writeDiv.classList.add('hidden') + previewDiv.classList.remove('hidden') + container.classList.remove('grid', 'grid-cols-2', 'gap-2') + this.updateReplyPreview() + break + case 'split': + writeDiv.classList.remove('hidden') + previewDiv.classList.remove('hidden') + container.classList.add('grid', 'grid-cols-2', 'gap-2') + this.updateReplyPreview() + break + } + } + + document.addEventListener('click', this.tabClickHandler) + }, + + setupReplyToolbars() { + // Setup toolbar for reply/forward mode + const toolbar = document.getElementById('reply-formatting-toolbar') + if (!toolbar) { + return + } + + // Remove existing listener if present to prevent double-firing + if (this.toolbarClickHandler) { + toolbar.removeEventListener('click', this.toolbarClickHandler) + } + + // Create and store the handler + this.toolbarClickHandler = (e) => { + const button = e.target.closest('[data-markdown-format]') + if (!button) return + + e.preventDefault() + + const format = button.dataset.markdownFormat + const target = button.dataset.target + const textarea = target ? document.getElementById(target) : this.el + + if (textarea) { + this.insertFormatInTarget(format, textarea) + // Update preview if visible + this.updateReplyPreview() + } + } + + toolbar.addEventListener('click', this.toolbarClickHandler) + }, + + setupReplyPreview() { + // Auto-update preview if it's visible for reply mode + // Remove existing listener if present + if (this.inputHandler) { + this.el.removeEventListener('input', this.inputHandler) + } + + this.inputHandler = () => { + this.updateReplyPreview() + } + + this.el.addEventListener('input', this.inputHandler) + }, + + updateReplyPreview() { + const previewContent = document.getElementById('reply-preview-content') + const previewDiv = document.getElementById('reply-editor-preview') + + if (previewContent && previewDiv && !previewDiv.classList.contains('hidden')) { + const html = this.markdownToHtml(this.el.value) + previewContent.innerHTML = html || '

Nothing to preview yet...

' + } + }, + + insertFormatInTarget(format, textarea) { + const start = textarea.selectionStart + const end = textarea.selectionEnd + const selectedText = textarea.value.substring(start, end) + let replacement = '' + + switch (format) { + case 'bold': + replacement = `**${selectedText || 'bold text'}**` + break + case 'italic': + replacement = `*${selectedText || 'italic text'}*` + break + case 'code': + replacement = `\`${selectedText || 'code'}\`` + break + case 'h1': + case 'heading': + replacement = `# ${selectedText || 'Heading 1'}` + break + case 'h2': + replacement = `## ${selectedText || 'Heading 2'}` + break + case 'h3': + replacement = `### ${selectedText || 'Heading 3'}` + break + case 'link': + const url = selectedText.startsWith('http') ? selectedText : 'https://example.com' + const linkText = selectedText.startsWith('http') ? 'link text' : selectedText || 'link text' + replacement = `[${linkText}](${url})` + break + case 'list': + case 'list-bullet': + replacement = `- ${selectedText || 'list item'}` + break + case 'list-number': + replacement = `1. ${selectedText || 'list item'}` + break + case 'quote': + replacement = `> ${selectedText || 'quoted text'}` + break + case 'code-block': + replacement = `\`\`\`\n${selectedText || 'code here'}\n\`\`\`` + break + } + + textarea.value = textarea.value.substring(0, start) + replacement + textarea.value.substring(end) + textarea.focus() + + // Set cursor position + const newPos = start + replacement.length + textarea.setSelectionRange(newPos, newPos) + }, + + toggleReplyPreview(textarea) { + const previewPanel = document.getElementById('reply-preview-panel') + const previewContent = document.getElementById('reply-preview-content') + + if (!previewPanel || !previewContent) return + + if (previewPanel.classList.contains('hidden')) { + // Show preview + const markdown = textarea.value + const html = this.markdownToHtml(markdown) + previewContent.innerHTML = html + previewPanel.classList.remove('hidden') + } else { + // Hide preview + previewPanel.classList.add('hidden') + } + }, + + markdownToHtml(markdown) { + if (!markdown) return '' + + // Process markdown line by line for better handling + const lines = markdown.split('\n') + const htmlLines = [] + let inCodeBlock = false + let codeBlockLines = [] + + for (let line of lines) { + // Handle code blocks + if (line.startsWith('```')) { + if (inCodeBlock) { + htmlLines.push(`
${codeBlockLines.join('\n')}
`) + codeBlockLines = [] + inCodeBlock = false + } else { + inCodeBlock = true + } + continue + } + + if (inCodeBlock) { + codeBlockLines.push(this.escapeHtml(line)) + continue + } + + // Process individual line + let processedLine = line + + // Headers (must be at line start) + if (line.startsWith('### ')) { + processedLine = `

${this.escapeHtml(line.substring(4))}

` + } else if (line.startsWith('## ')) { + processedLine = `

${this.escapeHtml(line.substring(3))}

` + } else if (line.startsWith('# ')) { + processedLine = `

${this.escapeHtml(line.substring(2))}

` + } + // Blockquotes + else if (line.startsWith('> ')) { + processedLine = `
${this.processMarkdownInline(line.substring(2))}
` + } + // Unordered lists + else if (line.startsWith('- ') || line.startsWith('* ')) { + processedLine = `
  • ${this.processMarkdownInline(line.substring(2))}
  • ` + } + // Ordered lists + else if (/^\d+\. /.test(line)) { + processedLine = `
  • ${this.processMarkdownInline(line.replace(/^\d+\. /, ''))}
  • ` + } + // Regular paragraph + else if (line.trim() !== '') { + processedLine = `

    ${this.processMarkdownInline(line)}

    ` + } else { + processedLine = '' + } + + if (processedLine) { + htmlLines.push(processedLine) + } + } + + // Handle any unclosed code block + if (inCodeBlock && codeBlockLines.length > 0) { + htmlLines.push(`
    ${codeBlockLines.join('\n')}
    `) + } + + // Wrap consecutive list items in ul/ol tags + let finalHtml = htmlLines.join('\n') + finalHtml = finalHtml.replace(/(
  • [\s\S]*?<\/li>\n?)+/g, (match) => { + return `
      ${match}
    ` + }) + + return finalHtml + }, + + processMarkdownInline(text) { + return this.escapeHtml(text) + // Bold (must come before italic) + .replace(/\*\*([^*]+)\*\*/g, '$1') + // Italic + .replace(/\*([^*]+)\*/g, '$1') + // Inline code + .replace(/`([^`]+)`/g, '$1') + // Links + .replace(/\[([^\]]+)\]\(([^\)]+)\)/g, (match, label, href) => { + const safeHref = sanitizeMarkdownHref(href) + + if (!safeHref) { + return match + } + + return `${label}` + }) + }, + + escapeHtml(text) { + const div = document.createElement('div') + div.textContent = text + return div.innerHTML + } +} diff --git a/apps/elektrine/assets/js/hooks/nerve_hooks.js b/apps/elektrine/assets/js/hooks/nerve_hooks.js new file mode 100644 index 0000000..d17e065 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/nerve_hooks.js @@ -0,0 +1,275 @@ +/** + * Nerve - zero-knowledge password entries, now keyed by the account + * password. Unlock is shared via vault_session; entries are encrypted/decrypted + * with the Nerve subkey of the encrypted data key. The server only ever stores ciphertext. + */ + +import { encryptValue, decryptValue, unwrapWithSecret } from "./vault_crypto" +import * as vaultSession from "./vault_session" + +const FEATURE = "nerve" +const PASSWORD_LENGTH = 24 +const PASSWORD_ALPHABET = + "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*-_" + +const metadataAad = () => ({ purpose: "elektrine-nerve-metadata" }) + +function entryAad(metadata, field) { + return { + purpose: "elektrine-nerve-entry", + field, + title: (metadata.title || "").trim(), + login_username: (metadata.login_username || "").trim(), + website: (metadata.website || "").trim() + } +} + +export const Nerve = { + mounted() { + this.unsubscribe = vaultSession.subscribe(() => this.onLockChange()) + this.bind() + this.onLockChange() + }, + + destroyed() { + this.unsubscribe && this.unsubscribe() + }, + + bind() { + this.el.addEventListener("click", (event) => { + if (event.target.closest("[data-vault-unlock]")) return this.unlock() + if (event.target.closest("[data-vault-lock]")) return vaultSession.lock() + if (event.target.closest("[data-nerve-toggle-password]")) return this.togglePasswordVisibility() + if (event.target.closest("[data-nerve-generate]")) return this.generatePassword() + if (event.target.closest("[data-nerve-entry-submit]")) { + event.preventDefault() + return this.saveEntry() + } + const reveal = event.target.closest("[data-nerve-reveal]") + if (reveal) { + event.preventDefault() + return this.toggleReveal(reveal) + } + }) + }, + + // --- lock state --- + + onLockChange() { + const unlocked = vaultSession.isUnlocked() + const status = this.el.querySelector("[data-vault-status]") + if (status) { + status.textContent = unlocked + ? status.dataset.unlockedLabel || "Unlocked." + : status.dataset.lockedLabel || "Locked." + } + + if (unlocked) this.decryptVisibleMetadata() + }, + + setError(message) { + const el = this.el.querySelector("[data-vault-error]") + if (el) el.textContent = message || "" + }, + + wrappedDek() { + const raw = this.el.dataset.vaultWrappedDek + if (!raw) return null + try { + return JSON.parse(raw) + } catch (_error) { + return null + } + }, + + async unlock() { + this.setError("") + const input = this.el.querySelector("[data-vault-unlock-input]") + const wrapped = this.wrappedDek() + if (!wrapped) return this.setError("Set up account-password encryption first.") + + try { + const mdk = await unwrapWithSecret(wrapped, input ? input.value : "") + vaultSession.unlock(mdk) + if (input) input.value = "" + } catch (_error) { + this.setError( + "Incorrect account password. If you just reset it, recover encrypted data at /account/encrypted-data." + ) + } + }, + + // --- entries --- + + async key() { + return vaultSession.featureKey(FEATURE) + }, + + async decryptMetadata(row, key) { + const raw = row.dataset.encryptedMetadata + if (!raw) return {} + const payload = JSON.parse(raw) + return JSON.parse(await decryptValue(payload, key, metadataAad())) + }, + + async decryptVisibleMetadata() { + let key + try { + key = await this.key() + } catch (_error) { + return + } + + const rows = this.el.querySelectorAll("[data-encrypted-metadata]") + for (const row of rows) { + try { + const metadata = await this.decryptMetadata(row, key) + this.setText(row, "[data-nerve-title-output]", metadata.title) + this.setText(row, "[data-nerve-username-output]", metadata.login_username) + this.setText(row, "[data-nerve-website-output]", metadata.website) + } catch (_error) { + // leave the placeholder if a row fails to decrypt + } + } + }, + + async saveEntry() { + this.setError("") + if (!vaultSession.isUnlocked()) return this.setError("Enter your account password first.") + + const form = this.el.querySelector("#nerve-entry-form") + const metadata = { + title: this.fieldValue('[name="entry[title]"]'), + login_username: this.fieldValue('[name="entry[login_username]"]'), + website: this.fieldValue('[name="entry[website]"]') + } + const password = this.value("[data-nerve-password-input]") + const notes = this.value("[data-nerve-notes-input]") + + if (!password) return this.setError("A password is required.") + + try { + const key = await this.key() + + this.setHidden( + "[data-nerve-encrypted-metadata]", + await encryptValue(JSON.stringify(metadata), key, metadataAad()) + ) + this.setHidden( + "[data-nerve-encrypted-password]", + await encryptValue(password, key, entryAad(metadata, "password")) + ) + this.setHidden( + "[data-nerve-encrypted-notes]", + notes ? await encryptValue(notes, key, entryAad(metadata, "notes")) : null + ) + + // Never let the plaintext reach the server. + this.setValue("[data-nerve-password-input]", "") + this.setValue("[data-nerve-notes-input]", "") + form.requestSubmit() + } catch (_error) { + this.setError("Could not encrypt this entry.") + } + }, + + toggleReveal(button) { + const id = button.dataset.nerveReveal + const secretRow = this.el.querySelector(`[data-nerve-secret-row="${id}"]`) + if (!secretRow) return + + if (!secretRow.classList.contains("hidden")) { + secretRow.classList.add("hidden") + return + } + + if (!vaultSession.isUnlocked()) return this.setError("Enter your account password first.") + + this.pushEvent("load_secret", { id }, (reply) => this.showSecret(button, secretRow, reply)) + }, + + async showSecret(button, secretRow, reply) { + if (!reply || reply.status !== "ok") return + + try { + const key = await this.key() + const row = button.closest("[data-encrypted-metadata]") || button + const metadata = await this.decryptMetadata(row, key) + + const passwordOut = secretRow.querySelector("[data-nerve-password-output]") + if (passwordOut && reply.encrypted_password) { + passwordOut.textContent = await decryptValue( + JSON.parse(reply.encrypted_password), + key, + entryAad(metadata, "password") + ) + } + + const notesWrapper = secretRow.querySelector("[data-nerve-notes-wrapper]") + const notesOut = secretRow.querySelector("[data-nerve-notes-output]") + if (notesOut && reply.encrypted_notes) { + notesOut.textContent = await decryptValue( + JSON.parse(reply.encrypted_notes), + key, + entryAad(metadata, "notes") + ) + notesWrapper && notesWrapper.classList.remove("hidden") + } + + secretRow.classList.remove("hidden") + } catch (_error) { + this.setError("Could not decrypt this entry.") + } + }, + + generatePassword() { + const bytes = new Uint8Array(PASSWORD_LENGTH) + crypto.getRandomValues(bytes) + const password = Array.from(bytes, (b) => PASSWORD_ALPHABET[b % PASSWORD_ALPHABET.length]).join("") + this.setValue("[data-nerve-password-input]", password) + // Reveal it so the user can see/copy what was generated. + this.setPasswordVisible(true) + }, + + togglePasswordVisibility() { + const input = this.el.querySelector("[data-nerve-password-input]") + this.setPasswordVisible(input && input.type === "password") + }, + + setPasswordVisible(visible) { + const input = this.el.querySelector("[data-nerve-password-input]") + if (!input) return + input.type = visible ? "text" : "password" + + const show = this.el.querySelector("[data-nerve-eye-show]") + const hide = this.el.querySelector("[data-nerve-eye-hide]") + if (show) show.classList.toggle("hidden", visible) + if (hide) hide.classList.toggle("hidden", !visible) + }, + + // --- small DOM helpers --- + + value(selector) { + const el = this.el.querySelector(selector) + return el ? el.value : "" + }, + + fieldValue(selector) { + const el = this.el.querySelector(`#nerve-entry-form ${selector}`) + return el ? el.value : "" + }, + + setValue(selector, value) { + const el = this.el.querySelector(selector) + if (el) el.value = value + }, + + setHidden(selector, payload) { + this.setValue(selector, payload ? JSON.stringify(payload) : "") + }, + + setText(scope, selector, text) { + const target = scope.querySelector(selector) + if (target && typeof text === "string") target.textContent = text + } +} diff --git a/apps/elektrine/assets/js/hooks/notification_hooks.js b/apps/elektrine/assets/js/hooks/notification_hooks.js new file mode 100644 index 0000000..65a9a21 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/notification_hooks.js @@ -0,0 +1,75 @@ +// Notification-related LiveView hooks +import { showNotificationFromPayload } from '../notification_system' + +export const NotificationHandler = { + mounted() { + // Store reference to current loading notifications for updates + this.loadingNotifications = new Map() + + this.handleEvent("show_notification", (data) => { + const { id, type } = data + const notification = showNotificationFromPayload(data) + + // Store loading notifications by ID for later updates + if (id && type === 'loading' && notification) { + this.loadingNotifications.set(id, notification) + } + }) + + // Handle updating a loading notification + this.handleEvent("update_notification", ({id, message, title}) => { + const notification = this.loadingNotifications.get(id) + if (notification) { + const msgEl = notification.querySelector('.text-sm') + if (msgEl) msgEl.textContent = message + if (title) { + const titleEl = notification.querySelector('h3') + if (titleEl) titleEl.textContent = title + } + } + }) + + // Handle dismissing a specific notification + this.handleEvent("dismiss_notification", ({id}) => { + const notification = this.loadingNotifications.get(id) + if (notification && notification.dismiss) { + notification.dismiss() + this.loadingNotifications.delete(id) + } + }) + + // Handle completing a loading notification (transition to success/error) + this.handleEvent("complete_notification", ({id, type, message, title}) => { + const notification = this.loadingNotifications.get(id) + if (notification && notification.dismiss) { + notification.dismiss() + this.loadingNotifications.delete(id) + showNotificationFromPayload({ + message, + type: type || 'success', + title + }) + } + }) + + // Listen for notification action events from JavaScript + this.actionHandler = (e) => { + const { event, data } = e.detail + if (event) { + this.pushEvent(event, data || {}) + } + } + window.addEventListener('phx:notification_action', this.actionHandler) + }, + + destroyed() { + if (this.actionHandler) { + window.removeEventListener('phx:notification_action', this.actionHandler) + } + // Dismiss any remaining loading notifications + this.loadingNotifications?.forEach(notification => { + if (notification.dismiss) notification.dismiss() + }) + } +} + diff --git a/apps/elektrine/assets/js/hooks/notification_visibility.js b/apps/elektrine/assets/js/hooks/notification_visibility.js new file mode 100644 index 0000000..8218e1c --- /dev/null +++ b/apps/elektrine/assets/js/hooks/notification_visibility.js @@ -0,0 +1,124 @@ +// Hook for marking notifications as read when they become visible +export const NotificationVisibility = { + mounted() { + this.observer = null + this.markedAsRead = new Set() + this.pendingMarkAsRead = [] + + this.setupIntersectionObserver() + }, + + autoMarkReadEnabled() { + return this.el.dataset.autoMarkRead !== 'false' + }, + + setupIntersectionObserver() { + if (!this.autoMarkReadEnabled()) { + return + } + + // Options for the intersection observer + const options = { + root: null, // Use the viewport as the root + rootMargin: '0px', + threshold: 0.1 // Trigger when just 10% of the notification is visible + } + + // Create the observer + this.observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const notificationEl = entry.target + const isUnread = notificationEl.dataset.unread === 'true' + const notificationIds = this.notificationIdsForElement(notificationEl) + + if (!isUnread || notificationIds.length === 0) { + return + } + + const idsToQueue = notificationIds.filter(id => !this.markedAsRead.has(id)) + + if (idsToQueue.length > 0) { + idsToQueue.forEach(id => { + this.markedAsRead.add(id) + this.pendingMarkAsRead.push(id) + }) + + // Update the data attribute to prevent re-marking + notificationEl.dataset.unread = 'false' + } + } + }) + + // Send event to mark visible notifications as read + if (this.pendingMarkAsRead.length > 0) { + // Debounce the marking to avoid too many server calls + clearTimeout(this.markTimeout) + this.markTimeout = setTimeout(() => { + // Send all pending notifications at once + const idsToMark = [...this.pendingMarkAsRead] + this.pendingMarkAsRead = [] + + this.pushEvent('mark_visible_as_read', { + notification_ids: idsToMark + }) + }, 100) // Reduced to 100ms for faster response + } + }, options) + + // Observe all notification cards + this.observeNotifications() + }, + + notificationIdsForElement(notificationEl) { + const groupedIds = notificationEl.dataset.notificationIds + + if (groupedIds) { + return groupedIds + .split(',') + .map(id => id.trim()) + .filter(Boolean) + } + + const singleId = notificationEl.dataset.notificationId + return singleId ? [singleId] : [] + }, + + observeNotifications() { + const notifications = this.el.querySelectorAll('[data-notification-id], [data-notification-ids]') + + notifications.forEach(notification => { + // Only observe unread notifications + if (notification.dataset.unread === 'true') { + this.observer.observe(notification) + } + }) + }, + + updated() { + if (!this.autoMarkReadEnabled()) { + if (this.observer) { + this.observer.disconnect() + this.observer = null + } + + return + } + + if (!this.observer) { + this.setupIntersectionObserver() + return + } + + // When the DOM updates, check for new notifications to observe + this.observeNotifications() + }, + + destroyed() { + // Clean up the observer when the component is destroyed + if (this.observer) { + this.observer.disconnect() + } + clearTimeout(this.markTimeout) + } +} diff --git a/apps/elektrine/assets/js/hooks/paige_search_hook.js b/apps/elektrine/assets/js/hooks/paige_search_hook.js new file mode 100644 index 0000000..cd82571 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/paige_search_hook.js @@ -0,0 +1,73 @@ +/** + * Keyboard UX for the Paige search page. + * + * - "/" or Cmd/Ctrl+K focuses the search input from anywhere on the page. + * - ArrowDown/ArrowUp move between the input and suggestion items. + * - Escape closes suggestions and returns focus to the input. + * + * Client-side only: no per-keystroke events are sent to the server + * (paige_live_test.exs asserts the input has no keyup bindings). + */ +export const PaigeSearch = { + mounted() { + this.onWindowKeydown = (event) => { + const input = this.input() + if (!input) return + + const commandK = (event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === "k" + const slash = event.key === "/" || event.code === "Slash" + + if (commandK || (slash && !this.isEditableElement(event.target))) { + event.preventDefault() + event.stopPropagation() + input.focus() + input.select() + } + } + + this.onKeydown = (event) => { + if (event.key === "Escape") { + this.pushEvent("clear_suggestions", {}) + this.input()?.focus() + return + } + + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return + + const items = Array.from(this.el.querySelectorAll("[data-suggestion-item]")) + if (items.length === 0) return + + event.preventDefault() + const index = items.indexOf(document.activeElement) + + if (event.key === "ArrowDown") { + const next = index < 0 ? items[0] : items[Math.min(index + 1, items.length - 1)] + next.focus() + } else if (index === 0) { + this.input()?.focus() + } else if (index > 0) { + items[index - 1].focus() + } + } + + window.addEventListener("keydown", this.onWindowKeydown, { capture: true }) + this.el.addEventListener("keydown", this.onKeydown) + }, + + destroyed() { + window.removeEventListener("keydown", this.onWindowKeydown, { capture: true }) + this.el.removeEventListener("keydown", this.onKeydown) + }, + + input() { + return this.el.querySelector("#global-search-input") + }, + + isEditableElement(element) { + if (!(element instanceof HTMLElement)) return false + + return Boolean( + element.closest("input, textarea, select, button, a, [contenteditable='true']") + ) + }, +} diff --git a/apps/elektrine/assets/js/hooks/passkey_hooks.js b/apps/elektrine/assets/js/hooks/passkey_hooks.js new file mode 100644 index 0000000..d6c38c7 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/passkey_hooks.js @@ -0,0 +1,278 @@ +/** + * Passkey Hooks + * WebAuthn/Passkey integration for passwordless authentication. + */ + +import { submitFormPreservingEvents } from '../utils/form_submission' + +/** + * Helper to convert ArrayBuffer to Base64URL string + */ +function bufferToBase64URL(buffer) { + const bytes = new Uint8Array(buffer) + let binary = '' + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]) + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +} + +/** + * Helper to convert Base64URL string to ArrayBuffer + */ +function base64URLToBuffer(base64url) { + // Add padding if needed + const padding = '='.repeat((4 - (base64url.length % 4)) % 4) + const base64 = base64url + .replace(/-/g, '+') + .replace(/_/g, '/') + + padding + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes.buffer +} + +/** + * Check if WebAuthn is supported + */ +function isWebAuthnSupported() { + return !!(window.PublicKeyCredential) +} + +/** + * PasskeyRegister Hook + * Handles WebAuthn registration for adding new passkeys. + * + * Usage: + */ +export const PasskeyAuth = { + mounted() { + // Check WebAuthn support + if (!isWebAuthnSupported()) { + this.el.disabled = true + this.el.title = 'Passkeys are not supported in this browser' + return + } + + // Handle click to start authentication + this.handleClick = async () => { + this.el.disabled = true + try { + // Request challenge from server + const challengeData = await this.getChallenge() + if (challengeData.error) { + throw new Error(challengeData.error) + } + await this.handleAuthentication(challengeData) + } catch (error) { + if (window.showNotification) { + window.showNotification( + error.name === 'NotAllowedError' + ? 'Authentication was cancelled or timed out' + : error.message || 'Authentication failed', + 'error' + ) + } + } finally { + this.el.disabled = false + } + } + + this.el.addEventListener('click', this.handleClick) + }, + + async getChallenge() { + return new Promise((resolve) => { + // Set up one-time event handler for challenge response + const challengeRef = this.handleEvent('passkey_auth_challenge', (data) => { + this.removeHandleEvent(challengeRef) + if (this.challengeEventRef === challengeRef) { + this.challengeEventRef = null + } + resolve(data) + }) + + this.challengeEventRef = challengeRef + + // Request challenge + this.pushEvent('get_passkey_challenge', {}) + }) + }, + + async handleAuthentication(challengeData) { + // Build the credential request options + const publicKey = { + challenge: base64URLToBuffer(challengeData.challenge_b64), + rpId: challengeData.rp_id, + timeout: challengeData.timeout, + userVerification: challengeData.user_verification || 'preferred', + allowCredentials: (challengeData.allow_credentials || []).map(cred => ({ + type: cred.type, + id: base64URLToBuffer(cred.id), + transports: cred.transports + })) + } + + // Request authentication from browser/authenticator + const credential = await navigator.credentials.get({ publicKey }) + + // Extract and encode the response + const response = credential.response + const assertionResponse = { + id: credential.id, + rawId: bufferToBase64URL(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bufferToBase64URL(response.clientDataJSON), + authenticatorData: bufferToBase64URL(response.authenticatorData), + signature: bufferToBase64URL(response.signature) + } + } + + // Include userHandle if present (for discoverable credentials) + if (response.userHandle) { + assertionResponse.response.userHandle = bufferToBase64URL(response.userHandle) + } + + // Submit to server via form POST for proper session handling + this.submitAuthentication(assertionResponse, challengeData.challenge_b64) + }, + + submitAuthentication(assertion, challenge) { + // Create a form to POST the assertion (this ensures proper cookie handling) + const form = document.createElement('form') + form.method = 'POST' + form.action = '/passkey/authenticate' + form.style.display = 'none' + + // Add CSRF token + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content + if (csrfToken) { + const csrfInput = document.createElement('input') + csrfInput.type = 'hidden' + csrfInput.name = '_csrf_token' + csrfInput.value = csrfToken + form.appendChild(csrfInput) + } + + // Add assertion data + const assertionInput = document.createElement('input') + assertionInput.type = 'hidden' + assertionInput.name = 'assertion' + assertionInput.value = JSON.stringify(assertion) + form.appendChild(assertionInput) + + // Add challenge + const challengeInput = document.createElement('input') + challengeInput.type = 'hidden' + challengeInput.name = 'challenge' + challengeInput.value = challenge + form.appendChild(challengeInput) + + document.body.appendChild(form) + submitFormPreservingEvents(form) + }, + + destroyed() { + if (this.handleClick) { + this.el.removeEventListener('click', this.handleClick) + } + + if (this.challengeEventRef) { + this.removeHandleEvent(this.challengeEventRef) + this.challengeEventRef = null + } + } +} diff --git a/apps/elektrine/assets/js/hooks/portal_dropdowns.js b/apps/elektrine/assets/js/hooks/portal_dropdowns.js new file mode 100644 index 0000000..e0c0e07 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/portal_dropdowns.js @@ -0,0 +1,272 @@ +import { OverlayPortal } from '../utils/overlay_portal' + +const activePortalDropdowns = new Set() +const portalDropdownStates = new WeakMap() +let portalDropdownGlobalListenersBound = false +let activePortalTooltip = null + +function ensurePortalDropdownGlobalListeners() { + if (portalDropdownGlobalListenersBound || typeof window === 'undefined') return + + portalDropdownGlobalListenersBound = true + // js-check: allow-global-listener-singleton + document.addEventListener('click', handlePortalDropdownDocumentClick) + document.addEventListener('pointerdown', handlePortalDropdownDocumentPointerDown) + document.addEventListener('keydown', handlePortalDropdownDocumentKeyDown) + document.addEventListener('mouseover', handlePortalTooltipMouseOver) + document.addEventListener('mouseout', handlePortalTooltipMouseOut) + document.addEventListener('focusin', handlePortalTooltipFocusIn) + document.addEventListener('focusout', handlePortalTooltipFocusOut) + window.addEventListener('scroll', repositionActivePortalDropdowns, true) + window.addEventListener('resize', repositionActivePortalDropdowns) +} + +function handlePortalDropdownDocumentClick(event) { + const target = event.target instanceof Element ? event.target : null + const trigger = target?.closest?.('[data-portal-dropdown-trigger]') + const root = trigger?.closest?.('[data-portal-dropdown-root]') + + if (root) { + event.preventDefault() + event.stopPropagation() + togglePortalDropdown(root) + return + } + + const state = portalDropdownStateForTarget(target) + if (state?.isOpen && target?.closest?.('button, a, [phx-click]')) { + window.setTimeout(() => closePortalDropdown(state), 0) + } +} + +function handlePortalDropdownDocumentPointerDown(event) { + if (activePortalDropdowns.size === 0) return + + const target = event.target instanceof Element ? event.target : null + const path = typeof event.composedPath === 'function' ? event.composedPath() : [] + + for (const state of activePortalDropdowns) { + if ( + path.includes(state.root) || + path.includes(state.menu) || + (target && state.root.contains(target)) || + (target && state.menu.contains(target)) + ) { + return + } + } + + closeAllPortalDropdowns() +} + +function handlePortalDropdownDocumentKeyDown(event) { + if (event.key !== 'Escape' || activePortalDropdowns.size === 0) return + + event.preventDefault() + const opened = Array.from(activePortalDropdowns) + const lastOpened = opened[opened.length - 1] + closeAllPortalDropdowns() + lastOpened?.trigger?.focus?.({ preventScroll: true }) +} + +function repositionActivePortalDropdowns() { + for (const state of activePortalDropdowns) positionPortalDropdown(state) + if (activePortalTooltip) positionPortalTooltip(activePortalTooltip) +} + +function togglePortalDropdown(root) { + const state = getPortalDropdownState(root) + if (!state) return + + if (state.isOpen) closePortalDropdown(state) + else openPortalDropdown(state) +} + +function getPortalDropdownState(root) { + const trigger = root.querySelector('[data-portal-dropdown-trigger]') + const menu = root.querySelector('[data-portal-dropdown-menu]') + + if (!trigger || !menu) return null + + const existing = portalDropdownStates.get(root) + if (existing?.trigger === trigger && existing?.menu === menu) return existing + + if (existing) closePortalDropdown(existing) + + const state = { + root, + trigger, + menu, + portal: new OverlayPortal(menu, { + portalRoot: root.closest('[data-phx-main]') || document.body + }), + shouldPortal: root.dataset.portalDropdownMode !== 'anchored', + isOpen: false + } + + trigger.setAttribute('aria-expanded', 'false') + menu.setAttribute('aria-hidden', 'true') + applyPortalDropdownClosedStyles(menu) + portalDropdownStates.set(root, state) + + return state +} + +function openPortalDropdown(state) { + closeAllPortalDropdowns(state) + + state.isOpen = true + state.root.dataset.portalDropdownOpen = 'true' + state.trigger.setAttribute('aria-expanded', 'true') + state.menu.setAttribute('aria-hidden', 'false') + if (state.shouldPortal) state.portal.mount() + positionPortalDropdown(state) + applyPortalDropdownOpenStyles(state.menu) + activePortalDropdowns.add(state) +} + +function closePortalDropdown(state) { + state.isOpen = false + delete state.root.dataset.portalDropdownOpen + state.trigger?.setAttribute('aria-expanded', 'false') + state.menu?.setAttribute('aria-hidden', 'true') + applyPortalDropdownClosedStyles(state.menu) + + if (state.shouldPortal) state.portal?.restore() + else state.portal?.clearPosition() + + activePortalDropdowns.delete(state) +} + +function closeAllPortalDropdowns(exceptState = null) { + for (const state of Array.from(activePortalDropdowns)) { + if (state !== exceptState) closePortalDropdown(state) + } +} + +function positionPortalDropdown(state) { + state.portal?.positionNear(state.trigger, { + placement: state.root.dataset.portalPlacement || 'auto', + align: state.root.dataset.portalAlign || 'start', + margin: Number.parseInt(state.root.dataset.portalMargin || '8', 10) || 8, + zIndex: Number.parseInt(state.root.dataset.portalZIndex || '10000', 10) || 10000 + }) +} + +function handlePortalTooltipMouseOver(event) { + const target = event.target instanceof Element ? event.target : null + const trigger = target?.closest?.('[data-portal-tooltip][data-tip]') + const relatedTarget = event.relatedTarget instanceof Node ? event.relatedTarget : null + + if (!trigger || trigger.contains(relatedTarget)) return + showPortalTooltip(trigger) +} + +function handlePortalTooltipMouseOut(event) { + const target = event.target instanceof Element ? event.target : null + const trigger = target?.closest?.('[data-portal-tooltip][data-tip]') + const relatedTarget = event.relatedTarget instanceof Node ? event.relatedTarget : null + + if (!trigger || trigger.contains(relatedTarget)) return + hidePortalTooltip(trigger) +} + +function handlePortalTooltipFocusIn(event) { + const trigger = event.target instanceof Element + ? event.target.closest('[data-portal-tooltip][data-tip]') + : null + + if (trigger) showPortalTooltip(trigger) +} + +function handlePortalTooltipFocusOut(event) { + const trigger = event.target instanceof Element + ? event.target.closest('[data-portal-tooltip][data-tip]') + : null + + if (trigger) hidePortalTooltip(trigger) +} + +function showPortalTooltip(trigger) { + const content = trigger.dataset.tip + if (!content) return + + if (activePortalTooltip?.trigger === trigger) { + activePortalTooltip.tooltip.textContent = content + positionPortalTooltip(activePortalTooltip) + return + } + + removePortalTooltip() + + const tooltip = document.createElement('div') + tooltip.className = 'floating-panel portal-tooltip rounded-box px-2 py-1 text-xs font-medium text-base-content' + tooltip.textContent = content + tooltip.setAttribute('role', 'tooltip') + tooltip.style.pointerEvents = 'none' + tooltip.style.maxWidth = 'min(18rem, calc(100vw - 1rem))' + tooltip.style.whiteSpace = 'normal' + + const portal = new OverlayPortal(tooltip, { + portalRoot: trigger.closest('[data-phx-main]') || document.body + }) + + activePortalTooltip = { trigger, tooltip, portal } + portal.mount() + positionPortalTooltip(activePortalTooltip) +} + +function hidePortalTooltip(trigger) { + if (!activePortalTooltip || activePortalTooltip.trigger !== trigger) return + removePortalTooltip() +} + +function removePortalTooltip() { + if (!activePortalTooltip) return + + activePortalTooltip.tooltip.remove() + activePortalTooltip = null +} + +function positionPortalTooltip(state) { + state.portal.positionNear(state.trigger, { + placement: state.trigger.dataset.portalTooltipPlacement || 'top', + align: state.trigger.dataset.portalTooltipAlign || 'center', + margin: Number.parseInt(state.trigger.dataset.portalTooltipMargin || '8', 10) || 8, + zIndex: Number.parseInt(state.trigger.dataset.portalTooltipZIndex || '10001', 10) || 10001 + }) +} + +function applyPortalDropdownOpenStyles(menu) { + if (!menu) return + + Object.assign(menu.style, { + visibility: 'visible', + opacity: '1', + pointerEvents: 'auto', + transform: 'translateY(0) scale(1)' + }) +} + +function applyPortalDropdownClosedStyles(menu) { + if (!menu) return + + Object.assign(menu.style, { + visibility: 'hidden', + opacity: '0', + pointerEvents: 'none', + transform: 'translateY(-4px) scale(0.98)' + }) +} + +function portalDropdownStateForTarget(target) { + if (!target) return null + + for (const state of activePortalDropdowns) { + if (state.root.contains(target) || state.menu.contains(target)) return state + } + + return null +} + +ensurePortalDropdownGlobalListeners() diff --git a/apps/elektrine/assets/js/hooks/presence_hooks.js b/apps/elektrine/assets/js/hooks/presence_hooks.js new file mode 100644 index 0000000..2cf30fc --- /dev/null +++ b/apps/elektrine/assets/js/hooks/presence_hooks.js @@ -0,0 +1,133 @@ +/** + * Presence-related LiveView hooks. + * + * ActivityTracker drives auto-away detection client-side: it pushes + * "auto_away_timeout" after five idle minutes and "user_activity" with + * clear_away when the user returns. Ordinary activity is handled entirely in + * the browser (resetting the idle timer) and sends nothing to the server. + * + * Device info (type, browser, timezone) is sent once in the LiveSocket + * connect params (see detectDevice below), not via events. + */ + +// Auto-away timeout in milliseconds (5 minutes) +const AUTO_AWAY_TIMEOUT = 5 * 60 * 1000; + +function isHookConnected(hook) { + return Boolean(hook?.liveSocket?.isConnected?.() && hook?.el?.isConnected); +} + +function safePushEvent(hook, event, payload) { + if (!isHookConnected(hook)) return Promise.resolve(null); + return Promise.resolve(hook.pushEvent(event, payload)).catch(() => null); +} + +/** + * Detects device type, browser, and timezone for presence metadata. + * Called once at LiveSocket setup and sent as connect params. + */ +export function detectDevice() { + const ua = navigator.userAgent; + const isMobile = /iPhone|iPad|iPod|Android|webOS|BlackBerry|IEMobile|Opera Mini/i.test(ua); + const isTablet = /iPad|Android/i.test(ua) && !/Mobile/i.test(ua); + + let deviceType = 'desktop'; + if (isTablet) { + deviceType = 'tablet'; + } else if (isMobile) { + deviceType = 'mobile'; + } + + let browser = 'unknown'; + if (ua.includes('Firefox')) browser = 'firefox'; + else if (ua.includes('Edg')) browser = 'edge'; + else if (ua.includes('Chrome')) browser = 'chrome'; + else if (ua.includes('Safari')) browser = 'safari'; + else if (ua.includes('Opera') || ua.includes('OPR')) browser = 'opera'; + + return { + device_type: deviceType, + browser: browser, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone + }; +} + +/** + * ActivityTracker - Tracks user activity for auto-away detection. + * + * Attach to a root element (e.g., body or main container) to track mouse, + * keyboard, touch, scroll, click, and visibility events. + */ +export const ActivityTracker = { + mounted() { + this.isAway = false; + this.awayTimeout = null; + this.boundHandleActivity = this.handleActivity.bind(this); + + const events = ['mousemove', 'keydown', 'touchstart', 'scroll', 'click', 'focus']; + events.forEach(event => { + document.addEventListener(event, this.boundHandleActivity, { passive: true }); + }); + + this.boundHandleVisibilityChange = this.handleVisibilityChange.bind(this); + document.addEventListener('visibilitychange', this.boundHandleVisibilityChange); + + this.resetAwayTimer(); + + // Keep local state in sync with the server's view of auto-away. + this.handleEvent("auto_away_set", ({ was_auto }) => { + if (was_auto) { + this.isAway = true; + } + }); + + this.handleEvent("auto_away_cleared", () => { + this.isAway = false; + }); + }, + + destroyed() { + const events = ['mousemove', 'keydown', 'touchstart', 'scroll', 'click', 'focus']; + events.forEach(event => { + document.removeEventListener(event, this.boundHandleActivity); + }); + + if (this.boundHandleVisibilityChange) { + document.removeEventListener('visibilitychange', this.boundHandleVisibilityChange); + } + + if (this.awayTimeout) { + clearTimeout(this.awayTimeout); + } + }, + + handleActivity() { + this.resetAwayTimer(); + + // Only tell the server anything when there's a state change to make: + // returning from auto-away. + if (this.isAway) { + this.isAway = false; + void safePushEvent(this, "user_activity", { clear_away: true }); + } + }, + + handleVisibilityChange() { + if (document.visibilityState === 'visible') { + this.handleActivity(); + } + }, + + resetAwayTimer() { + if (this.awayTimeout) { + clearTimeout(this.awayTimeout); + } + + this.awayTimeout = setTimeout(() => { + if (!this.isAway) { + this.isAway = true; + void safePushEvent(this, "auto_away_timeout", {}); + } + }, AUTO_AWAY_TIMEOUT); + } +}; diff --git a/apps/elektrine/assets/js/hooks/profile_hooks.js b/apps/elektrine/assets/js/hooks/profile_hooks.js new file mode 100644 index 0000000..34c5a91 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/profile_hooks.js @@ -0,0 +1,279 @@ +/** + * Profile Hooks + * Hooks related to user profiles and status display. + */ + +/** + * Typewriter Effect Hook + * Animates text to appear as if being typed + */ +export const TypewriterHook = { + mounted() { + this.hasCompleted = false + this.startTypewriter() + }, + + updated() { + const newText = this.el.dataset.text + + if (this.hasCompleted && this.lastText === newText) { + if (this.el.textContent !== newText) { + this.el.textContent = newText + this.el.classList.remove('typewriter-cursor') + + const effectClass = this.el.dataset.effectClass || '' + const effectStyle = this.el.dataset.effectStyle || '' + const dataText = this.el.dataset.dataText || newText + + if (effectClass) { + effectClass.split(' ').filter(c => c.trim()).forEach(c => this.el.classList.add(c)) + } + + if (effectStyle) { + effectStyle.split(';').filter(s => s.trim()).forEach(s => { + const [prop, value] = s.split(':').map(p => p.trim()) + if (prop && value) this.el.style.setProperty(prop, value) + }) + } + + if (dataText && (effectClass.includes('glitch') || effectClass.includes('double'))) { + this.el.setAttribute('data-text', dataText) + } + } + return + } + + if (this.timeout) clearTimeout(this.timeout) + this.hasCompleted = false + this.startTypewriter() + }, + + destroyed() { + if (this.timeout) clearTimeout(this.timeout) + }, + + startTypewriter() { + const text = this.el.dataset.text + const speed = this.el.dataset.speed || 'normal' + const effectClass = this.el.dataset.effectClass || '' + const effectStyle = this.el.dataset.effectStyle || '' + const dataText = this.el.dataset.dataText || text + + this.lastText = text + + if (!text) { + this.el.textContent = ' ' + return + } + + const speeds = { slow: 100, normal: 50, fast: 25 } + const delay = speeds[speed] || speeds.normal + + this.el.textContent = text + this.el.style.opacity = '0' + this.el.offsetHeight + this.el.textContent = '' + this.el.style.opacity = '1' + this.el.style.minHeight = '1em' + + if (effectClass) { + effectClass.split(' ').filter(c => c.trim()).forEach(c => this.el.classList.add(c)) + } + + if (effectStyle) { + effectStyle.split(';').filter(s => s.trim()).forEach(s => { + const [prop, value] = s.split(':').map(p => p.trim()) + if (prop && value) this.el.style.setProperty(prop, value) + }) + } + + if (dataText && (effectClass.includes('glitch') || effectClass.includes('double'))) { + this.el.setAttribute('data-text', dataText) + } + + this.el.classList.add('typewriter-cursor') + + let index = 0 + const typeChar = () => { + if (index < text.length) { + this.el.textContent = text.substring(0, index + 1) + if (dataText && (effectClass.includes('glitch') || effectClass.includes('double'))) { + this.el.setAttribute('data-text', text.substring(0, index + 1)) + } + index++ + this.timeout = setTimeout(typeChar, delay) + } else { + this.el.classList.remove('typewriter-cursor') + this.hasCompleted = true + } + } + + this.timeout = setTimeout(typeChar, 500) + } +} + +/** + * Tab Title Typewriter Effect + * Animates the browser tab title with typewriter effect + */ +export const TabTitleTypewriter = { + mounted() { + const text = this.el.dataset.text + const speed = this.el.dataset.speed || 'normal' + const speeds = { slow: 150, normal: 100, fast: 50 } + const delay = speeds[speed] || speeds.normal + + this.originalTitle = document.title + + let index = 0 + const typeChar = () => { + if (index <= text.length) { + document.title = text.substring(0, index) + (index < text.length ? '|' : '') + index++ + setTimeout(typeChar, delay) + } else { + document.title = text + } + } + + typeChar() + }, + + destroyed() { + if (this.originalTitle) document.title = this.originalTitle + } +} + +/** + * Video Background Hook + * Ensures background videos autoplay reliably across all browsers + */ +export const VideoBackground = { + mounted() { + this.video = this.el + this.isPlaying = false + this.setupEventListeners() + if (this.video.readyState >= 1) this.tryPlay() + }, + + destroyed() { + if (this.visibilityHandler) { + document.removeEventListener('visibilitychange', this.visibilityHandler) + } + }, + + setupEventListeners() { + this.video.addEventListener('loadedmetadata', () => this.tryPlay()) + this.video.addEventListener('playing', () => { this.isPlaying = true }) + this.video.addEventListener('pause', () => { this.isPlaying = false }) + this.video.addEventListener('ended', () => this.tryPlay()) + + this.visibilityHandler = () => { + if (!document.hidden && !this.isPlaying) this.tryPlay() + } + document.addEventListener('visibilitychange', this.visibilityHandler) + }, + + tryPlay() { + if (!this.video.paused) return + + const playPromise = this.video.play() + if (playPromise !== undefined) { + playPromise + .then(() => { this.isPlaying = true }) + .catch(() => { + this.isPlaying = false + setTimeout(() => { if (!this.isPlaying) this.tryPlay() }, 1000) + }) + } + } +} + +/** + * Status Selector Hook + * Updates UI immediately when status changes + */ +export const StatusSelector = { + mounted() { + this.handleEvent("status_updated", ({ status }) => this.updateStatusUI(status)) + this.handleSubmit = (event) => { + const form = event.target.closest('form[action="/account/status"]') + if (!form || !this.el.contains(form)) return + + event.preventDefault() + + const statusInput = form.querySelector('input[name="status"]') + const status = statusInput?.value + if (!status) return + + this.updateStatusUI(status) + this.submitStatus(form, status) + } + + this.el.addEventListener('submit', this.handleSubmit) + }, + + destroyed() { + if (this.handleSubmit) this.el.removeEventListener('submit', this.handleSubmit) + }, + + async submitStatus(form, status) { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content + const headers = csrfToken ? { 'X-CSRF-Token': csrfToken } : {} + + try { + await fetch(form.action, { + method: 'POST', + headers, + body: new FormData(form), + credentials: 'same-origin' + }) + + const idleDetector = document.getElementById('idle-detector') + if (idleDetector) { + idleDetector.dataset.userStatus = status + } + } catch (error) { + console.error('Failed to update status:', error) + } + }, + + updateStatusUI(status) { + const indicator = this.el.querySelector('.status-indicator') + if (indicator) { + indicator.classList.remove('bg-success', 'bg-warning', 'bg-error', 'bg-gray-400', 'bg-base-300') + switch(status) { + case 'online': indicator.classList.add('bg-success'); break + case 'away': indicator.classList.add('bg-warning'); break + case 'dnd': indicator.classList.add('bg-error'); break + case 'offline': indicator.classList.add('bg-base-300'); break + } + } + + const buttons = this.el.querySelectorAll('form[action="/account/status"] button') + buttons.forEach(button => { + const form = button.closest('form') + const statusInput = form.querySelector('input[name="status"]') + if (statusInput) { + const buttonStatus = statusInput.value + button.classList.remove( + 'bg-success/10', 'text-success', 'border-success/30', + 'bg-warning/10', 'text-warning', 'border-warning/30', + 'bg-error/10', 'text-error', 'border-error/30', + 'bg-base-300/20', 'bg-base-300/50' + ) + button.classList.add('border', 'border-base-300') + + if (buttonStatus === status) { + button.classList.remove('border-base-300') + switch(status) { + case 'online': button.classList.add('bg-success/10', 'text-success', 'border-success/30'); break + case 'away': button.classList.add('bg-warning/10', 'text-warning', 'border-warning/30'); break + case 'dnd': button.classList.add('bg-error/10', 'text-error', 'border-error/30'); break + case 'offline': button.classList.add('bg-base-300/20'); break + } + } + } + }) + } +} diff --git a/apps/elektrine/assets/js/hooks/profile_link_reorder_hook.js b/apps/elektrine/assets/js/hooks/profile_link_reorder_hook.js new file mode 100644 index 0000000..f70ed33 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/profile_link_reorder_hook.js @@ -0,0 +1,64 @@ +export const ProfileLinkReorder = { + mounted() { + this.dragging = null + + this.onDragStart = (event) => { + const row = event.target.closest("[data-profile-link-id]") + if (!row) return + + this.dragging = row + row.classList.add("opacity-60") + + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move" + event.dataTransfer.setData("text/plain", row.dataset.profileLinkId || "") + } + } + + this.onDragOver = (event) => { + if (!this.dragging) return + + const row = event.target.closest("[data-profile-link-id]") + if (!row || row === this.dragging || row.parentElement !== this.el) return + + event.preventDefault() + const rect = row.getBoundingClientRect() + const placeAfter = event.clientY > rect.top + rect.height / 2 + this.el.insertBefore(this.dragging, placeAfter ? row.nextSibling : row) + } + + this.onDrop = (event) => { + if (!this.dragging) return + event.preventDefault() + this.pushOrder() + } + + this.onDragEnd = () => { + if (this.dragging) { + this.dragging.classList.remove("opacity-60") + } + + this.dragging = null + } + + this.el.addEventListener("dragstart", this.onDragStart) + this.el.addEventListener("dragover", this.onDragOver) + this.el.addEventListener("drop", this.onDrop) + this.el.addEventListener("dragend", this.onDragEnd) + }, + + destroyed() { + this.el.removeEventListener("dragstart", this.onDragStart) + this.el.removeEventListener("dragover", this.onDragOver) + this.el.removeEventListener("drop", this.onDrop) + this.el.removeEventListener("dragend", this.onDragEnd) + }, + + pushOrder() { + const ids = Array.from(this.el.querySelectorAll("[data-profile-link-id]")) + .map((row) => row.dataset.profileLinkId) + .filter(Boolean) + + this.pushEvent("reorder_links", { ids }) + } +} diff --git a/apps/elektrine/assets/js/hooks/proof_graph_dom.js b/apps/elektrine/assets/js/hooks/proof_graph_dom.js new file mode 100644 index 0000000..af3c052 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/proof_graph_dom.js @@ -0,0 +1,18 @@ +const SVG_NS = "http://www.w3.org/2000/svg" +const XLINK_NS = "http://www.w3.org/1999/xlink" + +export function svg(tagName) { + return document.createElementNS(SVG_NS, tagName) +} + +export function setImageHref(node, url) { + node.setAttribute("href", url) + node.setAttributeNS(XLINK_NS, "xlink:href", url) +} + +export function setAttributes(node, attrs) { + Object.entries(attrs).forEach(([key, value]) => { + if (value === null || value === undefined) return + node.setAttribute(key, value) + }) +} diff --git a/apps/elektrine/assets/js/hooks/proof_graph_hook.js b/apps/elektrine/assets/js/hooks/proof_graph_hook.js new file mode 100644 index 0000000..abc8a43 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/proof_graph_hook.js @@ -0,0 +1,599 @@ +import { CLUSTERS, EDGE_COLORS, EDGE_DASHARRAY, NODE_STYLES, USER_NODE_KINDS } from "./proof_graph_styles" +import { createGraphDefs, createNodePaints, paletteForNode } from "./proof_graph_paints" +import { svg, setAttributes, setImageHref } from "./proof_graph_dom" + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)) +} + +function trimText(value, maxLength = 15) { + if (!value || value.length <= maxLength) return value || "" + return `${value.slice(0, maxLength - 3)}...` +} + +function isUserNode(node) { + return USER_NODE_KINDS.has(node.kind) +} + +function labelMaxLength(node) { + if (node.kind === "subject") return 12 + if (isUserNode(node)) return 8 + if (node.kind === "aggregate") return 5 + if (node.kind === "trust") return 6 + return 8 +} + +function subtitleMaxLength(node) { + if (node.kind === "subject") return 16 + if (isUserNode(node)) return 0 + return 10 +} + +function layoutForNode(node) { + if (node.kind === "subject") { + return { + labelY: 10, + subtitleY: 26, + labelFontSize: 12, + subtitleFontSize: 8, + showSubtitle: true + } + } + + if (isUserNode(node)) { + return { + labelY: 3, + subtitleY: 0, + labelFontSize: 9, + subtitleFontSize: 0, + showSubtitle: false + } + } + + return { + labelY: -1, + subtitleY: 13, + labelFontSize: node.kind === "aggregate" ? 12 : 11, + subtitleFontSize: 8, + showSubtitle: true + } +} + +function hashString(value) { + return Array.from(value || "").reduce((acc, char) => acc * 31 + char.charCodeAt(0), 7) +} + +function polarToCartesian(centerX, centerY, radius, angleDeg, yScale = 0.82) { + const angle = (angleDeg * Math.PI) / 180 + return { + x: centerX + Math.cos(angle) * radius, + y: centerY + Math.sin(angle) * radius * yScale + } +} + +function buildEdgePath(source, target, bend) { + const midX = (source.x + target.x) / 2 + const midY = (source.y + target.y) / 2 + const deltaX = target.x - source.x + const deltaY = target.y - source.y + const length = Math.hypot(deltaX, deltaY) || 1 + const normalX = -deltaY / length + const normalY = deltaX / length + const controlX = midX + normalX * bend + const controlY = midY + normalY * bend + return `M ${source.x} ${source.y} Q ${controlX} ${controlY} ${target.x} ${target.y}` +} + +function parseGraphPayload(el) { + const raw = el.dataset.graph + if (!raw) return null + + try { + return JSON.parse(raw) + } catch (_error) { + return null + } +} + +function createSceneState(graph, bounds) { + const center = { x: bounds.width / 2, y: bounds.height / 2 } + const graphRadius = Math.min(bounds.width, bounds.height) * 0.38 + const nodesByCluster = new Map() + + graph.nodes.forEach((node) => { + if (!nodesByCluster.has(node.cluster)) nodesByCluster.set(node.cluster, []) + nodesByCluster.get(node.cluster).push(node) + }) + + const nodes = graph.nodes.map((node) => { + const config = CLUSTERS[node.cluster] || CLUSTERS.followers + const clusterNodes = nodesByCluster.get(node.cluster) || [node] + const index = clusterNodes.findIndex((entry) => entry.id === node.id) + const spread = config.spread || 0 + const step = clusterNodes.length > 1 ? spread / (clusterNodes.length - 1) : 0 + const startAngle = config.angle - spread / 2 + const angle = clusterNodes.length > 1 ? startAngle + step * index : config.angle + const anchor = + node.cluster === "core" + ? { ...center } + : polarToCartesian(center.x, center.y, graphRadius * config.radius, angle) + const seed = hashString(node.id) + const radius = + node.kind === "subject" + ? 54 + : node.kind === "aggregate" + ? 35 + Math.round((node.weight || 0.6) * 10) + : 28 + Math.round((node.weight || 0.6) * 8) + + return { + ...node, + anchorX: anchor.x, + anchorY: anchor.y, + x: anchor.x + ((seed % 13) - 6) * 2.6, + y: anchor.y + ((seed % 17) - 8) * 2.2, + vx: 0, + vy: 0, + radius, + seed, + driftOffset: (seed % 360) * (Math.PI / 180), + depth: node.cluster === "core" ? 0 : config.radius + } + }) + + const nodeById = new Map(nodes.map((node) => [node.id, node])) + + const edges = graph.edges + .map((edge) => ({ + ...edge, + sourceNode: nodeById.get(edge.source), + targetNode: nodeById.get(edge.target) + })) + .filter((edge) => edge.sourceNode && edge.targetNode) + + return { center, graphRadius, nodes, edges } +} + +function createEdgeElement(edge) { + const path = svg("path") + const color = EDGE_COLORS[edge.kind] || "#64748b" + setAttributes(path, { + fill: "none", + stroke: color, + "stroke-width": edge.kind === "trust" ? 2.8 : 2.1, + "stroke-linecap": "round", + "stroke-dasharray": EDGE_DASHARRAY[edge.kind] || null, + opacity: 0.34 + }) + return path +} + +function createNodeElements(node) { + const group = svg("g") + const halo = svg("circle") + const body = svg("circle") + const avatar = node.avatar_url ? svg("image") : null + const wash = svg("circle") + const glaze = svg("circle") + const ring = svg("circle") + const labelText = svg("text") + const subtitleText = svg("text") + const style = NODE_STYLES[node.kind] || NODE_STYLES.signal + const layout = layoutForNode(node) + const palette = paletteForNode(node) + const paints = createNodePaints(this.defs, node, palette) + + group.style.cursor = node.href ? "pointer" : "grab" + + setAttributes(halo, { + r: node.radius + 16, + fill: palette.glow, + opacity: style.haloOpacity ?? (node.kind === "subject" ? 0.3 : 0.08), + "pointer-events": "none" + }) + + setAttributes(body, { + r: node.radius, + fill: paints.fill, + stroke: palette.stroke, + "stroke-width": node.kind === "subject" ? 2.8 : 2.2, + filter: "drop-shadow(0 16px 28px var(--proof-shadow))", + "pointer-events": "none" + }) + + if (avatar && paints.avatarRadius && paints.avatarClipId) { + setAttributes(avatar, { + x: -paints.avatarRadius, + y: -paints.avatarRadius, + width: paints.avatarRadius * 2, + height: paints.avatarRadius * 2, + preserveAspectRatio: "xMidYMid slice", + "clip-path": `url(#${paints.avatarClipId})`, + "pointer-events": "none" + }) + + setImageHref(avatar, node.avatar_url) + } + + setAttributes(wash, { + r: Math.max(node.radius - 1.5, 0), + fill: paints.wash, + opacity: node.avatar_url ? 0.82 : style.washOpacity ?? 0.84, + "pointer-events": "none" + }) + + setAttributes(glaze, { + r: Math.max(node.radius - 4, 0), + fill: paints.glaze, + opacity: node.avatar_url ? 0.72 : style.glazeOpacity ?? 0.3, + "pointer-events": "none" + }) + + setAttributes(ring, { + r: Math.max(node.radius - (node.kind === "subject" ? 5 : 4), 0), + fill: "none", + stroke: palette.ring, + "stroke-width": node.kind === "subject" ? 1.4 : 1.15, + opacity: node.kind === "subject" ? 0.9 : 0.72, + "pointer-events": "none" + }) + + labelText.textContent = trimText(node.label, labelMaxLength(node)) + setAttributes(labelText, { + "text-anchor": "middle", + y: layout.labelY, + "font-size": layout.labelFontSize, + "font-weight": node.kind === "subject" ? "800" : "700", + fill: palette.text, + "pointer-events": "none" + }) + labelText.style.filter = "drop-shadow(0 2px 8px rgba(2, 6, 23, 0.9))" + + subtitleText.textContent = layout.showSubtitle ? trimText(node.subtitle, subtitleMaxLength(node)) : "" + setAttributes(subtitleText, { + "text-anchor": "middle", + y: layout.subtitleY, + "font-size": layout.subtitleFontSize, + "font-weight": "600", + "letter-spacing": "0.08em", + fill: palette.subtitle, + opacity: layout.showSubtitle ? 0.98 : 0, + "pointer-events": "none" + }) + subtitleText.style.filter = "drop-shadow(0 2px 6px rgba(2, 6, 23, 0.8))" + + group.appendChild(halo) + group.appendChild(body) + if (avatar) group.appendChild(avatar) + group.appendChild(wash) + group.appendChild(glaze) + group.appendChild(ring) + group.appendChild(labelText) + group.appendChild(subtitleText) + + return { group, halo, body, avatar, wash, glaze, ring, labelText, subtitleText } +} + +function showTooltip(tooltip, event, node) { + if (!tooltip) return + + const title = document.createElement("div") + title.className = "font-semibold text-slate-950" + title.textContent = node.label || "" + + const children = [title] + const subtitleText = node.subtitle || "" + + if (subtitleText) { + const subtitle = document.createElement("div") + subtitle.className = "mt-1 text-slate-600" + subtitle.textContent = subtitleText + children.push(subtitle) + } + + tooltip.replaceChildren(...children) + tooltip.style.left = `${event.offsetX + 18}px` + tooltip.style.top = `${event.offsetY + 18}px` + tooltip.classList.remove("hidden") +} + +function hideTooltip(tooltip) { + if (!tooltip) return + tooltip.classList.add("hidden") +} + +export const ProofGraph = { + mounted() { + this.canvas = this.el.querySelector('[data-role="graph-canvas"]') + this.tooltip = this.el.querySelector('[data-role="graph-tooltip"]') + this.lastGraphJson = null + this.pointer = null + this.draggedNode = null + this.dragMoved = false + this.highlightedNodeId = null + this.renderGraph() + }, + + updated() { + const rawGraph = this.el.dataset.graph || "" + if (rawGraph !== this.lastGraphJson) { + this.renderGraph() + } + }, + + destroyed() { + this.teardownScene() + }, + + renderGraph() { + const graph = parseGraphPayload(this.el) + if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length === 0) return + + this.teardownScene() + + this.lastGraphJson = this.el.dataset.graph || "" + this.graph = graph + this.svg = svg("svg") + setAttributes(this.svg, { + width: "100%", + height: "100%", + viewBox: `0 0 ${this.el.clientWidth || 1200} ${this.el.clientHeight || 620}`, + "aria-label": "Interactive proof graph" + }) + this.svg.style.display = "block" + + this.defs = createGraphDefs() + this.edgeLayer = svg("g") + this.nodeLayer = svg("g") + this.svg.appendChild(this.defs) + this.svg.appendChild(this.edgeLayer) + this.svg.appendChild(this.nodeLayer) + const mountTarget = this.canvas || this.el + mountTarget.appendChild(this.svg) + + this.scene = createSceneState(graph, { + width: Math.max(this.el.clientWidth, 320), + height: Math.max(this.el.clientHeight, 420) + }) + + this.scene.edges.forEach((edge) => { + edge.element = createEdgeElement(edge) + this.edgeLayer.appendChild(edge.element) + }) + + this.scene.nodes.forEach((node) => { + const elements = createNodeElements.call(this, node) + Object.assign(node, elements) + this.nodeLayer.appendChild(node.group) + + node.group.addEventListener("pointerenter", (event) => { + this.highlightedNodeId = node.id + showTooltip(this.tooltip, event, node) + }) + + node.group.addEventListener("pointerleave", () => { + this.highlightedNodeId = null + hideTooltip(this.tooltip) + }) + + node.group.addEventListener("pointermove", (event) => { + if (this.highlightedNodeId === node.id) showTooltip(this.tooltip, event, node) + }) + + node.group.addEventListener("pointerdown", (event) => { + event.preventDefault() + this.draggedNode = node + this.dragMoved = false + node.group.setPointerCapture(event.pointerId) + }) + + node.group.addEventListener("pointerup", (event) => { + const clickTarget = !this.dragMoved && node.href + this.draggedNode = null + this.dragMoved = false + if (clickTarget) window.location.assign(node.href) + if (node.group.hasPointerCapture(event.pointerId)) { + node.group.releasePointerCapture(event.pointerId) + } + }) + }) + + this.pointerMoveHandler = (event) => { + const rect = this.el.getBoundingClientRect() + const point = { + x: clamp(event.clientX - rect.left, 0, rect.width), + y: clamp(event.clientY - rect.top, 0, rect.height) + } + + this.pointer = point + + if (this.draggedNode) { + this.dragMoved = true + this.draggedNode.x = point.x + this.draggedNode.y = point.y + this.draggedNode.vx = 0 + this.draggedNode.vy = 0 + } + } + + this.pointerLeaveHandler = () => { + this.pointer = null + if (!this.draggedNode) hideTooltip(this.tooltip) + } + + this.el.addEventListener("pointermove", this.pointerMoveHandler) + this.el.addEventListener("pointerleave", this.pointerLeaveHandler) + + this.resizeObserver = new ResizeObserver(() => { + const nextWidth = Math.max(this.el.clientWidth, 320) + const nextHeight = Math.max(this.el.clientHeight, 420) + setAttributes(this.svg, { viewBox: `0 0 ${nextWidth} ${nextHeight}` }) + this.reconcileSceneNodes() + }) + this.resizeObserver.observe(this.el) + + this.animate = this.animate.bind(this) + this.frame = window.requestAnimationFrame(this.animate) + }, + + reconcileSceneNodes() { + const oldNodes = new Map((this.scene?.nodes || []).map((node) => [node.id, node])) + this.scene = createSceneState(this.graph, { + width: Math.max(this.el.clientWidth, 320), + height: Math.max(this.el.clientHeight, 420) + }) + + this.scene.edges.forEach((edge, index) => { + edge.element = this.edgeLayer.children[index] || createEdgeElement(edge) + if (!edge.element.parentNode) this.edgeLayer.appendChild(edge.element) + }) + + this.scene.nodes.forEach((node, index) => { + const previous = oldNodes.get(node.id) + if (previous) { + node.group = previous.group + node.halo = previous.halo + node.body = previous.body + node.avatar = previous.avatar + node.wash = previous.wash + node.glaze = previous.glaze + node.ring = previous.ring + node.labelText = previous.labelText + node.subtitleText = previous.subtitleText + node.x = previous.x + node.y = previous.y + node.vx = previous.vx + node.vy = previous.vy + } else { + const elements = createNodeElements.call(this, node) + Object.assign(node, elements) + this.nodeLayer.appendChild(node.group) + } + + if (!node.group.parentNode) this.nodeLayer.appendChild(node.group) + if (this.nodeLayer.children[index] !== node.group) { + this.nodeLayer.appendChild(node.group) + } + }) + }, + + animate(timestamp) { + if (!this.scene) return + + const { nodes, edges, center, graphRadius } = this.scene + const width = Math.max(this.el.clientWidth, 320) + const height = Math.max(this.el.clientHeight, 420) + const pointerShiftX = this.pointer ? ((this.pointer.x - center.x) / center.x) * 12 : 0 + const pointerShiftY = this.pointer ? ((this.pointer.y - center.y) / center.y) * 10 : 0 + + nodes.forEach((node) => { + if (node.id === this.highlightedNodeId) { + node.vx *= 0.82 + node.vy *= 0.82 + } + + if (this.draggedNode && this.draggedNode.id === node.id) return + + const driftX = Math.cos(timestamp / 1350 + node.driftOffset) * (8 + node.depth * 12) + const driftY = Math.sin(timestamp / 1680 + node.driftOffset) * (6 + node.depth * 10) + const targetX = node.anchorX + driftX + pointerShiftX * (0.8 + node.depth) + const targetY = node.anchorY + driftY + pointerShiftY * (0.8 + node.depth) + const spring = node.cluster === "core" ? 0.12 : 0.045 + + node.vx += (targetX - node.x) * spring + node.vy += (targetY - node.y) * spring + node.vx *= 0.9 + node.vy *= 0.9 + }) + + for (let outer = 0; outer < nodes.length; outer += 1) { + for (let inner = outer + 1; inner < nodes.length; inner += 1) { + const first = nodes[outer] + const second = nodes[inner] + const deltaX = second.x - first.x + const deltaY = second.y - first.y + const distance = Math.hypot(deltaX, deltaY) || 1 + const minimum = first.radius + second.radius + 24 + + if (distance < minimum) { + const force = (minimum - distance) * 0.018 + const forceX = (deltaX / distance) * force + const forceY = (deltaY / distance) * force + + if (!(this.draggedNode && this.draggedNode.id === first.id) && first.cluster !== "core") { + first.vx -= forceX + first.vy -= forceY + } + + if (!(this.draggedNode && this.draggedNode.id === second.id) && second.cluster !== "core") { + second.vx += forceX + second.vy += forceY + } + } + } + } + + nodes.forEach((node) => { + if (node.cluster === "core") { + node.x = center.x + node.y = center.y + } else if (!(this.draggedNode && this.draggedNode.id === node.id)) { + node.x = clamp(node.x + node.vx, 28, width - 28) + node.y = clamp(node.y + node.vy, 28, height - 28) + } + }) + + edges.forEach((edge) => { + const source = edge.sourceNode + const target = edge.targetNode + const connected = + this.highlightedNodeId && + (source.id === this.highlightedNodeId || target.id === this.highlightedNodeId) + const bend = edge.kind === "invite" ? 22 : edge.kind === "trust" ? 12 : 18 + setAttributes(edge.element, { + d: buildEdgePath(source, target, bend), + opacity: this.highlightedNodeId ? (connected ? 0.92 : 0.12) : 0.34, + "stroke-width": connected ? 3.6 : edge.kind === "trust" ? 2.8 : 2.1 + }) + }) + + nodes.forEach((node) => { + const connected = + !this.highlightedNodeId || + node.id === this.highlightedNodeId || + edges.some( + (edge) => + (edge.sourceNode.id === node.id || edge.targetNode.id === node.id) && + (edge.sourceNode.id === this.highlightedNodeId || edge.targetNode.id === this.highlightedNodeId) + ) + const scale = this.highlightedNodeId && node.id === this.highlightedNodeId ? 1.08 : 1 + const opacity = connected ? 1 : 0.32 + setAttributes(node.halo, { opacity: connected ? (node.kind === "subject" ? 0.42 : 0.22) : 0.06 }) + setAttributes(node.group, { + transform: `translate(${node.x}, ${node.y}) scale(${scale})` + }) + node.group.style.opacity = opacity.toString() + node.group.style.transition = "opacity 120ms ease" + }) + + this.frame = window.requestAnimationFrame(this.animate) + }, + + teardownScene() { + if (this.frame) window.cancelAnimationFrame(this.frame) + if (this.resizeObserver) this.resizeObserver.disconnect() + if (this.pointerMoveHandler) this.el.removeEventListener("pointermove", this.pointerMoveHandler) + if (this.pointerLeaveHandler) this.el.removeEventListener("pointerleave", this.pointerLeaveHandler) + hideTooltip(this.tooltip) + this.frame = null + this.resizeObserver = null + this.pointerMoveHandler = null + this.pointerLeaveHandler = null + + if (this.svg && this.svg.parentNode) { + this.svg.remove() + } + + this.svg = null + this.scene = null + } +} diff --git a/apps/elektrine/assets/js/hooks/proof_graph_paints.js b/apps/elektrine/assets/js/hooks/proof_graph_paints.js new file mode 100644 index 0000000..6d61aec --- /dev/null +++ b/apps/elektrine/assets/js/hooks/proof_graph_paints.js @@ -0,0 +1,215 @@ +import { svg, setAttributes } from "./proof_graph_dom" + +function sanitizeId(value) { + return String(value || "") + .trim() + .replace(/[^a-zA-Z0-9_-]/g, "-") +} + +function hashString(value) { + return Array.from(value || "").reduce((acc, char) => acc * 31 + char.charCodeAt(0), 7) +} + +function createGradient(id, tagName, attrs, stops) { + const gradient = svg(tagName) + setAttributes(gradient, { id, ...attrs }) + + stops.forEach((stopConfig) => { + const stop = svg("stop") + setAttributes(stop, { offset: stopConfig.offset, "stop-color": stopConfig.color }) + + if (stopConfig.opacity !== undefined) { + setAttributes(stop, { "stop-opacity": stopConfig.opacity }) + } + + gradient.appendChild(stop) + }) + + return gradient +} + +export function paletteForNode(node) { + const seed = Math.abs(hashString(node.id || node.label || "node")) + const hue = seed % 360 + const hueShift = (hue + 26 + (seed % 19)) % 360 + const saturation = node.kind === "subject" ? 88 : 78 + const lightness = node.kind === "subject" ? 66 : 60 + + return { + accent: `hsl(${hue}, ${saturation}%, ${lightness}%)`, + stroke: `hsla(${hue}, ${Math.min(saturation + 6, 96)}%, ${Math.max(lightness - 6, 40)}%, 0.96)`, + ring: `hsla(${hue}, 95%, 85%, ${node.kind === "subject" ? 0.92 : 0.76})`, + glow: `hsla(${hue}, 96%, 70%, ${node.kind === "subject" ? 0.34 : 0.22})`, + fillTop: `hsla(${hue}, 56%, ${node.kind === "subject" ? 28 : 18}%, 0.96)`, + fillBottom: `hsla(${hueShift}, 62%, 10%, 0.98)`, + washTop: `hsla(${hue}, 94%, 76%, ${node.kind === "subject" ? 0.18 : 0.14})`, + washBottom: `hsla(${hueShift}, 88%, 58%, ${node.kind === "subject" ? 0.24 : 0.2})`, + text: "rgba(248, 250, 252, 0.98)", + subtitle: "rgba(226, 232, 240, 0.82)" + } +} + +export function createNodePaints(defs, node, palette) { + const safeId = sanitizeId(node.id) + const fillId = `proof-node-fill-${safeId}` + const washId = `proof-node-wash-${safeId}` + const glazeId = `proof-node-glaze-${safeId}` + let avatarClipId = null + let avatarRadius = null + + if (node.avatar_url) { + avatarClipId = `proof-node-avatar-clip-${safeId}` + avatarRadius = Math.max(node.radius - (node.kind === "subject" ? 6 : 4), 0) + const avatarClip = svg("clipPath") + const avatarClipCircle = svg("circle") + + setAttributes(avatarClip, { + id: avatarClipId + }) + + setAttributes(avatarClipCircle, { + r: avatarRadius + }) + + avatarClip.appendChild(avatarClipCircle) + defs.appendChild(avatarClip) + } + + defs.appendChild( + createGradient(fillId, "linearGradient", { x1: "0%", y1: "0%", x2: "100%", y2: "100%" }, [ + { offset: "0%", color: palette.fillTop }, + { offset: "100%", color: palette.fillBottom } + ]) + ) + + defs.appendChild( + createGradient(washId, "linearGradient", { x1: "0%", y1: "0%", x2: "0%", y2: "100%" }, [ + { offset: "0%", color: node.avatar_url ? "rgba(255, 255, 255, 0.04)" : palette.washTop }, + { offset: "45%", color: node.avatar_url ? "rgba(15, 23, 42, 0.08)" : palette.washTop }, + { offset: "100%", color: node.avatar_url ? "rgba(2, 6, 23, 0.62)" : palette.washBottom } + ]) + ) + + defs.appendChild( + createGradient(glazeId, "radialGradient", { cx: "32%", cy: "24%", r: "82%" }, [ + { offset: "0%", color: palette.accent, opacity: node.avatar_url ? 0.18 : 0.3 }, + { offset: "36%", color: "#ffffff", opacity: node.avatar_url ? 0.1 : 0.18 }, + { offset: "100%", color: "#ffffff", opacity: 0 } + ]) + ) + + return { + fill: `url(#${fillId})`, + wash: `url(#${washId})`, + glaze: `url(#${glazeId})`, + avatarClipId, + avatarRadius + } +} + +export function createGraphDefs() { + const defs = svg("defs") + + defs.appendChild( + createGradient( + "proof-node-gradient-surface", + "linearGradient", + { x1: "0%", y1: "0%", x2: "100%", y2: "100%" }, + [ + { offset: "0%", color: "var(--proof-node-surface-top)" }, + { offset: "100%", color: "var(--proof-node-surface-bottom)" } + ] + ) + ) + + defs.appendChild( + createGradient( + "proof-node-gradient-primary", + "linearGradient", + { x1: "0%", y1: "0%", x2: "100%", y2: "100%" }, + [ + { offset: "0%", color: "var(--proof-accent-bright)" }, + { offset: "100%", color: "var(--proof-accent-strong)" } + ] + ) + ) + + defs.appendChild( + createGradient( + "proof-node-gradient-ink", + "linearGradient", + { x1: "0%", y1: "0%", x2: "100%", y2: "100%" }, + [ + { offset: "0%", color: "rgba(15, 23, 42, 0.96)" }, + { offset: "100%", color: "rgba(30, 41, 59, 0.96)" } + ] + ) + ) + + defs.appendChild( + createGradient( + "proof-node-tint-neutral", + "linearGradient", + { x1: "0%", y1: "0%", x2: "100%", y2: "100%" }, + [ + { offset: "0%", color: "var(--proof-neutral-tint)" }, + { offset: "46%", color: "var(--proof-ink-wash)" }, + { offset: "100%", color: "#ffffff", opacity: 0 } + ] + ) + ) + + defs.appendChild( + createGradient( + "proof-node-tint-accent", + "linearGradient", + { x1: "0%", y1: "0%", x2: "100%", y2: "100%" }, + [ + { offset: "0%", color: "var(--proof-accent-soft)" }, + { offset: "46%", color: "var(--proof-ink-wash)" }, + { offset: "100%", color: "#ffffff", opacity: 0 } + ] + ) + ) + + defs.appendChild( + createGradient( + "proof-node-tint-primary", + "linearGradient", + { x1: "0%", y1: "0%", x2: "100%", y2: "100%" }, + [ + { offset: "0%", color: "var(--proof-accent-text-wash)" }, + { offset: "52%", color: "rgba(255, 255, 255, 0.06)" }, + { offset: "100%", color: "#ffffff", opacity: 0 } + ] + ) + ) + + defs.appendChild( + createGradient( + "proof-node-gloss", + "radialGradient", + { cx: "28%", cy: "24%", r: "82%" }, + [ + { offset: "0%", color: "#ffffff", opacity: 0.24 }, + { offset: "38%", color: "#ffffff", opacity: 0.12 }, + { offset: "100%", color: "#ffffff", opacity: 0 } + ] + ) + ) + + defs.appendChild( + createGradient( + "proof-node-gloss-primary", + "radialGradient", + { cx: "30%", cy: "22%", r: "84%" }, + [ + { offset: "0%", color: "#ffffff", opacity: 0.3 }, + { offset: "42%", color: "#ffffff", opacity: 0.14 }, + { offset: "100%", color: "#ffffff", opacity: 0 } + ] + ) + ) + + return defs +} diff --git a/apps/elektrine/assets/js/hooks/proof_graph_styles.js b/apps/elektrine/assets/js/hooks/proof_graph_styles.js new file mode 100644 index 0000000..66e5096 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/proof_graph_styles.js @@ -0,0 +1,119 @@ +export const CLUSTERS = { + core: { radius: 0, angle: -90, spread: 0 }, + trust: { radius: 0.28, angle: -88, spread: 18 }, + age: { radius: 0.28, angle: 88, spread: 18 }, + invite: { radius: 0.44, angle: 188, spread: 52 }, + invitee: { radius: 0.63, angle: 214, spread: 88 }, + followers: { radius: 0.58, angle: -18, spread: 94 }, + following: { radius: 0.58, angle: 58, spread: 94 } +} + +export const EDGE_COLORS = { + trust: "var(--proof-edge-strong)", + signal: "var(--proof-edge-muted)", + network: "var(--proof-edge-soft)", + invite: "var(--proof-edge-accent)", + follow: "var(--proof-edge-muted)" +} + +export const EDGE_DASHARRAY = { + signal: "4 12", + network: "8 14", + invite: "10 10", + follow: "5 10" +} + +export const USER_NODE_KINDS = new Set(["subject", "inviter", "invitee", "follower", "following"]) + +export const NODE_STYLES = { + subject: { + fill: "url(#proof-node-gradient-primary)", + stroke: "var(--proof-accent-strong)", + ring: "var(--proof-accent-text-soft)", + wash: "url(#proof-node-tint-primary)", + washOpacity: 0.12, + text: "var(--proof-node-text-inverse)", + subtitle: "var(--proof-node-subtle-inverse)", + halo: "var(--proof-glow)", + haloOpacity: 0.24, + glaze: "url(#proof-node-gloss-primary)", + glazeOpacity: 0.08 + }, + trust: { + fill: "url(#proof-node-gradient-ink)", + stroke: "var(--proof-accent)", + ring: "var(--proof-accent-soft)", + wash: "url(#proof-node-tint-accent)", + washOpacity: 0.16, + text: "var(--proof-node-text-inverse)", + subtitle: "var(--proof-node-subtle-inverse)", + halo: "var(--proof-glow-soft)", + haloOpacity: 0.08 + }, + signal: { + fill: "url(#proof-node-gradient-ink)", + stroke: "var(--proof-node-border-soft)", + ring: "var(--proof-node-border-soft)", + wash: "url(#proof-node-tint-neutral)", + washOpacity: 0.12, + text: "var(--proof-node-text-inverse)", + subtitle: "var(--proof-node-subtle-inverse)", + halo: "var(--proof-glow-soft)", + haloOpacity: 0.06 + }, + aggregate: { + fill: "url(#proof-node-gradient-ink)", + stroke: "var(--proof-accent-soft)", + ring: "var(--proof-accent-soft)", + wash: "url(#proof-node-tint-accent)", + washOpacity: 0.14, + text: "var(--proof-node-text-inverse)", + subtitle: "var(--proof-node-subtle-inverse)", + halo: "var(--proof-glow-soft)", + haloOpacity: 0.08 + }, + inviter: { + fill: "url(#proof-node-gradient-ink)", + stroke: "var(--proof-accent-soft)", + ring: "var(--proof-accent-soft)", + wash: "url(#proof-node-tint-accent)", + washOpacity: 0.14, + text: "var(--proof-node-text-inverse)", + subtitle: "var(--proof-node-subtle-inverse)", + halo: "var(--proof-glow-soft)", + haloOpacity: 0.08 + }, + invitee: { + fill: "url(#proof-node-gradient-ink)", + stroke: "var(--proof-accent-soft)", + ring: "var(--proof-accent-soft)", + wash: "url(#proof-node-tint-accent)", + washOpacity: 0.14, + text: "var(--proof-node-text-inverse)", + subtitle: "var(--proof-node-subtle-inverse)", + halo: "var(--proof-glow-soft)", + haloOpacity: 0.08 + }, + follower: { + fill: "url(#proof-node-gradient-ink)", + stroke: "var(--proof-node-border-soft)", + ring: "var(--proof-node-border-soft)", + wash: "url(#proof-node-tint-neutral)", + washOpacity: 0.12, + text: "var(--proof-node-text-inverse)", + subtitle: "var(--proof-node-subtle-inverse)", + halo: "var(--proof-glow-soft)", + haloOpacity: 0.06 + }, + following: { + fill: "url(#proof-node-gradient-ink)", + stroke: "var(--proof-node-border-soft)", + ring: "var(--proof-node-border-soft)", + wash: "url(#proof-node-tint-neutral)", + washOpacity: 0.12, + text: "var(--proof-node-text-inverse)", + subtitle: "var(--proof-node-subtle-inverse)", + halo: "var(--proof-glow-soft)", + haloOpacity: 0.06 + } +} diff --git a/apps/elektrine/assets/js/hooks/static_site_hooks.js b/apps/elektrine/assets/js/hooks/static_site_hooks.js new file mode 100644 index 0000000..b469456 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/static_site_hooks.js @@ -0,0 +1,86 @@ +// Static site hosting hooks + +// DragDrop hook for visual feedback on drag and drop uploads +export const DragDrop = { + mounted() { + this.dragDepth = 0 + + this.onDragEnter = (e) => { + e.preventDefault() + this.dragDepth += 1 + this.pushEvent("dragover", {}) + } + + this.onDragOver = (e) => { + e.preventDefault() + + if (e.dataTransfer) { + e.dataTransfer.dropEffect = "copy" + } + } + + this.onDragLeave = (e) => { + e.preventDefault() + this.dragDepth = Math.max(this.dragDepth - 1, 0) + + if (this.dragDepth === 0) { + this.pushEvent("dragleave", {}) + } + } + + this.onDrop = (e) => { + e.preventDefault() + this.dragDepth = 0 + this.pushEvent("drop", {}) + } + + this.el.addEventListener("dragenter", this.onDragEnter) + this.el.addEventListener("dragover", this.onDragOver) + this.el.addEventListener("dragleave", this.onDragLeave) + this.el.addEventListener("drop", this.onDrop) + }, + + destroyed() { + this.el.removeEventListener("dragenter", this.onDragEnter) + this.el.removeEventListener("dragover", this.onDragOver) + this.el.removeEventListener("dragleave", this.onDragLeave) + this.el.removeEventListener("drop", this.onDrop) + } +} + +// CodeEditor hook for better textarea handling +export const CodeEditor = { + mounted() { + this.el.addEventListener("keydown", (e) => { + // Handle Tab key to insert spaces instead of changing focus + if (e.key === "Tab") { + e.preventDefault() + const start = this.el.selectionStart + const end = this.el.selectionEnd + const value = this.el.value + + // Insert 2 spaces + this.el.value = value.substring(0, start) + " " + value.substring(end) + this.el.selectionStart = this.el.selectionEnd = start + 2 + } + + // Ctrl/Cmd + S to save + if ((e.ctrlKey || e.metaKey) && e.key === "s") { + e.preventDefault() + // Submit the form + this.el.closest("form").requestSubmit() + } + }) + + // Auto-resize textarea to fit content (optional) + this.autoResize() + this.el.addEventListener("input", () => this.autoResize()) + }, + + autoResize() { + // Keep minimum height but grow with content + this.el.style.height = "auto" + const minHeight = 400 + this.el.style.height = Math.max(minHeight, this.el.scrollHeight) + "px" + } +} diff --git a/apps/elektrine/assets/js/hooks/timeline_hooks.js b/apps/elektrine/assets/js/hooks/timeline_hooks.js new file mode 100644 index 0000000..6484d7f --- /dev/null +++ b/apps/elektrine/assets/js/hooks/timeline_hooks.js @@ -0,0 +1,714 @@ +/** + * Timeline Hooks + * Hooks for timeline/feed interactions including post clicks, infinite scroll, + * dwell time tracking and infinite scroll restoration. + */ + +// Dwell time tracking constants +const SCROLL_PAST_THRESHOLD_MS = 500; +const MIN_DWELL_TIME_MS = 1000; +const BATCH_INTERVAL_MS = 5000; + +function currentScrollY() { + return Math.max( + window.scrollY || 0, + window.pageYOffset || 0, + document.documentElement?.scrollTop || 0, + document.body?.scrollTop || 0, + ); +} + +function postAnchorFromElement(card) { + const postId = card?.dataset?.postId; + if (!postId) return null; + + return { postId, top: card.getBoundingClientRect().top }; +} + +function findVisiblePostAnchor(root = document) { + const viewportHeight = + window.innerHeight || document.documentElement.clientHeight; + const viewportWidth = + window.innerWidth || document.documentElement.clientWidth; + + if (viewportHeight <= 0 || viewportWidth <= 0) return null; + + const rootRect = root.getBoundingClientRect?.(); + const rootCenterX = rootRect + ? rootRect.left + rootRect.width / 2 + : viewportWidth / 2; + const sampleX = Math.max(0, Math.min(viewportWidth - 1, rootCenterX)); + const sampleYs = [ + Math.min(96, viewportHeight - 1), + Math.floor(viewportHeight * 0.25), + Math.floor(viewportHeight * 0.5), + Math.floor(viewportHeight * 0.75), + ].filter((y) => y >= 0 && y < viewportHeight); + + for (const sampleY of sampleYs) { + const target = document.elementFromPoint(sampleX, sampleY); + const card = target?.closest?.("[data-post-id]"); + if (card && root.contains(card)) { + const anchor = postAnchorFromElement(card); + if (anchor) return anchor; + } + } + + const lowerBound = -Math.max(viewportHeight * 1.5, 1200); + const belowBreak = viewportHeight + Math.max(viewportHeight, 1200); + const postCards = root.querySelectorAll("[data-post-id]"); + + for (const card of postCards) { + const rect = card.getBoundingClientRect(); + if (rect.bottom <= lowerBound) continue; + if (rect.top >= belowBreak) break; + if (rect.bottom <= 0 || rect.top >= viewportHeight) continue; + + const anchor = postAnchorFromElement(card); + if (anchor) return anchor; + } + + return null; +} + +// Global state for batching dwell time updates +let dwellTimeBuffer = new Map(); +let batchTimeout = null; +let navigationFlushInProgress = false; +const activeDwellTrackers = new Set(); +let infiniteScrollRestoreSnapshot = null; +const ACTION_LOCK_MS = 700; +const actionLocks = new Map(); + +if (typeof window !== "undefined") { + // js-check: allow-global-listener-singleton + window.addEventListener( + "click", + (event) => { + const target = event.target instanceof Element ? event.target : null; + const actionEl = target?.closest?.("[data-action-lock-key]"); + if (!(actionEl instanceof HTMLButtonElement)) return; + + const key = actionEl.dataset.actionLockKey; + if (!key) return; + + if (actionLocks.has(key)) { + event.preventDefault(); + event.stopImmediatePropagation(); + return; + } + + actionLocks.set(key, window.setTimeout(() => actionLocks.delete(key), ACTION_LOCK_MS)); + }, + true, + ); +} + +if (typeof window !== "undefined") { + window.addEventListener("phx:page-loading-start", () => { + if (navigationFlushInProgress || activeDwellTrackers.size === 0) return; + + const firstTracker = activeDwellTrackers.values().next().value; + if (firstTracker) void flushDwellBatchBeforeNavigation(firstTracker); + }); + + window.addEventListener("phx:page-loading-stop", () => { + navigationFlushInProgress = false; + }); +} + +function buildDwellPayload(state, dwellTimeMs) { + if (!state.postId || dwellTimeMs < MIN_DWELL_TIME_MS) return null; + + return { + post_id: state.postId, + dwell_time_ms: dwellTimeMs, + scroll_depth: state.maxScrollDepth, + expanded: state.wasExpanded, + source: state.source, + }; +} + +function mergeDwellPayloads(existing, incoming) { + if (!existing) return incoming; + if (!incoming) return existing; + + return { + post_id: incoming.post_id || existing.post_id, + dwell_time_ms: + (existing.dwell_time_ms || 0) + (incoming.dwell_time_ms || 0), + scroll_depth: Math.max( + existing.scroll_depth || 0, + incoming.scroll_depth || 0, + ), + expanded: Boolean(existing.expanded || incoming.expanded), + source: incoming.source || existing.source, + }; +} + +function queueDwellPayload(postId, payload) { + if (!postId || !payload) return; + dwellTimeBuffer.set( + postId, + mergeDwellPayloads(dwellTimeBuffer.get(postId), payload), + ); +} + +function takeQueuedDwellPayload(postId) { + if (!postId) return null; + + const payload = dwellTimeBuffer.get(postId) || null; + if (payload) dwellTimeBuffer.delete(postId); + return payload; +} + +function registerDwellTracker(hook) { + if (hook?.postId) activeDwellTrackers.add(hook); +} + +function unregisterDwellTracker(hook) { + activeDwellTrackers.delete(hook); +} + +async function flushDwellBatchBeforeNavigation(hook) { + navigationFlushInProgress = true; + + activeDwellTrackers.forEach((tracker) => { + tracker.pauseTracking?.(); + }); + + if (batchTimeout) { + clearTimeout(batchTimeout); + batchTimeout = null; + } + + if (dwellTimeBuffer.size === 0) return; + + const data = Array.from(dwellTimeBuffer.values()); + dwellTimeBuffer.clear(); + + await Promise.race([ + safePushEvent(hook, "record_dwell_times", { views: data }), + new Promise((resolve) => setTimeout(resolve, 150)), + ]); +} + +function isHookConnected(hook) { + return Boolean(hook?.liveSocket?.isConnected?.() && hook?.el?.isConnected); +} + +function safePushEvent(hook, event, payload) { + if (!isHookConnected(hook)) return Promise.resolve(null); + return Promise.resolve(hook.pushEvent(event, payload)).catch(() => null); +} + +function firstElementInEventPath(event) { + if (typeof event?.composedPath !== "function") return null; + + return event + .composedPath() + .find((node) => node instanceof Element) || null; +} + +function eventPathContainsSelector(event, selector) { + if (typeof event?.composedPath !== "function") return false; + + return event.composedPath().some((node) => { + return node instanceof Element && node.matches(selector); + }); +} + +/** + * Post Click Hook + * Handles navigation when clicking on posts and tracks dwell time + */ +export const PostClick = { + mounted() { + this.suppressNextClickFromPickerDismiss = false; + + this.handlePointerDown = (e) => { + const openReactionPicker = this.el.querySelector( + "[data-reaction-picker-root][data-portal-dropdown-open='true'], [data-reaction-picker-root]:focus-within", + ); + + if (openReactionPicker && !e.target.closest("[data-reaction-picker-root]")) { + this.suppressNextClickFromPickerDismiss = true; + } + }; + + this.handleClick = async (e) => { + const target = + e.target instanceof Element + ? e.target + : e.target?.parentElement || firstElementInEventPath(e); + + if (this.suppressNextClickFromPickerDismiss) { + this.suppressNextClickFromPickerDismiss = false; + return; + } + + if (!target) return; + + if ( + eventPathContainsSelector( + e, + "[data-portal-dropdown-root], [data-portal-dropdown-trigger], [data-portal-dropdown-menu], [data-reaction-picker-root]", + ) || + target.closest( + "[data-portal-dropdown-root], [data-portal-dropdown-trigger], [data-portal-dropdown-menu], [data-reaction-picker-root]", + ) + ) + return; + + if ( + eventPathContainsSelector( + e, + "a, button, input, textarea, select, option, label, form, .dropdown, details", + ) || + target.closest( + "a, button, input, textarea, select, option, label, form, .dropdown, details", + ) + ) + return; + + const nestedPostClick = target.closest('[phx-hook="PostClick"]'); + if (nestedPostClick && nestedPostClick !== this.el) return; + + const closestPhxClick = target.closest("[phx-click]"); + if (closestPhxClick && closestPhxClick !== this.el) return; + + const selection = window.getSelection(); + if (selection && selection.toString().length > 0) return; + + const navLink = this.el.querySelector("[data-post-nav-link]"); + + if (navLink) { + await flushDwellBatchBeforeNavigation(this); + + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) { + navLink.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + cancelable: true, + metaKey: e.metaKey, + ctrlKey: e.ctrlKey, + shiftKey: e.shiftKey, + altKey: e.altKey, + button: e.button, + }), + ); + } else { + navLink.click(); + } + + return; + } + }; + + this.el.addEventListener("pointerdown", this.handlePointerDown); + this.el.addEventListener("click", this.handleClick); + + // Dwell time tracking + if (this.el.dataset.trackDwell === "false") return; + + this.postId = this.el.dataset.postId; + if (!this.postId) return; + + this.source = this.el.dataset.source || "timeline"; + this.startTime = null; + this.totalDwellTime = 0; + this.lastReportedDwellTime = 0; + this.maxScrollDepth = 0; + this.isVisible = false; + this.wasExpanded = false; + + this.observer = new IntersectionObserver( + (entries) => this.handleIntersection(entries), + { root: null, rootMargin: "0px", threshold: [0, 0.25, 0.5, 0.75, 1.0] }, + ); + this.observer.observe(this.el); + registerDwellTracker(this); + + this.handleVisibilityChange = () => { + if (document.hidden && this.isVisible) this.pauseTracking(); + else if (!document.hidden && this.isVisible) this.resumeTracking(); + }; + document.addEventListener("visibilitychange", this.handleVisibilityChange); + }, + + destroyed() { + if (this.handlePointerDown) + this.el.removeEventListener("pointerdown", this.handlePointerDown); + if (this.handleClick) + this.el.removeEventListener("click", this.handleClick); + if (this.observer) this.observer.disconnect(); + if (this.handleVisibilityChange) { + document.removeEventListener( + "visibilitychange", + this.handleVisibilityChange, + ); + } + unregisterDwellTracker(this); + if (this.postId && !navigationFlushInProgress) { + this.pauseTracking(); + this.sendDwellData(); + } + }, + + handleIntersection(entries) { + const entry = entries[0]; + if (entry.isIntersecting) { + if (!this.isVisible) { + this.isVisible = true; + this.resumeTracking(); + } + if (entry.intersectionRatio > this.maxScrollDepth) { + this.maxScrollDepth = entry.intersectionRatio; + } + } else if (this.isVisible) { + this.isVisible = false; + this.pauseTracking(); + if ( + this.totalDwellTime < SCROLL_PAST_THRESHOLD_MS && + this.totalDwellTime > 0 + ) { + this.recordScrollPast(); + } + } + }, + + resumeTracking() { + if (!this.startTime) this.startTime = Date.now(); + }, + + pauseTracking() { + if (this.startTime) { + this.totalDwellTime += Date.now() - this.startTime; + this.startTime = null; + this.bufferDwellData(); + } + }, + + bufferDwellData() { + const pendingDwellTime = this.totalDwellTime - this.lastReportedDwellTime; + const payload = buildDwellPayload(this, pendingDwellTime); + + if (!payload) return; + + queueDwellPayload(this.postId, payload); + this.lastReportedDwellTime = this.totalDwellTime; + this.scheduleBatchSend(); + }, + + scheduleBatchSend() { + if (!batchTimeout) { + batchTimeout = setTimeout(() => { + this.sendBatch(); + batchTimeout = null; + }, BATCH_INTERVAL_MS); + } + }, + + sendBatch() { + if (dwellTimeBuffer.size === 0) return; + const data = Array.from(dwellTimeBuffer.values()); + dwellTimeBuffer.clear(); + void safePushEvent(this, "record_dwell_times", { views: data }); + }, + + sendDwellData() { + const queuedPayload = takeQueuedDwellPayload(this.postId); + const pendingDwellTime = this.totalDwellTime - this.lastReportedDwellTime; + const directPayload = buildDwellPayload(this, pendingDwellTime); + const payload = mergeDwellPayloads(queuedPayload, directPayload); + + if (!payload) return; + + if (directPayload) this.lastReportedDwellTime = this.totalDwellTime; + + void safePushEvent(this, "record_dwell_time", payload); + }, + + recordScrollPast() { + if (this.postId) { + void safePushEvent(this, "record_dismissal", { + post_id: this.postId, + type: "scrolled_past", + dwell_time_ms: this.totalDwellTime, + }); + } + }, +}; + +/** + * Feed pagination hook. + * Preserves scroll position around manual "load more" updates. + * Auto-loading is opt-in with data-auto-load="true". + */ +// Holds a feed's scroll position across a "load more". +// +// Pagination itself is a plain `phx-click` button — this hook does not fetch +// anything. It snapshots the post nearest the viewport before LiveView patches +// the list in and scrolls back to it afterwards, because a patch that grows the +// list can otherwise land the reader somewhere else. +export const ManualPagination = { + mounted() { + this.pending = false; + this.restoreRAF = null; + this.restoreTimeouts = []; + this.prePatchMode = null; + this.loadingMore = this.el.dataset.loadingMore === "true"; + this.prePatchShouldPreserve = false; + this.prePatchScrollY = null; + this.prePatchAnchor = null; + this.loadCycleStartY = null; + this.lastKnownScrollY = currentScrollY(); + this.previousScrollRestoration = null; + + this.disableNativeScrollRestoration(); + this.resetReloadScrollPosition(); + this.clearGlobalRestoreSnapshot(); + + this.handleScroll = () => { + this.lastKnownScrollY = currentScrollY(); + this.cancelSettledRestore(); + }; + window.addEventListener("scroll", this.handleScroll, { passive: true }); + + this.handleClick = (event) => { + const loadMoreTrigger = event.target?.closest?.( + '[phx-click="load-more"], [phx-click="load_more_posts"], [phx-click="load_more_overview"], [data-load-more-trigger]', + ); + if (!loadMoreTrigger || !this.el.contains(loadMoreTrigger)) return; + + this.beginLoadCycle(); + }; + this.el.addEventListener("click", this.handleClick); + }, + + beforeUpdate() { + this.prePatchMode = this.pending || this.loadingMore ? "load-cycle" : null; + this.prePatchShouldPreserve = this.prePatchMode !== null; + + if (this.prePatchShouldPreserve) { + this.prePatchScrollY = this.getStableScrollY(); + this.prePatchAnchor = this.findVisiblePostAnchor(); + this.storeGlobalRestoreSnapshot(this.prePatchAnchor, this.prePatchScrollY); + } else { + this.prePatchScrollY = null; + this.prePatchAnchor = null; + } + }, + + disableNativeScrollRestoration() { + if (!window.history || !("scrollRestoration" in window.history)) return; + + this.previousScrollRestoration = window.history.scrollRestoration; + window.history.scrollRestoration = "manual"; + }, + + resetReloadScrollPosition() { + const navigation = performance.getEntriesByType?.("navigation")?.[0]; + const isReload = navigation?.type === "reload"; + + if (isReload && currentScrollY() > 0) { + window.scrollTo({ top: 0, behavior: "auto" }); + this.lastKnownScrollY = 0; + } + }, + + beginLoadCycle() { + if (this.pending) return; + + this.pending = true; + this.lastKnownScrollY = currentScrollY(); + this.loadCycleStartY = this.lastKnownScrollY; + this.prePatchMode = "load-cycle"; + this.prePatchShouldPreserve = true; + this.prePatchScrollY = this.lastKnownScrollY; + this.prePatchAnchor = this.findVisiblePostAnchor(); + this.storeGlobalRestoreSnapshot(this.prePatchAnchor, this.prePatchScrollY); + }, + + updated() { + this.restoreFromGlobalSnapshotIfNeeded(); + + this.loadingMore = this.el.dataset.loadingMore === "true"; + + if (this.prePatchShouldPreserve) { + this.restoreAnchorPosition(this.prePatchAnchor, this.prePatchScrollY); + + if (this.prePatchMode === "load-cycle") { + this.scheduleSettledRestore( + this.prePatchAnchor, + this.prePatchScrollY, + true, + ); + } + } + + this.prePatchMode = null; + this.prePatchShouldPreserve = false; + this.prePatchScrollY = null; + this.prePatchAnchor = null; + this.lastKnownScrollY = currentScrollY(); + if (!this.loadingMore) { + this.clearGlobalRestoreSnapshot(); + this.loadCycleStartY = null; + this.pending = false; + } + }, + + findVisiblePostAnchor() { + return findVisiblePostAnchor(this.el); + }, + + getStableScrollY() { + const scrollY = currentScrollY(); + + if (scrollY === 0 && this.lastKnownScrollY > 0) { + return this.lastKnownScrollY; + } + + this.lastKnownScrollY = scrollY; + return scrollY; + }, + + findPostById(postId) { + if (!postId) return null; + + const escapedPostId = + typeof CSS !== "undefined" && CSS.escape ? CSS.escape(postId) : postId; + return this.el.querySelector(`[data-post-id="${escapedPostId}"]`); + }, + + restoreAnchorPosition(anchorSnapshot, fallbackScrollY) { + let restored = false; + const currentY = currentScrollY(); + + if (anchorSnapshot?.postId) { + const anchor = this.findPostById(anchorSnapshot.postId); + + if (anchor) { + const newTop = anchor.getBoundingClientRect().top; + const delta = newTop - anchorSnapshot.top; + + if (delta !== 0) window.scrollBy(0, delta); + restored = true; + } + } + + const shouldUseFallbackScroll = (targetY) => { + if (!Number.isFinite(targetY) || targetY <= 0) return false; + + return currentY === 0 || currentY + 120 < targetY; + }; + + if (!restored && shouldUseFallbackScroll(fallbackScrollY)) { + window.scrollTo({ top: fallbackScrollY, behavior: "auto" }); + restored = true; + } else if (!restored && shouldUseFallbackScroll(this.loadCycleStartY)) { + window.scrollTo({ top: this.loadCycleStartY, behavior: "auto" }); + restored = true; + } + + return restored; + }, + + cancelSettledRestore() { + if (this.restoreRAF) cancelAnimationFrame(this.restoreRAF); + this.restoreTimeouts.forEach((timeoutId) => clearTimeout(timeoutId)); + this.restoreRAF = null; + this.restoreTimeouts = []; + }, + + scheduleSettledRestore( + anchorSnapshot, + fallbackScrollY, + includeTimeout = false, + ) { + this.cancelSettledRestore(); + + this.restoreRAF = requestAnimationFrame(() => { + this.restoreRAF = null; + this.restoreAnchorPosition(anchorSnapshot, fallbackScrollY); + }); + + if (includeTimeout) { + [120, 280, 520].forEach((delay) => { + const timeoutId = setTimeout(() => { + this.restoreTimeouts = this.restoreTimeouts.filter( + (id) => id !== timeoutId, + ); + this.restoreAnchorPosition(anchorSnapshot, fallbackScrollY); + }, delay); + + this.restoreTimeouts.push(timeoutId); + }); + } + }, + + storeGlobalRestoreSnapshot(anchorSnapshot, fallbackScrollY) { + infiniteScrollRestoreSnapshot = { + rootId: this.el.id || null, + anchorSnapshot: anchorSnapshot || null, + fallbackScrollY, + capturedAt: Date.now(), + }; + }, + + restoreFromGlobalSnapshotIfNeeded(fromMount = false) { + const snapshot = infiniteScrollRestoreSnapshot; + + if (!snapshot) return; + if (snapshot.rootId && this.el.id && snapshot.rootId !== this.el.id) return; + if (Date.now() - snapshot.capturedAt > 5000) { + this.clearGlobalRestoreSnapshot(); + return; + } + + const shouldRestore = + currentScrollY() === 0 && + Number.isFinite(snapshot.fallbackScrollY) && + snapshot.fallbackScrollY > 0; + + if (!shouldRestore) return; + + this.restoreAnchorPosition(snapshot.anchorSnapshot, snapshot.fallbackScrollY); + + if (!fromMount) { + this.scheduleSettledRestore( + snapshot.anchorSnapshot, + snapshot.fallbackScrollY, + true, + ); + } + }, + + clearGlobalRestoreSnapshot() { + if (!infiniteScrollRestoreSnapshot) return; + if ( + infiniteScrollRestoreSnapshot.rootId && + this.el.id && + infiniteScrollRestoreSnapshot.rootId !== this.el.id + ) { + return; + } + + infiniteScrollRestoreSnapshot = null; + }, + + destroyed() { + this.cancelSettledRestore(); + if (this.handleScroll) + window.removeEventListener("scroll", this.handleScroll); + if (this.handleClick) this.el.removeEventListener("click", this.handleClick); + if ( + this.previousScrollRestoration && + window.history && + "scrollRestoration" in window.history + ) { + window.history.scrollRestoration = this.previousScrollRestoration; + } + }, +}; diff --git a/apps/elektrine/assets/js/hooks/timeline_media_hooks.js b/apps/elektrine/assets/js/hooks/timeline_media_hooks.js new file mode 100644 index 0000000..a746abf --- /dev/null +++ b/apps/elektrine/assets/js/hooks/timeline_media_hooks.js @@ -0,0 +1,157 @@ +import { OverlayPortal } from "../utils/overlay_portal"; +import { registerUserHoverCardHook, unregisterUserHoverCardHook } from "../utils/user_hover_card_scroll"; + +/** + * User Hover Card Hook + * Shows a profile preview card when hovering over usernames/avatars + */ +export const UserHoverCard = { + mounted() { + this.card = this.el.querySelector("[data-hover-card]"); + if (!this.card) return; + + this.trigger = this.el.querySelector("[data-hover-trigger]") || this.el; + this.card.__userHoverCard = this; + this.portal = new OverlayPortal(this.card, { + portalRoot: this.el.closest("[data-phx-main]") || document.body, + }); + this.showTimeout = null; + this.hideTimeout = null; + this.isCardHovered = false; + this.isVisible = false; + registerUserHoverCardHook(this); + + this.handleMouseEnter = () => { + clearTimeout(this.hideTimeout); + this.showTimeout = setTimeout(() => this.showCard(), 400); + }; + + this.handleMouseLeave = () => { + clearTimeout(this.showTimeout); + this.hideTimeout = setTimeout(() => { + if (!this.isCardHovered) this.hideCard(); + }, 200); + }; + + this.handleCardEnter = () => { + this.isCardHovered = true; + clearTimeout(this.hideTimeout); + }; + + this.handleCardLeave = () => { + this.isCardHovered = false; + this.hideTimeout = setTimeout(() => this.hideCard(), 200); + }; + + this.trigger.addEventListener("mouseenter", this.handleMouseEnter); + this.trigger.addEventListener("mouseleave", this.handleMouseLeave); + this.card.addEventListener("mouseenter", this.handleCardEnter); + this.card.addEventListener("mouseleave", this.handleCardLeave); + }, + + showCard() { + if (this.card) { + document.querySelectorAll("[data-hover-card]").forEach((card) => { + if (card !== this.card) { + if (card.__userHoverCard) { + card.__userHoverCard.hideCard(); + } else { + card.classList.remove("visible", "scale-100"); + card.classList.add("invisible", "scale-95"); + } + } + }); + + this.portal.mount(); + this.portal.positionNear(this.trigger); + this.card.classList.remove("invisible", "scale-95"); + this.card.classList.add("visible", "scale-100"); + this.isVisible = true; + } + }, + + hideCard() { + this.isVisible = false; + if (this.card) { + this.card.classList.remove("visible", "scale-100"); + this.card.classList.add("invisible", "scale-95"); + this.portal.restore(); + } + }, + + updated() { + this.trigger = this.el.querySelector("[data-hover-trigger]") || this.el; + + if (this.isVisible && this.card) { + this.portal.positionNear(this.trigger); + } + }, + + dismissForScroll() { + if (!this.showTimeout && !this.hideTimeout && !this.isVisible) return; + + clearTimeout(this.showTimeout); + clearTimeout(this.hideTimeout); + this.showTimeout = null; + this.hideTimeout = null; + this.isCardHovered = false; + this.hideCard(); + }, + + destroyed() { + clearTimeout(this.showTimeout); + clearTimeout(this.hideTimeout); + unregisterUserHoverCardHook(this); + this.card?.removeEventListener("mouseenter", this.handleCardEnter); + this.card?.removeEventListener("mouseleave", this.handleCardLeave); + this.trigger?.removeEventListener("mouseenter", this.handleMouseEnter); + this.trigger?.removeEventListener("mouseleave", this.handleMouseLeave); + this.portal?.restore(); + if (this.card) this.card.__userHoverCard = null; + }, +}; + +/** + * Image Modal Hook + * Adds keyboard and scroll navigation support for image galleries + */ +export const ImageModal = { + mounted() { + this.handleKeyDown = (e) => { + if (e.key === "Escape") this.pushEvent("close_image_modal", {}); + else if (e.key === "ArrowLeft") this.pushEvent("prev_image", {}); + else if (e.key === "ArrowRight") this.pushEvent("next_image", {}); + else if (e.key === "ArrowUp") { + e.preventDefault(); + this.pushEvent("prev_media_post", {}); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + this.pushEvent("next_media_post", {}); + } + }; + + this.lastScrollTime = 0; + this.scrollThrottle = 200; + + this.handleWheel = (e) => { + if (!this.el.contains(e.target)) return; + const now = Date.now(); + if (now - this.lastScrollTime < this.scrollThrottle) return; + if (Math.abs(e.deltaY) < 10) return; + + this.lastScrollTime = now; + e.preventDefault(); + + if (e.deltaY < 0) this.pushEvent("prev_image", {}); + else this.pushEvent("next_image", {}); + }; + + document.addEventListener("keydown", this.handleKeyDown); + document.addEventListener("wheel", this.handleWheel, { passive: false }); + }, + + destroyed() { + document.removeEventListener("keydown", this.handleKeyDown); + document.removeEventListener("wheel", this.handleWheel); + }, +}; diff --git a/apps/elektrine/assets/js/hooks/timeline_preservation_hooks.js b/apps/elektrine/assets/js/hooks/timeline_preservation_hooks.js new file mode 100644 index 0000000..d671149 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/timeline_preservation_hooks.js @@ -0,0 +1,306 @@ +function currentScrollY() { + return Math.max( + window.scrollY || 0, + window.pageYOffset || 0, + document.documentElement?.scrollTop || 0, + document.body?.scrollTop || 0, + ); +} + +function postAnchorFromElement(card) { + const postId = card?.dataset?.postId; + if (!postId) return null; + + return { postId, top: card.getBoundingClientRect().top }; +} + +function findVisiblePostAnchor(root = document) { + const viewportHeight = + window.innerHeight || document.documentElement.clientHeight; + const viewportWidth = + window.innerWidth || document.documentElement.clientWidth; + + if (viewportHeight <= 0 || viewportWidth <= 0) return null; + + const rootRect = root.getBoundingClientRect?.(); + const rootCenterX = rootRect + ? rootRect.left + rootRect.width / 2 + : viewportWidth / 2; + const sampleX = Math.max(0, Math.min(viewportWidth - 1, rootCenterX)); + const sampleYs = [ + Math.min(96, viewportHeight - 1), + Math.floor(viewportHeight * 0.25), + Math.floor(viewportHeight * 0.5), + Math.floor(viewportHeight * 0.75), + ].filter((y) => y >= 0 && y < viewportHeight); + + for (const sampleY of sampleYs) { + const target = document.elementFromPoint(sampleX, sampleY); + const card = target?.closest?.("[data-post-id]"); + if (card && root.contains(card)) { + const anchor = postAnchorFromElement(card); + if (anchor) return anchor; + } + } + + const lowerBound = -Math.max(viewportHeight * 1.5, 1200); + const belowBreak = viewportHeight + Math.max(viewportHeight, 1200); + const postCards = root.querySelectorAll("[data-post-id]"); + + for (const card of postCards) { + const rect = card.getBoundingClientRect(); + if (rect.bottom <= lowerBound) continue; + if (rect.top >= belowBreak) break; + if (rect.bottom <= 0 || rect.top >= viewportHeight) continue; + + const anchor = postAnchorFromElement(card); + if (anchor) return anchor; + } + + return null; +} + +function findPostById(root, postId) { + if (!postId) return null; + + const escapedPostId = + typeof CSS !== "undefined" && CSS.escape ? CSS.escape(postId) : postId; + return root.querySelector(`[data-post-id="${escapedPostId}"]`); +} + +let streamRestoreSnapshot = null; + +/** + * Stream Anchor Hook + * Preserves the visible post position when stream patches prepend/update entries. + */ +export const PreserveStreamAnchor = { + mounted() { + this.prePatchAnchor = null; + this.prePatchScrollY = null; + this.restoreRAF = null; + this.restoreTimeouts = []; + this.lastKnownScrollY = currentScrollY(); + this.clearGlobalRestoreSnapshot(); + + this.handleScroll = () => { + this.lastKnownScrollY = currentScrollY(); + this.cancelPendingRestore(); + }; + window.addEventListener("scroll", this.handleScroll, { passive: true }); + }, + + beforeUpdate() { + const stableScrollY = this.getStableScrollY(); + + if (stableScrollY <= 0) { + this.prePatchAnchor = null; + this.prePatchScrollY = null; + return; + } + + this.prePatchAnchor = this.findVisiblePostAnchor(); + this.prePatchScrollY = stableScrollY; + this.storeGlobalRestoreSnapshot(this.prePatchAnchor, this.prePatchScrollY); + }, + + updated() { + this.restoreFromGlobalSnapshotIfNeeded(); + + if (!this.prePatchAnchor?.postId && !Number.isFinite(this.prePatchScrollY)) return; + + this.restoreAnchorPosition(this.prePatchAnchor, this.prePatchScrollY); + this.schedulePendingRestore(this.prePatchAnchor, this.prePatchScrollY); + this.prePatchAnchor = null; + this.prePatchScrollY = null; + this.clearGlobalRestoreSnapshot(); + }, + + findVisiblePostAnchor() { + return findVisiblePostAnchor(this.el); + }, + + getStableScrollY() { + const scrollY = currentScrollY(); + + if (scrollY === 0 && this.lastKnownScrollY > 0) { + return this.lastKnownScrollY; + } + + this.lastKnownScrollY = scrollY; + return scrollY; + }, + + findPostById(postId) { + return findPostById(this.el, postId); + }, + + restoreAnchorPosition(anchorSnapshot, fallbackScrollY) { + if (anchorSnapshot?.postId) { + const anchor = this.findPostById(anchorSnapshot.postId); + + if (anchor) { + const delta = anchor.getBoundingClientRect().top - anchorSnapshot.top; + if (delta !== 0) window.scrollBy(0, delta); + return; + } + } + + if ( + Number.isFinite(fallbackScrollY) && + fallbackScrollY > 0 && + currentScrollY() === 0 + ) { + window.scrollTo({ top: fallbackScrollY, behavior: "auto" }); + } + }, + + cancelPendingRestore() { + if (this.restoreRAF) cancelAnimationFrame(this.restoreRAF); + this.restoreTimeouts.forEach((timeoutId) => clearTimeout(timeoutId)); + this.restoreRAF = null; + this.restoreTimeouts = []; + }, + + schedulePendingRestore(anchorSnapshot, fallbackScrollY) { + this.cancelPendingRestore(); + + this.restoreRAF = requestAnimationFrame(() => { + this.restoreRAF = null; + this.restoreAnchorPosition(anchorSnapshot, fallbackScrollY); + }); + [120, 280, 520].forEach((delay) => { + const timeoutId = setTimeout(() => { + this.restoreTimeouts = this.restoreTimeouts.filter( + (id) => id !== timeoutId, + ); + this.restoreAnchorPosition(anchorSnapshot, fallbackScrollY); + }, delay); + + this.restoreTimeouts.push(timeoutId); + }); + }, + + storeGlobalRestoreSnapshot(anchorSnapshot, fallbackScrollY) { + streamRestoreSnapshot = { + rootId: this.el.id || null, + anchorSnapshot: anchorSnapshot || null, + fallbackScrollY, + capturedAt: Date.now(), + }; + }, + + restoreFromGlobalSnapshotIfNeeded(fromMount = false) { + const snapshot = streamRestoreSnapshot; + + if (!snapshot) return; + if (snapshot.rootId && this.el.id && snapshot.rootId !== this.el.id) return; + if (Date.now() - snapshot.capturedAt > 5000) { + this.clearGlobalRestoreSnapshot(); + return; + } + + const shouldRestore = + currentScrollY() === 0 && + Number.isFinite(snapshot.fallbackScrollY) && + snapshot.fallbackScrollY > 0; + + if (!shouldRestore) return; + + this.restoreAnchorPosition(snapshot.anchorSnapshot, snapshot.fallbackScrollY); + + if (!fromMount) { + this.schedulePendingRestore(snapshot.anchorSnapshot, snapshot.fallbackScrollY); + } + }, + + clearGlobalRestoreSnapshot() { + if (!streamRestoreSnapshot) return; + if ( + streamRestoreSnapshot.rootId && + this.el.id && + streamRestoreSnapshot.rootId !== this.el.id + ) { + return; + } + + streamRestoreSnapshot = null; + }, + + destroyed() { + this.cancelPendingRestore(); + this.clearGlobalRestoreSnapshot(); + if (this.handleScroll) + window.removeEventListener("scroll", this.handleScroll); + }, +}; + +/** + * "Show N new posts" — Twitter-style: after the stream prepends, jump to the + * top of the feed so the newly inserted posts are actually visible. + * (Previously preserved scroll, which left new posts above the viewport.) + */ +export const PreserveQueuedPostsButtonScroll = { + mounted() { + this.restoreRAF = null; + this.restoreTimeouts = []; + this.pendingScrollTop = false; + + this.handleClick = () => { + this.pendingScrollTop = true; + }; + + this.el.addEventListener("click", this.handleClick); + }, + + updated() { + if (!this.pendingScrollTop) return; + this.pendingScrollTop = false; + this.scrollFeedToTop(); + }, + + destroyed() { + if (this.handleClick) + this.el.removeEventListener("click", this.handleClick); + + // Button unmounts once the queue is empty — still scroll so new posts show. + if (this.pendingScrollTop) { + this.pendingScrollTop = false; + this.scrollFeedToTop(); + } + + this.restoreTimeouts.forEach((id) => clearTimeout(id)); + this.restoreTimeouts = []; + if (this.restoreRAF) cancelAnimationFrame(this.restoreRAF); + }, + + scrollFeedToTop() { + const scrollTop = () => { + const target = + document.getElementById("timeline-posts-stream") || + document.getElementById("timeline-infinite-scroll"); + + if (target && typeof target.scrollIntoView === "function") { + target.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + this.restoreRAF = requestAnimationFrame(() => { + this.restoreRAF = null; + scrollTop(); + }); + + // Stream patches can lag a frame; re-assert a couple times. + [80, 200].forEach((delay) => { + const timeoutId = setTimeout(() => { + this.restoreTimeouts = this.restoreTimeouts.filter( + (id) => id !== timeoutId, + ); + scrollTop(); + }, delay); + this.restoreTimeouts.push(timeoutId); + }); + }, +}; diff --git a/apps/elektrine/assets/js/hooks/timeline_session_continuity.js b/apps/elektrine/assets/js/hooks/timeline_session_continuity.js new file mode 100644 index 0000000..bdd22c9 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/timeline_session_continuity.js @@ -0,0 +1,112 @@ +const TIMELINE_LAST_VISIT_KEY = "timeline-last-visit-at"; +const COMMUNITY_LAST_VISIT_KEY = "community-last-visit-at"; +const COMMUNITY_PREFERENCES_KEY = "community-view-preferences"; + +function safeJsonParse(value, fallback) { + if (!value) return fallback; + + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function readStorageMap(key) { + return safeJsonParse(window.localStorage?.getItem(key), {}); +} + +function writeStorageMap(key, value) { + try { + window.localStorage?.setItem(key, JSON.stringify(value)); + } catch { + // Ignore storage failures so LiveView stays functional. + } +} + +export const SessionContinuity = { + mounted() { + this.scope = this.el.dataset.scope || "timeline"; + this.userId = this.el.dataset.userId || null; + + if (!this.userId) return; + + if (this.scope === "timeline") this.restoreTimelineContinuity(); + if (this.scope === "community") this.restoreCommunityPreferences(); + + this.handlePageHide = () => this.persistCurrentState(); + window.addEventListener("pagehide", this.handlePageHide); + }, + + updated() { + if (!this.userId) return; + if (this.scope === "community") this.persistCommunityPreferences(); + }, + + destroyed() { + this.persistCurrentState(); + window.removeEventListener("pagehide", this.handlePageHide); + }, + + persistCurrentState() { + if (!this.userId) return; + + if (this.scope === "timeline") { + try { + window.localStorage?.setItem( + TIMELINE_LAST_VISIT_KEY, + String(Date.now()), + ); + } catch { + // Ignore storage failures. + } + return; + } + + if (this.scope === "community") { + const communityId = this.el.dataset.communityId; + if (!communityId) return; + + const visits = readStorageMap(COMMUNITY_LAST_VISIT_KEY); + visits[communityId] = Date.now(); + writeStorageMap(COMMUNITY_LAST_VISIT_KEY, visits); + this.persistCommunityPreferences(); + } + }, + + restoreTimelineContinuity() { + const lastTimelineVisitAt = window.localStorage?.getItem( + TIMELINE_LAST_VISIT_KEY, + ); + const communityLastVisitedAt = readStorageMap(COMMUNITY_LAST_VISIT_KEY); + + this.pushEvent("restore_session_continuity", { + last_timeline_visit_at: lastTimelineVisitAt, + community_last_visited_at: communityLastVisitedAt, + }); + }, + + restoreCommunityPreferences() { + const communityId = this.el.dataset.communityId; + if (!communityId) return; + + const preferences = readStorageMap(COMMUNITY_PREFERENCES_KEY); + const communityPreferences = preferences[communityId]; + + if (communityPreferences) { + this.pushEvent("restore_community_preferences", communityPreferences); + } + }, + + persistCommunityPreferences() { + const communityId = this.el.dataset.communityId; + if (!communityId) return; + + const preferences = readStorageMap(COMMUNITY_PREFERENCES_KEY); + preferences[communityId] = { + view: this.el.dataset.currentView || "posts", + sort: this.el.dataset.sortBy || "hot", + }; + writeStorageMap(COMMUNITY_PREFERENCES_KEY, preferences); + }, +}; diff --git a/apps/elektrine/assets/js/hooks/timeline_status_hooks.js b/apps/elektrine/assets/js/hooks/timeline_status_hooks.js new file mode 100644 index 0000000..918ccbd --- /dev/null +++ b/apps/elektrine/assets/js/hooks/timeline_status_hooks.js @@ -0,0 +1,195 @@ +function parseCount(value) { + const parsed = Number.parseInt(String(value ?? "0").replace(/[^\d-]/g, ""), 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +function formatCount(value) { + return String(value); +} + +export const AnimatedCount = { + mounted() { + this.animation = null; + this.currentCount = parseCount(this.el.dataset.count ?? this.el.textContent); + this.prepareElement(); + this.setCount(this.currentCount); + }, + + updated() { + const nextCount = parseCount(this.el.dataset.count); + const fromCount = this.currentCount ?? parseCount(this.el.textContent); + + if (nextCount === fromCount) { + this.setCount(nextCount); + return; + } + + if (window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches) { + this.setCount(nextCount); + return; + } + + this.animateCountChange(fromCount, nextCount); + }, + + destroyed() { + this.animation?.cancel(); + }, + + prepareElement() { + this.el.style.overflow = "hidden"; + this.el.style.position = "relative"; + this.el.style.verticalAlign = "bottom"; + this.el.style.fontVariantNumeric = "tabular-nums"; + }, + + // A zero count is noise next to an icon — an action row would otherwise read + // "0 0 0 0". Collapsing the element (rather than emptying it) also drops it + // out of the button's flex gap, so the icon isn't left with dead space. + // + // Opt-in via data-hide-zero: counts that carry a text label ("3 comments") + // must keep their zero, or the label is left stranded on its own. + syncVisibility(count) { + const hideZero = this.el.dataset.hideZero === "true"; + this.el.style.display = hideZero && count === 0 ? "none" : "inline-block"; + }, + + setCount(count) { + this.animation?.cancel(); + this.animation = null; + this.currentCount = count; + this.el.textContent = ""; + this.el.appendChild(this.buildCountSpan(count)); + this.syncVisibility(count); + }, + + buildCountSpan(count) { + const span = document.createElement("span"); + span.textContent = formatCount(count); + span.style.display = "block"; + return span; + }, + + animateCountChange(fromCount, toCount) { + this.animation?.cancel(); + + const direction = toCount > fromCount ? -1 : 1; + const currentSpan = this.buildCountSpan(toCount); + const previousSpan = this.buildCountSpan(fromCount); + + currentSpan.style.transform = `translateY(${100 * direction}%)`; + previousSpan.style.position = "absolute"; + previousSpan.style.inset = "0"; + + this.el.textContent = ""; + this.el.append(currentSpan, previousSpan); + // Both ends of the roll have to be on screen for it to read as a roll; + // setCount() re-collapses the element afterwards if it landed on zero. + this.el.style.display = "inline-block"; + + const currentAnimation = currentSpan.animate( + [ + { transform: `translateY(${100 * direction}%)` }, + { transform: "translateY(0%)" }, + ], + { duration: 200, easing: "ease-out", fill: "forwards" }, + ); + + const previousAnimation = previousSpan.animate( + [ + { transform: "translateY(0%)" }, + { transform: `translateY(${-100 * direction}%)` }, + ], + { duration: 200, easing: "ease-out", fill: "forwards" }, + ); + + this.animation = currentAnimation; + this.currentCount = toCount; + + Promise.allSettled([currentAnimation.finished, previousAnimation.finished]).then(() => { + if (this.animation === currentAnimation) this.setCount(toCount); + }); + }, +}; + +const REMOTE_FOLLOW_BUTTON_CLASSES = [ + "btn-ghost", + "btn-secondary", + "btn-primary", + "btn-disabled", + "border", + "border-base-300", + "hover:border-secondary", + "hover:text-secondary", + "phx-click-loading:bg-base-200", + "phx-click-loading:text-base-content", +]; + +// Keep `timeline.none` in step with the server-rendered class list in +// timeline_post_footer.ex — this hook re-applies it after every state change. +const REMOTE_FOLLOW_BUTTON_VARIANTS = { + timeline: { + following: { add: ["btn-ghost"], disabled: false }, + pending: { add: ["btn-ghost"], disabled: false }, + none: { + add: [ + "btn-ghost", + "border", + "border-base-300", + "hover:border-secondary", + "hover:text-secondary", + "phx-click-loading:bg-base-200", + "phx-click-loading:text-base-content", + ], + disabled: false, + }, + }, + "hover-card": { + following: { add: ["btn-ghost"], disabled: false }, + pending: { add: ["btn-disabled"], disabled: true }, + none: { + add: [ + "btn-primary", + "phx-click-loading:bg-base-200", + "phx-click-loading:text-base-content", + ], + disabled: false, + }, + }, +}; + +export const RemoteFollowButton = { + mounted() { + this.remoteActorId = String(this.el.dataset.remoteActorId || ""); + + this.handleEvent("remote_follow_state_changed", ({ remote_actor_id, state }) => { + if (String(remote_actor_id) !== this.remoteActorId) return; + + this.el.dataset.followState = state; + this.syncState(); + }); + + this.syncState(); + }, + + updated() { + this.syncState(); + }, + + syncState() { + const state = this.el.dataset.followState || "none"; + const variantName = this.el.dataset.followVariant || "timeline"; + const variant = + REMOTE_FOLLOW_BUTTON_VARIANTS[variantName] || + REMOTE_FOLLOW_BUTTON_VARIANTS.timeline; + const config = variant[state] || variant.none; + + this.el.classList.remove(...REMOTE_FOLLOW_BUTTON_CLASSES); + this.el.classList.add(...config.add); + this.el.disabled = !!config.disabled; + + this.el.querySelectorAll("[data-follow-display]").forEach((display) => { + display.classList.toggle("hidden", display.dataset.followDisplay !== state); + }); + }, +}; diff --git a/apps/elektrine/assets/js/hooks/ui_hooks.js b/apps/elektrine/assets/js/hooks/ui_hooks.js new file mode 100644 index 0000000..b447d89 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/ui_hooks.js @@ -0,0 +1,535 @@ +import './portal_dropdowns' + +function postAnchorFromElement(card) { + const postId = card?.dataset?.postId + if (!postId) return null + + return { postId, top: card.getBoundingClientRect().top } +} + +function findVisiblePostAnchor(root = document) { + const viewportHeight = window.innerHeight || document.documentElement.clientHeight + const viewportWidth = window.innerWidth || document.documentElement.clientWidth + + if (viewportHeight <= 0 || viewportWidth <= 0) return null + + const rootRect = root.getBoundingClientRect?.() + const rootCenterX = rootRect ? rootRect.left + rootRect.width / 2 : viewportWidth / 2 + const sampleX = Math.max(0, Math.min(viewportWidth - 1, rootCenterX)) + const sampleYs = [ + Math.min(96, viewportHeight - 1), + Math.floor(viewportHeight * 0.25), + Math.floor(viewportHeight * 0.5), + Math.floor(viewportHeight * 0.75) + ].filter((y) => y >= 0 && y < viewportHeight) + + for (const sampleY of sampleYs) { + const target = document.elementFromPoint(sampleX, sampleY) + const card = target?.closest?.('[data-post-id]') + if (card && root.contains(card)) { + const anchor = postAnchorFromElement(card) + if (anchor) return anchor + } + } + + const lowerBound = -Math.max(viewportHeight * 1.5, 1200) + const belowBreak = viewportHeight + Math.max(viewportHeight, 1200) + const postCards = root.querySelectorAll('[data-post-id]') + + for (const card of postCards) { + const rect = card.getBoundingClientRect() + if (rect.bottom <= lowerBound) continue + if (rect.top >= belowBreak) break + if (rect.bottom <= 0 || rect.top >= viewportHeight) continue + + const anchor = postAnchorFromElement(card) + if (anchor) return anchor + } + + return null +} + +// General UI-related LiveView hooks +/** + * UI Hooks + * General-purpose UI hooks for common interactions like copying to clipboard, + * focus management, flash messages, scrolling, and visual effects. + */ + +export const PreserveFocus = { + mounted() { + this.focusedId = null + + this.handleEvent("restore_focus", () => { + // Try to restore focus to the previously focused element + if (this.focusedId) { + const element = document.getElementById(this.focusedId) + if (element) { + element.focus() + } + } + }) + + // Track focus changes + this.focusInHandler = (e) => { + if (e.target.id) { + this.focusedId = e.target.id + } + } + + document.addEventListener('focusin', this.focusInHandler) + }, + + destroyed() { + if (this.focusInHandler) { + document.removeEventListener('focusin', this.focusInHandler) + } + } +} + +export const PreserveSearchFocus = { + mounted() { + this.snapshot = null + }, + + beforeUpdate() { + const active = document.activeElement + + if (!(active instanceof HTMLInputElement) || !this.el.contains(active)) { + this.snapshot = null + return + } + + this.snapshot = { + id: active.id, + name: active.name, + value: active.value, + selectionStart: active.selectionStart, + selectionEnd: active.selectionEnd + } + }, + + updated() { + if (!this.snapshot) return + + const { id, name, value, selectionStart, selectionEnd } = this.snapshot + const selector = id ? `#${CSS.escape(id)}` : `input[name="${CSS.escape(name)}"]` + const input = this.el.querySelector(selector) + + if (!(input instanceof HTMLInputElement)) return + + + input.value = value + input.focus({ preventScroll: true }) + + if (selectionStart !== null && selectionEnd !== null) { + input.setSelectionRange(selectionStart, selectionEnd) + } + } +} + +export const FlashAutoDismiss = { + mounted() { + this.schedule() + }, + + updated() { + this.schedule() + }, + + schedule() { + if (window.initAutoDismissFlashes) { + window.initAutoDismissFlashes(this.el) + } + } +} + +export const TimelineReply = { + mounted() { + this.replyFocusPending = false + this.queuedAnchor = null + this.pendingInteractionAnchor = null + this.pendingInteractionScrollY = null + this.prePatchAnchor = null + this.prePatchScrollY = null + this.prePatchShouldPreserve = false + + this.handleFeedClick = (event) => { + // "Show N new posts" should jump to the top of the feed (Twitter-style), + // not pin the previous viewport. PreserveQueuedPostsButtonScroll owns that. + const queuedBtn = event.target.closest('[data-load-queued-posts]') + if (queuedBtn) { + this.queuedAnchor = null + this.pendingInteractionAnchor = null + this.pendingInteractionScrollY = null + return + } + + const interactiveTarget = event.target.closest('[phx-click]') + if (!this.shouldTrackFeedInteraction(interactiveTarget)) return + + this.pendingInteractionAnchor = this.findVisiblePostAnchor() + this.pendingInteractionScrollY = window.scrollY + } + + this.el.addEventListener('click', this.handleFeedClick) + + // Focus and gently scroll active reply form into view without jumping around. + this.handleEvent("focus_reply_form", ({ textarea_id, container_id }) => { + this.replyFocusPending = true + setTimeout(() => { + const textarea = document.getElementById(textarea_id) + const container = container_id ? document.getElementById(container_id) : null + + if (container) { + const rect = container.getBoundingClientRect() + const topGuard = 96 + const bottomGuard = 24 + const needsScroll = + rect.top < topGuard || rect.bottom > (window.innerHeight - bottomGuard) + + if (needsScroll) { + container.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + } + + if (textarea) { + textarea.focus() + if (textarea.value) { + textarea.setSelectionRange(textarea.value.length, textarea.value.length) + } + } + }, 100) + }) + }, + + beforeUpdate() { + this.prePatchShouldPreserve = this.shouldPreserveFeedPatch() + + if (this.prePatchShouldPreserve) { + this.prePatchAnchor = this.pendingInteractionAnchor || this.findVisiblePostAnchor() + this.prePatchScrollY = + typeof this.pendingInteractionScrollY === 'number' + ? this.pendingInteractionScrollY + : window.scrollY + } else { + this.prePatchAnchor = null + this.prePatchScrollY = null + } + }, + + updated() { + if (this.queuedAnchor) { + this.restoreAnchorPosition(this.queuedAnchor, null) + + this.queuedAnchor = null + this.pendingInteractionAnchor = null + this.pendingInteractionScrollY = null + this.prePatchAnchor = null + this.prePatchScrollY = null + this.prePatchShouldPreserve = false + this.replyFocusPending = false + return + } + + if (this.prePatchShouldPreserve) { + this.restoreAnchorPosition(this.prePatchAnchor, this.prePatchScrollY) + } + + this.pendingInteractionAnchor = null + this.pendingInteractionScrollY = null + this.prePatchAnchor = null + this.prePatchScrollY = null + this.prePatchShouldPreserve = false + this.replyFocusPending = false + }, + + findVisiblePostAnchor() { + return findVisiblePostAnchor(this.el) + }, + + restoreAnchorPosition(anchorSnapshot, fallbackScrollY) { + if (anchorSnapshot?.postId) { + const anchor = this.findPostById(anchorSnapshot.postId) + + if (anchor) { + const newTop = anchor.getBoundingClientRect().top + const delta = newTop - anchorSnapshot.top + if (delta !== 0) window.scrollBy(0, delta) + return + } + } + + if (typeof fallbackScrollY === 'number') { + window.scrollTo({ top: fallbackScrollY, behavior: 'auto' }) + } + }, + + findPostById(postId) { + if (!postId) return null + + const escapedPostId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(postId) : postId + return this.el.querySelector(`[data-post-id="${escapedPostId}"]`) + }, + + shouldPreserveFeedPatch() { + if (this.timelineLoadMoreActive()) return false + + if (this.pendingInteractionScrollY == null || this.pendingInteractionScrollY < 200) return false + + return this.pendingInteractionAnchor !== null + }, + + shouldTrackFeedInteraction(target) { + if (!target || !target.closest('[data-post-id]')) return false + + return [ + 'like_post', + 'unlike_post', + 'boost_post', + 'unboost_post', + 'save_post', + 'unsave_post', + 'react_to_post', + 'vote', + 'vote_post', + 'vote_comment' + ].includes(target.getAttribute('phx-click')) + }, + + timelineLoadMoreActive() { + const infiniteScrollRoot = this.el.querySelector('#timeline-infinite-scroll') + return infiniteScrollRoot?.dataset?.loadingMore === 'true' + }, + + destroyed() { + this.el.removeEventListener('click', this.handleFeedClick) + } +} + +// Scroll to top button that appears when scrolled down +export const ScrollToTop = { + mounted() { + this.scrollThreshold = 400 + this.scrollContainer = this.getScrollContainer() + this.scrollTarget = this.scrollContainer === window ? window : this.scrollContainer + this.ticking = false + + this.handleScroll = () => { + if (this.ticking) return + + this.ticking = true + window.requestAnimationFrame(() => { + this.ticking = false + this.syncVisibility() + }) + } + + this.handleClick = () => { + const target = this.scrollContainer === window + ? window + : (this.scrollContainer || document.scrollingElement || document.documentElement) + + target.scrollTo({ + top: 0, + behavior: 'smooth' + }) + } + + this.el.addEventListener('click', this.handleClick) + this.scrollTarget.addEventListener('scroll', this.handleScroll, { passive: true }) + window.addEventListener('resize', this.handleScroll, { passive: true }) + this.syncVisibility() + }, + + updated() { + const nextScrollContainer = this.getScrollContainer() + + if (nextScrollContainer === this.scrollContainer) { + this.syncVisibility() + return + } + + this.scrollTarget?.removeEventListener('scroll', this.handleScroll) + this.scrollContainer = nextScrollContainer + this.scrollTarget = this.scrollContainer === window ? window : this.scrollContainer + this.scrollTarget.addEventListener('scroll', this.handleScroll, { passive: true }) + this.syncVisibility() + }, + + destroyed() { + this.scrollTarget?.removeEventListener('scroll', this.handleScroll) + window.removeEventListener('resize', this.handleScroll) + this.el.removeEventListener('click', this.handleClick) + }, + + getScrollContainer() { + const rootId = this.el.dataset.scrollRoot + const root = rootId ? document.getElementById(rootId) : this.el.parentElement + let current = root + + while (current && current !== document.body) { + if (this.isScrollable(current)) return current + current = current.parentElement + } + + return window + }, + + isScrollable(element) { + if (!element) return false + + const styles = window.getComputedStyle(element) + const canScroll = ['auto', 'scroll', 'overlay'].includes(styles.overflowY) + + return canScroll && element.scrollHeight > element.clientHeight + 1 + }, + + getScrollTop() { + if (this.scrollContainer === window) { + return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0 + } + + return this.scrollContainer?.scrollTop || 0 + }, + + syncVisibility() { + if (this.getScrollTop() > this.scrollThreshold) { + this.el.classList.remove('opacity-0', 'pointer-events-none') + this.el.classList.add('opacity-100', 'pointer-events-auto') + } else { + this.el.classList.remove('opacity-100', 'pointer-events-auto') + this.el.classList.add('opacity-0', 'pointer-events-none') + } + } +} + +export const RemoteProfileStickyFollow = { + mounted() { + this.panel = this.el.querySelector('.remote-user-sticky-follow-panel') || this.el + this.target = this.resolveTarget() + this.ticking = false + + this.syncVisibility = this.syncVisibility.bind(this) + this.scheduleSync = this.scheduleSync.bind(this) + + window.addEventListener('scroll', this.scheduleSync, { passive: true }) + window.addEventListener('resize', this.scheduleSync, { passive: true }) + + if (this.target && 'IntersectionObserver' in window) { + this.observer = new IntersectionObserver(this.scheduleSync, { threshold: 0 }) + this.observer.observe(this.target) + } + + this.syncVisibility() + }, + + updated() { + const nextTarget = this.resolveTarget() + + if (nextTarget !== this.target) { + if (this.observer && this.target) this.observer.unobserve(this.target) + this.target = nextTarget + if (this.observer && this.target) this.observer.observe(this.target) + } + + this.panel = this.el.querySelector('.remote-user-sticky-follow-panel') || this.el + this.syncVisibility() + }, + + destroyed() { + window.removeEventListener('scroll', this.scheduleSync) + window.removeEventListener('resize', this.scheduleSync) + if (this.observer) this.observer.disconnect() + }, + + resolveTarget() { + const targetId = this.el.dataset.followTarget + return targetId ? document.getElementById(targetId) : null + }, + + scheduleSync() { + if (this.ticking) return + + this.ticking = true + window.requestAnimationFrame(() => { + this.ticking = false + this.syncVisibility() + }) + }, + + syncVisibility() { + if (!this.target || !this.panel) return + + const targetRect = this.target.getBoundingClientRect() + const showOffset = Number.parseInt(this.el.dataset.showOffset || '0', 10) || 0 + const shouldShow = targetRect.bottom <= -showOffset + + this.el.setAttribute('aria-hidden', shouldShow ? 'false' : 'true') + + if (shouldShow) { + this.panel.classList.remove('hidden', 'opacity-0', 'pointer-events-none') + this.panel.classList.add('flex', 'opacity-100', 'pointer-events-auto') + } else { + this.panel.classList.remove('flex', 'opacity-100', 'pointer-events-auto') + this.panel.classList.add('hidden', 'opacity-0', 'pointer-events-none') + } + } +} + +/** + * ImageFallback - Handles image load errors without inline event handlers + * Hides the image and shows a fallback element when image fails to load + * + * Usage: + * + * + * + * + * + * The hook will hide the configured target and show the next sibling with [data-fallback-icon] + */ +export const ImageFallback = { + mounted() { + this.onError = () => { + const hideTarget = this.resolveHideTarget() + if (hideTarget) { + hideTarget.style.display = 'none' + } + + const fallback = this.el.nextElementSibling + if (fallback && fallback.hasAttribute('data-fallback-icon')) { + fallback.style.display = 'flex' + fallback.classList.remove('hidden') + } + } + + this.el.addEventListener('error', this.onError) + + // Also handle case where image is already broken (cached error) + if (this.el.complete && this.el.naturalHeight === 0) { + this.el.dispatchEvent(new Event('error')) + } + }, + + destroyed() { + if (this.onError) { + this.el.removeEventListener('error', this.onError) + } + }, + + resolveHideTarget() { + const hideTarget = this.el.dataset.hideTarget || 'self' + + if (hideTarget === 'parent') { + return this.el.parentElement + } + + if (hideTarget === 'closest') { + const selector = this.el.dataset.hideSelector + return selector ? this.el.closest(selector) : this.el + } + + return this.el + } +} diff --git a/apps/elektrine/assets/js/hooks/vault_crypto.js b/apps/elektrine/assets/js/hooks/vault_crypto.js new file mode 100644 index 0000000..b91f5ec --- /dev/null +++ b/apps/elektrine/assets/js/hooks/vault_crypto.js @@ -0,0 +1,178 @@ +/** + * Vault crypto - shared zero-knowledge primitives for the Elektrine master + * password. One passphrase unlocks a random Master Data Key (MDK); per-feature + * keys (Nerve, Kairo, email) are derived from the MDK via HKDF. The server only + * ever stores wrapped blobs and ciphertext - never the passphrase, recovery + * code, or MDK. + * + * Two payload shapes: + * - "wrapped" (server: account_master_keys): a secret-wrapped value carrying + * its own KDF params - {version, algorithm, kdf, iterations, salt, iv, ciphertext}. + * - "value" (feature data, keyed by an HKDF subkey): {version, algorithm, iv, ciphertext}. + */ + +export const VERSION = 2 +export const ALGORITHM = "AES-GCM" +export const KDF = "PBKDF2-SHA256" +export const PBKDF2_ITERATIONS = 600000 +export const MIN_PASSPHRASE_LENGTH = 14 + +const HKDF_PREFIX = "elektrine-vault:" + +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +export function randomBytes(length) { + const bytes = new Uint8Array(length) + crypto.getRandomValues(bytes) + return bytes +} + +export function bytesToBase64(bytes) { + let binary = "" + bytes.forEach((byte) => { + binary += String.fromCharCode(byte) + }) + return btoa(binary) +} + +export function base64ToBytes(value) { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i) + } + return bytes +} + +function stableJson(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return JSON.stringify(value) + return JSON.stringify( + Object.keys(value) + .sort() + .reduce((acc, key) => { + acc[key] = value[key] + return acc + }, {}) + ) +} + +function aesGcmParams(iv, associatedData) { + if (!associatedData) return { name: ALGORITHM, iv } + return { name: ALGORITHM, iv, additionalData: encoder.encode(stableJson(associatedData)) } +} + +// --- Secret (passphrase / recovery code) wrapping ------------------------------ + +async function deriveWrappingKey(secret, salt, iterations) { + const baseKey = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "PBKDF2" }, + false, + ["deriveKey"] + ) + + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, + baseKey, + { name: ALGORITHM, length: 256 }, + false, + ["encrypt", "decrypt"] + ) +} + +/** Wrap raw bytes (e.g. the MDK) under a key derived from a secret string. */ +export async function wrapWithSecret(plaintextBytes, secret) { + const salt = randomBytes(16) + const iv = randomBytes(12) + const key = await deriveWrappingKey(secret, salt, PBKDF2_ITERATIONS) + const ciphertext = await crypto.subtle.encrypt({ name: ALGORITHM, iv }, key, plaintextBytes) + + return { + version: VERSION, + algorithm: ALGORITHM, + kdf: KDF, + iterations: PBKDF2_ITERATIONS, + salt: bytesToBase64(salt), + iv: bytesToBase64(iv), + ciphertext: bytesToBase64(new Uint8Array(ciphertext)) + } +} + +/** Unwrap a secret-wrapped payload back to raw bytes. Throws on wrong secret. */ +export async function unwrapWithSecret(payload, secret) { + const salt = base64ToBytes(payload.salt) + const iv = base64ToBytes(payload.iv) + const ciphertext = base64ToBytes(payload.ciphertext) + const key = await deriveWrappingKey(secret, salt, Number(payload.iterations)) + const plaintext = await crypto.subtle.decrypt({ name: ALGORITHM, iv }, key, ciphertext) + return new Uint8Array(plaintext) +} + +// --- Master Data Key + per-feature subkeys ------------------------------------ + +export function generateMdk() { + return randomBytes(32) +} + +async function importHkdfBase(mdkBytes) { + return crypto.subtle.importKey("raw", mdkBytes, { name: "HKDF" }, false, ["deriveKey"]) +} + +/** Derive a feature's AES-256-GCM key from the MDK. */ +export async function deriveFeatureKey(mdkBytes, feature) { + const base = await importHkdfBase(mdkBytes) + return crypto.subtle.deriveKey( + { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: encoder.encode(HKDF_PREFIX + feature) }, + base, + { name: ALGORITHM, length: 256 }, + false, + ["encrypt", "decrypt"] + ) +} + +// --- Feature value encryption (keyed by a subkey, with optional AAD) ---------- + +export async function encryptValue(plaintext, key, associatedData = null) { + const iv = randomBytes(12) + const ciphertext = await crypto.subtle.encrypt( + aesGcmParams(iv, associatedData), + key, + encoder.encode(plaintext) + ) + + return { + version: VERSION, + algorithm: ALGORITHM, + iv: bytesToBase64(iv), + ciphertext: bytesToBase64(new Uint8Array(ciphertext)) + } +} + +export async function decryptValue(payload, key, associatedData = null) { + const iv = base64ToBytes(payload.iv) + const ciphertext = base64ToBytes(payload.ciphertext) + const plaintext = await crypto.subtle.decrypt(aesGcmParams(iv, associatedData), key, ciphertext) + return decoder.decode(plaintext) +} + +// --- Recovery code ------------------------------------------------------------ + +const RECOVERY_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +/** 256-bit one-time recovery code, grouped for readability (e.g. ABCD-EFGH-...). */ +export function generateRecoveryCode() { + const bytes = randomBytes(32) + let out = "" + bytes.forEach((byte) => { + out += RECOVERY_ALPHABET[byte & 31] + out += RECOVERY_ALPHABET[(byte >> 3) & 31] + }) + return out.match(/.{1,4}/g).join("-") +} + +/** Normalize a recovery code the user typed back to its canonical form. */ +export function normalizeRecoveryCode(value) { + return (value || "").toUpperCase().replace(/[^A-Z0-9]/g, "") +} diff --git a/apps/elektrine/assets/js/hooks/vault_hooks.js b/apps/elektrine/assets/js/hooks/vault_hooks.js new file mode 100644 index 0000000..3b0a7b2 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/vault_hooks.js @@ -0,0 +1,304 @@ +/** + * VaultManager - browser side of the account-password vault page. + * Generates/wraps the Master Data Key on setup, unwraps it on unlock, and + * shares the unlocked key via vault_session so every feature unlocks at once. + */ + +import { + generateMdk, + wrapWithSecret, + unwrapWithSecret, + generateRecoveryCode, + normalizeRecoveryCode, + MIN_PASSPHRASE_LENGTH +} from "./vault_crypto" +import * as vaultSession from "./vault_session" + +export const VaultManager = { + mounted() { + this.unsubscribe = vaultSession.subscribe(() => this.renderState()) + this.bind() + this.renderState() + }, + + updated() { + this.renderState() + this.renderResetState() + }, + + destroyed() { + this.unsubscribe && this.unsubscribe() + }, + + bind() { + if (this.bound) return + this.bound = true + + this.el.addEventListener("click", (event) => this.handleClick(event)) + this.el.addEventListener("submit", (event) => this.handleSubmit(event)) + this.el.addEventListener("input", (event) => this.handleInput(event)) + this.renderResetState() + }, + + handleClick(event) { + if (event.target.closest("[data-vault-setup]")) { + event.preventDefault() + this.setup() + return + } + + if (event.target.closest("[data-vault-setup-finish]")) { + event.preventDefault() + this.finishSetup() + return + } + + if (event.target.closest("[data-vault-unlock]")) { + event.preventDefault() + this.unlock() + return + } + + if (event.target.closest("[data-vault-lock]")) { + event.preventDefault() + vaultSession.lock() + return + } + + if (event.target.closest("[data-vault-reset-button]")) { + vaultSession.lock() + return + } + + if (event.target.closest("[data-vault-recovery]")) { + event.preventDefault() + this.recover() + return + } + + if (event.target.closest("[data-vault-recovery-finish]")) { + event.preventDefault() + this.finishRecovery() + } + }, + + handleSubmit(event) { + const setupForm = event.target.closest("[data-vault-setup-form]") + if (setupForm) { + this.handleSetupSubmit(event, setupForm) + return + } + + const recoveryForm = event.target.closest("[data-vault-recovery-form]") + if (recoveryForm) { + this.handleRecoverySubmit(event, recoveryForm) + } + }, + + handleInput(event) { + if (event.target.closest("[data-vault-reset-confirm]")) { + this.renderResetState() + } + }, + + setError(message) { + const el = this.el.querySelector("[data-vault-error]") + if (el) el.textContent = message || "" + }, + + setRecoveryError(message) { + const el = this.el.querySelector("[data-vault-recovery-error]") + if (el) el.textContent = message || "" + }, + + renderState() { + const unlocked = vaultSession.isUnlocked() + + const status = this.el.querySelector("[data-vault-status]") + if (status) { + status.textContent = unlocked + ? status.dataset.unlockedLabel || "Unlocked" + : status.dataset.lockedLabel || "Locked" + status.classList.toggle("badge-success", unlocked) + } + + const locked = this.el.querySelector("[data-vault-locked-section]") + const unlockedSection = this.el.querySelector("[data-vault-unlocked-section]") + if (locked) locked.classList.toggle("hidden", unlocked) + if (unlockedSection) unlockedSection.classList.toggle("hidden", !unlocked) + }, + + renderResetState() { + const input = this.el.querySelector("[data-vault-reset-confirm]") + const button = this.el.querySelector("[data-vault-reset-button]") + if (!input || !button) return + + const expected = "RESET ENCRYPTED DATA" + button.disabled = input.value.trim() !== expected + }, + + async setup() { + this.setError("") + const passphrase = this.value("[data-vault-setup-input]") + const confirmInput = this.el.querySelector("[data-vault-setup-confirm]") + const confirm = confirmInput ? confirmInput.value : passphrase + + if (!passphrase) { + return this.setError("Enter your current password.") + } + if (!this.accountPasswordMode() && passphrase.length < MIN_PASSPHRASE_LENGTH) { + return this.setError(`Use at least ${MIN_PASSPHRASE_LENGTH} characters.`) + } + if (passphrase !== confirm) { + return this.setError("Passwords do not match.") + } + + try { + const mdk = generateMdk() + const recoveryCode = generateRecoveryCode() + + const wrappedDek = await wrapWithSecret(mdk, passphrase) + const wrappedRecovery = await wrapWithSecret(mdk, normalizeRecoveryCode(recoveryCode)) + + this.setValue("[data-vault-wrapped-dek-input]", JSON.stringify(wrappedDek)) + this.setValue("[data-vault-wrapped-dek-recovery-input]", JSON.stringify(wrappedRecovery)) + + // Unlock the session now so the user is unlocked the moment setup saves. + vaultSession.unlock(mdk) + + const output = this.el.querySelector("[data-vault-recovery-output]") + if (output) output.textContent = recoveryCode + this.el.querySelector("[data-vault-recovery-panel]")?.classList.remove("hidden") + this.el.querySelector("[data-vault-setup]")?.classList.add("hidden") + } catch (_error) { + this.setError("Could not set up encryption in this browser.") + } + }, + + handleSetupSubmit(event, form = event.currentTarget) { + if (form?.dataset.vaultSetupSubmitting === "true") return + + event.preventDefault() + this.setup() + }, + + async recover() { + this.setRecoveryError("") + + const recoveryCode = normalizeRecoveryCode(this.value("[data-vault-recovery-code]")) + const passphrase = this.value("[data-vault-recovery-new-input]") + const confirmInput = this.el.querySelector("[data-vault-recovery-new-confirm]") + const confirm = confirmInput ? confirmInput.value : passphrase + const wrapped = this.wrappedRecoveryDek() + + if (!wrapped) { + return this.setRecoveryError("No recovery key found.") + } + if (!recoveryCode) { + return this.setRecoveryError("Enter your recovery code.") + } + if (!passphrase) { + return this.setRecoveryError("Enter your current password.") + } + if (!this.accountPasswordMode() && passphrase.length < MIN_PASSPHRASE_LENGTH) { + return this.setRecoveryError(`Use at least ${MIN_PASSPHRASE_LENGTH} characters.`) + } + if (passphrase !== confirm) { + return this.setRecoveryError("Passwords do not match.") + } + + try { + const mdk = await unwrapWithSecret(wrapped, recoveryCode) + const newRecoveryCode = generateRecoveryCode() + const wrappedDek = await wrapWithSecret(mdk, passphrase) + const wrappedRecovery = await wrapWithSecret(mdk, normalizeRecoveryCode(newRecoveryCode)) + + this.setValue("[data-vault-recovery-wrapped-dek-input]", JSON.stringify(wrappedDek)) + this.setValue( + "[data-vault-recovery-wrapped-dek-recovery-input]", + JSON.stringify(wrappedRecovery) + ) + + vaultSession.unlock(mdk) + + const output = this.el.querySelector("[data-vault-recovery-new-output]") + if (output) output.textContent = newRecoveryCode + this.el.querySelector("[data-vault-recovery-new-panel]")?.classList.remove("hidden") + this.el.querySelector("[data-vault-recovery]")?.classList.add("hidden") + } catch (_error) { + this.setRecoveryError("Recovery code is incorrect or cannot unlock this account.") + } + }, + + handleRecoverySubmit(event, form = event.currentTarget) { + if (form?.dataset.vaultRecoverySubmitting === "true") return + + event.preventDefault() + this.recover() + }, + + finishRecovery() { + const form = this.el.querySelector("[data-vault-recovery-form]") + if (form) { + form.dataset.vaultRecoverySubmitting = "true" + form.requestSubmit() + } + }, + + finishSetup() { + const form = this.el.querySelector("[data-vault-setup-form]") + if (form) { + form.dataset.vaultSetupSubmitting = "true" + form.requestSubmit() + } + }, + + async unlock() { + this.setError("") + const passphrase = this.value("[data-vault-unlock-input]") + const wrapped = this.wrappedDek() + if (!wrapped) return this.setError("No encrypted data key found.") + + try { + const mdk = await unwrapWithSecret(wrapped, passphrase) + vaultSession.unlock(mdk) + this.setValue("[data-vault-unlock-input]", "") + } catch (_error) { + this.setError("Incorrect password.") + } + }, + + accountPasswordMode() { + return this.el.dataset.vaultSecretMode === "account_password" + }, + + wrappedDek() { + const raw = this.el.dataset.vaultWrappedDek + if (!raw) return null + try { + return JSON.parse(raw) + } catch (_error) { + return null + } + }, + + wrappedRecoveryDek() { + const raw = this.el.dataset.vaultWrappedDekRecovery + if (!raw) return null + try { + return JSON.parse(raw) + } catch (_error) { + return null + } + }, + + value(selector) { + const el = this.el.querySelector(selector) + return el ? el.value : "" + }, + + setValue(selector, value) { + const el = this.el.querySelector(selector) + if (el) el.value = value + } +} diff --git a/apps/elektrine/assets/js/hooks/vault_session.js b/apps/elektrine/assets/js/hooks/vault_session.js new file mode 100644 index 0000000..409e9f7 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/vault_session.js @@ -0,0 +1,100 @@ +/** + * Vault session - the unlocked encrypted data key for this browser tab. + * + * Holds the Master Data Key (MDK) for this browser tab/session, so unlocking + * once unlocks Nerve, Kairo, and email private storage together even when + * navigation crosses LiveView/app boundaries. The MDK is stored in + * sessionStorage, not localStorage: it clears on tab close and explicit lock. + * Per-feature keys are derived lazily and cached in memory. + */ + +import { base64ToBytes, bytesToBase64, deriveFeatureKey } from "./vault_crypto" + +const SESSION_KEY = "elektrine:vault-session:mdk" + +let mdk = readStoredMdk() +const featureKeys = new Map() +const listeners = new Set() + +function storage() { + try { + return typeof window !== "undefined" ? window.sessionStorage : null + } catch (_error) { + return null + } +} + +function readStoredMdk() { + try { + const value = storage()?.getItem(SESSION_KEY) + if (!value) return null + + const bytes = base64ToBytes(value) + return bytes.length === 32 ? bytes : null + } catch (_error) { + clearStoredMdk() + return null + } +} + +function storeMdk(mdkBytes) { + try { + storage()?.setItem(SESSION_KEY, bytesToBase64(mdkBytes)) + } catch (_error) { + // Session persistence is best-effort; memory unlock still works. + } +} + +function clearStoredMdk() { + try { + storage()?.removeItem(SESSION_KEY) + } catch (_error) { + // Ignore storage failures. + } +} + +function notify() { + const unlocked = isUnlocked() + listeners.forEach((fn) => { + try { + fn(unlocked) + } catch (_error) { + // a listener throwing must not break the others + } + }) +} + +export function isUnlocked() { + return mdk !== null +} + +/** Store the unlocked MDK (a Uint8Array) for the tab and notify subscribers. */ +export function unlock(mdkBytes) { + mdk = mdkBytes + storeMdk(mdkBytes) + featureKeys.clear() + notify() +} + +/** Forget the MDK and all derived keys. */ +export function lock() { + mdk = null + clearStoredMdk() + featureKeys.clear() + notify() +} + +/** AES-GCM key for a feature ("nerve" | "kairo" | "email"). Throws if locked. */ +export async function featureKey(feature) { + if (!mdk) throw new Error("vault-locked") + if (!featureKeys.has(feature)) { + featureKeys.set(feature, await deriveFeatureKey(mdk, feature)) + } + return featureKeys.get(feature) +} + +/** Subscribe to lock/unlock changes; returns an unsubscribe function. */ +export function subscribe(fn) { + listeners.add(fn) + return () => listeners.delete(fn) +} diff --git a/apps/elektrine/assets/js/hooks/voice_channel_hook.js b/apps/elektrine/assets/js/hooks/voice_channel_hook.js new file mode 100644 index 0000000..dd3a072 --- /dev/null +++ b/apps/elektrine/assets/js/hooks/voice_channel_hook.js @@ -0,0 +1,403 @@ +/** + * LiveView hook for community voice channels (full-mesh WebRTC audio). + * + * The LiveView pushes "voice_join" / "voice_leave" / "voice_toggle_mute" + * events; this hook owns the media session: one Phoenix channel on + * `voice:`, one RTCPeerConnection per remote occupant, and + * one hidden
  • ${renderInlineMarkdown(line.slice(2))}
  • `); + continue; + } + + const orderedPrefixLength = orderedListPrefixLength(line); + + if (orderedPrefixLength !== null) { + listItems.push(`
  • ${renderInlineMarkdown(line.slice(orderedPrefixLength))}
  • `); + continue; + } + + flushList(); + blocks.push(`

    ${renderInlineMarkdown(line)}

    `); + } + + if (inCodeBlock) { + flushCodeBlock(); + } + + flushList(); + + return blocks.join('\n'); +} + +function headingLevelForLine(line) { + if (line.startsWith('### ')) return 3; + if (line.startsWith('## ')) return 2; + if (line.startsWith('# ')) return 1; + return null; +} + +function headingTag(level, content) { + if (level === 1) return `

    ${content}

    `; + if (level === 2) return `

    ${content}

    `; + return `

    ${content}

    `; +} + +function orderedListPrefixLength(line) { + let index = 0; + + while (index < line.length && isDigit(line[index])) { + index += 1; + } + + if (index === 0) return null; + if (line[index] !== '.' || line[index + 1] !== ' ') return null; + + return index + 2; +} + +function isDigit(character) { + return character >= '0' && character <= '9'; +} + +function renderInlineMarkdown(text) { + let output = ''; + let index = 0; + + while (index < text.length) { + if (text.startsWith('**', index)) { + const end = text.indexOf('**', index + 2); + + if (end !== -1) { + const content = renderInlineMarkdown(text.slice(index + 2, end)); + output += `${content}`; + index = end + 2; + continue; + } + } + + if (text[index] === '*') { + const end = text.indexOf('*', index + 1); + + if (end !== -1) { + const content = renderInlineMarkdown(text.slice(index + 1, end)); + output += `${content}`; + index = end + 1; + continue; + } + } + + if (text[index] === '`') { + const end = text.indexOf('`', index + 1); + + if (end !== -1) { + const content = escapeHtml(text.slice(index + 1, end)); + output += `${content}`; + index = end + 1; + continue; + } + } + + if (text[index] === '[') { + const closeLabel = text.indexOf(']', index + 1); + + if (closeLabel !== -1 && text[closeLabel + 1] === '(') { + const closeUrl = text.indexOf(')', closeLabel + 2); + + if (closeUrl !== -1) { + const label = text.slice(index + 1, closeLabel); + const href = text.slice(closeLabel + 2, closeUrl).trim(); + + if (isSafeHref(href)) { + output += `${renderInlineMarkdown(label)}`; + } else { + output += escapeHtml(text.slice(index, closeUrl + 1)); + } + + index = closeUrl + 1; + continue; + } + } + } + + output += escapeHtml(text[index]); + index += 1; + } + + return output; +} + +function isSafeHref(href) { + return ( + href.startsWith('http://') || + href.startsWith('https://') || + href.startsWith('mailto:') || + (href.startsWith('/') && !href.startsWith('//')) + ); +} + +function escapeHtml(text) { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/apps/elektrine/assets/js/markdown_helpers.js b/apps/elektrine/assets/js/markdown_helpers.js new file mode 100644 index 0000000..2c84f24 --- /dev/null +++ b/apps/elektrine/assets/js/markdown_helpers.js @@ -0,0 +1,178 @@ +// Markdown helper functions + +export function insertMarkdownFormat(format, textarea) { + const start = textarea.selectionStart + const end = textarea.selectionEnd + const selectedText = textarea.value.substring(start, end) + let replacement = '' + + switch (format) { + case 'bold': + replacement = `**${selectedText || 'bold text'}**` + break + case 'italic': + replacement = `*${selectedText || 'italic text'}*` + break + case 'underline': + replacement = `${selectedText || 'underlined text'}` + break + case 'heading': + replacement = `# ${selectedText || 'Heading'}` + break + case 'link': + const url = selectedText.startsWith('http') ? selectedText : 'https://example.com' + const linkText = selectedText.startsWith('http') ? 'link text' : selectedText || 'link text' + replacement = `[${linkText}](${url})` + break + case 'list': + replacement = `- ${selectedText || 'list item'}` + break + case 'quote': + replacement = `> ${selectedText || 'quoted text'}` + break + } + + textarea.value = textarea.value.substring(0, start) + replacement + textarea.value.substring(end) + textarea.focus() + + // Set cursor position + const newPos = start + replacement.length + textarea.setSelectionRange(newPos, newPos) +} + +export function toggleMarkdownPreview(textarea, targetId) { + let previewPanelId = 'preview-panel' + let previewContentId = 'preview-content' + + // Use reply preview for reply/forward mode + if (targetId === 'new-message-area') { + previewPanelId = 'reply-preview-panel' + previewContentId = 'reply-preview-content' + } + + const previewPanel = document.getElementById(previewPanelId) + const previewContent = document.getElementById(previewContentId) + + if (!previewPanel || !previewContent) return + + if (previewPanel.classList.contains('hidden')) { + // Show preview + const markdown = textarea.value + const html = markdownToHtml(markdown) + previewContent.innerHTML = html + previewPanel.classList.remove('hidden') + } else { + // Hide preview + previewPanel.classList.add('hidden') + } +} + +export function sanitizeMarkdownHref(href) { + if (typeof href !== 'string') return null + + const trimmedHref = href.trim() + if (trimmedHref === '') return null + + if (trimmedHref.startsWith('#')) { + return trimmedHref + } + + if (trimmedHref.startsWith('/')) { + return trimmedHref.startsWith('//') ? null : trimmedHref + } + + // Normalize away ASCII whitespace/control characters for scheme checks. + const normalizedHref = trimmedHref.replace(/[\u0000-\u001F\u007F\s]+/g, '') + const schemeMatch = normalizedHref.match(/^([a-zA-Z][a-zA-Z\d+.-]*):/) + + if (!schemeMatch) return null + + const scheme = schemeMatch[1].toLowerCase() + if (scheme === 'http' || scheme === 'https' || scheme === 'mailto') { + return trimmedHref + } + + return null +} + +export function markdownToHtml(markdown) { + if (!markdown) return '' + + const lines = markdown.split('\n') + const htmlLines = [] + let inCodeBlock = false + let codeBlockLines = [] + + for (const line of lines) { + if (line.startsWith('```')) { + if (inCodeBlock) { + htmlLines.push(`
    ${codeBlockLines.join('\n')}
    `) + codeBlockLines = [] + inCodeBlock = false + } else { + inCodeBlock = true + } + continue + } + + if (inCodeBlock) { + codeBlockLines.push(escapeHtml(line)) + continue + } + + let processedLine = '' + + if (line.startsWith('### ')) { + processedLine = `

    ${escapeHtml(line.substring(4))}

    ` + } else if (line.startsWith('## ')) { + processedLine = `

    ${escapeHtml(line.substring(3))}

    ` + } else if (line.startsWith('# ')) { + processedLine = `

    ${escapeHtml(line.substring(2))}

    ` + } else if (line.startsWith('> ')) { + processedLine = `
    ${processMarkdownInline(line.substring(2))}
    ` + } else if (line.startsWith('- ') || line.startsWith('* ')) { + processedLine = `
  • ${processMarkdownInline(line.substring(2))}
  • ` + } else if (/^\d+\. /.test(line)) { + processedLine = `
  • ${processMarkdownInline(line.replace(/^\d+\. /, ''))}
  • ` + } else if (line.trim() !== '') { + processedLine = `

    ${processMarkdownInline(line)}

    ` + } else { + processedLine = '
    ' + } + + htmlLines.push(processedLine) + } + + if (inCodeBlock && codeBlockLines.length > 0) { + htmlLines.push(`
    ${codeBlockLines.join('\n')}
    `) + } + + return htmlLines.join('\n').replace(/(
  • .*<\/li>\n?)+/g, (match) => { + return `
      ${match}
    ` + }) +} + +function processMarkdownInline(text) { + return escapeHtml(text) + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\[([^\]]+)\]\(([^\)]+)\)/g, (match, label, href) => { + const safeHref = sanitizeMarkdownHref(href) + + if (!safeHref) { + return match + } + + return `${label}` + }) +} + +function escapeHtml(text) { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} diff --git a/apps/elektrine/assets/js/markdown_toolbar.js b/apps/elektrine/assets/js/markdown_toolbar.js new file mode 100644 index 0000000..4ec1974 --- /dev/null +++ b/apps/elektrine/assets/js/markdown_toolbar.js @@ -0,0 +1,281 @@ +import { sanitizeMarkdownHref } from "./markdown_helpers"; + +// Markdown toolbar module for email compose +export function initMarkdownToolbar() { + const syncSplitPreview = (event) => { + const textarea = event.target.closest('#html-editor') + if (!textarea) return + + const previewTab = document.querySelector('[data-editor-tab].tab-active') + if (!previewTab || previewTab.dataset.editorTab !== 'split') return + + const previewContent = document.getElementById('preview-content') + if (previewContent) { + updatePreview(textarea.value, previewContent) + } + } + + document.addEventListener('input', syncSplitPreview) + + // Handle tab switching + document.addEventListener('click', (e) => { + const tab = e.target.closest('[data-editor-tab]'); + if (tab) { + e.preventDefault(); + const mode = tab.dataset.editorTab; + + // Update tab states + document.querySelectorAll('[data-editor-tab]').forEach(t => { + t.classList.remove('tab-active'); + }); + tab.classList.add('tab-active'); + + const container = document.getElementById('editor-container'); + const writeDiv = document.getElementById('editor-write'); + const previewDiv = document.getElementById('editor-preview'); + const toolbar = document.getElementById('formatting-toolbar'); + const textarea = document.getElementById('html-editor'); + const previewContent = document.getElementById('preview-content'); + + // Reset container classes + container.classList.remove('flex', 'gap-2'); + writeDiv.classList.remove('hidden', 'w-1/2'); + previewDiv.classList.remove('hidden', 'w-1/2'); + + if (mode === 'write') { + writeDiv.classList.remove('hidden'); + previewDiv.classList.add('hidden'); + toolbar.classList.remove('hidden'); + } else if (mode === 'preview') { + writeDiv.classList.add('hidden'); + previewDiv.classList.remove('hidden'); + toolbar.classList.add('hidden'); + + // Update preview + if (previewContent && textarea) { + updatePreview(textarea.value, previewContent); + } + } else if (mode === 'split') { + // Split view + container.classList.add('flex', 'gap-2'); + writeDiv.classList.remove('hidden'); + writeDiv.classList.add('w-1/2'); + previewDiv.classList.remove('hidden'); + previewDiv.classList.add('w-1/2'); + toolbar.classList.remove('hidden'); + + // Update preview + if (previewContent && textarea) { + updatePreview(textarea.value, previewContent); + } + } + } + }); + + // Handle markdown format buttons + document.addEventListener('click', (e) => { + const formatBtn = e.target.closest('[data-markdown-format]'); + if (!formatBtn) return; + + e.preventDefault(); + const format = formatBtn.dataset.markdownFormat; + const targetId = formatBtn.dataset.target || 'html-editor'; + const textarea = document.getElementById(targetId); + + if (!textarea) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const selectedText = textarea.value.substring(start, end); + const beforeText = textarea.value.substring(0, start); + const afterText = textarea.value.substring(end); + + let newText = ''; + let cursorOffset = 0; + + switch(format) { + case 'bold': + newText = selectedText ? `**${selectedText}**` : '**bold text**'; + cursorOffset = selectedText ? 2 + selectedText.length : 2; + break; + + case 'italic': + newText = selectedText ? `*${selectedText}*` : '*italic text*'; + cursorOffset = selectedText ? 1 + selectedText.length : 1; + break; + + case 'code': + newText = selectedText ? `\`${selectedText}\`` : '`code`'; + cursorOffset = selectedText ? 1 + selectedText.length : 1; + break; + + case 'code-block': + newText = selectedText ? `\`\`\`\n${selectedText}\n\`\`\`` : '```\ncode block\n```'; + cursorOffset = selectedText ? 4 : 4; + break; + + case 'link': + newText = selectedText ? `[${selectedText}](url)` : '[link text](url)'; + cursorOffset = selectedText ? 3 + selectedText.length : 11; + break; + + case 'list-bullet': + const lines = selectedText ? selectedText.split('\n') : ['']; + newText = lines.map(line => `- ${line}`).join('\n'); + cursorOffset = 2; + break; + + case 'list-number': + const numberedLines = selectedText ? selectedText.split('\n') : ['']; + newText = numberedLines.map((line, i) => `${i + 1}. ${line}`).join('\n'); + cursorOffset = 3; + break; + + case 'quote': + const quoteLines = selectedText ? selectedText.split('\n') : ['']; + newText = quoteLines.map(line => `> ${line}`).join('\n'); + cursorOffset = 2; + break; + + case 'h1': + newText = selectedText ? `# ${selectedText}` : '# Heading 1'; + cursorOffset = selectedText ? 2 : 2; + break; + + case 'h2': + newText = selectedText ? `## ${selectedText}` : '## Heading 2'; + cursorOffset = selectedText ? 3 : 3; + break; + + case 'h3': + newText = selectedText ? `### ${selectedText}` : '### Heading 3'; + cursorOffset = selectedText ? 4 : 4; + break; + + case 'heading': + newText = selectedText ? `## ${selectedText}` : '## Heading'; + cursorOffset = selectedText ? 3 : 3; + break; + + default: + return; + } + + textarea.value = beforeText + newText + afterText; + textarea.selectionStart = start + cursorOffset; + textarea.selectionEnd = start + cursorOffset; + textarea.focus(); + + // Trigger input event for any listeners + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }); +} + +// Convert markdown to HTML for preview +function updatePreview(markdown, previewElement) { + if (!previewElement) { + return; + } + + if (!markdown || markdown.trim() === '') { + previewElement.innerHTML = '

    Nothing to preview yet...

    '; + return; + } + + // Process markdown line by line for better handling + const lines = markdown.split('\n'); + const htmlLines = []; + let inCodeBlock = false; + let codeBlockLines = []; + + for (let line of lines) { + // Handle code blocks + if (line.startsWith('```')) { + if (inCodeBlock) { + htmlLines.push(`
    ${codeBlockLines.join('\n')}
    `); + codeBlockLines = []; + inCodeBlock = false; + } else { + inCodeBlock = true; + } + continue; + } + + if (inCodeBlock) { + codeBlockLines.push(escapeHtml(line)); + continue; + } + + // Process individual line + let processedLine = line; + + // Headers (must be at line start) + if (line.startsWith('### ')) { + processedLine = `

    ${escapeHtml(line.substring(4))}

    `; + } else if (line.startsWith('## ')) { + processedLine = `

    ${escapeHtml(line.substring(3))}

    `; + } else if (line.startsWith('# ')) { + processedLine = `

    ${escapeHtml(line.substring(2))}

    `; + } + // Blockquotes + else if (line.startsWith('> ')) { + processedLine = `
    ${processMarkdownInline(line.substring(2))}
    `; + } + // Unordered lists + else if (line.startsWith('- ') || line.startsWith('* ')) { + processedLine = `
  • ${processMarkdownInline(line.substring(2))}
  • `; + } + // Ordered lists + else if (/^\d+\. /.test(line)) { + processedLine = `
  • ${processMarkdownInline(line.replace(/^\d+\. /, ''))}
  • `; + } + // Regular paragraph + else if (line.trim() !== '') { + processedLine = `

    ${processMarkdownInline(line)}

    `; + } else { + processedLine = '
    '; + } + + htmlLines.push(processedLine); + } + + // Handle any unclosed code block + if (inCodeBlock && codeBlockLines.length > 0) { + htmlLines.push(`
    ${codeBlockLines.join('\n')}
    `); + } + + // Wrap consecutive list items in ul tags + const finalHtml = htmlLines.join('\n').replace(/(
  • .*<\/li>\n)+/g, (match) => { + return `
      ${match}
    `; + }); + + previewElement.innerHTML = finalHtml; +} + +// Process inline markdown (bold, italic, code, links) +function processMarkdownInline(text) { + return escapeHtml(text) + // Bold (must come before italic) + .replace(/\*\*([^*]+)\*\*/g, '$1') + // Italic + .replace(/\*([^*]+)\*/g, '$1') + // Inline code + .replace(/`([^`]+)`/g, '$1') + // Links + .replace(/\[([^\]]+)\]\(([^\)]+)\)/g, (match, label, href) => { + const safeHref = sanitizeMarkdownHref(href); + + if (!safeHref) { + return match; + } + + return `${label}`; + }); +} + +// Escape HTML to prevent XSS +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/apps/elektrine/assets/js/modal_controls.js b/apps/elektrine/assets/js/modal_controls.js new file mode 100644 index 0000000..b36603c --- /dev/null +++ b/apps/elektrine/assets/js/modal_controls.js @@ -0,0 +1,17 @@ +import { submitFormPreservingEvents } from './utils/form_submission' + +// Modal controls module for handling modal interactions +export function initModalControls() { + // Note: Confirmation dialogs should be handled by Phoenix.JS or LiveView + // This is kept minimal for older clients only + document.addEventListener('click', (e) => { + const confirmBtn = e.target.closest('[data-confirm]:not([phx-click])'); + if (confirmBtn && confirmBtn.form) { + e.preventDefault(); + const message = confirmBtn.dataset.confirm; + if (confirm(message)) { + submitFormPreservingEvents(confirmBtn.form, confirmBtn); + } + } + }); +} diff --git a/apps/elektrine/assets/js/notification_system.js b/apps/elektrine/assets/js/notification_system.js new file mode 100644 index 0000000..ea73f1a --- /dev/null +++ b/apps/elektrine/assets/js/notification_system.js @@ -0,0 +1,523 @@ +// Global notification system for reliable notifications + +import { spinnerSvg } from './utils/spinner' + +function appendHtml(parent, html) { + const template = document.createElement('template') + template.innerHTML = html.trim() + parent.appendChild(template.content) +} + +function notificationToneClass(type) { + return { + success: 'app-flash--success', + info: 'app-flash--info', + warning: 'app-flash--warning', + error: 'app-flash--error', + loading: 'app-flash--loading' + }[type] || 'app-flash--info' +} + +function flashGroup() { + let group = document.getElementById('flash-group') + + if (!group) { + group = document.createElement('div') + group.id = 'flash-group' + group.className = 'fixed bottom-4 left-1/2 z-[1000] w-full max-w-sm -translate-x-1/2 px-4 sm:left-auto sm:right-4 sm:translate-x-0 sm:px-0 space-y-2 pointer-events-none' + document.body.appendChild(group) + } + + return group +} + +/** + * Clone the flash blueprint that `flash_group/1` renders. + * + * This is the whole point of the template: the markup of a notification is + * defined once, in `core_components.ex`, and this side only fills it in. The + * alternative — building the same structure with createElement — is what let + * the two drift into different icon sets. + * + * Returns null when no blueprint is on the page. Every layout renders + * `flash_group/1`, so that means something is wrong rather than that a toast + * should be invented from scratch here. + */ +function cloneFlash(type, durationMs) { + const template = document.getElementById('flash-template') + + if (!template) { + console.warn('notification: #flash-template is missing; is flash_group/1 rendered?') + return null + } + + const el = template.content.firstElementChild.cloneNode(true) + + // The blueprint is rendered as an :info flash. Re-tone it, and drop the id so + // clones cannot collide with the server-rendered flashes beside them. + el.removeAttribute('id') + el.classList.remove('app-flash--info') + el.classList.add(notificationToneClass(type)) + el.dataset.flashKind = type + el.dataset.flashAutoDismissMs = String(durationMs) + el.style.setProperty('--flash-auto-dismiss', `${durationMs}ms`) + + // `lv:clear-flash` belongs to a flash the server is holding. This toast has no + // server-side counterpart, and pushing it would clear an unrelated flash. + el.querySelector('[data-flash-dismiss]')?.removeAttribute('phx-click') + + return el +} + +/** + * Show a toast notification with various options + * @param {string} message - The notification message + * @param {string} type - Notification type: 'success', 'info', 'warning', 'error', 'loading' + * @param {string|Object} titleOrOptions - Title string or options object + * @param {Object} options - Additional options (if title is a string) + * - title: Custom title + * - duration: Auto-dismiss time in ms (default: 5000, use 0 or 'persistent' for no auto-dismiss) + * - persistent: If true, notification won't auto-dismiss + * - actions: Array of action buttons [{label, event, class}] + * - progress: Show progress bar (countdown) + * - undoEvent: Event name for undo action (adds Undo button) + * - undoData: Data to pass with undo event + */ +export function showNotification(message, type = 'info', titleOrOptions = null, options = {}) { + // Handle different parameter signatures + let config = { + title: null, + duration: 5000, + persistent: false, + actions: [], + progress: false, + undoEvent: null, + undoData: {}, + ...options + } + + if (typeof titleOrOptions === 'string') { + config.title = titleOrOptions + } else if (typeof titleOrOptions === 'object' && titleOrOptions !== null) { + config = { ...config, ...titleOrOptions } + } + + // Handle persistent option + if (config.persistent || config.duration === 'persistent') { + config.duration = 0 + } + + // The same solid heroicons the server-rendered flash uses, by the same route + // — a `ui-icon` span the Tailwind plugin fills with a mask. These used to be + // hand-written outline paths in an inline , which made a JS toast and a + // `put_flash` sitting next to each other visibly different weights. + // + // Written as whole class names on purpose: tailwind.config.mjs scans + // ./js/**/*.js, so it only generates the ones it can read here literally. + const icons = { + 'success': 'hero-check-circle-solid', + 'info': 'hero-check-circle-solid', + 'warning': 'hero-exclamation-triangle-solid', + 'error': 'hero-exclamation-circle-solid', + 'loading': null // Will use spinner instead + } + + const iconName = icons[type] + + // Default titles based on type + const defaultTitle = config.title || { + 'success': 'Success!', + 'info': 'Info', + 'warning': 'Warning', + 'error': 'Error!', + 'loading': 'Loading...' + }[type] || '' + + const allActions = [...config.actions] + + // Add undo button if undoEvent is specified + if (config.undoEvent) { + allActions.push({ + label: 'Undo', + event: config.undoEvent, + data: config.undoData, + class: 'btn-ghost btn-xs' + }) + } + + const notification = cloneFlash(type, config.duration || 0) + if (!notification) return null + + const closeBtn = notification.querySelector('[data-flash-dismiss]') + closeBtn.classList.add('notification-close-btn') + + const iconSlot = notification.querySelector('[data-flash-icon]') + if (type === 'loading') { + iconSlot.className = 'flash-message__icon h-5 w-5 flex-shrink-0' + appendHtml(iconSlot, spinnerSvg()) + } else { + iconSlot.className = `ui-icon ${iconName} h-5 w-5 flex-shrink-0` + } + + const titleEl = notification.querySelector('[data-flash-title]') + if (defaultTitle) { + titleEl.textContent = defaultTitle + } else { + titleEl.remove() + } + + const messageEl = notification.querySelector('[data-flash-message]') + messageEl.textContent = message + + // Actions have no counterpart on a server flash, so they are appended to the + // cloned body rather than being part of the blueprint. + if (allActions.length > 0) { + const actions = document.createElement('div') + actions.className = 'notification-actions flex gap-2 mt-2' + + allActions.forEach((action, idx) => { + const button = document.createElement('button') + button.className = `btn ${action.class || 'btn-ghost btn-xs'}` + button.dataset.actionIdx = String(idx) + button.textContent = action.label + actions.appendChild(button) + }) + + messageEl.parentNode.appendChild(actions) + } + + if (config.progress && config.duration > 0) { + const progress = document.createElement('div') + progress.className = 'notification-progress absolute bottom-0 left-0 h-1 bg-current opacity-30' + progress.style.width = '100%' + progress.style.transition = `width ${config.duration}ms linear` + notification.appendChild(progress) + } + + // Add to DOM + flashGroup().appendChild(notification) + + let autoRemoveTimer = null + + // Function to remove the notification + const removeNotification = () => { + if (autoRemoveTimer) { + clearTimeout(autoRemoveTimer) + } + notification.classList.add('is-dismissing') + setTimeout(() => { + if (notification.parentNode) { + notification.remove() + } + }, 260) + } + + // Add event listener to close button + closeBtn.addEventListener('click', () => { + removeNotification() + }) + + // Add event listeners to action buttons + if (allActions.length > 0) { + const actionBtns = notification.querySelectorAll('[data-action-idx]') + actionBtns.forEach(btn => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.dataset.actionIdx) + const action = allActions[idx] + if (action && action.event) { + // Dispatch custom event for LiveView to handle + window.dispatchEvent(new CustomEvent('phx:notification_action', { + detail: { event: action.event, data: action.data || {} } + })) + // Also push to LiveView if available + if (window.liveSocket) { + const mainEl = document.querySelector('[data-phx-main]') + if (mainEl && mainEl._liveView) { + mainEl._liveView.pushEvent(action.event, action.data || {}) + } + } + } + if (action.callback) { + action.callback() + } + // Dismiss notification after action + removeNotification() + }) + }) + } + + // Use the flash manager for consistent positioning and animation + // Start progress bar animation + if (config.progress && config.duration > 0) { + const progressBar = notification.querySelector('.notification-progress') + if (progressBar) { + // Trigger reflow before starting animation + progressBar.offsetWidth + requestAnimationFrame(() => { + progressBar.style.width = '0%' + }) + } + } + + // Auto-remove after duration (if not persistent) + if (config.duration > 0) { + autoRemoveTimer = setTimeout(() => { + removeNotification() + }, config.duration) + } + + // Click to dismiss (on the notification itself, not the close button or action buttons) + notification.addEventListener('click', (e) => { + // Don't dismiss if clicking the close button or action buttons + if (!e.target.closest('.notification-close-btn') && !e.target.closest('[data-action-idx]')) { + if (autoRemoveTimer) { + clearTimeout(autoRemoveTimer) + } + removeNotification() + } + }) + + // Return notification element and dismiss function for programmatic control + notification.dismiss = removeNotification + return notification +} + +/** + * Normalizes LiveView-style payloads into notification options and renders them. + * @param {Object} payload - Notification payload from push_event/window events + * @returns {HTMLElement|null} + */ +export function showNotificationFromPayload(payload = {}) { + const { + message, + type, + title, + duration, + persistent, + progress, + undoEvent, + undoData, + actions + } = payload + + if (!message) return null + + const options = {} + if (title) options.title = title + if (duration !== undefined) options.duration = duration + if (persistent) options.persistent = persistent + if (progress) options.progress = progress + if (undoEvent) options.undoEvent = undoEvent + if (undoData) options.undoData = undoData + if (actions && actions.length > 0) options.actions = actions + + return showNotification(message, type || 'info', options) +} + +/** + * Show a loading notification that can be updated or dismissed + * @param {string} message - Loading message + * @param {Object} options - Additional options + * @returns {Object} - Object with update() and dismiss() methods + */ +export function showLoadingNotification(message, options = {}) { + const notification = showNotification(message, 'loading', { + ...options, + persistent: true, + title: options.title || 'Loading...' + }) + + return { + element: notification, + update: (newMessage, newTitle = null) => { + const msgEl = notification.querySelector('.text-sm') + if (msgEl) msgEl.textContent = newMessage + if (newTitle) { + const titleEl = notification.querySelector('h3') + if (titleEl) titleEl.textContent = newTitle + } + }, + dismiss: () => notification.dismiss(), + success: (msg, title = 'Success!') => { + notification.dismiss() + showNotification(msg, 'success', { title }) + }, + error: (msg, title = 'Error!') => { + notification.dismiss() + showNotification(msg, 'error', { title }) + } + } +} + +/** + * Show a notification with an undo action + * @param {string} message - The notification message + * @param {string} undoEvent - Event name to trigger on undo + * @param {Object} undoData - Data to pass with undo event + * @param {Object} options - Additional options + */ +export function showUndoNotification(message, undoEvent, undoData = {}, options = {}) { + return showNotification(message, options.type || 'info', { + ...options, + duration: options.duration || 8000, // Longer duration for undo + undoEvent, + undoData + }) +} + +/** + * Show a confirmation notification with confirm/cancel actions + * @param {string} message - The notification message + * @param {Function} onConfirm - Callback on confirm + * @param {Function} onCancel - Callback on cancel + * @param {Object} options - Additional options + */ +export function showConfirmNotification(message, onConfirm, onCancel = null, options = {}) { + return showNotification(message, options.type || 'warning', { + title: options.title || 'Confirm', + persistent: true, + actions: [ + { + label: options.confirmLabel || 'Confirm', + class: 'btn-primary btn-xs', + callback: onConfirm + }, + { + label: options.cancelLabel || 'Cancel', + class: 'btn-ghost btn-xs', + callback: onCancel || (() => {}) + } + ] + }) +} + +// Global function to show keyboard shortcuts (called from buttons) +export function showKeyboardShortcuts() { + + // Check if modal already exists to prevent duplicates + if (document.getElementById('keyboard-shortcuts-modal')) { + return + } + + const modal = document.createElement('div') + modal.id = 'keyboard-shortcuts-modal' + modal.className = 'shortcut-overlay fixed inset-0 flex items-center justify-center z-50' + + // Create modal content container + const modalContent = document.createElement('div') + modalContent.className = 'shortcut-dialog rounded-lg p-6 max-w-2xl w-full mx-4 max-h-96 overflow-y-auto' + modalContent.innerHTML = ` +
    +

    Keyboard Shortcuts

    + +
    + +
    +
    +

    Navigation

    +
    +
    + c + Compose +
    +
    + g + Go to menu +
    +
    + / + Search +
    +
    + j + Next message +
    +
    + k + Previous message +
    +
    + Enter + Open message +
    +
    +
    + +
    +

    Actions

    +
    +
    + e + Archive +
    +
    + r + Reply +
    +
    + f + Forward +
    +
    + # + Delete +
    +
    + ! + Mark as spam +
    +
    + Shift + / + Show this help (?) +
    +
    +
    +
    + +
    +

    Go to shortcuts (press 'g' then):

    +
    +
    i Inbox
    +
    s Sent
    +
    t Search
    +
    a Archive
    +
    p Spam
    +
    +
    + ` + + modal.appendChild(modalContent) + + // Function to close modal + const closeModal = () => { + modal.remove() + } + + // Close modal when clicking outside the content area + modal.addEventListener('click', (e) => { + if (!modalContent.contains(e.target)) { + closeModal() + } + }) + + // Add event listener to close button + const closeBtn = modalContent.querySelector('.modal-close-btn') + if (closeBtn) { + closeBtn.addEventListener('click', closeModal) + } + + // Close on Escape key + const escapeHandler = (e) => { + if (e.key === 'Escape') { + closeModal() + document.removeEventListener('keydown', escapeHandler) + } + } + document.addEventListener('keydown', escapeHandler) + + document.body.appendChild(modal) + + return modal +} diff --git a/apps/elektrine/assets/js/profile_static.js b/apps/elektrine/assets/js/profile_static.js new file mode 100644 index 0000000..868d399 --- /dev/null +++ b/apps/elektrine/assets/js/profile_static.js @@ -0,0 +1,644 @@ +/** + * Profile Static Page Handler + * + * This module handles interactive functionality for static (non-LiveView) profile pages. + * Used when profiles are accessed via subdomains (e.g., username.example.com) where LiveView + * websockets don't work properly due to cross-origin restrictions. + * + * Features: + * - Followers/Following modals with async data fetching + * - Follow/Unfollow functionality + * - Friend request actions (send, accept, cancel, unfriend) + * - Share modal with clipboard copy + * - Timeline drawer toggle + * - Profile hooks initialization (typewriter, video background) + */ + +import { TypewriterHook, TabTitleTypewriter, VideoBackground } from "./hooks/profile_hooks" + +// ============================================================================= +// Constants +// ============================================================================= + +const SELECTORS = { + container: "#profile-container", + csrfToken: 'meta[name="csrf-token"]', + + // Modals (outside container, at document level) + followersModal: '[data-modal="followers"]', + followingModal: '[data-modal="following"]', + shareModal: '[data-modal="share"]', + widgetImageModal: '[data-modal="widget-image"]', + + // Modal content + followersList: '[data-role="followers-list"]', + followersEmpty: '[data-role="followers-empty"]', + followingList: '[data-role="following-list"]', + followingEmpty: '[data-role="following-empty"]', + widgetImageContent: '[data-role="widget-image-content"]', + + // Buttons (inside container) + showFollowers: '[data-action="show-followers"]', + showFollowing: '[data-action="show-following"]', + showShareModal: '[data-action="show-share-modal"]', + toggleFollow: '[data-action="toggle-follow"]', + toggleTimelineDrawer: '[data-action="toggle-timeline-drawer"]', + openWidgetImage: '[data-action="open-widget-image"]', + + // Buttons (outside container, in modals) + closeModal: '[data-action="close-modal"]', + copyProfileUrl: '[data-action="copy-profile-url"]', + profileShareUrl: "#profile-share-url", + + // Timeline drawer + timelineDrawer: '[data-role="timeline-drawer"]' +} + +const ICONS = { + checkmark: '', + user: '' +} + +const profileContainer = document.querySelector(SELECTORS.container) +const CONFIGURED_MAIN_DOMAIN = profileContainer?.dataset.mainDomain || "" +const LOCAL_HANDLE_DOMAIN = + profileContainer?.dataset.localDomain || CONFIGURED_MAIN_DOMAIN || window.location.hostname + +// Main domain for absolute URLs (handles subdomains like username.example.com -> example.com) +const MAIN_DOMAIN = getMainDomain() + +const FRIEND_ACTIONS = [ + { selector: '[data-action="send-friend-request"]', method: "POST", endpoint: "friend-request" }, + { selector: '[data-action="accept-friend-request"]', method: "POST", endpoint: "friend-request/accept" }, + { selector: '[data-action="cancel-friend-request"]', method: "DELETE", endpoint: "friend-request" }, + { selector: '[data-action="unfriend"]', method: "DELETE", endpoint: "friend" } +] + +const COPY_FEEDBACK_DURATION = 2000 + +// ============================================================================= +// Utility Functions +// ============================================================================= + +/** + * Get the main domain URL for absolute links. + * Converts subdomain URLs (e.g., username.example.com) to main domain (example.com). + * This ensures links in modals navigate to the main site, not the subdomain. + */ +function getMainDomain() { + const host = window.location.host.toLowerCase() + const protocol = window.location.protocol + + const hostname = host.split(":")[0] + + if (CONFIGURED_MAIN_DOMAIN) { + const onConfiguredDomain = + hostname === CONFIGURED_MAIN_DOMAIN || + hostname === `www.${CONFIGURED_MAIN_DOMAIN}` || + hostname.endsWith(`.${CONFIGURED_MAIN_DOMAIN}`) + + if (onConfiguredDomain) { + return `${protocol}//${CONFIGURED_MAIN_DOMAIN}` + } + } + + // Best effort fallback for unexpected domains: assume registrable domain is last 2 labels. + const parts = hostname.split(".") + if (parts.length > 2) { + return `${protocol}//${parts.slice(-2).join(".")}` + } + + // On main domain or localhost, use current origin + return window.location.origin +} + +/** + * Convert hex color to RGB object + */ +function hexToRgb(hex) { + if (!hex || !hex.startsWith("#") || hex.length < 7) { + return { r: 0, g: 0, b: 0 } + } + return { + r: parseInt(hex.slice(1, 3), 16) || 0, + g: parseInt(hex.slice(3, 5), 16) || 0, + b: parseInt(hex.slice(5, 7), 16) || 0 + } +} + +/** + * Determine if a color is light (for contrast calculations) + */ +function isLightColor(hex) { + const { r, g, b } = hexToRgb(hex) + const luminance = 0.2126 * (r / 255) + 0.7152 * (g / 255) + 0.0722 * (b / 255) + return luminance > 0.5 +} + +/** + * Get CSRF token from meta tag + */ +function getCsrfToken() { + return document.querySelector(SELECTORS.csrfToken)?.getAttribute("content") || "" +} + +// ============================================================================= +// Hook Initialization (for non-LiveView pages) +// ============================================================================= + +/** + * Create a hook instance that mimics LiveView hook behavior + */ +function createHookInstance(hook, el) { + const instance = { el } + Object.keys(hook).forEach((key) => { + instance[key] = typeof hook[key] === 'function' ? hook[key].bind(instance) : hook[key] + }) + return instance +} + +/** + * Initialize a hook on all matching elements + */ +function initHook(hook, selector) { + document.querySelectorAll(selector).forEach((el) => { + const instance = createHookInstance(hook, el) + if (instance.mounted) instance.mounted() + }) +} + +/** + * Initialize all profile-related hooks + */ +function initProfileHooks() { + initHook(TypewriterHook, '[phx-hook="TypewriterHook"]') + initHook(TabTitleTypewriter, '[phx-hook="TabTitleTypewriter"]') + initHook(VideoBackground, '[phx-hook="VideoBackground"]') +} + +// ============================================================================= +// API Functions +// ============================================================================= + +/** + * Fetch JSON from API with proper headers and error handling + */ +async function fetchJson(url, options = {}) { + const headers = { + "Accept": "application/json", + ...options.headers + } + + const response = await fetch(url, { ...options, headers }) + + // Redirect to login if unauthorized + if (response.status === 401) { + window.location.href = `${MAIN_DOMAIN}/login` + return null + } + + const data = await response.json().catch(() => ({})) + + if (!response.ok) { + throw new Error(data.error || "Request failed") + } + + return data +} + +/** + * Make an authenticated API request + */ +async function apiRequest(url, method = "GET") { + return fetchJson(url, { + method, + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": getCsrfToken() + } + }) +} + +// ============================================================================= +// Modal Functions +// ============================================================================= + +function openModal(modal) { + if (modal) modal.classList.add("modal-open") +} + +function closeModal(modal) { + if (modal) modal.classList.remove("modal-open") +} + +// ============================================================================= +// UI Component Builders +// ============================================================================= + +/** + * Build an avatar element for a user entry + */ +function buildAvatar(entry) { + const avatar = document.createElement("div") + + if (entry.avatar_url) { + avatar.className = "w-10 h-10 rounded-full overflow-hidden bg-base-300 flex items-center justify-center" + const img = document.createElement("img") + img.src = entry.avatar_url + img.alt = entry.display_name || entry.username + img.className = "w-full h-full object-cover" + avatar.appendChild(img) + } else { + // Match the placeholder_avatar component: purple gradient with hero-user icon + avatar.className = "w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0" + avatar.style.background = "linear-gradient(to bottom right, #9333ea, #6b21a8)" + const icon = document.createElement("div") + icon.className = "w-6 h-6 text-white" + icon.innerHTML = ICONS.user + avatar.appendChild(icon) + } + + return avatar +} + +/** + * Build a user row element for followers/following lists. + * Uses absolute URLs to ensure links go to the main domain, not subdomains. + */ +function buildUserRow(entry) { + const row = document.createElement("a") + row.className = "flex items-center gap-3 p-2 hover:bg-base-200 rounded-lg" + + const isRemote = entry.type === "remote" + const displayHandle = isRemote + ? `@${entry.username}@${entry.domain}` + : `@${entry.handle || entry.username}@${LOCAL_HANDLE_DOMAIN}` + + // Use absolute URLs to main domain so links work correctly from subdomains + row.href = isRemote + ? `${MAIN_DOMAIN}/remote/${entry.username}@${entry.domain}` + : `${MAIN_DOMAIN}/${entry.handle || entry.username}` + + row.appendChild(buildAvatar(entry)) + + const text = document.createElement("div") + text.className = "flex-1 min-w-0" + + const name = document.createElement("p") + name.className = "font-medium truncate" + name.textContent = entry.display_name || entry.username + + const handle = document.createElement("p") + handle.className = "text-xs opacity-70 truncate" + handle.textContent = displayHandle + + text.appendChild(name) + text.appendChild(handle) + row.appendChild(text) + + return row +} + +/** + * Render a list of followers/following into a container + */ +function renderFollowList(listEl, emptyEl, entries) { + if (!listEl || !emptyEl) return + + listEl.innerHTML = "" + emptyEl.classList.add("hidden") + + if (!entries || entries.length === 0) { + emptyEl.classList.remove("hidden") + return + } + + const wrapper = document.createElement("div") + wrapper.className = "space-y-2" + + entries.forEach((entry) => { + wrapper.appendChild(buildUserRow(entry)) + }) + + listEl.appendChild(wrapper) +} + +// ============================================================================= +// Button State Management +// ============================================================================= + +/** + * Update follow button appearance based on follow state + */ +function updateFollowButton(button, isFollowing, accentColor) { + if (!button) return + + const textColor = isLightColor(accentColor) ? "#000000" : "#ffffff" + button.dataset.following = isFollowing ? "true" : "false" + button.textContent = isFollowing ? "Following" : "Follow" + + if (isFollowing) { + button.classList.add("btn-outline") + button.style.borderColor = accentColor + button.style.color = accentColor + button.style.backgroundColor = "" + } else { + button.classList.remove("btn-outline") + button.style.backgroundColor = accentColor + button.style.color = textColor + button.style.borderColor = "" + } +} + +/** + * Show success feedback on copy button + */ +function showCopySuccess(button, originalHTML) { + button.innerHTML = ICONS.checkmark + button.classList.add("btn-success") + button.classList.remove("btn-primary") + + setTimeout(() => { + button.innerHTML = originalHTML + button.classList.remove("btn-success") + button.classList.add("btn-primary") + }, COPY_FEEDBACK_DURATION) +} + +// ============================================================================= +// Event Handlers +// ============================================================================= + +/** + * Set up timeline drawer toggle + */ +function setupTimelineDrawer(container) { + const drawer = container.querySelector(SELECTORS.timelineDrawer) + const toggle = container.querySelector(SELECTORS.toggleTimelineDrawer) + + if (!drawer || !toggle) return + + const closedClasses = (drawer.dataset.closedClass || "").split(" ").filter(Boolean) + const openClasses = (drawer.dataset.openClass || "").split(" ").filter(Boolean) + + toggle.addEventListener("click", (e) => { + e.preventDefault() + const isOpen = openClasses.some(cls => drawer.classList.contains(cls)) + + if (isOpen) { + openClasses.forEach(cls => drawer.classList.remove(cls)) + closedClasses.forEach(cls => drawer.classList.add(cls)) + } else { + closedClasses.forEach(cls => drawer.classList.remove(cls)) + openClasses.forEach(cls => drawer.classList.add(cls)) + } + }) +} + +/** + * Set up modal close buttons + */ +function setupModalCloseButtons() { + document.querySelectorAll(SELECTORS.closeModal).forEach((button) => { + button.addEventListener("click", () => { + closeModal(button.closest(".modal")) + }) + }) +} + +/** + * Set up widget image modal for viewing images in widgets + */ +function setupWidgetImageModal(container, modal) { + if (!modal) return + + const imageContent = modal.querySelector(SELECTORS.widgetImageContent) + if (!imageContent) return + + // Find all widget image triggers + container.querySelectorAll(SELECTORS.openWidgetImage).forEach((trigger) => { + trigger.addEventListener("click", (e) => { + e.preventDefault() + + const imageUrl = trigger.dataset.imageUrl + const imageAlt = trigger.dataset.imageAlt || "Widget image" + + if (imageUrl) { + imageContent.src = imageUrl + imageContent.alt = imageAlt + openModal(modal) + } + }) + }) + + // Close on Escape key + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && modal.classList.contains("modal-open")) { + closeModal(modal) + } + }) +} + +/** + * Set up followers modal + */ +function setupFollowersModal(container, modal, handle) { + const button = container.querySelector(SELECTORS.showFollowers) + if (!button || !modal) return + + button.addEventListener("click", async (e) => { + e.preventDefault() + openModal(modal) + + try { + const data = await fetchJson(`/profiles/${handle}/followers`) + if (!data) return + + renderFollowList( + modal.querySelector(SELECTORS.followersList), + modal.querySelector(SELECTORS.followersEmpty), + data.followers || [] + ) + } catch (error) { + console.error("Failed to load followers:", error) + } + }) +} + +/** + * Set up following modal + */ +function setupFollowingModal(container, modal, handle) { + const button = container.querySelector(SELECTORS.showFollowing) + if (!button || !modal) return + + button.addEventListener("click", async (e) => { + e.preventDefault() + openModal(modal) + + try { + const data = await fetchJson(`/profiles/${handle}/following`) + if (!data) return + + renderFollowList( + modal.querySelector(SELECTORS.followingList), + modal.querySelector(SELECTORS.followingEmpty), + data.following || [] + ) + } catch (error) { + console.error("Failed to load following:", error) + } + }) +} + +/** + * Set up share modal and copy functionality + */ +function setupShareModal(container, modal) { + const shareButton = container.querySelector(SELECTORS.showShareModal) + if (shareButton && modal) { + shareButton.addEventListener("click", (e) => { + e.preventDefault() + openModal(modal) + }) + } + + const copyButton = document.querySelector(SELECTORS.copyProfileUrl) + if (!copyButton) return + + const originalHTML = copyButton.innerHTML + + copyButton.addEventListener("click", async (e) => { + e.preventDefault() + const input = document.querySelector(SELECTORS.profileShareUrl) + const url = input?.value + if (!url) return + + try { + await navigator.clipboard.writeText(url) + showCopySuccess(copyButton, originalHTML) + } catch (_) { + // Fallback for older browsers + if (input) { + input.select() + document.execCommand("copy") + showCopySuccess(copyButton, originalHTML) + } + } + }) +} + +/** + * Set up follow/unfollow button + */ +function setupFollowButton(container, handle, accentColor) { + const button = container.querySelector(SELECTORS.toggleFollow) + if (!button) return + + const followersButton = container.querySelector(SELECTORS.showFollowers) + const currentUserId = container.dataset.currentUserId + const profileUserId = container.dataset.profileUserId + + // Set initial state + updateFollowButton(button, button.dataset.following === "true", accentColor) + + button.addEventListener("click", async (e) => { + e.preventDefault() + const isFollowing = button.dataset.following === "true" + const method = isFollowing ? "DELETE" : "POST" + + try { + const result = await apiRequest(`/profiles/${handle}/follow`, method) + if (!result) return + + const nextIsFollowing = + result.status === "followed" ? true : result.status === "unfollowed" ? false : !isFollowing + + updateFollowButton(button, nextIsFollowing, accentColor) + + // Update follower count display + const countEl = followersButton?.querySelector("span.font-bold") + if (countEl && currentUserId !== profileUserId) { + const count = parseInt(countEl.textContent, 10) + if (!Number.isNaN(count)) { + if (nextIsFollowing !== isFollowing) { + countEl.textContent = nextIsFollowing ? count + 1 : count - 1 + } + } + } + } catch (error) { + if (method === "POST" && /has already been taken/i.test(error.message)) { + updateFollowButton(button, true, accentColor) + return + } + + console.error("Follow action failed:", error) + } + }) +} + +/** + * Set up friend action buttons (add friend, accept, cancel, unfriend) + */ +function setupFriendActions(container, handle) { + FRIEND_ACTIONS.forEach(({ selector, method, endpoint }) => { + const button = container.querySelector(selector) + if (!button) return + + button.addEventListener("click", async (e) => { + e.preventDefault() + + try { + const result = await apiRequest(`/profiles/${handle}/${endpoint}`, method) + if (!result) return + window.location.reload() + } catch (error) { + console.error("Friend action failed:", error) + } + }) + }) +} + +// ============================================================================= +// Main Initialization +// ============================================================================= + +/** + * Initialize all static profile functionality + * + * This function is called on page load for static profile pages. + * It sets up all event handlers and initializes hooks that would + * normally be handled by LiveView. + */ +export function initProfileStatic() { + const container = document.querySelector(SELECTORS.container) + if (!container) return + + // Skip if this is a LiveView page or already initialized + if (container.dataset.profileStatic === "false") return + if (container.dataset.profileStaticInit === "true") return + container.dataset.profileStaticInit = "true" + + // Initialize profile hooks (typewriter effects, video background) + initProfileHooks() + + // Get profile data from container attributes + const handle = container.dataset.profileHandle + const accentColor = container.dataset.profileAccent || "#22d3ee" + + // Get modals (they're outside the container for z-index reasons) + const followersModal = document.querySelector(SELECTORS.followersModal) + const followingModal = document.querySelector(SELECTORS.followingModal) + const shareModal = document.querySelector(SELECTORS.shareModal) + const widgetImageModal = document.querySelector(SELECTORS.widgetImageModal) + + // Set up all interactive features + setupTimelineDrawer(container) + setupModalCloseButtons() + setupFollowersModal(container, followersModal, handle) + setupFollowingModal(container, followingModal, handle) + setupShareModal(container, shareModal) + setupWidgetImageModal(container, widgetImageModal) + setupFollowButton(container, handle, accentColor) + setupFriendActions(container, handle) +} diff --git a/apps/elektrine/assets/js/ringtone_player.js b/apps/elektrine/assets/js/ringtone_player.js new file mode 100644 index 0000000..fecd727 --- /dev/null +++ b/apps/elektrine/assets/js/ringtone_player.js @@ -0,0 +1,225 @@ +/** + * Ringtone Player for Skype-like call sounds + * Generates ringtones using Web Audio API + */ + +export class RingtonePlayer { + constructor() { + this.audioContext = null + this.oscillator = null + this.gainNode = null + this.isPlaying = false + this.ringtoneInterval = null + } + + /** + * Initialize Audio Context (must be called after user interaction) + */ + init() { + if (!this.audioContext) { + this.audioContext = new (window.AudioContext || window.webkitAudioContext)() + } + } + + /** + * Play outgoing call ringtone (Skype-like beep-beep pattern) + */ + playOutgoing() { + this.stop() // Stop any current ringtone + this.init() + this.isPlaying = true + + const playTone = () => { + if (!this.isPlaying) return + + // Create oscillator for the tone + this.oscillator = this.audioContext.createOscillator() + this.gainNode = this.audioContext.createGain() + + this.oscillator.connect(this.gainNode) + this.gainNode.connect(this.audioContext.destination) + + // Skype outgoing call tone: 440Hz + this.oscillator.frequency.value = 440 + this.oscillator.type = 'sine' + + // Volume + this.gainNode.gain.value = 0.2 + + // Play pattern: beep (400ms) - pause (200ms) - beep (400ms) - pause (3000ms) + const now = this.audioContext.currentTime + + // First beep + this.oscillator.start(now) + this.gainNode.gain.setValueAtTime(0.2, now) + this.gainNode.gain.exponentialRampToValueAtTime(0.01, now + 0.4) + + // Stop after pattern completes + this.oscillator.stop(now + 0.4) + + // Schedule second beep + setTimeout(() => { + if (!this.isPlaying) return + + const osc2 = this.audioContext.createOscillator() + const gain2 = this.audioContext.createGain() + + osc2.connect(gain2) + gain2.connect(this.audioContext.destination) + + osc2.frequency.value = 440 + osc2.type = 'sine' + + const now2 = this.audioContext.currentTime + osc2.start(now2) + gain2.gain.setValueAtTime(0.2, now2) + gain2.gain.exponentialRampToValueAtTime(0.01, now2 + 0.4) + osc2.stop(now2 + 0.4) + }, 600) + } + + // Play immediately then repeat every 4 seconds + playTone() + this.ringtoneInterval = setInterval(playTone, 4000) + } + + /** + * Play incoming call ringtone (Skype-like ring pattern) + */ + playIncoming() { + this.stop() // Stop any current ringtone + this.init() + this.isPlaying = true + + const playRing = () => { + if (!this.isPlaying) return + + // Create two-tone ring (like a phone ring) + const playDualTone = (startTime) => { + // High frequency + const osc1 = this.audioContext.createOscillator() + const gain1 = this.audioContext.createGain() + osc1.connect(gain1) + gain1.connect(this.audioContext.destination) + osc1.frequency.value = 480 + osc1.type = 'sine' + gain1.gain.value = 0.15 + + // Low frequency + const osc2 = this.audioContext.createOscillator() + const gain2 = this.audioContext.createGain() + osc2.connect(gain2) + gain2.connect(this.audioContext.destination) + osc2.frequency.value = 440 + osc2.type = 'sine' + gain2.gain.value = 0.15 + + osc1.start(startTime) + osc2.start(startTime) + osc1.stop(startTime + 1) + osc2.stop(startTime + 1) + } + + const now = this.audioContext.currentTime + + // Ring pattern: ring (1s) - pause (0.5s) - ring (1s) - pause (3s) + playDualTone(now) + setTimeout(() => { + if (this.isPlaying) { + playDualTone(this.audioContext.currentTime) + } + }, 1500) + } + + // Play immediately then repeat every 5.5 seconds + playRing() + this.ringtoneInterval = setInterval(playRing, 5500) + } + + /** + * Play call connected sound (single short beep) + */ + playConnected() { + this.init() + + const osc = this.audioContext.createOscillator() + const gain = this.audioContext.createGain() + + osc.connect(gain) + gain.connect(this.audioContext.destination) + + osc.frequency.value = 800 + osc.type = 'sine' + + const now = this.audioContext.currentTime + osc.start(now) + gain.gain.setValueAtTime(0.2, now) + gain.gain.exponentialRampToValueAtTime(0.01, now + 0.2) + osc.stop(now + 0.2) + } + + /** + * Play call ended sound (descending tone) + */ + playEnded() { + this.stop() + this.init() + + const osc = this.audioContext.createOscillator() + const gain = this.audioContext.createGain() + + osc.connect(gain) + gain.connect(this.audioContext.destination) + + osc.type = 'sine' + + const now = this.audioContext.currentTime + osc.frequency.setValueAtTime(600, now) + osc.frequency.exponentialRampToValueAtTime(300, now + 0.5) + + gain.gain.setValueAtTime(0.2, now) + gain.gain.exponentialRampToValueAtTime(0.01, now + 0.5) + + osc.start(now) + osc.stop(now + 0.5) + } + + /** + * Stop all ringtones + */ + stop() { + this.isPlaying = false + + if (this.ringtoneInterval) { + clearInterval(this.ringtoneInterval) + this.ringtoneInterval = null + } + + if (this.oscillator) { + try { + this.oscillator.stop() + } catch (e) { + // Already stopped + } + this.oscillator = null + } + + if (this.gainNode) { + this.gainNode = null + } + } + + /** + * Cleanup + */ + destroy() { + this.stop() + if (this.audioContext) { + this.audioContext.close() + this.audioContext = null + } + } +} + +// Create a global instance +export const globalRingtone = new RingtonePlayer() diff --git a/apps/elektrine/assets/js/tagline_cycler.js b/apps/elektrine/assets/js/tagline_cycler.js new file mode 100644 index 0000000..db333ce --- /dev/null +++ b/apps/elektrine/assets/js/tagline_cycler.js @@ -0,0 +1,24 @@ +// Cycles through taglines on the homepage +export function initTaglineCycler() { + const taglineElements = document.querySelectorAll('[data-taglines]') + + taglineElements.forEach(element => { + const taglines = JSON.parse(element.getAttribute('data-taglines')) + let currentIndex = 0 + + // Cycle through taglines every 4 seconds + setInterval(() => { + currentIndex = (currentIndex + 1) % taglines.length + + // Fade out + element.style.transition = 'opacity 0.3s ease-out' + element.style.opacity = '0' + + // Change text and fade in + setTimeout(() => { + element.textContent = taglines[currentIndex] + element.style.opacity = '1' + }, 300) + }, 4000) + }) +} diff --git a/apps/elektrine/assets/js/theme.js b/apps/elektrine/assets/js/theme.js new file mode 100644 index 0000000..c829580 --- /dev/null +++ b/apps/elektrine/assets/js/theme.js @@ -0,0 +1,171 @@ +const STORAGE_KEY = "elektrine:theme" +const VALID_THEMES = new Set(["light", "dark"]) +const VALID_MODES = new Set(["system", "light", "dark", "custom"]) +const THEME_OVERRIDE_PROPERTIES = [ + "--theme-override-color-primary", + "--theme-override-color-primary-content", + "--theme-override-color-secondary", + "--theme-override-color-secondary-content", + "--theme-override-color-accent", + "--theme-override-color-accent-content", + "--theme-override-color-base-100", + "--theme-override-color-base-200", + "--theme-override-color-base-300", + "--theme-override-color-base-content", + "--theme-override-color-info", + "--theme-override-color-info-content", + "--theme-override-color-success", + "--theme-override-color-success-content", + "--theme-override-color-warning", + "--theme-override-color-warning-content", + "--theme-override-color-error", + "--theme-override-color-error-content" +] + +function currentTheme() { + const theme = document.documentElement.dataset.theme + return VALID_THEMES.has(theme) ? theme : "light" +} + +// The server-stored mode for signed-in users; absent for anonymous visitors, +// whose light/dark choice lives in localStorage instead. +function themeMode() { + const mode = document.documentElement.dataset.themeMode + return VALID_MODES.has(mode) ? mode : null +} + +function storedTheme() { + try { + const theme = window.localStorage.getItem(STORAGE_KEY) + return VALID_THEMES.has(theme) ? theme : null + } catch (_error) { + return null + } +} + +function systemTheme() { + return window.matchMedia?.("(prefers-color-scheme: light)")?.matches ? "light" : "dark" +} + +function resolveTheme() { + const mode = themeMode() + if (mode === "light" || mode === "dark") return mode + if (mode === "custom") return currentTheme() + if (mode === "system") return systemTheme() + return storedTheme() || systemTheme() +} + +function syncThemeColor() { + const themeColor = document.querySelector('meta[name="theme-color"]') + if (!themeColor) return + + const baseColor = getComputedStyle(document.documentElement) + .getPropertyValue("--color-base-100") + .trim() + + if (baseColor) themeColor.setAttribute("content", baseColor) +} + +export function syncThemeControls() { + const theme = currentTheme() + const nextTheme = theme === "light" ? "dark" : "light" + const label = `Use ${nextTheme} theme` + + document.querySelectorAll("[data-theme-toggle]").forEach((button) => { + button.setAttribute("aria-label", label) + button.setAttribute("title", label) + }) + + window.requestAnimationFrame(syncThemeColor) +} + +function applyTheme(theme, persist = false) { + const resolvedTheme = VALID_THEMES.has(theme) ? theme : "light" + document.documentElement.dataset.theme = resolvedTheme + + if (persist) { + try { + window.localStorage.setItem(STORAGE_KEY, resolvedTheme) + } catch (_error) { + // The selected theme still applies for this page when storage is unavailable. + } + } + + syncThemeControls() + window.dispatchEvent( + new CustomEvent("elektrine:theme-changed", { detail: { theme: resolvedTheme } }) + ) +} + +function persistThemeMode(mode) { + const csrfToken = + document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || "" + + fetch("/api/preferences/theme", { + method: "PUT", + headers: { "content-type": "application/json", "x-csrf-token": csrfToken }, + body: JSON.stringify({ mode }) + }).catch(() => { + // The theme still applies for this page; the preference syncs on the next save. + }) +} + +function clearOverrideProperties() { + THEME_OVERRIDE_PROPERTIES.forEach((property) => { + document.documentElement.style.removeProperty(property) + }) +} + +function applyThemeSettings({ style = "", mode = "system", theme = null } = {}) { + const root = document.documentElement + const parsedStyle = document.createElement("div").style + parsedStyle.cssText = typeof style === "string" ? style : "" + + clearOverrideProperties() + + THEME_OVERRIDE_PROPERTIES.forEach((property) => { + const value = parsedStyle.getPropertyValue(property).trim() + if (value) root.style.setProperty(property, value) + }) + + root.dataset.themeMode = VALID_MODES.has(mode) ? mode : "system" + applyTheme(VALID_THEMES.has(theme) ? theme : resolveTheme()) +} + +let initialized = false + +export function initThemeToggle() { + syncThemeControls() + if (initialized) return + + initialized = true + + window.addEventListener("phx:apply-theme-settings", (event) => { + applyThemeSettings(event.detail) + }) + + const colorScheme = window.matchMedia?.("(prefers-color-scheme: light)") + colorScheme?.addEventListener?.("change", () => { + const mode = themeMode() + if (mode === "system" || (!mode && !storedTheme())) applyTheme(systemTheme()) + }) + + document.addEventListener("click", (event) => { + const toggle = event.target.closest("[data-theme-toggle]") + if (!toggle) return + + event.preventDefault() + const nextTheme = currentTheme() === "light" ? "dark" : "light" + + if (themeMode()) { + // Signed in: the toggle pins an explicit day/night mode server-side, + // moving off system or custom. + if (themeMode() === "custom") clearOverrideProperties() + document.documentElement.dataset.themeMode = nextTheme + applyTheme(nextTheme) + persistThemeMode(nextTheme) + } else { + applyTheme(nextTheme, true) + } + }) +} diff --git a/apps/elektrine/assets/js/utils/clipboard.js b/apps/elektrine/assets/js/utils/clipboard.js new file mode 100644 index 0000000..a0accf6 --- /dev/null +++ b/apps/elektrine/assets/js/utils/clipboard.js @@ -0,0 +1,152 @@ +/** + * Clipboard Utilities + * Handles copy to clipboard operations + */ + +function coerceClipboardText(text) { + if (text === null || text === undefined) return '' + return typeof text === 'string' ? text : String(text) +} + +function fallbackCopyToClipboard(text) { + let textarea = null + try { + textarea = document.createElement('textarea') + textarea.value = text + textarea.setAttribute('readonly', '') + textarea.style.position = 'fixed' + textarea.style.top = '-9999px' + textarea.style.left = '-9999px' + document.body.appendChild(textarea) + textarea.select() + textarea.setSelectionRange(0, textarea.value.length) + return document.execCommand('copy') + } catch (_err) { + return false + } finally { + if (textarea && textarea.parentNode) { + textarea.parentNode.removeChild(textarea) + } + } +} + +/** + * Copy text to clipboard with notification. + * + * `announce: false` suppresses the success toast for callers whose success is + * already announced elsewhere — a LiveView that pairs `push_event` with a + * `put_flash` would otherwise raise two notifications for one click. Failure is + * always announced: an optimistic flash claims the copy worked, so nothing else + * would tell the reader when it did not. + */ +export function copyToClipboard(text, type = 'link', { announce = true } = {}) { + const textToCopy = coerceClipboardText(text) + const showSuccess = () => { + if (!announce) return + + const message = + type === 'message' + ? 'Message copied to clipboard' + : type === 'selection' + ? 'Selection copied to clipboard' + : type === 'email' + ? 'Email address copied to clipboard' + : 'Link copied to clipboard' + if (window.showNotification) { + window.showNotification(message, 'info', 'Success!') + } + } + const showFailure = (err) => { + console.error('Failed to copy text to clipboard:', err) + if (window.showNotification) { + window.showNotification('Failed to copy to clipboard', 'error', 'Error!') + } + } + + const canUseClipboardApi = + !!navigator.clipboard && + typeof navigator.clipboard.writeText === 'function' && + window.isSecureContext + + if (canUseClipboardApi) { + return navigator.clipboard.writeText(textToCopy).then(() => { + showSuccess() + return true + }).catch(err => { + if (fallbackCopyToClipboard(textToCopy)) { + showSuccess() + return true + } + showFailure(err) + return false + }) + } + + if (fallbackCopyToClipboard(textToCopy)) { + showSuccess() + return Promise.resolve(true) + } else { + showFailure(new Error('Clipboard API unavailable and fallback copy failed')) + return Promise.resolve(false) + } +} + +/** + * Open URL in new window + */ +export function openUrl(url) { + if (url) { + window.open(url, '_blank', 'noopener,noreferrer') + } +} + +/** + * Resolve what a copy trigger wants copied. + * + * Triggers carry the text inline (`data-copy`), name another element to read + * it from (`data-copy-target`), or leave both off and fall back to the page's + * primary address. The `data-content`/`data-email` spellings are what the + * markup already used before the copy hooks were replaced. + */ +function resolveCopyText(el) { + if (!(el instanceof Element)) return '' + + const inline = el.dataset.copy ?? el.dataset.content ?? el.dataset.email + if (inline) return inline + + if (el.dataset.copyTarget) { + const target = document.getElementById(el.dataset.copyTarget) + if (!target) return '' + + return 'value' in target && typeof target.value === 'string' + ? target.value + : target.textContent + } + + return document.getElementById('email-address')?.textContent ?? '' +} + +/** + * Initialize clipboard event handlers + */ +export function initClipboardHandlers() { + // Server-initiated: every LiveView that pushes this also puts a flash saying + // the same thing, so the copy happens quietly and the flash does the talking. + window.addEventListener("phx:copy_to_clipboard", (e) => { + copyToClipboard(e.detail.text, e.detail.type, { announce: false }) + }) + + // Raised by `Elektrine.Components.CopyButton.copy/2`. One listener replaces + // the per-element CopyEmail/CopyToClipboard/CopyButton hooks — the success + // flash is a JS.transition in the markup, so nothing here holds a timer. + window.addEventListener("phx:copy", (e) => { + const trigger = e.target instanceof Element ? e.target : null + const text = e.detail?.text || resolveCopyText(trigger) + + if (text) copyToClipboard(text, trigger?.dataset?.copyType || 'link') + }) + + window.addEventListener("phx:open_url", (e) => { + openUrl(e.detail.url) + }) +} diff --git a/apps/elektrine/assets/js/utils/dropdown.js b/apps/elektrine/assets/js/utils/dropdown.js new file mode 100644 index 0000000..50a14db --- /dev/null +++ b/apps/elektrine/assets/js/utils/dropdown.js @@ -0,0 +1,49 @@ +/** + * Dropdown Management Utilities + * Handles closing dropdowns on various events + */ + +/** + * Close all open dropdowns + */ +export function closeAllDropdowns() { + document.querySelectorAll('.dropdown').forEach(dropdown => { + dropdown.blur() + }) + document.querySelectorAll('.dropdown [tabindex="0"]').forEach(trigger => { + trigger.blur() + const activeElement = trigger.querySelector(':focus') + if (activeElement) { + activeElement.blur() + } + }) +} + +/** + * Handle clicks that should close dropdowns + */ +function handleDropdownClick(e) { + const clickedDropdown = e.target.closest(".dropdown") + const clickedDropdownContent = e.target.closest(".dropdown-content") + + // Keep dropdowns open when the trigger itself is clicked. + if (clickedDropdown && !clickedDropdownContent) { + return + } + + if (e.target.closest("[phx-click]")) { + setTimeout(closeAllDropdowns, 50) + } +} + +/** + * Initialize dropdown management + */ +export function initDropdownManagement() { + // Close dropdowns on phx-click + document.addEventListener("click", handleDropdownClick) + + // Close dropdowns when window loses/regains focus + window.addEventListener("blur", closeAllDropdowns) + window.addEventListener("focus", closeAllDropdowns) +} diff --git a/apps/elektrine/assets/js/utils/email_selection.js b/apps/elektrine/assets/js/utils/email_selection.js new file mode 100644 index 0000000..49a1cc5 --- /dev/null +++ b/apps/elektrine/assets/js/utils/email_selection.js @@ -0,0 +1,79 @@ +/** + * Email Selection Utilities + * Handles checkbox updates, keyboard shortcuts, and shift+click for message selection + */ + +/** + * Update message checkboxes based on selection state + */ +function updateCheckboxes({ selected_ids, select_all }) { + const checkboxes = document.querySelectorAll('[id^="message-checkbox-"]') + checkboxes.forEach(checkbox => { + const messageId = parseInt(checkbox.id.replace('message-checkbox-', '')) + const isSelected = select_all || selected_ids.includes(messageId) + + checkbox.checked = isSelected + + const messageCard = document.getElementById(`message-${messageId}`) + if (messageCard) { + messageCard.classList.toggle('message-selected', isSelected) + } + }) +} + +/** + * Handle keyboard shortcuts for message selection + */ +function handleSelectionKeyboard(e) { + // Only handle shortcuts when not typing in an input + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return + + // Ctrl+A or Cmd+A - Select all messages on page + if ((e.ctrlKey || e.metaKey) && e.key === 'a') { + e.preventDefault() + const selectAllBtn = document.querySelector('[phx-click="select_all_messages"]') + if (selectAllBtn) selectAllBtn.click() + } + + // Escape - Clear selection + if (e.key === 'Escape') { + const clearBtn = document.querySelector('[phx-click="deselect_all_messages"]') + if (clearBtn) clearBtn.click() + } +} + +/** + * Handle shift+click and checkbox clicks for message selection + */ +function handleSelectionClick(e) { + const messageCard = e.target.closest('.message-card[data-message-id]') + if (messageCard && e.shiftKey) { + e.preventDefault() + const messageId = messageCard.getAttribute('data-message-id') + const checkbox = document.getElementById(`message-checkbox-${messageId}`) + if (checkbox) { + checkbox.click() + } + return + } + + // Handle individual checkbox clicks for immediate visual feedback + if (e.target.type === 'checkbox' && e.target.id.startsWith('message-checkbox-')) { + const messageId = e.target.id.replace('message-checkbox-', '') + const messageCard = document.getElementById(`message-${messageId}`) + if (messageCard) { + setTimeout(() => { + messageCard.classList.toggle('message-selected', e.target.checked) + }, 50) + } + } +} + +/** + * Initialize email selection handlers + */ +export function initEmailSelection() { + window.addEventListener("phx:update_checkboxes", (e) => updateCheckboxes(e.detail)) + document.addEventListener('keydown', handleSelectionKeyboard) + document.addEventListener('click', handleSelectionClick) +} diff --git a/apps/elektrine/assets/js/utils/form_submission.js b/apps/elektrine/assets/js/utils/form_submission.js new file mode 100644 index 0000000..6436244 --- /dev/null +++ b/apps/elektrine/assets/js/utils/form_submission.js @@ -0,0 +1,19 @@ +export function submitFormPreservingEvents(form, submitter = null) { + if (!form) return + + if (typeof form.requestSubmit === 'function') { + if (submitter instanceof HTMLElement) { + form.requestSubmit(submitter) + } else { + form.requestSubmit() + } + return + } + + const transientSubmitter = document.createElement('button') + transientSubmitter.type = 'submit' + transientSubmitter.hidden = true + form.appendChild(transientSubmitter) + transientSubmitter.click() + transientSubmitter.remove() +} diff --git a/apps/elektrine/assets/js/utils/index.js b/apps/elektrine/assets/js/utils/index.js new file mode 100644 index 0000000..2c94954 --- /dev/null +++ b/apps/elektrine/assets/js/utils/index.js @@ -0,0 +1,9 @@ +/** + * Utility modules index + * Central export for all utility functions + */ + +export { initEmailSelection } from './email_selection' +export { initDropdownManagement, closeAllDropdowns } from './dropdown' +export { initClipboardHandlers, copyToClipboard, openUrl } from './clipboard' +export { spinnerSvg } from './spinner' diff --git a/apps/elektrine/assets/js/utils/overlay_portal.js b/apps/elektrine/assets/js/utils/overlay_portal.js new file mode 100644 index 0000000..462fd3a --- /dev/null +++ b/apps/elektrine/assets/js/utils/overlay_portal.js @@ -0,0 +1,85 @@ +export class OverlayPortal { + constructor(element, options = {}) { + this.element = element; + this.originParent = element?.parentNode || null; + this.originNextSibling = element?.nextSibling || null; + this.portalRoot = options.portalRoot || this.defaultPortalRoot(); + } + + mount() { + if (!this.element || !this.portalRoot || this.element.parentNode === this.portalRoot) return; + + this.portalRoot.appendChild(this.element); + } + + defaultPortalRoot() { + return this.element?.closest?.("[data-phx-main]") || document.body; + } + + restore() { + if (!this.element || !this.originParent || this.element.parentNode === this.originParent) { + return; + } + + this.clearPosition(); + + if (this.originNextSibling && this.originNextSibling.parentNode === this.originParent) { + this.originParent.insertBefore(this.element, this.originNextSibling); + } else { + this.originParent.appendChild(this.element); + } + } + + positionNear(trigger, options = {}) { + if (!this.element || !trigger) return; + + const margin = options.margin ?? 8; + const zIndex = options.zIndex ?? 10000; + const triggerRect = trigger.getBoundingClientRect(); + const elementRect = this.element.getBoundingClientRect(); + const maxLeft = Math.max(margin, window.innerWidth - elementRect.width - margin); + const align = options.align || "start"; + const leftAnchor = + align === "end" + ? triggerRect.right - elementRect.width + : align === "center" + ? triggerRect.left + triggerRect.width / 2 - elementRect.width / 2 + : triggerRect.left; + const left = Math.min(Math.max(leftAnchor, margin), maxLeft); + const placement = options.placement || "auto"; + const belowTop = triggerRect.bottom + margin; + const aboveTop = triggerRect.top - elementRect.height - margin; + const top = + placement === "top" + ? aboveTop >= margin + ? aboveTop + : belowTop + : placement === "bottom" + ? belowTop + elementRect.height <= window.innerHeight - margin + ? belowTop + : Math.max(margin, aboveTop) + : belowTop + elementRect.height <= window.innerHeight - margin + ? belowTop + : Math.max(margin, aboveTop); + + Object.assign(this.element.style, { + position: "fixed", + left: `${left}px`, + top: `${top}px`, + right: "auto", + bottom: "auto", + zIndex: String(zIndex), + }); + } + + clearPosition() { + if (!this.element) return; + + this.element.style.removeProperty("position"); + this.element.style.removeProperty("left"); + this.element.style.removeProperty("top"); + this.element.style.removeProperty("right"); + this.element.style.removeProperty("bottom"); + this.element.style.removeProperty("z-index"); + } +} diff --git a/apps/elektrine/assets/js/utils/spinner.js b/apps/elektrine/assets/js/utils/spinner.js new file mode 100644 index 0000000..220a34b --- /dev/null +++ b/apps/elektrine/assets/js/utils/spinner.js @@ -0,0 +1,20 @@ +const SPINNER_PATH = + 'M20.27,4.74a4.93,4.93,0,0,1,1.52,4.61,5.32,5.32,0,0,1-4.1,4.51,5.12,5.12,0,0,1-5.2-1.5,5.53,5.53,0,0,0,6.13-1.48A5.66,5.66,0,0,0,20.27,4.74ZM12.32,11.53a5.49,5.49,0,0,0-1.47-6.2A5.57,5.57,0,0,0,4.71,3.72,5.17,5.17,0,0,1,9.53,2.2,5.52,5.52,0,0,1,13.9,6.45,5.28,5.28,0,0,1,12.32,11.53ZM19.2,20.29a4.92,4.92,0,0,1-4.72,1.49,5.32,5.32,0,0,1-4.34-4.05A5.2,5.2,0,0,1,11.6,12.5a5.6,5.6,0,0,0,1.51,6.13A5.63,5.63,0,0,0,19.2,20.29ZM3.79,19.38A5.18,5.18,0,0,1,2.32,14a5.3,5.3,0,0,1,4.59-4,5,5,0,0,1,4.58,1.61,5.55,5.55,0,0,0-6.32,1.69A5.46,5.46,0,0,0,3.79,19.38ZM12.23,12a5.11,5.11,0,0,0,3.66-5,5.75,5.75,0,0,0-3.18-6,5,5,0,0,1,4.42,2.3,5.21,5.21,0,0,1,.24,5.92A5.4,5.4,0,0,1,12.23,12ZM11.76,12a5.18,5.18,0,0,0-3.68,5.09,5.58,5.58,0,0,0,3.19,5.79c-1,.35-2.9-.46-4-1.68A5.51,5.51,0,0,1,11.76,12ZM23,12.63a5.07,5.07,0,0,1-2.35,4.52,5.23,5.23,0,0,1-5.91.2,5.24,5.24,0,0,1-2.67-4.77,5.51,5.51,0,0,0,5.45,3.33A5.52,5.52,0,0,0,23,12.63ZM1,11.23a5,5,0,0,1,2.49-4.5,5.23,5.23,0,0,1,5.81-.06,5.3,5.3,0,0,1,2.61,4.74A5.56,5.56,0,0,0,6.56,8.06,5.71,5.71,0,0,0,1,11.23Z' + +const SPINNER_SIZE_CLASSES = { + xs: 'w-3 h-3', + sm: 'w-4 h-4', + md: 'w-5 h-5', + lg: 'w-8 h-8' +} + +export function spinnerSvg({ size = 'md', className = '' } = {}) { + const sizeClasses = SPINNER_SIZE_CLASSES[size] || SPINNER_SIZE_CLASSES.md + const classes = ['animate-spin', sizeClasses, className].filter(Boolean).join(' ') + + return ` + + ` +} diff --git a/apps/elektrine/assets/js/utils/user_hover_card_scroll.js b/apps/elektrine/assets/js/utils/user_hover_card_scroll.js new file mode 100644 index 0000000..fe00a91 --- /dev/null +++ b/apps/elektrine/assets/js/utils/user_hover_card_scroll.js @@ -0,0 +1,26 @@ +const hooks = new Set(); +let listenersInstalled = false; + +function dismissForScroll() { + hooks.forEach((hook) => hook.dismissForScroll?.()); +} + +function ensureScrollListeners() { + if (listenersInstalled || typeof window === "undefined") return; + + listenersInstalled = true; + // js-check: allow-global-listener-singleton + window.addEventListener("scroll", dismissForScroll, { + capture: true, + passive: true, + }); +} + +export function registerUserHoverCardHook(hook) { + hooks.add(hook); + ensureScrollListeners(); +} + +export function unregisterUserHoverCardHook(hook) { + hooks.delete(hook); +} diff --git a/apps/elektrine/assets/js/webrtc_client.js b/apps/elektrine/assets/js/webrtc_client.js new file mode 100644 index 0000000..771da77 --- /dev/null +++ b/apps/elektrine/assets/js/webrtc_client.js @@ -0,0 +1,638 @@ +/** + * WebRTC Client for peer-to-peer audio/video calling + * Handles signaling, media, recovery, and cleanup. + */ + +const CHANNEL_JOIN_DELAY_MS = 100 +const ICE_RESTART_DELAY_MS = 1200 +const MAX_ICE_RESTART_ATTEMPTS = 2 + +function newClientSessionId() { + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID() + } + + return `${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +export class WebRTCClient { + constructor(socket, callId, userId, iceServers, transport = null) { + this.socket = socket + this.callId = callId + this.userId = userId + this.iceServers = iceServers || [] + this.transport = transport || {} + this.clientSessionId = newClientSessionId() + this.channel = null + this.peerConnection = null + this.localStream = null + this.remoteStream = null + this.onRemoteStreamCallback = null + this.onCallEndedCallback = null + this.onConnectionEstablishedCallback = null + this.iceCandidateQueue = [] + this.remoteDescriptionSet = false + this.pendingOffer = null + this.disposed = false + this.cleanupStarted = false + this.callEndedNotified = false + this.iceRestartAttempts = 0 + this.connectionEstablishedNotified = false + this.iceRestartTimer = null + this.onlineHandler = null + this.offlineHandler = null + this.peerReady = false + this.shouldInitiateOnPeerReady = false + this.answerInProgress = false + this.handledSignals = new Set() + } + + /** + * Initialize the call channel and set up event handlers. + */ + async initialize() { + if (this.disposed) { + throw new Error("Client already cleaned up") + } + + this.channel = this.socket.channel(`call:${this.callId}`, { + client_session_id: this.clientSessionId + }) + this.setupChannelHandlers() + this.setupNetworkHandlers() + + return new Promise((resolve, reject) => { + this.channel + .join() + .receive("ok", () => { + setTimeout(() => resolve(), CHANNEL_JOIN_DELAY_MS) + }) + .receive("error", (resp) => { + reject(new Error(resp?.reason || "Failed to join call channel")) + }) + .receive("timeout", () => { + reject(new Error("Call channel join timed out")) + }) + }) + } + + /** + * Set up Phoenix Channel event handlers for signaling. + */ + setupChannelHandlers() { + this.channel.on("peer_ready", async (payload = {}) => { + if (this.ignoreSignal("peer_ready", payload)) return + + this.peerReady = true + + if (this.shouldInitiateOnPeerReady && !this.pendingOffer) { + try { + await this.initiateOffer() + } catch (error) { + this.finishCall(`failed: ${error?.message || 'offer_failed'}`) + return + } + } + + await this.flushPendingOffer() + }) + + this.channel.on("offer", async (payload = {}) => { + if (this.ignoreSignal("offer", payload)) return + + try { + await this.handleOffer(payload.sdp) + } catch (error) { + this.finishCall(`failed: ${error?.message || 'offer_handling_failed'}`) + } + }) + + this.channel.on("answer", async (payload = {}) => { + if (this.ignoreSignal("answer", payload)) return + + try { + await this.handleAnswer(payload.sdp) + } catch (error) { + this.finishCall(`failed: ${error?.message || 'answer_handling_failed'}`) + } + }) + + this.channel.on("ice_candidate", async (payload = {}) => { + if (this.ignoreSignal("ice_candidate", payload)) return + + try { + await this.handleIceCandidate(payload.candidate) + } catch (_error) { + // Ignore malformed candidates from remote peer. + } + }) + + this.channel.on("call_rejected", (payload = {}) => { + if (!this.ignoreSignal("call_rejected", payload)) this.finishCall("rejected") + }) + this.channel.on("call_ended", (payload = {}) => { + if (!this.ignoreSignal("call_ended", payload)) this.finishCall(payload.reason || "ended") + }) + this.channel.on("call_missed", (payload = {}) => { + if (!this.ignoreSignal("call_missed", payload)) this.finishCall("missed") + }) + } + + ignoreSignal(event, payload = {}) { + if (payload.from_client_session_id && payload.from_client_session_id === this.clientSessionId) { + return true + } + + const key = `${event}:${payload.from_user_id || ''}:${payload.from_client_session_id || ''}:${payload.signal_id || ''}:${payload.sdp?.type || ''}:${payload.candidate?.candidate || ''}` + + if (this.handledSignals.has(key)) { + return true + } + + this.handledSignals.add(key) + return false + } + + pushSignal(event, payload = {}) { + if (this.channel && this.channel.state === "joined") { + return this.channel.push(event, { + ...payload, + client_session_id: this.clientSessionId + }) + } + + return null + } + + setupNetworkHandlers() { + if (this.onlineHandler || this.offlineHandler || typeof window === "undefined") { + return + } + + this.onlineHandler = () => { + if (!this.disposed && this.peerConnection && this.remoteDescriptionSet) { + this.scheduleIceRestart("network_recovered") + } + } + + this.offlineHandler = () => { + if (!this.disposed) { + this.finishCall("failed: network_offline") + } + } + + window.addEventListener("online", this.onlineHandler) + window.addEventListener("offline", this.offlineHandler) + } + + teardownNetworkHandlers() { + if (typeof window === "undefined") { + return + } + + if (this.onlineHandler) { + window.removeEventListener("online", this.onlineHandler) + this.onlineHandler = null + } + + if (this.offlineHandler) { + window.removeEventListener("offline", this.offlineHandler) + this.offlineHandler = null + } + } + + /** + * Create a new RTCPeerConnection. + */ + createPeerConnection() { + if (this.peerConnection) { + return this.peerConnection + } + + this.peerConnection = new RTCPeerConnection({ iceServers: this.iceServers }) + this.connectionEstablishedNotified = false + + this.peerConnection.onicecandidate = (event) => { + if (event.candidate && this.channel && this.channel.state === "joined") { + this.pushSignal("ice_candidate", { candidate: event.candidate }) + } + } + + this.peerConnection.ontrack = (event) => { + if (!this.remoteStream) { + this.remoteStream = new MediaStream() + } + this.remoteStream.addTrack(event.track) + + if (this.onRemoteStreamCallback) { + this.onRemoteStreamCallback(this.remoteStream) + } + } + + this.peerConnection.onconnectionstatechange = () => { + if (!this.peerConnection) { + return + } + + const state = this.peerConnection.connectionState + + if (state === "connected") { + this.iceRestartAttempts = 0 + if (!this.connectionEstablishedNotified && this.onConnectionEstablishedCallback) { + this.connectionEstablishedNotified = true + this.onConnectionEstablishedCallback() + } + } + + if (state === "disconnected") { + this.scheduleIceRestart("disconnected") + } + + if (state === "failed") { + this.scheduleIceRestart("failed") + } + } + + this.peerConnection.oniceconnectionstatechange = () => { + if (!this.peerConnection) { + return + } + + const state = this.peerConnection.iceConnectionState + if (state === "disconnected" || state === "failed") { + this.scheduleIceRestart(state) + } + } + + return this.peerConnection + } + + /** + * Start a call as caller (create offer). + */ + async startCall(callType = "video") { + if (this.disposed) { + throw new Error("Client already cleaned up") + } + + await this.prepareLocalPeer(callType) + this.shouldInitiateOnPeerReady = true + await this.initiateOffer() + } + + async resumeCall(callType = "video", initiator = false) { + if (this.disposed) { + throw new Error("Client already cleaned up") + } + + await this.prepareLocalPeer(callType) + this.shouldInitiateOnPeerReady = initiator + + if (this.channel && this.channel.state === "joined") { + this.pushSignal("ready_to_receive") + } + } + + /** + * Handle incoming offer (callee). + */ + async handleOffer(offer) { + if (!offer || typeof offer.sdp !== "string") { + return + } + + const callType = offer.sdp.includes("m=video") ? "video" : "audio" + await this.getUserMedia(callType) + + const peer = this.createPeerConnection() + this.attachLocalTracks(peer) + + await peer.setRemoteDescription(new RTCSessionDescription(offer)) + this.remoteDescriptionSet = true + await this.processIceCandidateQueue() + + const answer = await peer.createAnswer() + await peer.setLocalDescription(answer) + + this.pushSignal("answer", { sdp: answer }) + } + + /** + * Handle incoming answer (caller). + */ + async handleAnswer(answer) { + if (!this.peerConnection || !answer) { + return + } + + if (this.answerInProgress || this.remoteDescriptionSet || this.peerConnection.signalingState !== "have-local-offer") { + return + } + + this.answerInProgress = true + + try { + await this.peerConnection.setRemoteDescription(new RTCSessionDescription(answer)) + this.remoteDescriptionSet = true + await this.processIceCandidateQueue() + } finally { + this.answerInProgress = false + } + } + + /** + * Handle incoming ICE candidate. + */ + async handleIceCandidate(candidate) { + if (!candidate) { + return + } + + if (this.remoteDescriptionSet && this.peerConnection) { + await this.peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) + return + } + + this.iceCandidateQueue.push(candidate) + } + + /** + * Process queued ICE candidates. + */ + async processIceCandidateQueue() { + if (!this.peerConnection) { + this.iceCandidateQueue = [] + return + } + + for (const candidate of this.iceCandidateQueue) { + try { + await this.peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) + } catch (_error) { + // Ignore stale/invalid candidate entries from race conditions. + } + } + + this.iceCandidateQueue = [] + } + + /** + * Get user media (camera/microphone). + */ + async getUserMedia(callType) { + if (this.localStream) { + return this.localStream + } + + const constraints = { + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + sampleRate: 48000, + channelCount: 1 + }, + video: + callType === "video" + ? { + width: { ideal: 1280 }, + height: { ideal: 720 }, + facingMode: "user" + } + : false + } + + this.localStream = await navigator.mediaDevices.getUserMedia(constraints) + return this.localStream + } + + attachLocalTracks(peerConnection) { + if (!this.localStream || !peerConnection) { + return + } + + const existingTrackIds = new Set( + peerConnection + .getSenders() + .map((sender) => sender.track?.id) + .filter(Boolean) + ) + + this.localStream.getTracks().forEach((track) => { + if (!existingTrackIds.has(track.id)) { + peerConnection.addTrack(track, this.localStream) + } + }) + } + + async flushPendingOffer() { + if (!this.pendingOffer || !this.peerReady || !this.channel || this.channel.state !== "joined") { + return + } + + this.pushSignal("offer", { sdp: this.pendingOffer }) + this.pendingOffer = null + } + + scheduleIceRestart(_reason) { + if (this.disposed || this.cleanupStarted || !this.peerConnection || !this.remoteDescriptionSet) { + return + } + + if (this.iceRestartAttempts >= MAX_ICE_RESTART_ATTEMPTS) { + this.finishCall("failed: ice_restart_exhausted") + return + } + + if (this.iceRestartTimer) { + return + } + + this.iceRestartTimer = window.setTimeout(async () => { + this.iceRestartTimer = null + await this.restartIce() + }, ICE_RESTART_DELAY_MS) + } + + async restartIce() { + if ( + this.disposed || + this.cleanupStarted || + !this.peerConnection || + !this.channel || + this.channel.state !== "joined" + ) { + return + } + + try { + this.iceRestartAttempts += 1 + const offer = await this.peerConnection.createOffer({ iceRestart: true }) + await this.peerConnection.setLocalDescription(offer) + this.pushSignal("offer", { sdp: offer }) + } catch (_error) { + if (this.iceRestartAttempts >= MAX_ICE_RESTART_ATTEMPTS) { + this.finishCall(`failed: ${_error?.message || 'ice_restart_failed'}`) + } + } + } + + /** + * Toggle video track. + */ + toggleVideo() { + if (this.localStream) { + const videoTrack = this.localStream.getVideoTracks()[0] + if (videoTrack) { + videoTrack.enabled = !videoTrack.enabled + return videoTrack.enabled + } + } + return false + } + + /** + * Toggle audio track. + */ + toggleAudio() { + if (this.localStream) { + const audioTrack = this.localStream.getAudioTracks()[0] + if (audioTrack) { + audioTrack.enabled = !audioTrack.enabled + return audioTrack.enabled + } + } + return false + } + + /** + * End the call. + */ + endCall() { + if (this.channel && this.channel.state === "joined") { + this.pushSignal("end_call") + } + this.cleanup() + } + + /** + * Reject the call. + */ + rejectCall() { + if (this.channel && this.channel.state === "joined") { + this.pushSignal("reject_call") + } + this.cleanup() + } + + finishCall(reason) { + this.cleanup() + + if (!this.callEndedNotified && this.onCallEndedCallback) { + this.callEndedNotified = true + this.onCallEndedCallback(reason || "ended") + } + } + + /** + * Clean up resources. + */ + cleanup() { + if (this.cleanupStarted) { + return + } + + this.cleanupStarted = true + this.disposed = true + + if (this.iceRestartTimer) { + clearTimeout(this.iceRestartTimer) + this.iceRestartTimer = null + } + + this.teardownNetworkHandlers() + + if (this.localStream) { + this.localStream.getTracks().forEach((track) => track.stop()) + this.localStream = null + } + + if (this.peerConnection) { + this.peerConnection.onicecandidate = null + this.peerConnection.ontrack = null + this.peerConnection.onconnectionstatechange = null + this.peerConnection.oniceconnectionstatechange = null + this.peerConnection.close() + this.peerConnection = null + } + + if (this.channel) { + this.channel.leave() + this.channel = null + } + + if (this.socket) { + this.socket.disconnect() + } + + this.remoteStream = null + this.pendingOffer = null + this.iceCandidateQueue = [] + this.remoteDescriptionSet = false + this.peerReady = false + this.shouldInitiateOnPeerReady = false + this.answerInProgress = false + this.handledSignals.clear() + } + + /** + * Set callback for when remote stream is received. + */ + onRemoteStream(callback) { + this.onRemoteStreamCallback = callback + } + + /** + * Set callback for when call ends. + */ + onCallEnded(callback) { + this.onCallEndedCallback = callback + } + + /** + * Set callback for when connection is established. + */ + onConnectionEstablished(callback) { + this.onConnectionEstablishedCallback = callback + } + + /** + * Get local stream. + */ + getLocalStream() { + return this.localStream + } + + /** + * Get remote stream. + */ + getRemoteStream() { + return this.remoteStream + } + + async prepareLocalPeer(callType) { + await this.getUserMedia(callType) + + const peer = this.createPeerConnection() + this.attachLocalTracks(peer) + return peer + } + + async initiateOffer() { + const peer = this.createPeerConnection() + const offer = await peer.createOffer() + await peer.setLocalDescription(offer) + this.pendingOffer = offer + await this.flushPendingOffer() + } +} diff --git a/apps/elektrine/assets/package-lock.json b/apps/elektrine/assets/package-lock.json new file mode 100644 index 0000000..e6ba61f --- /dev/null +++ b/apps/elektrine/assets/package-lock.json @@ -0,0 +1,1204 @@ +{ + "name": "elektrine", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "elektrine", + "version": "1.0.0", + "dependencies": { + "@noble/hashes": "^2.2.0", + "daisyui": "^5.6.18", + "uplot": "^1.6.32" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.3.3", + "tailwindcss": "^4.3.3" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz", + "integrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "2.5.1", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "enhanced-resolve": "^5.24.1", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.3.3" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/daisyui": { + "version": "5.6.18", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.6.18.tgz", + "integrity": "sha512-6y9rboRQl8fosnusy/5pg11yp+H0LtK3YdtfS4Vf2QVnaYOKasNdYm6hDxeiT6bwZPdl4WRPzVIlIApdo2ssBg==", + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", + "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", + "license": "MIT" + } + } +} diff --git a/apps/elektrine/assets/package.json b/apps/elektrine/assets/package.json new file mode 100644 index 0000000..7cfcdc4 --- /dev/null +++ b/apps/elektrine/assets/package.json @@ -0,0 +1,19 @@ +{ + "name": "elektrine", + "version": "1.0.0", + "description": "Elektrine Phoenix application", + "main": "js/app.js", + "scripts": { + "deploy": "cd .. && mix assets.deploy && rm -f _build/esbuild*", + "check": "node scripts/check_js.mjs" + }, + "dependencies": { + "@noble/hashes": "^2.2.0", + "daisyui": "^5.6.18", + "uplot": "^1.6.32" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.3.3", + "tailwindcss": "^4.3.3" + } +} diff --git a/apps/elektrine/assets/scripts/check_js.mjs b/apps/elektrine/assets/scripts/check_js.mjs new file mode 100644 index 0000000..f87017d --- /dev/null +++ b/apps/elektrine/assets/scripts/check_js.mjs @@ -0,0 +1,154 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); +const jsDir = join(rootDir, "js"); + +const bannedPatterns = [ + { + pattern: /\bdebugger\b/, + message: "debugger statements should not be committed", + }, + { + pattern: /\bconsole\.log\s*\(/, + message: "use intentional console.warn/error or remove debug logging", + }, + { + pattern: /(oklch|hsl)\(var\(--/, + message: "use public --color-* theme tokens instead of DaisyUI internals", + }, + { + pattern: /fallback-b[1-3c]\b/, + message: "use public --color-* theme tokens instead of fallback base tokens", + }, +]; + +const relativeImportPattern = + /(?:import|export)\s+(?:[\s\S]*?\s+from\s+)?["'](\.{1,2}\/[^"']+)["']/g; + +const lifecycleRules = [ + { + resource: "setInterval", + acquire: /\bsetInterval\s*\(/g, + release: /\bclearInterval\s*\(/g, + message: "hooks that create intervals must clear them", + }, + { + resource: "MutationObserver", + acquire: /\bnew\s+MutationObserver\s*\(/g, + release: /\.disconnect\s*\(/g, + message: "hooks that create MutationObserver instances must disconnect them", + }, + { + resource: "ResizeObserver", + acquire: /\bnew\s+ResizeObserver\s*\(/g, + release: /\.disconnect\s*\(/g, + message: "hooks that create ResizeObserver instances must disconnect them", + }, +]; + +function jsFiles(dir) { + return readdirSync(dir) + .flatMap((entry) => { + const path = join(dir, entry); + const stat = statSync(path); + + if (stat.isDirectory()) return jsFiles(path); + if (entry.endsWith(".js")) return [path]; + return []; + }) + .sort(); +} + +function resolveRelativeImport(file, specifier) { + const basePath = join(dirname(file), specifier); + const candidates = [basePath, `${basePath}.js`, join(basePath, "index.js")]; + + return candidates.find((candidate) => existsSync(candidate)); +} + +function countMatches(source, pattern) { + return Array.from(source.matchAll(pattern)).length; +} + +let failures = 0; + +for (const file of jsFiles(jsDir)) { + const displayPath = relative(rootDir, file); + const syntax = spawnSync(process.execPath, ["--check", file], { + encoding: "utf8", + }); + + if (syntax.status !== 0) { + failures += 1; + process.stderr.write(`error: ${displayPath} failed node --check\n`); + process.stderr.write(syntax.stderr || syntax.stdout); + continue; + } + + const source = readFileSync(file, "utf8"); + for (const { pattern, message } of bannedPatterns) { + if (!pattern.test(source)) continue; + + failures += 1; + process.stderr.write(`error: ${displayPath}: ${message}\n`); + } + + for (const match of source.matchAll(relativeImportPattern)) { + const specifier = match[1]; + + if (!resolveRelativeImport(file, specifier)) { + failures += 1; + process.stderr.write(`error: ${displayPath}: unresolved relative import ${specifier}\n`); + } + } + + if ( + displayPath.startsWith("js/hooks/") && + source.includes("mounted()") && + source.includes("addEventListener(") && + !source.includes("destroyed()") + ) { + failures += 1; + process.stderr.write( + `error: ${displayPath}: hooks that attach event listeners in mounted() must define destroyed()\n`, + ); + } + + if (displayPath.startsWith("js/hooks/")) { + const globalListenerAdds = countMatches( + source, + /\b(?:window|document)\.addEventListener\s*\(/g, + ); + const globalListenerRemoves = countMatches( + source, + /\b(?:window|document)\.removeEventListener\s*\(/g, + ); + const allowsAppLifetimeListeners = source.includes( + "js-check: allow-global-listener-singleton", + ); + + if (globalListenerAdds > globalListenerRemoves && !allowsAppLifetimeListeners) { + failures += 1; + process.stderr.write( + `error: ${displayPath}: global window/document listeners must be removed or documented as an app-lifetime singleton\n`, + ); + } + + for (const { acquire, release, message } of lifecycleRules) { + if (countMatches(source, acquire) > 0 && countMatches(source, release) === 0) { + failures += 1; + process.stderr.write(`error: ${displayPath}: ${message}\n`); + } + } + } +} + +if (failures > 0) { + process.stderr.write(`JavaScript check failed with ${failures} issue(s).\n`); + process.exit(1); +} + +process.stdout.write("JavaScript check passed.\n"); diff --git a/apps/elektrine/assets/tailwind.config.mjs b/apps/elektrine/assets/tailwind.config.mjs new file mode 100644 index 0000000..fb46b1d --- /dev/null +++ b/apps/elektrine/assets/tailwind.config.mjs @@ -0,0 +1,90 @@ +// Tailwind CSS v4 configuration +// https://tailwindcss.com/docs/configuration + +import plugin from "tailwindcss/plugin" +import fs from "fs" +import path from "path" +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +function resolveHeroiconsDir() { + const candidates = [ + path.join(__dirname, "vendor/heroicons/optimized") + ] + + const existing = candidates.find((candidate) => fs.existsSync(candidate)) + if (!existing) { + throw new Error(`Heroicons directory not found. Checked: ${candidates.join(", ")}`) + } + + return existing +} + +export default { + content: [ + "./js/**/*.js", + // Umbrella apps split UI templates and some icon definitions across app folders. + "../../*/lib/**/*.ex", + "../../*/lib/**/*.heex" + ], + theme: { + extend: { + colors: { + brand: "#FD4F00", + }, + fontFamily: { + sans: ['Geist', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'], + mono: ['Geist Mono', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'], + pixel: ['Geist Pixel', 'Geist Mono', 'ui-monospace', 'monospace'], + } + }, + }, + plugins: [ + // LiveView loading variants + plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), + plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), + plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), + + // Heroicons plugin + plugin(function({matchComponents, theme}) { + let iconsDir = resolveHeroiconsDir() + let values = {} + let icons = [ + ["", "/24/outline"], + ["-solid", "/24/solid"], + ["-mini", "/20/solid"], + ["-micro", "/16/solid"] + ] + icons.forEach(([suffix, dir]) => { + fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { + let name = path.basename(file, ".svg") + suffix + values[name] = {name, fullPath: path.join(iconsDir, dir, file)} + }) + }) + matchComponents({ + "hero": ({name, fullPath}) => { + let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") + let size = theme("spacing.6") + if (name.endsWith("-mini")) { + size = theme("spacing.5") + } else if (name.endsWith("-micro")) { + size = theme("spacing.4") + } + return { + [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, + "-webkit-mask": `var(--hero-${name})`, + "mask": `var(--hero-${name})`, + "mask-repeat": "no-repeat", + "background-color": "currentColor", + "vertical-align": "middle", + "display": "inline-block", + "width": size, + "height": size + } + } + }, {values}) + }) + ] +} diff --git a/apps/elektrine/assets/vendor/heroicons/LICENSE b/apps/elektrine/assets/vendor/heroicons/LICENSE new file mode 100644 index 0000000..d6a8229 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Tailwind Labs, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/elektrine/assets/vendor/heroicons/SOURCE.md b/apps/elektrine/assets/vendor/heroicons/SOURCE.md new file mode 100644 index 0000000..21472f9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/SOURCE.md @@ -0,0 +1,9 @@ +Vendored from Tailwind Labs Heroicons. + +- Source: https://github.com/tailwindlabs/heroicons +- Version: v2.2.0 +- Commit: 0435d4ca364a608cc75e2f8683d374e55abbae26 +- License: MIT + +Only the `optimized` SVG directory is vendored here. The Tailwind config reads +these static SVG files directly when generating `hero-*` icon classes. diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/academic-cap.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/academic-cap.svg new file mode 100644 index 0000000..c66143a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/academic-cap.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/adjustments-horizontal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/adjustments-horizontal.svg new file mode 100644 index 0000000..19f1fcc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/adjustments-horizontal.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/adjustments-vertical.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/adjustments-vertical.svg new file mode 100644 index 0000000..70f1f83 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/adjustments-vertical.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box-arrow-down.svg new file mode 100644 index 0000000..bf0ff06 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box-arrow-down.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box-x-mark.svg new file mode 100644 index 0000000..869641d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box-x-mark.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box.svg new file mode 100644 index 0000000..b6f42cb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-circle.svg new file mode 100644 index 0000000..8836f9b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-left.svg new file mode 100644 index 0000000..f6ab16f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-on-square-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-on-square-stack.svg new file mode 100644 index 0000000..2284806 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-on-square-stack.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-on-square.svg new file mode 100644 index 0000000..801c244 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-on-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-right.svg new file mode 100644 index 0000000..8a1d80a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-tray.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-tray.svg new file mode 100644 index 0000000..1ff676b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-tray.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down.svg new file mode 100644 index 0000000..b90f11a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-circle.svg new file mode 100644 index 0000000..5dd2ded --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-end-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-end-on-rectangle.svg new file mode 100644 index 0000000..2fd5cce --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-end-on-rectangle.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-start-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-start-on-rectangle.svg new file mode 100644 index 0000000..89e7d5a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-start-on-rectangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left.svg new file mode 100644 index 0000000..748b385 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-down.svg new file mode 100644 index 0000000..364503c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-left.svg new file mode 100644 index 0000000..185924f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-right.svg new file mode 100644 index 0000000..40b5533 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-up.svg new file mode 100644 index 0000000..535b7b7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-path-rounded-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-path-rounded-square.svg new file mode 100644 index 0000000..b698c36 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-path-rounded-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-path.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-path.svg new file mode 100644 index 0000000..2d5cbe2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-path.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-circle.svg new file mode 100644 index 0000000..e9d64e2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-end-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-end-on-rectangle.svg new file mode 100644 index 0000000..233ca17 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-end-on-rectangle.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-start-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-start-on-rectangle.svg new file mode 100644 index 0000000..14a3273 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-start-on-rectangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right.svg new file mode 100644 index 0000000..bc4ee02 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-top-right-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-top-right-on-square.svg new file mode 100644 index 0000000..ef67401 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-top-right-on-square.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-trending-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-trending-down.svg new file mode 100644 index 0000000..98dc7f9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-trending-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-trending-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-trending-up.svg new file mode 100644 index 0000000..77395a2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-trending-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-down-left.svg new file mode 100644 index 0000000..ca5f6f0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-down-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-down-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-down-right.svg new file mode 100644 index 0000000..3051a7f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-down-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-left-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-left-down.svg new file mode 100644 index 0000000..6e8cc84 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-left-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-left-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-left-up.svg new file mode 100644 index 0000000..e0d9b70 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-left-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-right-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-right-down.svg new file mode 100644 index 0000000..ba73dc7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-right-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-right-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-right-up.svg new file mode 100644 index 0000000..517ef31 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-right-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-up-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-up-left.svg new file mode 100644 index 0000000..227b8e7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-up-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-up-right.svg new file mode 100644 index 0000000..a9d9cf1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-up-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-circle.svg new file mode 100644 index 0000000..0385993 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-left.svg new file mode 100644 index 0000000..88cf7cf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-on-square-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-on-square-stack.svg new file mode 100644 index 0000000..9e43afc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-on-square-stack.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-on-square.svg new file mode 100644 index 0000000..13bf43d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-on-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-right.svg new file mode 100644 index 0000000..dd7a8ef --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-tray.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-tray.svg new file mode 100644 index 0000000..aecaa26 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-tray.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up.svg new file mode 100644 index 0000000..58f91a3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-down.svg new file mode 100644 index 0000000..2afee9f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-left.svg new file mode 100644 index 0000000..55f63dd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-right.svg new file mode 100644 index 0000000..8028a57 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-up.svg new file mode 100644 index 0000000..7df0285 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-pointing-in.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-pointing-in.svg new file mode 100644 index 0000000..29ea56b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-pointing-in.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-pointing-out.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-pointing-out.svg new file mode 100644 index 0000000..a9d9385 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-pointing-out.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-right-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-right-left.svg new file mode 100644 index 0000000..9ba45af --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-right-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-up-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-up-down.svg new file mode 100644 index 0000000..2cf5523 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-up-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/at-symbol.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/at-symbol.svg new file mode 100644 index 0000000..11e99d8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/at-symbol.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/backspace.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/backspace.svg new file mode 100644 index 0000000..1c5f355 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/backspace.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/backward.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/backward.svg new file mode 100644 index 0000000..0e7a49c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/backward.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/banknotes.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/banknotes.svg new file mode 100644 index 0000000..27ceef7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/banknotes.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-2.svg new file mode 100644 index 0000000..7a209e2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-2.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-bottom-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-bottom-left.svg new file mode 100644 index 0000000..08ca32a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-bottom-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-bottom-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-bottom-right.svg new file mode 100644 index 0000000..edee4c9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-bottom-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-center-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-center-left.svg new file mode 100644 index 0000000..d2228f2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-center-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3.svg new file mode 100644 index 0000000..9a1136a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-4.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-4.svg new file mode 100644 index 0000000..e1c529e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-4.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-arrow-down.svg new file mode 100644 index 0000000..4d48bba --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-arrow-up.svg new file mode 100644 index 0000000..6d44246 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-arrow-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-0.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-0.svg new file mode 100644 index 0000000..8df1066 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-0.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-100.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-100.svg new file mode 100644 index 0000000..561cc78 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-100.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-50.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-50.svg new file mode 100644 index 0000000..ad4ef42 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-50.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/beaker.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/beaker.svg new file mode 100644 index 0000000..f8c9c3a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/beaker.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-alert.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-alert.svg new file mode 100644 index 0000000..189b8bc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-alert.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-slash.svg new file mode 100644 index 0000000..7fcf3c1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-slash.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-snooze.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-snooze.svg new file mode 100644 index 0000000..0af5048 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-snooze.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell.svg new file mode 100644 index 0000000..1b4f00f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bold.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bold.svg new file mode 100644 index 0000000..25c3dbc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bold.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bolt-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bolt-slash.svg new file mode 100644 index 0000000..3f96c8e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bolt-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bolt.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bolt.svg new file mode 100644 index 0000000..e406e2f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bolt.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/book-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/book-open.svg new file mode 100644 index 0000000..442db26 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/book-open.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark-slash.svg new file mode 100644 index 0000000..b0b62d1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark-square.svg new file mode 100644 index 0000000..8c4a3c8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark.svg new file mode 100644 index 0000000..9a8282a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/briefcase.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/briefcase.svg new file mode 100644 index 0000000..96e74f1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/briefcase.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bug-ant.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bug-ant.svg new file mode 100644 index 0000000..bc7e73f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bug-ant.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-library.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-library.svg new file mode 100644 index 0000000..496facf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-library.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-office-2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-office-2.svg new file mode 100644 index 0000000..acd4621 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-office-2.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-office.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-office.svg new file mode 100644 index 0000000..691dce8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-office.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-storefront.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-storefront.svg new file mode 100644 index 0000000..fc9c14b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-storefront.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cake.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cake.svg new file mode 100644 index 0000000..012026a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cake.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calculator.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calculator.svg new file mode 100644 index 0000000..a3c3420 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calculator.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar-date-range.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar-date-range.svg new file mode 100644 index 0000000..3a086f8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar-date-range.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar-days.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar-days.svg new file mode 100644 index 0000000..16c9890 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar-days.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar.svg new file mode 100644 index 0000000..d8468cb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/camera.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/camera.svg new file mode 100644 index 0000000..0429f04 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/camera.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-bar-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-bar-square.svg new file mode 100644 index 0000000..7e56212 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-bar-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-bar.svg new file mode 100644 index 0000000..da7e5d0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-bar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-pie.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-pie.svg new file mode 100644 index 0000000..8d17490 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-pie.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-bottom-center-text.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-bottom-center-text.svg new file mode 100644 index 0000000..549eb6e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-bottom-center-text.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-bottom-center.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-bottom-center.svg new file mode 100644 index 0000000..0f5f609 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-bottom-center.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left-ellipsis.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left-ellipsis.svg new file mode 100644 index 0000000..b7f2b84 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left-ellipsis.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left-right.svg new file mode 100644 index 0000000..d99e13b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left-right.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left.svg new file mode 100644 index 0000000..5c9b47f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-oval-left-ellipsis.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-oval-left-ellipsis.svg new file mode 100644 index 0000000..a61f6bd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-oval-left-ellipsis.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-oval-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-oval-left.svg new file mode 100644 index 0000000..2cc7ce5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-oval-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check-badge.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check-badge.svg new file mode 100644 index 0000000..cb96b01 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check-badge.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check-circle.svg new file mode 100644 index 0000000..5beeb13 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check.svg new file mode 100644 index 0000000..0b7e9b3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-down.svg new file mode 100644 index 0000000..9d659c3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-left.svg new file mode 100644 index 0000000..3194c3f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-right.svg new file mode 100644 index 0000000..86a963a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-up.svg new file mode 100644 index 0000000..bcf7619 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-down.svg new file mode 100644 index 0000000..f734a0c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-left.svg new file mode 100644 index 0000000..f293041 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-right.svg new file mode 100644 index 0000000..3a08c80 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-up-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-up-down.svg new file mode 100644 index 0000000..db44522 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-up-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-up.svg new file mode 100644 index 0000000..58573a7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/circle-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/circle-stack.svg new file mode 100644 index 0000000..3da8275 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/circle-stack.svg @@ -0,0 +1,5 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document-check.svg new file mode 100644 index 0000000..b8909e7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document-check.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document-list.svg new file mode 100644 index 0000000..355dec2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document-list.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document.svg new file mode 100644 index 0000000..6e09983 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard.svg new file mode 100644 index 0000000..2447452 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clock.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clock.svg new file mode 100644 index 0000000..b808f55 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clock.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud-arrow-down.svg new file mode 100644 index 0000000..478173d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud-arrow-up.svg new file mode 100644 index 0000000..fcd0c96 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud-arrow-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud.svg new file mode 100644 index 0000000..98a40cf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/code-bracket-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/code-bracket-square.svg new file mode 100644 index 0000000..9d59c9a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/code-bracket-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/code-bracket.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/code-bracket.svg new file mode 100644 index 0000000..48b64b1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/code-bracket.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog-6-tooth.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog-6-tooth.svg new file mode 100644 index 0000000..67d34e6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog-6-tooth.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog-8-tooth.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog-8-tooth.svg new file mode 100644 index 0000000..6d92cde --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog-8-tooth.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog.svg new file mode 100644 index 0000000..4a7ef24 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/command-line.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/command-line.svg new file mode 100644 index 0000000..aa4c164 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/command-line.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/computer-desktop.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/computer-desktop.svg new file mode 100644 index 0000000..56f18f7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/computer-desktop.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cpu-chip.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cpu-chip.svg new file mode 100644 index 0000000..ee84795 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cpu-chip.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/credit-card.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/credit-card.svg new file mode 100644 index 0000000..d55fcd3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/credit-card.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cube-transparent.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cube-transparent.svg new file mode 100644 index 0000000..b3af3d6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cube-transparent.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cube.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cube.svg new file mode 100644 index 0000000..a3c841d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cube.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-bangladeshi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-bangladeshi.svg new file mode 100644 index 0000000..c9b4056 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-bangladeshi.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-dollar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-dollar.svg new file mode 100644 index 0000000..1d4c4ac --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-dollar.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-euro.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-euro.svg new file mode 100644 index 0000000..88b62da --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-euro.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-pound.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-pound.svg new file mode 100644 index 0000000..232af6e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-pound.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-rupee.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-rupee.svg new file mode 100644 index 0000000..f79b4e7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-rupee.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-yen.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-yen.svg new file mode 100644 index 0000000..e234e5f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-yen.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cursor-arrow-rays.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cursor-arrow-rays.svg new file mode 100644 index 0000000..ff9f3d5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cursor-arrow-rays.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cursor-arrow-ripple.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cursor-arrow-ripple.svg new file mode 100644 index 0000000..8ce389b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cursor-arrow-ripple.svg @@ -0,0 +1,5 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/device-phone-mobile.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/device-phone-mobile.svg new file mode 100644 index 0000000..99440e4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/device-phone-mobile.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/device-tablet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/device-tablet.svg new file mode 100644 index 0000000..79cf2bf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/device-tablet.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/divide.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/divide.svg new file mode 100644 index 0000000..09ae988 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/divide.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-arrow-down.svg new file mode 100644 index 0000000..3f24c1f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-arrow-up.svg new file mode 100644 index 0000000..b92118e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-arrow-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-chart-bar.svg new file mode 100644 index 0000000..0a0dc7c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-chart-bar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-check.svg new file mode 100644 index 0000000..9867eab --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-check.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-bangladeshi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-bangladeshi.svg new file mode 100644 index 0000000..d14b959 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-bangladeshi.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-dollar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-dollar.svg new file mode 100644 index 0000000..27936ba --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-dollar.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-euro.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-euro.svg new file mode 100644 index 0000000..622ab9b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-euro.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-pound.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-pound.svg new file mode 100644 index 0000000..7b4b079 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-pound.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-rupee.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-rupee.svg new file mode 100644 index 0000000..b4c50e7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-rupee.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-yen.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-yen.svg new file mode 100644 index 0000000..55e94ff --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-yen.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-duplicate.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-duplicate.svg new file mode 100644 index 0000000..faa81c5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-duplicate.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-magnifying-glass.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-magnifying-glass.svg new file mode 100644 index 0000000..e2c3f4b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-magnifying-glass.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-minus.svg new file mode 100644 index 0000000..0c0c5e0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-minus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-plus.svg new file mode 100644 index 0000000..2b9da44 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-text.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-text.svg new file mode 100644 index 0000000..14210f6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-text.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document.svg new file mode 100644 index 0000000..9f81766 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-horizontal-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-horizontal-circle.svg new file mode 100644 index 0000000..3002b9c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-horizontal-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-horizontal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-horizontal.svg new file mode 100644 index 0000000..6359416 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-horizontal.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-vertical.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-vertical.svg new file mode 100644 index 0000000..937c4c6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-vertical.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/envelope-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/envelope-open.svg new file mode 100644 index 0000000..648144c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/envelope-open.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/envelope.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/envelope.svg new file mode 100644 index 0000000..5f3d4fe --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/envelope.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/equals.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/equals.svg new file mode 100644 index 0000000..7dd31a9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/equals.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/exclamation-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/exclamation-circle.svg new file mode 100644 index 0000000..094c29f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/exclamation-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/exclamation-triangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/exclamation-triangle.svg new file mode 100644 index 0000000..b841436 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/exclamation-triangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye-dropper.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye-dropper.svg new file mode 100644 index 0000000..2807f23 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye-dropper.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye-slash.svg new file mode 100644 index 0000000..61957b3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye-slash.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye.svg new file mode 100644 index 0000000..0e0f914 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/face-frown.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/face-frown.svg new file mode 100644 index 0000000..e3ee54e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/face-frown.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/face-smile.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/face-smile.svg new file mode 100644 index 0000000..e36ddb7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/face-smile.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/film.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/film.svg new file mode 100644 index 0000000..d1bc36d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/film.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/finger-print.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/finger-print.svg new file mode 100644 index 0000000..f4f3340 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/finger-print.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/fire.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/fire.svg new file mode 100644 index 0000000..7556d5d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/fire.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/flag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/flag.svg new file mode 100644 index 0000000..ef6658e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/flag.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-arrow-down.svg new file mode 100644 index 0000000..6c25eea --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-minus.svg new file mode 100644 index 0000000..e9c9a04 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-minus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-open.svg new file mode 100644 index 0000000..3a36b57 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-open.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-plus.svg new file mode 100644 index 0000000..e76be0e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder.svg new file mode 100644 index 0000000..5b595b6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/forward.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/forward.svg new file mode 100644 index 0000000..1cf6daf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/forward.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/funnel.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/funnel.svg new file mode 100644 index 0000000..be9bae4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/funnel.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gif.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gif.svg new file mode 100644 index 0000000..96f6176 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gif.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gift-top.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gift-top.svg new file mode 100644 index 0000000..4ac9ece --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gift-top.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gift.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gift.svg new file mode 100644 index 0000000..cffe93d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gift.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-alt.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-alt.svg new file mode 100644 index 0000000..43d1dcc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-alt.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-americas.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-americas.svg new file mode 100644 index 0000000..a630940 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-americas.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-asia-australia.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-asia-australia.svg new file mode 100644 index 0000000..4c073cf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-asia-australia.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-europe-africa.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-europe-africa.svg new file mode 100644 index 0000000..fc4644a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-europe-africa.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h1.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h1.svg new file mode 100644 index 0000000..76f06cd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h1.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h2.svg new file mode 100644 index 0000000..0d8ba3e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h2.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h3.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h3.svg new file mode 100644 index 0000000..2ba4d4a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h3.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-raised.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-raised.svg new file mode 100644 index 0000000..de3a1a1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-raised.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-thumb-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-thumb-down.svg new file mode 100644 index 0000000..2cca438 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-thumb-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-thumb-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-thumb-up.svg new file mode 100644 index 0000000..32c6f96 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-thumb-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hashtag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hashtag.svg new file mode 100644 index 0000000..dc28035 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hashtag.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/heart.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/heart.svg new file mode 100644 index 0000000..b23191f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/heart.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/home-modern.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/home-modern.svg new file mode 100644 index 0000000..66948b1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/home-modern.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/home.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/home.svg new file mode 100644 index 0000000..60b4496 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/home.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/identification.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/identification.svg new file mode 100644 index 0000000..8e831db --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/identification.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox-arrow-down.svg new file mode 100644 index 0000000..c6bca81 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox-arrow-down.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox-stack.svg new file mode 100644 index 0000000..8bc16a5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox-stack.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox.svg new file mode 100644 index 0000000..4ae995f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/information-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/information-circle.svg new file mode 100644 index 0000000..a18abab --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/information-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/italic.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/italic.svg new file mode 100644 index 0000000..23698b5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/italic.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/key.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/key.svg new file mode 100644 index 0000000..dd7e56f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/key.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/language.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/language.svg new file mode 100644 index 0000000..e7e0cfc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/language.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lifebuoy.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lifebuoy.svg new file mode 100644 index 0000000..e0c7a1f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lifebuoy.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/light-bulb.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/light-bulb.svg new file mode 100644 index 0000000..1006288 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/light-bulb.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/link-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/link-slash.svg new file mode 100644 index 0000000..5da53cf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/link-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/link.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/link.svg new file mode 100644 index 0000000..35884ce --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/link.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/list-bullet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/list-bullet.svg new file mode 100644 index 0000000..da9f504 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/list-bullet.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lock-closed.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lock-closed.svg new file mode 100644 index 0000000..31bd407 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lock-closed.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lock-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lock-open.svg new file mode 100644 index 0000000..99b63e7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lock-open.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-circle.svg new file mode 100644 index 0000000..54c1fd3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-circle.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-minus.svg new file mode 100644 index 0000000..1cf27b5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-minus.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-plus.svg new file mode 100644 index 0000000..ab075d6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-plus.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass.svg new file mode 100644 index 0000000..2e4c54d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/map-pin.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/map-pin.svg new file mode 100644 index 0000000..c4f17e0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/map-pin.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/map.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/map.svg new file mode 100644 index 0000000..ce2931c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/map.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/megaphone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/megaphone.svg new file mode 100644 index 0000000..109b396 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/megaphone.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/microphone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/microphone.svg new file mode 100644 index 0000000..8d6e8f4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/microphone.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/minus-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/minus-circle.svg new file mode 100644 index 0000000..f3572df --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/minus-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/minus.svg new file mode 100644 index 0000000..f91443c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/minus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/moon.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/moon.svg new file mode 100644 index 0000000..799b15b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/moon.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/musical-note.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/musical-note.svg new file mode 100644 index 0000000..0a08598 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/musical-note.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/newspaper.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/newspaper.svg new file mode 100644 index 0000000..4d66d46 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/newspaper.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/no-symbol.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/no-symbol.svg new file mode 100644 index 0000000..dd92576 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/no-symbol.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/numbered-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/numbered-list.svg new file mode 100644 index 0000000..f00bbd6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/numbered-list.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paint-brush.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paint-brush.svg new file mode 100644 index 0000000..0aebe49 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paint-brush.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paper-airplane.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paper-airplane.svg new file mode 100644 index 0000000..5cb9fe3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paper-airplane.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paper-clip.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paper-clip.svg new file mode 100644 index 0000000..1a019c3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paper-clip.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pause-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pause-circle.svg new file mode 100644 index 0000000..ec93c1d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pause-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pause.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pause.svg new file mode 100644 index 0000000..55ea47d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pause.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pencil-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pencil-square.svg new file mode 100644 index 0000000..25c6e94 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pencil-square.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pencil.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pencil.svg new file mode 100644 index 0000000..2bc22ee --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pencil.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/percent-badge.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/percent-badge.svg new file mode 100644 index 0000000..1cd08c8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/percent-badge.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-arrow-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-arrow-down-left.svg new file mode 100644 index 0000000..592ed3d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-arrow-down-left.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-arrow-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-arrow-up-right.svg new file mode 100644 index 0000000..9f6896e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-arrow-up-right.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-x-mark.svg new file mode 100644 index 0000000..e8f1197 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-x-mark.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone.svg new file mode 100644 index 0000000..b6808fb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/photo.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/photo.svg new file mode 100644 index 0000000..fb02a1c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/photo.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play-circle.svg new file mode 100644 index 0000000..0944fc6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play-pause.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play-pause.svg new file mode 100644 index 0000000..5cc07e0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play-pause.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play.svg new file mode 100644 index 0000000..a348d36 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/plus-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/plus-circle.svg new file mode 100644 index 0000000..a877442 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/plus-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/plus.svg new file mode 100644 index 0000000..c650b5c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/power.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/power.svg new file mode 100644 index 0000000..77dfe41 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/power.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/presentation-chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/presentation-chart-bar.svg new file mode 100644 index 0000000..120aaaf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/presentation-chart-bar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/presentation-chart-line.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/presentation-chart-line.svg new file mode 100644 index 0000000..72f5720 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/presentation-chart-line.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/printer.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/printer.svg new file mode 100644 index 0000000..bf28802 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/printer.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/puzzle-piece.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/puzzle-piece.svg new file mode 100644 index 0000000..0f01af8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/puzzle-piece.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/qr-code.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/qr-code.svg new file mode 100644 index 0000000..4825d13 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/qr-code.svg @@ -0,0 +1,9 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/question-mark-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/question-mark-circle.svg new file mode 100644 index 0000000..34d8027 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/question-mark-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/queue-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/queue-list.svg new file mode 100644 index 0000000..c30fec3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/queue-list.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/radio.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/radio.svg new file mode 100644 index 0000000..273ae11 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/radio.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/receipt-percent.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/receipt-percent.svg new file mode 100644 index 0000000..3212a6f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/receipt-percent.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/receipt-refund.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/receipt-refund.svg new file mode 100644 index 0000000..94d0f22 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/receipt-refund.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rectangle-group.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rectangle-group.svg new file mode 100644 index 0000000..4898a0c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rectangle-group.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rectangle-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rectangle-stack.svg new file mode 100644 index 0000000..991c633 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rectangle-stack.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rocket-launch.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rocket-launch.svg new file mode 100644 index 0000000..f950c24 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rocket-launch.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rss.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rss.svg new file mode 100644 index 0000000..a148475 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rss.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/scale.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/scale.svg new file mode 100644 index 0000000..928d3b8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/scale.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/scissors.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/scissors.svg new file mode 100644 index 0000000..fcf6e9b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/scissors.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/server-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/server-stack.svg new file mode 100644 index 0000000..420066c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/server-stack.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/server.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/server.svg new file mode 100644 index 0000000..13f2ae4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/server.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/share.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/share.svg new file mode 100644 index 0000000..59671fb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/share.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shield-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shield-check.svg new file mode 100644 index 0000000..6e93ee4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shield-check.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shield-exclamation.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shield-exclamation.svg new file mode 100644 index 0000000..c1e135f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shield-exclamation.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shopping-bag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shopping-bag.svg new file mode 100644 index 0000000..5cf1a26 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shopping-bag.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shopping-cart.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shopping-cart.svg new file mode 100644 index 0000000..7afdf47 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shopping-cart.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/signal-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/signal-slash.svg new file mode 100644 index 0000000..b34500c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/signal-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/signal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/signal.svg new file mode 100644 index 0000000..9fa80dc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/signal.svg @@ -0,0 +1,5 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/slash.svg new file mode 100644 index 0000000..a2e3c14 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/sparkles.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/sparkles.svg new file mode 100644 index 0000000..fa542ae --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/sparkles.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/speaker-wave.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/speaker-wave.svg new file mode 100644 index 0000000..cf8780e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/speaker-wave.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/speaker-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/speaker-x-mark.svg new file mode 100644 index 0000000..be22558 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/speaker-x-mark.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/square-2-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/square-2-stack.svg new file mode 100644 index 0000000..328a997 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/square-2-stack.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/square-3-stack-3d.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/square-3-stack-3d.svg new file mode 100644 index 0000000..6520941 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/square-3-stack-3d.svg @@ -0,0 +1,5 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/squares-2x2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/squares-2x2.svg new file mode 100644 index 0000000..375f706 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/squares-2x2.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/squares-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/squares-plus.svg new file mode 100644 index 0000000..8b6e1c2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/squares-plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/star.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/star.svg new file mode 100644 index 0000000..c585d24 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/star.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/stop-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/stop-circle.svg new file mode 100644 index 0000000..767eb26 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/stop-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/stop.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/stop.svg new file mode 100644 index 0000000..e90d578 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/stop.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/strikethrough.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/strikethrough.svg new file mode 100644 index 0000000..69c3e5e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/strikethrough.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/sun.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/sun.svg new file mode 100644 index 0000000..f08a75c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/sun.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/swatch.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/swatch.svg new file mode 100644 index 0000000..91d53c8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/swatch.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/table-cells.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/table-cells.svg new file mode 100644 index 0000000..fa33c82 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/table-cells.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/tag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/tag.svg new file mode 100644 index 0000000..e8fa33f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/tag.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ticket.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ticket.svg new file mode 100644 index 0000000..1636997 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ticket.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/trash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/trash.svg new file mode 100644 index 0000000..242d02e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/trash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/trophy.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/trophy.svg new file mode 100644 index 0000000..4ce0044 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/trophy.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/truck.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/truck.svg new file mode 100644 index 0000000..cf8b2ae --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/truck.svg @@ -0,0 +1,5 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/tv.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/tv.svg new file mode 100644 index 0000000..71a03f1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/tv.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/underline.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/underline.svg new file mode 100644 index 0000000..8ce4f8a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/underline.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-circle.svg new file mode 100644 index 0000000..a4828ea --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-group.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-group.svg new file mode 100644 index 0000000..b3eac77 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-group.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-minus.svg new file mode 100644 index 0000000..a9222c1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-minus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-plus.svg new file mode 100644 index 0000000..dbfb380 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user.svg new file mode 100644 index 0000000..3b924bd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/users.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/users.svg new file mode 100644 index 0000000..51b4585 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/users.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/variable.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/variable.svg new file mode 100644 index 0000000..025ef98 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/variable.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/video-camera-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/video-camera-slash.svg new file mode 100644 index 0000000..7206f3c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/video-camera-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/video-camera.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/video-camera.svg new file mode 100644 index 0000000..91ffb60 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/video-camera.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/view-columns.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/view-columns.svg new file mode 100644 index 0000000..a953a60 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/view-columns.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/viewfinder-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/viewfinder-circle.svg new file mode 100644 index 0000000..da6fbe4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/viewfinder-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wallet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wallet.svg new file mode 100644 index 0000000..5198eee --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wallet.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wifi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wifi.svg new file mode 100644 index 0000000..bcd9226 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wifi.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/window.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/window.svg new file mode 100644 index 0000000..ecb5a8e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/window.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wrench-screwdriver.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wrench-screwdriver.svg new file mode 100644 index 0000000..89a99de --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wrench-screwdriver.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wrench.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wrench.svg new file mode 100644 index 0000000..552ef3d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wrench.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/x-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/x-circle.svg new file mode 100644 index 0000000..8050905 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/x-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/x-mark.svg new file mode 100644 index 0000000..dd7ae72 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/x-mark.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/academic-cap.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/academic-cap.svg new file mode 100644 index 0000000..28d31d1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/academic-cap.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/adjustments-horizontal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/adjustments-horizontal.svg new file mode 100644 index 0000000..3e5e2c9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/adjustments-horizontal.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/adjustments-vertical.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/adjustments-vertical.svg new file mode 100644 index 0000000..afefea9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/adjustments-vertical.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box-arrow-down.svg new file mode 100644 index 0000000..86f45bd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box-x-mark.svg new file mode 100644 index 0000000..3a48526 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box-x-mark.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box.svg new file mode 100644 index 0000000..7b80ec2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-circle.svg new file mode 100644 index 0000000..2676c66 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-left.svg new file mode 100644 index 0000000..710c80a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-on-square-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-on-square-stack.svg new file mode 100644 index 0000000..3f4bf61 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-on-square-stack.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-on-square.svg new file mode 100644 index 0000000..9c0621f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-on-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-right.svg new file mode 100644 index 0000000..3ed2487 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-tray.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-tray.svg new file mode 100644 index 0000000..312dc38 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-tray.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down.svg new file mode 100644 index 0000000..8442536 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-circle.svg new file mode 100644 index 0000000..52b3de6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-end-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-end-on-rectangle.svg new file mode 100644 index 0000000..81912ac --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-end-on-rectangle.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-on-rectangle.svg new file mode 100644 index 0000000..81912ac --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-on-rectangle.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-start-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-start-on-rectangle.svg new file mode 100644 index 0000000..0bdc78f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-start-on-rectangle.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left.svg new file mode 100644 index 0000000..f087dba --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-down.svg new file mode 100644 index 0000000..387286b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-left.svg new file mode 100644 index 0000000..6377071 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-right.svg new file mode 100644 index 0000000..82fe6cf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-up.svg new file mode 100644 index 0000000..61f2ba1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-path-rounded-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-path-rounded-square.svg new file mode 100644 index 0000000..47cf252 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-path-rounded-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-path.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-path.svg new file mode 100644 index 0000000..8090942 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-path.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-circle.svg new file mode 100644 index 0000000..fa2082b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-end-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-end-on-rectangle.svg new file mode 100644 index 0000000..99c4be5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-end-on-rectangle.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-on-rectangle.svg new file mode 100644 index 0000000..7e69255 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-on-rectangle.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-start-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-start-on-rectangle.svg new file mode 100644 index 0000000..7e69255 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-start-on-rectangle.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right.svg new file mode 100644 index 0000000..3415f36 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-down.svg new file mode 100644 index 0000000..f8a8957 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-left.svg new file mode 100644 index 0000000..55aae95 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-right.svg new file mode 100644 index 0000000..32ed766 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-up.svg new file mode 100644 index 0000000..f60e86b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-top-right-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-top-right-on-square.svg new file mode 100644 index 0000000..4216598 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-top-right-on-square.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-trending-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-trending-down.svg new file mode 100644 index 0000000..ae6d9cd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-trending-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-trending-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-trending-up.svg new file mode 100644 index 0000000..f5aa7b9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-trending-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-down-left.svg new file mode 100644 index 0000000..c4518df --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-down-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-down-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-down-right.svg new file mode 100644 index 0000000..eab8272 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-down-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-left-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-left-down.svg new file mode 100644 index 0000000..b97738d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-left-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-left-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-left-up.svg new file mode 100644 index 0000000..6b69325 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-left-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-right-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-right-down.svg new file mode 100644 index 0000000..1f455a7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-right-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-right-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-right-up.svg new file mode 100644 index 0000000..d8658ef --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-right-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-up-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-up-left.svg new file mode 100644 index 0000000..7c7775e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-up-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-up-right.svg new file mode 100644 index 0000000..6719f9f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-up-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-circle.svg new file mode 100644 index 0000000..a07eca3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-left.svg new file mode 100644 index 0000000..84d4ab9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-on-square-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-on-square-stack.svg new file mode 100644 index 0000000..c85ce24 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-on-square-stack.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-on-square.svg new file mode 100644 index 0000000..3a54f70 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-on-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-right.svg new file mode 100644 index 0000000..5f58b69 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-tray.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-tray.svg new file mode 100644 index 0000000..a57986a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-tray.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up.svg new file mode 100644 index 0000000..1a1ca8f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-down.svg new file mode 100644 index 0000000..6c88620 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-left.svg new file mode 100644 index 0000000..c3f666d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-right.svg new file mode 100644 index 0000000..0a8b31a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-up.svg new file mode 100644 index 0000000..f79c4bc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-pointing-in.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-pointing-in.svg new file mode 100644 index 0000000..e300a28 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-pointing-in.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-pointing-out.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-pointing-out.svg new file mode 100644 index 0000000..7f58167 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-pointing-out.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-right-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-right-left.svg new file mode 100644 index 0000000..cef196e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-right-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-up-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-up-down.svg new file mode 100644 index 0000000..43927b7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-up-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/at-symbol.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/at-symbol.svg new file mode 100644 index 0000000..5a7511a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/at-symbol.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/backspace.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/backspace.svg new file mode 100644 index 0000000..b95a6b7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/backspace.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/backward.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/backward.svg new file mode 100644 index 0000000..a8879ec --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/backward.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/banknotes.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/banknotes.svg new file mode 100644 index 0000000..e0b86f9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/banknotes.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-2.svg new file mode 100644 index 0000000..bdf38a3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-2.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-bottom-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-bottom-left.svg new file mode 100644 index 0000000..943bdac --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-bottom-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-bottom-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-bottom-right.svg new file mode 100644 index 0000000..4385318 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-bottom-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-center-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-center-left.svg new file mode 100644 index 0000000..f33da66 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-center-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3.svg new file mode 100644 index 0000000..7ebe49c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-4.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-4.svg new file mode 100644 index 0000000..fa63380 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-4.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-arrow-down.svg new file mode 100644 index 0000000..7d1bcd7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-arrow-up.svg new file mode 100644 index 0000000..dbba841 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-arrow-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-0.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-0.svg new file mode 100644 index 0000000..86323f9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-0.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-100.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-100.svg new file mode 100644 index 0000000..7d15706 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-100.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-50.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-50.svg new file mode 100644 index 0000000..b88ade1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-50.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/beaker.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/beaker.svg new file mode 100644 index 0000000..6347163 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/beaker.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-alert.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-alert.svg new file mode 100644 index 0000000..ea50a7b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-alert.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-slash.svg new file mode 100644 index 0000000..f7107f3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-snooze.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-snooze.svg new file mode 100644 index 0000000..06daf77 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-snooze.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell.svg new file mode 100644 index 0000000..4b55d26 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bold.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bold.svg new file mode 100644 index 0000000..b3f6425 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bold.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bolt-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bolt-slash.svg new file mode 100644 index 0000000..ba8d113 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bolt-slash.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bolt.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bolt.svg new file mode 100644 index 0000000..c2af3c1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bolt.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/book-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/book-open.svg new file mode 100644 index 0000000..5dc4839 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/book-open.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark-slash.svg new file mode 100644 index 0000000..2518392 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark-square.svg new file mode 100644 index 0000000..8aa84fb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark.svg new file mode 100644 index 0000000..237b0dc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/briefcase.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/briefcase.svg new file mode 100644 index 0000000..8487636 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/briefcase.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bug-ant.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bug-ant.svg new file mode 100644 index 0000000..81190d2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bug-ant.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-library.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-library.svg new file mode 100644 index 0000000..a0439c6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-library.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-office-2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-office-2.svg new file mode 100644 index 0000000..c4b4a9b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-office-2.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-office.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-office.svg new file mode 100644 index 0000000..2907c7b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-office.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-storefront.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-storefront.svg new file mode 100644 index 0000000..6a5be94 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-storefront.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cake.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cake.svg new file mode 100644 index 0000000..bd0ac56 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cake.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calculator.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calculator.svg new file mode 100644 index 0000000..374cfdf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calculator.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar-date-range.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar-date-range.svg new file mode 100644 index 0000000..8ec6d93 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar-date-range.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar-days.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar-days.svg new file mode 100644 index 0000000..a0ed5eb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar-days.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar.svg new file mode 100644 index 0000000..52e5752 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/camera.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/camera.svg new file mode 100644 index 0000000..418456d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/camera.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-bar-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-bar-square.svg new file mode 100644 index 0000000..d34b223 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-bar-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-bar.svg new file mode 100644 index 0000000..45a9866 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-bar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-pie.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-pie.svg new file mode 100644 index 0000000..f00fdeb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-pie.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-bottom-center-text.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-bottom-center-text.svg new file mode 100644 index 0000000..98e8c48 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-bottom-center-text.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-bottom-center.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-bottom-center.svg new file mode 100644 index 0000000..8b1f45d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-bottom-center.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left-ellipsis.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left-ellipsis.svg new file mode 100644 index 0000000..f447448 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left-ellipsis.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left-right.svg new file mode 100644 index 0000000..089463b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left-right.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left.svg new file mode 100644 index 0000000..fc1c62e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-oval-left-ellipsis.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-oval-left-ellipsis.svg new file mode 100644 index 0000000..59f7d8e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-oval-left-ellipsis.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-oval-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-oval-left.svg new file mode 100644 index 0000000..97beedf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-oval-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check-badge.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check-badge.svg new file mode 100644 index 0000000..6a79b88 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check-badge.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check-circle.svg new file mode 100644 index 0000000..763716e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check.svg new file mode 100644 index 0000000..c29bd3f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-down.svg new file mode 100644 index 0000000..bd4f869 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-left.svg new file mode 100644 index 0000000..60babe1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-right.svg new file mode 100644 index 0000000..7fe528c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-up.svg new file mode 100644 index 0000000..8ce111d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-down.svg new file mode 100644 index 0000000..18cbc1d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-left.svg new file mode 100644 index 0000000..b6e87d1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-right.svg new file mode 100644 index 0000000..e9eb2c4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-up-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-up-down.svg new file mode 100644 index 0000000..64964bf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-up-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-up.svg new file mode 100644 index 0000000..b6cd4db --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/circle-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/circle-stack.svg new file mode 100644 index 0000000..2133753 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/circle-stack.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document-check.svg new file mode 100644 index 0000000..38fb967 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document-check.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document-list.svg new file mode 100644 index 0000000..184daa0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document-list.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document.svg new file mode 100644 index 0000000..de69a12 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard.svg new file mode 100644 index 0000000..e08430a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clock.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clock.svg new file mode 100644 index 0000000..e8349dc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clock.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud-arrow-down.svg new file mode 100644 index 0000000..e5088ca --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud-arrow-up.svg new file mode 100644 index 0000000..e63c3c2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud-arrow-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud.svg new file mode 100644 index 0000000..c47c4f4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/code-bracket-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/code-bracket-square.svg new file mode 100644 index 0000000..5e6a0c4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/code-bracket-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/code-bracket.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/code-bracket.svg new file mode 100644 index 0000000..5b42af4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/code-bracket.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog-6-tooth.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog-6-tooth.svg new file mode 100644 index 0000000..8eae336 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog-6-tooth.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog-8-tooth.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog-8-tooth.svg new file mode 100644 index 0000000..81c94a5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog-8-tooth.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog.svg new file mode 100644 index 0000000..55b19d4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/command-line.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/command-line.svg new file mode 100644 index 0000000..93e35fc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/command-line.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/computer-desktop.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/computer-desktop.svg new file mode 100644 index 0000000..3c0fe1c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/computer-desktop.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cpu-chip.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cpu-chip.svg new file mode 100644 index 0000000..95adf0e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cpu-chip.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/credit-card.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/credit-card.svg new file mode 100644 index 0000000..dbfafef --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/credit-card.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cube-transparent.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cube-transparent.svg new file mode 100644 index 0000000..600d2e3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cube-transparent.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cube.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cube.svg new file mode 100644 index 0000000..75f3644 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cube.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-bangladeshi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-bangladeshi.svg new file mode 100644 index 0000000..187d75c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-bangladeshi.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-dollar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-dollar.svg new file mode 100644 index 0000000..e329589 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-dollar.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-euro.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-euro.svg new file mode 100644 index 0000000..64e2a32 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-euro.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-pound.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-pound.svg new file mode 100644 index 0000000..480e7b6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-pound.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-rupee.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-rupee.svg new file mode 100644 index 0000000..43a9b8c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-rupee.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-yen.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-yen.svg new file mode 100644 index 0000000..311006d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-yen.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cursor-arrow-rays.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cursor-arrow-rays.svg new file mode 100644 index 0000000..18f5dc8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cursor-arrow-rays.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cursor-arrow-ripple.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cursor-arrow-ripple.svg new file mode 100644 index 0000000..afbd741 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cursor-arrow-ripple.svg @@ -0,0 +1,5 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/device-phone-mobile.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/device-phone-mobile.svg new file mode 100644 index 0000000..7261026 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/device-phone-mobile.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/device-tablet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/device-tablet.svg new file mode 100644 index 0000000..c2b14bd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/device-tablet.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/divide.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/divide.svg new file mode 100644 index 0000000..1567b7c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/divide.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-arrow-down.svg new file mode 100644 index 0000000..04820b7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-arrow-up.svg new file mode 100644 index 0000000..b70468d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-arrow-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-chart-bar.svg new file mode 100644 index 0000000..48c91e9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-chart-bar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-check.svg new file mode 100644 index 0000000..922f600 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-check.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-bangladeshi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-bangladeshi.svg new file mode 100644 index 0000000..5dfa06e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-bangladeshi.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-dollar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-dollar.svg new file mode 100644 index 0000000..3cc4509 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-dollar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-euro.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-euro.svg new file mode 100644 index 0000000..455d22c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-euro.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-pound.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-pound.svg new file mode 100644 index 0000000..4925373 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-pound.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-rupee.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-rupee.svg new file mode 100644 index 0000000..533bccc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-rupee.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-yen.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-yen.svg new file mode 100644 index 0000000..a0c9ee4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-yen.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-duplicate.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-duplicate.svg new file mode 100644 index 0000000..5b52546 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-duplicate.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-magnifying-glass.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-magnifying-glass.svg new file mode 100644 index 0000000..0cfcadb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-magnifying-glass.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-minus.svg new file mode 100644 index 0000000..207fe75 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-minus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-plus.svg new file mode 100644 index 0000000..a59bca9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-text.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-text.svg new file mode 100644 index 0000000..11c383c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-text.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document.svg new file mode 100644 index 0000000..7631542 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-horizontal-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-horizontal-circle.svg new file mode 100644 index 0000000..4ed81ac --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-horizontal-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-horizontal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-horizontal.svg new file mode 100644 index 0000000..e0b281b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-horizontal.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-vertical.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-vertical.svg new file mode 100644 index 0000000..1f7b5a9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-vertical.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/envelope-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/envelope-open.svg new file mode 100644 index 0000000..871fc80 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/envelope-open.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/envelope.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/envelope.svg new file mode 100644 index 0000000..e0ef01b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/envelope.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/equals.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/equals.svg new file mode 100644 index 0000000..8db5d85 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/equals.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/exclamation-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/exclamation-circle.svg new file mode 100644 index 0000000..3f12a44 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/exclamation-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/exclamation-triangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/exclamation-triangle.svg new file mode 100644 index 0000000..c331b5b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/exclamation-triangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye-dropper.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye-dropper.svg new file mode 100644 index 0000000..bf1b0f4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye-dropper.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye-slash.svg new file mode 100644 index 0000000..8ee3cb9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye-slash.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye.svg new file mode 100644 index 0000000..5ce8bcc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/face-frown.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/face-frown.svg new file mode 100644 index 0000000..dd9cd52 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/face-frown.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/face-smile.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/face-smile.svg new file mode 100644 index 0000000..0e8030c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/face-smile.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/film.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/film.svg new file mode 100644 index 0000000..2131ddd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/film.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/finger-print.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/finger-print.svg new file mode 100644 index 0000000..c6cdebc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/finger-print.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/fire.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/fire.svg new file mode 100644 index 0000000..e22ab93 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/fire.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/flag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/flag.svg new file mode 100644 index 0000000..b2a332a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/flag.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-arrow-down.svg new file mode 100644 index 0000000..67f62c9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-minus.svg new file mode 100644 index 0000000..abab6e9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-minus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-open.svg new file mode 100644 index 0000000..5f236ce --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-open.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-plus.svg new file mode 100644 index 0000000..db22ab9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder.svg new file mode 100644 index 0000000..a0fd97b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/forward.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/forward.svg new file mode 100644 index 0000000..e7755db --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/forward.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/funnel.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/funnel.svg new file mode 100644 index 0000000..86aaba0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/funnel.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gif.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gif.svg new file mode 100644 index 0000000..7df5ca5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gif.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gift-top.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gift-top.svg new file mode 100644 index 0000000..e0564c3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gift-top.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gift.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gift.svg new file mode 100644 index 0000000..f3e40f4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gift.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-alt.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-alt.svg new file mode 100644 index 0000000..bb9f5cc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-alt.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-americas.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-americas.svg new file mode 100644 index 0000000..2ae1103 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-americas.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-asia-australia.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-asia-australia.svg new file mode 100644 index 0000000..bb70e98 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-asia-australia.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-europe-africa.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-europe-africa.svg new file mode 100644 index 0000000..4584e07 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-europe-africa.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h1.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h1.svg new file mode 100644 index 0000000..c57bb96 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h1.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h2.svg new file mode 100644 index 0000000..3b1bf3a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h2.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h3.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h3.svg new file mode 100644 index 0000000..93945c7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h3.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-raised.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-raised.svg new file mode 100644 index 0000000..9657afe --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-raised.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-thumb-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-thumb-down.svg new file mode 100644 index 0000000..3476072 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-thumb-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-thumb-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-thumb-up.svg new file mode 100644 index 0000000..6ddf587 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-thumb-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hashtag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hashtag.svg new file mode 100644 index 0000000..befe101 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hashtag.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/heart.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/heart.svg new file mode 100644 index 0000000..4277e6f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/heart.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/home-modern.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/home-modern.svg new file mode 100644 index 0000000..ede9c1e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/home-modern.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/home.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/home.svg new file mode 100644 index 0000000..9f7580a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/home.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/identification.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/identification.svg new file mode 100644 index 0000000..9117f87 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/identification.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox-arrow-down.svg new file mode 100644 index 0000000..e44a2a5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox-arrow-down.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox-stack.svg new file mode 100644 index 0000000..067b085 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox-stack.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox.svg new file mode 100644 index 0000000..9340579 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/information-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/information-circle.svg new file mode 100644 index 0000000..d834a25 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/information-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/italic.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/italic.svg new file mode 100644 index 0000000..f68b929 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/italic.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/key.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/key.svg new file mode 100644 index 0000000..ed927ea --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/key.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/language.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/language.svg new file mode 100644 index 0000000..53ad839 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/language.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lifebuoy.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lifebuoy.svg new file mode 100644 index 0000000..e3799ad --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lifebuoy.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/light-bulb.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/light-bulb.svg new file mode 100644 index 0000000..728c000 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/light-bulb.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/link-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/link-slash.svg new file mode 100644 index 0000000..e974918 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/link-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/link.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/link.svg new file mode 100644 index 0000000..a40b5a5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/link.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/list-bullet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/list-bullet.svg new file mode 100644 index 0000000..0700722 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/list-bullet.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lock-closed.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lock-closed.svg new file mode 100644 index 0000000..28f1387 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lock-closed.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lock-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lock-open.svg new file mode 100644 index 0000000..3370496 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lock-open.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-circle.svg new file mode 100644 index 0000000..7c427ea --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-circle.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-minus.svg new file mode 100644 index 0000000..353b848 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-minus.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-plus.svg new file mode 100644 index 0000000..1dc4f5d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-plus.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass.svg new file mode 100644 index 0000000..8fede6f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/map-pin.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/map-pin.svg new file mode 100644 index 0000000..43719f1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/map-pin.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/map.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/map.svg new file mode 100644 index 0000000..ac7d6d3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/map.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/megaphone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/megaphone.svg new file mode 100644 index 0000000..0155e4b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/megaphone.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/microphone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/microphone.svg new file mode 100644 index 0000000..7f17f47 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/microphone.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus-circle.svg new file mode 100644 index 0000000..ad202c9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus-small.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus-small.svg new file mode 100644 index 0000000..a27d7cd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus-small.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus.svg new file mode 100644 index 0000000..8dee1f9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/moon.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/moon.svg new file mode 100644 index 0000000..e5e23ac --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/moon.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/musical-note.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/musical-note.svg new file mode 100644 index 0000000..cfc5cd0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/musical-note.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/newspaper.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/newspaper.svg new file mode 100644 index 0000000..a45e27c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/newspaper.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/no-symbol.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/no-symbol.svg new file mode 100644 index 0000000..238e1c4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/no-symbol.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/numbered-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/numbered-list.svg new file mode 100644 index 0000000..935dade --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/numbered-list.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paint-brush.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paint-brush.svg new file mode 100644 index 0000000..bcb054b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paint-brush.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paper-airplane.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paper-airplane.svg new file mode 100644 index 0000000..4a019d5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paper-airplane.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paper-clip.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paper-clip.svg new file mode 100644 index 0000000..3b48652 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paper-clip.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pause-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pause-circle.svg new file mode 100644 index 0000000..c2fc5be --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pause-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pause.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pause.svg new file mode 100644 index 0000000..ffa1bf7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pause.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pencil-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pencil-square.svg new file mode 100644 index 0000000..358fc12 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pencil-square.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pencil.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pencil.svg new file mode 100644 index 0000000..a6ac73f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pencil.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/percent-badge.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/percent-badge.svg new file mode 100644 index 0000000..32b3275 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/percent-badge.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-arrow-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-arrow-down-left.svg new file mode 100644 index 0000000..d79024f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-arrow-down-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-arrow-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-arrow-up-right.svg new file mode 100644 index 0000000..015c1fb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-arrow-up-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-x-mark.svg new file mode 100644 index 0000000..3326c34 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-x-mark.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone.svg new file mode 100644 index 0000000..e58c217 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/photo.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/photo.svg new file mode 100644 index 0000000..a6d92e3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/photo.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play-circle.svg new file mode 100644 index 0000000..0b3dac9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play-pause.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play-pause.svg new file mode 100644 index 0000000..0727d8c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play-pause.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play.svg new file mode 100644 index 0000000..9d635f6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus-circle.svg new file mode 100644 index 0000000..9468c6e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus-small.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus-small.svg new file mode 100644 index 0000000..9718082 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus-small.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus.svg new file mode 100644 index 0000000..6faf9aa --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/power.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/power.svg new file mode 100644 index 0000000..14ba4ac --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/power.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/presentation-chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/presentation-chart-bar.svg new file mode 100644 index 0000000..7362861 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/presentation-chart-bar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/presentation-chart-line.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/presentation-chart-line.svg new file mode 100644 index 0000000..282087f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/presentation-chart-line.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/printer.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/printer.svg new file mode 100644 index 0000000..a511616 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/printer.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/puzzle-piece.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/puzzle-piece.svg new file mode 100644 index 0000000..fcb7d8a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/puzzle-piece.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/qr-code.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/qr-code.svg new file mode 100644 index 0000000..008823c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/qr-code.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/question-mark-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/question-mark-circle.svg new file mode 100644 index 0000000..e4b1de1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/question-mark-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/queue-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/queue-list.svg new file mode 100644 index 0000000..3afe65b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/queue-list.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/radio.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/radio.svg new file mode 100644 index 0000000..6261d4e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/radio.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/receipt-percent.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/receipt-percent.svg new file mode 100644 index 0000000..100e23d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/receipt-percent.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/receipt-refund.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/receipt-refund.svg new file mode 100644 index 0000000..cc0ce43 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/receipt-refund.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rectangle-group.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rectangle-group.svg new file mode 100644 index 0000000..c4ce4f6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rectangle-group.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rectangle-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rectangle-stack.svg new file mode 100644 index 0000000..f49e339 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rectangle-stack.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rocket-launch.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rocket-launch.svg new file mode 100644 index 0000000..e30ac8b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rocket-launch.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rss.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rss.svg new file mode 100644 index 0000000..a99b8bc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rss.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/scale.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/scale.svg new file mode 100644 index 0000000..ba1a849 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/scale.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/scissors.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/scissors.svg new file mode 100644 index 0000000..eec0302 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/scissors.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/server-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/server-stack.svg new file mode 100644 index 0000000..d4aea4a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/server-stack.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/server.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/server.svg new file mode 100644 index 0000000..7bc532a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/server.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/share.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/share.svg new file mode 100644 index 0000000..291a7b0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/share.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shield-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shield-check.svg new file mode 100644 index 0000000..d55fc7e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shield-check.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shield-exclamation.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shield-exclamation.svg new file mode 100644 index 0000000..dfcc0f3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shield-exclamation.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shopping-bag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shopping-bag.svg new file mode 100644 index 0000000..3c706ab --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shopping-bag.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shopping-cart.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shopping-cart.svg new file mode 100644 index 0000000..7fc14d4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shopping-cart.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/signal-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/signal-slash.svg new file mode 100644 index 0000000..0096e64 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/signal-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/signal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/signal.svg new file mode 100644 index 0000000..63aec83 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/signal.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/slash.svg new file mode 100644 index 0000000..08416ce --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/sparkles.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/sparkles.svg new file mode 100644 index 0000000..1f280a0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/sparkles.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/speaker-wave.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/speaker-wave.svg new file mode 100644 index 0000000..53bce85 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/speaker-wave.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/speaker-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/speaker-x-mark.svg new file mode 100644 index 0000000..f6948af --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/speaker-x-mark.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/square-2-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/square-2-stack.svg new file mode 100644 index 0000000..35a3aa8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/square-2-stack.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/square-3-stack-3d.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/square-3-stack-3d.svg new file mode 100644 index 0000000..8302bd9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/square-3-stack-3d.svg @@ -0,0 +1,5 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/squares-2x2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/squares-2x2.svg new file mode 100644 index 0000000..d778a4d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/squares-2x2.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/squares-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/squares-plus.svg new file mode 100644 index 0000000..725df1d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/squares-plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/star.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/star.svg new file mode 100644 index 0000000..c6882d0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/star.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/stop-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/stop-circle.svg new file mode 100644 index 0000000..aac6789 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/stop-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/stop.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/stop.svg new file mode 100644 index 0000000..c4559c4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/stop.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/strikethrough.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/strikethrough.svg new file mode 100644 index 0000000..13b3a49 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/strikethrough.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/sun.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/sun.svg new file mode 100644 index 0000000..8d0e58f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/sun.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/swatch.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/swatch.svg new file mode 100644 index 0000000..7ac8dfd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/swatch.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/table-cells.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/table-cells.svg new file mode 100644 index 0000000..c041d12 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/table-cells.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/tag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/tag.svg new file mode 100644 index 0000000..02f502e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/tag.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ticket.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ticket.svg new file mode 100644 index 0000000..cea3ce7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ticket.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/trash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/trash.svg new file mode 100644 index 0000000..529224d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/trash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/trophy.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/trophy.svg new file mode 100644 index 0000000..c02f0d5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/trophy.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/truck.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/truck.svg new file mode 100644 index 0000000..bf12ef7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/truck.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/tv.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/tv.svg new file mode 100644 index 0000000..b945a0c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/tv.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/underline.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/underline.svg new file mode 100644 index 0000000..416a009 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/underline.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-circle.svg new file mode 100644 index 0000000..59785eb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-group.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-group.svg new file mode 100644 index 0000000..17e755e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-group.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-minus.svg new file mode 100644 index 0000000..240bff5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-minus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-plus.svg new file mode 100644 index 0000000..3392941 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user.svg new file mode 100644 index 0000000..25b7abc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/users.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/users.svg new file mode 100644 index 0000000..5a48a77 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/users.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/variable.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/variable.svg new file mode 100644 index 0000000..c3e1ccf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/variable.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/video-camera-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/video-camera-slash.svg new file mode 100644 index 0000000..a5ff5f7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/video-camera-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/video-camera.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/video-camera.svg new file mode 100644 index 0000000..2485f0c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/video-camera.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/view-columns.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/view-columns.svg new file mode 100644 index 0000000..4289dcc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/view-columns.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/viewfinder-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/viewfinder-circle.svg new file mode 100644 index 0000000..e5f4c37 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/viewfinder-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wallet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wallet.svg new file mode 100644 index 0000000..9d7835d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wallet.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wifi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wifi.svg new file mode 100644 index 0000000..95f17df --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wifi.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/window.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/window.svg new file mode 100644 index 0000000..40ae1cc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/window.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wrench-screwdriver.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wrench-screwdriver.svg new file mode 100644 index 0000000..32c38ed --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wrench-screwdriver.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wrench.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wrench.svg new file mode 100644 index 0000000..46f68f4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wrench.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/x-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/x-circle.svg new file mode 100644 index 0000000..c8b56c7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/x-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/x-mark.svg new file mode 100644 index 0000000..b20f867 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/x-mark.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/academic-cap.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/academic-cap.svg new file mode 100644 index 0000000..cd05c0e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/academic-cap.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/adjustments-horizontal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/adjustments-horizontal.svg new file mode 100644 index 0000000..24a8bc7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/adjustments-horizontal.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/adjustments-vertical.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/adjustments-vertical.svg new file mode 100644 index 0000000..8ec937c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/adjustments-vertical.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box-arrow-down.svg new file mode 100644 index 0000000..a325df4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box-x-mark.svg new file mode 100644 index 0000000..cca86e1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box-x-mark.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box.svg new file mode 100644 index 0000000..921a582 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-circle.svg new file mode 100644 index 0000000..954042d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-left.svg new file mode 100644 index 0000000..be398da --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-on-square-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-on-square-stack.svg new file mode 100644 index 0000000..6032f2c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-on-square-stack.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-on-square.svg new file mode 100644 index 0000000..d725386 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-on-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-right.svg new file mode 100644 index 0000000..d31282e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-tray.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-tray.svg new file mode 100644 index 0000000..2b8890c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-tray.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down.svg new file mode 100644 index 0000000..5e1a5de --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-circle.svg new file mode 100644 index 0000000..8240282 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-end-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-end-on-rectangle.svg new file mode 100644 index 0000000..4bca1c9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-end-on-rectangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-on-rectangle.svg new file mode 100644 index 0000000..4bca1c9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-on-rectangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-start-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-start-on-rectangle.svg new file mode 100644 index 0000000..29997d2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-start-on-rectangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left.svg new file mode 100644 index 0000000..1071988 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-down.svg new file mode 100644 index 0000000..768a299 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-left.svg new file mode 100644 index 0000000..0c01923 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-right.svg new file mode 100644 index 0000000..746e1e4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-up.svg new file mode 100644 index 0000000..bebf680 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-path-rounded-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-path-rounded-square.svg new file mode 100644 index 0000000..4392295 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-path-rounded-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-path.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-path.svg new file mode 100644 index 0000000..fba7241 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-path.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-circle.svg new file mode 100644 index 0000000..6f065a0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-end-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-end-on-rectangle.svg new file mode 100644 index 0000000..48ffe90 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-end-on-rectangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-on-rectangle.svg new file mode 100644 index 0000000..abea6bf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-on-rectangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-start-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-start-on-rectangle.svg new file mode 100644 index 0000000..abea6bf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-start-on-rectangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right.svg new file mode 100644 index 0000000..f700b1f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-down.svg new file mode 100644 index 0000000..9977f0e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-left.svg new file mode 100644 index 0000000..8d54067 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-right.svg new file mode 100644 index 0000000..af2ef62 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-up.svg new file mode 100644 index 0000000..78e282d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-top-right-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-top-right-on-square.svg new file mode 100644 index 0000000..f3fb15f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-top-right-on-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-trending-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-trending-down.svg new file mode 100644 index 0000000..75f083a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-trending-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-trending-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-trending-up.svg new file mode 100644 index 0000000..b79bfbe --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-trending-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-down-left.svg new file mode 100644 index 0000000..4425ea1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-down-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-down-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-down-right.svg new file mode 100644 index 0000000..1efaec1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-down-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-left-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-left-down.svg new file mode 100644 index 0000000..8ca63d9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-left-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-left-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-left-up.svg new file mode 100644 index 0000000..37cc0b9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-left-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-right-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-right-down.svg new file mode 100644 index 0000000..e10ebd3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-right-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-right-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-right-up.svg new file mode 100644 index 0000000..ddf4cd8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-right-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-up-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-up-left.svg new file mode 100644 index 0000000..3b2a330 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-up-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-up-right.svg new file mode 100644 index 0000000..7a9e437 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-up-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-circle.svg new file mode 100644 index 0000000..c2e0359 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-left.svg new file mode 100644 index 0000000..6553168 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-on-square-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-on-square-stack.svg new file mode 100644 index 0000000..f1b3d5e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-on-square-stack.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-on-square.svg new file mode 100644 index 0000000..3625717 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-on-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-right.svg new file mode 100644 index 0000000..9ec7dee --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-tray.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-tray.svg new file mode 100644 index 0000000..3779677 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-tray.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up.svg new file mode 100644 index 0000000..cb05fb2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-down.svg new file mode 100644 index 0000000..f362008 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-left.svg new file mode 100644 index 0000000..5a9819c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-right.svg new file mode 100644 index 0000000..244e674 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-up.svg new file mode 100644 index 0000000..a19ff96 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-pointing-in.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-pointing-in.svg new file mode 100644 index 0000000..2621d38 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-pointing-in.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-pointing-out.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-pointing-out.svg new file mode 100644 index 0000000..73de08c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-pointing-out.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-right-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-right-left.svg new file mode 100644 index 0000000..22dca0a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-right-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-up-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-up-down.svg new file mode 100644 index 0000000..f8cc08f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-up-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/at-symbol.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/at-symbol.svg new file mode 100644 index 0000000..2b9e446 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/at-symbol.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/backspace.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/backspace.svg new file mode 100644 index 0000000..1563d7d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/backspace.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/backward.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/backward.svg new file mode 100644 index 0000000..ff788f8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/backward.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/banknotes.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/banknotes.svg new file mode 100644 index 0000000..945f60e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/banknotes.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-2.svg new file mode 100644 index 0000000..d64a9bb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-2.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-bottom-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-bottom-left.svg new file mode 100644 index 0000000..9960567 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-bottom-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-bottom-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-bottom-right.svg new file mode 100644 index 0000000..4e90c18 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-bottom-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-center-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-center-left.svg new file mode 100644 index 0000000..cca274c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-center-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3.svg new file mode 100644 index 0000000..6d2221f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-4.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-4.svg new file mode 100644 index 0000000..b9e9c46 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-4.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-arrow-down.svg new file mode 100644 index 0000000..91af7e4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-arrow-up.svg new file mode 100644 index 0000000..5c515e5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-arrow-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-0.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-0.svg new file mode 100644 index 0000000..b9811f7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-0.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-100.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-100.svg new file mode 100644 index 0000000..f880879 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-100.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-50.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-50.svg new file mode 100644 index 0000000..0018f8d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-50.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/beaker.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/beaker.svg new file mode 100644 index 0000000..9457e9a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/beaker.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-alert.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-alert.svg new file mode 100644 index 0000000..68d2f64 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-alert.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-slash.svg new file mode 100644 index 0000000..fc31652 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-snooze.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-snooze.svg new file mode 100644 index 0000000..10fa5bd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-snooze.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell.svg new file mode 100644 index 0000000..8145b9d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bold.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bold.svg new file mode 100644 index 0000000..d815857 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bold.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bolt-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bolt-slash.svg new file mode 100644 index 0000000..91f81b4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bolt-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bolt.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bolt.svg new file mode 100644 index 0000000..506283d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bolt.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/book-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/book-open.svg new file mode 100644 index 0000000..de02b14 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/book-open.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark-slash.svg new file mode 100644 index 0000000..d55d5ae --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark-square.svg new file mode 100644 index 0000000..538d9e6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark.svg new file mode 100644 index 0000000..4e88a5c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/briefcase.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/briefcase.svg new file mode 100644 index 0000000..d0c1fc3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/briefcase.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bug-ant.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bug-ant.svg new file mode 100644 index 0000000..056d5a5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bug-ant.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-library.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-library.svg new file mode 100644 index 0000000..2ea181c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-library.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-office-2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-office-2.svg new file mode 100644 index 0000000..d1f922a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-office-2.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-office.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-office.svg new file mode 100644 index 0000000..695729f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-office.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-storefront.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-storefront.svg new file mode 100644 index 0000000..8e287f8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-storefront.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cake.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cake.svg new file mode 100644 index 0000000..aa770ce --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cake.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calculator.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calculator.svg new file mode 100644 index 0000000..ef441d6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calculator.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar-date-range.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar-date-range.svg new file mode 100644 index 0000000..e1e9119 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar-date-range.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar-days.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar-days.svg new file mode 100644 index 0000000..94731d7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar-days.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar.svg new file mode 100644 index 0000000..81b3dfc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/camera.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/camera.svg new file mode 100644 index 0000000..aabc3c6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/camera.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-bar-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-bar-square.svg new file mode 100644 index 0000000..e03b7df --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-bar-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-bar.svg new file mode 100644 index 0000000..31ff30c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-bar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-pie.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-pie.svg new file mode 100644 index 0000000..eebd21f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-pie.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-bottom-center-text.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-bottom-center-text.svg new file mode 100644 index 0000000..549b4b2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-bottom-center-text.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-bottom-center.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-bottom-center.svg new file mode 100644 index 0000000..3bd9cf4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-bottom-center.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left-ellipsis.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left-ellipsis.svg new file mode 100644 index 0000000..5865363 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left-ellipsis.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left-right.svg new file mode 100644 index 0000000..fb00eb1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left.svg new file mode 100644 index 0000000..1c0d986 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-oval-left-ellipsis.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-oval-left-ellipsis.svg new file mode 100644 index 0000000..3cc9ce5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-oval-left-ellipsis.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-oval-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-oval-left.svg new file mode 100644 index 0000000..c4bab3a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-oval-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check-badge.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check-badge.svg new file mode 100644 index 0000000..d39704d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check-badge.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check-circle.svg new file mode 100644 index 0000000..912ffc6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check.svg new file mode 100644 index 0000000..37ad683 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-down.svg new file mode 100644 index 0000000..308710e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-left.svg new file mode 100644 index 0000000..7af0232 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-right.svg new file mode 100644 index 0000000..e29b66f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-up.svg new file mode 100644 index 0000000..ab19f2e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-up.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-down.svg new file mode 100644 index 0000000..b6ac1cf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-left.svg new file mode 100644 index 0000000..5e9f57b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-right.svg new file mode 100644 index 0000000..cd8a457 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-up-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-up-down.svg new file mode 100644 index 0000000..ba6820a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-up-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-up.svg new file mode 100644 index 0000000..eafbff4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/circle-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/circle-stack.svg new file mode 100644 index 0000000..2f75456 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/circle-stack.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document-check.svg new file mode 100644 index 0000000..b4be34b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document-check.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document-list.svg new file mode 100644 index 0000000..11e818a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document-list.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document.svg new file mode 100644 index 0000000..31eb487 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard.svg new file mode 100644 index 0000000..2e7501e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clock.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clock.svg new file mode 100644 index 0000000..a7fce7b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clock.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud-arrow-down.svg new file mode 100644 index 0000000..9bbee4f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud-arrow-up.svg new file mode 100644 index 0000000..0994437 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud-arrow-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud.svg new file mode 100644 index 0000000..9662d6c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/code-bracket-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/code-bracket-square.svg new file mode 100644 index 0000000..de71371 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/code-bracket-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/code-bracket.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/code-bracket.svg new file mode 100644 index 0000000..8754e63 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/code-bracket.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog-6-tooth.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog-6-tooth.svg new file mode 100644 index 0000000..91ba7db --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog-6-tooth.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog-8-tooth.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog-8-tooth.svg new file mode 100644 index 0000000..515cc16 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog-8-tooth.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog.svg new file mode 100644 index 0000000..5aadb3e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/command-line.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/command-line.svg new file mode 100644 index 0000000..6f9f8f9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/command-line.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/computer-desktop.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/computer-desktop.svg new file mode 100644 index 0000000..1744ea2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/computer-desktop.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cpu-chip.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cpu-chip.svg new file mode 100644 index 0000000..7ab9e09 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cpu-chip.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/credit-card.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/credit-card.svg new file mode 100644 index 0000000..fbe46d8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/credit-card.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cube-transparent.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cube-transparent.svg new file mode 100644 index 0000000..10f4c2c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cube-transparent.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cube.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cube.svg new file mode 100644 index 0000000..87d16c0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cube.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-bangladeshi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-bangladeshi.svg new file mode 100644 index 0000000..4aaae03 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-bangladeshi.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-dollar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-dollar.svg new file mode 100644 index 0000000..a69380e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-dollar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-euro.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-euro.svg new file mode 100644 index 0000000..b6504f3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-euro.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-pound.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-pound.svg new file mode 100644 index 0000000..fb32377 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-pound.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-rupee.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-rupee.svg new file mode 100644 index 0000000..2040a22 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-rupee.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-yen.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-yen.svg new file mode 100644 index 0000000..80d3c99 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-yen.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cursor-arrow-rays.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cursor-arrow-rays.svg new file mode 100644 index 0000000..813793c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cursor-arrow-rays.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cursor-arrow-ripple.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cursor-arrow-ripple.svg new file mode 100644 index 0000000..24dbb3e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cursor-arrow-ripple.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/device-phone-mobile.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/device-phone-mobile.svg new file mode 100644 index 0000000..7ff5ae2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/device-phone-mobile.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/device-tablet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/device-tablet.svg new file mode 100644 index 0000000..58b94a4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/device-tablet.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/divide.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/divide.svg new file mode 100644 index 0000000..d657f36 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/divide.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-arrow-down.svg new file mode 100644 index 0000000..39cb4d3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-arrow-up.svg new file mode 100644 index 0000000..f4ad844 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-arrow-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-chart-bar.svg new file mode 100644 index 0000000..0bf7dc1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-chart-bar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-check.svg new file mode 100644 index 0000000..6ef1c98 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-check.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-bangladeshi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-bangladeshi.svg new file mode 100644 index 0000000..16bc3bf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-bangladeshi.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-dollar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-dollar.svg new file mode 100644 index 0000000..e07ce04 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-dollar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-euro.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-euro.svg new file mode 100644 index 0000000..0a9846a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-euro.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-pound.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-pound.svg new file mode 100644 index 0000000..9944c17 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-pound.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-rupee.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-rupee.svg new file mode 100644 index 0000000..5767bec --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-rupee.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-yen.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-yen.svg new file mode 100644 index 0000000..8e2b983 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-yen.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-duplicate.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-duplicate.svg new file mode 100644 index 0000000..9c6bc80 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-duplicate.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-magnifying-glass.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-magnifying-glass.svg new file mode 100644 index 0000000..ebf1b13 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-magnifying-glass.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-minus.svg new file mode 100644 index 0000000..a13b6fe --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-minus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-plus.svg new file mode 100644 index 0000000..e06def8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-text.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-text.svg new file mode 100644 index 0000000..8c03e9e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-text.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document.svg new file mode 100644 index 0000000..5a4881a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-horizontal-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-horizontal-circle.svg new file mode 100644 index 0000000..d6f11dd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-horizontal-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-horizontal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-horizontal.svg new file mode 100644 index 0000000..4efa28a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-horizontal.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-vertical.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-vertical.svg new file mode 100644 index 0000000..2caf3e0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-vertical.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/envelope-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/envelope-open.svg new file mode 100644 index 0000000..01f34be --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/envelope-open.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/envelope.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/envelope.svg new file mode 100644 index 0000000..b8029e8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/envelope.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/equals.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/equals.svg new file mode 100644 index 0000000..3c9702b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/equals.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/exclamation-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/exclamation-circle.svg new file mode 100644 index 0000000..0833d8e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/exclamation-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/exclamation-triangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/exclamation-triangle.svg new file mode 100644 index 0000000..dc2e6cf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/exclamation-triangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye-dropper.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye-dropper.svg new file mode 100644 index 0000000..c1b7b8a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye-dropper.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye-slash.svg new file mode 100644 index 0000000..fb79424 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye.svg new file mode 100644 index 0000000..f4ae935 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/face-frown.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/face-frown.svg new file mode 100644 index 0000000..86af7b0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/face-frown.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/face-smile.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/face-smile.svg new file mode 100644 index 0000000..e987282 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/face-smile.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/film.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/film.svg new file mode 100644 index 0000000..f02856d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/film.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/finger-print.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/finger-print.svg new file mode 100644 index 0000000..2de9f82 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/finger-print.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/fire.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/fire.svg new file mode 100644 index 0000000..0e944f5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/fire.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/flag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/flag.svg new file mode 100644 index 0000000..f5d17df --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/flag.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-arrow-down.svg new file mode 100644 index 0000000..74f0b6c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-minus.svg new file mode 100644 index 0000000..fc4212b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-minus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-open.svg new file mode 100644 index 0000000..df90e71 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-open.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-plus.svg new file mode 100644 index 0000000..8acf5f7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder.svg new file mode 100644 index 0000000..4c301ad --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/forward.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/forward.svg new file mode 100644 index 0000000..317f5ce --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/forward.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/funnel.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/funnel.svg new file mode 100644 index 0000000..5937bdc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/funnel.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gif.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gif.svg new file mode 100644 index 0000000..6d22aca --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gif.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gift-top.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gift-top.svg new file mode 100644 index 0000000..3eb1793 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gift-top.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gift.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gift.svg new file mode 100644 index 0000000..a856b6f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gift.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-alt.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-alt.svg new file mode 100644 index 0000000..b63d268 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-alt.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-americas.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-americas.svg new file mode 100644 index 0000000..f0a6beb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-americas.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-asia-australia.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-asia-australia.svg new file mode 100644 index 0000000..396cd3c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-asia-australia.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-europe-africa.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-europe-africa.svg new file mode 100644 index 0000000..427637d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-europe-africa.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h1.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h1.svg new file mode 100644 index 0000000..e70d229 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h1.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h2.svg new file mode 100644 index 0000000..5fd0ee0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h2.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h3.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h3.svg new file mode 100644 index 0000000..386c70f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h3.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-raised.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-raised.svg new file mode 100644 index 0000000..c9008af --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-raised.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-thumb-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-thumb-down.svg new file mode 100644 index 0000000..3d2cc16 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-thumb-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-thumb-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-thumb-up.svg new file mode 100644 index 0000000..01b9877 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-thumb-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hashtag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hashtag.svg new file mode 100644 index 0000000..9956ce0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hashtag.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/heart.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/heart.svg new file mode 100644 index 0000000..2c371f3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/heart.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/home-modern.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/home-modern.svg new file mode 100644 index 0000000..b081fe8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/home-modern.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/home.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/home.svg new file mode 100644 index 0000000..e1900b8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/home.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/identification.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/identification.svg new file mode 100644 index 0000000..9a99a61 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/identification.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox-arrow-down.svg new file mode 100644 index 0000000..686060b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox-stack.svg new file mode 100644 index 0000000..c8a6ebb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox-stack.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox.svg new file mode 100644 index 0000000..2a26cee --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/information-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/information-circle.svg new file mode 100644 index 0000000..d4ac360 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/information-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/italic.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/italic.svg new file mode 100644 index 0000000..b424324 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/italic.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/key.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/key.svg new file mode 100644 index 0000000..30c5bde --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/key.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/language.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/language.svg new file mode 100644 index 0000000..1094eb1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/language.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lifebuoy.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lifebuoy.svg new file mode 100644 index 0000000..446562b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lifebuoy.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/light-bulb.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/light-bulb.svg new file mode 100644 index 0000000..37c0317 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/light-bulb.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/link-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/link-slash.svg new file mode 100644 index 0000000..9d6f0e8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/link-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/link.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/link.svg new file mode 100644 index 0000000..38febfc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/link.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/list-bullet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/list-bullet.svg new file mode 100644 index 0000000..0dcb940 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/list-bullet.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lock-closed.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lock-closed.svg new file mode 100644 index 0000000..4917dbb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lock-closed.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lock-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lock-open.svg new file mode 100644 index 0000000..7f9a5a3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lock-open.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-circle.svg new file mode 100644 index 0000000..4c0da1f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-minus.svg new file mode 100644 index 0000000..d4dfada --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-minus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-plus.svg new file mode 100644 index 0000000..a1d4363 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass.svg new file mode 100644 index 0000000..947336c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/map-pin.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/map-pin.svg new file mode 100644 index 0000000..74cc786 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/map-pin.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/map.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/map.svg new file mode 100644 index 0000000..2d9e239 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/map.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/megaphone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/megaphone.svg new file mode 100644 index 0000000..2b3c60b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/megaphone.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/microphone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/microphone.svg new file mode 100644 index 0000000..e349845 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/microphone.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus-circle.svg new file mode 100644 index 0000000..7cc1de3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus-small.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus-small.svg new file mode 100644 index 0000000..25144c8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus-small.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus.svg new file mode 100644 index 0000000..fa1fb8b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/moon.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/moon.svg new file mode 100644 index 0000000..6a13b2f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/moon.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/musical-note.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/musical-note.svg new file mode 100644 index 0000000..d903ff2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/musical-note.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/newspaper.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/newspaper.svg new file mode 100644 index 0000000..666274f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/newspaper.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/no-symbol.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/no-symbol.svg new file mode 100644 index 0000000..a7dd365 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/no-symbol.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/numbered-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/numbered-list.svg new file mode 100644 index 0000000..10ba072 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/numbered-list.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paint-brush.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paint-brush.svg new file mode 100644 index 0000000..21a208e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paint-brush.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paper-airplane.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paper-airplane.svg new file mode 100644 index 0000000..80db4d0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paper-airplane.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paper-clip.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paper-clip.svg new file mode 100644 index 0000000..c27a60a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paper-clip.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pause-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pause-circle.svg new file mode 100644 index 0000000..723e5f2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pause-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pause.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pause.svg new file mode 100644 index 0000000..7a61104 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pause.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pencil-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pencil-square.svg new file mode 100644 index 0000000..01de21e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pencil-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pencil.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pencil.svg new file mode 100644 index 0000000..7af74ab --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pencil.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/percent-badge.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/percent-badge.svg new file mode 100644 index 0000000..2f84596 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/percent-badge.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-arrow-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-arrow-down-left.svg new file mode 100644 index 0000000..31185c8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-arrow-down-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-arrow-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-arrow-up-right.svg new file mode 100644 index 0000000..ab875b0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-arrow-up-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-x-mark.svg new file mode 100644 index 0000000..50568f0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-x-mark.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone.svg new file mode 100644 index 0000000..a8789d1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/photo.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/photo.svg new file mode 100644 index 0000000..0e65af2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/photo.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play-circle.svg new file mode 100644 index 0000000..d83a73f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play-circle.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play-pause.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play-pause.svg new file mode 100644 index 0000000..943efcb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play-pause.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play.svg new file mode 100644 index 0000000..3c0fad3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus-circle.svg new file mode 100644 index 0000000..1beab85 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus-small.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus-small.svg new file mode 100644 index 0000000..7fb7dcb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus-small.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus.svg new file mode 100644 index 0000000..06b312f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/power.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/power.svg new file mode 100644 index 0000000..e471a03 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/power.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/presentation-chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/presentation-chart-bar.svg new file mode 100644 index 0000000..d056439 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/presentation-chart-bar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/presentation-chart-line.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/presentation-chart-line.svg new file mode 100644 index 0000000..ed212e5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/presentation-chart-line.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/printer.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/printer.svg new file mode 100644 index 0000000..f3c1c54 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/printer.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/puzzle-piece.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/puzzle-piece.svg new file mode 100644 index 0000000..004855b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/puzzle-piece.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/qr-code.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/qr-code.svg new file mode 100644 index 0000000..1cbd5d4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/qr-code.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/question-mark-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/question-mark-circle.svg new file mode 100644 index 0000000..e3dd599 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/question-mark-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/queue-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/queue-list.svg new file mode 100644 index 0000000..09f0066 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/queue-list.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/radio.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/radio.svg new file mode 100644 index 0000000..81c891b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/radio.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/receipt-percent.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/receipt-percent.svg new file mode 100644 index 0000000..4dd5996 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/receipt-percent.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/receipt-refund.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/receipt-refund.svg new file mode 100644 index 0000000..345a897 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/receipt-refund.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rectangle-group.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rectangle-group.svg new file mode 100644 index 0000000..8184b19 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rectangle-group.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rectangle-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rectangle-stack.svg new file mode 100644 index 0000000..8eb419a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rectangle-stack.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rocket-launch.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rocket-launch.svg new file mode 100644 index 0000000..a6a6cb9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rocket-launch.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rss.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rss.svg new file mode 100644 index 0000000..ff1e1a8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rss.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/scale.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/scale.svg new file mode 100644 index 0000000..559bcda --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/scale.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/scissors.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/scissors.svg new file mode 100644 index 0000000..4df73af --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/scissors.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/server-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/server-stack.svg new file mode 100644 index 0000000..060c820 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/server-stack.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/server.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/server.svg new file mode 100644 index 0000000..eef8b9e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/server.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/share.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/share.svg new file mode 100644 index 0000000..e931243 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/share.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shield-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shield-check.svg new file mode 100644 index 0000000..82b9125 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shield-check.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shield-exclamation.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shield-exclamation.svg new file mode 100644 index 0000000..5a8df17 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shield-exclamation.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shopping-bag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shopping-bag.svg new file mode 100644 index 0000000..7c5f29b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shopping-bag.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shopping-cart.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shopping-cart.svg new file mode 100644 index 0000000..b86c2ab --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shopping-cart.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/signal-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/signal-slash.svg new file mode 100644 index 0000000..b858fdf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/signal-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/signal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/signal.svg new file mode 100644 index 0000000..9daa002 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/signal.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/slash.svg new file mode 100644 index 0000000..782434d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/sparkles.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/sparkles.svg new file mode 100644 index 0000000..c8df7f1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/sparkles.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/speaker-wave.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/speaker-wave.svg new file mode 100644 index 0000000..7bb46f2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/speaker-wave.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/speaker-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/speaker-x-mark.svg new file mode 100644 index 0000000..e89da3e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/speaker-x-mark.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/square-2-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/square-2-stack.svg new file mode 100644 index 0000000..d6d6ca9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/square-2-stack.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/square-3-stack-3d.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/square-3-stack-3d.svg new file mode 100644 index 0000000..d11f07b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/square-3-stack-3d.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/squares-2x2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/squares-2x2.svg new file mode 100644 index 0000000..1fe6594 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/squares-2x2.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/squares-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/squares-plus.svg new file mode 100644 index 0000000..b87a5f1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/squares-plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/star.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/star.svg new file mode 100644 index 0000000..1f27ec8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/star.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/stop-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/stop-circle.svg new file mode 100644 index 0000000..26c2aba --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/stop-circle.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/stop.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/stop.svg new file mode 100644 index 0000000..2058714 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/stop.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/strikethrough.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/strikethrough.svg new file mode 100644 index 0000000..9ec9b9d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/strikethrough.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/sun.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/sun.svg new file mode 100644 index 0000000..0e49045 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/sun.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/swatch.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/swatch.svg new file mode 100644 index 0000000..e1e1b27 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/swatch.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/table-cells.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/table-cells.svg new file mode 100644 index 0000000..3d0f114 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/table-cells.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/tag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/tag.svg new file mode 100644 index 0000000..01eb0a3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/tag.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ticket.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ticket.svg new file mode 100644 index 0000000..f06ce3b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ticket.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/trash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/trash.svg new file mode 100644 index 0000000..71669b0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/trash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/trophy.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/trophy.svg new file mode 100644 index 0000000..9556227 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/trophy.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/truck.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/truck.svg new file mode 100644 index 0000000..7f475c8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/truck.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/tv.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/tv.svg new file mode 100644 index 0000000..b6fae6a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/tv.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/underline.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/underline.svg new file mode 100644 index 0000000..56a2051 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/underline.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-circle.svg new file mode 100644 index 0000000..74a941e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-group.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-group.svg new file mode 100644 index 0000000..2a3159e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-group.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-minus.svg new file mode 100644 index 0000000..878cc7a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-minus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-plus.svg new file mode 100644 index 0000000..a3c9bfc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user.svg new file mode 100644 index 0000000..9a343c4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/users.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/users.svg new file mode 100644 index 0000000..1de4a72 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/users.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/variable.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/variable.svg new file mode 100644 index 0000000..4656e2f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/variable.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/video-camera-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/video-camera-slash.svg new file mode 100644 index 0000000..c08bd00 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/video-camera-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/video-camera.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/video-camera.svg new file mode 100644 index 0000000..1af46bd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/video-camera.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/view-columns.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/view-columns.svg new file mode 100644 index 0000000..bc9a440 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/view-columns.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/viewfinder-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/viewfinder-circle.svg new file mode 100644 index 0000000..947dcd8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/viewfinder-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wallet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wallet.svg new file mode 100644 index 0000000..58989cf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wallet.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wifi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wifi.svg new file mode 100644 index 0000000..c838da4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wifi.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/window.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/window.svg new file mode 100644 index 0000000..fcccbe2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/window.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wrench-screwdriver.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wrench-screwdriver.svg new file mode 100644 index 0000000..8d05c28 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wrench-screwdriver.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wrench.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wrench.svg new file mode 100644 index 0000000..56c6f8d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wrench.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/x-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/x-circle.svg new file mode 100644 index 0000000..e8d86ef --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/x-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/x-mark.svg new file mode 100644 index 0000000..19b4ac7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/x-mark.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/academic-cap.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/academic-cap.svg new file mode 100644 index 0000000..caf08cf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/academic-cap.svg @@ -0,0 +1,5 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/adjustments-horizontal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/adjustments-horizontal.svg new file mode 100644 index 0000000..5941a18 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/adjustments-horizontal.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/adjustments-vertical.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/adjustments-vertical.svg new file mode 100644 index 0000000..67922e6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/adjustments-vertical.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box-arrow-down.svg new file mode 100644 index 0000000..a55eaac --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box-arrow-down.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box-x-mark.svg new file mode 100644 index 0000000..d317024 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box-x-mark.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box.svg new file mode 100644 index 0000000..045a553 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-circle.svg new file mode 100644 index 0000000..63783da --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-left.svg new file mode 100644 index 0000000..2a04263 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-on-square-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-on-square-stack.svg new file mode 100644 index 0000000..3ebf0cf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-on-square-stack.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-on-square.svg new file mode 100644 index 0000000..dcc413d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-on-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-right.svg new file mode 100644 index 0000000..dc6984c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-tray.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-tray.svg new file mode 100644 index 0000000..a574281 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-tray.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down.svg new file mode 100644 index 0000000..e8334d5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-circle.svg new file mode 100644 index 0000000..d958af3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-end-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-end-on-rectangle.svg new file mode 100644 index 0000000..cf507b8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-end-on-rectangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-on-rectangle.svg new file mode 100644 index 0000000..cf507b8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-on-rectangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-start-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-start-on-rectangle.svg new file mode 100644 index 0000000..9fbe1ee --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-start-on-rectangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left.svg new file mode 100644 index 0000000..3f84fb7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-down.svg new file mode 100644 index 0000000..9176936 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-left.svg new file mode 100644 index 0000000..b374f26 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-right.svg new file mode 100644 index 0000000..654992f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-up.svg new file mode 100644 index 0000000..71b5c3c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-path-rounded-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-path-rounded-square.svg new file mode 100644 index 0000000..e7e2019 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-path-rounded-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-path.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-path.svg new file mode 100644 index 0000000..11083b2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-path.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-circle.svg new file mode 100644 index 0000000..96ed0f5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-end-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-end-on-rectangle.svg new file mode 100644 index 0000000..930a4ac --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-end-on-rectangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-on-rectangle.svg new file mode 100644 index 0000000..17ad554 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-on-rectangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-start-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-start-on-rectangle.svg new file mode 100644 index 0000000..17ad554 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-start-on-rectangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right.svg new file mode 100644 index 0000000..c985254 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-down.svg new file mode 100644 index 0000000..dca2325 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-left.svg new file mode 100644 index 0000000..a3ce231 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-right.svg new file mode 100644 index 0000000..a2abcef --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-up.svg new file mode 100644 index 0000000..8c25bba --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-top-right-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-top-right-on-square.svg new file mode 100644 index 0000000..80e5aff --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-top-right-on-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-trending-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-trending-down.svg new file mode 100644 index 0000000..b65a016 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-trending-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-trending-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-trending-up.svg new file mode 100644 index 0000000..cf1ba1c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-trending-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-down-left.svg new file mode 100644 index 0000000..e40ed6b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-down-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-down-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-down-right.svg new file mode 100644 index 0000000..92db7a0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-down-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-left-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-left-down.svg new file mode 100644 index 0000000..a630e02 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-left-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-left-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-left-up.svg new file mode 100644 index 0000000..7af6dbd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-left-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-right-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-right-down.svg new file mode 100644 index 0000000..2ad2bfc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-right-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-right-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-right-up.svg new file mode 100644 index 0000000..fd0c49a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-right-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-up-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-up-left.svg new file mode 100644 index 0000000..69e8b13 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-up-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-up-right.svg new file mode 100644 index 0000000..f83300e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-up-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-circle.svg new file mode 100644 index 0000000..83747dc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-left.svg new file mode 100644 index 0000000..c6256c7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-on-square-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-on-square-stack.svg new file mode 100644 index 0000000..d38d02d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-on-square-stack.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-on-square.svg new file mode 100644 index 0000000..70bf772 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-on-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-right.svg new file mode 100644 index 0000000..af327a9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-tray.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-tray.svg new file mode 100644 index 0000000..97cea1e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-tray.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up.svg new file mode 100644 index 0000000..31d032b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-down.svg new file mode 100644 index 0000000..04c20f4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-left.svg new file mode 100644 index 0000000..bfb8063 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-right.svg new file mode 100644 index 0000000..4151694 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-up.svg new file mode 100644 index 0000000..1ea28a7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-pointing-in.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-pointing-in.svg new file mode 100644 index 0000000..1e398e8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-pointing-in.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-pointing-out.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-pointing-out.svg new file mode 100644 index 0000000..a150459 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-pointing-out.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-right-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-right-left.svg new file mode 100644 index 0000000..e0c2cd1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-right-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-up-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-up-down.svg new file mode 100644 index 0000000..65ecb1d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-up-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/at-symbol.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/at-symbol.svg new file mode 100644 index 0000000..12b3a4b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/at-symbol.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/backspace.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/backspace.svg new file mode 100644 index 0000000..67ead14 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/backspace.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/backward.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/backward.svg new file mode 100644 index 0000000..77bb887 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/backward.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/banknotes.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/banknotes.svg new file mode 100644 index 0000000..3537885 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/banknotes.svg @@ -0,0 +1,5 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-2.svg new file mode 100644 index 0000000..f202853 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-2.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-bottom-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-bottom-left.svg new file mode 100644 index 0000000..b6d722a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-bottom-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-bottom-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-bottom-right.svg new file mode 100644 index 0000000..fe4ca2d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-bottom-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-center-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-center-left.svg new file mode 100644 index 0000000..f1837b7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-center-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3.svg new file mode 100644 index 0000000..b346ec7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-4.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-4.svg new file mode 100644 index 0000000..a55c244 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-4.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-arrow-down.svg new file mode 100644 index 0000000..a8988f4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-arrow-up.svg new file mode 100644 index 0000000..50e5d62 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-arrow-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-0.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-0.svg new file mode 100644 index 0000000..e3cf12b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-0.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-100.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-100.svg new file mode 100644 index 0000000..8c0b12a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-100.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-50.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-50.svg new file mode 100644 index 0000000..9da699c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-50.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/beaker.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/beaker.svg new file mode 100644 index 0000000..09f8405 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/beaker.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-alert.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-alert.svg new file mode 100644 index 0000000..ab36d83 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-alert.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-slash.svg new file mode 100644 index 0000000..6c27b45 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-slash.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-snooze.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-snooze.svg new file mode 100644 index 0000000..fa81242 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-snooze.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell.svg new file mode 100644 index 0000000..15c304d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bold.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bold.svg new file mode 100644 index 0000000..4acbb17 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bold.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bolt-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bolt-slash.svg new file mode 100644 index 0000000..db5bb00 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bolt-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bolt.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bolt.svg new file mode 100644 index 0000000..ddafddc --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bolt.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/book-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/book-open.svg new file mode 100644 index 0000000..be9503f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/book-open.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark-slash.svg new file mode 100644 index 0000000..8029db0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark-square.svg new file mode 100644 index 0000000..5589364 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark.svg new file mode 100644 index 0000000..4f4f509 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/briefcase.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/briefcase.svg new file mode 100644 index 0000000..519aacd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/briefcase.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bug-ant.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bug-ant.svg new file mode 100644 index 0000000..1278695 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bug-ant.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-library.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-library.svg new file mode 100644 index 0000000..bcecbd9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-library.svg @@ -0,0 +1,5 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-office-2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-office-2.svg new file mode 100644 index 0000000..506b3cf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-office-2.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-office.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-office.svg new file mode 100644 index 0000000..a36c69a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-office.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-storefront.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-storefront.svg new file mode 100644 index 0000000..8a5f317 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-storefront.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cake.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cake.svg new file mode 100644 index 0000000..9626351 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cake.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calculator.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calculator.svg new file mode 100644 index 0000000..a512ef7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calculator.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar-date-range.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar-date-range.svg new file mode 100644 index 0000000..2336c66 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar-date-range.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar-days.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar-days.svg new file mode 100644 index 0000000..80d447c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar-days.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar.svg new file mode 100644 index 0000000..cc157d6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/camera.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/camera.svg new file mode 100644 index 0000000..8f26ad5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/camera.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-bar-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-bar-square.svg new file mode 100644 index 0000000..13a71f5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-bar-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-bar.svg new file mode 100644 index 0000000..0bb35a1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-bar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-pie.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-pie.svg new file mode 100644 index 0000000..2068099 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-pie.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-bottom-center-text.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-bottom-center-text.svg new file mode 100644 index 0000000..e4b8f13 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-bottom-center-text.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-bottom-center.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-bottom-center.svg new file mode 100644 index 0000000..8b19aee --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-bottom-center.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left-ellipsis.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left-ellipsis.svg new file mode 100644 index 0000000..bc6cc35 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left-ellipsis.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left-right.svg new file mode 100644 index 0000000..c2b25fd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left-right.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left.svg new file mode 100644 index 0000000..d65d62a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-oval-left-ellipsis.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-oval-left-ellipsis.svg new file mode 100644 index 0000000..30603d0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-oval-left-ellipsis.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-oval-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-oval-left.svg new file mode 100644 index 0000000..583d502 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-oval-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check-badge.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check-badge.svg new file mode 100644 index 0000000..bcf807a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check-badge.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check-circle.svg new file mode 100644 index 0000000..4c53408 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check.svg new file mode 100644 index 0000000..99d96ee --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-down.svg new file mode 100644 index 0000000..a2220ef --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-down.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-left.svg new file mode 100644 index 0000000..06446a7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-left.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-right.svg new file mode 100644 index 0000000..f25d457 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-right.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-up.svg new file mode 100644 index 0000000..a169797 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-up.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-down.svg new file mode 100644 index 0000000..9cd2647 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-left.svg new file mode 100644 index 0000000..5fcaa4e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-left.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-right.svg new file mode 100644 index 0000000..6e37a51 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-right.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-up-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-up-down.svg new file mode 100644 index 0000000..4c19e13 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-up-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-up.svg new file mode 100644 index 0000000..d9695d3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/circle-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/circle-stack.svg new file mode 100644 index 0000000..51b626e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/circle-stack.svg @@ -0,0 +1,6 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document-check.svg new file mode 100644 index 0000000..e5de2b6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document-check.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document-list.svg new file mode 100644 index 0000000..91ac9b7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document-list.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document.svg new file mode 100644 index 0000000..51c90de --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document.svg @@ -0,0 +1,5 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard.svg new file mode 100644 index 0000000..2c98bb3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clock.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clock.svg new file mode 100644 index 0000000..fb8943a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clock.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud-arrow-down.svg new file mode 100644 index 0000000..6bc1ece --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud-arrow-up.svg new file mode 100644 index 0000000..59814e6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud-arrow-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud.svg new file mode 100644 index 0000000..88edfce --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/code-bracket-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/code-bracket-square.svg new file mode 100644 index 0000000..b2e0ec1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/code-bracket-square.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/code-bracket.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/code-bracket.svg new file mode 100644 index 0000000..9420564 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/code-bracket.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog-6-tooth.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog-6-tooth.svg new file mode 100644 index 0000000..4eebfb6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog-6-tooth.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog-8-tooth.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog-8-tooth.svg new file mode 100644 index 0000000..17fcb97 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog-8-tooth.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog.svg new file mode 100644 index 0000000..762d109 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/command-line.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/command-line.svg new file mode 100644 index 0000000..d7a1426 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/command-line.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/computer-desktop.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/computer-desktop.svg new file mode 100644 index 0000000..a7ad833 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/computer-desktop.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cpu-chip.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cpu-chip.svg new file mode 100644 index 0000000..b9da5a1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cpu-chip.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/credit-card.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/credit-card.svg new file mode 100644 index 0000000..bd92d58 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/credit-card.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cube-transparent.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cube-transparent.svg new file mode 100644 index 0000000..56b08b8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cube-transparent.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cube.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cube.svg new file mode 100644 index 0000000..875c5f8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cube.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-bangladeshi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-bangladeshi.svg new file mode 100644 index 0000000..8d8833c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-bangladeshi.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-dollar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-dollar.svg new file mode 100644 index 0000000..e8aaeea --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-dollar.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-euro.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-euro.svg new file mode 100644 index 0000000..0d3f66d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-euro.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-pound.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-pound.svg new file mode 100644 index 0000000..832100e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-pound.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-rupee.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-rupee.svg new file mode 100644 index 0000000..6a70776 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-rupee.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-yen.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-yen.svg new file mode 100644 index 0000000..1c55b60 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-yen.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cursor-arrow-rays.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cursor-arrow-rays.svg new file mode 100644 index 0000000..cfe7553 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cursor-arrow-rays.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cursor-arrow-ripple.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cursor-arrow-ripple.svg new file mode 100644 index 0000000..7386ee2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cursor-arrow-ripple.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/device-phone-mobile.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/device-phone-mobile.svg new file mode 100644 index 0000000..a81f966 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/device-phone-mobile.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/device-tablet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/device-tablet.svg new file mode 100644 index 0000000..e576b7f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/device-tablet.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/divide.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/divide.svg new file mode 100644 index 0000000..bc10e9d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/divide.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-arrow-down.svg new file mode 100644 index 0000000..fab4cd1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-arrow-down.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-arrow-up.svg new file mode 100644 index 0000000..62bf419 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-arrow-up.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-chart-bar.svg new file mode 100644 index 0000000..3ef54d8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-chart-bar.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-check.svg new file mode 100644 index 0000000..357c565 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-check.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-bangladeshi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-bangladeshi.svg new file mode 100644 index 0000000..0e84d61 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-bangladeshi.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-dollar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-dollar.svg new file mode 100644 index 0000000..dcf6abe --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-dollar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-euro.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-euro.svg new file mode 100644 index 0000000..5d217ce --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-euro.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-pound.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-pound.svg new file mode 100644 index 0000000..5a36501 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-pound.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-rupee.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-rupee.svg new file mode 100644 index 0000000..bc2e664 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-rupee.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-yen.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-yen.svg new file mode 100644 index 0000000..4236e49 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-yen.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-duplicate.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-duplicate.svg new file mode 100644 index 0000000..55d74d5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-duplicate.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-magnifying-glass.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-magnifying-glass.svg new file mode 100644 index 0000000..63cd019 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-magnifying-glass.svg @@ -0,0 +1,5 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-minus.svg new file mode 100644 index 0000000..13b0924 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-minus.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-plus.svg new file mode 100644 index 0000000..1ce84c4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-plus.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-text.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-text.svg new file mode 100644 index 0000000..4f747fb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-text.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document.svg new file mode 100644 index 0000000..de025c4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-horizontal-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-horizontal-circle.svg new file mode 100644 index 0000000..4db4853 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-horizontal-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-horizontal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-horizontal.svg new file mode 100644 index 0000000..5f7a532 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-horizontal.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-vertical.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-vertical.svg new file mode 100644 index 0000000..5ada537 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-vertical.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/envelope-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/envelope-open.svg new file mode 100644 index 0000000..6393663 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/envelope-open.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/envelope.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/envelope.svg new file mode 100644 index 0000000..94fc56c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/envelope.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/equals.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/equals.svg new file mode 100644 index 0000000..18133df --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/equals.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/exclamation-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/exclamation-circle.svg new file mode 100644 index 0000000..3fb372d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/exclamation-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/exclamation-triangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/exclamation-triangle.svg new file mode 100644 index 0000000..2024fae --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/exclamation-triangle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye-dropper.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye-dropper.svg new file mode 100644 index 0000000..a6cd394 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye-dropper.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye-slash.svg new file mode 100644 index 0000000..f067f2a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye-slash.svg @@ -0,0 +1,5 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye.svg new file mode 100644 index 0000000..675bb24 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/face-frown.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/face-frown.svg new file mode 100644 index 0000000..06fa63c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/face-frown.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/face-smile.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/face-smile.svg new file mode 100644 index 0000000..0231474 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/face-smile.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/film.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/film.svg new file mode 100644 index 0000000..f94903c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/film.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/finger-print.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/finger-print.svg new file mode 100644 index 0000000..5e4955a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/finger-print.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/fire.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/fire.svg new file mode 100644 index 0000000..f61bdc1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/fire.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/flag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/flag.svg new file mode 100644 index 0000000..97e5377 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/flag.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-arrow-down.svg new file mode 100644 index 0000000..f6b5e27 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-arrow-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-minus.svg new file mode 100644 index 0000000..6bf869b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-minus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-open.svg new file mode 100644 index 0000000..e81ebee --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-open.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-plus.svg new file mode 100644 index 0000000..97ce688 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder.svg new file mode 100644 index 0000000..fa0733d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/forward.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/forward.svg new file mode 100644 index 0000000..4895530 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/forward.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/funnel.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/funnel.svg new file mode 100644 index 0000000..8fd3b26 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/funnel.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gif.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gif.svg new file mode 100644 index 0000000..5047d63 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gif.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gift-top.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gift-top.svg new file mode 100644 index 0000000..96ec097 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gift-top.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gift.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gift.svg new file mode 100644 index 0000000..461b34b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gift.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-alt.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-alt.svg new file mode 100644 index 0000000..bf5ac60 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-alt.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-americas.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-americas.svg new file mode 100644 index 0000000..faee6a1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-americas.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-asia-australia.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-asia-australia.svg new file mode 100644 index 0000000..2883841 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-asia-australia.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-europe-africa.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-europe-africa.svg new file mode 100644 index 0000000..9b1ad10 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-europe-africa.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h1.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h1.svg new file mode 100644 index 0000000..56c22b1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h1.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h2.svg new file mode 100644 index 0000000..5acc000 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h2.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h3.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h3.svg new file mode 100644 index 0000000..904df6e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h3.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-raised.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-raised.svg new file mode 100644 index 0000000..ad64f49 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-raised.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-thumb-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-thumb-down.svg new file mode 100644 index 0000000..4a4996d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-thumb-down.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-thumb-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-thumb-up.svg new file mode 100644 index 0000000..cb2396b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-thumb-up.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hashtag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hashtag.svg new file mode 100644 index 0000000..7bf54c2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hashtag.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/heart.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/heart.svg new file mode 100644 index 0000000..27c7d20 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/heart.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/home-modern.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/home-modern.svg new file mode 100644 index 0000000..755bf33 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/home-modern.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/home.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/home.svg new file mode 100644 index 0000000..4ee93c4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/home.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/identification.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/identification.svg new file mode 100644 index 0000000..03adf30 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/identification.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox-arrow-down.svg new file mode 100644 index 0000000..f593e2f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox-arrow-down.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox-stack.svg new file mode 100644 index 0000000..646629e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox-stack.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox.svg new file mode 100644 index 0000000..0de433b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/information-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/information-circle.svg new file mode 100644 index 0000000..46cbb61 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/information-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/italic.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/italic.svg new file mode 100644 index 0000000..15ffd3d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/italic.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/key.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/key.svg new file mode 100644 index 0000000..b4d50f5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/key.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/language.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/language.svg new file mode 100644 index 0000000..41570fe --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/language.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lifebuoy.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lifebuoy.svg new file mode 100644 index 0000000..2876a9c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lifebuoy.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/light-bulb.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/light-bulb.svg new file mode 100644 index 0000000..414cbf0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/light-bulb.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/link-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/link-slash.svg new file mode 100644 index 0000000..4e28b41 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/link-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/link.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/link.svg new file mode 100644 index 0000000..0c0509b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/link.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/list-bullet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/list-bullet.svg new file mode 100644 index 0000000..15b29ca --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/list-bullet.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lock-closed.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lock-closed.svg new file mode 100644 index 0000000..08ff114 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lock-closed.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lock-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lock-open.svg new file mode 100644 index 0000000..1fde51e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lock-open.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-circle.svg new file mode 100644 index 0000000..eb482b3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-circle.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-minus.svg new file mode 100644 index 0000000..b63c1a6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-minus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-plus.svg new file mode 100644 index 0000000..3a896f4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass.svg new file mode 100644 index 0000000..49657ff --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/map-pin.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/map-pin.svg new file mode 100644 index 0000000..15e9fff --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/map-pin.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/map.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/map.svg new file mode 100644 index 0000000..c8b9dbd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/map.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/megaphone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/megaphone.svg new file mode 100644 index 0000000..8d250a4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/megaphone.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/microphone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/microphone.svg new file mode 100644 index 0000000..db719be --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/microphone.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus-circle.svg new file mode 100644 index 0000000..4848ae5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus-small.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus-small.svg new file mode 100644 index 0000000..0c98430 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus-small.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus.svg new file mode 100644 index 0000000..f0853c4 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/moon.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/moon.svg new file mode 100644 index 0000000..21421e1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/moon.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/musical-note.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/musical-note.svg new file mode 100644 index 0000000..2647ae2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/musical-note.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/newspaper.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/newspaper.svg new file mode 100644 index 0000000..dd093eb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/newspaper.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/no-symbol.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/no-symbol.svg new file mode 100644 index 0000000..214f8d0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/no-symbol.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/numbered-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/numbered-list.svg new file mode 100644 index 0000000..e612e5d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/numbered-list.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paint-brush.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paint-brush.svg new file mode 100644 index 0000000..fd66d60 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paint-brush.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paper-airplane.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paper-airplane.svg new file mode 100644 index 0000000..fe0dba2 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paper-airplane.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paper-clip.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paper-clip.svg new file mode 100644 index 0000000..7b84732 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paper-clip.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pause-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pause-circle.svg new file mode 100644 index 0000000..39d5c9b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pause-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pause.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pause.svg new file mode 100644 index 0000000..58b58c5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pause.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pencil-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pencil-square.svg new file mode 100644 index 0000000..05f70cf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pencil-square.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pencil.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pencil.svg new file mode 100644 index 0000000..4b9897a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pencil.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/percent-badge.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/percent-badge.svg new file mode 100644 index 0000000..eeb025e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/percent-badge.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-arrow-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-arrow-down-left.svg new file mode 100644 index 0000000..ccba05f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-arrow-down-left.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-arrow-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-arrow-up-right.svg new file mode 100644 index 0000000..8aff3e8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-arrow-up-right.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-x-mark.svg new file mode 100644 index 0000000..2858b73 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-x-mark.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone.svg new file mode 100644 index 0000000..2e9b05a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/photo.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/photo.svg new file mode 100644 index 0000000..bb42a4d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/photo.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play-circle.svg new file mode 100644 index 0000000..d6a90c5 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play-pause.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play-pause.svg new file mode 100644 index 0000000..088f26a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play-pause.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play.svg new file mode 100644 index 0000000..d177bab --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus-circle.svg new file mode 100644 index 0000000..8e2d74d --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus-small.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus-small.svg new file mode 100644 index 0000000..18096b9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus-small.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus.svg new file mode 100644 index 0000000..3d22cdd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/power.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/power.svg new file mode 100644 index 0000000..ec3eb81 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/power.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/presentation-chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/presentation-chart-bar.svg new file mode 100644 index 0000000..637b71e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/presentation-chart-bar.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/presentation-chart-line.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/presentation-chart-line.svg new file mode 100644 index 0000000..6ebdcef --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/presentation-chart-line.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/printer.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/printer.svg new file mode 100644 index 0000000..eb04c85 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/printer.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/puzzle-piece.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/puzzle-piece.svg new file mode 100644 index 0000000..e519678 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/puzzle-piece.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/qr-code.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/qr-code.svg new file mode 100644 index 0000000..6be372e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/qr-code.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/question-mark-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/question-mark-circle.svg new file mode 100644 index 0000000..94150f1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/question-mark-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/queue-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/queue-list.svg new file mode 100644 index 0000000..7f45d2f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/queue-list.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/radio.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/radio.svg new file mode 100644 index 0000000..aab76e8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/radio.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/receipt-percent.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/receipt-percent.svg new file mode 100644 index 0000000..319028f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/receipt-percent.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/receipt-refund.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/receipt-refund.svg new file mode 100644 index 0000000..c48dd03 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/receipt-refund.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rectangle-group.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rectangle-group.svg new file mode 100644 index 0000000..fb09df7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rectangle-group.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rectangle-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rectangle-stack.svg new file mode 100644 index 0000000..c505e47 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rectangle-stack.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rocket-launch.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rocket-launch.svg new file mode 100644 index 0000000..0571660 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rocket-launch.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rss.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rss.svg new file mode 100644 index 0000000..d44d001 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rss.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/scale.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/scale.svg new file mode 100644 index 0000000..2287ace --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/scale.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/scissors.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/scissors.svg new file mode 100644 index 0000000..33d2770 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/scissors.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/server-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/server-stack.svg new file mode 100644 index 0000000..9d9ee19 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/server-stack.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/server.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/server.svg new file mode 100644 index 0000000..ea22cde --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/server.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/share.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/share.svg new file mode 100644 index 0000000..aecf6a3 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/share.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shield-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shield-check.svg new file mode 100644 index 0000000..7a74601 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shield-check.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shield-exclamation.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shield-exclamation.svg new file mode 100644 index 0000000..94dd996 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shield-exclamation.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shopping-bag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shopping-bag.svg new file mode 100644 index 0000000..87a0e41 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shopping-bag.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shopping-cart.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shopping-cart.svg new file mode 100644 index 0000000..43f8602 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shopping-cart.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/signal-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/signal-slash.svg new file mode 100644 index 0000000..ee7bc2c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/signal-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/signal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/signal.svg new file mode 100644 index 0000000..6034aec --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/signal.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/slash.svg new file mode 100644 index 0000000..29b1378 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/sparkles.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/sparkles.svg new file mode 100644 index 0000000..9c38ace --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/sparkles.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/speaker-wave.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/speaker-wave.svg new file mode 100644 index 0000000..2366120 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/speaker-wave.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/speaker-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/speaker-x-mark.svg new file mode 100644 index 0000000..47637aa --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/speaker-x-mark.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/square-2-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/square-2-stack.svg new file mode 100644 index 0000000..4bbdc7e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/square-2-stack.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/square-3-stack-3d.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/square-3-stack-3d.svg new file mode 100644 index 0000000..50c33a9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/square-3-stack-3d.svg @@ -0,0 +1,5 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/squares-2x2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/squares-2x2.svg new file mode 100644 index 0000000..84651b9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/squares-2x2.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/squares-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/squares-plus.svg new file mode 100644 index 0000000..f5b9779 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/squares-plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/star.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/star.svg new file mode 100644 index 0000000..875d16f --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/star.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/stop-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/stop-circle.svg new file mode 100644 index 0000000..a2d87ea --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/stop-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/stop.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/stop.svg new file mode 100644 index 0000000..56cdb3e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/stop.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/strikethrough.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/strikethrough.svg new file mode 100644 index 0000000..56fcfb0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/strikethrough.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/sun.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/sun.svg new file mode 100644 index 0000000..afca1bd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/sun.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/swatch.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/swatch.svg new file mode 100644 index 0000000..404c0bb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/swatch.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/table-cells.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/table-cells.svg new file mode 100644 index 0000000..da3bcaf --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/table-cells.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/tag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/tag.svg new file mode 100644 index 0000000..2969e32 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/tag.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ticket.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ticket.svg new file mode 100644 index 0000000..1bb654c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ticket.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/trash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/trash.svg new file mode 100644 index 0000000..8791a17 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/trash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/trophy.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/trophy.svg new file mode 100644 index 0000000..f8a5c8c --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/trophy.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/truck.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/truck.svg new file mode 100644 index 0000000..c919e58 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/truck.svg @@ -0,0 +1,5 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/tv.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/tv.svg new file mode 100644 index 0000000..2b33fba --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/tv.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/underline.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/underline.svg new file mode 100644 index 0000000..c6fef0e --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/underline.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-circle.svg new file mode 100644 index 0000000..26bc7ff --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-group.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-group.svg new file mode 100644 index 0000000..36c5e38 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-group.svg @@ -0,0 +1,4 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-minus.svg new file mode 100644 index 0000000..4fedc83 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-minus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-plus.svg new file mode 100644 index 0000000..eabe7c7 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-plus.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user.svg new file mode 100644 index 0000000..194979b --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/users.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/users.svg new file mode 100644 index 0000000..d098e72 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/users.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/variable.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/variable.svg new file mode 100644 index 0000000..922baf0 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/variable.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/video-camera-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/video-camera-slash.svg new file mode 100644 index 0000000..96ef401 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/video-camera-slash.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/video-camera.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/video-camera.svg new file mode 100644 index 0000000..7749fcd --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/video-camera.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/view-columns.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/view-columns.svg new file mode 100644 index 0000000..352e81a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/view-columns.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/viewfinder-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/viewfinder-circle.svg new file mode 100644 index 0000000..fc68de6 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/viewfinder-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wallet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wallet.svg new file mode 100644 index 0000000..57b676a --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wallet.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wifi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wifi.svg new file mode 100644 index 0000000..b05d487 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wifi.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/window.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/window.svg new file mode 100644 index 0000000..0f683fb --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/window.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wrench-screwdriver.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wrench-screwdriver.svg new file mode 100644 index 0000000..bb691b9 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wrench-screwdriver.svg @@ -0,0 +1,5 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wrench.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wrench.svg new file mode 100644 index 0000000..7ad17c8 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wrench.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/x-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/x-circle.svg new file mode 100644 index 0000000..d510ee1 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/x-circle.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/x-mark.svg new file mode 100644 index 0000000..5561f00 --- /dev/null +++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/x-mark.svg @@ -0,0 +1,3 @@ + diff --git a/apps/elektrine/assets/vendor/topbar.js b/apps/elektrine/assets/vendor/topbar.js new file mode 100644 index 0000000..4195727 --- /dev/null +++ b/apps/elektrine/assets/vendor/topbar.js @@ -0,0 +1,165 @@ +/** + * @license MIT + * topbar 2.0.0, 2023-02-04 + * https://buunguyen.github.io/topbar + * Copyright (c) 2021 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + // https://gist.github.com/paulirish/1579671 + (function () { + var lastTime = 0; + var vendors = ["ms", "moz", "webkit", "o"]; + for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = + window[vendors[x] + "RequestAnimationFrame"]; + window.cancelAnimationFrame = + window[vendors[x] + "CancelAnimationFrame"] || + window[vendors[x] + "CancelRequestAnimationFrame"]; + } + if (!window.requestAnimationFrame) + window.requestAnimationFrame = function (callback, element) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function () { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + if (!window.cancelAnimationFrame) + window.cancelAnimationFrame = function (id) { + clearTimeout(id); + }; + })(); + + var canvas, + currentProgress, + showing, + progressTimerId = null, + fadeTimerId = null, + delayTimerId = null, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { + 0: "rgba(26, 188, 156, .9)", + ".25": "rgba(52, 152, 219, .9)", + ".50": "rgba(241, 196, 15, .9)", + ".75": "rgba(230, 126, 34, .9)", + "1.0": "rgba(211, 84, 0, .9)", + }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; // need space for shadow + + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + document.body.appendChild(canvas); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function (delay) { + if (showing) return; + if (delay) { + if (delayTimerId) return; + delayTimerId = setTimeout(() => topbar.show(), delay); + } else { + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + clearTimeout(delayTimerId); + delayTimerId = null; + if (!showing) return; + showing = false; + if (progressTimerId != null) { + window.cancelAnimationFrame(progressTimerId); + progressTimerId = null; + } + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(this, window, document)); diff --git a/apps/elektrine/lib/elektrine.ex b/apps/elektrine/lib/elektrine.ex new file mode 100644 index 0000000..98d82c0 --- /dev/null +++ b/apps/elektrine/lib/elektrine.ex @@ -0,0 +1,9 @@ +defmodule Elektrine do + @moduledoc """ + Elektrine keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/apps/elektrine/lib/elektrine/account_identifiers.ex b/apps/elektrine/lib/elektrine/account_identifiers.ex new file mode 100644 index 0000000..74732db --- /dev/null +++ b/apps/elektrine/lib/elektrine/account_identifiers.ex @@ -0,0 +1,58 @@ +defmodule Elektrine.AccountIdentifiers do + @moduledoc """ + Shared helpers for rendering local account handles and public contact addresses. + """ + + alias Elektrine.Domains + + def local_handle(%{} = user) do + user + |> Map.get(:handle) + |> case do + value when is_binary(value) -> + case String.trim(value) do + "" -> Map.get(user, :username) + trimmed -> trimmed + end + + _ -> + Map.get(user, :username) + end + |> local_handle() + end + + def local_handle(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + identifier -> identifier <> "@" <> Domains.default_user_handle_domain() + end + end + + def local_handle(_), do: nil + + def at_local_handle(user_or_value) do + case local_handle(user_or_value) do + value when is_binary(value) -> "@" <> value + _ -> nil + end + end + + def public_contact_email(%{} = user) do + case Map.get(user, :username) do + username when is_binary(username) and username != "" -> + String.trim(username) <> "@" <> Domains.default_user_handle_domain() + + _ -> + nil + end + end + + def public_contact_email(_), do: nil + + def public_contact_mailto(user) do + case public_contact_email(user) do + email when is_binary(email) -> "mailto:" <> email + _ -> nil + end + end +end diff --git a/apps/elektrine/lib/elektrine/accounts.ex b/apps/elektrine/lib/elektrine/accounts.ex new file mode 100644 index 0000000..0b85dd4 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts.ex @@ -0,0 +1,1716 @@ +defmodule Elektrine.Accounts do + @moduledoc """ + The Accounts context. + Handles user accounts, authentication, and related functionality. + + This module serves as the main entry point for account-related operations + and delegates to specialized sub-contexts for specific functionality: + + - `Elektrine.Accounts.Authentication` - Password and 2FA authentication + - `Elektrine.Accounts.Blocking` - User blocking functionality + - `Elektrine.Accounts.Muting` - User muting functionality + - `Elektrine.Accounts.Moderation` - Admin moderation operations + - `Elektrine.Accounts.Tracking` - User activity tracking + """ + + import Ecto.Query, warn: false + alias Elektrine.Repo + + alias Elektrine.Accounts.Cached + alias Elektrine.Accounts.ConnectedAccount + alias Elektrine.Accounts.InviteCode + alias Elektrine.Accounts.InviteCodeUse + alias Elektrine.Accounts.User + alias Elektrine.Accounts.UsernameHistory + alias Elektrine.Accounts.UserSession + alias Elektrine.Async + alias Elektrine.Platform.Modules + + # Import sub-context modules for delegation + alias Elektrine.Accounts.AccountNotes + alias Elektrine.Accounts.Authentication + alias Elektrine.Accounts.Blocking + alias Elektrine.Accounts.Endorsements + alias Elektrine.Accounts.Moderation + alias Elektrine.Accounts.Muting + alias Elektrine.Accounts.Subscriptions + alias Elektrine.Accounts.Tracking + alias Elektrine.Subscriptions.RegistrationCheckout + + require Logger + + @self_service_invite_active_limit 5 + @self_service_invite_monthly_generation_limit 5 + @self_service_invite_monthly_use_limit 5 + @self_service_invite_max_uses 1 + @self_service_invite_expiry_days 14 + @seconds_per_day 86_400 + @activitypub_actor_update_fields [:avatar] + + ## Core User Management + + @doc "Lists external OAuth/OIDC accounts connected to a user." + def list_connected_accounts(user_id) when is_integer(user_id) do + ConnectedAccount + |> where([account], account.user_id == ^user_id) + |> order_by([account], asc: account.provider, asc: account.username) + |> Repo.all() + end + + def list_connected_accounts(_), do: [] + + @doc "Gets a connected account by provider identity." + def get_connected_account_by_provider(provider, provider_account_id) + when is_binary(provider) and is_binary(provider_account_id) do + Repo.get_by(ConnectedAccount, + provider: provider |> String.trim() |> String.downcase(), + provider_account_id: provider_account_id |> String.trim() |> String.downcase() + ) + end + + @doc "Creates or updates a user's connected provider account after OAuth succeeds." + def upsert_connected_account(%User{} = user, attrs) when is_map(attrs) do + user_id = user.id + + attrs = + attrs + |> stringify_account_attrs() + |> Map.put("user_id", user_id) + |> Map.put_new("last_verified_at", DateTime.utc_now() |> DateTime.truncate(:second)) + + case get_connected_account_by_provider(attrs["provider"], attrs["provider_account_id"]) do + %ConnectedAccount{user_id: ^user_id} = connected_account -> + connected_account + |> ConnectedAccount.changeset(attrs) + |> Repo.update() + + %ConnectedAccount{} -> + {:error, :provider_account_already_connected} + + nil -> + %ConnectedAccount{} + |> ConnectedAccount.changeset(attrs) + |> Repo.insert() + end + end + + defp stringify_account_attrs(attrs) do + Map.new(attrs, fn {key, value} -> {to_string(key), value} end) + end + + defp stringify_session_attrs(attrs), do: stringify_account_attrs(attrs) + + @doc """ + Returns the list of users. + + ## Examples + + iex> list_users() + [%User{}, ...] + + """ + def list_users do + from(u in User, + order_by: [desc: u.inserted_at], + select: [ + :id, + :username, + :is_admin, + :banned, + :inserted_at, + :registration_ip, + :last_login_ip, + :last_login_at, + :login_count + ] + ) + |> Repo.all() + end + + @doc """ + Searches for users by username or display name, excluding the current user. + """ + def search_users(query, current_user_id) do + # Sanitize search term to prevent LIKE pattern injection + safe_query = sanitize_search_term(query) + query_term = "%#{String.downcase(safe_query)}%" + + from(u in User, + where: u.id != ^current_user_id, + where: + fragment("LOWER(?) LIKE ?", u.username, ^query_term) or + fragment("LOWER(?) LIKE ?", u.display_name, ^query_term) or + fragment("LOWER(?) LIKE ?", u.handle, ^query_term), + order_by: [asc: u.username], + limit: 10, + select: [:id, :username, :display_name, :avatar, :handle] + ) + |> Repo.all() + end + + # Sanitize search terms to prevent LIKE pattern injection + defp sanitize_search_term(term), do: Elektrine.TextHelpers.sanitize_search_term(term) + + @doc """ + Gets a single user. + + Raises `Ecto.NoResultsError` if the User does not exist. + + ## Examples + + iex> get_user!(123) + %User{} + + iex> get_user!(456) + ** (Ecto.NoResultsError) + + """ + def get_user!(id), do: Repo.get!(User, id) + + @doc """ + Gets only the fields needed to validate a signed web session. + + This stays intentionally narrow because it runs on hot request paths. Callers + that need the full user should load it separately after validating the stamp. + """ + def get_user_session_stamp(id) when is_integer(id) do + from(u in User, + where: u.id == ^id, + select: %{ + id: u.id, + is_admin: u.is_admin, + banned: u.banned, + suspended: u.suspended, + suspended_until: u.suspended_until, + last_password_change: u.last_password_change, + auth_valid_after: u.auth_valid_after + } + ) + |> Repo.one() + end + + def get_user_session_stamp(_), do: nil + + @doc """ + Creates a tracked browser session for a successful sign-in. + """ + def create_user_session(user_or_id, attrs \\ %{}) + + def create_user_session(%User{id: user_id}, attrs) when is_map(attrs) do + create_user_session(user_id, attrs) + end + + def create_user_session(user_id, attrs) when is_integer(user_id) and is_map(attrs) do + %UserSession{} + |> UserSession.changeset(Map.put(stringify_session_attrs(attrs), "user_id", user_id)) + |> Repo.insert() + end + + @doc """ + Lists recent browser sessions for account security settings. + """ + def list_user_sessions(user_id) when is_integer(user_id) do + UserSession + |> where([session], session.user_id == ^user_id) + |> order_by([session], + asc: fragment("? IS NOT NULL", session.revoked_at), + desc: session.last_seen_at, + desc: session.inserted_at + ) + |> limit(50) + |> Repo.all() + end + + def list_user_sessions(_), do: [] + + def get_active_user_session(user_id, session_id) + when is_integer(user_id) and is_integer(session_id) do + UserSession + |> where([session], session.id == ^session_id) + |> where([session], session.user_id == ^user_id) + |> where([session], is_nil(session.revoked_at)) + |> Repo.one() + end + + def get_active_user_session(_user_id, _session_id), do: nil + + def touch_user_session(%UserSession{} = session) do + touch_user_session(session.id) + end + + def touch_user_session(session_id) when is_integer(session_id) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + cutoff = DateTime.add(now, -5 * 60, :second) + + from(session in UserSession, + where: + session.id == ^session_id and is_nil(session.revoked_at) and + session.last_seen_at < ^cutoff + ) + |> Repo.update_all(set: [last_seen_at: now]) + end + + def touch_user_session(_session_id), do: {0, nil} + + def revoke_user_session(user_id, session_id, reason \\ "revoked") + + def revoke_user_session(user_id, session_id, reason) + when is_integer(user_id) and is_integer(session_id) do + case Repo.get_by(UserSession, id: session_id, user_id: user_id) do + %UserSession{} = session -> + session + |> UserSession.revoke_changeset(reason) + |> Repo.update() + + nil -> + {:error, :not_found} + end + end + + def revoke_user_session(_user_id, _session_id, _reason), do: {:error, :not_found} + + def revoke_other_user_sessions( + user_id, + current_session_id, + reason \\ "signed_out_other_sessions" + ) + + def revoke_other_user_sessions(user_id, current_session_id, reason) + when is_integer(user_id) and is_integer(current_session_id) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + from(session in UserSession, + where: + session.user_id == ^user_id and session.id != ^current_session_id and + is_nil(session.revoked_at) + ) + |> Repo.update_all(set: [revoked_at: now, revoked_reason: reason]) + end + + def revoke_other_user_sessions(user_id, _current_session_id, reason) when is_integer(user_id) do + revoke_all_user_sessions(user_id, reason) + end + + def revoke_all_user_sessions(user_id, reason \\ "signed_out_all_sessions") + + def revoke_all_user_sessions(user_id, reason) when is_integer(user_id) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + from(session in UserSession, + where: session.user_id == ^user_id and is_nil(session.revoked_at) + ) + |> Repo.update_all(set: [revoked_at: now, revoked_reason: reason]) + end + + def revoke_all_user_sessions(_user_id, _reason), do: {0, nil} + + @doc """ + Gets a user by username. + + Returns nil if the User does not exist. + + ## Examples + + iex> get_user_by_username("username") + %User{} + + iex> get_user_by_username("nonexistent") + nil + + """ + def get_user_by_username(username) when is_binary(username) do + Repo.get_by(User, username: username) + end + + @doc """ + Gets a local user by the identifier used for ActivityPub actor URLs/WebFinger. + + Handles are canonical for federation, but older username-based identifiers + still resolve for older identifiers. + """ + def get_user_by_activitypub_identifier(identifier) when is_binary(identifier) do + normalized_identifier = + identifier + |> String.trim() + |> String.trim_leading("@") + |> String.downcase() + + cond do + normalized_identifier == "" -> + nil + + user = get_user_by_handle(normalized_identifier) -> + user + + true -> + get_user_by_username(normalized_identifier) + end + end + + def get_user_by_activitypub_identifier(_), do: nil + + @doc """ + Gets a user by username or handle (case-insensitive for handle). + Useful for @mentions where users might use either. + """ + def get_user_by_username_or_handle(identifier) when is_binary(identifier) do + # Try username first (exact match) + case Repo.get_by(User, username: identifier) do + nil -> + # Try handle (case-insensitive) + Repo.one( + from u in User, where: fragment("lower(?)", u.handle) == ^String.downcase(identifier) + ) + + user -> + user + end + end + + @doc """ + Creates a user. + + ## Examples + + iex> create_user(%{field: value}) + {:ok, %User{}} + + iex> create_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_user(attrs \\ %{}) do + result = + %User{} + |> User.registration_changeset(attrs) + |> Repo.insert() + + case result do + {:ok, user} -> + maybe_create_user_mailbox(user) + + # Keys will be generated lazily when first needed for federation + + # Reload user to get database default values (onboarding_completed, etc.) + user = Repo.get!(User, user.id) + + {:ok, user} + + error -> + error + end + end + + @doc """ + Updates a user. + + ## Examples + + iex> update_user(user, %{field: new_value}) + {:ok, %User{}} + + iex> update_user(user, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_user(%User{} = user, attrs) do + attrs = sanitize_update_attrs(attrs) + changeset = User.changeset(user, attrs) + + case Repo.update(changeset) do + {:ok, updated_user} -> + # Record username history and update mailbox if username changed + case Ecto.Changeset.get_change(changeset, :username) do + nil -> + :ok + + new_username -> + record_username_change(user.id, user.username, new_username) + update_mailbox_email_for_username_change(updated_user) + # Clear lookup caches keyed by the old username + Cached.invalidate_user_cache(user) + end + + maybe_federate_actor_update(updated_user, changeset) + Cached.invalidate_user_cache(updated_user) + {:ok, updated_user} + + error -> + error + end + end + + @doc """ + Updates notification-only user settings without validating unrelated profile fields. + """ + def update_notification_settings(%User{} = user, attrs) do + attrs = sanitize_update_attrs(attrs) + + user + |> User.notification_settings_changeset(attrs) + |> Repo.update() + |> invalidate_on_update() + end + + defp invalidate_on_update({:ok, %User{} = user} = result) do + Cached.invalidate_user_cache(user) + result + end + + defp invalidate_on_update(result), do: result + + defp sanitize_update_attrs(attrs) when is_map(attrs) do + Map.new(attrs, fn {key, value} -> {key, sanitize_update_value(value)} end) + end + + defp sanitize_update_attrs(attrs), do: attrs + + defp sanitize_update_value(value) when is_binary(value) do + value + |> ensure_valid_update_utf8() + |> String.replace(<<0>>, "") + end + + defp sanitize_update_value(%_{} = value), do: value + + defp sanitize_update_value(value) when is_map(value), do: sanitize_update_attrs(value) + + defp sanitize_update_value(value) when is_list(value), + do: Enum.map(value, &sanitize_update_value/1) + + defp sanitize_update_value(value), do: value + + defp ensure_valid_update_utf8(value) when is_binary(value) do + if String.valid?(value) do + value + else + case :unicode.characters_to_binary(value, :latin1, :utf8) do + converted when is_binary(converted) -> converted + {:error, good, _bad} when is_binary(good) -> good + {:incomplete, good, _rest} when is_binary(good) -> good + _ -> "" + end + end + end + + defp maybe_federate_actor_update(%User{activitypub_enabled: true} = user, changeset) do + if actor_update_field_changed?(changeset) do + Async.run(fn -> + Elektrine.ActivityPub.Outbox.federate_profile_update(user.id) + end) + end + + :ok + end + + defp maybe_federate_actor_update(_user, _changeset), do: :ok + + defp actor_update_field_changed?(%Ecto.Changeset{changes: changes}) do + Enum.any?(@activitypub_actor_update_fields, &Map.has_key?(changes, &1)) + end + + @doc """ + Updates the addressbook sync token (ctag) for a user. + Used by CardDAV for sync detection. + """ + def update_addressbook_ctag(%User{} = user, ctag) do + user + |> Ecto.Changeset.change(%{addressbook_ctag: ctag}) + |> Repo.update() + |> invalidate_on_update() + end + + @doc """ + Deletes a user and all associated data. + + ## Examples + + iex> delete_user(user) + {:ok, %User{}} + + iex> delete_user(user) + {:error, %Ecto.Changeset{}} + + """ + def delete_user(%User{} = user) do + # Delete all user's data first + Repo.transaction(fn -> + maybe_delete_email_data(user) + + # Finally delete the user + case Repo.delete(user) do + {:ok, user} -> user + {:error, changeset} -> Repo.rollback(changeset) + end + end) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user changes. + + ## Examples + + iex> change_user(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user(%User{} = user, attrs \\ %{}) do + User.changeset(user, attrs) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user registration changes. + + ## Examples + + iex> change_user_registration(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_registration(%User{} = user, attrs \\ %{}) do + User.registration_changeset(user, attrs) + end + + @doc """ + Get user's sequential number based on account creation order. + Returns the position of this user in the chronological list of all users. + """ + def get_user_number(user) do + from(u in User, + where: u.inserted_at <= ^user.inserted_at, + select: count(u.id) + ) + |> Repo.one() + end + + @doc """ + Updates a user's locale and/or timezone preference. + """ + def update_user_locale(%User{} = user, attrs) when is_map(attrs) do + user + |> User.locale_changeset(attrs) + |> Repo.update() + |> invalidate_on_update() + end + + def update_user_locale(%User{} = user, locale) when is_binary(locale) do + update_user_locale(user, %{locale: locale}) + end + + ## Authentication Functions - Delegated to Authentication module + + defdelegate authenticate_user(username, password), to: Authentication + defdelegate verify_user_password(user, password), to: Authentication + defdelegate update_user_password(user, attrs, opts \\ []), to: Authentication + defdelegate change_user_password(user, attrs \\ %{}), to: Authentication + + # Two-Factor Authentication + defdelegate initiate_two_factor_setup(user), to: Authentication + defdelegate enable_two_factor(user, secret, backup_codes, totp_code), to: Authentication + defdelegate disable_two_factor(user), to: Authentication + defdelegate verify_two_factor_code(user, code), to: Authentication + defdelegate verify_totp_only(user, code), to: Authentication + defdelegate regenerate_backup_codes(user), to: Authentication + defdelegate admin_reset_2fa(user), to: Authentication + + # App Passwords + defdelegate list_app_passwords(user_id), to: Authentication + defdelegate create_app_password(user_id, attrs), to: Authentication + defdelegate delete_app_password(app_password_id, user_id), to: Authentication + defdelegate authenticate_with_app_password(username, token), to: Authentication + defdelegate authenticate_with_app_password_info(username, token), to: Authentication + defdelegate app_password_exists?(app_password_id, user_id), to: Authentication + defdelegate mail_auth_subject(identifier), to: Authentication + defdelegate verify_app_password(user_id, token, ip_address \\ nil), to: Authentication + + # Password Recovery + defdelegate update_recovery_email(user, attrs), to: Authentication + defdelegate initiate_password_reset(username_or_email), to: Authentication + defdelegate get_users_by_recovery_email(email), to: Authentication + defdelegate get_user_by_password_reset_token(token), to: Authentication + defdelegate reset_password_with_token(token, attrs), to: Authentication + defdelegate validate_password_reset_token(token), to: Authentication + defdelegate clear_password_reset_token(user), to: Authentication + defdelegate get_users_with_old_passwords(max_days), to: Authentication + defdelegate count_users_with_old_passwords(max_days), to: Authentication + defdelegate admin_reset_password(user, attrs), to: Authentication + + ## Blocking Functions - Delegated to Blocking module + + defdelegate block_user(blocker_id, blocked_id, reason \\ nil), to: Blocking + defdelegate unblock_user(blocker_id, blocked_id), to: Blocking + defdelegate user_blocked?(blocker_id, blocked_id), to: Blocking + defdelegate list_blocked_users(blocker_id), to: Blocking + defdelegate list_users_who_blocked(blocked_id), to: Blocking + defdelegate block_remote_actor(user_id, remote_actor_id), to: Blocking + defdelegate unblock_remote_actor(user_id, remote_actor_id), to: Blocking + defdelegate remote_actor_blocked?(user_id, remote_actor_id), to: Blocking + defdelegate list_blocked_remote_actors(user_id), to: Blocking + defdelegate mute_remote_actor(user_id, remote_actor_id), to: Blocking + defdelegate unmute_remote_actor(user_id, remote_actor_id), to: Blocking + defdelegate remote_actor_muted?(user_id, remote_actor_id), to: Blocking + defdelegate list_muted_remote_actors(user_id), to: Blocking + defdelegate block_domain(user_id, domain), to: Blocking + defdelegate unblock_domain(user_id, domain), to: Blocking + defdelegate list_blocked_domains(user_id), to: Blocking + defdelegate domain_blocked?(user_id, domain), to: Blocking + + ## Muting Functions - Delegated to Muting module + + defdelegate mute_user(muter_id, muted_id, mute_notifications \\ false, expires_at \\ nil), + to: Muting + + defdelegate unmute_user(muter_id, muted_id), to: Muting + defdelegate user_muted?(muter_id, muted_id), to: Muting + defdelegate user_muting_notifications?(muter_id, muted_id), to: Muting + defdelegate list_muted_users(muter_id), to: Muting + defdelegate expire_due_mutes(now \\ Elektrine.Time.utc_now()), to: Muting + defdelegate account_note_comment(source_user_id, target), to: AccountNotes, as: :note_comment + defdelegate put_account_note(source_user_id, target, comment), to: AccountNotes, as: :put_note + + defdelegate endorse_account(user_id, account), to: Endorsements + defdelegate unendorse_account(user_id, account), to: Endorsements + defdelegate account_endorsed?(user_id, account), to: Endorsements + defdelegate list_endorsed_accounts(user_id), to: Endorsements + defdelegate subscribe_to_account(user_id, account), to: Subscriptions + defdelegate unsubscribe_from_account(user_id, account), to: Subscriptions + defdelegate account_subscribed?(user_id, account), to: Subscriptions + defdelegate notify_subscribers_for_message(message), to: Subscriptions + + def import_relationships(user_id, import_type, identifiers) when is_list(identifiers) do + Elektrine.Accounts.AccountImportWorker.enqueue_many(user_id, import_type, identifiers) + end + + ## Moderation Functions - Delegated to Moderation module + + defdelegate ban_user(user, attrs \\ %{}), to: Moderation + defdelegate unban_user(user), to: Moderation + defdelegate suspend_user(user, attrs), to: Moderation + defdelegate unsuspend_user(user), to: Moderation + defdelegate user_suspended?(user), to: Moderation + defdelegate unsuspend_expired_users(), to: Moderation + defdelegate update_user_admin_status(user, is_admin), to: Moderation + defdelegate admin_create_user(attrs \\ %{}), to: Moderation + defdelegate admin_update_user(user, attrs), to: Moderation + defdelegate admin_delete_user(user), to: Moderation + defdelegate change_user_admin(user, attrs \\ %{}), to: Moderation + defdelegate change_user_admin_registration(user, attrs \\ %{}), to: Moderation + + # Account Deletion Requests + defdelegate create_deletion_request(user, attrs \\ %{}), to: Moderation + defdelegate get_pending_deletion_request(user), to: Moderation + defdelegate list_deletion_requests(), to: Moderation + defdelegate get_deletion_request!(id), to: Moderation + defdelegate review_deletion_request(request, admin, status, attrs \\ %{}), to: Moderation + + ## Tracking Functions - Delegated to Tracking module + + defdelegate update_user_login_info(user, ip_address), to: Tracking + defdelegate record_imap_access(user_id), to: Tracking + defdelegate record_pop3_access(user_id), to: Tracking + defdelegate update_last_seen(user_id), to: Tracking + defdelegate update_last_seen_async(user_id), to: Tracking + defdelegate update_user_status(user, status, message \\ nil), to: Tracking + defdelegate get_user_status(user_id), to: Tracking + + @doc """ + Invalidates existing browser/session tokens for a user. + """ + def invalidate_auth_sessions(%User{} = user) do + case user + |> Ecto.Changeset.change(%{ + auth_valid_after: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.update() do + {:ok, updated_user} -> + _ = revoke_all_user_sessions(user.id, "global_invalidation") + {:ok, updated_user} + + error -> + error + end + end + + ## Invite Codes + + @doc """ + Returns the list of invite codes. + """ + def list_invite_codes do + InviteCode + |> order_by(desc: :inserted_at) + |> preload(:created_by) + |> Repo.all() + end + + @doc """ + Gets a single invite code. + + Returns nil if the InviteCode does not exist. + """ + def get_invite_code(id), do: Repo.get(InviteCode, id) |> Repo.preload(:created_by) + + @doc """ + Gets a single invite code. + + Raises if the InviteCode does not exist. + """ + def get_invite_code!(id), do: Repo.get!(InviteCode, id) |> Repo.preload(:created_by) + + @doc """ + Gets an invite code by its code string. + + Returns nil if the InviteCode does not exist. + """ + def get_invite_code_by_code(code) do + normalized_code = InviteCode.normalize_code(code) + + InviteCode + |> where([i], fragment("upper(?)", i.code) == ^normalized_code) + |> preload(:created_by) + |> Repo.one() + end + + @doc """ + Creates an invite code. + """ + def create_invite_code(attrs \\ %{}) do + attrs = + attrs + |> Enum.map(fn {k, v} -> {to_string(k), v} end) + |> Enum.into(%{}) + + if blank_invite_code?(attrs["code"]) do + create_generated_invite_code(attrs, 3) + else + %InviteCode{} + |> InviteCode.create_changeset(attrs) + |> Repo.insert() + end + end + + @doc """ + Updates an invite code. + """ + def update_invite_code(%InviteCode{} = invite_code, attrs) do + invite_code + |> InviteCode.update_changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes an invite code. + """ + def delete_invite_code(%InviteCode{} = invite_code) do + Repo.delete(invite_code) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking invite code changes. + """ + def change_invite_code(%InviteCode{} = invite_code, attrs \\ %{}) do + InviteCode.changeset(invite_code, attrs) + end + + @doc """ + Registers a new user and claims an invite code atomically. + """ + def register_user_with_invite(attrs) do + invite_code = extract_invite_code(attrs) + + Ecto.Multi.new() + |> Ecto.Multi.insert(:user, User.registration_changeset(%User{}, attrs)) + |> Ecto.Multi.run(:invite_use, fn repo, %{user: user} -> + claim_invite_code(repo, invite_code, user.id) + end) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> + maybe_create_user_mailbox(user) + {:ok, Repo.get!(User, user.id)} + + {:error, :user, changeset, _changes_so_far} -> + {:error, changeset} + + {:error, :invite_use, reason, _changes_so_far} -> + {:error, invite_registration_changeset(attrs, reason)} + end + end + + @doc """ + Registers a new user using either a valid invite code or a fulfilled paid registration checkout. + """ + def register_user_with_access(attrs) do + invite_code = extract_invite_code(attrs) + registration_access_token = extract_registration_access_token(attrs) + + Ecto.Multi.new() + |> Ecto.Multi.insert(:user, User.registration_changeset(%User{}, attrs)) + |> Ecto.Multi.run(:registration_access, fn repo, %{user: user} -> + cond do + !blank_invite_code?(invite_code) -> + claim_invite_code(repo, invite_code, user.id) + + !blank_registration_access_token?(registration_access_token) -> + claim_registration_checkout(repo, registration_access_token, user.id) + + true -> + {:error, :invite_or_payment_required} + end + end) + |> Repo.transaction() + |> case do + {:ok, %{user: user}} -> + maybe_create_user_mailbox(user) + {:ok, Repo.get!(User, user.id)} + + {:error, :user, changeset, _changes_so_far} -> + {:error, changeset} + + {:error, :registration_access, reason, _changes_so_far} -> + {:error, access_registration_changeset(attrs, reason)} + end + end + + @doc """ + Validates and uses an invite code for a user registration. + """ + def use_invite_code(code, user_id) do + Repo.transaction(fn -> + case claim_invite_code(Repo, code, user_id) do + {:ok, invite_code} -> invite_code + {:error, reason} -> Repo.rollback(reason) + end + end) + |> case do + {:ok, invite_code} -> {:ok, invite_code} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Gets statistics for invite codes. + """ + def get_invite_code_stats do + now = DateTime.utc_now() + + %{ + total: Repo.aggregate(InviteCode, :count), + active: + Repo.aggregate( + from(i in InviteCode, + where: + i.is_active == true and + (is_nil(i.expires_at) or i.expires_at >= ^now) and + i.uses_count < i.max_uses + ), + :count + ), + expired: + Repo.aggregate( + from(i in InviteCode, + where: not is_nil(i.expires_at) and i.expires_at < ^now + ), + :count + ), + exhausted: Repo.aggregate(from(i in InviteCode, where: i.uses_count >= i.max_uses), :count) + } + end + + @doc """ + Returns the invite-code policy used by self-service invite creation. + """ + def self_service_invite_policy do + %{ + min_trust_level: Elektrine.System.self_service_invite_min_trust_level(), + max_active_codes: @self_service_invite_active_limit, + max_codes_per_month: @self_service_invite_monthly_generation_limit, + max_uses_per_month: @self_service_invite_monthly_use_limit, + max_uses: @self_service_invite_max_uses, + expires_in_days: @self_service_invite_expiry_days + } + end + + @doc """ + Returns whether a user can create invite codes from account settings. + """ + def user_can_create_invite_codes?(%User{} = user) do + user.is_admin || user.trust_level >= self_service_invite_policy().min_trust_level + end + + def user_can_create_invite_codes?(_), do: false + + @doc """ + Returns invite codes created by a specific user. + """ + def list_user_invite_codes(user_id) do + InviteCode + |> where([i], i.created_by_id == ^user_id) + |> order_by([i], desc: i.inserted_at) + |> preload(:created_by) + |> Repo.all() + end + + @doc """ + Creates a constrained self-service invite code for a user. + """ + def create_self_service_invite_code(%User{} = user, attrs \\ %{}) do + attrs = + attrs + |> Enum.map(fn {k, v} -> {to_string(k), v} end) + |> Enum.into(%{}) + + with :ok <- ensure_self_service_invites_enabled(), + :ok <- authorize_self_service_invite_creation(user) do + Repo.transaction(fn -> + with :ok <- ensure_self_service_invite_creation_capacity(Repo, user), + {:ok, invite_code} <- + create_invite_code(%{ + "created_by_id" => user.id, + "max_uses" => @self_service_invite_max_uses, + "expires_at" => self_service_invite_expiration(), + "note" => normalize_optional_string(attrs["note"]) + }) do + invite_code + else + {:error, reason} -> Repo.rollback(reason) + end + end) + |> case do + {:ok, invite_code} -> {:ok, invite_code} + {:error, reason} -> {:error, reason} + end + end + end + + @doc """ + Deactivates a self-service invite code owned by the user. + """ + def deactivate_self_service_invite_code(%User{} = user, invite_code_id) + when is_integer(invite_code_id) do + case get_user_invite_code(user.id, invite_code_id) do + nil -> + {:error, :not_found} + + invite_code -> + update_invite_code(invite_code, %{is_active: false}) + end + end + + ## Handle Management + + @doc """ + Get a user by handle. + """ + def get_user_by_handle(handle) when is_binary(handle) do + Repo.one(from u in User, where: fragment("lower(?)", u.handle) == ^String.downcase(handle)) + end + + @doc """ + Update a user's handle. + Enforces 30-day cooldown and 90-day reservation. + """ + def update_user_handle(%User{} = user, handle) do + changeset = User.handle_changeset(user, %{handle: handle}) + + Repo.transaction(fn -> + case Repo.update(changeset) do + {:ok, updated_user} -> + # Record old handle in history if it changed + if user.handle && user.handle != updated_user.handle do + record_handle_history(user) + end + + updated_user + + {:error, changeset} -> + Repo.rollback(changeset) + end + end) + |> invalidate_on_update() + end + + @doc """ + Update a user's display name. + """ + def update_user_display_name(%User{} = user, display_name) do + user + |> User.handle_changeset(%{display_name: display_name}) + |> Repo.update() + |> invalidate_on_update() + end + + @doc """ + Check if a handle is available for claiming. + """ + def handle_available?(handle) when is_binary(handle) do + User.handle_available?(handle) + end + + # Check if user has priority claim (their original username) + + # Force claim a handle (bump current holder if necessary) + + # Generate alternative handle for displaced user + + # Record handle in history for 90-day reservation + defp record_handle_history(%User{} = user) do + if user.handle do + # Mark previous history entry as ended + from(h in "handle_history", + where: h.user_id == ^user.id and is_nil(h.used_until), + update: [ + set: [ + used_until: ^DateTime.utc_now(), + reserved_until: ^(DateTime.utc_now() |> DateTime.add(90 * 24 * 60 * 60, :second)) + ] + ] + ) + |> Repo.update_all([]) + + # Create new history entry + Repo.insert_all("handle_history", [ + %{ + user_id: user.id, + handle: user.handle, + used_from: user.handle_changed_at || user.inserted_at, + used_until: DateTime.utc_now(), + reserved_until: DateTime.utc_now() |> DateTime.add(90 * 24 * 60 * 60, :second), + inserted_at: DateTime.utc_now(), + updated_at: DateTime.utc_now() + } + ]) + end + end + + ## Privacy Settings + + @doc """ + Checks if a user can add another user to a group/channel based on privacy settings. + Returns {:ok, :allowed} or {:error, :privacy_restriction} + """ + def can_add_to_group?(target_user, requesting_user) do + check_privacy_permission(target_user, requesting_user, :allow_group_adds_from) + end + + @doc """ + Checks if a user can send a direct message to another user based on privacy settings. + Returns {:ok, :allowed} or {:error, :privacy_restriction} + """ + def can_send_direct_message?(target_user, requesting_user) do + check_privacy_permission(target_user, requesting_user, :allow_direct_messages_from) + end + + @doc """ + Checks if a user can mention another user based on privacy settings. + Returns {:ok, :allowed} or {:error, :privacy_restriction} + """ + def can_mention?(target_user, requesting_user) do + check_privacy_permission(target_user, requesting_user, :allow_mentions_from) + end + + @doc """ + Checks if a user can view another user's profile based on privacy settings. + Returns {:ok, :allowed} or {:error, :privacy_restriction} + """ + def can_view_profile?(target_user, requesting_user) do + setting = Map.get(target_user, :profile_visibility, "public") + + case setting do + "public" -> + {:ok, :allowed} + + "followers" -> + cond do + # If no requesting user (logged out), deny access + requesting_user == nil -> + {:error, :privacy_restriction} + + # If it's the user viewing their own profile, allow + requesting_user.id == target_user.id -> + {:ok, :allowed} + + # Check if requesting user follows target user + following?(requesting_user.id, target_user.id) -> + {:ok, :allowed} + + # Default case + true -> + {:error, :privacy_restriction} + end + + "private" -> + # Only allow the user to view their own profile + if requesting_user && requesting_user.id == target_user.id do + {:ok, :allowed} + else + {:error, :privacy_restriction} + end + + _ -> + # Default to public if setting is not recognized + {:ok, :allowed} + end + end + + # Private helper function to check privacy permissions + defp check_privacy_permission(target_user, requesting_user, field) do + # Always allow users to access their own resources + if requesting_user && requesting_user.id == target_user.id do + {:ok, :allowed} + else + # If requesting user is nil (anonymous), only allow if setting is "everyone" + if requesting_user == nil do + setting = Map.get(target_user, field, "everyone") + + if setting == "everyone" do + {:ok, :allowed} + else + {:error, :privacy_restriction} + end + else + # Check the privacy setting + setting = Map.get(target_user, field, "everyone") + + case setting do + "everyone" -> + {:ok, :allowed} + + "following" -> + # Check if target user follows the requesting user + if following?(target_user.id, requesting_user.id) do + {:ok, :allowed} + else + {:error, :privacy_restriction} + end + + "followers" -> + # Check if requesting user follows the target user + if following?(requesting_user.id, target_user.id) do + {:ok, :allowed} + else + {:error, :privacy_restriction} + end + + "mutual" -> + # Check if both users follow each other + if following?(target_user.id, requesting_user.id) && + following?(requesting_user.id, target_user.id) do + {:ok, :allowed} + else + {:error, :privacy_restriction} + end + + "nobody" -> + {:error, :privacy_restriction} + + _ -> + # Default to most permissive for unknown values + {:ok, :allowed} + end + end + end + end + + @doc """ + Checks if a user is following another user. + """ + def following?(follower_id, followed_id) do + # This assumes you have a follows/followers system. + query = + from f in "follows", + where: f.follower_id == ^follower_id and f.followed_id == ^followed_id, + select: true + + Repo.exists?(query) + end + + # Record username change in history for 1-year blocking + defp record_username_change(user_id, old_username, _new_username) do + # Record the old username being abandoned + history_attrs = %{ + user_id: user_id, + username: old_username, + previous_username: nil, + changed_at: DateTime.utc_now() |> DateTime.truncate(:second) + } + + case %UsernameHistory{} + |> UsernameHistory.changeset(history_attrs) + |> Repo.insert() do + {:ok, history_record} -> + {:ok, history_record} + + {:error, changeset} -> + Logger.error("Failed to record username history: #{inspect(changeset.errors)}") + # Don't fail the username change if history recording fails + {:error, changeset} + end + end + + # Updates the user's mailbox email address to match their new username. + # This prevents duplicate mailboxes when usernames change. + defp update_mailbox_email_for_username_change(user) do + if email_module_compiled?() do + case Elektrine.Email.get_user_mailbox(user.id) do + nil -> + :ok + + mailbox -> + # Calculate the new email address based on current username + domain = Elektrine.Domains.primary_email_domain() + new_email = "#{user.username}@#{domain}" + + # Only process if the email is different + if mailbox.email != new_email do + # Check if the new email conflicts with an existing mailbox + case Elektrine.Email.get_mailbox_by_email(new_email) do + nil -> + # New email is available - update the mailbox in place. + case Elektrine.Email.transition_mailbox_for_username_change( + user, + mailbox, + new_email + ) do + {:ok, _new_mailbox} -> + :ok + + {:error, reason} -> + Logger.error( + "Failed to transition mailbox for user #{user.id}: #{inspect(reason)}" + ) + + :ok + end + + _existing_mailbox -> + # Conflict! The new username's email is already taken + Logger.error( + "Cannot create #{new_email} for user #{user.id} - email already taken" + ) + + :ok + end + else + :ok + end + end + else + :ok + end + end + + defp maybe_create_user_mailbox(user) do + if email_module_compiled?() do + Elektrine.Email.create_mailbox(user) + else + :ok + end + end + + defp create_generated_invite_code(attrs, attempts_remaining) when attempts_remaining > 0 do + generated_attrs = Map.put(attrs, "code", InviteCode.generate_code()) + + case %InviteCode{} |> InviteCode.create_changeset(generated_attrs) |> Repo.insert() do + {:error, %Ecto.Changeset{} = changeset} = error -> + if autogenerated_code_collision?(changeset) and attempts_remaining > 1 do + create_generated_invite_code(attrs, attempts_remaining - 1) + else + error + end + + success -> + success + end + end + + defp create_generated_invite_code(attrs, _attempts_remaining) do + %InviteCode{} + |> InviteCode.create_changeset(Map.put(attrs, "code", InviteCode.generate_code())) + |> Repo.insert() + end + + defp autogenerated_code_collision?(%Ecto.Changeset{} = changeset) do + Enum.any?(changeset.errors, fn + {:code, {_message, opts}} -> Keyword.get(opts, :constraint) == :unique + _other -> false + end) + end + + defp claim_invite_code(repo, code, user_id) do + case get_invite_code_for_update(repo, code) do + nil -> + {:error, :invalid_code} + + %InviteCode{} = invite_code -> + case invite_code_validation_error(invite_code) do + nil -> + with :ok <- ensure_self_service_invite_use_capacity(repo, invite_code), + {:ok, _use} <- insert_invite_code_use(repo, invite_code.id, user_id), + {1, _} <- increment_invite_code_usage(repo, invite_code.id) do + {:ok, %{invite_code | uses_count: invite_code.uses_count + 1}} + else + {:error, :monthly_invite_use_limit_reached} -> + {:error, :monthly_invite_use_limit_reached} + + {:error, %Ecto.Changeset{} = changeset} -> + {:error, invite_code_use_error(changeset)} + + {0, _} -> + {:error, :code_exhausted} + end + + reason -> + {:error, reason} + end + end + end + + defp get_invite_code_for_update(repo, code) do + normalized_code = InviteCode.normalize_code(code) + + InviteCode + |> where([i], fragment("upper(?)", i.code) == ^normalized_code) + |> lock("FOR UPDATE") + |> repo.one() + end + + defp invite_code_validation_error(%InviteCode{} = invite_code) do + cond do + !invite_code.is_active -> :code_inactive + InviteCode.expired?(invite_code) -> :code_expired + InviteCode.exhausted?(invite_code) -> :code_exhausted + true -> nil + end + end + + defp insert_invite_code_use(repo, invite_code_id, user_id) do + %InviteCodeUse{} + |> InviteCodeUse.changeset(%{invite_code_id: invite_code_id, user_id: user_id}) + |> repo.insert() + end + + defp claim_registration_checkout(repo, lookup_token, user_id) do + case get_registration_checkout_for_update(repo, lookup_token) do + nil -> + {:error, :invalid_registration_access} + + %RegistrationCheckout{} = checkout -> + case registration_checkout_validation_error(checkout) do + nil -> + checkout + |> RegistrationCheckout.fulfill_changeset(%{ + redeemed_at: DateTime.utc_now() |> DateTime.truncate(:second), + redeemed_by_user_id: user_id + }) + |> repo.update() + |> case do + {:ok, updated_checkout} -> {:ok, updated_checkout} + {:error, _changeset} -> {:error, :invalid_registration_access} + end + + reason -> + {:error, reason} + end + end + end + + defp get_registration_checkout_for_update(repo, lookup_token) do + normalized_token = String.trim(to_string(lookup_token)) + + RegistrationCheckout + |> where([c], c.lookup_token == ^RegistrationCheckout.hash_lookup_token(normalized_token)) + |> lock("FOR UPDATE") + |> repo.one() + end + + defp registration_checkout_validation_error(%RegistrationCheckout{} = checkout) do + cond do + checkout.status != "fulfilled" -> :registration_payment_pending + not is_nil(checkout.redeemed_at) -> :registration_access_already_used + true -> nil + end + end + + defp increment_invite_code_usage(repo, invite_code_id) do + from(i in InviteCode, + where: i.id == ^invite_code_id and i.uses_count < i.max_uses + ) + |> repo.update_all(inc: [uses_count: 1]) + end + + defp invite_code_use_error(%Ecto.Changeset{} = changeset) do + case Enum.find(changeset.errors, fn {field, _error} -> field == :user_id end) do + {:user_id, {_message, opts}} -> + if Keyword.get(opts, :constraint) == :unique do + :already_used + else + :code_not_valid + end + + _other -> + :code_not_valid + end + end + + defp invite_registration_changeset(attrs, reason) do + %User{} + |> User.registration_changeset(attrs) + |> Ecto.Changeset.add_error(:invite_code, invite_code_error_message(reason)) + end + + defp access_registration_changeset(attrs, reason) do + changeset = User.registration_changeset(%User{}, attrs) + + case reason do + reason + when reason in [ + :invalid_code, + :code_expired, + :code_exhausted, + :code_inactive, + :monthly_invite_use_limit_reached, + :already_used + ] -> + Ecto.Changeset.add_error(changeset, :invite_code, invite_code_error_message(reason)) + + _ -> + Ecto.Changeset.add_error( + changeset, + :registration_access_token, + registration_access_error_message(reason) + ) + end + end + + defp invite_code_error_message(:invalid_code), do: "Invalid invite code" + defp invite_code_error_message(:code_expired), do: "This invite code has expired" + + defp invite_code_error_message(:code_exhausted), + do: "This invite code has reached its usage limit" + + defp invite_code_error_message(:code_inactive), do: "This invite code is no longer active" + + defp invite_code_error_message(:monthly_invite_use_limit_reached), + do: "This invite code is temporarily unavailable right now" + + defp invite_code_error_message(:already_used), + do: "This account has already used an invite code" + + defp invite_code_error_message(_reason), do: "Invalid invite code" + + defp registration_access_error_message(:registration_payment_pending), + do: "Your payment is still being confirmed. Please refresh and try again in a moment" + + defp registration_access_error_message(:registration_access_already_used), + do: "This paid registration access has already been used" + + defp registration_access_error_message(:invite_or_payment_required), + do: "An invite code or paid access is required to create an account" + + defp registration_access_error_message(:invalid_registration_access), + do: "Paid registration access could not be verified" + + defp registration_access_error_message(_reason), + do: "An invite code or paid access is required to create an account" + + defp extract_invite_code(attrs) when is_map(attrs) do + Map.get(attrs, "invite_code") || Map.get(attrs, :invite_code) + end + + defp extract_registration_access_token(attrs) when is_map(attrs) do + Map.get(attrs, "registration_access_token") || Map.get(attrs, :registration_access_token) + end + + defp blank_invite_code?(code) do + is_nil(code) or not Elektrine.Strings.present?(to_string(code)) + end + + defp blank_registration_access_token?(token) do + is_nil(token) or not Elektrine.Strings.present?(to_string(token)) + end + + defp ensure_self_service_invites_enabled do + if Elektrine.System.invite_codes_enabled?() do + :ok + else + {:error, :invite_codes_disabled} + end + end + + defp authorize_self_service_invite_creation(%User{} = user) do + if user_can_create_invite_codes?(user) do + :ok + else + {:error, :insufficient_trust_level} + end + end + + defp ensure_self_service_invite_creation_capacity(repo, %User{} = user) do + user = + case lock_user_for_update(repo, user.id) do + %User{} = locked_user -> locked_user + nil -> user + end + + with :ok <- ensure_self_service_invite_capacity(repo, user.id) do + ensure_self_service_monthly_generation_capacity(repo, user) + end + end + + defp ensure_self_service_invite_capacity(repo, user_id) do + now = DateTime.utc_now() + + active_count = + from(i in InviteCode, + where: + i.created_by_id == ^user_id and + i.is_active == true and + (is_nil(i.expires_at) or i.expires_at >= ^now) and + i.uses_count < i.max_uses + ) + |> repo.aggregate(:count) + + if active_count < @self_service_invite_active_limit do + :ok + else + {:error, :invite_code_limit_reached} + end + end + + defp ensure_self_service_monthly_generation_capacity(_repo, %User{is_admin: true}), do: :ok + + defp ensure_self_service_monthly_generation_capacity(repo, %User{id: user_id}) do + {month_start, next_month_start} = current_month_naive_window() + + generated_count = + from(i in InviteCode, + where: + i.created_by_id == ^user_id and + i.inserted_at >= ^month_start and + i.inserted_at < ^next_month_start + ) + |> repo.aggregate(:count) + + if generated_count < @self_service_invite_monthly_generation_limit do + :ok + else + {:error, :monthly_invite_code_limit_reached} + end + end + + defp ensure_self_service_invite_use_capacity(_repo, %InviteCode{created_by_id: nil}), do: :ok + + defp ensure_self_service_invite_use_capacity(repo, %InviteCode{created_by_id: created_by_id}) do + case lock_user_for_update(repo, created_by_id) do + %User{} = creator -> ensure_self_service_monthly_use_capacity(repo, creator) + nil -> :ok + end + end + + defp ensure_self_service_monthly_use_capacity(_repo, %User{is_admin: true}), do: :ok + + defp ensure_self_service_monthly_use_capacity(repo, %User{id: user_id}) do + {month_start, next_month_start} = current_month_datetime_window() + + used_count = + from(invite_use in InviteCodeUse, + join: invite_code in InviteCode, + on: invite_code.id == invite_use.invite_code_id, + where: + invite_code.created_by_id == ^user_id and + invite_use.used_at >= ^month_start and + invite_use.used_at < ^next_month_start + ) + |> repo.aggregate(:count) + + if used_count < @self_service_invite_monthly_use_limit do + :ok + else + {:error, :monthly_invite_use_limit_reached} + end + end + + defp lock_user_for_update(repo, user_id) do + from(u in User, + where: u.id == ^user_id, + lock: "FOR UPDATE" + ) + |> repo.one() + end + + defp get_user_invite_code(user_id, invite_code_id) do + InviteCode + |> where([i], i.id == ^invite_code_id and i.created_by_id == ^user_id) + |> preload(:created_by) + |> Repo.one() + end + + defp self_service_invite_expiration do + DateTime.utc_now() + |> DateTime.add(@self_service_invite_expiry_days * @seconds_per_day, :second) + |> DateTime.truncate(:second) + end + + defp current_month_naive_window do + {month_start, next_month_start} = current_month_date_window() + + { + NaiveDateTime.new!(month_start, ~T[00:00:00]), + NaiveDateTime.new!(next_month_start, ~T[00:00:00]) + } + end + + defp current_month_datetime_window do + {month_start, next_month_start} = current_month_date_window() + + { + DateTime.new!(month_start, ~T[00:00:00], "Etc/UTC"), + DateTime.new!(next_month_start, ~T[00:00:00], "Etc/UTC") + } + end + + defp current_month_date_window do + month_start = Date.utc_today() |> Date.beginning_of_month() + next_month_start = month_start |> Date.end_of_month() |> Date.add(1) + {month_start, next_month_start} + end + + defp normalize_optional_string(nil), do: nil + + defp normalize_optional_string(value) do + value + |> to_string() + |> String.trim() + |> case do + "" -> nil + trimmed -> trimmed + end + end + + defp maybe_delete_email_data(user) do + if email_module_compiled?() do + # Delete user's messages through their mailboxes + from(m in Elektrine.Email.Message, + join: mb in Elektrine.Email.Mailbox, + on: m.mailbox_id == mb.id, + where: mb.user_id == ^user.id + ) + |> Repo.delete_all() + + # Delete user's mailboxes + from(mb in Elektrine.Email.Mailbox, where: mb.user_id == ^user.id) + |> Repo.delete_all() + + # Delete user's email aliases + from(a in Elektrine.Email.Alias, where: a.user_id == ^user.id) + |> Repo.delete_all() + else + :ok + end + end + + defp email_module_compiled? do + Modules.compiled?(:email) + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/account_deletion_request.ex b/apps/elektrine/lib/elektrine/accounts/account_deletion_request.ex new file mode 100644 index 0000000..8cdf642 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/account_deletion_request.ex @@ -0,0 +1,63 @@ +defmodule Elektrine.Accounts.AccountDeletionRequest do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + alias Elektrine.Accounts.User + + schema "account_deletion_requests" do + field :reason, :string + field :status, :string, default: "pending" + field :requested_at, :utc_datetime + field :reviewed_at, :utc_datetime + field :admin_notes, :string + + belongs_to :user, User + belongs_to :reviewed_by, User + + timestamps(type: :utc_datetime) + end + + @valid_statuses ~w(pending approved denied) + + @doc """ + Changeset for creating a new account deletion request. + """ + def changeset(request, attrs) do + request + |> cast(attrs, [:user_id, :reason, :requested_at]) + |> validate_required([:user_id, :requested_at]) + |> validate_length(:reason, max: 1000) + |> put_change(:status, "pending") + |> unique_constraint(:user_id, message: "You already have a pending deletion request") + end + + @doc """ + Changeset for admin review of deletion request. + """ + def review_changeset(request, attrs) do + request + |> cast(attrs, [:status, :reviewed_at, :reviewed_by_id, :admin_notes]) + |> validate_required([:status, :reviewed_at, :reviewed_by_id]) + |> validate_inclusion(:status, @valid_statuses) + |> validate_length(:admin_notes, max: 1000) + end + + @doc """ + Returns true if the request is pending review. + """ + def pending?(%__MODULE__{status: "pending"}), do: true + def pending?(_), do: false + + @doc """ + Returns true if the request has been approved. + """ + def approved?(%__MODULE__{status: "approved"}), do: true + def approved?(_), do: false + + @doc """ + Returns true if the request has been denied. + """ + def denied?(%__MODULE__{status: "denied"}), do: true + def denied?(_), do: false +end diff --git a/apps/elektrine/lib/elektrine/accounts/account_endorsement.ex b/apps/elektrine/lib/elektrine/accounts/account_endorsement.ex new file mode 100644 index 0000000..bd951a5 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/account_endorsement.ex @@ -0,0 +1,55 @@ +defmodule Elektrine.Accounts.AccountEndorsement do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "account_endorsements" do + belongs_to :user, Elektrine.Accounts.User + belongs_to :endorsed_user, Elektrine.Accounts.User + belongs_to :remote_actor, Elektrine.ActivityPub.Actor + + timestamps(type: :utc_datetime) + end + + def changeset(endorsement, attrs) do + endorsement + |> cast(attrs, [:user_id, :endorsed_user_id, :remote_actor_id]) + |> validate_required([:user_id]) + |> validate_exactly_one_target() + |> validate_not_self() + |> unique_constraint([:user_id, :endorsed_user_id], + name: :account_endorsements_user_local_unique_idx + ) + |> unique_constraint([:user_id, :remote_actor_id], + name: :account_endorsements_user_remote_unique_idx + ) + |> check_constraint(:endorsed_user_id, name: :account_endorsements_exactly_one_target) + |> check_constraint(:endorsed_user_id, name: :account_endorsements_not_self) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:endorsed_user_id) + |> foreign_key_constraint(:remote_actor_id) + end + + defp validate_exactly_one_target(changeset) do + local_id = get_field(changeset, :endorsed_user_id) + remote_id = get_field(changeset, :remote_actor_id) + + case {local_id, remote_id} do + {nil, nil} -> add_error(changeset, :endorsed_user_id, "must select an account") + {_, nil} -> changeset + {nil, _} -> changeset + {_, _} -> add_error(changeset, :remote_actor_id, "cannot select multiple accounts") + end + end + + defp validate_not_self(changeset) do + user_id = get_field(changeset, :user_id) + endorsed_user_id = get_field(changeset, :endorsed_user_id) + + if user_id && endorsed_user_id && user_id == endorsed_user_id do + add_error(changeset, :endorsed_user_id, "cannot endorse yourself") + else + changeset + end + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/account_import_worker.ex b/apps/elektrine/lib/elektrine/accounts/account_import_worker.ex new file mode 100644 index 0000000..50d1995 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/account_import_worker.ex @@ -0,0 +1,228 @@ +defmodule Elektrine.Accounts.AccountImportWorker do + @moduledoc """ + Imports follow, mute, block, and domain-block relationships from identifiers. + """ + use Oban.Worker, queue: :default, max_attempts: 3, priority: 8 + + require Logger + + alias Elektrine.Accounts + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor + alias Elektrine.Profiles + + @types ~w(follow mute block domain_block) + @max_identifiers 5_000 + + def max_identifiers, do: @max_identifiers + + @impl Oban.Worker + def perform(%Oban.Job{ + args: %{"user_id" => user_id, "type" => type, "identifier" => identifier} + }) + when type in @types do + case perform_import(user_id, type, identifier) do + :ok -> + :ok + + {:error, reason} -> + Logger.debug( + "Account import #{type} failed for #{inspect(identifier)}: #{inspect(reason)}" + ) + + {:error, reason} + end + end + + def perform(_job), do: {:discard, :invalid_import_job} + + def enqueue_many(user_id, type, identifiers) when type in @types and is_list(identifiers) do + identifiers = + identifiers + |> normalize_identifiers() + + if length(identifiers) > @max_identifiers do + {:error, :too_many_import_identifiers} + else + jobs = + Enum.map(identifiers, fn identifier -> + %{user_id: user_id, type: type, identifier: identifier} + |> new() + |> Oban.insert() + end) + + {:ok, jobs} + end + end + + def enqueue_many(_user_id, _type, _identifiers), do: {:error, :invalid_import_type} + + defp normalize_identifiers(identifiers) do + identifiers + |> Enum.map(&normalize_identifier/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp perform_import(user_id, "follow", identifier) do + with {:ok, target} <- resolve_target(identifier) do + case target do + {:user, target_user_id} -> + normalize_idempotent(Profiles.follow_user(user_id, target_user_id)) + + {:remote_actor, actor_id} -> + normalize_idempotent(Profiles.follow_remote_actor(user_id, actor_id)) + end + end + end + + defp perform_import(user_id, "mute", identifier) do + with {:ok, target} <- resolve_target(identifier) do + case target do + {:user, target_user_id} -> + normalize_idempotent(Accounts.mute_user(user_id, target_user_id)) + + {:remote_actor, actor_id} -> + normalize_idempotent(Accounts.mute_remote_actor(user_id, actor_id)) + end + end + end + + defp perform_import(user_id, "block", identifier) do + with {:ok, target} <- resolve_target(identifier) do + case target do + {:user, target_user_id} -> + normalize_idempotent(Accounts.block_user(user_id, target_user_id)) + + {:remote_actor, actor_id} -> + normalize_idempotent(Accounts.block_remote_actor(user_id, actor_id)) + end + end + end + + defp perform_import(user_id, "domain_block", identifier) do + normalize_idempotent(Accounts.block_domain(user_id, normalize_identifier(identifier))) + end + + defp resolve_target(identifier) do + case resolve_local_user(identifier) do + {:ok, user} -> {:ok, {:user, user.id}} + {:error, :not_found} -> resolve_remote_actor(identifier) + end + end + + defp resolve_local_user(identifier) do + identifier + |> normalize_identifier() + |> local_part() + |> case do + nil -> + {:error, :not_found} + + local -> + case Accounts.get_user_by_username_or_handle(local) do + nil -> {:error, :not_found} + user -> {:ok, user} + end + end + end + + defp resolve_remote_actor(identifier) do + with acct when is_binary(acct) <- normalize_identifier(identifier) do + acct = String.trim_leading(acct, "@") + + if actor_uri?(acct) do + resolve_remote_actor_uri(acct) + else + resolve_remote_actor_account(acct) + end + end + end + + defp resolve_remote_actor_uri(uri) do + case ActivityPub.get_actor_by_uri(uri) do + %Actor{} = actor -> + {:ok, {:remote_actor, actor.id}} + + nil -> + case ActivityPub.get_or_fetch_actor(uri) do + {:ok, %Actor{} = actor} -> {:ok, {:remote_actor, actor.id}} + _ -> {:error, :not_found} + end + end + end + + defp resolve_remote_actor_account(acct) do + case cached_remote_actor(acct) do + %Actor{} = actor -> + {:ok, {:remote_actor, actor.id}} + + nil -> + with {:ok, actor_uri} <- ActivityPub.webfinger_lookup(acct), + {:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_actor(actor_uri) do + {:ok, {:remote_actor, actor.id}} + else + _ -> {:error, :not_found} + end + end + end + + defp actor_uri?(value) do + case URI.parse(value) do + %URI{scheme: scheme, host: host} when scheme in ["http", "https"] and is_binary(host) -> + true + + _ -> + false + end + end + + defp cached_remote_actor(acct) do + case String.split(acct, "@", parts: 2) do + [username, domain] when username != "" and domain != "" -> + ActivityPub.get_actor_by_username_and_domain(username, domain) + + _ -> + nil + end + end + + defp local_part(identifier) when is_binary(identifier) do + trimmed = String.trim_leading(identifier, "@") + local_domains = MapSet.new([String.downcase(ActivityPub.instance_domain())]) + + case String.split(trimmed, "@", parts: 2) do + [name] -> + name + + [name, domain] -> + if MapSet.member?(local_domains, String.downcase(domain)), do: name, else: nil + end + end + + defp local_part(_), do: nil + + defp normalize_identifier(nil), do: nil + + defp normalize_identifier(identifier) when is_binary(identifier) do + identifier + |> strip_utf8_bom() + |> String.trim() + |> String.trim_leading("acct:") + |> case do + "" -> nil + value -> value + end + end + + defp normalize_identifier(_), do: nil + + defp strip_utf8_bom(<<0xEF, 0xBB, 0xBF, rest::binary>>), do: rest + defp strip_utf8_bom(value), do: value + + defp normalize_idempotent({:ok, _}), do: :ok + defp normalize_idempotent({:error, :already_following}), do: :ok + defp normalize_idempotent({:error, changeset = %Ecto.Changeset{}}), do: {:error, changeset} + defp normalize_idempotent({:error, reason}), do: {:error, reason} + defp normalize_idempotent(_), do: :ok +end diff --git a/apps/elektrine/lib/elektrine/accounts/account_note.ex b/apps/elektrine/lib/elektrine/accounts/account_note.ex new file mode 100644 index 0000000..9f7213b --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/account_note.ex @@ -0,0 +1,45 @@ +defmodule Elektrine.Accounts.AccountNote do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + schema "account_notes" do + field :comment, :string + + belongs_to :source_user, Elektrine.Accounts.User + belongs_to :target_user, Elektrine.Accounts.User + belongs_to :target_remote_actor, Elektrine.ActivityPub.Actor + + timestamps(type: :utc_datetime) + end + + def changeset(note, attrs) do + note + |> cast(attrs, [:source_user_id, :target_user_id, :target_remote_actor_id, :comment]) + |> update_change(:comment, &normalize_comment/1) + |> validate_required([:source_user_id]) + |> validate_target() + |> validate_length(:comment, max: 5_000) + |> unique_constraint([:source_user_id, :target_user_id]) + |> unique_constraint([:source_user_id, :target_remote_actor_id]) + |> check_constraint(:target_user_id, name: :account_notes_exactly_one_target) + |> foreign_key_constraint(:source_user_id) + |> foreign_key_constraint(:target_user_id) + |> foreign_key_constraint(:target_remote_actor_id) + end + + defp validate_target(changeset) do + target_user_id = get_field(changeset, :target_user_id) + target_remote_actor_id = get_field(changeset, :target_remote_actor_id) + + case {is_nil(target_user_id), is_nil(target_remote_actor_id)} do + {false, true} -> changeset + {true, false} -> changeset + _ -> add_error(changeset, :base, "must have exactly one target") + end + end + + defp normalize_comment(value) when is_binary(value), do: String.trim(value) + defp normalize_comment(value), do: value +end diff --git a/apps/elektrine/lib/elektrine/accounts/account_notes.ex b/apps/elektrine/lib/elektrine/accounts/account_notes.ex new file mode 100644 index 0000000..a1340ea --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/account_notes.ex @@ -0,0 +1,68 @@ +defmodule Elektrine.Accounts.AccountNotes do + @moduledoc """ + Private account notes, compatible with Mastodon/Pleroma account notes. + """ + + alias Elektrine.Accounts.AccountNote + alias Elektrine.Repo + + def get_note(source_user_id, {:user, target_user_id}) do + Repo.get_by(AccountNote, source_user_id: source_user_id, target_user_id: target_user_id) + end + + def get_note(source_user_id, {:remote_actor, target_remote_actor_id}) do + Repo.get_by(AccountNote, + source_user_id: source_user_id, + target_remote_actor_id: target_remote_actor_id + ) + end + + def note_comment(source_user_id, target) do + case get_note(source_user_id, target) do + %AccountNote{comment: comment} when is_binary(comment) -> comment + _ -> "" + end + end + + def put_note(source_user_id, {:user, target_user_id}, comment) do + upsert_note(%{ + source_user_id: source_user_id, + target_user_id: target_user_id, + comment: comment + }) + end + + def put_note(source_user_id, {:remote_actor, target_remote_actor_id}, comment) do + upsert_note(%{ + source_user_id: source_user_id, + target_remote_actor_id: target_remote_actor_id, + comment: comment + }) + end + + defp upsert_note(attrs) do + lookup = + cond do + attrs[:target_user_id] -> + [source_user_id: attrs.source_user_id, target_user_id: attrs.target_user_id] + + attrs[:target_remote_actor_id] -> + [ + source_user_id: attrs.source_user_id, + target_remote_actor_id: attrs.target_remote_actor_id + ] + end + + case Repo.get_by(AccountNote, lookup) do + nil -> + %AccountNote{} + |> AccountNote.changeset(attrs) + |> Repo.insert() + + note -> + note + |> AccountNote.changeset(attrs) + |> Repo.update() + end + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/account_subscription.ex b/apps/elektrine/lib/elektrine/accounts/account_subscription.ex new file mode 100644 index 0000000..8cbae5a --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/account_subscription.ex @@ -0,0 +1,55 @@ +defmodule Elektrine.Accounts.AccountSubscription do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "account_subscriptions" do + belongs_to :user, Elektrine.Accounts.User + belongs_to :subscribed_user, Elektrine.Accounts.User + belongs_to :remote_actor, Elektrine.ActivityPub.Actor + + timestamps(type: :utc_datetime) + end + + def changeset(subscription, attrs) do + subscription + |> cast(attrs, [:user_id, :subscribed_user_id, :remote_actor_id]) + |> validate_required([:user_id]) + |> validate_exactly_one_target() + |> validate_not_self() + |> unique_constraint([:user_id, :subscribed_user_id], + name: :account_subscriptions_user_local_unique_idx + ) + |> unique_constraint([:user_id, :remote_actor_id], + name: :account_subscriptions_user_remote_unique_idx + ) + |> check_constraint(:subscribed_user_id, name: :account_subscriptions_exactly_one_target) + |> check_constraint(:subscribed_user_id, name: :account_subscriptions_not_self) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:subscribed_user_id) + |> foreign_key_constraint(:remote_actor_id) + end + + defp validate_exactly_one_target(changeset) do + local_id = get_field(changeset, :subscribed_user_id) + remote_id = get_field(changeset, :remote_actor_id) + + case {local_id, remote_id} do + {nil, nil} -> add_error(changeset, :subscribed_user_id, "must select an account") + {_, nil} -> changeset + {nil, _} -> changeset + {_, _} -> add_error(changeset, :remote_actor_id, "cannot select multiple accounts") + end + end + + defp validate_not_self(changeset) do + user_id = get_field(changeset, :user_id) + subscribed_user_id = get_field(changeset, :subscribed_user_id) + + if user_id && subscribed_user_id && user_id == subscribed_user_id do + add_error(changeset, :subscribed_user_id, "cannot subscribe to yourself") + else + changeset + end + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/app_password.ex b/apps/elektrine/lib/elektrine/accounts/app_password.ex new file mode 100644 index 0000000..23b6d19 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/app_password.ex @@ -0,0 +1,223 @@ +defmodule Elektrine.Accounts.AppPassword do + @moduledoc """ + Schema for app-specific passwords that bypass 2FA for email clients. + """ + use Ecto.Schema + import Ecto.Changeset + + @current_hash_prefix "v3$argon2id$" + @hmac_hash_prefix "v2$hmac-sha256$" + + schema "app_passwords" do + field :name, :string + field :token_hash, :string + field :last_used_at, :utc_datetime + field :last_used_ip, :string + field :expires_at, :utc_datetime + + # Virtual field for the raw token (only available on creation) + field :token, :string, virtual: true + + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(app_password, attrs) do + app_password + |> cast(attrs, [:name, :token_hash, :user_id, :expires_at]) + |> validate_required([:name, :token_hash, :user_id]) + |> validate_length(:name, max: 100) + |> unique_constraint(:token_hash) + end + + @doc """ + Creates a changeset for a new app password with a generated token. + """ + def create_changeset(attrs) do + # Generate a secure random token + token = generate_token() + token_hash = hash_token(token) + + changeset = + %__MODULE__{} + |> cast(attrs, [:name, :user_id, :expires_at]) + |> validate_required([:name, :user_id]) + |> validate_length(:name, max: 100) + |> put_change(:token_hash, token_hash) + + # Attach the raw token (WITH dashes) to the struct for display to the user + # This is the only time the raw token is available + %{changeset | changes: Map.put(changeset.changes, :token, token)} + end + + @doc """ + Updates last used information for an app password. + """ + def update_last_used(app_password, ip_address \\ nil) do + app_password + |> change(%{ + last_used_at: DateTime.utc_now() |> DateTime.truncate(:second), + last_used_ip: ip_address + }) + end + + @doc """ + Generates a secure random token for app password. + Format: xxxx-xxxx-xxxx-xxxx-xxxx-xxxx-xxxx-xxxx (32 characters) + """ + def generate_token do + :crypto.strong_rand_bytes(20) + |> Base.encode32(case: :lower, padding: false) + |> format_token() + end + + defp format_token(token) do + token + |> String.graphemes() + |> Enum.chunk_every(4) + |> Enum.map_join("-", &Enum.join/1) + end + + @doc """ + Hashes a token for secure storage. + """ + def hash_token(token) do + @current_hash_prefix <> Argon2.hash_pwd_salt(normalize_current_token(token)) + end + + @doc "Returns deterministic token hashes that can authenticate pre-v3 tokens. + + Argon2id app-password hashes are salted, so they must be verified row-by-row + with `verify_token/2` instead of looked up directly by hash. + " + def candidate_hashes(token) do + clean_token = normalize_current_token(token) + legacy_token = normalize_legacy_token(token) + + hmac_hashes([clean_token, legacy_token]) + |> Enum.uniq() + end + + defp normalize_current_token(token) when is_binary(token) do + token + |> String.replace(~r/[^a-z2-7]/i, "") + |> String.downcase() + end + + defp normalize_current_token(_token), do: "" + + defp normalize_legacy_token(token) when is_binary(token) do + String.trim(token) + end + + defp normalize_legacy_token(_token), do: "" + + defp hmac_hashes(tokens) do + case app_password_pepper() do + pepper when is_binary(pepper) -> + Enum.map(tokens, fn token -> @hmac_hash_prefix <> hmac_token(token, pepper) end) + + nil -> + [] + end + end + + defp hmac_token(token, pepper) do + :crypto.mac(:hmac, :sha256, pepper, token) + |> Base.url_encode64(padding: false) + end + + defp app_password_pepper do + Application.get_env(:elektrine, :app_password_pepper) || + Application.get_env(:elektrine, :encryption_master_secret) || + get_in(Application.get_env(:elektrine, ElektrineWeb.Endpoint, []), [:secret_key_base]) + end + + @doc """ + Verifies if a given token matches the stored hash. + """ + def verify_token(token, token_hash) do + cond do + argon2_hash?(token_hash) -> + verify_argon2_token(token, token_hash) + + hmac_hash?(token_hash) -> + Enum.any?(candidate_hashes(token), &secure_compare(&1, token_hash)) + + true -> + false + end + end + + @doc "Returns the stored app-password hash version for diagnostics." + def hash_version(token_hash) when is_binary(token_hash) do + cond do + String.starts_with?(token_hash, @current_hash_prefix) -> :v3_argon2id + argon2_hash?(token_hash) -> :argon2id + hmac_hash?(token_hash) -> :v2_hmac + true -> :unknown + end + end + + def hash_version(_token_hash), do: :unknown + + defp argon2_hash?(token_hash) when is_binary(token_hash), + do: not is_nil(argon2_hash_body(token_hash)) + + defp argon2_hash?(_), do: false + + defp hmac_hash?(token_hash) when is_binary(token_hash), + do: String.starts_with?(token_hash, @hmac_hash_prefix) + + defp hmac_hash?(_), do: false + + defp verify_argon2_token(token, token_hash) do + case argon2_hash_body(token_hash) do + nil -> + false + + argon2_hash -> + token + |> argon2_token_candidates() + |> Enum.any?(&verify_argon2_candidate(&1, argon2_hash)) + end + end + + defp argon2_hash_body(@current_hash_prefix <> argon2_hash), + do: normalize_argon2_hash_body(argon2_hash) + + defp argon2_hash_body("$argon2id$" <> _ = argon2_hash), do: argon2_hash + defp argon2_hash_body(_), do: nil + + defp normalize_argon2_hash_body("$argon2id$" <> _ = argon2_hash), do: argon2_hash + defp normalize_argon2_hash_body("argon2id$" <> _ = argon2_hash), do: "$" <> argon2_hash + defp normalize_argon2_hash_body(_), do: nil + + defp argon2_token_candidates(token) when is_binary(token) do + trimmed = String.trim(token) + + [ + normalize_current_token(token), + trimmed, + String.downcase(trimmed) + ] + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + defp argon2_token_candidates(_), do: [] + + defp verify_argon2_candidate(candidate, argon2_hash) do + Argon2.verify_pass(candidate, argon2_hash) + rescue + _ -> false + end + + defp secure_compare(left, right) when is_binary(left) and is_binary(right) do + byte_size(left) == byte_size(right) and Plug.Crypto.secure_compare(left, right) + end + + defp secure_compare(_left, _right), do: false +end diff --git a/apps/elektrine/lib/elektrine/accounts/authentication.ex b/apps/elektrine/lib/elektrine/accounts/authentication.ex new file mode 100644 index 0000000..e041099 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/authentication.ex @@ -0,0 +1,911 @@ +defmodule Elektrine.Accounts.Authentication do + @moduledoc ~s|Authentication context for user authentication, password management, and 2FA.\nHandles password verification, 2FA setup/verification, app passwords, and password recovery.\n| + import Ecto.Query, warn: false + alias Elektrine.Accounts.{AppPassword, TwoFactor, User} + alias Elektrine.DB.WriteGuard + alias Elektrine.Email + alias Elektrine.Email.Mailbox + alias Elektrine.OAuth + alias Elektrine.Repo + alias Elektrine.Vault + require Logger + + @doc ~s|Authenticates a user by username and password.\n\nReturns `{:ok, user}` if the username and password are valid,\nor `{:error, :invalid_credentials}` if the username or password are invalid.\n\n## Examples\n\n iex> authenticate_user(\"username\", \"correct_password\")\n {:ok, %User{}}\n\n iex> authenticate_user(\"username\", \"wrong_password\")\n {:error, :invalid_credentials}\n\n iex> authenticate_user(\"nonexistent\", \"any_password\")\n {:error, :invalid_credentials}\n\n| + def authenticate_user(username, password) when is_binary(username) and is_binary(password) do + user = get_user_by_username_case_insensitive(username) + + cond do + is_nil(user) -> + Argon2.no_user_verify(to_charlist(password)) + {:error, :invalid_credentials} + + user.banned -> + {:error, {:banned, user.banned_reason}} + + user_suspended?(user) -> + {:error, {:suspended, user.suspended_until, user.suspension_reason}} + + verify_password_hash(password, user.password_hash) -> + if bcrypt_hash?(user.password_hash) do + user + |> User.password_changeset(%{password: password, password_confirmation: password}) + |> Repo.update() + end + + if user.suspended && user.suspended_until && + DateTime.compare(user.suspended_until, DateTime.utc_now()) == :lt do + Elektrine.Accounts.Moderation.unsuspend_user(user) + end + + {:ok, user} + + true -> + {:error, :invalid_credentials} + end + end + + @doc ~s|Verifies password for an existing user without re-querying the database.\nUse this when you already have the user struct loaded.\nReturns {:ok, user} if password is valid, {:error, reason} otherwise.\n| + def verify_user_password(%User{} = user, password) when is_binary(password) do + cond do + user.banned -> + {:error, {:banned, user.banned_reason}} + + user_suspended?(user) -> + {:error, {:suspended, user.suspended_until, user.suspension_reason}} + + verify_password_hash(password, user.password_hash) -> + {:ok, user} + + true -> + {:error, :invalid_credentials} + end + end + + @doc "Returns :ok when a user is allowed to access authenticated surfaces." + def ensure_user_active(%User{} = user) do + cond do + user.banned -> {:error, :account_banned} + user_suspended?(user) -> {:error, :account_suspended} + true -> :ok + end + end + + defp verify_password_hash(password, hash) do + if bcrypt_hash?(hash) do + Bcrypt.verify_pass(password, hash) + else + Argon2.verify_pass(password, hash) + end + end + + defp bcrypt_hash?(hash) when is_binary(hash) do + String.starts_with?(hash, ["$2", "$2a$", "$2b$", "$2y$"]) + end + + defp bcrypt_hash?(_) do + false + end + + defp user_suspended?(%User{} = user) do + user.suspended && + (is_nil(user.suspended_until) || + DateTime.compare(user.suspended_until, DateTime.utc_now()) == :gt) + end + + @doc ~s|Updates a user's password.\n\n## Examples\n\n iex> update_user_password(user, %{password: \"new password\", password_confirmation: \"new password\"})\n {:ok, %User{}}\n\n iex> update_user_password(user, %{password: \"invalid\"})\n {:error, %Ecto.Changeset{}}\n\n| + def update_user_password(%User{} = user, attrs, opts \\ []) do + password_changeset = User.password_changeset(user, attrs) + encrypted_data_rewrap = Keyword.get(opts, :encrypted_data_rewrap) + mailbox_rewrap = Keyword.get(opts, :private_mailbox_rewrap) + + if password_changeset.valid? do + case Repo.transaction(fn -> + case Repo.update(password_changeset) do + {:ok, updated_user} -> + with :ok <- maybe_rewrap_encrypted_data(user.id, encrypted_data_rewrap), + :ok <- maybe_rewrap_private_mailbox(user.id, mailbox_rewrap) do + updated_user + else + {:error, reason} -> Repo.rollback(reason) + end + + {:error, changeset} -> + Repo.rollback(changeset) + end + end) do + {:ok, updated_user} -> + invalidate_long_lived_credentials(updated_user) + {:ok, updated_user} + + {:error, %Ecto.Changeset{} = changeset} -> + {:error, changeset} + + {:error, reason} -> + {:error, password_changeset_error(password_changeset, reason)} + end + else + {:error, password_changeset} + end + end + + @doc ~s|Returns an `%Ecto.Changeset{}` for tracking user password changes.\n| + def change_user_password(%User{} = user, attrs \\ %{}) do + User.password_changeset(user, attrs) + end + + @doc ~s|Initiates 2FA setup for a user by generating a secret and backup codes.\n\nReturns the secret and provisioning URI for QR code generation.\n| + def initiate_two_factor_setup(%User{} = user) do + secret = TwoFactor.generate_secret() + {plain_codes, hashed_codes} = TwoFactor.generate_backup_codes() + provisioning_uri = TwoFactor.generate_provisioning_uri(secret, user.username) + + {:ok, + %{ + secret: secret, + plain_backup_codes: plain_codes, + hashed_backup_codes: hashed_codes, + provisioning_uri: provisioning_uri + }} + rescue + _ -> {:error, :setup_failed} + end + + @doc ~s|Enables 2FA for a user after verifying the TOTP code.\nExpects hashed_backup_codes (not plain codes) for storage.\n| + def enable_two_factor(%User{} = user, secret, hashed_backup_codes, totp_code) do + db_check = + try do + case Repo.query("SELECT 1", []) do + {:ok, _} -> + :ok + + {:error, reason} -> + Logger.error("2FA: Database connectivity check failed: #{inspect(reason)}") + {:error, :database_unavailable} + end + rescue + exception -> + Logger.error("2FA: Database connectivity exception: #{inspect(exception)}") + {:error, :database_exception} + end + + case db_check do + :ok -> + if TwoFactor.verify_totp(secret, totp_code) do + encoded_secret = Base.encode64(secret) + + changeset = + User.enable_two_factor_changeset(user, %{ + two_factor_secret: encoded_secret, + two_factor_backup_codes: hashed_backup_codes + }) + + if changeset.valid? do + try do + case Repo.update(changeset) do + {:ok, updated_user} -> + Elektrine.Accounts.invalidate_auth_sessions(updated_user) + invalidate_long_lived_credentials(updated_user) + broadcast_mail_auth_changed(updated_user.id) + + case Repo.reload(updated_user) do + nil -> + Logger.error("2FA: User reload failed after update") + {:error, :reload_failed} + + reloaded_user -> + {:ok, reloaded_user} + end + + {:error, changeset} -> + Logger.error("2FA database update failed: #{inspect(changeset.errors)}") + {:error, changeset} + end + rescue + exception -> + Logger.error("2FA: Database update exception: #{inspect(exception)}") + {:error, :database_update_exception} + end + else + Logger.error("2FA changeset errors: #{inspect(changeset.errors)}") + {:error, :invalid_changeset} + end + else + {:error, :invalid_totp_code} + end + + {:error, reason} -> + Logger.error("2FA: Database connectivity failed: #{inspect(reason)}") + {:error, reason} + end + end + + @doc ~s|Disables 2FA for a user.\n| + def disable_two_factor(%User{} = user) do + case user |> User.disable_two_factor_changeset() |> Repo.update() do + {:ok, updated_user} -> + Elektrine.Accounts.invalidate_auth_sessions(updated_user) + invalidate_long_lived_credentials(updated_user) + {:ok, updated_user} + + error -> + error + end + end + + @doc ~s|Verifies a 2FA code (TOTP or backup code) for a user.\n| + def verify_two_factor_code(%User{two_factor_enabled: true} = user, code) do + decoded_secret = + if is_binary(user.two_factor_secret) do + case Base.decode64(user.two_factor_secret) do + {:ok, secret} -> secret + :error -> user.two_factor_secret + end + else + user.two_factor_secret + end + + cond do + TwoFactor.verify_totp(decoded_secret, code) -> + {:ok, :totp} + + user.two_factor_backup_codes != nil -> + Repo.transaction(fn -> + locked_user = + User + |> where([u], u.id == ^user.id) + |> lock("FOR UPDATE") + |> Repo.one!() + + case TwoFactor.verify_backup_code(locked_user.two_factor_backup_codes, code) do + {:ok, remaining_codes} -> + case locked_user + |> User.update_backup_codes_changeset(remaining_codes) + |> Repo.update() do + {:ok, _updated_user} -> :backup_code + {:error, changeset} -> Repo.rollback({:backup_code_update_failed, changeset}) + end + + {:error, :invalid} -> + Repo.rollback(:invalid_code) + end + end) + |> case do + {:ok, :backup_code} -> {:ok, :backup_code} + {:error, :invalid_code} -> {:error, :invalid_code} + {:error, _reason} -> {:error, :invalid_code} + end + + true -> + {:error, :invalid_code} + end + end + + def verify_two_factor_code(%User{two_factor_enabled: false}, _code) do + {:error, :two_factor_not_enabled} + end + + @doc ~s|Verifies a TOTP code ONLY (not backup codes) for a user.\nThis is used for sensitive operations like disabling 2FA where backup codes should not be allowed.\n| + def verify_totp_only(%User{two_factor_enabled: true} = user, code) do + decoded_secret = + if is_binary(user.two_factor_secret) do + case Base.decode64(user.two_factor_secret) do + {:ok, secret} -> secret + :error -> user.two_factor_secret + end + else + user.two_factor_secret + end + + if TwoFactor.verify_totp(decoded_secret, code) do + {:ok, :totp} + else + {:error, :invalid_code} + end + end + + def verify_totp_only(%User{two_factor_enabled: false}, _code) do + {:error, :two_factor_not_enabled} + end + + @doc ~s|Regenerates backup codes for a user with 2FA enabled.\nReturns {:ok, {updated_user, plain_backup_codes}} so user can save them.\n| + def regenerate_backup_codes(%User{two_factor_enabled: true} = user) do + {plain_codes, hashed_codes} = TwoFactor.generate_backup_codes() + result = user |> User.update_backup_codes_changeset(hashed_codes) |> Repo.update() + + case result do + {:ok, updated_user} -> {:ok, {updated_user, plain_codes}} + error -> error + end + end + + def regenerate_backup_codes(%User{two_factor_enabled: false}) do + {:error, :two_factor_not_enabled} + end + + @doc ~s|Admin function to reset a user's 2FA (disable and clear all 2FA data).\n| + def admin_reset_2fa(user) do + user |> User.admin_2fa_reset_changeset() |> Repo.update() + end + + defp maybe_rewrap_private_mailbox(user_id, mailbox_rewrap) do + case Email.get_user_mailbox(user_id) do + mailbox when not is_nil(mailbox) -> + cond do + !Mailbox.private_storage_account_password?(mailbox) -> + :ok + + !valid_private_mailbox_rewrap?(mailbox_rewrap) -> + {:error, :private_mailbox_rewrap_required} + + true -> + mailbox + |> Mailbox.private_storage_changeset(%{ + private_storage_enabled: mailbox.private_storage_enabled, + private_storage_public_key: mailbox.private_storage_public_key, + private_storage_wrapped_private_key: mailbox_rewrap.wrapped_private_key, + private_storage_verifier: mailbox_rewrap.verifier + }) + |> Repo.update() + |> case do + {:ok, _updated_mailbox} -> :ok + {:error, _changeset} -> {:error, :private_mailbox_rewrap_invalid} + end + end + + _mailbox -> + :ok + end + end + + defp maybe_rewrap_encrypted_data(user_id, encrypted_data_rewrap) do + if Vault.configured?(user_id) do + if valid_encrypted_data_rewrap?(encrypted_data_rewrap) do + case Vault.rotate(user_id, %{ + "wrapped_dek" => encrypted_data_rewrap.wrapped_dek, + "wrapped_dek_recovery" => encrypted_data_rewrap.wrapped_dek_recovery + }) do + {:ok, _master_key} -> :ok + {:error, _reason} -> {:error, :encrypted_data_rewrap_invalid} + end + else + {:error, :encrypted_data_rewrap_required} + end + else + :ok + end + end + + defp valid_encrypted_data_rewrap?(%{ + wrapped_dek: wrapped_dek, + wrapped_dek_recovery: wrapped_dek_recovery + }) + when is_map(wrapped_dek) and is_map(wrapped_dek_recovery), + do: true + + defp valid_encrypted_data_rewrap?(_value), do: false + + defp valid_private_mailbox_rewrap?(%{ + wrapped_private_key: wrapped_private_key, + verifier: verifier, + unlock_mode: "account_password" + }) + when is_map(wrapped_private_key) and is_map(verifier), + do: true + + defp valid_private_mailbox_rewrap?(_value), do: false + + defp password_changeset_error(password_changeset, :private_mailbox_rewrap_required) do + password_changeset + |> Map.put(:action, :update) + |> Ecto.Changeset.add_error( + :current_password, + "Your private mailbox must be rewrapped in this browser before changing your password." + ) + end + + defp password_changeset_error(password_changeset, :encrypted_data_rewrap_required) do + password_changeset + |> Map.put(:action, :update) + |> Ecto.Changeset.add_error( + :current_password, + "Encrypted data must be rewrapped in this browser before changing your password." + ) + end + + defp password_changeset_error(password_changeset, :encrypted_data_rewrap_invalid) do + password_changeset + |> Map.put(:action, :update) + |> Ecto.Changeset.add_error( + :current_password, + "Encrypted data could not be updated with the new password." + ) + end + + defp password_changeset_error(password_changeset, :private_mailbox_rewrap_invalid) do + password_changeset + |> Map.put(:action, :update) + |> Ecto.Changeset.add_error( + :current_password, + "Private mailbox protection could not be updated with the new password." + ) + end + + defp password_changeset_error(password_changeset, _reason) do + password_changeset + |> Map.put(:action, :update) + |> Ecto.Changeset.add_error(:current_password, "Password update failed.") + end + + @doc ~s|Lists all app passwords for a user.\n| + def list_app_passwords(user_id) do + AppPassword |> where(user_id: ^user_id) |> order_by(desc: :inserted_at) |> Repo.all() + end + + def revoke_all_app_passwords(user_id) do + app_passwords = + AppPassword + |> where(user_id: ^user_id) + |> Repo.all() + + {count, _} = + AppPassword + |> where(user_id: ^user_id) + |> Repo.delete_all() + + Enum.each(app_passwords, &broadcast_app_password_revoked/1) + + {count, nil} + end + + @doc ~s|Creates a new app password for a user.\nReturns {:ok, app_password} with the raw token attached, or {:error, changeset}.\n| + def create_app_password(user_id, attrs) do + attrs = Map.put(attrs, :user_id, user_id) + changeset = AppPassword.create_changeset(attrs) + + case Repo.insert(changeset) do + {:ok, app_password} -> + token = changeset.changes[:token] + {:ok, %{app_password | token: token}} + + error -> + error + end + end + + @doc ~s|Deletes an app password.\n| + def delete_app_password(app_password_id, user_id) do + AppPassword + |> where(id: ^app_password_id, user_id: ^user_id) + |> Repo.one() + |> case do + nil -> + {:error, :not_found} + + app_password -> + case Repo.delete(app_password) do + {:ok, deleted_app_password} = result -> + broadcast_app_password_revoked(deleted_app_password) + result + + error -> + error + end + end + end + + @doc ~s|Authenticates a user with an app password.\nReturns:\n - {:ok, user} if valid\n - {:error, :user_not_found} if user doesn't exist\n - {:error, {:invalid_token, user}} if user exists but token is invalid\n| + def authenticate_with_app_password(username, token) do + case authenticate_with_app_password_info(username, token) do + {:ok, user, _app_password} -> {:ok, user} + error -> error + end + end + + def authenticate_with_app_password_info(username, token) do + case get_user_by_username_or_email(username) do + {:ok, user} -> + case ensure_user_active(user) do + :ok -> + case verify_app_password(user.id, token) do + {:ok, app_password} -> {:ok, user, app_password} + {:error, _} -> {:error, {:invalid_token, user}} + end + + {:error, reason} -> + {:error, reason} + end + + {:error, _} -> + {:error, :user_not_found} + end + end + + def app_password_exists?(app_password_id, user_id) do + AppPassword + |> where(id: ^app_password_id, user_id: ^user_id) + |> where([ap], is_nil(ap.expires_at) or ap.expires_at > ^DateTime.utc_now()) + |> Repo.exists?() + end + + @doc "Returns a stable mail-auth subject for username or mailbox email identifiers." + def mail_auth_subject(identifier) do + normalized_identifier = normalize_mail_auth_identifier(identifier) + + case get_user_by_username_or_email(normalized_identifier) do + {:ok, %User{id: user_id}} -> "user:#{user_id}" + {:error, _} -> fallback_mail_auth_subject(normalized_identifier) + end + end + + @doc ~s|Verifies an app password token for a user.\nUpdates last used timestamp if valid.\n| + def verify_app_password(user_id, token, ip_address \\ nil) do + AppPassword + |> where(user_id: ^user_id) + |> where([ap], is_nil(ap.expires_at) or ap.expires_at > ^DateTime.utc_now()) + |> Repo.all() + |> Enum.find(&AppPassword.verify_token(token, &1.token_hash)) + |> case do + nil -> + {:error, :invalid_token} + + app_password -> + Logger.debug( + "App password verified (hash_vsn=#{AppPassword.hash_version(app_password.token_hash)}) for user_id=#{user_id}" + ) + + case update_app_password_last_used(app_password, ip_address) do + {:ok, updated} -> + {:ok, updated} + + {:error, _changeset} -> + Logger.warning( + "Failed to persist app password last_used_at for user_id=#{app_password.user_id}; allowing authentication" + ) + + {:ok, app_password} + end + end + end + + defp update_app_password_last_used(app_password, ip_address) do + WriteGuard.run( + "app password last_used_at update for user_id=#{app_password.user_id}", + fn -> + app_password + |> AppPassword.update_last_used(ip_address) + |> Repo.update() + end, + on_read_only: {:ok, app_password} + ) + end + + defp invalidate_long_lived_credentials(%User{} = user) do + OAuth.delete_user_tokens(user) + revoke_all_app_passwords(user.id) + Elektrine.Developer.revoke_all_api_tokens(user.id) + ElektrineWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) + end + + defp broadcast_app_password_revoked(%AppPassword{id: id, user_id: user_id}) do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "mail_auth:app_password:#{id}", + {:app_password_revoked, user_id, id} + ) + end + + defp broadcast_mail_auth_changed(user_id) do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "mail_auth:user:#{user_id}", + {:mail_auth_changed, user_id} + ) + end + + defp get_user_by_username_or_email(identifier) do + normalized_identifier = normalize_mail_auth_identifier(identifier) + + if String.contains?(normalized_identifier, "@") do + case String.split(normalized_identifier, "@", parts: 2) do + [username, domain] -> + username = String.downcase(username) + domain = String.downcase(domain) + + user = + case get_user_by_username_case_insensitive(username) do + nil -> + get_user_by_mailbox_email(normalized_identifier) + + candidate_user -> + if domain in Elektrine.Domains.available_email_domains_for_user(candidate_user) do + candidate_user + else + get_user_by_mailbox_email(normalized_identifier) + end + end + + if user do + {:ok, user} + else + {:error, :not_found} + end + + _ -> + {:error, :not_found} + end + else + case get_user_by_username_case_insensitive(normalized_identifier) do + nil -> {:error, :not_found} + user -> {:ok, user} + end + end + end + + defp normalize_mail_auth_identifier(identifier) do + identifier + |> to_string() + |> String.trim() + |> String.downcase() + end + + defp fallback_mail_auth_subject(""), do: "identifier:__empty__" + + defp fallback_mail_auth_subject(identifier) when is_binary(identifier), + do: "identifier:#{identifier}" + + defp get_user_by_mailbox_email(email_identifier) do + normalized_email = String.downcase(email_identifier) + + case Email.get_mailbox_by_email(normalized_email) do + %{user_id: user_id} when is_integer(user_id) -> + Repo.get(User, user_id) + + _ -> + nil + end + end + + defp get_user_by_username_case_insensitive(username) when is_binary(username) do + User + |> where([u], fragment("lower(?)", u.username) == ^String.downcase(username)) + |> Repo.one() + end + + @doc ~s|Updates a user's recovery email.\n| + def update_recovery_email(%User{} = user, attrs) do + user |> User.recovery_email_changeset(attrs) |> Repo.update() + end + + @doc ~s|Initiates a password reset by generating a token and sending email.\nReturns {:ok, user} if successful, even if no recovery email is set.\nIf multiple users have the same recovery email, sends reset emails to all of them.\n| + def initiate_password_reset(username_or_email) when is_binary(username_or_email) do + users = + case String.contains?(username_or_email, "@") do + true -> + get_users_by_recovery_email(username_or_email) + + false -> + case Repo.get_by(User, username: username_or_email) do + nil -> [] + user -> [user] + end + end + + case users do + [] -> + {:ok, :user_not_found} + + users_list -> + results = + Enum.map(users_list, fn user -> + case user do + %User{is_admin: true} -> + Logger.error( + "SECURITY ALERT: Attempted password reset for admin user: #{user.username}" + ) + + {:ok, :admin_blocked} + + %User{recovery_email: recovery_email, recovery_email_verified: true} + when not is_nil(recovery_email) -> + token = generate_password_reset_token() + previous_reset_state = password_reset_state(user) + + case user |> User.password_reset_changeset(token) |> Repo.update() do + {:ok, updated_user} -> + case send_password_reset_email(updated_user, token) do + {:ok, _result} -> + {:ok, updated_user} + + {:error, reason} -> + restore_password_reset_state(updated_user, previous_reset_state) + {:error, reason} + end + + {:error, changeset} -> + {:error, changeset} + end + + %User{recovery_email: recovery_email, recovery_email_verified: verified} + when not is_nil(recovery_email) and verified != true -> + {:error, :recovery_email_not_verified} + + %User{} -> + {:error, :no_recovery_email} + end + end) + + if Enum.any?(results, fn + {:ok, %User{}} -> true + _ -> false + end) do + {:ok, :emails_sent} + else + if Enum.any?(results, fn + {:error, :email_failed} -> true + _ -> false + end) do + {:error, :email_failed} + else + {:ok, :user_not_found} + end + end + end + end + + @doc ~s|Gets all users by recovery email (returns a list to handle potential duplicates).\n| + def get_users_by_recovery_email(email) when is_binary(email) do + from(u in User, where: u.recovery_email == ^email) |> Repo.all() + end + + @doc ~s|Gets a user by password reset token.\n| + def get_user_by_password_reset_token(token) when is_binary(token) do + hashed_token = User.hash_sensitive_token(token) + + User + |> where([u], u.password_reset_token == ^hashed_token) + |> Repo.one() + end + + @doc ~s|Resets a user's password using a valid token.\n| + def reset_password_with_token(token, attrs) when is_binary(token) do + case get_user_by_password_reset_token(token) do + %User{is_admin: true} = user -> + Logger.error( + "SECURITY ALERT: Attempted password reset completion for admin user: #{user.username}" + ) + + {:error, :invalid_token} + + %User{} = user -> + if User.valid_password_reset_token?(user) do + case user |> User.password_reset_with_token_changeset(attrs) |> Repo.update() do + {:ok, updated_user} -> + invalidate_long_lived_credentials(updated_user) + {:ok, updated_user} + + error -> + error + end + else + {:error, :invalid_token} + end + + nil -> + {:error, :invalid_token} + end + end + + @doc ~s|Validates a password reset token without using it.\n| + def validate_password_reset_token(token) when is_binary(token) do + case get_user_by_password_reset_token(token) do + %User{is_admin: true} = user -> + Logger.error( + "SECURITY ALERT: Attempted password reset token validation for admin user: #{user.username}" + ) + + {:error, :invalid_token} + + %User{} = user -> + if User.valid_password_reset_token?(user) do + {:ok, user} + else + {:error, :invalid_token} + end + + nil -> + {:error, :invalid_token} + end + end + + @doc ~s|Clears a password reset token.\n| + def clear_password_reset_token(%User{} = user) do + user |> User.clear_password_reset_changeset() |> Repo.update() + end + + @doc ~s|Returns users whose passwords are older than the specified number of days.\n| + def get_users_with_old_passwords(max_days) when is_integer(max_days) do + cutoff_date = DateTime.utc_now() |> DateTime.add(-max_days, :day) + + from(u in User, + where: not is_nil(u.last_password_change) and u.last_password_change < ^cutoff_date, + select: [:id, :username, :email, :last_password_change, :is_admin], + order_by: [asc: u.last_password_change] + ) + |> Repo.all() + end + + @doc ~s|Returns the count of users whose passwords are older than the specified number of days.\n| + def count_users_with_old_passwords(max_days) when is_integer(max_days) do + cutoff_date = DateTime.utc_now() |> DateTime.add(-max_days, :day) + + from(u in User, + where: not is_nil(u.last_password_change) and u.last_password_change < ^cutoff_date + ) + |> Repo.aggregate(:count) + end + + @doc ~s|Admin function to reset a user's password with a new temporary password.\n| + def admin_reset_password(user, attrs) do + case user |> User.admin_password_reset_changeset(attrs) |> Repo.update() do + {:ok, updated_user} = result -> + invalidate_long_lived_credentials(updated_user) + result + + error -> + error + end + end + + defp generate_password_reset_token do + :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + end + + defp send_password_reset_email(%User{recovery_email: recovery_email} = user, token) + when not is_nil(recovery_email) do + case Elektrine.UserNotifier.password_reset_instructions(user, token) + |> Elektrine.Mailer.deliver_later() do + {:ok, result} -> + {:ok, result} + + {:error, reason} -> + Logger.error("Failed to send password reset email: #{mail_error_summary(reason)}") + {:error, :email_failed} + end + rescue + e -> + Logger.error("Failed to send password reset email: #{mail_error_summary(e)}") + {:error, :email_failed} + end + + defp send_password_reset_email(_, _) do + {:error, :no_recovery_email} + end + + defp password_reset_state(%User{} = user) do + %{ + password_reset_token: user.password_reset_token, + password_reset_token_expires_at: user.password_reset_token_expires_at + } + end + + defp restore_password_reset_state(%User{} = user, previous_state) do + case user |> Ecto.Changeset.change(previous_state) |> Repo.update() do + {:ok, _restored_user} -> + :ok + + {:error, _restore_reason} -> + Logger.error("Failed to restore password reset token state") + :error + end + end + + defp mail_error_summary(%{__struct__: module}) do + inspect(module) + end + + defp mail_error_summary(reason) when is_atom(reason) do + Atom.to_string(reason) + end + + defp mail_error_summary(_reason), do: "delivery_failed" +end diff --git a/apps/elektrine/lib/elektrine/accounts/blocked_users_cache.ex b/apps/elektrine/lib/elektrine/accounts/blocked_users_cache.ex new file mode 100644 index 0000000..ade3654 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/blocked_users_cache.ex @@ -0,0 +1,56 @@ +defmodule Elektrine.Accounts.BlockedUsersCache do + @moduledoc """ + Cached blocked users lookup to avoid repeated database queries. + """ + + import Ecto.Query + alias Elektrine.Accounts.User + alias Elektrine.Repo + + @cache_ttl :timer.minutes(5) + + @doc """ + Gets all user IDs that are blocked (both ways) for a given user. + Cached for 5 minutes to avoid repeated queries on every feed load. + """ + def get_all_blocked_user_ids(user_id) do + case Cachex.get(:blocked_users_cache, cache_key(user_id)) do + {:ok, nil} -> + # Cache miss - fetch from database + blocked_ids = fetch_blocked_ids(user_id) + Cachex.put(:blocked_users_cache, cache_key(user_id), blocked_ids, ttl: @cache_ttl) + blocked_ids + + {:ok, blocked_ids} -> + # Cache hit + blocked_ids + + {:error, _} -> + # Cache error - fetch directly + fetch_blocked_ids(user_id) + end + end + + @doc """ + Invalidates the blocked users cache for a user. + Call this when a user blocks/unblocks someone. + """ + def invalidate(user_id) do + Cachex.del(:blocked_users_cache, cache_key(user_id)) + end + + defp cache_key(user_id), do: "blocked_#{user_id}" + + defp fetch_blocked_ids(user_id) do + # Single optimized query to get all blocked user IDs (both directions) + from(u in User, + left_join: b1 in "user_blocks", + on: b1.blocker_id == ^user_id and b1.blocked_id == u.id, + left_join: b2 in "user_blocks", + on: b2.blocked_id == ^user_id and b2.blocker_id == u.id, + where: not is_nil(b1.id) or not is_nil(b2.id), + select: u.id + ) + |> Repo.all() + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/blocking.ex b/apps/elektrine/lib/elektrine/accounts/blocking.ex new file mode 100644 index 0000000..9830645 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/blocking.ex @@ -0,0 +1,424 @@ +defmodule Elektrine.Accounts.Blocking do + @moduledoc """ + User blocking functionality. + Handles blocking, unblocking, and checking blocked relationships between users. + """ + + import Ecto.Query, warn: false + alias Elektrine.Accounts.{User, UserBlock} + alias Elektrine.ActivityPub.UserBlock, as: ActivityPubUserBlock + alias Elektrine.Repo + + @doc """ + Blocks a user. + """ + def block_user(blocker_id, blocked_id, reason \\ nil) do + result = + %UserBlock{} + |> UserBlock.changeset(%{ + blocker_id: blocker_id, + blocked_id: blocked_id, + reason: reason + }) + |> Repo.insert() + + notify_home_feed_policy_changed(blocker_id, :blocked_user) + result + end + + @doc """ + Unblocks a user. + """ + def unblock_user(blocker_id, blocked_id) do + result = + case Repo.get_by(UserBlock, blocker_id: blocker_id, blocked_id: blocked_id) do + nil -> {:error, :not_blocked} + block -> Repo.delete(block) + end + + notify_home_feed_policy_changed(blocker_id, :unblocked_user) + result + end + + @doc """ + Checks if a user is blocked by another user. + """ + def user_blocked?(blocker_id, blocked_id) do + Repo.exists?( + from(ub in UserBlock, + where: ub.blocker_id == ^blocker_id and ub.blocked_id == ^blocked_id + ) + ) + end + + @doc """ + Gets all users blocked by a user. + """ + def list_blocked_users(blocker_id) do + from(u in User, + join: ub in UserBlock, + on: ub.blocked_id == u.id, + where: ub.blocker_id == ^blocker_id, + order_by: [desc: ub.inserted_at], + preload: [:profile] + ) + |> Repo.all() + end + + @doc """ + Gets all users who have blocked a user. + """ + def list_users_who_blocked(blocked_id) do + from(u in User, + join: ub in UserBlock, + on: ub.blocker_id == u.id, + where: ub.blocked_id == ^blocked_id, + select: u + ) + |> Repo.all() + end + + @doc """ + Blocks a remote actor (ActivityPub federation). + """ + def block_remote_actor(user_id, remote_actor_id) do + alias Elektrine.ActivityPub.UserBlock + + # Get remote actor URI + remote_actor = Elektrine.Repo.get(Elektrine.ActivityPub.Actor, remote_actor_id) + + if remote_actor do + # Create block record + result = + %UserBlock{} + |> UserBlock.changeset(%{ + user_id: user_id, + blocked_uri: remote_actor.uri, + block_type: "user" + }) + |> Repo.insert() + + case result do + {:ok, block} -> + # Federate Block activity + Elektrine.Async.start(fn -> + user = Elektrine.Accounts.get_user!(user_id) + + block_activity = + Elektrine.ActivityPub.Builder.build_block_activity(user, remote_actor.uri) + + Elektrine.ActivityPub.Publisher.publish(block_activity, user, [remote_actor.inbox_url]) + end) + + notify_home_feed_policy_changed(user_id, :blocked_remote_actor) + {:ok, block} + + error -> + error + end + else + {:error, :remote_actor_not_found} + end + end + + @doc """ + Unblocks a remote actor (ActivityPub federation). + """ + def unblock_remote_actor(user_id, remote_actor_id) do + alias Elektrine.ActivityPub.UserBlock + + # Get remote actor URI + remote_actor = Elektrine.Repo.get(Elektrine.ActivityPub.Actor, remote_actor_id) + + if remote_actor do + # Delete block record + case Repo.get_by(UserBlock, user_id: user_id, blocked_uri: remote_actor.uri) do + nil -> + {:error, :not_blocked} + + block -> + result = Repo.delete(block) + + case result do + {:ok, _deleted_block} -> + # Federate Undo Block activity + Elektrine.Async.start(fn -> + user = Elektrine.Accounts.get_user!(user_id) + + # Build original Block activity + original_block = %{ + "type" => "Block", + "actor" => "#{Elektrine.ActivityPub.instance_url()}/users/#{user.username}", + "object" => remote_actor.uri + } + + undo_activity = + Elektrine.ActivityPub.Builder.build_undo_activity(user, original_block) + + Elektrine.ActivityPub.Publisher.publish(undo_activity, user, [ + remote_actor.inbox_url + ]) + end) + + notify_home_feed_policy_changed(user_id, :unblocked_remote_actor) + {:ok, :unblocked} + + error -> + error + end + end + else + {:error, :remote_actor_not_found} + end + end + + @doc """ + Checks if a user has blocked a remote actor. + """ + def remote_actor_blocked?(user_id, remote_actor_id) do + alias Elektrine.ActivityPub.UserBlock + + remote_actor = Elektrine.Repo.get(Elektrine.ActivityPub.Actor, remote_actor_id) + + if remote_actor do + Repo.exists?( + from(ub in UserBlock, + where: + ub.user_id == ^user_id and ub.blocked_uri == ^remote_actor.uri and + ub.block_type == "user" + ) + ) + else + false + end + end + + @doc """ + Lists all remote actors blocked by a user. + """ + def list_blocked_remote_actors(user_id) do + from(ub in ActivityPubUserBlock, + where: ub.user_id == ^user_id and ub.block_type == "user", + order_by: [desc: ub.inserted_at] + ) + |> Repo.all() + end + + @doc """ + Mutes a remote actor for local timelines and notifications. + """ + def mute_remote_actor(user_id, remote_actor_id) do + case Elektrine.Repo.get(Elektrine.ActivityPub.Actor, remote_actor_id) do + nil -> + {:error, :remote_actor_not_found} + + remote_actor -> + result = + case Repo.get_by(ActivityPubUserBlock, + user_id: user_id, + blocked_uri: remote_actor.uri + ) do + nil -> + %ActivityPubUserBlock{} + |> ActivityPubUserBlock.changeset(%{ + user_id: user_id, + blocked_uri: remote_actor.uri, + block_type: "mute" + }) + |> Repo.insert() + + %ActivityPubUserBlock{} = existing -> + {:ok, existing} + end + + notify_home_feed_policy_changed(user_id, :muted_remote_actor) + result + end + end + + @doc """ + Unmutes a remote actor. + """ + def unmute_remote_actor(user_id, remote_actor_id) do + case Elektrine.Repo.get(Elektrine.ActivityPub.Actor, remote_actor_id) do + nil -> + {:error, :remote_actor_not_found} + + remote_actor -> + result = + case Repo.get_by(ActivityPubUserBlock, + user_id: user_id, + blocked_uri: remote_actor.uri, + block_type: "mute" + ) do + nil -> {:error, :not_muted} + mute -> Repo.delete(mute) + end + + notify_home_feed_policy_changed(user_id, :unmuted_remote_actor) + result + end + end + + @doc """ + Checks if a user has muted a remote actor. + """ + def remote_actor_muted?(user_id, remote_actor_id) do + case Elektrine.Repo.get(Elektrine.ActivityPub.Actor, remote_actor_id) do + nil -> + false + + remote_actor -> + Repo.exists?( + from ub in ActivityPubUserBlock, + where: + ub.user_id == ^user_id and ub.blocked_uri == ^remote_actor.uri and + ub.block_type == "mute" + ) + end + end + + @doc """ + Lists all remote actor mutes for a user. + """ + def list_muted_remote_actors(user_id) do + from(ub in ActivityPubUserBlock, + where: ub.user_id == ^user_id and ub.block_type == "mute", + order_by: [desc: ub.inserted_at] + ) + |> Repo.all() + end + + @doc """ + Blocks a remote domain for a specific user. + """ + def block_domain(user_id, domain) when is_integer(user_id) and is_binary(domain) do + with {:ok, domain} <- normalize_domain_block(domain) do + result = + case Repo.get_by(ActivityPubUserBlock, user_id: user_id, blocked_uri: domain) do + nil -> + %ActivityPubUserBlock{} + |> ActivityPubUserBlock.changeset(%{ + user_id: user_id, + blocked_uri: domain, + block_type: "domain" + }) + |> Repo.insert() + + %ActivityPubUserBlock{} = block -> + {:ok, block} + end + + notify_home_feed_policy_changed(user_id, :blocked_domain) + result + end + end + + def block_domain(_user_id, _domain), do: {:error, :invalid_domain} + + @doc """ + Unblocks a remote domain for a specific user. + """ + def unblock_domain(user_id, domain) when is_integer(user_id) and is_binary(domain) do + with {:ok, domain} <- normalize_domain_block(domain) do + result = + case Repo.get_by(ActivityPubUserBlock, + user_id: user_id, + blocked_uri: domain, + block_type: "domain" + ) do + nil -> {:ok, :not_blocked} + block -> Repo.delete(block) + end + + notify_home_feed_policy_changed(user_id, :unblocked_domain) + result + end + end + + def unblock_domain(_user_id, _domain), do: {:error, :invalid_domain} + + @doc """ + Lists remote domains blocked by a specific user. + """ + def list_blocked_domains(user_id) when is_integer(user_id) do + from(ub in ActivityPubUserBlock, + where: ub.user_id == ^user_id and ub.block_type == "domain", + order_by: [asc: ub.blocked_uri], + select: ub.blocked_uri + ) + |> Repo.all() + end + + def list_blocked_domains(_user_id), do: [] + + @doc """ + Checks whether a remote domain is blocked by a user. + """ + def domain_blocked?(user_id, domain) when is_integer(user_id) and is_binary(domain) do + Repo.exists?( + from ub in ActivityPubUserBlock, + where: ub.user_id == ^user_id and ub.block_type == "domain", + where: + fragment("lower(?)", ub.blocked_uri) == fragment("lower(?)", ^domain) or + fragment( + "? LIKE '*.%' AND lower(?) LIKE ('%.' || substring(lower(?) from 3))", + ub.blocked_uri, + ^domain, + ub.blocked_uri + ) + ) + end + + def domain_blocked?(_user_id, _domain), do: false + + defp normalize_domain_block(value) when is_binary(value) do + value = + value + |> String.trim() + |> String.trim_leading("@") + |> String.downcase() + + value = + case URI.parse(value) do + %URI{host: host} when is_binary(host) -> host + _ -> value + end + + wildcard? = String.starts_with?(value, "*.") + domain = if wildcard?, do: String.trim_leading(value, "*."), else: value + domain = domain |> String.split("/", parts: 2) |> List.first() |> String.trim(".") + + cond do + domain == "" -> + {:error, :invalid_domain} + + not Regex.match?(~r/^[a-z0-9.-]+$/, domain) -> + {:error, :invalid_domain} + + String.contains?(domain, "..") -> + {:error, :invalid_domain} + + not String.contains?(domain, ".") -> + {:error, :invalid_domain} + + wildcard? -> + {:ok, "*." <> domain} + + true -> + {:ok, domain} + end + end + + defp notify_home_feed_policy_changed(user_id, reason) when is_integer(user_id) do + module = Module.concat([Elektrine, Social, HomeFeedInvalidationWorker]) + + if Code.ensure_loaded?(module) do + _ = module.clear_user(user_id, reason) + end + + :ok + rescue + _ -> :ok + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/built_in_subdomain.ex b/apps/elektrine/lib/elektrine/accounts/built_in_subdomain.ex new file mode 100644 index 0000000..a3c43d9 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/built_in_subdomain.ex @@ -0,0 +1,12 @@ +defmodule Elektrine.Accounts.BuiltInSubdomain do + @moduledoc false + + @modes ~w(path platform external_dns) + + def modes, do: @modes + + def mode(%{built_in_subdomain_mode: mode}) when mode in @modes, do: mode + def mode(_), do: "path" + + def hosted_by_platform?(user), do: mode(user) == "platform" +end diff --git a/apps/elektrine/lib/elektrine/accounts/cached.ex b/apps/elektrine/lib/elektrine/accounts/cached.ex new file mode 100644 index 0000000..fabb931 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/cached.ex @@ -0,0 +1,107 @@ +defmodule Elektrine.Accounts.Cached do + @moduledoc """ + Cached versions of Accounts context functions. + Provides caching for user data, preferences, and authentication. + """ + + alias Elektrine.Accounts + alias Elektrine.AppCache + alias Elektrine.EmailAddresses + + @doc """ + Gets cached user data by ID. + """ + def get_user!(id) do + {:ok, user} = + AppCache.get_user_data(id, fn -> + Accounts.get_user!(id) + end) + + user + end + + @doc """ + Gets cached user data by username. + """ + def get_user_by_username(username) do + # For username lookups, we cache with a different key + {:ok, user} = + AppCache.get_system_config("user_by_username:#{username}", fn -> + Accounts.get_user_by_username(username) + end) + + user + end + + @doc """ + Gets cached user data by ActivityPub identifier (handle canonical, username alias). + """ + def get_user_by_activitypub_identifier(identifier) do + {:ok, user} = + AppCache.get_system_config("user_by_activitypub_identifier:#{identifier}", fn -> + Accounts.get_user_by_activitypub_identifier(identifier) + end) + + user + end + + @doc """ + Gets cached user preferences/settings. + """ + def get_user_preferences(user_id) do + {:ok, preferences} = + AppCache.get_user_preferences(user_id, fn -> + # This would need to be implemented in Accounts context + # Return a baseline structure until Accounts-backed preferences are wired in. + %{ + email_notifications: true, + theme: "light", + timezone: "UTC", + language: "en", + time_format: "12" + } + end) + + preferences + end + + @doc """ + Invalidates all cached data for a user. + Should be called when user data changes. Accepts a `%User{}` (clears the + lookup caches keyed by that struct's username/email) or a user id. + """ + def invalidate_user_cache(%Accounts.User{} = user) do + AppCache.invalidate_user_cache(user.id) + delete_lookup_caches(user) + end + + def invalidate_user_cache(user_id) do + AppCache.invalidate_user_cache(user_id) + + try do + user_id + |> Accounts.get_user!() + |> delete_lookup_caches() + rescue + _ -> :ok + end + end + + defp delete_lookup_caches(user) do + AppCache.delete_system_config("user_by_username:#{user.username}") + AppCache.delete_system_config("user_by_activitypub_identifier:#{user.username}") + + if user.handle do + AppCache.delete_system_config("user_by_activitypub_identifier:#{user.handle}") + end + + try do + user_email = EmailAddresses.primary_for_user(user) + AppCache.delete_system_config("user_by_email:#{user_email}") + rescue + _ -> :ok + end + + :ok + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/capabilities.ex b/apps/elektrine/lib/elektrine/accounts/capabilities.ex new file mode 100644 index 0000000..1093635 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/capabilities.ex @@ -0,0 +1,420 @@ +defmodule Elektrine.Accounts.Capabilities do + @moduledoc """ + Central account capability policy. + + Proofs and clean account history feed reputation. Reputation, trust level, + restrictions, and credits then determine what an account can do and what + risky actions cost. + """ + + alias Elektrine.Accounts.User + alias Elektrine.{Friends, Repo, System} + + @personhood_module Module.concat([Atomine, Personhood]) + @credits_module Module.concat([Atomine, Credits]) + @credit_earning_policy_module Module.concat([Atomine, CreditEarningPolicy]) + + @first_dm_action "first_dm" + @send_email_action "send_email" + @first_dm_cost 1 + @send_email_cost 1 + + @email_tier_limits %{ + day_1: %{minute: 1, hour: 5, day: 10, recipients: 3}, + days_2_3: %{minute: 2, hour: 8, day: 10, recipients: 5}, + days_4_7: %{minute: 2, hour: 15, day: 10, recipients: 10}, + week_2: %{minute: 3, hour: 25, day: 10, recipients: 15}, + weeks_3_4: %{minute: 3, hour: 35, day: 10, recipients: 20}, + tl1: %{minute: 5, hour: 50, day: 200, recipients: 50}, + tl2: %{minute: 10, hour: 100, day: 500, recipients: 100}, + tl3_plus: %{minute: 15, hour: 150, day: 1000, recipients: 200} + } + + @vpn_default_bandwidth_quota_bytes 10_737_418_240 + @vpn_default_rate_limit_mbps 50 + + @doc "Returns the complete policy snapshot for an account." + def snapshot(user_or_id, opts \\ []) do + user = load_user(user_or_id) + reputation = reputation_snapshot(user) + credits = credit_snapshot(user) + + %{ + user_id: user && user.id, + trust_level: trust_level(user), + effective_trust_level: effective_trust_level(user, reputation), + reputation: reputation, + proofs: %{ + verified_count: reputation.verified_proof_count, + verified_kinds: reputation.verified_proof_kinds + }, + credits: credits, + restrictions: restrictions(user), + capabilities: %{ + email: email_capability(user), + dm: dm_capability(user, opts), + vpn: vpn_capability(user, opts), + invites: invite_capability(user) + } + } + end + + @doc "Returns current built-in action pricing for UI/API discovery." + def action_prices do + [ + %{ + action: @first_dm_action, + label: "First DM", + atomine_cost: @first_dm_cost, + gate_enabled: credit_gate_enabled?(:dm), + free_for: "TL1+ and admins" + }, + %{ + action: @send_email_action, + label: "External email", + atomine_cost: @send_email_cost, + gate_enabled: credit_gate_enabled?(:email), + free_for: "TL3+ and admins" + } + ] + end + + def credit_gate_enabled?(:dm) do + :atomine + |> Application.get_env(:credits, []) + |> Keyword.get(:dm_gate_enabled, false) + end + + def credit_gate_enabled?(:email) do + :atomine + |> Application.get_env(:credits, []) + |> Keyword.get(:email_gate_enabled, false) + end + + def credit_gate_enabled?(_gate), do: false + + @doc "Returns whether a first DM is free or requires credits." + def first_dm_credit_requirement(user_or_id, opts \\ []) do + if credit_gate_enabled?(:dm) do + user = load_user(user_or_id) + local_recipient_id = Keyword.get(opts, :local_recipient_id) + + cond do + is_nil(user) -> + {:error, :user_not_found} + + trusted_for_free_action?(user) -> + :free + + is_integer(local_recipient_id) and Friends.are_friends?(user.id, local_recipient_id) -> + :free + + true -> + :required + end + else + :free + end + end + + @doc "Returns whether an external email send is free or requires credits." + def email_credit_requirement(user_or_id, _opts \\ []) do + if credit_gate_enabled?(:email) do + case load_user(user_or_id) do + nil -> {:error, :user_not_found} + user -> if trusted_for_free_email?(user), do: :free, else: :required + end + else + :free + end + end + + @doc "Returns the email send tier and limits for a user." + def email_limits(user_or_id) do + user = load_user(user_or_id) + tier = email_tier(user) + limits = Map.fetch!(@email_tier_limits, tier) + + %{ + tier: tier, + minute_limit: limits.minute, + hour_limit: limits.hour, + day_limit: limits.day, + recipient_limit: limits.recipients + } + end + + @doc "Returns the configured platform-wide VPN minimum trust level." + def vpn_minimum_trust_level, do: System.module_min_trust_level(:vpn) + + @doc "Returns VPN access and quota policy for a user." + def vpn_capability(user_or_id, opts \\ []) do + user = load_user(user_or_id) + required_trust_level = vpn_required_trust_level(opts) + + base = %{ + required_trust_level: required_trust_level, + bandwidth_quota_bytes: @vpn_default_bandwidth_quota_bytes, + rate_limit_mbps: @vpn_default_rate_limit_mbps + } + + cond do + is_nil(user) -> + Map.merge(base, %{allowed: false, reason: :user_not_found}) + + user.is_admin -> + Map.merge(base, %{allowed: true, reason: nil}) + + user.banned -> + Map.merge(base, %{allowed: false, reason: :banned}) + + user.suspended -> + Map.merge(base, %{allowed: false, reason: :suspended}) + + trust_level(user) < required_trust_level -> + Map.merge(base, %{allowed: false, reason: :insufficient_trust_level}) + + true -> + Map.merge(base, %{allowed: true, reason: nil}) + end + end + + @doc "Returns true when a user can access VPN under the current policy." + def vpn_allowed?(user_or_id, opts \\ []), do: vpn_capability(user_or_id, opts).allowed + + defp dm_capability(user, opts) do + requirement = first_dm_credit_requirement(user, opts) + + %{ + first_dm_credit_requirement: requirement, + first_dm_atomine_cost: @first_dm_cost + } + end + + defp email_capability(user) do + limits = email_limits(user) + requirement = email_credit_requirement(user) + + %{ + allowed: email_allowed?(user), + reason: email_block_reason(user), + tier: limits.tier, + minute_limit: limits.minute_limit, + hour_limit: limits.hour_limit, + day_limit: limits.day_limit, + recipient_limit: limits.recipient_limit, + external_send_credit_requirement: requirement, + external_send_atomine_cost: @send_email_cost + } + end + + defp invite_capability(nil) do + %{self_service_allowed: false, min_trust_level: System.self_service_invite_min_trust_level()} + end + + defp invite_capability(%User{} = user) do + min_trust_level = System.self_service_invite_min_trust_level() + + %{ + self_service_allowed: user.is_admin || trust_level(user) >= min_trust_level, + min_trust_level: min_trust_level + } + end + + defp credit_snapshot(nil) do + %{ + available?: atomine_module_loaded?(@credits_module), + balances: %{}, + rows: [], + action_prices: [], + earning_paths: [] + } + end + + defp credit_snapshot(%User{id: user_id}) do + if atomine_module_loaded?(@credits_module) do + credit_types = ["atomine_credit"] + accounts = Atomine.Credits.list_accounts(user_id) + balances = Map.new(accounts, &{&1.credit_type, &1.balance}) + + rows = + Enum.map(credit_types, fn credit_type -> + %{ + type: credit_type, + label: credit_label(credit_type), + balance: Map.get(balances, credit_type, 0) + } + end) + + %{ + available?: true, + balances: Map.new(rows, &{&1.type, &1.balance}), + rows: rows, + action_prices: action_prices(), + earning_paths: credit_earning_paths() + } + else + %{available?: false, balances: %{}, rows: [], action_prices: [], earning_paths: []} + end + end + + defp credit_earning_paths do + if atomine_module_loaded?(@credit_earning_policy_module) do + Atomine.CreditEarningPolicy.earning_paths() + else + [] + end + end + + defp reputation_snapshot(nil) do + breakdown = empty_reputation_breakdown() + + %{ + score: breakdown.score, + level: breakdown.level, + verified_proof_count: 0, + verified_proof_kinds: [], + breakdown: breakdown + } + end + + defp reputation_snapshot(%User{} = user) do + breakdown = personhood_breakdown(user) + + %{ + score: breakdown.score, + level: breakdown.level, + verified_proof_count: breakdown.verified_proof_count, + verified_proof_kinds: breakdown.verified_proof_kinds, + breakdown: breakdown + } + end + + defp personhood_breakdown(user) do + if atomine_module_loaded?(@personhood_module) do + Atomine.Personhood.personhood_breakdown(user) + else + empty_reputation_breakdown() + end + end + + defp restrictions(nil) do + %{banned: false, suspended: false, email_sending_restricted: false} + end + + defp restrictions(%User{} = user) do + %{ + banned: user.banned || false, + suspended: user.suspended || false, + email_sending_restricted: user.email_sending_restricted || false, + email_rate_limit_violations: user.email_rate_limit_violations || 0, + email_restriction_reason: user.email_restriction_reason + } + end + + defp email_allowed?(%User{} = user) do + !user.banned && !user.suspended && !user.email_sending_restricted + end + + defp email_allowed?(_), do: false + + defp email_block_reason(nil), do: :user_not_found + defp email_block_reason(%User{banned: true}), do: :banned + defp email_block_reason(%User{suspended: true}), do: :suspended + defp email_block_reason(%User{email_sending_restricted: true}), do: :email_sending_restricted + defp email_block_reason(%User{}), do: nil + + defp email_tier(nil), do: :day_1 + + defp email_tier(%User{} = user) do + account_age_days = account_age_days(user.inserted_at) + trust_level = effective_trust_level(user) + + cond do + trust_level >= 3 -> :tl3_plus + trust_level == 2 -> :tl2 + trust_level == 1 -> :tl1 + account_age_days < 1 -> :day_1 + account_age_days < 3 -> :days_2_3 + account_age_days < 7 -> :days_4_7 + account_age_days < 14 -> :week_2 + account_age_days < 30 -> :weeks_3_4 + true -> :weeks_3_4 + end + end + + defp vpn_required_trust_level(opts) do + server_min_trust_level = Keyword.get(opts, :server_min_trust_level, 0) || 0 + max(vpn_minimum_trust_level(), server_min_trust_level) + end + + defp trusted_for_free_action?(%User{is_admin: true}), do: true + defp trusted_for_free_action?(%User{} = user), do: effective_trust_level(user) >= 1 + + defp trusted_for_free_email?(%User{is_admin: true}), do: true + defp trusted_for_free_email?(%User{} = user), do: effective_trust_level(user) >= 3 + + defp effective_trust_level(%User{} = user) do + effective_trust_level(user, reputation_snapshot(user)) + end + + defp effective_trust_level(%User{} = user, reputation) do + max(trust_level(user), reputation_trust_level(reputation)) + end + + defp effective_trust_level(_user, _reputation), do: 0 + + defp reputation_trust_level(%{level: :high}), do: 3 + defp reputation_trust_level(%{level: :medium}), do: 2 + defp reputation_trust_level(%{level: :low}), do: 1 + defp reputation_trust_level(_reputation), do: 0 + + defp trust_level(%User{trust_level: trust_level}) when is_integer(trust_level), do: trust_level + defp trust_level(_), do: 0 + + defp load_user(%User{} = user), do: user + defp load_user(user_id) when is_integer(user_id), do: Repo.get(User, user_id) + defp load_user(_), do: nil + + defp account_age_days(%DateTime{} = inserted_at), + do: DateTime.diff(DateTime.utc_now(), inserted_at, :day) + + defp account_age_days(%NaiveDateTime{} = inserted_at) do + inserted_at + |> DateTime.from_naive!("Etc/UTC") + |> account_age_days() + end + + defp account_age_days(_), do: 0 + + defp atomine_module_loaded?(module), do: Code.ensure_loaded?(module) + + defp credit_label("atomine_credit"), do: "Identity Credits" + + defp credit_label(value) do + value + |> to_string() + |> String.replace("_", " ") + |> String.split() + |> Enum.map_join(" ", &String.capitalize/1) + end + + defp empty_reputation_breakdown do + %{ + score: 0, + raw_score: 0, + level: :unknown, + positive: %{ + proofs: 0, + proof_diversity: 0, + account_age: 0, + security: 0, + account_history: 0, + platform_trust: 0 + }, + penalties: %{account_restrictions: 0, onion_registration: 0, proof_rejections: 0}, + verified_proof_count: 0, + verified_proof_kinds: [] + } + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/client_app_setting.ex b/apps/elektrine/lib/elektrine/accounts/client_app_setting.ex new file mode 100644 index 0000000..83e3fd1 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/client_app_setting.ex @@ -0,0 +1,39 @@ +defmodule Elektrine.Accounts.ClientAppSetting do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + schema "client_app_settings" do + field :app, :string + field :settings, :map, default: %{} + + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + def changeset(setting, attrs) do + setting + |> cast(attrs, [:user_id, :app, :settings]) + |> update_change(:app, &normalize_app/1) + |> validate_required([:user_id, :app, :settings]) + |> validate_length(:app, min: 1, max: 100) + |> validate_format(:app, ~r/^[A-Za-z0-9_.:-]+$/, + message: "may only contain letters, numbers, dots, underscores, colons, and hyphens" + ) + |> validate_settings_map() + |> unique_constraint([:user_id, :app]) + |> foreign_key_constraint(:user_id) + end + + def normalize_app(app) when is_binary(app), do: String.trim(app) + def normalize_app(app), do: app + + defp validate_settings_map(changeset) do + case get_field(changeset, :settings) do + settings when is_map(settings) -> changeset + _ -> add_error(changeset, :settings, "must be a map") + end + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/client_app_settings.ex b/apps/elektrine/lib/elektrine/accounts/client_app_settings.ex new file mode 100644 index 0000000..5c81e0b --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/client_app_settings.ex @@ -0,0 +1,65 @@ +defmodule Elektrine.Accounts.ClientAppSettings do + @moduledoc """ + Per-user settings persisted by API clients. + """ + + alias Elektrine.Accounts.ClientAppSetting + alias Elektrine.Repo + + def get_settings(user_id, app) when is_integer(user_id) and is_binary(app) do + app = ClientAppSetting.normalize_app(app) + + case Repo.get_by(ClientAppSetting, user_id: user_id, app: app) do + %ClientAppSetting{settings: settings} when is_map(settings) -> settings + _ -> %{} + end + end + + def update_settings(user_id, app, patch) + when is_integer(user_id) and is_binary(app) and is_map(patch) do + app = ClientAppSetting.normalize_app(app) + existing = Repo.get_by(ClientAppSetting, user_id: user_id, app: app) + current = settings_from(existing) + merged = deep_merge_delete(current, patch) + + attrs = %{user_id: user_id, app: app, settings: merged} + + case existing do + nil -> + %ClientAppSetting{} + |> ClientAppSetting.changeset(attrs) + |> Repo.insert() + |> unwrap_settings() + + %ClientAppSetting{} = setting -> + setting + |> ClientAppSetting.changeset(attrs) + |> Repo.update() + |> unwrap_settings() + end + end + + def update_settings(_user_id, _app, _patch), do: {:error, :invalid_settings} + + defp settings_from(%ClientAppSetting{settings: settings}) when is_map(settings), do: settings + defp settings_from(_), do: %{} + + defp unwrap_settings({:ok, %ClientAppSetting{settings: settings}}), do: {:ok, settings} + defp unwrap_settings(error), do: error + + defp deep_merge_delete(current, patch) do + Enum.reduce(patch, current, fn + {key, nil}, acc -> + Map.delete(acc, key) + + {key, value}, acc when is_map(value) -> + case Map.get(acc, key) do + existing when is_map(existing) -> Map.put(acc, key, deep_merge_delete(existing, value)) + _ -> Map.put(acc, key, deep_merge_delete(%{}, value)) + end + + {key, value}, acc -> + Map.put(acc, key, value) + end) + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/connected_account.ex b/apps/elektrine/lib/elektrine/accounts/connected_account.ex new file mode 100644 index 0000000..cec396b --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/connected_account.ex @@ -0,0 +1,84 @@ +defmodule Elektrine.Accounts.ConnectedAccount do + @moduledoc """ + A reusable external account connection owned by a user. + + These records are created by provider OAuth/OIDC flows and can be consumed by + modules such as Atomine proofs, static-site deploy integrations, importers, or + webhook setup without each module owning a separate provider identity table. + """ + + use Ecto.Schema + import Ecto.Changeset + + @providers ~w(github gitlab google discord x mastodon bluesky) + + schema "connected_accounts" do + belongs_to :user, Elektrine.Accounts.User + field :provider, :string + field :provider_account_id, :string + field :username, :string + field :display_name, :string + field :email, :string + field :profile_url, :string + field :avatar_url, :string + field :scopes, {:array, :string}, default: [] + field :metadata, :map, default: %{} + field :last_verified_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + def providers, do: @providers + + def changeset(connected_account, attrs) do + connected_account + |> cast(attrs, [ + :user_id, + :provider, + :provider_account_id, + :username, + :display_name, + :email, + :profile_url, + :avatar_url, + :scopes, + :metadata, + :last_verified_at + ]) + |> normalize_string(:provider) + |> normalize_string(:provider_account_id) + |> normalize_optional_string(:username) + |> normalize_optional_string(:display_name) + |> normalize_optional_string(:email) + |> validate_required([:user_id, :provider, :provider_account_id]) + |> validate_inclusion(:provider, @providers) + |> validate_length(:provider_account_id, min: 1, max: 500) + |> validate_length(:username, max: 500) + |> validate_length(:display_name, max: 500) + |> validate_length(:email, max: 500) + |> validate_length(:profile_url, max: 2_000) + |> validate_length(:avatar_url, max: 2_000) + |> foreign_key_constraint(:user_id) + |> unique_constraint([:provider, :provider_account_id]) + |> unique_constraint([:user_id, :provider, :provider_account_id], + name: :connected_accounts_user_provider_account_unique + ) + end + + defp normalize_string(changeset, field) do + update_change(changeset, field, fn + value when is_binary(value) -> value |> String.trim() |> String.downcase() + value -> value + end) + end + + defp normalize_optional_string(changeset, field) do + update_change(changeset, field, fn + value when is_binary(value) -> value |> String.trim() |> blank_to_nil() + value -> value + end) + end + + defp blank_to_nil(""), do: nil + defp blank_to_nil(value), do: value +end diff --git a/apps/elektrine/lib/elektrine/accounts/endorsements.ex b/apps/elektrine/lib/elektrine/accounts/endorsements.ex new file mode 100644 index 0000000..f1b74bd --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/endorsements.ex @@ -0,0 +1,93 @@ +defmodule Elektrine.Accounts.Endorsements do + @moduledoc """ + User-owned endorsed account relationships. + """ + + import Ecto.Query, warn: false + + alias Elektrine.Accounts.{AccountEndorsement, User} + alias Elektrine.ActivityPub.Actor + alias Elektrine.Repo + + def endorse_account(user_id, %User{id: user_id}), do: {:error, :self_endorse} + + def endorse_account(user_id, %User{} = account) when is_integer(user_id) do + attrs = %{user_id: user_id, endorsed_user_id: account.id} + + case Repo.get_by(AccountEndorsement, attrs) do + %AccountEndorsement{} = endorsement -> {:ok, endorsement} + nil -> insert_endorsement(attrs) + end + end + + def endorse_account(user_id, %Actor{} = actor) when is_integer(user_id) do + attrs = %{user_id: user_id, remote_actor_id: actor.id} + + case Repo.get_by(AccountEndorsement, attrs) do + %AccountEndorsement{} = endorsement -> {:ok, endorsement} + nil -> insert_endorsement(attrs) + end + end + + def endorse_account(_user_id, _account), do: {:error, :invalid_account} + + def unendorse_account(user_id, %User{} = account) when is_integer(user_id) do + delete_endorsement(user_id, endorsed_user_id: account.id) + end + + def unendorse_account(user_id, %Actor{} = actor) when is_integer(user_id) do + delete_endorsement(user_id, remote_actor_id: actor.id) + end + + def unendorse_account(_user_id, _account), do: {:error, :invalid_account} + + def account_endorsed?(user_id, %User{} = account) when is_integer(user_id) do + endorsement_exists?(user_id, endorsed_user_id: account.id) + end + + def account_endorsed?(user_id, %Actor{} = actor) when is_integer(user_id) do + endorsement_exists?(user_id, remote_actor_id: actor.id) + end + + def account_endorsed?(_user_id, _account), do: false + + def list_endorsed_accounts(user_id) when is_integer(user_id) do + endorsements = + AccountEndorsement + |> where([endorsement], endorsement.user_id == ^user_id) + |> order_by([endorsement], desc: endorsement.inserted_at, desc: endorsement.id) + |> preload([:endorsed_user, :remote_actor]) + |> Repo.all() + + Enum.flat_map(endorsements, fn + %AccountEndorsement{endorsed_user: %User{} = user} -> [user] + %AccountEndorsement{remote_actor: %Actor{} = actor} -> [actor] + _endorsement -> [] + end) + end + + def list_endorsed_accounts(_user_id), do: [] + + defp insert_endorsement(attrs) do + %AccountEndorsement{} + |> AccountEndorsement.changeset(attrs) + |> Repo.insert() + end + + defp delete_endorsement(user_id, clauses) do + case get_endorsement(user_id, clauses) do + %AccountEndorsement{} = endorsement -> Repo.delete(endorsement) + nil -> {:ok, :not_endorsed} + end + end + + defp endorsement_exists?(user_id, clauses) do + not is_nil(get_endorsement(user_id, clauses)) + end + + defp get_endorsement(user_id, clauses) do + clauses + |> Keyword.put(:user_id, user_id) + |> then(&Repo.get_by(AccountEndorsement, &1)) + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/invite_code.ex b/apps/elektrine/lib/elektrine/accounts/invite_code.ex new file mode 100644 index 0000000..ff1cf22 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/invite_code.ex @@ -0,0 +1,100 @@ +defmodule Elektrine.Accounts.InviteCode do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @code_regex ~r/^[A-Z0-9]{6,}$/ + + schema "invite_codes" do + field :code, :string + field :max_uses, :integer, default: 1 + field :uses_count, :integer, default: 0 + field :expires_at, :utc_datetime + field :note, :string + field :is_active, :boolean, default: true + + belongs_to :created_by, Elektrine.Accounts.User + has_many :uses, Elektrine.Accounts.InviteCodeUse + + timestamps() + end + + @doc false + def changeset(invite_code, attrs) do + if invite_code.id do + update_changeset(invite_code, attrs) + else + create_changeset(invite_code, attrs) + end + end + + @doc false + def create_changeset(invite_code, attrs) do + invite_code + |> cast(attrs, [:code, :max_uses, :expires_at, :note, :is_active, :created_by_id]) + |> normalize_code_field() + |> validate_required([:code]) + |> validate_number(:max_uses, greater_than: 0) + |> unique_constraint(:code) + |> unique_constraint(:code, name: :invite_codes_code_upper_unique) + |> validate_code_format() + end + + @doc false + def update_changeset(invite_code, attrs) do + invite_code + |> cast(attrs, [:max_uses, :expires_at, :note, :is_active]) + |> validate_number(:max_uses, greater_than: 0) + |> validate_max_uses_not_below_current_usage() + |> validate_code_format() + end + + defp validate_code_format(changeset) do + validate_change(changeset, :code, fn :code, code -> + if String.match?(code, @code_regex) do + [] + else + [code: "must be at least 6 characters long and contain only letters and numbers"] + end + end) + end + + defp validate_max_uses_not_below_current_usage(%Ecto.Changeset{} = changeset) do + max_uses = get_field(changeset, :max_uses) + current_uses = changeset.data.uses_count || 0 + + if is_integer(max_uses) and max_uses < current_uses do + add_error(changeset, :max_uses, "cannot be less than current uses (#{current_uses})") + else + changeset + end + end + + defp normalize_code_field(changeset) do + update_change(changeset, :code, &normalize_code/1) + end + + def normalize_code(code) when is_binary(code) do + code + |> String.trim() + |> String.upcase() + end + + def normalize_code(_), do: nil + + def generate_code do + :crypto.strong_rand_bytes(16) + |> Base.encode32(padding: false) + |> String.upcase() + end + + def expired?(%__MODULE__{expires_at: nil}), do: false + + def expired?(%__MODULE__{expires_at: expires_at}) do + DateTime.compare(expires_at, DateTime.utc_now()) == :lt + end + + def exhausted?(%__MODULE__{uses_count: uses_count, max_uses: max_uses}) do + uses_count >= max_uses + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/invite_code_use.ex b/apps/elektrine/lib/elektrine/accounts/invite_code_use.ex new file mode 100644 index 0000000..5b70415 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/invite_code_use.ex @@ -0,0 +1,22 @@ +defmodule Elektrine.Accounts.InviteCodeUse do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "invite_code_uses" do + belongs_to :invite_code, Elektrine.Accounts.InviteCode + belongs_to :user, Elektrine.Accounts.User + field :used_at, :utc_datetime + end + + @doc false + def changeset(invite_code_use, attrs) do + invite_code_use + |> cast(attrs, [:invite_code_id, :user_id]) + |> put_change(:used_at, DateTime.utc_now() |> DateTime.truncate(:second)) + |> validate_required([:invite_code_id, :user_id]) + |> foreign_key_constraint(:invite_code_id) + |> foreign_key_constraint(:user_id) + |> unique_constraint(:user_id, name: :invite_code_uses_user_id_unique) + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/moderation.ex b/apps/elektrine/lib/elektrine/accounts/moderation.ex new file mode 100644 index 0000000..47263c2 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/moderation.ex @@ -0,0 +1,521 @@ +defmodule Elektrine.Accounts.Moderation do + @moduledoc """ + User moderation functionality for administrators. + Handles banning, suspending, and administrative operations on user accounts. + """ + + import Ecto.Query, warn: false + alias Elektrine.Accounts.{AccountDeletionRequest, TrustLevel, User} + alias Elektrine.Accounts.Authentication + alias Elektrine.Accounts.Cached + alias Elektrine.Platform.Modules + alias Elektrine.Repo + + require Logger + + ## Ban/Suspend Operations + + @doc """ + Bans a user with an optional reason. + Admins cannot be banned for security reasons. + + ## Examples + + iex> ban_user(user, %{banned_reason: "Violation of terms"}) + {:ok, %User{}} + + iex> ban_user(admin_user) + {:error, :cannot_ban_admin} + + """ + def ban_user(%User{} = user, attrs \\ %{}) do + if user.is_admin do + {:error, :cannot_ban_admin} + else + user + |> User.ban_changeset(attrs) + |> Repo.update() + |> maybe_reconcile_trust_level() + |> revoke_banned_user_credentials() + end + end + + @doc """ + Unbans a user. + + ## Examples + + iex> unban_user(user) + {:ok, %User{}} + + """ + def unban_user(%User{} = user) do + user + |> User.unban_changeset() + |> Repo.update() + |> maybe_reconcile_trust_level() + end + + @doc """ + Suspends a user until a specific date/time. + Admins cannot be suspended for security reasons. + + ## Examples + + iex> suspend_user(user, %{suspended_until: ~U[2024-12-31 23:59:59Z], suspension_reason: "Spam"}) + {:ok, %User{}} + + iex> suspend_user(admin_user, %{suspended_until: ~U[2024-12-31 23:59:59Z]}) + {:error, :cannot_suspend_admin} + + """ + def suspend_user(%User{} = user, attrs) do + if user.is_admin do + {:error, :cannot_suspend_admin} + else + user + |> User.suspend_changeset(attrs) + |> Repo.update() + |> maybe_record_suspension(user) + end + end + + @doc """ + Unsuspends a user. + + ## Examples + + iex> unsuspend_user(user) + {:ok, %User{}} + + """ + def unsuspend_user(%User{} = user) do + user + |> User.unsuspend_changeset() + |> Repo.update() + |> maybe_reconcile_trust_level() + end + + @doc """ + Checks if a user is currently suspended. + """ + def user_suspended?(%User{} = user) do + user.suspended && + (is_nil(user.suspended_until) || + DateTime.compare(user.suspended_until, DateTime.utc_now()) == :gt) + end + + defp revoke_banned_user_credentials({:ok, %User{} = user} = result) do + _ = Elektrine.Accounts.revoke_all_user_sessions(user.id, "account_banned") + _ = Authentication.revoke_all_app_passwords(user.id) + _ = Elektrine.Developer.revoke_all_api_tokens(user.id) + _ = ElektrineWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) + _ = broadcast_mail_auth_changed(user.id) + + result + end + + defp revoke_banned_user_credentials(result), do: result + + defp broadcast_mail_auth_changed(user_id) do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "mail_auth:user:#{user_id}", + {:mail_auth_changed, user_id} + ) + end + + @doc """ + Automatically unsuspends users whose suspension period has expired. + """ + def unsuspend_expired_users do + now = DateTime.utc_now() + + from(u in User, + where: u.suspended == true, + where: not is_nil(u.suspended_until), + where: u.suspended_until <= ^now + ) + |> Repo.all() + |> Enum.each(&unsuspend_user/1) + end + + ## Admin User Management + + @doc """ + Updates a user's admin status. + + ## Examples + + iex> update_user_admin_status(user, true) + {:ok, %User{}} + + iex> update_user_admin_status(user, false) + {:ok, %User{}} + + """ + def update_user_admin_status(%User{} = user, is_admin) when is_boolean(is_admin) do + user + |> Ecto.Changeset.cast(%{is_admin: is_admin}, [:is_admin]) + |> Repo.update() + end + + @doc """ + Creates a user (admin only). + + ## Examples + + iex> admin_create_user(%{username: "newuser", password: "password123", password_confirmation: "password123"}) + {:ok, %User{}} + + iex> admin_create_user(%{username: ""}) + {:error, %Ecto.Changeset{}} + + """ + def admin_create_user(attrs \\ %{}) do + result = + %User{} + |> User.admin_registration_changeset(attrs) + |> Repo.insert() + + case result do + {:ok, user} -> + maybe_create_user_mailbox(user) + {:ok, user} + + error -> + error + end + end + + @doc """ + Updates a user (admin only). + + ## Examples + + iex> admin_update_user(user, %{username: "new_username"}) + {:ok, %User{}} + + iex> admin_update_user(user, %{username: ""}) + {:error, %Ecto.Changeset{}} + + """ + def admin_update_user(%User{} = user, attrs) do + changeset = User.admin_changeset(user, attrs) + + case Repo.update(changeset) do + {:ok, updated_user} -> + # Update mailbox email if username changed + case Ecto.Changeset.get_change(changeset, :username) do + nil -> + :ok + + _new_username -> + update_mailbox_email_for_username_change(updated_user) + # Clear lookup caches keyed by the old username + Cached.invalidate_user_cache(user) + end + + Cached.invalidate_user_cache(updated_user) + {:ok, updated_user} + + error -> + error + end + end + + @doc """ + Deletes a user and all associated data (admin operation). + + ## Examples + + iex> admin_delete_user(user) + {:ok, %User{}} + + iex> admin_delete_user(user) + {:error, %Ecto.Changeset{}} + + """ + def admin_delete_user(%User{} = user) do + # Delete all user's data first + Repo.transaction(fn -> + maybe_delete_email_data(user) + + # Finally delete the user + case Repo.delete(user) do + {:ok, user} -> user + {:error, changeset} -> Repo.rollback(changeset) + end + end) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user admin changes. + + ## Examples + + iex> change_user_admin(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_admin(%User{} = user, attrs \\ %{}) do + User.admin_changeset(user, attrs) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for creating a user (admin only). + + ## Examples + + iex> change_user_admin_registration(%User{}) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_admin_registration(%User{} = user, attrs \\ %{}) do + User.admin_registration_changeset(user, attrs) + end + + ## Account Deletion Request Management + + @doc """ + Creates an account deletion request. + + ## Examples + + iex> create_deletion_request(user, %{reason: "No longer needed"}) + {:ok, %AccountDeletionRequest{}} + + iex> create_deletion_request(user, %{}) + {:error, %Ecto.Changeset{}} + + """ + def create_deletion_request(%User{} = user, attrs \\ %{}) do + attrs = Map.put(attrs, :user_id, user.id) + attrs = Map.put(attrs, :requested_at, DateTime.utc_now() |> DateTime.truncate(:second)) + + %AccountDeletionRequest{} + |> AccountDeletionRequest.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Gets a user's pending deletion request. + + ## Examples + + iex> get_pending_deletion_request(user) + %AccountDeletionRequest{} + + iex> get_pending_deletion_request(user) + nil + + """ + def get_pending_deletion_request(%User{} = user) do + Repo.get_by(AccountDeletionRequest, user_id: user.id, status: "pending") + end + + @doc """ + Lists all account deletion requests. + + ## Examples + + iex> list_deletion_requests() + [%AccountDeletionRequest{}, ...] + + """ + def list_deletion_requests do + from(r in AccountDeletionRequest, + join: u in User, + on: r.user_id == u.id, + left_join: admin in User, + on: r.reviewed_by_id == admin.id, + select: %{r | user: u, reviewed_by: admin}, + order_by: [desc: r.requested_at] + ) + |> Repo.all() + end + + @doc """ + Gets a single deletion request. + + ## Examples + + iex> get_deletion_request!(123) + %AccountDeletionRequest{} + + iex> get_deletion_request!(456) + ** (Ecto.NoResultsError) + + """ + def get_deletion_request!(id) do + from(r in AccountDeletionRequest, + join: u in User, + on: r.user_id == u.id, + left_join: admin in User, + on: r.reviewed_by_id == admin.id, + where: r.id == ^id, + select: %{r | user: u, reviewed_by: admin} + ) + |> Repo.one!() + end + + @doc """ + Reviews an account deletion request (approve or deny). + + ## Examples + + iex> review_deletion_request(request, admin, "approved", %{admin_notes: "Approved"}) + {:ok, %AccountDeletionRequest{}} + + iex> review_deletion_request(request, admin, "denied", %{admin_notes: "Invalid reason"}) + {:ok, %AccountDeletionRequest{}} + + """ + def review_deletion_request( + %AccountDeletionRequest{} = request, + %User{} = admin, + status, + attrs \\ %{} + ) do + review_attrs = %{ + status: status, + reviewed_at: DateTime.utc_now() |> DateTime.truncate(:second), + reviewed_by_id: admin.id, + admin_notes: Map.get(attrs, :admin_notes) || Map.get(attrs, "admin_notes") + } + + case Repo.transaction(fn -> + updated_request = + case request + |> AccountDeletionRequest.review_changeset(review_attrs) + |> Repo.update() do + {:ok, updated_request} -> + updated_request + + {:error, changeset} -> + Repo.rollback(changeset) + end + + if status == "approved" do + user = Repo.get!(User, request.user_id) + + case admin_delete_user(user) do + {:ok, _user} -> updated_request + {:error, error} -> Repo.rollback(error) + end + else + updated_request + end + end) do + {:ok, updated_request} -> + {:ok, updated_request} + + {:error, %Ecto.Changeset{} = changeset} -> + {:error, changeset} + + {:error, _error} -> + {:error, "Failed to delete user account"} + end + end + + defp maybe_record_suspension({:ok, %User{} = updated_user}, %User{} = original_user) do + if !original_user.suspended && updated_user.suspended do + case TrustLevel.increment_stat(updated_user.id, :suspensions_count) do + {:ok, _stats} -> {:ok, Repo.get!(User, updated_user.id)} + error -> error + end + else + maybe_reconcile_trust_level({:ok, updated_user}) + end + end + + defp maybe_record_suspension(error, _original_user), do: error + + defp maybe_reconcile_trust_level({:ok, %User{} = updated_user}) do + TrustLevel.maybe_auto_promote_user(updated_user.id) + end + + defp maybe_reconcile_trust_level(error), do: error + + # Updates the user's mailbox email address to match their new username. + # This prevents duplicate mailboxes when usernames change. + defp update_mailbox_email_for_username_change(user) do + if email_module_compiled?() do + case Elektrine.Email.get_user_mailbox(user.id) do + nil -> + :ok + + mailbox -> + # Calculate the new email address based on current username + domain = Elektrine.Domains.primary_email_domain() + new_email = "#{user.username}@#{domain}" + + # Only process if the email is different + if mailbox.email != new_email do + # Check if the new email conflicts with an existing mailbox + case Elektrine.Email.get_mailbox_by_email(new_email) do + nil -> + # New email is available - update the mailbox in place. + case Elektrine.Email.transition_mailbox_for_username_change( + user, + mailbox, + new_email + ) do + {:ok, _new_mailbox} -> + :ok + + {:error, reason} -> + Logger.error( + "Failed to transition mailbox for user #{user.id}: #{inspect(reason)}" + ) + + :ok + end + + _existing_mailbox -> + Logger.error( + "Cannot create #{new_email} for user #{user.id} - email already taken" + ) + + :ok + end + else + :ok + end + end + else + :ok + end + end + + defp maybe_create_user_mailbox(user) do + if email_module_compiled?() do + Elektrine.Email.create_mailbox(user) + else + :ok + end + end + + defp maybe_delete_email_data(user) do + if email_module_compiled?() do + from(m in Elektrine.Email.Message, + join: mb in Elektrine.Email.Mailbox, + on: m.mailbox_id == mb.id, + where: mb.user_id == ^user.id + ) + |> Repo.delete_all() + + from(mb in Elektrine.Email.Mailbox, where: mb.user_id == ^user.id) + |> Repo.delete_all() + + from(a in Elektrine.Email.Alias, where: a.user_id == ^user.id) + |> Repo.delete_all() + else + :ok + end + end + + defp email_module_compiled? do + Modules.compiled?(:email) + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/mute_expire_worker.ex b/apps/elektrine/lib/elektrine/accounts/mute_expire_worker.ex new file mode 100644 index 0000000..ccac2cf --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/mute_expire_worker.ex @@ -0,0 +1,36 @@ +defmodule Elektrine.Accounts.MuteExpireWorker do + @moduledoc """ + Removes temporary user mutes when their expiration time arrives. + """ + + use Oban.Worker, + queue: :default, + max_attempts: 3, + unique: [period: 3600, fields: [:args], keys: [:muter_id, :muted_id]] + + alias Elektrine.Accounts + alias Elektrine.Accounts.UserMute + alias Elektrine.Repo + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"muter_id" => muter_id, "muted_id" => muted_id}}) do + case Repo.get_by(UserMute, muter_id: muter_id, muted_id: muted_id) do + %UserMute{expires_at: %DateTime{} = expires_at} -> + if DateTime.compare(expires_at, Elektrine.Time.utc_now()) in [:lt, :eq] do + _ = Accounts.unmute_user(muter_id, muted_id) + :ok + else + :ok + end + + _ -> + :ok + end + end + + def enqueue(%{muter_id: muter_id, muted_id: muted_id}, %DateTime{} = scheduled_at) do + %{"muter_id" => muter_id, "muted_id" => muted_id} + |> new(scheduled_at: scheduled_at) + |> Elektrine.JobQueue.insert() + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/muting.ex b/apps/elektrine/lib/elektrine/accounts/muting.ex new file mode 100644 index 0000000..9dbd914 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/muting.ex @@ -0,0 +1,159 @@ +defmodule Elektrine.Accounts.Muting do + @moduledoc """ + User muting functionality. + Handles muting, unmuting, and checking muted relationships between users. + """ + + import Ecto.Query, warn: false + alias Elektrine.Accounts.{User, UserMute} + alias Elektrine.Repo + + @doc """ + Mutes a user. If a mute already exists, updates notification muting preference. + + Pass a DateTime or a positive seconds integer as `expires_at` for temporary mutes. + """ + def mute_user(muter_id, muted_id, mute_notifications \\ false, expires_at \\ nil) do + expires_at = normalize_expires_at(expires_at) + + result = + case Repo.get_by(UserMute, muter_id: muter_id, muted_id: muted_id) do + nil -> + %UserMute{} + |> UserMute.changeset(%{ + muter_id: muter_id, + muted_id: muted_id, + mute_notifications: mute_notifications, + expires_at: expires_at + }) + |> Repo.insert() + + mute -> + mute + |> UserMute.changeset(%{ + muter_id: muter_id, + muted_id: muted_id, + mute_notifications: mute_notifications, + expires_at: expires_at + }) + |> Repo.update() + end + + maybe_schedule_mute_expiration(result) + notify_home_feed_policy_changed(muter_id, :muted_user) + result + end + + @doc """ + Unmutes a user. + """ + def unmute_user(muter_id, muted_id) do + result = + case Repo.get_by(UserMute, muter_id: muter_id, muted_id: muted_id) do + nil -> {:error, :not_muted} + mute -> Repo.delete(mute) + end + + notify_home_feed_policy_changed(muter_id, :unmuted_user) + result + end + + @doc """ + Checks if a user is muted by another user. + """ + def user_muted?(muter_id, muted_id) do + expire_due_mute(muter_id, muted_id) + + Repo.exists?( + from(um in UserMute, + where: um.muter_id == ^muter_id and um.muted_id == ^muted_id, + where: is_nil(um.expires_at) or um.expires_at > ^Elektrine.Time.utc_now() + ) + ) + end + + @doc """ + Checks if notifications are muted for a muted user. + """ + def user_muting_notifications?(muter_id, muted_id) do + expire_due_mute(muter_id, muted_id) + + Repo.exists?( + from(um in UserMute, + where: + um.muter_id == ^muter_id and um.muted_id == ^muted_id and + um.mute_notifications == true, + where: is_nil(um.expires_at) or um.expires_at > ^Elektrine.Time.utc_now() + ) + ) + end + + @doc """ + Gets all users muted by a user. + """ + def list_muted_users(muter_id) do + from(u in User, + join: um in UserMute, + on: um.muted_id == u.id, + where: um.muter_id == ^muter_id, + where: is_nil(um.expires_at) or um.expires_at > ^Elektrine.Time.utc_now(), + order_by: [desc: um.inserted_at], + preload: [:profile] + ) + |> Repo.all() + end + + @doc """ + Removes all due expired mutes. + """ + def expire_due_mutes(now \\ Elektrine.Time.utc_now()) do + {count, _} = + from(um in UserMute, + where: not is_nil(um.expires_at) and um.expires_at <= ^now + ) + |> Repo.delete_all() + + count + end + + defp expire_due_mute(muter_id, muted_id) do + from(um in UserMute, + where: um.muter_id == ^muter_id and um.muted_id == ^muted_id, + where: not is_nil(um.expires_at) and um.expires_at <= ^Elektrine.Time.utc_now() + ) + |> Repo.delete_all() + + :ok + end + + defp maybe_schedule_mute_expiration( + {:ok, %UserMute{expires_at: %DateTime{} = expires_at} = mute} + ) do + _ = Elektrine.Accounts.MuteExpireWorker.enqueue(mute, expires_at) + :ok + end + + defp maybe_schedule_mute_expiration(_result), do: :ok + + defp normalize_expires_at(%DateTime{} = datetime), do: DateTime.truncate(datetime, :second) + + defp normalize_expires_at(seconds) when is_integer(seconds) and seconds > 0 do + DateTime.utc_now() + |> DateTime.add(seconds, :second) + |> DateTime.truncate(:second) + end + + defp normalize_expires_at(_), do: nil + + defp notify_home_feed_policy_changed(user_id, reason) when is_integer(user_id) do + module = Module.concat([Elektrine, Social, HomeFeedInvalidationWorker]) + + if Code.ensure_loaded?(module) do + _ = module.clear_user(user_id, reason) + end + + :ok + rescue + _ -> :ok + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/passkey_credential.ex b/apps/elektrine/lib/elektrine/accounts/passkey_credential.ex new file mode 100644 index 0000000..46a0983 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/passkey_credential.ex @@ -0,0 +1,82 @@ +defmodule Elektrine.Accounts.PasskeyCredential do + @moduledoc """ + Schema for WebAuthn/Passkey credentials. + + Users can register up to 10 passkeys across multiple devices (phone, laptop, security key). + Passkeys provide passwordless, phishing-resistant authentication. + """ + use Ecto.Schema + import Ecto.Changeset + + @max_passkeys_per_user 10 + + schema "passkey_credentials" do + belongs_to :user, Elektrine.Accounts.User + + # WebAuthn credential ID (raw bytes from authenticator) + field :credential_id, :binary + # Public key in COSE format (stored as binary via :erlang.term_to_binary) + field :public_key, :binary + # Counter for clone detection - increments with each use + field :sign_count, :integer, default: 0 + # Stable random bytes for resident/discoverable credentials + field :user_handle, :binary + # User-friendly name (e.g., "MacBook", "iPhone", "YubiKey") + field :name, :string, default: "Passkey" + # Authenticator type identifier (AAGUID from attestation) + field :aaguid, :binary + # Transport hints: ["usb", "nfc", "ble", "internal", "hybrid"] + field :transports, {:array, :string}, default: [] + field :last_used_at, :utc_datetime + # Audit information + field :created_from_ip, :string + field :created_user_agent, :string + + timestamps() + end + + @doc "Maximum number of passkeys allowed per user" + def max_passkeys_per_user, do: @max_passkeys_per_user + + @doc "Generate a cryptographically random user handle (32 bytes)" + def generate_user_handle do + :crypto.strong_rand_bytes(32) + end + + @doc "Changeset for creating a new passkey credential" + def create_changeset(credential, attrs) do + credential + |> cast(attrs, [ + :user_id, + :credential_id, + :public_key, + :sign_count, + :user_handle, + :name, + :aaguid, + :transports, + :created_from_ip, + :created_user_agent + ]) + |> validate_required([:user_id, :credential_id, :public_key, :user_handle]) + |> validate_length(:name, max: 100) + |> unique_constraint(:credential_id) + |> foreign_key_constraint(:user_id) + end + + @doc "Changeset for updating sign count after authentication" + def update_sign_count_changeset(credential, sign_count) do + credential + |> change() + |> put_change(:sign_count, sign_count) + |> put_change(:last_used_at, DateTime.utc_now() |> DateTime.truncate(:second)) + end + + @doc "Changeset for renaming a passkey" + def rename_changeset(credential, name) do + credential + |> change() + |> put_change(:name, name) + |> validate_length(:name, max: 100) + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/passkeys.ex b/apps/elektrine/lib/elektrine/accounts/passkeys.ex new file mode 100644 index 0000000..835bf3d --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/passkeys.ex @@ -0,0 +1,409 @@ +defmodule Elektrine.Accounts.Passkeys do + @moduledoc ~s|Context module for WebAuthn/Passkey management.\n\nProvides functions for:\n- Registering new passkeys\n- Authenticating with passkeys\n- Managing user's passkeys (list, rename, delete)\n| + import Ecto.Query, warn: false + alias Elektrine.Accounts.{PasskeyCredential, User} + alias Elektrine.AppCache + alias Elektrine.OAuth + alias Elektrine.Repo + require Logger + @challenge_timeout 300_000 + @doc ~s|Generate a registration challenge for adding a new passkey.\n\nReturns {:ok, challenge_data} or {:error, reason}\n\nThe challenge_data contains all information needed by the browser's\nWebAuthn API to create a new credential.\n\nOptions:\n - :host - The request host (e.g., \"example.com\") for multi-domain support\n| + def generate_registration_challenge(user, opts \\ []) do + passkey_count = count_user_passkeys(user) + + if passkey_count >= PasskeyCredential.max_passkeys_per_user() do + {:error, :passkey_limit_reached} + else + user_handle = get_or_generate_user_handle(user) + exclude_credentials = list_credential_ids_for_user(user) + rp_id = get_rp_id(opts[:host]) + origin = get_origin(opts[:host]) + + challenge_opts = [ + origin: origin, + rp_id: rp_id, + attestation: "none", + user_verification: "required", + timeout: div(@challenge_timeout, 1000) + ] + + challenge = Wax.new_registration_challenge(challenge_opts) + + {:ok, + %{ + challenge: challenge, + challenge_b64: Base.url_encode64(challenge.bytes, padding: false), + rp_id: rp_id, + rp_name: "Elektrine", + user_id: Base.url_encode64(user_handle, padding: false), + user_name: user.username, + user_display_name: user.display_name || user.username, + timeout: @challenge_timeout, + attestation: "none", + authenticator_selection: %{resident_key: "preferred", user_verification: "required"}, + exclude_credentials: + Enum.map(exclude_credentials, fn cred_id -> + %{type: "public-key", id: Base.url_encode64(cred_id, padding: false)} + end), + pub_key_cred_params: [%{type: "public-key", alg: -7}, %{type: "public-key", alg: -257}] + }} + end + end + + @doc ~s|Complete passkey registration by verifying the attestation.\n\nThe attestation_response is expected to be a map with the following structure:\n- \"response\" => %{\n \"clientDataJSON\" => base64url encoded JSON string\n \"attestationObject\" => base64url encoded CBOR binary\n \"transports\" => optional list of transports\n }\n\nReturns {:ok, credential} or {:error, reason}\n| + def complete_registration(user, challenge, attestation_response, metadata \\ %{}) do + response = attestation_response["response"] || %{} + + with {:ok, client_data_json_raw} <- decode_base64url(response["clientDataJSON"]), + {:ok, attestation_object_cbor} <- decode_base64url(response["attestationObject"]), + {:ok, {authenticator_data, _attestation_result}} <- + Wax.register(attestation_object_cbor, client_data_json_raw, challenge) do + credential_id = authenticator_data.attested_credential_data.credential_id + public_key = authenticator_data.attested_credential_data.credential_public_key + sign_count = authenticator_data.sign_count + aaguid = authenticator_data.attested_credential_data.aaguid + user_handle = get_or_generate_user_handle(user) + transports = response["transports"] || [] + + attrs = %{ + user_id: user.id, + credential_id: credential_id, + public_key: :erlang.term_to_binary(public_key), + sign_count: sign_count, + user_handle: user_handle, + aaguid: aaguid, + transports: transports, + name: metadata[:name] || generate_passkey_name(user), + created_from_ip: metadata[:ip], + created_user_agent: metadata[:user_agent] + } + + case %PasskeyCredential{} |> PasskeyCredential.create_changeset(attrs) |> Repo.insert() do + {:ok, credential} -> + Elektrine.Accounts.invalidate_auth_sessions(user) + OAuth.delete_user_tokens(user) + Elektrine.Accounts.Authentication.revoke_all_app_passwords(user.id) + Elektrine.Developer.revoke_all_api_tokens(user.id) + ElektrineWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) + {:ok, credential} + + error -> + error + end + else + {:error, reason} -> {:error, reason} + end + end + + @doc ~s|Generate an authentication challenge for passkey login.\n\nFor discoverable credentials (passkey login without username):\n- Pass nil for user\n- Browser will prompt user to select a passkey\n\nFor non-discoverable credentials (after username entry):\n- Pass the user struct\n- Challenge will include allowed credentials for that user\n\nOptions:\n - :host - The request host (e.g., \"example.com\") for multi-domain support\n| + def generate_authentication_challenge(user \\ nil, opts \\ []) do + rp_id = get_rp_id(opts[:host]) + origin = get_origin(opts[:host]) + + allowed_credentials = + case user do + nil -> + [] + + %User{} = user -> + user + |> list_user_passkeys() + |> Enum.reduce([], fn cred, acc -> + case decode_public_key(cred.public_key) do + {:ok, public_key} -> + [{cred.credential_id, public_key} | acc] + + {:error, reason} -> + Logger.warning( + "Skipping invalid passkey key for credential #{inspect(cred.id)}: #{inspect(reason)}" + ) + + acc + end + end) + |> Enum.reverse() + end + + challenge_opts = [ + origin: origin, + rp_id: rp_id, + user_verification: "required", + timeout: div(@challenge_timeout, 1000), + allow_credentials: allowed_credentials + ] + + challenge = Wax.new_authentication_challenge(challenge_opts) + + allow_credentials_for_client = + case user do + nil -> + [] + + %User{} = u -> + u + |> list_user_passkeys() + |> Enum.map(fn cred -> + %{ + type: "public-key", + id: Base.url_encode64(cred.credential_id, padding: false), + transports: cred.transports + } + end) + end + + AppCache.put_passkey_challenge(challenge.bytes, challenge) + + {:ok, + %{ + challenge: challenge, + challenge_b64: Base.url_encode64(challenge.bytes, padding: false), + rp_id: rp_id, + timeout: @challenge_timeout, + user_verification: "required", + allow_credentials: allow_credentials_for_client + }} + end + + @doc ~s|Retrieve a stored challenge from cache.\nUsed by the controller to get the full challenge struct for verification.\n| + def get_challenge(challenge_bytes) when is_binary(challenge_bytes) do + AppCache.get_passkey_challenge(challenge_bytes) + end + + @doc ~s|Verify passkey authentication assertion.\n\nThe assertion_response is expected to be a map with:\n- \"id\" or \"rawId\" => base64url encoded credential ID\n- \"response\" => %{\n \"clientDataJSON\" => base64url encoded JSON string\n \"authenticatorData\" => base64url encoded binary\n \"signature\" => base64url encoded binary\n }\n\nReturns {:ok, user} or {:error, reason}\n| + def verify_authentication(challenge, assertion_response) do + case verify_authentication_with_credential(challenge, assertion_response) do + {:ok, user, _credential} -> {:ok, user} + {:error, reason} -> {:error, reason} + end + end + + @doc ~s|Verify passkey authentication assertion and return both user and credential.\n\nReturns {:ok, user, credential} or {:error, reason}\n| + def verify_authentication_with_credential(challenge, assertion_response) do + credential_id_b64 = assertion_response["id"] || assertion_response["rawId"] + response = assertion_response["response"] || %{} + + with {:ok, credential_id} <- decode_base64url(credential_id_b64), + {:ok, client_data_json_raw} <- decode_base64url(response["clientDataJSON"]), + {:ok, authenticator_data_bin} <- decode_base64url(response["authenticatorData"]), + {:ok, signature} <- decode_base64url(response["signature"]) do + case get_credential_by_id(credential_id) do + nil -> + {:error, :credential_not_found} + + credential -> + case decode_public_key(credential.public_key) do + {:ok, public_key} -> + credentials = [{credential_id, public_key}] + + case Wax.authenticate( + credential_id, + authenticator_data_bin, + signature, + client_data_json_raw, + challenge, + credentials + ) do + {:ok, authenticator_data} -> + new_sign_count = authenticator_data.sign_count + + if new_sign_count > 0 and new_sign_count <= credential.sign_count do + Logger.warning( + "Passkey clone detection: credential #{inspect(credential.id)} " <> + "sign_count did not increase (stored: #{credential.sign_count}, received: #{new_sign_count})" + ) + + {:error, :cloned_authenticator} + else + credential + |> PasskeyCredential.update_sign_count_changeset(new_sign_count) + |> Repo.update() + + user = Repo.get!(User, credential.user_id) + + case Elektrine.Accounts.Authentication.ensure_user_active(user) do + :ok -> {:ok, user, credential} + {:error, reason} -> {:error, reason} + end + end + + {:error, reason} -> + {:error, reason} + end + + {:error, reason} -> + Logger.warning( + "Invalid passkey key for credential #{inspect(credential.id)}: #{inspect(reason)}" + ) + + {:error, :invalid_credential_key} + end + end + else + {:error, reason} -> {:error, reason} + end + end + + @doc ~s|List all passkeys for a user| + def list_user_passkeys(%User{id: user_id}) do + PasskeyCredential + |> where([p], p.user_id == ^user_id) + |> order_by([p], desc: p.inserted_at) + |> Repo.all() + end + + @doc ~s|Count passkeys for a user| + def count_user_passkeys(%User{id: user_id}) do + PasskeyCredential |> where([p], p.user_id == ^user_id) |> Repo.aggregate(:count, :id) + end + + @doc ~s|Check if user has any passkeys registered| + def has_passkeys?(%User{} = user) do + count_user_passkeys(user) > 0 + end + + @doc ~s|Get a specific passkey by ID, scoped to user| + def get_user_passkey(%User{id: user_id}, passkey_id) do + PasskeyCredential |> where([p], p.id == ^passkey_id and p.user_id == ^user_id) |> Repo.one() + end + + @doc ~s|Delete a passkey| + def delete_passkey(%User{} = user, passkey_id) do + case get_user_passkey(user, passkey_id) do + nil -> + {:error, :not_found} + + credential -> + case Repo.delete(credential) do + {:ok, deleted} -> + Elektrine.Accounts.invalidate_auth_sessions(user) + OAuth.delete_user_tokens(user) + Elektrine.Accounts.Authentication.revoke_all_app_passwords(user.id) + Elektrine.Developer.revoke_all_api_tokens(user.id) + ElektrineWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) + {:ok, deleted} + + error -> + error + end + end + end + + @doc ~s|Rename a passkey| + def rename_passkey(%User{} = user, passkey_id, new_name) do + case get_user_passkey(user, passkey_id) do + nil -> {:error, :not_found} + credential -> credential |> PasskeyCredential.rename_changeset(new_name) |> Repo.update() + end + end + + defp get_credential_by_id(credential_id) do + PasskeyCredential |> where([p], p.credential_id == ^credential_id) |> Repo.one() + end + + defp list_credential_ids_for_user(%User{id: user_id}) do + PasskeyCredential + |> where([p], p.user_id == ^user_id) + |> select([p], p.credential_id) + |> Repo.all() + end + + defp get_or_generate_user_handle(%User{id: user_id}) do + existing = + PasskeyCredential + |> where([p], p.user_id == ^user_id) + |> select([p], p.user_handle) + |> limit(1) + |> Repo.one() + + case existing do + nil -> PasskeyCredential.generate_user_handle() + handle -> handle + end + end + + defp generate_passkey_name(%User{} = user) do + count = count_user_passkeys(user) + 1 + "Passkey #{count}" + end + + defp get_rp_id(nil) do + Application.get_env(:elektrine, :passkey_rp_id, "localhost") + end + + defp get_rp_id(host) when is_binary(host) do + rp_id = extract_registrable_domain(host) + + if rp_id in Elektrine.Domains.local_passkey_domains() do + rp_id + else + Application.get_env(:elektrine, :passkey_rp_id, "localhost") + end + end + + defp get_origin(nil) do + Application.get_env(:elektrine, :passkey_origin, "http://localhost:4000") + end + + defp get_origin(host) when is_binary(host) do + rp_id = get_rp_id(host) + origin_host = normalize_host(host) + + cond do + rp_id == "localhost" -> + "http://localhost:4000" + + valid_origin_host?(origin_host, rp_id) -> + "https://#{origin_host}" + + true -> + Application.get_env(:elektrine, :passkey_origin, "http://localhost:4000") + end + end + + defp valid_origin_host?(host, rp_id) do + host == rp_id or String.ends_with?(host, ".#{rp_id}") + end + + defp normalize_host(host) when is_binary(host) do + host + |> String.downcase() + |> String.split(":", parts: 2) + |> List.first() + end + + defp extract_registrable_domain(host) do + host + |> String.downcase() + |> String.split(".") + |> case do + [single] -> single + [_, _] = parts -> Enum.join(parts, ".") + parts when length(parts) >= 3 -> parts |> Enum.take(-2) |> Enum.join(".") + end + end + + defp decode_public_key(data) when is_binary(data) do + {:ok, :erlang.binary_to_term(data, [:safe])} + rescue + ArgumentError -> {:error, :invalid_binary} + end + + defp decode_public_key(_) do + {:error, :invalid_key} + end + + defp decode_base64url(nil) do + {:error, :missing_data} + end + + defp decode_base64url(data) when is_binary(data) do + case Base.url_decode64(data, padding: false) do + {:ok, decoded} -> {:ok, decoded} + :error -> {:error, :invalid_base64} + end + end + + defp decode_base64url(_) do + {:error, :invalid_data} + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/recovery_email_verification.ex b/apps/elektrine/lib/elektrine/accounts/recovery_email_verification.ex new file mode 100644 index 0000000..39bb017 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/recovery_email_verification.ex @@ -0,0 +1,511 @@ +defmodule Elektrine.Accounts.RecoveryEmailVerification do + @moduledoc """ + Handles recovery email verification. + + Recovery emails must be verified before they can be used for password resets. + This also handles lifting email restrictions for users who hit rate limits. + """ + + import Ecto.Query + import Swoosh.Email, except: [from: 2] + alias Elektrine.Accounts.User + alias Elektrine.EmailAddresses + alias Elektrine.Repo + alias Elektrine.Theme + require Logger + + @token_validity_hours 24 + + @doc """ + Sends a verification email to the user's recovery email address. + Works for both new recovery emails and restricted accounts. + Returns {:ok, user} or {:error, reason}. + """ + def send_verification_email(user_id, opts \\ []) do + # Option to force resend even if already verified + force_resend = Keyword.get(opts, :force, false) + + case Repo.get(User, user_id) do + nil -> + {:error, :user_not_found} + + user -> + cond do + is_nil(user.recovery_email) || user.recovery_email == "" -> + {:error, :no_recovery_email} + + user.recovery_email_verified && !force_resend && !user.email_sending_restricted -> + {:error, :already_verified} + + true -> + do_send_verification_email(user) + end + end + end + + defp do_send_verification_email(user) do + token = generate_token() + previous_verification_state = verification_state(user) + + case update_verification_token(user, token) do + {:ok, updated_user} -> + case send_email(updated_user, token, user.email_sending_restricted) do + {:ok, _result} -> + {:ok, updated_user} + + {:error, reason} -> + restore_verification_state(updated_user, previous_verification_state) + {:error, reason} + end + + {:error, _} = error -> + error + end + end + + defp generate_token do + :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + end + + defp update_verification_token(user, token) do + user + |> Ecto.Changeset.change(%{ + recovery_email_verification_token: User.hash_sensitive_token(token), + recovery_email_verification_sent_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.update() + end + + defp send_email(user, token, is_restricted) do + verify_url = base_url() <> "/verify-recovery-email?token=#{token}" + + app_name = Application.get_env(:elektrine, :app_name, "Elektrine") + + {subject, text_body, html_body} = + if is_restricted do + build_restriction_email(user, verify_url, app_name) + else + build_verification_email(user, verify_url, app_name) + end + + case new() + |> to(user.recovery_email) + |> Swoosh.Email.from({"Elektrine", EmailAddresses.local("noreply")}) + |> subject(subject) + |> html_body(html_body) + |> text_body(text_body) + |> header("List-Id", EmailAddresses.list_id("elektrine-account")) + |> Elektrine.Mailer.deliver_later() do + {:ok, result} -> + Logger.info( + "Queued recovery email verification to #{user.recovery_email} for user #{user.id}" + ) + + {:ok, result} + + {:error, reason} -> + Logger.error( + "Failed to send recovery email verification to #{user.recovery_email}: #{inspect(reason)}" + ) + + {:error, :email_failed} + end + end + + defp verification_state(%User{} = user) do + %{ + recovery_email_verification_token: user.recovery_email_verification_token, + recovery_email_verification_sent_at: user.recovery_email_verification_sent_at + } + end + + defp restore_verification_state(%User{} = user, previous_state) do + case user |> Ecto.Changeset.change(previous_state) |> Repo.update() do + {:ok, _restored_user} -> + :ok + + {:error, restore_reason} -> + Logger.error("Failed to restore recovery verification state: #{inspect(restore_reason)}") + :error + end + end + + defp base_url do + if Code.ensure_loaded?(ElektrineWeb.Endpoint) do + ElektrineWeb.Endpoint.url() + else + "https://#{Elektrine.Domains.instance_domain()}" + end + end + + defp build_verification_email(user, verify_url, app_name) do + subject = "Verify your recovery email address" + palette = Theme.email_palette(user) + + text_body = """ + Hi #{user.username}, + + Please verify your recovery email address by clicking the link below: + + #{verify_url} + + This link will expire in #{@token_validity_hours} hours. + + Once verified, this email can be used for password resets and account recovery. + + If you did not add this recovery email, you can safely ignore this message. + + - The #{app_name} Team + """ + + html_body = """ + + + + + + + + + + + + + +
    + + + + +
    + + + + + +
    +

    Verify Your Recovery Email

    +
    + + + + + + +
    +

    + Hi #{user.username}, +

    +

    + Please verify your recovery email address by clicking the button below: +

    + + + + + + +
    + + Verify Email Address + +
    + +

    + This link will expire in #{@token_validity_hours} hours. +

    +

    + Once verified, this email can be used for password resets and account recovery. +

    +

    + If you did not add this recovery email, you can safely ignore this message. +

    +
    + + + + + + +
    +

    + - The #{app_name} Team +

    +
    +
    +
    + + + """ + + {subject, text_body, html_body} + end + + defp build_restriction_email(user, verify_url, app_name) do + subject = "Verify your recovery email to restore email sending" + palette = Theme.email_palette(user, :warning) + + text_body = """ + Hi #{user.username}, + + Your email sending privileges have been temporarily restricted due to rate limit violations. + + To restore your ability to send emails, please verify your recovery email by clicking the link below: + + #{verify_url} + + This link will expire in #{@token_validity_hours} hours. + + If you did not request this or believe this is an error, please contact support. + + - The #{app_name} Team + """ + + html_body = """ + + + + + + + + + + + + + +
    + + + + +
    + + + + + +
    +

    Restore Email Sending

    +
    + + + + + + +
    + + + + +
    +

    + Your email sending privileges have been temporarily restricted due to rate limit violations. +

    +
    +
    + + + + + + +
    +

    + Hi #{user.username}, +

    +

    + To restore your ability to send emails, please verify your recovery email by clicking the button below: +

    + + + + + + +
    + + Verify Recovery Email + +
    + +

    + This link will expire in #{@token_validity_hours} hours. +

    +

    + If you did not request this or believe this is an error, please contact support. +

    +
    + + + + + + +
    +

    + - The #{app_name} Team +

    +
    +
    +
    + + + """ + + {subject, text_body, html_body} + end + + @doc """ + Verifies the recovery email token. + If the user was restricted, also lifts the restriction. + Returns {:ok, user} or {:error, reason}. + """ + def verify_token(token) when is_binary(token) and byte_size(token) > 0 do + case find_user_by_token(token) do + nil -> + {:error, :invalid_token} + + user -> + if token_expired?(user) do + {:error, :token_expired} + else + do_verify(user) + end + end + end + + def verify_token(_), do: {:error, :invalid_token} + + @doc """ + Finds a user by their recovery email verification token. + Returns the user or nil if not found. + """ + def get_user_by_token(token) when is_binary(token) and byte_size(token) > 0 do + find_user_by_token(token) + end + + def get_user_by_token(_), do: nil + + defp find_user_by_token(token) do + hashed_token = User.hash_sensitive_token(token) + + from(u in User, where: u.recovery_email_verification_token == ^hashed_token) + |> Repo.one() + end + + defp token_expired?(user) do + case user.recovery_email_verification_sent_at do + nil -> + true + + sent_at -> + expiry = DateTime.add(sent_at, @token_validity_hours * 3600, :second) + DateTime.compare(DateTime.utc_now(), expiry) == :gt + end + end + + defp do_verify(user) do + # Always set recovery_email_verified to true + # If user was restricted, also lift the restriction + changes = %{ + recovery_email_verified: true, + recovery_email_verification_token: nil, + recovery_email_verification_sent_at: nil + } + + # Add restriction lifting if user was restricted + changes = + if user.email_sending_restricted do + Map.merge(changes, %{ + email_sending_restricted: false, + email_rate_limit_violations: 0, + email_restriction_reason: nil, + email_restricted_at: nil + }) + else + changes + end + + user + |> Ecto.Changeset.change(changes) + |> Repo.update() + |> case do + {:ok, updated_user} -> + if user.email_sending_restricted do + Logger.info("User #{user.id} verified recovery email and restriction lifted") + else + Logger.info("User #{user.id} verified recovery email") + end + + {:ok, updated_user} + + error -> + error + end + end + + @doc """ + Checks if a user's recovery email is verified. + """ + def verified?(user_id) do + case Repo.get(User, user_id) do + nil -> false + user -> user.recovery_email_verified == true + end + end + + @doc """ + Checks if a user needs to verify their recovery email. + Returns true if they have a recovery email but it's not verified. + """ + def needs_verification?(user_id) do + case Repo.get(User, user_id) do + nil -> + false + + user -> + has_recovery_email = !is_nil(user.recovery_email) && user.recovery_email != "" + has_recovery_email && !user.recovery_email_verified + end + end + + @doc """ + Sets the recovery email for a user. + Automatically marks it as unverified and sends verification email. + """ + def set_recovery_email(user_id, email) do + case Repo.get(User, user_id) do + nil -> + {:error, :user_not_found} + + user -> + # Check if email is actually changing + if user.recovery_email == email && user.recovery_email_verified do + {:ok, user} + else + result = + user + |> User.recovery_email_changeset(%{recovery_email: email}) + |> Ecto.Changeset.change(%{ + recovery_email_verified: false, + recovery_email_verification_token: nil, + recovery_email_verification_sent_at: nil + }) + |> Repo.update() + + # If successful and email is not empty, send verification + case result do + {:ok, updated_user} when email != nil and email != "" -> + send_verification_email(updated_user.id) + {:ok, updated_user} + + other -> + other + end + end + end + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/storage.ex b/apps/elektrine/lib/elektrine/accounts/storage.ex new file mode 100644 index 0000000..2f34c29 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/storage.ex @@ -0,0 +1,394 @@ +defmodule Elektrine.Accounts.Storage do + @moduledoc """ + Centralized storage tracking for user uploads across the entire application. + Tracks storage usage from emails, chat attachments, profile images, etc. + """ + + import Ecto.Query + alias Elektrine.Accounts.User + alias Elektrine.Email.Message + alias Elektrine.Platform.Modules + alias Elektrine.Repo + + @doc """ + Calculates total storage used by a user across all sources. + Includes: email messages, chat attachments, profile images, Kairo sources, etc. + """ + def calculate_user_storage(user_id) do + # Email storage (mailbox messages) + email_storage = calculate_email_storage(user_id) + + # Chat message media storage + chat_storage = calculate_chat_storage(user_id) + + # Profile images and backgrounds + profile_storage = calculate_profile_storage(user_id) + + # Static site files + static_site_storage = calculate_static_site_storage(user_id) + + # Personal file library + files_storage = calculate_files_storage(user_id) + + # Kairo ingested sources and project metadata + kairo_storage = calculate_kairo_storage(user_id) + + total = + email_storage + chat_storage + profile_storage + static_site_storage + files_storage + + kairo_storage + + total + end + + @doc """ + Updates the storage_used_bytes for a user. + """ + def update_user_storage(user_id) do + total_bytes = calculate_user_storage(user_id) + + from(u in User, where: u.id == ^user_id) + |> Repo.update_all( + set: [ + storage_used_bytes: total_bytes, + storage_last_calculated_at: DateTime.utc_now() |> DateTime.truncate(:second) + ] + ) + + # Invalidate storage cache + Elektrine.AppCache.invalidate_storage_cache(user_id) + + # Broadcast storage update to LiveViews + Phoenix.PubSub.broadcast!( + Elektrine.PubSub, + "user:#{user_id}", + {:storage_updated, + %{ + storage_used_bytes: total_bytes, + user_id: user_id + }} + ) + + {:ok, total_bytes} + end + + @doc """ + Checks if a user would exceed their storage limit with additional bytes. + """ + def would_exceed_limit?(user_id, additional_bytes) do + user = Repo.get(User, user_id) + + if user do + current_storage = user.storage_used_bytes || 0 + # 500MB default + limit = user.storage_limit_bytes || 524_288_000 + + current_storage + additional_bytes > limit + else + false + end + end + + @doc """ + Gets storage info for a user including usage percentage and formatted sizes. + """ + def get_storage_info(user_id) do + user = Repo.get(User, user_id) + + if user do + used = user.storage_used_bytes || 0 + # 500MB default (matches schema + enforcement paths) + limit = user.storage_limit_bytes || 524_288_000 + + %{ + used_bytes: used, + limit_bytes: limit, + used_formatted: format_bytes(used), + limit_formatted: format_bytes(limit), + percentage: if(limit > 0, do: used / limit, else: 0), + over_limit: used > limit, + available_bytes: max(0, limit - used) + } + else + nil + end + end + + # Public calculation functions for storage management page + + def calculate_email_storage(user_id) do + calculate_email_message_storage(user_id) + calculate_email_attachment_storage(user_id) + end + + def calculate_email_message_storage(user_id) do + if Modules.compiled?(:email) do + # Get user's mailbox + case Elektrine.Email.get_user_mailbox(user_id) do + nil -> + 0 + + mailbox -> + # Sum stored mailbox content separately from attachment blobs so the UI can break them out. + Message + |> where([m], m.mailbox_id == ^mailbox.id) + |> select([m], %{ + text_size: coalesce(fragment("length(?)", m.text_body), 0), + html_size: coalesce(fragment("length(?)", m.html_body), 0), + subject_size: coalesce(fragment("length(?)", m.subject), 0), + from_size: coalesce(fragment("length(?)", m.from), 0), + to_size: coalesce(fragment("length(?)", m.to), 0), + cc_size: coalesce(fragment("length(?)", m.cc), 0), + bcc_size: coalesce(fragment("length(?)", m.bcc), 0) + }) + |> Repo.all() + |> Enum.reduce(0, fn row, acc -> + acc + row.text_size + row.html_size + row.subject_size + + row.from_size + row.to_size + row.cc_size + row.bcc_size + end) + end + else + 0 + end + end + + def calculate_email_attachment_storage(user_id) do + if Modules.compiled?(:email) do + case Elektrine.Email.get_user_mailbox(user_id) do + nil -> + 0 + + mailbox -> + Message + |> where([m], m.mailbox_id == ^mailbox.id) + |> select([m], %{attachments: m.attachments}) + |> Repo.all() + |> Enum.reduce(0, fn row, acc -> + acc + calculate_attachments_size(row.attachments) + end) + end + else + 0 + end + end + + # Calculate total size of attachments from the attachments JSON + defp calculate_attachments_size(nil), do: 0 + + defp calculate_attachments_size(attachments) when is_map(attachments) do + attachments + |> Map.values() + |> Enum.reduce(0, fn attachment, acc -> + # Get size from attachment metadata (works for both S3 and DB storage) + size = Map.get(attachment, "size", 0) + acc + size_to_integer(size) + end) + end + + defp calculate_attachments_size(_), do: 0 + + defp size_to_integer(size) when is_integer(size) and size > 0, do: size + + defp size_to_integer(%Decimal{} = size) do + size + |> Decimal.round(0, :down) + |> Decimal.to_integer() + |> size_to_integer() + end + + defp size_to_integer(size) when is_binary(size) do + case Integer.parse(size) do + {size, ""} when size > 0 -> size + _ -> 0 + end + end + + defp size_to_integer(_), do: 0 + + def calculate_chat_storage(user_id) do + # Get all messages with media from the user (exclude deleted) + messages = + Repo.all( + from(m in Elektrine.Social.Message, + where: + m.sender_id == ^user_id and fragment("cardinality(?) > 0", m.media_urls) and + is_nil(m.deleted_at), + select: %{media_urls: m.media_urls, media_metadata: m.media_metadata} + ) + ) + + Enum.reduce(messages, 0, fn message, acc -> + metadata = message.media_metadata || %{} + + message_size = + message.media_urls + |> Enum.reduce(0, fn url, url_acc -> + case Map.get(metadata, url) do + %{"size" => size} when is_integer(size) -> url_acc + size + %{size: size} when is_integer(size) -> url_acc + size + _ -> url_acc + end + end) + + acc + message_size + end) + end + + def calculate_profile_storage(user_id) do + # Get user avatar size from users table + user_avatar_size = + case Repo.one( + from(u in User, + where: u.id == ^user_id, + select: u.avatar_size + ) + ) do + nil -> 0 + size when is_integer(size) -> size + _ -> 0 + end + + # Get profile image sizes from user_profiles table + profile_sizes = + case Repo.one( + from(p in Elektrine.Profiles.UserProfile, + where: p.user_id == ^user_id, + select: %{ + avatar: p.avatar_size, + banner: p.banner_size, + background: p.background_size + } + ) + ) do + nil -> + 0 + + data -> + avatar_size = if is_integer(data.avatar), do: data.avatar, else: 0 + banner_size = if is_integer(data.banner), do: data.banner, else: 0 + background_size = if is_integer(data.background), do: data.background, else: 0 + avatar_size + banner_size + background_size + end + + user_avatar_size + profile_sizes + end + + def calculate_static_site_storage(user_id) do + Elektrine.StaticSites.total_storage_used(user_id) + end + + def calculate_files_storage(user_id) do + Elektrine.Drive.storage_used(user_id) + end + + def calculate_kairo_storage(user_id) do + if Modules.compiled?(:kairo) do + calculate_kairo_projects_storage(user_id) + calculate_kairo_sources_storage(user_id) + else + 0 + end + end + + defp calculate_kairo_projects_storage(user_id) do + from(project in "kairo_projects", + where: field(project, :user_id) == ^user_id, + select: + fragment( + """ + COALESCE(SUM( + COALESCE(octet_length(?), 0) + + COALESCE(octet_length(?), 0) + + COALESCE(octet_length(?), 0) + + COALESCE(octet_length(?), 0) + ), 0) + """, + field(project, :name), + field(project, :slug), + field(project, :description), + field(project, :status) + ) + ) + |> Repo.one() + |> size_to_integer() + end + + defp calculate_kairo_sources_storage(user_id) do + calculate_kairo_source_rows_storage(user_id) + calculate_kairo_upload_blobs_storage(user_id) + end + + defp calculate_kairo_source_rows_storage(user_id) do + from(source in "kairo_sources", + where: field(source, :user_id) == ^user_id, + select: + fragment( + """ + COALESCE(SUM( + COALESCE(octet_length(?), 0) + + COALESCE(octet_length(?), 0) + + COALESCE(octet_length(?), 0) + + COALESCE(octet_length(?), 0) + + COALESCE(octet_length(?), 0) + + COALESCE(octet_length(?), 0) + + COALESCE(octet_length(?::text), 0) + + COALESCE(octet_length(?::text), 0) + + COALESCE(octet_length(?::text), 0) + + COALESCE(octet_length(?::text), 0) + ), 0) + """, + field(source, :source_type), + field(source, :title), + field(source, :url), + field(source, :content), + field(source, :content_format), + field(source, :raw_hash), + field(source, :tags), + field(source, :metadata), + field(source, :content_encrypted), + field(source, :encrypted_content) + ) + ) + |> Repo.one() + |> size_to_integer() + end + + defp calculate_kairo_upload_blobs_storage(user_id) do + upload_key_prefix = "kairo-sources/#{user_id}/%" + + from(source in "kairo_sources", + where: field(source, :user_id) == ^user_id, + where: + fragment( + "COALESCE(?->>'storage_key', ?->>'key') LIKE ?", + field(source, :metadata), + field(source, :metadata), + ^upload_key_prefix + ), + where: + fragment( + "COALESCE(?->>'size', '') ~ '^[0-9]{1,12}$'", + field(source, :metadata) + ), + group_by: + fragment( + "COALESCE(?->>'storage_key', ?->>'key')", + field(source, :metadata), + field(source, :metadata) + ), + select: fragment("MAX((?->>'size')::bigint)", field(source, :metadata)) + ) + |> Repo.all() + |> Enum.reduce(0, &(size_to_integer(&1) + &2)) + end + + @doc """ + Formats bytes in human readable format (public for mix tasks). + """ + def format_bytes(bytes) when is_integer(bytes) do + cond do + bytes >= 1024 * 1024 * 1024 -> "#{Float.round(bytes / (1024 * 1024 * 1024), 2)} GB" + bytes >= 1024 * 1024 -> "#{Float.round(bytes / (1024 * 1024), 2)} MB" + bytes >= 1024 -> "#{Float.round(bytes / 1024, 2)} KB" + true -> "#{bytes} B" + end + end + + def format_bytes(_), do: "0 B" +end diff --git a/apps/elektrine/lib/elektrine/accounts/subscriptions.ex b/apps/elektrine/lib/elektrine/accounts/subscriptions.ex new file mode 100644 index 0000000..b003474 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/subscriptions.ex @@ -0,0 +1,182 @@ +defmodule Elektrine.Accounts.Subscriptions do + @moduledoc """ + Per-account notification subscriptions. + """ + + import Ecto.Query + + alias Elektrine.Accounts.{AccountSubscription, User} + alias Elektrine.ActivityPub.Actor + alias Elektrine.Notifications + alias Elektrine.Repo + alias Elektrine.Social.Message + + def subscribe_to_account(user_id, %User{id: user_id}), do: {:error, :self_subscribe} + + def subscribe_to_account(user_id, %User{} = account) when is_integer(user_id) do + attrs = %{user_id: user_id, subscribed_user_id: account.id} + + case Repo.get_by(AccountSubscription, attrs) do + %AccountSubscription{} = subscription -> {:ok, subscription} + nil -> insert_subscription(attrs) + end + end + + def subscribe_to_account(user_id, %Actor{} = actor) when is_integer(user_id) do + attrs = %{user_id: user_id, remote_actor_id: actor.id} + + case Repo.get_by(AccountSubscription, attrs) do + %AccountSubscription{} = subscription -> {:ok, subscription} + nil -> insert_subscription(attrs) + end + end + + def subscribe_to_account(_user_id, _account), do: {:error, :invalid_account} + + def unsubscribe_from_account(user_id, %User{} = account) when is_integer(user_id) do + delete_subscription(user_id, subscribed_user_id: account.id) + end + + def unsubscribe_from_account(user_id, %Actor{} = actor) when is_integer(user_id) do + delete_subscription(user_id, remote_actor_id: actor.id) + end + + def unsubscribe_from_account(_user_id, _account), do: {:error, :invalid_account} + + def account_subscribed?(user_id, %User{} = account) when is_integer(user_id) do + subscription_exists?(user_id, subscribed_user_id: account.id) + end + + def account_subscribed?(user_id, %Actor{} = actor) when is_integer(user_id) do + subscription_exists?(user_id, remote_actor_id: actor.id) + end + + def account_subscribed?(_user_id, _account), do: false + + def notify_subscribers_for_message(%Message{} = message) do + message = Repo.preload(message, [:sender, :remote_actor, :conversation]) + + if notifiable_message?(message) do + message + |> subscribed_user_ids() + |> Enum.each(&create_status_notification(&1, message)) + end + + :ok + end + + def notify_subscribers_for_message(_message), do: :ok + + defp insert_subscription(attrs) do + %AccountSubscription{} + |> AccountSubscription.changeset(attrs) + |> Repo.insert() + end + + defp delete_subscription(user_id, clauses) do + case get_subscription(user_id, clauses) do + %AccountSubscription{} = subscription -> Repo.delete(subscription) + nil -> {:ok, :not_subscribed} + end + end + + defp subscription_exists?(user_id, clauses) do + not is_nil(get_subscription(user_id, clauses)) + end + + defp get_subscription(user_id, clauses) do + clauses + |> Keyword.put(:user_id, user_id) + |> then(&Repo.get_by(AccountSubscription, &1)) + end + + defp notifiable_message?( + %Message{deleted_at: nil, is_draft: draft, conversation: conversation} = + message + ) do + draft != true and social_message?(message, conversation) + end + + defp notifiable_message?(_message), do: false + + defp social_message?(%Message{federated: true}, _conversation), do: true + defp social_message?(_message, %{type: type}) when type in ["timeline", "community"], do: true + defp social_message?(_message, _conversation), do: false + + defp subscribed_user_ids(%Message{sender_id: sender_id}) when is_integer(sender_id) do + AccountSubscription + |> where([s], s.subscribed_user_id == ^sender_id) + |> where([s], s.user_id != ^sender_id) + |> select([s], s.user_id) + |> Repo.all() + end + + defp subscribed_user_ids(%Message{remote_actor_id: remote_actor_id}) + when is_integer(remote_actor_id) do + AccountSubscription + |> where([s], s.remote_actor_id == ^remote_actor_id) + |> select([s], s.user_id) + |> Repo.all() + end + + defp subscribed_user_ids(_message), do: [] + + defp create_status_notification(user_id, %Message{} = message) when is_integer(user_id) do + actor = message.sender || message.remote_actor + + attrs = + %{ + user_id: user_id, + type: "status", + title: status_notification_title(actor), + body: status_notification_body(message), + url: Elektrine.Paths.post_path(message), + source_type: "message", + source_id: message.id, + priority: "normal" + } + |> maybe_put_actor_id(message) + |> maybe_put_remote_actor_id(message) + + Notifications.create_notification(attrs) + end + + defp maybe_put_actor_id(attrs, %Message{sender_id: sender_id}) when is_integer(sender_id) do + Map.put(attrs, :actor_id, sender_id) + end + + defp maybe_put_actor_id(attrs, _message), do: attrs + + defp maybe_put_remote_actor_id(attrs, %Message{remote_actor_id: remote_actor_id}) + when is_integer(remote_actor_id) do + Map.put(attrs, :metadata, %{remote_actor_id: remote_actor_id}) + end + + defp maybe_put_remote_actor_id(attrs, _message), do: attrs + + defp status_notification_title(%User{} = user), do: "#{display_name(user)} posted" + defp status_notification_title(%Actor{} = actor), do: "#{display_name(actor)} posted" + defp status_notification_title(_actor), do: "New post" + + defp display_name(%User{} = user), do: user.display_name || user.handle || user.username + + defp display_name(%Actor{} = actor), + do: actor.display_name || actor.username || actor.domain || "Remote user" + + defp status_notification_body(%Message{content_warning: warning}) + when is_binary(warning) and warning != "" do + warning + end + + defp status_notification_body(%Message{title: title}) when is_binary(title) and title != "" do + title + end + + defp status_notification_body(%Message{content: content}) when is_binary(content) do + content + |> String.replace(~r/<[^>]*>/, "") + |> String.slice(0, 140) + end + + defp status_notification_body(_message), do: "Open Elektrine to view the post." +end diff --git a/apps/elektrine/lib/elektrine/accounts/tracking.ex b/apps/elektrine/lib/elektrine/accounts/tracking.ex new file mode 100644 index 0000000..d310a32 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/tracking.ex @@ -0,0 +1,192 @@ +defmodule Elektrine.Accounts.Tracking do + @moduledoc """ + User activity tracking functionality. + Handles tracking of user logins, last seen timestamps, and email protocol access (IMAP/POP3). + """ + + use GenServer + + import Ecto.Query, warn: false + alias Elektrine.Accounts.User + alias Elektrine.DB.WriteGuard + alias Elektrine.Repo + + @last_seen_db_interval_seconds 5 * 60 + @last_seen_local_throttle_ms @last_seen_db_interval_seconds * 1000 + @last_seen_table :last_seen_update_throttle + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(_opts) do + ensure_last_seen_table() + {:ok, %{}} + end + + @doc """ + Update user login information (IP address, login time, login count). + """ + def update_user_login_info(user, ip_address) do + login_count = (user.login_count || 0) + 1 + + user + |> User.login_changeset(%{ + last_login_ip: ip_address, + last_login_at: DateTime.utc_now() |> DateTime.truncate(:second), + login_count: login_count + }) + |> Repo.update() + |> case do + {:ok, updated_user} -> + Elektrine.Accounts.TrustLevel.maybe_auto_promote_user(updated_user.id) + {:ok, updated_user} + + error -> + error + end + end + + @doc """ + Record IMAP access for a user. + """ + def record_imap_access(user_id) do + record_protocol_access(user_id, :last_imap_access, :imap) + end + + @doc """ + Record POP3 access for a user. + """ + def record_pop3_access(user_id) do + record_protocol_access(user_id, :last_pop3_access, :pop3) + end + + @doc """ + Update user's last_seen_at timestamp. + Only updates if the last write attempt was more than 5 minutes ago to reduce database load. + """ + def update_last_seen(user_id) do + now_ms = System.monotonic_time(:millisecond) + + if allow_last_seen_update?(user_id, now_ms) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + cutoff = DateTime.add(now, -@last_seen_db_interval_seconds, :second) + + from(u in User, + where: u.id == ^user_id and (is_nil(u.last_seen_at) or u.last_seen_at < ^cutoff) + ) + |> Repo.update_all(set: [last_seen_at: now]) + else + {0, nil} + end + end + + @doc """ + Update user's last_seen_at timestamp asynchronously. + Delegates to update_last_seen/1 in a background task. + """ + def update_last_seen_async(user_id) do + update_last_seen(user_id) + :ok + end + + @doc """ + Updates user status (online, away, dnd, offline). + """ + def update_user_status(%User{} = user, status, message \\ nil) + when status in ["online", "away", "dnd", "offline"] do + # Sanitize status message - trim and limit length + sanitized_message = + if message && is_binary(message) do + message + |> String.trim() + # Max 100 characters + |> String.slice(0, 100) + |> case do + "" -> nil + msg -> msg + end + else + nil + end + + attrs = %{ + status: status, + status_message: sanitized_message, + status_updated_at: DateTime.utc_now() + } + + user + |> Ecto.Changeset.cast(attrs, [:status, :status_message, :status_updated_at]) + |> Ecto.Changeset.validate_required([:status]) + |> Ecto.Changeset.validate_inclusion(:status, ["online", "away", "dnd", "offline"]) + |> Ecto.Changeset.validate_length(:status_message, max: 100) + |> Repo.update() + end + + @doc """ + Gets user status information. + """ + def get_user_status(user_id) do + case Repo.get(User, user_id) do + nil -> + {:error, :not_found} + + user -> + {:ok, + %{status: user.status, message: user.status_message, updated_at: user.status_updated_at}} + end + end + + defp record_protocol_access(user_id, field, protocol) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + WriteGuard.run("#{protocol} access timestamp update for user_id=#{user_id}", fn -> + from(u in User, where: u.id == ^user_id) + |> Repo.update_all(set: [{field, now}]) + end) + end + + defp allow_last_seen_update?(user_id, now_ms) when is_integer(user_id) do + ensure_last_seen_table() + + try do + case :ets.lookup(@last_seen_table, user_id) do + [{^user_id, last_ms}] when now_ms - last_ms < @last_seen_local_throttle_ms -> + false + + _ -> + :ets.insert(@last_seen_table, {user_id, now_ms}) + true + end + rescue + ArgumentError -> + # If the supervised owner is still starting, recreate and retry once. + ensure_last_seen_table() + + case :ets.lookup(@last_seen_table, user_id) do + [{^user_id, last_ms}] when now_ms - last_ms < @last_seen_local_throttle_ms -> + false + + _ -> + :ets.insert(@last_seen_table, {user_id, now_ms}) + true + end + end + end + + defp ensure_last_seen_table do + case :ets.whereis(@last_seen_table) do + :undefined -> + try do + :ets.new(@last_seen_table, [:named_table, :public, :set, {:write_concurrency, true}]) + rescue + ArgumentError -> @last_seen_table + end + + _ -> + @last_seen_table + end + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/trust_level.ex b/apps/elektrine/lib/elektrine/accounts/trust_level.ex new file mode 100644 index 0000000..a9ebcbd --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/trust_level.ex @@ -0,0 +1,405 @@ +defmodule Elektrine.Accounts.TrustLevel do + @moduledoc """ + Trust level system similar to Discourse. + + Trust Levels: + - TL0 (New): Default for all new users + - TL1 (Basic): Established account with repeat logins and visits + - TL2 (Member): Active participant + - TL3 (Regular): Trusted community member with sustained engagement + - TL4 (Leader): Manually granted, full moderation access + """ + + import Ecto.Query + alias Elektrine.Accounts.{TrustLevelLog, User, UserActivityStats} + alias Elektrine.Repo + require Logger + + @levels %{ + 0 => %{name: "New", color: "gray", description: "Welcome! Start exploring."}, + 1 => %{name: "Basic", color: "blue", description: "You've learned the basics."}, + 2 => %{name: "Member", color: "green", description: "Active community member."}, + 3 => %{ + name: "Regular", + color: "purple", + description: "Trusted regular with moderation powers." + }, + 4 => %{name: "Leader", color: "red", description: "Community leader."} + } + + # Trust levels only depend on signals that are currently tracked in production. + @requirements %{ + 1 => %{ + minimums: %{ + account_age_days: 1, + login_count: 2, + days_visited: 2, + topics_entered: 5, + posts_read: 30, + time_read_seconds: 600 + }, + maximums: %{ + banned: 0, + suspended: 0 + } + }, + 2 => %{ + minimums: %{ + account_age_days: 14, + login_count: 5, + days_visited: 15, + topics_entered: 15, + posts_read: 100, + topics_created: 1, + posts_created: 3, + replies_created: 3, + time_read_seconds: 3600, + likes_given: 3, + likes_received: 1, + replies_received: 1 + }, + maximums: %{ + banned: 0, + suspended: 0, + posts_deleted: 0, + suspensions_count: 0 + } + }, + 3 => %{ + minimums: %{ + account_age_days: 50, + login_count: 10, + days_visited: 50, + topics_entered: 25, + posts_read: 250, + posts_created: 10, + topics_created: 2, + replies_created: 10, + likes_given: 15, + likes_received: 10, + replies_received: 5 + }, + maximums: %{ + banned: 0, + suspended: 0, + flags_received: 3, + posts_deleted: 1, + suspensions_count: 0 + } + } + # TL4 is manual only + } + + @incrementable_stats [ + :posts_created, + :topics_created, + :replies_created, + :likes_given, + :likes_received, + :replies_received, + :posts_read, + :topics_entered, + :time_read_seconds, + :flags_given, + :flags_received, + :flags_agreed, + :posts_deleted, + :suspensions_count + ] + + def levels, do: @levels + def requirements, do: @requirements + + @doc """ + Get trust level name and info. + """ + def get_level_info(level) when level in 0..4 do + @levels[level] + end + + def get_level_info(_), do: @levels[0] + + @doc """ + Check if user qualifies for a specific trust level. + """ + def qualifies_for_level?(user, stats, target_level) when target_level in 1..3 do + %{minimums: minimums, maximums: maximums} = @requirements[target_level] + + Enum.all?(minimums, fn {metric, required_value} -> + metric_value(user, stats, metric) >= required_value + end) and + Enum.all?(maximums, fn {metric, allowed_value} -> + metric_value(user, stats, metric) <= allowed_value + end) + end + + # TL0 is default + def qualifies_for_level?(_, _, 0), do: true + + # TL4 is manual only + def qualifies_for_level?(_, _, 4), do: false + + @doc """ + Calculate the maximum trust level a user qualifies for. + """ + def calculate_trust_level(user, stats) do + if user.trust_level_locked do + user.trust_level + else + cond do + qualifies_for_level?(user, stats, 3) -> 3 + qualifies_for_level?(user, stats, 2) -> 2 + qualifies_for_level?(user, stats, 1) -> 1 + true -> 0 + end + end + end + + @doc """ + Change a user's trust level and persist an audit log. + """ + def change_user_level(user, new_level, opts \\ []) when new_level in 0..4 do + persist_level_change(user, user.trust_level, new_level, opts, persist_user?: true) + end + + @doc """ + Record a trust-level change after the caller has already updated `user.trust_level`. + """ + def record_level_change(user, old_level, opts \\ []) when old_level in 0..4 do + persist_level_change(user, old_level, user.trust_level, opts, persist_user?: false) + end + + @doc """ + Automatically reconcile user trust levels based on their activity stats. + Returns {:ok, changed_count} or {:error, reason}. + """ + def auto_promote_eligible_users do + users_with_stats = + from(u in User, + where: u.trust_level_locked == false, + left_join: s in UserActivityStats, + on: s.user_id == u.id, + preload: [activity_stats: s] + ) + |> Repo.all() + + changed_count = + users_with_stats + |> Enum.reduce(0, fn user, count -> + stats = user.activity_stats || %UserActivityStats{} + new_level = calculate_trust_level(user, stats) + + if new_level != user.trust_level do + case change_user_level(user, new_level, reason: "automatic") do + {:ok, _} -> count + 1 + {:error, _} -> count + end + else + count + end + end) + + Logger.info("Auto-reconciled trust levels for #{changed_count} users") + {:ok, changed_count} + end + + @doc """ + Recalculate a single user's trust level and apply any automatic change immediately. + """ + def maybe_auto_promote_user(user_id) do + case get_user_with_stats(user_id) do + nil -> + {:error, :not_found} + + user -> + stats = user.activity_stats || %UserActivityStats{} + new_level = calculate_trust_level(user, stats) + + if new_level != user.trust_level do + change_user_level(user, new_level, reason: "automatic") + else + {:ok, user} + end + end + end + + @doc """ + Initialize activity stats for a user if they don't exist. + """ + def ensure_activity_stats(user_id) do + case Repo.get_by(UserActivityStats, user_id: user_id) do + nil -> + %UserActivityStats{user_id: user_id} + |> UserActivityStats.changeset(%{}) + |> Repo.insert( + on_conflict: :nothing, + conflict_target: :user_id + ) + |> case do + {:ok, %{id: nil}} -> + {:ok, Repo.get_by!(UserActivityStats, user_id: user_id)} + + result -> + result + end + + stats -> + {:ok, stats} + end + end + + @doc """ + Track a visit (daily). + """ + def track_visit(user_id) do + {:ok, _stats} = ensure_activity_stats(user_id) + today = Date.utc_today() + now = DateTime.utc_now() |> DateTime.truncate(:second) + + {updated_count, _} = + from(s in UserActivityStats, + where: + s.user_id == ^user_id and (is_nil(s.last_visit_date) or s.last_visit_date != ^today) + ) + |> Repo.update_all( + inc: [days_visited: 1], + set: [last_visit_date: today, updated_at: now] + ) + + stats = Repo.get_by!(UserActivityStats, user_id: user_id) + + if updated_count > 0 do + maybe_auto_promote_user(user_id) + end + + {:ok, stats} + end + + @doc """ + Increment a stat for a user. + """ + def increment_stat(user_id, stat_name, amount \\ 1) + + def increment_stat(user_id, stat_name, amount) + when stat_name in @incrementable_stats and is_integer(amount) do + {:ok, _stats} = ensure_activity_stats(user_id) + now = DateTime.utc_now() |> DateTime.truncate(:second) + + from(s in UserActivityStats, where: s.user_id == ^user_id) + |> Repo.update_all(inc: [{stat_name, amount}], set: [updated_at: now]) + + stats = Repo.get_by!(UserActivityStats, user_id: user_id) + maybe_auto_promote_user(user_id) + {:ok, stats} + end + + def increment_stat(_user_id, stat_name, _amount), do: {:error, {:unknown_stat, stat_name}} + + defp persist_level_change(user, old_level, new_level, opts, persist_opts) do + if new_level != old_level do + now = DateTime.utc_now() |> DateTime.truncate(:second) + reason = Keyword.get(opts, :reason, "automatic") + changed_by_user_id = Keyword.get(opts, :changed_by_user_id) + notes = Keyword.get(opts, :notes) + persist_user? = Keyword.get(persist_opts, :persist_user?, true) + + user_changes = + if persist_user? do + %{trust_level: new_level, promoted_at: now} + else + %{promoted_at: now} + end + + user_changeset = User.trust_level_changeset(user, user_changes) + + user_changeset = + if new_level > old_level do + Ecto.Changeset.change(user_changeset, %{ + email_sending_restricted: false, + email_rate_limit_violations: 0, + email_restriction_reason: nil, + email_restricted_at: nil + }) + else + user_changeset + end + + log_changeset = + TrustLevelLog.changeset(%TrustLevelLog{}, %{ + user_id: user.id, + old_level: old_level, + new_level: new_level, + reason: reason, + changed_by_user_id: changed_by_user_id, + notes: notes + }) + + Ecto.Multi.new() + |> Ecto.Multi.update(:user, user_changeset) + |> Ecto.Multi.insert(:log, log_changeset) + |> Repo.transaction() + |> case do + {:ok, %{user: updated_user}} -> + Logger.info( + "User #{user.id} trust level changed from TL#{old_level} to TL#{new_level} (#{reason})" + ) + + if new_level > old_level do + notify_promotion(user.id, old_level, new_level, reason) + end + + {:ok, updated_user} + + {:error, _failed_operation, changeset, _changes} -> + {:error, changeset} + end + else + {:ok, user} + end + end + + defp notify_promotion(user_id, old_level, new_level, reason) do + Elektrine.Async.start(fn -> + level_info = get_level_info(new_level) + + Elektrine.Notifications.create_notification(%{ + user_id: user_id, + type: "trust_level_promoted", + title: "Promoted to #{level_info.name}!", + body: + "Congratulations! You've been promoted to Trust Level #{new_level}: #{level_info.description}", + metadata: %{ + "old_level" => old_level, + "new_level" => new_level, + "reason" => reason + }, + priority: "high" + }) + end) + end + + defp get_user_with_stats(user_id) do + from(u in User, + where: u.id == ^user_id, + left_join: s in UserActivityStats, + on: s.user_id == u.id, + preload: [activity_stats: s] + ) + |> Repo.one() + end + + defp metric_value(user, _stats, :account_age_days) do + case user.inserted_at do + %DateTime{} = inserted_at -> Date.diff(Date.utc_today(), DateTime.to_date(inserted_at)) + _ -> 0 + end + end + + defp metric_value(user, _stats, :login_count), do: user.login_count || 0 + defp metric_value(user, _stats, :banned), do: if(user.banned, do: 1, else: 0) + defp metric_value(user, _stats, :suspended), do: if(user.suspended, do: 1, else: 0) + + defp metric_value(_user, stats, metric) do + Map.get(stats, metric, 0) || 0 + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/trust_level_log.ex b/apps/elektrine/lib/elektrine/accounts/trust_level_log.ex new file mode 100644 index 0000000..f0c449d --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/trust_level_log.ex @@ -0,0 +1,28 @@ +defmodule Elektrine.Accounts.TrustLevelLog do + @moduledoc """ + Logs trust level changes for audit trail. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "trust_level_logs" do + belongs_to :user, Elektrine.Accounts.User + field :old_level, :integer + field :new_level, :integer + # "automatic", "manual", "penalty" + field :reason, :string + belongs_to :changed_by_user, Elektrine.Accounts.User + field :notes, :string + + timestamps(type: :utc_datetime) + end + + def changeset(log, attrs) do + log + |> cast(attrs, [:user_id, :old_level, :new_level, :reason, :changed_by_user_id, :notes]) + |> validate_required([:user_id, :old_level, :new_level, :reason]) + |> validate_inclusion(:reason, ["automatic", "manual", "penalty"]) + |> validate_number(:old_level, greater_than_or_equal_to: 0, less_than_or_equal_to: 4) + |> validate_number(:new_level, greater_than_or_equal_to: 0, less_than_or_equal_to: 4) + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/trusted_device.ex b/apps/elektrine/lib/elektrine/accounts/trusted_device.ex new file mode 100644 index 0000000..3f9e53f --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/trusted_device.ex @@ -0,0 +1,82 @@ +defmodule Elektrine.Accounts.TrustedDevice do + @moduledoc """ + Schema for trusted devices that can skip 2FA verification. + Devices are trusted for 30 days by default. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "trusted_devices" do + field :device_token, :string + field :device_name, :string + field :user_agent, :string + field :ip_address, :string + field :last_used_at, :utc_datetime + field :expires_at, :utc_datetime + + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(trusted_device, attrs) do + trusted_device + |> cast(attrs, [ + :user_id, + :device_token, + :device_name, + :user_agent, + :ip_address, + :last_used_at, + :expires_at + ]) + |> validate_required([:user_id, :device_token]) + |> unique_constraint(:device_token) + |> foreign_key_constraint(:user_id) + end + + @doc """ + Generates a new trusted device with a secure random token. + Devices are trusted for 30 days by default. + """ + def new_trusted_device(user_id, attrs \\ %{}) do + raw_device_token = generate_device_token() + expires_at = DateTime.utc_now() |> DateTime.add(30, :day) + + default_attrs = %{ + user_id: user_id, + device_token: hash_device_token(raw_device_token), + last_used_at: DateTime.utc_now(), + expires_at: expires_at + } + + attrs = Map.merge(default_attrs, attrs) + + changeset = + %__MODULE__{} + |> changeset(attrs) + + {changeset, raw_device_token} + end + + @doc """ + Generates a secure random device token. + """ + def generate_device_token do + :crypto.strong_rand_bytes(32) + |> Base.url_encode64(padding: false) + end + + def hash_device_token(token) when is_binary(token) do + :crypto.hash(:sha256, token) + |> Base.encode16(case: :lower) + end + + @doc """ + Checks if a trusted device is still valid (not expired). + """ + def valid?(%__MODULE__{expires_at: expires_at}) do + DateTime.compare(DateTime.utc_now(), expires_at) == :lt + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/two_factor.ex b/apps/elektrine/lib/elektrine/accounts/two_factor.ex new file mode 100644 index 0000000..5168b46 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/two_factor.ex @@ -0,0 +1,121 @@ +defmodule Elektrine.Accounts.TwoFactor do + @moduledoc "Provides functionality for Two-Factor Authentication using TOTP.\n" + alias NimbleTOTP + @doc "Generates a new TOTP secret for a user.\n" + def generate_secret do + NimbleTOTP.secret() + end + + @doc "Generates a TOTP provisioning URI for QR code generation.\n" + def generate_provisioning_uri(secret, username, issuer \\ "Elektrine") do + NimbleTOTP.otpauth_uri("#{issuer}:#{username}", secret, issuer: issuer) + end + + @doc "Converts a binary secret to Base32 encoding for manual entry in authenticator apps.\n" + def secret_to_base32(secret) when is_binary(secret) do + Base.encode32(secret, padding: false) + end + + @doc "Generates a QR code PNG image as binary data for the given provisioning URI.\n" + def generate_qr_code_png(provisioning_uri) when is_binary(provisioning_uri) do + qr_code = EQRCode.encode(provisioning_uri) + png_binary = EQRCode.png(qr_code, width: 200) + {:ok, png_binary} + rescue + error -> {:error, error} + end + + @doc "Generates a QR code as a base64 data URI for inline embedding in HTML.\nThis avoids timing issues with session cookies when using separate image requests.\n" + def generate_qr_code_data_uri(provisioning_uri) when is_binary(provisioning_uri) do + qr_code = EQRCode.encode(provisioning_uri) + png_binary = EQRCode.png(qr_code, width: 200) + base64_png = Base.encode64(png_binary) + {:ok, "data:image/png;base64,#{base64_png}"} + rescue + error -> {:error, error} + end + + @doc "Verifies a TOTP code against the user's secret.\n\nTOTP Configuration:\n- Algorithm: SHA1 (TOTP standard)\n- Digits: 6\n- Period: 30 seconds\n- Custom window: checks ±10 time periods manually\n\nUses ±1 period (±30 seconds) for a total 90-second validation window.\nThis handles minor clock drift while maintaining security.\n" + def verify_totp(secret, code) when is_binary(secret) and is_binary(code) do + require Logger + + case Integer.parse(code) do + {numeric_code, ""} when numeric_code >= 0 and numeric_code <= 999_999 -> + formatted_code = String.pad_leading(code, 6, "0") + current_time = System.system_time(:second) + + valid = + Enum.any?(-1..1, fn offset -> + time_for_offset = current_time + offset * 30 + expected_code = NimbleTOTP.verification_code(secret, time: time_for_offset) + expected_code == formatted_code + end) + + if valid do + Logger.debug("2FA code verified successfully") + else + Logger.warning("2FA code verification failed for user") + end + + valid + + _ -> + Logger.warning("2FA code format invalid") + false + end + end + + def verify_totp(_, _) do + false + end + + @doc "Generates backup codes for account recovery.\nReturns {plain_codes, hashed_codes} tuple.\nPlain codes should be shown to user once, hashed codes stored in database.\n" + def generate_backup_codes(count \\ 8) do + plain_codes = 1..count |> Enum.map(fn _ -> generate_backup_code() end) + hashed_codes = Enum.map(plain_codes, &hash_backup_code/1) + {plain_codes, hashed_codes} + end + + @doc "Verifies a backup code against the user's stored hashed backup codes.\nReturns {:ok, remaining_codes} if valid, {:error, :invalid} if not.\n" + def verify_backup_code(backup_codes, code) when is_list(backup_codes) and is_binary(code) do + formatted_code = String.upcase(String.trim(code)) + + case Enum.find_index(backup_codes, fn stored_code -> + verify_backup_code_match(formatted_code, stored_code) + end) do + nil -> + {:error, :invalid} + + index -> + remaining_codes = List.delete_at(backup_codes, index) + {:ok, remaining_codes} + end + end + + def verify_backup_code(_, _) do + {:error, :invalid} + end + + defp hash_backup_code(code) when is_binary(code) do + Argon2.hash_pwd_salt(code) + end + + defp verify_backup_code_match(code, stored_code) + when is_binary(code) and is_binary(stored_code) do + String.starts_with?(stored_code, "$argon2") and Argon2.verify_pass(code, stored_code) + end + + defp verify_backup_code_match(_, _) do + false + end + + defp generate_backup_code do + chars = "ABCDEFGHIJKLMNPQRSTUVWXYZ123456789" + chars_list = String.codepoints(chars) + chars_count = length(chars_list) + + :crypto.strong_rand_bytes(8) + |> :binary.bin_to_list() + |> Enum.map_join("", fn byte -> Enum.at(chars_list, rem(byte, chars_count)) end) + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/user.ex b/apps/elektrine/lib/elektrine/accounts/user.ex new file mode 100644 index 0000000..a797e07 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/user.ex @@ -0,0 +1,1489 @@ +defmodule Elektrine.Accounts.User do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query, warn: false + + alias Elektrine.Accounts.BuiltInSubdomain + alias Elektrine.Security.URLValidator + + @built_in_subdomain_modes BuiltInSubdomain.modes() + + schema "users" do + # Authentication + field :username, :string + field :password_hash, :string + field :password, :string, virtual: true + field :password_confirmation, :string, virtual: true + field :current_password, :string, virtual: true + field :invite_code, :string, virtual: true + field :registration_access_token, :string, virtual: true + field :avatar, :string + field :avatar_size, :integer, default: 0 + field :is_admin, :boolean, default: false + field :is_bot, :boolean, default: false + field :verified, :boolean, default: false + field :banned, :boolean, default: false + field :banned_at, :utc_datetime + field :banned_reason, :string + field :suspended, :boolean, default: false + field :suspended_until, :utc_datetime + field :suspension_reason, :string + + # Trust Level System + field :trust_level, :integer, default: 0 + field :trust_level_locked, :boolean, default: false + field :promoted_at, :utc_datetime + field :two_factor_enabled, :boolean, default: false + field :two_factor_secret, Elektrine.Secrets.EncryptedString + field :two_factor_backup_codes, {:array, :string} + field :two_factor_enabled_at, :utc_datetime + field :registration_ip, :string + field :registered_via_onion, :boolean, default: false + field :last_login_ip, :string + field :last_login_at, :utc_datetime + field :login_count, :integer, default: 0 + field :recovery_email, :string + field :password_reset_token, :string + field :password_reset_token_expires_at, :utc_datetime + field :last_password_change, :utc_datetime + field :auth_valid_after, :utc_datetime + field :locale, :string, default: "en" + field :timezone, :string + field :time_format, :string, default: "12" + field :theme_mode, :string, default: "system" + field :theme_overrides, :map, default: %{} + + # Social Identity + field :handle, :string + field :display_name, :string + field :unique_id, :string + field :handle_changed_at, :utc_datetime + field :built_in_subdomain_mode, :string, default: "path" + field :birthday, :date + field :show_birthday, :boolean, default: false + field :hide_followers, :boolean, default: false + field :hide_follows, :boolean, default: false + field :hide_favorites, :boolean, default: false + field :also_known_as, {:array, :string}, default: [] + field :moved_to, :string + + # Privacy Settings + field :allow_group_adds_from, :string, default: "everyone" + field :allow_direct_messages_from, :string, default: "everyone" + field :allow_mentions_from, :string, default: "everyone" + field :allow_calls_from, :string, default: "friends" + field :allow_friend_requests_from, :string, default: "everyone" + field :profile_visibility, :string, default: "public" + field :default_post_visibility, :string, default: "followers" + + # Notification Settings + field :notify_on_new_follower, :boolean, default: true + field :notify_on_direct_message, :boolean, default: true + field :notify_on_mention, :boolean, default: true + field :notify_on_reply, :boolean, default: true + field :notify_on_like, :boolean, default: true + field :notify_on_email_received, :boolean, default: true + field :notify_on_discussion_reply, :boolean, default: true + field :notify_on_comment, :boolean, default: true + field :block_notifications_from_strangers, :boolean, default: false + field :hide_notification_contents, :boolean, default: false + + # Storage Tracking + field :storage_used_bytes, :integer, default: 0 + # 500MB + field :storage_limit_bytes, :integer, default: 524_288_000 + field :storage_last_calculated_at, :utc_datetime + + # CardDAV/CalDAV sync + field :addressbook_ctag, :string + + # Email Protocol Usage Tracking + field :last_imap_access, :utc_datetime + field :last_pop3_access, :utc_datetime + + # User Status + field :status, :string, default: "online" + field :status_message, :string + field :status_updated_at, :utc_datetime + field :last_seen_at, :utc_datetime + + # Email Settings + field :email_signature, :string + field :preferred_email_domain, :string + field :stripe_customer_id, :string + + # Email Sending Restrictions (anti-spam) + field :email_sending_restricted, :boolean, default: false + field :email_rate_limit_violations, :integer, default: 0 + field :email_restriction_reason, :string + field :email_restricted_at, :utc_datetime + field :recovery_email_verified, :boolean, default: false + field :recovery_email_verification_token, :string + field :recovery_email_verification_sent_at, :utc_datetime + + # Onboarding + field :onboarding_completed, :boolean, default: false + field :onboarding_completed_at, :utc_datetime + field :onboarding_step, :integer, default: 1 + + # ActivityPub Federation + field :activitypub_enabled, :boolean, default: true + field :activitypub_private_key, Elektrine.Secrets.EncryptedString + field :activitypub_public_key, :string + field :activitypub_manually_approve_followers, :boolean, default: true + + # Bluesky Cross-posting + field :bluesky_enabled, :boolean, default: false + field :bluesky_identifier, :string + field :bluesky_app_password, Elektrine.Secrets.EncryptedString + field :bluesky_did, :string + field :bluesky_pds_url, :string + field :bluesky_inbound_cursor, :string + field :bluesky_inbound_last_polled_at, :utc_datetime + + # PGP/OpenPGP Encryption + field :pgp_public_key, :string + field :pgp_key_id, :string + field :pgp_fingerprint, :string + field :pgp_key_uploaded_at, :utc_datetime + field :pgp_wkd_hash, :string + + has_one :profile, Elektrine.Profiles.UserProfile + has_many :sessions, Elektrine.Accounts.UserSession + has_many :badges, Elektrine.Profiles.UserBadge, foreign_key: :user_id + has_one :activity_stats, Elektrine.Accounts.UserActivityStats + has_many :trust_level_logs, Elektrine.Accounts.TrustLevelLog + + timestamps(type: :utc_datetime) + end + + @doc """ + Changeset for user registration. + """ + def registration_changeset(user, attrs) do + user + |> cast(attrs, [ + :username, + :password, + :password_confirmation, + :invite_code, + :registration_access_token, + :registration_ip, + :registered_via_onion + ]) + |> validate_tos_agreement(attrs) + |> normalize_username() + |> validate_required([:username, :password, :password_confirmation]) + |> validate_length(:username, min: 2, max: 30) + |> validate_format(:username, ~r/^[a-zA-Z0-9]+$/, message: "only letters and numbers allowed") + |> validate_username_not_alias() + |> validate_username_not_matching_other_handle() + |> validate_username_not_reserved() + |> validate_username_case_conflicts() + |> unique_constraint(:username, + name: :users_username_ci_unique, + message: "this username is already taken" + ) + |> unique_constraint(:username, + name: :users_username_index, + message: "this username is already taken" + ) + |> validate_length(:password, min: 8, max: 72) + |> validate_confirmation(:password, message: "does not match password") + |> generate_unique_id() + |> assign_initial_handle() + |> put_display_name_from_username() + |> maybe_put_default_preferred_email_domain() + |> hash_password() + end + + @doc """ + Changeset for admin user registration. + Allows admins to create users with additional options. + """ + def admin_registration_changeset(user, attrs) do + user + |> cast(attrs, [ + :username, + :password, + :password_confirmation, + :is_admin, + :trust_level, + :trust_level_locked + ]) + |> normalize_username() + |> validate_required([:username, :password, :password_confirmation]) + |> validate_length(:username, min: 2, max: 30) + |> validate_format(:username, ~r/^[a-zA-Z0-9]+$/, message: "only letters and numbers allowed") + |> validate_username_not_alias() + |> validate_username_not_matching_other_handle() + # Skip reserved username validation for admin-created users + |> validate_username_case_conflicts() + |> unique_constraint(:username, + name: :users_username_ci_unique, + message: "this username is already taken (case-insensitive)" + ) + |> validate_length(:password, min: 8, max: 72) + |> validate_confirmation(:password, message: "does not match password") + |> validate_number(:trust_level, greater_than_or_equal_to: 0, less_than_or_equal_to: 4) + |> generate_unique_id() + |> assign_initial_handle() + |> put_display_name_from_username() + |> maybe_put_default_preferred_email_domain() + |> hash_password() + end + + defp hash_password(%Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset) do + change(changeset, %{ + password_hash: Argon2.hash_pwd_salt(password), + last_password_change: DateTime.utc_now() |> DateTime.truncate(:second) + }) + end + + defp hash_password(changeset), do: changeset + + defp validate_preferred_email_domain(changeset, user) do + case fetch_change(changeset, :preferred_email_domain) do + :error -> + changeset + + {:ok, nil} -> + changeset + + {:ok, preferred_domain} -> + allowed_domains = Elektrine.Domains.available_email_domains_for_user(user) + + if preferred_domain in allowed_domains do + changeset + else + add_error(changeset, :preferred_email_domain, "must be one of your available domains") + end + end + end + + # Generate a unique ID for the user + defp generate_unique_id(changeset) do + if get_field(changeset, :unique_id) do + changeset + else + # 8 bytes = 64 bits entropy + # 50% collision probability at ~5 billion users (vs 77k with 4 bytes) + unique_id = "usr_" <> (:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)) + put_change(changeset, :unique_id, unique_id) + end + end + + # Assign an initial handle based on username + defp assign_initial_handle(changeset) do + if get_field(changeset, :handle) do + changeset + else + username = get_field(changeset, :username) + + if username do + base_handle = String.downcase(username) + handle = find_available_handle(base_handle) + put_change(changeset, :handle, handle) + else + changeset + end + end + end + + # Find an available handle by appending numbers if needed + defp find_available_handle(base_handle, attempt \\ 0) do + handle = + if attempt == 0 do + base_handle + else + "#{base_handle}#{attempt}" + end + + # Check if handle is already taken (use exists? to avoid multiple results error) + import Ecto.Query + exists = Elektrine.Repo.exists?(from u in __MODULE__, where: u.handle == ^handle) + + if exists do + find_available_handle(base_handle, attempt + 1) + else + handle + end + end + + # Set display name from username if not provided + defp put_display_name_from_username(changeset) do + if get_field(changeset, :display_name) do + changeset + else + username = get_field(changeset, :username) + + if username do + put_change(changeset, :display_name, username) + else + changeset + end + end + end + + @doc """ + A changeset for changing the user account. + """ + def changeset(user, attrs) do + user + |> cast(attrs, [ + :avatar, + :avatar_size, + :display_name, + :recovery_email, + :allow_group_adds_from, + :allow_direct_messages_from, + :allow_mentions_from, + :allow_calls_from, + :allow_friend_requests_from, + :profile_visibility, + :default_post_visibility, + :notify_on_new_follower, + :notify_on_direct_message, + :notify_on_mention, + :notify_on_reply, + :notify_on_like, + :notify_on_email_received, + :notify_on_discussion_reply, + :notify_on_comment, + :block_notifications_from_strangers, + :hide_notification_contents, + :locale, + :timezone, + :time_format, + :theme_mode, + :theme_overrides, + :email_signature, + :preferred_email_domain, + :onboarding_completed, + :onboarding_completed_at, + :onboarding_step, + :activitypub_manually_approve_followers, + :built_in_subdomain_mode, + :birthday, + :show_birthday, + :hide_followers, + :hide_follows, + :hide_favorites, + :also_known_as, + :moved_to, + :bluesky_enabled, + :bluesky_identifier, + :bluesky_app_password, + :bluesky_pds_url + ]) + |> validate_length(:display_name, max: 100) + |> validate_format(:recovery_email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, + message: "must be a valid email address with @ symbol and domain" + ) + |> validate_length(:recovery_email, max: 160) + |> validate_length(:email_signature, max: 500) + |> validate_inclusion(:allow_group_adds_from, [ + "everyone", + "following", + "followers", + "mutual", + "friends", + "nobody" + ]) + |> validate_inclusion(:allow_direct_messages_from, [ + "everyone", + "following", + "followers", + "mutual", + "friends", + "nobody" + ]) + |> validate_inclusion(:allow_mentions_from, [ + "everyone", + "following", + "followers", + "mutual", + "friends", + "nobody" + ]) + |> validate_inclusion(:allow_calls_from, ["everyone", "friends", "nobody"]) + |> validate_inclusion(:allow_friend_requests_from, ["everyone", "followers", "nobody"]) + |> validate_inclusion(:profile_visibility, ["public", "followers", "private"]) + |> validate_inclusion(:default_post_visibility, ["public", "followers", "friends", "private"]) + |> validate_inclusion(:built_in_subdomain_mode, @built_in_subdomain_modes) + |> validate_inclusion(:locale, ~w(en zh), message: "is not a supported locale") + |> validate_inclusion(:time_format, ~w(12 24), message: "must be 12 or 24") + |> validate_birthday() + |> normalize_account_migration_metadata() + |> validate_account_migration_metadata() + |> validate_inclusion(:theme_mode, Elektrine.Theme.modes(), + message: "is not a supported theme mode" + ) + |> Elektrine.Theme.validate_overrides(:theme_overrides) + |> validate_preferred_email_domain(user) + |> validate_bluesky_settings() + end + + @doc """ + A changeset for notification-only settings updates. + """ + def notification_settings_changeset(user, attrs) do + cast(user, attrs, [ + :notify_on_new_follower, + :notify_on_direct_message, + :notify_on_mention, + :notify_on_reply, + :notify_on_like, + :notify_on_email_received, + :notify_on_discussion_reply, + :notify_on_comment, + :block_notifications_from_strangers, + :hide_notification_contents + ]) + end + + @doc """ + A changeset for changing the user password. + """ + def password_changeset(user, attrs) do + user + |> cast(attrs, [:password, :password_confirmation, :current_password]) + |> validate_required([:password, :password_confirmation, :current_password]) + |> validate_length(:password, min: 8, max: 72) + |> validate_confirmation(:password, message: "does not match password") + |> validate_current_password() + |> hash_password() + end + + @doc """ + A changeset for importing users with pre-hashed passwords. + For use in migrations only. + """ + def import_changeset(user, attrs) do + user + |> cast(attrs, [:username, :password_hash]) + |> normalize_username() + |> validate_required([:username, :password_hash]) + |> validate_length(:username, min: 2, max: 30) + |> validate_format(:username, ~r/^[a-zA-Z0-9]+$/, message: "only letters and numbers allowed") + |> maybe_put_default_preferred_email_domain() + |> unique_constraint(:username) + end + + defp maybe_put_default_preferred_email_domain(changeset) do + case get_field(changeset, :preferred_email_domain) do + nil -> + put_change( + changeset, + :preferred_email_domain, + Elektrine.Domains.default_user_handle_domain() + ) + + "" -> + put_change( + changeset, + :preferred_email_domain, + Elektrine.Domains.default_user_handle_domain() + ) + + _ -> + changeset + end + end + + @doc """ + A changeset for trust-level updates managed by the trust system. + """ + def trust_level_changeset(user, attrs) do + user + |> cast(attrs, [:trust_level, :promoted_at]) + |> validate_number(:trust_level, greater_than_or_equal_to: 0, less_than_or_equal_to: 4) + end + + @doc """ + A changeset for admin user editing. + """ + def admin_changeset(user, attrs) do + user + |> cast(attrs, [ + :username, + :avatar, + :handle, + :display_name, + :recovery_email, + :locale, + :timezone, + :time_format, + :built_in_subdomain_mode, + :birthday, + :show_birthday, + :also_known_as, + :moved_to, + :verified, + :is_bot, + :banned, + :banned_reason, + :suspended, + :suspended_until, + :suspension_reason, + :is_admin, + :trust_level, + :trust_level_locked + ]) + |> validate_required([:username]) + |> normalize_username() + |> validate_length(:username, min: 2, max: 30) + |> validate_format(:username, ~r/^[a-zA-Z0-9]+$/, message: "only letters and numbers allowed") + |> validate_handle_immutable() + |> validate_handle() + |> validate_length(:display_name, max: 100) + |> validate_inclusion(:built_in_subdomain_mode, @built_in_subdomain_modes) + |> validate_format(:recovery_email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, + message: "must be a valid email" + ) + |> validate_inclusion(:locale, ["en", "zh"]) + |> validate_inclusion(:time_format, ["12", "24"]) + |> validate_birthday() + |> normalize_account_migration_metadata() + |> validate_account_migration_metadata() + |> validate_length(:banned_reason, max: 500) + |> validate_length(:suspension_reason, max: 500) + |> validate_number(:trust_level, greater_than_or_equal_to: 0, less_than_or_equal_to: 4) + |> maybe_set_ban_timestamp() + |> maybe_clear_suspension_data() + |> validate_username_not_alias() + |> validate_username_not_matching_other_handle() + |> unique_constraint(:handle, + name: :users_handle_ci_unique, + message: "this handle is already taken" + ) + |> validate_username_not_reserved() + |> validate_username_case_conflicts() + |> unique_constraint(:username, + name: :users_username_ci_unique, + message: "this username is already taken" + ) + |> unique_constraint(:username, + name: :users_username_index, + message: "this username is already taken" + ) + end + + # Set banned_at timestamp when banned status changes + defp maybe_set_ban_timestamp(changeset) do + case get_change(changeset, :banned) do + true -> put_change(changeset, :banned_at, DateTime.utc_now()) + false -> put_change(changeset, :banned_at, nil) |> put_change(:banned_reason, nil) + _ -> changeset + end + end + + # Clear suspension data when suspension is disabled + defp maybe_clear_suspension_data(changeset) do + case get_change(changeset, :suspended) do + false -> + changeset + |> put_change(:suspended_until, nil) + |> put_change(:suspension_reason, nil) + + _ -> + changeset + end + end + + @doc """ + A changeset for banning a user. + """ + def ban_changeset(user, attrs \\ %{}) do + user + |> cast(attrs, [:banned_reason]) + |> put_change(:banned, true) + |> put_change(:banned_at, DateTime.utc_now() |> DateTime.truncate(:second)) + end + + @doc """ + A changeset for updating user locale preference. + """ + def locale_changeset(user, attrs) do + user + |> cast(attrs, [:locale, :timezone, :time_format]) + |> validate_inclusion(:locale, ~w(en zh), message: "is not a supported locale") + |> validate_inclusion(:time_format, ~w(12 24), message: "must be 12 or 24") + end + + @doc """ + A changeset for unbanning a user. + """ + def unban_changeset(user, attrs \\ %{}) do + user + |> cast(attrs, []) + |> put_change(:banned, false) + |> put_change(:banned_at, nil) + |> put_change(:banned_reason, nil) + end + + @doc """ + A changeset for suspending a user. + """ + def suspend_changeset(user, attrs) do + user + |> cast(attrs, [:suspension_reason, :suspended_until]) + |> validate_length(:suspension_reason, max: 500) + |> put_change(:suspended, true) + end + + @doc """ + A changeset for unsuspending a user. + """ + def unsuspend_changeset(user, attrs \\ %{}) do + user + |> cast(attrs, []) + |> put_change(:suspended, false) + |> put_change(:suspended_until, nil) + |> put_change(:suspension_reason, nil) + end + + @doc """ + A changeset for enabling two-factor authentication. + """ + def enable_two_factor_changeset(user, attrs) do + user + |> cast(attrs, [:two_factor_secret, :two_factor_backup_codes]) + |> validate_required([:two_factor_secret, :two_factor_backup_codes]) + |> put_change(:two_factor_enabled, true) + |> put_change(:two_factor_enabled_at, DateTime.utc_now() |> DateTime.truncate(:second)) + end + + @doc """ + A changeset for disabling two-factor authentication. + """ + def disable_two_factor_changeset(user, _attrs \\ %{}) do + user + |> cast(%{}, []) + |> put_change(:two_factor_enabled, false) + |> put_change(:two_factor_secret, nil) + |> put_change(:two_factor_backup_codes, nil) + |> put_change(:two_factor_enabled_at, nil) + end + + @doc """ + A changeset for updating backup codes after one is used. + """ + def update_backup_codes_changeset(user, remaining_codes) do + user + |> cast(%{}, []) + |> put_change(:two_factor_backup_codes, remaining_codes) + end + + @doc """ + A changeset for updating login information. + """ + def login_changeset(user, attrs) do + user + |> cast(attrs, [:last_login_ip, :last_login_at, :login_count]) + end + + # Private helper functions + + defp validate_tos_agreement(changeset, attrs) do + # Only validate if this is a form submission (attrs has string keys and values) + # Skip validation on initial changeset creation + if is_map(attrs) && map_size(attrs) > 0 && Map.has_key?(attrs, "username") do + case Map.get(attrs, "agree_to_terms") do + "true" -> + changeset + + _ -> + add_error( + changeset, + :agree_to_terms, + "You must agree to the Terms of Service and Privacy Policy" + ) + end + else + changeset + end + end + + defp validate_username_not_alias(changeset) do + username = get_field(changeset, :username) + + if not is_nil(username) and Elektrine.Platform.Modules.compiled?(:email) do + # Check if this username would conflict with existing aliases on our domains + allowed_domains = Elektrine.Domains.supported_email_domains() + + # Check each domain for conflicts (case-insensitive) + conflicts = + Enum.any?(allowed_domains, fn domain -> + alias_email = String.downcase("#{username}@#{domain}") + + query = + from(a in Elektrine.Email.Alias, + where: fragment("lower(?)", a.alias_email) == ^alias_email and a.enabled == true, + limit: 1 + ) + + case Elektrine.Repo.one(query) do + nil -> false + _alias -> true + end + end) + + if conflicts do + add_error(changeset, :username, "this username conflicts with an existing email alias") + else + changeset + end + else + changeset + end + end + + defp validate_username_not_matching_other_handle(changeset) do + username = get_field(changeset, :username) + user_id = get_field(changeset, :id) + + if Elektrine.Strings.present?(username) do + query = + from(u in Elektrine.Accounts.User, + where: fragment("lower(?)", u.handle) == ^String.downcase(username) + ) + + query = + if user_id do + from(u in query, where: u.id != ^user_id) + else + query + end + + case Elektrine.Repo.one(query) do + nil -> changeset + _ -> add_error(changeset, :username, "conflicts with an existing handle") + end + else + changeset + end + end + + defp validate_username_case_conflicts(changeset) do + username = get_field(changeset, :username) + user_id = get_field(changeset, :id) + + if username do + # Check for case-insensitive duplicates + query = + from(u in Elektrine.Accounts.User, + where: fragment("lower(?)", u.username) == ^String.downcase(username) + ) + + # Exclude current user if updating + query = + if user_id do + from(u in query, where: u.id != ^user_id) + else + query + end + + case Elektrine.Repo.one(query) do + nil -> + changeset + + _existing -> + add_error( + changeset, + :username, + "a user with this username already exists (case-insensitive check)" + ) + end + else + changeset + end + end + + defp validate_current_password(changeset) do + current_password = get_change(changeset, :current_password) + + if current_password do + user = changeset.data + + # Verify the current password + if verify_password(current_password, user.password_hash) do + changeset + else + add_error(changeset, :current_password, "is incorrect") + end + else + changeset + end + end + + # Helper function to verify password against hash + defp verify_password(password, hash) when is_binary(password) and is_binary(hash) do + if String.starts_with?(hash, ["$2", "$2a$", "$2b$", "$2y$"]) do + Bcrypt.verify_pass(password, hash) + else + Argon2.verify_pass(password, hash) + end + end + + defp verify_password(_, _), do: false + + defp validate_username_not_reserved(changeset) do + username = get_field(changeset, :username) + + if username do + # List of reserved/sensitive usernames + reserved_usernames = + [ + # System accounts (critical - these would create system emails) + "admin", + "administrator", + "root", + "system", + "daemon", + "postmaster", + "webmaster", + "hostmaster", + # Service accounts + "support", + "help", + "contact", + "info", + "sales", + "billing", + "noreply", + "no-reply", + "donotreply", + "do-not-reply", + # ActivityPub endpoints (critical - would break federation) + "inbox", + "outbox", + "followers", + "following", + "actor", + "users", + "activities", + "ap", + # Well-known paths + "webfinger", + ".well-known", + "nodeinfo", + # Federation-related + "relay", + "instance", + "sharedInbox", + "abuse", + "security", + "privacy", + "legal", + "compliance", + "audit", + # Technical infrastructure + "api", + "www", + "mail", + "email", + "smtp", + "pop", + "imap", + "ftp", + "ssh", + "git", + "dev", + "test", + "staging", + "prod", + # Social/Marketing + "marketing", + "news", + "newsletter", + "notifications", + "social", + "media", + "press", + "blog", + "forum", + # Brand protection + "elektrine", + "z", + "zorg", + "official", + "verified", + # Common exploits + "null", + "undefined", + "nil", + "void", + "empty", + "blank", + "anonymous", + "guest", + "user", + "member", + "public", + # Application routes (prevent username/route conflicts) + "chat", + "timeline", + "discussions", + "friends", + "search", + "hashtag", + "vpn", + "account", + "login", + "register", + "password", + "logout", + "onboarding", + "about", + "terms", + "faq", + "locale", + "unsubscribe", + "resubscribe", + "pripyat", + "badges", + "reports", + "updates", + "storage", + "profile", + "settings", + "invite", + "invites", + "l", + "haraka" + ] ++ Elektrine.Domains.reserved_local_parts() + + if String.downcase(username) in reserved_usernames do + add_error(changeset, :username, "this username is reserved and cannot be used") + else + changeset + end + else + changeset + end + end + + defp normalize_username(changeset) do + case get_field(changeset, :username) do + nil -> + changeset + + username -> + normalized = String.downcase(username) + put_change(changeset, :username, normalized) + end + end + + defp validate_bluesky_settings(changeset) do + changeset + |> normalize_optional_string(:bluesky_identifier) + |> normalize_optional_string(:bluesky_app_password) + |> normalize_optional_string(:bluesky_pds_url) + |> normalize_bluesky_pds_url() + |> validate_length(:bluesky_identifier, max: 255) + |> validate_length(:bluesky_app_password, max: 255) + |> validate_length(:bluesky_pds_url, max: 255) + |> validate_bluesky_pds_url() + |> require_bluesky_credentials_when_enabled() + end + + defp normalize_bluesky_pds_url(changeset) do + case get_change(changeset, :bluesky_pds_url) do + value when is_binary(value) and value != "" -> + normalized = + value + |> then(fn url -> + if String.starts_with?(url, ["http://", "https://"]), do: url, else: "https://" <> url + end) + |> String.trim_trailing("/") + + put_change(changeset, :bluesky_pds_url, normalized) + + _ -> + changeset + end + end + + defp validate_bluesky_pds_url(changeset) do + validate_change(changeset, :bluesky_pds_url, fn :bluesky_pds_url, value -> + if configured_managed_bluesky_pds_url?(value) do + [] + else + case URLValidator.validate(value) do + :ok -> [] + {:error, _reason} -> [bluesky_pds_url: "must be a public http or https URL"] + end + end + end) + end + + defp configured_managed_bluesky_pds_url?(value) when is_binary(value) do + configured = + Application.get_env(:elektrine, :bluesky, []) + |> Keyword.get(:managed_service_url) + + normalize_bluesky_pds_url_value(value) == normalize_bluesky_pds_url_value(configured) + end + + defp configured_managed_bluesky_pds_url?(_value), do: false + + defp normalize_bluesky_pds_url_value(value) when is_binary(value) do + value + |> String.trim() + |> case do + "" -> + nil + + url -> + if String.starts_with?(url, ["http://", "https://"]), do: url, else: "https://" <> url + end + |> case do + nil -> nil + url -> String.trim_trailing(url, "/") + end + end + + defp normalize_bluesky_pds_url_value(_value), do: nil + + defp normalize_optional_string(changeset, field) do + case get_change(changeset, field) do + nil -> + changeset + + value when is_binary(value) -> + value + |> String.trim() + |> case do + "" -> put_change(changeset, field, nil) + trimmed -> put_change(changeset, field, trimmed) + end + + _ -> + changeset + end + end + + defp require_bluesky_credentials_when_enabled(changeset) do + if get_field(changeset, :bluesky_enabled) do + changeset + |> require_bluesky_identifier() + |> require_bluesky_app_password() + else + changeset + end + end + + defp require_bluesky_identifier(changeset) do + case get_field(changeset, :bluesky_identifier) do + identifier when is_binary(identifier) -> + if Elektrine.Strings.present?(identifier), + do: changeset, + else: add_error(changeset, :bluesky_identifier, "is required when Bluesky is enabled") + + _ -> + add_error(changeset, :bluesky_identifier, "is required when Bluesky is enabled") + end + end + + defp require_bluesky_app_password(changeset) do + case get_field(changeset, :bluesky_app_password) do + password when is_binary(password) -> + if Elektrine.Strings.present?(password), + do: changeset, + else: add_error(changeset, :bluesky_app_password, "is required when Bluesky is enabled") + + _ -> + add_error(changeset, :bluesky_app_password, "is required when Bluesky is enabled") + end + end + + @doc """ + A changeset for updating recovery email. + """ + def recovery_email_changeset(user, attrs) do + user + |> cast(attrs, [:recovery_email]) + |> validate_format(:recovery_email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, + message: "must be a valid email address with @ symbol and domain" + ) + |> validate_length(:recovery_email, max: 160) + end + + @doc """ + A changeset for initiating password reset. + """ + def password_reset_changeset(user, token) do + expires_at = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second) + + user + |> cast(%{}, []) + |> put_change(:password_reset_token, hash_sensitive_token(token)) + |> put_change(:password_reset_token_expires_at, expires_at) + end + + @doc """ + A changeset for clearing password reset token. + """ + def clear_password_reset_changeset(user) do + user + |> cast(%{}, []) + |> put_change(:password_reset_token, nil) + |> put_change(:password_reset_token_expires_at, nil) + end + + @doc """ + A changeset for resetting password with token. + """ + def password_reset_with_token_changeset(user, attrs) do + user + |> cast(attrs, [:password, :password_confirmation]) + |> validate_required([:password, :password_confirmation]) + |> validate_length(:password, min: 8, max: 72) + |> validate_confirmation(:password, message: "does not match password") + |> hash_password() + |> put_change(:password_reset_token, nil) + |> put_change(:password_reset_token_expires_at, nil) + end + + @doc """ + Checks if a password reset token is valid and not expired. + """ + def valid_password_reset_token?(%__MODULE__{password_reset_token: nil}), do: false + def valid_password_reset_token?(%__MODULE__{password_reset_token_expires_at: nil}), do: false + + def valid_password_reset_token?(%__MODULE__{password_reset_token_expires_at: expires_at}) do + DateTime.compare(DateTime.utc_now(), expires_at) == :lt + end + + @doc """ + Hashes one-time recovery tokens before they are stored. + """ + def hash_sensitive_token(token) when is_binary(token) do + :crypto.hash(:sha256, token) |> Base.encode16(case: :lower) + end + + @doc """ + Admin changeset for resetting user password. + """ + def admin_password_reset_changeset(user, attrs) do + user + |> cast(attrs, [:password]) + |> validate_required([:password]) + |> validate_length(:password, min: 8, max: 72) + |> hash_password() + |> put_change(:password_reset_token, nil) + |> put_change(:password_reset_token_expires_at, nil) + end + + @doc """ + Admin changeset for resetting user 2FA. + """ + def admin_2fa_reset_changeset(user) do + user + |> cast(%{}, []) + |> put_change(:two_factor_enabled, false) + |> put_change(:two_factor_secret, nil) + |> put_change(:two_factor_backup_codes, nil) + |> put_change(:two_factor_enabled_at, nil) + end + + @doc """ + Changeset for updating social handle. + """ + def handle_changeset(user, attrs) do + user + |> cast(attrs, [:handle, :display_name]) + |> validate_handle_immutable() + |> validate_handle() + |> validate_display_name() + |> unique_constraint(:handle, + name: :users_handle_ci_unique, + message: "this handle is already taken" + ) + end + + def built_in_subdomain_modes, do: BuiltInSubdomain.modes() + + def built_in_subdomain_mode(user), do: BuiltInSubdomain.mode(user) + + def built_in_subdomain_hosted_by_platform?(%__MODULE__{} = user), + do: BuiltInSubdomain.hosted_by_platform?(user) + + def built_in_subdomain_hosted_by_platform?(_), do: true + + # Validate handle format: 1-20 chars, alphanumeric + underscore + defp validate_handle(changeset) do + changeset + |> validate_required([:handle]) + |> validate_length(:handle, min: 1, max: 20) + |> validate_format(:handle, ~r/^[a-zA-Z0-9_]+$/, + message: "can only contain letters, numbers, and underscores" + ) + |> validate_handle_not_reserved() + |> validate_handle_not_matching_other_username() + |> normalize_handle() + end + + defp validate_handle_immutable(%Ecto.Changeset{} = changeset) do + current_handle = changeset.data.handle + new_handle = get_change(changeset, :handle) + + cond do + not Elektrine.Strings.present?(new_handle) -> + changeset + + not Elektrine.Strings.present?(current_handle) -> + changeset + + String.downcase(String.trim(new_handle)) == String.downcase(String.trim(current_handle)) -> + changeset + + true -> + add_error(changeset, :handle, "cannot be changed once set") + end + end + + # Normalize handle to lowercase for uniqueness checks + defp normalize_handle(changeset) do + case get_change(changeset, :handle) do + nil -> changeset + handle -> put_change(changeset, :handle, String.downcase(handle)) + end + end + + # Check if handle is reserved + defp validate_handle_not_reserved(changeset) do + handle = get_field(changeset, :handle) + + if handle do + reserved_handles = [ + "admin", + "administrator", + "root", + "system", + "moderator", + "mod", + "support", + "help", + "contact", + "info", + "api", + "www", + "mail", + "blog", + "news", + "about", + "legal", + "privacy", + "terms", + "tos", + "security", + "abuse", + "noreply", + "no-reply", + "elektrine", + "official", + # ActivityPub endpoints + "inbox", + "outbox", + "followers", + "following", + "actor", + "users", + "activities", + "ap", + "relay", + "instance", + "webfinger", + "nodeinfo" + ] + + if String.downcase(handle) in reserved_handles do + add_error(changeset, :handle, "this handle is reserved") + else + # Also check if it's in the handle_history reservation period + validate_handle_not_recently_used(changeset, handle) + end + else + changeset + end + end + + # Check if handle was recently used by another user (90-day reservation) + defp validate_handle_not_recently_used(changeset, handle) do + user_id = get_field(changeset, :id) + + # Check handle history for recent usage + _ninety_days_ago = DateTime.utc_now() |> DateTime.add(-90 * 24 * 60 * 60, :second) + + query = + from h in "handle_history", + where: fragment("lower(?)", h.handle) == ^String.downcase(handle), + where: h.user_id != ^user_id, + where: is_nil(h.reserved_until) or h.reserved_until > ^DateTime.utc_now(), + select: h.id, + limit: 1 + + case Elektrine.Repo.one(query) do + nil -> + changeset + + _reserved -> + add_error(changeset, :handle, "this handle is reserved for 90 days after last use") + end + end + + defp validate_handle_not_matching_other_username(changeset) do + handle = get_field(changeset, :handle) + user_id = get_field(changeset, :id) + + if Elektrine.Strings.present?(handle) do + query = + from(u in Elektrine.Accounts.User, + where: fragment("lower(?)", u.username) == ^String.downcase(handle) + ) + + query = + if user_id do + from(u in query, where: u.id != ^user_id) + else + query + end + + case Elektrine.Repo.one(query) do + nil -> changeset + _ -> add_error(changeset, :handle, "conflicts with an existing username") + end + else + changeset + end + end + + # Validate display name + defp validate_display_name(changeset) do + changeset + |> validate_length(:display_name, max: 50) + |> validate_format(:display_name, ~r/^[^\n\r\t\x00-\x1F\x7F]+$/u, + message: "cannot contain control characters" + ) + end + + defp validate_birthday(changeset) do + validate_change(changeset, :birthday, fn :birthday, birthday -> + if Date.compare(birthday, Date.utc_today()) == :gt do + [birthday: "cannot be in the future"] + else + [] + end + end) + end + + defp normalize_account_migration_metadata(changeset) do + changeset + |> normalize_moved_to() + |> normalize_also_known_as() + end + + defp normalize_moved_to(changeset) do + case get_change(changeset, :moved_to) do + nil -> + changeset + + value -> + put_change(changeset, :moved_to, normalize_account_uri(value)) + end + end + + defp normalize_also_known_as(changeset) do + case get_change(changeset, :also_known_as) do + nil -> + changeset + + values when is_list(values) -> + values = + values + |> Enum.map(&normalize_account_uri/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + + put_change(changeset, :also_known_as, values) + + _ -> + add_error(changeset, :also_known_as, "must be a list") + end + end + + defp normalize_account_uri(value) when is_binary(value) do + value = String.trim(value) + if value == "", do: nil, else: value + end + + defp normalize_account_uri(_), do: nil + + defp validate_account_migration_metadata(changeset) do + changeset + |> validate_length(:moved_to, max: 2048) + |> validate_length(:also_known_as, max: 10) + |> validate_change(:moved_to, fn :moved_to, value -> + account_uri_errors(:moved_to, value) + end) + |> validate_change(:also_known_as, fn :also_known_as, values -> + if Enum.all?(values, &(account_uri_errors(:also_known_as, &1) == [])) do + [] + else + [also_known_as: "must contain only http(s) URLs or acct: identifiers"] + end + end) + end + + defp account_uri_errors(_field, nil), do: [] + + defp account_uri_errors(field, "acct:" <> rest) do + case String.split(rest, "@", parts: 2) do + [name, domain] when name != "" and domain != "" -> [] + _ -> [{field, "must be a valid acct: identifier"}] + end + end + + defp account_uri_errors(field, value) when is_binary(value) do + case URI.parse(value) do + %URI{scheme: scheme, host: host, userinfo: nil} + when scheme in ["http", "https"] and host not in [nil, ""] -> + [] + + _ -> + [{field, "must be an http(s) URL or acct: identifier"}] + end + end + + defp account_uri_errors(field, _value), do: [{field, "must be a string"}] + + # Validate unique_id format + + @doc """ + Check if a handle is available. + """ + def handle_available?(handle) when is_binary(handle) do + handle = String.downcase(handle) + + # Check if handle exists + # Check if handle is reserved + !Elektrine.Repo.exists?( + from u in __MODULE__, where: fragment("lower(?)", u.handle) == ^handle + ) && + !handle_reserved?(handle) + end + + defp handle_reserved?(handle) do + _ninety_days_ago = DateTime.utc_now() |> DateTime.add(-90 * 24 * 60 * 60, :second) + + Elektrine.Repo.exists?( + from h in "handle_history", + where: fragment("lower(?)", h.handle) == ^String.downcase(handle), + where: is_nil(h.reserved_until) or h.reserved_until > ^DateTime.utc_now(), + select: h.id + ) + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/user_activity_stats.ex b/apps/elektrine/lib/elektrine/accounts/user_activity_stats.ex new file mode 100644 index 0000000..75c3abb --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/user_activity_stats.ex @@ -0,0 +1,74 @@ +defmodule Elektrine.Accounts.UserActivityStats do + @moduledoc """ + Tracks user activity metrics for trust level calculation. + Similar to Discourse's user_stats table. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "user_activity_stats" do + belongs_to :user, Elektrine.Accounts.User + + # Content creation + field :posts_created, :integer, default: 0 + field :topics_created, :integer, default: 0 + field :replies_created, :integer, default: 0 + + # Engagement metrics + field :likes_given, :integer, default: 0 + field :likes_received, :integer, default: 0 + field :replies_received, :integer, default: 0 + + # Reading metrics + field :posts_read, :integer, default: 0 + field :topics_entered, :integer, default: 0 + field :time_read_seconds, :integer, default: 0 + + # Visit tracking + field :days_visited, :integer, default: 0 + field :last_visit_date, :date + + # Moderation + field :flags_given, :integer, default: 0 + field :flags_received, :integer, default: 0 + field :flags_agreed, :integer, default: 0 + + # Penalties + field :posts_deleted, :integer, default: 0 + field :suspensions_count, :integer, default: 0 + + timestamps(type: :utc_datetime) + end + + def changeset(stats, attrs) do + stats + |> cast(attrs, [ + :posts_created, + :topics_created, + :replies_created, + :likes_given, + :likes_received, + :replies_received, + :posts_read, + :topics_entered, + :time_read_seconds, + :days_visited, + :last_visit_date, + :flags_given, + :flags_received, + :flags_agreed, + :posts_deleted, + :suspensions_count + ]) + |> validate_number(:posts_created, greater_than_or_equal_to: 0) + |> validate_number(:days_visited, greater_than_or_equal_to: 0) + end + + @doc """ + Increment a stat by a given amount. + """ + def increment(stats, field, amount \\ 1) do + current_value = Map.get(stats, field, 0) + changeset(stats, %{field => current_value + amount}) + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/user_block.ex b/apps/elektrine/lib/elektrine/accounts/user_block.ex new file mode 100644 index 0000000..32b1606 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/user_block.ex @@ -0,0 +1,36 @@ +defmodule Elektrine.Accounts.UserBlock do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "user_blocks" do + belongs_to :blocker, Elektrine.Accounts.User + belongs_to :blocked, Elektrine.Accounts.User + field :reason, :string + + timestamps() + end + + @doc false + def changeset(user_block, attrs) do + user_block + |> cast(attrs, [:blocker_id, :blocked_id, :reason]) + |> validate_required([:blocker_id, :blocked_id]) + |> validate_length(:reason, max: 255) + |> validate_not_self_block() + |> unique_constraint([:blocker_id, :blocked_id]) + |> foreign_key_constraint(:blocker_id) + |> foreign_key_constraint(:blocked_id) + end + + defp validate_not_self_block(changeset) do + blocker_id = get_field(changeset, :blocker_id) + blocked_id = get_field(changeset, :blocked_id) + + if blocker_id && blocked_id && blocker_id == blocked_id do + add_error(changeset, :blocked_id, "cannot block yourself") + else + changeset + end + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/user_mute.ex b/apps/elektrine/lib/elektrine/accounts/user_mute.ex new file mode 100644 index 0000000..aa691cf --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/user_mute.ex @@ -0,0 +1,37 @@ +defmodule Elektrine.Accounts.UserMute do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "user_mutes" do + belongs_to :muter, Elektrine.Accounts.User + belongs_to :muted, Elektrine.Accounts.User + field :mute_notifications, :boolean, default: false + field :expires_at, :utc_datetime + + timestamps() + end + + @doc false + def changeset(user_mute, attrs) do + user_mute + |> cast(attrs, [:muter_id, :muted_id, :mute_notifications, :expires_at]) + |> update_change(:expires_at, &Elektrine.Time.truncate/1) + |> validate_required([:muter_id, :muted_id]) + |> validate_not_self_mute() + |> unique_constraint([:muter_id, :muted_id]) + |> foreign_key_constraint(:muter_id) + |> foreign_key_constraint(:muted_id) + end + + defp validate_not_self_mute(changeset) do + muter_id = get_field(changeset, :muter_id) + muted_id = get_field(changeset, :muted_id) + + if muter_id && muted_id && muter_id == muted_id do + add_error(changeset, :muted_id, "cannot mute yourself") + else + changeset + end + end +end diff --git a/apps/elektrine/lib/elektrine/accounts/user_session.ex b/apps/elektrine/lib/elektrine/accounts/user_session.ex new file mode 100644 index 0000000..103226b --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/user_session.ex @@ -0,0 +1,108 @@ +defmodule Elektrine.Accounts.UserSession do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "user_sessions" do + belongs_to :user, Elektrine.Accounts.User + + field :auth_method, :string, default: "password" + field :device_label, :string + field :browser, :string + field :platform, :string + field :ip_address, :string + field :user_agent, :string + field :remembered, :boolean, default: false + field :last_seen_at, :utc_datetime + field :revoked_at, :utc_datetime + field :revoked_reason, :string + + timestamps(type: :utc_datetime) + end + + def changeset(session, attrs) do + session + |> cast(attrs, [ + :user_id, + :auth_method, + :device_label, + :browser, + :platform, + :ip_address, + :user_agent, + :remembered, + :last_seen_at, + :revoked_at, + :revoked_reason + ]) + |> put_default_last_seen() + |> normalize_device_metadata() + |> validate_required([:user_id, :auth_method, :last_seen_at]) + |> validate_length(:auth_method, max: 40) + |> validate_length(:device_label, max: 120) + |> validate_length(:browser, max: 80) + |> validate_length(:platform, max: 80) + |> validate_length(:ip_address, max: 120) + |> validate_length(:revoked_reason, max: 120) + |> foreign_key_constraint(:user_id) + end + + def revoke_changeset(session, reason \\ "revoked") do + session + |> cast(%{revoked_at: now(), revoked_reason: reason}, [:revoked_at, :revoked_reason]) + |> validate_required([:revoked_at]) + |> validate_length(:revoked_reason, max: 120) + end + + defp put_default_last_seen(changeset) do + case get_field(changeset, :last_seen_at) do + nil -> put_change(changeset, :last_seen_at, now()) + _value -> changeset + end + end + + defp normalize_device_metadata(changeset) do + user_agent = get_field(changeset, :user_agent) + browser = get_field(changeset, :browser) || browser_from_user_agent(user_agent) + platform = get_field(changeset, :platform) || platform_from_user_agent(user_agent) + label = get_field(changeset, :device_label) || device_label(browser, platform) + + changeset + |> put_change(:browser, browser) + |> put_change(:platform, platform) + |> put_change(:device_label, label) + end + + defp browser_from_user_agent(user_agent) when is_binary(user_agent) do + cond do + String.contains?(user_agent, "Edg/") -> "Edge" + String.contains?(user_agent, "Firefox/") -> "Firefox" + String.contains?(user_agent, "Chrome/") -> "Chrome" + String.contains?(user_agent, "Safari/") -> "Safari" + true -> "Browser" + end + end + + defp browser_from_user_agent(_), do: "Browser" + + defp platform_from_user_agent(user_agent) when is_binary(user_agent) do + cond do + String.contains?(user_agent, "Windows") -> "Windows" + String.contains?(user_agent, "Mac OS X") -> "macOS" + String.contains?(user_agent, "Android") -> "Android" + String.contains?(user_agent, "iPhone") or String.contains?(user_agent, "iPad") -> "iOS" + String.contains?(user_agent, "Linux") -> "Linux" + true -> "Unknown device" + end + end + + defp platform_from_user_agent(_), do: "Unknown device" + + defp device_label(browser, platform) do + [browser, platform] + |> Enum.reject(&is_nil/1) + |> Enum.join(" on ") + end + + defp now, do: DateTime.utc_now() |> DateTime.truncate(:second) +end diff --git a/apps/elektrine/lib/elektrine/accounts/username_history.ex b/apps/elektrine/lib/elektrine/accounts/username_history.ex new file mode 100644 index 0000000..df78ad7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/accounts/username_history.ex @@ -0,0 +1,24 @@ +defmodule Elektrine.Accounts.UsernameHistory do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "username_history" do + field :username, :string + field :previous_username, :string + field :changed_at, :utc_datetime + + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + def changeset(username_history, attrs) do + username_history + |> cast(attrs, [:username, :previous_username, :changed_at, :user_id]) + |> validate_required([:username, :user_id, :changed_at]) + |> validate_length(:username, min: 1, max: 30) + |> validate_length(:previous_username, max: 30) + |> foreign_key_constraint(:user_id) + end +end diff --git a/apps/elektrine/lib/elektrine/acme/wildcard_renewal_worker.ex b/apps/elektrine/lib/elektrine/acme/wildcard_renewal_worker.ex new file mode 100644 index 0000000..df9e242 --- /dev/null +++ b/apps/elektrine/lib/elektrine/acme/wildcard_renewal_worker.ex @@ -0,0 +1,148 @@ +defmodule Elektrine.ACME.WildcardRenewalWorker do + @moduledoc """ + Runs wildcard certificate renewal through acme.sh on an Oban schedule. + + The worker intentionally delegates ACME protocol handling to acme.sh. Elektrine + provides the DNS-01 hook and scheduling; acme.sh keeps renewal state and only + renews certificates that are near expiry. + """ + + use Oban.Worker, queue: :default, max_attempts: 1 + + require Logger + + @default_timeout_ms :timer.minutes(15) + + @impl Oban.Worker + def perform(%Oban.Job{}) do + if enabled?() do + run_renewal() + else + Logger.debug("Wildcard ACME renewal skipped: disabled") + :ok + end + end + + def enqueue do + %{} + |> new() + |> Elektrine.JobQueue.insert() + end + + defp enabled? do + System.get_env("ACME_WILDCARD_RENEWAL_ENABLED") in ["1", "true", "TRUE", "yes", "YES"] + end + + defp run_renewal do + with :ok <- bootstrap_if_needed() do + run_command(acme_sh(), ["--cron", "--home", acme_home()], "renewal") + end + end + + defp bootstrap_if_needed do + acme_sh = acme_sh() + + case validate_executable_path(acme_sh) do + :ok -> + if File.exists?(acme_sh) do + :ok + else + run_command(default_issue_executable(), [], "bootstrap") + end + + {:error, reason} -> + Logger.error("Wildcard ACME bootstrap failed: #{reason}") + {:error, :invalid_executable} + end + end + + defp run_command(executable, args, action) when is_binary(executable) and is_list(args) do + timeout = timeout_ms() + + Logger.info("Running wildcard ACME #{action}") + + case validate_executable_path(executable) do + :ok -> + case run_system_command(executable, args, timeout) do + {output, 0} -> + log_output(:info, "Wildcard ACME #{action} completed", output) + :ok + + {output, status} -> + log_output(:error, "Wildcard ACME #{action} failed with status #{status}", output) + {:error, :acme_failed} + + :timeout -> + Logger.error("Wildcard ACME #{action} timed out") + {:error, :acme_timeout} + end + + {:error, reason} -> + Logger.error("Wildcard ACME #{action} failed: #{reason}") + {:error, :invalid_executable} + end + rescue + error -> + Logger.error("Wildcard ACME #{action} crashed: #{Exception.message(error)}") + {:error, :acme_crashed} + end + + defp run_command(_executable, _args, action) do + Logger.error("Wildcard ACME #{action} failed: invalid executable") + {:error, :invalid_executable} + end + + defp acme_sh do + System.get_env("ACME_SH_BIN") || Path.join(acme_home(), "acme.sh") + end + + defp default_issue_executable do + "/app/scripts/acme/issue_elektrine_wildcard_cert.sh" + end + + defp acme_home do + System.get_env("ACME_HOME") || "/data/acme.sh" + end + + defp timeout_ms do + case Integer.parse(System.get_env("ACME_RENEW_TIMEOUT_MS") || "") do + {value, ""} when value > 0 -> value + _ -> @default_timeout_ms + end + end + + defp validate_executable_path(executable) do + cond do + String.contains?(executable, "\0") -> + {:error, "executable path contains NUL"} + + Path.type(executable) != :absolute -> + {:error, "executable path must be absolute"} + + true -> + :ok + end + end + + defp run_system_command(executable, args, timeout) do + task = + Task.async(fn -> + System.cmd(executable, args, stderr_to_stdout: true) + end) + + case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do + {:ok, result} -> result + nil -> :timeout + end + end + + defp log_output(level, message, output) do + output = String.trim(to_string(output)) + + if output == "" do + Logger.log(level, message) + else + Logger.log(level, "#{message}: #{output}") + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub.ex b/apps/elektrine/lib/elektrine/activitypub.ex new file mode 100644 index 0000000..e27cba2 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub.ex @@ -0,0 +1,2243 @@ +defmodule Elektrine.ActivityPub do + @moduledoc """ + The ActivityPub context for federation. + Handles actors, activities, and federation with other instances. + """ + + import Ecto.Query, warn: false + require Logger + alias Elektrine.Domains + alias Elektrine.Repo + + alias Elektrine.ActivityPub.{ + Activity, + ActivityDeliveryWorker, + Actor, + CollectionFetcher, + Delivery, + DomainDeliveryHealth, + Fetcher, + HTTPSignature, + Instance, + KeyManager, + LemmyApi, + LocalReferences, + MastodonApi, + MRF, + ObjectDeliveries, + RelaySubscription, + RemoteFetch, + ReplyFetchPolicy, + RequestReplayCache, + Tombstones, + UserBlock + } + + alias Elektrine.Accounts.User + alias Elektrine.Async + alias Elektrine.HTTP.SafeFetch + alias Elektrine.Security.URLValidator + alias Elektrine.Social.Conversation + alias Elektrine.Social.Conversations + alias Elektrine.Telemetry.Events + @public_audience_uri "https://www.w3.org/ns/activitystreams#Public" + + @doc """ + Fetches a single remote ActivityPub object for detail-page refresh paths. + + UI layers should call this context boundary instead of invoking fetch modules + directly. + """ + def fetch_remote_object_strict(uri) when is_binary(uri) do + RemoteFetch.fetch_object_strict(uri) + end + + def fetch_remote_object_strict(_), do: {:error, :invalid_activitypub_id} + + @doc """ + Fetches a remote ActivityPub object for interactive UI paths, allowing recovery + through compatible instance APIs when the canonical object URL is not directly + fetchable. + """ + def fetch_remote_object(uri) when is_binary(uri) do + RemoteFetch.fetch_object_uncached(uri) + end + + def fetch_remote_object(_), do: {:error, :invalid_activitypub_id} + + @doc """ + Resolves a WebFinger handle through the federation fetch boundary. + """ + def webfinger_lookup(acct, opts \\ []) do + RemoteFetch.webfinger_lookup(acct, opts) + end + + @doc """ + Gets the instance domain for this server. + """ + def instance_domain do + Elektrine.Domains.instance_domain() + end + + @doc """ + Gets the instance base URL. + """ + def instance_url do + instance_url_for_domain(instance_domain()) + end + + @doc """ + Gets an instance-style base URL for a specific domain. + """ + def instance_url_for_domain(domain) when is_binary(domain) do + Domains.inferred_base_url_for_domain(domain) + end + + @doc """ + Returns the canonical local ActivityPub identifier for a user. + """ + def actor_identifier(%User{handle: handle, username: username}) do + if Elektrine.Strings.present?(handle), do: handle, else: username + end + + def actor_identifier(%{handle: handle, username: username}) do + if Elektrine.Strings.present?(handle), do: handle, else: username + end + + def actor_identifier(identifier) when is_binary(identifier) do + identifier + |> String.trim() + |> String.trim_leading("@") + |> String.downcase() + end + + def actor_identifier(_), do: nil + + @doc """ + Returns all local actor identifiers that should resolve for a user. + + The handle is canonical. The username remains as a compatibility alias. + """ + def actor_identifiers(%User{} = user) do + [actor_identifier(user), user.username] + |> Enum.filter(&Elektrine.Strings.present?/1) + |> Enum.uniq() + end + + @doc """ + Returns the local ActivityPub actor URI for a user or explicit identifier. + """ + def actor_uri(user_or_identifier, base_url \\ instance_url()) + + def actor_uri(%User{} = user, base_url) do + actor_uri(actor_identifier(user), base_url) + end + + def actor_uri(identifier, base_url) when is_binary(identifier) and is_binary(base_url) do + "#{String.trim_trailing(base_url, "/")}/users/#{actor_identifier(identifier)}" + end + + def actor_uri(_, _), do: nil + + @doc """ + Returns the legacy username-based actor URI for a local user. + """ + def actor_uri_by_username(user_or_identifier, base_url \\ instance_url()) + + def actor_uri_by_username(%User{username: username}, base_url) when is_binary(username) do + actor_uri(username, base_url) + end + + def actor_uri_by_username(_, _), do: nil + + @doc """ + Returns the local ActivityPub key id for a user or explicit identifier. + """ + def actor_key_id(user_or_identifier, base_url \\ instance_url()) + + def actor_key_id(%User{} = user, base_url) do + "#{actor_uri(user, base_url)}#main-key" + end + + def actor_key_id(identifier, base_url) when is_binary(identifier) and is_binary(base_url) do + "#{actor_uri(identifier, base_url)}#main-key" + end + + def actor_key_id(_, _), do: nil + + @doc """ + Returns a local actor collection URI for a user. + """ + def user_collection_uri(user_or_identifier, collection, base_url \\ instance_url()) + + def user_collection_uri(%User{} = user, collection, base_url) + when collection in ["inbox", "outbox", "followers", "following"] do + "#{actor_uri(user, base_url)}/#{collection}" + end + + def user_collection_uri(identifier, collection, base_url) + when is_binary(identifier) and collection in ["inbox", "outbox", "followers", "following"] do + "#{actor_uri(identifier, base_url)}/#{collection}" + end + + def user_collection_uri(%User{} = user, collection, base_url) when is_atom(collection) do + user_collection_uri(user, Atom.to_string(collection), base_url) + end + + def user_collection_uri(identifier, collection, base_url) + when is_binary(identifier) and is_atom(collection) do + user_collection_uri(identifier, Atom.to_string(collection), base_url) + end + + def user_collection_uri(_, _, _), do: nil + + @doc """ + Returns the local ActivityPub object URI for a user's status. + """ + def user_status_uri(user_or_identifier, message_id, base_url \\ instance_url()) + + def user_status_uri(%User{} = user, message_id, base_url) do + "#{actor_uri(user, base_url)}/statuses/#{message_id}" + end + + def user_status_uri(identifier, message_id, base_url) + when is_binary(identifier) and is_binary(base_url) do + "#{actor_uri(identifier, base_url)}/statuses/#{message_id}" + end + + @doc """ + Returns the canonical ActivityPub slug for a local community. + """ + def community_slug(name) when is_binary(name) do + name + |> String.trim() + |> String.downcase() + |> String.replace(~r/[^a-z0-9]+/, "-") + |> String.trim("-") + end + + @doc """ + Returns the local ActivityPub actor URI for a community. + """ + def community_actor_uri(name, base_url \\ instance_url()) when is_binary(name) do + "#{String.trim_trailing(base_url, "/")}/c/#{community_slug(name)}" + end + + @doc """ + Returns the inbox URI for a local community actor. + """ + def community_inbox_uri(name, base_url \\ instance_url()) when is_binary(name) do + "#{community_actor_uri(name, base_url)}/inbox" + end + + @doc """ + Returns the outbox URI for a local community actor. + """ + def community_outbox_uri(name, base_url \\ instance_url()) when is_binary(name) do + "#{community_actor_uri(name, base_url)}/outbox" + end + + @doc """ + Returns the followers collection URI for a local community actor. + """ + def community_followers_uri(name, base_url \\ instance_url()) when is_binary(name) do + "#{community_actor_uri(name, base_url)}/followers" + end + + @doc """ + Returns the moderators collection URI for a local community actor. + """ + def community_moderators_uri(name, base_url \\ instance_url()) when is_binary(name) do + "#{community_actor_uri(name, base_url)}/moderators" + end + + @doc """ + Returns the ActivityPub object URI for a local community post. + """ + def community_post_uri(name, message_id, base_url \\ instance_url()) when is_binary(name) do + "#{community_actor_uri(name, base_url)}/posts/#{message_id}" + end + + @doc """ + Returns the HTML community page URL. + """ + def community_web_url(name, base_url \\ instance_url()) when is_binary(name) do + "#{String.trim_trailing(base_url, "/")}/communities/#{encode_path_segment(name)}" + end + + @doc """ + Returns the HTML discussion post URL for a local community post. + """ + def community_post_web_url(name, message_id, base_url \\ instance_url()) + when is_binary(name) do + "#{community_web_url(name, base_url)}/post/#{message_id}" + end + + @doc """ + Resolves a local community from either its stored name or its ActivityPub slug. + """ + def get_community_by_identifier(identifier) when is_binary(identifier) do + normalized_identifier = + identifier + |> String.trim() + |> String.downcase() + + slug = community_slug(identifier) + + from(c in Conversation, + where: c.type == "community", + where: + fragment("LOWER(?)", c.name) == ^normalized_identifier or + fragment("regexp_replace(lower(?), '[^a-z0-9]+', '-', 'g')", c.name) == ^slug, + order_by: [ + desc: fragment("LOWER(?) = ?", c.name, ^normalized_identifier) + ], + limit: 1, + preload: [:creator] + ) + |> Repo.one() + end + + def get_community_by_identifier(_), do: nil + + defp encode_path_segment(segment) when is_binary(segment) do + URI.encode(segment, &URI.char_unreserved?/1) + end + + @doc """ + Returns local actor URI prefixes used to detect local `/users/:identifier` actor URLs. + """ + def local_actor_prefixes do + LocalReferences.actor_prefixes() + end + + @doc """ + Resolves a local actor/profile URI to the owning user's username. + + Canonical actor URLs use handles, but legacy username-based URLs still resolve. + """ + def local_username_from_uri(uri) when is_binary(uri) do + LocalReferences.local_username_from_uri(uri) + end + + def local_username_from_uri(uri), do: LocalReferences.local_username_from_uri(uri) + + @doc """ + Best-effort resolution of the local user targeted by an incoming activity. + + This is used for shared inbox processing so per-user moderation checks + still apply even when the request did not hit `/users/:username/inbox`. + """ + def resolve_target_user(activity) when is_map(activity) do + LocalReferences.resolve_target_user(activity) + end + + def resolve_target_user(activity), do: LocalReferences.resolve_target_user(activity) + + @doc """ + Best-effort resolution of the local user id targeted by an incoming activity. + """ + def resolve_target_user_id(activity) when is_map(activity) do + LocalReferences.resolve_target_user_id(activity) + end + + def resolve_target_user_id(activity), do: LocalReferences.resolve_target_user_id(activity) + + ## Actors + + @doc """ + Gets or creates a remote actor by their URI. + Fetches the actor document if not cached or stale. + Uses in-memory caching to reduce database load during high traffic. + """ + def get_or_fetch_actor(uri) do + case normalize_actor_uri_input(uri) do + normalized_uri when is_binary(normalized_uri) -> + Elektrine.AppCache.get_actor(normalized_uri, fn -> + do_get_or_fetch_actor(normalized_uri) + end) + + _ -> + {:error, :invalid_actor_uri} + end + end + + defp normalize_actor_uri_input(uri) when is_binary(uri) do + case String.trim(uri) do + "" -> nil + normalized_uri -> normalized_uri + end + end + + defp normalize_actor_uri_input(values) when is_list(values) do + Enum.find_value(values, &normalize_actor_uri_input/1) + end + + defp normalize_actor_uri_input(value) when is_map(value) do + value + |> Map.take(["id", "url", "href", :id, :url, :href]) + |> Map.values() + |> Enum.find_value(&normalize_actor_uri_input/1) + end + + defp normalize_actor_uri_input(_), do: nil + + defp do_get_or_fetch_actor(uri) do + case get_actor_by_uri(uri) do + nil -> + fetch_and_cache_actor(uri) + + %Actor{last_fetched_at: nil} = actor -> + # Never fetched, refresh + fetch_and_cache_actor(uri, actor) + + %Actor{last_fetched_at: last_fetched} = actor -> + # Refresh if older than 24 hours + if DateTime.diff(DateTime.utc_now(), last_fetched, :hour) > 24 do + fetch_and_cache_actor(uri, actor) + else + {:ok, actor} + end + end + end + + @doc """ + Gets an actor by their ActivityPub URI. + + Returns the oldest actor if duplicates exist, so duplicate rows do not crash + callers while the bad data is being cleaned up. + """ + def get_actor_by_uri(uri) do + from(a in Actor, + where: a.uri == ^uri, + order_by: [asc: a.inserted_at, asc: a.id], + limit: 1 + ) + |> Repo.one() + end + + @doc """ + Gets a remote actor by their database ID. + """ + def get_remote_actor(id) do + Repo.get(Actor, id) + end + + @doc """ + Gets an actor by username and domain. + Returns the oldest actor if duplicates exist (to be cleaned up by migration). + """ + def get_actor_by_username_and_domain(username, domain) do + import Ecto.Query + + from(a in Actor, + where: a.username == ^username and a.domain == ^domain, + order_by: [asc: a.inserted_at], + limit: 1 + ) + |> Repo.one() + end + + @doc """ + Fetches an actor document from a remote instance and caches it. + """ + def fetch_and_cache_actor(uri, existing_actor_or_opts \\ nil) + + def fetch_and_cache_actor(uri, opts) when is_list(opts) do + do_fetch_and_cache_actor(uri, nil, opts) + end + + def fetch_and_cache_actor(uri, existing_actor) do + do_fetch_and_cache_actor(uri, existing_actor, []) + end + + defp do_fetch_and_cache_actor(uri, existing_actor, opts) do + with {:ok, actor_data} <- RemoteFetch.fetch_actor(uri, opts), + :ok <- validate_fetched_actor_identity(uri, actor_data), + :ok <- validate_fetched_actor_urls(actor_data), + {:ok, actor_data} <- apply_actor_policies(actor_data, uri), + {:ok, actor} <- cache_actor(actor_data, existing_actor) do + {:ok, actor} + else + {:reject, reason} -> + Logger.info("MRF rejected actor document #{uri}: #{reason}") + {:error, :mrf_rejected} + + {:error, reason} -> + {:error, reason} + end + end + + defp apply_actor_policies(actor_data, _uri) when is_map(actor_data) do + case MRF.filter(actor_data) do + {:ok, filtered_actor_data} -> {:ok, filtered_actor_data} + {:reject, reason} -> {:reject, reason} + end + end + + defp apply_actor_policies(actor_data, _uri), do: {:ok, actor_data} + + defp validate_fetched_actor_identity(uri, %{"id" => actor_id} = actor_data) + when is_binary(uri) and is_binary(actor_id) and is_map(actor_data) do + if actor_identity_matches?(uri, actor_id, actor_data) do + :ok + else + Logger.warning( + "Rejected fetched actor due to id mismatch: requested=#{inspect(uri)} returned=#{inspect(actor_id)}" + ) + + {:error, :actor_id_mismatch} + end + end + + defp validate_fetched_actor_identity(_uri, _actor_data), do: {:error, :actor_id_mismatch} + + defp actor_identity_matches?(requested_uri, actor_id, actor_data) when is_map(actor_data) do + comparable_actor_uri(requested_uri) == comparable_actor_uri(actor_id) || + actor_username_alias_match?(requested_uri, actor_id, actor_data) + end + + defp actor_username_alias_match?(requested_uri, actor_id, actor_data) do + with %URI{host: requested_host} <- URI.parse(requested_uri), + %URI{host: actor_host} <- URI.parse(actor_id), + true <- is_binary(requested_host) and is_binary(actor_host), + true <- String.downcase(requested_host) == String.downcase(actor_host), + requested_username when is_binary(requested_username) <- + actor_uri_username(requested_uri), + fetched_username when is_binary(fetched_username) <- fetched_actor_username(actor_data) do + String.downcase(requested_username) == String.downcase(fetched_username) + else + _ -> false + end + end + + defp actor_uri_username(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{path: "/users/" <> username} -> username |> String.split("/", parts: 2) |> List.first() + %URI{path: "/@" <> username} -> username |> String.split("/", parts: 2) |> List.first() + _ -> nil + end + end + + defp actor_uri_username(_), do: nil + + defp fetched_actor_username(actor_data) when is_map(actor_data) do + actor_data["preferredUsername"] || + actor_data["preferred_username"] || + actor_data["name"] + end + + defp fetched_actor_username(_), do: nil + + defp validate_fetched_actor_urls(actor_data) when is_map(actor_data) do + actor_data + |> actor_document_urls() + |> Enum.reduce_while(:ok, fn {field, url}, :ok -> + case URLValidator.validate(url) do + :ok -> + {:cont, :ok} + + {:error, reason} -> + Logger.warning( + "Rejected fetched actor due to unsafe #{field} URL #{inspect(url)}: #{inspect(reason)}" + ) + + {:halt, {:error, :unsafe_actor_document}} + end + end) + end + + defp actor_document_urls(actor_data) do + [ + {:id, actor_data["id"]}, + {:inbox, actor_data["inbox"]}, + {:outbox, actor_data["outbox"]}, + {:followers, actor_data["followers"]}, + {:following, actor_data["following"]}, + {:shared_inbox, get_in(actor_data, ["endpoints", "sharedInbox"])} + ] + |> Enum.filter(fn {_field, url} -> Elektrine.Strings.present?(url) end) + end + + defp comparable_actor_uri(uri) when is_binary(uri) do + uri + |> String.trim() + |> case do + "" -> + nil + + trimmed -> + case URI.parse(trimmed) do + %URI{scheme: scheme, host: host} = parsed + when is_binary(scheme) and is_binary(host) and host != "" -> + normalized_path = + parsed.path + |> Kernel.||("/") + |> case do + "/" -> "/" + path -> String.trim_trailing(path, "/") + end + + parsed + |> Map.put(:scheme, String.downcase(scheme)) + |> Map.put(:host, String.downcase(host)) + |> Map.put(:path, normalized_path) + |> Map.put(:fragment, nil) + |> URI.to_string() + + _ -> + trimmed + end + end + end + + defp comparable_actor_uri(_), do: nil + + defp cache_actor(actor_data, existing_actor) do + uri = URI.parse(actor_data["id"]) + domain = uri.host + + # Ensure instance record exists + get_or_create_instance(domain) + + # Extract and cache custom emojis from actor profile + Async.start(fn -> + Elektrine.Emojis.process_activitypub_tags(actor_data["tag"], domain) + end) + + # Extract username from preferredUsername or id + username = + actor_data["preferredUsername"] || + extract_username_from_uri(actor_data["id"]) + + # Parse the published date from the actor data + published_at = + case actor_data["published"] do + nil -> + nil + + date_string -> + case DateTime.from_iso8601(date_string) do + {:ok, datetime, _} -> DateTime.truncate(datetime, :second) + _ -> nil + end + end + + attrs = %{ + uri: actor_data["id"], + username: username, + domain: domain, + display_name: actor_data["name"], + summary: actor_data["summary"], + avatar_url: get_icon_url(actor_data), + header_url: get_image_url(actor_data), + inbox_url: actor_data["inbox"], + outbox_url: actor_data["outbox"], + followers_url: actor_data["followers"], + following_url: actor_data["following"], + public_key: extract_public_key(actor_data), + manually_approves_followers: actor_data["manuallyApprovesFollowers"] || false, + actor_type: actor_data["type"] || "Person", + last_fetched_at: DateTime.utc_now(), + published_at: published_at, + moderators_url: actor_data["moderators"], + metadata: actor_data + } + + actor = existing_actor || get_actor_by_username_and_domain(attrs.username, attrs.domain) + + upsert_cached_actor(actor, attrs) + end + + defp upsert_cached_actor(%Actor{} = existing_actor, attrs) do + attrs = merge_existing_actor_metadata(existing_actor, attrs) + + existing_actor + |> Actor.changeset(attrs) + |> Repo.update() + end + + defp upsert_cached_actor(nil, attrs) do + insert_opts = [ + on_conflict: {:replace_all_except, [:id, :inserted_at]}, + conflict_target: :uri, + returning: true + ] + + case %Actor{} |> Actor.changeset(attrs) |> Repo.insert(insert_opts) do + {:ok, actor} -> + {:ok, actor} + + {:error, %Ecto.Changeset{} = changeset} -> + recover_actor_conflict(attrs, changeset) + end + end + + # During federation bursts, multiple requests can race to create the same actor + # (or a canonicalized variant such as trailing-slash URI differences). + # Recover by reloading and refreshing the existing row instead of failing. + defp recover_actor_conflict(attrs, original_changeset) do + actor = + get_actor_by_uri(attrs.uri) || + get_actor_by_username_and_domain(attrs.username, attrs.domain) + + case actor do + nil -> + {:error, original_changeset} + + %Actor{} = existing_actor -> + attrs = merge_existing_actor_metadata(existing_actor, attrs) + + case existing_actor |> Actor.changeset(attrs) |> Repo.update() do + {:ok, updated_actor} -> + {:ok, updated_actor} + + {:error, _changeset} -> + attrs_without_identity = Map.drop(attrs, [:uri, :username, :domain]) + + case existing_actor + |> Actor.changeset(attrs_without_identity) + |> Repo.update() do + {:ok, updated_actor} -> {:ok, updated_actor} + {:error, _} -> {:ok, existing_actor} + end + end + end + end + + defp merge_existing_actor_metadata(%Actor{} = existing_actor, attrs) when is_map(attrs) do + existing_metadata = normalize_actor_metadata(existing_actor.metadata) + incoming_metadata = normalize_actor_metadata(Map.get(attrs, :metadata)) + + Map.put(attrs, :metadata, Map.merge(existing_metadata, incoming_metadata)) + end + + defp merge_existing_actor_metadata(_, attrs), do: attrs + + defp normalize_actor_metadata(metadata) when is_map(metadata) do + Map.new(metadata, fn {key, value} -> {to_string(key), value} end) + end + + defp normalize_actor_metadata(_), do: %{} + + defp extract_username_from_uri(uri) do + # Extract username from URIs like https://mastodon.social/users/alice + uri + |> String.split("/") + |> List.last() + end + + defp get_icon_url(%{"icon" => %{"url" => url}}), do: url + defp get_icon_url(%{"icon" => url}) when is_binary(url), do: url + defp get_icon_url(_), do: nil + + defp get_image_url(%{"image" => %{"url" => url}}), do: url + defp get_image_url(%{"image" => url}) when is_binary(url), do: url + defp get_image_url(_), do: nil + + defp extract_public_key(%{"publicKey" => %{"publicKeyPem" => pem}}), do: pem + defp extract_public_key(_), do: nil + + ## Activities + + @doc """ + Creates an activity. + """ + def create_activity(attrs) do + %Activity{} + |> Activity.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Gets an activity by its ActivityPub ID. + """ + def get_activity_by_id(activity_id) do + Repo.get_by(Activity, activity_id: activity_id) + end + + defdelegate record_remote_delete_receipt(activity, actor_uri, object_id), to: Tombstones + defdelegate record_remote_tombstone(activity, actor_uri, object_id), to: Tombstones + defdelegate remote_delete_recorded?(actor_uri, object_refs), to: Tombstones + defdelegate remote_tombstone_recorded?(actor_uri, object_refs), to: Tombstones + + @doc """ + Gets the most recent local activity for a user, type, and object. + """ + def get_latest_local_activity(user_id, activity_type, object_id, opts \\ []) + + def get_latest_local_activity(_user_id, _activity_type, object_id, _opts) + when not is_binary(object_id) do + nil + end + + def get_latest_local_activity(user_id, activity_type, object_id, opts) do + content = Keyword.get(opts, :content) + + Activity + |> where( + [a], + a.internal_user_id == ^user_id and a.local == true and a.activity_type == ^activity_type and + a.object_id == ^object_id + ) + |> maybe_filter_activity_content(content) + |> order_by([a], desc: a.inserted_at, desc: a.id) + |> limit(1) + |> Repo.one() + end + + @doc """ + Lists activities for a user's outbox. + """ + def list_outbox_activities(user_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 20) + offset = Keyword.get(opts, :offset, 0) + + Activity + |> where([a], a.internal_user_id == ^user_id and a.local == true) + |> where([a], a.activity_type in ["Create", "Announce"]) + |> where_public_outbox_activity() + |> order_by([a], desc: a.inserted_at) + |> limit(^limit) + |> offset(^offset) + |> Repo.all() + end + + @doc """ + Counts local outbox activities for a user. + """ + def count_outbox_activities(user_id) do + Activity + |> where([a], a.internal_user_id == ^user_id and a.local == true) + |> where([a], a.activity_type in ["Create", "Announce"]) + |> where_public_outbox_activity() + |> Repo.aggregate(:count, :id) + end + + defp maybe_filter_activity_content(query, nil), do: query + + defp maybe_filter_activity_content(query, content) when is_binary(content) do + where(query, [a], fragment("?->>'content' = ?", a.data, ^content)) + end + + defp where_public_outbox_activity(query) do + where( + query, + [a], + fragment( + """ + jsonb_exists(COALESCE(?->'to', '[]'::jsonb), ?) OR + jsonb_exists(COALESCE(?->'cc', '[]'::jsonb), ?) OR + jsonb_exists(COALESCE(?->'object'->'to', '[]'::jsonb), ?) OR + jsonb_exists(COALESCE(?->'object'->'cc', '[]'::jsonb), ?) + """, + a.data, + ^@public_audience_uri, + a.data, + ^@public_audience_uri, + a.data, + ^@public_audience_uri, + a.data, + ^@public_audience_uri + ) + ) + end + + ## Deliveries + + @doc """ + Creates delivery records for an activity to be sent to remote inboxes. + Also enqueues Oban jobs for processing. + """ + def create_deliveries(activity_id, inbox_urls) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + activity = Repo.get(Activity, activity_id) + object_id = activity && (activity.object_id || object_id_from_activity_data(activity.data)) + + record_object_deliveries(object_id, activity_id, inbox_urls) + + deliveries = + inbox_urls + |> deliverable_inboxes_for_activity(activity) + |> Enum.map(fn inbox_url -> + %{ + activity_id: activity_id, + inbox_url: inbox_url, + status: "pending", + attempts: 0, + inserted_at: now, + updated_at: now + } + end) + + {count, inserted} = Repo.insert_all(Delivery, deliveries, returning: [:id]) + + # Enqueue Oban jobs for the inserted deliveries + if count > 0 do + delivery_ids = Enum.map(inserted, & &1.id) + ActivityDeliveryWorker.enqueue_many(delivery_ids) + end + + {count, inserted} + end + + defp deliverable_inboxes_for_activity(inbox_urls, %Activity{activity_type: type}) + when type in ["Delete", "Update"] do + Enum.uniq(inbox_urls) + end + + defp deliverable_inboxes_for_activity(inbox_urls, _activity) do + DomainDeliveryHealth.filter_deliverable_inboxes(inbox_urls) + end + + defp object_id_from_activity_data(%{"object" => object}) when is_binary(object), do: object + defp object_id_from_activity_data(%{"object" => %{"id" => id}}) when is_binary(id), do: id + defp object_id_from_activity_data(_), do: nil + + defdelegate record_object_deliveries(object_id, activity_id, inbox_urls), to: ObjectDeliveries + defdelegate get_object_delivery_inboxes(object_id), to: ObjectDeliveries + + @doc """ + Gets a delivery by ID with its associated activity. + """ + def get_delivery(delivery_id) do + Delivery + |> where([d], d.id == ^delivery_id) + |> preload(:activity) + |> Repo.one() + end + + @doc """ + Gets pending deliveries that are ready to be attempted. + """ + def get_pending_deliveries(limit \\ 100) do + now = DateTime.utc_now() + + Delivery + |> where([d], d.status == "pending") + |> where([d], is_nil(d.next_retry_at) or d.next_retry_at <= ^now) + |> where([d], d.attempts < 10) + |> limit(^limit) + |> preload(:activity) + |> Repo.all() + end + + @doc """ + Gets delivery IDs that are pending and ready to be retried. + """ + def get_retryable_delivery_ids(limit \\ 500) do + started_at = System.monotonic_time(:millisecond) + now = DateTime.utc_now() + + retryable_deliveries = + Delivery + |> where([d], d.status == "pending") + |> where([d], is_nil(d.next_retry_at) or d.next_retry_at <= ^now) + |> where([d], d.attempts < 10) + |> order_by([d], asc: d.updated_at) + |> limit(^limit) + |> select([d], {d.id, d.inbox_url}) + |> Repo.all() + + delivery_ids = + retryable_deliveries + |> Enum.filter(fn {_id, inbox_url} -> DomainDeliveryHealth.deliverable_url?(inbox_url) end) + |> Enum.map(fn {id, _inbox_url} -> id end) + + Events.db_hot_path( + :activitypub, + :get_retryable_delivery_ids, + System.monotonic_time(:millisecond) - started_at, + %{limit: limit, result_count: length(delivery_ids)} + ) + + delivery_ids + end + + @doc """ + Marks a delivery as delivered. + """ + def mark_delivery_delivered(delivery_id) do + case Repo.get(Delivery, delivery_id) |> Repo.preload(:activity) do + nil -> + {:error, :not_found} + + delivery -> + result = + delivery + |> Delivery.changeset(%{ + status: "delivered", + last_attempt_at: DateTime.utc_now() + }) + |> Repo.update() + + if match?({:ok, _}, result) do + DomainDeliveryHealth.record_delivery_success(delivery.inbox_url) + ObjectDeliveries.mark_object_delivery_delivered(delivery) + end + + result + end + end + + @doc """ + Cleans up old failed deliveries (older than 7 days). + Returns count of deleted deliveries. + """ + def cleanup_old_deliveries do + seven_days_ago = DateTime.utc_now() |> DateTime.add(-7, :day) + + {count, _} = + from(d in Delivery, + where: d.status == "failed" and d.inserted_at < ^seven_days_ago + ) + |> Repo.delete_all() + + count + RequestReplayCache.prune_expired() + end + + @doc """ + Marks a delivery as failed and schedules retry. + """ + def mark_delivery_failed(delivery_id, error_message) do + case Repo.get(Delivery, delivery_id) do + nil -> + {:error, :not_found} + + delivery -> + DomainDeliveryHealth.record_delivery_failure(delivery.inbox_url, error_message) + attempts = delivery.attempts + 1 + + # Exponential backoff: 5min, 15min, 1hr, 3hr, 12hr, then 24hr per attempt + # until the attempt-10 cutoff below. Attempts 7-9 must keep a real + # backoff — leaving next_retry_at nil makes the retry query treat them + # as immediately due, firing 7→10 back-to-back with no delay. + next_retry_minutes = + case attempts do + 1 -> 5 + 2 -> 15 + 3 -> 60 + 4 -> 180 + 5 -> 720 + _ -> 1440 + end + + next_retry_at = + DateTime.add(DateTime.utc_now(), next_retry_minutes * 60, :second) + + status = if attempts >= 10, do: "failed", else: "pending" + + delivery + |> Delivery.changeset(%{ + status: status, + attempts: attempts, + last_attempt_at: DateTime.utc_now(), + next_retry_at: next_retry_at, + error_message: error_message + }) + |> Repo.update() + end + end + + ## Instances + + @doc """ + Gets or creates an instance record. + """ + def get_or_create_instance(domain) do + domain = normalize_instance_domain(domain) + + case get_instance_by_domain(domain) do + nil -> + %Instance{} + |> Instance.changeset(%{domain: domain}) + |> Repo.insert() + |> case do + {:ok, instance} -> + {:ok, instance} + + {:error, _changeset} -> + case get_instance_by_domain(domain) do + %Instance{} = instance -> {:ok, instance} + nil -> {:error, :instance_insert_failed} + end + end + + instance -> + {:ok, instance} + end + end + + @doc """ + Checks if an instance is blocked. + """ + def instance_blocked?(domain) do + case get_instance_by_domain(domain) do + %Instance{blocked: true} -> true + _ -> false + end + end + + defp get_instance_by_domain(domain) when is_binary(domain) do + normalized = normalize_instance_domain(domain) + + Instance + |> where([i], fragment("lower(?)", i.domain) == ^normalized) + |> order_by([i], asc: i.id) + |> limit(1) + |> Repo.one() + end + + defp get_instance_by_domain(_), do: nil + + defp normalize_instance_domain(domain) when is_binary(domain) do + domain + |> String.trim() + |> String.downcase() + end + + defp normalize_instance_domain(domain), do: domain + + @doc """ + Blocks an instance. + """ + def block_instance(domain, reason, admin_user_id) do + case get_or_create_instance(domain) do + {:ok, instance} -> + result = + instance + |> Instance.changeset(%{ + blocked: true, + reason: reason, + blocked_by_id: admin_user_id, + blocked_at: DateTime.utc_now() + }) + |> Repo.update() + + notify_all_home_feeds(:instance_blocked) + result + + error -> + error + end + end + + ## User blocks + + @doc """ + Blocks a remote actor or domain for a specific user. + """ + def block_for_user(user_id, blocked_uri, type \\ "user") do + result = + %UserBlock{} + |> UserBlock.changeset(%{ + user_id: user_id, + blocked_uri: blocked_uri, + block_type: type + }) + |> Repo.insert() + + notify_home_feed_policy_changed(user_id, :activitypub_blocked) + result + end + + @doc """ + Checks if a user has blocked an actor or domain. + """ + def user_blocked?(user_id, uri) do + # Check both direct URI block and domain block + %URI{host: domain} = URI.parse(uri) + + UserBlock + |> where([b], b.user_id == ^user_id) + |> where( + [b], + b.blocked_uri == ^uri or (b.block_type == "domain" and b.blocked_uri == ^domain) + ) + |> Repo.exists?() + end + + ## Relays (stub functions for now - full relay implementation exists in relay_manager.ex) + + @doc """ + Gets all relay inbox URLs for sending activities. + """ + def get_relay_inboxes do + from(s in RelaySubscription, + where: s.status == "active" and s.accepted == true and not is_nil(s.relay_inbox), + select: s.relay_inbox + ) + |> Repo.all() + |> Enum.uniq() + end + + @doc """ + Returns true when the actor URI belongs to an accepted relay subscription. + """ + def active_relay_actor_uri?(actor_uri) when is_binary(actor_uri) do + RelaySubscription + |> where([s], s.relay_uri == ^actor_uri and s.status == "active" and s.accepted == true) + |> Repo.exists?() + end + + def active_relay_actor_uri?(_), do: false + + ## Community/Group Actors + + @doc """ + Gets or creates an ActivityPub Group actor for a local community. + """ + def get_or_create_community_actor(community_id) when is_integer(community_id) do + case Repo.get_by(Actor, community_id: community_id) do + nil -> + # Fetch the community + case Conversations.get_conversation_basic(community_id) do + {:ok, community} -> + create_community_actor(community) + + {:error, _} -> + {:error, :community_not_found} + end + + actor -> + ensure_community_actor_keys(actor) + end + end + + defp create_community_actor(community) do + base_url = instance_url() + community_slug = community_slug(community.name) + actor_url = community_actor_uri(community.name, base_url) + {public_key, private_key} = HTTPSignature.generate_key_pair() + + attrs = %{ + uri: actor_url, + username: community_slug, + domain: instance_domain(), + display_name: community.name, + summary: community.description, + avatar_url: community.avatar_url, + inbox_url: community_inbox_uri(community.name, base_url), + outbox_url: community_outbox_uri(community.name, base_url), + followers_url: community_followers_uri(community.name, base_url), + moderators_url: community_moderators_uri(community.name, base_url), + public_key: public_key, + manually_approves_followers: !community.is_public, + actor_type: "Group", + community_id: community.id, + published_at: community.inserted_at, + last_fetched_at: DateTime.utc_now(), + metadata: Actor.put_metadata_private_key(%{}, private_key) + } + + case %Actor{} + |> Actor.changeset(attrs) + |> Repo.insert() do + {:ok, actor} -> ensure_community_actor_keys(actor) + error -> error + end + end + + defp ensure_community_actor_keys(%Actor{} = actor) do + case KeyManager.ensure_user_has_keys(actor) do + {:ok, actor_with_keys} -> {:ok, actor_with_keys} + {:error, _reason} -> {:ok, actor} + end + end + + @doc """ + Gets a community actor by community name. + """ + def get_community_actor_by_name(name) do + community_slug = community_slug(name) + + Repo.get_by(Actor, + username: community_slug, + domain: instance_domain(), + actor_type: "Group" + ) + end + + @doc """ + Fetches a remote user's timeline from their outbox. + Returns a list of post objects from their public timeline. + """ + def fetch_remote_user_timeline(remote_actor_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 20) + fetcher = Keyword.get(opts, :fetcher, Fetcher) + + case Repo.get(Actor, remote_actor_id) do + nil -> + {:error, :actor_not_found} + + %Actor{outbox_url: nil} -> + {:error, :no_outbox_url} + + %Actor{outbox_url: outbox_url} -> + case fetcher.fetch_object(outbox_url) do + {:ok, outbox_data} -> + posts = extract_posts_from_outbox(outbox_data, limit, fetcher) + {:ok, posts} + + {:error, reason} -> + {:error, reason} + end + end + end + + defp extract_posts_from_outbox(outbox_data, limit, fetcher) do + outbox_data + |> collect_outbox_items(limit, fetcher) + |> Enum.map(fn item -> + case item do + # Create activity wrapping an object + %{"type" => "Create", "object" => %{"type" => type} = object} + when type in ["Note", "Article", "Question", "Page"] -> + object + + # Announce activity wrapping a Create activity + %{ + "type" => "Announce", + "object" => %{"type" => "Create", "object" => %{"type" => type} = inner_object} + } + when type in ["Note", "Article", "Question", "Page"] -> + inner_object + + # Announce activity with direct embedded object (some instances) + %{"type" => "Announce", "object" => %{"type" => type} = object} + when type in ["Note", "Article", "Question", "Page"] -> + object + + # Announce activity where object is a URL + %{"type" => "Announce", "object" => object_url} when is_binary(object_url) -> + case fetcher.fetch_object(object_url) do + {:ok, %{"type" => type} = object} + when type in ["Note", "Article", "Question", "Page"] -> + object + + {:ok, %{"type" => "Create", "object" => %{"type" => type} = inner_object}} + when type in ["Note", "Article", "Question", "Page"] -> + inner_object + + _ -> + nil + end + + # Direct Note/Page object (some instances send these directly) + %{"type" => type} = object when type in ["Note", "Article", "Question", "Page"] -> + object + + # URL string - need to fetch the object + url when is_binary(url) -> + case fetcher.fetch_object(url) do + {:ok, %{"type" => type} = object} + when type in ["Note", "Article", "Question", "Page"] -> + object + + {:ok, %{"type" => "Create", "object" => %{"type" => type} = inner_object}} + when type in ["Note", "Article", "Question", "Page"] -> + inner_object + + _ -> + nil + end + + _ -> + nil + end + end) + |> Enum.filter(fn post -> + post && + post["inReplyTo"] in [nil, ""] && + post["type"] in [ + "Page", + "Article", + "Question", + # Mastodon/Pleroma top-level statuses are usually Note. + # Comments are filtered by inReplyTo above. + "Note" + ] + end) + |> Enum.take(limit) + end + + defp collect_outbox_items(outbox_data, limit, fetcher) do + {items, next_page_url} = extract_outbox_page(outbox_data) + + if length(items) >= limit or is_nil(next_page_url) do + items + else + collect_next_outbox_items(items, next_page_url, limit, fetcher, MapSet.new()) + end + end + + defp collect_next_outbox_items(items, _next_page_url, limit, _fetcher, _visited_urls) + when length(items) >= limit, + do: items + + defp collect_next_outbox_items(items, nil, _limit, _fetcher, _visited_urls), do: items + + defp collect_next_outbox_items(items, next_page_url, limit, fetcher, visited_urls) do + if MapSet.member?(visited_urls, next_page_url) do + items + else + visited_urls = MapSet.put(visited_urls, next_page_url) + + case fetcher.fetch_object(next_page_url) do + {:ok, page} -> + {page_items, following_page_url} = extract_outbox_page(page) + combined_items = items ++ page_items + + if length(combined_items) >= limit or is_nil(following_page_url) do + combined_items + else + collect_next_outbox_items( + combined_items, + following_page_url, + limit, + fetcher, + visited_urls + ) + end + + _ -> + items + end + end + end + + defp extract_outbox_page(outbox_data) do + case outbox_data do + %{"orderedItems" => items} when is_list(items) -> + {items, outbox_data["next"]} + + %{"items" => items} when is_list(items) -> + {items, outbox_data["next"]} + + # Handle first as a URL string + %{"first" => first_page_url} when is_binary(first_page_url) -> + {[], first_page_url} + + # Handle first as an object with id + %{"first" => %{"id" => first_page_url}} when is_binary(first_page_url) -> + {[], first_page_url} + + # Handle first as an object with items directly + %{"first" => %{"orderedItems" => items} = first} when is_list(items) -> + {items, first["next"]} + + %{"first" => %{"items" => items} = first} when is_list(items) -> + {items, first["next"]} + + _ -> + {[], nil} + end + end + + @doc """ + Fetches replies for a remote post. + Returns a list of reply objects. + """ + def fetch_remote_post_replies(post_object, opts \\ []) do + limit = opts |> Keyword.get(:limit, 10) |> ReplyFetchPolicy.clamp_collection_limit() + post_id = post_object["id"] + post_url = post_object["url"] || post_id + + # Link posts often use the external article URL as object.url. Keep using that for + # rendering, but use the ActivityPub/community post URL when deciding how to load + # comments so Lemmy-style replies aren't fetched from the submitted link target. + community_post_ref = + cond do + lemmy_post_url?(post_id) -> post_id + lemmy_post_url?(post_url) -> post_url + true -> post_id || post_url + end + + lemmy_post? = post_object["type"] == "Page" && lemmy_post_url?(community_post_ref) + + # Prefer standard replies collection, but accept comments collections used by + # some platforms. + replies_url = + extract_collection_url(post_object["replies"]) || + extract_collection_url(post_object["comments"]) + + expected_replies = + [ + normalize_reply_count(post_object["repliesCount"]), + normalize_reply_count(collection_total_items(post_object["replies"])), + normalize_reply_count(collection_total_items(post_object["comments"])) + ] + |> Enum.max(fn -> 0 end) + + has_replies_count = expected_replies > 0 + post_id = post_id || post_url + + cond do + # Lemmy community posts are more reliably served by the instance API than the + # AP replies collection, which is often empty or requires extra indirection. + lemmy_post? -> + case fetch_lemmy_comments(community_post_ref, limit) do + {:ok, replies} when replies != [] -> + {:ok, replies} + + _ when replies_url != nil -> + fetch_replies_from_collection(replies_url, limit, community_post_ref) + + other -> + other + end + + # Standard ActivityPub replies collection + replies_url != nil -> + case fetch_replies_from_collection(replies_url, limit, post_id || post_url) do + {:ok, replies} when replies != [] -> + {:ok, replies} + + other -> + # ActivityPub collection returned empty but we expect replies - try + # Mastodon-compatible context endpoints when the post URL supports them. + if expected_replies > 0 and is_binary(post_url) and + MastodonApi.mastodon_compatible?(%{activitypub_id: post_url}) do + fetch_mastodon_context_replies(post_url, limit) + else + other + end + end + + # Has replies count but no replies field - try Mastodon-compatible context APIs. + has_replies_count && is_binary(post_url) && + MastodonApi.mastodon_compatible?(%{activitypub_id: post_url}) -> + case fetch_mastodon_context_replies(post_url, limit) do + {:ok, replies} when replies != [] -> {:ok, replies} + _ -> fetch_pleroma_replies(post_id, limit) + end + + true -> + {:ok, []} + end + end + + defp extract_collection_url(%{"first" => %{"id" => id}}) when is_binary(id), do: id + defp extract_collection_url(%{"first" => first_url}) when is_binary(first_url), do: first_url + defp extract_collection_url(%{"id" => id}) when is_binary(id), do: id + defp extract_collection_url(url) when is_binary(url), do: url + defp extract_collection_url(_), do: nil + + defp collection_total_items(collection) when is_map(collection) do + Map.get(collection, "totalItems") || Map.get(collection, :totalItems) + end + + defp collection_total_items(_), do: nil + + defp normalize_reply_count(value) when is_integer(value), do: max(value, 0) + + defp normalize_reply_count(value) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {parsed, _} -> max(parsed, 0) + :error -> 0 + end + end + + defp normalize_reply_count(_), do: 0 + + # Fetch replies using Mastodon's /api/v1/statuses/:id/context endpoint + defp fetch_mastodon_context_replies(post_url, limit) do + case MastodonApi.fetch_status_context(post_url) do + {:ok, descendants} -> + # Convert Mastodon API format to ActivityPub-like objects + replies = + descendants + |> Enum.take(limit) + |> Enum.map(fn status -> + in_reply_to = + cond do + is_binary(status.in_reply_to_uri) -> + status.in_reply_to_uri + + status.in_reply_to_id -> + post_url + + true -> + nil + end + + %{ + "id" => status.uri || status.url || "#{post_url}#status-#{status.id}", + "url" => status.url || status.uri, + "type" => "Note", + "content" => status.content, + "attributedTo" => status.account[:url] || status.account[:uri], + "published" => status.created_at, + "inReplyTo" => in_reply_to, + "likes" => %{"totalItems" => status.favourites_count}, + "shares" => %{"totalItems" => status.reblogs_count}, + "replies" => %{"totalItems" => status.replies_count}, + "_mastodon_account" => status.account + } + end) + |> supplement_mastodon_thread_replies(post_url, limit) + |> ReplyFetchPolicy.filter_same_host_replies(post_url) + + {:ok, replies} + + {:error, _} -> + {:ok, []} + end + end + + defp supplement_mastodon_thread_replies(replies, _post_url, limit) + when not is_list(replies) or limit <= 0 do + Enum.take(List.wrap(replies), max(limit, 0)) + end + + defp supplement_mastodon_thread_replies(replies, post_url, limit) do + seen_ids = + replies + |> Enum.map(& &1["id"]) + |> Enum.filter(&is_binary/1) + |> MapSet.new() + + additional_replies = + [post_url | replies] + |> collect_supplemental_thread_replies(limit, seen_ids, []) + |> ReplyFetchPolicy.filter_same_host_replies(post_url) + + merge_unique_replies(replies, additional_replies, limit) + end + + defp collect_supplemental_thread_replies([], _limit, _seen_ids, acc), do: Enum.reverse(acc) + + defp collect_supplemental_thread_replies(_queue, limit, _seen_ids, acc) + when length(acc) >= limit do + acc |> Enum.reverse() |> Enum.take(limit) + end + + defp collect_supplemental_thread_replies([seed | rest], limit, seen_ids, acc) do + remaining = max(limit - length(acc), 0) + + case fetch_thread_seed_object(seed) do + %{"replies" => replies_collection} when not is_nil(replies_collection) -> + fetched_replies = fetch_supplemental_reply_page(replies_collection, remaining) + + {fresh_replies, updated_seen_ids} = + Enum.reduce(fetched_replies, {[], seen_ids}, fn reply, {fresh, seen} -> + case reply["id"] do + id when is_binary(id) -> + if MapSet.member?(seen, id) do + {fresh, seen} + else + {[reply | fresh], MapSet.put(seen, id)} + end + + _ -> + {[reply | fresh], seen} + end + end) + + fresh_replies = Enum.reverse(fresh_replies) + + next_queue = + rest ++ + Enum.filter(fresh_replies, fn reply -> + reply_has_nested_replies?(reply) + end) + + collect_supplemental_thread_replies( + next_queue, + limit, + updated_seen_ids, + fresh_replies ++ acc + ) + + %{} = object -> + next_queue = if(reply_has_nested_replies?(object), do: rest ++ [object], else: rest) + collect_supplemental_thread_replies(next_queue, limit, seen_ids, acc) + + _ -> + collect_supplemental_thread_replies(rest, limit, seen_ids, acc) + end + end + + defp fetch_thread_seed_object(%{} = object), do: normalize_thread_reply_object(object) + + defp fetch_thread_seed_object(seed) when is_binary(seed) do + case RemoteFetch.fetch_object(seed) do + {:ok, object} -> normalize_thread_reply_object(object) + _ -> nil + end + end + + defp fetch_thread_seed_object(_), do: nil + + defp normalize_thread_reply_object(%{"type" => type} = object) + when type in ["Note", "Article", "Page", "Question"] do + object + end + + defp normalize_thread_reply_object(%{"object" => %{} = object}) do + normalize_thread_reply_object(object) + end + + defp normalize_thread_reply_object(_), do: nil + + defp fetch_supplemental_reply_page(replies_collection, limit) when limit > 0 do + case CollectionFetcher.fetch_collection(replies_collection, max_items: limit) do + {:ok, items} -> normalize_thread_reply_items(items, limit) + {:partial, items} -> normalize_thread_reply_items(items, limit) + _ -> [] + end + end + + defp fetch_supplemental_reply_page(_, _), do: [] + + defp normalize_thread_reply_items(items, limit) do + items + |> Enum.map(&normalize_thread_reply_object/1) + |> Enum.reject(&is_nil/1) + |> Enum.take(limit) + end + + defp reply_has_nested_replies?(%{} = reply) do + replies = reply["replies"] + + cond do + is_map(replies) -> true + is_binary(replies) -> true + true -> Elektrine.ActivityPub.Helpers.extract_interaction_count(reply, "replies") > 0 + end + end + + defp reply_has_nested_replies?(_), do: false + + defp merge_unique_replies(primary, secondary, limit) do + {merged, _seen} = + Enum.reduce(primary ++ secondary, {[], MapSet.new()}, fn reply, {acc, seen} -> + case reply["id"] do + id when is_binary(id) -> + if MapSet.member?(seen, id) do + {acc, seen} + else + {[reply | acc], MapSet.put(seen, id)} + end + + _ -> + {[reply | acc], seen} + end + end) + + merged + |> Enum.reverse() + |> Enum.take(limit) + end + + # Check if URL is a community-post format across Lemmy/PieFed/Mbin. + defp lemmy_post_url?(url) when is_binary(url) do + LemmyApi.community_post_url?(url) + end + + defp lemmy_post_url?(_), do: false + + # Fetch comments from instance API + defp fetch_lemmy_comments(post_url, limit) do + case resolve_lemmy_post_reference(post_url) do + {:ok, domain, post_id} -> + # First try the origin domain + case fetch_lemmy_comments_from_instance(domain, post_id, post_url, limit) do + {:ok, comments} when comments != [] -> + {:ok, comments} + + _ -> + # If empty, try to find the community's home instance + # The post might be federated from another instance + try_fetch_from_community_instance(post_url, limit) + end + + :error -> + {:ok, []} + end + end + + defp resolve_lemmy_post_reference(post_url) do + case Regex.run(~r{https?://([^/]+)/post/(\d+)}, post_url) do + [_, domain, post_id] -> + {:ok, domain, post_id} + + _ -> + case Regex.run(~r{https?://([^/]+)/c/[^/]+/p/(\d+)}, post_url) do + [_, domain, post_id] -> + {:ok, domain, post_id} + + _ -> + case Regex.run(~r{https?://([^/]+)/m/[^/]+/[pt]/(\d+)}, post_url) do + [_, domain, post_id] -> + {:ok, domain, post_id} + + _ -> + resolve_lemmy_post_reference_via_api(post_url) + end + end + end + end + + defp resolve_lemmy_post_reference_via_api(post_url) do + case URI.parse(post_url) do + %URI{host: domain} when is_binary(domain) -> + resolve_url = "https://#{domain}/api/v4/resolve_object?q=#{URI.encode_www_form(post_url)}" + + case safe_get_lemmy_api(resolve_url, [{"Accept", "application/json"}], + receive_timeout: 10_000 + ) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, %{"post" => %{"post" => %{"id" => post_id}}}} when is_integer(post_id) -> + {:ok, domain, Integer.to_string(post_id)} + + {:ok, %{"post" => %{"post" => %{"id" => post_id}}}} when is_binary(post_id) -> + {:ok, domain, post_id} + + _ -> + :error + end + + _ -> + :error + end + + _ -> + :error + end + end + + # Try to fetch comments from the community's home instance + defp try_fetch_from_community_instance(post_url, limit) do + # Fetch the post object to get the audience (community) + case RemoteFetch.fetch_object(post_url) do + {:ok, post_object} -> + # Get the community URL from audience field + community_url = post_object["audience"] + + if is_binary(community_url) do + # Extract community's home domain + case URI.parse(community_url) do + %URI{host: community_domain} when is_binary(community_domain) -> + # Resolve the post on the community's home instance + resolve_url = + "https://#{community_domain}/api/v4/resolve_object?q=#{URI.encode_www_form(post_url)}" + + case safe_get_lemmy_api(resolve_url, [{"Accept", "application/json"}], + receive_timeout: 10_000 + ) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, %{"post" => %{"post" => %{"id" => local_post_id}}}} -> + # Now fetch comments using the local post ID + fetch_lemmy_comments_from_instance( + community_domain, + to_string(local_post_id), + post_url, + limit + ) + + _ -> + {:ok, []} + end + + _ -> + {:ok, []} + end + + _ -> + {:ok, []} + end + else + {:ok, []} + end + + _ -> + {:ok, []} + end + end + + # Fetch comments from a specific instance + defp fetch_lemmy_comments_from_instance(domain, post_id, post_url, limit) do + replies = LemmyApi.fetch_post_comments_from_instance(domain, post_id, post_url, limit) + + {:ok, replies} + end + + # Fetch replies using context API (Mastodon/Pleroma/Akkoma) + defp fetch_pleroma_replies(post_ap_id, limit) do + require Logger + + # Extract domain from the ActivityPub ID + case URI.parse(post_ap_id) do + %URI{host: domain} when is_binary(domain) -> + # Step 1: Search for the post to get its internal ID + search_url = + "https://#{domain}/api/v2/search?q=#{URI.encode_www_form(post_ap_id)}&type=statuses&resolve=true&limit=1" + + case safe_get(search_url, [{"Accept", "application/json"}], receive_timeout: 15_000) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, %{"statuses" => [%{"id" => status_id, "uri" => root_uri} = root_status | _]}} -> + # Step 2: Fetch context (ancestors + descendants) + context_url = "https://#{domain}/api/v1/statuses/#{status_id}/context" + + case safe_get(context_url, [{"Accept", "application/json"}], + receive_timeout: 15_000 + ) do + {:ok, %Finch.Response{status: 200, body: context_body}} -> + case Jason.decode(context_body) do + {:ok, %{"ancestors" => ancestors, "descendants" => descendants}} + when is_list(descendants) -> + # Build a lookup map from local ID -> AP URI + # Include the root post, ancestors, and descendants + all_statuses = [root_status | ancestors] ++ descendants + + id_to_uri_map = + all_statuses + |> Enum.map(fn s -> {s["id"], s["uri"] || s["url"]} end) + |> Map.new() + + # Convert Mastodon API statuses to ActivityPub-like objects + # with proper inReplyTo URIs + replies = + descendants + |> Enum.take(limit) + |> Enum.map( + &mastodon_status_to_activitypub(&1, id_to_uri_map, post_ap_id) + ) + |> Enum.filter(&(&1 != nil)) + + Logger.debug( + "Fetched #{length(replies)} replies from context API for #{post_ap_id}" + ) + + {:ok, replies} + + {:ok, %{"descendants" => descendants}} when is_list(descendants) -> + # Fallback if no ancestors field - use root post for mapping + id_to_uri_map = %{status_id => root_uri} + + replies = + descendants + |> Enum.take(limit) + |> Enum.map( + &mastodon_status_to_activitypub(&1, id_to_uri_map, post_ap_id) + ) + |> Enum.filter(&(&1 != nil)) + + {:ok, replies} + + _ -> + {:ok, []} + end + + {:error, reason} -> + Logger.warning( + "Failed to fetch context for #{post_ap_id}: #{inspect(reason)}" + ) + + {:ok, []} + + _ -> + {:ok, []} + end + + _ -> + Logger.debug("Post not found in search: #{post_ap_id}") + {:ok, []} + end + + {:error, reason} -> + Logger.warning("Failed to search for #{post_ap_id}: #{inspect(reason)}") + {:ok, []} + + _ -> + {:ok, []} + end + + _ -> + {:ok, []} + end + end + + # Convert a Mastodon API status to an ActivityPub-like object + # id_to_uri_map maps local status IDs to their ActivityPub URIs + # root_post_uri is the URI of the post we're fetching replies for + defp mastodon_status_to_activitypub(status, id_to_uri_map, root_post_uri) do + # Extract the ActivityPub URI if available + ap_id = status["uri"] || status["url"] + + # Resolve inReplyTo: look up the parent's AP URI from the map + # Fall back to akkoma/pleroma extensions, then to root post + in_reply_to = + cond do + # Check Akkoma extension first (provides direct AP ID) + ap_uri = get_in(status, ["akkoma", "in_reply_to_apid"]) -> + ap_uri + + # Check Pleroma extension (note: in_reply_to_account_acct is the account, not the post) + get_in(status, ["pleroma", "in_reply_to_account_acct"]) -> + # This is the account acct, not the post URI - fall through to ID lookup + nil + + # Look up from our ID map + parent_id = status["in_reply_to_id"] -> + Map.get(id_to_uri_map, parent_id) + + true -> + nil + end + + # If we couldn't resolve inReplyTo but we know there is one, + # assume it's the root post (for direct replies) + in_reply_to = + if is_nil(in_reply_to) && status["in_reply_to_id"] do + root_post_uri + else + in_reply_to + end + + %{ + "id" => ap_id, + "type" => "Note", + "content" => status["content"], + "published" => status["created_at"], + "attributedTo" => get_in(status, ["account", "url"]) || get_in(status, ["account", "uri"]), + "inReplyTo" => in_reply_to, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "sensitive" => status["sensitive"] || false, + "summary" => status["spoiler_text"], + # Store original API data for reference + "_mastodon" => %{ + "id" => status["id"], + "in_reply_to_id" => status["in_reply_to_id"], + "account" => status["account"], + "favourites_count" => status["favourites_count"], + "reblogs_count" => status["reblogs_count"], + "replies_count" => status["replies_count"] + } + } + end + + # Fetch replies from standard ActivityPub collection + defp fetch_replies_from_collection(replies_url, limit, root_ref) do + case RemoteFetch.fetch_object(replies_url) do + {:ok, replies_data} -> + {items, next_page} = extract_items_from_collection(replies_data) + + # If items is empty but there's a next page, fetch it + {items, _} = + if items == [] && next_page do + case RemoteFetch.fetch_object(next_page) do + {:ok, next_data} -> extract_items_from_collection(next_data) + _ -> {[], nil} + end + else + {items, next_page} + end + + # Fetch full objects for URI-only items, then filter and extract reply objects + replies = + items + |> Enum.take(limit) + |> Enum.map(fn item -> + case item do + # Item is already a full object + %{"type" => _type} = obj -> + obj + + # Item is a Create activity - extract the object + %{"object" => %{"type" => _} = obj} -> + obj + + # Item is just a URI - fetch it + uri when is_binary(uri) -> + case RemoteFetch.fetch_object(uri) do + {:ok, fetched_obj} -> fetched_obj + _ -> nil + end + + _ -> + nil + end + end) + |> Enum.filter(fn item -> + case item do + %{"type" => type} when type in ["Note", "Article", "Page"] -> true + _ -> false + end + end) + |> ReplyFetchPolicy.filter_same_host_replies(root_ref) + + {:ok, replies} + + {:error, reason} -> + {:error, reason} + end + end + + # Returns {items, next_page_url} + defp extract_items_from_collection(collection_data) do + case collection_data do + %{"orderedItems" => items} when is_list(items) -> + {items, collection_data["next"]} + + %{"items" => items} when is_list(items) -> + {items, collection_data["next"]} + + %{"first" => %{"orderedItems" => items} = first} when is_list(items) -> + {items, first["next"]} + + %{"first" => %{"items" => items} = first} when is_list(items) -> + {items, first["next"]} + + _ -> + {[], nil} + end + end + + # Group Follow functions + + alias Elektrine.ActivityPub.GroupFollow + + @doc """ + Creates a group follow relationship (remote actor following local Group). + """ + def create_group_follow(remote_actor_id, group_actor_id, activitypub_id, pending \\ false) do + %GroupFollow{} + |> GroupFollow.changeset(%{ + remote_actor_id: remote_actor_id, + group_actor_id: group_actor_id, + activitypub_id: activitypub_id, + pending: pending + }) + |> Repo.insert() + end + + @doc """ + Gets an existing group follow by remote actor and group actor. + """ + def get_group_follow(remote_actor_id, group_actor_id) do + Repo.get_by(GroupFollow, remote_actor_id: remote_actor_id, group_actor_id: group_actor_id) + end + + @doc """ + Deletes a group follow relationship. + """ + def delete_group_follow(remote_actor_id, group_actor_id) do + case get_group_follow(remote_actor_id, group_actor_id) do + nil -> {:ok, :not_found} + follow -> Repo.delete(follow) + end + end + + @doc """ + Gets all followers (remote actors) of a local Group actor. + Returns list of Actor structs with inbox_url. + """ + def get_group_followers(group_actor_id) do + from(gf in GroupFollow, + where: gf.group_actor_id == ^group_actor_id and gf.pending == false, + join: actor in assoc(gf, :remote_actor), + select: actor + ) + |> Repo.all() + end + + @doc """ + Gets the inbox URLs for all followers of a local Group actor. + Uses shared inbox when available. + """ + def get_group_follower_inboxes(group_actor_id) do + get_group_followers(group_actor_id) + |> Enum.map(fn actor -> + # Shared inbox is in metadata.endpoints.sharedInbox, not a direct field + shared_inbox = get_in(actor.metadata || %{}, ["endpoints", "sharedInbox"]) + shared_inbox || actor.inbox_url + end) + |> Enum.filter(&is_binary/1) + |> Enum.uniq() + end + + @doc """ + Gets the count of followers for a local Group actor. + """ + def get_group_follower_count(group_actor_id) do + from(gf in GroupFollow, + where: gf.group_actor_id == ^group_actor_id and gf.pending == false, + select: count(gf.id) + ) + |> Repo.one() + end + + @doc """ + Gets a local Group actor by its URI. + Returns nil if not found or not a Group. + """ + def get_local_group_actor_by_uri(uri) do + from(a in Actor, + join: c in Conversation, + on: c.id == a.community_id, + where: a.uri == ^uri and a.domain == ^instance_domain() and a.actor_type == "Group", + where: c.type == "community" and c.is_public == true + ) + |> Repo.one() + end + + defp safe_get(url, headers, opts) do + request = Finch.build(:get, url, headers) + SafeFetch.request(request, Elektrine.Finch, opts) + end + + defp safe_get_lemmy_api(url, headers, opts) do + case safe_get(url, headers, opts) do + {:ok, %Finch.Response{status: 404}} -> + url + |> fallback_lemmy_api_url() + |> case do + nil -> {:ok, %Finch.Response{status: 404, headers: [], body: ""}} + fallback_url -> safe_get(fallback_url, headers, opts) + end + + other -> + other + end + end + + defp fallback_lemmy_api_url(url) when is_binary(url) do + if String.contains?(url, "/api/v4/") do + String.replace(url, "/api/v4/", "/api/v3/", global: false) + else + nil + end + end + + defp fallback_lemmy_api_url(_), do: nil + + defp notify_home_feed_policy_changed(user_id, reason) when is_integer(user_id) do + module = Module.concat([Elektrine, Social, HomeFeedInvalidationWorker]) + + if Code.ensure_loaded?(module) do + _ = module.clear_user(user_id, reason) + end + + :ok + rescue + _ -> :ok + end + + defp notify_all_home_feeds(reason) do + module = Module.concat([Elektrine, Social, HomeFeed]) + + if Code.ensure_loaded?(module) do + _ = module.clear_all(reason) + end + + :ok + rescue + _ -> :ok + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/activity.ex b/apps/elektrine/lib/elektrine/activitypub/activity.ex new file mode 100644 index 0000000..a825397 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/activity.ex @@ -0,0 +1,56 @@ +defmodule Elektrine.ActivityPub.Activity do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "activitypub_activities" do + field :activity_id, :string + field :activity_type, :string + field :actor_uri, :string + field :object_id, :string + field :data, :map + field :local, :boolean, default: false + + # Processing state for async handling + field :processed, :boolean, default: false + field :processed_at, :utc_datetime + field :process_error, :string + field :process_attempts, :integer, default: 0 + + belongs_to :internal_user, Elektrine.Accounts.User + belongs_to :internal_message, Elektrine.Social.Message + + has_many :deliveries, Elektrine.ActivityPub.Delivery + + timestamps() + end + + @doc false + def changeset(activity, attrs) do + activity + |> cast(attrs, [ + :activity_id, + :activity_type, + :actor_uri, + :object_id, + :data, + :local, + :internal_user_id, + :internal_message_id, + :processed, + :processed_at, + :process_error, + :process_attempts + ]) + |> validate_required([:activity_id, :activity_type, :actor_uri, :data]) + |> unique_constraint(:activity_id) + end + + @doc """ + Changeset for marking an activity as processed. + """ + def mark_processed_changeset(activity, attrs \\ %{}) do + activity + |> cast(attrs, [:processed, :processed_at, :process_error, :process_attempts]) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/activity_delivery_worker.ex b/apps/elektrine/lib/elektrine/activitypub/activity_delivery_worker.ex new file mode 100644 index 0000000..f044cb0 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/activity_delivery_worker.ex @@ -0,0 +1,282 @@ +defmodule Elektrine.ActivityPub.ActivityDeliveryWorker do + @moduledoc """ + Oban worker for delivering ActivityPub activities to remote instances. + + Replaces the old DeliveryWorker GenServer with guaranteed delivery + and automatic retries. + + Uses Lemmy-style per-domain throttling to prevent overwhelming + remote instances with too many concurrent requests. + """ + + use Oban.Worker, + queue: :activitypub_delivery, + max_attempts: 1, + unique: [ + period: :infinity, + fields: [:args, :worker], + keys: [:delivery_id], + states: :incomplete + ] + + require Logger + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.DomainThrottler + alias Elektrine.ActivityPub.Publisher + alias Elektrine.Telemetry.Events + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"delivery_id" => delivery_id}}) do + started_at = System.monotonic_time(:millisecond) + + case ActivityPub.get_delivery(delivery_id) do + nil -> + Logger.warning("ActivityDeliveryWorker: Delivery #{delivery_id} not found") + + Events.federation(:delivery_worker, :perform, :missing_delivery, nil, %{ + delivery_id: delivery_id + }) + + {:discard, :missing_delivery} + + %{status: status} when status != "pending" -> + Logger.debug( + "ActivityDeliveryWorker: Delivery #{delivery_id} already processed (#{status})" + ) + + Events.federation(:delivery_worker, :perform, :already_processed, nil, %{ + delivery_id: delivery_id, + status: status + }) + + {:discard, :already_processed} + + delivery -> + # Extract domain from inbox URL for throttling + domain = inbox_domain(delivery.inbox_url) + + # Try to acquire a processing slot for this domain (Lemmy-style) + case DomainThrottler.acquire(domain) do + {:ok, :acquired} -> + result = process_delivery(delivery) + + # Release slot with success/failure status + success? = + case result do + {:ok, _} -> true + _ -> false + end + + DomainThrottler.release(domain, success?) + + outcome = + case result do + {:ok, _} -> :success + _ -> :failure + end + + Events.federation( + :delivery_worker, + :perform, + outcome, + System.monotonic_time(:millisecond) - started_at, + %{ + delivery_id: delivery_id, + actor_domain: domain + } + ) + + # Delivery retries are driven by next_retry_at + DeliveryRetryWorker. + # Reflect terminal delivery failures without asking Oban to retry them. + case result do + {:ok, _} -> :ok + {:error, reason} -> {:discard, reason} + end + + {:error, :throttled} -> + # Domain has too many concurrent deliveries, snooze and retry + Logger.debug("Throttled delivery to #{domain}, snoozing for 5s") + + Events.federation( + :delivery_worker, + :perform, + :throttled, + System.monotonic_time(:millisecond) - started_at, + %{ + delivery_id: delivery_id, + actor_domain: domain + } + ) + + {:snooze, 5} + + {:error, :backoff, remaining_ms} -> + # Domain is in backoff due to failures + snooze_seconds = max(1, div(normalize_remaining_ms(remaining_ms), 1000)) + Logger.debug("Domain #{domain} in backoff, snoozing delivery for #{snooze_seconds}s") + + Events.federation( + :delivery_worker, + :perform, + :backoff, + System.monotonic_time(:millisecond) - started_at, + %{ + delivery_id: delivery_id, + actor_domain: domain, + backoff_ms: remaining_ms + } + ) + + {:snooze, snooze_seconds} + end + end + end + + defp process_delivery(delivery) do + started_at = System.monotonic_time(:millisecond) + activity = delivery.activity + domain = inbox_domain(delivery.inbox_url) + + case get_signing_entity(activity) do + {:ok, entity} -> + # Attempt delivery + case Publisher.deliver(activity.data, entity, delivery.inbox_url) do + {:ok, :delivered} -> + ActivityPub.mark_delivery_delivered(delivery.id) + Logger.info("ActivityDeliveryWorker: Delivered to #{delivery.inbox_url}") + + Events.federation( + :delivery_worker, + :deliver, + :success, + System.monotonic_time(:millisecond) - started_at, + %{ + delivery_id: delivery.id, + actor_domain: domain + } + ) + + {:ok, :delivered} + + {:error, reason} -> + ActivityPub.mark_delivery_failed(delivery.id, reason) + + Logger.warning( + "ActivityDeliveryWorker: Failed to deliver to #{delivery.inbox_url}: #{inspect(reason)}" + ) + + Events.federation( + :delivery_worker, + :deliver, + :failure, + System.monotonic_time(:millisecond) - started_at, + %{ + delivery_id: delivery.id, + actor_domain: domain, + reason: inspect(reason) + } + ) + + {:error, reason} + end + + {:error, reason} -> + ActivityPub.mark_delivery_failed(delivery.id, reason) + Logger.warning("ActivityDeliveryWorker: #{reason} for delivery #{delivery.id}") + + Events.federation( + :delivery_worker, + :deliver, + :failure, + System.monotonic_time(:millisecond) - started_at, + %{ + delivery_id: delivery.id, + actor_domain: domain, + reason: inspect(reason) + } + ) + + {:error, reason} + end + rescue + e -> + Logger.error( + "ActivityDeliveryWorker: Error processing delivery #{delivery.id}: #{inspect(e)}" + ) + + ActivityPub.mark_delivery_failed(delivery.id, inspect(e)) + + Events.federation(:delivery_worker, :deliver, :failure, nil, %{ + delivery_id: delivery.id, + actor_domain: inbox_domain(delivery.inbox_url), + reason: inspect(e) + }) + + {:error, inspect(e)} + end + + defp get_signing_entity(activity) do + cond do + is_integer(activity.internal_user_id) -> + case Elektrine.Repo.get(Elektrine.Accounts.User, activity.internal_user_id) do + nil -> + {:error, "User not found"} + + user -> + {:ok, user} + end + + is_binary(activity.actor_uri) -> + case ActivityPub.get_actor_by_uri(activity.actor_uri) do + %ActivityPub.Actor{} = actor -> + if actor.domain == ActivityPub.instance_domain() do + {:ok, actor} + else + {:error, "Local actor not found"} + end + + _ -> + {:error, "Local actor not found"} + end + + true -> + {:error, "No signing entity found"} + end + end + + @doc """ + Enqueue a delivery for processing. + """ + def enqueue(delivery_id) do + %{delivery_id: delivery_id} + |> new() + |> Elektrine.JobQueue.insert() + end + + @doc """ + Enqueue multiple deliveries for processing. + """ + def enqueue_many(delivery_ids) when is_list(delivery_ids) do + delivery_ids + |> Enum.uniq() + |> Enum.map(&enqueue/1) + end + + defp inbox_domain(inbox_url) when is_binary(inbox_url) do + case URI.parse(inbox_url) do + %URI{host: host} when is_binary(host) and host != "" -> host + _ -> "unknown" + end + end + + defp inbox_domain(_), do: "unknown" + + defp normalize_remaining_ms(remaining_ms) when is_integer(remaining_ms) and remaining_ms > 0, + do: remaining_ms + + defp normalize_remaining_ms(remaining_ms) when is_float(remaining_ms) and remaining_ms > 0, + do: round(remaining_ms) + + defp normalize_remaining_ms(_), do: 1_000 +end diff --git a/apps/elektrine/lib/elektrine/activitypub/actor.ex b/apps/elektrine/lib/elektrine/activitypub/actor.ex new file mode 100644 index 0000000..edd1a0f --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/actor.ex @@ -0,0 +1,160 @@ +defmodule Elektrine.ActivityPub.Actor do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + alias Elektrine.Secrets.EncryptedString + alias Elektrine.Security.SafeExternalURL + + schema "activitypub_actors" do + field :uri, :string + field :username, :string + field :domain, :string + field :display_name, :string + field :summary, :string + field :avatar_url, :string + field :header_url, :string + field :inbox_url, :string + field :outbox_url, :string + field :followers_url, :string + field :following_url, :string + field :public_key, :string + field :manually_approves_followers, :boolean, default: false + field :actor_type, :string, default: "Person" + field :last_fetched_at, :utc_datetime + field :published_at, :utc_datetime + field :metadata, :map, default: %{} + field :moderators_url, :string + + # Relationship to local community (if this actor represents a local community) + belongs_to :community, Elektrine.Social.Conversation, foreign_key: :community_id + + timestamps() + end + + @doc false + def changeset(actor, attrs) do + actor + |> cast(attrs, [ + :uri, + :username, + :domain, + :display_name, + :summary, + :avatar_url, + :header_url, + :inbox_url, + :outbox_url, + :followers_url, + :following_url, + :public_key, + :manually_approves_followers, + :actor_type, + :last_fetched_at, + :published_at, + :metadata, + :moderators_url, + :community_id + ]) + |> truncate_utc_datetimes([:last_fetched_at, :published_at]) + |> update_change(:metadata, &encrypt_metadata_private_key/1) + |> validate_required([:uri, :username, :domain, :inbox_url]) + |> validate_safe_href(:uri) + |> validate_safe_href(:inbox_url) + |> validate_optional_safe_href(:avatar_url) + |> validate_optional_safe_href(:header_url) + |> validate_optional_safe_href(:outbox_url) + |> validate_optional_safe_href(:followers_url) + |> validate_optional_safe_href(:following_url) + |> validate_optional_safe_href(:moderators_url) + |> validate_inclusion(:actor_type, [ + "Person", + "Group", + "Organization", + "Service", + "Application" + ]) + |> unique_constraint(:uri) + |> unique_constraint([:username, :domain], + name: :activitypub_actors_username_domain_unique_index + ) + |> unique_constraint(:community_id) + end + + def metadata_private_key(%__MODULE__{metadata: metadata}), + do: decrypt_metadata_private_key(metadata) + + def metadata_private_key(_actor), do: nil + + def put_metadata_private_key(metadata, private_key) when is_binary(private_key) do + metadata + |> normalize_metadata() + |> Map.put("private_key", encrypt_private_key(private_key)) + end + + def put_metadata_private_key(metadata, _private_key), do: normalize_metadata(metadata) + + defp encrypt_metadata_private_key(metadata) when is_map(metadata) do + case Map.get(metadata, "private_key") || Map.get(metadata, :private_key) do + private_key when is_binary(private_key) -> put_metadata_private_key(metadata, private_key) + _ -> metadata + end + end + + defp encrypt_metadata_private_key(metadata), do: metadata + + defp decrypt_metadata_private_key(metadata) when is_map(metadata) do + case Map.get(metadata, "private_key") || Map.get(metadata, :private_key) do + private_key when is_binary(private_key) -> + case EncryptedString.decrypt(private_key) do + {:ok, decrypted} -> decrypted + :error -> private_key + end + + _ -> + nil + end + end + + defp decrypt_metadata_private_key(_metadata), do: nil + + defp truncate_utc_datetimes(changeset, fields) do + Enum.reduce(fields, changeset, fn field, changeset -> + update_change(changeset, field, &Elektrine.Time.truncate/1) + end) + end + + defp encrypt_private_key(private_key) do + case EncryptedString.encrypt(private_key) do + {:ok, encrypted} -> encrypted + :error -> private_key + end + end + + defp normalize_metadata(metadata) when is_map(metadata), + do: Map.new(metadata, fn {key, value} -> {to_string(key), value} end) + + defp normalize_metadata(_metadata), do: %{} + + defp validate_safe_href(changeset, field) do + validate_change(changeset, field, fn _, url -> + safe_href_errors(field, url) + end) + end + + defp validate_optional_safe_href(changeset, field) do + validate_change(changeset, field, fn + _, nil -> [] + _, "" -> [] + _, url -> safe_href_errors(field, url) + end) + end + + defp safe_href_errors(field, url) do + case SafeExternalURL.normalize_href(url) do + {:ok, _url} -> [] + {:error, :userinfo_not_allowed} -> [{field, "must not include username or password"}] + {:error, _reason} -> [{field, "must be a valid HTTP or HTTPS URL"}] + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/actor_refresh_worker.ex b/apps/elektrine/lib/elektrine/activitypub/actor_refresh_worker.ex new file mode 100644 index 0000000..ab79b54 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/actor_refresh_worker.ex @@ -0,0 +1,82 @@ +defmodule Elektrine.ActivityPub.ActorRefreshWorker do + @moduledoc """ + Bounded refresh of stale remote actors and their profile metadata. + """ + + use Oban.Worker, + queue: :federation_metadata, + max_attempts: 2, + priority: 8, + unique: [ + period: 3_600, + keys: [:type, :actor_id], + states: [:available, :scheduled, :executing, :retryable] + ] + + import Ecto.Query + + alias Elektrine.ActivityPub.{Actor, FederationLoadGuard} + alias Elektrine.Repo + + @default_limit 200 + @stale_hours 24 + + def enqueue(actor_id) when is_integer(actor_id) do + %{"type" => "single", "actor_id" => actor_id} + |> new() + |> Elektrine.JobQueue.insert() + end + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"type" => "stale"} = args}) do + if FederationLoadGuard.skip_nonessential?(__MODULE__) do + {:discard, :federation_overloaded} + else + args + |> Map.get("limit", @default_limit) + |> enqueue_stale_actor_batch() + + :ok + end + end + + def perform(%Oban.Job{args: %{"type" => "single", "actor_id" => actor_id}}) + when is_integer(actor_id) do + if FederationLoadGuard.skip_nonessential?(__MODULE__) do + {:discard, :federation_overloaded} + else + refresh_actor(actor_id) + end + end + + defp enqueue_stale_actor_batch(limit) do + limit = max(1, min(limit || @default_limit, @default_limit)) + cutoff = DateTime.utc_now() |> DateTime.add(-@stale_hours, :hour) + + Actor + |> where([a], not is_nil(a.uri)) + |> where([a], is_nil(a.last_fetched_at) or a.last_fetched_at < ^cutoff) + |> order_by([a], asc: a.last_fetched_at, asc: a.id) + |> limit(^limit) + |> select([a], a.id) + |> Repo.all() + |> Enum.each(&enqueue/1) + end + + defp refresh_actor(actor_id) do + case Repo.get(Actor, actor_id) do + %Actor{uri: uri} when is_binary(uri) -> + case Elektrine.ActivityPub.get_or_fetch_actor(uri) do + {:ok, _actor} -> :ok + {:error, :invalid_actor_uri} -> {:discard, :invalid_actor_uri} + {:error, reason} -> {:error, reason} + end + + nil -> + {:discard, :actor_not_found} + + _actor -> + {:discard, :missing_actor_uri} + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/builder.ex b/apps/elektrine/lib/elektrine/activitypub/builder.ex new file mode 100644 index 0000000..2d2c564 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/builder.ex @@ -0,0 +1,1340 @@ +defmodule Elektrine.ActivityPub.Builder do + @moduledoc """ + Builds ActivityPub JSON-LD documents for actors, activities, and objects. + """ + + alias Elektrine.Accounts.User + alias Elektrine.ActivityPub + alias Elektrine.Messaging + alias Elektrine.Social.{Conversation, Message} + + @toot_context %{ + "toot" => "http://joinmastodon.org/ns#", + "discoverable" => "toot:discoverable", + "indexable" => "toot:indexable" + } + @actor_context [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + @toot_context + ] + @object_context ["https://www.w3.org/ns/activitystreams", @toot_context] + + @doc """ + Builds an Actor document for a user. + """ + def build_actor(%User{} = user), do: build_actor(user, %{}) + + def build_actor(%User{} = user, opts) when is_map(opts) do + base_url = Map.get(opts, :base_url, ActivityPub.instance_url()) + moved_to = Map.get(opts, :moved_to) + also_known_as = Map.get(opts, :also_known_as, []) + actor_identifier = Map.get(opts, :actor_identifier, ActivityPub.actor_identifier(user)) + actor_url = ActivityPub.actor_uri(actor_identifier, base_url) + public_profile? = user.profile_visibility == "public" + + %{ + "@context" => @actor_context, + "id" => actor_url, + "type" => "Person", + "preferredUsername" => ActivityPub.actor_identifier(user), + "name" => actor_display_name(user, public_profile?), + "summary" => if(public_profile?, do: build_user_summary(user), else: nil), + "url" => actor_profile_url(user, base_url, actor_url, public_profile?), + "inbox" => "#{actor_url}/inbox", + "outbox" => "#{actor_url}/outbox", + "followers" => "#{actor_url}/followers", + "following" => "#{actor_url}/following", + "published" => format_datetime(user.inserted_at), + "manuallyApprovesFollowers" => user.activitypub_manually_approve_followers || false, + "discoverable" => user.profile_visibility == "public", + "indexable" => user.profile_visibility == "public", + "icon" => if(public_profile?, do: build_icon(user, base_url), else: nil), + "image" => if(public_profile?, do: build_header_image(user, base_url), else: nil), + "publicKey" => %{ + "id" => "#{actor_url}#main-key", + "owner" => actor_url, + "publicKeyPem" => String.trim(user.activitypub_public_key || "") + }, + "endpoints" => %{ + "sharedInbox" => "#{base_url}/inbox" + } + } + |> maybe_put( + "attachment", + if(public_profile?, do: build_profile_attachments(user), else: nil) + ) + |> maybe_put("movedTo", moved_to) + |> maybe_put_list("alsoKnownAs", also_known_as) + end + + defp actor_display_name(user, true), do: user.display_name || user.username + defp actor_display_name(user, false), do: user.username + + defp actor_profile_url(user, base_url, _actor_url, true) do + handle = if is_binary(user.handle) and user.handle != "", do: user.handle, else: user.username + "#{base_url}/#{handle}" + end + + defp actor_profile_url(_user, _base_url, actor_url, false), do: actor_url + + defp build_user_summary(user) do + # Get user profile bio if available + case user.profile do + %Ecto.Association.NotLoaded{} -> nil + nil -> nil + profile -> Map.get(profile, :description) + end + end + + defp build_icon(user, base_url) do + if user.avatar do + # Convert avatar to full URL for federation + avatar_url = user.avatar |> Elektrine.Uploads.avatar_url() |> absolutize_url(base_url) + + %{ + "type" => "Image", + "mediaType" => guess_media_type(avatar_url), + "url" => avatar_url + } + else + nil + end + end + + defp build_header_image(user, base_url) do + # Check if user has a banner image + case user.profile do + %Ecto.Association.NotLoaded{} -> + nil + + nil -> + nil + + profile -> + banner = Map.get(profile, :banner_url) + + if banner do + # Ensure banner URL is absolute + banner_url = + if String.starts_with?(banner, "http") do + banner + else + "#{base_url}#{banner}" + end + + %{ + "type" => "Image", + "mediaType" => guess_media_type(banner_url), + "url" => banner_url + } + else + nil + end + end + end + + defp build_profile_attachments(%User{profile: %Ecto.Association.NotLoaded{}}), do: nil + defp build_profile_attachments(%User{profile: nil}), do: nil + + defp build_profile_attachments(%User{profile: profile}) do + case Map.get(profile, :links) do + %Ecto.Association.NotLoaded{} -> + nil + + links when is_list(links) -> + links + |> Enum.filter(&profile_link_exportable?/1) + |> Enum.sort_by(&{Map.get(&1, :position) || 0, Map.get(&1, :id) || 0}) + |> Enum.map(&build_profile_attachment/1) + |> case do + [] -> nil + attachments -> attachments + end + + _ -> + nil + end + end + + defp profile_link_exportable?(link) do + Map.get(link, :is_active, false) && + present_text?(Map.get(link, :title)) && + present_text?(Map.get(link, :url)) + end + + defp build_profile_attachment(link) do + %{ + "type" => "PropertyValue", + "name" => String.trim(Map.get(link, :title)), + "value" => build_profile_attachment_value(String.trim(Map.get(link, :url))) + } + end + + defp build_profile_attachment_value(url) do + escaped_url = escape_html(url) + + rel = + if http_profile_link?(url) do + ~s( rel="me nofollow noopener noreferrer" target="_blank") + else + ~s( rel="nofollow noopener noreferrer") + end + + ~s(#{escaped_url}) + end + + defp escape_html(text) when is_binary(text) do + text + |> Phoenix.HTML.html_escape() + |> Phoenix.HTML.safe_to_string() + end + + defp http_profile_link?(url) when is_binary(url) do + String.starts_with?(url, "http://") or String.starts_with?(url, "https://") + end + + defp present_text?(value) when is_binary(value), do: Elektrine.Strings.present?(value) + defp present_text?(_value), do: false + + defp absolutize_url(nil, _base_url), do: nil + + defp absolutize_url(url, base_url) when is_binary(url) do + if String.starts_with?(url, "http://") or String.starts_with?(url, "https://") do + url + else + "#{String.trim_trailing(base_url, "/")}#{url}" + end + end + + @doc """ + Builds a Group actor document for a community/discussion (Lemmy-compatible). + """ + def build_group(%Conversation{} = community) do + base_url = ActivityPub.instance_url() + community_slug = ActivityPub.community_slug(community.name) + actor_url = ActivityPub.community_actor_uri(community.name, base_url) + public_key = get_community_public_key(community) + + %{ + "@context" => [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + @toot_context, + %{ + "lemmy" => "https://join-lemmy.org/ns#", + "moderators" => %{ + "@type" => "@id", + "@id" => "lemmy:moderators" + }, + "postingRestrictedToMods" => "lemmy:postingRestrictedToMods" + } + ], + "id" => actor_url, + "type" => "Group", + "preferredUsername" => community_slug, + "name" => community.name, + "summary" => community.description || "A community on #{ActivityPub.instance_domain()}", + "url" => ActivityPub.community_web_url(community.name, base_url), + "inbox" => ActivityPub.community_inbox_uri(community.name, base_url), + "outbox" => ActivityPub.community_outbox_uri(community.name, base_url), + "followers" => ActivityPub.community_followers_uri(community.name, base_url), + "moderators" => ActivityPub.community_moderators_uri(community.name, base_url), + "published" => format_datetime(community.inserted_at), + "manuallyApprovesFollowers" => !community.is_public, + "discoverable" => community.is_public, + "indexable" => community.is_public, + "postingRestrictedToMods" => false, + "icon" => build_community_icon(community), + "publicKey" => %{ + "id" => "#{actor_url}#main-key", + "owner" => actor_url, + "publicKeyPem" => String.trim(public_key) + }, + "endpoints" => %{ + "sharedInbox" => "#{base_url}/inbox" + }, + "attributedTo" => community_moderator_actor_uris(community, base_url) + } + end + + defp get_community_public_key(community) do + case ActivityPub.get_or_create_community_actor(community.id) do + {:ok, actor} -> + actor = + case Elektrine.ActivityPub.KeyManager.ensure_user_has_keys(actor) do + {:ok, actor_with_keys} -> actor_with_keys + _ -> actor + end + + actor.public_key || "" + + {:error, _reason} -> + "" + end + end + + defp build_community_icon(community) do + if community.avatar_url do + %{ + "type" => "Image", + "mediaType" => guess_media_type(community.avatar_url), + "url" => community.avatar_url + } + else + nil + end + end + + def community_moderator_actor_uris( + %Conversation{} = community, + base_url \\ ActivityPub.instance_url() + ) do + community + |> moderator_users() + |> Enum.map(&ActivityPub.actor_uri(&1, base_url)) + end + + defp moderator_users(%Conversation{} = community) do + community.id + |> Messaging.get_community_moderators() + |> Enum.map(& &1.user) + |> Enum.filter(&(&1 && &1.activitypub_enabled)) + |> case do + [] -> creator_user(community) + users -> users + end + end + + defp creator_user(%Conversation{creator_id: nil}), do: [] + + defp creator_user(%Conversation{creator_id: creator_id}) do + case Elektrine.Repo.get(User, creator_id) do + %User{activitypub_enabled: true} = user -> [user] + _ -> [] + end + end + + @doc """ + Builds a Note object from a message. + """ + def build_note(%Message{} = message, %User{} = user) do + base_url = ActivityPub.instance_url() + + object_id = + message.activitypub_id || ActivityPub.user_status_uri(user, message.id, base_url) + + # Check if posting to a community - walk up reply chain if needed + community_uri = get_community_uri_from_chain(message) + + note = %{ + "@context" => @object_context, + "id" => object_id, + "type" => "Note", + "attributedTo" => ActivityPub.actor_uri(user, base_url), + "content" => format_content(message), + "published" => format_datetime(message.inserted_at), + "to" => build_to_addresses(message, user, community_uri), + "cc" => build_cc_addresses(message, user), + "indexable" => message_indexable?(message), + "sensitive" => message.sensitive || false, + "attachment" => build_attachments(message), + "tag" => build_tags(message), + "url" => object_id, + "inReplyTo" => build_in_reply_to(message) + } + + # Add audience for community posts + note = + if community_uri do + Map.put(note, "audience", community_uri) + else + note + end + + note + # Mastodon only treats an Update as a content edit (rather than a silent + # metadata refresh) when the object carries an `updated` timestamp newer + # than `published`; without it, edits to plain Notes are ignored remotely. + |> maybe_put("updated", format_datetime(message.edited_at)) + |> maybe_add_content_warning(message) + |> maybe_add_title_as_name(message) + |> maybe_add_quote_url(message) + end + + @doc """ + Formats a datetime for ActivityPub (ISO8601). + """ + def format_datetime(%DateTime{} = dt), do: DateTime.to_iso8601(dt) + + def format_datetime(%NaiveDateTime{} = ndt) do + # Convert NaiveDateTime to DateTime (assume UTC) + DateTime.from_naive!(ndt, "Etc/UTC") |> DateTime.to_iso8601() + end + + def format_datetime(nil), do: nil + + @doc """ + Builds a community post object for local Group actors. + """ + def build_community_note(%Message{} = post, %Conversation{} = community, opts \\ []) do + base_url = get_builder_opt(opts, :base_url, ActivityPub.instance_url()) + community_actor_url = ActivityPub.community_actor_uri(community.name, base_url) + post_id = ActivityPub.community_post_uri(community.name, post.id, base_url) + + author_uri = + case get_builder_opt(opts, :author_uri) do + value when is_binary(value) -> + if Elektrine.Strings.present?(value) do + value + else + case post.sender do + %{username: _username} = author -> ActivityPub.actor_uri(author, base_url) + _ -> community_actor_url + end + end + + _ -> + case post.sender do + %{username: _username} = author -> ActivityPub.actor_uri(author, base_url) + _ -> community_actor_url + end + end + + object_type = if post.reply_to_id, do: "Note", else: "Page" + cc = community_cc_addresses(post, community_actor_url) + + %{ + "@context" => @object_context, + "id" => post_id, + "type" => object_type, + "attributedTo" => author_uri, + "content" => format_html_content(post.content), + "mediaType" => "text/html", + "published" => format_datetime(post.inserted_at), + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => cc, + "attachment" => build_attachments(post), + "tag" => build_tags(post), + "audience" => community_actor_url, + "url" => ActivityPub.community_post_web_url(community.name, post.id, base_url), + "inReplyTo" => build_community_in_reply_to(post, community, base_url), + "indexable" => message_indexable?(post), + "sensitive" => post.sensitive || false, + "context" => community_actor_url, + "commentsEnabled" => is_nil(post.locked_at), + "stickied" => post.is_pinned || false, + "distinguished" => false + } + |> maybe_put("updated", format_datetime(post.edited_at)) + |> maybe_put("summary", post.content_warning) + |> maybe_put("name", if(object_type == "Page", do: post.title, else: nil)) + |> maybe_add_quote_url(post) + end + + @doc """ + Builds a local community object, choosing Question for polls. + """ + def build_community_object(%Message{} = post, %Conversation{} = community, opts \\ []) do + case get_builder_opt(opts, :poll) do + %{options: _} = poll -> build_community_question(post, community, poll, opts) + _ -> build_community_note(post, community, opts) + end + end + + defp format_content(message) do + format_html_content(message.content) + end + + defp format_html_content(content) when is_binary(content) do + if Elektrine.Strings.present?(content) do + content + |> String.replace("\n", "
    ") + |> HtmlSanitizeEx.basic_html() + else + "" + end + end + + defp format_html_content(_), do: "" + + defp build_to_addresses(message, user, community_uri) do + base_url = ActivityPub.instance_url() + + base = + case message.visibility do + "public" -> + ["https://www.w3.org/ns/activitystreams#Public"] + + vis when vis in ["followers", "unlisted"] -> + # Unlisted uses the same primary audience as followers-only; the + # Public magic URI is demoted to `cc` (see build_cc_addresses), which + # is exactly how Mastodon distinguishes unlisted from public. + [ActivityPub.user_collection_uri(user, "followers", base_url)] + + _ -> + # Direct/conversation - send to specific recipients + [] + end + + # Add community URI if posting to a community + if community_uri do + [community_uri | base] + else + base + end + end + + defp build_cc_addresses(message, user) do + base_url = ActivityPub.instance_url() + + base_cc = + case message.visibility do + "public" -> + # CC to followers when posting publicly + [ActivityPub.user_collection_uri(user, "followers", base_url)] + + "unlisted" -> + # Public in cc (not to) = unlisted: reaches the public web but stays + # out of remote public/federated timelines. + ["https://www.w3.org/ns/activitystreams#Public"] + + _ -> + [] + end + + # Add mentioned users (handle nil content) + mention_uris = mention_uris_for_message(message) + + base_cc ++ mention_uris + end + + defp build_attachments(message) do + if message.media_urls && message.media_urls != [] do + # Get alt texts from metadata if available + alt_texts = + if message.media_metadata && message.media_metadata["alt_texts"] do + message.media_metadata["alt_texts"] + else + %{} + end + + Enum.with_index(message.media_urls) + |> Enum.map(fn {key, idx} -> + full_url = Elektrine.Uploads.attachment_url(key, message) + media_type = guess_media_type(full_url) + + # Use appropriate ActivityPub type based on media type + attachment_type = + cond do + String.starts_with?(media_type, "image/") -> "Image" + String.starts_with?(media_type, "video/") -> "Video" + String.starts_with?(media_type, "audio/") -> "Audio" + true -> "Document" + end + + # Build attachment object (Pixelfed-compatible) + attachment = %{ + "type" => attachment_type, + "mediaType" => media_type, + "url" => full_url + } + + # Add alt text (name field) if available + alt_text = Map.get(alt_texts, to_string(idx)) + + if Elektrine.Strings.present?(alt_text) do + Map.put(attachment, "name", alt_text) + else + attachment + end + end) + else + [] + end + end + + defp guess_media_type(url) do + cond do + String.ends_with?(url, [".jpg", ".jpeg"]) -> "image/jpeg" + String.ends_with?(url, ".png") -> "image/png" + String.ends_with?(url, ".gif") -> "image/gif" + String.ends_with?(url, ".webp") -> "image/webp" + String.ends_with?(url, ".mp4") -> "video/mp4" + String.ends_with?(url, ".webm") -> "video/webm" + String.ends_with?(url, ".ogv") -> "video/ogg" + String.ends_with?(url, ".mov") -> "video/quicktime" + String.ends_with?(url, ".mp3") -> "audio/mpeg" + String.ends_with?(url, ".ogg") -> "audio/ogg" + String.ends_with?(url, ".wav") -> "audio/wav" + String.ends_with?(url, ".m4a") -> "audio/mp4" + String.ends_with?(url, ".aac") -> "audio/aac" + String.ends_with?(url, ".flac") -> "audio/flac" + true -> "application/octet-stream" + end + end + + defp build_tags(message) do + tags = [] + + # Add hashtags + hashtag_tags = + if message.extracted_hashtags && message.extracted_hashtags != [] do + Enum.map(message.extracted_hashtags, fn tag -> + %{ + "type" => "Hashtag", + "name" => "##{tag}", + "href" => "#{Elektrine.ActivityPub.instance_url()}/tags/#{tag}" + } + end) + else + [] + end + + # Add mentions from content + mention_tags = + if message.content do + Elektrine.ActivityPub.Mentions.resolve_mentions(message.content) + |> Enum.map(fn mention -> + %{ + "type" => "Mention", + "href" => mention.uri, + "name" => "@#{mention.handle}" + } + end) + else + [] + end + + tags ++ hashtag_tags ++ mention_tags + end + + defp build_in_reply_to(message) do + if message.reply_to_id do + # Get the parent message + parent = Elektrine.Messaging.get_message(message.reply_to_id) + + if parent && parent.activitypub_id do + parent.activitypub_id + else + nil + end + else + nil + end + end + + # Add content warning as summary field (for sensitive content / spoiler text) + defp maybe_add_content_warning(note, message) do + if Elektrine.Strings.present?(message.content_warning) do + Map.put(note, "summary", message.content_warning) + else + note + end + end + + # Add title as name field (for titled posts like Article-style) + defp maybe_add_title_as_name(note, message) do + if Elektrine.Strings.present?(message.title) do + Map.put(note, "name", message.title) + else + note + end + end + + # Add quoteUrl for quote posts (Mastodon-style quoting) + defp maybe_add_quote_url(note, message) do + if message.quoted_message_id do + # Load the quoted message if not already loaded + quoted_message = + case message.quoted_message do + %Elektrine.Social.Message{} = qm -> qm + _ -> Elektrine.Repo.get(Elektrine.Social.Message, message.quoted_message_id) + end + + if quoted_message && quoted_message.activitypub_id do + # Use quoteUrl field (Mastodon 4.3+ standard) + note + |> Map.put("quoteUrl", quoted_message.activitypub_id) + # Also add _misskey_quote for Misskey/Calckey compatibility + |> Map.put("_misskey_quote", quoted_message.activitypub_id) + else + note + end + else + note + end + end + + @doc """ + Builds a Create activity for a message. + """ + def build_create_activity(%Message{} = message, %User{} = user) do + base_url = ActivityPub.instance_url() + note = build_note(message, user) + activity_id = "#{note["id"]}/activity" + + %{ + "@context" => @object_context, + "id" => activity_id, + "type" => "Create", + "actor" => ActivityPub.actor_uri(user, base_url), + "published" => format_datetime(message.inserted_at), + "to" => note["to"], + "cc" => note["cc"], + "object" => note + } + end + + @doc """ + Builds a Question object for a poll. + """ + def build_question(%Message{} = message, %User{} = user, poll) do + base_url = ActivityPub.instance_url() + + object_id = + message.activitypub_id || ActivityPub.user_status_uri(user, message.id, base_url) + + community_uri = get_community_uri_from_chain(message) + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => object_id, + "type" => "Question", + "attributedTo" => ActivityPub.actor_uri(user, base_url), + "name" => poll.question, + "content" => poll_content(message, poll), + "published" => format_datetime(message.inserted_at), + "endTime" => poll_end_time(poll), + "closed" => format_datetime(poll.closes_at), + "votersCount" => poll_voters_count(poll), + poll_choice_key(poll) => build_poll_options(poll), + "to" => build_to_addresses(message, user, community_uri), + "cc" => build_cc_addresses(message, user), + "attachment" => build_attachments(message), + "tag" => build_tags(message), + "url" => object_id, + "inReplyTo" => build_in_reply_to(message), + "indexable" => message_indexable?(message), + "sensitive" => message.sensitive || false + } + |> maybe_put("audience", community_uri) + |> maybe_put("updated", format_datetime(message.edited_at)) + |> maybe_add_content_warning(message) + |> maybe_add_quote_url(message) + end + + defp message_indexable?(%Message{visibility: "public", is_draft: draft}) do + draft != true + end + + defp message_indexable?(_), do: false + + @doc """ + Builds a Question object for a local community poll. + """ + def build_community_question(%Message{} = post, %Conversation{} = community, poll, opts \\ []) do + base_url = get_builder_opt(opts, :base_url, ActivityPub.instance_url()) + community_actor_url = ActivityPub.community_actor_uri(community.name, base_url) + post_id = ActivityPub.community_post_uri(community.name, post.id, base_url) + + author_uri = + case get_builder_opt(opts, :author_uri) do + value when is_binary(value) -> + if Elektrine.Strings.present?(value) do + value + else + case post.sender do + %{username: _username} = author -> ActivityPub.actor_uri(author, base_url) + _ -> community_actor_url + end + end + + _ -> + case post.sender do + %{username: _username} = author -> ActivityPub.actor_uri(author, base_url) + _ -> community_actor_url + end + end + + %{ + "@context" => @object_context, + "id" => post_id, + "type" => "Question", + "attributedTo" => author_uri, + "name" => post.title || poll.question, + "content" => poll_content(post, poll), + "mediaType" => "text/html", + "published" => format_datetime(post.inserted_at), + "endTime" => poll_end_time(poll), + "closed" => format_datetime(poll.closes_at), + "votersCount" => poll_voters_count(poll), + poll_choice_key(poll) => build_poll_options(poll), + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => community_cc_addresses(post, community_actor_url), + "attachment" => build_attachments(post), + "tag" => build_tags(post), + "audience" => community_actor_url, + "url" => ActivityPub.community_post_web_url(community.name, post.id, base_url), + "inReplyTo" => build_community_in_reply_to(post, community, base_url), + "indexable" => message_indexable?(post), + "sensitive" => post.sensitive || false, + "context" => community_actor_url, + "commentsEnabled" => is_nil(post.locked_at), + "stickied" => post.is_pinned || false, + "distinguished" => false + } + |> maybe_put("updated", format_datetime(post.edited_at)) + |> maybe_put("summary", post.content_warning) + |> maybe_add_quote_url(post) + end + + @doc """ + Builds a Create activity for a local community object. + """ + def build_community_create_activity(%Message{} = post, %Conversation{} = community, object) do + base_url = ActivityPub.instance_url() + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "#{object["id"]}/activity", + "type" => "Create", + "actor" => ActivityPub.community_actor_uri(community.name, base_url), + "published" => format_datetime(post.inserted_at), + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => community_create_cc_addresses(post, community, base_url), + "object" => object + } + end + + defp build_community_in_reply_to(%Message{reply_to_id: nil}, _community, _base_url), do: nil + + defp build_community_in_reply_to(%Message{reply_to_id: reply_to_id}, community, base_url) do + case Elektrine.Messaging.get_message(reply_to_id) do + %Message{activitypub_id: activitypub_id, id: parent_id} when is_binary(activitypub_id) -> + if Elektrine.Strings.present?(activitypub_id) do + activitypub_id + else + ActivityPub.community_post_uri(community.name, parent_id, base_url) + end + + %Message{id: parent_id} -> + ActivityPub.community_post_uri(community.name, parent_id, base_url) + + _ -> + nil + end + end + + defp get_builder_opt(opts, key, default \\ nil) + + defp get_builder_opt(opts, key, default) when is_list(opts) do + Keyword.get(opts, key, default) + end + + defp get_builder_opt(opts, key, default) when is_map(opts) do + Map.get(opts, key, default) + end + + defp get_builder_opt(_, _, default), do: default + + defp community_cc_addresses(%Message{} = post, community_actor_url) do + [community_actor_url | mention_uris_for_message(post)] + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp community_create_cc_addresses(%Message{} = post, %Conversation{} = community, base_url) do + [ + ActivityPub.community_followers_uri(community.name, base_url) + | mention_uris_for_message(post) + ] + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp mention_uris_for_message(%Message{content: content}) when is_binary(content), + do: Elektrine.ActivityPub.Mentions.get_mention_uris(content) + + defp mention_uris_for_message(_), do: [] + + defp poll_content(%Message{} = message, poll) do + case format_content(message) do + "" -> format_html_content(poll.question) + content -> content + end + end + + defp build_poll_options(poll) do + poll.options + |> Enum.sort_by(& &1.position) + |> Enum.map(fn option -> + %{ + "type" => "Note", + "name" => option.option_text, + "replies" => %{ + "type" => "Collection", + "totalItems" => option.vote_count || 0 + } + } + end) + end + + defp poll_choice_key(poll) do + if poll.allow_multiple, do: "anyOf", else: "oneOf" + end + + defp poll_voters_count(poll) do + poll.voters_count || poll.total_votes || 0 + end + + defp poll_end_time(poll) do + if poll.closes_at do + format_datetime(poll.closes_at) + else + DateTime.add(DateTime.utc_now(), 24 * 3600, :second) |> DateTime.to_iso8601() + end + end + + @doc """ + Builds a Follow activity. + """ + def build_follow_activity(%User{} = follower, target_uri) do + base_url = ActivityPub.instance_url() + activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}" + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "type" => "Follow", + "actor" => ActivityPub.actor_uri(follower, base_url), + "object" => target_uri, + "to" => [target_uri], + "published" => DateTime.utc_now() |> DateTime.to_iso8601() + } + end + + @doc """ + Builds a Move activity for account migration. + """ + def build_move_activity(%User{}, old_actor_uri, new_actor_uri) + when is_binary(old_actor_uri) and is_binary(new_actor_uri) do + base_url = actor_base_url_from_uri(old_actor_uri) || ActivityPub.instance_url() + activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}" + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "type" => "Move", + "actor" => old_actor_uri, + "object" => old_actor_uri, + "target" => new_actor_uri, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => ["#{old_actor_uri}/followers"], + "published" => DateTime.utc_now() |> DateTime.to_iso8601() + } + end + + @doc """ + Builds an Accept activity (for accepting follows). + """ + def build_accept_activity(%User{} = user, follow_activity) do + base_url = ActivityPub.instance_url() + activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}" + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "type" => "Accept", + "actor" => ActivityPub.actor_uri(user, base_url), + "object" => follow_activity + } + end + + @doc """ + Builds a Reject activity (for rejecting follows). + """ + def build_reject_activity(%User{} = user, follow_activity) do + base_url = ActivityPub.instance_url() + activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}" + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "type" => "Reject", + "actor" => ActivityPub.actor_uri(user, base_url), + "object" => follow_activity + } + end + + @doc """ + Builds an Announce activity (boost/share). + """ + def build_announce_activity(%User{} = user, object_uri) do + base_url = ActivityPub.instance_url() + + # Deterministic id per (user, object): a user has at most one active boost of + # a given post, so a stable id is semantically correct AND lets Undo Announce + # reconstruct the exact original id when the stored activity row is missing — + # instead of minting a fresh UUID that strict remote servers can't match to + # the boost, leaving a ghost boost. + activity_id = "#{base_url}/activities/#{announce_ref(user, object_uri)}" + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "type" => "Announce", + "actor" => ActivityPub.actor_uri(user, base_url), + "object" => object_uri, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [ActivityPub.user_collection_uri(user, "followers", base_url)], + "published" => DateTime.utc_now() |> DateTime.to_iso8601() + } + end + + defp announce_ref(%User{id: user_id}, object_uri) do + :crypto.hash(:sha256, "announce:#{user_id}:#{object_uri}") + |> Base.url_encode64(padding: false) + |> binary_part(0, 24) + end + + @doc """ + Builds a Like activity. + """ + def build_like_activity(%User{} = user, object_uri) do + base_url = ActivityPub.instance_url() + activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}" + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "type" => "Like", + "actor" => ActivityPub.actor_uri(user, base_url), + "object" => object_uri, + "published" => DateTime.utc_now() |> DateTime.to_iso8601() + } + end + + @doc """ + Builds a Dislike activity (downvote). + """ + def build_dislike_activity(%User{} = user, object_uri) do + base_url = ActivityPub.instance_url() + activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}" + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "type" => "Dislike", + "actor" => ActivityPub.actor_uri(user, base_url), + "object" => object_uri, + "published" => DateTime.utc_now() |> DateTime.to_iso8601() + } + end + + @doc """ + Builds an EmojiReact activity (for custom emoji reactions). + """ + def build_emoji_react_activity(%User{} = user, object_uri, emoji) do + base_url = ActivityPub.instance_url() + activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}" + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "type" => "EmojiReact", + "actor" => ActivityPub.actor_uri(user, base_url), + "object" => object_uri, + "content" => emoji + } + end + + @doc """ + Builds a Block activity. + """ + def build_block_activity(%User{} = user, target_uri) do + base_url = ActivityPub.instance_url() + activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}" + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "type" => "Block", + "actor" => ActivityPub.actor_uri(user, base_url), + "object" => target_uri + } + end + + @doc """ + Builds an Undo activity (for undoing follows, likes, etc.). + """ + def build_undo_activity(%User{} = user, original_activity) do + base_url = ActivityPub.instance_url() + activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}" + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "type" => "Undo", + "actor" => ActivityPub.actor_uri(user, base_url), + "object" => original_activity, + "published" => DateTime.utc_now() |> DateTime.to_iso8601() + } + |> maybe_copy_routing_from_original(original_activity) + end + + defp maybe_copy_routing_from_original(activity, original_activity) + when is_map(original_activity) do + Enum.reduce(["to", "cc", "audience", "context"], activity, fn field, acc -> + case Map.get(original_activity, field) do + nil -> acc + value -> Map.put(acc, field, value) + end + end) + end + + defp maybe_copy_routing_from_original(activity, _), do: activity + + @doc """ + Builds a Delete activity with a Tombstone object. + """ + def build_delete_activity(actor_or_user, object_uri, former_type \\ "Note", opts \\ []) + + def build_delete_activity(%User{} = user, object_uri, former_type, opts) do + base_url = ActivityPub.instance_url() + activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}" + {to, cc} = activity_audience(nil, opts, ["https://www.w3.org/ns/activitystreams#Public"], []) + + tombstone = %{ + "type" => "Tombstone", + "id" => object_uri, + "formerType" => former_type, + "deleted" => DateTime.utc_now() |> DateTime.to_iso8601() + } + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "type" => "Delete", + "actor" => ActivityPub.actor_uri(user, base_url), + "object" => tombstone + } + |> maybe_put_list("to", to) + |> maybe_put_list("cc", cc) + end + + def build_delete_activity( + %Elektrine.ActivityPub.Actor{} = actor, + object_uri, + former_type, + opts + ) do + activity_id = "#{actor.uri}/activities/#{Ecto.UUID.generate()}" + {to, cc} = activity_audience(nil, opts, ["https://www.w3.org/ns/activitystreams#Public"], []) + + tombstone = %{ + "type" => "Tombstone", + "id" => object_uri, + "formerType" => former_type, + "deleted" => DateTime.utc_now() |> DateTime.to_iso8601() + } + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "type" => "Delete", + "actor" => actor.uri, + "object" => tombstone + } + |> maybe_put_list("to", to) + |> maybe_put_list("cc", cc) + end + + @doc """ + Builds an Update activity (for profile or post updates). + """ + def build_update_activity(actor_or_user, object, opts \\ []) + + def build_update_activity(%User{} = user, object, opts) do + base_url = ActivityPub.instance_url() + activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}" + + {to, cc} = + activity_audience(object, opts, ["https://www.w3.org/ns/activitystreams#Public"], []) + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "type" => "Update", + "actor" => ActivityPub.actor_uri(user, base_url), + "object" => object + } + |> maybe_put_list("to", to) + |> maybe_put_list("cc", cc) + end + + def build_update_activity(%Elektrine.ActivityPub.Actor{} = actor, object, opts) do + activity_id = "#{actor.uri}/activities/#{Ecto.UUID.generate()}" + + {to, cc} = + activity_audience(object, opts, ["https://www.w3.org/ns/activitystreams#Public"], []) + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "type" => "Update", + "actor" => actor.uri, + "object" => object + } + |> maybe_put_list("to", to) + |> maybe_put_list("cc", cc) + end + + @doc """ + Builds a Flag (report) activity for reporting users or content to remote instances. + + ## Parameters + - `user` - The user making the report + - `target_actor_uri` - The AP URI of the user being reported + - `object_uris` - List of content URIs being reported (posts, etc.) + - `content` - The report reason/description + + ## Example + build_flag_activity(user, "https://remote.server/users/baduser", + ["https://remote.server/posts/123"], "Spam content") + """ + def build_flag_activity(%User{} = user, target_actor_uri, object_uris \\ [], content \\ nil) do + base_url = ActivityPub.instance_url() + activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}" + + # Object is an array containing the reported actor and optionally reported content + objects = + [target_actor_uri | List.wrap(object_uris)] + |> Enum.filter(& &1) + |> Enum.uniq() + + activity = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "type" => "Flag", + "actor" => ActivityPub.actor_uri(user, base_url), + "object" => objects + } + + # Add content/reason if provided + if Elektrine.Strings.present?(content) do + Map.put(activity, "content", content) + else + activity + end + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, _key, ""), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp activity_audience(object, opts, default_to, default_cc) do + to = + select_audience( + get_builder_opt(opts, :to, object_audience(object, "to")), + default_to + ) + + cc = + select_audience( + get_builder_opt(opts, :cc, object_audience(object, "cc")), + default_cc + ) + + {to, cc} + end + + defp object_audience(object, field) when is_map(object), do: Map.get(object, field) + defp object_audience(_object, _field), do: nil + + defp select_audience(value, fallback) do + case normalize_audience(value) do + [] -> normalize_audience(fallback) + audience -> audience + end + end + + defp normalize_audience(values) when is_list(values) do + values + |> Enum.map(&Elektrine.Strings.present/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp normalize_audience(value) when is_binary(value) do + if Elektrine.Strings.present?(value) do + [value] + else + [] + end + end + + defp normalize_audience(_value), do: [] + + defp maybe_put_list(map, _key, values) when values in [nil, []], do: map + + defp maybe_put_list(map, key, values) when is_list(values) do + normalized = + values + |> Enum.map(&Elektrine.Strings.present/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + + if normalized == [] do + map + else + Map.put(map, key, normalized) + end + end + + defp actor_base_url_from_uri(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{scheme: scheme, host: host, port: port} + when scheme in ["http", "https"] and is_binary(host) -> + if is_nil(port) do + "#{scheme}://#{host}" + else + "#{scheme}://#{host}:#{port}" + end + + _ -> + nil + end + end + + # Walk up the reply chain to find community_actor_uri (for Lemmy posts) + # This ensures replies to replies in a Lemmy thread have the correct audience + defp get_community_uri_from_chain(message, depth \\ 0) + + defp get_community_uri_from_chain(_message, depth) when depth > 10 do + # Prevent infinite loops - max 10 levels deep + nil + end + + defp get_community_uri_from_chain(message, depth) do + # Check current message's metadata + case get_in(message.media_metadata || %{}, ["community_actor_uri"]) do + uri when is_binary(uri) -> + uri + + nil -> + # Check parent message if this is a reply + if message.reply_to_id do + parent = Elektrine.Messaging.get_message(message.reply_to_id) + + if parent do + get_community_uri_from_chain(parent, depth + 1) + else + nil + end + else + nil + end + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/collection_count_sync_worker.ex b/apps/elektrine/lib/elektrine/activitypub/collection_count_sync_worker.ex new file mode 100644 index 0000000..4bafe89 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/collection_count_sync_worker.ex @@ -0,0 +1,101 @@ +defmodule Elektrine.ActivityPub.CollectionCountSyncWorker do + @moduledoc """ + Fetches collection-backed like/reply counts for a cached remote message. + """ + + use Oban.Worker, + queue: :federation, + max_attempts: 2, + unique: [ + period: 300, + keys: [:message_id], + states: [:available, :scheduled, :executing, :retryable] + ] + + import Ecto.Query + + alias Elektrine.ActivityPub.CollectionFetcher + alias Elektrine.Repo + alias Elektrine.Social.{EngagementCounts, Message, MessageStats} + + def enqueue(message_id, post) when is_integer(message_id) and is_map(post) do + %{ + "message_id" => message_id, + "likes" => Map.get(post, "likes"), + "replies" => Map.get(post, "replies"), + "comments" => Map.get(post, "comments"), + "replies_count" => Map.get(post, "repliesCount") + } + |> new() + |> Elektrine.JobQueue.insert() + end + + def enqueue(_, _), do: {:error, :invalid_collection_sync} + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"message_id" => message_id} = args}) do + like_count = fetch_collection_count(args["likes"]) + + reply_count = + [ + fetch_collection_count(args["replies"]), + fetch_collection_count(args["comments"]), + parse_non_negative_count(args["replies_count"]) + ] + |> Enum.max(fn -> 0 end) + + if like_count > 0 || reply_count > 0 do + fetched_at = DateTime.utc_now() |> DateTime.truncate(:second) + remote_like_count = EngagementCounts.nullable_remote_count(like_count) + remote_reply_count = EngagementCounts.nullable_remote_count(reply_count) + + Repo.update_all( + from(m in Message, + where: m.id == ^message_id, + update: [ + set: [ + like_count: fragment("GREATEST(like_count, ?)", ^like_count), + reply_count: fragment("GREATEST(reply_count, ?)", ^reply_count), + remote_like_count: ^remote_like_count, + remote_reply_count: ^remote_reply_count, + remote_counts_fetched_at: ^fetched_at + ] + ] + ), + [] + ) + + MessageStats.upsert_counts(message_id, %{ + like_count: like_count, + reply_count: reply_count, + remote_like_count: remote_like_count, + remote_reply_count: remote_reply_count, + remote_counts_fetched_at: fetched_at + }) + end + + :ok + end + + defp fetch_collection_count(nil), do: 0 + + defp fetch_collection_count(value) when is_binary(value) or is_map(value) do + case CollectionFetcher.fetch_collection_count(value) do + {:ok, count} -> parse_non_negative_count(count) + _ -> 0 + end + end + + defp fetch_collection_count(_), do: 0 + + defp parse_non_negative_count(value) when is_integer(value), do: max(value, 0) + + defp parse_non_negative_count(value) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {parsed, _} -> max(parsed, 0) + :error -> 0 + end + end + + defp parse_non_negative_count(_), do: 0 +end diff --git a/apps/elektrine/lib/elektrine/activitypub/collection_fetcher.ex b/apps/elektrine/lib/elektrine/activitypub/collection_fetcher.ex new file mode 100644 index 0000000..15a3ba6 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/collection_fetcher.ex @@ -0,0 +1,316 @@ +defmodule Elektrine.ActivityPub.CollectionFetcher do + @moduledoc """ + Fetches and paginates through ActivityPub collections. + + Handles various collection formats: + - Collection / OrderedCollection + - CollectionPage / OrderedCollectionPage + - Paginated collections with `first`, `next` links + + Based on: https://www.w3.org/TR/activitystreams-core/#paging + """ + + alias Elektrine.ActivityPub.RemoteFetch + require Logger + + @max_collection_items Application.compile_env( + :elektrine, + [:activitypub, :max_collection_items], + 100 + ) + @max_pages Application.compile_env(:elektrine, [:activitypub, :max_collection_pages], 10) + + @type fetch_result :: {:ok, list(map())} | {:partial, list(map())} | {:error, any()} + + @doc """ + Fetches items from an ActivityPub collection. + + Accepts either a URL string or a collection object map. + Automatically handles pagination up to configured limits. + + ## Options + - `:max_items` - Maximum items to fetch (default: #{@max_collection_items}) + - `:max_pages` - Maximum pages to traverse (default: #{@max_pages}) + + ## Examples + + iex> fetch_collection("https://example.com/users/alice/likes") + {:ok, [%{"id" => "...", "type" => "Like"}, ...]} + + iex> fetch_collection(%{"type" => "Collection", "items" => [...]}) + {:ok, [...]} + """ + @spec fetch_collection(String.t() | map(), keyword()) :: fetch_result() + def fetch_collection(collection, opts \\ []) + + def fetch_collection(url, opts) when is_binary(url) do + case RemoteFetch.fetch_object(url, fetch_object_opts(opts)) do + {:ok, page} -> + collect_from_page(page, opts) + + {:error, reason} -> + Logger.warning("Could not fetch collection #{url}: #{inspect(reason)}") + {:error, reason} + end + end + + def fetch_collection(%{"type" => type} = collection, opts) + when type in ["Collection", "OrderedCollection", "CollectionPage", "OrderedCollectionPage"] do + cond do + collection_page_content?(collection) -> + collect_from_page(collection, opts) + + count_only_reference?(collection) -> + fetch_collection(collection["id"], opts) + + true -> + collect_from_page(collection, opts) + end + end + + # Untyped collection references: use embedded content when present, otherwise + # follow the "id" URL (count-only refs like %{"id" => ..., "totalItems" => 74}). + def fetch_collection(%{} = collection, opts) do + cond do + items_from_page(collection) != [] or is_map(collection["first"]) or + is_binary(collection["first"]) or is_binary(collection["next"]) -> + collect_from_page(collection, opts) + + is_binary(collection["id"]) -> + fetch_collection(collection["id"], opts) + + true -> + {:ok, []} + end + end + + def fetch_collection(nil, _opts), do: {:ok, []} + def fetch_collection(_, _opts), do: {:error, :invalid_collection} + + @doc """ + Fetches only the count from a collection without fetching items. + + More efficient when you only need the total. + """ + @spec fetch_collection_count(String.t() | map()) :: {:ok, non_neg_integer()} | {:error, any()} + def fetch_collection_count(url) when is_binary(url) do + case RemoteFetch.fetch_object(url) do + {:ok, collection} -> + count_collection_items(collection) + + {:error, reason} -> + {:error, reason} + end + end + + def fetch_collection_count(collection) when is_map(collection), + do: count_collection_items(collection) + + def fetch_collection_count(_), do: {:ok, 0} + + @doc """ + Fetches actor IDs from a likes/shares collection. + + Returns a list of actor AP IDs who performed the interaction. + """ + @spec fetch_interaction_actors(String.t() | map(), keyword()) :: + {:ok, list(String.t())} | {:error, any()} + def fetch_interaction_actors(collection, opts \\ []) do + case fetch_collection(collection, opts) do + {:ok, items} -> + actors = extract_actors(items) + {:ok, actors} + + {:partial, items} -> + actors = extract_actors(items) + {:ok, actors} + + error -> + error + end + end + + # Private functions + + defp count_collection_items(%{"totalItems" => count}) when is_integer(count) do + {:ok, max(count, 0)} + end + + defp count_collection_items(%{"totalItems" => count} = collection) when is_binary(count) do + case parse_count(count) do + {:ok, parsed_count} -> + {:ok, parsed_count} + + :error -> + count_collection_items_without_total(collection) + end + end + + defp count_collection_items(%{"items" => items}) when is_list(items), do: {:ok, length(items)} + + defp count_collection_items(%{"orderedItems" => items}) when is_list(items), + do: {:ok, length(items)} + + defp count_collection_items(collection) when is_map(collection) do + count_collection_items_without_total(collection) + end + + defp count_collection_items(_), do: {:ok, 0} + + defp count_collection_items_without_total(collection) do + case collect_from_page(collection, max_items: 1000) do + {:ok, items} -> {:ok, length(items)} + {:partial, items} -> {:ok, length(items)} + error -> error + end + end + + defp parse_count(value) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {count, ""} -> {:ok, max(count, 0)} + _ -> :error + end + end + + defp normalize_count(value) when is_integer(value), do: max(value, 0) + + defp normalize_count(value) when is_binary(value) do + case parse_count(value) do + {:ok, count} -> count + :error -> 0 + end + end + + defp normalize_count(_), do: 0 + + defp collect_from_page(page, opts) do + max_items = Keyword.get(opts, :max_items, @max_collection_items) + max_pages = Keyword.get(opts, :max_pages, @max_pages) + fetch_opts = fetch_object_opts(opts) + + do_collect(page, [], max_items, max_pages, 0, fetch_opts) + end + + defp do_collect(_page, items, max_items, _max_pages, _page_count, _fetch_opts) + when length(items) >= max_items do + {:ok, Enum.take(items, max_items)} + end + + defp do_collect(_page, items, _max_items, max_pages, page_count, _fetch_opts) + when page_count >= max_pages do + if items == [] do + {:ok, []} + else + {:partial, items} + end + end + + defp do_collect(page, items, max_items, max_pages, page_count, fetch_opts) do + current_items = items_from_page(page) + all_items = items ++ current_items + + cond do + # Reached item limit + length(all_items) >= max_items -> + {:ok, Enum.take(all_items, max_items)} + + # Has next page + next_ref = get_next_page(page) -> + follow_page(next_ref, all_items, max_items, max_pages, page_count, fetch_opts) + + # Has first page reference (for Collection pointing to first CollectionPage) + first_ref = get_first_page(page) -> + follow_page(first_ref, all_items, max_items, max_pages, page_count, fetch_opts) + + # No more pages + true -> + {:ok, all_items} + end + end + + # Mastodon embeds the first CollectionPage inline (without an "id"), so + # embedded page maps are traversed directly instead of re-fetched by URL. + defp follow_page({:embedded, page}, items, max_items, max_pages, page_count, fetch_opts) do + do_collect(page, items, max_items, max_pages, page_count + 1, fetch_opts) + end + + defp follow_page(url, items, max_items, max_pages, page_count, fetch_opts) + when is_binary(url) do + case RemoteFetch.fetch_object(url, fetch_opts) do + {:ok, page} -> + do_collect(page, items, max_items, max_pages, page_count + 1, fetch_opts) + + {:error, _reason} -> + # Failed to fetch the page, return what we have + if items == [] do + {:error, :fetch_failed} + else + {:partial, items} + end + end + end + + defp items_from_page(%{"orderedItems" => items}) when is_list(items), do: items + defp items_from_page(%{"items" => items}) when is_list(items), do: items + defp items_from_page(_), do: [] + + defp collection_page_content?(collection) do + items_from_page(collection) != [] or is_map(collection["first"]) or + is_binary(collection["first"]) or is_binary(collection["next"]) + end + + defp count_only_reference?(%{"id" => id} = collection) when is_binary(id) do + collection["totalItems"] + |> normalize_count() + |> Kernel.>(0) + end + + defp count_only_reference?(_), do: false + + defp get_next_page(%{"next" => next}) when is_binary(next), do: next + defp get_next_page(%{"next" => %{} = next}), do: page_ref(next) + defp get_next_page(_), do: nil + + defp get_first_page(%{"first" => first}) when is_binary(first), do: first + defp get_first_page(%{"first" => %{} = first}), do: page_ref(first) + defp get_first_page(_), do: nil + + # An embedded page with content is used as-is; a bare reference like + # %{"id" => url} still needs to be fetched. + defp page_ref(%{} = page) do + if items_from_page(page) != [] or get_next_page(page) != nil do + {:embedded, page} + else + case page["id"] do + id when is_binary(id) -> id + _ -> nil + end + end + end + + defp fetch_object_opts(opts) do + opts + |> Keyword.take([:request_fun, :skip_cache, :sign, :validate_url, :allow_recovery]) + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + end + + defp extract_actors(items) do + items + |> Enum.map(&extract_actor/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + # Like/Announce activity - actor is in "actor" field + defp extract_actor(%{"actor" => actor}) when is_binary(actor), do: actor + defp extract_actor(%{"actor" => %{"id" => id}}) when is_binary(id), do: id + + # Direct actor reference (some implementations just list actor IDs) + defp extract_actor(actor) when is_binary(actor), do: actor + + # Actor object + defp extract_actor(%{"id" => id, "type" => type}) + when type in ["Person", "Service", "Application", "Group"], do: id + + defp extract_actor(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/activitypub/community_fetcher.ex b/apps/elektrine/lib/elektrine/activitypub/community_fetcher.ex new file mode 100644 index 0000000..bbdb9fd --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/community_fetcher.ex @@ -0,0 +1,69 @@ +defmodule Elektrine.ActivityPub.CommunityFetcher do + @moduledoc false + + require Logger + + alias Elektrine.Social.Message + + def handle_info(:fetch_community_posts, state) when is_map(state) do + config = Application.get_env(:elektrine, :community_fetcher, []) + followed_communities = Keyword.get(config, :followed_communities, fn -> [] end) + activitypub = Keyword.get(config, :activitypub, Elektrine.ActivityPub) + handler = Keyword.get(config, :handler, Elektrine.ActivityPub.Handler) + messaging = Keyword.get(config, :messaging, Elektrine.Messaging) + sleep = Keyword.get(config, :sleep, fn _ -> :ok end) + + followed_communities.() + |> Enum.each(fn community_actor -> + fetch_and_store_posts(activitypub, handler, messaging, sleep, community_actor) + end) + + {:noreply, state} + end + + def handle_info(_message, state), do: {:noreply, state} + + defp fetch_and_store_posts(activitypub, handler, messaging, sleep, community_actor) do + case activitypub.fetch_remote_user_timeline(community_actor.uri, []) do + {:ok, posts} when is_list(posts) -> + Enum.each(posts, fn post_object -> + process_post(handler, messaging, community_actor, post_object) + sleep.(:between_posts) + end) + + _ -> + :ok + end + end + + defp process_post(handler, messaging, community_actor, post_object) when is_map(post_object) do + actor_uri = post_object["attributedTo"] || community_actor.uri + + try do + case handler.store_remote_post(post_object, actor_uri) do + {:ok, %Message{} = message} -> + metadata = + Map.merge(message.media_metadata || %{}, %{ + "community_actor_uri" => community_actor.uri + }) + + _ = messaging.update_message(message, %{media_metadata: metadata}) + :ok + + _ -> + :ok + end + rescue + error -> + post_id = Map.get(post_object, "id") + + Logger.warning( + "Community fetch ingest failed for #{inspect(post_id)}: #{Exception.message(error)}" + ) + + :ok + end + end + + defp process_post(_handler, _messaging, _community_actor, _post_object), do: :ok +end diff --git a/apps/elektrine/lib/elektrine/activitypub/containment.ex b/apps/elektrine/lib/elektrine/activitypub/containment.ex new file mode 100644 index 0000000..93ec7cf --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/containment.ex @@ -0,0 +1,145 @@ +defmodule Elektrine.ActivityPub.Containment do + @moduledoc """ + Origin containment checks for remote ActivityPub payloads. + + These checks prevent remote servers from smuggling objects that claim to be + authored by another host or by this instance. + """ + + alias Elektrine.ActivityPub + + @actor_types ["Person", "Group", "Application", "Service", "Organization"] + + def validate_fetch(uri, object) when is_binary(uri) and is_map(object) do + with :ok <- contain_local_fetch(uri), + :ok <- contain_fetch_origin(uri, object) do + contain_child(object) + end + end + + def validate_fetch(_uri, _object), do: :ok + + def validate_activity(%{"type" => "Create", "object" => object} = activity) + when is_map(object) do + with :ok <- contain_origin(activity), + :ok <- contain_origin(object) do + contain_child(activity) + end + end + + def validate_activity(%{"type" => "Update", "object" => object} = activity) + when is_map(object) do + with :ok <- contain_origin(activity), + :ok <- contain_origin(object) do + contain_child(activity) + end + end + + def validate_activity(%{"type" => type} = activity) when type in @actor_types do + contain_origin_from_id(actor_uri(activity), activity) + end + + def validate_activity(%{"type" => _type} = activity), do: contain_origin(activity) + def validate_activity(_), do: {:error, :origin_containment_failed} + + def contain_local_fetch(id) when is_binary(id) do + with %URI{host: host} when is_binary(host) <- URI.parse(id), + local when is_binary(local) <- ActivityPub.instance_domain(), + true <- String.downcase(host) == String.downcase(local) do + {:error, :local_fetch_containment_failed} + else + _ -> :ok + end + end + + def contain_local_fetch(_), do: :ok + + def contain_origin(%{"id" => id} = object) when is_binary(id) do + case actor_uri(object) do + actor when is_binary(actor) -> same_origin(actor, id) + _ -> {:error, :origin_containment_failed} + end + end + + def contain_origin(_), do: :ok + + def contain_origin_from_id(id, %{"id" => other_id}) + when is_binary(id) and is_binary(other_id) do + same_origin(id, other_id) + end + + def contain_origin_from_id(id, %{"object" => object}) + when is_binary(id) and is_binary(object) do + same_origin(id, object) + end + + def contain_origin_from_id(_id, _object), do: :ok + + def contain_fetch_origin(uri, %{"_source_url" => source_url} = object) + when is_binary(uri) and is_binary(source_url) do + if comparable_uri(uri) == comparable_uri(source_url) do + contain_origin(object) + else + contain_origin_from_id(uri, object) + end + end + + def contain_fetch_origin(uri, object), do: contain_origin_from_id(uri, object) + + def contain_child(%{"object" => %{"id" => id} = object}) when is_binary(id) do + contain_origin(object) + end + + def contain_child(_), do: :ok + + def actor_uri(%{"actor" => actor}), do: normalize_actor(actor) + def actor_uri(%{"attributedTo" => actor}), do: normalize_actor(actor) + def actor_uri(%{"id" => id, "type" => type}) when type in @actor_types and is_binary(id), do: id + def actor_uri(_), do: nil + + defp normalize_actor(actor) when is_binary(actor), do: actor + defp normalize_actor(%{"id" => id}) when is_binary(id), do: id + + defp normalize_actor([first | _]) when is_binary(first), do: first + + defp normalize_actor(actors) when is_list(actors) do + Enum.find_value(actors, fn + %{"type" => type, "id" => id} when type in @actor_types and is_binary(id) -> id + _ -> nil + end) + end + + defp normalize_actor(_), do: nil + + defp same_origin(left, right) when is_binary(left) and is_binary(right) do + with %URI{host: left_host} when is_binary(left_host) <- URI.parse(left), + %URI{host: right_host} when is_binary(right_host) <- URI.parse(right), + true <- String.downcase(left_host) == String.downcase(right_host) do + :ok + else + _ -> {:error, :origin_containment_failed} + end + end + + defp same_origin(_left, _right), do: {:error, :origin_containment_failed} + + defp comparable_uri(uri) when is_binary(uri) do + uri + |> String.trim() + |> URI.parse() + |> case do + %URI{scheme: scheme, host: host} = parsed when is_binary(scheme) and is_binary(host) -> + path = parsed.path || "/" + + parsed + |> Map.put(:scheme, String.downcase(scheme)) + |> Map.put(:host, String.downcase(host)) + |> Map.put(:path, String.trim_trailing(path, "/")) + |> Map.put(:fragment, nil) + |> URI.to_string() + + _ -> + uri + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/delivery.ex b/apps/elektrine/lib/elektrine/activitypub/delivery.ex new file mode 100644 index 0000000..57b010b --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/delivery.ex @@ -0,0 +1,34 @@ +defmodule Elektrine.ActivityPub.Delivery do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "activitypub_deliveries" do + field :inbox_url, :string + field :status, :string, default: "pending" + field :attempts, :integer, default: 0 + field :last_attempt_at, :utc_datetime + field :next_retry_at, :utc_datetime + field :error_message, :string + + belongs_to :activity, Elektrine.ActivityPub.Activity + + timestamps() + end + + @doc false + def changeset(delivery, attrs) do + delivery + |> cast(attrs, [ + :activity_id, + :inbox_url, + :status, + :attempts, + :last_attempt_at, + :next_retry_at, + :error_message + ]) + |> validate_required([:inbox_url]) + |> validate_inclusion(:status, ["pending", "delivered", "failed"]) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/delivery_cleanup.ex b/apps/elektrine/lib/elektrine/activitypub/delivery_cleanup.ex new file mode 100644 index 0000000..1dc2754 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/delivery_cleanup.ex @@ -0,0 +1,44 @@ +defmodule Elektrine.ActivityPub.DeliveryCleanup do + @moduledoc """ + Background job that cleans up old failed deliveries from the database. + Runs daily to prevent the delivery queue from growing indefinitely. + """ + + use GenServer + require Logger + + # 24 hours + @cleanup_interval 24 * 60 * 60 * 1000 + + def start_link(_opts) do + GenServer.start_link(__MODULE__, %{}, name: __MODULE__) + end + + @impl true + def init(state) do + # Schedule first cleanup after 1 hour + schedule_cleanup(60 * 60 * 1000) + {:ok, state} + end + + @impl true + def handle_info(:cleanup, state) do + Logger.info("DeliveryCleanup: Starting cleanup of old failed deliveries...") + + try do + count = Elektrine.ActivityPub.cleanup_old_deliveries() + Logger.info("DeliveryCleanup: Deleted #{count} old failed deliveries") + rescue + e -> + Logger.error("DeliveryCleanup: Error during cleanup: #{inspect(e)}") + end + + # Schedule next cleanup + schedule_cleanup(@cleanup_interval) + {:noreply, state} + end + + defp schedule_cleanup(interval) do + Process.send_after(self(), :cleanup, interval) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/delivery_cleanup_worker.ex b/apps/elektrine/lib/elektrine/activitypub/delivery_cleanup_worker.ex new file mode 100644 index 0000000..ff56851 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/delivery_cleanup_worker.ex @@ -0,0 +1,38 @@ +defmodule Elektrine.ActivityPub.DeliveryCleanupWorker do + @moduledoc """ + Oban worker for cleaning up old failed ActivityPub deliveries. + + Scheduled via Oban Cron to run daily. Replaces the old DeliveryCleanup GenServer. + """ + + use Oban.Worker, + queue: :default, + max_attempts: 3 + + require Logger + + alias Elektrine.ActivityPub + + @impl Oban.Worker + def perform(%Oban.Job{}) do + Logger.info("DeliveryCleanupWorker: Starting cleanup of old failed deliveries...") + + count = ActivityPub.cleanup_old_deliveries() + Logger.info("DeliveryCleanupWorker: Deleted #{count} old failed deliveries") + + :ok + rescue + e -> + Logger.error("DeliveryCleanupWorker: Error during cleanup: #{inspect(e)}") + {:error, inspect(e)} + end + + @doc """ + Manually enqueue a cleanup job (for testing or manual triggering). + """ + def enqueue do + %{} + |> new() + |> Elektrine.JobQueue.insert() + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/delivery_retry_worker.ex b/apps/elektrine/lib/elektrine/activitypub/delivery_retry_worker.ex new file mode 100644 index 0000000..4e9b839 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/delivery_retry_worker.ex @@ -0,0 +1,22 @@ +defmodule Elektrine.ActivityPub.DeliveryRetryWorker do + @moduledoc """ + Periodically re-enqueues pending federation deliveries that are due for retry. + """ + + use Oban.Worker, + queue: :activitypub_delivery, + max_attempts: 1 + + alias Elektrine.ActivityPub + + @impl Oban.Worker + def perform(%Oban.Job{}) do + delivery_ids = ActivityPub.get_retryable_delivery_ids(500) + + if delivery_ids != [] do + ActivityPub.ActivityDeliveryWorker.enqueue_many(delivery_ids) + end + + :ok + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/domain_delivery_health.ex b/apps/elektrine/lib/elektrine/activitypub/domain_delivery_health.ex new file mode 100644 index 0000000..9b42f04 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/domain_delivery_health.ex @@ -0,0 +1,68 @@ +defmodule Elektrine.ActivityPub.DomainDeliveryHealth do + @moduledoc """ + Uses instance reachability state to gate noisy remote delivery and fetch work. + """ + + alias Elektrine.ActivityPub.Instances + + def filter_deliverable_inboxes(inbox_urls) when is_list(inbox_urls) do + inbox_urls = Enum.uniq(inbox_urls) + deliverable = Enum.filter(inbox_urls, &deliverable_url?/1) + paused = length(inbox_urls) - length(deliverable) + + if paused > 0 do + :telemetry.execute( + [:elektrine, :federation, :domain_delivery, :paused], + %{count: paused}, + %{} + ) + end + + deliverable + end + + def deliverable_url?(url) when is_binary(url) do + case domain_for_url(url) do + domain when is_binary(domain) -> Instances.should_retry?(domain) + _ -> false + end + end + + def deliverable_url?(_url), do: false + + def record_delivery_success(url) when is_binary(url) do + with domain when is_binary(domain) <- domain_for_url(url) do + _ = Instances.set_reachable(domain) + end + + :ok + rescue + _ -> :ok + end + + def record_delivery_success(_url), do: :ok + + def record_delivery_failure(url, _reason) when is_binary(url) do + with domain when is_binary(domain) <- domain_for_url(url) do + _ = Instances.set_unreachable(domain) + end + + :ok + rescue + _ -> :ok + end + + def record_delivery_failure(_url, _reason), do: :ok + + def domain_for_url(url) when is_binary(url) do + case URI.parse(url) do + %URI{host: host} when is_binary(host) -> + host |> String.trim() |> String.downcase() + + _ -> + nil + end + end + + def domain_for_url(_url), do: nil +end diff --git a/apps/elektrine/lib/elektrine/activitypub/domain_migration.ex b/apps/elektrine/lib/elektrine/activitypub/domain_migration.ex new file mode 100644 index 0000000..99a4dfb --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/domain_migration.ex @@ -0,0 +1,353 @@ +defmodule Elektrine.ActivityPub.DomainMigration do + @moduledoc """ + Helpers for ActivityPub domain migration (Move activities). + """ + + import Ecto.Query, warn: false + + alias Elektrine.Accounts + alias Elektrine.Accounts.User + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.{Actor, Builder, Publisher} + alias Elektrine.Domains + alias Elektrine.Repo + + @doc """ + Verifies, stores, and broadcasts a single local account move. + """ + def move_account(user, target_account, opts \\ []) + + def move_account(%User{} = user, target_account, opts) when is_binary(target_account) do + with {:ok, target_actor} <- resolve_move_target_actor(target_account, opts), + old_actor_uri <- ActivityPub.actor_uri(user), + :ok <- validate_target_alias(target_actor, old_actor_uri), + {:ok, updated_user} <- Accounts.update_user(user, %{moved_to: target_actor.uri}), + {:ok, summary} <- broadcast_account_move(updated_user, target_actor.uri, opts) do + {:ok, Map.put(summary, :user, updated_user)} + end + end + + def move_account(%User{}, _target_account, _opts), do: {:error, :invalid_move_target} + + @doc """ + Broadcasts a Move activity for an already-verified local account move. + """ + def broadcast_account_move(user, target_actor_uri, opts \\ []) + + def broadcast_account_move(%User{} = user, target_actor_uri, opts) + when is_binary(target_actor_uri) do + old_actor_uri = ActivityPub.actor_uri(user) + move_activity = Builder.build_move_activity(user, old_actor_uri, target_actor_uri) + inboxes = Publisher.get_follower_inboxes(user.id) |> Enum.uniq() + + if Keyword.get(opts, :dry_run, false) do + {:ok, + %{ + activity: nil, + target_actor_uri: target_actor_uri, + old_actor_uri: old_actor_uri, + deliveries_queued: length(inboxes), + dry_run: true + }} + else + with {:ok, activity_record} <- Publisher.publish(move_activity, user, inboxes) do + {:ok, + %{ + activity: activity_record, + target_actor_uri: target_actor_uri, + old_actor_uri: old_actor_uri, + deliveries_queued: length(inboxes), + dry_run: false + }} + end + end + end + + def broadcast_account_move(%User{}, _target_actor_uri, _opts), + do: {:error, :invalid_move_target} + + @doc """ + Broadcasts Move activities to remote follower inboxes. + + ## Options + * `:from_domain` - legacy domain to move from (defaults to `ACTIVITYPUB_MOVE_FROM_DOMAIN`) + * `:to_domain` - target domain to move to (defaults to current `INSTANCE_DOMAIN`) + * `:dry_run` - if true, only computes counts without delivering + * `:usernames` - optional list of usernames to migrate + * `:limit` - optional max number of users + """ + def broadcast_moves(opts \\ []) do + with {:ok, from_domain} <- resolve_from_domain(opts), + {:ok, to_domain} <- resolve_to_domain(opts), + :ok <- validate_domain_pair(from_domain, to_domain) do + users = users_for_migration(opts) + dry_run = Keyword.get(opts, :dry_run, false) + from_base_url = ActivityPub.instance_url_for_domain(from_domain) + to_base_url = ActivityPub.instance_url_for_domain(to_domain) + + summary = + Enum.reduce(users, initial_summary(from_domain, to_domain, dry_run), fn user, acc -> + old_actor_uri = ActivityPub.actor_uri(user, from_base_url) + new_actor_uri = ActivityPub.actor_uri(user, to_base_url) + move_activity = Builder.build_move_activity(user, old_actor_uri, new_actor_uri) + inboxes = Publisher.get_follower_inboxes(user.id) |> Enum.uniq() + + acc = %{acc | users_processed: acc.users_processed + 1} + + cond do + inboxes == [] -> + %{acc | users_without_remote_followers: acc.users_without_remote_followers + 1} + + dry_run -> + %{acc | deliveries_attempted: acc.deliveries_attempted + length(inboxes)} + + true -> + deliver_move(user, move_activity, inboxes, from_base_url, acc) + end + end) + + {:ok, summary} + end + end + + defp resolve_from_domain(opts) do + case normalize_domain(Keyword.get(opts, :from_domain, Domains.activitypub_move_from_domain())) do + nil -> {:error, :missing_from_domain} + domain -> {:ok, domain} + end + end + + defp resolve_to_domain(opts) do + case normalize_domain(Keyword.get(opts, :to_domain, ActivityPub.instance_domain())) do + nil -> {:error, :missing_to_domain} + domain -> {:ok, domain} + end + end + + defp validate_domain_pair(from_domain, to_domain) when from_domain == to_domain do + {:error, :same_domain} + end + + defp validate_domain_pair(_, _), do: :ok + + defp users_for_migration(opts) do + usernames = + opts + |> Keyword.get(:usernames, []) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + + limit = Keyword.get(opts, :limit) + + base_query = + from(u in User, + where: + u.activitypub_enabled == true and not is_nil(u.activitypub_private_key) and + not is_nil(u.activitypub_public_key), + order_by: [asc: u.id] + ) + + scoped_query = + if usernames == [] do + base_query + else + from(u in base_query, where: u.username in ^usernames) + end + + limited_query = + case limit do + value when is_integer(value) and value > 0 -> + from(u in scoped_query, limit: ^value) + + _ -> + scoped_query + end + + Repo.all(limited_query) + end + + defp deliver_move(user, move_activity, inboxes, from_base_url, acc) do + Enum.reduce(inboxes, acc, fn inbox_url, current -> + current = %{current | deliveries_attempted: current.deliveries_attempted + 1} + + case Publisher.deliver(move_activity, user, inbox_url, key_id_base_url: from_base_url) do + {:ok, :delivered} -> + %{current | deliveries_succeeded: current.deliveries_succeeded + 1} + + {:error, reason} -> + %{ + current + | deliveries_failed: current.deliveries_failed + 1, + errors: [ + %{ + username: user.username, + inbox: inbox_url, + reason: inspect(reason) + } + | current.errors + ] + } + end + end) + end + + defp initial_summary(from_domain, to_domain, dry_run) do + %{ + from_domain: from_domain, + to_domain: to_domain, + dry_run: dry_run, + users_processed: 0, + users_without_remote_followers: 0, + deliveries_attempted: 0, + deliveries_succeeded: 0, + deliveries_failed: 0, + errors: [] + } + end + + defp normalize_domain(domain) when is_binary(domain) do + case domain |> String.trim() |> String.downcase() do + "" -> nil + value -> value + end + end + + defp normalize_domain(_), do: nil + + defp resolve_move_target_actor(target_account, opts) do + case normalize_target_account(target_account) do + nil -> + {:error, :invalid_move_target} + + {:uri, actor_uri} -> + resolve_actor_uri(actor_uri, opts) + + {:acct, acct} -> + resolve_acct_actor(acct, opts) + end + end + + defp normalize_target_account(value) when is_binary(value) do + value = String.trim(value) + + cond do + value == "" -> + nil + + String.starts_with?(value, ["http://", "https://"]) -> + {:uri, value} + + String.starts_with?(value, "acct:") -> + value |> String.trim_leading("acct:") |> normalize_acct_target() + + String.contains?(value, "@") -> + normalize_acct_target(value) + + true -> + nil + end + end + + defp normalize_target_account(_), do: nil + + defp normalize_acct_target(acct) do + acct = + acct + |> String.trim() + |> String.trim_leading("@") + + case String.split(acct, "@", parts: 2) do + [username, domain] when username != "" and domain != "" -> {:acct, "#{username}@#{domain}"} + _ -> nil + end + end + + defp resolve_actor_uri(actor_uri, _opts) do + case ActivityPub.get_actor_by_uri(actor_uri) do + %Actor{} = actor -> + {:ok, actor} + + nil -> + ActivityPub.get_or_fetch_actor(actor_uri) + end + end + + defp resolve_acct_actor(acct, opts) do + with {:ok, {username, domain}} <- split_acct(acct) do + case get_actor_by_handle(username, domain) do + %Actor{} = actor -> + {:ok, actor} + + nil -> + with {:ok, actor_uri} <- ActivityPub.webfinger_lookup(acct, opts) do + resolve_actor_uri(actor_uri, opts) + end + end + end + end + + defp split_acct(acct) when is_binary(acct) do + case String.split(acct, "@", parts: 2) do + [username, domain] when username != "" and domain != "" -> {:ok, {username, domain}} + _ -> {:error, :invalid_move_target} + end + end + + defp get_actor_by_handle(username, domain) do + from(a in Actor, + where: + fragment("LOWER(?)", a.username) == ^String.downcase(username) and + fragment("LOWER(?)", a.domain) == ^String.downcase(domain), + order_by: [asc: a.inserted_at, asc: a.id], + limit: 1 + ) + |> Repo.one() + end + + defp validate_target_alias(%Actor{} = target_actor, old_actor_uri) + when is_binary(old_actor_uri) do + aliases = + target_actor.metadata + |> extract_uri_candidates("alsoKnownAs") + |> Enum.map(&normalize_uri/1) + + if normalize_uri(old_actor_uri) in aliases do + :ok + else + {:error, :move_target_not_verified} + end + end + + defp validate_target_alias(_, _), do: {:error, :move_target_not_verified} + + defp extract_uri_candidates(metadata, field) when is_map(metadata) do + metadata + |> Map.get(field) + |> expand_uri_candidates() + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp extract_uri_candidates(_metadata, _field), do: [] + + defp expand_uri_candidates(value) when is_binary(value), do: [value] + + defp expand_uri_candidates(values) when is_list(values), + do: Enum.flat_map(values, &expand_uri_candidates/1) + + defp expand_uri_candidates(%{"id" => id}) when is_binary(id), do: [id] + defp expand_uri_candidates(%{"href" => href}) when is_binary(href), do: [href] + defp expand_uri_candidates(%{"url" => url}) when is_binary(url), do: [url] + defp expand_uri_candidates(_), do: [] + + defp normalize_uri(uri) when is_binary(uri), + do: + uri + |> String.trim() + |> String.split("#", parts: 2) + |> hd() + |> String.split("?", parts: 2) + |> hd() + |> String.trim_trailing("/") +end diff --git a/apps/elektrine/lib/elektrine/activitypub/domain_throttler.ex b/apps/elektrine/lib/elektrine/activitypub/domain_throttler.ex new file mode 100644 index 0000000..372e8bb --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/domain_throttler.ex @@ -0,0 +1,227 @@ +defmodule Elektrine.ActivityPub.DomainThrottler do + @moduledoc """ + Per-domain throttling for ActivityPub processing, similar to Lemmy's approach. + + Lemmy uses one queue per federated instance with configurable concurrent sends. + This module provides similar functionality by: + - Limiting concurrent processing per domain (default: 1) + - Tracking failed domains with exponential backoff + - Preventing one noisy instance from overwhelming the system + """ + + use GenServer + require Logger + + # Max concurrent jobs per domain. Keep this high enough that large instances + # like lemmy.world don't stall thread hydration behind reaction traffic. + @max_concurrent_per_domain 4 + # Base delay for exponential backoff (2 seconds) + @base_backoff_ms 2_000 + # Max backoff delay (2 minutes) + @max_backoff_ms 120_000 + # After this many consecutive failures, apply backoff + @failure_threshold 5 + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc """ + Attempt to acquire a processing slot for a domain. + Returns {:ok, :acquired} if slot available, {:error, :throttled} otherwise. + """ + def acquire(domain) when is_binary(domain) do + GenServer.call(__MODULE__, {:acquire, domain}) + end + + @doc """ + Release a processing slot for a domain. + Call this when done processing an activity from the domain. + """ + def release(domain, success? \\ true) when is_binary(domain) do + GenServer.cast(__MODULE__, {:release, domain, success?}) + end + + @doc """ + Check if a domain is currently in backoff due to failures. + """ + def in_backoff?(domain) when is_binary(domain) do + GenServer.call(__MODULE__, {:in_backoff?, domain}) + end + + @doc """ + Get the current delay for a domain (0 if no backoff). + """ + def get_delay(domain) when is_binary(domain) do + GenServer.call(__MODULE__, {:get_delay, domain}) + end + + @doc """ + Get stats about current throttling state. + """ + def stats do + GenServer.call(__MODULE__, :stats) + end + + # GenServer callbacks + + @impl true + def init(_opts) do + # ETS table for concurrent counts per domain + :ets.new(:domain_concurrent, [:named_table, :public, :set]) + # ETS table for failure tracking + :ets.new(:domain_failures, [:named_table, :public, :set]) + + Logger.info( + "Startup: domain throttler ready (max_concurrent_per_domain=#{@max_concurrent_per_domain})" + ) + + {:ok, %{}} + end + + @impl true + def handle_call({:acquire, domain}, _from, state) do + now = System.system_time(:millisecond) + + # Check if domain is in backoff + case :ets.lookup(:domain_failures, domain) do + [{^domain, failures, last_failure, _last_success}] + when is_integer(failures) and is_integer(last_failure) and failures >= @failure_threshold -> + backoff_ms = calculate_backoff(failures) + time_since_failure = now - last_failure + + if time_since_failure < backoff_ms do + # Still in backoff period + {:reply, {:error, :backoff, backoff_ms - time_since_failure}, state} + else + # Backoff expired, try to acquire + try_acquire(domain, state) + end + + _ -> + try_acquire(domain, state) + end + end + + def handle_call({:in_backoff?, domain}, _from, state) do + now = System.system_time(:millisecond) + + result = + case :ets.lookup(:domain_failures, domain) do + [{^domain, failures, last_failure, _}] + when is_integer(failures) and is_integer(last_failure) and failures >= @failure_threshold -> + backoff_ms = calculate_backoff(failures) + now - last_failure < backoff_ms + + _ -> + false + end + + {:reply, result, state} + end + + def handle_call({:get_delay, domain}, _from, state) do + now = System.system_time(:millisecond) + + delay = + case :ets.lookup(:domain_failures, domain) do + [{^domain, failures, last_failure, _}] + when is_integer(failures) and is_integer(last_failure) and failures >= @failure_threshold -> + backoff_ms = calculate_backoff(failures) + remaining = backoff_ms - (now - last_failure) + max(0, remaining) + + _ -> + 0 + end + + {:reply, delay, state} + end + + def handle_call(:stats, _from, state) do + concurrent = :ets.tab2list(:domain_concurrent) + + failures = + :ets.tab2list(:domain_failures) + |> Enum.filter(fn {_domain, count, _last_fail, _last_success} -> + is_integer(count) and count >= @failure_threshold + end) + + stats = %{ + active_domains: length(concurrent), + domains_in_backoff: length(failures), + max_concurrent_per_domain: @max_concurrent_per_domain + } + + {:reply, stats, state} + end + + @impl true + def handle_cast({:release, domain, success?}, state) do + # Decrement concurrent count + case :ets.lookup(:domain_concurrent, domain) do + [{^domain, count}] when count > 0 -> + if count == 1 do + :ets.delete(:domain_concurrent, domain) + else + :ets.insert(:domain_concurrent, {domain, count - 1}) + end + + _ -> + :ok + end + + # Update failure tracking + now = System.system_time(:millisecond) + + if success? do + # Reset or decrement failure count on success + case :ets.lookup(:domain_failures, domain) do + [{^domain, failures, last_failure, _last_success}] + when is_integer(failures) and is_integer(last_failure) -> + if failures > 0 do + :ets.insert(:domain_failures, {domain, max(0, failures - 1), last_failure, now}) + end + + _ -> + :ok + end + else + # Increment failure count + case :ets.lookup(:domain_failures, domain) do + [{^domain, failures, _last_failure, last_success}] + when is_integer(failures) and is_integer(last_success) -> + :ets.insert(:domain_failures, {domain, failures + 1, now, last_success}) + + _ -> + :ets.insert(:domain_failures, {domain, 1, now, 0}) + end + end + + {:noreply, state} + end + + # Private functions + + defp try_acquire(domain, state) do + current = + case :ets.lookup(:domain_concurrent, domain) do + [{^domain, count}] when is_integer(count) and count > 0 -> count + _ -> 0 + end + + if current < @max_concurrent_per_domain do + :ets.insert(:domain_concurrent, {domain, current + 1}) + {:reply, {:ok, :acquired}, state} + else + {:reply, {:error, :throttled}, state} + end + end + + defp calculate_backoff(failures) do + # Exponential backoff: base * 2^(failures - threshold) + exponent = (failures - @failure_threshold) |> max(0) |> min(16) + delay = @base_backoff_ms * :math.pow(2, exponent) + min(round(delay), @max_backoff_ms) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/federation_cleanup_worker.ex b/apps/elektrine/lib/elektrine/activitypub/federation_cleanup_worker.ex new file mode 100644 index 0000000..f2b442a --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/federation_cleanup_worker.ex @@ -0,0 +1,73 @@ +defmodule Elektrine.ActivityPub.FederationCleanupWorker do + @moduledoc """ + Retention cleanup for federation-side cache and bookkeeping tables. + """ + + use Oban.Worker, + queue: :federation_metadata, + max_attempts: 1, + priority: 9 + + import Ecto.Query + + alias Elektrine.ActivityPub.{Instances, Tombstone} + alias Elektrine.Repo + alias Elektrine.Social.{LinkPreview, Message} + alias Oban.Job + + @impl Oban.Worker + def perform(%Oban.Job{}) do + %{ + deliveries: Elektrine.ActivityPub.cleanup_old_deliveries(), + instances: Instances.cleanup_old_records(), + tombstones: prune_old_tombstones(), + link_previews: prune_orphaned_link_previews(), + completed_jobs: prune_old_completed_oban_jobs() + } + + :ok + end + + defp prune_old_tombstones do + cutoff = DateTime.utc_now() |> DateTime.add(-180, :day) + + {count, _} = + from(t in Tombstone, + where: t.received_at < ^cutoff + ) + |> Repo.delete_all() + + count + end + + defp prune_orphaned_link_previews do + cutoff = DateTime.utc_now() |> DateTime.add(-30, :day) + + referenced_ids = + from(m in Message, + where: not is_nil(m.link_preview_id), + select: m.link_preview_id + ) + + {count, _} = + from(p in LinkPreview, + where: p.inserted_at < ^cutoff, + where: p.id not in subquery(referenced_ids) + ) + |> Repo.delete_all() + + count + end + + defp prune_old_completed_oban_jobs do + cutoff = DateTime.utc_now() |> DateTime.add(-6, :hour) + + {count, _} = + from(j in Job, + where: j.state == "completed" and j.completed_at < ^cutoff + ) + |> Repo.delete_all() + + count + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/federation_helpers.ex b/apps/elektrine/lib/elektrine/activitypub/federation_helpers.ex new file mode 100644 index 0000000..428a2eb --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/federation_helpers.ex @@ -0,0 +1,168 @@ +defmodule Elektrine.ActivityPub.FederationHelpers do + @moduledoc """ + Helper functions for testing and managing ActivityPub federation. + """ + + require Logger + import Ecto.Query + + alias Elektrine.Accounts + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.{Builder, Publisher, RemoteFetch} + alias Elektrine.Profiles + alias Elektrine.Repo + + @doc """ + Follow a remote user from a local user. + """ + def follow_remote_user(local_username, remote_handle) do + with {:ok, local_user} <- get_local_user(local_username), + {:ok, local_user} <- ActivityPub.KeyManager.ensure_user_has_keys(local_user), + {:ok, acct} <- parse_remote_handle(remote_handle), + {:ok, actor_uri} <- RemoteFetch.webfinger_lookup(acct), + {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri) do + # Check if already following (local user following remote actor) + existing = + from(f in Profiles.Follow, + where: f.follower_id == ^local_user.id and f.remote_actor_id == ^remote_actor.id + ) + |> Repo.one() + + if existing do + {:error, :already_following} + else + # Build Follow activity + follow_activity = Builder.build_follow_activity(local_user, remote_actor.uri) + + # Create pending follow relationship first + {:ok, follow} = + %Profiles.Follow{} + |> Ecto.Changeset.change(%{ + follower_id: local_user.id, + remote_actor_id: remote_actor.id, + activitypub_id: follow_activity["id"], + pending: true + }) + |> Repo.insert() + + # Send to remote inbox + Publisher.publish(follow_activity, local_user, [remote_actor.inbox_url]) + + Logger.info("Sent Follow request to #{remote_handle}") + + {:ok, + %{ + remote_actor: remote_actor, + follow: follow + }} + end + else + {:error, reason} -> {:error, reason} + end + end + + @doc """ + List all remote users following a local user. + """ + def list_remote_followers(local_username) do + case get_local_user(local_username) do + {:ok, user} -> + follows = + from(f in Profiles.Follow, + where: f.followed_id == ^user.id and not is_nil(f.remote_actor_id), + preload: :remote_actor + ) + |> Repo.all() + + remote_actors = Enum.map(follows, & &1.remote_actor) + {:ok, remote_actors} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Get federated timeline for a user. + Returns posts from remote users they follow. + """ + def get_federated_timeline(local_username, opts \\ []) do + limit = Keyword.get(opts, :limit, 20) + offset = Keyword.get(opts, :offset, 0) + + case get_local_user(local_username) do + {:ok, user} -> + # Get remote actors the user follows + remote_actor_ids = + from(f in Profiles.Follow, + where: f.follower_id == ^user.id and not is_nil(f.remote_actor_id), + select: f.remote_actor_id + ) + |> Repo.all() + + if remote_actor_ids == [] do + {:ok, []} + else + # Get messages from those remote actors + messages = + from(m in Elektrine.Social.Message, + where: m.federated == true and m.remote_actor_id in ^remote_actor_ids, + where: is_nil(m.deleted_at), + order_by: [desc: m.inserted_at], + limit: ^limit, + offset: ^offset, + preload: :remote_actor + ) + |> Repo.all() + + {:ok, messages} + end + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Get all public federated posts (discover feed). + """ + def get_public_federated_posts(opts \\ []) do + limit = Keyword.get(opts, :limit, 20) + offset = Keyword.get(opts, :offset, 0) + + messages = + from(m in Elektrine.Social.Message, + where: m.federated == true and m.visibility == "public", + where: is_nil(m.deleted_at), + order_by: [desc: m.inserted_at], + limit: ^limit, + offset: ^offset, + preload: :remote_actor + ) + |> Repo.all() + + {:ok, messages} + end + + ## Private helpers + + defp get_local_user(username) do + case Accounts.get_user_by_username(username) do + nil -> {:error, :user_not_found} + user -> {:ok, user} + end + end + + defp parse_remote_handle(handle) do + # Handle formats: user@domain.com or @user@domain.com + handle = String.trim_leading(handle, "@") + + case String.split(handle, "@") do + [username, domain] when username != "" and domain != "" -> + {:ok, "#{username}@#{domain}"} + + _ -> + {:error, :invalid_handle} + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/federation_load_guard.ex b/apps/elektrine/lib/elektrine/activitypub/federation_load_guard.ex new file mode 100644 index 0000000..f460e13 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/federation_load_guard.ex @@ -0,0 +1,59 @@ +defmodule Elektrine.ActivityPub.FederationLoadGuard do + @moduledoc """ + Back-pressure guard for nonessential federation jobs. + """ + + import Ecto.Query + + alias Elektrine.Repo + alias Oban.Job + + @default_threshold 50_000 + + def overloaded? do + enabled?() and queued_federation_jobs() >= threshold() + end + + def skip_nonessential?(component \\ :unknown) do + skip? = overloaded?() + + if skip? do + :telemetry.execute( + [:elektrine, :federation, :load_guard, :skip], + %{count: 1}, + %{component: component} + ) + end + + skip? + end + + def queued_federation_jobs do + count = + Repo.one( + from j in Job, + where: j.queue == "federation" and j.state in ["available", "retryable"], + select: count(j.id) + ) || 0 + + :telemetry.execute( + [:elektrine, :federation, :queue_depth], + %{jobs: count}, + %{queue: :federation, states: "available,retryable"} + ) + + count + end + + defp enabled? do + :elektrine + |> Application.get_env(:federation_load_guard, []) + |> Keyword.get(:enabled, true) + end + + defp threshold do + :elektrine + |> Application.get_env(:federation_load_guard, []) + |> Keyword.get(:max_available_or_retryable, @default_threshold) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/fetch_remote_poll_service.ex b/apps/elektrine/lib/elektrine/activitypub/fetch_remote_poll_service.ex new file mode 100644 index 0000000..704fd4a --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/fetch_remote_poll_service.ex @@ -0,0 +1,58 @@ +defmodule Elektrine.ActivityPub.FetchRemotePollService do + @moduledoc """ + Refreshes a remote ActivityPub poll and syncs its local cached poll record. + """ + + require Logger + + alias Elektrine.ActivityPub.Handlers.{CreateHandler, UpdateHandler} + alias Elektrine.ActivityPub.RemoteFetch + alias Elektrine.Repo + alias Elektrine.Social.Message + + def call(%{id: _poll_id, message_id: _message_id} = poll) do + poll = Repo.preload(poll, message: [:remote_actor]) + + with %Message{} = message <- poll.message, + true <- message.federated, + true <- poll_stale?(poll), + activitypub_id when is_binary(activitypub_id) <- message.activitypub_id, + actor_uri when is_binary(actor_uri) <- message.remote_actor && message.remote_actor.uri, + {:ok, object} when is_map(object) <- RemoteFetch.fetch_object(activitypub_id), + {:ok, _} <- UpdateHandler.handle(%{"object" => object}, actor_uri, nil), + {:ok, refreshed_poll} <- CreateHandler.upsert_federated_poll(message.id, object) do + refreshed_poll + |> Ecto.Changeset.change(last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second)) + |> Repo.update() + else + false -> + {:ok, poll} + + nil -> + {:error, :poll_not_refreshable} + + {:error, reason} -> + {:error, reason} + + other -> + Logger.warning("Failed to refresh remote poll #{poll.id}: #{inspect(other)}") + {:error, :refresh_failed} + end + end + + def call(_), do: {:error, :invalid_poll} + + defp poll_stale?(%{last_fetched_at: last_fetched_at, closes_at: closes_at}) do + expires_after_last_fetch? = + is_nil(closes_at) || is_nil(last_fetched_at) || + DateTime.compare(last_fetched_at, closes_at) == :lt + + stale_fetch? = + is_nil(last_fetched_at) || + DateTime.compare(last_fetched_at, DateTime.add(DateTime.utc_now(), -60, :second)) == :lt + + expires_after_last_fetch? && stale_fetch? + end + + defp poll_stale?(_poll), do: true +end diff --git a/apps/elektrine/lib/elektrine/activitypub/fetch_remote_poll_worker.ex b/apps/elektrine/lib/elektrine/activitypub/fetch_remote_poll_worker.ex new file mode 100644 index 0000000..2986716 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/fetch_remote_poll_worker.ex @@ -0,0 +1,69 @@ +defmodule Elektrine.ActivityPub.FetchRemotePollWorker do + @moduledoc """ + Refreshes a cached remote poll through Oban so LiveViews don't perform + network fetches inline. + """ + + use Oban.Worker, + queue: :federation, + max_attempts: 3, + unique: [ + period: 300, + keys: [:message_id], + states: [:available, :scheduled, :executing, :retryable] + ] + + import Ecto.Query + + alias Elektrine.ActivityPub.FetchRemotePollService + alias Elektrine.Messaging + alias Elektrine.Repo + alias Elektrine.Social.{Message, Poll} + + def enqueue(message_id) when is_integer(message_id) and message_id > 0 do + %{"message_id" => message_id} + |> new() + |> Elektrine.JobQueue.insert() + end + + def enqueue(_message_id), do: {:error, :invalid_message_id} + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"type" => "final_due"} = args}) do + args + |> Map.get("limit", 100) + |> enqueue_final_due_batch() + + :ok + end + + def perform(%Oban.Job{args: %{"message_id" => message_id}}) do + case message_id |> Messaging.get_message() |> Repo.preload(poll: [options: []]) do + %{post_type: "poll", poll: poll} when not is_nil(poll) -> + case FetchRemotePollService.call(poll) do + {:ok, _poll} -> :ok + {:error, :poll_not_refreshable} -> {:discard, :poll_not_refreshable} + {:error, reason} -> {:error, reason} + end + + _ -> + {:discard, :message_not_poll} + end + end + + defp enqueue_final_due_batch(limit) do + limit = max(1, min(limit || 100, 500)) + now = DateTime.utc_now() |> DateTime.truncate(:second) + + Poll + |> join(:inner, [p], m in Message, on: m.id == p.message_id) + |> where([p, m], m.federated == true) + |> where([p, _m], not is_nil(p.closes_at) and p.closes_at <= ^now) + |> where([p, _m], is_nil(p.last_fetched_at) or p.last_fetched_at < p.closes_at) + |> order_by([p, _m], asc: p.closes_at) + |> limit(^limit) + |> select([p, _m], p.message_id) + |> Repo.all() + |> Enum.each(&enqueue/1) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/fetcher.ex b/apps/elektrine/lib/elektrine/activitypub/fetcher.ex new file mode 100644 index 0000000..cee22fb --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/fetcher.ex @@ -0,0 +1,1123 @@ +defmodule Elektrine.ActivityPub.Fetcher do + @moduledoc """ + Fetches ActivityPub resources from remote instances using Finch. + Supports signed fetches for instances requiring authorized fetch mode. + """ + + require Logger + + alias Elektrine.ActivityPub.Containment + alias Elektrine.ActivityPub.HTTPSignature + alias Elektrine.ActivityPub.Instances + alias Elektrine.ActivityPub.Visibility + alias Elektrine.HTTP.Backoff + alias Elektrine.Security.URLValidator + + @max_activitypub_body_bytes 2 * 1024 * 1024 + @content_object_types [ + "Note", + "Article", + "Page", + "Question", + "Event", + "Audio", + "Video", + "Image" + ] + + @doc """ + Fetches an actor document from a remote instance. + Uses signed fetch if configured. + Also triggers a background fetch of instance metadata (nodeinfo). + """ + def fetch_actor(uri, opts \\ []) do + with :ok <- validate_fetch_url(uri, :actor, opts) do + # Tests may inject a request function and don't need nodeinfo side effects. + unless Keyword.has_key?(opts, :request_fun) do + # Trigger nodeinfo fetch for this instance (async, deduplicated) + Instances.fetch_metadata_from_url(uri) + end + + do_signed_fetch(uri, opts) + end + end + + @doc """ + Fetches an activity or object from a remote instance. + Uses signed fetch if configured. + Results are cached to reduce network load. + + Options: + - `:skip_cache` - bypass the cache and always fetch fresh (default: false) + - `:sign` - force signed fetch (default: based on config) + """ + def fetch_object(uri, opts \\ []) do + skip_cache = Keyword.get(opts, :skip_cache, false) + + with :ok <- validate_fetch_url(uri, :object, opts) do + result = + if skip_cache do + fetch_and_validate_object(uri, opts) + else + Elektrine.AppCache.get_object(uri, fn -> + fetch_and_validate_object(uri, opts) + end) + end + + enforce_fetched_object_access(uri, result, opts) + end + end + + @doc """ + Fetches an object without caching. Use when you need fresh data. + """ + def fetch_object_uncached(uri, opts \\ []) do + with :ok <- validate_fetch_url(uri, :object, opts) do + # Also invalidate the cache so next regular fetch gets fresh data + Elektrine.AppCache.invalidate_object(uri) + fetch_and_validate_object(uri, opts) + end + end + + defp fetch_and_validate_object(uri, opts) do + with {:ok, object} <- do_signed_fetch(uri, opts), + :ok <- Containment.validate_fetch(uri, object), + :ok <- validate_fetched_object_identity(uri, object, opts), + :ok <- validate_fetched_object_access(uri, object, opts) do + {:ok, object} + end + end + + defp enforce_fetched_object_access(uri, {:ok, object}, opts) do + case validate_fetched_object_access(uri, object, opts) do + :ok -> + {:ok, object} + + {:error, :unauthorized_fetch} = error -> + Elektrine.AppCache.invalidate_object(uri) + error + end + end + + defp enforce_fetched_object_access(_uri, result, _opts), do: result + + defp validate_fetched_object_access(uri, object, _opts), + do: do_validate_fetched_object_access(uri, object) + + defp do_validate_fetched_object_access(_uri, object) when not is_map(object), do: :ok + + defp do_validate_fetched_object_access(_uri, %{"type" => type}) + when type in ["Collection", "OrderedCollection", "CollectionPage", "OrderedCollectionPage"] do + :ok + end + + defp do_validate_fetched_object_access(_uri, %{"type" => type}) + when type in ["Person", "Group", "Application", "Service", "Organization"] do + :ok + end + + defp do_validate_fetched_object_access(uri, %{"type" => "Create"} = activity) do + object = Map.get(activity, "object") + + if Visibility.public_or_unlisted?(activity) or + (is_map(object) and Visibility.public_or_unlisted?(object)) do + :ok + else + reject_unauthorized_object(uri, activity) + end + end + + defp do_validate_fetched_object_access(uri, %{"type" => type} = object) + when type in @content_object_types do + if Visibility.public_or_unlisted?(object) do + :ok + else + reject_unauthorized_object(uri, object) + end + end + + defp do_validate_fetched_object_access(_uri, _object), do: :ok + + defp reject_unauthorized_object(uri, object) do + Logger.warning( + "Rejected fetched ActivityPub content outside public audience: requested=#{inspect(uri)} id=#{inspect(object["id"])}" + ) + + {:error, :unauthorized_fetch} + end + + defp validate_fetched_object_identity(_uri, object, _opts) when not is_map(object), do: :ok + + defp validate_fetched_object_identity(_uri, %{"type" => type}, _opts) + when type in ["Collection", "OrderedCollection", "CollectionPage", "OrderedCollectionPage"] do + :ok + end + + defp validate_fetched_object_identity(uri, object, opts) do + if Keyword.get(opts, :skip_identity_check, false) do + :ok + else + requested_uri = comparable_object_uri(uri) + object_uris = object_identity_candidates(object) |> Enum.map(&comparable_object_uri/1) + + if requested_uri in object_uris do + :ok + else + Logger.warning( + "Rejected fetched object due to id mismatch: requested=#{inspect(uri)} returned=#{inspect(object["id"])}" + ) + + {:error, :object_id_mismatch} + end + end + end + + defp object_identity_candidates(%{"type" => "Create", "object" => object} = activity) do + expand_uri_candidates(activity) ++ expand_uri_candidates(object) + end + + defp object_identity_candidates(object), do: expand_uri_candidates(object) + + defp expand_uri_candidates(value) when is_binary(value), do: [value] + + defp expand_uri_candidates(values) when is_list(values), + do: Enum.flat_map(values, &expand_uri_candidates/1) + + defp expand_uri_candidates(value) when is_map(value) do + value + |> Map.take(["id", "url", "href", "_source_url"]) + |> Map.values() + |> Enum.flat_map(&expand_uri_candidates/1) + end + + defp expand_uri_candidates(_), do: [] + + defp comparable_object_uri(uri) when is_binary(uri) do + uri + |> String.trim() + |> case do + "" -> + nil + + trimmed -> + case URI.parse(trimmed) do + %URI{scheme: scheme, host: host} = parsed + when is_binary(scheme) and is_binary(host) and host != "" -> + path = parsed.path || "/" + + parsed + |> Map.put(:scheme, String.downcase(scheme)) + |> Map.put(:host, String.downcase(host)) + |> Map.put(:path, String.trim_trailing(path, "/")) + |> Map.put(:fragment, nil) + |> URI.to_string() + + _ -> + trimmed + end + end + end + + defp comparable_object_uri(_), do: nil + + # Performs a signed or unsigned fetch based on configuration + defp do_signed_fetch(uri, opts) do + base_headers = [ + {"accept", + "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""}, + {"user-agent", "Elektrine/1.0"} + ] + + # Check if we should sign the request + sign_fetches = Keyword.get(opts, :sign, signed_fetches_enabled?()) + allow_recovery = Keyword.get(opts, :allow_recovery, true) + + headers = + if sign_fetches do + case get_instance_signing_key() do + {:ok, {private_key, key_id}} -> + # Sign the GET request + signature_headers = HTTPSignature.sign_get(uri, private_key, key_id) + base_headers ++ signature_headers + + {:error, _} -> + # No signing key available, use unsigned request + base_headers + end + else + base_headers + end + + case request_with_backoff(uri, headers, request_opts(opts)) do + {:ok, %Finch.Response{status: 200, body: body}} = response -> + case Jason.decode(body) do + {:ok, data} -> + {:ok, data} + + {:error, reason} -> + if allow_recovery do + maybe_recover_object_from_html(uri, response, reason, opts) + else + {:error, :invalid_json} + end + end + + {:ok, %Finch.Response{} = response} + when response.status in [401, 403] and sign_fetches == false -> + # Instance requires signed fetches - retry with signature + Logger.debug("Instance #{uri} requires signed fetch, retrying...") + do_signed_fetch(uri, Keyword.put(opts, :sign, true)) + + {:ok, %Finch.Response{status: status, body: _body}} when status in [404, 410] -> + if allow_recovery do + case mastodon_status_fallback(uri, opts) do + {:ok, object_data} -> + Logger.info("Recovered status document via Mastodon API fallback for #{uri}") + {:ok, object_data} + + _ -> + Logger.debug("Object not found or deleted: #{uri}, status: #{status}") + {:error, :not_found} + end + else + Logger.debug("Object not found or deleted: #{uri}, status: #{status}") + {:error, :not_found} + end + + {:ok, %Finch.Response{} = response} -> + log_failed_fetch(uri, response) + + {:error, :fetch_failed} + + {:error, :backoff} -> + Logger.debug("Backoff active for #{uri}, deferring fetch") + {:error, :fetch_failed} + + {:error, reason} -> + Logger.warning("HTTP error fetching from #{uri}: #{inspect(reason)}") + {:error, :http_error} + end + end + + defp signed_fetches_enabled? do + Application.get_env(:elektrine, :activitypub, []) + |> Keyword.get(:sign_fetches, false) + end + + # Get the instance actor's signing key for signed fetches + defp get_instance_signing_key do + # Use the first admin user's key, or create an instance actor + case get_instance_actor_key() do + {:ok, _} = result -> result + {:error, _} -> {:error, :no_signing_key} + end + end + + defp get_instance_actor_key do + # Try to get from the first AP-enabled admin user + import Ecto.Query + + case Elektrine.Repo.one( + from(u in Elektrine.Accounts.User, + where: u.is_admin == true and u.activitypub_enabled == true, + where: not is_nil(u.activitypub_private_key), + limit: 1 + ) + ) do + %{activitypub_private_key: private_key} = user when is_binary(private_key) -> + key_id = Elektrine.ActivityPub.actor_key_id(user) + {:ok, {private_key, key_id}} + + _ -> + {:error, :no_admin_key} + end + end + + @doc """ + Resolves a WebFinger URI to an ActivityPub actor URI. + Results are cached since WebFinger data rarely changes. + """ + def webfinger_lookup(acct, opts \\ []) do + if Keyword.get(opts, :skip_cache, false) do + do_webfinger_lookup(acct, opts) + else + Elektrine.AppCache.get_webfinger(acct, fn -> + do_webfinger_lookup(acct, opts) + end) + end + end + + defp do_webfinger_lookup(acct, opts) do + case String.split(acct, "@", parts: 2) do + [username, domain] when username != "" and domain != "" -> + webfinger_url = "https://#{domain}/.well-known/webfinger?resource=acct:#{acct}" + + headers = [ + {"accept", "application/jrd+json, application/json"}, + {"user-agent", "Elektrine/1.0"} + ] + + with :ok <- validate_fetch_url(webfinger_url, :webfinger, opts) do + case request_with_backoff(webfinger_url, headers, request_opts(opts)) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, %{"links" => links}} -> + # Find the self link with type application/activity+json + actor_link = + Enum.find(links, fn link -> + link["rel"] == "self" && + (link["type"] == "application/activity+json" || + link["type"] == + "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"") + end) + + case actor_link do + %{"href" => href} -> {:ok, href} + _ -> {:error, :no_actor_link} + end + + {:error, _} -> + {:error, :invalid_json} + end + + {:ok, %Finch.Response{status: 404}} -> + Logger.debug("WebFinger lookup not found: #{acct}") + {:error, :not_found} + + {:ok, %Finch.Response{status: status}} when status in [400, 410] -> + Logger.debug("WebFinger lookup rejected for #{acct}, status: #{status}") + {:error, :webfinger_failed} + + {:ok, %Finch.Response{status: status}} -> + Logger.warning("WebFinger lookup failed for #{acct}, status: #{status}") + {:error, :webfinger_failed} + + {:error, :backoff} -> + Logger.warning("WebFinger lookup deferred due to remote backoff: #{webfinger_url}") + {:error, :webfinger_failed} + + {:error, reason} -> + Logger.warning("HTTP error during WebFinger: #{inspect(reason)}") + {:error, :http_error} + end + end + + _ -> + {:error, :invalid_acct} + end + end + + defp request_opts(opts) do + [recv_timeout: 10_000, timeout: 10_000, max_body_bytes: @max_activitypub_body_bytes] + |> Keyword.merge(Keyword.take(opts, [:request_fun])) + end + + defp maybe_recover_object_from_html( + uri, + {:ok, %Finch.Response{body: body, headers: response_headers}}, + reason, + opts + ) do + case lemmy_object_fallback(uri, body, response_headers, opts) do + {:ok, object_data, recovery_type} -> + Logger.info("Recovered #{recovery_type} document via Lemmy fallback for #{uri}") + {:ok, object_data} + + _ -> + Logger.warning( + "Expected ActivityPub JSON from #{uri}, got non-JSON response: " <> + "content_type=#{inspect(response_content_type(response_headers))} " <> + "body_preview=#{inspect(body_preview(body))} decode_error=#{inspect(reason)}" + ) + + {:error, :invalid_json} + end + end + + defp response_content_type(headers) when is_list(headers) do + headers + |> Enum.find_value(fn {key, value} -> + if String.downcase(to_string(key)) == "content-type", do: value + end) + end + + defp response_content_type(_), do: nil + + defp body_preview(body) when is_binary(body) do + body + |> String.replace(~r/\s+/, " ") + |> String.slice(0, 120) + end + + defp body_preview(_), do: nil + + defp mastodon_status_fallback(uri, opts) when is_binary(uri) do + case extract_mastodon_status_info(uri) do + {:ok, domain, username, status_id} -> + with {:ok, status} <- fetch_mastodon_status(domain, status_id, opts), + {:ok, object} <- normalize_mastodon_status(status, uri, username) do + {:ok, object} + else + _ -> {:error, :no_status_fallback} + end + + {:search, domain, status_url} -> + with {:ok, status} <- fetch_mastodon_status_via_search(domain, status_url, opts), + {:ok, object} <- normalize_mastodon_status(status, uri, nil) do + {:ok, object} + else + _ -> {:error, :no_status_fallback} + end + + _ -> + {:error, :no_status_fallback} + end + end + + defp mastodon_status_fallback(_, _), do: {:error, :no_status_fallback} + + defp extract_mastodon_status_info(url) when is_binary(url) do + cond do + match = Regex.run(~r{https?://([^/]+)/users/([^/]+)/statuses/([a-zA-Z0-9_-]+)}, url) -> + [_, domain, username, status_id] = match + {:ok, domain, username, status_id} + + match = Regex.run(~r{https?://([^/]+)/@([^/]+)/([a-zA-Z0-9_-]+)}, url) -> + [_, domain, username, status_id] = match + {:ok, domain, username, status_id} + + match = Regex.run(~r{https?://([^/]+)/objects/[a-f0-9-]+(?:$|[/?#])}i, url) -> + [_, domain] = match + {:search, domain, url} + + true -> + {:error, :unsupported_status_url} + end + end + + defp fetch_mastodon_status(domain, status_id, opts) do + api_url = "https://#{domain}/api/v1/statuses/#{status_id}" + + headers = [ + {"accept", "application/json"}, + {"user-agent", "Elektrine/1.0"} + ] + + case request_with_backoff(api_url, headers, request_opts(opts)) do + {:ok, %Finch.Response{status: 200, body: body}} -> Jason.decode(body) + _ -> {:error, :status_fetch_failed} + end + end + + defp fetch_mastodon_status_via_search(domain, status_url, opts) do + api_url = + "https://#{domain}/api/v2/search?q=#{URI.encode_www_form(status_url)}&type=statuses&resolve=true&limit=1" + + headers = [ + {"accept", "application/json"}, + {"user-agent", "Elektrine/1.0"} + ] + + case request_with_backoff(api_url, headers, request_opts(opts)) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, %{"statuses" => [%{} = status | _]}} -> {:ok, status} + _ -> {:error, :status_search_failed} + end + + _ -> + {:error, :status_search_failed} + end + end + + defp normalize_mastodon_status(%{"account" => _account} = status, requested_uri, username) do + visibility = Map.get(status, "visibility") + + if visibility in ["public", "unlisted", nil] do + actor_uri = mastodon_actor_uri(status, requested_uri, username) + + {:ok, + %{ + "id" => Map.get(status, "uri") || requested_uri, + "type" => "Note", + "content" => Map.get(status, "content") || "", + "published" => Map.get(status, "created_at"), + "url" => Map.get(status, "url") || requested_uri, + "attributedTo" => actor_uri, + "to" => mastodon_status_to_audience(visibility), + "cc" => mastodon_status_cc_audience(visibility, actor_uri), + "indexable" => Map.get(status, "indexable"), + "sensitive" => Map.get(status, "sensitive") || false, + "summary" => Map.get(status, "spoiler_text"), + "attachment" => normalize_mastodon_attachments(Map.get(status, "media_attachments", [])), + "tag" => [] + }} + else + {:error, :unsupported_visibility} + end + end + + defp normalize_mastodon_status(_, _, _), do: {:error, :invalid_status} + + defp mastodon_actor_uri(%{"account" => %{} = account}, requested_uri, username) do + account["uri"] || account["url"] || mastodon_actor_uri(requested_uri, username) + end + + defp mastodon_actor_uri(_, requested_uri, username), + do: mastodon_actor_uri(requested_uri, username) + + defp mastodon_actor_uri(requested_uri, username) do + case URI.parse(requested_uri) do + %URI{scheme: scheme, host: host} when is_binary(scheme) and is_binary(host) -> + if is_binary(username) && username != "" do + "#{scheme}://#{host}/users/#{username}" + else + requested_uri + end + + _ -> + requested_uri + end + end + + defp mastodon_status_to_audience("unlisted"), do: [] + defp mastodon_status_to_audience(_), do: ["https://www.w3.org/ns/activitystreams#Public"] + + defp mastodon_status_cc_audience("unlisted", actor_uri), + do: ["https://www.w3.org/ns/activitystreams#Public", actor_uri <> "/followers"] + + defp mastodon_status_cc_audience(_, _), do: [] + + defp normalize_mastodon_attachments(attachments) when is_list(attachments) do + Enum.map(attachments, fn attachment -> + %{ + "type" => "Document", + "mediaType" => + attachment["mime_type"] || attachment["type"] || "application/octet-stream", + "url" => attachment["url"], + "name" => attachment["description"] + } + end) + |> Enum.filter(&(is_binary(&1["url"]) and &1["url"] != "")) + end + + defp normalize_mastodon_attachments(_), do: [] + + defp lemmy_object_fallback(uri, body, response_headers, opts) do + if html_response?(body, response_headers) do + with {:ok, _resolve_url, type} <- lemmy_resolve_url(uri) do + case normalize_lemmy_html_object(body, uri, type) do + {:ok, object_data} -> + {:ok, object_data, type} + + _ -> + with {:ok, resolve_url, ^type} <- lemmy_resolve_url(uri), + {:ok, resolved_body} <- fetch_lemmy_resolved_object(resolve_url, opts), + {:ok, object_data} <- normalize_lemmy_resolved_object(resolved_body, uri, type) do + {:ok, object_data, type} + else + _ -> {:error, :no_object_fallback} + end + end + end + else + {:error, :no_object_fallback} + end + end + + defp html_response?(body, response_headers) + when is_binary(body) and is_list(response_headers) do + content_type = + response_headers + |> Enum.find_value(fn {key, value} -> + if String.downcase(key) == "content-type", do: String.downcase(value), else: nil + end) + + is_binary(content_type) && String.contains?(content_type, "text/html") && + String.contains?(String.downcase(body), " + {:ok, "#{scheme}://#{host}/api/v4/site", :site} + + %URI{scheme: scheme, host: host, path: "/u/" <> _rest} + when scheme in ["http", "https"] and is_binary(host) -> + {:ok, "#{scheme}://#{host}/api/v4/resolve_object?q=#{URI.encode_www_form(uri)}", :person} + + %URI{scheme: scheme, host: host, path: "/c/" <> _rest} + when scheme in ["http", "https"] and is_binary(host) -> + {:ok, "#{scheme}://#{host}/api/v4/resolve_object?q=#{URI.encode_www_form(uri)}", :group} + + %URI{scheme: scheme, host: host, path: "/comment/" <> comment_id} + when scheme in ["http", "https"] and is_binary(host) -> + if Regex.match?(~r{^\d+(?:$|/)}, comment_id) do + {:ok, "#{scheme}://#{host}/api/v4/resolve_object?q=#{URI.encode_www_form(uri)}", + :comment} + else + {:error, :unsupported_actor_path} + end + + %URI{scheme: scheme, host: host, path: path} + when scheme in ["http", "https"] and is_binary(host) -> + if Regex.match?( + ~r{^/(post/\d+|c/[^/]+/p/\d+|m/[^/]+/[pt]/\d+)(?:$|/)}, + String.trim_leading(path, "/") |> then(&("/" <> &1)) + ) do + {:ok, "#{scheme}://#{host}/api/v4/resolve_object?q=#{URI.encode_www_form(uri)}", :page} + else + {:error, :unsupported_actor_path} + end + + _ -> + {:error, :unsupported_actor_path} + end + end + + defp lemmy_resolve_url(_), do: {:error, :unsupported_actor_path} + + defp fetch_lemmy_resolved_object(resolve_url, opts) do + headers = [ + {"accept", "application/json"}, + {"user-agent", "Elektrine/1.0"} + ] + + case request_lemmy_resolve_with_fallback(resolve_url, headers, request_opts(opts)) do + {:ok, %Finch.Response{status: 200, body: body}} -> + Jason.decode(body) + + _ -> + {:error, :resolve_failed} + end + end + + defp request_lemmy_resolve_with_fallback(resolve_url, headers, opts) do + case request_with_backoff(resolve_url, headers, opts) do + {:ok, %Finch.Response{status: 404}} -> + resolve_url + |> fallback_lemmy_api_url() + |> case do + nil -> {:ok, %Finch.Response{status: 404, headers: [], body: ""}} + fallback_url -> request_with_backoff(fallback_url, headers, opts) + end + + other -> + other + end + end + + defp fallback_lemmy_api_url(url) when is_binary(url) do + if String.contains?(url, "/api/v4/") do + String.replace(url, "/api/v4/", "/api/v3/", global: false) + else + nil + end + end + + defp fallback_lemmy_api_url(_), do: nil + + defp normalize_lemmy_resolved_actor( + %{"site_view" => %{"site" => site}}, + requested_uri, + :site + ) + when is_map(site) do + actor_id = site["actor_id"] || requested_uri + + preferred_username = + actor_id + |> URI.parse() + |> Map.get(:host) + |> case do + host when is_binary(host) and host != "" -> host + _ -> site["name"] || "instance" + end + + {:ok, + %{ + "id" => actor_id, + "type" => "Application", + "preferredUsername" => preferred_username, + "name" => site["name"] || preferred_username, + "summary" => site["description"], + "icon" => normalize_lemmy_image(site["icon"]), + "image" => normalize_lemmy_image(site["banner"]), + "inbox" => site["inbox_url"] || actor_id <> "/inbox", + "published" => site["published"], + "updated" => site["updated"], + "publicKey" => %{ + "id" => actor_id <> "#main-key", + "owner" => actor_id, + "publicKeyPem" => site["public_key"] || unavailable_public_key_pem() + }, + "endpoints" => %{"sharedInbox" => shared_inbox_for_actor(actor_id)} + }} + end + + defp normalize_lemmy_resolved_actor( + %{"person" => %{"person" => person}}, + requested_uri, + :person + ) + when is_map(person) do + actor_id = person["actor_id"] || requested_uri + + {:ok, + %{ + "id" => actor_id, + "type" => "Person", + "preferredUsername" => person["name"], + "name" => person["display_name"], + "summary" => person["bio"], + "icon" => normalize_lemmy_image(person["avatar"]), + "image" => normalize_lemmy_image(person["banner"]), + "inbox" => actor_id <> "/inbox", + "outbox" => actor_id <> "/outbox", + "followers" => actor_id <> "/followers", + "following" => actor_id <> "/following", + "published" => person["published"], + "publicKey" => %{ + "id" => actor_id <> "#main-key", + "owner" => actor_id, + "publicKeyPem" => unavailable_public_key_pem() + }, + "endpoints" => %{"sharedInbox" => shared_inbox_for_actor(actor_id)} + }} + end + + defp normalize_lemmy_resolved_actor( + %{"community" => %{"community" => community}}, + requested_uri, + :group + ) + when is_map(community) do + actor_id = community["actor_id"] || requested_uri + + {:ok, + %{ + "id" => actor_id, + "type" => "Group", + "preferredUsername" => community["name"], + "name" => community["title"] || community["name"], + "summary" => community["description"], + "icon" => normalize_lemmy_image(community["icon"]), + "image" => normalize_lemmy_image(community["banner"]), + "inbox" => actor_id <> "/inbox", + "outbox" => actor_id <> "/outbox", + "followers" => actor_id <> "/followers", + "published" => community["published"], + "updated" => community["updated"], + "publicKey" => %{ + "id" => actor_id <> "#main-key", + "owner" => actor_id, + "publicKeyPem" => unavailable_public_key_pem() + }, + "endpoints" => %{"sharedInbox" => shared_inbox_for_actor(actor_id)} + }} + end + + defp normalize_lemmy_resolved_actor(_, _, _), do: {:error, :invalid_resolved_actor} + + defp normalize_lemmy_resolved_object(resolved_body, requested_uri, :person) do + normalize_lemmy_resolved_actor(resolved_body, requested_uri, :person) + end + + defp normalize_lemmy_resolved_object(resolved_body, requested_uri, :site) do + normalize_lemmy_resolved_actor(resolved_body, requested_uri, :site) + end + + defp normalize_lemmy_resolved_object(resolved_body, requested_uri, :group) do + normalize_lemmy_resolved_actor(resolved_body, requested_uri, :group) + end + + defp normalize_lemmy_resolved_object(%{"comment" => comment_view}, requested_uri, :comment) + when is_map(comment_view) do + comment = comment_view["comment"] || %{} + creator = comment_view["creator"] || %{} + post = comment_view["post"] || %{} + community = comment_view["community"] || %{} + counts = comment_view["counts"] || %{} + + object_id = comment["ap_id"] || requested_uri + post_url = post["ap_id"] || post["url"] || requested_uri + community_actor_id = community["actor_id"] + + {:ok, + %{ + "id" => object_id, + "type" => "Note", + "url" => object_id, + "_source_url" => requested_uri, + "content" => comment["content"] || "", + "published" => comment["published"], + "updated" => comment["updated"], + "attributedTo" => creator["actor_id"], + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => Enum.reject([community_actor_id], &is_nil/1), + "inReplyTo" => normalize_lemmy_comment_parent_ref(comment, post_url, requested_uri), + "likes" => %{"totalItems" => counts["upvotes"] || 0}, + "replies" => %{"type" => "Collection", "totalItems" => counts["child_count"] || 0}, + "shares" => %{"totalItems" => 0}, + "sensitive" => false, + "_lemmy" => %{ + "community_actor_id" => community_actor_id, + "creator_name" => creator["name"], + "creator_avatar" => creator["avatar"], + "score" => counts["score"] || 0, + "upvotes" => counts["upvotes"] || 0, + "downvotes" => counts["downvotes"] || 0, + "child_count" => counts["child_count"] || 0 + } + }} + end + + defp normalize_lemmy_resolved_object(%{"post" => post_view}, requested_uri, :page) + when is_map(post_view) do + post = post_view["post"] || %{} + creator = post_view["creator"] || %{} + community = post_view["community"] || %{} + counts = post_view["counts"] || %{} + + object_id = post["ap_id"] || requested_uri + community_actor_id = community["actor_id"] + external_url = normalize_external_post_url(post["url"], object_id) + comment_count = counts["comments"] || 0 + + {:ok, + %{ + "id" => object_id, + "type" => "Page", + "url" => external_url || object_id, + "name" => post["name"], + "content" => post["body"], + "published" => post["published"], + "updated" => post["updated"], + "attributedTo" => creator["actor_id"], + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => Enum.reject([community_actor_id], &is_nil/1), + "sensitive" => post["nsfw"] || false, + "comments" => %{"type" => "Collection", "totalItems" => comment_count}, + "replies" => %{"type" => "Collection", "totalItems" => comment_count}, + "attachment" => normalize_lemmy_post_attachment(external_url, post["name"]), + "_lemmy" => %{ + "community_actor_id" => community_actor_id, + "creator_name" => creator["name"], + "creator_avatar" => creator["avatar"], + "score" => counts["score"] || 0, + "upvotes" => counts["upvotes"] || 0, + "downvotes" => counts["downvotes"] || 0 + } + }} + end + + defp normalize_lemmy_resolved_object(_, _, _), do: {:error, :invalid_resolved_object} + + defp normalize_lemmy_html_object(body, requested_uri, type) + when is_binary(body) and type in [:site, :person, :group, :comment, :page] do + with {:ok, iso_data} <- extract_lemmy_iso_data(body), + {:ok, resolved_body} <- lemmy_iso_data_to_resolved_body(iso_data, requested_uri, type), + {:ok, object_data} <- normalize_lemmy_resolved_object(resolved_body, requested_uri, type) do + {:ok, object_data} + else + _ -> {:error, :invalid_html_object} + end + end + + defp normalize_lemmy_html_object(_, _, _), do: {:error, :invalid_html_object} + + defp extract_lemmy_iso_data(body) when is_binary(body) do + case Regex.run(~r/window\.isoData\s*=\s*(\{.*?\});/s, body, capture: :all_but_first) do + [json] -> json |> normalize_lemmy_iso_data_json() |> Jason.decode() + _ -> {:error, :missing_iso_data} + end + end + + defp normalize_lemmy_iso_data_json(json) when is_binary(json) do + Regex.replace(~r/:undefined(?=\s*[,}])/, json, ":null") + end + + defp lemmy_iso_data_to_resolved_body(%{"site_res" => site_res}, _requested_uri, :site) + when is_map(site_res), + do: {:ok, site_res} + + defp lemmy_iso_data_to_resolved_body( + %{"person_res" => %{"person_view" => person_view}}, + _requested_uri, + :person + ) + when is_map(person_view), + do: {:ok, %{"person" => person_view}} + + defp lemmy_iso_data_to_resolved_body( + %{"community_res" => %{"community_view" => community_view}}, + _requested_uri, + :group + ) + when is_map(community_view), + do: {:ok, %{"community" => community_view}} + + defp lemmy_iso_data_to_resolved_body( + %{"comment_res" => %{"comment_view" => comment_view}}, + _requested_uri, + :comment + ) + when is_map(comment_view), + do: {:ok, %{"comment" => comment_view}} + + defp lemmy_iso_data_to_resolved_body( + %{"routeData" => %{"commentsRes" => %{"data" => %{"comments" => comments}}}}, + requested_uri, + :comment + ) + when is_list(comments) do + case find_lemmy_comment_view(comments, requested_uri) do + %{} = comment_view -> {:ok, %{"comment" => comment_view}} + nil -> {:error, :unsupported_iso_data} + end + end + + defp lemmy_iso_data_to_resolved_body( + %{"post_res" => %{"post_view" => post_view}}, + _requested_uri, + :page + ) + when is_map(post_view), + do: {:ok, %{"post" => post_view}} + + defp lemmy_iso_data_to_resolved_body( + %{"routeData" => %{"postRes" => %{"data" => %{"post_view" => post_view}}}}, + _requested_uri, + :page + ) + when is_map(post_view), + do: {:ok, %{"post" => post_view}} + + defp lemmy_iso_data_to_resolved_body(_, _, _), do: {:error, :unsupported_iso_data} + + defp find_lemmy_comment_view(comments, requested_uri) when is_list(comments) do + requested_comment_id = lemmy_comment_id_from_uri(requested_uri) + + Enum.find(comments, fn comment_view -> + comment = comment_view["comment"] || %{} + counts = comment_view["counts"] || %{} + + comment["ap_id"] == requested_uri || + comment["id"] == requested_comment_id || + counts["comment_id"] == requested_comment_id + end) + end + + defp lemmy_comment_id_from_uri(requested_uri) when is_binary(requested_uri) do + case URI.parse(requested_uri) do + %URI{path: "/comment/" <> comment_id} -> + case Integer.parse(comment_id) do + {id, _} -> id + :error -> nil + end + + _ -> + nil + end + end + + defp lemmy_comment_id_from_uri(_), do: nil + + defp normalize_external_post_url(url, object_id) when is_binary(url) and url != object_id, + do: url + + defp normalize_external_post_url(_, _), do: nil + + defp normalize_lemmy_comment_parent_ref(comment, post_url, requested_uri) + when is_map(comment) do + case comment["path"] do + path when is_binary(path) -> + case String.split(path, ".") |> Enum.reverse() do + [_self, parent_id | _] when parent_id not in [nil, "", "0"] -> + build_lemmy_comment_uri(requested_uri, parent_id) + + _ -> + post_url + end + + _ -> + post_url + end + end + + defp normalize_lemmy_comment_parent_ref(_, post_url, _requested_uri), do: post_url + + defp build_lemmy_comment_uri(requested_uri, comment_id) do + case URI.parse(requested_uri) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and is_binary(comment_id) -> + "#{scheme}://#{host}/comment/#{comment_id}" + + _ -> + requested_uri + end + end + + defp normalize_lemmy_post_attachment(url, name) when is_binary(url) do + [ + %{ + "type" => "Link", + "href" => url, + "name" => name || url + } + ] + end + + defp normalize_lemmy_post_attachment(_, _), do: [] + + defp normalize_lemmy_image(url) when is_binary(url), do: %{"type" => "Image", "url" => url} + defp normalize_lemmy_image(_), do: nil + + defp shared_inbox_for_actor(actor_id) when is_binary(actor_id) do + case URI.parse(actor_id) do + %URI{scheme: scheme, host: host} when scheme in ["http", "https"] and is_binary(host) -> + "#{scheme}://#{host}/inbox" + + _ -> + nil + end + end + + defp unavailable_public_key_pem do + "-----BEGIN PUBLIC KEY-----\nUNAVAILABLE\n-----END PUBLIC KEY-----" + end + + defp validate_fetch_url(uri, kind, opts) when is_binary(uri) do + if Keyword.get(opts, :validate_url, true) do + case URLValidator.validate(uri) do + :ok -> + :ok + + {:error, reason} -> + Logger.warning( + "Blocked unsafe ActivityPub #{kind} URL #{inspect(uri)}: #{inspect(reason)}" + ) + + {:error, :unsafe_url} + end + else + :ok + end + end + + defp validate_fetch_url(_uri, _kind, _opts), do: {:error, :unsafe_url} + + defp request_with_backoff(url, headers, opts) do + request_fun = Keyword.get(opts, :request_fun, &Backoff.get/3) + request_fun.(url, headers, Keyword.drop(opts, [:request_fun])) + end + + defp log_failed_fetch(uri, %Finch.Response{} = response) do + Logger.warning( + "Failed to fetch from #{uri}, status: #{response.status}, body: #{String.slice(response.body || "", 0, 200)}" + ) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/group_follow.ex b/apps/elektrine/lib/elektrine/activitypub/group_follow.ex new file mode 100644 index 0000000..80a6a2b --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/group_follow.ex @@ -0,0 +1,25 @@ +defmodule Elektrine.ActivityPub.GroupFollow do + @moduledoc """ + Schema representing a remote actor following a local Group actor (community). + Used to track followers for community federation delivery. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "group_follows" do + belongs_to :remote_actor, Elektrine.ActivityPub.Actor + belongs_to :group_actor, Elektrine.ActivityPub.Actor + field :activitypub_id, :string + field :pending, :boolean, default: false + + timestamps() + end + + @doc false + def changeset(group_follow, attrs) do + group_follow + |> cast(attrs, [:remote_actor_id, :group_actor_id, :activitypub_id, :pending]) + |> validate_required([:remote_actor_id, :group_actor_id]) + |> unique_constraint([:remote_actor_id, :group_actor_id]) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/handler.ex b/apps/elektrine/lib/elektrine/activitypub/handler.ex new file mode 100644 index 0000000..aed30a3 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/handler.ex @@ -0,0 +1,327 @@ +defmodule Elektrine.ActivityPub.Handler do + @moduledoc """ + Handles incoming ActivityPub activities. + + This module routes activities to specialized handlers: + - FollowHandler: Follow, Accept, Reject activities + - CreateHandler: Create activities for Notes, Pages, Articles, Questions + - LikeHandler: Like, Dislike, EmojiReact activities + - AnnounceHandler: Announce (boost/share) activities + - MoveHandler: Account migration activities + - DeleteHandler: Delete activities + - UpdateHandler: Update activities + - BlockHandler: Block activities + """ + + require Logger + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.{MRF, Normalizer, ObjectValidator, UndoResolver} + + alias Elektrine.ActivityPub.Handlers.{ + AnnounceHandler, + BlockHandler, + CreateHandler, + DeleteHandler, + FlagHandler, + FollowHandler, + LikeHandler, + MoveHandler, + UpdateHandler + } + + @doc """ + Stores a remote post object locally. + Used when we need to interact with remote posts (like, boost, reply). + """ + def store_remote_post(post_object, actor_uri) do + actor_uri = Normalizer.actor_ref_uri(actor_uri) || Normalizer.actor_uri(post_object) + + if is_binary(actor_uri) do + CreateHandler.handle(%{"object" => post_object}, actor_uri, nil) + else + {:error, :invalid_actor_uri} + end + end + + @doc """ + Queues an activity for async processing. + + This function validates and saves the activity, then returns immediately. + The IncomingActivityWorker will process it in the background. + + Returns {:ok, :queued} on success, allowing the inbox endpoint to return 202 Accepted. + """ + def handle_activity(activity, actor_uri, _target_user) do + # Check if the instance is blocked + %URI{host: domain} = URI.parse(actor_uri) + + if ActivityPub.instance_blocked?(domain) do + {:ok, :blocked} + else + # Validate the activity structure first + case ObjectValidator.validate(activity) do + {:error, reason} -> + Logger.warning("Invalid activity from #{actor_uri}: #{reason}") + {:ok, :invalid} + + {:ok, validated_activity} -> + # Run MRF policies + case apply_mrf_policies(validated_activity, actor_uri) do + {:reject, _reason} -> + {:ok, :mrf_rejected} + + {:ok, filtered_activity} -> + # Save the (possibly modified) activity for async processing + case ActivityPub.create_activity(%{ + activity_id: filtered_activity["id"], + activity_type: filtered_activity["type"], + actor_uri: actor_uri, + object_id: get_object_id(filtered_activity), + data: filtered_activity, + local: false, + processed: false + }) do + {:ok, _record} -> + # Trigger the worker to process soon (non-blocking) + spawn(fn -> Elektrine.ActivityPub.IncomingActivityWorker.process_now() end) + {:ok, :queued} + + {:error, %Ecto.Changeset{errors: [activity_id: {"has already been taken", _}]}} -> + {:ok, :already_received} + + {:error, changeset} -> + Logger.error("Failed to save activity: #{inspect(changeset)}") + {:error, :failed_to_save_activity} + end + end + end + end + end + + @doc """ + Processes an activity asynchronously (called by IncomingActivityWorker). + + This does the actual work of handling the activity, including HTTP calls + to fetch actors and objects from remote servers. + """ + def process_activity_async(activity, actor_uri, target_user) do + domain = actor_uri |> URI.parse() |> Map.get(:host) + + if domain && ActivityPub.instance_blocked?(domain) do + {:ok, :blocked} + else + case ObjectValidator.validate(activity) do + {:error, reason} -> + Logger.warning("Invalid activity from #{actor_uri}: #{reason}") + {:ok, :invalid} + + {:ok, validated_activity} -> + target_user = target_user || ActivityPub.resolve_target_user(validated_activity) + + # Check if user has blocked this actor + if target_user && ActivityPub.user_blocked?(target_user.id, actor_uri) do + {:ok, :blocked} + else + case apply_mrf_policies(validated_activity, actor_uri) do + {:reject, _reason} -> + {:ok, :mrf_rejected} + + {:ok, filtered_activity} -> + route_activity(filtered_activity, actor_uri, target_user) + end + end + end + end + end + + def retryable_error?(reason) when is_atom(reason) do + not non_retryable_error?(reason) + end + + def retryable_error?({:validation_failed, _}), do: false + def retryable_error?({:rejected, _}), do: false + def retryable_error?({:key_fetch_failed, _}), do: true + + def retryable_error?(_reason), do: true + + def non_retryable_error?(reason) + when reason in [ + :actor_mismatch, + :blocked, + :blocked_instance, + :domain_unreachable, + :handle_like_failed, + :handle_dislike_failed, + :invalid, + :invalid_activity, + :invalid_signature, + :local_fetch_containment_failed, + :mrf_rejected, + :origin_containment_failed, + :fetch_failed, + :http_error, + :not_found, + :object_id_mismatch, + :unauthorized, + :unauthorized_fetch, + :unsafe_url + ] do + true + end + + def non_retryable_error?(_reason), do: false + + # Route activity to the appropriate handler + defp route_activity(activity, actor_uri, target_user) do + case activity["type"] do + "Follow" -> + FollowHandler.handle(activity, actor_uri, target_user) + + "Accept" -> + FollowHandler.handle_accept(activity, actor_uri, target_user) + + "Reject" -> + FollowHandler.handle_reject(activity, actor_uri, target_user) + + "Create" -> + CreateHandler.handle(activity, actor_uri, target_user) + + "Like" -> + LikeHandler.handle(activity, actor_uri, target_user) + + "Dislike" -> + LikeHandler.handle_dislike(activity, actor_uri, target_user) + + "EmojiReact" -> + LikeHandler.handle_emoji_react(activity, actor_uri, target_user) + + "Announce" -> + AnnounceHandler.handle(activity, actor_uri, target_user) + + "Move" -> + MoveHandler.handle(activity, actor_uri, target_user) + + "Undo" -> + handle_undo(activity, actor_uri, target_user) + + "Delete" -> + DeleteHandler.handle(activity, actor_uri, target_user) + + "Update" -> + UpdateHandler.handle(activity, actor_uri, target_user) + + "Block" -> + BlockHandler.handle(activity, actor_uri, target_user) + + "Flag" -> + FlagHandler.handle(activity, actor_uri, target_user) + + _ -> + {:ok, :unhandled} + end + end + + # Handle Undo activities by routing to the appropriate handler + defp handle_undo(%{"object" => object} = _activity, actor_uri, _target_user) + when is_map(object) do + case object["type"] do + "Follow" -> + FollowHandler.handle_undo(object, actor_uri) + + "Like" -> + LikeHandler.handle_undo_like(object, actor_uri) + + "Dislike" -> + LikeHandler.handle_undo_dislike(object, actor_uri) + + "EmojiReact" -> + LikeHandler.handle_undo_emoji_react(object, actor_uri) + + "Announce" -> + AnnounceHandler.handle_undo(object, actor_uri) + + "Block" -> + BlockHandler.handle_undo(object, actor_uri) + + _ -> + {:ok, :unhandled} + end + end + + # Handle Undo when object is a URI string - fetch and process + defp handle_undo(%{"object" => object_uri} = activity, actor_uri, target_user) + when is_binary(object_uri) do + case UndoResolver.resolve(object_uri, actor_uri) do + {:ok, object} -> + handle_undo(%{activity | "object" => object}, actor_uri, target_user) + + :not_found -> + case Elektrine.ActivityPub.RemoteFetch.fetch_object(object_uri) do + {:ok, object} when is_map(object) -> + handle_undo(%{activity | "object" => object}, actor_uri, target_user) + + {:ok, _object} -> + Logger.warning("Undo object #{object_uri} did not resolve to an object map") + {:error, :undo_activity_fetch_failed} + + {:error, reason} -> + Logger.warning("Failed to fetch Undo object #{object_uri}: #{inspect(reason)}") + {:error, :undo_activity_fetch_failed} + end + end + end + + defp handle_undo(_activity, _actor_uri, _target_user), do: {:ok, :unhandled} + + defp get_object_id(%{"object" => object}) when is_binary(object), do: object + defp get_object_id(%{"object" => %{"id" => id}}), do: id + defp get_object_id(_), do: nil + + defp apply_mrf_policies(activity, actor_uri) do + case MRF.filter(activity) do + {:reject, reason} -> + Logger.info("MRF rejected activity from #{actor_uri}: #{reason}") + {:reject, reason} + + {:ok, filtered_activity} -> + {:ok, filtered_activity} + end + end + + # Legacy functions kept for backwards compatibility + + @doc """ + Extracts local usernames from ActivityPub Mention tags. + Delegated to CreateHandler for implementation. + """ + def extract_local_mentions(object) do + # Keep for backwards compatibility - call internal implementation + extract_local_mentions_internal(object) + end + + defp extract_local_mentions_internal(object) do + case object["tag"] do + tags when is_list(tags) -> + tags + |> Enum.filter(fn tag -> tag["type"] == "Mention" end) + |> Enum.map(fn tag -> + case extract_local_username_from_uri(tag["href"]) do + {:ok, username} -> username + _ -> nil + end + end) + |> Enum.filter(&(&1 != nil)) + |> Enum.uniq() + + _ -> + [] + end + end + + defp extract_local_username_from_uri(uri) when is_binary(uri) do + Elektrine.ActivityPub.local_username_from_uri(uri) + end + + defp extract_local_username_from_uri(_), do: {:error, :invalid_uri} +end diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/announce_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/announce_handler.ex new file mode 100644 index 0000000..bcd9981 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/handlers/announce_handler.ex @@ -0,0 +1,445 @@ +defmodule Elektrine.ActivityPub.Handlers.AnnounceHandler do + @moduledoc """ + Handles Announce (boost/share) ActivityPub activities. + """ + + require Logger + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Normalizer + alias Elektrine.Async + alias Elektrine.Messaging + + @doc """ + Handles an incoming Announce activity. + """ + def handle(%{"object" => object_uri} = activity, actor_uri, _target_user) + when is_binary(object_uri) do + activity_id = activity["id"] + + case get_local_message_from_uri(object_uri) do + {:ok, message} -> + # Local post being boosted + with {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri) do + case Messaging.create_federated_boost(message.id, remote_actor.id, activity_id) do + {:ok, _} -> + Async.run(fn -> + Elektrine.Notifications.FederationNotifications.notify_remote_announce( + message.id, + remote_actor.id + ) + end) + + {:ok, :announced} + + {:error, reason} -> + Logger.error("Failed to create boost record: #{inspect(reason)}") + {:error, :failed_to_boost} + end + end + + {:error, :message_not_found} -> + handle_remote_announce(object_uri, actor_uri, activity_id) + end + end + + def handle(%{"object" => objects}, actor_uri, target_user) when is_list(objects) do + {any_ok?, any_announced?, first_error} = + Enum.reduce(objects, {false, false, nil}, fn object_ref, + {any_ok?, any_announced?, first_error} -> + case handle(%{"object" => object_ref}, actor_uri, target_user) do + {:ok, result} -> + announced? = result in [:announced, :unannounced] + {true, any_announced? || announced?, first_error} + + {:error, reason} -> + Logger.warning("Failed to process item in Announce object list: #{inspect(reason)}") + {any_ok?, any_announced?, first_error || reason} + end + end) + + cond do + any_announced? -> {:ok, :announced} + any_ok? -> {:ok, :ignored} + is_atom(first_error) -> {:error, first_error} + true -> {:ok, :ignored} + end + end + + def handle(%{"object" => object_ref}, actor_uri, target_user) when is_map(object_ref) do + case extract_target_object_uri(object_ref) do + nil -> {:error, :invalid_object} + object_uri -> handle(%{"object" => object_uri}, actor_uri, target_user) + end + end + + @doc """ + Handles Undo Announce activity. + """ + def handle_undo(%{"object" => object_ref}, actor_uri) do + object_uri = extract_target_object_uri(object_ref) + + with {:ok, object_uri} when not is_nil(object_uri) <- {:ok, object_uri}, + {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri), + {:ok, message} <- get_local_message_from_uri(object_uri) do + Messaging.delete_federated_boost(message.id, remote_actor.id) + {:ok, :unannounced} + else + {:error, :message_not_found} -> + {:ok, :message_not_found} + + error -> + Logger.warning("Failed to undo announce: #{inspect(error)}") + {:error, :undo_announce_failed} + end + end + + # Private functions + + defp handle_remote_announce(object_uri, actor_uri, announce_activity_id) do + with {:ok, booster_actor} <- ActivityPub.get_or_fetch_actor(actor_uri), + {:ok, object} <- Elektrine.ActivityPub.RemoteFetch.fetch_object(object_uri), + {:ok, {actual_object, original_actor_uri}} <- unwrap_object(object) do + cond do + is_nil(actual_object) -> + {:ok, :ignored} + + actual_object["type"] not in ["Note", "Page", "Article"] -> + {:ok, :ignored} + + !is_binary(original_actor_uri) -> + {:ok, :ignored} + + original_actor_uri == actor_uri -> + {:ok, :ignored} + + true -> + create_or_boost_post( + actual_object, + original_actor_uri, + booster_actor, + announce_activity_id + ) + end + else + {:error, :not_found} -> + if remote_object_uri?(object_uri) || remote_activity_wrapper_uri?(object_uri) do + {:ok, :ignored} + else + {:error, :announce_object_fetch_failed} + end + + {:error, :nested_fetch_failed} -> + {:error, :announce_object_fetch_failed} + + {:error, reason} -> + if ignorable_remote_activity_wrapper_fetch_failure?(object_uri, reason) do + Logger.debug( + "Ignoring remote Announce wrapper #{object_uri} after fetch failure: #{inspect(reason)}" + ) + + {:ok, :ignored} + else + Logger.warning("Failed to fetch announced object #{object_uri}: #{inspect(reason)}") + {:error, :announce_object_fetch_failed} + end + end + end + + defp unwrap_object(object, depth \\ 0) + defp unwrap_object(_object, depth) when depth > 4, do: {:ok, {nil, nil}} + + defp unwrap_object(object, depth) do + case object["type"] do + type when type in ["Note", "Page", "Article"] -> + {:ok, {object, Normalizer.actor_uri(object)}} + + "Create" -> + inner_object = object["object"] + + if is_map(inner_object) do + inherited_object = inherit_wrapper_fields(object, inner_object) + + {:ok, + {inherited_object, + Normalizer.actor_uri(inherited_object) || Normalizer.actor_ref_uri(object["actor"])}} + else + case Elektrine.ActivityPub.RemoteFetch.fetch_object(inner_object) do + {:ok, fetched} -> + inherited_object = inherit_wrapper_fields(object, fetched) + + with {:ok, {resolved, actor_uri}} <- unwrap_object(inherited_object, depth + 1) do + {:ok, + {resolved, + actor_uri || Normalizer.actor_uri(inherited_object) || + Normalizer.actor_ref_uri(object["actor"])}} + end + + {:error, reason} -> + Logger.warning( + "Failed to fetch nested announced object #{inspect(inner_object)}: #{inspect(reason)}" + ) + + {:error, :nested_fetch_failed} + end + end + + "Announce" -> + unwrap_announced_object(object["object"], object["actor"], depth + 1) + + _ -> + {:ok, {nil, nil}} + end + end + + defp unwrap_announced_object(object_ref, fallback_actor_uri, depth) when is_map(object_ref) do + with {:ok, {resolved, actor_uri}} <- unwrap_object(object_ref, depth) do + {:ok, {resolved, actor_uri || fallback_actor_uri}} + end + end + + defp unwrap_announced_object(object_ref, fallback_actor_uri, depth) + when is_binary(object_ref) do + case Elektrine.ActivityPub.RemoteFetch.fetch_object(object_ref) do + {:ok, fetched} -> + with {:ok, {resolved, actor_uri}} <- unwrap_object(fetched, depth) do + {:ok, {resolved, actor_uri || fallback_actor_uri}} + end + + {:error, reason} -> + Logger.warning( + "Failed to fetch nested announced object #{inspect(object_ref)}: #{inspect(reason)}" + ) + + {:error, :nested_fetch_failed} + end + end + + defp unwrap_announced_object(_object_ref, _fallback_actor_uri, _depth), do: {:ok, {nil, nil}} + + defp create_or_boost_post( + actual_object, + original_actor_uri, + booster_actor, + announce_activity_id + ) do + # Delegate to CreateHandler for the actual post creation + alias Elektrine.ActivityPub.Handlers.CreateHandler + + create_opts = + if booster_actor.actor_type == "Group" do + [fallback_community_uri: booster_actor.uri] + else + [] + end + + with :ok <- CreateHandler.validate_object_author(actual_object, original_actor_uri), + false <- + ActivityPub.remote_tombstone_recorded?(original_actor_uri, [ + actual_object["id"], + actual_object["url"] + ]) do + case CreateHandler.create_note(actual_object, original_actor_uri, create_opts) do + {:ok, :already_exists} -> + # Skip boost treatment for community distributions and relay actors + is_distribution = distribution_actor?(booster_actor) + + unless is_distribution do + ap_id = actual_object["id"] + + case Elektrine.Repo.get_by(Elektrine.Social.Message, activitypub_id: ap_id) do + %{id: message_id, deleted_at: nil} -> + Messaging.create_federated_boost( + message_id, + booster_actor.id, + announce_activity_id + ) + + nil -> + :ok + + _deleted_message -> + :ok + end + end + + {:ok, :announced} + + {:ok, message} when is_struct(message) -> + # Skip boost treatment for community distributions and relay actors + is_distribution = distribution_actor?(booster_actor) + + updated_msg = + if is_distribution do + message + else + Messaging.create_federated_boost(message.id, booster_actor.id, announce_activity_id) + + case Elektrine.Social.Messages.update_message_metadata(message, %{ + media_metadata: + Map.merge(message.media_metadata || %{}, %{ + "boosted_by" => %{ + "username" => booster_actor.username, + "domain" => booster_actor.domain, + "display_name" => booster_actor.display_name, + "avatar_url" => booster_actor.avatar_url + } + }) + }) do + {:ok, updated} -> updated + {:error, _} -> message + end + end + + # Reload with associations for broadcasting + reloaded = + Elektrine.Repo.preload( + updated_msg, + [:remote_actor, :sender, :link_preview, :hashtags], + force: true + ) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "timeline:public", + {:new_public_post, reloaded} + ) + + {:ok, :announced} + + error -> + error + end + else + true -> + {:ok, :ignored_deleted_object} + + {:error, :actor_mismatch} -> + Logger.warning( + "Rejecting Announce for #{inspect(actual_object["id"])}: object attributedTo does not match announced actor #{original_actor_uri}" + ) + + {:ok, :unauthorized} + end + end + + defp extract_target_object_uri(object) when is_binary(object), do: object + defp extract_target_object_uri(%{"id" => id}) when is_binary(id), do: id + + defp extract_target_object_uri(%{"object" => object_ref}) do + extract_target_object_uri(object_ref) + end + + defp extract_target_object_uri(_), do: nil + + # Check if actor is a distribution actor (relay or community group) + # These actors relay/distribute content, not boost it + defp distribution_actor?(actor) do + cond do + # Community/Group actors distribute content + actor.actor_type == "Group" -> + true + + # Relay actors (Application type with relay in URI) + actor.actor_type == "Application" && relay_uri?(actor.uri) -> + true + + # Service actors that are relays + actor.actor_type == "Service" && relay_uri?(actor.uri) -> + true + + true -> + false + end + end + + defp relay_uri?(uri) when is_binary(uri) do + uri_lower = String.downcase(uri) + + String.contains?(uri_lower, "/relay") || + String.contains?(uri_lower, "relay.") || + (String.ends_with?(uri_lower, "/actor") && String.contains?(uri_lower, "relay")) + end + + defp relay_uri?(_), do: false + + defp remote_object_uri?(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{host: host} when is_binary(host) and host != "" -> + String.downcase(host) != String.downcase(ActivityPub.instance_domain()) + + _ -> + false + end + end + + defp remote_object_uri?(_), do: false + + defp ignorable_remote_activity_wrapper_fetch_failure?(uri, reason) + when reason in [:fetch_failed, :http_error, :backoff, :not_found, :unsafe_url] do + remote_activity_wrapper_uri?(uri) + end + + defp ignorable_remote_activity_wrapper_fetch_failure?(_, _), do: false + + defp remote_activity_wrapper_uri?(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{host: host, path: path} + when is_binary(host) and host != "" and is_binary(path) and path != "" -> + String.downcase(host) != String.downcase(ActivityPub.instance_domain()) && + Regex.match?(~r{/(?:activity|activities)(?:/|$)}, String.downcase(path)) + + _ -> + false + end + end + + defp remote_activity_wrapper_uri?(_), do: false + + defp inherit_wrapper_fields(activity, object) when is_map(activity) and is_map(object) do + ["to", "cc", "audience", "published"] + |> Enum.reduce(object, fn field, acc -> + case {Map.get(acc, field), Map.get(activity, field)} do + {value, inherited} when value in [nil, ""] and not is_nil(inherited) -> + Map.put(acc, field, inherited) + + {[], inherited} when not is_nil(inherited) -> + Map.put(acc, field, inherited) + + _ -> + acc + end + end) + end + + defp inherit_wrapper_fields(_activity, object), do: object + + defp get_local_message_from_uri(uri) do + base_url = ActivityPub.instance_url() + + cond do + String.starts_with?(uri, "#{base_url}/posts/") -> + id = String.replace_prefix(uri, "#{base_url}/posts/", "") + get_message_by_id(id) + + String.match?(uri, ~r{#{base_url}/users/[^/]+/posts/}) -> + id = uri |> String.split("/posts/") |> List.last() + get_message_by_id(id) + + true -> + case Messaging.get_message_by_activitypub_ref(uri) do + nil -> {:error, :message_not_found} + message -> {:ok, message} + end + end + end + + defp get_message_by_id(id) do + case Messaging.get_message(id) do + nil -> {:error, :message_not_found} + message -> {:ok, message} + end + rescue + Ecto.Query.CastError -> {:error, :message_not_found} + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/block_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/block_handler.ex new file mode 100644 index 0000000..ccfe9b0 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/handlers/block_handler.ex @@ -0,0 +1,62 @@ +defmodule Elektrine.ActivityPub.Handlers.BlockHandler do + @moduledoc """ + Handles Block ActivityPub activities. + """ + + require Logger + + alias Elektrine.ActivityPub + + @doc """ + Handles an incoming Block activity. + Someone is blocking one of our users - we just acknowledge it. + The remote instance handles the actual blocking. + """ + def handle(%{"object" => blocked_uri}, actor_uri, _target_user) do + with {:ok, _remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri), + {:ok, _blocked_user} <- get_local_user_from_uri(blocked_uri) do + # We don't need to do anything specific - the remote instance handles the blocking + # In the future, we could record this to hide our user's posts from the blocker + {:ok, :blocked} + else + {:error, :not_local} -> + {:ok, :not_our_user} + + {:error, reason} -> + Logger.warning("Failed to handle block: #{inspect(reason)}") + {:error, :handle_block_failed} + end + end + + @doc """ + Handles Undo Block activity. + Someone is unblocking one of our users. + """ + def handle_undo(%{"object" => blocked_uri}, actor_uri) do + with {:ok, _remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri), + {:ok, _blocked_user} <- get_local_user_from_uri(blocked_uri) do + # We don't need to do anything specific - the remote instance handles the unblocking + {:ok, :unblocked} + else + {:error, :not_local} -> + {:ok, :not_our_user} + + {:error, reason} -> + Logger.warning("Failed to undo block: #{inspect(reason)}") + {:error, :undo_block_failed} + end + end + + defp get_local_user_from_uri(uri) do + case ActivityPub.local_username_from_uri(uri) do + {:ok, username} -> + case Elektrine.Accounts.get_user_by_username(username) do + nil -> {:error, :not_found} + user -> {:ok, user} + end + + {:error, _reason} -> + {:error, :not_local} + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/create_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/create_handler.ex new file mode 100644 index 0000000..8b43fc9 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/handlers/create_handler.ex @@ -0,0 +1,926 @@ +defmodule Elektrine.ActivityPub.Handlers.CreateHandler do + @moduledoc """ + Handles Create ActivityPub activities for Notes, Pages, Articles, and Questions. + """ + + require Logger + + import Ecto.Query + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Helpers + alias Elektrine.ActivityPub.Normalizer + alias Elektrine.Async + alias Elektrine.Emojis + alias Elektrine.Messaging + alias Elektrine.Social + alias Elektrine.Social.Poll + + # Bounds the ingestion-time "fetch missing reply parent" recursion so a + # hostile inReplyTo chain can't drive unbounded upstream fetches. + @max_ingestion_ancestor_depth 10 + + @doc """ + Handles an incoming Create activity. + """ + def handle(%{"object" => object} = activity, actor_ref, _target_user) when is_map(object) do + actor_uri = Normalizer.actor_ref_uri(actor_ref) + object = inherit_wrapper_fields(activity, object) + activity_id = activity["id"] || object["id"] + opts = ingestion_opts(activity) + + if is_binary(actor_uri) do + case validate_object_author(object, actor_uri) do + :ok -> + case object["type"] do + "Note" -> create_note(object, actor_uri, opts) + "Page" -> create_note(object, actor_uri, opts) + "Article" -> create_note(object, actor_uri, opts) + "Question" -> create_question(object, actor_uri, opts) + "Video" -> create_note(object, actor_uri, opts) + # Akkoma/Pleroma explicitly sends Answer type for poll votes. + "Answer" -> handle_incoming_poll_vote(object, actor_uri, activity_id: activity_id) + _ -> {:ok, :unhandled} + end + + {:error, :actor_mismatch} -> + Logger.warning( + "Rejecting Create #{inspect(activity_id)}: object attributedTo does not match verified actor #{actor_uri}" + ) + + {:ok, :unauthorized} + end + else + Logger.warning("Rejecting Create #{inspect(activity_id)}: missing or invalid actor URI") + {:error, :invalid_actor_uri} + end + end + + def handle(%{"object" => object_uri} = activity, actor_ref, target_user) + when is_binary(object_uri) do + actor_uri = Normalizer.actor_ref_uri(actor_ref) + activity_id = activity["id"] || object_uri + + if is_binary(actor_uri) do + case ActivityPub.RemoteFetch.fetch_object(object_uri) do + {:ok, object} when is_map(object) -> + activity + |> Map.put("object", Map.put_new(object, "attributedTo", actor_uri)) + |> handle(actor_uri, target_user) + + {:ok, _object} -> + Logger.warning( + "Rejecting Create #{inspect(activity_id)}: referenced object #{inspect(object_uri)} was not an object map" + ) + + {:error, :fetch_failed} + + {:error, reason} -> + Logger.warning( + "Rejecting Create #{inspect(activity_id)}: failed to fetch referenced object #{inspect(object_uri)} (#{inspect(reason)})" + ) + + {:error, :create_object_fetch_failed} + end + else + Logger.warning("Rejecting Create #{inspect(activity_id)}: missing or invalid actor URI") + {:error, :invalid_actor_uri} + end + end + + @doc """ + Creates a note from an ActivityPub object. + Public API for use by other handlers (e.g., AnnounceHandler). + """ + def create_note(object, actor_uri) do + create_note(object, actor_uri, []) + end + + @doc """ + Creates a note from an ActivityPub object with extra ingestion options. + Supported opts: + - `:fallback_community_uri` - fallback Group/community URI when object fields are incomplete + """ + def create_note(object, actor_ref, opts) when is_list(opts) do + actor_uri = Normalizer.actor_ref_uri(actor_ref) + + if instance_blocked_for_uri?(actor_uri) do + # Fetch paths (replies backfill, Announce/Like of unknown objects) reach + # here without the inbox's instance-block gate, so a defederated + # instance's content would otherwise be stored and rendered. Reject it + # at this shared choke point. + {:ok, :blocked} + else + with :ok <- validate_object_author(object, actor_uri) do + if Normalizer.poll_vote?(object) do + handle_incoming_poll_vote(object, actor_uri, opts) + else + create_regular_note(object, actor_uri, opts) + end + end + end + end + + defp instance_blocked_for_uri?(actor_uri) when is_binary(actor_uri) do + case URI.parse(actor_uri) do + %URI{host: host} when is_binary(host) -> ActivityPub.instance_blocked?(host) + _ -> false + end + end + + defp instance_blocked_for_uri?(_), do: false + + @doc """ + Validates that an object's attributedTo matches the verified actor. + """ + def validate_object_author(object, actor_uri) when is_map(object) do + Normalizer.validate_object_author(object, actor_uri) + end + + def validate_object_author(_object, _actor_uri), do: {:error, :actor_mismatch} + + @doc """ + Creates a Question (poll) from an ActivityPub object. + """ + def create_question(object, actor_ref, opts \\ []) do + actor_uri = Normalizer.actor_ref_uri(actor_ref) + + if deleted_object_recorded?(object, actor_uri) do + {:ok, :ignored_deleted_object} + else + with {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri) do + payload = build_federated_question_payload(object, actor_uri, opts) + attrs = Map.put(payload.attrs, :remote_actor_id, remote_actor.id) + + %URI{host: instance_domain} = URI.parse(actor_uri) + Async.start(fn -> Emojis.process_activitypub_tags(object["tag"], instance_domain) end) + + if attrs.visibility in ["public", "unlisted"] do + case Messaging.create_federated_message_with_status(Map.put(attrs, :federated, true)) do + {:ok, message, :created} -> + if payload.options != [] do + upsert_federated_poll(message.id, object, payload.options) + end + + enqueue_home_feed_fanout(message.id) + + if payload.hashtags != [] do + Async.run(fn -> sync_message_hashtags(message.id, payload.hashtags) end) + end + + broadcast_created_message(message, opts, [ + :remote_actor, + :sender, + :conversation, + :link_preview, + :hashtags, + poll: [options: []] + ]) + + {:ok, message} + + {:ok, _message, :existing} -> + {:ok, :already_exists} + + {:error, reason} -> + Logger.error("Failed to create federated poll: #{inspect(reason)}") + {:error, :failed_to_create_poll} + end + else + {:ok, :ignored_non_public} + end + end + end + end + + @doc false + def build_federated_message_payload(object, actor_uri, opts \\ []) when is_map(object) do + Normalizer.message_payload(object, actor_uri, opts) + end + + @doc false + def build_federated_question_payload(object, actor_uri, opts \\ []) when is_map(object) do + Normalizer.question_payload(object, actor_uri, opts) + end + + @doc false + def sync_message_hashtags(message_id, hashtag_names) + when is_integer(message_id) and is_list(hashtag_names) do + normalized_names = + hashtag_names + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq_by(&String.downcase/1) + + existing_hashtags = + from( + ph in "post_hashtags", + join: h in "hashtags", + on: h.id == ph.hashtag_id, + where: ph.message_id == ^message_id, + select: %{id: h.id, normalized_name: h.normalized_name} + ) + |> Elektrine.Repo.all() + + existing_names = MapSet.new(existing_hashtags, & &1.normalized_name) + + hashtags = + normalized_names + |> Enum.map(&Social.get_or_create_hashtag/1) + |> Enum.filter(&(&1 != nil)) + |> Enum.uniq_by(& &1.id) + + new_names = MapSet.new(hashtags, & &1.normalized_name) + + from(ph in "post_hashtags", where: ph.message_id == ^message_id) + |> Elektrine.Repo.delete_all() + + removed_hashtags = + Enum.reject(existing_hashtags, fn hashtag -> + MapSet.member?(new_names, hashtag.normalized_name) + end) + + added_hashtags = + Enum.reject(hashtags, fn hashtag -> + MapSet.member?(existing_names, hashtag.normalized_name) + end) + + if hashtags != [] do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + associations = + Enum.map(hashtags, fn hashtag -> + %{message_id: message_id, hashtag_id: hashtag.id, inserted_at: now} + end) + + Elektrine.Repo.insert_all("post_hashtags", associations, on_conflict: :nothing) + end + + Enum.each(removed_hashtags, fn hashtag -> Social.decrement_hashtag_usage(hashtag.id) end) + Enum.each(added_hashtags, fn hashtag -> Social.increment_hashtag_usage(hashtag.id) end) + + :ok + end + + def sync_message_hashtags(_message_id, _hashtag_names), do: :ok + + @doc false + def upsert_federated_poll(message_id, object) when is_integer(message_id) and is_map(object) do + upsert_federated_poll(message_id, object, Normalizer.poll_options(object)) + end + + def upsert_federated_poll(_message_id, _object), do: {:error, :invalid_poll} + + @doc false + def upsert_federated_poll(message_id, object, options) + when is_integer(message_id) and is_map(object) and is_list(options) do + do_upsert_federated_poll(message_id, object, options) + end + + def upsert_federated_poll(_message_id, _object, _options), do: {:error, :invalid_poll} + + defp handle_incoming_poll_vote(object, actor_uri, opts) do + option_name = object["name"] + in_reply_to = object["inReplyTo"] + + poll_post_uri = + case in_reply_to do + uri when is_binary(uri) -> uri + %{"id" => id} -> id + _ -> nil + end + + if poll_post_uri do + with {:ok, _remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri), + {:ok, message} <- get_local_message_from_uri(poll_post_uri), + poll when not is_nil(poll) <- + Elektrine.Repo.get_by(Elektrine.Social.Poll, message_id: message.id) do + poll = Elektrine.Repo.preload(poll, :options) + vote_id = Keyword.get(opts, :activity_id) || object["id"] + + record_remote_poll_vote(poll, message, option_name, actor_uri, vote_id) + else + nil -> + {:ok, :not_a_poll} + + {:error, :message_not_found} -> + {:ok, :message_not_found} + + {:error, reason} -> + Logger.warning("Failed to process poll vote: #{inspect(reason)}") + {:error, reason} + end + else + {:ok, :invalid_poll_vote} + end + end + + defp record_remote_poll_vote(poll, message, option_name, actor_uri, vote_id) do + option_key = + option_name + |> to_string() + |> String.trim() + |> String.downcase() + + dedupe_vote_id = + case vote_id do + value when is_binary(value) and value != "" -> + "poll-vote:#{value}" + + _ -> + "poll-vote:#{actor_uri}:#{poll.id}:#{option_key}" + end + + case persist_poll_vote_receipt(dedupe_vote_id, poll, message, actor_uri) do + {:ok, :already_recorded} -> + {:ok, :already_voted} + + {:ok, :recorded} -> + Elektrine.Repo.transaction(fn -> + locked_poll = + from(p in Poll, where: p.id == ^poll.id, lock: "FOR UPDATE") + |> Elektrine.Repo.one!() + |> Elektrine.Repo.preload(:options) + + if Poll.has_voted?(locked_poll, actor_uri) do + :already_voted + else + matching_option = + Enum.find(locked_poll.options, fn opt -> + String.downcase(String.trim(opt.option_text)) == option_key + end) + + if matching_option do + Elektrine.Repo.update_all( + from(o in Elektrine.Social.PollOption, where: o.id == ^matching_option.id), + inc: [vote_count: 1] + ) + + Elektrine.Repo.update_all( + from(p in Poll, where: p.id == ^locked_poll.id), + inc: [total_votes: 1] + ) + + case Poll.record_voter(locked_poll, actor_uri) do + {:ok, _updated_poll} -> :poll_vote_recorded + {:error, reason} -> Elektrine.Repo.rollback(reason) + end + else + :option_not_found + end + end + end) + |> case do + {:ok, result} -> {:ok, result} + {:error, reason} -> {:error, reason} + end + + {:error, reason} -> + {:error, reason} + end + end + + defp persist_poll_vote_receipt(vote_id, poll, message, actor_uri) do + object_id = + case message.activitypub_id do + value when is_binary(value) and value != "" -> value + _ -> Integer.to_string(poll.message_id) + end + + case ActivityPub.create_activity(%{ + activity_id: vote_id, + activity_type: "PollVote", + actor_uri: actor_uri, + object_id: object_id, + data: %{ + "type" => "PollVote", + "id" => vote_id, + "actor" => actor_uri + }, + local: false, + processed: true, + internal_message_id: poll.message_id + }) do + {:ok, _record} -> + {:ok, :recorded} + + {:error, %Ecto.Changeset{errors: [activity_id: {"has already been taken", _}]}} -> + {:ok, :already_recorded} + + {:error, reason} -> + Logger.warning("Failed to persist poll vote receipt: #{inspect(reason)}") + {:error, reason} + end + end + + defp create_regular_note(object, actor_uri, opts) do + object = Normalizer.enrich_sparse_object(object) + + if deleted_object_recorded?(object, actor_uri) do + {:ok, :ignored_deleted_object} + else + with {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri) do + payload = + build_federated_message_payload( + object, + actor_uri, + Keyword.put(opts, :enrich_sparse_object, false) + ) + + attrs = Map.merge(payload.attrs, %{federated: true, remote_actor_id: remote_actor.id}) + + %URI{host: instance_domain} = URI.parse(actor_uri) + Async.start(fn -> Emojis.process_activitypub_tags(object["tag"], instance_domain) end) + + if attrs.visibility in ["public", "unlisted"] do + case Messaging.create_federated_message_with_status(attrs) do + {:ok, message, :created} -> + enqueue_home_feed_fanout(message.id) + + handle_post_create_tasks( + message, + remote_actor, + payload.hashtags, + attrs.reply_to_id, + payload.mentioned_local_users, + opts + ) + + {:ok, message} + + # Redelivery / Announce of an already-stored reply must NOT re-run + # new-reply side effects (reply-count increment, notifications). + {:ok, _message, :existing} -> + {:ok, :already_exists} + + {:error, reason} -> + Logger.error("Failed to create federated message: #{inspect(reason)}") + {:error, :failed_to_create_message} + end + else + {:ok, :ignored_non_public} + end + end + end + end + + defp handle_post_create_tasks( + message, + remote_actor, + hashtags, + reply_to_id, + mentioned_local_users, + opts + ) do + if hashtags != [] do + Async.run(fn -> link_hashtags_to_message(message.id, hashtags) end) + end + + Async.start(fn -> generate_link_preview_for_message(message) end) + + if reply_to_id do + Elektrine.ActivityPub.SideEffects.increment_reply_count(reply_to_id) + + Async.run(fn -> + Elektrine.Notifications.FederationNotifications.notify_remote_reply( + message.id, + remote_actor.id + ) + end) + else + Async.start(fn -> maybe_store_missing_reply_parent(message, remote_actor, opts) end) + end + + if mentioned_local_users != [] do + Async.run(fn -> + notify_mentioned_users(mentioned_local_users, message.id, remote_actor.id) + end) + end + + broadcast_created_message(message, opts, [ + :remote_actor, + :sender, + :conversation, + :link_preview, + :hashtags + ]) + end + + defp ingestion_opts(activity) when is_map(activity) do + ancestor_depth = + activity["_elektrine_ancestor_depth"] || + get_in(activity, ["object", "_elektrine_ancestor_depth"]) + + [] + |> maybe_put_opt(:conversation_id, activity["_elektrine_target_community_id"]) + |> maybe_put_opt(:fallback_community_uri, activity["_elektrine_target_community_uri"]) + |> maybe_put_opt(:ancestor_depth, ancestor_depth) + end + + defp maybe_put_opt(opts, key, value) when is_integer(value), do: Keyword.put(opts, key, value) + + defp maybe_put_opt(opts, key, value) when is_binary(value) and value != "", + do: Keyword.put(opts, key, value) + + defp maybe_put_opt(opts, _key, _value), do: opts + + defp broadcast_created_message(message, opts, preloads) do + reloaded_message = Elektrine.Repo.preload(message, preloads, force: true) + + case Keyword.get(opts, :conversation_id) do + conversation_id when is_integer(conversation_id) -> + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{conversation_id}", + {:new_message, reloaded_message} + ) + + if is_nil(reloaded_message.reply_to_id) do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "discussion:#{conversation_id}", + {:new_message, reloaded_message} + ) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "discussions:all", + {:new_discussion_post, reloaded_message} + ) + end + + _ -> + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "timeline:public", + {:new_public_post, reloaded_message} + ) + end + end + + defp enqueue_home_feed_fanout(message_id) when is_integer(message_id) do + module = Module.concat([Elektrine.Social, HomeFeedFanoutWorker]) + _ = module.enqueue(message_id) + :ok + rescue + _ -> :ok + end + + defp maybe_store_missing_reply_parent(message, remote_actor, opts) do + metadata = Map.get(message, :media_metadata) || %{} + + in_reply_to_ref = + metadata["inReplyTo"] || + metadata[:inReplyTo] || + metadata["in_reply_to"] || + metadata[:in_reply_to] + + normalized_ref = Normalizer.normalize_uri(in_reply_to_ref) + depth = Keyword.get(opts, :ancestor_depth, 0) + + cond do + !is_binary(normalized_ref) or normalized_ref == "" -> + :ok + + message.reply_to_id -> + :ok + + # Parent already exists locally: link the orphan to it rather than + # leaving reply_to_id nil forever. This is the common case when the + # reply raced its parent (or the parent was masked by the negative + # ref cache at payload-build time). Linking to an existing parent is + # cheap and involves no fetch, so it is not depth-bounded. + parent = Messaging.get_message_by_activitypub_ref(normalized_ref) -> + maybe_link_orphan_reply_to_parent(message, parent) + :ok + + # Bound how far ingestion walks up an unknown reply chain: a hostile + # server can otherwise serve an endless inReplyTo chain of fresh objects + # and drive unbounded signed fetches + inserts, one per hop. + depth >= @max_ingestion_ancestor_depth -> + :ok + + true -> + case Helpers.get_or_store_remote_post(normalized_ref, remote_actor && remote_actor.uri, + ancestor_depth: depth + 1 + ) do + {:ok, parent_message} when is_map(parent_message) -> + maybe_link_orphan_reply_to_parent(message, parent_message) + + _ -> + :ok + end + + :ok + end + end + + defp maybe_link_orphan_reply_to_parent(message, parent_message) + when is_map(message) and is_map(parent_message) do + cond do + # Only link a still-unthreaded reply. + not is_nil(message.reply_to_id) -> + :ok + + # Reject self-references and direct 2-cycles (A.inReplyTo=B, B.inReplyTo=A) + # so the thread tree can't be corrupted into a loop. + parent_message.id == message.id or parent_message.reply_to_id == message.id -> + :ok + + true -> + case message + |> Ecto.Changeset.change(reply_to_id: parent_message.id) + |> Elektrine.Repo.update() do + {:ok, _updated} -> + # A late link is still a new reply for this parent — count it once. + Elektrine.ActivityPub.SideEffects.increment_reply_count(parent_message.id) + :ok + + error -> + error + end + end + end + + defp maybe_link_orphan_reply_to_parent(_, _), do: :ok + + defp do_upsert_federated_poll(message_id, object, options) + when is_integer(message_id) and is_map(object) and is_list(options) do + poll_attrs = build_poll_record_attrs(message_id, object, options) + + case Elektrine.Repo.get_by(Elektrine.Social.Poll, message_id: message_id) do + nil -> + poll_struct = :erlang.apply(Elektrine.Social.Poll, :__struct__, []) + + case Elektrine.Repo.insert(Elektrine.Social.Poll.changeset(poll_struct, poll_attrs)) do + {:ok, poll} -> + replace_poll_options(poll, options) + {:ok, poll} + + {:error, reason} -> + Logger.error("Failed to create federated poll: #{inspect(reason)}") + {:error, reason} + end + + poll -> + case poll + |> Elektrine.Social.Poll.changeset(Map.delete(poll_attrs, :message_id)) + |> Elektrine.Repo.update() do + {:ok, updated_poll} -> + replace_poll_options(updated_poll, options) + {:ok, updated_poll} + + {:error, reason} -> + Logger.error("Failed to update federated poll: #{inspect(reason)}") + {:error, reason} + end + end + end + + defp do_upsert_federated_poll(_message_id, _object, _options), do: {:error, :invalid_poll} + + defp replace_poll_options(poll, options) do + from(o in Elektrine.Social.PollOption, where: o.poll_id == ^poll.id) + |> Elektrine.Repo.delete_all() + + Enum.each(options, fn option -> + option_struct = :erlang.apply(Elektrine.Social.PollOption, :__struct__, []) + + option_attrs = %{ + poll_id: poll.id, + option_text: option.text, + vote_count: option.votes, + position: option[:position] || 0 + } + + option_struct + |> Elektrine.Social.PollOption.changeset(option_attrs) + |> Elektrine.Repo.insert() + end) + end + + defp build_poll_record_attrs(message_id, object, options) do + end_time = + case object["endTime"] || object["closed"] do + nil -> nil + time_str -> Helpers.parse_published_date(time_str) + end + + voters_count = extract_voters_count(object, options) + + %{ + message_id: message_id, + question: Normalizer.poll_question_text(object) |> String.slice(0, 300), + total_votes: voters_count, + voters_count: voters_count, + closes_at: end_time, + allow_multiple: !is_nil(object["anyOf"]), + hide_totals: false, + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + } + end + + defp extract_voters_count(object, options) do + case object["votersCount"] do + count when is_integer(count) and count > 0 -> + count + + count when is_binary(count) -> + case Integer.parse(count) do + {n, _} when n > 0 -> n + _ -> sum_option_votes(options) + end + + _ -> + sum_option_votes(options) + end + end + + defp sum_option_votes(options) do + Enum.reduce(options, 0, fn opt, acc -> acc + (opt[:votes] || 0) end) + end + + defp inherit_wrapper_fields(activity, object) when is_map(activity) and is_map(object) do + ["to", "cc", "audience", "published"] + |> Enum.reduce(object, fn field, acc -> + inherit_wrapper_field(acc, activity, field) + end) + end + + defp inherit_wrapper_fields(_activity, object), do: object + + defp inherit_wrapper_field(object, activity, field) do + case {Map.get(object, field), Map.get(activity, field)} do + {value, inherited} when value in [nil, ""] and not is_nil(inherited) -> + Map.put(object, field, inherited) + + {[], inherited} when not is_nil(inherited) -> + Map.put(object, field, inherited) + + _ -> + object + end + end + + defp deleted_object_recorded?(object, actor_uri) when is_map(object) and is_binary(actor_uri) do + refs = + [object["id"], object["url"]] + |> Enum.reject(&is_nil/1) + + ActivityPub.remote_delete_recorded?(actor_uri, refs) + end + + defp deleted_object_recorded?(_object, _actor_uri), do: false + + defp notify_mentioned_users(usernames, message_id, remote_actor_id) do + remote_actor = Elektrine.Repo.get(Elektrine.ActivityPub.Actor, remote_actor_id) + + Enum.each(usernames, fn username -> + case Elektrine.Accounts.get_user_by_username(username) do + nil -> + :ok + + user -> + actor_name = + if remote_actor do + "@#{remote_actor.username}@#{remote_actor.domain}" + else + "a remote user" + end + + Elektrine.Notifications.create_notification(%{ + user_id: user.id, + type: "mention", + title: "Mentioned in a post", + body: "#{actor_name} mentioned you in a post", + url: Elektrine.Notifications.resolve_message_notification_url(message_id, "mention"), + source_type: "message", + source_id: message_id, + priority: "normal" + }) + end + end) + end + + defp generate_link_preview_for_message(message) do + external_link = get_in(message.media_metadata || %{}, ["external_link"]) + + content_urls = + if Elektrine.Strings.present?(message.content) do + Elektrine.Social.LinkPreviewFetcher.extract_urls(message.content) + else + [] + end + + url = external_link || List.first(content_urls) + + case url do + nil -> + :ok + + url -> + try do + metadata = Elektrine.Social.LinkPreviewFetcher.fetch_preview_metadata(url) + + case metadata do + %{status: "success"} -> + preview_struct = :erlang.apply(Social.LinkPreview, :__struct__, []) + + preview_changeset = + Social.LinkPreview.changeset(preview_struct, %{ + url: url, + title: metadata.title, + description: metadata.description, + image_url: metadata.image_url, + favicon_url: metadata.favicon_url, + site_name: metadata.site_name, + status: "success", + fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + _ = + Elektrine.Repo.insert( + preview_changeset, + on_conflict: :nothing, + conflict_target: :url + ) + + case Elektrine.Repo.get_by(Social.LinkPreview, url: url) do + %{id: id} when is_integer(id) -> + Messaging.update_message(message, %{link_preview_id: id}) + + _ -> + :ok + end + + _ -> + :ok + end + rescue + e -> + Logger.warning("Failed to generate link preview: #{inspect(e)}") + :ok + end + end + end + + defp link_hashtags_to_message(message_id, hashtag_names) do + hashtags = + Enum.map(hashtag_names, fn name -> Social.get_or_create_hashtag(name) end) + |> Enum.filter(&(&1 != nil)) + + if hashtags != [] do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + associations = + Enum.map(hashtags, fn hashtag -> + %{message_id: message_id, hashtag_id: hashtag.id, inserted_at: now} + end) + + Elektrine.Repo.insert_all(Social.PostHashtag, associations, on_conflict: :nothing) + + Enum.each(hashtags, fn hashtag -> Social.increment_hashtag_usage(hashtag.id) end) + end + end + + defp get_local_message_from_uri(uri) do + base_url = ActivityPub.instance_url() + + cond do + String.starts_with?(uri, "#{base_url}/posts/") -> + id = String.replace_prefix(uri, "#{base_url}/posts/", "") + get_message_by_id(id) + + String.match?(uri, ~r{#{base_url}/users/[^/]+/posts/}) -> + id = uri |> String.split("/posts/") |> List.last() + get_message_by_id(id) + + true -> + case Messaging.get_message_by_activitypub_ref(uri) do + nil -> {:error, :message_not_found} + message -> {:ok, message} + end + end + end + + defp get_message_by_id(id) do + case Messaging.get_message(parse_message_id(id)) do + nil -> {:error, :message_not_found} + message -> {:ok, message} + end + rescue + Ecto.Query.CastError -> {:error, :message_not_found} + end + + defp parse_message_id(id) when is_integer(id), do: id + + defp parse_message_id(id) when is_binary(id) do + case Integer.parse(id) do + {parsed, ""} -> parsed + _ -> id + end + end + + defp parse_message_id(id), do: id +end diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/delete_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/delete_handler.ex new file mode 100644 index 0000000..37d16fe --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/handlers/delete_handler.ex @@ -0,0 +1,108 @@ +defmodule Elektrine.ActivityPub.Handlers.DeleteHandler do + @moduledoc """ + Handles Delete ActivityPub activities. + """ + + require Logger + + alias Elektrine.ActivityPub + alias Elektrine.Messaging + + @doc """ + Handles an incoming Delete activity. + """ + def handle(%{"object" => object} = activity, actor_uri, _target_user) do + object_id = + case object do + obj when is_binary(obj) -> obj + %{"id" => id} -> id + _ -> nil + end + + if object_id do + case Messaging.get_message_by_activitypub_ref(object_id) do + nil -> + case ActivityPub.record_remote_delete_receipt(activity, actor_uri, object_id) do + {:ok, _receipt} -> + {:ok, :delete_receipt_recorded} + + {:error, reason} -> + Logger.warning( + "Failed to record Delete receipt for #{inspect(object_id)} from #{actor_uri}: #{inspect(reason)}" + ) + + {:error, :delete_receipt_failed} + end + + message -> + if message.federated && message.remote_actor_id do + # Capture pre-update state so a redelivered Delete (Mastodon retries) + # doesn't decrement the parent's reply_count more than once. + already_deleted? = not is_nil(message.deleted_at) + + with {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri), + true <- message.remote_actor_id == remote_actor.id do + update_result = + message + |> Ecto.Changeset.change(%{ + deleted_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Elektrine.Repo.update() + + case update_result do + {:ok, deleted_message} -> + # Keep the parent's reply_count from drifting upward: a + # deleted reply must decrement its parent exactly once. + if deleted_message.reply_to_id && not already_deleted? do + Elektrine.ActivityPub.SideEffects.decrement_reply_count( + deleted_message.reply_to_id + ) + end + + broadcast_deleted_post(deleted_message) + + _ -> + :ok + end + + case ActivityPub.record_remote_delete_receipt(activity, actor_uri, object_id) do + {:ok, _receipt} -> + {:ok, :deleted} + + {:error, reason} -> + Logger.warning( + "Deleted message #{message.id} but failed to persist Delete receipt: #{inspect(reason)}" + ) + + {:ok, :deleted} + end + else + {:error, reason} -> + Logger.warning( + "Delete actor resolution failed for #{actor_uri}: #{inspect(reason)}" + ) + + {:error, :delete_actor_fetch_failed} + + false -> + Logger.warning("Delete attempt from non-owner: #{actor_uri}") + {:ok, :unauthorized} + end + else + {:ok, :not_federated_message} + end + end + else + Logger.warning("Delete activity has no valid object ID") + {:ok, :invalid_delete} + end + end + + defp broadcast_deleted_post(%{id: message_id}) when is_integer(message_id) do + Phoenix.PubSub.broadcast(Elektrine.PubSub, "timeline:public", {:post_deleted, message_id}) + Phoenix.PubSub.broadcast(Elektrine.PubSub, "post:#{message_id}", {:post_deleted, message_id}) + :ok + end + + defp broadcast_deleted_post(_), do: :ok +end diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/flag_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/flag_handler.ex new file mode 100644 index 0000000..dd15aba --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/handlers/flag_handler.ex @@ -0,0 +1,329 @@ +defmodule Elektrine.ActivityPub.Handlers.FlagHandler do + @moduledoc """ + Handles Flag (report) ActivityPub activities. + + Flag activities are used to report users or content to instance administrators. + When we receive a Flag, we create a local report for admin review. + + ## ActivityPub Flag format + + { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Flag", + "actor": "https://remote.server/users/reporter", + "object": [ + "https://our.server/users/reported_user", + "https://our.server/posts/123" + ], + "content": "This user is posting spam" + } + """ + + require Logger + + alias Elektrine.Accounts + alias Elektrine.ActivityPub + alias Elektrine.Reports + + @doc """ + Handles an incoming Flag (report) activity. + Creates a local report for admin review. + """ + def handle(%{"object" => objects, "content" => content} = _activity, actor_uri, _target_user) do + handle_report(objects, content, actor_uri) + end + + def handle(%{"object" => objects} = _activity, actor_uri, _target_user) do + handle_report(objects, nil, actor_uri) + end + + defp handle_report(objects, content, reporter_actor_uri) do + objects = List.wrap(objects) + + # Separate user URIs from content URIs + {user_uris, content_uris} = categorize_objects(objects) + + # Get or create the remote reporter actor + reporter_actor = + case ActivityPub.get_or_fetch_actor(reporter_actor_uri) do + {:ok, actor} -> actor + _ -> nil + end + + # Find local users being reported + reported_users = find_local_users(user_uris) + + # Find local messages being reported + reported_messages = find_local_messages(content_uris) + + cond do + # Report against a local user + reported_users != [] -> + Enum.each(reported_users, fn user -> + create_federated_report( + user, + reported_messages, + content, + reporter_actor, + reporter_actor_uri + ) + end) + + {:ok, :report_received} + + # Report against local content only (no user specified) + reported_messages != [] -> + # Get the author of the first reported message + first_message = hd(reported_messages) + + if first_message.sender_id do + user = Accounts.get_user!(first_message.sender_id) + + if user do + create_federated_report( + user, + reported_messages, + content, + reporter_actor, + reporter_actor_uri + ) + end + end + + {:ok, :report_received} + + true -> + Logger.debug("Flag activity targets no local users or content, ignoring") + {:ok, :ignored} + end + end + + defp categorize_objects(objects) do + Enum.reduce(objects, {[], []}, fn object_uri, {users, content} -> + uri = normalize_uri(object_uri) + + if user_uri?(uri), do: {[uri | users], content}, else: {users, [uri | content]} + end) + end + + defp normalize_uri(uri) when is_binary(uri), do: uri + defp normalize_uri(%{"id" => id}), do: id + defp normalize_uri(_), do: nil + + defp user_uri?(nil), do: false + + defp user_uri?(uri) do + match?({:ok, _username}, ActivityPub.local_username_from_uri(uri)) + end + + defp find_local_users(uris) do + uris + |> Enum.filter(& &1) + |> Enum.map(fn uri -> + case ActivityPub.local_username_from_uri(uri) do + {:ok, username} -> Accounts.get_user_by_username(username) + _ -> nil + end + end) + |> Enum.filter(& &1) + end + + defp find_local_messages(uris) do + uris + |> Enum.filter(& &1) + |> Enum.flat_map(fn uri -> + local_path = local_path_if_local_domain(uri) + + cond do + # Format: /posts/{id} + is_binary(local_path) and String.starts_with?(local_path, "/posts/") -> + id_str = String.replace_prefix(local_path, "/posts/", "") + get_message_by_id_string(id_str) + + # Format: /users/{username}/statuses/{id} + is_binary(local_path) and String.match?(local_path, ~r{^/users/[^/]+/statuses/}) -> + id_str = local_path |> String.split("/statuses/") |> List.last() + get_message_by_id_string(id_str) + + # Check by canonical ID or URL variant. + true -> + case Elektrine.Messaging.get_message_by_activitypub_ref(uri) do + nil -> [] + msg -> [msg] + end + end + end) + end + + defp local_path_if_local_domain(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{host: host, path: path} when is_binary(host) and is_binary(path) -> + if Elektrine.Domains.local_activitypub_domain?(String.downcase(host)) do + path + else + nil + end + + _ -> + nil + end + end + + defp local_path_if_local_domain(_), do: nil + + # Safely parse ID string and fetch message + defp get_message_by_id_string(id_str) do + case Integer.parse(id_str) do + {id, ""} -> + case Elektrine.Messaging.get_message(id) do + nil -> [] + msg -> [msg] + end + + _ -> + # Invalid ID format, skip + [] + end + end + + defp create_federated_report( + reported_user, + reported_messages, + content, + reporter_actor, + reporter_uri + ) do + # Build metadata about the reporter + reporter_info = + if reporter_actor do + %{ + "remote_reporter" => %{ + "uri" => reporter_actor.uri, + "username" => reporter_actor.username, + "domain" => reporter_actor.domain, + "display_name" => reporter_actor.display_name + } + } + else + %{ + "remote_reporter" => %{ + "uri" => reporter_uri + } + } + end + + # Add reported content URIs to metadata + content_info = + if reported_messages != [] do + Map.put(reporter_info, "reported_content_ids", Enum.map(reported_messages, & &1.id)) + else + reporter_info + end + + # Create the report + # Use a system reporter ID for federated reports (or first admin) + system_reporter_id = get_system_reporter_id() + + report_attrs = %{ + reporter_id: system_reporter_id, + reportable_type: "user", + reportable_id: reported_user.id, + reason: "other", + description: build_report_description(content, reporter_actor, reporter_uri), + status: "pending", + priority: "normal", + metadata: content_info + } + + case Reports.create_report(report_attrs) do + {:ok, report} -> + Logger.info( + "Created federated report ##{report.id} against user #{reported_user.username} " <> + "from #{reporter_uri}" + ) + + # Notify admins about the new report + notify_admins_of_federated_report(report, reported_user, reporter_actor) + + {:ok, report} + + {:error, reason} -> + Logger.error("Failed to create federated report: #{inspect(reason)}") + {:error, reason} + end + end + + defp build_report_description(content, reporter_actor, reporter_uri) do + reporter_name = + if reporter_actor do + "@#{reporter_actor.username}@#{reporter_actor.domain}" + else + reporter_uri + end + + base = "[Federated Report from #{reporter_name}]" + + if content && content != "" do + "#{base}\n\n#{content}" + else + "#{base}\n\nNo additional details provided." + end + end + + defp get_system_reporter_id do + # Get the first admin user to use as the reporter for federated reports + import Ecto.Query + + case Elektrine.Repo.one( + from(u in Elektrine.Accounts.User, + where: u.is_admin == true, + limit: 1, + select: u.id + ) + ) do + nil -> + # Fallback: get any user (shouldn't happen in production) + Elektrine.Repo.one( + from(u in Elektrine.Accounts.User, + limit: 1, + select: u.id + ) + ) + + admin_id -> + admin_id + end + end + + defp notify_admins_of_federated_report(report, reported_user, reporter_actor) do + # Create notifications for all admins + import Ecto.Query + + admin_ids = + Elektrine.Repo.all( + from(u in Elektrine.Accounts.User, + where: u.is_admin == true, + select: u.id + ) + ) + + reporter_name = + if reporter_actor do + "@#{reporter_actor.username}@#{reporter_actor.domain}" + else + "a remote user" + end + + Enum.each(admin_ids, fn admin_id -> + Elektrine.Notifications.create_notification(%{ + user_id: admin_id, + type: "system", + title: "New Federated Report", + body: "#{reporter_name} reported @#{reported_user.username}", + source_type: "report", + source_id: report.id, + priority: "high" + }) + end) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/follow_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/follow_handler.ex new file mode 100644 index 0000000..8ffd5dc --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/handlers/follow_handler.ex @@ -0,0 +1,419 @@ +defmodule Elektrine.ActivityPub.Handlers.FollowHandler do + @moduledoc "Handles Follow, Accept, and Reject ActivityPub activities.\n" + require Logger + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.{Builder, Publisher, Relay} + alias Elektrine.Async + alias Elektrine.Profiles + alias Elektrine.RuntimeEnv + @doc "Handles an incoming Follow activity.\n" + def handle( + %{"id" => follow_id, "actor" => actor_uri, "object" => object_ref}, + actor_uri, + _target_user + ) do + case resolve_follow_target_uri(object_ref) do + object_uri when is_binary(object_uri) and object_uri != "" -> + remote_actor_result = + if RuntimeEnv.environment() == :dev do + case ActivityPub.get_or_fetch_actor(actor_uri) do + {:ok, actor} -> + {:ok, actor} + + {:error, _} -> + uri = URI.parse(actor_uri) + + Elektrine.Repo.insert( + %ActivityPub.Actor{ + uri: actor_uri, + username: "testuser", + domain: uri.host, + inbox_url: "#{actor_uri}/inbox", + public_key: + "-----BEGIN RSA PUBLIC KEY-----\nMOCK\n-----END RSA PUBLIC KEY-----\n", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second), + metadata: %{"mock" => true} + }, + on_conflict: :nothing + ) + end + else + ActivityPub.get_or_fetch_actor(actor_uri) + end + + case remote_actor_result do + {:ok, remote_actor} -> + relay_url = Relay.relay_actor_id() + + cond do + object_uri == relay_url -> + Logger.info("Relay #{actor_uri} wants to follow our relay") + {:ok, relay_actor} = Relay.get_or_create_relay_actor() + send_relay_accept(relay_actor, remote_actor, follow_id) + {:ok, :relay_follow_accepted} + + group_actor = ActivityPub.get_local_group_actor_by_uri(object_uri) -> + handle_group_follow(remote_actor, group_actor, follow_id, actor_uri) + + true -> + handle_user_follow(remote_actor, object_uri, follow_id, actor_uri) + end + + error -> + Logger.error("Failed to handle follow: #{inspect(error)}") + {:error, :handle_follow_failed} + end + + _ -> + Logger.warning("Invalid Follow target from #{actor_uri}: #{inspect(object_ref)}") + {:error, :handle_follow_failed} + end + end + + @doc "Handles an incoming Accept activity for a Follow.\n" + def handle_accept( + %{"object" => %{"type" => "Follow", "id" => follow_id}} = _activity, + actor_uri, + _target_user + ) do + Logger.debug( + "FollowHandler: Received Accept with Follow object, id=#{follow_id}, actor=#{actor_uri}" + ) + + case Relay.handle_accept(follow_id, actor_uri) do + {:ok, _subscription} -> + {:ok, :relay_subscription_accepted} + + {:error, :subscription_not_found} -> + case Relay.handle_accept_from_actor(actor_uri) do + {:ok, _subscription} -> {:ok, :relay_subscription_accepted} + {:error, :subscription_not_found} -> handle_user_accept(follow_id, actor_uri) + end + end + end + + def handle_accept( + %{"object" => follow_id} = _activity, + actor_uri, + _target_user + ) + when is_binary(follow_id) do + Logger.debug( + "FollowHandler: Received Accept with string object=#{follow_id}, actor=#{actor_uri}" + ) + + case Relay.handle_accept(follow_id, actor_uri) do + {:ok, _subscription} -> + {:ok, :relay_subscription_accepted} + + {:error, :subscription_not_found} -> + case Relay.handle_accept_from_actor(actor_uri) do + {:ok, _subscription} -> {:ok, :relay_subscription_accepted} + {:error, :subscription_not_found} -> handle_user_accept(follow_id, actor_uri) + end + end + end + + def handle_accept(_activity, _actor_uri, _target_user) do + {:ok, :unhandled} + end + + defp handle_user_accept(follow_id, actor_uri) do + case ActivityPub.get_activity_by_id(follow_id) do + nil -> + Logger.warning("Received Accept for unknown Follow: #{follow_id}") + {:ok, :unknown_follow} + + activity -> + cond do + !activity.internal_user_id -> + {:ok, :not_our_follow} + + !follow_target_matches_actor?(activity, actor_uri) -> + Logger.warning( + "Rejecting Accept for Follow #{follow_id}: actor #{actor_uri} does not match original follow target #{activity.object_id}" + ) + + {:ok, :unauthorized} + + true -> + Profiles.accept_follow_by_activity_id(follow_id) + + Async.run(fn -> + Elektrine.Notifications.FederationNotifications.notify_follow_accepted( + activity.internal_user_id, + activity.object_id + ) + end) + + {:ok, :follow_accepted} + end + end + end + + @doc "Handles an incoming Reject activity for a Follow.\n" + def handle_reject( + %{"object" => %{"type" => "Follow", "id" => follow_id}}, + actor_uri, + _target_user + ) do + handle_reject_by_id(follow_id, actor_uri) + end + + def handle_reject( + %{"object" => follow_id}, + actor_uri, + _target_user + ) + when is_binary(follow_id) do + handle_reject_by_id(follow_id, actor_uri) + end + + def handle_reject(_activity, _actor_uri, _target_user) do + {:ok, :unhandled} + end + + defp handle_reject_by_id(follow_id, actor_uri) do + case Relay.handle_reject(follow_id, actor_uri) do + {:ok, _subscription} -> + {:ok, :relay_subscription_rejected} + + {:error, :subscription_not_found} -> + case ActivityPub.get_activity_by_id(follow_id) do + nil -> + {:ok, :unknown_follow} + + activity -> + cond do + !activity.internal_user_id -> + {:ok, :not_our_follow} + + !follow_target_matches_actor?(activity, actor_uri) -> + Logger.warning( + "Rejecting Reject for Follow #{follow_id}: actor #{actor_uri} does not match original follow target #{activity.object_id}" + ) + + {:ok, :unauthorized} + + true -> + Profiles.delete_follow_by_activity_id(follow_id) + {:ok, :follow_rejected} + end + end + end + end + + @doc "Handles Undo Follow activity.\n" + def handle_undo(%{"object" => followed_uri}, actor_uri) when is_binary(followed_uri) do + case ActivityPub.get_or_fetch_actor(actor_uri) do + {:ok, remote_actor} -> + case ActivityPub.get_local_group_actor_by_uri(followed_uri) do + nil -> + case get_local_user_from_uri(followed_uri) do + {:ok, followed_user} -> + Profiles.delete_remote_follow(remote_actor.id, followed_user.id) + {:ok, :unfollowed} + + {:error, _} -> + {:ok, :target_not_found} + end + + group_actor -> + ActivityPub.delete_group_follow(remote_actor.id, group_actor.id) + {:ok, :unfollowed} + end + + {:error, reason} -> + Logger.warning("Failed to process Undo Follow from #{actor_uri}: #{inspect(reason)}") + {:error, :undo_follow_failed} + end + end + + def handle_undo(%{"object" => followed_uri} = _follow_object, actor_uri) + when is_map(followed_uri) do + case resolve_follow_target_uri(followed_uri) do + uri when is_binary(uri) and uri != "" -> + handle_undo(%{"object" => uri}, actor_uri) + + _ -> + Logger.warning("Invalid Undo Follow from #{actor_uri}") + {:ok, :invalid} + end + end + + def handle_undo(_object, actor_uri) do + Logger.warning("Invalid Undo Follow from #{actor_uri}") + {:ok, :invalid} + end + + defp follow_target_matches_actor?(%{object_id: object_id}, actor_uri) + when is_binary(object_id) and is_binary(actor_uri) do + normalize_actor_uri(object_id) == normalize_actor_uri(actor_uri) + end + + defp follow_target_matches_actor?(_, _), do: false + + defp normalize_actor_uri(uri) when is_binary(uri) do + uri + |> String.trim() + |> String.split("#", parts: 2) + |> hd() + |> String.split("?", parts: 2) + |> hd() + |> String.trim_trailing("/") + end + + defp resolve_follow_target_uri(uri) when is_binary(uri), do: uri + + defp resolve_follow_target_uri(%{"object" => object_ref}) do + resolve_follow_target_uri(object_ref) + end + + defp resolve_follow_target_uri(%{"id" => id}) when is_binary(id), do: id + defp resolve_follow_target_uri(_), do: nil + + defp handle_user_follow(remote_actor, object_uri, follow_id, _actor_uri) do + case get_local_user_from_uri(object_uri) do + {:ok, followed_user} -> + existing_follow = Profiles.get_follow_by_remote_actor(remote_actor.id, followed_user.id) + + if existing_follow do + if existing_follow.pending do + {:ok, :pending} + else + send_accept(followed_user, remote_actor, follow_id, object_uri) + {:ok, :already_following} + end + else + pending = + followed_user.activitypub_manually_approve_followers || + followed_user.profile_visibility in ["followers", "private"] + + case Profiles.create_remote_follow( + remote_actor.id, + followed_user.id, + pending, + follow_id + ) do + {:ok, _follow} -> + if pending do + Async.run(fn -> + Elektrine.Notifications.FederationNotifications.notify_remote_follow( + followed_user.id, + remote_actor.id + ) + end) + + {:ok, :pending} + else + send_accept(followed_user, remote_actor, follow_id, object_uri) + + Async.run(fn -> + Elektrine.Notifications.FederationNotifications.notify_remote_follow( + followed_user.id, + remote_actor.id + ) + end) + + {:ok, :accepted} + end + + {:error, reason} -> + Logger.error("Failed to create follow: #{inspect(reason)}") + {:error, :failed_to_create_follow} + end + end + + {:error, _} -> + Logger.error("Follow target not found: #{object_uri}") + {:error, :target_not_found} + end + end + + defp handle_group_follow(remote_actor, group_actor, follow_id, _actor_uri) do + existing_follow = ActivityPub.get_group_follow(remote_actor.id, group_actor.id) + + if existing_follow do + send_group_accept(group_actor, remote_actor, follow_id) + {:ok, :already_following} + else + case ActivityPub.create_group_follow(remote_actor.id, group_actor.id, follow_id, false) do + {:ok, _follow} -> + send_group_accept(group_actor, remote_actor, follow_id) + {:ok, :accepted} + + {:error, reason} -> + Logger.error("Failed to create group follow: #{inspect(reason)}") + {:error, :failed_to_create_follow} + end + end + end + + defp send_accept(user, remote_actor, follow_id, object_uri) when is_binary(follow_id) do + target_uri = object_uri || ActivityPub.actor_uri(user) + + accept_activity = + Builder.build_accept_activity(user, %{ + "id" => follow_id, + "type" => "Follow", + "actor" => remote_actor.uri, + "object" => target_uri + }) + + Publisher.publish(accept_activity, user, [remote_actor.inbox_url]) + end + + defp send_group_accept(group_actor, remote_actor, follow_id) when is_binary(follow_id) do + base_url = ActivityPub.instance_url() + activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}" + + accept_activity = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "type" => "Accept", + "actor" => group_actor.uri, + "object" => %{ + "id" => follow_id, + "type" => "Follow", + "actor" => remote_actor.uri, + "object" => group_actor.uri + } + } + + Publisher.publish(accept_activity, nil, [remote_actor.inbox_url]) + end + + defp send_relay_accept(relay_actor, remote_actor, follow_id) do + base_url = ActivityPub.instance_url() + relay_url = Relay.relay_actor_id() + activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}" + + accept_activity = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => activity_id, + "type" => "Accept", + "actor" => relay_url, + "object" => %{ + "id" => follow_id, + "type" => "Follow", + "actor" => remote_actor.uri, + "object" => relay_url + } + } + + Publisher.deliver(accept_activity, relay_actor, remote_actor.inbox_url) + end + + defp get_local_user_from_uri(uri) do + case ActivityPub.local_username_from_uri(uri) do + {:ok, username} -> + case Elektrine.Accounts.get_user_by_username(username) do + %{activitypub_enabled: true} = user -> {:ok, user} + _ -> {:error, :not_found} + end + + {:error, _reason} -> + {:error, :not_local} + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/like_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/like_handler.ex new file mode 100644 index 0000000..116cafd --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/handlers/like_handler.ex @@ -0,0 +1,424 @@ +defmodule Elektrine.ActivityPub.Handlers.LikeHandler do + @moduledoc """ + Handles Like, Dislike, and EmojiReact ActivityPub activities. + """ + + require Logger + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Handlers.CreateHandler + alias Elektrine.ActivityPub.RemoteFetch + alias Elektrine.Async + alias Elektrine.Messaging + + @doc """ + Handles an incoming Like activity. + """ + def handle(%{"object" => object_ref} = activity, actor_uri, _target_user) do + activity_id = activity["id"] + object_uri = extract_target_object_uri(object_ref) + + with {:ok, object_uri} when not is_nil(object_uri) <- {:ok, object_uri}, + {:ok, message} <- get_or_fetch_message_from_uri(object_uri), + {:ok, remote_actor} <- fetch_remote_actor(actor_uri, :like_actor_fetch_failed) do + case Messaging.create_federated_like(message.id, remote_actor.id, activity_id) do + {:ok, _like} -> + Async.run(fn -> + Elektrine.Notifications.FederationNotifications.notify_remote_like( + message.id, + remote_actor.id + ) + end) + + {:ok, :liked} + + {:error, reason} -> + Logger.error("Failed to create like: #{inspect(reason)}") + {:error, :failed_to_like} + end + else + {:error, :message_not_found} -> + Logger.debug("Like target #{inspect(object_uri)} not available yet, retrying") + {:error, :message_not_found} + + {:error, :like_actor_fetch_failed} -> + {:error, :like_actor_fetch_failed} + + error -> + Logger.warning("Failed to handle like: #{inspect(error)}") + {:error, :handle_like_failed} + end + end + + @doc """ + Handles an incoming Dislike activity (downvote). + """ + def handle_dislike(%{"object" => object_ref} = activity, actor_uri, _target_user) do + activity_id = activity["id"] + object_uri = extract_target_object_uri(object_ref) + + with {:ok, object_uri} when not is_nil(object_uri) <- {:ok, object_uri}, + {:ok, message} <- get_or_fetch_message_from_uri(object_uri), + {:ok, remote_actor} <- fetch_remote_actor(actor_uri, :dislike_actor_fetch_failed) do + case Messaging.create_federated_dislike(message.id, remote_actor.id, activity_id) do + {:ok, _dislike} -> + {:ok, :disliked} + + {:error, reason} -> + Logger.error("Failed to create dislike: #{inspect(reason)}") + {:error, :failed_to_dislike} + end + else + {:error, :message_not_found} -> + Logger.debug("Dislike target #{inspect(object_uri)} not available yet, retrying") + {:error, :message_not_found} + + {:error, :dislike_actor_fetch_failed} -> + {:error, :dislike_actor_fetch_failed} + + error -> + Logger.warning("Failed to handle dislike: #{inspect(error)}") + {:error, :handle_dislike_failed} + end + end + + @doc """ + Handles an incoming EmojiReact activity. + Supports both standard Unicode emoji and custom emoji with URLs. + """ + def handle_emoji_react(%{"object" => object_ref} = activity, actor_uri, _target_user) do + object_uri = extract_target_object_uri(object_ref) + emoji = extract_emoji_content(activity) + + if is_binary(emoji) and emoji != "" do + emoji_url = extract_emoji_url(activity, emoji) + + with {:ok, object_uri} when not is_nil(object_uri) <- {:ok, object_uri}, + {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri), + {:ok, message} <- get_or_fetch_message_from_uri(object_uri) do + case Elektrine.Social.Messages.create_federated_emoji_reaction( + message.id, + remote_actor.id, + emoji, + emoji_url + ) do + {:ok, _reaction} -> + Async.run(fn -> + Elektrine.Notifications.FederationNotifications.notify_remote_reaction( + message.id, + remote_actor.id, + emoji + ) + end) + + {:ok, :emoji_reacted} + + {:error, reason} -> + Logger.error("Failed to create emoji reaction: #{inspect(reason)}") + {:error, :failed_to_react} + end + else + {:error, :message_not_found} -> + Logger.debug("EmojiReact for unknown message, ignoring") + {:error, :handle_emoji_react_failed} + + error -> + Logger.warning("Failed to handle emoji react: #{inspect(error)}") + {:error, :handle_emoji_react_failed} + end + else + {:ok, :unhandled} + end + end + + def handle_emoji_react(_activity, _actor_uri, _target_user), do: {:ok, :unhandled} + + # Extract custom emoji URL from EmojiReact activity tags + # Format: {"tag": [{"type": "Emoji", "name": ":blobcat:", "icon": {"url": "https://..."}}]} + defp extract_emoji_url(%{"tag" => tags}, emoji) when is_list(tags) do + # Find matching emoji tag + Enum.find_value(tags, fn + %{"type" => "Emoji", "name" => name, "icon" => %{"url" => url}} -> + # Match by name (with or without colons) + clean_name = String.trim(name, ":") + clean_emoji = String.trim(emoji, ":") + if clean_name == clean_emoji, do: url, else: nil + + _ -> + nil + end) + end + + defp extract_emoji_url(_, _), do: nil + + @doc """ + Handles Undo Like activity. + """ + def handle_undo_like(%{"object" => object_ref}, actor_uri) do + object_uri = resolve_target_object_uri(object_ref) + + with {:ok, object_uri} when not is_nil(object_uri) <- {:ok, object_uri}, + {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri), + {:ok, message} <- get_local_message_from_uri(object_uri) do + Messaging.delete_federated_like(message.id, remote_actor.id) + {:ok, :unliked} + else + {:error, :message_not_found} -> + {:ok, :message_not_found} + + error -> + Logger.warning("Failed to undo like: #{inspect(error)}") + {:error, :undo_like_failed} + end + end + + @doc """ + Handles Undo Dislike activity. + """ + def handle_undo_dislike(%{"object" => object_ref}, actor_uri) do + object_uri = resolve_target_object_uri(object_ref) + + with {:ok, object_uri} when not is_nil(object_uri) <- {:ok, object_uri}, + {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri), + {:ok, message} <- get_local_message_from_uri(object_uri) do + Messaging.delete_federated_dislike(message.id, remote_actor.id) + {:ok, :undisliked} + else + {:error, :message_not_found} -> + {:ok, :message_not_found} + + error -> + Logger.warning("Failed to undo dislike: #{inspect(error)}") + {:error, :undo_dislike_failed} + end + end + + @doc """ + Handles Undo EmojiReact activity. + """ + def handle_undo_emoji_react(%{"object" => object_ref, "content" => emoji}, actor_uri) + when is_binary(emoji) do + object_uri = resolve_target_object_uri(object_ref) + + with {:ok, object_uri} when not is_nil(object_uri) <- {:ok, object_uri}, + {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri), + {:ok, message} <- get_local_message_from_uri(object_uri) do + Elektrine.Social.Messages.delete_federated_emoji_reaction( + message.id, + remote_actor.id, + emoji + ) + + {:ok, :emoji_unreacted} + else + error -> + Logger.warning("Failed to undo emoji react: #{inspect(error)}") + {:error, :undo_emoji_react_failed} + end + end + + def handle_undo_emoji_react(%{"object" => _object_ref, "tag" => tags} = object, actor_uri) + when is_list(tags) do + # Extract emoji from tag + emoji = + Enum.find_value(tags, fn + %{"type" => "Emoji", "name" => name} -> name + _ -> nil + end) + + if emoji do + handle_undo_emoji_react(Map.put(object, "content", emoji), actor_uri) + else + {:ok, :no_emoji_found} + end + end + + def handle_undo_emoji_react(_object, actor_uri) do + Logger.warning("Invalid Undo EmojiReact from #{actor_uri}") + {:ok, :invalid} + end + + # Private functions + + defp extract_target_object_uri(object) when is_binary(object), do: object + + defp extract_target_object_uri(%{"id" => id}) when is_binary(id), do: id + + defp extract_target_object_uri(%{"object" => object_ref}) do + extract_target_object_uri(object_ref) + end + + defp extract_target_object_uri(_), do: nil + + defp resolve_target_object_uri(object), do: extract_target_object_uri(object) + + defp extract_emoji_content(%{"content" => emoji}) when is_binary(emoji) and emoji != "", + do: emoji + + defp extract_emoji_content(%{"contentMap" => content_map}) when is_map(content_map) do + content_map + |> Map.values() + |> Enum.find(&(is_binary(&1) and &1 != "")) + end + + defp extract_emoji_content(%{"tag" => tags}) when is_list(tags) do + Enum.find_value(tags, fn + %{"type" => "Emoji", "name" => name} when is_binary(name) and name != "" -> name + _ -> nil + end) + end + + defp extract_emoji_content(_), do: nil + + defp get_local_message_from_uri(uri) do + base_url = ActivityPub.instance_url() + + cond do + # Format: https://domain/posts/{id} + String.starts_with?(uri, "#{base_url}/posts/") -> + id = String.replace_prefix(uri, "#{base_url}/posts/", "") + get_message_by_id(id) + + # Format: https://domain/users/{username}/posts/{id} + String.match?(uri, ~r{#{base_url}/users/[^/]+/posts/}) -> + id = uri |> String.split("/posts/") |> List.last() + get_message_by_id(id) + + # Check by canonical ID or URL variant. + true -> + case Messaging.get_message_by_activitypub_ref(uri) do + nil -> {:error, :message_not_found} + message -> {:ok, message} + end + end + end + + defp get_message_by_id(id) do + case Messaging.get_message(parse_message_id(id)) do + nil -> {:error, :message_not_found} + message -> {:ok, message} + end + rescue + Ecto.Query.CastError -> {:error, :message_not_found} + end + + defp parse_message_id(id) when is_integer(id), do: id + + defp parse_message_id(id) when is_binary(id) do + case Integer.parse(id) do + {parsed, ""} -> parsed + _ -> id + end + end + + defp parse_message_id(id), do: id + + # For remote EmojiReact races, try to fetch/cache the target object if we don't + # already have it locally. + defp get_or_fetch_message_from_uri(uri) do + case get_local_message_from_uri(uri) do + {:ok, message} -> + {:ok, message} + + {:error, :message_not_found} -> + with {:ok, object, author_uri} <- fetch_reactable_object(uri), + :ok <- cache_reactable_object(object, author_uri), + {:ok, message} <- get_local_message_from_uri(object["id"] || uri) do + {:ok, message} + else + _ -> {:error, :message_not_found} + end + end + end + + defp fetch_reactable_object(uri) do + with {:ok, object} <- RemoteFetch.fetch_object(uri) do + normalize_reactable_object(object) + end + end + + # Handles Note/Page/Article directly and Create wrappers with embedded/linked object. + defp normalize_reactable_object(%{"type" => type} = object) + when type in ["Note", "Page", "Article"] do + case normalize_actor_uri(object["attributedTo"]) do + actor_uri when is_binary(actor_uri) -> {:ok, object, actor_uri} + _ -> {:error, :missing_author} + end + end + + defp normalize_reactable_object(%{"type" => "Create"} = object) do + creator_uri = normalize_actor_uri(object["actor"]) + + case object["object"] do + %{} = inner_object -> + inner_with_actor = + if creator_uri do + Map.put_new(inner_object, "attributedTo", creator_uri) + else + inner_object + end + |> inherit_wrapper_fields(object) + + normalize_reactable_object(inner_with_actor) + + inner_uri when is_binary(inner_uri) -> + with {:ok, fetched_inner} <- RemoteFetch.fetch_object(inner_uri) do + inner_with_actor = + if creator_uri do + Map.put_new(fetched_inner, "attributedTo", creator_uri) + else + fetched_inner + end + |> inherit_wrapper_fields(object) + + normalize_reactable_object(inner_with_actor) + end + + _ -> + {:error, :unsupported_object} + end + end + + defp normalize_reactable_object(_), do: {:error, :unsupported_object} + + defp cache_reactable_object(object, author_uri) do + case CreateHandler.create_note(object, author_uri) do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp fetch_remote_actor(actor_uri, error_reason) do + case ActivityPub.get_or_fetch_actor(actor_uri) do + {:ok, actor} -> + {:ok, actor} + + {:error, reason} -> + Logger.warning("Failed to fetch remote actor #{actor_uri}: #{inspect(reason)}") + {:error, error_reason} + end + end + + defp inherit_wrapper_fields(object, activity) when is_map(object) and is_map(activity) do + ["to", "cc", "audience", "published"] + |> Enum.reduce(object, fn field, acc -> + case {Map.get(acc, field), Map.get(activity, field)} do + {value, inherited} when value in [nil, ""] and not is_nil(inherited) -> + Map.put(acc, field, inherited) + + {[], inherited} when not is_nil(inherited) -> + Map.put(acc, field, inherited) + + _ -> + acc + end + end) + end + + defp inherit_wrapper_fields(object, _activity), do: object + + defp normalize_actor_uri(uri) when is_binary(uri), do: uri + defp normalize_actor_uri([head | _]), do: normalize_actor_uri(head) + defp normalize_actor_uri(%{"id" => id}) when is_binary(id), do: id + defp normalize_actor_uri(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/move_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/move_handler.ex new file mode 100644 index 0000000..a7cf2d6 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/handlers/move_handler.ex @@ -0,0 +1,168 @@ +defmodule Elektrine.ActivityPub.Handlers.MoveHandler do + @moduledoc """ + Handles ActivityPub Move activities for account migration. + """ + + import Ecto.Query, warn: false + require Logger + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor + alias Elektrine.Profiles + alias Elektrine.Profiles.Follow + alias Elektrine.Repo + + @doc """ + Handles an incoming Move activity. + """ + def handle( + %{"actor" => actor_uri, "object" => object, "target" => target}, + _actor_uri, + _target_user + ) do + with {:ok, old_actor_uri} <- extract_uri(object), + {:ok, new_actor_uri} <- extract_uri(target), + :ok <- validate_actor_match(actor_uri, old_actor_uri), + {:ok, old_actor} <- fetch_move_actor(old_actor_uri), + {:ok, new_actor} <- fetch_move_actor(new_actor_uri), + :ok <- validate_move_aliases(old_actor, new_actor, new_actor_uri) do + migrate_local_remote_follows(old_actor.id, new_actor.id) + mark_actor_moved(old_actor, new_actor_uri) + {:ok, :moved} + else + {:error, :move_actor_fetch_failed} -> + {:error, :move_actor_fetch_failed} + + {:error, reason} -> + Logger.warning("Ignoring Move activity: #{inspect(reason)}") + {:ok, :ignored} + end + end + + def handle(_activity, _actor_uri, _target_user), do: {:ok, :unhandled} + + defp extract_uri(uri) when is_binary(uri) do + trimmed = String.trim(uri) + + if trimmed == "" do + {:error, :invalid_uri} + else + {:ok, trimmed} + end + end + + defp extract_uri(%{"id" => uri}) when is_binary(uri), do: extract_uri(uri) + defp extract_uri(%{id: uri}) when is_binary(uri), do: extract_uri(uri) + defp extract_uri(_), do: {:error, :invalid_uri} + + defp validate_actor_match(actor_uri, object_uri) + when is_binary(actor_uri) and is_binary(object_uri) do + if normalize_uri(actor_uri) == normalize_uri(object_uri) do + :ok + else + {:error, :actor_object_mismatch} + end + end + + defp validate_actor_match(_, _), do: {:error, :actor_object_mismatch} + + defp normalize_uri(uri) when is_binary(uri), + do: + uri + |> String.trim() + |> String.split("#", parts: 2) + |> hd() + |> String.split("?", parts: 2) + |> hd() + |> String.trim_trailing("/") + + defp fetch_move_actor(uri) when is_binary(uri) do + case ActivityPub.get_or_fetch_actor(uri) do + {:ok, actor} -> + {:ok, actor} + + {:error, reason} -> + Logger.warning("Failed to fetch Move actor #{uri}: #{inspect(reason)}") + {:error, :move_actor_fetch_failed} + end + end + + defp validate_move_aliases(%Actor{} = old_actor, %Actor{} = new_actor, new_actor_uri) do + moved_to_uris = + old_actor.metadata + |> extract_uri_candidates("movedTo") + |> Enum.map(&normalize_uri/1) + + known_aliases = + new_actor.metadata + |> extract_uri_candidates("alsoKnownAs") + |> Enum.map(&normalize_uri/1) + + cond do + normalize_uri(new_actor_uri) not in moved_to_uris -> + {:error, :moved_to_mismatch} + + normalize_uri(old_actor.uri) not in known_aliases -> + {:error, :missing_alias} + + true -> + :ok + end + end + + defp extract_uri_candidates(metadata, field) when is_map(metadata) do + metadata + |> Map.get(field) + |> expand_uri_candidates() + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp extract_uri_candidates(_metadata, _field), do: [] + + defp expand_uri_candidates(value) when is_binary(value), do: [value] + + defp expand_uri_candidates(values) when is_list(values), + do: Enum.flat_map(values, &expand_uri_candidates/1) + + defp expand_uri_candidates(%{"id" => id}) when is_binary(id), do: [id] + defp expand_uri_candidates(%{"href" => href}) when is_binary(href), do: [href] + defp expand_uri_candidates(%{"url" => url}) when is_binary(url), do: [url] + defp expand_uri_candidates(_), do: [] + + defp migrate_local_remote_follows(old_actor_id, new_actor_id) + when is_integer(old_actor_id) and is_integer(new_actor_id) do + follows = + Follow + |> where( + [f], + f.remote_actor_id == ^old_actor_id and not is_nil(f.follower_id) and is_nil(f.followed_id) + ) + |> Repo.all() + + Enum.each(follows, fn follow -> + if Profiles.get_follow_to_remote_actor(follow.follower_id, new_actor_id) do + Repo.delete(follow) + else + follow + |> Ecto.Changeset.change(remote_actor_id: new_actor_id) + |> Repo.update() + end + end) + end + + defp migrate_local_remote_follows(_, _), do: :ok + + defp mark_actor_moved(%Actor{} = old_actor, new_actor_uri) do + metadata = + (old_actor.metadata || %{}) + |> Map.put("movedTo", new_actor_uri) + + old_actor + |> Actor.changeset(%{ + metadata: metadata, + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.update() + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/update_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/update_handler.ex new file mode 100644 index 0000000..6548a79 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/handlers/update_handler.ex @@ -0,0 +1,413 @@ +defmodule Elektrine.ActivityPub.Handlers.UpdateHandler do + @moduledoc """ + Handles Update ActivityPub activities for actors and objects. + """ + + require Logger + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Handlers.CreateHandler + alias Elektrine.Messaging + alias Elektrine.Repo + alias Elektrine.Social.Message + + @remote_media_metadata_keys [ + "alt_texts", + "original_like_count", + "original_reply_count", + "original_share_count", + "inReplyTo", + "inReplyToAuthor", + "inReplyToContent", + "external_link", + "community_actor_uri", + "emoji_reactions", + "quotes_count", + "quote", + "quote_id", + "quote_url", + "card", + "application", + "language", + "media_attachments", + "pleroma", + "misskey" + ] + @history_key "activitypub_edit_history" + @history_limit 20 + + @doc """ + Handles an incoming Update activity. + """ + def handle(%{"object" => object} = activity, actor_uri, _target_user) when is_map(object) do + object = inherit_wrapper_fields(activity, object) + + case object["type"] do + "Person" -> + refresh_actor(actor_uri, object["id"], :profile_updated) + + "Service" -> + refresh_actor(actor_uri, object["id"], :profile_updated) + + "Group" -> + refresh_actor(actor_uri, object["id"], :group_updated) + + type when type in ["Note", "Article", "Page", "Question"] -> + update_note(object, actor_uri) + + _other_type -> + {:ok, :unhandled} + end + end + + def handle(%{"object" => object_uri} = activity, actor_uri, target_user) + when is_binary(object_uri) do + case Elektrine.ActivityPub.RemoteFetch.fetch_object(object_uri) do + {:ok, object} when is_map(object) -> + handle( + %{activity | "object" => inherit_wrapper_fields(activity, object)}, + actor_uri, + target_user + ) + + {:ok, _object} -> + Logger.warning( + "Failed to fetch Update object #{object_uri}: referenced object was not a map" + ) + + {:error, :update_object_fetch_failed} + + {:error, reason} -> + Logger.warning("Failed to fetch Update object #{object_uri}: #{inspect(reason)}") + {:error, :update_object_fetch_failed} + end + end + + def handle(_activity, _actor_uri, _target_user) do + {:ok, :unhandled} + end + + defp refresh_actor(actor_uri, object_id, success_result) do + with :ok <- validate_actor_object_match(actor_uri, object_id), + {:ok, _actor} <- ActivityPub.fetch_and_cache_actor(actor_uri) do + {:ok, success_result} + else + {:error, :actor_mismatch} -> + Logger.warning( + "Rejecting Update for #{inspect(object_id)}: object id does not match verified actor #{actor_uri}" + ) + + {:ok, :unauthorized} + + {:error, reason} -> + Logger.warning("Failed to refresh actor #{actor_uri}: #{inspect(reason)}") + {:error, :update_actor_fetch_failed} + end + end + + defp update_note(object, actor_uri) do + case Messaging.get_message_by_activitypub_ref(object["id"]) do + nil -> + import_unknown_update_object(object, actor_uri) + + message -> + with :ok <- CreateHandler.validate_object_author(object, actor_uri), + {:ok, remote_actor} <- fetch_message_actor(actor_uri), + :ok <- authorize_message_update(message, remote_actor), + {:ok, _updated_message} <- persist_message_update(message, object, actor_uri) do + {:ok, :updated} + else + {:error, :actor_mismatch} -> + Logger.warning( + "Rejecting Update for #{inspect(object["id"])}: object attributedTo does not match verified actor #{actor_uri}" + ) + + {:ok, :unauthorized} + + {:error, :unauthorized} -> + Logger.warning( + "Rejecting Update for #{inspect(object["id"])} from non-owner #{actor_uri}" + ) + + {:ok, :unauthorized} + + {:error, :update_actor_fetch_failed} -> + {:error, :update_actor_fetch_failed} + + {:error, :update_failed} -> + {:error, :update_failed} + end + end + end + + defp import_unknown_update_object(%{"type" => "Question"} = object, actor_uri) do + case CreateHandler.create_question(object, actor_uri) do + {:ok, message} when is_struct(message) -> {:ok, :created_from_update} + {:ok, result} -> {:ok, result} + {:error, :actor_mismatch} -> {:error, :unauthorized} + {:error, reason} -> {:error, reason} + end + end + + defp import_unknown_update_object(object, actor_uri) do + case CreateHandler.create_note(object, actor_uri) do + {:ok, message} when is_struct(message) -> {:ok, :created_from_update} + {:ok, result} -> {:ok, result} + {:error, :actor_mismatch} -> {:error, :unauthorized} + {:error, reason} -> {:error, reason} + end + end + + defp fetch_message_actor(actor_uri) do + case ActivityPub.get_or_fetch_actor(actor_uri) do + {:ok, remote_actor} -> + {:ok, remote_actor} + + {:error, reason} -> + Logger.warning("Failed to fetch Update actor #{actor_uri}: #{inspect(reason)}") + {:error, :update_actor_fetch_failed} + end + end + + defp authorize_message_update(message, remote_actor) do + if message.remote_actor_id == remote_actor.id do + :ok + else + {:error, :unauthorized} + end + end + + defp persist_message_update(message, %{"type" => "Question"} = object, actor_uri) do + payload = CreateHandler.build_federated_question_payload(object, actor_uri) + + attrs = + payload.attrs + |> Map.take([ + :content, + :visibility, + :activitypub_url, + :media_urls, + :post_type, + :like_count, + :reply_count, + :share_count, + :quote_count, + :sensitive, + :content_warning, + :extracted_hashtags + ]) + |> Map.put( + :media_metadata, + message.media_metadata + |> merge_remote_media_metadata(payload.attrs.media_metadata) + |> append_activitypub_edit_history(message, object, actor_uri) + ) + + message + |> Message.federated_changeset(attrs) + |> Ecto.Changeset.put_change(:edited_at, DateTime.utc_now() |> DateTime.truncate(:second)) + |> Repo.update() + |> case do + {:ok, updated_message} -> + CreateHandler.sync_message_hashtags(updated_message.id, payload.hashtags) + + case CreateHandler.upsert_federated_poll(updated_message.id, object, payload.options) do + {:ok, _poll} -> + {:ok, updated_message} + + {:error, reason} -> + Logger.warning( + "Updated federated message #{updated_message.id} but failed to sync poll data: #{inspect(reason)}" + ) + + {:error, :update_failed} + end + + {:error, changeset} -> + log_update_failure(message.id, changeset) + {:error, :update_failed} + end + end + + defp persist_message_update(message, object, actor_uri) do + payload = CreateHandler.build_federated_message_payload(object, actor_uri) + + attrs = + payload.attrs + |> Map.take([ + :content, + :title, + :visibility, + :activitypub_url, + :primary_url, + :media_urls, + :reply_to_id, + :quoted_message_id, + :like_count, + :reply_count, + :share_count, + :quote_count, + :sensitive, + :content_warning, + :extracted_hashtags + ]) + |> Map.put( + :media_metadata, + message.media_metadata + |> merge_remote_media_metadata(payload.attrs.media_metadata) + |> append_activitypub_edit_history(message, object, actor_uri) + ) + |> maybe_put_primary_url_from_metadata(message) + + message + |> Message.federated_changeset(attrs) + |> Ecto.Changeset.put_change(:edited_at, DateTime.utc_now() |> DateTime.truncate(:second)) + |> Repo.update() + |> case do + {:ok, updated_message} -> + CreateHandler.sync_message_hashtags(updated_message.id, payload.hashtags) + {:ok, updated_message} + + {:error, changeset} -> + log_update_failure(message.id, changeset) + {:error, :update_failed} + end + end + + defp merge_remote_media_metadata(existing_metadata, fresh_metadata) do + existing_metadata = + case existing_metadata do + metadata when is_map(metadata) -> metadata + _ -> %{} + end + + fresh_metadata = + case fresh_metadata do + metadata when is_map(metadata) -> metadata + _ -> %{} + end + + existing_metadata + |> Map.drop(@remote_media_metadata_keys) + |> Map.merge(fresh_metadata) + end + + defp append_activitypub_edit_history(metadata, message, object, actor_uri) do + previous = + %{ + "content" => message.content, + "title" => message.title, + "summary" => message.content_warning, + "sensitive" => message.sensitive, + "visibility" => message.visibility, + "media_urls" => message.media_urls || [], + "activitypub_id" => message.activitypub_id, + "activitypub_url" => message.activitypub_url, + "edited_at" => format_datetime(message.edited_at || message.updated_at), + "seen_update_id" => object["id"], + "seen_update_actor" => actor_uri, + "seen_update_at" => format_datetime(DateTime.utc_now()) + } + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + + history = + metadata + |> Map.get(@history_key, []) + |> case do + values when is_list(values) -> values + _ -> [] + end + + metadata + |> maybe_put_former_representations(object) + |> Map.put(@history_key, Enum.take([previous | history], @history_limit)) + end + + defp maybe_put_former_representations(metadata, %{"formerRepresentations" => history}) + when is_map(history) do + Map.put(metadata, "formerRepresentations", trim_former_representations(history)) + end + + defp maybe_put_former_representations(metadata, _object), do: metadata + + defp trim_former_representations(%{"orderedItems" => items} = history) when is_list(items) do + Map.put(history, "orderedItems", Enum.take(items, @history_limit)) + end + + defp trim_former_representations(%{"items" => items} = history) when is_list(items) do + Map.put(history, "items", Enum.take(items, @history_limit)) + end + + defp trim_former_representations(history), do: history + + defp format_datetime(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime) + + defp format_datetime(%NaiveDateTime{} = datetime), + do: datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601() + + defp format_datetime(_), do: nil + + defp maybe_put_primary_url_from_metadata(attrs, message) when is_map(attrs) do + cond do + Elektrine.Strings.present?(attrs[:primary_url]) -> + attrs + + Elektrine.Strings.present?(message.primary_url) -> + attrs + + true -> + case get_in(attrs[:media_metadata] || %{}, ["external_link"]) do + url when is_binary(url) and url != "" -> Map.put(attrs, :primary_url, url) + _ -> attrs + end + end + end + + defp log_update_failure(message_id, changeset) do + Logger.warning( + "Failed to update federated message #{message_id}: #{inspect(changeset.errors)}" + ) + end + + defp validate_actor_object_match(actor_uri, nil) when is_binary(actor_uri), do: :ok + + defp validate_actor_object_match(actor_uri, object_id) + when is_binary(actor_uri) and is_binary(object_id) do + if normalize_uri(actor_uri) == normalize_uri(object_id) do + :ok + else + {:error, :actor_mismatch} + end + end + + defp validate_actor_object_match(_actor_uri, _object_id), do: {:error, :actor_mismatch} + + defp normalize_uri(uri) when is_binary(uri) do + uri + |> String.trim() + |> String.split("#", parts: 2) + |> hd() + |> String.split("?", parts: 2) + |> hd() + |> String.trim_trailing("/") + end + + defp inherit_wrapper_fields(activity, object) when is_map(activity) and is_map(object) do + ["to", "cc", "audience", "published"] + |> Enum.reduce(object, fn field, acc -> + case {Map.get(acc, field), Map.get(activity, field)} do + {value, inherited} when value in [nil, ""] and not is_nil(inherited) -> + Map.put(acc, field, inherited) + + {[], inherited} when not is_nil(inherited) -> + Map.put(acc, field, inherited) + + _ -> + acc + end + end) + end + + defp inherit_wrapper_fields(_activity, object), do: object +end diff --git a/apps/elektrine/lib/elektrine/activitypub/helpers.ex b/apps/elektrine/lib/elektrine/activitypub/helpers.ex new file mode 100644 index 0000000..618b5fc --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/helpers.ex @@ -0,0 +1,503 @@ +defmodule Elektrine.ActivityPub.Helpers do + @moduledoc """ + Shared helper functions for ActivityPub data extraction and formatting. + """ + + import Ecto.Query + + @doc """ + Extracts totalItems from an ActivityPub collection or returns integer directly. + + Handles multiple formats: + - `%{"totalItems" => count}` - collection object + - `count` - direct integer + - `nil` or other - returns 0 + """ + def get_collection_total(collection) when is_map(collection) do + parse_count(collection["totalItems"]) + end + + def get_collection_total(count) when is_integer(count), do: count + def get_collection_total(count) when is_binary(count), do: parse_count(count) + def get_collection_total(_), do: 0 + + @doc """ + Extracts interaction count (likes, replies, shares) from an ActivityPub object. + + Handles multiple formats that different platforms use: + - `object["likes"]["totalItems"]` - collection format + - `object["likes"]` = integer - direct count + - `object["likesCount"]` - alternative field name + """ + def extract_interaction_count(object, type) when is_map(object) do + # Try multiple formats that different platforms use + cond do + # Format 1: Direct collection object with totalItems + is_map(object[type]) && is_integer(object[type]["totalItems"]) -> + object[type]["totalItems"] + + # Format 1b: Collection with string totalItems + is_map(object[type]) && is_binary(object[type]["totalItems"]) -> + parse_count(object[type]["totalItems"]) + + # Format 2: Direct integer count + is_integer(object[type]) -> + object[type] + + # Format 2b: Direct numeric string count + is_binary(object[type]) -> + parse_count(object[type]) + + # Format 3: Alternative field name (e.g., likesCount instead of likes) + is_integer(object["#{type}Count"]) -> + object["#{type}Count"] + + is_binary(object["#{type}Count"]) -> + parse_count(object["#{type}Count"]) + + # Format 4: Akkoma/Pleroma style - like_count, announcement_count, repliesCount + type == "likes" && is_integer(object["like_count"]) -> + object["like_count"] + + type == "likes" && is_binary(object["like_count"]) -> + parse_count(object["like_count"]) + + type == "shares" && is_integer(object["announcement_count"]) -> + object["announcement_count"] + + type == "shares" && is_binary(object["announcement_count"]) -> + parse_count(object["announcement_count"]) + + type == "replies" && is_integer(object["repliesCount"]) -> + object["repliesCount"] + + type == "replies" && is_binary(object["repliesCount"]) -> + parse_count(object["repliesCount"]) + + # Format 4b: Some servers expose comments instead of replies + type == "replies" && is_integer(object["comments"]) -> + object["comments"] + + type == "replies" && is_binary(object["comments"]) -> + parse_count(object["comments"]) + + type == "replies" && is_map(object["comments"]) -> + get_collection_total(object["comments"]) + + # Format 5: Mastodon API style field names + type == "likes" && is_integer(object["favourites_count"]) -> + object["favourites_count"] + + type == "likes" && is_binary(object["favourites_count"]) -> + parse_count(object["favourites_count"]) + + type == "shares" && is_integer(object["reblogs_count"]) -> + object["reblogs_count"] + + type == "shares" && is_binary(object["reblogs_count"]) -> + parse_count(object["reblogs_count"]) + + type == "replies" && is_integer(object["replies_count"]) -> + object["replies_count"] + + type == "replies" && is_binary(object["replies_count"]) -> + parse_count(object["replies_count"]) + + # Default to 0 + true -> + 0 + end + end + + def extract_interaction_count(_, _), do: 0 + + @doc """ + Extracts vote totals commonly exposed by Lemmy/PieFed/Mbin style objects. + + Returns a map with `:upvotes`, `:downvotes`, and `:score` integers. + """ + def extract_vote_totals(object) when is_map(object) do + %{ + upvotes: + parse_count(object["upvotes"] || object["likes_count"] || object["positive_votes"]), + downvotes: + parse_count(object["downvotes"] || object["dislikes_count"] || object["negative_votes"]), + score: parse_signed_count(object["score"] || object["net_score"]) + } + end + + def extract_vote_totals(_), do: %{upvotes: 0, downvotes: 0, score: 0} + + @doc """ + Extracts follower count from actor metadata. + + Handles various ActivityPub formats: + - `metadata["followers_count"]` - Mastodon extension + - `metadata["followersCount"]` - alternative format + - `metadata["followers"]["totalItems"]` - collection format + """ + def get_follower_count(metadata) when is_map(metadata) do + [ + parse_count(metadata["followers_count"]), + parse_count(metadata["followersCount"]), + parse_count(metadata["subscriber_count"]), + parse_count(metadata["subscribers_count"]), + parse_count(metadata["subscribersCount"]), + parse_count(metadata["subscribers"]), + parse_count(metadata["member_count"]), + get_collection_total(metadata["followers"]), + get_collection_total(metadata["subscribers"]) + ] + |> Enum.max(fn -> 0 end) + end + + def get_follower_count(_), do: 0 + + @doc """ + Extracts following count from actor metadata. + """ + def get_following_count(metadata) when is_map(metadata) do + [ + parse_count(metadata["following_count"]), + parse_count(metadata["followingCount"]), + get_collection_total(metadata["following"]) + ] + |> Enum.max(fn -> 0 end) + end + + def get_following_count(_), do: 0 + + @doc """ + Extracts post/status count from actor metadata. + """ + def get_status_count(metadata) when is_map(metadata) do + [ + parse_count(metadata["statuses_count"]), + parse_count(metadata["statusesCount"]), + parse_count(metadata["posts_count"]), + parse_count(metadata["postsCount"]), + parse_count(metadata["post_count"]), + parse_count(metadata["published_count"]), + get_collection_total(metadata["outbox"]) + ] + |> Enum.max(fn -> 0 end) + end + + def get_status_count(_), do: 0 + + @doc """ + Formats an ActivityPub ISO8601 date string to human-readable relative time. + """ + def format_activitypub_date(date_string) when is_binary(date_string) do + case DateTime.from_iso8601(date_string) do + {:ok, datetime, _} -> + Elektrine.Social.time_ago_in_words(datetime) + + _ -> + date_string + end + end + + def format_activitypub_date(_), do: "" + + @doc """ + Formats a join/published date to human-readable format. + """ + def format_join_date(%DateTime{} = datetime) do + Calendar.strftime(datetime, "%B %d, %Y") + end + + def format_join_date(%NaiveDateTime{} = ndt) do + ndt + |> DateTime.from_naive!("Etc/UTC") + |> format_join_date() + end + + def format_join_date(_), do: "Unknown" + + @doc """ + Parses an ActivityPub published date string to DateTime. + Returns a truncated DateTime suitable for database storage. + """ + def parse_published_date(date_string) when is_binary(date_string) do + case DateTime.from_iso8601(date_string) do + {:ok, datetime, _} -> DateTime.truncate(datetime, :second) + _ -> DateTime.utc_now() |> DateTime.truncate(:second) + end + end + + def parse_published_date(_), do: DateTime.utc_now() |> DateTime.truncate(:second) + + @doc """ + Loads interaction state (likes/boosts) for ActivityPub posts for a given user. + + Takes a list of ActivityPub post objects (maps with "id" field) and returns + a map of activitypub_id => %{liked: bool, boosted: bool, like_delta: 0, boost_delta: 0} + """ + def load_post_interactions(posts, user_id) when is_list(posts) do + activitypub_ids = Enum.map(posts, & &1["id"]) |> Enum.filter(& &1) + + if Enum.empty?(activitypub_ids) do + %{} + else + # Find messages that exist locally + local_messages = Elektrine.Messaging.get_messages_by_activitypub_ids(activitypub_ids) + message_id_map = Map.new(local_messages, fn msg -> {msg.activitypub_id, msg.id} end) + message_ids = Enum.map(local_messages, & &1.id) + + # Get liked message IDs + liked_ids = + if Enum.empty?(message_ids) do + MapSet.new() + else + from(l in Elektrine.Social.PostLike, + where: l.user_id == ^user_id and l.message_id in ^message_ids, + select: l.message_id + ) + |> Elektrine.Repo.all() + |> MapSet.new() + end + + # Get boosted message IDs + boosted_ids = + if Enum.empty?(message_ids) do + MapSet.new() + else + from(b in Elektrine.Social.PostBoost, + where: b.user_id == ^user_id and b.message_id in ^message_ids, + select: b.message_id + ) + |> Elektrine.Repo.all() + |> MapSet.new() + end + + # Get user's votes (upvote/downvote) - maps message_id to vote_type ("up" or "down") + user_votes = + if Enum.empty?(message_ids) do + %{} + else + from(v in Elektrine.Social.MessageVote, + where: v.user_id == ^user_id and v.message_id in ^message_ids, + select: {v.message_id, v.vote_type} + ) + |> Elektrine.Repo.all() + |> Map.new() + end + + # Build result map + Map.new(activitypub_ids, fn activitypub_id -> + case Map.get(message_id_map, activitypub_id) do + nil -> + {activitypub_id, + %{ + liked: false, + boosted: false, + like_delta: 0, + boost_delta: 0, + vote: nil, + vote_delta: 0 + }} + + message_id -> + {activitypub_id, + %{ + liked: MapSet.member?(liked_ids, message_id), + boosted: MapSet.member?(boosted_ids, message_id), + like_delta: 0, + boost_delta: 0, + vote: Map.get(user_votes, message_id), + vote_delta: 0 + }} + end + end) + end + end + + def load_post_interactions(_, _), do: %{} + + @doc """ + Extracts username from an ActivityPub actor URI. + + Handles various URI patterns: + - `/u/username` - Lemmy format + - `/users/username` - Mastodon format + - `/@username` - Mastodon alt format + """ + def extract_username_from_uri(uri) when is_binary(uri) do + cond do + String.contains?(uri, "/u/") -> + uri |> String.split("/u/") |> List.last() |> String.split("/") |> List.first() + + String.contains?(uri, "/users/") -> + uri |> String.split("/users/") |> List.last() |> String.split("/") |> List.first() + + String.contains?(uri, "/@") -> + uri |> String.split("/@") |> List.last() |> String.split("/") |> List.first() + + true -> + uri |> URI.parse() |> Map.get(:path, "") |> String.split("/") |> List.last() + end + end + + def extract_username_from_uri(_), do: "unknown" + + @doc """ + Gets or stores a remote post locally so we can interact with it. + + If the post exists locally, returns it. Otherwise fetches from the remote + server and stores it using the ActivityPub handler. + + Two variants: + - `get_or_store_remote_post(activitypub_id)` - extracts actor_uri from fetched post + - `get_or_store_remote_post(activitypub_id, actor_uri)` - uses provided actor_uri + """ + def get_or_store_remote_post(activitypub_id) do + case cached_remote_post(activitypub_id) do + nil -> + case Elektrine.ActivityPub.RemoteFetch.fetch_object(activitypub_id) do + {:ok, post_object} -> + actor_uri = Elektrine.ActivityPub.Normalizer.actor_uri(post_object) + store_or_resolve_remote_post(post_object, actor_uri) + + {:error, reason} -> + {:error, reason} + end + + message -> + {:ok, message} + end + end + + def get_or_store_remote_post(activitypub_id, actor_uri, opts \\ []) do + case cached_remote_post(activitypub_id) do + nil -> + case Elektrine.ActivityPub.RemoteFetch.fetch_object(activitypub_id) do + {:ok, post_object} -> + preferred_actor_uri = + extracted_object_actor_uri(post_object) || actor_uri + + post_object + |> tag_ancestor_depth(opts) + |> store_or_resolve_remote_post(preferred_actor_uri) + + {:error, reason} -> + {:error, reason} + end + + message -> + {:ok, message} + end + end + + # Carries the ingestion-time ancestor-fetch depth into the stored object so + # CreateHandler can bound how far the "fetch missing parent" recursion walks + # up a reply chain (a hostile server can otherwise serve an endless + # inReplyTo chain and drive unbounded fetches). + defp tag_ancestor_depth(post_object, opts) when is_map(post_object) do + case Keyword.get(opts, :ancestor_depth) do + depth when is_integer(depth) -> Map.put(post_object, "_elektrine_ancestor_depth", depth) + _ -> post_object + end + end + + defp tag_ancestor_depth(post_object, _opts), do: post_object + + defp extracted_object_actor_uri(%{} = post_object) do + Elektrine.ActivityPub.Normalizer.actor_uri(post_object) + end + + defp extracted_object_actor_uri(_), do: nil + + defp cached_remote_post(activitypub_id) when is_binary(activitypub_id) do + Elektrine.Messaging.get_message_by_activitypub_ref(activitypub_id) + end + + defp cached_remote_post(activitypub_id) do + Elektrine.Messaging.get_message_by_activitypub_id(activitypub_id) + end + + defp store_or_resolve_remote_post(post_object, actor_uri) do + case store_remote_post_safely(post_object, actor_uri) do + {:ok, %Elektrine.Social.Message{} = message} -> + {:ok, message} + + {:ok, status} -> + case resolve_cached_remote_post(post_object) do + {:ok, %Elektrine.Social.Message{} = message} -> {:ok, message} + :error -> {:error, status} + end + + {:error, reason} -> + {:error, reason} + end + end + + defp store_remote_post_safely(post_object, actor_uri) do + Elektrine.ActivityPub.Handler.store_remote_post(post_object, actor_uri) + rescue + error in Postgrex.Error -> + if unique_activitypub_violation?(error) do + {:ok, :already_exists} + else + reraise error, __STACKTRACE__ + end + end + + defp unique_activitypub_violation?(%Postgrex.Error{postgres: postgres}) when is_map(postgres) do + postgres[:code] in [:unique_violation, "unique_violation", "23505"] && + to_string(postgres[:constraint] || "") == "messages_activitypub_id_index" + end + + defp unique_activitypub_violation?(_), do: false + + defp resolve_cached_remote_post(post_object) when is_map(post_object) do + refs = + [post_object["id"], post_object["url"]] + |> Enum.flat_map(&expand_activitypub_refs/1) + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + + case Enum.find_value(refs, &cached_remote_post/1) do + %Elektrine.Social.Message{} = message -> {:ok, message} + _ -> :error + end + end + + defp resolve_cached_remote_post(_), do: :error + + defp expand_activitypub_refs(value) when is_binary(value), do: [value] + + defp expand_activitypub_refs(values) when is_list(values), + do: Enum.flat_map(values, &expand_activitypub_refs/1) + + defp expand_activitypub_refs(%{"id" => id}), do: expand_activitypub_refs(id) + defp expand_activitypub_refs(%{"href" => href}), do: expand_activitypub_refs(href) + defp expand_activitypub_refs(%{"url" => url}), do: expand_activitypub_refs(url) + defp expand_activitypub_refs(_), do: [] + + defp parse_count(value) when is_integer(value), do: max(value, 0) + + defp parse_count(value) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {count, ""} -> max(count, 0) + _ -> 0 + end + end + + defp parse_count(_), do: 0 + + defp parse_signed_count(value) when is_integer(value), do: value + + defp parse_signed_count(value) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {count, ""} -> count + _ -> 0 + end + end + + defp parse_signed_count(_), do: 0 + + # Check if a post is from a Lemmy instance (has /post/ in activitypub_id) +end diff --git a/apps/elektrine/lib/elektrine/activitypub/http_signature.ex b/apps/elektrine/lib/elektrine/activitypub/http_signature.ex new file mode 100644 index 0000000..700eb0b --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/http_signature.ex @@ -0,0 +1,158 @@ +defmodule Elektrine.ActivityPub.HTTPSignature do + @moduledoc """ + Signs outbound ActivityPub requests with HTTP Signatures. + + Signing only. Inbound verification lives in + `Elektrine.ActivityPub.SignatureVerification`, which is the single verifier + for every entrance — do not add a second one here. + """ + require Logger + + @doc "Signs an HTTP GET request for authorized fetch mode.\nReturns headers to add to the request.\n" + def sign_get(url, private_key_pem, key_id) do + uri = URI.parse(url) + date = Calendar.strftime(DateTime.utc_now(), "%a, %d %b %Y %H:%M:%S GMT") + host = host_header_value(uri) + + path_with_query = + if uri.query do + "#{uri.path || "/"}?#{uri.query}" + else + uri.path || "/" + end + + headers_to_sign = ["(request-target)", "host", "date"] + + signing_string_parts = [ + "(request-target): get #{path_with_query}", + "host: #{host}", + "date: #{date}" + ] + + signing_string = Enum.join(signing_string_parts, "\n") + signature = sign_string(signing_string, private_key_pem) + headers_string = Enum.join(headers_to_sign, " ") + + signature_header = + [ + ~s(keyId="#{key_id}"), + ~s(algorithm="hs2019"), + ~s(headers="#{headers_string}"), + ~s(signature="#{signature}") + ] + |> Enum.join(",") + + [{"host", host}, {"date", date}, {"signature", signature_header}] + end + + @doc "Signs an HTTP POST request for outgoing federation.\nReturns headers to add to the request.\n" + def sign(url, body, private_key_pem, key_id) do + uri = URI.parse(url) + date = Calendar.strftime(DateTime.utc_now(), "%a, %d %b %Y %H:%M:%S GMT") + host = host_header_value(uri) + + digest = + if body do + "SHA-256=#{:crypto.hash(:sha256, body) |> Base.encode64()}" + else + nil + end + + headers_to_sign = + if digest do + ["(request-target)", "host", "date", "digest"] + else + ["(request-target)", "host", "date"] + end + + path_with_query = + if uri.query do + "#{uri.path || "/"}?#{uri.query}" + else + uri.path || "/" + end + + signing_string_parts = [ + "(request-target): post #{path_with_query}", + "host: #{host}", + "date: #{date}" + ] + + signing_string_parts = + if digest do + signing_string_parts ++ ["digest: #{digest}"] + else + signing_string_parts + end + + signing_string = Enum.join(signing_string_parts, "\n") + signature = sign_string(signing_string, private_key_pem) + headers_string = Enum.join(headers_to_sign, " ") + + signature_header = + [ + ~s(keyId="#{key_id}"), + ~s(algorithm="hs2019"), + ~s(headers="#{headers_string}"), + ~s(signature="#{signature}") + ] + |> Enum.join(",") + + base_headers = [{"host", host}, {"date", date}, {"signature", signature_header}] + + if digest do + base_headers ++ [{"digest", digest}] + else + base_headers + end + end + + defp sign_string(string, private_key_pem) do + case decode_private_key(private_key_pem) do + {:ok, private_key} -> + signature = :public_key.sign(string, :sha256, private_key) + Base.encode64(signature) + + {:error, _} -> + raise "Failed to decode private key" + end + end + + defp decode_private_key(pem) do + [entry] = :public_key.pem_decode(pem) + private_key = :public_key.pem_entry_decode(entry) + {:ok, private_key} + rescue + e -> + Logger.error("Failed to decode private key: #{inspect(e)}") + {:error, :invalid_key} + end + + defp host_header_value(%URI{host: host, port: port, scheme: scheme}) when is_binary(host) do + if is_nil(port) or default_port?(scheme, port) do + host + else + "#{host}:#{port}" + end + end + + defp default_port?("http", 80), do: true + defp default_port?("https", 443), do: true + defp default_port?(_, _), do: false + + @doc "Generates a new RSA key pair for a user.\nReturns {public_key_pem, private_key_pem}.\n\nPublic key is generated in PKCS#8 format for ActivityPub compatibility.\n" + def generate_key_pair do + private_key = :public_key.generate_key({:rsa, 2048, 65_537}) + + {:RSAPrivateKey, _, modulus, exponent, _, _, _, _, _, _, _} = private_key + public_key = {:RSAPublicKey, modulus, exponent} + + private_pem = + :public_key.pem_encode([:public_key.pem_entry_encode(:RSAPrivateKey, private_key)]) + + public_pem = + :public_key.pem_encode([:public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key)]) + + {public_pem, private_pem} + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/inbound_delivery_policy.ex b/apps/elektrine/lib/elektrine/activitypub/inbound_delivery_policy.ex new file mode 100644 index 0000000..31ab098 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/inbound_delivery_policy.ex @@ -0,0 +1,223 @@ +defmodule Elektrine.ActivityPub.InboundDeliveryPolicy do + @moduledoc """ + Decides whether an inbound content activity is addressed to anything local. + + Remote servers can post any activity to any inbox. Without this gate a peer + can push arbitrary `Create`/`Announce` traffic into our storage by aiming it + at an inbox it has no relationship with, so a signed activity still has to + name a local audience: a targeted user or community, a thread we already + know, an actor somebody here follows, or an active relay. + + This lives in `apps/elektrine` rather than beside the inbox controller + because the signature retry queue has to apply the same rule. It is the only + place the rule exists, and both entry points call `permit/4`. + """ + + alias Elektrine.ActivityPub + alias Elektrine.Messaging + alias Elektrine.Profiles + + @doc """ + Returns `:ok` when the activity may be processed, or + `{:error, :not_addressed_to_local_audience}`. + + Activities that do not distribute content are always permitted; they are + filtered on their own terms further down the pipeline. + """ + def permit(activity, actor_uri, target_user, target_community) do + if content_distribution_activity?(activity) do + cond do + local_user_targeted?(activity, target_user) -> :ok + local_community_targeted?(activity, target_community) -> :ok + references_known_message?(activity) -> :ok + locally_followed_actor?(actor_uri) -> :ok + ActivityPub.active_relay_actor_uri?(actor_uri) -> :ok + true -> {:error, :not_addressed_to_local_audience} + end + else + :ok + end + end + + @doc """ + Normalizes a URI for comparison: lowercased scheme and host, no trailing + slash, no fragment. + """ + def comparable_uri(uri) when is_binary(uri) do + uri + |> String.trim() + |> case do + "" -> + nil + + trimmed -> + case URI.parse(trimmed) do + %URI{scheme: scheme, host: host} = parsed + when is_binary(scheme) and is_binary(host) and host != "" -> + normalized_path = + parsed.path + |> Kernel.||("/") + |> case do + "/" -> "/" + path -> String.trim_trailing(path, "/") + end + + parsed + |> Map.put(:scheme, String.downcase(scheme)) + |> Map.put(:host, String.downcase(host)) + |> Map.put(:path, normalized_path) + |> Map.put(:fragment, nil) + |> URI.to_string() + + _ -> + trimmed + end + end + end + + def comparable_uri(_), do: nil + + defp content_distribution_activity?(%{"type" => type}) when type in ["Create", "Announce"], + do: true + + defp content_distribution_activity?(_), do: false + + defp local_user_targeted?(activity, nil) do + activity + |> activity_recipient_refs() + |> Enum.any?(&local_user_ref?/1) + end + + defp local_user_targeted?(activity, user) do + target_uris = user_delivery_uris(user) |> Enum.map(&comparable_uri/1) + + activity + |> activity_recipient_refs() + |> Enum.map(&comparable_uri/1) + |> Enum.any?(&(&1 in target_uris)) + end + + defp local_community_targeted?(_activity, nil), do: false + + defp local_community_targeted?(activity, community) do + target_uris = community_delivery_uris(community) |> Enum.map(&comparable_uri/1) + + activity + |> activity_recipient_refs() + |> Enum.map(&comparable_uri/1) + |> Enum.any?(&(&1 in target_uris)) + end + + defp references_known_message?(activity) do + activity + |> activity_object_refs() + |> Enum.any?(&known_message_ref?/1) + end + + defp locally_followed_actor?(actor_uri) when is_binary(actor_uri) do + case ActivityPub.get_actor_by_uri(actor_uri) do + %ActivityPub.Actor{id: actor_id} -> Profiles.any_local_following_remote_actor?(actor_id) + _ -> false + end + end + + defp locally_followed_actor?(_), do: false + + defp activity_recipient_refs(activity) when is_map(activity) do + object = if is_map(activity["object"]), do: activity["object"], else: %{} + + [ + activity["to"], + activity["cc"], + activity["bto"], + activity["bcc"], + activity["audience"], + activity["target"], + object["to"], + object["cc"], + object["bto"], + object["bcc"], + object["audience"], + object["target"], + object["context"], + mention_hrefs(object) + ] + |> Enum.flat_map(&expand_uri_candidates/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp activity_recipient_refs(_), do: [] + + defp activity_object_refs(activity) when is_map(activity) do + object = Map.get(activity, "object") + + [ + object, + object_ref_field(object, "id"), + object_ref_field(object, "url"), + object_ref_field(object, "href"), + object_ref_field(object, "inReplyTo") + ] + |> Enum.flat_map(&expand_uri_candidates/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp activity_object_refs(_), do: [] + + defp object_ref_field(object, field) when is_map(object), do: Map.get(object, field) + defp object_ref_field(_, _), do: nil + + defp mention_hrefs(%{"tag" => tags}) when is_list(tags) do + tags + |> Enum.filter(&(Map.get(&1, "type") == "Mention")) + |> Enum.map(&Map.get(&1, "href")) + end + + defp mention_hrefs(_), do: [] + + defp expand_uri_candidates(value) when is_binary(value), do: [value] + + defp expand_uri_candidates(values) when is_list(values), + do: Enum.flat_map(values, &expand_uri_candidates/1) + + defp expand_uri_candidates(%{"id" => id}) when is_binary(id), do: [id] + defp expand_uri_candidates(%{"href" => href}) when is_binary(href), do: [href] + defp expand_uri_candidates(%{"url" => url}) when is_binary(url), do: [url] + defp expand_uri_candidates(_), do: [] + + defp local_user_ref?(ref) when is_binary(ref) do + case ActivityPub.local_username_from_uri(ref) do + {:ok, _username} -> true + _ -> false + end + end + + defp local_user_ref?(_), do: false + + defp known_message_ref?(ref) when is_binary(ref) do + match?(%{}, Messaging.get_message_by_activitypub_ref(ref)) + end + + defp known_message_ref?(_), do: false + + defp user_delivery_uris(user) do + for identifier <- ActivityPub.actor_identifiers(user), + uri <- [ + ActivityPub.actor_uri(identifier), + ActivityPub.user_collection_uri(identifier, "followers") + ], + is_binary(uri), + do: uri + end + + defp community_delivery_uris(community) do + [ + ActivityPub.community_actor_uri(community.name), + ActivityPub.community_followers_uri(community.name), + Map.get(community, :activitypub_id) + ] + |> Enum.filter(&is_binary/1) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/inbox_queue.ex b/apps/elektrine/lib/elektrine/activitypub/inbox_queue.ex new file mode 100644 index 0000000..1bb985e --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/inbox_queue.ex @@ -0,0 +1,292 @@ +defmodule Elektrine.ActivityPub.InboxQueue do + @moduledoc "Fast in-memory queue for incoming ActivityPub activities.\n\nInstead of calling Oban.insert() for every /inbox request (which requires\na database connection), activities are queued in ETS and batch-inserted\ninto Oban periodically. This removes the database from the /inbox hot path.\n\nPerformance improvement: ~1000ms → ~5ms per /inbox request\n" + use GenServer + require Logger + alias Elektrine.Telemetry.Events + @table_name :inbox_activity_queue + @flush_interval 500 + @max_batch_size 25 + @insert_chunk_size 5 + @max_queue_size 5000 + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc "Enqueue an activity for processing. Returns immediately without hitting the database.\n" + def enqueue(activity, actor_uri, target_user_id) do + activity_id = activity["id"] + activity_type = activity["type"] || "unknown" + + cond do + activity_id && already_queued?(activity_id) -> + Events.federation(:inbox_queue, :enqueue, :duplicate, nil, %{activity_type: activity_type}) + + {:ok, :duplicate} + + overload_drop?(activity) -> + Events.federation(:inbox_queue, :enqueue, :shed, nil, %{ + activity_type: activity_type, + actor_domain: actor_domain(actor_uri) + }) + + {:ok, :shed} + + test_mode_bypass?() -> + item = %{ + activity: activity, + actor_uri: actor_uri, + target_user_id: target_user_id, + activity_id: activity_id, + queued_at: System.system_time(:millisecond) + } + + case Elektrine.JobQueue.insert(build_job(item)) do + {:ok, _job} -> + {:ok, :queued} + + {:error, reason} -> + {:error, reason} + end + + true -> + if activity_id do + mark_queued(activity_id) + end + + item = %{ + activity: activity, + actor_uri: actor_uri, + target_user_id: target_user_id, + activity_id: activity_id, + queued_at: System.system_time(:millisecond) + } + + :ets.insert(@table_name, {make_ref(), item}) + + Events.federation(:inbox_queue, :enqueue, :queued, nil, %{ + activity_type: activity_type, + has_target_user: not is_nil(target_user_id), + actor_domain: actor_domain(actor_uri) + }) + + {:ok, :queued} + end + end + + @impl true + def init(_opts) do + :ets.new(@table_name, [:named_table, :public, :set, write_concurrency: true]) + :ets.new(:inbox_dedup, [:named_table, :public, :set, write_concurrency: true]) + schedule_flush() + Logger.info("Startup: inbox queue ready (flush_interval_ms=#{@flush_interval})") + {:ok, %{}} + end + + @impl true + def handle_info(:flush, state) do + flush_queue() + schedule_flush() + {:noreply, state} + end + + defp schedule_flush do + Process.send_after(self(), :flush, @flush_interval) + end + + defp flush_queue do + items = take_batch(@max_batch_size) + + if items != [] do + flush_started_at = System.monotonic_time(:millisecond) + flush_result = insert_in_chunks(items) + flush_duration = System.monotonic_time(:millisecond) - flush_started_at + + case flush_result do + {:ok, inserted_count} -> + Logger.debug("InboxQueue flushed #{inserted_count} activities") + + Events.federation(:inbox_queue, :flush, :success, flush_duration, %{ + batch_size: inserted_count + }) + + {:error, reason, inserted_count, failed_items} -> + Logger.error( + "InboxQueue flush failed after #{inserted_count} inserts: #{inspect(reason)}" + ) + + Events.federation(:inbox_queue, :flush, :failure, flush_duration, %{ + batch_size: length(items), + inserted_count: inserted_count, + requeued_count: length(failed_items), + reason: inspect(reason) + }) + + Enum.each(failed_items, fn item -> :ets.insert(@table_name, {make_ref(), item}) end) + end + + cleanup_dedup() + end + end + + defp take_batch(limit) when is_integer(limit) and limit > 0 do + do_take_batch(limit, :ets.first(@table_name), []) + end + + defp take_batch(_) do + [] + end + + defp do_take_batch(0, _key, acc) do + Enum.reverse(acc) + end + + defp do_take_batch(_remaining, :"$end_of_table", acc) do + Enum.reverse(acc) + end + + defp do_take_batch(remaining, key, acc) do + next_key = :ets.next(@table_name, key) + + case :ets.take(@table_name, key) do + [{^key, item}] -> do_take_batch(remaining - 1, next_key, [item | acc]) + [] -> do_take_batch(remaining, next_key, acc) + end + end + + defp insert_in_chunks(items) when is_list(items) do + chunks = Enum.chunk_every(items, @insert_chunk_size) + do_insert_chunks(chunks, 0) + end + + defp do_insert_chunks([], inserted_count) do + {:ok, inserted_count} + end + + defp do_insert_chunks([chunk | rest], inserted_count) do + jobs = Enum.map(chunk, &build_job/1) + + case safe_insert_all(jobs) do + {:ok, inserted} -> + do_insert_chunks(rest, inserted_count + length(inserted)) + + {:error, reason} -> + failed_items = List.flatten([chunk | rest]) + {:error, reason, inserted_count, failed_items} + end + end + + defp safe_insert_all(jobs) do + {:ok, Elektrine.JobQueue.insert_all(jobs)} + rescue + e -> {:error, e} + end + + defp build_job(item) do + priority = activity_priority(item.activity) + + %{ + "activity" => compact_activity(item.activity), + "actor_uri" => item.actor_uri, + "activity_id" => item.activity_id, + "target_user_id" => item.target_user_id + } + |> Elektrine.ActivityPub.ProcessActivityWorker.new(priority: priority) + end + + defp activity_priority(%{"type" => type} = activity) do + case type do + "Create" -> 0 + "Update" -> 0 + "Delete" -> 0 + "Follow" -> 1 + "Accept" -> 1 + "Reject" -> 1 + "Undo" -> 1 + "Block" -> 1 + "Like" -> 2 + "Dislike" -> 2 + "EmojiReact" -> 2 + "Announce" -> announce_priority(activity) + _ -> 1 + end + end + + defp announce_priority(%{"object" => object}) when is_map(object) do + case object["type"] do + type when type in ["Note", "Page", "Article", "Create", "Update", "Delete"] -> 0 + type when type in ["Like", "Dislike", "EmojiReact"] -> 2 + _ -> 1 + end + end + + defp announce_priority(_) do + 1 + end + + defp actor_domain(nil) do + "unknown" + end + + defp actor_domain(actor_uri) when is_binary(actor_uri) do + case URI.parse(actor_uri) do + %URI{host: host} when is_binary(host) and host != "" -> host + _ -> "unknown" + end + end + + defp actor_domain(_) do + "unknown" + end + + defp test_mode_bypass? do + not Elektrine.Async.async_enabled?() + end + + defp overload_drop?(activity) do + queue_size = + case :ets.whereis(@table_name) do + :undefined -> 0 + _ -> :ets.info(@table_name, :size) || 0 + end + + queue_size >= @max_queue_size and low_priority_activity?(activity) + end + + defp low_priority_activity?(%{"type" => type}) when type in ["Like", "Dislike", "EmojiReact"] do + true + end + + defp low_priority_activity?(%{"type" => "Undo", "object" => %{"type" => object_type}}) + when object_type in ["Like", "Dislike", "EmojiReact"] do + true + end + + defp low_priority_activity?(_) do + false + end + + defp compact_activity(activity), do: activity + + defp already_queued?(activity_id) do + case :ets.lookup(:inbox_dedup, activity_id) do + [{^activity_id, timestamp}] -> System.system_time(:second) - timestamp < 60 + _ -> false + end + end + + defp mark_queued(activity_id) do + :ets.insert(:inbox_dedup, {activity_id, System.system_time(:second)}) + end + + defp cleanup_dedup do + if :rand.uniform(100) == 1 do + cutoff = System.system_time(:second) - 120 + + try do + :ets.select_delete(:inbox_dedup, [{{:_, :"$1"}, [{:<, :"$1", cutoff}], [true]}]) + rescue + _ -> :ok + end + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/inbox_rate_limiter.ex b/apps/elektrine/lib/elektrine/activitypub/inbox_rate_limiter.ex new file mode 100644 index 0000000..95c18ff --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/inbox_rate_limiter.ex @@ -0,0 +1,144 @@ +defmodule Elektrine.ActivityPub.InboxRateLimiter do + @moduledoc """ + Simple rate limiter for ActivityPub inbox to prevent resource exhaustion. + Uses ETS for fast in-memory tracking. + """ + + use GenServer + require Logger + + # Default limits can be overridden at runtime via: + # config :elektrine, Elektrine.ActivityPub.InboxRateLimiter, ... + # Federation bursts are normal for relays, busy instances, and backfills. + @default_max_per_minute 120 + @default_max_per_domain_per_minute 300 + @default_max_global_per_second 30 + # Cleanup interval + @cleanup_interval 60_000 + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc """ + Check if request should be allowed. + Returns {:ok, :allowed} or {:error, :rate_limited} + """ + def check_rate_limit(ip_address, actor_domain \\ nil) do + now = System.system_time(:second) + minute_bucket = div(now, 60) + + max_global_per_second = + configured_limit(:max_global_per_second, @default_max_global_per_second) + + max_per_domain_per_minute = + configured_limit(:max_per_domain_per_minute, @default_max_per_domain_per_minute) + + max_per_minute = configured_limit(:max_per_minute, @default_max_per_minute) + + # Check global rate first + global_count = + :ets.update_counter(:inbox_rate_limit, {:global, now}, {2, 1}, {{:global, now}, 0}) + + cond do + global_count > max_global_per_second -> + {:error, :rate_limited} + + # Check per-domain rate (most important - prevents one noisy instance from flooding) + actor_domain && domain_over_limit?(actor_domain, minute_bucket, max_per_domain_per_minute) -> + {:error, :rate_limited} + + # Check per-IP rate + ip_over_limit?(ip_address, minute_bucket, max_per_minute) -> + {:error, :rate_limited} + + true -> + {:ok, :allowed} + end + end + + def check_domain_rate_limit(nil), do: {:ok, :allowed} + + def check_domain_rate_limit(actor_domain) do + minute_bucket = div(System.system_time(:second), 60) + + max_per_domain_per_minute = + configured_limit(:max_per_domain_per_minute, @default_max_per_domain_per_minute) + + if domain_over_limit?(actor_domain, minute_bucket, max_per_domain_per_minute) do + {:error, :rate_limited} + else + {:ok, :allowed} + end + end + + defp domain_over_limit?(domain, minute_bucket, max_per_domain_per_minute) do + count = + :ets.update_counter( + :inbox_rate_limit, + {:domain, domain, minute_bucket}, + {2, 1}, + {{:domain, domain, minute_bucket}, 0} + ) + + count > max_per_domain_per_minute + end + + defp ip_over_limit?(ip_address, minute_bucket, max_per_minute) do + count = + :ets.update_counter( + :inbox_rate_limit, + {ip_address, minute_bucket}, + {2, 1}, + {{ip_address, minute_bucket}, 0} + ) + + count > max_per_minute + end + + @impl true + def init(_opts) do + # Create ETS table for rate limiting + :ets.new(:inbox_rate_limit, [:named_table, :public, :set, {:write_concurrency, true}]) + + # Schedule cleanup + schedule_cleanup() + + Logger.info("Startup: inbox rate limiter ready") + {:ok, %{}} + end + + @impl true + def handle_info(:cleanup, state) do + # Clean up old entries + now = System.system_time(:second) + current_minute = div(now, 60) + + # Delete entries older than 2 minutes (IP entries) + :ets.select_delete(:inbox_rate_limit, [ + {{{:"$1", :"$2"}, :_}, [{:is_integer, :"$2"}, {:<, :"$2", current_minute - 2}], [true]}, + # Clean up old global entries + {{{:global, :"$1"}, :_}, [{:<, :"$1", now - 5}], [true]}, + # Clean up old domain entries + {{{:domain, :_, :"$1"}, :_}, [{:is_integer, :"$1"}, {:<, :"$1", current_minute - 2}], + [true]} + ]) + + schedule_cleanup() + {:noreply, state} + end + + defp schedule_cleanup do + Process.send_after(self(), :cleanup, @cleanup_interval) + end + + defp configured_limit(key, default) do + :elektrine + |> Application.get_env(__MODULE__, []) + |> Keyword.get(key, default) + |> normalize_limit(default) + end + + defp normalize_limit(value, _default) when is_integer(value) and value > 0, do: value + defp normalize_limit(_, default), do: default +end diff --git a/apps/elektrine/lib/elektrine/activitypub/incoming_activity_worker.ex b/apps/elektrine/lib/elektrine/activitypub/incoming_activity_worker.ex new file mode 100644 index 0000000..38761aa --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/incoming_activity_worker.ex @@ -0,0 +1,160 @@ +defmodule Elektrine.ActivityPub.IncomingActivityWorker do + @moduledoc "Background worker for processing incoming ActivityPub activities.\n\nActivities are saved to the database immediately when received, then this\nworker processes them asynchronously to avoid blocking the inbox endpoint.\n\nBenefits:\n- Inbox endpoint returns immediately (202 Accepted)\n- Remote HTTP calls don't block web requests\n- Failed activities can be retried\n- Backpressure handling via batch size limits\n" + use GenServer + require Logger + alias Elektrine.ActivityPub.Activity + alias Elektrine.ActivityPub.Handler + alias Elektrine.Repo + import Ecto.Query + @tick_interval 30_000 + @batch_size 10 + @max_attempts 2 + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc "Triggers immediate processing (useful after receiving new activities).\n" + def process_now do + GenServer.cast(__MODULE__, :process_now) + end + + @impl true + def init(_opts) do + schedule_tick() + {:ok, %{processing: false}} + end + + @impl true + def handle_info(:tick, state) do + state = + if state.processing do + state + else + try do + process_pending_activities() + rescue + e -> Logger.error("IncomingActivityWorker error: #{Exception.message(e)}") + end + + state + end + + schedule_tick() + {:noreply, state} + end + + @impl true + def handle_cast(:process_now, state) do + if !state.processing do + try do + process_pending_activities() + rescue + e -> Logger.error("IncomingActivityWorker error: #{Exception.message(e)}") + end + end + + {:noreply, state} + end + + defp schedule_tick do + Process.send_after(self(), :tick, @tick_interval) + end + + defp process_pending_activities do + activities = + from(a in Activity, + where: a.processed == false and a.local == false and a.process_attempts < @max_attempts, + order_by: [asc: a.inserted_at], + limit: ^@batch_size + ) + |> Repo.all() + + Enum.each(activities, fn activity -> + process_activity(activity) + Process.sleep(50) + end) + end + + defp process_activity(activity) do + do_process_activity(activity) + end + + defp do_process_activity(activity) do + activity + |> Activity.mark_processed_changeset(%{process_attempts: activity.process_attempts + 1}) + |> Repo.update() + + target_user = get_target_user(activity) + + result = + try do + Handler.process_activity_async(activity.data, activity.actor_uri, target_user) + rescue + e -> + Logger.error( + "Error processing activity #{activity.activity_id}: #{Exception.message(e)}" + ) + + {:error, Exception.message(e)} + catch + :exit, reason -> + Logger.error("Activity processing exited: #{inspect(reason)}") + {:error, "Process exited: #{inspect(reason)}"} + end + + case result do + {:ok, _} -> + activity + |> Activity.mark_processed_changeset(%{ + processed: true, + processed_at: DateTime.utc_now() |> DateTime.truncate(:second), + process_error: nil + }) + |> Repo.update() + + {:error, reason} -> + error_msg = + if is_binary(reason) do + reason + else + inspect(reason) + end + + retryable? = Handler.retryable_error?(reason) + + attrs = + if retryable? do + %{process_error: String.slice(error_msg, 0, 255)} + else + %{ + processed: true, + processed_at: DateTime.utc_now() |> DateTime.truncate(:second), + process_error: String.slice("discarded: " <> error_msg, 0, 255) + } + end + + activity + |> Activity.mark_processed_changeset(attrs) + |> Repo.update() + + if retryable? and activity.process_attempts >= @max_attempts do + Logger.warning( + "Activity #{activity.activity_id} failed after #{@max_attempts} attempts: #{error_msg}" + ) + end + end + end + + defp get_target_user(activity) do + case activity.object_id do + nil -> + nil + + object_id -> + case Elektrine.ActivityPub.local_username_from_uri(object_id) do + {:ok, username} -> Elektrine.Accounts.get_user_by_username(username) + _ -> nil + end + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/instance.ex b/apps/elektrine/lib/elektrine/activitypub/instance.ex new file mode 100644 index 0000000..4869991 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/instance.ex @@ -0,0 +1,356 @@ +defmodule Elektrine.ActivityPub.Instance do + @moduledoc """ + Represents a remote ActivityPub instance with MRF (Message Rewrite Facility) policies. + + ## Policy Types + + - `blocked` - Completely reject all activities from this instance (except deletes) + - `silenced` - Accept activities but don't show in public timelines (deprecated, use federated_timeline_removal) + - `media_removal` - Strip media attachments from posts + - `media_nsfw` - Mark all media as sensitive/NSFW + - `federated_timeline_removal` - Hide posts from federated timeline (but visible to followers) + - `followers_only` - Force all posts to be followers-only visibility + - `report_removal` - Reject Flag (report) activities + - `avatar_removal` - Strip avatar images from actor profiles + - `banner_removal` - Strip banner/header images from actor profiles + - `reject_deletes` - Reject Delete activities (prevents removing content) + + ## Wildcard Domains + + Domains can include wildcards: `*.example.com` matches all subdomains of example.com. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "activitypub_instances" do + field(:domain, :string) + field(:reason, :string) + field(:notes, :string) + field(:blocked_at, :utc_datetime) + field(:policy_applied_at, :utc_datetime) + + # Core blocking policies + field(:blocked, :boolean, default: false) + field(:silenced, :boolean, default: false) + + # Granular MRF policies + field(:media_removal, :boolean, default: false) + field(:media_nsfw, :boolean, default: false) + field(:federated_timeline_removal, :boolean, default: false) + field(:followers_only, :boolean, default: false) + field(:report_removal, :boolean, default: false) + field(:avatar_removal, :boolean, default: false) + field(:banner_removal, :boolean, default: false) + field(:reject_deletes, :boolean, default: false) + + # Reachability tracking - when non-nil, instance is considered unreachable + field(:unreachable_since, :utc_datetime) + # Count of consecutive failures (for exponential backoff) + field(:failure_count, :integer, default: 0) + + # Instance metadata (fetched via NodeInfo) + field(:nodeinfo, :map, default: %{}) + field(:favicon, :string) + field(:metadata_updated_at, :utc_datetime) + + belongs_to(:blocked_by, Elektrine.Accounts.User) + belongs_to(:policy_applied_by, Elektrine.Accounts.User) + + timestamps() + end + + @policy_fields [ + :blocked, + :silenced, + :media_removal, + :media_nsfw, + :federated_timeline_removal, + :followers_only, + :report_removal, + :avatar_removal, + :banner_removal, + :reject_deletes + ] + + @doc """ + Returns the list of policy field names. + """ + def policy_fields, do: @policy_fields + + @doc false + def changeset(instance, attrs) do + instance + |> cast(attrs, [ + :domain, + :reason, + :notes, + :blocked_at, + :policy_applied_at, + :blocked_by_id, + :policy_applied_by_id + | @policy_fields + ]) + |> normalize_domain() + |> validate_required([:domain]) + |> validate_domain() + |> unique_constraint(:domain, name: :activitypub_instances_domain_ci_unique) + |> unique_constraint(:domain, name: :activitypub_instances_domain_index) + end + + @doc """ + Creates a changeset for applying MRF policies to an instance. + """ + def policy_changeset(instance, attrs, admin_user_id) do + instance + |> cast(attrs, [:reason, :notes | @policy_fields]) + |> put_change(:policy_applied_at, DateTime.utc_now() |> DateTime.truncate(:second)) + |> put_change(:policy_applied_by_id, admin_user_id) + |> maybe_set_blocked_at(attrs) + end + + defp maybe_set_blocked_at(changeset, attrs) do + if attrs[:blocked] || attrs["blocked"] do + put_change(changeset, :blocked_at, DateTime.utc_now() |> DateTime.truncate(:second)) + else + changeset + end + end + + defp validate_domain(changeset) do + validate_change(changeset, :domain, fn :domain, domain -> + # Allow wildcards like *.example.com + domain = String.trim(domain) |> String.downcase() + + cond do + domain == "" -> + [domain: "cannot be blank"] + + String.contains?(domain, " ") -> + [domain: "cannot contain spaces"] + + String.starts_with?(domain, ".") && !String.starts_with?(domain, "*.") -> + [domain: "invalid format"] + + # Check for valid domain format (with optional wildcard prefix) + !Regex.match?( + ~r/^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/, + domain + ) -> + [domain: "invalid domain format"] + + true -> + [] + end + end) + end + + defp normalize_domain(changeset) do + update_change(changeset, :domain, fn domain -> + domain + |> String.trim() + |> String.downcase() + |> String.replace(~r/^https?:\/\//, "") + end) + end + + @doc """ + Checks if a given hostname matches this instance's domain pattern. + Supports wildcard patterns like *.example.com + """ + def matches_domain?(%__MODULE__{domain: pattern}, hostname) when is_binary(hostname) do + matches_domain_pattern?(pattern, hostname) + end + + @doc """ + Checks if a hostname matches a domain pattern. + """ + def matches_domain_pattern?(pattern, hostname) + when is_binary(pattern) and is_binary(hostname) do + pattern = String.downcase(pattern) + hostname = String.downcase(hostname) + + cond do + # Exact match + pattern == hostname -> + true + + # Wildcard pattern: *.example.com matches sub.example.com but not example.com + String.starts_with?(pattern, "*.") -> + suffix = String.replace_prefix(pattern, "*", "") + String.ends_with?(hostname, suffix) && hostname != String.replace_prefix(suffix, ".", "") + + true -> + false + end + end + + @doc """ + Returns a human-readable summary of active policies. + """ + def policy_summary(%__MODULE__{} = instance) do + @policy_fields + |> Enum.filter(fn field -> Map.get(instance, field, false) end) + |> Enum.map(&policy_label/1) + end + + defp policy_label(:blocked), do: "Blocked" + defp policy_label(:silenced), do: "Silenced" + defp policy_label(:media_removal), do: "Media Removed" + defp policy_label(:media_nsfw), do: "Media NSFW" + defp policy_label(:federated_timeline_removal), do: "FTL Removed" + defp policy_label(:followers_only), do: "Followers Only" + defp policy_label(:report_removal), do: "Reports Rejected" + defp policy_label(:avatar_removal), do: "Avatars Stripped" + defp policy_label(:banner_removal), do: "Banners Stripped" + defp policy_label(:reject_deletes), do: "Deletes Rejected" + + @doc """ + Checks if any policy is active for this instance. + """ + def has_any_policy?(%__MODULE__{} = instance) do + Enum.any?(@policy_fields, fn field -> Map.get(instance, field, false) end) + end + + @doc """ + Returns the severity level based on active policies. + """ + def severity_level(%__MODULE__{} = instance) do + cond do + instance.blocked -> :blocked + instance.silenced || instance.followers_only -> :restricted + instance.federated_timeline_removal -> :limited + has_any_policy?(instance) -> :modified + true -> :none + end + end + + # Reachability tracking functions + + @doc """ + Checks if an instance is currently considered reachable. + An instance is unreachable if unreachable_since is set and within the timeout period. + """ + def reachable?(%__MODULE__{unreachable_since: nil}), do: true + + def reachable?(%__MODULE__{unreachable_since: unreachable_since}) do + timeout_days = Application.get_env(:elektrine, :federation_reachability_timeout_days, 7) + cutoff = DateTime.add(DateTime.utc_now(), -timeout_days * 24 * 60 * 60, :second) + DateTime.compare(unreachable_since, cutoff) == :gt + end + + @doc """ + Creates a changeset to mark an instance as unreachable. + """ + def set_unreachable_changeset(instance) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + failure_count = (instance.failure_count || 0) + 1 + + instance + |> change(%{ + unreachable_since: instance.unreachable_since || now, + failure_count: failure_count + }) + end + + @doc """ + Creates a changeset to mark an instance as reachable (clears unreachable status). + """ + def set_reachable_changeset(instance) do + instance + |> change(%{ + unreachable_since: nil, + failure_count: 0 + }) + end + + @doc """ + Returns the backoff duration in seconds based on failure count. + Uses exponential backoff: 1min, 2min, 4min, 8min, ... up to 1 day max. + """ + def backoff_duration(%__MODULE__{failure_count: count}) when is_integer(count) and count > 0 do + # 1 minute + base = 60 + # 1 day + max = 86_400 + min((base * :math.pow(2, count - 1)) |> round(), max) + end + + def backoff_duration(_), do: 0 + + # NodeInfo / Metadata functions + + @doc """ + Creates a changeset for updating instance metadata (nodeinfo, favicon). + """ + def metadata_changeset(instance, attrs) do + instance + |> cast(attrs, [:nodeinfo, :favicon, :metadata_updated_at]) + end + + @doc """ + Checks if the instance metadata needs to be refreshed. + Returns true if never fetched or older than 24 hours. + """ + def needs_metadata_update?(%__MODULE__{metadata_updated_at: nil}), do: true + + def needs_metadata_update?(%__MODULE__{metadata_updated_at: updated_at}) do + # Refresh if older than 24 hours + cutoff = DateTime.add(DateTime.utc_now(), -24 * 60 * 60, :second) + DateTime.compare(updated_at, cutoff) == :lt + end + + def needs_metadata_update?(nil), do: true + + @doc """ + Returns the software name from nodeinfo, if available. + """ + def software_name(%__MODULE__{nodeinfo: %{"software" => %{"name" => name}}}) + when is_binary(name) do + String.downcase(name) + end + + def software_name(_), do: nil + + @doc """ + Returns the software version from nodeinfo, if available. + """ + def software_version(%__MODULE__{nodeinfo: %{"software" => %{"version" => version}}}) + when is_binary(version) do + version + end + + def software_version(_), do: nil + + @doc """ + Returns user statistics from nodeinfo, if available. + """ + def user_stats(%__MODULE__{nodeinfo: %{"usage" => %{"users" => users}}}) when is_map(users) do + %{ + total: users["total"], + active_month: users["activeMonth"], + active_half_year: users["activeHalfyear"] + } + end + + def user_stats(_), do: nil + + @doc """ + Returns local post count from nodeinfo, if available. + """ + def local_posts(%__MODULE__{nodeinfo: %{"usage" => %{"localPosts" => count}}}) + when is_integer(count) do + count + end + + def local_posts(_), do: nil + + @doc """ + Checks if the instance has open registrations. + """ + def open_registrations?(%__MODULE__{nodeinfo: %{"openRegistrations" => open}}) + when is_boolean(open) do + open + end + + def open_registrations?(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/activitypub/instances.ex b/apps/elektrine/lib/elektrine/activitypub/instances.ex new file mode 100644 index 0000000..09dad51 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/instances.ex @@ -0,0 +1,235 @@ +defmodule Elektrine.ActivityPub.Instances do + @moduledoc """ + Context for managing ActivityPub instance reachability and federation status. + + Tracks which remote instances are reachable and implements smart delivery + filtering to avoid wasting resources on dead instances. + """ + + import Ecto.Query + alias Elektrine.ActivityPub.Instance + alias Elektrine.ActivityPub.NodeInfoFetcherWorker + alias Elektrine.Repo + + @doc """ + Checks if a domain is reachable. + Returns true if no unreachable record exists or if the instance has recovered. + """ + def reachable?(domain) when is_binary(domain) do + domain = normalize_domain(domain) + + case get_instance(domain) do + nil -> true + instance -> Instance.reachable?(instance) + end + end + + @doc """ + Marks an instance as unreachable after a delivery failure. + Creates the instance record if it doesn't exist. + """ + def set_unreachable(domain) when is_binary(domain) do + domain = normalize_domain(domain) + + case get_or_create_instance(domain) do + {:ok, instance} -> + instance + |> Instance.set_unreachable_changeset() + |> Repo.update() + + error -> + error + end + end + + @doc """ + Marks an instance as reachable after a successful delivery. + Only updates if the instance record exists. + """ + def set_reachable(domain) when is_binary(domain) do + domain = normalize_domain(domain) + + case get_instance(domain) do + nil -> + {:ok, :no_record} + + instance -> + if instance.unreachable_since do + instance + |> Instance.set_reachable_changeset() + |> Repo.update() + else + {:ok, instance} + end + end + end + + @doc """ + Gets an instance record by domain. + """ + def get_instance(domain) when is_binary(domain) do + domain = normalize_domain(domain) + + Instance + |> where([i], i.domain == ^domain) + |> order_by([i], desc: i.metadata_updated_at, desc: i.updated_at, desc: i.id) + |> limit(1) + |> Repo.one() + end + + @doc """ + Gets or creates an instance record for a domain. + """ + def get_or_create_instance(domain) when is_binary(domain) do + domain = normalize_domain(domain) + + case get_instance(domain) do + nil -> + case upsert_instance(domain) do + {:ok, %Instance{id: id} = instance} when is_integer(id) -> + {:ok, instance} + + {:ok, _instance} -> + {:ok, get_instance(domain)} + + {:error, changeset} -> + if Keyword.has_key?(changeset.errors, :domain) do + {:ok, get_instance(domain)} + else + {:error, changeset} + end + end + + instance -> + {:ok, instance} + end + end + + defp upsert_instance(domain) do + %Instance{} + |> Instance.changeset(%{domain: domain}) + |> Repo.insert( + on_conflict: :nothing, + conflict_target: {:unsafe_fragment, "(lower(domain))"} + ) + end + + @doc """ + Checks if we should attempt delivery to a domain based on backoff. + """ + def should_retry?(domain) when is_binary(domain) do + domain = normalize_domain(domain) + + case get_instance(domain) do + nil -> + true + + %{unreachable_since: nil} -> + true + + instance -> + backoff_seconds = Instance.backoff_duration(instance) + backoff_until = DateTime.add(instance.unreachable_since, backoff_seconds, :second) + DateTime.compare(DateTime.utc_now(), backoff_until) == :gt + end + end + + @doc """ + Cleans up old unreachable records that have exceeded the timeout period. + Called periodically to prevent unbounded growth. + """ + def cleanup_old_records do + timeout_days = Application.get_env(:elektrine, :federation_reachability_timeout_days, 7) + # Keep records for 3x the timeout period before cleanup + cleanup_cutoff = DateTime.add(DateTime.utc_now(), -timeout_days * 3 * 24 * 60 * 60, :second) + + # Only clean up instances that have no MRF policies and are just reachability records + from(i in Instance, + where: not is_nil(i.unreachable_since), + where: i.unreachable_since < ^cleanup_cutoff, + where: i.blocked == false, + where: i.silenced == false, + where: i.media_removal == false, + where: i.media_nsfw == false, + where: i.federated_timeline_removal == false, + where: i.followers_only == false, + where: i.report_removal == false, + where: i.avatar_removal == false, + where: i.banner_removal == false, + where: i.reject_deletes == false + ) + |> Repo.delete_all() + end + + defp normalize_domain(domain) do + domain + |> String.trim() + |> String.downcase() + end + + # NodeInfo / Metadata functions + + @doc """ + Enqueues a background job to fetch nodeinfo for a domain. + Safe to call frequently - jobs are deduplicated and metadata is only + refreshed if older than 24 hours. + """ + def fetch_metadata(domain) when is_binary(domain) do + NodeInfoFetcherWorker.enqueue(domain) + end + + @doc """ + Enqueues a background job to fetch nodeinfo for a domain extracted from a URL. + """ + def fetch_metadata_from_url(url) when is_binary(url) do + NodeInfoFetcherWorker.enqueue_from_url(url) + end + + @doc """ + Gets instance with nodeinfo for a domain. + Optionally triggers a background fetch if metadata is stale. + """ + def get_instance_with_metadata(domain, opts \\ []) when is_binary(domain) do + domain = normalize_domain(domain) + instance = get_instance(domain) + + if opts[:fetch_if_stale] && Instance.needs_metadata_update?(instance) do + fetch_metadata(domain) + end + + instance + end + + @doc """ + Gets software name for a domain from stored nodeinfo. + Returns nil if not known. + """ + def get_software(domain) when is_binary(domain) do + domain = normalize_domain(domain) + + case get_instance(domain) do + %Instance{} = instance -> Instance.software_name(instance) + nil -> nil + end + end + + @doc """ + Gets software info for multiple domains in a single query. + Returns a map of domain => software_name. + """ + def get_software_batch(domains) when is_list(domains) do + domains = Enum.map(domains, &normalize_domain/1) |> Enum.uniq() + + Instance + |> where([i], i.domain in ^domains) + |> where([i], not is_nil(i.nodeinfo)) + |> select([i], {i.domain, i.nodeinfo}) + |> Repo.all() + |> Enum.reduce(%{}, fn {domain, nodeinfo}, acc -> + case get_in(nodeinfo, ["software", "name"]) do + name when is_binary(name) -> Map.put(acc, domain, String.downcase(name)) + _ -> acc + end + end) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/key_manager.ex b/apps/elektrine/lib/elektrine/activitypub/key_manager.ex new file mode 100644 index 0000000..10ebdc5 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/key_manager.ex @@ -0,0 +1,83 @@ +defmodule Elektrine.ActivityPub.KeyManager do + @moduledoc """ + Manages ActivityPub RSA keys for users and actors. + Generates keys lazily when first needed. + """ + + require Logger + + alias Elektrine.Accounts + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.HTTPSignature + alias Elektrine.Repo + + @doc """ + Ensures a user or actor has ActivityPub keys, generating them if needed. + Returns the entity with keys loaded. + + Accepts a User struct, Actor struct, or user ID. + """ + def ensure_user_has_keys(%ActivityPub.Actor{} = actor) do + private_key = ActivityPub.Actor.metadata_private_key(actor) + + if actor.public_key && private_key do + # Actor already has keys + {:ok, actor} + else + # Generate keys for actor (local actors like relay) + generate_keys_for_actor(actor) + end + end + + def ensure_user_has_keys(%Accounts.User{} = user) do + if user.activitypub_public_key && user.activitypub_private_key do + # Keys already exist + {:ok, user} + else + # Generate keys + generate_keys_for_user(user) + end + end + + def ensure_user_has_keys(user_id) when is_integer(user_id) do + user = Accounts.get_user!(user_id) + ensure_user_has_keys(user) + end + + defp generate_keys_for_user(user) do + Logger.info("Generating ActivityPub keys for user: #{user.username}") + + {public_key, private_key} = HTTPSignature.generate_key_pair() + + user + |> Ecto.Changeset.change(%{ + activitypub_public_key: public_key, + activitypub_private_key: private_key + }) + |> Repo.update() + rescue + e -> + Logger.error("Failed to generate keys for user #{user.username}: #{inspect(e)}") + {:error, :key_generation_failed} + end + + defp generate_keys_for_actor(actor) do + Logger.info("Generating ActivityPub keys for actor: #{actor.uri}") + + {public_key, private_key} = HTTPSignature.generate_key_pair() + + # Store private key in metadata, public key in dedicated field + updated_metadata = ActivityPub.Actor.put_metadata_private_key(actor.metadata, private_key) + + actor + |> Ecto.Changeset.change(%{ + public_key: public_key, + metadata: updated_metadata + }) + |> Repo.update() + rescue + e -> + Logger.error("Failed to generate keys for actor #{actor.uri}: #{inspect(e)}") + {:error, :key_generation_failed} + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/lemmy_api.ex b/apps/elektrine/lib/elektrine/activitypub/lemmy_api.ex new file mode 100644 index 0000000..874038d --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/lemmy_api.ex @@ -0,0 +1,721 @@ +defmodule Elektrine.ActivityPub.LemmyApi do + @moduledoc """ + Helper module for fetching data from Lemmy instances via their JSON API. + """ + + alias Elektrine.HTTP.SafeFetch + + @comment_page_size 100 + @comment_max_pages 50 + + @doc """ + Fetch post counts (upvotes, downvotes, score, comments) from a Lemmy instance. + Returns nil if the post is not from a Lemmy instance or if the fetch fails. + """ + def fetch_post_counts(post_url) when is_binary(post_url) do + case resolve_post_reference(post_url) do + {:ok, domain, post_id} -> + api_url = "https://#{domain}/api/v4/post?id=#{post_id}" + headers = [{"Accept", "application/json"}, {"User-Agent", "Elektrine/1.0"}] + + case safe_request_lemmy_api(:get, api_url, headers, nil, receive_timeout: 5_000) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, %{"post_view" => %{"counts" => counts}}} -> + %{ + upvotes: counts["upvotes"] || 0, + downvotes: counts["downvotes"] || 0, + score: counts["score"] || 0, + comments: counts["comments"] || 0 + } + + _ -> + fetch_post_counts_from_html(post_url) + end + + {:ok, %Finch.Response{status: _status}} -> + fetch_post_counts_from_html(post_url) + + {:error, _reason} -> + fetch_post_counts_from_html(post_url) + end + + :error -> + fetch_post_counts_from_html(post_url) + end + end + + def fetch_post_counts(_), do: nil + + @doc """ + Fetch member and post counts for a Lemmy community. + + Returns `%{members: count, posts: count}` on success, or `nil` if the + community is not found or the instance is not Lemmy-compatible. + """ + def fetch_community_counts(domain, community_name) + when is_binary(domain) and is_binary(community_name) do + api_url = "https://#{domain}/api/v4/community?name=#{URI.encode_www_form(community_name)}" + headers = [{"Accept", "application/json"}, {"User-Agent", "Elektrine/1.0"}] + + case safe_request_lemmy_api(:get, api_url, headers, nil, receive_timeout: 5_000) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, %{"community_view" => %{"counts" => counts}}} -> + %{ + members: parse_count(counts["subscribers"]), + posts: parse_count(counts["posts"]) + } + + _ -> + nil + end + + {:ok, %Finch.Response{status: _status}} -> + nil + + {:error, _reason} -> + nil + end + end + + def fetch_community_counts(_, _), do: nil + + @doc """ + Fetch counts for multiple Lemmy posts in parallel. + Returns a map of activitypub_id => counts. + Uses yield_many to avoid blocking on slow/failed requests. + """ + def fetch_posts_counts(posts) when is_list(posts) do + tasks = + posts + |> Enum.filter(&lemmy_post?/1) + |> Enum.map(fn post -> + Task.async(fn -> + activitypub_id = get_activitypub_id(post) + post_url = activitypub_id || get_activitypub_url(post) + counts = fetch_post_counts(post_url) + {activitypub_id, counts} + end) + end) + + # Use a slightly longer timeout for busy Lemmy instances. + results = Task.yield_many(tasks, timeout: 6_000) + + # Shutdown any tasks that didn't complete + Enum.each(results, fn {task, result} -> + if result == nil, do: Task.shutdown(task, :brutal_kill) + end) + + # Collect successful results + results + |> Enum.flat_map(fn + {_task, {:ok, {id, counts}}} when counts != nil -> [{id, counts}] + _ -> [] + end) + |> Map.new() + end + + @doc """ + Fetch comment counts for a Lemmy post. + Returns a map of comment_ap_id => %{score, upvotes, downvotes, child_count} + """ + def fetch_comment_counts(post_url) when is_binary(post_url) do + case resolve_post_reference(post_url) do + {:ok, domain, post_id} -> + domain + |> fetch_comment_counts_pages(post_id, 100, 1, %{}) + |> case do + %{} = counts when map_size(counts) > 0 -> counts + _ -> fetch_comment_counts_from_post_comments(post_url) + end + + :error -> + fetch_comment_counts_from_post_comments(post_url) + end + end + + def fetch_comment_counts(_), do: %{} + + defp fetch_comment_counts_pages(_domain, _post_id, _page_size, page, acc) when page > 10, + do: acc + + defp fetch_comment_counts_pages(domain, post_id, page_size, page, acc) do + api_url = + "https://#{domain}/api/v4/comment/list?post_id=#{post_id}&limit=#{page_size}&page=#{page}&max_depth=10" + + headers = [{"Accept", "application/json"}, {"User-Agent", "Elektrine/1.0"}] + + case safe_request_lemmy_api(:get, api_url, headers, nil, receive_timeout: 10_000) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, %{"comments" => comments}} when is_list(comments) -> + updated_acc = + Enum.reduce(comments, acc, fn comment_view, current_acc -> + ap_id = get_in(comment_view, ["comment", "ap_id"]) + counts = comment_view["counts"] || %{} + + if is_binary(ap_id) do + Map.put(current_acc, ap_id, %{ + score: counts["score"] || 0, + upvotes: counts["upvotes"] || 0, + downvotes: counts["downvotes"] || 0, + child_count: counts["child_count"] || 0 + }) + else + current_acc + end + end) + + cond do + comments == [] -> + updated_acc + + length(comments) < page_size -> + updated_acc + + true -> + fetch_comment_counts_pages(domain, post_id, page_size, page + 1, updated_acc) + end + + _ -> + acc + end + + _ -> + acc + end + end + + defp fetch_comment_counts_from_post_comments(post_url) when is_binary(post_url) do + post_url + |> fetch_post_comments(500) + |> Enum.reduce(%{}, fn + %{"id" => ap_id} = comment, acc when is_binary(ap_id) -> + Map.put(acc, ap_id, %{ + score: parse_count(comment["score"]), + upvotes: parse_count(comment["upvotes"]), + downvotes: parse_count(comment["downvotes"]), + child_count: extract_collection_total(comment["replies"]) + }) + + _, acc -> + acc + end) + end + + @doc """ + Fetch top comments for a Lemmy post with content. + Returns a list of comment maps with :author, :content, :score, :ap_id + Sorted by score (top comments first), limited to top 3. + """ + def fetch_top_comments(post_url, limit \\ 3) + + def fetch_top_comments(post_url, limit) when is_binary(post_url) do + case resolve_post_reference(post_url) do + {:ok, domain, post_id} -> + # Sort by Top to get highest scored comments, only get top-level (parent_id not set) + api_url = + "https://#{domain}/api/v4/comment/list?post_id=#{post_id}&sort=Top&limit=#{limit}&max_depth=1" + + headers = [{"Accept", "application/json"}, {"User-Agent", "Elektrine/1.0"}] + + case safe_request_lemmy_api(:get, api_url, headers, nil, receive_timeout: 5_000) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, %{"comments" => comments}} -> + comments + |> Enum.reject(&lemmy_comment_hidden?/1) + |> Enum.take(limit) + |> Enum.map(fn comment_view -> + comment = comment_view["comment"] || %{} + creator = comment_view["creator"] || %{} + counts = comment_view["counts"] || %{} + + %{ + ap_id: comment["ap_id"], + content: comment["content"] || "", + author: creator["name"] || creator["display_name"] || "unknown", + author_domain: extract_domain(creator["actor_id"]), + actor_id: creator["actor_id"], + author_avatar: creator["avatar"], + published: comment["published"], + score: counts["score"] || 0, + upvotes: counts["upvotes"] || 0, + child_count: counts["child_count"] || 0 + } + end) + + _ -> + [] + end + + {:ok, %Finch.Response{status: _status}} -> + [] + + {:error, _reason} -> + [] + end + + :error -> + [] + end + end + + def fetch_top_comments(_, _), do: [] + + defp lemmy_comment_hidden?(%{"comment" => comment}) when is_map(comment) do + comment["deleted"] == true or comment["removed"] == true or + not Elektrine.Strings.present?(comment["content"]) + end + + defp lemmy_comment_hidden?(_), do: true + + @doc """ + Fetch all available comments for a Lemmy post with content and parent refs. + Returns a list of ActivityPub-like comment maps suitable for local storage. + """ + def fetch_post_comments(post_url, limit \\ 100) + + def fetch_post_comments(post_url, limit) when is_binary(post_url) do + fetch_post_comments(post_url, limit, []) + end + + def fetch_post_comments(_, _), do: [] + + def fetch_post_comments(post_url, limit, opts) when is_binary(post_url) and is_list(opts) do + case resolve_post_reference(post_url) do + {:ok, domain, post_id} -> + fetch_post_comments_from_instance(domain, post_id, post_url, limit, opts) + + :error -> + [] + end + end + + def fetch_post_comments(_, _, _), do: [] + + @doc false + def fetch_post_comments_from_instance(domain, post_id, post_url, limit \\ 100, opts \\ []) + + def fetch_post_comments_from_instance(domain, post_id, post_url, limit, opts) + when is_binary(domain) and is_binary(post_id) and is_binary(post_url) and is_integer(limit) and + is_list(opts) and limit > 0 do + headers = [{"Accept", "application/json"}, {"User-Agent", "Elektrine/1.0"}] + requested_page_size = Keyword.get(opts, :page_size, @comment_page_size) + page_size = min(max(limit, 1), normalize_page_size(requested_page_size)) + + comment_api_url_templates(domain, post_id) + |> Enum.find_value([], fn api_url_template -> + comments = + fetch_post_comment_pages( + api_url_template, + headers, + page_size, + limit, + 1, + [], + opts + ) + + case lemmy_comment_views_to_ap(comments, post_url) do + [] -> nil + decoded_comments -> decoded_comments + end + end) + end + + def fetch_post_comments_from_instance(_, _, _, _, _), do: [] + + defp comment_api_url_templates(domain, post_id) do + [ + fn page_size, page -> + "https://#{domain}/api/v4/comment/list?post_id=#{post_id}&limit=#{page_size}&page=#{page}&max_depth=10" + end, + fn page_size, page -> + "https://#{domain}/api/v3/comment/list?post_id=#{post_id}&limit=#{page_size}&page=#{page}&max_depth=10" + end, + fn page_size, page -> + "https://#{domain}/api/v3/comment/list?post_id=#{post_id}&sort=Hot&limit=#{page_size}&page=#{page}&max_depth=10" + end, + fn page_size, page -> + "https://#{domain}/api/v3/comment/list?post_id=#{post_id}&type_=All&sort=Hot&limit=#{page_size}&page=#{page}&max_depth=10" + end + ] + end + + defp fetch_post_comment_pages( + _api_url_template, + _headers, + _page_size, + limit, + _page, + acc, + _opts + ) + when length(acc) >= limit do + Enum.take(acc, limit) + end + + defp fetch_post_comment_pages( + _api_url_template, + _headers, + _page_size, + limit, + page, + acc, + _opts + ) + when page > @comment_max_pages do + Enum.take(acc, limit) + end + + defp fetch_post_comment_pages(api_url_template, headers, page_size, limit, page, acc, opts) do + api_url = api_url_template.(page_size, page) + + case safe_request_lemmy_api( + :get, + api_url, + headers, + nil, + Keyword.merge([receive_timeout: 10_000], opts) + ) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case decode_lemmy_comment_views(body) do + {:ok, comments} -> + updated_acc = acc ++ comments + + cond do + comments == [] -> + Enum.take(updated_acc, limit) + + length(updated_acc) >= limit -> + Enum.take(updated_acc, limit) + + length(comments) < page_size -> + updated_acc + + true -> + fetch_post_comment_pages( + api_url_template, + headers, + page_size, + limit, + page + 1, + updated_acc, + opts + ) + end + + :error -> + Enum.take(acc, limit) + end + + _ -> + Enum.take(acc, limit) + end + end + + defp decode_lemmy_comment_views(body) when is_binary(body) do + case Jason.decode(body) do + {:ok, %{"comments" => comments}} when is_list(comments) -> {:ok, comments} + _ -> :error + end + end + + defp decode_lemmy_comment_views(_), do: :error + + defp lemmy_comment_views_to_ap(comments, post_url) when is_list(comments) do + comment_id_to_ap = + comments + |> Enum.reduce(%{}, fn comment_view, acc -> + comment = comment_view["comment"] || %{} + + case {comment["id"], comment["ap_id"]} do + {id, ap_id} when is_integer(id) and is_binary(ap_id) -> + Map.put(acc, Integer.to_string(id), ap_id) + + {id, ap_id} when is_binary(id) and is_binary(ap_id) -> + Map.put(acc, id, ap_id) + + _ -> + acc + end + end) + + comments + |> Enum.reject(&lemmy_comment_hidden?/1) + |> Enum.map(&lemmy_comment_to_ap(&1, comment_id_to_ap, post_url)) + |> Enum.reject(&is_nil/1) + end + + defp lemmy_comment_views_to_ap(_, _), do: [] + + defp normalize_page_size(value) when is_integer(value), + do: value |> max(1) |> min(@comment_page_size) + + defp normalize_page_size(_), do: @comment_page_size + + defp extract_domain(url), do: Elektrine.TextHelpers.extract_domain_from_url(url) + + defp lemmy_comment_to_ap(comment_view, comment_id_to_ap, post_url) when is_map(comment_view) do + comment = comment_view["comment"] || %{} + creator = comment_view["creator"] || %{} + counts = comment_view["counts"] || %{} + + ap_id = comment["ap_id"] + actor_id = creator["actor_id"] + + if is_binary(ap_id) && is_binary(actor_id) do + %{ + "id" => ap_id, + "type" => "Note", + "content" => comment["content"] || "", + "published" => comment["published"], + "attributedTo" => actor_id, + "inReplyTo" => parse_lemmy_parent_ref(comment, comment_id_to_ap, post_url), + "likes" => %{"totalItems" => counts["upvotes"] || 0}, + "replies" => %{"totalItems" => counts["child_count"] || 0}, + "shares" => %{"totalItems" => 0}, + "upvotes" => counts["upvotes"] || 0, + "downvotes" => counts["downvotes"] || 0, + "score" => counts["score"] || 0, + "_lemmy" => %{ + "creator_name" => creator["name"], + "creator_avatar" => creator["avatar"], + "path" => comment["path"], + "score" => counts["score"] || 0, + "upvotes" => counts["upvotes"] || 0, + "downvotes" => counts["downvotes"] || 0, + "child_count" => counts["child_count"] || 0 + }, + "sensitive" => false + } + end + end + + defp lemmy_comment_to_ap(_, _, _), do: nil + + defp parse_lemmy_parent_ref(comment, comment_id_to_ap, post_url) when is_map(comment) do + path = comment["path"] + + case path do + value when is_binary(value) -> + segments = String.split(value, ".") + + case Enum.reverse(segments) do + [_self, parent_id | _] when is_binary(parent_id) and parent_id != "" -> + Map.get(comment_id_to_ap, parent_id, post_url) + + _ -> + post_url + end + + _ -> + post_url + end + end + + defp parse_lemmy_parent_ref(_, _, post_url), do: post_url + + # Check if a post is from a Lemmy-compatible community platform. + defp lemmy_post?(post) do + activitypub_id = get_activitypub_id(post) + activitypub_url = get_activitypub_url(post) + community_uri = get_community_actor_uri(post) + + (is_binary(activitypub_id) && community_post_url?(activitypub_id)) || + (is_binary(activitypub_url) && community_post_url?(activitypub_url)) || + is_binary(community_uri) + end + + defp get_activitypub_id(%{activitypub_id: id}) when is_binary(id), do: id + defp get_activitypub_id(%{"id" => id}) when is_binary(id), do: id + defp get_activitypub_id(_), do: nil + + defp get_activitypub_url(%{activitypub_url: url}) when is_binary(url), do: url + defp get_activitypub_url(%{"url" => url}) when is_binary(url), do: url + defp get_activitypub_url(_), do: nil + + defp get_community_actor_uri(%{media_metadata: metadata}) when is_map(metadata) do + metadata["community_actor_uri"] + end + + defp get_community_actor_uri(%{"media_metadata" => metadata}) when is_map(metadata) do + metadata["community_actor_uri"] + end + + defp get_community_actor_uri(_), do: nil + + @doc """ + Returns true when the URL matches a Lemmy/PieFed/Mbin community post pattern. + + This intentionally requires a numeric post id for `/post/` so URLs like + `bsky.app/profile/.../post/` are not treated as Lemmy posts. + """ + def community_post_url?(url) when is_binary(url) do + Regex.match?(~r{/post/\d+(?:$|[/?#])}, url) || + Regex.match?(~r{/c/[^/]+/p/\d+(?:$|[/?#])}, url) || + Regex.match?(~r{/m/[^/]+/[pt]/\d+(?:$|[/?#])}, url) + end + + def community_post_url?(_), do: false + + defp resolve_post_reference(post_url) do + case Regex.run(~r{https?://([^/]+)/post/(\d+)}, post_url) do + [_, domain, post_id] -> + {:ok, domain, post_id} + + _ -> + case Regex.run(~r{https?://([^/]+)/c/[^/]+/p/(\d+)}, post_url) do + [_, domain, post_id] -> + {:ok, domain, post_id} + + _ -> + case Regex.run(~r{https?://([^/]+)/m/[^/]+/[pt]/(\d+)}, post_url) do + [_, domain, post_id] -> + {:ok, domain, post_id} + + _ -> + resolve_post_reference_via_api(post_url) + end + end + end + end + + defp resolve_post_reference_via_api(post_url) do + case URI.parse(post_url) do + %URI{host: domain} when is_binary(domain) -> + resolve_url = "https://#{domain}/api/v4/resolve_object?q=#{URI.encode_www_form(post_url)}" + + case safe_request_lemmy_api(:get, resolve_url, [{"Accept", "application/json"}], nil, + receive_timeout: 10_000 + ) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, %{"post" => %{"post" => %{"id" => post_id}}}} when is_integer(post_id) -> + {:ok, domain, Integer.to_string(post_id)} + + {:ok, %{"post" => %{"post" => %{"id" => post_id}}}} when is_binary(post_id) -> + {:ok, domain, post_id} + + _ -> + :error + end + + _ -> + :error + end + + _ -> + :error + end + end + + defp parse_count(value) when is_integer(value), do: max(value, 0) + + defp parse_count(value) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {parsed, _} -> max(parsed, 0) + :error -> 0 + end + end + + defp parse_count(_), do: 0 + + defp extract_collection_total(%{"totalItems" => total}), do: parse_count(total) + defp extract_collection_total(%{totalItems: total}), do: parse_count(total) + + defp extract_collection_total(total) when is_integer(total) or is_binary(total), + do: parse_count(total) + + defp extract_collection_total(_), do: 0 + + defp fetch_post_counts_from_html(post_url) when is_binary(post_url) do + headers = [{"Accept", "text/html,application/xhtml+xml"}, {"User-Agent", "Elektrine/1.0"}] + + case safe_request(:get, post_url, headers, nil, receive_timeout: 5_000) do + {:ok, %Finch.Response{status: 200, body: body}} -> + parse_post_counts_from_html(body) + + _ -> + nil + end + end + + defp parse_post_counts_from_html(body) when is_binary(body) do + score = extract_named_capture_integer(body, ~r/class="score"[^>]*>(?[^<]+)]*>\s*(?\d+)\s+Comments\s*<\/h2>/i) || + extract_named_capture_integer(body, ~r/post_reply_count_text">\s*(?\d+)\s*]*title="(?\d+)\s*,\s*\d+"/) + + downvotes = + extract_named_capture_integer(body, ~r/class="score"[^>]*title="\d+\s*,\s*(?\d+)"/) + + if is_integer(score) or is_integer(comments) do + %{ + upvotes: upvotes || max(score || 0, 0), + downvotes: downvotes || 0, + score: score || 0, + comments: comments || 0 + } + else + nil + end + end + + defp parse_post_counts_from_html(_), do: nil + + defp extract_named_capture_integer(body, regex) when is_binary(body) do + case Regex.named_captures(regex, body) do + %{"value" => value} -> + case Integer.parse(String.trim(value)) do + {parsed, _} -> parsed + :error -> nil + end + + _ -> + nil + end + end + + defp safe_request(method, url, headers, body, opts) do + case Keyword.get(opts, :request_fun) do + request_fun when is_function(request_fun, 5) -> + request_fun.(method, url, headers, body, opts) + + _ -> + request = Finch.build(method, url, headers, body || "") + SafeFetch.request(request, Elektrine.Finch, opts) + end + end + + defp safe_request_lemmy_api(method, url, headers, body, opts) do + case safe_request(method, url, headers, body, opts) do + {:ok, %Finch.Response{status: 404}} -> + url + |> fallback_lemmy_api_url() + |> case do + nil -> {:ok, %Finch.Response{status: 404, headers: [], body: ""}} + fallback_url -> safe_request(method, fallback_url, headers, body, opts) + end + + other -> + other + end + end + + defp fallback_lemmy_api_url(url) when is_binary(url) do + if String.contains?(url, "/api/v4/") do + String.replace(url, "/api/v4/", "/api/v3/", global: false) + else + nil + end + end + + defp fallback_lemmy_api_url(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/activitypub/lemmy_cache.ex b/apps/elektrine/lib/elektrine/activitypub/lemmy_cache.ex new file mode 100644 index 0000000..fb3df2e --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/lemmy_cache.ex @@ -0,0 +1,173 @@ +defmodule Elektrine.ActivityPub.LemmyCache do + @moduledoc """ + Cache for Lemmy post counts and top comments. + Stores data in database to avoid slow API calls on page load. + """ + + use Ecto.Schema + import Ecto.Query + alias Elektrine.ActivityPub.LemmyApi + alias Elektrine.Repo + + @cache_ttl_minutes 15 + + schema "lemmy_counts_cache" do + field :activitypub_id, :string + field :upvotes, :integer, default: 0 + field :downvotes, :integer, default: 0 + field :score, :integer, default: 0 + field :comments, :integer, default: 0 + field :top_comments, {:array, :map}, default: [] + field :fetched_at, :utc_datetime + + timestamps() + end + + def changeset(cache, attrs) do + cache + |> Ecto.Changeset.cast(attrs, [ + :activitypub_id, + :upvotes, + :downvotes, + :score, + :comments, + :top_comments, + :fetched_at + ]) + |> Ecto.Changeset.validate_required([:activitypub_id, :fetched_at]) + end + + @doc """ + Get cached counts for multiple posts. + Returns a map of activitypub_id => %{upvotes, downvotes, score, comments} + """ + def get_cached_counts(activitypub_ids) when is_list(activitypub_ids) do + from(c in __MODULE__, + where: c.activitypub_id in ^activitypub_ids, + select: + {c.activitypub_id, + %{ + upvotes: c.upvotes, + downvotes: c.downvotes, + score: c.score, + comments: c.comments + }} + ) + |> Repo.all() + |> Map.new() + end + + @doc """ + Get both counts and comments for posts. + Returns {counts_map, comments_map} + """ + def get_cached_data(activitypub_ids) when is_list(activitypub_ids) do + results = + from(c in __MODULE__, + where: c.activitypub_id in ^activitypub_ids, + select: c + ) + |> Repo.all() + + counts = + results + |> Enum.map(fn c -> + {c.activitypub_id, + %{ + upvotes: c.upvotes, + downvotes: c.downvotes, + score: c.score, + comments: c.comments + }} + end) + |> Map.new() + + comments = + results + |> Enum.filter(fn c -> c.top_comments != [] end) + |> Enum.map(fn c -> {c.activitypub_id, c.top_comments} end) + |> Map.new() + + {counts, comments} + end + + @doc """ + Check which posts need their cache refreshed (stale or missing). + Returns list of activitypub_ids that need updating. + """ + def get_stale_ids(activitypub_ids) when is_list(activitypub_ids) do + cutoff = DateTime.add(DateTime.utc_now(), -@cache_ttl_minutes, :minute) + + cached_fresh = + from(c in __MODULE__, + where: c.activitypub_id in ^activitypub_ids and c.fetched_at > ^cutoff, + select: c.activitypub_id + ) + |> Repo.all() + |> MapSet.new() + + activitypub_ids + |> Enum.filter(fn id -> not MapSet.member?(cached_fresh, id) end) + end + + @doc """ + Update cache for a single post. Called by background worker. + """ + def refresh_cache(activitypub_id) when is_binary(activitypub_id) do + # Only fetch if it's a Lemmy post + if LemmyApi.community_post_url?(activitypub_id) do + counts = LemmyApi.fetch_post_counts(activitypub_id) || %{} + top_comments = LemmyApi.fetch_top_comments(activitypub_id, 3) || [] + + attrs = %{ + activitypub_id: activitypub_id, + upvotes: counts[:upvotes] || 0, + downvotes: counts[:downvotes] || 0, + score: counts[:score] || 0, + comments: counts[:comments] || 0, + top_comments: top_comments, + fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + } + + %__MODULE__{} + |> changeset(attrs) + |> Repo.insert( + on_conflict: + {:replace, + [:upvotes, :downvotes, :score, :comments, :top_comments, :fetched_at, :updated_at]}, + conflict_target: :activitypub_id + ) + else + {:ok, nil} + end + end + + @doc """ + Schedule background refresh for posts that need updating. + """ + def schedule_refresh(activitypub_ids) when is_list(activitypub_ids) do + stale_ids = get_stale_ids(activitypub_ids) + + # Only schedule if there are stale posts + if stale_ids != [] do + # Schedule one job that handles all stale posts + %{activitypub_ids: stale_ids} + |> Elektrine.ActivityPub.LemmyCacheWorker.new() + |> Elektrine.JobQueue.insert() + else + {:ok, :no_stale} + end + end + + @doc """ + Clean up old cache entries (older than 24 hours). + """ + def cleanup_old_entries do + cutoff = DateTime.add(DateTime.utc_now(), -24, :hour) + + from(c in __MODULE__, + where: c.fetched_at < ^cutoff + ) + |> Repo.delete_all() + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/lemmy_cache_worker.ex b/apps/elektrine/lib/elektrine/activitypub/lemmy_cache_worker.ex new file mode 100644 index 0000000..a1dde79 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/lemmy_cache_worker.ex @@ -0,0 +1,43 @@ +defmodule Elektrine.ActivityPub.LemmyCacheWorker do + @moduledoc """ + Oban worker for refreshing Lemmy post counts cache in background. + """ + + use Oban.Worker, + queue: :default, + max_attempts: 2 + + require Logger + + alias Elektrine.ActivityPub.LemmyCache + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"activitypub_ids" => activitypub_ids}}) do + Logger.debug("LemmyCacheWorker: Refreshing #{length(activitypub_ids)} posts") + + # Process in parallel but with limited concurrency + activitypub_ids + |> Task.async_stream( + fn id -> LemmyCache.refresh_cache(id) end, + max_concurrency: 5, + timeout: 10_000, + on_timeout: :kill_task + ) + |> Enum.each(fn + {:ok, {:ok, _}} -> :ok + {:ok, {:error, reason}} -> Logger.warning("LemmyCache refresh failed: #{inspect(reason)}") + {:exit, :timeout} -> Logger.warning("LemmyCache refresh timed out") + _ -> :ok + end) + + :ok + end + + def perform(%Oban.Job{args: %{"activitypub_id" => activitypub_id}}) do + # Single post refresh + case LemmyCache.refresh_cache(activitypub_id) do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/lemmy_comment_backfill.ex b/apps/elektrine/lib/elektrine/activitypub/lemmy_comment_backfill.ex new file mode 100644 index 0000000..337fb57 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/lemmy_comment_backfill.ex @@ -0,0 +1,168 @@ +defmodule Elektrine.ActivityPub.LemmyCommentBackfill do + @moduledoc false + + import Ecto.Query + + alias Elektrine.ActivityPub.LemmyApi + alias Elektrine.Repo + alias Elektrine.Social.Message + alias Elektrine.Social.Messages + + @spec run(keyword()) :: %{posts: non_neg_integer(), comments: non_neg_integer()} + def run(opts \\ []) do + dry_run? = Keyword.get(opts, :dry_run, false) + + lemmy_posts = list_lemmy_posts() + + remote_comments_updated = + Enum.reduce(lemmy_posts, 0, fn post, acc -> + acc + backfill_post_comments(post, dry_run?: dry_run?) + end) + + fallback_comments_updated = backfill_upvotes_from_like_count(dry_run?: dry_run?) + + %{ + posts: length(lemmy_posts), + comments: remote_comments_updated + fallback_comments_updated, + remote_comments: remote_comments_updated, + fallback_comments: fallback_comments_updated + } + end + + @spec backfill_post_comments(Message.t(), keyword()) :: non_neg_integer() + def backfill_post_comments(%Message{} = post, opts \\ []) do + dry_run? = Keyword.get(opts, :dry_run, false) + fetcher = Keyword.get(opts, :fetcher, &LemmyApi.fetch_comment_counts/1) + + post + |> lemmy_post_ref() + |> fetcher.() + |> apply_comment_counts(dry_run?: dry_run?) + end + + @spec apply_comment_counts(map(), keyword()) :: non_neg_integer() + def apply_comment_counts(comment_counts, opts \\ []) + + def apply_comment_counts(comment_counts, opts) when is_map(comment_counts) do + dry_run? = Keyword.get(opts, :dry_run, false) + + ap_ids = Map.keys(comment_counts) + + if ap_ids == [] do + 0 + else + Message + |> where([m], m.activitypub_id in ^ap_ids) + |> Repo.all() + |> Enum.reduce(0, fn message, acc -> + counts = Map.get(comment_counts, message.activitypub_id, %{}) + + if comment_counts_changed?(message, counts) do + unless dry_run? do + persist_comment_counts(message, counts) + end + + acc + 1 + else + acc + end + end) + end + end + + def apply_comment_counts(_, _), do: 0 + + @spec backfill_upvotes_from_like_count(keyword()) :: non_neg_integer() + def backfill_upvotes_from_like_count(opts \\ []) do + dry_run? = Keyword.get(opts, :dry_run, false) + + Message + |> where( + [m], + fragment("? LIKE '%/comment/%'", m.activitypub_id) or + fragment("? LIKE '%/comment/%'", m.activitypub_url) + ) + |> where([m], m.upvotes == 0 and m.like_count > 0) + |> Repo.all() + |> Enum.reduce(0, fn message, acc -> + unless dry_run? do + Repo.update_all( + from(m in Message, where: m.id == ^message.id), + set: [ + upvotes: message.like_count, + updated_at: DateTime.utc_now() |> DateTime.truncate(:second) + ] + ) + + Messages.broadcast_post_counts_updated(message.id, %{ + like_count: message.like_count, + reply_count: message.reply_count || 0, + share_count: message.share_count || 0 + }) + end + + acc + 1 + end) + end + + defp list_lemmy_posts do + Message + |> join(:inner, [p], r in Message, on: r.reply_to_id == p.id) + |> where([p, _r], is_nil(p.reply_to_id)) + |> where([p, _r], not is_nil(p.activitypub_id)) + |> distinct([p, _r], true) + |> select([p, _r], p) + |> Repo.all() + |> Enum.filter(fn post -> + LemmyApi.community_post_url?(post.activitypub_id || post.activitypub_url) + end) + end + + defp lemmy_post_ref(%Message{activitypub_id: activitypub_id}) when is_binary(activitypub_id), + do: activitypub_id + + defp lemmy_post_ref(%Message{activitypub_url: activitypub_url}) when is_binary(activitypub_url), + do: activitypub_url + + defp lemmy_post_ref(_), do: nil + + defp comment_counts_changed?(message, counts) do + message.like_count != normalized_count(counts, :upvotes) || + message.upvotes != normalized_count(counts, :upvotes) || + message.downvotes != normalized_count(counts, :downvotes) || + message.score != normalized_count(counts, :score) || + message.reply_count != normalized_count(counts, :child_count) + end + + defp persist_comment_counts(message, counts) do + updated_counts = %{ + like_count: normalized_count(counts, :upvotes), + upvotes: normalized_count(counts, :upvotes), + downvotes: normalized_count(counts, :downvotes), + score: normalized_count(counts, :score), + reply_count: normalized_count(counts, :child_count), + share_count: message.share_count || 0 + } + + Repo.update_all( + from(m in Message, where: m.id == ^message.id), + set: [ + like_count: updated_counts.like_count, + upvotes: updated_counts.upvotes, + downvotes: updated_counts.downvotes, + score: updated_counts.score, + reply_count: updated_counts.reply_count, + updated_at: DateTime.utc_now() |> DateTime.truncate(:second) + ] + ) + + Messages.broadcast_post_counts_updated(message.id, updated_counts) + end + + defp normalized_count(counts, key) do + case Map.get(counts, key, 0) do + value when is_integer(value) -> max(value, 0) + _ -> 0 + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/local_references.ex b/apps/elektrine/lib/elektrine/activitypub/local_references.ex new file mode 100644 index 0000000..3412dbf --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/local_references.ex @@ -0,0 +1,272 @@ +defmodule Elektrine.ActivityPub.LocalReferences do + @moduledoc false + + import Ecto.Query, warn: false + + alias Elektrine.Accounts.User + alias Elektrine.ActivityPub.Activity + alias Elektrine.Domains + alias Elektrine.Repo + + @public_audience_uri "https://www.w3.org/ns/activitystreams#Public" + + def actor_prefixes do + domains = Domains.activitypub_domains() + + urls = + [instance_url()] ++ + Enum.flat_map(domains, fn domain -> ["https://#{domain}", "http://#{domain}"] end) + + urls + |> Enum.uniq() + |> Enum.map(&(String.trim_trailing(&1, "/") <> "/users/")) + end + + def local_username_from_uri(uri) when is_binary(uri) do + normalized_uri = String.trim(uri) + + if Elektrine.Strings.present?(normalized_uri) do + case URI.parse(normalized_uri) do + %URI{host: host, path: path} when is_binary(host) and is_binary(path) -> + if Domains.local_activitypub_domain?(String.downcase(host)) do + username_from_local_path(path) + else + {:error, :not_local} + end + + _ -> + {:error, :invalid_uri} + end + else + {:error, :invalid_uri} + end + end + + def local_username_from_uri(_), do: {:error, :invalid_uri} + + def resolve_target_user(activity) when is_map(activity) do + case resolve_target_user_id(activity) do + user_id when is_integer(user_id) -> Repo.get(User, user_id) + _ -> nil + end + end + + def resolve_target_user(_), do: nil + + def resolve_target_user_id(activity) when is_map(activity) do + direct_ref = + activity + |> candidate_target_refs() + |> Enum.find_value(&target_user_id_from_ref/1) + + direct_ref || inferred_recipient_user_id(activity) + end + + def resolve_target_user_id(_), do: nil + + defp username_from_local_path(path) do + case extract_local_identifier_from_path(path) do + nil -> + {:error, :not_local} + + identifier -> + case Elektrine.Accounts.get_user_by_activitypub_identifier(identifier) do + %User{username: username} -> {:ok, username} + _ -> {:ok, actor_identifier(identifier)} + end + end + end + + defp extract_local_identifier_from_path(path) when is_binary(path) do + case path |> String.trim_leading("/") |> String.split("/", trim: true) do + ["users", identifier | _] -> + if Elektrine.Strings.present?(identifier), do: identifier, else: nil + + [<<"@", identifier::binary>> | _] -> + if Elektrine.Strings.present?(identifier), do: identifier, else: nil + + _ -> + nil + end + end + + defp inferred_recipient_user_id(%{"type" => type} = activity) + when type in ["Follow", "Accept", "Reject", "Block", "Flag", "Move"] do + single_recipient_user_id(activity) + end + + defp inferred_recipient_user_id(_), do: nil + + defp candidate_target_refs(activity) when is_map(activity) do + [] + |> add_candidate_ref(Map.get(activity, "object")) + |> add_candidate_ref(Map.get(activity, "target")) + |> Enum.reverse() + end + + defp add_candidate_ref(acc, nil), do: acc + defp add_candidate_ref(acc, value) when is_binary(value), do: [value | acc] + + defp add_candidate_ref(acc, values) when is_list(values) do + Enum.reduce(values, acc, fn value, nested_acc -> add_candidate_ref(nested_acc, value) end) + end + + defp add_candidate_ref(acc, %{} = value) do + acc + |> add_candidate_ref(Map.get(value, "id")) + |> add_candidate_ref(Map.get(value, "object")) + |> add_candidate_ref(Map.get(value, "target")) + |> add_candidate_ref(Map.get(value, "inReplyTo")) + |> add_candidate_ref(Map.get(value, "url")) + |> add_candidate_ref(Map.get(value, "href")) + end + + defp add_candidate_ref(acc, _value), do: acc + + defp single_recipient_user_id(activity) do + recipient_ids = + activity + |> recipient_refs() + |> Enum.map(&local_user_id_from_uri/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + + case recipient_ids do + [user_id] -> user_id + _ -> nil + end + end + + defp recipient_refs(activity) when is_map(activity) do + activity_object = + case Map.get(activity, "object") do + %{} = object -> object + _ -> %{} + end + + mention_refs = + if public_activity?(activity, activity_object) do + [] + else + mention_hrefs(activity_object) + end + + [ + Map.get(activity, "to"), + Map.get(activity, "cc"), + Map.get(activity, "audience"), + Map.get(activity, "target"), + Map.get(activity_object, "to"), + Map.get(activity_object, "cc"), + Map.get(activity_object, "audience"), + mention_refs + ] + |> Enum.flat_map(&recipient_values/1) + end + + defp recipient_refs(_), do: [] + + defp recipient_values(nil), do: [] + defp recipient_values(value) when is_binary(value), do: [value] + + defp recipient_values(values) when is_list(values) do + Enum.flat_map(values, &recipient_values/1) + end + + defp recipient_values(%{} = value) do + [Map.get(value, "id"), Map.get(value, "href"), Map.get(value, "url")] + |> Enum.map(&Elektrine.Strings.present/1) + |> Enum.reject(&is_nil/1) + end + + defp recipient_values(_), do: [] + + defp mention_hrefs(%{"tag" => tags}) when is_list(tags) do + tags + |> Enum.filter(&(Map.get(&1, "type") == "Mention")) + |> Enum.map(&Map.get(&1, "href")) + |> Enum.map(&Elektrine.Strings.present/1) + |> Enum.reject(&is_nil/1) + end + + defp mention_hrefs(_), do: [] + + defp public_activity?(activity, activity_object) + when is_map(activity) and is_map(activity_object) do + [ + Map.get(activity, "to"), + Map.get(activity, "cc"), + Map.get(activity, "audience"), + Map.get(activity_object, "to"), + Map.get(activity_object, "cc"), + Map.get(activity_object, "audience") + ] + |> Enum.flat_map(&recipient_values/1) + |> Enum.any?(&(&1 == @public_audience_uri)) + end + + defp public_activity?(_, _), do: false + + defp target_user_id_from_ref(ref) when is_binary(ref) do + local_user_id_from_activity(ref) || + local_user_id_from_uri(ref) || + local_user_id_from_message(ref) + end + + defp target_user_id_from_ref(_), do: nil + + defp local_user_id_from_activity(activity_id) when is_binary(activity_id) do + case get_activity_by_id(activity_id) do + %Activity{internal_user_id: user_id} when is_integer(user_id) -> user_id + _ -> nil + end + end + + defp local_user_id_from_uri(uri) when is_binary(uri) do + case local_username_from_uri(uri) do + {:ok, username} -> + case Elektrine.Accounts.get_user_by_username(username) do + %User{id: user_id} -> user_id + _ -> nil + end + + _ -> + nil + end + end + + defp local_user_id_from_uri(_), do: nil + + defp local_user_id_from_message(ref) when is_binary(ref) do + case Elektrine.Messaging.get_message_by_activitypub_ref(ref) do + %{sender_id: user_id} when is_integer(user_id) -> user_id + _ -> nil + end + end + + defp get_activity_by_id(activity_id) do + from(a in Activity, where: a.activity_id == ^activity_id) + |> Repo.one() + end + + defp instance_url do + Domains.inferred_base_url_for_domain(Domains.instance_domain()) + end + + defp actor_identifier(%User{handle: handle, username: username}) do + if Elektrine.Strings.present?(handle), do: handle, else: username + end + + defp actor_identifier(%{handle: handle, username: username}) do + if Elektrine.Strings.present?(handle), do: handle, else: username + end + + defp actor_identifier(identifier) when is_binary(identifier) do + identifier + |> String.trim() + |> String.trim_leading("@") + |> String.downcase() + end + + defp actor_identifier(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mastodon_api.ex b/apps/elektrine/lib/elektrine/activitypub/mastodon_api.ex new file mode 100644 index 0000000..3511c1e --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mastodon_api.ex @@ -0,0 +1,944 @@ +defmodule Elektrine.ActivityPub.MastodonApi do + @moduledoc """ + Helper module for fetching data from Mastodon-compatible instances via their API. + + Supports Mastodon, Pleroma, Akkoma, Misskey (partial), and other compatible servers. + Uses the Mastodon API v1 endpoints which are widely supported across the fediverse. + """ + + alias Elektrine.Domains + alias Elektrine.HTTP.SafeFetch + + @doc """ + Fetch status counts (favourites, reblogs, replies) from a Mastodon-compatible instance. + Returns nil if the status is not from a compatible instance or if the fetch fails. + + ## Examples + + iex> fetch_status_counts("https://mastodon.social/users/user/statuses/123456789") + %{favourites_count: 42, reblogs_count: 10, replies_count: 5} + + iex> fetch_status_counts("https://example.com/not-a-status") + nil + """ + def fetch_status_counts(status_url, opts \\ []) + + def fetch_status_counts(status_url, opts) when is_binary(status_url) and is_list(opts) do + case extract_status_info(status_url) do + {:ok, domain, status_id} -> + fetch_from_api(domain, status_id, opts) + + {:search, domain, status_url} -> + fetch_status_counts_via_search(domain, status_url, opts) + + :error -> + nil + end + end + + def fetch_status_counts(_, _), do: nil + + @doc """ + Fetch counts for a post struct/map, using `activitypub_url` as a fallback when + `activitypub_id` is not directly addressable by a Mastodon-compatible API. + """ + def fetch_status_counts_for_post(post, opts \\ []) do + case status_count_lookup(post) do + {_result_key, status_url} -> fetch_status_counts(status_url, opts) + nil -> nil + end + end + + @doc """ + Fetch counts for multiple statuses in parallel. + Returns a map of activitypub_id => counts. + Uses yield_many to avoid blocking on slow/failed requests. + """ + def fetch_statuses_counts(posts) when is_list(posts) do + tasks = + posts + |> Enum.flat_map(fn post -> + case status_count_lookup(post) do + {result_key, status_url} -> + [ + Task.async(fn -> + counts = fetch_status_counts(status_url) + {result_key, counts} + end) + ] + + nil -> + [] + end + end) + + # Use yield_many with 5s timeout - accept whatever completes in time + results = Task.yield_many(tasks, timeout: 5_000) + + # Shutdown any tasks that didn't complete + Enum.each(results, fn {task, result} -> + if result == nil, do: Task.shutdown(task, :brutal_kill) + end) + + # Collect successful results + results + |> Enum.flat_map(fn + {_task, {:ok, {id, counts}}} when counts != nil -> [{id, counts}] + _ -> [] + end) + |> Map.new() + end + + @doc """ + Fetch replies to a status from a Mastodon-compatible instance. + Returns a list of reply statuses with their counts. + """ + def fetch_status_context(status_url, opts \\ []) + + def fetch_status_context(status_url, opts) when is_binary(status_url) and is_list(opts) do + case extract_status_info(status_url) do + {:ok, domain, status_id} -> + if misskey_note_url?(status_url) do + fetch_misskey_context(domain, status_id, status_url, opts) + else + fetch_context_from_api(domain, status_id, status_url, opts) + end + + {:search, domain, search_url} -> + fetch_status_context_via_search(domain, search_url, opts) + + :error -> + {:error, :invalid_url} + end + end + + def fetch_status_context(_, _), do: {:error, :invalid_url} + + @doc """ + Fetch who favourited a status (likers). + Returns a list of account info maps. + """ + def fetch_favourited_by(status_url, limit_or_opts \\ 40, opts \\ []) + + def fetch_favourited_by(status_url, opts, []) when is_list(opts), + do: fetch_favourited_by(status_url, 40, opts) + + def fetch_favourited_by(status_url, limit, opts) + when is_binary(status_url) and is_integer(limit) and is_list(opts) do + if misskey_note_url?(status_url) do + case extract_status_info(status_url) do + {:ok, domain, note_id} -> fetch_misskey_reactions(domain, note_id, limit, opts) + _ -> {:error, :invalid_url} + end + else + case status_endpoint_lookup(status_url, opts) do + {:ok, domain, status_id} -> + api_url = + "https://#{domain}/api/v1/statuses/#{status_id}/favourited_by?limit=#{limit}" + + fetch_account_list(api_url, opts) + + {:error, _reason} -> + {:error, :invalid_url} + end + end + end + + def fetch_favourited_by(_, _, _), do: {:error, :invalid_url} + + @doc """ + Fetch who favourited a post struct/map, using `activitypub_url` as a fallback + when the canonical `activitypub_id` needs display/status URL resolution. + """ + def fetch_favourited_by_for_post(post, limit_or_opts \\ 40, opts \\ []) + + def fetch_favourited_by_for_post(post, opts, []) when is_list(opts), + do: fetch_favourited_by_for_post(post, 40, opts) + + def fetch_favourited_by_for_post(post, limit, opts) + when is_integer(limit) and is_list(opts) do + case status_interaction_lookup(post) do + {_result_key, status_url} -> fetch_favourited_by(status_url, limit, opts) + nil -> {:error, :invalid_url} + end + end + + @doc """ + Fetch who reblogged a status (boosters). + Returns a list of account info maps. + """ + def fetch_reblogged_by(status_url, limit_or_opts \\ 40, opts \\ []) + + def fetch_reblogged_by(status_url, opts, []) when is_list(opts), + do: fetch_reblogged_by(status_url, 40, opts) + + def fetch_reblogged_by(status_url, limit, opts) + when is_binary(status_url) and is_integer(limit) and is_list(opts) do + if misskey_note_url?(status_url) do + case extract_status_info(status_url) do + {:ok, domain, note_id} -> fetch_misskey_renotes(domain, note_id, limit, opts) + _ -> {:error, :invalid_url} + end + else + case status_endpoint_lookup(status_url, opts) do + {:ok, domain, status_id} -> + api_url = "https://#{domain}/api/v1/statuses/#{status_id}/reblogged_by?limit=#{limit}" + fetch_account_list(api_url, opts) + + {:error, _reason} -> + {:error, :invalid_url} + end + end + end + + def fetch_reblogged_by(_, _, _), do: {:error, :invalid_url} + + @doc """ + Fetch who reblogged a post struct/map, using `activitypub_url` as a fallback + when the canonical `activitypub_id` needs display/status URL resolution. + """ + def fetch_reblogged_by_for_post(post, limit_or_opts \\ 40, opts \\ []) + + def fetch_reblogged_by_for_post(post, opts, []) when is_list(opts), + do: fetch_reblogged_by_for_post(post, 40, opts) + + def fetch_reblogged_by_for_post(post, limit, opts) + when is_integer(limit) and is_list(opts) do + case status_interaction_lookup(post) do + {_result_key, status_url} -> fetch_reblogged_by(status_url, limit, opts) + nil -> {:error, :invalid_url} + end + end + + @doc """ + Detect if a post can use the counts API in this module. + This includes Misskey note URLs because count lookup falls back to `/api/notes/show`. + """ + def count_api_compatible?(post) do + post + |> status_url_candidates() + |> Enum.any?(&count_api_compatible_url?/1) + end + + @doc """ + Detect if a post can use Mastodon-compatible status endpoints like + `/api/v1/statuses/:id`, `/context`, `/favourited_by`, and `/reblogged_by`. + """ + def mastodon_compatible?(post) do + post + |> status_url_candidates() + |> Enum.any?(&mastodon_compatible_url?/1) + end + + # Private functions + + defp extract_status_info(url) do + cond do + # Mastodon/Pleroma/Akkoma/GoToSocial: /users/{user}/statuses/{id} + match = Regex.run(~r{https?://([^/]+)/users/[^/]+/statuses/([a-zA-Z0-9_-]+)}, url) -> + [_, domain, status_id] = match + {:ok, domain, status_id} + + # Mastodon display URL: /@{user}/{id} + match = Regex.run(~r{https?://([^/]+)/@[^/]+/([a-zA-Z0-9_-]+)}, url) -> + [_, domain, status_id] = match + {:ok, domain, status_id} + + # Pleroma/Akkoma display URL: /notice/{id} + match = Regex.run(~r{https?://([^/]+)/notice/([a-zA-Z0-9_-]+)(?:$|[/?#])}, url) -> + [_, domain, status_id] = match + {:ok, domain, status_id} + + # Misskey/Calckey: /notes/{id} - need to use their API + match = Regex.run(~r{https?://([^/]+)/notes/([a-zA-Z0-9]+)(?:$|[/?#])}, url) -> + [_, domain, note_id] = match + {:ok, domain, note_id} + + # Pixelfed: /p/{user}/{id} + match = Regex.run(~r{https?://([^/]+)/p/[^/]+/(\d+)$}, url) -> + [_, domain, status_id] = match + {:ok, domain, status_id} + + # Pleroma/Akkoma/Friendica object URLs need API search to resolve an internal status ID. + match = Regex.run(~r{https?://([^/]+)/objects/[a-f0-9-]+$}i, url) -> + [_, domain] = match + {:search, domain, url} + + true -> + :error + end + end + + defp status_count_lookup(post) do + candidates = status_url_candidates(post) + + status_url = + Enum.find(candidates, &direct_count_api_url?/1) || + Enum.find(candidates, &count_api_compatible_url?/1) + + if status_url do + {get_activitypub_id(post) || status_url, status_url} + end + end + + defp status_interaction_lookup(post) do + status_url = + post + |> status_url_candidates() + |> Enum.find(&(mastodon_compatible_url?(&1) || misskey_note_url?(&1))) + + if status_url do + {get_activitypub_id(post) || status_url, status_url} + end + end + + defp status_url_candidates(post) do + [get_activitypub_id(post), get_activitypub_url(post)] + |> Enum.filter(&(is_binary(&1) and String.trim(&1) != "")) + |> Enum.uniq() + end + + defp count_api_compatible_url?(url) when is_binary(url) do + cond do + # Lemmy posts/comments are handled by LemmyApi, not Mastodon-compatible APIs. + String.contains?(url, "/post/") -> false + String.contains?(url, "/comment/") -> false + direct_count_api_url?(url) -> true + # Friendica/Akkoma object URLs may still be resolvable by API search paths. + String.match?(url, ~r{/objects/[a-f0-9-]+(?:$|[/?#])}i) -> true + true -> false + end + end + + defp count_api_compatible_url?(_), do: false + + defp mastodon_compatible_url?(url) when is_binary(url) do + count_api_compatible_url?(url) && !String.match?(url, ~r{/notes/[a-zA-Z0-9]+(?:$|[/?#])}) + end + + defp mastodon_compatible_url?(_), do: false + + defp misskey_note_url?(url) when is_binary(url), + do: String.match?(url, ~r{/notes/[a-zA-Z0-9]+(?:$|[/?#])}) + + defp misskey_note_url?(_), do: false + + defp direct_count_api_url?(url) when is_binary(url) do + String.match?(url, ~r{/users/[^/]+/statuses/[a-zA-Z0-9_-]+(?:$|[/?#])}) || + String.match?(url, ~r{/@[^/]+/[a-zA-Z0-9_-]+(?:$|[/?#])}) || + String.match?(url, ~r{/notice/[a-zA-Z0-9_-]+(?:$|[/?#])}) || + String.match?(url, ~r{/notes/[a-zA-Z0-9]+(?:$|[/?#])}) || + String.match?(url, ~r{/p/[^/]+/\d+(?:$|[/?#])}) + end + + defp direct_count_api_url?(_), do: false + + defp status_endpoint_lookup(status_url, opts) do + case extract_status_info(status_url) do + {:ok, domain, status_id} -> + {:ok, domain, status_id} + + {:search, domain, search_url} -> + case fetch_status_via_search(domain, search_url, opts) do + {:ok, %{"id" => status_id}} when is_binary(status_id) and status_id != "" -> + {:ok, domain, status_id} + + _ -> + {:error, :invalid_url} + end + + :error -> + {:error, :invalid_url} + end + end + + defp fetch_context_from_api(domain, status_id, root_status_url, opts, root_status \\ nil) do + api_url = "https://#{domain}/api/v1/statuses/#{status_id}/context" + headers = build_headers() + + case safe_request(:get, api_url, headers, nil, Keyword.merge([receive_timeout: 10_000], opts)) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, %{"descendants" => descendants} = context} -> + ancestors = context["ancestors"] || [] + + # Mastodon context returns local status IDs in `in_reply_to_id`. Build a + # lookup map so we can expose ActivityPub URIs for reply threading. + id_to_uri_map = + build_status_id_to_uri_map( + status_id, + root_status_url, + ancestors, + descendants, + root_status + ) + + {:ok, + Enum.map(descendants, fn status -> + parent_id = normalize_status_id(status["in_reply_to_id"]) + + %{ + id: status["id"], + uri: status["uri"], + url: status["url"], + content: status["content"], + account: extract_account_info(status["account"]), + favourites_count: status["favourites_count"] || 0, + reblogs_count: status["reblogs_count"] || 0, + replies_count: status["replies_count"] || 0, + created_at: status["created_at"], + in_reply_to_id: status["in_reply_to_id"], + in_reply_to_uri: + if(parent_id, + do: Map.get(id_to_uri_map, parent_id, root_status_url), + else: nil + ) + } + end)} + + _ -> + {:error, :parse_error} + end + + {:ok, %Finch.Response{status: 404}} -> + maybe_fetch_status_context_via_search(domain, root_status_url, opts) + + {:ok, %Finch.Response{status: status}} -> + {:error, {:http_error, status}} + + {:error, reason} -> + {:error, reason} + end + end + + defp maybe_fetch_status_context_via_search(domain, status_url, opts) do + if Keyword.get(opts, :skip_context_search, false) do + {:error, {:http_error, 404}} + else + fetch_status_context_via_search(domain, status_url, opts) + end + end + + defp fetch_status_context_via_search(domain, status_url, opts) do + api_url = + "https://#{domain}/api/v2/search?q=#{URI.encode_www_form(status_url)}&type=statuses&resolve=true&limit=1" + + case safe_request( + :get, + api_url, + build_headers(), + nil, + Keyword.merge([receive_timeout: 15_000], opts) + ) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, %{"statuses" => [%{"id" => status_id} = root_status | _]}} -> + root_status_url = root_status["uri"] || root_status["url"] || status_url + + fetch_context_from_api( + domain, + status_id, + root_status_url, + Keyword.put(opts, :skip_context_search, true), + root_status + ) + + _ -> + {:error, :not_found} + end + + {:ok, %Finch.Response{status: status}} -> + {:error, {:http_error, status}} + + {:error, reason} -> + {:error, reason} + end + end + + defp build_status_id_to_uri_map( + root_status_id, + root_status_url, + ancestors, + descendants, + root_status + ) do + root_id = normalize_status_id(root_status_id) + + statuses = + ([%{"id" => root_id, "uri" => root_status_url}] ++ + List.wrap(root_status) ++ + ancestors ++ + descendants) + |> Enum.filter(&is_map/1) + + Enum.reduce(statuses, %{}, fn status, acc -> + id = normalize_status_id(status["id"]) + uri = status["uri"] || status["url"] + + if id && is_binary(uri) && uri != "" do + Map.put(acc, id, uri) + else + acc + end + end) + end + + defp normalize_status_id(id) when is_integer(id), do: Integer.to_string(id) + defp normalize_status_id(id) when is_binary(id), do: id + defp normalize_status_id(_), do: nil + + defp fetch_status_counts_via_search(domain, status_url, opts) do + case fetch_status_via_search(domain, status_url, opts) do + {:ok, status} -> status_counts(status) + {:error, _reason} -> nil + end + end + + defp fetch_status_via_search(domain, status_url, opts) do + api_url = + "https://#{domain}/api/v2/search?q=#{URI.encode_www_form(status_url)}&type=statuses&resolve=true&limit=1" + + case safe_request( + :get, + api_url, + build_headers(), + nil, + Keyword.merge([receive_timeout: 15_000], opts) + ) do + {:ok, %Finch.Response{status: 200, body: body}} -> + parse_search_status_response(body) + + {:ok, %Finch.Response{status: _status}} -> + {:error, :not_found} + + {:error, _reason} -> + {:error, :request_failed} + end + end + + defp fetch_from_api(domain, status_id, opts) do + # Try Mastodon API first + api_url = "https://#{domain}/api/v1/statuses/#{status_id}" + headers = build_headers() + + case safe_request(:get, api_url, headers, nil, Keyword.merge([receive_timeout: 5_000], opts)) do + {:ok, %Finch.Response{status: 200, body: body}} -> + parse_status_response(body) + + {:ok, %Finch.Response{status: 404}} -> + # Maybe it's a Misskey instance - try their API + try_misskey_api(domain, status_id, opts) + + {:ok, %Finch.Response{status: _status}} -> + nil + + {:error, _reason} -> + nil + end + end + + defp try_misskey_api(domain, note_id, opts) do + # Misskey uses POST for their API + case fetch_misskey_note(domain, note_id, opts) do + {:ok, note} -> misskey_status_counts(note) + {:error, _reason} -> nil + end + end + + defp fetch_misskey_note(domain, note_id, opts) do + misskey_post(domain, "/api/notes/show", %{"noteId" => note_id}, opts, receive_timeout: 5_000) + end + + defp fetch_misskey_reactions(domain, note_id, limit, opts) do + case misskey_post( + domain, + "/api/notes/reactions", + %{"noteId" => note_id, "limit" => limit}, + opts, + receive_timeout: 5_000 + ) do + {:ok, reactions} when is_list(reactions) -> + {:ok, + reactions + |> Enum.map(&misskey_reaction_account/1) + |> Enum.reject(&is_nil/1)} + + {:ok, _} -> + {:error, :parse_error} + + error -> + error + end + end + + defp fetch_misskey_renotes(domain, note_id, limit, opts) do + case misskey_post( + domain, + "/api/notes/renotes", + %{"noteId" => note_id, "limit" => limit}, + opts, + receive_timeout: 5_000 + ) do + {:ok, renotes} when is_list(renotes) -> + {:ok, + renotes + |> Enum.map(&misskey_renote_account/1) + |> Enum.reject(&is_nil/1)} + + {:ok, _} -> + {:error, :parse_error} + + error -> + error + end + end + + defp fetch_misskey_context(domain, note_id, root_status_url, opts) do + case misskey_post( + domain, + "/api/notes/children", + %{"noteId" => note_id, "limit" => 100, "depth" => 12}, + opts, + receive_timeout: 10_000 + ) do + {:ok, children} when is_list(children) -> + {:ok, + children + |> Enum.map(&misskey_note_context_status(&1, domain, note_id, root_status_url)) + |> Enum.reject(&is_nil/1)} + + {:ok, _} -> + {:error, :parse_error} + + error -> + error + end + end + + defp misskey_post(domain, path, payload, opts, request_opts) do + api_url = "https://#{domain}#{path}" + headers = [{"Content-Type", "application/json"} | build_headers()] + body = Jason.encode!(payload) + + case safe_request(:post, api_url, headers, body, Keyword.merge(request_opts, opts)) do + {:ok, %Finch.Response{status: 200, body: response_body}} -> + Jason.decode(response_body) + + {:ok, %Finch.Response{status: status}} -> + {:error, {:http_error, status}} + + {:error, reason} -> + {:error, reason} + end + end + + defp parse_status_response(body) do + case Jason.decode(body) do + {:ok, status} -> + status_counts(status) + + _ -> + nil + end + end + + defp parse_search_status_response(body) do + case Jason.decode(body) do + {:ok, %{"statuses" => [%{} = status | _]}} -> {:ok, status} + _ -> {:error, :parse_error} + end + end + + defp status_counts(status) when is_map(status) do + %{ + favourites_count: status["favourites_count"] || 0, + reblogs_count: status["reblogs_count"] || 0, + replies_count: status["replies_count"] || 0, + quotes_count: status["quotes_count"] || get_in(status, ["pleroma", "quotes_count"]) || 0, + status_metadata: status_metadata(status) + } + end + + defp misskey_status_counts(note) when is_map(note) do + reaction_count = + [ + nonnegative_count(note["reactionCount"]), + misskey_reaction_total(note["reactions"]) + ] + |> Enum.max(fn -> 0 end) + + %{ + favourites_count: reaction_count + nonnegative_count(note["likeCount"]), + reblogs_count: nonnegative_count(note["renoteCount"]), + replies_count: nonnegative_count(note["repliesCount"]), + quotes_count: misskey_quote_count(note), + status_metadata: misskey_status_metadata(note) + } + end + + defp status_metadata(status) when is_map(status) do + %{} + |> maybe_put_metadata( + "emoji_reactions", + status["emoji_reactions"] || get_in(status, ["pleroma", "emoji_reactions"]) + ) + |> maybe_put_metadata("quotes_count", status["quotes_count"]) + |> maybe_put_metadata("quote", get_in(status, ["pleroma", "quote"]) || status["quote"]) + |> maybe_put_metadata( + "quote_id", + get_in(status, ["pleroma", "quote_id"]) || status["quote_id"] + ) + |> maybe_put_metadata( + "quote_url", + get_in(status, ["pleroma", "quote_url"]) || status["quote_url"] + ) + |> maybe_put_metadata("card", status["card"]) + |> maybe_put_metadata("application", status["application"]) + |> maybe_put_metadata("language", status["language"]) + |> maybe_put_metadata("media_attachments", status["media_attachments"]) + |> maybe_put_metadata("pleroma", status["pleroma"]) + end + + defp misskey_status_metadata(note) when is_map(note) do + %{} + |> maybe_put_metadata("emoji_reactions", misskey_emoji_reactions(note["reactions"])) + |> maybe_put_metadata("quotes_count", positive_metadata_count(misskey_quote_count(note))) + |> maybe_put_metadata("quote", note["renote"]) + |> maybe_put_metadata("quote_id", note["renoteId"] || get_in(note, ["renote", "id"])) + |> maybe_put_metadata( + "quote_url", + note["renoteUri"] || note["renoteUrl"] || misskey_note_url(note["renote"]) + ) + |> maybe_put_metadata("card", note["cw"] && %{"title" => note["cw"]}) + |> maybe_put_metadata("application", misskey_application(note)) + |> maybe_put_metadata("language", note["lang"] || note["language"]) + |> maybe_put_metadata("media_attachments", misskey_media_attachments(note["files"])) + |> maybe_put_metadata("misskey", misskey_note_metadata(note)) + end + + defp misskey_emoji_reactions(reactions) when is_map(reactions) do + reactions + |> Enum.map(fn {emoji, count} -> + %{"name" => emoji, "count" => nonnegative_count(count)} + end) + |> Enum.filter(&(&1["count"] > 0)) + end + + defp misskey_emoji_reactions(_), do: [] + + defp misskey_reaction_total(reactions) when is_map(reactions) do + Enum.reduce(reactions, 0, fn {_emoji, count}, total -> total + nonnegative_count(count) end) + end + + defp misskey_reaction_total(_), do: 0 + + defp misskey_quote_count(note) when is_map(note) do + nonnegative_count(note["quoteCount"] || note["quotesCount"] || note["quotedCount"]) + end + + defp misskey_application(%{"app" => %{} = app}), do: app + defp misskey_application(%{"viaMobile" => true}), do: %{"name" => "Misskey Mobile"} + defp misskey_application(_), do: nil + + defp misskey_note_metadata(note) when is_map(note) do + note + |> Map.take([ + "id", + "createdAt", + "cw", + "visibility", + "reactionAcceptance", + "reactionCount", + "reactions", + "renoteCount", + "repliesCount", + "renoteId", + "replyId", + "uri", + "url" + ]) + end + + defp misskey_media_attachments(files) when is_list(files) do + files + |> Enum.map(&misskey_media_attachment/1) + |> Enum.reject(&is_nil/1) + end + + defp misskey_media_attachments(_), do: [] + + defp misskey_media_attachment(%{} = file) do + url = file["url"] || file["uri"] || file["thumbnailUrl"] + + if is_binary(url) and url != "" do + %{ + "id" => to_string(file["id"] || ""), + "type" => misskey_media_type(file, url), + "url" => url, + "preview_url" => file["thumbnailUrl"] || file["sensitiveThumbnailUrl"] || url, + "remote_url" => file["uri"] || url, + "meta" => file["properties"] || %{}, + "description" => file["comment"] || file["name"], + "blurhash" => file["blurhash"] + } + end + end + + defp misskey_media_attachment(_), do: nil + + defp misskey_media_type(file, url) do + media_type = String.downcase(to_string(file["type"] || file["mimeType"] || "")) + url_downcased = String.downcase(url) + + cond do + String.starts_with?(media_type, "video/") -> "video" + String.starts_with?(media_type, "audio/") -> "audio" + String.starts_with?(media_type, "image/gif") -> "gifv" + String.starts_with?(media_type, "image/") -> "image" + String.match?(url_downcased, ~r/\.(mp4|webm|ogv|mov)(\?.*)?$/) -> "video" + String.match?(url_downcased, ~r/\.(mp3|wav|ogg|m4a|flac)(\?.*)?$/) -> "audio" + String.match?(url_downcased, ~r/\.gif(\?.*)?$/) -> "gifv" + true -> "unknown" + end + end + + defp positive_metadata_count(count) when is_integer(count) and count > 0, do: count + defp positive_metadata_count(_), do: nil + + defp misskey_reaction_account(%{"user" => user}) when is_map(user), + do: extract_account_info(user) + + defp misskey_reaction_account(%{"userId" => user_id}) when is_binary(user_id), + do: %{id: user_id} + + defp misskey_reaction_account(_), do: nil + + defp misskey_renote_account(%{"user" => user}) when is_map(user), do: extract_account_info(user) + defp misskey_renote_account(_), do: nil + + defp misskey_note_context_status(note, domain, root_note_id, root_status_url) + when is_map(note) do + counts = misskey_status_counts(note) + note_id = normalize_status_id(note["id"]) + reply_id = normalize_status_id(note["replyId"]) + note_url = misskey_note_url(note) || if(note_id, do: "https://#{domain}/notes/#{note_id}") + + %{ + id: note_id, + uri: note["uri"] || note_url, + url: note_url, + content: misskey_note_content(note), + account: extract_account_info(note["user"]), + favourites_count: counts.favourites_count, + reblogs_count: counts.reblogs_count, + replies_count: counts.replies_count, + created_at: note["createdAt"], + in_reply_to_id: reply_id, + in_reply_to_uri: + cond do + reply_id == normalize_status_id(root_note_id) -> root_status_url + is_binary(reply_id) -> "https://#{domain}/notes/#{reply_id}" + true -> nil + end + } + end + + defp misskey_note_context_status(_note, _domain, _root_note_id, _root_status_url), do: nil + + defp misskey_note_content(note) when is_map(note) do + note["text"] || note["cw"] || "" + end + + defp misskey_note_url(note) when is_map(note) do + note["url"] || note["uri"] || note["href"] + end + + defp misskey_note_url(_), do: nil + + defp nonnegative_count(value) when is_integer(value), do: max(value, 0) + + defp nonnegative_count(value) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {count, _} -> max(count, 0) + :error -> 0 + end + end + + defp nonnegative_count(_), do: 0 + + defp maybe_put_metadata(metadata, _key, nil), do: metadata + defp maybe_put_metadata(metadata, _key, []), do: metadata + defp maybe_put_metadata(metadata, _key, %{} = value) when map_size(value) == 0, do: metadata + defp maybe_put_metadata(metadata, key, value), do: Map.put(metadata, key, value) + + defp fetch_account_list(api_url, opts) do + headers = build_headers() + + case safe_request(:get, api_url, headers, nil, Keyword.merge([receive_timeout: 5_000], opts)) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, accounts} when is_list(accounts) -> + {:ok, Enum.map(accounts, &extract_account_info/1)} + + _ -> + {:error, :parse_error} + end + + {:ok, %Finch.Response{status: status}} -> + {:error, {:http_error, status}} + + {:error, reason} -> + {:error, reason} + end + end + + defp extract_account_info(account) when is_map(account) do + username = account["username"] + host = account["host"] || account["domain"] + + %{ + id: account["id"], + username: username, + acct: account["acct"] || misskey_acct(username, host), + display_name: account["display_name"] || account["name"], + url: account["url"] || account["uri"], + uri: account["uri"] || account["url"], + avatar: account["avatar"] || account["avatarUrl"] + } + end + + defp extract_account_info(_), do: nil + + defp misskey_acct(username, host) when is_binary(username) and is_binary(host) and host != "", + do: "#{username}@#{host}" + + defp misskey_acct(username, _host), do: username + + defp build_headers do + [ + {"Accept", "application/json"}, + {"User-Agent", "Elektrine/1.0 (ActivityPub; +#{Domains.public_base_url()})"} + ] + end + + defp safe_request(method, url, headers, body, opts) do + case Keyword.get(opts, :request_fun) do + request_fun when is_function(request_fun, 5) -> + request_fun.(method, url, headers, body, opts) + + _ -> + request = Finch.build(method, url, headers, body || "") + SafeFetch.request(request, Elektrine.Finch, opts) + end + end + + defp get_activitypub_id(%{activitypub_id: id}) when is_binary(id), do: id + defp get_activitypub_id(%{"activitypub_id" => id}) when is_binary(id), do: id + defp get_activitypub_id(%{"id" => id}) when is_binary(id), do: id + defp get_activitypub_id(_), do: nil + + defp get_activitypub_url(%{activitypub_url: url}) when is_binary(url), do: url + defp get_activitypub_url(%{"activitypub_url" => url}) when is_binary(url), do: url + defp get_activitypub_url(%{"url" => url}) when is_binary(url), do: url + defp get_activitypub_url(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mentions.ex b/apps/elektrine/lib/elektrine/activitypub/mentions.ex new file mode 100644 index 0000000..dab3475 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mentions.ex @@ -0,0 +1,136 @@ +defmodule Elektrine.ActivityPub.Mentions do + @moduledoc """ + Handles mention extraction and federation for ActivityPub. + """ + + require Logger + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.RemoteFetch + alias Elektrine.Domains + + @remote_mention_regex ~r/(^|[^A-Za-z0-9_@\/])@([a-zA-Z0-9_][a-zA-Z0-9_-]*)@((?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63})(?![A-Za-z0-9.-])/u + @local_mention_regex ~r/(^|[^A-Za-z0-9_@\/])@([a-zA-Z0-9_][a-zA-Z0-9_-]*)(?![A-Za-z0-9_@.-])/u + @non_fediverse_mention_regex ~r/(^|[^A-Za-z0-9_@\/])@((?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63})(?![A-Za-z0-9._-])/u + @non_fediverse_handle_regex ~r/(^|[^A-Za-z0-9._%+-@\/])([A-Za-z0-9][A-Za-z0-9._-]{0,62}@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63})(?![A-Za-z0-9._-])/u + + @doc """ + Extracts remote mentions from text content. + Matches @user@domain.com format. + """ + def extract_mentions(content) when is_binary(content) do + Regex.scan(@remote_mention_regex, content) + |> Enum.map(fn [_full, _prefix, username, domain] -> + %{username: username, domain: domain, handle: "#{username}@#{domain}"} + end) + |> Enum.uniq_by(& &1.handle) + end + + def extract_mentions(nil), do: [] + def extract_mentions(_), do: [] + + @doc """ + Extracts local mentions from text content. + Supports both @user and @user@local-domain while ignoring non-federated + handles like @x.com. + """ + def extract_local_mentions(content) when is_binary(content) do + short_mentions = + Regex.scan(@local_mention_regex, content) + |> Enum.map(fn [_full, _prefix, username] -> + %{username: username, handle: username} + end) + + local_federated_mentions = + extract_mentions(content) + |> Enum.filter(fn mention -> Domains.local_activitypub_domain?(mention.domain) end) + |> Enum.map(fn mention -> %{username: mention.username, handle: mention.handle} end) + + (short_mentions ++ local_federated_mentions) + |> Enum.uniq_by(&String.downcase(&1.username)) + end + + def extract_local_mentions(nil), do: [] + def extract_local_mentions(_), do: [] + + @doc """ + Extracts non-federated domain-style handles like @x.com. + """ + def extract_non_fediverse_mentions(content) when is_binary(content) do + domain_only_mentions = + Regex.scan(@non_fediverse_mention_regex, content) + |> Enum.map(fn [_full, _prefix, handle] -> %{handle: handle} end) + + bare_handle_mentions = + Regex.scan(@non_fediverse_handle_regex, content) + |> Enum.map(fn [_full, _prefix, handle] -> %{handle: handle} end) + + (domain_only_mentions ++ bare_handle_mentions) + |> Enum.uniq_by(&String.downcase(&1.handle)) + end + + def extract_non_fediverse_mentions(nil), do: [] + def extract_non_fediverse_mentions(_), do: [] + + @doc """ + Counts mention-like tokens across local, federated, and non-federated handles. + """ + def count_mentions(content) when is_binary(content) do + length(Regex.scan(@local_mention_regex, content)) + + length(Regex.scan(@remote_mention_regex, content)) + + length(Regex.scan(@non_fediverse_mention_regex, content)) + + length(Regex.scan(@non_fediverse_handle_regex, content)) + end + + def count_mentions(_), do: 0 + + @doc """ + Resolves mentions to ActivityPub actor URIs. + Returns {actor_uris, inbox_urls} for including in activities. + """ + def resolve_mentions(content) do + mentions = extract_mentions(content) + + mentions + |> Enum.map(fn mention -> + case RemoteFetch.webfinger_lookup(mention.handle) do + {:ok, actor_uri} -> + case ActivityPub.get_or_fetch_actor(actor_uri) do + {:ok, actor} -> + %{ + uri: actor.uri, + inbox: actor.inbox_url, + handle: mention.handle + } + + {:error, reason} -> + Logger.warning( + "Failed to fetch mentioned actor #{mention.handle}: #{inspect(reason)}" + ) + + nil + end + + {:error, reason} -> + Logger.warning("WebFinger failed for #{mention.handle}: #{inspect(reason)}") + nil + end + end) + |> Enum.filter(&(&1 != nil)) + end + + @doc """ + Gets inbox URLs for mentioned users. + """ + def get_mention_inboxes(content) do + resolved = resolve_mentions(content) + Enum.map(resolved, & &1.inbox) + end + + @doc """ + Gets actor URIs for mentioned users (for cc field). + """ + def get_mention_uris(content) do + resolved = resolve_mentions(content) + Enum.map(resolved, & &1.uri) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf.ex b/apps/elektrine/lib/elektrine/activitypub/mrf.ex new file mode 100644 index 0000000..cf96aff --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf.ex @@ -0,0 +1,164 @@ +defmodule Elektrine.ActivityPub.MRF do + @moduledoc """ + Message Rewrite Facility (MRF) for ActivityPub federation. + + MRF is a policy-based system for filtering, modifying, or rejecting + incoming ActivityPub activities. Policies are applied in order and + can transform or reject activities before they are processed. + + ## Configuration + + config :elektrine, :mrf, + policies: [ + Elektrine.ActivityPub.MRF.SimplePolicy + ], + transparency: true + + ## Built-in Policies + + - `SimplePolicy` - Domain-based accept/reject, media removal, NSFW marking + - `KeywordPolicy` - Filters based on content keywords + - `DropPolicy` - Drops all activities (for testing) + - `NoOpPolicy` - Passes all activities through (default) + """ + + require Logger + + @doc """ + Filters an activity through all configured MRF policies. + + Returns `{:ok, activity}` if the activity passes all policies, + or `{:reject, reason}` if any policy rejects it. + """ + def filter(activity) when is_map(activity) do + policies = get_policies() + + Enum.reduce_while(policies, {:ok, activity}, fn policy, {:ok, acc} -> + case filter_one(policy, acc) do + {:ok, filtered} -> {:cont, {:ok, filtered}} + {:reject, reason} -> {:halt, {:reject, reason}} + end + end) + end + + @doc """ + Filters an activity through a single policy. + """ + def filter_one(policy, activity) do + # Don't filter Undo, Block, or Delete through most policies + # These should generally be processed to maintain consistency + activity_type = activity["type"] + + if activity_type in ["Undo", "Block", "Delete"] and + policy != Elektrine.ActivityPub.MRF.SimplePolicy do + {:ok, activity} + else + try do + policy.filter(activity) + rescue + e -> + Logger.error("MRF policy #{inspect(policy)} crashed: #{inspect(e)}") + {:ok, activity} + end + end + end + + @doc """ + Gets the list of configured MRF policies. + """ + def get_policies do + configured = Application.get_env(:elektrine, :mrf, [])[:policies] || [] + + # Always include these core policies at the end + core_policies = [ + Elektrine.ActivityPub.MRF.AntiFollowbotPolicy, + Elektrine.ActivityPub.MRF.NoPlaceholderTextPolicy, + Elektrine.ActivityPub.MRF.NoEmptyPolicy, + Elektrine.ActivityPub.MRF.HellthreadPolicy, + Elektrine.ActivityPub.MRF.ObjectAgePolicy, + Elektrine.ActivityPub.MRF.SimplePolicy, + Elektrine.ActivityPub.MRF.NormalizePolicy + ] + + (List.wrap(configured) ++ core_policies) + |> Enum.uniq() + end + + @doc """ + Describes the current MRF configuration. + Used for transparency in nodeinfo. + """ + def describe do + policies = get_policies() + + policy_configs = + Enum.reduce(policies, %{}, fn policy, acc -> + case policy.describe() do + {:ok, config} -> Map.merge(acc, config) + _ -> acc + end + end) + + policy_names = + policies + |> Enum.map(fn policy -> + policy + |> to_string() + |> String.split(".") + |> List.last() + end) + + transparency = Application.get_env(:elektrine, :mrf, [])[:transparency] || false + + {:ok, + %{ + mrf_policies: policy_names, + transparency: transparency + } + |> Map.merge(if(transparency, do: policy_configs, else: %{}))} + end + + @doc """ + Checks if a domain matches any in a list of domains/patterns. + Supports wildcard patterns like "*.example.com". + """ + def subdomain_match?(patterns, host) when is_list(patterns) do + Enum.any?(patterns, fn pattern -> + subdomain_match_one?(pattern, host) + end) + end + + defp subdomain_match_one?(pattern, host) do + cond do + # Exact match + pattern == host -> + true + + # Wildcard subdomain match: *.example.com matches foo.example.com + String.starts_with?(pattern, "*.") -> + base = String.trim_leading(pattern, "*.") + host == base or String.ends_with?(host, "." <> base) + + # No match + true -> + false + end + end + + @doc """ + Extracts the host from an actor URI or activity. + """ + def get_actor_host(%{"actor" => actor}) when is_binary(actor) do + URI.parse(actor).host + end + + def get_actor_host(%{"id" => id}) when is_binary(id) do + URI.parse(id).host + end + + def get_actor_host(uri) when is_binary(uri) do + URI.parse(uri).host + end + + def get_actor_host(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/anti_followbot_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/anti_followbot_policy.ex new file mode 100644 index 0000000..c694abf --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/anti_followbot_policy.ex @@ -0,0 +1,85 @@ +defmodule Elektrine.ActivityPub.MRF.AntiFollowbotPolicy do + @moduledoc """ + Rejects obvious remote follow-bot accounts unless the local target follows them. + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + alias Elektrine.Accounts + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor + alias Elektrine.Profiles + + @impl true + def filter(%{"type" => "Follow", "actor" => actor_ref, "object" => object_ref} = activity) do + with actor_uri when is_binary(actor_uri) <- actor_uri(actor_ref), + %Actor{} = actor <- ActivityPub.get_actor_by_uri(actor_uri), + true <- followbot?(actor), + {:ok, target_user} <- local_target_user(object_ref), + false <- Profiles.following_remote_actor_by_identity?(target_user.id, actor) do + {:reject, "[AntiFollowbotPolicy] rejected follow-bot actor #{actor_uri}"} + else + _ -> {:ok, activity} + end + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe, do: {:ok, %{mrf_anti_followbot: true}} + + defp followbot?(%Actor{} = actor), do: followbot_score(actor) >= 0.8 + + defp followbot_score(%Actor{} = actor) do + nickname_score(actor) + display_name_score(actor) + actor_type_score(actor) + end + + defp nickname_score(%Actor{username: username, domain: domain}) + when is_binary(username) and is_binary(domain) do + score_nickname(String.downcase("#{username}@#{domain}")) + end + + defp nickname_score(_actor), do: 0.0 + + defp score_nickname("followbot@" <> _), do: 1.0 + defp score_nickname("federationbot@" <> _), do: 1.0 + defp score_nickname("federation_bot@" <> _), do: 1.0 + defp score_nickname(_nickname), do: 0.0 + + defp display_name_score(%Actor{display_name: display_name}) when is_binary(display_name) do + display_name + |> String.downcase() + |> String.trim() + |> score_display_name() + end + + defp display_name_score(_actor), do: 0.0 + + defp score_display_name("federation bot"), do: 1.0 + defp score_display_name("federationbot"), do: 1.0 + defp score_display_name("fedibot"), do: 1.0 + defp score_display_name(_display_name), do: 0.0 + + defp actor_type_score(%Actor{actor_type: actor_type}) + when actor_type in ["Service", "Application"], + do: 1.0 + + defp actor_type_score(_actor), do: 0.0 + + defp local_target_user(object_ref) do + case object_ref |> actor_uri() |> ActivityPub.local_username_from_uri() do + {:ok, username} when is_binary(username) -> + case Accounts.get_user_by_activitypub_identifier(username) do + nil -> :error + user -> {:ok, user} + end + + _ -> + :error + end + end + + defp actor_uri(uri) when is_binary(uri), do: uri + defp actor_uri(%{"id" => uri}) when is_binary(uri), do: uri + defp actor_uri(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/anti_link_spam_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/anti_link_spam_policy.ex new file mode 100644 index 0000000..c45f078 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/anti_link_spam_policy.ex @@ -0,0 +1,92 @@ +defmodule Elektrine.ActivityPub.MRF.AntiLinkSpamPolicy do + @moduledoc """ + Rejects link posts from unknown or very new remote actors. + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + import Ecto.Query + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.MRF.Utils + alias Elektrine.Repo + alias Elektrine.Social.Message + + @impl true + def filter(%{"type" => "Create", "actor" => actor, "object" => object} = activity) + when is_binary(actor) and is_map(object) do + if Utils.local_actor?(actor) or not contains_external_links?(object) do + {:ok, activity} + else + case remote_actor_reputation(actor) do + :trusted -> {:ok, activity} + :unknown -> {:reject, "[AntiLinkSpamPolicy] unknown remote actor posted links"} + :new -> {:reject, "[AntiLinkSpamPolicy] new remote actor posted links"} + end + end + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe do + {:ok, %{mrf_anti_link_spam: Map.new(config())}} + end + + defp contains_external_links?(object) do + content = Map.get(object, "content", "") + + hrefs = + Regex.scan(~r/]+href=["']([^"']+)["']/i, content, capture: :all_but_first) + |> List.flatten() + |> Enum.reject(&mention_or_hashtag_link?/1) + + plain_urls = + content + |> Utils.strip_html() + |> then(&Regex.scan(~r/https?:\/\/[^\s<>"']+/i, &1)) + |> List.flatten() + + hrefs != [] or plain_urls != [] + end + + defp mention_or_hashtag_link?(href) when is_binary(href) do + String.contains?(href, ["/tags/", "/tag/", "/users/", "/@"]) or String.starts_with?(href, "#") + end + + defp mention_or_hashtag_link?(_), do: false + + defp remote_actor_reputation(actor_uri) do + case ActivityPub.get_actor_by_uri(actor_uri) do + nil -> + :unknown + + %Actor{} = actor -> + cond do + cached_post_count(actor.id) > 0 -> :trusted + old_enough?(actor) -> :trusted + true -> :new + end + end + end + + defp cached_post_count(actor_id) do + Repo.one( + from m in Message, + where: m.remote_actor_id == ^actor_id and is_nil(m.deleted_at), + select: count(m.id) + ) || 0 + end + + defp old_enough?(%Actor{inserted_at: inserted_at}) do + min_age_seconds = Keyword.get(config(), :min_actor_age_seconds, 30 * 60) + DateTime.diff(DateTime.utc_now(), to_datetime(inserted_at), :second) >= min_age_seconds + end + + defp to_datetime(%DateTime{} = datetime), do: datetime + defp to_datetime(%NaiveDateTime{} = datetime), do: DateTime.from_naive!(datetime, "Etc/UTC") + defp to_datetime(_), do: DateTime.utc_now() + + defp config, do: Application.get_env(:elektrine, :mrf_anti_link_spam, []) +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/anti_mention_spam_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/anti_mention_spam_policy.ex new file mode 100644 index 0000000..0ad8459 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/anti_mention_spam_policy.ex @@ -0,0 +1,68 @@ +defmodule Elektrine.ActivityPub.MRF.AntiMentionSpamPolicy do + @moduledoc """ + Rejects mention-heavy posts from unknown or very new remote actors. + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + import Ecto.Query + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.MRF.Utils + alias Elektrine.Repo + alias Elektrine.Social.Message + + @impl true + def filter(%{"type" => "Create", "actor" => actor} = activity) when is_binary(actor) do + if Utils.local_actor?(actor) or Utils.mention_recipients(activity) == [] do + {:ok, activity} + else + case remote_actor_reputation(actor) do + :trusted -> {:ok, activity} + :unknown -> {:reject, "[AntiMentionSpamPolicy] unknown remote actor mentioned users"} + :new -> {:reject, "[AntiMentionSpamPolicy] new remote actor mentioned users"} + end + end + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe do + {:ok, %{mrf_anti_mention_spam: Map.new(config())}} + end + + defp remote_actor_reputation(actor_uri) do + case ActivityPub.get_actor_by_uri(actor_uri) do + nil -> + :unknown + + %Actor{} = actor -> + cond do + cached_post_count(actor.id) > 0 -> :trusted + old_enough?(actor) -> :trusted + true -> :new + end + end + end + + defp cached_post_count(actor_id) do + Repo.one( + from m in Message, + where: m.remote_actor_id == ^actor_id and is_nil(m.deleted_at), + select: count(m.id) + ) || 0 + end + + defp old_enough?(%Actor{inserted_at: inserted_at}) do + min_age_seconds = Keyword.get(config(), :min_actor_age_seconds, 30 * 60) + DateTime.diff(DateTime.utc_now(), to_datetime(inserted_at), :second) >= min_age_seconds + end + + defp to_datetime(%DateTime{} = datetime), do: datetime + defp to_datetime(%NaiveDateTime{} = datetime), do: DateTime.from_naive!(datetime, "Etc/UTC") + defp to_datetime(_), do: DateTime.utc_now() + + defp config, do: Application.get_env(:elektrine, :mrf_anti_mention_spam, []) +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/emoji_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/emoji_policy.ex new file mode 100644 index 0000000..ddfed28 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/emoji_policy.ex @@ -0,0 +1,102 @@ +defmodule Elektrine.ActivityPub.MRF.EmojiPolicy do + @moduledoc """ + Removes, rejects, or delists activities based on custom emoji names/URLs. + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + alias Elektrine.ActivityPub.MRF.Utils + + @impl true + def filter(%{"type" => "EmojiReact"} = activity) do + config = Application.get_env(:elektrine, :mrf_emoji, []) + + if emoji_matches?(activity, config[:remove_url] || [], config[:remove_shortcode] || []) do + {:reject, "[EmojiPolicy] rejected emoji reaction"} + else + {:ok, activity} + end + end + + def filter(%{"type" => type, "object" => object} = activity) + when type in ["Create", "Update"] and is_map(object) do + config = Application.get_env(:elektrine, :mrf_emoji, []) + + object = + object + |> remove_matching(:url, config[:remove_url] || []) + |> remove_matching(:shortcode, config[:remove_shortcode] || []) + + activity = Map.put(activity, "object", object) + + if type == "Create" and + emoji_matches?( + object, + config[:federated_timeline_removal_url] || [], + config[:federated_timeline_removal_shortcode] || [] + ) do + {:ok, Utils.delist(activity)} + else + {:ok, activity} + end + end + + def filter(%{"type" => type} = actor) + when type in ["Person", "Group", "Application", "Service", "Organization"] do + config = Application.get_env(:elektrine, :mrf_emoji, []) + + {:ok, + actor + |> remove_matching(:url, config[:remove_url] || []) + |> remove_matching(:shortcode, config[:remove_shortcode] || [])} + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe do + {:ok, %{mrf_emoji: Application.get_env(:elektrine, :mrf_emoji, []) |> Map.new()}} + end + + defp remove_matching(object, kind, patterns) do + tags = + object + |> Map.get("tag", []) + |> List.wrap() + |> Enum.reject(fn + %{"type" => "Emoji"} = tag -> Utils.matches_any?(emoji_value(tag, kind), patterns) + _ -> false + end) + + emoji = + object + |> Map.get("emoji", %{}) + |> case do + map when is_map(map) -> + Enum.reject(map, fn {name, url} -> + value = if kind == :url, do: to_string(url), else: String.trim(to_string(name), ":") + Utils.matches_any?(value, patterns) + end) + |> Map.new() + + other -> + other + end + + object + |> Map.put("tag", tags) + |> Map.put("emoji", emoji) + end + + defp emoji_matches?(object, url_patterns, shortcode_patterns) do + Enum.any?(Utils.emoji_tags(object), fn tag -> + Utils.matches_any?(emoji_value(tag, :url), url_patterns) or + Utils.matches_any?(emoji_value(tag, :shortcode), shortcode_patterns) + end) + end + + defp emoji_value(%{"icon" => %{"url" => url}}, :url) when is_binary(url), do: url + defp emoji_value(%{"url" => url}, :url) when is_binary(url), do: url + defp emoji_value(%{"name" => name}, :shortcode) when is_binary(name), do: String.trim(name, ":") + defp emoji_value(_tag, _kind), do: "" +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/force_bot_unlisted_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/force_bot_unlisted_policy.ex new file mode 100644 index 0000000..e88600e --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/force_bot_unlisted_policy.ex @@ -0,0 +1,51 @@ +defmodule Elektrine.ActivityPub.MRF.ForceBotUnlistedPolicy do + @moduledoc """ + Converts public posts from likely bot actors to unlisted. + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.MRF.Utils + + @impl true + def filter(%{"type" => "Create", "actor" => actor, "object" => object} = activity) + when is_binary(actor) and is_map(object) do + if Utils.visibility(activity) == "public" and bot_actor?(actor) do + {:ok, Utils.delist(activity)} + else + {:ok, activity} + end + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe, do: {:ok, %{mrf_force_bot_unlisted: true}} + + defp bot_actor?(actor_uri) do + case ActivityPub.get_actor_by_uri(actor_uri) do + %Actor{actor_type: type} when type in ["Application", "Service"] -> + true + + %Actor{username: username} when is_binary(username) -> + bot_username?(username) + + _ -> + actor_uri + |> URI.parse() + |> Map.get(:path) + |> to_string() + |> Path.basename() + |> bot_username?() + end + end + + defp bot_username?(username) when is_binary(username) do + Regex.match?(~r/(^|[-_.])(bot|ebooks)($|[-_.])/i, username) or + Regex.match?(~r/(bot|ebooks)@/i, username) + end + + defp bot_username?(_), do: false +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/hashtag_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/hashtag_policy.ex new file mode 100644 index 0000000..5a39279 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/hashtag_policy.ex @@ -0,0 +1,53 @@ +defmodule Elektrine.ActivityPub.MRF.HashtagPolicy do + @moduledoc """ + Rejects, delists, or marks sensitive posts by hashtag. + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + alias Elektrine.ActivityPub.MRF.Utils + + @impl true + def filter(%{"type" => type, "object" => object} = activity) + when type in ["Create", "Update"] and is_map(object) do + hashtags = Utils.hashtags(object) + config = Application.get_env(:elektrine, :mrf_hashtag, []) + + if intersects?(hashtags, Keyword.get(config, :reject, [])) do + {:reject, "[HashtagPolicy] rejected hashtag"} + else + activity = + activity + |> maybe_delist(type, hashtags, Keyword.get(config, :federated_timeline_removal, [])) + |> maybe_sensitive(hashtags, Keyword.get(config, :sensitive, [])) + + {:ok, activity} + end + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe do + {:ok, %{mrf_hashtag: Application.get_env(:elektrine, :mrf_hashtag, []) |> Map.new()}} + end + + defp maybe_delist(activity, "Create", hashtags, patterns) do + if intersects?(hashtags, patterns), do: Utils.delist(activity), else: activity + end + + defp maybe_delist(activity, _type, _hashtags, _patterns), do: activity + + defp maybe_sensitive(%{"object" => object} = activity, hashtags, patterns) do + if intersects?(hashtags, patterns) do + Map.put(activity, "object", Map.put(object, "sensitive", true)) + else + activity + end + end + + defp intersects?(hashtags, patterns) do + normalized = Enum.map(patterns, &Utils.normalize_hashtag/1) + Enum.any?(hashtags, &(&1 in normalized)) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/hellthread_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/hellthread_policy.ex new file mode 100644 index 0000000..bc96d87 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/hellthread_policy.ex @@ -0,0 +1,138 @@ +defmodule Elektrine.ActivityPub.MRF.HellthreadPolicy do + @moduledoc """ + MRF policy for filtering hellthreads (posts with excessive mentions). + + Hellthreads are posts that mention a large number of users, often used for spam + or harassment. This policy limits the number of mentions allowed in a post. + + ## Configuration + + Configure in runtime.exs or config.exs: + + config :elektrine, :mrf_hellthread, + # Maximum mentions before action is taken + delist_threshold: 10, + reject_threshold: 20 + + ## Actions + + - Posts with mentions >= `delist_threshold` are removed from public timelines + - Posts with mentions >= `reject_threshold` are rejected entirely + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + require Logger + + @default_delist_threshold 10 + @default_reject_threshold 20 + + @impl true + def filter(%{"type" => "Create", "object" => object} = activity) when is_map(object) do + # Count mentions from both activity-level to/cc and object-level tags + mention_count = count_mentions(activity, object) + config = get_config() + + reject_threshold = config[:reject_threshold] || @default_reject_threshold + delist_threshold = config[:delist_threshold] || @default_delist_threshold + + cond do + mention_count >= reject_threshold -> + Logger.info( + "HellthreadPolicy: Rejecting activity with #{mention_count} mentions (threshold: #{reject_threshold})" + ) + + {:reject, "Too many mentions (#{mention_count})"} + + mention_count >= delist_threshold -> + Logger.info( + "HellthreadPolicy: Delisting activity with #{mention_count} mentions (threshold: #{delist_threshold})" + ) + + # Remove from public timelines + object = Map.put(object, "_mrf_federated_timeline_removal", true) + {:ok, Map.put(activity, "object", object)} + + true -> + {:ok, activity} + end + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe do + config = get_config() + + {:ok, + %{ + mrf_hellthread: %{ + delist_threshold: config[:delist_threshold] || @default_delist_threshold, + reject_threshold: config[:reject_threshold] || @default_reject_threshold + } + }} + end + + defp count_mentions(activity, object) do + # Count recipients from activity-level to/cc fields + # (this is where ActivityPub specifies delivery targets) + activity_to_count = count_recipients(activity["to"]) + activity_cc_count = count_recipients(activity["cc"]) + + # Also count recipients from object-level to/cc (some implementations use this) + object_to_count = count_recipients(object["to"]) + object_cc_count = count_recipients(object["cc"]) + + # Count Mention tags in the object + tag_count = count_mention_tags(object["tag"]) + + # Use the maximum of all counts + # (different implementations put mentions in different places) + max( + activity_to_count + activity_cc_count, + max(object_to_count + object_cc_count, tag_count) + ) + end + + defp count_recipients(nil), do: 0 + + defp count_recipients(recipients) when is_list(recipients) do + # Don't count public addresses or followers collections + recipients + |> Enum.reject(fn uri -> + public_address?(uri) || followers_collection?(uri) + end) + |> length() + end + + defp count_recipients(_), do: 0 + + defp count_mention_tags(nil), do: 0 + + defp count_mention_tags(tags) when is_list(tags) do + tags + |> Enum.count(fn tag -> is_map(tag) && tag["type"] == "Mention" end) + end + + defp count_mention_tags(_), do: 0 + + defp public_address?(uri) when is_binary(uri) do + uri in [ + "https://www.w3.org/ns/activitystreams#Public", + "as:Public", + "Public" + ] + end + + defp public_address?(_), do: false + + defp followers_collection?(uri) when is_binary(uri) do + String.ends_with?(uri, "/followers") + end + + defp followers_collection?(_), do: false + + defp get_config do + Application.get_env(:elektrine, :mrf_hellthread, []) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/inline_quote_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/inline_quote_policy.ex new file mode 100644 index 0000000..772521e --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/inline_quote_policy.ex @@ -0,0 +1,56 @@ +defmodule Elektrine.ActivityPub.MRF.InlineQuotePolicy do + @moduledoc """ + Appends a visible quote URL line to quoted posts for clients that ignore quote metadata. + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + @impl true + def filter(%{"object" => %{"quoteUrl" => quote_url} = object} = activity) + when is_binary(quote_url) do + {:ok, Map.put(activity, "object", inline_quote(object, quote_url))} + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe do + {:ok, %{mrf_inline_quote: %{template: template()}}} + end + + defp inline_quote(%{"content" => content} = object, quote_url) when is_binary(content) do + if inline_quote_present?(content, quote_url) do + object + else + Map.put(object, "content", append_quote(content, quote_url)) + end + end + + defp inline_quote(object, quote_url), + do: Map.put(object, "content", append_quote("", quote_url)) + + defp append_quote(content, quote_url) do + quote_line = + template() + |> String.replace("{url}", ~s(#{quote_url})) + |> then(&~s(

    #{&1}
    )) + + if String.ends_with?(content, "

    ") do + String.replace_suffix(content, "

    ", quote_line <> "

    ") + else + content <> quote_line + end + end + + defp inline_quote_present?(content, quote_url) do + String.contains?(content, quote_url) or + String.contains?(content, ~s(class="quote-inline")) or + String.contains?(content, ~s(class='quote-inline')) + end + + defp template do + :elektrine + |> Application.get_env(:mrf_inline_quote, []) + |> Keyword.get(:template, "QT: {url}") + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/keyword_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/keyword_policy.ex new file mode 100644 index 0000000..ba10737 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/keyword_policy.ex @@ -0,0 +1,207 @@ +defmodule Elektrine.ActivityPub.MRF.KeywordPolicy do + @moduledoc """ + MRF policy for filtering content based on keywords and regex patterns. + + ## Configuration + + Configure in runtime.exs or config.exs: + + config :elektrine, :mrf_keyword, + reject: [ + "spam phrase", + ~r/buy.*now/i + ], + federated_timeline_removal: [ + "controversial topic", + ~r/politics/i + ], + replace: [ + {~r/bad word/i, "****"} + ], + mark_sensitive: [ + ~r/nsfw/i, + "adult content" + ] + + ## Actions + + - `reject` - Block the activity entirely + - `federated_timeline_removal` - Remove from federated timeline but allow to followers + - `replace` - Replace matching text with specified replacement + - `mark_sensitive` - Mark the post as sensitive/NSFW + + ## Pattern Types + + - String: Exact case-insensitive match + - Regex: Full regex pattern matching + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + require Logger + + @impl true + def filter(%{"type" => "Create", "object" => object} = activity) when is_map(object) do + with {:ok, object} <- check_reject(object), + {:ok, object} <- check_replace(object), + {:ok, object} <- check_federated_timeline_removal(object), + {:ok, object} <- check_mark_sensitive(object) do + {:ok, Map.put(activity, "object", object)} + end + end + + # Also filter Update activities + def filter(%{"type" => "Update", "object" => object} = activity) when is_map(object) do + with {:ok, object} <- check_reject(object), + {:ok, object} <- check_replace(object), + {:ok, object} <- check_federated_timeline_removal(object), + {:ok, object} <- check_mark_sensitive(object) do + {:ok, Map.put(activity, "object", object)} + end + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe do + config = get_config() + + # Only expose counts in describe, not actual patterns (for security) + {:ok, + %{ + mrf_keyword: %{ + reject_count: length(config[:reject] || []), + federated_timeline_removal_count: length(config[:federated_timeline_removal] || []), + replace_count: length(config[:replace] || []), + mark_sensitive_count: length(config[:mark_sensitive] || []) + } + }} + end + + # Private functions + + defp check_reject(object) do + patterns = get_config()[:reject] || [] + + if matches_any?(object, patterns) do + Logger.info("KeywordPolicy: Rejecting activity due to keyword match") + {:reject, "Blocked by keyword filter"} + else + {:ok, object} + end + end + + defp check_replace(object) do + replacements = get_config()[:replace] || [] + + object = + Enum.reduce(replacements, object, fn {pattern, replacement}, obj -> + apply_replacement(obj, pattern, replacement) + end) + + {:ok, object} + end + + defp check_federated_timeline_removal(object) do + patterns = get_config()[:federated_timeline_removal] || [] + + if matches_any?(object, patterns) do + # Remove from federated timeline by adjusting visibility + # This is done by setting a flag that the timeline queries will check + object = + object + |> Map.put("_mrf_federated_timeline_removal", true) + + {:ok, object} + else + {:ok, object} + end + end + + defp check_mark_sensitive(object) do + patterns = get_config()[:mark_sensitive] || [] + + if matches_any?(object, patterns) do + object = Map.put(object, "sensitive", true) + {:ok, object} + else + {:ok, object} + end + end + + defp matches_any?(object, patterns) do + content = extract_searchable_content(object) + Enum.any?(patterns, fn pattern -> matches_pattern?(content, pattern) end) + end + + defp extract_searchable_content(object) do + # Combine all searchable fields + fields = [ + object["content"], + object["summary"], + object["name"], + # Check hashtags + extract_hashtags(object["tag"]) + ] + + fields + |> Enum.reject(&is_nil/1) + |> Enum.join(" ") + |> strip_html() + |> String.downcase() + end + + defp extract_hashtags(nil), do: "" + + defp extract_hashtags(tags) when is_list(tags) do + tags + |> Enum.filter(fn tag -> is_map(tag) && tag["type"] == "Hashtag" end) + |> Enum.map_join(" ", fn tag -> tag["name"] || "" end) + end + + defp extract_hashtags(_), do: "" + + defp strip_html(nil), do: "" + + defp strip_html(html) do + html + |> String.replace(~r/<[^>]*>/, " ") + |> String.replace(~r/\s+/, " ") + |> String.trim() + end + + defp matches_pattern?(content, %Regex{} = regex) do + Regex.match?(regex, content) + end + + defp matches_pattern?(content, pattern) when is_binary(pattern) do + String.contains?(content, String.downcase(pattern)) + end + + defp matches_pattern?(_, _), do: false + + defp apply_replacement(object, pattern, replacement) do + content = object["content"] + + if content do + new_content = replace_in_text(content, pattern, replacement) + Map.put(object, "content", new_content) + else + object + end + end + + defp replace_in_text(text, %Regex{} = pattern, replacement) do + Regex.replace(pattern, text, replacement) + end + + defp replace_in_text(text, pattern, replacement) when is_binary(pattern) do + # Case-insensitive string replacement + regex = Regex.compile!(Regex.escape(pattern), "i") + Regex.replace(regex, text, replacement) + end + + defp get_config do + Application.get_env(:elektrine, :mrf_keyword, []) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/mention_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/mention_policy.ex new file mode 100644 index 0000000..fd9d635 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/mention_policy.ex @@ -0,0 +1,35 @@ +defmodule Elektrine.ActivityPub.MRF.MentionPolicy do + @moduledoc """ + Rejects Create activities that mention protected actor URIs. + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + alias Elektrine.ActivityPub.MRF.Utils + + @impl true + def filter(%{"type" => "Create"} = activity) do + rejected_actors = + :elektrine + |> Application.get_env(:mrf_mention, []) + |> Keyword.get(:actors, []) + + case Enum.find(Utils.mention_recipients(activity), &(&1 in rejected_actors)) do + actor when is_binary(actor) -> {:reject, "[MentionPolicy] rejected mention of #{actor}"} + _ -> {:ok, activity} + end + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe do + count = + :elektrine + |> Application.get_env(:mrf_mention, []) + |> Keyword.get(:actors, []) + |> length() + + {:ok, %{mrf_mention: %{actor_count: count}}} + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/no_empty_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/no_empty_policy.ex new file mode 100644 index 0000000..2a2e22b --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/no_empty_policy.ex @@ -0,0 +1,48 @@ +defmodule Elektrine.ActivityPub.MRF.NoEmptyPolicy do + @moduledoc """ + Rejects local Note creates/updates that contain no real content. + + This catches accidental posts that are blank or only mention handles, while + allowing media-only posts with attachments. + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + alias Elektrine.ActivityPub.MRF.Utils + + @impl true + def filter(%{"actor" => actor, "object" => %{"type" => "Note"} = object} = activity) + when is_binary(actor) do + if Utils.local_actor?(actor) and Utils.create_or_update?(activity) and empty_note?(object) do + {:reject, "[NoEmptyPolicy] empty local note"} + else + {:ok, activity} + end + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe, do: {:ok, %{mrf_no_empty: true}} + + defp empty_note?(object) do + not has_attachment?(object) and only_mentions_or_blank?(source_text(object)) + end + + defp has_attachment?(%{"attachment" => attachments}) when is_list(attachments), + do: attachments != [] + + defp has_attachment?(_), do: false + + defp source_text(%{"source" => %{"content" => content}}), do: content + defp source_text(%{"source" => source}) when is_binary(source), do: source + defp source_text(%{"content" => content}) when is_binary(content), do: Utils.strip_html(content) + defp source_text(_), do: "" + + defp only_mentions_or_blank?(text) when is_binary(text) do + text + |> String.split(~r/\s+/, trim: true) + |> Enum.reject(&String.starts_with?(&1, "@")) + |> Enum.empty?() + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/no_placeholder_text_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/no_placeholder_text_policy.ex new file mode 100644 index 0000000..e561100 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/no_placeholder_text_policy.ex @@ -0,0 +1,24 @@ +defmodule Elektrine.ActivityPub.MRF.NoPlaceholderTextPolicy do + @moduledoc """ + Removes placeholder content from media posts. + + Some servers send media-only posts with "." or "

    .

    " as filler text. + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + @impl true + def filter( + %{"type" => type, "object" => %{"content" => content, "attachment" => attachments}} = + activity + ) + when type in ["Create", "Update"] and content in [".", "

    .

    "] and is_list(attachments) and + attachments != [] do + {:ok, put_in(activity, ["object", "content"], "")} + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe, do: {:ok, %{mrf_no_placeholder_text: true}} +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/normalize_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/normalize_policy.ex new file mode 100644 index 0000000..61ea9b7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/normalize_policy.ex @@ -0,0 +1,42 @@ +defmodule Elektrine.ActivityPub.MRF.NormalizePolicy do + @moduledoc """ + Normalizes incoming ActivityPub activities. + + This policy runs last and ensures activities have consistent structure: + - Normalizes content markup + - Ensures required fields are present + - Sanitizes potentially dangerous content + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + @impl true + def filter(%{"type" => type, "object" => %{"content" => content} = object} = activity) + when type in ["Create", "Update"] and is_binary(content) do + # Normalize HTML content - strip dangerous tags but preserve safe formatting + sanitized_content = sanitize_html(content) + updated_object = Map.put(object, "content", sanitized_content) + + {:ok, Map.put(activity, "object", updated_object)} + end + + def filter(activity), do: {:ok, activity} + + defp sanitize_html(html) when is_binary(html) do + # Use HtmlSanitizeEx if available, otherwise basic sanitization + if Code.ensure_loaded?(HtmlSanitizeEx) do + HtmlSanitizeEx.basic_html(html) + else + # Basic fallback - strip script tags at minimum + html + |> String.replace(~r/]*>.*?<\/script>/is, "") + |> String.replace(~r/]*>.*?<\/style>/is, "") + |> String.replace(~r/on\w+\s*=/i, "data-removed=") + end + end + + @impl true + def describe do + {:ok, %{normalize_markup: true}} + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/object_age_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/object_age_policy.ex new file mode 100644 index 0000000..4e0a6fa --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/object_age_policy.ex @@ -0,0 +1,125 @@ +defmodule Elektrine.ActivityPub.MRF.ObjectAgePolicy do + @moduledoc """ + MRF policy for filtering objects based on their age. + + This prevents old/backdated content from being imported, which can be used + for spam or to fill timelines with old content. + + ## Configuration + + Configure in runtime.exs or config.exs: + + config :elektrine, :mrf_object_age, + # Maximum age in seconds before action is taken + threshold: 604800, # 7 days + # Actions: :reject, :delist, or :mark_sensitive + actions: [:delist] + + ## Actions + + - `:reject` - Block the activity entirely + - `:delist` - Remove from public timelines + - `:mark_sensitive` - Mark as sensitive content + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + require Logger + + # 7 days in seconds + @default_threshold 604_800 + @default_actions [:delist] + + @impl true + def filter(%{"type" => "Create", "object" => object} = activity) when is_map(object) do + case get_object_age(object) do + {:ok, age_seconds} -> + config = get_config() + threshold = config[:threshold] || @default_threshold + actions = config[:actions] || @default_actions + + if age_seconds > threshold do + apply_actions(activity, object, actions, age_seconds, threshold) + else + {:ok, activity} + end + + :error -> + # Can't determine age, let it through + {:ok, activity} + end + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe do + config = get_config() + + %{ + mrf_object_age: %{ + threshold: config[:threshold] || @default_threshold, + actions: config[:actions] || @default_actions + } + } + end + + defp get_object_age(object) do + with published when is_binary(published) <- object["published"], + {:ok, datetime, _offset} <- DateTime.from_iso8601(published) do + age = DateTime.diff(DateTime.utc_now(), datetime, :second) + {:ok, max(0, age)} + else + _ -> :error + end + end + + defp apply_actions(activity, object, actions, age_seconds, threshold) do + age_days = Float.round(age_seconds / 86_400, 1) + threshold_days = Float.round(threshold / 86_400, 1) + + cond do + :reject in actions -> + Logger.info( + "ObjectAgePolicy: Rejecting #{age_days} day old activity (threshold: #{threshold_days} days)" + ) + + {:reject, "Object too old (#{age_days} days)"} + + :delist in actions or :mark_sensitive in actions -> + Logger.info( + "ObjectAgePolicy: Modifying #{age_days} day old activity (threshold: #{threshold_days} days)" + ) + + object = + object + |> maybe_delist(actions) + |> maybe_mark_sensitive(actions) + + {:ok, Map.put(activity, "object", object)} + + true -> + {:ok, activity} + end + end + + defp maybe_delist(object, actions) do + if :delist in actions do + Map.put(object, "_mrf_federated_timeline_removal", true) + else + object + end + end + + defp maybe_mark_sensitive(object, actions) do + if :mark_sensitive in actions do + Map.put(object, "sensitive", true) + else + object + end + end + + defp get_config do + Application.get_env(:elektrine, :mrf_object_age, []) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/policy.ex new file mode 100644 index 0000000..be1a2e1 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/policy.ex @@ -0,0 +1,50 @@ +defmodule Elektrine.ActivityPub.MRF.Policy do + @moduledoc """ + Behaviour for MRF (Message Rewrite Facility) policies. + + MRF policies can filter, modify, or reject incoming ActivityPub activities. + They are applied in order before any activity processing occurs. + + ## Implementing a Policy + + defmodule MyApp.MRF.MyPolicy do + @behaviour Elektrine.ActivityPub.MRF.Policy + + @impl true + def filter(activity) do + # Return {:ok, activity} to pass through + # Return {:ok, modified_activity} to modify + # Return {:reject, reason} to reject + {:ok, activity} + end + + @impl true + def describe do + {:ok, %{}} + end + end + """ + + @doc """ + Filters an incoming activity. + + Returns: + - `{:ok, activity}` - Activity passes through unchanged + - `{:ok, modified_activity}` - Activity is modified before processing + - `{:reject, reason}` - Activity is rejected with the given reason + """ + @callback filter(activity :: map()) :: {:ok, map()} | {:reject, String.t()} + + @doc """ + Describes the policy's current configuration. + Used for transparency reporting in nodeinfo. + """ + @callback describe() :: {:ok, map()} | {:error, any()} + + @doc """ + Optional: Returns configuration description for admin UI. + """ + @callback config_description() :: map() + + @optional_callbacks config_description: 0 +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/quiet_reply_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/quiet_reply_policy.ex new file mode 100644 index 0000000..b589a89 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/quiet_reply_policy.ex @@ -0,0 +1,34 @@ +defmodule Elektrine.ActivityPub.MRF.QuietReplyPolicy do + @moduledoc """ + Converts local public replies to unlisted replies. + + Replies are still delivered to expected recipients, but do not enter the + federated public timeline by default. + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + alias Elektrine.ActivityPub.MRF.Utils + + @impl true + def filter( + %{ + "type" => "Create", + "actor" => actor, + "to" => to, + "object" => %{"type" => "Note", "inReplyTo" => in_reply_to} + } = activity + ) + when is_binary(actor) and is_binary(in_reply_to) and is_list(to) do + if Utils.local_actor?(actor) and Utils.public_uri() in to do + {:ok, Utils.quiet_reply(activity)} + else + {:ok, activity} + end + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe, do: {:ok, %{mrf_quiet_reply: true}} +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/quote_to_link_tag_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/quote_to_link_tag_policy.ex new file mode 100644 index 0000000..5b46e16 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/quote_to_link_tag_policy.ex @@ -0,0 +1,37 @@ +defmodule Elektrine.ActivityPub.MRF.QuoteToLinkTagPolicy do + @moduledoc """ + Ensures quoted posts also carry an ActivityPub Link tag for compatibility. + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + @activity_json "application/activity+json" + + @impl true + def filter(%{"object" => %{"quoteUrl" => quote_url} = object} = activity) + when is_binary(quote_url) do + {:ok, Map.put(activity, "object", ensure_quote_link_tag(object, quote_url))} + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe, do: {:ok, %{mrf_quote_to_link_tag: true}} + + defp ensure_quote_link_tag(object, quote_url) do + tags = object |> Map.get("tag", []) |> List.wrap() + + if Enum.any?(tags, "e_link_tag?(&1, quote_url)) do + object + else + Map.put( + object, + "tag", + tags ++ [%{"type" => "Link", "mediaType" => @activity_json, "href" => quote_url}] + ) + end + end + + defp quote_link_tag?(%{"type" => "Link", "href" => href}, quote_url), do: href == quote_url + defp quote_link_tag?(_tag, _quote_url), do: false +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/reject_non_public_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/reject_non_public_policy.ex new file mode 100644 index 0000000..6eb2d88 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/reject_non_public_policy.ex @@ -0,0 +1,44 @@ +defmodule Elektrine.ActivityPub.MRF.RejectNonPublicPolicy do + @moduledoc """ + Rejects incoming non-public Create activities unless explicitly allowed. + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + alias Elektrine.ActivityPub.MRF.Utils + + @impl true + def filter(%{"type" => "Create"} = activity) do + config = Application.get_env(:elektrine, :mrf_reject_non_public, []) + + case Utils.visibility(activity) do + visibility when visibility in ["public", "unlisted"] -> + {:ok, activity} + + "followers" -> + if Keyword.get(config, :allow_followers_only, false) do + {:ok, activity} + else + {:reject, "[RejectNonPublicPolicy] followers-only activity rejected"} + end + + "direct" -> + if Keyword.get(config, :allow_direct, false) do + {:ok, activity} + else + {:reject, "[RejectNonPublicPolicy] direct activity rejected"} + end + end + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe do + {:ok, + %{ + mrf_reject_non_public: + Application.get_env(:elektrine, :mrf_reject_non_public, []) |> Map.new() + }} + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/remote_report_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/remote_report_policy.ex new file mode 100644 index 0000000..42c18b1 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/remote_report_policy.ex @@ -0,0 +1,61 @@ +defmodule Elektrine.ActivityPub.MRF.RemoteReportPolicy do + @moduledoc """ + Filters low-quality remote Flag activities. + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + alias Elektrine.ActivityPub.MRF.Utils + + @impl true + def filter(%{"type" => "Flag"} = activity) do + config = Application.get_env(:elektrine, :mrf_remote_report, []) + + cond do + Utils.local_actor?(activity["actor"]) -> + {:ok, activity} + + Keyword.get(config, :reject_all, false) -> + {:reject, "[RemoteReportPolicy] remote reports rejected"} + + Keyword.get(config, :reject_anonymous, false) and anonymous_actor?(activity["actor"]) -> + {:reject, "[RemoteReportPolicy] anonymous remote report rejected"} + + Keyword.get(config, :reject_third_party, false) and third_party_report?(activity) -> + {:reject, "[RemoteReportPolicy] third-party report rejected"} + + Keyword.get(config, :reject_empty_message, false) and empty_content?(activity) -> + {:reject, "[RemoteReportPolicy] empty remote report rejected"} + + true -> + {:ok, activity} + end + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe do + {:ok, + %{mrf_remote_report: Application.get_env(:elektrine, :mrf_remote_report, []) |> Map.new()}} + end + + defp anonymous_actor?(actor) when is_binary(actor) do + URI.parse(actor).path == "/actor" + end + + defp anonymous_actor?(_), do: false + + defp third_party_report?(%{"object" => [reported | _]}) when is_binary(reported), + do: not Utils.local_actor?(reported) + + defp third_party_report?(%{"object" => reported}) when is_binary(reported), + do: not Utils.local_actor?(reported) + + defp third_party_report?(_), do: false + + defp empty_content?(%{"content" => content}) when is_binary(content), + do: String.trim(Utils.strip_html(content)) == "" + + defp empty_content?(_), do: true +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/simple_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/simple_policy.ex new file mode 100644 index 0000000..55075cd --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/simple_policy.ex @@ -0,0 +1,445 @@ +defmodule Elektrine.ActivityPub.MRF.SimplePolicy do + @moduledoc """ + Simple domain-based MRF policy using database-stored instance policies. + + Provides filtering capabilities based on Instance records: + - Reject: Block activities from instance (blocked = true) + - Media removal: Strip media from activities + - Media NSFW: Mark all media as sensitive + - Federated timeline removal: Remove from public timeline + - Followers only: Force posts to followers-only visibility + - Report rejection: Reject Flag activities + - Avatar/Banner removal: Strip profile images + - Reject deletes: Reject Delete activities + + ## Caching + + Instance policies are cached in ETS for performance. Cache is invalidated + when instances are updated via the Federation admin interface. + + ## Fallback + + Also supports config-based lists as fallback: + + config :elektrine, :mrf_simple, + reject: [{"spam.example.com", "Known spam instance"}] + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + alias Elektrine.ActivityPub.Instance + alias Elektrine.ActivityPub.MRF + alias Elektrine.Repo + + import Ecto.Query + + require Logger + + # Cache table name + @cache_table :mrf_simple_policy_cache + # Cache TTL in milliseconds (5 minutes) + @cache_ttl 300_000 + + @impl true + # Handle Delete activities separately - must be before generic actor clause + def filter(%{"type" => "Delete", "actor" => actor} = activity) when is_binary(actor) do + host = MRF.get_actor_host(activity) + + # First check if blocked entirely + if host_has_policy?(host, :blocked) do + {:reject, "[SimplePolicy] host is blocked"} + else + if host_has_policy?(host, :reject_deletes) do + {:reject, "[SimplePolicy] host in reject_deletes list"} + else + {:ok, activity} + end + end + end + + def filter(%{"actor" => actor} = activity) when is_binary(actor) do + host = MRF.get_actor_host(activity) + + with {:ok, _} <- check_reject(host), + {:ok, activity} <- check_media_removal(host, activity), + {:ok, activity} <- check_media_nsfw(host, activity), + {:ok, activity} <- check_ftl_removal(host, activity) do + with {:ok, activity} <- check_followers_only(host, activity) do + check_report_removal(host, activity) + end + end + end + + # Handle actor objects (Person, Group, etc.) + def filter(%{"id" => id, "type" => type} = object) + when type in ["Person", "Group", "Organization", "Service", "Application"] do + host = URI.parse(id).host + + with {:ok, _} <- check_reject(host), + {:ok, object} <- check_avatar_removal(host, object) do + check_banner_removal(host, object) + end + end + + # Pass through anything else + def filter(activity), do: {:ok, activity} + + # Check if host is blocked (reject list) + defp check_reject(host) do + if host_has_policy?(host, :blocked) do + {:reject, "[SimplePolicy] host is blocked"} + else + {:ok, nil} + end + end + + # Strip media attachments from listed domains + defp check_media_removal( + host, + %{"type" => type, "object" => %{"attachment" => attachments} = object} = activity + ) + when type in ["Create", "Update"] and is_list(attachments) and attachments != [] do + if host_has_policy?(host, :media_removal) do + Logger.info("[SimplePolicy] Removing media from #{host}") + updated_object = Map.delete(object, "attachment") + {:ok, Map.put(activity, "object", updated_object)} + else + {:ok, activity} + end + end + + defp check_media_removal(_host, activity), do: {:ok, activity} + + # Mark all media as sensitive from listed domains + defp check_media_nsfw(host, %{"type" => type, "object" => object} = activity) + when type in ["Create", "Update"] and is_map(object) do + if host_has_policy?(host, :media_nsfw) do + Logger.debug("[SimplePolicy] Marking content from #{host} as sensitive") + updated_object = Map.put(object, "sensitive", true) + {:ok, Map.put(activity, "object", updated_object)} + else + {:ok, activity} + end + end + + defp check_media_nsfw(_host, activity), do: {:ok, activity} + + # Remove from federated timeline (move Public to CC). + # Also applies for legacy :silenced policies. + defp check_ftl_removal(host, activity) do + public_uri = "https://www.w3.org/ns/activitystreams#Public" + + if host_has_policy?(host, :federated_timeline_removal) or host_has_policy?(host, :silenced) do + updated_activity = + update_visibility_targets(activity, fn to, cc -> + move_public_to_cc(to, cc, public_uri) + end) + + if updated_activity != activity do + Logger.debug("[SimplePolicy] Removing #{host} activity from federated timeline") + end + + {:ok, updated_activity} + else + {:ok, activity} + end + end + + # Force posts to followers-only visibility + defp check_followers_only(host, %{"actor" => actor} = activity) when is_binary(actor) do + public_uri = "https://www.w3.org/ns/activitystreams#Public" + + if host_has_policy?(host, :followers_only) do + follower_collection = "#{actor}/followers" + + updated_activity = + update_visibility_targets(activity, fn to, cc -> + force_followers_only(to, cc, public_uri, follower_collection) + end) + + if updated_activity != activity do + Logger.debug("[SimplePolicy] Forcing #{host} activity to followers-only") + end + + {:ok, updated_activity} + else + {:ok, activity} + end + end + + defp check_followers_only(_host, activity), do: {:ok, activity} + + defp ensure_contains(list, item) do + if item in list, do: list, else: [item | list] + end + + defp move_public_to_cc(to, cc, public_uri) do + if public_uri in to do + {List.delete(to, public_uri), if(public_uri in cc, do: cc, else: [public_uri | cc])} + else + {to, cc} + end + end + + defp force_followers_only(to, cc, public_uri, follower_collection) do + if public_uri in to or public_uri in cc do + { + to |> List.delete(public_uri) |> ensure_contains(follower_collection), + List.delete(cc, public_uri) + } + else + {to, cc} + end + end + + defp update_visibility_targets(%{"object" => object} = activity, updater) when is_map(object) do + activity + |> update_targets(updater) + |> Map.put("object", update_targets(object, updater)) + end + + defp update_visibility_targets(activity, updater), do: update_targets(activity, updater) + + defp update_targets(%{"to" => to, "cc" => cc} = value, updater) + when is_list(to) and is_list(cc) do + {updated_to, updated_cc} = updater.(to, cc) + + value + |> Map.put("to", updated_to) + |> Map.put("cc", updated_cc) + end + + defp update_targets(value, _updater), do: value + + # Reject reports from listed domains + defp check_report_removal(host, %{"type" => "Flag"} = activity) do + if host_has_policy?(host, :report_removal) do + {:reject, "[SimplePolicy] host in report_removal list"} + else + {:ok, activity} + end + end + + defp check_report_removal(_host, activity), do: {:ok, activity} + + # Strip avatars from listed domains + defp check_avatar_removal(host, %{"icon" => _} = object) do + if host_has_policy?(host, :avatar_removal) do + {:ok, Map.delete(object, "icon")} + else + {:ok, object} + end + end + + defp check_avatar_removal(_host, object), do: {:ok, object} + + # Strip banners from listed domains + defp check_banner_removal(host, %{"image" => _} = object) do + if host_has_policy?(host, :banner_removal) do + {:ok, Map.delete(object, "image")} + else + {:ok, object} + end + end + + defp check_banner_removal(_host, object), do: {:ok, object} + + @doc """ + Checks if a host has a specific policy active. + Uses cached data for performance with fallback to database. + """ + def host_has_policy?(host, policy_type) when is_binary(host) and is_atom(policy_type) do + # Check cache first + case get_cached_policies(host) do + {:ok, policies} -> + Map.get(policies, policy_type, false) + + :miss -> + # Cache miss - load from database + policies = load_policies_for_host(host) + cache_policies(host, policies) + Map.get(policies, policy_type, false) + end + end + + defp get_cached_policies(host) do + ensure_cache_table_exists() + + case :ets.lookup(@cache_table, host) do + [{^host, policies, cached_at}] -> + if System.monotonic_time(:millisecond) - cached_at < @cache_ttl do + {:ok, policies} + else + :miss + end + + [] -> + :miss + end + end + + defp cache_policies(host, policies) do + ensure_cache_table_exists() + :ets.insert(@cache_table, {host, policies, System.monotonic_time(:millisecond)}) + end + + defp ensure_cache_table_exists do + if :ets.whereis(@cache_table) == :undefined do + try do + :ets.new(@cache_table, [:named_table, :public, :set, {:read_concurrency, true}]) + rescue + ArgumentError -> + @cache_table + end + end + end + + @doc """ + Invalidates the cache for a specific host or all hosts. + Call this when instance policies are updated. + """ + def invalidate_cache(host \\ nil) do + ensure_cache_table_exists() + + if host do + :ets.delete(@cache_table, host) + :ets.delete(@cache_table, :db_policy_instances) + else + :ets.delete_all_objects(@cache_table) + end + + :ok + end + + # Load policies from database for a specific host + defp load_policies_for_host(host) do + # Get all instances that could match this host (exact match or wildcard) + instances = get_matching_instances(host) + + # Merge policies from all matching instances + Enum.reduce(instances, %{}, fn instance, acc -> + Instance.policy_fields() + |> Enum.reduce(acc, fn field, inner_acc -> + if Map.get(instance, field, false) do + Map.put(inner_acc, field, true) + else + inner_acc + end + end) + end) + |> merge_config_policies(host) + end + + # Get instances matching this host (supports wildcards) + defp get_matching_instances(host) do + host = String.downcase(host || "") + + Enum.filter(db_policy_instances(), fn instance -> + Instance.matches_domain?(instance, host) + end) + end + + defp db_policy_instances do + ensure_cache_table_exists() + now = System.monotonic_time(:millisecond) + + case :ets.lookup(@cache_table, :db_policy_instances) do + [{:db_policy_instances, instances, timestamp}] when now - timestamp < @cache_ttl -> + instances + + _ -> + instances = load_db_policy_instances() + :ets.insert(@cache_table, {:db_policy_instances, instances, now}) + instances + end + end + + defp load_db_policy_instances do + policy_filter = + Instance.policy_fields() + |> Enum.map(&dynamic([i], field(i, ^&1) == true)) + |> Enum.reduce(fn condition, acc -> dynamic([i], ^acc or ^condition) end) + + Repo.all(from(i in Instance, where: ^policy_filter)) + end + + # Merge with config-based policies as fallback + defp merge_config_policies(db_policies, host) do + config = Application.get_env(:elektrine, :mrf_simple, []) + + config_policies = + [ + {:blocked, :reject}, + {:silenced, :silence}, + {:silenced, :silenced}, + {:media_removal, :media_removal}, + {:media_nsfw, :media_nsfw}, + {:federated_timeline_removal, :federated_timeline_removal}, + {:followers_only, :followers_only}, + {:report_removal, :report_removal}, + {:reject_deletes, :reject_deletes}, + {:avatar_removal, :avatar_removal}, + {:banner_removal, :banner_removal} + ] + |> Enum.reduce(%{}, fn {policy_field, config_key}, acc -> + domains = extract_domains(config[config_key] || []) + + if MRF.subdomain_match?(domains, host) do + Map.put(acc, policy_field, true) + else + acc + end + end) + + Map.merge(config_policies, db_policies) + end + + # Config can be list of strings or list of {domain, reason} tuples + defp extract_domains(list) when is_list(list) do + Enum.map(list, fn + {domain, _reason} -> domain + domain when is_binary(domain) -> domain + end) + end + + defp extract_domains(_), do: [] + + @impl true + def describe do + # Get all instances with policies + instances = + from(i in Instance, + where: + i.blocked == true or + i.silenced == true or + i.media_removal == true or + i.media_nsfw == true or + i.federated_timeline_removal == true or + i.followers_only == true or + i.report_removal == true or + i.avatar_removal == true or + i.banner_removal == true or + i.reject_deletes == true + ) + |> Repo.all() + + # Only expose if transparency is enabled + if Application.get_env(:elektrine, :mrf, [])[:transparency] do + policy_data = + Instance.policy_fields() + |> Enum.map(fn field -> + domains = + instances + |> Enum.filter(fn i -> Map.get(i, field, false) end) + |> Enum.map(& &1.domain) + + {field, domains} + end) + |> Map.new() + + {:ok, %{mrf_simple: policy_data}} + else + {:ok, %{}} + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/user_allow_list_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/user_allow_list_policy.ex new file mode 100644 index 0000000..1074a37 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/user_allow_list_policy.ex @@ -0,0 +1,67 @@ +defmodule Elektrine.ActivityPub.MRF.UserAllowListPolicy do + @moduledoc """ + Restricts selected remote domains to configured actor allowlists. + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + @impl true + def filter(%{"actor" => actor} = activity) when is_binary(actor) do + with host when is_binary(host) <- actor_host(actor), + allow_list when is_list(allow_list) and allow_list != [] <- allow_list_for_host(host) do + if actor in allow_list do + {:ok, activity} + else + {:reject, "[UserAllowListPolicy] #{actor} not in allowlist for #{host}"} + end + else + _ -> {:ok, activity} + end + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe do + hosts = + :elektrine + |> Application.get_env(:mrf_user_allowlist, []) + |> Keyword.get(:hosts, %{}) + |> Map.new(fn {host, actors} -> {host, length(List.wrap(actors))} end) + + {:ok, %{mrf_user_allowlist: hosts}} + end + + defp allow_list_for_host(host) do + config = Application.get_env(:elektrine, :mrf_user_allowlist, []) + + config + |> Keyword.get(:hosts, %{}) + |> get_host_value(host) + end + + defp get_host_value(map, host) when is_map(map) do + Enum.find_value(map, [], fn {configured_host, actors} -> + if host_key(configured_host) == host, do: actors + end) + end + + defp get_host_value(keyword, host) when is_list(keyword) do + Enum.find_value(keyword, [], fn {configured_host, actors} -> + if host_key(configured_host) == host, do: actors + end) + end + + defp get_host_value(_config, _host), do: [] + + defp host_key(host) when is_binary(host), do: String.downcase(host) + defp host_key(host) when is_atom(host), do: host |> Atom.to_string() |> String.downcase() + defp host_key(_host), do: nil + + defp actor_host(actor) do + case URI.parse(actor) do + %URI{host: host} when is_binary(host) -> String.downcase(host) + _ -> nil + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/utils.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/utils.ex new file mode 100644 index 0000000..4f13c8f --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/utils.ex @@ -0,0 +1,180 @@ +defmodule Elektrine.ActivityPub.MRF.Utils do + @moduledoc false + + @public_uri "https://www.w3.org/ns/activitystreams#Public" + + def public_uri, do: @public_uri + + def create_or_update?(%{"type" => type}), do: type in ["Create", "Update"] + def create_or_update?(_), do: false + + def local_actor?(actor) when is_binary(actor) do + String.starts_with?(actor, Elektrine.ActivityPub.instance_url()) + end + + def local_actor?(_), do: false + + def recipients(value) when is_list(value), do: value + def recipients(value) when is_binary(value), do: [value] + def recipients(_), do: [] + + def mention_recipients(activity) do + actor = activity["actor"] || get_in(activity, ["object", "actor"]) + followers = if is_binary(actor), do: actor <> "/followers", else: nil + + activity_recipients = + recipients(activity["to"]) ++ recipients(activity["cc"]) + + object_recipients = + case activity["object"] do + object when is_map(object) -> recipients(object["to"]) ++ recipients(object["cc"]) + _ -> [] + end + + (activity_recipients ++ object_recipients) + |> Enum.reject(&(&1 in [@public_uri, followers, nil])) + |> Enum.uniq() + end + + def visibility(%{"actor" => actor} = activity) when is_binary(actor) do + to = recipients(activity["to"]) + cc = recipients(activity["cc"]) + followers = actor <> "/followers" + + cond do + @public_uri in to -> "public" + @public_uri in cc -> "unlisted" + followers in to -> "followers" + true -> "direct" + end + end + + def visibility(_activity), do: "direct" + + def delist(activity) do + update_visibility_targets(activity, fn to, cc -> + {List.delete(to, @public_uri), Enum.uniq([@public_uri | cc])} + end) + end + + def quiet_reply(%{"actor" => actor} = activity) when is_binary(actor) do + followers = actor <> "/followers" + + update_visibility_targets(activity, fn to, cc -> + { + [followers | to] |> List.delete(@public_uri) |> Enum.uniq(), + [@public_uri | cc] |> List.delete(followers) |> Enum.uniq() + } + end) + end + + def quiet_reply(activity), do: activity + + def update_visibility_targets(%{"object" => object} = activity, updater) when is_map(object) do + activity + |> update_targets(updater) + |> Map.put("object", update_targets(object, updater)) + end + + def update_visibility_targets(activity, updater), do: update_targets(activity, updater) + + def update_targets(value, updater) when is_map(value) do + to = recipients(value["to"]) + cc = recipients(value["cc"]) + {updated_to, updated_cc} = updater.(to, cc) + + value + |> Map.put("to", updated_to) + |> Map.put("cc", updated_cc) + end + + def update_targets(value, _updater), do: value + + def hashtags(value) when is_map(value) do + tag_hashtags = + value + |> Map.get("tag", []) + |> List.wrap() + |> Enum.flat_map(fn + %{"type" => "Hashtag", "name" => name} when is_binary(name) -> [normalize_hashtag(name)] + %{"type" => "Hashtag", "href" => href} when is_binary(href) -> [normalize_hashtag(href)] + _ -> [] + end) + + extracted = + value + |> Map.get("content", "") + |> strip_html() + |> then(&Regex.scan(~r/(?:^|\s)#([\p{L}\p{N}_-]+)/u, &1, capture: :all_but_first)) + |> List.flatten() + |> Enum.map(&normalize_hashtag/1) + + (tag_hashtags ++ extracted) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + def hashtags(_), do: [] + + def emoji_tags(value) when is_map(value) do + tags = + value + |> Map.get("tag", []) + |> List.wrap() + |> Enum.filter(&match?(%{"type" => "Emoji"}, &1)) + + emoji_map = + value + |> Map.get("emoji", %{}) + |> case do + map when is_map(map) -> + Enum.map(map, fn {name, url} -> + %{ + "type" => "Emoji", + "name" => ":" <> String.trim(to_string(name), ":") <> ":", + "icon" => %{"url" => url} + } + end) + + _ -> + [] + end + + tags ++ emoji_map + end + + def emoji_tags(_), do: [] + + def strip_html(value) when is_binary(value) do + value + |> String.replace(~r/<[^>]*>/, " ") + |> String.replace(~r/\s+/, " ") + |> String.trim() + end + + def strip_html(_), do: "" + + def matches_any?(value, patterns) when is_binary(value) and is_list(patterns) do + Enum.any?(patterns, &matches?(value, &1)) + end + + def matches_any?(_value, _patterns), do: false + + def matches?(value, %Regex{} = pattern), do: Regex.match?(pattern, value) + + def matches?(value, pattern) when is_binary(pattern) do + String.downcase(value) == String.downcase(pattern) + end + + def matches?(_value, _pattern), do: false + + def normalize_hashtag(value) when is_binary(value) do + value + |> String.split("/") + |> List.last() + |> String.trim_leading("#") + |> String.downcase() + end + + def normalize_hashtag(_), do: "" +end diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/vocabulary_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/vocabulary_policy.ex new file mode 100644 index 0000000..d898b5c --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/mrf/vocabulary_policy.ex @@ -0,0 +1,43 @@ +defmodule Elektrine.ActivityPub.MRF.VocabularyPolicy do + @moduledoc """ + Allows or rejects ActivityStreams vocabulary terms by type. + """ + + @behaviour Elektrine.ActivityPub.MRF.Policy + + @impl true + def filter(%{"type" => "Undo", "object" => object} = activity) do + with {:ok, _object} <- filter(object) do + {:ok, activity} + end + end + + def filter(%{"type" => type} = activity) do + config = Application.get_env(:elektrine, :mrf_vocabulary, []) + accept = Keyword.get(config, :accept, []) + reject = Keyword.get(config, :reject, []) + + cond do + accept != [] and type not in accept -> + {:reject, "[VocabularyPolicy] #{type} not in accept list"} + + type in reject -> + {:reject, "[VocabularyPolicy] #{type} in reject list"} + + is_map(activity["object"]) -> + with {:ok, _object} <- filter(activity["object"]) do + {:ok, activity} + end + + true -> + {:ok, activity} + end + end + + def filter(activity), do: {:ok, activity} + + @impl true + def describe do + {:ok, %{mrf_vocabulary: Application.get_env(:elektrine, :mrf_vocabulary, []) |> Map.new()}} + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/nodeinfo.ex b/apps/elektrine/lib/elektrine/activitypub/nodeinfo.ex new file mode 100644 index 0000000..2793740 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/nodeinfo.ex @@ -0,0 +1,250 @@ +defmodule Elektrine.ActivityPub.Nodeinfo do + @moduledoc """ + Fetches and caches nodeinfo from ActivityPub instances to detect software type. + """ + + use GenServer + require Logger + + alias Elektrine.HTTP.SafeFetch + + @cache_ttl :timer.hours(24) + + def start_link(_opts) do + GenServer.start_link(__MODULE__, %{}, name: __MODULE__) + end + + @impl true + def init(_) do + {:ok, %{cache: %{}}} + end + + @doc """ + Get the software name for a domain. Caches results. + """ + def get_software(domain) do + GenServer.call(__MODULE__, {:get_software, domain}, 10_000) + catch + :exit, _ -> {:error, :timeout} + end + + @doc """ + Batch lookup software for multiple domains. Much faster than calling get_software repeatedly. + Returns a map of domain => software_name (lowercase). + Domains that fail to resolve are omitted from the result. + """ + def get_software_batch(domains) when is_list(domains) do + domains = Enum.uniq(domains) + + # First, get all cached results in one GenServer call + {cached, uncached} = GenServer.call(__MODULE__, {:get_batch_cached, domains}, 10_000) + + fetched = fetch_and_cache_software_batch(uncached) + + # Merge cached and fetched results + Map.merge(cached, fetched) + catch + :exit, _ -> %{} + end + + @doc """ + Returns cached software names immediately and refreshes uncached domains in the background. + + Use this on request/render paths where waiting on remote nodeinfo endpoints would make the UI + feel stuck. + """ + def get_cached_software_batch(domains) when is_list(domains) do + domains = Enum.uniq(domains) + {cached, uncached} = GenServer.call(__MODULE__, {:get_batch_cached, domains}, 500) + + if uncached != [] do + Task.start(fn -> fetch_and_cache_software_batch(uncached) end) + end + + cached + catch + :exit, _ -> %{} + end + + @impl true + def handle_call({:get_software, domain}, _from, state) do + case check_cache(state.cache, domain) do + {:ok, software} -> + {:reply, {:ok, software}, state} + + :miss -> + case fetch_nodeinfo(domain) do + {:ok, software} -> + new_cache = + Map.put(state.cache, domain, {software, System.monotonic_time(:millisecond)}) + + {:reply, {:ok, software}, %{state | cache: new_cache}} + + {:error, reason} -> + # Cache the failure too to avoid repeated requests + new_cache = + Map.put(state.cache, domain, {:unknown, System.monotonic_time(:millisecond)}) + + {:reply, {:error, reason}, %{state | cache: new_cache}} + end + + {:error, :unknown} -> + {:reply, {:error, :unknown}, state} + end + end + + @impl true + def handle_call({:get_batch_cached, domains}, _from, state) do + # Partition domains into cached (with valid results) and uncached + {cached_map, uncached} = + Enum.reduce(domains, {%{}, []}, fn domain, {cached, uncached} -> + case check_cache(state.cache, domain) do + {:ok, software} -> + {Map.put(cached, domain, String.downcase(software)), uncached} + + :miss -> + {cached, [domain | uncached]} + + {:error, :unknown} -> + # Already tried and failed, skip + {cached, uncached} + end + end) + + {:reply, {cached_map, Enum.reverse(uncached)}, state} + end + + @impl true + def handle_cast({:cache_batch, results}, state) when is_map(results) do + now = System.monotonic_time(:millisecond) + + new_cache = + Enum.reduce(results, state.cache, fn {domain, software}, cache -> + Map.put(cache, domain, {software, now}) + end) + + {:noreply, %{state | cache: new_cache}} + end + + @impl true + def handle_cast({:cache_failures, domains}, state) when is_list(domains) do + now = System.monotonic_time(:millisecond) + + new_cache = + Enum.reduce(domains, state.cache, fn domain, cache -> + Map.put(cache, domain, {:unknown, now}) + end) + + {:noreply, %{state | cache: new_cache}} + end + + defp check_cache(cache, domain) do + case Map.get(cache, domain) do + nil -> + :miss + + {:unknown, _timestamp} -> + {:error, :unknown} + + {software, timestamp} -> + age = System.monotonic_time(:millisecond) - timestamp + + if age < @cache_ttl do + {:ok, software} + else + :miss + end + end + end + + defp fetch_and_cache_software_batch([]), do: %{} + + defp fetch_and_cache_software_batch(domains) do + fetched = + domains + |> Task.async_stream( + fn domain -> + result = fetch_nodeinfo(domain) + {domain, result} + end, + max_concurrency: 20, + timeout: 6000, + on_timeout: :kill_task + ) + |> Enum.reduce(%{}, fn + {:ok, {domain, {:ok, software}}}, acc -> + Map.put(acc, domain, String.downcase(software)) + + {:ok, {_domain, {:error, _}}}, acc -> + acc + + {:exit, _reason}, acc -> + acc + end) + + if map_size(fetched) > 0 do + GenServer.cast(__MODULE__, {:cache_batch, fetched}) + end + + failed = domains -- Map.keys(fetched) + + if failed != [] do + GenServer.cast(__MODULE__, {:cache_failures, failed}) + end + + fetched + end + + defp fetch_nodeinfo(domain) do + # First fetch the well-known nodeinfo to get the actual nodeinfo URL + well_known_url = "https://#{domain}/.well-known/nodeinfo" + + with {:ok, %Finch.Response{status: 200, body: body}} <- + Finch.build(:get, well_known_url, [{"Accept", "application/json"}]) + |> SafeFetch.request(Elektrine.Finch, receive_timeout: 5000, max_body_bytes: 50_000), + {:ok, data} <- Jason.decode(body), + nodeinfo_url when is_binary(nodeinfo_url) <- get_nodeinfo_url(data), + {:ok, software} <- fetch_software_from_nodeinfo(nodeinfo_url) do + {:ok, software} + else + error -> + Logger.debug("Failed to fetch nodeinfo for #{domain}: #{inspect(error)}") + {:error, :fetch_failed} + end + end + + defp get_nodeinfo_url(%{"links" => links}) when is_list(links) do + # Prefer 2.1, then 2.0 + link = + Enum.find(links, fn l -> + l["rel"] in [ + "http://nodeinfo.diaspora.software/ns/schema/2.1", + "http://nodeinfo.diaspora.software/ns/schema/2.0" + ] + end) + + case link do + %{"href" => href} -> href + _ -> nil + end + end + + defp get_nodeinfo_url(_), do: nil + + defp fetch_software_from_nodeinfo(url) do + case Finch.build(:get, url, [{"Accept", "application/json"}]) + |> SafeFetch.request(Elektrine.Finch, receive_timeout: 5000, max_body_bytes: 50_000) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, %{"software" => %{"name" => name}}} when is_binary(name) -> + {:ok, String.downcase(name)} + + _ -> + {:error, :invalid_nodeinfo} + end + + _ -> + {:error, :fetch_failed} + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/nodeinfo_fetcher_worker.ex b/apps/elektrine/lib/elektrine/activitypub/nodeinfo_fetcher_worker.ex new file mode 100644 index 0000000..10398c3 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/nodeinfo_fetcher_worker.ex @@ -0,0 +1,305 @@ +defmodule Elektrine.ActivityPub.NodeInfoFetcherWorker do + @moduledoc """ + Oban worker for fetching and storing NodeInfo metadata from remote instances. + + This worker fetches: + - NodeInfo (software name, version, user stats, protocols) + - Favicon + + Jobs are deduplicated by domain to avoid redundant fetches. + Metadata is refreshed at most once per 24 hours. + """ + use Oban.Worker, + queue: :federation_metadata, + max_attempts: 3, + unique: [ + keys: [:domain], + period: :timer.hours(1), + states: [:available, :scheduled, :executing] + ] + + require Logger + + alias Elektrine.ActivityPub.{Instance, Instances} + alias Elektrine.HTTP.SafeFetch + alias Elektrine.Repo + alias Elektrine.Security.SafeExternalURL + + @doc """ + Enqueues a job to fetch nodeinfo for a domain, if needed. + Returns :ok if enqueued or skipped (already up to date). + """ + def enqueue(domain) when is_binary(domain) do + domain = normalize_domain(domain) + + case Instances.get_instance(domain) do + %Instance{} = instance -> + if Instance.needs_metadata_update?(instance) do + do_enqueue(domain) + else + :ok + end + + nil -> + do_enqueue(domain) + end + end + + @doc """ + Enqueues a job to fetch nodeinfo for a domain extracted from a URL or URI. + """ + def enqueue_from_url(url) when is_binary(url) do + case URI.parse(url) do + %URI{host: host} when is_binary(host) and host != "" -> + enqueue(host) + + _ -> + {:error, :invalid_url} + end + end + + def enqueue_from_url(%URI{host: host}) when is_binary(host) and host != "" do + enqueue(host) + end + + def enqueue_from_url(_), do: {:error, :invalid_url} + + @doc false + def normalize_favicon_url(href, domain) when is_binary(href) and is_binary(domain) do + href + |> String.trim() + |> favicon_url_for_domain(domain) + |> normalize_external_favicon_url() + end + + def normalize_favicon_url(_href, _domain), do: nil + + defp do_enqueue(domain) do + %{domain: domain} + |> new() + |> Elektrine.JobQueue.insert() + end + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"domain" => domain}}) do + Logger.debug("[NodeInfoFetcher] Fetching metadata for #{domain}") + + # Check reachability first + if Instances.reachable?(domain) do + fetch_and_store_metadata(domain) + else + Logger.debug("[NodeInfoFetcher] Skipping unreachable instance #{domain}") + {:discard, :unreachable} + end + end + + defp fetch_and_store_metadata(domain) do + # Fetch nodeinfo and favicon in parallel + nodeinfo_task = Task.async(fn -> fetch_nodeinfo(domain) end) + favicon_task = Task.async(fn -> fetch_favicon(domain) end) + + nodeinfo = Task.await(nodeinfo_task, 10_000) + favicon = Task.await(favicon_task, 10_000) + + # Get or create instance record + {:ok, instance} = Instances.get_or_create_instance(domain) + + # Update with metadata + attrs = %{ + nodeinfo: nodeinfo || %{}, + favicon: favicon, + metadata_updated_at: DateTime.utc_now() |> DateTime.truncate(:second) + } + + case instance + |> Instance.metadata_changeset(attrs) + |> Repo.update() do + {:ok, updated} -> + software = Instance.software_name(updated) + Logger.info("[NodeInfoFetcher] Updated #{domain}: software=#{software || "unknown"}") + :ok + + {:error, changeset} -> + Logger.warning( + "[NodeInfoFetcher] Failed to update #{domain}: #{inspect(changeset.errors)}" + ) + + {:error, :update_failed} + end + rescue + e -> + Logger.warning("[NodeInfoFetcher] Error fetching #{domain}: #{Exception.message(e)}") + {:error, :fetch_failed} + end + + defp fetch_nodeinfo(domain) do + well_known_url = "https://#{domain}/.well-known/nodeinfo" + + with {:ok, %Finch.Response{status: 200, body: body}} <- + Finch.build(:get, well_known_url, [{"Accept", "application/json"}]) + |> SafeFetch.request(Elektrine.Finch, receive_timeout: 5000, max_body_bytes: 50_000), + {:ok, data} <- Jason.decode(body), + nodeinfo_url when is_binary(nodeinfo_url) <- get_nodeinfo_url(data), + {:ok, nodeinfo} <- fetch_nodeinfo_document(nodeinfo_url) do + nodeinfo + else + error -> + Logger.debug( + "[NodeInfoFetcher] Failed to fetch nodeinfo for #{domain}: #{inspect(normalize_fetch_error(error))}" + ) + + nil + end + end + + defp get_nodeinfo_url(%{"links" => links}) when is_list(links) do + # Prefer 2.1, then 2.0 + Enum.find_value(links, fn + %{"rel" => rel, "href" => href} when is_binary(href) -> + if rel in [ + "http://nodeinfo.diaspora.software/ns/schema/2.1", + "http://nodeinfo.diaspora.software/ns/schema/2.0" + ] do + href + end + + _ -> + nil + end) + end + + defp get_nodeinfo_url(_), do: nil + + defp fetch_nodeinfo_document(url) do + case Finch.build(:get, url, [{"Accept", "application/json"}]) + |> SafeFetch.request(Elektrine.Finch, receive_timeout: 5000, max_body_bytes: 50_000) do + {:ok, %Finch.Response{status: 200, body: body}} when byte_size(body) < 50_000 -> + Jason.decode(body) + + {:ok, %Finch.Response{status: 200, body: body}} -> + Logger.debug("[NodeInfoFetcher] NodeInfo too large: #{byte_size(body)} bytes") + {:error, :too_large} + + {:ok, %Finch.Response{status: status, body: body}} -> + {:error, {:http_status, status, summarize_response_body(body)}} + + error -> + {:error, normalize_fetch_error(error)} + end + end + + defp normalize_fetch_error({:ok, %Finch.Response{status: status, body: body}}) do + {:http_status, status, summarize_response_body(body)} + end + + defp normalize_fetch_error({:error, reason}), do: reason + defp normalize_fetch_error(error), do: error + + defp summarize_response_body(body) when is_binary(body) do + body + |> String.trim() + |> String.slice(0, 300) + end + + defp summarize_response_body(_), do: nil + + defp fetch_favicon(domain) do + # Try common favicon locations + urls = [ + "https://#{domain}/favicon.ico", + "https://#{domain}/favicon.png" + ] + + # First try to find favicon link in HTML + case fetch_favicon_from_html(domain) do + {:ok, favicon_url} -> + favicon_url + + :not_found -> + # Fall back to checking common locations + Enum.find_value(urls, fn url -> + case Finch.build(:head, url) + |> SafeFetch.request(Elektrine.Finch, receive_timeout: 3000, max_body_bytes: 0) do + {:ok, %Finch.Response{status: status}} when status in 200..299 -> + url + + _ -> + nil + end + end) + end + end + + defp fetch_favicon_from_html(domain) do + case Finch.build(:get, "https://#{domain}/", [{"Accept", "text/html"}]) + |> SafeFetch.request(Elektrine.Finch, receive_timeout: 5000, max_body_bytes: 500_000) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case extract_favicon_from_html(body, domain) do + nil -> :not_found + url -> {:ok, url} + end + + _ -> + :not_found + end + end + + defp extract_favicon_from_html(html, domain) do + # Simple regex to find favicon link - avoid full HTML parsing for performance + case Regex.run(~r/]+rel=["'](?:shortcut )?icon["'][^>]+href=["']([^"']+)["']/i, html) do + [_, href] -> + resolve_favicon_url(href, domain) + + nil -> + # Try alternate order (href before rel) + case Regex.run( + ~r/]+href=["']([^"']+)["'][^>]+rel=["'](?:shortcut )?icon["']/i, + html + ) do + [_, href] -> resolve_favicon_url(href, domain) + nil -> nil + end + end + end + + defp resolve_favicon_url(href, domain) do + normalize_favicon_url(href, domain) + end + + defp favicon_url_for_domain("", _domain), do: nil + + defp favicon_url_for_domain(href, domain) do + cond do + String.starts_with?(href, "http://") or String.starts_with?(href, "https://") -> + href + + String.starts_with?(href, "//") -> + "https:" <> href + + true -> + domain + |> base_favicon_uri() + |> URI.merge(href) + |> URI.to_string() + end + end + + defp normalize_external_favicon_url(nil), do: nil + + defp normalize_external_favicon_url(url) do + case SafeExternalURL.normalize(url) do + {:ok, safe_url} -> String.slice(safe_url, 0, 255) + {:error, _reason} -> nil + end + end + + defp base_favicon_uri(domain) do + URI.parse("https://#{domain}/") + end + + defp normalize_domain(domain) do + domain + |> String.trim() + |> String.downcase() + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/normalizer.ex b/apps/elektrine/lib/elektrine/activitypub/normalizer.ex new file mode 100644 index 0000000..e6ceba3 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/normalizer.ex @@ -0,0 +1,1369 @@ +defmodule Elektrine.ActivityPub.Normalizer do + @moduledoc """ + Converts raw ActivityPub objects into internal message payloads. + + Raw ActivityPub maps should be normalized at this federation boundary before + create/update handlers persist or render their fields. + """ + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Helpers + alias Elektrine.ActivityPub.RemoteFetch + alias Elektrine.ActivityPub.Visibility + alias Elektrine.Async + alias Elektrine.Messaging + + @user_actor_path_markers [ + "/users/", + "/user/", + "/u/", + "/@", + "/profile/", + "/profiles/", + "/accounts/" + ] + @community_path_markers ["/c/", "/m/", "/community/", "/communities/", "/groups/", "/g/"] + + @doc """ + Builds the internal payload used to persist or update federated Note-like objects. + """ + def message_payload(object, actor_uri, opts \\ []) when is_map(object) do + object = maybe_enrich_sparse_object(object, opts) + content = strip_html(object["content"] || "", object["tag"]) + title = normalize_object_title(object["name"]) + hashtags = extract_hashtags(object, content) + {media_urls, alt_texts} = extract_media_with_alt_text(object) + primary_url = extract_primary_url(object) + + %{ + attrs: + %{ + content: content, + title: title, + visibility: determine_visibility(object, opts), + activitypub_id: object["id"], + activitypub_url: object["url"] || object["id"], + primary_url: primary_url, + media_urls: media_urls, + media_metadata: + build_metadata_with_engagement( + alt_texts, + object, + Keyword.put_new(opts, :author_uri, actor_uri) + ), + reply_to_id: get_reply_to_message_id(object["inReplyTo"]), + quoted_message_id: get_quoted_message_id(object), + inserted_at: Helpers.parse_published_date(object["published"]), + extracted_hashtags: hashtags, + like_count: Helpers.extract_interaction_count(object, "likes"), + reply_count: Helpers.extract_interaction_count(object, "replies"), + share_count: Helpers.extract_interaction_count(object, "shares"), + quote_count: extract_quote_count(object), + sensitive: object["sensitive"] || false, + content_warning: object["summary"] + } + |> Map.merge(federated_context_attrs(opts)) + |> Map.merge(Helpers.extract_vote_totals(object)), + hashtags: hashtags, + mentioned_local_users: extract_local_mentions(object) + } + end + + @doc """ + Builds the internal payload used to persist or update federated Question polls. + """ + def question_payload(object, actor_uri, opts \\ []) when is_map(object) do + object = maybe_enrich_sparse_object(object, opts) + content = strip_html(object["content"] || "", object["tag"]) + question = poll_question_text(object) + hashtags = extract_hashtags(object, hashtag_source_content(content, question)) + {media_urls, alt_texts} = extract_media_with_alt_text(object) + options = poll_options(object) + + %{ + attrs: + %{ + content: content, + visibility: determine_visibility(object, opts), + activitypub_id: object["id"], + activitypub_url: object["url"] || object["id"], + media_urls: media_urls, + media_metadata: + build_poll_metadata( + alt_texts, + object, + Keyword.put_new(opts, :author_uri, actor_uri) + ), + inserted_at: Helpers.parse_published_date(object["published"]), + extracted_hashtags: hashtags, + post_type: "poll", + like_count: Helpers.extract_interaction_count(object, "likes"), + reply_count: Helpers.extract_interaction_count(object, "replies"), + share_count: Helpers.extract_interaction_count(object, "shares"), + quote_count: extract_quote_count(object), + sensitive: object["sensitive"] || false, + content_warning: object["summary"] + } + |> Map.merge(federated_context_attrs(opts)) + |> Map.merge(Helpers.extract_vote_totals(object)), + question: question, + hashtags: hashtags, + options: options + } + end + + @doc """ + Validates that an object's attributedTo value matches the verified actor URI. + """ + def validate_object_author(object, actor_uri) when is_map(object) do + attributed_actor_uris = + object["attributedTo"] + |> expand_uri_candidates() + |> Enum.map(&normalize_uri/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + + normalized_actor_uri = actor_ref_uri(actor_uri) + + cond do + is_nil(normalized_actor_uri) -> + {:error, :actor_mismatch} + + attributed_actor_uris == [] -> + :ok + + normalized_actor_uri in attributed_actor_uris -> + :ok + + true -> + {:error, :actor_mismatch} + end + end + + def validate_object_author(_object, _actor_uri), do: {:error, :actor_mismatch} + + @doc """ + Extracts the actor URI from common ActivityPub object fields. + """ + def actor_uri(object) when is_map(object) do + actor_ref_uri(object["attributedTo"]) || actor_ref_uri(object["actor"]) + end + + def actor_uri(_), do: nil + + @doc """ + Extracts the first URI from an ActivityPub actor reference. + + Actor references may be strings, maps with `id`/`url`/`href`, or lists of + those values. PeerTube commonly sends `attributedTo` as a list containing an + account Person and a channel Group. + """ + def actor_ref_uri(value) do + value + |> expand_uri_candidates() + |> Enum.map(&normalize_uri/1) + |> Enum.find(&is_binary/1) + end + + @doc """ + Extracts normalized engagement counters from a raw ActivityPub object. + """ + def engagement_counts(object) when is_map(object) do + %{ + like_count: Helpers.extract_interaction_count(object, "likes"), + reply_count: Helpers.extract_interaction_count(object, "replies"), + share_count: Helpers.extract_interaction_count(object, "shares"), + quote_count: extract_quote_count(object) + } + |> Map.merge(Helpers.extract_vote_totals(object)) + end + + def engagement_counts(_), + do: %{ + like_count: 0, + reply_count: 0, + share_count: 0, + quote_count: 0, + upvotes: 0, + downvotes: 0, + score: 0 + } + + @doc """ + Detects ActivityPub Answer-like poll vote objects encoded as minimal Notes. + """ + def poll_vote?(object) when is_map(object) do + has_name = Elektrine.Strings.present?(object["name"]) + has_reply_to = object["inReplyTo"] != nil + content = object["content"] || "" + has_minimal_content = String.length(strip_html(content, object["tag"])) < 5 + + has_name && has_reply_to && has_minimal_content + end + + def poll_vote?(_), do: false + + @doc """ + Extracts poll options from a Question object. + """ + def poll_options(object) when is_map(object) do + options = object["oneOf"] || object["anyOf"] || [] + + Enum.with_index(options) + |> Enum.map(fn {option, index} -> + votes = extract_vote_count(option) + %{text: option["name"], votes: votes, position: index} + end) + end + + def poll_options(_), do: [] + + @doc """ + Extracts the display question text from a Question object. + """ + def poll_question_text(object) when is_map(object) do + [object["question"], object["name"], object["content"]] + |> Enum.find_value("", fn value -> + normalized = strip_html(value || "", object["tag"]) + if normalized == "", do: nil, else: normalized + end) + end + + def poll_question_text(_), do: "" + + @doc """ + Fetches fuller object data for sparse objects when safe to do so. + """ + def enrich_sparse_object(%{"id" => id} = object) when is_binary(id) do + if sparse_object_payload?(object) do + case RemoteFetch.fetch_object(id) do + {:ok, fetched} when is_map(fetched) -> + merge_sparse_object_payload(object, fetched) + + _ -> + object + end + else + object + end + end + + def enrich_sparse_object(object), do: object + + @doc """ + Trims URI values and normalizes blank/non-string values to nil. + """ + def normalize_uri(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + def normalize_uri(_), do: nil + + defp maybe_enrich_sparse_object(object, opts) do + if Keyword.get(opts, :enrich_sparse_object, true) do + enrich_sparse_object(object) + else + object + end + end + + defp build_poll_metadata(alt_texts, object, opts) do + base = if map_size(alt_texts) > 0, do: %{"alt_texts" => alt_texts}, else: %{} + + base + |> Map.merge(extract_remote_status_metadata(object)) + |> Map.merge(extract_community_metadata(object, opts)) + end + + defp build_metadata_with_engagement(alt_texts, object, opts) do + base = if map_size(alt_texts) > 0, do: %{"alt_texts" => alt_texts}, else: %{} + + engagement = %{ + "original_like_count" => Helpers.extract_interaction_count(object, "likes"), + "original_reply_count" => Helpers.extract_interaction_count(object, "replies"), + "original_share_count" => Helpers.extract_interaction_count(object, "shares") + } + + base + |> Map.merge(engagement) + |> Map.merge(extract_remote_status_metadata(object)) + |> Map.merge(build_reply_context(object)) + |> Map.merge(extract_external_link(object)) + |> Map.merge(extract_community_metadata(object, opts)) + end + + defp extract_remote_status_metadata(object) when is_map(object) do + %{} + |> maybe_put_metadata( + "emoji_reactions", + object["emoji_reactions"] || get_in(object, ["pleroma", "emoji_reactions"]) || + misskey_emoji_reactions(object["reactions"]) + ) + |> maybe_put_metadata("quotes_count", extract_quote_count(object)) + |> maybe_put_metadata( + "quote", + object["quote"] || object["renote"] || get_in(object, ["pleroma", "quote"]) + ) + |> maybe_put_metadata( + "quote_id", + object["quote_id"] || object["renoteId"] || get_in(object, ["pleroma", "quote_id"]) + ) + |> maybe_put_metadata( + "quote_url", + object["quoteUrl"] || object["quoteUri"] || object["quote_url"] || + object["_misskey_quote"] || + get_in(object, ["pleroma", "quote_url"]) + ) + |> maybe_put_metadata("card", object["card"]) + |> maybe_put_metadata("application", object["application"] || object["app"]) + |> maybe_put_metadata("language", object["language"]) + |> maybe_put_metadata("type", object["type"]) + |> maybe_put_metadata("duration", object["duration"]) + |> maybe_put_metadata("thumbnail_url", object_preview_url(object)) + |> maybe_put_metadata("indexable", object["indexable"]) + |> maybe_put_metadata("media_attachments", extract_media_attachments_metadata(object)) + |> maybe_put_metadata("pleroma", object["pleroma"]) + |> maybe_put_metadata("misskey", misskey_status_metadata(object)) + end + + defp extract_remote_status_metadata(_), do: %{} + + defp maybe_put_metadata(metadata, _key, nil), do: metadata + defp maybe_put_metadata(metadata, _key, []), do: metadata + defp maybe_put_metadata(metadata, _key, %{} = value) when map_size(value) == 0, do: metadata + defp maybe_put_metadata(metadata, key, value), do: Map.put(metadata, key, value) + + defp misskey_emoji_reactions(reactions) when is_map(reactions) do + reactions + |> Enum.map(fn {emoji, count} -> + %{"name" => emoji, "count" => parse_nonnegative_count(count)} + end) + |> Enum.filter(&(&1["count"] > 0)) + end + + defp misskey_emoji_reactions(_), do: [] + + defp misskey_status_metadata(object) when is_map(object) do + object + |> Map.take([ + "id", + "createdAt", + "cw", + "visibility", + "reactionAcceptance", + "reactionCount", + "reactions", + "renoteCount", + "repliesCount", + "renoteId", + "replyId", + "uri", + "url" + ]) + end + + defp extract_community_metadata(object, opts) do + case detect_community_actor_uri(object, opts) do + uri when is_binary(uri) -> %{"community_actor_uri" => uri} + _ -> %{} + end + end + + defp detect_community_actor_uri(object, opts) when is_map(object) and is_list(opts) do + author_uri = + actor_ref_uri(object["attributedTo"]) || actor_ref_uri(Keyword.get(opts, :author_uri)) + + fallback_uri = normalize_uri(Keyword.get(opts, :fallback_community_uri)) + + direct_candidate = + object + |> community_uri_candidates(fallback_uri) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + |> Enum.reject(fn uri -> + public_audience_uri?(uri) or + collection_uri?(uri) or + post_reference_uri?(uri) or + uri == author_uri + end) + |> Enum.find(&community_actor_uri?/1) + + direct_candidate || community_uri_from_reply_chain(object["inReplyTo"]) + end + + defp detect_community_actor_uri(_, _), do: nil + + defp community_uri_candidates(object, fallback_uri) do + [ + object["audience"], + object["context"], + object["to"], + object["cc"], + object["target"], + fallback_uri + ] + |> Enum.flat_map(&expand_uri_candidates/1) + |> Enum.map(&normalize_uri/1) + end + + defp community_uri_from_reply_chain(in_reply_to) do + with uri when is_binary(uri) <- extract_in_reply_to_uri(in_reply_to), + %{} = parent_message <- Messaging.get_message_by_activitypub_ref(uri) do + get_community_uri_from_chain(parent_message) + else + _ -> nil + end + end + + defp extract_in_reply_to_uri(in_reply_to) when is_binary(in_reply_to), + do: normalize_uri(in_reply_to) + + defp extract_in_reply_to_uri(in_reply_to) when is_map(in_reply_to) do + in_reply_to + |> Map.get("id") + |> extract_in_reply_to_uri() + end + + defp extract_in_reply_to_uri(_), do: nil + + defp get_community_uri_from_chain(message, depth \\ 0) + + defp get_community_uri_from_chain(_message, depth) when depth > 10, do: nil + + defp get_community_uri_from_chain(message, depth) do + current_uri = + message + |> Map.get(:media_metadata, %{}) + |> case do + metadata when is_map(metadata) -> Map.get(metadata, "community_actor_uri") + _ -> nil + end + |> normalize_uri() + + if is_binary(current_uri) && community_actor_uri?(current_uri) do + current_uri + else + with reply_to_id when is_integer(reply_to_id) <- Map.get(message, :reply_to_id), + %{} = parent <- Messaging.get_message(reply_to_id) do + get_community_uri_from_chain(parent, depth + 1) + else + _ -> nil + end + end + end + + defp expand_uri_candidates(value) when is_binary(value), do: [value] + + defp expand_uri_candidates(values) when is_list(values), + do: Enum.flat_map(values, &expand_uri_candidates/1) + + defp expand_uri_candidates(%{"id" => id}) when is_binary(id), do: [id] + + defp expand_uri_candidates(map) when is_map(map) do + map + |> Map.take(["id", "url", "href"]) + |> Map.values() + |> Enum.flat_map(&expand_uri_candidates/1) + end + + defp expand_uri_candidates(_), do: [] + + defp public_audience_uri?(uri) when is_binary(uri), do: Visibility.public_audience?(uri) + defp public_audience_uri?(_), do: false + + defp collection_uri?(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{path: path} when is_binary(path) -> + normalized = path |> String.downcase() |> String.trim_trailing("/") + String.ends_with?(normalized, "/followers") || String.ends_with?(normalized, "/following") + + _ -> + false + end + end + + defp collection_uri?(_), do: false + + defp post_reference_uri?(uri) when is_binary(uri) do + Regex.match?(~r{/post/\d+(?:$|[/?#])}, uri) || + Regex.match?(~r{/c/[^/]+/p/\d+(?:$|[/?#])}, uri) || + Regex.match?(~r{/m/[^/]+/[pt]/\d+(?:$|[/?#])}, uri) + end + + defp post_reference_uri?(_), do: false + + defp community_actor_uri?(uri) when is_binary(uri) do + known_group_actor_uri?(uri) || community_path_uri?(uri) + end + + defp community_actor_uri?(_), do: false + + defp known_group_actor_uri?(uri) when is_binary(uri) do + case ActivityPub.get_actor_by_uri(uri) do + %Elektrine.ActivityPub.Actor{actor_type: "Group"} -> true + _ -> false + end + end + + defp community_path_uri?(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{path: path} when is_binary(path) -> + path_downcased = String.downcase(path) + + Enum.any?(@community_path_markers, &String.contains?(path_downcased, &1)) && + !user_actor_uri?(uri) + + _ -> + false + end + end + + defp user_actor_uri?(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{path: path} when is_binary(path) -> + downcased_path = String.downcase(path) + Enum.any?(@user_actor_path_markers, &String.contains?(downcased_path, &1)) + + _ -> + false + end + end + + defp extract_external_link(object) do + activity_id = normalize_external_link_candidate(object["id"]) + + submitted_link = + [ + extract_attachment_link(object["attachment"]), + extract_url_field_link(object["url"], activity_id), + extract_source_field_link(object["source"], activity_id) + ] + |> Enum.find(&is_binary/1) + + if is_binary(submitted_link), do: %{"external_link" => submitted_link}, else: %{} + end + + defp extract_primary_url(object) do + case extract_external_link(object) do + %{"external_link" => url} when is_binary(url) -> url + _ -> nil + end + end + + defp extract_attachment_link(attachments) when is_list(attachments) do + attachments + |> Enum.find_value(fn + %{"type" => "Link"} = att -> + normalize_external_link_candidate( + att["href"] || att["url"] || get_in(att, ["url", "href"]) + ) + + %{} = att -> + normalize_external_link_candidate(att["href"]) + + _ -> + nil + end) + end + + defp extract_attachment_link(%{} = attachment), do: extract_attachment_link([attachment]) + defp extract_attachment_link(_), do: nil + + defp extract_url_field_link(url_field, activity_id) do + url_field + |> expand_external_link_candidates() + |> Enum.find(&external_link_candidate?(&1, activity_id)) + end + + defp extract_source_field_link(%{} = source, activity_id) do + [source["url"], source["href"], source["content"]] + |> expand_external_link_candidates() + |> Enum.find(&external_link_candidate?(&1, activity_id)) + end + + defp extract_source_field_link(_, _), do: nil + + # A `url`/`source` candidate counts as a submitted external link only when it + # points off the post's own instance. Microblog software (Mastodon, Pleroma, + # GoToSocial, …) exposes a post `url` (e.g. /@user/123) that is a different + # representation of the same object as its `id` (/users/user/statuses/123) on + # the same host - not an external link. Treating it as one produced a + # self-referential link preview on every such post. Genuine link-aggregator + # posts (Lemmy/Mbin) put the article on a different host, so a host comparison + # keeps those working. + defp external_link_candidate?(candidate, activity_id) when is_binary(candidate) do + candidate != activity_id and not same_host?(candidate, activity_id) + end + + defp external_link_candidate?(_candidate, _activity_id), do: false + + defp same_host?(a, b) when is_binary(a) and is_binary(b) do + host_a = a |> URI.parse() |> Map.get(:host) + host_b = b |> URI.parse() |> Map.get(:host) + + is_binary(host_a) and is_binary(host_b) and + String.downcase(host_a) == String.downcase(host_b) + end + + defp same_host?(_a, _b), do: false + + defp expand_external_link_candidates(value) when is_list(value) do + Enum.flat_map(value, &expand_external_link_candidates/1) + end + + defp expand_external_link_candidates(%{"href" => href}), + do: expand_external_link_candidates(href) + + defp expand_external_link_candidates(%{"url" => url}), do: expand_external_link_candidates(url) + defp expand_external_link_candidates(%{href: href}), do: expand_external_link_candidates(href) + defp expand_external_link_candidates(%{url: url}), do: expand_external_link_candidates(url) + + defp expand_external_link_candidates(value) when is_binary(value) do + case normalize_external_link_candidate(value) do + normalized when is_binary(normalized) -> [normalized] + _ -> [] + end + end + + defp expand_external_link_candidates(_), do: [] + + defp normalize_external_link_candidate(value) when is_binary(value) do + case Elektrine.Security.SafeExternalURL.normalize_href(value) do + {:ok, safe_url} -> safe_url + {:error, _reason} -> nil + end + end + + defp normalize_external_link_candidate(_), do: nil + + defp build_reply_context(object) do + in_reply_to = object["inReplyTo"] + + cond do + is_nil(in_reply_to) -> + %{} + + is_binary(in_reply_to) -> + %{ + "inReplyTo" => in_reply_to, + "inReplyToAuthor" => extract_reply_author(in_reply_to, object["tag"]) + } + + is_map(in_reply_to) && in_reply_to["id"] -> + author = + in_reply_to["attributedTo"] || + in_reply_to["actor"] || + extract_reply_author(in_reply_to["id"], object["tag"]) + + %{ + "inReplyTo" => in_reply_to["id"], + "inReplyToAuthor" => normalize_author(author), + "inReplyToContent" => extract_reply_content_preview(in_reply_to) + } + + true -> + %{} + end + end + + defp extract_author_from_url(url) when is_binary(url) do + case URI.parse(url) do + %{host: host, path: path} when is_binary(host) and is_binary(path) -> + case extract_username_from_path(path) do + username when is_binary(username) -> + "@#{username}@#{host}" + + _ -> + case extract_post_id_from_path(path) do + post_id when is_binary(post_id) -> "post #{post_id} on #{host}" + _ -> "a post on #{host}" + end + end + + %{host: host} when is_binary(host) -> + "a post on #{host}" + + _ -> + nil + end + end + + defp extract_author_from_url(_), do: nil + + defp extract_reply_author(in_reply_to_url, tags) when is_binary(in_reply_to_url) do + extract_reply_author_from_tags(tags, in_reply_to_url) || + extract_author_from_url(in_reply_to_url) + end + + defp extract_reply_author(_, _), do: nil + + defp extract_reply_author_from_tags(tags, in_reply_to_url) when is_list(tags) do + handles = + tags + |> Enum.filter(fn tag -> is_map(tag) && tag["type"] == "Mention" end) + |> Enum.map(&mention_tag_to_handle/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + + case handles do + [] -> + nil + + handles -> + reply_host = extract_host_from_url(in_reply_to_url) + + if is_binary(reply_host) do + Enum.find(handles, fn handle -> + String.ends_with?(String.downcase(handle), "@#{String.downcase(reply_host)}") + end) || hd(handles) + else + hd(handles) + end + end + end + + defp extract_reply_author_from_tags(_, _), do: nil + + defp mention_tag_to_handle(tag) when is_map(tag) do + name = tag["name"] + href = tag["href"] + host = extract_host_from_url(href) + + cond do + is_binary(name) -> + normalize_mention_name(name, host) || extract_author_from_url(href) + + is_binary(href) -> + extract_author_from_url(href) + + true -> + nil + end + end + + defp mention_tag_to_handle(_), do: nil + + defp normalize_mention_name(name, host) when is_binary(name) do + cleaned = String.trim(name) + + cond do + Regex.match?(~r/^@[^@\s]+@[^@\s]+$/, cleaned) -> + cleaned + + Regex.match?(~r/^@[^@\s]+$/, cleaned) && is_binary(host) -> + "#{cleaned}@#{host}" + + true -> + nil + end + end + + defp extract_host_from_url(url) when is_binary(url) do + case URI.parse(url) do + %{host: host} when is_binary(host) and host != "" -> host + _ -> nil + end + end + + defp extract_host_from_url(_), do: nil + + defp normalize_author(author) when is_binary(author) do + if String.starts_with?(author, "http") do + extract_author_from_url(author) || author + else + author + end + end + + defp normalize_author(%{"id" => id}), do: normalize_author(id) + defp normalize_author(_), do: nil + + defp extract_username_from_path(path) when is_binary(path) do + case path_segments(path) do + ["users", username | _] -> + sanitize_identifier(username) + + ["u", username | _] -> + sanitize_identifier(username) + + ["profile", username | _] -> + sanitize_identifier(username) + + ["accounts", username | _] -> + sanitize_identifier(username) + + [segment | _] -> + if String.starts_with?(segment, "@") do + sanitize_identifier(segment) + else + nil + end + + _ -> + nil + end + end + + defp extract_post_id_from_path(path) when is_binary(path) do + candidate = + case path_segments(path) do + ["users", _username, "statuses", post_id | _] -> post_id + ["notice", post_id | _] -> post_id + ["objects", post_id | _] -> post_id + ["posts", post_id | _] -> post_id + ["post", post_id | _] -> post_id + ["comment", post_id | _] -> post_id + ["comments", post_id | _] -> post_id + ["activities", post_id | _] -> post_id + [first, post_id | _] -> if String.starts_with?(first, "@"), do: post_id, else: nil + _ -> nil + end + + sanitize_identifier(candidate) + end + + defp path_segments(path) when is_binary(path) do + path + |> String.split("/", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp sanitize_identifier(value) when is_binary(value) do + value + |> URI.decode() + |> String.trim() + |> String.trim_leading("@") + |> String.split(["?", "#"], parts: 2) + |> List.first() + |> case do + "" -> nil + sanitized -> sanitized + end + end + + defp sanitize_identifier(_), do: nil + + defp extract_reply_content_preview(%{"content" => content, "tag" => tags}) + when is_binary(content) do + content + |> strip_html(tags) + |> String.slice(0, 200) + end + + defp extract_reply_content_preview(%{"content" => content}) when is_binary(content) do + content + |> strip_html() + |> String.slice(0, 200) + end + + defp extract_reply_content_preview(_), do: nil + + defp extract_vote_count(option) do + case option["replies"] do + %{"totalItems" => count} when is_integer(count) -> + max(count, 0) + + %{"totalItems" => count} when is_binary(count) -> + parse_nonnegative_count(count) + + %{} = replies -> + parse_nonnegative_count(replies["totalItems"]) + + url when is_binary(url) -> + case RemoteFetch.fetch_object(url) do + {:ok, %{"totalItems" => count}} -> parse_nonnegative_count(count) + _ -> 0 + end + + _ -> + 0 + end + rescue + _ -> 0 + end + + defp strip_html(html, tags \\ []) + + defp strip_html(nil, _tags), do: "" + + defp strip_html(html, tags) when is_binary(html) do + html + |> extract_mentions_from_at_pattern() + |> extract_mentions_from_users_pattern() + |> extract_mentions_from_u_pattern() + |> String.replace(~r//, "\n") + |> String.replace(~r/]*>/, "\n") + |> String.replace(~r/<\/p>/, "\n") + |> String.replace(~r/<[^>]*>/, "") + |> HtmlEntities.decode() + |> expand_short_tag_mentions(tags) + |> String.trim() + end + + defp strip_html(_, _tags), do: "" + + defp normalize_object_title(title) when is_binary(title) do + title + |> strip_html() + |> String.replace(~r/\s+/, " ") + |> String.trim() + |> case do + "" -> nil + normalized -> normalized + end + end + + defp normalize_object_title(_), do: nil + + defp hashtag_source_content(content, question) + when is_binary(content) and is_binary(question) do + if Elektrine.Strings.present?(content), do: content, else: question + end + + defp sparse_object_payload?(object) when is_map(object) do + missing_name = blank_object_value?(object["name"]) + missing_content = blank_object_value?(object["content"]) + missing_attachment = blank_object_value?(object["attachment"]) + missing_image = blank_object_value?(object["image"]) + + missing_name && missing_content && missing_attachment && missing_image + end + + defp merge_sparse_object_payload(base, fetched) when is_map(base) and is_map(fetched) do + Enum.reduce(fetched, base, fn {key, fetched_value}, acc -> + current_value = Map.get(acc, key) + + if blank_object_value?(current_value) and not blank_object_value?(fetched_value) do + Map.put(acc, key, fetched_value) + else + acc + end + end) + end + + defp merge_sparse_object_payload(base, _), do: base + + defp blank_object_value?(nil), do: true + defp blank_object_value?(value) when is_binary(value), do: not Elektrine.Strings.present?(value) + defp blank_object_value?(value) when is_list(value), do: value == [] + defp blank_object_value?(value) when is_map(value), do: map_size(value) == 0 + defp blank_object_value?(_), do: false + + defp extract_mentions_from_at_pattern(html) do + Regex.replace( + ~r/]*href=["']https?:\/\/([^\/\s"']+)\/@([^\/\s"'#]+)["'][^>]*>.*?<\/a>/, + html, + fn _, domain, username -> "@#{username}@#{domain}" end + ) + end + + defp extract_mentions_from_users_pattern(html) do + Regex.replace( + ~r/]*href=["']https?:\/\/([^\/\s"']+)\/users\/([^\/\s"'#]+)["'][^>]*>.*?<\/a>/i, + html, + fn _, domain, username -> "@#{username}@#{domain}" end + ) + end + + defp extract_mentions_from_u_pattern(html) do + Regex.replace( + ~r/]*href=["']https?:\/\/([^\/\s"']+)\/u\/([^\/\s"'#]+)["'][^>]*>.*?<\/a>/i, + html, + fn _, domain, username -> "@#{username}@#{domain}" end + ) + end + + defp expand_short_tag_mentions(text, tags) when is_binary(text) and is_list(tags) do + tags + |> short_mention_replacements() + |> Enum.reduce(text, fn {short, full}, acc -> + Regex.replace( + ~r/(^|[^A-Za-z0-9_@\/])#{Regex.escape(short)}(?![A-Za-z0-9_@])/u, + acc, + fn _, prefix -> "#{prefix}#{full}" end + ) + end) + end + + defp expand_short_tag_mentions(text, _tags), do: text + + defp short_mention_replacements(tags) when is_list(tags) do + tags + |> Enum.filter(fn tag -> is_map(tag) && tag["type"] == "Mention" end) + |> Enum.reduce(%{}, fn tag, acc -> + short = short_mention_name(tag["name"]) + full = mention_tag_to_handle(tag) + + if is_binary(short) and mention_handle?(full) and short != full do + Map.update(acc, short, MapSet.new([full]), &MapSet.put(&1, full)) + else + acc + end + end) + |> Enum.reduce(%{}, fn {short, handles}, acc -> + case MapSet.to_list(handles) do + [full] -> Map.put(acc, short, full) + _ -> acc + end + end) + end + + defp short_mention_name(name) when is_binary(name) do + cleaned = String.trim(name) + + if Regex.match?(~r/^@[^@\s]+$/, cleaned), do: cleaned, else: nil + end + + defp short_mention_name(_), do: nil + + defp mention_handle?(handle) when is_binary(handle) do + Regex.match?(~r/^@[^@\s]+@[^@\s]+$/, handle) + end + + defp mention_handle?(_), do: false + + defp determine_visibility(object, opts) do + visibility_opts = Keyword.take(opts, [:assume_public_reply_without_audience]) + Visibility.visibility(object, visibility_opts) + end + + defp get_reply_to_message_id(nil), do: nil + + defp get_reply_to_message_id(in_reply_to) when is_binary(in_reply_to) do + case Messaging.get_message_by_activitypub_ref(in_reply_to) do + nil -> nil + message -> message.id + end + end + + defp get_reply_to_message_id(in_reply_to) when is_map(in_reply_to) do + case Map.get(in_reply_to, "id") do + nil -> nil + id -> get_reply_to_message_id(id) + end + end + + defp get_reply_to_message_id(_), do: nil + + defp get_quoted_message_id(object) do + quote_url = object["quoteUrl"] || object["_misskey_quote"] || object["quoteUri"] + + case quote_url do + nil -> + nil + + url when is_binary(url) -> + case Messaging.get_message_by_activitypub_ref(url) do + nil -> + nil + + message -> + Async.run(fn -> Messaging.increment_quote_count(message.id) end) + message.id + end + + _ -> + nil + end + end + + defp extract_quote_count(object) when is_map(object) do + parse_nonnegative_count( + object["quotes_count"] || + object["quote_count"] || + object["quotesCount"] || + object["quoteCount"] || + object["quotedCount"] || + get_in(object, ["pleroma", "quotes_count"]) + ) + end + + defp extract_quote_count(_), do: 0 + + defp parse_nonnegative_count(value) when is_integer(value), do: max(value, 0) + + defp parse_nonnegative_count(value) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {count, ""} -> max(count, 0) + _ -> 0 + end + end + + defp parse_nonnegative_count(_), do: 0 + + defp extract_media_attachments_metadata(object) when is_map(object) do + object + |> media_candidates() + |> Enum.with_index() + |> Enum.map(fn {attachment, index} -> media_attachment_metadata(attachment, index) end) + |> Enum.filter(& &1) + |> Enum.take(10) + end + + defp media_attachment_metadata(%{} = attachment, index) do + url = attachment_url(attachment) + media_type = attachment_media_type(attachment) + + if is_binary(url) && valid_media_url?(url, media_type) do + %{ + "id" => to_string(index), + "type" => mastodon_media_type(attachment, url), + "url" => url, + "mediaType" => media_type, + "preview_url" => attachment_preview_url(attachment) || attachment["thumbnailUrl"] || url, + "remote_url" => + attachment["remote_url"] || attachment["remoteUrl"] || attachment["uri"] || url, + "meta" => attachment["meta"] || attachment["properties"] || %{}, + "width" => attachment["width"], + "height" => attachment["height"], + "duration" => attachment["duration"], + "description" => + attachment["comment"] || attachment["name"] || attachment["summary"] || + attachment["content"], + "blurhash" => attachment["blurhash"] + } + end + end + + defp media_attachment_metadata(_, _), do: nil + + defp media_candidates(object) when is_map(object) do + attachments = + case Map.get(object, "attachment", []) do + [] -> Map.get(object, "files", []) + attachments -> attachments + end + + object_preview = object_preview_url(object) + + attachment_candidates = + attachments + |> List.wrap() + |> Enum.filter(&is_map/1) + + url_candidates = + object + |> Map.get("url", []) + |> List.wrap() + |> Enum.filter(&media_link?/1) + |> Enum.map(fn link -> + if is_binary(object_preview), + do: Map.put_new(link, "preview_url", object_preview), + else: link + end) + + attachment_candidates ++ url_candidates + end + + defp media_candidates(_), do: [] + + defp media_link?(%{} = link) do + url = attachment_url(link) + is_binary(url) && valid_media_url?(url, attachment_media_type(link)) + end + + defp media_link?(_), do: false + + defp attachment_url(attachment) when is_map(attachment) do + cond do + is_binary(attachment["url"]) -> attachment["url"] + is_binary(attachment["uri"]) -> attachment["uri"] + is_map(attachment["url"]) -> attachment["url"]["href"] + is_list(attachment["url"]) -> Enum.find_value(attachment["url"], &attachment_url/1) + is_binary(attachment["href"]) -> attachment["href"] + true -> nil + end + end + + defp attachment_url(_), do: nil + + defp attachment_preview_url(%{"preview_url" => url}) when is_binary(url), do: url + defp attachment_preview_url(%{"previewUrl" => url}) when is_binary(url), do: url + defp attachment_preview_url(%{"preview" => %{"url" => url}}) when is_binary(url), do: url + defp attachment_preview_url(%{"preview" => %{"href" => url}}) when is_binary(url), do: url + defp attachment_preview_url(_), do: nil + + defp object_preview_url(object) when is_map(object) do + [object["thumbnailUrl"], object["preview"], object["icon"], object["image"]] + |> Enum.find_value(fn value -> + value + |> List.wrap() + |> Enum.find_value(&preview_candidate_url/1) + end) + end + + defp preview_candidate_url(value) when is_binary(value) do + if valid_media_url?(value, "image/*"), do: value, else: nil + end + + defp preview_candidate_url(%{} = value) do + url = attachment_url(value) + + if is_binary(url) && valid_media_url?(url, attachment_media_type(value) || "image/*"), + do: url, + else: nil + end + + defp preview_candidate_url(_), do: nil + + defp attachment_media_type(attachment) when is_map(attachment) do + attachment["mediaType"] || attachment["mimeType"] || attachment["media_type"] + end + + defp attachment_media_type(_), do: nil + + defp mastodon_media_type(attachment, url) do + media_type = + String.downcase(to_string(attachment_media_type(attachment) || attachment["type"] || "")) + + url_downcased = String.downcase(url) + + cond do + video_media_type?(media_type) -> "video" + String.starts_with?(media_type, "audio/") -> "audio" + String.starts_with?(media_type, "image/gif") -> "gifv" + String.starts_with?(media_type, "image/") -> "image" + String.match?(url_downcased, ~r/\.(mp4|webm|ogv|mov)(\?.*)?$/) -> "video" + String.match?(url_downcased, ~r/\.(mp3|wav|ogg|m4a|flac)(\?.*)?$/) -> "audio" + String.match?(url_downcased, ~r/\.gif(\?.*)?$/) -> "gifv" + true -> "image" + end + end + + defp extract_media_with_alt_text(object) do + object + |> media_candidates() + |> Enum.with_index() + |> Enum.map(fn {attachment, idx} -> + url = attachment_url(attachment) + + alt_text = + attachment["comment"] || attachment["name"] || attachment["summary"] || + attachment["content"] + + {url, attachment_media_type(attachment), alt_text, idx} + end) + |> Enum.filter(fn {url, media_type, _alt, _idx} -> + is_binary(url) && valid_media_url?(url, media_type) + end) + |> Enum.take(10) + |> Enum.reduce({[], %{}}, fn {url, _media_type, alt_text, idx}, {urls, alt_map} -> + new_urls = urls ++ [url] + + new_alt_map = + if Elektrine.Strings.present?(alt_text) do + Map.put(alt_map, to_string(idx), String.trim(alt_text)) + else + alt_map + end + + {new_urls, new_alt_map} + end) + end + + defp valid_media_url?(url, media_type) when is_binary(url) do + uri = URI.parse(url) + valid_scheme = uri.scheme in ["https", "http"] + has_host = uri.host != nil + not_localhost = uri.host && !String.contains?(uri.host, "localhost") + not_private_ip = uri.host && !private_ip?(uri.host) + is_media = media_url?(url) || media_mime_type?(media_type) + + valid_scheme && has_host && not_localhost && not_private_ip && is_media + end + + defp media_mime_type?(media_type) when is_binary(media_type) do + media_type = String.downcase(media_type) + + String.starts_with?(media_type, ["image/", "audio/"]) || video_media_type?(media_type) + end + + defp media_mime_type?(_), do: false + + defp video_media_type?(media_type) when is_binary(media_type) do + String.starts_with?(media_type, "video/") || + media_type in ["application/x-mpegurl", "application/vnd.apple.mpegurl"] + end + + defp video_media_type?(_), do: false + + defp media_url?(url) when is_binary(url) do + url_lower = String.downcase(url) + + has_media_extension = + String.match?( + url_lower, + ~r/\.(jpe?g|png|gif|webp|svg|bmp|ico|avif|mp4|webm|ogv|mov|mp3|wav|ogg|m4a|flac)(\?.*)?$/ + ) + + is_known_media_host = + String.match?( + url_lower, + ~r/(\/media\/|\/images\/|\/uploads\/|\/files\/|\/attachments\/|\/pictrs\/|i\.imgur|pbs\.twimg|cdn\.discordapp|media\.tenor|i\.redd\.it|preview\.redd\.it)/ + ) + + has_media_extension || is_known_media_host + end + + defp private_ip?(host) do + String.starts_with?(host, ["127.", "192.168.", "10.", "0."]) || + Regex.match?(~r/^172\.(1[6-9]|2[0-9]|3[0-1])\./, host) || + String.starts_with?(host, ["::1", "fc00:", "fd00:", "fe80:", "::ffff:", "100.64."]) || + host in ["localhost", "localhost.localdomain"] + end + + defp extract_local_mentions(object) do + case object["tag"] do + tags when is_list(tags) -> + tags + |> Enum.filter(fn tag -> tag["type"] == "Mention" end) + |> Enum.map(fn tag -> + case extract_local_username_from_uri(tag["href"]) do + {:ok, username} -> username + _ -> nil + end + end) + |> Enum.filter(&(&1 != nil)) + |> Enum.uniq() + + _ -> + [] + end + end + + defp extract_local_username_from_uri(uri) when is_binary(uri) do + ActivityPub.local_username_from_uri(uri) + end + + defp extract_local_username_from_uri(_), do: {:error, :invalid_uri} + + defp extract_hashtags(object, content) do + tag_hashtags = + case object["tag"] do + tags when is_list(tags) -> + tags + |> Enum.filter(fn tag -> tag["type"] == "Hashtag" end) + |> Enum.map(fn tag -> tag["name"] |> String.trim_leading("#") |> String.downcase() end) + + _ -> + [] + end + + content_hashtags = + Regex.scan(~r/#([a-zA-Z0-9_]+)/, content) + |> Enum.map(fn [_, tag] -> String.downcase(tag) end) + + (tag_hashtags ++ content_hashtags) |> Enum.uniq() |> Enum.take(10) + end + + defp federated_context_attrs(opts) do + case Keyword.get(opts, :conversation_id) do + conversation_id when is_integer(conversation_id) -> %{conversation_id: conversation_id} + _ -> %{} + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/object_deliveries.ex b/apps/elektrine/lib/elektrine/activitypub/object_deliveries.ex new file mode 100644 index 0000000..93e237d --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/object_deliveries.ex @@ -0,0 +1,83 @@ +defmodule Elektrine.ActivityPub.ObjectDeliveries do + @moduledoc """ + Ledger of inboxes that have been targeted for an ActivityPub object. + + The delivery attempt table can be pruned, but this ledger lets Deletes and + Updates reach everyone who may have previously received the object. + """ + + import Ecto.Query + + alias Elektrine.ActivityPub.Activity + alias Elektrine.ActivityPub.Delivery + alias Elektrine.ActivityPub.ObjectDelivery + alias Elektrine.Repo + + @doc """ + Records inboxes that have been targeted for an ActivityPub object. + """ + def record_object_deliveries(object_id, activity_id, inbox_urls) + when is_binary(object_id) and is_list(inbox_urls) do + utc_now = DateTime.utc_now() |> DateTime.truncate(:second) + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + rows = + inbox_urls + |> Enum.filter(&(is_binary(&1) and &1 != "")) + |> Enum.uniq() + |> Enum.map(fn inbox_url -> + %{ + object_id: object_id, + inbox_url: inbox_url, + activity_id: activity_id, + first_seen_at: utc_now, + last_seen_at: utc_now, + inserted_at: now, + updated_at: now + } + end) + + if rows == [] do + {0, nil} + else + Repo.insert_all(ObjectDelivery, rows, + on_conflict: {:replace, [:activity_id, :last_seen_at, :updated_at]}, + conflict_target: [:object_id, :inbox_url] + ) + end + end + + def record_object_deliveries(_object_id, _activity_id, _inbox_urls), do: {0, nil} + + @doc """ + Lists every inbox the object has been delivered toward. + """ + def get_object_delivery_inboxes(object_id) when is_binary(object_id) do + ObjectDelivery + |> where([d], d.object_id == ^object_id) + |> select([d], d.inbox_url) + |> Repo.all() + end + + def get_object_delivery_inboxes(_), do: [] + + @doc """ + Stamps the object-delivery ledger after a successful delivery attempt. + """ + def mark_object_delivery_delivered(%Delivery{ + inbox_url: inbox_url, + activity: %Activity{object_id: object_id} + }) + when is_binary(object_id) and is_binary(inbox_url) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + from(d in ObjectDelivery, + where: d.object_id == ^object_id and d.inbox_url == ^inbox_url + ) + |> Repo.update_all(set: [last_delivered_at: now, last_seen_at: now]) + + :ok + end + + def mark_object_delivery_delivered(_delivery), do: :ok +end diff --git a/apps/elektrine/lib/elektrine/activitypub/object_delivery.ex b/apps/elektrine/lib/elektrine/activitypub/object_delivery.ex new file mode 100644 index 0000000..314dfa6 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/object_delivery.ex @@ -0,0 +1,34 @@ +defmodule Elektrine.ActivityPub.ObjectDelivery do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + schema "activitypub_object_deliveries" do + field :object_id, :string + field :inbox_url, :string + field :first_seen_at, :utc_datetime + field :last_seen_at, :utc_datetime + field :last_delivered_at, :utc_datetime + + belongs_to :activity, Elektrine.ActivityPub.Activity + + timestamps() + end + + def changeset(delivery, attrs) do + delivery + |> cast(attrs, [ + :object_id, + :inbox_url, + :activity_id, + :first_seen_at, + :last_seen_at, + :last_delivered_at + ]) + |> validate_required([:object_id, :inbox_url, :first_seen_at, :last_seen_at]) + |> unique_constraint([:object_id, :inbox_url], + name: :activitypub_object_deliveries_object_inbox_unique + ) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/object_validator.ex b/apps/elektrine/lib/elektrine/activitypub/object_validator.ex new file mode 100644 index 0000000..9a4e631 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/object_validator.ex @@ -0,0 +1,549 @@ +defmodule Elektrine.ActivityPub.ObjectValidator do + @moduledoc """ + Validates incoming ActivityPub objects and activities. + + Ensures objects are well-formed and compatible with our system before processing. + This helps prevent malformed or malicious activities from causing issues. + """ + + alias Elektrine.ActivityPub.Containment + alias Elektrine.Security.URLValidator + + @doc """ + Validates an activity before processing. + Returns {:ok, activity} if valid, {:error, reason} if invalid. + """ + def validate(activity) when is_map(activity) do + with {:ok, activity} <- validate_basic_structure(activity), + {:ok, activity} <- validate_actor(activity), + :ok <- Containment.validate_activity(activity) do + validate_type_specific(activity) + end + end + + def validate(_), do: {:error, "Activity must be a map"} + + # Basic structure validation - all activities need these + defp validate_basic_structure(activity) do + cond do + !is_binary(activity["type"]) -> + {:error, "Missing or invalid type"} + + !has_valid_id?(activity) -> + {:error, "Missing or invalid id"} + + is_binary(activity["id"]) and not valid_uri?(activity["id"]) -> + {:error, "Invalid activity id"} + + true -> + {:ok, activity} + end + end + + defp has_valid_id?(%{"id" => id}) when is_binary(id) and byte_size(id) > 0, do: true + # Delete activities may not have id + defp has_valid_id?(%{"type" => "Delete"}), do: true + defp has_valid_id?(_), do: false + + # Actor validation + defp validate_actor(%{"actor" => actor} = activity) when is_binary(actor) do + case URI.parse(actor) do + %URI{scheme: scheme, host: host} when scheme in ["http", "https"] and is_binary(host) -> + if URLValidator.private_ip?(host) or URLValidator.is_private_domain?(host) do + {:error, "Invalid actor URI"} + else + {:ok, activity} + end + + _ -> + {:error, "Invalid actor URI"} + end + end + + defp validate_actor(%{"type" => type} = activity) + when type in ["Person", "Group", "Application", "Service", "Organization"] do + # Actor objects don't have an "actor" field, they ARE the actor + {:ok, activity} + end + + defp validate_actor(_), do: {:error, "Missing or invalid actor"} + + # Type-specific validation + defp validate_type_specific(%{"type" => "Create"} = activity), do: validate_create(activity) + defp validate_type_specific(%{"type" => "Update"} = activity), do: validate_update(activity) + defp validate_type_specific(%{"type" => "Delete"} = activity), do: validate_delete(activity) + defp validate_type_specific(%{"type" => "Follow"} = activity), do: validate_follow(activity) + + defp validate_type_specific(%{"type" => "Accept"} = activity), + do: validate_accept_reject(activity) + + defp validate_type_specific(%{"type" => "Reject"} = activity), + do: validate_accept_reject(activity) + + defp validate_type_specific(%{"type" => "Like"} = activity), do: validate_like(activity) + defp validate_type_specific(%{"type" => "Announce"} = activity), do: validate_announce(activity) + defp validate_type_specific(%{"type" => "Move"} = activity), do: validate_move(activity) + defp validate_type_specific(%{"type" => "Undo"} = activity), do: validate_undo(activity) + defp validate_type_specific(%{"type" => "Flag"} = activity), do: validate_flag(activity) + defp validate_type_specific(%{"type" => "Block"} = activity), do: validate_block(activity) + + defp validate_type_specific(%{"type" => type} = activity) + when type in ["Person", "Group", "Application", "Service", "Organization"] do + validate_actor_object(activity) + end + + # Unknown types pass through + defp validate_type_specific(activity), do: {:ok, activity} + + # Create validation + defp validate_create(%{"object" => object} = activity) when is_map(object) do + object = normalize_embedded_object(object) + + with {:ok, object} <- validate_object(object), + :ok <- validate_object_author_matches_activity(activity, object), + :ok <- validate_object_addressing_matches_activity(activity, object) do + {:ok, Map.put(activity, "object", object)} + end + end + + defp validate_create(%{"object" => object_uri} = activity) when is_binary(object_uri) do + if valid_uri?(object_uri), do: {:ok, activity}, else: {:error, "Create object URI invalid"} + end + + defp validate_create(_), do: {:error, "Create activity missing object"} + + # Update validation + defp validate_update(%{"object" => object} = activity) when is_map(object) do + object = normalize_embedded_object(object) + + with {:ok, object} <- validate_object(object), + :ok <- validate_object_author_matches_activity(activity, object), + :ok <- validate_object_addressing_matches_activity(activity, object) do + {:ok, Map.put(activity, "object", object)} + end + end + + defp validate_update(%{"object" => object_uri} = activity) when is_binary(object_uri) do + if valid_uri?(object_uri), do: {:ok, activity}, else: {:error, "Update object URI invalid"} + end + + defp validate_update(_), do: {:error, "Update activity missing object"} + + # Delete validation + defp validate_delete(%{"object" => object} = activity) + when is_binary(object) or is_map(object) do + validate_object_ref_or_map(activity, object, "Delete object URI invalid") + end + + defp validate_delete(_), do: {:error, "Delete activity missing object"} + + # Follow validation + defp validate_follow(%{"object" => object} = activity) + when is_binary(object) or is_map(object) do + validate_object_ref_or_map(activity, object, "Follow object URI invalid") + end + + defp validate_follow(_), do: {:error, "Follow activity missing object"} + + # Accept/Reject validation + defp validate_accept_reject(%{"object" => object} = activity) + when is_binary(object) or is_map(object) do + validate_object_ref_or_map(activity, object, "Accept/Reject object URI invalid") + end + + defp validate_accept_reject(_), do: {:error, "Accept/Reject activity missing object"} + + # Like validation + defp validate_like(%{"object" => object} = activity) when is_binary(object) or is_map(object) do + validate_object_ref_or_map(activity, object, "Like object URI invalid") + end + + defp validate_like(_), do: {:error, "Like activity missing object"} + + # Announce validation + defp validate_announce(%{"object" => object} = activity) + when is_binary(object) or is_map(object) do + validate_object_ref_or_map(activity, object, "Announce object URI invalid") + end + + defp validate_announce(%{"object" => objects} = activity) when is_list(objects) do + if Enum.all?(objects, &announce_object_ref?/1) do + {:ok, activity} + else + {:error, "Announce activity has invalid object list"} + end + end + + defp validate_announce(_), do: {:error, "Announce activity missing object"} + + # Move validation + defp validate_move(%{"object" => object, "target" => target} = activity) + when (is_binary(object) or is_map(object)) and (is_binary(target) or is_map(target)) do + with {:ok, activity} <- + validate_object_ref_or_map(activity, object, "Move object URI invalid"), + :ok <- validate_ref_or_map(target, "Move target URI invalid") do + {:ok, activity} + end + end + + defp validate_move(%{"object" => _}), do: {:error, "Move activity missing target"} + defp validate_move(_), do: {:error, "Move activity missing object"} + + defp announce_object_ref?(object) when is_binary(object) or is_map(object), do: true + defp announce_object_ref?(_), do: false + + # Undo validation + defp validate_undo(%{"object" => object} = activity) when is_binary(object) or is_map(object) do + validate_object_ref_or_map(activity, object, "Undo object URI invalid") + end + + defp validate_undo(_), do: {:error, "Undo activity missing object"} + + # Flag (report) validation + defp validate_flag(%{"object" => objects} = activity) + when is_list(objects) or is_binary(objects) do + objects + |> List.wrap() + |> Enum.all?(fn + object when is_binary(object) -> valid_uri?(object) + _ -> true + end) + |> if(do: {:ok, activity}, else: {:error, "Flag object URI invalid"}) + end + + defp validate_flag(_), do: {:error, "Flag activity missing object"} + + # Block validation + defp validate_block(%{"object" => object} = activity) when is_binary(object) do + if valid_uri?(object), do: {:ok, activity}, else: {:error, "Block object URI invalid"} + end + + defp validate_block(_), do: {:error, "Block activity missing object"} + + # Object validation (for embedded objects in Create/Update) + defp validate_object(object) when is_map(object) do + cond do + !is_binary(object["type"]) -> + {:error, "Object missing type"} + + object["type"] in [ + "Note", + "Article", + "Page", + "Question", + "Event", + "Audio", + "Video", + "Image" + ] -> + validate_content_object(object) + + true -> + {:ok, object} + end + end + + # Content object validation (Note, Article, etc.) + defp validate_content_object(object) do + # Content objects should have some content + has_content = + is_binary(object["content"]) or + is_binary(object["summary"]) or + is_binary(object["name"]) or + is_list(object["attachment"]) + + cond do + not has_content -> + {:error, "Content object has no content"} + + not object_identity_valid?(object) -> + {:error, "Content object has invalid id or url"} + + not attachments_valid?(object["attachment"]) -> + {:error, "Content object has invalid attachment"} + + not tags_valid?(object["tag"]) -> + {:error, "Content object has invalid tag"} + + object["type"] == "Question" and not question_options_valid?(object) -> + {:error, "Question object has invalid options"} + + true -> + {:ok, object} + end + end + + # Actor object validation + defp validate_actor_object(actor) do + cond do + !is_binary(actor["id"]) -> + {:error, "Actor missing id"} + + !is_binary(actor["inbox"]) -> + {:error, "Actor missing inbox"} + + !valid_uri?(actor["id"]) -> + {:error, "Actor id is not a valid URI"} + + !valid_uri?(actor["inbox"]) -> + {:error, "Actor inbox is not a valid URI"} + + true -> + {:ok, actor} + end + end + + # URI validation helper + defp valid_uri?(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and byte_size(host) > 0 -> + true + + _ -> + false + end + end + + defp valid_uri?(_), do: false + + defp safe_uri?(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and byte_size(host) > 0 -> + not URLValidator.private_ip?(host) and not URLValidator.is_private_domain?(host) + + _ -> + false + end + end + + defp safe_uri?(_), do: false + + defp validate_object_ref_or_map(activity, object, error_message) when is_binary(object) do + if valid_uri?(object), do: {:ok, activity}, else: {:error, error_message} + end + + defp validate_object_ref_or_map(activity, object, _error_message) when is_map(object) do + object = normalize_embedded_object(object) + + with {:ok, object} <- validate_object(object) do + {:ok, Map.put(activity, "object", object)} + end + end + + defp validate_ref_or_map(object, error_message) when is_binary(object) do + if valid_uri?(object), do: :ok, else: {:error, error_message} + end + + defp validate_ref_or_map(object, _error_message) when is_map(object) do + object + |> normalize_embedded_object() + |> validate_object() + |> case do + {:ok, _object} -> :ok + error -> error + end + end + + defp normalize_embedded_object(object) when is_map(object) do + object + |> normalize_quote_url() + |> normalize_map_list_field("attachment") + |> normalize_map_list_field("tag") + end + + defp normalize_quote_url(%{"quoteUrl" => _} = object), do: object + + defp normalize_quote_url(%{"quoteUri" => quote_url} = object), + do: Map.put(object, "quoteUrl", quote_url) + + defp normalize_quote_url(%{"quoteURL" => quote_url} = object), + do: Map.put(object, "quoteUrl", quote_url) + + defp normalize_quote_url(%{"_misskey_quote" => quote_url} = object), + do: Map.put(object, "quoteUrl", quote_url) + + defp normalize_quote_url(object), do: object + + defp normalize_map_list_field(object, field) do + case Map.get(object, field) do + value when is_map(value) -> Map.put(object, field, [value]) + values when is_list(values) -> Map.put(object, field, Enum.filter(values, &is_map/1)) + nil -> object + _ -> Map.delete(object, field) + end + end + + defp validate_object_author_matches_activity(%{"actor" => actor}, object) + when is_binary(actor) do + object_actors = + object + |> Map.take(["actor", "attributedTo"]) + |> Map.values() + |> Enum.flat_map(&actor_refs/1) + |> Enum.uniq() + + if object_actors == [] or actor in object_actors do + :ok + else + {:error, "Object actor does not match activity actor"} + end + end + + defp validate_object_author_matches_activity(_activity, _object), do: :ok + + defp actor_refs(value) when is_binary(value), do: [value] + defp actor_refs(values) when is_list(values), do: Enum.flat_map(values, &actor_refs/1) + defp actor_refs(%{"id" => id}) when is_binary(id), do: [id] + defp actor_refs(%{"href" => href}) when is_binary(href), do: [href] + defp actor_refs(_), do: [] + + defp validate_object_addressing_matches_activity(activity, object) do + ["to", "cc", "bto", "bcc"] + |> Enum.find_value(:ok, fn field -> + activity_recipients = Map.get(activity, field) + object_recipients = Map.get(object, field) + + cond do + is_nil(activity_recipients) or is_nil(object_recipients) -> + false + + recipient_set(activity_recipients) == recipient_set(object_recipients) -> + false + + true -> + {:error, "Object #{field} does not match activity #{field}"} + end + end) + end + + defp recipient_set(values) when is_list(values), + do: values |> Enum.filter(&is_binary/1) |> MapSet.new() + + defp recipient_set(value) when is_binary(value), do: MapSet.new([value]) + defp recipient_set(_), do: MapSet.new() + + defp object_identity_valid?(object) do + ["id", "url"] + |> Enum.all?(fn field -> + case Map.get(object, field) do + nil -> true + uri when is_binary(uri) -> safe_uri?(uri) + %{"href" => href} when is_binary(href) -> safe_uri?(href) + values when is_list(values) -> Enum.all?(values, &url_value_valid?/1) + _ -> false + end + end) + end + + defp url_value_valid?(uri) when is_binary(uri), do: safe_uri?(uri) + defp url_value_valid?(%{"href" => href}) when is_binary(href), do: safe_uri?(href) + defp url_value_valid?(_), do: false + + defp attachments_valid?(nil), do: true + + defp attachments_valid?(attachments) when is_list(attachments), + do: Enum.all?(attachments, &attachment_valid?/1) + + defp attachments_valid?(_), do: false + + defp attachment_valid?(attachment) when is_map(attachment) do + type = attachment["type"] || "Document" + media_type = attachment["mediaType"] || attachment["mimeType"] || "application/octet-stream" + url = attachment["url"] || attachment["href"] + + type in ["Document", "Audio", "Image", "Video", "Link"] and mime_type_valid?(media_type) and + attachment_url_valid?(url) + end + + defp attachment_valid?(_), do: false + + defp attachment_url_valid?(nil), do: true + defp attachment_url_valid?(url) when is_binary(url), do: safe_uri?(url) + defp attachment_url_valid?(%{"href" => href}) when is_binary(href), do: safe_uri?(href) + + defp attachment_url_valid?(urls) when is_list(urls), + do: Enum.all?(urls, &attachment_url_valid?/1) + + defp attachment_url_valid?(_), do: false + + defp tags_valid?(nil), do: true + defp tags_valid?(tags) when is_list(tags), do: Enum.all?(tags, &tag_valid?/1) + defp tags_valid?(_), do: false + + defp tag_valid?(%{"type" => "Mention", "href" => href}), do: safe_uri?(href) + + defp tag_valid?(%{"type" => "Hashtag", "name" => name}), + do: is_binary(name) and String.trim(name) != "" + + defp tag_valid?(%{"type" => "Emoji", "name" => name, "icon" => %{"url" => url}}), + do: is_binary(name) and safe_uri?(url) + + defp tag_valid?(%{"type" => "Link", "href" => href} = tag), + do: safe_uri?(href) and mime_type_valid?(tag["mediaType"] || "application/activity+json") + + defp tag_valid?(%{"type" => type}) when is_binary(type), do: true + defp tag_valid?(_), do: false + + defp question_options_valid?(%{"oneOf" => options}) when is_list(options), + do: poll_options_valid?(options) + + defp question_options_valid?(%{"anyOf" => options}) when is_list(options), + do: poll_options_valid?(options) + + defp question_options_valid?(_), do: false + + defp poll_options_valid?(options) do + options != [] and + Enum.all?(options, fn + %{"type" => "Note", "name" => name} -> is_binary(name) and String.trim(name) != "" + %{"name" => name} -> is_binary(name) and String.trim(name) != "" + _ -> false + end) + end + + defp mime_type_valid?(value) when is_binary(value) do + Regex.match?(~r/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*(?:\s*;.*)?$/i, value) + end + + defp mime_type_valid?(_), do: false + + @doc """ + Quick validation check - returns true if object looks valid, false otherwise. + Useful for filtering before full processing. + """ + def valid?(object) when is_map(object) do + case validate(object) do + {:ok, _} -> true + _ -> false + end + end + + def valid?(_), do: false + + @doc """ + Returns the validation error message, or nil if valid. + """ + def error_message(object) when is_map(object) do + case validate(object) do + {:ok, _} -> nil + {:error, message} -> message + end + end + + def error_message(_), do: "Activity must be a map" + + @doc """ + Validates that a URI is well-formed and uses http(s). + """ + def validate_uri(uri) when is_binary(uri) do + if valid_uri?(uri) do + {:ok, uri} + else + {:error, "Invalid URI: #{uri}"} + end + end + + def validate_uri(_), do: {:error, "URI must be a string"} +end diff --git a/apps/elektrine/lib/elektrine/activitypub/outbox.ex b/apps/elektrine/lib/elektrine/activitypub/outbox.ex new file mode 100644 index 0000000..ce88424 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/outbox.ex @@ -0,0 +1,1109 @@ +defmodule Elektrine.ActivityPub.Outbox do + @moduledoc """ + Handles outgoing ActivityPub activities. + Publishes local user actions to remote followers. + """ + + import Ecto.Query, warn: false + + require Logger + + alias Elektrine.Accounts + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.{Builder, Mentions, Publisher, RemoteFetch} + alias Elektrine.Bluesky.OutboundWorker + alias Elektrine.Messaging.{FederatedBoost, FederatedLike} + alias Elektrine.Repo + alias Elektrine.Social.Message + + @doc """ + Federates a newly created message/post to remote followers. + Called automatically when a user creates a public or followers-only post. + """ + def federate_post(%Message{} = message) do + maybe_federate_activitypub(message) + maybe_federate_bluesky(message) + :ok + end + + defp maybe_federate_activitypub(%Message{} = message) do + with true <- federatable_post?(message), + %{} = user <- activitypub_user(message.sender_id) do + poll = maybe_poll_for_message(message) + create_activity = build_post_create_activity(message, user, poll) + maybe_set_message_activitypub_id(message, create_activity["object"]["id"]) + community_uri = get_community_uri_from_chain(message) + inbox_urls = fanout_inboxes_for_post(message, user.id) + + Logger.debug( + "Federating post #{message.id}: " <> + "base=#{length(base_post_inboxes(message, user.id))}, " <> + "mentions=#{length(mention_inboxes_for_message(message))}, " <> + "relays=#{length(relay_inboxes_for_visibility(message.visibility))}, " <> + "community=#{length(community_inboxes_for_uri(community_uri))}, " <> + "total=#{length(inbox_urls)}, community_uri=#{inspect(community_uri)}" + ) + + publish_post_activity(message, create_activity, user, inbox_urls) + end + + :ok + end + + defp maybe_federate_bluesky(%Message{} = message) do + _ = OutboundWorker.enqueue_mirror_post(message.id) + :ok + end + + defp federatable_post?(%Message{visibility: visibility, sender_id: sender_id}) do + visibility in ["public", "unlisted", "followers"] and not is_nil(sender_id) + end + + defp activitypub_user(sender_id) when not is_nil(sender_id) do + user = Accounts.get_user!(sender_id) + if user.activitypub_enabled, do: user, else: nil + end + + defp activitypub_user(_), do: nil + + defp maybe_poll_for_message(%Message{post_type: "poll", id: message_id}) do + case poll_schema() do + nil -> + nil + + poll_schema -> + Repo.get_by(poll_schema, message_id: message_id) + |> Repo.preload(:options) + end + end + + defp maybe_poll_for_message(_), do: nil + + defp poll_schema do + if Code.ensure_loaded?(Elektrine.Social.Poll), do: Elektrine.Social.Poll, else: nil + end + + defp build_post_create_activity(message, user, nil), + do: Builder.build_create_activity(message, user) + + defp build_post_create_activity(message, user, poll) do + question = Builder.build_question(message, user, poll) + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "#{question["id"]}/activity", + "type" => "Create", + "actor" => ActivityPub.actor_uri(user), + "published" => Builder.format_datetime(message.inserted_at), + "to" => question["to"], + "cc" => question["cc"], + "object" => question + } + end + + defp maybe_set_message_activitypub_id(%Message{activitypub_id: nil} = message, object_id) do + Elektrine.Messaging.update_message(message, %{ + activitypub_id: object_id, + activitypub_url: object_id + }) + end + + defp maybe_set_message_activitypub_id(_message, _object_id), do: :ok + + defp base_post_inboxes(%Message{reply_to_id: nil}, user_id), + do: Publisher.get_follower_inboxes(user_id) + + defp base_post_inboxes(message, user_id), do: get_reply_inboxes(message, user_id) + + defp mention_inboxes_for_message(%Message{content: content}) when is_binary(content), + do: Mentions.get_mention_inboxes(content) + + defp mention_inboxes_for_message(_), do: [] + + defp relay_inboxes_for_visibility("public"), do: ActivityPub.get_relay_inboxes() + defp relay_inboxes_for_visibility(_), do: [] + + defp community_inboxes_for_uri(nil), do: [] + + defp community_inboxes_for_uri(uri) do + case ActivityPub.get_actor_by_uri(uri) do + nil -> + fetch_community_inboxes(uri) + + %{inbox_url: inbox_url} -> + maybe_single_inbox(inbox_url) + end + end + + defp fetch_community_inboxes(uri) do + case RemoteFetch.fetch_actor(uri) do + {:ok, actor_data} -> + inbox = get_in(actor_data, ["endpoints", "sharedInbox"]) || actor_data["inbox"] + maybe_single_inbox(inbox) + + _ -> + [] + end + end + + defp maybe_single_inbox(inbox_url) when is_binary(inbox_url), do: [inbox_url] + defp maybe_single_inbox(_), do: [] + + defp fanout_inboxes_for_post(message, user_id) do + base_inboxes = base_post_inboxes(message, user_id) + mention_inboxes = mention_inboxes_for_message(message) + relay_inboxes = relay_inboxes_for_visibility(message.visibility) + community_uri = get_community_uri_from_chain(message) + community_inboxes = community_inboxes_for_uri(community_uri) + + (base_inboxes ++ mention_inboxes ++ relay_inboxes ++ community_inboxes) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp publish_post_activity(message, _create_activity, _user, []), + do: Logger.warning("No inboxes to send post #{message.id} to") + + defp publish_post_activity(_message, create_activity, user, inbox_urls), + do: Publisher.publish(create_activity, user, inbox_urls) + + defp maybe_publish(_activity, _user, []), do: :ok + + defp maybe_publish(activity, user, inbox_urls), + do: Publisher.publish(activity, user, inbox_urls) + + defp get_reply_inboxes(message, user_id) do + # Get the parent message + parent = Elektrine.Messaging.get_message(message.reply_to_id) + + base_inboxes = Publisher.get_follower_inboxes(user_id) + + if parent && parent.federated && parent.remote_actor_id do + # Parent is a federated post - send to remote author + remote_actor = Elektrine.Repo.get(ActivityPub.Actor, parent.remote_actor_id) + + if remote_actor do + # Include remote author's inbox (prefer shared inbox for Lemmy/large instances) + # Shared inbox is stored in metadata.endpoints.sharedInbox, not as a direct field + shared_inbox = get_in(remote_actor.metadata || %{}, ["endpoints", "sharedInbox"]) + author_inbox = shared_inbox || remote_actor.inbox_url + + inboxes = [author_inbox | base_inboxes] + + Enum.uniq(Enum.reject(inboxes, &is_nil/1)) + else + Logger.warning("Remote actor not found for federated parent") + base_inboxes + end + else + base_inboxes + end + end + + # Walk up the reply chain to find community_actor_uri (for Lemmy posts) + # This ensures replies to replies in a Lemmy thread still get sent to the community + defp get_community_uri_from_chain(message, depth \\ 0) + + defp get_community_uri_from_chain(_message, depth) when depth > 10 do + # Prevent infinite loops - max 10 levels deep + nil + end + + defp get_community_uri_from_chain(message, depth) do + # Check current message's metadata + case get_in(message.media_metadata || %{}, ["community_actor_uri"]) do + uri when is_binary(uri) -> + uri + + nil -> + with reply_to_id when not is_nil(reply_to_id) <- message.reply_to_id, + %{} = parent <- Elektrine.Messaging.get_message(reply_to_id) do + get_community_uri_from_chain(parent, depth + 1) + else + _ -> nil + end + end + end + + @doc """ + Sends an Undo Like activity. + """ + def federate_unlike(message_id, user_id) do + message = Elektrine.Messaging.get_message(message_id) + user = Accounts.get_user!(user_id) + + if message && message.activitypub_id && user.activitypub_enabled do + like_activity = + latest_local_activity_data(user.id, "Like", message.activitypub_id) || + maybe_add_remote_author_audience( + message, + Builder.build_like_activity(user, message.activitypub_id) + ) + + # Build Undo activity + undo_activity = Builder.build_undo_activity(user, like_activity) + + inbox_urls = target_inboxes_for_message(message) + + if inbox_urls != [] do + Publisher.publish(undo_activity, user, inbox_urls) + end + end + + :ok + end + + @doc """ + Sends a Like activity to the original author. + """ + def federate_like(message_id, user_id) do + message = Elektrine.Messaging.get_message(message_id) + user = Accounts.get_user!(user_id) + + if message && message.activitypub_id && user.activitypub_enabled do + # Build Like activity + like_activity = + maybe_add_remote_author_audience( + message, + Builder.build_like_activity(user, message.activitypub_id) + ) + + inbox_urls = target_inboxes_for_message(message) + + if inbox_urls != [] do + Publisher.publish(like_activity, user, inbox_urls) + end + end + + :ok + end + + @doc """ + Sends a Dislike activity (downvote) to the original author. + """ + def federate_dislike(message_id, user_id) do + message = Elektrine.Messaging.get_message(message_id) + user = Accounts.get_user!(user_id) + + if message && message.activitypub_id && user.activitypub_enabled do + # Build Dislike activity + dislike_activity = + maybe_add_remote_author_audience( + message, + Builder.build_dislike_activity(user, message.activitypub_id) + ) + + inbox_urls = target_inboxes_for_message(message) + + if inbox_urls != [] do + Publisher.publish(dislike_activity, user, inbox_urls) + end + end + + :ok + end + + @doc """ + Sends an Undo Dislike activity. + """ + def federate_undo_dislike(message_id, user_id) do + message = Elektrine.Messaging.get_message(message_id) + user = Accounts.get_user!(user_id) + + if message && message.activitypub_id && user.activitypub_enabled do + dislike_activity = + latest_local_activity_data(user.id, "Dislike", message.activitypub_id) || + maybe_add_remote_author_audience( + message, + Builder.build_dislike_activity(user, message.activitypub_id) + ) + + # Build Undo activity + undo_activity = Builder.build_undo_activity(user, dislike_activity) + + inbox_urls = target_inboxes_for_message(message) + + if inbox_urls != [] do + Publisher.publish(undo_activity, user, inbox_urls) + end + end + + :ok + end + + @doc """ + Sends an Announce (boost/share) activity. + """ + def federate_announce(message_id, user_id) do + message = Elektrine.Messaging.get_message(message_id) + user = Accounts.get_user!(user_id) + + if message && message.activitypub_id && user.activitypub_enabled do + # Build Announce activity + announce_activity = Builder.build_announce_activity(user, message.activitypub_id) + + # Send to user's followers + follower_inboxes = Publisher.get_follower_inboxes(user.id) + + # If boosting a federated post, also send to the original author + author_inbox = maybe_remote_author_inbox(message) + + inbox_urls = (follower_inboxes ++ author_inbox) |> Enum.uniq() + + if inbox_urls != [] do + Publisher.publish(announce_activity, user, inbox_urls) + end + end + + :ok + end + + @doc """ + Sends an Undo Announce (unboost) activity. + """ + def federate_undo_announce(message_id, user_id) do + message = Elektrine.Messaging.get_message(message_id) + user = Accounts.get_user!(user_id) + + if message && message.activitypub_id && user.activitypub_enabled do + announce_activity = + latest_local_activity_data(user.id, "Announce", message.activitypub_id) || + Builder.build_announce_activity(user, message.activitypub_id) + + # Build Undo activity + undo_activity = Builder.build_undo_activity(user, announce_activity) + + # Send to user's followers + follower_inboxes = Publisher.get_follower_inboxes(user.id) + + # If unboosting a federated post, also send to the original author + author_inbox = maybe_remote_author_inbox(message) + + inbox_urls = (follower_inboxes ++ author_inbox) |> Enum.uniq() + + if inbox_urls != [] do + Publisher.publish(undo_activity, user, inbox_urls) + end + end + + :ok + end + + @doc """ + Sends an Undo EmojiReact activity. + """ + def federate_undo_emoji_react(message_id, user_id, emoji) do + message = Elektrine.Messaging.get_message(message_id) + user = Accounts.get_user!(user_id) + + if message && message.activitypub_id && user.activitypub_enabled do + emoji_react_activity = + latest_local_activity_data(user.id, "EmojiReact", message.activitypub_id, content: emoji) || + Builder.build_emoji_react_activity(user, message.activitypub_id, emoji) + + # Build Undo activity + undo_activity = Builder.build_undo_activity(user, emoji_react_activity) + + inbox_urls = target_inboxes_for_message(message) + + if inbox_urls != [] do + Publisher.publish(undo_activity, user, inbox_urls) + end + end + + :ok + end + + @doc """ + Sends a Delete activity when a message is deleted. + """ + def federate_delete(%Message{} = message) do + case local_public_community(message) do + {:ok, community} -> federate_local_community_delete(message, community) + :error -> federate_user_delete(message) + end + + :ok + end + + @doc """ + Sends an Update activity when a message is edited. + """ + def federate_update(%Message{} = message) do + case local_public_community(message) do + {:ok, community} -> federate_local_community_update(message, community) + :error -> federate_user_update(message) + end + + :ok + end + + @doc """ + Sends an Update activity when user updates their profile. + """ + def federate_profile_update(user_id) do + user = Accounts.get_user!(user_id) + + if user.activitypub_enabled do + # Keep exported actor fields in sync with the profile shown over HTTP. + user = Elektrine.Repo.preload(user, profile: :links) + + # Build updated actor + actor = Builder.build_actor(user) + + # Build Update activity + update_activity = Builder.build_update_activity(user, actor) + + # Send to all followers + inbox_urls = Publisher.get_follower_inboxes(user.id) + + if inbox_urls != [] do + Publisher.publish(update_activity, user, inbox_urls) + end + end + + :ok + end + + defp build_post_object(message, user) do + case maybe_poll_for_message(message) do + %{options: _} = poll -> Builder.build_question(message, user, poll) + _ -> Builder.build_note(message, user) + end + end + + defp build_community_post_object(message, community, author_uri) do + Builder.build_community_object(message, community, + poll: maybe_poll_for_message(message), + author_uri: author_uri + ) + end + + defp update_delete_inboxes(message, user_id, create_activity) do + case delivery_inboxes_for_activity(create_activity) do + [] -> delete_fanout_inboxes(message, fanout_inboxes_for_post(message, user_id)) + inboxes -> inboxes + end + end + + defp community_update_delete_inboxes(message, community_actor_id, create_activity) do + case delivery_inboxes_for_activity(create_activity) do + [] -> delete_fanout_inboxes(message, community_post_inboxes(message, community_actor_id)) + inboxes -> inboxes + end + end + + defp delete_fanout_inboxes(%Message{} = message, base_inboxes) do + (base_inboxes ++ remote_interaction_inboxes_for_message(message)) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp remote_interaction_inboxes_for_message(%Message{id: message_id}) + when is_integer(message_id) do + [ + remote_reply_inboxes(message_id), + remote_quote_inboxes(message_id), + remote_federated_like_inboxes(message_id), + remote_federated_boost_inboxes(message_id) + ] + |> List.flatten() + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp remote_interaction_inboxes_for_message(_), do: [] + + defp remote_reply_inboxes(message_id) do + from(m in Message, + join: a in ActivityPub.Actor, + on: a.id == m.remote_actor_id, + where: m.reply_to_id == ^message_id and is_nil(m.deleted_at), + select: {a.inbox_url, a.metadata} + ) + |> Repo.all() + |> actor_inboxes() + end + + defp remote_quote_inboxes(message_id) do + from(m in Message, + join: a in ActivityPub.Actor, + on: a.id == m.remote_actor_id, + where: m.quoted_message_id == ^message_id and is_nil(m.deleted_at), + select: {a.inbox_url, a.metadata} + ) + |> Repo.all() + |> actor_inboxes() + end + + defp remote_federated_like_inboxes(message_id) do + from(l in FederatedLike, + join: a in ActivityPub.Actor, + on: a.id == l.remote_actor_id, + where: l.message_id == ^message_id, + select: {a.inbox_url, a.metadata} + ) + |> Repo.all() + |> actor_inboxes() + end + + defp remote_federated_boost_inboxes(message_id) do + from(b in FederatedBoost, + join: a in ActivityPub.Actor, + on: a.id == b.remote_actor_id, + where: b.message_id == ^message_id, + select: {a.inbox_url, a.metadata} + ) + |> Repo.all() + |> actor_inboxes() + end + + defp actor_inboxes(rows) do + rows + |> Enum.map(fn {inbox_url, metadata} -> + get_in(metadata || %{}, ["endpoints", "sharedInbox"]) || inbox_url + end) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp delivery_inboxes_for_activity(nil), do: [] + + defp delivery_inboxes_for_activity(%ActivityPub.Activity{id: activity_id}) do + from(d in ActivityPub.Delivery, + where: d.activity_id == ^activity_id, + select: d.inbox_url + ) + |> Repo.all() + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp latest_local_create_activity(%Message{activitypub_id: activitypub_id}, user_id) + when is_binary(activitypub_id) and not is_nil(user_id) do + from(a in ActivityPub.Activity, + where: + a.local == true and + a.internal_user_id == ^user_id and + a.activity_type == "Create" and + a.object_id == ^activitypub_id, + order_by: [desc: a.inserted_at], + limit: 1 + ) + |> Repo.one() + end + + defp latest_local_create_activity(_, _), do: nil + + defp latest_local_community_create_activity(%Message{activitypub_id: activitypub_id}, actor_uri) + when is_binary(activitypub_id) and is_binary(actor_uri) do + from(a in ActivityPub.Activity, + where: + a.local == true and + is_nil(a.internal_user_id) and + a.actor_uri == ^actor_uri and + a.activity_type == "Create" and + a.object_id == ^activitypub_id, + order_by: [desc: a.inserted_at], + limit: 1 + ) + |> Repo.one() + end + + defp latest_local_community_create_activity(_, _), do: nil + + defp activity_audience_opts(create_activity, fallback_object) do + audience_source = + if is_map(create_activity && create_activity.data), do: create_activity.data, else: %{} + + %{ + to: + Map.get(audience_source, "to") || get_in(audience_source, ["object", "to"]) || + fallback_object["to"], + cc: + Map.get(audience_source, "cc") || get_in(audience_source, ["object", "cc"]) || + fallback_object["cc"] + } + end + + defp former_type_for_message(%Message{post_type: "poll"}), do: "Question" + defp former_type_for_message(_message), do: "Note" + + defp federate_user_delete(%Message{} = message) do + with activitypub_id when not is_nil(activitypub_id) <- message.activitypub_id, + sender_id when not is_nil(sender_id) <- message.sender_id, + %{} = user <- activitypub_user(sender_id) do + create_activity = latest_local_create_activity(message, user.id) + + delete_activity = + Builder.build_delete_activity( + user, + activitypub_id, + former_type_for_message(message), + activity_audience_opts(create_activity, build_post_object(message, user)) + ) + + inbox_urls = update_delete_inboxes(message, user.id, create_activity) + maybe_publish(delete_activity, user, inbox_urls) + end + end + + defp federate_user_update(%Message{} = message) do + with true <- not is_nil(message.activitypub_id), + sender_id when not is_nil(sender_id) <- message.sender_id, + %{} = user <- activitypub_user(sender_id) do + create_activity = latest_local_create_activity(message, user.id) + + updated_object = + message + |> build_post_object(user) + |> preserve_original_object_routing(create_activity) + + update_activity = + Builder.build_update_activity( + user, + updated_object, + activity_audience_opts(create_activity, updated_object) + ) + + inbox_urls = update_delete_inboxes(message, user.id, create_activity) + maybe_publish(update_activity, user, inbox_urls) + end + end + + defp federate_local_community_delete(%Message{} = message, community) do + with activitypub_id when not is_nil(activitypub_id) <- message.activitypub_id, + sender_id when not is_nil(sender_id) <- message.sender_id, + %{} = user <- activitypub_user(sender_id), + {:ok, community_actor} <- ActivityPub.get_or_create_community_actor(community.id) do + author_uri = ActivityPub.actor_uri(user) + community_object = build_community_post_object(message, community, author_uri) + create_activity = latest_local_community_create_activity(message, community_actor.uri) + + delete_activity = + Builder.build_delete_activity( + community_actor, + activitypub_id, + former_type_for_message(message), + activity_audience_opts(create_activity, community_object) + ) + + inbox_urls = community_update_delete_inboxes(message, community_actor.id, create_activity) + maybe_publish(delete_activity, nil, inbox_urls) + end + end + + defp federate_local_community_update(%Message{} = message, community) do + with true <- not is_nil(message.activitypub_id), + sender_id when not is_nil(sender_id) <- message.sender_id, + %{} = user <- activitypub_user(sender_id), + {:ok, community_actor} <- ActivityPub.get_or_create_community_actor(community.id) do + create_activity = latest_local_community_create_activity(message, community_actor.uri) + + updated_object = + build_community_post_object( + message, + community, + ActivityPub.actor_uri(user) + ) + |> preserve_original_object_routing(create_activity) + + update_activity = + Builder.build_update_activity( + community_actor, + updated_object, + activity_audience_opts(create_activity, updated_object) + ) + + inbox_urls = community_update_delete_inboxes(message, community_actor.id, create_activity) + maybe_publish(update_activity, nil, inbox_urls) + end + end + + defp local_public_community(%Message{} = message) do + case message_conversation(message) do + %Elektrine.Social.Conversation{ + type: "community", + is_public: true, + is_federated_mirror: false + } = community -> + {:ok, community} + + _ -> + :error + end + end + + defp message_conversation(%Message{ + conversation: %Ecto.Association.NotLoaded{}, + conversation_id: id + }), + do: Repo.get(Elektrine.Social.Conversation, id) + + defp message_conversation(%Message{conversation: nil, conversation_id: id}), + do: Repo.get(Elektrine.Social.Conversation, id) + + defp message_conversation(%Message{conversation: conversation}), do: conversation + + defp community_post_inboxes(message, community_actor_id) do + (relay_inboxes_for_visibility(message.visibility) ++ + ActivityPub.get_group_follower_inboxes(community_actor_id) ++ + mention_inboxes_for_message(message)) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + @doc """ + Federates a community post to remote followers. + Called when a discussion post is created in a public community. + """ + def federate_community_post(%Message{} = message, community) do + if community.is_public && message.sender_id do + # Check if this is a federated mirror - send differently + if community.is_federated_mirror && community.remote_group_actor_id do + federate_post_to_remote_group(message, community) + else + # Local community - create our own Group actor + federate_local_community_post(message, community) + end + end + + :ok + end + + defp federate_local_community_post(message, community) do + with {:ok, community_actor} <- ActivityPub.get_or_create_community_actor(community.id), + %Accounts.User{activitypub_enabled: true} = user <- Accounts.get_user!(message.sender_id) do + post_id = ActivityPub.community_post_uri(community.name, message.id) + + page_object = + build_community_post_object( + message, + community, + ActivityPub.actor_uri(user) + ) + + create_activity = Builder.build_community_create_activity(message, community, page_object) + + maybe_set_community_activitypub_id(message, community, post_id) + + all_inboxes = community_post_inboxes(message, community_actor.id) + + # Community actors don't have a local User, so we publish with `nil` actor user. + maybe_publish(create_activity, nil, all_inboxes) + else + %Accounts.User{activitypub_enabled: false} -> + :ok + + {:error, reason} -> + Logger.error("Failed to create community actor: #{inspect(reason)}") + end + end + + defp maybe_set_community_activitypub_id( + %Message{activitypub_id: nil} = message, + community, + post_id + ) do + Elektrine.Messaging.update_message(message, %{ + activitypub_id: post_id, + activitypub_url: ActivityPub.community_post_web_url(community.name, message.id) + }) + end + + defp maybe_set_community_activitypub_id(_message, _community, _post_id), do: :ok + + @doc """ + Sends a poll vote for a remote ActivityPub poll (viewed on remote post page). + Creates and sends a Create{Note} activity with the option name to the poll author. + """ + def send_poll_vote(user, poll_id, option_name, remote_actor) do + if user.activitypub_enabled && remote_actor && remote_actor.inbox_url do + base_url = ActivityPub.instance_url() + + # Deterministic, restart-stable vote id derived from (user, poll, option). + # `:erlang.unique_integer/1` only guarantees uniqueness within a single + # BEAM lifetime, so ids could collide across restarts and be deduplicated + # away by the remote server; a content hash is globally unique and makes + # re-submitting the same vote idempotent. + vote_ref = + :crypto.hash(:sha256, "#{user.id}:#{poll_id}:#{option_name}") + |> Base.url_encode64(padding: false) + |> binary_part(0, 16) + + # Build vote Note object + vote_object = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "#{ActivityPub.actor_uri(user, base_url)}/votes/#{vote_ref}", + "type" => "Note", + "name" => option_name, + "inReplyTo" => poll_id, + "attributedTo" => ActivityPub.actor_uri(user, base_url), + "to" => [remote_actor.uri], + "cc" => [] + } + + # Build Create activity + create_activity = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "#{vote_object["id"]}/activity", + "type" => "Create", + "actor" => ActivityPub.actor_uri(user, base_url), + "to" => [remote_actor.uri], + "cc" => [], + "object" => vote_object + } + + # Send to poll author's inbox + Publisher.publish(create_activity, user, [remote_actor.inbox_url]) + :ok + else + :ok + end + end + + @doc """ + Sends a poll vote to the remote instance (for local federated polls). + ActivityPub poll votes are sent as Create{Note} with the option name. + """ + def federate_poll_vote(poll, option, user, message) do + if user.activitypub_enabled && message.federated && message.activitypub_id do + # Get the remote actor who created the poll + remote_actor = + if message.remote_actor_id do + Elektrine.Repo.get(ActivityPub.Actor, message.remote_actor_id) + end + + if remote_actor && remote_actor.inbox_url do + base_url = ActivityPub.instance_url() + + # Build vote Note object + vote_object = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "#{ActivityPub.actor_uri(user, base_url)}/votes/#{poll.id}/#{option.id}", + "type" => "Note", + "name" => option.option_text, + "inReplyTo" => message.activitypub_id, + "attributedTo" => ActivityPub.actor_uri(user, base_url), + "to" => [remote_actor.uri], + "cc" => [] + } + + # Build Create activity + create_activity = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "#{vote_object["id"]}/activity", + "type" => "Create", + "actor" => ActivityPub.actor_uri(user, base_url), + "to" => [remote_actor.uri], + "cc" => [], + "object" => vote_object + } + + # Send to poll author's inbox + Publisher.publish(create_activity, user, [remote_actor.inbox_url]) + :ok + else + :ok + end + else + :ok + end + end + + @doc """ + Sends a Flag (report) activity to a remote instance. + Used when reporting remote users or their content. + + ## Parameters + - `reporter_id` - The local user making the report + - `target_actor_uri` - The AP URI of the user being reported + - `object_uris` - List of content URIs being reported (optional) + - `reason` - The report reason/description + """ + def federate_report(reporter_id, target_actor_uri, object_uris \\ [], reason \\ nil) do + user = Accounts.get_user!(reporter_id) + + if user.activitypub_enabled && target_actor_uri do + # Build Flag activity + flag_activity = Builder.build_flag_activity(user, target_actor_uri, object_uris, reason) + + # Determine the inbox to send to (the instance of the reported user) + inbox_url = get_instance_inbox_from_uri(target_actor_uri) + + if inbox_url do + Logger.info( + "Federating report from #{user.username} against #{target_actor_uri} to #{inbox_url}" + ) + + Publisher.publish(flag_activity, user, [inbox_url]) + else + Logger.warning("Could not determine inbox for report target: #{target_actor_uri}") + end + end + + :ok + end + + defp target_inboxes_for_message(%Message{} = message) do + cond do + message.federated && message.remote_actor_id -> + actor_inbox_for_remote_actor(message.remote_actor_id) + + message.sender_id -> + follower_inboxes_for_author(message.sender_id) + + true -> + [] + end + end + + defp maybe_remote_author_inbox(%Message{federated: true, remote_actor_id: remote_actor_id}) + when not is_nil(remote_actor_id) do + actor_inbox_for_remote_actor(remote_actor_id) + end + + defp maybe_remote_author_inbox(_), do: [] + + defp actor_inbox_for_remote_actor(remote_actor_id) do + case Elektrine.Repo.get(ActivityPub.Actor, remote_actor_id) do + %ActivityPub.Actor{inbox_url: inbox_url} when is_binary(inbox_url) -> [inbox_url] + _ -> [] + end + end + + defp follower_inboxes_for_author(sender_id) do + author = Accounts.get_user!(sender_id) + if author.activitypub_enabled, do: Publisher.get_follower_inboxes(author.id), else: [] + end + + defp latest_local_activity_data(user_id, activity_type, object_id, opts \\ []) do + case ActivityPub.get_latest_local_activity(user_id, activity_type, object_id, opts) do + %{data: data} when is_map(data) -> data + _ -> nil + end + end + + defp maybe_add_remote_author_audience( + %Message{federated: true, remote_actor_id: remote_actor_id}, + activity + ) + when is_map(activity) and not is_nil(remote_actor_id) do + case Elektrine.Repo.get(ActivityPub.Actor, remote_actor_id) do + %ActivityPub.Actor{uri: actor_uri} when is_binary(actor_uri) and actor_uri != "" -> + Map.put_new(activity, "to", [actor_uri]) + + _ -> + activity + end + end + + defp maybe_add_remote_author_audience(_, activity), do: activity + + # Get the instance inbox from an actor URI + defp get_instance_inbox_from_uri(actor_uri) when is_binary(actor_uri) do + # First try to get the actor from our database + case ActivityPub.get_actor_by_uri(actor_uri) do + %{metadata: metadata, inbox_url: inbox} -> + shared_inbox = get_in(metadata || %{}, ["endpoints", "sharedInbox"]) + + case preferred_actor_inbox(shared_inbox, inbox) do + nil -> fallback_instance_inbox(actor_uri) + inbox_url -> inbox_url + end + + _ -> + fallback_instance_inbox(actor_uri) + end + end + + defp get_instance_inbox_from_uri(_), do: nil + + defp preferred_actor_inbox(shared_inbox, inbox_url) do + case shared_inbox do + value when is_binary(value) and value != "" -> value + _ -> if(is_binary(inbox_url) and inbox_url != "", do: inbox_url, else: nil) + end + end + + defp fallback_instance_inbox(actor_uri) do + case URI.parse(actor_uri) do + %URI{scheme: scheme, host: host, port: port} + when scheme in ["http", "https"] and is_binary(host) -> + "#{uri_origin(scheme, host, port)}/inbox" + + _ -> + nil + end + end + + defp uri_origin(scheme, host, port) when port in [80, 443, nil], + do: "#{scheme}://#{host}" + + defp uri_origin(scheme, host, port), do: "#{scheme}://#{host}:#{port}" + + defp preserve_original_object_routing(updated_object, nil), do: updated_object + + defp preserve_original_object_routing(updated_object, %ActivityPub.Activity{data: data}) + when is_map(updated_object) and is_map(data) do + case Map.get(data, "object") do + original_object when is_map(original_object) -> + Enum.reduce(["to", "cc", "audience", "context"], updated_object, fn field, acc -> + case Map.get(original_object, field) do + nil -> acc + value -> Map.put(acc, field, value) + end + end) + + _ -> + updated_object + end + end + + defp preserve_original_object_routing(updated_object, _), do: updated_object + + # Federates a post to a remote Group actor. + defp federate_post_to_remote_group(message, mirror_community) do + user = Accounts.get_user!(message.sender_id) + remote_group = Elektrine.Repo.get(ActivityPub.Actor, mirror_community.remote_group_actor_id) + + if remote_group && remote_group.inbox_url do + # Build Note object for the post + note = Builder.build_note(message, user) + + # Add audience field to indicate this is for the Group + note = + Map.merge(note, %{ + "audience" => remote_group.uri, + "context" => remote_group.uri, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [remote_group.uri] + }) + + # Build Create activity + create_activity = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "#{note["id"]}/activity", + "type" => "Create", + "actor" => ActivityPub.actor_uri(user), + "published" => Builder.format_datetime(message.inserted_at), + "to" => note["to"], + "cc" => note["cc"], + "object" => note + } + + # Set ActivityPub ID on the message + if !message.activitypub_id do + Elektrine.Messaging.update_message(message, %{ + activitypub_id: note["id"], + activitypub_url: note["id"] + }) + end + + # Send to the Group's inbox + Publisher.publish(create_activity, user, [remote_group.inbox_url]) + else + Logger.warning("Remote group actor not found or has no inbox") + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/pipeline.ex b/apps/elektrine/lib/elektrine/activitypub/pipeline.ex new file mode 100644 index 0000000..4d01e60 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/pipeline.ex @@ -0,0 +1,221 @@ +defmodule Elektrine.ActivityPub.Pipeline do + @moduledoc "Formal pipeline for processing ActivityPub activities.\n\nImplements a structured flow for incoming and outgoing activities:\n1. Validate - Validate activity structure and content\n2. MRF - Apply Message Rewrite Facility policies\n3. Persist - Save to database within a transaction\n4. Side Effects - Handle notifications, broadcasts, etc.\n5. Federate - Deliver to remote instances (for local activities)\n\nAll operations are wrapped in a database transaction for atomicity.\n" + require Logger + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.{MRF, ObjectValidator, SideEffects, UndoResolver} + alias Elektrine.Repo + + alias Elektrine.ActivityPub.Handlers.{ + AnnounceHandler, + BlockHandler, + CreateHandler, + DeleteHandler, + FollowHandler, + LikeHandler, + MoveHandler, + UpdateHandler + } + + @type pipeline_result :: {:ok, map()} | {:error, atom()} | {:reject, String.t()} + @doc "Processes an incoming activity through the full pipeline.\nWraps all operations in a transaction for atomicity.\n" + @spec process_incoming(map(), String.t(), map() | nil) :: pipeline_result() + def process_incoming(activity, actor_uri, target_user) do + Repo.transaction(fn -> + with {:ok, activity} <- validate(activity), + {:ok, activity} <- apply_mrf(activity, actor_uri), + {:ok, result} <- handle_activity(activity, actor_uri, target_user), + :ok <- handle_side_effects(activity, actor_uri, result) do + result + else + {:reject, reason} -> Repo.rollback({:rejected, reason}) + {:error, reason} -> Repo.rollback(reason) + end + end) + |> normalize_result() + end + + @doc "Processes an outgoing activity (created locally).\nValidates, persists, and queues for federation.\n" + @spec process_outgoing(map(), map()) :: pipeline_result() + def process_outgoing(activity, user) do + Repo.transaction(fn -> + with {:ok, activity} <- validate(activity), + {:ok, activity} <- apply_mrf(activity, ActivityPub.instance_url()), + {:ok, result} <- persist_local_activity(activity, user), + :ok <- queue_federation(activity, user) do + result + else + {:reject, reason} -> Repo.rollback({:rejected, reason}) + {:error, reason} -> Repo.rollback(reason) + end + end) + |> normalize_result() + end + + @doc "Stage 1: Validate the activity structure.\n" + def validate(activity) do + case ObjectValidator.validate(activity) do + {:ok, validated} -> {:ok, validated} + {:error, reason} -> {:error, {:validation_failed, reason}} + end + end + + @doc "Stage 2: Apply MRF policies.\n" + def apply_mrf(activity, actor_uri) do + case MRF.filter(activity) do + {:ok, filtered} -> + {:ok, filtered} + + {:reject, reason} -> + Logger.info("Pipeline: MRF rejected activity from #{actor_uri}: #{reason}") + {:reject, reason} + end + end + + @doc "Stage 3: Handle the activity based on type.\nRoutes to the appropriate handler module.\n" + def handle_activity(%{"type" => "Follow"} = activity, actor_uri, target_user) do + FollowHandler.handle(activity, actor_uri, target_user) + end + + def handle_activity(%{"type" => "Accept"} = activity, actor_uri, target_user) do + FollowHandler.handle_accept(activity, actor_uri, target_user) + end + + def handle_activity(%{"type" => "Reject"} = activity, actor_uri, target_user) do + FollowHandler.handle_reject(activity, actor_uri, target_user) + end + + def handle_activity(%{"type" => "Create"} = activity, actor_uri, target_user) do + CreateHandler.handle(activity, actor_uri, target_user) + end + + def handle_activity(%{"type" => "Like"} = activity, actor_uri, target_user) do + LikeHandler.handle(activity, actor_uri, target_user) + end + + def handle_activity(%{"type" => "Dislike"} = activity, actor_uri, target_user) do + LikeHandler.handle_dislike(activity, actor_uri, target_user) + end + + def handle_activity(%{"type" => "EmojiReact"} = activity, actor_uri, target_user) do + LikeHandler.handle_emoji_react(activity, actor_uri, target_user) + end + + def handle_activity(%{"type" => "Announce"} = activity, actor_uri, target_user) do + AnnounceHandler.handle(activity, actor_uri, target_user) + end + + def handle_activity(%{"type" => "Move"} = activity, actor_uri, target_user) do + MoveHandler.handle(activity, actor_uri, target_user) + end + + def handle_activity(%{"type" => "Undo"} = activity, actor_uri, target_user) do + handle_undo(activity, actor_uri, target_user) + end + + def handle_activity(%{"type" => "Delete"} = activity, actor_uri, target_user) do + DeleteHandler.handle(activity, actor_uri, target_user) + end + + def handle_activity(%{"type" => "Update"} = activity, actor_uri, target_user) do + UpdateHandler.handle(activity, actor_uri, target_user) + end + + def handle_activity(%{"type" => "Block"} = activity, actor_uri, target_user) do + BlockHandler.handle(activity, actor_uri, target_user) + end + + def handle_activity(_activity, _actor_uri, _target_user) do + {:ok, :unhandled} + end + + @doc "Stage 4: Handle side effects after main processing.\n" + def handle_side_effects(activity, actor_uri, result) do + SideEffects.handle(activity, actor_uri, result) + end + + defp handle_undo(%{"object" => object} = _activity, actor_uri, _target_user) + when is_map(object) do + case object["type"] do + "Follow" -> FollowHandler.handle_undo(object, actor_uri) + "Like" -> LikeHandler.handle_undo_like(object, actor_uri) + "Dislike" -> LikeHandler.handle_undo_dislike(object, actor_uri) + "EmojiReact" -> LikeHandler.handle_undo_emoji_react(object, actor_uri) + "Announce" -> AnnounceHandler.handle_undo(object, actor_uri) + "Block" -> BlockHandler.handle_undo(object, actor_uri) + _ -> {:ok, :unhandled} + end + end + + defp handle_undo(%{"object" => object_uri}, actor_uri, target_user) + when is_binary(object_uri) do + case UndoResolver.resolve(object_uri, actor_uri) do + {:ok, object} -> + handle_undo(%{"object" => object}, actor_uri, target_user) + + :not_found -> + case ActivityPub.RemoteFetch.fetch_object(object_uri) do + {:ok, object} when is_map(object) -> + handle_undo(%{"object" => object}, actor_uri, target_user) + + {:ok, _object} -> + Logger.warning("Undo object #{object_uri} did not resolve to an object map") + {:error, :undo_activity_fetch_failed} + + {:error, reason} -> + Logger.warning("Failed to fetch Undo object #{object_uri}: #{inspect(reason)}") + {:error, :undo_activity_fetch_failed} + end + end + end + + defp handle_undo(_, _, _) do + {:ok, :unhandled} + end + + defp persist_local_activity(activity, user) do + case ActivityPub.create_activity(%{ + activity_id: activity["id"], + activity_type: activity["type"], + actor_uri: activity["actor"], + object_id: get_object_id(activity), + data: activity, + local: true, + processed: true, + internal_user_id: user.id + }) do + {:ok, record} -> {:ok, record} + {:error, changeset} -> {:error, {:persist_failed, changeset}} + end + end + + defp queue_federation(activity, user) do + case Elektrine.ActivityPub.Publisher.publish_async(activity, user) do + :ok -> :ok + {:error, reason} -> {:error, {:federation_failed, reason}} + end + end + + defp get_object_id(%{"object" => object}) when is_binary(object) do + object + end + + defp get_object_id(%{"object" => %{"id" => id}}) do + id + end + + defp get_object_id(_) do + nil + end + + defp normalize_result({:ok, result}) do + {:ok, result} + end + + defp normalize_result({:error, {:rejected, reason}}) do + {:reject, reason} + end + + defp normalize_result({:error, reason}) do + {:error, reason} + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/process_activity_worker.ex b/apps/elektrine/lib/elektrine/activitypub/process_activity_worker.ex new file mode 100644 index 0000000..e1c85ab --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/process_activity_worker.ex @@ -0,0 +1,421 @@ +defmodule Elektrine.ActivityPub.ProcessActivityWorker do + @moduledoc """ + Oban worker for processing incoming ActivityPub activities. + + Activities are enqueued when received at the inbox and processed + asynchronously with automatic retries on failure. + + Uses Lemmy-style per-domain throttling to prevent one noisy instance + from overwhelming the system. + + Deduplication is handled via fast ETS lookup instead of Oban's database-based + uniqueness constraint to avoid the 700-1200ms latency caused by DB queries + under high federation load. + """ + + use Oban.Worker, + queue: :activitypub, + max_attempts: 3 + + # NOTE: Removed Oban uniqueness constraint - using ETS deduplication instead + # unique: [period: 60, fields: [:args], keys: [:activity_id]] + + require Logger + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.DomainThrottler + alias Elektrine.ActivityPub.Handler + alias Elektrine.Telemetry.Events + + # Deduplication window in seconds + @dedup_window_seconds 60 + + # Maximum age of a job before we discard it (prevents infinite snooze loops) + # Jobs older than 10 minutes are stale and should be dropped + @max_job_age_seconds 600 + # Keep throttling/backoff churn bounded so Oban doesn't become the bottleneck. + @max_throttle_snoozes 24 + @throttle_snooze_seconds 5 + @max_throttled_job_age_seconds 1800 + @max_backoff_job_age_seconds 1800 + + @impl Oban.Worker + def perform(%Oban.Job{ + args: %{"activity" => activity, "actor_uri" => actor_uri} = args, + inserted_at: inserted_at, + attempt: attempt + }) do + started_at = System.monotonic_time(:millisecond) + target_user_id = args["target_user_id"] + activity_type = activity["type"] || "unknown" + domain = actor_domain(actor_uri) + + # Discard jobs that are too old (prevents infinite snooze loops) + job_age = + case inserted_at do + %DateTime{} = timestamp -> DateTime.diff(DateTime.utc_now(), timestamp, :second) + _ -> 0 + end + + if job_age > @max_job_age_seconds do + Logger.warning("Activity #{activity["id"]} too old (#{job_age}s), discarding") + + emit_perform_telemetry(started_at, activity_type, domain, :discarded_stale, %{ + job_age_seconds: job_age + }) + + {:discard, :stale_job} + else + # Check if instance is blocked first + if ActivityPub.instance_blocked?(domain) do + Logger.info("Rejected activity from blocked instance: #{domain}") + emit_perform_telemetry(started_at, activity_type, domain, :blocked_instance) + :ok + else + # Try to acquire a processing slot for this domain (Lemmy-style) + case DomainThrottler.acquire(domain) do + {:ok, :acquired} -> + # Got a slot, process the activity + result = process_activity(activity, actor_uri, target_user_id) + + # Release slot with success/failure status + success? = match?(:ok, result) + DomainThrottler.release(domain, success?) + + outcome = + case result do + :ok -> :processed + {:discard, _reason} -> :discarded_non_retryable + {:error, _reason} -> :processing_failed + end + + emit_perform_telemetry(started_at, activity_type, domain, outcome) + result + + {:error, :throttled} -> + handle_throttled(activity, domain, attempt, job_age, started_at, activity_type) + + {:error, :backoff, remaining_ms} -> + handle_backoff( + activity, + domain, + attempt, + job_age, + remaining_ms, + started_at, + activity_type + ) + end + end + end + end + + defp process_activity(activity, actor_uri, target_user_id) do + started_at = System.monotonic_time(:millisecond) + + # Get target user if specified + target_user = + if target_user_id do + Elektrine.Repo.get(Elektrine.Accounts.User, target_user_id) + else + nil + end + + # Process the activity using the handler + case Handler.process_activity_async(activity, actor_uri, target_user) do + {:ok, _result} -> + Logger.debug("Successfully processed activity #{activity["id"]}") + emit_handler_telemetry(activity, actor_uri, :success, started_at) + :ok + + {:error, reason} -> + if Handler.retryable_error?(reason) do + Logger.warning("Failed to process activity #{activity["id"]}: #{inspect(reason)}") + + emit_handler_telemetry(activity, actor_uri, :failure, started_at, %{ + reason: reason + }) + + {:error, reason} + else + Logger.debug("Activity #{activity["id"]} failed (#{reason}), not retrying") + + emit_handler_telemetry(activity, actor_uri, :ignored, started_at, %{ + reason: reason + }) + + {:discard, reason} + end + + {:discard, reason} -> + emit_handler_telemetry(activity, actor_uri, :discarded, started_at, %{ + reason: reason + }) + + {:discard, reason} + end + end + + defp handle_throttled(activity, domain, attempt, job_age, started_at, activity_type) do + if attempt <= max_throttle_snoozes(activity) and + job_age <= max_job_age_seconds(activity, :throttled) do + Logger.debug("Throttled activity from #{domain}, snoozing for #{@throttle_snooze_seconds}s") + emit_perform_telemetry(started_at, activity_type, domain, :throttled) + {:snooze, @throttle_snooze_seconds} + else + Logger.warning( + "Dropping throttled activity #{activity["id"]} from #{domain} (attempt=#{attempt}, age=#{job_age}s)" + ) + + emit_perform_telemetry(started_at, activity_type, domain, :discarded_throttled, %{ + attempt: attempt, + job_age_seconds: job_age + }) + + {:discard, :throttled} + end + end + + defp handle_backoff(activity, domain, attempt, job_age, remaining_ms, started_at, activity_type) do + if attempt <= max_throttle_snoozes(activity) and + job_age <= max_job_age_seconds(activity, :backoff) do + snooze_seconds = max(@throttle_snooze_seconds, div(remaining_ms, 1000)) + Logger.debug("Domain #{domain} in backoff, snoozing for #{snooze_seconds}s") + + emit_perform_telemetry(started_at, activity_type, domain, :backoff, %{ + backoff_ms: remaining_ms, + attempt: attempt + }) + + {:snooze, snooze_seconds} + else + Logger.warning( + "Dropping backoff activity #{activity["id"]} from #{domain} (attempt=#{attempt}, age=#{job_age}s)" + ) + + emit_perform_telemetry(started_at, activity_type, domain, :discarded_backoff, %{ + backoff_ms: remaining_ms, + attempt: attempt, + job_age_seconds: job_age + }) + + {:discard, :backoff} + end + end + + @doc """ + Enqueues an activity for processing. + + Returns {:ok, job} on success or {:ok, :skipped} if the activity type should be skipped. + """ + def enqueue(activity, actor_uri, target_user \\ nil) do + activity_id = activity["id"] + start_time = System.monotonic_time(:millisecond) + activity_type = activity["type"] || "unknown" + + # Fast ETS-based deduplication (O(1) instead of database query) + if activity_id && already_seen?(activity_id) do + Events.federation(:inbox_worker, :enqueue, :duplicate, nil, %{ + activity_type: activity_type, + actor_domain: actor_domain(actor_uri) + }) + + {:ok, :duplicate} + else + # Mark as seen before inserting (prevents race conditions) + if activity_id, do: mark_seen(activity_id) + + dedup_time = System.monotonic_time(:millisecond) - start_time + + args = %{ + "activity" => activity, + "actor_uri" => actor_uri, + "activity_id" => activity_id, + "target_user_id" => target_user && target_user.id + } + + # Priority: 0 = highest, 3 = lowest + # Content activities (Create, Update) get priority 0 + # Votes (Like, Dislike, EmojiReact) get priority 2 (lower priority) + # Announces need to check inner object type + priority = activity_priority(activity) + + insert_start = System.monotonic_time(:millisecond) + + result = + args + |> new(priority: priority) + |> Elektrine.JobQueue.insert() + + insert_time = System.monotonic_time(:millisecond) - insert_start + + # Log if Oban insert is slow (> 50ms) + if insert_time > 50 do + Logger.warning("Slow Oban.insert: dedup=#{dedup_time}ms insert=#{insert_time}ms") + end + + enqueue_duration = System.monotonic_time(:millisecond) - start_time + + case result do + {:ok, _job} -> + Events.federation(:inbox_worker, :enqueue, :success, enqueue_duration, %{ + activity_type: activity_type, + actor_domain: actor_domain(actor_uri), + priority: priority, + dedup_ms: dedup_time, + insert_ms: insert_time + }) + + {:error, reason} -> + Events.federation(:inbox_worker, :enqueue, :failure, enqueue_duration, %{ + activity_type: activity_type, + actor_domain: actor_domain(actor_uri), + priority: priority, + reason: inspect(reason), + dedup_ms: dedup_time, + insert_ms: insert_time + }) + end + + result + end + end + + # ETS-based activity deduplication for fast O(1) lookups + # Much faster than Oban's database-based uniqueness constraint + + defp ensure_dedup_table do + case :ets.whereis(:activitypub_dedup) do + :undefined -> + try do + :ets.new(:activitypub_dedup, [:named_table, :public, :set, {:write_concurrency, true}]) + rescue + ArgumentError -> :ok + end + + _ -> + {:discard, :backoff} + end + end + + defp already_seen?(activity_id) do + ensure_dedup_table() + now = System.system_time(:second) + cutoff = now - @dedup_window_seconds + + case :ets.lookup(:activitypub_dedup, activity_id) do + [{^activity_id, timestamp}] when timestamp > cutoff -> true + _ -> false + end + end + + defp mark_seen(activity_id) do + ensure_dedup_table() + now = System.system_time(:second) + :ets.insert(:activitypub_dedup, {activity_id, now}) + + # Periodically clean old entries (1% chance per insert) + if :rand.uniform(100) == 1 do + cleanup_old_entries() + end + end + + defp cleanup_old_entries do + cutoff = System.system_time(:second) - @dedup_window_seconds + + try do + :ets.select_delete(:activitypub_dedup, [ + {{:_, :"$1"}, [{:<, :"$1", cutoff}], [true]} + ]) + rescue + ArgumentError -> :ok + end + end + + # Determine job priority based on activity type + # Lower number = higher priority (processed first) + defp activity_priority(%{"type" => type} = activity) do + case type do + # High priority - content creation + "Create" -> 0 + "Update" -> 0 + "Delete" -> 0 + # Medium priority - social graph + "Follow" -> 1 + "Accept" -> 1 + "Reject" -> 1 + "Undo" -> 1 + "Block" -> 1 + # Low priority - votes and reactions (high volume) + "Like" -> 2 + "Dislike" -> 2 + "EmojiReact" -> 2 + # Announces depend on inner object + "Announce" -> announce_priority(activity) + # Default medium priority + _ -> 1 + end + end + + # Announces of content get high priority, announces of votes get low priority + defp announce_priority(%{"object" => object}) when is_map(object) do + case object["type"] do + type when type in ["Note", "Page", "Article", "Create", "Update", "Delete"] -> 0 + type when type in ["Like", "Dislike", "EmojiReact"] -> 2 + _ -> 1 + end + end + + defp announce_priority(_), do: 1 + + defp max_throttle_snoozes(activity) do + if low_priority_activity?(activity), do: 6, else: @max_throttle_snoozes + end + + defp max_job_age_seconds(activity, :throttled) do + if low_priority_activity?(activity), do: 300, else: @max_throttled_job_age_seconds + end + + defp max_job_age_seconds(activity, :backoff) do + if low_priority_activity?(activity), do: 300, else: @max_backoff_job_age_seconds + end + + defp low_priority_activity?(activity), do: activity_priority(activity) >= 2 + + defp emit_handler_telemetry(activity, actor_uri, outcome, started_at, metadata \\ %{}) do + Events.federation( + :inbox_worker, + :handler, + outcome, + System.monotonic_time(:millisecond) - started_at, + Map.merge(metadata, %{ + activity_type: activity["type"] || "unknown", + actor_domain: actor_domain(actor_uri) + }) + ) + end + + defp emit_perform_telemetry(started_at, activity_type, domain, outcome, metadata \\ %{}) do + Events.federation( + :inbox_worker, + :perform, + outcome, + System.monotonic_time(:millisecond) - started_at, + Map.merge(metadata, %{ + activity_type: activity_type, + actor_domain: domain || "unknown" + }) + ) + end + + defp actor_domain(nil), do: "unknown" + + defp actor_domain(actor_uri) when is_binary(actor_uri) do + case URI.parse(actor_uri) do + %URI{host: host} when is_binary(host) and host != "" -> host + _ -> "unknown" + end + end + + defp actor_domain(_), do: "unknown" +end diff --git a/apps/elektrine/lib/elektrine/activitypub/publisher.ex b/apps/elektrine/lib/elektrine/activitypub/publisher.ex new file mode 100644 index 0000000..b5cb77e --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/publisher.ex @@ -0,0 +1,291 @@ +defmodule Elektrine.ActivityPub.Publisher do + @moduledoc """ + Publishes ActivityPub activities to remote instances. + """ + + require Logger + import Ecto.Query + + alias Elektrine.Accounts.User + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.HTTPSignature + alias Elektrine.HTTP.SafeFetch + alias Elektrine.Security.URLValidator + + @doc """ + Publishes an activity to remote inboxes. + Creates delivery records and queues them for processing. + Supports both user activities and community actor activities (when user is nil). + """ + def publish(activity, nil, inbox_urls) when is_list(inbox_urls) do + # Community actor publishing (no user associated) + # Extract the actor URI from the activity itself + actor_uri = activity["actor"] + + # Save the activity first (handle duplicates) + activity_record = + case ActivityPub.create_activity(%{ + activity_id: activity["id"], + activity_type: activity["type"], + actor_uri: actor_uri, + object_id: get_object_id(activity), + data: activity, + local: true, + # No user for community actors + internal_user_id: nil + }) do + {:ok, record} -> + record + + {:error, %Ecto.Changeset{errors: [activity_id: {"has already been taken", _}]}} -> + # Already exists, get it + ActivityPub.get_activity_by_id(activity["id"]) + + {:error, changeset} -> + raise "Failed to create activity: #{inspect(changeset)}" + end + + # Create delivery records and enqueue delivery jobs. + unique_inboxes = delivery_inboxes_for_activity(activity, inbox_urls) + ActivityPub.create_deliveries(activity_record.id, unique_inboxes) + + {:ok, activity_record} + end + + def publish(activity, %User{} = user, inbox_urls) when is_list(inbox_urls) do + # Save the activity first (handle duplicates) + activity_record = + case ActivityPub.create_activity(%{ + activity_id: activity["id"], + activity_type: activity["type"], + actor_uri: ActivityPub.actor_uri(user), + object_id: get_object_id(activity), + data: activity, + local: true, + internal_user_id: user.id + }) do + {:ok, record} -> + record + + {:error, %Ecto.Changeset{errors: [activity_id: {"has already been taken", _}]}} -> + # Already exists, get it + ActivityPub.get_activity_by_id(activity["id"]) + + {:error, changeset} -> + raise "Failed to create activity: #{inspect(changeset)}" + end + + # Create delivery records and enqueue delivery jobs. + unique_inboxes = delivery_inboxes_for_activity(activity, inbox_urls) + ActivityPub.create_deliveries(activity_record.id, unique_inboxes) + + {:ok, activity_record} + end + + defp get_object_id(%{"object" => object}) when is_binary(object), do: object + defp get_object_id(%{"object" => %{"id" => id}}), do: id + defp get_object_id(_), do: nil + + defp delivery_inboxes_for_activity(%{"type" => type} = activity, inbox_urls) + when type in ["Delete", "Update"] do + activity + |> get_object_id() + |> ActivityPub.get_object_delivery_inboxes() + |> Kernel.++(inbox_urls) + |> Enum.uniq() + end + + defp delivery_inboxes_for_activity(_activity, inbox_urls), do: Enum.uniq(inbox_urls) + + @doc """ + Delivers an activity to a specific inbox. + Signs the request with HTTP Signatures. + + Accepts either a User or Actor struct for signing. + """ + def deliver(activity, entity, inbox_url, opts \\ []) do + with :ok <- validate_inbox_url(inbox_url), + {:ok, entity} <- Elektrine.ActivityPub.KeyManager.ensure_user_has_keys(entity) do + body = Jason.encode!(activity) + + # Log the activity being sent for debugging + if String.contains?(inbox_url, "relay") do + Logger.debug( + "Sending activity to relay: #{inspect(activity, pretty: true, limit: :infinity)}" + ) + end + + base_headers = [ + {"content-type", "application/activity+json"}, + {"accept", "application/activity+json"}, + {"user-agent", "Elektrine/1.0"} + ] + + # Sign the request - extract key info based on entity type + {private_key, key_id} = get_signing_info(entity, opts) + signature_headers = HTTPSignature.sign(inbox_url, body, private_key, key_id) + + all_headers = base_headers ++ signature_headers + + # Build and send the request + request = Finch.build(:post, inbox_url, all_headers, body) + + case SafeFetch.request(request, Elektrine.Finch, receive_timeout: 10_000) do + {:ok, %Finch.Response{status: status}} when status in 200..299 -> + Logger.info("Successfully delivered to #{inbox_url}") + {:ok, :delivered} + + {:ok, %Finch.Response{status: status, body: error_body}} -> + Logger.warning( + "Failed to deliver to #{inbox_url}, status: #{status}, body: #{inspect(error_body)}" + ) + + Logger.debug( + "Failed delivery activity_type=#{activity["type"] || activity[:type]} " <> + "activity_id=#{activity["id"] || activity[:id]}" + ) + + {:error, "HTTP #{status}"} + + {:error, reason} -> + Logger.error("HTTP error delivering to #{inbox_url}: #{inspect(reason)}") + {:error, inspect(reason)} + end + end + end + + @doc """ + Gets the inbox URLs for all followers of a user. + """ + def get_follower_inboxes(user_id) do + # Get remote followers + follows = Elektrine.Profiles.list_remote_followers(user_id) + + # Batch fetch all actors in a single query instead of N+1 + actor_ids = Enum.map(follows, & &1.remote_actor_id) |> Enum.filter(& &1) + + actors_map = + if Enum.empty?(actor_ids) do + %{} + else + Elektrine.ActivityPub.Actor + |> where([a], a.id in ^actor_ids) + |> Elektrine.Repo.all() + |> Map.new(fn actor -> {actor.id, actor} end) + end + + follows + |> Enum.map(&Map.get(actors_map, &1.remote_actor_id)) + |> Enum.reject(&is_nil/1) + |> optimize_inboxes() + end + + # Optimizes inbox delivery by using shared inboxes when possible. + # When sending to multiple users on the same instance, use their shared inbox. + defp optimize_inboxes(actors) do + actors + |> Enum.group_by(& &1.domain) + |> Enum.flat_map(fn {_domain, domain_actors} -> + case shared_inbox_for_group(domain_actors) do + {:ok, shared_inbox} -> + [shared_inbox] + + :error -> + domain_actors + |> Enum.map(& &1.inbox_url) + |> Enum.filter(&(is_binary(&1) and &1 != "")) + |> Enum.uniq() + end + end) + end + + defp shared_inbox_for_group(domain_actors) do + shared_inboxes = + domain_actors + |> Enum.map(&shared_inbox_for_actor/1) + + unique_shared_inboxes = shared_inboxes |> Enum.reject(&is_nil/1) |> Enum.uniq() + + if length(domain_actors) > 1 and + match?([_], unique_shared_inboxes) and + Enum.all?(shared_inboxes, &(&1 == hd(unique_shared_inboxes))) do + {:ok, hd(unique_shared_inboxes)} + else + :error + end + end + + defp shared_inbox_for_actor(%ActivityPub.Actor{} = actor) do + case get_in(actor.metadata || %{}, ["endpoints", "sharedInbox"]) do + value when is_binary(value) and value != "" -> value + _ -> nil + end + end + + defp shared_inbox_for_actor(_), do: nil + + # Extracts signing key and key_id based on entity type (User or Actor) + defp get_signing_info(%User{} = user, opts) do + key_id_base = + case Keyword.get(opts, :key_id_base_url) do + value when is_binary(value) and value != "" -> String.trim_trailing(value, "/") + _ -> ActivityPub.instance_url() + end + + key_id = ActivityPub.actor_key_id(user, key_id_base) + {user.activitypub_private_key, key_id} + end + + defp get_signing_info(%ActivityPub.Actor{username: "relay"} = actor, _opts) do + # For relay actor, use dynamic URL to match current instance domain + private_key = ActivityPub.Actor.metadata_private_key(actor) + key_id = "#{ActivityPub.instance_url()}/relay#main-key" + {private_key, key_id} + end + + defp get_signing_info(%ActivityPub.Actor{} = actor, _opts) do + private_key = ActivityPub.Actor.metadata_private_key(actor) + key_id = "#{actor.uri}#main-key" + {private_key, key_id} + end + + defp validate_inbox_url(inbox_url) when is_binary(inbox_url) do + case URLValidator.validate(inbox_url) do + :ok -> + :ok + + {:error, reason} -> + Logger.warning( + "Blocked unsafe ActivityPub inbox URL #{inspect(inbox_url)}: #{inspect(reason)}" + ) + + {:error, :unsafe_inbox_url} + end + end + + defp validate_inbox_url(_), do: {:error, :unsafe_inbox_url} + + @doc """ + Asynchronously publishes an activity via Oban job queue. + Used by the Pipeline for outgoing local activities. + Creates delivery records and queues them via ActivityDeliveryWorker. + """ + def publish_async(activity, user) do + # Get inbox URLs for followers + inbox_urls = get_follower_inboxes(user.id) + + if Enum.empty?(inbox_urls) do + Logger.debug("No followers to deliver activity to") + :ok + else + # Use the existing publish/3 flow which creates deliveries and schedules them + # publish/3 raises on failure, so wrap in try/rescue + {:ok, _activity_record} = publish(activity, user, inbox_urls) + :ok + end + rescue + e -> + Logger.warning("Failed to queue federation publish: #{inspect(e)}") + {:error, :queue_failed} + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/reachability_worker.ex b/apps/elektrine/lib/elektrine/activitypub/reachability_worker.ex new file mode 100644 index 0000000..a9065f2 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/reachability_worker.ex @@ -0,0 +1,81 @@ +defmodule Elektrine.ActivityPub.ReachabilityWorker do + @moduledoc """ + Probes unreachable ActivityPub domains once their backoff window has elapsed. + """ + + use Oban.Worker, + queue: :federation_metadata, + max_attempts: 1, + priority: 9, + unique: [ + period: 1_800, + keys: [:type, :domain], + states: [:available, :scheduled, :executing] + ] + + import Ecto.Query + + require Logger + + alias Elektrine.ActivityPub.{Instance, Instances} + alias Elektrine.HTTP.SafeFetch + alias Elektrine.Repo + + @default_limit 100 + + def enqueue_due(limit \\ @default_limit) do + %{"type" => "due", "limit" => limit} + |> new() + |> Elektrine.JobQueue.insert() + end + + def enqueue_domain(domain) when is_binary(domain) do + %{"type" => "domain", "domain" => normalize_domain(domain)} + |> new() + |> Elektrine.JobQueue.insert() + end + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"type" => "due"} = args}) do + limit = args |> Map.get("limit", @default_limit) |> clamp_limit() + + Instance + |> where([i], not is_nil(i.unreachable_since)) + |> where([i], i.blocked == false) + |> order_by([i], asc: i.unreachable_since) + |> limit(^limit) + |> Repo.all() + |> Enum.filter(&Instances.should_retry?(&1.domain)) + |> Enum.each(&enqueue_domain(&1.domain)) + + :ok + end + + def perform(%Oban.Job{args: %{"type" => "domain", "domain" => domain}}) do + case probe(domain) do + :ok -> + _ = Instances.set_reachable(domain) + :ok + + {:error, reason} -> + Logger.debug("Reachability probe failed for #{domain}: #{inspect(reason)}") + :ok + end + end + + defp probe(domain) do + url = "https://#{normalize_domain(domain)}/.well-known/nodeinfo" + + case Finch.build(:get, url, [{"accept", "application/json"}]) + |> SafeFetch.request(Elektrine.Finch, receive_timeout: 5_000, max_body_bytes: 50_000) do + {:ok, %Finch.Response{status: status}} when status in 200..399 -> :ok + {:ok, %Finch.Response{status: status}} -> {:error, {:http_status, status}} + {:error, reason} -> {:error, reason} + end + end + + defp clamp_limit(limit) when is_integer(limit), do: max(1, min(limit, @default_limit)) + defp clamp_limit(_limit), do: @default_limit + + defp normalize_domain(domain), do: domain |> String.trim() |> String.downcase() +end diff --git a/apps/elektrine/lib/elektrine/activitypub/refresh_counts_worker.ex b/apps/elektrine/lib/elektrine/activitypub/refresh_counts_worker.ex new file mode 100644 index 0000000..94c33c7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/refresh_counts_worker.ex @@ -0,0 +1,890 @@ +defmodule Elektrine.ActivityPub.RefreshCountsWorker do + @moduledoc """ + Background worker that periodically refreshes engagement counts + (likes, shares, replies) for cached remote posts. + + This ensures that cached posts stay up-to-date with the original + server's counts, which can change as users interact with the post + on the original instance. + + ## Strategy + + 1. Recent posts (< 24h old) are refreshed more frequently + 2. Popular posts (high engagement) are prioritized + 3. Posts users have interacted with are synced promptly + 4. Uses platform-specific APIs (Lemmy, Mastodon) when available + 5. Falls back to ActivityPub collection fetching + 6. Domain throttling prevents spamming any single instance + """ + + use Oban.Worker, + queue: :federation, + max_attempts: 3, + priority: 3, + unique: [ + period: 300, + fields: [:worker, :args], + states: [:available, :scheduled, :executing, :retryable] + ] + + require Logger + + import Ecto.Query + + alias Elektrine.ActivityPub.{ + FederationLoadGuard, + LemmyApi, + MastodonApi, + Normalizer, + RemoteFetch + } + + alias Elektrine.Repo + alias Elektrine.Social.EngagementCounts + alias Elektrine.Social.Message + alias Elektrine.Social.Messages + alias Elektrine.Social.MessageStats + alias Elektrine.Social.PostBoost + alias Elektrine.Social.PostLike + + @batch_size 50 + @recent_threshold_hours 24 + @stale_threshold_hours 6 + @visible_refresh_limit 20 + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"type" => "refresh_recent"}}) do + nonessential_or(fn -> refresh_recent_posts() end) + end + + def perform(%Oban.Job{args: %{"type" => "refresh_popular"}}) do + nonessential_or(fn -> refresh_popular_posts() end) + end + + def perform(%Oban.Job{args: %{"type" => "refresh_interacted"}}) do + nonessential_or(fn -> refresh_interacted_posts() end) + end + + def perform(%Oban.Job{args: %{"type" => "refresh_single", "message_id" => message_id}}) do + nonessential_or(fn -> refresh_single_post(message_id) end) + end + + def perform(%Oban.Job{}) do + nonessential_or(fn -> refresh_stale_posts() end) + end + + defp nonessential_or(fun) when is_function(fun, 0) do + if FederationLoadGuard.skip_nonessential?(__MODULE__) do + {:discard, :federation_overloaded} + else + fun.() + :ok + end + end + + @doc """ + Schedules a refresh for a specific post. + Useful when viewing a remote post's detail page. + """ + def schedule_single_refresh(message_id) do + %{"type" => "refresh_single", "message_id" => message_id} + |> new(unique: refresh_type_unique(300)) + |> Elektrine.JobQueue.insert() + end + + @doc """ + Returns refresh candidate ids from a visible post list. + + Visible feed surfaces should use this before enqueueing refresh jobs so timeline, + and communities all share the same ActivityPub eligibility rules. + """ + def visible_refresh_candidate_ids(posts, opts \\ []) + + def visible_refresh_candidate_ids(posts, opts) when is_list(posts) and is_list(opts) do + limit = Keyword.get(opts, :limit, @visible_refresh_limit) + + if is_integer(limit) and limit > 0 do + posts + |> Enum.flat_map(&visible_refresh_candidate_targets/1) + |> Enum.uniq() + |> Enum.take(limit) + else + [] + end + end + + def visible_refresh_candidate_ids(_posts, _opts), do: [] + + @doc """ + Feed page views intentionally do not enqueue count refresh jobs. + + Remote engagement counts are refreshed by bounded cron jobs and explicit + single-post detail refreshes. Enqueueing refresh jobs from timeline renders can + create unbounded Oban backlogs under normal traffic. + """ + def schedule_visible_refreshes(_posts, _opts \\ []), do: [] + + defp refresh_type_unique(period) do + [ + period: period, + fields: [:worker, :args], + states: [:available, :scheduled, :executing, :retryable] + ] + end + + @doc """ + Immediately refresh a post's counts using the best available API. + Returns {:ok, updated_counts} or {:error, reason}. + Useful for synchronous refresh when viewing a post. + """ + def refresh_now(message_id) do + case Repo.get(Message, message_id) do + nil -> {:error, :not_found} + message -> do_refresh_post(message) + end + end + + @doc false + def reconcile_refreshed_engagement_counts(post, remote_counts) when is_map(remote_counts) do + remote_counts + |> Map.put( + :like_count, + reconciled_refreshed_like_count(post, Map.get(remote_counts, :like_count)) + ) + |> Map.put( + :reply_count, + reconciled_refreshed_reply_count(post, Map.get(remote_counts, :reply_count)) + ) + |> Map.put( + :share_count, + reconciled_refreshed_share_count(post, Map.get(remote_counts, :share_count)) + ) + end + + def reconcile_refreshed_engagement_counts(_post, remote_counts), do: remote_counts + + # Private implementation + + defp refresh_recent_posts do + recent_cutoff = DateTime.utc_now() |> DateTime.add(-@recent_threshold_hours * 3600, :second) + + posts = + from(m in Message, + where: m.federated == true, + where: not is_nil(m.remote_actor_id), + where: m.inserted_at > ^recent_cutoff, + where: is_nil(m.deleted_at), + order_by: [desc: m.inserted_at], + limit: ^@batch_size, + select: [ + :id, + :activitypub_id, + :activitypub_url, + :like_count, + :reply_count, + :share_count, + :quote_count, + :upvotes, + :downvotes, + :score, + :media_metadata + ] + ) + |> Repo.all() + + refresh_posts_batch(posts) + end + + defp refresh_popular_posts do + # Posts with high engagement that might have changed + posts = + from(m in Message, + where: m.federated == true, + where: not is_nil(m.remote_actor_id), + where: is_nil(m.deleted_at), + where: m.like_count > 5 or m.reply_count > 2 or m.share_count > 1, + order_by: [ + desc: fragment("? + ? * 2 + ? * 3", m.like_count, m.reply_count, m.share_count) + ], + limit: ^@batch_size, + select: [ + :id, + :activitypub_id, + :activitypub_url, + :like_count, + :reply_count, + :share_count, + :quote_count, + :upvotes, + :downvotes, + :score, + :media_metadata + ] + ) + |> Repo.all() + + refresh_posts_batch(posts) + end + + defp refresh_stale_posts do + stale_cutoff = DateTime.utc_now() |> DateTime.add(-@stale_threshold_hours * 3600, :second) + + # Find posts that haven't been refreshed recently + # We use updated_at as a proxy for "last refreshed" + posts = + from(m in Message, + where: m.federated == true, + where: not is_nil(m.remote_actor_id), + where: is_nil(m.deleted_at), + where: m.updated_at < ^stale_cutoff or is_nil(m.updated_at), + order_by: [asc: m.updated_at], + limit: ^@batch_size, + select: [ + :id, + :activitypub_id, + :activitypub_url, + :like_count, + :reply_count, + :share_count, + :quote_count, + :upvotes, + :downvotes, + :score, + :media_metadata + ] + ) + |> Repo.all() + + refresh_posts_batch(posts) + end + + defp refresh_interacted_posts do + # Find remote posts that local users have interacted with recently + # This includes posts they liked, replied to, or boosted + recent_cutoff = DateTime.utc_now() |> DateTime.add(-24 * 3600, :second) + + # Posts that have received federated likes (from remote users to local posts, + # or local users to remote posts - tracked via FederatedLike) + liked_post_ids = + from(l in Elektrine.Messaging.FederatedLike, + where: l.inserted_at > ^recent_cutoff, + join: m in Message, + on: m.id == l.message_id, + where: m.federated == true and not is_nil(m.remote_actor_id), + select: m.id + ) + |> Repo.all() + + # Posts that have received local replies + replied_post_ids = + from(m in Message, + where: m.inserted_at > ^recent_cutoff, + where: not is_nil(m.reply_to_id), + join: parent in Message, + on: parent.id == m.reply_to_id, + where: parent.federated == true and not is_nil(parent.remote_actor_id), + select: parent.id + ) + |> Repo.all() + + # Posts that have received federated boosts + boosted_post_ids = + from(b in Elektrine.Messaging.FederatedBoost, + where: b.inserted_at > ^recent_cutoff, + join: m in Message, + on: m.id == b.message_id, + where: m.federated == true and not is_nil(m.remote_actor_id), + select: m.id + ) + |> Repo.all() + + # Combine and deduplicate + all_ids = + (liked_post_ids ++ replied_post_ids ++ boosted_post_ids) + |> Enum.uniq() + |> Enum.take(@batch_size) + + if all_ids != [] do + posts = + from(m in Message, + where: m.id in ^all_ids, + select: [ + :id, + :activitypub_id, + :activitypub_url, + :like_count, + :reply_count, + :share_count, + :quote_count, + :upvotes, + :downvotes, + :score, + :media_metadata + ] + ) + |> Repo.all() + + refresh_posts_batch(posts) + end + end + + defp refresh_single_post(message_id) do + case Repo.get(Message, message_id) do + nil -> + Logger.debug("Post #{message_id} not found for refresh") + :ok + + message -> + refresh_post(message) + end + end + + defp visible_refresh_candidate?(post) when is_map(post) do + is_integer(post_field(post, :id)) && + post_field(post, :federated) == true && + has_activitypub_count_reference?(post) + end + + defp visible_refresh_candidate?(_post), do: false + + defp visible_refresh_candidate_targets(post) when is_map(post) do + [post, post_field(post, :shared_message)] + |> Enum.reject(&is_nil/1) + |> Enum.filter(&visible_refresh_candidate?/1) + |> Enum.map(&post_field(&1, :id)) + end + + defp visible_refresh_candidate_targets(_post), do: [] + + defp has_activitypub_count_reference?(post) do + [:activitypub_id, :activitypub_url] + |> Enum.map(&post_field(post, &1)) + |> Enum.any?(&(is_binary(&1) && String.trim(&1) != "")) + end + + defp post_field(post, key) when is_map(post) and is_atom(key) do + Map.get(post, key) || Map.get(post, Atom.to_string(key)) + end + + defp refresh_posts_batch(posts) do + # Group posts by platform type for efficient batch fetching + {lemmy_posts, other_posts} = Enum.split_with(posts, &lemmy_reference/1) + + {mastodon_posts, activitypub_posts} = + Enum.split_with(other_posts, &MastodonApi.count_api_compatible?/1) + + # Batch fetch Lemmy posts (uses parallel requests internally) + if lemmy_posts != [] do + refresh_lemmy_batch(lemmy_posts) + end + + # Batch fetch Mastodon posts (uses parallel requests internally) + if mastodon_posts != [] do + refresh_mastodon_batch(mastodon_posts) + end + + # Process remaining posts individually with domain throttling + if activitypub_posts != [] do + refresh_activitypub_posts(activitypub_posts) + end + end + + defp refresh_activitypub_posts(activitypub_posts) do + activitypub_posts + |> Enum.group_by(&extract_domain/1) + |> Enum.each(fn {_domain, domain_posts} -> refresh_domain_posts(domain_posts) end) + end + + defp refresh_domain_posts(domain_posts) do + Enum.each(domain_posts, fn post -> + refresh_post(post) + Process.sleep(100) + end) + end + + defp refresh_lemmy_batch(posts) do + # Use LemmyApi's batch fetching + counts_map = LemmyApi.fetch_posts_counts(posts) + + Enum.each(posts, fn post -> + refresh_lemmy_post(post, Map.get(counts_map, post.activitypub_id)) + end) + end + + defp refresh_lemmy_post(post, %{score: score, comments: comments} = counts) do + updated_counts = %{ + like_count: normalize_remote_count(score), + reply_count: normalize_remote_count(comments), + share_count: 0, + upvotes: normalize_remote_count(counts.upvotes), + downvotes: normalize_remote_count(counts.downvotes), + score: normalize_remote_count(score) + } + + updated_counts = reconcile_refreshed_engagement_counts(post, updated_counts) + + media_metadata = merge_original_count_metadata(post.media_metadata, score, comments, 0) + metadata_changed? = media_metadata != normalize_status_metadata(post.media_metadata) + + if refreshed_counts_changed?(post, updated_counts) || metadata_changed? do + Repo.update_all( + from(m in Message, where: m.id == ^post.id), + set: [ + like_count: updated_counts.like_count, + reply_count: updated_counts.reply_count, + share_count: updated_counts.share_count, + remote_like_count: normalize_remote_count(score), + remote_reply_count: normalize_remote_count(comments), + remote_share_count: 0, + upvotes: updated_counts.upvotes, + downvotes: updated_counts.downvotes, + score: updated_counts.score, + media_metadata: media_metadata, + remote_counts_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second), + updated_at: DateTime.utc_now() |> DateTime.truncate(:second) + ] + ) + + Messages.broadcast_post_counts_updated(post.id, updated_counts) + sync_refreshed_stats(post.id, updated_counts, score, comments, 0, nil) + end + end + + defp refresh_lemmy_post(post, nil) do + # Fallback to individual refresh + refresh_post(post) + end + + defp refresh_mastodon_batch(posts) do + # Use MastodonApi's batch fetching + counts_map = MastodonApi.fetch_statuses_counts(posts) + + Enum.each(posts, fn post -> + refresh_mastodon_post(post, Map.get(counts_map, post.activitypub_id)) + end) + end + + defp refresh_mastodon_post( + post, + %{ + favourites_count: fav, + reblogs_count: reb, + replies_count: rep + } = counts + ) do + quotes = normalize_remote_count(Map.get(counts, :quotes_count)) + status_metadata = normalize_status_metadata(Map.get(counts, :status_metadata)) + + media_metadata = + post.media_metadata + |> merge_status_metadata(status_metadata, quotes) + |> merge_original_count_metadata(fav, rep, reb) + + metadata_changed? = media_metadata != normalize_status_metadata(post.media_metadata) + + updated_counts = %{ + like_count: normalize_remote_count(fav), + reply_count: normalize_remote_count(rep), + share_count: normalize_remote_count(reb), + quote_count: quotes + } + + updated_counts = reconcile_refreshed_engagement_counts(post, updated_counts) + + if refreshed_counts_changed?(post, updated_counts) || + (post.quote_count || 0) != quotes || + metadata_changed? do + Repo.update_all( + from(m in Message, where: m.id == ^post.id), + set: [ + like_count: updated_counts.like_count, + reply_count: updated_counts.reply_count, + share_count: updated_counts.share_count, + quote_count: updated_counts.quote_count, + remote_like_count: normalize_remote_count(fav), + remote_reply_count: normalize_remote_count(rep), + remote_share_count: normalize_remote_count(reb), + remote_quote_count: quotes, + media_metadata: media_metadata, + remote_counts_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second), + updated_at: DateTime.utc_now() |> DateTime.truncate(:second) + ] + ) + + Messages.broadcast_post_counts_updated(post.id, updated_counts) + sync_refreshed_stats(post.id, updated_counts, fav, rep, reb, quotes) + end + end + + defp refresh_mastodon_post(post, nil) do + # Fallback to ActivityPub refresh + refresh_post(post) + end + + defp refresh_post(post) do + do_refresh_post(post) + :ok + rescue + e -> + Logger.warning("Error refreshing post #{post.id}: #{inspect(e)}") + :ok + end + + defp do_refresh_post(%{id: id} = post) do + count_ref = count_reference(post) + + # Try platform-specific APIs first (more reliable counts), then fall back to ActivityPub + case fetch_counts_smart(post) do + {:ok, new_counts} -> + normalized_counts = normalize_counts(new_counts) + maybe_update_refreshed_counts(post, id, count_ref, normalized_counts) + + {:error, reason} -> + Logger.debug("Failed to refresh #{count_ref || post.id}: #{inspect(reason)}") + {:error, reason} + end + end + + defp normalize_counts(new_counts) do + %{ + like_count: new_counts[:like_count] || new_counts[:favourites_count] || 0, + reply_count: + new_counts[:reply_count] || new_counts[:replies_count] || new_counts[:comments] || 0, + share_count: new_counts[:share_count] || new_counts[:reblogs_count] || 0, + quote_count: new_counts[:quote_count] || new_counts[:quotes_count] || 0, + upvotes: new_counts[:upvotes] || get_in(new_counts, [:raw, :upvotes]) || 0, + downvotes: new_counts[:downvotes] || get_in(new_counts, [:raw, :downvotes]) || 0, + score: + new_counts[:score] || get_in(new_counts, [:raw, :score]) || + new_counts[:like_count] || + new_counts[:favourites_count] || 0, + status_metadata: normalize_status_metadata(new_counts[:status_metadata]) + } + end + + defp maybe_update_refreshed_counts(post, id, ap_id, %{ + like_count: likes, + reply_count: replies, + share_count: shares, + quote_count: quotes, + upvotes: upvotes, + downvotes: downvotes, + score: score, + status_metadata: status_metadata + }) do + media_metadata = + post.media_metadata + |> merge_status_metadata(status_metadata, quotes) + |> merge_original_count_metadata(likes, replies, shares) + + metadata_changed? = media_metadata != normalize_status_metadata(post.media_metadata) + + updated_counts = %{ + like_count: normalize_remote_count(likes), + reply_count: normalize_remote_count(replies), + share_count: normalize_remote_count(shares), + quote_count: normalize_remote_count(quotes), + upvotes: normalize_remote_count(upvotes), + downvotes: normalize_remote_count(downvotes), + score: normalize_remote_count(score) + } + + updated_counts = reconcile_refreshed_engagement_counts(post, updated_counts) + + if refreshed_counts_changed?(post, updated_counts) || + (post.quote_count || 0) != quotes || + (post.upvotes || 0) != upvotes || + (post.downvotes || 0) != downvotes || + (post.score || 0) != score || + metadata_changed? do + Repo.update_all( + from(m in Message, where: m.id == ^id), + set: [ + like_count: updated_counts.like_count, + reply_count: updated_counts.reply_count, + share_count: updated_counts.share_count, + quote_count: updated_counts.quote_count, + remote_like_count: normalize_remote_count(likes), + remote_reply_count: normalize_remote_count(replies), + remote_share_count: normalize_remote_count(shares), + remote_quote_count: normalize_remote_count(quotes), + upvotes: updated_counts.upvotes, + downvotes: updated_counts.downvotes, + score: updated_counts.score, + media_metadata: media_metadata, + remote_counts_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second), + updated_at: DateTime.utc_now() |> DateTime.truncate(:second) + ] + ) + + Messages.broadcast_post_counts_updated(id, updated_counts) + sync_refreshed_stats(id, updated_counts, likes, replies, shares, quotes) + + Logger.debug( + "Refreshed counts for #{ap_id}: likes=#{likes}, replies=#{replies}, shares=#{shares}" + ) + + {:ok, updated_counts} + else + {:ok, + %{ + like_count: post.like_count, + reply_count: post.reply_count, + share_count: post.share_count, + quote_count: post.quote_count || 0, + upvotes: post.upvotes || 0, + downvotes: post.downvotes || 0, + score: post.score || 0, + status_metadata: status_metadata + }} + end + end + + # Intelligently fetch counts using the best available API for each platform + defp fetch_counts_smart(%{activitypub_id: _ap_id} = post) do + count_ref = count_reference(post) + lemmy_ref = lemmy_reference(post) + + cond do + # Lemmy posts: use Lemmy API (most reliable) + is_binary(lemmy_ref) -> + case LemmyApi.fetch_post_counts(lemmy_ref) do + %{score: score, comments: comments} = counts -> + {:ok, %{like_count: score, reply_count: comments, share_count: 0, raw: counts}} + + nil -> + fetch_counts_activitypub(count_ref) + end + + # Mastodon-compatible and Misskey note URLs: use instance-specific counts API + MastodonApi.count_api_compatible?(post) -> + case MastodonApi.fetch_status_counts_for_post(post) do + %{favourites_count: fav, reblogs_count: reb, replies_count: rep} = counts -> + {:ok, + %{ + like_count: fav, + reply_count: rep, + share_count: reb, + quote_count: Map.get(counts, :quotes_count, 0), + status_metadata: Map.get(counts, :status_metadata, %{}) + }} + + nil -> + fetch_counts_activitypub(count_ref) + end + + # Fallback: use ActivityPub + is_binary(count_ref) -> + fetch_counts_activitypub(count_ref) + + true -> + {:error, :invalid_activitypub_id} + end + end + + defp fetch_counts_smart(ap_id) when is_binary(ap_id), + do: fetch_counts_smart(%{activitypub_id: ap_id}) + + defp fetch_counts_smart(_), do: {:error, :invalid_activitypub_id} + + defp fetch_counts_activitypub(ap_id) do + case RemoteFetch.fetch_object(ap_id) do + {:ok, object} -> + counts = Normalizer.engagement_counts(object) + + {:ok, + Map.take(counts, [ + :like_count, + :reply_count, + :share_count, + :quote_count, + :upvotes, + :downvotes, + :score + ])} + + {:error, reason} -> + {:error, reason} + end + end + + defp normalize_remote_count(value), do: EngagementCounts.remote_count(value) + + defp sync_refreshed_stats(message_id, counts, likes, replies, shares, quotes) do + counts = + counts + |> Map.put(:remote_like_count, normalize_remote_count(likes)) + |> Map.put(:remote_reply_count, normalize_remote_count(replies)) + |> Map.put(:remote_share_count, normalize_remote_count(shares)) + |> Map.put(:remote_quote_count, normalize_remote_count(quotes)) + |> Map.put(:remote_counts_fetched_at, DateTime.utc_now() |> DateTime.truncate(:second)) + + MessageStats.upsert_counts(message_id, counts) + end + + defp normalize_status_metadata(metadata) when is_map(metadata), do: metadata + defp normalize_status_metadata(_), do: %{} + + defp merge_status_metadata(existing_metadata, status_metadata, quote_count) do + status_metadata = normalize_status_metadata(status_metadata) + quote_count = normalize_remote_count(quote_count) + + quote_count_metadata = + Map.get(status_metadata, "quotes_count") || if(quote_count > 0, do: quote_count) + + existing_metadata + |> normalize_status_metadata() + |> maybe_put_status_metadata("emoji_reactions", Map.get(status_metadata, "emoji_reactions")) + |> maybe_put_status_metadata("quotes_count", quote_count_metadata) + |> maybe_put_status_metadata("quote", Map.get(status_metadata, "quote")) + |> maybe_put_status_metadata("quote_id", Map.get(status_metadata, "quote_id")) + |> maybe_put_status_metadata("quote_url", Map.get(status_metadata, "quote_url")) + |> maybe_put_status_metadata("card", Map.get(status_metadata, "card")) + |> maybe_put_status_metadata("application", Map.get(status_metadata, "application")) + |> maybe_put_status_metadata("language", Map.get(status_metadata, "language")) + |> maybe_put_status_metadata( + "media_attachments", + Map.get(status_metadata, "media_attachments") + ) + |> maybe_put_status_metadata("pleroma", Map.get(status_metadata, "pleroma")) + |> maybe_put_status_metadata("misskey", Map.get(status_metadata, "misskey")) + end + + defp maybe_put_status_metadata(metadata, _key, nil), do: metadata + defp maybe_put_status_metadata(metadata, _key, []), do: metadata + + defp maybe_put_status_metadata(metadata, _key, %{} = value) when map_size(value) == 0, + do: metadata + + defp maybe_put_status_metadata(metadata, key, value), do: Map.put(metadata, key, value) + + defp merge_original_count_metadata(metadata, likes, replies, shares) do + metadata + |> normalize_status_metadata() + |> maybe_put_original_count("original_like_count", likes) + |> maybe_put_original_count("original_reply_count", replies) + |> maybe_put_original_count("original_share_count", shares) + end + + defp maybe_put_original_count(metadata, key, value) do + existing = normalize_remote_count(Map.get(metadata, key)) + value = normalize_remote_count(value) + merged = max(existing, value) + + if merged > 0 do + Map.put(metadata, key, merged) + else + metadata + end + end + + defp reconciled_refreshed_like_count(%{id: message_id} = post, remote_like_count) + when is_integer(message_id) do + [ + post.like_count, + post.remote_like_count, + remote_like_count, + get_in(normalize_status_metadata(post.media_metadata), ["original_like_count"]), + local_like_count(message_id) + ] + |> Enum.map(&normalize_remote_count/1) + |> Enum.max(fn -> 0 end) + end + + defp reconciled_refreshed_like_count(_post, remote_like_count), + do: normalize_remote_count(remote_like_count) + + defp reconciled_refreshed_reply_count(%{id: message_id} = post, remote_reply_count) + when is_integer(message_id) do + [ + post.reply_count, + post.remote_reply_count, + remote_reply_count, + get_in(normalize_status_metadata(post.media_metadata), ["original_reply_count"]), + local_reply_count(message_id) + ] + |> Enum.map(&normalize_remote_count/1) + |> Enum.max(fn -> 0 end) + end + + defp reconciled_refreshed_reply_count(_post, remote_reply_count), + do: normalize_remote_count(remote_reply_count) + + defp reconciled_refreshed_share_count(%{id: message_id} = post, remote_share_count) + when is_integer(message_id) do + [ + post.share_count, + post.remote_share_count, + remote_share_count, + get_in(normalize_status_metadata(post.media_metadata), ["original_share_count"]), + local_share_count(message_id) + ] + |> Enum.map(&normalize_remote_count/1) + |> Enum.max(fn -> 0 end) + end + + defp reconciled_refreshed_share_count(_post, remote_share_count), + do: normalize_remote_count(remote_share_count) + + defp local_like_count(message_id) do + from(l in PostLike, + where: l.message_id == ^message_id, + select: count(l.id) + ) + |> Repo.one() + |> normalize_remote_count() + end + + defp local_reply_count(message_id) do + from(m in Message, + where: m.reply_to_id == ^message_id and is_nil(m.deleted_at), + select: count(m.id) + ) + |> Repo.one() + |> normalize_remote_count() + end + + defp local_share_count(message_id) do + from(b in PostBoost, + where: b.message_id == ^message_id, + select: count(b.id) + ) + |> Repo.one() + |> normalize_remote_count() + end + + defp refreshed_counts_changed?(post, counts) when is_map(counts) do + (post.like_count || 0) != Map.get(counts, :like_count, 0) || + (post.reply_count || 0) != Map.get(counts, :reply_count, 0) || + (post.share_count || 0) != Map.get(counts, :share_count, 0) + end + + defp lemmy_url?(url) when is_binary(url) do + LemmyApi.community_post_url?(url) or Regex.match?(~r{/comment/\d+(?:$|[/?#])}, url) + end + + defp lemmy_url?(_), do: false + + defp lemmy_reference(post) do + [Map.get(post, :activitypub_id), Map.get(post, :activitypub_url)] + |> Enum.find(&lemmy_url?/1) + end + + defp count_reference(post) do + [Map.get(post, :activitypub_id), Map.get(post, :activitypub_url)] + |> Enum.find(&(is_binary(&1) and String.trim(&1) != "")) + end + + defp extract_domain(post) do + case count_reference(post) do + ref when is_binary(ref) -> + case URI.parse(ref) do + %URI{host: host} when is_binary(host) -> host + _ -> "unknown" + end + + _ -> + "unknown" + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/relay.ex b/apps/elektrine/lib/elektrine/activitypub/relay.ex new file mode 100644 index 0000000..d87563a --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/relay.ex @@ -0,0 +1,630 @@ +defmodule Elektrine.ActivityPub.Relay do + @moduledoc """ + Manages ActivityPub relay subscriptions. + + A relay is a special actor that rebroadcasts content from all instances + that follow it. This allows smaller instances to discover content from + a wider network without directly following thousands of individual accounts. + + ## How Relays Work + + 1. Our instance sends a Follow activity to the relay actor + 2. The relay sends back an Accept activity + 3. When any subscribed instance publishes public content, the relay + sends an Announce activity to all followers + 4. We receive and process the Announced content like any other federated post + + ## Common Relays + + - `https://relay.fedi.buzz/actor` - ActivityRelay (general purpose) + - `https://relay.infosec.exchange/actor` - Infosec community relay + """ + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.{HTTPSignature, Publisher, RelaySubscription, RemoteFetch} + alias Elektrine.Async + alias Elektrine.Repo + + import Ecto.Query + + require Logger + + @relay_actor_nickname "relay" + + @doc """ + Returns the AP ID of this instance's relay actor. + """ + def relay_actor_id do + "#{ActivityPub.instance_url()}/#{@relay_actor_nickname}" + end + + @doc """ + Gets or creates the local relay actor. + The relay actor is an Application-type actor that represents this instance + for relay federation purposes. + """ + def get_or_create_relay_actor do + # Check if we already have a relay actor + case Repo.get_by(ActivityPub.Actor, uri: relay_actor_id()) do + nil -> + create_relay_actor() + + actor -> + normalize_relay_actor(actor) + end + end + + defp create_relay_actor do + # Generate a key pair for the relay actor + {public_key, private_key} = generate_key_pair() + + actor_attrs = %{ + uri: relay_actor_id(), + username: @relay_actor_nickname, + domain: ActivityPub.instance_domain(), + inbox_url: "#{ActivityPub.instance_url()}/#{@relay_actor_nickname}/inbox", + public_key: public_key, + actor_type: "Application", + display_name: "Elektrine Relay", + summary: "Relay actor for #{ActivityPub.instance_domain()}", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second), + # Store private key in metadata since actors table doesn't have private_key column + # This is used for signing outgoing activities to relays + metadata: ActivityPub.Actor.put_metadata_private_key(%{}, private_key) + } + + %ActivityPub.Actor{} + |> ActivityPub.Actor.changeset(actor_attrs) + |> Repo.insert() + end + + defp normalize_relay_actor(actor) do + if actor.outbox_url || actor.followers_url || actor.following_url do + actor + |> ActivityPub.Actor.changeset(%{ + outbox_url: nil, + followers_url: nil, + following_url: nil + }) + |> Repo.update() + else + {:ok, actor} + end + end + + defp generate_key_pair do + HTTPSignature.generate_key_pair() + end + + @doc """ + Subscribes to a relay by sending a Follow activity. + + ## Parameters + + - `relay_uri` - The relay actor URI (e.g., "https://relay.fedi.buzz/actor") + - `admin_user_id` - The admin user initiating the subscription + """ + def subscribe(relay_uri, admin_user_id \\ nil) do + with {:ok, relay_actor} <- get_or_create_relay_actor(), + {:ok, remote_relay} <- fetch_relay_actor(relay_uri), + {:ok, subscription} <- create_subscription(relay_uri, remote_relay, admin_user_id), + {:ok, activity} <- send_follow(relay_actor, remote_relay, subscription) do + # Update subscription with the follow activity ID + subscription + |> RelaySubscription.changeset(%{follow_activity_id: activity["id"]}) + |> Repo.update() + + Logger.info("Relay: Sent follow to #{relay_uri}") + {:ok, subscription} + else + {:error, :already_subscribed} -> + {:error, "Already subscribed to this relay"} + + {:error, reason} -> + Logger.error("Relay: Failed to subscribe to #{relay_uri}: #{inspect(reason)}") + {:error, reason} + end + end + + @doc """ + Unsubscribes from a relay by sending an Undo Follow activity. + """ + def unsubscribe(relay_uri) do + with {:ok, subscription} <- get_subscription(relay_uri), + {:ok, relay_actor} <- get_or_create_relay_actor(), + {:ok, remote_relay} <- fetch_relay_actor(relay_uri), + :ok <- send_unfollow(relay_actor, remote_relay, subscription) do + # Delete the subscription + Repo.delete(subscription) + Logger.info("Relay: Unfollowed #{relay_uri}") + {:ok, :unfollowed} + else + {:error, :not_found} -> + {:error, "Not subscribed to this relay"} + + {:error, reason} -> + Logger.error("Relay: Failed to unsubscribe from #{relay_uri}: #{inspect(reason)}") + {:error, reason} + end + end + + @doc """ + Force deletes a relay subscription without sending an Unfollow activity. + Use when the remote relay is unreachable or for cleanup. + """ + def force_delete(relay_uri) do + case get_subscription(relay_uri) do + {:ok, subscription} -> + Repo.delete(subscription) + Logger.info("Relay: Force deleted subscription to #{relay_uri}") + {:ok, :deleted} + + {:error, :not_found} -> + {:error, "Not subscribed to this relay"} + end + end + + @doc """ + Lists all relay subscriptions. + """ + def list_subscriptions do + from(s in RelaySubscription, + order_by: [desc: s.inserted_at], + preload: [:subscribed_by] + ) + |> Repo.all() + end + + @doc """ + Returns a paginated slice of relay subscriptions for admin surfaces. + """ + def paginate_subscriptions(page, per_page) when is_integer(page) and is_integer(per_page) do + page = max(page, 1) + per_page = max(per_page, 1) + query = from(s in RelaySubscription) + total_count = Repo.aggregate(query, :count, :id) + total_pages = total_pages(total_count, per_page) + safe_page = min(page, total_pages) + offset = (safe_page - 1) * per_page + + entries = + from(s in RelaySubscription, + order_by: [desc: s.inserted_at], + preload: [:subscribed_by], + limit: ^per_page, + offset: ^offset + ) + |> Repo.all() + + %{ + entries: entries, + page: safe_page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages + } + end + + @doc """ + Returns aggregate relay subscription counts without loading all rows. + """ + def subscription_stats do + counts = + from(s in RelaySubscription, + group_by: [s.status, s.accepted], + select: {s.status, s.accepted, count(s.id)} + ) + |> Repo.all() + + Enum.reduce(counts, %{total: 0, active: 0, pending: 0, error: 0}, fn {status, accepted, count}, + acc -> + acc + |> Map.update!(:total, &(&1 + count)) + |> Map.update!( + :active, + &(&1 + if(status == "active" and accepted == true, do: count, else: 0)) + ) + |> Map.update!(:pending, &(&1 + if(status == "pending", do: count, else: 0))) + |> Map.update!( + :error, + &(&1 + if(status in ["error", "rejected"], do: count, else: 0)) + ) + end) + end + + @doc """ + Returns subscribed relay URLs for quick membership checks in the admin UI. + """ + def subscribed_relay_uris do + from(s in RelaySubscription, select: s.relay_uri) + |> Repo.all() + |> MapSet.new() + end + + @doc """ + Gets active relay subscriptions. + """ + def list_active_subscriptions do + from(s in RelaySubscription, + where: s.status == "active" and s.accepted == true + ) + |> Repo.all() + end + + @doc """ + Gets pending relay subscriptions (stuck waiting for Accept). + """ + def list_pending_subscriptions do + from(s in RelaySubscription, + where: s.status == "pending", + order_by: [asc: s.inserted_at] + ) + |> Repo.all() + end + + @doc """ + Force activates all pending subscriptions. + Use this for relays that auto-accept but don't send Accept activities. + """ + def force_activate_all_pending do + pending = list_pending_subscriptions() + + results = + Enum.map(pending, fn sub -> + case force_activate(sub.relay_uri) do + {:ok, _} -> {:ok, sub.relay_uri} + {:error, reason} -> {:error, sub.relay_uri, reason} + end + end) + + activated = Enum.count(results, fn r -> match?({:ok, _}, r) end) + Logger.info("Relay: Force activated #{activated}/#{length(pending)} pending subscriptions") + {:ok, results} + end + + @doc """ + Gets a subscription by relay URI. + """ + def get_subscription(relay_uri) do + case Repo.get_by(RelaySubscription, relay_uri: relay_uri) do + nil -> {:error, :not_found} + subscription -> {:ok, subscription} + end + end + + @doc """ + Handles an Accept activity for a relay follow. + Called by the ActivityPub handler when we receive an Accept. + + Supports multiple formats: + - Exact follow_activity_id match + - Match by relay_uri when relay sends Accept with actor URI as object + """ + def handle_accept(follow_activity_id), do: handle_accept(follow_activity_id, nil) + + def handle_accept(follow_activity_id, actor_uri) do + Logger.debug("Relay: Looking for subscription with follow_activity_id: #{follow_activity_id}") + + case get_subscription_by_follow_reference(follow_activity_id) do + nil -> + Logger.debug("Relay: No subscription found for #{follow_activity_id}") + {:error, :subscription_not_found} + + subscription -> + if relay_actor_matches?(subscription, actor_uri) do + activate_subscription(subscription) + else + Logger.warning( + "Relay: Rejecting Accept for #{follow_activity_id}, actor #{actor_uri} does not match relay #{subscription.relay_uri}" + ) + + {:error, :subscription_not_found} + end + end + end + + @doc """ + Handles an Accept activity by actor URI. + Called when we receive an Accept from a specific actor. + """ + def handle_accept_from_actor(actor_uri) do + Logger.debug("Relay: Looking for subscription from actor: #{actor_uri}") + + # Find pending subscription for this relay actor + case Repo.get_by(RelaySubscription, relay_uri: actor_uri, status: "pending") do + nil -> + Logger.debug("Relay: No pending subscription found for actor #{actor_uri}") + {:error, :subscription_not_found} + + subscription -> + activate_subscription(subscription) + end + end + + defp total_pages(total_count, per_page) when total_count > 0 and per_page > 0 do + div(total_count + per_page - 1, per_page) + end + + defp total_pages(_, _), do: 1 + + defp activate_subscription(subscription) do + case subscription + |> RelaySubscription.changeset(%{status: "active", accepted: true}) + |> Repo.update() do + {:ok, updated_subscription} -> + Logger.info("Relay: Subscription to #{subscription.relay_uri} accepted") + {:ok, updated_subscription} + + {:error, changeset} -> + {:error, changeset} + end + end + + @doc """ + Manually activates a pending relay subscription. + Use this for relays that don't send Accept activities or when debugging. + """ + def force_activate(relay_uri) do + case get_subscription(relay_uri) do + {:ok, subscription} -> + activate_subscription(subscription) + + {:error, :not_found} -> + {:error, :subscription_not_found} + end + end + + @doc """ + Retries sending Follow to a relay that's stuck in pending. + """ + def retry_subscription(relay_uri) do + with {:ok, subscription} <- get_subscription(relay_uri), + {:ok, relay_actor} <- get_or_create_relay_actor(), + {:ok, remote_relay} <- fetch_relay_actor(relay_uri) do + # Reset status to pending and retry + subscription + |> RelaySubscription.changeset(%{status: "pending", error_message: nil}) + |> Repo.update!() + + case send_follow(relay_actor, remote_relay, subscription) do + {:ok, activity} -> + # Update with new activity ID + subscription + |> RelaySubscription.changeset(%{follow_activity_id: activity["id"]}) + |> Repo.update() + + Logger.info("Relay: Retried follow to #{relay_uri}") + {:ok, :retried} + + {:error, reason} -> + {:error, reason} + end + end + end + + @doc """ + Handles a Reject activity for a relay follow. + """ + def handle_reject(follow_activity_id), do: handle_reject(follow_activity_id, nil) + + def handle_reject(follow_activity_id, actor_uri) do + case get_subscription_by_follow_reference(follow_activity_id) do + nil -> + {:error, :subscription_not_found} + + subscription -> + if relay_actor_matches?(subscription, actor_uri) do + case subscription + |> RelaySubscription.changeset(%{status: "rejected", accepted: false}) + |> Repo.update() do + {:ok, updated_subscription} -> + Logger.info("Relay: Subscription to #{subscription.relay_uri} rejected") + {:ok, updated_subscription} + + {:error, changeset} -> + {:error, changeset} + end + else + Logger.warning( + "Relay: Rejecting Reject for #{follow_activity_id}, actor #{actor_uri} does not match relay #{subscription.relay_uri}" + ) + + {:error, :subscription_not_found} + end + end + end + + defp get_subscription_by_follow_reference(follow_activity_id) do + Repo.get_by(RelaySubscription, follow_activity_id: follow_activity_id) || + Repo.get_by(RelaySubscription, relay_uri: follow_activity_id) + end + + defp relay_actor_matches?(_subscription, nil), do: true + + defp relay_actor_matches?(subscription, actor_uri) do + normalize_relay_uri(subscription.relay_uri) == normalize_relay_uri(actor_uri) + end + + defp normalize_relay_uri(uri) when is_binary(uri) do + uri + |> String.trim() + |> String.split("#", parts: 2) + |> hd() + |> String.split("?", parts: 2) + |> hd() + |> String.trim_trailing("/") + end + + @doc """ + Publishes a local public post to all active relay subscriptions. + This sends an Announce activity to each relay's inbox. + """ + def publish_to_relays(activity) do + # Only publish Create activities for public content + if should_publish_to_relays?(activity) do + relays = list_active_subscriptions() + + if relays != [] do + Async.start(fn -> + Enum.each(relays, fn subscription -> + publish_to_relay(activity, subscription) + end) + end) + end + end + + :ok + end + + defp should_publish_to_relays?(%{"type" => "Create", "object" => object}) when is_map(object) do + # Only public posts + to = object["to"] || [] + "https://www.w3.org/ns/activitystreams#Public" in to + end + + defp should_publish_to_relays?(_), do: false + + defp publish_to_relay(activity, subscription) do + with {:ok, relay_actor} <- get_or_create_relay_actor() do + # Build an Announce activity + announce = build_announce(relay_actor, activity) + + # Send to the relay's inbox + case Publisher.deliver(announce, relay_actor, subscription.relay_inbox) do + {:ok, :delivered} -> + Logger.debug("Relay: Published to #{subscription.relay_uri}") + + {:error, reason} -> + Logger.warning( + "Relay: Failed to publish to #{subscription.relay_uri}: #{inspect(reason)}" + ) + end + end + end + + defp build_announce(relay_actor, activity) do + object_id = activity["object"]["id"] || activity["object"] + + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "#{ActivityPub.instance_url()}/activities/#{Ecto.UUID.generate()}", + "type" => "Announce", + "actor" => relay_actor.uri, + "object" => object_id, + "to" => ["https://www.w3.org/ns/activitystreams#Public"] + } + end + + # Private functions + + defp fetch_relay_actor(relay_uri) do + case RemoteFetch.fetch_object(relay_uri) do + {:ok, actor_data} -> + {:ok, + %{ + uri: actor_data["id"], + inbox: actor_data["inbox"] || actor_data["endpoints"]["sharedInbox"], + name: actor_data["name"] || actor_data["preferredUsername"], + software: detect_relay_software(actor_data) + }} + + {:error, reason} -> + {:error, {:fetch_failed, reason}} + end + end + + defp detect_relay_software(actor_data) do + cond do + String.contains?(actor_data["name"] || "", "ActivityRelay") -> "ActivityRelay" + String.contains?(actor_data["summary"] || "", "ActivityRelay") -> "ActivityRelay" + actor_data["type"] == "Application" -> "Generic Relay" + true -> "Unknown" + end + end + + defp create_subscription(relay_uri, remote_relay, admin_user_id) do + # Check if already subscribed + case Repo.get_by(RelaySubscription, relay_uri: relay_uri) do + nil -> + %RelaySubscription{} + |> RelaySubscription.changeset(%{ + relay_uri: relay_uri, + relay_inbox: remote_relay.inbox, + relay_name: remote_relay.name, + relay_software: remote_relay.software, + status: "pending", + subscribed_by_id: admin_user_id + }) + |> Repo.insert() + + _existing -> + {:error, :already_subscribed} + end + end + + defp send_follow(relay_actor, remote_relay, subscription) do + # Use dynamic URL for the relay actor to match what we serve at /relay + relay_url = relay_actor_id() + + follow_activity = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "#{ActivityPub.instance_url()}/activities/#{Ecto.UUID.generate()}", + "type" => "Follow", + "actor" => relay_url, + "object" => remote_relay.uri + } + + # Store the activity + ActivityPub.create_activity(%{ + activity_id: follow_activity["id"], + activity_type: "Follow", + actor_uri: relay_url, + object_id: remote_relay.uri, + data: follow_activity, + local: true, + processed: true + }) + + # Deliver to the relay + case Publisher.deliver(follow_activity, relay_actor, remote_relay.inbox) do + {:ok, :delivered} -> + {:ok, follow_activity} + + {:error, reason} -> + # Mark subscription as error + subscription + |> RelaySubscription.changeset(%{ + status: "error", + error_message: "Delivery failed: #{inspect(reason)}" + }) + |> Repo.update() + + {:error, {:delivery_failed, reason}} + end + end + + defp send_unfollow(relay_actor, remote_relay, subscription) do + # Use dynamic URL for the relay actor to match what we serve at /relay + relay_url = relay_actor_id() + + # Build Undo Follow + undo_activity = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => "#{ActivityPub.instance_url()}/activities/#{Ecto.UUID.generate()}", + "type" => "Undo", + "actor" => relay_url, + "object" => %{ + "id" => subscription.follow_activity_id, + "type" => "Follow", + "actor" => relay_url, + "object" => remote_relay.uri + } + } + + # Deliver to the relay + case Publisher.deliver(undo_activity, relay_actor, remote_relay.inbox) do + {:ok, :delivered} -> :ok + {:error, reason} -> {:error, {:delivery_failed, reason}} + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/relay_subscription.ex b/apps/elektrine/lib/elektrine/activitypub/relay_subscription.ex new file mode 100644 index 0000000..737ccc1 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/relay_subscription.ex @@ -0,0 +1,82 @@ +defmodule Elektrine.ActivityPub.RelaySubscription do + @moduledoc """ + Represents a subscription to a remote ActivityPub relay. + + Relays are special actors that rebroadcast public content from instances + that follow them. By subscribing to a relay, an instance can receive + content from other instances that also subscribe to that relay. + + ## Status Values + + - `pending` - Follow sent, awaiting Accept from relay + - `active` - Relay accepted our follow, receiving content + - `rejected` - Relay rejected our follow request + - `error` - Subscription failed due to an error + """ + use Ecto.Schema + import Ecto.Changeset + + @status_values ~w(pending active rejected error) + + schema "activitypub_relay_subscriptions" do + field :relay_uri, :string + field :follow_activity_id, :string + field :status, :string, default: "pending" + field :relay_inbox, :string + field :relay_name, :string + field :relay_software, :string + field :accepted, :boolean, default: false + field :error_message, :string + + belongs_to :subscribed_by, Elektrine.Accounts.User + + timestamps() + end + + @doc false + def changeset(subscription, attrs) do + subscription + |> cast(attrs, [ + :relay_uri, + :follow_activity_id, + :status, + :relay_inbox, + :relay_name, + :relay_software, + :accepted, + :error_message, + :subscribed_by_id + ]) + |> validate_required([:relay_uri]) + |> validate_inclusion(:status, @status_values) + |> validate_relay_uri() + |> unique_constraint(:relay_uri) + end + + defp validate_relay_uri(changeset) do + validate_change(changeset, :relay_uri, fn :relay_uri, uri -> + case URI.parse(uri) do + %URI{scheme: scheme, host: host} when scheme in ["http", "https"] and is_binary(host) -> + [] + + _ -> + [relay_uri: "must be a valid HTTP(S) URL"] + end + end) + end + + @doc """ + Returns a human-readable status label. + """ + def status_label(%__MODULE__{status: "pending"}), do: "Pending" + def status_label(%__MODULE__{status: "active"}), do: "Active" + def status_label(%__MODULE__{status: "rejected"}), do: "Rejected" + def status_label(%__MODULE__{status: "error"}), do: "Error" + def status_label(_), do: "Unknown" + + @doc """ + Checks if the subscription is currently active. + """ + def active?(%__MODULE__{status: "active", accepted: true}), do: true + def active?(_), do: false +end diff --git a/apps/elektrine/lib/elektrine/activitypub/remote_fetch.ex b/apps/elektrine/lib/elektrine/activitypub/remote_fetch.ex new file mode 100644 index 0000000..b67aaa3 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/remote_fetch.ex @@ -0,0 +1,191 @@ +defmodule Elektrine.ActivityPub.RemoteFetch do + @moduledoc """ + Central fetch boundary for remote ActivityPub resources. + + This module applies the federation fetch policy before delegating to the low-level + `Fetcher`: per-domain concurrency/backoff through `DomainThrottler`, HTTP + rate-limit backoff through `Elektrine.HTTP.Backoff`, and the existing URL/body + validation in `Fetcher`. + """ + + require Logger + + alias Elektrine.ActivityPub.{DomainDeliveryHealth, DomainThrottler, Fetcher} + alias Elektrine.HTTP.Backoff + + @non_domain_failure_reasons [ + :invalid_activitypub_id, + :invalid_json, + :not_found, + :object_id_mismatch, + :unsafe_url + ] + + @doc """ + Fetches a remote ActivityPub object through the centralized fetch policy. + """ + def fetch_object(uri, opts \\ []) do + fetch_with_domain_policy(uri, opts, &Fetcher.fetch_object/2) + end + + @doc """ + Fetches a remote ActivityPub actor through the centralized fetch policy. + """ + def fetch_actor(uri, opts \\ []) do + fetch_with_domain_policy(uri, opts, &Fetcher.fetch_actor/2) + end + + @doc """ + Resolves a WebFinger handle through the centralized fetch policy. + """ + def webfinger_lookup(acct, opts \\ []) do + fetch_with_domain_policy(acct, opts, &Fetcher.webfinger_lookup/2, webfinger_domain(acct)) + end + + @doc """ + Fetches a remote ActivityPub object without using the object cache. + """ + def fetch_object_uncached(uri, opts \\ []) do + fetch_object(uri, Keyword.put(opts, :skip_cache, true)) + end + + @doc """ + Fetches a single object strictly, bypassing cache and HTML/API recovery. + """ + def fetch_object_strict(uri, opts \\ []) do + opts = Keyword.merge([skip_cache: true, allow_recovery: false], opts) + fetch_object(uri, opts) + end + + @doc """ + Returns current local health/backoff state for a remote URI or domain. + """ + def domain_health(uri_or_domain) when is_binary(uri_or_domain) do + domain = fetch_domain(uri_or_domain) || uri_or_domain + + %{ + domain: domain, + domain_backoff?: domain_backoff?(domain), + domain_delay_ms: domain_delay(domain), + http_backoff_seconds: Backoff.get_backoff_remaining(domain) + } + end + + def domain_health(_), + do: %{domain: nil, domain_backoff?: false, domain_delay_ms: 0, http_backoff_seconds: 0} + + defp fetch_with_domain_policy(resource, opts, fetch_fun, domain_source \\ nil) do + domain = fetch_domain(domain_source || resource) + + cond do + bypass_domain_policy?(opts) or !is_binary(domain) -> + fetch_fun.(resource, opts) + + not DomainDeliveryHealth.deliverable_url?("https://" <> domain) -> + {:error, :domain_unreachable} + + true -> + with_domain_slot(domain, fn -> fetch_fun.(resource, opts) end) + end + end + + defp bypass_domain_policy?(opts) do + Keyword.get(opts, :bypass_domain_throttler, false) || Keyword.has_key?(opts, :request_fun) + end + + defp with_domain_slot(domain, fun) when is_function(fun, 0) do + case acquire_domain_slot(domain) do + {:ok, _} -> + try do + result = fun.() + release_domain_slot(domain, domain_healthy_result?(result)) + result + rescue + exception -> + release_domain_slot(domain, false) + reraise exception, __STACKTRACE__ + catch + kind, reason -> + release_domain_slot(domain, false) + :erlang.raise(kind, reason, __STACKTRACE__) + end + + {:error, :throttled} -> + Logger.debug("RemoteFetch: domain #{domain} is currently throttled") + {:error, :domain_throttled} + + {:error, :backoff, delay_ms} -> + Logger.debug("RemoteFetch: domain #{domain} is in backoff for #{delay_ms}ms") + {:error, :domain_backoff} + end + end + + defp acquire_domain_slot(domain) do + DomainThrottler.acquire(domain) + catch + :exit, _ -> {:ok, :throttler_unavailable} + end + + defp release_domain_slot(domain, success?) do + DomainThrottler.release(domain, success?) + catch + :exit, _ -> :ok + end + + defp domain_backoff?(domain) when is_binary(domain) do + DomainThrottler.in_backoff?(domain) + catch + :exit, _ -> false + end + + defp domain_backoff?(_), do: false + + defp domain_delay(domain) when is_binary(domain) do + DomainThrottler.get_delay(domain) + catch + :exit, _ -> 0 + end + + defp domain_delay(_), do: 0 + + defp domain_healthy_result?({:ok, _}), do: true + + defp domain_healthy_result?({:error, reason}) when reason in @non_domain_failure_reasons, + do: true + + defp domain_healthy_result?(_), do: false + + defp fetch_domain(uri) when is_binary(uri) do + trimmed = String.trim(uri) + + case URI.parse(trimmed) do + %URI{host: host} when is_binary(host) and host != "" -> + String.downcase(host) + + _ -> + if domain_like?(trimmed), do: String.downcase(trimmed) + end + end + + defp fetch_domain(_), do: nil + + defp webfinger_domain(acct) when is_binary(acct) do + acct + |> String.trim() + |> String.trim_leading("acct:") + |> String.trim_leading("@") + |> String.trim_leading("!") + |> String.split("@", parts: 2) + |> case do + [_name, domain] when domain != "" -> domain + _ -> nil + end + end + + defp webfinger_domain(_), do: nil + + defp domain_like?(value) do + String.contains?(value, ".") and not String.contains?(value, "/") and + not String.contains?(value, "@") + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/replies_fetcher.ex b/apps/elektrine/lib/elektrine/activitypub/replies_fetcher.ex new file mode 100644 index 0000000..2679ed5 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/replies_fetcher.ex @@ -0,0 +1,868 @@ +defmodule Elektrine.ActivityPub.RepliesFetcher do + @moduledoc """ + Proactively fetches and stores replies from ActivityPub reply collections. + + When viewing a remote post, this module fetches the replies collection + and stores replies locally, similar to how Akkoma handles reply fetching. + This ensures replies are available even when the remote server's collection + doesn't expose counts. + """ + + require Logger + + import Ecto.Query + + alias Elektrine.ActivityPub + + alias Elektrine.ActivityPub.{ + Actor, + CollectionFetcher, + Helpers, + LemmyApi, + MastodonApi, + Normalizer, + RemoteFetch, + ReplyFetchPolicy + } + + alias Elektrine.Messaging + alias Elektrine.Repo + + @max_replies Keyword.fetch!(ReplyFetchPolicy.preview_defaults(), :max_replies) + @max_depth Keyword.fetch!(ReplyFetchPolicy.preview_defaults(), :max_depth) + @max_pages Keyword.fetch!(ReplyFetchPolicy.preview_defaults(), :max_pages) + + @doc """ + Fetches replies for a post and stores them locally. + + Called when viewing a remote post to ensure replies are available. + Runs asynchronously to not block the page load. + + ## Parameters + - `post_object` - The ActivityPub post object (must have "id" and optionally "replies") + - `opts` - Options: + - `:max_replies` - Maximum replies to fetch (default: #{@max_replies}) + - `:max_depth` - Maximum nesting depth to fetch (default: #{@max_depth}) + + ## Returns + - `{:ok, count}` - Number of replies stored + - `{:error, reason}` - If fetching failed + """ + def fetch_and_store_replies(post_object, opts \\ []) do + mode = Keyword.get(opts, :reply_fetch_mode, :preview) + opts = ReplyFetchPolicy.clamp_fetch_opts(opts, mode) + max_replies = Keyword.get(opts, :max_replies, @max_replies) + max_depth = Keyword.get(opts, :max_depth, @max_depth) + max_pages = Keyword.get(opts, :max_pages, @max_pages) + parent_ap_id = post_object["id"] + replies_collection = post_object["replies"] || post_object["comments"] + + # Get or create the parent message + parent_message = Messaging.get_message_by_activitypub_id(parent_ap_id) + + if is_nil(parent_message) do + Logger.debug("Parent message not found for #{parent_ap_id}, skipping reply fetch") + {:error, :parent_not_found} + else + do_fetch_and_store_replies( + replies_collection, + parent_message.id, + max_replies, + max_depth, + max_pages, + opts + ) + end + end + + @doc """ + Fetches replies for a message by its local ID. + + Useful when you have a message but need to refresh its replies. + """ + def fetch_replies_for_message(message_id) + when is_binary(message_id) or is_integer(message_id) do + fetch_replies_for_message(message_id, []) + end + + def fetch_replies_for_message(message_id, opts) + when (is_binary(message_id) or is_integer(message_id)) and is_list(opts) do + mode = Keyword.get(opts, :reply_fetch_mode, :preview) + opts = ReplyFetchPolicy.clamp_fetch_opts(opts, mode) + + case Elektrine.Repo.get(Elektrine.Social.Message, message_id) do + nil -> + {:error, :message_not_found} + + message -> + cond do + is_nil(message.activitypub_id) -> + {:error, :no_activitypub_id} + + not ReplyFetchPolicy.cooldown_elapsed?(message, opts) -> + {:ok, 0} + + true -> + result = + case RemoteFetch.fetch_object(message.activitypub_id, fetch_object_opts(opts)) do + {:ok, post_object} -> + case fetch_and_store_replies(post_object, opts) do + {:ok, stored_count} -> + maybe_fetch_missing_replies_via_fallback( + post_object, + message.id, + opts, + stored_count + ) + + {:error, _reason} -> + fetch_and_store_replies_via_fallback(post_object, message.id, opts) + end + + {:error, _reason} -> + fetch_replies_without_post_object(message.activitypub_id, message.id, opts) + end + + # Failed attempts don't start the cooldown, so a later retry isn't + # silently short-circuited to {:ok, 0} for 15 minutes. + case result do + {:ok, _} -> mark_reply_fetch_attempt(message) + _ -> :ok + end + + result + end + end + end + + @doc """ + Backfills as much of a remote reply tree as possible within safety limits. + """ + def fetch_full_thread_for_message(message_id, opts \\ []) do + opts = + opts + |> Keyword.put(:reply_fetch_mode, :full_thread) + |> ReplyFetchPolicy.clamp_fetch_opts(:full_thread) + + fetch_replies_for_message(message_id, opts) + end + + # Private implementation + + defp mark_reply_fetch_attempt(message) do + metadata = + (message.media_metadata || %{}) + |> Map.put( + ReplyFetchPolicy.fetched_at_metadata_key(), + ReplyFetchPolicy.fetched_at_timestamp() + ) + + from(m in Elektrine.Social.Message, where: m.id == ^message.id) + |> Repo.update_all(set: [media_metadata: metadata]) + + :ok + end + + defp do_fetch_and_store_replies( + collection, + parent_message_id, + max_replies, + max_depth, + max_pages + ) do + do_fetch_and_store_replies( + collection, + parent_message_id, + max_replies, + max_depth, + max_pages, + [] + ) + end + + defp do_fetch_and_store_replies( + nil, + _parent_message_id, + _max_replies, + _max_depth, + _max_pages, + _opts + ) do + {:ok, 0} + end + + defp do_fetch_and_store_replies( + _collection, + _parent_message_id, + max_replies, + _max_depth, + _max_pages, + _opts + ) + when max_replies <= 0 do + {:ok, 0} + end + + defp do_fetch_and_store_replies( + collection, + parent_message_id, + max_replies, + max_depth, + max_pages, + opts + ) + when is_binary(collection) or is_map(collection) do + case CollectionFetcher.fetch_collection(collection, + max_items: max_replies, + max_pages: max_pages, + skip_cache: Keyword.get(opts, :skip_cache, false), + request_fun: Keyword.get(opts, :request_fun), + sign: Keyword.get(opts, :sign), + validate_url: Keyword.get(opts, :validate_url), + allow_recovery: Keyword.get(opts, :allow_recovery) + ) do + {:ok, items} -> + {stored_count, _remaining} = + process_reply_items(items, parent_message_id, max_replies, max_depth, max_pages) + + Logger.debug("Stored #{stored_count} replies for message #{parent_message_id}") + {:ok, stored_count} + + {:partial, items} -> + {stored_count, _remaining} = + process_reply_items(items, parent_message_id, max_replies, max_depth, max_pages) + + Logger.debug("Stored #{stored_count} partial replies for message #{parent_message_id}") + {:ok, stored_count} + + {:error, reason} -> + Logger.warning("Failed to fetch replies collection: #{inspect(reason)}") + {:error, reason} + end + end + + defp fetch_and_store_replies_via_fallback(post_object, parent_message_id, opts) do + post_ref = post_object["id"] || post_object["url"] + max_replies = Keyword.get(opts, :max_replies, @max_replies) + max_depth = Keyword.get(opts, :max_depth, @max_depth) + max_pages = Keyword.get(opts, :max_pages, @max_pages) + + cond do + lemmy_comment_api_candidate?(post_ref) -> + case LemmyApi.fetch_post_comments(post_ref, max_replies) do + [] -> + fetch_activitypub_replies_fallback( + post_object, + parent_message_id, + max_replies, + max_depth, + max_pages, + opts + ) + + replies -> + process_fetched_reply_items( + replies, + parent_message_id, + max_replies, + max_depth, + max_pages + ) + end + + is_binary(post_ref) && MastodonApi.mastodon_compatible?(%{activitypub_id: post_ref}) -> + case MastodonApi.fetch_status_context(post_ref) do + {:ok, descendants} when is_list(descendants) -> + replies = + Enum.map(descendants, fn status -> + %{ + "id" => status.uri || status.url || "#{post_ref}#status-#{status.id}", + "url" => status.url || status.uri, + "type" => "Note", + "content" => status.content, + "attributedTo" => mastodon_status_actor_ref(status), + "_account" => status.account, + "published" => status.created_at, + "inReplyTo" => status.in_reply_to_uri, + "likes" => %{"totalItems" => status.favourites_count || 0}, + "shares" => %{"totalItems" => status.reblogs_count || 0}, + "replies" => %{"totalItems" => status.replies_count || 0} + } + end) + + {stored_count, _remaining} = + process_reply_items(replies, parent_message_id, max_replies, max_depth, max_pages) + + {:ok, stored_count} + + _ -> + {:ok, 0} + end + + true -> + fetch_activitypub_replies_fallback( + post_object, + parent_message_id, + max_replies, + max_depth, + max_pages, + opts + ) + end + end + + defp fetch_replies_without_post_object(post_ref, parent_message_id, opts) + when is_binary(post_ref) do + max_replies = Keyword.get(opts, :max_replies, @max_replies) + max_depth = Keyword.get(opts, :max_depth, @max_depth) + max_pages = Keyword.get(opts, :max_pages, @max_pages) + + cond do + lemmy_comment_api_candidate?(post_ref) -> + replies = LemmyApi.fetch_post_comments(post_ref, max_replies) + + process_fetched_reply_items(replies, parent_message_id, max_replies, max_depth, max_pages) + + MastodonApi.mastodon_compatible?(%{activitypub_id: post_ref}) -> + case MastodonApi.fetch_status_context(post_ref) do + {:ok, descendants} when is_list(descendants) -> + replies = + Enum.map(descendants, fn status -> + %{ + "id" => status.uri || status.url || "#{post_ref}#status-#{status.id}", + "url" => status.url || status.uri, + "type" => "Note", + "content" => status.content, + "attributedTo" => mastodon_status_actor_ref(status), + "_account" => status.account, + "published" => status.created_at, + "inReplyTo" => status.in_reply_to_uri, + "likes" => %{"totalItems" => status.favourites_count || 0}, + "shares" => %{"totalItems" => status.reblogs_count || 0}, + "replies" => %{"totalItems" => status.replies_count || 0} + } + end) + + {stored_count, _remaining} = + process_reply_items(replies, parent_message_id, max_replies, max_depth, max_pages) + + {:ok, stored_count} + + _ -> + {:ok, 0} + end + + true -> + {:error, :fetch_failed} + end + end + + defp process_reply_items(items, parent_message_id, remaining_budget, max_depth, max_pages) do + Enum.reduce_while(items, {0, remaining_budget}, fn item, {stored_count, remaining} -> + if remaining <= 0 do + {:halt, {stored_count, 0}} + else + normalized_item = normalize_reply_item(item) + + {item_stored_count, item_remaining} = + process_reply_item(normalized_item, parent_message_id, remaining, max_depth, max_pages) + + {:cont, {stored_count + item_stored_count, item_remaining}} + end + end) + end + + defp process_fetched_reply_items(replies, parent_message_id, max_replies, max_depth, max_pages) + when is_list(replies) do + {stored_count, _remaining} = + process_reply_items(replies, parent_message_id, max_replies, max_depth, max_pages) + + {:ok, stored_count} + end + + defp process_fetched_reply_items(_, _parent_message_id, _max_replies, _max_depth, _max_pages), + do: {:ok, 0} + + defp fetch_activitypub_replies_fallback( + post_object, + parent_message_id, + max_replies, + max_depth, + max_pages, + opts + ) do + fetch_opts = Keyword.merge(opts, limit: max_replies) + + case ActivityPub.fetch_remote_post_replies(post_object, fetch_opts) do + {:ok, replies} when is_list(replies) -> + process_fetched_reply_items(replies, parent_message_id, max_replies, max_depth, max_pages) + + {:error, reason} -> + {:error, reason} + end + end + + defp maybe_fetch_missing_replies_via_fallback( + post_object, + parent_message_id, + opts, + stored_count + ) do + if stored_count == 0 || reply_count_below_expected?(post_object, parent_message_id) do + case fetch_and_store_replies_via_fallback(post_object, parent_message_id, opts) do + {:ok, fallback_count} -> + {:ok, stored_count + fallback_count} + + {:error, reason} when stored_count > 0 -> + Logger.debug( + "Reply fallback failed after storing #{stored_count} replies for #{parent_message_id}: #{inspect(reason)}" + ) + + {:ok, stored_count} + + error -> + error + end + else + {:ok, stored_count} + end + end + + defp reply_count_below_expected?(post_object, parent_message_id) do + expected_count = expected_reply_count(post_object) + + expected_count > 0 && + stored_reply_descendant_count(parent_message_id, expected_count) < expected_count + end + + defp expected_reply_count(post_object) when is_map(post_object) do + [ + post_object["reply_count"], + post_object["repliesCount"], + collection_total_items(post_object["replies"]), + collection_total_items(post_object["comments"]) + ] + |> Enum.map(&normalize_count/1) + |> Enum.max(fn -> 0 end) + end + + defp expected_reply_count(_), do: 0 + + defp collection_total_items(%{"totalItems" => total}), do: total + defp collection_total_items(%{totalItems: total}), do: total + defp collection_total_items(_), do: nil + + defp normalize_count(value) when is_integer(value), do: max(value, 0) + + defp normalize_count(value) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {parsed, _} -> max(parsed, 0) + :error -> 0 + end + end + + defp normalize_count(_), do: 0 + + defp stored_reply_descendant_count(parent_message_id, expected_count) + when is_integer(parent_message_id) do + max_scan = max(expected_count, 1) |> min(5000) + do_stored_reply_descendant_count([parent_message_id], MapSet.new(), 0, max_scan) + end + + defp stored_reply_descendant_count(_, _), do: 0 + + defp do_stored_reply_descendant_count(_parent_ids, _seen_ids, count, max_scan) + when count >= max_scan, + do: count + + defp do_stored_reply_descendant_count(parent_ids, seen_ids, count, max_scan) + when is_list(parent_ids) do + pending_parent_ids = + parent_ids + |> Enum.filter(&is_integer/1) + |> Enum.uniq() + + if pending_parent_ids == [] do + count + else + reply_ids = + Elektrine.Social.Message + |> where([m], m.reply_to_id in ^pending_parent_ids and is_nil(m.deleted_at)) + |> select([m], m.id) + |> Repo.all() + + new_reply_ids = Enum.reject(reply_ids, &MapSet.member?(seen_ids, &1)) + new_count = min(count + length(new_reply_ids), max_scan) + next_seen_ids = Enum.reduce(new_reply_ids, seen_ids, &MapSet.put(&2, &1)) + + do_stored_reply_descendant_count(new_reply_ids, next_seen_ids, new_count, max_scan) + end + end + + defp fetch_object_opts(opts) do + opts + |> Keyword.take([:request_fun, :skip_cache, :sign, :validate_url]) + |> case do + fetch_opts -> + case Keyword.get(fetch_opts, :request_fun) do + request_fun when is_function(request_fun, 3) -> fetch_opts + nil -> fetch_opts + _ -> Keyword.delete(fetch_opts, :request_fun) + end + end + end + + defp lemmy_comment_api_candidate?(post_ref) when is_binary(post_ref) do + LemmyApi.community_post_url?(post_ref) || LemmyApi.fetch_post_counts(post_ref) != nil + end + + defp lemmy_comment_api_candidate?(_), do: false + + defp process_reply_item(nil, _parent_message_id, remaining_budget, _max_depth, _max_pages), + do: {0, remaining_budget} + + defp process_reply_item(object, parent_message_id, remaining_budget, max_depth, max_pages) do + case store_reply(object, parent_message_id) do + {:stored, reply_message} -> + nested_budget = remaining_budget - 1 + + {nested_count, final_remaining} = + maybe_fetch_nested_replies(object, reply_message, nested_budget, max_depth, max_pages) + + {1 + nested_count, final_remaining} + + {:existing, reply_message} when is_map(reply_message) -> + maybe_fetch_nested_replies(object, reply_message, remaining_budget, max_depth, max_pages) + + _ -> + {0, remaining_budget} + end + end + + defp maybe_fetch_nested_replies( + _object, + _reply_message, + remaining_budget, + max_depth, + _max_pages + ) + when remaining_budget <= 0 or max_depth <= 1 do + {0, remaining_budget} + end + + defp maybe_fetch_nested_replies(object, reply_message, remaining_budget, max_depth, max_pages) do + replies_collection = object["replies"] || object["comments"] + + case replies_collection do + nil -> + {0, remaining_budget} + + _ -> + case do_fetch_and_store_replies( + replies_collection, + reply_message.id, + remaining_budget, + max_depth - 1, + max_pages + ) do + {:ok, stored_count} -> {stored_count, max(remaining_budget - stored_count, 0)} + {:error, _reason} -> {0, remaining_budget} + end + end + end + + # Normalize different reply item formats + defp normalize_reply_item(item) when is_binary(item) do + # Just a URL reference - need to fetch the full object + case RemoteFetch.fetch_object(item) do + {:ok, object} -> object + {:error, _} -> nil + end + end + + defp normalize_reply_item(%{"type" => type} = item) + when type in ["Note", "Article", "Page", "Question"] do + item + end + + defp normalize_reply_item(%{"object" => object}) when is_map(object) do + # Wrapped in a Create activity + normalize_reply_item(object) + end + + defp normalize_reply_item(%{"object" => object_url}) when is_binary(object_url) do + # Create activity with object URL + case RemoteFetch.fetch_object(object_url) do + {:ok, object} -> object + {:error, _} -> nil + end + end + + defp normalize_reply_item(_), do: nil + + defp store_reply(object, parent_message_id) do + actor_uri = Normalizer.actor_uri(object) + + if is_nil(actor_uri) do + Logger.debug("Reply has no author, skipping: #{object["id"]}") + :skip + else + case ActivityPub.get_or_fetch_actor(actor_uri) do + {:ok, remote_actor} -> + resolved_parent_message_id = resolve_parent_message_id(object, parent_message_id) + create_reply_message(object, remote_actor, resolved_parent_message_id) + + {:error, reason} -> + case fallback_remote_actor(object, actor_uri) do + %Actor{} = remote_actor -> + Logger.debug( + "Using fallback actor for #{actor_uri} after fetch failure: #{inspect(reason)}" + ) + + resolved_parent_message_id = resolve_parent_message_id(object, parent_message_id) + create_reply_message(object, remote_actor, resolved_parent_message_id) + + _ -> + Logger.debug("Failed to fetch actor #{actor_uri}: #{inspect(reason)}") + :error + end + end + end + rescue + e -> + Logger.warning("Error storing reply: #{inspect(e)}") + :error + end + + defp create_reply_message(object, remote_actor, parent_message_id) do + # Check if already exists + case Messaging.get_message_by_activitypub_id(object["id"]) do + nil -> + payload = + Normalizer.message_payload(object, remote_actor.uri, + enrich_sparse_object: false, + assume_public_reply_without_audience: true + ) + + visibility = payload.attrs.visibility + + if visibility in ["public", "unlisted"] do + attrs = + payload.attrs + |> Map.merge(reply_count_attrs(object)) + |> Map.merge(%{ + federated: true, + remote_actor_id: remote_actor.id, + reply_to_id: parent_message_id + }) + + case Messaging.create_federated_message_with_status(attrs) do + {:ok, message, :created} -> + # Increment parent's reply count + Elektrine.ActivityPub.SideEffects.increment_reply_count(parent_message_id) + + # Only genuinely-public replies belong on the public firehose; + # unlisted replies stay visible in-thread but off the timeline. + if message.visibility == "public" do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "timeline:public", + {:new_public_post, + Repo.preload(message, [:remote_actor, :sender, :link_preview, :hashtags])} + ) + end + + {:stored, message} + + # Lost an insert race against a concurrent worker: the reply already + # exists, so refresh its counts but do NOT increment the parent again. + {:ok, message, :existing} -> + {:existing, refresh_existing_reply_counts(message, object)} + + {:error, reason} -> + Logger.debug("Failed to create reply: #{inspect(reason)}") + :error + end + else + :skip + end + + existing -> + {:existing, refresh_existing_reply_counts(existing, object)} + end + end + + defp refresh_existing_reply_counts(message, object) when is_map(message) and is_map(object) do + attrs = reply_count_attrs(object) + + if reply_count_attrs_changed?(message, attrs) do + case Elektrine.Social.Messages.update_message_metadata(message, attrs) do + {:ok, updated_message} -> updated_message + _ -> message + end + else + message + end + end + + defp refresh_existing_reply_counts(message, _), do: message + + defp reply_count_attrs(object) when is_map(object) do + object + |> Normalizer.engagement_counts() + |> Map.take([:like_count, :reply_count, :share_count, :upvotes, :downvotes, :score]) + end + + defp reply_count_attrs(_), do: %{} + + defp reply_count_attrs_changed?(message, attrs) when is_map(message) and is_map(attrs) do + (message.like_count || 0) != (attrs[:like_count] || 0) || + (message.reply_count || 0) != (attrs[:reply_count] || 0) || + (message.share_count || 0) != (attrs[:share_count] || 0) || + (message.upvotes || 0) != (attrs[:upvotes] || 0) || + (message.downvotes || 0) != (attrs[:downvotes] || 0) || + (message.score || 0) != (attrs[:score] || 0) + end + + defp reply_count_attrs_changed?(_, _), do: false + + defp resolve_parent_message_id(object, fallback_parent_message_id) when is_map(object) do + in_reply_to_ref = normalize_in_reply_to_ref(object["inReplyTo"]) + + cond do + !is_binary(in_reply_to_ref) or in_reply_to_ref == "" -> + fallback_parent_message_id + + parent = Messaging.get_message_by_activitypub_ref(in_reply_to_ref) -> + parent.id + + true -> + actor_uri = Normalizer.actor_uri(object) + + case Helpers.get_or_store_remote_post(in_reply_to_ref, actor_uri) do + {:ok, parent} when is_map(parent) -> parent.id + _ -> fallback_parent_message_id + end + end + end + + defp resolve_parent_message_id(_, fallback_parent_message_id), do: fallback_parent_message_id + + defp normalize_in_reply_to_ref(%{"id" => id}), do: normalize_in_reply_to_ref(id) + defp normalize_in_reply_to_ref(%{id: id}), do: normalize_in_reply_to_ref(id) + defp normalize_in_reply_to_ref(%{"href" => href}), do: normalize_in_reply_to_ref(href) + defp normalize_in_reply_to_ref(%{href: href}), do: normalize_in_reply_to_ref(href) + defp normalize_in_reply_to_ref([first | _]), do: normalize_in_reply_to_ref(first) + defp normalize_in_reply_to_ref(value) when is_binary(value), do: String.trim(value) + defp normalize_in_reply_to_ref(_), do: nil + + defp mastodon_status_actor_ref(status) when is_map(status) do + account = Map.get(status, :account) || %{} + + case account[:uri] || account["uri"] do + uri when is_binary(uri) and uri != "" -> + uri + + _ -> + normalize_mastodon_profile_url(account[:url] || account["url"]) + end + end + + defp fallback_remote_actor(%{"_account" => account}, actor_uri) when is_map(account) do + create_or_get_fallback_actor(actor_uri, account) + end + + defp fallback_remote_actor(_, _), do: nil + + defp create_or_get_fallback_actor(actor_uri, account) + when is_binary(actor_uri) and is_map(account) do + username = account[:username] || account["username"] + acct = account[:acct] || account["acct"] + actor_url = account[:url] || account["url"] + avatar = account[:avatar] || account["avatar"] + display_name = account[:display_name] || account["display_name"] || username + domain = actor_domain_from_acct_or_uri(acct, actor_uri) + + cond do + existing = ActivityPub.get_actor_by_uri(actor_uri) -> + existing + + !is_binary(username) or username == "" or !is_binary(domain) or domain == "" -> + nil + + existing = ActivityPub.get_actor_by_username_and_domain(username, domain) -> + existing + + true -> + attrs = %{ + uri: actor_uri, + username: username, + domain: domain, + display_name: display_name, + avatar_url: avatar, + inbox_url: fallback_actor_inbox(actor_uri), + outbox_url: fallback_actor_outbox(actor_uri), + public_key: "placeholder-public-key", + actor_type: "Person", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second), + metadata: %{ + "acct" => acct, + "url" => actor_url, + "fallback" => true + } + } + + %Actor{} + |> Actor.changeset(attrs) + |> Repo.insert( + on_conflict: {:replace, [:display_name, :avatar_url, :last_fetched_at, :metadata]}, + conflict_target: :uri, + returning: true + ) + |> case do + {:ok, actor} -> actor + _ -> ActivityPub.get_actor_by_uri(actor_uri) + end + end + end + + defp create_or_get_fallback_actor(_, _), do: nil + + defp actor_domain_from_acct_or_uri(acct, actor_uri) when is_binary(acct) do + case String.split(acct, "@", parts: 2) do + [_username, domain] when domain != "" -> domain + _ -> actor_domain_from_uri(actor_uri) + end + end + + defp actor_domain_from_acct_or_uri(_, actor_uri), do: actor_domain_from_uri(actor_uri) + + defp actor_domain_from_uri(actor_uri) when is_binary(actor_uri) do + case URI.parse(actor_uri) do + %URI{host: host} when is_binary(host) -> host + _ -> nil + end + end + + defp actor_domain_from_uri(_), do: nil + + defp fallback_actor_inbox(actor_uri) when is_binary(actor_uri), do: actor_uri <> "/inbox" + defp fallback_actor_outbox(actor_uri) when is_binary(actor_uri), do: actor_uri <> "/outbox" + + defp normalize_mastodon_profile_url(url) when is_binary(url) do + case URI.parse(String.trim(url)) do + %URI{scheme: scheme, host: host, path: path} + when scheme in ["http", "https"] and is_binary(host) and is_binary(path) -> + if String.starts_with?(path, "/@") do + "#{scheme}://#{host}/users/#{String.trim_leading(path, "/@")}" + else + url + end + + _ -> + nil + end + end + + defp normalize_mastodon_profile_url(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/activitypub/replies_ingest_worker.ex b/apps/elektrine/lib/elektrine/activitypub/replies_ingest_worker.ex new file mode 100644 index 0000000..1cc168d --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/replies_ingest_worker.ex @@ -0,0 +1,44 @@ +defmodule Elektrine.ActivityPub.RepliesIngestWorker do + @moduledoc """ + Background worker for ingesting remote replies into local storage. + + Timeline reads stay local-first by enqueueing reply ingestion instead of + issuing direct remote HTTP fetches in the LiveView read path. + """ + + use Oban.Worker, + queue: :federation, + max_attempts: 2, + unique: [ + period: 300, + keys: [:message_id], + states: [:available, :scheduled, :executing, :retryable] + ] + + alias Elektrine.ActivityPub.{FederationLoadGuard, RepliesFetcher} + + @doc """ + Enqueue a reply ingestion job for a local message id. + """ + def enqueue(message_id) when is_integer(message_id) and message_id > 0 do + %{"message_id" => message_id} + |> new() + |> Elektrine.JobQueue.insert() + end + + def enqueue(_message_id), do: {:error, :invalid_message_id} + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"message_id" => message_id}}) do + if FederationLoadGuard.skip_nonessential?(__MODULE__) do + {:discard, :federation_overloaded} + else + case RepliesFetcher.fetch_replies_for_message(message_id) do + {:ok, _count} -> :ok + {:error, :message_not_found} -> {:discard, :message_not_found} + {:error, :no_activitypub_id} -> {:discard, :no_activitypub_id} + {:error, reason} -> {:error, reason} + end + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/reply_fetch_policy.ex b/apps/elektrine/lib/elektrine/activitypub/reply_fetch_policy.ex new file mode 100644 index 0000000..a07c2e6 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/reply_fetch_policy.ex @@ -0,0 +1,130 @@ +defmodule Elektrine.ActivityPub.ReplyFetchPolicy do + @moduledoc """ + Central safety limits for remote reply ingestion. + """ + + @preview_max_replies 5 + @preview_max_depth 3 + @preview_max_pages 10 + @full_thread_max_replies 1_000 + @full_thread_max_depth 5 + @full_thread_max_pages 500 + @collection_max_replies 5 + @cooldown_seconds 15 * 60 + @metadata_fetched_at_key "remote_replies_fetched_at" + + def preview_defaults do + [ + max_replies: @preview_max_replies, + max_depth: @preview_max_depth, + max_pages: @preview_max_pages + ] + end + + def full_thread_defaults do + [ + max_replies: @full_thread_max_replies, + max_depth: @full_thread_max_depth, + max_pages: @full_thread_max_pages + ] + end + + def clamp_fetch_opts(opts, mode \\ :preview) when is_list(opts) do + defaults = if mode == :full_thread, do: full_thread_defaults(), else: preview_defaults() + + defaults + |> Keyword.merge(opts) + |> Keyword.update!(:max_replies, &clamp_positive(&1, Keyword.fetch!(defaults, :max_replies))) + |> Keyword.update!(:max_depth, &clamp_positive(&1, Keyword.fetch!(defaults, :max_depth))) + |> Keyword.update!(:max_pages, &clamp_positive(&1, Keyword.fetch!(defaults, :max_pages))) + end + + def clamp_collection_limit(limit), do: clamp_positive(limit, @collection_max_replies) + + def cooldown_seconds, do: @cooldown_seconds + + def fetched_at_metadata_key, do: @metadata_fetched_at_key + + def cooldown_elapsed?(message, opts \\ []) + + def cooldown_elapsed?(%{media_metadata: metadata}, opts) + when is_map(metadata) and is_list(opts) do + if Keyword.get(opts, :skip_cooldown, false) do + true + else + metadata + |> Map.get(@metadata_fetched_at_key) + |> cooldown_elapsed_since?() + end + end + + def cooldown_elapsed?(%{media_metadata: nil}, opts) + when is_list(opts), + do: true + + def cooldown_elapsed?(_message, opts) when is_list(opts) do + Keyword.get(opts, :skip_cooldown, false) + end + + def cooldown_elapsed?(_message, _opts), do: true + + def fetched_at_timestamp do + DateTime.utc_now() + |> DateTime.truncate(:second) + |> DateTime.to_iso8601() + end + + def same_host?(left, right) when is_binary(left) and is_binary(right) do + host(left) == host(right) + end + + def same_host?(_, _), do: false + + def same_host_reply?(root_ref, reply) when is_binary(root_ref) and is_map(reply) do + root_host = host(root_ref) + reply_host = host(reply["id"]) || host(reply["url"]) + parent_host = host(reply["inReplyTo"]) + + is_binary(root_host) and (reply_host == root_host or parent_host == root_host) + end + + def same_host_reply?(_, _), do: false + + def filter_same_host_replies(replies, root_ref) when is_list(replies) and is_binary(root_ref) do + Enum.filter(replies, &same_host_reply?(root_ref, &1)) + end + + def filter_same_host_replies(replies, _), do: replies + + defp clamp_positive(value, max_value) do + value + |> Elektrine.Social.EngagementCounts.non_negative_integer() + |> min(max_value) + end + + defp cooldown_elapsed_since?(nil), do: true + defp cooldown_elapsed_since?(""), do: true + + defp cooldown_elapsed_since?(timestamp) when is_binary(timestamp) do + case DateTime.from_iso8601(timestamp) do + {:ok, fetched_at, _offset} -> + DateTime.diff(DateTime.utc_now(), fetched_at, :second) >= @cooldown_seconds + + _ -> + true + end + end + + defp cooldown_elapsed_since?(_), do: true + + defp host(value) when is_binary(value) do + case URI.parse(value) do + %URI{host: host} when is_binary(host) -> String.downcase(host) + _ -> nil + end + rescue + _ -> nil + end + + defp host(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/activitypub/request_replay.ex b/apps/elektrine/lib/elektrine/activitypub/request_replay.ex new file mode 100644 index 0000000..df11f2b --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/request_replay.ex @@ -0,0 +1,46 @@ +defmodule Elektrine.ActivityPub.RequestReplay do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + schema "activitypub_request_replays" do + field :nonce, :string + field :key_id, :string + field :actor_uri, :string + field :http_method, :string + field :request_path, :string + field :query_string, :string + field :signature_timestamp, :string + field :digest, :string + field :seen_at, :utc_datetime + field :expires_at, :utc_datetime + + timestamps(type: :utc_datetime, updated_at: false) + end + + def changeset(entry, attrs) do + entry + |> cast(attrs, [ + :nonce, + :key_id, + :actor_uri, + :http_method, + :request_path, + :query_string, + :signature_timestamp, + :digest, + :seen_at, + :expires_at + ]) + |> validate_required([ + :nonce, + :key_id, + :http_method, + :request_path, + :seen_at, + :expires_at + ]) + |> unique_constraint(:nonce, name: :activitypub_request_replays_nonce_unique) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/request_replay_cache.ex b/apps/elektrine/lib/elektrine/activitypub/request_replay_cache.ex new file mode 100644 index 0000000..789ff13 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/request_replay_cache.ex @@ -0,0 +1,118 @@ +defmodule Elektrine.ActivityPub.RequestReplayCache do + @moduledoc false + + import Ecto.Query, warn: false + + alias Elektrine.ActivityPub.RequestReplay + alias Elektrine.ActivityPub.SigningKey + alias Elektrine.Repo + + @default_signature_max_age_seconds 300 + @default_signature_clock_skew_seconds 300 + + def claim( + %Plug.Conn{} = conn, + %SigningKey{} = signing_key, + headers_list, + signature_params, + signature + ) + when is_list(headers_list) and is_map(signature_params) and is_binary(signature) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + expires_at = DateTime.add(now, replay_ttl_seconds(), :second) + digest = request_digest(conn) + timestamp = signature_timestamp(conn, signature_params) + + attrs = %{ + nonce: replay_nonce(conn, signing_key, headers_list, signature_params, signature, digest), + key_id: signing_key.key_id, + actor_uri: actor_uri(signing_key), + http_method: conn.method, + request_path: conn.request_path, + query_string: conn.query_string, + signature_timestamp: timestamp, + digest: digest, + seen_at: now, + expires_at: expires_at, + inserted_at: now + } + + case Repo.insert_all(RequestReplay, [attrs], + on_conflict: :nothing, + conflict_target: :nonce + ) do + {1, _rows} -> :ok + {0, _rows} -> {:error, :replayed_request} + end + end + + def claim(_, _, _, _, _), do: {:error, :invalid_replay_claim} + + def prune_expired(now \\ DateTime.utc_now()) do + {count, _rows} = + RequestReplay + |> where([replay], replay.expires_at < ^DateTime.truncate(now, :second)) + |> Repo.delete_all() + + count + end + + defp replay_nonce(conn, signing_key, headers_list, signature_params, signature, digest) do + %{ + version: 1, + key_id: signing_key.key_id, + method: conn.method, + path: conn.request_path, + query: conn.query_string || "", + host: host_header(conn), + signed_headers: headers_list, + created: Map.get(signature_params, "created"), + expires: Map.get(signature_params, "expires"), + date: List.first(Plug.Conn.get_req_header(conn, "date")), + digest: digest, + signature: signature + } + |> Jason.encode!() + |> then(&:crypto.hash(:sha256, &1)) + |> Base.url_encode64(padding: false) + end + + defp host_header(conn) do + conn + |> Plug.Conn.get_req_header("host") + |> List.first() + |> case do + value when is_binary(value) and value != "" -> value + _ -> conn.host + end + end + + defp request_digest(conn) do + conn + |> Plug.Conn.get_req_header("digest") + |> List.first() + end + + defp signature_timestamp(conn, signature_params) do + Map.get(signature_params, "created") || Map.get(signature_params, "expires") || + List.first(Plug.Conn.get_req_header(conn, "date")) + end + + defp actor_uri(%SigningKey{key_id: key_id}) when is_binary(key_id) do + key_id |> String.split("#") |> List.first() + end + + defp actor_uri(_), do: nil + + defp replay_ttl_seconds do + max_age = + Application.get_env(:elektrine, :activitypub, []) + |> Keyword.get(:signature_max_age_seconds, @default_signature_max_age_seconds) + + skew = + Application.get_env(:elektrine, :activitypub, []) + |> Keyword.get(:signature_clock_skew_seconds, @default_signature_clock_skew_seconds) + + max_age + skew + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/side_effects.ex b/apps/elektrine/lib/elektrine/activitypub/side_effects.ex new file mode 100644 index 0000000..c94dde2 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/side_effects.ex @@ -0,0 +1,117 @@ +defmodule Elektrine.ActivityPub.SideEffects do + @moduledoc """ + Handles side effects after ActivityPub activities are processed. + + Side effects include: + - Sending notifications + - Broadcasting to PubSub channels + - Updating counters and statistics + - Triggering background jobs + + This module is inspired by Akkoma's side_effects.ex pattern, + separating the "what happened" from the "what should happen next". + """ + + alias Elektrine.Async + alias Elektrine.Notifications.FederationNotifications + + @doc """ + Generic side effect handler called by the Pipeline. + Dispatches to specific handlers based on activity type. + Returns :ok always - side effects should not fail the main transaction. + """ + def handle(activity, _actor_uri, _result) do + # Side effects are best-effort and type-specific + # The Pipeline calls this generically; we dispatch based on activity type + case activity["type"] do + "Like" -> + # Like side effects are handled when the LikeHandler processes it + :ok + + "Announce" -> + # Announce side effects are handled when AnnounceHandler processes it + :ok + + "Follow" -> + # Follow side effects are handled when FollowHandler processes it + :ok + + "Create" -> + # Create side effects (like broadcasting) are handled in CreateHandler + :ok + + _ -> + # No side effects for this activity type + :ok + end + end + + @doc """ + Triggers side effects for an EmojiReact activity. + """ + def handle_emoji_react(message_id, remote_actor_id, emoji) do + Async.run(fn -> + FederationNotifications.notify_remote_reaction(message_id, remote_actor_id, emoji) + end) + + :ok + end + + @doc """ + Increments the reply count on a parent message. + """ + def increment_reply_count(parent_message_id) when not is_nil(parent_message_id) do + import Ecto.Query + + Elektrine.Repo.update_all( + from(m in Elektrine.Social.Message, + where: m.id == ^parent_message_id and is_nil(m.deleted_at) + ), + inc: [reply_count: 1] + ) + + reply_count = + from(m in Elektrine.Social.Message, + where: m.id == ^parent_message_id, + select: coalesce(m.reply_count, 0) + ) + |> Elektrine.Repo.one() + |> Kernel.||(0) + + Elektrine.Social.MessageStats.upsert_counts(parent_message_id, %{reply_count: reply_count}) + + :ok + end + + def increment_reply_count(nil), do: :ok + + @doc """ + Decrements the reply count on a parent message, flooring at zero, and mirrors + the result into MessageStats. Called when a reply is deleted so counts don't + drift upward forever. + """ + def decrement_reply_count(parent_message_id) when not is_nil(parent_message_id) do + import Ecto.Query + + Elektrine.Repo.update_all( + from(m in Elektrine.Social.Message, + where: m.id == ^parent_message_id and m.reply_count > 0 + ), + inc: [reply_count: -1] + ) + + reply_count = + from(m in Elektrine.Social.Message, + where: m.id == ^parent_message_id, + select: coalesce(m.reply_count, 0) + ) + |> Elektrine.Repo.one() + |> Kernel.||(0) + + Elektrine.Social.MessageStats.upsert_counts(parent_message_id, %{reply_count: reply_count}) + + :ok + end + + def decrement_reply_count(nil), do: :ok +end diff --git a/apps/elektrine/lib/elektrine/activitypub/signature_retry_worker.ex b/apps/elektrine/lib/elektrine/activitypub/signature_retry_worker.ex new file mode 100644 index 0000000..248164f --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/signature_retry_worker.ex @@ -0,0 +1,251 @@ +defmodule Elektrine.ActivityPub.SignatureRetryWorker do + @moduledoc """ + Retries ActivityPub inbox payloads whose signing key could not be resolved. + + Some remote servers rotate or repair actor keys after our first validation + attempt. This worker preserves the signed request — headers, request line and + the exact body bytes — and replays it through + `Elektrine.ActivityPub.SignatureVerification`, the same verifier the inbox + plug uses. It then applies the same activity validation and audience policy + the inbox applies before enqueueing anything. + + The retry queue is an inbox entrance, so it runs the inbox's checks. Two + details make that true rather than aspirational: + + * The activity is parsed from the stored body *after* the digest check + passes, so what gets enqueued is exactly what the signature covered. It + is never taken from a separately supplied parsed map. + * Freshness is judged against the time the request was received, not the + time the job runs. A retry minutes later still rejects a request that was + already stale when it arrived, while an honest request survives the wait. + + Only key-resolution failures are queued. A request whose signature verified + but which broke a constraint has been rejected on the merits and must not get + a second entrance; see `SignatureVerification.retryable_error?/1`. + """ + + use Oban.Worker, + queue: :activitypub, + max_attempts: 5, + unique: [period: 300, fields: [:args], keys: [:activity_id, :actor_uri]] + + require Logger + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.InboundDeliveryPolicy + alias Elektrine.ActivityPub.ObjectValidator + alias Elektrine.ActivityPub.ProcessActivityWorker + alias Elektrine.ActivityPub.SignatureVerification + + @impl Oban.Worker + def perform(%Oban.Job{ + args: + %{ + "actor_uri" => actor_uri, + "method" => method, + "req_headers" => req_headers, + "request_path" => request_path, + "query_string" => query_string, + "raw_body" => raw_body, + "received_at" => received_at + } = args + }) + when is_binary(actor_uri) and is_list(req_headers) and is_binary(raw_body) do + with {:ok, headers} <- normalize_headers(req_headers), + {:ok, received_at} <- parse_received_at(received_at), + conn = rebuild_conn(method, request_path, query_string, headers), + {:ok, %{actor_uri: ^actor_uri}} <- + verify(conn, raw_body, received_at), + {:ok, activity} <- decode_signed_body(raw_body), + :ok <- activity_actor_matches?(activity, actor_uri), + {:ok, validated} <- validate_activity(activity, actor_uri), + :ok <- validate_audience(validated, actor_uri, args), + :ok <- maybe_domain_deliverable(actor_uri), + {:ok, _job_or_duplicate} <- + ProcessActivityWorker.enqueue(validated, actor_uri, target_user_from_args(args)) do + :ok + else + # The signature verified but named a different actor than the payload. + {:ok, %{actor_uri: other}} -> + Logger.warning("Signature retry actor mismatch: payload #{actor_uri}, signature #{other}") + + {:discard, :actor_mismatch} + + {:error, reason} -> + handle_failure(actor_uri, reason) + end + end + + def perform(%Oban.Job{}), do: {:discard, :invalid_metadata} + + # A replayed nonce means an earlier attempt already claimed this request; + # treat it as handled rather than burning the remaining attempts. + defp handle_failure(_actor_uri, :replayed_request), do: {:discard, :replayed_request} + + defp handle_failure(actor_uri, reason) do + if SignatureVerification.retryable_error?(reason) do + Logger.warning("Signature retry failed from #{actor_uri}: #{inspect(reason)}") + {:error, reason} + else + # Constraint failures do not improve by trying again. + Logger.info("Signature retry rejected from #{actor_uri}: #{inspect(reason)}") + {:discard, reason} + end + end + + defp verify(conn, raw_body, received_at) do + SignatureVerification.verify(conn, raw_body: raw_body, now: received_at) + end + + @doc """ + Enqueues a retry when the signature failure looks like an unresolvable key. + + Never raises; a failure to enqueue only loses the retry, not the request. + """ + def enqueue_if_retryable(activity, actor_uri, conn, target_user, reason) do + if SignatureVerification.retryable_error?(reason) do + _ = enqueue(activity, actor_uri, conn, target_user) + end + + :ok + rescue + e -> + Logger.warning("Failed to enqueue signature retry: #{inspect(e)}") + :ok + end + + defdelegate retryable_error?(reason), to: SignatureVerification + + @doc """ + Stores the signed request for a later replay. + + `activity` is used only to key Oban's uniqueness check. The activity that is + eventually processed is re-parsed from `raw_body` after the digest check, so + a caller cannot smuggle in a payload the signature never covered. + """ + def enqueue(activity, actor_uri, conn, target_user \\ nil) + when is_map(activity) and is_binary(actor_uri) do + case raw_body(conn) do + {:ok, raw_body} -> + %{ + "activity_id" => activity["id"], + "actor_uri" => actor_uri, + "method" => conn.method, + "req_headers" => json_safe_headers(conn.req_headers), + "request_path" => conn.request_path, + "query_string" => conn.query_string || "", + "raw_body" => raw_body, + "received_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "target_user_id" => target_user && target_user.id + } + |> new() + |> Elektrine.JobQueue.insert() + + :error -> + # Without the body there is nothing to bind the signature to, so the + # retry could never be verified anyway. + Logger.debug("Skipping signature retry for #{actor_uri}: raw body unavailable") + {:error, :missing_raw_body} + end + end + + defp raw_body(conn) do + case conn.assigns[:raw_body] || conn.private[:cached_body] do + body when is_binary(body) -> {:ok, body} + _ -> :error + end + end + + defp rebuild_conn(method, request_path, query_string, headers) do + %Plug.Conn{ + method: method || "POST", + request_path: request_path || "/inbox", + query_string: query_string || "", + req_headers: headers, + host: host_from_headers(headers) + } + end + + defp host_from_headers(headers) do + case List.keyfind(headers, "host", 0) do + {"host", value} when is_binary(value) -> value + _ -> "" + end + end + + defp decode_signed_body(raw_body) do + case Jason.decode(raw_body) do + {:ok, activity} when is_map(activity) -> {:ok, activity} + _ -> {:error, :invalid_activity} + end + end + + defp activity_actor_matches?(%{"actor" => actor}, actor_uri) when is_binary(actor) do + if actor == actor_uri, do: :ok, else: {:error, :actor_mismatch} + end + + defp activity_actor_matches?(_activity, _actor_uri), do: {:error, :actor_mismatch} + + defp validate_activity(activity, actor_uri) do + case ObjectValidator.validate(activity) do + {:ok, validated} -> + {:ok, validated} + + {:error, reason} -> + Logger.warning("Invalid retried activity from #{actor_uri}: #{inspect(reason)}") + {:error, :invalid_activity} + end + end + + # Only the user inbox queues retries; the community inbox rejects outright, + # so there is never a community to target here. + defp validate_audience(activity, actor_uri, args) do + InboundDeliveryPolicy.permit(activity, actor_uri, target_user_from_args(args), nil) + end + + defp parse_received_at(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, datetime, _offset} -> {:ok, datetime} + _ -> {:error, :invalid_metadata} + end + end + + defp parse_received_at(_), do: {:error, :invalid_metadata} + + defp json_safe_headers(headers) when is_list(headers) do + Enum.flat_map(headers, fn + {key, value} when is_binary(key) and is_binary(value) -> [[key, value]] + [key, value] when is_binary(key) and is_binary(value) -> [[key, value]] + _ -> [] + end) + end + + defp json_safe_headers(_headers), do: [] + + defp normalize_headers(headers) do + Enum.reduce_while(headers, {:ok, []}, fn + {key, value}, {:ok, acc} when is_binary(key) and is_binary(value) -> + {:cont, {:ok, [{String.downcase(key), value} | acc]}} + + [key, value], {:ok, acc} when is_binary(key) and is_binary(value) -> + {:cont, {:ok, [{String.downcase(key), value} | acc]}} + + _, _ -> + {:halt, {:error, :invalid_metadata}} + end) + end + + defp target_user_from_args(%{"target_user_id" => user_id}) when is_integer(user_id) do + Elektrine.Repo.get(Elektrine.Accounts.User, user_id) + end + + defp target_user_from_args(_), do: nil + + defp maybe_domain_deliverable(actor_uri) do + if ActivityPub.DomainDeliveryHealth.deliverable_url?(actor_uri) do + :ok + else + {:error, :domain_unreachable} + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/signature_verification.ex b/apps/elektrine/lib/elektrine/activitypub/signature_verification.ex new file mode 100644 index 0000000..b8b3928 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/signature_verification.ex @@ -0,0 +1,479 @@ +defmodule Elektrine.ActivityPub.SignatureVerification do + @moduledoc """ + The single HTTP Signature verifier for inbound ActivityPub requests. + + Everything that decides whether a signed request is trustworthy lives here: + parsing the header, rebuilding the signing string, checking the signature + against the actor's key, and the constraint checks that a bare signature + check does not cover — required signed headers, timestamp freshness, digest + against the body, and replay. + + There is deliberately one implementation. A second verifier that skipped a + subset of these checks was reachable through the signature retry queue, so a + request rejected at the door could be accepted on the way back in. Callers + differ only in the options they pass, never in which checks run. + + `verify/2` accepts: + + * `:now` — the instant to judge timestamp freshness against. Defaults to + now. The retry worker passes the time the request was *received*, so a + delayed retry still rejects a request that was already stale on arrival. + * `:raw_body` — the exact bytes the digest must match. Defaults to the body + cached on the conn. Verification fails closed when it is absent. + * `:claim_replay` — whether to burn the request's replay nonce. Defaults to + true. + """ + + require Logger + + alias Elektrine.ActivityPub.RequestReplayCache + alias Elektrine.ActivityPub.SigningKey + + @default_signature_max_age_seconds 300 + @default_signature_clock_skew_seconds 300 + + @type result :: %{ + signing_key: SigningKey.t() | struct(), + actor: term(), + actor_uri: String.t() | nil + } + + @doc """ + Verifies the signature on `conn` and returns the signing key and its actor. + + Returns `{:ok, result}` or `{:error, reason}`. Every rejection reason is an + atom or `{atom, term}` so callers can classify without string matching. + """ + def verify(%Plug.Conn{} = conn, opts \\ []) do + with {:ok, signature_header} <- signature_header(conn), + {:ok, params} <- parse_signature_header(signature_header), + {:ok, signing_key} <- fetch_key(params["keyId"]), + headers_list = parse_signed_headers(params["headers"]), + {:ok, signing_key} <- + verify_signature(conn, signing_key, headers_list, params), + :ok <- validate_constraints(conn, headers_list, params, opts), + :ok <- claim_replay(conn, signing_key, headers_list, params, opts) do + {:ok, + %{ + signing_key: signing_key, + actor: load_actor_for_key(signing_key), + actor_uri: actor_uri_from_key_id(signing_key.key_id) + }} + end + end + + @doc """ + True when a rejection reason is worth retrying once the actor's key can be + refetched. + + Only failures to resolve the key are retryable. A signature that verified but + broke a constraint — stale timestamp, digest mismatch, missing signed headers + — is a rejection on the merits and must never come back through the queue. + """ + def retryable_error?({:key_fetch_failed, _}), do: true + def retryable_error?(:invalid_signature), do: true + def retryable_error?(:actor_fetch_failed), do: true + def retryable_error?(_), do: false + + @doc """ + Extracts the signature header, accepting either the `signature` header or an + `Authorization: Signature ...` header. + """ + def signature_header(%Plug.Conn{} = conn) do + case Plug.Conn.get_req_header(conn, "signature") do + [value | _] when is_binary(value) and value != "" -> + {:ok, value} + + _ -> + case authorization_signature_header(conn) do + value when is_binary(value) and value != "" -> {:ok, value} + _ -> {:error, :missing_signature} + end + end + end + + defp authorization_signature_header(conn) do + conn + |> Plug.Conn.get_req_header("authorization") + |> Enum.find_value(fn value -> + case Regex.run(~r/^\s*Signature\s+(.+)$/i, value, capture: :all_but_first) do + [signature_header] -> String.trim(signature_header) + _ -> nil + end + end) + end + + defp parse_signature_header(header) do + parts = extract_signature_params(header) + + if Enum.all?(["keyId", "headers", "signature"], &Map.has_key?(parts, &1)) do + {:ok, parts} + else + {:error, :invalid_signature_header} + end + end + + defp extract_signature_params(header) do + Regex.scan(~r/([A-Za-z][A-Za-z0-9_-]*)=(?:"([^"]*)"|([^,\s]+))/, header) + |> Enum.reduce(%{}, fn + [_, key, quoted], acc -> + Map.put(acc, key, quoted) + + [_, key, quoted, unquoted], acc -> + value = + case quoted do + "" -> String.trim(unquoted) + _ -> quoted + end + + Map.put(acc, key, value) + end) + end + + defp fetch_key(key_id) do + case SigningKey.get_or_fetch_by_key_id(key_id) do + {:ok, signing_key} -> + {:ok, signing_key} + + {:error, reason} -> + Logger.debug("Failed to fetch signing key #{key_id}: #{inspect(reason)}") + {:error, {:key_fetch_failed, reason}} + end + end + + defp parse_signed_headers(headers_string) do + headers_string + |> String.split(" ", trim: true) + |> Enum.map(&String.downcase/1) + end + + # Verifies against the cached key, then once more against a freshly fetched + # key so a rotation on the remote side does not reject an honest request. + defp verify_signature(conn, signing_key, headers_list, params) do + with {:ok, signing_string} <- build_signing_string(conn, headers_list, params) do + if SigningKey.verify(signing_key, signing_string, params["signature"]) do + {:ok, signing_key} + else + Logger.info("Signature verification failed for key #{signing_key.key_id}") + retry_with_refreshed_key(signing_string, signing_key, params) + end + end + end + + defp retry_with_refreshed_key(signing_string, signing_key, params) do + case SigningKey.refresh_by_key_id(signing_key.key_id) do + {:ok, refreshed_key} -> + if SigningKey.verify(refreshed_key, signing_string, params["signature"]) do + {:ok, refreshed_key} + else + Logger.warning("Signature verification failed even after key refresh") + {:error, :invalid_signature} + end + + {:error, _reason} -> + {:error, :invalid_signature} + end + end + + defp build_signing_string(conn, headers_list, signature_params) do + results = + Enum.map(headers_list, fn header_name -> + case get_header_value(conn, header_name, signature_params) do + {:ok, value} -> {:ok, "#{header_name}: #{value}"} + {:error, :missing} -> {:error, header_name} + end + end) + + missing_headers = + results + |> Enum.filter(&match?({:error, _}, &1)) + |> Enum.map(fn {:error, name} -> name end) + + if Enum.empty?(missing_headers) do + {:ok, Enum.map_join(results, "\n", fn {:ok, line} -> line end)} + else + Logger.warning("Missing headers for signature verification: #{inspect(missing_headers)}") + {:error, {:missing_headers, missing_headers}} + end + end + + defp get_header_value(conn, header_name, signature_params) do + case header_name do + "(request-target)" -> + method = String.downcase(conn.method) + query = if conn.query_string != "", do: "?#{conn.query_string}", else: "" + {:ok, "#{method} #{conn.request_path}#{query}"} + + "(created)" -> + signature_param_value(signature_params, "created") + + "(expires)" -> + signature_param_value(signature_params, "expires") + + "host" -> + case Plug.Conn.get_req_header(conn, "host") do + [value | _] -> {:ok, value} + [] -> {:ok, conn.host} + end + + _ -> + case Plug.Conn.get_req_header(conn, header_name) do + [value | _] -> {:ok, value} + [] -> {:error, :missing} + end + end + end + + defp signature_param_value(signature_params, key) do + case Map.get(signature_params, key) do + value when is_binary(value) and value != "" -> {:ok, value} + _ -> {:error, :missing} + end + end + + defp validate_constraints(conn, headers_list, signature_params, opts) do + now = Keyword.get(opts, :now) || DateTime.utc_now() + + with :ok <- validate_required_signed_headers(headers_list), + :ok <- validate_signature_timing(conn, headers_list, signature_params, now) do + validate_request_digest(conn, headers_list, opts) + end + end + + defp validate_required_signed_headers(headers_list) do + cond do + "(request-target)" not in headers_list -> {:error, :missing_signed_request_target} + "host" not in headers_list -> {:error, :missing_signed_host} + true -> :ok + end + end + + defp validate_signature_timing(conn, headers_list, signature_params, now) do + cond do + "(created)" in headers_list or "(expires)" in headers_list -> + validate_created_expires(signature_params, now) + + "date" in headers_list -> + validate_date_header(conn, now) + + true -> + {:error, :missing_signature_timestamp} + end + end + + defp validate_created_expires(signature_params, now) do + max_age_seconds = signature_max_age_seconds() + clock_skew_seconds = signature_clock_skew_seconds() + + with {:ok, created_at} <- parse_optional_unix_timestamp(signature_params, "created"), + {:ok, expires_at} <- parse_optional_unix_timestamp(signature_params, "expires"), + :ok <- validate_created_timestamp(now, created_at, max_age_seconds, clock_skew_seconds), + :ok <- validate_expires_timestamp(now, expires_at, clock_skew_seconds) do + validate_created_before_expires(created_at, expires_at) + end + end + + defp validate_date_header(conn, now) do + max_age_seconds = signature_max_age_seconds() + clock_skew_seconds = signature_clock_skew_seconds() + + case Plug.Conn.get_req_header(conn, "date") do + [date_header | _] -> + with {:ok, date_time} <- parse_http_date(date_header), + diff_seconds <- DateTime.diff(now, date_time, :second), + true <- diff_seconds <= max_age_seconds, + true <- diff_seconds >= -clock_skew_seconds do + :ok + else + false -> {:error, :stale_signature} + {:error, _reason} -> {:error, :invalid_signature_date} + end + + [] -> + {:error, :missing_signature_timestamp} + end + end + + defp validate_created_timestamp(_now, nil, _max_age_seconds, _clock_skew_seconds), do: :ok + + defp validate_created_timestamp(now, created_at, max_age_seconds, clock_skew_seconds) do + cond do + DateTime.diff(created_at, now, :second) > clock_skew_seconds -> + {:error, :signature_not_yet_valid} + + DateTime.diff(now, created_at, :second) > max_age_seconds -> + {:error, :stale_signature} + + true -> + :ok + end + end + + defp validate_expires_timestamp(_now, nil, _clock_skew_seconds), do: :ok + + defp validate_expires_timestamp(now, expires_at, clock_skew_seconds) do + if DateTime.diff(now, expires_at, :second) > clock_skew_seconds do + {:error, :signature_expired} + else + :ok + end + end + + defp validate_created_before_expires(nil, _expires_at), do: :ok + defp validate_created_before_expires(_created_at, nil), do: :ok + + defp validate_created_before_expires(created_at, expires_at) do + if DateTime.compare(created_at, expires_at) == :gt do + {:error, :invalid_signature_window} + else + :ok + end + end + + defp parse_optional_unix_timestamp(signature_params, key) do + case Map.get(signature_params, key) do + nil -> {:ok, nil} + value when is_binary(value) -> parse_unix_timestamp(value) + value when is_integer(value) -> DateTime.from_unix(value) + _ -> {:error, :invalid_signature_timestamp} + end + end + + defp parse_unix_timestamp(value) when is_binary(value) do + case Integer.parse(value) do + {unix_seconds, ""} -> DateTime.from_unix(unix_seconds) + _ -> {:error, :invalid_signature_timestamp} + end + end + + defp parse_http_date(value) when is_binary(value) do + case :httpd_util.convert_request_date(String.to_charlist(value)) do + {{year, month, day}, {hour, minute, second}} -> + NaiveDateTime.new(year, month, day, hour, minute, second) + |> case do + {:ok, naive} -> {:ok, DateTime.from_naive!(naive, "Etc/UTC")} + {:error, reason} -> {:error, reason} + end + + _ -> + {:error, :invalid_signature_date} + end + rescue + _ -> {:error, :invalid_signature_date} + end + + defp validate_request_digest(conn, headers_list, opts) do + if request_body_expected?(conn) or "digest" in headers_list do + if "digest" in headers_list do + validate_digest_header(conn, opts) + else + {:error, :missing_digest_in_signature} + end + else + :ok + end + end + + defp request_body_expected?(%Plug.Conn{method: method}), do: method in ["POST", "PUT", "PATCH"] + + defp validate_digest_header(conn, opts) do + case Plug.Conn.get_req_header(conn, "digest") do + [digest_header | _] -> + with {:ok, expected_digest} <- extract_sha256_digest(digest_header), + {:ok, body} <- raw_body(conn, opts) do + actual_digest = Base.encode64(:crypto.hash(:sha256, body)) + + if secure_compare(expected_digest, actual_digest) do + :ok + else + {:error, :digest_mismatch} + end + end + + [] -> + {:error, :missing_digest_header} + end + end + + defp extract_sha256_digest(header_value) when is_binary(header_value) do + header_value + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.find_value(fn entry -> + case String.split(entry, "=", parts: 2) do + [algorithm, value] -> + if String.upcase(String.trim(algorithm)) == "SHA-256" do + {:ok, String.trim(value)} + else + nil + end + + _ -> + nil + end + end) + |> case do + {:ok, digest} when digest != "" -> {:ok, digest} + _ -> {:error, :unsupported_digest_algorithm} + end + end + + # Fails closed: with no body to hash there is nothing binding the signature + # to the payload, so the request cannot be trusted. + defp raw_body(conn, opts) do + case Keyword.get(opts, :raw_body) || conn.assigns[:raw_body] || conn.private[:cached_body] do + body when is_binary(body) -> {:ok, body} + _ -> {:error, :missing_raw_body} + end + end + + defp secure_compare(left, right) + when is_binary(left) and is_binary(right) and byte_size(left) == byte_size(right) do + Plug.Crypto.secure_compare(left, right) + end + + defp secure_compare(_left, _right), do: false + + defp claim_replay(conn, signing_key, headers_list, signature_params, opts) do + if Keyword.get(opts, :claim_replay, true) do + RequestReplayCache.claim( + conn, + signing_key, + headers_list, + signature_params, + signature_params["signature"] + ) + else + :ok + end + end + + defp load_actor_for_key(%SigningKey{user_id: user_id}) when not is_nil(user_id) do + Elektrine.Accounts.get_user!(user_id) + rescue + Ecto.NoResultsError -> nil + end + + defp load_actor_for_key(%SigningKey{remote_actor_id: remote_actor_id}) + when not is_nil(remote_actor_id) do + Elektrine.ActivityPub.get_remote_actor(remote_actor_id) + end + + defp load_actor_for_key(_), do: nil + + defp actor_uri_from_key_id(key_id) when is_binary(key_id) do + key_id |> String.split("#") |> List.first() + end + + defp actor_uri_from_key_id(_), do: nil + + defp signature_max_age_seconds do + Application.get_env(:elektrine, :activitypub, []) + |> Keyword.get(:signature_max_age_seconds, @default_signature_max_age_seconds) + end + + defp signature_clock_skew_seconds do + Application.get_env(:elektrine, :activitypub, []) + |> Keyword.get(:signature_clock_skew_seconds, @default_signature_clock_skew_seconds) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/signing_key.ex b/apps/elektrine/lib/elektrine/activitypub/signing_key.ex new file mode 100644 index 0000000..fece142 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/signing_key.ex @@ -0,0 +1,212 @@ +defmodule Elektrine.ActivityPub.SigningKey do + @moduledoc "Manages cryptographic signing keys for ActivityPub federation.\n\nKeys are stored separately from users/actors to support:\n- Key rotation\n- Direct lookup by key_id (avoiding actor fetch for verification)\n- Key refresh with backoff to prevent hammering remote servers\n" + use Ecto.Schema + import Ecto.Changeset + alias Elektrine.Accounts.User + alias Elektrine.ActivityPub.Actor, as: RemoteActor + alias Elektrine.Repo + require Logger + @min_refetch_interval 300 + @derive {Inspect, only: [:key_id, :user_id, :remote_actor_id, :inserted_at, :updated_at]} + @primary_key {:key_id, :string, autogenerate: false} + schema("signing_keys") do + belongs_to(:user, User) + belongs_to(:remote_actor, RemoteActor) + field(:public_key, :string) + field(:private_key, Elektrine.Secrets.EncryptedString) + timestamps() + end + + @doc "Changeset for creating/updating a remote signing key.\n" + def remote_changeset(signing_key \\ %__MODULE__{}, attrs) do + signing_key + |> cast(attrs, [:key_id, :remote_actor_id, :public_key]) + |> validate_required([:key_id, :remote_actor_id, :public_key]) + |> unique_constraint(:key_id, name: :signing_keys_pkey) + end + + @doc "Changeset for creating a local user's signing key.\n" + def local_changeset(signing_key \\ %__MODULE__{}, attrs) do + signing_key + |> cast(attrs, [:key_id, :user_id, :public_key, :private_key]) + |> validate_required([:key_id, :user_id, :public_key, :private_key]) + |> unique_constraint(:key_id, name: :signing_keys_pkey) + end + + @doc "Gets a signing key by its key_id.\nReturns nil if not found.\n" + def get_by_key_id(key_id) do + Repo.get(__MODULE__, key_id) + end + + @doc "Gets a signing key by key_id, fetching from remote if not found.\nReturns {:ok, signing_key} or {:error, reason}.\n" + def get_or_fetch_by_key_id(key_id) do + case get_by_key_id(key_id) do + nil -> fetch_remote_key(key_id) + key -> {:ok, key} + end + end + + @doc "Refreshes a signing key if it's old enough.\nImplements backoff to prevent excessive fetching.\n" + def refresh_by_key_id(key_id) do + case get_by_key_id(key_id) do + nil -> + {:error, :not_found} + + key -> + seconds_since_update = + NaiveDateTime.diff(NaiveDateTime.utc_now(), key.updated_at, :second) + + if seconds_since_update >= @min_refetch_interval do + fetch_remote_key(key_id) + else + Logger.debug( + "Key #{key_id} too fresh to refresh (#{seconds_since_update}s < #{@min_refetch_interval}s)" + ) + + {:error, :too_young} + end + end + end + + @doc "Fetches a remote key by key_id.\nThe key_id is typically the actor's URI with #main-key appended.\n" + def fetch_remote_key(key_id) do + Logger.debug("Fetching remote key: #{key_id}") + actor_uri = extract_actor_uri(key_id) + + case Elektrine.ActivityPub.get_or_fetch_actor(actor_uri) do + {:ok, actor} -> + store_remote_key(key_id, actor) + + {:error, reason} -> + Logger.debug("Failed to fetch actor for key #{key_id}: #{inspect(reason)}") + {:error, reason} + end + end + + defp store_remote_key(key_id, %RemoteActor{} = actor) do + if is_nil(actor.public_key) or actor.public_key == "" do + {:error, :no_public_key} + else + attrs = %{key_id: key_id, remote_actor_id: actor.id, public_key: actor.public_key} + + %__MODULE__{} + |> remote_changeset(attrs) + |> Repo.insert( + on_conflict: {:replace, [:public_key, :updated_at]}, + conflict_target: :key_id, + returning: true + ) + end + end + + @doc "Creates or updates a signing key for a local user.\n" + def upsert_local_key(user, public_key, private_key) do + key_id = local_key_id(user) + attrs = %{key_id: key_id, user_id: user.id, public_key: public_key, private_key: private_key} + + %__MODULE__{} + |> local_changeset(attrs) + |> Repo.insert( + on_conflict: {:replace, [:public_key, :private_key, :updated_at]}, + conflict_target: :key_id, + returning: true + ) + end + + @doc "Generates the key_id for a local user.\nUses ActivityPub.instance_url() to ensure consistency with the actor document.\n" + def local_key_id(%User{} = user) do + Elektrine.ActivityPub.actor_key_id(user) + end + + def local_key_id(identifier) when is_binary(identifier) do + Elektrine.ActivityPub.actor_key_id(identifier) + end + + @doc "Extracts the actor URI from a key_id.\n" + def extract_actor_uri(key_id) do + key_id |> String.split("#") |> List.first() + end + + @doc "Decodes the public key from PEM format.\nReturns {:ok, decoded_key} or {:error, reason}.\n" + def public_key_decoded(%__MODULE__{public_key: pem}) when is_binary(pem) do + [entry] = :public_key.pem_decode(pem) + decoded = :public_key.pem_entry_decode(entry) + {:ok, decoded} + rescue + e -> + Logger.error("Failed to decode public key: #{inspect(e)}") + {:error, :invalid_key} + end + + def public_key_decoded(_) do + {:error, :no_key} + end + + @doc "Decodes the private key from PEM format.\nReturns {:ok, decoded_key} or {:error, reason}.\n" + def private_key_decoded(%__MODULE__{private_key: pem}) when is_binary(pem) do + [entry] = :public_key.pem_decode(pem) + decoded = :public_key.pem_entry_decode(entry) + {:ok, decoded} + rescue + e -> + Logger.error("Failed to decode private key: #{inspect(e)}") + {:error, :invalid_key} + end + + def private_key_decoded(_) do + {:error, :no_key} + end + + @doc "Generates a new RSA key pair.\nReturns {public_key_pem, private_key_pem}.\n" + def generate_key_pair do + private_key = :public_key.generate_key({:rsa, 2048, 65_537}) + + private_pem = + :public_key.pem_encode([:public_key.pem_entry_encode(:RSAPrivateKey, private_key)]) + + {:RSAPrivateKey, _version, modulus, exponent, _d, _p, _q, _e1, _e2, _c, _other} = private_key + public_key = {:RSAPublicKey, modulus, exponent} + + public_pem = + :public_key.pem_encode([:public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key)]) + + {public_pem, private_pem} + end + + @doc "Signs a string with a signing key.\nReturns base64-encoded signature.\n" + def sign(%__MODULE__{} = key, data) do + case private_key_decoded(key) do + {:ok, private_key} -> + signature = :public_key.sign(data, :sha256, private_key) + {:ok, Base.encode64(signature)} + + {:error, reason} -> + {:error, reason} + end + end + + @doc "Verifies a signature with a signing key.\nReturns true if valid, false otherwise.\n" + def verify(%__MODULE__{} = key, data, signature) do + case public_key_decoded(key) do + {:ok, public_key} -> + case decode_signature(signature) do + {:ok, decoded_sig} -> :public_key.verify(data, :sha256, decoded_sig, public_key) + :error -> false + end + + {:error, _} -> + false + end + end + + # Some implementations send the signature without base64 padding, so accept + # both rather than rejecting an otherwise valid signature. + defp decode_signature(signature) when is_binary(signature) do + case Base.decode64(signature) do + {:ok, decoded} -> {:ok, decoded} + :error -> Base.decode64(signature, padding: false) + end + end + + defp decode_signature(_), do: :error +end diff --git a/apps/elektrine/lib/elektrine/activitypub/submitted_link_repair_worker.ex b/apps/elektrine/lib/elektrine/activitypub/submitted_link_repair_worker.ex new file mode 100644 index 0000000..e440ca4 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/submitted_link_repair_worker.ex @@ -0,0 +1,183 @@ +defmodule Elektrine.ActivityPub.SubmittedLinkRepairWorker do + @moduledoc """ + Repairs missing submitted-link metadata for federated root posts. + + It re-fetches remote post objects and routes them through the normal + ActivityPub update handler so `external_link` and `primary_url` can be + persisted onto older local rows. + """ + + use Oban.Worker, + queue: :federation, + max_attempts: 2, + unique: [ + period: 300, + keys: [:message_id, :type], + states: [:available, :scheduled, :executing, :retryable] + ] + + import Ecto.Query + + alias Elektrine.Repo + alias Elektrine.Social.Message + + @batch_size 100 + + def enqueue_batch(limit \\ @batch_size) when is_integer(limit) and limit > 0 do + %{"type" => "batch", "limit" => limit} + |> new() + |> Elektrine.JobQueue.insert() + end + + def enqueue_single(message_id) when is_integer(message_id) and message_id > 0 do + %{"type" => "single", "message_id" => message_id} + |> new() + |> Elektrine.JobQueue.insert() + end + + def enqueue_single(_), do: {:error, :invalid_message_id} + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"type" => "single", "message_id" => message_id}}) do + case Repo.get(Message, message_id) |> Repo.preload(:remote_actor) do + nil -> {:discard, :message_not_found} + message -> repair_message(message) + end + end + + def perform(%Oban.Job{args: %{"type" => "batch", "limit" => limit}}) do + candidate_messages(limit) + |> Enum.each(&repair_message/1) + + :ok + end + + def perform(%Oban.Job{}) do + candidate_messages(@batch_size) + |> Enum.each(&repair_message/1) + + :ok + end + + defp candidate_messages(limit) do + from(m in Message, + where: m.federated == true, + where: is_nil(m.deleted_at), + where: is_nil(m.reply_to_id), + where: not is_nil(m.remote_actor_id), + where: not is_nil(m.activitypub_id), + where: is_nil(m.primary_url), + order_by: [desc: m.inserted_at], + limit: ^limit, + preload: [:remote_actor] + ) + |> Repo.all() + |> Enum.filter(&missing_external_link?/1) + end + + defp missing_external_link?(%Message{media_metadata: metadata}) when is_map(metadata) do + case Map.get(metadata, "external_link") do + url when is_binary(url) and url != "" -> false + _ -> true + end + end + + defp missing_external_link?(_), do: true + + defp repair_message(%Message{activitypub_id: activitypub_id} = message) + when is_binary(activitypub_id) do + with {:ok, post_object} <- Elektrine.ActivityPub.RemoteFetch.fetch_object(activitypub_id), + submitted_url when is_binary(submitted_url) <- extract_external_link(post_object) do + metadata = Map.put(message.media_metadata || %{}, "external_link", submitted_url) + + Repo.update_all( + from(m in Message, where: m.id == ^message.id), + set: [ + primary_url: submitted_url, + media_metadata: metadata, + updated_at: DateTime.utc_now() |> DateTime.truncate(:second) + ] + ) + + :ok + else + _ -> :ok + end + end + + defp repair_message(_), do: :ok + + defp extract_external_link(object) when is_map(object) do + activity_id = normalize_external_link_candidate(object["id"]) + + [ + extract_attachment_link(object["attachment"]), + extract_url_field_link(object["url"], activity_id), + extract_source_field_link(object["source"], activity_id) + ] + |> Enum.find(&is_binary/1) + end + + defp extract_external_link(_), do: nil + + defp extract_attachment_link(attachments) when is_list(attachments) do + attachments + |> Enum.find_value(fn + %{"type" => "Link"} = att -> + normalize_external_link_candidate( + att["href"] || att["url"] || get_in(att, ["url", "href"]) + ) + + %{} = att -> + normalize_external_link_candidate(att["href"]) + + _ -> + nil + end) + end + + defp extract_attachment_link(%{} = attachment), do: extract_attachment_link([attachment]) + defp extract_attachment_link(_), do: nil + + defp extract_url_field_link(url_field, activity_id) do + url_field + |> expand_external_link_candidates() + |> Enum.find(fn candidate -> is_binary(candidate) and candidate != activity_id end) + end + + defp extract_source_field_link(%{} = source, activity_id) do + [source["url"], source["href"], source["content"]] + |> expand_external_link_candidates() + |> Enum.find(fn candidate -> is_binary(candidate) and candidate != activity_id end) + end + + defp extract_source_field_link(_, _), do: nil + + defp expand_external_link_candidates(value) when is_list(value), + do: Enum.flat_map(value, &expand_external_link_candidates/1) + + defp expand_external_link_candidates(%{"href" => href}), + do: expand_external_link_candidates(href) + + defp expand_external_link_candidates(%{"url" => url}), do: expand_external_link_candidates(url) + defp expand_external_link_candidates(%{href: href}), do: expand_external_link_candidates(href) + defp expand_external_link_candidates(%{url: url}), do: expand_external_link_candidates(url) + + defp expand_external_link_candidates(value) when is_binary(value) do + case normalize_external_link_candidate(value) do + normalized when is_binary(normalized) -> [normalized] + _ -> [] + end + end + + defp expand_external_link_candidates(_), do: [] + + defp normalize_external_link_candidate(value) when is_binary(value) do + case Elektrine.Security.SafeExternalURL.normalize_href(value) do + {:ok, safe_url} -> safe_url + {:error, _reason} -> nil + end + end + + defp normalize_external_link_candidate(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/activitypub/thread_backfill_worker.ex b/apps/elektrine/lib/elektrine/activitypub/thread_backfill_worker.ex new file mode 100644 index 0000000..da94f2d --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/thread_backfill_worker.ex @@ -0,0 +1,121 @@ +defmodule Elektrine.ActivityPub.ThreadBackfillWorker do + @moduledoc """ + Backfills a remote thread by storing missing ancestors and ingesting replies. + + This is closer to Mastodon's thread/context hydration model: when a remote + post is opened, we try to ensure the local DB has both the ancestor chain and + the descendant replies needed to render a coherent thread. + """ + + use Oban.Worker, + queue: :federation, + max_attempts: 2, + unique: [ + period: 300, + keys: [:message_id, :force], + states: [:available, :scheduled, :executing, :retryable] + ] + + alias Elektrine.ActivityPub.{FederationLoadGuard, Helpers, RepliesFetcher} + alias Elektrine.Messaging + + @max_ancestor_depth 10 + + def enqueue(message_id, opts \\ []) + + def enqueue(message_id, opts) when is_integer(message_id) and message_id > 0 do + %{"message_id" => message_id, "force" => Keyword.get(opts, :force, false)} + |> new() + |> Elektrine.JobQueue.insert() + end + + def enqueue(_, _opts), do: {:error, :invalid_message_id} + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"message_id" => message_id} = args}) do + # A forced backfill is a direct user action (retry button), so it bypasses + # the load shed and the reply-fetch cooldown. + force = args["force"] == true + + if not force and FederationLoadGuard.skip_nonessential?(__MODULE__) do + {:discard, :federation_overloaded} + else + case Messaging.get_message(message_id) do + nil -> + {:discard, :message_not_found} + + message -> + actor_uri = message.remote_actor && message.remote_actor.uri + :ok = backfill_ancestors(message, actor_uri, 0) + + case RepliesFetcher.fetch_full_thread_for_message(message.id, + skip_cache: true, + skip_cooldown: force + ) do + {:ok, _} -> :ok + {:error, :message_not_found} -> {:discard, :message_not_found} + {:error, :no_activitypub_id} -> {:discard, :no_activitypub_id} + {:error, reason} -> {:error, reason} + end + end + end + end + + defp backfill_ancestors(_message, _actor_uri, depth) when depth >= @max_ancestor_depth, do: :ok + + defp backfill_ancestors(message, actor_uri, depth) do + metadata = message.media_metadata || %{} + + in_reply_to_ref = + metadata["inReplyTo"] || metadata[:inReplyTo] || metadata["in_reply_to"] || + metadata[:in_reply_to] + + cond do + message.reply_to_id -> + case Messaging.get_message(message.reply_to_id) do + nil -> :ok + parent -> backfill_ancestors(parent, actor_uri, depth + 1) + end + + !is_binary(in_reply_to_ref) or String.trim(in_reply_to_ref) == "" -> + :ok + + parent = Messaging.get_message_by_activitypub_ref(String.trim(in_reply_to_ref)) -> + maybe_link_reply_to_parent(message, parent) + backfill_ancestors(parent, actor_uri, depth + 1) + + true -> + case Helpers.get_or_store_remote_post(String.trim(in_reply_to_ref), actor_uri) do + {:ok, parent} when is_map(parent) -> + maybe_link_reply_to_parent(message, parent) + backfill_ancestors(parent, actor_uri, depth + 1) + + _ -> + :ok + end + end + end + + defp maybe_link_reply_to_parent(message, parent) do + cond do + not (is_nil(message.reply_to_id) && is_integer(parent.id)) -> + :ok + + # Guard against self-reference and direct 2-cycles. + parent.id == message.id or parent.reply_to_id == message.id -> + :ok + + true -> + case message + |> Ecto.Changeset.change(reply_to_id: parent.id) + |> Elektrine.Repo.update() do + {:ok, _updated} -> + Elektrine.ActivityPub.SideEffects.increment_reply_count(parent.id) + :ok + + error -> + error + end + end + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/tombstone.ex b/apps/elektrine/lib/elektrine/activitypub/tombstone.ex new file mode 100644 index 0000000..473a6da --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/tombstone.ex @@ -0,0 +1,26 @@ +defmodule Elektrine.ActivityPub.Tombstone do + @moduledoc false + + use Ecto.Schema + + import Ecto.Changeset + + schema "activitypub_tombstones" do + field :activity_id, :string + field :actor_uri, :string + field :object_id, :string + field :data, :map, default: %{} + field :received_at, :utc_datetime + + timestamps() + end + + def changeset(tombstone, attrs) do + tombstone + |> cast(attrs, [:activity_id, :actor_uri, :object_id, :data, :received_at]) + |> validate_required([:actor_uri, :object_id, :received_at]) + |> unique_constraint([:actor_uri, :object_id], + name: :activitypub_tombstones_actor_object_unique + ) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/tombstones.ex b/apps/elektrine/lib/elektrine/activitypub/tombstones.ex new file mode 100644 index 0000000..b36a896 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/tombstones.ex @@ -0,0 +1,157 @@ +defmodule Elektrine.ActivityPub.Tombstones do + @moduledoc """ + Records and checks remote Delete receipts (tombstones). + + Keeping a tombstone for every remote Delete lets later imports of the same + object be ignored, matching Mastodon's delete-arrived-first guard. + """ + + import Ecto.Query + + alias Elektrine.ActivityPub.Activity + alias Elektrine.ActivityPub.Tombstone + alias Elektrine.Repo + + @doc """ + Records a remote Delete receipt so later imports of the same object can be ignored. + """ + def record_remote_delete_receipt(activity, actor_uri, object_id) + when is_map(activity) and is_binary(actor_uri) and is_binary(object_id) do + canonical_actor_uri = normalize_activitypub_ref(actor_uri) + canonical_object_id = normalize_activitypub_ref(object_id) + + if is_nil(canonical_actor_uri) or is_nil(canonical_object_id) do + {:error, :invalid_delete_receipt} + else + record_remote_tombstone_row(activity, canonical_actor_uri, canonical_object_id) + + existing_receipt = + from(a in Activity, + where: + a.local == false and a.activity_type == "Delete" and + a.actor_uri == ^canonical_actor_uri and a.object_id == ^canonical_object_id, + limit: 1 + ) + |> Repo.one() + + if existing_receipt do + {:ok, existing_receipt} + else + activity_id = + Map.get(activity, "id") || + delete_receipt_activity_id(canonical_actor_uri, canonical_object_id) + + %Activity{} + |> Activity.changeset(%{ + activity_id: activity_id, + activity_type: "Delete", + actor_uri: canonical_actor_uri, + object_id: canonical_object_id, + data: activity, + local: false, + processed: true, + processed_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert() + end + end + end + + def record_remote_delete_receipt(_activity, _actor_uri, _object_id), + do: {:error, :invalid_delete_receipt} + + @doc """ + Records a remote object tombstone. + + This is an explicit alias for Delete receipts; keeping the name separate makes + call sites read like Mastodon's delete-arrived-first guard. + """ + def record_remote_tombstone(activity, actor_uri, object_id), + do: record_remote_delete_receipt(activity, actor_uri, object_id) + + @doc """ + Returns true when a previously received remote Delete applies to the actor/object pair. + """ + def remote_delete_recorded?(actor_uri, object_refs) when is_binary(actor_uri) do + canonical_actor_uri = normalize_activitypub_ref(actor_uri) + + canonical_object_refs = + object_refs + |> List.wrap() + |> Enum.map(&normalize_activitypub_ref/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + + if is_binary(canonical_actor_uri) and canonical_object_refs != [] do + tombstone_exists? = + from(t in Tombstone, + where: t.actor_uri == ^canonical_actor_uri and t.object_id in ^canonical_object_refs, + select: 1, + limit: 1 + ) + |> Repo.exists?() + + tombstone_exists? or + from(a in Activity, + where: + a.local == false and a.activity_type == "Delete" and + a.actor_uri == ^canonical_actor_uri and a.object_id in ^canonical_object_refs, + select: 1, + limit: 1 + ) + |> Repo.exists?() + else + false + end + end + + def remote_delete_recorded?(_actor_uri, _object_refs), do: false + + @doc """ + Returns true when a remote tombstone exists for the actor/object pair. + """ + def remote_tombstone_recorded?(actor_uri, object_refs), + do: remote_delete_recorded?(actor_uri, object_refs) + + defp record_remote_tombstone_row(activity, actor_uri, object_id) do + received_at = DateTime.utc_now() |> DateTime.truncate(:second) + + %Tombstone{} + |> Tombstone.changeset(%{ + activity_id: Map.get(activity, "id"), + actor_uri: actor_uri, + object_id: object_id, + data: activity, + received_at: received_at + }) + |> Repo.insert( + on_conflict: :nothing, + conflict_target: [:actor_uri, :object_id] + ) + end + + defp delete_receipt_activity_id(actor_uri, object_id) + when is_binary(actor_uri) and is_binary(object_id) do + digest = + :crypto.hash(:sha256, actor_uri <> "\n" <> object_id) + |> Base.encode16(case: :lower) + + "delete-receipt:" <> digest + end + + defp normalize_activitypub_ref(ref) when is_binary(ref) do + ref + |> String.trim() + |> String.split("#", parts: 2) + |> hd() + |> String.split("?", parts: 2) + |> hd() + |> String.trim_trailing("/") + |> case do + "" -> nil + value -> value + end + end + + defp normalize_activitypub_ref(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/activitypub/undo_resolver.ex b/apps/elektrine/lib/elektrine/activitypub/undo_resolver.ex new file mode 100644 index 0000000..ea93252 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/undo_resolver.ex @@ -0,0 +1,157 @@ +defmodule Elektrine.ActivityPub.UndoResolver do + @moduledoc false + + import Ecto.Query, warn: false + + alias Elektrine.Accounts.User + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.{Activity, Actor, GroupFollow} + alias Elektrine.Messaging.{FederatedBoost, FederatedDislike, FederatedLike} + alias Elektrine.Profiles.Follow + alias Elektrine.Repo + alias Elektrine.Social.Message + + @undoable_activity_types ~w(Follow Like Dislike EmojiReact Announce Block) + + def resolve(activity_id, actor_uri) + when is_binary(activity_id) and activity_id != "" and is_binary(actor_uri) and + actor_uri != "" do + case resolve_stored_activity(activity_id, actor_uri) || + resolve_follow(activity_id, actor_uri) || + resolve_group_follow(activity_id, actor_uri) || + resolve_reaction_activity(FederatedLike, "Like", activity_id, actor_uri) || + resolve_reaction_activity(FederatedDislike, "Dislike", activity_id, actor_uri) || + resolve_reaction_activity(FederatedBoost, "Announce", activity_id, actor_uri) do + nil -> :not_found + object -> {:ok, object} + end + end + + def resolve(_activity_id, _actor_uri), do: :not_found + + defp resolve_stored_activity(activity_id, actor_uri) do + case Repo.get_by(Activity, activity_id: activity_id, local: false) do + %Activity{activity_type: type, actor_uri: stored_actor_uri, data: data} + when type in @undoable_activity_types and is_map(data) -> + if same_actor?(stored_actor_uri, actor_uri) do + data + |> Map.put_new("id", activity_id) + |> Map.put_new("type", type) + end + + _ -> + nil + end + end + + defp resolve_follow(activity_id, actor_uri) do + case Repo.one( + from(f in Follow, + join: ra in Actor, + on: ra.id == f.remote_actor_id, + join: u in User, + on: u.id == f.followed_id, + where: f.activitypub_id == ^activity_id and ra.uri == ^actor_uri, + select: %{id: f.activitypub_id, username: u.username} + ) + ) do + nil -> + nil + + %{id: id, username: username} -> + %{ + "type" => "Follow", + "id" => id, + "object" => "#{ActivityPub.instance_url()}/users/#{username}" + } + end + end + + defp resolve_group_follow(activity_id, actor_uri) do + case Repo.one( + from(f in GroupFollow, + join: ra in Actor, + on: ra.id == f.remote_actor_id, + join: ga in Actor, + on: ga.id == f.group_actor_id, + where: f.activitypub_id == ^activity_id and ra.uri == ^actor_uri, + select: %{id: f.activitypub_id, object: ga.uri} + ) + ) do + nil -> + nil + + %{id: id, object: object_uri} -> + %{ + "type" => "Follow", + "id" => id, + "object" => object_uri + } + end + end + + defp resolve_reaction_activity(schema, type, activity_id, actor_uri) do + case Repo.one( + from(r in schema, + join: ra in Actor, + on: ra.id == r.remote_actor_id, + join: m in Message, + on: m.id == r.message_id, + where: r.activitypub_id == ^activity_id and ra.uri == ^actor_uri, + select: %{ + id: r.activitypub_id, + message_id: m.id, + message_activitypub_id: m.activitypub_id, + message_activitypub_url: m.activitypub_url + } + ) + ) do + nil -> + nil + + record -> + %{ + "type" => type, + "id" => record.id, + "object" => target_object_uri(record) + } + end + end + + defp target_object_uri(%{message_activitypub_id: activitypub_id}) + when is_binary(activitypub_id) and activitypub_id != "" do + activitypub_id + end + + defp target_object_uri(%{message_activitypub_url: activitypub_url}) + when is_binary(activitypub_url) and activitypub_url != "" do + activitypub_url + end + + defp target_object_uri(%{message_id: message_id}) do + "#{ActivityPub.instance_url()}/posts/#{message_id}" + end + + defp same_actor?(left, right) do + normalized_left = normalize_ref(left) + normalized_right = normalize_ref(right) + + not is_nil(normalized_left) and normalized_left == normalized_right + end + + defp normalize_ref(ref) when is_binary(ref) do + ref + |> String.trim() + |> String.split("#", parts: 2) + |> hd() + |> String.split("?", parts: 2) + |> hd() + |> String.trim_trailing("/") + |> case do + "" -> nil + value -> value + end + end + + defp normalize_ref(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/activitypub/user_block.ex b/apps/elektrine/lib/elektrine/activitypub/user_block.ex new file mode 100644 index 0000000..a13b199 --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/user_block.ex @@ -0,0 +1,23 @@ +defmodule Elektrine.ActivityPub.UserBlock do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "activitypub_user_blocks" do + field :blocked_uri, :string + field :block_type, :string, default: "user" + + belongs_to :user, Elektrine.Accounts.User + + timestamps() + end + + @doc false + def changeset(user_block, attrs) do + user_block + |> cast(attrs, [:user_id, :blocked_uri, :block_type]) + |> validate_required([:user_id, :blocked_uri]) + |> validate_inclusion(:block_type, ["user", "domain", "mute"]) + |> unique_constraint([:user_id, :blocked_uri]) + end +end diff --git a/apps/elektrine/lib/elektrine/activitypub/visibility.ex b/apps/elektrine/lib/elektrine/activitypub/visibility.ex new file mode 100644 index 0000000..8bcde8b --- /dev/null +++ b/apps/elektrine/lib/elektrine/activitypub/visibility.ex @@ -0,0 +1,72 @@ +defmodule Elektrine.ActivityPub.Visibility do + @moduledoc false + + @public_audience_uris MapSet.new([ + "Public", + "as:Public", + "https://www.w3.org/ns/activitystreams#Public" + ]) + + def visibility(object, opts \\ []) + + def visibility(object, opts) when is_map(object) do + to = List.wrap(object["to"]) + cc = List.wrap(object["cc"]) + + cond do + Enum.any?(to, &public_audience?/1) -> + "public" + + Enum.any?(cc, &public_audience?/1) -> + "unlisted" + + # A reply fetched from a public post's replies collection that carries no + # audience of its own is treated as unlisted, not public: it stays visible + # in the thread but never reaches the public firehose timeline. Promoting + # it to "public" would leak replies from servers that strip audience on + # fetch (Pleroma-lineage convention treats missing audience as non-public). + Keyword.get(opts, :assume_public_reply_without_audience, false) and + is_binary(object["inReplyTo"]) and to == [] and cc == [] -> + "unlisted" + + true -> + "followers" + end + end + + def visibility(_object, _opts), do: "followers" + + def public?(object, opts \\ []) do + visibility(object, opts) == "public" + end + + def public_or_unlisted?(object, opts \\ []) do + visibility(object, opts) in ["public", "unlisted"] + end + + def publicly_addressed?(object) when is_map(object) do + object + |> audience_refs() + |> Enum.any?(&public_audience?/1) + end + + def publicly_addressed?(_object), do: false + + def public_audience?(value) when is_binary(value) do + MapSet.member?(@public_audience_uris, String.trim(value)) + end + + def public_audience?(_value), do: false + + def audience_refs(object) when is_map(object) do + List.wrap(object["to"]) ++ List.wrap(object["cc"]) + end + + def audience_refs(_object), do: [] + + def indexable?(object) when is_map(object) do + public_or_unlisted?(object) && Map.get(object, "indexable") != false + end + + def indexable?(_object), do: false +end diff --git a/apps/elektrine/lib/elektrine/admin.ex b/apps/elektrine/lib/elektrine/admin.ex new file mode 100644 index 0000000..6f60b9b --- /dev/null +++ b/apps/elektrine/lib/elektrine/admin.ex @@ -0,0 +1,305 @@ +defmodule Elektrine.Admin do + @moduledoc """ + The Admin context. + Handles administrative functions like announcements and system management. + """ + + import Ecto.Query, warn: false + alias Elektrine.Repo + + alias Elektrine.Admin.Announcement + alias Elektrine.Admin.AnnouncementDismissal + + @doc """ + Returns the list of announcements. + + ## Examples + + iex> list_announcements() + [%Announcement{}, ...] + + """ + def list_announcements do + Announcement + |> order_by([a], desc: a.inserted_at) + |> preload([:created_by]) + |> Repo.all() + end + + @doc """ + Returns the list of active announcements that should be displayed. + Uses caching to reduce database queries. + + ## Examples + + iex> list_active_announcements() + [%Announcement{}, ...] + + """ + def list_active_announcements do + cache_key = :active_announcements + + case Cachex.get(:app_cache, cache_key) do + {:ok, announcements} when not is_nil(announcements) -> + announcements + + _ -> + announcements = fetch_active_announcements() + # Cache for 5 minutes + Cachex.put(:app_cache, cache_key, announcements, ttl: :timer.minutes(5)) + announcements + end + end + + # Private function to fetch active announcements from database + defp fetch_active_announcements do + now = DateTime.utc_now() + + Announcement + |> where([a], a.active == true) + |> where([a], is_nil(a.starts_at) or a.starts_at <= ^now) + |> where([a], is_nil(a.ends_at) or a.ends_at > ^now) + |> order_by([a], [a.type, desc: a.inserted_at]) + |> preload([:created_by]) + |> Repo.all() + end + + @doc """ + Returns active announcements for a specific user, excluding dismissed ones. + Uses caching to reduce database queries. + + ## Examples + + iex> list_active_announcements_for_user(user_id) + [%Announcement{}, ...] + + """ + def list_active_announcements_for_user(user_id) when is_integer(user_id) do + cache_key = {:announcements, user_id} + + case Cachex.get(:app_cache, cache_key) do + {:ok, announcements} when not is_nil(announcements) -> + announcements + + _ -> + announcements = fetch_active_announcements_for_user(user_id) + # Cache for 5 minutes + Cachex.put(:app_cache, cache_key, announcements, ttl: :timer.minutes(5)) + announcements + end + end + + def list_active_announcements_for_user(nil), do: list_active_announcements() + + # Private function to fetch announcements from database + defp fetch_active_announcements_for_user(user_id) do + now = DateTime.utc_now() + + Announcement + |> where([a], a.active == true) + |> where([a], is_nil(a.starts_at) or a.starts_at <= ^now) + |> where([a], is_nil(a.ends_at) or a.ends_at > ^now) + |> join(:left, [a], d in AnnouncementDismissal, + on: d.announcement_id == a.id and d.user_id == ^user_id + ) + |> where([a, d], is_nil(d.id)) + |> order_by([a], [a.type, desc: a.inserted_at]) + |> preload([:created_by]) + |> Repo.all() + end + + @doc """ + Gets a single announcement. + + Raises `Ecto.NoResultsError` if the Announcement does not exist. + + ## Examples + + iex> get_announcement!(123) + %Announcement{} + + iex> get_announcement!(456) + ** (Ecto.NoResultsError) + + """ + def get_announcement!(id) do + Announcement + |> preload([:created_by]) + |> Repo.get!(id) + end + + @doc """ + Creates a announcement. + + ## Examples + + iex> create_announcement(%{field: value}) + {:ok, %Announcement{}} + + iex> create_announcement(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_announcement(attrs \\ %{}) do + result = + %Announcement{} + |> Announcement.changeset(attrs) + |> Repo.insert() + + # Invalidate all user announcement caches + case result do + {:ok, _announcement} -> clear_all_announcement_caches() + _ -> :ok + end + + result + end + + @doc """ + Updates a announcement. + + ## Examples + + iex> update_announcement(announcement, %{field: new_value}) + {:ok, %Announcement{}} + + iex> update_announcement(announcement, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_announcement(%Announcement{} = announcement, attrs) do + result = + announcement + |> Announcement.changeset(attrs) + |> Repo.update() + + # Invalidate all user announcement caches + case result do + {:ok, _announcement} -> clear_all_announcement_caches() + _ -> :ok + end + + result + end + + @doc """ + Deletes a announcement. + + ## Examples + + iex> delete_announcement(announcement) + {:ok, %Announcement{}} + + iex> delete_announcement(announcement) + {:error, %Ecto.Changeset{}} + + """ + def delete_announcement(%Announcement{} = announcement) do + result = Repo.delete(announcement) + + # Invalidate all user announcement caches + case result do + {:ok, _announcement} -> clear_all_announcement_caches() + _ -> :ok + end + + result + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking announcement changes. + + ## Examples + + iex> change_announcement(announcement) + %Ecto.Changeset{data: %Announcement{}} + + """ + def change_announcement(%Announcement{} = announcement, attrs \\ %{}) do + Announcement.changeset(announcement, attrs) + end + + @doc """ + Deactivates expired announcements. + This can be run as a scheduled task. + """ + def deactivate_expired_announcements do + now = DateTime.utc_now() + + {count, _} = + Announcement + |> where([a], a.active == true) + |> where([a], not is_nil(a.ends_at) and a.ends_at <= ^now) + |> Repo.update_all(set: [active: false, updated_at: now]) + + count + end + + @doc """ + Dismisses an announcement for a specific user. + + ## Examples + + iex> dismiss_announcement_for_user(user_id, announcement_id) + {:ok, %AnnouncementDismissal{}} + + """ + def dismiss_announcement_for_user(user_id, announcement_id) do + attrs = %{ + user_id: user_id, + announcement_id: announcement_id, + dismissed_at: DateTime.utc_now() |> DateTime.truncate(:second) + } + + result = + %AnnouncementDismissal{} + |> AnnouncementDismissal.changeset(attrs) + |> Repo.insert() + + # Invalidate cache for this specific user + case result do + {:ok, _dismissal} -> Cachex.del(:app_cache, {:announcements, user_id}) + _ -> :ok + end + + result + end + + @doc """ + Checks if an announcement has been dismissed by a user. + + ## Examples + + iex> announcement_dismissed_by_user?(user_id, announcement_id) + true + + """ + def announcement_dismissed_by_user?(user_id, announcement_id) do + Repo.exists?( + from d in AnnouncementDismissal, + where: d.user_id == ^user_id and d.announcement_id == ^announcement_id + ) + end + + # Private helper to clear all announcement caches + # Clears all keys matching {:announcements, user_id} and :active_announcements + defp clear_all_announcement_caches do + # Clear the general active announcements cache + Cachex.del(:app_cache, :active_announcements) + + # Get all keys from cache + case Cachex.keys(:app_cache) do + {:ok, keys} -> + # Filter and delete user-specific announcement keys + keys + |> Enum.filter(fn + {:announcements, _user_id} -> true + _ -> false + end) + |> Enum.each(&Cachex.del(:app_cache, &1)) + + _ -> + :ok + end + end +end diff --git a/apps/elektrine/lib/elektrine/admin/announcement.ex b/apps/elektrine/lib/elektrine/admin/announcement.ex new file mode 100644 index 0000000..623ade1 --- /dev/null +++ b/apps/elektrine/lib/elektrine/admin/announcement.ex @@ -0,0 +1,72 @@ +defmodule Elektrine.Admin.Announcement do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + # Announcement types + @types ["info", "warning", "maintenance", "feature", "urgent"] + + schema "announcements" do + field :active, :boolean, default: true + field :type, :string, default: "info" + field :title, :string + field :content, :string + field :starts_at, :utc_datetime + field :ends_at, :utc_datetime + + belongs_to :created_by, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + @doc """ + Returns the list of valid announcement types. + """ + def types, do: @types + + @doc """ + Creates a changeset for announcements. + """ + def changeset(announcement, attrs) do + announcement + |> cast(attrs, [:title, :content, :type, :starts_at, :ends_at, :active, :created_by_id]) + |> validate_required([:title, :content, :type, :created_by_id]) + |> validate_inclusion(:type, @types, message: "must be one of: #{Enum.join(@types, ", ")}") + |> validate_length(:title, min: 1, max: 200) + |> validate_length(:content, min: 1, max: 2000) + |> validate_dates() + |> foreign_key_constraint(:created_by_id) + end + + @doc """ + Checks if an announcement is currently active and within its time window. + """ + def currently_active?(%__MODULE__{} = announcement) do + now = DateTime.utc_now() + + announcement.active && + (is_nil(announcement.starts_at) || DateTime.compare(announcement.starts_at, now) != :gt) && + (is_nil(announcement.ends_at) || DateTime.compare(announcement.ends_at, now) != :lt) + end + + # Private helper functions + + defp validate_dates(changeset) do + starts_at = get_field(changeset, :starts_at) + ends_at = get_field(changeset, :ends_at) + + cond do + is_nil(starts_at) && is_nil(ends_at) -> + changeset + + is_nil(starts_at) || is_nil(ends_at) -> + changeset + + DateTime.compare(starts_at, ends_at) != :lt -> + add_error(changeset, :ends_at, "must be after start date") + + true -> + changeset + end + end +end diff --git a/apps/elektrine/lib/elektrine/admin/announcement_dismissal.ex b/apps/elektrine/lib/elektrine/admin/announcement_dismissal.ex new file mode 100644 index 0000000..aa47ca2 --- /dev/null +++ b/apps/elektrine/lib/elektrine/admin/announcement_dismissal.ex @@ -0,0 +1,28 @@ +defmodule Elektrine.Admin.AnnouncementDismissal do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "announcement_dismissals" do + field :dismissed_at, :utc_datetime + + belongs_to :user, Elektrine.Accounts.User + belongs_to :announcement, Elektrine.Admin.Announcement + + timestamps(type: :utc_datetime) + end + + @doc """ + Creates a changeset for dismissing an announcement. + """ + def changeset(announcement_dismissal, attrs) do + announcement_dismissal + |> cast(attrs, [:dismissed_at, :user_id, :announcement_id]) + |> validate_required([:dismissed_at, :user_id, :announcement_id]) + |> unique_constraint([:user_id, :announcement_id], + message: "You have already dismissed this announcement" + ) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:announcement_id) + end +end diff --git a/apps/elektrine/lib/elektrine/api/import_rate_limiter.ex b/apps/elektrine/lib/elektrine/api/import_rate_limiter.ex new file mode 100644 index 0000000..736e3a3 --- /dev/null +++ b/apps/elektrine/lib/elektrine/api/import_rate_limiter.ex @@ -0,0 +1,14 @@ +defmodule Elektrine.API.ImportRateLimiter do + @moduledoc """ + Rate limiting for bulk import endpoints. + """ + + use Elektrine.RateLimiter, + table: :api_import_rate_limiter, + limits: [ + {:minute, 3}, + {:hour, 20} + ], + lockout: {:minutes, 30}, + cleanup_interval: {:minutes, 5} +end diff --git a/apps/elektrine/lib/elektrine/api/media_rate_limiter.ex b/apps/elektrine/lib/elektrine/api/media_rate_limiter.ex new file mode 100644 index 0000000..e180373 --- /dev/null +++ b/apps/elektrine/lib/elektrine/api/media_rate_limiter.ex @@ -0,0 +1,14 @@ +defmodule Elektrine.API.MediaRateLimiter do + @moduledoc """ + Rate limiting for media upload and metadata API endpoints. + """ + + use Elektrine.RateLimiter, + table: :api_media_rate_limiter, + limits: [ + {:minute, 10}, + {:hour, 120} + ], + lockout: {:minutes, 15}, + cleanup_interval: {:minutes, 5} +end diff --git a/apps/elektrine/lib/elektrine/api/rate_limiter.ex b/apps/elektrine/lib/elektrine/api/rate_limiter.ex new file mode 100644 index 0000000..a94cc9a --- /dev/null +++ b/apps/elektrine/lib/elektrine/api/rate_limiter.ex @@ -0,0 +1,20 @@ +defmodule Elektrine.API.RateLimiter do + @moduledoc """ + Rate limiting for API endpoints to prevent abuse. + Uses ETS for fast in-memory tracking. + + Limits: + - 60 requests per minute + - 1000 requests per hour + - 15-minute lockout after exceeding limits + """ + + use Elektrine.RateLimiter, + table: :api_rate_limiter, + limits: [ + {:minute, 60}, + {:hour, 1000} + ], + lockout: {:minutes, 15}, + cleanup_interval: {:minutes, 5} +end diff --git a/apps/elektrine/lib/elektrine/api/search_rate_limiter.ex b/apps/elektrine/lib/elektrine/api/search_rate_limiter.ex new file mode 100644 index 0000000..3474bab --- /dev/null +++ b/apps/elektrine/lib/elektrine/api/search_rate_limiter.ex @@ -0,0 +1,14 @@ +defmodule Elektrine.API.SearchRateLimiter do + @moduledoc """ + Stricter rate limiting for expensive API search and lookup endpoints. + """ + + use Elektrine.RateLimiter, + table: :api_search_rate_limiter, + limits: [ + {:minute, 20}, + {:hour, 300} + ], + lockout: {:minutes, 10}, + cleanup_interval: {:minutes, 5} +end diff --git a/apps/elektrine/lib/elektrine/api/timeline_rate_limiter.ex b/apps/elektrine/lib/elektrine/api/timeline_rate_limiter.ex new file mode 100644 index 0000000..ae8c0e5 --- /dev/null +++ b/apps/elektrine/lib/elektrine/api/timeline_rate_limiter.ex @@ -0,0 +1,14 @@ +defmodule Elektrine.API.TimelineRateLimiter do + @moduledoc """ + Rate limiting for paginated timeline and status-read API endpoints. + """ + + use Elektrine.RateLimiter, + table: :api_timeline_rate_limiter, + limits: [ + {:minute, 40}, + {:hour, 700} + ], + lockout: {:minutes, 10}, + cleanup_interval: {:minutes, 5} +end diff --git a/apps/elektrine/lib/elektrine/api/write_rate_limiter.ex b/apps/elektrine/lib/elektrine/api/write_rate_limiter.ex new file mode 100644 index 0000000..4c6a358 --- /dev/null +++ b/apps/elektrine/lib/elektrine/api/write_rate_limiter.ex @@ -0,0 +1,14 @@ +defmodule Elektrine.API.WriteRateLimiter do + @moduledoc """ + Rate limiting for write-heavy compatible API actions. + """ + + use Elektrine.RateLimiter, + table: :api_write_rate_limiter, + limits: [ + {:minute, 30}, + {:hour, 500} + ], + lockout: {:minutes, 10}, + cleanup_interval: {:minutes, 5} +end diff --git a/apps/elektrine/lib/elektrine/app_cache.ex b/apps/elektrine/lib/elektrine/app_cache.ex new file mode 100644 index 0000000..22d422c --- /dev/null +++ b/apps/elektrine/lib/elektrine/app_cache.ex @@ -0,0 +1,921 @@ +defmodule Elektrine.AppCache do + @moduledoc """ + Centralized caching system for the entire Elektrine application. + Provides caching for users, mailboxes, admin data, search results, + and system configurations. + """ + + # Cache names and TTLs + @cache_name :app_cache + @user_ttl :timer.minutes(30) + @system_ttl :timer.hours(1) + @search_ttl :timer.minutes(5) + @web_search_ttl :timer.minutes(10) + @admin_ttl :timer.minutes(10) + @contact_ttl :timer.minutes(15) + # Increased from 15 min to reduce HTTP/DB load + @actor_ttl :timer.hours(1) + # Short TTL for fetched objects - balance freshness vs network load + @object_ttl :timer.minutes(10) + # Failed object fetches are cached briefly to avoid hammering unavailable remotes. + @object_negative_ttl :timer.seconds(90) + # ActivityPub ref lookups are hot during inbox processing. + @activitypub_ref_ttl :timer.minutes(5) + @activitypub_ref_negative_ttl :timer.seconds(15) + @activitypub_actor_id_ttl :timer.minutes(10) + @social_message_ttl :timer.seconds(5) + @dns_service_config_ttl :timer.minutes(5) + @site_visit_track_ttl :timer.seconds(60) + # WebFinger lookups cached longer since they rarely change + @webfinger_ttl :timer.hours(6) + @webfinger_negative_ttl :timer.minutes(15) + @media_proxy_negative_ttl :timer.minutes(15) + # Passkey challenges - short TTL for security (5 minutes) + @passkey_challenge_ttl :timer.minutes(5) + # Last-known numeric stats per surface, shown instantly while a fresh load runs + @user_stats_ttl :timer.minutes(10) + # Domain analytics are expensive aggregate queries; keep them fresh but reusable. + @domain_analytics_ttl :timer.seconds(60) + # First page of global (non-personalized) feeds; short TTL to stay fresh + @global_feed_ttl :timer.minutes(2) + # Operator blog posts on the home page come from a remote feed; long TTL, + # with failures cached briefly so a broken feed is not refetched per visit. + @home_blog_ttl :timer.hours(1) + @home_blog_negative_ttl :timer.minutes(5) + import Cachex.Spec + + alias Elektrine.Telemetry.Events + + @doc """ + Starts the application cache. + This should be called from the application supervision tree. + """ + def start_link(_opts) do + Cachex.start_link(@cache_name, + expiration: expiration(default: @system_ttl), + hooks: [hook(module: Cachex.Limit.Scheduled, args: {50_000, [], []})] + ) + end + + def child_spec(opts) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, [opts]}, + type: :worker, + restart: :permanent, + shutdown: 5000 + } + end + + # User-related caching + + @doc """ + Caches user profile and settings data. + """ + def get_user_data(user_id, fetch_fn) do + key = {:user, user_id} + fetch_ok(key, @user_ttl, fetch_fn) + end + + @doc """ + Caches user preferences and settings. + """ + def get_user_preferences(user_id, fetch_fn) do + key = {:user_preferences, user_id} + fetch_ok(key, @user_ttl, fetch_fn) + end + + @doc """ + Returns the last cached stats map for a named surface and subject + (e.g. `get_user_stats(:gallery_insights, user_id)`), or nil. + + Used to show last-known numbers immediately while a fresh load runs + in the background (stale-while-revalidate). + """ + def get_user_stats(scope, subject_id) when is_atom(scope) do + case get_with_telemetry({:user_stats, scope, subject_id}) do + {:ok, %{} = stats} -> stats + _ -> nil + end + end + + @doc """ + Caches the computed stats map for a named surface and subject. + """ + def cache_user_stats(scope, subject_id, stats) when is_atom(scope) and is_map(stats) do + put_with_telemetry({:user_stats, scope, subject_id}, stats, expire: @user_stats_ttl) + end + + @doc """ + Caches the assembled domain analytics payload for a short period. + """ + def get_domain_analytics(scope, fetch_fn) do + key = {:domain_analytics, scope} + fetch_value(key, @domain_analytics_ttl, fetch_fn) + end + + @doc """ + Returns the cached first page of a global (non-personalized) feed + (e.g. `get_global_feed({:videos, "discover"})`), or nil. + """ + def get_global_feed(scope) do + case get_with_telemetry({:global_feed, scope}) do + {:ok, [_ | _] = items} -> items + _ -> nil + end + end + + @doc """ + Caches the first page of a global feed. Empty pages are not cached. + """ + def cache_global_feed(scope, items) when is_list(items) do + if items == [] do + {:ok, false} + else + put_with_telemetry({:global_feed, scope}, items, expire: @global_feed_ttl) + end + end + + @doc """ + Caches mailbox settings and configuration. + """ + def get_mailbox_settings(mailbox_id, fetch_fn) do + key = {:mailbox_settings, mailbox_id} + fetch_ok(key, @user_ttl, fetch_fn) + end + + # Search caching + + @doc """ + Caches search results with query-specific keys. + """ + def get_search_results(user_id, query, page, per_page, fetch_fn) do + # Use SHA256 instead of MD5 for cache key hashing (security best practice) + query_hash = + :crypto.hash(:sha256, query) |> Base.encode16(case: :lower) |> String.slice(0, 32) + + key = {:search, user_id, query_hash, page, per_page} + fetch_ok(key, @search_ttl, fetch_fn) + end + + @doc """ + Caches external web-search results (shared across users; queries hit paid + provider APIs). + + The fetch function must return `{:commit, value}` to cache the value or + `{:ignore, value}` to return it without caching — e.g. degraded results + that shouldn't stick for the whole TTL. + """ + def get_web_search_results(key, fetch_fn) do + case fetch_with_telemetry({:web_search, key}, fn _key -> + case fetch_fn.() do + {:commit, value} -> {:commit, value, expire: @web_search_ttl} + {:ignore, value} -> {:ignore, value} + end + end) do + {:ok, value} -> value + {:commit, value} -> value + {:ignore, value} -> value + end + end + + @doc """ + Caches user's recent search queries. + """ + def get_recent_searches(user_id, fetch_fn) do + key = {:recent_searches, user_id} + fetch_ok(key, @system_ttl, fetch_fn) + end + + # Alias management caching + + @doc """ + Caches email aliases for a user. + """ + def get_aliases(user_id, fetch_fn) do + key = {:aliases, user_id} + fetch_ok(key, @contact_ttl, fetch_fn) + end + + @doc """ + Invalidates cached aliases for a user. + """ + def invalidate_aliases(user_id) do + key = {:aliases, user_id} + delete_with_telemetry(key) + end + + # System configuration caching + + @doc """ + Caches system-wide configuration settings. + """ + def get_system_config(key, fetch_fn) do + cache_key = {:system_config, key} + fetch_ok(cache_key, @system_ttl, fetch_fn) + end + + # Admin dashboard caching + + @doc """ + Caches admin dashboard statistics. + """ + def get_admin_stats(stat_type, fetch_fn) do + key = {:admin_stats, stat_type} + fetch_ok(key, @admin_ttl, fetch_fn) + end + + # ActivityPub actor caching + + @doc """ + Caches ActivityPub actors by their URI. + This significantly reduces database load during high-volume inbox processing. + """ + def get_actor(uri, fetch_fn) do + key = {:actor, uri} + fetch_value(key, @actor_ttl, fetch_fn) + end + + @doc """ + Caches ActivityPub actors by database ID for hot broadcast/projection paths. + """ + def get_activitypub_actor_by_id(actor_id, fetch_fn) when is_integer(actor_id) do + key = {:activitypub_actor_id, actor_id} + fetch_value(key, @activitypub_actor_id_ttl, fetch_fn) + end + + # ActivityPub object caching + + @doc """ + Caches ActivityPub objects by their URI. + This reduces HTTP requests when fetching the same object multiple times. + """ + def get_object(uri, fetch_fn) do + key = {:object, uri} + + case fetch_with_telemetry(key, fn _key -> + case fetch_fn.() do + {:ok, object} -> + {:commit, {:ok, object}, expire: @object_ttl} + + {:error, reason} -> + if negative_cacheable_object_error?(reason) do + {:commit, {:error, reason}, expire: @object_negative_ttl} + else + {:ignore, {:error, reason}} + end + end + end) do + {:commit, value} -> value + {:ok, value} -> value + {:ignore, value} -> value + error -> error + end + end + + defp negative_cacheable_object_error?(reason) + when reason in [ + :not_found, + :fetch_failed, + :http_error, + :invalid_json, + :backoff, + :unauthorized_fetch + ], + do: true + + defp negative_cacheable_object_error?(_), do: false + + @doc """ + Invalidates cached object data. + """ + def invalidate_object(uri) do + delete_with_telemetry({:object, uri}) + end + + @doc """ + Caches message lookups by normalized ActivityPub ref. + Missing refs are cached briefly to avoid repeated expensive misses during inbox storms. + """ + def get_activitypub_message_ref(ref, fetch_fn) when is_binary(ref) do + key = {:activitypub_ref, normalize_activitypub_ref(ref)} + + case fetch_with_telemetry(key, fn _key -> + case fetch_fn.() do + nil -> {:commit, :not_found, expire: @activitypub_ref_negative_ttl} + value -> {:commit, value, expire: @activitypub_ref_ttl} + end + end) do + {:commit, :not_found} -> nil + {:ok, :not_found} -> nil + {:commit, value} -> value + {:ok, value} -> value + _ -> nil + end + end + + @doc """ + Invalidates a cached ActivityPub ref lookup. + """ + def invalidate_activitypub_message_ref(ref) when is_binary(ref) do + delete_with_telemetry({:activitypub_ref, normalize_activitypub_ref(ref)}) + end + + @doc """ + Caches social message primary-key lookups briefly to absorb repeated UI/broadcast reads. + """ + def get_social_message(message_id, fetch_fn) when is_integer(message_id) do + key = {:social_message, message_id} + fetch_value(key, @social_message_ttl, fetch_fn) + end + + def invalidate_social_message(message_id) when is_integer(message_id) do + delete_with_telemetry({:social_message, message_id}) + end + + @doc """ + Caches DNS service configs per zone. Managed service writes explicitly invalidate this. + """ + def get_dns_service_configs(zone_id, fetch_fn) when is_integer(zone_id) do + key = {:dns_service_configs, zone_id} + fetch_value(key, @dns_service_config_ttl, fetch_fn) + end + + def invalidate_dns_service_configs(zone_id) when is_integer(zone_id) do + delete_with_telemetry({:dns_service_configs, zone_id}) + end + + @doc """ + Returns true once per short tracking window for a site/page/session tuple. + """ + def allow_site_visit_tracking?(scope, session_id, request_host, request_path, status) + when is_binary(session_id) and is_binary(request_path) do + key = {:site_visit_seen, scope, session_id, request_host, request_path, status} + + case get_with_telemetry(key) do + {:ok, nil} -> + _ = put_with_telemetry(key, true, expire: @site_visit_track_ttl) + true + + {:ok, _} -> + false + + _ -> + true + end + end + + def allow_site_visit_tracking?(_scope, _session_id, _request_host, _request_path, _status), + do: true + + # WebFinger caching + + @doc """ + Caches WebFinger lookups by acct. + WebFinger results rarely change, so we cache them longer. + """ + def get_webfinger(acct, fetch_fn) do + key = {:webfinger, acct} + + case fetch_with_telemetry(key, fn _key -> + case fetch_fn.() do + {:ok, result} -> + {:commit, {:ok, result}, expire: @webfinger_ttl} + + {:error, :not_found} -> + {:commit, {:error, :not_found}, expire: @webfinger_negative_ttl} + + {:error, reason} -> + {:ignore, {:error, reason}} + end + end) do + {:commit, value} -> value + {:ok, value} -> value + {:ignore, value} -> value + error -> error + end + end + + @doc """ + Returns true if a proxied media URL recently failed upstream fetching. + """ + def media_proxy_failed?(url) when is_binary(url) do + case get_with_telemetry({:media_proxy_failed, url}) do + {:ok, nil} -> false + {:ok, _reason} -> true + _ -> false + end + end + + def media_proxy_failed?(_url), do: false + + @doc """ + Temporarily records a failed proxied media URL to avoid hammering remote hosts. + """ + def mark_media_proxy_failed(url, reason) when is_binary(url) do + put_with_telemetry({:media_proxy_failed, url}, reason, expire: @media_proxy_negative_ttl) + end + + def mark_media_proxy_failed(_url, _reason), do: {:ok, false} + + @doc """ + Clears cached media proxy failure state after a successful fetch or admin invalidation. + """ + def invalidate_media_proxy_failure(url) when is_binary(url) do + delete_with_telemetry({:media_proxy_failed, url}) + end + + def invalidate_media_proxy_failure(_url), do: {:ok, false} + + @doc """ + Returns true if an admin has temporarily banned a media proxy URL. + """ + def media_proxy_banned?(url) when is_binary(url) do + case get_with_telemetry({:media_proxy_banned, url}) do + {:ok, nil} -> false + {:ok, _metadata} -> true + _ -> false + end + end + + def media_proxy_banned?(_url), do: false + + @doc """ + Adds a runtime media proxy ban. Persistent policy still belongs in config blocklists. + """ + def ban_media_proxy_url(url, reason \\ :admin) + + def ban_media_proxy_url(url, reason) when is_binary(url) do + put_with_telemetry( + {:media_proxy_banned, url}, + %{reason: inspect(reason), inserted_at: DateTime.utc_now()}, + expire: :timer.hours(24) + ) + end + + def ban_media_proxy_url(_url, _reason), do: {:ok, false} + + @doc """ + Removes a runtime media proxy ban. + """ + def unban_media_proxy_url(url) when is_binary(url) do + delete_with_telemetry({:media_proxy_banned, url}) + end + + def unban_media_proxy_url(_url), do: {:ok, false} + + @doc """ + Lists recent media proxy failure cache entries. + """ + def list_media_proxy_failures(limit \\ 100) do + list_media_proxy_entries(:media_proxy_failed, limit) + end + + @doc """ + Lists runtime media proxy bans. + """ + def list_media_proxy_bans(limit \\ 100) do + list_media_proxy_entries(:media_proxy_banned, limit) + end + + # Platform stats caching (for home page, public pages) + + @doc """ + Caches platform-wide statistics (user count, post count, etc). + Uses longer TTL since these don't need to be real-time accurate. + """ + def get_platform_stats(fetch_fn) do + key = {:platform_stats} + fetch_ok(key, @admin_ttl, fetch_fn) + end + + @doc """ + Returns the cached home page blog posts, or nil when nothing is cached. + An empty list is a cached negative result, not a miss. + """ + def get_home_blog_posts do + case get_with_telemetry({:home_blog_posts}) do + {:ok, posts} when is_list(posts) -> posts + _ -> nil + end + end + + @doc """ + Caches the home page blog posts. Empty results (fetch failures or an empty + feed) are kept on a short TTL so the feed is retried soon. + """ + def cache_home_blog_posts(posts) when is_list(posts) do + ttl = if posts == [], do: @home_blog_negative_ttl, else: @home_blog_ttl + put_with_telemetry({:home_blog_posts}, posts, expire: ttl) + end + + # Storage caching + + @doc """ + Caches storage info for a user. + """ + def get_storage_info(user_id, fetch_fn) do + key = {:storage_info, user_id} + fetch_ok(key, @user_ttl, fetch_fn) + end + + @doc """ + Invalidates storage cache for a user. + """ + def invalidate_storage_cache(user_id) do + delete_with_telemetry({:storage_info, user_id}) + end + + # Chat/Messaging caching + + @doc """ + Caches chat unread count for a user. + """ + def get_chat_unread_count(user_id, fetch_fn) do + key = {:chat_unread, user_id} + fetch_ok(key, @user_ttl, fetch_fn) + end + + @doc """ + Caches conversation list for a user. + """ + def get_conversations(user_id, fetch_fn) do + key = {:conversations, user_id} + fetch_ok(key, @user_ttl, fetch_fn) + end + + @doc """ + Invalidates chat cache for a user. + """ + def invalidate_chat_cache(user_id) do + delete_with_telemetry({:chat_unread, user_id}) + delete_with_telemetry({:conversations, user_id}) + end + + # Notification caching + + @doc """ + Caches notification unread count for a user. + """ + def get_notification_unread_count(user_id, fetch_fn) do + key = {:notification_unread, user_id} + fetch_ok(key, @user_ttl, fetch_fn) + end + + @doc """ + Invalidates notification cache for a user. + """ + def invalidate_notification_cache(user_id) do + delete_with_telemetry({:notification_unread, user_id}) + end + + # Friends caching + + @doc """ + Caches friends list for a user. + """ + def get_friends(user_id, fetch_fn) do + key = {:friends, user_id} + fetch_ok(key, @user_ttl, fetch_fn) + end + + @doc """ + Caches pending friend requests for a user. + """ + def get_pending_friend_requests(user_id, fetch_fn) do + key = {:pending_friend_requests, user_id} + fetch_ok(key, @user_ttl, fetch_fn) + end + + @doc """ + Invalidates friends cache for a user. + """ + def invalidate_friends_cache(user_id) do + delete_with_telemetry({:friends, user_id}) + delete_with_telemetry({:pending_friend_requests, user_id}) + end + + @doc """ + Caches remote user/community count snapshots for profile pages. + """ + def get_remote_user_counts(actor_id, fetch_fn) do + key = {:remote_user_counts, actor_id} + fetch_ok(key, @contact_ttl, fetch_fn) + end + + def put_remote_user_counts(actor_id, counts) do + put_with_telemetry({:remote_user_counts, actor_id}, counts, expire: @contact_ttl) + end + + def get_remote_user_community_stats(actor_id, fetch_fn) do + key = {:remote_user_community_stats, actor_id} + fetch_ok(key, @contact_ttl, fetch_fn) + end + + def put_remote_user_community_stats(actor_id, stats) do + put_with_telemetry({:remote_user_community_stats, actor_id}, stats, expire: @contact_ttl) + end + + # Cache invalidation functions + + @doc """ + Invalidates all cache entries for a specific user. + """ + def invalidate_user_cache(user_id) do + patterns = [ + {:user, user_id}, + {:user_preferences, user_id}, + {:recent_searches, user_id}, + {:aliases, user_id}, + {:chat_unread, user_id}, + {:conversations, user_id}, + {:notification_unread, user_id}, + {:storage_info, user_id} + ] + + patterns + |> Enum.each(&delete_with_telemetry(&1)) + + # Also invalidate search results for this user + clear_user_searches(user_id) + end + + @doc """ + Deletes a single cached system configuration entry. + """ + def delete_system_config(key) do + delete_with_telemetry({:system_config, key}) + end + + @doc """ + Invalidates admin dashboard cache. + """ + def invalidate_admin_cache do + clear_by_pattern({:admin_stats, :_}) + clear_by_pattern({:admin_recent, :_}) + end + + @doc """ + Invalidates search results for a user. + """ + def invalidate_search_cache(user_id) do + clear_user_searches(user_id) + delete_with_telemetry({:recent_searches, user_id}) + end + + # Warming functions + + @doc """ + Warms up cache for a user after login. + """ + def warm_user_cache(user_id, mailbox_id) do + Elektrine.Async.start(fn -> + # Warm user data + try do + user = Elektrine.Accounts.get_user!(user_id) + get_user_data(user_id, fn -> user end) + rescue + _ -> :ok + end + + # Warm mailbox settings + try do + mailbox = Elektrine.Email.get_mailbox_internal(mailbox_id) + get_mailbox_settings(mailbox_id, fn -> mailbox end) + rescue + _ -> :ok + end + end) + end + + @doc """ + Gets cache statistics for monitoring. + """ + def stats do + Cachex.stats(@cache_name) + end + + @doc """ + Clears all application cache entries. + """ + def clear_all do + clear_with_telemetry() + end + + @doc """ + Clears cache entries matching a pattern. + """ + def clear_by_pattern(pattern) do + {:ok, keys} = keys_with_telemetry() + + keys + |> Enum.filter(&matches_pattern?(&1, pattern)) + |> Enum.each(&delete_with_telemetry(&1)) + end + + # Private helper functions + + defp fetch_ok(key, ttl, fetch_fn) do + case fetch_with_telemetry(key, fn _key -> + {:commit, fetch_fn.(), expire: ttl} + end) do + {:commit, value} -> {:ok, value} + {:ok, value} -> {:ok, value} + error -> error + end + end + + defp fetch_value(key, ttl, fetch_fn) do + case fetch_with_telemetry(key, fn _key -> + {:commit, fetch_fn.(), expire: ttl} + end) do + {:commit, value} -> value + {:ok, value} -> value + error -> error + end + end + + defp clear_user_searches(user_id) do + {:ok, keys} = keys_with_telemetry() + + keys + |> Enum.filter(fn + {:search, ^user_id, _, _, _} -> true + _ -> false + end) + |> Enum.each(&delete_with_telemetry(&1)) + end + + defp matches_pattern?(key, pattern) when is_tuple(key) and is_tuple(pattern) do + key_list = Tuple.to_list(key) + pattern_list = Tuple.to_list(pattern) + + length(key_list) == length(pattern_list) and + Enum.zip(key_list, pattern_list) + |> Enum.all?(fn + {_key_elem, :_} -> true + {key_elem, pattern_elem} -> key_elem == pattern_elem + end) + end + + defp matches_pattern?(_, _), do: false + + defp list_media_proxy_entries(prefix, limit) do + {:ok, keys} = keys_with_telemetry() + + keys + |> Enum.filter(fn + {^prefix, url} when is_binary(url) -> true + _ -> false + end) + |> Enum.take(limit) + |> Enum.map(fn {^prefix, url} = key -> + value = + case get_with_telemetry(key) do + {:ok, metadata} -> metadata + _ -> nil + end + + %{url: url, metadata: value} + end) + end + + # Cachex 4 executes `Cachex.fetch/3` fallbacks inside its Courier process. + # Our fallbacks read from the database, and several callers invoke them + # while already holding a checked-out DB connection inside a transaction + # (for example federation ingress applying an event). That deadlocks: the + # caller blocks on the Courier while the Courier's fallback blocks waiting + # for a database connection. Run the fallback in the calling process + # instead (Cachex 3 semantics), trading cross-process fetch coalescing for + # deadlock safety. + defp fetch_with_telemetry(key, fetch_fn) do + result = + case Cachex.get(@cache_name, key) do + {:ok, value} when not is_nil(value) -> + {:ok, value} + + _miss_or_error -> + case fetch_fn.(key) do + {:commit, value} = commit -> + _ = Cachex.put(@cache_name, key, value) + commit + + {:commit, value, opts} -> + _ = Cachex.put(@cache_name, key, value, Keyword.take(List.wrap(opts), [:expire])) + # Match Cachex.fetch/3, which strips commit options from the + # returned tuple. + {:commit, value} + + {:ignore, _value} = ignore -> + ignore + + other -> + other + end + end + + fetch_result = + case result do + {:ok, _} -> :hit + {:commit, _} -> :miss + {:ignore, _} -> :ignored + _ -> :error + end + + emit_cache(:fetch, fetch_result, key) + result + end + + defp get_with_telemetry(key) do + result = Cachex.get(@cache_name, key) + + get_result = + case result do + {:ok, nil} -> :miss + {:ok, _value} -> :hit + _ -> :error + end + + emit_cache(:get, get_result, key) + result + end + + defp put_with_telemetry(key, value, opts) do + result = Cachex.put(@cache_name, key, value, opts) + emit_cache(:put, cachex_result(result), key) + result + end + + defp delete_with_telemetry(key) do + result = Cachex.del(@cache_name, key) + emit_cache(:delete, cachex_result(result), key) + result + end + + defp clear_with_telemetry do + result = Cachex.clear(@cache_name) + emit_cache(:clear, cachex_result(result), :all) + result + end + + defp keys_with_telemetry do + result = Cachex.keys(@cache_name) + emit_cache(:keys, cachex_result(result), :all) + result + end + + defp cachex_result({:ok, _}), do: :ok + defp cachex_result(:ok), do: :ok + defp cachex_result(_), do: :error + + defp normalize_activitypub_ref(ref) do + ref + |> String.trim() + |> String.split("#", parts: 2) + |> hd() + |> String.split("?", parts: 2) + |> hd() + |> String.trim_trailing("/") + end + + defp emit_cache(operation, result, key) do + Events.cache(:app_cache, operation, result, %{scope: cache_scope(key)}) + end + + defp cache_scope({scope, _}) when is_atom(scope), do: scope + defp cache_scope({scope, _, _}) when is_atom(scope), do: scope + defp cache_scope({scope, _, _, _}) when is_atom(scope), do: scope + defp cache_scope({scope, _, _, _, _}) when is_atom(scope), do: scope + defp cache_scope(_), do: :other + + # ============================================================================= + # Passkey Challenge Caching + # ============================================================================= + + @doc """ + Store a passkey authentication challenge. + The challenge is keyed by its bytes for later retrieval. + """ + def put_passkey_challenge(challenge_bytes, challenge) when is_binary(challenge_bytes) do + key = {:passkey_challenge, challenge_bytes} + put_with_telemetry(key, challenge, expire: @passkey_challenge_ttl) + end + + @doc """ + Retrieve a passkey authentication challenge by its bytes. + Returns {:ok, challenge} or {:ok, nil} if not found/expired. + """ + def get_passkey_challenge(challenge_bytes) when is_binary(challenge_bytes) do + key = {:passkey_challenge, challenge_bytes} + + case get_with_telemetry(key) do + {:ok, challenge} when not is_nil(challenge) -> + # Delete after retrieval to prevent replay attacks + delete_with_telemetry(key) + {:ok, challenge} + + _ -> + {:ok, nil} + end + end +end diff --git a/apps/elektrine/lib/elektrine/application.ex b/apps/elektrine/lib/elektrine/application.ex new file mode 100644 index 0000000..56c90a5 --- /dev/null +++ b/apps/elektrine/lib/elektrine/application.ex @@ -0,0 +1,118 @@ +defmodule Elektrine.Application do + @moduledoc false + + use Application + + alias Elektrine.Platform.ModuleProviders + + @impl true + def start(_type, _args) do + children = + core_children() ++ + jobs_children() ++ + web_children() ++ + mail_children() + + opts = [strategy: :one_for_one, name: Elektrine.Supervisor] + Supervisor.start_link(children, opts) + end + + @impl true + def config_change(changed, _new, removed) do + if Code.ensure_loaded?(ElektrineWeb.Endpoint) do + ElektrineWeb.Endpoint.config_change(changed, removed) + end + + :ok + end + + defp core_children do + [ + Elektrine.Repo, + {DNSCluster, query: Application.get_env(:elektrine, :dns_cluster_query) || :ignore}, + {Phoenix.PubSub, name: Elektrine.PubSub}, + {Finch, name: Elektrine.Finch}, + {Registry, keys: :unique, name: Elektrine.Messaging.FederationSessionRegistry}, + {DynamicSupervisor, + strategy: :one_for_one, name: Elektrine.Messaging.FederationSessionSupervisor}, + Elektrine.AppCache, + Elektrine.Accounts.Tracking, + Elektrine.Encryption.KeyCache, + Elektrine.Messaging.RateLimiter, + Elektrine.Messaging.Federation.IngressRateLimiter, + Elektrine.Auth.RateLimiter, + Elektrine.API.RateLimiter, + Elektrine.API.SearchRateLimiter, + Elektrine.API.TimelineRateLimiter, + Elektrine.API.MediaRateLimiter, + Elektrine.API.ImportRateLimiter, + Elektrine.API.WriteRateLimiter, + Elektrine.Search.RateLimiter, + Elektrine.Search.PaigeRateLimiter, + Elektrine.HTTP.Backoff, + Elektrine.MailAuth.RateLimiter, + Elektrine.JobQueueMonitor + ] ++ ModuleProviders.core_children() + end + + defp jobs_children do + cond do + component_enabled?(:jobs) -> + [ + {Oban, Application.fetch_env!(:elektrine, Oban)} + ] + + component_enabled?(:web) -> + [ + {Oban, enqueue_only_oban_config()} + ] + + true -> + [] + end + end + + defp web_children do + if component_enabled?(:web) and web_runtime_available?() do + [ + ElektrineWeb.Telemetry, + Elektrine.Webhook.RateLimiter, + Elektrine.DAV.RateLimiter, + Elektrine.SecurityAlerts.Cache, + Elektrine.CaddyTLSDomainCache, + ElektrineWeb.Presence + ] ++ ModuleProviders.web_children() ++ [ElektrineWeb.Endpoint] + else + [] + end + end + + defp mail_children do + if component_enabled?(:mail) do + ModuleProviders.mail_children() + else + [] + end + end + + defp component_enabled?(component) do + :elektrine + |> Application.get_env(:runtime_components, []) + |> Keyword.get(component, true) + end + + defp web_runtime_available? do + Code.ensure_loaded?(ElektrineWeb.Telemetry) and + Code.ensure_loaded?(ElektrineWeb.Presence) and + Code.ensure_loaded?(ElektrineWeb.Endpoint) + end + + defp enqueue_only_oban_config do + Application.fetch_env!(:elektrine, Oban) + |> Keyword.merge( + plugins: [], + queues: [], + stage_interval: :infinity + ) + end +end diff --git a/apps/elektrine/lib/elektrine/async.ex b/apps/elektrine/lib/elektrine/async.ex new file mode 100644 index 0000000..9929465 --- /dev/null +++ b/apps/elektrine/lib/elektrine/async.ex @@ -0,0 +1,66 @@ +defmodule Elektrine.Async do + @moduledoc """ + Helper module for running code asynchronously in a way that's compatible + with Ecto Sandbox in tests. + + In production, tasks run asynchronously using Task.start. + In tests, tasks run synchronously to work with the database sandbox. + + ## Usage + + # Instead of: + Task.start(fn -> do_something() end) + + # Use: + Elektrine.Async.run(fn -> do_something() end) + + ## Configuration + + Set `:elektrine, :async_enabled` to false in config/test.exs: + + config :elektrine, :async_enabled, false + """ + + @doc """ + Runs a function asynchronously in production, synchronously in tests. + + In production (async_enabled: true), uses Task.start for fire-and-forget execution. + In tests (async_enabled: false), runs synchronously to work with Ecto Sandbox. + """ + def run(fun) when is_function(fun, 0) do + if async_enabled?() do + Task.start(fun) + else + # Run synchronously in tests, catching errors to match Task.start behavior + try do + fun.() + {:ok, :completed} + rescue + _e -> {:ok, :error_ignored} + end + end + end + + @doc """ + Starts a fire-and-forget task in production, skips it in tests. + + Use this for side-effects that shouldn't run during tests (network calls, + background polling, analytics, cache warmers), while still keeping the same + production behavior. + """ + def start(fun) when is_function(fun, 0) do + if async_enabled?() do + Task.start(fun) + else + {:ok, :skipped} + end + end + + @doc """ + Checks if async execution is enabled (production) or disabled (tests). + Defaults to true (async) if not configured. + """ + def async_enabled? do + Application.get_env(:elektrine, :async_enabled, true) + end +end diff --git a/apps/elektrine/lib/elektrine/atomine_proof_bundle.ex b/apps/elektrine/lib/elektrine/atomine_proof_bundle.ex new file mode 100644 index 0000000..b536c26 --- /dev/null +++ b/apps/elektrine/lib/elektrine/atomine_proof_bundle.ex @@ -0,0 +1,232 @@ +defmodule Elektrine.AtomineProofBundle do + @moduledoc """ + Public Atomine proof bundle for a domain-rooted identity. + + The bundle is intentionally claim-oriented rather than score-oriented. It + gives consumers a signed set of verifiable statements and lets them decide + which claims matter for their own risk model. + """ + + alias Atomine.Personhood + alias Elektrine.Accounts.TrustLevel + alias Elektrine.Accounts.User + alias Elektrine.ActivityPub + alias Elektrine.Domains + alias Elektrine.OIDC + alias Elektrine.OwnRoot + alias Elektrine.Uploads + + @version 1 + @bundle_type "atomine.proof_bundle" + @signature_typ "atomine-proof-bundle+jwt" + + def document(user, domain, opts \\ []) + + def document(%User{} = user, domain, opts) when is_binary(domain) do + domain = normalize_domain(domain) + provider_base_url = opts[:provider_base_url] || Domains.public_base_url() + account_base_url = "https://#{domain}" + issued_at = DateTime.utc_now() |> DateTime.truncate(:second) + + payload = + %{ + "type" => @bundle_type, + "version" => @version, + "issuer" => provider_base_url, + "subject" => OwnRoot.subject(domain), + "domain" => domain, + "did" => OwnRoot.did_for_domain(domain), + "profile" => profile_document(user, domain, account_base_url), + "federation" => federation_document(user, domain, account_base_url), + "claims" => claims(user, domain, account_base_url), + "proofs" => atomine_proofs(user), + "verification" => %{ + "jwks_uri" => provider_base_url <> "/oauth/jwks", + "signature_format" => "jws", + "signature_alg" => "RS256" + }, + "issued_at" => DateTime.to_iso8601(issued_at) + } + |> prune_nil_values() + + Map.put(payload, "signature", signature_document(payload, provider_base_url)) + end + + def document(_, _, _), do: nil + + defp profile_document(user, domain, account_base_url) do + %{ + "url" => account_base_url <> "/", + "handle" => user.handle || user.username, + "username" => user.username, + "display_name" => blank_to_nil(user.display_name || user.handle || user.username), + "avatar_url" => public_avatar_url(user), + "domain_verified" => true, + "own_root" => account_base_url <> "/.well-known/own-root", + "atomine" => account_base_url <> "/.well-known/atomine", + "webfinger" => "acct:#{ActivityPub.actor_identifier(user)}@#{domain}" + } + end + + defp federation_document(user, domain, account_base_url) do + %{ + "activitypub_actor" => ActivityPub.actor_uri(user, account_base_url), + "webfinger" => account_base_url <> "/.well-known/webfinger", + "activitypub_webfinger" => "acct:#{ActivityPub.actor_identifier(user)}@#{domain}", + "atproto_did" => bluesky_did_for_user(user) + } + |> prune_nil_values() + end + + defp claims(user, domain, account_base_url) do + trust_info = TrustLevel.get_level_info(user.trust_level) + + [ + %{ + "type" => "domain.verified", + "value" => true, + "subject" => domain, + "evidence" => "profile_domain_discovery" + }, + %{ + "type" => "own_root.subject", + "value" => OwnRoot.subject(domain) + }, + %{ + "type" => "did.web", + "value" => OwnRoot.did_for_domain(domain) + }, + %{ + "type" => "profile.public_url", + "value" => account_base_url <> "/" + }, + %{ + "type" => "activitypub.actor", + "value" => ActivityPub.actor_uri(user, account_base_url) + }, + %{ + "type" => "webfinger", + "value" => "acct:#{ActivityPub.actor_identifier(user)}@#{domain}" + }, + %{ + "type" => "account.age", + "days" => account_age_days(user), + "created_at" => iso8601(user.inserted_at) + }, + %{ + "type" => "elektrine.trust_level", + "value" => user.trust_level, + "label" => trust_info.name + }, + bluesky_claim(user) + ] + |> Enum.reject(&is_nil/1) + |> Enum.map(&prune_nil_values/1) + end + + defp bluesky_claim(user) do + case bluesky_did_for_user(user) do + "did:" <> _ = did -> + %{"type" => "atproto.did", "value" => did} + + _ -> + nil + end + end + + defp atomine_proofs(%User{id: user_id}) do + user_id + |> Personhood.list_proofs() + |> Enum.map(fn proof -> + %{ + "id" => proof.id, + "kind" => proof.kind, + "claim_type" => proof.claim_type, + "mode" => proof.proof_mode, + "status" => proof.status, + "verification_method" => proof.verification_method, + "subject" => proof.subject, + "evidence_url" => blank_to_nil(proof.evidence_url), + "score_weight" => proof.score_weight, + "checked_at" => iso8601(proof.checked_at), + "verified_at" => iso8601(proof.verified_at), + "live_status" => blank_to_nil(proof.live_status), + "metadata" => proof.metadata || %{} + } + |> prune_nil_values() + end) + end + + defp signature_document(payload, provider_base_url) do + jws = OIDC.sign_claims(payload, @signature_typ) + [header, _claims, _signature] = String.split(jws, ".", parts: 3) + + %{ + "format" => "jws", + "alg" => "RS256", + "typ" => @signature_typ, + "protected" => header, + "value" => jws, + "jwks_uri" => provider_base_url <> "/oauth/jwks" + } + end + + defp account_age_days(%{inserted_at: %DateTime{} = inserted_at}) do + DateTime.diff(DateTime.utc_now(), inserted_at, :day) + |> max(0) + end + + defp account_age_days(%{inserted_at: %NaiveDateTime{} = inserted_at}) do + inserted_at + |> DateTime.from_naive!("Etc/UTC") + |> then(&DateTime.diff(DateTime.utc_now(), &1, :day)) + |> max(0) + end + + defp account_age_days(_), do: 0 + + defp iso8601(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime) + + defp iso8601(%NaiveDateTime{} = datetime) do + datetime + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.to_iso8601() + end + + defp iso8601(_), do: nil + + defp public_avatar_url(%{avatar: avatar}) when is_binary(avatar) and avatar != "" do + Uploads.avatar_url(avatar) + end + + defp public_avatar_url(_), do: nil + + defp bluesky_did_for_user(%{bluesky_did: "did:" <> _ = did}), do: String.trim(did) + defp bluesky_did_for_user(%{bluesky_identifier: "did:" <> _ = did}), do: String.trim(did) + defp bluesky_did_for_user(_), do: nil + + defp normalize_domain(domain) do + domain + |> String.trim() + |> String.downcase() + |> String.trim_trailing(".") + end + + defp blank_to_nil(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp blank_to_nil(_), do: nil + + defp prune_nil_values(map) do + map + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new(fn + {key, value} when is_map(value) -> {key, prune_nil_values(value)} + pair -> pair + end) + end +end diff --git a/apps/elektrine/lib/elektrine/atomine_proof_graph.ex b/apps/elektrine/lib/elektrine/atomine_proof_graph.ex new file mode 100644 index 0000000..e2c5c33 --- /dev/null +++ b/apps/elektrine/lib/elektrine/atomine_proof_graph.ex @@ -0,0 +1,401 @@ +defmodule Elektrine.AtomineProofGraph do + @moduledoc """ + Public Atomine proof graph data built from trust, invite lineage, and local follow edges. + """ + + import Ecto.Query, warn: false + + alias Elektrine.{Accounts, Domains, OwnRoot, Profiles, Repo, Uploads} + alias Elektrine.Accounts.{InviteCode, InviteCodeUse, TrustLevel, User} + + @sample_limit 4 + + def search_public_users(query, limit \\ 8) do + query = query |> to_string() |> String.trim() + + if String.length(query) < 2 do + [] + else + query_term = + "%" <> String.downcase(Elektrine.TextHelpers.sanitize_search_term(query)) <> "%" + + User + |> where([u], u.profile_visibility == "public") + |> where( + [u], + fragment("LOWER(?) LIKE ?", u.username, ^query_term) or + fragment("LOWER(?) LIKE ?", u.display_name, ^query_term) or + fragment("LOWER(?) LIKE ?", u.handle, ^query_term) + ) + |> order_by( + [u], + asc: + fragment( + "CASE WHEN LOWER(COALESCE(?, '')) = ? OR LOWER(?) = ? OR LOWER(?) = ? THEN 0 ELSE 1 END", + u.handle, + ^String.downcase(query), + u.username, + ^String.downcase(query), + u.display_name, + ^String.downcase(query) + ), + asc: u.username + ) + |> limit(^limit) + |> select([u], %{ + id: u.id, + handle: u.handle, + username: u.username, + display_name: u.display_name, + trust_level: u.trust_level, + avatar_url: u.avatar + }) + |> Repo.all() + |> Enum.map(fn result -> + Map.put(result, :avatar_url, Uploads.avatar_url(result.avatar_url)) + end) + end + end + + def build_public_graph(%User{} = user, viewer \\ nil) do + trust_info = TrustLevel.get_level_info(user.trust_level) + palette = Elektrine.Theme.proof_palette(user.trust_level) + handle = display_handle(user) + account_age_days = account_age_days(user) + follower_count = Profiles.get_follower_count(user.id) + following_count = Profiles.get_following_count(user.id) + invitee_count = invitee_count(user.id) + inviter = inviter(user, viewer) + invitees = invitees(user.id, viewer) + followers = followers(user.id, viewer) + following = following(user.id, viewer) + proof_identity = proof_identity(user) + + subject_id = subject_id(user) + + nodes = + [ + %{ + id: subject_id, + kind: "subject", + cluster: "core", + label: "@" <> handle, + subtitle: trust_info.name, + href: graph_path(user), + avatar_url: Uploads.avatar_url(user.avatar), + avatar_label: avatar_label(user), + weight: 1.0 + }, + %{ + id: "trust:#{user.id}", + kind: "trust", + cluster: "trust", + label: "TL#{user.trust_level}", + subtitle: trust_info.name, + weight: 0.8 + }, + %{ + id: "age:#{user.id}", + kind: "signal", + cluster: "age", + label: age_label(account_age_days), + subtitle: "account age", + weight: 0.64 + }, + %{ + id: "followers:#{user.id}", + kind: "aggregate", + cluster: "followers", + label: Integer.to_string(follower_count), + subtitle: "followers", + weight: aggregate_weight(follower_count) + }, + %{ + id: "following:#{user.id}", + kind: "aggregate", + cluster: "following", + label: Integer.to_string(following_count), + subtitle: "following", + weight: aggregate_weight(following_count) + }, + %{ + id: "invitees:#{user.id}", + kind: "aggregate", + cluster: "invite", + label: Integer.to_string(invitee_count), + subtitle: "invitees", + weight: aggregate_weight(invitee_count) + } + ] ++ + maybe_inviter_node(inviter) ++ + Enum.map(invitees, &sample_node(&1, "invitee", "invitee", "invite accepted")) ++ + Enum.map(followers, &sample_node(&1, "follower", "followers", "follows this user")) ++ + Enum.map(following, &sample_node(&1, "following", "following", "followed by this user")) + + edges = + [ + %{ + source: subject_id, + target: "trust:#{user.id}", + kind: "trust", + label: "trust" + }, + %{ + source: subject_id, + target: "age:#{user.id}", + kind: "signal", + label: "longevity" + }, + %{ + source: subject_id, + target: "followers:#{user.id}", + kind: "network", + label: "reach" + }, + %{ + source: subject_id, + target: "following:#{user.id}", + kind: "network", + label: "outgoing" + }, + %{ + source: subject_id, + target: "invitees:#{user.id}", + kind: "invite", + label: "issued" + } + ] ++ + maybe_inviter_edge(inviter, user) ++ + Enum.map(invitees, fn invitee -> + %{ + source: subject_id, + target: sample_id("invitee", invitee.id), + kind: "invite", + label: "invite" + } + end) ++ + Enum.map(followers, fn follower -> + %{ + source: sample_id("follower", follower.id), + target: subject_id, + kind: "follow", + label: "follow" + } + end) ++ + Enum.map(following, fn followee -> + %{ + source: subject_id, + target: sample_id("following", followee.id), + kind: "follow", + label: "follow" + } + end) + + %{ + palette: palette, + subject: %{ + id: user.id, + handle: handle, + display_name: user.display_name || handle, + avatar_url: Uploads.avatar_url(user.avatar), + trust_level: user.trust_level, + trust_name: trust_info.name, + domain: proof_identity.domain, + domain_subject: proof_identity.subject, + did: proof_identity.did, + atomine_url: proof_identity.atomine_url, + own_root_url: proof_identity.own_root_url + }, + stats: [ + %{label: "Trust", value: "TL#{user.trust_level}", note: trust_info.name}, + %{ + label: "Invitees", + value: Integer.to_string(invitee_count), + note: "accepted invite codes" + }, + %{ + label: "Followers", + value: Integer.to_string(follower_count), + note: "public network reach" + }, + %{label: "Age", value: age_label(account_age_days), note: "time in network"} + ], + highlights: + Enum.reject( + [ + inviter && "Invited by @#{display_handle(inviter)}", + invitee_count > 0 && "#{invitee_count} accepted invite#{pluralize(invitee_count)}", + follower_count > 0 && "#{follower_count} follower#{pluralize(follower_count)}", + following_count > 0 && "#{following_count} following" + ], + &is_nil/1 + ), + nodes: nodes, + edges: edges + } + end + + defp proof_identity(%User{} = user) do + domain = + case Profiles.preferred_verified_domain_for_user(user) do + %{domain: domain} when is_binary(domain) -> + domain + + _ -> + "#{display_handle(user)}.#{Domains.default_profile_domain()}" + end + + %{ + domain: domain, + subject: OwnRoot.subject(domain), + did: OwnRoot.did_for_domain(domain), + atomine_url: "https://#{domain}/.well-known/atomine", + own_root_url: "https://#{domain}/.well-known/own-root" + } + end + + defp inviter(%User{} = user, viewer) do + InviteCodeUse + |> join(:inner, [icu], invite_code in InviteCode, on: invite_code.id == icu.invite_code_id) + |> join(:inner, [_icu, invite_code], inviter in User, + on: inviter.id == invite_code.created_by_id + ) + |> where([icu], icu.user_id == ^user.id) + |> limit(1) + |> select([_icu, _invite_code, inviter], inviter) + |> Repo.one() + |> maybe_visible(viewer) + end + + defp invitees(user_id, viewer) do + InviteCode + |> where([invite_code], invite_code.created_by_id == ^user_id) + |> join(:inner, [invite_code], invite_use in InviteCodeUse, + on: invite_use.invite_code_id == invite_code.id + ) + |> join(:inner, [_invite_code, invite_use], invitee in User, + on: invitee.id == invite_use.user_id + ) + |> distinct([_invite_code, _invite_use, invitee], invitee.id) + |> order_by([_invite_code, _invite_use, invitee], desc: invitee.inserted_at) + |> limit(12) + |> select([_invite_code, _invite_use, invitee], invitee) + |> Repo.all() + |> Enum.map(&maybe_visible(&1, viewer)) + |> Enum.reject(&is_nil/1) + |> Enum.take(@sample_limit) + end + + defp invitee_count(user_id) do + InviteCode + |> where([invite_code], invite_code.created_by_id == ^user_id) + |> join(:inner, [invite_code], invite_use in InviteCodeUse, + on: invite_use.invite_code_id == invite_code.id + ) + |> select([_invite_code, invite_use], count(invite_use.user_id, :distinct)) + |> Repo.one() + |> Kernel.||(0) + end + + defp followers(user_id, viewer) do + Profiles.get_followers(user_id, limit: 20) + |> Enum.filter(&(&1.type == "local" && match?(%User{}, &1.user))) + |> Enum.map(&maybe_visible(&1.user, viewer)) + |> Enum.reject(&is_nil/1) + |> Enum.take(@sample_limit) + end + + defp following(user_id, viewer) do + Profiles.get_following(user_id, limit: 20) + |> Enum.filter(&(&1.type == "local" && match?(%User{}, &1.user))) + |> Enum.map(&maybe_visible(&1.user, viewer)) + |> Enum.reject(&is_nil/1) + |> Enum.take(@sample_limit) + end + + defp maybe_visible(%User{} = user, viewer) do + case Accounts.can_view_profile?(user, viewer) do + {:ok, :allowed} -> user + _ -> nil + end + end + + defp maybe_visible(nil, _viewer), do: nil + + defp maybe_inviter_node(nil), do: [] + + defp maybe_inviter_node(%User{} = inviter) do + [ + %{ + id: sample_id("inviter", inviter.id), + kind: "inviter", + cluster: "invite", + label: "@" <> display_handle(inviter), + subtitle: "invited this account", + href: graph_path(inviter), + avatar_url: Uploads.avatar_url(inviter.avatar), + avatar_label: avatar_label(inviter), + weight: 0.72 + } + ] + end + + defp maybe_inviter_edge(nil, _user), do: [] + + defp maybe_inviter_edge(%User{} = inviter, %User{} = user) do + [ + %{ + source: sample_id("inviter", inviter.id), + target: subject_id(user), + kind: "invite", + label: "invited" + } + ] + end + + defp sample_node(%User{} = user, kind, cluster, subtitle) do + %{ + id: sample_id(kind, user.id), + kind: kind, + cluster: cluster, + label: "@" <> display_handle(user), + subtitle: subtitle, + href: graph_path(user), + avatar_url: Uploads.avatar_url(user.avatar), + avatar_label: avatar_label(user), + weight: 0.58 + } + end + + defp aggregate_weight(value) when value >= 20, do: 0.78 + defp aggregate_weight(value) when value >= 5, do: 0.7 + defp aggregate_weight(_value), do: 0.62 + + defp age_label(days) when days < 1, do: "today" + defp age_label(days), do: "#{days}d" + + defp account_age_days(%User{inserted_at: %DateTime{} = inserted_at}) do + Date.diff(Date.utc_today(), DateTime.to_date(inserted_at)) + end + + defp account_age_days(_), do: 0 + + defp display_handle(%User{} = user), do: user.handle || user.username + + defp avatar_label(%User{} = user) do + user.display_name + |> Kernel.||(display_handle(user)) + |> String.trim() + |> String.first() + |> Kernel.||("?") + |> String.upcase() + end + + defp graph_path(%User{} = user), do: "/proofs/" <> display_handle(user) + + defp subject_id(%User{} = user), do: "subject:#{user.id}" + defp sample_id(kind, id), do: "#{kind}:#{id}" + + defp pluralize(1), do: "" + defp pluralize(_), do: "s" +end diff --git a/apps/elektrine/lib/elektrine/audit_log.ex b/apps/elektrine/lib/elektrine/audit_log.ex new file mode 100644 index 0000000..e290d5d --- /dev/null +++ b/apps/elektrine/lib/elektrine/audit_log.ex @@ -0,0 +1,91 @@ +defmodule Elektrine.AuditLog do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + + alias Elektrine.Accounts.User + alias Elektrine.Repo + + schema "audit_logs" do + field :action, :string + field :resource_type, :string + field :resource_id, :integer + field :details, :map + field :ip_address, :string + field :user_agent, :string + + belongs_to :admin, User, foreign_key: :admin_id + belongs_to :target_user, User, foreign_key: :target_user_id + + timestamps() + end + + @doc """ + Creates an audit log entry. + """ + def log(admin_id, action, resource_type, opts \\ []) do + attrs = %{ + admin_id: admin_id, + action: action, + resource_type: resource_type, + target_user_id: opts[:target_user_id], + resource_id: opts[:resource_id], + details: opts[:details] || %{}, + ip_address: opts[:ip_address], + user_agent: opts[:user_agent] + } + + %__MODULE__{} + |> changeset(attrs) + |> Repo.insert() + end + + @doc """ + Gets audit logs with pagination and filtering. + """ + def list_audit_logs(opts \\ []) do + page = Keyword.get(opts, :page, 1) + per_page = Keyword.get(opts, :per_page, 50) + admin_id = Keyword.get(opts, :admin_id) + action = Keyword.get(opts, :action) + + offset = (page - 1) * per_page + + query = + from(a in __MODULE__, + order_by: [desc: a.inserted_at], + preload: [:admin, :target_user] + ) + + query = if admin_id, do: where(query, [a], a.admin_id == ^admin_id), else: query + query = if action, do: where(query, [a], a.action == ^action), else: query + + total_count = Repo.aggregate(query, :count, :id) + + logs = + query + |> limit(^per_page) + |> offset(^offset) + |> Repo.all() + + {logs, total_count} + end + + def changeset(audit_log, attrs) do + audit_log + |> cast(attrs, [ + :admin_id, + :target_user_id, + :action, + :resource_type, + :resource_id, + :details, + :ip_address, + :user_agent + ]) + |> validate_required([:admin_id, :action, :resource_type]) + |> validate_length(:action, max: 100) + |> validate_length(:resource_type, max: 100) + end +end diff --git a/apps/elektrine/lib/elektrine/auth/api_token_revocation.ex b/apps/elektrine/lib/elektrine/auth/api_token_revocation.ex new file mode 100644 index 0000000..366046d --- /dev/null +++ b/apps/elektrine/lib/elektrine/auth/api_token_revocation.ex @@ -0,0 +1,24 @@ +defmodule Elektrine.Auth.APITokenRevocation do + @moduledoc """ + Stores revoked mobile/API bearer tokens until their natural expiration. + """ + + use Ecto.Schema + import Ecto.Changeset + + schema "api_token_revocations" do + field :token_hash, :string + field :expires_at, :utc_datetime + field :revoked_at, :utc_datetime + + timestamps(updated_at: false) + end + + @doc false + def changeset(revocation, attrs) do + revocation + |> cast(attrs, [:token_hash, :expires_at, :revoked_at]) + |> validate_required([:token_hash, :expires_at, :revoked_at]) + |> unique_constraint(:token_hash) + end +end diff --git a/apps/elektrine/lib/elektrine/auth/rate_limiter.ex b/apps/elektrine/lib/elektrine/auth/rate_limiter.ex new file mode 100644 index 0000000..68e1636 --- /dev/null +++ b/apps/elektrine/lib/elektrine/auth/rate_limiter.ex @@ -0,0 +1,25 @@ +defmodule Elektrine.Auth.RateLimiter do + @moduledoc """ + Handles authentication rate limiting to prevent brute force attacks. + Uses ETS for fast in-memory tracking of login attempts. + + Limits: + - 5 attempts per minute + - 10 attempts per hour + - 30-minute lockout after exceeding hourly limit + """ + + use Elektrine.RateLimiter, + table: :auth_rate_limiter, + limits: [ + {:minute, 5}, + {:hour, 10} + ], + lockout: {:minutes, 30}, + cleanup_interval: {:minutes, 5} + + # Older callers aliases + def record_failed_attempt(identifier), do: record_attempt(identifier) + def record_successful_attempt(identifier), do: clear_limits(identifier) + def get_rate_limit_status(identifier), do: get_status(identifier) +end diff --git a/apps/elektrine/lib/elektrine/bluesky.ex b/apps/elektrine/lib/elektrine/bluesky.ex new file mode 100644 index 0000000..da32dbb --- /dev/null +++ b/apps/elektrine/lib/elektrine/bluesky.ex @@ -0,0 +1,1773 @@ +defmodule Elektrine.Bluesky do + @moduledoc "Mirrors local public timeline posts to Bluesky (ATProto).\n\nThis bridge is intentionally best-effort:\n- It never raises to callers.\n- Failures are logged and skipped.\n- ActivityPub delivery remains independent.\n" + import Ecto.Query, warn: false + require Logger + alias Elektrine.Accounts.User + alias Elektrine.ActivityPub + alias Elektrine.Messaging + alias Elektrine.Repo + alias Elektrine.Security.URLValidator + alias Elektrine.Social.Message + alias Elektrine.Uploads + @post_collection "app.bsky.feed.post" + @like_collection "app.bsky.feed.like" + @repost_collection "app.bsky.feed.repost" + @follow_collection "app.bsky.graph.follow" + @images_embed_type "app.bsky.embed.images" + @video_embed_type "app.bsky.embed.video" + @external_embed_type "app.bsky.embed.external" + @record_embed_type "app.bsky.embed.record" + @record_with_media_embed_type "app.bsky.embed.recordWithMedia" + @facet_link_type "app.bsky.richtext.facet#link" + @facet_mention_type "app.bsky.richtext.facet#mention" + @facet_tag_type "app.bsky.richtext.facet#tag" + @default_max_chars 300 + @default_record_list_limit 100 + @default_timeout_ms 12_000 + @max_reply_depth 20 + @max_embed_images 4 + @max_alt_text_chars 1000 + @max_external_title_chars 100 + @max_external_description_chars 300 + @max_facets 30 + @local_media_prefixes [ + "timeline-attachments/", + "chat-attachments/", + "discussion-attachments/", + "gallery-attachments/", + "uploads/" + ] + @doc "Mirrors a local message to Bluesky when all bridge requirements are met.\n" + def mirror_post(%Message{} = message) do + with :ok <- ensure_bridge_enabled(), + {:ok, message} <- refresh_message_for_mirror(message), + {:ok, user} <- fetch_sender(message.sender_id), + {:ok, text} <- build_post_text(message), + {:ok, reply_payload} <- maybe_build_reply_payload(message.reply_to_id), + {:ok, session} <- session_for_user(user), + {:ok, record} <- build_record(message, text, reply_payload, session), + {:ok, published} <- + create_record(session.service_url, session.access_jwt, session.did, record), + :ok <- persist_message_mapping(message.id, published, session) do + :ok + else + {:skip, reason} -> + {:skipped, reason} + + {:error, reason} -> + Logger.warning("Bluesky mirror failed for message #{message.id}: #{inspect(reason)}") + {:error, reason} + end + end + + @doc "Updates an already mirrored Bluesky post after a local edit.\n" + def mirror_post_update(%Message{} = message) do + with :ok <- ensure_bridge_enabled(), + :ok <- ensure_updatable_message(message), + {:ok, user} <- fetch_sender(message.sender_id), + {:ok, text} <- build_post_text(message), + {:ok, reply_payload} <- maybe_build_reply_payload(message.reply_to_id), + {:ok, session} <- session_for_user(user), + {:ok, parsed_uri} <- parse_at_uri(message.bluesky_uri), + :ok <- ensure_collection(parsed_uri.collection, @post_collection), + {:ok, record} <- build_record(message, text, reply_payload, session), + {:ok, published} <- + put_record( + session.service_url, + session.access_jwt, + parsed_uri.repo, + parsed_uri.collection, + parsed_uri.rkey, + record + ) do + persist_message_mapping_update(message.id, published) + else + {:skip, reason} -> + {:skipped, reason} + + {:error, reason} -> + Logger.warning( + "Bluesky update mirror failed for message #{message.id}: #{inspect(reason)}" + ) + + {:error, reason} + end + end + + @doc "Deletes an already mirrored Bluesky post after a local delete.\n" + def mirror_post_delete(%Message{} = message) do + with :ok <- ensure_bridge_enabled(), + :ok <- ensure_deletable_message(message), + {:ok, user} <- fetch_sender(message.sender_id), + {:ok, session} <- session_for_user(user), + {:ok, parsed_uri} <- parse_at_uri(message.bluesky_uri), + :ok <- ensure_collection(parsed_uri.collection, @post_collection) do + delete_record( + session.service_url, + session.access_jwt, + parsed_uri.repo, + parsed_uri.collection, + parsed_uri.rkey + ) + else + {:skip, reason} -> + {:skipped, reason} + + {:error, reason} -> + Logger.warning( + "Bluesky delete mirror failed for message #{message.id}: #{inspect(reason)}" + ) + + {:error, reason} + end + end + + @doc "Mirrors a local like on a mirrored post to Bluesky.\n" + def mirror_like(message_id, user_id) when is_integer(message_id) and is_integer(user_id) do + with :ok <- ensure_bridge_enabled(), + {:ok, message} <- fetch_mirrored_subject_message(message_id), + {:ok, user} <- fetch_user(user_id), + {:ok, session} <- session_for_user(user), + {:ok, parsed_subject} <- parse_at_uri(message.bluesky_uri), + {:ok, existing_uri} <- + find_subject_record_uri(session, @like_collection, parsed_subject.uri), + {:ok, _published} <- + maybe_create_subject_record( + session, + @like_collection, + existing_uri, + %{ + "$type" => @like_collection, + "subject" => %{"uri" => message.bluesky_uri, "cid" => message.bluesky_cid}, + "createdAt" => format_created_at(DateTime.utc_now()) + } + ) do + :ok + else + {:skip, reason} -> {:skipped, reason} + {:error, reason} -> {:error, reason} + end + end + + @doc "Mirrors a local unlike by deleting the matching Bluesky like record.\n" + def mirror_unlike(message_id, user_id) when is_integer(message_id) and is_integer(user_id) do + with :ok <- ensure_bridge_enabled(), + {:ok, message} <- fetch_mirrored_subject_message(message_id), + {:ok, user} <- fetch_user(user_id), + {:ok, session} <- session_for_user(user), + {:ok, parsed_subject} <- parse_at_uri(message.bluesky_uri), + {:ok, like_uri} <- find_subject_record_uri(session, @like_collection, parsed_subject.uri) do + maybe_delete_record_by_uri(session, @like_collection, like_uri) + else + {:skip, reason} -> {:skipped, reason} + {:error, reason} -> {:error, reason} + end + end + + @doc "Mirrors a local repost on a mirrored post to Bluesky.\n" + def mirror_repost(message_id, user_id) when is_integer(message_id) and is_integer(user_id) do + with :ok <- ensure_bridge_enabled(), + {:ok, message} <- fetch_mirrored_subject_message(message_id), + {:ok, user} <- fetch_user(user_id), + {:ok, session} <- session_for_user(user), + {:ok, parsed_subject} <- parse_at_uri(message.bluesky_uri), + {:ok, existing_uri} <- + find_subject_record_uri(session, @repost_collection, parsed_subject.uri), + {:ok, _published} <- + maybe_create_subject_record( + session, + @repost_collection, + existing_uri, + %{ + "$type" => @repost_collection, + "subject" => %{"uri" => message.bluesky_uri, "cid" => message.bluesky_cid}, + "createdAt" => format_created_at(DateTime.utc_now()) + } + ) do + :ok + else + {:skip, reason} -> {:skipped, reason} + {:error, reason} -> {:error, reason} + end + end + + @doc "Mirrors a local unrepost by deleting the matching Bluesky repost record.\n" + def mirror_unrepost(message_id, user_id) when is_integer(message_id) and is_integer(user_id) do + with :ok <- ensure_bridge_enabled(), + {:ok, message} <- fetch_mirrored_subject_message(message_id), + {:ok, user} <- fetch_user(user_id), + {:ok, session} <- session_for_user(user), + {:ok, parsed_subject} <- parse_at_uri(message.bluesky_uri), + {:ok, repost_uri} <- + find_subject_record_uri(session, @repost_collection, parsed_subject.uri) do + maybe_delete_record_by_uri(session, @repost_collection, repost_uri) + else + {:skip, reason} -> {:skipped, reason} + {:error, reason} -> {:error, reason} + end + end + + @doc "Mirrors a local follow relationship to Bluesky.\n" + def mirror_follow(follower_id, followed_id) + when is_integer(follower_id) and is_integer(followed_id) do + with :ok <- ensure_bridge_enabled(), + {:ok, follower} <- fetch_user(follower_id), + {:ok, followed} <- fetch_user(followed_id), + {:ok, session} <- session_for_user(follower), + {:ok, followed_did} <- resolve_follow_target_did(followed, session), + {:ok, existing_uri} <- find_follow_record_uri(session, @follow_collection, followed_did), + {:ok, _published} <- + maybe_create_subject_record( + session, + @follow_collection, + existing_uri, + %{ + "$type" => @follow_collection, + "subject" => followed_did, + "createdAt" => format_created_at(DateTime.utc_now()) + } + ) do + :ok + else + {:skip, reason} -> {:skipped, reason} + {:error, reason} -> {:error, reason} + end + end + + @doc "Mirrors a local unfollow by deleting the matching Bluesky follow record.\n" + def mirror_unfollow(follower_id, followed_id) + when is_integer(follower_id) and is_integer(followed_id) do + with :ok <- ensure_bridge_enabled(), + {:ok, follower} <- fetch_user(follower_id), + {:ok, followed} <- fetch_user(followed_id), + {:ok, session} <- session_for_user(follower), + {:ok, followed_did} <- resolve_follow_target_did(followed, session), + {:ok, follow_uri} <- find_follow_record_uri(session, @follow_collection, followed_did) do + maybe_delete_record_by_uri(session, @follow_collection, follow_uri) + else + {:skip, reason} -> {:skipped, reason} + {:error, reason} -> {:error, reason} + end + end + + @doc "Creates a Bluesky session for a configured local user.\n" + def session_for_user(%User{} = user) do + with :ok <- ensure_bridge_enabled(), + :ok <- ensure_user_enabled(user), + {:ok, identifier} <- require_user_value(user.bluesky_identifier, :missing_identifier), + {:ok, password} <- require_user_value(user.bluesky_app_password, :missing_app_password), + {:ok, service_url} <- service_url_for(user), + {:ok, session} <- create_session(service_url, identifier, password), + :ok <- persist_user_did(user.id, session.did) do + {:ok, Map.put(session, :service_url, service_url)} + end + end + + defp ensure_bridge_enabled do + if Keyword.get(bluesky_config(), :enabled, false) do + :ok + else + {:skip, :bridge_disabled} + end + end + + defp ensure_mirrorable_message(%Message{} = message) do + cond do + message.visibility != "public" -> {:skip, :not_public} + message.federated -> {:skip, :remote_message} + message.post_type == "message" -> {:skip, :not_timeline_post} + is_nil(message.sender_id) -> {:skip, :missing_sender} + not is_nil(message.deleted_at) -> {:skip, :already_deleted} + Elektrine.Strings.present?(message.bluesky_uri) -> {:skip, :already_mirrored} + true -> :ok + end + end + + defp refresh_message_for_mirror(%Message{id: message_id}) when is_integer(message_id) do + case Repo.get(Message, message_id) do + %Message{} = fresh_message -> + with :ok <- ensure_mirrorable_message(fresh_message) do + {:ok, fresh_message} + end + + nil -> + {:skip, :message_not_found} + end + end + + defp refresh_message_for_mirror(%Message{} = message) do + with :ok <- ensure_mirrorable_message(message) do + {:ok, message} + end + end + + defp ensure_updatable_message(%Message{} = message) do + cond do + message.visibility != "public" -> {:skip, :not_public} + message.federated -> {:skip, :remote_message} + message.post_type == "message" -> {:skip, :not_timeline_post} + is_nil(message.sender_id) -> {:skip, :missing_sender} + not Elektrine.Strings.present?(message.bluesky_uri) -> {:skip, :not_mirrored} + not is_nil(message.deleted_at) -> {:skip, :already_deleted} + true -> :ok + end + end + + defp ensure_deletable_message(%Message{} = message) do + cond do + message.federated -> {:skip, :remote_message} + is_nil(message.sender_id) -> {:skip, :missing_sender} + not Elektrine.Strings.present?(message.bluesky_uri) -> {:skip, :not_mirrored} + true -> :ok + end + end + + defp fetch_sender(nil) do + {:skip, :missing_sender} + end + + defp fetch_sender(sender_id) do + case Repo.get(User, sender_id) do + %User{} = user -> {:ok, user} + _ -> {:skip, :sender_not_found} + end + end + + defp fetch_user(user_id) when is_integer(user_id) do + case Repo.get(User, user_id) do + %User{} = user -> {:ok, user} + _ -> {:skip, :user_not_found} + end + end + + defp ensure_user_enabled(%User{bluesky_enabled: true}) do + :ok + end + + defp ensure_user_enabled(_) do + {:skip, :user_not_enabled} + end + + defp require_user_value(value, reason) when is_binary(value) do + trimmed = String.trim(value) + + if Elektrine.Strings.present?(trimmed) do + {:ok, trimmed} + else + {:error, reason} + end + end + + defp require_user_value(_value, reason) do + {:error, reason} + end + + defp service_url_for(%User{bluesky_pds_url: user_pds_url}) do + configured = Keyword.get(bluesky_config(), :service_url, "https://bsky.social") + normalize_service_url(user_pds_url || configured) + end + + defp normalize_service_url(url) when is_binary(url) do + url = url |> String.trim() |> ensure_url_scheme() |> String.trim_trailing("/") + + case URI.parse(url) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + case URLValidator.validate(url) do + :ok -> {:ok, url} + {:error, _reason} -> {:error, :invalid_service_url} + end + + _ -> + {:error, :invalid_service_url} + end + end + + defp normalize_service_url(_) do + {:error, :invalid_service_url} + end + + defp ensure_url_scheme(url) do + if String.starts_with?(url, ["http://", "https://"]) do + url + else + "https://" <> url + end + end + + defp build_post_text(%Message{} = message) do + decrypted = Message.decrypt_content(message) + + text = + [cw_prefix(message.content_warning), title_prefix(message.title), decrypted.content] + |> Enum.map(&Elektrine.Strings.present/1) + |> Enum.reject(&is_nil/1) + |> Enum.join("\n\n") + |> String.trim() + + cond do + Elektrine.Strings.present?(text) -> + {:ok, clamp_text(text)} + + Elektrine.Strings.present?(message.primary_url) -> + {:ok, clamp_text(String.trim(message.primary_url))} + + is_list(message.media_urls) and message.media_urls != [] -> + {:ok, ""} + + true -> + {:skip, :empty_post} + end + end + + defp cw_prefix(nil) do + nil + end + + defp cw_prefix("") do + nil + end + + defp cw_prefix(value) do + "[CW] " <> String.trim(value) + end + + defp title_prefix(nil) do + nil + end + + defp title_prefix("") do + nil + end + + defp title_prefix(value) do + String.trim(value) + end + + defp clamp_text(text) do + max_chars = max(1, Keyword.get(bluesky_config(), :max_chars, @default_max_chars)) + + if String.length(text) <= max_chars do + text + else + truncated = + if max_chars <= 3 do + String.slice(text, 0, max_chars) + else + String.slice(text, 0, max_chars - 3) <> "..." + end + + String.trim(truncated) + end + end + + defp build_record(%Message{} = message, text, reply_payload, session) do + with {:ok, facets} <- maybe_build_facets(text, session), + {:ok, embed_payload} <- maybe_build_embed_payload(message, session) do + record = + %{ + "$type" => @post_collection, + "text" => text, + "createdAt" => format_created_at(message.inserted_at) + } + |> maybe_put_reply(reply_payload) + |> maybe_put_facets(facets) + |> maybe_put_embed(embed_payload) + + {:ok, record} + end + end + + defp maybe_put_reply(record, nil) do + record + end + + defp maybe_put_reply(record, reply) do + Map.put(record, "reply", reply) + end + + defp maybe_put_facets(record, []) do + record + end + + defp maybe_put_facets(record, nil) do + record + end + + defp maybe_put_facets(record, facets) do + Map.put(record, "facets", facets) + end + + defp maybe_put_embed(record, nil) do + record + end + + defp maybe_put_embed(record, embed) do + Map.put(record, "embed", embed) + end + + defp maybe_build_facets(text, _session) when not is_binary(text) do + {:ok, []} + end + + defp maybe_build_facets("", _session) do + {:ok, []} + end + + defp maybe_build_facets(text, session) do + link_candidates = extract_link_candidates(text) + link_ranges = Enum.map(link_candidates, &{&1.byte_start, &1.byte_end}) + mention_candidates = extract_mention_candidates(text, link_ranges) + mention_ranges = Enum.map(mention_candidates, &{&1.byte_start, &1.byte_end}) + hashtag_candidates = extract_hashtag_candidates(text, link_ranges ++ mention_ranges) + + {mention_facets, _cache} = + Enum.reduce(mention_candidates, {[], %{}}, fn candidate, {acc, cache} -> + {did, next_cache} = resolve_mention_did_cached(candidate.handle, session, cache) + + if Elektrine.Strings.present?(did) do + facet = %{ + "index" => %{"byteStart" => candidate.byte_start, "byteEnd" => candidate.byte_end}, + "features" => [%{"$type" => @facet_mention_type, "did" => did}] + } + + {[facet | acc], next_cache} + else + {acc, next_cache} + end + end) + + facets = link_facets(link_candidates) ++ mention_facets ++ hashtag_facets(hashtag_candidates) + {:ok, facets |> Enum.sort_by(&get_in(&1, ["index", "byteStart"])) |> Enum.take(@max_facets)} + end + + defp extract_link_candidates(text) do + Regex.scan(~r/https?:\/\/[^\s<>()]+/u, text, return: :index) + |> Enum.reduce([], fn + [{byte_start, byte_len}], acc -> + raw_url = binary_part(text, byte_start, byte_len) + trimmed_url = trim_trailing_url_punctuation(raw_url) + trimmed_len = byte_size(trimmed_url) + + cond do + not Elektrine.Strings.present?(trimmed_url) -> + acc + + not valid_absolute_http_url?(trimmed_url) -> + acc + + true -> + [ + %{byte_start: byte_start, byte_end: byte_start + trimmed_len, url: trimmed_url} + | acc + ] + end + + _other, acc -> + acc + end) + |> Enum.reverse() + end + + defp trim_trailing_url_punctuation(url) when is_binary(url) do + url + |> String.trim_trailing(")") + |> String.trim_trailing("]") + |> String.trim_trailing("}") + |> String.trim_trailing(".") + |> String.trim_trailing(",") + |> String.trim_trailing("!") + |> String.trim_trailing("?") + |> String.trim_trailing(";") + |> String.trim_trailing(":") + end + + defp valid_absolute_http_url?(url) when is_binary(url) do + case URI.parse(url) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + true + + _ -> + false + end + end + + defp valid_absolute_http_url?(_url) do + false + end + + defp extract_mention_candidates(text, occupied_ranges) do + Regex.scan(~r/@([A-Za-z0-9][A-Za-z0-9._-]{0,62})/u, text, return: :index, capture: :all) + |> Enum.reduce([], fn + [{byte_start, byte_len}, {handle_start, handle_len}], acc -> + byte_end = byte_start + byte_len + handle = binary_part(text, handle_start, handle_len) + + if mention_or_tag_boundary?(text, byte_start) and + not overlaps_any?({byte_start, byte_end}, occupied_ranges) do + [%{byte_start: byte_start, byte_end: byte_end, handle: handle} | acc] + else + acc + end + + _other, acc -> + acc + end) + |> Enum.reverse() + end + + defp extract_hashtag_candidates(text, occupied_ranges) do + Regex.scan(~r/#([[:alnum:]_]{1,64})/u, text, return: :index, capture: :all) + |> Enum.reduce([], fn + [{byte_start, byte_len}, {tag_start, tag_len}], acc -> + byte_end = byte_start + byte_len + tag = binary_part(text, tag_start, tag_len) + + if mention_or_tag_boundary?(text, byte_start) and + not overlaps_any?({byte_start, byte_end}, occupied_ranges) do + [%{byte_start: byte_start, byte_end: byte_end, tag: tag} | acc] + else + acc + end + + _other, acc -> + acc + end) + |> Enum.reverse() + end + + defp mention_or_tag_boundary?(text, byte_start) do + case preceding_character(text, byte_start) do + nil -> true + char -> not String.match?(char, ~r/[A-Za-z0-9_]/u) + end + end + + defp preceding_character(_text, byte_start) when byte_start <= 0 do + nil + end + + defp preceding_character(text, byte_start) when is_binary(text) and byte_start > 0 do + text |> binary_part(0, byte_start) |> String.last() + end + + defp overlaps_any?({start_a, end_a}, ranges) do + Enum.any?(ranges, fn {start_b, end_b} -> start_a < end_b and end_a > start_b end) + end + + defp link_facets(candidates) do + Enum.map(candidates, fn candidate -> + %{ + "index" => %{"byteStart" => candidate.byte_start, "byteEnd" => candidate.byte_end}, + "features" => [%{"$type" => @facet_link_type, "uri" => candidate.url}] + } + end) + end + + defp hashtag_facets(candidates) do + Enum.map(candidates, fn candidate -> + %{ + "index" => %{"byteStart" => candidate.byte_start, "byteEnd" => candidate.byte_end}, + "features" => [%{"$type" => @facet_tag_type, "tag" => candidate.tag}] + } + end) + end + + defp resolve_mention_did_cached(handle, session, cache) do + key = String.downcase(handle || "") + + case Map.fetch(cache, key) do + {:ok, did} -> + {did, cache} + + :error -> + did = resolve_mention_did(key, session) + {did, Map.put(cache, key, did)} + end + end + + defp resolve_mention_did(handle, _session) when not is_binary(handle) do + nil + end + + defp resolve_mention_did("", _session) do + nil + end + + defp resolve_mention_did(handle, session) do + local_user = Repo.get_by(User, handle: handle) || Repo.get_by(User, username: handle) + + cond do + match?(%User{bluesky_did: did} when is_binary(did), local_user) and + Elektrine.Strings.present?(local_user.bluesky_did) -> + local_user.bluesky_did + + match?(%User{bluesky_identifier: "did:" <> _}, local_user) -> + local_user.bluesky_identifier + + match?(%User{bluesky_identifier: identifier} when is_binary(identifier), local_user) -> + case resolve_handle_to_did(session.service_url, local_user.bluesky_identifier) do + {:ok, did} -> + persist_user_did(local_user.id, did) + did + + _ -> + nil + end + + String.contains?(handle, ".") -> + case resolve_handle_to_did(session.service_url, handle) do + {:ok, did} -> did + _ -> nil + end + + true -> + nil + end + end + + defp maybe_build_embed_payload(%Message{} = message, session) do + with {:ok, media_embed} <- maybe_build_media_embed(message, session), + {:ok, link_embed} <- maybe_build_link_embed(message), + {:ok, quote_embed} <- maybe_build_quote_embed(message) do + embed = + case {quote_embed, media_embed || link_embed} do + {nil, nil} -> nil + {nil, media_or_link} -> media_or_link + {quote, nil} -> quote + {quote, media_or_link} -> build_record_with_media_embed(quote, media_or_link) + end + + {:ok, embed} + end + end + + defp maybe_build_media_embed(%Message{media_urls: media_urls} = message, session) + when is_list(media_urls) and media_urls != [] do + state = + media_urls + |> Enum.with_index() + |> Enum.reduce(%{images: [], video: nil, external: nil}, fn {media_source, index}, acc -> + if media_kind(guess_media_type(media_source)) == :other do + maybe_add_external_from_source(message, media_source, index, acc) + else + case fetch_media_binary(media_source) do + {:ok, binary, content_type} -> + case media_kind(content_type) do + :image -> + maybe_add_image_blob( + message, + session, + media_source, + index, + binary, + content_type, + acc + ) + + :video -> + maybe_add_video_blob( + message, + session, + media_source, + index, + binary, + content_type, + acc + ) + + :other -> + maybe_add_external_from_source(message, media_source, index, acc) + end + + {:skip, reason} when reason in [:unsafe_media_url] -> + acc + + {:skip, {:unsafe_media_url, _reason}} -> + acc + + {:skip, _reason} -> + maybe_add_external_from_source(message, media_source, index, acc) + + {:error, reason} -> + Logger.warning( + "Bluesky media embed skipped for message #{message.id} at index #{index}: #{inspect(reason)}" + ) + + acc + end + end + end) + + embed = + cond do + state.images != [] -> + %{"$type" => @images_embed_type, "images" => Enum.reverse(state.images)} + + is_map(state.video) -> + state.video + + is_map(state.external) -> + state.external + + true -> + nil + end + + {:ok, embed} + end + + defp maybe_build_media_embed(_message, _session) do + {:ok, nil} + end + + defp maybe_add_image_blob( + %Message{} = message, + %{service_url: service_url, access_jwt: access_jwt}, + media_source, + index, + binary, + content_type, + %{images: images} = acc + ) do + if length(images) >= @max_embed_images do + acc + else + case upload_blob(service_url, access_jwt, binary, content_type) do + {:ok, blob} -> + image = %{"image" => blob, "alt" => media_alt_text(message, media_source, index)} + %{acc | images: [image | images]} + + {:error, reason} -> + Logger.warning( + "Bluesky image upload failed for message #{message.id} at index #{index}: #{inspect(reason)}" + ) + + acc + end + end + end + + defp maybe_add_video_blob( + %Message{} = message, + %{service_url: service_url, access_jwt: access_jwt}, + media_source, + index, + binary, + content_type, + %{video: video} = acc + ) do + if is_map(video) do + acc + else + case upload_blob(service_url, access_jwt, binary, content_type) do + {:ok, blob} -> + %{ + acc + | video: %{ + "$type" => @video_embed_type, + "video" => blob, + "alt" => media_alt_text(message, media_source, index) + } + } + + {:error, reason} -> + Logger.warning( + "Bluesky video upload failed for message #{message.id} at index #{index}: #{inspect(reason)}" + ) + + acc + end + end + end + + defp maybe_add_external_from_source( + %Message{} = message, + media_source, + index, + %{external: external} = acc + ) do + if is_map(external) do + acc + else + case external_uri_for_media_source(media_source) do + {:ok, uri} -> %{acc | external: build_external_embed(uri, message, media_source, index)} + _ -> acc + end + end + end + + defp build_record_with_media_embed(record_embed, media_embed) do + %{"$type" => @record_with_media_embed_type, "record" => record_embed, "media" => media_embed} + end + + defp maybe_build_link_embed(%Message{primary_url: primary_url} = message) + when is_binary(primary_url) do + primary_url + |> String.trim() + |> normalize_external_uri() + |> case do + {:ok, uri} -> {:ok, build_external_embed(uri, message, uri, 0)} + _ -> {:ok, nil} + end + end + + defp maybe_build_link_embed(_message) do + {:ok, nil} + end + + defp maybe_build_quote_embed(%Message{quoted_message_id: quoted_message_id}) + when is_integer(quoted_message_id) do + case Messaging.get_message(quoted_message_id) do + %Message{bluesky_uri: uri, bluesky_cid: cid} when is_binary(uri) and is_binary(cid) -> + if Elektrine.Strings.present?(uri) and Elektrine.Strings.present?(cid) do + {:ok, %{"$type" => @record_embed_type, "record" => %{"uri" => uri, "cid" => cid}}} + else + {:ok, nil} + end + + _ -> + {:ok, nil} + end + end + + defp maybe_build_quote_embed(_message) do + {:ok, nil} + end + + defp build_external_embed(uri, message, media_source, index) do + %{ + "$type" => @external_embed_type, + "external" => %{ + "uri" => uri, + "title" => external_title_for(message, media_source, index), + "description" => external_description_for(message) + } + } + end + + defp external_title_for(%Message{} = message, media_source, _index) do + title = + if Elektrine.Strings.present?(message.title) do + String.trim(message.title) + else + media_source |> to_string() |> source_display_name() + end + + title |> String.slice(0, @max_external_title_chars) |> blank_to_default("Shared link") + end + + defp external_description_for(%Message{} = message) do + decrypted = Message.decrypt_content(message) + + [message.content_warning, decrypted.content] + |> Enum.map(&Elektrine.Strings.present/1) + |> Enum.reject(&is_nil/1) + |> Enum.join(" ") + |> String.trim() + |> String.slice(0, @max_external_description_chars) + |> blank_to_default("Shared from Elektrine") + end + + defp blank_to_default(value, default) when is_binary(value) do + if Elektrine.Strings.present?(value) do + value + else + default + end + end + + defp source_display_name(source) when is_binary(source) do + with {:ok, normalized} <- normalize_external_uri(source), + %URI{} = parsed <- URI.parse(normalized), + path when is_binary(path) <- parsed.path do + path + |> Path.basename() + |> case do + "" -> normalized + basename -> basename + end + else + _ -> source |> String.split("/") |> List.last() |> to_string() + end + end + + defp source_display_name(source) do + to_string(source) + end + + defp external_uri_for_media_source(media_source) do + cond do + not is_binary(media_source) -> + {:skip, :invalid_media_source} + + String.starts_with?(media_source, ["http://", "https://"]) -> + normalize_external_uri(media_source) + + String.starts_with?(media_source, "/") -> + normalize_external_uri(ActivityPub.instance_url() <> media_source) + + true -> + media_source |> Uploads.attachment_url() |> normalize_external_uri() + end + rescue + _ -> {:skip, :external_uri_unavailable} + end + + defp normalize_external_uri(nil) do + {:skip, :missing_external_uri} + end + + defp normalize_external_uri("") do + {:skip, :missing_external_uri} + end + + defp normalize_external_uri(uri) when is_binary(uri) do + uri = String.trim(uri) + + cond do + not Elektrine.Strings.present?(uri) -> + {:skip, :missing_external_uri} + + String.starts_with?(uri, "/") -> + {:ok, ActivityPub.instance_url() <> uri} + + String.starts_with?(uri, ["http://", "https://"]) -> + if valid_absolute_http_url?(uri) do + {:ok, uri} + else + {:skip, :invalid_external_uri} + end + + true -> + candidate = "https://" <> uri + + if valid_absolute_http_url?(candidate) do + {:ok, candidate} + else + {:skip, :invalid_external_uri} + end + end + end + + defp normalize_external_uri(_) do + {:skip, :invalid_external_uri} + end + + defp fetch_media_binary(source) when not is_binary(source) do + {:skip, :invalid_media_source} + end + + defp fetch_media_binary("") do + {:skip, :empty_media_source} + end + + defp fetch_media_binary(source) do + source = String.trim(source) + + cond do + not Elektrine.Strings.present?(source) -> + {:skip, :empty_media_source} + + String.starts_with?(source, ["http://", "https://"]) -> + case URLValidator.validate(source) do + :ok -> fetch_media_binary_from_url(source, guess_media_type(source)) + {:error, reason} -> {:skip, {:unsafe_media_url, reason}} + end + + true -> + case local_media_path_for(source) do + {:ok, path, fallback_content_type} -> + read_media_from_file(path, fallback_content_type) + + :error -> + source + |> remote_media_url_for() + |> fetch_media_binary_from_url(guess_media_type(source)) + end + end + end + + defp remote_media_url_for(source) do + case Uploads.attachment_url(source) do + "/uploads/" <> _rest = local_source -> + case local_media_path_for(local_source) do + {:ok, path, fallback_content_type} -> {:file, path, fallback_content_type} + :error -> {:skip, :unsupported_media_source} + end + + url when is_binary(url) -> + cond do + not String.starts_with?(url, ["http://", "https://"]) -> + {:skip, :unsupported_media_source} + + URLValidator.validate(url) == :ok -> + {:ok, url} + + true -> + {:skip, :unsafe_media_url} + end + + _ -> + {:skip, :unsupported_media_source} + end + rescue + _ -> {:skip, :unsupported_media_source} + end + + defp fetch_media_binary_from_url({:file, path, fallback_content_type}, _fallback_guess) do + read_media_from_file(path, fallback_content_type) + end + + defp fetch_media_binary_from_url({:ok, url}, fallback_guess) do + fetch_media_binary_from_url(url, fallback_guess) + end + + defp fetch_media_binary_from_url({:skip, reason}, _fallback_guess) do + {:skip, reason} + end + + defp fetch_media_binary_from_url(url, fallback_guess) when is_binary(url) do + if Elektrine.Strings.present?(url) do + headers = [{"accept", "*/*"}] + + with {:ok, %Finch.Response{} = response} <- request_raw(:get, url, headers, ""), + :ok <- require_success_status(response.status, :media_fetch_failed) do + content_type = + response.headers + |> response_content_type() + |> maybe_fallback_content_type(fallback_guess) + + {:ok, response.body, content_type} + end + else + {:skip, :invalid_media_source} + end + end + + defp fetch_media_binary_from_url(_url, _fallback_guess), do: {:skip, :invalid_media_source} + + defp read_media_from_file(path, fallback_content_type) do + case File.read(path) do + {:ok, binary} -> {:ok, binary, fallback_content_type} + {:error, reason} -> {:error, {:media_read_failed, reason}} + end + end + + defp local_media_path_for("/uploads/" <> rest) do + safe_local_media_path(rest, guess_media_type(rest)) + end + + defp local_media_path_for(source) do + adapter = Application.get_env(:elektrine, :uploads, []) |> Keyword.get(:adapter, :local) + + if adapter == :local and local_media_key?(source) do + normalized = source |> String.trim_leading("/") |> String.trim_leading("uploads/") + safe_local_media_path(normalized, guess_media_type(source)) + else + :error + end + end + + defp local_media_key?(source) when is_binary(source) do + Enum.any?(@local_media_prefixes, &String.starts_with?(source, &1)) + end + + defp local_media_key?(_source) do + false + end + + defp safe_local_media_path(relative_path, fallback_content_type) do + uploads_dir = + Application.get_env(:elektrine, :uploads, []) + |> Keyword.get(:uploads_dir, "priv/static/uploads") + |> Path.expand() + + candidate_path = Path.expand(relative_path, uploads_dir) + + if safe_local_media_child_path?(relative_path, candidate_path, uploads_dir) do + {:ok, candidate_path, fallback_content_type} + else + :error + end + end + + defp safe_local_media_child_path?(relative_path, candidate_path, uploads_dir) do + is_binary(relative_path) and + relative_path != "" and + Path.type(relative_path) == :relative and + not String.contains?(relative_path, ["\0", "\\"]) and + String.starts_with?(candidate_path, uploads_dir <> "/") + end + + defp media_alt_text(%Message{media_metadata: media_metadata}, media_source, index) do + alt_texts = + case media_metadata do + %{"alt_texts" => map} when is_map(map) -> map + %{alt_texts: map} when is_map(map) -> map + _ -> %{} + end + + alt_value = + Map.get(alt_texts, to_string(index)) || Map.get(alt_texts, index) || + Map.get(alt_texts, media_source) + + normalize_alt_text(alt_value) + end + + defp normalize_alt_text(value) when is_binary(value) do + value |> String.trim() |> String.slice(0, @max_alt_text_chars) + end + + defp normalize_alt_text(_value) do + "" + end + + defp response_content_type(headers) when is_list(headers) do + headers + |> Enum.find_value(fn + {key, value} when is_binary(key) and is_binary(value) -> + if String.downcase(key) == "content-type" do + normalize_content_type(value) + else + nil + end + + _ -> + nil + end) + end + + defp response_content_type(_headers) do + nil + end + + defp maybe_fallback_content_type(nil, fallback) do + fallback || "application/octet-stream" + end + + defp maybe_fallback_content_type(content_type, _fallback) do + content_type + end + + defp normalize_content_type(value) when is_binary(value) do + value + |> String.split(";") + |> List.first() + |> to_string() + |> String.trim() + |> String.downcase() + end + + defp media_kind("image/" <> _rest) do + :image + end + + defp media_kind("video/" <> _rest) do + :video + end + + defp media_kind(_content_type) do + :other + end + + defp guess_media_type(value) when is_binary(value) do + normalized = value |> String.downcase() |> String.split("?") |> List.first() + + cond do + String.ends_with?(normalized, [".jpg", ".jpeg"]) -> "image/jpeg" + String.ends_with?(normalized, ".png") -> "image/png" + String.ends_with?(normalized, ".gif") -> "image/gif" + String.ends_with?(normalized, ".webp") -> "image/webp" + String.ends_with?(normalized, ".svg") -> "image/svg+xml" + String.ends_with?(normalized, ".bmp") -> "image/bmp" + String.ends_with?(normalized, ".ico") -> "image/x-icon" + String.ends_with?(normalized, ".mp4") -> "video/mp4" + String.ends_with?(normalized, ".webm") -> "video/webm" + String.ends_with?(normalized, ".ogv") -> "video/ogg" + String.ends_with?(normalized, ".mov") -> "video/quicktime" + String.ends_with?(normalized, ".mp3") -> "audio/mpeg" + String.ends_with?(normalized, ".ogg") -> "audio/ogg" + String.ends_with?(normalized, ".wav") -> "audio/wav" + String.ends_with?(normalized, ".m4a") -> "audio/mp4" + String.ends_with?(normalized, ".aac") -> "audio/aac" + String.ends_with?(normalized, ".flac") -> "audio/flac" + String.ends_with?(normalized, ".pdf") -> "application/pdf" + true -> "application/octet-stream" + end + end + + defp guess_media_type(_value) do + "application/octet-stream" + end + + defp maybe_build_reply_payload(nil) do + {:ok, nil} + end + + defp maybe_build_reply_payload(reply_to_id) do + case Messaging.get_message(reply_to_id) do + %Message{bluesky_uri: uri, bluesky_cid: cid} = parent + when is_binary(uri) and is_binary(cid) -> + if Elektrine.Strings.present?(uri) and Elektrine.Strings.present?(cid) do + parent_ref = %{"uri" => uri, "cid" => cid} + root_ref = find_root_reference(parent, parent_ref) + {:ok, %{"root" => root_ref, "parent" => parent_ref}} + else + {:skip, :reply_parent_not_mirrored} + end + + _ -> + {:skip, :reply_parent_not_mirrored} + end + end + + defp find_root_reference(_message, current_ref, depth) when depth >= @max_reply_depth do + current_ref + end + + defp find_root_reference(%Message{reply_to_id: nil}, current_ref, _depth) do + current_ref + end + + defp find_root_reference(%Message{reply_to_id: reply_to_id}, current_ref, depth) do + case Messaging.get_message(reply_to_id) do + %Message{bluesky_uri: uri, bluesky_cid: cid} = parent + when is_binary(uri) and is_binary(cid) -> + if Elektrine.Strings.present?(uri) and Elektrine.Strings.present?(cid) do + next_ref = %{"uri" => uri, "cid" => cid} + find_root_reference(parent, next_ref, depth + 1) + else + current_ref + end + + _ -> + current_ref + end + end + + defp find_root_reference(message, current_ref) do + find_root_reference(message, current_ref, 0) + end + + defp create_session(service_url, identifier, password) do + url = service_url <> "/xrpc/com.atproto.server.createSession" + payload = %{identifier: identifier, password: password} + + with {:ok, %Finch.Response{} = response} <- request_json(:post, url, payload), + :ok <- require_success_status(response.status, :create_session_failed), + {:ok, body} <- decode_json_body(response.body), + {:ok, access_jwt} <- map_fetch_string(body, "accessJwt", :missing_access_jwt), + {:ok, did} <- map_fetch_string(body, "did", :missing_did) do + {:ok, %{access_jwt: access_jwt, did: did}} + end + end + + defp create_record(service_url, access_jwt, did, record, collection \\ @post_collection) do + url = service_url <> "/xrpc/com.atproto.repo.createRecord" + payload = %{repo: did, collection: collection, record: record} + headers = [{"authorization", "Bearer " <> access_jwt}] + + with {:ok, %Finch.Response{} = response} <- request_json(:post, url, payload, headers), + :ok <- require_success_status(response.status, :create_record_failed), + {:ok, body} <- decode_json_body(response.body), + {:ok, uri} <- map_fetch_string(body, "uri", :missing_uri), + {:ok, cid} <- map_fetch_string(body, "cid", :missing_cid) do + {:ok, %{uri: uri, cid: cid}} + end + end + + defp upload_blob(service_url, access_jwt, binary, content_type) do + url = service_url <> "/xrpc/com.atproto.repo.uploadBlob" + + headers = [ + {"authorization", "Bearer " <> access_jwt}, + {"content-type", content_type || "application/octet-stream"}, + {"accept", "application/json"} + ] + + with {:ok, %Finch.Response{} = response} <- request_raw(:post, url, headers, binary), + :ok <- require_success_status(response.status, :upload_blob_failed), + {:ok, body} <- decode_json_body(response.body) do + map_fetch_map(body, "blob", :missing_blob) + end + end + + defp put_record(service_url, access_jwt, repo, collection, rkey, record) do + url = service_url <> "/xrpc/com.atproto.repo.putRecord" + payload = %{repo: repo, collection: collection, rkey: rkey, record: record} + headers = [{"authorization", "Bearer " <> access_jwt}] + + with {:ok, %Finch.Response{} = response} <- request_json(:post, url, payload, headers), + :ok <- require_success_status(response.status, :put_record_failed), + {:ok, body} <- decode_json_body(response.body), + {:ok, uri} <- map_fetch_string(body, "uri", :missing_uri), + {:ok, cid} <- map_fetch_string(body, "cid", :missing_cid) do + {:ok, %{uri: uri, cid: cid}} + end + end + + defp delete_record(service_url, access_jwt, repo, collection, rkey) do + url = service_url <> "/xrpc/com.atproto.repo.deleteRecord" + payload = %{repo: repo, collection: collection, rkey: rkey} + headers = [{"authorization", "Bearer " <> access_jwt}] + + with {:ok, %Finch.Response{} = response} <- request_json(:post, url, payload, headers) do + require_success_status(response.status, :delete_record_failed) + end + end + + defp list_records(service_url, access_jwt, repo, collection, cursor) do + params = + %{ + "repo" => repo, + "collection" => collection, + "limit" => Integer.to_string(@default_record_list_limit) + } + |> maybe_put_cursor_param(cursor) + + url = service_url <> "/xrpc/com.atproto.repo.listRecords?" <> URI.encode_query(params) + headers = [{"accept", "application/json"}, {"authorization", "Bearer " <> access_jwt}] + + with {:ok, %Finch.Response{} = response} <- request_raw(:get, url, headers, ""), + :ok <- require_success_status(response.status, :list_records_failed), + {:ok, body} <- decode_json_body(response.body), + {:ok, records} <- map_fetch_list(body, "records", :missing_records) do + {:ok, %{records: records, cursor: body["cursor"]}} + end + end + + defp maybe_put_cursor_param(params, cursor) do + if Elektrine.Strings.present?(cursor), do: Map.put(params, "cursor", cursor), else: params + end + + defp request_json(method, url, payload, extra_headers \\ []) do + headers = [ + {"content-type", "application/json"}, + {"accept", "application/json"} | extra_headers + ] + + body = Jason.encode!(payload) + request_raw(method, url, headers, body) + end + + defp request_raw(method, url, headers, body) do + timeout_ms = Keyword.get(bluesky_config(), :timeout_ms, @default_timeout_ms) + + case http_client().request(method, url, headers, body, receive_timeout: timeout_ms) do + {:ok, %Finch.Response{} = response} -> {:ok, response} + {:error, reason} -> {:error, {:http_error, reason}} + end + end + + defp require_success_status(status, _reason) when status in 200..299 do + :ok + end + + defp require_success_status(status, reason) do + {:error, {reason, status}} + end + + defp decode_json_body(body) when is_binary(body) do + case Jason.decode(body) do + {:ok, map} when is_map(map) -> {:ok, map} + _ -> {:error, :invalid_json} + end + end + + defp decode_json_body(_) do + {:error, :invalid_json} + end + + defp map_fetch_string(map, key, reason) when is_map(map) do + case Map.get(map, key) do + value when is_binary(value) -> + if Elektrine.Strings.present?(value), do: {:ok, value}, else: {:error, reason} + + _ -> + {:error, reason} + end + end + + defp map_fetch_map(map, key, reason) when is_map(map) do + case Map.get(map, key) do + value when is_map(value) -> {:ok, value} + _ -> {:error, reason} + end + end + + defp map_fetch_list(map, key, reason) when is_map(map) do + case Map.get(map, key) do + value when is_list(value) -> {:ok, value} + _ -> {:error, reason} + end + end + + defp fetch_mirrored_subject_message(message_id) when is_integer(message_id) do + case Repo.get(Message, message_id) do + %Message{bluesky_uri: uri, bluesky_cid: cid} = message + when is_binary(uri) and is_binary(cid) -> + if Elektrine.Strings.present?(uri) and Elektrine.Strings.present?(cid) do + {:ok, message} + else + {:skip, :message_not_mirrored} + end + + %Message{} -> + {:skip, :message_not_mirrored} + + nil -> + {:skip, :message_not_found} + end + end + + defp parse_at_uri("at://" <> rest) do + case String.split(rest, "/", parts: 3) do + [repo, collection, rkey] -> + if Elektrine.Strings.present?(repo) and Elektrine.Strings.present?(collection) and + Elektrine.Strings.present?(rkey) do + {:ok, %{repo: repo, collection: collection, rkey: rkey, uri: "at://" <> rest}} + else + {:error, :invalid_at_uri} + end + + _ -> + {:error, :invalid_at_uri} + end + end + + defp parse_at_uri(_value) do + {:error, :invalid_at_uri} + end + + defp ensure_collection(collection, expected) when collection == expected do + :ok + end + + defp ensure_collection(_collection, _expected) do + {:error, :unexpected_collection} + end + + defp maybe_create_subject_record(session, collection, existing_uri, record) do + if Elektrine.Strings.present?(existing_uri) do + {:ok, %{uri: existing_uri, cid: nil}} + else + create_record(session.service_url, session.access_jwt, session.did, record, collection) + end + end + + defp find_subject_record_uri(session, collection, subject_uri) do + find_record_uri(session, collection, fn record -> + get_in(record, ["value", "subject", "uri"]) == subject_uri + end) + end + + defp find_follow_record_uri(session, collection, target_did) do + find_record_uri(session, collection, fn record -> + get_in(record, ["value", "subject"]) == target_did + end) + end + + defp find_record_uri(session, collection, matcher, cursor \\ nil, seen_cursors \\ MapSet.new()) + + defp find_record_uri(session, collection, matcher, cursor, seen_cursors) do + if Elektrine.Strings.present?(cursor) and MapSet.member?(seen_cursors, cursor) do + {:error, :record_cursor_loop} + else + next_seen_cursors = + if Elektrine.Strings.present?(cursor) do + MapSet.put(seen_cursors, cursor) + else + seen_cursors + end + + with {:ok, response} <- + list_records( + session.service_url, + session.access_jwt, + session.did, + collection, + cursor + ) do + case Enum.find(response.records, matcher) do + %{"uri" => uri} when is_binary(uri) -> + if Elektrine.Strings.present?(uri), do: {:ok, uri}, else: {:ok, nil} + + _ -> + case response.cursor do + next_cursor when is_binary(next_cursor) -> + if Elektrine.Strings.present?(next_cursor) do + find_record_uri(session, collection, matcher, next_cursor, next_seen_cursors) + else + {:ok, nil} + end + + _other -> + {:ok, nil} + end + end + end + end + end + + defp maybe_delete_record_by_uri(_session, _collection, nil) do + :ok + end + + defp maybe_delete_record_by_uri(session, collection, uri) when is_binary(uri) do + with {:ok, parsed} <- parse_at_uri(uri), + :ok <- ensure_collection(parsed.collection, collection) do + delete_record( + session.service_url, + session.access_jwt, + parsed.repo, + parsed.collection, + parsed.rkey + ) + end + end + + defp maybe_delete_record_by_uri(_session, _collection, _uri) do + {:error, :invalid_record_uri} + end + + defp resolve_follow_target_did(%User{} = followed, session) do + cond do + Elektrine.Strings.present?(followed.bluesky_did) -> + {:ok, followed.bluesky_did} + + is_binary(followed.bluesky_identifier) and + String.starts_with?(followed.bluesky_identifier, "did:") -> + {:ok, followed.bluesky_identifier} + + Elektrine.Strings.present?(followed.bluesky_identifier) -> + case resolve_handle_to_did(session.service_url, followed.bluesky_identifier) do + {:ok, did} -> + persist_user_did(followed.id, did) + {:ok, did} + + {:error, _reason} -> + {:skip, :target_did_unresolvable} + end + + true -> + {:skip, :target_missing_bluesky_identity} + end + end + + defp resolve_handle_to_did(service_url, handle) do + normalized = String.trim(handle || "") + + cond do + not Elektrine.Strings.present?(normalized) -> + {:error, :missing_handle} + + String.starts_with?(normalized, "did:") -> + {:ok, normalized} + + true -> + url = + service_url <> + "/xrpc/com.atproto.identity.resolveHandle?handle=" <> URI.encode_www_form(normalized) + + headers = [{"accept", "application/json"}] + + with {:ok, %Finch.Response{} = response} <- request_raw(:get, url, headers, ""), + :ok <- require_success_status(response.status, :resolve_handle_failed), + {:ok, body} <- decode_json_body(response.body) do + map_fetch_string(body, "did", :missing_did) + end + end + end + + defp persist_message_mapping(message_id, %{uri: uri, cid: cid} = published, session) do + case from(m in Message, + where: m.id == ^message_id and is_nil(m.bluesky_uri) and is_nil(m.deleted_at) + ) + |> Repo.update_all(set: [bluesky_uri: uri, bluesky_cid: cid]) do + {1, _} -> + :ok + + {0, _} -> + handle_unclaimed_message_mapping(message_id, published, session) + end + end + + defp handle_unclaimed_message_mapping(message_id, published, session) do + with :ok <- cleanup_orphaned_published_post(session, published) do + case Repo.get(Message, message_id) do + nil -> + {:skip, :message_not_found} + + %Message{deleted_at: deleted_at} when not is_nil(deleted_at) -> + {:skip, :already_deleted} + + %Message{bluesky_uri: uri} when is_binary(uri) -> + if Elektrine.Strings.present?(uri), + do: {:skip, :already_mirrored}, + else: {:error, :message_mapping_not_persisted} + + _ -> + {:error, :message_mapping_not_persisted} + end + end + end + + defp cleanup_orphaned_published_post(session, %{uri: uri}) when is_binary(uri) do + if Elektrine.Strings.present?(uri) do + case maybe_delete_record_by_uri(session, @post_collection, uri) do + :ok -> + :ok + + {:error, {:delete_record_failed, 404}} -> + :ok + + {:error, reason} -> + {:error, {:cleanup_failed, reason}} + end + else + :ok + end + end + + defp persist_message_mapping_update(message_id, %{uri: uri, cid: cid}) do + from(m in Message, where: m.id == ^message_id) + |> Repo.update_all(set: [bluesky_uri: uri, bluesky_cid: cid]) + + :ok + end + + defp persist_user_did(user_id, did) when is_integer(user_id) and is_binary(did) do + if Elektrine.Strings.present?(did) do + from(u in User, where: u.id == ^user_id) |> Repo.update_all(set: [bluesky_did: did]) + end + + :ok + end + + defp persist_user_did(_user_id, _did), do: :ok + + defp format_created_at(nil) do + DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + end + + defp format_created_at(%DateTime{} = datetime) do + datetime |> DateTime.truncate(:second) |> DateTime.to_iso8601() + end + + defp format_created_at(datetime) do + datetime + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.truncate(:second) + |> DateTime.to_iso8601() + end + + defp bluesky_config do + Application.get_env(:elektrine, :bluesky, []) + end + + defp http_client do + Keyword.get(bluesky_config(), :http_client, Elektrine.Bluesky.FinchClient) + end +end diff --git a/apps/elektrine/lib/elektrine/bluesky/finch_client.ex b/apps/elektrine/lib/elektrine/bluesky/finch_client.ex new file mode 100644 index 0000000..d900fad --- /dev/null +++ b/apps/elektrine/lib/elektrine/bluesky/finch_client.ex @@ -0,0 +1,11 @@ +defmodule Elektrine.Bluesky.FinchClient do + @moduledoc false + + alias Elektrine.HTTP.SafeFetch + + def request(method, url, headers, body, opts) do + request = Finch.build(method, url, headers, body) + + SafeFetch.request(request, Elektrine.Finch, opts) + end +end diff --git a/apps/elektrine/lib/elektrine/bluesky/inbound.ex b/apps/elektrine/lib/elektrine/bluesky/inbound.ex new file mode 100644 index 0000000..2a81eaa --- /dev/null +++ b/apps/elektrine/lib/elektrine/bluesky/inbound.ex @@ -0,0 +1,484 @@ +defmodule Elektrine.Bluesky.Inbound do + @moduledoc "Polls Bluesky notifications and mirrors inbound interactions back into Elektrine notifications.\n\nCurrent behavior:\n- Tracks reply/mention/quote/like/repost events targeting mirrored posts.\n- Deduplicates events with `bluesky_inbound_events`.\n- Creates local notifications for the owner of the related local post.\n" + import Ecto.Query, warn: false + require Logger + alias Elektrine.Accounts.User + alias Elektrine.Bluesky + alias Elektrine.Bluesky.InboundEvent + alias Elektrine.Notifications + alias Elektrine.Repo + alias Elektrine.Social.Message + @default_limit 50 + @default_feed_limit 50 + @default_timeout_ms 12_000 + @supported_reasons ~w(reply mention quote like repost) + @doc "Sync inbound Bluesky notifications for all users with Bluesky enabled.\n" + def sync_enabled_users do + if inbound_enabled?() do + users = inbound_enabled_users() + + summary = + Enum.reduce( + users, + %{users: 0, processed_events: 0, created_notifications: 0, synced_feed_posts: 0}, + fn user, acc -> + case sync_user(user) do + {:ok, result} -> + %{ + users: acc.users + 1, + processed_events: acc.processed_events + result.processed_events, + created_notifications: acc.created_notifications + result.created_notifications, + synced_feed_posts: acc.synced_feed_posts + result.synced_feed_posts + } + + {:skipped, _reason} -> + %{acc | users: acc.users + 1} + + {:error, reason} -> + Logger.warning( + "Bluesky inbound sync failed for user #{user.id}/#{user.username}: #{inspect(reason)}" + ) + + %{acc | users: acc.users + 1} + end + end + ) + + {:ok, summary} + else + {:skipped, :inbound_disabled} + end + end + + @doc "Sync inbound Bluesky notifications for a single user.\n" + def sync_user(%User{} = user) do + if inbound_enabled?() do + with {:ok, session} <- Bluesky.session_for_user(user), + {:ok, payload} <- list_notifications(session, user.bluesky_inbound_cursor), + {:ok, notification_result} <- + process_notifications(user, payload["notifications"] || []) do + update_poll_tracking(user.id, payload["cursor"]) + + feed_result = + case sync_feed_posts(user, session) do + {:ok, result} -> + result + + {:error, reason} -> + Logger.warning( + "Bluesky inbound feed sync failed for user #{user.id}/#{user.username}: #{inspect(reason)}" + ) + + %{synced_feed_posts: 0} + end + + {:ok, + %{ + processed_events: notification_result.processed_events, + created_notifications: notification_result.created_notifications, + synced_feed_posts: feed_result.synced_feed_posts + }} + end + else + {:skipped, :inbound_disabled} + end + end + + defp inbound_enabled_users do + from(u in User, + where: + u.bluesky_enabled == true and not is_nil(u.bluesky_identifier) and + not is_nil(u.bluesky_app_password) + ) + |> Repo.all() + end + + defp list_notifications(session, cursor) do + params = %{"limit" => Integer.to_string(inbound_limit())} |> maybe_put_cursor(cursor) + + url = + session.service_url <> + "/xrpc/app.bsky.notification.listNotifications?" <> URI.encode_query(params) + + headers = [{"accept", "application/json"}, {"authorization", "Bearer " <> session.access_jwt}] + timeout_ms = Keyword.get(bluesky_config(), :timeout_ms, @default_timeout_ms) + + case http_client().request(:get, url, headers, "", receive_timeout: timeout_ms) do + {:ok, %Finch.Response{status: status, body: body}} when status in 200..299 -> + case Jason.decode(body) do + {:ok, payload} when is_map(payload) -> {:ok, payload} + _ -> {:error, :invalid_notifications_payload} + end + + {:ok, %Finch.Response{status: status}} -> + {:error, {:notifications_request_failed, status}} + + {:error, reason} -> + {:error, {:notifications_http_error, reason}} + end + end + + defp maybe_put_cursor(params, cursor) when is_binary(cursor) and cursor != "" do + Map.put(params, "cursor", cursor) + end + + defp maybe_put_cursor(params, _cursor) do + params + end + + defp sync_feed_posts(user, session) do + if feed_sync_enabled?() do + with {:ok, payload} <- list_timeline(session) do + process_feed_posts(user, payload["feed"] || []) + end + else + {:ok, %{synced_feed_posts: 0}} + end + end + + defp list_timeline(session) do + params = %{"limit" => Integer.to_string(feed_limit())} + url = session.service_url <> "/xrpc/app.bsky.feed.getTimeline?" <> URI.encode_query(params) + headers = [{"accept", "application/json"}, {"authorization", "Bearer " <> session.access_jwt}] + timeout_ms = Keyword.get(bluesky_config(), :timeout_ms, @default_timeout_ms) + + case http_client().request(:get, url, headers, "", receive_timeout: timeout_ms) do + {:ok, %Finch.Response{status: status, body: body}} when status in 200..299 -> + case Jason.decode(body) do + {:ok, payload} when is_map(payload) -> {:ok, payload} + _ -> {:error, :invalid_timeline_payload} + end + + {:ok, %Finch.Response{status: status}} -> + {:error, {:timeline_request_failed, status}} + + {:error, reason} -> + {:error, {:timeline_http_error, reason}} + end + end + + defp process_feed_posts(user, feed_items) when is_list(feed_items) do + feed_items + |> Enum.reduce({:ok, %{synced_feed_posts: 0}}, fn feed_item, {:ok, acc} -> + case process_feed_post(user, feed_item) do + :ok -> + {:ok, %{synced_feed_posts: acc.synced_feed_posts + 1}} + + {:skip, _reason} -> + {:ok, acc} + + {:error, reason} -> + Logger.warning( + "Bluesky inbound feed processing failed for user #{user.id}: #{inspect(reason)}" + ) + + {:ok, acc} + end + end) + end + + defp process_feed_posts(_user, _feed_items) do + {:ok, %{synced_feed_posts: 0}} + end + + defp process_feed_post(%User{} = user, feed_item) when is_map(feed_item) do + with {:ok, post_uri} <- feed_post_uri(feed_item), + {:ok, event_id} <- feed_event_id(post_uri), + {:ok, raw_feed_event} <- build_feed_event_payload(feed_item, post_uri) do + track_event(user.id, event_id, "feed_post", post_uri, raw_feed_event) + else + {:skip, reason} -> {:skip, reason} + end + end + + defp process_feed_post(_user, _feed_item) do + {:skip, :invalid_feed_item} + end + + defp feed_post_uri(feed_item) do + case get_in(feed_item, ["post", "uri"]) do + uri when is_binary(uri) and uri != "" -> {:ok, uri} + _ -> {:skip, :missing_feed_uri} + end + end + + defp feed_event_id(post_uri) do + {:ok, "feed:" <> post_uri} + end + + defp build_feed_event_payload(feed_item, post_uri) do + post = feed_item["post"] || %{} + + {:ok, + %{ + "uri" => post_uri, + "cid" => post["cid"], + "indexedAt" => post["indexedAt"] || feed_item["indexedAt"], + "author" => post["author"], + "record" => post["record"], + "embed" => post["embed"], + "replyCount" => post["replyCount"], + "repostCount" => post["repostCount"], + "likeCount" => post["likeCount"], + "quoteCount" => post["quoteCount"] + }} + end + + defp process_notifications(user, notifications) when is_list(notifications) do + notifications + |> Enum.reverse() + |> Enum.reduce({:ok, %{processed_events: 0, created_notifications: 0}}, fn notification, + {:ok, acc} -> + case process_notification(user, notification) do + {:ok, true} -> + {:ok, + %{ + processed_events: acc.processed_events + 1, + created_notifications: acc.created_notifications + 1 + }} + + {:skip, _reason} -> + {:ok, acc} + + {:error, reason} -> + Logger.warning( + "Bluesky inbound notification processing failed for user #{user.id}: #{inspect(reason)}" + ) + + {:ok, acc} + end + end) + end + + defp process_notifications(_user, _notifications) do + {:ok, %{processed_events: 0, created_notifications: 0}} + end + + defp process_notification(%User{} = user, notification) when is_map(notification) do + with {:ok, reason} <- extract_reason(notification), + {:ok, event_id} <- extract_event_id(notification), + {:ok, subject_uri} <- extract_subject_uri(notification), + {:ok, local_message} <- find_local_message(subject_uri), + :ok <- ensure_message_owner(local_message, user.id), + :ok <- track_event(user.id, event_id, reason, subject_uri, notification), + :ok <- create_local_notification(user, local_message, reason, notification, subject_uri) do + {:ok, true} + else + {:skip, reason} -> {:skip, reason} + {:error, reason} -> {:error, reason} + end + end + + defp process_notification(_user, _notification) do + {:skip, :invalid_notification} + end + + defp extract_reason(notification) do + reason = notification["reason"] + + cond do + not is_binary(reason) -> {:skip, :missing_reason} + reason not in @supported_reasons -> {:skip, :unsupported_reason} + true -> {:ok, reason} + end + end + + defp extract_event_id(notification) do + case notification["uri"] || notification["cid"] do + value when is_binary(value) and value != "" -> + {:ok, value} + + _ -> + author_did = get_in(notification, ["author", "did"]) || "unknown" + reason = notification["reason"] || "unknown" + indexed_at = notification["indexedAt"] || "unknown" + subject = notification["reasonSubject"] || "unknown" + {:ok, "#{author_did}|#{reason}|#{indexed_at}|#{subject}"} + end + end + + defp extract_subject_uri(notification) do + case notification["reasonSubject"] do + uri when is_binary(uri) and uri != "" -> {:ok, uri} + _ -> {:skip, :missing_reason_subject} + end + end + + defp find_local_message(subject_uri) do + case Repo.get_by(Message, bluesky_uri: subject_uri) do + %Message{} = message -> {:ok, message} + nil -> {:skip, :subject_not_local} + end + end + + defp ensure_message_owner(%Message{sender_id: sender_id}, user_id) + when is_integer(sender_id) and sender_id == user_id do + :ok + end + + defp ensure_message_owner(_message, _user_id) do + {:skip, :subject_owned_by_another_user} + end + + defp track_event(user_id, event_id, reason, subject_uri, raw_notification) do + metadata = + %{ + "author" => raw_notification["author"], + "uri" => raw_notification["uri"], + "cid" => raw_notification["cid"], + "indexedAt" => raw_notification["indexedAt"] + } + |> Map.merge(%{"payload" => raw_notification}) + + attrs = %{ + user_id: user_id, + event_id: event_id, + reason: reason, + related_post_uri: subject_uri, + processed_at: DateTime.utc_now() |> DateTime.truncate(:second), + metadata: metadata + } + + case %InboundEvent{} + |> InboundEvent.changeset(attrs) + |> Repo.insert( + on_conflict: :nothing, + conflict_target: [:user_id, :event_id], + returning: false + ) do + {:ok, %InboundEvent{id: nil}} -> + {:skip, :duplicate_event} + + {:ok, _event} -> + :ok + + {:error, %Ecto.Changeset{} = changeset} -> + {:error, {:event_tracking_failed, changeset.errors}} + end + end + + defp create_local_notification(user, local_message, reason, raw_notification, subject_uri) do + author = raw_notification["author"] || %{} + author_handle = author["handle"] || author["did"] || "unknown" + + {type, title, body, priority} = + notification_content(reason, author_handle, raw_notification["record"] || %{}) + + attrs = %{ + type: type, + title: title, + body: body, + user_id: user.id, + source_type: "bluesky", + source_id: local_message.id, + url: bluesky_post_url(raw_notification["uri"]) || bluesky_post_url(subject_uri), + icon: "hero-globe-alt", + priority: priority, + metadata: %{ + "bluesky_reason" => reason, + "bluesky_author" => author, + "bluesky_uri" => raw_notification["uri"], + "bluesky_reason_subject" => subject_uri + } + } + + case Notifications.create_notification(attrs) do + {:ok, _notification} -> :ok + {:error, changeset} -> {:error, {:create_notification_failed, changeset.errors}} + end + end + + defp notification_content("reply", author_handle, record) do + body = text_or_default(record["text"], "Someone replied on Bluesky.") + {"reply", "New Bluesky reply from @#{author_handle}", body, "normal"} + end + + defp notification_content("mention", author_handle, record) do + body = text_or_default(record["text"], "Someone mentioned your post on Bluesky.") + {"mention", "New Bluesky mention from @#{author_handle}", body, "normal"} + end + + defp notification_content("quote", author_handle, record) do + body = text_or_default(record["text"], "Someone quoted your mirrored post on Bluesky.") + {"comment", "Your post was quoted by @#{author_handle}", body, "normal"} + end + + defp notification_content("like", author_handle, _record) do + {"like", "New Bluesky like from @#{author_handle}", "Your mirrored post received a like.", + "low"} + end + + defp notification_content("repost", author_handle, _record) do + {"system", "New Bluesky repost from @#{author_handle}", "Your mirrored post was reposted.", + "low"} + end + + defp text_or_default(value, default) when is_binary(value) do + value + |> String.trim() + |> case do + "" -> default + text -> String.slice(text, 0, 300) + end + end + + defp text_or_default(_value, default) do + default + end + + defp bluesky_post_url("at://" <> rest) do + case String.split(rest, "/") do + [repo, "app.bsky.feed.post", rkey | _] -> "https://bsky.app/profile/#{repo}/post/#{rkey}" + _ -> nil + end + end + + defp bluesky_post_url(uri) when is_binary(uri) and uri != "" do + uri + end + + defp bluesky_post_url(_uri) do + nil + end + + defp update_poll_tracking(user_id, cursor) do + updates = + [bluesky_inbound_last_polled_at: DateTime.utc_now() |> DateTime.truncate(:second)] + |> maybe_add_cursor_update(cursor) + + from(u in User, where: u.id == ^user_id) |> Repo.update_all(set: updates) + :ok + end + + defp maybe_add_cursor_update(updates, cursor) when is_binary(cursor) and cursor != "" do + Keyword.put(updates, :bluesky_inbound_cursor, cursor) + end + + defp maybe_add_cursor_update(updates, _cursor) do + updates + end + + defp inbound_enabled? do + Keyword.get(bluesky_config(), :inbound_enabled, false) + end + + defp feed_sync_enabled? do + Keyword.get(bluesky_config(), :inbound_feed_enabled, true) + end + + defp inbound_limit do + max(1, Keyword.get(bluesky_config(), :inbound_limit, @default_limit)) + end + + defp feed_limit do + max(1, Keyword.get(bluesky_config(), :inbound_feed_limit, @default_feed_limit)) + end + + defp bluesky_config do + Application.get_env(:elektrine, :bluesky, []) + end + + defp http_client do + Keyword.get(bluesky_config(), :http_client, Elektrine.Bluesky.FinchClient) + end +end diff --git a/apps/elektrine/lib/elektrine/bluesky/inbound_event.ex b/apps/elektrine/lib/elektrine/bluesky/inbound_event.ex new file mode 100644 index 0000000..c2f038a --- /dev/null +++ b/apps/elektrine/lib/elektrine/bluesky/inbound_event.ex @@ -0,0 +1,26 @@ +defmodule Elektrine.Bluesky.InboundEvent do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + schema "bluesky_inbound_events" do + belongs_to :user, Elektrine.Accounts.User + + field :event_id, :string + field :reason, :string + field :related_post_uri, :string + field :processed_at, :utc_datetime + field :metadata, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + def changeset(event, attrs) do + event + |> cast(attrs, [:user_id, :event_id, :reason, :related_post_uri, :processed_at, :metadata]) + |> validate_required([:user_id, :event_id, :processed_at]) + |> validate_length(:event_id, max: 255) + |> unique_constraint([:user_id, :event_id]) + end +end diff --git a/apps/elektrine/lib/elektrine/bluesky/inbound_poll_worker.ex b/apps/elektrine/lib/elektrine/bluesky/inbound_poll_worker.ex new file mode 100644 index 0000000..8f0760b --- /dev/null +++ b/apps/elektrine/lib/elektrine/bluesky/inbound_poll_worker.ex @@ -0,0 +1,20 @@ +defmodule Elektrine.Bluesky.InboundPollWorker do + @moduledoc """ + Periodic worker that syncs inbound Bluesky interactions for enabled users. + """ + + use Oban.Worker, + queue: :federation, + max_attempts: 1, + unique: [period: 90, states: [:available, :scheduled, :executing]] + + alias Elektrine.Bluesky.Inbound + + @impl Oban.Worker + def perform(%Oban.Job{}) do + case Inbound.sync_enabled_users() do + {:ok, _summary} -> :ok + {:skipped, _reason} -> :ok + end + end +end diff --git a/apps/elektrine/lib/elektrine/bluesky/managed.ex b/apps/elektrine/lib/elektrine/bluesky/managed.ex new file mode 100644 index 0000000..4f673bd --- /dev/null +++ b/apps/elektrine/lib/elektrine/bluesky/managed.ex @@ -0,0 +1,519 @@ +defmodule Elektrine.Bluesky.Managed do + @moduledoc "Managed PDS account provisioning for Bluesky.\n\nThis path allows the instance to:\n- Create invite codes with admin credentials\n- Create a per-user PDS account\n- Create an app password for bridge usage\n- Persist Bluesky linkage fields to the local user account\n" + require Logger + import Ecto.Query, warn: false + alias Elektrine.Accounts + alias Elektrine.Accounts.Authentication + alias Elektrine.Accounts.User + alias Elektrine.Profiles + alias Elektrine.Repo + @default_timeout_ms 12_000 + @domain_regex ~r/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/ + @handle_regex ~r/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/ + @doc "Provisions Bluesky for a local user on a managed PDS.\n" + def enable_for_user(%User{} = user, current_password) when is_binary(current_password) do + with :ok <- ensure_managed_enabled(), + {:ok, _verified_user} <- Authentication.verify_user_password(user, current_password), + :ok <- ensure_not_already_enabled(user), + {:ok, service_url} <- managed_service_url(), + {:ok, handle_domain} <- managed_domain(), + {:ok, admin_password} <- managed_admin_password(), + {:ok, invite_code} <- create_invite_code(service_url, admin_password), + {:ok, account} <- + create_account(service_url, invite_code, user, current_password, handle_domain), + {:ok, session} <- create_session(service_url, account["did"], current_password), + {:ok, app_password} <- + create_app_password(service_url, session.access_jwt, user.username), + {:ok, updated_user} <- persist_user_link(user, account, app_password, service_url) do + {:ok, %{user: updated_user, did: account["did"], handle: account["handle"]}} + else + {:error, reason} = error -> + Logger.warning("Managed Bluesky enable failed for user #{user.id}: #{inspect(reason)}") + error + end + end + + def enable_for_user(%User{}, _current_password) do + {:error, :current_password_required} + end + + @doc "Reconnects (or refreshes) managed Bluesky credentials for a local user.\n" + def reconnect_for_user(%User{} = user, current_password) when is_binary(current_password) do + with :ok <- ensure_managed_enabled(), + {:ok, _verified_user} <- Authentication.verify_user_password(user, current_password), + {:ok, service_url} <- managed_service_url(), + {:ok, handle_domain} <- managed_domain(), + {:ok, identifier} <- reconnect_identifier(user, handle_domain), + {:ok, session} <- + create_reconnect_session(service_url, identifier, user, current_password), + {:ok, app_password} <- + create_app_password(service_url, session.access_jwt, user.username), + {:ok, updated_user} <- + persist_reconnect_link(user, session, identifier, app_password, service_url) do + {:ok, + %{ + user: updated_user, + did: session.did, + handle: session.handle || fallback_handle(identifier, user.username, handle_domain) + }} + else + {:error, reason} = error -> + Logger.warning("Managed Bluesky reconnect failed for user #{user.id}: #{inspect(reason)}") + error + end + end + + def reconnect_for_user(%User{}, _current_password) do + {:error, :current_password_required} + end + + @doc "Disconnects managed Bluesky linkage for a local user.\n" + def disconnect_for_user(%User{} = user, current_password) when is_binary(current_password) do + with {:ok, _verified_user} <- Authentication.verify_user_password(user, current_password), + {1, _} <- + from(u in User, where: u.id == ^user.id) + |> Repo.update_all( + set: [ + bluesky_enabled: false, + bluesky_identifier: nil, + bluesky_app_password: nil, + bluesky_did: nil, + bluesky_pds_url: nil, + bluesky_inbound_cursor: nil, + bluesky_inbound_last_polled_at: nil + ] + ) do + {:ok, Repo.get!(User, user.id)} + else + {0, _} -> + {:error, :user_not_found} + + {:error, reason} = error -> + Logger.warning( + "Managed Bluesky disconnect failed for user #{user.id}: #{inspect(reason)}" + ) + + error + end + end + + def disconnect_for_user(%User{}, _current_password) do + {:error, :current_password_required} + end + + defp ensure_managed_enabled do + if Keyword.get(bluesky_config(), :managed_enabled, false) do + :ok + else + {:error, :managed_pds_disabled} + end + end + + defp ensure_not_already_enabled(%User{bluesky_enabled: true, bluesky_app_password: password}) + when is_binary(password) and password != "" do + {:error, :already_enabled} + end + + defp ensure_not_already_enabled(_user) do + :ok + end + + defp managed_service_url do + configured = + Keyword.get(bluesky_config(), :managed_service_url) || + Keyword.get(bluesky_config(), :service_url, "https://bsky.social") + + normalize_service_url(configured) + end + + defp managed_domain do + case Keyword.get(bluesky_config(), :managed_domain) do + value when is_binary(value) -> + domain = value |> String.trim() |> String.downcase() + + cond do + domain == "" -> + {:error, :missing_managed_domain} + + domain =~ @domain_regex -> + {:ok, domain} + + true -> + {:error, :invalid_managed_domain} + end + + _ -> + {:error, :missing_managed_domain} + end + end + + defp managed_admin_password do + case Keyword.get(bluesky_config(), :managed_admin_password) do + value when is_binary(value) -> + password = String.trim(value) + + if password == "" do + {:error, :missing_managed_admin_password} + else + {:ok, password} + end + + _ -> + {:error, :missing_managed_admin_password} + end + end + + defp normalize_service_url(url) when is_binary(url) do + normalized = url |> String.trim() |> maybe_add_scheme() |> String.trim_trailing("/") + + case URI.parse(normalized) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + {:ok, normalized} + + _ -> + {:error, :invalid_managed_service_url} + end + end + + defp normalize_service_url(_) do + {:error, :invalid_managed_service_url} + end + + defp maybe_add_scheme(url) do + if String.starts_with?(url, ["http://", "https://"]) do + url + else + "https://" <> url + end + end + + defp create_invite_code(service_url, admin_password) do + url = service_url <> "/xrpc/com.atproto.server.createInviteCode" + headers = managed_admin_headers(admin_password) + payload = %{"useCount" => 1} + + with {:ok, response} <- request_json(:post, url, payload, headers), + :ok <- require_success_status(response.status, :create_invite_code_failed), + {:ok, body} <- decode_json_body(response.body) do + map_fetch_string(body, "code", :missing_invite_code) + end + end + + defp create_account(service_url, invite_code, user, password, handle_domain) do + url = service_url <> "/xrpc/com.atproto.server.createAccount" + email = user.recovery_email || default_pds_email(user) + + with {:ok, handle} <- preferred_managed_handle(user, handle_domain), + {:ok, response} <- + request_json( + :post, + url, + %{ + "email" => email, + "handle" => handle, + "password" => password, + "inviteCode" => invite_code + }, + [] + ), + :ok <- require_success_status(response.status, :create_account_failed), + {:ok, body} <- decode_json_body(response.body), + {:ok, _did} <- map_fetch_string(body, "did", :missing_did), + {:ok, returned_handle} <- map_fetch_string(body, "handle", :missing_handle), + {:ok, _normalized_handle} <- validate_managed_handle(returned_handle) do + {:ok, body} + end + end + + defp create_session(service_url, identifier, password) do + url = service_url <> "/xrpc/com.atproto.server.createSession" + payload = %{"identifier" => identifier, "password" => password} + + with {:ok, response} <- request_json(:post, url, payload, []), + :ok <- require_success_status(response.status, :create_session_failed), + {:ok, body} <- decode_json_body(response.body), + {:ok, access_jwt} <- map_fetch_string(body, "accessJwt", :missing_access_jwt), + {:ok, did} <- map_fetch_string(body, "did", :missing_did) do + {:ok, %{access_jwt: access_jwt, did: did, handle: body["handle"]}} + end + end + + defp create_app_password(service_url, access_jwt, username) do + url = service_url <> "/xrpc/com.atproto.server.createAppPassword" + headers = [{"authorization", "Bearer " <> access_jwt}] + create_app_password_with_retry(url, headers, username, 1) + end + + defp extract_app_password(body) when is_map(body) do + case body["password"] || body["appPassword"] do + value when is_binary(value) and value != "" -> {:ok, value} + _ -> {:error, :missing_app_password} + end + end + + defp extract_app_password(_) do + {:error, :missing_app_password} + end + + defp create_app_password_with_retry(url, headers, username, retries_left) do + payload = %{"name" => app_password_name(username)} + + with {:ok, response} <- request_json(:post, url, payload, headers), + :ok <- require_success_status(response.status, :create_app_password_failed), + {:ok, body} <- decode_json_body(response.body) do + extract_app_password(body) + else + {:error, {:create_app_password_failed, 500}} when retries_left > 0 -> + create_app_password_with_retry(url, headers, username, retries_left - 1) + + error -> + error + end + end + + defp app_password_name(username) do + base = + username + |> to_string() + |> String.downcase() + |> String.replace(~r/[^a-z0-9]/, "") + |> case do + "" -> "user" + value -> value + end + |> String.slice(0, 16) + + suffix = :erlang.unique_integer([:positive, :monotonic]) |> Integer.to_string(36) + "elektrine-#{base}-#{suffix}" + end + + defp persist_user_link(user, account, app_password, service_url) do + did = account["did"] + + attrs = %{ + bluesky_enabled: true, + bluesky_identifier: did || account["handle"], + bluesky_app_password: app_password, + bluesky_pds_url: service_url, + bluesky_inbound_cursor: nil + } + + case Accounts.update_user(user, attrs) do + {:ok, updated_user} -> + if is_binary(did) and did != "" do + from(u in User, where: u.id == ^updated_user.id) + |> Repo.update_all(set: [bluesky_did: did]) + end + + {:ok, Repo.get!(User, updated_user.id)} + + error -> + error + end + end + + defp persist_reconnect_link(user, session, identifier, app_password, service_url) do + did = session.did + + identifier_value = + if is_binary(did) and did != "" do + did + else + identifier + end + + attrs = %{ + bluesky_enabled: true, + bluesky_identifier: identifier_value, + bluesky_app_password: app_password, + bluesky_pds_url: service_url, + bluesky_inbound_cursor: nil + } + + case Accounts.update_user(user, attrs) do + {:ok, updated_user} -> + if is_binary(did) and did != "" do + from(u in User, where: u.id == ^updated_user.id) + |> Repo.update_all(set: [bluesky_did: did]) + end + + {:ok, Repo.get!(User, updated_user.id)} + + error -> + error + end + end + + defp reconnect_identifier(%User{} = user, handle_domain) do + candidate = + [ + user.bluesky_did, + user.bluesky_identifier, + preferred_managed_handle_value(user, handle_domain) + ] + |> Enum.find_value(&Elektrine.Strings.present/1) + + case candidate do + value when is_binary(value) -> {:ok, String.trim(value)} + _ -> {:error, :missing_identifier} + end + end + + defp fallback_handle("did:" <> _did, username, handle_domain) do + preferred_managed_handle_value(%User{username: username}, handle_domain) + end + + defp fallback_handle(handle, _username, _handle_domain) do + handle + end + + defp preferred_managed_handle(%User{id: user_id, username: username}, handle_domain) + when is_integer(user_id) and is_binary(username) do + case Profiles.preferred_verified_domain_for_user(user_id) do + %{domain: domain} when is_binary(domain) and domain != "" -> validate_managed_handle(domain) + _ -> validate_managed_handle("#{String.downcase(username)}.#{handle_domain}") + end + end + + defp preferred_managed_handle(%User{username: username}, handle_domain) + when is_binary(username) do + validate_managed_handle("#{String.downcase(username)}.#{handle_domain}") + end + + defp preferred_managed_handle(_user, _handle_domain) do + {:error, :invalid_handle} + end + + defp preferred_managed_handle_value(user, handle_domain) do + case preferred_managed_handle(user, handle_domain) do + {:ok, handle} -> handle + {:error, _reason} -> nil + end + end + + defp validate_managed_handle(handle) when is_binary(handle) do + normalized = handle |> String.trim() |> String.downcase() + + if normalized != "" and normalized =~ @handle_regex do + {:ok, normalized} + else + {:error, :invalid_handle} + end + end + + defp create_reconnect_session(service_url, identifier, %User{} = user, current_password) do + user + |> reconnect_password_candidates(current_password) + |> do_create_reconnect_session(service_url, identifier) + end + + defp reconnect_password_candidates(%User{bluesky_app_password: app_password}, current_password) do + [app_password, current_password] + |> Enum.map(&Elektrine.Strings.present/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp do_create_reconnect_session([], _service_url, _identifier) do + {:error, :missing_app_password} + end + + defp do_create_reconnect_session([password | rest], service_url, identifier) do + case create_session(service_url, identifier, password) do + {:ok, _session} = ok -> + ok + + {:error, {:create_session_failed, 401}} when rest != [] -> + do_create_reconnect_session(rest, service_url, identifier) + + {:error, reason} -> + {:error, reason} + end + end + + defp request_json(method, url, payload, extra_headers) do + headers = [ + {"content-type", "application/json"}, + {"accept", "application/json"} | extra_headers + ] + + body = Jason.encode!(payload) + timeout_ms = Keyword.get(bluesky_config(), :timeout_ms, @default_timeout_ms) + request_opts = [receive_timeout: timeout_ms] + request_with_retry(method, url, headers, body, request_opts) + end + + defp request_with_retry(method, url, headers, body, opts, retries_left \\ 1) do + case http_client().request(method, url, headers, body, opts) do + {:ok, %Finch.Response{} = response} -> + {:ok, response} + + {:error, reason} -> + if retries_left > 0 and retryable_transport_closed?(reason) do + request_with_retry(method, url, headers, body, opts, retries_left - 1) + else + {:error, {:http_error, reason}} + end + end + end + + defp retryable_transport_closed?(%Mint.TransportError{reason: :closed}) do + true + end + + defp retryable_transport_closed?(:closed) do + true + end + + defp retryable_transport_closed?(_reason) do + false + end + + defp managed_admin_headers(admin_password) do + token = Base.encode64("admin:" <> admin_password) + [{"authorization", "Basic " <> token}] + end + + defp require_success_status(status, _reason) when status in 200..299 do + :ok + end + + defp require_success_status(status, reason) do + {:error, {reason, status}} + end + + defp decode_json_body(body) when is_binary(body) do + case Jason.decode(body) do + {:ok, payload} when is_map(payload) -> {:ok, payload} + _ -> {:error, :invalid_json} + end + end + + defp decode_json_body(_) do + {:error, :invalid_json} + end + + defp map_fetch_string(map, key, reason) when is_map(map) do + case Map.get(map, key) do + value when is_binary(value) and value != "" -> {:ok, value} + _ -> {:error, reason} + end + end + + defp map_fetch_string(_map, _key, reason) do + {:error, reason} + end + + defp default_pds_email(user) do + "#{user.username}@#{Elektrine.Domains.primary_email_domain()}" + end + + defp bluesky_config do + Application.get_env(:elektrine, :bluesky, []) + end + + defp http_client do + Keyword.get(bluesky_config(), :http_client, Elektrine.Bluesky.FinchClient) + end +end diff --git a/apps/elektrine/lib/elektrine/bluesky/outbound_worker.ex b/apps/elektrine/lib/elektrine/bluesky/outbound_worker.ex new file mode 100644 index 0000000..431a395 --- /dev/null +++ b/apps/elektrine/lib/elektrine/bluesky/outbound_worker.ex @@ -0,0 +1,163 @@ +defmodule Elektrine.Bluesky.OutboundWorker do + @moduledoc """ + Durable outbound Bluesky sync worker. + + This replaces fire-and-forget async calls with persisted Oban jobs. + """ + + use Oban.Worker, + queue: :federation, + max_attempts: 8, + unique: [ + period: 300, + fields: [:args], + keys: [:action, :message_id, :user_id, :follower_id, :followed_id] + ] + + require Logger + + alias Elektrine.Bluesky + alias Elektrine.Messaging + alias Oban.Job + + @action_mirror_post "mirror_post" + @action_post_update "mirror_post_update" + @action_post_delete "mirror_post_delete" + @action_like "mirror_like" + @action_unlike "mirror_unlike" + @action_repost "mirror_repost" + @action_unrepost "mirror_unrepost" + @action_follow "mirror_follow" + @action_unfollow "mirror_unfollow" + + @impl Oban.Worker + def perform(%Job{args: %{"action" => action} = args}) do + action + |> dispatch(args) + |> normalize_result(action, args) + end + + def perform(%Job{}), do: :ok + + def enqueue_mirror_post(message_id), + do: enqueue(@action_mirror_post, %{"message_id" => message_id}) + + def enqueue_post_update(message_id), + do: enqueue(@action_post_update, %{"message_id" => message_id}) + + def enqueue_post_delete(message_id), + do: enqueue(@action_post_delete, %{"message_id" => message_id}) + + def enqueue_like(message_id, user_id), + do: enqueue(@action_like, %{"message_id" => message_id, "user_id" => user_id}) + + def enqueue_unlike(message_id, user_id), + do: enqueue(@action_unlike, %{"message_id" => message_id, "user_id" => user_id}) + + def enqueue_repost(message_id, user_id), + do: enqueue(@action_repost, %{"message_id" => message_id, "user_id" => user_id}) + + def enqueue_unrepost(message_id, user_id), + do: enqueue(@action_unrepost, %{"message_id" => message_id, "user_id" => user_id}) + + def enqueue_follow(follower_id, followed_id), + do: enqueue(@action_follow, %{"follower_id" => follower_id, "followed_id" => followed_id}) + + def enqueue_unfollow(follower_id, followed_id), + do: enqueue(@action_unfollow, %{"follower_id" => follower_id, "followed_id" => followed_id}) + + def enqueue(action, args \\ %{}) when is_binary(action) and is_map(args) do + args + |> Map.put("action", action) + |> new() + |> Elektrine.JobQueue.insert() + end + + defp dispatch(@action_mirror_post, %{"message_id" => message_id}) do + with {:ok, id} <- ensure_integer(message_id), + %{} = message <- Messaging.get_message(id) do + Bluesky.mirror_post(message) + else + nil -> {:skipped, :message_not_found} + {:error, reason} -> {:skipped, reason} + end + end + + defp dispatch(@action_post_update, %{"message_id" => message_id}) do + with {:ok, id} <- ensure_integer(message_id), + %{} = message <- Messaging.get_message(id) do + Bluesky.mirror_post_update(message) + else + nil -> {:skipped, :message_not_found} + {:error, reason} -> {:skipped, reason} + end + end + + defp dispatch(@action_post_delete, %{"message_id" => message_id}) do + with {:ok, id} <- ensure_integer(message_id), + %{} = message <- Messaging.get_message(id) do + Bluesky.mirror_post_delete(message) + else + nil -> {:skipped, :message_not_found} + {:error, reason} -> {:skipped, reason} + end + end + + defp dispatch(action, %{"message_id" => message_id, "user_id" => user_id}) + when action in [@action_like, @action_unlike, @action_repost, @action_unrepost] do + with {:ok, parsed_message_id} <- ensure_integer(message_id), + {:ok, parsed_user_id} <- ensure_integer(user_id) do + case action do + @action_like -> Bluesky.mirror_like(parsed_message_id, parsed_user_id) + @action_unlike -> Bluesky.mirror_unlike(parsed_message_id, parsed_user_id) + @action_repost -> Bluesky.mirror_repost(parsed_message_id, parsed_user_id) + @action_unrepost -> Bluesky.mirror_unrepost(parsed_message_id, parsed_user_id) + end + else + {:error, reason} -> {:skipped, reason} + end + end + + defp dispatch(action, %{"follower_id" => follower_id, "followed_id" => followed_id}) + when action in [@action_follow, @action_unfollow] do + with {:ok, parsed_follower_id} <- ensure_integer(follower_id), + {:ok, parsed_followed_id} <- ensure_integer(followed_id) do + case action do + @action_follow -> Bluesky.mirror_follow(parsed_follower_id, parsed_followed_id) + @action_unfollow -> Bluesky.mirror_unfollow(parsed_follower_id, parsed_followed_id) + end + else + {:error, reason} -> {:skipped, reason} + end + end + + defp dispatch(_action, _args), do: {:skipped, :unsupported_action} + + defp normalize_result(:ok, _action, _args), do: :ok + defp normalize_result({:skipped, _reason}, _action, _args), do: :ok + + defp normalize_result({:error, reason}, action, _args) do + if retryable_reason?(reason) do + Logger.warning("Bluesky outbound action #{action} failed (retrying): #{inspect(reason)}") + {:error, reason} + else + Logger.warning("Bluesky outbound action #{action} skipped after error: #{inspect(reason)}") + :ok + end + end + + defp retryable_reason?({:http_error, _reason}), do: true + defp retryable_reason?({_, status}) when is_integer(status), do: status == 429 or status >= 500 + defp retryable_reason?(_reason), do: false + + defp ensure_integer(value) when is_integer(value), do: {:ok, value} + + defp ensure_integer(value) when is_binary(value) do + case Integer.parse(value) do + {parsed, ""} -> {:ok, parsed} + _ -> {:error, :invalid_integer} + end + end + + defp ensure_integer(_value), do: {:error, :invalid_integer} +end diff --git a/apps/elektrine/lib/elektrine/booking.ex b/apps/elektrine/lib/elektrine/booking.ex new file mode 100644 index 0000000..a302874 --- /dev/null +++ b/apps/elektrine/lib/elektrine/booking.ex @@ -0,0 +1,363 @@ +defmodule Elektrine.Booking do + @moduledoc """ + Public booking pages on top of a user's own calendar. + + Availability is computed, never stored: the offered slots are whatever the + page's weekly windows allow minus what the calendar and existing bookings + already occupy. Storing availability would mean two sources of truth, and the + calendar would win the argument at the worst possible moment. + + Double-booking is prevented in the database, by a partial unique index over + live bookings, rather than by checking-then-inserting. Two requests can + always pass the same check at the same time; only one can win the insert. + """ + + import Ecto.Query, warn: false + + alias Elektrine.Booking.Booking + alias Elektrine.Booking.ConfirmationWorker + alias Elektrine.Booking.Page + alias Elektrine.Calendar + alias Elektrine.Calendar.Availability + alias Elektrine.Repo + + require Logger + + @hold_minutes 10 + + ## Pages + + def create_page(attrs) do + %Page{} + |> Page.changeset(attrs) + |> Repo.insert() + end + + def update_page(%Page{} = page, attrs) do + page + |> Page.changeset(attrs) + |> Repo.update() + end + + def delete_page(%Page{} = page), do: Repo.delete(page) + + def list_pages(user_id) do + Page + |> where([p], p.user_id == ^user_id) + |> order_by([p], asc: p.title) + |> Repo.all() + end + + def get_page(user_id, id), do: Repo.get_by(Page, id: id, user_id: user_id) + + @doc "Fetches a page for public display. Inactive pages are invisible." + def get_public_page(slug) when is_binary(slug) do + Repo.get_by(Page, slug: String.downcase(String.trim(slug)), active: true) + end + + def get_public_page(_slug), do: nil + + ## Availability + + @doc """ + Slots currently on offer for `page`. + + Busy time is the union of the owner's calendar and the page's own live + bookings, so a slot someone is mid-way through taking is not offered twice. + """ + def available_slots(%Page{} = page, opts \\ []) do + now = Keyword.get(opts, :now, DateTime.utc_now()) + from = Keyword.get(opts, :from, now) + until = Keyword.get(opts, :until, DateTime.add(now, page.max_days_ahead * 86_400, :second)) + + busy = + Calendar.busy_blocks(page.user_id, from, until) ++ booked_blocks(page, from, until, now) + + Availability.free_slots_in_zone(from, + timezone: page.timezone, + until: until, + windows: Page.windows(page), + slot_minutes: page.slot_minutes, + buffer_minutes: page.buffer_minutes, + min_notice_minutes: page.min_notice_minutes, + max_per_day: page.max_per_day, + now: now + ) + |> Enum.reject(fn slot -> Enum.any?(busy, &overlaps?(slot, &1)) end) + end + + @doc """ + Takes a short hold on a slot. + + Returns `{:error, :slot_taken}` when someone else got there first — including + when they got there in the microseconds between this caller checking and + inserting, because the uniqueness is enforced by the database. + """ + def hold_slot(%Page{} = page, %{start: start_at, end: end_at}, opts \\ []) do + now = Keyword.get(opts, :now, DateTime.utc_now()) + + if offered?(page, start_at, now) do + insert_hold(page, start_at, end_at, now) + else + {:error, :slot_unavailable} + end + end + + defp insert_hold(page, start_at, end_at, now) do + attrs = %{ + booking_page_id: page.id, + user_id: page.user_id, + start_at: truncate(start_at), + end_at: truncate(end_at), + status: "held", + hold_expires_at: truncate(DateTime.add(now, @hold_minutes * 60, :second)), + confirmation_token: token(), + cancellation_token: token() + } + + %Booking{} + |> Booking.changeset(attrs) + |> Repo.insert() + |> case do + {:ok, booking} -> {:ok, booking} + {:error, %Ecto.Changeset{errors: errors}} = error -> slot_conflict(errors, error) + end + end + + defp slot_conflict(errors, error) do + if Keyword.has_key?(errors, :booking_page_id) or Keyword.has_key?(errors, :start_at) do + {:error, :slot_taken} + else + error + end + end + + @doc "Confirms a held booking, given the token issued with the hold." + def confirm_booking(token, attrs, opts \\ []) when is_binary(token) do + now = Keyword.get(opts, :now, DateTime.utc_now()) + + case Repo.get_by(Booking, confirmation_token: token) do + nil -> + {:error, :not_found} + + %Booking{status: "confirmed"} = booking -> + {:ok, booking} + + %Booking{status: "held"} = booking -> + if expired?(booking, now) do + {:error, :hold_expired} + else + booking + |> Booking.confirm_changeset(attrs) + |> Repo.update() + |> after_confirm() + end + + %Booking{} -> + {:error, :not_holdable} + end + end + + @doc "Cancels a booking using the token given to the invitee." + def cancel_booking(token) when is_binary(token) do + case Repo.get_by(Booking, cancellation_token: token) do + nil -> {:error, :not_found} + %Booking{status: "cancelled"} = booking -> {:ok, booking} + booking -> do_cancel(booking) + end + end + + @doc "Cancels a booking as its owner." + def cancel_booking(user_id, booking_id) do + case Repo.get_by(Booking, id: booking_id, user_id: user_id) do + nil -> {:error, :not_found} + booking -> do_cancel(booking) + end + end + + defp do_cancel(booking) do + case booking |> Booking.changeset(%{status: "cancelled"}) |> Repo.update() do + {:ok, cancelled} -> + release_calendar_event(cancelled) + {:ok, cancelled} + + error -> + error + end + end + + # Confirming a booking writes an event onto the owner's calendar, so + # cancelling has to take it off again. Leaving it behind would keep the owner + # busy at a time nobody is coming, and the slot would never be re-offered. + defp release_calendar_event(%Booking{calendar_event_id: nil}), do: :ok + + defp release_calendar_event(%Booking{calendar_event_id: event_id}) do + Calendar.delete_event(Calendar.get_event!(event_id)) + rescue + error -> + Logger.warning("Could not release calendar event #{event_id}: #{inspect(error)}") + :error + end + + @doc """ + Releases holds nobody completed. + + Without this an abandoned form would keep a slot out of circulation forever, + which is the same as letting anyone silently delete your availability. + """ + def expire_holds(opts \\ []) do + now = Keyword.get(opts, :now, DateTime.utc_now()) + + {count, _} = + Booking + |> where([b], b.status == "held") + |> where([b], not is_nil(b.hold_expires_at) and b.hold_expires_at < ^now) + |> Repo.update_all(set: [status: "expired", updated_at: truncate(now)]) + + count + end + + def list_bookings(user_id, opts \\ []) do + status = Keyword.get(opts, :status) + + Booking + |> where([b], b.user_id == ^user_id) + |> then(fn query -> + if status, do: where(query, [b], b.status == ^status), else: query + end) + |> order_by([b], asc: b.start_at) + |> Repo.all() + end + + @doc """ + Confirmed bookings still to come, for the owner's own view. + + Held bookings are excluded: an in-progress hold is not something the owner + should plan around, and it may well expire. + """ + def list_upcoming_bookings(user_id, opts \\ []) do + now = Keyword.get(opts, :now, DateTime.utc_now()) + limit = Keyword.get(opts, :limit, 25) + + Booking + |> where([b], b.user_id == ^user_id) + |> where([b], b.status == "confirmed") + |> where([b], b.start_at >= ^now) + |> order_by([b], asc: b.start_at) + |> limit(^limit) + |> preload(:booking_page) + |> Repo.all() + end + + def get_booking_by_token(token) when is_binary(token) do + Repo.get_by(Booking, confirmation_token: token) || + Repo.get_by(Booking, cancellation_token: token) + end + + ## Confirmation side effects + + # The booking is already saved by this point. Putting the event and the mail + # after the write, and letting each fail independently, means a calendar + # hiccup cannot lose a confirmation the invitee has already been shown. + defp after_confirm({:ok, booking}) do + booking = Repo.preload(booking, [:booking_page, :user]) + + booking = + case create_calendar_event(booking) do + {:ok, event} -> attach_event(booking, event) + _ -> booking + end + + send_confirmation(booking) + + {:ok, booking} + end + + defp after_confirm(other), do: other + + defp create_calendar_event(%Booking{booking_page: %Page{} = page} = booking) do + {:ok, calendar} = Calendar.get_or_create_default_calendar(booking.user_id) + + Calendar.create_event(%{ + calendar_id: calendar.id, + uid: Elektrine.EmailAddresses.uid("booking-#{booking.id}"), + summary: booking_summary(booking, page), + description: booking.note, + dtstart: booking.start_at, + dtend: booking.end_at, + status: "CONFIRMED", + transparency: "OPAQUE", + timezone: page.timezone + }) + rescue + error -> + Logger.warning( + "Could not create calendar event for booking #{booking.id}: #{inspect(error)}" + ) + + :error + end + + defp create_calendar_event(_booking), do: :error + + defp booking_summary(%Booking{invitee_name: name}, %Page{title: title}) + when is_binary(name) and name != "", + do: "#{title} with #{name}" + + defp booking_summary(%Booking{invitee_email: email}, %Page{title: title}) + when is_binary(email), + do: "#{title} with #{email}" + + defp booking_summary(_booking, %Page{title: title}), do: title + + defp attach_event(booking, event) do + case booking |> Booking.changeset(%{calendar_event_id: event.id}) |> Repo.update() do + {:ok, updated} -> %{updated | booking_page: booking.booking_page, user: booking.user} + _ -> booking + end + end + + defp send_confirmation(%Booking{invitee_email: nil}), do: :ok + + defp send_confirmation(%Booking{} = booking) do + ConfirmationWorker.enqueue(booking) + rescue + error -> + Logger.warning("Could not queue booking confirmation #{booking.id}: #{inspect(error)}") + :error + end + + ## Internals + + defp offered?(page, start_at, now) do + page + |> available_slots(now: now, from: now) + |> Enum.any?(&(DateTime.compare(&1.start, start_at) == :eq)) + end + + # Expired holds are not busy, so a slot frees itself even before the sweeper + # gets to it. + defp booked_blocks(page, from, until, now) do + Booking + |> where([b], b.booking_page_id == ^page.id) + |> where([b], b.status in ^Booking.live_statuses()) + |> where([b], b.start_at < ^until and b.end_at > ^from) + |> Repo.all() + |> Enum.reject(&expired?(&1, now)) + |> Enum.map(&%{start: &1.start_at, end: &1.end_at}) + end + + defp expired?(%Booking{status: "held", hold_expires_at: %DateTime{} = expires_at}, now), + do: DateTime.compare(expires_at, now) != :gt + + defp expired?(_booking, _now), do: false + + defp overlaps?(slot, block) do + DateTime.compare(slot.start, block.end) == :lt and + DateTime.compare(block.start, slot.end) == :lt + end + + defp truncate(datetime), do: DateTime.truncate(datetime, :second) + + defp token, do: :crypto.strong_rand_bytes(24) |> Base.url_encode64(padding: false) +end diff --git a/apps/elektrine/lib/elektrine/booking/booking.ex b/apps/elektrine/lib/elektrine/booking/booking.ex new file mode 100644 index 0000000..bafa998 --- /dev/null +++ b/apps/elektrine/lib/elektrine/booking/booking.ex @@ -0,0 +1,94 @@ +defmodule Elektrine.Booking.Booking do + @moduledoc """ + One reservation on a booking page. + + Moves `held -> confirmed`, or `held -> cancelled` / expiry. A hold exists so + that the moment an invitee picks a slot it stops being offered to anyone + else, rather than two people filling in the form and one of them losing. + """ + use Ecto.Schema + + import Ecto.Changeset + + @statuses ~w(held confirmed cancelled expired) + @live_statuses ~w(held confirmed) + + schema "bookings" do + field :start_at, :utc_datetime + field :end_at, :utc_datetime + field :status, :string, default: "held" + + field :invitee_name, :string + field :invitee_email, :string + field :note, :string + + field :hold_expires_at, :utc_datetime + field :confirmation_token, :string + field :cancellation_token, :string + + belongs_to :booking_page, Elektrine.Booking.Page + belongs_to :user, Elektrine.Accounts.User + belongs_to :calendar_event, Elektrine.Calendar.Event + + timestamps() + end + + def statuses, do: @statuses + def live_statuses, do: @live_statuses + + def changeset(booking, attrs) do + booking + |> cast(attrs, [ + :booking_page_id, + :user_id, + :start_at, + :end_at, + :status, + :invitee_name, + :invitee_email, + :note, + :hold_expires_at, + :confirmation_token, + :cancellation_token, + :calendar_event_id + ]) + |> validate_required([:booking_page_id, :user_id, :start_at, :end_at, :status]) + |> validate_inclusion(:status, @statuses) + |> validate_ordering() + |> validate_length(:invitee_name, max: 200) + |> validate_length(:note, max: 2000) + |> validate_invitee_email() + |> unique_constraint([:booking_page_id, :start_at], name: :bookings_live_slot_index) + |> unique_constraint(:confirmation_token) + |> unique_constraint(:cancellation_token) + end + + def confirm_changeset(booking, attrs) do + booking + |> cast(attrs, [:invitee_name, :invitee_email, :note]) + |> put_change(:status, "confirmed") + |> put_change(:hold_expires_at, nil) + |> validate_required([:invitee_email]) + |> validate_invitee_email() + |> validate_length(:invitee_name, max: 200) + |> validate_length(:note, max: 2000) + end + + defp validate_ordering(changeset) do + start_at = get_field(changeset, :start_at) + end_at = get_field(changeset, :end_at) + + if start_at && end_at && DateTime.compare(end_at, start_at) != :gt do + add_error(changeset, :end_at, "must be after the start") + else + changeset + end + end + + defp validate_invitee_email(changeset) do + case get_field(changeset, :invitee_email) do + nil -> changeset + _ -> validate_format(changeset, :invitee_email, ~r/^[^@\s]+@[^@\s]+\.[^@\s]+$/) + end + end +end diff --git a/apps/elektrine/lib/elektrine/booking/confirmation_worker.ex b/apps/elektrine/lib/elektrine/booking/confirmation_worker.ex new file mode 100644 index 0000000..bb4e725 --- /dev/null +++ b/apps/elektrine/lib/elektrine/booking/confirmation_worker.ex @@ -0,0 +1,78 @@ +defmodule Elektrine.Booking.ConfirmationWorker do + @moduledoc """ + Sends the invitee's confirmation, with the calendar invite attached. + + `Elektrine.Mailer.deliver_later/1` cannot carry attachments — it serialises + the email into Oban args, and a file does not belong in a job payload. So + this worker carries only the booking id and rebuilds the mail on the other + side, which keeps the retry behaviour without putting the invite through the + job queue. + """ + use Oban.Worker, queue: :email, max_attempts: 5 + + alias Elektrine.Booking.Booking + alias Elektrine.Booking.Notifier + alias Elektrine.Repo + + require Logger + + def enqueue(%Booking{id: id}) do + %{"booking_id" => id} + |> new() + |> Elektrine.JobQueue.insert() + end + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"booking_id" => booking_id}}) do + case Repo.get(Booking, booking_id) do + nil -> + # Deleted before the job ran. Nothing to send, and nothing wrong. + :ok + + booking -> + booking + |> Repo.preload([:booking_page, :user]) + |> deliver() + end + end + + defp deliver(%Booking{booking_page: nil}), do: :ok + + # Cancelled or expired between confirming and this job running. + defp deliver(%Booking{status: status}) when status != "confirmed", do: :ok + + defp deliver(booking) do + case Notifier.confirmation_email(booking, booking.booking_page, booking.user) do + nil -> + :ok + + email -> + email + |> attach_invite(booking) + |> Elektrine.Mailer.deliver() + |> case do + {:ok, _metadata} -> :ok + {:error, reason} -> {:error, reason} + end + end + end + + # The invite is a convenience; a booking that cannot produce one should still + # send its confirmation. + defp attach_invite(email, booking) do + ics = Notifier.invite_ics(booking, booking.booking_page, booking.user) + + Swoosh.Email.attachment( + email, + Swoosh.Attachment.new({:data, ics}, + filename: "invite.ics", + content_type: "text/calendar", + type: :attachment + ) + ) + rescue + error -> + Logger.warning("Could not attach invite for booking #{booking.id}: #{inspect(error)}") + email + end +end diff --git a/apps/elektrine/lib/elektrine/booking/expire_holds_worker.ex b/apps/elektrine/lib/elektrine/booking/expire_holds_worker.ex new file mode 100644 index 0000000..b68824e --- /dev/null +++ b/apps/elektrine/lib/elektrine/booking/expire_holds_worker.ex @@ -0,0 +1,25 @@ +defmodule Elektrine.Booking.ExpireHoldsWorker do + @moduledoc """ + Releases booking holds nobody completed. + + `Elektrine.Booking` already treats an expired hold as free when computing + availability, so this is bookkeeping rather than correctness — it stops the + table filling with rows that will never mean anything, and keeps the owner's + booking list honest about what is actually reserved. + """ + use Oban.Worker, queue: :default, max_attempts: 3 + + alias Elektrine.Booking + + require Logger + + @impl Oban.Worker + def perform(_job) do + case Booking.expire_holds() do + 0 -> :ok + count -> Logger.info("Expired #{count} abandoned booking hold(s)") + end + + :ok + end +end diff --git a/apps/elektrine/lib/elektrine/booking/notifier.ex b/apps/elektrine/lib/elektrine/booking/notifier.ex new file mode 100644 index 0000000..43ab962 --- /dev/null +++ b/apps/elektrine/lib/elektrine/booking/notifier.ex @@ -0,0 +1,101 @@ +defmodule Elektrine.Booking.Notifier do + @moduledoc """ + Confirmation mail for a booking, carrying a real calendar invite. + + The iCalendar body is attached as `text/calendar` so the invitee's mail + client offers to add it, rather than making them retype a time from prose. + """ + + import Swoosh.Email + + alias Elektrine.Booking.Booking + alias Elektrine.Booking.Page + alias Elektrine.Calendar.ICalendar + alias Elektrine.EmailAddresses + + @doc "Builds the invitee's confirmation email, or nil when there is nobody to write to." + def confirmation_email(%Booking{invitee_email: nil}, _page, _owner), do: nil + + def confirmation_email(%Booking{} = booking, %Page{} = page, owner) do + new() + |> to(booking.invitee_email) + |> from({"Elektrine", EmailAddresses.local("noreply")}) + |> subject("Confirmed: #{page.title}") + |> text_body(text_body(booking, page, owner)) + |> html_body(html_body(booking, page, owner)) + |> header("List-Id", EmailAddresses.list_id("elektrine-bookings")) + # Auto-Submitted marks this as machine-generated, which is also what the + # inbound classifier reads to keep replies of this shape out of the + # unknown-sender bucket on the other side. + |> header("Auto-Submitted", "auto-generated") + end + + @doc "The iCalendar invite for a booking, suitable for attaching or serving." + def invite_ics(%Booking{} = booking, %Page{} = page, owner) do + {:ok, ics} = build_ics(booking, page, owner) + ics + end + + defp build_ics(booking, page, owner) do + ICalendar.generate(%{ + uid: EmailAddresses.uid("booking-#{booking.id}"), + summary: page.title, + description: booking.note, + dtstart: booking.start_at, + dtend: booking.end_at, + status: "CONFIRMED", + organizer: %{"email" => owner_email(owner), "name" => owner_name(owner)}, + attendees: [%{"email" => booking.invitee_email, "name" => booking.invitee_name}] + }) + end + + defp text_body(booking, page, owner) do + """ + #{page.title} is confirmed. + + When: #{format_local(booking.start_at, page.timezone)} (#{page.timezone}) + With: #{owner_name(owner)} + #{if booking.note && booking.note != "", do: "\nYour note: #{booking.note}\n", else: ""} + Need to cancel? #{cancel_url(booking)} + """ + end + + defp html_body(booking, page, owner) do + """ +

    #{escape(page.title)} is confirmed.

    +

    + When: #{escape(format_local(booking.start_at, page.timezone))} + (#{escape(page.timezone)})
    + With: #{escape(owner_name(owner))} +

    + #{note_html(booking)} +

    Need to cancel? Cancel this booking.

    + """ + end + + defp note_html(%Booking{note: note}) when is_binary(note) and note != "", + do: "

    Your note: #{escape(note)}

    " + + defp note_html(_booking), do: "" + + defp cancel_url(booking) do + ElektrineWeb.Endpoint.url() <> "/book/cancel/#{booking.cancellation_token}" + end + + defp format_local(datetime, timezone) do + case DateTime.shift_zone(datetime, timezone, Tz.TimeZoneDatabase) do + {:ok, local} -> Calendar.strftime(local, "%A %-d %B %Y at %H:%M") + _ -> Calendar.strftime(datetime, "%A %-d %B %Y at %H:%M UTC") + end + end + + defp owner_name(%{display_name: name}) when is_binary(name) and name != "", do: name + defp owner_name(%{username: username}), do: username + defp owner_name(_owner), do: "Elektrine" + + defp owner_email(%{username: username}), do: EmailAddresses.local(username) + defp owner_email(_owner), do: EmailAddresses.local("noreply") + + defp escape(value), + do: value |> to_string() |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() +end diff --git a/apps/elektrine/lib/elektrine/booking/page.ex b/apps/elektrine/lib/elektrine/booking/page.ex new file mode 100644 index 0000000..6534af7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/booking/page.ex @@ -0,0 +1,116 @@ +defmodule Elektrine.Booking.Page do + @moduledoc """ + A public page where other people can take time on someone's calendar. + + `windows` holds the weekly availability as `%{"windows" => [%{"day" => 1, + "start_minute" => 540, "end_minute" => 1020}]}`, in minutes from local + midnight in `timezone`. Local rather than UTC on purpose: "I take calls from + nine" is a statement about a clock on a wall, and storing the UTC equivalent + would quietly shift it twice a year. + """ + use Ecto.Schema + + import Ecto.Changeset + + @slug_format ~r/^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$/ + + schema "booking_pages" do + field :slug, :string + field :title, :string + field :description, :string + field :timezone, :string, default: "Etc/UTC" + field :windows, :map, default: %{} + + field :slot_minutes, :integer, default: 30 + field :buffer_minutes, :integer, default: 0 + field :min_notice_minutes, :integer, default: 60 + field :max_days_ahead, :integer, default: 30 + field :max_per_day, :integer + + field :active, :boolean, default: true + + belongs_to :user, Elektrine.Accounts.User + has_many :bookings, Elektrine.Booking.Booking, foreign_key: :booking_page_id + + timestamps() + end + + def changeset(page, attrs) do + page + |> cast(attrs, [ + :user_id, + :slug, + :title, + :description, + :timezone, + :windows, + :slot_minutes, + :buffer_minutes, + :min_notice_minutes, + :max_days_ahead, + :max_per_day, + :active + ]) + |> validate_required([:user_id, :slug, :title, :timezone]) + |> update_change(:slug, &String.downcase(String.trim(&1 || ""))) + |> validate_format(:slug, @slug_format, + message: "must be lowercase letters, numbers, and dashes" + ) + |> validate_number(:slot_minutes, greater_than: 0, less_than_or_equal_to: 480) + |> validate_number(:buffer_minutes, greater_than_or_equal_to: 0, less_than_or_equal_to: 240) + |> validate_number(:min_notice_minutes, greater_than_or_equal_to: 0) + |> validate_number(:max_days_ahead, greater_than: 0, less_than_or_equal_to: 365) + |> validate_timezone() + |> validate_windows() + |> unique_constraint(:slug) + |> foreign_key_constraint(:user_id) + end + + @doc "The page's availability windows, as the availability engine wants them." + def windows(%__MODULE__{windows: %{"windows" => windows}}) when is_list(windows) do + Enum.map(windows, fn window -> + %{ + day: window["day"], + start_minute: window["start_minute"], + end_minute: window["end_minute"] + } + end) + end + + def windows(_page), do: [] + + # A page whose timezone does not resolve would generate no slots at all and + # look broken with no explanation, so reject it at write time. + defp validate_timezone(changeset) do + validate_change(changeset, :timezone, fn :timezone, timezone -> + case DateTime.now(timezone, Tz.TimeZoneDatabase) do + {:ok, _} -> [] + _ -> [timezone: "is not a known timezone"] + end + end) + end + + defp validate_windows(changeset) do + validate_change(changeset, :windows, fn :windows, windows -> + entries = (is_map(windows) && windows["windows"]) || [] + + cond do + not is_list(entries) -> + [windows: "must be a list of weekly windows"] + + Enum.all?(entries, &valid_window?/1) -> + [] + + true -> + [windows: "each window needs a day 1-7 and start_minute < end_minute within a day"] + end + end) + end + + defp valid_window?(%{"day" => day, "start_minute" => from, "end_minute" => to}) + when is_integer(day) and is_integer(from) and is_integer(to) do + day in 1..7 and from >= 0 and to > from and to <= 1440 + end + + defp valid_window?(_window), do: false +end diff --git a/apps/elektrine/lib/elektrine/caddy_tls_domain_cache.ex b/apps/elektrine/lib/elektrine/caddy_tls_domain_cache.ex new file mode 100644 index 0000000..07b5497 --- /dev/null +++ b/apps/elektrine/lib/elektrine/caddy_tls_domain_cache.ex @@ -0,0 +1,26 @@ +defmodule Elektrine.CaddyTLSDomainCache do + @moduledoc """ + Owns the ETS table used by the Caddy on-demand TLS allowlist cache. + """ + + use GenServer + + @table_name :caddy_tls_domain_cache + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(_opts) do + :ets.new(@table_name, [ + :named_table, + :public, + :set, + {:read_concurrency, true}, + {:write_concurrency, true} + ]) + + {:ok, %{}} + end +end diff --git a/apps/elektrine/lib/elektrine/calendar.ex b/apps/elektrine/lib/elektrine/calendar.ex new file mode 100644 index 0000000..4298ea1 --- /dev/null +++ b/apps/elektrine/lib/elektrine/calendar.ex @@ -0,0 +1,313 @@ +defmodule Elektrine.Calendar do + @moduledoc """ + Context for calendar and event management. + Provides functions for both web UI and CalDAV operations. + """ + + import Ecto.Query + alias Elektrine.Calendar.{Calendar, Event, ICalendar} + alias Elektrine.Repo + + # ===== CALENDARS ===== + + @doc """ + Lists all calendars for a user. + """ + def list_calendars(user_id) do + from(c in Calendar, + where: c.user_id == ^user_id, + order_by: [asc: c.order, asc: c.name] + ) + |> Repo.all() + end + + @doc """ + Gets a single calendar. + """ + def get_calendar!(id) do + Repo.get!(Calendar, id) + end + + @doc """ + Gets a calendar by user_id and name. + """ + def get_calendar_by_name(user_id, name) do + Repo.get_by(Calendar, user_id: user_id, name: name) + end + + @doc """ + Gets the default calendar for a user, creating one if needed. + """ + def get_or_create_default_calendar(user_id) do + case Repo.get_by(Calendar, user_id: user_id, is_default: true) do + nil -> + create_calendar(%{ + user_id: user_id, + name: "Default", + is_default: true, + color: Elektrine.Theme.calendar_default_color() + }) + + calendar -> + {:ok, calendar} + end + end + + @doc """ + Creates a calendar. + """ + def create_calendar(attrs) do + %Calendar{} + |> Calendar.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a calendar. + """ + def update_calendar(%Calendar{} = calendar, attrs) do + calendar + |> Calendar.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a calendar and all its events. + """ + def delete_calendar(%Calendar{} = calendar) do + Repo.delete(calendar) + end + + @doc """ + Updates the ctag for a calendar (for sync detection). + """ + def update_calendar_ctag(%Calendar{} = calendar) do + ctag = "ctag-#{DateTime.utc_now() |> DateTime.to_unix()}" + + calendar + |> Ecto.Changeset.change(%{ctag: ctag}) + |> Repo.update() + end + + # ===== EVENTS ===== + + @doc """ + Lists all events in a calendar. + """ + def list_events(calendar_id) do + from(e in Event, + where: e.calendar_id == ^calendar_id, + order_by: [asc: e.dtstart] + ) + |> Repo.all() + end + + @doc """ + Lists events in a date range. + """ + def list_events_in_range(calendar_id, start_date, end_date) do + from(e in Event, + where: e.calendar_id == ^calendar_id, + where: e.dtstart >= ^start_date and e.dtstart <= ^end_date, + order_by: [asc: e.dtstart] + ) + |> Repo.all() + end + + @doc """ + Lists events across all user's calendars in a date range. + """ + def list_user_events_in_range(user_id, start_date, end_date) do + from(e in Event, + join: c in Calendar, + on: e.calendar_id == c.id, + where: c.user_id == ^user_id, + where: e.dtstart >= ^start_date and e.dtstart <= ^end_date, + order_by: [asc: e.dtstart], + preload: [:calendar] + ) + |> Repo.all() + end + + @doc """ + Lists events that could occupy the window, including recurring series that + began before it. + + `list_user_events_in_range/3` filters on `dtstart`, which is right for + drawing a calendar and wrong for reasoning about time: a weekly meeting set + up last year has a `dtstart` far outside any window you would ask about, yet + it still occupies next Tuesday. Anything computing free/busy wants this + instead, and must expand the results through `Elektrine.Calendar.Recurrence`. + """ + def list_user_events_for_window(user_id, window_start, window_end) do + from(e in Event, + join: c in Calendar, + on: e.calendar_id == c.id, + where: c.user_id == ^user_id, + where: + (e.dtstart <= ^window_end and e.dtstart >= ^window_start) or + (not is_nil(e.rrule) and e.dtstart <= ^window_end), + order_by: [asc: e.dtstart], + preload: [:calendar] + ) + |> Repo.all() + end + + @doc """ + Busy blocks for a user across a window, with recurrence expanded. + + Times are UTC. See `Elektrine.Calendar.Availability` for why local-time + rendering is not done here. + """ + def busy_blocks(user_id, window_start, window_end) do + user_id + |> list_user_events_for_window(window_start, window_end) + |> Elektrine.Calendar.Availability.busy_blocks(window_start, window_end) + end + + @doc """ + Gets a single event. + """ + def get_event!(id) do + Repo.get!(Event, id) + end + + @doc """ + Gets an event by its UID within a calendar. + """ + def get_event_by_uid(calendar_id, uid) do + Repo.get_by(Event, calendar_id: calendar_id, uid: uid) + end + + @doc """ + Creates an event. + """ + def create_event(attrs) do + result = + %Event{} + |> Event.changeset(attrs) + |> Repo.insert() + + case result do + {:ok, event} -> + # Update calendar ctag + calendar = get_calendar!(event.calendar_id) + update_calendar_ctag(calendar) + {:ok, event} + + error -> + error + end + end + + @doc """ + Updates an event. + """ + def update_event(%Event{} = event, attrs) do + result = + event + |> Event.changeset(attrs) + |> Repo.update() + + case result do + {:ok, updated_event} -> + # Update calendar ctag + calendar = get_calendar!(updated_event.calendar_id) + update_calendar_ctag(calendar) + {:ok, updated_event} + + error -> + error + end + end + + @doc """ + Deletes an event. + """ + def delete_event(%Event{} = event) do + calendar_id = event.calendar_id + + result = Repo.delete(event) + + case result do + {:ok, _} -> + # Update calendar ctag + calendar = get_calendar!(calendar_id) + update_calendar_ctag(calendar) + result + + error -> + error + end + end + + # ===== CALDAV OPERATIONS ===== + + @doc """ + Creates or updates an event from CalDAV PUT request. + """ + def upsert_event_from_icalendar(calendar_id, uid, icalendar_data) do + case ICalendar.parse(icalendar_data) do + {:ok, event_data} -> + attrs = + Map.merge(event_data, %{ + calendar_id: calendar_id, + uid: uid, + icalendar_data: icalendar_data + }) + + case get_event_by_uid(calendar_id, uid) do + nil -> + # Create new event + %Event{} + |> Event.caldav_changeset(attrs) + |> Repo.insert() + + existing -> + # Update existing event + existing + |> Event.caldav_changeset(attrs) + |> Repo.update() + end + |> tap_update_ctag(calendar_id) + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Lists events modified since a given timestamp (for sync). + """ + def list_events_since(calendar_id, nil) do + list_events(calendar_id) + end + + def list_events_since(calendar_id, %DateTime{} = since) do + from(e in Event, + where: e.calendar_id == ^calendar_id and e.updated_at > ^since, + order_by: [asc: e.updated_at] + ) + |> Repo.all() + end + + @doc """ + Generates iCalendar data for an event if not already present. + """ + def ensure_icalendar_data(%Event{icalendar_data: nil} = event) do + {:ok, ical_data} = ICalendar.generate(event) + ical_data + end + + def ensure_icalendar_data(%Event{icalendar_data: data}), do: data + + # Private helpers + + defp tap_update_ctag({:ok, event}, calendar_id) do + calendar = get_calendar!(calendar_id) + update_calendar_ctag(calendar) + {:ok, event} + end + + defp tap_update_ctag(error, _calendar_id), do: error +end diff --git a/apps/elektrine/lib/elektrine/calendar/availability.ex b/apps/elektrine/lib/elektrine/calendar/availability.ex new file mode 100644 index 0000000..df2c343 --- /dev/null +++ b/apps/elektrine/lib/elektrine/calendar/availability.ex @@ -0,0 +1,325 @@ +defmodule Elektrine.Calendar.Availability do + @moduledoc """ + Turns a user's calendar into bookable slots. + + Two steps, kept separate because they fail differently. `busy_blocks/3` + answers "when is this person occupied", and being wrong there means + double-booking someone. `free_slots/2` answers "what may a stranger take", + and being wrong there only means offering too little. + + Busy time is derived from expanded occurrences (see + `Elektrine.Calendar.Recurrence`), skipping events that are cancelled or + marked `TRANSPARENT` — the iCalendar way of saying "this is on my calendar + but does not block me". + + ## Timezones + + Busy blocks and slots are always UTC datetimes. Availability *rules* come in + two flavours: `free_slots/2` reads windows as UTC minutes from midnight, and + `free_slots_in_zone/2` reads them as local minutes and resolves them per + local day. Anything a human configured wants the second one. + + Zone conversion passes `Tz.TimeZoneDatabase` explicitly, following the + convention in `ElektrineWeb.Components.Datetime.LocalTime`: the default + database stays `Calendar.UTCOnlyTimeZoneDatabase` so `DateTime.add/3` keeps + simple arithmetic semantics. + """ + + alias Elektrine.Calendar.Recurrence + + @type block :: %{start: DateTime.t(), end: DateTime.t()} + + @default_slot_minutes 30 + + @doc """ + Merged busy blocks for `events` overlapping the window. + + Overlapping meetings collapse into one block, so a slot is never rejected + twice for the same reason and the result reads as "occupied from X to Y". + """ + def busy_blocks(events, window_start, window_end) when is_list(events) do + events + |> Enum.reject(&free_event?/1) + |> Enum.flat_map(fn event -> + Recurrence.expand(event, window_start, window_end).occurrences + end) + |> Enum.map(&clamp(&1, window_start, window_end)) + |> Enum.reject(&(DateTime.compare(&1.start, &1.end) != :lt)) + |> merge_blocks() + end + + @doc """ + Free slots inside the window, given busy blocks and booking rules. + + Options: + + * `:busy` — blocks to avoid, from `busy_blocks/3` + * `:slot_minutes` — slot length, default 30 + * `:buffer_minutes` — padding kept clear either side of a booking + * `:min_notice_minutes` — how far ahead the first slot may be + * `:now` — the clock, injectable so tests do not depend on wall time + * `:windows` — allowed weekly windows as + `%{day: 1..7, start_minute: integer, end_minute: integer}`, in UTC + minutes from midnight. Omitted means the whole window is open. + * `:max_per_day` — cap on slots offered per UTC day + """ + def free_slots(window_start, opts \\ []) do + window_end = Keyword.fetch!(opts, :until) + busy = Keyword.get(opts, :busy, []) + slot_minutes = Keyword.get(opts, :slot_minutes, @default_slot_minutes) + buffer = Keyword.get(opts, :buffer_minutes, 0) + now = Keyword.get(opts, :now, DateTime.utc_now()) + min_notice = Keyword.get(opts, :min_notice_minutes, 0) + windows = Keyword.get(opts, :windows) + max_per_day = Keyword.get(opts, :max_per_day) + + earliest = DateTime.add(now, min_notice * 60, :second) + padded_busy = Enum.map(busy, &pad(&1, buffer)) + + window_start + |> align(slot_minutes) + |> slot_stream(slot_minutes, window_end) + |> Stream.filter(&(DateTime.compare(&1.start, earliest) != :lt)) + |> Stream.filter(&within_windows?(&1, windows)) + |> Stream.reject(&overlaps_any?(&1, padded_busy)) + |> Enum.to_list() + |> cap_per_day(max_per_day) + end + + @doc """ + Free slots for availability expressed in a local timezone. + + `free_slots/2` takes windows as UTC minutes from midnight, which is wrong for + anything a human configured: "I take calls 09:00–17:00" means local time, and + the UTC offset that corresponds to moves twice a year. This generates one + local day at a time and resolves each day's window to UTC through + `Tz.TimeZoneDatabase`, so a booking page keeps offering 09:00 across a DST + change instead of silently sliding to 08:00 or 10:00. + + Options are as `free_slots/2`, with `:timezone` required and `:windows` + interpreted as local minutes from midnight. + + Both awkward DST cases are resolved deliberately. When a window boundary + lands in a spring-forward gap the start moves to when the clock resumes and + the end stops when it jumped, so the window shrinks rather than inventing + time that did not exist. When it lands in an autumn ambiguity the widest + reading wins — the person said they are free 09:00–17:00 local, and both + 17:00s are 17:00 to them. + """ + def free_slots_in_zone(from, opts) do + timezone = Keyword.fetch!(opts, :timezone) + window_end = Keyword.fetch!(opts, :until) + windows = Keyword.get(opts, :windows) || [] + slot_minutes = Keyword.get(opts, :slot_minutes, @default_slot_minutes) + buffer = Keyword.get(opts, :buffer_minutes, 0) + now = Keyword.get(opts, :now, DateTime.utc_now()) + min_notice = Keyword.get(opts, :min_notice_minutes, 0) + max_per_day = Keyword.get(opts, :max_per_day) + busy = Keyword.get(opts, :busy, []) + + earliest = DateTime.add(now, min_notice * 60, :second) + padded_busy = Enum.map(busy, &pad(&1, buffer)) + + with {:ok, first_local} <- to_zone(from, timezone), + {:ok, last_local} <- to_zone(window_end, timezone) do + Date.range(DateTime.to_date(first_local), DateTime.to_date(last_local)) + |> Enum.flat_map(&day_slots(&1, timezone, windows, slot_minutes)) + |> Enum.filter(fn slot -> + DateTime.compare(slot.start, from) != :lt and + DateTime.compare(slot.end, window_end) != :gt and + DateTime.compare(slot.start, earliest) != :lt + end) + |> Enum.reject(&overlaps_any?(&1, padded_busy)) + |> Enum.sort_by(&DateTime.to_unix(&1.start)) + |> cap_per_local_day(max_per_day, timezone) + else + _ -> [] + end + end + + defp day_slots(date, timezone, windows, slot_minutes) do + weekday = Date.day_of_week(date) + + windows + |> Enum.filter(&(&1.day == weekday)) + |> Enum.flat_map(fn window -> + with {:ok, start_at} <- local_boundary(date, window.start_minute, timezone, :start), + {:ok, end_at} <- local_boundary(date, window.end_minute, timezone, :end), + true <- DateTime.compare(start_at, end_at) == :lt do + start_at + |> slot_stream(slot_minutes, end_at) + |> Enum.to_list() + else + _ -> [] + end + end) + end + + # Minutes are added to local midnight rather than baked into a naive + # datetime, so a window that crosses a DST boundary keeps its wall-clock + # meaning. + defp local_boundary(date, minute_of_day, timezone, position) do + naive = + date + |> NaiveDateTime.new!(~T[00:00:00]) + |> NaiveDateTime.add(minute_of_day * 60, :second) + + case DateTime.from_naive(naive, timezone, Tz.TimeZoneDatabase) do + {:ok, datetime} -> + to_utc(datetime) + + # Clocks went back: the wall-clock time happens twice. Take the widest + # reading of the window. + {:ambiguous, first, second} -> + to_utc(if position == :start, do: first, else: second) + + # Clocks went forward: this wall-clock time never happened. Start when + # time resumed, end when it jumped. + {:gap, before_gap, after_gap} -> + to_utc(if position == :start, do: after_gap, else: before_gap) + + _ -> + :error + end + end + + defp to_utc(datetime) do + case DateTime.shift_zone(datetime, "Etc/UTC", Tz.TimeZoneDatabase) do + {:ok, utc} -> {:ok, utc} + _ -> :error + end + end + + defp to_zone(datetime, timezone), + do: DateTime.shift_zone(datetime, timezone, Tz.TimeZoneDatabase) + + defp cap_per_local_day(slots, nil, _timezone), do: slots + + defp cap_per_local_day(slots, max_per_day, timezone) do + slots + |> Enum.group_by(fn slot -> + case to_zone(slot.start, timezone) do + {:ok, local} -> DateTime.to_date(local) + _ -> DateTime.to_date(slot.start) + end + end) + |> Enum.flat_map(fn {_date, day_slots} -> Enum.take(day_slots, max_per_day) end) + |> Enum.sort_by(&DateTime.to_unix(&1.start)) + end + + @doc "Whether a specific slot is still bookable against `busy`." + def slot_free?(slot, busy, opts \\ []) do + buffer = Keyword.get(opts, :buffer_minutes, 0) + padded = Enum.map(busy, &pad(&1, buffer)) + + not overlaps_any?(slot, padded) + end + + # TRANSPARENT means "does not block"; CANCELLED means it is not happening. + # Treating either as busy would silently hide availability. + defp free_event?(event) do + transparency = field(event, :transparency) + status = field(event, :status) + + upcase(transparency) == "TRANSPARENT" or upcase(status) == "CANCELLED" + end + + defp upcase(nil), do: nil + defp upcase(value) when is_binary(value), do: String.upcase(value) + defp upcase(_), do: nil + + defp field(event, key) when is_map(event) do + Map.get(event, key) || Map.get(event, Atom.to_string(key)) + end + + defp clamp(%{start: block_start, end: block_end}, window_start, window_end) do + %{ + start: max_datetime(block_start, window_start), + end: min_datetime(block_end, window_end) + } + end + + defp merge_blocks(blocks) do + blocks + |> Enum.sort_by(&DateTime.to_unix(&1.start)) + |> Enum.reduce([], fn block, acc -> + case acc do + [%{end: previous_end} = previous | rest] -> + if DateTime.compare(block.start, previous_end) != :gt do + [%{previous | end: max_datetime(previous_end, block.end)} | rest] + else + [block | acc] + end + + [] -> + [block] + end + end) + |> Enum.reverse() + end + + defp pad(%{start: block_start, end: block_end}, 0), do: %{start: block_start, end: block_end} + + defp pad(%{start: block_start, end: block_end}, buffer_minutes) do + %{ + start: DateTime.add(block_start, -buffer_minutes * 60, :second), + end: DateTime.add(block_end, buffer_minutes * 60, :second) + } + end + + defp slot_stream(start, slot_minutes, window_end) do + seconds = slot_minutes * 60 + + Stream.iterate(start, &DateTime.add(&1, seconds, :second)) + |> Stream.map(&%{start: &1, end: DateTime.add(&1, seconds, :second)}) + |> Stream.take_while(&(DateTime.compare(&1.end, window_end) != :gt)) + end + + # Start slots on a clean boundary so a booking page does not offer 09:07. + defp align(datetime, slot_minutes) do + seconds = slot_minutes * 60 + unix = DateTime.to_unix(datetime) + remainder = rem(unix, seconds) + + if remainder == 0 do + datetime + else + DateTime.from_unix!(unix + (seconds - remainder)) + end + end + + defp within_windows?(_slot, nil), do: true + defp within_windows?(_slot, []), do: false + + defp within_windows?(slot, windows) do + day = Date.day_of_week(DateTime.to_date(slot.start)) + start_minute = minute_of_day(slot.start) + end_minute = start_minute + div(DateTime.diff(slot.end, slot.start, :second), 60) + + Enum.any?(windows, fn window -> + window.day == day and start_minute >= window.start_minute and + end_minute <= window.end_minute + end) + end + + defp minute_of_day(datetime), do: datetime.hour * 60 + datetime.minute + + defp overlaps_any?(slot, blocks) do + Enum.any?(blocks, fn block -> + DateTime.compare(slot.start, block.end) == :lt and + DateTime.compare(block.start, slot.end) == :lt + end) + end + + defp cap_per_day(slots, nil), do: slots + + defp cap_per_day(slots, max_per_day) do + slots + |> Enum.group_by(&DateTime.to_date(&1.start)) + |> Enum.flat_map(fn {_date, day_slots} -> Enum.take(day_slots, max_per_day) end) + |> Enum.sort_by(&DateTime.to_unix(&1.start)) + end + + defp max_datetime(a, b), do: if(DateTime.compare(a, b) == :gt, do: a, else: b) + defp min_datetime(a, b), do: if(DateTime.compare(a, b) == :lt, do: a, else: b) +end diff --git a/apps/elektrine/lib/elektrine/calendar/calendar.ex b/apps/elektrine/lib/elektrine/calendar/calendar.ex new file mode 100644 index 0000000..1a6bbd3 --- /dev/null +++ b/apps/elektrine/lib/elektrine/calendar/calendar.ex @@ -0,0 +1,45 @@ +defmodule Elektrine.Calendar.Calendar do + @moduledoc """ + Schema for user calendars. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "calendars" do + field :name, :string + field :color, :string, default: "#5f87b8" + field :description, :string + field :timezone, :string, default: "UTC" + field :is_default, :boolean, default: false + field :ctag, :string + field :order, :integer, default: 0 + + belongs_to :user, Elektrine.Accounts.User + has_many :events, Elektrine.Calendar.Event + + timestamps(type: :utc_datetime) + end + + @doc """ + Changeset for creating/updating a calendar. + """ + def changeset(calendar, attrs) do + calendar + |> cast(attrs, [:name, :color, :description, :timezone, :is_default, :order, :user_id]) + |> validate_required([:name, :user_id]) + |> validate_length(:name, max: 255) + |> validate_length(:color, max: 20) + |> validate_length(:description, max: 1000) + |> unique_constraint([:user_id, :name]) + |> generate_ctag() + end + + defp generate_ctag(changeset) do + if changeset.valid? && changeset.changes != %{} do + ctag = "ctag-#{DateTime.utc_now() |> DateTime.to_unix()}" + put_change(changeset, :ctag, ctag) + else + changeset + end + end +end diff --git a/apps/elektrine/lib/elektrine/calendar/event.ex b/apps/elektrine/lib/elektrine/calendar/event.ex new file mode 100644 index 0000000..d1f443a --- /dev/null +++ b/apps/elektrine/lib/elektrine/calendar/event.ex @@ -0,0 +1,159 @@ +defmodule Elektrine.Calendar.Event do + @moduledoc """ + Schema for calendar events. + Supports iCalendar/CalDAV properties. + """ + use Ecto.Schema + import Ecto.Changeset + + alias Elektrine.Calendar.ICalendar + + schema "calendar_events" do + field :uid, :string + field :etag, :string + field :summary, :string + field :description, :string + field :location, :string + field :url, :string + + # Time fields + field :dtstart, :utc_datetime + field :dtend, :utc_datetime + field :duration, :string + field :all_day, :boolean, default: false + field :timezone, :string + + # Recurrence + field :rrule, :string + field :rdate, {:array, :utc_datetime}, default: [] + field :exdate, {:array, :utc_datetime}, default: [] + field :recurrence_id, :utc_datetime + + # Status and classification + field :status, :string, default: "CONFIRMED" + field :transparency, :string, default: "OPAQUE" + field :classification, :string, default: "PUBLIC" + field :priority, :integer, default: 0 + + # JSON fields + field :alarms, {:array, :map}, default: [] + field :attendees, {:array, :map}, default: [] + field :organizer, :map + + # Categories/tags + field :categories, {:array, :string}, default: [] + + # Raw iCalendar data + field :icalendar_data, :string + + # Sequence for updates + field :sequence, :integer, default: 0 + + belongs_to :calendar, Elektrine.Calendar.Calendar + + timestamps(type: :utc_datetime) + end + + @doc """ + Changeset for creating/updating an event. + """ + def changeset(event, attrs) do + event + |> cast(attrs, [ + :calendar_id, + :uid, + :summary, + :description, + :location, + :url, + :dtstart, + :dtend, + :duration, + :all_day, + :timezone, + :rrule, + :rdate, + :exdate, + :recurrence_id, + :status, + :transparency, + :classification, + :priority, + :alarms, + :attendees, + :organizer, + :categories, + :icalendar_data, + :sequence + ]) + |> validate_required([:calendar_id, :dtstart]) + |> ensure_uid() + |> generate_etag() + |> increment_sequence() + |> unique_constraint([:calendar_id, :uid]) + end + + @doc """ + Changeset for CalDAV PUT requests with raw iCalendar data. + """ + def caldav_changeset(event, attrs) do + event + |> cast(attrs, [ + :calendar_id, + :uid, + :summary, + :description, + :location, + :url, + :dtstart, + :dtend, + :duration, + :all_day, + :timezone, + :rrule, + :rdate, + :exdate, + :recurrence_id, + :status, + :transparency, + :classification, + :priority, + :alarms, + :attendees, + :organizer, + :categories, + :icalendar_data, + :sequence, + :etag + ]) + |> validate_required([:calendar_id, :uid, :dtstart]) + |> generate_etag() + |> unique_constraint([:calendar_id, :uid]) + end + + defp ensure_uid(changeset) do + case get_field(changeset, :uid) do + nil -> put_change(changeset, :uid, ICalendar.generate_uid()) + _ -> changeset + end + end + + defp generate_etag(changeset) do + if changeset.valid? && changeset.changes != %{} do + etag = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) + put_change(changeset, :etag, etag) + else + changeset + end + end + + defp increment_sequence(changeset) do + if get_change(changeset, :uid) == nil && changeset.data.id do + # Updating existing event - increment sequence + current = get_field(changeset, :sequence) || 0 + put_change(changeset, :sequence, current + 1) + else + changeset + end + end +end diff --git a/apps/elektrine/lib/elektrine/calendar/icalendar.ex b/apps/elektrine/lib/elektrine/calendar/icalendar.ex new file mode 100644 index 0000000..0645c47 --- /dev/null +++ b/apps/elektrine/lib/elektrine/calendar/icalendar.ex @@ -0,0 +1,620 @@ +defmodule Elektrine.Calendar.ICalendar do + @moduledoc """ + iCalendar (RFC 5545) parser and generator for CalDAV support. + Handles VEVENT, VTODO, and VALARM components. + """ + + alias Elektrine.EmailAddresses + + @doc """ + Parse an iCalendar string into an event map. + """ + def parse(icalendar_string) when is_binary(icalendar_string) do + # Unfold continuation lines + unfolded = unfold_lines(icalendar_string) + + lines = + String.split(unfolded, ~r/\r?\n/) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + + # Find VEVENT block + case extract_vevent(lines) do + {:ok, event_lines} -> + event = parse_event_lines(event_lines) + {:ok, event} + + {:error, reason} -> + {:error, reason} + end + rescue + e -> {:error, Exception.message(e)} + end + + @doc """ + Generate an iCalendar string from an event struct or map. + """ + def generate(event) when is_map(event) do + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Elektrine//CalDAV Server//EN", + "CALSCALE:GREGORIAN" + ] + + # Add timezone if present + tz = Map.get(event, :timezone) || Map.get(event, "timezone") + + lines = + if tz && tz != "UTC" do + lines ++ build_vtimezone(tz) + else + lines + end + + # Add VEVENT + lines = lines ++ ["BEGIN:VEVENT"] + + # Required properties + uid = Map.get(event, :uid) || Map.get(event, "uid") || generate_uid() + dtstart = Map.get(event, :dtstart) || Map.get(event, "dtstart") + dtstamp = DateTime.utc_now() + + lines = + lines ++ + [ + "UID:#{uid}", + "DTSTAMP:#{format_datetime(dtstamp)}" + ] + + # Start time + all_day = Map.get(event, :all_day, false) || Map.get(event, "all_day", false) + lines = lines ++ [format_dtstart(dtstart, all_day, tz)] + + # End time or duration + dtend = Map.get(event, :dtend) || Map.get(event, "dtend") + duration = Map.get(event, :duration) || Map.get(event, "duration") + + lines = + cond do + dtend -> lines ++ [format_dtend(dtend, all_day, tz)] + duration -> lines ++ ["DURATION:#{duration}"] + true -> lines + end + + # Summary (title) + summary = Map.get(event, :summary) || Map.get(event, "summary") + lines = if summary, do: lines ++ ["SUMMARY:#{escape_value(summary)}"], else: lines + + # Description + description = Map.get(event, :description) || Map.get(event, "description") + lines = if description, do: lines ++ ["DESCRIPTION:#{escape_value(description)}"], else: lines + + # Location + location = Map.get(event, :location) || Map.get(event, "location") + lines = if location, do: lines ++ ["LOCATION:#{escape_value(location)}"], else: lines + + # URL + url = Map.get(event, :url) || Map.get(event, "url") + lines = if url, do: lines ++ ["URL:#{url}"], else: lines + + # Status + status = Map.get(event, :status, "CONFIRMED") || "CONFIRMED" + lines = lines ++ ["STATUS:#{status}"] + + # Transparency + transp = Map.get(event, :transparency, "OPAQUE") || "OPAQUE" + lines = lines ++ ["TRANSP:#{transp}"] + + # Class (classification) + classification = Map.get(event, :classification, "PUBLIC") || "PUBLIC" + lines = lines ++ ["CLASS:#{classification}"] + + # Priority + priority = Map.get(event, :priority, 0) || 0 + lines = if priority > 0, do: lines ++ ["PRIORITY:#{priority}"], else: lines + + # Recurrence + rrule = Map.get(event, :rrule) || Map.get(event, "rrule") + lines = if rrule, do: lines ++ ["RRULE:#{rrule}"], else: lines + + # Recurrence dates + rdate = Map.get(event, :rdate, []) || [] + lines = if rdate != [], do: lines ++ format_rdates(rdate), else: lines + + # Exception dates + exdate = Map.get(event, :exdate, []) || [] + lines = if exdate != [], do: lines ++ format_exdates(exdate), else: lines + + # Recurrence ID (for exceptions) + recurrence_id = Map.get(event, :recurrence_id) || Map.get(event, "recurrence_id") + + lines = + if recurrence_id, + do: lines ++ ["RECURRENCE-ID:#{format_datetime(recurrence_id)}"], + else: lines + + # Categories + categories = Map.get(event, :categories, []) || [] + + lines = + if categories != [], do: lines ++ ["CATEGORIES:#{Enum.join(categories, ",")}"], else: lines + + # Organizer + organizer = Map.get(event, :organizer) || Map.get(event, "organizer") + lines = if organizer, do: lines ++ [format_organizer(organizer)], else: lines + + # Attendees + attendees = Map.get(event, :attendees, []) || [] + lines = lines ++ Enum.flat_map(attendees, &format_attendee/1) + + # Alarms + alarms = Map.get(event, :alarms, []) || [] + lines = lines ++ Enum.flat_map(alarms, &format_alarm/1) + + # Sequence (for updates) + sequence = Map.get(event, :sequence, 0) || 0 + lines = lines ++ ["SEQUENCE:#{sequence}"] + + # Created/Last-modified + created = Map.get(event, :inserted_at) || dtstamp + modified = Map.get(event, :updated_at) || dtstamp + + lines = + lines ++ + [ + "CREATED:#{format_datetime(created)}", + "LAST-MODIFIED:#{format_datetime(modified)}" + ] + + lines = lines ++ ["END:VEVENT", "END:VCALENDAR"] + + # Fold long lines + folded = Enum.map(lines, &fold_line/1) + + {:ok, Enum.join(folded, "\r\n") <> "\r\n"} + end + + @doc """ + Generate a unique UID for an event. + """ + def generate_uid do + uuid = + :crypto.strong_rand_bytes(16) + |> Base.encode16(case: :lower) + |> String.replace(~r/(.{8})(.{4})(.{4})(.{4})(.{12})/, "\\1-\\2-\\3-\\4-\\5") + + EmailAddresses.uid(uuid) + end + + @doc """ + Generate an etag from event data. + """ + def generate_etag(event) do + data = "#{event.uid}#{event.sequence}#{event.updated_at}" + :crypto.hash(:md5, data) |> Base.encode16(case: :lower) + end + + # Private functions - Parsing + + defp unfold_lines(text) do + text + |> String.replace(~r/\r?\n[ \t]/, "") + end + + defp extract_vevent(lines) do + # Find VEVENT boundaries + result = + Enum.reduce_while(lines, {false, []}, fn line, {in_event, acc} -> + cond do + String.starts_with?(line, "BEGIN:VEVENT") -> + {:cont, {true, []}} + + String.starts_with?(line, "END:VEVENT") -> + {:halt, {false, acc}} + + in_event -> + {:cont, {true, acc ++ [line]}} + + true -> + {:cont, {in_event, acc}} + end + end) + + case result do + {_, event_lines} when event_lines != [] -> + {:ok, event_lines} + + _ -> + {:error, :no_vevent_found} + end + end + + defp parse_event_lines(lines) do + Enum.reduce(lines, %{}, fn line, acc -> + case parse_line(line) do + {property, params, value} -> + parse_property(acc, property, params, value) + + _ -> + acc + end + end) + end + + defp parse_line(line) do + case String.split(line, ":", parts: 2) do + [property_part, value] -> + {property, params} = parse_property_and_params(property_part) + {property, params, value} + + _ -> + nil + end + end + + defp parse_property_and_params(property_part) do + parts = String.split(property_part, ";") + property = List.first(parts) |> String.upcase() + + params = + parts + |> Enum.drop(1) + |> Enum.map(&parse_param/1) + |> Map.new() + + {property, params} + end + + defp parse_param(param_str) do + case String.split(param_str, "=", parts: 2) do + [key, value] -> {String.upcase(key), value} + [key] -> {String.upcase(key), true} + end + end + + defp parse_property(acc, "UID", _params, value) do + Map.put(acc, :uid, value) + end + + defp parse_property(acc, "SUMMARY", _params, value) do + Map.put(acc, :summary, unescape_value(value)) + end + + defp parse_property(acc, "DESCRIPTION", _params, value) do + Map.put(acc, :description, unescape_value(value)) + end + + defp parse_property(acc, "LOCATION", _params, value) do + Map.put(acc, :location, unescape_value(value)) + end + + defp parse_property(acc, "URL", _params, value) do + Map.put(acc, :url, value) + end + + defp parse_property(acc, "DTSTART", params, value) do + {dt, all_day, tz} = parse_datetime_value(value, params) + + acc + |> Map.put(:dtstart, dt) + |> Map.put(:all_day, all_day) + |> Map.put(:timezone, tz || Map.get(acc, :timezone)) + end + + defp parse_property(acc, "DTEND", params, value) do + {dt, _all_day, _tz} = parse_datetime_value(value, params) + Map.put(acc, :dtend, dt) + end + + defp parse_property(acc, "DURATION", _params, value) do + Map.put(acc, :duration, value) + end + + defp parse_property(acc, "RRULE", _params, value) do + Map.put(acc, :rrule, value) + end + + defp parse_property(acc, "RDATE", _params, value) do + dates = String.split(value, ",") |> Enum.map(&parse_datetime_simple/1) + current = Map.get(acc, :rdate, []) + Map.put(acc, :rdate, current ++ dates) + end + + defp parse_property(acc, "EXDATE", _params, value) do + dates = String.split(value, ",") |> Enum.map(&parse_datetime_simple/1) + current = Map.get(acc, :exdate, []) + Map.put(acc, :exdate, current ++ dates) + end + + defp parse_property(acc, "RECURRENCE-ID", _params, value) do + Map.put(acc, :recurrence_id, parse_datetime_simple(value)) + end + + defp parse_property(acc, "STATUS", _params, value) do + Map.put(acc, :status, String.upcase(value)) + end + + defp parse_property(acc, "TRANSP", _params, value) do + Map.put(acc, :transparency, String.upcase(value)) + end + + defp parse_property(acc, "CLASS", _params, value) do + Map.put(acc, :classification, String.upcase(value)) + end + + defp parse_property(acc, "PRIORITY", _params, value) do + case Integer.parse(value) do + {n, _} -> Map.put(acc, :priority, n) + :error -> acc + end + end + + defp parse_property(acc, "SEQUENCE", _params, value) do + case Integer.parse(value) do + {n, _} -> Map.put(acc, :sequence, n) + :error -> acc + end + end + + defp parse_property(acc, "CATEGORIES", _params, value) do + categories = String.split(value, ",") |> Enum.map(&String.trim/1) + Map.put(acc, :categories, categories) + end + + defp parse_property(acc, "ORGANIZER", params, value) do + organizer = %{ + "email" => extract_mailto(value), + "cn" => Map.get(params, "CN") + } + + Map.put(acc, :organizer, organizer) + end + + defp parse_property(acc, "ATTENDEE", params, value) do + attendee = %{ + "email" => extract_mailto(value), + "cn" => Map.get(params, "CN"), + "partstat" => Map.get(params, "PARTSTAT", "NEEDS-ACTION"), + "role" => Map.get(params, "ROLE", "REQ-PARTICIPANT"), + "rsvp" => Map.get(params, "RSVP") == "TRUE" + } + + current = Map.get(acc, :attendees, []) + Map.put(acc, :attendees, current ++ [attendee]) + end + + defp parse_property(acc, _property, _params, _value) do + acc + end + + defp extract_mailto(value) do + case Regex.run(~r/mailto:(.+)/i, value) do + [_, email] -> email + _ -> value + end + end + + defp parse_datetime_value(value, params) do + tzid = Map.get(params, "TZID") + is_date = Map.get(params, "VALUE") == "DATE" + + cond do + is_date || String.length(value) == 8 -> + # All-day date: YYYYMMDD + {parse_date_only(value), true, tzid} + + String.ends_with?(value, "Z") -> + # UTC datetime + {parse_datetime_simple(value), false, "UTC"} + + true -> + # Local or TZID datetime + {parse_datetime_simple(value), false, tzid} + end + end + + defp parse_date_only(value) do + year = String.slice(value, 0..3) + month = String.slice(value, 4..5) + day = String.slice(value, 6..7) + + case Date.from_iso8601("#{year}-#{month}-#{day}") do + {:ok, date} -> + DateTime.new!(date, ~T[00:00:00], "Etc/UTC") + + _ -> + nil + end + end + + defp parse_datetime_simple(value) do + # Parse YYYYMMDDTHHMMSS or YYYYMMDDTHHMMSSZ + value = String.replace(value, "Z", "") + + if String.length(value) >= 15 do + year = String.slice(value, 0..3) + month = String.slice(value, 4..5) + day = String.slice(value, 6..7) + hour = String.slice(value, 9..10) + min = String.slice(value, 11..12) + sec = String.slice(value, 13..14) + + case NaiveDateTime.from_iso8601("#{year}-#{month}-#{day}T#{hour}:#{min}:#{sec}") do + {:ok, ndt} -> DateTime.from_naive!(ndt, "Etc/UTC") + _ -> nil + end + else + parse_date_only(value) + end + end + + # Private functions - Generation + + defp format_datetime(%DateTime{} = dt) do + dt + |> DateTime.shift_zone!("Etc/UTC") + |> Calendar.strftime("%Y%m%dT%H%M%SZ") + end + + defp format_datetime(%NaiveDateTime{} = ndt) do + Calendar.strftime(ndt, "%Y%m%dT%H%M%SZ") + end + + defp format_datetime(nil), do: format_datetime(DateTime.utc_now()) + + defp format_dtstart(dt, true, _tz) do + # All-day event + date_str = Calendar.strftime(dt, "%Y%m%d") + "DTSTART;VALUE=DATE:#{date_str}" + end + + defp format_dtstart(dt, false, nil) do + "DTSTART:#{format_datetime(dt)}" + end + + defp format_dtstart(dt, false, tz) when tz in ["UTC", "Etc/UTC"] do + "DTSTART:#{format_datetime(dt)}" + end + + defp format_dtstart(dt, false, tz) do + dt_str = Calendar.strftime(dt, "%Y%m%dT%H%M%S") + "DTSTART;TZID=#{tz}:#{dt_str}" + end + + defp format_dtend(dt, true, _tz) do + date_str = Calendar.strftime(dt, "%Y%m%d") + "DTEND;VALUE=DATE:#{date_str}" + end + + defp format_dtend(dt, false, nil) do + "DTEND:#{format_datetime(dt)}" + end + + defp format_dtend(dt, false, tz) when tz in ["UTC", "Etc/UTC"] do + "DTEND:#{format_datetime(dt)}" + end + + defp format_dtend(dt, false, tz) do + dt_str = Calendar.strftime(dt, "%Y%m%dT%H%M%S") + "DTEND;TZID=#{tz}:#{dt_str}" + end + + defp format_rdates(dates) do + dates + |> Enum.filter(&(&1 != nil)) + |> Enum.map(&format_datetime/1) + |> Enum.map(&"RDATE:#{&1}") + end + + defp format_exdates(dates) do + dates + |> Enum.filter(&(&1 != nil)) + |> Enum.map(&format_datetime/1) + |> Enum.map(&"EXDATE:#{&1}") + end + + defp format_organizer(organizer) do + email = Map.get(organizer, "email") || Map.get(organizer, :email) + cn = Map.get(organizer, "cn") || Map.get(organizer, :cn) + + if cn do + "ORGANIZER;CN=#{cn}:mailto:#{email}" + else + "ORGANIZER:mailto:#{email}" + end + end + + defp format_attendee(attendee) do + email = Map.get(attendee, "email") || Map.get(attendee, :email) + cn = Map.get(attendee, "cn") || Map.get(attendee, :cn) + partstat = Map.get(attendee, "partstat") || Map.get(attendee, :partstat) || "NEEDS-ACTION" + role = Map.get(attendee, "role") || Map.get(attendee, :role) || "REQ-PARTICIPANT" + rsvp = Map.get(attendee, "rsvp", false) || Map.get(attendee, :rsvp, false) + + params = ["PARTSTAT=#{partstat}", "ROLE=#{role}"] + params = if cn, do: ["CN=#{cn}" | params], else: params + params = if rsvp, do: params ++ ["RSVP=TRUE"], else: params + + ["ATTENDEE;#{Enum.join(params, ";")}:mailto:#{email}"] + end + + defp format_alarm(alarm) do + action = Map.get(alarm, "action") || Map.get(alarm, :action) || "DISPLAY" + trigger = Map.get(alarm, "trigger") || Map.get(alarm, :trigger) || "-PT15M" + description = Map.get(alarm, "description") || Map.get(alarm, :description) || "Reminder" + + [ + "BEGIN:VALARM", + "ACTION:#{action}", + "TRIGGER:#{trigger}", + "DESCRIPTION:#{escape_value(description)}", + "END:VALARM" + ] + end + + defp build_vtimezone(_tz) do + # Simplified - in production would generate proper VTIMEZONE + [] + end + + defp unescape_value(value) when is_binary(value) do + value + |> String.replace("\\n", "\n") + |> String.replace("\\N", "\n") + |> String.replace("\\,", ",") + |> String.replace("\\;", ";") + |> String.replace("\\\\", "\\") + end + + defp unescape_value(nil), do: nil + + defp escape_value(value) when is_binary(value) do + value + |> String.replace("\\", "\\\\") + |> String.replace(",", "\\,") + |> String.replace(";", "\\;") + |> String.replace("\n", "\\n") + end + + defp fold_line(line) when byte_size(line) <= 75, do: line + + defp fold_line(line) do + do_fold(line, []) + |> Enum.reverse() + |> Enum.join("\r\n ") + end + + defp do_fold(<<>>, acc), do: acc + + defp do_fold(line, []) do + {chunk, rest} = safe_split(line, 75) + do_fold(rest, [chunk]) + end + + defp do_fold(line, acc) do + {chunk, rest} = safe_split(line, 74) + do_fold(rest, [chunk | acc]) + end + + defp safe_split(binary, max_bytes) do + if byte_size(binary) <= max_bytes do + {binary, <<>>} + else + safe_point = find_safe_split(binary, max_bytes) + {String.slice(binary, 0, safe_point), String.slice(binary, safe_point..-1//1)} + end + end + + defp find_safe_split(binary, max) do + Enum.reduce_while(max..1//-1, max, fn pos, _acc -> + chunk = :binary.part(binary, 0, pos) + + if String.valid?(chunk) do + {:halt, pos} + else + {:cont, pos - 1} + end + end) + end +end diff --git a/apps/elektrine/lib/elektrine/calendar/recurrence.ex b/apps/elektrine/lib/elektrine/calendar/recurrence.ex new file mode 100644 index 0000000..41109a6 --- /dev/null +++ b/apps/elektrine/lib/elektrine/calendar/recurrence.ex @@ -0,0 +1,353 @@ +defmodule Elektrine.Calendar.Recurrence do + @moduledoc """ + Expands an event's RRULE, RDATE, and EXDATE into concrete occurrences. + + Recurrence was stored and round-tripped through iCalendar but never expanded, + which is fine for showing a calendar (clients expand it themselves) and wrong + for anything that reasons about time. Availability is the case that matters: + without expansion a weekly standup created six months ago looks like a single + past event, and a booking system built on that will cheerfully sell a slot on + top of it. + + ## What is supported + + `FREQ` of `DAILY`, `WEEKLY`, `MONTHLY`, and `YEARLY`, plus `INTERVAL`, + `COUNT`, `UNTIL`, and `BYDAY` for weekly rules. That covers ordinary human + calendars — standups, one-to-ones, classes. + + ## What is not + + `BYSETPOS`, `BYMONTHDAY`, `BYYEARDAY`, `BYWEEKNO`, and `BYDAY` with ordinals + ("3rd Thursday") are not expanded; those events yield their base occurrence + only. `expand/3` reports this through `unsupported?` so a caller that must not + under-report busy time can decide what to do rather than being quietly wrong. + + Everything here works in UTC. Local-time rendering is the caller's problem, + and currently needs a real time zone database — the app is configured with + `Calendar.UTCOnlyTimeZoneDatabase`. + """ + + @type occurrence :: %{start: DateTime.t(), end: DateTime.t()} + + @weekdays %{ + "MO" => 1, + "TU" => 2, + "WE" => 3, + "TH" => 4, + "FR" => 5, + "SA" => 6, + "SU" => 7 + } + + @supported_freqs ~w(DAILY WEEKLY MONTHLY YEARLY) + @unsupported_parts ~w(BYSETPOS BYMONTHDAY BYYEARDAY BYWEEKNO BYMONTH BYHOUR BYMINUTE) + + # A recurring event with no UNTIL/COUNT is infinite; expansion is always + # bounded by the requested window, but this caps pathological INTERVAL=1 + # DAILY rules over a decade-long window. + @max_occurrences 1000 + + @doc """ + Expands `event` into the occurrences overlapping `window_start..window_end`. + + Returns `%{occurrences: [occurrence], unsupported?: boolean}`. An event with + no recurrence yields at most its own single occurrence. + """ + def expand(event, window_start, window_end) do + case to_datetime(event_field(event, :dtstart)) do + %DateTime{} = dtstart -> + duration = duration_seconds(event, dtstart) + rrule = parse_rrule(event_field(event, :rrule)) + + occurrences = + dtstart + |> occurrence_starts(rrule, window_start, window_end) + |> Enum.concat(rdates(event)) + |> Enum.uniq_by(&DateTime.to_unix/1) + |> reject_exdates(exdates(event)) + |> Enum.map(&%{start: &1, end: DateTime.add(&1, duration, :second)}) + |> Enum.filter(&overlaps?(&1, window_start, window_end)) + |> Enum.sort_by(&DateTime.to_unix(&1.start)) + + %{occurrences: occurrences, unsupported?: unsupported?(rrule)} + + _ -> + %{occurrences: [], unsupported?: false} + end + end + + @doc "Whether a parsed rule uses parts this module does not expand." + def unsupported?(nil), do: false + + def unsupported?(rule) do + cond do + rule[:freq] not in @supported_freqs -> true + Enum.any?(@unsupported_parts, &Map.has_key?(rule, &1)) -> true + ordinal_byday?(rule[:byday]) -> true + true -> false + end + end + + @doc false + def parse_rrule(nil), do: nil + def parse_rrule(""), do: nil + + def parse_rrule(rrule) when is_binary(rrule) do + rrule + |> String.replace_prefix("RRULE:", "") + |> String.split(";", trim: true) + |> Enum.reduce(%{}, fn part, acc -> + case String.split(part, "=", parts: 2) do + [key, value] -> put_rule_part(acc, String.upcase(String.trim(key)), String.trim(value)) + _ -> acc + end + end) + |> case do + empty when map_size(empty) == 0 -> nil + rule -> rule + end + end + + def parse_rrule(_), do: nil + + defp put_rule_part(acc, "FREQ", value), do: Map.put(acc, :freq, String.upcase(value)) + + defp put_rule_part(acc, "INTERVAL", value) do + case Integer.parse(value) do + {interval, ""} when interval > 0 -> Map.put(acc, :interval, interval) + _ -> acc + end + end + + defp put_rule_part(acc, "COUNT", value) do + case Integer.parse(value) do + {count, ""} when count > 0 -> Map.put(acc, :count, count) + _ -> acc + end + end + + defp put_rule_part(acc, "UNTIL", value) do + case parse_ical_datetime(value) do + %DateTime{} = until -> Map.put(acc, :until, until) + _ -> acc + end + end + + defp put_rule_part(acc, "BYDAY", value) do + days = value |> String.split(",", trim: true) |> Enum.map(&String.upcase(String.trim(&1))) + Map.put(acc, :byday, days) + end + + defp put_rule_part(acc, "WKST", _value), do: acc + defp put_rule_part(acc, key, value), do: Map.put(acc, key, value) + + # No rule: the event happens once. + defp occurrence_starts(dtstart, nil, _window_start, _window_end), do: [dtstart] + + defp occurrence_starts(dtstart, rule, window_start, window_end) do + if unsupported?(rule) do + [dtstart] + else + dtstart + |> generate(rule, window_end) + |> Enum.filter(&(DateTime.compare(&1, window_start) != :lt or true)) + |> Enum.take(@max_occurrences) + end + end + + defp generate(dtstart, rule, window_end) do + interval = Map.get(rule, :interval, 1) + limit = expansion_limit(rule, window_end) + + starts = + case rule[:freq] do + "WEEKLY" -> + weekly_starts(dtstart, rule, interval, limit) + + "DAILY" -> + step_starts(dtstart, limit, fn n -> shift_days(dtstart, n * interval) end) + + "MONTHLY" -> + step_starts(dtstart, limit, fn n -> shift_months(dtstart, n * interval) end) + + "YEARLY" -> + step_starts(dtstart, limit, fn n -> shift_months(dtstart, n * 12 * interval) end) + + _ -> + [dtstart] + end + + apply_count(starts, rule) + end + + # UNTIL, when present, can end the series before the window does. + defp expansion_limit(rule, window_end) do + case rule[:until] do + %DateTime{} = until -> + if DateTime.compare(until, window_end) == :lt, do: until, else: window_end + + _ -> + window_end + end + end + + defp step_starts(dtstart, limit, step_fun) do + Stream.iterate(0, &(&1 + 1)) + |> Stream.map(step_fun) + |> Stream.take_while(&(DateTime.compare(&1, limit) != :gt)) + |> Enum.take(@max_occurrences) + |> case do + [] -> [dtstart] + starts -> starts + end + end + + defp weekly_starts(dtstart, rule, interval, limit) do + days = + case rule[:byday] do + days when is_list(days) and days != [] -> + days |> Enum.map(&@weekdays[&1]) |> Enum.reject(&is_nil/1) + + _ -> + [Date.day_of_week(DateTime.to_date(dtstart))] + end + + week_start = start_of_week(dtstart) + + Stream.iterate(0, &(&1 + 1)) + |> Stream.map(&shift_days(week_start, &1 * 7 * interval)) + |> Stream.take_while(&(DateTime.compare(&1, limit) != :gt)) + |> Stream.flat_map(fn week -> + Enum.map(days, fn weekday -> shift_days(week, weekday - 1) end) + end) + |> Stream.filter(fn occurrence -> + DateTime.compare(occurrence, dtstart) != :lt and + DateTime.compare(occurrence, limit) != :gt + end) + |> Enum.take(@max_occurrences) + end + + defp apply_count(starts, rule) do + case rule[:count] do + count when is_integer(count) -> Enum.take(starts, count) + _ -> starts + end + end + + defp start_of_week(datetime) do + weekday = Date.day_of_week(DateTime.to_date(datetime)) + shift_days(datetime, -(weekday - 1)) + end + + defp shift_days(datetime, days), do: DateTime.add(datetime, days * 86_400, :second) + + # Month arithmetic clamps rather than rolling over, so a rule anchored on the + # 31st lands on the last day of a short month instead of skipping into the + # next one. + defp shift_months(%DateTime{} = datetime, months) do + date = DateTime.to_date(datetime) + total = date.year * 12 + (date.month - 1) + months + year = div(total, 12) + month = rem(total, 12) + 1 + day = min(date.day, Date.days_in_month(%Date{year: year, month: month, day: 1})) + + case Date.new(year, month, day) do + {:ok, shifted} -> + %{datetime | year: shifted.year, month: shifted.month, day: shifted.day} + + _ -> + datetime + end + end + + defp rdates(event) do + event + |> event_field(:rdate) + |> List.wrap() + |> Enum.map(&to_datetime/1) + |> Enum.reject(&is_nil/1) + end + + defp exdates(event) do + event + |> event_field(:exdate) + |> List.wrap() + |> Enum.map(&to_datetime/1) + |> Enum.reject(&is_nil/1) + |> MapSet.new(&DateTime.to_unix/1) + end + + defp reject_exdates(starts, exdates) do + Enum.reject(starts, &MapSet.member?(exdates, DateTime.to_unix(&1))) + end + + defp duration_seconds(event, dtstart) do + case to_datetime(event_field(event, :dtend)) do + %DateTime{} = dtend -> + max(DateTime.diff(dtend, dtstart, :second), 0) + + _ -> + if event_field(event, :all_day), do: 86_400, else: 3600 + end + end + + defp overlaps?(%{start: occurrence_start, end: occurrence_end}, window_start, window_end) do + DateTime.compare(occurrence_start, window_end) != :gt and + DateTime.compare(occurrence_end, window_start) != :lt + end + + defp ordinal_byday?(days) when is_list(days) do + Enum.any?(days, &(not Map.has_key?(@weekdays, &1))) + end + + defp ordinal_byday?(_days), do: false + + defp event_field(event, key) when is_map(event) do + Map.get(event, key) || Map.get(event, Atom.to_string(key)) + end + + defp to_datetime(%DateTime{} = datetime), do: datetime + + defp to_datetime(%NaiveDateTime{} = naive), do: DateTime.from_naive!(naive, "Etc/UTC") + + defp to_datetime(value) when is_binary(value), do: parse_ical_datetime(value) + defp to_datetime(_), do: nil + + defp parse_ical_datetime(value) do + case DateTime.from_iso8601(value) do + {:ok, datetime, _offset} -> + datetime + + _ -> + parse_basic_ical(value) + end + end + + defp parse_basic_ical(value) do + case Regex.run(~r/^(\d{4})(\d{2})(\d{2})T?(\d{2})?(\d{2})?(\d{2})?Z?$/, value) do + [_, y, m, d | rest] -> + [h, min, s] = Enum.map(0..2, fn i -> Enum.at(rest, i, "00") end) + + with {:ok, date} <- Date.new(int(y), int(m), int(d)), + {:ok, time} <- Time.new(int(h), int(min), int(s)), + {:ok, naive} <- NaiveDateTime.new(date, time) do + DateTime.from_naive!(naive, "Etc/UTC") + else + _ -> nil + end + + _ -> + nil + end + end + + defp int(nil), do: 0 + defp int(""), do: 0 + + defp int(value) do + case Integer.parse(value) do + {number, _} -> number + _ -> 0 + end + end +end diff --git a/apps/elektrine/lib/elektrine/calls.ex b/apps/elektrine/lib/elektrine/calls.ex new file mode 100644 index 0000000..3b9d4ff --- /dev/null +++ b/apps/elektrine/lib/elektrine/calls.ex @@ -0,0 +1,354 @@ +defmodule Elektrine.Calls do + @moduledoc """ + The Calls context for managing audio and video calls. + """ + + import Ecto.Query, warn: false + alias Elektrine.Calls.Call + alias Elektrine.Messaging.{ChatConversation, ChatConversationMember, ChatMessage} + alias Elektrine.Repo + @terminal_statuses ["ended", "rejected", "missed", "failed"] + @allowed_previous_statuses %{ + "ringing" => ["initiated"], + "active" => ["initiated", "ringing"], + "ended" => ["initiated", "ringing", "active"], + "rejected" => ["initiated", "ringing"], + "missed" => ["initiated", "ringing"], + "failed" => ["initiated", "ringing", "active"] + } + + @doc """ + Initiates a new call between two users. + """ + def initiate_call(caller_id, callee_id, call_type, conversation_id \\ nil) do + # Privacy: Check if call is allowed + case Elektrine.Privacy.can_call?(caller_id, callee_id) do + {:error, reason} -> + {:error, reason} + + {:ok, :allowed} -> + if conversation_id && !valid_call_conversation?(conversation_id, caller_id, callee_id) do + {:error, :invalid_conversation} + else + do_initiate_call(caller_id, callee_id, call_type, conversation_id) + end + end + end + + defp do_initiate_call(caller_id, callee_id, call_type, conversation_id) do + # Auto-cleanup stale calls (> 2 minutes old) + two_minutes_ago = DateTime.utc_now() |> DateTime.add(-120, :second) + + if existing_caller = get_active_call(caller_id) do + if DateTime.compare(existing_caller.inserted_at, two_minutes_ago) == :lt do + require Logger + + Logger.warning("Force ending stale call #{existing_caller.id} for caller #{caller_id}") + + end_call(existing_caller.id) + end + end + + if existing_callee = get_active_call(callee_id) do + if DateTime.compare(existing_callee.inserted_at, two_minutes_ago) == :lt do + require Logger + + Logger.warning("Force ending stale call #{existing_callee.id} for callee #{callee_id}") + + end_call(existing_callee.id) + end + end + + # Now check for active calls after cleanup + cond do + get_active_call(caller_id) -> + {:error, :caller_already_in_call} + + get_active_call(callee_id) -> + {:error, :callee_already_in_call} + + too_many_recent_calls?(caller_id) -> + {:error, :rate_limit_exceeded} + + true -> + %Call{} + |> Call.changeset(%{ + caller_id: caller_id, + callee_id: callee_id, + call_type: call_type, + conversation_id: conversation_id, + status: "initiated" + }) + |> Repo.insert() + end + end + + defp valid_call_conversation?(conversation_id, caller_id, callee_id) do + from(cm in ChatConversationMember, + join: c in ChatConversation, + on: c.id == cm.conversation_id, + where: cm.conversation_id == ^conversation_id and c.type == "dm" and is_nil(cm.left_at), + where: cm.user_id in ^[caller_id, callee_id], + select: count(cm.user_id, :distinct) + ) + |> Repo.one() == 2 + end + + @doc """ + Check if user has too many recent call attempts (rate limiting). + Max 5 calls per minute to prevent spam/DoS. + """ + def too_many_recent_calls?(user_id) do + one_minute_ago = DateTime.utc_now() |> DateTime.add(-60, :second) + + recent_call_count = + Call + |> where([c], c.caller_id == ^user_id) + |> where([c], c.inserted_at > ^one_minute_ago) + |> select([c], count(c.id)) + |> Repo.one() + + recent_call_count >= 5 + end + + @doc """ + Updates call status. + """ + def update_call_status(call_id, status, attrs \\ %{}) do + call = Repo.get!(Call, call_id) |> Repo.preload([:caller, :callee, :conversation]) + + # Don't update if status hasn't changed + if call.status == status do + {:ok, call} + else + if transition_allowed?(call.status, status) do + attrs = Map.put(attrs, :status, status) + + # Auto-set started_at when call becomes active + attrs = + if status == "active" and is_nil(call.started_at) do + Map.put(attrs, :started_at, DateTime.utc_now()) + else + attrs + end + + # Auto-set ended_at and calculate duration when call ends + attrs = + if status in @terminal_statuses and is_nil(call.ended_at) do + ended_at = DateTime.utc_now() + + duration = + if call.started_at do + DateTime.diff(ended_at, call.started_at) + else + 0 + end + + attrs + |> Map.put(:ended_at, ended_at) + |> Map.put(:duration_seconds, duration) + else + attrs + end + + changeset = Call.changeset(call, attrs) + + if changeset.valid? do + update_fields = Map.to_list(changeset.changes) + now = DateTime.utc_now() + + {updated_rows, _} = + status_update_query(call_id, status) + |> Repo.update_all(set: Keyword.put(update_fields, :updated_at, now)) + + updated_call = + Repo.get!(Call, call_id) |> Repo.preload([:caller, :callee, :conversation]) + + if updated_rows > 0 and status in @terminal_statuses do + maybe_broadcast_call_event(updated_call, status) + + if updated_call.conversation_id do + create_call_log_message(updated_call) + end + end + + {:ok, updated_call} + else + {:error, changeset} + end + else + {:ok, call} + end + end + end + + defp transition_allowed?(from_status, to_status) do + from_status in Map.get(@allowed_previous_statuses, to_status, []) + end + + defp status_update_query(call_id, status) do + from(c in Call, + where: c.id == ^call_id and c.status in ^Map.get(@allowed_previous_statuses, status, []) + ) + end + + defp maybe_broadcast_call_event(call, status) when status in @terminal_statuses do + event = + case status do + "rejected" -> :call_rejected + "missed" -> :call_missed + _ -> :call_ended + end + + Enum.each([call.caller_id, call.callee_id], fn user_id -> + Phoenix.PubSub.broadcast(Elektrine.PubSub, "user:#{user_id}", {event, call}) + end) + end + + defp maybe_broadcast_call_event(_call, _status), do: :ok + + # Create a system message in the conversation for call log + defp create_call_log_message(call) do + require Logger + + message_content = format_call_log_message(call) + + # Create system message - use a system user ID (admin or create a dedicated system user) + # Use the caller as sender because system messages require sender_id. + sender_id = call.caller_id + + %ChatMessage{} + |> ChatMessage.changeset(%{ + conversation_id: call.conversation_id, + sender_id: sender_id, + content: message_content, + message_type: "system", + visibility: "conversation", + post_type: "message", + media_metadata: %{ + call_id: call.id, + call_type: call.call_type, + call_status: call.status, + call_duration: call.duration_seconds + } + }) + |> Repo.insert() + |> case do + {:ok, message} -> + # Update conversation's last message time + import Ecto.Query + + from(c in ChatConversation, where: c.id == ^call.conversation_id) + |> Repo.update_all(set: [last_message_at: Elektrine.Time.utc_now()]) + + # Broadcast to conversation + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{call.conversation_id}", + {:new_chat_message, Repo.preload(message, [:sender])} + ) + + {:ok, message} + + error -> + Logger.error("Failed to create call log message: #{inspect(error)}") + error + end + end + + defp format_call_log_message(call) do + call_type = if call.call_type == "video", do: "Video call", else: "Audio call" + + case call.status do + "ended" -> + if call.duration_seconds && call.duration_seconds > 0 do + "#{call_type} ended - Duration: #{format_duration(call.duration_seconds)}" + else + "#{call_type} ended" + end + + "rejected" -> + "#{call_type} declined" + + "missed" -> + "Missed #{String.downcase(call_type)}" + + "failed" -> + "#{call_type} failed to connect" + + _ -> + "#{call_type} #{call.status}" + end + end + + defp format_duration(seconds) do + hours = div(seconds, 3600) + minutes = div(rem(seconds, 3600), 60) + secs = rem(seconds, 60) + + cond do + hours > 0 -> "#{hours}:#{pad(minutes)}:#{pad(secs)}" + minutes > 0 -> "#{minutes}:#{pad(secs)}" + true -> "#{secs}s" + end + end + + defp pad(num), do: String.pad_leading("#{num}", 2, "0") + + @doc """ + Gets a call by ID. + """ + def get_call(id), do: Repo.get(Call, id) + + @doc """ + Gets call with preloaded caller and callee. + """ + def get_call_with_users(id) do + Call + |> where([c], c.id == ^id) + |> preload([:caller, :callee]) + |> Repo.one() + end + + @doc """ + Finds an active call for a user. + Returns the most recent active call if multiple exist. + """ + def get_active_call(user_id) do + Call + |> where([c], c.status in ["initiated", "ringing", "active"]) + |> where([c], c.caller_id == ^user_id or c.callee_id == ^user_id) + |> order_by([c], desc: c.inserted_at) + |> limit(1) + |> preload([:caller, :callee]) + |> Repo.one() + end + + @doc """ + Ends a call. + """ + def end_call(call_id) do + update_call_status(call_id, "ended") + end + + @doc """ + Answers an incoming call. + """ + def answer_call(call_id) do + update_call_status(call_id, "active") + end + + @doc """ + Rejects a call. + """ + def reject_call(call_id) do + update_call_status(call_id, "rejected") + end + + @doc """ + Marks a call as missed. + """ + def miss_call(call_id) do + update_call_status(call_id, "missed") + end +end diff --git a/apps/elektrine/lib/elektrine/calls/call.ex b/apps/elektrine/lib/elektrine/calls/call.ex new file mode 100644 index 0000000..8d3b776 --- /dev/null +++ b/apps/elektrine/lib/elektrine/calls/call.ex @@ -0,0 +1,50 @@ +defmodule Elektrine.Calls.Call do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "calls" do + belongs_to :caller, Elektrine.Accounts.User, foreign_key: :caller_id + belongs_to :callee, Elektrine.Accounts.User, foreign_key: :callee_id + belongs_to :conversation, Elektrine.Messaging.ChatConversation + + # "audio" or "video" + field :call_type, :string + # "initiated", "ringing", "active", "ended", "rejected", "missed", "failed" + field :status, :string + field :started_at, :utc_datetime + field :ended_at, :utc_datetime + field :duration_seconds, :integer + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(call, attrs) do + call + |> cast(attrs, [ + :caller_id, + :callee_id, + :conversation_id, + :call_type, + :status, + :started_at, + :ended_at, + :duration_seconds + ]) + |> validate_required([:caller_id, :callee_id, :call_type, :status]) + |> validate_inclusion(:call_type, ["audio", "video"]) + |> validate_inclusion(:status, [ + "initiated", + "ringing", + "active", + "ended", + "rejected", + "missed", + "failed" + ]) + |> foreign_key_constraint(:caller_id) + |> foreign_key_constraint(:callee_id) + |> foreign_key_constraint(:conversation_id) + end +end diff --git a/apps/elektrine/lib/elektrine/calls/transport.ex b/apps/elektrine/lib/elektrine/calls/transport.ex new file mode 100644 index 0000000..c3dcc5e --- /dev/null +++ b/apps/elektrine/lib/elektrine/calls/transport.ex @@ -0,0 +1,118 @@ +defmodule Elektrine.Calls.Transport do + @moduledoc false + + @default_turn_ttl_seconds 3600 + @default_resume_window_seconds 300 + + def descriptor_for_user(user_id, call_id, opts \\ []) + when is_integer(user_id) and is_integer(call_id) and is_list(opts) do + config = Application.get_env(:elektrine, :webrtc, []) + + %{ + "mode" => transport_mode(config), + "ice_servers" => ice_servers_for_user(user_id, call_id, opts), + "resume_window_seconds" => + Keyword.get(config, :resume_window_seconds, @default_resume_window_seconds), + "sfu" => maybe_sfu_descriptor(config, user_id, call_id) + } + end + + def ice_servers_for_user(user_id, call_id, opts \\ []) + when is_integer(user_id) and is_integer(call_id) and is_list(opts) do + config = Application.get_env(:elektrine, :webrtc, []) + static_servers = normalize_ice_servers(Keyword.get(config, :ice_servers, [])) + dynamic_turn_servers = build_dynamic_turn_servers(config, user_id, call_id, opts) + + static_servers ++ dynamic_turn_servers + end + + defp transport_mode(config) do + case Keyword.get(config, :transport_mode, :mesh) do + mode when mode in [:mesh, "mesh", :sfu, "sfu"] -> + mode |> to_string() + + _ -> + "mesh" + end + end + + defp maybe_sfu_descriptor(config, user_id, call_id) do + endpoint = Keyword.get(config, :sfu_endpoint) + token_secret = Keyword.get(config, :sfu_token_secret) + + cond do + not Elektrine.Strings.present?(endpoint) -> + nil + + not Elektrine.Strings.present?(token_secret) -> + %{"endpoint" => endpoint} + + true -> + claims = %{ + "user_id" => user_id, + "call_id" => call_id, + "exp" => System.system_time(:second) + @default_turn_ttl_seconds + } + + %{ + "endpoint" => endpoint, + "token" => Phoenix.Token.sign(ElektrineWeb.Endpoint, "webrtc_sfu", claims) + } + end + end + + defp build_dynamic_turn_servers(config, user_id, call_id, opts) do + secret = Keyword.get(config, :turn_shared_secret) + uris = normalize_turn_uris(Keyword.get(config, :turn_uris, [])) + ttl_seconds = Keyword.get(config, :turn_username_ttl_seconds, @default_turn_ttl_seconds) + now = Keyword.get(opts, :now, DateTime.utc_now()) + + if is_binary(secret) and secret != "" and uris != [] do + expires_at = + now + |> DateTime.add(ttl_seconds, :second) + |> DateTime.to_unix() + + username = "#{expires_at}:#{user_id}:#{call_id}" + credential = turn_credential(secret, username) + + [%{"urls" => uris, "username" => username, "credential" => credential}] + else + [] + end + end + + defp turn_credential(secret, username) do + :crypto.mac(:hmac, :sha, secret, username) + |> Base.encode64() + end + + defp normalize_ice_servers(servers) when is_list(servers) do + Enum.map(servers, &normalize_ice_server/1) + end + + defp normalize_ice_servers(_servers), do: [] + + defp normalize_ice_server(%{} = server) do + server + |> Enum.into(%{}, fn + {key, value} when is_atom(key) -> {Atom.to_string(key), value} + pair -> pair + end) + |> Map.update("urls", [], &normalize_turn_uris/1) + end + + defp normalize_ice_server(_server), do: %{} + + defp normalize_turn_uris(uris) when is_list(uris) do + uris + |> Enum.map(fn + uri when is_binary(uri) -> String.trim(uri) + _ -> nil + end) + |> Enum.reject(&(is_nil(&1) or &1 == "")) + end + + defp normalize_turn_uris(uri) when is_binary(uri), do: [String.trim(uri)] + defp normalize_turn_uris(_uris), do: [] +end diff --git a/apps/elektrine/lib/elektrine/captcha.ex b/apps/elektrine/lib/elektrine/captcha.ex new file mode 100644 index 0000000..2f936f3 --- /dev/null +++ b/apps/elektrine/lib/elektrine/captcha.ex @@ -0,0 +1,217 @@ +defmodule Elektrine.Captcha do + @moduledoc """ + Simple server-side image captcha for Tor-protected auth flows. + Generates math problems rendered as images. + """ + + @word_numbers %{ + 1 => "one", + 2 => "two", + 3 => "three", + 4 => "four", + 5 => "five", + 6 => "six", + 7 => "seven", + 8 => "eight", + 9 => "nine", + 10 => "ten", + 11 => "eleven", + 12 => "twelve" + } + + @doc """ + Generates a new captcha. + Returns {image_binary, answer_string, token} + """ + def generate do + {text, answer} = generate_problem() + + # Generate token and sign the answer + token = generate_token(answer) + + # Generate image + image_binary = render_captcha_image(text) + + {image_binary, answer, token} + end + + defp generate_problem do + case Enum.random(1..4) do + 1 -> generate_word_math() + 2 -> generate_mixed_math() + 3 -> generate_multiply() + 4 -> generate_two_step() + end + end + + # "seven + 4 = ?" - mix words and digits + defp generate_word_math do + a = Enum.random(1..12) + b = Enum.random(1..12) + op = Enum.random([:add, :subtract]) + + a_str = if Enum.random([true, false]), do: @word_numbers[a], else: "#{a}" + b_str = if Enum.random([true, false]), do: @word_numbers[b], else: "#{b}" + + case op do + :add -> + {"#{a_str} + #{b_str} = ?", Integer.to_string(a + b)} + + :subtract -> + {max, min} = if a >= b, do: {a, b}, else: {b, a} + max_str = if Enum.random([true, false]), do: @word_numbers[max], else: "#{max}" + min_str = if Enum.random([true, false]), do: @word_numbers[min], else: "#{min}" + {"#{max_str} - #{min_str} = ?", Integer.to_string(max - min)} + end + end + + # "3 x 4 = ?" - simple multiplication + defp generate_multiply do + a = Enum.random(2..9) + b = Enum.random(2..9) + op_symbol = Enum.random(["x", "*"]) + {"#{a} #{op_symbol} #{b} = ?", Integer.to_string(a * b)} + end + + # "5 + 3 - 2 = ?" - two operations + defp generate_two_step do + a = Enum.random(5..15) + b = Enum.random(1..5) + c = Enum.random(1..5) + + case Enum.random(1..2) do + 1 -> + # a + b - c + {"#{a} + #{b} - #{c} = ?", Integer.to_string(a + b - c)} + + 2 -> + # a - b + c (ensure positive intermediate) + if a > b do + {"#{a} - #{b} + #{c} = ?", Integer.to_string(a - b + c)} + else + {"#{a} + #{b} - #{c} = ?", Integer.to_string(a + b - c)} + end + end + end + + # Regular mixed format + defp generate_mixed_math do + a = Enum.random(10..25) + b = Enum.random(3..12) + + if a > b do + {"#{a} - #{b} = ?", Integer.to_string(a - b)} + else + {"#{a} + #{b} = ?", Integer.to_string(a + b)} + end + end + + @doc """ + Verifies a captcha answer against the token. + """ + def verify(token, user_answer) when is_binary(token) and is_binary(user_answer) do + user_answer = String.trim(user_answer) + + with {:ok, decoded} <- Base.decode64(token), + [timestamp_str, answer_hash] <- String.split(decoded, ":", parts: 2), + {timestamp, ""} <- Integer.parse(timestamp_str), + :ok <- verify_timestamp(timestamp), + :ok <- verify_answer(user_answer, answer_hash) do + :ok + else + {:error, reason} -> {:error, reason} + _ -> {:error, :invalid_token} + end + end + + def verify(_, _), do: {:error, :invalid_input} + + # Token expires after 5 minutes + @token_ttl 300 + + defp generate_token(answer) do + timestamp = System.system_time(:second) + answer_hash = hash_answer(answer) + + Base.encode64("#{timestamp}:#{answer_hash}") + end + + defp hash_answer(answer) do + secret = get_secret() + + :crypto.mac(:hmac, :sha256, secret, answer) + |> Base.encode16(case: :lower) + end + + defp get_secret do + Application.get_env(:elektrine, ElektrineWeb.Endpoint)[:secret_key_base] + end + + defp verify_timestamp(timestamp) do + now = System.system_time(:second) + age = now - timestamp + + if age >= 0 and age <= @token_ttl do + :ok + else + {:error, :captcha_expired} + end + end + + defp verify_answer(user_answer, stored_hash) do + expected_hash = hash_answer(user_answer) + + if byte_size(expected_hash) == byte_size(stored_hash) and + Plug.Crypto.secure_compare(expected_hash, stored_hash) do + :ok + else + {:error, :wrong_answer} + end + end + + defp render_captcha_image(text) do + # Create a simple captcha image using Image library + # White background, black text, some noise + + width = 200 + height = 60 + + # Create base image with slight gradient/noise background + {:ok, bg} = Image.new(width, height, color: [240, 240, 245]) + + # Add some random noise lines + bg = add_noise_lines(bg, width, height) + + # Render text + text_image = + Image.Text.text!(text, + font_size: 28, + text_fill_color: [30, 30, 40], + padding: [15, 10] + ) + + # Compose text ON TOP of background + {:ok, final} = Image.compose(bg, text_image, x: 20, y: 10) + + # Convert to PNG binary + {:ok, binary} = Image.write(final, :memory, suffix: ".png") + binary + end + + defp add_noise_lines(image, width, height) do + # Add random lines to make OCR harder + Enum.reduce(1..5, image, fn _, img -> + x1 = Enum.random(0..width) + y1 = Enum.random(0..height) + x2 = Enum.random(0..width) + y2 = Enum.random(0..height) + # Vary the gray intensity + gray = Enum.random(120..180) + + case Image.Draw.line(img, x1, y1, x2, y2, color: [gray, gray, gray]) do + {:ok, new_img} -> new_img + _ -> img + end + end) + end +end diff --git a/apps/elektrine/lib/elektrine/components/copy_button.ex b/apps/elektrine/lib/elektrine/components/copy_button.ex new file mode 100644 index 0000000..4cab356 --- /dev/null +++ b/apps/elektrine/lib/elektrine/components/copy_button.ex @@ -0,0 +1,43 @@ +defmodule Elektrine.Components.CopyButton do + @moduledoc """ + Click handling for copy-to-clipboard triggers. + + The whole interaction is declared in the markup: `JS.dispatch/2` raises a + `phx:copy` event that the window listener in `assets/js/utils/clipboard.js` + picks up, and `JS.transition/2` flashes the success class for two seconds. + Neither half needs a hook, so there is no per-element `mounted`/`destroyed` + pair and no timer to clear when LiveView patches the button away. + + The listener reads the text off the element that raised the event, so a + trigger says what to copy with one of: + + * `data-copy` — the text itself + * `data-copy-target` — the id of an element to read `value`/`textContent` from + * neither — falls back to the page's `#email-address` + + `data-copy-type` picks the confirmation wording ("email", "message", + "selection", or the default "link"). + """ + use Phoenix.Component + + alias Phoenix.LiveView.JS + + @flash_ms 2000 + + @doc """ + Builds the click handler for a copy trigger. + + Pipe an existing `JS` struct in when the click does something else too — a + `JS.push/2` to the server, say — and the copy is appended to it. + + ## Examples + + + + """ + def copy(js \\ %JS{}) do + js + |> JS.dispatch("phx:copy") + |> JS.transition("copy-flash", time: @flash_ms) + end +end diff --git a/apps/elektrine/lib/elektrine/components/experimental_notice.ex b/apps/elektrine/lib/elektrine/components/experimental_notice.ex new file mode 100644 index 0000000..4696dc2 --- /dev/null +++ b/apps/elektrine/lib/elektrine/components/experimental_notice.ex @@ -0,0 +1,47 @@ +defmodule Elektrine.Components.ExperimentalNotice do + @moduledoc false + + use Phoenix.Component + + @doc "Renders a compact notice for experimental product surfaces." + attr :title, :string, default: "Experimental" + + attr :message, :string, + default: + "This area is still being tested. Behavior may change, and you should not rely on it as your only copy of important information." + + attr :class, :any, default: nil + attr :rest, :global + + def experimental_notice(assigns) do + ~H""" +
    + + + +
    +

    {@title}

    +

    {@message}

    +
    +
    + """ + end +end diff --git a/apps/elektrine/lib/elektrine/components/loaders/skeleton.ex b/apps/elektrine/lib/elektrine/components/loaders/skeleton.ex new file mode 100644 index 0000000..f76a9e8 --- /dev/null +++ b/apps/elektrine/lib/elektrine/components/loaders/skeleton.ex @@ -0,0 +1,171 @@ +defmodule Elektrine.Components.Loaders.Skeleton do + @moduledoc false + use Phoenix.Component + + @doc """ + Renders a skeleton loader for a post card. + """ + def post_skeleton(assigns) do + ~H""" +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + """ + end + + @doc """ + Renders the loading state for a feed column. + + Timeline and Communities each used to hand-roll their own, so a page + changed character between its first paint and a "load more". Route all of + them through this, picking the variant that matches what will replace it: + `:post` for the timeline card, `:discussion` for the vote-column card, + `:community` for a list of communities. + + ## Examples + + <.feed_skeleton count={5} /> + <.feed_skeleton count={2} variant={:discussion} /> + """ + attr :count, :integer, default: 4 + attr :variant, :atom, default: :post, values: [:post, :discussion, :community] + attr :label, :string, default: nil + attr :class, :any, default: nil + attr :rest, :global + + def feed_skeleton(assigns) do + # Not assign_new/3: `attr :label` already put the key there as nil. + assigns = assign(assigns, :label, assigns.label || default_skeleton_label(assigns.variant)) + + ~H""" +
    + <%= for _i <- 1..@count do %> + <%= case @variant do %> + <% :discussion -> %> + <.discussion_skeleton /> + <% :community -> %> + <.community_skeleton /> + <% _ -> %> + <.post_skeleton /> + <% end %> + <% end %> +
    + """ + end + + defp default_skeleton_label(:community), do: "Loading communities" + defp default_skeleton_label(_), do: "Loading posts" + + @doc """ + Renders a skeleton loader for a profile header. + """ + def profile_skeleton(assigns) do + ~H""" +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + """ + end + + @doc """ + Renders a skeleton loader for a reply/comment. + """ + def reply_skeleton(assigns) do + ~H""" +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + """ + end + + @doc """ + Renders a skeleton loader for a discussion post. + """ + def discussion_skeleton(assigns) do + ~H""" +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + """ + end + + @doc """ + Renders a skeleton loader for a community card. + """ + def community_skeleton(assigns) do + ~H""" +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + """ + end +end diff --git a/apps/elektrine/lib/elektrine/components/report_modal.ex b/apps/elektrine/lib/elektrine/components/report_modal.ex new file mode 100644 index 0000000..6a800c8 --- /dev/null +++ b/apps/elektrine/lib/elektrine/components/report_modal.ex @@ -0,0 +1,267 @@ +defmodule Elektrine.Components.ReportModal do + @moduledoc false + use Phoenix.LiveComponent + + alias Elektrine.Reports + + @impl true + def mount(socket) do + {:ok, + socket + |> assign(:reason, "") + |> assign(:description, "") + |> assign(:submitting, false) + |> assign(:error_message, nil) + |> assign(:cooldown_remaining, 0)} + end + + @impl true + def update(assigns, socket) do + socket = assign(socket, assigns) + cooldown = Reports.get_report_cooldown(socket.assigns.reporter_id) + {:ok, assign(socket, :cooldown_remaining, cooldown)} + end + + @impl true + def render(assigns) do + ~H""" + + """ + end + + @impl true + def handle_event("update_reason", params, socket) do + reason = params["reason"] || "" + description = params["description"] || socket.assigns.description + {:noreply, assign(socket, reason: reason, description: description)} + end + + @impl true + def handle_event("update_description", params, socket) do + reason = params["reason"] || socket.assigns.reason + description = params["description"] || "" + {:noreply, assign(socket, reason: reason, description: description)} + end + + @impl true + def handle_event("submit_report", %{"reason" => reason, "description" => description}, socket) do + socket = assign(socket, submitting: true, error_message: nil) + + case Reports.can_user_report?(socket.assigns.reporter_id) do + {:error, error_message} -> + {:noreply, socket |> assign(submitting: false) |> assign(error_message: error_message)} + + {:ok, true} -> + if Reports.already_reported?( + socket.assigns.reporter_id, + socket.assigns.reportable_type, + socket.assigns.reportable_id + ) do + {:noreply, + socket + |> assign(submitting: false) + |> assign( + error_message: + "You already reported this content. We'll keep your existing report in review." + )} + else + metadata = + Reports.build_metadata(socket.assigns.reportable_type, socket.assigns.reportable_id) + |> Map.merge(socket.assigns[:additional_metadata] || %{}) + + report_attrs = %{ + reporter_id: socket.assigns.reporter_id, + reportable_type: socket.assigns.reportable_type, + reportable_id: socket.assigns.reportable_id, + reason: reason, + description: description, + metadata: metadata + } + + case Reports.create_report(report_attrs) do + {:ok, _report} -> + send( + self(), + {:report_submitted, socket.assigns.reportable_type, socket.assigns.reportable_id} + ) + + {:noreply, socket} + + {:error, :rate_limited} -> + {:noreply, + socket + |> assign(submitting: false) + |> assign( + error_message: "You're sending reports too quickly. Please wait and try again." + )} + + {:error, :spam_detected} -> + {:noreply, + socket + |> assign(submitting: false) + |> assign( + error_message: + "Reporting is temporarily limited on your account. Please try again later." + )} + + {:error, _} -> + {:noreply, + socket + |> assign(submitting: false) + |> assign(error_message: "Couldn't submit your report. Please try again.")} + end + end + end + end + + defp format_reportable_type("user"), do: "User" + defp format_reportable_type("message"), do: "Message" + defp format_reportable_type("conversation"), do: "Conversation" + defp format_reportable_type("post"), do: "Post" + defp format_reportable_type(type), do: String.capitalize(type) + + attr :name, :string, required: true + attr :class, :string, default: nil + + defp icon(%{name: "hero-" <> _} = assigns) do + ~H""" + + """ + end + + attr :class, :string, default: nil + + defp spinner(assigns) do + ~H""" + + """ + end +end diff --git a/apps/elektrine/lib/elektrine/components/trust_level_badge.ex b/apps/elektrine/lib/elektrine/components/trust_level_badge.ex new file mode 100644 index 0000000..5c4d243 --- /dev/null +++ b/apps/elektrine/lib/elektrine/components/trust_level_badge.ex @@ -0,0 +1,98 @@ +defmodule Elektrine.Components.TrustLevelBadge do + @moduledoc """ + Trust level badge component for displaying user trust levels. + """ + use Phoenix.Component + + alias Elektrine.Theme + + attr :level, :integer, required: true + attr :show_name, :boolean, default: false + attr :size, :string, default: "md" + attr :class, :string, default: "" + + def trust_level_badge(assigns) do + level_info = Elektrine.Accounts.TrustLevel.get_level_info(assigns.level) + + size_class = + case assigns.size do + "sm" -> "w-4 h-4" + "md" -> "w-5 h-5" + "lg" -> "w-6 h-6" + "xl" -> "w-7 h-7" + _ -> "w-5 h-5" + end + + color = + case assigns.level do + 0 -> Theme.default_value("color_base_content") + 1 -> Theme.default_value("color_info") + 2 -> Theme.default_value("color_success") + 3 -> Theme.default_value("color_accent") + 4 -> Theme.default_value("color_error") + _ -> Theme.default_value("color_base_content") + end + + unique_id = "tl-gradient-#{assigns.level}-#{:erlang.unique_integer([:positive])}" + + assigns = + assigns + |> assign(:level_info, level_info) + |> assign(:size_class, size_class) + |> assign(:color, color) + |> assign(:unique_id, unique_id) + + ~H""" + + + TL{@level} - {@level_info.name} + + + + + + + + + {@level} + + + + + + + + + <%= if @show_name do %> + {@level_info.name} + <% end %> + + """ + end + + defp darken_color(hex), do: Theme.darken(hex, 0.25) +end diff --git a/apps/elektrine/lib/elektrine/components/user/avatar.ex b/apps/elektrine/lib/elektrine/components/user/avatar.ex new file mode 100644 index 0000000..604b9ec --- /dev/null +++ b/apps/elektrine/lib/elektrine/components/user/avatar.ex @@ -0,0 +1,343 @@ +defmodule Elektrine.Components.User.Avatar do + @moduledoc """ + Reusable avatar components for users and conversations. + """ + + use Phoenix.Component + alias Elektrine.Security.SafeExternalURL + + attr :size, :string, default: "md" + attr :icon, :string, default: "hero-user" + attr :class, :string, default: "" + attr :rest, :global + + def placeholder_avatar(assigns) do + ~H""" +
    + <.icon name={@icon} class={placeholder_icon_size(@size)} /> +
    + """ + end + + attr :user, :map, required: true + attr :size, :string, default: "md" + attr :online_users, :list, default: [] + attr :user_statuses, :map, default: %{} + attr :show_device, :boolean, default: false + attr :class, :string, default: "" + attr :rest, :global + + def user_avatar(assigns) do + user_id = to_string(Map.get(assigns.user, :id)) + status_data = Map.get(assigns.user_statuses, user_id, %{}) + + assigns = + assigns + |> assign(:computed_status, get_user_status(assigns)) + |> assign(:devices, Map.get(status_data, :devices, [])) + |> assign(:device_count, Map.get(status_data, :device_count, 0)) + + ~H""" +
    + <%= if @user && Map.get(@user, :avatar) do %> + {"#{Map.get(@user, + <% else %> +
    +
    + <.icon name="hero-user" class={"#{hero_icon_size(@size)} block"} /> +
    +
    + <% end %> + <%= if @computed_status do %> + 1, do: "w-5 h-5", else: "w-4 h-4") + ]} + title={device_tooltip(@devices, @device_count)} + > + <%= if @show_device && @device_count > 1 do %> + {@device_count} + <% end %> + + <% end %> + <%= if @show_device && "mobile" in @devices && @computed_status not in [nil, "offline"] do %> + + <.icon name="hero-device-phone-mobile" class="w-2 h-2 text-primary" /> + + <% end %> +
    + """ + end + + attr :conversation, :map, required: true + attr :current_user_id, :integer, required: true + attr :size, :string, default: "md" + attr :class, :string, default: "" + attr :online_users, :list, default: [] + attr :user_statuses, :map, default: %{} + attr :rest, :global + + def conversation_avatar(assigns) do + assigns = + assign( + assigns, + :safe_conversation_avatar_url, + safe_conversation_avatar_url(assigns.conversation.avatar_url) + ) + + ~H""" + <%= cond do %> + <% @conversation.type == "dm" -> %> + <% other_user = get_other_dm_user(@conversation, @current_user_id) %> + <% user_status = other_user && Map.get(@user_statuses, to_string(other_user.id)) %> + <% status = + (user_status && user_status.status) || + if other_user && to_string(other_user.id) in @online_users, do: "online", else: nil %> +
    + <%= if other_user && other_user.avatar do %> + {"#{other_user.username} + <% else %> + <%= if @safe_conversation_avatar_url do %> + conversation avatar + <% else %> +
    +
    + <.icon name="hero-user" class={"#{hero_icon_size(@size)} block"} /> +
    +
    + <% end %> + <% end %> + <%= if status do %> + + + <% end %> +
    + <% @safe_conversation_avatar_url -> %> + {"#{@conversation.name} + <% @conversation.type == "group" -> %> +
    +
    + <.icon name="hero-users" class={"#{hero_icon_size(@size)} block"} /> +
    +
    + <% @conversation.type == "channel" -> %> +
    +
    + <.icon name="hero-megaphone" class={"#{hero_icon_size(@size)} block"} /> +
    +
    + <% true -> %> +
    +
    + <.icon name="hero-chat-bubble-left-right" class={"#{hero_icon_size(@size)} block"} /> +
    +
    + <% end %> + """ + end + + attr :name, :string, required: true + attr :class, :string, default: nil + + defp icon(%{name: "hero-" <> _} = assigns) do + ~H""" + + """ + end + + defp placeholder_size_classes("2xs"), do: "w-4 h-4" + defp placeholder_size_classes("xs"), do: "w-6 h-6" + defp placeholder_size_classes("sm"), do: "w-8 h-8" + defp placeholder_size_classes("md"), do: "w-10 h-10" + defp placeholder_size_classes("lg"), do: "w-12 h-12" + defp placeholder_size_classes("xl"), do: "w-16 h-16" + defp placeholder_size_classes("2xl"), do: "w-20 h-20" + defp placeholder_size_classes("3xl"), do: "w-24 h-24" + defp placeholder_size_classes("profile"), do: "w-24 h-24 sm:w-32 sm:h-32" + defp placeholder_size_classes(_), do: "w-10 h-10" + + defp placeholder_icon_size("2xs"), do: "w-2.5 h-2.5" + defp placeholder_icon_size("xs"), do: "w-4 h-4" + defp placeholder_icon_size("sm"), do: "w-5 h-5" + defp placeholder_icon_size("md"), do: "w-6 h-6" + defp placeholder_icon_size("lg"), do: "w-8 h-8" + defp placeholder_icon_size("xl"), do: "w-10 h-10" + defp placeholder_icon_size("2xl"), do: "w-12 h-12" + defp placeholder_icon_size("3xl"), do: "w-14 h-14" + defp placeholder_icon_size("profile"), do: "w-16 h-16" + defp placeholder_icon_size(_), do: "w-6 h-6" + + defp avatar_size_classes("xs"), do: "w-6 h-6" + defp avatar_size_classes("sm"), do: "w-8 h-8" + defp avatar_size_classes("md"), do: "w-10 h-10" + defp avatar_size_classes("lg"), do: "w-12 h-12" + defp avatar_size_classes("xl"), do: "w-16 h-16" + defp avatar_size_classes("2xl"), do: "w-20 h-20" + defp avatar_size_classes("responsive"), do: "w-10 sm:w-12 lg:w-12 h-10 sm:h-12 lg:h-12" + defp avatar_size_classes(_), do: "w-10 h-10" + + defp hero_icon_size("xs"), do: "w-3 h-3" + defp hero_icon_size("sm"), do: "w-4 h-4" + defp hero_icon_size("md"), do: "w-5 h-5" + defp hero_icon_size("lg"), do: "w-6 h-6" + defp hero_icon_size("xl"), do: "w-8 h-8" + defp hero_icon_size("2xl"), do: "w-10 h-10" + defp hero_icon_size("responsive"), do: "w-4 h-4 sm:w-5 sm:h-5 lg:w-6 lg:h-6" + defp hero_icon_size(_), do: "w-5 h-5" + + defp safe_conversation_avatar_url(url) when is_binary(url) do + trimmed = String.trim(url) + + cond do + trimmed == "" -> + nil + + String.starts_with?(trimmed, "/uploads/") -> + trimmed + + String.starts_with?(trimmed, "uploads/") -> + "/" <> trimmed + + String.starts_with?(trimmed, "/") -> + nil + + true -> + case SafeExternalURL.normalize(trimmed) do + {:ok, safe_url} -> safe_url + {:error, _reason} -> nil + end + end + end + + defp safe_conversation_avatar_url(_), do: nil + + defp avatar_placeholder_style do + "background: linear-gradient(135deg, var(--theme-avatar-accent-light-color), var(--theme-avatar-accent-color));" + end + + defp get_user_status(assigns) do + user_id = to_string(Map.get(assigns.user, :id)) + + cond do + map_size(assigns.user_statuses) > 0 && Map.has_key?(assigns.user_statuses, user_id) -> + Map.get(assigns.user_statuses, user_id).status + + user_id in assigns.online_users -> + "online" + + true -> + nil + end + end + + defp status_color("online"), do: "bg-success" + defp status_color("away"), do: "bg-warning" + defp status_color("dnd"), do: "bg-error" + defp status_color("offline"), do: "bg-base-content/40" + defp status_color(_), do: "bg-success" + + defp device_tooltip([], _count), do: nil + defp device_tooltip(_devices, 0), do: nil + defp device_tooltip(_devices, 1), do: nil + + defp device_tooltip(devices, count) do + device_names = + devices + |> Enum.map(fn + "mobile" -> "Mobile" + "tablet" -> "Tablet" + "desktop" -> "Desktop" + other -> String.capitalize(other) + end) + |> Enum.map_join(", ", & &1) + + "Online on #{count} devices: #{device_names}" + end + + defp get_other_dm_user(%{type: "dm", members: members}, current_user_id) do + Enum.find(members, fn member -> + member.user_id != current_user_id and is_nil(member.left_at) + end) + |> case do + %{user: user} -> user + nil -> nil + end + end + + defp get_other_dm_user(_, _), do: nil +end diff --git a/apps/elektrine/lib/elektrine/components/user/username_effects.ex b/apps/elektrine/lib/elektrine/components/user/username_effects.ex new file mode 100644 index 0000000..f71ee93 --- /dev/null +++ b/apps/elektrine/lib/elektrine/components/user/username_effects.ex @@ -0,0 +1,244 @@ +defmodule Elektrine.Components.User.UsernameEffects do + @moduledoc false + use Phoenix.Component + + import Phoenix.HTML, only: [html_escape: 1, raw: 1, safe_to_string: 1] + alias Elektrine.Profiles.UserProfile + + import Elektrine.Components.TrustLevelBadge + import Elektrine.Components.User.VerificationBadge + + attr :user, :map, required: true + attr :display_name, :boolean, default: false + attr :show_at, :boolean, default: false + attr :show_verified, :boolean, default: true + attr :verified_size, :string, default: "sm" + attr :show_trust_level, :boolean, default: false + attr :trust_level_size, :string, default: "sm" + attr :class, :string, default: "" + attr :rest, :global + + def username_with_effects(assigns) do + if assigns.user == nil do + assigns = + assign(assigns, %{ + username: "unknown", + effect_class: "", + animation_class: "", + inline_styles: "", + verified_color: UserProfile.default(:tick_color), + is_verified: false + }) + + ~H""" + + + <%= if @show_at do %> + @ + <% end %>unknown + + + """ + else + render_username(assigns) + end + end + + defp render_username(assigns) do + {user, profile} = + cond do + is_map(assigns.user) && !Map.has_key?(assigns.user, :__struct__) -> + user = + case Map.get(assigns.user, :user_id) do + user_id when is_integer(user_id) -> + Elektrine.Repo.get(Elektrine.Accounts.User, user_id) + |> maybe_preload_profile() + |> Kernel.||(assigns.user) + + _ -> + assigns.user + end + + {user, extract_profile(user)} + + Map.has_key?(assigns.user, :__struct__) && Map.has_key?(assigns.user, :profile) -> + user = maybe_preload_profile(assigns.user) + {user, extract_profile(user)} + + true -> + {assigns.user, nil} + end + + username = + if assigns.display_name do + profile_display_name(profile) || + Map.get(user, :display_name) || + Map.get(user, :handle) || + Map.get(user, :username) + else + Map.get(user, :handle) || Map.get(user, :username) + end + + effect_class = get_effect_class(profile) + animation_class = get_animation_class(profile) + inline_styles = get_inline_styles(profile) + + verified_color = + if profile && profile.tick_color do + profile.tick_color + else + UserProfile.default(:tick_color) + end + + is_verified = Map.get(user, :verified, false) + + rendered_username = + if assigns.display_name do + username + |> html_escape() + |> safe_to_string() + |> render_custom_emojis() + else + username + end + + assigns = + assign(assigns, %{ + username: username, + rendered_username: rendered_username, + effect_class: effect_class, + animation_class: animation_class, + inline_styles: inline_styles, + verified_color: verified_color, + is_verified: is_verified + }) + + ~H""" + + + <%= if @show_at do %> + @ + <% end %> + <%= if @display_name do %> + {raw(@rendered_username)} + <% else %> + {@rendered_username} + <% end %> + + <%= if @is_verified && @show_verified do %> + <.verification_badge size={@verified_size} color={@verified_color} tooltip="Verified Account" /> + <% end %> + <%= if @show_trust_level do %> + <.trust_level_badge level={Map.get(@user, :trust_level, 0)} size={@trust_level_size} /> + <% end %> + + """ + end + + defp maybe_preload_profile(nil), do: nil + + defp maybe_preload_profile(user) do + if Ecto.assoc_loaded?(user.profile) do + user + else + Elektrine.Repo.preload(user, :profile) + end + end + + defp extract_profile(nil), do: nil + defp extract_profile(user), do: Map.get(user, :profile) + + defp profile_display_name(%{display_name: display_name}) + when is_binary(display_name) and display_name != "", + do: display_name + + defp profile_display_name(_), do: nil + defp get_effect_class(nil), do: "" + + defp get_effect_class(profile) do + case profile.username_effect do + "glow" -> "username-effect-glow" + "rainbow" -> "username-effect-rainbow" + "neon" -> "username-effect-neon" + "shadow" -> "username-effect-shadow" + "gradient" -> "username-effect-gradient" + "glitch" -> "username-effect-glitch" + "fire" -> "username-effect-fire" + "ice" -> "username-effect-ice" + "chrome" -> "username-effect-chrome" + "holographic" -> "username-effect-holographic" + "outline" -> "username-effect-outline" + "double" -> "username-effect-double" + "retro" -> "username-effect-retro" + "pixelated" -> "username-effect-pixelated" + _ -> "" + end + end + + defp get_animation_class(nil), do: "" + + defp get_animation_class(profile) do + animated_effects = ["rainbow", "glitch", "neon", "fire", "ice", "holographic"] + + if profile.username_effect in animated_effects do + case profile.username_animation_speed do + "slow" -> "username-animation-slow" + "fast" -> "username-animation-fast" + _ -> "username-animation-normal" + end + else + "" + end + end + + defp get_inline_styles(nil), do: "" + + defp get_inline_styles(profile) do + styles = [] + + styles = + case profile.username_effect do + effect when effect in ["glow", "neon", "outline", "pixelated"] -> + glow_color = profile.username_glow_color || UserProfile.default(:username_glow_color) + intensity = profile.username_glow_intensity || 10 + ["--glow-color: #{glow_color}", "--glow-intensity: #{intensity}px" | styles] + + effect when effect in ["shadow", "double"] -> + shadow_color = + profile.username_shadow_color || UserProfile.default(:username_shadow_color) + + ["--shadow-color: #{shadow_color}" | styles] + + "gradient" -> + gradient_from = + profile.username_gradient_from || UserProfile.default(:username_glow_color) + + gradient_to = + profile.username_gradient_to || Elektrine.Theme.default_value("color_secondary") + + ["--gradient-from: #{gradient_from}", "--gradient-to: #{gradient_to}" | styles] + + _ -> + styles + end + + Enum.join(styles, "; ") + end + + defp render_custom_emojis(text) when is_binary(text) do + text + |> Elektrine.Emojis.render_custom_emojis() + |> elem(0) + end + + defp render_custom_emojis(text), do: text +end diff --git a/apps/elektrine/lib/elektrine/components/user/verification_badge.ex b/apps/elektrine/lib/elektrine/components/user/verification_badge.ex new file mode 100644 index 0000000..3b3118f --- /dev/null +++ b/apps/elektrine/lib/elektrine/components/user/verification_badge.ex @@ -0,0 +1,55 @@ +defmodule Elektrine.Components.User.VerificationBadge do + @moduledoc false + use Phoenix.Component + + alias Elektrine.Theme + + attr :color, :string, default: "#5f87b8" + attr :size, :string, default: "md" + attr :class, :string, default: "" + attr :tooltip, :string, default: "Verified" + + def verification_badge(assigns) do + size_class = + case assigns.size do + "sm" -> "w-4 h-4" + "md" -> "w-5 h-5" + "lg" -> "w-6 h-6" + "xl" -> "w-7 h-7" + _ -> "w-5 h-5" + end + + assigns = assign(assigns, :size_class, size_class) + + ~H""" + + + + + + + + + + + + + {@tooltip} + + + """ + end + + defp darken_verify_color(hex), do: Theme.darken(hex, 0.25) +end diff --git a/apps/elektrine/lib/elektrine/constants.ex b/apps/elektrine/lib/elektrine/constants.ex new file mode 100644 index 0000000..dc61e8c --- /dev/null +++ b/apps/elektrine/lib/elektrine/constants.ex @@ -0,0 +1,156 @@ +defmodule Elektrine.Constants do + @moduledoc """ + Application-wide constants and limits. + + This module centralizes magic numbers used throughout the application + to improve maintainability and consistency. + """ + + # File Upload Limits (in bytes) + # 25MB + @max_chat_attachment_size 25 * 1024 * 1024 + # 250MB + @max_chat_attachment_size_admin 250 * 1024 * 1024 + # 25MB + @max_email_attachment_size 25 * 1024 * 1024 + # 250MB + @max_email_attachment_size_admin 250 * 1024 * 1024 + # 50MB + @max_email_message_size 50 * 1024 * 1024 + # 1MB + @max_favicon_size 1 * 1024 * 1024 + + def max_chat_attachment_size, do: @max_chat_attachment_size + def max_chat_attachment_size_admin, do: @max_chat_attachment_size_admin + def max_email_attachment_size, do: @max_email_attachment_size + def max_email_attachment_size_admin, do: @max_email_attachment_size_admin + def max_email_message_size, do: @max_email_message_size + def max_favicon_size, do: @max_favicon_size + + # SMTP Server Limits + # 50MB + @smtp_max_data_size 50 * 1024 * 1024 + # Reduced from 1000 to limit bot abuse + @smtp_max_connections 50 + # Drastically reduced from 50 to stop bot spam + @smtp_max_connections_per_ip 2 + # Reduced from 100 + @smtp_max_recipients 20 + # 30 seconds + @smtp_send_timeout_ms 30_000 + # Reduced from 5 min to 1 min to free connections faster + @smtp_timeout_ms 60_000 + + def smtp_max_data_size, do: @smtp_max_data_size + def smtp_max_connections, do: @smtp_max_connections + def smtp_max_connections_per_ip, do: @smtp_max_connections_per_ip + def smtp_max_recipients, do: @smtp_max_recipients + def smtp_send_timeout_ms, do: @smtp_send_timeout_ms + def smtp_timeout_ms, do: @smtp_timeout_ms + + # Mail TLS handshake limits + # Keep short so slow or abusive clients do not block native TLS listeners. + @mail_tls_handshake_timeout_ms 10_000 + + def mail_tls_handshake_timeout_ms, do: @mail_tls_handshake_timeout_ms + + # IMAP Server Limits + # Raised from 100 to better support concurrent client syncs. + @imap_max_connections 200 + # Raised from 10 so clients like Apple Mail can hold more folder sessions. + @imap_max_connections_per_ip 25 + # Raised from 30 min to reduce reconnect churn during large syncs. + @imap_connection_timeout_ms 60 * 60 * 1000 + # Raised from 10 min for slower/bulk mailbox traversal. + @imap_inactivity_timeout_ms 20 * 60 * 1000 + # 30 seconds + @imap_send_timeout_ms 30_000 + # 50MB + @imap_max_message_size 50 * 1024 * 1024 + # Apple Mail may idle one session per folder; allow additional headroom. + @imap_max_idle_per_ip 30 + # Raised from 15 min to reduce frequent idle reconnects. + @imap_idle_timeout_ms 30 * 60 * 1000 + + def imap_max_connections, do: @imap_max_connections + def imap_max_connections_per_ip, do: @imap_max_connections_per_ip + def imap_connection_timeout_ms, do: @imap_connection_timeout_ms + def imap_inactivity_timeout_ms, do: @imap_inactivity_timeout_ms + def imap_send_timeout_ms, do: @imap_send_timeout_ms + def imap_max_message_size, do: @imap_max_message_size + def imap_max_idle_per_ip, do: @imap_max_idle_per_ip + def imap_idle_timeout_ms, do: @imap_idle_timeout_ms + + # POP3 Server Limits + # Reduced from 2000 + @pop3_max_connections 50 + # Reduced from 200 + @pop3_max_connections_per_ip 3 + # 30 seconds + @pop3_send_timeout_ms 30_000 + # Reduced from 10 min to 2 min + @pop3_timeout_ms 120_000 + + def pop3_max_connections, do: @pop3_max_connections + def pop3_max_connections_per_ip, do: @pop3_max_connections_per_ip + def pop3_send_timeout_ms, do: @pop3_send_timeout_ms + def pop3_timeout_ms, do: @pop3_timeout_ms + + # HTTP/API Timeouts (in milliseconds) + # 30 seconds + @http_timeout_default 30_000 + # 5 seconds + @http_timeout_short 5_000 + # 15 seconds + @http_timeout_medium 15_000 + # 60 seconds + @http_timeout_long 60_000 + # 45 seconds + @websocket_timeout 45_000 + + def http_timeout_default, do: @http_timeout_default + def http_timeout_short, do: @http_timeout_short + def http_timeout_medium, do: @http_timeout_medium + def http_timeout_long, do: @http_timeout_long + def websocket_timeout, do: @websocket_timeout + + # Session/Auth Timeouts + # 60 days + @session_max_age_days 60 + # 60 days in seconds + @session_max_age_seconds 60 * 60 * 24 * 60 + + def session_max_age_days, do: @session_max_age_days + def session_max_age_seconds, do: @session_max_age_seconds + + # Call/WebRTC Timeouts + # 30 seconds + @call_ring_timeout_ms 30_000 + + def call_ring_timeout_ms, do: @call_ring_timeout_ms + + # User Limits + @max_profile_links 50 + @max_profile_widgets 5 + @registration_limit_per_request 1 + + def max_profile_links, do: @max_profile_links + def max_profile_widgets, do: @max_profile_widgets + def registration_limit_per_request, do: @registration_limit_per_request + + # Moderation Timeouts (in seconds) + # 5 minutes + @timeout_5min 300 + # 1 hour + @timeout_1hr 3600 + + def timeout_5min, do: @timeout_5min + def timeout_1hr, do: @timeout_1hr + + # Safe timeout parsing defaults + @parse_timeout_min 1 + @parse_timeout_max 300 + + def parse_timeout_min, do: @parse_timeout_min + def parse_timeout_max, do: @parse_timeout_max +end diff --git a/apps/elektrine/lib/elektrine/database/index_repair.ex b/apps/elektrine/lib/elektrine/database/index_repair.ex new file mode 100644 index 0000000..0e491f0 --- /dev/null +++ b/apps/elektrine/lib/elektrine/database/index_repair.ex @@ -0,0 +1,69 @@ +defmodule Elektrine.Database.IndexRepair do + @moduledoc """ + Operational helpers for repairing PostgreSQL indexes. + + These functions intentionally live outside migrations because reindexing is + database maintenance, not a schema change. + """ + + alias Elektrine.Repo + + @doc """ + Rebuilds every index in the current database without blocking writes. + """ + def reindex_database(opts \\ []) do + concurrently? = Keyword.get(opts, :concurrently, true) + database = current_database!() + + execute_reindex(:database, database, concurrently?) + end + + @doc """ + Rebuilds every index on a table. + """ + def reindex_table(table, opts \\ []) when is_binary(table) do + concurrently? = Keyword.get(opts, :concurrently, true) + execute_reindex(:table, table, concurrently?) + end + + @doc """ + Rebuilds one index. + """ + def reindex_index(index, opts \\ []) when is_binary(index) do + concurrently? = Keyword.get(opts, :concurrently, true) + execute_reindex(:index, index, concurrently?) + end + + defp execute_reindex(kind, name, concurrently?) when kind in [:database, :table, :index] do + sql = + [ + "REINDEX", + kind |> Atom.to_string() |> String.upcase(), + if(concurrently?, do: "CONCURRENTLY", else: nil), + quote_qualified_identifier(name) + ] + |> Enum.reject(&is_nil/1) + |> Enum.join(" ") + + case Repo.query(sql, [], timeout: :infinity, queue_target: 5_000, queue_interval: 60_000) do + {:ok, result} -> {:ok, %{command: sql, result: result}} + {:error, reason} -> {:error, %{command: sql, reason: reason}} + end + end + + defp current_database! do + %{rows: [[database]]} = Repo.query!("SELECT current_database()", []) + database + end + + defp quote_qualified_identifier(identifier) do + identifier + |> String.split(".") + |> Enum.map_join(".", "e_identifier/1) + end + + defp quote_identifier(identifier) do + escaped = String.replace(identifier, "\"", "\"\"") + "\"#{escaped}\"" + end +end diff --git a/apps/elektrine/lib/elektrine/dav/rate_limiter.ex b/apps/elektrine/lib/elektrine/dav/rate_limiter.ex new file mode 100644 index 0000000..fc9367f --- /dev/null +++ b/apps/elektrine/lib/elektrine/dav/rate_limiter.ex @@ -0,0 +1,23 @@ +defmodule Elektrine.DAV.RateLimiter do + @moduledoc """ + Rate limiting for CalDAV/CardDAV endpoints. + + DAV clients (iOS, macOS, Thunderbird, DAVx5) sync frequently, + so limits are more generous than API limits but still protect + against abuse. + + Limits: + - 120 requests per minute (DAV clients batch sync operations) + - 2000 requests per hour + - 15-minute lockout after exceeding limits + """ + + use Elektrine.RateLimiter, + table: :dav_rate_limiter, + limits: [ + {:minute, 120}, + {:hour, 2000} + ], + lockout: {:minutes, 15}, + cleanup_interval: {:minutes, 5} +end diff --git a/apps/elektrine/lib/elektrine/db/write_guard.ex b/apps/elektrine/lib/elektrine/db/write_guard.ex new file mode 100644 index 0000000..b984d6b --- /dev/null +++ b/apps/elektrine/lib/elektrine/db/write_guard.ex @@ -0,0 +1,67 @@ +defmodule Elektrine.DB.WriteGuard do + @moduledoc """ + Utilities for best-effort writes in contexts that may become read-only. + + Use this for non-critical writes (tracking/telemetry timestamps, last-used markers) + where authentication or request processing should continue if the database session + is read-only. + """ + + alias Elektrine.Repo + require Logger + + @default_fallback {:error, :read_only_sql_transaction} + + @doc """ + Runs `write_fun` and gracefully degrades on PostgreSQL read-only transaction errors. + + ## Options + + * `:on_read_only` - return value used when write is skipped/fails due to + read-only transaction (`{:error, :read_only_sql_transaction}` by default). + Can be a value or a zero-arity function. + + """ + def run(operation, write_fun, opts \\ []) + when is_binary(operation) and is_function(write_fun, 0) do + fallback = Keyword.get(opts, :on_read_only, @default_fallback) + + if Repo.in_transaction?() and read_only_transaction_mode?() do + log_skip(operation) + fallback_value(fallback) + else + try do + write_fun.() + rescue + error in Postgrex.Error -> + if read_only_sql_transaction?(error) do + log_skip(operation) + fallback_value(fallback) + else + reraise(error, __STACKTRACE__) + end + end + end + end + + defp fallback_value(fallback) when is_function(fallback, 0), do: fallback.() + defp fallback_value(fallback), do: fallback + + defp log_skip(operation) do + Logger.warning("Skipped #{operation}: read-only SQL transaction") + end + + defp read_only_sql_transaction?(%Postgrex.Error{ + postgres: %{code: :read_only_sql_transaction} + }), + do: true + + defp read_only_sql_transaction?(%Postgrex.Error{}), do: false + + defp read_only_transaction_mode? do + case Repo.query("SHOW transaction_read_only", []) do + {:ok, %{rows: [[mode]]}} -> mode == "on" + _ -> false + end + end +end diff --git a/apps/elektrine/lib/elektrine/developer.ex b/apps/elektrine/lib/elektrine/developer.ex new file mode 100644 index 0000000..c3600ed --- /dev/null +++ b/apps/elektrine/lib/elektrine/developer.ex @@ -0,0 +1,854 @@ +defmodule Elektrine.Developer do + @moduledoc """ + The Developer context. + + Handles Personal Access Tokens (PATs), data exports, and webhooks + for developer/hacker-friendly API access. + """ + + import Ecto.Query, warn: false + + alias Elektrine.Developer.{ + ApiToken, + DataExport, + Webhook, + WebhookDelivery, + WebhookDeliveryWorker + } + + alias Elektrine.Accounts.Authentication + alias Elektrine.HTTP.SafeFetch + alias Elektrine.Repo + alias Elektrine.Security.FilePath + + @localhost_hosts ["localhost", "127.0.0.1", "::1"] + @nerve_extension_token_name "Nerve browser extension" + @nerve_extension_scopes ["read:nerve", "write:nerve", "read:kairo", "write:kairo"] + + defp export_dir, do: Application.get_env(:elektrine, :export_dir, "/tmp/elektrine/exports") + + # ============================================================================= + # API Tokens + # ============================================================================= + + @doc """ + Lists all active (non-revoked) API tokens for a user. + """ + def list_api_tokens(user_id) do + ApiToken + |> where([t], t.user_id == ^user_id and is_nil(t.revoked_at)) + |> order_by([t], desc: t.inserted_at) + |> Repo.all() + end + + @doc """ + Gets a single API token by ID for a user. + Returns nil if not found or revoked. + """ + def get_api_token(user_id, token_id) do + ApiToken + |> where([t], t.id == ^token_id and t.user_id == ^user_id and is_nil(t.revoked_at)) + |> Repo.one() + end + + @doc """ + Gets an API token by its raw token string. + Used for authentication. + """ + def get_api_token_by_token(raw_token) do + token_hash = ApiToken.hash_token(raw_token) + + ApiToken + |> where([t], t.token_hash == ^token_hash and is_nil(t.revoked_at)) + |> preload(:user) + |> Repo.one() + end + + @doc """ + Creates a new API token for a user. + + Returns `{:ok, token}` with the raw token in the virtual `:token` field. + The raw token is only available at creation time. + + ## Options + + - `:name` - Required. Name for the token. + - `:scopes` - Required. List of scope strings. + - `:expires_at` - Optional. Expiration datetime. + """ + def create_api_token(user_id, attrs) do + with :ok <- ensure_api_token_owner_active(user_id), + :ok <- ensure_api_token_limit_available(user_id) do + insert_api_token(user_id, attrs) + end + end + + defp ensure_api_token_owner_active(user_id) do + case Repo.get(Elektrine.Accounts.User, user_id) do + nil -> + {:error, api_token_error_changeset(:user_id, "does not exist")} + + user -> + case Authentication.ensure_user_active(user) do + :ok -> :ok + {:error, _reason} -> {:error, api_token_error_changeset(:user_id, "is not active")} + end + end + end + + defp ensure_api_token_limit_available(user_id) do + if count_api_tokens(user_id) >= max_tokens_per_user() do + {:error, + api_token_error_changeset( + :name, + "token limit reached (maximum #{max_tokens_per_user()} active tokens)" + )} + else + :ok + end + end + + defp insert_api_token(user_id, attrs) do + {raw_token, token_hash, token_prefix} = ApiToken.generate_token() + + attrs = + attrs + |> Map.put(:user_id, user_id) + |> Map.put(:token_hash, token_hash) + |> Map.put(:token_prefix, token_prefix) + + case %ApiToken{} + |> ApiToken.changeset(attrs) + |> Repo.insert() do + {:ok, token} -> + # Return with raw token in virtual field (only time it's available) + {:ok, %{token | token: raw_token}} + + {:error, changeset} -> + {:error, changeset} + end + end + + defp api_token_error_changeset(field, message) do + %ApiToken{} + |> Ecto.Changeset.change() + |> Ecto.Changeset.add_error(field, message) + end + + @doc """ + Creates a replaceable PAT for the Nerve browser extension. + + Only one active extension token is kept per user. Reconnecting the extension + revokes stale extension tokens first so they do not exhaust the user's normal + PAT quota. + """ + def create_nerve_extension_token(user_id) do + expires_at = + DateTime.utc_now() + |> DateTime.add(ApiToken.default_expiration_days() * 24 * 60 * 60, :second) + |> DateTime.truncate(:second) + + create_nerve_extension_token(user_id, expires_at) + end + + def create_nerve_extension_token(user_id, expires_at) do + Repo.transaction(fn -> + revoke_nerve_extension_tokens(user_id) + + case create_api_token(user_id, %{ + name: @nerve_extension_token_name, + scopes: @nerve_extension_scopes, + expires_at: expires_at + }) do + {:ok, token} -> token + {:error, changeset} -> Repo.rollback(changeset) + end + end) + end + + @doc """ + Revokes an API token. + """ + def revoke_api_token(user_id, token_id) do + case get_api_token(user_id, token_id) do + nil -> + {:error, :not_found} + + token -> + token + |> ApiToken.revoke_changeset() + |> Repo.update() + end + end + + @doc """ + Revokes all API tokens for a user. + """ + def revoke_all_api_tokens(user_id) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + {count, _} = + ApiToken + |> where([t], t.user_id == ^user_id and is_nil(t.revoked_at)) + |> Repo.update_all(set: [revoked_at: now]) + + {:ok, count} + end + + defp revoke_nerve_extension_tokens(user_id) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + ApiToken + |> where( + [t], + t.user_id == ^user_id and is_nil(t.revoked_at) and + t.name == ^@nerve_extension_token_name + ) + |> Repo.update_all(set: [revoked_at: now]) + end + + @doc """ + Verifies an API token and returns the token struct with user if valid. + Updates last_used_at timestamp. + + Returns `{:ok, token}` or `{:error, reason}`. + """ + def verify_api_token(raw_token, ip_address \\ nil) do + case get_api_token_by_token(raw_token) do + nil -> + {:error, :invalid_token} + + token -> + cond do + ApiToken.expired?(token) -> + {:error, :token_expired} + + ApiToken.revoked?(token) -> + {:error, :token_revoked} + + Authentication.ensure_user_active(token.user) != :ok -> + {:error, :account_inactive} + + token_older_than_auth_boundary?(token) -> + {:error, :token_revoked} + + true -> + touch_api_token(token, ip_address) + + {:ok, token} + end + end + end + + defp token_older_than_auth_boundary?( + %ApiToken{user: %{auth_valid_after: %DateTime{} = valid_after}} = token + ) do + DateTime.compare(token.inserted_at, valid_after) == :lt + end + + defp token_older_than_auth_boundary?(_), do: false + + @doc """ + Counts active tokens for a user. + """ + def count_api_tokens(user_id) do + ApiToken + |> where([t], t.user_id == ^user_id and is_nil(t.revoked_at)) + |> Repo.aggregate(:count) + end + + @doc """ + Maximum number of tokens allowed per user. + """ + def max_tokens_per_user, do: 20 + + # ============================================================================= + # Webhooks + # ============================================================================= + + @doc """ + Lists webhook subscriptions for a user. + """ + def list_webhooks(user_id) do + Webhook + |> where([w], w.user_id == ^user_id) + |> order_by([w], desc: w.inserted_at) + |> Repo.all() + end + + @doc """ + Gets a webhook subscription by id for a user. + """ + def get_webhook(user_id, webhook_id) do + Webhook + |> where([w], w.id == ^webhook_id and w.user_id == ^user_id) + |> Repo.one() + end + + @doc """ + Creates a new webhook subscription. + """ + def create_webhook(user_id, attrs) do + attrs = Map.put(attrs, :user_id, user_id) + + %Webhook{} + |> Webhook.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Deletes a webhook subscription. + """ + def delete_webhook(user_id, webhook_id) do + case get_webhook(user_id, webhook_id) do + nil -> {:error, :not_found} + webhook -> Repo.delete(webhook) + end + end + + @doc """ + Rotates the signing secret for a webhook. + """ + def rotate_webhook_secret(user_id, webhook_id) do + case get_webhook(user_id, webhook_id) do + nil -> + {:error, :not_found} + + webhook -> + new_secret = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) + + webhook + |> Ecto.Changeset.change(%{secret: new_secret}) + |> Repo.update() + end + end + + @doc """ + Lists recent webhook deliveries for a user. + """ + def list_webhook_deliveries(user_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 20) + webhook_id = Keyword.get(opts, :webhook_id) + + WebhookDelivery + |> where([d], d.user_id == ^user_id) + |> maybe_filter_webhook(webhook_id) + |> order_by([d], desc: d.inserted_at) + |> limit(^limit) + |> Repo.all() + end + + @doc """ + Gets a webhook delivery by id. + """ + def get_webhook_delivery(delivery_id) do + WebhookDelivery + |> where([d], d.id == ^delivery_id) + |> preload(:webhook) + |> Repo.one() + end + + @doc """ + Gets a webhook delivery by id for a specific user. + """ + def get_webhook_delivery(user_id, delivery_id) do + WebhookDelivery + |> where([d], d.id == ^delivery_id and d.user_id == ^user_id) + |> preload(:webhook) + |> Repo.one() + end + + @doc """ + Sends a test webhook delivery. + """ + def test_webhook(user_id, webhook_id) do + case get_webhook(user_id, webhook_id) do + nil -> + {:error, :not_found} + + webhook -> + test_payload = %{ + message: "This is a test delivery from Elektrine.", + webhook_id: webhook.id, + user_id: user_id + } + + with {:ok, delivery} <- create_webhook_delivery(webhook, "webhook.test", test_payload) do + run_webhook_delivery(%{delivery | webhook: webhook}, 1) + end + end + end + + @doc """ + Replays a historical webhook delivery by creating a fresh delivery attempt. + """ + def replay_webhook_delivery(user_id, delivery_id) do + case get_webhook_delivery(user_id, delivery_id) do + nil -> + {:error, :not_found} + + %WebhookDelivery{} = delivery -> + case get_webhook(user_id, delivery.webhook_id) do + nil -> + {:error, :not_found} + + webhook -> + enqueue_webhook_delivery(webhook, delivery.event, delivery.payload) + end + end + end + + @doc """ + Delivers an event to all enabled webhooks that are subscribed to it. + """ + def deliver_event(user_id, event, payload) when is_binary(event) and is_map(payload) do + webhooks = + Webhook + |> where([w], w.user_id == ^user_id and w.enabled == true) + |> where([w], ^event in w.events) + |> Repo.all() + + Enum.map(webhooks, fn webhook -> + {webhook.id, enqueue_webhook_delivery(webhook, event, payload)} + end) + end + + @doc """ + Processes one queued webhook delivery attempt. + """ + def process_webhook_delivery(delivery_id, attempt \\ 1) do + case get_webhook_delivery(delivery_id) do + nil -> + {:error, :not_found} + + delivery -> + run_webhook_delivery(delivery, attempt) + end + end + + defp enqueue_webhook_delivery(%Webhook{} = webhook, event, payload) do + Repo.transaction(fn -> + with {:ok, delivery} <- create_webhook_delivery(webhook, event, payload), + {:ok, _job} <- + %{delivery_id: delivery.id} + |> WebhookDeliveryWorker.new() + |> Elektrine.JobQueue.insert() do + :queued + else + {:error, %Ecto.Changeset{} = changeset} -> Repo.rollback({:error, changeset}) + {:error, reason} -> Repo.rollback({:enqueue_failed, reason}) + end + end) + |> case do + {:ok, :queued} -> {:ok, :queued} + {:error, reason} -> {:error, reason} + end + end + + defp create_webhook_delivery(%Webhook{} = webhook, event, payload) do + %WebhookDelivery{} + |> WebhookDelivery.changeset(%{ + webhook_id: webhook.id, + user_id: webhook.user_id, + event: event, + event_id: Ecto.UUID.generate(), + payload: payload, + status: "pending" + }) + |> Repo.insert() + end + + defp run_webhook_delivery(%WebhookDelivery{status: "delivered"} = delivery, _attempt) do + {:ok, delivery.response_status || 200} + end + + defp run_webhook_delivery(%WebhookDelivery{} = delivery, attempt) do + webhook = delivery.webhook + + cond do + is_nil(webhook) -> + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + _ = + update_webhook_delivery_result(delivery, %{ + status: "failed", + attempt_count: attempt, + error: "Webhook not found", + last_attempted_at: timestamp + }) + + {:error, :not_found} + + not webhook.enabled -> + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + _ = + update_webhook_delivery_result(delivery, %{ + status: "failed", + attempt_count: attempt, + error: "Webhook is disabled", + last_attempted_at: timestamp + }) + + {:error, :webhook_disabled} + + true -> + deliver_webhook(webhook, delivery, attempt) + end + end + + defp deliver_webhook(%Webhook{} = webhook, %WebhookDelivery{} = delivery, attempt) do + case Webhook.validate_url(webhook.url) do + :ok -> + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + started_ms = System.monotonic_time(:millisecond) + body = build_webhook_body(delivery.event, delivery.payload, timestamp, delivery.event_id) + signature = sign_webhook_payload(webhook.secret, body) + + headers = [ + {"content-type", "application/json"}, + {"user-agent", "Elektrine-Webhooks/1.0"}, + {"x-elektrine-event", delivery.event}, + {"x-elektrine-delivery-id", delivery.event_id}, + {"x-elektrine-timestamp", DateTime.to_iso8601(timestamp)}, + {"x-elektrine-signature", "sha256=#{signature}"} + ] + + request = Finch.build(:post, webhook.url, headers, body) + duration_ms = fn -> System.monotonic_time(:millisecond) - started_ms end + + case perform_webhook_request(request) do + {:ok, %Finch.Response{status: status}} when status >= 200 and status < 300 -> + _ = + update_webhook_delivery_result(delivery, %{ + status: "delivered", + attempt_count: attempt, + response_status: status, + error: nil, + duration_ms: duration_ms.(), + last_attempted_at: timestamp, + delivered_at: timestamp + }) + + update_webhook_delivery_status(webhook, timestamp, status, nil) + {:ok, status} + + {:ok, %Finch.Response{status: status}} -> + _ = + update_webhook_delivery_result(delivery, %{ + status: "failed", + attempt_count: attempt, + response_status: status, + error: "HTTP #{status}", + duration_ms: duration_ms.(), + last_attempted_at: timestamp + }) + + update_webhook_delivery_status(webhook, timestamp, status, "HTTP #{status}") + {:error, {:http_error, status}} + + {:error, reason} -> + error_message = request_error_message(reason) + + _ = + update_webhook_delivery_result(delivery, %{ + status: "failed", + attempt_count: attempt, + response_status: nil, + error: error_message, + duration_ms: duration_ms.(), + last_attempted_at: timestamp + }) + + update_webhook_delivery_status(webhook, timestamp, nil, request_error_message(reason)) + {:error, {:request_failed, reason}} + end + + {:error, reason} -> + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + _ = + update_webhook_delivery_result(delivery, %{ + status: "failed", + attempt_count: attempt, + response_status: nil, + error: "Unsafe webhook URL: #{reason}", + duration_ms: 0, + last_attempted_at: timestamp + }) + + update_webhook_delivery_status(webhook, timestamp, nil, "Unsafe webhook URL: #{reason}") + {:error, {:unsafe_url, reason}} + end + end + + defp build_webhook_body(event, payload, timestamp, event_id) do + Jason.encode!(%{ + id: event_id, + event: event, + sent_at: DateTime.to_iso8601(timestamp), + data: payload + }) + end + + defp sign_webhook_payload(secret, payload) do + :crypto.mac(:hmac, :sha256, secret, payload) + |> Base.encode16(case: :lower) + end + + defp update_webhook_delivery_status(webhook, timestamp, status, error) do + webhook + |> Webhook.delivery_result_changeset(%{ + last_triggered_at: timestamp, + last_response_status: status, + last_error: error + }) + |> Repo.update() + end + + defp update_webhook_delivery_result(delivery, attrs) do + delivery + |> WebhookDelivery.result_changeset(attrs) + |> Repo.update() + end + + defp maybe_filter_webhook(query, nil), do: query + + defp maybe_filter_webhook(query, webhook_id) do + where(query, [d], d.webhook_id == ^webhook_id) + end + + defp perform_webhook_request(request) do + if localhost_request?(request) do + Finch.request(request, Elektrine.Finch, receive_timeout: 5_000, pool_timeout: 5_000) + else + SafeFetch.request(request, Elektrine.Finch, + receive_timeout: 5_000, + pool_timeout: 5_000, + max_body_bytes: 256_000, + allow_localhost: Elektrine.RuntimeEnv.dev_or_test?() + ) + end + end + + defp localhost_request?(%Finch.Request{scheme: :http, host: host}) + when host in @localhost_hosts, + do: true + + defp localhost_request?(_), do: false + + defp request_error_message(reason) when is_exception(reason), do: Exception.message(reason) + defp request_error_message(reason), do: inspect(reason) + + defp touch_api_token(token, ip_address) do + update_fun = fn -> + token + |> ApiToken.touch_changeset(ip_address) + |> Repo.update() + end + + if test_env?() do + _ = update_fun.() + :ok + else + Elektrine.Async.run(fn -> + try do + _ = update_fun.() + rescue + _ -> :ok + end + end) + + :ok + end + end + + defp test_env? do + Elektrine.RuntimeEnv.environment() == :test + end + + # ============================================================================= + # Data Exports + # ============================================================================= + + @doc """ + Lists recent exports for a user. + """ + def list_exports(user_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 10) + + DataExport + |> where([e], e.user_id == ^user_id) + |> order_by([e], desc: e.inserted_at) + |> limit(^limit) + |> Repo.all() + end + + @doc """ + Gets an export by ID for a user. + """ + def get_export(user_id, export_id) do + DataExport + |> where([e], e.id == ^export_id and e.user_id == ^user_id) + |> Repo.one() + end + + @doc """ + Gets an export by download token. + """ + def get_export_by_token(download_token) do + DataExport + |> where( + [e], + e.download_token == ^Elektrine.Accounts.User.hash_sensitive_token(download_token) + ) + |> preload(:user) + |> Repo.one() + end + + @doc """ + Creates a new export request. + """ + def create_export(user_id, attrs) do + attrs = Map.put(attrs, :user_id, user_id) + + %DataExport{} + |> DataExport.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Creates a new export request and enqueues background processing atomically. + """ + def create_export_and_enqueue(user_id, attrs) do + attrs = Map.put(attrs, :user_id, user_id) + + Repo.transaction(fn -> + with {:ok, export} <- + %DataExport{} + |> DataExport.changeset(attrs) + |> Repo.insert(), + {:ok, _job} <- + %{export_id: export.id} + |> Elektrine.Developer.ExportWorker.new() + |> Elektrine.JobQueue.insert() do + export + else + {:error, %Ecto.Changeset{} = changeset} -> Repo.rollback({:error, changeset}) + {:error, reason} -> Repo.rollback({:error, reason}) + end + end) + |> case do + {:ok, export} -> {:ok, export} + {:error, {:error, reason}} -> {:error, reason} + end + end + + @doc """ + Starts processing an export. + """ + def start_export(%DataExport{} = export) do + export + |> DataExport.start_changeset() + |> Repo.update() + end + + @doc """ + Marks an export as completed. + """ + def complete_export(%DataExport{} = export, file_path, file_size, item_count) do + result = + export + |> DataExport.complete_changeset(file_path, file_size, item_count) + |> Repo.update() + + case result do + {:ok, completed_export} = ok -> + broadcast_export_update(completed_export, :completed) + maybe_emit_export_completed_webhook(completed_export) + ok + + error -> + error + end + end + + @doc """ + Marks an export as failed. + """ + def fail_export(%DataExport{} = export, error) do + result = + export + |> DataExport.fail_changeset(error) + |> Repo.update() + + case result do + {:ok, failed_export} = ok -> + broadcast_export_update(failed_export, :failed) + ok + + error -> + error + end + end + + @doc """ + Records a download of an export. + """ + def record_download(%DataExport{} = export) do + export + |> DataExport.download_changeset() + |> Repo.update() + end + + @doc """ + Deletes an export and its file. + """ + def delete_export(%DataExport{} = export) do + # Delete file if it exists + if export.file_path do + case FilePath.validate_existing_file(export.file_path, export_dir()) do + {:ok, file_path} -> File.rm(file_path) + {:error, _reason} -> :ok + end + end + + Repo.delete(export) + end + + @doc """ + Gets pending exports for a user (for UI display). + """ + def get_pending_exports(user_id) do + DataExport + |> where([e], e.user_id == ^user_id and e.status in ["pending", "processing", "completed"]) + |> where([e], e.expires_at > ^DateTime.utc_now() or is_nil(e.expires_at)) + |> order_by([e], desc: e.inserted_at) + |> limit(5) + |> Repo.all() + end + + defp broadcast_export_update(%DataExport{} = export, status) do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{export.user_id}", + {:export_updated, status, export.id} + ) + end + + defp maybe_emit_export_completed_webhook(%DataExport{} = export) do + payload = %{ + export_id: export.id, + type: export.export_type, + format: export.format, + file_size: export.file_size, + item_count: export.item_count, + completed_at: export.completed_at + } + + _ = deliver_event(export.user_id, "export.completed", payload) + :ok + rescue + _ -> :ok + end +end diff --git a/apps/elektrine/lib/elektrine/developer/api_token.ex b/apps/elektrine/lib/elektrine/developer/api_token.ex new file mode 100644 index 0000000..02d5012 --- /dev/null +++ b/apps/elektrine/lib/elektrine/developer/api_token.ex @@ -0,0 +1,375 @@ +defmodule Elektrine.Developer.ApiToken do + @moduledoc """ + Schema for Personal Access Tokens (PATs). + + PATs allow users to authenticate with the API using long-lived tokens + with specific scopes, similar to GitHub/GitLab personal access tokens. + + ## Token Format + + Tokens use the format: `ekt_<32 random bytes base64>` + Example: `ekt_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6` + + The `ekt_` prefix makes tokens easy to identify in logs and code. + + ## Scopes + + Tokens can have fine-grained scopes: + - `read:email` - Read emails, folders, labels + - `write:email` - Send, delete, move emails + - `read:social` - Read posts, profile, followers + - `write:social` - Create posts, follow, like + - `read:chat` - Read conversations, messages + - `write:chat` - Send messages, create conversations + - `read:contacts` - Read contacts/addressbook + - `write:contacts` - Create/update/delete contacts + - `read:calendar` - Read calendar events + - `write:calendar` - Create/update/delete events + - `read:account` - Read account info, settings + - `write:account` - Update settings (not password) + - `read:nerve` - Read encrypted Nerve entries + - `write:nerve` - Create/update/delete encrypted Nerve entries + - `read:kairo` - Read Kairo projects and sources + - `write:kairo` - Ingest Kairo sources and manage projects + - `read:proofs` - Read identity proofs and score + - `write:proofs` - Create, check, and delete identity proofs + - `read:static_site` - Read static site deployment status + - `write:static_site` - Deploy static site files + - `read:moderation` - Read moderation queues and reports + - `write:moderation` - Resolve, dismiss, and reopen reports + - `export` - Trigger and download data exports + - `webhook` - Manage webhook subscriptions + """ + use Ecto.Schema + import Ecto.Changeset + + @valid_scopes ~w( + read:email write:email + read:social write:social + read:chat write:chat + read:contacts write:contacts + read:calendar write:calendar + read:account write:account + read:nerve write:nerve + read:kairo write:kairo + read:dns write:dns + read:proofs write:proofs + read:static_site write:static_site + read:moderation write:moderation + export webhook + ) + @token_presets [ + %{ + id: "search_read_only", + name: "Read-only search", + description: "Search across account data without write access.", + scopes: [ + "read:account", + "read:email", + "read:chat", + "read:social", + "read:contacts", + "read:calendar" + ] + }, + %{ + id: "calendar_sync", + name: "Calendar sync", + description: "Read and write calendar events for sync tools or bots.", + scopes: ["read:calendar", "write:calendar"] + }, + %{ + id: "backup_export", + name: "Backup and export", + description: "Trigger and download account exports.", + scopes: ["export"] + }, + %{ + id: "webhook_admin", + name: "Webhook admin", + description: "Create, test, rotate, and inspect webhook subscriptions.", + scopes: ["webhook"] + }, + %{ + id: "nerve_access", + name: "Nerve access", + description: "Read and write encrypted Nerve entries.", + scopes: ["read:nerve", "write:nerve"] + }, + %{ + id: "kairo_ingest", + name: "Kairo ingest", + description: "Ingest and inspect sources in Kairo.", + scopes: ["read:kairo", "write:kairo"] + }, + %{ + id: "dns_admin", + name: "DNS admin", + description: "Read and write managed DNS zones and records.", + scopes: ["read:dns", "write:dns"] + }, + %{ + id: "identity_proofs", + name: "Identity proofs", + description: "Create, check, and read Identity proofs.", + scopes: ["read:proofs", "write:proofs"] + }, + %{ + id: "static_site_deploy", + name: "Static site deploy", + description: "Deploy static site files from CI/CD.", + scopes: ["read:static_site", "write:static_site"] + }, + %{ + id: "moderation_tools", + name: "Moderation tools", + description: "Review reports and perform moderation actions.", + scopes: ["read:moderation", "write:moderation"] + } + ] + + schema "api_tokens" do + field :name, :string + field :token_hash, :string + field :token_prefix, :string + field :scopes, {:array, :string}, default: [] + field :last_used_at, :utc_datetime + field :last_used_ip, :string + field :expires_at, :utc_datetime + field :revoked_at, :utc_datetime + + # Virtual field for returning the token once on creation + field :token, :string, virtual: true + + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + @doc """ + Returns list of all valid scopes. + """ + def valid_scopes, do: @valid_scopes + + @doc """ + Returns recommended token presets for common integrations. + """ + def token_presets, do: @token_presets + + @doc """ + Returns the preferred default token expiration in days. + """ + def default_expiration_days, do: 90 + + @doc """ + Returns the scopes for a preset id. + """ + def preset_scopes(preset_id) when is_binary(preset_id) do + case Enum.find(@token_presets, &(&1.id == preset_id)) do + %{scopes: scopes} -> scopes + _ -> [] + end + end + + def preset_scopes(_preset_id), do: [] + + @doc """ + Finds a preset that matches a scope list exactly. + """ + def preset_for_scopes(scopes) when is_list(scopes) do + normalized = normalize_scope_list(scopes) + + Enum.find_value(@token_presets, "custom", fn preset -> + if normalize_scope_list(preset.scopes) == normalized, do: preset.id + end) + end + + def preset_for_scopes(_), do: "custom" + + @doc """ + Returns scopes grouped by category for UI display. + """ + def scopes_by_category do + %{ + "Email" => [ + {"read:email", "Read emails, folders, and labels"}, + {"write:email", "Send, delete, and move emails"} + ], + "Social" => [ + {"read:social", "Read posts, profile, and followers"}, + {"write:social", "Create posts, follow users, and like content"} + ], + "Chat" => [ + {"read:chat", "Read conversations and messages"}, + {"write:chat", "Send messages and create conversations"} + ], + "Contacts" => [ + {"read:contacts", "Read contacts and addressbook"}, + {"write:contacts", "Create, update, and delete contacts"} + ], + "Calendar" => [ + {"read:calendar", "Read calendar events"}, + {"write:calendar", "Create, update, and delete events"} + ], + "Account" => [ + {"read:account", "Read account info and settings"}, + {"write:account", "Update account settings"} + ], + "Nerve" => [ + {"read:nerve", "Read encrypted Nerve entries"}, + {"write:nerve", "Create, update, and delete Nerve entries"} + ], + "Kairo" => [ + {"read:kairo", "Read Kairo projects and sources"}, + {"write:kairo", "Ingest Kairo sources and manage projects"} + ], + "DNS" => [ + {"read:dns", "Read managed DNS zones and records"}, + {"write:dns", "Create, update, verify, and delete DNS zones and records"} + ], + "Identity" => [ + {"read:proofs", "Read identity proofs and personhood score"}, + {"write:proofs", "Create, check, and delete identity proofs"} + ], + "Static Site" => [ + {"read:static_site", "Read static site deployment status"}, + {"write:static_site", "Deploy static site files"} + ], + "Moderation" => [ + {"read:moderation", "Read moderation queues and reports"}, + {"write:moderation", "Resolve, dismiss, and reopen reports"} + ], + "Developer" => [ + {"export", "Trigger and download data exports"}, + {"webhook", "Manage webhook subscriptions"} + ] + } + end + + @doc """ + Changeset for creating a new API token. + """ + def changeset(api_token, attrs) do + api_token + |> cast(attrs, [:name, :token_hash, :token_prefix, :scopes, :expires_at, :user_id]) + |> validate_required([:name, :token_hash, :token_prefix, :user_id]) + |> validate_length(:name, min: 1, max: 100) + |> update_change(:scopes, &normalize_scope_list/1) + |> validate_scope_selection() + |> validate_scopes() + |> unique_constraint(:token_hash) + |> foreign_key_constraint(:user_id) + end + + @doc """ + Changeset for revoking a token. + """ + def revoke_changeset(api_token) do + api_token + |> change(%{revoked_at: DateTime.utc_now() |> DateTime.truncate(:second)}) + end + + @doc """ + Changeset for updating last used info. + """ + def touch_changeset(api_token, ip_address \\ nil) do + changes = %{last_used_at: DateTime.utc_now() |> DateTime.truncate(:second)} + changes = if ip_address, do: Map.put(changes, :last_used_ip, ip_address), else: changes + + api_token + |> change(changes) + end + + @doc """ + Generate a new API token. + Returns {raw_token, token_hash, token_prefix}. + """ + def generate_token do + raw_token = "ekt_" <> Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) + token_hash = hash_token(raw_token) + token_prefix = String.slice(raw_token, 0, 12) + {raw_token, token_hash, token_prefix} + end + + @doc """ + Hash a token for storage using SHA256. + """ + def hash_token(token) do + :crypto.hash(:sha256, token) |> Base.encode16(case: :lower) + end + + @doc """ + Check if the token has expired. + """ + def expired?(%__MODULE__{expires_at: nil}), do: false + + def expired?(%__MODULE__{expires_at: expires_at}) do + DateTime.compare(DateTime.utc_now(), expires_at) == :gt + end + + @doc """ + Check if the token has been revoked. + """ + def revoked?(%__MODULE__{revoked_at: nil}), do: false + def revoked?(%__MODULE__{revoked_at: _}), do: true + + @doc """ + Check if the token is valid (not expired and not revoked). + """ + def valid?(%__MODULE__{} = token) do + not expired?(token) and not revoked?(token) + end + + @doc """ + Check if the token has a specific scope. + """ + def has_scope?(%__MODULE__{scopes: scopes}, scope) do + scope in scopes + end + + @doc """ + Check if the token has any of the given scopes. + """ + def has_any_scope?(%__MODULE__{scopes: scopes}, required_scopes) + when is_list(required_scopes) do + Enum.any?(required_scopes, &(&1 in scopes)) + end + + # Validate that all scopes are valid + defp validate_scopes(changeset) do + case get_change(changeset, :scopes) do + nil -> + changeset + + scopes -> + invalid = Enum.filter(scopes, &(&1 not in @valid_scopes)) + + if Enum.empty?(invalid) do + changeset + else + add_error(changeset, :scopes, "contains invalid scopes: #{Enum.join(invalid, ", ")}") + end + end + end + + defp validate_scope_selection(changeset) do + scopes = get_field(changeset, :scopes, []) + + if Enum.empty?(scopes) do + add_error(changeset, :scopes, "must include at least one scope") + else + changeset + end + end + + defp normalize_scope_list(scopes) when is_list(scopes) do + scopes + |> Enum.map(&to_string/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + |> Enum.sort() + end + + defp normalize_scope_list(_), do: [] +end diff --git a/apps/elektrine/lib/elektrine/developer/data_export.ex b/apps/elektrine/lib/elektrine/developer/data_export.ex new file mode 100644 index 0000000..dc3ee82 --- /dev/null +++ b/apps/elektrine/lib/elektrine/developer/data_export.ex @@ -0,0 +1,227 @@ +defmodule Elektrine.Developer.DataExport do + @moduledoc """ + Schema for data export jobs. + + Tracks the status of data export requests for various data types. + Exports are processed asynchronously and available for download for 7 days. + + ## Export Types + + - `email` - Email messages and metadata + - `social` - Timeline posts, likes, followers, following + - `chat` - Conversations and messages + - `contacts` - Addressbook contacts + - `calendar` - Calendar events + - `account` - Profile, settings, preferences + - `full` - Everything (GDPR compliance export) + + ## Formats + + - `json` - JSON format (default, most developer-friendly) + - `csv` - CSV format (for spreadsheet import) + - `mbox` - Mbox format (email only) + - `vcf` - vCard format (contacts only) + - `ical` - iCal format (calendar only) + - `zip` - ZIP archive (for full exports) + """ + use Ecto.Schema + import Ecto.Changeset + + alias Elektrine.Accounts.User + + @valid_types ~w(email social chat contacts calendar account full) + @valid_formats ~w(json csv mbox vcf ical zip) + @valid_statuses ~w(pending processing completed failed expired) + + # Export files are available for 7 days + @default_expiry_days 7 + + schema "data_exports" do + field :export_type, :string + field :format, :string, default: "json" + field :status, :string, default: "pending" + field :file_path, :string + field :file_size, :integer + field :item_count, :integer + field :filters, :map, default: %{} + field :download_token, :string + field :plain_download_token, :string, virtual: true + field :download_count, :integer, default: 0 + field :expires_at, :utc_datetime + field :started_at, :utc_datetime + field :completed_at, :utc_datetime + field :error, :string + + belongs_to :user, Elektrine.Accounts.User + + timestamps() + end + + @doc """ + Returns valid export types. + """ + def valid_types, do: @valid_types + + @doc """ + Returns valid formats. + """ + def valid_formats, do: @valid_formats + + @doc """ + Returns valid statuses. + """ + def valid_statuses, do: @valid_statuses + + @doc """ + Returns formats available for each export type. + """ + def formats_for_type("email"), do: ~w(json mbox) + def formats_for_type("contacts"), do: ~w(json vcf csv) + def formats_for_type("calendar"), do: ~w(json ical) + def formats_for_type("account"), do: ~w(json) + def formats_for_type("social"), do: ~w(json csv) + def formats_for_type("chat"), do: ~w(json csv) + def formats_for_type("full"), do: ~w(zip) + def formats_for_type(_), do: ~w(json) + + @doc """ + Returns the default format for an export type. + """ + def default_format_for_type(type) do + type + |> formats_for_type() + |> List.first() + end + + @doc """ + Creates a changeset for a new export. + """ + def changeset(export, attrs) do + export + |> cast(attrs, [:export_type, :format, :filters, :user_id]) + |> validate_required([:export_type, :user_id]) + |> validate_inclusion(:export_type, @valid_types) + |> put_default_format_for_type() + |> validate_inclusion(:format, @valid_formats) + |> validate_format_for_type() + |> generate_download_token() + |> set_expiry() + |> foreign_key_constraint(:user_id) + end + + @doc """ + Marks an export as started. + """ + def start_changeset(export) do + export + |> change() + |> put_change(:status, "processing") + |> put_change(:started_at, DateTime.utc_now() |> DateTime.truncate(:second)) + end + + @doc """ + Marks an export as completed. + """ + def complete_changeset(export, file_path, file_size, item_count) do + export + |> change() + |> put_change(:status, "completed") + |> put_change(:file_path, file_path) + |> put_change(:file_size, file_size) + |> put_change(:item_count, item_count) + |> put_change(:completed_at, DateTime.utc_now() |> DateTime.truncate(:second)) + end + + @doc """ + Marks an export as failed. + """ + def fail_changeset(export, error) do + export + |> change() + |> put_change(:status, "failed") + |> put_change(:error, error) + |> put_change(:completed_at, DateTime.utc_now() |> DateTime.truncate(:second)) + end + + @doc """ + Increments the download count. + """ + def download_changeset(export) do + export + |> change() + |> put_change(:download_count, (export.download_count || 0) + 1) + end + + @doc """ + Checks if an export has expired. + """ + def expired?(%__MODULE__{expires_at: nil}), do: false + + def expired?(%__MODULE__{expires_at: expires_at}) do + DateTime.compare(DateTime.utc_now(), expires_at) == :gt + end + + @doc """ + Checks if an export is ready for download. + """ + def downloadable?(%__MODULE__{status: "completed"} = export), do: not expired?(export) + def downloadable?(_), do: false + + defp put_default_format_for_type(changeset) do + export_type = get_field(changeset, :export_type) + + if export_type && not format_provided?(changeset) do + put_change(changeset, :format, default_format_for_type(export_type)) + else + changeset + end + end + + defp format_provided?(%Ecto.Changeset{params: params}) when is_map(params) do + case Map.fetch(params, "format") do + {:ok, value} -> not is_nil(value) + :error -> not is_nil(Map.get(params, :format)) + end + end + + defp format_provided?(_changeset), do: false + + # Validate that the format is valid for the export type + defp validate_format_for_type(changeset) do + export_type = get_field(changeset, :export_type) + format = get_field(changeset, :format) + + if export_type && format not in formats_for_type(export_type) do + add_error(changeset, :format, "is not valid for #{export_type} exports") + else + changeset + end + end + + # Generate a secure download token + defp generate_download_token(changeset) do + if get_change(changeset, :download_token) do + changeset + else + token = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) + + changeset + |> put_change(:download_token, User.hash_sensitive_token(token)) + |> put_change(:plain_download_token, token) + end + end + + # Set default expiry + defp set_expiry(changeset) do + if get_change(changeset, :expires_at) do + changeset + else + expires_at = + DateTime.utc_now() + |> DateTime.add(@default_expiry_days * 24 * 60 * 60, :second) + |> DateTime.truncate(:second) + + put_change(changeset, :expires_at, expires_at) + end + end +end diff --git a/apps/elektrine/lib/elektrine/developer/exports/account_exporter.ex b/apps/elektrine/lib/elektrine/developer/exports/account_exporter.ex new file mode 100644 index 0000000..5960625 --- /dev/null +++ b/apps/elektrine/lib/elektrine/developer/exports/account_exporter.ex @@ -0,0 +1,546 @@ +defmodule Elektrine.Developer.Exports.AccountExporter do + @moduledoc """ + Exports user's account data including profile, settings, and preferences. + + This module also handles contacts and calendar exports. + + Supported formats: + - json: JSON format (most complete) + - csv: CSV format for contacts + - vcf: vCard format for contacts + - ical: iCal format for calendar + """ + + import Ecto.Query + alias Elektrine.Accounts.User + alias Elektrine.Accounts.UserMute + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.UserBlock, as: ActivityPubUserBlock + alias Elektrine.Domains + alias Elektrine.EmailAddresses + alias Elektrine.OwnRoot + alias Elektrine.Profiles + alias Elektrine.Profiles.Follow + alias Elektrine.Repo + + @doc """ + Exports account data for a user. + + Returns `{:ok, item_count}` on success. + """ + def export(user_id, file_path, format, _filters \\ %{}) do + user = Repo.get!(User, user_id) + contacts = fetch_contacts(user_id) + blocked = fetch_blocked_users(user_id) + muted = fetch_muted_users(user_id) + remote_following = fetch_remote_following(user_id) + remote_blocks = fetch_remote_relationships(user_id, "user") + remote_mutes = fetch_remote_relationships(user_id, "mute") + domain_blocks = fetch_domain_blocks(user_id) + + relationships = + format_relationships(%{ + contacts: contacts, + blocked: blocked, + muted: muted, + remote_following: remote_following, + remote_blocks: remote_blocks, + remote_mutes: remote_mutes, + domain_blocks: domain_blocks + }) + + data = %{ + profile: format_profile(user), + own_root: format_own_root(user), + settings: format_settings(user), + privacy: format_privacy(user), + notifications: format_notifications(user), + contacts: Enum.map(contacts, &format_contact/1), + blocked_users: Enum.map(blocked, &format_blocked/1), + muted_users: Enum.map(muted, &format_muted/1), + relationships: relationships, + exported_at: DateTime.utc_now() + } + + case format do + "json" -> export_json(data, file_path) + _ -> export_json(data, file_path) + end + + relationship_count = + length(contacts) + length(blocked) + length(muted) + length(remote_following) + + length(remote_blocks) + length(remote_mutes) + length(domain_blocks) + + # Count: 1 for the account itself + exported relationship rows. + {:ok, 1 + relationship_count} + end + + @doc """ + Exports contacts for a user. + + Returns `{:ok, item_count}` on success. + """ + def export_contacts(user_id, file_path, format, _filters \\ %{}) do + contacts = fetch_contacts(user_id) + + case format do + "json" -> export_json(%{contacts: Enum.map(contacts, &format_contact/1)}, file_path) + "vcf" -> export_vcf(contacts, file_path) + "csv" -> export_contacts_csv(contacts, file_path) + _ -> export_json(%{contacts: Enum.map(contacts, &format_contact/1)}, file_path) + end + + {:ok, length(contacts)} + end + + @doc """ + Exports calendar data for a user. + + Returns `{:ok, item_count}` on success. + """ + def export_calendar(user_id, file_path, format, _filters \\ %{}) do + events = fetch_calendar_events(user_id) + + case format do + "json" -> export_json(%{events: Enum.map(events, &format_event/1)}, file_path) + "ical" -> export_ical(events, file_path) + _ -> export_json(%{events: Enum.map(events, &format_event/1)}, file_path) + end + + {:ok, length(events)} + end + + # Fetch contacts (friends/connections) + defp fetch_contacts(user_id) do + # Get friends (people the user follows) + from(f in Elektrine.Profiles.Follow, + where: f.follower_id == ^user_id, + join: u in User, + on: u.id == f.followed_id, + select: %{ + user: u, + followed_at: f.inserted_at + } + ) + |> Repo.all() + end + + defp fetch_blocked_users(user_id) do + from(b in Elektrine.Accounts.UserBlock, + where: b.blocker_id == ^user_id, + join: u in User, + on: u.id == b.blocked_id, + select: %{user: u, blocked_at: b.inserted_at} + ) + |> Repo.all() + end + + defp fetch_muted_users(user_id) do + from(m in UserMute, + where: m.muter_id == ^user_id, + where: is_nil(m.expires_at) or m.expires_at > ^Elektrine.Time.utc_now(), + join: u in User, + on: u.id == m.muted_id, + select: %{ + user: u, + muted_at: m.inserted_at, + mute_notifications: m.mute_notifications, + expires_at: m.expires_at + } + ) + |> Repo.all() + end + + defp fetch_remote_following(user_id) do + from(f in Follow, + where: f.follower_id == ^user_id and not is_nil(f.remote_actor_id), + join: a in Actor, + on: a.id == f.remote_actor_id, + select: %{actor: a, followed_at: f.inserted_at, pending: f.pending} + ) + |> Repo.all() + end + + defp fetch_remote_relationships(user_id, block_type) do + from(b in ActivityPubUserBlock, + where: b.user_id == ^user_id and b.block_type == ^block_type, + left_join: a in Actor, + on: a.uri == b.blocked_uri, + select: %{actor: a, uri: b.blocked_uri, inserted_at: b.inserted_at} + ) + |> Repo.all() + end + + defp fetch_domain_blocks(user_id) do + from(b in ActivityPubUserBlock, + where: b.user_id == ^user_id and b.block_type == "domain", + select: %{domain: b.blocked_uri, blocked_at: b.inserted_at} + ) + |> Repo.all() + end + + defp fetch_calendar_events(user_id) do + case Code.ensure_loaded(Elektrine.Calendar.Event) do + {:module, _} -> + from(e in Elektrine.Calendar.Event, + where: e.user_id == ^user_id, + order_by: [desc: e.start_at] + ) + |> Repo.all() + + _ -> + [] + end + end + + defp export_json(data, file_path) do + json = Jason.encode!(data, pretty: true) + File.write!(file_path, json) + end + + defp export_vcf(contacts, file_path) do + vcf_content = + contacts + |> Enum.map_join("\n", &format_vcard/1) + + File.write!(file_path, vcf_content) + end + + defp export_contacts_csv(contacts, file_path) do + headers = ["username", "handle", "display_name", "email", "followed_at"] + header_row = Enum.join(headers, ",") + + rows = + contacts + |> Enum.map(fn contact -> + user = contact.user + + [ + escape_csv(user.username || ""), + escape_csv(user.handle || ""), + escape_csv(user.display_name || ""), + escape_csv(EmailAddresses.primary_for_user(user) || ""), + to_string(contact.followed_at) + ] + |> Enum.join(",") + end) + + content = [header_row | rows] |> Enum.join("\n") + File.write!(file_path, content) + end + + defp export_ical(events, file_path) do + ical_content = """ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Elektrine//Data Export//EN + #{Enum.map_join(events, "\n", &format_vevent/1)} + END:VCALENDAR + """ + + File.write!(file_path, ical_content) + end + + defp format_profile(user) do + %{ + id: user.id, + username: user.username, + handle: user.handle, + display_name: user.display_name, + unique_id: user.unique_id, + avatar: user.avatar, + verified: user.verified, + is_admin: user.is_admin, + trust_level: user.trust_level, + status: user.status, + status_message: user.status_message, + locale: user.locale, + timezone: user.timezone, + created_at: user.inserted_at + } + end + + defp format_own_root(user) do + built_in_domain = "#{user.handle || user.username}.#{Domains.default_profile_domain()}" + verified_profile_domains = Profiles.verified_domains_for_user(user) + per_site_identities = Profiles.list_user_per_site_identities(user) + provider_base_url = Domains.public_base_url() + + domains = + [%{domain: built_in_domain, status: "built_in", dns_records: []}] + |> Enum.concat( + Enum.map(verified_profile_domains, fn custom_domain -> + %{ + domain: custom_domain.domain, + status: custom_domain.status, + dns_records: Profiles.dns_records_for_custom_domain(custom_domain) + } + end) + ) + + %{ + provider: provider_base_url, + portable_root: "dns", + domains: + Enum.map(domains, fn %{domain: domain, status: status, dns_records: dns_records} -> + %{ + domain: domain, + status: status, + subject: OwnRoot.subject(domain), + did: OwnRoot.did_for_domain(domain), + own_root: + OwnRoot.document(user, domain, + provider_base_url: provider_base_url, + per_site_identities: per_site_identities + ), + did_document: + OwnRoot.did_document(user, domain, provider_base_url: provider_base_url), + activitypub_actor: "https://#{domain}/users/#{user.handle || user.username}", + email_address: "#{user.username}@#{domain}", + dns_records: dns_records, + migration: %{ + own_root: "Serve this JSON at https://#{domain}/.well-known/own-root", + did: "Serve this JSON at https://#{domain}/.well-known/did.json", + oidc: "Update the OwnRoot document's OIDC issuer to the new provider.", + activitypub: + "Keep the ActivityPub actor URL stable or publish a Move activity from the old actor." + } + } + end) + } + end + + defp format_settings(user) do + %{ + locale: user.locale, + timezone: user.timezone, + time_format: user.time_format, + preferred_email_domain: user.preferred_email_domain, + email_signature: user.email_signature, + two_factor_enabled: user.two_factor_enabled + } + end + + defp format_privacy(user) do + %{ + allow_group_adds_from: user.allow_group_adds_from, + allow_direct_messages_from: user.allow_direct_messages_from, + allow_mentions_from: user.allow_mentions_from, + allow_calls_from: user.allow_calls_from, + allow_friend_requests_from: user.allow_friend_requests_from, + profile_visibility: user.profile_visibility, + default_post_visibility: user.default_post_visibility, + hide_followers: user.hide_followers, + hide_follows: user.hide_follows, + hide_favorites: user.hide_favorites + } + end + + defp format_notifications(user) do + %{ + notify_on_new_follower: user.notify_on_new_follower, + notify_on_direct_message: user.notify_on_direct_message, + notify_on_mention: user.notify_on_mention, + notify_on_reply: user.notify_on_reply, + notify_on_like: user.notify_on_like, + notify_on_email_received: user.notify_on_email_received, + notify_on_discussion_reply: user.notify_on_discussion_reply, + notify_on_comment: user.notify_on_comment, + block_notifications_from_strangers: user.block_notifications_from_strangers, + hide_notification_contents: user.hide_notification_contents + } + end + + defp format_contact(contact) do + user = contact.user + + %{ + type: "local", + user_id: user.id, + username: user.username, + account: local_account_address(user), + handle: user.handle, + display_name: user.display_name, + avatar: user.avatar, + followed_at: contact.followed_at + } + end + + defp format_blocked(blocked) do + user = blocked.user + + %{ + type: "local", + user_id: user.id, + username: user.username, + account: local_account_address(user), + blocked_at: blocked.blocked_at + } + end + + defp format_muted(muted) do + user = muted.user + + %{ + type: "local", + user_id: user.id, + username: user.username, + account: local_account_address(user), + muted_at: muted.muted_at, + mute_notifications: muted.mute_notifications, + expires_at: muted.expires_at + } + end + + defp format_relationships(relationships) do + local_following = Enum.map(relationships.contacts, &format_contact/1) + local_blocks = Enum.map(relationships.blocked, &format_blocked/1) + local_mutes = Enum.map(relationships.muted, &format_muted/1) + remote_following = Enum.map(relationships.remote_following, &format_remote_follow/1) + remote_blocks = Enum.map(relationships.remote_blocks, &format_remote_block/1) + remote_mutes = Enum.map(relationships.remote_mutes, &format_remote_mute/1) + + %{ + following: local_following ++ remote_following, + blocks: local_blocks ++ remote_blocks, + mutes: local_mutes ++ remote_mutes, + domain_blocks: Enum.map(relationships.domain_blocks, &format_domain_block/1), + import_lists: %{ + follows: Enum.map(local_following ++ remote_following, & &1.account), + blocks: Enum.map(local_blocks ++ remote_blocks, & &1.account), + mutes: Enum.map(local_mutes ++ remote_mutes, & &1.account), + domain_blocks: Enum.map(relationships.domain_blocks, & &1.domain) + } + } + end + + defp format_remote_follow(%{actor: actor, followed_at: followed_at, pending: pending}) do + actor + |> format_remote_actor() + |> Map.merge(%{ + type: "remote", + followed_at: followed_at, + pending: pending + }) + end + + defp format_remote_block(%{actor: actor, uri: uri, inserted_at: blocked_at}) do + actor + |> format_remote_actor(uri) + |> Map.merge(%{type: "remote", blocked_at: blocked_at}) + end + + defp format_remote_mute(%{actor: actor, uri: uri, inserted_at: muted_at}) do + actor + |> format_remote_actor(uri) + |> Map.merge(%{type: "remote", muted_at: muted_at}) + end + + defp format_domain_block(domain_block) do + %{domain: domain_block.domain, blocked_at: domain_block.blocked_at} + end + + defp format_remote_actor(actor, fallback_uri \\ nil) + + defp format_remote_actor(%Actor{} = actor, _fallback_uri) do + %{ + account: remote_account_address(actor), + uri: actor.uri, + username: actor.username, + domain: actor.domain, + display_name: actor.display_name + } + end + + defp format_remote_actor(nil, fallback_uri) do + %{account: fallback_uri, uri: fallback_uri, username: nil, domain: nil, display_name: nil} + end + + defp local_account_address(%User{} = user) do + "#{user.handle || user.username}@#{ActivityPub.instance_domain()}" + end + + defp remote_account_address(%Actor{username: username, domain: domain}) + when is_binary(username) and is_binary(domain) do + "#{username}@#{domain}" + end + + defp remote_account_address(%Actor{uri: uri}), do: uri + + defp format_vcard(contact) do + user = contact.user + display_name = user.display_name || user.username + + """ + BEGIN:VCARD + VERSION:3.0 + FN:#{display_name} + N:;#{display_name};;; + NICKNAME:#{user.username} + EMAIL:#{EmailAddresses.primary_for_user(user)} + X-SOCIALPROFILE;TYPE=elektrine:#{user.handle} + REV:#{DateTime.utc_now() |> DateTime.to_iso8601()} + END:VCARD + """ + end + + defp format_event(event) do + %{ + id: event.id, + title: event.title, + description: event.description, + start_at: event.start_at, + end_at: event.end_at, + location: event.location, + all_day: event.all_day, + created_at: event.inserted_at + } + end + + defp format_vevent(event) do + start_dt = format_ical_datetime(event.start_at) + end_dt = format_ical_datetime(event.end_at) + + """ + BEGIN:VEVENT + UID:#{EmailAddresses.uid(event.id)} + DTSTART:#{start_dt} + DTEND:#{end_dt} + SUMMARY:#{escape_ical(event.title)} + DESCRIPTION:#{escape_ical(event.description || "")} + LOCATION:#{escape_ical(event.location || "")} + END:VEVENT + """ + end + + defp format_ical_datetime(nil), do: "" + + defp format_ical_datetime(datetime) do + datetime + |> DateTime.to_naive() + |> NaiveDateTime.to_iso8601(:basic) + |> String.replace("-", "") + |> String.replace(":", "") + end + + defp escape_csv(string) when is_binary(string) do + if String.contains?(string, [",", "\"", "\n"]) do + "\"" <> String.replace(string, "\"", "\"\"") <> "\"" + else + string + end + end + + defp escape_csv(_), do: "" + + defp escape_ical(string) when is_binary(string) do + string + |> String.replace("\\", "\\\\") + |> String.replace(",", "\\,") + |> String.replace(";", "\\;") + |> String.replace("\n", "\\n") + end + + defp escape_ical(_), do: "" +end diff --git a/apps/elektrine/lib/elektrine/developer/exports/chat_exporter.ex b/apps/elektrine/lib/elektrine/developer/exports/chat_exporter.ex new file mode 100644 index 0000000..b12107a --- /dev/null +++ b/apps/elektrine/lib/elektrine/developer/exports/chat_exporter.ex @@ -0,0 +1,191 @@ +defmodule Elektrine.Developer.Exports.ChatExporter do + @moduledoc """ + Exports user's chat messages from DMs, groups, and channels. + + Supported formats: + - json: JSON format (most complete) + - csv: CSV format for spreadsheet import + """ + + import Ecto.Query + alias Elektrine.Messaging.ChatMessage + alias Elektrine.Repo + alias Elektrine.Social.{Conversation, ConversationMember} + + @doc """ + Exports all chat data for a user. + + Returns `{:ok, item_count}` on success. + """ + def export(user_id, file_path, format, filters \\ %{}) do + conversations = fetch_conversations(user_id) + messages = fetch_messages(user_id, filters) + + count = length(messages) + + data = %{ + conversations: Enum.map(conversations, &format_conversation/1), + messages: Enum.map(messages, &format_message/1) + } + + case format do + "json" -> export_json(data, file_path) + "csv" -> export_csv(data, file_path) + _ -> export_json(data, file_path) + end + + {:ok, count} + end + + defp fetch_conversations(user_id) do + # First get conversation IDs user is member of + conversation_ids = + from(cm in ConversationMember, + where: cm.user_id == ^user_id, + select: cm.conversation_id + ) + |> Repo.all() + + # Then fetch conversations with preloads + from(c in Conversation, + where: c.id in ^conversation_ids, + where: c.type in ["dm", "group", "channel"], + preload: [:members] + ) + |> Repo.all() + end + + defp fetch_messages(user_id, filters) do + # Get conversation IDs user is member of + conversation_ids = + from(cm in ConversationMember, + where: cm.user_id == ^user_id, + select: cm.conversation_id + ) + |> Repo.all() + + query = + from m in ChatMessage, + where: m.conversation_id in ^conversation_ids, + where: is_nil(m.deleted_at), + order_by: [asc: m.inserted_at], + preload: [:sender, :conversation] + + query = apply_filters(query, filters) + + Repo.all(query) + end + + defp apply_filters(query, %{"from_date" => from_date}) when is_binary(from_date) do + case DateTime.from_iso8601(from_date) do + {:ok, dt, _} -> from(m in query, where: m.inserted_at >= ^dt) + _ -> query + end + end + + defp apply_filters(query, %{"to_date" => to_date}) when is_binary(to_date) do + case DateTime.from_iso8601(to_date) do + {:ok, dt, _} -> from(m in query, where: m.inserted_at <= ^dt) + _ -> query + end + end + + defp apply_filters(query, _), do: query + + defp export_json(data, file_path) do + json = Jason.encode!(data, pretty: true) + File.write!(file_path, json) + end + + defp export_csv(data, file_path) do + csv_content = messages_to_csv(data.messages) + File.write!(file_path, csv_content) + end + + defp messages_to_csv(messages) do + headers = ["id", "conversation_id", "sender", "content", "type", "created_at"] + header_row = Enum.join(headers, ",") + + rows = + messages + |> Enum.map(fn msg -> + [ + to_string(msg.id), + to_string(msg.conversation_id), + escape_csv(msg.sender || ""), + escape_csv(msg.content || ""), + msg.message_type, + to_string(msg.created_at) + ] + |> Enum.join(",") + end) + + [header_row | rows] |> Enum.join("\n") + end + + defp escape_csv(string) when is_binary(string) do + if String.contains?(string, [",", "\"", "\n"]) do + "\"" <> String.replace(string, "\"", "\"\"") <> "\"" + else + string + end + end + + defp escape_csv(_), do: "" + + defp format_conversation(conversation) do + members = + case conversation.members do + members when is_list(members) -> + Enum.map(members, fn m -> + %{ + user_id: m.user_id, + role: m.role, + joined_at: m.inserted_at + } + end) + + _ -> + [] + end + + %{ + id: conversation.id, + type: conversation.type, + name: conversation.name, + description: conversation.description, + members: members, + created_at: conversation.inserted_at + } + end + + defp format_message(message) do + sender_name = + if message.sender do + message.sender.username || message.sender.handle + else + nil + end + + conversation_name = + if message.conversation do + message.conversation.name + else + nil + end + + %{ + id: message.id, + conversation_id: message.conversation_id, + conversation_name: conversation_name, + sender_id: message.sender_id, + sender: sender_name, + content: message.content, + message_type: message.message_type, + media_urls: message.media_urls, + reply_to_id: message.reply_to_id, + created_at: message.inserted_at, + edited_at: message.edited_at + } + end +end diff --git a/apps/elektrine/lib/elektrine/developer/exports/email_exporter.ex b/apps/elektrine/lib/elektrine/developer/exports/email_exporter.ex new file mode 100644 index 0000000..1c73088 --- /dev/null +++ b/apps/elektrine/lib/elektrine/developer/exports/email_exporter.ex @@ -0,0 +1,147 @@ +defmodule Elektrine.Developer.Exports.EmailExporter do + @moduledoc """ + Exports user's email messages in various formats. + + Supported formats: + - json: JSON format (most complete) + - mbox: Standard mbox format for email clients + - zip: Compressed archive + """ + + import Ecto.Query + alias Elektrine.Email.Message + alias Elektrine.Repo + + @doc """ + Exports all emails for a user. + + Returns `{:ok, item_count}` on success. + """ + def export(user_id, file_path, format, filters \\ %{}) do + # Get user's mailbox + mailbox = Elektrine.Email.get_user_mailbox(user_id) + + if mailbox do + messages = fetch_messages(mailbox.id, filters) + count = length(messages) + + case format do + "json" -> export_json(messages, file_path) + "mbox" -> export_mbox(messages, file_path) + _ -> export_json(messages, file_path) + end + + {:ok, count} + else + # No mailbox, export empty + File.write!(file_path, "[]") + {:ok, 0} + end + end + + defp fetch_messages(mailbox_id, filters) do + query = + from m in Message, + where: m.mailbox_id == ^mailbox_id and m.deleted == false, + order_by: [desc: m.inserted_at], + select: m + + query = apply_filters(query, filters) + + Repo.all(query) + end + + defp apply_filters(query, %{"from_date" => from_date}) when is_binary(from_date) do + case DateTime.from_iso8601(from_date) do + {:ok, dt, _} -> from(m in query, where: m.inserted_at >= ^dt) + _ -> query + end + end + + defp apply_filters(query, %{"to_date" => to_date}) when is_binary(to_date) do + case DateTime.from_iso8601(to_date) do + {:ok, dt, _} -> from(m in query, where: m.inserted_at <= ^dt) + _ -> query + end + end + + defp apply_filters(query, _), do: query + + defp export_json(messages, file_path) do + data = + messages + |> Enum.map(&format_message/1) + |> Jason.encode!(pretty: true) + + File.write!(file_path, data) + end + + defp export_mbox(messages, file_path) do + mbox_content = + messages + |> Enum.map_join("\n", &format_mbox_message/1) + + File.write!(file_path, mbox_content) + end + + defp format_message(message) do + %{ + id: message.id, + message_id: message.message_id, + from: message.from, + to: message.to, + cc: message.cc, + bcc: message.bcc, + subject: message.subject, + text_body: message.text_body, + html_body: message.html_body, + status: message.status, + read: message.read, + spam: message.spam, + archived: message.archived, + flagged: message.flagged, + answered: message.answered, + category: message.category, + has_attachments: message.has_attachments, + attachments: message.attachments, + in_reply_to: message.in_reply_to, + references: message.references, + priority: message.priority, + metadata: message.metadata, + created_at: message.inserted_at, + updated_at: message.updated_at + } + end + + defp format_mbox_message(message) do + # Format date for mbox + date = format_date(message.inserted_at) + + # Build mbox format + """ + From #{extract_email(message.from)} #{date} + From: #{message.from} + To: #{message.to} + Subject: #{message.subject || "(no subject)"} + Date: #{date} + Message-ID: #{message.message_id} + #{if message.cc, do: "Cc: #{message.cc}\n", else: ""}#{if message.in_reply_to, do: "In-Reply-To: #{message.in_reply_to}\n", else: ""} + #{message.text_body || ""} + """ + end + + defp extract_email(from) when is_binary(from) do + case Regex.run(~r/<([^>]+)>/, from) do + [_, email] -> email + _ -> from + end + end + + defp extract_email(_), do: "unknown@localhost" + + defp format_date(nil), do: "Mon Jan 1 00:00:00 2000" + + defp format_date(datetime) do + Calendar.strftime(datetime, "%a %b %d %H:%M:%S %Y") + end +end diff --git a/apps/elektrine/lib/elektrine/developer/exports/social_exporter.ex b/apps/elektrine/lib/elektrine/developer/exports/social_exporter.ex new file mode 100644 index 0000000..2b766eb --- /dev/null +++ b/apps/elektrine/lib/elektrine/developer/exports/social_exporter.ex @@ -0,0 +1,250 @@ +defmodule Elektrine.Developer.Exports.SocialExporter do + @moduledoc """ + Exports user's social timeline data including posts, likes, and follows. + + Supported formats: + - json: JSON format (most complete) + - csv: CSV format for spreadsheet import + """ + + import Ecto.Query + alias Elektrine.Profiles.Follow + alias Elektrine.Repo + alias Elektrine.Social.Message + alias Elektrine.Social.PostLike + + @doc """ + Exports all social data for a user. + + Returns `{:ok, item_count}` on success. + """ + def export(user_id, file_path, format, filters \\ %{}) do + posts = fetch_posts(user_id, filters) + likes = fetch_likes(user_id) + follows = fetch_follows(user_id) + + count = length(posts) + length(likes) + length(follows.following) + length(follows.followers) + + data = %{ + posts: Enum.map(posts, &format_post/1), + likes: Enum.map(likes, &format_like/1) |> Enum.reject(&is_nil/1), + following: Enum.map(follows.following, &format_follow/1), + followers: Enum.map(follows.followers, &format_follow/1) + } + + case format do + "json" -> export_json(data, file_path) + "csv" -> export_csv(data, file_path) + _ -> export_json(data, file_path) + end + + {:ok, count} + end + + defp fetch_posts(user_id, filters) do + query = + from m in Message, + where: m.sender_id == ^user_id, + where: is_nil(m.deleted_at), + # Only public/followers posts, not DM/channel messages + where: m.visibility in ["public", "followers", "unlisted"], + order_by: [desc: m.inserted_at], + preload: [:conversation, :link_preview, :hashtags] + + query = apply_filters(query, filters) + + Repo.all(query) + end + + defp fetch_likes(user_id) do + # Fetch local likes (PostLike has user_id and message_id) + from(pl in PostLike, + where: pl.user_id == ^user_id, + preload: [:message] + ) + |> Repo.all() + end + + defp fetch_follows(user_id) do + # Following uses :followed relationship (not :following) + following = + from(f in Follow, + where: f.follower_id == ^user_id, + order_by: [desc: f.inserted_at], + preload: [:followed] + ) + |> Repo.all() + + followers = + from(f in Follow, + where: f.followed_id == ^user_id, + order_by: [desc: f.inserted_at], + preload: [:follower] + ) + |> Repo.all() + + %{following: following, followers: followers} + end + + defp apply_filters(query, %{"from_date" => from_date}) when is_binary(from_date) do + case DateTime.from_iso8601(from_date) do + {:ok, dt, _} -> from(m in query, where: m.inserted_at >= ^dt) + _ -> query + end + end + + defp apply_filters(query, %{"to_date" => to_date}) when is_binary(to_date) do + case DateTime.from_iso8601(to_date) do + {:ok, dt, _} -> from(m in query, where: m.inserted_at <= ^dt) + _ -> query + end + end + + defp apply_filters(query, _), do: query + + defp export_json(data, file_path) do + json = Jason.encode!(data, pretty: true) + File.write!(file_path, json) + end + + defp export_csv(data, file_path) do + # For CSV, we'll export posts as the main data + csv_content = posts_to_csv(data.posts) + File.write!(file_path, csv_content) + end + + defp posts_to_csv(posts) do + headers = [ + "id", + "content", + "visibility", + "type", + "like_count", + "reply_count", + "share_count", + "hashtags", + "created_at" + ] + + header_row = Enum.join(headers, ",") + + rows = + posts + |> Enum.map(fn post -> + [ + to_string(post.id), + escape_csv(post.content || ""), + post.visibility, + post.post_type, + to_string(post.like_count), + to_string(post.reply_count), + to_string(post.share_count), + escape_csv(Enum.join(post.hashtags, " ")), + to_string(post.created_at) + ] + |> Enum.join(",") + end) + + [header_row | rows] |> Enum.join("\n") + end + + defp escape_csv(string) when is_binary(string) do + if String.contains?(string, [",", "\"", "\n"]) do + "\"" <> String.replace(string, "\"", "\"\"") <> "\"" + else + string + end + end + + defp escape_csv(_), do: "" + + defp format_post(message) do + hashtags = + case message.hashtags do + hashtags when is_list(hashtags) -> + Enum.map(hashtags, fn h -> + case h do + %{name: name} -> name + %{tag: tag} -> tag + _ -> nil + end + end) + |> Enum.reject(&is_nil/1) + + _ -> + message.extracted_hashtags || [] + end + + %{ + id: message.id, + content: message.content, + title: message.title, + visibility: message.visibility, + post_type: message.post_type, + media_urls: message.media_urls, + like_count: message.like_count, + reply_count: message.reply_count, + share_count: message.share_count, + quote_count: message.quote_count, + upvotes: message.upvotes, + downvotes: message.downvotes, + score: message.score, + hashtags: hashtags, + primary_url: message.primary_url, + link_preview: + if message.link_preview do + %{ + url: message.link_preview.url, + title: message.link_preview.title, + description: message.link_preview.description, + image_url: message.link_preview.image_url + } + else + nil + end, + conversation_id: message.conversation_id, + reply_to_id: message.reply_to_id, + created_at: message.inserted_at, + edited_at: message.edited_at + } + end + + defp format_like(%{message_id: _message_id, created_at: _created_at} = pl) do + %{ + type: "local", + message_id: pl.message_id, + created_at: pl.created_at + } + end + + defp format_like(_), do: nil + + defp format_follow(follow) do + user = + cond do + Ecto.assoc_loaded?(follow.follower) and follow.follower -> + follow.follower + + Ecto.assoc_loaded?(follow.followed) and follow.followed -> + follow.followed + + true -> + nil + end + + if user do + %{ + user_id: user.id, + username: user.username, + handle: user.handle, + display_name: user.display_name, + followed_at: follow.inserted_at + } + else + %{ + user_id: follow.follower_id || follow.followed_id, + followed_at: follow.inserted_at + } + end + end +end diff --git a/apps/elektrine/lib/elektrine/developer/webhook.ex b/apps/elektrine/lib/elektrine/developer/webhook.ex new file mode 100644 index 0000000..0b23d27 --- /dev/null +++ b/apps/elektrine/lib/elektrine/developer/webhook.ex @@ -0,0 +1,170 @@ +defmodule Elektrine.Developer.Webhook do + @moduledoc """ + Schema for developer webhooks. + + Stores outbound webhook subscriptions configured by users and delivery metadata. + """ + + use Ecto.Schema + import Ecto.Changeset + alias Elektrine.Secrets.EncryptedString + alias Elektrine.Security.URLValidator + + @valid_events ~w( + email.received + email.sent + message.received + post.created + post.liked + follow.new + export.completed + ) + schema "developer_webhooks" do + field :name, :string + field :url, :string + field :events, {:array, :string}, default: [] + field :secret, EncryptedString + field :enabled, :boolean, default: true + field :last_triggered_at, :utc_datetime + field :last_response_status, :integer + field :last_error, :string + + belongs_to :user, Elektrine.Accounts.User + + timestamps() + end + + @doc """ + Returns valid webhook event names. + """ + def valid_events, do: @valid_events + + @doc """ + Returns a stable non-secret fingerprint for display and API responses. + """ + def secret_fingerprint(%__MODULE__{secret: secret}), do: secret_fingerprint(secret) + + def secret_fingerprint(secret) when is_binary(secret) do + digest = + :crypto.hash(:sha256, secret) + |> Base.encode16(case: :lower) + |> String.slice(0, 12) + + "sha256:#{digest}..." + end + + def secret_fingerprint(_), do: nil + + @doc """ + Changeset for creating/updating webhooks. + """ + def changeset(webhook, attrs) do + webhook + |> cast(attrs, [:name, :url, :events, :enabled, :user_id, :secret]) + |> validate_required([:name, :url, :events, :user_id]) + |> validate_length(:name, min: 1, max: 100) + |> validate_length(:events, min: 1, max: 20) + |> validate_event_names() + |> validate_webhook_url() + |> maybe_generate_secret() + |> foreign_key_constraint(:user_id) + end + + @doc """ + Changeset for recording webhook delivery results. + """ + def delivery_result_changeset(webhook, attrs) do + webhook + |> cast(attrs, [:last_triggered_at, :last_response_status, :last_error]) + end + + @doc """ + Validates that a webhook URL is safe for outbound delivery. + """ + def validate_url(url) when is_binary(url) do + uri = URI.parse(url) + host = normalize_host(uri.host) + + cond do + uri.scheme in [nil, ""] -> + {:error, :missing_scheme} + + not is_binary(host) or host == "" -> + {:error, :missing_host} + + not is_nil(uri.userinfo) -> + {:error, :userinfo_not_allowed} + + uri.scheme == "http" and localhost_http_allowed?(host) -> + :ok + + uri.scheme != "https" -> + {:error, :https_required} + + true -> + URLValidator.validate(url) + end + end + + def validate_url(_), do: {:error, :invalid_url} + + defp validate_event_names(changeset) do + events = get_field(changeset, :events, []) + invalid_events = Enum.filter(events, &(&1 not in @valid_events)) + + if invalid_events == [] do + changeset + else + add_error(changeset, :events, "contains invalid events: #{Enum.join(invalid_events, ", ")}") + end + end + + defp validate_webhook_url(changeset) do + validate_change(changeset, :url, fn :url, value -> + case validate_url(value) do + :ok -> [] + {:error, reason} -> [url: webhook_url_error(reason)] + end + end) + end + + defp webhook_url_error(:https_required), do: "must be a valid HTTPS URL" + defp webhook_url_error(:userinfo_not_allowed), do: "must not include username or password" + + defp webhook_url_error(:private_ip), + do: "must not point to private or internal addresses" + + defp webhook_url_error(:private_domain), + do: "must not point to private or internal domains" + + defp webhook_url_error(:unresolvable_host), do: "must use a resolvable public hostname" + defp webhook_url_error(_), do: "must be a valid public HTTPS URL" + + defp localhost_http_allowed?(host) do + Elektrine.RuntimeEnv.dev_or_test?() and host in ["localhost", "127.0.0.1", "::1"] + end + + defp normalize_host(host) when is_binary(host) do + host + |> String.trim() + |> String.trim_leading("[") + |> String.trim_trailing("]") + |> String.downcase() + end + + defp normalize_host(_), do: nil + + defp maybe_generate_secret(changeset) do + case get_field(changeset, :secret) do + secret when is_binary(secret) and secret != "" -> + changeset + + _ -> + put_change( + changeset, + :secret, + Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) + ) + end + end +end diff --git a/apps/elektrine/lib/elektrine/developer/webhook_delivery.ex b/apps/elektrine/lib/elektrine/developer/webhook_delivery.ex new file mode 100644 index 0000000..f1d20c6 --- /dev/null +++ b/apps/elektrine/lib/elektrine/developer/webhook_delivery.ex @@ -0,0 +1,58 @@ +defmodule Elektrine.Developer.WebhookDelivery do + @moduledoc """ + Persistent record of outbound developer webhook delivery attempts. + """ + + use Ecto.Schema + import Ecto.Changeset + + @valid_statuses ~w(pending delivered failed) + + schema "developer_webhook_deliveries" do + field :event, :string + field :event_id, :string + field :payload, :map, default: %{} + field :status, :string, default: "pending" + field :attempt_count, :integer, default: 0 + field :response_status, :integer + field :error, :string + field :duration_ms, :integer + field :last_attempted_at, :utc_datetime + field :delivered_at, :utc_datetime + + belongs_to :webhook, Elektrine.Developer.Webhook + belongs_to :user, Elektrine.Accounts.User + + timestamps() + end + + @doc """ + Changeset for creating a webhook delivery record. + """ + def changeset(delivery, attrs) do + delivery + |> cast(attrs, [:webhook_id, :user_id, :event, :event_id, :payload, :status]) + |> validate_required([:webhook_id, :user_id, :event, :event_id, :payload, :status]) + |> validate_inclusion(:status, @valid_statuses) + |> validate_length(:event, min: 1, max: 120) + |> foreign_key_constraint(:webhook_id) + |> foreign_key_constraint(:user_id) + end + + @doc """ + Changeset for recording attempt metadata and result. + """ + def result_changeset(delivery, attrs) do + delivery + |> cast(attrs, [ + :status, + :attempt_count, + :response_status, + :error, + :duration_ms, + :last_attempted_at, + :delivered_at + ]) + |> validate_inclusion(:status, @valid_statuses) + end +end diff --git a/apps/elektrine/lib/elektrine/developer/workers/export_worker.ex b/apps/elektrine/lib/elektrine/developer/workers/export_worker.ex new file mode 100644 index 0000000..577a318 --- /dev/null +++ b/apps/elektrine/lib/elektrine/developer/workers/export_worker.ex @@ -0,0 +1,280 @@ +defmodule Elektrine.Developer.ExportWorker do + @moduledoc """ + Oban worker for processing data exports in the background. + + Supports exporting various data types (email, social, chat, etc.) + in different formats (JSON, CSV, mbox, etc.). + """ + use Oban.Worker, + queue: :exports, + max_attempts: 3, + priority: 3 + + require Logger + + alias Elektrine.Developer + alias Elektrine.Developer.DataExport + alias Elektrine.Developer.Exports.{AccountExporter, ChatExporter, EmailExporter, SocialExporter} + alias Elektrine.Security.FilePath + + # Export directory - configurable via :elektrine, :export_dir + defp export_dir, do: Application.get_env(:elektrine, :export_dir, "/tmp/elektrine/exports") + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"export_id" => export_id}}) do + Logger.info("[ExportWorker] Starting export #{export_id}") + + # Get the export record + case Elektrine.Repo.get(DataExport, export_id) do + nil -> + Logger.error("[ExportWorker] Export #{export_id} not found") + {:error, :not_found} + + export -> + process_export(export) + end + end + + defp process_export(%DataExport{} = export) do + # Mark as processing + {:ok, export} = Developer.start_export(export) + + try do + # Ensure export directory exists + File.mkdir_p!(export_dir()) + + # Generate unique filename - sanitize user_id and export_type to prevent path traversal + timestamp = DateTime.utc_now() |> DateTime.to_unix() + safe_user_id = export.user_id |> to_string() |> String.replace(~r/[^0-9]/, "") + safe_type = export.export_type |> String.replace(~r/[^a-z_]/, "") + safe_format = export.format |> String.replace(~r/[^a-z]/, "") + filename = "#{safe_user_id}_#{safe_type}_#{timestamp}.#{safe_format}" + file_path = Path.join(export_dir(), filename) + + # Security: Verify the path doesn't escape the export directory + {:ok, file_path} = FilePath.validate_child_path(file_path, export_dir()) + + # Run the appropriate exporter + result = run_exporter(export, file_path) + + case result do + {:ok, item_count} -> + # Get file size + file_size = File.stat!(file_path).size + + # Mark as completed + {:ok, _export} = Developer.complete_export(export, file_path, file_size, item_count) + + Logger.info( + "[ExportWorker] Export #{export.id} completed: #{item_count} items, #{file_size} bytes" + ) + + :ok + + {:error, reason} -> + # Mark as failed + {:ok, _export} = Developer.fail_export(export, inspect(reason)) + + Logger.error("[ExportWorker] Export #{export.id} failed: #{inspect(reason)}") + {:error, reason} + end + rescue + e -> + # Mark as failed + {:ok, _export} = Developer.fail_export(export, Exception.message(e)) + + Logger.error("[ExportWorker] Export #{export.id} crashed: #{Exception.message(e)}") + reraise e, __STACKTRACE__ + end + end + + # Route to the appropriate exporter based on type + defp run_exporter(%DataExport{export_type: "email"} = export, file_path) do + EmailExporter.export(export.user_id, file_path, export.format, export.filters) + end + + defp run_exporter(%DataExport{export_type: "social"} = export, file_path) do + SocialExporter.export(export.user_id, file_path, export.format, export.filters) + end + + defp run_exporter(%DataExport{export_type: "chat"} = export, file_path) do + ChatExporter.export(export.user_id, file_path, export.format, export.filters) + end + + defp run_exporter(%DataExport{export_type: "account"} = export, file_path) do + AccountExporter.export(export.user_id, file_path, export.format, export.filters) + end + + defp run_exporter(%DataExport{export_type: "contacts"} = export, file_path) do + # Contacts are part of account data + AccountExporter.export_contacts(export.user_id, file_path, export.format, export.filters) + end + + defp run_exporter(%DataExport{export_type: "calendar"} = export, file_path) do + # Calendar is part of account data + AccountExporter.export_calendar(export.user_id, file_path, export.format, export.filters) + end + + defp run_exporter(%DataExport{export_type: "full"} = export, file_path) do + # Full export combines all data into a zip + export_full(export.user_id, file_path, export.filters) + end + + defp run_exporter(%DataExport{export_type: type}, _file_path) do + {:error, "Unknown export type: #{type}"} + end + + # Full export: combines all data types into a zip archive + defp export_full(user_id, file_path, filters) do + temp_dir = Path.join(export_dir(), "temp_#{user_id}_#{:erlang.unique_integer([:positive])}") + File.mkdir_p!(temp_dir) + + try do + # Export each type + {:ok, email_count} = + EmailExporter.export(user_id, Path.join(temp_dir, "emails.json"), "json", filters) + + {:ok, social_count} = + SocialExporter.export(user_id, Path.join(temp_dir, "social.json"), "json", filters) + + {:ok, chat_count} = + ChatExporter.export(user_id, Path.join(temp_dir, "messages.json"), "json", filters) + + {:ok, account_count} = + AccountExporter.export(user_id, Path.join(temp_dir, "account.json"), "json", filters) + + write_relationship_import_files(temp_dir) + + total_count = email_count + social_count + chat_count + account_count + + write_full_export_manifest(temp_dir, user_id, filters, %{ + email: email_count, + social: social_count, + chat: chat_count, + account: account_count, + total: total_count + }) + + # Create zip archive + files = + temp_dir + |> File.ls!() + |> Enum.map(fn file -> + {String.to_charlist(file), File.read!(Path.join(temp_dir, file))} + end) + + {:ok, _} = :zip.create(String.to_charlist(file_path), files) + + {:ok, total_count} + after + # Clean up temp directory + _ = File.rm_rf(temp_dir) + end + end + + defp write_relationship_import_files(temp_dir) do + account_path = Path.join(temp_dir, "account.json") + + with {:ok, account_json} <- File.read(account_path), + {:ok, %{"relationships" => %{"import_lists" => import_lists}}} <- + Jason.decode(account_json) do + [ + {"following_accounts.csv", "Account address", Map.get(import_lists, "follows", [])}, + {"blocked_accounts.csv", "Account address", Map.get(import_lists, "blocks", [])}, + {"muted_accounts.csv", "Account address", Map.get(import_lists, "mutes", [])}, + {"blocked_domains.csv", "Domain", Map.get(import_lists, "domain_blocks", [])} + ] + |> Enum.each(fn {filename, header, values} -> + File.write!(Path.join(temp_dir, filename), import_csv(header, values)) + end) + else + _ -> :ok + end + end + + defp write_full_export_manifest(temp_dir, user_id, filters, counts) do + manifest = %{ + schema_version: 1, + export_type: "full", + user_id: user_id, + generated_at: DateTime.utc_now() |> DateTime.truncate(:second), + counts: counts, + filters: filters || %{}, + files: export_file_manifest(temp_dir), + relationship_imports: %{ + follows: %{ + path: "following_accounts.csv", + type: "follows", + field: "file", + header: "Account address" + }, + blocks: %{ + path: "blocked_accounts.csv", + type: "blocks", + field: "file", + header: "Account address" + }, + mutes: %{ + path: "muted_accounts.csv", + type: "mutes", + field: "file", + header: "Account address" + }, + domain_blocks: %{ + path: "blocked_domains.csv", + type: "domain_blocks", + field: "file", + header: "Domain" + } + } + } + + File.write!( + Path.join(temp_dir, "export_manifest.json"), + Jason.encode!(manifest, pretty: true) + ) + end + + defp export_file_manifest(temp_dir) do + temp_dir + |> File.ls!() + |> Enum.sort() + |> Enum.map(fn file -> + path = Path.join(temp_dir, file) + stat = File.stat!(path) + + %{ + path: file, + bytes: stat.size, + content_type: content_type_for_export_file(file) + } + end) + end + + defp content_type_for_export_file(file) do + case Path.extname(file) do + ".json" -> "application/json" + ".csv" -> "text/csv" + _ -> "application/octet-stream" + end + end + + defp import_csv(header, values) do + rows = + values + |> Enum.filter(&is_binary/1) + |> Enum.map(&escape_csv/1) + + [header | rows] + |> Enum.join("\n") + |> Kernel.<>("\n") + end + + defp escape_csv(value) do + if String.contains?(value, [",", "\"", "\n", "\r"]) do + "\"" <> String.replace(value, "\"", "\"\"") <> "\"" + else + value + end + end +end diff --git a/apps/elektrine/lib/elektrine/developer/workers/webhook_delivery_worker.ex b/apps/elektrine/lib/elektrine/developer/workers/webhook_delivery_worker.ex new file mode 100644 index 0000000..a83b3f0 --- /dev/null +++ b/apps/elektrine/lib/elektrine/developer/workers/webhook_delivery_worker.ex @@ -0,0 +1,37 @@ +defmodule Elektrine.Developer.WebhookDeliveryWorker do + @moduledoc """ + Oban worker for outbound developer webhook deliveries. + + Retries transient failures and records delivery outcomes. + """ + + use Oban.Worker, + queue: :webhooks, + max_attempts: 5, + priority: 2 + + alias Elektrine.Developer + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"delivery_id" => delivery_id}, attempt: attempt}) do + case Developer.process_webhook_delivery(delivery_id, attempt) do + {:ok, _status} -> + :ok + + {:error, :not_found} -> + {:discard, :not_found} + + {:error, {:unsafe_url, _reason}} -> + {:discard, :unsafe_url} + + {:error, :webhook_disabled} -> + {:discard, :webhook_disabled} + + {:error, {:http_error, status}} when status >= 400 and status < 500 and status != 429 -> + {:discard, {:http_error, status}} + + {:error, reason} -> + {:error, reason} + end + end +end diff --git a/apps/elektrine/lib/elektrine/discord.ex b/apps/elektrine/lib/elektrine/discord.ex new file mode 100644 index 0000000..9807c60 --- /dev/null +++ b/apps/elektrine/lib/elektrine/discord.ex @@ -0,0 +1,180 @@ +defmodule Elektrine.Discord do + @moduledoc """ + Discord integration using Lanyard API for real-time status. + """ + + require Logger + + @lanyard_base_url "https://api.lanyard.rest/v1/users/" + @discord_snowflake_regex ~r/^\d{17,20}$/ + + @doc """ + Gets Discord presence data for a user ID. + Returns nil if user is not found or offline. + """ + def get_user_presence(discord_id, opts \\ []) + + def get_user_presence(discord_id, opts) when is_binary(discord_id) do + if valid_discord_id?(discord_id) do + fetch_user_presence(discord_id, opts) + else + nil + end + end + + def get_user_presence(_, _), do: nil + + defp fetch_user_presence(discord_id, opts) do + request = Finch.build(:get, "#{@lanyard_base_url}#{discord_id}") + request_fun = Keyword.get(opts, :request_fun, &request/2) + receive_timeout = Keyword.get(opts, :receive_timeout, 5000) + + case request_fun.(request, receive_timeout: receive_timeout) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, %{"success" => true, "data" => data}} when is_map(data) -> + parse_discord_data(data) + + {:ok, %{"success" => false} = response} -> + error = Map.get(response, "error") || response + Logger.warning("Lanyard API error for #{discord_id}: #{inspect(error)}") + nil + + {:ok, decoded} -> + Logger.warning("Unexpected Lanyard response for #{discord_id}: #{inspect(decoded)}") + nil + + {:error, decode_error} -> + Logger.warning( + "Failed to parse Lanyard response for #{discord_id}: #{inspect(decode_error)}" + ) + + nil + end + + {:ok, %Finch.Response{status: 404}} -> + nil + + {:ok, %Finch.Response{status: status, body: body}} -> + Logger.warning("Lanyard API returned status #{status} for #{discord_id}. Body: #{body}") + nil + + {:error, reason} -> + Logger.error("Failed to fetch Discord data for #{discord_id}: #{inspect(reason)}") + nil + end + end + + defp valid_discord_id?(discord_id), do: Regex.match?(@discord_snowflake_regex, discord_id) + + defp request(request, opts), do: Finch.request(request, Elektrine.Finch, opts) + + # Parse Lanyard API response into useful data + defp parse_discord_data(data) do + discord_user = data["discord_user"] || %{} + + # Use global_name or display_name if available, fallback to username + display_name = + discord_user["global_name"] || + discord_user["display_name"] || + discord_user["username"] || + "Unknown" + + activities = list_value(data["activities"]) + + %{ + username: display_name, + discriminator: discord_user["discriminator"], + avatar: get_avatar_url(discord_user), + # "online", "idle", "dnd", "offline" + status: data["discord_status"], + activities: parse_activities(activities), + spotify: parse_spotify(data["spotify"]), + custom_status: get_custom_status(activities) + } + end + + # Get Discord avatar URL + defp get_avatar_url(%{"id" => user_id, "avatar" => nil}), do: get_default_avatar(user_id) + + defp get_avatar_url(%{"id" => user_id, "avatar" => avatar_hash}) when is_binary(avatar_hash) do + ext = if String.starts_with?(avatar_hash, "a_"), do: "gif", else: "webp" + "https://cdn.discordapp.com/avatars/#{user_id}/#{avatar_hash}.#{ext}?size=128" + end + + # Fallback for missing/invalid avatar + defp get_avatar_url(%{"id" => user_id}), do: get_default_avatar(user_id) + defp get_avatar_url(_), do: "https://cdn.discordapp.com/embed/avatars/0.png" + + defp get_default_avatar(user_id) when is_binary(user_id) do + case Integer.parse(user_id) do + {id_int, ""} -> + # Discord's default avatar logic + discriminator = rem(id_int, 5) + "https://cdn.discordapp.com/embed/avatars/#{discriminator}.png" + + _ -> + get_default_avatar(nil) + end + end + + defp get_default_avatar(_), do: "https://cdn.discordapp.com/embed/avatars/0.png" + + # Parse Discord activities (games, apps, etc.) + defp parse_activities(activities) do + activities + # Exclude custom status + |> Enum.reject(&(&1["type"] == 4)) + |> Enum.map(fn activity -> + %{ + name: activity["name"], + # 0: Playing, 1: Streaming, 2: Listening, 3: Watching + type: activity["type"], + details: activity["details"], + state: activity["state"] + } + end) + end + + # Parse Spotify data if listening + defp parse_spotify(nil), do: nil + defp parse_spotify(spotify) when not is_map(spotify), do: nil + + defp parse_spotify(spotify) do + %{ + song: spotify["song"], + artist: spotify["artist"], + album: spotify["album"], + album_art: album_art_url(spotify["album_art_url"]), + track_id: spotify["track_id"] + } + end + + defp album_art_url(url) when is_binary(url), do: url + defp album_art_url([url | _]) when is_binary(url), do: url + defp album_art_url(_), do: nil + + defp list_value(value) when is_list(value), do: value + defp list_value(_), do: [] + + # Get custom status message + defp get_custom_status(activities) do + activities + # Custom status type + |> Enum.find(&(&1["type"] == 4)) + |> case do + nil -> nil + activity -> activity["state"] + end + end + + @doc """ + Gets a formatted status string for display. + """ + def format_status(nil), do: "Offline" + def format_status(%{status: "online"}), do: "Online" + def format_status(%{status: "idle"}), do: "Away" + def format_status(%{status: "dnd"}), do: "Do Not Disturb" + def format_status(%{status: "offline"}), do: "Offline" + def format_status(_), do: "Unknown" +end diff --git a/apps/elektrine/lib/elektrine/domains.ex b/apps/elektrine/lib/elektrine/domains.ex new file mode 100644 index 0000000..faa90de --- /dev/null +++ b/apps/elektrine/lib/elektrine/domains.ex @@ -0,0 +1,707 @@ +defmodule Elektrine.Domains do + @moduledoc """ + Centralized helpers for local app domains. + """ + + alias Elektrine.Accounts.User + alias Elektrine.RuntimeEnv + + @default_primary_domain "example.com" + @official_email_domains ["elektrine.com", "elektrine.net", "elektrine.org", "maidcorps.net"] + + @doc """ + Primary email domain (usually the main app domain). + """ + def primary_email_domain do + email_config() + |> Keyword.get(:domain, @default_primary_domain) + |> normalize_domain(@default_primary_domain) + end + + @doc """ + Domains considered local mailbox domains. + """ + def supported_email_domains do + email_config() + |> Keyword.get(:supported_domains, [primary_email_domain()]) + |> normalize_domains() + |> reject_receive_only_domains() + |> ensure_supported_primary_domain() + end + + @doc """ + Secondary non-official built-in email domains that only accept inbound mail. + + Official Elektrine domains are user-selectable. Other configured built-in + domains outside the primary email domain default to receive-only. They are not + offered for sending, identities, profile hosts, ActivityPub discovery, or web + access. + """ + def receive_only_email_domains do + config = email_config() + + (Keyword.get(config, :receive_only_domains, []) ++ default_receive_only_email_domains(config)) + |> normalize_domains() + end + + @doc """ + All receiving email domains, including verified user custom domains. + """ + def receiving_email_domains do + (supported_email_domains() ++ receive_only_email_domains() ++ verified_custom_email_domains()) + |> normalize_domains() + |> ensure_primary_domain() + end + + @doc """ + Email domains available to a specific user for sending and main address variants. + """ + def available_email_domains_for_user(user_or_user_id) do + (supported_email_domains() ++ verified_custom_email_domains_for_user(user_or_user_id)) + |> normalize_domains() + |> ensure_primary_domain() + end + + @doc """ + Base domains that can host profile subdomains. + """ + def profile_base_domains do + configured_profile_base_domains() + end + + @doc """ + Configured base domains that can host profile subdomains. + """ + def configured_profile_base_domains do + Application.get_env(:elektrine, :profile_base_domains, [primary_email_domain()]) + |> normalize_domains() + |> reject_receive_only_domains() + |> ensure_profile_primary_domain() + end + + @doc """ + Primary base domain for profile URLs. + """ + def primary_profile_domain do + List.first(configured_profile_base_domains()) || primary_email_domain() + end + + @doc """ + Preferred built-in profile domain for user-facing URLs. + + Uses the configured primary profile domain so self-hosted deployments can control the + canonical built-in profile URL via environment configuration. + """ + def default_profile_domain do + primary_profile_domain() + end + + @doc """ + Preferred built-in profile URL for a handle. + """ + def default_profile_url_for_handle(handle) when is_binary(handle) do + normalized_handle = String.trim(handle) + + if Elektrine.Strings.present?(normalized_handle) do + built_in_profile_url_for_handle(normalized_handle) + else + nil + end + end + + def default_profile_url_for_handle(_), do: nil + + def default_profile_url_for_user(%{handle: handle, username: username} = user) do + profile_url_for_user(user, nil) || default_profile_url_for_handle(handle || username) + end + + def default_profile_url_for_user(_), do: nil + + @doc """ + All built-in profile URLs for a handle across configured profile base domains. + """ + def profile_urls_for_handle(handle) when is_binary(handle) do + normalized_handle = String.trim(handle) + + if Elektrine.Strings.present?(normalized_handle) do + configured_profile_base_domains() + |> Enum.map(&"https://#{URI.encode_www_form(normalized_handle)}.#{&1}") + |> Enum.uniq() + else + [] + end + end + + def profile_urls_for_handle(_), do: [] + + @doc """ + Public HTTPS base URL for the main site. + """ + def public_base_url do + "https://" <> primary_profile_domain() + end + + @doc """ + Public HTTPS base URL for mail-facing services. + """ + def mail_base_url do + "https://mail." <> primary_email_domain() + end + + @doc """ + Infers a local base URL for a domain using the current runtime environment. + + This is used for local federation surfaces where development instances may run + over HTTP on a non-standard port, while production and public tunnel domains + should stay on HTTPS without an explicit port suffix. + """ + def inferred_base_url_for_domain(domain) when is_binary(domain) do + normalized_domain = + domain + |> String.trim() + |> String.downcase() + + is_tunnel = + String.contains?(normalized_domain, ".") and + not String.starts_with?(normalized_domain, "localhost") + + scheme = if runtime_environment() == :prod or is_tunnel, do: "https", else: "http" + port = System.get_env("PORT") || "4000" + + if scheme == "https" or port in ["80", "443"] or is_tunnel do + "#{scheme}://#{normalized_domain}" + else + "#{scheme}://#{normalized_domain}:#{port}" + end + end + + @doc """ + Hostname advertised by SMTP and other mail protocols. + """ + def mail_hostname do + primary_email_domain() + end + + @doc """ + Optional DNS target hostname to show when onboarding custom profile domains. + + Set this to the hostname of your external profile edge, such as a dedicated + Caddy deployment, when custom domains should not point directly at the main app. + """ + def profile_custom_domain_edge_target do + System.get_env("PROFILE_CUSTOM_DOMAIN_EDGE_TARGET") + |> normalize_domain(default_profile_custom_domain_edge_target()) + end + + @doc """ + Stable hostname target for onboarding custom profile domains. + + Falls back to the primary profile domain when no dedicated edge hostname is + configured. + """ + def profile_custom_domain_routing_target do + profile_custom_domain_edge_target() || primary_profile_domain() + end + + defp default_profile_custom_domain_edge_target do + "edge." <> primary_profile_domain() + end + + defp runtime_environment do + RuntimeEnv.environment() + end + + defp email_config do + RuntimeEnv.app_config(:email, []) + end + + @doc """ + Main app hosts (root + www for each configured profile base domain). + """ + def app_hosts do + profile_base_domains() + |> Enum.flat_map(&[&1, "www." <> &1]) + |> Enum.uniq() + end + + @doc """ + Local passkey domains, including localhost for development. + """ + def local_passkey_domains do + (profile_base_domains() ++ ["localhost"]) + |> normalize_domains() + |> Enum.uniq() + end + + @doc """ + True if the given domain is one of our local mailbox domains. + """ + def local_email_domain?(domain) when is_binary(domain) do + String.downcase(domain) in supported_email_domains() + end + + def local_email_domain?(_), do: false + + @doc """ + True if the given domain receives mail locally, including verified custom domains. + """ + def receiving_email_domain?(domain) when is_binary(domain) do + String.downcase(domain) in receiving_email_domains() + end + + def receiving_email_domain?(_), do: false + + @doc """ + Builds all local email addresses for a username across supported domains. + """ + def local_addresses_for_username(username) when is_binary(username) do + normalized_username = String.trim(username) + + if Elektrine.Strings.present?(normalized_username) do + supported_email_domains() + |> Enum.map(&"#{normalized_username}@#{&1}") + else + [] + end + end + + def local_addresses_for_username(_), do: [] + + @doc """ + Builds all available main addresses for a user across system and verified custom domains. + """ + def email_addresses_for_user(%{id: user_id, username: username}) + when is_integer(user_id) and is_binary(username) do + available_email_domains_for_user(user_id) + |> Enum.map(&"#{String.trim(username)}@#{&1}") + end + + def email_addresses_for_user(_), do: [] + + @doc """ + Builds all local email address variants for a given local email address. + """ + def local_address_variants(email_address) when is_binary(email_address) do + case String.split(String.trim(String.downcase(email_address)), "@", parts: 2) do + [username, domain] when username != "" -> + if local_email_domain?(domain) do + local_addresses_for_username(username) + else + [] + end + + _ -> + [] + end + end + + def local_address_variants(_), do: [] + + @doc """ + Returns alternative local-domain variants for a local email address. + """ + def alternate_local_addresses(email_address) when is_binary(email_address) do + downcased = String.downcase(String.trim(email_address)) + local_address_variants(downcased) |> Enum.reject(&(&1 == downcased)) + end + + def alternate_local_addresses(_), do: [] + + @doc """ + Reserved local-part names that must not be user-created aliases/mailboxes. + """ + def reserved_local_parts do + [ + # RFC 2142 / operational mailboxes + "abuse", + "admin", + "administrator", + "hostmaster", + "info", + "marketing", + "noc", + "postmaster", + "sales", + "security", + "support", + "webmaster", + # Certificate/domain-control validation and infrastructure mailboxes + "ssladmin", + "ssladministrator", + "sysadmin", + # Automated/system senders + "bin", + "daemon", + "devnull", + "donotreply", + "mailer-daemon", + "no-reply", + "noreply", + "null", + "root", + "sys", + # Brand, trust, and business-sensitive mailboxes + "accounts", + "billing", + "contact", + "dmca", + "help", + "legal", + "official", + "payments", + "privacy", + "register", + "registration", + "service", + "signup", + "staff", + "team", + # Local app and protocol surface names + "mail", + "email", + "inbox", + "outbox", + "followers", + "following", + "actor", + "users", + "activities", + "relay", + "ap" + ] + end + + @doc """ + Reserved full addresses across all supported local domains. + """ + def reserved_addresses do + for local_part <- reserved_local_parts(), domain <- supported_email_domains() do + "#{local_part}@#{domain}" + end + end + + @doc """ + Default domain for local handle display and contact links. + """ + def default_user_handle_domain do + List.first(supported_email_domains()) || primary_email_domain() + end + + @doc """ + Instance domain for federation documents and actor URLs. + """ + def instance_domain do + configured = System.get_env("INSTANCE_DOMAIN") + fallback = primary_profile_domain() + normalize_domain(configured || fallback, fallback) + end + + @doc """ + Optional older ActivityPub domain to migrate from. + """ + def activitypub_move_from_domain do + configured = System.get_env("ACTIVITYPUB_MOVE_FROM_DOMAIN") + canonical = instance_domain() + normalized = normalize_domain(configured, nil) + + case normalized do + nil -> nil + "" -> nil + ^canonical -> nil + value -> value + end + end + + @doc """ + Domains treated as local for ActivityPub identity/discovery. + """ + def activitypub_domains do + ([instance_domain(), activitypub_move_from_domain()] ++ + profile_base_domains() ++ supported_email_domains()) + |> normalize_domains() + |> Enum.uniq() + end + + @doc """ + True if the given domain is one of our local ActivityPub domains. + """ + def local_activitypub_domain?(domain) when is_binary(domain) do + normalized = normalize_domain(domain, "") + Elektrine.Strings.present?(normalized) and normalized in activitypub_domains() + end + + def local_activitypub_domain?(_), do: false + + @doc """ + True if the given domain is one of our local built-in profile domains. + """ + def local_profile_domain?(domain) when is_binary(domain) do + normalized = normalize_host(domain) + + Elektrine.Strings.present?(normalized) and + (local_activitypub_domain?(normalized) or + not is_nil(configured_profile_base_domain_for_host(normalized))) + end + + def local_profile_domain?(_), do: false + + @doc """ + True if the given host is a root app host (domain or www.domain) for configured profile domains. + """ + def app_host?(host) when is_binary(host) do + normalized_host = normalize_host(host) + + case profile_base_domain_for_host(normalized_host) do + nil -> false + domain -> normalized_host == domain or normalized_host == "www." <> domain + end + end + + def app_host?(_), do: false + + @doc """ + Builds the local profile URL for a handle. + + When the current request host is a verified custom profile domain, the returned URL is the + bare root domain for that profile. + """ + def profile_url_for_handle(handle, host \\ nil) + + def profile_url_for_handle(handle, host) when is_binary(handle) do + clean_handle = + handle + |> String.trim() + |> String.trim_leading("@") + + if Elektrine.Strings.present?(clean_handle) do + case profile_custom_domain_for_host(host) do + %{domain: domain} -> + "https://#{domain}" + + _ -> + built_in_profile_url_for_handle(clean_handle) + end + else + nil + end + end + + def profile_url_for_handle(_, _), do: nil + + def profile_url_for_user(user, host \\ nil) + + def profile_url_for_user(%{handle: handle, username: username} = user, host) do + case User.built_in_subdomain_mode(user) do + "platform" -> profile_subdomain_url_for_handle(handle || username, host) + "path" -> built_in_profile_url_for_handle(handle || username) + _ -> nil + end + end + + def profile_url_for_user(_, _), do: nil + + @doc """ + Returns the configured profile base domain for a host. + Supports both root hosts and handle subdomains. + """ + def profile_base_domain_for_host(host) when is_binary(host) do + downcased = normalize_host(host) + configured_profile_base_domain_for_host(downcased) + end + + def profile_base_domain_for_host(_), do: nil + + @doc """ + Returns the single-label built-in profile subdomain for a configured profile host. + + For example, with `example.com` configured as a profile base domain, + `alice.example.com` returns `"alice"`. Root hosts, `www` hosts, unknown + hosts, and nested subdomains return `nil`. + """ + def built_in_profile_subdomain_identifier(host) when is_binary(host) do + normalized_host = normalize_host(host) + + case profile_base_domain_for_host(normalized_host) do + base_domain when is_binary(base_domain) -> + suffix = "." <> base_domain + + cond do + normalized_host in [base_domain, "www." <> base_domain] -> + nil + + String.ends_with?(normalized_host, suffix) -> + normalized_host + |> String.trim_trailing(suffix) + |> case do + label when label != "" -> if(String.contains?(label, "."), do: nil, else: label) + _ -> nil + end + + true -> + nil + end + + _ -> + nil + end + end + + def built_in_profile_subdomain_identifier(_), do: nil + + @doc """ + Returns the verified custom profile domain for a host when one exists. + Supports redirecting `www.` aliases to the root domain. + """ + def profile_custom_domain_for_host(host) when is_binary(host) do + normalized_host = normalize_host(host) + + if Elektrine.Strings.present?(normalized_host) do + maybe_profile_custom_domains(:get_verified_custom_domain_for_host, [normalized_host]) + else + nil + end + end + + def profile_custom_domain_for_host(_), do: nil + + defp profile_subdomain_url_for_handle(handle, host) when is_binary(handle) do + clean_handle = handle |> String.trim() |> String.trim_leading("@") + + if Elektrine.Strings.present?(clean_handle) do + case profile_custom_domain_for_host(host) do + %{domain: domain} -> "https://#{domain}" + _ -> "https://#{URI.encode_www_form(clean_handle)}.#{default_profile_domain()}" + end + else + nil + end + end + + defp profile_subdomain_url_for_handle(_, _), do: nil + + defp built_in_profile_url_for_handle(handle) do + encoded_handle = URI.encode_www_form(handle) + "https://#{default_profile_domain()}/#{encoded_handle}" + end + + defp verified_custom_email_domains do + maybe_custom_domains(:verified_domains, []) + end + + defp verified_custom_email_domains_for_user(%{id: user_id}), + do: verified_custom_email_domains_for_user(user_id) + + defp verified_custom_email_domains_for_user(user_id) when is_integer(user_id) do + maybe_custom_domains(:verified_domains_for_user, [user_id]) + end + + defp verified_custom_email_domains_for_user(_), do: [] + + defp configured_profile_base_domain_for_host(host) do + Enum.find(configured_profile_base_domains(), fn domain -> + host == domain or host == "www." <> domain or String.ends_with?(host, "." <> domain) + end) + end + + defp maybe_custom_domains(function_name, args) do + if Code.ensure_loaded?(Elektrine.Email.CustomDomains) and + function_exported?(Elektrine.Email.CustomDomains, function_name, length(args)) do + apply(Elektrine.Email.CustomDomains, function_name, args) + else + [] + end + rescue + _ -> [] + end + + defp maybe_profile_custom_domains(function_name, args) do + if Code.ensure_loaded?(Elektrine.Profiles.CustomDomains) and + function_exported?(Elektrine.Profiles.CustomDomains, function_name, length(args)) do + apply(Elektrine.Profiles.CustomDomains, function_name, args) + else + nil + end + rescue + _ -> nil + end + + defp ensure_primary_domain(domains) do + primary = primary_email_domain() + + if primary in domains do + domains + else + [primary | domains] + end + end + + defp ensure_supported_primary_domain(domains) do + if primary_email_domain() in receive_only_email_domains() do + domains + else + ensure_primary_domain(domains) + end + end + + defp ensure_profile_primary_domain(domains) do + if primary_email_domain() in receive_only_email_domains() do + domains + else + ensure_primary_domain(domains) + end + end + + defp reject_receive_only_domains(domains) do + receive_only = MapSet.new(receive_only_email_domains()) + Enum.reject(domains, &MapSet.member?(receive_only, &1)) + end + + defp default_receive_only_email_domains(config) do + primary = primary_email_domain() + + if local_development_domain?(primary) do + [] + else + config + |> Keyword.get(:supported_domains, [primary]) + |> normalize_domains() + |> Enum.reject(&(&1 == primary or &1 in @official_email_domains)) + end + end + + defp local_development_domain?(domain) when is_binary(domain) do + domain == "localhost" or String.ends_with?(domain, ".localhost") + end + + defp local_development_domain?(_), do: false + + defp normalize_domains(domains) when is_list(domains) do + domains + |> Enum.map(&normalize_domain(&1, nil)) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp normalize_domains(_), do: [] + + defp normalize_domain(nil, fallback), do: fallback + + defp normalize_domain(domain, fallback) when is_binary(domain) do + domain + |> String.trim() + |> String.downcase() + |> case do + "" -> fallback + value -> value + end + end + + defp normalize_host(host) when is_binary(host) do + host + |> String.trim() + |> String.downcase() + |> String.split(":", parts: 2) + |> List.first() + |> normalize_domain("") + end +end diff --git a/apps/elektrine/lib/elektrine/drive.ex b/apps/elektrine/lib/elektrine/drive.ex new file mode 100644 index 0000000..df8cb7d --- /dev/null +++ b/apps/elektrine/lib/elektrine/drive.ex @@ -0,0 +1,1287 @@ +defmodule Elektrine.Drive do + @moduledoc """ + Personal drive library with folders, share links, and DAV-friendly operations. + """ + + import Ecto.Query, warn: false + + alias Elektrine.Accounts.Storage + alias Elektrine.Accounts.User + alias Elektrine.Drive.{FileShare, StoredFile, StoredFolder} + alias Elektrine.Repo + alias ExAws.S3 + + @default_max_upload_size 100 * 1024 * 1024 + @share_expiry_options [ + {"1 day", "1d"}, + {"7 days", "7d"}, + {"30 days", "30d"}, + {"Never", "never"} + ] + @share_access_options [ + {"Download", "download"}, + {"View inline", "view"} + ] + @sort_options [ + {"Newest", "updated_desc"}, + {"Oldest", "updated_asc"}, + {"Name A-Z", "name_asc"}, + {"Name Z-A", "name_desc"}, + {"Location A-Z", "path_asc"}, + {"Location Z-A", "path_desc"}, + {"Largest", "size_desc"}, + {"Smallest", "size_asc"} + ] + @quick_filter_options [ + {"All", "all"}, + {"Recent", "recent"}, + {"Images", "images"}, + {"Docs", "documents"}, + {"Media", "media"}, + {"Shared", "shared"} + ] + @valid_share_access_levels Enum.map(@share_access_options, &elem(&1, 1)) + @valid_sort_values Enum.map(@sort_options, &elem(&1, 1)) + @valid_filter_values Enum.map(@quick_filter_options, &elem(&1, 1)) + def minimum_trust_level, do: Elektrine.System.module_min_trust_level(:drive) + def max_upload_size, do: @default_max_upload_size + def share_expiry_options, do: @share_expiry_options + def share_access_options, do: @share_access_options + def sort_options, do: @sort_options + def quick_filter_options, do: @quick_filter_options + + def user_can_access?(%User{} = user), do: Elektrine.System.user_can_access_module?(user, :drive) + def user_can_access?(_user), do: false + + def list_files(user_id) do + StoredFile + |> where([f], f.user_id == ^user_id) + |> order_by([f], asc: f.path) + |> preload(shares: ^active_shares_query()) + |> Repo.all() + end + + def list_folders(user_id) do + StoredFolder + |> where([f], f.user_id == ^user_id) + |> order_by([f], asc: f.path) + |> Repo.all() + end + + def list_folder(user_id, folder_path \\ "", opts \\ %{}) do + with {:ok, normalized_folder} <- normalize_folder(folder_path) do + normalized_opts = normalize_list_opts(opts) + files = list_files(user_id) + folders = list_folders(user_id) + + {:ok, + %{ + current_folder: normalized_folder, + breadcrumbs: breadcrumbs(normalized_folder), + folders: filtered_folder_entries(folders, files, normalized_folder, normalized_opts), + files: + files_in_folder(files, normalized_folder) + |> apply_file_filter(normalized_opts.filter) + |> filter_files(normalized_opts.search) + |> sort_files(normalized_opts.sort), + search_query: normalized_opts.search, + sort_by: normalized_opts.sort, + filter_by: normalized_opts.filter + }} + end + end + + def get_file(user_id, file_id) do + Repo.get_by(StoredFile, id: file_id, user_id: user_id) + end + + def get_file_by_path(user_id, path) when is_binary(path) do + case normalize_file_path(path) do + {:ok, normalized_path} -> Repo.get_by(StoredFile, user_id: user_id, path: normalized_path) + _ -> nil + end + end + + def normalize_folder(folder_path), do: normalize_folder_path(folder_path) + + def create_folder(user_id, path) do + with {:ok, normalized_path} <- normalize_folder(path), + false <- normalized_path == "", + false <- file_exists?(user_id, normalized_path) do + Repo.transaction(fn -> + ensure_folder_hierarchy!(user_id, normalized_path) + Repo.get_by!(StoredFolder, user_id: user_id, path: normalized_path) + end) + |> normalize_transaction_result() + else + true -> {:error, :invalid_folder_path} + {:error, reason} -> {:error, reason} + end + end + + def upload_file(%User{} = user, folder_path, %Plug.Upload{} = upload) do + with {:ok, normalized_path} <- build_visible_path(folder_path, upload.filename), + {:ok, binary} <- File.read(upload.path), + content_type <- normalize_content_type(upload.content_type, upload.filename) do + put_file_content(user, normalized_path, binary, + filename: upload.filename, + content_type: content_type + ) + end + end + + def put_file_content(%User{} = user, path, binary, opts \\ %{}) when is_binary(binary) do + opts = if Keyword.keyword?(opts), do: Map.new(opts), else: opts + + with {:ok, normalized_path} <- normalize_file_path(path), + :ok <- validate_file_size(byte_size(binary)), + existing_file <- Repo.get_by(StoredFile, user_id: user.id, path: normalized_path), + :ok <- validate_storage_limit(user, existing_file, byte_size(binary)), + :ok <- ensure_parent_folder_hierarchy(user.id, normalized_path), + filename <- Map.get(opts, :filename) || file_name_from_path(normalized_path), + content_type <- normalize_content_type(Map.get(opts, :content_type), filename), + {:ok, storage_key} <- put_storage_binary(user.id, binary, filename, content_type) do + attrs = %{ + user_id: user.id, + path: normalized_path, + storage_key: storage_key, + original_filename: file_name_from_path(normalized_path), + content_type: content_type, + size: byte_size(binary) + } + + case persist_file(existing_file, attrs) do + {:ok, file, old_storage_key} -> + maybe_delete_storage(old_storage_key) + Storage.update_user_storage(user.id) + {:ok, Repo.preload(file, :shares)} + + {:error, reason} -> + maybe_delete_storage(storage_key) + {:error, reason} + end + end + end + + def rename_file(user_id, file_id, new_name) do + with %StoredFile{} = file <- get_file(user_id, file_id), + {:ok, normalized_name} <- normalize_filename(new_name), + new_path <- join_path(parent_folder(file.path), normalized_name), + :ok <- ensure_target_file_path_available(user_id, new_path, file.id), + {:ok, updated} <- + file + |> StoredFile.changeset(%{path: new_path, original_filename: normalized_name}) + |> Repo.update() do + {:ok, Repo.preload(updated, :shares)} + else + nil -> {:error, :not_found} + {:error, reason} -> {:error, reason} + end + end + + def move_file(user_id, file_id, folder_path) do + with %StoredFile{} = file <- get_file(user_id, file_id), + {:ok, normalized_folder} <- normalize_folder(folder_path), + :ok <- ensure_folder_hierarchy(user_id, normalized_folder), + new_path <- join_path(normalized_folder, file_name_from_path(file.path)), + :ok <- ensure_target_file_path_available(user_id, new_path, file.id), + {:ok, updated} <- file |> StoredFile.changeset(%{path: new_path}) |> Repo.update() do + {:ok, Repo.preload(updated, :shares)} + else + nil -> {:error, :not_found} + {:error, reason} -> {:error, reason} + end + end + + def delete_file(user_id, file_id) do + case get_file(user_id, file_id) do + nil -> + {:error, :not_found} + + file -> + with :ok <- delete_storage(file.storage_key), + {:ok, _deleted} <- Repo.delete(file) do + Storage.update_user_storage(user_id) + :ok + else + {:error, reason} -> {:error, reason} + end + end + end + + def rename_folder(user_id, folder_path, new_name) do + with {:ok, normalized_source} <- normalize_folder(folder_path), + false <- normalized_source == "", + {:ok, normalized_name} <- normalize_filename(new_name) do + move_folder(user_id, normalized_source, parent_folder(normalized_source), normalized_name) + else + true -> {:error, :invalid_folder_path} + {:error, reason} -> {:error, reason} + end + end + + def move_folder(user_id, source_path, destination_folder_path, new_name \\ nil) do + with {:ok, normalized_source} <- normalize_folder(source_path), + {:ok, normalized_destination_folder} <- normalize_folder(destination_folder_path), + false <- normalized_source == "", + :ok <- ensure_folder_exists(user_id, normalized_source), + normalized_new_name <- new_name || Path.basename(normalized_source), + {:ok, valid_new_name} <- normalize_filename(normalized_new_name), + target_path <- join_path(normalized_destination_folder, valid_new_name), + :ok <- validate_folder_move_target(normalized_source, target_path), + :ok <- ensure_target_folder_path_available(user_id, target_path, normalized_source), + :ok <- ensure_folder_hierarchy(user_id, parent_folder(target_path)) do + Repo.transaction(fn -> + source_prefix = normalized_source <> "/" + target_prefix = target_path <> "/" + + StoredFolder + |> where( + [f], + f.user_id == ^user_id and + (f.path == ^normalized_source or like(f.path, ^"#{source_prefix}%")) + ) + |> Repo.all() + |> Enum.each(fn folder -> + new_folder_path = replace_prefix(folder.path, normalized_source, target_path) + folder |> StoredFolder.changeset(%{path: new_folder_path}) |> Repo.update!() + end) + + StoredFile + |> where([f], f.user_id == ^user_id and like(f.path, ^"#{source_prefix}%")) + |> Repo.all() + |> Enum.each(fn file -> + new_file_path = replace_prefix(file.path, normalized_source, target_path) + file |> StoredFile.changeset(%{path: new_file_path}) |> Repo.update!() + end) + + if is_nil(Repo.get_by(StoredFolder, user_id: user_id, path: target_path)) do + %StoredFolder{} + |> StoredFolder.changeset(%{user_id: user_id, path: target_path}) + |> Repo.insert!(on_conflict: :nothing) + end + + {target_path, target_prefix} + end) + |> case do + {:ok, {target_path, _target_prefix}} -> {:ok, target_path} + {:error, reason} -> {:error, reason} + end + else + true -> {:error, :invalid_folder_path} + {:error, reason} -> {:error, reason} + end + end + + def delete_folder(user_id, folder_path) do + with {:ok, normalized_folder} <- normalize_folder(folder_path), + false <- normalized_folder == "", + :ok <- ensure_folder_exists(user_id, normalized_folder) do + prefix = normalized_folder <> "/" + + files = + StoredFile + |> where([f], f.user_id == ^user_id and like(f.path, ^"#{prefix}%")) + |> Repo.all() + + with :ok <- delete_file_storage_entries(files), + {:ok, _result} <- + Repo.transaction(fn -> + StoredFile + |> where([f], f.user_id == ^user_id and like(f.path, ^"#{prefix}%")) + |> Repo.delete_all() + + StoredFolder + |> where( + [f], + f.user_id == ^user_id and + (f.path == ^normalized_folder or like(f.path, ^"#{prefix}%")) + ) + |> Repo.delete_all() + end) do + Storage.update_user_storage(user_id) + :ok + else + {:error, reason} -> {:error, reason} + end + else + true -> {:error, :invalid_folder_path} + {:error, reason} -> {:error, reason} + end + end + + def bulk_delete(user_id, selections) when is_list(selections) do + {folders, files} = parse_bulk_selections(selections) + + Enum.reduce_while(Enum.sort_by(folders, &String.length/1, :desc), :ok, fn folder, _acc -> + case delete_folder(user_id, folder) do + :ok -> {:cont, :ok} + {:error, :not_found} -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + :ok -> + Enum.reduce_while(files, :ok, fn file_id, _acc -> + case delete_file(user_id, file_id) do + :ok -> {:cont, :ok} + {:error, :not_found} -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + + {:error, reason} -> + {:error, reason} + end + end + + def bulk_move(user_id, selections, destination_folder_path) when is_list(selections) do + {folders, files} = parse_bulk_selections(selections) + + with {:ok, normalized_destination} <- normalize_folder(destination_folder_path), + :ok <- ensure_folder_hierarchy(user_id, normalized_destination) do + Enum.reduce_while(Enum.sort_by(folders, &String.length/1), :ok, fn folder, _acc -> + case move_folder(user_id, folder, normalized_destination) do + {:ok, _path} -> {:cont, :ok} + {:error, :not_found} -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + :ok -> + Enum.reduce_while(files, :ok, fn file_id, _acc -> + case move_file(user_id, file_id, normalized_destination) do + {:ok, _file} -> {:cont, :ok} + {:error, :not_found} -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + + {:error, reason} -> + {:error, reason} + end + end + end + + def create_share(user_id, file_id, attrs \\ %{}) do + with {:ok, expires_at} <- expires_at_from_attrs(attrs), + {:ok, access_level} <- access_level_from_attrs(attrs), + {:ok, password_hash} <- password_hash_from_attrs(attrs) do + case get_file(user_id, file_id) do + nil -> + {:error, :not_found} + + %StoredFile{} = file -> + token = random_share_token() + + %FileShare{} + |> FileShare.changeset(%{ + drive_file_id: file.id, + user_id: user_id, + token: token, + token_hash: share_token_hash(token), + expires_at: expires_at, + access_level: access_level, + password_hash: password_hash, + burn_after_read: + truthy?(Map.get(attrs, "burn_after_read") || Map.get(attrs, :burn_after_read)) + }) + |> Repo.insert() + end + end + end + + def revoke_share(user_id, share_id) do + query = + from(s in FileShare, + join: f in assoc(s, :stored_file), + where: s.id == ^share_id and f.user_id == ^user_id and is_nil(s.revoked_at) + ) + + case Repo.one(query) do + nil -> + {:error, :not_found} + + share -> + share + |> Ecto.Changeset.change(revoked_at: DateTime.utc_now() |> DateTime.truncate(:second)) + |> Repo.update() + end + end + + def get_active_share(token) when is_binary(token) do + token_hash = share_token_hash(token) + + FileShare + |> where([s], s.token_hash == ^token_hash and is_nil(s.revoked_at)) + |> where([s], is_nil(s.expires_at) or s.expires_at > ^DateTime.utc_now()) + |> where([s], not s.burn_after_read or s.download_count == 0) + |> preload(:stored_file) + |> Repo.one() + end + + def get_active_share(_token), do: nil + + def share_token_hash(token) when is_binary(token) do + token + |> String.trim() + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + end + + def share_token_hash(_token), do: nil + + def share_owner_can_access?(%FileShare{stored_file: %StoredFile{user_id: user_id}}) do + user_id + |> then(&Repo.get(User, &1)) + |> user_can_access?() + end + + def share_owner_can_access?(_share), do: false + + def share_requires_password?(%FileShare{password_hash: hash}), + do: is_binary(hash) and hash != "" + + def share_requires_password?(_share), do: false + + def verify_share_password(%FileShare{} = share, password) do + cond do + not share_requires_password?(share) -> true + not is_binary(password) or password == "" -> false + true -> Argon2.verify_pass(password, share.password_hash) + end + end + + def share_inline_view?(%FileShare{access_level: "view", stored_file: %StoredFile{} = file}) do + inline_viewable_content_type?(file.content_type) + end + + def share_inline_view?(_share), do: false + + def inline_content_type(content_type) when is_binary(content_type) do + normalized = normalize_media_type(content_type) + + cond do + not safe_media_type?(normalized) -> + {:error, :unsafe_content_type} + + normalized in ["image/svg+xml", "image/svg+xml-compressed"] -> + {:error, :unsafe_content_type} + + String.starts_with?(normalized, ["image/", "video/", "audio/"]) -> + {:ok, normalized} + + normalized in ["application/pdf", "application/json", "text/plain"] -> + {:ok, normalized} + + true -> + {:error, :unsafe_content_type} + end + end + + def inline_content_type(_), do: {:error, :unsafe_content_type} + + def inline_viewable_content_type?(content_type) when is_binary(content_type) do + match?({:ok, _content_type}, inline_content_type(content_type)) + end + + def inline_viewable_content_type?(_), do: false + + def safe_download_content_type(content_type) do + normalized = normalize_media_type(content_type) + + if safe_media_type?(normalized), do: normalized, else: "application/octet-stream" + end + + def safe_download_filename(filename, fallback \\ "download") do + fallback = safe_filename_fallback(fallback) + + filename + |> to_string() + |> Path.basename() + |> String.replace(~r/[\x00-\x1F\x7F"\\\/<>:|?*]/, "_") + |> String.trim() + |> case do + "" -> fallback + "." -> fallback + ".." -> fallback + value -> String.slice(value, 0, 255) + end + end + + def increment_share_download_count(%FileShare{} = share) do + share + |> Ecto.Changeset.change(download_count: (share.download_count || 0) + 1) + |> Repo.update() + end + + def reserve_share_download(%FileShare{} = share) do + now = DateTime.utc_now() + + query = + FileShare + |> where([s], s.id == ^share.id and is_nil(s.revoked_at)) + |> where([s], is_nil(s.expires_at) or s.expires_at > ^now) + |> where([s], not s.burn_after_read or s.download_count == 0) + + case Repo.update_all(query, inc: [download_count: 1]) do + {1, _} -> :ok + _ -> {:error, :not_found} + end + end + + def read_file(%StoredFile{} = file) do + case storage_adapter() do + :local -> read_local(file.storage_key) + :s3 -> read_s3(file.storage_key) + end + end + + def storage_used(user_id) do + case StoredFile + |> where([f], f.user_id == ^user_id) + |> select([f], sum(f.size)) + |> Repo.one() do + nil -> 0 + %Decimal{} = decimal -> Decimal.to_integer(decimal) + value when is_integer(value) -> value + _ -> 0 + end + end + + defp persist_file(nil, attrs) do + case %StoredFile{} |> StoredFile.changeset(attrs) |> Repo.insert() do + {:ok, file} -> {:ok, file, nil} + {:error, changeset} -> {:error, changeset} + end + end + + defp persist_file(%StoredFile{} = existing_file, attrs) do + old_storage_key = existing_file.storage_key + + case existing_file |> StoredFile.changeset(attrs) |> Repo.update() do + {:ok, file} -> {:ok, file, old_storage_key} + {:error, changeset} -> {:error, changeset} + end + end + + defp validate_file_size(size) when size <= 0, do: {:error, :empty_file} + + defp validate_file_size(size) when size > @default_max_upload_size do + {:error, {:file_too_large, @default_max_upload_size}} + end + + defp validate_file_size(_size), do: :ok + + defp validate_storage_limit(%User{is_admin: true}, _existing_file, _size), do: :ok + + defp validate_storage_limit(%User{id: user_id}, existing_file, new_size) do + existing_size = if existing_file, do: existing_file.size || 0, else: 0 + additional_bytes = max(new_size - existing_size, 0) + + if Storage.would_exceed_limit?(user_id, additional_bytes) do + {:error, :storage_limit_exceeded} + else + :ok + end + end + + defp ensure_parent_folder_hierarchy(user_id, file_path) do + ensure_folder_hierarchy(user_id, parent_folder(file_path)) + end + + defp ensure_folder_hierarchy(_user_id, ""), do: :ok + + defp ensure_folder_hierarchy(user_id, normalized_folder) when is_binary(normalized_folder) do + with {:ok, folder} <- normalize_folder(normalized_folder) do + Repo.transaction(fn -> ensure_folder_hierarchy!(user_id, folder) end) + |> case do + {:ok, _result} -> :ok + {:error, reason} -> {:error, reason} + end + end + end + + defp ensure_folder_hierarchy!(user_id, folder) do + folder + |> folder_ancestors() + |> Enum.each(fn path -> + %StoredFolder{} + |> StoredFolder.changeset(%{user_id: user_id, path: path}) + |> Repo.insert!(on_conflict: :nothing) + end) + end + + defp folder_ancestors(""), do: [] + + defp folder_ancestors(folder) do + folder + |> String.split("/", trim: true) + |> Enum.reduce({[], ""}, fn segment, {paths, prefix} -> + current = if prefix == "", do: segment, else: prefix <> "/" <> segment + {paths ++ [current], current} + end) + |> elem(0) + end + + defp ensure_target_file_path_available(user_id, path, existing_id) do + cond do + file_conflict?(user_id, path, existing_id) -> {:error, :path_taken} + folder_exists?(user_id, path) -> {:error, :path_taken} + true -> :ok + end + end + + defp ensure_target_folder_path_available(user_id, path, source_path) do + cond do + is_binary(source_path) and path == source_path -> :ok + folder_exists?(user_id, path) -> {:error, :path_taken} + file_exists?(user_id, path) -> {:error, :path_taken} + true -> :ok + end + end + + defp validate_folder_move_target(source_path, target_path) do + cond do + target_path == source_path -> + {:error, :path_taken} + + String.starts_with?(target_path <> "/", source_path <> "/") -> + {:error, :invalid_move_target} + + true -> + :ok + end + end + + defp file_conflict?(user_id, path, existing_id) do + query = from(f in StoredFile, where: f.user_id == ^user_id and f.path == ^path) + query = if existing_id, do: where(query, [f], f.id != ^existing_id), else: query + Repo.exists?(query) + end + + defp file_exists?(user_id, path) do + Repo.exists?(from(f in StoredFile, where: f.user_id == ^user_id and f.path == ^path)) + end + + defp folder_exists?(_user_id, ""), do: true + + defp folder_exists?(user_id, path) do + prefix = path <> "/" + + Repo.exists?(from(f in StoredFolder, where: f.user_id == ^user_id and f.path == ^path)) or + Repo.exists?( + from(f in StoredFolder, where: f.user_id == ^user_id and like(f.path, ^"#{prefix}%")) + ) or + Repo.exists?( + from(f in StoredFile, where: f.user_id == ^user_id and like(f.path, ^"#{prefix}%")) + ) + end + + defp ensure_folder_exists(user_id, path) do + if folder_exists?(user_id, path), do: :ok, else: {:error, :not_found} + end + + defp build_visible_path(folder_path, filename) do + with {:ok, normalized_folder} <- normalize_folder_path(folder_path), + {:ok, normalized_filename} <- normalize_filename(filename) do + {:ok, join_path(normalized_folder, normalized_filename)} + end + end + + defp normalize_file_path(path) when is_binary(path) do + normalized = + path + |> String.trim() + |> String.trim("/") + |> String.replace("\\", "/") + + case String.split(normalized, "/", trim: true) do + [] -> + {:error, :invalid_filename} + + segments -> + {folder_segments, [filename]} = Enum.split(segments, length(segments) - 1) + + with {:ok, folder} <- normalize_folder_path(Enum.join(folder_segments, "/")), + {:ok, normalized_filename} <- normalize_filename(filename) do + {:ok, join_path(folder, normalized_filename)} + end + end + end + + defp normalize_file_path(_), do: {:error, :invalid_filename} + + defp normalize_folder_path(nil), do: {:ok, ""} + + defp normalize_folder_path(folder_path) when is_binary(folder_path) do + normalized = + folder_path + |> String.trim() + |> String.trim("/") + |> String.replace("\\", "/") + + segments = String.split(normalized, "/", trim: true) + + cond do + normalized == "" -> {:ok, ""} + Enum.any?(segments, &invalid_path_segment?/1) -> {:error, :invalid_folder_path} + true -> {:ok, Enum.join(segments, "/")} + end + end + + defp normalize_folder_path(_), do: {:error, :invalid_folder_path} + + defp normalize_filename(filename) when is_binary(filename) do + normalized = filename |> Path.basename() |> String.trim() + + cond do + normalized == "" -> {:error, :invalid_filename} + invalid_path_segment?(normalized) -> {:error, :invalid_filename} + true -> {:ok, normalized} + end + end + + defp normalize_filename(_), do: {:error, :invalid_filename} + + defp invalid_path_segment?(segment) do + trimmed = String.trim(segment) + trimmed == "" or trimmed in [".", ".."] or String.contains?(trimmed, [<<0>>, "/", "\\"]) + end + + defp files_in_folder(files, current_folder) do + Enum.filter(files, &(parent_folder(&1.path) == current_folder)) + end + + defp build_folder_entries(folders, files, current_folder, opts) do + folder_paths = + (Enum.map(folders, &immediate_child_folder_from_folder(&1.path, current_folder)) ++ + Enum.map(files, &immediate_child_folder_from_file(&1.path, current_folder))) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + + folder_paths + |> Enum.map(fn path -> + %{ + name: Path.basename(path), + path: path, + file_count: count_files_under(files, path), + updated_at: latest_update_under(files, folders, path) + } + end) + |> filter_folders(opts.search) + |> sort_folders(opts.sort) + end + + defp count_files_under(files, folder_path) do + prefix = folder_path <> "/" + Enum.count(files, &String.starts_with?(&1.path, prefix)) + end + + defp latest_update_under(files, folders, folder_path) do + prefix = folder_path <> "/" + + ((Enum.filter(files, &String.starts_with?(&1.path, prefix)) |> Enum.map(& &1.updated_at)) ++ + (Enum.filter(folders, &(&1.path == folder_path or String.starts_with?(&1.path, prefix))) + |> Enum.map(& &1.updated_at))) + |> Enum.reject(&is_nil/1) + |> Enum.max(DateTime, fn -> nil end) + end + + defp immediate_child_folder(path, current_folder) do + current_prefix = if current_folder == "", do: "", else: current_folder <> "/" + + if String.starts_with?(path, current_prefix) do + remainder = + if current_prefix == "", do: path, else: String.trim_leading(path, current_prefix) + + case String.split(remainder, "/", parts: 2) do + [_single] -> nil + [child, _rest] -> join_path(current_folder, child) + end + else + nil + end + end + + defp immediate_child_folder_from_folder(path, current_folder) do + cond do + path == current_folder -> + nil + + current_folder == "" and not String.contains?(path, "/") -> + path + + current_folder != "" and String.starts_with?(path, current_folder <> "/") -> + remainder = String.trim_leading(path, current_folder <> "/") + + case String.split(remainder, "/", parts: 2) do + [child] -> join_path(current_folder, child) + [child, _rest] -> join_path(current_folder, child) + end + + true -> + nil + end + end + + defp immediate_child_folder_from_file(path, current_folder), + do: immediate_child_folder(path, current_folder) + + defp parent_folder(path) do + case String.split(path, "/", trim: true) do + [_filename] -> "" + segments -> segments |> Enum.drop(-1) |> Enum.join("/") + end + end + + defp breadcrumbs(""), do: [%{name: "My Drive", path: ""}] + + defp breadcrumbs(folder_path) do + folder_path + |> String.split("/", trim: true) + |> Enum.reduce({[%{name: "My Drive", path: ""}], ""}, fn segment, {crumbs, prefix} -> + path = join_path(prefix, segment) + {crumbs ++ [%{name: segment, path: path}], path} + end) + |> elem(0) + end + + defp normalize_list_opts(opts) do + search = + Map.get(opts, :search) || Map.get(opts, "search") || Map.get(opts, :q) || Map.get(opts, "q") || + "" + + sort = Map.get(opts, :sort) || Map.get(opts, "sort") || "updated_desc" + filter = Map.get(opts, :filter) || Map.get(opts, "filter") || "all" + + %{ + search: String.trim(to_string(search)), + sort: normalize_sort(sort), + filter: normalize_filter(filter) + } + end + + defp normalize_sort(sort) when sort in @valid_sort_values, do: sort + defp normalize_sort(sort) when is_binary(sort) and sort in @valid_sort_values, do: sort + defp normalize_sort(_sort), do: "updated_desc" + + defp normalize_filter(filter) when filter in @valid_filter_values, do: filter + + defp normalize_filter(filter) when is_binary(filter) and filter in @valid_filter_values, + do: filter + + defp normalize_filter(_filter), do: "all" + + defp filtered_folder_entries(folders, files, current_folder, opts) do + if opts.filter == "all" do + build_folder_entries(folders, files, current_folder, opts) + else + [] + end + end + + defp apply_file_filter(files, "all"), do: files + + defp apply_file_filter(files, "recent") do + threshold = DateTime.utc_now() |> DateTime.add(-1_209_600, :second) + + Enum.filter(files, fn file -> + case file.updated_at do + %DateTime{} = updated_at -> DateTime.compare(updated_at, threshold) != :lt + _ -> false + end + end) + end + + defp apply_file_filter(files, "images") do + Enum.filter(files, &content_type_match?(&1.content_type, :images)) + end + + defp apply_file_filter(files, "documents") do + Enum.filter(files, &content_type_match?(&1.content_type, :documents)) + end + + defp apply_file_filter(files, "media") do + Enum.filter(files, &content_type_match?(&1.content_type, :media)) + end + + defp apply_file_filter(files, "shared") do + Enum.filter(files, &match?([_ | _], &1.shares || [])) + end + + defp apply_file_filter(files, _filter), do: files + + defp content_type_match?(content_type, :images), + do: is_binary(content_type) and String.starts_with?(content_type, "image/") + + defp content_type_match?(content_type, :media), + do: + is_binary(content_type) and + String.starts_with?(content_type, ["image/", "video/", "audio/"]) + + defp content_type_match?(content_type, :documents) do + is_binary(content_type) and + (String.starts_with?(content_type, "text/") or + content_type in [ + "application/pdf", + "application/json", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ]) + end + + defp filter_files(files, ""), do: files + + defp filter_files(files, search) do + needle = String.downcase(search) + + Enum.filter(files, fn file -> + String.contains?(String.downcase(file.original_filename || file.path), needle) or + String.contains?(String.downcase(file.path), needle) + end) + end + + defp filter_folders(folders, ""), do: folders + + defp filter_folders(folders, search) do + needle = String.downcase(search) + Enum.filter(folders, &String.contains?(String.downcase(&1.name), needle)) + end + + defp sort_files(files, "name_asc"), + do: Enum.sort_by(files, &String.downcase(&1.original_filename || &1.path), :asc) + + defp sort_files(files, "name_desc"), + do: Enum.sort_by(files, &String.downcase(&1.original_filename || &1.path), :desc) + + defp sort_files(files, "updated_asc"), + do: Enum.sort_by(files, & &1.updated_at, {:asc, DateTime}) + + defp sort_files(files, "updated_desc"), + do: Enum.sort_by(files, & &1.updated_at, {:desc, DateTime}) + + defp sort_files(files, "path_asc"), do: Enum.sort_by(files, &String.downcase(&1.path), :asc) + defp sort_files(files, "path_desc"), do: Enum.sort_by(files, &String.downcase(&1.path), :desc) + + defp sort_files(files, "size_asc"), do: Enum.sort_by(files, &(&1.size || 0), :asc) + defp sort_files(files, "size_desc"), do: Enum.sort_by(files, &(&1.size || 0), :desc) + defp sort_files(files, _sort), do: files + + defp sort_folders(folders, "path_asc"), + do: Enum.sort_by(folders, &String.downcase(&1.path), :asc) + + defp sort_folders(folders, "path_desc"), + do: Enum.sort_by(folders, &String.downcase(&1.path), :desc) + + defp sort_folders(folders, "name_desc"), + do: Enum.sort_by(folders, &String.downcase(&1.name), :desc) + + defp sort_folders(folders, "updated_asc"), + do: Enum.sort_by(folders, &(&1.updated_at || ~U[1970-01-01 00:00:00Z]), {:asc, DateTime}) + + defp sort_folders(folders, "updated_desc"), + do: Enum.sort_by(folders, &(&1.updated_at || ~U[1970-01-01 00:00:00Z]), {:desc, DateTime}) + + defp sort_folders(folders, _sort), do: Enum.sort_by(folders, &String.downcase(&1.name), :asc) + + defp parse_bulk_selections(selections) do + Enum.reduce(selections, {[], []}, fn selection, {folders, files} -> + cond do + String.starts_with?(selection, "folder:") -> + {[String.replace_prefix(selection, "folder:", "") | folders], files} + + String.starts_with?(selection, "file:") -> + case Integer.parse(String.replace_prefix(selection, "file:", "")) do + {id, ""} -> {folders, [id | files]} + _ -> {folders, files} + end + + true -> + {folders, files} + end + end) + |> then(fn {folders, files} -> {Enum.uniq(folders), Enum.uniq(files)} end) + end + + defp active_shares_query do + from(s in FileShare, + where: is_nil(s.revoked_at), + where: is_nil(s.expires_at) or s.expires_at > ^DateTime.utc_now(), + order_by: [desc: s.inserted_at] + ) + end + + defp expires_at_from_attrs(%{"expires_in" => expires_in}), + do: expires_at_from_option(expires_in) + + defp expires_at_from_attrs(%{expires_in: expires_in}), do: expires_at_from_option(expires_in) + defp expires_at_from_attrs(_attrs), do: {:ok, nil} + + defp access_level_from_attrs(%{"access_level" => access_level}), + do: access_level_from_option(access_level) + + defp access_level_from_attrs(%{access_level: access_level}), + do: access_level_from_option(access_level) + + defp access_level_from_attrs(_attrs), do: {:ok, "download"} + + defp access_level_from_option(level) when level in @valid_share_access_levels, do: {:ok, level} + defp access_level_from_option(_level), do: {:error, :invalid_share_access} + + defp password_hash_from_attrs(%{"password" => password}), do: hash_share_password(password) + defp password_hash_from_attrs(%{password: password}), do: hash_share_password(password) + defp password_hash_from_attrs(_attrs), do: {:ok, nil} + + defp hash_share_password(password) when not is_binary(password) or password == "", + do: {:ok, nil} + + defp hash_share_password(password) when byte_size(password) < 4 do + {:error, :invalid_share_password} + end + + defp hash_share_password(password) do + {:ok, Argon2.hash_pwd_salt(password)} + end + + defp truthy?(value), do: value in [true, "true", "1", 1, "on"] + + defp expires_at_from_option(nil), do: {:ok, nil} + defp expires_at_from_option(""), do: {:ok, nil} + defp expires_at_from_option("never"), do: {:ok, nil} + + defp expires_at_from_option("1d"), + do: {:ok, DateTime.utc_now() |> DateTime.add(86_400, :second) |> DateTime.truncate(:second)} + + defp expires_at_from_option("7d"), + do: {:ok, DateTime.utc_now() |> DateTime.add(604_800, :second) |> DateTime.truncate(:second)} + + defp expires_at_from_option("30d"), + do: + {:ok, DateTime.utc_now() |> DateTime.add(2_592_000, :second) |> DateTime.truncate(:second)} + + defp expires_at_from_option(_), do: {:error, :invalid_share_expiry} + + defp normalize_content_type(content_type, filename) do + value = + content_type + |> normalize_media_type() + + normalized = + if value == "", + do: MIME.from_path(filename || ""), + else: value + + cond do + not safe_media_type?(normalized) -> "application/octet-stream" + active_content_type?(normalized) -> "application/octet-stream" + true -> normalized + end + end + + defp active_content_type?(content_type) do + content_type in [ + "text/html", + "text/xml", + "text/javascript", + "application/javascript", + "application/ecmascript", + "application/xml", + "application/xhtml+xml", + "image/svg+xml" + ] + end + + defp normalize_media_type(content_type) do + content_type + |> to_string() + |> String.trim() + |> String.downcase() + |> String.split(";", parts: 2) + |> List.first() + |> to_string() + |> String.trim() + end + + defp safe_media_type?(content_type) do + Regex.match?(~r/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/, content_type) + end + + defp safe_filename_fallback(fallback) do + fallback + |> to_string() + |> String.replace(~r/[^A-Za-z0-9._-]/, "_") + |> String.trim("._-") + |> case do + "" -> "download" + value -> String.slice(value, 0, 100) + end + end + + defp put_storage_binary(user_id, binary, filename, content_type) do + storage_key = generate_storage_key(user_id, filename) + + case storage_adapter() do + :local -> + with {:ok, path} <- local_storage_path(storage_key), + :ok <- File.mkdir_p(Path.dirname(path)), + :ok <- File.write(path, binary) do + {:ok, storage_key} + else + {:error, reason} -> {:error, reason} + end + + :s3 -> + bucket = get_bucket() + + case S3.put_object(bucket, storage_key, binary, content_type: content_type) + |> ExAws.request() do + {:ok, _response} -> {:ok, storage_key} + {:error, reason} -> {:error, reason} + end + end + end + + defp read_local(storage_key) do + with {:ok, path} <- local_storage_path(storage_key), + {:ok, binary} <- File.read(path) do + {:ok, binary} + else + {:error, reason} -> {:error, reason} + end + end + + defp read_s3(storage_key) do + case S3.get_object(get_bucket(), storage_key) |> ExAws.request() do + {:ok, %{body: binary}} -> {:ok, binary} + {:error, reason} -> {:error, reason} + end + end + + defp delete_file_storage_entries(files) do + Enum.reduce_while(files, :ok, fn file, _acc -> + case delete_storage(file.storage_key) do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp delete_storage(storage_key) do + case storage_adapter() do + :local -> + case local_storage_path(storage_key) do + {:ok, path} -> + case File.rm(path) do + :ok -> :ok + {:error, :enoent} -> :ok + {:error, reason} -> {:error, reason} + end + + {:error, reason} -> + {:error, reason} + end + + :s3 -> + case S3.delete_object(get_bucket(), storage_key) |> ExAws.request() do + {:ok, _response} -> :ok + {:error, reason} -> {:error, reason} + end + end + end + + defp maybe_delete_storage(nil), do: :ok + defp maybe_delete_storage(storage_key), do: delete_storage(storage_key) + + defp replace_prefix(path, source_prefix, target_prefix) when path == source_prefix, + do: target_prefix + + defp replace_prefix(path, source_prefix, target_prefix), + do: String.replace_prefix(path, source_prefix <> "/", target_prefix <> "/") + + defp join_path("", name), do: name + defp join_path(folder, name), do: folder <> "/" <> name + + defp file_name_from_path(path), do: Path.basename(path) + + defp generate_storage_key(user_id, filename) do + random = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false) + sanitized = anonymized_storage_filename(filename) + "cloud-files/#{user_id}/#{random}/#{sanitized}" + end + + defp sanitize_storage_filename(filename) when is_binary(filename) do + filename + |> Path.basename() + |> String.replace(~r/[^A-Za-z0-9._-]/, "_") + |> case do + "" -> "file" + "." -> "file" + ".." -> "file" + value -> String.slice(value, 0, 100) + end + end + + defp sanitize_storage_filename(_), do: "file" + + defp anonymized_storage_filename(filename) do + extension = + filename + |> sanitize_storage_filename() + |> Path.extname() + |> String.downcase() + |> case do + "" -> ".bin" + ext -> ext + end + + random = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false) + random <> extension + end + + defp local_storage_path(storage_key) when is_binary(storage_key) do + uploads_dir = + Application.get_env(:elektrine, :uploads, [])[:uploads_dir] || "priv/static/uploads" + + base = Path.expand(uploads_dir) + expanded = Path.expand(Path.join(base, storage_key)) + + cond do + storage_key == "" -> + {:error, :invalid_storage_key} + + Path.type(storage_key) != :relative -> + {:error, :invalid_storage_key} + + String.contains?(storage_key, [<<0>>, "\\"]) -> + {:error, :invalid_storage_key} + + expanded == base or not String.starts_with?(expanded, base <> "/") -> + {:error, :invalid_storage_key} + + true -> + {:ok, expanded} + end + end + + defp local_storage_path(_), do: {:error, :invalid_storage_key} + + defp storage_adapter do + Application.get_env(:elektrine, :uploads, [])[:adapter] || :local + end + + defp get_bucket do + Application.get_env(:elektrine, :uploads, [])[:bucket] || raise "S3 bucket not configured" + end + + defp random_share_token do + :crypto.strong_rand_bytes(24) |> Base.url_encode64(padding: false) + end + + defp normalize_transaction_result({:ok, result}), do: {:ok, result} + defp normalize_transaction_result({:error, reason}), do: {:error, reason} +end diff --git a/apps/elektrine/lib/elektrine/drive/file_share.ex b/apps/elektrine/lib/elektrine/drive/file_share.ex new file mode 100644 index 0000000..0d2e9af --- /dev/null +++ b/apps/elektrine/lib/elektrine/drive/file_share.ex @@ -0,0 +1,69 @@ +defmodule Elektrine.Drive.FileShare do + @moduledoc """ + Public share links for user-owned files. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias Elektrine.Drive.StoredFile + alias Elektrine.Secrets.EncryptedString + + schema "drive_shares" do + field :token, EncryptedString + field :token_hash, :string + field :revoked_at, :utc_datetime + field :expires_at, :utc_datetime + field :access_level, :string, default: "download" + field :password_hash, :string + field :download_count, :integer, default: 0 + field :burn_after_read, :boolean, default: false + + belongs_to :stored_file, StoredFile, foreign_key: :drive_file_id + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + def changeset(share, attrs) do + share + |> cast(attrs, [ + :drive_file_id, + :user_id, + :token, + :token_hash, + :revoked_at, + :expires_at, + :access_level, + :password_hash, + :download_count, + :burn_after_read + ]) + |> validate_required([:drive_file_id, :user_id, :token, :token_hash]) + |> validate_length(:token, min: 16, max: 255) + |> validate_length(:token_hash, is: 64) + |> validate_number(:download_count, greater_than_or_equal_to: 0) + |> validate_inclusion(:access_level, ["download", "view"]) + |> validate_expiry() + |> foreign_key_constraint(:drive_file_id) + |> foreign_key_constraint(:user_id) + |> unique_constraint(:token_hash) + end + + defp validate_expiry(changeset) do + case get_change(changeset, :expires_at) do + %DateTime{} = expires_at -> + if DateTime.compare(expires_at, DateTime.utc_now()) == :gt do + changeset + else + add_error(changeset, :expires_at, "must be in the future") + end + + nil -> + changeset + + _ -> + add_error(changeset, :expires_at, "is invalid") + end + end +end diff --git a/apps/elektrine/lib/elektrine/drive/stored_file.ex b/apps/elektrine/lib/elektrine/drive/stored_file.ex new file mode 100644 index 0000000..1e86512 --- /dev/null +++ b/apps/elektrine/lib/elektrine/drive/stored_file.ex @@ -0,0 +1,72 @@ +defmodule Elektrine.Drive.StoredFile do + @moduledoc """ + Metadata for a user-owned file stored in local or S3-compatible object storage. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias Elektrine.Drive.FileShare + + schema "drive_files" do + field :path, :string + field :storage_key, :string + field :original_filename, :string + field :content_type, :string + field :size, :integer + + belongs_to :user, Elektrine.Accounts.User + has_many :shares, FileShare, foreign_key: :drive_file_id + + timestamps(type: :utc_datetime) + end + + def changeset(file, attrs) do + file + |> cast(attrs, [:user_id, :path, :storage_key, :original_filename, :content_type, :size]) + |> validate_required([ + :user_id, + :path, + :storage_key, + :original_filename, + :content_type, + :size + ]) + |> validate_length(:path, max: 500) + |> validate_length(:storage_key, max: 500) + |> validate_length(:original_filename, max: 255) + |> validate_length(:content_type, max: 255) + |> validate_number(:size, greater_than: 0) + |> validate_path() + |> foreign_key_constraint(:user_id) + |> unique_constraint([:user_id, :path]) + end + + defp validate_path(changeset) do + case get_change(changeset, :path) do + nil -> + changeset + + path -> + cond do + not Elektrine.Strings.present?(path) -> + add_error(changeset, :path, "cannot be blank") + + String.starts_with?(path, "/") -> + add_error(changeset, :path, "cannot start with '/'") + + String.contains?(path, "..") -> + add_error(changeset, :path, "cannot contain '..'") + + String.contains?(path, ["//", "\\", <<0>>]) -> + add_error(changeset, :path, "contains invalid path segments") + + Enum.any?(String.split(path, "/", trim: true), &(not Elektrine.Strings.present?(&1))) -> + add_error(changeset, :path, "contains empty path segments") + + true -> + changeset + end + end + end +end diff --git a/apps/elektrine/lib/elektrine/drive/stored_folder.ex b/apps/elektrine/lib/elektrine/drive/stored_folder.ex new file mode 100644 index 0000000..bb62a1d --- /dev/null +++ b/apps/elektrine/lib/elektrine/drive/stored_folder.ex @@ -0,0 +1,51 @@ +defmodule Elektrine.Drive.StoredFolder do + @moduledoc """ + Explicit folder records for the personal drive library. + """ + + use Ecto.Schema + import Ecto.Changeset + + schema "drive_folders" do + field :path, :string + + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + def changeset(folder, attrs) do + folder + |> cast(attrs, [:user_id, :path]) + |> validate_required([:user_id, :path]) + |> validate_length(:path, max: 500) + |> validate_path() + |> foreign_key_constraint(:user_id) + |> unique_constraint([:user_id, :path]) + end + + defp validate_path(changeset) do + case get_change(changeset, :path) do + nil -> + changeset + + path -> + cond do + not Elektrine.Strings.present?(path) -> + add_error(changeset, :path, "cannot be blank") + + String.starts_with?(path, "/") -> + add_error(changeset, :path, "cannot start with '/'") + + String.contains?(path, "..") -> + add_error(changeset, :path, "cannot contain '..'") + + String.contains?(path, ["//", "\\", <<0>>]) -> + add_error(changeset, :path, "contains invalid path segments") + + true -> + changeset + end + end + end +end diff --git a/apps/elektrine/lib/elektrine/email_addresses.ex b/apps/elektrine/lib/elektrine/email_addresses.ex new file mode 100644 index 0000000..993bdc6 --- /dev/null +++ b/apps/elektrine/lib/elektrine/email_addresses.ex @@ -0,0 +1,49 @@ +defmodule Elektrine.EmailAddresses do + @moduledoc """ + Helpers for rendering instance and user email addresses from configured domains. + """ + + alias Elektrine.Domains + + def local(local_part) when is_binary(local_part) do + "#{String.trim(local_part)}@#{Domains.primary_email_domain()}" + end + + def mailto(local_part) when is_binary(local_part) do + "mailto:" <> local(local_part) + end + + def uid(value) do + "#{value |> to_string() |> String.trim()}@#{Domains.primary_email_domain()}" + end + + def message_id(value) do + "<#{uid(value)}>" + end + + def list_id(value) do + "<#{value |> to_string() |> String.trim()}.#{Domains.primary_email_domain()}>" + end + + def primary_for_user(%{username: username} = user) when is_binary(username) do + domain = + case Map.get(user, :preferred_email_domain) do + value when is_binary(value) -> + trimmed = String.trim(value) + + if trimmed == "" do + Domains.default_user_handle_domain() + else + trimmed + end + + _ -> + Domains.default_user_handle_domain() + end + + "#{String.trim(username)}@#{domain}" + end + + def primary_for_user(_), do: nil + def mail_base_url, do: Domains.mail_base_url() +end diff --git a/apps/elektrine/lib/elektrine/emojis.ex b/apps/elektrine/lib/elektrine/emojis.ex new file mode 100644 index 0000000..84d71cf --- /dev/null +++ b/apps/elektrine/lib/elektrine/emojis.ex @@ -0,0 +1,427 @@ +defmodule Elektrine.Emojis do + @moduledoc """ + Context for managing custom emojis from local and federated instances. + """ + import Ecto.Query + alias Elektrine.Emojis.CustomEmoji + alias Elektrine.Repo + + @doc """ + Gets a custom emoji by shortcode and instance domain. + Returns nil if not found. + """ + def get_custom_emoji(shortcode, instance_domain \\ nil) do + query = + from e in CustomEmoji, + where: e.shortcode == ^shortcode + + query = + case direct_lookup_scope(instance_domain) do + :local -> + from e in query, where: is_nil(e.instance_domain) + + {:domain, domain} -> + from e in query, where: e.instance_domain == ^domain + end + + Repo.one(query) + end + + @doc """ + Gets or creates a custom emoji from ActivityPub emoji data. + + Expected format from ActivityPub: + %{ + "type" => "Emoji", + "name" => ":blobcat:", + "icon" => %{ + "url" => "https://mastodon.social/emoji/blobcat.png" + } + } + """ + def get_or_create_from_activitypub(emoji_data, instance_domain) do + with {:ok, shortcode} <- extract_shortcode(emoji_data["name"]), + {:ok, image_url} <- extract_image_url(emoji_data) do + case get_custom_emoji(shortcode, instance_domain) do + nil -> + %CustomEmoji{} + |> CustomEmoji.changeset(%{ + shortcode: shortcode, + image_url: image_url, + instance_domain: instance_domain, + visible_in_picker: false + }) + |> Repo.insert() + + emoji -> + # Update image URL if changed + if emoji.image_url != image_url do + emoji + |> CustomEmoji.changeset(%{image_url: image_url}) + |> Repo.update() + else + {:ok, emoji} + end + end + end + end + + @doc """ + Processes ActivityPub tags array and caches any custom emojis. + Returns a list of cached emojis. + """ + def process_activitypub_tags(tags, instance_domain) when is_list(tags) do + tags + |> Enum.filter(&(&1["type"] == "Emoji")) + |> Enum.map(fn emoji_data -> + case get_or_create_from_activitypub(emoji_data, instance_domain) do + {:ok, emoji} -> emoji + _ -> nil + end + end) + |> Enum.reject(&is_nil/1) + end + + def process_activitypub_tags(_, _), do: [] + + @doc """ + Lists all custom emojis visible in the picker. + """ + def list_picker_emojis do + Repo.all( + from e in CustomEmoji, + where: e.visible_in_picker == true and e.disabled == false, + order_by: [asc: e.category, asc: e.shortcode] + ) + end + + @doc """ + Lists picker-visible custom emojis with safe image URLs for public API clients. + """ + def list_public_picker_emojis do + list_picker_emojis() + |> Enum.filter(fn emoji -> + match?({:ok, _safe_url}, CustomEmoji.validate_image_url(emoji.image_url)) + end) + end + + @doc """ + Searches for custom emojis by shortcode. + """ + def search_emojis(query, limit \\ 20) do + pattern = "%#{query}%" + + Repo.all( + from e in CustomEmoji, + where: ilike(e.shortcode, ^pattern) and e.disabled == false, + order_by: [asc: fragment("length(?)", e.shortcode), asc: e.shortcode], + limit: ^limit + ) + end + + @doc """ + Parses text and replaces custom emoji shortcodes with HTML img tags. + Returns {processed_text, list_of_used_emojis} + + Optional instance_domain parameter to filter emojis by instance. + """ + def render_custom_emojis(text, instance_domain \\ nil) + + def render_custom_emojis(text, instance_domain) when is_binary(text) do + # Find all :shortcode: patterns + shortcode_pattern = ~r/:([a-zA-Z0-9_]+):/ + + matches = + shortcode_pattern + |> Regex.scan(text, capture: :all_but_first) + |> List.flatten() + |> Enum.uniq() + + if matches == [] do + {text, []} + else + # Fetch all matching emojis from database + emojis = fetch_emojis_by_shortcodes(matches, instance_domain) + + emoji_map = + emojis + |> Enum.filter(fn emoji -> + match?({:ok, _safe_url}, CustomEmoji.validate_image_url(emoji.image_url)) + end) + |> Map.new(fn emoji -> {emoji.shortcode, emoji} end) + + # Replace shortcodes with img tags + processed_text = + Regex.replace(shortcode_pattern, text, fn full_match, shortcode -> + case Map.get(emoji_map, shortcode) do + nil -> + full_match + + emoji -> + render_emoji_img_tag(emoji.image_url, ":#{shortcode}:", "custom-emoji") || + full_match + end + end) + + {processed_text, Map.values(emoji_map)} + end + end + + def render_custom_emojis(nil, _instance_domain), do: {nil, []} + def render_custom_emojis(text, _instance_domain), do: {text, []} + + @doc """ + Returns HTML for a custom emoji shortcode (for use in reactions). + Returns the shortcode itself if emoji not found. + """ + def render_emoji_html(shortcode) when is_binary(shortcode) do + if String.starts_with?(shortcode, ":") and String.ends_with?(shortcode, ":") do + # Custom emoji shortcode format + clean_shortcode = shortcode |> String.trim_leading(":") |> String.trim_trailing(":") + + case fetch_emoji_by_shortcode(clean_shortcode) do + nil -> + shortcode + + emoji -> + render_emoji_img_tag(emoji.image_url, shortcode, "custom-emoji inline-emoji") || + shortcode + end + else + # Regular Unicode emoji + shortcode + end + end + + # Private functions + + defp extract_shortcode(name) when is_binary(name) do + # Remove colons from :shortcode: + shortcode = + name + |> String.trim_leading(":") + |> String.trim_trailing(":") + + if String.match?(shortcode, ~r/^[a-zA-Z0-9_]+$/) do + {:ok, shortcode} + else + {:error, :invalid_shortcode} + end + end + + defp extract_shortcode(_), do: {:error, :invalid_shortcode} + + defp extract_image_url(%{"icon" => %{"url" => url}}) when is_binary(url) do + {:ok, CustomEmoji.normalize_image_url(url)} + end + + defp extract_image_url(_), do: {:error, :missing_image_url} + + defp render_emoji_img_tag(image_url, alt_text, class_name) do + case CustomEmoji.validate_image_url(image_url) do + {:ok, safe_url} -> + escaped_url = escape_html_attribute(safe_url) + escaped_alt_text = escape_html_attribute(alt_text) + + ~s(#{escaped_alt_text}) + + {:error, _reason} -> + nil + end + end + + defp escape_html_attribute(value) when is_binary(value) do + value + |> Phoenix.HTML.html_escape() + |> Phoenix.HTML.safe_to_string() + end + + defp fetch_emojis_by_shortcodes(shortcodes, instance_domain) do + query = + from e in CustomEmoji, + where: e.shortcode in ^shortcodes and e.disabled == false + + query = + case render_lookup_scope(instance_domain) do + :any -> + query + + :local -> + from e in query, where: is_nil(e.instance_domain) + + {:domain, domain} -> + from e in query, where: e.instance_domain == ^domain + end + + Repo.all(query) + end + + defp direct_lookup_scope(instance_domain) do + case normalize_lookup_instance_domain(instance_domain) do + nil -> :local + :local -> :local + domain -> {:domain, domain} + end + end + + defp render_lookup_scope(instance_domain) do + case normalize_lookup_instance_domain(instance_domain) do + nil -> :any + :local -> :local + domain -> {:domain, domain} + end + end + + defp normalize_lookup_instance_domain(instance_domain) when is_binary(instance_domain) do + normalized = String.trim(instance_domain) + + cond do + normalized == "" -> + nil + + String.downcase(normalized) == String.downcase(Elektrine.Domains.instance_domain()) -> + :local + + true -> + normalized + end + end + + defp normalize_lookup_instance_domain(_), do: nil + + defp fetch_emoji_by_shortcode(shortcode) do + Repo.one( + from e in CustomEmoji, + where: e.shortcode == ^shortcode and e.disabled == false, + limit: 1 + ) + end + + # ==================== Admin Functions ==================== + + @doc """ + Lists all custom emojis for admin management. + Includes both local and federated emojis. + """ + def list_all_emojis(opts \\ []) do + limit = Keyword.get(opts, :limit, 100) + offset = Keyword.get(opts, :offset, 0) + search = Keyword.get(opts, :search) + filter = Keyword.get(opts, :filter, "all") + + query = + from e in CustomEmoji, + order_by: [desc: e.inserted_at], + limit: ^limit, + offset: ^offset + + # Apply search filter + query = + if search && search != "" do + pattern = "%#{search}%" + from e in query, where: ilike(e.shortcode, ^pattern) or ilike(e.category, ^pattern) + else + query + end + + # Apply type filter + query = + case filter do + "local" -> from(e in query, where: is_nil(e.instance_domain)) + "remote" -> from(e in query, where: not is_nil(e.instance_domain)) + "enabled" -> from(e in query, where: e.disabled == false) + "disabled" -> from(e in query, where: e.disabled == true) + _ -> query + end + + Repo.all(query) + end + + @doc """ + Counts all custom emojis. + """ + def count_emojis(opts \\ []) do + filter = Keyword.get(opts, :filter, "all") + search = Keyword.get(opts, :search) + + query = from(e in CustomEmoji) + + query = + if search && search != "" do + pattern = "%#{search}%" + from e in query, where: ilike(e.shortcode, ^pattern) or ilike(e.category, ^pattern) + else + query + end + + query = + case filter do + "local" -> from(e in query, where: is_nil(e.instance_domain)) + "remote" -> from(e in query, where: not is_nil(e.instance_domain)) + "enabled" -> from(e in query, where: e.disabled == false) + "disabled" -> from(e in query, where: e.disabled == true) + _ -> query + end + + Repo.aggregate(query, :count, :id) + end + + @doc """ + Gets a single emoji by ID. + """ + def get_emoji(id) do + Repo.get(CustomEmoji, id) + end + + @doc """ + Creates a new local custom emoji. + """ + def create_emoji(attrs) do + %CustomEmoji{} + |> CustomEmoji.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates an existing emoji. + """ + def update_emoji(%CustomEmoji{} = emoji, attrs) do + emoji + |> CustomEmoji.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes an emoji. + """ + def delete_emoji(%CustomEmoji{} = emoji) do + Repo.delete(emoji) + end + + @doc """ + Toggles the disabled status of an emoji. + """ + def toggle_emoji_disabled(%CustomEmoji{} = emoji) do + update_emoji(emoji, %{disabled: !emoji.disabled}) + end + + @doc """ + Toggles visibility in the picker. + """ + def toggle_emoji_visibility(%CustomEmoji{} = emoji) do + update_emoji(emoji, %{visible_in_picker: !emoji.visible_in_picker}) + end + + @doc """ + Lists all unique categories. + """ + def list_categories do + from(e in CustomEmoji, + where: not is_nil(e.category) and e.category != "", + distinct: true, + select: e.category, + order_by: e.category + ) + |> Repo.all() + end +end diff --git a/apps/elektrine/lib/elektrine/emojis/custom_emoji.ex b/apps/elektrine/lib/elektrine/emojis/custom_emoji.ex new file mode 100644 index 0000000..b41c6f2 --- /dev/null +++ b/apps/elektrine/lib/elektrine/emojis/custom_emoji.ex @@ -0,0 +1,91 @@ +defmodule Elektrine.Emojis.CustomEmoji do + @moduledoc """ + Schema for custom emojis, both local and from federated instances. + """ + use Ecto.Schema + import Ecto.Changeset + alias Elektrine.Security.URLValidator + + @unsafe_image_url_chars ~r/[\s"'<>`]/ + + schema "custom_emojis" do + field :shortcode, :string + field :image_url, :string + field :instance_domain, :string + field :category, :string + field :visible_in_picker, :boolean, default: true + field :disabled, :boolean, default: false + + timestamps() + end + + @doc false + def changeset(emoji, attrs) do + emoji + |> cast(attrs, [ + :shortcode, + :image_url, + :instance_domain, + :category, + :visible_in_picker, + :disabled + ]) + |> validate_required([:shortcode, :image_url]) + |> update_change(:image_url, &normalize_image_url/1) + |> validate_format(:shortcode, ~r/^[a-zA-Z0-9_]+$/, + message: "must contain only letters, numbers, and underscores" + ) + |> validate_change(:image_url, fn :image_url, image_url -> + case validate_image_url(image_url) do + {:ok, _normalized_url} -> [] + {:error, _reason} -> [image_url: "must be a valid public http(s) URL"] + end + end) + |> validate_length(:shortcode, min: 2, max: 30) + |> unique_constraint([:shortcode, :instance_domain]) + end + + def normalize_image_url(url) when is_binary(url), do: String.trim(url) + def normalize_image_url(url), do: url + + def validate_image_url(url) when is_binary(url) do + normalized_url = normalize_image_url(url) + uri = URI.parse(normalized_url) + + cond do + normalized_url == "" -> + {:error, :invalid_image_url} + + Regex.match?(@unsafe_image_url_chars, normalized_url) -> + {:error, :invalid_image_url} + + uri.scheme not in ["http", "https"] -> + {:error, :invalid_scheme} + + !is_binary(uri.host) or uri.host == "" -> + {:error, :missing_host} + + uri.userinfo not in [nil, ""] -> + {:error, :userinfo_not_allowed} + + URLValidator.private_ip?(uri.host) -> + {:error, :private_host} + + true -> + {:ok, normalized_url} + end + end + + def validate_image_url(_), do: {:error, :invalid_image_url} + + @doc """ + Returns true if this is a local emoji (nil instance_domain) + """ + def local?(%__MODULE__{instance_domain: nil}), do: true + def local?(%__MODULE__{}), do: false + + @doc """ + Returns true if this emoji is from a remote instance + """ + def remote?(%__MODULE__{} = emoji), do: !local?(emoji) +end diff --git a/apps/elektrine/lib/elektrine/encryption.ex b/apps/elektrine/lib/elektrine/encryption.ex new file mode 100644 index 0000000..a59825f --- /dev/null +++ b/apps/elektrine/lib/elektrine/encryption.ex @@ -0,0 +1,219 @@ +defmodule Elektrine.Encryption do + @moduledoc """ + Provides server-side encryption for messages and emails using AES-256-GCM. + Includes blind keyword indexing for searchable content. + """ + + @aad "ElektrineV1" + + @doc """ + Encrypts content using AES-256-GCM with a user-specific key. + Returns {encrypted_data, iv, tag} as base64-encoded strings. + """ + def encrypt(plaintext, user_id) when is_binary(plaintext) and is_integer(user_id) do + if encryption_enabled?() do + key = derive_key(user_id) + iv = :crypto.strong_rand_bytes(12) + + {ciphertext, tag} = + :crypto.crypto_one_time_aead( + :aes_256_gcm, + key, + iv, + plaintext, + @aad, + true + ) + + %{ + encrypted_data: Base.encode64(ciphertext), + iv: Base.encode64(iv), + tag: Base.encode64(tag) + } + else + # Encryption is disabled when secrets are not configured. + %{plaintext_data: plaintext} + end + end + + @doc """ + Decrypts content encrypted with encrypt/2. + Takes a map with encrypted_data, iv, and tag (all base64-encoded). + Handles both atom and string keys (for database retrieval). + """ + def decrypt(encrypted_map, user_id) when is_map(encrypted_map) and is_integer(user_id) do + plaintext = encrypted_map[:plaintext_data] || encrypted_map["plaintext_data"] + + cond do + is_binary(plaintext) -> + {:ok, plaintext} + + not encryption_enabled?() -> + {:error, :encryption_not_configured} + + true -> + decrypt_encrypted_payload(encrypted_map, user_id) + end + end + + @doc """ + Extracts keywords from text for search indexing. + Removes common stop words and extracts hashtags. + """ + def extract_keywords(text) when is_binary(text) do + # Extract hashtags + hashtags = + Regex.scan(~r/#[a-zA-Z0-9_]+/, text) + |> Enum.map(fn [tag] -> String.downcase(tag) end) + + # Extract words (3+ characters, not stop words) + words = + text + |> String.downcase() + |> String.replace(~r/[^\w\s#]/, " ") + |> String.split() + |> Enum.filter(&(String.length(&1) >= 3)) + |> Enum.reject(&stop_word?/1) + |> Enum.uniq() + + Enum.uniq(hashtags ++ words) + end + + @doc """ + Creates a blind search index from keywords. + Returns a list of hashed keywords that can be stored in the database. + """ + def create_search_index(keywords, user_id) when is_list(keywords) and is_integer(user_id) do + keywords + |> Enum.map(&hash_keyword(&1, user_id)) + |> Enum.uniq() + end + + @doc """ + Hashes a single keyword for search index lookup. + """ + def hash_keyword(keyword, user_id) when is_binary(keyword) and is_integer(user_id) do + if encryption_enabled?() do + salt = get_search_salt!() + key = derive_key(user_id) + + :crypto.mac(:hmac, :sha256, key <> salt, String.downcase(keyword)) + |> Base.encode64() + else + String.downcase(keyword) + end + end + + @doc """ + Creates search index from text content. + Convenience function that extracts keywords and creates index. + """ + def index_content(text, user_id) when is_binary(text) and is_integer(user_id) do + text + |> extract_keywords() + |> create_search_index(user_id) + end + + # Private functions + + defp derive_key(user_id) do + # Use cached key if available, otherwise derive and cache + Elektrine.Encryption.KeyCache.get_or_derive(user_id, fn -> + master_secret = get_master_secret!() + salt = get_key_salt!() + + # Use PBKDF2 to derive a user-specific key (expensive, but cached) + :crypto.pbkdf2_hmac( + :sha256, + master_secret, + salt <> Integer.to_string(user_id), + 100_000, + 32 + ) + end) + end + + defp decrypt_encrypted_payload(encrypted_map, user_id) do + encrypted_data = encrypted_map[:encrypted_data] || encrypted_map["encrypted_data"] + iv_data = encrypted_map[:iv] || encrypted_map["iv"] + tag_data = encrypted_map[:tag] || encrypted_map["tag"] + + with encrypted_data when is_binary(encrypted_data) <- encrypted_data, + iv_data when is_binary(iv_data) <- iv_data, + tag_data when is_binary(tag_data) <- tag_data, + {:ok, ciphertext} <- Base.decode64(encrypted_data), + {:ok, iv} <- Base.decode64(iv_data), + {:ok, tag} <- Base.decode64(tag_data) do + key = derive_key(user_id) + + case :crypto.crypto_one_time_aead( + :aes_256_gcm, + key, + iv, + ciphertext, + @aad, + tag, + false + ) do + plaintext when is_binary(plaintext) -> {:ok, plaintext} + :error -> {:error, :decryption_failed} + end + else + _ -> {:error, :invalid_encrypted_payload} + end + end + + defp encryption_enabled? do + encryption_flag = Application.get_env(:elektrine, :encryption_enabled, true) + encryption_flag != false and encryption_secrets_configured?() + end + + defp encryption_secrets_configured? do + Enum.all?([master_secret(), key_salt(), search_salt()], &present_secret?/1) + end + + defp present_secret?(value) when is_binary(value), do: Elektrine.Strings.present?(value) + defp present_secret?(_), do: false + + defp master_secret do + Application.get_env(:elektrine, :encryption_master_secret) + end + + defp key_salt do + Application.get_env(:elektrine, :encryption_key_salt) + end + + defp search_salt do + Application.get_env(:elektrine, :encryption_search_salt) + end + + defp get_master_secret! do + # In production, this should come from environment variable or secure storage + Application.get_env(:elektrine, :encryption_master_secret) || + raise "ENCRYPTION_MASTER_SECRET not configured" + end + + defp get_key_salt! do + Application.get_env(:elektrine, :encryption_key_salt) || + raise "ENCRYPTION_KEY_SALT not configured" + end + + defp get_search_salt! do + Application.get_env(:elektrine, :encryption_search_salt) || + raise "ENCRYPTION_SEARCH_SALT not configured" + end + + defp stop_word?(word) do + stop_words = ~w( + the and but for not with you that this from they have + are was were been will would could should may might + can has had been being about into through during before + after above below between under since until while when + where which what who whom whose why how all each every + both few more most other some such only own same than + too very just now then here there + ) + + word in stop_words + end +end diff --git a/apps/elektrine/lib/elektrine/encryption/key_cache.ex b/apps/elektrine/lib/elektrine/encryption/key_cache.ex new file mode 100644 index 0000000..d8c3d44 --- /dev/null +++ b/apps/elektrine/lib/elektrine/encryption/key_cache.ex @@ -0,0 +1,120 @@ +defmodule Elektrine.Encryption.KeyCache do + @moduledoc """ + In-memory cache for derived encryption keys using ETS. + Dramatically improves performance by avoiding repeated PBKDF2 computation. + """ + + use GenServer + require Logger + alias Elektrine.Telemetry.Events + + @table_name :encryption_key_cache + # Keys expire after 24 hours + @cache_ttl :timer.hours(24) + + # Client API + + def start_link(_opts) do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @doc """ + Gets a cached key or derives and caches it. + """ + def get_or_derive(user_id, derive_fun) + when is_integer(user_id) and is_function(derive_fun, 0) do + case :ets.lookup(@table_name, user_id) do + [{^user_id, key, expires_at}] -> + if System.system_time(:millisecond) < expires_at do + # Cache hit - return cached key + Events.cache(:encryption_key_cache, :get, :hit, %{scope: :key}) + key + else + # Expired - derive new key + Events.cache(:encryption_key_cache, :get, :expired, %{scope: :key}) + derive_and_cache(user_id, derive_fun) + end + + [] -> + # Cache miss - derive new key + Events.cache(:encryption_key_cache, :get, :miss, %{scope: :key}) + derive_and_cache(user_id, derive_fun) + end + end + + @doc """ + Invalidates cached key for a user (use when rotating keys). + """ + def invalidate(user_id) do + :ets.delete(@table_name, user_id) + Events.cache(:encryption_key_cache, :delete, :ok, %{scope: :key}) + end + + @doc """ + Clears all cached keys. + """ + def clear_all do + :ets.delete_all_objects(@table_name) + Events.cache(:encryption_key_cache, :clear, :ok, %{scope: :all}) + end + + # Server Callbacks + + @impl true + def init(_opts) do + # Create ETS table with read concurrency for high-performance reads + :ets.new(@table_name, [ + :set, + :named_table, + :public, + read_concurrency: true, + write_concurrency: true + ]) + + # Schedule periodic cleanup of expired keys + schedule_cleanup() + + Logger.info("Startup: encryption key cache ready") + {:ok, %{}} + end + + @impl true + def handle_info(:cleanup, state) do + # Remove expired keys + now = System.system_time(:millisecond) + + expired = + :ets.select(@table_name, [ + {{:"$1", :"$2", :"$3"}, [{:<, :"$3", now}], [:"$1"]} + ]) + + Enum.each(expired, &:ets.delete(@table_name, &1)) + + Events.cache(:encryption_key_cache, :cleanup, :ok, %{ + scope: :key, + deleted_count: length(expired) + }) + + schedule_cleanup() + {:noreply, state} + end + + # Private Functions + + defp derive_and_cache(user_id, derive_fun) do + # Derive the key (expensive operation) + key = derive_fun.() + + # Cache it with expiration + expires_at = System.system_time(:millisecond) + @cache_ttl + :ets.insert(@table_name, {user_id, key, expires_at}) + Events.cache(:encryption_key_cache, :put, :ok, %{scope: :key}) + + key + end + + defp schedule_cleanup do + # Clean up expired keys every hour + Process.send_after(self(), :cleanup, :timer.hours(1)) + end +end diff --git a/apps/elektrine/lib/elektrine/friends.ex b/apps/elektrine/lib/elektrine/friends.ex new file mode 100644 index 0000000..b335ce7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/friends.ex @@ -0,0 +1,405 @@ +defmodule Elektrine.Friends do + @moduledoc """ + The Friends context for managing friend requests and friendships. + + Xbox-style friend system: + - Users send friend requests + - Must be accepted to become friends + - Friends have special privileges (calls, see online status, friends-only posts) + - Independent from follow system (can follow without being friends) + """ + + import Ecto.Query, warn: false + alias Elektrine.Accounts.User + alias Elektrine.Friends.FriendRequest + alias Elektrine.Repo + + @doc """ + Sends a friend request from requester to recipient. + """ + def send_friend_request(requester_id, recipient_id, message \\ nil) do + # Check privacy settings first + case Elektrine.Privacy.can_send_friend_request?(requester_id, recipient_id) do + {:error, reason} -> + {:error, reason} + + {:ok, :allowed} -> + # Check if request already exists + case get_friend_request(requester_id, recipient_id) do + nil -> + result = + %FriendRequest{} + |> FriendRequest.changeset(%{ + requester_id: requester_id, + recipient_id: recipient_id, + status: "pending", + message: message + }) + |> Repo.insert() + + # Invalidate pending requests cache for recipient + case result do + {:ok, _} -> + Elektrine.AppCache.invalidate_friends_cache(recipient_id) + broadcast_friend_requests_updated(recipient_id) + + _ -> + :ok + end + + result + + _existing -> + {:error, :request_already_exists} + end + end + end + + @doc """ + Accepts a friend request. + Re-validates privacy settings at acceptance time to prevent stale requests. + """ + def accept_friend_request(request_id, user_id) do + request = Repo.get(FriendRequest, request_id) + + cond do + !request -> + {:error, :not_found} + + request.recipient_id != user_id -> + {:error, :unauthorized} + + request.status != "pending" -> + {:error, :already_processed} + + true -> + # Re-validate privacy settings at acceptance time + # This prevents accepting old requests after changing privacy to "nobody" + case Elektrine.Privacy.can_send_friend_request?(request.requester_id, user_id) do + {:error, :privacy_restricted} -> + # Privacy settings changed - auto-reject the stale request + request + |> FriendRequest.changeset(%{status: "rejected"}) + |> Repo.update() + |> case do + {:ok, _} -> {:error, :privacy_settings_changed} + error -> error + end + + {:error, reason} -> + {:error, reason} + + {:ok, :allowed} -> + result = + request + |> FriendRequest.changeset(%{status: "accepted"}) + |> Repo.update() + + # Invalidate friends cache for both users + case result do + {:ok, _} -> + Elektrine.AppCache.invalidate_friends_cache(request.requester_id) + Elektrine.AppCache.invalidate_friends_cache(request.recipient_id) + broadcast_friend_requests_updated(request.recipient_id) + + _ -> + :ok + end + + result + end + end + end + + @doc """ + Rejects a friend request. + """ + def reject_friend_request(request_id, user_id) do + request = Repo.get(FriendRequest, request_id) + + cond do + !request -> + {:error, :not_found} + + request.recipient_id != user_id -> + {:error, :unauthorized} + + request.status != "pending" -> + {:error, :already_processed} + + true -> + result = + request + |> FriendRequest.changeset(%{status: "rejected"}) + |> Repo.update() + + # Invalidate cache for recipient (pending requests count changes) + case result do + {:ok, _} -> + Elektrine.AppCache.invalidate_friends_cache(user_id) + broadcast_friend_requests_updated(user_id) + + _ -> + :ok + end + + result + end + end + + @doc """ + Cancels a sent friend request. + """ + def cancel_friend_request(request_id, user_id) do + request = Repo.get(FriendRequest, request_id) + + cond do + !request -> + {:error, :not_found} + + request.requester_id != user_id -> + {:error, :unauthorized} + + request.status != "pending" -> + {:error, :already_processed} + + true -> + result = Repo.delete(request) + + # Invalidate cache for recipient (pending requests count changes) + case result do + {:ok, _} -> + Elektrine.AppCache.invalidate_friends_cache(request.recipient_id) + broadcast_friend_requests_updated(request.recipient_id) + + _ -> + :ok + end + + result + end + end + + @doc """ + Unfriends a user (deletes the accepted friend request). + """ + def unfriend(user_id, friend_id) do + # Find accepted friend request in either direction + request = + from(f in FriendRequest, + where: f.status == "accepted", + where: + (f.requester_id == ^user_id and f.recipient_id == ^friend_id) or + (f.requester_id == ^friend_id and f.recipient_id == ^user_id) + ) + |> Repo.one() + + if request do + result = Repo.delete(request) + + # Invalidate friends cache for both users + case result do + {:ok, _} -> + Elektrine.AppCache.invalidate_friends_cache(user_id) + Elektrine.AppCache.invalidate_friends_cache(friend_id) + + _ -> + :ok + end + + result + else + {:error, :not_friends} + end + end + + @doc """ + Checks if two users are friends. + """ + def friends?(user_id, other_user_id) do + Repo.exists?( + from(f in FriendRequest, + where: f.status == "accepted", + where: + (f.requester_id == ^user_id and f.recipient_id == ^other_user_id) or + (f.requester_id == ^other_user_id and f.recipient_id == ^user_id) + ) + ) + end + + @doc """ + Alias for friends?/2 for consistency. + """ + def are_friends?(user_id, other_user_id), do: friends?(user_id, other_user_id) + + @doc """ + Gets a friend request between two users (in either direction). + """ + def get_friend_request(user_id, other_user_id) do + from(f in FriendRequest, + where: + (f.requester_id == ^user_id and f.recipient_id == ^other_user_id) or + (f.requester_id == ^other_user_id and f.recipient_id == ^user_id) + ) + |> Repo.one() + end + + @doc """ + Lists all friends for a user (accepted requests in either direction). + """ + def list_friends(user_id) do + from(f in FriendRequest, + where: f.status == "accepted", + where: f.requester_id == ^user_id or f.recipient_id == ^user_id, + preload: [:requester, :recipient] + ) + |> Repo.all() + |> Enum.map(fn request -> + # Return the other user (not the current user) + if request.requester_id == user_id do + request.recipient + else + request.requester + end + end) + end + + @doc """ + Checks if a user has any friend data (friends, pending, or sent requests). + Fast check for loading skeleton optimization. + """ + def user_has_any_friend_data?(user_id) do + from(f in FriendRequest, + where: f.requester_id == ^user_id or f.recipient_id == ^user_id, + limit: 1, + select: 1 + ) + |> Repo.exists?() + end + + @doc """ + Lists pending friend requests received by a user. + """ + def list_pending_requests(user_id) do + from(f in FriendRequest, + where: f.recipient_id == ^user_id, + where: f.status == "pending", + order_by: [desc: f.inserted_at], + preload: [:requester] + ) + |> Repo.all() + end + + @doc """ + Lists pending friend requests sent by a user. + """ + def list_sent_requests(user_id) do + from(f in FriendRequest, + where: f.requester_id == ^user_id, + where: f.status == "pending", + order_by: [desc: f.inserted_at], + preload: [:recipient] + ) + |> Repo.all() + end + + @doc """ + Gets pending friend request count for a user. + """ + def get_pending_request_count(user_id) do + from(f in FriendRequest, + where: f.recipient_id == ^user_id, + where: f.status == "pending", + select: count(f.id) + ) + |> Repo.one() + end + + defp broadcast_friend_requests_updated(user_id) when is_integer(user_id) do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}", + {:friend_requests_updated, get_pending_request_count(user_id)} + ) + end + + defp broadcast_friend_requests_updated(_user_id), do: :ok + + @doc """ + Gets the friendship and follow status between two users. + Returns a map with: + - :are_friends - boolean + - :you_follow_them - boolean + - :they_follow_you - boolean + - :mutual_follow - boolean + - :pending_request - friend request if exists + """ + def get_relationship_status(user_id, other_user_id) do + # Check friendship + are_friends = friends?(user_id, other_user_id) + + # Check follow status + you_follow_them = Elektrine.Profiles.following?(user_id, other_user_id) + they_follow_you = Elektrine.Profiles.following?(other_user_id, user_id) + + # Check for pending request + pending_request = + from(f in FriendRequest, + where: f.status == "pending", + where: + (f.requester_id == ^user_id and f.recipient_id == ^other_user_id) or + (f.requester_id == ^other_user_id and f.recipient_id == ^user_id) + ) + |> Repo.one() + + %{ + are_friends: are_friends, + you_follow_them: you_follow_them, + they_follow_you: they_follow_you, + mutual_follow: you_follow_them && they_follow_you, + pending_request: pending_request + } + end + + @doc """ + Gets suggested friends (people you follow who follow you back, but aren't friends yet). + Essentially mutual follows who haven't sent/accepted friend requests. + """ + def get_suggested_friends(user_id, limit \\ 10) do + # Get mutual follows + mutual_follow_ids = + from(f1 in Elektrine.Profiles.Follow, + join: f2 in Elektrine.Profiles.Follow, + on: f1.followed_id == f2.follower_id and f1.follower_id == f2.followed_id, + where: f1.follower_id == ^user_id, + select: f1.followed_id + ) + |> Repo.all() + + # Get users who are already friends or have pending requests + existing_friend_ids = + from(f in FriendRequest, + where: f.requester_id == ^user_id or f.recipient_id == ^user_id, + where: f.status in ["accepted", "pending"], + select: + fragment( + "CASE WHEN ? = ? THEN ? ELSE ? END", + f.requester_id, + ^user_id, + f.recipient_id, + f.requester_id + ) + ) + |> Repo.all() + + # Suggest mutual follows who aren't friends yet + suggested_ids = mutual_follow_ids -- existing_friend_ids + + from(u in User, + where: u.id in ^suggested_ids, + limit: ^limit, + preload: [:profile] + ) + |> Repo.all() + end +end diff --git a/apps/elektrine/lib/elektrine/friends/friend_request.ex b/apps/elektrine/lib/elektrine/friends/friend_request.ex new file mode 100644 index 0000000..a0bc42f --- /dev/null +++ b/apps/elektrine/lib/elektrine/friends/friend_request.ex @@ -0,0 +1,32 @@ +defmodule Elektrine.Friends.FriendRequest do + @moduledoc """ + Schema for friend requests between users, managing the lifecycle of friendship connections. + Supports pending, accepted, and rejected statuses with optional messages. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "friend_requests" do + belongs_to :requester, Elektrine.Accounts.User + belongs_to :recipient, Elektrine.Accounts.User + + # "pending", "accepted", "rejected" + field :status, :string + # Optional message with request + field :message, :string + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(friend_request, attrs) do + friend_request + |> cast(attrs, [:requester_id, :recipient_id, :status, :message]) + |> validate_required([:requester_id, :recipient_id, :status]) + |> validate_inclusion(:status, ["pending", "accepted", "rejected"]) + |> validate_length(:message, max: 500) + |> foreign_key_constraint(:requester_id) + |> foreign_key_constraint(:recipient_id) + |> unique_constraint([:requester_id, :recipient_id]) + end +end diff --git a/apps/elektrine/lib/elektrine/home_blog.ex b/apps/elektrine/lib/elektrine/home_blog.ex new file mode 100644 index 0000000..a900bf7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/home_blog.ex @@ -0,0 +1,79 @@ +defmodule Elektrine.HomeBlog do + @moduledoc """ + Latest entries from the operator's blog feed, shown on the public home page. + + The feed is fetched server-side and cached, so visitors' browsers never + contact the blog host. Override the source with: + + config :elektrine, :home_blog_feed_url, "https://example.com/atom.xml" + + Set it to nil to hide the section entirely (the test env does this). + """ + + require Logger + + alias Elektrine.AppCache + alias Elektrine.HTTP.SafeFetch + alias Elektrine.RSS.Parser + + @max_entries 3 + @user_agent "Elektrine/1.0 (Home Page)" + @default_feed_url "https://maidcorps.net/atom.xml" + + def feed_url, do: Application.get_env(:elektrine, :home_blog_feed_url, @default_feed_url) + + @doc """ + Returns cached posts without ever touching the network. + Suitable for the static render of the home page. + """ + def cached_posts do + if feed_url(), do: AppCache.get_home_blog_posts() || [], else: [] + end + + @doc """ + Returns the latest posts, fetching and caching the feed when the cache is + cold. Failures are cached briefly as an empty list so a broken feed does not + get refetched on every visit. + """ + def latest_posts do + case feed_url() do + nil -> + [] + + url -> + case AppCache.get_home_blog_posts() do + posts when is_list(posts) -> + posts + + nil -> + posts = fetch_posts(url) + AppCache.cache_home_blog_posts(posts) + posts + end + end + end + + defp fetch_posts(url) do + headers = [ + {"user-agent", @user_agent}, + {"accept", "application/atom+xml, application/rss+xml, application/xml, text/xml"} + ] + + request = Finch.build(:get, url, headers) + + with {:ok, %Finch.Response{status: 200, body: body}} <- + SafeFetch.request(request, Elektrine.Finch, receive_timeout: 10_000), + {:ok, %{entries: entries}} <- Parser.parse(body) do + entries + |> Enum.filter(&(present?(&1.title) and present?(&1.link))) + |> Enum.take(@max_entries) + |> Enum.map(&%{title: &1.title, url: &1.link, published_at: &1.published_at}) + else + other -> + Logger.warning("Home blog feed fetch failed: #{inspect(other)}") + [] + end + end + + defp present?(value), do: is_binary(value) and String.trim(value) != "" +end diff --git a/apps/elektrine/lib/elektrine/http/backoff.ex b/apps/elektrine/lib/elektrine/http/backoff.ex new file mode 100644 index 0000000..a84ebe1 --- /dev/null +++ b/apps/elektrine/lib/elektrine/http/backoff.ex @@ -0,0 +1,211 @@ +defmodule Elektrine.HTTP.Backoff do + @moduledoc "HTTP client-side backoff handling for respecting remote server rate limits.\n\nThis module tracks rate-limited hosts and prevents hammering servers that\nhave returned 429 (Too Many Requests) or 503 (Service Unavailable) responses.\n\nBased on Akkoma's implementation for federation compatibility.\n\n## Usage\n\n # Wrap HTTP requests\n case Elektrine.HTTP.Backoff.get(url) do\n {:ok, response} -> handle_response(response)\n {:error, :backoff} -> # Host is rate-limited, try later\n {:error, reason} -> # Other error\n end\n\n # Or check before making request\n if Elektrine.HTTP.Backoff.should_backoff?(host) do\n {:error, :rate_limited}\n else\n make_request(url)\n end\n" + use GenServer + require Logger + alias Elektrine.HTTP.SafeFetch + @table :http_backoff_cache + @default_backoff_seconds 300 + @max_backoff_seconds 3600 + @cleanup_interval :timer.minutes(5) + @doc "Starts the backoff tracker.\n" + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc "Makes an HTTP GET request with backoff handling.\nReturns {:error, :backoff} if the host is currently rate-limited.\n" + def get(url, headers \\ [], opts \\ []) do + uri = URI.parse(url) + host = uri.host + + if should_backoff?(host) do + Logger.debug("HTTP Backoff: Skipping request to #{host} - currently rate-limited") + {:error, :backoff} + else + result = do_request(:get, url, "", headers, opts) + handle_response(host, result) + end + end + + @doc "Makes an HTTP POST request with backoff handling.\n" + def post(url, body, headers \\ [], opts \\ []) do + uri = URI.parse(url) + host = uri.host + + if should_backoff?(host) do + Logger.debug("HTTP Backoff: Skipping request to #{host} - currently rate-limited") + {:error, :backoff} + else + result = do_request(:post, url, body, headers, opts) + handle_response(host, result) + end + end + + @doc "Checks if we should back off from making requests to the given host.\n" + def should_backoff?(nil) do + false + end + + def should_backoff?(host) when is_binary(host) do + case :ets.lookup(@table, host) do + [{^host, backoff_until}] -> + now = System.system_time(:second) + now < backoff_until + + [] -> + false + end + end + + @doc "Marks a host as rate-limited for the specified duration.\n" + def set_backoff(host, seconds \\ @default_backoff_seconds) when is_binary(host) do + seconds = min(seconds, @max_backoff_seconds) + backoff_until = System.system_time(:second) + seconds + :ets.insert(@table, {host, backoff_until}) + Logger.info("HTTP Backoff: Rate-limiting #{host} for #{seconds} seconds") + :ok + end + + @doc "Clears backoff for a host (e.g., after successful request).\n" + def clear_backoff(host) when is_binary(host) do + :ets.delete(@table, host) + :ok + end + + @doc "Gets the remaining backoff time for a host in seconds.\nReturns 0 if not rate-limited.\n" + def get_backoff_remaining(host) when is_binary(host) do + case :ets.lookup(@table, host) do + [{^host, backoff_until}] -> + remaining = backoff_until - System.system_time(:second) + max(0, remaining) + + [] -> + 0 + end + end + + @impl true + def init(_opts) do + :ets.new(@table, [:set, :public, :named_table, read_concurrency: true]) + schedule_cleanup() + {:ok, %{}} + end + + @impl true + def handle_info(:cleanup, state) do + cleanup_expired() + schedule_cleanup() + {:noreply, state} + end + + defp do_request(method, url, body, headers, opts) do + timeout = Keyword.get(opts, :timeout, 15_000) + recv_timeout = Keyword.get(opts, :recv_timeout, 15_000) + max_body_bytes = Keyword.get(opts, :max_body_bytes) + request = Finch.build(method, url, headers, body) + + case SafeFetch.request(request, Elektrine.Finch, + receive_timeout: recv_timeout, + pool_timeout: timeout, + max_body_bytes: max_body_bytes + ) do + {:ok, response} -> {:ok, response} + {:error, reason} -> {:error, reason} + end + end + + defp handle_response(host, {:ok, %Finch.Response{status: status, headers: headers} = response}) + when status in [429, 503] do + backoff_seconds = parse_retry_after(headers) + set_backoff(host, backoff_seconds) + {:ok, response} + end + + defp handle_response(host, {:ok, %Finch.Response{status: status} = response}) + when status >= 200 and status < 300 do + clear_backoff(host) + {:ok, response} + end + + defp handle_response(_host, {:ok, response}) do + {:ok, response} + end + + defp handle_response(_host, {:error, reason}) do + {:error, reason} + end + + @doc "Parses the Retry-After header to determine backoff duration.\nSupports both seconds format and HTTP-date format.\nAlso checks X-RateLimit-Reset header.\n" + def parse_retry_after(headers) do + headers_map = headers_to_map(headers) + + cond do + retry_after = Map.get(headers_map, "retry-after") -> + parse_retry_after_value(retry_after) + + rate_limit_reset = Map.get(headers_map, "x-ratelimit-reset") -> + parse_rate_limit_reset(rate_limit_reset) + + true -> + @default_backoff_seconds + end + end + + defp headers_to_map(headers) do + headers |> Enum.map(fn {k, v} -> {String.downcase(k), v} end) |> Map.new() + end + + defp parse_retry_after_value(value) when is_binary(value) do + case Integer.parse(value) do + {seconds, ""} -> min(seconds, @max_backoff_seconds) + _ -> parse_http_date(value) + end + end + + defp parse_retry_after_value(_) do + @default_backoff_seconds + end + + defp parse_rate_limit_reset(value) when is_binary(value) do + case Integer.parse(value) do + {timestamp, ""} -> + now = System.system_time(:second) + seconds = max(0, timestamp - now) + min(seconds, @max_backoff_seconds) + + _ -> + @default_backoff_seconds + end + end + + defp parse_rate_limit_reset(_) do + @default_backoff_seconds + end + + defp parse_http_date(date_string) do + case :httpd_util.convert_request_date(String.to_charlist(date_string)) do + {{year, month, day}, {hour, minute, second}} -> + {:ok, datetime} = NaiveDateTime.new(year, month, day, hour, minute, second) + {:ok, utc_datetime} = DateTime.from_naive(datetime, "Etc/UTC") + now = DateTime.utc_now() + diff = DateTime.diff(utc_datetime, now, :second) + min(max(0, diff), @max_backoff_seconds) + + :bad_date -> + @default_backoff_seconds + end + rescue + _ -> @default_backoff_seconds + end + + defp schedule_cleanup do + Process.send_after(self(), :cleanup, @cleanup_interval) + end + + defp cleanup_expired do + now = System.system_time(:second) + + :ets.tab2list(@table) + |> Enum.filter(fn {_host, backoff_until} -> backoff_until <= now end) + |> Enum.each(fn {host, _} -> :ets.delete(@table, host) end) + end +end diff --git a/apps/elektrine/lib/elektrine/http/safe_fetch.ex b/apps/elektrine/lib/elektrine/http/safe_fetch.ex new file mode 100644 index 0000000..dc33cd4 --- /dev/null +++ b/apps/elektrine/lib/elektrine/http/safe_fetch.ex @@ -0,0 +1,230 @@ +defmodule Elektrine.HTTP.SafeFetch do + @moduledoc """ + Fetches remote HTTP responses through a pinned, prevalidated IP address. + """ + + alias Elektrine.Security.URLValidator + alias Finch.Request + + @default_max_body_bytes 2 * 1024 * 1024 + + def request(request, finch_name, opts \\ []) + + def request(%Request{} = request, _finch_name, opts) do + max_body_bytes = Keyword.get(opts, :max_body_bytes, @default_max_body_bytes) + + with :ok <- validate_request_port(request, opts), + {:ok, address} <- resolve_request_address(request, opts), + {:ok, conn} <- connect(request, address, opts), + {:ok, conn, ref} <- + Mint.HTTP.request( + conn, + request.method, + Request.request_path(request), + request_headers(request), + request_body(request) + ) do + receive_response(conn, ref, max_body_bytes, request_receive_timeout(opts)) + end + end + + def request(_request, _finch_name, _opts), do: {:error, :invalid_request} + + defp resolve_request_address(%Request{host: host, scheme: :http}, opts) do + cond do + Keyword.get(opts, :allow_private_network, false) -> + URLValidator.resolve_address(host) + + Keyword.get(opts, :allow_localhost, false) and host in ["localhost", "127.0.0.1", "::1"] -> + case host do + "localhost" -> + {:ok, {127, 0, 0, 1}} + + _ -> + case :inet.parse_address(String.to_charlist(host)) do + {:ok, address} -> {:ok, address} + {:error, _} -> {:error, :unresolvable_host} + end + end + + true -> + URLValidator.resolve_public_address(host) + end + end + + defp resolve_request_address(%Request{host: host}, opts) do + if Keyword.get(opts, :allow_private_network, false) do + URLValidator.resolve_address(host) + else + URLValidator.resolve_public_address(host) + end + end + + defp connect(request, address, opts) do + Mint.HTTP.connect( + request.scheme, + address, + request_port(request), + hostname: request.host, + protocols: [:http1], + transport_opts: [timeout: request_connect_timeout(opts)] + ) + end + + defp validate_request_port(%Request{} = request, opts) do + if Keyword.get(opts, :allow_dangerous_ports, false) do + :ok + else + uri = %URI{scheme: Atom.to_string(request.scheme), host: request.host, port: request.port} + + if URLValidator.dangerous_port?(uri), do: {:error, :dangerous_port}, else: :ok + end + end + + defp receive_response(conn, ref, max_body_bytes, receive_timeout) do + receive_loop( + conn, + ref, + %{status: nil, headers: [], body: [], body_size: 0}, + max_body_bytes, + receive_timeout + ) + end + + defp receive_loop(conn, ref, acc, max_body_bytes, receive_timeout) do + receive do + message -> + case Mint.HTTP.stream(conn, message) do + :unknown -> + receive_loop(conn, ref, acc, max_body_bytes, receive_timeout) + + {:ok, conn, responses} -> + case handle_responses(conn, ref, responses, acc, max_body_bytes) do + {:continue, conn, acc} -> + receive_loop(conn, ref, acc, max_body_bytes, receive_timeout) + + result -> + result + end + + {:error, conn, reason, responses} -> + case handle_responses(conn, ref, responses, acc, max_body_bytes) do + {:continue, conn, _acc} -> close_with_error(conn, reason) + result -> result + end + end + after + receive_timeout -> + close_with_error(conn, :timeout) + end + end + + defp handle_responses(conn, ref, responses, acc, max_body_bytes) do + Enum.reduce_while(responses, {:continue, conn, acc}, fn + {:status, ^ref, status}, {:continue, conn, acc} -> + {:cont, {:continue, conn, %{acc | status: status}}} + + {:headers, ^ref, headers}, {:continue, conn, acc} -> + if content_length_too_large?(headers, max_body_bytes) do + {:halt, close_with_error(conn, :too_large)} + else + {:cont, {:continue, conn, %{acc | headers: headers}}} + end + + {:data, ^ref, data}, {:continue, conn, acc} -> + new_size = acc.body_size + byte_size(data) + + if is_integer(max_body_bytes) and new_size > max_body_bytes do + {:halt, close_with_error(conn, :too_large)} + else + {:cont, {:continue, conn, %{acc | body: [data | acc.body], body_size: new_size}}} + end + + {:done, ^ref}, {:continue, conn, acc} -> + {:halt, close_with_response(conn, acc)} + + {:error, ^ref, reason}, {:continue, conn, _acc} -> + {:halt, close_with_error(conn, reason)} + + _, state -> + {:cont, state} + end) + end + + defp close_with_response(conn, %{status: status, headers: headers, body: body}) + when is_integer(status) do + _ = Mint.HTTP.close(conn) + + {:ok, + %Finch.Response{ + status: status, + headers: headers, + body: body |> Enum.reverse() |> IO.iodata_to_binary() + }} + end + + defp close_with_response(conn, _acc) do + _ = Mint.HTTP.close(conn) + {:error, :invalid_response} + end + + defp close_with_error(conn, reason) do + _ = Mint.HTTP.close(conn) + {:error, reason} + end + + defp request_headers(%Request{} = request) do + if Enum.any?(request.headers, fn {name, _value} -> String.downcase(name) == "host" end) do + request.headers + else + [{"host", host_header(request)} | request.headers] + end + end + + defp host_header(%Request{host: host, port: port, scheme: scheme}) do + cond do + scheme == :http and port in [nil, 80] -> host + scheme == :https and port in [nil, 443] -> host + true -> "#{host}:#{request_port(port, scheme)}" + end + end + + defp request_body(%Request{body: nil}), do: "" + + defp request_body(%Request{body: {:stream, _enumerable}}) do + raise ArgumentError, "streaming request bodies are not supported by SafeFetch" + end + + defp request_body(%Request{body: body}), do: body + + defp request_port(%Request{port: port, scheme: scheme}), do: request_port(port, scheme) + + defp request_port(nil, :http), do: 80 + defp request_port(nil, :https), do: 443 + defp request_port(port, _scheme), do: port + + defp request_connect_timeout(opts), do: Keyword.get(opts, :pool_timeout, 15_000) + defp request_receive_timeout(opts), do: Keyword.get(opts, :receive_timeout, 15_000) + + defp content_length_too_large?(_headers, nil), do: false + + defp content_length_too_large?(headers, max_body_bytes) do + headers + |> Enum.find_value(fn {name, value} -> + if String.downcase(name) == "content-length", do: parse_integer(value) + end) + |> case do + length when is_integer(length) -> length > max_body_bytes + _ -> false + end + end + + defp parse_integer(value) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {int, ""} -> int + _ -> nil + end + end + + defp parse_integer(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/input.ex b/apps/elektrine/lib/elektrine/input.ex new file mode 100644 index 0000000..9fbf5c5 --- /dev/null +++ b/apps/elektrine/lib/elektrine/input.ex @@ -0,0 +1,24 @@ +defmodule Elektrine.Input do + @moduledoc false + + def sanitize_email(value) when is_binary(value) do + value = String.trim(value) + + if Regex.match?(~r/^[A-Za-z0-9.!#$%&'*+\/?=_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/u, value) do + value + else + "" + end + end + + def sanitize_email(_), do: "" + + def sanitize_username(value) when is_binary(value) do + value + |> String.trim() + |> String.replace(~r/[^[:alnum:]_.+\- ]/u, "") + |> String.slice(0, 128) + end + + def sanitize_username(_), do: "" +end diff --git a/apps/elektrine/lib/elektrine/internal_api.ex b/apps/elektrine/lib/elektrine/internal_api.ex new file mode 100644 index 0000000..c50e282 --- /dev/null +++ b/apps/elektrine/lib/elektrine/internal_api.ex @@ -0,0 +1,11 @@ +defmodule Elektrine.InternalAPI do + @moduledoc false + + alias Elektrine.RuntimeEnv + alias Elektrine.RuntimeSecrets + + def api_key(extra_env_names \\ []) when is_list(extra_env_names) do + RuntimeEnv.first_present(extra_env_names ++ ["INTERNAL_API_KEY"]) || + RuntimeEnv.app_config(:internal_api_key) || RuntimeSecrets.internal_api_key() + end +end diff --git a/apps/elektrine/lib/elektrine/ip_lookup.ex b/apps/elektrine/lib/elektrine/ip_lookup.ex new file mode 100644 index 0000000..fbd77d5 --- /dev/null +++ b/apps/elektrine/lib/elektrine/ip_lookup.ex @@ -0,0 +1,130 @@ +defmodule Elektrine.IpLookup do + @moduledoc """ + Provides IP address geolocation lookup functionality. + Uses ip-api.com free service. + """ + + require Logger + alias Elektrine.HTTP.SafeFetch + + @doc """ + Looks up geolocation information for an IP address. + Returns {:ok, map} with location data or {:error, reason}. + """ + def lookup(ip_address) when is_binary(ip_address) do + # Check if it's a private/local IP first + if private_ip?(ip_address) do + {:ok, + %{ + ip: ip_address, + country: "Local/Private", + country_code: "N/A", + region: "N/A", + city: get_private_ip_description(ip_address), + zip: nil, + latitude: nil, + longitude: nil, + timezone: nil, + isp: "Private Network", + org: nil, + as: nil + }} + else + url = "https://ipwho.is/#{URI.encode_www_form(ip_address)}" + + request = Finch.build(:get, url) + + case SafeFetch.request(request, Elektrine.Finch, receive_timeout: 10_000) do + {:ok, %Finch.Response{status: 200, body: body}} -> + case Jason.decode(body) do + {:ok, %{"success" => true} = data} -> + {:ok, format_lookup_result(data)} + + {:ok, %{"success" => false, "message" => message}} -> + {:error, message} + + {:ok, _} -> + {:error, "Invalid response from geolocation service"} + + {:error, _} -> + {:error, "Failed to parse response"} + end + + {:ok, %Finch.Response{status: status}} -> + {:error, "HTTP #{status}"} + + {:error, reason} -> + Logger.error("IP lookup failed for #{ip_address}: #{inspect(reason)}") + {:error, "Network error: #{inspect(reason)}"} + end + end + end + + defp private_ip?(ip) do + case :inet.parse_address(String.to_charlist(ip)) do + {:ok, {10, _, _, _}} -> + true + + {:ok, {172, second, _, _}} when second >= 16 and second <= 31 -> + true + + {:ok, {192, 168, _, _}} -> + true + + {:ok, {127, _, _, _}} -> + true + + {:ok, {169, 254, _, _}} -> + true + + {:ok, {0, 0, 0, 0, 0, 0, 0, 1}} -> + true + + {:ok, {0, 0, 0, 0, 0, 65_535, 127, _}} -> + true + + {:ok, {first, second, _, _, _, _, _, _}} -> + unique_local_ipv6?(first) or link_local_ipv6?(first, second) + + _ -> + false + end + end + + defp unique_local_ipv6?(first), do: first >= 64_512 and first <= 65_023 + + defp link_local_ipv6?(65_152, second), do: second >= 32_768 and second <= 49_151 + defp link_local_ipv6?(_, _), do: false + + defp get_private_ip_description(ip) do + cond do + String.starts_with?(ip, "127.") -> "Localhost/Loopback" + String.starts_with?(ip, "10.") -> "Private Network (Class A)" + String.starts_with?(ip, "192.168.") -> "Private Network (Class C)" + String.starts_with?(ip, "172.") -> "Private Network (Class B)" + String.starts_with?(ip, "169.254.") -> "Link-Local Address" + true -> "Private/Reserved Range" + end + end + + defp format_lookup_result(data) do + %{ + ip: data["ip"], + country: data["country"], + country_code: data["country_code"], + region: data["region"], + city: data["city"], + zip: data["postal"], + latitude: data["latitude"], + longitude: data["longitude"], + timezone: get_in(data, ["timezone", "id"]), + isp: get_in(data, ["connection", "isp"]), + org: get_in(data, ["connection", "org"]), + as: format_as_number(get_in(data, ["connection", "asn"])) + } + end + + defp format_as_number(asn) when is_integer(asn), do: "AS#{asn}" + defp format_as_number(asn) when is_binary(asn), do: asn + defp format_as_number(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/job_queue.ex b/apps/elektrine/lib/elektrine/job_queue.ex new file mode 100644 index 0000000..bf63c8f --- /dev/null +++ b/apps/elektrine/lib/elektrine/job_queue.ex @@ -0,0 +1,50 @@ +defmodule Elektrine.JobQueue do + @moduledoc false + + alias Oban.{Config, Engine} + + def insert(changeset, opts \\ []) do + insert_to(Oban, changeset, opts) + end + + def insert_to(name, changeset, opts \\ []) do + if running?(name) do + Oban.insert(name, changeset, opts) + else + name + |> fallback_config() + |> Engine.insert_job(changeset, opts) + end + end + + def insert_all(changesets, opts \\ []) do + insert_all_to(Oban, changesets, opts) + end + + def insert_all_to(name, changesets, opts \\ []) do + if running?(name) do + Oban.insert_all(name, changesets, opts) + else + name + |> fallback_config() + |> Engine.insert_all_jobs(changesets, opts) + end + end + + def running?(name \\ Oban) do + not is_nil(Oban.whereis(name)) + end + + defp fallback_config(name) do + Application.fetch_env!(:elektrine, Oban) + |> Keyword.merge( + name: name, + insert_trigger: false, + peer: false, + plugins: [], + queues: [], + stage_interval: :infinity + ) + |> Config.new() + end +end diff --git a/apps/elektrine/lib/elektrine/job_queue_monitor.ex b/apps/elektrine/lib/elektrine/job_queue_monitor.ex new file mode 100644 index 0000000..86cd36f --- /dev/null +++ b/apps/elektrine/lib/elektrine/job_queue_monitor.ex @@ -0,0 +1,209 @@ +defmodule Elektrine.JobQueueMonitor do + @moduledoc """ + Lightweight in-memory Oban telemetry monitor. + + This is intentionally process-local. It is for live operational visibility, + not historical analytics. + """ + + use GenServer + + @initial_state %{ + processed_jobs: 0, + queues: %{}, + workers: %{}, + federation: %{ + queue_depth: 0, + skipped_jobs: 0, + skipped_by_component: %{} + }, + home_feed: %{ + fanout_insert: 0, + fanout_remove: 0, + cache_hit: 0, + cache_miss: 0, + last_cache_size: 0 + }, + reports: %{} + } + + def start_link(_opts) do + GenServer.start_link(__MODULE__, @initial_state, name: __MODULE__) + end + + def stats do + GenServer.call(__MODULE__, :stats) + catch + :exit, _ -> @initial_state + end + + @impl GenServer + def init(state) do + :telemetry.attach_many( + "#{__MODULE__}-oban", + [ + [:oban, :job, :stop], + [:oban, :job, :exception], + [:elektrine, :federation, :queue_depth], + [:elektrine, :federation, :load_guard, :skip], + [:elektrine, :home_feed, :fanout], + [:elektrine, :home_feed, :cache], + [:elektrine, :reports, :operation] + ], + &__MODULE__.handle_event/4, + nil + ) + + {:ok, state} + end + + def handle_event([:oban, :job, event], measurements, metadata, _config) do + GenServer.cast(__MODULE__, {:job_event, event_status(event), measurements, metadata}) + catch + :exit, _ -> :ok + end + + def handle_event([:elektrine, :federation, :queue_depth], measurements, metadata, _config) do + GenServer.cast(__MODULE__, {:federation_queue_depth, measurements, metadata}) + catch + :exit, _ -> :ok + end + + def handle_event([:elektrine, :federation, :load_guard, :skip], measurements, metadata, _config) do + GenServer.cast(__MODULE__, {:federation_skip, measurements, metadata}) + catch + :exit, _ -> :ok + end + + def handle_event([:elektrine, :home_feed, event], measurements, metadata, _config) + when event in [:fanout, :cache] do + GenServer.cast(__MODULE__, {:home_feed_event, event, measurements, metadata}) + catch + :exit, _ -> :ok + end + + def handle_event([:elektrine, :reports, :operation], measurements, metadata, _config) do + GenServer.cast(__MODULE__, {:report_event, measurements, metadata}) + catch + :exit, _ -> :ok + end + + @impl GenServer + def handle_call(:stats, _from, state), do: {:reply, state, state} + + @impl GenServer + def handle_cast({:job_event, status, measurements, metadata}, state) do + queue = metadata_value(metadata, :queue) || "unknown" + worker = metadata_value(metadata, :worker) || "unknown" + op = operation(metadata) + duration = Map.get(measurements, :duration, 0) + + state = + state + |> Map.update!(:processed_jobs, &(&1 + 1)) + |> update_in([:queues], &increment_bucket(&1, queue, status, duration)) + |> update_in([:workers], &increment_worker(&1, worker, op, status, duration)) + + {:noreply, state} + end + + @impl GenServer + def handle_cast({:federation_queue_depth, measurements, _metadata}, state) do + depth = Map.get(measurements, :jobs, 0) + {:noreply, put_in(state, [:federation, :queue_depth], depth)} + end + + def handle_cast({:federation_skip, measurements, metadata}, state) do + count = Map.get(measurements, :count, 1) + component = metadata_value(metadata, :component) || "unknown" + + state = + state + |> update_in([:federation, :skipped_jobs], &(&1 + count)) + |> update_in([:federation, :skipped_by_component], fn buckets -> + Map.update(buckets, component, count, &(&1 + count)) + end) + + {:noreply, state} + end + + def handle_cast({:home_feed_event, :fanout, measurements, metadata}, state) do + count = Map.get(measurements, :count, 1) + + key = + case metadata_value(metadata, :operation) do + "remove" -> :fanout_remove + :remove -> :fanout_remove + _ -> :fanout_insert + end + + {:noreply, update_in(state, [:home_feed, key], &(&1 + count))} + end + + def handle_cast({:home_feed_event, :cache, measurements, metadata}, state) do + count = Map.get(measurements, :count, 1) + size = Map.get(measurements, :size, 0) + + key = + case metadata_value(metadata, :result) do + "hit" -> :cache_hit + :hit -> :cache_hit + _ -> :cache_miss + end + + state = + state + |> update_in([:home_feed, key], &(&1 + count)) + |> put_in([:home_feed, :last_cache_size], size) + + {:noreply, state} + end + + def handle_cast({:report_event, measurements, metadata}, state) do + count = Map.get(measurements, :count, 1) + operation = metadata_value(metadata, :operation) || "unknown" + outcome = metadata_value(metadata, :outcome) || "unknown" + key = "#{operation}:#{outcome}" + + {:noreply, + update_in(state, [:reports], &Map.update(&1, key, count, fn value -> value + count end))} + end + + defp event_status(:stop), do: :success + defp event_status(:exception), do: :failure + defp event_status(_), do: :unknown + + defp increment_bucket(buckets, key, status, duration) do + bucket = + buckets + |> Map.get(key, empty_bucket()) + |> increment_status(status, duration) + + Map.put(buckets, key, bucket) + end + + defp increment_worker(workers, worker, op, status, duration) do + worker_stats = Map.get(workers, worker, %{}) + Map.put(workers, worker, increment_bucket(worker_stats, op, status, duration)) + end + + defp increment_status(bucket, status, duration) do + bucket + |> Map.update!(:processed_jobs, &(&1 + 1)) + |> Map.update!(status, &(&1 + 1)) + |> Map.update!(:duration_native, &(&1 + duration)) + end + + defp empty_bucket do + %{processed_jobs: 0, success: 0, failure: 0, unknown: 0, duration_native: 0} + end + + defp operation(metadata) do + args = metadata_value(metadata, :args) || %{} + Map.get(args, "op") || Map.get(args, :op) || Map.get(args, "type") || "perform" + end + + defp metadata_value(metadata, key) when is_map(metadata) do + Map.get(metadata, key) || Map.get(metadata, Atom.to_string(key)) + end +end diff --git a/apps/elektrine/lib/elektrine/jobs/attachment_uploader.ex b/apps/elektrine/lib/elektrine/jobs/attachment_uploader.ex new file mode 100644 index 0000000..60046c1 --- /dev/null +++ b/apps/elektrine/lib/elektrine/jobs/attachment_uploader.ex @@ -0,0 +1,124 @@ +defmodule Elektrine.Jobs.AttachmentUploader do + @moduledoc """ + Background job to upload email attachments to S3 API storage asynchronously. + This prevents SMTP connections from being held open during slow S3 uploads. + """ + + require Logger + alias Elektrine.Email + alias Elektrine.Email.AttachmentStorage + + @doc """ + Uploads attachments for a message from database to S3 asynchronously. + """ + def upload_message_attachments(message_id) do + case Email.get_message_internal(message_id) do + nil -> + Logger.error("Message #{message_id} not found for attachment upload") + {:error, :message_not_found} + + message -> + if message.attachments && map_size(message.attachments) > 0 do + Logger.info( + "Uploading #{map_size(message.attachments)} attachments for message #{message_id} to S3" + ) + + updated_attachments = + upload_attachments_to_s3( + message.mailbox_id, + message.id, + message.attachments + ) + + # Update message with S3 storage metadata + case Email.update_message(message, %{attachments: updated_attachments}) do + {:ok, _updated_message} -> + Logger.info("Successfully uploaded all attachments for message #{message_id}") + :ok + + {:error, changeset} -> + Logger.error( + "Failed to update message #{message_id} with S3 metadata: #{inspect(changeset.errors)}" + ) + + {:error, :update_failed} + end + else + :ok + end + end + end + + # Upload attachments to S3 and return updated metadata + defp upload_attachments_to_s3(mailbox_id, message_id, attachments) when is_map(attachments) do + attachments + |> Enum.map(fn {attachment_id, attachment_data} -> + if AttachmentStorage.stored_attachment?(attachment_data) do + {attachment_id, attachment_data} + else + case upload_single_attachment(mailbox_id, message_id, attachment_id, attachment_data) do + {:ok, storage_metadata} -> + updated_data = + Map.merge(attachment_data, storage_metadata) + |> Map.delete("data") + |> Map.delete("content") + + {attachment_id, updated_data} + + {:error, reason} -> + Logger.error("Failed to upload attachment #{attachment_id}: #{inspect(reason)}") + # Keep original attachment data if upload fails + {attachment_id, attachment_data} + end + end + end) + |> Enum.into(%{}) + end + + defp upload_single_attachment(mailbox_id, message_id, attachment_id, attachment_data) do + # Extract content from attachment data + content = + case attachment_data["data"] || attachment_data["content"] do + nil -> + Logger.error("No data found for attachment #{attachment_id}") + nil + + data when is_binary(data) -> + # Decode base64 if needed + case attachment_data["encoding"] || "base64" do + "base64" -> + case Base.decode64(data) do + {:ok, decoded} -> + decoded + + :error -> + Logger.warning( + "Failed to decode base64 for attachment #{attachment_id}, using as-is" + ) + + data + end + + _ -> + data + end + + data -> + Logger.warning("Unexpected data type for attachment #{attachment_id}: #{inspect(data)}") + data + end + + if content do + # Upload to S3 API storage + AttachmentStorage.upload_attachment( + mailbox_id, + message_id, + attachment_id, + content, + attachment_data + ) + else + {:error, "No content to upload"} + end + end +end diff --git a/apps/elektrine/lib/elektrine/jobs/auto_promote_eligible_users_worker.ex b/apps/elektrine/lib/elektrine/jobs/auto_promote_eligible_users_worker.ex new file mode 100644 index 0000000..5cf7dfb --- /dev/null +++ b/apps/elektrine/lib/elektrine/jobs/auto_promote_eligible_users_worker.ex @@ -0,0 +1,22 @@ +defmodule Elektrine.Jobs.AutoPromoteEligibleUsersWorker do + @moduledoc """ + Oban worker that auto-promotes users whose trust stats qualify them. + """ + + use Oban.Worker, queue: :default, max_attempts: 1 + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{}) do + {:ok, count} = Elektrine.Accounts.TrustLevel.auto_promote_eligible_users() + Logger.info("AutoPromoteEligibleUsersWorker: promoted #{count} user(s)") + :ok + end + + def enqueue do + %{} + |> new() + |> Elektrine.JobQueue.insert() + end +end diff --git a/apps/elektrine/lib/elektrine/jobs/deactivate_expired_announcements_worker.ex b/apps/elektrine/lib/elektrine/jobs/deactivate_expired_announcements_worker.ex new file mode 100644 index 0000000..6c91f00 --- /dev/null +++ b/apps/elektrine/lib/elektrine/jobs/deactivate_expired_announcements_worker.ex @@ -0,0 +1,22 @@ +defmodule Elektrine.Jobs.DeactivateExpiredAnnouncementsWorker do + @moduledoc """ + Oban worker that deactivates expired announcements. + """ + + use Oban.Worker, queue: :default, max_attempts: 1 + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{}) do + count = Elektrine.Admin.deactivate_expired_announcements() + Logger.info("DeactivateExpiredAnnouncementsWorker: deactivated #{count} announcement(s)") + :ok + end + + def enqueue do + %{} + |> new() + |> Elektrine.JobQueue.insert() + end +end diff --git a/apps/elektrine/lib/elektrine/jobs/email_recategorizer.ex b/apps/elektrine/lib/elektrine/jobs/email_recategorizer.ex new file mode 100644 index 0000000..173a0e0 --- /dev/null +++ b/apps/elektrine/lib/elektrine/jobs/email_recategorizer.ex @@ -0,0 +1,95 @@ +defmodule Elektrine.Jobs.EmailRecategorizer do + @moduledoc """ + Oban worker that recategorizes recent emails that were left in the inbox. + """ + + use Oban.Worker, queue: :default, max_attempts: 1 + + require Logger + alias Elektrine.Email + alias Elektrine.Email.Message + alias Elektrine.Repo + import Ecto.Query + @batch_size 50 + @batch_delay_ms 100 + + @impl Oban.Worker + def perform(%Oban.Job{}) do + run() + end + + def run do + cutoff = DateTime.utc_now() |> DateTime.add(-1, :day) + + messages = + from(m in Message, + where: m.inserted_at > ^cutoff and not m.spam and not m.archived, + order_by: [desc: m.inserted_at], + limit: ^@batch_size + ) + |> Repo.all(timeout: 5000) + + if messages != [] do + Logger.info("EmailRecategorizer: Checking #{length(messages)} messages") + + recategorized = + Enum.reduce(messages, 0, fn message, count -> + Process.sleep(@batch_delay_ms) + + case recategorize_message(message) do + {:ok, true} -> count + 1 + _ -> count + end + end) + + if recategorized > 0 do + Logger.info("EmailRecategorizer: Recategorized #{recategorized} messages") + end + end + + :ok + end + + defp recategorize_message(message) do + message_attrs = %{ + "subject" => message.subject || "", + "from" => message.from || "", + "to" => message.to || "", + "text_body" => message.text_body || "", + "html_body" => message.html_body || "", + "metadata" => %{"headers" => (message.metadata && message.metadata["headers"]) || %{}} + } + + categorized = Email.categorize_message(message_attrs) + new_category = categorized["category"] + + if new_category != "inbox" && new_category != message.category do + message + |> Ecto.Changeset.change(%{ + category: new_category, + is_newsletter: categorized["is_newsletter"], + is_receipt: categorized["is_receipt"], + is_notification: categorized["is_notification"] + }) + |> Repo.update() + + Logger.info( + "Recategorized: '#{String.slice(message.subject || "No subject", 0, 50)}' from inbox -> #{new_category}" + ) + + {:ok, true} + else + {:ok, false} + end + rescue + e -> + Logger.error("Failed to recategorize message #{message.id}: #{inspect(e)}") + {:error, e} + end + + def enqueue do + %{} + |> new() + |> Elektrine.JobQueue.insert() + end +end diff --git a/apps/elektrine/lib/elektrine/jobs/profile_custom_domain_health_worker.ex b/apps/elektrine/lib/elektrine/jobs/profile_custom_domain_health_worker.ex new file mode 100644 index 0000000..5d6e371 --- /dev/null +++ b/apps/elektrine/lib/elektrine/jobs/profile_custom_domain_health_worker.ex @@ -0,0 +1,18 @@ +defmodule Elektrine.Jobs.ProfileCustomDomainHealthWorker do + @moduledoc """ + Periodically re-verifies the DNS records backing custom profile domains so a + domain whose records were removed is eventually demoted back to pending. + """ + + use Oban.Worker, queue: :default, max_attempts: 1 + + alias Elektrine.Profiles + + @impl Oban.Worker + def perform(%Oban.Job{}) do + Profiles.list_custom_domains_for_recheck(500) + |> Enum.each(fn domain -> _ = Profiles.verify_custom_domain(domain) end) + + :ok + end +end diff --git a/apps/elektrine/lib/elektrine/jobs/recalculate_recent_discussion_scores_worker.ex b/apps/elektrine/lib/elektrine/jobs/recalculate_recent_discussion_scores_worker.ex new file mode 100644 index 0000000..db4d419 --- /dev/null +++ b/apps/elektrine/lib/elektrine/jobs/recalculate_recent_discussion_scores_worker.ex @@ -0,0 +1,31 @@ +defmodule Elektrine.Jobs.RecalculateRecentDiscussionScoresWorker do + @moduledoc """ + Oban worker that refreshes discussion ranking scores for recent social posts. + """ + + use Oban.Worker, queue: :default, max_attempts: 1 + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{}) do + case Elektrine.Social.recalculate_recent_discussion_scores() do + {:ok, result} -> + Logger.info( + "RecalculateRecentDiscussionScoresWorker: updated discussion scores #{inspect(result)}" + ) + + :ok + + {:error, reason} -> + Logger.error("RecalculateRecentDiscussionScoresWorker failed: #{inspect(reason)}") + {:error, inspect(reason)} + end + end + + def enqueue do + %{} + |> new() + |> Elektrine.JobQueue.insert() + end +end diff --git a/apps/elektrine/lib/elektrine/jobs/reply_later_processor.ex b/apps/elektrine/lib/elektrine/jobs/reply_later_processor.ex new file mode 100644 index 0000000..f57b0c7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/jobs/reply_later_processor.ex @@ -0,0 +1,63 @@ +defmodule Elektrine.Jobs.ReplyLaterProcessor do + @moduledoc """ + Oban worker that returns due reply-later messages to the inbox. + """ + + use Oban.Worker, queue: :default, max_attempts: 1 + + require Logger + import Ecto.Query + alias Elektrine.Email.Message + alias Elektrine.Repo + + @impl Oban.Worker + def perform(%Oban.Job{}) do + run() + :ok + end + + @doc """ + Processes all due reply later messages and returns them to inbox. + """ + def run do + now = DateTime.utc_now() + + # Find all messages where reply_later_at has passed + due_messages = + Message + |> where([m], not is_nil(m.reply_later_at)) + |> where([m], m.reply_later_at <= ^now) + |> where([m], not m.deleted) + |> where([m], not m.archived) + |> Repo.all() + + count = length(due_messages) + + if count > 0 do + Logger.info("Processing #{count} reply later message(s)") + + # Clear reply_later_at for each message to return them to inbox + Enum.each(due_messages, fn message -> + case Elektrine.Email.clear_reply_later(message) do + {:ok, _updated_message} -> + :ok + + {:error, changeset} -> + Logger.error( + "Failed to clear reply_later for message #{message.id}: #{inspect(changeset.errors)}" + ) + end + end) + + Logger.info("Successfully processed #{count} reply later message(s)") + end + + :ok + end + + def enqueue do + %{} + |> new() + |> Elektrine.JobQueue.insert() + end +end diff --git a/apps/elektrine/lib/elektrine/jobs/stale_call_cleanup.ex b/apps/elektrine/lib/elektrine/jobs/stale_call_cleanup.ex new file mode 100644 index 0000000..b8076d3 --- /dev/null +++ b/apps/elektrine/lib/elektrine/jobs/stale_call_cleanup.ex @@ -0,0 +1,71 @@ +defmodule Elektrine.Jobs.StaleCallCleanup do + @moduledoc """ + Oban worker that cleans up stale calls that never completed. + + Calls stuck in "initiated" or "ringing" for more than 5 minutes + are automatically marked as "missed" to prevent database pollution + and blocking new calls. + """ + + use Oban.Worker, queue: :default, max_attempts: 1 + + require Logger + alias Elektrine.Calls.Call + alias Elektrine.Repo + import Ecto.Query + + @impl Oban.Worker + def perform(%Oban.Job{}) do + run() + :ok + end + + def run do + one_minute_ago = DateTime.utc_now() |> DateTime.add(-60, :second) + ten_minutes_ago = DateTime.utc_now() |> DateTime.add(-600, :second) + + # Clean up calls stuck in initiated/ringing state (1 minute - unanswered calls) + {initiated_count, _} = + from(c in Call, + where: c.status in ["initiated", "ringing"], + where: c.inserted_at < ^one_minute_ago + ) + |> Repo.update_all( + set: [ + status: "missed", + ended_at: DateTime.utc_now(), + updated_at: DateTime.utc_now() + ] + ) + + # Clean up calls stuck in active state (10 minutes - these are hanging calls) + {active_count, _} = + from(c in Call, + where: c.status == "active", + where: c.inserted_at < ^ten_minutes_ago + ) + |> Repo.update_all( + set: [ + status: "ended", + ended_at: DateTime.utc_now(), + updated_at: DateTime.utc_now() + ] + ) + + total_count = initiated_count + active_count + + if total_count > 0 do + Logger.info( + "Cleaned up #{total_count} stale calls (#{initiated_count} initiated/ringing, #{active_count} active)" + ) + end + + :ok + end + + def enqueue do + %{} + |> new() + |> Elektrine.JobQueue.insert() + end +end diff --git a/apps/elektrine/lib/elektrine/link_archive.ex b/apps/elektrine/lib/elektrine/link_archive.ex new file mode 100644 index 0000000..1386f2d --- /dev/null +++ b/apps/elektrine/lib/elektrine/link_archive.ex @@ -0,0 +1,298 @@ +defmodule Elektrine.LinkArchive do + @moduledoc """ + Keeps a private copy of the pages a user links to. + + Link rot is the problem this exists for: a post that cited a source is worth + less every year as the sources go dark. When a user posts or saves a link, + the page behind it is fetched once and stored against their account, so the + citation still resolves after the origin stops serving it. + + Fetching goes through `Elektrine.LinkArchive.FetchWorker`, which reuses the + SSRF-safe HTTP stack, so archiving a hostile URL cannot be used to probe the + internal network. + + Archives are private by default. Storing someone else's page is defensible as + a personal copy of something the user already read; republishing it is not, + so nothing here exposes an archive to anyone but its owner. + """ + + import Ecto.Query, warn: false + + alias Elektrine.LinkArchive.Archive + alias Elektrine.LinkArchive.FetchWorker + alias Elektrine.Repo + alias Elektrine.Security.URLValidator + + require Logger + + @doc """ + Requests an archive of `url` for `user_id`. + + Returns `{:ok, archive}` with an existing row when the URL was already + archived — archiving is idempotent per user, so callers can fire it on every + post without checking first. Returns `{:error, :unsupported_url}` for + anything the URL validator rejects. + """ + def archive_url(user_id, url, opts \\ []) + + def archive_url(user_id, url, opts) when is_integer(user_id) and is_binary(url) do + with {:ok, normalized} <- normalize_url(url) do + hash = url_hash(normalized) + + case get_by_hash(user_id, hash) do + %Archive{} = existing -> + {:ok, existing} + + nil -> + insert_pending(user_id, url, normalized, hash, opts) + end + end + end + + def archive_url(_user_id, _url, _opts), do: {:error, :unsupported_url} + + @doc """ + Archives every URL in `urls`, ignoring the ones that cannot be archived. + + Used by callers that have just extracted links from a post and want to fire + and forget; failures are logged rather than surfaced, because a link that + cannot be archived must never stop a post from being published. + """ + def archive_urls(user_id, urls, opts \\ []) when is_list(urls) do + urls + |> Enum.uniq() + |> Enum.reduce([], fn url, acc -> + case archive_url(user_id, url, opts) do + {:ok, archive} -> + [archive | acc] + + {:error, reason} -> + Logger.debug("Skipped archiving #{inspect(url)}: #{inspect(reason)}") + acc + end + end) + |> Enum.reverse() + end + + @doc """ + Archives whatever a freshly published post linked to. + + Lives here rather than at the call site so the policy — never let archiving + affect publishing — is stated once. A post going out matters; a copy of a + page it cited does not matter nearly as much. + """ + def archive_post_links(%{extracted_urls: urls}, user_id) when is_list(urls) and urls != [] do + archive_urls(user_id, urls, origin: "timeline_post") + rescue + error -> + Logger.warning("Link archiving failed for user #{user_id}: #{inspect(error)}") + [] + end + + def archive_post_links(_message, _user_id), do: [] + + @doc "Lists a user's archives, newest first." + def list_archives(user_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + + Archive + |> where([a], a.user_id == ^user_id) + |> maybe_filter_status(Keyword.get(opts, :status)) + |> order_by([a], desc: a.inserted_at) + |> limit(^limit) + |> Repo.all() + end + + @doc "Fetches one of the user's archives by id." + def get_archive(user_id, id) do + Repo.get_by(Archive, id: id, user_id: user_id) + end + + @doc "Fetches a user's archive of a specific URL, if any." + def get_archive_by_url(user_id, url) do + case normalize_url(url) do + {:ok, normalized} -> get_by_hash(user_id, url_hash(normalized)) + _ -> nil + end + end + + @doc false + def get_by_hash(user_id, hash) do + Repo.get_by(Archive, user_id: user_id, url_hash: hash) + end + + @doc false + def update_archive(%Archive{} = archive, attrs) do + archive + |> Archive.changeset(attrs) + |> Repo.update() + end + + @doc """ + The archived page as HTML safe to render inside the app. + + The stored copy is someone else's markup, so it goes through the same + allowlist sanitizer used elsewhere: structure, headings, and links survive; + scripts, styles, and event handlers do not. Images are dropped rather than + left pointing at the origin — a snapshot that silently reaches back out to + the server it was archiving would leak the reader's IP and, once the origin + is gone, show nothing anyway. + + Returns nil when there is no stored HTML, in which case callers should fall + back to `content_text`. + """ + def readable_html(%Archive{content_html: html}) when is_binary(html) and html != "" do + readable = + html + |> drop_script_and_style() + |> HtmlSanitizeEx.basic_html() + |> strip_images() + + # A page that is nothing but script and markup sanitizes down to whitespace. + # Returning that would render an empty pane; nil lets the caller fall back + # to the extracted text. + if blank_markup?(readable), do: nil, else: readable + end + + def readable_html(_archive), do: nil + + defp blank_markup?(html) do + html + |> String.replace(~r/<[^>]+>/, " ") + |> String.trim() + |> String.replace(~r/\s+/u, "") + |> Kernel.==("") + end + + # The sanitizer drops the tags but keeps what was between them, so a page's + # JavaScript would otherwise be rendered as body text. Inert, but it reads + # like the archive captured gibberish. + defp drop_script_and_style(html) do + String.replace(html, ~r/<(script|style)[^>]*>.*?<\/\1>/is, " ") + end + + defp strip_images(html), do: String.replace(html, ~r/]*>/i, "") + + @doc "Deletes an archive the user owns." + def delete_archive(user_id, id) do + case get_archive(user_id, id) do + nil -> {:error, :not_found} + archive -> Repo.delete(archive) + end + end + + @doc """ + Canonicalizes a URL for deduplication. + + Strips the fragment and the tracking parameters that vary per share without + changing the document, so the same article shared from three places archives + once. + """ + @tracking_params ~w( + utm_source utm_medium utm_campaign utm_term utm_content utm_id + gclid fbclid mc_cid mc_eid igshid ref ref_src s cmpid + ) + + def canonicalize(url) when is_binary(url) do + uri = URI.parse(String.trim(url)) + + query = + case uri.query do + nil -> + nil + + query -> + query + |> URI.decode_query() + |> Map.drop(@tracking_params) + |> case do + empty when map_size(empty) == 0 -> nil + kept -> kept |> Enum.sort() |> URI.encode_query() + end + end + + %URI{uri | fragment: nil, query: query, host: normalize_host(uri.host)} + |> URI.to_string() + end + + def canonicalize(_url), do: nil + + @doc false + def url_hash(url), do: :crypto.hash(:sha256, url) |> Base.encode16(case: :lower) + + defp insert_pending(user_id, url, normalized, hash, opts) do + attrs = %{ + user_id: user_id, + url: url, + url_hash: hash, + canonical_url: normalized, + status: "pending", + origin: opts[:origin] && to_string(opts[:origin]), + origin_id: opts[:origin_id] && to_string(opts[:origin_id]) + } + + %Archive{} + |> Archive.changeset(attrs) + |> Repo.insert() + |> case do + {:ok, archive} -> + enqueue_fetch(archive, opts) + {:ok, archive} + + # Two posts sharing a link can race; the loser just uses the winner's row. + {:error, %Ecto.Changeset{errors: errors}} = error -> + if Keyword.has_key?(errors, :user_id) or Keyword.has_key?(errors, :url_hash) do + case get_by_hash(user_id, hash) do + %Archive{} = existing -> {:ok, existing} + nil -> error + end + else + error + end + end + end + + defp enqueue_fetch(archive, opts) do + if Keyword.get(opts, :fetch, true) do + case FetchWorker.enqueue(archive) do + {:ok, _job} -> :ok + {:error, reason} -> Logger.warning("Could not enqueue archive fetch: #{inspect(reason)}") + end + end + end + + defp normalize_url(url) do + canonical = canonicalize(url) + + cond do + is_nil(canonical) -> {:error, :unsupported_url} + not http_url?(canonical) -> {:error, :unsupported_url} + not safe_url?(canonical) -> {:error, :unsupported_url} + true -> {:ok, canonical} + end + end + + defp http_url?(url) do + case URI.parse(url) do + %URI{scheme: scheme, host: host} when is_binary(host) and host != "" -> + scheme in ["http", "https"] + + _ -> + false + end + end + + # The validator is the same one the SSRF-safe fetcher uses, so a URL that + # would be refused at fetch time is refused before a row is written. + defp safe_url?(url) do + URLValidator.validate(url) == :ok + rescue + _ -> false + end + + defp normalize_host(nil), do: nil + defp normalize_host(host), do: String.downcase(host) + + defp maybe_filter_status(query, nil), do: query + defp maybe_filter_status(query, status), do: where(query, [a], a.status == ^status) +end diff --git a/apps/elektrine/lib/elektrine/link_archive/archive.ex b/apps/elektrine/lib/elektrine/link_archive/archive.ex new file mode 100644 index 0000000..a80f397 --- /dev/null +++ b/apps/elektrine/lib/elektrine/link_archive/archive.ex @@ -0,0 +1,67 @@ +defmodule Elektrine.LinkArchive.Archive do + @moduledoc """ + A user's archived copy of a linked page. + + One row per (user, URL). The copy is stored rather than referenced on + purpose: the point of the feature is that it survives the original going + away, so a pointer to someone else's server would defeat it. + """ + use Ecto.Schema + + import Ecto.Changeset + + @statuses ~w(pending fetching archived failed skipped) + + schema "link_archives" do + field :url, :string + field :url_hash, :string + field :canonical_url, :string + + field :status, :string, default: "pending" + field :title, :string + field :content_text, :string + field :content_html, :string + field :content_type, :string + field :byte_size, :integer + field :body_hash, :string + + field :origin, :string + field :origin_id, :string + + field :error_message, :string + field :fetched_at, :utc_datetime + field :last_verified_at, :utc_datetime + + belongs_to :user, Elektrine.Accounts.User + + timestamps() + end + + def statuses, do: @statuses + + def changeset(archive, attrs) do + archive + |> cast(attrs, [ + :user_id, + :url, + :url_hash, + :canonical_url, + :status, + :title, + :content_text, + :content_html, + :content_type, + :byte_size, + :body_hash, + :origin, + :origin_id, + :error_message, + :fetched_at, + :last_verified_at + ]) + |> validate_required([:user_id, :url, :url_hash, :status]) + |> validate_inclusion(:status, @statuses) + |> unique_constraint([:user_id, :url_hash]) + |> foreign_key_constraint(:user_id) + end +end diff --git a/apps/elektrine/lib/elektrine/link_archive/fetch_worker.ex b/apps/elektrine/lib/elektrine/link_archive/fetch_worker.ex new file mode 100644 index 0000000..167204d --- /dev/null +++ b/apps/elektrine/lib/elektrine/link_archive/fetch_worker.ex @@ -0,0 +1,198 @@ +defmodule Elektrine.LinkArchive.FetchWorker do + @moduledoc """ + Fetches and stores the page behind an archived link. + + Runs on the `kairo` queue, which is already sized for polite outbound page + fetching, and goes through `Elektrine.HTTP.SafeFetch` so a link someone else + wrote cannot be used to reach the internal network. + + Failures are recorded on the row rather than retried forever: a link that is + already dead when it is first seen will not become alive, and the useful + outcome is knowing that, not hammering it. + """ + + use Oban.Worker, + queue: :kairo, + max_attempts: 3, + unique: [ + period: 300, + keys: [:archive_id], + states: [:available, :scheduled, :executing, :retryable] + ] + + alias Elektrine.HTTP.SafeFetch + alias Elektrine.LinkArchive + alias Elektrine.LinkArchive.Archive + + @user_agent "Elektrine/1.0 (Link Archive)" + @max_body_bytes 5 * 1024 * 1024 + @max_text_chars 200_000 + @archivable_types ~w(text/html application/xhtml+xml text/plain text/markdown) + + def enqueue(%Archive{id: id, user_id: user_id}) do + %{"archive_id" => id, "user_id" => user_id} + |> new() + |> Elektrine.JobQueue.insert() + end + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"archive_id" => archive_id, "user_id" => user_id}}) do + case LinkArchive.get_archive(user_id, archive_id) do + nil -> + # The user deleted it before the job ran. Nothing to do. + :ok + + %Archive{status: "archived"} -> + :ok + + %Archive{} = archive -> + archive + |> mark_fetching() + |> fetch_and_store() + end + end + + defp mark_fetching(archive) do + case LinkArchive.update_archive(archive, %{status: "fetching"}) do + {:ok, updated} -> updated + {:error, _changeset} -> archive + end + end + + defp fetch_and_store(%Archive{} = archive) do + url = archive.canonical_url || archive.url + + case request(url) do + {:ok, %{status: status, headers: headers, body: body}} when status in 200..299 -> + store(archive, headers, body) + + {:ok, %{status: status}} -> + fail(archive, "http_#{status}", retry?: status >= 500) + + {:error, :too_large} -> + fail(archive, "too_large", retry?: false) + + {:error, reason} -> + fail(archive, inspect(reason), retry?: true) + end + end + + defp store(archive, headers, body) do + content_type = content_type(headers) + + if archivable?(content_type) do + body_hash = :crypto.hash(:sha256, body) |> Base.encode16(case: :lower) + + attrs = %{ + status: "archived", + title: title(body, content_type), + content_html: html_body(body, content_type), + content_text: text_body(body, content_type), + content_type: content_type, + byte_size: byte_size(body), + body_hash: body_hash, + fetched_at: now(), + last_verified_at: now(), + error_message: nil + } + + case LinkArchive.update_archive(archive, attrs) do + {:ok, _archive} -> :ok + {:error, changeset} -> {:error, changeset} + end + else + # PDFs, images, and video are out of scope for a text archive. Recording + # the type is more honest than storing a truncated binary. + LinkArchive.update_archive(archive, %{ + status: "skipped", + content_type: content_type, + error_message: "unsupported_content_type", + fetched_at: now() + }) + + :ok + end + end + + defp fail(archive, reason, opts) do + LinkArchive.update_archive(archive, %{ + status: "failed", + error_message: reason, + fetched_at: now() + }) + + if Keyword.get(opts, :retry?, false), do: {:error, reason}, else: :ok + end + + defp request(url) do + case Application.get_env(:elektrine, :link_archive_request_fun) do + fun when is_function(fun, 1) -> + fun.(url) + + nil -> + request = Finch.build(:get, url, [{"user-agent", @user_agent}]) + + SafeFetch.request(request, Elektrine.Finch, + receive_timeout: 30_000, + max_body_bytes: @max_body_bytes + ) + end + end + + defp archivable?(nil), do: true + defp archivable?(type), do: Enum.any?(@archivable_types, &String.starts_with?(type, &1)) + + defp html?(nil), do: true + + defp html?(content_type), + do: String.starts_with?(content_type, "text/html") or String.contains?(content_type, "xhtml") + + defp html_body(body, content_type) do + if html?(content_type), do: body + end + + defp text_body(body, content_type) do + if html?(content_type) do + body + |> strip_tags() + |> String.slice(0, @max_text_chars) + else + String.slice(body, 0, @max_text_chars) + end + end + + defp title(body, content_type) do + if html?(content_type) do + case Regex.run(~r/]*>(.*?)<\/title>/is, body) do + [_, title] -> + title |> collapse_whitespace() |> String.slice(0, 500) + + _ -> + nil + end + end + end + + defp strip_tags(html) do + html + |> String.replace(~r/<(script|style)[^>]*>.*?<\/\1>/is, " ") + |> String.replace(~r/<[^>]+>/, " ") + |> collapse_whitespace() + end + + defp collapse_whitespace(text) do + text + |> String.replace(~r/\s+/u, " ") + |> String.trim() + end + + defp now, do: DateTime.utc_now() |> DateTime.truncate(:second) + + defp content_type(headers) do + Enum.find_value(headers, fn {key, value} -> + if String.downcase(to_string(key)) == "content-type" do + value |> to_string() |> String.split(";") |> List.first() |> String.trim() + end + end) + end +end diff --git a/apps/elektrine/lib/elektrine/mail/telemetry.ex b/apps/elektrine/lib/elektrine/mail/telemetry.ex new file mode 100644 index 0000000..eaa716b --- /dev/null +++ b/apps/elektrine/lib/elektrine/mail/telemetry.ex @@ -0,0 +1,54 @@ +defmodule Elektrine.Mail.Telemetry do + @moduledoc """ + Shared telemetry helpers for mailbox protocols (IMAP/POP3/SMTP). + """ + + @spec auth(atom(), atom(), map()) :: :ok + def auth(protocol, outcome, metadata \\ %{}) do + :telemetry.execute( + [:elektrine, :mail, :auth], + %{count: 1}, + Map.merge(%{protocol: protocol, outcome: outcome}, sanitize_metadata(metadata)) + ) + + :ok + end + + @spec command(atom(), String.t() | atom(), non_neg_integer(), atom()) :: :ok + def command(protocol, command, duration_us, outcome \\ :ok) do + :telemetry.execute( + [:elektrine, :mail, :command], + %{duration: duration_us}, + %{protocol: protocol, command: normalize_command(command), outcome: outcome} + ) + + :ok + end + + @spec sessions(atom(), non_neg_integer(), non_neg_integer()) :: :ok + def sessions(protocol, total, per_ip) do + :telemetry.execute( + [:elektrine, :mail, :sessions], + %{total: total, per_ip: per_ip}, + %{protocol: protocol} + ) + + :ok + end + + defp sanitize_metadata(metadata) do + metadata + |> Map.take([:reason, :source, :ratelimit]) + |> Enum.into(%{}, fn {k, v} -> {k, sanitize_value(v)} end) + end + + defp sanitize_value(value) when is_atom(value), do: Atom.to_string(value) + defp sanitize_value(value) when is_binary(value), do: value + defp sanitize_value(value), do: inspect(value) + + defp normalize_command(command) when is_atom(command), + do: command |> Atom.to_string() |> String.upcase() + + defp normalize_command(command) when is_binary(command), do: String.upcase(command) + defp normalize_command(_command), do: "UNKNOWN" +end diff --git a/apps/elektrine/lib/elektrine/mail_auth/rate_limiter.ex b/apps/elektrine/lib/elektrine/mail_auth/rate_limiter.ex new file mode 100644 index 0000000..5041dde --- /dev/null +++ b/apps/elektrine/lib/elektrine/mail_auth/rate_limiter.ex @@ -0,0 +1,84 @@ +defmodule Elektrine.MailAuth.RateLimiter do + @moduledoc """ + Cross-protocol rate limiter for mailbox authentication by account identifier. + + This complements per-IP rate limiting by also throttling repeated failures + against the same account name (or email address), reducing account-targeted + password spray attacks. + """ + + use Elektrine.RateLimiter, + table: :mail_auth_account_rate_limiter, + limits: [ + {:minute, 12}, + {:hour, 60} + ], + lockout: {:minutes, 10}, + cleanup_interval: {:minutes, 1} + + @spec check_attempt(atom(), String.t() | nil) :: {:ok, non_neg_integer()} | {:error, atom()} + def check_attempt(protocol, identifier) do + key = normalized_key(protocol, identifier) + + case check_rate_limit(key) do + {:ok, :allowed} -> + status = get_status(key) + remaining = get_in(status.attempts, [60, :remaining]) || 0 + {:ok, remaining} + + {:error, {:rate_limited, _retry_after, :locked_out}} -> + {:error, :blocked} + + {:error, {:rate_limited, _retry_after, _reason}} -> + {:error, :rate_limited} + end + end + + @spec record_failure(atom(), String.t() | nil) :: :ok + def record_failure(protocol, identifier) do + record_attempt(normalized_key(protocol, identifier)) + end + + @spec clear_attempts(atom(), String.t() | nil) :: :ok + def clear_attempts(protocol, identifier) do + clear_limits(normalized_key(protocol, identifier)) + end + + @spec failure_count(atom(), String.t() | nil) :: non_neg_integer() + def failure_count(protocol, identifier) do + key = normalized_key(protocol, identifier) + status = get_status(key) + get_in(status.attempts, [60, :count]) || 0 + end + + @doc """ + Returns a stable key with a hashed identifier so account names are not stored + in plain text in ETS. + """ + @spec normalized_key(atom(), String.t() | nil) :: String.t() + def normalized_key(protocol, identifier) do + _ = normalize_protocol(protocol) + normalized_subject = normalize_subject(identifier) + digest = :crypto.hash(:sha256, normalized_subject) |> Base.encode16(case: :lower) + "mail-auth:#{digest}" + end + + defp normalize_protocol(protocol) when protocol in [:imap, :pop3, :smtp], do: protocol + + defp normalize_protocol(protocol) when is_binary(protocol) do + case String.downcase(protocol) do + "imap" -> :imap + "pop3" -> :pop3 + "smtp" -> :smtp + _ -> :unknown + end + end + + defp normalize_protocol(_protocol), do: :unknown + + defp normalize_subject(nil), do: "identifier:__nil__" + + defp normalize_subject(identifier) do + Elektrine.Accounts.mail_auth_subject(identifier) + end +end diff --git a/apps/elektrine/lib/elektrine/mail_client_settings.ex b/apps/elektrine/lib/elektrine/mail_client_settings.ex new file mode 100644 index 0000000..48450b7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/mail_client_settings.ex @@ -0,0 +1,125 @@ +defmodule Elektrine.MailClientSettings do + @moduledoc """ + Resolves the client-facing mail host, port, and transport settings. + """ + + alias Elektrine.Domains + alias Elektrine.RuntimeEnv + + @default_settings [ + imap: [port: 993, security: :ssl], + pop3: [port: 995, security: :ssl], + smtp: [port: 465, security: :ssl] + ] + + def imap(domain \\ Domains.primary_email_domain()), do: build(:imap, domain) + def pop3(domain \\ Domains.primary_email_domain()), do: build(:pop3, domain) + def smtp(domain \\ Domains.primary_email_domain()), do: build(:smtp, domain) + + def socket_type(%{security: security}), do: socket_type(security) + def socket_type(:ssl), do: "SSL" + def socket_type(:starttls), do: "STARTTLS" + def socket_type(:plain), do: "plain" + + def security_label(%{security: security}), do: security_label(security) + def security_label(:ssl), do: "SSL/TLS" + def security_label(:starttls), do: "STARTTLS" + def security_label(:plain), do: "plain text" + + def ssl?(%{security: security}), do: ssl?(security) + def ssl?(:ssl), do: true + def ssl?(:starttls), do: false + def ssl?(:plain), do: false + + def starttls?(%{security: security}), do: starttls?(security) + def starttls?(:starttls), do: true + def starttls?(:ssl), do: false + def starttls?(:plain), do: false + + def host(protocol, domain \\ Domains.primary_email_domain()) + when protocol in [:imap, :pop3, :smtp] do + env_host(protocol) || present_env("MAIL_SERVICE_HOST") || default_host(domain) + end + + defp default_host(domain), do: "mail.#{String.downcase(domain)}" + + defp build(protocol, domain) do + default_settings = Keyword.fetch!(@default_settings, protocol) + configured_settings = configured_settings(protocol) + + %{ + protocol: protocol, + host: host(protocol, domain), + port: Keyword.get(configured_settings, :port, Keyword.fetch!(default_settings, :port)), + security: + configured_settings + |> Keyword.get(:security, Keyword.fetch!(default_settings, :security)) + |> normalize_security() + } + end + + defp configured_settings(protocol) do + client_settings = Application.get_env(:elektrine, :mail_client_settings, []) + + cond do + Keyword.keyword?(client_settings) and Keyword.has_key?(client_settings, protocol) -> + Keyword.get(client_settings, protocol, []) + + RuntimeEnv.environment() == :dev -> + listener_settings(protocol) + + true -> + [] + end + end + + defp listener_settings(:imap) do + if Application.get_env(:elektrine, :imaps_enabled, false) do + [port: Application.get_env(:elektrine, :imaps_port, 2993), security: :ssl] + else + [port: Application.get_env(:elektrine, :imap_port, 2143), security: :plain] + end + end + + defp listener_settings(:pop3) do + if Application.get_env(:elektrine, :pop3s_enabled, false) do + [port: Application.get_env(:elektrine, :pop3s_port, 2995), security: :ssl] + else + [port: Application.get_env(:elektrine, :pop3_port, 2110), security: :plain] + end + end + + defp listener_settings(:smtp) do + [port: Application.get_env(:elektrine, :smtp_port, 2587), security: :starttls] + end + + defp env_host(:imap), do: present_env("IMAP_HOST") + defp env_host(:pop3), do: present_env("POP_HOST") || present_env("POP3_HOST") + defp env_host(:smtp), do: present_env("SMTP_HOST") + + defp present_env(name) do + case System.get_env(name) do + value when is_binary(value) -> + value = String.trim(value) + if value == "", do: nil, else: value + + _ -> + nil + end + end + + defp normalize_security(:ssl), do: :ssl + defp normalize_security(:starttls), do: :starttls + defp normalize_security(:plain), do: :plain + + defp normalize_security(value) when is_binary(value) do + case value |> String.trim() |> String.downcase() do + "ssl" -> :ssl + "tls" -> :ssl + "starttls" -> :starttls + _ -> :plain + end + end + + defp normalize_security(_value), do: :plain +end diff --git a/apps/elektrine/lib/elektrine/mailer.ex b/apps/elektrine/lib/elektrine/mailer.ex new file mode 100644 index 0000000..6339f98 --- /dev/null +++ b/apps/elektrine/lib/elektrine/mailer.ex @@ -0,0 +1,18 @@ +defmodule Elektrine.Mailer do + use Swoosh.Mailer, otp_app: :elektrine + + @doc """ + Queues the email for delivery on the `:email` Oban queue so transient + mail-server failures are retried instead of dropped. + + Returns `{:ok, %Oban.Job{}}` once the email is durably queued. Attachments + are not supported on this path; use `deliver/1` for emails that carry them. + """ + def deliver_later(%Swoosh.Email{attachments: [_ | _]}) do + raise ArgumentError, "deliver_later/1 does not support attachments" + end + + def deliver_later(%Swoosh.Email{} = email) do + Elektrine.Mailer.DeliveryWorker.enqueue(email) + end +end diff --git a/apps/elektrine/lib/elektrine/mailer/delivery_worker.ex b/apps/elektrine/lib/elektrine/mailer/delivery_worker.ex new file mode 100644 index 0000000..a41c220 --- /dev/null +++ b/apps/elektrine/lib/elektrine/mailer/delivery_worker.ex @@ -0,0 +1,56 @@ +defmodule Elektrine.Mailer.DeliveryWorker do + @moduledoc """ + Delivers transactional Swoosh emails through Oban so transient mail-server + failures are retried instead of dropped. + """ + + use Oban.Worker, queue: :email, max_attempts: 5 + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"email" => email_args}}) do + case email_args |> to_email() |> Elektrine.Mailer.deliver() do + {:ok, _metadata} -> :ok + {:error, reason} -> {:error, reason} + end + end + + def enqueue(%Swoosh.Email{} = email) do + %{"email" => to_args(email)} + |> new() + |> Elektrine.JobQueue.insert() + end + + defp to_args(%Swoosh.Email{} = email) do + %{ + "from" => address_to_args(email.from), + "to" => Enum.map(email.to || [], &address_to_args/1), + "cc" => Enum.map(email.cc || [], &address_to_args/1), + "bcc" => Enum.map(email.bcc || [], &address_to_args/1), + "reply_to" => address_to_args(email.reply_to), + "subject" => email.subject, + "text_body" => email.text_body, + "html_body" => email.html_body, + "headers" => email.headers || %{} + } + end + + defp to_email(args) do + %Swoosh.Email{ + from: args_to_address(args["from"]), + to: Enum.map(args["to"] || [], &args_to_address/1), + cc: Enum.map(args["cc"] || [], &args_to_address/1), + bcc: Enum.map(args["bcc"] || [], &args_to_address/1), + reply_to: args_to_address(args["reply_to"]), + subject: args["subject"], + text_body: args["text_body"], + html_body: args["html_body"], + headers: args["headers"] || %{} + } + end + + defp address_to_args(nil), do: nil + defp address_to_args({name, address}), do: [name, address] + + defp args_to_address(nil), do: nil + defp args_to_address([name, address]), do: {name, address} +end diff --git a/apps/elektrine/lib/elektrine/markdown.ex b/apps/elektrine/lib/elektrine/markdown.ex new file mode 100644 index 0000000..aea1bd9 --- /dev/null +++ b/apps/elektrine/lib/elektrine/markdown.ex @@ -0,0 +1,97 @@ +defmodule Elektrine.Markdown do + @moduledoc """ + Safe markdown processing for profile descriptions. + Converts markdown to HTML while sanitizing dangerous content. + """ + + @doc """ + Converts markdown text to safe HTML. + Strips all images and dangerous content. + """ + def to_html(markdown_text) when is_binary(markdown_text) do + markdown_text + |> MDEx.to_html!() + |> HtmlSanitizeEx.markdown_html() + |> strip_images() + end + + def to_html(nil), do: "" + + @doc """ + Strips markdown formatting and returns plain text. + Useful for previews or when HTML isn't supported. + """ + def to_text(markdown_text) when is_binary(markdown_text) do + markdown_text + # Bold + |> String.replace(~r/\*\*(.*?)\*\*/, "\\1") + # Italic + |> String.replace(~r/\*(.*?)\*/, "\\1") + # Code + |> String.replace(~r/`(.*?)`/, "\\1") + # Links + |> String.replace(~r/\[(.*?)\]\(.*?\)/, "\\1") + # Headers + |> String.replace(~r/#+\s*/, "") + |> String.trim() + end + + def to_text(nil), do: "" + + @doc """ + Validates markdown content length and complexity for user-facing feedback. + + SECURITY: This is a UX-only check, NOT a security boundary. Its substring + blacklist (` 1000 -> + {:error, "Markdown content too long (max 1000 characters)"} + + String.contains?(markdown_text, [" + {:error, "Dangerous content detected"} + + contains_images?(markdown_text) -> + {:error, "Images are not allowed in bio"} + + String.contains?(markdown_text, [" + {:error, "HTML embeds are not allowed in bio"} + + count_links(markdown_text) > 10 -> + {:error, "Too many links (max 10)"} + + true -> + {:ok, markdown_text} + end + end + + def validate(nil), do: {:ok, nil} + + # Count markdown links + defp count_links(text) do + ~r/\[.*?\]\(.*?\)/ + |> Regex.scan(text) + |> length() + end + + # Check for markdown image syntax: ![alt](url) + defp contains_images?(text) do + Regex.match?(~r/!\[.*?\]\(.*?\)/, text) + end + + # Strip all img tags and iframes from HTML + defp strip_images(html) do + html + |> String.replace(~r/]*>/i, "") + |> String.replace(~r/]*>.*?<\/iframe>/is, "") + |> String.replace(~r/]*>.*?<\/video>/is, "") + |> String.replace(~r/]*>.*?<\/audio>/is, "") + |> String.replace(~r/]*>/i, "") + |> String.replace(~r/]*>.*?<\/object>/is, "") + end +end diff --git a/apps/elektrine/lib/elektrine/markers.ex b/apps/elektrine/lib/elektrine/markers.ex new file mode 100644 index 0000000..6f44108 --- /dev/null +++ b/apps/elektrine/lib/elektrine/markers.ex @@ -0,0 +1,141 @@ +defmodule Elektrine.Markers do + @moduledoc """ + Stores durable client read positions for timelines and notifications. + """ + + import Ecto.Query + + alias Elektrine.Markers.Marker + alias Elektrine.Notifications.Notification + alias Elektrine.Repo + + @type marker_attrs :: %{required(String.t()) => map()} | %{required(atom()) => map()} + + @doc """ + Returns markers for the requested timelines. + """ + def list_markers(user_id, timelines) do + timelines = normalize_timelines(timelines) + + from(marker in Marker, + where: marker.user_id == ^user_id and marker.timeline in ^timelines + ) + |> Repo.all() + |> Enum.map(&with_unread_count/1) + |> Map.new(&{&1.timeline, &1}) + end + + @doc """ + Upserts markers from a Mastodon/Pleroma-style payload. + + Expected shape: + + %{"home" => %{"last_read_id" => "123"}} + """ + @spec upsert_markers(pos_integer(), marker_attrs()) :: + {:ok, %{String.t() => Marker.t()}} | {:error, Ecto.Changeset.t()} + def upsert_markers(user_id, attrs) when is_map(attrs) do + entries = normalize_marker_attrs(attrs) + + Repo.transaction(fn -> + Enum.reduce_while(entries, %{}, fn {timeline, marker_attrs}, acc -> + case upsert_marker(user_id, timeline, marker_attrs) do + {:ok, marker} -> + marker = with_unread_count(marker) + {:cont, Map.put(acc, marker.timeline, marker)} + + {:error, changeset} -> + Repo.rollback(changeset) + end + end) + end) + end + + def format_marker(marker) do + %{ + last_read_id: marker.last_read_id, + unread_count: marker.unread_count || 0, + version: marker.version, + updated_at: marker.updated_at + } + end + + defp with_unread_count(%Marker{timeline: "notifications", user_id: user_id} = marker) + when is_integer(user_id) do + %{marker | unread_count: notification_unread_count(user_id)} + end + + defp with_unread_count(%Marker{} = marker), do: %{marker | unread_count: 0} + + defp notification_unread_count(user_id) do + count = + from(n in Notification, + where: n.user_id == ^user_id and is_nil(n.read_at) and is_nil(n.dismissed_at), + select: count(n.id) + ) + |> Repo.one() + + count || 0 + end + + defp upsert_marker(user_id, timeline, attrs) do + attrs = + attrs + |> Map.put(:user_id, user_id) + |> Map.put(:timeline, timeline) + + case Repo.get_by(Marker, user_id: user_id, timeline: timeline) do + nil -> + %Marker{} + |> Marker.changeset(Map.put(attrs, :version, 0)) + |> Repo.insert() + + marker -> + marker + |> Marker.changeset(Map.put(attrs, :version, marker.version + 1)) + |> Repo.update() + end + end + + defp normalize_timelines(nil), do: [] + defp normalize_timelines(timeline) when is_binary(timeline), do: [timeline] + + defp normalize_timelines(timelines) when is_list(timelines) do + timelines + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + defp normalize_timelines(_), do: [] + + defp normalize_marker_attrs(attrs) do + attrs + |> Enum.flat_map(fn {timeline, value} -> + with true <- valid_timeline_key?(timeline), + %{} = marker_attrs <- normalize_marker_value(value), + last_read_id when is_binary(last_read_id) <- marker_attrs[:last_read_id] do + [{to_string(timeline), %{last_read_id: last_read_id}}] + else + _ -> [] + end + end) + end + + defp valid_timeline_key?(key) when is_binary(key), do: true + defp valid_timeline_key?(key) when is_atom(key), do: true + defp valid_timeline_key?(_), do: false + + defp normalize_marker_value(value) when is_map(value) do + last_read_id = value[:last_read_id] || value["last_read_id"] + + if is_binary(last_read_id) do + %{last_read_id: last_read_id} + else + nil + end + end + + defp normalize_marker_value(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/markers/marker.ex b/apps/elektrine/lib/elektrine/markers/marker.ex new file mode 100644 index 0000000..0a3198a --- /dev/null +++ b/apps/elektrine/lib/elektrine/markers/marker.ex @@ -0,0 +1,33 @@ +defmodule Elektrine.Markers.Marker do + @moduledoc """ + Persistent Mastodon/Pleroma-compatible read marker for a user timeline. + """ + use Ecto.Schema + + import Ecto.Changeset + + @timeline_format ~r/\A[a-zA-Z0-9_.:-]{1,64}\z/ + + schema "api_markers" do + field :timeline, :string + field :last_read_id, :string + field :version, :integer, default: 0 + field :unread_count, :integer, virtual: true, default: 0 + + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + def changeset(marker, attrs) do + marker + |> cast(attrs, [:user_id, :timeline, :last_read_id, :version]) + |> update_change(:timeline, &String.trim/1) + |> update_change(:last_read_id, &String.trim/1) + |> validate_required([:user_id, :timeline, :last_read_id]) + |> validate_format(:timeline, @timeline_format) + |> validate_length(:last_read_id, min: 1, max: 255) + |> validate_number(:version, greater_than_or_equal_to: 0) + |> unique_constraint([:user_id, :timeline]) + end +end diff --git a/apps/elektrine/lib/elektrine/media_proxy.ex b/apps/elektrine/lib/elektrine/media_proxy.ex new file mode 100644 index 0000000..ee269b1 --- /dev/null +++ b/apps/elektrine/lib/elektrine/media_proxy.ex @@ -0,0 +1,365 @@ +defmodule Elektrine.MediaProxy do + @moduledoc """ + Media proxy for federated content. + + Proxies remote media through the local instance to: + - Protect user privacy (hides IPs from remote servers) + - Enable caching of remote media + - Provide consistent availability + - Allow content filtering/blocking + + ## Configuration + + config :elektrine, :media_proxy, + enabled: true, + base_url: "https://media.example.com", # Optional custom domain + whitelist: ["cdn.example.com"], # Skip proxy for these domains + blocklist: ["evil.com"] # Block these domains entirely + + ## URL Format + + Proxied URLs have the format: + /media_proxy/{signature}/{base64_encoded_url} + + The signature prevents URL enumeration attacks. + """ + + alias Elektrine.Security.URLValidator + + @doc """ + Returns whether the media proxy is enabled. + """ + def enabled? do + Application.get_env(:elektrine, :media_proxy, [])[:enabled] || false + end + + @doc """ + Converts a remote media URL to a proxied URL. + Returns the original URL if proxy is disabled or URL is whitelisted. + """ + def url(nil), do: nil + def url(""), do: nil + + def url(url) when is_binary(url) do + if should_proxy?(url) do + encode_url(url) + else + url + end + end + + @doc """ + Returns a signed local proxy URL for remote media regardless of the global + proxy preference. + + This is intended for privacy-sensitive surfaces such as search thumbnails, + where the browser must never contact the result host directly. Obvious + private targets are rejected synchronously; the proxy controller performs + full DNS and redirect validation again before fetching. + """ + def signed_url(nil), do: nil + def signed_url(""), do: nil + + def signed_url(url) when is_binary(url) do + url = String.trim(url) + + case URI.parse(url) do + %URI{scheme: scheme, host: host, userinfo: userinfo} + when scheme in ["http", "https"] and is_binary(host) and host != "" and + userinfo in [nil, ""] -> + cond do + String.match?(url, ~r/[\x00-\x20\x7F]/u) -> nil + URLValidator.private_ip?(host) -> nil + URLValidator.is_private_domain?(host) -> nil + blocklisted?(url) -> nil + true -> encode_url(url) + end + + _uri -> + nil + end + end + + def signed_url(_url), do: nil + + @doc """ + Converts a list of URLs to proxied URLs. + """ + def urls(urls) when is_list(urls) do + Enum.map(urls, &url/1) + end + + @doc """ + Decodes a proxied URL back to the original URL. + Returns {:ok, url} or {:error, reason}. + """ + def decode_url(encoded) do + with [signature, encoded_url] <- String.split(encoded, "/", parts: 2), + {:ok, url} <- Base.url_decode64(encoded_url, padding: false), + true <- verify_signature(url, signature), + :ok <- URLValidator.validate(url) do + {:ok, url} + else + _ -> {:error, :invalid_url} + end + end + + @doc """ + Checks if a URL should be proxied. + """ + def should_proxy?(url) when is_binary(url) do + cond do + !enabled?() -> + false + + URLValidator.validate(url) != :ok -> + false + + local_url?(url) -> + false + + whitelisted?(url) -> + false + + blocklisted?(url) -> + # Don't proxy blocked URLs, they'll return 404. + false + + true -> + true + end + end + + @doc """ + Checks if a URL is blocklisted and should return 404. + """ + def blocklisted?(url) when is_binary(url) do + blocklist = get_config()[:blocklist] || [] + + runtime_banned?(url) or + case URI.parse(url) do + %URI{host: host} when is_binary(host) -> + Enum.any?(blocklist, fn pattern -> + matches_domain?(host, pattern) + end) + + _ -> + false + end + end + + @doc """ + Returns true when a remote media URL recently failed proxy fetching. + """ + def failed?(url) when is_binary(url), do: Elektrine.AppCache.media_proxy_failed?(url) + def failed?(_url), do: false + + @doc """ + Temporarily suppresses a remote media URL after upstream failures. + """ + def mark_failed(url, reason) when is_binary(url), + do: Elektrine.AppCache.mark_media_proxy_failed(url, reason) + + def mark_failed(_url, _reason), do: {:ok, false} + + @doc """ + Invalidates cached proxy failure state for a URL. + """ + def invalidate(url) when is_binary(url), + do: Elektrine.AppCache.invalidate_media_proxy_failure(url) + + def invalidate(_url), do: {:ok, false} + + @doc """ + Returns true when a remote media URL has a runtime admin ban. + """ + def runtime_banned?(url) when is_binary(url), do: Elektrine.AppCache.media_proxy_banned?(url) + def runtime_banned?(_url), do: false + + @doc """ + Adds a runtime admin ban for a remote media URL. + """ + def ban(url, reason \\ :admin) + + def ban(url, reason) when is_binary(url) do + if admin_url_valid?(url) do + Elektrine.AppCache.ban_media_proxy_url(url, reason) + else + {:error, :invalid_url} + end + end + + def ban(_url, _reason), do: {:error, :invalid_url} + + @doc """ + Removes a runtime admin ban for a remote media URL. + """ + def unban(url) when is_binary(url), do: Elektrine.AppCache.unban_media_proxy_url(url) + def unban(_url), do: {:error, :invalid_url} + + @doc """ + Clears media proxy failure state and optionally bans the URLs. + """ + def purge(urls, opts \\ []) + + def purge(urls, opts) when is_list(urls) do + ban? = Keyword.get(opts, :ban, false) + + urls + |> Enum.map(&purge_one(&1, ban?)) + |> Enum.reduce(%{invalidated: [], banned: [], rejected: []}, fn + {:invalidated, url}, acc -> + update_in(acc.invalidated, &[url | &1]) + + {:banned, url}, acc -> + acc |> update_in([:invalidated], &[url | &1]) |> update_in([:banned], &[url | &1]) + + {:rejected, url}, acc -> + update_in(acc.rejected, &[url | &1]) + end) + |> Map.update!(:invalidated, &Enum.reverse/1) + |> Map.update!(:banned, &Enum.reverse/1) + |> Map.update!(:rejected, &Enum.reverse/1) + end + + def purge(url, opts) when is_binary(url), do: purge([url], opts) + def purge(_urls, _opts), do: %{invalidated: [], banned: [], rejected: []} + + @doc """ + Lists recent failed media proxy URLs and runtime bans. + """ + def cache_state(limit \\ 100) do + %{ + failures: Elektrine.AppCache.list_media_proxy_failures(limit), + bans: Elektrine.AppCache.list_media_proxy_bans(limit) + } + end + + @doc """ + Returns the proxy base URL. + """ + def base_url do + config = get_config() + + config[:base_url] || + ElektrineWeb.Endpoint.url() <> "/media_proxy" + end + + # Private functions + + defp encode_url(url) do + encoded = Base.url_encode64(url, padding: false) + signature = sign_url(url) + + "#{base_url()}/#{signature}/#{encoded}" + end + + defp purge_one(url, ban?) when is_binary(url) do + if admin_url_valid?(url) do + invalidate(url) + + if ban? do + ban(url, :admin_purge) + {:banned, url} + else + {:invalidated, url} + end + else + {:rejected, url} + end + end + + defp purge_one(url, _ban?), do: {:rejected, url} + + defp admin_url_valid?(url) when is_binary(url) do + case URI.parse(url) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and byte_size(host) > 0 -> + not URLValidator.private_ip?(host) and not URLValidator.is_private_domain?(host) + + _ -> + false + end + end + + defp sign_url(url) do + secret = get_signing_secret() + + :crypto.mac(:hmac, :sha256, secret, url) + |> Base.url_encode64(padding: false) + # Use first 16 chars for shorter URLs + |> String.slice(0, 16) + end + + defp verify_signature(url, signature) do + expected = sign_url(url) + secure_compare(signature, expected) + end + + defp secure_compare(left, right) + when is_binary(left) and is_binary(right) and byte_size(left) == byte_size(right) do + Plug.Crypto.secure_compare(left, right) + end + + defp secure_compare(_left, _right), do: false + + defp get_signing_secret do + # Use Phoenix secret key base + Application.get_env(:elektrine, ElektrineWeb.Endpoint)[:secret_key_base] + # Derive a separate key for media proxy + |> :erlang.md5() + end + + defp local_url?(url) do + case URI.parse(url) do + %URI{host: host} when is_binary(host) -> + local_hosts = [ + URI.parse(ElektrineWeb.Endpoint.url()).host, + "localhost", + "127.0.0.1" + ] + + host in local_hosts + + _ -> + false + end + end + + defp whitelisted?(url) do + whitelist = get_config()[:whitelist] || [] + + case URI.parse(url) do + %URI{host: host} when is_binary(host) -> + Enum.any?(whitelist, fn pattern -> + matches_domain?(host, pattern) + end) + + _ -> + false + end + end + + defp matches_domain?(host, pattern) when is_binary(host) and is_binary(pattern) do + host = String.downcase(host) + pattern = String.downcase(pattern) + + cond do + # Exact match + host == pattern -> + true + + # Wildcard: *.example.com + String.starts_with?(pattern, "*.") -> + suffix = String.replace_prefix(pattern, "*", "") + String.ends_with?(host, suffix) + + true -> + false + end + end + + defp get_config do + Application.get_env(:elektrine, :media_proxy, []) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging.ex b/apps/elektrine/lib/elektrine/messaging.ex new file mode 100644 index 0000000..7c21ca0 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging.ex @@ -0,0 +1,1287 @@ +defmodule Elektrine.Messaging.Router do + @moduledoc false + + # Compile-time helper for `Elektrine.Messaging`. See `defrouted/3`. + + @doc """ + Generates a routing function that dispatches by conversation type on its + first argument: chat conversations go to `chat_mod`, social conversations to + `social_mod`. Both modules must expose the same function name/arity, and the + importing module must define a private `chat_conversation_type?/1` predicate. + + defrouted delete_conversation(conversation_id), ChatConversations, Conversations + + is equivalent to writing the explicit `if chat_conversation_type?(...)` body. + Put any `@doc` immediately before the macro call as you would for a normal def. + """ + defmacro defrouted(call, chat_mod, social_mod) do + {name, args} = Macro.decompose_call(call) + first_arg = hd(args) + + quote do + def unquote(name)(unquote_splicing(args)) do + if chat_conversation_type?(unquote(first_arg)) do + unquote(chat_mod).unquote(name)(unquote_splicing(args)) + else + unquote(social_mod).unquote(name)(unquote_splicing(args)) + end + end + end + end +end + +defmodule Elektrine.Messaging do + @moduledoc """ + The Messaging context - handles conversations, messages, and real-time communication. + + This module serves as the main entry point and delegates to specialized sub-contexts: + - `Elektrine.Social.Conversations` - Social conversation management + - `Elektrine.Social.Messages` - Social message operations + - `Elektrine.Messaging.Reactions` - Reaction handling + - `Elektrine.Messaging.Moderation` - Moderation features + """ + + import Ecto.Query, warn: false + alias Elektrine.Accounts.User + alias Elektrine.Repo + + # Delegate to sub-contexts + alias Elektrine.Messaging.{ + ChannelCategories, + ChatConversation, + ChatConversations, + ChatMessage, + ChatMessagePins, + ChatMessages, + ChatThreads, + ChatWebhooks + } + + alias Elektrine.Social.Message + + alias Elektrine.Messaging.Federation + alias Elektrine.Messaging.Moderation + alias Elektrine.Messaging.Reactions + alias Elektrine.Messaging.Servers + alias Elektrine.Social.Conversations + alias Elektrine.Social.Messages + + # Routes uniform "dispatch by conversation type" functions to the chat or + # social context. See `Elektrine.Messaging.Router.defrouted/3`. + import Elektrine.Messaging.Router + + ## Conversations - Delegate to Conversations context + + @doc """ + Returns the list of conversations for a user. + """ + defdelegate list_conversations(user_id, opts \\ []), to: Conversations + + defdelegate list_chat_conversations(user_id, opts \\ []), + to: ChatConversations, + as: :list_conversations + + @doc """ + Gets a single conversation with members and recent messages. + """ + defrouted(get_conversation!(id, user_id), ChatConversations, Conversations) + + defdelegate get_chat_conversation!(id, user_id), to: ChatConversations, as: :get_conversation! + + @doc """ + Gets a conversation for chat display without preloading messages. + Lightweight version optimized for chat view where messages are loaded separately. + """ + defdelegate get_conversation_for_chat!(id, user_id), to: ChatConversations + + @doc """ + Gets a conversation by its hash. + """ + defdelegate get_conversation_by_hash(hash), to: ChatConversations + + @doc """ + Gets a conversation for chat display by hash, ensuring user membership. + """ + defdelegate get_conversation_for_chat_by_hash!(hash, user_id), to: ChatConversations + + @doc """ + Gets a conversation by its name (for communities). + """ + defdelegate get_conversation_by_name(name), to: Conversations + + @doc """ + Gets moderators and admins for a community. + """ + defdelegate get_community_moderators(community_id), to: Conversations + + @doc """ + Creates a direct message conversation between two users. + """ + defdelegate create_dm_conversation(user1_id, user2_id), to: ChatConversations + + @doc """ + Creates or gets a direct message conversation with a remote `user@domain` handle. + """ + defdelegate create_remote_dm_conversation(local_user_id, remote_handle, attrs \\ %{}), + to: ChatConversations + + @doc """ + Returns true when a conversation is a federated remote DM. + """ + defdelegate remote_dm_conversation?(conversation), to: ChatConversations + + @doc """ + Returns normalized remote DM handle for a federated DM conversation. + """ + defdelegate remote_dm_handle(conversation), to: ChatConversations + + @doc """ + Checks if user can create more conversations of the given type. + """ + defdelegate check_creation_limit(user_id, type), to: ChatConversations + + @doc """ + Creates a group conversation. + """ + defdelegate create_group_conversation(creator_id, attrs, member_ids \\ []), to: Conversations + + @doc """ + Creates a chat group conversation. + """ + defdelegate create_chat_group_conversation(creator_id, attrs, member_ids \\ []), + to: ChatConversations, + as: :create_group_conversation + + @doc """ + Creates a channel. + """ + defdelegate create_channel(creator_id, attrs), to: ChatConversations + + @doc """ + Updates a conversation (name, description, etc.). + """ + def update_conversation(%ChatConversation{} = conversation, attrs), + do: ChatConversations.update_conversation(conversation, attrs) + + def update_conversation(conversation, attrs), + do: Conversations.update_conversation(conversation, attrs) + + @doc """ + Deletes a conversation (admin/creator only). + """ + defrouted(delete_conversation(conversation_id), ChatConversations, Conversations) + + @doc """ + Lists public channels. + """ + defdelegate list_public_channels(opts \\ []), to: ChatConversations + + @doc """ + Lists public groups. + """ + defdelegate list_public_groups(opts \\ []), to: Conversations + defdelegate list_chat_public_groups(opts \\ []), to: ChatConversations, as: :list_public_groups + + @doc """ + Searches for public groups and channels that the user can join. + """ + defdelegate search_public_conversations(query, current_user_id, limit \\ 10), to: Conversations + + defdelegate search_public_chat_conversations(query, current_user_id, limit \\ 10), + to: ChatConversations, + as: :search_public_conversations + + @doc """ + Adds a member to a conversation. + """ + def add_member_to_conversation( + conversation_id, + user_id, + role \\ "member", + added_by_user_id \\ nil + ) do + if chat_conversation_type?(conversation_id) do + ChatConversations.add_member_to_conversation( + conversation_id, + user_id, + role, + added_by_user_id + ) + else + Conversations.add_member_to_conversation(conversation_id, user_id, role, added_by_user_id) + end + end + + @doc """ + Removes a member from a conversation. + """ + defrouted( + remove_member_from_conversation(conversation_id, user_id), + ChatConversations, + Conversations + ) + + @doc """ + Removes a member from a conversation on behalf of `actor_user_id`. + + Routes to the correct context by conversation type so callers need not name a + concrete module. Both contexts enforce actor-role authorization when an actor + is supplied (manager-or-self), and skip the check when the actor is nil. + """ + defrouted( + remove_member_from_conversation(conversation_id, user_id, actor_user_id), + ChatConversations, + Conversations + ) + + @doc """ + Gets a conversation member record. + """ + defrouted(get_conversation_member(conversation_id, user_id), ChatConversations, Conversations) + + @doc """ + Gets all members of a conversation. + """ + defrouted(get_conversation_members(conversation_id), ChatConversations, Conversations) + + @doc """ + Lists pending remote join requests for a locally authoritative channel. + """ + defrouted(list_pending_remote_join_requests(conversation_id), ChatConversations, Conversations) + + @doc """ + Approves a pending remote join request. + """ + defrouted( + approve_remote_join_request(conversation_id, remote_actor_id, reviewer_user_id), + ChatConversations, + Conversations + ) + + @doc """ + Declines a pending remote join request. + """ + defrouted( + decline_remote_join_request(conversation_id, remote_actor_id, reviewer_user_id), + ChatConversations, + Conversations + ) + + @doc """ + Promotes a member to admin role. + """ + defrouted( + promote_to_admin(conversation_id, user_id, promoter_id), + ChatConversations, + Conversations + ) + + @doc """ + Demotes an admin to regular member. + """ + defrouted( + demote_from_admin(conversation_id, user_id, demoter_id), + ChatConversations, + Conversations + ) + + @doc """ + Updates a member's role in a conversation. + """ + defrouted( + update_member_role(conversation_id, user_id, new_role), + ChatConversations, + Conversations + ) + + defrouted( + update_member_role(conversation_id, user_id, new_role, actor_user_id), + ChatConversations, + Conversations + ) + + @doc """ + Promotes a user to moderator. Pass `actor_user_id` to enforce owner/admin authz. + """ + defdelegate promote_to_moderator(conversation_id, user_id, actor_user_id \\ nil), + to: Conversations + + @doc """ + Demotes a moderator to member. Pass `actor_user_id` to enforce owner/admin authz. + """ + defdelegate demote_from_moderator(conversation_id, user_id, actor_user_id \\ nil), + to: Conversations + + @doc """ + Joins a public conversation (channel or group). + """ + defrouted(join_conversation(conversation_id, user_id), ChatConversations, Conversations) + + @doc """ + Joins a public channel. + """ + defdelegate join_channel(channel_id, user_id), to: ChatConversations + + @doc """ + Pins a conversation for a user. + """ + defrouted(pin_conversation(conversation_id, user_id), ChatConversations, Conversations) + + @doc """ + Unpins a conversation for a user. + """ + defrouted(unpin_conversation(conversation_id, user_id), ChatConversations, Conversations) + + @doc """ + Allows a user to leave a conversation. + """ + defrouted(leave_conversation(conversation_id, user_id), ChatConversations, Conversations) + + @doc """ + Checks if a user is the owner of a community. + """ + defdelegate community_owner?(conversation_id, user_id), to: Conversations + + @doc """ + Checks if a user has any community memberships (for loading skeleton optimization). + """ + defdelegate user_has_communities?(user_id), to: Conversations + + @doc """ + Checks if there are any communities in the system (for loading skeleton optimization). + """ + defdelegate has_any_communities?(), to: Conversations + + @doc """ + Checks if a user has any chat conversations (for loading skeleton optimization). + """ + defdelegate user_has_conversations?(user_id), to: ChatConversations + + ## Servers - Delegate to Servers context + + @doc """ + Lists servers where the user is an active member. + """ + defdelegate list_servers(user_id, opts \\ []), to: Servers + + @doc """ + Lists federated remote presence states for a server. + """ + defdelegate list_server_presence_states(server_id), to: Federation + + @doc """ + Lists federated remote presence states visible to a specific local user in a server. + """ + defdelegate list_visible_server_presence_states(server_id, user_id), to: Federation + defdelegate list_room_presence_states(conversation_id), to: Federation + defdelegate list_visible_room_presence_states(conversation_id, user_id), to: Federation + + @doc """ + Lists public servers the user can discover and join. + """ + defdelegate list_public_servers(user_id, opts \\ []), to: Servers + + @doc """ + Lists local public servers for federation directory export. + """ + defdelegate list_public_directory_servers(opts \\ []), to: Servers + + @doc """ + Gets a server with channels for a user. + """ + defdelegate get_server(server_id, user_id), to: Servers + + @doc """ + Gets a server membership record. + """ + defdelegate get_server_member(server_id, user_id), to: Servers + + @doc """ + Creates a new server with a default channel. + """ + defdelegate create_server(creator_id, attrs), to: Servers + + @doc """ + Creates a new channel in a server. + """ + defdelegate create_server_channel(server_id, creator_id, attrs), to: Servers + + @doc """ + Joins a public server. + """ + defdelegate join_server(server_id, user_id), to: Servers + + ## Channel categories - Delegate to ChannelCategories context + + @doc """ + Lists a server's channel categories ordered by position. + """ + defdelegate list_channel_categories(server_id), to: ChannelCategories, as: :list_categories + + @doc """ + Creates a channel category in a server (requires `manage_channels`). + """ + defdelegate create_channel_category(server_id, user_id, attrs), + to: ChannelCategories, + as: :create_category + + @doc """ + Moves a channel into a category, or out of any category when `category_id` + is `nil` (requires `manage_channels`). + """ + defdelegate assign_channel_to_category(channel_id, user_id, category_id), + to: ChannelCategories + + ## Messages - Delegate to Messages context + + @doc """ + Creates a text message in a conversation. + """ + def create_text_message(conversation_id, sender_id, content, reply_to_id \\ nil, opts \\ []) do + if chat_conversation_type?(conversation_id) do + chat_opts = + if is_nil(reply_to_id), + do: opts, + else: Keyword.put(opts, :reply_to_id, reply_to_id) + + ChatMessages.create_text_message(conversation_id, sender_id, content, chat_opts) + else + Messages.create_text_message(conversation_id, sender_id, content, reply_to_id, opts) + end + end + + @doc """ + Creates a media message in a conversation. + """ + def create_media_message( + conversation_id, + sender_id, + media_urls, + content \\ nil, + media_metadata \\ %{} + ) do + if chat_conversation_type?(conversation_id) do + ChatMessages.create_media_message( + conversation_id, + sender_id, + media_urls, + content, + media_metadata + ) + else + Messages.create_media_message( + conversation_id, + sender_id, + media_urls, + content, + media_metadata + ) + end + end + + @doc """ + Creates a voice message in a conversation. + """ + defrouted( + create_voice_message(conversation_id, sender_id, audio_url, duration, mime_type), + ChatMessages, + Messages + ) + + @doc """ + Creates a system message in a conversation. + """ + def create_system_message(conversation_id, content, metadata \\ %{}) do + if chat_conversation_type?(conversation_id) do + ChatMessages.create_system_message(conversation_id, content) + else + Messages.create_system_message(conversation_id, content, metadata) + end + end + + @doc """ + Edits a message. + """ + def edit_message(message_id, user_id, new_content) when is_integer(message_id) do + if Repo.get(ChatMessage, message_id) do + ChatMessages.edit_message(message_id, user_id, new_content) + else + Messages.edit_message(message_id, user_id, new_content) + end + end + + @doc """ + Deletes a message. + """ + def delete_message(message_id, user_id, is_admin \\ false) when is_integer(message_id) do + if Repo.get(ChatMessage, message_id) do + ChatMessages.delete_message(message_id, user_id, is_admin) + else + Messages.delete_message(message_id, user_id, is_admin) + end + end + + @doc """ + Admin deletes a message (bypasses ownership check). + """ + def admin_delete_message(message_id, %User{} = admin_user) when is_integer(message_id) do + cond do + not admin_user.is_admin -> + {:error, :unauthorized} + + chat_message = Repo.get(ChatMessage, message_id) -> + if chat_message.deleted_at do + {:error, :already_deleted} + else + ChatMessages.delete_message(message_id, admin_user.id, true) + end + + message = Repo.get(Message, message_id) -> + if message.deleted_at do + {:error, :already_deleted} + else + Messages.delete_message(message_id, admin_user.id, true) + end + + true -> + {:error, :not_found} + end + end + + @doc """ + Get messages for a conversation with pagination support. + """ + def get_conversation_messages(conversation_id, user_id, opts \\ []) do + if chat_conversation_type?(conversation_id) do + ChatMessages.get_conversation_messages(conversation_id, user_id, opts) + else + Messages.get_conversation_messages(conversation_id, user_id, opts) + end + end + + @doc """ + Gets messages for a conversation with pagination. + """ + def get_messages(conversation_id, user_id, opts \\ []) do + case get_conversation_member(conversation_id, user_id) do + nil -> + {:error, :unauthorized} + + _member -> + if chat_conversation_type?(conversation_id) do + messages = + ChatMessages.get_messages(conversation_id, Keyword.put(opts, :user_id, user_id)) + + {:ok, messages} + else + Messages.get_messages(conversation_id, user_id, opts) + end + end + end + + @doc """ + Searches messages within a specific conversation. + """ + def search_messages_in_conversation(conversation_id, user_id, query, opts \\ []) do + case get_conversation_member(conversation_id, user_id) do + nil -> + {:error, :unauthorized} + + _member -> + if chat_conversation_type?(conversation_id) do + {:ok, + ChatMessages.search_messages( + conversation_id, + query, + Keyword.put(opts, :user_id, user_id) + )} + else + Messages.search_messages_in_conversation(conversation_id, user_id, query, opts) + end + end + end + + @doc """ + Marks messages as read for a user in a conversation. + """ + def mark_as_read(conversation_id, user_id) do + if chat_conversation_type?(conversation_id) do + latest_message_id = + ChatMessages.get_messages(conversation_id, limit: 1, user_id: user_id) + |> case do + [%{id: id} | _] -> id + _ -> nil + end + + case ChatMessages.update_last_read_message(conversation_id, user_id, latest_message_id) do + {:ok, _} -> {:ok, :read} + error -> error + end + else + Messages.mark_as_read(conversation_id, user_id) + end + end + + @doc """ + Updates the last read message for a user in a conversation. + """ + defrouted( + update_last_read_message(conversation_id, user_id, message_id), + ChatMessages, + Messages + ) + + @doc """ + Gets the last read message ID for a user in a conversation. + """ + defrouted(get_last_read_message_id(conversation_id, user_id), ChatMessages, Messages) + + @doc """ + Clears message history for a specific user. + """ + defrouted(clear_history_for_user(conversation_id, user_id), ChatMessages, Messages) + + @doc """ + Gets users who have read a specific message. + """ + def get_message_readers(message_id, conversation_id) do + if chat_conversation_type?(conversation_id) do + ChatMessages.get_message_readers(message_id, conversation_id) + else + Messages.get_message_readers(message_id, conversation_id) + end + end + + @doc """ + Gets read status for messages in a conversation. + """ + def get_read_status_for_messages(message_ids, conversation_id) do + if chat_conversation_type?(conversation_id) do + ChatMessages.get_read_status_for_messages(message_ids, conversation_id) + else + Messages.get_read_status_for_messages(message_ids, conversation_id) + end + end + + @doc """ + Gets read status for last messages across multiple conversations in a batch. + """ + def get_batch_last_message_read_status(message_info_list) do + if message_info_list == [] do + %{} + else + conversation_ids = + message_info_list |> Enum.map(fn {conversation_id, _, _} -> conversation_id end) + + conversation_type_map = conversation_type_map(conversation_ids) + + {chat_info, non_chat_info} = + Enum.split_with(message_info_list, fn {conversation_id, _message_id, _inserted_at} -> + Map.get(conversation_type_map, conversation_id) in [ + "dm", + "group", + "channel", + "voice_channel" + ] + end) + + chat_result = ChatMessages.get_batch_last_message_read_status(chat_info) + non_chat_result = Messages.get_batch_last_message_read_status(non_chat_info) + + Map.merge(non_chat_result, chat_result) + end + end + + @doc """ + Gets unread message count for a specific conversation and user. + """ + def get_conversation_unread_count(conversation_id, user_id) do + if chat_conversation_type?(conversation_id) do + ChatMessages.get_unread_count(conversation_id, user_id) + else + Messages.get_conversation_unread_count(conversation_id, user_id) + end + end + + @doc """ + Gets unread message counts for multiple conversations in a single query. + Returns a map of conversation_id => unread_count. + """ + def get_conversation_unread_counts(conversation_ids, user_id) do + if conversation_ids == [] do + %{} + else + type_map = conversation_type_map(conversation_ids) + + {chat_ids, non_chat_ids} = + conversation_ids + |> Enum.uniq() + |> Enum.split_with(fn conversation_id -> + Map.get(type_map, conversation_id) in ["dm", "group", "channel", "voice_channel"] + end) + + chat_counts = ChatMessages.get_conversation_unread_counts(chat_ids, user_id) + non_chat_counts = Messages.get_conversation_unread_counts(non_chat_ids, user_id) + + Map.merge(non_chat_counts, chat_counts) + end + end + + @doc """ + Gets unread message count for a user across all conversations. + """ + def get_unread_count(user_id) do + ChatMessages.get_total_unread_count(user_id) + end + + @doc """ + Pins a message in a community (moderators only). + """ + defdelegate pin_message(message_id, user_id), to: Messages + + @doc """ + Unpins a message in a community (moderators only). + """ + defdelegate unpin_message(message_id, user_id), to: Messages + + @doc """ + Lists pinned messages for a conversation. + """ + defdelegate list_pinned_messages(conversation_id), to: Messages + + ## Chat message pins - Delegate to ChatMessagePins context + + @doc """ + Pins a chat message (requires `manage_messages` in the conversation). + """ + defdelegate pin_chat_message(message_id, user_id), to: ChatMessagePins, as: :pin_message + + @doc """ + Unpins a chat message (requires `manage_messages` in the conversation). + """ + defdelegate unpin_chat_message(message_id, user_id), to: ChatMessagePins, as: :unpin_message + + @doc """ + Lists a chat conversation's pinned messages, newest pin first. + """ + defdelegate list_pinned_chat_messages(conversation_id), + to: ChatMessagePins, + as: :list_pinned_messages + + ## Chat threads - Delegate to ChatThreads context + + @doc """ + Creates a thread from a channel message (requires `create_threads`). + """ + defdelegate create_chat_thread_from_message(message_id, user_id, attrs \\ %{}), + to: ChatThreads, + as: :create_thread_from_message + + @doc """ + Creates a standalone thread in a channel (requires `create_threads`). + """ + defdelegate create_chat_thread(conversation_id, user_id, attrs), + to: ChatThreads, + as: :create_thread + + @doc """ + Archives a thread (thread creator or `manage_threads`). + """ + defdelegate archive_chat_thread(thread_id, user_id), to: ChatThreads, as: :archive_thread + + @doc """ + Unarchives a thread (thread creator or `manage_threads`). + """ + defdelegate unarchive_chat_thread(thread_id, user_id), to: ChatThreads, as: :unarchive_thread + + @doc """ + Gets a thread by id. + """ + defdelegate get_chat_thread(thread_id), to: ChatThreads, as: :get_thread + + @doc """ + Lists a conversation's threads (`:active`, `:archived`, or `:all`). + """ + defdelegate list_chat_threads(conversation_id, status \\ :active), + to: ChatThreads, + as: :list_threads + + @doc """ + Sends a text message into a thread. + """ + defdelegate create_chat_thread_message(thread_id, sender_id, content, opts \\ []), + to: ChatThreads, + as: :create_thread_message + + @doc """ + Lists the messages inside a thread, oldest first. + """ + defdelegate list_chat_thread_messages(thread_id, opts \\ []), + to: ChatThreads, + as: :list_thread_messages + + ## Chat webhooks - Delegate to ChatWebhooks context + + @doc """ + Creates an incoming webhook on a channel/group (requires `manage_webhooks`). + """ + defdelegate create_chat_webhook(conversation_id, user_id, attrs \\ %{}), + to: ChatWebhooks, + as: :create_webhook + + @doc """ + Lists a conversation's incoming webhooks (requires `manage_webhooks`). + """ + defdelegate list_chat_webhooks(conversation_id, user_id), + to: ChatWebhooks, + as: :list_webhooks + + @doc """ + Renames an incoming webhook or updates its avatar (requires `manage_webhooks`). + """ + defdelegate update_chat_webhook(webhook_id, user_id, attrs), + to: ChatWebhooks, + as: :update_webhook + + @doc """ + Rotates an incoming webhook's token (requires `manage_webhooks`). + """ + defdelegate rotate_chat_webhook_token(webhook_id, user_id), + to: ChatWebhooks, + as: :rotate_webhook_token + + @doc """ + Deactivates an incoming webhook (requires `manage_webhooks`). + """ + defdelegate deactivate_chat_webhook(webhook_id, user_id), + to: ChatWebhooks, + as: :deactivate_webhook + + @doc """ + Deletes an incoming webhook (requires `manage_webhooks`). + """ + defdelegate delete_chat_webhook(webhook_id, user_id), + to: ChatWebhooks, + as: :delete_webhook + + @doc """ + Executes an incoming webhook (token-authenticated message post). + """ + defdelegate execute_chat_webhook(webhook_id, token, params), + to: ChatWebhooks, + as: :execute_webhook + + @doc """ + Generates a friendly URL path for a discussion post. + """ + defdelegate discussion_post_path(community_name, post_id, title), to: Messages + + @doc """ + Gets a user's discussion posts across all communities. + """ + defdelegate get_user_discussion_posts(user_id, opts \\ []), to: Messages + + ## Chat Messages - Delegate to ChatMessages context + ## (Separate from timeline posts for performance and clarity) + + @doc """ + Gets chat messages for a conversation with pagination. + """ + defdelegate get_chat_messages(conversation_id, opts \\ []), to: ChatMessages, as: :get_messages + + @doc """ + Gets a single chat message by ID. + """ + defdelegate get_chat_message(id), to: ChatMessages, as: :get_message + + @doc """ + Creates a text chat message. + """ + defdelegate create_chat_text_message(conversation_id, sender_id, content, opts \\ []), + to: ChatMessages, + as: :create_text_message + + defdelegate create_client_encrypted_chat_text_message( + conversation_id, + sender_id, + attrs, + opts \\ [] + ), + to: ChatMessages, + as: :create_client_encrypted_text_message + + defdelegate register_chat_encryption_device(user_id, attrs), to: ChatMessages + + defdelegate list_chat_encryption_devices_for_conversation(conversation_id), to: ChatMessages + + defdelegate get_wrapped_chat_key(conversation_id, user_id, device_id, key_uid), to: ChatMessages + + @doc """ + Creates a media chat message. + """ + defdelegate create_chat_media_message( + conversation_id, + sender_id, + media_urls, + content \\ nil, + media_metadata \\ %{} + ), to: ChatMessages, as: :create_media_message + + @doc """ + Creates a voice chat message. + """ + defdelegate create_chat_voice_message( + conversation_id, + sender_id, + audio_url, + duration, + mime_type + ), to: ChatMessages, as: :create_voice_message + + @doc """ + Edits a chat message. + """ + defdelegate edit_chat_message(message_id, user_id, new_content), + to: ChatMessages, + as: :edit_message + + @doc """ + Deletes a chat message. + """ + defdelegate delete_chat_message(message_id, user_id, is_admin \\ false), + to: ChatMessages, + as: :delete_message + + @doc """ + Adds a reaction to a chat message. + """ + defdelegate add_chat_reaction(message_id, user_id, emoji), to: ChatMessages, as: :add_reaction + + @doc """ + Removes a reaction from a chat message. + """ + defdelegate remove_chat_reaction(message_id, user_id, emoji), + to: ChatMessages, + as: :remove_reaction + + @doc """ + Marks chat messages as read. + """ + defdelegate mark_chat_messages_read(conversation_id, user_id, up_to_message_id \\ nil), + to: ChatMessages, + as: :mark_messages_read + + @doc """ + Gets unread chat message count for a conversation. + """ + defdelegate get_chat_unread_count(conversation_id, user_id), + to: ChatMessages, + as: :get_unread_count + + @doc """ + Gets unread chat message counts for all conversations. + """ + defdelegate get_all_chat_unread_counts(user_id), to: ChatMessages, as: :get_all_unread_counts + + @doc """ + Searches chat messages in a conversation. + """ + defdelegate search_chat_messages(conversation_id, query, opts \\ []), + to: ChatMessages, + as: :search_messages + + ## Reactions - Delegate to Reactions context + + @doc """ + Adds a reaction to a message. + """ + defdelegate add_reaction(message_id, user_id, emoji), to: Reactions + + @doc """ + Removes a reaction from a message. + """ + defdelegate remove_reaction(message_id, user_id, emoji), to: Reactions + + ## Moderation - Delegate to Moderation context + + @doc """ + Creates a timeout for a user in a specific conversation or globally. + """ + def timeout_user(conversation_id, user_id, created_by_id, duration_seconds) + when is_integer(conversation_id) and is_integer(user_id) and is_integer(created_by_id) and + is_integer(duration_seconds) do + if chat_conversation_type?(conversation_id) do + Moderation.timeout_chat_user(conversation_id, user_id, created_by_id, duration_seconds) + else + Moderation.timeout_user(user_id, created_by_id, duration_seconds, + conversation_id: conversation_id + ) + end + end + + def timeout_user(user_id, created_by_id, duration_seconds, opts) + when is_integer(user_id) and is_integer(created_by_id) and is_integer(duration_seconds) and + is_list(opts) do + Moderation.timeout_user(user_id, created_by_id, duration_seconds, opts) + end + + @doc """ + Checks if a user is currently timed out in a conversation or globally. + """ + defdelegate user_timed_out?(user_id, conversation_id \\ nil), to: Moderation + + @doc """ + Removes timeout for a user. + """ + def remove_timeout(user_id, conversation_id \\ nil) + + def remove_timeout(conversation_id, user_id) + when is_integer(conversation_id) and is_integer(user_id) do + if chat_conversation_type?(conversation_id) do + Moderation.remove_chat_timeout(conversation_id, user_id) + else + Moderation.remove_timeout(conversation_id, user_id) + end + end + + def remove_timeout(user_id, conversation_id), + do: Moderation.remove_timeout(user_id, conversation_id) + + @doc """ + Gets active timeouts for a user. + """ + defdelegate get_user_timeouts(user_id), to: Moderation + + @doc """ + Remove member from conversation (kick). + """ + defdelegate remove_member(conversation_id, user_id, current_user), to: Moderation + + @doc """ + Bans a user from a community. + """ + defdelegate ban_user_from_community( + community_id, + user_id, + banned_by_id, + reason \\ nil, + expires_at \\ nil + ), + to: Moderation + + @doc """ + Unbans a user from a community. + """ + defdelegate unban_user_from_community(community_id, user_id, unbanned_by_id), to: Moderation + + @doc """ + Checks if a user is banned from a community. + """ + defdelegate user_banned?(community_id, user_id), to: Moderation + + @doc """ + Lists banned users for a community. + """ + defdelegate list_community_bans(community_id), to: Moderation + + @doc """ + Gets moderation actions for a conversation or user. + """ + defdelegate get_moderation_log(opts \\ []), to: Moderation + + @doc """ + Log a moderation action. + """ + defdelegate log_moderation_action(action_type, target_user_id, moderator_id, opts \\ []), + to: Moderation + + @doc """ + Lists all flairs for a community. + """ + defdelegate list_community_flairs(community_id), to: Moderation + + @doc """ + Lists enabled flairs for a community. + """ + defdelegate list_enabled_community_flairs(community_id), to: Moderation + + @doc """ + Gets a single community flair. + """ + defdelegate get_community_flair!(id), to: Moderation + + @doc """ + Lists flairs available to a user for a community. + """ + defdelegate list_available_flairs(community_id, user_id), to: Moderation + + @doc """ + Gets a single flair. + """ + defdelegate get_flair!(id), to: Moderation + + @doc """ + Creates a flair for a community. + """ + defdelegate create_community_flair(attrs \\ %{}), to: Moderation + + @doc """ + Updates a flair. + """ + defdelegate update_community_flair(flair, attrs), to: Moderation + + @doc """ + Deletes a flair. + """ + defdelegate delete_community_flair(flair), to: Moderation + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking flair changes. + """ + defdelegate change_community_flair(flair, attrs \\ %{}), to: Moderation + + ## Utility functions (kept in main context) + + @doc """ + Searches for users to start a conversation with. + """ + def search_users(query, current_user_id, limit \\ 10) do + search_term = "%#{query}%" + + from(u in User, + where: + u.id != ^current_user_id and + (ilike(u.username, ^search_term) or + ilike(u.display_name, ^search_term)), + limit: ^limit, + preload: [:profile] + ) + |> Repo.all() + end + + defp chat_conversation_type?(conversation_id) when is_integer(conversation_id) do + case conversation_type(conversation_id) do + "dm" -> true + "group" -> true + "channel" -> true + "voice_channel" -> true + _ -> false + end + end + + defp chat_conversation_type?(_), do: false + + defp conversation_type(conversation_id) do + from(c in ChatConversation, + where: c.id == ^conversation_id, + select: c.type + ) + |> Repo.one() + end + + defp conversation_type_map(conversation_ids) when is_list(conversation_ids) do + conversation_ids = Enum.uniq(conversation_ids) + + from(c in ChatConversation, + where: c.id in ^conversation_ids, + select: {c.id, c.type} + ) + |> Repo.all() + |> Map.new() + end + + ## ActivityPub Federation - Delegate to Messages context + + @doc """ + Creates a message from a federated source (ActivityPub). + """ + defdelegate create_federated_message(attrs), to: Messages + defdelegate create_federated_message_with_status(attrs), to: Messages + + @doc """ + Gets a message by its ActivityPub ID. + """ + defdelegate get_message_by_activitypub_id(activitypub_id), to: Messages + + @doc """ + Gets a message by ActivityPub ID or URL reference. + """ + defdelegate get_message_by_activitypub_ref(activitypub_ref), to: Messages + defdelegate get_message_by_activitypub_ref(activitypub_ref, opts), to: Messages + + @doc """ + Gets multiple messages by their ActivityPub IDs. + """ + defdelegate get_messages_by_activitypub_ids(activitypub_ids), to: Messages + + @doc """ + Gets local replies to messages with the given ActivityPub IDs. + """ + defdelegate get_local_replies_to_activitypub_ids(activitypub_ids), to: Messages + + @doc """ + Gets cached replies (local and federated) to messages with the given ActivityPub IDs. + """ + defdelegate get_cached_replies_to_activitypub_ids(activitypub_ids), to: Messages + + @doc """ + Gets a message by ID. + """ + defdelegate get_message(id), to: Messages + + @doc """ + Updates a message. + """ + defdelegate update_message(message, attrs), to: Messages + + @doc """ + Creates a like from a federated source. + """ + def create_federated_like(message_id, remote_actor_id, activitypub_id \\ nil) do + Messages.create_federated_like(message_id, remote_actor_id, activitypub_id) + end + + @doc """ + Deletes a like from a federated source. + """ + defdelegate delete_federated_like(message_id, remote_actor_id), to: Messages + + @doc """ + Creates a dislike (downvote) from a federated source. + """ + def create_federated_dislike(message_id, remote_actor_id, activitypub_id \\ nil) do + Messages.create_federated_dislike(message_id, remote_actor_id, activitypub_id) + end + + @doc """ + Deletes a dislike from a federated source. + """ + defdelegate delete_federated_dislike(message_id, remote_actor_id), to: Messages + + @doc """ + Creates a boost (announce) record from a federated source. + """ + def create_federated_boost(message_id, remote_actor_id, activitypub_id \\ nil) do + Messages.create_federated_boost(message_id, remote_actor_id, activitypub_id) + end + + @doc """ + Deletes a boost from a federated source. + """ + defdelegate delete_federated_boost(message_id, remote_actor_id), to: Messages + + @doc """ + Increments the share count for a message. + """ + defdelegate increment_share_count(message_id), to: Messages + + @doc """ + Decrements the share count for a message. + """ + defdelegate decrement_share_count(message_id), to: Messages + + @doc """ + Increments the quote count for a message. + """ + defdelegate increment_quote_count(message_id), to: Messages +end diff --git a/apps/elektrine/lib/elektrine/messaging/arblarg_profiles.ex b/apps/elektrine/lib/elektrine/messaging/arblarg_profiles.ex new file mode 100644 index 0000000..0850428 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/arblarg_profiles.ex @@ -0,0 +1,379 @@ +defmodule Elektrine.Messaging.ArblargProfiles do + @moduledoc """ + Arblarg profile and extension registry metadata. + + This module codifies interoperability discipline for Arblarg: + - one mandatory core profile + - optional community extension profile + - strict extension registry with namespaces and rejection rules + - conformance-claim gating + """ + + alias Elektrine.Messaging.ArblargSDK + + @core_profile_id "arblarg-core/1.0" + @core_profile_description "Mandatory Arblarg core interoperability profile" + @community_profile_id "arblarg-community/1.0" + + @community_profile_description "Optional community profile requiring roles, permissions, threads, presence, and moderation extensions" + + @conformance_suite_version "2026.02" + @extension_conformance_suite_version "2026.02-ext" + @conformance_test_command "mix test apps/elektrine_web/test/elektrine_web/controllers/arblarg_conformance_test.exs" + + @community_conformance_test_command "mix test apps/elektrine/test/elektrine/messaging/arblarg_extension_conformance_test.exs" + + @extension_definitions [ + %{ + urn: "urn:arblarg:ext:bootstrap:1", + version: 1, + stability: "stable", + required: false, + events: ["urn:arblarg:ext:bootstrap:1#server.upsert"], + fallback: "reject_unsupported_event_type", + gate: "optional", + env_flag: "MESSAGING_FEDERATION_CONFORMANCE_CORE_PASSED", + test_command: @conformance_test_command, + profile_requirement: nil + }, + %{ + urn: "urn:arblarg:ext:roles:1", + version: 1, + stability: "experimental", + required: false, + events: [ + "urn:arblarg:ext:roles:1#role.upsert", + "urn:arblarg:ext:roles:1#role.assignment.upsert" + ], + fallback: "reject_unsupported_event_type", + gate: "required_for_arblarg_community_1_0", + env_flag: "MESSAGING_FEDERATION_CONFORMANCE_EXT_ROLES_PASSED", + test_command: @community_conformance_test_command, + profile_requirement: @community_profile_id + }, + %{ + urn: "urn:arblarg:ext:permissions:1", + version: 1, + stability: "experimental", + required: false, + events: ["urn:arblarg:ext:permissions:1#overwrite.upsert"], + fallback: "reject_unsupported_event_type", + gate: "required_for_arblarg_community_1_0", + env_flag: "MESSAGING_FEDERATION_CONFORMANCE_EXT_PERMISSIONS_PASSED", + test_command: @community_conformance_test_command, + profile_requirement: @community_profile_id + }, + %{ + urn: "urn:arblarg:ext:threads:1", + version: 1, + stability: "experimental", + required: false, + events: [ + "urn:arblarg:ext:threads:1#thread.upsert", + "urn:arblarg:ext:threads:1#thread.archive" + ], + fallback: "reject_unsupported_event_type", + gate: "required_for_arblarg_community_1_0", + env_flag: "MESSAGING_FEDERATION_CONFORMANCE_EXT_THREADS_PASSED", + test_command: @community_conformance_test_command, + profile_requirement: @community_profile_id + }, + %{ + urn: "urn:arblarg:ext:presence:1", + version: 1, + stability: "experimental", + required: false, + events: [ + "urn:arblarg:ext:presence:1#presence.update", + "urn:arblarg:ext:presence:1#typing.start", + "urn:arblarg:ext:presence:1#typing.stop" + ], + fallback: "reject_unsupported_event_type", + gate: "required_for_arblarg_community_1_0", + env_flag: "MESSAGING_FEDERATION_CONFORMANCE_EXT_PRESENCE_PASSED", + test_command: @community_conformance_test_command, + profile_requirement: @community_profile_id + }, + %{ + urn: "urn:arblarg:ext:moderation:1", + version: 1, + stability: "experimental", + required: false, + events: ["urn:arblarg:ext:moderation:1#action.recorded"], + fallback: "reject_unsupported_event_type", + gate: "required_for_arblarg_community_1_0", + env_flag: "MESSAGING_FEDERATION_CONFORMANCE_EXT_MODERATION_PASSED", + test_command: @community_conformance_test_command, + profile_requirement: @community_profile_id + }, + %{ + urn: "urn:arblarg:ext:pins:1", + version: 1, + stability: "experimental", + required: false, + events: ["urn:arblarg:ext:pins:1#pin.upsert"], + fallback: "reject_unsupported_event_type", + gate: "optional", + env_flag: nil, + test_command: nil, + profile_requirement: nil + }, + %{ + urn: "urn:arblarg:ext:dm:1", + version: 1, + stability: "experimental", + required: false, + events: ["urn:arblarg:ext:dm:1#message.create"], + fallback: "reject_unsupported_event_type", + gate: "optional", + env_flag: nil, + test_command: nil, + profile_requirement: nil + }, + %{ + urn: "urn:arblarg:ext:voice:1", + version: 1, + stability: "experimental", + required: false, + events: [ + "urn:arblarg:ext:voice:1#dm.call.invite", + "urn:arblarg:ext:voice:1#dm.call.accept", + "urn:arblarg:ext:voice:1#dm.call.reject", + "urn:arblarg:ext:voice:1#dm.call.end", + "urn:arblarg:ext:voice:1#dm.call.signal" + ], + fallback: "reject_unsupported_event_type", + gate: "optional", + env_flag: nil, + test_command: nil, + profile_requirement: nil + } + ] + + @community_required_extensions @extension_definitions + |> Enum.filter( + &(&1.profile_requirement == @community_profile_id) + ) + |> Enum.map(& &1.urn) + + @extension_definitions_by_event_type Enum.reduce(@extension_definitions, %{}, fn definition, + acc -> + Enum.reduce(definition.events, acc, fn event_type, + event_acc -> + Map.put(event_acc, event_type, definition) + end) + end) + + def core_profile_id, do: @core_profile_id + def core_profile_description, do: @core_profile_description + def community_profile_id, do: @community_profile_id + def community_profile_description, do: @community_profile_description + def conformance_suite_version, do: @conformance_suite_version + def extension_conformance_suite_version, do: @extension_conformance_suite_version + def conformance_test_command, do: @conformance_test_command + def community_conformance_test_command, do: @community_conformance_test_command + def community_required_extensions, do: @community_required_extensions + + def extension_urn_for_event_type(event_type) do + case extension_definition(event_type) do + %{urn: urn} -> urn + _ -> nil + end + end + + def required_profile_for_event_type(event_type) do + case extension_definition(event_type) do + %{profile_requirement: profile_requirement} -> profile_requirement + _ -> nil + end + end + + def core_event_types, do: ArblargSDK.core_event_types() + + def extension_registry(opts \\ []) do + Enum.map(@extension_definitions, &build_extension_entry(&1, opts)) + end + + def extension_event_types do + @extension_definitions + |> Enum.flat_map(&Map.get(&1, :events, [])) + |> Enum.uniq() + end + + def conformance_verified_extension_statuses do + community_verified = + @extension_definitions + |> Enum.filter(&(&1.profile_requirement == @community_profile_id)) + |> Enum.map(&{&1.urn, true}) + + optional_unverified = + @extension_definitions + |> Enum.reject( + &(&1.urn == "urn:arblarg:ext:bootstrap:1" or + &1.profile_requirement == @community_profile_id) + ) + |> Enum.map(&{&1.urn, false}) + + Map.new([ + {"urn:arblarg:ext:bootstrap:1", true} + | community_verified ++ optional_unverified + ]) + end + + def profile_badges(opts \\ []) do + [core_profile_badge(opts), community_profile_badge(opts)] + end + + def passing_profile_claims(opts \\ []) do + profile_badges(opts) + |> Enum.filter(&(&1["status"] == "passing")) + |> Enum.map(& &1["id"]) + end + + def conformance_core_passed?(opts \\ []) do + if Keyword.has_key?(opts, :core_passed?) do + Keyword.get(opts, :core_passed?) == true + else + messaging_federation_config() + |> Keyword.get(:conformance_core_passed, false) + |> truthy?() + end + end + + def extension_conformance_passed?(urn, opts \\ []) + + def extension_conformance_passed?(urn, opts) when is_binary(urn) do + extension_status = + opts + |> Keyword.get(:extension_statuses, %{}) + |> status_lookup(urn) + + cond do + not is_nil(extension_status) -> + truthy?(extension_status) + + urn == "urn:arblarg:ext:bootstrap:1" -> + conformance_core_passed?(opts) + + true -> + messaging_federation_config() + |> Keyword.get(:conformance_extensions, %{}) + |> status_lookup(urn) + |> truthy?() + end + end + + def extension_conformance_passed?(_urn, _opts), do: false + + def community_profile_passed?(opts \\ []) do + if Keyword.has_key?(opts, :community_passed?) do + Keyword.get(opts, :community_passed?) == true + else + conformance_core_passed?(opts) and + Enum.all?(@community_required_extensions, &extension_conformance_passed?(&1, opts)) + end + end + + defp core_profile_badge(opts) do + status = if conformance_core_passed?(opts), do: "passing", else: "unverified" + + %{ + "id" => @core_profile_id, + "description" => @core_profile_description, + "required" => true, + "status" => status, + "suite_version" => @conformance_suite_version, + "conformance_test_command" => @conformance_test_command, + "required_events" => core_event_types(), + "required_security" => %{ + "request_signature_algorithm" => ArblargSDK.signature_algorithm(), + "event_signature_required" => true, + "replay_protection_required" => true, + "tls_required" => true + } + } + end + + defp community_profile_badge(opts) do + status = if community_profile_passed?(opts), do: "passing", else: "unverified" + + %{ + "id" => @community_profile_id, + "description" => @community_profile_description, + "required" => false, + "status" => status, + "suite_version" => @extension_conformance_suite_version, + "conformance_test_command" => @community_conformance_test_command, + "required_extensions" => @community_required_extensions, + "required_events" => + @extension_definitions + |> Enum.filter(&(&1.profile_requirement == @community_profile_id)) + |> Enum.flat_map(& &1.events) + |> Enum.uniq(), + "required_security" => %{ + "request_signature_algorithm" => ArblargSDK.signature_algorithm(), + "event_signature_required" => true, + "replay_protection_required" => true, + "tls_required" => true + } + } + end + + defp build_extension_entry(definition, opts) do + status = + if extension_conformance_passed?(definition.urn, opts), do: "passing", else: "unverified" + + %{ + "urn" => definition.urn, + "version" => definition.version, + "stability" => definition.stability, + "required" => definition.required, + "events" => definition.events, + "fallback" => definition.fallback, + "profile_requirement" => definition.profile_requirement, + "conformance" => %{ + "status" => status, + "suite_version" => + if(definition.urn == "urn:arblarg:ext:bootstrap:1", + do: @conformance_suite_version, + else: @extension_conformance_suite_version + ), + "test_command" => definition.test_command, + "env_flag" => definition.env_flag, + "gate" => definition.gate + } + } + end + + defp extension_definition(event_type) when is_binary(event_type) do + canonical_event_type = ArblargSDK.canonical_event_type(event_type) + Map.get(@extension_definitions_by_event_type, canonical_event_type) + end + + defp extension_definition(_event_type), do: nil + + defp messaging_federation_config do + Application.get_env(:elektrine, :messaging_federation, []) + end + + defp status_lookup(statuses, key) when is_map(statuses) do + case Map.get(statuses, key) do + nil -> + statuses + |> Map.keys() + |> Enum.find(fn map_key -> is_atom(map_key) and Atom.to_string(map_key) == key end) + |> case do + nil -> nil + atom_key -> Map.get(statuses, atom_key) + end + + value -> + value + end + end + + defp status_lookup(_statuses, _key), do: nil + + defp truthy?(value) when value in [true, "true", "1", 1], do: true + defp truthy?(_), do: false +end diff --git a/apps/elektrine/lib/elektrine/messaging/arblarg_sdk.ex b/apps/elektrine/lib/elektrine/messaging/arblarg_sdk.ex new file mode 100644 index 0000000..ae99130 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/arblarg_sdk.ex @@ -0,0 +1,2794 @@ +defmodule Elektrine.Messaging.ArblargSDK do + @moduledoc """ + Arblarg v1 reference SDK utilities. + + Provides deterministic signing, envelope validation, schema access, + and retry helpers for interoperable multi-vendor implementations. + """ + + @protocol_name "arblarg" + @protocol_id "arblarg" + @protocol_version "1.0" + @protocol_label "arblarg/1.0" + @signature_algorithm "ed25519" + @clock_skew_seconds 300 + + @bootstrap_extension_urn "urn:arblarg:ext:bootstrap:1" + @bootstrap_server_upsert_event_type "urn:arblarg:ext:bootstrap:1#server.upsert" + + @roles_extension_urn "urn:arblarg:ext:roles:1" + @roles_role_upsert_event_type "urn:arblarg:ext:roles:1#role.upsert" + @roles_role_assignment_upsert_event_type "urn:arblarg:ext:roles:1#role.assignment.upsert" + + @permissions_extension_urn "urn:arblarg:ext:permissions:1" + @permissions_overwrite_upsert_event_type "urn:arblarg:ext:permissions:1#overwrite.upsert" + + @threads_extension_urn "urn:arblarg:ext:threads:1" + @threads_thread_upsert_event_type "urn:arblarg:ext:threads:1#thread.upsert" + @threads_thread_archive_event_type "urn:arblarg:ext:threads:1#thread.archive" + + @pins_extension_urn "urn:arblarg:ext:pins:1" + @pins_pin_upsert_event_type "urn:arblarg:ext:pins:1#pin.upsert" + + @presence_extension_urn "urn:arblarg:ext:presence:1" + @presence_update_event_type "urn:arblarg:ext:presence:1#presence.update" + @typing_start_event_type "urn:arblarg:ext:presence:1#typing.start" + @typing_stop_event_type "urn:arblarg:ext:presence:1#typing.stop" + + @moderation_extension_urn "urn:arblarg:ext:moderation:1" + @moderation_action_recorded_event_type "urn:arblarg:ext:moderation:1#action.recorded" + + @dm_extension_urn "urn:arblarg:ext:dm:1" + @dm_message_create_event_type "urn:arblarg:ext:dm:1#message.create" + + @voice_extension_urn "urn:arblarg:ext:voice:1" + @voice_dm_call_invite_event_type "urn:arblarg:ext:voice:1#dm.call.invite" + @voice_dm_call_accept_event_type "urn:arblarg:ext:voice:1#dm.call.accept" + @voice_dm_call_reject_event_type "urn:arblarg:ext:voice:1#dm.call.reject" + @voice_dm_call_end_event_type "urn:arblarg:ext:voice:1#dm.call.end" + @voice_dm_call_signal_event_type "urn:arblarg:ext:voice:1#dm.call.signal" + + @core_event_types [ + "message.create", + "message.update", + "message.delete", + "reaction.add", + "reaction.remove", + "read.cursor", + "membership.upsert", + "invite.upsert", + "ban.upsert" + ] + + @roles_event_types [ + @roles_role_upsert_event_type, + @roles_role_assignment_upsert_event_type + ] + + @permissions_event_types [ + @permissions_overwrite_upsert_event_type + ] + + @threads_event_types [ + @threads_thread_upsert_event_type, + @threads_thread_archive_event_type + ] + + @pins_event_types [ + @pins_pin_upsert_event_type + ] + + @presence_event_types [ + @presence_update_event_type, + @typing_start_event_type, + @typing_stop_event_type + ] + + @moderation_event_types [ + @moderation_action_recorded_event_type + ] + + @dm_event_types [ + @dm_message_create_event_type + ] + + @voice_event_types [ + @voice_dm_call_invite_event_type, + @voice_dm_call_accept_event_type, + @voice_dm_call_reject_event_type, + @voice_dm_call_end_event_type, + @voice_dm_call_signal_event_type + ] + + @durable_extension_event_types @roles_event_types ++ + @permissions_event_types ++ + @threads_event_types ++ + @pins_event_types ++ + @moderation_event_types ++ + @dm_event_types ++ + Enum.take(@voice_event_types, 4) + + @extension_event_types @durable_extension_event_types ++ + @presence_event_types ++ + [@voice_dm_call_signal_event_type] + + @durable_event_types [@bootstrap_server_upsert_event_type] ++ + @core_event_types ++ @durable_extension_event_types + + @supported_event_types @durable_event_types ++ @presence_event_types + + @channel_scoped_durable_event_types @core_event_types ++ + @roles_event_types ++ + @permissions_event_types ++ + @threads_event_types ++ + @pins_event_types ++ @moderation_event_types + + @extension_event_aliases %{ + "server.upsert" => @bootstrap_server_upsert_event_type, + "role.upsert" => @roles_role_upsert_event_type, + "role.assignment.upsert" => @roles_role_assignment_upsert_event_type, + "permission.overwrite.upsert" => @permissions_overwrite_upsert_event_type, + "thread.upsert" => @threads_thread_upsert_event_type, + "thread.archive" => @threads_thread_archive_event_type, + "pin.upsert" => @pins_pin_upsert_event_type, + "presence.update" => @presence_update_event_type, + "typing.start" => @typing_start_event_type, + "typing.stop" => @typing_stop_event_type, + "moderation.action.recorded" => @moderation_action_recorded_event_type, + "dm.message.create" => @dm_message_create_event_type, + "dm.call.invite" => @voice_dm_call_invite_event_type, + "dm.call.accept" => @voice_dm_call_accept_event_type, + "dm.call.reject" => @voice_dm_call_reject_event_type, + "dm.call.end" => @voice_dm_call_end_event_type, + "dm.call.signal" => @voice_dm_call_signal_event_type + } + + @schema_name_aliases %{ + @bootstrap_server_upsert_event_type => "server.upsert", + @roles_role_upsert_event_type => "role.upsert", + @roles_role_assignment_upsert_event_type => "role.assignment.upsert", + @permissions_overwrite_upsert_event_type => "permission.overwrite.upsert", + @threads_thread_upsert_event_type => "thread.upsert", + @threads_thread_archive_event_type => "thread.archive", + @pins_pin_upsert_event_type => "pin.upsert", + @presence_update_event_type => "presence.update", + @typing_start_event_type => "typing.start", + @typing_stop_event_type => "typing.stop", + @moderation_action_recorded_event_type => "moderation.action.recorded", + @dm_message_create_event_type => "dm.message.create", + @voice_dm_call_invite_event_type => "dm.call.invite", + @voice_dm_call_accept_event_type => "dm.call.accept", + @voice_dm_call_reject_event_type => "dm.call.reject", + @voice_dm_call_end_event_type => "dm.call.end", + @voice_dm_call_signal_event_type => "dm.call.signal" + } + + @schema_bindings %{ + "envelope" => "envelope", + @bootstrap_server_upsert_event_type => "server.upsert", + "server.upsert" => "server.upsert", + "message.create" => "message.create", + "message.update" => "message.update", + "message.delete" => "message.delete", + "reaction.add" => "reaction.add", + "reaction.remove" => "reaction.remove", + "read.cursor" => "read.cursor", + "membership.upsert" => "membership.upsert", + "invite.upsert" => "invite.upsert", + "ban.upsert" => "ban.upsert", + @roles_role_upsert_event_type => "role.upsert", + "role.upsert" => "role.upsert", + @roles_role_assignment_upsert_event_type => "role.assignment.upsert", + "role.assignment.upsert" => "role.assignment.upsert", + @permissions_overwrite_upsert_event_type => "permission.overwrite.upsert", + "permission.overwrite.upsert" => "permission.overwrite.upsert", + @threads_thread_upsert_event_type => "thread.upsert", + "thread.upsert" => "thread.upsert", + @threads_thread_archive_event_type => "thread.archive", + "thread.archive" => "thread.archive", + @pins_pin_upsert_event_type => "pin.upsert", + "pin.upsert" => "pin.upsert", + @presence_update_event_type => "presence.update", + "presence.update" => "presence.update", + @typing_start_event_type => "typing.start", + "typing.start" => "typing.start", + @typing_stop_event_type => "typing.stop", + "typing.stop" => "typing.stop", + @moderation_action_recorded_event_type => "moderation.action.recorded", + "moderation.action.recorded" => "moderation.action.recorded", + @dm_message_create_event_type => "dm.message.create", + "dm.message.create" => "dm.message.create", + @voice_dm_call_invite_event_type => "dm.call.invite", + "dm.call.invite" => "dm.call.invite", + @voice_dm_call_accept_event_type => "dm.call.accept", + "dm.call.accept" => "dm.call.accept", + @voice_dm_call_reject_event_type => "dm.call.reject", + "dm.call.reject" => "dm.call.reject", + @voice_dm_call_end_event_type => "dm.call.end", + "dm.call.end" => "dm.call.end", + @voice_dm_call_signal_event_type => "dm.call.signal", + "dm.call.signal" => "dm.call.signal" + } + + @presence_statuses ["online", "idle", "dnd", "offline", "invisible"] + @moderation_action_kinds ["warn", "timeout", "kick", "ban", "unban", "delete_message"] + @call_types ["audio", "video"] + @call_signal_kinds ["offer", "answer", "ice"] + @call_end_reasons ["ended", "failed", "disconnected", "timeout", "cancelled"] + @permission_vocabulary [ + "read_messages", + "send_messages", + "send_tts_messages", + "send_voice_signaling", + "attach_files", + "embed_links", + "mention_everyone", + "use_external_emoji", + "invite_members", + "manage_moderation", + "manage_messages", + "manage_roles", + "manage_permissions", + "manage_channels", + "manage_threads", + "create_threads", + "view_audit_log", + "manage_webhooks", + "manage_server" + ] + + @structured_error_codes [ + "invalid_payload", + "unsupported_event_type", + "invalid_event_signature", + "origin_domain_mismatch", + "origin_actor_domain_mismatch", + "origin_identifier_host_mismatch", + "origin_stream_host_mismatch", + "not_authorized_for_room", + "unsupported_version", + "rate_limited", + "peer_quarantined", + "peer_blocked", + "event_too_large", + "batch_too_large", + "snapshot_too_large", + "cursor_expired", + "media_not_authorized" + ] + @actor_schema %{ + "type" => "object", + "required" => ["uri", "username", "domain", "handle"], + "properties" => %{ + "id" => %{ + "type" => "string", + "minLength" => 1, + "format" => "uri", + "pattern" => "^https?://" + }, + "uri" => %{ + "type" => "string", + "minLength" => 1, + "format" => "uri", + "pattern" => "^https?://" + }, + "username" => %{"type" => "string", "minLength" => 1}, + "display_name" => %{"type" => "string"}, + "domain" => %{"type" => "string", "minLength" => 1}, + "handle" => %{"type" => "string", "minLength" => 3}, + "avatar_url" => %{"type" => "string"}, + "key_id" => %{"type" => "string"} + } + } + @attachment_schema %{ + "type" => "object", + "required" => ["id", "url", "mime_type", "authorization", "retention"], + "properties" => %{ + "id" => %{"type" => "string", "minLength" => 1}, + "url" => %{"type" => "string", "minLength" => 1, "format" => "uri"}, + "mime_type" => %{"type" => "string", "minLength" => 1}, + "byte_size" => %{"type" => "integer", "minimum" => 0}, + "sha256" => %{"type" => "string", "minLength" => 8}, + "authorization" => %{ + "type" => "string", + "enum" => ["public", "signed", "origin-authenticated"] + }, + "retention" => %{ + "type" => "string", + "enum" => ["origin", "rehosted", "expiring"] + }, + "expires_at" => %{"type" => "string", "format" => "date-time"}, + "alt_text" => %{"type" => "string"}, + "width" => %{"type" => "integer", "minimum" => 0}, + "height" => %{"type" => "integer", "minimum" => 0}, + "duration_ms" => %{"type" => "integer", "minimum" => 0} + } + } + + @schemas %{ + "1.0" => %{ + "envelope" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/envelope", + "title" => "Arblarg Event Envelope", + "type" => "object", + "required" => [ + "protocol", + "protocol_id", + "protocol_version", + "event_type", + "event_id", + "origin_domain", + "stream_id", + "sequence", + "sent_at", + "idempotency_key", + "payload", + "signature" + ], + "properties" => %{ + "protocol" => %{"type" => "string", "const" => @protocol_name}, + "protocol_id" => %{"type" => "string", "const" => @protocol_id}, + "protocol_version" => %{"type" => "string", "const" => "1.0"}, + "event_type" => %{"type" => "string", "enum" => @durable_event_types}, + "event_id" => %{"type" => "string", "minLength" => 1}, + "origin_domain" => %{"type" => "string", "minLength" => 1}, + "stream_id" => %{"type" => "string", "minLength" => 1}, + "sequence" => %{"type" => "integer", "minimum" => 1}, + "sent_at" => %{"type" => "string", "format" => "date-time"}, + "idempotency_key" => %{"type" => "string", "minLength" => 1}, + "payload" => %{"type" => "object"}, + "signature" => %{ + "type" => "object", + "required" => ["algorithm", "key_id", "value"], + "properties" => %{ + "algorithm" => %{"type" => "string", "const" => @signature_algorithm}, + "key_id" => %{"type" => "string", "minLength" => 1}, + "value" => %{"type" => "string", "minLength" => 1} + } + } + } + }, + "server.upsert" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/server.upsert", + "title" => "Arblarg server.upsert payload", + "type" => "object", + "required" => ["server", "channels"], + "properties" => %{ + "server" => %{"type" => "object"}, + "channels" => %{"type" => "array"} + } + }, + "message.create" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/message.create", + "title" => "Arblarg message.create payload", + "type" => "object", + "required" => ["message"], + "anyOf" => [ + %{"required" => ["server", "channel"]}, + %{"required" => ["refs"]} + ], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "refs" => %{ + "type" => "object", + "required" => ["server_id", "channel_id"], + "properties" => %{ + "server_id" => %{"type" => "string", "minLength" => 1}, + "channel_id" => %{"type" => "string", "minLength" => 1} + } + }, + "message" => %{ + "type" => "object", + "required" => ["id", "content", "sender"], + "properties" => %{ + "id" => %{"type" => "string", "minLength" => 1}, + "content" => %{"type" => "string"}, + "message_type" => %{"type" => "string"}, + "attachments" => %{"type" => "array", "items" => @attachment_schema}, + "created_at" => %{"type" => "string", "format" => "date-time"}, + "edited_at" => %{"type" => "string", "format" => "date-time"}, + "sender" => @actor_schema, + "thread_id" => %{"type" => "string", "minLength" => 1} + } + } + } + }, + "message.update" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/message.update", + "title" => "Arblarg message.update payload", + "type" => "object", + "required" => ["message"], + "anyOf" => [ + %{"required" => ["server", "channel"]}, + %{"required" => ["refs"]} + ], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "refs" => %{ + "type" => "object", + "required" => ["server_id", "channel_id"], + "properties" => %{ + "server_id" => %{"type" => "string", "minLength" => 1}, + "channel_id" => %{"type" => "string", "minLength" => 1} + } + }, + "message" => %{ + "type" => "object", + "required" => ["id", "content", "sender"], + "properties" => %{ + "id" => %{"type" => "string", "minLength" => 1}, + "content" => %{"type" => "string"}, + "message_type" => %{"type" => "string"}, + "attachments" => %{"type" => "array", "items" => @attachment_schema}, + "created_at" => %{"type" => "string", "format" => "date-time"}, + "edited_at" => %{"type" => "string", "format" => "date-time"}, + "sender" => @actor_schema + } + } + } + }, + "message.delete" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/message.delete", + "title" => "Arblarg message.delete payload", + "type" => "object", + "required" => ["message_id"], + "anyOf" => [ + %{"required" => ["server", "channel"]}, + %{"required" => ["refs"]} + ], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "refs" => %{ + "type" => "object", + "required" => ["server_id", "channel_id"], + "properties" => %{ + "server_id" => %{"type" => "string", "minLength" => 1}, + "channel_id" => %{"type" => "string", "minLength" => 1} + } + }, + "message_id" => %{"type" => "string", "minLength" => 1}, + "deleted_at" => %{"type" => "string", "format" => "date-time"} + } + }, + "reaction.add" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/reaction.add", + "title" => "Arblarg reaction.add payload", + "type" => "object", + "required" => ["message_id", "reaction"], + "anyOf" => [ + %{"required" => ["server", "channel"]}, + %{"required" => ["refs"]} + ], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "refs" => %{ + "type" => "object", + "required" => ["server_id", "channel_id"], + "properties" => %{ + "server_id" => %{"type" => "string", "minLength" => 1}, + "channel_id" => %{"type" => "string", "minLength" => 1} + } + }, + "message_id" => %{"type" => "string", "minLength" => 1}, + "reaction" => %{ + "type" => "object", + "required" => ["emoji", "actor"], + "properties" => %{ + "emoji" => %{"type" => "string", "minLength" => 1}, + "actor" => @actor_schema + } + } + } + }, + "reaction.remove" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/reaction.remove", + "title" => "Arblarg reaction.remove payload", + "type" => "object", + "required" => ["message_id", "reaction"], + "anyOf" => [ + %{"required" => ["server", "channel"]}, + %{"required" => ["refs"]} + ], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "refs" => %{ + "type" => "object", + "required" => ["server_id", "channel_id"], + "properties" => %{ + "server_id" => %{"type" => "string", "minLength" => 1}, + "channel_id" => %{"type" => "string", "minLength" => 1} + } + }, + "message_id" => %{"type" => "string", "minLength" => 1}, + "reaction" => %{ + "type" => "object", + "required" => ["emoji", "actor"], + "properties" => %{ + "emoji" => %{"type" => "string", "minLength" => 1}, + "actor" => @actor_schema + } + } + } + }, + "read.cursor" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/read.cursor", + "title" => "Arblarg read.cursor payload", + "type" => "object", + "required" => ["read_through_message_id", "actor", "read_at"], + "anyOf" => [ + %{"required" => ["server", "channel"]}, + %{"required" => ["refs"]} + ], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "refs" => %{ + "type" => "object", + "required" => ["server_id", "channel_id"], + "properties" => %{ + "server_id" => %{"type" => "string", "minLength" => 1}, + "channel_id" => %{"type" => "string", "minLength" => 1} + } + }, + "read_through_message_id" => %{"type" => "string", "minLength" => 1}, + "read_through_sequence" => %{"type" => "integer", "minimum" => 1}, + "actor" => @actor_schema, + "read_at" => %{"type" => "string", "format" => "date-time"} + } + }, + "membership.upsert" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/membership.upsert", + "title" => "Arblarg membership.upsert payload", + "type" => "object", + "required" => ["membership"], + "anyOf" => [ + %{"required" => ["server", "channel"]}, + %{"required" => ["refs"]} + ], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "refs" => %{ + "type" => "object", + "required" => ["server_id", "channel_id"], + "properties" => %{ + "server_id" => %{"type" => "string", "minLength" => 1}, + "channel_id" => %{"type" => "string", "minLength" => 1} + } + }, + "membership" => %{ + "type" => "object", + "required" => ["actor", "role", "state", "updated_at"], + "properties" => %{ + "actor" => @actor_schema, + "role" => %{ + "type" => "string", + "enum" => ["owner", "admin", "moderator", "member", "readonly"] + }, + "state" => %{ + "type" => "string", + "enum" => ["active", "invited", "left", "banned"] + }, + "joined_at" => %{"type" => "string", "format" => "date-time"}, + "updated_at" => %{"type" => "string", "format" => "date-time"}, + "metadata" => %{"type" => "object"} + } + } + } + }, + "invite.upsert" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/invite.upsert", + "title" => "Arblarg invite.upsert payload", + "type" => "object", + "required" => ["invite"], + "anyOf" => [ + %{"required" => ["server", "channel"]}, + %{"required" => ["refs"]} + ], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "refs" => %{ + "type" => "object", + "required" => ["server_id", "channel_id"], + "properties" => %{ + "server_id" => %{"type" => "string", "minLength" => 1}, + "channel_id" => %{"type" => "string", "minLength" => 1} + } + }, + "invite" => %{ + "type" => "object", + "required" => ["actor", "target", "role", "state", "invited_at", "updated_at"], + "properties" => %{ + "actor" => @actor_schema, + "target" => @actor_schema, + "role" => %{ + "type" => "string", + "enum" => ["owner", "admin", "moderator", "member", "readonly"] + }, + "state" => %{ + "type" => "string", + "enum" => ["pending", "accepted", "declined", "revoked"] + }, + "invited_at" => %{"type" => "string", "format" => "date-time"}, + "updated_at" => %{"type" => "string", "format" => "date-time"}, + "metadata" => %{"type" => "object"} + } + } + } + }, + "ban.upsert" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/ban.upsert", + "title" => "Arblarg ban.upsert payload", + "type" => "object", + "required" => ["ban"], + "anyOf" => [ + %{"required" => ["server", "channel"]}, + %{"required" => ["refs"]} + ], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "refs" => %{ + "type" => "object", + "required" => ["server_id", "channel_id"], + "properties" => %{ + "server_id" => %{"type" => "string", "minLength" => 1}, + "channel_id" => %{"type" => "string", "minLength" => 1} + } + }, + "ban" => %{ + "type" => "object", + "required" => ["actor", "target", "state", "banned_at", "updated_at"], + "properties" => %{ + "actor" => @actor_schema, + "target" => @actor_schema, + "state" => %{"type" => "string", "enum" => ["active", "lifted"]}, + "reason" => %{"type" => "string"}, + "banned_at" => %{"type" => "string", "format" => "date-time"}, + "updated_at" => %{"type" => "string", "format" => "date-time"}, + "expires_at" => %{"type" => "string", "format" => "date-time"}, + "metadata" => %{"type" => "object"} + } + } + } + }, + "role.upsert" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/role.upsert", + "title" => "Arblarg role.upsert payload", + "type" => "object", + "required" => ["server", "channel", "role", "actor"], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "actor" => @actor_schema, + "role" => %{ + "type" => "object", + "required" => ["id", "name", "permissions", "position"], + "properties" => %{ + "id" => %{"type" => "string", "minLength" => 1}, + "name" => %{"type" => "string", "minLength" => 1}, + "permissions" => %{"type" => "array", "items" => %{"type" => "string"}}, + "position" => %{"type" => "integer", "minimum" => 0}, + "color" => %{"type" => "string"}, + "hoist" => %{"type" => "boolean"}, + "mentionable" => %{"type" => "boolean"} + } + } + } + }, + "role.assignment.upsert" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/role.assignment.upsert", + "title" => "Arblarg role.assignment.upsert payload", + "type" => "object", + "required" => ["server", "channel", "assignment", "actor"], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "actor" => @actor_schema, + "assignment" => %{ + "type" => "object", + "required" => ["role_id", "target", "state"], + "properties" => %{ + "role_id" => %{"type" => "string", "minLength" => 1}, + "target" => %{ + "type" => "object", + "required" => ["type", "id"], + "properties" => %{ + "type" => %{"type" => "string", "enum" => ["user", "member"]}, + "id" => %{"type" => "string", "minLength" => 1} + } + }, + "state" => %{"type" => "string", "enum" => ["assigned", "removed"]} + } + } + } + }, + "permission.overwrite.upsert" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/permission.overwrite.upsert", + "title" => "Arblarg permission.overwrite.upsert payload", + "type" => "object", + "required" => ["server", "channel", "overwrite", "actor"], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "actor" => @actor_schema, + "overwrite" => %{ + "type" => "object", + "required" => ["id", "target", "allow", "deny"], + "properties" => %{ + "id" => %{"type" => "string", "minLength" => 1}, + "target" => %{ + "type" => "object", + "required" => ["type", "id"], + "properties" => %{ + "type" => %{"type" => "string", "enum" => ["role", "member"]}, + "id" => %{"type" => "string", "minLength" => 1} + } + }, + "allow" => %{"type" => "array", "items" => %{"type" => "string"}}, + "deny" => %{"type" => "array", "items" => %{"type" => "string"}} + } + } + } + }, + "thread.upsert" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/thread.upsert", + "title" => "Arblarg thread.upsert payload", + "type" => "object", + "required" => ["server", "channel", "thread"], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "thread" => %{ + "type" => "object", + "required" => ["id", "channel_id", "name", "state", "owner"], + "properties" => %{ + "id" => %{"type" => "string", "minLength" => 1}, + "channel_id" => %{"type" => "string", "minLength" => 1}, + "name" => %{"type" => "string", "minLength" => 1}, + "state" => %{"type" => "string", "enum" => ["active", "archived", "locked"]}, + "owner" => @actor_schema, + "message_count" => %{"type" => "integer", "minimum" => 0}, + "member_count" => %{"type" => "integer", "minimum" => 0} + } + } + } + }, + "thread.archive" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/thread.archive", + "title" => "Arblarg thread.archive payload", + "type" => "object", + "required" => ["server", "channel", "thread_id", "archived_at", "actor"], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "thread_id" => %{"type" => "string", "minLength" => 1}, + "archived_at" => %{"type" => "string", "format" => "date-time"}, + "reason" => %{"type" => "string"}, + "actor" => @actor_schema + } + }, + "pin.upsert" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/pin.upsert", + "title" => "Arblarg pin.upsert payload", + "type" => "object", + "required" => ["server", "channel", "pin", "actor"], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "pin" => %{ + "type" => "object", + "required" => ["message_id", "state"], + "properties" => %{ + "message_id" => %{"type" => "string", "minLength" => 1}, + "state" => %{"type" => "string", "enum" => ["pinned", "unpinned"]}, + "pinned_at" => %{"type" => "string", "format" => "date-time"}, + "updated_at" => %{"type" => "string", "format" => "date-time"} + } + }, + "actor" => @actor_schema + } + }, + "presence.update" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/presence.update", + "title" => "Arblarg presence.update payload", + "type" => "object", + "required" => ["presence"], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "refs" => %{ + "type" => "object", + "properties" => %{ + "server_id" => %{"type" => "string", "minLength" => 1}, + "channel_id" => %{"type" => "string", "minLength" => 1} + } + }, + "presence" => %{ + "type" => "object", + "required" => ["actor", "status", "updated_at"], + "properties" => %{ + "actor" => @actor_schema, + "status" => %{"type" => "string", "enum" => @presence_statuses}, + "updated_at" => %{"type" => "string", "format" => "date-time"}, + "activities" => %{"type" => "array", "items" => %{"type" => "object"}}, + "ttl_ms" => %{"type" => "integer", "minimum" => 100, "maximum" => 86_400_000} + } + } + } + }, + "typing.start" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/typing.start", + "title" => "Arblarg typing.start payload", + "type" => "object", + "required" => ["actor", "started_at"], + "anyOf" => [ + %{"required" => ["server", "channel"]}, + %{"required" => ["refs"]} + ], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "refs" => %{ + "type" => "object", + "required" => ["server_id", "channel_id"], + "properties" => %{ + "server_id" => %{"type" => "string", "minLength" => 1}, + "channel_id" => %{"type" => "string", "minLength" => 1} + } + }, + "actor" => @actor_schema, + "started_at" => %{"type" => "string", "format" => "date-time"}, + "ttl_ms" => %{"type" => "integer", "minimum" => 100, "maximum" => 10_000} + } + }, + "typing.stop" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/typing.stop", + "title" => "Arblarg typing.stop payload", + "type" => "object", + "required" => ["actor", "stopped_at"], + "anyOf" => [ + %{"required" => ["server", "channel"]}, + %{"required" => ["refs"]} + ], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "refs" => %{ + "type" => "object", + "required" => ["server_id", "channel_id"], + "properties" => %{ + "server_id" => %{"type" => "string", "minLength" => 1}, + "channel_id" => %{"type" => "string", "minLength" => 1} + } + }, + "actor" => @actor_schema, + "stopped_at" => %{"type" => "string", "format" => "date-time"} + } + }, + "moderation.action.recorded" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/moderation.action.recorded", + "title" => "Arblarg moderation.action.recorded payload", + "type" => "object", + "required" => ["server", "channel", "action"], + "properties" => %{ + "server" => %{"type" => "object"}, + "channel" => %{"type" => "object"}, + "action" => %{ + "type" => "object", + "required" => ["id", "kind", "target", "actor", "occurred_at"], + "properties" => %{ + "id" => %{"type" => "string", "minLength" => 1}, + "kind" => %{"type" => "string", "enum" => @moderation_action_kinds}, + "target" => %{"type" => "object"}, + "actor" => @actor_schema, + "occurred_at" => %{"type" => "string", "format" => "date-time"}, + "duration_seconds" => %{"type" => "integer", "minimum" => 0}, + "reason" => %{"type" => "string"} + } + } + } + }, + "dm.message.create" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/dm.message.create", + "title" => "Arblarg dm.message.create payload", + "type" => "object", + "required" => ["dm", "message"], + "properties" => %{ + "dm" => %{ + "type" => "object", + "required" => ["id", "sender", "recipient"], + "properties" => %{ + "id" => %{"type" => "string", "minLength" => 1}, + "sender" => @actor_schema, + "recipient" => @actor_schema + } + }, + "message" => %{ + "type" => "object", + "required" => ["id", "dm_id", "content", "sender"], + "properties" => %{ + "id" => %{"type" => "string", "minLength" => 1}, + "dm_id" => %{"type" => "string", "minLength" => 1}, + "content" => %{"type" => "string"}, + "message_type" => %{"type" => "string"}, + "attachments" => %{"type" => "array", "items" => @attachment_schema}, + "created_at" => %{"type" => "string", "format" => "date-time"}, + "edited_at" => %{"type" => "string", "format" => "date-time"}, + "sender" => @actor_schema + } + } + } + }, + "dm.call.invite" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/dm.call.invite", + "title" => "Arblarg dm.call.invite payload", + "type" => "object", + "required" => ["dm", "call"], + "properties" => %{ + "dm" => %{ + "type" => "object", + "required" => ["id", "sender", "recipient"], + "properties" => %{ + "id" => %{"type" => "string", "minLength" => 1}, + "sender" => @actor_schema, + "recipient" => @actor_schema + } + }, + "call" => %{ + "type" => "object", + "required" => ["id", "dm_id", "call_type", "actor", "initiated_at"], + "properties" => %{ + "id" => %{"type" => "string", "minLength" => 1}, + "dm_id" => %{"type" => "string", "minLength" => 1}, + "call_type" => %{"type" => "string", "enum" => @call_types}, + "actor" => @actor_schema, + "initiated_at" => %{"type" => "string", "format" => "date-time"}, + "metadata" => %{"type" => "object"} + } + } + } + }, + "dm.call.accept" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/dm.call.accept", + "title" => "Arblarg dm.call.accept payload", + "type" => "object", + "required" => ["dm", "call_id", "actor", "accepted_at"], + "properties" => %{ + "dm" => %{ + "type" => "object", + "required" => ["id", "sender", "recipient"], + "properties" => %{ + "id" => %{"type" => "string", "minLength" => 1}, + "sender" => @actor_schema, + "recipient" => @actor_schema + } + }, + "call_id" => %{"type" => "string", "minLength" => 1}, + "actor" => @actor_schema, + "accepted_at" => %{"type" => "string", "format" => "date-time"}, + "metadata" => %{"type" => "object"} + } + }, + "dm.call.reject" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/dm.call.reject", + "title" => "Arblarg dm.call.reject payload", + "type" => "object", + "required" => ["dm", "call_id", "actor", "rejected_at"], + "properties" => %{ + "dm" => %{ + "type" => "object", + "required" => ["id", "sender", "recipient"], + "properties" => %{ + "id" => %{"type" => "string", "minLength" => 1}, + "sender" => @actor_schema, + "recipient" => @actor_schema + } + }, + "call_id" => %{"type" => "string", "minLength" => 1}, + "actor" => @actor_schema, + "rejected_at" => %{"type" => "string", "format" => "date-time"}, + "reason" => %{"type" => "string"}, + "metadata" => %{"type" => "object"} + } + }, + "dm.call.end" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/dm.call.end", + "title" => "Arblarg dm.call.end payload", + "type" => "object", + "required" => ["dm", "call_id", "actor", "ended_at"], + "properties" => %{ + "dm" => %{ + "type" => "object", + "required" => ["id", "sender", "recipient"], + "properties" => %{ + "id" => %{"type" => "string", "minLength" => 1}, + "sender" => @actor_schema, + "recipient" => @actor_schema + } + }, + "call_id" => %{"type" => "string", "minLength" => 1}, + "actor" => @actor_schema, + "ended_at" => %{"type" => "string", "format" => "date-time"}, + "reason" => %{"type" => "string", "enum" => @call_end_reasons}, + "metadata" => %{"type" => "object"} + } + }, + "dm.call.signal" => %{ + "$schema" => "https://json-schema.org/draft/2020-12/schema", + "$id" => "arblarg://schemas/1.0/dm.call.signal", + "title" => "Arblarg dm.call.signal payload", + "type" => "object", + "required" => ["dm", "call_id", "actor", "signal", "sent_at"], + "properties" => %{ + "dm" => %{ + "type" => "object", + "required" => ["id", "sender", "recipient"], + "properties" => %{ + "id" => %{"type" => "string", "minLength" => 1}, + "sender" => @actor_schema, + "recipient" => @actor_schema + } + }, + "call_id" => %{"type" => "string", "minLength" => 1}, + "actor" => @actor_schema, + "sent_at" => %{"type" => "string", "format" => "date-time"}, + "signal" => %{ + "type" => "object", + "required" => ["kind", "payload"], + "properties" => %{ + "kind" => %{"type" => "string", "enum" => @call_signal_kinds}, + "payload" => %{"type" => "object"}, + "sequence" => %{"type" => "integer", "minimum" => 0} + } + } + } + } + } + } + + def protocol_name, do: @protocol_name + def protocol_id, do: @protocol_id + def protocol_version, do: @protocol_version + def protocol_label, do: @protocol_label + def signature_algorithm, do: @signature_algorithm + def clock_skew_seconds, do: @clock_skew_seconds + def core_event_types, do: @core_event_types + def extension_event_types, do: @extension_event_types + def permission_vocabulary, do: @permission_vocabulary + def structured_error_codes, do: @structured_error_codes + def bootstrap_extension_urn, do: @bootstrap_extension_urn + def bootstrap_server_upsert_event_type, do: @bootstrap_server_upsert_event_type + def roles_extension_urn, do: @roles_extension_urn + def permissions_extension_urn, do: @permissions_extension_urn + def threads_extension_urn, do: @threads_extension_urn + def pins_extension_urn, do: @pins_extension_urn + def presence_extension_urn, do: @presence_extension_urn + def moderation_extension_urn, do: @moderation_extension_urn + def dm_extension_urn, do: @dm_extension_urn + def voice_extension_urn, do: @voice_extension_urn + def roles_event_types, do: @roles_event_types + def permissions_event_types, do: @permissions_event_types + def threads_event_types, do: @threads_event_types + def pins_event_types, do: @pins_event_types + def pins_pin_upsert_event_type, do: @pins_pin_upsert_event_type + def presence_event_types, do: @presence_event_types + def moderation_event_types, do: @moderation_event_types + def dm_event_types, do: @dm_event_types + def voice_event_types, do: @voice_event_types + def dm_message_create_event_type, do: @dm_message_create_event_type + def dm_call_invite_event_type, do: @voice_dm_call_invite_event_type + def dm_call_accept_event_type, do: @voice_dm_call_accept_event_type + def dm_call_reject_event_type, do: @voice_dm_call_reject_event_type + def dm_call_end_event_type, do: @voice_dm_call_end_event_type + def dm_call_signal_event_type, do: @voice_dm_call_signal_event_type + def schema_bindings, do: @schema_bindings + def durable_event_types, do: @durable_event_types + def ephemeral_event_types, do: @presence_event_types ++ [@voice_dm_call_signal_event_type] + + def canonical_event_type(event_type) when is_binary(event_type) do + Map.get(@extension_event_aliases, event_type, event_type) + end + + def canonical_event_type(event_type), do: event_type + + def supported_event_types, do: @supported_event_types + + def schema(version \\ @protocol_version, name) + + def schema(version, name) when is_binary(version) and is_binary(name) do + schema_map = Map.get(@schemas, version, %{}) + Map.get(schema_map, name) || Map.get(schema_map, schema_name_alias(name)) + end + + def schema(_version, _name), do: nil + + def schema_names(version \\ @protocol_version) when is_binary(version) do + @schemas + |> Map.get(version, %{}) + |> Map.keys() + |> Enum.sort() + end + + def canonical_json_payload(value) do + canonical_json(value) + end + + def body_digest(body) when is_binary(body) do + :crypto.hash(:sha256, body) |> Base.url_encode64(padding: false) + end + + def body_digest(_), do: body_digest("") + + def canonical_request_signature_payload( + domain, + method, + request_path, + query_string, + timestamp, + content_digest \\ "", + request_id \\ "" + ) do + [ + String.downcase(to_string(domain || "")), + String.downcase(to_string(method || "")), + canonical_path(request_path), + canonical_query_string(query_string), + to_string(timestamp || "") |> String.trim(), + canonical_content_digest(content_digest), + to_string(request_id || "") |> String.trim() + ] + |> Enum.join("\n") + end + + def sign_payload(payload, private_key_material) when is_binary(payload) do + case normalize_private_key(private_key_material) do + {:ok, private_key} -> + :crypto.sign(:eddsa, :none, payload, [private_key, :ed25519]) + |> Base.url_encode64(padding: false) + + _ -> + "" + end + end + + def verify_payload_signature(payload, public_key_material, signature) + when is_binary(payload) and is_binary(signature) do + with {:ok, public_key} <- normalize_public_key(public_key_material), + {:ok, raw_signature} <- Base.url_decode64(String.trim(signature), padding: false) do + :crypto.verify(:eddsa, :none, payload, raw_signature, [public_key, :ed25519]) + else + _ -> false + end + end + + def verify_payload_signature(_payload, _public_key_material, _signature), do: false + + def verification_public_key(material) do + normalize_verification_public_key(material) + end + + def valid_timestamp?(timestamp, skew_seconds \\ @clock_skew_seconds) + + def valid_timestamp?(timestamp, skew_seconds) when is_binary(timestamp) do + case Integer.parse(timestamp) do + {ts, ""} when is_integer(skew_seconds) and skew_seconds >= 0 -> + abs(System.system_time(:second) - ts) <= skew_seconds + + _ -> + false + end + end + + def valid_timestamp?(_timestamp, _skew_seconds), do: false + + def sign_event_envelope(envelope, key_id, private_key_material) when is_map(envelope) do + envelope = normalize_envelope_for_signing(envelope) + + signature_value = + envelope + |> canonical_event_signature_payload() + |> sign_payload(private_key_material) + + Map.put(envelope, "signature", %{ + "algorithm" => @signature_algorithm, + "key_id" => to_string(key_id || ""), + "value" => signature_value + }) + end + + def verify_event_envelope_signature(envelope, key_lookup_fun) + when is_map(envelope) and is_function(key_lookup_fun, 1) do + signature = envelope["signature"] || %{} + key_id = signature["key_id"] + algorithm = signature["algorithm"] + value = signature["value"] + + if is_binary(key_id) and is_binary(value) and algorithm == @signature_algorithm do + envelope_without_signature = Map.delete(envelope, "signature") + verification_materials = key_lookup_fun.(key_id) |> List.wrap() + + Enum.any?(verification_materials, fn public_key_material -> + verify_payload_signature( + canonical_event_signature_payload(envelope_without_signature), + public_key_material, + value + ) + end) + else + false + end + end + + def verify_event_envelope_signature(_, _), do: false + + def canonical_event_payload_for_signing(envelope) when is_map(envelope) do + envelope + |> normalize_envelope_for_signing() + |> Map.delete("signature") + |> canonical_event_signature_payload() + end + + def canonical_event_payload_for_signing(_), do: "" + + def validate_event_envelope(envelope) when is_map(envelope) do + protocol_id = envelope["protocol_id"] + protocol_name = envelope["protocol"] + protocol_version = envelope["protocol_version"] + raw_event_type = envelope["event_type"] + event_type = canonical_event_type(raw_event_type) + payload = envelope["payload"] + idempotency_key = envelope["idempotency_key"] + + cond do + protocol_id != @protocol_id -> + {:error, :unsupported_protocol} + + protocol_name != @protocol_name -> + {:error, :unsupported_protocol} + + protocol_version != @protocol_version -> + {:error, :unsupported_version} + + raw_event_type not in @durable_event_types -> + {:error, :unsupported_event_type} + + !non_empty_binary?(envelope["event_id"]) -> + {:error, :invalid_event_id} + + !non_empty_binary?(envelope["origin_domain"]) -> + {:error, :invalid_origin_domain} + + !non_empty_binary?(envelope["stream_id"]) -> + {:error, :invalid_stream_id} + + parse_int(envelope["sequence"], 0) <= 0 -> + {:error, :invalid_sequence} + + !non_empty_binary?(idempotency_key) -> + {:error, :invalid_idempotency_key} + + !valid_iso8601?(envelope["sent_at"]) -> + {:error, :invalid_sent_at} + + !is_map(payload) -> + {:error, :invalid_event_payload} + + !valid_signature_map?(envelope["signature"]) -> + {:error, :invalid_signature} + + true -> + with :ok <- validate_event_payload(event_type, payload) do + validate_stream_binding(event_type, envelope["stream_id"], payload) + end + end + end + + def validate_event_envelope(_), do: {:error, :invalid_payload} + + def validate_event_payload("message.create", payload), + do: validate_message_upsert_payload(payload) + + def validate_event_payload("message.update", payload), + do: validate_message_upsert_payload(payload) + + def validate_event_payload(@bootstrap_server_upsert_event_type, payload) do + cond do + !is_map(payload) -> + {:error, :invalid_event_payload} + + !valid_server_object?(payload["server"], require_name?: true) -> + {:error, :invalid_event_payload} + + !valid_server_channels?(payload["channels"]) -> + {:error, :invalid_event_payload} + + true -> + :ok + end + end + + def validate_event_payload("server.upsert", payload), + do: validate_event_payload(@bootstrap_server_upsert_event_type, payload) + + def validate_event_payload("message.delete", payload) do + cond do + !is_map(payload) -> + {:error, :invalid_event_payload} + + !valid_channel_event_context?(payload) -> + {:error, :invalid_event_payload} + + is_map(payload["server"]) and !valid_server_object?(payload["server"]) -> + {:error, :invalid_event_payload} + + is_map(payload["channel"]) and !valid_channel_object?(payload["channel"]) -> + {:error, :invalid_event_payload} + + !non_empty_binary?(payload["message_id"]) -> + {:error, :invalid_event_payload} + + !valid_optional_iso8601?(payload["deleted_at"]) -> + {:error, :invalid_event_payload} + + true -> + :ok + end + end + + def validate_event_payload("reaction.add", payload), do: validate_reaction_payload(payload) + def validate_event_payload("reaction.remove", payload), do: validate_reaction_payload(payload) + + def validate_event_payload("read.cursor", payload) do + cond do + !is_map(payload) -> + {:error, :invalid_event_payload} + + !valid_channel_event_context?(payload) -> + {:error, :invalid_event_payload} + + is_map(payload["server"]) and !valid_server_object?(payload["server"]) -> + {:error, :invalid_event_payload} + + is_map(payload["channel"]) and !valid_channel_object?(payload["channel"]) -> + {:error, :invalid_event_payload} + + !non_empty_binary?(payload["read_through_message_id"]) -> + {:error, :invalid_event_payload} + + !valid_actor_payload?(payload["actor"]) -> + {:error, :invalid_event_payload} + + !valid_positive_integer_or_nil?(payload["read_through_sequence"]) -> + {:error, :invalid_event_payload} + + !valid_iso8601?(payload["read_at"]) -> + {:error, :invalid_event_payload} + + true -> + :ok + end + end + + def validate_event_payload("membership.upsert", payload) do + membership = if is_map(payload), do: payload["membership"], else: nil + + cond do + !is_map(payload) -> + {:error, :invalid_event_payload} + + !valid_channel_event_context?(payload) -> + {:error, :invalid_event_payload} + + is_map(payload["server"]) and !valid_server_object?(payload["server"]) -> + {:error, :invalid_event_payload} + + is_map(payload["channel"]) and !valid_channel_object?(payload["channel"]) -> + {:error, :invalid_event_payload} + + !is_map(membership) -> + {:error, :invalid_event_payload} + + !valid_actor_payload?(membership["actor"]) -> + {:error, :invalid_event_payload} + + membership["role"] not in ["owner", "admin", "moderator", "member", "readonly"] -> + {:error, :invalid_event_payload} + + membership["state"] not in ["active", "invited", "left", "banned"] -> + {:error, :invalid_event_payload} + + !valid_optional_iso8601?(membership["joined_at"]) -> + {:error, :invalid_event_payload} + + !valid_iso8601?(membership["updated_at"]) -> + {:error, :invalid_event_payload} + + !is_map(membership["metadata"] || %{}) -> + {:error, :invalid_event_payload} + + true -> + :ok + end + end + + def validate_event_payload("invite.upsert", payload) do + invite = if is_map(payload), do: payload["invite"], else: nil + + cond do + !is_map(payload) -> + {:error, :invalid_event_payload} + + !valid_channel_event_context?(payload) -> + {:error, :invalid_event_payload} + + is_map(payload["server"]) and !valid_server_object?(payload["server"]) -> + {:error, :invalid_event_payload} + + is_map(payload["channel"]) and !valid_channel_object?(payload["channel"]) -> + {:error, :invalid_event_payload} + + !is_map(invite) -> + {:error, :invalid_event_payload} + + !valid_actor_payload?(invite["actor"]) -> + {:error, :invalid_event_payload} + + !valid_actor_payload?(invite["target"]) -> + {:error, :invalid_event_payload} + + invite["role"] not in ["owner", "admin", "moderator", "member", "readonly"] -> + {:error, :invalid_event_payload} + + invite["state"] not in ["pending", "accepted", "declined", "revoked"] -> + {:error, :invalid_event_payload} + + !valid_iso8601?(invite["invited_at"]) -> + {:error, :invalid_event_payload} + + !valid_iso8601?(invite["updated_at"]) -> + {:error, :invalid_event_payload} + + !is_map(invite["metadata"] || %{}) -> + {:error, :invalid_event_payload} + + true -> + :ok + end + end + + def validate_event_payload("ban.upsert", payload) do + ban = if is_map(payload), do: payload["ban"], else: nil + + cond do + !is_map(payload) -> + {:error, :invalid_event_payload} + + !valid_channel_event_context?(payload) -> + {:error, :invalid_event_payload} + + is_map(payload["server"]) and !valid_server_object?(payload["server"]) -> + {:error, :invalid_event_payload} + + is_map(payload["channel"]) and !valid_channel_object?(payload["channel"]) -> + {:error, :invalid_event_payload} + + !is_map(ban) -> + {:error, :invalid_event_payload} + + !valid_actor_payload?(ban["actor"]) -> + {:error, :invalid_event_payload} + + !valid_actor_payload?(ban["target"]) -> + {:error, :invalid_event_payload} + + ban["state"] not in ["active", "lifted"] -> + {:error, :invalid_event_payload} + + !valid_iso8601?(ban["banned_at"]) -> + {:error, :invalid_event_payload} + + !valid_iso8601?(ban["updated_at"]) -> + {:error, :invalid_event_payload} + + !valid_optional_iso8601?(ban["expires_at"]) -> + {:error, :invalid_event_payload} + + !is_map(ban["metadata"] || %{}) -> + {:error, :invalid_event_payload} + + true -> + :ok + end + end + + def validate_event_payload(@roles_role_upsert_event_type, payload), + do: validate_role_upsert_payload(payload) + + def validate_event_payload("role.upsert", payload), + do: validate_event_payload(@roles_role_upsert_event_type, payload) + + def validate_event_payload(@roles_role_assignment_upsert_event_type, payload), + do: validate_role_assignment_upsert_payload(payload) + + def validate_event_payload("role.assignment.upsert", payload), + do: validate_event_payload(@roles_role_assignment_upsert_event_type, payload) + + def validate_event_payload(@permissions_overwrite_upsert_event_type, payload), + do: validate_permission_overwrite_upsert_payload(payload) + + def validate_event_payload("permission.overwrite.upsert", payload), + do: validate_event_payload(@permissions_overwrite_upsert_event_type, payload) + + def validate_event_payload(@threads_thread_upsert_event_type, payload), + do: validate_thread_upsert_payload(payload) + + def validate_event_payload("thread.upsert", payload), + do: validate_event_payload(@threads_thread_upsert_event_type, payload) + + def validate_event_payload(@threads_thread_archive_event_type, payload), + do: validate_thread_archive_payload(payload) + + def validate_event_payload("thread.archive", payload), + do: validate_event_payload(@threads_thread_archive_event_type, payload) + + def validate_event_payload(@pins_pin_upsert_event_type, payload), + do: validate_pin_upsert_payload(payload) + + def validate_event_payload("pin.upsert", payload), + do: validate_event_payload(@pins_pin_upsert_event_type, payload) + + def validate_event_payload(@presence_update_event_type, payload), + do: validate_presence_update_payload(payload) + + def validate_event_payload("presence.update", payload), + do: validate_event_payload(@presence_update_event_type, payload) + + def validate_event_payload(@typing_start_event_type, payload), + do: validate_typing_payload(payload, :start) + + def validate_event_payload("typing.start", payload), + do: validate_event_payload(@typing_start_event_type, payload) + + def validate_event_payload(@typing_stop_event_type, payload), + do: validate_typing_payload(payload, :stop) + + def validate_event_payload("typing.stop", payload), + do: validate_event_payload(@typing_stop_event_type, payload) + + def validate_event_payload(@moderation_action_recorded_event_type, payload), + do: validate_moderation_action_recorded_payload(payload) + + def validate_event_payload("moderation.action.recorded", payload), + do: validate_event_payload(@moderation_action_recorded_event_type, payload) + + def validate_event_payload(@dm_message_create_event_type, payload), + do: validate_dm_message_create_payload(payload) + + def validate_event_payload("dm.message.create", payload), + do: validate_event_payload(@dm_message_create_event_type, payload) + + def validate_event_payload(@voice_dm_call_invite_event_type, payload), + do: validate_dm_call_invite_payload(payload) + + def validate_event_payload("dm.call.invite", payload), + do: validate_event_payload(@voice_dm_call_invite_event_type, payload) + + def validate_event_payload(@voice_dm_call_accept_event_type, payload), + do: validate_dm_call_accept_payload(payload) + + def validate_event_payload("dm.call.accept", payload), + do: validate_event_payload(@voice_dm_call_accept_event_type, payload) + + def validate_event_payload(@voice_dm_call_reject_event_type, payload), + do: validate_dm_call_reject_payload(payload) + + def validate_event_payload("dm.call.reject", payload), + do: validate_event_payload(@voice_dm_call_reject_event_type, payload) + + def validate_event_payload(@voice_dm_call_end_event_type, payload), + do: validate_dm_call_end_payload(payload) + + def validate_event_payload("dm.call.end", payload), + do: validate_event_payload(@voice_dm_call_end_event_type, payload) + + def validate_event_payload(@voice_dm_call_signal_event_type, payload), + do: validate_dm_call_signal_payload(payload) + + def validate_event_payload("dm.call.signal", payload), + do: validate_event_payload(@voice_dm_call_signal_event_type, payload) + + def validate_event_payload(_event_type, _payload), do: {:error, :unsupported_event_type} + + def with_retries(fun, opts \\ []) when is_function(fun, 0) do + attempts = Keyword.get(opts, :attempts, 3) |> max(1) + base_backoff_ms = Keyword.get(opts, :base_backoff_ms, 250) |> max(1) + jitter_ms = Keyword.get(opts, :jitter_ms, 50) |> max(0) + + do_with_retries(fun, attempts, base_backoff_ms, jitter_ms) + end + + def derive_keypair_from_secret(secret) when is_binary(secret) do + seed = :crypto.hash(:sha256, secret) |> binary_part(0, 32) + :crypto.generate_key(:eddsa, :ed25519, seed) + end + + defp do_with_retries(fun, attempts_left, base_backoff_ms, jitter_ms) do + case safe_call(fun) do + {:ok, _} = ok -> + ok + + {:error, _reason} = error when attempts_left <= 1 -> + error + + {:error, _reason} -> + retry_index = attempts_left - 1 + backoff_ms = trunc(min(base_backoff_ms * :math.pow(2, retry_index - 1), 5_000)) + jitter = if jitter_ms > 0, do: :rand.uniform(jitter_ms), else: 0 + Process.sleep(backoff_ms + jitter) + do_with_retries(fun, attempts_left - 1, base_backoff_ms, jitter_ms) + end + end + + defp safe_call(fun) do + case fun.() do + {:ok, _} = ok -> ok + {:error, _} = error -> error + other -> {:ok, other} + end + rescue + error -> {:error, {:exception, error}} + catch + kind, reason -> {:error, {kind, reason}} + end + + defp normalize_private_key(key) when is_binary(key) and byte_size(key) == 32, + do: {:ok, key} + + defp normalize_private_key(%{private_key: key}), do: normalize_private_key(key) + defp normalize_private_key(%{secret: secret}), do: normalize_private_key(secret) + + defp normalize_private_key(key) when is_binary(key) do + trimmed = String.trim(key) + + if Elektrine.Strings.present?(trimmed) do + case decode_32_byte_key(trimmed) do + {:ok, decoded} -> + {:ok, decoded} + + :error -> + {_public_key, private_key} = derive_keypair_from_secret(trimmed) + {:ok, private_key} + end + else + {:error, :invalid_private_key} + end + end + + defp normalize_private_key(_), do: {:error, :invalid_private_key} + + defp normalize_public_key(key) when is_binary(key) and byte_size(key) == 32, + do: {:ok, key} + + defp normalize_public_key(%{public_key: key}), do: normalize_public_key(key) + defp normalize_public_key(%{"public_key" => key}), do: normalize_public_key(key) + + defp normalize_public_key(key) when is_binary(key) do + trimmed = String.trim(key) + + if Elektrine.Strings.present?(trimmed) do + case decode_32_byte_key(trimmed) do + {:ok, decoded} -> + {:ok, decoded} + + :error -> + {:error, :invalid_public_key} + end + else + {:error, :invalid_public_key} + end + end + + defp normalize_public_key(_), do: {:error, :invalid_public_key} + + defp normalize_verification_public_key(%{public_key: key}), do: normalize_public_key(key) + defp normalize_verification_public_key(%{"public_key" => key}), do: normalize_public_key(key) + + defp normalize_verification_public_key(%{secret: secret}) when is_binary(secret) do + if Elektrine.Strings.present?(secret) do + {public_key, _private_key} = derive_keypair_from_secret(secret) + {:ok, public_key} + else + {:error, :invalid_public_key} + end + end + + defp normalize_verification_public_key(%{"secret" => secret}) when is_binary(secret) do + if Elektrine.Strings.present?(secret) do + {public_key, _private_key} = derive_keypair_from_secret(secret) + {:ok, public_key} + else + {:error, :invalid_public_key} + end + end + + defp normalize_verification_public_key(key) when is_binary(key) do + case normalize_public_key(key) do + {:ok, public_key} -> + {:ok, public_key} + + {:error, :invalid_public_key} -> + trimmed = String.trim(key) + + if Elektrine.Strings.present?(trimmed) do + {public_key, _private_key} = derive_keypair_from_secret(trimmed) + {:ok, public_key} + else + {:error, :invalid_public_key} + end + end + end + + defp normalize_verification_public_key(_), do: {:error, :invalid_public_key} + + defp decode_32_byte_key(encoded) when is_binary(encoded) do + case Base.url_decode64(encoded, padding: false) do + {:ok, raw} when byte_size(raw) == 32 -> {:ok, raw} + _ -> decode_32_byte_key_standard(encoded) + end + end + + defp decode_32_byte_key_standard(encoded) do + case Base.decode64(encoded) do + {:ok, raw} when byte_size(raw) == 32 -> {:ok, raw} + _ -> :error + end + end + + defp validate_message_upsert_payload(payload) do + message = if is_map(payload), do: payload["message"], else: nil + + cond do + !is_map(payload) -> {:error, :invalid_event_payload} + !valid_channel_event_context?(payload) -> {:error, :invalid_event_payload} + !is_map(message) -> {:error, :invalid_event_payload} + !non_empty_binary?(message["id"]) -> {:error, :invalid_event_payload} + !valid_message_content?(message) -> {:error, :invalid_event_payload} + !valid_message_metadata?(message) -> {:error, :invalid_event_payload} + !valid_actor_payload?(message["sender"]) -> {:error, :invalid_event_payload} + !valid_message_channel_match?(payload, message) -> {:error, :invalid_event_payload} + true -> :ok + end + end + + defp validate_reaction_payload(payload) do + reaction = if is_map(payload), do: payload["reaction"], else: nil + + cond do + !is_map(payload) -> + {:error, :invalid_event_payload} + + !valid_channel_event_context?(payload) -> + {:error, :invalid_event_payload} + + is_map(payload["server"]) and !valid_server_object?(payload["server"]) -> + {:error, :invalid_event_payload} + + is_map(payload["channel"]) and !valid_channel_object?(payload["channel"]) -> + {:error, :invalid_event_payload} + + !non_empty_binary?(payload["message_id"]) -> + {:error, :invalid_event_payload} + + !is_map(reaction) -> + {:error, :invalid_event_payload} + + !non_empty_binary?(reaction["emoji"]) -> + {:error, :invalid_event_payload} + + !valid_actor_payload?(reaction["actor"]) -> + {:error, :invalid_event_payload} + + true -> + :ok + end + end + + defp validate_role_upsert_payload(payload) do + role = if is_map(payload), do: payload["role"], else: nil + + cond do + !is_map(payload) -> {:error, :invalid_event_payload} + !valid_server_object?(payload["server"]) -> {:error, :invalid_event_payload} + !valid_channel_object?(payload["channel"]) -> {:error, :invalid_event_payload} + !valid_channel_event_context?(payload) -> {:error, :invalid_event_payload} + !valid_actor_payload?(payload["actor"]) -> {:error, :invalid_event_payload} + !is_map(role) -> {:error, :invalid_event_payload} + !non_empty_binary?(role["id"]) -> {:error, :invalid_event_payload} + !non_empty_binary?(role["name"]) -> {:error, :invalid_event_payload} + !is_integer(role["position"]) or role["position"] < 0 -> {:error, :invalid_event_payload} + !valid_string_list?(role["permissions"]) -> {:error, :invalid_event_payload} + !valid_optional_binary?(role["color"]) -> {:error, :invalid_event_payload} + !valid_boolean_or_nil?(role["hoist"]) -> {:error, :invalid_event_payload} + !valid_boolean_or_nil?(role["mentionable"]) -> {:error, :invalid_event_payload} + true -> :ok + end + end + + defp validate_role_assignment_upsert_payload(payload) do + assignment = if is_map(payload), do: payload["assignment"], else: nil + + cond do + !is_map(payload) -> {:error, :invalid_event_payload} + !valid_server_object?(payload["server"]) -> {:error, :invalid_event_payload} + !valid_channel_object?(payload["channel"]) -> {:error, :invalid_event_payload} + !valid_channel_event_context?(payload) -> {:error, :invalid_event_payload} + !valid_actor_payload?(payload["actor"]) -> {:error, :invalid_event_payload} + !is_map(assignment) -> {:error, :invalid_event_payload} + !non_empty_binary?(assignment["role_id"]) -> {:error, :invalid_event_payload} + !valid_target?(assignment["target"], ["user", "member"]) -> {:error, :invalid_event_payload} + assignment["state"] not in ["assigned", "removed"] -> {:error, :invalid_event_payload} + true -> :ok + end + end + + defp validate_permission_overwrite_upsert_payload(payload) do + overwrite = if is_map(payload), do: payload["overwrite"], else: nil + + cond do + !is_map(payload) -> {:error, :invalid_event_payload} + !valid_server_object?(payload["server"]) -> {:error, :invalid_event_payload} + !valid_channel_object?(payload["channel"]) -> {:error, :invalid_event_payload} + !valid_channel_event_context?(payload) -> {:error, :invalid_event_payload} + !valid_actor_payload?(payload["actor"]) -> {:error, :invalid_event_payload} + !is_map(overwrite) -> {:error, :invalid_event_payload} + !non_empty_binary?(overwrite["id"]) -> {:error, :invalid_event_payload} + !valid_target?(overwrite["target"], ["role", "member"]) -> {:error, :invalid_event_payload} + !valid_string_list?(overwrite["allow"]) -> {:error, :invalid_event_payload} + !valid_string_list?(overwrite["deny"]) -> {:error, :invalid_event_payload} + true -> :ok + end + end + + defp validate_thread_upsert_payload(payload) do + thread = if is_map(payload), do: payload["thread"], else: nil + + cond do + !is_map(payload) -> + {:error, :invalid_event_payload} + + !valid_server_object?(payload["server"]) -> + {:error, :invalid_event_payload} + + !valid_channel_object?(payload["channel"]) -> + {:error, :invalid_event_payload} + + !valid_channel_event_context?(payload) -> + {:error, :invalid_event_payload} + + !is_map(thread) -> + {:error, :invalid_event_payload} + + !non_empty_binary?(thread["id"]) -> + {:error, :invalid_event_payload} + + !non_empty_binary?(thread["channel_id"]) -> + {:error, :invalid_event_payload} + + !non_empty_binary?(thread["name"]) -> + {:error, :invalid_event_payload} + + thread["state"] not in ["active", "archived", "locked"] -> + {:error, :invalid_event_payload} + + !valid_actor_payload?(thread["owner"]) -> + {:error, :invalid_event_payload} + + !valid_non_negative_integer_or_nil?(thread["message_count"]) -> + {:error, :invalid_event_payload} + + !valid_non_negative_integer_or_nil?(thread["member_count"]) -> + {:error, :invalid_event_payload} + + !valid_thread_channel_match?(payload, thread) -> + {:error, :invalid_event_payload} + + true -> + :ok + end + end + + defp validate_typing_payload(payload, mode) do + timestamp_field = if mode == :start, do: "started_at", else: "stopped_at" + ttl_ms = if is_map(payload), do: payload["ttl_ms"], else: nil + + cond do + !is_map(payload) -> + {:error, :invalid_event_payload} + + !valid_channel_event_context?(payload) -> + {:error, :invalid_event_payload} + + !valid_actor_payload?(payload["actor"]) -> + {:error, :invalid_event_payload} + + !valid_iso8601?(payload[timestamp_field]) -> + {:error, :invalid_event_payload} + + !is_nil(ttl_ms) and (!is_integer(ttl_ms) or ttl_ms < 100 or ttl_ms > 10_000) -> + {:error, :invalid_event_payload} + + true -> + :ok + end + end + + defp validate_thread_archive_payload(payload) do + cond do + !is_map(payload) -> {:error, :invalid_event_payload} + !valid_server_object?(payload["server"]) -> {:error, :invalid_event_payload} + !valid_channel_object?(payload["channel"]) -> {:error, :invalid_event_payload} + !valid_channel_event_context?(payload) -> {:error, :invalid_event_payload} + !non_empty_binary?(payload["thread_id"]) -> {:error, :invalid_event_payload} + !valid_iso8601?(payload["archived_at"]) -> {:error, :invalid_event_payload} + !valid_optional_binary?(payload["reason"]) -> {:error, :invalid_event_payload} + !valid_actor_payload?(payload["actor"]) -> {:error, :invalid_event_payload} + true -> :ok + end + end + + defp validate_pin_upsert_payload(payload) do + pin = if is_map(payload), do: payload["pin"], else: nil + + cond do + !is_map(payload) -> {:error, :invalid_event_payload} + !valid_server_object?(payload["server"]) -> {:error, :invalid_event_payload} + !valid_channel_object?(payload["channel"]) -> {:error, :invalid_event_payload} + !valid_channel_event_context?(payload) -> {:error, :invalid_event_payload} + !is_map(pin) -> {:error, :invalid_event_payload} + !non_empty_binary?(pin["message_id"]) -> {:error, :invalid_event_payload} + pin["state"] not in ["pinned", "unpinned"] -> {:error, :invalid_event_payload} + !valid_optional_iso8601?(pin["pinned_at"]) -> {:error, :invalid_event_payload} + !valid_optional_iso8601?(pin["updated_at"]) -> {:error, :invalid_event_payload} + !valid_actor_payload?(payload["actor"]) -> {:error, :invalid_event_payload} + true -> :ok + end + end + + defp validate_presence_update_payload(payload) do + presence = if is_map(payload), do: payload["presence"], else: nil + activities = if is_map(presence), do: presence["activities"], else: nil + ttl_ms = if is_map(presence), do: presence["ttl_ms"], else: nil + + cond do + !is_map(payload) -> + {:error, :invalid_event_payload} + + !valid_optional_presence_context?(payload) -> + {:error, :invalid_event_payload} + + !is_map(presence) -> + {:error, :invalid_event_payload} + + !valid_actor_payload?(presence["actor"]) -> + {:error, :invalid_event_payload} + + presence["status"] not in @presence_statuses -> + {:error, :invalid_event_payload} + + !valid_iso8601?(presence["updated_at"]) -> + {:error, :invalid_event_payload} + + !is_nil(activities) and (!is_list(activities) or Enum.any?(activities, &(not is_map(&1)))) -> + {:error, :invalid_event_payload} + + !is_nil(ttl_ms) and (!is_integer(ttl_ms) or ttl_ms < 100 or ttl_ms > 86_400_000) -> + {:error, :invalid_event_payload} + + true -> + :ok + end + end + + defp validate_moderation_action_recorded_payload(payload) do + action = if is_map(payload), do: payload["action"], else: nil + + cond do + !is_map(payload) -> + {:error, :invalid_event_payload} + + !valid_server_object?(payload["server"]) -> + {:error, :invalid_event_payload} + + !valid_channel_object?(payload["channel"]) -> + {:error, :invalid_event_payload} + + !valid_channel_event_context?(payload) -> + {:error, :invalid_event_payload} + + !is_map(action) -> + {:error, :invalid_event_payload} + + !non_empty_binary?(action["id"]) -> + {:error, :invalid_event_payload} + + action["kind"] not in @moderation_action_kinds -> + {:error, :invalid_event_payload} + + !valid_opaque_target?(action["target"]) -> + {:error, :invalid_event_payload} + + !valid_actor_payload?(action["actor"]) -> + {:error, :invalid_event_payload} + + !valid_iso8601?(action["occurred_at"]) -> + {:error, :invalid_event_payload} + + !valid_non_negative_integer_or_nil?(action["duration_seconds"]) -> + {:error, :invalid_event_payload} + + true -> + :ok + end + end + + defp validate_dm_message_create_payload(payload) do + dm = if is_map(payload), do: payload["dm"], else: nil + message = if is_map(payload), do: payload["message"], else: nil + + cond do + !is_map(payload) -> + {:error, :invalid_event_payload} + + !is_map(dm) -> + {:error, :invalid_event_payload} + + !is_map(message) -> + {:error, :invalid_event_payload} + + !non_empty_binary?(dm["id"]) -> + {:error, :invalid_event_payload} + + !valid_actor_payload?(dm["sender"]) -> + {:error, :invalid_event_payload} + + !valid_actor_payload?(dm["recipient"]) -> + {:error, :invalid_event_payload} + + !non_empty_binary?(message["id"]) -> + {:error, :invalid_event_payload} + + !non_empty_binary?(message["dm_id"]) -> + {:error, :invalid_event_payload} + + message["dm_id"] != dm["id"] -> + {:error, :invalid_event_payload} + + !valid_message_content?(message) -> + {:error, :invalid_event_payload} + + !valid_message_metadata?(message) -> + {:error, :invalid_event_payload} + + !valid_actor_payload?(message["sender"]) -> + {:error, :invalid_event_payload} + + !same_actor_identity?(dm["sender"], message["sender"]) -> + {:error, :invalid_event_payload} + + true -> + :ok + end + end + + defp validate_dm_call_invite_payload(payload) do + dm = if is_map(payload), do: payload["dm"], else: nil + call = if is_map(payload), do: payload["call"], else: nil + + cond do + !is_map(payload) -> + {:error, :invalid_event_payload} + + !valid_dm_event_context?(dm) -> + {:error, :invalid_event_payload} + + !is_map(call) -> + {:error, :invalid_event_payload} + + !non_empty_binary?(call["id"]) -> + {:error, :invalid_event_payload} + + !non_empty_binary?(call["dm_id"]) -> + {:error, :invalid_event_payload} + + call["dm_id"] != dm["id"] -> + {:error, :invalid_event_payload} + + call["call_type"] not in @call_types -> + {:error, :invalid_event_payload} + + !valid_actor_payload?(call["actor"]) -> + {:error, :invalid_event_payload} + + !same_actor_identity?(dm["sender"], call["actor"]) -> + {:error, :invalid_event_payload} + + !valid_iso8601?(call["initiated_at"]) -> + {:error, :invalid_event_payload} + + !is_map(call["metadata"] || %{}) -> + {:error, :invalid_event_payload} + + true -> + :ok + end + end + + defp validate_dm_call_accept_payload(payload), + do: validate_dm_call_state_payload(payload, "accepted_at", nil) + + defp validate_dm_call_reject_payload(payload), + do: validate_dm_call_state_payload(payload, "rejected_at", :reject) + + defp validate_dm_call_end_payload(payload), + do: validate_dm_call_state_payload(payload, "ended_at", :end_call) + + defp validate_dm_call_state_payload(payload, timestamp_field, mode) do + dm = if is_map(payload), do: payload["dm"], else: nil + actor = if is_map(payload), do: payload["actor"], else: nil + + cond do + !is_map(payload) -> + {:error, :invalid_event_payload} + + !valid_dm_event_context?(dm) -> + {:error, :invalid_event_payload} + + !non_empty_binary?(payload["call_id"]) -> + {:error, :invalid_event_payload} + + !valid_actor_payload?(actor) -> + {:error, :invalid_event_payload} + + !same_actor_identity?(dm["recipient"], actor) and !same_actor_identity?(dm["sender"], actor) -> + {:error, :invalid_event_payload} + + !valid_iso8601?(payload[timestamp_field]) -> + {:error, :invalid_event_payload} + + mode == :reject and !valid_optional_binary?(payload["reason"]) -> + {:error, :invalid_event_payload} + + mode == :end_call and payload["reason"] not in [nil | @call_end_reasons] -> + {:error, :invalid_event_payload} + + !is_map(payload["metadata"] || %{}) -> + {:error, :invalid_event_payload} + + true -> + :ok + end + end + + defp validate_dm_call_signal_payload(payload) do + dm = if is_map(payload), do: payload["dm"], else: nil + actor = if is_map(payload), do: payload["actor"], else: nil + signal = if is_map(payload), do: payload["signal"], else: nil + signal_payload = if is_map(signal), do: signal["payload"], else: nil + + cond do + !is_map(payload) -> + {:error, :invalid_event_payload} + + !valid_dm_event_context?(dm) -> + {:error, :invalid_event_payload} + + !non_empty_binary?(payload["call_id"]) -> + {:error, :invalid_event_payload} + + !valid_actor_payload?(actor) -> + {:error, :invalid_event_payload} + + !same_actor_identity?(dm["recipient"], actor) and !same_actor_identity?(dm["sender"], actor) -> + {:error, :invalid_event_payload} + + !valid_iso8601?(payload["sent_at"]) -> + {:error, :invalid_event_payload} + + !is_map(signal) -> + {:error, :invalid_event_payload} + + signal["kind"] not in @call_signal_kinds -> + {:error, :invalid_event_payload} + + !is_map(signal_payload) -> + {:error, :invalid_event_payload} + + !valid_dm_call_signal_payload?(signal["kind"], signal_payload) -> + {:error, :invalid_event_payload} + + !is_nil(signal["sequence"]) and + (!is_integer(signal["sequence"]) or signal["sequence"] < 0) -> + {:error, :invalid_event_payload} + + true -> + :ok + end + end + + defp valid_dm_event_context?(dm) when is_map(dm) do + non_empty_binary?(dm["id"]) and valid_actor_payload?(dm["sender"]) and + valid_actor_payload?(dm["recipient"]) + end + + defp valid_dm_event_context?(_dm), do: false + + defp valid_dm_call_signal_payload?("offer", payload) when is_map(payload) do + valid_sdp_signal_payload?(payload, "offer") + end + + defp valid_dm_call_signal_payload?("answer", payload) when is_map(payload) do + valid_sdp_signal_payload?(payload, "answer") + end + + defp valid_dm_call_signal_payload?("ice", payload) when is_map(payload) do + candidate = payload["candidate"] + + is_map(candidate) and non_empty_binary?(candidate["candidate"]) and + byte_size(candidate["candidate"]) <= 4096 and + (is_nil(candidate["sdpMid"]) or + (is_binary(candidate["sdpMid"]) and byte_size(candidate["sdpMid"]) <= 128)) and + (is_nil(candidate["sdpMLineIndex"]) or + (is_integer(candidate["sdpMLineIndex"]) and candidate["sdpMLineIndex"] >= 0 and + candidate["sdpMLineIndex"] <= 1024)) and + (is_nil(candidate["usernameFragment"]) or + (is_binary(candidate["usernameFragment"]) and + byte_size(candidate["usernameFragment"]) <= 256)) + end + + defp valid_dm_call_signal_payload?(_kind, _payload), do: false + + defp valid_sdp_signal_payload?(payload, expected_type) when is_map(payload) do + sdp = payload["sdp"] + + is_map(sdp) and sdp["type"] == expected_type and is_binary(sdp["sdp"]) and + byte_size(sdp["sdp"]) > 0 and byte_size(sdp["sdp"]) <= 100_000 and + String.starts_with?(sdp["sdp"], "v=0") + end + + defp validate_stream_binding(@bootstrap_server_upsert_event_type, stream_id, payload) + when is_map(payload) do + validate_exact_stream_binding(stream_id, "server", get_in(payload, ["server", "id"])) + end + + defp validate_stream_binding(event_type, stream_id, payload) + when event_type in @channel_scoped_durable_event_types and is_map(payload) do + case context_identifier(payload, "channel") do + {:ok, channel_id} -> + validate_exact_stream_binding(stream_id, "channel", channel_id) + + :error -> + {:error, :invalid_event_payload} + end + end + + defp validate_stream_binding(@dm_message_create_event_type, "dm:" <> dm_id, payload) + when is_map(payload) do + if get_in(payload, ["dm", "id"]) == dm_id do + :ok + else + {:error, :invalid_event_payload} + end + end + + defp validate_stream_binding(@dm_message_create_event_type, _stream_id, _payload), + do: {:error, :invalid_event_payload} + + defp validate_stream_binding(event_type, "dm:" <> dm_id, payload) + when event_type in [ + @voice_dm_call_invite_event_type, + @voice_dm_call_accept_event_type, + @voice_dm_call_reject_event_type, + @voice_dm_call_end_event_type + ] and is_map(payload) do + if get_in(payload, ["dm", "id"]) == dm_id do + :ok + else + {:error, :invalid_event_payload} + end + end + + defp validate_stream_binding(event_type, _stream_id, _payload) + when event_type in [ + @voice_dm_call_invite_event_type, + @voice_dm_call_accept_event_type, + @voice_dm_call_reject_event_type, + @voice_dm_call_end_event_type + ], + do: {:error, :invalid_event_payload} + + defp validate_stream_binding(_event_type, _stream_id, _payload), do: :ok + + defp validate_exact_stream_binding(stream_id, scope, identifier) + when is_binary(stream_id) and is_binary(scope) and is_binary(identifier) do + if stream_id == "#{scope}:#{identifier}" do + :ok + else + {:error, :invalid_event_payload} + end + end + + defp validate_exact_stream_binding(_stream_id, _scope, _identifier), + do: {:error, :invalid_event_payload} + + defp valid_channel_event_context?(payload) when is_map(payload) do + valid_server_context?(payload) and valid_channel_context?(payload) + end + + defp valid_server_context?(payload) when is_map(payload) do + case context_identifier(payload, "server") do + {:ok, server_id} -> valid_absolute_http_uri?(server_id) + :error -> false + end + end + + defp valid_optional_presence_context?(payload) when is_map(payload) do + refs = normalized_refs(payload) + + cond do + is_map(payload["channel"]) or is_binary(refs["channel_id"]) -> + valid_channel_event_context?(payload) + + is_map(payload["server"]) or is_binary(refs["server_id"]) -> + valid_server_context?(payload) + + true -> + true + end + end + + defp valid_channel_context?(payload) when is_map(payload) do + case context_identifier(payload, "channel") do + {:ok, channel_id} -> valid_absolute_http_uri?(channel_id) + :error -> false + end + end + + defp context_identifier(payload, field) + when is_map(payload) and field in ["server", "channel"] do + refs = normalized_refs(payload) + object_id = get_in(payload, [field, "id"]) + ref_id = refs["#{field}_id"] + + cond do + is_binary(object_id) and is_binary(ref_id) and object_id != ref_id -> + :error + + is_binary(object_id) -> + {:ok, object_id} + + is_binary(ref_id) -> + {:ok, ref_id} + + true -> + :error + end + end + + defp context_identifier(_payload, _field), do: :error + + defp normalized_refs(%{"refs" => refs}) when is_map(refs), do: refs + defp normalized_refs(%{refs: refs}) when is_map(refs), do: refs + defp normalized_refs(_payload), do: %{} + + defp valid_server_object?(server, opts \\ []) + + defp valid_server_object?(server, opts) when is_map(server) do + require_name? = Keyword.get(opts, :require_name?, false) + require_id? = Keyword.get(opts, :require_id?, true) + + cond do + require_id? and !valid_absolute_http_uri?(server["id"]) -> + false + + !valid_optional_binary?(server["name"], allow_empty?: false, required?: require_name?) -> + false + + !valid_optional_binary?(server["description"]) -> + false + + !valid_optional_binary?(server["icon_url"]) -> + false + + !valid_boolean_or_nil?(server["is_public"]) -> + false + + !valid_non_negative_integer_or_nil?(server["member_count"]) -> + false + + true -> + true + end + end + + defp valid_server_object?(_server, _opts), do: false + + defp valid_channel_object?(channel, opts \\ []) + + defp valid_channel_object?(channel, opts) when is_map(channel) do + require_name? = Keyword.get(opts, :require_name?, false) + require_id? = Keyword.get(opts, :require_id?, true) + + cond do + require_id? and !valid_absolute_http_uri?(channel["id"]) -> + false + + !valid_optional_binary?(channel["name"], allow_empty?: false, required?: require_name?) -> + false + + !valid_optional_binary?(channel["description"]) -> + false + + !valid_optional_binary?(channel["topic"]) -> + false + + !valid_non_negative_integer_or_nil?(channel["position"]) -> + false + + !valid_boolean_or_nil?(channel["is_public"]) -> + false + + !valid_boolean_or_nil?(channel["approval_mode_enabled"]) -> + false + + true -> + true + end + end + + defp valid_channel_object?(_channel, _opts), do: false + + defp valid_server_channels?(channels) when is_list(channels) do + Enum.all?(channels, &valid_channel_object?(&1, require_name?: true)) + end + + defp valid_server_channels?(_channels), do: false + + defp valid_message_content?(message) when is_map(message) do + Map.has_key?(message, "content") and is_binary(message["content"]) + end + + defp valid_message_metadata?(message) when is_map(message) do + valid_optional_binary?(message["message_type"]) and + valid_attachment_list?(message["attachments"]) and + valid_optional_iso8601?(message["created_at"]) and + valid_optional_iso8601?(message["edited_at"]) + end + + defp valid_message_channel_match?(payload, message) when is_map(payload) and is_map(message) do + case message["channel_id"] do + nil -> + true + + channel_id when is_binary(channel_id) -> + match?({:ok, ^channel_id}, context_identifier(payload, "channel")) + + _ -> + false + end + end + + defp valid_message_channel_match?(_payload, _message), do: false + + defp valid_thread_channel_match?(payload, thread) when is_map(payload) and is_map(thread) do + case thread["channel_id"] do + channel_id when is_binary(channel_id) -> + match?({:ok, ^channel_id}, context_identifier(payload, "channel")) + + _ -> + false + end + end + + defp valid_thread_channel_match?(_payload, _thread), do: false + + defp same_actor_identity?(left, right) when is_map(left) and is_map(right) do + valid_actor_payload?(left) and valid_actor_payload?(right) and + normalize_actor_identity_field(left, "uri") == normalize_actor_identity_field(right, "uri") + end + + defp same_actor_identity?(_left, _right), do: false + + defp valid_opaque_target?(target) when is_map(target) do + non_empty_binary?(target["type"]) and non_empty_binary?(target["id"]) + end + + defp valid_opaque_target?(_target), do: false + + defp valid_target?(target, allowed_types) when is_map(target) and is_list(allowed_types) do + non_empty_binary?(target["id"]) and target["type"] in allowed_types + end + + defp valid_target?(_target, _allowed_types), do: false + + defp valid_string_list?(values) when is_list(values) do + Enum.all?(values, &non_empty_binary?/1) + end + + defp valid_string_list?(_values), do: false + + defp valid_attachment_list?(nil), do: true + + defp valid_attachment_list?(attachments) when is_list(attachments) do + Enum.all?(attachments, &valid_attachment?/1) + end + + defp valid_attachment_list?(_attachments), do: false + + defp valid_attachment?(attachment) when is_map(attachment) do + authorization = attachment["authorization"] + retention = attachment["retention"] + + non_empty_binary?(attachment["id"]) and + non_empty_binary?(attachment["url"]) and + non_empty_binary?(attachment["mime_type"]) and + authorization in ["public", "signed", "origin-authenticated"] and + retention in ["origin", "rehosted", "expiring"] and + valid_non_negative_integer_or_nil?(attachment["byte_size"]) and + valid_non_negative_integer_or_nil?(attachment["width"]) and + valid_non_negative_integer_or_nil?(attachment["height"]) and + valid_non_negative_integer_or_nil?(attachment["duration_ms"]) and + valid_optional_iso8601?(attachment["expires_at"]) + end + + defp valid_attachment?(_attachment), do: false + + defp valid_non_negative_integer_or_nil?(nil), do: true + defp valid_non_negative_integer_or_nil?(value) when is_integer(value) and value >= 0, do: true + defp valid_non_negative_integer_or_nil?(_), do: false + + defp valid_positive_integer_or_nil?(nil), do: true + defp valid_positive_integer_or_nil?(value) when is_integer(value) and value > 0, do: true + defp valid_positive_integer_or_nil?(_), do: false + + defp valid_boolean_or_nil?(nil), do: true + defp valid_boolean_or_nil?(value) when is_boolean(value), do: true + defp valid_boolean_or_nil?(_), do: false + + defp valid_optional_binary?(value, opts \\ []) + + defp valid_optional_binary?(nil, opts), do: Keyword.get(opts, :required?, false) != true + + defp valid_optional_binary?(value, opts) when is_binary(value) do + allow_empty? = Keyword.get(opts, :allow_empty?, true) + required? = Keyword.get(opts, :required?, false) + trimmed = String.trim(value) + + cond do + not Elektrine.Strings.present?(trimmed) and required? -> + false + + not Elektrine.Strings.present?(trimmed) and !allow_empty? -> + false + + true -> + true + end + end + + defp valid_optional_binary?(_value, _opts), do: false + + defp valid_optional_iso8601?(nil), do: true + defp valid_optional_iso8601?(value), do: valid_iso8601?(value) + + defp valid_actor_payload?(actor) when is_map(actor) do + uri = normalize_actor_identity_field(actor, "uri") + id = normalize_actor_identity_field(actor, "id") + username = normalize_actor_identity_field(actor, "username") + domain = normalize_actor_identity_field(actor, "domain") + handle = normalize_actor_identity_field(actor, "handle") + + canonical_handle = + if is_binary(username) and is_binary(domain), + do: "#{String.downcase(username)}@#{String.downcase(domain)}" + + cond do + !valid_absolute_http_uri?(uri) -> + false + + !is_binary(username) -> + false + + !is_binary(domain) -> + false + + !is_binary(handle) -> + false + + is_binary(id) and !valid_absolute_http_uri?(id) -> + false + + String.downcase(handle) != canonical_handle -> + false + + true -> + true + end + end + + defp valid_actor_payload?(_actor), do: false + + defp normalize_actor_identity_field(actor, key) when is_map(actor) do + case Map.get(actor, key) do + value when is_binary(value) -> + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + + _ -> + nil + end + end + + defp valid_absolute_http_uri?(value) when is_binary(value) do + case URI.parse(value) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + true + + _ -> + false + end + end + + defp valid_absolute_http_uri?(_value), do: false + + defp valid_signature_map?(signature) when is_map(signature) do + signature["algorithm"] == @signature_algorithm and non_empty_binary?(signature["key_id"]) and + non_empty_binary?(signature["value"]) + end + + defp valid_signature_map?(_), do: false + + defp valid_iso8601?(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, _dt, _offset} -> true + _ -> false + end + end + + defp valid_iso8601?(_), do: false + + defp parse_int(value, _default) when is_integer(value), do: value + + defp parse_int(value, default) when is_binary(value) do + case Integer.parse(value) do + {int, _rest} -> int + :error -> default + end + end + + defp parse_int(_value, default), do: default + + defp non_empty_binary?(value) when is_binary(value) do + Elektrine.Strings.present?(value) + end + + defp non_empty_binary?(_), do: false + + defp normalize_envelope_for_signing(envelope) when is_map(envelope) do + case Map.get(envelope, "event_type") do + event_type when is_binary(event_type) -> + Map.put(envelope, "event_type", canonical_event_type(event_type)) + + _ -> + envelope + end + end + + defp canonical_event_signature_payload(envelope) when is_map(envelope) do + canonical_event_signature_payload(envelope, @protocol_id) + end + + defp canonical_event_signature_payload(envelope, protocol_identifier) when is_map(envelope) do + payload = envelope["payload"] || %{} + idempotency_key = envelope["idempotency_key"] + + [ + protocol_identifier, + to_string(envelope["protocol_version"] || ""), + to_string(envelope["event_type"] || ""), + to_string(envelope["event_id"] || ""), + to_string(envelope["origin_domain"] || ""), + to_string(envelope["stream_id"] || ""), + to_string(parse_int(envelope["sequence"], 0)), + to_string(envelope["sent_at"] || ""), + to_string(idempotency_key || ""), + canonical_json(payload) + ] + |> Enum.join("\n") + end + + defp canonical_json(value) when is_map(value) do + value + |> Enum.map(fn {key, val} -> {to_string(key), val} end) + |> Enum.sort_by(&elem(&1, 0)) + |> Enum.map_join(",", fn {key, val} -> + Jason.encode!(key) <> ":" <> canonical_json(val) + end) + |> then(fn body -> "{" <> body <> "}" end) + end + + defp canonical_json(value) when is_list(value) do + value + |> Enum.map_join(",", &canonical_json/1) + |> then(fn body -> "[" <> body <> "]" end) + end + + defp canonical_json(value), do: Jason.encode!(value) + + defp canonical_path(nil), do: "/" + + defp canonical_path(path) when is_binary(path) do + trimmed = String.trim(path) + + cond do + not Elektrine.Strings.present?(trimmed) -> "/" + String.starts_with?(trimmed, "/") -> trimmed + true -> "/" <> trimmed + end + end + + defp canonical_path(path), do: canonical_path(to_string(path)) + + defp canonical_query_string(nil), do: "" + defp canonical_query_string(query) when is_binary(query), do: String.trim(query) + defp canonical_query_string(query), do: to_string(query) + + defp canonical_content_digest(nil), do: body_digest("") + + defp canonical_content_digest(content_digest) when is_binary(content_digest) do + case String.trim(content_digest) do + "" -> body_digest("") + value -> value + end + end + + defp canonical_content_digest(content_digest), + do: canonical_content_digest(to_string(content_digest)) + + defp schema_name_alias(name), do: Map.get(@schema_name_aliases, name, name) +end diff --git a/apps/elektrine/lib/elektrine/messaging/auto_mod_rule.ex b/apps/elektrine/lib/elektrine/messaging/auto_mod_rule.ex new file mode 100644 index 0000000..d1e47b7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/auto_mod_rule.ex @@ -0,0 +1,40 @@ +defmodule Elektrine.Messaging.AutoModRule do + @moduledoc """ + Schema for automated moderation rules that filter content. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "auto_mod_rules" do + field :name, :string + field :rule_type, :string + field :pattern, :string + field :action, :string + field :enabled, :boolean, default: true + + belongs_to :conversation, Elektrine.Social.Conversation + belongs_to :created_by, Elektrine.Accounts.User + + timestamps() + end + + @doc false + def changeset(rule, attrs) do + rule + |> cast(attrs, [ + :conversation_id, + :name, + :rule_type, + :pattern, + :action, + :enabled, + :created_by_id + ]) + |> validate_required([:conversation_id, :name, :rule_type, :pattern, :action, :created_by_id]) + |> validate_inclusion(:rule_type, ["keyword", "link_domain", "spam_pattern"]) + |> validate_inclusion(:action, ["flag", "remove", "hold_for_review"]) + |> validate_length(:name, min: 3, max: 100) + |> foreign_key_constraint(:conversation_id) + |> foreign_key_constraint(:created_by_id) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/channel_categories.ex b/apps/elektrine/lib/elektrine/messaging/channel_categories.ex new file mode 100644 index 0000000..8a14d0d --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/channel_categories.ex @@ -0,0 +1,228 @@ +defmodule Elektrine.Messaging.ChannelCategories do + @moduledoc """ + Context for channel categories inside community servers. + + Categories group a server's channels in the sidebar. All mutating functions + require the acting user to hold the `manage_channels` permission on the + server, which maps to the built-in `owner` and `admin` roles (see + `Elektrine.Messaging.RoomACL` builtin role definitions). + """ + + import Ecto.Query, warn: false + + alias Elektrine.Messaging.{ + ChannelCategory, + ChatConversation, + Server, + ServerMember + } + + alias Elektrine.Repo + + # Built-in server roles that carry the `manage_channels` permission. + @manage_channel_roles ["owner", "admin"] + + @doc """ + Lists a server's categories ordered by position (then id for stability). + """ + def list_categories(server_id) when is_integer(server_id) do + from(c in ChannelCategory, + where: c.server_id == ^server_id, + order_by: [asc: c.position, asc: c.id] + ) + |> Repo.all() + end + + def list_categories(_server_id), do: [] + + @doc """ + Lists a server's categories with their channels preloaded. + + Returns `{categories, uncategorized_channels}` where categories are ordered + by position and each category's channels (as well as the uncategorized + list) are ordered by `channel_position`. + """ + def list_categories_with_channels(server_id) when is_integer(server_id) do + channel_query = + from(conversation in ChatConversation, + where: conversation.type == "channel", + order_by: [asc: conversation.channel_position, asc: conversation.inserted_at] + ) + + categories = + from(c in ChannelCategory, + where: c.server_id == ^server_id, + order_by: [asc: c.position, asc: c.id], + preload: [channels: ^channel_query] + ) + |> Repo.all() + + uncategorized = + from(conversation in ChatConversation, + where: + conversation.server_id == ^server_id and conversation.type == "channel" and + is_nil(conversation.category_id), + order_by: [asc: conversation.channel_position, asc: conversation.inserted_at] + ) + |> Repo.all() + + {categories, uncategorized} + end + + @doc """ + Gets a category by id, or `nil`. + """ + def get_category(category_id) when is_integer(category_id) do + Repo.get(ChannelCategory, category_id) + end + + def get_category(_category_id), do: nil + + @doc """ + Creates a category in a server. Requires `manage_channels`. + """ + def create_category(server_id, user_id, attrs) when is_map(attrs) do + with %Server{} <- Repo.get(Server, server_id), + :ok <- authorize_manage_channels(server_id, user_id) do + params = %{ + name: attrs[:name] || attrs["name"], + position: attrs[:position] || attrs["position"] || next_category_position(server_id), + server_id: server_id + } + + %ChannelCategory{} + |> ChannelCategory.changeset(params) + |> Repo.insert() + else + nil -> {:error, :not_found} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Renames a category. Requires `manage_channels` on the category's server. + """ + def rename_category(category_id, user_id, name) do + with %ChannelCategory{} = category <- get_category(category_id), + :ok <- authorize_manage_channels(category.server_id, user_id) do + category + |> ChannelCategory.changeset(%{name: name}) + |> Repo.update() + else + nil -> {:error, :not_found} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Deletes a category. The category's channels are kept and their + `category_id` is nullified. Requires `manage_channels`. + """ + def delete_category(category_id, user_id) do + with %ChannelCategory{} = category <- get_category(category_id), + :ok <- authorize_manage_channels(category.server_id, user_id) do + Repo.transaction(fn -> + from(conversation in ChatConversation, where: conversation.category_id == ^category.id) + |> Repo.update_all(set: [category_id: nil]) + + case Repo.delete(category) do + {:ok, deleted} -> deleted + {:error, reason} -> Repo.rollback(reason) + end + end) + else + nil -> {:error, :not_found} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Reorders a server's categories to match the given list of category ids. + Ids not belonging to the server are ignored; categories missing from the + list keep their relative order after the reordered ones. + Requires `manage_channels`. + """ + def reorder_categories(server_id, user_id, ordered_ids) when is_list(ordered_ids) do + with :ok <- authorize_manage_channels(server_id, user_id) do + categories = list_categories(server_id) + categories_by_id = Map.new(categories, &{&1.id, &1}) + + listed = Enum.filter(ordered_ids, &Map.has_key?(categories_by_id, &1)) + missing = Enum.map(categories, & &1.id) -- listed + + (listed ++ missing) + |> Enum.with_index() + |> Enum.each(fn {category_id, position} -> + from(c in ChannelCategory, where: c.id == ^category_id) + |> Repo.update_all(set: [position: position]) + end) + + {:ok, list_categories(server_id)} + end + end + + @doc """ + Assigns a channel to a category (or clears the assignment when + `category_id` is `nil`). The category must belong to the channel's server. + Requires `manage_channels`. + """ + def assign_channel_to_category(channel_id, user_id, category_id) do + with %ChatConversation{type: "channel", server_id: server_id} = channel + when is_integer(server_id) <- Repo.get(ChatConversation, channel_id), + :ok <- authorize_manage_channels(server_id, user_id), + {:ok, resolved_category_id} <- resolve_category_for_server(category_id, server_id) do + channel + |> ChatConversation.changeset(%{category_id: resolved_category_id}) + |> Repo.update() + else + nil -> {:error, :not_found} + %ChatConversation{} -> {:error, :not_a_server_channel} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Validates that `category_id` (possibly `nil`) references a category of the + given server. Returns `{:ok, category_id | nil}` or + `{:error, :category_not_in_server}`. + """ + def resolve_category_for_server(nil, _server_id), do: {:ok, nil} + + def resolve_category_for_server(category_id, server_id) when is_integer(category_id) do + case get_category(category_id) do + %ChannelCategory{server_id: ^server_id} -> {:ok, category_id} + _ -> {:error, :category_not_in_server} + end + end + + def resolve_category_for_server(_category_id, _server_id), + do: {:error, :category_not_in_server} + + defp authorize_manage_channels(server_id, user_id) + when is_integer(server_id) and is_integer(user_id) do + member = + from(sm in ServerMember, + where: sm.server_id == ^server_id and sm.user_id == ^user_id and is_nil(sm.left_at) + ) + |> Repo.one() + + case member do + %ServerMember{role: role} when role in @manage_channel_roles -> :ok + _ -> {:error, :unauthorized} + end + end + + defp authorize_manage_channels(_server_id, _user_id), do: {:error, :unauthorized} + + defp next_category_position(server_id) do + from(c in ChannelCategory, + where: c.server_id == ^server_id, + select: max(c.position) + ) + |> Repo.one() + |> case do + nil -> 0 + position -> position + 1 + end + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/channel_category.ex b/apps/elektrine/lib/elektrine/messaging/channel_category.ex new file mode 100644 index 0000000..58f5851 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/channel_category.ex @@ -0,0 +1,33 @@ +defmodule Elektrine.Messaging.ChannelCategory do + @moduledoc """ + Schema for channel categories inside a community server. + + Categories group a server's channels in the sidebar. Channels reference a + category through `chat_conversations.category_id`; deleting a category + nullifies that reference without deleting the channels. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "messaging_channel_categories" do + field :name, :string + field :position, :integer, default: 0 + + belongs_to :server, Elektrine.Messaging.Server + + has_many :channels, Elektrine.Messaging.ChatConversation, foreign_key: :category_id + + timestamps() + end + + @doc false + def changeset(category, attrs) do + category + |> cast(attrs, [:name, :position, :server_id]) + |> validate_required([:name, :server_id]) + |> update_change(:name, &String.trim/1) + |> validate_length(:name, min: 1, max: 80) + |> validate_number(:position, greater_than_or_equal_to: 0) + |> foreign_key_constraint(:server_id) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_conversation.ex b/apps/elektrine/lib/elektrine/messaging/chat_conversation.ex new file mode 100644 index 0000000..7122cb6 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_conversation.ex @@ -0,0 +1,97 @@ +defmodule Elektrine.Messaging.ChatConversation do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "chat_conversations" do + field :name, :string + field :description, :string + field :type, :string + field :avatar_url, :string + field :is_public, :boolean, default: false + field :member_count, :integer, default: 0 + field :last_message_at, :utc_datetime + field :archived, :boolean, default: false + field :hash, :string + field :slow_mode_seconds, :integer, default: 0 + field :approval_mode_enabled, :boolean, default: false + field :approval_threshold_posts, :integer, default: 3 + field :channel_topic, :string + field :channel_position, :integer, default: 0 + field :federated_source, :string + field :is_federated_mirror, :boolean, default: false + + belongs_to :remote_group_actor, Elektrine.ActivityPub.Actor + belongs_to :creator, Elektrine.Accounts.User + belongs_to :server, Elektrine.Messaging.Server + belongs_to :category, Elektrine.Messaging.ChannelCategory + has_many :channels, __MODULE__, foreign_key: :server_id + has_many :members, Elektrine.Messaging.ChatConversationMember, foreign_key: :conversation_id + has_many :users, through: [:members, :user] + has_many :messages, Elektrine.Messaging.ChatMessage, foreign_key: :conversation_id + + timestamps() + end + + @doc false + def changeset(conversation, attrs) do + conversation + |> cast(attrs, [ + :name, + :description, + :type, + :creator_id, + :avatar_url, + :is_public, + :member_count, + :last_message_at, + :archived, + :hash, + :slow_mode_seconds, + :approval_mode_enabled, + :approval_threshold_posts, + :channel_topic, + :channel_position, + :server_id, + :category_id, + :federated_source, + :is_federated_mirror, + :remote_group_actor_id + ]) + |> validate_required([:type]) + |> validate_inclusion(:type, ["dm", "group", "channel", "voice_channel"]) + |> validate_length(:channel_topic, max: 300) + |> validate_number(:channel_position, greater_than_or_equal_to: 0) + |> validate_length(:name, max: 100) + |> validate_length(:description, max: 500) + |> generate_hash_if_needed() + |> foreign_key_constraint(:category_id) + |> unique_constraint(:hash) + end + + def dm_changeset(conversation, attrs) do + changeset(conversation, Map.put(attrs, :type, "dm")) + end + + def group_changeset(conversation, attrs) do + changeset(conversation, Map.put(attrs, :type, "group")) + end + + def channel_changeset(conversation, attrs) do + changeset(conversation, Map.put(attrs, :type, "channel")) + end + + def voice_channel_changeset(conversation, attrs) do + changeset(conversation, Map.put(attrs, :type, "voice_channel")) + end + + defp generate_hash_if_needed(changeset) do + case get_field(changeset, :hash) do + hash when is_binary(hash) and hash != "" -> + changeset + + _ -> + put_change(changeset, :hash, :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower)) + end + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_conversation_encryption_key.ex b/apps/elektrine/lib/elektrine/messaging/chat_conversation_encryption_key.ex new file mode 100644 index 0000000..5ce1adb --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_conversation_encryption_key.ex @@ -0,0 +1,31 @@ +defmodule Elektrine.Messaging.ChatConversationEncryptionKey do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "chat_conversation_encryption_keys" do + field :key_uid, :string + field :algorithm, :string, default: "AES-256-GCM" + field :active, :boolean, default: true + field :metadata, :map, default: %{} + + belongs_to :conversation, Elektrine.Messaging.ChatConversation + belongs_to :created_by, Elektrine.Accounts.User + + has_many :recipients, Elektrine.Messaging.ChatConversationKeyRecipient, + foreign_key: :conversation_key_id + + timestamps() + end + + def changeset(encryption_key, attrs) do + encryption_key + |> cast(attrs, [:conversation_id, :key_uid, :created_by_id, :algorithm, :active, :metadata]) + |> validate_required([:conversation_id, :key_uid, :algorithm]) + |> validate_length(:key_uid, min: 12, max: 128) + |> validate_inclusion(:algorithm, ["AES-256-GCM"]) + |> foreign_key_constraint(:conversation_id) + |> foreign_key_constraint(:created_by_id) + |> unique_constraint([:conversation_id, :key_uid]) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_conversation_key_recipient.ex b/apps/elektrine/lib/elektrine/messaging/chat_conversation_key_recipient.ex new file mode 100644 index 0000000..668548f --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_conversation_key_recipient.ex @@ -0,0 +1,55 @@ +defmodule Elektrine.Messaging.ChatConversationKeyRecipient do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "chat_conversation_key_recipients" do + field :device_id, :string + field :wrapped_key, :map + + belongs_to :conversation_key, Elektrine.Messaging.ChatConversationEncryptionKey + belongs_to :user, Elektrine.Accounts.User + + timestamps() + end + + def changeset(recipient, attrs) do + recipient + |> cast(attrs, [:conversation_key_id, :user_id, :device_id, :wrapped_key]) + |> validate_required([:conversation_key_id, :user_id, :device_id, :wrapped_key]) + |> validate_length(:device_id, min: 8, max: 128) + |> validate_wrapped_key() + |> foreign_key_constraint(:conversation_key_id) + |> foreign_key_constraint(:user_id) + |> unique_constraint([:conversation_key_id, :user_id, :device_id]) + end + + defp validate_wrapped_key(changeset) do + case get_field(changeset, :wrapped_key) do + %{} = wrapped_key -> + version = payload_value(wrapped_key, "version", :version) + key_algorithm = payload_value(wrapped_key, "key_algorithm", :key_algorithm) + encrypted_key = payload_value(wrapped_key, "encrypted_key", :encrypted_key) + + if version in [1, "1"] and key_algorithm == "RSA-OAEP-SHA256" and + valid_base64?(encrypted_key) do + changeset + else + add_error(changeset, :wrapped_key, "must be a valid wrapped chat key") + end + + _ -> + add_error(changeset, :wrapped_key, "must be a valid wrapped chat key") + end + end + + defp payload_value(payload, string_key, atom_key) do + Map.get(payload, string_key) || Map.get(payload, atom_key) + end + + defp valid_base64?(value) when is_binary(value) do + match?({:ok, decoded} when byte_size(decoded) > 16, Base.decode64(value)) + end + + defp valid_base64?(_), do: false +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_conversation_member.ex b/apps/elektrine/lib/elektrine/messaging/chat_conversation_member.ex new file mode 100644 index 0000000..3757b26 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_conversation_member.ex @@ -0,0 +1,8 @@ +defmodule Elektrine.Messaging.ChatConversationMember do + @moduledoc false + + use Elektrine.Messaging.MemberSchemaBase, + table: "chat_conversation_members", + conversation: Elektrine.Messaging.ChatConversation, + message: Elektrine.Messaging.ChatMessage +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_conversations.ex b/apps/elektrine/lib/elektrine/messaging/chat_conversations.ex new file mode 100644 index 0000000..0f8174a --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_conversations.ex @@ -0,0 +1,1176 @@ +defmodule Elektrine.Messaging.ChatConversations do + @moduledoc "Context for chat conversations backed by chat-specific tables." + + import Ecto.Query, warn: false + + alias Elektrine.Accounts + alias Elektrine.Accounts.User + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor, as: ActivityPubActor + alias Elektrine.Messaging.ChatConversation + alias Elektrine.Messaging.ChatConversationMember + alias Elektrine.Messaging.ChatMessage + alias Elektrine.Messaging.Federation + alias Elektrine.Messaging.Federation.DirectMessageState + alias Elektrine.Messaging.Federation.State, as: FederationState + alias Elektrine.Messaging.FederationInviteState + alias Elektrine.Messaging.FederationMembershipState + alias Elektrine.Messaging.Membership + alias Elektrine.Messaging.RateLimiter + alias Elektrine.Profiles + alias Elektrine.Repo + + @doc "Returns chat conversations for a user." + def list_conversations(user_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + type = Keyword.get(opts, :type) + + query = + from(c in ChatConversation, + join: cm in ChatConversationMember, + on: c.id == cm.conversation_id and cm.user_id == ^user_id, + where: is_nil(cm.left_at), + order_by: [desc: cm.pinned, desc: c.last_message_at, desc: c.updated_at], + preload: [members: [user: [:profile]]] + ) + |> maybe_filter_conversation_type(type) + |> maybe_limit_conversations(limit) + + conversations = Repo.all(query) + conversation_ids = Enum.map(conversations, & &1.id) + + latest_messages = + from(m in ChatMessage, + where: m.conversation_id in ^conversation_ids and is_nil(m.deleted_at), + distinct: m.conversation_id, + order_by: [asc: m.conversation_id, desc: m.inserted_at, desc: m.id], + preload: [sender: [:profile]] + ) + |> Repo.all() + |> ChatMessage.decrypt_messages() + |> Enum.group_by(& &1.conversation_id) + + Enum.map(conversations, fn conversation -> + %{conversation | messages: Map.get(latest_messages, conversation.id, [])} + end) + |> filter_blocked_conversations(user_id) + end + + defp maybe_filter_conversation_type(query, type) when type in ["dm", "group", "channel"] do + from(c in query, where: c.type == ^type) + end + + defp maybe_filter_conversation_type(query, _type), do: query + + defp maybe_limit_conversations(query, limit) when is_integer(limit) and limit > 0 do + from(c in query, limit: ^limit) + end + + defp maybe_limit_conversations(query, _limit), do: query + + def get_conversation!(id, user_id) do + query = + from(c in ChatConversation, + join: cm in ChatConversationMember, + on: c.id == cm.conversation_id and cm.user_id == ^user_id, + where: c.id == ^id and is_nil(cm.left_at), + preload: [creator: [], members: [user: [:profile]]] + ) + + case Repo.one(query) do + nil -> + {:error, :not_found} + + conversation -> + {:ok, %{conversation | messages: load_recent_conversation_messages(conversation)}} + end + end + + def get_conversation_for_chat!(id, user_id) do + query = + from(c in ChatConversation, + join: cm in ChatConversationMember, + on: c.id == cm.conversation_id and cm.user_id == ^user_id, + where: c.id == ^id and is_nil(cm.left_at), + preload: [creator: [], members: [user: [:profile]]] + ) + + case Repo.one(query) do + nil -> {:error, :not_found} + conversation -> {:ok, %{conversation | messages: []}} + end + end + + def get_conversation_by_hash(hash) do + from(c in ChatConversation, where: c.hash == ^hash, preload: [:creator, members: :user]) + |> Repo.one() + end + + def get_conversation_for_chat_by_hash!(hash, user_id) do + query = + from(c in ChatConversation, + join: cm in ChatConversationMember, + on: c.id == cm.conversation_id and cm.user_id == ^user_id, + where: c.hash == ^hash and is_nil(cm.left_at), + preload: [creator: [], members: [user: [:profile]]] + ) + + case Repo.one(query) do + nil -> {:error, :not_found} + conversation -> {:ok, %{conversation | messages: []}} + end + end + + def create_dm_conversation(user1_id, user2_id) do + if RateLimiter.can_create_dm?(user1_id) do + case Elektrine.Privacy.can_send_dm?(user1_id, user2_id) do + {:error, reason} -> + {:error, reason} + + {:ok, :allowed} -> + existing_dm = + from(c in ChatConversation, + join: cm1 in ChatConversationMember, + on: c.id == cm1.conversation_id, + join: cm2 in ChatConversationMember, + on: c.id == cm2.conversation_id, + where: + c.type == "dm" and cm1.user_id == ^user1_id and is_nil(cm1.left_at) and + cm2.user_id == ^user2_id and is_nil(cm2.left_at), + limit: 1 + ) + + case Repo.one(existing_dm) do + %ChatConversation{} = conversation -> + {:ok, conversation} + + nil -> + Repo.transaction(fn -> + case spend_first_dm_credit(user1_id, user2_id) do + :ok -> :ok + {:error, reason} -> Repo.rollback(reason) + end + + RateLimiter.record_dm_creation(user1_id) + + {:ok, conversation} = + %ChatConversation{} + |> ChatConversation.dm_changeset(%{creator_id: user1_id}) + |> Repo.insert() + + {:ok, _} = add_member_to_conversation(conversation.id, user1_id) + {:ok, _} = add_member_to_conversation(conversation.id, user2_id) + conversation + end) + end + end + else + {:error, :rate_limited} + end + end + + def create_remote_dm_conversation(local_user_id, remote_handle, attrs \\ %{}) do + with :ok <- ensure_dm_creation_allowed(local_user_id), + {:ok, recipient} <- normalize_remote_dm_handle(remote_handle), + :ok <- ensure_remote_recipient_domain(local_user_id, recipient), + %{} <- Federation.outgoing_peer(recipient.domain) do + remote_source = DirectMessageState.remote_dm_source(recipient.handle) + display_name = remote_dm_display_name(attrs, recipient) + avatar_url = remote_dm_avatar_url(attrs) + + existing_remote_dm = + from(c in ChatConversation, + join: cm in ChatConversationMember, + on: c.id == cm.conversation_id, + where: + c.type == "dm" and c.federated_source == ^remote_source and + cm.user_id == ^local_user_id and is_nil(cm.left_at), + limit: 1 + ) + + case Repo.one(existing_remote_dm) do + %ChatConversation{} = conversation -> + {:ok, conversation} + + nil -> + Repo.transaction(fn -> + case spend_first_dm_credit(local_user_id, "remote:#{recipient.handle}") do + :ok -> :ok + {:error, reason} -> Repo.rollback(reason) + end + + RateLimiter.record_dm_creation(local_user_id) + + {:ok, conversation} = + %ChatConversation{} + |> ChatConversation.dm_changeset(%{ + creator_id: local_user_id, + name: display_name, + avatar_url: avatar_url, + federated_source: remote_source + }) + |> Repo.insert() + + {:ok, _} = add_member_to_conversation(conversation.id, local_user_id) + update_member_count(conversation.id) + conversation + end) + end + else + {:redirect_local_dm, remote_user_id} -> + create_dm_conversation(local_user_id, remote_user_id) + + nil -> + {:error, :unknown_peer} + + {:error, _} = error -> + error + + false -> + {:error, :rate_limited} + end + end + + def remote_dm_conversation?(%ChatConversation{type: "dm", federated_source: source}) + when is_binary(source) do + is_binary(DirectMessageState.remote_dm_handle_from_source(source)) + end + + def remote_dm_conversation?(_), do: false + + def remote_dm_handle(%ChatConversation{} = conversation) do + if remote_dm_conversation?(conversation), + do: DirectMessageState.remote_dm_handle_from_source(conversation.federated_source), + else: nil + end + + def remote_dm_handle(_), do: nil + + def check_creation_limit(user_id, type) do + max_channels = 10 + max_groups = 20 + + case type do + "channel" -> + count = + from(c in ChatConversation, + where: c.creator_id == ^user_id and c.type == "channel", + select: count(c.id) + ) + |> Repo.one() + + if count < max_channels, do: :ok, else: {:error, :limit_exceeded} + + "group" -> + count = + from(c in ChatConversation, + where: c.creator_id == ^user_id and c.type == "group", + select: count(c.id) + ) + |> Repo.one() + + if count < max_groups, do: :ok, else: {:error, :limit_exceeded} + + _ -> + :ok + end + end + + def create_group_conversation(creator_id, attrs, member_ids \\ []) do + case check_creation_limit(creator_id, "group") do + :ok -> + attrs = Map.put(attrs, :creator_id, creator_id) + + Repo.transaction(fn -> + {:ok, conversation} = + %ChatConversation{} |> ChatConversation.group_changeset(attrs) |> Repo.insert() + + {:ok, _} = add_member_to_conversation(conversation.id, creator_id, "admin") + + {successful_adds, failed_adds} = + Enum.reduce(member_ids, {[], []}, fn user_id, {success, failed} -> + case add_member_to_conversation(conversation.id, user_id, "member", creator_id) do + {:ok, _} -> {[user_id | success], failed} + {:error, _reason} -> {success, [user_id | failed]} + end + end) + + update_member_count(conversation.id) + all_member_ids = [creator_id | successful_adds] + + Enum.each(all_member_ids, fn user_id -> + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}", + {:added_to_conversation, %{conversation_id: conversation.id}} + ) + end) + + {conversation, length(failed_adds)} + end) + |> case do + {:ok, {conversation, 0}} -> {:ok, conversation} + {:ok, {conversation, failed_count}} -> {:ok, conversation, failed_count} + error -> error + end + + {:error, :limit_exceeded} -> + {:error, :group_limit_exceeded} + end + end + + def create_channel(creator_id, attrs) do + case check_creation_limit(creator_id, "channel") do + :ok -> + attrs = Map.put(attrs, :creator_id, creator_id) + + Repo.transaction(fn -> + {:ok, conversation} = + %ChatConversation{} |> ChatConversation.channel_changeset(attrs) |> Repo.insert() + + {:ok, _} = add_member_to_conversation(conversation.id, creator_id, "admin") + update_member_count(conversation.id) + conversation + end) + + {:error, :limit_exceeded} -> + {:error, :channel_limit_exceeded} + end + end + + def update_conversation(conversation, attrs) do + conversation |> ChatConversation.changeset(attrs) |> Repo.update() + end + + def delete_conversation(conversation_id) do + case Repo.get(ChatConversation, conversation_id) do + nil -> {:error, :not_found} + conversation -> Repo.delete(conversation) + end + end + + def list_public_channels(opts \\ []) do + limit = Keyword.get(opts, :limit, 20) + + from(c in ChatConversation, + where: c.type == "channel" and c.is_public == true and is_nil(c.server_id), + order_by: [desc: c.member_count, desc: c.last_message_at], + limit: ^limit, + preload: [:creator] + ) + |> Repo.all() + end + + def list_public_groups(opts \\ []) do + limit = Keyword.get(opts, :limit, 20) + + from(c in ChatConversation, + where: c.type == "group" and c.is_public == true, + order_by: [desc: c.member_count, desc: c.last_message_at], + limit: ^limit, + preload: [:creator] + ) + |> Repo.all() + end + + def search_public_conversations(query, current_user_id, limit \\ 10) do + search_term = "%#{query}%" + + user_conversation_ids = + from(cm in ChatConversationMember, + where: cm.user_id == ^current_user_id and is_nil(cm.left_at), + select: cm.conversation_id + ) + |> Repo.all() + + from(c in ChatConversation, + where: + c.is_public == true and c.type in ["group", "channel"] and is_nil(c.server_id) and + c.id not in ^user_conversation_ids and + (ilike(c.name, ^search_term) or ilike(c.description, ^search_term)), + order_by: [desc: c.member_count, desc: c.last_message_at], + limit: ^limit, + preload: [:creator] + ) + |> Repo.all() + end + + def add_member_to_conversation( + conversation_id, + user_id, + role \\ "member", + added_by_user_id \\ nil + ) do + Membership.add_member( + ChatConversationMember, + ChatConversation, + conversation_id, + user_id, + role, + added_by_user_id + ) + end + + def add_member_to_conversation_without_federation(conversation_id, user_id, role \\ "member") do + Membership.add_member_without_federation( + ChatConversationMember, + ChatConversation, + conversation_id, + user_id, + role + ) + end + + def remove_member_from_conversation(conversation_id, user_id, actor_user_id \\ nil) do + Membership.remove_member( + ChatConversationMember, + ChatConversation, + conversation_id, + user_id, + actor_user_id + ) + end + + def get_conversation_member(conversation_id, user_id), + do: Membership.get_member(ChatConversationMember, conversation_id, user_id) + + def get_conversation_members(conversation_id), + do: Membership.get_members(ChatConversationMember, conversation_id) + + def list_pending_remote_join_requests(conversation_id) when is_integer(conversation_id) do + case remote_join_review_conversation(conversation_id) do + {:ok, _conversation} -> + local_domain = Federation.local_domain() + + from(state in FederationMembershipState, + where: state.conversation_id == ^conversation_id and state.state == "invited", + where: state.origin_domain != ^local_domain, + preload: [:remote_actor], + order_by: [asc: state.inserted_at] + ) + |> Repo.all() + |> Enum.filter(&pending_remote_join_request?/1) + |> Enum.map(&format_pending_remote_join_request/1) + + {:error, _reason} -> + [] + end + end + + def list_pending_remote_join_requests(_conversation_id), do: [] + + def approve_remote_join_request(conversation_id, remote_actor_id, reviewer_user_id) + when is_integer(conversation_id) and is_integer(remote_actor_id) and + is_integer(reviewer_user_id), + do: + review_remote_join_request(conversation_id, remote_actor_id, reviewer_user_id, "accepted") + + def approve_remote_join_request(_, _, _), do: {:error, :not_found} + + def decline_remote_join_request(conversation_id, remote_actor_id, reviewer_user_id) + when is_integer(conversation_id) and is_integer(remote_actor_id) and + is_integer(reviewer_user_id), + do: + review_remote_join_request(conversation_id, remote_actor_id, reviewer_user_id, "declined") + + def decline_remote_join_request(_, _, _), do: {:error, :not_found} + + def promote_to_admin(conversation_id, user_id, promoter_id) do + Membership.promote_to_admin( + ChatConversationMember, + ChatConversation, + conversation_id, + user_id, + promoter_id, + &admin?/2, + &builtin_room_role_definition/1 + ) + end + + def demote_from_admin(conversation_id, user_id, demoter_id) do + Membership.demote_from_admin( + ChatConversationMember, + ChatConversation, + conversation_id, + user_id, + demoter_id, + &admin?/2, + &builtin_room_role_definition/1 + ) + end + + def update_member_role(conversation_id, user_id, new_role), + do: update_member_role(conversation_id, user_id, new_role, nil) + + def update_member_role(conversation_id, user_id, new_role, actor_user_id) do + Membership.update_member_role( + ChatConversationMember, + ChatConversation, + conversation_id, + user_id, + new_role, + actor_user_id, + &builtin_room_role_definition/1 + ) + end + + def join_conversation(conversation_id, user_id) do + case get_conversation_basic(conversation_id) do + {:error, _} = error -> + error + + {:ok, conversation} -> + cond do + remote_mirror_channel_join?(conversation) -> + request_mirrored_channel_join(conversation, user_id) + + conversation.type == "channel" and not is_nil(conversation.server_id) -> + {:error, :must_join_server} + + conversation.type not in ["channel", "group"] -> + {:error, :not_joinable} + + conversation.is_public != true -> + {:error, :not_public_channel} + + true -> + existing_member = + from(cm in ChatConversationMember, + where: cm.conversation_id == ^conversation_id and cm.user_id == ^user_id + ) + |> Repo.one() + + case existing_member do + nil -> + role = if conversation.type == "channel", do: "readonly", else: "member" + add_member_to_conversation(conversation_id, user_id, role) + + %ChatConversationMember{left_at: nil} -> + {:error, :already_member} + + %ChatConversationMember{} = member -> + case member + |> ChatConversationMember.changeset(%{ + left_at: nil, + joined_at: DateTime.utc_now() + }) + |> Repo.update() do + {:ok, updated_member} = result -> + update_member_count(conversation_id) + + # Re-announce the rejoining member to federated followers. + # The fresh-join branch and join_channel/2 both publish via + # add_member_to_conversation; this reactivation path must do + # the same or remote mirrors silently miss the rejoin. + _ = + Federation.publish_membership_state( + conversation_id, + user_id, + "active", + updated_member.role || "member" + ) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{conversation_id}", + {:member_joined, user_id} + ) + + result + + error -> + error + end + end + end + end + end + + def join_channel(channel_id, user_id) do + Membership.join_channel( + ChatConversationMember, + ChatConversation, + channel_id, + user_id, + &add_member_to_conversation(&1, &2, &3) + ) + end + + def pin_conversation(conversation_id, user_id), + do: Membership.pin(ChatConversationMember, conversation_id, user_id) + + def unpin_conversation(conversation_id, user_id), + do: Membership.unpin(ChatConversationMember, conversation_id, user_id) + + def leave_conversation(conversation_id, user_id) do + case get_conversation_member(conversation_id, user_id) do + nil -> + {:error, :not_a_member} + + member -> + conversation = Repo.get(ChatConversation, conversation_id) + + if conversation && conversation.creator_id == user_id && + conversation.type in ["group", "channel"] do + other_members = + from(cm in ChatConversationMember, + where: + cm.conversation_id == ^conversation_id and cm.user_id != ^user_id and + is_nil(cm.left_at) + ) + |> Repo.all() + + if other_members != [] do + {:error, :owner_must_transfer} + else + complete_leave(member, conversation_id, user_id) + end + else + complete_leave(member, conversation_id, user_id) + end + end + end + + def user_has_conversations?(user_id) do + from(cm in ChatConversationMember, + join: c in ChatConversation, + on: c.id == cm.conversation_id, + where: cm.user_id == ^user_id and is_nil(cm.left_at), + limit: 1, + select: 1 + ) + |> Repo.exists?() + end + + def admin?(conversation_id, user_id) do + member = + from(cm in ChatConversationMember, + where: + cm.conversation_id == ^conversation_id and cm.user_id == ^user_id and is_nil(cm.left_at) + ) + |> Repo.one() + + # Owners are implicitly admins. (Note: the "owner" role is currently + # vestigial on the chat side - creators are stored as "admin" and creator + # protection keys off conversation.creator_id - but federated/imported + # members can carry it, so treat it as admin-or-higher here.) + case member do + %ChatConversationMember{role: role} when role in ["owner", "admin"] -> true + _ -> false + end + end + + defp complete_leave(member, conversation_id, user_id) do + result = member |> ChatConversationMember.remove_member_changeset() |> Repo.update() + + case result do + {:ok, _} -> + update_member_count(conversation_id) + _ = Federation.publish_membership_state(conversation_id, user_id, "left") + result + + error -> + error + end + end + + defp filter_blocked_conversations(conversations, user_id) do + Enum.filter(conversations, fn conversation -> + case conversation.type do + "dm" -> + other_user = + Enum.find(conversation.members, fn member -> + member.user_id != user_id and is_nil(member.left_at) + end) + + case other_user do + nil -> + true + + %{user_id: other_user_id} -> + not (Elektrine.Accounts.user_blocked?(user_id, other_user_id) or + Elektrine.Accounts.user_blocked?(other_user_id, user_id)) + end + + _ -> + true + end + end) + end + + defp load_recent_conversation_messages(%ChatConversation{id: conversation_id}) do + from(m in ChatMessage, + where: m.conversation_id == ^conversation_id and is_nil(m.deleted_at), + order_by: [desc: m.inserted_at], + limit: 50, + preload: [:sender, :link_preview, reply_to: [:sender], reactions: [:user, :remote_actor]] + ) + |> Repo.all() + |> ChatMessage.decrypt_messages() + end + + defp get_conversation_basic(conversation_id) do + case Repo.get(ChatConversation, conversation_id) do + nil -> {:error, :not_found} + conversation -> {:ok, conversation} + end + end + + defp remote_mirror_channel_join?(%ChatConversation{ + type: "channel", + is_federated_mirror: true, + server_id: server_id + }) + when is_integer(server_id), do: true + + defp remote_mirror_channel_join?(_conversation), do: false + + defp request_mirrored_channel_join(%ChatConversation{} = conversation, user_id) + when is_integer(user_id) do + case get_conversation_member(conversation.id, user_id) do + %ChatConversationMember{} -> + {:error, :already_member} + + nil -> + role = "member" + + with false <- local_join_request_pending?(conversation.id, user_id), + %User{} = user <- Repo.get(User, user_id), + :ok <- + FederationState.persist_local_invite_projection( + conversation.id, + user.id, + user.id, + "pending", + role, + %{"source" => "remote_join_request"}, + %{local_domain: &Federation.local_domain/0} + ) do + _ = Federation.publish_membership_state(conversation.id, user.id, "invited", role) + {:ok, :pending} + else + true -> {:ok, :pending} + nil -> {:error, :not_found} + {:error, reason} -> {:error, reason} + _ -> {:error, :invalid_event_payload} + end + end + end + + defp request_mirrored_channel_join(_conversation, _user_id), + do: {:error, :invalid_event_payload} + + defp review_remote_join_request(conversation_id, remote_actor_id, reviewer_user_id, decision) + when decision in ["accepted", "declined"] do + with {:ok, conversation} <- remote_join_review_conversation(conversation_id), + %User{} <- Repo.get(User, reviewer_user_id), + # NOTE: authorization for remote-join review is enforced at the LiveView + # layer (can_manage_remote_join_requests?/2). It is NOT enforced here via + # channel membership, because these are SERVER channels whose authority + # is server-level (the owner has no per-channel ChatConversationMember + # row). A context-level check belongs against server role, not channel + # membership - see federation_test.exs "remote join moderation workflow". + %FederationMembershipState{} = membership_state <- + pending_remote_join_membership_state(conversation_id, remote_actor_id), + %ActivityPubActor{} = remote_actor <- Repo.get(ActivityPubActor, remote_actor_id) do + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + membership_state_value = if decision == "accepted", do: "active", else: "left" + + updated_metadata = + membership_state.metadata + |> normalize_remote_join_metadata() + |> Map.merge(%{ + "join_request" => false, + "join_decision" => decision, + "reviewed_by_user_id" => reviewer_user_id, + "reviewed_at" => DateTime.to_iso8601(timestamp) + }) + + updated_joined_at = + if decision == "accepted", + do: membership_state.joined_at_remote || timestamp, + else: membership_state.joined_at_remote + + case membership_state + |> FederationMembershipState.changeset(%{ + state: membership_state_value, + joined_at_remote: updated_joined_at, + updated_at_remote: timestamp, + metadata: updated_metadata + }) + |> Repo.update() do + {:ok, updated_membership_state} -> + _ = broadcast_remote_join_request_update(conversation.id, updated_membership_state) + + _ = + Federation.publish_remote_invite_state( + conversation.id, + remote_join_actor_payload(remote_actor), + reviewer_user_id, + decision, + membership_state.role || "member", + outbound_remote_join_decision_metadata(updated_metadata) + ) + + {:ok, format_pending_remote_join_request(updated_membership_state)} + + {:error, _changeset} -> + {:error, :update_failed} + end + else + {:error, reason} -> {:error, reason} + nil -> {:error, :not_found} + false -> {:error, :not_found} + _ -> {:error, :not_found} + end + end + + defp pending_remote_join_membership_state(conversation_id, remote_actor_id) + when is_integer(conversation_id) and is_integer(remote_actor_id) do + from(state in FederationMembershipState, + where: + state.conversation_id == ^conversation_id and state.remote_actor_id == ^remote_actor_id and + state.state == "invited", + preload: [:remote_actor] + ) + |> Repo.one() + |> case do + %FederationMembershipState{} = membership_state -> + if pending_remote_join_request?(membership_state), do: membership_state, else: nil + + nil -> + nil + end + end + + defp pending_remote_join_membership_state(_, _), do: nil + + defp remote_join_review_conversation(conversation_id) when is_integer(conversation_id) do + case Repo.get(ChatConversation, conversation_id) do + %ChatConversation{type: "channel", is_federated_mirror: false, server_id: server_id} = + conversation + when is_integer(server_id) -> + {:ok, conversation} + + _ -> + {:error, :not_found} + end + end + + defp remote_join_review_conversation(_conversation_id), do: {:error, :not_found} + + defp pending_remote_join_request?(%FederationMembershipState{metadata: metadata}) + when is_map(metadata), do: metadata["join_request"] in [true, "true"] + + defp pending_remote_join_request?(_membership_state), do: false + + defp format_pending_remote_join_request(%FederationMembershipState{} = membership_state) do + actor = membership_state.remote_actor + + handle = + case actor do + %ActivityPubActor{username: username, domain: domain} + when is_binary(username) and is_binary(domain) -> + "@#{username}@#{domain}" + + _ -> + nil + end + + display_name = + case actor do + %ActivityPubActor{display_name: display_name} when is_binary(display_name) -> + if Elektrine.Strings.present?(display_name), do: display_name, else: nil + + %ActivityPubActor{username: username} when is_binary(username) -> + if Elektrine.Strings.present?(username), do: username, else: nil + + _ -> + handle || "remote user" + end + |> case do + nil -> handle || "remote user" + value -> value + end + + %{ + remote_actor_id: membership_state.remote_actor_id, + actor_uri: actor && actor.uri, + origin_domain: membership_state.origin_domain, + role: membership_state.role, + state: membership_state.state, + handle: handle, + display_name: display_name, + display_label: remote_join_display_label(display_name, handle), + avatar_url: actor && actor.avatar_url, + requested_at: membership_state.inserted_at, + updated_at: membership_state.updated_at_remote || membership_state.updated_at, + metadata: membership_state.metadata || %{} + } + end + + defp format_pending_remote_join_request(_membership_state), do: %{} + + defp remote_join_display_label(display_name, handle) + when is_binary(display_name) and is_binary(handle) do + if Elektrine.Strings.present?(display_name) and display_name != handle, + do: "#{display_name} (#{handle})", + else: handle + end + + defp remote_join_display_label(display_name, _handle) when is_binary(display_name), + do: display_name + + defp normalize_remote_join_metadata(metadata) when is_map(metadata), do: metadata + + defp outbound_remote_join_decision_metadata(metadata) when is_map(metadata), + do: + metadata + |> Map.drop(["reviewed_by_user_id", "reviewed_at"]) + |> Map.put("source", "moderator_review") + + defp outbound_remote_join_decision_metadata(_metadata), do: %{"source" => "moderator_review"} + + defp remote_join_actor_payload(%ActivityPubActor{} = actor) do + %{ + "uri" => actor.uri, + "username" => actor.username, + "domain" => actor.domain, + "handle" => "#{actor.username}@#{actor.domain}", + "display_name" => actor.display_name || actor.username + } + |> maybe_put_actor_avatar(actor.avatar_url) + end + + defp maybe_put_actor_avatar(payload, avatar_url) when is_binary(avatar_url) do + if Elektrine.Strings.present?(avatar_url), + do: Map.put(payload, "avatar_url", avatar_url), + else: payload + end + + defp maybe_put_actor_avatar(payload, _avatar_url), do: payload + + defp broadcast_remote_join_request_update( + conversation_id, + %FederationMembershipState{} = membership_state + ) + when is_integer(conversation_id) do + case Repo.get(ActivityPubActor, membership_state.remote_actor_id) do + %ActivityPubActor{} = actor -> + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{conversation_id}", + {:federation_membership_update, + %{ + conversation_id: conversation_id, + remote_actor_id: membership_state.remote_actor_id, + handle: "@#{actor.username}@#{actor.domain}", + role: membership_state.role, + state: membership_state.state, + joined_at: membership_state.joined_at_remote, + updated_at: membership_state.updated_at_remote, + avatar_url: actor.avatar_url + }} + ) + + :ok + + _ -> + :ok + end + end + + defp broadcast_remote_join_request_update(_conversation_id, _membership_state), do: :ok + + defp local_join_request_pending?(conversation_id, user_id) + when is_integer(conversation_id) and is_integer(user_id) do + case Repo.get(User, user_id) do + %User{} = user -> + target_uri = local_actor_uri(user) + + Repo.exists?( + from(invite in FederationInviteState, + where: + invite.conversation_id == ^conversation_id and invite.target_uri == ^target_uri and + invite.state == "pending" + ) + ) + + _ -> + false + end + end + + defp local_join_request_pending?(_conversation_id, _user_id), do: false + + defp local_actor_uri(%User{username: username}) when is_binary(username), + do: "#{ActivityPub.instance_url()}/users/#{username}" + + defp builtin_room_role_definition("owner"), + do: %{ + "id" => "builtin:owner", + "name" => "Owner", + "permissions" => [ + "manage_roles", + "manage_permissions", + "manage_moderation", + "manage_messages", + "manage_channels", + "manage_threads", + "create_threads", + "view_audit_log", + "manage_webhooks", + "manage_server", + "invite_members", + "send_messages", + "send_tts_messages", + "send_voice_signaling", + "attach_files", + "embed_links", + "mention_everyone", + "use_external_emoji" + ], + "position" => 100 + } + + defp builtin_room_role_definition("admin"), + do: %{ + "id" => "builtin:admin", + "name" => "Admin", + "permissions" => [ + "manage_roles", + "manage_permissions", + "manage_moderation", + "manage_messages", + "manage_channels", + "manage_threads", + "create_threads", + "view_audit_log", + "manage_webhooks", + "invite_members", + "send_messages", + "send_tts_messages", + "send_voice_signaling", + "attach_files", + "embed_links", + "mention_everyone", + "use_external_emoji" + ], + "position" => 80 + } + + defp builtin_room_role_definition("moderator"), + do: %{ + "id" => "builtin:moderator", + "name" => "Moderator", + "permissions" => [ + "manage_moderation", + "manage_messages", + "manage_threads", + "invite_members", + "send_messages", + "attach_files", + "embed_links", + "use_external_emoji" + ], + "position" => 60 + } + + defp builtin_room_role_definition("member"), + do: %{ + "id" => "builtin:member", + "name" => "Member", + "permissions" => ["send_messages", "attach_files", "embed_links", "use_external_emoji"], + "position" => 10 + } + + defp builtin_room_role_definition("readonly"), + do: %{ + "id" => "builtin:readonly", + "name" => "Readonly", + "permissions" => ["read_messages"], + "position" => 0 + } + + defp builtin_room_role_definition(_role), do: nil + + defp update_member_count(conversation_id) do + Membership.update_member_count( + ChatConversationMember, + ChatConversation, + conversation_id, + ChatMessage + ) + end + + defp normalize_remote_dm_handle(handle) when is_binary(handle) do + normalized = handle |> String.trim() |> String.trim_leading("@") |> String.downcase() + + case Regex.run(~r/^([a-z0-9_]{1,64})@([a-z0-9.-]+\.[a-z]{2,})$/, normalized) do + [_, username, domain] -> + {:ok, %{username: username, domain: domain, handle: "#{username}@#{domain}"}} + + _ -> + {:error, :invalid_remote_handle} + end + end + + defp normalize_remote_dm_handle(_), do: {:error, :invalid_remote_handle} + + defp ensure_dm_creation_allowed(user_id), + do: if(RateLimiter.can_create_dm?(user_id), do: :ok, else: {:error, :rate_limited}) + + defp spend_first_dm_credit(sender_id, recipient) do + if Code.ensure_loaded?(Atomine.CreditPolicy) do + Atomine.CreditPolicy.spend_first_dm_credit(sender_id, recipient) + else + :ok + end + end + + defp ensure_remote_recipient_domain(_local_user_id, recipient) do + case local_recipient_user(recipient) do + %User{} = user -> {:redirect_local_dm, user.id} + :local_domain -> {:error, :user_not_found} + :local_custom_domain -> {:error, :user_not_found} + _ -> :ok + end + end + + defp local_domain?(domain) when is_binary(domain), + do: String.downcase(domain) == Federation.local_domain() + + defp local_domain?(_), do: false + + defp local_recipient_user(%{domain: domain, username: username}) + when is_binary(domain) and is_binary(username) do + normalized_domain = String.downcase(domain) + + if local_domain?(normalized_domain) do + Accounts.get_user_by_username(username) || :local_domain + else + case Profiles.get_verified_custom_domain(normalized_domain) do + %{user: %{username: ^username} = user} -> user + %{domain: ^normalized_domain} -> :local_custom_domain + _ -> nil + end + end + end + + defp local_recipient_user(_recipient), do: nil + + defp remote_dm_display_name(attrs, recipient) when is_map(attrs) do + display_name = attrs[:display_name] || attrs["display_name"] || attrs[:name] || attrs["name"] + + if Elektrine.Strings.present?(display_name), + do: String.trim(display_name), + else: "@" <> recipient.handle + end + + defp remote_dm_display_name(_attrs, recipient), do: "@" <> recipient.handle + + defp remote_dm_avatar_url(attrs) when is_map(attrs), + do: attrs[:avatar_url] || attrs["avatar_url"] + + defp remote_dm_avatar_url(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_encryption_device.ex b/apps/elektrine/lib/elektrine/messaging/chat_encryption_device.ex new file mode 100644 index 0000000..e9172cf --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_encryption_device.ex @@ -0,0 +1,73 @@ +defmodule Elektrine.Messaging.ChatEncryptionDevice do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "chat_encryption_devices" do + field :device_id, :string + field :public_key, :map + field :key_algorithm, :string, default: "RSA-OAEP-SHA256" + field :fingerprint, :string + field :signing_public_key, :map + field :device_signature, :map + field :label, :string + field :last_seen_at, :utc_datetime + field :revoked_at, :utc_datetime + + belongs_to :user, Elektrine.Accounts.User + + timestamps() + end + + def changeset(device, attrs) do + device + |> cast(attrs, [ + :user_id, + :device_id, + :public_key, + :key_algorithm, + :fingerprint, + :signing_public_key, + :device_signature, + :label, + :last_seen_at, + :revoked_at + ]) + |> validate_required([:user_id, :device_id, :public_key, :key_algorithm]) + |> validate_length(:device_id, min: 8, max: 128) + |> validate_length(:fingerprint, min: 32, max: 128) + |> validate_length(:label, max: 120) + |> validate_inclusion(:key_algorithm, ["RSA-OAEP-SHA256"]) + |> validate_public_key() + |> foreign_key_constraint(:user_id) + |> unique_constraint([:user_id, :device_id]) + end + + defp validate_public_key(changeset) do + case get_field(changeset, :public_key) do + %{} = public_key -> + version = payload_value(public_key, "version", :version) + algorithm = payload_value(public_key, "algorithm", :algorithm) + key = payload_value(public_key, "key", :key) + + if version in [1, "1"] and algorithm == "RSA-OAEP-SHA256" and valid_base64?(key) do + changeset + else + add_error(changeset, :public_key, "must be a valid RSA-OAEP public key") + end + + _ -> + add_error(changeset, :public_key, "must be a valid RSA-OAEP public key") + end + end + + defp payload_value(payload, string_key, atom_key) do + Map.get(payload, string_key) || Map.get(payload, atom_key) + end + + defp valid_base64?(value) when is_binary(value) do + match?({:ok, decoded} when byte_size(decoded) > 32, Base.decode64(value)) + end + + defp valid_base64?(_), do: false +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_message.ex b/apps/elektrine/lib/elektrine/messaging/chat_message.ex new file mode 100644 index 0000000..c4becc7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_message.ex @@ -0,0 +1,567 @@ +defmodule Elektrine.Messaging.ChatMessage do + @moduledoc """ + Schema for chat messages in DMs, groups, and channels. + + This is separate from the `social_messages` table which is used for timeline posts, + community discussions, and ActivityPub federation. + + Chat messages are: + - Stored encrypted at rest when they have a local sender + - High-volume and ephemeral + - Optimized for real-time delivery + - Simple: text, images, files, voice, system messages + """ + use Ecto.Schema + import Ecto.Changeset + + schema "chat_messages" do + field :content, :string + field :encrypted_content, :map + field :client_encrypted_payload, :map + field :search_index, {:array, :string}, default: [] + field :message_type, :string, default: "text" + field :media_urls, {:array, :string}, default: [] + field :media_metadata, :map, default: %{} + field :federated_source, :string + field :origin_domain, :string + field :is_federated_mirror, :boolean, default: false + field :edited_at, :utc_datetime + field :deleted_at, :utc_datetime + + # Pin state hydrated from `chat_message_pins` (see ChatMessagePins context). + field :is_pinned, :boolean, virtual: true, default: false + field :pinned_at, :utc_datetime, virtual: true + field :pinned_by_id, :integer, virtual: true + + # Voice message fields + field :audio_duration, :integer + field :audio_mime_type, :string + + belongs_to :conversation, Elektrine.Messaging.ChatConversation + belongs_to :sender, Elektrine.Accounts.User + belongs_to :webhook, Elektrine.Messaging.ChatWebhook + belongs_to :thread, Elektrine.Messaging.ChatThread + belongs_to :reply_to, __MODULE__ + belongs_to :client_encryption_key, Elektrine.Messaging.ChatConversationEncryptionKey + + belongs_to :link_preview, Elektrine.Social.LinkPreview + + has_many :replies, __MODULE__, foreign_key: :reply_to_id + has_many :reactions, Elektrine.Messaging.ChatMessageReaction, foreign_key: :chat_message_id + + timestamps() + end + + @doc false + def changeset(chat_message, attrs) do + chat_message + |> cast(attrs, [ + :conversation_id, + :sender_id, + :webhook_id, + :thread_id, + :content, + :encrypted_content, + :client_encrypted_payload, + :client_encryption_key_id, + :search_index, + :message_type, + :media_urls, + :media_metadata, + :federated_source, + :origin_domain, + :is_federated_mirror, + :reply_to_id, + :link_preview_id, + :edited_at, + :deleted_at, + :audio_duration, + :audio_mime_type + ]) + |> truncate_utc_datetimes([:edited_at, :deleted_at]) + |> validate_required([:conversation_id]) + |> validate_inclusion(:message_type, ["text", "image", "file", "voice", "system"]) + |> validate_length(:content, max: 4000) + |> validate_content_or_media() + |> validate_client_encrypted_payload() + |> validate_content_security() + |> validate_media_urls_security() + |> foreign_key_constraint(:conversation_id) + |> foreign_key_constraint(:sender_id) + |> foreign_key_constraint(:webhook_id) + |> foreign_key_constraint(:thread_id) + |> foreign_key_constraint(:reply_to_id) + |> unique_constraint(:federated_source, + name: :chat_messages_conversation_federated_source_unique + ) + end + + @doc """ + Creates a changeset for a text message. + """ + def text_changeset(conversation_id, sender_id, content, reply_to_id \\ nil, encrypt? \\ false) do + attrs = + %{ + conversation_id: conversation_id, + sender_id: sender_id, + content: content, + message_type: "text", + reply_to_id: reply_to_id + } + |> maybe_encrypt(sender_id, encrypt?) + + %__MODULE__{} + |> changeset(attrs) + end + + @doc """ + Creates a changeset for a media message (image or file). + """ + def media_changeset( + conversation_id, + sender_id, + media_urls, + content \\ nil, + media_metadata \\ %{}, + encrypt? \\ false + ) do + message_type = determine_media_type(media_urls) + + attrs = + %{ + conversation_id: conversation_id, + sender_id: sender_id, + content: content, + message_type: message_type, + media_urls: media_urls, + media_metadata: media_metadata + } + |> maybe_encrypt(sender_id, encrypt?) + + %__MODULE__{} + |> changeset(attrs) + end + + @doc """ + Creates a changeset for a voice message. + """ + def voice_changeset(conversation_id, sender_id, audio_url, duration, mime_type) do + %__MODULE__{} + |> changeset(%{ + conversation_id: conversation_id, + sender_id: sender_id, + message_type: "voice", + media_urls: [audio_url], + audio_duration: duration, + audio_mime_type: mime_type + }) + end + + @doc """ + Creates a changeset for a browser-encrypted text message. + """ + def client_encrypted_text_changeset( + conversation_id, + sender_id, + encrypted_payload, + client_encryption_key_id, + search_index \\ [], + reply_to_id \\ nil + ) do + %__MODULE__{} + |> changeset(%{ + conversation_id: conversation_id, + sender_id: sender_id, + client_encrypted_payload: encrypted_payload, + client_encryption_key_id: client_encryption_key_id, + search_index: search_index, + message_type: "text", + reply_to_id: reply_to_id + }) + end + + @doc """ + Creates a changeset for a system message. + """ + def system_changeset(conversation_id, content) do + %__MODULE__{} + |> changeset(%{ + conversation_id: conversation_id, + content: content, + message_type: "system" + }) + end + + @doc """ + Creates a changeset for editing a message. + """ + def edit_changeset(message, new_content, encrypt? \\ false) do + attrs = + %{ + content: new_content, + edited_at: Elektrine.Time.utc_now() + } + |> maybe_encrypt(message.sender_id, encrypt?) + + message + |> changeset(attrs) + end + + @doc """ + Creates a changeset for soft-deleting a message. + """ + def delete_changeset(message) do + message + |> changeset(%{deleted_at: Elektrine.Time.utc_now()}) + end + + @doc """ + Checks if the message is deleted. + """ + def deleted?(%__MODULE__{deleted_at: nil}), do: false + def deleted?(%__MODULE__{}), do: true + + @doc """ + Checks if the message has been edited. + """ + def edited?(%__MODULE__{edited_at: nil}), do: false + def edited?(%__MODULE__{}), do: true + + defp truncate_utc_datetimes(changeset, fields) do + Enum.reduce(fields, changeset, fn field, changeset -> + update_change(changeset, field, &Elektrine.Time.truncate/1) + end) + end + + @doc """ + Returns the display content for the message. + """ + def display_content(%__MODULE__{deleted_at: deleted_at}) when not is_nil(deleted_at) do + "This message was deleted" + end + + def display_content(%__MODULE__{content: content, message_type: "system"}) do + content + end + + def display_content(%__MODULE__{client_encrypted_payload: payload}) when is_map(payload) do + "Encrypted message" + end + + def display_content(%__MODULE__{content: content, message_type: "text"}) do + content + end + + def display_content(%__MODULE__{ + content: content, + message_type: "voice", + audio_duration: duration + }) do + duration_str = format_duration(duration) + + if Elektrine.Strings.present?(content) do + "Voice message (#{duration_str}): #{content}" + else + "Voice message (#{duration_str})" + end + end + + def display_content(%__MODULE__{content: content, message_type: type, media_urls: media_urls}) + when type in ["image", "file"] do + media_text = + case type do + "image" -> + is_gif = Enum.any?(media_urls, &String.contains?(&1, ".gif")) + if is_gif, do: "GIF", else: "Photo" + + "file" -> + "File" + end + + if Elektrine.Strings.present?(content) do + "#{media_text}: #{content}" + else + media_text + end + end + + @doc """ + Decrypts message content if encrypted. + """ + def decrypt_content(%__MODULE__{content: content} = message) when not is_nil(content) do + # Already has plaintext content + message + end + + def decrypt_content(%__MODULE__{encrypted_content: nil} = message), do: message + + def decrypt_content(%__MODULE__{encrypted_content: encrypted, sender_id: sender_id} = message) + when not is_nil(encrypted) do + case Elektrine.Encryption.decrypt(encrypted, sender_id) do + {:ok, content} -> %{message | content: content} + {:error, _} -> %{message | content: "[Decryption failed]"} + end + end + + def decrypt_content(message), do: message + + @doc """ + Decrypts a list of messages. + """ + def decrypt_messages(messages) when is_list(messages) do + Enum.map(messages, &decrypt_content/1) + end + + @doc """ + Returns true if the message can be edited by the given user. + """ + def can_edit?(%__MODULE__{sender_id: sender_id, message_type: type, deleted_at: nil}, user_id) + when type in ["text", "image", "file"] do + sender_id == user_id + end + + def can_edit?(%__MODULE__{}, _user_id), do: false + + @doc """ + Returns true if the message can be deleted by the given user. + """ + def can_delete?(message, user_id, is_admin \\ false) + + def can_delete?(%__MODULE__{sender_id: sender_id, deleted_at: nil}, user_id, is_admin) do + is_admin || sender_id == user_id + end + + def can_delete?(%__MODULE__{}, _user_id, _is_admin), do: false + + # Private helpers + + defp validate_content_or_media(changeset) do + content = get_field(changeset, :content) + encrypted_content = get_field(changeset, :encrypted_content) + client_encrypted_payload = get_field(changeset, :client_encrypted_payload) + media_urls = get_field(changeset, :media_urls) || [] + message_type = get_field(changeset, :message_type) + deleted_at = get_field(changeset, :deleted_at) + + has_content = Elektrine.Strings.present?(content) + has_encrypted = !is_nil(encrypted_content) + has_client_encrypted = !is_nil(client_encrypted_payload) + has_media = !Enum.empty?(media_urls) + + cond do + not is_nil(deleted_at) -> changeset + message_type == "system" -> changeset + message_type == "voice" && has_media -> changeset + has_content or has_encrypted or has_client_encrypted or has_media -> changeset + true -> add_error(changeset, :content, "must have either content or media") + end + end + + defp validate_client_encrypted_payload(changeset) do + case get_field(changeset, :client_encrypted_payload) do + nil -> + changeset + + %{} = payload -> + version = payload_value(payload, "version", :version) + content_algorithm = payload_value(payload, "content_algorithm", :content_algorithm) + key_uid = payload_value(payload, "key_uid", :key_uid) + iv = payload_value(payload, "iv", :iv) + ciphertext = payload_value(payload, "ciphertext", :ciphertext) + + if version in [1, "1"] and content_algorithm == "AES-256-GCM" and + is_binary(key_uid) and byte_size(key_uid) <= 128 and valid_base64_bytes?(iv, 12) and + valid_base64_bytes?(ciphertext, 1) do + changeset + else + add_error( + changeset, + :client_encrypted_payload, + "must be a valid encrypted chat payload" + ) + end + + _ -> + add_error(changeset, :client_encrypted_payload, "must be a valid encrypted chat payload") + end + end + + defp payload_value(payload, string_key, atom_key) do + Map.get(payload, string_key) || Map.get(payload, atom_key) + end + + defp valid_base64_bytes?(value, min_size) when is_binary(value) do + case Base.decode64(value) do + {:ok, decoded} -> byte_size(decoded) >= min_size + :error -> false + end + end + + defp valid_base64_bytes?(_, _), do: false + + defp validate_content_security(changeset) do + case get_change(changeset, :content) do + nil -> + changeset + + content when is_binary(content) -> + cleaned_content = + content + |> String.replace(~r/)<[^<]*)*<\/script>/mi, "") + |> String.replace(~r/)<[^<]*)*<\/iframe>/mi, "") + |> String.replace(~r/javascript:/i, "") + + if String.length(cleaned_content) != String.length(content) do + add_error(changeset, :content, "contains potentially unsafe content") + else + changeset + end + + _ -> + changeset + end + end + + defp validate_media_urls_security(changeset) do + case get_change(changeset, :media_urls) do + nil -> + changeset + + urls when is_list(urls) -> + invalid_urls = + Enum.filter(urls, fn url -> + not trusted_media_url?(url, get_field(changeset, :sender_id)) + end) + + if invalid_urls != [] do + add_error(changeset, :media_urls, "contains untrusted media URLs") + else + changeset + end + + _ -> + changeset + end + end + + defp trusted_media_url?(url, sender_id) do + cond do + String.starts_with?(url, "/uploads/") -> + owned_chat_media_key?(String.trim_leading(url, "/uploads/"), sender_id) + + String.starts_with?(url, "chat-attachments/") -> + owned_chat_media_key?(url, sender_id) + + String.starts_with?(url, "attachments/") -> + owned_chat_media_key?(url, sender_id) + + true -> + trusted_domains = [ + "i.imgur.com", + "imgur.com" + ] + + uri = URI.parse(url) + uri.scheme == "https" and uri.host in trusted_domains + end + end + + defp owned_chat_media_key?(key, sender_id) when is_binary(key) and is_integer(sender_id) do + normalized = String.trim_leading(key, "/") + + case String.split(normalized, "/", parts: 2) do + [prefix, filename] when prefix in ["chat-attachments", "attachments"] -> + legacy_owned_chat_media_filename?(filename, sender_id) or + content_addressed_chat_media_key?(filename, sender_id) + + _ -> + false + end + end + + defp owned_chat_media_key?(_key, _sender_id), do: false + + defp legacy_owned_chat_media_filename?(filename, sender_id) when is_binary(filename) do + safe_chat_media_key?(filename) and String.starts_with?(filename, "#{sender_id}_") + end + + defp content_addressed_chat_media_key?(path, sender_id) when is_binary(path) do + case String.split(path, "/") do + [user_id, hash_a, hash_b, hash_c, filename] -> + user_id == to_string(sender_id) and safe_hash_segment?(hash_a) and + safe_hash_segment?(hash_b) and safe_hash_segment?(hash_c) and + safe_content_addressed_filename?(filename) + + _ -> + false + end + end + + defp safe_content_addressed_filename?(filename) when is_binary(filename) do + String.match?(filename, ~r/\A[0-9a-f]{64}\.[A-Za-z0-9]{1,16}\z/) + end + + defp safe_hash_segment?(segment) when is_binary(segment) do + String.match?(segment, ~r/\A[0-9a-f]{2}\z/) + end + + defp safe_chat_media_key?(key) when is_binary(key) do + not String.contains?(key, ["..", "\\", "/"]) and not Regex.match?(~r/[\x00-\x1F\x7F]/, key) + end + + defp determine_media_type(media_urls) do + if Enum.any?(media_urls, &String.match?(&1, ~r/\.(jpg|jpeg|png|gif|webp|heic|heif|avif)$/i)) do + "image" + else + "file" + end + end + + defp maybe_encrypt(attrs, sender_id, false) do + case Map.get(attrs, :content) do + nil -> + attrs + |> Map.put(:encrypted_content, nil) + |> Map.put(:search_index, []) + + "" -> + attrs + |> Map.put(:encrypted_content, nil) + |> Map.put(:search_index, []) + + content -> + attrs + |> Map.put(:encrypted_content, nil) + # Keep plaintext content, but maintain the same blind keyword index as + # the older message path so chat search semantics stay user-specific. + |> Map.put(:search_index, Elektrine.Encryption.index_content(content, sender_id)) + end + end + + defp maybe_encrypt(attrs, sender_id, true) do + case Map.get(attrs, :content) do + nil -> + attrs + + "" -> + attrs + + content -> + encrypted = Elektrine.Encryption.encrypt(content, sender_id) + search_index = Elektrine.Encryption.index_content(content, sender_id) + + attrs + |> Map.put(:encrypted_content, encrypted) + |> Map.put(:search_index, search_index) + |> Map.put(:content, nil) + end + end + + defp format_duration(nil), do: "0:00" + + defp format_duration(seconds) when is_integer(seconds) do + minutes = div(seconds, 60) + secs = rem(seconds, 60) + "#{minutes}:#{String.pad_leading(Integer.to_string(secs), 2, "0")}" + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_message_pin.ex b/apps/elektrine/lib/elektrine/messaging/chat_message_pin.ex new file mode 100644 index 0000000..670c959 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_message_pin.ex @@ -0,0 +1,29 @@ +defmodule Elektrine.Messaging.ChatMessagePin do + @moduledoc """ + Schema for pinned chat messages. + + A join table between conversations and messages so pin metadata (who pinned + the message and when) is preserved. Each message can be pinned at most once. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "chat_message_pins" do + belongs_to :conversation, Elektrine.Messaging.ChatConversation + belongs_to :message, Elektrine.Messaging.ChatMessage + belongs_to :pinned_by, Elektrine.Accounts.User + + timestamps(type: :utc_datetime, updated_at: false) + end + + @doc false + def changeset(pin, attrs) do + pin + |> cast(attrs, [:conversation_id, :message_id, :pinned_by_id]) + |> validate_required([:conversation_id, :message_id]) + |> foreign_key_constraint(:conversation_id) + |> foreign_key_constraint(:message_id) + |> foreign_key_constraint(:pinned_by_id) + |> unique_constraint(:message_id) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_message_pins.ex b/apps/elektrine/lib/elektrine/messaging/chat_message_pins.ex new file mode 100644 index 0000000..6085a37 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_message_pins.ex @@ -0,0 +1,355 @@ +defmodule Elektrine.Messaging.ChatMessagePins do + @moduledoc """ + Context for pinning chat messages. + + Pins are stored in the `chat_message_pins` join table so pin metadata (who + pinned and when) is preserved. Pinning requires the `manage_messages` + permission: for channels this is resolved through `RoomACL`; for DMs and + groups it maps to the conversation member roles that carry + `manage_messages` in the built-in role definitions (owner/admin/moderator). + + Pins on channels federate as `pin.upsert` extension events + (`urn:arblarg:ext:pins:1`), projected last-write-wins per pinned message + (spec section 7.6: pinned messages as governed room state). Pins in DMs and + groups stay local. + """ + + import Ecto.Query, warn: false + + alias Elektrine.Messaging.{ + ArblargSDK, + ChatConversation, + ChatConversationMember, + ChatMessage, + ChatMessagePin, + Federation, + FederationExtensionEvent, + RoomACL, + ServerMember + } + + alias Elektrine.Messaging.Federation.Utils, as: FederationUtils + alias Elektrine.PubSubTopics + alias Elektrine.Repo + + @max_pins_per_conversation 50 + + # Built-in conversation roles that carry `manage_messages` (see RoomACL). + @manage_message_roles ["owner", "admin", "moderator"] + + @doc """ + Maximum number of pinned messages allowed per conversation. + """ + def max_pins_per_conversation, do: @max_pins_per_conversation + + @doc """ + Pins a message. Requires the `manage_messages` permission in the message's + conversation. At most #{@max_pins_per_conversation} messages can be pinned + per conversation. + """ + def pin_message(message_id, user_id) do + with %ChatMessage{deleted_at: nil} = message <- fetch_message(message_id), + :ok <- authorize_manage_messages(message.conversation_id, user_id), + :ok <- ensure_pin_capacity(message.conversation_id) do + %ChatMessagePin{} + |> ChatMessagePin.changeset(%{ + conversation_id: message.conversation_id, + message_id: message.id, + pinned_by_id: user_id + }) + |> Repo.insert() + |> case do + {:ok, pin} -> + pinned_message = message |> ChatMessage.decrypt_content() |> put_pin_state(pin) + broadcast_pin_event(:message_pinned, pinned_message) + maybe_publish_pin_state(message, user_id, "pinned", pin.inserted_at) + {:ok, pinned_message} + + {:error, %Ecto.Changeset{errors: errors} = changeset} -> + if Keyword.has_key?(errors, :message_id) do + {:error, :already_pinned} + else + {:error, changeset} + end + end + else + nil -> {:error, :not_found} + %ChatMessage{} -> {:error, :not_found} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Unpins a message. Requires the `manage_messages` permission in the + message's conversation. + """ + def unpin_message(message_id, user_id) do + with %ChatMessage{} = message <- fetch_message(message_id), + :ok <- authorize_manage_messages(message.conversation_id, user_id) do + from(pin in ChatMessagePin, where: pin.message_id == ^message_id) + |> Repo.delete_all() + |> case do + {count, _} when count > 0 -> + unpinned_message = put_pin_state(message, nil) + broadcast_pin_event(:message_unpinned, unpinned_message) + maybe_publish_pin_state(message, user_id, "unpinned", nil) + {:ok, unpinned_message} + + {0, _} -> + {:error, :not_pinned} + end + else + nil -> {:error, :not_found} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Lists the pinned messages of a conversation, newest pin first. + """ + def list_pinned_messages(conversation_id) do + from(pin in ChatMessagePin, + join: message in ChatMessage, + on: message.id == pin.message_id, + where: pin.conversation_id == ^conversation_id and is_nil(message.deleted_at), + order_by: [desc: pin.inserted_at, desc: pin.id], + preload: [message: [:sender, :link_preview, reply_to: [:sender]]] + ) + |> Repo.all() + |> Enum.map(fn pin -> put_pin_state(pin.message, pin) end) + |> ChatMessage.decrypt_messages() + end + + @doc """ + Hydrates the virtual pin fields (`is_pinned`, `pinned_at`, `pinned_by_id`) + on a list of chat messages using a single query. + """ + def hydrate_pin_state(messages) when is_list(messages) do + message_ids = messages |> Enum.map(& &1.id) |> Enum.reject(&is_nil/1) + + case message_ids do + [] -> + messages + + _ -> + pins_by_message_id = + from(pin in ChatMessagePin, where: pin.message_id in ^message_ids) + |> Repo.all() + |> Map.new(&{&1.message_id, &1}) + + Enum.map(messages, fn message -> + put_pin_state(message, Map.get(pins_by_message_id, message.id)) + end) + end + end + + def hydrate_pin_state(messages), do: messages + + defp put_pin_state(%ChatMessage{} = message, %ChatMessagePin{} = pin) do + %{message | is_pinned: true, pinned_at: pin.inserted_at, pinned_by_id: pin.pinned_by_id} + end + + defp put_pin_state(%ChatMessage{} = message, nil) do + %{message | is_pinned: false, pinned_at: nil, pinned_by_id: nil} + end + + defp fetch_message(message_id) do + ChatMessage + |> Repo.get(message_id) + |> Repo.preload(:sender) + end + + defp authorize_manage_messages(conversation_id, user_id) + when is_integer(conversation_id) and is_integer(user_id) do + case Repo.get(ChatConversation, conversation_id) do + %ChatConversation{type: "channel", server_id: server_id} when is_integer(server_id) -> + # Server staff manage channel messages (mirrors `create_server_channel` + # authorization); other members go through the room ACL. + if server_staff?(server_id, user_id) do + :ok + else + RoomACL.authorize_local_user_action(conversation_id, user_id, :manage_messages) + end + + %ChatConversation{type: "channel"} -> + RoomACL.authorize_local_user_action(conversation_id, user_id, :manage_messages) + + %ChatConversation{} -> + member = + from(cm in ChatConversationMember, + where: + cm.conversation_id == ^conversation_id and cm.user_id == ^user_id and + is_nil(cm.left_at) + ) + |> Repo.one() + + case member do + %ChatConversationMember{role: role} when role in @manage_message_roles -> :ok + _ -> {:error, :unauthorized} + end + + nil -> + {:error, :not_found} + end + end + + defp authorize_manage_messages(_conversation_id, _user_id), do: {:error, :unauthorized} + + defp server_staff?(server_id, user_id) do + from(sm in ServerMember, + where: + sm.server_id == ^server_id and sm.user_id == ^user_id and is_nil(sm.left_at) and + sm.role in ^@manage_message_roles + ) + |> Repo.exists?() + end + + defp ensure_pin_capacity(conversation_id) do + count = + from(pin in ChatMessagePin, + where: pin.conversation_id == ^conversation_id, + select: count() + ) + |> Repo.one() + + if count < @max_pins_per_conversation do + :ok + else + {:error, :pin_limit_reached} + end + end + + defp broadcast_pin_event(event, %ChatMessage{} = message) do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + PubSubTopics.conversation(message.conversation_id), + {event, message} + ) + end + + ## Federation + + @doc """ + Projects an accepted `pin.upsert` extension event into the local + `chat_message_pins` rows. The projection row passed in is the current + last-write-wins state per pinned message, so replays converge. Unknown + message refs are ignored (the extension event state is still stored). + Remote-driven pins carry no local `pinned_by_id`. + """ + def apply_remote_pin_projection( + %ChatConversation{} = conversation, + %FederationExtensionEvent{} = projection + ) do + with true <- projection.event_type == ArblargSDK.canonical_event_type("pin.upsert"), + %{} = pin_payload <- projection.payload["pin"], + ref when is_binary(ref) <- pin_payload["message_id"], + %ChatMessage{} = message <- resolve_message_by_ref(conversation.id, ref) do + case pin_payload["state"] do + "pinned" -> apply_remote_pin(message) + "unpinned" -> apply_remote_unpin(message) + _ -> :ok + end + else + _ -> :ok + end + end + + def apply_remote_pin_projection(_conversation, _projection), do: :ok + + defp apply_remote_pin(%ChatMessage{} = message) do + with nil <- Repo.get_by(ChatMessagePin, message_id: message.id), + :ok <- ensure_pin_capacity(message.conversation_id), + {:ok, pin} <- + %ChatMessagePin{} + |> ChatMessagePin.changeset(%{ + conversation_id: message.conversation_id, + message_id: message.id, + pinned_by_id: nil + }) + |> Repo.insert(on_conflict: :nothing) do + pinned_message = message |> ChatMessage.decrypt_content() |> put_pin_state(pin) + broadcast_pin_event(:message_pinned, pinned_message) + :ok + else + # Already pinned, capacity reached, or conflicting insert: keep local + # state as-is; the extension projection still records the remote intent. + _ -> :ok + end + end + + defp apply_remote_unpin(%ChatMessage{} = message) do + from(pin in ChatMessagePin, where: pin.message_id == ^message.id) + |> Repo.delete_all() + |> case do + {count, _} when count > 0 -> + unpinned_message = message |> ChatMessage.decrypt_content() |> put_pin_state(nil) + broadcast_pin_event(:message_unpinned, unpinned_message) + :ok + + _ -> + :ok + end + end + + defp resolve_message_by_ref(conversation_id, ref) do + Repo.get_by(ChatMessage, conversation_id: conversation_id, federated_source: ref) || + resolve_local_message_ref(conversation_id, ref) + end + + defp resolve_local_message_ref(conversation_id, ref) do + case String.split(ref, "/_arblarg/messages/") do + [_base, id_string] -> + with {message_id, ""} <- Integer.parse(id_string), + # Re-minting the ref verifies scheme + domain, so foreign refs + # cannot bind to arbitrary local message ids. + true <- FederationUtils.message_federation_id(message_id) == ref, + %ChatMessage{conversation_id: ^conversation_id, federated_source: nil} = message <- + Repo.get(ChatMessage, message_id) do + message + else + _ -> nil + end + + _ -> + nil + end + end + + # Publishes pin state for channels through the shared governance extension + # pipeline; DMs and groups stay local. Never blocks the local pin result. + defp maybe_publish_pin_state(%ChatMessage{} = message, user_id, state, pinned_at) + when is_integer(user_id) and state in ["pinned", "unpinned"] do + case Repo.get(ChatConversation, message.conversation_id) do + %ChatConversation{type: "channel"} -> + pin_payload = + %{ + "message_id" => + message.federated_source || FederationUtils.message_federation_id(message.id), + "state" => state, + "updated_at" => DateTime.to_iso8601(Elektrine.Time.utc_now()) + } + |> maybe_put_pinned_at(pinned_at) + + _ = + Federation.publish_extension_event( + message.conversation_id, + user_id, + "pin.upsert", + %{"pin" => pin_payload} + ) + + :ok + + _ -> + :ok + end + end + + defp maybe_publish_pin_state(_message, _user_id, _state, _pinned_at), do: :ok + + defp maybe_put_pinned_at(pin_payload, %DateTime{} = pinned_at) do + Map.put(pin_payload, "pinned_at", DateTime.to_iso8601(pinned_at)) + end + + defp maybe_put_pinned_at(pin_payload, _pinned_at), do: pin_payload +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_message_reaction.ex b/apps/elektrine/lib/elektrine/messaging/chat_message_reaction.ex new file mode 100644 index 0000000..6ab2e1a --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_message_reaction.ex @@ -0,0 +1,45 @@ +defmodule Elektrine.Messaging.ChatMessageReaction do + @moduledoc """ + Schema for reactions to chat messages. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "chat_message_reactions" do + field :emoji, :string + + belongs_to :chat_message, Elektrine.Messaging.ChatMessage + belongs_to :user, Elektrine.Accounts.User + belongs_to :remote_actor, Elektrine.ActivityPub.Actor + + timestamps() + end + + @doc false + def changeset(reaction, attrs) do + reaction + |> cast(attrs, [:chat_message_id, :user_id, :remote_actor_id, :emoji]) + |> validate_required([:chat_message_id, :emoji]) + |> validate_user_or_remote_actor() + |> validate_length(:emoji, max: 50) + |> foreign_key_constraint(:chat_message_id) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:remote_actor_id) + end + + defp validate_user_or_remote_actor(changeset) do + user_id = get_field(changeset, :user_id) + remote_actor_id = get_field(changeset, :remote_actor_id) + + cond do + is_nil(user_id) and is_nil(remote_actor_id) -> + add_error(changeset, :user_id, "either user_id or remote_actor_id must be present") + + not is_nil(user_id) and not is_nil(remote_actor_id) -> + add_error(changeset, :user_id, "only one of user_id or remote_actor_id can be present") + + true -> + changeset + end + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_messages.ex b/apps/elektrine/lib/elektrine/messaging/chat_messages.ex new file mode 100644 index 0000000..2ed97b3 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_messages.ex @@ -0,0 +1,2015 @@ +defmodule Elektrine.Messaging.ChatMessages do + @moduledoc """ + Context for chat message operations. + + Handles DMs, group chats, and channel messages. + Separate from timeline posts and community discussions. + """ + + import Ecto.Query, warn: false + alias Elektrine.Accounts + alias Elektrine.ActivityPub.Actor, as: ActivityPubActor + alias Elektrine.Repo + alias Elektrine.Social.{FetchLinkPreviewWorker, LinkPreview, LinkPreviewFetcher} + + alias Elektrine.Messaging.{ + ChatConversation, + ChatConversationEncryptionKey, + ChatConversationKeyRecipient, + ChatConversationMember, + ChatEncryptionDevice, + ChatMessage, + ChatMessagePins, + ChatMessageReaction, + ChatRemoteEncryptionDevice, + ChatUserHiddenMessage, + Federation, + FederationReadCursor, + LinkPreviewBroadcast, + RoomACL + } + + alias Elektrine.PubSubTopics + + @mention_pattern ~r/(?:^|[^A-Za-z0-9_])@([A-Za-z0-9_]{1,30})/ + + # Message fetching + + @doc """ + Gets messages for a conversation with pagination. + """ + def get_messages(conversation_id, opts \\ []) do + user_id = Keyword.get(opts, :user_id) + limit = Keyword.get(opts, :limit, 50) + before_id = Keyword.get(opts, :before_id) + after_id = Keyword.get(opts, :after_id) + decrypt? = Keyword.get(opts, :decrypt, true) + + messages = + query_paginated_messages(conversation_id, + user_id: user_id, + limit: limit, + before_id: before_id, + after_id: after_id, + decrypt?: decrypt? + ) + + Enum.reverse(messages) + end + + @doc """ + Gets messages for a conversation with pagination metadata. + Returns messages in reverse chronological order (newest first). + """ + def get_conversation_messages(conversation_id, user_id, opts \\ []) do + case get_conversation_member(conversation_id, user_id) do + nil -> + %{ + messages: [], + has_more_older: false, + has_more_newer: false, + oldest_id: nil, + newest_id: nil + } + + _member -> + limit = Keyword.get(opts, :limit, 50) + before_id = Keyword.get(opts, :before_id) + after_id = Keyword.get(opts, :after_id) + + messages_desc = + query_paginated_messages(conversation_id, + user_id: user_id, + limit: limit, + before_id: before_id, + after_id: after_id, + decrypt?: true + ) + + has_more_older = + has_more_older_messages?(conversation_id, user_id, messages_desc, before_id, after_id) + + has_more_newer = + has_more_newer_messages?(conversation_id, user_id, messages_desc, after_id) + + oldest_id = + case messages_desc do + [] -> nil + _ -> List.last(messages_desc).id + end + + newest_id = + case messages_desc do + [%{id: id} | _] -> id + [] -> nil + end + + %{ + messages: messages_desc, + has_more_older: has_more_older, + has_more_newer: has_more_newer, + oldest_id: oldest_id, + newest_id: newest_id + } + end + end + + defp query_paginated_messages(conversation_id, opts) do + user_id = Keyword.get(opts, :user_id) + limit = Keyword.get(opts, :limit, 50) + before_id = Keyword.get(opts, :before_id) + after_id = Keyword.get(opts, :after_id) + decrypt? = Keyword.get(opts, :decrypt?, true) + + base_query = + from(m in ChatMessage, + left_join: h in ChatUserHiddenMessage, + on: h.chat_message_id == m.id and h.user_id == ^user_id, + # Thread messages live in the thread panel, not the main timeline + # (thread root messages keep a nil thread_id and stay visible here). + where: + m.conversation_id == ^conversation_id and is_nil(m.deleted_at) and is_nil(h.id) and + is_nil(m.thread_id), + limit: ^limit, + preload: [:sender, :link_preview, reply_to: [:sender], reactions: [:user, :remote_actor]] + ) + + query = + cond do + before_id -> + from(m in base_query, + where: m.id < ^before_id, + order_by: [desc: m.inserted_at, desc: m.id] + ) + + after_id -> + from(m in base_query, + where: m.id > ^after_id, + order_by: [asc: m.inserted_at, asc: m.id] + ) + + true -> + from(m in base_query, order_by: [desc: m.inserted_at, desc: m.id]) + end + + messages = Repo.all(query) + messages = if after_id, do: Enum.reverse(messages), else: messages + messages = ChatMessagePins.hydrate_pin_state(messages) + + if decrypt? do + messages + |> ChatMessage.decrypt_messages() + |> hydrate_remote_senders() + else + hydrate_remote_senders(messages) + end + end + + defp has_more_older_messages?(conversation_id, user_id, messages_desc, before_id, after_id) do + if before_id || is_nil(after_id) do + case List.last(messages_desc) do + nil -> + false + + oldest_message -> + from(m in ChatMessage, + left_join: h in ChatUserHiddenMessage, + on: h.chat_message_id == m.id and h.user_id == ^user_id, + where: + m.conversation_id == ^conversation_id and + m.id < ^oldest_message.id and + is_nil(m.deleted_at) and + is_nil(h.id) and + is_nil(m.thread_id), + select: m.id, + limit: 1 + ) + |> Repo.one() + |> case do + nil -> false + _ -> true + end + end + else + false + end + end + + defp has_more_newer_messages?(conversation_id, user_id, messages_desc, after_id) do + if after_id do + case List.first(messages_desc) do + nil -> + false + + newest_message -> + from(m in ChatMessage, + left_join: h in ChatUserHiddenMessage, + on: h.chat_message_id == m.id and h.user_id == ^user_id, + where: + m.conversation_id == ^conversation_id and + m.id > ^newest_message.id and + is_nil(m.deleted_at) and + is_nil(h.id) and + is_nil(m.thread_id), + select: m.id, + limit: 1 + ) + |> Repo.one() + |> case do + nil -> false + _ -> true + end + end + else + false + end + end + + defp get_conversation_member(conversation_id, user_id) when is_integer(user_id) do + from(cm in ChatConversationMember, + where: + cm.conversation_id == ^conversation_id and + cm.user_id == ^user_id and + is_nil(cm.left_at) + ) + |> Repo.one() + end + + defp get_conversation_member(_conversation_id, _user_id), do: nil + + @doc """ + Gets a single message by ID. + """ + def get_message(id) do + Repo.get(ChatMessage, id) + |> Repo.preload([ + :sender, + :link_preview, + reply_to: [:sender], + reactions: [:user, :remote_actor] + ]) + |> hydrate_remote_sender() + end + + @doc """ + Gets a message and decrypts it. + """ + def get_message_decrypted(id) do + case get_message(id) do + nil -> nil + message -> message |> ChatMessage.decrypt_content() |> hydrate_remote_sender() + end + end + + # Message creation + + @doc """ + Creates a text message. + + Pass `thread_id` in `opts` to send the message into a thread instead of the + conversation's main timeline (see `Elektrine.Messaging.ChatThreads`). + """ + def create_text_message(conversation_id, sender_id, content, opts \\ []) do + with :ok <- ensure_writable_conversation(conversation_id, sender_id) do + reply_to_id = Keyword.get(opts, :reply_to_id) + thread_id = Keyword.get(opts, :thread_id) + encrypt? = should_encrypt?(conversation_id) + + ChatMessage.text_changeset(conversation_id, sender_id, content, reply_to_id, encrypt?) + |> maybe_put_thread_id(thread_id) + |> Repo.insert() + |> handle_message_created(conversation_id) + end + end + + defp maybe_put_thread_id(changeset, thread_id) when is_integer(thread_id) do + Ecto.Changeset.put_change(changeset, :thread_id, thread_id) + end + + defp maybe_put_thread_id(changeset, _thread_id), do: changeset + + @doc """ + Registers or refreshes a browser chat encryption device for a user. + """ + def register_chat_encryption_device(user_id, attrs) + when is_integer(user_id) and is_map(attrs) do + now = Elektrine.Time.utc_now() + updated_at = DateTime.to_naive(now) + + device_attrs = %{ + user_id: user_id, + device_id: Map.get(attrs, "device_id") || Map.get(attrs, :device_id), + public_key: Map.get(attrs, "public_key") || Map.get(attrs, :public_key), + key_algorithm: + Map.get(attrs, "key_algorithm") || Map.get(attrs, :key_algorithm) || "RSA-OAEP-SHA256", + fingerprint: Map.get(attrs, "fingerprint") || Map.get(attrs, :fingerprint), + signing_public_key: + Map.get(attrs, "signing_public_key") || Map.get(attrs, :signing_public_key), + device_signature: Map.get(attrs, "device_signature") || Map.get(attrs, :device_signature), + label: Map.get(attrs, "label") || Map.get(attrs, :label), + last_seen_at: now, + revoked_at: nil + } + + %ChatEncryptionDevice{} + |> ChatEncryptionDevice.changeset(device_attrs) + |> Repo.insert( + on_conflict: [ + set: [ + public_key: device_attrs.public_key, + key_algorithm: device_attrs.key_algorithm, + fingerprint: device_attrs.fingerprint, + signing_public_key: device_attrs.signing_public_key, + device_signature: device_attrs.device_signature, + label: device_attrs.label, + last_seen_at: now, + revoked_at: nil, + updated_at: updated_at + ] + ], + conflict_target: [:user_id, :device_id] + ) + end + + @doc """ + Lists active chat encryption devices for local and known remote conversation participants. + """ + def list_chat_encryption_devices_for_conversation(conversation_id) + when is_integer(conversation_id) do + local_devices = + from(d in ChatEncryptionDevice, + join: cm in ChatConversationMember, + on: cm.user_id == d.user_id, + where: + cm.conversation_id == ^conversation_id and is_nil(cm.left_at) and is_nil(d.revoked_at), + order_by: [asc: d.user_id, asc: d.device_id], + select: %{ + user_id: d.user_id, + device_id: d.device_id, + public_key: d.public_key, + key_algorithm: d.key_algorithm, + fingerprint: d.fingerprint, + signing_public_key: d.signing_public_key, + device_signature: d.device_signature + } + ) + |> Repo.all() + + local_devices ++ list_remote_chat_encryption_devices_for_conversation(conversation_id) + end + + def list_chat_encryption_devices_for_conversation(_), do: [] + + @doc """ + Lists active chat encryption devices for a local user. + """ + def list_chat_encryption_devices_for_user(user_id) when is_integer(user_id) do + from(d in ChatEncryptionDevice, + where: d.user_id == ^user_id and is_nil(d.revoked_at), + order_by: [asc: d.device_id], + select: %{ + device_id: d.device_id, + public_key: d.public_key, + key_algorithm: d.key_algorithm, + fingerprint: d.fingerprint, + signing_public_key: d.signing_public_key, + device_signature: d.device_signature, + label: d.label + } + ) + |> Repo.all() + end + + def list_chat_encryption_devices_for_user(_), do: [] + + @doc """ + Stores chat encryption devices advertised by a remote actor. + """ + def register_remote_chat_encryption_devices(remote_actor, remote_domain) + when is_map(remote_actor) and is_binary(remote_domain) do + remote_handle = remote_actor_handle(remote_actor, remote_domain) + origin_domain = String.downcase(remote_domain) + now = Elektrine.Time.utc_now() + updated_at = DateTime.to_naive(now) + + remote_actor + |> Map.get("chat_encryption_devices", Map.get(remote_actor, :chat_encryption_devices, [])) + |> List.wrap() + |> Enum.take(64) + |> Enum.each(fn device -> + attrs = %{ + origin_domain: origin_domain, + remote_handle: remote_handle, + device_id: Map.get(device, "device_id") || Map.get(device, :device_id), + public_key: Map.get(device, "public_key") || Map.get(device, :public_key), + key_algorithm: + Map.get(device, "key_algorithm") || Map.get(device, :key_algorithm) || + "RSA-OAEP-SHA256", + fingerprint: Map.get(device, "fingerprint") || Map.get(device, :fingerprint), + signing_public_key: + Map.get(device, "signing_public_key") || Map.get(device, :signing_public_key), + device_signature: + Map.get(device, "device_signature") || Map.get(device, :device_signature), + label: Map.get(device, "label") || Map.get(device, :label), + last_seen_at: now, + revoked_at: nil + } + + %ChatRemoteEncryptionDevice{} + |> ChatRemoteEncryptionDevice.changeset(attrs) + |> Repo.insert( + on_conflict: [ + set: [ + public_key: attrs.public_key, + key_algorithm: attrs.key_algorithm, + fingerprint: attrs.fingerprint, + signing_public_key: attrs.signing_public_key, + device_signature: attrs.device_signature, + label: attrs.label, + last_seen_at: now, + revoked_at: nil, + updated_at: updated_at + ] + ], + conflict_target: [:origin_domain, :remote_handle, :device_id] + ) + end) + + :ok + end + + def register_remote_chat_encryption_devices(_, _), do: :ok + + @doc """ + Creates a browser-encrypted text message. + + The server stores only the encrypted payload and client-generated search tokens. + Conversation keys are stored once per recipient device instead of once per message. + """ + def create_client_encrypted_text_message(conversation_id, sender_id, attrs, opts \\ []) + when is_integer(conversation_id) and is_integer(sender_id) and is_map(attrs) do + with :ok <- ensure_writable_conversation(conversation_id, sender_id) do + reply_to_id = Keyword.get(opts, :reply_to_id) + + Repo.transaction(fn -> + with {:ok, encryption_key} <- + ensure_conversation_encryption_key(conversation_id, sender_id, attrs), + :ok <- upsert_conversation_key_recipients(encryption_key, attrs), + {:ok, message} <- + ChatMessage.client_encrypted_text_changeset( + conversation_id, + sender_id, + encrypted_payload(attrs), + encryption_key.id, + search_index(attrs), + reply_to_id + ) + |> Repo.insert() do + message + else + {:error, reason} -> Repo.rollback(reason) + end + end) + |> case do + {:ok, message} -> handle_message_created({:ok, message}, conversation_id) + {:error, reason} -> {:error, reason} + end + end + end + + @doc """ + Returns the wrapped conversation key for a user's device. + """ + def get_wrapped_chat_key(conversation_id, user_id, device_id, key_uid) + when is_integer(conversation_id) and is_integer(user_id) and is_binary(device_id) and + is_binary(key_uid) do + from(r in ChatConversationKeyRecipient, + join: k in ChatConversationEncryptionKey, + on: k.id == r.conversation_key_id, + join: d in ChatEncryptionDevice, + on: d.user_id == r.user_id and d.device_id == r.device_id, + join: cm in ChatConversationMember, + on: cm.conversation_id == k.conversation_id and cm.user_id == ^user_id, + where: + k.conversation_id == ^conversation_id and k.key_uid == ^key_uid and r.user_id == ^user_id and + r.device_id == ^device_id and is_nil(cm.left_at) and is_nil(d.revoked_at), + select: r.wrapped_key, + limit: 1 + ) + |> Repo.one() + |> case do + nil -> {:error, :not_found} + wrapped_key -> {:ok, wrapped_key} + end + end + + @doc """ + Creates a media message (image or file). + """ + def create_media_message( + conversation_id, + sender_id, + media_urls, + content \\ nil, + media_metadata \\ %{} + ) do + with :ok <- ensure_writable_conversation(conversation_id, sender_id) do + encrypt? = should_encrypt?(conversation_id) + + ChatMessage.media_changeset( + conversation_id, + sender_id, + media_urls, + content, + media_metadata, + encrypt? + ) + |> Repo.insert() + |> handle_message_created(conversation_id) + end + end + + @doc """ + Creates a voice message. + """ + def create_voice_message(conversation_id, sender_id, audio_url, duration, mime_type) do + with :ok <- ensure_writable_conversation(conversation_id, sender_id) do + ChatMessage.voice_changeset(conversation_id, sender_id, audio_url, duration, mime_type) + |> Repo.insert() + |> handle_message_created(conversation_id) + end + end + + @doc """ + Creates a text message authored by an incoming webhook. + + Webhook messages have no local sender: the webhook id is stored on the + message row and display metadata (name/avatar) is carried in + `media_metadata` under `"webhook_sender"` so clients can render the + webhook as the author. Token verification, authorization, and rate + limiting happen in `Elektrine.Messaging.ChatWebhooks`. + """ + def create_webhook_text_message(conversation_id, webhook_id, content, sender_meta) + when is_integer(conversation_id) and is_integer(webhook_id) and is_map(sender_meta) do + %ChatMessage{} + |> ChatMessage.changeset(%{ + conversation_id: conversation_id, + webhook_id: webhook_id, + content: content, + message_type: "text", + media_metadata: %{"webhook_sender" => sender_meta} + }) + |> Repo.insert() + |> handle_message_created(conversation_id) + end + + @doc """ + Creates a system message. + """ + def create_system_message(conversation_id, content) do + with :ok <- ensure_writable_conversation(conversation_id) do + ChatMessage.system_changeset(conversation_id, content) + |> Repo.insert() + |> handle_message_created(conversation_id) + end + end + + # Message editing + + @doc """ + Edits a message. + """ + def edit_message(message_id, user_id, new_content) do + case get_message(message_id) do + nil -> + {:error, :not_found} + + message -> + cond do + read_only_mirror_message?(message) -> + {:error, :read_only_mirror} + + unauthorized_write?(message.conversation_id, user_id) -> + {:error, :unauthorized} + + ChatMessage.can_edit?(message, user_id) -> + encrypt? = should_encrypt?(message.conversation_id) + + message + |> ChatMessage.edit_changeset(new_content, encrypt?) + |> Repo.update() + |> handle_message_updated() + + true -> + {:error, :unauthorized} + end + end + end + + # Message deletion + + @doc """ + Soft-deletes a message. + """ + def delete_message(message_id, user_id, is_admin \\ false) do + case get_message(message_id) do + nil -> + {:error, :not_found} + + message -> + cond do + read_only_mirror_message?(message) -> + {:error, :read_only_mirror} + + unauthorized_write?(message.conversation_id, user_id) -> + {:error, :unauthorized} + + ChatMessage.can_delete?(message, user_id, is_admin) -> + message + |> ChatMessage.delete_changeset() + |> Repo.update() + |> handle_message_deleted() + + true -> + {:error, :unauthorized} + end + end + end + + # Reactions + + @doc """ + Adds a reaction to a message. + """ + def add_reaction(message_id, user_id, emoji) do + case get_message(message_id) do + nil -> + {:error, :not_found} + + %ChatMessage{conversation_id: conversation_id} -> + with :ok <- ensure_writable_conversation(conversation_id, user_id) do + %ChatMessageReaction{} + |> ChatMessageReaction.changeset(%{ + chat_message_id: message_id, + user_id: user_id, + emoji: emoji + }) + |> Repo.insert() + |> case do + {:ok, reaction} -> + reaction = Repo.preload(reaction, [:user, :remote_actor]) + broadcast_reaction_added(message_id, reaction) + maybe_federate_reaction_added(message_id, reaction) + {:ok, reaction} + + {:error, changeset} -> + {:error, changeset} + end + end + end + end + + @doc """ + Removes a reaction from a message. + """ + def remove_reaction(message_id, user_id, emoji) do + case get_message(message_id) do + nil -> + {:error, :not_found} + + %ChatMessage{conversation_id: conversation_id} -> + with :ok <- ensure_writable_conversation(conversation_id, user_id) do + from(r in ChatMessageReaction, + where: + r.chat_message_id == ^message_id and r.user_id == ^user_id and r.emoji == ^emoji + ) + |> Repo.delete_all() + |> case do + {count, _} when count > 0 -> + broadcast_reaction_removed(message_id, user_id, emoji) + maybe_federate_reaction_removed(message_id, user_id, emoji) + {:ok, count} + + {0, _} -> + {:error, :not_found} + end + end + end + end + + # Read receipts + + @doc """ + Marks messages as read up to a certain message. + """ + def mark_messages_read(conversation_id, user_id, up_to_message_id \\ nil) do + with :ok <- ensure_participating_conversation(conversation_id, user_id) do + now = DateTime.utc_now() + + query = + from(m in ChatMessage, + left_join: h in ChatUserHiddenMessage, + on: h.chat_message_id == m.id and h.user_id == ^user_id, + where: m.conversation_id == ^conversation_id and is_nil(m.deleted_at) and is_nil(h.id), + select: m.id + ) + + query = limit_read_query(query, conversation_id, up_to_message_id) + + message_ids = Repo.all(query) + + entries = + Enum.map(message_ids, fn msg_id -> + %{chat_message_id: msg_id, user_id: user_id, read_at: now} + end) + + if entries != [] do + Repo.insert_all("chat_message_reads", entries, on_conflict: :nothing) + end + + latest_read_message_id = Enum.max(message_ids, fn -> nil end) + + if is_integer(latest_read_message_id) and + publishable_read_cursor_conversation?(conversation_id) do + Federation.publish_read_receipt(conversation_id, user_id, latest_read_message_id, now) + end + + from(cm in ChatConversationMember, + where: cm.conversation_id == ^conversation_id and cm.user_id == ^user_id + ) + |> Repo.update_all(set: [last_read_at: now, last_read_message_id: nil]) + + Elektrine.AppCache.invalidate_chat_cache(user_id) + broadcast_chat_unread_count(user_id) + Elektrine.Notifications.mark_as_read_by_sources(user_id, "message", message_ids) + + :ok + end + end + + @doc """ + Gets unread message count for a user in a conversation. + """ + def get_unread_count(conversation_id, user_id) do + from(m in ChatMessage, + left_join: h in ChatUserHiddenMessage, + on: h.chat_message_id == m.id and h.user_id == ^user_id, + left_join: r in "chat_message_reads", + on: field(r, :chat_message_id) == m.id and field(r, :user_id) == ^user_id, + where: + m.conversation_id == ^conversation_id and + is_nil(h.id) and + is_nil(m.deleted_at) and + m.sender_id != ^user_id and + is_nil(field(r, :chat_message_id)), + select: count(m.id) + ) + |> Repo.one() + end + + defp limit_read_query(query, _conversation_id, nil), do: query + + defp limit_read_query(query, conversation_id, up_to_message_id) do + case Repo.get_by(ChatMessage, id: up_to_message_id, conversation_id: conversation_id) do + %ChatMessage{inserted_at: inserted_at} -> + from(m in query, + where: + m.inserted_at < ^inserted_at or + (m.inserted_at == ^inserted_at and m.id <= ^up_to_message_id) + ) + + nil -> + from(m in query, where: false) + end + end + + @doc """ + Gets unread counts for all of a user's conversations. + """ + def get_all_unread_counts(user_id) do + from(cm in ChatConversationMember, + join: c in ChatConversation, + on: c.id == cm.conversation_id, + join: m in ChatMessage, + on: m.conversation_id == cm.conversation_id, + left_join: h in ChatUserHiddenMessage, + on: h.chat_message_id == m.id and h.user_id == ^user_id, + left_join: r in "chat_message_reads", + on: field(r, :chat_message_id) == m.id and field(r, :user_id) == ^user_id, + where: + cm.user_id == ^user_id and + is_nil(cm.left_at) and + c.type in ["dm", "group", "channel"] and + is_nil(h.id) and + is_nil(m.deleted_at) and + m.sender_id != ^user_id and + is_nil(field(r, :chat_message_id)), + group_by: cm.conversation_id, + select: {cm.conversation_id, count(m.id)} + ) + |> Repo.all() + |> Map.new() + end + + @doc """ + Gets unread counts for a specific set of conversation IDs. + """ + def get_conversation_unread_counts(conversation_ids, user_id) when is_list(conversation_ids) do + ids = Enum.uniq(conversation_ids) + + # Single grouped query instead of one COUNT per conversation (was N+1). + # Mirrors get_unread_count/2's exact filters so per-conversation counts are + # identical; conversations with no unread rows are 0-filled below. + counts = + from(m in ChatMessage, + left_join: h in ChatUserHiddenMessage, + on: h.chat_message_id == m.id and h.user_id == ^user_id, + left_join: r in "chat_message_reads", + on: field(r, :chat_message_id) == m.id and field(r, :user_id) == ^user_id, + where: + m.conversation_id in ^ids and + is_nil(h.id) and + is_nil(m.deleted_at) and + m.sender_id != ^user_id and + is_nil(field(r, :chat_message_id)), + group_by: m.conversation_id, + select: {m.conversation_id, count(m.id)} + ) + |> Repo.all() + |> Map.new() + + Map.new(ids, fn id -> {id, Map.get(counts, id, 0)} end) + end + + @doc """ + Gets total unread chat count across DM/group/channel conversations. + """ + def get_total_unread_count(user_id) do + user_id + |> get_all_unread_counts() + |> Map.values() + |> Enum.sum() + end + + defp broadcast_chat_unread_count(user_id) when is_integer(user_id) do + count = get_total_unread_count(user_id) + + Enum.each(["user:#{user_id}", "user:#{user_id}:notification_count"], fn topic -> + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + topic, + {:chat_unread_count_updated, count} + ) + end) + end + + @doc """ + Updates last read state up to a specific chat message. + """ + def update_last_read_message(conversation_id, user_id, message_id) do + case get_conversation_member(conversation_id, user_id) do + nil -> + {:error, :unauthorized} + + _member -> + case mark_messages_read(conversation_id, user_id, message_id) do + :ok -> {:ok, :updated} + {:error, _reason} = error -> error + end + end + end + + @doc """ + Gets the last read message ID for a conversation member. + """ + def get_last_read_message_id(conversation_id, user_id) do + from(r in "chat_message_reads", + join: m in ChatMessage, + on: m.id == r.chat_message_id, + left_join: h in ChatUserHiddenMessage, + on: h.chat_message_id == m.id and h.user_id == ^user_id, + where: + r.user_id == ^user_id and + m.conversation_id == ^conversation_id and + is_nil(m.deleted_at) and + is_nil(h.id), + select: max(r.chat_message_id) + ) + |> Repo.one() + end + + @doc """ + Hides all current chat messages in a conversation for a specific user. + """ + def clear_history_for_user(conversation_id, user_id) do + case get_conversation_member(conversation_id, user_id) do + nil -> + {:error, :unauthorized} + + _member -> + message_ids = + from(m in ChatMessage, + where: m.conversation_id == ^conversation_id and is_nil(m.deleted_at), + select: m.id + ) + |> Repo.all() + + now = DateTime.utc_now() |> DateTime.truncate(:second) + inserted_at = DateTime.to_naive(now) + + hidden_records = + Enum.map(message_ids, fn message_id -> + %{ + user_id: user_id, + chat_message_id: message_id, + hidden_at: now, + inserted_at: inserted_at, + updated_at: inserted_at + } + end) + + if hidden_records != [] do + Repo.insert_all(ChatUserHiddenMessage, hidden_records, on_conflict: :nothing) + end + + from(cm in ChatConversationMember, + where: cm.conversation_id == ^conversation_id and cm.user_id == ^user_id + ) + |> Repo.update_all(set: [last_read_at: now, last_read_message_id: nil]) + + Elektrine.AppCache.invalidate_chat_cache(user_id) + + {:ok, :cleared} + end + end + + @doc """ + Gets users who have read a specific chat message. + """ + def get_message_readers(message_id, conversation_id) do + sender_id = + from(m in ChatMessage, + where: m.id == ^message_id and m.conversation_id == ^conversation_id, + select: m.sender_id + ) + |> Repo.one() + + local_readers = + from(r in "chat_message_reads", + join: u in Elektrine.Accounts.User, + on: u.id == r.user_id, + where: + r.chat_message_id == ^message_id and + (is_nil(^sender_id) or r.user_id != ^sender_id), + select: %{user_id: u.id, username: u.username, avatar: u.avatar, read_at: r.read_at} + ) + |> Repo.all() + + remote_readers = + from(rr in FederationReadCursor, + join: actor in ActivityPubActor, + on: actor.id == rr.remote_actor_id, + where: + rr.conversation_id == ^conversation_id and + rr.chat_message_id >= ^message_id, + select: %{ + user_id: nil, + remote_actor_id: actor.id, + username: actor.username, + display_name: actor.display_name, + domain: actor.domain, + avatar: actor.avatar_url, + read_at: rr.read_at + } + ) + |> Repo.all() + |> Enum.map(fn reader -> + %{ + user_id: nil, + remote_actor_id: reader.remote_actor_id, + username: remote_reader_label(reader), + avatar: reader.avatar, + read_at: reader.read_at + } + end) + + dedupe_readers(local_readers ++ remote_readers) + end + + @doc """ + Gets read status for chat messages in a conversation. + """ + def get_read_status_for_messages(message_ids, conversation_id) when is_list(message_ids) do + if message_ids == [] do + %{} + else + messages = + from(m in ChatMessage, + where: m.id in ^message_ids and m.conversation_id == ^conversation_id, + select: %{id: m.id, sender_id: m.sender_id} + ) + |> Repo.all() + + message_sender_map = Map.new(messages, &{&1.id, &1.sender_id}) + + reads_by_message = + from(r in "chat_message_reads", + join: u in Elektrine.Accounts.User, + on: u.id == r.user_id, + where: r.chat_message_id in ^message_ids, + select: %{ + message_id: r.chat_message_id, + user_id: u.id, + username: u.username, + avatar: u.avatar + } + ) + |> Repo.all() + |> Enum.group_by(& &1.message_id) + + remote_read_cursors = + from(rr in FederationReadCursor, + join: actor in ActivityPubActor, + on: actor.id == rr.remote_actor_id, + where: rr.conversation_id == ^conversation_id, + select: %{ + read_through_message_id: rr.chat_message_id, + user_id: nil, + remote_actor_id: actor.id, + username: actor.username, + display_name: actor.display_name, + domain: actor.domain, + avatar: actor.avatar_url + } + ) + |> Repo.all() + |> Enum.map(fn reader -> + %{ + read_through_message_id: reader.read_through_message_id, + user_id: nil, + remote_actor_id: reader.remote_actor_id, + username: remote_reader_label(reader), + avatar: reader.avatar + } + end) + + Enum.reduce(message_ids, %{}, fn message_id, acc -> + sender_id = Map.get(message_sender_map, message_id) + + local_readers = + reads_by_message + |> Map.get(message_id, []) + |> Enum.reject(fn reader -> reader.user_id == sender_id end) + + remote_readers = + remote_read_cursors + |> Enum.filter(&(&1.read_through_message_id >= message_id)) + |> Enum.map(fn reader -> + %{ + user_id: nil, + remote_actor_id: reader.remote_actor_id, + username: reader.username, + avatar: reader.avatar + } + end) + + readers = dedupe_readers(local_readers ++ remote_readers) + + Map.put(acc, message_id, readers) + end) + end + end + + @doc """ + Gets read status for last messages across multiple conversations. + """ + def get_batch_last_message_read_status(message_info_list) when is_list(message_info_list) do + if message_info_list == [] do + %{} + else + message_ids = + Enum.map(message_info_list, fn {_conversation_id, message_id, _} -> message_id end) + + senders_by_message = + from(m in ChatMessage, + where: m.id in ^message_ids, + select: {m.id, m.sender_id} + ) + |> Repo.all() + |> Map.new() + + reads_by_message = + from(r in "chat_message_reads", + where: r.chat_message_id in ^message_ids, + select: {r.chat_message_id, r.user_id} + ) + |> Repo.all() + |> Enum.group_by(fn {message_id, _user_id} -> message_id end, fn {_message_id, user_id} -> + user_id + end) + + conversation_ids = + message_info_list + |> Enum.map(fn {conversation_id, _message_id, _inserted_at} -> conversation_id end) + |> Enum.uniq() + + remote_read_cursors_by_conversation = + from(rr in FederationReadCursor, + where: rr.conversation_id in ^conversation_ids, + select: {rr.conversation_id, rr.chat_message_id} + ) + |> Repo.all() + |> Enum.group_by( + fn {conversation_id, _message_id} -> conversation_id end, + fn {_conversation_id, message_id} -> + message_id + end + ) + + message_info_list + |> Enum.map(fn {conversation_id, message_id, _inserted_at} -> + sender_id = Map.get(senders_by_message, message_id) + + local_reader_count = + reads_by_message + |> Map.get(message_id, []) + |> Enum.reject(&(&1 == sender_id)) + |> Enum.uniq() + |> length() + + remote_reader_count = + remote_read_cursors_by_conversation + |> Map.get(conversation_id, []) + |> Enum.count(&(&1 >= message_id)) + + reader_count = local_reader_count + remote_reader_count + + {conversation_id, %{is_read: reader_count > 0, reader_count: reader_count}} + end) + |> Map.new() + end + end + + defp dedupe_readers(readers) when is_list(readers) do + readers + |> Enum.uniq_by(fn reader -> + cond do + is_integer(reader[:user_id]) -> {:user, reader[:user_id]} + is_integer(reader[:remote_actor_id]) -> {:remote_actor, reader[:remote_actor_id]} + true -> {:fallback, reader[:username], reader[:avatar]} + end + end) + end + + defp dedupe_readers(_), do: [] + + defp remote_reader_label(reader) when is_map(reader) do + username = reader[:username] || reader["username"] || "remote" + display_name = reader[:display_name] || reader["display_name"] + domain = reader[:domain] || reader["domain"] + handle = if is_binary(domain), do: "@#{username}@#{domain}", else: "@#{username}" + + if Elektrine.Strings.present?(display_name) and display_name != username do + "#{display_name} (#{handle})" + else + handle + end + end + + # Search + + @doc """ + Searches messages in a conversation. + Uses blind `search_index` tokens derived before content is encrypted. + """ + def search_messages(conversation_id, query, opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + user_id = Keyword.get(opts, :user_id) + + search_tokens = + Keyword.get(opts, :search_tokens, []) |> List.wrap() |> Enum.filter(&is_binary/1) + + keywords = Elektrine.Encryption.extract_keywords(query) + + if is_nil(user_id) or (Enum.empty?(keywords) and search_tokens == []) do + [] + else + keyword_hashes = + keywords + |> Enum.map(fn keyword -> Elektrine.Encryption.hash_keyword(keyword, user_id) end) + |> Kernel.++(search_tokens) + |> Enum.uniq() + + from(m in ChatMessage, + left_join: h in ChatUserHiddenMessage, + on: h.chat_message_id == m.id and h.user_id == ^user_id, + where: + m.conversation_id == ^conversation_id and + is_nil(m.deleted_at) and + is_nil(h.id) and + fragment("? && ?", m.search_index, ^keyword_hashes), + order_by: [desc: m.inserted_at], + limit: ^limit, + preload: [:sender, reactions: [:user, :remote_actor]] + ) + |> Repo.all() + |> ChatMessage.decrypt_messages() + |> hydrate_remote_senders() + end + end + + # Private helpers + + # Existing plaintext rows still decrypt as-is; new local chat content is stored encrypted. + defp should_encrypt?(_conversation_id), do: true + + defp list_remote_chat_encryption_devices_for_conversation(conversation_id) do + with %ChatConversation{type: "dm", federated_source: source} <- + Repo.get(ChatConversation, conversation_id), + handle when is_binary(handle) <- + Elektrine.Messaging.Federation.DirectMessageState.remote_dm_handle_from_source(source) do + from(d in ChatRemoteEncryptionDevice, + where: d.remote_handle == ^handle and is_nil(d.revoked_at), + order_by: [asc: d.origin_domain, asc: d.device_id], + select: %{ + recipient_handle: d.remote_handle, + origin_domain: d.origin_domain, + device_id: d.device_id, + public_key: d.public_key, + key_algorithm: d.key_algorithm, + fingerprint: d.fingerprint, + signing_public_key: d.signing_public_key, + device_signature: d.device_signature + } + ) + |> Repo.all() + else + _ -> [] + end + end + + defp remote_actor_handle(remote_actor, remote_domain) do + handle = Map.get(remote_actor, "handle") || Map.get(remote_actor, :handle) + + if is_binary(handle) and String.contains?(handle, "@") do + handle + else + username = Map.get(remote_actor, "username") || Map.get(remote_actor, :username) || "remote" + domain = Map.get(remote_actor, "domain") || Map.get(remote_actor, :domain) || remote_domain + "#{username}@#{domain}" + end + end + + defp ensure_conversation_encryption_key(conversation_id, sender_id, attrs) do + key_uid = key_uid(attrs) + + existing_key = + Repo.get_by(ChatConversationEncryptionKey, + conversation_id: conversation_id, + key_uid: key_uid + ) + + cond do + not is_binary(key_uid) or String.trim(key_uid) == "" -> + {:error, :invalid_encrypted_payload} + + existing_key -> + {:ok, existing_key} + + key_packages(attrs) == [] -> + {:error, :missing_key_packages} + + true -> + %ChatConversationEncryptionKey{} + |> ChatConversationEncryptionKey.changeset(%{ + conversation_id: conversation_id, + key_uid: key_uid, + created_by_id: sender_id, + algorithm: "AES-256-GCM", + active: true, + metadata: %{"source" => "browser"} + }) + |> Repo.insert() + end + end + + defp upsert_conversation_key_recipients(%ChatConversationEncryptionKey{} = key, attrs) do + packages = key_packages(attrs) + + entries = + packages + |> Enum.map(&normalize_key_package(key.id, &1)) + |> Enum.reject(&is_nil/1) + + active_device_keys = active_conversation_device_keys(key.conversation_id) + + cond do + packages == [] -> + :ok + + length(entries) != length(packages) -> + {:error, :invalid_key_package} + + Enum.any?(entries, fn entry -> + not MapSet.member?(active_device_keys, {entry.user_id, entry.device_id}) + end) -> + {:error, :invalid_key_recipient} + + true -> + {_count, _rows} = + Repo.insert_all(ChatConversationKeyRecipient, entries, + on_conflict: {:replace, [:wrapped_key, :updated_at]}, + conflict_target: [:conversation_key_id, :user_id, :device_id] + ) + + :ok + end + end + + defp active_conversation_device_keys(conversation_id) do + from(d in ChatEncryptionDevice, + join: cm in ChatConversationMember, + on: cm.user_id == d.user_id, + where: + cm.conversation_id == ^conversation_id and is_nil(cm.left_at) and is_nil(d.revoked_at), + select: {d.user_id, d.device_id} + ) + |> Repo.all() + |> MapSet.new() + end + + defp normalize_key_package(conversation_key_id, package) when is_map(package) do + user_id = Map.get(package, "user_id") || Map.get(package, :user_id) + device_id = Map.get(package, "device_id") || Map.get(package, :device_id) + wrapped_key = Map.get(package, "wrapped_key") || Map.get(package, :wrapped_key) + now = Elektrine.Time.utc_now() |> DateTime.to_naive() + + if is_integer(user_id) and is_binary(device_id) and valid_wrapped_key?(wrapped_key) do + %{ + conversation_key_id: conversation_key_id, + user_id: user_id, + device_id: device_id, + wrapped_key: wrapped_key, + inserted_at: now, + updated_at: now + } + end + end + + defp normalize_key_package(_, _), do: nil + + defp valid_wrapped_key?(%{} = wrapped_key) do + version = Map.get(wrapped_key, "version") || Map.get(wrapped_key, :version) + key_algorithm = Map.get(wrapped_key, "key_algorithm") || Map.get(wrapped_key, :key_algorithm) + encrypted_key = Map.get(wrapped_key, "encrypted_key") || Map.get(wrapped_key, :encrypted_key) + + version in [1, "1"] and key_algorithm == "RSA-OAEP-SHA256" and + valid_base64_size?(encrypted_key, 17) + end + + defp valid_wrapped_key?(_), do: false + + defp valid_base64_size?(value, min_size) when is_binary(value) do + case Base.decode64(value) do + {:ok, decoded} -> byte_size(decoded) >= min_size + :error -> false + end + end + + defp valid_base64_size?(_, _), do: false + + defp encrypted_payload(attrs) do + Map.get(attrs, "encrypted_payload") || Map.get(attrs, :encrypted_payload) || + Map.get(attrs, "client_encrypted_payload") || Map.get(attrs, :client_encrypted_payload) + end + + defp key_uid(attrs) do + payload = encrypted_payload(attrs) || %{} + + Map.get(attrs, "key_uid") || Map.get(attrs, :key_uid) || Map.get(payload, "key_uid") || + Map.get(payload, :key_uid) + end + + defp key_packages(attrs) do + attrs + |> Map.get("key_packages", Map.get(attrs, :key_packages, [])) + |> List.wrap() + end + + defp search_index(attrs) do + attrs + |> Map.get("search_index", Map.get(attrs, :search_index, [])) + |> List.wrap() + |> Enum.filter(&(is_binary(&1) and byte_size(&1) <= 256)) + |> Enum.uniq() + |> Enum.take(128) + end + + defp handle_message_created({:ok, message}, conversation_id) do + # Preload associations + message = + Repo.preload(message, [ + :sender, + :link_preview, + reply_to: [:sender], + reactions: [:user, :remote_actor] + ]) + + # Decrypt for API response and broadcasting + decrypted = message |> ChatMessage.decrypt_content() |> hydrate_remote_sender() + + Elektrine.Async.run(fn -> extract_and_attach_link_preview(message) end) + + # Update conversation's last_message_at + from(c in ChatConversation, where: c.id == ^conversation_id) + |> Repo.update_all(set: [last_message_at: message.inserted_at]) + + # Invalidate chat cache for all conversation members + member_ids = + from(cm in ChatConversationMember, + where: cm.conversation_id == ^conversation_id and is_nil(cm.left_at), + select: cm.user_id + ) + |> Repo.all() + + Enum.each(member_ids, &Elektrine.AppCache.invalidate_chat_cache/1) + + # Broadcast to conversation. Thread messages get their own event so open + # LiveViews update the thread panel instead of the main timeline. + if is_integer(decrypted.thread_id) do + broadcast_new_thread_message(conversation_id, decrypted) + Elektrine.Messaging.ChatThreads.record_message_activity(decrypted) + else + broadcast_new_message(conversation_id, decrypted) + end + + maybe_federate_message_created(conversation_id, decrypted) + + # Notification fan-out costs several queries per member, so large group + # chats must not pay for it on the sender's request path. + Elektrine.Async.run(fn -> maybe_notify_chat_members(conversation_id, decrypted) end) + + {:ok, decrypted} + end + + defp handle_message_created(error, _conversation_id), do: error + + defp handle_message_updated({:ok, message}) do + message = + Repo.preload(message, [ + :sender, + :link_preview, + reply_to: [:sender], + reactions: [:user, :remote_actor] + ]) + + decrypted = message |> ChatMessage.decrypt_content() |> hydrate_remote_sender() + + broadcast_message_updated(decrypted.conversation_id, decrypted) + maybe_federate_message_updated(decrypted) + + {:ok, decrypted} + end + + defp handle_message_updated(error), do: error + + defp handle_message_deleted({:ok, message}) do + broadcast_message_deleted(message.conversation_id, message.id) + maybe_federate_message_deleted(message) + {:ok, message} + end + + defp handle_message_deleted(error), do: error + + # PubSub broadcasts + + defp broadcast_new_message(conversation_id, message) do + topic = PubSubTopics.conversation(conversation_id) + Phoenix.PubSub.broadcast(Elektrine.PubSub, topic, {:new_chat_message, message}) + end + + defp broadcast_new_thread_message(conversation_id, message) do + topic = PubSubTopics.conversation(conversation_id) + Phoenix.PubSub.broadcast(Elektrine.PubSub, topic, {:new_thread_message, message}) + end + + defp broadcast_message_updated(conversation_id, message) do + topic = PubSubTopics.conversation(conversation_id) + Phoenix.PubSub.broadcast(Elektrine.PubSub, topic, {:chat_message_updated, message}) + end + + defp broadcast_message_deleted(conversation_id, message_id) do + topic = PubSubTopics.conversation(conversation_id) + Phoenix.PubSub.broadcast(Elektrine.PubSub, topic, {:chat_message_deleted, message_id}) + end + + defp extract_and_attach_link_preview(message) do + decrypted = ChatMessage.decrypt_content(message) + urls = LinkPreviewFetcher.extract_urls(decrypted.content) + + case urls do + [url | _] -> + _ = FetchLinkPreviewWorker.enqueue(url) + + preview = + Repo.get_by(LinkPreview, url: url) + + if preview do + updated_message = attach_link_preview(message, preview) + + case preview.status do + "success" -> + broadcast_preview_update(updated_message, preview) + + "pending" -> + Elektrine.Async.start(fn -> + poll_and_broadcast_preview(updated_message, preview.id, 15) + end) + + _ -> + :ok + end + else + :ok + end + + _ -> + :ok + end + end + + defp poll_and_broadcast_preview(message, preview_id, attempts_left) do + LinkPreviewBroadcast.poll_and_broadcast( + message, + preview_id, + attempts_left, + link_preview_broadcast_opts() + ) + end + + defp attach_link_preview(message, preview) do + if message.link_preview_id == preview.id do + %{message | link_preview: preview} + else + {:ok, updated_message} = + message + |> ChatMessage.changeset(%{link_preview_id: preview.id}) + |> Repo.update() + + %{updated_message | link_preview: preview} + end + end + + defp broadcast_preview_update(message, preview) do + LinkPreviewBroadcast.broadcast_update(message, preview, link_preview_broadcast_opts()) + end + + defp link_preview_broadcast_opts do + [ + preload: [:sender, :reply_to, :reactions], + transform: &hydrate_remote_sender/1, + topic: fn message -> PubSubTopics.conversation(message.conversation_id) end + ] + end + + defp broadcast_reaction_added(message_id, reaction) do + message = get_message(message_id) + + if message do + topic = PubSubTopics.conversation(message.conversation_id) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + topic, + {:chat_reaction_added, message_id, reaction} + ) + end + end + + defp broadcast_reaction_removed(message_id, user_id, emoji) do + message = get_message(message_id) + + if message do + topic = PubSubTopics.conversation(message.conversation_id) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + topic, + {:chat_reaction_removed, message_id, user_id, emoji} + ) + end + end + + # Thread messages federate as regular channel messages carrying an optional + # thread reference (see Federation.Utils.event_message_payload/1); peers + # without thread support show them in the channel timeline. + defp maybe_federate_message_created(conversation_id, message) do + case Repo.get(ChatConversation, conversation_id) do + %ChatConversation{type: "channel"} -> + Federation.publish_message_created(message) + + %ChatConversation{type: "dm"} when is_integer(message.sender_id) -> + :ok + + _ -> + :ok + end + end + + defp maybe_federate_message_updated(message) do + case Repo.get(ChatConversation, message.conversation_id) do + %ChatConversation{type: "channel"} -> + Federation.publish_message_updated(message) + + _ -> + :ok + end + end + + defp maybe_federate_message_deleted(message) do + case Repo.get(ChatConversation, message.conversation_id) do + %ChatConversation{type: "channel"} -> + Federation.publish_message_deleted(message) + + _ -> + :ok + end + end + + defp maybe_federate_reaction_added(message_id, reaction) do + case get_message(message_id) do + %ChatMessage{} = message -> + case Repo.get(ChatConversation, message.conversation_id) do + %ChatConversation{type: "channel"} -> + Federation.publish_reaction_added(message, reaction) + + _ -> + :ok + end + + _ -> + :ok + end + end + + defp maybe_federate_reaction_removed(message_id, user_id, emoji) do + case get_message(message_id) do + %ChatMessage{} = message -> + case Repo.get(ChatConversation, message.conversation_id) do + %ChatConversation{type: "channel"} -> + Federation.publish_reaction_removed(message, user_id, emoji) + + _ -> + :ok + end + + _ -> + :ok + end + end + + defp hydrate_remote_senders(messages) when is_list(messages) do + Enum.map(messages, &hydrate_remote_sender/1) + end + + defp hydrate_remote_senders(_), do: [] + + defp hydrate_remote_sender(nil), do: nil + + defp hydrate_remote_sender(%ChatMessage{} = message) do + reply_to = + case message.reply_to do + %ChatMessage{} = reply -> hydrate_remote_sender(reply) + other -> other + end + + message = %{message | reply_to: reply_to} + + if is_nil(message.sender) do + case webhook_sender_from_metadata(message.media_metadata) || + remote_sender_from_metadata(message.media_metadata) do + nil -> + message + + hydrated_sender -> + %{message | sender: hydrated_sender} + end + else + message + end + end + + defp hydrate_remote_sender(message), do: message + + defp ensure_writable_conversation(conversation_id, sender_id) + when is_integer(conversation_id) and is_integer(sender_id) do + case RoomACL.authorize_local_user_action(conversation_id, sender_id, :write) do + :ok -> ensure_dm_privacy_allows_send(conversation_id, sender_id) + error -> error + end + end + + defp ensure_writable_conversation(_conversation_id, _sender_id), do: {:error, :unauthorized} + + defp ensure_writable_conversation(conversation_id) when is_integer(conversation_id), do: :ok + + defp ensure_writable_conversation(_conversation_id), do: :ok + + defp ensure_dm_privacy_allows_send(conversation_id, sender_id) do + case Repo.get(ChatConversation, conversation_id) do + %ChatConversation{type: "dm", federated_source: nil} -> + conversation_id + |> local_dm_recipient_id(sender_id) + |> case do + nil -> + :ok + + recipient_id -> + case Elektrine.Privacy.can_send_dm?(sender_id, recipient_id) do + {:ok, :allowed} -> :ok + {:error, reason} -> {:error, reason} + end + end + + _ -> + :ok + end + end + + defp local_dm_recipient_id(conversation_id, sender_id) do + from(cm in ChatConversationMember, + where: + cm.conversation_id == ^conversation_id and cm.user_id != ^sender_id and is_nil(cm.left_at), + select: cm.user_id, + limit: 1 + ) + |> Repo.one() + end + + defp ensure_participating_conversation(conversation_id, user_id) + when is_integer(conversation_id) and is_integer(user_id) do + RoomACL.authorize_local_user_action(conversation_id, user_id, :participate) + end + + defp ensure_participating_conversation(_conversation_id, _user_id), do: {:error, :unauthorized} + + defp unauthorized_write?(conversation_id, user_id) + when is_integer(conversation_id) and is_integer(user_id) do + ensure_writable_conversation(conversation_id, user_id) != :ok + end + + defp unauthorized_write?(_conversation_id, _user_id), do: true + + defp read_only_mirror_message?(%ChatMessage{ + conversation_id: conversation_id, + sender_id: sender_id + }) do + read_only_mirror_conversation?(conversation_id) and is_nil(sender_id) + end + + defp read_only_mirror_message?(_message), do: false + + defp publishable_read_cursor_conversation?(conversation_id) when is_integer(conversation_id) do + case Repo.get(ChatConversation, conversation_id) do + %ChatConversation{type: "channel"} -> true + _ -> false + end + end + + defp read_only_mirror_conversation?(conversation_id) when is_integer(conversation_id) do + match?( + %ChatConversation{type: "channel", is_federated_mirror: true}, + Repo.get(ChatConversation, conversation_id) + ) + end + + defp read_only_mirror_conversation?(_conversation_id), do: false + + # Webhook-authored messages carry display metadata under "webhook_sender" + # (see `create_webhook_text_message/4`). Hydrate a sender-shaped map so the + # UI renders the webhook name/avatar; `is_bot`/`webhook` let clients show a + # bot/app badge. + defp webhook_sender_from_metadata(metadata) when is_map(metadata) do + sender = metadata["webhook_sender"] || metadata[:webhook_sender] + + if is_map(sender) do + name = sender["name"] || sender[:name] || "Webhook" + avatar = sender["avatar_url"] || sender[:avatar_url] + + %{ + id: nil, + username: to_string(name), + display_name: to_string(name), + handle: to_string(name), + avatar: avatar, + remote: false, + remote_domain: nil, + webhook: true, + webhook_id: sender["webhook_id"] || sender[:webhook_id], + is_bot: true + } + else + nil + end + end + + defp webhook_sender_from_metadata(_), do: nil + + defp remote_sender_from_metadata(metadata) when is_map(metadata) do + sender = metadata["remote_sender"] || metadata[:remote_sender] + + if is_map(sender) do + username = + sender["username"] || sender[:username] || sender["handle"] || sender[:handle] || "remote" + + display_name = sender["display_name"] || sender[:display_name] || username + domain = sender["domain"] || sender[:domain] + handle = sender["handle"] || sender[:handle] || remote_handle(username, domain) + avatar = sender["avatar"] || sender[:avatar] || sender["avatar_url"] || sender[:avatar_url] + + %{ + id: nil, + username: to_string(username), + display_name: to_string(display_name), + handle: to_string(handle), + avatar: avatar, + remote: true, + remote_domain: domain + } + else + nil + end + end + + defp remote_sender_from_metadata(_), do: nil + + defp remote_handle(username, domain) when is_binary(domain) do + if Elektrine.Strings.present?(domain), do: "#{username}@#{domain}", else: to_string(username) + end + + defp remote_handle(username, _domain), do: to_string(username) + + defp maybe_notify_chat_members(conversation_id, %ChatMessage{sender_id: sender_id} = message) + when is_integer(sender_id) do + content = message.content || ChatMessage.display_content(message) || "" + notify_chat_members(conversation_id, sender_id, content, message.id, message.reply_to_id) + end + + defp maybe_notify_chat_members(_conversation_id, _message), do: :ok + + defp notify_chat_members(conversation_id, sender_id, content, message_id, reply_to_id) do + sender = Accounts.get_user!(sender_id) + + case Repo.get(ChatConversation, conversation_id) do + %ChatConversation{} = conversation -> + original_sender_id = + if reply_to_id do + from(m in ChatMessage, where: m.id == ^reply_to_id, select: m.sender_id) + |> Repo.one() + end + + if original_sender_id && original_sender_id != sender_id do + user = Accounts.get_user!(original_sender_id) + + if notification_preference_enabled?(user, :notify_on_reply) do + Elektrine.Notifications.create_notification(%{ + user_id: original_sender_id, + actor_id: sender_id, + type: "reply", + title: "New reply from @#{sender.handle || sender.username}", + body: + "@#{sender.handle || sender.username} replied to your message: #{String.slice(content, 0, 50)}#{if String.length(content) > 50, do: "...", else: ""}", + url: Elektrine.Paths.chat_message_path(conversation_id, message_id), + source_type: "message", + source_id: message_id, + priority: "normal" + }) + end + end + + members_with_preferences = + from(cm in ChatConversationMember, + join: u in Accounts.User, + on: u.id == cm.user_id, + where: + cm.conversation_id == ^conversation_id and + cm.user_id != ^sender_id and + is_nil(cm.left_at), + select: %{ + user_id: u.id, + username: u.username, + handle: u.handle, + notify_on_direct_message: u.notify_on_direct_message + } + ) + |> Repo.all() + + Enum.each(members_with_preferences, fn member -> + unless reply_to_id && member.user_id == original_sender_id do + should_notify = + case conversation.type do + "dm" -> notification_preference_enabled?(member, :notify_on_direct_message) + _ -> true + end + + if should_notify do + {title, message_text} = + case conversation.type do + "dm" -> + {"Message from #{sender.username}", + "#{String.slice(content, 0, 100)}#{if String.length(content) > 100, do: "...", else: ""}"} + + "group" -> + {"#{conversation.name || "Group"} message", + "#{sender.username}: #{String.slice(content, 0, 80)}#{if String.length(content) > 80, do: "...", else: ""}"} + + "channel" -> + {"##{conversation.name || "channel"}", + "#{sender.username}: #{String.slice(content, 0, 80)}#{if String.length(content) > 80, do: "...", else: ""}"} + + _ -> + {"New message", "From #{sender.username}"} + end + + Elektrine.Notifications.create_notification(%{ + user_id: member.user_id, + actor_id: sender_id, + type: "new_message", + title: title, + body: message_text, + url: Elektrine.Paths.chat_message_path(conversation_id, message_id), + source_type: "message", + source_id: message_id, + priority: if(conversation.type == "dm", do: "normal", else: "low") + }) + end + end + end) + + notify_mentions_in_chat(content, members_with_preferences, sender, sender_id, message_id) + + _ -> + :ok + end + end + + defp notification_preference_enabled?(subject, field) when is_atom(field) do + Map.get(subject, field) != false + end + + defp notify_mentions_in_chat(content, members_with_preferences, sender, sender_id, message_id) do + mentioned_user_ids = + extract_mentioned_user_ids(content, members_with_preferences) + |> Enum.reject(&(&1 == sender_id)) + + context = mention_context(content) + + Enum.each(mentioned_user_ids, fn mentioned_user_id -> + case Elektrine.Privacy.can_mention?(sender_id, mentioned_user_id) do + {:ok, :allowed} -> + Elektrine.Notifications.notify_mention( + mentioned_user_id, + sender, + "message", + message_id, + context + ) + + _ -> + :ok + end + end) + end + + defp extract_mentioned_user_ids(content, members_with_preferences) when is_binary(content) do + lookup = + Enum.reduce(members_with_preferences, %{}, fn member, acc -> + acc + |> put_mention_lookup(member.username, member.user_id) + |> put_mention_lookup(member.handle, member.user_id) + end) + + Regex.scan(@mention_pattern, content, capture: :all_but_first) + |> List.flatten() + |> Enum.map(&String.downcase/1) + |> Enum.uniq() + |> Enum.flat_map(fn mention -> Map.get(lookup, mention, []) end) + |> Enum.uniq() + end + + defp extract_mentioned_user_ids(_, _), do: [] + + defp put_mention_lookup(lookup, identifier, user_id) when is_binary(identifier) do + key = String.downcase(identifier) + Map.update(lookup, key, [user_id], &[user_id | &1]) + end + + defp put_mention_lookup(lookup, _identifier, _user_id), do: lookup + + defp mention_context(content) when is_binary(content) do + snippet = String.trim(content) |> String.slice(0, 120) + "Mentioned you in chat: #{snippet}" + end + + defp mention_context(_), do: "Mentioned you in chat" +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_moderation_action.ex b/apps/elektrine/lib/elektrine/messaging/chat_moderation_action.ex new file mode 100644 index 0000000..1a79d50 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_moderation_action.ex @@ -0,0 +1,35 @@ +defmodule Elektrine.Messaging.ChatModerationAction do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "chat_moderation_actions" do + field :action_type, :string + field :reason, :string + field :duration, :integer + field :details, :map + + belongs_to :target_user, Elektrine.Accounts.User + belongs_to :moderator, Elektrine.Accounts.User + belongs_to :conversation, Elektrine.Messaging.ChatConversation + + timestamps(type: :utc_datetime) + end + + @valid_actions ~w(timeout kick delete_message ban warn) + + def changeset(action, attrs) do + action + |> cast(attrs, [ + :action_type, + :target_user_id, + :moderator_id, + :conversation_id, + :reason, + :duration, + :details + ]) + |> validate_required([:action_type, :target_user_id, :moderator_id, :conversation_id]) + |> validate_inclusion(:action_type, @valid_actions) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_remote_encryption_device.ex b/apps/elektrine/lib/elektrine/messaging/chat_remote_encryption_device.ex new file mode 100644 index 0000000..88a9b40 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_remote_encryption_device.ex @@ -0,0 +1,81 @@ +defmodule Elektrine.Messaging.ChatRemoteEncryptionDevice do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "chat_remote_encryption_devices" do + field :origin_domain, :string + field :remote_handle, :string + field :device_id, :string + field :public_key, :map + field :key_algorithm, :string, default: "RSA-OAEP-SHA256" + field :fingerprint, :string + field :signing_public_key, :map + field :device_signature, :map + field :label, :string + field :last_seen_at, :utc_datetime + field :revoked_at, :utc_datetime + + timestamps() + end + + def changeset(device, attrs) do + device + |> cast(attrs, [ + :origin_domain, + :remote_handle, + :device_id, + :public_key, + :key_algorithm, + :fingerprint, + :signing_public_key, + :device_signature, + :label, + :last_seen_at, + :revoked_at + ]) + |> validate_required([ + :origin_domain, + :remote_handle, + :device_id, + :public_key, + :key_algorithm + ]) + |> validate_length(:origin_domain, min: 3, max: 253) + |> validate_length(:remote_handle, min: 3, max: 255) + |> validate_length(:device_id, min: 8, max: 128) + |> validate_length(:fingerprint, min: 32, max: 128) + |> validate_length(:label, max: 120) + |> validate_inclusion(:key_algorithm, ["RSA-OAEP-SHA256"]) + |> validate_public_key() + |> unique_constraint([:origin_domain, :remote_handle, :device_id]) + end + + defp validate_public_key(changeset) do + case get_field(changeset, :public_key) do + %{} = public_key -> + version = payload_value(public_key, "version", :version) + algorithm = payload_value(public_key, "algorithm", :algorithm) + key = payload_value(public_key, "key", :key) + + if version in [1, "1"] and algorithm == "RSA-OAEP-SHA256" and valid_base64?(key) do + changeset + else + add_error(changeset, :public_key, "must be a valid RSA-OAEP public key") + end + + _ -> + add_error(changeset, :public_key, "must be a valid RSA-OAEP public key") + end + end + + defp payload_value(payload, string_key, atom_key) do + Map.get(payload, string_key) || Map.get(payload, atom_key) + end + + defp valid_base64?(value) when is_binary(value) do + match?({:ok, decoded} when byte_size(decoded) > 32, Base.decode64(value)) + end + + defp valid_base64?(_), do: false +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_thread.ex b/apps/elektrine/lib/elektrine/messaging/chat_thread.ex new file mode 100644 index 0000000..677285c --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_thread.ex @@ -0,0 +1,86 @@ +defmodule Elektrine.Messaging.ChatThread do + @moduledoc """ + Schema for chat threads. + + A thread is a focused side-conversation attached to a channel. It can be + spawned from an existing message (`root_message_id`) or created standalone. + Messages that belong to a thread carry `chat_messages.thread_id` and are + excluded from the channel's main timeline; the root message (when present) + stays in the main timeline. + + Remote threads projected from federation carry `federation_id` (the stable + id assigned by the origin domain) plus `origin_domain`; local threads leave + both nil. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "chat_threads" do + field :title, :string + field :archived_at, :utc_datetime + field :last_activity_at, :utc_datetime + field :message_count, :integer, default: 0 + field :federation_id, :string + field :origin_domain, :string + + belongs_to :conversation, Elektrine.Messaging.ChatConversation + belongs_to :root_message, Elektrine.Messaging.ChatMessage + belongs_to :creator, Elektrine.Accounts.User + + has_many :messages, Elektrine.Messaging.ChatMessage, foreign_key: :thread_id + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(thread, attrs) do + thread + |> cast(attrs, [ + :conversation_id, + :root_message_id, + :title, + :creator_id, + :archived_at, + :last_activity_at, + :message_count, + :federation_id, + :origin_domain + ]) + |> update_change(:title, &normalize_title/1) + |> validate_required([:conversation_id, :title]) + |> validate_length(:title, min: 1, max: 100) + |> validate_number(:message_count, greater_than_or_equal_to: 0) + |> truncate_utc_datetimes([:archived_at, :last_activity_at]) + |> foreign_key_constraint(:conversation_id) + |> foreign_key_constraint(:root_message_id) + |> foreign_key_constraint(:creator_id) + |> unique_constraint(:root_message_id) + |> unique_constraint([:federation_id, :origin_domain], + name: :chat_threads_federation_id_origin_domain_index + ) + end + + @doc """ + Returns true when the thread is archived. + """ + def archived?(%__MODULE__{archived_at: nil}), do: false + def archived?(%__MODULE__{}), do: true + + defp normalize_title(title) when is_binary(title) do + title + |> String.trim() + |> String.slice(0, 100) + |> case do + "" -> nil + trimmed -> trimmed + end + end + + defp normalize_title(title), do: title + + defp truncate_utc_datetimes(changeset, fields) do + Enum.reduce(fields, changeset, fn field, changeset -> + update_change(changeset, field, &Elektrine.Time.truncate/1) + end) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_threads.ex b/apps/elektrine/lib/elektrine/messaging/chat_threads.ex new file mode 100644 index 0000000..28f83f1 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_threads.ex @@ -0,0 +1,665 @@ +defmodule Elektrine.Messaging.ChatThreads do + @moduledoc """ + Context for chat threads. + + Threads are focused side-conversations attached to channels. A thread can be + spawned from an existing channel message (the root message stays visible in + the main timeline) or created standalone. Messages sent into a thread carry + `chat_messages.thread_id` and are excluded from the channel's main timeline. + + Authorization mirrors the pins feature: server staff (owner/admin/moderator) + always qualify; other members are resolved through `RoomACL` (`create_threads` + for creation, `manage_threads` for archiving, with a carve-out for the + thread's creator). + + Federation: thread lifecycle (create/archive/unarchive) is published as + `thread.upsert` / `thread.archive` extension events for channels. Thread + messages federate as regular `message.create` events carrying an optional + `message.thread_id` reference (spec section 7.6); receivers that do not + track threads fall back to showing them in the channel timeline. + """ + + import Ecto.Query, warn: false + + require Logger + + alias Elektrine.Messaging.{ + ArblargSDK, + ChatConversation, + ChatMessage, + ChatThread, + Federation, + FederationExtensionEvent, + RoomACL, + ServerMember + } + + alias Elektrine.Messaging.Federation.Utils, as: FederationUtils + alias Elektrine.PubSubTopics + alias Elektrine.Repo + + # Built-in server roles that always qualify for thread management + # (mirrors the `create_server_channel` / pins authorization carve-out). + @staff_roles ["owner", "admin", "moderator"] + + @default_title "New thread" + + ## Fetching + + @doc """ + Gets a thread by id with creator and root message preloaded. + """ + def get_thread(thread_id) do + ChatThread + |> Repo.get(thread_id) + |> preload_thread() + end + + @doc """ + Lists a conversation's threads, most recently active first. + + `status` is `:active` (default), `:archived`, or `:all`. + """ + def list_threads(conversation_id, status \\ :active) do + base = + from(thread in ChatThread, + where: thread.conversation_id == ^conversation_id, + order_by: [ + desc: coalesce(thread.last_activity_at, thread.inserted_at), + desc: thread.id + ], + preload: [:creator, root_message: [:sender]] + ) + + case status do + :active -> from(thread in base, where: is_nil(thread.archived_at)) + :archived -> from(thread in base, where: not is_nil(thread.archived_at)) + :all -> base + end + |> Repo.all() + |> Enum.map(&decrypt_root_message/1) + end + + @doc """ + Lists the messages inside a thread, oldest first. + """ + def list_thread_messages(thread_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 200) + + from(m in ChatMessage, + where: m.thread_id == ^thread_id and is_nil(m.deleted_at), + order_by: [asc: m.inserted_at, asc: m.id], + limit: ^limit, + preload: [:sender, :link_preview, reply_to: [:sender], reactions: [:user, :remote_actor]] + ) + |> Repo.all() + |> ChatMessage.decrypt_messages() + end + + ## Creation + + @doc """ + Creates a thread from an existing channel message. The message becomes the + thread's root and stays in the main timeline. Requires the `create_threads` + permission (server staff always qualify). + """ + def create_thread_from_message(message_id, user_id, attrs \\ %{}) do + with %ChatMessage{deleted_at: nil, thread_id: nil} = message <- fetch_message(message_id), + %ChatConversation{} = conversation <- get_channel(message.conversation_id), + :ok <- authorize_create(conversation, user_id), + nil <- Repo.get_by(ChatThread, root_message_id: message.id) do + message = ChatMessage.decrypt_content(message) + + insert_thread(conversation, user_id, %{ + root_message_id: message.id, + title: normalized_title(attrs) || title_from_content(message.content) + }) + else + nil -> {:error, :not_found} + %ChatThread{} -> {:error, :thread_exists} + %ChatMessage{} -> {:error, :not_found} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Creates a standalone thread in a channel (no root message). Requires the + `create_threads` permission (server staff always qualify). + """ + def create_thread(conversation_id, user_id, attrs) do + with %ChatConversation{} = conversation <- get_channel(conversation_id), + :ok <- authorize_create(conversation, user_id) do + insert_thread(conversation, user_id, %{ + title: normalized_title(attrs) || @default_title + }) + else + nil -> {:error, :not_found} + {:error, reason} -> {:error, reason} + end + end + + ## Archiving + + @doc """ + Archives a thread. Allowed for the thread's creator or holders of the + `manage_threads` permission (server staff always qualify). + """ + def archive_thread(thread_id, user_id) do + with %ChatThread{archived_at: nil} = thread <- Repo.get(ChatThread, thread_id), + :ok <- authorize_manage(thread, user_id) do + thread + |> ChatThread.changeset(%{archived_at: Elektrine.Time.utc_now()}) + |> Repo.update() + |> case do + {:ok, thread} -> + thread = preload_thread(thread) + broadcast_thread_event(:thread_archived, thread) + publish_thread_archived(thread, user_id) + {:ok, thread} + + error -> + error + end + else + nil -> {:error, :not_found} + %ChatThread{} -> {:error, :already_archived} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Unarchives a thread. Same authorization as `archive_thread/2`. + """ + def unarchive_thread(thread_id, user_id) do + with %ChatThread{archived_at: %DateTime{}} = thread <- Repo.get(ChatThread, thread_id), + :ok <- authorize_manage(thread, user_id) do + thread + |> ChatThread.changeset(%{archived_at: nil}) + |> Repo.update() + |> case do + {:ok, thread} -> + thread = preload_thread(thread) + broadcast_thread_event(:thread_updated, thread) + publish_thread_upsert(thread, user_id) + {:ok, thread} + + error -> + error + end + else + nil -> {:error, :not_found} + %ChatThread{} -> {:error, :not_archived} + {:error, reason} -> {:error, reason} + end + end + + ## Thread messages + + @doc """ + Sends a text message into a thread. Reuses the regular chat send path (write + permission on the parent conversation) and bumps the thread's activity + counters. The message federates with a `thread_id` reference when the + parent channel federates. + """ + def create_thread_message(thread_id, sender_id, content, opts \\ []) do + case Repo.get(ChatThread, thread_id) do + nil -> + {:error, :not_found} + + %ChatThread{archived_at: archived_at} when not is_nil(archived_at) -> + {:error, :thread_archived} + + %ChatThread{} = thread -> + Elektrine.Messaging.ChatMessages.create_text_message( + thread.conversation_id, + sender_id, + content, + Keyword.put(opts, :thread_id, thread.id) + ) + end + end + + @doc """ + Bumps a thread's `message_count` / `last_activity_at` for a newly created + thread message and broadcasts the updated thread. Called from the chat + message creation path. + """ + def record_message_activity(%ChatMessage{thread_id: thread_id} = message) + when is_integer(thread_id) do + last_activity = + case message.inserted_at do + %NaiveDateTime{} = naive -> DateTime.from_naive!(naive, "Etc/UTC") + %DateTime{} = datetime -> datetime + _ -> DateTime.utc_now() + end + |> DateTime.truncate(:second) + + from(thread in ChatThread, where: thread.id == ^thread_id) + |> Repo.update_all( + inc: [message_count: 1], + set: [last_activity_at: last_activity] + ) + + case get_thread(thread_id) do + %ChatThread{} = thread -> + broadcast_thread_event(:thread_updated, thread) + :ok + + nil -> + :ok + end + end + + def record_message_activity(_message), do: :ok + + ## Federation projection (inbound) + + @doc """ + Projects an accepted `thread.upsert` / `thread.archive` extension event into + a local `chat_threads` row, keyed by `federation_id` + `origin_domain`. + + The passed projection row is the current winning state from the + last-write-wins extension event storage, so replaying a stale event simply + reapplies the winning state. Never raises; returns `:ok` even when the + payload cannot be projected (the extension event state is still stored). + """ + def apply_remote_thread_projection( + %ChatConversation{} = conversation, + %FederationExtensionEvent{} = projection + ) do + upsert_type = ArblargSDK.canonical_event_type("thread.upsert") + archive_type = ArblargSDK.canonical_event_type("thread.archive") + + result = + cond do + projection.event_type == upsert_type -> + project_remote_thread_upsert(conversation, projection) + + projection.event_type == archive_type -> + project_remote_thread_archive(conversation, projection) + + true -> + :ok + end + + case result do + :ok -> + :ok + + {:error, reason} -> + Logger.warning( + "chat_threads: failed to project remote thread event #{projection.event_type} " <> + "for conversation #{conversation.id}: #{inspect(reason)}" + ) + + :ok + end + end + + def apply_remote_thread_projection(_conversation, _projection), do: :ok + + ## Outbound federation payloads + + @doc """ + Builds the `thread.upsert` extension payload for a local thread. The + federation builder fills in server/channel/refs and defaults the owner to + the publishing actor. + """ + def thread_upsert_payload(%ChatThread{} = thread, %ChatConversation{} = conversation) do + %{ + "thread" => + %{ + "id" => thread_federation_ref(thread), + "channel_id" => channel_federation_ref(conversation), + "name" => thread.title, + "state" => if(ChatThread.archived?(thread), do: "archived", else: "active"), + "message_count" => thread.message_count + } + |> maybe_put_owner(thread) + } + end + + @doc """ + Builds the `thread.archive` extension payload for a local thread. The + federation builder fills in server/channel/refs and the acting user. + """ + def thread_archive_payload(%ChatThread{} = thread) do + archived_at = thread.archived_at || Elektrine.Time.utc_now() + + %{ + "thread_id" => thread_federation_ref(thread), + "archived_at" => DateTime.to_iso8601(archived_at) + } + end + + @doc """ + Returns the federation identifier used for this thread in extension events: + the origin-assigned id for remote threads, a local URI otherwise. + """ + def thread_federation_ref(%ChatThread{federation_id: federation_id}) + when is_binary(federation_id), + do: federation_id + + def thread_federation_ref(%ChatThread{id: id}), + do: FederationUtils.thread_federation_id(id) + + @doc """ + Returns the federation thread ref carried on outbound `message.create` + payloads for a thread message, or nil for main-timeline messages. + """ + def message_thread_ref(%{thread_id: thread_id}) when is_integer(thread_id) do + case Repo.get(ChatThread, thread_id) do + %ChatThread{} = thread -> thread_federation_ref(thread) + nil -> nil + end + end + + def message_thread_ref(_message), do: nil + + @doc """ + Resolves the local thread referenced by an inbound `message.thread_id` + within `conversation_id`. Remote-origin threads match on their stored + `federation_id`; local threads match their minted local ref. Returns nil + for unknown refs so callers fall back to the main timeline (per spec + section 7.6). + """ + def resolve_thread_for_message_ref(conversation_id, ref) + when is_integer(conversation_id) and is_binary(ref) do + remote = + from(thread in ChatThread, + where: thread.conversation_id == ^conversation_id and thread.federation_id == ^ref, + limit: 1 + ) + |> Repo.one() + + remote || resolve_local_thread_ref(conversation_id, ref) + end + + def resolve_thread_for_message_ref(_conversation_id, _ref), do: nil + + defp resolve_local_thread_ref(conversation_id, ref) do + case String.split(ref, "/_arblarg/threads/") do + [_base, id_string] -> + with {thread_id, ""} <- Integer.parse(id_string), + # Re-minting the ref verifies scheme + domain, so foreign refs + # cannot bind to arbitrary local thread ids. + true <- FederationUtils.thread_federation_id(thread_id) == ref, + %ChatThread{conversation_id: ^conversation_id, federation_id: nil} = thread <- + Repo.get(ChatThread, thread_id) do + thread + else + _ -> nil + end + + _ -> + nil + end + end + + defp channel_federation_ref(%ChatConversation{} = conversation) do + conversation.federated_source || FederationUtils.channel_federation_id(conversation.id) + end + + # Private helpers + + defp insert_thread(%ChatConversation{} = conversation, user_id, attrs) do + now = Elektrine.Time.utc_now() + + %ChatThread{} + |> ChatThread.changeset( + Map.merge(attrs, %{ + conversation_id: conversation.id, + creator_id: user_id, + last_activity_at: now + }) + ) + |> Repo.insert() + |> case do + {:ok, thread} -> + thread = preload_thread(thread) + broadcast_thread_event(:thread_created, thread) + publish_thread_upsert(thread, user_id, conversation) + {:ok, thread} + + {:error, %Ecto.Changeset{errors: errors} = changeset} -> + if Keyword.has_key?(errors, :root_message_id) do + {:error, :thread_exists} + else + {:error, changeset} + end + end + end + + defp project_remote_thread_upsert(conversation, projection) do + with %{} = thread_payload <- projection.payload["thread"], + federation_id when is_binary(federation_id) <- thread_payload["id"], + title when is_binary(title) <- thread_payload["name"] do + state = thread_payload["state"] + occurred_at = projection.occurred_at || DateTime.utc_now() + + existing = + Repo.get_by(ChatThread, + federation_id: federation_id, + origin_domain: projection.origin_domain + ) + + attrs = + %{ + conversation_id: conversation.id, + title: title, + federation_id: federation_id, + origin_domain: projection.origin_domain, + archived_at: remote_archived_at(state, existing, occurred_at), + last_activity_at: occurred_at + } + |> maybe_put_remote_message_count(thread_payload["message_count"]) + + case existing do + nil -> + %ChatThread{} + |> ChatThread.changeset(attrs) + |> Repo.insert() + |> broadcast_remote_result(:thread_created) + + %ChatThread{} = thread -> + thread + |> ChatThread.changeset(attrs) + |> Repo.update() + |> broadcast_remote_result(:thread_updated) + end + else + _ -> {:error, :invalid_thread_payload} + end + end + + defp project_remote_thread_archive(conversation, projection) do + with federation_id when is_binary(federation_id) <- projection.payload["thread_id"], + %ChatThread{} = thread <- + Repo.get_by(ChatThread, + federation_id: federation_id, + origin_domain: projection.origin_domain + ), + true <- thread.conversation_id == conversation.id do + thread + |> ChatThread.changeset(%{archived_at: projection.occurred_at || DateTime.utc_now()}) + |> Repo.update() + |> broadcast_remote_result(:thread_archived) + else + # An archive for a thread we never projected is a no-op locally; the + # extension event state is still retained for replay. + nil -> :ok + false -> :ok + _ -> {:error, :invalid_thread_payload} + end + end + + defp remote_archived_at("archived", %ChatThread{archived_at: %DateTime{} = existing}, _at), + do: existing + + defp remote_archived_at("archived", _existing, occurred_at), do: occurred_at + + defp remote_archived_at("locked", existing, occurred_at), + do: remote_archived_at("archived", existing, occurred_at) + + defp remote_archived_at(_state, _existing, _occurred_at), do: nil + + defp maybe_put_remote_message_count(attrs, count) when is_integer(count) and count >= 0, + do: Map.put(attrs, :message_count, count) + + defp maybe_put_remote_message_count(attrs, _count), do: attrs + + defp broadcast_remote_result({:ok, thread}, event) do + broadcast_thread_event(event, preload_thread(thread)) + :ok + end + + defp broadcast_remote_result({:error, reason}, _event), do: {:error, reason} + + ## Authorization + + defp get_channel(conversation_id) do + case Repo.get(ChatConversation, conversation_id) do + %ChatConversation{type: "channel"} = conversation -> conversation + %ChatConversation{} -> {:error, :unsupported_conversation_type} + nil -> nil + end + end + + defp authorize_create(%ChatConversation{} = conversation, user_id) do + if server_staff?(conversation.server_id, user_id) do + :ok + else + RoomACL.authorize_local_user_action(conversation.id, user_id, :create_threads) + end + end + + defp authorize_manage(%ChatThread{} = thread, user_id) do + server_id = + case Repo.get(ChatConversation, thread.conversation_id) do + %ChatConversation{server_id: server_id} -> server_id + _ -> nil + end + + cond do + is_integer(user_id) and thread.creator_id == user_id -> + :ok + + server_staff?(server_id, user_id) -> + :ok + + true -> + RoomACL.authorize_local_user_action(thread.conversation_id, user_id, :manage_threads) + end + end + + defp server_staff?(server_id, user_id) when is_integer(server_id) and is_integer(user_id) do + from(sm in ServerMember, + where: + sm.server_id == ^server_id and sm.user_id == ^user_id and is_nil(sm.left_at) and + sm.role in ^@staff_roles + ) + |> Repo.exists?() + end + + defp server_staff?(_server_id, _user_id), do: false + + ## Federation publishing (outbound) + + defp publish_thread_upsert(%ChatThread{} = thread, user_id, conversation \\ nil) do + conversation = conversation || Repo.get(ChatConversation, thread.conversation_id) + + if match?(%ChatConversation{type: "channel"}, conversation) and is_integer(user_id) do + _ = + Federation.publish_extension_event( + conversation.id, + user_id, + "thread.upsert", + thread_upsert_payload(thread, conversation) + ) + end + + :ok + end + + defp publish_thread_archived(%ChatThread{} = thread, user_id) do + conversation = Repo.get(ChatConversation, thread.conversation_id) + + if match?(%ChatConversation{type: "channel"}, conversation) and is_integer(user_id) do + _ = + Federation.publish_extension_event( + conversation.id, + user_id, + "thread.archive", + thread_archive_payload(thread) + ) + end + + :ok + end + + defp maybe_put_owner(thread_payload, %ChatThread{creator: %Elektrine.Accounts.User{} = creator}) do + Map.put(thread_payload, "owner", FederationUtils.sender_payload(creator)) + end + + defp maybe_put_owner(thread_payload, _thread), do: thread_payload + + ## Misc helpers + + defp fetch_message(message_id) when is_integer(message_id), + do: Repo.get(ChatMessage, message_id) + + defp fetch_message(message_id) when is_binary(message_id) do + case Integer.parse(message_id) do + {id, ""} -> fetch_message(id) + _ -> nil + end + end + + defp fetch_message(_message_id), do: nil + + defp preload_thread(nil), do: nil + + defp preload_thread(%ChatThread{} = thread) do + thread + |> Repo.preload([:creator, root_message: [:sender]]) + |> decrypt_root_message() + end + + defp decrypt_root_message(%ChatThread{root_message: %ChatMessage{} = root} = thread) do + %{thread | root_message: ChatMessage.decrypt_content(root)} + end + + defp decrypt_root_message(thread), do: thread + + defp normalized_title(attrs) when is_map(attrs) do + title = Map.get(attrs, :title) || Map.get(attrs, "title") + + if is_binary(title) and String.trim(title) != "" do + String.trim(title) + else + nil + end + end + + defp normalized_title(_attrs), do: nil + + defp title_from_content(content) when is_binary(content) do + content + |> String.trim() + |> String.slice(0, 60) + |> case do + "" -> @default_title + snippet -> snippet + end + end + + defp title_from_content(_content), do: @default_title + + defp broadcast_thread_event(event, %ChatThread{} = thread) do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + PubSubTopics.conversation(thread.conversation_id), + {event, thread} + ) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_user_hidden_message.ex b/apps/elektrine/lib/elektrine/messaging/chat_user_hidden_message.ex new file mode 100644 index 0000000..9ff1564 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_user_hidden_message.ex @@ -0,0 +1,26 @@ +defmodule Elektrine.Messaging.ChatUserHiddenMessage do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "chat_user_hidden_messages" do + belongs_to :user, Elektrine.Accounts.User + belongs_to :chat_message, Elektrine.Messaging.ChatMessage + + field :hidden_at, :utc_datetime + + timestamps() + end + + @doc false + def changeset(hidden_message, attrs) do + hidden_message + |> cast(attrs, [:user_id, :chat_message_id, :hidden_at]) + |> validate_required([:user_id, :chat_message_id, :hidden_at]) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:chat_message_id) + |> unique_constraint([:user_id, :chat_message_id], + name: :chat_user_hidden_messages_user_message_unique + ) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_user_timeout.ex b/apps/elektrine/lib/elektrine/messaging/chat_user_timeout.ex new file mode 100644 index 0000000..2b1fd38 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_user_timeout.ex @@ -0,0 +1,24 @@ +defmodule Elektrine.Messaging.ChatUserTimeout do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "chat_user_timeouts" do + belongs_to :user, Elektrine.Accounts.User + belongs_to :conversation, Elektrine.Messaging.ChatConversation + belongs_to :created_by, Elektrine.Accounts.User + field :timeout_until, :utc_datetime + field :reason, :string + + timestamps(type: :utc_datetime) + end + + def changeset(user_timeout, attrs) do + user_timeout + |> cast(attrs, [:user_id, :conversation_id, :timeout_until, :reason, :created_by_id]) + |> validate_required([:user_id, :conversation_id, :timeout_until, :created_by_id]) + |> unique_constraint([:user_id, :conversation_id], + name: :chat_user_timeouts_user_conversation_unique + ) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_webhook.ex b/apps/elektrine/lib/elektrine/messaging/chat_webhook.ex new file mode 100644 index 0000000..0c0f4c7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_webhook.ex @@ -0,0 +1,106 @@ +defmodule Elektrine.Messaging.ChatWebhook do + @moduledoc """ + Schema for incoming chat webhooks. + + A webhook lets external services post messages into a channel or group + conversation. The secret token is stored only as a SHA-256 hash (same + storage pattern as personal access tokens); the plaintext token is + returned exactly once on creation or rotation via the virtual `:token` + field. + + Webhook-authored messages have no local sender. They carry the webhook id + on the message row plus display metadata (name/avatar) in the message's + `media_metadata`, so clients can render the webhook as the author. + """ + use Ecto.Schema + import Ecto.Changeset + + @token_prefix "ewh_" + + schema "chat_webhooks" do + field :name, :string + field :avatar_url, :string + field :token_hash, :string + field :active, :boolean, default: true + + # Virtual field for returning the plaintext token once on create/rotate. + field :token, :string, virtual: true + + belongs_to :conversation, Elektrine.Messaging.ChatConversation + belongs_to :creator, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(webhook, attrs) do + webhook + |> cast(attrs, [:conversation_id, :creator_id, :name, :avatar_url, :token_hash, :active]) + |> validate_required([:conversation_id, :name, :token_hash]) + |> validate_name_and_avatar() + |> unique_constraint(:token_hash) + |> foreign_key_constraint(:conversation_id) + |> foreign_key_constraint(:creator_id) + end + + @doc """ + Changeset for renaming a webhook or updating its avatar. + """ + def update_changeset(webhook, attrs) do + webhook + |> cast(attrs, [:name, :avatar_url]) + |> validate_required([:name]) + |> validate_name_and_avatar() + end + + @doc """ + Changeset for storing a rotated token hash. + """ + def rotate_token_changeset(webhook, token_hash) do + webhook + |> change(%{token_hash: token_hash}) + |> unique_constraint(:token_hash) + end + + @doc """ + Changeset for deactivating a webhook. + """ + def deactivate_changeset(webhook) do + change(webhook, %{active: false}) + end + + @doc """ + Generates a new webhook token. + + Returns `{plaintext_token, token_hash}`. Only the hash is persisted. + """ + def generate_token do + token = @token_prefix <> Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) + {token, hash_token(token)} + end + + @doc """ + Hashes a webhook token for storage using SHA-256 (same pattern as + `Elektrine.Developer.ApiToken`). + """ + def hash_token(token) when is_binary(token) do + :crypto.hash(:sha256, token) |> Base.encode16(case: :lower) + end + + @doc """ + Verifies a plaintext token against the stored hash in constant time. + """ + def valid_token?(%__MODULE__{token_hash: token_hash}, token) + when is_binary(token_hash) and is_binary(token) do + Plug.Crypto.secure_compare(hash_token(token), token_hash) + end + + def valid_token?(_webhook, _token), do: false + + defp validate_name_and_avatar(changeset) do + changeset + |> update_change(:name, &String.trim/1) + |> validate_length(:name, min: 1, max: 80) + |> validate_length(:avatar_url, max: 500) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/chat_webhooks.ex b/apps/elektrine/lib/elektrine/messaging/chat_webhooks.ex new file mode 100644 index 0000000..cf1c9a3 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/chat_webhooks.ex @@ -0,0 +1,352 @@ +defmodule Elektrine.Messaging.ChatWebhooks do + @moduledoc """ + Context for incoming chat webhooks. + + Incoming webhooks let external services post text messages into channel + and group conversations (never DMs). Management (create/list/update/ + rotate/deactivate/delete) requires the `manage_webhooks` permission: for + channels this is resolved through `RoomACL` with the usual server + owner/admin carve-out (mirroring `ChatMessagePins`); for groups it maps to + the conversation member roles that carry `manage_webhooks` in the built-in + role definitions (owner/admin). + + Execution requires no session: the webhook token IS the credential. The + token is stored only as a SHA-256 hash and verified in constant time; + unknown ids and bad tokens are indistinguishable (`{:error, :not_found}`) + to avoid oracle behavior. Executions are rate limited per webhook. + + Webhook-authored messages go through the canonical + `ChatMessages.create_webhook_text_message/4` path, so they broadcast over + PubSub and federate exactly like normal messages. + """ + + import Ecto.Query, warn: false + + alias Elektrine.Messaging.{ + ChatConversation, + ChatConversationMember, + ChatMessages, + ChatWebhook, + RateLimiter, + RoomACL, + ServerMember + } + + alias Elektrine.Repo + + @max_webhooks_per_conversation 15 + @max_content_length 4000 + @max_name_length 80 + @max_avatar_url_length 500 + + # Built-in conversation roles that carry `manage_webhooks` (see RoomACL). + @manage_webhook_roles ["owner", "admin"] + + @webhook_conversation_types ["channel", "group"] + + @doc """ + Maximum number of webhooks allowed per conversation. + """ + def max_webhooks_per_conversation, do: @max_webhooks_per_conversation + + @doc """ + Creates a webhook on a channel or group conversation. + + Requires the `manage_webhooks` permission. On success the returned + webhook carries the plaintext token in the virtual `:token` field -- + this is the only time the plaintext is available. + """ + def create_webhook(conversation_id, user_id, attrs \\ %{}) do + with {:ok, conversation} <- fetch_webhook_conversation(conversation_id), + :ok <- authorize_manage_webhooks(conversation, user_id), + :ok <- ensure_webhook_capacity(conversation.id) do + {token, token_hash} = ChatWebhook.generate_token() + + %ChatWebhook{} + |> ChatWebhook.changeset(%{ + conversation_id: conversation.id, + creator_id: user_id, + name: attrs_value(attrs, "name", :name), + avatar_url: attrs_value(attrs, "avatar_url", :avatar_url), + token_hash: token_hash, + active: true + }) + |> Repo.insert() + |> case do + {:ok, webhook} -> {:ok, %{webhook | token: token}} + {:error, changeset} -> {:error, changeset} + end + end + end + + @doc """ + Lists the webhooks of a conversation. Requires `manage_webhooks`. + """ + def list_webhooks(conversation_id, user_id) do + with {:ok, conversation} <- fetch_webhook_conversation(conversation_id), + :ok <- authorize_manage_webhooks(conversation, user_id) do + webhooks = + from(w in ChatWebhook, + where: w.conversation_id == ^conversation.id, + order_by: [asc: w.id] + ) + |> Repo.all() + + {:ok, webhooks} + end + end + + @doc """ + Renames a webhook and/or updates its avatar. Requires `manage_webhooks`. + """ + def update_webhook(webhook_id, user_id, attrs) do + with {:ok, webhook, _conversation} <- fetch_authorized_webhook(webhook_id, user_id) do + webhook + |> ChatWebhook.update_changeset(%{ + name: attrs_value(attrs, "name", :name) || webhook.name, + avatar_url: attrs_value(attrs, "avatar_url", :avatar_url) + }) + |> Repo.update() + end + end + + @doc """ + Rotates a webhook's token. Requires `manage_webhooks`. + + The returned webhook carries the new plaintext token in the virtual + `:token` field -- this is the only time the plaintext is available. + """ + def rotate_webhook_token(webhook_id, user_id) do + with {:ok, webhook, _conversation} <- fetch_authorized_webhook(webhook_id, user_id) do + {token, token_hash} = ChatWebhook.generate_token() + + webhook + |> ChatWebhook.rotate_token_changeset(token_hash) + |> Repo.update() + |> case do + {:ok, updated} -> {:ok, %{updated | token: token}} + {:error, changeset} -> {:error, changeset} + end + end + end + + @doc """ + Deactivates a webhook without deleting it. Requires `manage_webhooks`. + """ + def deactivate_webhook(webhook_id, user_id) do + with {:ok, webhook, _conversation} <- fetch_authorized_webhook(webhook_id, user_id) do + webhook + |> ChatWebhook.deactivate_changeset() + |> Repo.update() + end + end + + @doc """ + Deletes a webhook. Requires `manage_webhooks`. + """ + def delete_webhook(webhook_id, user_id) do + with {:ok, webhook, _conversation} <- fetch_authorized_webhook(webhook_id, user_id) do + Repo.delete(webhook) + end + end + + @doc """ + Executes a webhook: verifies the token and posts a text message into the + webhook's conversation, authored as the webhook. + + No user authentication is involved -- the token is the credential. + Returns `{:error, :not_found}` for unknown ids and bad tokens alike, + `{:error, :webhook_inactive}` for deactivated webhooks (only reachable + with a valid token), `{:error, :rate_limited}` past the per-webhook + execution limit, and `{:error, :invalid_content}` / + `{:error, :invalid_override}` for bad payloads. + + Supported params: `"content"` (required), `"username"` and + `"avatar_url"` (optional display overrides for this message). + """ + def execute_webhook(webhook_id, token, params) when is_binary(token) and is_map(params) do + webhook = fetch_webhook(webhook_id) + + with :ok <- verify_webhook_token(webhook, token), + :ok <- ensure_active(webhook), + :ok <- check_rate_limit(webhook), + {:ok, content} <- validate_content(attrs_value(params, "content", :content)), + {:ok, name_override} <- + validate_override(attrs_value(params, "username", :username), @max_name_length), + {:ok, avatar_override} <- + validate_override( + attrs_value(params, "avatar_url", :avatar_url), + @max_avatar_url_length + ) do + RateLimiter.record_webhook_execution(webhook.id) + + ChatMessages.create_webhook_text_message( + webhook.conversation_id, + webhook.id, + content, + webhook_sender_meta(webhook, name_override, avatar_override) + ) + end + end + + def execute_webhook(_webhook_id, _token, _params), do: {:error, :not_found} + + # Fetching and authorization + + defp fetch_webhook(webhook_id) when is_integer(webhook_id), + do: Repo.get(ChatWebhook, webhook_id) + + defp fetch_webhook(webhook_id) when is_binary(webhook_id) do + case Integer.parse(webhook_id) do + {id, ""} -> fetch_webhook(id) + _ -> nil + end + end + + defp fetch_webhook(_webhook_id), do: nil + + defp verify_webhook_token(%ChatWebhook{} = webhook, token) do + if ChatWebhook.valid_token?(webhook, token) do + :ok + else + {:error, :not_found} + end + end + + defp verify_webhook_token(nil, token) do + # Burn a comparison so unknown ids cost the same as bad tokens. + ChatWebhook.valid_token?(%ChatWebhook{token_hash: ChatWebhook.hash_token("")}, token) + {:error, :not_found} + end + + defp ensure_active(%ChatWebhook{active: true}), do: :ok + defp ensure_active(_webhook), do: {:error, :webhook_inactive} + + defp check_rate_limit(%ChatWebhook{id: webhook_id}) do + if RateLimiter.can_execute_webhook?(webhook_id) do + :ok + else + {:error, :rate_limited} + end + end + + defp validate_content(content) when is_binary(content) do + trimmed = String.trim(content) + + cond do + trimmed == "" -> {:error, :invalid_content} + String.length(trimmed) > @max_content_length -> {:error, :invalid_content} + true -> {:ok, trimmed} + end + end + + defp validate_content(_content), do: {:error, :invalid_content} + + defp validate_override(nil, _max_length), do: {:ok, nil} + + defp validate_override(value, max_length) when is_binary(value) do + trimmed = String.trim(value) + + cond do + trimmed == "" -> {:ok, nil} + String.length(trimmed) > max_length -> {:error, :invalid_override} + true -> {:ok, trimmed} + end + end + + defp validate_override(_value, _max_length), do: {:error, :invalid_override} + + defp webhook_sender_meta(%ChatWebhook{} = webhook, name_override, avatar_override) do + %{ + "webhook_id" => webhook.id, + "name" => name_override || webhook.name, + "avatar_url" => avatar_override || webhook.avatar_url + } + end + + defp fetch_webhook_conversation(conversation_id) do + case Repo.get(ChatConversation, conversation_id) do + %ChatConversation{type: type, is_federated_mirror: false} = conversation + when type in @webhook_conversation_types -> + {:ok, conversation} + + %ChatConversation{} -> + {:error, :unsupported_conversation} + + nil -> + {:error, :not_found} + end + end + + defp fetch_authorized_webhook(webhook_id, user_id) do + with %ChatWebhook{} = webhook <- fetch_webhook(webhook_id), + %ChatConversation{} = conversation <- Repo.get(ChatConversation, webhook.conversation_id), + :ok <- authorize_manage_webhooks(conversation, user_id) do + {:ok, webhook, conversation} + else + nil -> {:error, :not_found} + {:error, reason} -> {:error, reason} + end + end + + defp authorize_manage_webhooks(%ChatConversation{} = conversation, user_id) + when is_integer(user_id) do + case conversation do + %ChatConversation{type: "channel", server_id: server_id} when is_integer(server_id) -> + # Server owners/admins manage webhooks (mirrors the carve-out in + # ChatMessagePins); other members go through the room ACL. + if server_staff?(server_id, user_id) do + :ok + else + RoomACL.authorize_local_user_action(conversation.id, user_id, :manage_webhooks) + end + + %ChatConversation{type: "channel"} -> + RoomACL.authorize_local_user_action(conversation.id, user_id, :manage_webhooks) + + %ChatConversation{type: "group"} -> + member = + from(cm in ChatConversationMember, + where: + cm.conversation_id == ^conversation.id and cm.user_id == ^user_id and + is_nil(cm.left_at) + ) + |> Repo.one() + + case member do + %ChatConversationMember{role: role} when role in @manage_webhook_roles -> :ok + _ -> {:error, :unauthorized} + end + + %ChatConversation{} -> + {:error, :unsupported_conversation} + end + end + + defp authorize_manage_webhooks(_conversation, _user_id), do: {:error, :unauthorized} + + defp server_staff?(server_id, user_id) do + from(sm in ServerMember, + where: + sm.server_id == ^server_id and sm.user_id == ^user_id and is_nil(sm.left_at) and + sm.role in ^@manage_webhook_roles + ) + |> Repo.exists?() + end + + defp ensure_webhook_capacity(conversation_id) do + count = + from(w in ChatWebhook, where: w.conversation_id == ^conversation_id, select: count()) + |> Repo.one() + + if count < @max_webhooks_per_conversation do + :ok + else + {:error, :webhook_limit_reached} + end + end + + defp attrs_value(attrs, string_key, atom_key) do + Map.get(attrs, string_key) || Map.get(attrs, atom_key) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/community_ban.ex b/apps/elektrine/lib/elektrine/messaging/community_ban.ex new file mode 100644 index 0000000..d1de300 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/community_ban.ex @@ -0,0 +1,61 @@ +defmodule Elektrine.Messaging.CommunityBan do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "community_bans" do + field :conversation_id, :integer + belongs_to :user, Elektrine.Accounts.User + belongs_to :banned_by, Elektrine.Accounts.User + field :origin_domain, :string + field :actor_payload, :map, default: %{} + field :metadata, :map, default: %{} + field :reason, :string + field :expires_at, :utc_datetime + field :banned_at_remote, :utc_datetime + field :updated_at_remote, :utc_datetime + + timestamps() + end + + @doc false + def changeset(ban, attrs) do + ban + |> cast(attrs, [ + :conversation_id, + :user_id, + :banned_by_id, + :origin_domain, + :actor_payload, + :metadata, + :reason, + :expires_at, + :banned_at_remote, + :updated_at_remote + ]) + |> validate_required([:conversation_id, :user_id]) + |> validate_ban_actor() + |> validate_length(:reason, max: 500) + |> validate_length(:origin_domain, max: 255) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:banned_by_id) + |> unique_constraint([:conversation_id, :user_id]) + end + + defp validate_ban_actor(changeset) do + banned_by_id = get_field(changeset, :banned_by_id) + actor_payload = get_field(changeset, :actor_payload) || %{} + origin_domain = get_field(changeset, :origin_domain) + + cond do + is_integer(banned_by_id) -> + changeset + + map_size(actor_payload) > 0 and Elektrine.Strings.present?(origin_domain) -> + changeset + + true -> + add_error(changeset, :banned_by_id, "must be present for local bans or remote actor bans") + end + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/community_flair.ex b/apps/elektrine/lib/elektrine/messaging/community_flair.ex new file mode 100644 index 0000000..e543002 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/community_flair.ex @@ -0,0 +1,45 @@ +defmodule Elektrine.Messaging.CommunityFlair do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "community_flairs" do + field :name, :string + field :text_color, :string, default: "#ffffff" + field :background_color, :string, default: "#2a2a31" + + field :position, :integer, default: 0 + field :is_mod_only, :boolean, default: false + field :is_enabled, :boolean, default: true + + belongs_to :community, Elektrine.Social.Conversation + has_many :messages, Elektrine.Social.Message, foreign_key: :flair_id + + timestamps() + end + + @doc false + def changeset(flair, attrs) do + flair + |> cast(attrs, [ + :name, + :text_color, + :background_color, + :community_id, + :position, + :is_mod_only, + :is_enabled + ]) + |> validate_required([:name, :community_id]) + |> validate_length(:name, min: 1, max: 30) + |> validate_format(:text_color, ~r/^#[0-9A-Fa-f]{6}$/, message: "must be a valid hex color") + |> validate_format(:background_color, ~r/^#[0-9A-Fa-f]{6}$/, + message: "must be a valid hex color" + ) + |> unique_constraint(:name, + name: :community_flairs_community_id_name_index, + message: "Flair name already exists in this community" + ) + |> foreign_key_constraint(:community_id) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/community_search.ex b/apps/elektrine/lib/elektrine/messaging/community_search.ex new file mode 100644 index 0000000..cfc4414 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/community_search.ex @@ -0,0 +1,196 @@ +defmodule Elektrine.Messaging.CommunitySearch do + @moduledoc """ + Handles searching for both local and federated communities. + """ + + import Ecto.Query + alias Elektrine.{ActivityPub, Repo} + alias Elektrine.Profiles + alias Elektrine.Social.Conversation + require Logger + + @doc """ + Searches for communities across both local and federated sources. + + Returns a list of: + - Local communities matching the search + - Federated mirror communities + - Remote Group actors from the fediverse (if search looks like a handle) + + ## Examples + + search_communities("technology") + search_communities("linux@lemmy.ml") + """ + def search_communities(query, opts \\ []) do + limit = Keyword.get(opts, :limit, 20) + user_id = Keyword.get(opts, :user_id) + + # Normalize query + query = String.trim(query) |> String.downcase() + + if Elektrine.Strings.present?(query) do + # Search local communities + local_results = search_local_communities(query, limit) + + # If query looks like a fediverse handle, try webfinger + federated_results = + if String.contains?(query, "@") do + search_remote_groups(query, limit - length(local_results)) + else + [] + end + + # Combine results + (local_results ++ federated_results) + |> Enum.take(limit) + |> add_membership_info(user_id) + else + [] + end + end + + @doc """ + Searches only local communities (including mirrors). + """ + def search_local_communities(query, limit \\ 20) do + query_pattern = "%#{query}%" + + from(c in Conversation, + where: + c.type == "community" and + c.is_public == true and + (ilike(c.name, ^query_pattern) or + ilike(c.description, ^query_pattern) or + ilike(c.community_category, ^query_pattern)), + order_by: [ + # Prioritize non-federated (local) communities + desc: fragment("CASE WHEN ? = false THEN 1 ELSE 0 END", c.is_federated_mirror), + desc: c.member_count, + desc: c.last_message_at + ], + limit: ^limit, + preload: [:creator, :remote_group_actor] + ) + |> Repo.all() + |> Enum.map(fn community -> + %{ + type: :local, + community: community, + name: community.name, + description: community.description, + member_count: community.member_count, + is_federated_mirror: community.is_federated_mirror, + remote_actor: community.remote_group_actor + } + end) + end + + @doc """ + Searches for remote Group actors via WebFinger. + Handles queries like "linux@lemmy.ml" or "!technology@lemmy.world" + """ + def search_remote_groups(handle, limit \\ 5) do + # Clean up handle - remove leading ! if present + clean_handle = String.trim_leading(handle, "!") + + case ActivityPub.webfinger_lookup(clean_handle) do + {:ok, actor_uri} -> + case ActivityPub.get_or_fetch_actor(actor_uri) do + {:ok, actor} -> + if actor.actor_type == "Group" do + [ + %{ + type: :remote, + remote_actor: actor, + name: extract_group_name(actor), + description: actor.summary && HtmlSanitizeEx.strip_tags(actor.summary), + member_count: extract_follower_count(actor), + is_federated_mirror: false, + mirror_community: nil + } + ] + else + # Not a Group, ignore + [] + end + + _ -> + [] + end + + _ -> + [] + end + |> Enum.take(limit) + end + + @doc """ + Follows a remote Group actor directly without creating a local mirror. + """ + def follow_remote_group(user_id, group_actor_id) do + with group_actor <- Repo.get(ActivityPub.Actor, group_actor_id), + true <- group_actor && group_actor.actor_type == "Group", + {:ok, _follow} <- maybe_follow_remote_group(user_id, group_actor_id) do + {:ok, group_actor} + else + false -> {:error, :not_a_group} + nil -> {:error, :actor_not_found} + error -> error + end + end + + # Private helpers + + defp add_membership_info(results, nil), do: results + + defp add_membership_info(results, user_id) do + # Get user's community memberships + member_community_ids = + from(cm in Elektrine.Social.ConversationMember, + where: cm.user_id == ^user_id and is_nil(cm.left_at), + select: cm.conversation_id + ) + |> Repo.all() + |> MapSet.new() + + Enum.map(results, fn result -> + is_member = + case result.type do + :local -> + MapSet.member?(member_community_ids, result.community.id) + + :remote -> + false + end + + Map.put(result, :is_member, is_member) + end) + end + + defp extract_group_name(actor) do + # Extract community name from Group actor + # Prefer preferredUsername, fall back to username + actor.username || "community" + end + + defp extract_follower_count(actor) do + # Try to get follower count from metadata + get_in(actor.metadata, ["followers", "totalItems"]) || 0 + end + + defp maybe_follow_remote_group(user_id, group_actor_id) do + case Profiles.follow_remote_actor(user_id, group_actor_id) do + {:ok, _follow} -> + :ok + + {:error, :already_following} -> + :ok + + {:error, reason} -> + Logger.warning( + "Failed to send ActivityPub Follow while following remote group #{group_actor_id}: #{inspect(reason)}" + ) + end + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/conversation_member.ex b/apps/elektrine/lib/elektrine/messaging/conversation_member.ex new file mode 100644 index 0000000..e6c3237 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/conversation_member.ex @@ -0,0 +1,25 @@ +defmodule Elektrine.Social.ConversationMember do + @moduledoc false + + alias Elektrine.Messaging.ChatConversationMember + + use Elektrine.Messaging.MemberSchemaBase, + table: "conversation_members", + conversation: Elektrine.Social.Conversation, + message: Elektrine.Social.Message + + # In addition to the shared `%__MODULE__{}` clauses provided by the base + # macro, this module also accepts `%ChatConversationMember{}` structs for the + # permission predicates (cross-schema dispatch preserved from the original + # implementation). The base-supplied `%__MODULE__{}` clauses are reached via + # `super/1`. + + def can_send_messages?(%ChatConversationMember{role: "readonly"}), do: false + def can_send_messages?(%ChatConversationMember{left_at: nil}), do: true + def can_send_messages?(%ChatConversationMember{}), do: false + def can_send_messages?(member), do: super(member) + + def admin?(%ChatConversationMember{role: "admin"}), do: true + def admin?(%ChatConversationMember{}), do: false + def admin?(member), do: super(member) +end diff --git a/apps/elektrine/lib/elektrine/messaging/federated_boost.ex b/apps/elektrine/lib/elektrine/messaging/federated_boost.ex new file mode 100644 index 0000000..2e27a1e --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federated_boost.ex @@ -0,0 +1,24 @@ +defmodule Elektrine.Messaging.FederatedBoost do + @moduledoc """ + Tracks boosts (announces) from remote ActivityPub actors on local posts. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "federated_boosts" do + belongs_to :message, Elektrine.Social.Message + belongs_to :remote_actor, Elektrine.ActivityPub.Actor + + field :activitypub_id, :string + + timestamps() + end + + @doc false + def changeset(boost, attrs) do + boost + |> cast(attrs, [:message_id, :remote_actor_id, :activitypub_id]) + |> validate_required([:message_id, :remote_actor_id]) + |> unique_constraint([:message_id, :remote_actor_id]) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federated_dislike.ex b/apps/elektrine/lib/elektrine/messaging/federated_dislike.ex new file mode 100644 index 0000000..5ed3a75 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federated_dislike.ex @@ -0,0 +1,24 @@ +defmodule Elektrine.Messaging.FederatedDislike do + @moduledoc """ + Tracks dislikes (downvotes) from remote ActivityPub actors to prevent duplicate dislike spam. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "federated_dislikes" do + belongs_to :message, Elektrine.Social.Message + belongs_to :remote_actor, Elektrine.ActivityPub.Actor + + field :activitypub_id, :string + + timestamps() + end + + @doc false + def changeset(dislike, attrs) do + dislike + |> cast(attrs, [:message_id, :remote_actor_id, :activitypub_id]) + |> validate_required([:message_id, :remote_actor_id]) + |> unique_constraint([:message_id, :remote_actor_id]) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federated_like.ex b/apps/elektrine/lib/elektrine/messaging/federated_like.ex new file mode 100644 index 0000000..e24f72f --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federated_like.ex @@ -0,0 +1,24 @@ +defmodule Elektrine.Messaging.FederatedLike do + @moduledoc """ + Tracks likes from remote ActivityPub actors to prevent duplicate like spam. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "federated_likes" do + belongs_to :message, Elektrine.Social.Message + belongs_to :remote_actor, Elektrine.ActivityPub.Actor + + field :activitypub_id, :string + + timestamps() + end + + @doc false + def changeset(like, attrs) do + like + |> cast(attrs, [:message_id, :remote_actor_id, :activitypub_id]) + |> validate_required([:message_id, :remote_actor_id]) + |> unique_constraint([:message_id, :remote_actor_id]) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation.ex b/apps/elektrine/lib/elektrine/messaging/federation.ex new file mode 100644 index 0000000..df5cd4e --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation.ex @@ -0,0 +1,227 @@ +defmodule Elektrine.Messaging.Federation do + @moduledoc "Compatibility facade for modular Arblarg federation APIs." + + alias Elektrine.Messaging.Federation.{ + Egress, + Errors, + Ingress, + IngressRateLimiter, + Maintenance, + Peers, + PublicDiscovery, + RequestAuth, + Runtime + } + + defdelegate check_ingress_peer_limit(domain, lane, cost \\ 1), + to: IngressRateLimiter, + as: :check_peer + + defdelegate check_ingress_durable_limit(domain, stream_ids), + to: IngressRateLimiter, + as: :check_durable + + defdelegate enabled?, to: Peers + defdelegate peers, to: Peers + defdelegate incoming_peer(domain), to: Peers + defdelegate outgoing_peers, to: Peers + defdelegate outgoing_peer(domain), to: Peers + defdelegate local_domain, to: Peers + defdelegate list_server_presence_states(server_id), to: Peers + defdelegate list_visible_server_presence_states(server_id, user_id), to: Peers + defdelegate list_room_presence_states(conversation_id), to: Peers + defdelegate list_visible_room_presence_states(conversation_id, user_id), to: Peers + defdelegate list_peer_controls, to: Peers + defdelegate paginate_peer_controls(search_query, page, per_page), to: Peers + defdelegate upsert_peer_policy(domain, attrs), to: Peers + defdelegate upsert_peer_policy(domain, attrs, updated_by_id), to: Peers + defdelegate clear_peer_policy(domain), to: Peers + defdelegate block_peer_domain(domain), to: Peers + defdelegate block_peer_domain(domain, reason), to: Peers + defdelegate block_peer_domain(domain, reason, updated_by_id), to: Peers + defdelegate unblock_peer_domain(domain), to: Peers + defdelegate unblock_peer_domain(domain, updated_by_id), to: Peers + + defdelegate discover_peer(domain), to: PublicDiscovery + defdelegate discover_peer(domain, opts), to: PublicDiscovery + defdelegate refresh_peer_discovery(domain), to: PublicDiscovery + defdelegate local_discovery_document(), to: PublicDiscovery + defdelegate local_discovery_document(version), to: PublicDiscovery + defdelegate arblarg_profiles_document(), to: PublicDiscovery + defdelegate arblarg_profiles_document(version), to: PublicDiscovery + defdelegate discovery_limits_for_transport, to: PublicDiscovery + defdelegate transport_profiles_for_transport, to: PublicDiscovery + defdelegate session_flow_control_for_transport, to: PublicDiscovery + + def signature_payload( + domain, + method, + request_path, + query_string, + timestamp, + content_digest \\ "", + request_id \\ "" + ) do + RequestAuth.signature_payload( + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id + ) + end + + defdelegate body_digest(body), to: RequestAuth + defdelegate sign_payload(payload, signing_material), to: RequestAuth + defdelegate valid_timestamp?(timestamp), to: RequestAuth + defdelegate verify_signature(a, b, c, d, e, f, g), to: RequestAuth + defdelegate verify_signature(a, b, c, d, e, f, g, h), to: RequestAuth + defdelegate verify_signature(a, b, c, d, e, f, g, h, i), to: RequestAuth + defdelegate verify_signature(a, b, c, d, e, f, g, h, i, j), to: RequestAuth + + def signed_headers(peer, method, request_path, query_string \\ "", body \\ "") do + RequestAuth.signed_headers(peer, method, request_path, query_string, body) + end + + defdelegate request_replay_nonce(a, b, c, d, e, f, g, h, i), to: RequestAuth + defdelegate claim_request_nonce(a, b, c, d, e, f, g, h, i), to: RequestAuth + + def build_server_snapshot(server_id, opts \\ []) do + Ingress.build_server_snapshot(server_id, opts) + end + + defdelegate import_server_snapshot(payload, remote_domain), to: Ingress + defdelegate receive_event(payload, remote_domain), to: Ingress + defdelegate receive_event_batch(payload, remote_domain), to: Ingress + defdelegate receive_ephemeral_batch(payload, remote_domain), to: Ingress + defdelegate receive_session_stream_batch(payload, remote_domain), to: Ingress + defdelegate receive_session_stream_batch(payload, remote_domain, frame_delivery_id), to: Ingress + defdelegate receive_session_ephemeral_batch(payload, remote_domain), to: Ingress + + defdelegate receive_session_ephemeral_batch(payload, remote_domain, frame_delivery_id), + to: Ingress + + defdelegate export_stream_events(stream_id), to: Ingress + defdelegate export_stream_events(stream_id, opts), to: Ingress + defdelegate recover_sequence_gap(payload, remote_domain), to: Ingress + defdelegate refresh_mirror_server_snapshot(server), to: Ingress + + defdelegate push_server_snapshot(server_id), to: Egress + defdelegate publish_server_upsert(server_id), to: Egress + defdelegate publish_message_created(message), to: Egress + defdelegate publish_dm_message_created(message), to: Egress + defdelegate publish_dm_message_created(message, remote_handle), to: Egress + defdelegate publish_dm_call_invite(session_id), to: Egress + defdelegate publish_dm_call_accept(session_id), to: Egress + defdelegate publish_dm_call_reject(session_id), to: Egress + defdelegate publish_dm_call_end(session_id), to: Egress + defdelegate publish_dm_call_signal(session_id, actor_user_id, kind, signal_payload), to: Egress + defdelegate publish_message_updated(message), to: Egress + defdelegate publish_message_deleted(message), to: Egress + defdelegate publish_reaction_added(message, reaction), to: Egress + defdelegate publish_reaction_removed(message, user_id, emoji), to: Egress + + def publish_read_cursor(conversation_id, user_id, message_id, read_at \\ DateTime.utc_now()) do + Egress.publish_read_cursor(conversation_id, user_id, message_id, read_at) + end + + def publish_read_receipt(conversation_id, user_id, message_id, read_at \\ DateTime.utc_now()) do + Egress.publish_read_receipt(conversation_id, user_id, message_id, read_at) + end + + defdelegate publish_typing_started(conversation_id, user_id), to: Egress + defdelegate publish_typing_stopped(conversation_id, user_id), to: Egress + + def publish_presence_update(server_id, user_id, status, activities \\ []) do + Egress.publish_presence_update(server_id, user_id, status, activities) + end + + def publish_room_presence_update(conversation_id, user_id, status, activities \\ []) do + Egress.publish_room_presence_update(conversation_id, user_id, status, activities) + end + + def publish_user_presence_update(user_id, status, activities \\ []) do + Egress.publish_user_presence_update(user_id, status, activities) + end + + def publish_extension_event(conversation_id, actor_user_id, event_type, payload) do + Egress.publish_extension_event(conversation_id, actor_user_id, event_type, payload) + end + + def publish_membership_state(conversation_id, user_id, state, role \\ "member") do + Egress.publish_membership_state(conversation_id, user_id, state, role) + end + + def publish_invite_state( + conversation_id, + target_user_id, + actor_user_id, + state \\ "pending", + role \\ "member", + metadata \\ %{} + ) do + Egress.publish_invite_state( + conversation_id, + target_user_id, + actor_user_id, + state, + role, + metadata + ) + end + + def publish_remote_invite_state( + conversation_id, + target_payload, + actor_user_id, + state \\ "pending", + role \\ "member", + metadata \\ %{} + ) do + Egress.publish_remote_invite_state( + conversation_id, + target_payload, + actor_user_id, + state, + role, + metadata + ) + end + + def publish_ban_state( + conversation_id, + target_user_id, + actor_user_id, + state \\ "active", + reason \\ nil, + expires_at \\ nil, + metadata \\ %{} + ) do + Egress.publish_ban_state( + conversation_id, + target_user_id, + actor_user_id, + state, + reason, + expires_at, + metadata + ) + end + + defdelegate maybe_push_for_conversation(conversation_id), to: Egress + defdelegate maybe_push_for_server(server_id), to: Egress + defdelegate process_outbox_event(outbox_event_id), to: Egress + + def enqueue_due_outbox_events(limit \\ 500) do + Egress.enqueue_due_outbox_events(limit) + end + + def run_retention do + Maintenance.run_retention(Runtime.federation_config()) + end + + defdelegate error_code(reason), to: Errors + defdelegate error_reason(code), to: Errors +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/actors.ex b/apps/elektrine/lib/elektrine/messaging/federation/actors.ex new file mode 100644 index 0000000..6e8a0c6 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/actors.ex @@ -0,0 +1,265 @@ +defmodule Elektrine.Messaging.Federation.Actors do + @moduledoc false + + alias Elektrine.ActivityPub.Actor, as: ActivityPubActor + alias Elektrine.Repo + + def resolve_remote_actor_id(%{"uri" => uri}) when is_binary(uri) do + case Repo.get_by(ActivityPubActor, uri: uri) do + %ActivityPubActor{id: actor_id} -> + {:ok, actor_id} + + nil -> + {:error, :actor_not_found} + end + end + + def resolve_remote_actor_id(_actor_payload), do: {:error, :invalid_actor} + + def resolve_or_create_remote_actor_id(actor_payload, remote_domain, context) + when is_map(actor_payload) and is_binary(remote_domain) and is_map(context) do + case resolve_remote_actor_id(actor_payload) do + {:ok, actor_id} -> + {:ok, actor_id} + + _ -> + upsert_remote_actor(actor_payload, remote_domain, context) + end + end + + def resolve_or_create_remote_actor_id(_actor_payload, _remote_domain, _context), + do: {:error, :invalid_actor} + + def upsert_remote_actor(actor_payload, remote_domain, context) + when is_map(actor_payload) and is_binary(remote_domain) and is_map(context) do + normalized_remote_domain = String.downcase(remote_domain) + + case normalize_canonical_actor_payload(actor_payload, normalized_remote_domain) do + {:ok, actor_identity} -> + attrs = %{ + uri: actor_identity.uri, + username: actor_identity.username, + domain: actor_identity.domain, + display_name: actor_identity.display_name, + avatar_url: actor_identity.avatar_url, + inbox_url: actor_identity.inbox_url, + public_key: remote_actor_public_key(actor_payload, normalized_remote_domain, context), + actor_type: "Person" + } + + case Repo.get_by(ActivityPubActor, uri: actor_identity.uri) do + %ActivityPubActor{id: actor_id} = actor -> + _ = actor |> ActivityPubActor.changeset(attrs) |> Repo.update() + {:ok, actor_id} + + nil -> + case %ActivityPubActor{} |> ActivityPubActor.changeset(attrs) |> Repo.insert() do + {:ok, actor} -> {:ok, actor.id} + {:error, _} -> {:error, :invalid_actor} + end + end + + _ -> + {:error, :invalid_actor} + end + end + + def upsert_remote_actor(_actor_payload, _remote_domain, _context), do: {:error, :invalid_actor} + + defp remote_actor_public_key(actor_payload, remote_domain, context) + when is_map(actor_payload) and is_binary(remote_domain) do + explicit_key = + normalize_optional_string( + actor_payload["public_key"] || + actor_payload["public_key_pem"] || + get_in(actor_payload, ["publicKey", "publicKeyPem"]) || + get_in(actor_payload, ["public_key", "public_key_pem"]) + ) + + explicit_key || + call(context, :resolve_peer, [remote_domain]) + |> peer_actor_public_key(actor_payload, context) + end + + defp remote_actor_public_key(_actor_payload, _remote_domain, _context), do: nil + + defp peer_actor_public_key(%{} = peer, actor_payload, context) when is_map(actor_payload) do + actor_key_id = + normalize_optional_string( + actor_payload["key_id"] || + actor_payload[:key_id] || + get_in(actor_payload, ["publicKey", "id"]) + ) + + peer + |> then(&call(context, :incoming_verification_materials_for_key_id, [&1, actor_key_id])) + |> List.first() + |> encode_actor_public_key() + end + + defp peer_actor_public_key(_peer, _actor_payload, _context), do: nil + + defp encode_actor_public_key(key) when is_binary(key) and byte_size(key) == 32 do + Base.url_encode64(key, padding: false) + end + + defp encode_actor_public_key(key) when is_binary(key) do + normalize_optional_string(key) + end + + defp encode_actor_public_key(_key), do: nil + + defp normalize_canonical_actor_payload(actor_payload, fallback_domain) + when is_map(actor_payload) and is_binary(fallback_domain) do + with {:ok, actor} <- normalize_dm_actor_payload(actor_payload, fallback_domain), + uri when is_binary(uri) <- + normalize_optional_string(actor_payload["uri"] || actor_payload[:uri]), + true <- valid_absolute_http_uri?(uri), + true <- canonical_actor_handle?(actor.username, actor.domain, actor.handle), + inbox_url when is_binary(inbox_url) <- canonical_actor_inbox_url(actor_payload, uri) do + {:ok, + %{ + uri: uri, + username: actor.username, + domain: actor.domain, + handle: actor.handle, + display_name: actor.display_name, + avatar_url: actor.avatar_url, + inbox_url: inbox_url + }} + else + false -> + {:error, :invalid_actor} + + _ -> + {:error, :invalid_actor} + end + end + + defp normalize_canonical_actor_payload(_actor_payload, _fallback_domain), + do: {:error, :invalid_actor} + + defp normalize_dm_actor_payload(payload, fallback_domain) + when is_map(payload) and is_binary(fallback_domain) do + normalized_fallback_domain = String.downcase(fallback_domain) + raw_uri = normalize_optional_string(payload["uri"] || payload[:uri]) + raw_handle = normalize_optional_string(payload["handle"] || payload[:handle]) + raw_username = normalize_optional_string(payload["username"] || payload[:username]) + raw_domain = normalize_optional_string(payload["domain"] || payload[:domain]) + + normalized_identity = + cond do + is_binary(raw_handle) -> + normalize_remote_dm_handle(raw_handle) + + is_binary(raw_username) -> + domain = raw_domain || normalized_fallback_domain + normalize_remote_dm_handle("#{raw_username}@#{domain}") + + true -> + {:error, :invalid_event_payload} + end + + with {:ok, identity} <- normalized_identity, + true <- valid_absolute_http_uri?(raw_uri) do + {:ok, + %{ + uri: raw_uri, + username: identity.username, + domain: identity.domain, + handle: identity.handle, + display_name: + normalize_optional_string(payload["display_name"] || payload[:display_name]) || + identity.username, + avatar_url: + normalize_optional_string( + payload["avatar_url"] || payload[:avatar_url] || payload["avatar"] || + payload[:avatar] + ) + }} + else + false -> + {:error, :invalid_event_payload} + + error -> + error + end + end + + defp normalize_dm_actor_payload(_payload, _fallback_domain), + do: {:error, :invalid_event_payload} + + defp normalize_remote_dm_handle(handle) when is_binary(handle) do + normalized = + handle + |> String.trim() + |> String.trim_leading("@") + |> String.downcase() + + case Regex.run(~r/^([a-z0-9][a-z0-9_.-]{0,63})@([a-z0-9.-]+\.[a-z]{2,})$/, normalized) do + [_, username, domain] -> + {:ok, %{username: username, domain: domain, handle: "#{username}@#{domain}"}} + + _ -> + {:error, :invalid_remote_handle} + end + end + + defp canonical_actor_handle?(username, domain, handle) + when is_binary(username) and is_binary(domain) and is_binary(handle) do + String.downcase(handle) == String.downcase("#{username}@#{domain}") + end + + defp canonical_actor_handle?(_username, _domain, _handle), do: false + + defp canonical_actor_inbox_url(actor_payload, uri) + when is_map(actor_payload) and is_binary(uri) do + normalize_optional_string( + actor_payload["inbox_url"] || + actor_payload[:inbox_url] || + actor_payload["inbox"] || + actor_payload[:inbox] + ) || derive_actor_inbox_from_uri(uri) + end + + defp canonical_actor_inbox_url(_actor_payload, _uri), do: nil + + defp valid_absolute_http_uri?(value) when is_binary(value) do + case URI.parse(value) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + true + + _ -> + false + end + end + + defp valid_absolute_http_uri?(_value), do: false + + defp derive_actor_inbox_from_uri(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{scheme: scheme, host: host, port: port, path: path} + when scheme in ["http", "https"] and is_binary(host) and host != "" and is_binary(path) and + path != "" -> + normalized_path = String.trim_trailing(path, "/") + + %URI{scheme: scheme, host: host, port: port, path: normalized_path <> "/inbox"} + |> URI.to_string() + + _ -> + nil + end + end + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_value), do: nil + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/attachments.ex b/apps/elektrine/lib/elektrine/messaging/federation/attachments.ex new file mode 100644 index 0000000..a0c2750 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/attachments.ex @@ -0,0 +1,118 @@ +defmodule Elektrine.Messaging.Federation.Attachments do + @moduledoc false + + import Elektrine.Messaging.Federation.Utils + + def normalize_message_attachments(message_payload) when is_map(message_payload) do + case message_payload["attachments"] do + attachments when is_list(attachments) -> + attachments + |> Enum.filter(&valid_attachment_payload?/1) + |> Enum.map(&normalize_attachment_payload/1) + |> Enum.reject(&is_nil/1) + |> Enum.take(10) + + _ -> + old_urls = normalize_media_urls(message_payload["media_urls"]) + + old_metadata = + if is_map(message_payload["media_metadata"]), + do: message_payload["media_metadata"], + else: %{} + + old_urls + |> Enum.with_index() + |> Enum.map(fn {url, index} -> + %{ + "id" => "legacy-#{index}", + "url" => url, + "mime_type" => "application/octet-stream", + "authorization" => "public", + "retention" => "origin", + "alt_text" => old_attachment_text(old_metadata, "alt_texts", index) + } + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + end) + end + end + + def normalize_message_attachments(_message_payload), do: [] + + def attachment_storage_metadata(attachments) when is_list(attachments) do + %{"attachments" => attachments} + end + + def attachment_storage_metadata(_attachments), do: %{"attachments" => []} + + defp valid_attachment_payload?(attachment) when is_map(attachment) do + authorization = normalize_optional_string(attachment["authorization"]) + retention = normalize_optional_string(attachment["retention"]) + + is_binary(normalize_optional_string(attachment["id"])) and + is_binary(normalize_optional_string(attachment["url"])) and + is_binary(normalize_optional_string(attachment["mime_type"])) and + authorization in ["public", "signed", "origin-authenticated"] and + retention in ["origin", "rehosted", "expiring"] + end + + defp valid_attachment_payload?(_attachment), do: false + + defp normalize_attachment_payload(attachment) when is_map(attachment) do + %{} + |> maybe_put_optional_map_value("id", attachment["id"]) + |> maybe_put_optional_map_value("url", attachment["url"]) + |> maybe_put_optional_map_value("mime_type", attachment["mime_type"]) + |> maybe_put_optional_map_value("sha256", attachment["sha256"]) + |> maybe_put_optional_map_value("authorization", attachment["authorization"]) + |> maybe_put_optional_map_value("retention", attachment["retention"]) + |> maybe_put_optional_map_value("expires_at", attachment["expires_at"]) + |> maybe_put_optional_map_value("alt_text", attachment["alt_text"]) + |> maybe_put_optional_integer_value("byte_size", attachment["byte_size"]) + |> maybe_put_optional_integer_value("width", attachment["width"]) + |> maybe_put_optional_integer_value("height", attachment["height"]) + |> maybe_put_optional_integer_value("duration_ms", attachment["duration_ms"]) + end + + defp normalize_attachment_payload(_attachment), do: nil + + defp old_attachment_text(metadata, key, index) when is_map(metadata) and is_binary(key) do + case Map.get(metadata, key) do + %{} = values -> + case values[to_string(index)] || values[index] do + value when is_binary(value) -> + trimmed = String.trim(value) + if Elektrine.Strings.present?(trimmed), do: trimmed, else: nil + + _ -> + nil + end + + _ -> + nil + end + end + + defp old_attachment_text(_metadata, _key, _index), do: nil + + defp maybe_put_optional_map_value(map, _key, nil), do: map + + defp maybe_put_optional_map_value(map, key, value) when is_binary(value) do + trimmed = String.trim(value) + if Elektrine.Strings.present?(trimmed), do: Map.put(map, key, trimmed), else: map + end + + defp maybe_put_optional_map_value(map, _key, _value), do: map + + defp maybe_put_optional_integer_value(map, key, value) + when is_integer(value) and value >= 0 do + Map.put(map, key, value) + end + + defp maybe_put_optional_integer_value(map, _key, _value), do: map + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_value), do: nil +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/auth.ex b/apps/elektrine/lib/elektrine/messaging/federation/auth.ex new file mode 100644 index 0000000..b0def74 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/auth.ex @@ -0,0 +1,383 @@ +defmodule Elektrine.Messaging.Federation.Auth do + @moduledoc false + + alias Elektrine.Messaging.{ArblargSDK, FederationRequestReplay} + alias Elektrine.Repo + + def signature_payload( + domain, + method, + request_path, + query_string, + timestamp, + content_digest \\ "", + request_id \\ "" + ) do + ArblargSDK.canonical_request_signature_payload( + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id + ) + end + + def body_digest(body) when is_binary(body), do: ArblargSDK.body_digest(body) + def body_digest(_body), do: body_digest("") + + def sign_payload(payload, signing_material) when is_binary(payload) do + ArblargSDK.sign_payload(payload, signing_material) + end + + def valid_timestamp?(timestamp, clock_skew_seconds) when is_binary(timestamp) do + ArblargSDK.valid_timestamp?(timestamp, clock_skew_seconds) + end + + def verify_secret_signature( + secret, + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + signature + ) + when is_binary(secret) and is_binary(signature) do + payload = + signature_payload( + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id + ) + + case ArblargSDK.verification_public_key(secret) do + {:ok, public_key} -> ArblargSDK.verify_payload_signature(payload, public_key, signature) + _ -> false + end + end + + def verify_peer_signature( + peer, + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + key_id, + signature, + context + ) + when is_map(peer) and is_binary(signature) and is_map(context) do + if verify_signature_with_peer( + peer, + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + key_id, + signature + ) do + true + else + maybe_refresh_discovered_peer_signature( + peer, + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + key_id, + signature, + context + ) + end + end + + def signed_headers(peer, method, request_path, query_string, body, context) + when is_map(peer) and is_binary(method) and is_binary(request_path) and is_map(context) do + timestamp = Integer.to_string(System.system_time(:second)) + domain = call(context, :local_domain, []) + request_id = Ecto.UUID.generate() + {key_id, signing_material} = outbound_signing_material(peer, context) + content_digest = body_digest(body) + + signature = + signature_payload( + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id + ) + |> sign_payload(signing_material) + + [ + {"content-type", "application/json"}, + {"x-arblarg-domain", domain}, + {"x-arblarg-key-id", key_id}, + {"x-arblarg-timestamp", timestamp}, + {"x-arblarg-content-digest", content_digest}, + {"x-arblarg-request-id", request_id}, + {"x-arblarg-signature-algorithm", ArblargSDK.signature_algorithm()}, + {"x-arblarg-signature", signature} + ] + end + + def request_replay_nonce( + domain, + key_id, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + signature + ) do + base = + [ + to_string(domain || "") |> String.downcase(), + to_string(key_id || ""), + to_string(method || "") |> String.upcase(), + canonical_path(request_path), + canonical_query_string(query_string), + to_string(timestamp || "") |> String.trim(), + canonical_content_digest(content_digest), + to_string(request_id || "") |> String.trim(), + to_string(signature || "") |> String.trim() + ] + |> Enum.join("\n") + + :crypto.hash(:sha256, base) |> Base.url_encode64(padding: false) + end + + def claim_request_nonce( + domain, + key_id, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + signature, + context + ) + when is_map(context) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + expires_at = DateTime.add(now, call(context, :replay_nonce_ttl_seconds, []), :second) + + nonce = + request_replay_nonce( + domain, + key_id, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + signature + ) + + inserted_at = DateTime.to_naive(now) + + attrs = [ + %{ + nonce: nonce, + origin_domain: to_string(domain || ""), + key_id: call(context, :normalize_optional_string, [key_id]), + http_method: to_string(method || "") |> String.upcase(), + request_path: canonical_path(request_path), + timestamp: call(context, :parse_int, [timestamp, 0]), + seen_at: now, + expires_at: expires_at, + inserted_at: inserted_at + } + ] + + {count, _} = Repo.insert_all(FederationRequestReplay, attrs, on_conflict: :nothing) + + if count == 1, do: :ok, else: {:error, :replayed_request} + end + + def incoming_verification_materials_for_key_id(peer, key_id) when is_map(peer) do + case normalize_optional_string(key_id) do + nil -> + peer.keys + |> Enum.map(&key_verification_material/1) + |> Enum.reject(&is_nil/1) + + requested_key_id -> + peer.keys + |> Enum.filter(&(key_id_for(&1) == requested_key_id)) + |> Enum.map(&key_verification_material/1) + |> Enum.reject(&is_nil/1) + end + end + + def incoming_verification_materials_for_key_id(_peer, _key_id), do: [] + + def outbound_signing_material(peer, context) when is_map(peer) and is_map(context) do + active_key_id = peer.active_outbound_key_id + + case Enum.find(peer.keys, fn key -> key.id == active_key_id and is_binary(key.private_key) end) do + %{id: id, private_key: private_key} -> + {id, private_key} + + _ -> + peer.keys + |> Enum.find(&is_binary(&1.private_key)) + |> case do + %{id: id, private_key: private_key} -> {id, private_key} + _ -> call(context, :local_event_signing_material, []) + end + end + end + + def outbound_signing_material(_peer, context), + do: call(context, :local_event_signing_material, []) + + defp verify_signature_with_peer( + peer, + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + key_id, + signature + ) do + peer + |> incoming_verification_materials_for_key_id(key_id) + |> Enum.any?(fn public_key_material -> + verify_secret_signature( + public_key_material, + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + signature + ) + end) + end + + defp maybe_refresh_discovered_peer_signature( + peer, + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + key_id, + signature, + context + ) do + if discovered_peer?(peer) do + case call(context, :discover_peer_force, [peer.domain]) do + {:ok, %{allow_incoming: true} = refreshed_peer} -> + verify_signature_with_peer( + refreshed_peer, + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + key_id, + signature + ) + + _ -> + false + end + else + false + end + end + + defp discovered_peer?(peer) when is_map(peer) do + Map.get(peer, :discovery_source) == :dynamic or Map.get(peer, "discovery_source") == :dynamic + end + + defp discovered_peer?(_peer), do: false + + defp key_verification_material(key) when is_map(key) do + Map.get(key, :public_key) || Map.get(key, "public_key") || Map.get(key, :secret) || + Map.get(key, "secret") + end + + defp key_verification_material(_key), do: nil + + defp key_id_for(key) when is_map(key) do + Map.get(key, :id) || Map.get(key, "id") + end + + defp key_id_for(_key), do: nil + + defp canonical_path(nil), do: "/" + + defp canonical_path(path) when is_binary(path) do + trimmed = String.trim(path) + + cond do + not Elektrine.Strings.present?(trimmed) -> "/" + String.starts_with?(trimmed, "/") -> trimmed + true -> "/" <> trimmed + end + end + + defp canonical_path(path), do: canonical_path(to_string(path)) + + defp canonical_query_string(nil), do: "" + defp canonical_query_string(query) when is_binary(query), do: String.trim(query) + defp canonical_query_string(query), do: to_string(query) + + defp canonical_content_digest(nil), do: body_digest("") + + defp canonical_content_digest(content_digest) when is_binary(content_digest) do + case String.trim(content_digest) do + "" -> body_digest("") + value -> value + end + end + + defp canonical_content_digest(content_digest), + do: canonical_content_digest(to_string(content_digest)) + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_value), do: nil + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/builders.ex b/apps/elektrine/lib/elektrine/messaging/federation/builders.ex new file mode 100644 index 0000000..a49ffdb --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/builders.ex @@ -0,0 +1,1116 @@ +defmodule Elektrine.Messaging.Federation.Builders do + @moduledoc false + + import Ecto.Query, warn: false + import Elektrine.Messaging.Federation.Utils + + alias Elektrine.Accounts.User + + alias Elektrine.Messaging.{ + ArblargSDK, + ChatConversation, + ChatConversationMember, + ChatMessage, + ChatMessageReaction, + FederationCallSession, + FederationOutboxEvent, + Server, + ServerMember + } + + alias Elektrine.Messaging.Federation.{DirectMessageState, State, Visibility, VoiceCalls} + alias Elektrine.Repo + + @dm_message_create_event_type "urn:arblarg:ext:dm:1#message.create" + @dm_call_invite_event_type "urn:arblarg:ext:voice:1#dm.call.invite" + @dm_call_accept_event_type "urn:arblarg:ext:voice:1#dm.call.accept" + @dm_call_reject_event_type "urn:arblarg:ext:voice:1#dm.call.reject" + @dm_call_end_event_type "urn:arblarg:ext:voice:1#dm.call.end" + @dm_call_signal_event_type "urn:arblarg:ext:voice:1#dm.call.signal" + + def build_server_upsert_event(server_id, context) + when is_integer(server_id) and is_map(context) do + with %Server{} = server <- Repo.get(Server, server_id), + false <- server.is_federated_mirror, + true <- server.is_public == true do + channels = Visibility.public_bootstrap_channels(server) + + stream_id = server_stream_id(server.id) + sequence = next_outbound_sequence(stream_id) + + {:ok, + event_envelope( + ArblargSDK.bootstrap_server_upsert_event_type(), + stream_id, + sequence, + %{ + "server" => server_payload(server), + "channels" => Enum.map(channels, &channel_payload/1) + }, + context + )} + else + nil -> {:error, :not_found} + true -> {:error, :federated_mirror} + false -> {:error, :not_public} + end + end + + def build_message_created_event(%ChatMessage{} = message, context) when is_map(context) do + message = + message |> reset_hydrated_sender() |> Repo.preload([:sender, conversation: [:server]]) + + conversation = message.conversation + server = if conversation, do: conversation.server, else: nil + + cond do + is_nil(conversation) or is_nil(server) -> + {:error, :not_found} + + conversation.type != "channel" -> + {:error, :unsupported_conversation_type} + + true -> + stream_id = outbound_channel_stream_id(conversation) + sequence = next_outbound_sequence(stream_id) + refs = event_refs_payload(server, conversation) + + {:ok, + event_envelope( + "message.create", + stream_id, + sequence, + %{"refs" => refs, "message" => event_message_payload(message)}, + context + )} + end + end + + def build_dm_message_created_event(%ChatMessage{} = message, remote_handle, context) + when is_binary(remote_handle) and is_map(context) do + message = message |> reset_hydrated_sender() |> Repo.preload([:sender, :conversation]) + conversation = message.conversation + + with {:ok, recipient} <- DirectMessageState.normalize_remote_dm_handle(remote_handle), + %ChatConversation{} <- conversation, + true <- conversation.type == "dm", + true <- is_integer(message.sender_id), + %User{} = sender <- message.sender, + origin_domain = preferred_dm_origin_domain_for_user(sender), + conversation_handle when is_binary(conversation_handle) <- + DirectMessageState.remote_dm_handle_from_source(conversation.federated_source), + true <- conversation_handle == recipient.handle do + stream_id = dm_stream_id(conversation.id, domain: origin_domain) + sequence = next_outbound_sequence(stream_id) + dm_id = dm_federation_id(conversation.id, domain: origin_domain) + sender_data = sender_payload(sender, domain: origin_domain) + + message_payload = + %{ + "id" => + message.federated_source || message_federation_id(message.id, domain: origin_domain), + "dm_id" => dm_id, + "content" => message.content || "", + "message_type" => message.message_type || "text", + "attachments" => attachment_payloads(message), + "created_at" => format_created_at(message.inserted_at), + "edited_at" => format_created_at(message.edited_at), + "sender" => sender_data + } + |> maybe_put_client_encryption(message) + + {:ok, + event_envelope( + @dm_message_create_event_type, + stream_id, + sequence, + %{ + "dm" => %{ + "id" => dm_id, + "sender" => sender_data, + "recipient" => DirectMessageState.dm_actor_payload(recipient) + }, + "message" => message_payload + }, + context, + origin_domain: origin_domain + )} + else + nil -> {:error, :not_found} + false -> {:error, :invalid_event_payload} + _ -> {:error, :unsupported_conversation_type} + end + end + + def build_dm_call_invite_event(session_id, context) + when is_integer(session_id) and is_map(context) do + with %FederationCallSession{} = session <- VoiceCalls.get_session(session_id), + %ChatConversation{} = conversation <- session.conversation, + %User{} = local_user <- session.local_user do + origin_domain = dm_event_origin_domain(session, local_user) + stream_id = dm_stream_id(conversation.id, domain: origin_domain) + sequence = next_outbound_sequence(stream_id) + dm_payload = dm_call_context_payload(session, local_user) + + payload = %{ + "dm" => dm_payload, + "call" => %{ + "id" => session.federated_call_id, + "dm_id" => dm_payload["id"], + "call_type" => session.call_type, + "actor" => sender_payload(local_user, domain: origin_domain), + "initiated_at" => format_created_at(session.inserted_at), + "metadata" => session.metadata || %{} + } + } + + with :ok <- ArblargSDK.validate_event_payload(@dm_call_invite_event_type, payload) do + {:ok, + event_envelope( + @dm_call_invite_event_type, + stream_id, + sequence, + payload, + context, + origin_domain: origin_domain + ), [session.remote_domain]} + end + else + _ -> {:error, :not_found} + end + end + + def build_dm_call_accept_event(session_id, context) + when is_integer(session_id) and is_map(context) do + build_dm_call_terminal_event( + session_id, + @dm_call_accept_event_type, + %{ + "accepted_at" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + }, + context + ) + end + + def build_dm_call_reject_event(session_id, context) + when is_integer(session_id) and is_map(context) do + build_dm_call_terminal_event( + session_id, + @dm_call_reject_event_type, + %{ + "rejected_at" => + DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "reason" => get_in(VoiceCalls.get_session(session_id) || %{}, [:metadata, "reason"]) + }, + context + ) + end + + def build_dm_call_end_event(session_id, context) + when is_integer(session_id) and is_map(context) do + build_dm_call_terminal_event( + session_id, + @dm_call_end_event_type, + %{ + "ended_at" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "reason" => get_in(VoiceCalls.get_session(session_id) || %{}, [:metadata, "reason"]) + }, + context + ) + end + + def build_dm_call_signal_ephemeral_item( + session_id, + actor_user_id, + kind, + signal_payload, + context + ) + when is_integer(session_id) and is_integer(actor_user_id) and is_binary(kind) and + is_map(signal_payload) and is_map(context) do + with %FederationCallSession{} = session <- VoiceCalls.get_session(session_id), + %ChatConversation{} = _conversation <- session.conversation, + %User{} = local_user <- session.local_user, + true <- local_user.id == actor_user_id do + origin_domain = dm_event_origin_domain(session, local_user) + + payload = %{ + "dm" => dm_call_context_payload(session, local_user), + "call_id" => session.federated_call_id, + "actor" => sender_payload(local_user, domain: origin_domain), + "sent_at" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "signal" => %{ + "kind" => kind, + "payload" => signal_payload + } + } + + with :ok <- ArblargSDK.validate_event_payload(@dm_call_signal_event_type, payload) do + {:ok, + ephemeral_item(@dm_call_signal_event_type, payload, context, + origin_domain: origin_domain + ), [session.remote_domain]} + end + else + false -> {:error, :unauthorized} + _ -> {:error, :not_found} + end + end + + def build_message_updated_event(%ChatMessage{} = message, context) when is_map(context) do + message = + message |> reset_hydrated_sender() |> Repo.preload([:sender, conversation: [:server]]) + + conversation = message.conversation + server = if conversation, do: conversation.server, else: nil + + cond do + is_nil(conversation) or is_nil(server) -> + {:error, :not_found} + + conversation.type != "channel" -> + {:error, :unsupported_conversation_type} + + true -> + stream_id = outbound_channel_stream_id(conversation) + sequence = next_outbound_sequence(stream_id) + refs = event_refs_payload(server, conversation) + + {:ok, + event_envelope( + "message.update", + stream_id, + sequence, + %{"refs" => refs, "message" => event_message_payload(message)}, + context + )} + end + end + + def build_message_deleted_event(%ChatMessage{} = message, context) when is_map(context) do + message = Repo.preload(message, conversation: [:server]) + conversation = message.conversation + server = if conversation, do: conversation.server, else: nil + + cond do + is_nil(conversation) or is_nil(server) -> + {:error, :not_found} + + conversation.type != "channel" -> + {:error, :unsupported_conversation_type} + + true -> + stream_id = outbound_channel_stream_id(conversation) + sequence = next_outbound_sequence(stream_id) + refs = event_refs_payload(server, conversation) + + {:ok, + event_envelope( + "message.delete", + stream_id, + sequence, + %{ + "refs" => refs, + "message_id" => message.federated_source || message_federation_id(message.id), + "deleted_at" => format_created_at(message.deleted_at || DateTime.utc_now()) + }, + context + )} + end + end + + def build_reaction_added_event( + %ChatMessage{} = message, + %ChatMessageReaction{} = reaction, + context + ) + when is_map(context) do + message = Repo.preload(message, conversation: [:server]) + conversation = message.conversation + server = if conversation, do: conversation.server, else: nil + + cond do + is_nil(conversation) or is_nil(server) -> + {:error, :not_found} + + conversation.type != "channel" -> + {:error, :unsupported_conversation_type} + + is_nil(reaction.user_id) -> + {:error, :unsupported_reaction_actor} + + true -> + user = Repo.get(User, reaction.user_id) + + if is_nil(user) do + {:error, :not_found} + else + stream_id = outbound_channel_stream_id(conversation) + sequence = next_outbound_sequence(stream_id) + refs = event_refs_payload(server, conversation) + + {:ok, + event_envelope( + "reaction.add", + stream_id, + sequence, + %{ + "refs" => refs, + "message_id" => message.federated_source || message_federation_id(message.id), + "reaction" => %{"emoji" => reaction.emoji, "actor" => sender_payload(user)} + }, + context + )} + end + end + end + + def build_reaction_removed_event(%ChatMessage{} = message, user_id, emoji, context) + when is_integer(user_id) and is_map(context) do + message = Repo.preload(message, conversation: [:server]) + conversation = message.conversation + server = if conversation, do: conversation.server, else: nil + + cond do + is_nil(conversation) or is_nil(server) -> + {:error, :not_found} + + conversation.type != "channel" -> + {:error, :unsupported_conversation_type} + + true -> + user = Repo.get(User, user_id) + + if is_nil(user) do + {:error, :not_found} + else + stream_id = outbound_channel_stream_id(conversation) + sequence = next_outbound_sequence(stream_id) + refs = event_refs_payload(server, conversation) + + {:ok, + event_envelope( + "reaction.remove", + stream_id, + sequence, + %{ + "refs" => refs, + "message_id" => message.federated_source || message_federation_id(message.id), + "reaction" => %{"emoji" => emoji, "actor" => sender_payload(user)} + }, + context + )} + end + end + end + + def build_read_cursor_event(conversation_id, user_id, message_id, read_at, context) + when is_integer(conversation_id) and is_integer(user_id) and is_integer(message_id) and + is_map(context) do + conversation = Repo.get(ChatConversation, conversation_id) |> Repo.preload(:server) + message = Repo.get(ChatMessage, message_id) + user = Repo.get(User, user_id) + server = if conversation, do: conversation.server, else: nil + + cond do + is_nil(conversation) or is_nil(server) or is_nil(message) or is_nil(user) -> + {:error, :not_found} + + conversation.type != "channel" -> + {:error, :unsupported_conversation_type} + + message.conversation_id != conversation.id -> + {:error, :invalid_event_payload} + + true -> + stream_id = outbound_channel_stream_id(conversation) + sequence = next_outbound_sequence(stream_id) + refs = event_refs_payload(server, conversation) + target_domains = target_domains_for_conversation(conversation, context) + + {:ok, + event_envelope( + "read.cursor", + stream_id, + sequence, + %{ + "refs" => refs, + "read_through_message_id" => + message.federated_source || message_federation_id(message.id), + "actor" => sender_payload(user), + "read_through_sequence" => read_cursor_sequence_for_message(stream_id, message), + "read_at" => format_created_at(read_at || DateTime.utc_now()) + }, + context + ), target_domains} + end + end + + # NOTE: unlike build_invite/remote_invite/ban events, this intentionally has + # no `is_federated_mirror` reject guard. Membership is multi-origin - each + # instance is authoritative for its own users' membership - so a mirror + # legitimately emits membership events, which Visibility routes back to the + # conversation's origin_domain. The asymmetry with the guarded sibling + # builders is by design, not an oversight. + def build_membership_upsert_event(conversation_id, user_id, state, role, context) + when is_integer(conversation_id) and is_integer(user_id) and is_binary(state) and + is_binary(role) and is_map(context) do + conversation = Repo.get(ChatConversation, conversation_id) |> Repo.preload(:server) + user = Repo.get(User, user_id) + server = if conversation, do: conversation.server, else: nil + + cond do + is_nil(conversation) or is_nil(server) or is_nil(user) -> + {:error, :not_found} + + conversation.type != "channel" -> + {:error, :unsupported_conversation_type} + + true -> + stream_id = outbound_channel_stream_id(conversation) + sequence = next_outbound_sequence(stream_id) + refs = event_refs_payload(server, conversation) + target_domains = target_domains_for_conversation(conversation, context) + joined_at = active_membership_joined_at(conversation.id, user.id) + + {:ok, + event_envelope( + "membership.upsert", + stream_id, + sequence, + %{ + "refs" => refs, + "membership" => %{ + "actor" => sender_payload(user), + "role" => role, + "state" => state, + "joined_at" => call(context, :maybe_iso8601, [joined_at]), + "updated_at" => + DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "metadata" => %{} + } + }, + context + ), target_domains} + end + end + + def build_invite_upsert_event( + conversation_id, + target_user_id, + actor_user_id, + state, + role, + metadata, + context + ) + when is_integer(conversation_id) and is_integer(target_user_id) and + is_integer(actor_user_id) and is_binary(state) and is_binary(role) and + is_map(metadata) and is_map(context) do + conversation = Repo.get(ChatConversation, conversation_id) |> Repo.preload(:server) + target_user = Repo.get(User, target_user_id) + actor_user = Repo.get(User, actor_user_id) + server = if conversation, do: conversation.server, else: nil + + cond do + is_nil(conversation) or is_nil(server) or is_nil(target_user) or is_nil(actor_user) -> + {:error, :not_found} + + conversation.type != "channel" -> + {:error, :unsupported_conversation_type} + + server.is_federated_mirror -> + {:error, :federated_mirror} + + state not in ["pending", "accepted", "declined", "revoked"] -> + {:error, :invalid_event_payload} + + true -> + build_invite_upsert_event_for_target_payload( + conversation_id, + sender_payload(target_user), + actor_user_id, + state, + role, + metadata, + context + ) + end + end + + def build_invite_upsert_event_for_target_payload( + conversation_id, + target_payload, + actor_user_id, + state, + role, + metadata, + context + ) + when is_integer(conversation_id) and is_map(target_payload) and + is_integer(actor_user_id) and is_binary(state) and is_binary(role) and + is_map(metadata) and is_map(context) do + conversation = Repo.get(ChatConversation, conversation_id) |> Repo.preload(:server) + actor_user = Repo.get(User, actor_user_id) + server = if conversation, do: conversation.server, else: nil + + cond do + is_nil(conversation) or is_nil(server) or is_nil(actor_user) -> + {:error, :not_found} + + conversation.type != "channel" -> + {:error, :unsupported_conversation_type} + + server.is_federated_mirror -> + {:error, :federated_mirror} + + state not in ["pending", "accepted", "declined", "revoked"] -> + {:error, :invalid_event_payload} + + true -> + stream_id = outbound_channel_stream_id(conversation) + sequence = next_outbound_sequence(stream_id) + refs = event_refs_payload(server, conversation) + target_domains = target_domains_for_invite_target(conversation, target_payload, context) + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + {:ok, + event_envelope( + "invite.upsert", + stream_id, + sequence, + %{ + "refs" => refs, + "invite" => %{ + "actor" => sender_payload(actor_user), + "target" => target_payload, + "role" => role, + "state" => state, + "invited_at" => DateTime.to_iso8601(timestamp), + "updated_at" => DateTime.to_iso8601(timestamp), + "metadata" => metadata + } + }, + context + ), target_domains} + end + end + + def build_ban_upsert_event( + conversation_id, + target_user_id, + actor_user_id, + state, + reason, + expires_at, + metadata, + context + ) + when is_integer(conversation_id) and is_integer(target_user_id) and + is_integer(actor_user_id) and is_binary(state) and is_map(metadata) and + is_map(context) do + conversation = Repo.get(ChatConversation, conversation_id) |> Repo.preload(:server) + target_user = Repo.get(User, target_user_id) + actor_user = Repo.get(User, actor_user_id) + server = if conversation, do: conversation.server, else: nil + + cond do + is_nil(conversation) or is_nil(server) or is_nil(target_user) or is_nil(actor_user) -> + {:error, :not_found} + + conversation.type != "channel" -> + {:error, :unsupported_conversation_type} + + server.is_federated_mirror -> + {:error, :federated_mirror} + + state not in ["active", "lifted"] -> + {:error, :invalid_event_payload} + + true -> + stream_id = outbound_channel_stream_id(conversation) + sequence = next_outbound_sequence(stream_id) + refs = event_refs_payload(server, conversation) + + target_domains = + target_domains_for_invite_target(conversation, sender_payload(target_user), context) + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + {:ok, + event_envelope( + "ban.upsert", + stream_id, + sequence, + %{ + "refs" => refs, + "ban" => %{ + "actor" => sender_payload(actor_user), + "target" => sender_payload(target_user), + "state" => state, + "reason" => call(context, :normalize_optional_string, [reason]), + "banned_at" => DateTime.to_iso8601(timestamp), + "updated_at" => DateTime.to_iso8601(timestamp), + "expires_at" => call(context, :maybe_iso8601, [expires_at]), + "metadata" => metadata + } + }, + context + ), target_domains} + end + end + + def build_typing_ephemeral_item(conversation_id, user_id, mode, context) + when mode in [:start, :stop] and is_integer(conversation_id) and is_integer(user_id) and + is_map(context) do + conversation = Repo.get(ChatConversation, conversation_id) |> Repo.preload(:server) + user = Repo.get(User, user_id) + server = if conversation, do: conversation.server, else: nil + + cond do + is_nil(conversation) or is_nil(server) or is_nil(user) -> + {:error, :not_found} + + conversation.type != "channel" -> + {:error, :unsupported_conversation_type} + + true -> + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + payload = %{ + "refs" => event_refs_payload(server, conversation), + "actor" => sender_payload(user) + } + + payload = + case mode do + :start -> + payload + |> Map.put("started_at", DateTime.to_iso8601(timestamp)) + |> Map.put("ttl_ms", 3_000) + + :stop -> + Map.put(payload, "stopped_at", DateTime.to_iso8601(timestamp)) + end + + event_type = if mode == :start, do: "typing.start", else: "typing.stop" + + {:ok, ephemeral_item(event_type, payload, context), + target_domains_for_conversation(conversation, context)} + end + end + + def build_presence_ephemeral_item(user_id, status, activities, context) + when is_integer(user_id) and is_binary(status) and is_map(context) do + user = Repo.get(User, user_id) + + if is_nil(user) do + {:error, :not_found} + else + ttl_ms = call(context, :presence_ttl_seconds, []) * 1_000 + + payload = %{ + "presence" => %{ + "actor" => sender_payload(user), + "status" => status, + "updated_at" => + DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "activities" => State.normalize_presence_activities(activities), + "ttl_ms" => ttl_ms + } + } + + {:ok, ephemeral_item("presence.update", payload, context)} + end + end + + def build_room_presence_ephemeral_item(conversation_id, user_id, status, activities, context) + when is_integer(conversation_id) and is_integer(user_id) and is_binary(status) and + is_map(context) do + conversation = Repo.get(ChatConversation, conversation_id) |> Repo.preload(:server) + user = Repo.get(User, user_id) + server = if conversation, do: conversation.server, else: nil + + cond do + is_nil(conversation) or is_nil(server) or is_nil(user) -> + {:error, :not_found} + + conversation.type != "channel" -> + {:error, :unsupported_conversation_type} + + true -> + ttl_ms = call(context, :presence_ttl_seconds, []) * 1_000 + + payload = %{ + "refs" => event_refs_payload(server, conversation), + "presence" => %{ + "actor" => sender_payload(user), + "status" => status, + "updated_at" => + DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "activities" => State.normalize_presence_activities(activities), + "ttl_ms" => ttl_ms + } + } + + {:ok, ephemeral_item("presence.update", payload, context), + target_domains_for_conversation(conversation, context)} + end + end + + def build_extension_event(conversation_id, actor_user_id, event_type, payload, context) + when is_integer(conversation_id) and is_integer(actor_user_id) and is_binary(event_type) and + is_map(payload) and is_map(context) do + conversation = Repo.get(ChatConversation, conversation_id) |> Repo.preload(:server) + actor_user = Repo.get(User, actor_user_id) + server = if conversation, do: conversation.server, else: nil + canonical_event_type = ArblargSDK.canonical_event_type(event_type) + + normalized_event_type = + Map.get(ArblargSDK.schema_bindings(), canonical_event_type, canonical_event_type) + + cond do + is_nil(conversation) or is_nil(server) or is_nil(actor_user) -> + {:error, :not_found} + + conversation.type != "channel" -> + {:error, :unsupported_conversation_type} + + canonical_event_type not in shared_governance_extension_event_types() -> + {:error, :unsupported_event_type} + + true -> + stream_id = outbound_channel_stream_id(conversation) + sequence = next_outbound_sequence(stream_id) + refs = event_refs_payload(server, conversation) + + extension_payload = + normalized_event_type + |> extension_payload_for_event_type( + payload, + server, + conversation, + actor_user + ) + |> Map.put("refs", refs) + + with :ok <- ArblargSDK.validate_event_payload(canonical_event_type, extension_payload) do + {:ok, + event_envelope( + canonical_event_type, + stream_id, + sequence, + extension_payload, + context + ), target_domains_for_conversation(conversation, context), canonical_event_type, + extension_payload} + end + end + end + + def event_envelope(event_type, stream_id, sequence, data, context, opts \\ []) + when is_binary(event_type) and is_binary(stream_id) and is_integer(sequence) and + is_map(data) and is_map(context) do + unsigned = %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => ArblargSDK.protocol_id(), + "protocol_label" => ArblargSDK.protocol_label(), + "protocol_version" => ArblargSDK.protocol_version(), + "event_id" => Ecto.UUID.generate(), + "event_type" => event_type, + "origin_domain" => Keyword.get(opts, :origin_domain, call(context, :local_domain, [])), + "stream_id" => stream_id, + "sequence" => sequence, + "sent_at" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "idempotency_key" => Ecto.UUID.generate(), + "payload" => data + } + + {key_id, signing_material} = call(context, :local_event_signing_material, []) + ArblargSDK.sign_event_envelope(unsigned, key_id, signing_material) + end + + def active_server_ids_for_user(user_id) when is_integer(user_id) do + from(sm in ServerMember, + where: sm.user_id == ^user_id and is_nil(sm.left_at), + select: sm.server_id + ) + |> Repo.all() + end + + def active_server_ids_for_user(_user_id), do: [] + + defp ephemeral_item(event_type, payload, context, opts \\ []) + when is_binary(event_type) and is_map(payload) and is_map(context) do + %{ + "event_type" => event_type, + "origin_domain" => Keyword.get(opts, :origin_domain, call(context, :local_domain, [])), + "sent_at" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "payload" => payload + } + end + + defp maybe_put_client_encryption(payload, %{client_encrypted_payload: encrypted_payload}) + when is_map(payload) and is_map(encrypted_payload) do + payload + |> Map.put("client_encrypted_payload", encrypted_payload) + |> Map.put("e2ee", %{ + "version" => 1, + "payload" => encrypted_payload, + "key_packages" => + encrypted_payload["federated_key_packages"] || encrypted_payload[:federated_key_packages] || + [] + }) + end + + defp maybe_put_client_encryption(payload, _message), do: payload + + defp read_cursor_sequence_for_message(stream_id, %ChatMessage{} = message) + when is_binary(stream_id) do + federation_message_id = message.federated_source || message_federation_id(message.id) + + from(o in FederationOutboxEvent, + where: o.stream_id == ^stream_id and o.event_type == "message.create", + where: fragment("?->'payload'->'message'->>'id' = ?", o.payload, ^federation_message_id), + select: o.sequence, + limit: 1 + ) + |> Repo.one() + end + + defp read_cursor_sequence_for_message(_stream_id, _message), do: nil + + defp outbound_channel_stream_id(%ChatConversation{} = conversation) do + "channel:" <> (conversation.federated_source || channel_federation_id(conversation.id)) + end + + defp outbound_channel_stream_id(conversation_id) when is_integer(conversation_id) do + case Repo.get(ChatConversation, conversation_id) do + %ChatConversation{} = conversation -> outbound_channel_stream_id(conversation) + _ -> channel_stream_id(conversation_id) + end + end + + defp outbound_channel_stream_id(_conversation), do: nil + + defp target_domains_for_conversation(%ChatConversation{} = conversation, context) + when is_map(context) do + Visibility.target_domains_for_room(conversation) + end + + defp target_domains_for_conversation(_conversation, _context), do: [] + + defp build_dm_call_terminal_event(session_id, event_type, extra_fields, context) + when is_integer(session_id) and is_binary(event_type) and is_map(extra_fields) and + is_map(context) do + with %FederationCallSession{} = session <- VoiceCalls.get_session(session_id), + %ChatConversation{} = conversation <- session.conversation, + %User{} = local_user <- session.local_user do + origin_domain = dm_event_origin_domain(session, local_user) + stream_id = dm_stream_id(conversation.id, domain: origin_domain) + sequence = next_outbound_sequence(stream_id) + dm_payload = dm_call_context_payload(session, local_user) + + payload = + %{ + "dm" => dm_payload, + "call_id" => session.federated_call_id, + "actor" => sender_payload(local_user, domain: origin_domain), + "metadata" => session.metadata || %{} + } + |> Map.merge( + Enum.reject(extra_fields, fn {_key, value} -> is_nil(value) end) + |> Map.new() + ) + + with :ok <- ArblargSDK.validate_event_payload(event_type, payload) do + {:ok, + event_envelope( + event_type, + stream_id, + sequence, + payload, + context, + origin_domain: origin_domain + ), [session.remote_domain]} + end + else + _ -> {:error, :not_found} + end + end + + defp dm_call_context_payload(%FederationCallSession{} = session, %User{} = local_user) do + origin_domain = dm_event_origin_domain(session, local_user) + local_actor = sender_payload(local_user, domain: origin_domain) + + if local_dm_origin_domain?(session.origin_domain, local_user) do + %{ + "id" => dm_federation_id(session.conversation_id, domain: origin_domain), + "sender" => local_actor, + "recipient" => normalize_remote_actor_payload(session.remote_actor, session.remote_handle) + } + else + %{ + "id" => dm_federation_id(session.conversation_id, domain: origin_domain), + "sender" => normalize_remote_actor_payload(session.remote_actor, session.remote_handle), + "recipient" => local_actor + } + end + end + + defp dm_event_origin_domain(%FederationCallSession{}, %User{} = local_user) do + preferred_dm_origin_domain_for_user(local_user) + end + + defp local_dm_origin_domain?(origin_domain, %User{} = local_user) + when is_binary(origin_domain) do + normalized_origin = String.downcase(origin_domain) + + normalized_origin == String.downcase(Elektrine.Messaging.Federation.local_domain()) or + normalized_origin == String.downcase(preferred_dm_origin_domain_for_user(local_user)) + end + + defp local_dm_origin_domain?(_, _), do: false + + defp normalize_remote_actor_payload(actor, remote_handle) when is_map(actor) do + base = + actor + |> Enum.into(%{}, fn + {key, value} when is_atom(key) -> {Atom.to_string(key), value} + pair -> pair + end) + + case base["handle"] || remote_handle do + handle when is_binary(handle) -> Map.put(base, "handle", handle) + _ -> base + end + end + + defp normalize_remote_actor_payload(_actor, remote_handle) when is_binary(remote_handle) do + case DirectMessageState.normalize_remote_dm_handle(remote_handle) do + {:ok, recipient} -> DirectMessageState.dm_actor_payload(recipient) + _ -> %{"handle" => remote_handle} + end + end + + defp normalize_remote_actor_payload(_actor, _remote_handle), do: %{} + + defp target_domains_for_invite_target( + %ChatConversation{} = conversation, + target_payload, + context + ) + when is_map(target_payload) and is_map(context) do + Visibility.target_domains_for_invite(conversation, target_payload) + end + + defp target_domains_for_invite_target( + %ChatConversation{} = conversation, + _target_payload, + context + ), + do: target_domains_for_conversation(conversation, context) + + defp active_membership_joined_at(conversation_id, user_id) + when is_integer(conversation_id) and is_integer(user_id) do + active_joined_at = + from(cm in ChatConversationMember, + where: + cm.conversation_id == ^conversation_id and cm.user_id == ^user_id and is_nil(cm.left_at), + limit: 1, + select: cm.joined_at + ) + |> Repo.one() + + case active_joined_at do + %DateTime{} = joined_at -> + joined_at + + _ -> + from(cm in ChatConversationMember, + where: cm.conversation_id == ^conversation_id and cm.user_id == ^user_id, + limit: 1, + select: cm.joined_at + ) + |> Repo.one() + end + end + + defp active_membership_joined_at(_conversation_id, _user_id), do: nil + + defp shared_governance_extension_event_types do + [ + "urn:arblarg:ext:roles:1#role.upsert", + "urn:arblarg:ext:roles:1#role.assignment.upsert", + "urn:arblarg:ext:permissions:1#overwrite.upsert", + "urn:arblarg:ext:threads:1#thread.upsert", + "urn:arblarg:ext:threads:1#thread.archive", + "urn:arblarg:ext:pins:1#pin.upsert", + "urn:arblarg:ext:moderation:1#action.recorded" + ] + end + + defp extension_payload_for_event_type(event_type, payload, server, conversation, actor_user) + when is_binary(event_type) and is_map(payload) do + base_payload = + payload + |> Map.put("server", Map.get(payload, "server", server_payload(server))) + |> Map.put("channel", Map.get(payload, "channel", channel_payload(conversation))) + + case event_type do + event_type + when event_type in [ + "role.upsert", + "role.assignment.upsert", + "permission.overwrite.upsert" + ] -> + Map.put_new(base_payload, "actor", sender_payload(actor_user)) + + "thread.upsert" -> + update_in(base_payload["thread"], fn + %{} = thread -> Map.put_new(thread, "owner", sender_payload(actor_user)) + other -> other + end) + + "thread.archive" -> + Map.put_new(base_payload, "actor", sender_payload(actor_user)) + + "pin.upsert" -> + Map.put_new(base_payload, "actor", sender_payload(actor_user)) + + "moderation.action.recorded" -> + update_in(base_payload["action"], fn + %{} = action -> Map.put_new(action, "actor", sender_payload(actor_user)) + other -> other + end) + + _ -> + base_payload + end + end + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + end + + # Messages hydrated for PubSub carry a plain-map sender (webhook/remote + # display metadata), which Repo.preload cannot handle. Reset it so preload + # reloads the real association; the display metadata still reaches the + # payload via message_sender_payload/1 (webhook_sender/remote_sender in + # media_metadata). + defp reset_hydrated_sender(%ChatMessage{sender: sender} = message) + when is_map(sender) and not is_struct(sender) do + %{ + message + | sender: %Ecto.Association.NotLoaded{ + __field__: :sender, + __owner__: ChatMessage, + __cardinality__: :one + } + } + end + + defp reset_hydrated_sender(message), do: message +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/config.ex b/apps/elektrine/lib/elektrine/messaging/federation/config.ex new file mode 100644 index 0000000..964a07f --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/config.ex @@ -0,0 +1,815 @@ +defmodule Elektrine.Messaging.Federation.Config do + @moduledoc false + + alias Elektrine.Domains + alias Elektrine.Messaging.ArblargSDK + alias Elektrine.Security.URLValidator + + def delivery_concurrency(config) when is_list(config) do + Keyword.get(config, :delivery_concurrency, 6) + end + + def outbound_events_url(peer) do + peer.event_endpoint || "#{peer.base_url}/_arblarg/events" + end + + def outbound_events_batch_url(peer) do + peer.events_batch_endpoint || "#{peer.base_url}/_arblarg/events/batch" + end + + def outbound_ephemeral_url(peer) do + peer.ephemeral_endpoint || "#{peer.base_url}/_arblarg/ephemeral" + end + + def outbound_sync_url(peer) do + peer.sync_endpoint || "#{peer.base_url}/_arblarg/sync" + end + + def outbound_stream_events_url(peer, query_string \\ "") do + base = peer.stream_events_endpoint || "#{peer.base_url}/_arblarg/streams/events" + + case normalize_optional_string(query_string) do + nil -> base + query -> base <> "?" <> query + end + end + + def outbound_session_websocket_url(peer) do + allow_insecure_transport = + Application.get_env(:elektrine, :messaging_federation, []) + |> allow_insecure_transport?() + + outbound_session_websocket_url(peer, allow_insecure_transport) + end + + def outbound_session_websocket_url(peer, allow_insecure_transport) + when is_boolean(allow_insecure_transport) do + explicit_endpoint = + peer + |> value_from(:session_websocket_endpoint) + |> normalize_optional_string() + + cond do + valid_session_websocket_url?(explicit_endpoint, allow_insecure_transport) -> + explicit_endpoint + + is_binary(normalize_optional_string(value_from(peer, :base_url))) -> + case peer + |> value_from(:base_url) + |> normalize_optional_string() + |> websocket_base_url(allow_insecure_transport) do + base when is_binary(base) -> + url = base <> "/_arblarg/session" + if valid_session_websocket_url?(url, allow_insecure_transport), do: url + + _ -> + nil + end + + true -> + nil + end + end + + def outbound_snapshot_url(peer, remote_server_id) do + case peer.snapshot_endpoint_template do + template when is_binary(template) -> + if String.contains?(template, "{server_id}") do + String.replace(template, "{server_id}", Integer.to_string(remote_server_id)) + else + template + end + + _ -> + "#{peer.base_url}/_arblarg/servers/#{remote_server_id}/snapshot" + end + end + + def delivery_timeout_ms(config) when is_list(config) do + Keyword.get(config, :delivery_timeout_ms, 12_000) + end + + def outbox_max_attempts(config) when is_list(config) do + Keyword.get(config, :outbox_max_attempts, 8) + end + + def delivery_batch_size(config) when is_list(config) do + Keyword.get(config, :delivery_batch_size, 32) + end + + def outbox_base_backoff_seconds(config) when is_list(config) do + Keyword.get(config, :outbox_base_backoff_seconds, 5) + end + + def outbox_backoff_seconds(config, attempt_count) when is_integer(attempt_count) do + base = outbox_base_backoff_seconds(config) + trunc(min(base * :math.pow(2, max(attempt_count - 1, 0)), 900)) + end + + def outbox_partition_month(%DateTime{} = datetime) do + Date.new!(datetime.year, datetime.month, 1) + end + + def event_retention_days(config) when is_list(config) do + Keyword.get(config, :event_retention_days, 14) + end + + def outbox_retention_days(config) when is_list(config) do + Keyword.get(config, :outbox_retention_days, 30) + end + + def replay_nonce_ttl_seconds(config, default_clock_skew_seconds) when is_list(config) do + max(clock_skew_seconds(config, default_clock_skew_seconds) * 2, 600) + end + + def stream_replay_limit(config) when is_list(config) do + Keyword.get(config, :stream_replay_limit, 128) + end + + def incoming_batch_limit(config) when is_list(config) do + Keyword.get(config, :incoming_batch_limit, 128) + end + + def incoming_ephemeral_limit(config) when is_list(config) do + Keyword.get(config, :incoming_ephemeral_limit, 256) + end + + def snapshot_channel_limit(config) when is_list(config) do + Keyword.get(config, :snapshot_channel_limit, 500) + end + + def snapshot_message_limit(config) when is_list(config) do + Keyword.get(config, :snapshot_message_limit, 5_000) + end + + def snapshot_governance_limit(config) when is_list(config) do + Keyword.get(config, :snapshot_governance_limit, 5_000) + end + + def discovery_ttl_seconds(config) when is_list(config) do + Keyword.get(config, :discovery_ttl_seconds, 3_600) + end + + def session_max_inflight_batches(config) when is_list(config) do + Keyword.get(config, :session_max_inflight_batches, 8) + end + + def session_max_inflight_events(config) when is_list(config) do + Keyword.get(config, :session_max_inflight_events, 256) + end + + def discovery_stale_grace_seconds(config) when is_list(config) do + Keyword.get(config, :discovery_stale_grace_seconds, 86_400) + end + + def presence_ttl_seconds(config) when is_list(config) do + Keyword.get(config, :presence_ttl_seconds, 120) + end + + @ingress_rate_limit_defaults %{ + ingress_peer_durable_events_per_minute: 600, + ingress_peer_ephemeral_items_per_minute: 1200, + ingress_peer_sync_requests_per_minute: 10, + ingress_peer_replay_requests_per_minute: 60, + ingress_room_durable_events_per_minute: 240 + } + + def ingress_rate_limit(config, key) when is_list(config) do + default = Map.fetch!(@ingress_rate_limit_defaults, key) + + case Keyword.get(config, key, default) do + value when is_integer(value) and value > 0 -> value + _ -> default + end + end + + def ingress_rate_limit_enabled?(config) when is_list(config) do + Keyword.get(config, :ingress_rate_limit_enabled, true) != false + end + + def ingress_rate_limit_exempt_domains(config) when is_list(config) do + configured = + config + |> Keyword.get(:ingress_rate_limit_exempt_domains, []) + |> Enum.filter(&is_binary/1) + + relay_hosts = + config + |> discovery_official_relays() + |> Enum.map(&URI.parse(&1["url"]).host) + |> Enum.filter(&is_binary/1) + + (configured ++ relay_hosts) |> Enum.map(&String.downcase/1) |> Enum.uniq() + end + + def allow_insecure_transport?(config) when is_list(config) do + Keyword.get(config, :allow_insecure_http_transport, false) + end + + def clock_skew_seconds(config, default_clock_skew_seconds) when is_list(config) do + Keyword.get(config, :clock_skew_seconds, default_clock_skew_seconds) + end + + def local_base_url(config, local_domain) when is_binary(local_domain) do + configured = + config + |> Keyword.get(:base_url) + |> normalize_optional_string() + + configured || infer_local_base_url(local_domain) + end + + def local_identity_key_id(config) when is_list(config) do + config |> Keyword.get(:identity_key_id, "default") |> to_string() + end + + def local_identity_discovery_identity(identity_keys, key_id) when is_binary(key_id) do + keys = + identity_keys + |> Enum.map(fn key -> + %{ + "id" => key.id, + "algorithm" => ArblargSDK.signature_algorithm(), + "public_key" => Base.url_encode64(key.public_key, padding: false) + } + end) + + %{ + "algorithm" => ArblargSDK.signature_algorithm(), + "current_key_id" => key_id, + "keys" => keys + } + end + + def local_identity_keys(config, key_id, local_domain, federation_enabled, prod_environment) + when is_list(config) and is_binary(key_id) and is_binary(local_domain) and + is_boolean(federation_enabled) and is_boolean(prod_environment) do + configured = + config + |> Keyword.get(:identity_keys, []) + |> Enum.map(&normalize_identity_key/1) + |> Enum.reject(&is_nil/1) + + cond do + configured != [] -> + configured + + is_binary(normalize_optional_string(config |> Keyword.get(:identity_shared_secret))) -> + secret = config |> Keyword.get(:identity_shared_secret) + {public_key, private_key} = ArblargSDK.derive_keypair_from_secret(secret) + [%{id: key_id, public_key: public_key, private_key: private_key}] + + true -> + if federation_enabled and prod_environment do + raise ArgumentError, + "messaging federation requires explicit identity keys in production; " <> + "configure :identity_keys or :identity_shared_secret" + end + + {public_key, private_key} = ArblargSDK.derive_keypair_from_secret(local_domain) + [%{id: key_id, public_key: public_key, private_key: private_key}] + end + end + + def official_relay_operator(config) when is_list(config) do + config + |> Keyword.get(:official_relay_operator, "Community-operated") + |> normalize_relay_operator_label() + end + + def discovery_official_relays(config) when is_list(config) do + config + |> Keyword.get(:official_relays, []) + |> Enum.map(&normalize_discovery_relay/1) + |> Enum.reject(&is_nil/1) + end + + def configured_peers(config, allow_insecure_transport) when is_list(config) do + config + |> Keyword.get(:peers, []) + |> Enum.map(&normalize_peer(&1, allow_insecure_transport)) + |> Enum.reject(&is_nil/1) + end + + def normalize_peer_config(peer, allow_insecure_transport) do + normalize_peer(peer, allow_insecure_transport) + end + + def infer_local_base_url(domain) when is_binary(domain) do + Domains.inferred_base_url_for_domain(domain) + end + + defp normalize_identity_key(key) when is_list(key), do: normalize_identity_key(Map.new(key)) + + defp normalize_identity_key(key) when is_map(key) do + id = normalize_optional_string(value_from(key, :id)) + secret = normalize_optional_string(value_from(key, :secret)) + public_key_encoded = normalize_optional_string(value_from(key, :public_key)) + private_key_encoded = normalize_optional_string(value_from(key, :private_key)) + + cond do + !is_binary(id) -> + nil + + is_binary(secret) -> + {public_key, private_key} = ArblargSDK.derive_keypair_from_secret(secret) + %{id: id, public_key: public_key, private_key: private_key} + + true -> + case decode_or_derive_identity_keys(public_key_encoded, private_key_encoded) do + {:ok, public_key, private_key} -> + %{id: id, public_key: public_key, private_key: private_key} + + _ -> + nil + end + end + end + + defp normalize_identity_key(_), do: nil + + defp decode_or_derive_identity_keys(public_key_encoded, private_key_encoded) do + private_key_result = decode_key_material(private_key_encoded) + public_key_result = decode_key_material(public_key_encoded) + + case {public_key_result, private_key_result} do + {{:ok, public_key}, {:ok, private_key}} -> + {:ok, public_key, private_key} + + {:error, {:ok, private_key}} -> + {public_key, _} = :crypto.generate_key(:eddsa, :ed25519, private_key) + {:ok, public_key, private_key} + + {{:ok, public_key}, :error} -> + {:ok, public_key, nil} + + _ -> + :error + end + end + + defp normalize_discovery_relay(relay) when is_binary(relay) do + url = normalize_optional_string(relay) + + if is_binary(url) do + %{"url" => url} + else + nil + end + end + + defp normalize_discovery_relay(relay) when is_map(relay) do + url = normalize_optional_string(value_from(relay, :url)) + name = normalize_optional_string(value_from(relay, :name)) + websocket_url = normalize_optional_string(value_from(relay, :websocket_url)) + region = normalize_optional_string(value_from(relay, :region)) + + if is_binary(url) do + relay_doc = %{"url" => url} + + relay_doc = + if is_binary(name) do + Map.put(relay_doc, "name", name) + else + relay_doc + end + + relay_doc = + if is_binary(websocket_url) do + Map.put(relay_doc, "websocket_url", websocket_url) + else + relay_doc + end + + if is_binary(region) do + Map.put(relay_doc, "region", region) + else + relay_doc + end + else + nil + end + end + + defp normalize_discovery_relay(_), do: nil + + defp normalize_relay_operator_label(value) do + value + |> to_string() + |> String.trim() + |> case do + "" -> "Community-operated" + label -> label + end + end + + defp normalize_peer(peer, allow_insecure_transport) when is_map(peer) do + domain = value_from(peer, :domain) + base_url = value_from(peer, :base_url) + shared_secret = value_from(peer, :shared_secret) + keys = normalize_peer_keys(value_from(peer, :keys, []), shared_secret) + + compatibility_claims = + normalize_peer_compatibility_claims(value_from(peer, :compatibility_claims, [])) + + extensions = normalize_peer_extensions(value_from(peer, :extensions, [])) + + supported_event_types = + normalize_peer_supported_event_types(value_from(peer, :supported_event_types, [])) + + limits = normalize_peer_limits(value_from(peer, :limits)) + + transport_profiles = + normalize_peer_transport_profiles(value_from(peer, :transport_profiles)) + + features = + value_from(peer, :features) + |> normalize_peer_features() + |> maybe_put_peer_capability("compatibility_claims", compatibility_claims) + |> maybe_put_peer_capability("extensions", extensions) + |> maybe_put_peer_capability("supported_event_types", supported_event_types) + |> maybe_put_peer_capability("limits", limits) + |> maybe_put_peer_capability("transport_profiles", transport_profiles) + + normalized_base_url = + if is_binary(base_url) do + String.trim_trailing(base_url, "/") + else + nil + end + + if !is_binary(domain) or !is_binary(normalized_base_url) or Enum.empty?(keys) or + !valid_peer_base_url?(normalized_base_url, allow_insecure_transport) do + nil + else + %{ + domain: domain, + base_url: normalized_base_url, + shared_secret: shared_secret, + keys: keys, + active_outbound_key_id: resolve_active_outbound_key_id(peer, keys), + allow_incoming: value_from(peer, :allow_incoming, true) == true, + allow_outgoing: value_from(peer, :allow_outgoing, true) == true, + compatibility_claims: compatibility_claims, + extensions: extensions, + supported_event_types: supported_event_types, + features: features, + event_endpoint: normalize_optional_string(value_from(peer, :event_endpoint)), + events_batch_endpoint: + normalize_optional_string(value_from(peer, :events_batch_endpoint)), + ephemeral_endpoint: normalize_optional_string(value_from(peer, :ephemeral_endpoint)), + sync_endpoint: normalize_optional_string(value_from(peer, :sync_endpoint)), + directory_endpoint: normalize_optional_string(value_from(peer, :directory_endpoint)), + stream_events_endpoint: + normalize_optional_string(value_from(peer, :stream_events_endpoint)), + session_websocket_endpoint: + normalize_optional_string(value_from(peer, :session_websocket_endpoint)), + snapshot_endpoint_template: + normalize_optional_string(value_from(peer, :snapshot_endpoint_template)) + } + end + end + + defp normalize_peer(peer, allow_insecure_transport) when is_list(peer) do + normalize_peer(Map.new(peer), allow_insecure_transport) + end + + defp normalize_peer(_, _), do: nil + + defp valid_peer_base_url?(base_url, allow_insecure_transport) when is_binary(base_url) do + case URI.parse(base_url) do + %URI{scheme: "https", host: host} when is_binary(host) and host != "" -> + true + + %URI{scheme: "http", host: host} when is_binary(host) and host != "" -> + allow_insecure_transport == true + + _ -> + false + end + end + + defp normalize_peer_features(features) when is_map(features) do + normalize_peer_capability_document(features) + end + + defp normalize_peer_features(_features), do: %{} + + defp normalize_peer_compatibility_claims(claims) when is_list(claims) do + claims + |> Enum.map(&normalize_optional_string/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp normalize_peer_compatibility_claims(_claims), do: [] + + defp normalize_peer_extensions(extensions) when is_list(extensions) do + extensions + |> Enum.map(&normalize_peer_extension/1) + |> Enum.reject(&is_nil/1) + end + + defp normalize_peer_extensions(_extensions), do: [] + + defp normalize_peer_extension(extension) when is_binary(extension) do + case normalize_optional_string(extension) do + nil -> nil + urn -> %{"urn" => urn} + end + end + + defp normalize_peer_extension(extension) when is_map(extension) do + normalized = normalize_peer_capability_document(extension) + + case normalize_optional_string(value_from(normalized, :urn)) do + nil -> nil + urn -> Map.put(normalized, "urn", urn) + end + end + + defp normalize_peer_extension(_extension), do: nil + + defp normalize_peer_supported_event_types(event_types) when is_list(event_types) do + event_types + |> Enum.map(&normalize_optional_string/1) + |> Enum.reject(&is_nil/1) + |> Enum.map(&ArblargSDK.canonical_event_type/1) + |> Enum.uniq() + end + + defp normalize_peer_supported_event_types(_event_types), do: [] + + defp normalize_peer_limits(limits) when is_map(limits) do + [ + "max_batch_events", + "max_ephemeral_items", + "max_snapshot_channels", + "max_snapshot_messages", + "max_snapshot_governance_entries", + "max_stream_replay_limit", + "max_session_inflight_batches", + "max_session_inflight_events", + "typing_ttl_ms", + "presence_ttl_ms" + ] + |> Enum.reduce(%{}, fn key, acc -> + case capability_value_from(limits, key) do + value when is_integer(value) and value > 0 -> Map.put(acc, key, value) + _ -> acc + end + end) + end + + defp normalize_peer_limits(_limits), do: %{} + + defp normalize_peer_transport_profiles(profiles) when is_map(profiles) do + preferred_order = + profiles + |> value_from(:preferred_order, []) + |> List.wrap() + |> Enum.filter(&is_binary/1) + + fallback_order = + profiles + |> value_from(:fallback_order, []) + |> List.wrap() + |> Enum.filter(&is_binary/1) + + %{} + |> maybe_put_peer_capability( + "preferred_order", + if(preferred_order == [], do: nil, else: preferred_order) + ) + |> maybe_put_peer_capability( + "fallback_order", + if(fallback_order == [], do: nil, else: fallback_order) + ) + |> maybe_put_peer_capability( + "session_websocket", + case value_from(profiles, :session_websocket) do + %{} = session_profile -> normalize_peer_capability_document(session_profile) + _ -> nil + end + ) + end + + defp normalize_peer_transport_profiles(_profiles), do: %{} + + defp maybe_put_peer_capability(map, _key, nil), do: map + defp maybe_put_peer_capability(map, _key, []), do: map + defp maybe_put_peer_capability(map, _key, %{} = value) when map_size(value) == 0, do: map + + defp maybe_put_peer_capability(map, key, value) when is_map(map) and is_binary(key) do + Map.put(map, key, value) + end + + defp normalize_peer_capability_document(value) when is_map(value) do + Enum.reduce(value, %{}, fn {key, nested_value}, acc -> + normalized_key = + case key do + binary when is_binary(binary) -> binary + atom when is_atom(atom) -> Atom.to_string(atom) + other -> to_string(other) + end + + Map.put(acc, normalized_key, normalize_peer_capability_document(nested_value)) + end) + end + + defp normalize_peer_capability_document(value) when is_list(value) do + Enum.map(value, &normalize_peer_capability_document/1) + end + + defp normalize_peer_capability_document(value), do: value + + defp normalize_peer_keys(keys, shared_secret) when is_list(keys) do + normalized = keys |> Enum.map(&normalize_single_peer_key/1) |> Enum.reject(&is_nil/1) + + if Enum.empty?(normalized) and is_binary(shared_secret) do + {public_key, private_key} = ArblargSDK.derive_keypair_from_secret(shared_secret) + + [ + %{ + id: "k1", + secret: shared_secret, + public_key: public_key, + private_key: private_key, + active_outbound: true + } + ] + else + normalized + end + end + + defp normalize_peer_keys(_, shared_secret) when is_binary(shared_secret) do + {public_key, private_key} = ArblargSDK.derive_keypair_from_secret(shared_secret) + + [ + %{ + id: "k1", + secret: shared_secret, + public_key: public_key, + private_key: private_key, + active_outbound: true + } + ] + end + + defp normalize_peer_keys(_, _), do: [] + + defp normalize_single_peer_key(key) when is_list(key) do + normalize_single_peer_key(Map.new(key)) + end + + defp normalize_single_peer_key(key) when is_map(key) do + id = normalize_optional_string(value_from(key, :id)) + secret = normalize_optional_string(value_from(key, :secret)) + public_key = decode_key_material(value_from(key, :public_key)) + private_key = decode_key_material(value_from(key, :private_key)) + + cond do + !is_binary(id) -> + nil + + is_binary(secret) -> + {derived_public, derived_private} = ArblargSDK.derive_keypair_from_secret(secret) + + %{ + id: id, + secret: secret, + public_key: derived_public, + private_key: derived_private, + active_outbound: value_from(key, :active_outbound, false) == true + } + + public_key == :error and private_key == :error -> + nil + + true -> + public_key = + case public_key do + {:ok, decoded_public} -> + decoded_public + + :error -> + case private_key do + {:ok, decoded_private} -> + {derived_public, _} = :crypto.generate_key(:eddsa, :ed25519, decoded_private) + derived_public + + :error -> + nil + end + end + + private_key = if match?({:ok, _}, private_key), do: elem(private_key, 1), else: nil + + %{ + id: id, + secret: nil, + public_key: public_key, + private_key: private_key, + active_outbound: value_from(key, :active_outbound, false) == true + } + end + end + + defp normalize_single_peer_key(_), do: nil + + defp resolve_active_outbound_key_id(peer, keys) do + configured = normalize_optional_string(value_from(peer, :active_outbound_key_id)) + + cond do + is_binary(configured) and + Enum.any?(keys, &(&1.id == configured and is_binary(&1.private_key))) -> + configured + + key = Enum.find(keys, &(&1.active_outbound and is_binary(&1.private_key))) -> + key.id + + key = Enum.find(keys, &is_binary(&1.private_key)) -> + key.id + + true -> + keys |> List.first() |> Map.get(:id) + end + end + + defp decode_key_material(value) when is_binary(value) and byte_size(value) == 32, + do: {:ok, value} + + defp decode_key_material(value) when is_binary(value) do + trimmed = String.trim(value) + + if Elektrine.Strings.present?(trimmed) do + case Base.url_decode64(trimmed, padding: false) do + {:ok, raw} when byte_size(raw) == 32 -> {:ok, raw} + _ -> decode_key_material_standard_base64(trimmed) + end + else + :error + end + end + + defp decode_key_material(_), do: :error + + defp decode_key_material_standard_base64(value) do + case Base.decode64(value) do + {:ok, raw} when byte_size(raw) == 32 -> {:ok, raw} + _ -> :error + end + end + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_), do: nil + + defp valid_session_websocket_url?(url, allow_insecure_transport) + when is_binary(url) and is_boolean(allow_insecure_transport) do + URLValidator.validate_websocket(url, + allow_insecure_transport: allow_insecure_transport, + allow_localhost: Elektrine.RuntimeEnv.dev_or_test?() + ) == :ok + end + + defp valid_session_websocket_url?(_url, _allow_insecure_transport), do: false + + defp websocket_base_url(base_url, allow_insecure_transport) + when is_binary(base_url) and is_boolean(allow_insecure_transport) do + case URI.parse(base_url) do + %URI{scheme: "https"} = uri -> + %{uri | scheme: "wss"} |> URI.to_string() |> String.trim_trailing("/") + + %URI{scheme: "http"} = uri when allow_insecure_transport -> + %{uri | scheme: "ws"} |> URI.to_string() |> String.trim_trailing("/") + + _ -> + nil + end + end + + defp value_from(map, key, default \\ nil) do + Map.get(map, key, Map.get(map, Atom.to_string(key), default)) + end + + defp capability_value_from(map, key, default \\ nil) when is_map(map) and is_binary(key) do + case Map.get(map, key) do + nil -> + map + |> Enum.find(fn + {atom_key, _value} when is_atom(atom_key) -> Atom.to_string(atom_key) == key + _ -> false + end) + |> case do + {_, value} -> value + nil -> default + end + + value -> + value + end + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/contexts.ex b/apps/elektrine/lib/elektrine/messaging/federation/contexts.ex new file mode 100644 index 0000000..389c603 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/contexts.ex @@ -0,0 +1,680 @@ +defmodule Elektrine.Messaging.Federation.Contexts do + @moduledoc false + + alias Elektrine.Messaging.Federation.{ + Actors, + Attachments, + Builders, + Delivery, + DirectMessageState, + Dispatch, + EventTracking, + MirrorBroadcasts, + Mirrors, + Runtime, + State, + Transport + } + + def publisher(options) when is_map(options) do + builder_context = fetch(options, :builder_context) + dispatch_context = fetch(options, :dispatch_context) + state_context = fetch(options, :state_context) + mirror_broadcast_context = fetch(options, :mirror_broadcast_context) + + %{ + enabled?: fetch(options, :enabled?), + build_server_snapshot: fetch(options, :build_server_snapshot), + build_server_snapshot_for_peer: fetch(options, :build_server_snapshot_for_peer), + outgoing_peers: fetch(options, :outgoing_peers), + push_snapshot_to_peer: fetch(options, :push_snapshot_to_peer), + build_server_upsert_event: fn server_id -> + Builders.build_server_upsert_event(server_id, builder_context.()) + end, + build_message_created_event: fn message -> + Builders.build_message_created_event(message, builder_context.()) + end, + build_dm_message_created_event: fn message, remote_handle -> + Builders.build_dm_message_created_event(message, remote_handle, builder_context.()) + end, + build_dm_call_invite_event: fn session_id -> + Builders.build_dm_call_invite_event(session_id, builder_context.()) + end, + build_dm_call_accept_event: fn session_id -> + Builders.build_dm_call_accept_event(session_id, builder_context.()) + end, + build_dm_call_reject_event: fn session_id -> + Builders.build_dm_call_reject_event(session_id, builder_context.()) + end, + build_dm_call_end_event: fn session_id -> + Builders.build_dm_call_end_event(session_id, builder_context.()) + end, + build_dm_call_signal_ephemeral_item: fn session_id, actor_user_id, kind, signal_payload -> + Builders.build_dm_call_signal_ephemeral_item( + session_id, + actor_user_id, + kind, + signal_payload, + builder_context.() + ) + end, + build_message_updated_event: fn message -> + Builders.build_message_updated_event(message, builder_context.()) + end, + build_message_deleted_event: fn message -> + Builders.build_message_deleted_event(message, builder_context.()) + end, + build_reaction_added_event: fn message, reaction -> + Builders.build_reaction_added_event(message, reaction, builder_context.()) + end, + build_reaction_removed_event: fn message, user_id, emoji -> + Builders.build_reaction_removed_event(message, user_id, emoji, builder_context.()) + end, + build_read_cursor_event: fn conversation_id, user_id, message_id, read_at -> + Builders.build_read_cursor_event( + conversation_id, + user_id, + message_id, + read_at, + builder_context.() + ) + end, + build_typing_ephemeral_item: fn conversation_id, user_id, mode -> + Builders.build_typing_ephemeral_item(conversation_id, user_id, mode, builder_context.()) + end, + build_presence_ephemeral_item: fn user_id, status, activities -> + Builders.build_presence_ephemeral_item( + user_id, + status, + activities, + builder_context.() + ) + end, + build_extension_event: fn conversation_id, actor_user_id, event_type, payload -> + Builders.build_extension_event( + conversation_id, + actor_user_id, + event_type, + payload, + builder_context.() + ) + end, + build_membership_upsert_event: fn conversation_id, user_id, state, role -> + Builders.build_membership_upsert_event( + conversation_id, + user_id, + state, + role, + builder_context.() + ) + end, + build_invite_upsert_event: fn conversation_id, + target_user_id, + actor_user_id, + state, + role, + metadata -> + Builders.build_invite_upsert_event( + conversation_id, + target_user_id, + actor_user_id, + state, + role, + metadata, + builder_context.() + ) + end, + build_invite_upsert_event_for_target_payload: fn conversation_id, + target_payload, + actor_user_id, + state, + role, + metadata -> + Builders.build_invite_upsert_event_for_target_payload( + conversation_id, + target_payload, + actor_user_id, + state, + role, + metadata, + builder_context.() + ) + end, + build_ban_upsert_event: fn conversation_id, + target_user_id, + actor_user_id, + state, + reason, + expires_at, + metadata -> + Builders.build_ban_upsert_event( + conversation_id, + target_user_id, + actor_user_id, + state, + reason, + expires_at, + metadata, + builder_context.() + ) + end, + enqueue_outbox_event: fn event, target_domains -> + Dispatch.enqueue_outbox_event(event, target_domains, dispatch_context.()) + end, + resolve_outbound_dm_handle: &DirectMessageState.resolve_outbound_dm_handle/2, + normalize_remote_dm_handle: &DirectMessageState.normalize_remote_dm_handle/1, + outgoing_peer: fetch(options, :outgoing_peer), + fanout_ephemeral_batch: fn items, domains -> + Dispatch.fanout_ephemeral_batch(items, domains, dispatch_context.()) + end, + build_room_presence_ephemeral_item: fn conversation_id, user_id, status, activities -> + Builders.build_room_presence_ephemeral_item( + conversation_id, + user_id, + status, + activities, + builder_context.() + ) + end, + presence_subscriber_domains_for_local_user: + &State.local_presence_subscriber_domains_for_user/1, + active_server_ids_for_user: &Builders.active_server_ids_for_user/1, + persist_local_invite_projection: fn conversation_id, + target_user_id, + actor_user_id, + state, + role, + metadata -> + State.persist_local_invite_projection( + conversation_id, + target_user_id, + actor_user_id, + state, + role, + metadata, + state_context.() + ) + end, + persist_local_extension_projection: fn conversation_id, event_type, payload -> + State.persist_local_extension_projection( + conversation_id, + event_type, + payload, + state_context.() + ) + end, + publish_latest_message_event: fn conversation_id -> + MirrorBroadcasts.publish_latest_message_event( + conversation_id, + mirror_broadcast_context.() + ) + end, + get_chat_message: fetch(options, :get_chat_message) + } + end + + def builder(options) when is_map(options) do + %{ + local_domain: &Runtime.local_domain/0, + local_event_signing_material: &Runtime.local_event_signing_material/0, + outgoing_peers: fetch(options, :outgoing_peers), + maybe_iso8601: fetch(options, :maybe_iso8601), + normalize_optional_string: fetch(options, :normalize_optional_string), + presence_ttl_seconds: &Runtime.presence_ttl_seconds/0 + } + end + + def delivery(options) when is_map(options) do + builder_context = fetch(options, :builder_context) + + %{ + successful_delivery_statuses: fetch(options, :successful_delivery_statuses), + outgoing_peer: fetch(options, :outgoing_peer), + peer_batch_limit: fn peer -> + Transport.peer_batch_limit(peer, Runtime.delivery_batch_size()) + end, + delivery_batch_size: &Runtime.delivery_batch_size/0, + outbox_backoff_seconds: &Runtime.outbox_backoff_seconds/1, + normalize_optional_string: fetch(options, :normalize_optional_string), + transport_profiles_document: fetch(options, :transport_profiles_document), + peer_supports: &Transport.peer_supports?/3, + peer_configured: fn peer -> + Map.get(peer, :discovered) != true and Map.get(peer, "discovered") != true + end, + outbound_session_websocket_url: &Runtime.outbound_session_websocket_url/1, + outbound_events_batch_url: &Runtime.outbound_events_batch_url/1, + outbound_ephemeral_url: &Runtime.outbound_ephemeral_url/1, + outbound_events_url: &Runtime.outbound_events_url/1, + signed_headers: fetch(options, :signed_headers), + delivery_timeout_ms: &Runtime.delivery_timeout_ms/0, + truncate: fetch(options, :truncate), + delivery_concurrency: &Runtime.delivery_concurrency/0, + ephemeral_stream_id: &Dispatch.ephemeral_stream_id/2, + next_outbound_sequence: fetch(options, :next_outbound_sequence), + event_envelope: fn event_type, stream_id, sequence, data -> + Builders.event_envelope(event_type, stream_id, sequence, data, builder_context.()) + end + } + end + + def inbound(options) when is_map(options) do + %{ + incoming_batch_limit: &Runtime.incoming_batch_limit/0, + incoming_ephemeral_limit: &Runtime.incoming_ephemeral_limit/0, + normalize_optional_string: fetch(options, :normalize_optional_string), + parse_int: fetch(options, :parse_int), + receive_event: fetch(options, :receive_event), + recover_sequence_gap: fetch(options, :recover_sequence_gap), + error_code: fetch(options, :error_code), + validate_origin_bound_actors_in_event_data: + fetch(options, :validate_origin_bound_actors_in_event_data), + validate_origin_owned_identifiers_in_event_data: + fetch(options, :validate_origin_owned_identifiers_in_event_data), + apply_event: fetch(options, :apply_event) + } + end + + def validation(options) when is_map(options) do + %{ + normalize_incoming_event_payload: fetch(options, :normalize_incoming_event_payload), + incoming_peer: fetch(options, :incoming_peer), + incoming_verification_materials_for_key_id: + fetch(options, :incoming_verification_materials_for_key_id), + event_server_id: fetch(options, :event_server_id), + event_channel_id: fetch(options, :event_channel_id), + snapshot_governance_entries: fetch(options, :snapshot_governance_entries), + snapshot_channel_limit: &Runtime.snapshot_channel_limit/0, + snapshot_message_limit: &Runtime.snapshot_message_limit/0, + snapshot_governance_limit: &Runtime.snapshot_governance_limit/0, + snapshot_signature_payload: fetch(options, :snapshot_signature_payload) + } + end + + def snapshot(options) when is_map(options) do + %{ + parse_int: fetch(options, :parse_int), + snapshot_channel_limit: &Runtime.snapshot_channel_limit/0, + snapshot_message_limit: &Runtime.snapshot_message_limit/0, + snapshot_governance_limit: &Runtime.snapshot_governance_limit/0, + channel_payload: fetch(options, :channel_payload), + message_payload: fetch(options, :message_payload), + local_domain: &Runtime.local_domain/0, + server_payload: fetch(options, :server_payload), + event_refs_payload: fetch(options, :event_refs_payload), + sender_payload: fetch(options, :sender_payload), + format_created_at: fetch(options, :format_created_at), + maybe_iso8601: fetch(options, :maybe_iso8601), + normalize_optional_string: fetch(options, :normalize_optional_string), + local_event_signing_material: &Runtime.local_event_signing_material/0, + validate_snapshot_payload: fetch(options, :validate_snapshot_payload), + upsert_mirror_server: &Mirrors.upsert_mirror_server/2, + upsert_mirror_channels: fn server, channels -> + Mirrors.upsert_mirror_channels(server, channels, mirror_data()) + end, + upsert_mirror_messages: fn channel_map, messages, remote_domain -> + Mirrors.upsert_mirror_messages(channel_map, messages, remote_domain, mirror_data()) + end, + validate_snapshot_governance_payload: fetch(options, :validate_snapshot_governance_payload), + apply_event: fetch(options, :apply_event), + store_stream_position: &EventTracking.store_stream_position/3, + outbound_sync_url: &Runtime.outbound_sync_url/1, + signed_headers: fetch(options, :signed_headers), + delivery_timeout_ms: &Runtime.delivery_timeout_ms/0, + truncate: fetch(options, :truncate), + peer_supports: &Transport.peer_supports?/3, + peer_supports_event_type: &Transport.peer_supports_event_type?/2, + outbound_session_websocket_url: &Runtime.outbound_session_websocket_url/1, + outbound_snapshot_url: &Runtime.outbound_snapshot_url/2, + infer_remote_server_id_from_federation_id: + fetch(options, :infer_remote_server_id_from_federation_id), + outgoing_peer: fetch(options, :outgoing_peer), + incoming_peer: fetch(options, :incoming_peer), + infer_remote_server_id: fetch(options, :infer_remote_server_id), + receive_event: fetch(options, :receive_event), + stream_replay_limit: &Runtime.stream_replay_limit/0, + outbound_stream_events_url: &Runtime.outbound_stream_events_url/2, + server_stream_id: fetch(options, :server_stream_id), + channel_stream_id: fetch(options, :channel_stream_id) + } + end + + def mirror_data do + %{ + normalize_message_attachments: &Attachments.normalize_message_attachments/1, + attachment_storage_metadata: &Attachments.attachment_storage_metadata/1 + } + end + + def actor(options) when is_map(options) do + %{ + resolve_peer: fetch(options, :resolve_peer), + incoming_verification_materials_for_key_id: + fetch(options, :incoming_verification_materials_for_key_id) + } + end + + def direct_message_state do + %{ + local_domain: &Runtime.local_domain/0, + normalize_message_attachments: &Attachments.normalize_message_attachments/1, + attachment_storage_metadata: &Attachments.attachment_storage_metadata/1, + broadcast_conversation_event: &MirrorBroadcasts.broadcast_conversation_event/2 + } + end + + def state do + %{ + broadcast_conversation_event: &MirrorBroadcasts.broadcast_conversation_event/2, + maybe_broadcast_mirror_message_created: + &MirrorBroadcasts.maybe_broadcast_mirror_message_created/1, + maybe_broadcast_mirror_message_updated: + &MirrorBroadcasts.maybe_broadcast_mirror_message_updated/1, + local_domain: &Runtime.local_domain/0, + presence_ttl_seconds: &Runtime.presence_ttl_seconds/0 + } + end + + def discovery(options) when is_map(options) do + %{ + local_domain: &Runtime.local_domain/0, + peers: fetch(options, :peers), + federation_config: &Runtime.federation_config/0, + delivery_timeout_ms: &Runtime.delivery_timeout_ms/0, + allow_insecure_transport?: &Runtime.allow_insecure_transport?/0, + discovery_ttl_seconds: &Runtime.discovery_ttl_seconds/0, + discovery_stale_grace_seconds: &Runtime.discovery_stale_grace_seconds/0, + truncate: fetch(options, :truncate), + local_event_signing_material: &Runtime.local_event_signing_material/0 + } + end + + def auth(options) when is_map(options) do + %{ + local_domain: &Runtime.local_domain/0, + local_event_signing_material: &Runtime.local_event_signing_material/0, + replay_nonce_ttl_seconds: &Runtime.replay_nonce_ttl_seconds/0, + normalize_optional_string: fetch(options, :normalize_optional_string), + parse_int: fetch(options, :parse_int), + discover_peer_force: fetch(options, :discover_peer_force) + } + end + + def dispatch(options) when is_map(options) do + delivery_context = fetch(options, :delivery_context) + + %{ + outgoing_peers: fetch(options, :outgoing_peers), + outgoing_peer: fetch(options, :outgoing_peer), + outbox_max_attempts: &Runtime.outbox_max_attempts/0, + outbox_partition_month: &Runtime.outbox_partition_month/1, + peer_ephemeral_limit: fn peer -> + Transport.peer_ephemeral_limit(peer, Runtime.incoming_ephemeral_limit()) + end, + push_ephemeral_batch_to_peer: fn peer, items -> + Delivery.push_ephemeral_batch_to_peer(peer, items, delivery_context.()) + end + } + end + + def mirror_broadcast(options) when is_map(options) do + %{ + publish_message_created: fetch(options, :publish_message_created) + } + end + + def direct_message do + %{ + resolve_local_dm_recipient: fn recipient_payload -> + DirectMessageState.resolve_local_dm_recipient(recipient_payload, direct_message_state()) + end, + resolve_local_dm_participant: + &Elektrine.Messaging.Federation.VoiceCalls.resolve_local_dm_participant/1, + resolve_remote_dm_sender: &DirectMessageState.resolve_remote_dm_sender/2, + ensure_remote_dm_conversation: &DirectMessageState.ensure_remote_dm_conversation/2, + upsert_remote_dm_message: fn conversation, message_payload, remote_domain, remote_sender -> + DirectMessageState.upsert_remote_dm_message( + conversation, + message_payload, + remote_domain, + remote_sender, + direct_message_state() + ) + end, + maybe_broadcast_remote_dm_message_created: fn conversation, + message, + local_user, + remote_sender -> + DirectMessageState.maybe_broadcast_remote_dm_message_created( + conversation, + message, + local_user, + remote_sender, + direct_message_state() + ) + end, + ensure_inbound_call_session: + &Elektrine.Messaging.Federation.VoiceCalls.ensure_inbound_session/5, + reject_inbound_call_invite: + &Elektrine.Messaging.Federation.VoiceCalls.reject_inbound_invite/6, + apply_remote_call_accept: &Elektrine.Messaging.Federation.VoiceCalls.apply_remote_accept/5, + apply_remote_call_reject: &Elektrine.Messaging.Federation.VoiceCalls.apply_remote_reject/6, + apply_remote_call_end: &Elektrine.Messaging.Federation.VoiceCalls.apply_remote_end/6, + apply_remote_call_signal: &Elektrine.Messaging.Federation.VoiceCalls.apply_remote_signal/5 + } + end + + def mirror_event(options) when is_map(options) do + actor_context = fetch(options, :actor_context) + + %{ + upsert_mirror_server: &Mirrors.upsert_mirror_server/2, + upsert_mirror_channels: fn server, channels -> + Mirrors.upsert_mirror_channels(server, channels, mirror_data()) + end, + ensure_channel_event_context: fn data, remote_domain -> + Mirrors.ensure_channel_event_context(data, remote_domain, mirror_data()) + end, + ensure_authoritative_channel_event_context: fn data, remote_domain -> + Mirrors.ensure_authoritative_channel_event_context(data, remote_domain, mirror_data()) + end, + resolve_channel_event_context: fn data, remote_domain -> + Mirrors.resolve_channel_event_context(data, remote_domain, mirror_data()) + end, + ensure_server_event_context: fn data, remote_domain -> + Mirrors.ensure_server_event_context(data, remote_domain, mirror_data()) + end, + upsert_mirror_message: fn channel, payload, remote_domain -> + Mirrors.upsert_mirror_message(channel, payload, remote_domain, mirror_data()) + end, + upsert_or_update_mirror_message: fn channel, payload, remote_domain -> + Mirrors.upsert_or_update_mirror_message(channel, payload, remote_domain, mirror_data()) + end, + soft_delete_mirror_message: &Mirrors.soft_delete_mirror_message/4, + maybe_broadcast_mirror_message_created: + &MirrorBroadcasts.maybe_broadcast_mirror_message_created/1, + maybe_broadcast_mirror_message_updated: + &MirrorBroadcasts.maybe_broadcast_mirror_message_updated/1, + maybe_broadcast_mirror_message_deleted: + &MirrorBroadcasts.maybe_broadcast_mirror_message_deleted/1, + get_mirror_message: &Mirrors.get_mirror_message/2, + ensure_remote_actor_membership: &Mirrors.ensure_remote_actor_membership/4, + ensure_remote_actor_governance_permission: + &Mirrors.ensure_remote_actor_governance_permission/4, + ensure_remote_message_author: &Mirrors.ensure_remote_message_author/3, + resolve_or_create_remote_actor_id: fn actor_payload, remote_domain -> + Actors.resolve_or_create_remote_actor_id(actor_payload, remote_domain, actor_context.()) + end, + add_mirror_reaction: &MirrorBroadcasts.add_mirror_reaction/3, + remove_mirror_reaction: &MirrorBroadcasts.remove_mirror_reaction/3, + maybe_broadcast_mirror_reaction_added: + &MirrorBroadcasts.maybe_broadcast_mirror_reaction_added/2, + maybe_broadcast_mirror_reaction_removed: + &MirrorBroadcasts.maybe_broadcast_mirror_reaction_removed/4, + parse_datetime: fetch(options, :parse_datetime), + parse_int: fetch(options, :parse_int), + upsert_remote_read_cursor: fn conversation_id, + chat_message_id, + remote_actor_id, + remote_domain, + read_at, + read_through_sequence -> + State.upsert_remote_read_cursor( + conversation_id, + chat_message_id, + remote_actor_id, + remote_domain, + read_at, + read_through_sequence, + state() + ) + end, + maybe_broadcast_remote_read_cursor: fn conversation_id, chat_message_id, remote_actor_id -> + State.maybe_broadcast_remote_read_cursor( + conversation_id, + chat_message_id, + remote_actor_id, + state() + ) + end, + upsert_membership_state: &State.upsert_membership_state/8, + upsert_invite_state: &State.upsert_invite_state/9, + invite_membership_state: &State.invite_membership_state/1, + ban_membership_state: &State.ban_membership_state/1, + maybe_broadcast_membership_state: fn conversation_id, membership_state -> + State.maybe_broadcast_membership_state(conversation_id, membership_state, state()) + end, + event_server_payload: &Mirrors.event_server_payload/1, + normalize_presence_activities: &State.normalize_presence_activities/1, + local_presence_subscriber_user_ids: &State.local_presence_subscriber_user_ids/1, + server_ids_for_remote_actor: &State.server_ids_for_remote_actor/1, + upsert_account_presence_state: fn remote_actor_id, + status, + activities, + updated_at, + remote_domain, + ttl_ms -> + State.upsert_account_presence_state( + remote_actor_id, + status, + activities, + updated_at, + remote_domain, + ttl_ms, + state() + ) + end, + upsert_room_presence_state: fn conversation_id, + remote_actor_id, + status, + activities, + updated_at, + remote_domain, + ttl_ms -> + State.upsert_room_presence_state( + conversation_id, + remote_actor_id, + status, + activities, + updated_at, + remote_domain, + ttl_ms, + state() + ) + end, + maybe_broadcast_presence_update: fn subscriber_user_ids, + server_ids, + remote_actor_id, + status, + activities, + updated_at -> + State.maybe_broadcast_presence_update( + subscriber_user_ids, + server_ids, + remote_actor_id, + status, + activities, + updated_at, + state() + ) + end, + maybe_broadcast_room_presence_update: fn conversation_id, + remote_actor_id, + status, + activities, + updated_at -> + State.maybe_broadcast_room_presence_update( + conversation_id, + remote_actor_id, + status, + activities, + updated_at, + state() + ) + end, + maybe_broadcast_remote_typing_started: fn conversation_id, remote_actor_id -> + State.maybe_broadcast_remote_typing_started(conversation_id, remote_actor_id, state()) + end, + maybe_broadcast_remote_typing_stopped: fn conversation_id, remote_actor_id -> + State.maybe_broadcast_remote_typing_stopped(conversation_id, remote_actor_id, state()) + end + } + end + + def extension_event(options) when is_map(options) do + actor_context = fetch(options, :actor_context) + + %{ + ensure_authoritative_channel_event_context: fn data, remote_domain -> + Mirrors.ensure_authoritative_channel_event_context(data, remote_domain, mirror_data()) + end, + resolve_channel_event_context: fn data, remote_domain -> + Mirrors.resolve_channel_event_context(data, remote_domain, mirror_data()) + end, + ensure_remote_actor_governance_permission: + &Mirrors.ensure_remote_actor_governance_permission/4, + resolve_or_create_remote_actor_id: fn actor_payload, remote_domain -> + Actors.resolve_or_create_remote_actor_id(actor_payload, remote_domain, actor_context.()) + end, + upsert_extension_projection: fn event_type, + event_key, + payload, + remote_domain, + server_id, + conversation_id, + occurred_at, + status -> + State.upsert_extension_projection( + event_type, + event_key, + payload, + remote_domain, + server_id, + conversation_id, + occurred_at, + status + ) + end, + upsert_extension_system_message: fn mirror_channel, + event_type, + event_key, + content, + metadata, + remote_domain -> + State.upsert_extension_system_message( + mirror_channel, + event_type, + event_key, + content, + metadata, + remote_domain, + state() + ) + end, + parse_datetime: fetch(options, :parse_datetime) + } + end + + defp fetch(options, key) do + Map.fetch!(options, key) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/delivery.ex b/apps/elektrine/lib/elektrine/messaging/federation/delivery.ex new file mode 100644 index 0000000..ee77255 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/delivery.ex @@ -0,0 +1,710 @@ +defmodule Elektrine.Messaging.Federation.Delivery do + @moduledoc false + + import Ecto.Query, warn: false + + alias Elektrine.Messaging.ArblargSDK + + alias Elektrine.Messaging.{ + FederationOutboxEvent, + FederationOutboxWorker, + FederationSessionClient + } + + alias Elektrine.HTTP.SafeFetch + alias Elektrine.Messaging.Federation.Transport + alias Elektrine.Repo + + @cbor_batch_content_type "application/arblarg-batch+cbor" + @ephemeral_item_content_type "application/arblarg-ephemeral+cbor" + + def process_outbox_event(outbox_event_id, context) + when is_integer(outbox_event_id) and is_map(context) do + Repo.transaction(fn -> + outbox = + from(o in FederationOutboxEvent, where: o.id == ^outbox_event_id, lock: "FOR UPDATE") + |> Repo.one() + + case outbox do + nil -> + :not_found + + %{status: "delivered"} -> + :already_delivered + + %{status: "failed"} -> + :already_failed + + %{next_retry_at: %DateTime{} = next_retry_at} -> + if DateTime.compare(next_retry_at, DateTime.utc_now()) == :gt do + :not_due + else + do_process_outbox(outbox, context) + end + + _ -> + do_process_outbox(outbox, context) + end + end) + |> case do + {:ok, result} -> result + {:error, reason} -> {:error, reason} + end + end + + def enqueue_due_outbox_events(limit, _context) when is_integer(limit) do + now = DateTime.utc_now() + + outbox_event_ids = + from(o in FederationOutboxEvent, + where: + o.status == "pending" and o.attempt_count < o.max_attempts and o.next_retry_at <= ^now, + order_by: [asc: o.next_retry_at, asc: o.id], + limit: ^limit, + select: o.id + ) + |> Repo.all() + + _ = FederationOutboxWorker.enqueue_many(outbox_event_ids) + length(outbox_event_ids) + end + + def push_event_batch_to_peer(peer, events, context) when is_list(events) and is_map(context) do + peer + |> event_transport_order(context) + |> Enum.reduce_while({:error, :no_compatible_transport}, fn transport, _acc -> + case push_event_batch_via_transport(peer, events, transport, context) do + {:skip, _reason} -> + {:cont, {:error, :no_compatible_transport}} + + {:error, reason} = error -> + if Transport.transport_fallback_reason?(reason) do + {:cont, error} + else + {:halt, error} + end + + result -> + {:halt, result} + end + end) + end + + def push_ephemeral_batch_to_peer(peer, items, context) + when is_list(items) and is_map(context) do + peer + |> ephemeral_transport_order(context) + |> Enum.reduce_while({:error, :no_compatible_transport}, fn transport, _acc -> + case push_ephemeral_batch_via_transport(peer, items, transport, context) do + {:skip, _reason} -> + {:cont, {:error, :no_compatible_transport}} + + {:error, reason} = error -> + if Transport.transport_fallback_reason?(reason) do + {:cont, error} + else + {:halt, error} + end + + result -> + {:halt, result} + end + end) + end + + defp do_process_outbox(outbox, context) do + pending = pending_domains(outbox) + + if pending == [] do + mark_outbox_fully_delivered(outbox) + else + Enum.each(pending, fn domain -> + _ = deliver_outbox_domain_batch(outbox, domain, context) + end) + + case Repo.get(FederationOutboxEvent, outbox.id) do + %FederationOutboxEvent{status: "delivered"} -> :delivered + %FederationOutboxEvent{status: "failed"} -> :failed + %FederationOutboxEvent{} -> :pending_retry + nil -> :not_found + end + end + end + + defp pending_domains(outbox) do + delivered = MapSet.new(outbox.delivered_domains || []) + outbox.target_domains |> Enum.reject(&MapSet.member?(delivered, &1)) + end + + defp mark_outbox_fully_delivered(outbox) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + outbox + |> FederationOutboxEvent.changeset(%{ + status: "delivered", + dispatched_at: now, + delivered_domains: outbox.target_domains, + next_retry_at: now, + last_error: nil + }) + |> Repo.update() + + :delivered + end + + defp deliver_outbox_domain_batch(outbox, domain, context) do + normalized_domain = String.downcase(domain) + + case call(context, :outgoing_peer, [normalized_domain]) do + nil -> + batch_rows = outbox_batch_for_domain(outbox, normalized_domain, context) + + Enum.each( + batch_rows, + &record_outbox_domain_failure(&1, normalized_domain, :unknown_peer, context) + ) + + {:error, :unknown_peer} + + peer -> + batch_rows = outbox_batch_for_domain(outbox, normalized_domain, context) + peer_batch_limit = call(context, :peer_batch_limit, [peer]) + {supported_rows, unsupported_rows} = split_supported_rows(batch_rows, peer) + + Enum.each(unsupported_rows, &record_outbox_domain_success(&1, normalized_domain)) + + supported_rows + |> Enum.chunk_every(peer_batch_limit) + |> Enum.reduce_while(:ok, fn row_chunk, :ok -> + events = Enum.map(row_chunk, & &1.payload) + + case push_event_batch_to_peer(peer, events, context) do + {:ok, results} -> + case apply_outbox_batch_results(row_chunk, normalized_domain, results, context) do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + + {:error, reason} -> + Enum.each( + row_chunk, + &record_outbox_domain_failure(&1, normalized_domain, reason, context) + ) + + {:halt, {:error, reason}} + end + end) + end + end + + defp outbox_batch_for_domain(outbox, domain, context) do + sibling_limit = max(call(context, :delivery_batch_size, []) - 1, 0) + now = DateTime.utc_now() + + siblings = + if sibling_limit > 0 do + from(o in FederationOutboxEvent, + where: + o.id != ^outbox.id and o.status == "pending" and o.attempt_count < o.max_attempts and + o.next_retry_at <= ^now and + fragment("? = ANY(?)", ^domain, o.target_domains) and + not fragment("? = ANY(?)", ^domain, o.delivered_domains), + order_by: [asc: o.id], + limit: ^sibling_limit, + lock: "FOR UPDATE SKIP LOCKED" + ) + |> Repo.all() + else + [] + end + + ([outbox] ++ siblings) + |> Enum.uniq_by(& &1.id) + |> Enum.sort_by(& &1.id) + end + + defp apply_outbox_batch_results(batch_rows, domain, results, context) do + results_by_event_id = + results + |> Enum.filter(&is_map/1) + |> Map.new(fn result -> + {result["event_id"], + call(context, :normalize_optional_string, [result["status"]]) || "missing"} + end) + + failures = + Enum.reduce(batch_rows, [], fn row, acc -> + status = Map.get(results_by_event_id, row.event_id, "missing") + + if Transport.event_result_success?( + %{"status" => status}, + Map.fetch!(context, :successful_delivery_statuses) + ) do + record_outbox_domain_success(row, domain) + acc + else + record_outbox_domain_failure(row, domain, {:remote_status, status}, context) + [{row.event_id, status} | acc] + end + end) + + if failures == [] do + :ok + else + {:error, {:partial_batch_failure, Enum.reverse(failures)}} + end + end + + defp record_outbox_domain_success(outbox, domain) do + case Repo.get(FederationOutboxEvent, outbox.id) do + %FederationOutboxEvent{} = current -> + now = DateTime.utc_now() |> DateTime.truncate(:second) + delivered_domains = Enum.uniq((current.delivered_domains || []) ++ [domain]) + delivered_all? = Enum.sort(delivered_domains) == Enum.sort(current.target_domains || []) + + attrs = + %{ + delivered_domains: delivered_domains, + attempt_count: current.attempt_count + 1, + next_retry_at: now, + last_error: nil + } + |> maybe_put_delivered_status(delivered_all?, now) + + current + |> FederationOutboxEvent.changeset(attrs) + |> Repo.update() + + _ -> + :ok + end + end + + defp record_outbox_domain_failure(outbox, domain, reason, context) do + case Repo.get(FederationOutboxEvent, outbox.id) do + %FederationOutboxEvent{} = current -> + attempt_count = current.attempt_count + 1 + exhausted = attempt_count >= current.max_attempts + backoff_seconds = call(context, :outbox_backoff_seconds, [attempt_count]) + next_retry_at = DateTime.add(DateTime.utc_now(), backoff_seconds, :second) + + current + |> FederationOutboxEvent.changeset(%{ + status: if(exhausted, do: "failed", else: "pending"), + attempt_count: attempt_count, + next_retry_at: next_retry_at, + last_error: merge_outbox_error(current.last_error, domain, reason, context) + }) + |> Repo.update() + + _ -> + :ok + end + end + + defp maybe_put_delivered_status(attrs, true, now) do + Map.merge(attrs, %{status: "delivered", dispatched_at: now}) + end + + defp maybe_put_delivered_status(attrs, false, _now) do + Map.put(attrs, :status, "pending") + end + + defp merge_outbox_error(existing, domain, reason, context) do + latest = "#{domain}: #{inspect(reason)}" + + case call(context, :normalize_optional_string, [existing]) do + nil -> latest + prior -> prior <> "; " <> latest + end + end + + defp event_transport_order(peer, context) when is_map(peer) do + Transport.event_transport_order(peer, call(context, :transport_profiles_document, [])) + end + + defp event_transport_order(_peer, _context), + do: ["events_batch_cbor", "events_batch_json", "events_json"] + + defp ephemeral_transport_order(peer, context) when is_map(peer) do + Transport.ephemeral_transport_order(peer, call(context, :transport_profiles_document, [])) + end + + defp ephemeral_transport_order(_peer, _context), + do: ["events_batch_cbor", "events_batch_json", "events_json"] + + defp split_supported_rows(batch_rows, peer) when is_list(batch_rows) and is_map(peer) do + Enum.split_with(batch_rows, fn row -> + Transport.peer_supports_event_type?( + peer, + row.event_type || get_in(row.payload, ["event_type"]) + ) + end) + end + + defp split_supported_rows(batch_rows, _peer), do: {batch_rows, []} + + defp push_event_batch_via_transport(peer, events, "session_websocket", context) do + if call(context, :peer_supports, [peer, "session_transport", false]) && + is_binary(call(context, :outbound_session_websocket_url, [peer])) do + push_event_batch_over_session(peer, events, context) + else + {:skip, :unsupported_transport_profile} + end + end + + defp push_event_batch_via_transport(peer, events, "events_batch_cbor", context) do + if call(context, :peer_supports, [ + peer, + "binary_event_batches", + call(context, :peer_configured, [peer]) + ]) && + call(context, :peer_supports, [peer, "batched_event_delivery", true]) && + is_binary(call(context, :outbound_events_batch_url, [peer])) do + push_event_batch_request(peer, events, :cbor, context) + else + {:skip, :unsupported_transport_profile} + end + end + + defp push_event_batch_via_transport(peer, events, "events_batch_json", context) do + if call(context, :peer_supports, [peer, "batched_event_delivery", true]) && + is_binary(call(context, :outbound_events_batch_url, [peer])) do + push_event_batch_request(peer, events, :json, context) + else + {:skip, :unsupported_transport_profile} + end + end + + defp push_event_batch_via_transport(peer, events, "events_json", context) do + push_event_individual_to_peer(peer, events, context) + end + + defp push_event_batch_via_transport(_peer, _events, _transport, _context), + do: {:skip, :unsupported_transport_profile} + + defp push_ephemeral_batch_via_transport(peer, items, "session_websocket", context) do + if call(context, :peer_supports, [peer, "session_transport", false]) && + is_binary(call(context, :outbound_session_websocket_url, [peer])) do + push_ephemeral_batch_over_session(peer, items, context) + else + {:skip, :unsupported_transport_profile} + end + end + + defp push_ephemeral_batch_via_transport(peer, items, "events_batch_cbor", context) do + if call(context, :peer_supports, [ + peer, + "ephemeral_lane", + call(context, :peer_configured, [peer]) + ]) && + call(context, :peer_supports, [ + peer, + "binary_event_batches", + call(context, :peer_configured, [peer]) + ]) && + is_binary(call(context, :outbound_ephemeral_url, [peer])) do + push_ephemeral_batch_request(peer, items, :cbor, context) + else + {:skip, :unsupported_transport_profile} + end + end + + defp push_ephemeral_batch_via_transport(peer, items, "events_batch_json", context) do + if call(context, :peer_supports, [ + peer, + "ephemeral_lane", + call(context, :peer_configured, [peer]) + ]) && + is_binary(call(context, :outbound_ephemeral_url, [peer])) do + push_ephemeral_batch_request(peer, items, :json, context) + else + {:skip, :unsupported_transport_profile} + end + end + + defp push_ephemeral_batch_via_transport(peer, items, "events_json", context) do + push_ephemeral_items_as_events(peer, items, context) + end + + defp push_ephemeral_batch_via_transport(_peer, _items, _transport, _context), + do: {:skip, :unsupported_transport_profile} + + defp push_event_batch_request(peer, events, format, context) when format in [:cbor, :json] do + path = "/_arblarg/events/batch" + url = call(context, :outbound_events_batch_url, [peer]) + + payload = %{ + "version" => 1, + "batch_id" => Ecto.UUID.generate(), + "sent_at" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "events" => events + } + + {body, content_type} = + case format do + :cbor -> {CBOR.encode(payload), @cbor_batch_content_type} + :json -> {Jason.encode!(payload), "application/json"} + end + + headers = + call(context, :signed_headers, [peer, "POST", path, "", body]) + |> put_header("content-type", content_type) + |> put_header("accept", content_type) + + request = Finch.build(:post, url, headers, body) + + case SafeFetch.request(request, Elektrine.Finch, + receive_timeout: call(context, :delivery_timeout_ms, []), + pool_timeout: 5_000 + ) do + {:ok, %Finch.Response{status: status, body: response_body}} when status in 200..299 -> + case decode_batch_response(response_body, content_type) do + {:ok, %{"results" => results}} when is_list(results) -> + {:ok, results} + + _ -> + {:ok, Enum.map(events, &%{"event_id" => &1["event_id"], "status" => "applied"})} + end + + {:ok, %Finch.Response{status: status, body: response_body}} -> + {:error, {:http_error, status, call(context, :truncate, [response_body])}} + + {:error, reason} -> + {:error, reason} + end + end + + defp push_event_individual_to_peer(peer, events, context) when is_list(events) do + events + |> Enum.reduce_while({:ok, []}, fn event, {:ok, results} -> + case push_single_event_to_peer(peer, event, context) do + {:ok, result} -> {:cont, {:ok, results ++ [result]}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp push_single_event_to_peer(peer, event, context) when is_map(event) do + path = "/_arblarg/events" + url = call(context, :outbound_events_url, [peer]) + body = Jason.encode!(event) + + headers = + call(context, :signed_headers, [peer, "POST", path, "", body]) + |> put_header("content-type", "application/json") + |> put_header("accept", "application/json") + + request = Finch.build(:post, url, headers, body) + + case SafeFetch.request(request, Elektrine.Finch, + receive_timeout: call(context, :delivery_timeout_ms, []), + pool_timeout: 5_000 + ) do + {:ok, %Finch.Response{status: status, body: response_body}} when status in 200..299 -> + case Jason.decode(response_body) do + {:ok, %{"status" => event_status}} -> + {:ok, %{"event_id" => event["event_id"], "status" => event_status}} + + _ -> + {:ok, %{"event_id" => event["event_id"], "status" => "applied"}} + end + + {:ok, %Finch.Response{status: status, body: response_body}} -> + {:error, {:http_error, status, call(context, :truncate, [response_body])}} + + {:error, reason} -> + {:error, reason} + end + end + + defp push_ephemeral_batch_request(peer, items, format, context) when format in [:cbor, :json] do + path = "/_arblarg/ephemeral" + url = call(context, :outbound_ephemeral_url, [peer]) + + payload = %{ + "version" => 1, + "batch_id" => Ecto.UUID.generate(), + "sent_at" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "items" => items + } + + {body, content_type} = + case format do + :cbor -> {CBOR.encode(payload), @ephemeral_item_content_type} + :json -> {Jason.encode!(payload), "application/json"} + end + + headers = + call(context, :signed_headers, [peer, "POST", path, "", body]) + |> put_header("content-type", content_type) + |> put_header("accept", content_type) + + request = Finch.build(:post, url, headers, body) + + case SafeFetch.request(request, Elektrine.Finch, + receive_timeout: call(context, :delivery_timeout_ms, []), + pool_timeout: 5_000 + ) do + {:ok, %Finch.Response{status: status}} when status in 200..299 -> + :ok + + {:ok, %Finch.Response{status: status, body: response_body}} -> + {:error, {:http_error, status, call(context, :truncate, [response_body])}} + + {:error, reason} -> + {:error, reason} + end + end + + defp push_event_batch_over_session(peer, events, context) when is_list(events) do + grouped_events = + events + |> Enum.group_by(& &1["stream_id"]) + |> Enum.reject(fn {stream_id, stream_events} -> + !is_binary(stream_id) or stream_id == "" or stream_events == [] + end) + + grouped_events + |> Task.async_stream( + fn {stream_id, stream_events} -> + payload = %{ + "version" => 1, + "delivery_id" => Ecto.UUID.generate(), + "stream_id" => stream_id, + "events" => stream_events + } + + case FederationSessionClient.send_delivery(peer, "stream_batch", payload, + timeout: call(context, :delivery_timeout_ms, []) + ) do + {:ok, %{"results" => results}} when is_list(results) -> + {:ok, results} + + {:ok, _payload} -> + {:ok, + Enum.map(stream_events, &%{"event_id" => &1["event_id"], "status" => "applied"})} + + {:error, reason} -> + {:error, reason} + end + end, + timeout: call(context, :delivery_timeout_ms, []) + 1_000, + max_concurrency: + max(min(length(grouped_events), call(context, :delivery_concurrency, [])), 1), + ordered: false + ) + |> Enum.reduce_while({:ok, []}, fn + {:ok, {:ok, results}}, {:ok, acc} -> + {:cont, {:ok, acc ++ results}} + + {:ok, {:error, reason}}, _acc -> + {:halt, {:error, reason}} + + {:exit, reason}, _acc -> + {:halt, {:error, reason}} + end) + end + + defp push_ephemeral_batch_over_session(peer, items, context) when is_list(items) do + payload = %{"version" => 1, "delivery_id" => Ecto.UUID.generate(), "items" => items} + + case FederationSessionClient.send_delivery(peer, "deliver_ephemeral", payload, + timeout: call(context, :delivery_timeout_ms, []) + ) do + {:ok, %{"results" => results}} when is_list(results) -> + if Enum.all?(results, &event_result_success?(&1, context)) do + :ok + else + {:error, :ephemeral_fallback_failed} + end + + {:ok, _payload} -> + :ok + + {:error, reason} -> + {:error, reason} + end + end + + defp push_ephemeral_items_as_events(peer, items, context) when is_list(items) do + with {:ok, events} <- durable_events_from_ephemeral_items(items, context), + {:ok, results} <- push_event_batch_to_peer(peer, events, context), + true <- Enum.all?(results, &event_result_success?(&1, context)) do + :ok + else + false -> {:error, :ephemeral_fallback_failed} + {:error, reason} -> {:error, reason} + end + end + + defp durable_events_from_ephemeral_items(items, context) when is_list(items) do + items + |> Enum.reduce_while({:ok, []}, fn item, {:ok, acc} -> + case durable_event_from_ephemeral_item(item, context) do + {:ok, event} -> {:cont, {:ok, acc ++ [event]}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp durable_event_from_ephemeral_item(item, context) when is_map(item) do + payload = item["payload"] || %{} + event_type = ArblargSDK.canonical_event_type(item["event_type"]) + + with true <- event_type in ["presence.update", "typing.start", "typing.stop"], + stream_id when is_binary(stream_id) <- + call(context, :ephemeral_stream_id, [event_type, payload]) do + sequence = call(context, :next_outbound_sequence, [stream_id]) + {:ok, call(context, :event_envelope, [event_type, stream_id, sequence, payload])} + else + false -> {:error, :unsupported_event_type} + _ -> {:error, :invalid_event_payload} + end + end + + defp durable_event_from_ephemeral_item(_item, _context), do: {:error, :invalid_payload} + + defp event_result_success?(result, context) do + Transport.event_result_success?(result, Map.fetch!(context, :successful_delivery_statuses)) + end + + defp decode_batch_response(body, content_type) + when is_binary(body) and is_binary(content_type) do + case String.downcase(content_type) do + @cbor_batch_content_type -> + decode_cbor(body) + + @ephemeral_item_content_type -> + decode_cbor(body) + + _ -> + Jason.decode(body) + end + end + + defp decode_batch_response(body, _content_type), do: Jason.decode(body) + + defp decode_cbor(body) when is_binary(body) do + case CBOR.decode(body) do + {:ok, decoded, ""} -> {:ok, decoded} + {:ok, _decoded, _rest} -> {:error, :invalid_payload} + {:error, _reason} -> {:error, :invalid_payload} + end + end + + defp put_header(headers, key, value) when is_list(headers) do + normalized_key = String.downcase(key) + + filtered = + Enum.reject(headers, fn {header_key, _} -> String.downcase(header_key) == normalized_key end) + + filtered ++ [{key, value}] + end + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/direct_message_state.ex b/apps/elektrine/lib/elektrine/messaging/federation/direct_message_state.ex new file mode 100644 index 0000000..ab7af6d --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/direct_message_state.ex @@ -0,0 +1,555 @@ +defmodule Elektrine.Messaging.Federation.DirectMessageState do + @moduledoc false + + import Ecto.Query, warn: false + import Elektrine.Messaging.Federation.Utils + + alias Elektrine.Accounts + alias Elektrine.Accounts.User + alias Elektrine.Messaging.{ChatConversation, ChatConversationMember, ChatMessage, ChatMessages} + alias Elektrine.Notifications + alias Elektrine.Profiles + alias Elektrine.Repo + + @remote_dm_source_prefix "arblarg:dm:" + + def resolve_outbound_dm_handle(%ChatMessage{} = message, nil) do + case Repo.get(ChatConversation, message.conversation_id) do + %ChatConversation{} = conversation -> + case remote_dm_handle_from_source(conversation.federated_source) do + handle when is_binary(handle) -> {:ok, handle} + _ -> {:error, :invalid_remote_handle} + end + + nil -> + {:error, :not_found} + end + end + + def resolve_outbound_dm_handle(_message, remote_handle) when is_binary(remote_handle) do + case normalize_remote_dm_handle(remote_handle) do + {:ok, recipient} -> {:ok, recipient.handle} + error -> error + end + end + + def resolve_outbound_dm_handle(_message, _remote_handle), do: {:error, :invalid_remote_handle} + + def resolve_local_dm_recipient(recipient_payload, context) + when is_map(recipient_payload) and is_map(context) do + with {:ok, recipient} <- + normalize_dm_actor_payload(recipient_payload, call(context, :local_domain, [])), + {:ok, %User{} = local_user} <- resolve_local_recipient_user(recipient) do + {:ok, local_user} + else + {:error, :user_not_found} -> {:error, :user_not_found} + _ -> {:error, :invalid_event_payload} + end + end + + def resolve_local_dm_recipient(_recipient_payload, _context), + do: {:error, :invalid_event_payload} + + def resolve_remote_dm_sender(sender_payload, remote_domain) + when is_map(sender_payload) and is_binary(remote_domain) do + normalized_remote_domain = String.downcase(remote_domain) + + with {:ok, sender} <- normalize_dm_actor_payload(sender_payload, normalized_remote_domain), + true <- sender.domain == normalized_remote_domain do + {:ok, sender} + else + false -> {:error, :origin_domain_mismatch} + _ -> {:error, :invalid_event_payload} + end + end + + def resolve_remote_dm_sender(_sender_payload, _remote_domain), + do: {:error, :invalid_event_payload} + + defp resolve_local_recipient_user(%{domain: domain, username: username}) + when is_binary(domain) and is_binary(username) do + normalized_domain = String.downcase(domain) + + if normalized_domain == String.downcase(Elektrine.Messaging.Federation.local_domain()) do + case Accounts.get_user_by_username(username) do + %User{} = user -> {:ok, user} + _ -> {:error, :user_not_found} + end + else + case Profiles.get_verified_custom_domain(normalized_domain) do + %{user: %{username: ^username} = user} -> {:ok, user} + %{domain: ^normalized_domain} -> {:error, :user_not_found} + _ -> {:error, :invalid_event_payload} + end + end + end + + defp resolve_local_recipient_user(_recipient), do: {:error, :invalid_event_payload} + + def ensure_remote_dm_conversation(%User{} = local_user, remote_sender) + when is_map(remote_sender) do + remote_source = remote_dm_source(remote_sender) + remote_sources = remote_dm_source_candidates(remote_sender) + + existing_remote_dm = + from(c in ChatConversation, + join: cm in ChatConversationMember, + on: c.id == cm.conversation_id, + where: + c.type == "dm" and c.federated_source in ^remote_sources and + cm.user_id == ^local_user.id and is_nil(cm.left_at), + limit: 1 + ) + + case Repo.one(existing_remote_dm) do + %ChatConversation{} = conversation -> + maybe_upgrade_remote_dm_source(conversation, remote_source) + + nil -> + case Repo.transaction(fn -> + {:ok, conversation} = + %ChatConversation{} + |> ChatConversation.dm_changeset(%{ + creator_id: local_user.id, + name: "@" <> remote_sender.handle, + avatar_url: remote_sender.avatar_url, + federated_source: remote_source + }) + |> Repo.insert() + + {:ok, _member} = + ChatConversationMember.add_member_changeset( + conversation.id, + local_user.id, + "member" + ) + |> Repo.insert() + + from(c in ChatConversation, where: c.id == ^conversation.id) + |> Repo.update_all(set: [member_count: 1]) + + conversation + end) do + {:ok, conversation} -> + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{local_user.id}", + {:added_to_conversation, %{conversation_id: conversation.id}} + ) + + {:ok, conversation} + + {:error, reason} -> + {:error, reason} + end + end + end + + def ensure_remote_dm_conversation(_local_user, _remote_sender), + do: {:error, :invalid_event_payload} + + def upsert_remote_dm_message( + %ChatConversation{} = conversation, + message_payload, + remote_domain, + remote_sender, + context + ) + when is_map(message_payload) and is_binary(remote_domain) and is_map(remote_sender) and + is_map(context) do + federation_message_id = normalize_optional_string(message_payload["id"]) + attachments = call(context, :normalize_message_attachments, [message_payload]) + media_urls = Enum.map(attachments, & &1["url"]) + content = normalize_optional_string(message_payload["content"]) + client_encrypted_payload = client_encrypted_payload(message_payload) + + cond do + !is_binary(federation_message_id) -> + {:error, :invalid_event_payload} + + !is_binary(content) and media_urls == [] and is_nil(client_encrypted_payload) -> + {:error, :invalid_event_payload} + + true -> + ChatMessages.register_remote_chat_encryption_devices(remote_sender, remote_domain) + + case Repo.get_by(ChatMessage, + conversation_id: conversation.id, + federated_source: federation_message_id + ) do + %ChatMessage{} -> + {:ok, :duplicate} + + nil -> + message_metadata = call(context, :attachment_storage_metadata, [attachments]) + + attrs = %{ + conversation_id: conversation.id, + sender_id: nil, + content: content, + client_encrypted_payload: client_encrypted_payload, + message_type: normalize_message_type(message_payload["message_type"]), + media_urls: media_urls, + media_metadata: + Map.put( + message_metadata, + "remote_sender", + remote_sender_metadata(remote_sender, message_payload["sender"]) + ), + federated_source: federation_message_id, + origin_domain: String.downcase(remote_domain), + is_federated_mirror: true, + edited_at: parse_datetime(message_payload["edited_at"]) + } + + with {:ok, inserted_message} <- + %ChatMessage{} |> ChatMessage.changeset(attrs) |> Repo.insert() do + from(c in ChatConversation, where: c.id == ^conversation.id) + |> Repo.update_all(set: [last_message_at: inserted_message.inserted_at]) + + {:ok, inserted_message} + end + end + end + end + + def upsert_remote_dm_message( + _conversation, + _message_payload, + _remote_domain, + _remote_sender, + _context + ), + do: {:error, :invalid_event_payload} + + defp client_encrypted_payload(message_payload) when is_map(message_payload) do + case message_payload["client_encrypted_payload"] || + get_in(message_payload, ["e2ee", "payload"]) do + payload when is_map(payload) -> payload + _ -> nil + end + end + + def maybe_broadcast_remote_dm_message_created( + _conversation, + :duplicate, + _local_user, + _remote_sender, + _context + ), + do: :ok + + def maybe_broadcast_remote_dm_message_created( + %ChatConversation{} = conversation, + %ChatMessage{} = message, + %User{} = local_user, + remote_sender, + context + ) + when is_map(remote_sender) and is_map(context) do + decrypted = + case ChatMessages.get_message_decrypted(message.id) do + %ChatMessage{} = hydrated -> hydrated + _ -> message + end + + call(context, :broadcast_conversation_event, [conversation.id, {:new_chat_message, decrypted}]) + + Elektrine.AppCache.invalidate_chat_cache(local_user.id) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{local_user.id}", + {:conversation_activity, %{conversation_id: conversation.id}} + ) + + maybe_notify_remote_dm_recipient(local_user, conversation, decrypted, remote_sender) + :ok + end + + def maybe_broadcast_remote_dm_message_created( + _conversation, + _message, + _local_user, + _remote_sender, + _context + ), + do: :ok + + def dm_actor_payload(recipient) when is_map(recipient) do + username = Map.get(recipient, :username) || Map.get(recipient, "username") + domain = Map.get(recipient, :domain) || Map.get(recipient, "domain") + handle = Map.get(recipient, :handle) || Map.get(recipient, "handle") + display_name = Map.get(recipient, :display_name) || Map.get(recipient, "display_name") + avatar_url = Map.get(recipient, :avatar_url) || Map.get(recipient, "avatar_url") + + uri = + Map.get(recipient, :uri) || Map.get(recipient, "uri") || Map.get(recipient, :id) || + Map.get(recipient, "id") || + if(is_binary(username) and is_binary(domain), do: "https://#{domain}/users/#{username}") + + %{ + "id" => uri, + "uri" => uri, + "username" => username, + "display_name" => display_name || username, + "domain" => domain, + "handle" => handle + } + |> maybe_put_optional_map_value("avatar_url", avatar_url) + end + + def normalize_dm_actor_payload(payload, fallback_domain) + when is_map(payload) and is_binary(fallback_domain) do + normalized_fallback_domain = String.downcase(fallback_domain) + raw_uri = normalize_optional_string(payload["uri"] || payload[:uri]) + raw_handle = normalize_optional_string(payload["handle"] || payload[:handle]) + raw_username = normalize_optional_string(payload["username"] || payload[:username]) + raw_domain = normalize_optional_string(payload["domain"] || payload[:domain]) + + normalized_identity = + cond do + is_binary(raw_handle) -> + normalize_remote_dm_handle(raw_handle) + + is_binary(raw_username) -> + domain = raw_domain || normalized_fallback_domain + normalize_remote_dm_handle("#{raw_username}@#{domain}") + + true -> + {:error, :invalid_event_payload} + end + + with {:ok, identity} <- normalized_identity, + true <- valid_absolute_http_uri?(raw_uri) do + {:ok, + %{ + uri: raw_uri, + username: identity.username, + domain: identity.domain, + handle: identity.handle, + display_name: + normalize_optional_string(payload["display_name"] || payload[:display_name]) || + identity.username, + avatar_url: + normalize_optional_string( + payload["avatar_url"] || payload[:avatar_url] || payload["avatar"] || + payload[:avatar] + ), + chat_encryption_devices: + payload["chat_encryption_devices"] || payload[:chat_encryption_devices] || [] + }} + else + false -> + {:error, :invalid_event_payload} + + error -> + error + end + end + + def normalize_dm_actor_payload(_payload, _fallback_domain), + do: {:error, :invalid_event_payload} + + def normalize_remote_dm_handle(handle) when is_binary(handle) do + normalized = + handle + |> String.trim() + |> String.trim_leading("@") + |> String.downcase() + + case Regex.run(~r/^([a-z0-9][a-z0-9_.-]{0,63})@([a-z0-9.-]+\.[a-z]{2,})$/, normalized) do + [_, username, domain] -> + {:ok, %{username: username, domain: domain, handle: "#{username}@#{domain}"}} + + _ -> + {:error, :invalid_remote_handle} + end + end + + def normalize_remote_dm_handle(_handle), do: {:error, :invalid_remote_handle} + + def remote_dm_handle_from_source(source) when is_binary(source) do + with remote_handle when is_binary(remote_handle) <- remote_dm_source_value(source), + {:ok, recipient} <- normalize_remote_dm_handle(remote_handle) do + recipient.handle + else + _ -> nil + end + end + + def remote_dm_handle_from_source(_source), do: nil + + def remote_dm_source(remote_sender) when is_map(remote_sender) do + uri = Map.get(remote_sender, :uri) || Map.get(remote_sender, "uri") + handle = Map.get(remote_sender, :handle) || Map.get(remote_sender, "handle") + + if is_binary(uri) and is_binary(handle) do + @remote_dm_source_prefix <> "uri:" <> URI.encode_www_form(uri) <> "|handle:" <> handle + else + remote_dm_source(handle) + end + end + + def remote_dm_source(handle) when is_binary(handle) do + @remote_dm_source_prefix <> "handle:" <> handle + end + + def remote_dm_source(_handle), do: nil + + defp remote_dm_source_candidates(remote_sender) when is_map(remote_sender) do + handle = Map.get(remote_sender, :handle) || Map.get(remote_sender, "handle") + + [ + remote_dm_source(remote_sender), + remote_dm_source(handle), + if(is_binary(handle), do: @remote_dm_source_prefix <> handle) + ] + |> Enum.filter(&is_binary/1) + |> Enum.uniq() + end + + defp remote_dm_source_value(source) do + cond do + !String.starts_with?(source, @remote_dm_source_prefix) -> + nil + + String.starts_with?(source, @remote_dm_source_prefix <> "uri:") -> + source + |> String.split("|handle:", parts: 2) + |> case do + [_uri_source, handle] -> handle + _ -> nil + end + + String.starts_with?(source, @remote_dm_source_prefix <> "handle:") -> + String.replace_prefix(source, @remote_dm_source_prefix <> "handle:", "") + + true -> + String.replace_prefix(source, @remote_dm_source_prefix, "") + end + end + + defp maybe_upgrade_remote_dm_source(%ChatConversation{} = conversation, remote_source) do + if is_binary(remote_source) and conversation.federated_source != remote_source do + case conversation + |> ChatConversation.changeset(%{federated_source: remote_source}) + |> Repo.update() do + {:ok, updated_conversation} -> {:ok, updated_conversation} + {:error, _reason} -> {:ok, conversation} + end + else + {:ok, conversation} + end + end + + defp maybe_notify_remote_dm_recipient( + %User{notify_on_direct_message: false}, + _conversation, + _message, + _remote_sender + ), + do: :ok + + defp maybe_notify_remote_dm_recipient( + %User{} = local_user, + %ChatConversation{} = conversation, + %ChatMessage{} = message, + remote_sender + ) + when is_map(remote_sender) do + title = "Message from @#{remote_sender.handle}" + + _ = + Notifications.create_notification(%{ + user_id: local_user.id, + actor_id: nil, + type: "new_message", + title: title, + body: remote_dm_message_preview(message), + url: Elektrine.Paths.chat_message_path(conversation.hash || conversation.id, message.id), + source_type: "message", + source_id: message.id, + priority: "normal", + metadata: %{"remote_sender" => remote_sender_metadata(remote_sender)} + }) + + :ok + end + + defp maybe_notify_remote_dm_recipient(_local_user, _conversation, _message, _remote_sender), + do: :ok + + defp remote_dm_message_preview(%ChatMessage{} = message) do + cond do + is_binary(normalize_optional_string(message.content)) -> + message.content |> String.trim() |> String.slice(0, 140) + + (message.media_urls || []) != [] -> + "Sent an attachment" + + true -> + "New message" + end + end + + defp remote_sender_metadata(remote_sender, sender_payload \\ nil) + + defp remote_sender_metadata(remote_sender, sender_payload) when is_map(remote_sender) do + base = + case sender_payload do + %{} = payload -> payload + _ -> %{} + end + + base + |> maybe_put_optional_map_value( + "id", + Map.get(remote_sender, :uri) || Map.get(remote_sender, "uri") + ) + |> maybe_put_optional_map_value( + "uri", + Map.get(remote_sender, :uri) || Map.get(remote_sender, "uri") + ) + |> Map.put("username", remote_sender.username) + |> Map.put("display_name", remote_sender.display_name || remote_sender.username) + |> Map.put("domain", remote_sender.domain) + |> Map.put("handle", remote_sender.handle) + |> maybe_put_optional_map_value("avatar_url", remote_sender.avatar_url) + |> maybe_put_optional_map_value("avatar", remote_sender.avatar_url) + end + + defp remote_sender_metadata(_remote_sender, _sender_payload), do: %{} + + defp maybe_put_optional_map_value(map, _key, nil), do: map + + defp maybe_put_optional_map_value(map, key, value) when is_binary(value) do + if Elektrine.Strings.present?(value), do: Map.put(map, key, value), else: map + end + + defp maybe_put_optional_map_value(map, _key, _value), do: map + + defp valid_absolute_http_uri?(value) when is_binary(value) do + case URI.parse(value) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + true + + _ -> + false + end + end + + defp valid_absolute_http_uri?(_value), do: false + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_value), do: nil + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/direct_messages.ex b/apps/elektrine/lib/elektrine/messaging/federation/direct_messages.ex new file mode 100644 index 0000000..cb6da76 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/direct_messages.ex @@ -0,0 +1,173 @@ +defmodule Elektrine.Messaging.Federation.DirectMessages do + @moduledoc false + + def apply_event("dm.message.create", data, remote_domain, context) + when is_map(data) and is_binary(remote_domain) and is_map(context) do + with %{} = dm_payload <- data["dm"], + %{} = message_payload <- data["message"], + {:ok, recipient_user} <- + call(context, :resolve_local_dm_recipient, [dm_payload["recipient"]]), + {:ok, remote_sender} <- + call(context, :resolve_remote_dm_sender, [dm_payload["sender"], remote_domain]), + {:ok, conversation} <- + call(context, :ensure_remote_dm_conversation, [recipient_user, remote_sender]), + {:ok, message_or_duplicate} <- + call(context, :upsert_remote_dm_message, [ + conversation, + message_payload, + remote_domain, + remote_sender + ]), + :ok <- + call(context, :maybe_broadcast_remote_dm_message_created, [ + conversation, + message_or_duplicate, + recipient_user, + remote_sender + ]) do + :ok + else + error -> normalize_event_error(error) + end + end + + def apply_event("dm.call.invite", data, remote_domain, context) + when is_map(data) and is_binary(remote_domain) and is_map(context) do + with %{} = dm_payload <- data["dm"], + %{} = call_payload <- data["call"], + {:ok, recipient_user} <- + call(context, :resolve_local_dm_recipient, [dm_payload["recipient"]]), + {:ok, remote_sender} <- + call(context, :resolve_remote_dm_sender, [dm_payload["sender"], remote_domain]), + {:ok, conversation} <- + call(context, :ensure_remote_dm_conversation, [recipient_user, remote_sender]), + {:ok, _session} <- + call(context, :ensure_inbound_call_session, [ + recipient_user, + conversation, + remote_sender, + call_payload, + remote_domain + ]) do + :ok + else + {:error, :busy} -> + with %{} = dm_payload <- data["dm"], + %{} = call_payload <- data["call"], + {:ok, recipient_user} <- + call(context, :resolve_local_dm_recipient, [dm_payload["recipient"]]), + {:ok, remote_sender} <- + call(context, :resolve_remote_dm_sender, [dm_payload["sender"], remote_domain]), + {:ok, conversation} <- + call(context, :ensure_remote_dm_conversation, [recipient_user, remote_sender]), + {:ok, _session} <- + call(context, :reject_inbound_call_invite, [ + recipient_user, + conversation, + remote_sender, + call_payload, + remote_domain, + "busy" + ]) do + :ok + else + error -> normalize_event_error(error) + end + + error -> + normalize_event_error(error) + end + end + + def apply_event("dm.call.accept", data, remote_domain, context) + when is_map(data) and is_binary(remote_domain) and is_map(context) do + with %{} = dm_payload <- data["dm"], + call_id when is_binary(call_id) <- data["call_id"], + %{} = actor_payload <- data["actor"], + :ok <- + call(context, :apply_remote_call_accept, [ + dm_payload, + call_id, + actor_payload, + data["accepted_at"], + remote_domain + ]) do + :ok + else + error -> normalize_event_error(error) + end + end + + def apply_event("dm.call.reject", data, remote_domain, context) + when is_map(data) and is_binary(remote_domain) and is_map(context) do + with %{} = dm_payload <- data["dm"], + call_id when is_binary(call_id) <- data["call_id"], + %{} = actor_payload <- data["actor"], + :ok <- + call(context, :apply_remote_call_reject, [ + dm_payload, + call_id, + actor_payload, + data["rejected_at"], + data["reason"], + remote_domain + ]) do + :ok + else + error -> normalize_event_error(error) + end + end + + def apply_event("dm.call.end", data, remote_domain, context) + when is_map(data) and is_binary(remote_domain) and is_map(context) do + with %{} = dm_payload <- data["dm"], + call_id when is_binary(call_id) <- data["call_id"], + %{} = actor_payload <- data["actor"], + :ok <- + call(context, :apply_remote_call_end, [ + dm_payload, + call_id, + actor_payload, + data["ended_at"], + data["reason"], + remote_domain + ]) do + :ok + else + error -> normalize_event_error(error) + end + end + + def apply_event("dm.call.signal", data, remote_domain, context) + when is_map(data) and is_binary(remote_domain) and is_map(context) do + with %{} = dm_payload <- data["dm"], + call_id when is_binary(call_id) <- data["call_id"], + %{} = actor_payload <- data["actor"], + %{} = signal <- data["signal"], + :ok <- + call(context, :apply_remote_call_signal, [ + dm_payload, + call_id, + actor_payload, + signal, + remote_domain + ]) do + :ok + else + error -> normalize_event_error(error) + end + end + + def apply_event(_event_type, _data, _remote_domain, _context), + do: {:error, :unhandled_event_type} + + defp normalize_event_error({:error, _reason} = error), do: error + defp normalize_event_error(:ok), do: :ok + defp normalize_event_error(_error), do: {:error, :invalid_event_payload} + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/discovery.ex b/apps/elektrine/lib/elektrine/messaging/federation/discovery.ex new file mode 100644 index 0000000..567fa68 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/discovery.ex @@ -0,0 +1,1608 @@ +defmodule Elektrine.Messaging.Federation.Discovery do + @moduledoc false + + import Ecto.Query, warn: false + + alias Elektrine.HTTP.Backoff + + alias Elektrine.Messaging.ArblargSDK + alias Elektrine.Messaging.FederationDiscoveredPeer + + alias Elektrine.Messaging.Federation.Config + alias Elektrine.Messaging.Federation.PeerPolicies + + alias Elektrine.Repo + alias Elektrine.Security.URLValidator + + @max_discovery_body_bytes 262_144 + + def discover_peer(domain, opts, context) + when is_binary(domain) and is_list(opts) and is_map(context) do + force? = Keyword.get(opts, :force, false) + + case normalize_peer_domain(domain) do + {:ok, normalized_domain} -> + cond do + normalized_domain == call(context, :local_domain, []) -> + {:error, :local_domain} + + peer = maybe_resolve_configured_peer(normalized_domain, context) -> + {:ok, peer} + + peer = cached_discovered_peer(normalized_domain, force?, context) -> + {:ok, peer} + + true -> + fetch_and_cache_peer(normalized_domain, context) + end + + {:error, reason} -> + {:error, reason} + end + end + + def discover_peer(_domain, _opts, _context), do: {:error, :invalid_domain} + + def resolve_peer(domain, context) when is_binary(domain) and is_map(context) do + with {:ok, normalized_domain} <- normalize_peer_domain(domain), + false <- normalized_domain == call(context, :local_domain, []) do + maybe_resolve_configured_peer(normalized_domain, context) || + resolve_discovered_peer(normalized_domain, context) + else + _ -> nil + end + end + + def resolve_peer(_domain, _context), do: nil + + def discovered_peer_controls(context) when is_map(context) do + list_discovered_peer_controls(context) + rescue + _ -> + [] + end + + def list_discovered_peer_controls(context, opts \\ []) when is_map(context) and is_list(opts) do + query = + FederationDiscoveredPeer + |> order_by([peer], asc: peer.domain) + |> maybe_filter_discovered_domains(Keyword.get(opts, :domains)) + |> maybe_filter_discovered_search(Keyword.get(opts, :search)) + + Repo.all(query) + |> Enum.map(&build_discovered_peer_control(&1, context)) + end + + def list_discovered_peer_domains(search_query \\ nil) do + FederationDiscoveredPeer + |> maybe_filter_discovered_search(search_query) + |> select([peer], peer.domain) + |> order_by([peer], asc: peer.domain) + |> Repo.all() + rescue + _ -> + [] + end + + def discovered_peer_state_map(search_query \\ nil) do + FederationDiscoveredPeer + |> maybe_filter_discovered_search(search_query) + |> select([peer], %{ + domain: peer.domain, + trust_state: peer.trust_state + }) + |> Repo.all() + |> Map.new(fn peer -> + allow? = peer.trust_state != "replaced" + + {peer.domain, + %{ + blocked: peer.trust_state == "replaced", + effective_allow_incoming: allow?, + effective_allow_outgoing: allow? + }} + end) + rescue + _ -> + %{} + end + + def sign_discovery_document(document, context) when is_map(document) and is_map(context) do + {key_id, signing_material} = call(context, :local_event_signing_material, []) + + Map.put(document, "signature", %{ + "algorithm" => ArblargSDK.signature_algorithm(), + "key_id" => key_id, + "value" => + document + |> discovery_signature_payload() + |> ArblargSDK.sign_payload(signing_material) + }) + end + + def sign_discovery_document(document, _context), do: document + + def discovery_signature_payload(document) when is_map(document) do + document + |> Map.delete("signature") + |> ArblargSDK.canonical_json_payload() + end + + def discovery_signature_payload(_document), do: "" + + def normalize_peer_domain(domain) when is_binary(domain) do + normalized = + domain + |> String.trim() + |> String.downcase() + |> String.replace(~r/^https?:\/\//, "") + |> String.split("/", parts: 2) + |> List.first() + |> to_string() + |> String.trim(".") + + if Elektrine.Strings.present?(normalized) do + {:ok, normalized} + else + {:error, :invalid_domain} + end + end + + def normalize_peer_domain(_domain), do: {:error, :invalid_domain} + + defp maybe_resolve_configured_peer(domain, context) when is_binary(domain) do + Enum.find(call(context, :peers, []), fn peer -> + String.downcase(peer.domain) == domain + end) + end + + defp maybe_resolve_configured_peer(_domain, _context), do: nil + + defp cached_discovered_peer(_domain, true, _context), do: nil + + defp cached_discovered_peer(domain, false, context) when is_binary(domain) do + case get_discovered_peer_record(domain) do + %FederationDiscoveredPeer{} = record -> + cond do + not discovered_peer_stale?(record, context) -> + build_discovered_peer(record, context) + + discovered_peer_within_grace?(record, context) -> + build_discovered_peer(record, context) + + true -> + nil + end + + _ -> + nil + end + end + + defp cached_discovered_peer(_domain, _force?, _context), do: nil + + defp resolve_discovered_peer(domain, context) when is_binary(domain) and is_map(context) do + case get_discovered_peer_record(domain) do + nil -> + case fetch_and_cache_peer(domain, context) do + {:ok, %{} = peer} -> peer + _ -> nil + end + + %FederationDiscoveredPeer{} = record -> + if discovered_peer_stale?(record, context) do + case fetch_and_cache_peer(domain, context) do + {:ok, %{} = peer} -> + peer + + {:error, reason} -> + _ = record_discovery_failure(domain, reason) + + if discovered_peer_within_grace?(record, context) do + build_discovered_peer(record, context) + else + nil + end + end + else + build_discovered_peer(record, context) + end + end + end + + defp resolve_discovered_peer(_domain, _context), do: nil + + defp fetch_and_cache_peer(domain, context) when is_binary(domain) and is_map(context) do + with {:ok, attrs} <- fetch_remote_discovery_document(domain, context), + {:ok, record} <- upsert_discovered_peer(attrs), + %{} = peer <- build_discovered_peer(record, context) do + {:ok, peer} + else + nil -> + _ = record_discovery_failure(domain, :invalid_discovered_peer) + {:error, :invalid_discovered_peer} + + {:error, reason} -> + _ = record_discovery_failure(domain, reason) + {:error, reason} + + _ -> + _ = record_discovery_failure(domain, :invalid_discovered_peer) + {:error, :invalid_discovered_peer} + end + end + + defp fetch_and_cache_peer(_domain, _context), do: {:error, :invalid_domain} + + defp fetch_remote_discovery_document(domain, context) + when is_binary(domain) and is_map(context) do + urls = discovery_candidate_urls(domain, context) + + case call(context, :federation_config, []) |> Keyword.get(:discovery_fetcher) do + fun when is_function(fun, 2) -> + case fun.(domain, urls) do + {:ok, payload, discovery_url} -> + normalize_discovery_document( + domain, + payload, + discovery_url || List.first(urls), + false, + context + ) + + {:ok, payload} -> + normalize_discovery_document(domain, payload, List.first(urls), false, context) + + {:error, reason} -> + {:error, reason} + + other -> + {:error, {:invalid_discovery_fetcher_response, other}} + end + + _ -> + fetch_remote_discovery_document_over_http(domain, urls, context) + end + end + + defp fetch_remote_discovery_document(_domain, _context), do: {:error, :invalid_domain} + + defp fetch_remote_discovery_document_over_http(_domain, [], _context), + do: {:error, :discovery_unavailable} + + defp fetch_remote_discovery_document_over_http(domain, urls, context) + when is_list(urls) and is_map(context) do + Enum.reduce_while(urls, {:error, :discovery_unavailable}, fn url, _acc -> + case fetch_single_discovery_url(url, context) do + {:ok, payload} -> + case normalize_discovery_document(domain, payload, url, true, context) do + {:ok, attrs} -> {:halt, {:ok, attrs}} + {:error, reason} -> {:cont, {:error, reason}} + end + + {:error, reason} -> + {:cont, {:error, reason}} + end + end) + end + + defp fetch_single_discovery_url(url, context) when is_binary(url) and is_map(context) do + headers = [{"accept", "application/json"}] + + case validate_discovery_url(url, context) do + :ok -> + case Backoff.get(url, headers, + timeout: call(context, :delivery_timeout_ms, []), + recv_timeout: call(context, :delivery_timeout_ms, []) + ) do + {:ok, %Finch.Response{status: status, body: body}} when status in 200..299 -> + cond do + !is_binary(body) -> + {:error, :invalid_discovery_response} + + byte_size(body) > @max_discovery_body_bytes -> + {:error, :discovery_response_too_large} + + true -> + case Jason.decode(body) do + {:ok, payload} -> {:ok, payload} + {:error, reason} -> {:error, {:invalid_json, reason}} + end + end + + {:ok, %Finch.Response{status: status}} when status in [404, 406, 410] -> + {:error, {:http_error, status}} + + {:ok, %Finch.Response{status: status, body: body}} -> + {:error, {:http_error, status, call(context, :truncate, [body])}} + + {:error, reason} -> + {:error, reason} + end + + {:error, reason} -> + {:error, reason} + end + end + + defp fetch_single_discovery_url(_url, _context), do: {:error, :invalid_discovery_url} + + defp normalize_discovery_document(domain, payload, discovery_url, validate_urls?, context) + when is_binary(domain) and is_map(payload) and is_map(context) do + protocol = normalize_optional_string(discovery_field(payload, "protocol")) + protocol_id = normalize_optional_string(discovery_field(payload, "protocol_id")) + + with true <- + protocol_id == ArblargSDK.protocol_id() and protocol == ArblargSDK.protocol_name(), + {:ok, claimed_domain} <- normalize_discovery_claimed_domain(domain, payload), + {:ok, protocol_version} <- normalize_discovery_protocol_version(payload), + %{} = identity <- normalize_discovery_identity(discovery_field(payload, "identity")), + :ok <- verify_discovery_document_signature(payload, identity), + identity_fingerprint when is_binary(identity_fingerprint) <- + discovery_identity_fingerprint(identity), + :ok <- + verify_discovery_bootstrap_trust( + claimed_domain, + identity, + identity_fingerprint, + context + ), + raw_endpoints when is_map(raw_endpoints) <- discovery_field(payload, "endpoints"), + {:ok, endpoints} <- + normalize_discovery_endpoints(raw_endpoints, claimed_domain, validate_urls?, context), + true <- map_size(endpoints) > 0 or {:error, :invalid_discovery_endpoints}, + base_url when is_binary(base_url) <- + extract_discovery_base_url( + endpoints, + discovery_url, + claimed_domain, + validate_urls?, + context + ) do + {:ok, + %{ + domain: claimed_domain, + base_url: base_url, + discovery_url: + normalize_discovery_url( + discovery_url, + validate_urls?, + claimed_domain, + ["http", "https"], + context + ) || + discovery_url, + protocol: ArblargSDK.protocol_name(), + protocol_id: protocol_id || ArblargSDK.protocol_id(), + protocol_version: protocol_version, + trust_state: "trusted", + identity_fingerprint: identity_fingerprint, + previous_identity_fingerprint: nil, + last_key_change_at: nil, + identity: identity, + endpoints: endpoints, + features: + payload + |> normalize_discovery_capabilities() + |> maybe_enrich_discovery_capabilities(claimed_domain, endpoints, context), + last_discovered_at: DateTime.utc_now() |> DateTime.truncate(:second), + last_error: nil + }} + else + false -> {:error, :unsupported_protocol} + {:error, reason} -> {:error, reason} + nil -> {:error, :missing_base_url} + _ -> {:error, :invalid_discovery_document} + end + end + + defp normalize_discovery_document(_domain, _payload, _discovery_url, _validate_urls?, _context) do + {:error, :invalid_discovery_document} + end + + defp normalize_discovery_signature(signature) when is_map(signature) do + algorithm = normalize_optional_string(discovery_field(signature, "algorithm")) + key_id = normalize_optional_string(discovery_field(signature, "key_id")) + value = normalize_optional_string(discovery_field(signature, "value")) + + cond do + algorithm != ArblargSDK.signature_algorithm() -> nil + !is_binary(key_id) -> nil + !is_binary(value) -> nil + true -> %{"algorithm" => algorithm, "key_id" => key_id, "value" => value} + end + end + + defp normalize_discovery_signature(_signature), do: nil + + defp verify_discovery_document_signature(payload, identity) + when is_map(payload) and is_map(identity) do + signature = + payload + |> discovery_field("signature") + |> normalize_discovery_signature() + + with %{} = signature <- signature, + key_id when is_binary(key_id) <- discovery_field(signature, "key_id"), + keys when is_list(keys) <- discovery_field(identity, "keys"), + %{} = key <- Enum.find(keys, &(discovery_field(&1, "id") == key_id)), + public_key when is_binary(public_key) <- discovery_field(key, "public_key"), + true <- + ArblargSDK.verify_payload_signature( + discovery_signature_payload(payload), + public_key, + discovery_field(signature, "value") + ) do + :ok + else + nil -> {:error, :invalid_discovery_signature} + false -> {:error, :invalid_discovery_signature} + _ -> {:error, :invalid_discovery_signature} + end + end + + defp verify_discovery_document_signature(_payload, _identity), + do: {:error, :invalid_discovery_signature} + + defp normalize_discovery_claimed_domain(domain, payload) + when is_binary(domain) and is_map(payload) do + case normalize_peer_domain(discovery_field(payload, "domain")) do + {:ok, ^domain} -> {:ok, domain} + {:ok, _other} -> {:error, :domain_mismatch} + {:error, _} -> {:error, :invalid_discovery_domain} + end + end + + defp normalize_discovery_protocol_version(payload) when is_map(payload) do + supported_version = ArblargSDK.protocol_version() + + case normalize_optional_string(discovery_field(payload, "default_protocol_version")) do + ^supported_version -> + {:ok, supported_version} + + version when is_binary(version) -> + {:error, :unsupported_version} + + _ -> + {:error, :invalid_discovery_document} + end + end + + defp normalize_discovery_identity(identity) when is_map(identity) do + keys = + case discovery_field(identity, "keys") do + keys when is_list(keys) -> + keys + |> Enum.map(&normalize_discovery_key/1) + |> Enum.reject(&is_nil/1) + + _ -> + [] + end + + current_key_id = normalize_optional_string(discovery_field(identity, "current_key_id")) + + current_key_id = + if is_binary(current_key_id) and + Enum.any?(keys, &(discovery_field(&1, "id") == current_key_id)) do + current_key_id + else + keys |> List.first() |> then(&discovery_field(&1, "id")) + end + + if keys == [] do + nil + else + %{ + "algorithm" => ArblargSDK.signature_algorithm(), + "current_key_id" => current_key_id, + "keys" => keys + } + end + end + + defp normalize_discovery_identity(_identity), do: nil + + defp normalize_discovery_key(key) when is_map(key) do + id = normalize_optional_string(discovery_field(key, "id")) + algorithm = normalize_optional_string(discovery_field(key, "algorithm")) + public_key = normalize_optional_string(discovery_field(key, "public_key")) + + cond do + !is_binary(id) -> + nil + + !is_binary(public_key) -> + nil + + is_binary(algorithm) and String.downcase(algorithm) != ArblargSDK.signature_algorithm() -> + nil + + true -> + %{"id" => id, "algorithm" => ArblargSDK.signature_algorithm(), "public_key" => public_key} + end + end + + defp normalize_discovery_key(_key), do: nil + + defp normalize_discovery_endpoints(endpoints, claimed_domain, validate_urls?, context) + when is_map(endpoints) and is_binary(claimed_domain) and is_map(context) do + [ + {"well_known", discovery_field(endpoints, "well_known"), ["http", "https"]}, + {"well_known_versioned", discovery_field(endpoints, "well_known_versioned"), + ["http", "https"]}, + {"events", discovery_field(endpoints, "events"), ["http", "https"]}, + {"events_batch", discovery_field(endpoints, "events_batch"), ["http", "https"]}, + {"ephemeral", discovery_field(endpoints, "ephemeral"), ["http", "https"]}, + {"sync", discovery_field(endpoints, "sync"), ["http", "https"]}, + {"stream_events", discovery_field(endpoints, "stream_events"), ["http", "https"]}, + {"session_websocket", discovery_field(endpoints, "session_websocket"), + session_websocket_schemes(context)}, + {"public_servers", discovery_field(endpoints, "public_servers"), ["http", "https"]}, + {"snapshot_template", discovery_field(endpoints, "snapshot_template"), ["http", "https"]}, + {"profiles", discovery_field(endpoints, "profiles"), ["http", "https"]}, + {"schema_template", discovery_field(endpoints, "schema_template"), ["http", "https"]}, + {"schemas", discovery_field(endpoints, "schemas"), ["http", "https"]} + ] + |> Enum.reduce_while({:ok, %{}}, fn {key, value, allowed_schemes}, {:ok, acc} -> + case maybe_normalize_discovery_endpoint( + key, + value, + validate_urls?, + claimed_domain, + allowed_schemes, + context + ) do + {:ok, nil} -> {:cont, {:ok, acc}} + {:ok, normalized} -> {:cont, {:ok, Map.put(acc, key, normalized)}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp normalize_discovery_endpoints(_endpoints, _claimed_domain, _validate_urls?, _context), + do: {:error, :invalid_discovery_endpoints} + + defp session_websocket_schemes(context) do + if call(context, :allow_insecure_transport?, []), do: ["ws", "wss"], else: ["wss"] + end + + defp maybe_normalize_discovery_endpoint( + _key, + value, + _validate_urls?, + _claimed_domain, + _allowed_schemes, + _context + ) + when value in [nil, ""] do + {:ok, nil} + end + + defp maybe_normalize_discovery_endpoint( + _key, + value, + validate_urls?, + claimed_domain, + allowed_schemes, + context + ) do + case normalize_discovery_url(value, validate_urls?, claimed_domain, allowed_schemes, context) do + normalized when is_binary(normalized) -> {:ok, normalized} + _ -> {:error, :invalid_discovery_endpoints} + end + end + + defp normalize_discovery_capabilities(payload) when is_map(payload) do + features = normalize_discovery_features(discovery_field(payload, "features")) + limits = normalize_discovery_limits(discovery_field(payload, "limits")) + + compatibility_claims = + normalize_discovery_compatibility_claims(discovery_field(payload, "compatibility_claims")) + + extensions = normalize_discovery_extensions(discovery_field(payload, "extensions")) + + supported_event_types = + normalize_discovery_supported_event_types( + discovery_field(payload, "supported_event_types") || + discovery_field(discovery_field(payload, "events"), "supported") + ) + + transport_profiles = + normalize_discovery_transport_profiles(discovery_field(payload, "transport_profiles")) + + features + |> maybe_put_discovery_capability( + "compatibility_claims", + if(compatibility_claims == [], do: nil, else: compatibility_claims) + ) + |> maybe_put_discovery_capability( + "extensions", + if(extensions == [], do: nil, else: extensions) + ) + |> maybe_put_discovery_capability( + "supported_event_types", + if(supported_event_types == [], do: nil, else: supported_event_types) + ) + |> maybe_put_discovery_capability("limits", limits) + |> maybe_put_discovery_capability("transport_profiles", transport_profiles) + end + + defp maybe_enrich_discovery_capabilities(capabilities, claimed_domain, endpoints, context) + when is_map(capabilities) and is_binary(claimed_domain) and is_map(endpoints) and + is_map(context) do + case discovery_field(endpoints, "profiles") do + profiles_url when is_binary(profiles_url) -> + case fetch_remote_profiles_capabilities(claimed_domain, profiles_url, context) do + {:ok, %{} = profile_capabilities} when map_size(profile_capabilities) > 0 -> + deep_merge_capability_maps(capabilities, profile_capabilities) + + _ -> + capabilities + end + + _ -> + capabilities + end + end + + defp maybe_enrich_discovery_capabilities(capabilities, _claimed_domain, _endpoints, _context) + when is_map(capabilities), + do: capabilities + + defp fetch_remote_profiles_capabilities(domain, profiles_url, context) + when is_binary(domain) and is_binary(profiles_url) and is_map(context) do + payload_result = + case call(context, :federation_config, []) |> Keyword.get(:profiles_fetcher) do + fun when is_function(fun, 2) -> + case fun.(domain, profiles_url) do + {:ok, %{} = payload} -> {:ok, payload} + {:error, reason} -> {:error, reason} + _other -> {:error, :invalid_profiles_response} + end + + _ -> + fetch_single_discovery_url(profiles_url, context) + end + + case payload_result do + {:ok, %{} = payload} -> {:ok, normalize_profiles_capabilities(payload)} + {:error, reason} -> {:error, reason} + _ -> {:error, :invalid_profiles_response} + end + end + + defp fetch_remote_profiles_capabilities(_domain, _profiles_url, _context), + do: {:error, :invalid_profiles_response} + + defp normalize_profiles_capabilities(payload) when is_map(payload) do + protocol = normalize_optional_string(discovery_field(payload, "protocol")) + protocol_id = normalize_optional_string(discovery_field(payload, "protocol_id")) + + if protocol == ArblargSDK.protocol_name() and + protocol_id == ArblargSDK.protocol_id() and + profiles_support_supported_version?(payload) do + normalize_discovery_capabilities(payload) + else + %{} + end + end + + defp profiles_support_supported_version?(payload) when is_map(payload) do + supported_version = ArblargSDK.protocol_version() + + case normalize_optional_string(discovery_field(payload, "default_protocol_version")) do + version when is_binary(version) -> + version == supported_version + + _ -> + payload + |> discovery_field("protocol_versions") + |> List.wrap() + |> Enum.any?(&(&1 == supported_version)) + end + end + + defp deep_merge_capability_maps(left, right) when is_map(left) and is_map(right) do + Map.merge(left, right, fn _key, left_value, right_value -> + deep_merge_capability_values(left_value, right_value) + end) + end + + defp deep_merge_capability_values(left, right) when is_map(left) and is_map(right), + do: deep_merge_capability_maps(left, right) + + defp deep_merge_capability_values(left, right) when is_list(left) and is_list(right), + do: Enum.uniq(left ++ right) + + defp deep_merge_capability_values(_left, right), do: right + + defp maybe_put_discovery_capability(map, _key, nil), do: map + defp maybe_put_discovery_capability(map, _key, %{} = value) when map_size(value) == 0, do: map + + defp maybe_put_discovery_capability(map, key, value) when is_map(map) and is_binary(key) do + Map.put(map, key, value) + end + + defp normalize_discovery_features(features) when is_map(features), do: features + defp normalize_discovery_features(_features), do: %{} + + defp normalize_discovery_compatibility_claims(claims) when is_list(claims) do + claims + |> Enum.map(&normalize_optional_string/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp normalize_discovery_compatibility_claims(_claims), do: [] + + defp normalize_discovery_extensions(extensions) when is_list(extensions) do + extensions + |> Enum.map(&normalize_discovery_extension/1) + |> Enum.reject(&is_nil/1) + end + + defp normalize_discovery_extensions(_extensions), do: [] + + defp normalize_discovery_extension(extension) when is_binary(extension) do + case normalize_optional_string(extension) do + nil -> nil + urn -> %{"urn" => urn} + end + end + + defp normalize_discovery_extension(extension) when is_map(extension) do + normalized = + Enum.reduce(extension, %{}, fn {key, value}, acc -> + normalized_key = + case key do + binary when is_binary(binary) -> binary + atom when is_atom(atom) -> Atom.to_string(atom) + other -> to_string(other) + end + + Map.put(acc, normalized_key, value) + end) + + case normalize_optional_string(discovery_field(normalized, "urn")) do + nil -> nil + urn -> Map.put(normalized, "urn", urn) + end + end + + defp normalize_discovery_extension(_extension), do: nil + + defp normalize_discovery_supported_event_types(event_types) when is_list(event_types) do + event_types + |> Enum.map(&normalize_optional_string/1) + |> Enum.reject(&is_nil/1) + |> Enum.map(&ArblargSDK.canonical_event_type/1) + |> Enum.uniq() + end + + defp normalize_discovery_supported_event_types(_event_types), do: [] + + defp normalize_discovery_limits(limits) when is_map(limits) do + [ + "max_batch_events", + "max_ephemeral_items", + "max_snapshot_channels", + "max_snapshot_messages", + "max_snapshot_governance_entries", + "max_stream_replay_limit", + "max_session_inflight_batches", + "max_session_inflight_events", + "typing_ttl_ms", + "presence_ttl_ms" + ] + |> Enum.reduce(%{}, fn key, acc -> + case discovery_field(limits, key) do + value when is_integer(value) and value > 0 -> Map.put(acc, key, value) + _ -> acc + end + end) + end + + defp normalize_discovery_limits(_limits), do: %{} + + defp normalize_discovery_transport_profiles(profiles) when is_map(profiles) do + preferred_order = + profiles + |> discovery_field("preferred_order") + |> List.wrap() + |> Enum.filter(&is_binary/1) + + fallback_order = + profiles + |> discovery_field("fallback_order") + |> List.wrap() + |> Enum.filter(&is_binary/1) + + %{} + |> maybe_put_discovery_capability( + "preferred_order", + if(preferred_order == [], do: nil, else: preferred_order) + ) + |> maybe_put_discovery_capability( + "fallback_order", + if(fallback_order == [], do: nil, else: fallback_order) + ) + |> maybe_put_discovery_capability( + "session_websocket", + case discovery_field(profiles, "session_websocket") do + %{} = session_profile -> session_profile + _ -> nil + end + ) + end + + defp normalize_discovery_transport_profiles(_profiles), do: %{} + + defp discovery_identity_fingerprint(%{"keys" => keys, "current_key_id" => current_key_id}) + when is_list(keys) do + normalized = + keys + |> Enum.map(fn key -> + %{"id" => discovery_field(key, "id"), "public_key" => discovery_field(key, "public_key")} + end) + |> Enum.sort_by(&{&1["id"] || "", &1["public_key"] || ""}) + + :crypto.hash( + :sha256, + Jason.encode!(%{"current_key_id" => current_key_id, "keys" => normalized}) + ) + |> Base.url_encode64(padding: false) + end + + defp discovery_identity_fingerprint(_identity), do: "" + + defp verify_discovery_bootstrap_trust(domain, identity, identity_fingerprint, context) + when is_binary(domain) and is_map(identity) and is_binary(identity_fingerprint) and + is_map(context) do + case configured_trust_anchor(domain, context) do + anchor when is_binary(anchor) -> + if anchor == identity_fingerprint do + :ok + else + {:error, :identity_anchor_mismatch} + end + + nil -> + current_record = get_discovered_peer_record(domain) + + current_fingerprint = + current_record && normalize_optional_string(current_record.identity_fingerprint) + + cond do + is_binary(current_fingerprint) and current_fingerprint == identity_fingerprint -> + :ok + + dns_identity_proof_matches?(domain, identity, identity_fingerprint, context) -> + :ok + + dns_identity_proof_present?(domain, context) -> + {:error, :identity_dns_proof_failed} + + true -> + {:error, :bootstrap_trust_not_verified} + end + end + end + + defp verify_discovery_bootstrap_trust(_domain, _identity, _identity_fingerprint, _context), + do: {:error, :bootstrap_trust_not_verified} + + defp configured_trust_anchor(domain, context) when is_binary(domain) and is_map(context) do + anchors = + call(context, :federation_config, []) + |> Keyword.get(:trust_anchors, %{}) + + case anchors do + %{} = map -> + map + |> Map.get(String.downcase(domain)) + |> normalize_optional_string() + + list when is_list(list) -> + list + |> Enum.reduce(%{}, fn + {key, value}, acc -> + Map.put(acc, String.downcase(to_string(key)), value) + + %{} = value, acc -> + mapped_domain = normalize_optional_string(value[:domain] || value["domain"]) + fingerprint = normalize_optional_string(value[:fingerprint] || value["fingerprint"]) + + if is_binary(mapped_domain) and is_binary(fingerprint) do + Map.put(acc, String.downcase(mapped_domain), fingerprint) + else + acc + end + + _value, acc -> + acc + end) + |> Map.get(String.downcase(domain)) + |> normalize_optional_string() + + _ -> + nil + end + end + + defp configured_trust_anchor(_domain, _context), do: nil + + defp dns_identity_proof_matches?(domain, identity, identity_fingerprint, context) + when is_binary(domain) and is_map(identity) and is_binary(identity_fingerprint) and + is_map(context) do + public_keys = + identity + |> discovery_field("keys") + |> List.wrap() + |> Enum.map(&normalize_optional_string(discovery_field(&1, "public_key"))) + |> Enum.reject(&is_nil/1) + + domain + |> discovery_dns_identity_proofs(context) + |> Enum.any?(fn proof -> + dns_identity_record_matches?( + proof, + identity_fingerprint, + public_keys, + discovery_tls_identity_binding(domain, context) + ) + end) + end + + defp dns_identity_proof_matches?(_domain, _identity, _identity_fingerprint, _context), do: false + + defp dns_identity_proof_present?(domain, context) when is_binary(domain) and is_map(context) do + discovery_dns_identity_proofs(domain, context) != [] + end + + defp dns_identity_proof_present?(_domain, _context), do: false + + defp discovery_dns_identity_proofs(domain, context) + when is_binary(domain) and is_map(context) do + case call(context, :federation_config, []) |> Keyword.get(:dns_identity_fetcher) do + fun when is_function(fun, 2) -> + ["_arblarg.#{domain}", "_arblarg-bootstrap.#{domain}"] + |> Enum.flat_map(fn name -> + case fun.(name, domain) do + records when is_list(records) -> Enum.map(records, &normalize_dns_identity_proof/1) + record -> [normalize_dns_identity_proof(record)] + end + end) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + + fun when is_function(fun, 1) -> + ["_arblarg.#{domain}", "_arblarg-bootstrap.#{domain}"] + |> Enum.flat_map(fn name -> + case fun.(name) do + records when is_list(records) -> Enum.map(records, &normalize_dns_identity_proof/1) + record -> [normalize_dns_identity_proof(record)] + end + end) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + + _ -> + ["_arblarg.#{domain}", "_arblarg-bootstrap.#{domain}"] + |> Enum.flat_map(&lookup_dns_txt_records/1) + |> Enum.map(&normalize_dns_identity_proof/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + rescue + _ -> [] + end + + defp discovery_dns_identity_proofs(_domain, _context), do: [] + + defp normalize_dns_identity_proof(%{} = proof) do + text = + normalize_optional_string(proof[:text] || proof["text"] || proof[:value] || proof["value"]) + + if is_binary(text) do + %{ + text: text, + authenticated: + truthy_feature_flag?(proof[:authenticated] || proof["authenticated"] || false) + } + else + nil + end + end + + defp normalize_dns_identity_proof(record) when is_binary(record) do + case normalize_optional_string(record) do + nil -> nil + text -> %{text: text, authenticated: false} + end + end + + defp normalize_dns_identity_proof(_record), do: nil + + defp dns_identity_record_matches?(proof, identity_fingerprint, public_keys, tls_binding) + when is_map(proof) and is_binary(identity_fingerprint) and is_list(public_keys) do + normalized = String.downcase(String.trim(proof.text || "")) + fingerprint = String.downcase(identity_fingerprint) + + identity_match? = + normalized == fingerprint or + String.contains?(normalized, "fingerprint=#{fingerprint}") or + Enum.any?(public_keys, fn key -> + downcased_key = String.downcase(key) + + normalized == downcased_key or + String.contains?(normalized, "public_key=#{downcased_key}") or + String.contains?(normalized, "key=#{downcased_key}") + end) + + tls_match? = + case tls_binding do + %{"certificate_sha256" => certificate_sha256} -> + String.contains?( + normalized, + "tls_certificate_sha256=#{String.downcase(certificate_sha256)}" + ) + + _ -> + false + end + + identity_match? and (proof.authenticated == true or tls_match?) + end + + defp dns_identity_record_matches?(_proof, _identity_fingerprint, _public_keys, _tls_binding), + do: false + + defp discovery_tls_identity_binding(domain, context) + when is_binary(domain) and is_map(context) do + case call(context, :federation_config, []) |> Keyword.get(:tls_identity_fetcher) do + fun when is_function(fun, 2) -> + normalize_tls_identity_binding(fun.(domain, %{scheme: "https", port: 443})) + + fun when is_function(fun, 1) -> + normalize_tls_identity_binding(fun.(domain)) + + _ -> + lookup_tls_identity_binding(domain) + end + rescue + _ -> nil + end + + defp discovery_tls_identity_binding(_domain, _context), do: nil + + defp normalize_tls_identity_binding(%{} = binding) do + certificate_sha256 = + normalize_optional_string(binding[:certificate_sha256] || binding["certificate_sha256"]) + + if is_binary(certificate_sha256) do + %{"certificate_sha256" => String.downcase(certificate_sha256)} + else + nil + end + end + + defp normalize_tls_identity_binding(binding) when is_binary(binding) do + normalize_tls_identity_binding(%{"certificate_sha256" => binding}) + end + + defp normalize_tls_identity_binding(_binding), do: nil + + defp lookup_tls_identity_binding(domain) when is_binary(domain) do + ssl_opts = [ + :binary, + active: false, + verify: :verify_peer, + cacerts: :public_key.cacerts_get(), + server_name_indication: String.to_charlist(domain), + customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)] + ] + + with {:ok, socket} <- :ssl.connect(String.to_charlist(domain), 443, ssl_opts, 5_000), + {:ok, cert_der} <- :ssl.peercert(socket) do + _ = :ssl.close(socket) + + %{ + "certificate_sha256" => + cert_der + |> :crypto.hash(:sha256) + |> Base.url_encode64(padding: false) + |> String.downcase() + } + else + _ -> nil + end + rescue + _ -> nil + end + + defp lookup_dns_txt_records(name) when is_binary(name) do + name + |> String.to_charlist() + |> :inet_res.lookup(:in, :txt) + |> Enum.map(fn record -> + record + |> List.wrap() + |> Enum.map_join("", &to_string/1) + end) + rescue + _ -> [] + end + + defp lookup_dns_txt_records(_name), do: [] + + defp normalize_discovery_url(value, false, claimed_domain, allowed_schemes, _context) do + case normalize_optional_string(value) do + nil -> + nil + + url -> + %URI{scheme: scheme, host: host} = URI.parse(url) + + if is_binary(host) and host != "" and scheme in allowed_schemes and + host_belongs_to_domain?(host, claimed_domain) do + url + else + nil + end + end + end + + defp normalize_discovery_url(value, true, claimed_domain, allowed_schemes, context) do + with url when is_binary(url) <- normalize_optional_string(value), + :ok <- validate_discovery_url(url, claimed_domain, allowed_schemes, context) do + url + else + _ -> nil + end + end + + defp validate_discovery_url(url, claimed_domain, allowed_schemes, context) + when is_binary(url) and is_binary(claimed_domain) and is_list(allowed_schemes) and + is_map(context) do + with %URI{scheme: scheme, host: host} <- URI.parse(url), + true <- is_binary(host) and host != "", + true <- scheme in allowed_schemes, + :ok <- maybe_validate_httpish_discovery_url(url, scheme, context), + true <- host_belongs_to_domain?(host, claimed_domain) do + :ok + else + false -> {:error, :invalid_discovery_url} + {:error, reason} -> {:error, reason} + end + end + + defp validate_discovery_url(url, context) when is_binary(url) and is_map(context) do + with :ok <- URLValidator.validate(url), + %URI{scheme: scheme, host: host} <- URI.parse(url), + true <- is_binary(host) and host != "", + true <- + scheme == "https" or + (call(context, :allow_insecure_transport?, []) and scheme == "http") do + :ok + else + false -> {:error, :invalid_discovery_url} + {:error, reason} -> {:error, reason} + end + end + + defp validate_discovery_url(_url, _context), do: {:error, :invalid_discovery_url} + + defp host_belongs_to_domain?(host, remote_domain) + when is_binary(host) and is_binary(remote_domain) do + normalized_host = String.downcase(host) + normalized_domain = String.downcase(remote_domain) + + normalized_host == normalized_domain or + String.ends_with?(normalized_host, "." <> normalized_domain) + end + + defp host_belongs_to_domain?(_host, _remote_domain), do: false + + defp maybe_validate_httpish_discovery_url(url, scheme, context) + when scheme in ["http", "https"] do + with :ok <- URLValidator.validate(url), + true <- + scheme == "https" or + (call(context, :allow_insecure_transport?, []) and scheme == "http") do + :ok + else + false -> {:error, :invalid_discovery_url} + {:error, reason} -> {:error, reason} + end + end + + defp maybe_validate_httpish_discovery_url(url, scheme, context) + when scheme in ["ws", "wss"] do + URLValidator.validate_websocket(url, + allow_insecure_transport: call(context, :allow_insecure_transport?, []), + allow_localhost: Elektrine.RuntimeEnv.dev_or_test?() + ) + end + + defp extract_discovery_base_url( + endpoints, + discovery_url, + claimed_domain, + validate_urls?, + context + ) + when is_map(endpoints) and is_binary(discovery_url) and is_binary(claimed_domain) and + is_map(context) do + [ + Map.get(endpoints, "events_batch"), + Map.get(endpoints, "events"), + Map.get(endpoints, "sync"), + Map.get(endpoints, "stream_events"), + Map.get(endpoints, "public_servers"), + Map.get(endpoints, "well_known"), + normalize_discovery_url( + discovery_url, + validate_urls?, + claimed_domain, + ["http", "https"], + context + ) + ] + |> Enum.find_value(&base_url_from_remote_url/1) + end + + defp extract_discovery_base_url( + _endpoints, + discovery_url, + claimed_domain, + validate_urls?, + context + ) do + base_url_from_remote_url( + normalize_discovery_url( + discovery_url, + validate_urls?, + claimed_domain, + ["http", "https"], + context + ) + ) + end + + defp base_url_from_remote_url(url) when is_binary(url) do + case URI.parse(url) do + %URI{scheme: scheme, host: host, port: port} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + default_port? = + (scheme == "https" and port in [nil, 443]) or + (scheme == "http" and port in [nil, 80]) + + if default_port? do + "#{scheme}://#{host}" + else + "#{scheme}://#{host}:#{port}" + end + + _ -> + nil + end + end + + defp base_url_from_remote_url(_url), do: nil + + defp upsert_discovered_peer(attrs) when is_map(attrs) do + record = + get_discovered_peer_record(attrs.domain) || + %FederationDiscoveredPeer{} + + attrs = merge_discovered_peer_continuity(record, attrs) + + record + |> FederationDiscoveredPeer.changeset(attrs) + |> Repo.insert_or_update() + rescue + _ -> + {:error, :discovered_peer_store_unavailable} + end + + defp upsert_discovered_peer(_attrs), do: {:error, :invalid_discovered_peer} + + defp get_discovered_peer_record(domain) when is_binary(domain) do + Repo.get_by(FederationDiscoveredPeer, domain: domain) + rescue + _ -> + nil + end + + defp get_discovered_peer_record(_domain), do: nil + + defp merge_discovered_peer_continuity(%FederationDiscoveredPeer{} = record, attrs) + when is_map(attrs) do + current_fingerprint = normalize_optional_string(record.identity_fingerprint) + new_fingerprint = normalize_optional_string(attrs.identity_fingerprint) + now = attrs.last_discovered_at || DateTime.utc_now() |> DateTime.truncate(:second) + + cond do + is_nil(current_fingerprint) or is_nil(new_fingerprint) or + current_fingerprint == new_fingerprint -> + attrs + |> Map.put(:trust_state, record.trust_state || attrs.trust_state || "trusted") + |> Map.put(:previous_identity_fingerprint, record.previous_identity_fingerprint) + |> Map.put(:last_key_change_at, record.last_key_change_at) + + discovery_identity_overlaps?(record.identity, attrs.identity) -> + attrs + |> Map.put(:trust_state, "rotated") + |> Map.put(:previous_identity_fingerprint, current_fingerprint) + |> Map.put(:last_key_change_at, now) + + true -> + attrs + |> Map.put(:trust_state, "replaced") + |> Map.put(:previous_identity_fingerprint, current_fingerprint) + |> Map.put(:last_key_change_at, now) + end + end + + defp merge_discovered_peer_continuity(_record, attrs) when is_map(attrs), do: attrs + + defp discovery_identity_overlaps?(left, right) when is_map(left) and is_map(right) do + left_keys = + left + |> discovery_field("keys") + |> List.wrap() + |> Enum.map(&normalize_optional_string(discovery_field(&1, "public_key"))) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + + right_keys = + right + |> discovery_field("keys") + |> List.wrap() + |> Enum.map(&normalize_optional_string(discovery_field(&1, "public_key"))) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + + not MapSet.disjoint?(left_keys, right_keys) + end + + defp discovery_identity_overlaps?(_left, _right), do: false + + defp build_discovered_peer(%FederationDiscoveredPeer{} = record, context) + when is_map(context) do + {allow_incoming, allow_outgoing} = discovered_peer_permissions(record.trust_state) + + peer_attrs = %{ + domain: record.domain, + base_url: record.base_url, + keys: + case record.identity do + %{"keys" => keys} when is_list(keys) -> keys + %{keys: keys} when is_list(keys) -> keys + _ -> [] + end, + active_outbound_key_id: + normalize_optional_string(discovery_field(record.identity || %{}, "current_key_id")), + allow_incoming: allow_incoming, + allow_outgoing: allow_outgoing, + event_endpoint: discovery_field(record.endpoints || %{}, "events"), + events_batch_endpoint: discovery_field(record.endpoints || %{}, "events_batch"), + ephemeral_endpoint: discovery_field(record.endpoints || %{}, "ephemeral"), + sync_endpoint: discovery_field(record.endpoints || %{}, "sync"), + directory_endpoint: discovery_field(record.endpoints || %{}, "public_servers"), + stream_events_endpoint: discovery_field(record.endpoints || %{}, "stream_events"), + session_websocket_endpoint: discovery_field(record.endpoints || %{}, "session_websocket"), + snapshot_endpoint_template: discovery_field(record.endpoints || %{}, "snapshot_template") + } + + case Config.normalize_peer_config(peer_attrs, call(context, :allow_insecure_transport?, [])) do + %{} = peer -> + peer + |> Map.put(:discovery_source, :dynamic) + |> Map.put(:discovered, true) + |> Map.put(:discovery_url, record.discovery_url) + |> Map.put(:last_discovered_at, record.last_discovered_at) + |> Map.put(:trust_state, record.trust_state) + |> Map.put(:identity_fingerprint, record.identity_fingerprint) + |> Map.put(:previous_identity_fingerprint, record.previous_identity_fingerprint) + |> Map.put(:last_key_change_at, record.last_key_change_at) + |> Map.put(:features, record.features || %{}) + |> apply_runtime_policy_to_peer() + + _ -> + nil + end + end + + defp build_discovered_peer(_record, _context), do: nil + + defp build_discovered_peer_control(%FederationDiscoveredPeer{} = peer, context) do + effective_peer = build_discovered_peer(peer, context) + + %{ + domain: peer.domain, + configured: false, + discovered: true, + base_url: peer.base_url, + discovery_url: peer.discovery_url, + blocked: peer.trust_state == "replaced", + reason: peer.last_error, + allow_incoming_override: nil, + allow_outgoing_override: nil, + effective_allow_incoming: + if(is_map(effective_peer), do: effective_peer.allow_incoming == true, else: false), + effective_allow_outgoing: + if(is_map(effective_peer), do: effective_peer.allow_outgoing == true, else: false), + updated_at: peer.updated_at, + updated_by: nil, + trust_state: peer.trust_state, + protocol_version: peer.protocol_version, + features: peer.features || %{}, + last_discovered_at: peer.last_discovered_at, + last_key_change_at: peer.last_key_change_at, + requires_operator_action: peer.trust_state == "replaced" + } + end + + defp maybe_filter_discovered_search(query, search_query) when is_binary(search_query) do + case String.trim(search_query) do + "" -> query + trimmed -> where(query, [peer], ilike(peer.domain, ^"%#{trimmed}%")) + end + end + + defp maybe_filter_discovered_search(query, _search_query), do: query + + defp maybe_filter_discovered_domains(query, domains) when is_list(domains) do + normalized_domains = + domains + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.downcase/1) + |> Enum.uniq() + + case normalized_domains do + [] -> where(query, [peer], false) + _ -> where(query, [peer], peer.domain in ^normalized_domains) + end + end + + defp maybe_filter_discovered_domains(query, _domains), do: query + + defp discovered_peer_permissions(trust_state) do + case normalize_optional_string(trust_state) do + "replaced" -> {false, false} + _ -> {true, true} + end + end + + defp apply_runtime_policy_to_peer(peer) when is_map(peer) do + [peer] + |> PeerPolicies.apply_runtime_policies() + |> List.first() + end + + defp discovered_peer_stale?( + %FederationDiscoveredPeer{last_discovered_at: %DateTime{} = discovered_at}, + context + ) + when is_map(context) do + DateTime.diff(DateTime.utc_now(), discovered_at, :second) > + call(context, :discovery_ttl_seconds, []) + end + + defp discovered_peer_stale?(_record, _context), do: true + + defp discovered_peer_within_grace?( + %FederationDiscoveredPeer{last_discovered_at: %DateTime{} = discovered_at}, + context + ) + when is_map(context) do + DateTime.diff(DateTime.utc_now(), discovered_at, :second) <= + call(context, :discovery_stale_grace_seconds, []) + end + + defp discovered_peer_within_grace?(_record, _context), do: false + + defp record_discovery_failure(domain, reason) when is_binary(domain) do + case get_discovered_peer_record(domain) do + %FederationDiscoveredPeer{} = record -> + record + |> FederationDiscoveredPeer.changeset(%{last_error: inspect(reason)}) + |> Repo.update() + + _ -> + :ok + end + rescue + _ -> + :ok + end + + defp discovery_candidate_urls(domain, context) when is_binary(domain) and is_map(context) do + schemes = + if call(context, :allow_insecure_transport?, []) do + ["https", "http"] + else + ["https"] + end + + Enum.flat_map(schemes, fn scheme -> + base = "#{scheme}://#{domain}" + + [ + "#{base}/.well-known/_arblarg", + "#{base}/.well-known/_arblarg/#{ArblargSDK.protocol_version()}" + ] + end) + end + + defp discovery_candidate_urls(_domain, _context), do: [] + + defp discovery_field(map, key) when is_map(map) and is_binary(key) do + Map.get(map, key) || + case safe_existing_atom_key(key) do + nil -> nil + atom_key -> Map.get(map, atom_key) + end + end + + defp discovery_field(_map, _key), do: nil + + defp safe_existing_atom_key(key) when is_binary(key) do + discovery_atom_key(key) + end + + defp discovery_atom_key("protocol"), do: :protocol + defp discovery_atom_key("protocol_id"), do: :protocol_id + defp discovery_atom_key("identity"), do: :identity + defp discovery_atom_key("endpoints"), do: :endpoints + defp discovery_atom_key("algorithm"), do: :algorithm + defp discovery_atom_key("key_id"), do: :key_id + defp discovery_atom_key("value"), do: :value + defp discovery_atom_key("keys"), do: :keys + defp discovery_atom_key("id"), do: :id + defp discovery_atom_key("public_key"), do: :public_key + defp discovery_atom_key("domain"), do: :domain + defp discovery_atom_key("default_protocol_version"), do: :default_protocol_version + defp discovery_atom_key("current_key_id"), do: :current_key_id + defp discovery_atom_key("well_known"), do: :well_known + defp discovery_atom_key("well_known_versioned"), do: :well_known_versioned + defp discovery_atom_key("events"), do: :events + defp discovery_atom_key("events_batch"), do: :events_batch + defp discovery_atom_key("ephemeral"), do: :ephemeral + defp discovery_atom_key("sync"), do: :sync + defp discovery_atom_key("stream_events"), do: :stream_events + defp discovery_atom_key("session_websocket"), do: :session_websocket + defp discovery_atom_key("public_servers"), do: :public_servers + defp discovery_atom_key("snapshot_template"), do: :snapshot_template + defp discovery_atom_key("profiles"), do: :profiles + defp discovery_atom_key("schema_template"), do: :schema_template + defp discovery_atom_key("schemas"), do: :schemas + defp discovery_atom_key("features"), do: :features + defp discovery_atom_key("limits"), do: :limits + defp discovery_atom_key("compatibility_claims"), do: :compatibility_claims + defp discovery_atom_key("extensions"), do: :extensions + defp discovery_atom_key("supported_event_types"), do: :supported_event_types + defp discovery_atom_key("supported"), do: :supported + defp discovery_atom_key("transport_profiles"), do: :transport_profiles + defp discovery_atom_key("urn"), do: :urn + defp discovery_atom_key(_), do: nil + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_value), do: nil + + defp truthy_feature_flag?(value) + when value in [true, 1, "1", "true", "TRUE", "yes", "YES"], + do: true + + defp truthy_feature_flag?(_value), do: false + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/dispatch.ex b/apps/elektrine/lib/elektrine/messaging/federation/dispatch.ex new file mode 100644 index 0000000..ded8f2b --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/dispatch.ex @@ -0,0 +1,232 @@ +defmodule Elektrine.Messaging.Federation.Dispatch do + @moduledoc false + + require Logger + + import Elektrine.Messaging.Federation.Utils + + alias Elektrine.Messaging.Federation.{Transport, Visibility} + alias Elektrine.Messaging.{FederationOutboxEvent, FederationOutboxWorker} + alias Elektrine.Repo + + def enqueue_outbox_event(event, target_domains \\ :all, context) + + def enqueue_outbox_event(event, :all, context) when is_map(event) and is_map(context) do + peer_domains = + call(context, :outgoing_peers, []) + |> Enum.map(&String.downcase(&1.domain)) + |> Enum.uniq() + + peer_domains = filter_domains_for_event(event, peer_domains, context) + + do_enqueue_outbox_event(event, peer_domains, context) + end + + def enqueue_outbox_event(event, target_domains, context) + when is_map(event) and is_list(target_domains) and is_map(context) do + filtered_domains = + target_domains + |> Enum.map(&normalize_optional_string/1) + |> Enum.reject(&is_nil/1) + |> Enum.map(&String.downcase/1) + |> Enum.filter(fn domain -> match?(%{}, call(context, :outgoing_peer, [domain])) end) + |> Enum.uniq() + + filtered_domains = filter_domains_for_event(event, filtered_domains, context) + + do_enqueue_outbox_event(event, filtered_domains, context) + end + + def enqueue_outbox_event(event, _target_domains, context) + when is_map(event) and is_map(context) do + enqueue_outbox_event(event, :all, context) + end + + def fanout_ephemeral_batch(_items, [], _context), do: :ok + + def fanout_ephemeral_batch(items, domains, context) + when is_list(items) and is_list(domains) and is_map(context) do + domains + |> Enum.map(&normalize_optional_string/1) + |> Enum.reject(&is_nil/1) + |> Enum.map(&String.downcase/1) + |> Enum.uniq() + |> Enum.each(fn domain -> + case call(context, :outgoing_peer, [domain]) do + %{} = peer -> + items + |> Enum.filter(&peer_supports_item?(peer, &1)) + |> Enum.chunk_every(call(context, :peer_ephemeral_limit, [peer])) + |> Enum.each(fn + [] -> + :ok + + item_chunk -> + _ = call(context, :push_ephemeral_batch_to_peer, [peer, item_chunk]) + end) + + _ -> + :ok + end + end) + + :ok + end + + def ephemeral_stream_id(event_type, payload) + + def ephemeral_stream_id(event_type, payload) + when event_type in ["typing.start", "typing.stop"] and is_map(payload) do + with channel_id when is_binary(channel_id) <- event_channel_id(payload), + fragment when is_binary(fragment) <- actor_stream_fragment(payload["actor"]) do + "typing:#{channel_id}:#{fragment}" + else + _ -> nil + end + end + + def ephemeral_stream_id(event_type, payload) + when event_type in ["presence.update"] and is_map(payload) do + with server_id when is_binary(server_id) <- event_server_id(payload), + fragment when is_binary(fragment) <- + actor_stream_fragment(get_in(payload, ["presence", "actor"])) do + "presence:#{server_id}:#{fragment}" + else + _ -> nil + end + end + + def ephemeral_stream_id(event_type, payload) + when event_type in ["dm.call.signal"] and is_map(payload) do + with call_id when is_binary(call_id) <- normalize_optional_string(payload["call_id"]), + fragment when is_binary(fragment) <- actor_stream_fragment(payload["actor"]) do + "dm_call:#{call_id}:#{fragment}" + else + _ -> nil + end + end + + def ephemeral_stream_id(_event_type, _payload), do: nil + + defp do_enqueue_outbox_event(event, peer_domains, context) do + if Enum.empty?(peer_domains) do + :ok + else + do_enqueue_nonempty_outbox_event(event, peer_domains, context) + end + end + + defp do_enqueue_nonempty_outbox_event(event, peer_domains, context) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + attrs = %{ + event_id: event["event_id"], + event_type: event["event_type"], + stream_id: event["stream_id"], + sequence: parse_int(event["sequence"], 0), + payload: event, + target_domains: peer_domains, + delivered_domains: [], + attempt_count: 0, + max_attempts: call(context, :outbox_max_attempts, []), + status: "pending", + next_retry_at: now, + partition_month: call(context, :outbox_partition_month, [now]) + } + + case %FederationOutboxEvent{} |> FederationOutboxEvent.changeset(attrs) |> Repo.insert() do + {:ok, outbox_event} -> + _ = FederationOutboxWorker.enqueue(outbox_event.id) + :ok + + {:error, %Ecto.Changeset{errors: [event_id: _]}} -> + :ok + + {:error, reason} -> + Logger.warning("Failed to enqueue federation outbox event: #{inspect(reason)}") + :ok + end + end + + defp actor_stream_fragment(actor_payload) when is_map(actor_payload) do + actor_hint = + normalize_optional_string( + actor_payload["uri"] || + actor_payload["handle"] || + actor_payload["id"] || + actor_payload["actor"] + ) || + case { + normalize_optional_string(actor_payload["username"]), + normalize_optional_string(actor_payload["domain"]) + } do + {username, domain} when is_binary(username) and is_binary(domain) -> + "#{username}@#{domain}" + + _ -> + nil + end + + case actor_hint do + hint when is_binary(hint) -> + :crypto.hash(:sha256, hint) + |> Base.url_encode64(padding: false) + |> binary_part(0, 12) + + _ -> + nil + end + end + + defp actor_stream_fragment(_actor_payload), do: nil + + defp event_server_id(data) when is_map(data) do + refs = data["refs"] || %{} + get_in(data, ["server", "id"]) || refs["server_id"] + end + + defp event_channel_id(data) when is_map(data) do + refs = data["refs"] || %{} + get_in(data, ["channel", "id"]) || refs["channel_id"] + end + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_value), do: nil + + defp filter_domains_for_event(event, domains, context) + when is_map(event) and is_list(domains) and is_map(context) do + event_type = normalize_optional_string(event["event_type"]) + + authorized_domains = + case Visibility.target_domains_for_event(event) do + nil -> nil + domains -> MapSet.new(domains) + end + + Enum.filter(domains, fn domain -> + case {event_type, call(context, :outgoing_peer, [domain])} do + {event_type, %{} = peer} when is_binary(event_type) -> + Transport.peer_supports_event_type?(peer, event_type) and + (is_nil(authorized_domains) or MapSet.member?(authorized_domains, domain)) + + _ -> + false + end + end) + end + + defp peer_supports_item?(peer, %{"event_type" => event_type}) + when is_map(peer) and is_binary(event_type) do + Transport.peer_supports_event_type?(peer, event_type) + end + + defp peer_supports_item?(_peer, _item), do: false + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/egress.ex b/apps/elektrine/lib/elektrine/messaging/federation/egress.ex new file mode 100644 index 0000000..a5048af --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/egress.ex @@ -0,0 +1,489 @@ +defmodule Elektrine.Messaging.Federation.Egress do + @moduledoc false + + alias Elektrine.Messaging.Federation.Contexts + alias Elektrine.Messaging.Federation.Delivery + alias Elektrine.Messaging.Federation.Ingress + alias Elektrine.Messaging.Federation.PublicDiscovery + alias Elektrine.Messaging.Federation.Publisher + alias Elektrine.Messaging.Federation.RequestAuth + alias Elektrine.Messaging.Federation.Runtime + alias Elektrine.Messaging.Federation.Utils + + alias Elektrine.Messaging.{ChatMessage, ChatMessageReaction} + alias Elektrine.Repo + + @successful_delivery_statuses MapSet.new([ + "applied", + "duplicate", + "stale", + "recovered_via_stream", + "recovered_via_snapshot" + ]) + + def push_server_snapshot(server_id) do + push_server_snapshot(server_id, publisher_context()) + end + + def push_server_snapshot(server_id, publisher_context) do + Publisher.push_server_snapshot(server_id, publisher_context) + end + + def publish_server_upsert(server_id) do + publish_server_upsert(server_id, publisher_context()) + end + + def publish_server_upsert(server_id, publisher_context) do + Publisher.publish_server_upsert(server_id, publisher_context) + end + + def publish_message_created(message) do + publish_message_created(message, publisher_context()) + end + + def publish_message_created(%ChatMessage{} = message, publisher_context) do + Publisher.publish_message_created(message, publisher_context) + end + + def publish_message_created(message_id, publisher_context) when is_integer(message_id) do + Publisher.publish_message_created(message_id, publisher_context) + end + + def publish_dm_message_created(message, remote_handle \\ nil) + + def publish_dm_message_created(%ChatMessage{} = message, remote_handle) do + publish_dm_message_created(message, remote_handle, publisher_context()) + end + + def publish_dm_message_created(%ChatMessage{} = message, remote_handle, publisher_context) do + Publisher.publish_dm_message_created(message, remote_handle, publisher_context) + end + + def publish_dm_call_invite(session_id) do + publish_dm_call_invite(session_id, publisher_context()) + end + + def publish_dm_call_invite(session_id, publisher_context) do + Publisher.publish_dm_call_invite(session_id, publisher_context) + end + + def publish_dm_call_accept(session_id) do + publish_dm_call_accept(session_id, publisher_context()) + end + + def publish_dm_call_accept(session_id, publisher_context) do + Publisher.publish_dm_call_accept(session_id, publisher_context) + end + + def publish_dm_call_reject(session_id) do + publish_dm_call_reject(session_id, publisher_context()) + end + + def publish_dm_call_reject(session_id, publisher_context) do + Publisher.publish_dm_call_reject(session_id, publisher_context) + end + + def publish_dm_call_end(session_id) do + publish_dm_call_end(session_id, publisher_context()) + end + + def publish_dm_call_end(session_id, publisher_context) do + Publisher.publish_dm_call_end(session_id, publisher_context) + end + + def publish_dm_call_signal(session_id, actor_user_id, kind, signal_payload) do + publish_dm_call_signal(session_id, actor_user_id, kind, signal_payload, publisher_context()) + end + + def publish_dm_call_signal( + session_id, + actor_user_id, + kind, + signal_payload, + publisher_context + ) do + Publisher.publish_dm_call_signal( + session_id, + actor_user_id, + kind, + signal_payload, + publisher_context + ) + end + + def publish_message_updated(message) do + publish_message_updated(message, publisher_context()) + end + + def publish_message_updated(%ChatMessage{} = message, publisher_context) do + Publisher.publish_message_updated(message, publisher_context) + end + + def publish_message_updated(message_id, publisher_context) when is_integer(message_id) do + Publisher.publish_message_updated(message_id, publisher_context) + end + + def publish_message_deleted(message) do + publish_message_deleted(message, publisher_context()) + end + + def publish_message_deleted(%ChatMessage{} = message, publisher_context) do + Publisher.publish_message_deleted(message, publisher_context) + end + + def publish_message_deleted(message_id, publisher_context) when is_integer(message_id) do + Publisher.publish_message_deleted(message_id, publisher_context) + end + + def publish_reaction_added(message, reaction) do + publish_reaction_added(message, reaction, publisher_context()) + end + + def publish_reaction_added( + %ChatMessage{} = message, + %ChatMessageReaction{} = reaction, + publisher_context + ) do + Publisher.publish_reaction_added(message, reaction, publisher_context) + end + + def publish_reaction_removed(%ChatMessage{} = message, user_id, emoji) + when is_integer(user_id) do + publish_reaction_removed(message, user_id, emoji, publisher_context()) + end + + def publish_reaction_removed(%ChatMessage{} = message, user_id, emoji, publisher_context) + when is_integer(user_id) do + Publisher.publish_reaction_removed(message, user_id, emoji, publisher_context) + end + + def publish_read_cursor(conversation_id, user_id, message_id, read_at \\ DateTime.utc_now()) do + publish_read_cursor(conversation_id, user_id, message_id, read_at, publisher_context()) + end + + def publish_read_cursor(conversation_id, user_id, message_id, read_at, publisher_context) do + Publisher.publish_read_cursor( + conversation_id, + user_id, + message_id, + read_at, + publisher_context + ) + end + + def publish_read_receipt(conversation_id, user_id, message_id, read_at \\ DateTime.utc_now()) do + publish_read_receipt(conversation_id, user_id, message_id, read_at, publisher_context()) + end + + def publish_read_receipt(conversation_id, user_id, message_id, read_at, publisher_context) do + Publisher.publish_read_receipt( + conversation_id, + user_id, + message_id, + read_at, + publisher_context + ) + end + + def publish_typing_started(conversation_id, user_id) do + publish_typing_started(conversation_id, user_id, publisher_context()) + end + + def publish_typing_started(conversation_id, user_id, publisher_context) do + Publisher.publish_typing_started(conversation_id, user_id, publisher_context) + end + + def publish_typing_stopped(conversation_id, user_id) do + publish_typing_stopped(conversation_id, user_id, publisher_context()) + end + + def publish_typing_stopped(conversation_id, user_id, publisher_context) do + Publisher.publish_typing_stopped(conversation_id, user_id, publisher_context) + end + + def publish_presence_update(server_id, user_id, status, activities \\ []) do + publish_presence_update(server_id, user_id, status, activities, publisher_context()) + end + + def publish_presence_update(server_id, user_id, status, activities, publisher_context) do + Publisher.publish_presence_update(server_id, user_id, status, activities, publisher_context) + end + + def publish_room_presence_update(conversation_id, user_id, status, activities \\ []) do + publish_room_presence_update( + conversation_id, + user_id, + status, + activities, + publisher_context() + ) + end + + def publish_room_presence_update( + conversation_id, + user_id, + status, + activities, + publisher_context + ) do + Publisher.publish_room_presence_update( + conversation_id, + user_id, + status, + activities, + publisher_context + ) + end + + def publish_user_presence_update(user_id, status, activities \\ []) do + publish_user_presence_update(user_id, status, activities, publisher_context()) + end + + def publish_user_presence_update(user_id, status, activities, publisher_context) do + Publisher.publish_user_presence_update(user_id, status, activities, publisher_context) + end + + def publish_extension_event(conversation_id, actor_user_id, event_type, payload) do + publish_extension_event( + conversation_id, + actor_user_id, + event_type, + payload, + publisher_context() + ) + end + + def publish_extension_event( + conversation_id, + actor_user_id, + event_type, + payload, + publisher_context + ) do + Publisher.publish_extension_event( + conversation_id, + actor_user_id, + event_type, + payload, + publisher_context + ) + end + + def publish_membership_state(conversation_id, user_id, state, role \\ "member") do + publish_membership_state(conversation_id, user_id, state, role, publisher_context()) + end + + def publish_membership_state(conversation_id, user_id, state, role, publisher_context) do + Publisher.publish_membership_state(conversation_id, user_id, state, role, publisher_context) + end + + def publish_invite_state( + conversation_id, + target_user_id, + actor_user_id, + state \\ "pending", + role \\ "member", + metadata \\ %{} + ) do + publish_invite_state( + conversation_id, + target_user_id, + actor_user_id, + state, + role, + metadata, + publisher_context() + ) + end + + def publish_invite_state( + conversation_id, + target_user_id, + actor_user_id, + state, + role, + metadata, + publisher_context + ) do + Publisher.publish_invite_state( + conversation_id, + target_user_id, + actor_user_id, + state, + role, + metadata, + publisher_context + ) + end + + def publish_remote_invite_state( + conversation_id, + target_payload, + actor_user_id, + state \\ "pending", + role \\ "member", + metadata \\ %{} + ) do + publish_remote_invite_state( + conversation_id, + target_payload, + actor_user_id, + state, + role, + metadata, + publisher_context() + ) + end + + def publish_remote_invite_state( + conversation_id, + target_payload, + actor_user_id, + state, + role, + metadata, + publisher_context + ) do + Publisher.publish_remote_invite_state( + conversation_id, + target_payload, + actor_user_id, + state, + role, + metadata, + publisher_context + ) + end + + def publish_ban_state( + conversation_id, + target_user_id, + actor_user_id, + state \\ "active", + reason \\ nil, + expires_at \\ nil, + metadata \\ %{} + ) do + publish_ban_state( + conversation_id, + target_user_id, + actor_user_id, + state, + reason, + expires_at, + metadata, + publisher_context() + ) + end + + def publish_ban_state( + conversation_id, + target_user_id, + actor_user_id, + state, + reason, + expires_at, + metadata, + publisher_context + ) do + Publisher.publish_ban_state( + conversation_id, + target_user_id, + actor_user_id, + state, + reason, + expires_at, + metadata, + publisher_context + ) + end + + def maybe_push_for_conversation(conversation_id) do + maybe_push_for_conversation(conversation_id, publisher_context()) + end + + def maybe_push_for_conversation(conversation_id, publisher_context) do + Publisher.maybe_push_for_conversation(conversation_id, publisher_context) + end + + def maybe_push_for_server(server_id) do + maybe_push_for_server(server_id, publisher_context()) + end + + def maybe_push_for_server(server_id, publisher_context) do + Publisher.maybe_push_for_server(server_id, publisher_context) + end + + def process_outbox_event(outbox_event_id) when is_integer(outbox_event_id) do + Delivery.process_outbox_event(outbox_event_id, delivery_context()) + end + + def enqueue_due_outbox_events(limit \\ 500) do + Delivery.enqueue_due_outbox_events(limit, delivery_context()) + end + + defp publisher_context do + Contexts.publisher(%{ + enabled?: &Runtime.enabled?/0, + build_server_snapshot: &Ingress.build_server_snapshot/1, + build_server_snapshot_for_peer: fn server_id, peer -> + Ingress.build_server_snapshot(server_id, peer: peer) + end, + outgoing_peers: &Elektrine.Messaging.Federation.Peers.outgoing_peers/0, + push_snapshot_to_peer: &Ingress.push_snapshot_to_peer/2, + builder_context: &builder_context/0, + dispatch_context: &dispatch_context/0, + outgoing_peer: &Elektrine.Messaging.Federation.Peers.outgoing_peer/1, + state_context: &state_context/0, + mirror_broadcast_context: &mirror_broadcast_context/0, + get_chat_message: fn message_id -> Repo.get(ChatMessage, message_id) end, + publish_message_created: &publish_message_created/1 + }) + end + + defp builder_context do + Contexts.builder(%{ + outgoing_peers: &Elektrine.Messaging.Federation.Peers.outgoing_peers/0, + maybe_iso8601: &maybe_iso8601/1, + normalize_optional_string: &normalize_optional_string/1 + }) + end + + defp delivery_context do + Contexts.delivery(%{ + successful_delivery_statuses: @successful_delivery_statuses, + outgoing_peer: &Elektrine.Messaging.Federation.Peers.outgoing_peer/1, + transport_profiles_document: &PublicDiscovery.transport_profiles_document/0, + signed_headers: &RequestAuth.signed_headers/5, + truncate: &Utils.truncate/1, + next_outbound_sequence: &Utils.next_outbound_sequence/1, + normalize_optional_string: &normalize_optional_string/1, + builder_context: &builder_context/0 + }) + end + + defp dispatch_context do + Contexts.dispatch(%{ + outgoing_peers: &Elektrine.Messaging.Federation.Peers.outgoing_peers/0, + outgoing_peer: &Elektrine.Messaging.Federation.Peers.outgoing_peer/1, + delivery_context: &delivery_context/0 + }) + end + + defp state_context, do: Contexts.state() + + defp mirror_broadcast_context do + Contexts.mirror_broadcast(%{ + publish_message_created: &publish_message_created/1 + }) + end + + defp maybe_iso8601(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime) + defp maybe_iso8601(%NaiveDateTime{} = datetime), do: NaiveDateTime.to_iso8601(datetime) + defp maybe_iso8601(_value), do: nil + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_value), do: nil +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/errors.ex b/apps/elektrine/lib/elektrine/messaging/federation/errors.ex new file mode 100644 index 0000000..b159201 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/errors.ex @@ -0,0 +1,96 @@ +defmodule Elektrine.Messaging.Federation.Errors do + @moduledoc false + + @error_codes %{ + missing_domain: "missing_domain", + missing_key_id: "missing_key_id", + missing_timestamp: "missing_timestamp", + missing_content_digest: "missing_content_digest", + missing_request_id: "missing_request_id", + missing_signature_algorithm: "missing_signature_algorithm", + missing_signature: "missing_signature", + invalid_signature_algorithm: "invalid_signature_algorithm", + invalid_signature: "invalid_signature", + invalid_timestamp: "invalid_timestamp", + invalid_content_digest: "invalid_content_digest", + replayed_request: "replayed_request", + unknown_peer: "unknown_peer", + invalid_payload: "invalid_payload", + invalid_json: "invalid_json", + invalid_event_payload: "invalid_event_payload", + invalid_snapshot_signature: "invalid_snapshot_signature", + invalid_snapshot_governance: "invalid_snapshot_governance", + invalid_snapshot_response: "invalid_snapshot_response", + invalid_stream_replay_response: "invalid_stream_replay_response", + invalid_discovery_document: "invalid_discovery_document", + invalid_discovery_signature: "invalid_discovery_signature", + invalid_discovery_response: "invalid_discovery_response", + invalid_discovery_url: "invalid_discovery_url", + invalid_session_frame: "invalid_session_frame", + invalid_session_handshake: "invalid_session_handshake", + invalid_session_endpoint: "invalid_session_endpoint", + invalid_server_id: "invalid_server_id", + unsupported_protocol: "unsupported_protocol", + missing_base_url: "missing_base_url", + domain_mismatch: "domain_mismatch", + origin_domain_mismatch: "origin_domain_mismatch", + origin_identifier_host_mismatch: "origin_identifier_host_mismatch", + origin_actor_host_mismatch: "origin_actor_host_mismatch", + origin_stream_host_mismatch: "origin_stream_host_mismatch", + federation_origin_conflict: "federation_origin_conflict", + not_authorized_for_room: "not_authorized_for_room", + snapshot_unavailable: "snapshot_unavailable", + stream_recovery_failed: "stream_recovery_failed", + sequence_gap: "sequence_gap", + batch_limit_exceeded: "batch_limit_exceeded", + ephemeral_limit_exceeded: "ephemeral_limit_exceeded", + snapshot_limit_exceeded: "snapshot_limit_exceeded", + rate_limited: "rate_limited", + peer_quarantined: "peer_quarantined", + peer_blocked: "peer_blocked", + event_too_large: "event_too_large", + batch_too_large: "batch_too_large", + snapshot_too_large: "snapshot_too_large", + cursor_expired: "cursor_expired", + media_not_authorized: "media_not_authorized", + unsupported_transport_profile: "unsupported_transport_profile", + no_compatible_transport: "no_compatible_transport", + session_transport_unavailable: "session_transport_unavailable", + session_transport_failed: "session_transport_failed", + session_closed: "session_closed", + session_timeout: "session_timeout", + unsupported_event_type: "unsupported_event_type", + unsupported_operation: "unsupported_operation", + bootstrap_trust_not_verified: "bootstrap_trust_not_verified", + identity_anchor_mismatch: "identity_anchor_mismatch", + identity_dns_proof_failed: "identity_dns_proof_failed", + discovery_unavailable: "discovery_unavailable", + internal_error: "internal_error" + } + + @error_reasons Map.new(@error_codes, fn {reason, code} -> {code, reason} end) + + def error_code({:post_recovery_apply_failed, reason}), do: error_code(reason) + def error_code({:http_error, _status, _body}), do: "upstream_http_error" + def error_code({:http_error, _status}), do: "upstream_http_error" + def error_code({:unknown_peer, _domain}), do: "unknown_peer" + def error_code({:invalid_json, _reason}), do: "invalid_json" + def error_code({:session_connect_failed, _reason}), do: "session_transport_failed" + def error_code({:session_receive_failed, _reason}), do: "session_transport_failed" + def error_code({:session_handshake_failed, _reason}), do: "session_transport_failed" + def error_code({:invalid_discovery_fetcher_response, _other}), do: "invalid_discovery_response" + + def error_code(reason) when is_atom(reason) do + Map.get(@error_codes, reason, "internal_error") + end + + def error_code(_reason), do: "internal_error" + + def error_reason("upstream_http_error"), do: {:http_error, 502} + + def error_reason(code) when is_binary(code) do + Map.get(@error_reasons, code) + end + + def error_reason(_code), do: nil +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/event_router.ex b/apps/elektrine/lib/elektrine/messaging/federation/event_router.ex new file mode 100644 index 0000000..fd26120 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/event_router.ex @@ -0,0 +1,110 @@ +defmodule Elektrine.Messaging.Federation.EventRouter do + @moduledoc false + + alias Elektrine.Messaging.ArblargSDK + + alias Elektrine.Messaging.Federation.{ + Contexts, + DirectMessages, + ExtensionEvents, + MirrorEvents, + Peers, + RequestAuth, + Utils + } + + @bootstrap_server_upsert_event_type "urn:arblarg:ext:bootstrap:1#server.upsert" + @dm_message_create_event_type "urn:arblarg:ext:dm:1#message.create" + + def event_server_id(data) when is_map(data) do + refs = data["refs"] || %{} + get_in(data, ["server", "id"]) || refs["server_id"] + end + + def event_server_id(_data), do: nil + + def event_channel_id(data) when is_map(data) do + refs = data["refs"] || %{} + get_in(data, ["channel", "id"]) || refs["channel_id"] + end + + def event_channel_id(_data), do: nil + + def apply_event(@bootstrap_server_upsert_event_type, data, remote_domain) do + apply_event("server.upsert", data, remote_domain) + end + + def apply_event(@dm_message_create_event_type, data, remote_domain) do + apply_event("dm.message.create", data, remote_domain) + end + + def apply_event(event_type, data, remote_domain) when is_binary(event_type) do + canonical_event_type = ArblargSDK.canonical_event_type(event_type) + + handler_event_type = + Map.get(ArblargSDK.schema_bindings(), canonical_event_type, canonical_event_type) + + case DirectMessages.apply_event( + handler_event_type, + data, + remote_domain, + direct_message_context() + ) do + {:error, :unhandled_event_type} -> + case MirrorEvents.apply_event( + handler_event_type, + data, + remote_domain, + mirror_event_context() + ) do + {:error, :unhandled_event_type} -> + case ExtensionEvents.apply_event( + handler_event_type, + data, + remote_domain, + extension_event_context() + ) do + {:error, :unhandled_event_type} -> + {:error, :unsupported_event_type} + + result -> + result + end + + result -> + result + end + + result -> + result + end + end + + def normalize_incoming_event_payload(payload) when is_map(payload), do: payload + def normalize_incoming_event_payload(payload), do: payload + + defp direct_message_context, do: Contexts.direct_message() + + defp mirror_event_context do + Contexts.mirror_event(%{ + parse_datetime: &Utils.parse_datetime/1, + parse_int: &Utils.parse_int/2, + actor_context: &actor_context/0 + }) + end + + defp extension_event_context do + Contexts.extension_event(%{ + parse_datetime: &Utils.parse_datetime/1, + actor_context: &actor_context/0 + }) + end + + defp actor_context do + Contexts.actor(%{ + resolve_peer: &Peers.resolve_peer/1, + incoming_verification_materials_for_key_id: + &RequestAuth.incoming_verification_materials_for_key_id/2 + }) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/event_tracking.ex b/apps/elektrine/lib/elektrine/messaging/federation/event_tracking.ex new file mode 100644 index 0000000..5647543 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/event_tracking.ex @@ -0,0 +1,90 @@ +defmodule Elektrine.Messaging.Federation.EventTracking do + @moduledoc false + + import Ecto.Query, warn: false + import Elektrine.Messaging.Federation.Utils + + alias Elektrine.Messaging.{ArblargSDK, FederationEvent, FederationStreamPosition} + alias Elektrine.Repo + + def claim_event_id(payload, remote_domain) when is_map(payload) and is_binary(remote_domain) do + inserted_now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + received_now = DateTime.utc_now() |> DateTime.truncate(:second) + + protocol_version = payload["protocol_version"] || ArblargSDK.protocol_version() + idempotency_key = payload["idempotency_key"] || payload["event_id"] + + attrs = [ + %{ + protocol_version: protocol_version, + event_id: payload["event_id"], + idempotency_key: idempotency_key, + origin_domain: remote_domain, + event_type: ArblargSDK.canonical_event_type(payload["event_type"]), + stream_id: payload["stream_id"], + sequence: parse_int(payload["sequence"], 0), + payload: payload, + received_at: received_now, + inserted_at: inserted_now + } + ] + + {count, _} = Repo.insert_all(FederationEvent, attrs, on_conflict: :nothing) + + if count == 1 do + :new + else + :duplicate + end + end + + def check_sequence(payload, remote_domain) when is_map(payload) and is_binary(remote_domain) do + stream_id = payload["stream_id"] + incoming_sequence = parse_int(payload["sequence"], 0) + + position = + from(p in FederationStreamPosition, + where: p.origin_domain == ^remote_domain and p.stream_id == ^stream_id, + lock: "FOR UPDATE" + ) + |> Repo.one() + + last_sequence = + if position do + position.last_sequence + else + 0 + end + + cond do + incoming_sequence <= last_sequence -> :stale + incoming_sequence > last_sequence + 1 -> {:error, :sequence_gap} + true -> :ok + end + end + + def store_stream_position(remote_domain, stream_id, sequence) + when is_binary(remote_domain) and is_binary(stream_id) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + attrs = [ + %{ + origin_domain: remote_domain, + stream_id: stream_id, + last_sequence: parse_int(sequence, 0), + inserted_at: now, + updated_at: now + } + ] + + {_count, _} = + Repo.insert_all(FederationStreamPosition, attrs, + on_conflict: [set: [last_sequence: parse_int(sequence, 0), updated_at: now]], + conflict_target: [:origin_domain, :stream_id] + ) + + :ok + end + + def store_stream_position(_remote_domain, _stream_id, _sequence), do: :ok +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/extension_events.ex b/apps/elektrine/lib/elektrine/messaging/federation/extension_events.ex new file mode 100644 index 0000000..bfb14d4 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/extension_events.ex @@ -0,0 +1,512 @@ +defmodule Elektrine.Messaging.Federation.ExtensionEvents do + @moduledoc false + + import Ecto.Query, warn: false + + alias Elektrine.Messaging.FederationExtensionEvent + alias Elektrine.Repo + + def apply_event(event_type, data, remote_domain, context) + when is_binary(event_type) and is_map(data) and is_binary(remote_domain) and is_map(context) do + case event_type do + "role.upsert" -> + apply_role_upsert(data, remote_domain, context) + + "role.assignment.upsert" -> + apply_role_assignment_upsert(data, remote_domain, context) + + "permission.overwrite.upsert" -> + apply_permission_overwrite_upsert(data, remote_domain, context) + + "thread.upsert" -> + apply_thread_upsert(data, remote_domain, context) + + "thread.archive" -> + apply_thread_archive(data, remote_domain, context) + + "pin.upsert" -> + apply_pin_upsert(data, remote_domain, context) + + "moderation.action.recorded" -> + apply_moderation_action_recorded(data, remote_domain, context) + + _ -> + {:error, :unhandled_event_type} + end + end + + defp apply_role_upsert(data, remote_domain, context) do + with %{} <- data["server"], + %{} <- data["channel"], + %{} = actor_payload <- data["actor"], + %{} = role_payload <- data["role"], + role_id when is_binary(role_id) <- role_payload["id"], + role_name when is_binary(role_name) <- role_payload["name"], + {:ok, mirror_server, mirror_channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [actor_payload, remote_domain]), + :ok <- + authorize_governance_event( + data, + remote_domain, + mirror_channel, + remote_actor_id, + :role_upsert, + %{remote_actor_id: remote_actor_id}, + context + ), + event_key <- "role:#{role_id}:channel:#{mirror_channel.id}", + {:ok, _event_projection} <- + call(context, :upsert_extension_projection, [ + "role.upsert", + event_key, + data, + remote_domain, + mirror_server.id, + mirror_channel.id, + call(context, :parse_datetime, [role_payload["updated_at"]]) || DateTime.utc_now(), + nil + ]), + :ok <- + call(context, :upsert_extension_system_message, [ + mirror_channel, + "role.upsert", + event_key, + "Role updated: #{role_name}", + %{"event_type" => "role.upsert", "role" => role_payload}, + remote_domain + ]) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_role_assignment_upsert(data, remote_domain, context) do + with %{} <- data["server"], + %{} <- data["channel"], + %{} = actor_payload <- data["actor"], + %{} = assignment_payload <- data["assignment"], + role_id when is_binary(role_id) <- assignment_payload["role_id"], + %{} = target <- assignment_payload["target"], + target_type when is_binary(target_type) <- target["type"], + target_id when is_binary(target_id) <- target["id"], + state when is_binary(state) <- assignment_payload["state"], + {:ok, mirror_server, mirror_channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [actor_payload, remote_domain]), + :ok <- + authorize_governance_event( + data, + remote_domain, + mirror_channel, + remote_actor_id, + :role_assignment, + %{remote_actor_id: remote_actor_id}, + context + ), + event_key <- + "role_assignment:#{role_id}:#{target_type}:#{target_id}:channel:#{mirror_channel.id}", + {:ok, _event_projection} <- + call(context, :upsert_extension_projection, [ + "role.assignment.upsert", + event_key, + data, + remote_domain, + mirror_server.id, + mirror_channel.id, + DateTime.utc_now(), + nil + ]), + :ok <- + call(context, :upsert_extension_system_message, [ + mirror_channel, + "role.assignment.upsert", + event_key, + "Role #{state}: #{role_id} -> #{target_type}:#{target_id}", + %{"event_type" => "role.assignment.upsert", "assignment" => assignment_payload}, + remote_domain + ]) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_permission_overwrite_upsert(data, remote_domain, context) do + with %{} <- data["server"], + %{} <- data["channel"], + %{} = actor_payload <- data["actor"], + %{} = overwrite_payload <- data["overwrite"], + overwrite_id when is_binary(overwrite_id) <- overwrite_payload["id"], + %{} = target <- overwrite_payload["target"], + target_type when is_binary(target_type) <- target["type"], + target_id when is_binary(target_id) <- target["id"], + {:ok, mirror_server, mirror_channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [actor_payload, remote_domain]), + :ok <- + authorize_governance_event( + data, + remote_domain, + mirror_channel, + remote_actor_id, + :permission_overwrite, + %{remote_actor_id: remote_actor_id}, + context + ), + event_key <- "overwrite:#{overwrite_id}:channel:#{mirror_channel.id}", + {:ok, _event_projection} <- + call(context, :upsert_extension_projection, [ + "permission.overwrite.upsert", + event_key, + data, + remote_domain, + mirror_server.id, + mirror_channel.id, + DateTime.utc_now(), + nil + ]), + :ok <- + call(context, :upsert_extension_system_message, [ + mirror_channel, + "permission.overwrite.upsert", + event_key, + "Permissions updated for #{target_type}:#{target_id}", + %{ + "event_type" => "permission.overwrite.upsert", + "overwrite" => overwrite_payload + }, + remote_domain + ]) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_thread_upsert(data, remote_domain, context) do + with %{} <- data["server"], + %{} <- data["channel"], + %{} = thread_payload <- data["thread"], + thread_id when is_binary(thread_id) <- thread_payload["id"], + thread_name when is_binary(thread_name) <- thread_payload["name"], + thread_state when is_binary(thread_state) <- thread_payload["state"], + %{} = owner_payload <- thread_payload["owner"], + {:ok, mirror_server, mirror_channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [owner_payload, remote_domain]), + :ok <- + authorize_governance_event( + data, + remote_domain, + mirror_channel, + remote_actor_id, + :thread_upsert, + %{remote_actor_id: remote_actor_id, owner_remote_actor_id: remote_actor_id}, + context + ), + event_key <- "thread:#{thread_id}:channel:#{mirror_channel.id}", + {:ok, event_projection} <- + call(context, :upsert_extension_projection, [ + "thread.upsert", + event_key, + data, + remote_domain, + mirror_server.id, + mirror_channel.id, + DateTime.utc_now(), + thread_state + ]), + # Project the winning extension state into a local chat_threads row so + # the thread is visible in the chat UI alongside the event storage. + :ok <- + Elektrine.Messaging.ChatThreads.apply_remote_thread_projection( + mirror_channel, + event_projection + ), + :ok <- + call(context, :upsert_extension_system_message, [ + mirror_channel, + "thread.upsert", + event_key, + "Thread #{thread_state}: #{thread_name}", + %{"event_type" => "thread.upsert", "thread" => thread_payload}, + remote_domain + ]) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_thread_archive(data, remote_domain, context) do + with %{} <- data["server"], + %{} <- data["channel"], + thread_id when is_binary(thread_id) <- data["thread_id"], + %{} = actor_payload <- data["actor"], + archived_at <- + call(context, :parse_datetime, [data["archived_at"]]) || DateTime.utc_now(), + {:ok, mirror_server, mirror_channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + thread_owner_remote_actor_id <- + resolve_thread_owner_remote_actor_id( + mirror_channel.id, + thread_id, + remote_domain, + context + ), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [actor_payload, remote_domain]), + :ok <- + authorize_governance_event( + data, + remote_domain, + mirror_channel, + remote_actor_id, + :thread_archive, + %{ + remote_actor_id: remote_actor_id, + thread_owner_remote_actor_id: thread_owner_remote_actor_id + }, + context + ), + event_key <- "thread:#{thread_id}:channel:#{mirror_channel.id}", + {:ok, event_projection} <- + call(context, :upsert_extension_projection, [ + "thread.archive", + event_key, + data, + remote_domain, + mirror_server.id, + mirror_channel.id, + archived_at, + "archived" + ]), + # Keep the local chat_threads projection in sync with the archive. + :ok <- + Elektrine.Messaging.ChatThreads.apply_remote_thread_projection( + mirror_channel, + event_projection + ), + :ok <- + call(context, :upsert_extension_system_message, [ + mirror_channel, + "thread.archive", + event_key, + "Thread archived: #{thread_id}", + %{ + "event_type" => "thread.archive", + "thread_id" => thread_id, + "archived_at" => DateTime.to_iso8601(archived_at), + "reason" => data["reason"] + }, + remote_domain + ]) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_pin_upsert(data, remote_domain, context) do + with %{} <- data["server"], + %{} <- data["channel"], + %{} = pin_payload <- data["pin"], + message_id when is_binary(message_id) <- pin_payload["message_id"], + pin_state when pin_state in ["pinned", "unpinned"] <- pin_payload["state"], + %{} = actor_payload <- data["actor"], + occurred_at <- + call(context, :parse_datetime, [ + pin_payload["updated_at"] || pin_payload["pinned_at"] + ]) || DateTime.utc_now(), + {:ok, mirror_server, mirror_channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [actor_payload, remote_domain]), + :ok <- + authorize_governance_event( + data, + remote_domain, + mirror_channel, + remote_actor_id, + :manage_messages, + %{remote_actor_id: remote_actor_id}, + context + ), + event_key <- "pin:#{message_id}:channel:#{mirror_channel.id}", + {:ok, event_projection} <- + call(context, :upsert_extension_projection, [ + "pin.upsert", + event_key, + data, + remote_domain, + mirror_server.id, + mirror_channel.id, + occurred_at, + pin_state + ]), + # Project the winning pin state into the local chat_message_pins rows. + :ok <- + Elektrine.Messaging.ChatMessagePins.apply_remote_pin_projection( + mirror_channel, + event_projection + ) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_moderation_action_recorded(data, remote_domain, context) do + with %{} <- data["server"], + %{} <- data["channel"], + %{} = action_payload <- data["action"], + action_id when is_binary(action_id) <- action_payload["id"], + action_kind when is_binary(action_kind) <- action_payload["kind"], + target when is_map(target) <- action_payload["target"], + target_type when is_binary(target_type) <- target["type"], + target_id when is_binary(target_id) <- target["id"], + %{} = actor_payload <- action_payload["actor"], + occurred_at <- + call(context, :parse_datetime, [action_payload["occurred_at"]]) || DateTime.utc_now(), + {:ok, mirror_server, mirror_channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [actor_payload, remote_domain]), + :ok <- + authorize_governance_event( + data, + remote_domain, + mirror_channel, + remote_actor_id, + :moderation_action, + %{remote_actor_id: remote_actor_id, kind: action_kind}, + context + ), + event_key <- "moderation:#{action_id}:channel:#{mirror_channel.id}", + {:ok, _event_projection} <- + call(context, :upsert_extension_projection, [ + "moderation.action.recorded", + event_key, + data, + remote_domain, + mirror_server.id, + mirror_channel.id, + occurred_at, + action_kind + ]), + :ok <- + call(context, :upsert_extension_system_message, [ + mirror_channel, + "moderation.action.recorded", + event_key, + "Moderation action (#{action_kind}) on #{target_type}:#{target_id}", + %{ + "event_type" => "moderation.action.recorded", + "action" => action_payload + }, + remote_domain + ]) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp normalize_event_error({:error, _reason} = error), do: error + defp normalize_event_error(:ok), do: :ok + defp normalize_event_error(_error), do: {:error, :invalid_event_payload} + + defp authorize_governance_event( + data, + remote_domain, + mirror_channel, + remote_actor_id, + action, + options, + context + ) + when is_map(data) and is_binary(remote_domain) and is_map(mirror_channel) and + is_integer(remote_actor_id) and is_atom(action) and is_map(options) and + is_map(context) do + case call(context, :ensure_authoritative_channel_event_context, [data, remote_domain]) do + {:ok, _server, authoritative_channel} -> + call(context, :ensure_remote_actor_governance_permission, [ + authoritative_channel, + remote_actor_id, + action, + options + ]) + + _error -> + call(context, :ensure_remote_actor_governance_permission, [ + mirror_channel, + remote_actor_id, + action, + options + ]) + end + end + + defp authorize_governance_event( + _data, + _remote_domain, + _mirror_channel, + _remote_actor_id, + _action, + _options, + _context + ), + do: {:error, :not_authorized_for_room} + + defp resolve_thread_owner_remote_actor_id(conversation_id, thread_id, remote_domain, context) + when is_integer(conversation_id) and is_binary(thread_id) and is_binary(remote_domain) and + is_map(context) do + case thread_owner_actor_payload(conversation_id, thread_id) do + %{} = owner_payload -> + case call(context, :resolve_or_create_remote_actor_id, [owner_payload, remote_domain]) do + {:ok, remote_actor_id} -> remote_actor_id + _ -> nil + end + + _ -> + nil + end + end + + defp resolve_thread_owner_remote_actor_id( + _conversation_id, + _thread_id, + _remote_domain, + _context + ), + do: nil + + defp thread_owner_actor_payload(conversation_id, thread_id) + when is_integer(conversation_id) and is_binary(thread_id) do + event_type = Elektrine.Messaging.ArblargSDK.canonical_event_type("thread.upsert") + event_key = "thread:#{thread_id}:channel:#{conversation_id}" + + from(event in FederationExtensionEvent, + where: + event.conversation_id == ^conversation_id and event.event_type == ^event_type and + event.event_key == ^event_key, + select: fragment("?->'thread'->'owner'", event.payload), + limit: 1 + ) + |> Repo.one() + end + + defp thread_owner_actor_payload(_conversation_id, _thread_id), do: nil + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/inbound.ex b/apps/elektrine/lib/elektrine/messaging/federation/inbound.ex new file mode 100644 index 0000000..54d16f6 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/inbound.ex @@ -0,0 +1,271 @@ +defmodule Elektrine.Messaging.Federation.Inbound do + @moduledoc false + + alias Elektrine.Messaging.ArblargSDK + + @presence_update_event_type "urn:arblarg:ext:presence:1#presence.update" + @typing_start_event_type "urn:arblarg:ext:presence:1#typing.start" + @typing_stop_event_type "urn:arblarg:ext:presence:1#typing.stop" + @dm_call_signal_event_type "urn:arblarg:ext:voice:1#dm.call.signal" + + def normalize_incoming_batch_payload(%{"events" => events} = payload, context) + when is_list(events) and is_map(context) do + if length(events) <= call(context, :incoming_batch_limit, []) do + batch_id = + call(context, :normalize_optional_string, [payload["batch_id"]]) || Ecto.UUID.generate() + + {:ok, batch_id, events} + else + {:error, :batch_limit_exceeded} + end + end + + def normalize_incoming_batch_payload(events, context) + when is_list(events) and is_map(context) do + if length(events) <= call(context, :incoming_batch_limit, []) do + {:ok, Ecto.UUID.generate(), events} + else + {:error, :batch_limit_exceeded} + end + end + + def normalize_incoming_batch_payload(_payload, _context), do: {:error, :invalid_payload} + + def normalize_incoming_ephemeral_payload(%{"items" => items} = payload, context) + when is_list(items) and is_map(context) do + if length(items) <= call(context, :incoming_ephemeral_limit, []) do + batch_id = + call(context, :normalize_optional_string, [payload["batch_id"]]) || Ecto.UUID.generate() + + {:ok, batch_id, items} + else + {:error, :ephemeral_limit_exceeded} + end + end + + def normalize_incoming_ephemeral_payload(items, context) + when is_list(items) and is_map(context) do + if length(items) <= call(context, :incoming_ephemeral_limit, []) do + {:ok, Ecto.UUID.generate(), items} + else + {:error, :ephemeral_limit_exceeded} + end + end + + def normalize_incoming_ephemeral_payload(_payload, _context), do: {:error, :invalid_payload} + + def normalize_session_stream_batch_payload(payload, expected_delivery_id, context) + when is_map(payload) and is_map(context) do + events = payload["events"] + delivery_id = call(context, :normalize_optional_string, [payload["delivery_id"]]) + stream_id = call(context, :normalize_optional_string, [payload["stream_id"]]) + version = call(context, :parse_int, [payload["version"], 0]) + + cond do + version != 1 -> + {:error, :unsupported_version} + + !is_list(events) -> + {:error, :invalid_payload} + + events == [] -> + {:error, :invalid_payload} + + length(events) > call(context, :incoming_batch_limit, []) -> + {:error, :batch_limit_exceeded} + + !is_binary(delivery_id) -> + {:error, :invalid_payload} + + is_binary(expected_delivery_id) and delivery_id != expected_delivery_id -> + {:error, :invalid_payload} + + !is_binary(stream_id) -> + {:error, :invalid_payload} + + Enum.any?( + events, + &(call(context, :normalize_optional_string, [&1["stream_id"]]) != stream_id) + ) -> + {:error, :invalid_payload} + + true -> + {:ok, delivery_id, events} + end + end + + def normalize_session_stream_batch_payload(_payload, _expected_delivery_id, _context), + do: {:error, :invalid_payload} + + def normalize_session_ephemeral_batch_payload(payload, expected_delivery_id, context) + when is_map(payload) and is_map(context) do + items = payload["items"] + delivery_id = call(context, :normalize_optional_string, [payload["delivery_id"]]) + version = call(context, :parse_int, [payload["version"], 0]) + + cond do + version != 1 -> + {:error, :unsupported_version} + + !is_list(items) -> + {:error, :invalid_payload} + + items == [] -> + {:error, :invalid_payload} + + length(items) > call(context, :incoming_ephemeral_limit, []) -> + {:error, :ephemeral_limit_exceeded} + + !is_binary(delivery_id) -> + {:error, :invalid_payload} + + is_binary(expected_delivery_id) and delivery_id != expected_delivery_id -> + {:error, :invalid_payload} + + true -> + {:ok, delivery_id, items} + end + end + + def normalize_session_ephemeral_batch_payload(_payload, _expected_delivery_id, _context), + do: {:error, :invalid_payload} + + def process_incoming_event_result(event, remote_domain, context) + when is_map(event) and is_binary(remote_domain) and is_map(context) do + case call(context, :receive_event, [event, remote_domain]) do + {:ok, result} -> + %{"event_id" => event["event_id"], "status" => Atom.to_string(result)} + + {:error, :sequence_gap} -> + recovery_result = + case call(context, :recover_sequence_gap, [event, remote_domain]) do + {:ok, :recovered_via_stream} -> "recovered_via_stream" + {:ok, :recovered} -> "recovered_via_snapshot" + {:ok, :recovered_via_snapshot} -> "recovered_via_snapshot" + {:error, reason} -> error_result(event["event_id"], reason, context) + end + + case recovery_result do + %{} = result -> result + status -> %{"event_id" => event["event_id"], "status" => status} + end + + {:error, reason} -> + error_result(event["event_id"], reason, context) + end + end + + def process_incoming_ephemeral_result(item, remote_domain, context) + when is_map(item) and is_binary(remote_domain) and is_map(context) do + item_payload = item["payload"] || %{} + event_type = ArblargSDK.canonical_event_type(item["event_type"]) + + result = + cond do + !is_binary(item["event_type"]) -> + {:error, :invalid_payload} + + call(context, :normalize_optional_string, [item["origin_domain"]]) not in [ + nil, + remote_domain + ] -> + {:error, :origin_domain_mismatch} + + event_type not in [ + @presence_update_event_type, + "presence.update", + @typing_start_event_type, + "typing.start", + @typing_stop_event_type, + "typing.stop", + @dm_call_signal_event_type, + "dm.call.signal" + ] -> + {:error, :unsupported_event_type} + + true -> + case ArblargSDK.validate_event_payload(event_type, item_payload) do + :ok -> + with :ok <- + call(context, :validate_origin_bound_actors_in_event_data, [ + event_type, + item_payload, + remote_domain + ]), + :ok <- + call(context, :validate_origin_owned_identifiers_in_event_data, [ + event_type, + item_payload, + remote_domain + ]) do + call(context, :apply_event, [event_type, item_payload, remote_domain]) + end + + {:error, reason} -> + {:error, reason} + end + end + + case result do + :ok -> + %{"event_type" => item["event_type"], "status" => "applied"} + + {:error, reason} -> + error_result(item["event_type"], reason, context, "event_type") + end + end + + def batch_summary(batch_id, results) when is_list(results) do + %{ + "version" => 1, + "batch_id" => batch_id, + "event_count" => length(results), + "counts" => summarize_result_counts(results), + "error_counts" => summarize_result_errors(results), + "results" => results + } + end + + def batch_summary(batch_id, _results) do + %{ + "version" => 1, + "batch_id" => batch_id, + "event_count" => 0, + "counts" => %{}, + "error_counts" => %{}, + "results" => [] + } + end + + defp summarize_result_counts(results) do + Enum.reduce(results, %{}, fn result, acc -> + Map.update(acc, result["status"], 1, &(&1 + 1)) + end) + end + + defp summarize_result_errors(results) do + Enum.reduce(results, %{}, fn result, acc -> + case result["code"] do + code when is_binary(code) -> + Map.update(acc, code, 1, &(&1 + 1)) + + _ -> + acc + end + end) + end + + defp error_result(identifier, reason, context, identifier_key \\ "event_id") do + %{ + identifier_key => identifier, + "status" => "error", + "code" => call(context, :error_code, [reason]) + } + end + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/ingress.ex b/apps/elektrine/lib/elektrine/messaging/federation/ingress.ex new file mode 100644 index 0000000..dab2c3d --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/ingress.ex @@ -0,0 +1,362 @@ +defmodule Elektrine.Messaging.Federation.Ingress do + @moduledoc false + + import Elektrine.Messaging.Federation.Utils + + alias Elektrine.Messaging.{ArblargSDK, Server} + + alias Elektrine.Messaging.Federation.{ + Contexts, + Errors, + EventRouter, + EventTracking, + Inbound, + Peers, + RequestAuth, + Snapshots, + Validation + } + + alias Elektrine.Repo + + def build_server_snapshot(server_id, opts \\ []) do + Snapshots.build_server_snapshot(server_id, opts, snapshot_context()) + end + + def import_server_snapshot(payload, remote_domain) when is_binary(remote_domain) do + import_server_snapshot(payload, remote_domain, snapshot_context()) + end + + def import_server_snapshot(payload, remote_domain, snapshot_context) + when is_binary(remote_domain) do + Snapshots.import_server_snapshot(payload, remote_domain, snapshot_context) + end + + def receive_event(payload, remote_domain) when is_binary(remote_domain) do + receive_event(payload, remote_domain, ingress_context()) + end + + def receive_event(payload, remote_domain, context) + when is_binary(remote_domain) and is_map(context) do + with :ok <- call(context, :validate_event_payload, [payload, remote_domain]) do + payload = call(context, :normalize_incoming_event_payload, [payload]) + + Repo.transaction(fn -> + case call(context, :claim_event_id, [payload, remote_domain]) do + :duplicate -> + :duplicate + + :new -> + case call(context, :check_sequence, [payload, remote_domain]) do + :stale -> + :stale + + :ok -> + event_type = ArblargSDK.canonical_event_type(payload["event_type"]) + + with :ok <- + call(context, :apply_event, [ + event_type, + payload["payload"] || %{}, + remote_domain + ]), + :ok <- + call(context, :store_stream_position, [ + remote_domain, + payload["stream_id"], + payload["sequence"] + ]) do + :applied + else + {:error, reason} -> Repo.rollback(reason) + end + + {:error, reason} -> + Repo.rollback(reason) + end + end + end) + |> case do + {:ok, result} when result in [:applied, :duplicate, :stale] -> {:ok, result} + {:error, reason} -> {:error, reason} + end + end + end + + def receive_event_batch(payload, remote_domain) when is_binary(remote_domain) do + receive_event_batch(payload, remote_domain, ingress_context()) + end + + def receive_event_batch(payload, remote_domain, context) + when is_binary(remote_domain) and is_map(context) do + with {:ok, batch_id, events} <- call(context, :normalize_incoming_batch_payload, [payload]) do + results = + Enum.map(events, fn event -> + call(context, :process_incoming_event_result, [event, remote_domain]) + end) + + {:ok, call(context, :batch_summary, [batch_id, results])} + end + end + + def receive_ephemeral_batch(payload, remote_domain) when is_binary(remote_domain) do + receive_ephemeral_batch(payload, remote_domain, ingress_context()) + end + + def receive_ephemeral_batch(payload, remote_domain, context) + when is_binary(remote_domain) and is_map(context) do + with {:ok, batch_id, items} <- + call(context, :normalize_incoming_ephemeral_payload, [payload]) do + results = + Enum.map(items, fn item -> + call(context, :process_incoming_ephemeral_result, [item, remote_domain]) + end) + + {:ok, call(context, :batch_summary, [batch_id, results])} + end + end + + def receive_session_stream_batch(payload, remote_domain, frame_delivery_id \\ nil) + + def receive_session_stream_batch(payload, remote_domain, frame_delivery_id) + when is_binary(remote_domain) do + receive_session_stream_batch(payload, remote_domain, frame_delivery_id, ingress_context()) + end + + def receive_session_stream_batch(payload, remote_domain, frame_delivery_id, context) + when is_binary(remote_domain) and is_map(context) do + with {:ok, delivery_id, events} <- + call(context, :normalize_session_stream_batch_payload, [ + payload, + frame_delivery_id + ]), + results <- + Enum.map(events, &call(context, :process_incoming_event_result, [&1, remote_domain])) do + {:ok, call(context, :batch_summary, [delivery_id, results])} + end + end + + def receive_session_ephemeral_batch(payload, remote_domain, frame_delivery_id \\ nil) + + def receive_session_ephemeral_batch(payload, remote_domain, frame_delivery_id) + when is_binary(remote_domain) do + receive_session_ephemeral_batch(payload, remote_domain, frame_delivery_id, ingress_context()) + end + + def receive_session_ephemeral_batch(payload, remote_domain, frame_delivery_id, context) + when is_binary(remote_domain) and is_map(context) do + with {:ok, delivery_id, items} <- + call(context, :normalize_session_ephemeral_batch_payload, [ + payload, + frame_delivery_id + ]), + results <- + Enum.map( + items, + &call(context, :process_incoming_ephemeral_result, [&1, remote_domain]) + ) do + {:ok, call(context, :batch_summary, [delivery_id, results])} + end + end + + def export_stream_events(stream_id, opts \\ []) + + def export_stream_events(stream_id, opts) when is_binary(stream_id) do + export_stream_events(stream_id, opts, snapshot_context()) + end + + def export_stream_events(stream_id, opts, snapshot_context) when is_binary(stream_id) do + Snapshots.export_stream_events(stream_id, opts, snapshot_context) + end + + def recover_sequence_gap(payload, remote_domain) when is_binary(remote_domain) do + recover_sequence_gap(payload, remote_domain, snapshot_context()) + end + + def recover_sequence_gap(payload, remote_domain, snapshot_context) + when is_binary(remote_domain) do + Snapshots.recover_sequence_gap(payload, remote_domain, snapshot_context) + end + + def refresh_mirror_server_snapshot(%Server{} = server) do + refresh_mirror_server_snapshot(server, snapshot_context()) + end + + def refresh_mirror_server_snapshot(%Server{} = server, snapshot_context) do + Snapshots.refresh_mirror_server_snapshot(server, snapshot_context) + end + + def refresh_mirror_server_snapshot(_server, _snapshot_context), + do: {:error, :not_federated_mirror} + + def push_snapshot_to_peer(peer, snapshot) do + Snapshots.push_snapshot_to_peer(peer, snapshot, snapshot_context()) + end + + def push_snapshot_to_peer(peer, snapshot, context) when is_map(context) do + Snapshots.push_snapshot_to_peer(peer, snapshot, context) + end + + defp validate_snapshot_governance_payload(event_type, payload, remote_domain) + when is_binary(event_type) and is_map(payload) and is_binary(remote_domain) do + Validation.validate_snapshot_governance_payload( + event_type, + payload, + remote_domain, + validation_context() + ) + end + + defp validate_snapshot_governance_payload(_event_type, _payload, _remote_domain), + do: {:error, :invalid_snapshot_governance} + + defp snapshot_governance_entries(governance) do + Snapshots.snapshot_governance_entries(governance, snapshot_context()) + end + + defp validate_snapshot_payload(payload, remote_domain) do + Validation.validate_snapshot_payload(payload, remote_domain, validation_context()) + end + + defp validate_event_payload(payload, remote_domain) do + Validation.validate_event_payload(payload, remote_domain, validation_context()) + end + + defp validate_origin_bound_actors_in_event_data(event_type, data, remote_domain) do + Validation.validate_origin_bound_actors_in_event_data( + event_type, + data, + remote_domain, + validation_context() + ) + end + + defp validate_origin_owned_identifiers_in_event_data(event_type, data, remote_domain) do + Validation.validate_origin_owned_identifiers_in_event_data( + event_type, + data, + remote_domain, + validation_context() + ) + end + + defp validation_context do + Contexts.validation(%{ + normalize_incoming_event_payload: &EventRouter.normalize_incoming_event_payload/1, + incoming_peer: &Peers.incoming_peer/1, + incoming_verification_materials_for_key_id: + &RequestAuth.incoming_verification_materials_for_key_id/2, + event_server_id: &EventRouter.event_server_id/1, + event_channel_id: &EventRouter.event_channel_id/1, + snapshot_governance_entries: &snapshot_governance_entries/1, + snapshot_signature_payload: &Snapshots.snapshot_signature_payload/1 + }) + end + + defp snapshot_context do + Contexts.snapshot(%{ + parse_int: &parse_int/2, + channel_payload: &channel_payload/1, + message_payload: &message_payload/2, + server_payload: &server_payload/1, + event_refs_payload: &event_refs_payload/2, + sender_payload: &sender_payload/1, + format_created_at: &format_created_at/1, + maybe_iso8601: &maybe_iso8601/1, + normalize_optional_string: &normalize_optional_string/1, + validate_snapshot_payload: &validate_snapshot_payload/2, + validate_snapshot_governance_payload: &validate_snapshot_governance_payload/3, + truncate: &truncate/1, + apply_event: &EventRouter.apply_event/3, + signed_headers: &RequestAuth.signed_headers/5, + peer_supports_event_type: + &Elektrine.Messaging.Federation.Transport.peer_supports_event_type?/2, + infer_remote_server_id_from_federation_id: &infer_remote_server_id_from_federation_id/1, + outgoing_peer: &Peers.outgoing_peer/1, + incoming_peer: &Peers.incoming_peer/1, + infer_remote_server_id: &infer_remote_server_id/1, + receive_event: &__MODULE__.receive_event/2, + server_stream_id: &server_stream_id/1, + channel_stream_id: &channel_stream_id/1 + }) + end + + defp inbound_context do + Contexts.inbound(%{ + normalize_optional_string: &normalize_optional_string/1, + parse_int: &parse_int/2, + receive_event: &__MODULE__.receive_event/2, + recover_sequence_gap: &__MODULE__.recover_sequence_gap/2, + error_code: &Errors.error_code/1, + validate_origin_bound_actors_in_event_data: &validate_origin_bound_actors_in_event_data/3, + validate_origin_owned_identifiers_in_event_data: + &validate_origin_owned_identifiers_in_event_data/3, + apply_event: &EventRouter.apply_event/3 + }) + end + + defp ingress_context do + %{ + validate_event_payload: &validate_event_payload/2, + normalize_incoming_event_payload: &EventRouter.normalize_incoming_event_payload/1, + claim_event_id: &EventTracking.claim_event_id/2, + check_sequence: &EventTracking.check_sequence/2, + apply_event: &EventRouter.apply_event/3, + store_stream_position: &EventTracking.store_stream_position/3, + normalize_incoming_batch_payload: &normalize_incoming_batch_payload/1, + normalize_incoming_ephemeral_payload: &normalize_incoming_ephemeral_payload/1, + normalize_session_stream_batch_payload: &normalize_session_stream_batch_payload/2, + normalize_session_ephemeral_batch_payload: &normalize_session_ephemeral_batch_payload/2, + process_incoming_event_result: &process_incoming_event_result/2, + process_incoming_ephemeral_result: &process_incoming_ephemeral_result/2, + batch_summary: &Inbound.batch_summary/2 + } + end + + defp normalize_incoming_batch_payload(payload) do + Inbound.normalize_incoming_batch_payload(payload, inbound_context()) + end + + defp normalize_incoming_ephemeral_payload(payload) do + Inbound.normalize_incoming_ephemeral_payload(payload, inbound_context()) + end + + defp normalize_session_stream_batch_payload(payload, expected_delivery_id) do + Inbound.normalize_session_stream_batch_payload( + payload, + expected_delivery_id, + inbound_context() + ) + end + + defp normalize_session_ephemeral_batch_payload(payload, expected_delivery_id) do + Inbound.normalize_session_ephemeral_batch_payload( + payload, + expected_delivery_id, + inbound_context() + ) + end + + defp process_incoming_event_result(event, remote_domain) do + Inbound.process_incoming_event_result(event, remote_domain, inbound_context()) + end + + defp process_incoming_ephemeral_result(item, remote_domain) do + Inbound.process_incoming_ephemeral_result(item, remote_domain, inbound_context()) + end + + defp maybe_iso8601(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime) + defp maybe_iso8601(%NaiveDateTime{} = datetime), do: NaiveDateTime.to_iso8601(datetime) + defp maybe_iso8601(_value), do: nil + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_value), do: nil + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/ingress_rate_limiter.ex b/apps/elektrine/lib/elektrine/messaging/federation/ingress_rate_limiter.ex new file mode 100644 index 0000000..1b7bd29 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/ingress_rate_limiter.ex @@ -0,0 +1,158 @@ +defmodule Elektrine.Messaging.Federation.IngressRateLimiter do + @moduledoc """ + Per-peer and per-room rate limiting for federation ingress (arblarg 9.4). + + Fixed one-minute windows tracked in ETS keyed on the verified peer domain, + so checks are a single `:ets.update_counter/4` on the hot path. Buckets: + + * `{:peer, domain, lane}` for the durable, ephemeral, sync (sync + + snapshot), and replay lanes + * `{:room, domain, stream_id}` for durable events per origin stream + + Batches are reserved atomically up front: when a batch alone exceeds the + remaining capacity of any bucket the whole batch is rejected and already + reserved costs are rolled back, so rejected traffic does not consume budget. + + Checks return `:ok` or `{:error, :rate_limited, retry_after_seconds}` where + `retry_after_seconds` is the time until the current window resets. + """ + + use GenServer + + alias Elektrine.Messaging.Federation.{Config, Runtime} + + @table __MODULE__ + @window_seconds 60 + @cleanup_interval_ms 60_000 + + def start_link(_opts) do + GenServer.start_link(__MODULE__, %{}, name: __MODULE__) + end + + @doc """ + Checks a request-lane limit for a verified peer domain. + + Lanes: `:durable`, `:ephemeral`, `:sync`, `:replay`. `cost` is the number + of items the request carries (batch size for batched lanes). + Accepts `now: unix_seconds` in `opts` for deterministic tests. + """ + def check_peer(domain, lane, cost \\ 1, opts \\ []) + + def check_peer(domain, lane, cost, opts) + when is_binary(domain) and is_atom(lane) and is_integer(cost) and cost >= 0 do + check_buckets(domain, [{{:peer, domain, lane}, cost, peer_limit(lane)}], opts) + end + + def check_peer(_domain, _lane, _cost, _opts), do: :ok + + @doc """ + Checks the durable-event lanes for a batch of events from a verified peer. + + `stream_ids` carries one entry per event (entries that are not binaries + still count against the per-peer bucket but skip per-room accounting). + Reserves the per-peer durable bucket plus a per-room bucket for each + `(domain, stream_id)` pair; rejects the whole batch when any bucket lacks + capacity. Accepts `now: unix_seconds` in `opts` for deterministic tests. + """ + def check_durable(domain, stream_ids, opts \\ []) + + def check_durable(domain, stream_ids, opts) when is_binary(domain) and is_list(stream_ids) do + room_limit = config_value(:ingress_room_durable_events_per_minute) + + room_buckets = + stream_ids + |> Enum.filter(&is_binary/1) + |> Enum.frequencies() + |> Enum.map(fn {stream_id, cost} -> {{:room, domain, stream_id}, cost, room_limit} end) + + buckets = [{{:peer, domain, :durable}, length(stream_ids), peer_limit(:durable)}] + + check_buckets(domain, buckets ++ room_buckets, opts) + end + + def check_durable(_domain, _stream_ids, _opts), do: :ok + + @doc "Clears all rate limit state (test support)." + def reset do + :ets.delete_all_objects(@table) + :ok + end + + # GenServer callbacks + + @impl true + def init(state) do + :ets.new(@table, [:named_table, :public, :set, write_concurrency: true]) + :timer.send_interval(@cleanup_interval_ms, self(), :cleanup) + {:ok, state} + end + + @impl true + def handle_info(:cleanup, state) do + cutoff = window_index(System.system_time(:second)) - 1 + + :ets.select_delete(@table, [ + {{{:_, :"$1"}, :_}, [{:<, :"$1", cutoff}], [true]} + ]) + + {:noreply, state} + end + + # Internal helpers + + defp check_buckets(domain, buckets, opts) do + if enabled?() and not exempt_domain?(domain) do + now = Keyword.get(opts, :now, System.system_time(:second)) + reserve_all(buckets, window_index(now), [], now) + else + :ok + end + end + + defp reserve_all([], _window, _taken, _now), do: :ok + + defp reserve_all([{key, cost, limit} | rest], window, taken, now) do + if reserve(key, window, cost, limit) do + reserve_all(rest, window, [{key, cost} | taken], now) + else + Enum.each(taken, fn {taken_key, taken_cost} -> + :ets.update_counter(@table, {taken_key, window}, {2, -taken_cost}) + end) + + {:error, :rate_limited, retry_after_seconds(now)} + end + end + + defp reserve(key, window, cost, limit) do + counter_key = {key, window} + count = :ets.update_counter(@table, counter_key, {2, cost}, {counter_key, 0}) + + if count > limit do + :ets.update_counter(@table, counter_key, {2, -cost}) + false + else + true + end + end + + defp window_index(now), do: div(now, @window_seconds) + + defp retry_after_seconds(now), do: @window_seconds - rem(now, @window_seconds) + + defp peer_limit(:durable), do: config_value(:ingress_peer_durable_events_per_minute) + defp peer_limit(:ephemeral), do: config_value(:ingress_peer_ephemeral_items_per_minute) + defp peer_limit(:sync), do: config_value(:ingress_peer_sync_requests_per_minute) + defp peer_limit(:replay), do: config_value(:ingress_peer_replay_requests_per_minute) + + defp config_value(key), do: Config.ingress_rate_limit(Runtime.federation_config(), key) + + defp enabled? do + Config.ingress_rate_limit_enabled?(Runtime.federation_config()) + end + + defp exempt_domain?(domain) do + domain + |> String.downcase() + |> then(&(&1 in Config.ingress_rate_limit_exempt_domains(Runtime.federation_config()))) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/maintenance.ex b/apps/elektrine/lib/elektrine/messaging/federation/maintenance.ex new file mode 100644 index 0000000..b6682d5 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/maintenance.ex @@ -0,0 +1,60 @@ +defmodule Elektrine.Messaging.Federation.Maintenance do + @moduledoc false + + import Ecto.Query, warn: false + + alias Elektrine.Messaging.{ + FederationEvent, + FederationOutboxEvent, + FederationRequestReplay + } + + alias Elektrine.Messaging.Federation.Config + alias Elektrine.Repo + + def run_retention(config) when is_list(config) do + archive_old_events(config) + prune_old_outbox_rows(config) + prune_request_replays() + :ok + end + + defp archive_old_events(config) do + cutoff = + DateTime.utc_now() + |> DateTime.add(-Config.event_retention_days(config) * 86_400, :second) + |> DateTime.truncate(:second) + + sql = + "INSERT INTO messaging_federation_events_archive (\n protocol_version,\n event_id,\n idempotency_key,\n origin_domain,\n event_type,\n stream_id,\n sequence,\n payload,\n received_at,\n partition_month,\n inserted_at\n)\nSELECT\n protocol_version,\n event_id,\n idempotency_key,\n origin_domain,\n event_type,\n stream_id,\n sequence,\n payload,\n received_at,\n date_trunc('month', inserted_at)::date,\n inserted_at\nFROM messaging_federation_events\nWHERE inserted_at < $1\nON CONFLICT (origin_domain, event_id) DO NOTHING\n" + + _ = Ecto.Adapters.SQL.query(Repo, sql, [cutoff]) + {_deleted, _} = Repo.delete_all(from(e in FederationEvent, where: e.inserted_at < ^cutoff)) + :ok + end + + defp prune_old_outbox_rows(config) do + cutoff = + DateTime.utc_now() + |> DateTime.add(-Config.outbox_retention_days(config) * 86_400, :second) + |> DateTime.truncate(:second) + + {_deleted, _} = + Repo.delete_all( + from(o in FederationOutboxEvent, + where: o.updated_at < ^cutoff and o.status in ["delivered", "failed"] + ) + ) + + :ok + end + + defp prune_request_replays do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + {_deleted, _} = + Repo.delete_all(from(r in FederationRequestReplay, where: r.expires_at < ^now)) + + :ok + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/mirror_broadcasts.ex b/apps/elektrine/lib/elektrine/messaging/federation/mirror_broadcasts.ex new file mode 100644 index 0000000..8f20984 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/mirror_broadcasts.ex @@ -0,0 +1,199 @@ +defmodule Elektrine.Messaging.Federation.MirrorBroadcasts do + @moduledoc false + + import Ecto.Query, warn: false + + alias Elektrine.Messaging.{ + ChatConversation, + ChatMessage, + ChatMessageReaction, + ChatMessages, + Server + } + + alias Elektrine.PubSubTopics + alias Elektrine.Repo + + def add_mirror_reaction(chat_message_id, remote_actor_id, emoji) + when is_integer(chat_message_id) and is_integer(remote_actor_id) and is_binary(emoji) do + case Repo.get_by(ChatMessageReaction, + chat_message_id: chat_message_id, + remote_actor_id: remote_actor_id, + emoji: emoji + ) do + nil -> + %ChatMessageReaction{} + |> ChatMessageReaction.changeset(%{ + chat_message_id: chat_message_id, + remote_actor_id: remote_actor_id, + emoji: emoji + }) + |> Repo.insert() + |> case do + {:ok, reaction} -> {:ok, reaction} + {:error, reason} -> {:error, reason} + end + + _existing -> + {:ok, :duplicate} + end + end + + def add_mirror_reaction(_chat_message_id, _remote_actor_id, _emoji), + do: {:error, :invalid_event_payload} + + def remove_mirror_reaction(chat_message_id, remote_actor_id, emoji) + when is_integer(chat_message_id) and is_integer(remote_actor_id) and is_binary(emoji) do + {removed_count, _} = + from(r in ChatMessageReaction, + where: + r.chat_message_id == ^chat_message_id and + r.remote_actor_id == ^remote_actor_id and + r.emoji == ^emoji + ) + |> Repo.delete_all() + + {:ok, removed_count} + end + + def remove_mirror_reaction(_chat_message_id, _remote_actor_id, _emoji), + do: {:error, :invalid_event_payload} + + def maybe_broadcast_mirror_message_created(:duplicate), do: :ok + + def maybe_broadcast_mirror_message_created(%ChatMessage{ + id: message_id, + conversation_id: conversation_id + }) do + case ChatMessages.get_message_decrypted(message_id) do + # Thread messages update the thread panel, not the main timeline. + %ChatMessage{thread_id: thread_id} = message when is_integer(thread_id) -> + broadcast_conversation_event(conversation_id, {:new_thread_message, message}) + + %ChatMessage{} = message -> + broadcast_conversation_event(conversation_id, {:new_chat_message, message}) + + _ -> + :ok + end + end + + def maybe_broadcast_mirror_message_created(_message), do: :ok + + def maybe_broadcast_mirror_message_updated(%ChatMessage{ + id: message_id, + conversation_id: conversation_id + }) do + case ChatMessages.get_message_decrypted(message_id) do + %ChatMessage{} = message -> + broadcast_conversation_event(conversation_id, {:chat_message_updated, message}) + + _ -> + :ok + end + end + + def maybe_broadcast_mirror_message_updated(_message), do: :ok + + def maybe_broadcast_mirror_message_deleted(message_id) when is_integer(message_id) do + case Repo.get(ChatMessage, message_id) do + %ChatMessage{conversation_id: conversation_id} -> + broadcast_conversation_event(conversation_id, {:chat_message_deleted, message_id}) + + _ -> + :ok + end + end + + def maybe_broadcast_mirror_message_deleted(_message_id), do: :ok + + def maybe_broadcast_mirror_reaction_added(_message_id, :duplicate), do: :ok + + def maybe_broadcast_mirror_reaction_added(message_id, %ChatMessageReaction{} = reaction) + when is_integer(message_id) do + case Repo.get(ChatMessage, message_id) do + %ChatMessage{conversation_id: conversation_id} -> + reaction = Repo.preload(reaction, [:user, :remote_actor]) + + broadcast_conversation_event( + conversation_id, + {:chat_reaction_added, message_id, reaction} + ) + + _ -> + :ok + end + end + + def maybe_broadcast_mirror_reaction_added(_message_id, _reaction), do: :ok + + def maybe_broadcast_mirror_reaction_removed( + _message_id, + _remote_actor_id, + _emoji, + removed_count + ) + when removed_count <= 0, + do: :ok + + def maybe_broadcast_mirror_reaction_removed(message_id, remote_actor_id, emoji, _removed_count) + when is_integer(message_id) and is_integer(remote_actor_id) and is_binary(emoji) do + case Repo.get(ChatMessage, message_id) do + %ChatMessage{conversation_id: conversation_id} -> + broadcast_conversation_event( + conversation_id, + {:chat_reaction_removed, message_id, nil, emoji, remote_actor_id} + ) + + _ -> + :ok + end + end + + def maybe_broadcast_mirror_reaction_removed( + _message_id, + _remote_actor_id, + _emoji, + _removed_count + ), + do: :ok + + def broadcast_conversation_event(conversation_id, event) when is_integer(conversation_id) do + topic = PubSubTopics.conversation(conversation_id) + Phoenix.PubSub.broadcast(Elektrine.PubSub, topic, event) + end + + def publish_latest_message_event(conversation_id, context) + when is_integer(conversation_id) and is_map(context) do + case Repo.get(ChatConversation, conversation_id) do + %ChatConversation{type: "channel", server_id: server_id} when not is_nil(server_id) -> + case Repo.get(Server, server_id) do + %Server{is_federated_mirror: false} -> + from(m in ChatMessage, + where: m.conversation_id == ^conversation_id and is_nil(m.deleted_at), + order_by: [desc: m.inserted_at], + limit: 1 + ) + |> Repo.one() + |> case do + nil -> :ok + latest -> call(context, :publish_message_created, [latest]) + end + + _ -> + :ok + end + + _ -> + :ok + end + end + + def publish_latest_message_event(_conversation_id, _context), do: :ok + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/mirror_events.ex b/apps/elektrine/lib/elektrine/messaging/federation/mirror_events.ex new file mode 100644 index 0000000..3faaf61 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/mirror_events.ex @@ -0,0 +1,1297 @@ +defmodule Elektrine.Messaging.Federation.MirrorEvents do + @moduledoc false + + import Ecto.Query, warn: false + + alias Elektrine.Accounts + alias Elektrine.Accounts.User + alias Elektrine.ActivityPub + + alias Elektrine.Messaging.{ + ChatConversation, + ChatConversationMember, + ChatConversations, + CommunityBan, + Federation, + FederationMembershipState, + Server, + ServerMember + } + + alias Elektrine.Repo + + def apply_event(event_type, data, remote_domain, context) + when is_binary(event_type) and is_map(data) and is_binary(remote_domain) and is_map(context) do + case event_type do + "server.upsert" -> apply_server_upsert(data, remote_domain, context) + "message.create" -> apply_message_create(data, remote_domain, context) + "message.update" -> apply_message_update(data, remote_domain, context) + "message.delete" -> apply_message_delete(data, remote_domain, context) + "reaction.add" -> apply_reaction_add(data, remote_domain, context) + "reaction.remove" -> apply_reaction_remove(data, remote_domain, context) + "read.cursor" -> apply_read_cursor(data, remote_domain, context) + "invite.upsert" -> apply_invite_upsert(data, remote_domain, context) + "ban.upsert" -> apply_ban_upsert(data, remote_domain, context) + "membership.upsert" -> apply_membership_upsert(data, remote_domain, context) + "presence.update" -> apply_presence_update(data, remote_domain, context) + "typing.start" -> apply_typing_start(data, remote_domain, context) + "typing.stop" -> apply_typing_stop(data, remote_domain, context) + _ -> {:error, :unhandled_event_type} + end + end + + defp apply_server_upsert(data, remote_domain, context) do + with %{} = server_payload <- data["server"], + {:ok, mirror_server} <- + call(context, :upsert_mirror_server, [server_payload, remote_domain]), + {:ok, _channel_map} <- + call(context, :upsert_mirror_channels, [mirror_server, data["channels"] || []]) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_message_create(data, remote_domain, context) do + with %{} = message_payload <- data["message"], + %{} = sender_payload <- message_payload["sender"], + {:ok, _server, channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [sender_payload, remote_domain]), + :ok <- + call(context, :ensure_remote_actor_membership, [ + channel, + remote_actor_id, + remote_domain, + :write + ]), + {:ok, mirror_message_or_duplicate} <- + call(context, :upsert_mirror_message, [channel, message_payload, remote_domain]), + :ok <- + call(context, :maybe_broadcast_mirror_message_created, [mirror_message_or_duplicate]) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_message_update(data, remote_domain, context) do + with %{} = message_payload <- data["message"], + %{} = sender_payload <- message_payload["sender"], + {:ok, _server, channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [sender_payload, remote_domain]), + :ok <- + call(context, :ensure_remote_actor_membership, [ + channel, + remote_actor_id, + remote_domain, + :write + ]), + :ok <- + call(context, :ensure_remote_message_author, [ + channel, + message_payload["id"], + remote_domain + ]), + {:ok, mirror_message} <- + call(context, :upsert_or_update_mirror_message, [ + channel, + message_payload, + remote_domain + ]), + :ok <- call(context, :maybe_broadcast_mirror_message_updated, [mirror_message]) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_message_delete(data, remote_domain, context) do + with message_id when is_binary(message_id) <- data["message_id"], + {:ok, _server, channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + :ok <- + call(context, :ensure_remote_message_author, [channel, message_id, remote_domain]), + {:ok, deleted_message} <- + call(context, :soft_delete_mirror_message, [ + channel, + message_id, + data["deleted_at"], + remote_domain + ]), + :ok <- call(context, :maybe_broadcast_mirror_message_deleted, [deleted_message.id]) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_reaction_add(data, remote_domain, context) do + with message_id when is_binary(message_id) <- data["message_id"], + reaction when is_map(reaction) <- data["reaction"], + emoji when is_binary(emoji) <- reaction["emoji"], + {:ok, _server, channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + {:ok, message} <- call(context, :get_mirror_message, [channel, message_id]), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [reaction["actor"], remote_domain]), + :ok <- + call(context, :ensure_remote_actor_membership, [ + channel, + remote_actor_id, + remote_domain, + :write + ]), + {:ok, reaction_or_duplicate} <- + call(context, :add_mirror_reaction, [message.id, remote_actor_id, emoji]), + :ok <- + call(context, :maybe_broadcast_mirror_reaction_added, [ + message.id, + reaction_or_duplicate + ]) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_reaction_remove(data, remote_domain, context) do + with message_id when is_binary(message_id) <- data["message_id"], + reaction when is_map(reaction) <- data["reaction"], + emoji when is_binary(emoji) <- reaction["emoji"], + {:ok, _server, channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + {:ok, message} <- call(context, :get_mirror_message, [channel, message_id]), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [reaction["actor"], remote_domain]), + :ok <- + call(context, :ensure_remote_actor_membership, [ + channel, + remote_actor_id, + remote_domain, + :write + ]), + {:ok, removed_count} <- + call(context, :remove_mirror_reaction, [message.id, remote_actor_id, emoji]), + :ok <- + call(context, :maybe_broadcast_mirror_reaction_removed, [ + message.id, + remote_actor_id, + emoji, + removed_count + ]) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_read_cursor(data, remote_domain, context) do + with read_through_message_id when is_binary(read_through_message_id) <- + data["read_through_message_id"], + %{} = actor_payload <- data["actor"], + {:ok, _server, channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + {:ok, message} <- + call(context, :get_mirror_message, [channel, read_through_message_id]), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [actor_payload, remote_domain]), + :ok <- + call(context, :ensure_remote_actor_membership, [ + channel, + remote_actor_id, + remote_domain, + :read + ]), + read_at <- call(context, :parse_datetime, [data["read_at"]]) || DateTime.utc_now(), + read_through_sequence <- call(context, :parse_int, [data["read_through_sequence"], 0]), + {:ok, _cursor} <- + call(context, :upsert_remote_read_cursor, [ + channel.id, + message.id, + remote_actor_id, + remote_domain, + read_at, + read_through_sequence + ]), + :ok <- + call(context, :maybe_broadcast_remote_read_cursor, [ + channel.id, + message.id, + remote_actor_id + ]) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_invite_upsert(data, remote_domain, context) do + with %{} = invite_payload <- data["invite"], + %{} = target_payload <- invite_payload["target"], + %{} = actor_payload <- invite_payload["actor"], + state when is_binary(state) <- invite_payload["state"], + role when is_binary(role) <- invite_payload["role"], + {:ok, _mirror_server, mirror_channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [actor_payload, remote_domain]), + :ok <- + authorize_governance_event( + data, + remote_domain, + mirror_channel, + remote_actor_id, + :invite, + context + ), + {:ok, _invite_state} <- + call(context, :upsert_invite_state, [ + mirror_channel.id, + remote_domain, + actor_payload, + target_payload, + role, + state, + call(context, :parse_datetime, [invite_payload["invited_at"]]), + call(context, :parse_datetime, [invite_payload["updated_at"]]) || DateTime.utc_now(), + invite_payload["metadata"] || %{} + ]), + :ok <- + apply_invite_target_projection( + mirror_channel, + target_payload, + remote_domain, + remote_actor_id, + role, + state, + invite_payload, + context + ) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_ban_upsert(data, remote_domain, context) do + with %{} = ban_payload <- data["ban"], + %{} = target_payload <- ban_payload["target"], + %{} = actor_payload <- ban_payload["actor"], + state when is_binary(state) <- ban_payload["state"], + {:ok, _mirror_server, mirror_channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [actor_payload, remote_domain]), + :ok <- + authorize_governance_event( + data, + remote_domain, + mirror_channel, + remote_actor_id, + :ban, + context + ), + :ok <- + apply_ban_target_projection( + mirror_channel, + target_payload, + remote_domain, + remote_actor_id, + state, + ban_payload, + context + ) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_membership_upsert(data, remote_domain, context) do + with %{} = membership_payload <- data["membership"], + %{} = actor_payload <- membership_payload["actor"], + state when is_binary(state) <- membership_payload["state"], + role when is_binary(role) <- membership_payload["role"], + {:ok, server, channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [actor_payload, remote_domain]), + :ok <- + apply_membership_projection( + server, + channel, + remote_actor_id, + remote_domain, + role, + state, + membership_payload, + context + ) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_presence_update(data, remote_domain, context) do + with %{} = presence_payload <- data["presence"], + %{} = actor_payload <- presence_payload["actor"], + status when is_binary(status) <- presence_payload["status"], + updated_at <- + call(context, :parse_datetime, [presence_payload["updated_at"]]) || DateTime.utc_now(), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [actor_payload, remote_domain]), + activities <- + call(context, :normalize_presence_activities, [presence_payload["activities"]]), + ttl_ms <- call(context, :parse_int, [presence_payload["ttl_ms"], nil]), + :ok <- + apply_scoped_presence_update( + data, + remote_domain, + remote_actor_id, + status, + activities, + updated_at, + ttl_ms, + context + ) do + :ok + else + false -> :ok + error -> normalize_event_error(error) + end + end + + defp apply_scoped_presence_update( + data, + remote_domain, + remote_actor_id, + status, + activities, + updated_at, + ttl_ms, + context + ) do + if room_scoped_presence_payload?(data) do + with {:ok, _server, channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + :ok <- + call(context, :ensure_remote_actor_membership, [ + channel, + remote_actor_id, + remote_domain, + :read + ]), + {:ok, _presence_state} <- + call(context, :upsert_room_presence_state, [ + channel.id, + remote_actor_id, + status, + activities, + updated_at, + remote_domain, + ttl_ms + ]) do + call(context, :maybe_broadcast_room_presence_update, [ + channel.id, + remote_actor_id, + status, + activities, + updated_at + ]) + end + else + subscriber_user_ids = call(context, :local_presence_subscriber_user_ids, [remote_actor_id]) + + with true <- subscriber_user_ids != [], + {:ok, _presence_state} <- + call(context, :upsert_account_presence_state, [ + remote_actor_id, + status, + activities, + updated_at, + remote_domain, + ttl_ms + ]), + server_ids <- call(context, :server_ids_for_remote_actor, [remote_actor_id]) do + call(context, :maybe_broadcast_presence_update, [ + subscriber_user_ids, + server_ids, + remote_actor_id, + status, + activities, + updated_at + ]) + end + end + end + + defp apply_typing_start(data, remote_domain, context) do + with %{} = actor_payload <- data["actor"], + {:ok, _server, channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [actor_payload, remote_domain]), + :ok <- + call(context, :ensure_remote_actor_membership, [ + channel, + remote_actor_id, + remote_domain, + :write + ]), + :ok <- + call(context, :maybe_broadcast_remote_typing_started, [ + channel.id, + remote_actor_id + ]) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_typing_stop(data, remote_domain, context) do + with %{} = actor_payload <- data["actor"], + {:ok, _server, channel} <- + call(context, :resolve_channel_event_context, [data, remote_domain]), + {:ok, remote_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [actor_payload, remote_domain]), + :ok <- + call(context, :ensure_remote_actor_membership, [ + channel, + remote_actor_id, + remote_domain, + :write + ]), + :ok <- + call(context, :maybe_broadcast_remote_typing_stopped, [ + channel.id, + remote_actor_id + ]) do + :ok + else + error -> normalize_event_error(error) + end + end + + defp apply_invite_target_projection( + mirror_channel, + target_payload, + remote_domain, + remote_actor_id, + role, + state, + invite_payload, + context + ) do + invited_at = call(context, :parse_datetime, [invite_payload["invited_at"]]) + + updated_at = + call(context, :parse_datetime, [invite_payload["updated_at"]]) || DateTime.utc_now() + + case resolve_local_user_from_actor_payload(target_payload) do + %User{} = local_user -> + with :ok <- apply_local_invite_state(mirror_channel, local_user, role, state), + {:ok, membership_state} <- + maybe_local_membership_state_for_invite( + mirror_channel, + local_user, + role, + state, + invited_at, + updated_at, + actor_payload: get_actor_payload(invite_payload), + actor_remote_id: remote_actor_id, + metadata: invite_payload["metadata"] || %{} + ) do + maybe_broadcast_optional_membership_state( + mirror_channel.id, + membership_state, + context + ) + end + + nil -> + with {:ok, remote_target_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [target_payload, remote_domain]), + {:ok, membership_state} <- + call(context, :upsert_membership_state, [ + mirror_channel.id, + remote_target_actor_id, + remote_domain, + role, + call(context, :invite_membership_state, [state]), + invited_at, + updated_at, + %{ + "governance_event" => "invite.upsert", + "invite_state" => state, + "actor" => get_actor_payload(invite_payload), + "actor_remote_id" => remote_actor_id, + "metadata" => invite_payload["metadata"] || %{} + } + ]) do + call(context, :maybe_broadcast_membership_state, [mirror_channel.id, membership_state]) + end + end + end + + defp apply_ban_target_projection( + mirror_channel, + target_payload, + remote_domain, + remote_actor_id, + state, + ban_payload, + context + ) do + banned_at = call(context, :parse_datetime, [ban_payload["banned_at"]]) + updated_at = call(context, :parse_datetime, [ban_payload["updated_at"]]) || DateTime.utc_now() + + case resolve_local_user_from_actor_payload(target_payload) do + %User{} = local_user -> + with :ok <- + apply_local_ban_state( + mirror_channel, + local_user, + state, + ban_payload, + remote_domain + ), + {:ok, membership_state} <- + maybe_local_membership_state_for_ban( + mirror_channel, + local_user, + state, + banned_at, + updated_at, + actor_payload: get_actor_payload(ban_payload), + actor_remote_id: remote_actor_id, + reason: ban_payload["reason"], + expires_at: ban_payload["expires_at"], + metadata: ban_payload["metadata"] || %{} + ) do + maybe_broadcast_optional_membership_state( + mirror_channel.id, + membership_state, + context + ) + end + + nil -> + with {:ok, remote_target_actor_id} <- + call(context, :resolve_or_create_remote_actor_id, [target_payload, remote_domain]), + {:ok, membership_state} <- + call(context, :upsert_membership_state, [ + mirror_channel.id, + remote_target_actor_id, + remote_domain, + "member", + call(context, :ban_membership_state, [state]), + banned_at, + updated_at, + %{ + "governance_event" => "ban.upsert", + "ban_state" => state, + "reason" => ban_payload["reason"], + "expires_at" => ban_payload["expires_at"], + "actor" => get_actor_payload(ban_payload), + "actor_remote_id" => remote_actor_id, + "metadata" => ban_payload["metadata"] || %{} + } + ]) do + call(context, :maybe_broadcast_membership_state, [mirror_channel.id, membership_state]) + end + end + end + + defp apply_membership_projection( + server, + channel, + remote_actor_id, + remote_domain, + role, + state, + membership_payload, + context + ) do + joined_at = call(context, :parse_datetime, [membership_payload["joined_at"]]) + + updated_at = + call(context, :parse_datetime, [membership_payload["updated_at"]]) || DateTime.utc_now() + + metadata = membership_payload["metadata"] || %{} + + if server.is_federated_mirror do + apply_mirrored_membership_projection( + server, + channel, + remote_actor_id, + remote_domain, + role, + state, + joined_at, + updated_at, + metadata, + membership_payload, + context + ) + else + apply_authoritative_membership_projection( + channel, + remote_actor_id, + role, + state, + joined_at, + updated_at, + metadata, + membership_payload, + context + ) + end + end + + defp apply_mirrored_membership_projection( + %Server{} = server, + %ChatConversation{} = channel, + remote_actor_id, + remote_domain, + role, + state, + joined_at, + updated_at, + metadata, + membership_payload, + context + ) do + if normalize_domain(remote_domain) == normalize_domain(server.origin_domain) do + with {:ok, membership_state} <- + call(context, :upsert_membership_state, [ + channel.id, + remote_actor_id, + remote_domain, + role, + state, + joined_at, + updated_at, + metadata + ]) do + call(context, :maybe_broadcast_membership_state, [channel.id, membership_state]) + end + else + apply_participant_membership_projection( + channel, + remote_actor_id, + remote_domain, + role, + state, + joined_at, + updated_at, + metadata, + membership_payload, + context + ) + end + end + + defp apply_participant_membership_projection( + %ChatConversation{} = channel, + remote_actor_id, + remote_domain, + role, + state, + joined_at, + updated_at, + metadata, + membership_payload, + context + ) do + actor_payload = membership_payload["actor"] || %{} + + existing_state = + Repo.get_by(FederationMembershipState, + conversation_id: channel.id, + remote_actor_id: remote_actor_id + ) + + cond do + role not in ["member", "readonly"] -> + {:error, :invalid_event_payload} + + state not in ["active", "invited", "left"] -> + {:error, :invalid_event_payload} + + existing_state && existing_state.state == "banned" -> + :ok + + true -> + with {:ok, membership_state} <- + call(context, :upsert_membership_state, [ + channel.id, + remote_actor_id, + actor_origin_domain(actor_payload) || remote_domain, + role, + state, + joined_at, + updated_at, + metadata + ]) do + call(context, :maybe_broadcast_membership_state, [channel.id, membership_state]) + end + end + end + + defp apply_authoritative_membership_projection( + %ChatConversation{} = channel, + remote_actor_id, + role, + state, + joined_at, + updated_at, + metadata, + membership_payload, + context + ) do + actor_payload = membership_payload["actor"] || %{} + + existing_state = + Repo.get_by(FederationMembershipState, + conversation_id: channel.id, + remote_actor_id: remote_actor_id + ) + + actor_uri = actor_uri(actor_payload) + invite_state = invite_state_for_actor(channel.id, actor_uri) + + cond do + role not in ["member", "readonly"] -> + {:error, :invalid_event_payload} + + state == "banned" -> + {:error, :invalid_event_payload} + + existing_state && existing_state.state == "banned" -> + publish_join_decision(channel, actor_payload, "declined", role, %{"reason" => "banned"}) + :ok + + state == "active" and allowed_remote_activation?(existing_state, invite_state) -> + with {:ok, membership_state} <- + call(context, :upsert_membership_state, [ + channel.id, + remote_actor_id, + actor_origin_domain(actor_payload), + role, + "active", + joined_at, + updated_at, + metadata + ]) do + call(context, :maybe_broadcast_membership_state, [channel.id, membership_state]) + end + + state == "active" -> + {:error, :not_authorized_for_room} + + state == "left" -> + with {:ok, membership_state} <- + call(context, :upsert_membership_state, [ + channel.id, + remote_actor_id, + actor_origin_domain(actor_payload), + role, + "left", + joined_at, + updated_at, + metadata + ]) do + call(context, :maybe_broadcast_membership_state, [channel.id, membership_state]) + end + + state == "invited" -> + handle_remote_join_request( + channel, + remote_actor_id, + actor_payload, + role, + joined_at, + updated_at, + metadata, + context + ) + + true -> + {:error, :invalid_event_payload} + end + end + + defp handle_remote_join_request( + %ChatConversation{} = channel, + remote_actor_id, + actor_payload, + role, + joined_at, + updated_at, + metadata, + context + ) do + decision = + cond do + remote_actor_banned?(channel.id, remote_actor_id) -> {"declined", %{"reason" => "banned"}} + channel.is_public != true -> {"pending", %{"reason" => "invite_only"}} + channel.approval_mode_enabled == true -> {"pending", %{"reason" => "approval_required"}} + true -> {"accepted", %{}} + end + + {invite_state, decision_metadata} = decision + membership_state_value = if invite_state == "accepted", do: "active", else: "invited" + joined_at_value = if invite_state == "accepted", do: joined_at, else: nil + + with {:ok, membership_state} <- + call(context, :upsert_membership_state, [ + channel.id, + remote_actor_id, + actor_origin_domain(actor_payload), + role, + membership_state_value, + joined_at_value, + updated_at, + Map.merge(metadata, %{"join_request" => true}) + ]), + :ok <- call(context, :maybe_broadcast_membership_state, [channel.id, membership_state]) do + publish_join_decision(channel, actor_payload, invite_state, role, decision_metadata) + :ok + end + end + + defp apply_local_invite_state( + %ChatConversation{} = mirror_channel, + local_user, + role, + "accepted" + ) do + ensure_local_mirror_server_membership(mirror_channel, local_user.id) + + case ChatConversations.add_member_to_conversation_without_federation( + mirror_channel.id, + local_user.id, + role + ) do + {:ok, _member} -> :ok + {:error, :already_member} -> :ok + other -> other + end + end + + defp apply_local_invite_state(_mirror_channel, _local_user, _role, state) + when state in ["pending", "declined", "revoked"], + do: :ok + + defp apply_local_invite_state(_mirror_channel, _local_user, _role, _state), + do: {:error, :invalid_event_payload} + + defp apply_local_ban_state( + %ChatConversation{} = mirror_channel, + local_user, + "active", + ban_payload, + remote_domain + ) do + with :ok <- remove_local_banned_member(mirror_channel, local_user), + {:ok, _ban} <- + upsert_local_ban_projection(mirror_channel, local_user, ban_payload, remote_domain) do + :ok + end + end + + defp apply_local_ban_state( + %ChatConversation{} = mirror_channel, + local_user, + "lifted", + ban_payload, + remote_domain + ) do + delete_local_ban_projection(mirror_channel, local_user, ban_payload, remote_domain) + end + + defp apply_local_ban_state(_mirror_channel, _local_user, _state, _ban_payload, _remote_domain), + do: {:error, :invalid_event_payload} + + defp remove_local_banned_member(%ChatConversation{} = mirror_channel, local_user) do + from(cm in ChatConversationMember, + where: + cm.conversation_id == ^mirror_channel.id and cm.user_id == ^local_user.id and + is_nil(cm.left_at) + ) + |> Repo.one() + |> case do + %ChatConversationMember{} = member -> + member + |> ChatConversationMember.remove_member_changeset() + |> Repo.update() + |> case do + {:ok, _updated} -> + refresh_local_member_count(mirror_channel.id) + :ok + + error -> + error + end + + nil -> + :ok + end + end + + defp upsert_local_ban_projection( + %ChatConversation{} = mirror_channel, + %User{} = local_user, + ban_payload, + remote_domain + ) + when is_map(ban_payload) and is_binary(remote_domain) do + actor_payload = get_actor_payload(ban_payload) + actor_origin_domain = actor_origin_domain(actor_payload) || remote_domain + + attrs = %{ + conversation_id: mirror_channel.id, + user_id: local_user.id, + banned_by_id: nil, + origin_domain: actor_origin_domain, + actor_payload: actor_payload, + metadata: ban_payload["metadata"] || %{}, + reason: ban_payload["reason"], + expires_at: parse_optional_datetime(ban_payload["expires_at"]), + banned_at_remote: parse_optional_datetime(ban_payload["banned_at"]), + updated_at_remote: parse_optional_datetime(ban_payload["updated_at"]) || DateTime.utc_now() + } + + %CommunityBan{} + |> CommunityBan.changeset(attrs) + |> Repo.insert( + on_conflict: + {:replace, + [ + :banned_by_id, + :origin_domain, + :actor_payload, + :metadata, + :reason, + :expires_at, + :banned_at_remote, + :updated_at_remote, + :updated_at + ]}, + conflict_target: [:conversation_id, :user_id] + ) + end + + defp upsert_local_ban_projection(_mirror_channel, _local_user, _ban_payload, _remote_domain), + do: {:error, :invalid_event_payload} + + defp delete_local_ban_projection( + %ChatConversation{} = mirror_channel, + %User{} = local_user, + ban_payload, + remote_domain + ) + when is_map(ban_payload) and is_binary(remote_domain) do + actor_payload = get_actor_payload(ban_payload) + actor_origin_domain = actor_origin_domain(actor_payload) || remote_domain + + from(ban in CommunityBan, + where: ban.conversation_id == ^mirror_channel.id and ban.user_id == ^local_user.id, + where: ban.origin_domain == ^actor_origin_domain + ) + |> Repo.delete_all() + + :ok + end + + defp delete_local_ban_projection(_mirror_channel, _local_user, _ban_payload, _remote_domain), + do: {:error, :invalid_event_payload} + + defp parse_optional_datetime(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, datetime, _offset} -> datetime + _ -> nil + end + end + + defp parse_optional_datetime(_value), do: nil + + defp refresh_local_member_count(conversation_id) when is_integer(conversation_id) do + count = + from(cm in ChatConversationMember, + where: cm.conversation_id == ^conversation_id and is_nil(cm.left_at), + select: count() + ) + |> Repo.one() + + from(c in ChatConversation, where: c.id == ^conversation_id) + |> Repo.update_all(set: [member_count: count]) + + :ok + end + + defp refresh_local_member_count(_conversation_id), do: :ok + + defp maybe_local_membership_state_for_invite( + _mirror_channel, + _local_user, + _role, + _state, + _invited_at, + _updated_at, + _opts + ), + do: {:ok, nil} + + defp maybe_local_membership_state_for_ban( + _mirror_channel, + _local_user, + _state, + _banned_at, + _updated_at, + _opts + ), + do: {:ok, nil} + + defp maybe_broadcast_optional_membership_state(_conversation_id, nil, _context), do: :ok + + defp ensure_local_mirror_server_membership(%ChatConversation{server_id: server_id}, user_id) + when is_integer(server_id) and is_integer(user_id) do + case Repo.get_by(ServerMember, server_id: server_id, user_id: user_id) do + nil -> + ServerMember.add_member_changeset(server_id, user_id, "member") + |> Repo.insert() + |> case do + {:ok, _member} -> + refresh_local_server_member_count(server_id) + :ok + + {:error, reason} -> + {:error, reason} + end + + %ServerMember{left_at: nil} -> + :ok + + %ServerMember{} = member -> + member + |> ServerMember.changeset(%{left_at: nil, joined_at: DateTime.utc_now(), role: "member"}) + |> Repo.update() + |> case do + {:ok, _updated} -> + refresh_local_server_member_count(server_id) + :ok + + {:error, reason} -> + {:error, reason} + end + end + end + + defp ensure_local_mirror_server_membership(_mirror_channel, _user_id), do: :ok + + defp refresh_local_server_member_count(server_id) when is_integer(server_id) do + count = + from(sm in ServerMember, + where: sm.server_id == ^server_id and is_nil(sm.left_at), + select: count() + ) + |> Repo.one() + + from(server in Elektrine.Messaging.Server, where: server.id == ^server_id) + |> Repo.update_all(set: [member_count: count]) + + :ok + end + + defp resolve_local_user_from_actor_payload(actor_payload) when is_map(actor_payload) do + uri = actor_uri(actor_payload) + username = actor_payload["username"] || actor_payload[:username] + domain = actor_payload["domain"] || actor_payload[:domain] + + cond do + is_binary(uri) -> + case ActivityPub.local_username_from_uri(uri) do + {:ok, local_username} -> Accounts.get_user_by_username(local_username) + _ -> nil + end + + is_binary(username) and is_binary(domain) and + String.downcase(domain) == Federation.local_domain() -> + Accounts.get_user_by_username(username) + + true -> + nil + end + end + + defp resolve_local_user_from_actor_payload(_actor_payload), do: nil + + defp publish_join_decision(%ChatConversation{} = channel, target_payload, state, role, metadata) + when state in ["pending", "accepted", "declined", "revoked"] do + case governance_actor_user_id(channel) do + actor_user_id when is_integer(actor_user_id) -> + Federation.publish_remote_invite_state( + channel.id, + target_payload, + actor_user_id, + state, + role, + metadata + ) + + :ok + + _ -> + :ok + end + end + + defp governance_actor_user_id(%ChatConversation{creator_id: creator_id}) + when is_integer(creator_id), + do: creator_id + + defp governance_actor_user_id(_channel), do: nil + + defp allowed_remote_activation?(%FederationMembershipState{state: "active"}, _invite_state), + do: true + + defp allowed_remote_activation?(_membership_state, "accepted"), do: true + defp allowed_remote_activation?(_membership_state, _invite_state), do: false + + defp invite_state_for_actor(conversation_id, actor_uri) + when is_integer(conversation_id) and is_binary(actor_uri) do + from(invite in Elektrine.Messaging.FederationInviteState, + where: invite.conversation_id == ^conversation_id and invite.target_uri == ^actor_uri, + select: invite.state, + limit: 1 + ) + |> Repo.one() + end + + defp invite_state_for_actor(_conversation_id, _actor_uri), do: nil + + defp remote_actor_banned?(conversation_id, remote_actor_id) + when is_integer(conversation_id) and is_integer(remote_actor_id) do + Repo.exists?( + from(state in FederationMembershipState, + where: + state.conversation_id == ^conversation_id and + state.remote_actor_id == ^remote_actor_id and + state.state == "banned" + ) + ) + end + + defp remote_actor_banned?(_conversation_id, _remote_actor_id), do: false + + defp actor_uri(actor_payload) when is_map(actor_payload) do + value = + actor_payload["uri"] || actor_payload[:uri] || actor_payload["id"] || actor_payload[:id] + + case value do + uri when is_binary(uri) and uri != "" -> uri + _ -> nil + end + end + + defp actor_uri(_actor_payload), do: nil + + defp actor_origin_domain(actor_payload) when is_map(actor_payload) do + case actor_payload["domain"] || actor_payload[:domain] do + domain when is_binary(domain) and domain != "" -> + String.downcase(domain) + + _ -> + actor_payload + |> actor_uri() + |> case do + uri when is_binary(uri) -> + case URI.parse(uri) do + %URI{host: host} when is_binary(host) and host != "" -> String.downcase(host) + _ -> Federation.local_domain() + end + + _ -> + Federation.local_domain() + end + end + end + + defp actor_origin_domain(_actor_payload), do: Federation.local_domain() + + defp authorize_governance_event( + data, + remote_domain, + mirror_channel, + remote_actor_id, + action, + context + ) + when is_map(data) and is_binary(remote_domain) and is_map(mirror_channel) and + is_integer(remote_actor_id) and is_atom(action) and is_map(context) do + case call(context, :ensure_authoritative_channel_event_context, [data, remote_domain]) do + {:ok, _server, authoritative_channel} -> + call(context, :ensure_remote_actor_governance_permission, [ + authoritative_channel, + remote_actor_id, + action, + %{remote_actor_id: remote_actor_id} + ]) + + _error -> + call(context, :ensure_remote_actor_governance_permission, [ + mirror_channel, + remote_actor_id, + action, + %{remote_actor_id: remote_actor_id} + ]) + end + end + + defp authorize_governance_event( + _data, + _remote_domain, + _mirror_channel, + _remote_actor_id, + _action, + _context + ), + do: {:error, :not_authorized_for_room} + + defp get_actor_payload(payload) when is_map(payload), do: payload["actor"] || %{} + + defp normalize_domain(value) when is_binary(value) do + value + |> String.trim() + |> case do + "" -> nil + trimmed -> String.downcase(trimmed) + end + end + + defp normalize_domain(_value), do: nil + + defp normalize_event_error({:error, _reason} = error), do: error + defp normalize_event_error(:ok), do: :ok + defp normalize_event_error(_error), do: {:error, :invalid_event_payload} + + defp room_scoped_presence_payload?(data) when is_map(data) do + is_binary(get_in(data, ["refs", "channel_id"])) or is_binary(get_in(data, ["channel", "id"])) + end + + defp room_scoped_presence_payload?(_data), do: false + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/mirrors.ex b/apps/elektrine/lib/elektrine/messaging/federation/mirrors.ex new file mode 100644 index 0000000..0fd90bb --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/mirrors.ex @@ -0,0 +1,701 @@ +defmodule Elektrine.Messaging.Federation.Mirrors do + @moduledoc false + + import Ecto.Query, warn: false + import Elektrine.Messaging.Federation.Utils + + alias Elektrine.Messaging.{ + ChatConversation, + ChatMessage, + RoomACL, + Server + } + + alias Elektrine.Repo + + def ensure_channel_event_context(data, remote_domain, context) + when is_map(data) and is_binary(remote_domain) and is_map(context) do + with %{} = server_payload <- event_server_payload(data), + %{} = channel_payload <- event_channel_payload(data), + {:ok, mirror_server} <- ensure_mirror_server_from_event(server_payload, remote_domain), + {:ok, mirror_channel} <- + ensure_mirror_channel_from_event(mirror_server, channel_payload) do + {:ok, mirror_server, mirror_channel} + else + {:error, :federation_origin_conflict} = error -> error + _ -> {:error, :invalid_event_payload} + end + end + + def ensure_channel_event_context(_data, _remote_domain, _context), + do: {:error, :invalid_event_payload} + + def ensure_authoritative_channel_event_context(data, remote_domain, context) + when is_map(data) and is_binary(remote_domain) and is_map(context) do + with {:ok, server, channel} <- resolve_channel_event_context(data, remote_domain, context), + authority_domain when is_binary(authority_domain) <- room_authority_domain(server, data), + true <- + normalize_domain(authority_domain) == normalize_domain(remote_domain) or + {:error, :not_authorized_for_room} do + {:ok, server, channel} + else + {:error, reason} -> {:error, reason} + _ -> {:error, :invalid_event_payload} + end + end + + def ensure_authoritative_channel_event_context(_data, _remote_domain, _context), + do: {:error, :invalid_event_payload} + + def resolve_channel_event_context(data, remote_domain, context) + when is_map(data) and is_binary(remote_domain) and is_map(context) do + channel_id = event_channel_id(data) + server_id = event_server_id(data) + room_origin_domain = event_room_origin_domain(data) + + with channel_id when is_binary(channel_id) <- channel_id, + room_origin_domain when is_binary(room_origin_domain) <- room_origin_domain do + case resolve_existing_channel_context(server_id, channel_id) do + {:ok, server, channel} -> + {:ok, server, channel} + + {:error, :not_found} -> + with true <- normalize_domain(room_origin_domain) == normalize_domain(remote_domain), + %{} = server_payload <- event_server_payload(data), + %{} = channel_payload <- event_channel_payload(data), + {:ok, mirror_server} <- + ensure_mirror_server_from_event(server_payload, remote_domain), + {:ok, mirror_channel} <- + ensure_mirror_channel_from_event(mirror_server, channel_payload) do + {:ok, mirror_server, mirror_channel} + else + {:error, :federation_origin_conflict} = error -> error + _ -> {:error, :invalid_event_payload} + end + + {:error, :federation_origin_conflict} = error -> + error + end + else + _ -> {:error, :invalid_event_payload} + end + end + + def resolve_channel_event_context(_data, _remote_domain, _context), + do: {:error, :invalid_event_payload} + + def ensure_server_event_context(data, remote_domain, _context) + when is_map(data) and is_binary(remote_domain) do + with %{} = server_payload <- event_server_payload(data), + {:ok, mirror_server} <- ensure_mirror_server_from_event(server_payload, remote_domain) do + {:ok, mirror_server} + else + {:error, :federation_origin_conflict} = error -> error + _ -> {:error, :invalid_event_payload} + end + end + + def ensure_server_event_context(_data, _remote_domain, _context), + do: {:error, :invalid_event_payload} + + def event_server_payload(data) when is_map(data) do + server_id = event_server_id(data) + + case data["server"] do + %{} = server when is_binary(server_id) -> Map.put_new(server, "id", server_id) + %{} = server -> server + _ when is_binary(server_id) -> %{"id" => server_id} + _ -> nil + end + end + + def event_server_payload(_data), do: nil + + def upsert_mirror_server(server_payload, remote_domain) + when is_map(server_payload) and is_binary(remote_domain) do + attrs = %{ + name: server_payload["name"], + description: server_payload["description"], + icon_url: server_payload["icon_url"], + is_public: server_payload["is_public"] == true, + member_count: parse_int(server_payload["member_count"], 0), + federation_id: server_payload["id"], + origin_domain: remote_domain, + is_federated_mirror: true, + last_federated_at: DateTime.utc_now() + } + + case Repo.get_by(Server, federation_id: server_payload["id"]) do + nil -> + %Server{} |> Server.changeset(attrs) |> Repo.insert() + + %Server{origin_domain: existing_origin} = server -> + if is_binary(existing_origin) and existing_origin != remote_domain do + {:error, :federation_origin_conflict} + else + server |> Server.changeset(attrs) |> Repo.update() + end + end + end + + def upsert_mirror_server(_server_payload, _remote_domain), do: {:error, :invalid_event_payload} + + def upsert_mirror_channels(server, channels, context) + when is_list(channels) and is_map(context) do + channels + |> Enum.reduce_while({:ok, %{}}, fn payload, {:ok, acc} -> + case upsert_single_mirror_channel(server, payload) do + {:ok, channel} -> + {:cont, {:ok, Map.put(acc, payload["id"], channel)}} + + {:error, reason} -> + {:halt, {:error, reason}} + end + end) + end + + def upsert_mirror_channels(_server, _channels, _context), do: {:error, :invalid_event_payload} + + def upsert_single_mirror_channel(server, %{"id" => channel_id} = channel_payload) + when is_map(server) and is_binary(channel_id) do + attrs = %{ + name: channel_payload["name"] || "channel", + description: channel_payload["description"], + channel_topic: channel_payload["topic"], + channel_position: parse_int(channel_payload["position"], 0), + creator_id: nil, + server_id: server.id, + is_public: channel_payload["is_public"] == true, + approval_mode_enabled: channel_payload["approval_mode_enabled"] == true, + is_federated_mirror: true, + federated_source: channel_id + } + + case Repo.get_by(ChatConversation, type: "channel", federated_source: channel_id) do + nil -> + %ChatConversation{} |> ChatConversation.channel_changeset(attrs) |> Repo.insert() + + %ChatConversation{} = channel -> + with :ok <- ensure_channel_origin_matches(channel, server.origin_domain) do + channel |> ChatConversation.changeset(attrs) |> Repo.update() + end + end + end + + def upsert_single_mirror_channel(_server, _channel_payload), do: {:error, :invalid_channel} + + def upsert_mirror_messages(channel_map, messages, remote_domain, context) + when is_map(channel_map) and is_list(messages) and is_binary(remote_domain) and + is_map(context) do + messages + |> Enum.reduce_while(:ok, fn payload, :ok -> + channel = Map.get(channel_map, payload["channel_id"]) + + if channel do + case upsert_mirror_message(channel, payload, remote_domain, context) do + {:ok, _result} -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + else + {:cont, :ok} + end + end) + end + + def upsert_mirror_messages(_channel_map, _messages, _remote_domain, _context), + do: {:error, :invalid_event_payload} + + def upsert_mirror_message(channel, payload, remote_domain, context) + when is_map(context) and is_binary(remote_domain) do + federation_id = payload["id"] + + cond do + is_nil(channel) -> + {:error, :invalid_channel} + + !is_binary(federation_id) -> + {:error, :invalid_message_payload} + + Repo.get_by(ChatMessage, conversation_id: channel.id, federated_source: federation_id) -> + {:ok, :duplicate} + + true -> + attachments = call(context, :normalize_message_attachments, [payload]) + message_origin_domain = message_origin_domain(payload, remote_domain) + + media_metadata = + call(context, :attachment_storage_metadata, [attachments]) + |> Map.put("remote_sender", payload["sender"] || %{}) + + # Optional thread reference (spec section 7.6): map to a local thread + # row when known, otherwise fall back to the channel timeline. + thread = resolve_message_thread(channel.id, payload["thread_id"]) + + attrs = %{ + conversation_id: channel.id, + sender_id: nil, + content: payload["content"], + client_encrypted_payload: client_encrypted_payload(payload), + message_type: normalize_message_type(payload["message_type"]), + media_urls: Enum.map(attachments, & &1["url"]), + media_metadata: media_metadata, + federated_source: federation_id, + origin_domain: message_origin_domain, + is_federated_mirror: channel.is_federated_mirror == true, + thread_id: thread && thread.id + } + + %ChatMessage{} + |> ChatMessage.changeset(attrs) + |> Repo.insert() + |> case do + {:ok, %ChatMessage{thread_id: thread_id} = message} when is_integer(thread_id) -> + _ = Elektrine.Messaging.ChatThreads.record_message_activity(message) + {:ok, message} + + other -> + other + end + end + end + + def upsert_mirror_message(_channel, _payload, _remote_domain, _context), + do: {:error, :invalid_event_payload} + + defp resolve_message_thread(conversation_id, ref) when is_binary(ref) do + Elektrine.Messaging.ChatThreads.resolve_thread_for_message_ref(conversation_id, ref) + end + + defp resolve_message_thread(_conversation_id, _ref), do: nil + + def upsert_or_update_mirror_message(channel, payload, remote_domain, context) + when is_map(context) and is_binary(remote_domain) do + federation_id = payload["id"] + + case is_binary(federation_id) do + true -> + case Repo.get_by(ChatMessage, + conversation_id: channel.id, + federated_source: federation_id + ) do + %ChatMessage{} = existing -> + attachments = call(context, :normalize_message_attachments, [payload]) + + media_metadata = + call(context, :attachment_storage_metadata, [attachments]) + |> Map.put("remote_sender", payload["sender"] || %{}) + + attrs = %{ + content: payload["content"], + client_encrypted_payload: client_encrypted_payload(payload), + message_type: normalize_message_type(payload["message_type"]), + media_urls: Enum.map(attachments, & &1["url"]), + media_metadata: media_metadata, + edited_at: parse_datetime(payload["edited_at"]) || DateTime.utc_now() + } + + existing + |> ChatMessage.changeset(attrs) + |> Repo.update() + + nil when channel.is_federated_mirror == true -> + upsert_mirror_message(channel, payload, remote_domain, context) + + nil -> + {:error, :not_found} + end + + false -> + {:error, :invalid_event_payload} + end + end + + def upsert_or_update_mirror_message(_channel, _payload, _remote_domain, _context), + do: {:error, :invalid_event_payload} + + defp client_encrypted_payload(payload) when is_map(payload) do + case payload["client_encrypted_payload"] || get_in(payload, ["e2ee", "payload"]) do + encrypted_payload when is_map(encrypted_payload) -> encrypted_payload + _ -> nil + end + end + + defp client_encrypted_payload(_), do: nil + + def soft_delete_mirror_message(channel, federation_message_id, deleted_at, remote_domain) + when is_map(channel) and is_binary(federation_message_id) and is_binary(remote_domain) do + case Repo.get_by(ChatMessage, + conversation_id: channel.id, + federated_source: federation_message_id + ) do + nil when channel.is_federated_mirror == true -> + %ChatMessage{} + |> ChatMessage.changeset(%{ + conversation_id: channel.id, + federated_source: federation_message_id, + origin_domain: String.downcase(remote_domain), + is_federated_mirror: true, + message_type: "text", + deleted_at: parse_datetime(deleted_at) || DateTime.utc_now() + }) + |> Repo.insert() + + nil -> + {:error, :not_found} + + %ChatMessage{} = message -> + attrs = %{deleted_at: parse_datetime(deleted_at) || DateTime.utc_now()} + + case message |> ChatMessage.changeset(attrs) |> Repo.update() do + {:ok, updated} -> {:ok, updated} + {:error, reason} -> {:error, reason} + end + end + end + + def soft_delete_mirror_message(_channel, _federation_message_id, _deleted_at, _remote_domain), + do: {:error, :invalid_event_payload} + + def get_mirror_message(channel, federation_message_id) + when is_map(channel) and is_binary(federation_message_id) do + case Repo.get_by(ChatMessage, + conversation_id: channel.id, + federated_source: federation_message_id + ) do + nil -> {:error, :not_found} + %ChatMessage{} = message -> {:ok, message} + end + end + + def get_mirror_message(_channel, _federation_message_id), do: {:error, :invalid_event_payload} + + def ensure_remote_actor_membership(channel, remote_actor_id, remote_domain, action \\ :write) + + def ensure_remote_actor_membership(channel, remote_actor_id, _remote_domain, action) + when is_map(channel) and is_integer(remote_actor_id) and action in [:read, :write] do + case Repo.get(Server, channel.server_id) do + %Server{} -> + remote_action = if action == :read, do: :participate, else: :write + RoomACL.authorize_remote_actor_action(channel, remote_actor_id, remote_action) + + _ -> + {:error, :invalid_event_payload} + end + end + + def ensure_remote_actor_membership(_channel, _remote_actor_id, _remote_domain, _action), + do: {:error, :invalid_event_payload} + + def ensure_remote_actor_governance_permission( + channel, + remote_actor_id, + action, + options \\ %{} + ) + + def ensure_remote_actor_governance_permission( + channel, + remote_actor_id, + action, + options + ) + when is_map(channel) and is_integer(remote_actor_id) and is_atom(action) and is_map(options) do + RoomACL.authorize_remote_actor_action(channel, remote_actor_id, action, options) + end + + def ensure_remote_actor_governance_permission(_channel, _remote_actor_id, _action, _options), + do: {:error, :invalid_event_payload} + + def ensure_remote_message_author(channel, federation_message_id, remote_domain) + when is_map(channel) and is_binary(federation_message_id) and is_binary(remote_domain) do + case Repo.get_by(ChatMessage, + conversation_id: channel.id, + federated_source: federation_message_id + ) do + %ChatMessage{origin_domain: origin_domain} -> + if normalize_domain(origin_domain) == normalize_domain(remote_domain) do + :ok + else + {:error, :not_authorized_for_room} + end + + nil -> + if channel.is_federated_mirror == true do + :ok + else + {:error, :not_found} + end + + _ -> + {:error, :not_authorized_for_room} + end + end + + def ensure_remote_message_author(_channel, _federation_message_id, _remote_domain), + do: {:error, :invalid_event_payload} + + defp event_channel_payload(data) when is_map(data) do + channel_id = event_channel_id(data) + + case data["channel"] do + %{} = channel when is_binary(channel_id) -> Map.put_new(channel, "id", channel_id) + %{} = channel -> channel + _ when is_binary(channel_id) -> %{"id" => channel_id} + _ -> nil + end + end + + defp event_server_id(data) when is_map(data) do + refs = data["refs"] || %{} + get_in(data, ["server", "id"]) || refs["server_id"] + end + + defp event_channel_id(data) when is_map(data) do + refs = data["refs"] || %{} + get_in(data, ["channel", "id"]) || refs["channel_id"] + end + + defp event_room_origin_domain(data) when is_map(data) do + server_host = data |> event_server_id() |> uri_host() + channel_host = data |> event_channel_id() |> uri_host() + + cond do + is_binary(server_host) and is_binary(channel_host) and server_host == channel_host -> + server_host + + is_binary(server_host) and is_binary(channel_host) -> + nil + + is_binary(channel_host) -> + channel_host + + is_binary(server_host) -> + server_host + + true -> + nil + end + end + + defp room_authority_domain(%Server{} = server, data) when is_map(data) do + normalize_domain( + server.origin_domain || + server.federation_id |> uri_host() || + event_room_origin_domain(data) || + Elektrine.Messaging.Federation.local_domain() + ) + end + + defp room_authority_domain(_server, _data), do: nil + + defp ensure_mirror_server_from_event(%{"id" => server_id} = server_payload, remote_domain) + when is_binary(server_id) and is_binary(remote_domain) do + if Map.has_key?(server_payload, "name") do + upsert_mirror_server(server_payload, remote_domain) + else + case Repo.get_by(Server, federation_id: server_id) do + %Server{origin_domain: ^remote_domain} = server -> + {:ok, server} + + %Server{origin_domain: nil} = server -> + {:ok, server} + + %Server{} -> + {:error, :federation_origin_conflict} + + nil -> + {:error, :invalid_event_payload} + end + end + end + + defp ensure_mirror_server_from_event(_server_payload, _remote_domain), + do: {:error, :invalid_event_payload} + + defp ensure_mirror_channel_from_event( + %Server{} = server, + %{"id" => channel_id} = channel_payload + ) + when is_binary(channel_id) do + if Map.has_key?(channel_payload, "name") do + upsert_single_mirror_channel(server, channel_payload) + else + case Repo.get_by(ChatConversation, type: "channel", federated_source: channel_id) do + %ChatConversation{server_id: server_id} = channel when server_id == server.id -> + with :ok <- ensure_channel_origin_matches(channel, server.origin_domain) do + {:ok, channel} + end + + %ChatConversation{} -> + {:error, :federation_origin_conflict} + + nil -> + {:error, :invalid_event_payload} + end + end + end + + defp ensure_mirror_channel_from_event(_server, _channel_payload), + do: {:error, :invalid_event_payload} + + defp resolve_existing_channel_context(server_id, channel_id) + when is_binary(channel_id) do + case resolve_channel_by_federation_id(channel_id) do + %ChatConversation{server_id: conversation_server_id} = channel + when is_integer(conversation_server_id) -> + case Repo.get(Server, conversation_server_id) do + %Server{} = server -> + if channel_matches_identifier?(channel, channel_id) and + (is_nil(server_id) or server_matches_identifier?(server, server_id)) do + {:ok, server, channel} + else + {:error, :federation_origin_conflict} + end + + _ -> + {:error, :invalid_event_payload} + end + + _ -> + {:error, :not_found} + end + end + + defp resolve_channel_by_federation_id(channel_id) when is_binary(channel_id) do + case Repo.get_by(ChatConversation, type: "channel", federated_source: channel_id) do + %ChatConversation{} = channel -> + channel + + nil -> + case local_resource_id(channel_id, "channels") do + id when is_integer(id) -> + case Repo.get(ChatConversation, id) do + %ChatConversation{type: "channel"} = channel -> channel + _ -> nil + end + + _ -> + nil + end + end + end + + defp server_matches_identifier?(%Server{} = server, identifier) when is_binary(identifier) do + normalize_identifier(server.federation_id || server_federation_id(server.id)) == + normalize_identifier(identifier) + end + + defp server_matches_identifier?(_server, _identifier), do: false + + defp channel_matches_identifier?(%ChatConversation{} = channel, identifier) + when is_binary(identifier) do + normalize_identifier(channel.federated_source || channel_federation_id(channel.id)) == + normalize_identifier(identifier) + end + + defp channel_matches_identifier?(_channel, _identifier), do: false + + defp local_resource_id(identifier, resource) + when is_binary(identifier) and is_binary(resource) do + with %URI{scheme: scheme, host: host, path: path} + when scheme in ["http", "https"] and is_binary(host) and host != "" <- + URI.parse(identifier), + true <- host_belongs_to_local_domain?(host), + normalized_path <- String.trim_trailing(path || "", "/"), + ["", "_arblarg", ^resource, id] <- String.split(normalized_path, "/"), + {parsed_id, ""} <- Integer.parse(id) do + parsed_id + else + _ -> nil + end + end + + defp local_resource_id(_identifier, _resource), do: nil + + defp host_belongs_to_local_domain?(host) when is_binary(host) do + local_domain = normalize_domain(Elektrine.Messaging.Federation.local_domain()) + normalized_host = normalize_domain(host) + + normalized_host == local_domain or + String.ends_with?(normalized_host, "." <> local_domain) + end + + defp message_origin_domain(payload, fallback_domain) + when is_map(payload) and is_binary(fallback_domain) do + sender_domain = + payload["sender"] + |> actor_domain_from_payload() + + message_host = payload["id"] |> uri_host() + + cond do + is_binary(sender_domain) and is_binary(message_host) and + host_belongs_to_domain?(message_host, sender_domain) -> + normalize_domain(sender_domain) + + is_binary(message_host) -> + normalize_domain(message_host) + + true -> + normalize_domain(fallback_domain) + end + end + + defp message_origin_domain(_payload, _fallback_domain), do: nil + + defp actor_domain_from_payload(%{} = actor) do + normalize_domain(actor["domain"] || actor[:domain] || uri_host(actor["uri"] || actor[:uri])) + end + + defp actor_domain_from_payload(_actor), do: nil + + defp normalize_identifier(identifier) when is_binary(identifier) do + identifier + |> String.trim() + |> String.trim_trailing("/") + end + + defp normalize_identifier(_identifier), do: nil + + defp normalize_domain(value) when is_binary(value) do + value + |> String.trim() + |> String.downcase() + |> case do + "" -> nil + normalized -> normalized + end + end + + defp normalize_domain(_value), do: nil + + defp host_belongs_to_domain?(host, domain) + when is_binary(host) and is_binary(domain) do + normalized_host = normalize_domain(host) + normalized_domain = normalize_domain(domain) + + normalized_host == normalized_domain or + String.ends_with?(normalized_host, "." <> normalized_domain) + end + + defp host_belongs_to_domain?(_host, _domain), do: false + + defp ensure_channel_origin_matches(%ChatConversation{server_id: server_id}, remote_domain) + when is_integer(server_id) and is_binary(remote_domain) do + case Repo.get(Server, server_id) do + %Server{origin_domain: ^remote_domain} -> :ok + %Server{origin_domain: nil} -> :ok + %Server{} -> {:error, :federation_origin_conflict} + nil -> {:error, :federation_origin_conflict} + end + end + + defp ensure_channel_origin_matches(_channel, _remote_domain), + do: {:error, :federation_origin_conflict} + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/peer_policies.ex b/apps/elektrine/lib/elektrine/messaging/federation/peer_policies.ex new file mode 100644 index 0000000..a5bae8b --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/peer_policies.ex @@ -0,0 +1,407 @@ +defmodule Elektrine.Messaging.Federation.PeerPolicies do + @moduledoc false + + import Ecto.Query, warn: false + + alias Elektrine.Accounts.User + alias Elektrine.Messaging.FederationPeerPolicy + alias Elektrine.Repo + + def apply_runtime_policies(configured_peers) when is_list(configured_peers) do + policy_overrides = runtime_policy_overrides() + + Enum.map(configured_peers, fn peer -> + apply_runtime_policy(peer, Map.get(policy_overrides, String.downcase(peer.domain))) + end) + end + + def apply_runtime_policies(_), do: [] + + def list_peer_controls(configured_peers) when is_list(configured_peers) do + list_peer_controls(configured_peers, []) + end + + def list_peer_controls(configured_peers, discovered_controls) + when is_list(configured_peers) and is_list(discovered_controls) do + {policy_overrides, users_by_id} = list_policy_overrides(include_users: true) + build_peer_controls(configured_peers, discovered_controls, policy_overrides, users_by_id) + end + + def list_peer_controls(_, _), do: [] + + def build_peer_controls( + configured_peers, + discovered_controls, + policy_overrides \\ %{}, + users_by_id \\ %{} + ) + + def build_peer_controls( + configured_peers, + discovered_controls, + policy_overrides, + users_by_id + ) + when is_list(configured_peers) and is_list(discovered_controls) and is_map(policy_overrides) and + is_map(users_by_id) do + configured_by_domain = Map.new(configured_peers, &{String.downcase(&1.domain), &1}) + discovered_by_domain = Map.new(discovered_controls, &{String.downcase(&1.domain), &1}) + + configured_by_domain + |> Map.keys() + |> Enum.concat(Map.keys(discovered_by_domain)) + |> Enum.concat(Map.keys(policy_overrides)) + |> Enum.uniq() + |> Enum.sort() + |> Enum.map(fn domain -> + configured_peer = Map.get(configured_by_domain, domain) + discovered_peer = Map.get(discovered_by_domain, domain) + policy = Map.get(policy_overrides, domain) + effective_peer = apply_runtime_policy(configured_peer, policy) + + %{ + domain: domain, + configured: not is_nil(configured_peer), + discovered: discovered_peer_value(discovered_peer, :discovered, false), + base_url: + if(is_map(configured_peer), + do: configured_peer.base_url, + else: discovered_peer_value(discovered_peer, :base_url) + ), + discovery_url: discovered_peer_value(discovered_peer, :discovery_url), + blocked: + if(is_map(policy), + do: policy.blocked == true, + else: discovered_peer_value(discovered_peer, :blocked, false) + ), + reason: + if(is_map(policy), + do: policy.reason, + else: discovered_peer_value(discovered_peer, :reason) + ), + allow_incoming_override: if(is_map(policy), do: policy.allow_incoming, else: nil), + allow_outgoing_override: if(is_map(policy), do: policy.allow_outgoing, else: nil), + effective_allow_incoming: + effective_allow( + configured_peer, + effective_peer, + discovered_peer, + :effective_allow_incoming + ), + effective_allow_outgoing: + effective_allow( + configured_peer, + effective_peer, + discovered_peer, + :effective_allow_outgoing + ), + updated_at: + if(is_map(policy), + do: policy.updated_at, + else: discovered_peer_value(discovered_peer, :updated_at) + ), + updated_by: if(is_map(policy), do: Map.get(users_by_id, policy.updated_by_id), else: nil), + trust_state: discovered_peer_value(discovered_peer, :trust_state), + protocol_version: discovered_peer_value(discovered_peer, :protocol_version), + features: discovered_peer_value(discovered_peer, :features, %{}), + last_discovered_at: discovered_peer_value(discovered_peer, :last_discovered_at), + last_key_change_at: discovered_peer_value(discovered_peer, :last_key_change_at), + requires_operator_action: + discovered_peer_value(discovered_peer, :requires_operator_action, false) + } + end) + end + + def build_peer_controls(_, _, _, _), do: [] + + def list_policy_domains(search_query \\ nil) do + FederationPeerPolicy + |> maybe_filter_policy_search(search_query) + |> select([p], p.domain) + |> order_by([p], asc: p.domain) + |> Repo.all() + rescue + _ -> + [] + end + + def list_policy_overrides(opts \\ []) when is_list(opts) do + include_users = Keyword.get(opts, :include_users, false) + + query = + FederationPeerPolicy + |> order_by([p], asc: p.domain) + |> maybe_filter_policy_domains(Keyword.get(opts, :domains)) + |> maybe_filter_policy_search(Keyword.get(opts, :search)) + + policy_overrides = + query + |> Repo.all() + |> Map.new(fn policy -> {String.downcase(policy.domain), policy} end) + + if include_users do + {policy_overrides, users_by_id_for_policies(policy_overrides)} + else + policy_overrides + end + rescue + _ -> + if Keyword.get(opts, :include_users, false), do: {%{}, %{}}, else: %{} + end + + def control_stats(configured_peers, discovered_state_by_domain, policy_overrides) + when is_list(configured_peers) and is_map(discovered_state_by_domain) and + is_map(policy_overrides) do + configured_by_domain = Map.new(configured_peers, &{String.downcase(&1.domain), &1}) + + configured_by_domain + |> Map.keys() + |> Enum.concat(Map.keys(discovered_state_by_domain)) + |> Enum.concat(Map.keys(policy_overrides)) + |> Enum.uniq() + |> Enum.reduce(%{blocked: 0, incoming_denied: 0, outgoing_denied: 0}, fn domain, acc -> + configured_peer = Map.get(configured_by_domain, domain) + discovered_peer = Map.get(discovered_state_by_domain, domain) + policy = Map.get(policy_overrides, domain) + effective_peer = apply_runtime_policy(configured_peer, policy) + + blocked = + if is_map(policy), + do: policy.blocked == true, + else: discovered_peer_value(discovered_peer, :blocked, false) + + incoming_allowed = + effective_allow( + configured_peer, + effective_peer, + discovered_peer, + :effective_allow_incoming + ) + + outgoing_allowed = + effective_allow( + configured_peer, + effective_peer, + discovered_peer, + :effective_allow_outgoing + ) + + %{ + blocked: acc.blocked + if(blocked, do: 1, else: 0), + incoming_denied: acc.incoming_denied + if(incoming_allowed, do: 0, else: 1), + outgoing_denied: acc.outgoing_denied + if(outgoing_allowed, do: 0, else: 1) + } + end) + end + + def control_stats(_, _, _), do: %{blocked: 0, incoming_denied: 0, outgoing_denied: 0} + + def upsert_peer_policy(domain, attrs, updated_by_id \\ nil) when is_map(attrs) do + with {:ok, normalized_domain} <- normalize_peer_domain(domain) do + policy = + Repo.get_by(FederationPeerPolicy, domain: normalized_domain) || %FederationPeerPolicy{} + + attrs = + attrs + |> normalize_peer_policy_attrs() + |> Map.put(:domain, normalized_domain) + |> maybe_put_updated_by(updated_by_id) + + policy + |> FederationPeerPolicy.changeset(attrs) + |> Repo.insert_or_update() + end + end + + def clear_peer_policy(domain) do + with {:ok, normalized_domain} <- normalize_peer_domain(domain) do + case Repo.get_by(FederationPeerPolicy, domain: normalized_domain) do + nil -> {:ok, :not_found} + policy -> Repo.delete(policy) + end + end + end + + def block_peer_domain(domain, reason \\ nil, updated_by_id \\ nil) do + attrs = %{ + blocked: true, + allow_incoming: false, + allow_outgoing: false, + reason: normalize_reason(reason) + } + + upsert_peer_policy(domain, attrs, updated_by_id) + end + + def unblock_peer_domain(domain, updated_by_id \\ nil) do + attrs = %{blocked: false, allow_incoming: nil, allow_outgoing: nil, reason: nil} + upsert_peer_policy(domain, attrs, updated_by_id) + end + + defp runtime_policy_overrides do + list_policy_overrides() + rescue + _ -> + %{} + end + + defp users_by_id_for_policies(policy_overrides) when is_map(policy_overrides) do + user_ids = + policy_overrides + |> Map.values() + |> Enum.map(& &1.updated_by_id) + |> Enum.filter(&is_integer/1) + |> Enum.uniq() + + if user_ids == [] do + %{} + else + try do + from(u in User, where: u.id in ^user_ids, select: {u.id, u}) + |> Repo.all() + |> Map.new() + rescue + _ -> + %{} + end + end + end + + defp users_by_id_for_policies(_), do: %{} + + defp apply_runtime_policy(nil, _policy), do: nil + defp apply_runtime_policy(peer, nil) when is_map(peer), do: peer + + defp apply_runtime_policy(peer, policy) when is_map(peer) and is_map(policy) do + blocked? = policy.blocked == true + + allow_incoming = + cond do + blocked? -> false + is_boolean(policy.allow_incoming) -> policy.allow_incoming + true -> peer.allow_incoming + end + + allow_outgoing = + cond do + blocked? -> false + is_boolean(policy.allow_outgoing) -> policy.allow_outgoing + true -> peer.allow_outgoing + end + + %{peer | allow_incoming: allow_incoming, allow_outgoing: allow_outgoing} + end + + defp apply_runtime_policy(peer, _policy), do: peer + + defp effective_allow(configured_peer, effective_peer, _discovered_peer, field) + when is_map(configured_peer) and is_map(effective_peer) do + Map.get(effective_peer, configured_allow_field(field)) == true + end + + defp effective_allow(_configured_peer, _effective_peer, discovered_peer, field) do + discovered_peer_value(discovered_peer, field, false) == true + end + + defp configured_allow_field(:effective_allow_incoming), do: :allow_incoming + defp configured_allow_field(:effective_allow_outgoing), do: :allow_outgoing + + defp discovered_peer_value(peer, key, default \\ nil) + + defp discovered_peer_value(peer, key, default) when is_map(peer) and is_atom(key) do + Map.get(peer, key, Map.get(peer, Atom.to_string(key), default)) + end + + defp discovered_peer_value(_peer, _key, default), do: default + + defp maybe_filter_policy_search(query, search_query) when is_binary(search_query) do + case String.trim(search_query) do + "" -> query + trimmed -> where(query, [p], ilike(p.domain, ^"%#{trimmed}%")) + end + end + + defp maybe_filter_policy_search(query, _search_query), do: query + + defp maybe_filter_policy_domains(query, domains) when is_list(domains) do + normalized_domains = + domains + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.downcase/1) + |> Enum.uniq() + + case normalized_domains do + [] -> where(query, [p], false) + _ -> where(query, [p], p.domain in ^normalized_domains) + end + end + + defp maybe_filter_policy_domains(query, _domains), do: query + + defp normalize_peer_policy_attrs(attrs) when is_map(attrs) do + %{ + allow_incoming: + normalize_optional_boolean(value_from(attrs, :allow_incoming, :__missing__), :__missing__), + allow_outgoing: + normalize_optional_boolean(value_from(attrs, :allow_outgoing, :__missing__), :__missing__), + blocked: + normalize_optional_boolean(value_from(attrs, :blocked, :__missing__), :__missing__), + reason: normalize_reason(value_from(attrs, :reason, :__missing__)) + } + |> Enum.reject(fn {_key, value} -> value == :__missing__ end) + |> Map.new() + end + + defp maybe_put_updated_by(attrs, updated_by_id) + when is_map(attrs) and is_integer(updated_by_id) do + Map.put(attrs, :updated_by_id, updated_by_id) + end + + defp maybe_put_updated_by(attrs, _updated_by_id), do: attrs + + defp normalize_peer_domain(domain) when is_binary(domain) do + normalized = + domain + |> String.trim() + |> String.downcase() + |> String.replace(~r/^https?:\/\//, "") + |> String.split("/", parts: 2) + |> List.first() + |> to_string() + |> String.trim(".") + + if Elektrine.Strings.present?(normalized) do + {:ok, normalized} + else + {:error, :invalid_domain} + end + end + + defp normalize_peer_domain(_), do: {:error, :invalid_domain} + + defp normalize_optional_boolean(:__missing__, missing), do: missing + defp normalize_optional_boolean(nil, _missing), do: nil + defp normalize_optional_boolean(true, _missing), do: true + defp normalize_optional_boolean(false, _missing), do: false + defp normalize_optional_boolean("true", _missing), do: true + defp normalize_optional_boolean("false", _missing), do: false + defp normalize_optional_boolean("1", _missing), do: true + defp normalize_optional_boolean("0", _missing), do: false + defp normalize_optional_boolean("inherit", _missing), do: nil + defp normalize_optional_boolean("", _missing), do: nil + defp normalize_optional_boolean(_value, _missing), do: nil + + defp normalize_reason(:__missing__), do: :__missing__ + + defp normalize_reason(reason) when is_binary(reason) do + trimmed = String.trim(reason) + if Elektrine.Strings.present?(trimmed), do: trimmed, else: nil + end + + defp normalize_reason(nil), do: nil + defp normalize_reason(_), do: nil + + defp value_from(map, key, default) do + Map.get(map, key, Map.get(map, Atom.to_string(key), default)) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/peers.ex b/apps/elektrine/lib/elektrine/messaging/federation/peers.ex new file mode 100644 index 0000000..1faf19d --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/peers.ex @@ -0,0 +1,311 @@ +defmodule Elektrine.Messaging.Federation.Peers do + @moduledoc false + + import Ecto.Query, warn: false + + alias Elektrine.ActivityPub.Actor, as: ActivityPubActor + alias Elektrine.Messaging.Federation.{Contexts, Discovery, Runtime, State, Utils} + + alias Elektrine.Messaging.{ + ChatConversation, + Federation.PeerPolicies, + FederationAccountPresenceState, + FederationMembershipState, + FederationRoomPresenceState + } + + alias Elektrine.Profiles.Follow + alias Elektrine.Repo + + def enabled?, do: Runtime.enabled?() + + def peers do + Runtime.configured_peers() + |> PeerPolicies.apply_runtime_policies() + end + + def incoming_peer(domain) when is_binary(domain) do + case resolve_peer(domain) do + %{allow_incoming: true} = peer -> peer + _ -> nil + end + end + + def incoming_peer(_domain), do: nil + + def outgoing_peers do + Enum.filter(peers(), & &1.allow_outgoing) + end + + def outgoing_peer(domain) when is_binary(domain) do + case resolve_peer(domain) do + %{allow_outgoing: true} = peer -> peer + _ -> nil + end + end + + def outgoing_peer(_domain), do: nil + + def local_domain, do: Runtime.local_domain() + + def list_server_presence_states(server_id) when is_integer(server_id) do + server_presence_states_query(server_id) + |> Repo.all() + |> normalize_presence_states() + end + + def list_server_presence_states(_server_id), do: [] + + def list_visible_server_presence_states(server_id, user_id) + when is_integer(server_id) and is_integer(user_id) do + server_presence_states_query(server_id, user_id) + |> Repo.all() + |> normalize_presence_states() + end + + def list_visible_server_presence_states(_server_id, _user_id), do: [] + + def list_room_presence_states(conversation_id) when is_integer(conversation_id) do + room_presence_states_query(conversation_id) + |> Repo.all() + |> normalize_presence_states() + end + + def list_room_presence_states(_conversation_id), do: [] + + def list_visible_room_presence_states(conversation_id, user_id) + when is_integer(conversation_id) and is_integer(user_id) do + if viewer_can_access_conversation?(conversation_id, user_id) do + room_presence_states_query(conversation_id) + |> Repo.all() + |> normalize_presence_states() + else + [] + end + end + + def list_visible_room_presence_states(_conversation_id, _user_id), do: [] + + def list_peer_controls do + PeerPolicies.list_peer_controls(Runtime.configured_peers(), discovered_peer_controls()) + end + + def paginate_peer_controls(search_query, page, per_page) do + configured_peers = Runtime.configured_peers() + filtered_configured_peers = filter_configured_peers(configured_peers, search_query) + + domains = + filtered_configured_peers + |> Enum.map(&String.downcase(&1.domain)) + |> Enum.concat(Discovery.list_discovered_peer_domains(search_query)) + |> Enum.concat(PeerPolicies.list_policy_domains(search_query)) + |> Enum.uniq() + |> Enum.sort() + + total_count = length(domains) + total_pages = total_pages(total_count, per_page) + safe_page = clamp_page(page, total_pages) + offset = (safe_page - 1) * per_page + page_domains = Enum.slice(domains, offset, per_page) + + {policy_overrides, users_by_id} = + PeerPolicies.list_policy_overrides(domains: page_domains, include_users: true) + + discovered_controls = + Discovery.list_discovered_peer_controls(discovery_context(), domains: page_domains) + + page_entries = + filtered_configured_peers + |> Enum.filter(fn peer -> String.downcase(peer.domain) in page_domains end) + |> PeerPolicies.build_peer_controls(discovered_controls, policy_overrides, users_by_id) + + %{ + entries: page_entries, + page: safe_page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages, + stats: peer_control_stats(configured_peers) + } + end + + def upsert_peer_policy(domain, attrs, updated_by_id \\ nil) when is_map(attrs) do + PeerPolicies.upsert_peer_policy(domain, attrs, updated_by_id) + end + + def clear_peer_policy(domain), do: PeerPolicies.clear_peer_policy(domain) + + def block_peer_domain(domain, reason \\ nil, updated_by_id \\ nil) do + PeerPolicies.block_peer_domain(domain, reason, updated_by_id) + end + + def unblock_peer_domain(domain, updated_by_id \\ nil) do + PeerPolicies.unblock_peer_domain(domain, updated_by_id) + end + + def resolve_peer(domain) when is_binary(domain) do + Discovery.resolve_peer(domain, discovery_context()) + end + + def resolve_peer(_domain), do: nil + + defp discovered_peer_controls do + Discovery.discovered_peer_controls(discovery_context()) + end + + defp discovery_context do + Contexts.discovery(%{ + peers: &peers/0, + truncate: &Utils.truncate/1 + }) + end + + defp peer_control_stats(configured_peers) do + discovered_state = Discovery.discovered_peer_state_map() + policy_overrides = PeerPolicies.list_policy_overrides() + + PeerPolicies.control_stats(configured_peers, discovered_state, policy_overrides) + end + + defp filter_configured_peers(configured_peers, search_query) + when is_list(configured_peers) and is_binary(search_query) do + case String.trim(search_query) do + "" -> + configured_peers + + trimmed -> + needle = String.downcase(trimmed) + + Enum.filter(configured_peers, fn peer -> + String.contains?(String.downcase(peer.domain), needle) + end) + end + end + + defp filter_configured_peers(configured_peers, _search_query) when is_list(configured_peers), + do: configured_peers + + defp server_presence_states_query(server_id, viewer_user_id \\ nil) + when is_integer(server_id) do + query = + from(state in FederationAccountPresenceState, + join: actor in ActivityPubActor, + on: actor.id == state.remote_actor_id, + join: membership in FederationMembershipState, + on: membership.remote_actor_id == state.remote_actor_id and membership.state == "active", + join: conversation in ChatConversation, + on: + conversation.id == membership.conversation_id and conversation.server_id == ^server_id, + join: follow in Follow, + on: + follow.remote_actor_id == state.remote_actor_id and is_nil(follow.followed_id) and + follow.pending == false, + distinct: actor.id, + order_by: [desc: state.updated_at_remote], + select: %{ + remote_actor_id: actor.id, + username: actor.username, + display_name: actor.display_name, + domain: actor.domain, + avatar_url: actor.avatar_url, + status: state.status, + activities: state.activities, + updated_at: state.updated_at_remote, + expires_at: state.expires_at_remote + } + ) + + case viewer_user_id do + user_id when is_integer(user_id) -> + from([_state, _actor, _membership, _conversation, follow] in query, + where: follow.follower_id == ^user_id + ) + + _ -> + query + end + end + + defp room_presence_states_query(conversation_id) when is_integer(conversation_id) do + from(state in FederationRoomPresenceState, + join: actor in ActivityPubActor, + on: actor.id == state.remote_actor_id, + join: membership in FederationMembershipState, + on: + membership.remote_actor_id == state.remote_actor_id and + membership.conversation_id == state.conversation_id and membership.state == "active", + where: state.conversation_id == ^conversation_id, + distinct: actor.id, + order_by: [desc: state.updated_at_remote], + select: %{ + remote_actor_id: actor.id, + username: actor.username, + display_name: actor.display_name, + domain: actor.domain, + avatar_url: actor.avatar_url, + status: state.status, + activities: state.activities, + updated_at: state.updated_at_remote, + expires_at: state.expires_at_remote + } + ) + end + + defp normalize_presence_states(states) when is_list(states) do + Enum.map(states, fn state -> + effective_status = + if State.expired_presence_state?(state.expires_at) and state.status != "offline" do + "offline" + else + state.status + end + + %{ + remote_actor_id: state.remote_actor_id, + handle: "@#{state.username}@#{state.domain}", + label: + case normalize_optional_string(state.display_name) do + nil -> "@#{state.username}@#{state.domain}" + display_name -> "#{display_name} (@#{state.username}@#{state.domain})" + end, + avatar_url: state.avatar_url, + status: effective_status, + activities: State.normalize_presence_activities(state.activities), + updated_at: state.updated_at, + expires_at: state.expires_at + } + end) + end + + defp normalize_presence_states(_states), do: [] + + defp viewer_can_access_conversation?(conversation_id, user_id) + when is_integer(conversation_id) and is_integer(user_id) do + from(conversation in ChatConversation, + join: membership in assoc(conversation, :members), + on: membership.user_id == ^user_id and is_nil(membership.left_at), + where: conversation.id == ^conversation_id, + select: 1, + limit: 1 + ) + |> Repo.one() + |> Kernel.==(1) + end + + defp viewer_can_access_conversation?(_conversation_id, _user_id), do: false + + defp total_pages(total_count, per_page) when total_count > 0 and per_page > 0 do + div(total_count + per_page - 1, per_page) + end + + defp total_pages(_, _), do: 1 + + defp clamp_page(page, _total_pages) when page < 1, do: 1 + defp clamp_page(page, total_pages) when page > total_pages, do: total_pages + defp clamp_page(page, _total_pages), do: page + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_value), do: nil +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/protocol.ex b/apps/elektrine/lib/elektrine/messaging/federation/protocol.ex new file mode 100644 index 0000000..7ed27fc --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/protocol.ex @@ -0,0 +1,328 @@ +defmodule Elektrine.Messaging.Federation.Protocol do + @moduledoc false + + alias Elektrine.Messaging.ArblargProfiles + alias Elektrine.Messaging.ArblargSDK + + def local_discovery_document(version, context) when is_binary(version) and is_map(context) do + unsigned = %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => ArblargSDK.protocol_id(), + "protocol_labels" => [ArblargSDK.protocol_label()], + "default_protocol_label" => ArblargSDK.protocol_label(), + "protocol_versions" => [ArblargSDK.protocol_version()], + "default_protocol_version" => ArblargSDK.protocol_version(), + "version" => 1, + "domain" => Map.get(context, :local_domain), + "identity" => Map.get(context, :identity), + "endpoints" => + discovery_endpoints( + Map.get(context, :base_url), + version, + Map.get(context, :allow_insecure_transport, false) + ), + "features" => discovery_features(), + "limits" => Map.get(context, :limits, %{}), + "transport_profiles" => + transport_profiles_document( + Map.get(context, :limits, %{}), + Map.get(context, :cache_ttl_seconds, 0) + ), + "relay_transport" => %{ + "mode" => "optional", + "community_hostable" => true, + "official_operator" => Map.get(context, :official_relay_operator), + "official_relays" => Map.get(context, :official_relays, []) + } + } + + sign_document(unsigned, Map.get(context, :sign_fun)) + end + + def arblarg_profiles_document(version, context) when is_binary(version) and is_map(context) do + clock_skew_seconds = Map.get(context, :clock_skew_seconds) + core_event_types = ArblargProfiles.core_event_types() + extension_event_types = ArblargProfiles.extension_event_types() + supported_event_types = ArblargSDK.supported_event_types() + + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => ArblargSDK.protocol_id(), + "protocol_label" => ArblargSDK.protocol_label(), + "protocol_versions" => [ArblargSDK.protocol_version()], + "default_protocol_version" => ArblargSDK.protocol_version(), + "version" => 1, + "profiles" => ArblargProfiles.profile_badges(), + "compatibility_claims" => ArblargProfiles.passing_profile_claims(), + "extensions" => ArblargProfiles.extension_registry(), + "features" => profiles_features(), + "security" => %{ + "request_signature" => %{ + "algorithm" => ArblargSDK.signature_algorithm(), + "required" => true, + "headers" => [ + "x-arblarg-domain", + "x-arblarg-key-id", + "x-arblarg-timestamp", + "x-arblarg-content-digest", + "x-arblarg-request-id", + "x-arblarg-signature-algorithm", + "x-arblarg-signature" + ], + "clock_skew_seconds" => clock_skew_seconds + }, + "event_signature" => %{ + "algorithm" => ArblargSDK.signature_algorithm(), + "field" => "signature", + "required" => true + }, + "discovery_signature" => %{ + "algorithm" => ArblargSDK.signature_algorithm(), + "field" => "signature", + "required" => true + }, + "transport" => %{ + "tls_required" => true, + "allow_insecure_http_for_testing" => Map.get(context, :allow_insecure_transport, false) + } + }, + "events" => %{ + "core" => core_event_types, + "extensions" => extension_event_types, + "supported" => supported_event_types, + "ordering" => %{ + "cursor" => %{"field" => "sequence", "scope" => ["origin_domain", "stream_id"]}, + "idempotency" => %{"field" => "idempotency_key", "scope" => ["origin_domain"]}, + "retry" => %{"strategy" => "bounded_exponential_backoff", "deterministic" => true} + } + }, + "schemas" => discovery_schema_map(Map.get(context, :base_url), version), + "relay_transport" => %{ + "mode" => "optional", + "community_hostable" => true, + "official_operator" => Map.get(context, :official_relay_operator), + "official_relays" => Map.get(context, :official_relays, []) + }, + "limits" => Map.get(context, :limits, %{}), + "transport_profiles" => + transport_profiles_document( + Map.get(context, :limits, %{}), + Map.get(context, :cache_ttl_seconds, 0) + ), + "wire_contract" => %{ + "status" => "stable", + "breaking_changes" => "disallowed_in_1_0", + "change_policy" => "additive_only", + "deprecation_policy" => "must_remain_compatible_with_1_0" + }, + "endpoints" => + discovery_endpoints( + Map.get(context, :base_url), + version, + Map.get(context, :allow_insecure_transport, false) + ), + "conformance" => %{ + "gate" => "hard", + "suite_version" => ArblargProfiles.conformance_suite_version(), + "required_profile" => ArblargProfiles.core_profile_id(), + "test_command" => ArblargProfiles.conformance_test_command() + } + } + end + + def discovery_limits_for_transport(context) when is_map(context) do + Map.get(context, :limits, %{}) + end + + def transport_profiles_for_transport(context) when is_map(context) do + transport_profiles_document( + Map.get(context, :limits, %{}), + Map.get(context, :cache_ttl_seconds, 0) + ) + end + + def session_flow_control_for_transport(context) when is_map(context) do + session_flow_control_document(Map.get(context, :limits, %{})) + end + + def discovery_endpoints(base_url, version, allow_insecure_transport) + when is_binary(base_url) and is_binary(version) do + endpoints = %{ + "well_known" => "#{base_url}/.well-known/_arblarg", + "well_known_versioned" => "#{base_url}/.well-known/_arblarg/{version}", + "profiles" => "#{base_url}/_arblarg/profiles", + "events" => "#{base_url}/_arblarg/events", + "events_batch" => "#{base_url}/_arblarg/events/batch", + "ephemeral" => "#{base_url}/_arblarg/ephemeral", + "sync" => "#{base_url}/_arblarg/sync", + "stream_events" => "#{base_url}/_arblarg/streams/events", + "public_servers" => "#{base_url}/_arblarg/servers/public", + "snapshot_template" => "#{base_url}/_arblarg/servers/{server_id}/snapshot", + "schema_template" => "#{base_url}/_arblarg/{version}/schemas/{name}", + "schemas" => "#{base_url}/_arblarg/#{version}/schemas" + } + + case session_websocket_url(base_url, allow_insecure_transport) do + url when is_binary(url) -> Map.put(endpoints, "session_websocket", url) + _ -> endpoints + end + end + + def discovery_endpoints(_base_url, _version, _allow_insecure_transport), do: %{} + + def transport_profiles_document(limits, cache_ttl_seconds) + when is_map(limits) and is_integer(cache_ttl_seconds) do + %{ + "preferred_order" => [ + "session_websocket", + "events_batch_cbor", + "events_batch_json", + "events_json" + ], + "fallback_order" => [ + "session_websocket", + "events_batch_cbor", + "events_batch_json", + "events_json" + ], + "cache_ttl_seconds" => cache_ttl_seconds, + "downgrade_http_statuses" => [404, 406, 410, 415, 426, 501], + "session_websocket" => %{ + "mode" => "optional", + "framing" => "arblarg_websocket_stream_session", + "request_path" => "/_arblarg/session", + "subprotocol" => "arblarg.session.v1", + "encodings" => ["json", "cbor"], + "flow_control" => session_flow_control_document(limits), + "delivery_ops" => ["stream_batch", "deliver_ephemeral"], + "control_ops" => ["events_batch", "ephemeral_batch", "stream_events", "snapshot", "ping"] + } + } + end + + def transport_profiles_document(_limits, _cache_ttl_seconds), do: %{} + + def session_flow_control_document(limits) when is_map(limits) do + %{ + "mode" => "ack_window", + "max_inflight_batches" => Map.get(limits, "max_session_inflight_batches"), + "max_inflight_events" => Map.get(limits, "max_session_inflight_events") + } + end + + def session_flow_control_document(_limits), do: %{} + + def discovery_schema_map(base_url, version) when is_binary(base_url) and is_binary(version) do + schema_base = "#{base_url}/_arblarg/#{version}/schemas" + + schema_links = + ArblargSDK.schema_bindings() + |> Enum.reduce(%{}, fn {schema_key, schema_name}, acc -> + Map.put(acc, schema_key, "#{schema_base}/#{schema_name}") + end) + + Map.merge(%{"version" => version, "base_url" => schema_base}, schema_links) + end + + def discovery_schema_map(_base_url, _version), do: %{} + + defp sign_document(document, fun) when is_function(fun, 1), do: fun.(document) + defp sign_document(document, _fun), do: document + + defp session_websocket_url(base_url, allow_insecure_transport) when is_binary(base_url) do + case URI.parse(base_url) do + %URI{scheme: "https"} = uri -> + %{uri | scheme: "wss", path: "/_arblarg/session"} + |> URI.to_string() + + %URI{scheme: "http"} = uri -> + if allow_insecure_transport do + %{uri | scheme: "ws", path: "/_arblarg/session"} + |> URI.to_string() + else + nil + end + + _ -> + nil + end + end + + defp discovery_features do + %{ + "relay_transport" => true, + "batched_event_delivery" => true, + "stream_catch_up" => true, + "compact_event_refs" => true, + "binary_event_batches" => true, + "read_cursors" => true, + "ephemeral_lane" => true, + "structured_error_codes" => true, + "origin_owned_identifiers" => true, + "signed_snapshots" => true, + "snapshot_governance" => true, + "snapshot_message_deletions" => true, + "snapshot_reactions" => true, + "snapshot_read_cursors" => true, + "snapshot_extensions" => true, + "canonical_extension_event_types" => true, + "session_transport" => true, + "dynamic_peer_discovery" => true, + "open_domain_bootstrap" => true, + "key_continuity_tracking" => true, + "key_continuity_quarantine" => true, + "deterministic_governance_projection" => true, + "client_sync_cursors" => true, + "gateway_resume" => true, + "attachment_authorization_metadata" => true, + "rich_room_metadata" => true, + "federation_abuse_controls" => true + } + end + + defp profiles_features do + %{ + "event_federation" => true, + "snapshot_sync" => true, + "batched_event_delivery" => true, + "stream_catch_up" => true, + "ordered_streams" => true, + "idempotent_events" => true, + "relay_transport" => true, + "event_signature_envelope" => true, + "request_replay_protection" => true, + "strict_profiles" => true, + "extension_negotiation" => true, + "compact_event_refs" => true, + "binary_event_batches" => true, + "read_cursors" => true, + "ephemeral_lane" => true, + "ephemeral_presence" => true, + "ephemeral_typing" => true, + "structured_error_codes" => true, + "origin_owned_identifiers" => true, + "signed_snapshots" => true, + "snapshot_governance" => true, + "snapshot_message_deletions" => true, + "snapshot_reactions" => true, + "snapshot_read_cursors" => true, + "snapshot_extensions" => true, + "canonical_extension_event_types" => true, + "session_transport" => true, + "dynamic_peer_discovery" => true, + "open_domain_bootstrap" => true, + "discovery_document_signature" => true, + "dns_identity_proof" => true, + "configured_trust_anchors" => true, + "key_continuity_tracking" => true, + "key_continuity_quarantine" => true, + "deterministic_governance_projection" => true, + "client_sync_cursors" => true, + "gateway_resume" => true, + "attachment_authorization_metadata" => true, + "rich_room_metadata" => true, + "federation_abuse_controls" => true, + "wire_contract_frozen" => true + } + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/public_discovery.ex b/apps/elektrine/lib/elektrine/messaging/federation/public_discovery.ex new file mode 100644 index 0000000..7dae25c --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/public_discovery.ex @@ -0,0 +1,89 @@ +defmodule Elektrine.Messaging.Federation.PublicDiscovery do + @moduledoc false + + alias Elektrine.Messaging.ArblargSDK + alias Elektrine.Messaging.Federation.{Contexts, Discovery, Peers, Protocol, Runtime, Utils} + + def discover_peer(domain, opts \\ []) + + def discover_peer(domain, opts) when is_binary(domain) and is_list(opts) do + Discovery.discover_peer(domain, opts, discovery_context()) + end + + def discover_peer(_domain, _opts), do: {:error, :invalid_domain} + + def refresh_peer_discovery(domain) when is_binary(domain) do + Discovery.discover_peer(domain, [force: true], discovery_context()) + end + + def refresh_peer_discovery(_domain), do: {:error, :invalid_domain} + + def local_discovery_document(version \\ ArblargSDK.protocol_version()) do + Protocol.local_discovery_document(version, signed_protocol_context()) + end + + def arblarg_profiles_document(version \\ ArblargSDK.protocol_version()) do + Protocol.arblarg_profiles_document(version, protocol_context()) + end + + def discovery_limits_for_transport do + Protocol.discovery_limits_for_transport(protocol_context()) + end + + def transport_profiles_for_transport do + Protocol.transport_profiles_for_transport(protocol_context()) + end + + def session_flow_control_for_transport do + Protocol.session_flow_control_for_transport(protocol_context()) + end + + def transport_profiles_document do + Protocol.transport_profiles_document(discovery_limits(), Runtime.discovery_ttl_seconds()) + end + + defp discovery_limits do + %{ + "max_batch_events" => Runtime.incoming_batch_limit(), + "max_ephemeral_items" => Runtime.incoming_ephemeral_limit(), + "max_snapshot_channels" => Runtime.snapshot_channel_limit(), + "max_snapshot_messages" => Runtime.snapshot_message_limit(), + "max_snapshot_governance_entries" => Runtime.snapshot_governance_limit(), + "max_stream_replay_limit" => Runtime.stream_replay_limit(), + "max_session_inflight_batches" => Runtime.session_max_inflight_batches(), + "max_session_inflight_events" => Runtime.session_max_inflight_events(), + "typing_ttl_ms" => 10_000, + "presence_ttl_ms" => 86_400_000 + } + end + + defp protocol_context do + %{ + local_domain: Runtime.local_domain(), + base_url: Runtime.local_base_url(), + allow_insecure_transport: Runtime.allow_insecure_transport?(), + limits: discovery_limits(), + cache_ttl_seconds: Runtime.discovery_ttl_seconds(), + official_relay_operator: Runtime.official_relay_operator(), + official_relays: Runtime.discovery_official_relays(), + clock_skew_seconds: Runtime.clock_skew_seconds() + } + end + + defp signed_protocol_context do + protocol_context() + |> Map.put(:identity, Runtime.local_identity_discovery_identity()) + |> Map.put(:sign_fun, &sign_discovery_document/1) + end + + defp discovery_context do + Contexts.discovery(%{ + peers: &Peers.peers/0, + truncate: &Utils.truncate/1 + }) + end + + defp sign_discovery_document(document) do + Discovery.sign_discovery_document(document, discovery_context()) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/publisher.ex b/apps/elektrine/lib/elektrine/messaging/federation/publisher.ex new file mode 100644 index 0000000..75f8b61 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/publisher.ex @@ -0,0 +1,484 @@ +defmodule Elektrine.Messaging.Federation.Publisher do + @moduledoc false + + require Logger + + alias Elektrine.Async + alias Elektrine.Messaging.{ChatMessage, ChatMessageReaction} + + @missing_identity_config_message "messaging federation requires explicit identity keys in production; configure :identity_keys or :identity_shared_secret" + + def push_server_snapshot(server_id, context) when is_map(context) do + if enabled?(context) do + Enum.each(call(context, :outgoing_peers, []), fn peer -> + with {:ok, snapshot} <- build_server_snapshot_for_peer(server_id, peer, context) do + call(context, :push_snapshot_to_peer, [peer, snapshot]) + end + end) + end + + :ok + end + + def publish_server_upsert(server_id, context) when is_map(context) do + if enabled?(context) do + with {:ok, event} <- call(context, :build_server_upsert_event, [server_id]) do + enqueue_outbox_event(context, event) + end + end + + :ok + end + + def publish_message_created(%ChatMessage{} = message, context) when is_map(context) do + if enabled?(context) do + with {:ok, event} <- call(context, :build_message_created_event, [message]) do + enqueue_outbox_event(context, event) + end + end + + :ok + end + + def publish_message_created(message_id, context) + when is_integer(message_id) and is_map(context) do + case call(context, :get_chat_message, [message_id]) do + nil -> :ok + message -> publish_message_created(message, context) + end + end + + def publish_dm_message_created(%ChatMessage{} = message, remote_handle, context) + when is_map(context) do + if enabled?(context) do + with {:ok, outbound_handle} <- + call(context, :resolve_outbound_dm_handle, [message, remote_handle]), + {:ok, recipient} <- call(context, :normalize_remote_dm_handle, [outbound_handle]), + %{} <- call(context, :outgoing_peer, [recipient.domain]), + {:ok, event} <- + call(context, :build_dm_message_created_event, [message, recipient.handle]) do + enqueue_outbox_event(context, event, [recipient.domain]) + end + end + + :ok + end + + def publish_dm_call_invite(session_id, context) + when is_integer(session_id) and is_map(context) do + if enabled?(context) do + with {:ok, event, target_domains} <- + call(context, :build_dm_call_invite_event, [session_id]) do + enqueue_outbox_event(context, event, target_domains) + end + end + + :ok + end + + def publish_dm_call_accept(session_id, context) + when is_integer(session_id) and is_map(context) do + if enabled?(context) do + with {:ok, event, target_domains} <- + call(context, :build_dm_call_accept_event, [session_id]) do + enqueue_outbox_event(context, event, target_domains) + end + end + + :ok + end + + def publish_dm_call_reject(session_id, context) + when is_integer(session_id) and is_map(context) do + if enabled?(context) do + with {:ok, event, target_domains} <- + call(context, :build_dm_call_reject_event, [session_id]) do + enqueue_outbox_event(context, event, target_domains) + end + end + + :ok + end + + def publish_dm_call_end(session_id, context) + when is_integer(session_id) and is_map(context) do + if enabled?(context) do + with {:ok, event, target_domains} <- call(context, :build_dm_call_end_event, [session_id]) do + enqueue_outbox_event(context, event, target_domains) + end + end + + :ok + end + + def publish_dm_call_signal(session_id, actor_user_id, kind, signal_payload, context) + when is_integer(session_id) and is_integer(actor_user_id) and is_binary(kind) and + is_map(signal_payload) and is_map(context) do + if enabled?(context) do + Async.start(fn -> + with {:ok, item, target_domains} <- + call(context, :build_dm_call_signal_ephemeral_item, [ + session_id, + actor_user_id, + kind, + signal_payload + ]), + true <- target_domains != [] do + _ = call(context, :fanout_ephemeral_batch, [[item], target_domains]) + end + end) + end + + :ok + end + + def publish_message_updated(%ChatMessage{} = message, context) when is_map(context) do + if enabled?(context) do + with {:ok, event} <- call(context, :build_message_updated_event, [message]) do + enqueue_outbox_event(context, event) + end + end + + :ok + end + + def publish_message_updated(message_id, context) + when is_integer(message_id) and is_map(context) do + case call(context, :get_chat_message, [message_id]) do + nil -> :ok + message -> publish_message_updated(message, context) + end + end + + def publish_message_deleted(%ChatMessage{} = message, context) when is_map(context) do + if enabled?(context) do + with {:ok, event} <- call(context, :build_message_deleted_event, [message]) do + enqueue_outbox_event(context, event) + end + end + + :ok + end + + def publish_message_deleted(message_id, context) + when is_integer(message_id) and is_map(context) do + case call(context, :get_chat_message, [message_id]) do + nil -> :ok + message -> publish_message_deleted(message, context) + end + end + + def publish_reaction_added(%ChatMessage{} = message, %ChatMessageReaction{} = reaction, context) + when is_map(context) do + if enabled?(context) do + with {:ok, event} <- call(context, :build_reaction_added_event, [message, reaction]) do + enqueue_outbox_event(context, event) + end + end + + :ok + end + + def publish_reaction_removed(%ChatMessage{} = message, user_id, emoji, context) + when is_integer(user_id) and is_map(context) do + if enabled?(context) do + with {:ok, event} <- call(context, :build_reaction_removed_event, [message, user_id, emoji]) do + enqueue_outbox_event(context, event) + end + end + + :ok + end + + def publish_read_cursor(conversation_id, user_id, message_id, read_at, context) + when is_integer(conversation_id) and is_integer(user_id) and is_integer(message_id) and + is_map(context) do + if enabled?(context) do + with {:ok, event, target_domains} <- + call(context, :build_read_cursor_event, [ + conversation_id, + user_id, + message_id, + read_at + ]) do + enqueue_outbox_event(context, event, target_domains) + end + end + + :ok + end + + def publish_read_receipt(conversation_id, user_id, message_id, read_at, context) + when is_integer(conversation_id) and is_integer(user_id) and is_integer(message_id) and + is_map(context) do + publish_read_cursor(conversation_id, user_id, message_id, read_at, context) + end + + def publish_typing_started(conversation_id, user_id, context) + when is_integer(conversation_id) and is_integer(user_id) and is_map(context) do + publish_typing_indicator(conversation_id, user_id, :start, context) + end + + def publish_typing_stopped(conversation_id, user_id, context) + when is_integer(conversation_id) and is_integer(user_id) and is_map(context) do + publish_typing_indicator(conversation_id, user_id, :stop, context) + end + + def publish_presence_update(_server_id, user_id, status, activities, context) + when is_integer(user_id) and is_binary(status) and + is_map(context) do + publish_user_presence_update(user_id, status, activities, context) + end + + def publish_room_presence_update(conversation_id, user_id, status, activities, context) + when is_integer(conversation_id) and is_integer(user_id) and is_binary(status) and + is_map(context) do + if enabled?(context) do + Async.start(fn -> + with {:ok, item, target_domains} <- + call(context, :build_room_presence_ephemeral_item, [ + conversation_id, + user_id, + status, + activities + ]), + true <- target_domains != [] do + _ = call(context, :fanout_ephemeral_batch, [[item], target_domains]) + end + end) + end + + :ok + end + + def publish_user_presence_update(user_id, status, activities, context) + when is_integer(user_id) and is_binary(status) and is_map(context) do + if enabled?(context) do + target_domains = call(context, :presence_subscriber_domains_for_local_user, [user_id]) + + if target_domains != [] do + Async.start(fn -> + with {:ok, item} <- + call(context, :build_presence_ephemeral_item, [user_id, status, activities]) do + _ = call(context, :fanout_ephemeral_batch, [[item], target_domains]) + end + end) + end + end + + :ok + end + + def publish_extension_event(conversation_id, actor_user_id, event_type, payload, context) + when is_integer(conversation_id) and is_integer(actor_user_id) and is_binary(event_type) and + is_map(payload) and is_map(context) do + if enabled?(context) do + with {:ok, event, target_domains, canonical_event_type, extension_payload} <- + call(context, :build_extension_event, [ + conversation_id, + actor_user_id, + event_type, + payload + ]), + :ok <- + call(context, :persist_local_extension_projection, [ + conversation_id, + canonical_event_type, + extension_payload + ]) do + enqueue_outbox_event(context, event, target_domains) + end + end + + :ok + end + + def publish_membership_state(conversation_id, user_id, state, role, context) + when is_integer(conversation_id) and is_integer(user_id) and is_binary(state) and + is_binary(role) and is_map(context) do + if enabled?(context) do + with {:ok, event, target_domains} <- + call(context, :build_membership_upsert_event, [ + conversation_id, + user_id, + state, + role + ]) do + enqueue_outbox_event(context, event, target_domains) + end + end + + :ok + end + + def publish_invite_state( + conversation_id, + target_user_id, + actor_user_id, + state, + role, + metadata, + context + ) + when is_integer(conversation_id) and is_integer(target_user_id) and + is_integer(actor_user_id) and is_binary(state) and is_binary(role) and + is_map(metadata) and is_map(context) do + if enabled?(context) do + with {:ok, event, target_domains} <- + call(context, :build_invite_upsert_event, [ + conversation_id, + target_user_id, + actor_user_id, + state, + role, + metadata + ]), + :ok <- + call(context, :persist_local_invite_projection, [ + conversation_id, + target_user_id, + actor_user_id, + state, + role, + metadata + ]) do + enqueue_outbox_event(context, event, target_domains) + end + end + + :ok + end + + def publish_remote_invite_state( + conversation_id, + target_payload, + actor_user_id, + state, + role, + metadata, + context + ) + when is_integer(conversation_id) and is_map(target_payload) and + is_integer(actor_user_id) and is_binary(state) and is_binary(role) and + is_map(metadata) and is_map(context) do + if enabled?(context) do + with {:ok, event, target_domains} <- + call(context, :build_invite_upsert_event_for_target_payload, [ + conversation_id, + target_payload, + actor_user_id, + state, + role, + metadata + ]) do + enqueue_outbox_event(context, event, target_domains) + end + end + + :ok + end + + def publish_ban_state( + conversation_id, + target_user_id, + actor_user_id, + state, + reason, + expires_at, + metadata, + context + ) + when is_integer(conversation_id) and is_integer(target_user_id) and + is_integer(actor_user_id) and is_binary(state) and is_map(metadata) and + is_map(context) do + if enabled?(context) do + with {:ok, event, target_domains} <- + call(context, :build_ban_upsert_event, [ + conversation_id, + target_user_id, + actor_user_id, + state, + reason, + expires_at, + metadata + ]) do + enqueue_outbox_event(context, event, target_domains) + end + end + + :ok + end + + def maybe_push_for_conversation(conversation_id, context) + when is_integer(conversation_id) and is_map(context) do + if enabled?(context) do + Async.start(fn -> call(context, :publish_latest_message_event, [conversation_id]) end) + end + + :ok + end + + def maybe_push_for_server(server_id, context) + when is_integer(server_id) and is_map(context) do + if enabled?(context) do + Async.start(fn -> + publish_server_upsert(server_id, context) + push_server_snapshot(server_id, context) + end) + end + + :ok + end + + defp publish_typing_indicator(conversation_id, user_id, mode, context) + when mode in [:start, :stop] and is_integer(conversation_id) and is_integer(user_id) do + if enabled?(context) do + Async.start(fn -> + with {:ok, item, target_domains} <- + call(context, :build_typing_ephemeral_item, [conversation_id, user_id, mode]) do + _ = call(context, :fanout_ephemeral_batch, [[item], target_domains]) + end + end) + end + + :ok + end + + defp enabled?(context) do + call(context, :enabled?, []) + end + + defp enqueue_outbox_event(context, event, target_domains \\ :all) do + call(context, :enqueue_outbox_event, [event, target_domains]) + end + + defp build_server_snapshot_for_peer(server_id, peer, context) do + call(context, :build_server_snapshot_for_peer, [server_id, peer]) + rescue + KeyError -> + call(context, :build_server_snapshot, [server_id]) + end + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + rescue + error in ArgumentError -> + if missing_identity_config_error?(error) do + Logger.error( + "Messaging federation publish skipped because identity signing is not configured. " <> + "Set MESSAGING_FEDERATION_IDENTITY_SHARED_SECRET or " <> + "MESSAGING_FEDERATION_IDENTITY_KEYS_JSON in production." + ) + + {:error, :identity_not_configured} + else + reraise error, __STACKTRACE__ + end + end + + defp missing_identity_config_error?(%ArgumentError{} = error) do + Exception.message(error) == @missing_identity_config_message + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/request_auth.ex b/apps/elektrine/lib/elektrine/messaging/federation/request_auth.ex new file mode 100644 index 0000000..dd79a39 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/request_auth.ex @@ -0,0 +1,256 @@ +defmodule Elektrine.Messaging.Federation.RequestAuth do + @moduledoc false + + alias Elektrine.Messaging.Federation.{Auth, Contexts, Discovery, Peers, Runtime, Utils} + + def signature_payload( + domain, + method, + request_path, + query_string, + timestamp, + content_digest \\ "", + request_id \\ "" + ) do + Auth.signature_payload( + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id + ) + end + + def body_digest(body), do: Auth.body_digest(body) + + def sign_payload(payload, signing_material), do: Auth.sign_payload(payload, signing_material) + + def valid_timestamp?(timestamp), + do: Auth.valid_timestamp?(timestamp, Runtime.clock_skew_seconds()) + + def verify_signature(secret, domain, method, request_path, query_string, timestamp, signature) + when is_binary(secret) and is_binary(signature) do + verify_signature( + secret, + domain, + method, + request_path, + query_string, + timestamp, + "", + "", + signature + ) + end + + def verify_signature( + secret, + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + signature + ) + when is_binary(secret) and is_binary(signature) do + verify_signature( + secret, + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + "", + signature + ) + end + + def verify_signature( + peer, + domain, + method, + request_path, + query_string, + timestamp, + key_id, + signature + ) + when is_map(peer) and is_binary(signature) do + verify_signature( + peer, + domain, + method, + request_path, + query_string, + timestamp, + "", + "", + key_id, + signature + ) + end + + def verify_signature( + secret, + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + signature + ) + when is_binary(secret) and is_binary(signature) do + Auth.verify_secret_signature( + secret, + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + signature + ) + end + + def verify_signature( + peer, + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + key_id, + signature + ) + when is_map(peer) and is_binary(signature) do + verify_signature( + peer, + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + "", + key_id, + signature + ) + end + + def verify_signature( + peer, + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + key_id, + signature + ) + when is_map(peer) and is_binary(signature) do + Auth.verify_peer_signature( + peer, + domain, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + key_id, + signature, + auth_context() + ) + end + + def signed_headers(peer, method, request_path, query_string \\ "", body \\ "") do + Auth.signed_headers(peer, method, request_path, query_string, body, auth_context()) + end + + def request_replay_nonce( + domain, + key_id, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + signature + ) do + Auth.request_replay_nonce( + domain, + key_id, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + signature + ) + end + + def claim_request_nonce( + domain, + key_id, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + signature + ) do + Auth.claim_request_nonce( + domain, + key_id, + method, + request_path, + query_string, + timestamp, + content_digest, + request_id, + signature, + auth_context() + ) + end + + def incoming_verification_materials_for_key_id(peer, key_id) do + Auth.incoming_verification_materials_for_key_id(peer, key_id) + end + + defp auth_context do + Contexts.auth(%{ + normalize_optional_string: &normalize_optional_string/1, + parse_int: &Utils.parse_int/2, + discover_peer_force: &discover_peer_force/1 + }) + end + + defp discover_peer_force(domain) do + Discovery.discover_peer(domain, [force: true], discovery_context()) + end + + defp discovery_context do + Contexts.discovery(%{ + peers: &Peers.peers/0, + truncate: &Utils.truncate/1 + }) + end + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_value), do: nil +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/runtime.ex b/apps/elektrine/lib/elektrine/messaging/federation/runtime.ex new file mode 100644 index 0000000..05c5ce3 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/runtime.ex @@ -0,0 +1,215 @@ +defmodule Elektrine.Messaging.Federation.Runtime do + @moduledoc false + + alias Elektrine.Messaging.ArblargSDK + alias Elektrine.Messaging.Federation.Config + alias Elektrine.RuntimeEnv + + @clock_skew_seconds 300 + + def federation_config do + Application.get_env(:elektrine, :messaging_federation, []) + end + + def enabled? do + federation_config() |> Keyword.get(:enabled, false) + end + + def local_domain do + configured = + federation_config() + |> Keyword.get(:local_domain) + |> normalize_optional_string() + |> case do + nil -> nil + value -> String.downcase(value) + end + + configured || + System.get_env("INSTANCE_DOMAIN") + |> normalize_optional_string() + |> case do + nil -> Elektrine.Domains.instance_domain() + domain -> String.downcase(domain) + end + end + + def local_base_url do + Config.local_base_url(federation_config(), local_domain()) + end + + def local_identity_key_id do + Config.local_identity_key_id(federation_config()) + end + + def local_identity_discovery_identity do + Config.local_identity_discovery_identity(local_identity_keys(), local_identity_key_id()) + end + + def local_identity_keys do + Config.local_identity_keys( + federation_config(), + local_identity_key_id(), + local_domain(), + enabled?(), + prod_environment?() + ) + end + + def local_event_signing_material do + key_id = local_identity_key_id() + + case local_identity_keys() |> Enum.find(&(&1.id == key_id and is_binary(&1.private_key))) do + %{id: id, private_key: private_key} -> + {id, private_key} + + _ -> + case local_identity_keys() |> Enum.find(&is_binary(&1.private_key)) do + %{id: id, private_key: private_key} -> + {id, private_key} + + _ -> + derived_key_seed_source = + federation_config() + |> Keyword.get( + :identity_shared_secret, + "#{local_domain()}:#{key_id}" + ) + + {_public_key, private_key} = + ArblargSDK.derive_keypair_from_secret(derived_key_seed_source) + + {key_id, private_key} + end + end + end + + def configured_peers do + Config.configured_peers(federation_config(), allow_insecure_transport?()) + end + + def official_relay_operator do + Config.official_relay_operator(federation_config()) + end + + def discovery_official_relays do + Config.discovery_official_relays(federation_config()) + end + + def outbound_events_batch_url(peer) do + Config.outbound_events_batch_url(peer) + end + + def outbound_events_url(peer) do + Config.outbound_events_url(peer) + end + + def outbound_ephemeral_url(peer) do + Config.outbound_ephemeral_url(peer) + end + + def outbound_sync_url(peer) do + Config.outbound_sync_url(peer) + end + + def outbound_stream_events_url(peer, query_string) do + Config.outbound_stream_events_url(peer, query_string) + end + + def outbound_session_websocket_url(peer) do + Config.outbound_session_websocket_url(peer) + end + + def outbound_snapshot_url(peer, remote_server_id) do + Config.outbound_snapshot_url(peer, remote_server_id) + end + + def delivery_timeout_ms do + Config.delivery_timeout_ms(federation_config()) + end + + def outbox_max_attempts do + Config.outbox_max_attempts(federation_config()) + end + + def delivery_batch_size do + Config.delivery_batch_size(federation_config()) + end + + def outbox_backoff_seconds(attempt_count) do + Config.outbox_backoff_seconds(federation_config(), attempt_count) + end + + def outbox_partition_month(%DateTime{} = datetime) do + Config.outbox_partition_month(datetime) + end + + def delivery_concurrency do + Config.delivery_concurrency(federation_config()) + end + + def replay_nonce_ttl_seconds do + Config.replay_nonce_ttl_seconds(federation_config(), @clock_skew_seconds) + end + + def stream_replay_limit do + Config.stream_replay_limit(federation_config()) + end + + def incoming_batch_limit do + Config.incoming_batch_limit(federation_config()) + end + + def incoming_ephemeral_limit do + Config.incoming_ephemeral_limit(federation_config()) + end + + def snapshot_channel_limit do + Config.snapshot_channel_limit(federation_config()) + end + + def snapshot_message_limit do + Config.snapshot_message_limit(federation_config()) + end + + def snapshot_governance_limit do + Config.snapshot_governance_limit(federation_config()) + end + + def discovery_ttl_seconds do + Config.discovery_ttl_seconds(federation_config()) + end + + def discovery_stale_grace_seconds do + Config.discovery_stale_grace_seconds(federation_config()) + end + + def session_max_inflight_batches do + Config.session_max_inflight_batches(federation_config()) + end + + def session_max_inflight_events do + Config.session_max_inflight_events(federation_config()) + end + + def presence_ttl_seconds do + Config.presence_ttl_seconds(federation_config()) + end + + def allow_insecure_transport? do + Config.allow_insecure_transport?(federation_config()) + end + + def clock_skew_seconds do + Config.clock_skew_seconds(federation_config(), @clock_skew_seconds) + end + + def prod_environment? do + RuntimeEnv.prod?() + end + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_value), do: nil +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/snapshots.ex b/apps/elektrine/lib/elektrine/messaging/federation/snapshots.ex new file mode 100644 index 0000000..5d58027 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/snapshots.ex @@ -0,0 +1,1229 @@ +defmodule Elektrine.Messaging.Federation.Snapshots do + @moduledoc false + + import Ecto.Query, warn: false + + import Elektrine.Messaging.Federation.Utils, + only: [infer_room_origin_domain: 1, message_federation_id: 1] + + require Logger + + alias Elektrine.Accounts.User + alias Elektrine.ActivityPub.Actor, as: ActivityPubActor + alias Elektrine.HTTP.SafeFetch + alias Elektrine.Messaging.ArblargSDK + + alias Elektrine.Messaging.{ + ChatConversation, + ChatConversationMember, + ChatMessage, + ChatMessageReaction, + CommunityBan, + FederationExtensionEvent, + FederationInviteState, + FederationMembershipState, + FederationOutboxEvent, + FederationReadCursor, + FederationSessionClient, + FederationStreamPosition, + Server + } + + alias Elektrine.Messaging.Federation.Visibility + alias Elektrine.Repo + + def build_server_snapshot(server_id, opts, context) + when is_integer(server_id) and is_list(opts) and is_map(context) do + messages_per_channel = + call(context, :parse_int, [Keyword.get(opts, :messages_per_channel, 25), 25]) + |> max(1) + |> min(250) + + peer = Keyword.get(opts, :peer) + + with %Server{} = server <- Repo.get(Server, server_id), + false <- server.is_federated_mirror do + channels = + case peer do + nil -> + from(c in ChatConversation, + where: + c.server_id == ^server.id and c.type == "channel" and + c.is_federated_mirror != true, + order_by: [asc: c.channel_position, asc: c.inserted_at] + ) + |> Repo.all() + + _ -> + Visibility.visible_channels_for_peer(server, peer) + end + |> Enum.take(call(context, :snapshot_channel_limit, [])) + + if channels != [] or is_nil(peer) or server.is_public == true do + channel_payloads = Enum.map(channels, &call(context, :channel_payload, [&1])) + + channel_messages = + Enum.flat_map(channels, fn channel -> + from(m in ChatMessage, + where: m.conversation_id == ^channel.id and is_nil(m.deleted_at), + order_by: [desc: m.inserted_at], + limit: ^messages_per_channel, + preload: [:sender] + ) + |> Repo.all() + |> Enum.reverse() + |> ChatMessage.decrypt_messages() + |> Enum.map(fn message -> call(context, :message_payload, [message, channel]) end) + end) + |> Enum.take(call(context, :snapshot_message_limit, [])) + + unsigned = %{ + "version" => 1, + "origin_domain" => call(context, :local_domain, []), + "server" => call(context, :server_payload, [server]), + "channels" => channel_payloads, + "messages" => channel_messages, + "governance" => snapshot_governance_payload(server, channels, context), + "message_deletions" => snapshot_message_deletions(server, channels, context), + "reactions" => snapshot_reaction_payloads(server, channels, context), + "read_cursors" => snapshot_read_cursor_payloads(server, channels, context), + "extensions" => snapshot_extension_payloads(server, channels, peer, context), + "stream_positions" => snapshot_stream_positions(server, channels, context) + } + + {:ok, sign_snapshot_payload(unsigned, context)} + else + {:error, :not_authorized} + end + else + nil -> {:error, :not_found} + true -> {:error, :federated_mirror} + end + end + + def import_server_snapshot(payload, remote_domain, context) + when is_map(payload) and is_binary(remote_domain) and is_map(context) do + with :ok <- call(context, :validate_snapshot_payload, [payload, remote_domain]) do + Repo.transaction(fn -> + with {:ok, mirror_server} <- + call(context, :upsert_mirror_server, [payload["server"], remote_domain]), + {:ok, channel_map} <- + call(context, :upsert_mirror_channels, [mirror_server, payload["channels"] || []]), + :ok <- + call(context, :upsert_mirror_messages, [ + channel_map, + payload["messages"] || [], + remote_domain + ]), + :ok <- + import_snapshot_governance( + channel_map, + payload["governance"] || %{}, + remote_domain, + context + ), + :ok <- + import_snapshot_extensions( + payload["extensions"] || [], + remote_domain, + context + ), + :ok <- + import_snapshot_message_deletions( + payload["message_deletions"] || [], + remote_domain, + context + ), + :ok <- + import_snapshot_reactions(payload["reactions"] || [], remote_domain, context), + :ok <- + import_snapshot_read_cursors( + payload["read_cursors"] || [], + remote_domain, + context + ), + :ok <- + store_snapshot_stream_positions( + payload["stream_positions"] || [], + remote_domain, + context + ) do + {:ok, mirror_server} + else + {:error, reason} -> Repo.rollback(reason) + end + end) + |> case do + {:ok, {:ok, server}} -> {:ok, server} + {:error, reason} -> {:error, reason} + end + end + end + + def export_stream_events(stream_id, opts, context) + when is_binary(stream_id) and is_list(opts) and is_map(context) do + after_sequence = + call(context, :parse_int, [Keyword.get(opts, :after_sequence, 0), 0]) |> max(0) + + limit = + call(context, :parse_int, [ + Keyword.get(opts, :limit, call(context, :stream_replay_limit, [])), + call(context, :stream_replay_limit, []) + ]) + + bounded_limit = limit |> max(1) |> min(call(context, :stream_replay_limit, [])) + peer = Keyword.get(opts, :peer) + visible_filter = Visibility.visible_stream_events_query(stream_id, peer) + + base_events_query = + from(o in FederationOutboxEvent, + where: o.stream_id == ^stream_id and o.sequence > ^after_sequence, + order_by: [asc: o.sequence, asc: o.id], + limit: ^bounded_limit, + select: %{sequence: o.sequence, payload: o.payload} + ) + |> maybe_filter_visible_stream_events(visible_filter) + + events = Repo.all(base_events_query) + + last_sequence = + from(o in FederationOutboxEvent, + where: o.stream_id == ^stream_id, + select: max(o.sequence) + ) + |> maybe_filter_visible_stream_events(visible_filter) + |> Repo.one() + |> case do + nil -> after_sequence + sequence -> sequence + end + + next_after_sequence = + case List.last(events) do + %{sequence: sequence} -> sequence + _ -> after_sequence + end + + %{ + "version" => 1, + "stream_id" => stream_id, + "after_sequence" => after_sequence, + "next_after_sequence" => next_after_sequence, + "last_sequence" => last_sequence, + "has_more" => next_after_sequence < last_sequence, + "events" => Enum.map(events, & &1.payload) + } + end + + def recover_sequence_gap(payload, remote_domain, context) + when is_map(payload) and is_binary(remote_domain) and is_map(context) do + with %{} = peer <- call(context, :incoming_peer, [remote_domain]), + stream_id when is_binary(stream_id) <- payload["stream_id"] do + case recover_sequence_gap_via_stream(peer, remote_domain, stream_id, context) do + :ok -> + case call(context, :receive_event, [payload, remote_domain]) do + {:ok, result} when result in [:applied, :duplicate, :stale] -> + {:ok, :recovered_via_stream} + + {:error, reason} -> + recover_sequence_gap_via_snapshot(payload, remote_domain, peer, reason, context) + end + + {:error, _reason} -> + recover_sequence_gap_via_snapshot( + payload, + remote_domain, + peer, + :stream_recovery_failed, + context + ) + end + else + nil -> {:error, :unknown_peer} + _ -> {:error, :recovery_failed} + end + end + + def refresh_mirror_server_snapshot(%Server{} = server, context) when is_map(context) do + with true <- server.is_federated_mirror == true, + remote_domain when is_binary(remote_domain) <- + call(context, :normalize_optional_string, [server.origin_domain]), + {:ok, remote_server_id} <- + call(context, :infer_remote_server_id_from_federation_id, [server.federation_id]), + %{} = peer <- + call(context, :outgoing_peer, [remote_domain]) || + call(context, :incoming_peer, [remote_domain]), + {:ok, snapshot_payload} <- fetch_remote_snapshot(peer, remote_server_id, context) do + import_server_snapshot(snapshot_payload, remote_domain, context) + else + false -> {:error, :not_federated_mirror} + nil -> {:error, :unknown_peer} + {:error, reason} -> {:error, reason} + _ -> {:error, :cannot_infer_snapshot_server_id} + end + end + + def refresh_mirror_server_snapshot(_server, _context), do: {:error, :not_federated_mirror} + + def push_snapshot_to_peer(peer, snapshot, context) when is_map(context) do + path = "/_arblarg/sync" + url = call(context, :outbound_sync_url, [peer]) + body = Jason.encode!(snapshot) + headers = call(context, :signed_headers, [peer, "POST", path, "", body]) + request = Finch.build(:post, url, headers, body) + + case SafeFetch.request(request, Elektrine.Finch, + receive_timeout: call(context, :delivery_timeout_ms, []), + pool_timeout: 5000 + ) do + {:ok, %Finch.Response{status: status}} when status in 200..299 -> + :ok + + {:ok, %Finch.Response{status: status, body: response_body}} -> + Logger.warning( + "Messaging federation sync failed for #{peer.domain}: HTTP #{status} #{call(context, :truncate, [response_body])}" + ) + + {:error, reason} -> + Logger.warning( + "Messaging federation sync transport error for #{peer.domain}: #{inspect(reason)}" + ) + end + end + + def snapshot_governance_entries(governance, context) + when is_map(governance) and is_map(context) do + memberships = + governance + |> Map.get("memberships", []) + |> List.wrap() + |> Enum.map(&{"membership.upsert", &1}) + + invites = + governance + |> Map.get("invites", []) + |> List.wrap() + |> Enum.map(&{"invite.upsert", &1}) + + bans = + governance + |> Map.get("bans", []) + |> List.wrap() + |> Enum.map(&{"ban.upsert", &1}) + + (memberships ++ invites ++ bans) + |> Enum.take(call(context, :snapshot_governance_limit, [])) + end + + def snapshot_governance_entries(_governance, _context), do: [] + + def snapshot_signature_payload(snapshot) when is_map(snapshot) do + snapshot + |> Map.delete("signature") + |> ArblargSDK.canonical_json_payload() + end + + def snapshot_signature_payload(_snapshot), do: "" + + defp snapshot_stream_positions(%Server{} = server, channels, context) when is_list(channels) do + stream_ids = + [ + call(context, :server_stream_id, [server.id]) + | Enum.map(channels, &call(context, :channel_stream_id, [&1.id])) + ] + + stream_ids = Enum.uniq(stream_ids) + local_origin_domain = call(context, :local_domain, []) + + local_positions = + Enum.map(stream_ids, fn stream_id -> + %{ + "origin_domain" => local_origin_domain, + "stream_id" => stream_id, + "last_sequence" => local_last_sequence_for_stream(stream_id) + } + end) + + remote_positions = + from(position in FederationStreamPosition, + where: position.stream_id in ^stream_ids, + select: %{ + origin_domain: position.origin_domain, + stream_id: position.stream_id, + last_sequence: position.last_sequence + } + ) + |> Repo.all() + |> Enum.reject(fn %{origin_domain: origin_domain} -> + String.downcase(to_string(origin_domain || "")) == + String.downcase(to_string(local_origin_domain || "")) + end) + |> Enum.map(fn position -> + %{ + "origin_domain" => position.origin_domain, + "stream_id" => position.stream_id, + "last_sequence" => position.last_sequence + } + end) + + local_positions ++ remote_positions + end + + defp snapshot_stream_positions(_server, _channels, _context), do: [] + + defp snapshot_governance_payload(%Server{} = server, channels, context) + when is_list(channels) do + channel_ids = Enum.map(channels, & &1.id) + channel_index = Map.new(channels, &{&1.id, &1}) + + memberships = + (local_membership_payloads(server, channel_index, context) ++ + remote_membership_payloads(server, channel_index, context)) + |> Enum.take(call(context, :snapshot_governance_limit, [])) + + remaining_governance_slots = + max(call(context, :snapshot_governance_limit, []) - length(memberships), 0) + + invites = + from(invite in FederationInviteState, + where: invite.conversation_id in ^channel_ids, + order_by: [asc: invite.conversation_id, asc: invite.target_uri] + ) + |> Repo.all() + |> Enum.take(remaining_governance_slots) + |> Enum.reduce([], fn invite, acc -> + with %ChatConversation{} = indexed_channel <- + Map.get(channel_index, invite.conversation_id), + actor when is_map(actor) <- invite.actor_payload, + target when is_map(target) <- invite.target_payload do + payload = %{ + "refs" => call(context, :event_refs_payload, [server, indexed_channel]), + "invite" => %{ + "actor" => actor, + "target" => target, + "role" => invite.role, + "state" => invite.state, + "invited_at" => call(context, :maybe_iso8601, [invite.invited_at_remote]), + "updated_at" => call(context, :maybe_iso8601, [invite.updated_at_remote]), + "metadata" => invite.metadata || %{} + } + } + + acc ++ [payload] + else + _ -> acc + end + end) + + remaining_governance_slots = + max(remaining_governance_slots - length(invites), 0) + + bans = + (local_ban_payloads(server, channel_ids, channel_index, context) ++ + federated_ban_payloads(server, channel_ids, channel_index, context)) + |> Enum.uniq_by(fn payload -> + { + get_in(payload, ["refs", "channel_id"]), + get_in(payload, ["ban", "target", "uri"]) || get_in(payload, ["ban", "target", "id"]) + } + end) + |> Enum.take(remaining_governance_slots) + + %{"memberships" => memberships, "invites" => invites, "bans" => bans} + end + + defp snapshot_governance_payload(_server, _channels, _context), + do: %{"memberships" => [], "invites" => [], "bans" => []} + + defp local_membership_payloads(%Server{} = server, channel_index, context) + when is_map(channel_index) and is_map(context) do + from(member in ChatConversationMember, + where: member.conversation_id in ^Map.keys(channel_index), + preload: [:user, :conversation], + order_by: [asc: member.conversation_id, asc: member.user_id] + ) + |> Repo.all() + |> Enum.reduce([], fn member, acc -> + with %User{} = user <- member.user, + %ChatConversation{} = conversation <- member.conversation, + %ChatConversation{} = indexed_channel <- Map.get(channel_index, conversation.id) do + payload = %{ + "refs" => call(context, :event_refs_payload, [server, indexed_channel]), + "membership" => %{ + "actor" => call(context, :sender_payload, [user]), + "role" => member.role || "member", + "state" => if(is_nil(member.left_at), do: "active", else: "left"), + "joined_at" => call(context, :format_created_at, [member.joined_at]), + "updated_at" => call(context, :format_created_at, [member.updated_at]), + "metadata" => %{} + } + } + + acc ++ [payload] + else + _ -> acc + end + end) + end + + defp local_membership_payloads(_server, _channel_index, _context), do: [] + + defp remote_membership_payloads(%Server{} = server, channel_index, context) + when is_map(channel_index) and is_map(context) do + from(state in FederationMembershipState, + where: state.conversation_id in ^Map.keys(channel_index), + where: state.state in ["active", "invited", "left", "banned"], + preload: [:remote_actor], + order_by: [asc: state.conversation_id, asc: state.remote_actor_id] + ) + |> Repo.all() + |> Enum.reduce([], fn state, acc -> + with %Elektrine.ActivityPub.Actor{} = actor <- state.remote_actor, + %ChatConversation{} = indexed_channel <- Map.get(channel_index, state.conversation_id) do + payload = %{ + "refs" => call(context, :event_refs_payload, [server, indexed_channel]), + "membership" => %{ + "actor" => remote_actor_payload(actor), + "role" => state.role || "member", + "state" => state.state || "active", + "joined_at" => call(context, :maybe_iso8601, [state.joined_at_remote]), + "updated_at" => call(context, :maybe_iso8601, [state.updated_at_remote]), + "metadata" => state.metadata || %{} + } + } + + acc ++ [payload] + else + _ -> acc + end + end) + end + + defp remote_membership_payloads(_server, _channel_index, _context), do: [] + + defp local_ban_payloads(%Server{} = server, channel_ids, channel_index, context) + when is_list(channel_ids) and is_map(channel_index) and is_map(context) do + from(ban in CommunityBan, + where: ban.conversation_id in ^channel_ids, + preload: [:user, :banned_by], + order_by: [asc: ban.conversation_id, asc: ban.user_id] + ) + |> Repo.all() + |> Enum.reduce([], fn ban, acc -> + with %User{} = target_user <- ban.user, + %ChatConversation{} = indexed_channel <- Map.get(channel_index, ban.conversation_id), + %{} = actor_payload <- ban_actor_payload(ban, context) do + payload = %{ + "refs" => call(context, :event_refs_payload, [server, indexed_channel]), + "ban" => %{ + "actor" => actor_payload, + "target" => call(context, :sender_payload, [target_user]), + "state" => "active", + "reason" => call(context, :normalize_optional_string, [ban.reason]), + "banned_at" => + call(context, :maybe_iso8601, [ban.banned_at_remote || ban.inserted_at]), + "updated_at" => + call(context, :maybe_iso8601, [ban.updated_at_remote || ban.updated_at]), + "expires_at" => call(context, :maybe_iso8601, [ban.expires_at]), + "metadata" => ban.metadata || %{} + } + } + + acc ++ [payload] + else + _ -> acc + end + end) + end + + defp local_ban_payloads(_server, _channel_ids, _channel_index, _context), do: [] + + defp ban_actor_payload(%CommunityBan{actor_payload: actor_payload}, _context) + when is_map(actor_payload) and map_size(actor_payload) > 0, + do: actor_payload + + defp ban_actor_payload(%CommunityBan{banned_by: %User{} = banned_by}, context), + do: call(context, :sender_payload, [banned_by]) + + defp ban_actor_payload(_ban, _context), do: nil + + defp federated_ban_payloads(%Server{} = server, channel_ids, channel_index, context) + when is_list(channel_ids) and is_map(channel_index) and is_map(context) do + from(state in FederationMembershipState, + where: state.conversation_id in ^channel_ids and state.state == "banned", + preload: [:remote_actor], + order_by: [asc: state.conversation_id, asc: state.remote_actor_id] + ) + |> Repo.all() + |> Enum.reduce([], fn state, acc -> + metadata = state.metadata || %{} + + with %ActivityPubActor{} = target_actor <- state.remote_actor, + %ChatConversation{} = indexed_channel <- Map.get(channel_index, state.conversation_id), + %{} = actor_payload <- metadata["actor"] do + payload = %{ + "refs" => call(context, :event_refs_payload, [server, indexed_channel]), + "ban" => %{ + "actor" => actor_payload, + "target" => remote_actor_payload(target_actor), + "state" => metadata["ban_state"] || "active", + "reason" => call(context, :normalize_optional_string, [metadata["reason"]]), + "banned_at" => call(context, :maybe_iso8601, [state.joined_at_remote]), + "updated_at" => call(context, :maybe_iso8601, [state.updated_at_remote]), + "expires_at" => call(context, :normalize_optional_string, [metadata["expires_at"]]), + "metadata" => Map.get(metadata, "metadata", %{}) + } + } + + acc ++ [payload] + else + _ -> acc + end + end) + end + + defp federated_ban_payloads(_server, _channel_ids, _channel_index, _context), do: [] + + defp snapshot_message_deletions(%Server{} = server, channels, context) when is_list(channels) do + channel_ids = Enum.map(channels, & &1.id) + channel_index = Map.new(channels, &{&1.id, &1}) + + from(m in ChatMessage, + where: m.conversation_id in ^channel_ids and not is_nil(m.deleted_at), + preload: [:conversation], + order_by: [desc: m.updated_at, desc: m.id] + ) + |> Repo.all() + |> Enum.take(call(context, :snapshot_message_limit, [])) + |> Enum.reduce([], fn message, acc -> + with %ChatConversation{} = conversation <- message.conversation, + %ChatConversation{} = indexed_channel <- Map.get(channel_index, conversation.id) do + payload = %{ + "refs" => call(context, :event_refs_payload, [server, indexed_channel]), + "message_id" => message.federated_source || message_federation_id(message.id), + "deleted_at" => call(context, :maybe_iso8601, [message.deleted_at]) + } + + acc ++ [payload] + else + _ -> acc + end + end) + end + + defp snapshot_message_deletions(_server, _channels, _context), do: [] + + defp snapshot_reaction_payloads(%Server{} = server, channels, context) when is_list(channels) do + channel_ids = Enum.map(channels, & &1.id) + channel_index = Map.new(channels, &{&1.id, &1}) + + local_reaction_payloads(server, channel_ids, channel_index, context) ++ + remote_reaction_payloads(server, channel_ids, channel_index, context) + end + + defp snapshot_reaction_payloads(_server, _channels, _context), do: [] + + defp local_reaction_payloads(%Server{} = server, channel_ids, channel_index, context) + when is_list(channel_ids) and is_map(channel_index) and is_map(context) do + from(reaction in ChatMessageReaction, + join: message in ChatMessage, + on: reaction.chat_message_id == message.id, + join: user in User, + on: reaction.user_id == user.id, + where: message.conversation_id in ^channel_ids, + preload: [chat_message: [:conversation], user: []], + order_by: [asc: reaction.id] + ) + |> Repo.all() + |> Enum.reduce([], fn reaction, acc -> + with %ChatMessage{} = message <- reaction.chat_message, + %ChatConversation{} = conversation <- message.conversation, + %ChatConversation{} = indexed_channel <- Map.get(channel_index, conversation.id), + %User{} = user <- reaction.user do + payload = %{ + "refs" => call(context, :event_refs_payload, [server, indexed_channel]), + "message_id" => message.federated_source || message_federation_id(message.id), + "reaction" => %{ + "emoji" => reaction.emoji, + "actor" => call(context, :sender_payload, [user]) + } + } + + acc ++ [payload] + else + _ -> acc + end + end) + end + + defp local_reaction_payloads(_server, _channel_ids, _channel_index, _context), do: [] + + defp remote_reaction_payloads(%Server{} = server, channel_ids, channel_index, context) + when is_list(channel_ids) and is_map(channel_index) and is_map(context) do + from(reaction in ChatMessageReaction, + join: message in ChatMessage, + on: reaction.chat_message_id == message.id, + join: actor in ActivityPubActor, + on: reaction.remote_actor_id == actor.id, + where: message.conversation_id in ^channel_ids, + preload: [chat_message: [:conversation], remote_actor: []], + order_by: [asc: reaction.id] + ) + |> Repo.all() + |> Enum.reduce([], fn reaction, acc -> + with %ChatMessage{} = message <- reaction.chat_message, + %ChatConversation{} = conversation <- message.conversation, + %ChatConversation{} = indexed_channel <- Map.get(channel_index, conversation.id), + %ActivityPubActor{} = actor <- reaction.remote_actor do + payload = %{ + "refs" => call(context, :event_refs_payload, [server, indexed_channel]), + "message_id" => message.federated_source || message_federation_id(message.id), + "reaction" => %{ + "emoji" => reaction.emoji, + "actor" => remote_actor_payload(actor) + } + } + + acc ++ [payload] + else + _ -> acc + end + end) + end + + defp remote_reaction_payloads(_server, _channel_ids, _channel_index, _context), do: [] + + defp snapshot_read_cursor_payloads(%Server{} = server, channels, context) + when is_list(channels) do + channel_index = Map.new(channels, &{&1.id, &1}) + + local_read_cursor_payloads(server, channel_index, context) ++ + remote_read_cursor_payloads(server, channel_index, context) + end + + defp snapshot_read_cursor_payloads(_server, _channels, _context), do: [] + + defp local_read_cursor_payloads(%Server{} = server, channel_index, context) + when is_map(channel_index) and is_map(context) do + from(member in ChatConversationMember, + where: + member.conversation_id in ^Map.keys(channel_index) and is_nil(member.left_at) and + not is_nil(member.last_read_at), + join: user in User, + on: member.user_id == user.id, + preload: [user: []], + order_by: [asc: member.conversation_id, asc: member.user_id] + ) + |> Repo.all() + |> Enum.reduce([], fn member, acc -> + with %User{} = user <- member.user, + %ChatConversation{} = channel <- Map.get(channel_index, member.conversation_id), + %ChatMessage{} = message <- last_read_chat_message(channel.id, member.last_read_at) do + payload = %{ + "refs" => call(context, :event_refs_payload, [server, channel]), + "read_through_message_id" => + message.federated_source || message_federation_id(message.id), + "actor" => call(context, :sender_payload, [user]), + "read_at" => call(context, :maybe_iso8601, [member.last_read_at]) + } + + acc ++ [payload] + else + _ -> acc + end + end) + end + + defp local_read_cursor_payloads(_server, _channel_index, _context), do: [] + + defp remote_read_cursor_payloads(%Server{} = server, channel_index, context) + when is_map(channel_index) and is_map(context) do + from(cursor in FederationReadCursor, + where: cursor.conversation_id in ^Map.keys(channel_index), + preload: [:chat_message, :remote_actor], + order_by: [asc: cursor.conversation_id, asc: cursor.remote_actor_id] + ) + |> Repo.all() + |> Enum.reduce([], fn cursor, acc -> + with %ChatConversation{} = channel <- Map.get(channel_index, cursor.conversation_id), + %ChatMessage{} = message <- cursor.chat_message, + %ActivityPubActor{} = actor <- cursor.remote_actor do + payload = + %{ + "refs" => call(context, :event_refs_payload, [server, channel]), + "read_through_message_id" => + message.federated_source || message_federation_id(message.id), + "actor" => remote_actor_payload(actor), + "read_at" => call(context, :maybe_iso8601, [cursor.read_at]) + } + |> maybe_put_read_through_sequence(cursor.read_through_sequence) + + acc ++ [payload] + else + _ -> acc + end + end) + end + + defp remote_read_cursor_payloads(_server, _channel_index, _context), do: [] + + defp snapshot_extension_payloads(%Server{} = server, channels, peer, context) + when is_list(channels) and is_map(context) do + channel_ids = Enum.map(channels, & &1.id) + + from(event in FederationExtensionEvent, + where: event.server_id == ^server.id or event.conversation_id in ^channel_ids, + order_by: [asc: event.occurred_at, asc: event.id] + ) + |> Repo.all() + |> Enum.map(fn event -> + %{"event_type" => event.event_type, "payload" => event.payload || %{}} + end) + |> maybe_filter_snapshot_extensions_for_peer(peer, context) + end + + defp snapshot_extension_payloads(_server, _channels, _peer, _context), do: [] + + defp maybe_filter_snapshot_extensions_for_peer(extensions, %{} = peer, context) + when is_list(extensions) and is_map(context) do + Enum.filter(extensions, fn + %{"event_type" => event_type} when is_binary(event_type) -> + call(context, :peer_supports_event_type, [peer, event_type]) + + _ -> + false + end) + end + + defp maybe_filter_snapshot_extensions_for_peer(extensions, _peer, _context) + when is_list(extensions), + do: extensions + + defp import_snapshot_governance(_channel_map, governance, remote_domain, context) + when is_map(governance) and is_binary(remote_domain) and is_map(context) do + governance + |> snapshot_governance_entries(context) + |> Enum.reduce_while(:ok, fn {event_type, payload}, :ok -> + case call(context, :validate_snapshot_governance_payload, [ + event_type, + payload, + remote_domain + ]) do + :ok -> + case call(context, :apply_event, [event_type, payload, remote_domain]) do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + + {:error, reason} -> + {:halt, {:error, reason}} + end + end) + end + + defp import_snapshot_governance(_channel_map, _governance, _remote_domain, _context), + do: {:error, :invalid_snapshot_governance} + + defp import_snapshot_message_deletions(message_deletions, remote_domain, context) + when is_list(message_deletions) and is_binary(remote_domain) and is_map(context) do + Enum.reduce_while(message_deletions, :ok, fn payload, :ok -> + case call(context, :apply_event, ["message.delete", payload, remote_domain]) do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp import_snapshot_message_deletions(_message_deletions, _remote_domain, _context), + do: {:error, :invalid_payload} + + defp import_snapshot_reactions(reactions, remote_domain, context) + when is_list(reactions) and is_binary(remote_domain) and is_map(context) do + Enum.reduce_while(reactions, :ok, fn payload, :ok -> + case call(context, :apply_event, ["reaction.add", payload, remote_domain]) do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp import_snapshot_reactions(_reactions, _remote_domain, _context), + do: {:error, :invalid_payload} + + defp import_snapshot_read_cursors(read_cursors, remote_domain, context) + when is_list(read_cursors) and is_binary(remote_domain) and is_map(context) do + Enum.reduce_while(read_cursors, :ok, fn payload, :ok -> + case call(context, :apply_event, ["read.cursor", payload, remote_domain]) do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp import_snapshot_read_cursors(_read_cursors, _remote_domain, _context), + do: {:error, :invalid_payload} + + defp import_snapshot_extensions(extensions, remote_domain, context) + when is_list(extensions) and is_binary(remote_domain) and is_map(context) do + Enum.reduce_while(extensions, :ok, fn + %{"event_type" => event_type, "payload" => payload}, :ok + when is_binary(event_type) and is_map(payload) -> + case call(context, :apply_event, [event_type, payload, remote_domain]) do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + + _, :ok -> + {:halt, {:error, :invalid_payload}} + end) + end + + defp import_snapshot_extensions(_extensions, _remote_domain, _context), + do: {:error, :invalid_payload} + + defp remote_actor_payload(%Elektrine.ActivityPub.Actor{} = actor) do + %{ + "id" => actor.uri, + "uri" => actor.uri, + "username" => actor.username, + "display_name" => actor.display_name || actor.username, + "domain" => actor.domain, + "handle" => "#{actor.username}@#{actor.domain}" + } + end + + defp maybe_put_read_through_sequence(payload, sequence) + when is_map(payload) and is_integer(sequence) and sequence > 0 do + Map.put(payload, "read_through_sequence", sequence) + end + + defp maybe_put_read_through_sequence(payload, _sequence) when is_map(payload), do: payload + + defp sign_snapshot_payload(snapshot, context) when is_map(snapshot) do + {key_id, private_key} = call(context, :local_event_signing_material, []) + + Map.put(snapshot, "signature", %{ + "algorithm" => ArblargSDK.signature_algorithm(), + "key_id" => key_id, + "value" => + snapshot + |> snapshot_signature_payload() + |> ArblargSDK.sign_payload(private_key) + }) + end + + defp local_last_sequence_for_stream(stream_id) when is_binary(stream_id) do + from(o in FederationOutboxEvent, + where: o.stream_id == ^stream_id, + select: max(o.sequence) + ) + |> Repo.one() + |> case do + nil -> 0 + sequence -> sequence + end + end + + defp local_last_sequence_for_stream(_stream_id), do: 0 + + defp store_snapshot_stream_positions(stream_positions, remote_domain, context) + when is_list(stream_positions) and is_binary(remote_domain) and is_map(context) do + Enum.reduce_while(stream_positions, :ok, fn position, :ok -> + position_origin_domain = + call(context, :normalize_optional_string, [ + position["origin_domain"] || position[:origin_domain] || remote_domain + ]) + + stream_id = + call(context, :normalize_optional_string, [position["stream_id"] || position[:stream_id]]) + + last_sequence = + call(context, :parse_int, [position["last_sequence"] || position[:last_sequence], -1]) + + cond do + !is_binary(position_origin_domain) -> + {:halt, {:error, :invalid_snapshot_stream_positions}} + + !is_binary(stream_id) -> + {:halt, {:error, :invalid_snapshot_stream_positions}} + + last_sequence < 0 -> + {:halt, {:error, :invalid_snapshot_stream_positions}} + + last_sequence > current_stream_sequence(position_origin_domain, stream_id) -> + call(context, :store_stream_position, [position_origin_domain, stream_id, last_sequence]) + + {:cont, :ok} + + true -> + {:cont, :ok} + end + end) + end + + defp store_snapshot_stream_positions(_stream_positions, _remote_domain, _context), + do: {:error, :invalid_snapshot_stream_positions} + + defp maybe_filter_visible_stream_events(query, nil), do: query + + defp maybe_filter_visible_stream_events(query, visible_filter) do + from(row in query, where: ^visible_filter) + end + + defp fetch_remote_snapshot(peer, remote_server_id, context) + when is_integer(remote_server_id) and is_map(context) do + if call(context, :peer_supports, [peer, "session_transport", false]) && + is_binary(call(context, :outbound_session_websocket_url, [peer])) do + case FederationSessionClient.send_request( + peer, + "snapshot", + %{"server_id" => remote_server_id}, + timeout: call(context, :delivery_timeout_ms, []) + ) do + {:ok, %{} = payload} -> + {:ok, payload} + + {:error, :snapshot_unavailable} -> + {:error, :snapshot_unavailable} + + {:error, _reason} -> + fetch_remote_snapshot_over_http(peer, remote_server_id, context) + end + else + fetch_remote_snapshot_over_http(peer, remote_server_id, context) + end + end + + defp fetch_remote_snapshot_over_http(peer, remote_server_id, context) + when is_integer(remote_server_id) and is_map(context) do + path = "/_arblarg/servers/#{remote_server_id}/snapshot" + url = call(context, :outbound_snapshot_url, [peer, remote_server_id]) + headers = call(context, :signed_headers, [peer, "GET", path, "", ""]) + request = Finch.build(:get, url, headers) + + case SafeFetch.request(request, Elektrine.Finch, + receive_timeout: call(context, :delivery_timeout_ms, []), + pool_timeout: 5000 + ) do + {:ok, %Finch.Response{status: status, body: body}} when status in 200..299 -> + case Jason.decode(body) do + {:ok, payload} -> {:ok, payload} + _ -> {:error, :invalid_snapshot_response} + end + + {:ok, %Finch.Response{status: status}} when status in [404, 422] -> + {:error, :snapshot_unavailable} + + {:ok, %Finch.Response{status: status, body: body}} -> + {:error, {:http_error, status, call(context, :truncate, [body])}} + + {:error, reason} -> + {:error, reason} + end + end + + defp recover_sequence_gap_via_stream(peer, remote_domain, stream_id, context) + when is_binary(remote_domain) and is_binary(stream_id) and is_map(context) do + after_sequence = current_stream_sequence(remote_domain, stream_id) + + recover_stream_replay_pages(peer, remote_domain, stream_id, after_sequence, context) + end + + defp recover_sequence_gap_via_snapshot(payload, remote_domain, peer, reason, context) + when is_binary(remote_domain) and is_map(context) do + snapshot_remote_domain = snapshot_recovery_domain(payload, remote_domain, context) + snapshot_peer = snapshot_recovery_peer(snapshot_remote_domain, peer, context) + + with {:ok, remote_server_id} <- call(context, :infer_remote_server_id, [payload]), + %{} = snapshot_peer <- snapshot_peer, + {:ok, snapshot_payload} <- + fetch_remote_snapshot(snapshot_peer, remote_server_id, context), + {:ok, _mirror_server} <- + import_server_snapshot(snapshot_payload, snapshot_remote_domain, context) do + case call(context, :receive_event, [payload, remote_domain]) do + {:ok, result} when result in [:applied, :duplicate, :stale] -> + {:ok, :recovered} + + {:error, post_reason} -> + {:error, {:post_recovery_apply_failed, post_reason}} + end + else + nil -> {:error, :unknown_peer} + {:error, snapshot_reason} -> {:error, snapshot_reason} + _ -> {:error, reason} + end + end + + defp current_stream_sequence(remote_domain, stream_id) do + from(p in FederationStreamPosition, + where: p.origin_domain == ^remote_domain and p.stream_id == ^stream_id, + select: p.last_sequence + ) + |> Repo.one() + |> case do + nil -> 0 + sequence -> sequence + end + end + + defp snapshot_recovery_domain(payload, remote_domain, context) + when is_map(payload) and is_binary(remote_domain) and is_map(context) do + authority_domain = infer_room_origin_domain(payload) + local_domain = call(context, :local_domain, []) + + cond do + !is_binary(authority_domain) -> + remote_domain + + String.downcase(authority_domain) == String.downcase(local_domain || "") -> + remote_domain + + true -> + String.downcase(authority_domain) + end + end + + defp snapshot_recovery_domain(_payload, remote_domain, _context), do: remote_domain + + defp snapshot_recovery_peer(snapshot_remote_domain, peer, context) + when is_binary(snapshot_remote_domain) and is_map(context) do + case call(context, :outgoing_peer, [snapshot_remote_domain]) || + call(context, :incoming_peer, [snapshot_remote_domain]) do + %{} = resolved_peer -> resolved_peer + _ -> peer + end + end + + defp snapshot_recovery_peer(_snapshot_remote_domain, peer, _context), do: peer + + defp fetch_remote_stream_events(peer, stream_id, after_sequence, limit, context) + when is_binary(stream_id) and is_integer(after_sequence) and is_integer(limit) and + is_map(context) do + if call(context, :peer_supports, [peer, "session_transport", false]) && + is_binary(call(context, :outbound_session_websocket_url, [peer])) do + case FederationSessionClient.send_request( + peer, + "stream_events", + %{ + "stream_id" => stream_id, + "after_sequence" => after_sequence, + "limit" => limit + }, + timeout: call(context, :delivery_timeout_ms, []) + ) do + {:ok, %{} = payload} -> + {:ok, payload} + + {:error, _reason} -> + fetch_remote_stream_events_over_http(peer, stream_id, after_sequence, limit, context) + end + else + fetch_remote_stream_events_over_http(peer, stream_id, after_sequence, limit, context) + end + end + + defp fetch_remote_stream_events_over_http(peer, stream_id, after_sequence, limit, context) do + path = "/_arblarg/streams/events" + + query_string = + URI.encode_query([ + {"stream_id", stream_id}, + {"after_sequence", Integer.to_string(after_sequence)}, + {"limit", Integer.to_string(limit)} + ]) + + url = call(context, :outbound_stream_events_url, [peer, query_string]) + headers = call(context, :signed_headers, [peer, "GET", path, query_string, ""]) + request = Finch.build(:get, url, headers) + + case SafeFetch.request(request, Elektrine.Finch, + receive_timeout: call(context, :delivery_timeout_ms, []), + pool_timeout: 5000 + ) do + {:ok, %Finch.Response{status: status, body: body}} when status in 200..299 -> + case Jason.decode(body) do + {:ok, payload} -> {:ok, payload} + _ -> {:error, :invalid_stream_replay_response} + end + + {:ok, %Finch.Response{status: status, body: body}} -> + {:error, {:http_error, status, call(context, :truncate, [body])}} + + {:error, reason} -> + {:error, reason} + end + end + + defp import_stream_replay_events(events, remote_domain, context) + when is_list(events) and is_binary(remote_domain) and is_map(context) do + Enum.reduce_while(events, :ok, fn event, :ok -> + case call(context, :receive_event, [event, remote_domain]) do + {:ok, _result} -> {:cont, :ok} + {:error, :sequence_gap} -> {:halt, {:error, :sequence_gap}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp import_stream_replay_events(_events, _remote_domain, _context), + do: {:error, :invalid_payload} + + defp recover_stream_replay_pages(peer, remote_domain, stream_id, after_sequence, context) + when is_binary(remote_domain) and is_binary(stream_id) and is_integer(after_sequence) and + is_map(context) do + with {:ok, replay_payload} <- + fetch_remote_stream_events( + peer, + stream_id, + after_sequence, + call(context, :stream_replay_limit, []), + context + ), + events when is_list(events) <- replay_payload["events"], + :ok <- import_stream_replay_events(events, remote_domain, context) do + if replay_payload["has_more"] == true do + next_after_sequence = + call(context, :parse_int, [replay_payload["next_after_sequence"], after_sequence]) + + if next_after_sequence > after_sequence do + recover_stream_replay_pages( + peer, + remote_domain, + stream_id, + next_after_sequence, + context + ) + else + {:error, :stream_recovery_failed} + end + else + :ok + end + else + _ -> {:error, :stream_recovery_failed} + end + end + + defp recover_stream_replay_pages(_peer, _remote_domain, _stream_id, _after_sequence, _context), + do: {:error, :stream_recovery_failed} + + defp last_read_chat_message(conversation_id, %DateTime{} = last_read_at) + when is_integer(conversation_id) do + last_read_at = DateTime.to_naive(last_read_at) + + from(m in ChatMessage, + where: m.conversation_id == ^conversation_id and m.inserted_at <= ^last_read_at, + order_by: [desc: m.inserted_at, desc: m.id], + limit: 1 + ) + |> Repo.one() + end + + defp last_read_chat_message(_conversation_id, _last_read_at), do: nil + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/state.ex b/apps/elektrine/lib/elektrine/messaging/federation/state.ex new file mode 100644 index 0000000..64109ea --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/state.ex @@ -0,0 +1,975 @@ +defmodule Elektrine.Messaging.Federation.State do + @moduledoc false + + import Ecto.Query, warn: false + import Elektrine.Messaging.Federation.Utils + + alias Elektrine.Accounts.User + alias Elektrine.ActivityPub.Actor, as: ActivityPubActor + alias Elektrine.AppCache + + alias Elektrine.Messaging.{ + ChatConversation, + ChatMessage, + FederationAccountPresenceState, + FederationExtensionEvent, + FederationInviteState, + FederationMembershipState, + FederationReadCursor, + FederationRoomPresenceState + } + + alias Elektrine.Profiles.Follow + alias Elektrine.Repo + + def upsert_remote_read_cursor( + conversation_id, + chat_message_id, + remote_actor_id, + remote_domain, + read_at, + read_through_sequence, + _context + ) + when is_integer(conversation_id) and is_integer(chat_message_id) and + is_integer(remote_actor_id) and is_binary(remote_domain) do + attrs = %{ + conversation_id: conversation_id, + chat_message_id: chat_message_id, + remote_actor_id: remote_actor_id, + origin_domain: String.downcase(remote_domain), + read_at: read_at || DateTime.utc_now(), + read_through_sequence: normalize_positive_int(read_through_sequence) + } + + case Repo.get_by(FederationReadCursor, + conversation_id: conversation_id, + remote_actor_id: remote_actor_id + ) do + nil -> + %FederationReadCursor{} + |> FederationReadCursor.changeset(attrs) + |> Repo.insert() + + %FederationReadCursor{} = cursor when cursor.chat_message_id > chat_message_id -> + {:ok, cursor} + + %FederationReadCursor{} = cursor -> + cursor + |> FederationReadCursor.changeset(attrs) + |> Repo.update() + end + end + + def upsert_remote_read_cursor( + _conversation_id, + _chat_message_id, + _remote_actor_id, + _remote_domain, + _read_at, + _read_through_sequence, + _context + ), + do: {:error, :invalid_event_payload} + + def maybe_broadcast_remote_read_cursor( + conversation_id, + chat_message_id, + remote_actor_id, + context + ) + when is_integer(conversation_id) and is_integer(chat_message_id) and + is_integer(remote_actor_id) and is_map(context) do + case get_remote_actor(remote_actor_id) do + %ActivityPubActor{} = actor -> + label = + case normalize_optional_string(actor.display_name) do + nil -> "@#{actor.username}@#{actor.domain}" + display_name -> "#{display_name} (@#{actor.username}@#{actor.domain})" + end + + call(context, :broadcast_conversation_event, [ + conversation_id, + {:chat_remote_read_cursor, + %{ + read_through_message_id: chat_message_id, + remote_actor_id: remote_actor_id, + username: label, + avatar: actor.avatar_url + }} + ]) + + :ok + + _ -> + :ok + end + end + + def maybe_broadcast_remote_read_cursor( + _conversation_id, + _chat_message_id, + _remote_actor_id, + _context + ), + do: :ok + + def upsert_extension_projection( + event_type, + event_key, + payload, + remote_domain, + server_id, + conversation_id, + occurred_at, + status + ) + when is_binary(event_type) and is_binary(event_key) and is_binary(remote_domain) and + is_map(payload) do + event_type = Elektrine.Messaging.ArblargSDK.canonical_event_type(event_type) + + attrs = %{ + event_type: event_type, + origin_domain: String.downcase(remote_domain), + event_key: event_key, + payload: payload, + status: status, + occurred_at: occurred_at || DateTime.utc_now(), + server_id: server_id, + conversation_id: conversation_id + } + + case Repo.get_by(FederationExtensionEvent, + event_type: event_type, + origin_domain: String.downcase(remote_domain), + event_key: event_key + ) do + nil -> + %FederationExtensionEvent{} + |> FederationExtensionEvent.changeset(attrs) + |> Repo.insert() + + %FederationExtensionEvent{} = event -> + if stale_remote_update?(event.occurred_at, attrs.occurred_at) do + {:ok, event} + else + event + |> FederationExtensionEvent.changeset(attrs) + |> Repo.update() + end + end + end + + def upsert_extension_projection( + _event_type, + _event_key, + _payload, + _remote_domain, + _server_id, + _conversation_id, + _occurred_at, + _status + ), + do: {:error, :invalid_event_payload} + + def upsert_extension_system_message( + %ChatConversation{} = mirror_channel, + event_type, + event_key, + content, + metadata, + remote_domain, + context + ) + when is_binary(event_type) and is_binary(event_key) and is_binary(content) and + is_map(metadata) and is_binary(remote_domain) and is_map(context) do + event_type = Elektrine.Messaging.ArblargSDK.canonical_event_type(event_type) + federated_source = "arblarg:ext:#{event_type}:#{event_key}" + + attrs = %{ + conversation_id: mirror_channel.id, + content: content, + message_type: "system", + media_metadata: metadata, + is_federated_mirror: true, + origin_domain: String.downcase(remote_domain), + federated_source: federated_source, + sender_id: nil + } + + case Repo.get_by(ChatMessage, + conversation_id: mirror_channel.id, + federated_source: federated_source + ) do + nil -> + case %ChatMessage{} |> ChatMessage.changeset(attrs) |> Repo.insert() do + {:ok, message} -> + from(c in ChatConversation, where: c.id == ^mirror_channel.id) + |> Repo.update_all(set: [last_message_at: message.inserted_at]) + + call(context, :maybe_broadcast_mirror_message_created, [message]) + + error -> + error + end + + %ChatMessage{} = existing -> + update_attrs = %{ + content: content, + media_metadata: metadata, + edited_at: Elektrine.Time.utc_now() + } + + case existing |> ChatMessage.changeset(update_attrs) |> Repo.update() do + {:ok, message} -> + from(c in ChatConversation, where: c.id == ^mirror_channel.id) + |> Repo.update_all(set: [last_message_at: Elektrine.Time.utc_now()]) + + call(context, :maybe_broadcast_mirror_message_updated, [message]) + + error -> + error + end + end + end + + def upsert_extension_system_message( + _mirror_channel, + _event_type, + _event_key, + _content, + _metadata, + _remote_domain, + _context + ), + do: {:error, :invalid_event_payload} + + def upsert_account_presence_state( + remote_actor_id, + status, + activities, + updated_at, + remote_domain, + ttl_ms, + context + ) + when is_integer(remote_actor_id) and is_binary(status) and is_binary(remote_domain) and + is_map(context) do + effective_updated_at = updated_at || DateTime.utc_now() + + attrs = %{ + remote_actor_id: remote_actor_id, + status: status, + origin_domain: String.downcase(remote_domain), + updated_at_remote: effective_updated_at, + expires_at_remote: presence_expiry_from_ttl(effective_updated_at, ttl_ms, context), + activities: %{"items" => normalize_presence_activities(activities)} + } + + case Repo.get_by(FederationAccountPresenceState, remote_actor_id: remote_actor_id) do + nil -> + %FederationAccountPresenceState{} + |> FederationAccountPresenceState.changeset(attrs) + |> Repo.insert() + + %FederationAccountPresenceState{} = state -> + if stale_remote_update?(state.updated_at_remote, effective_updated_at) do + {:ok, state} + else + state + |> FederationAccountPresenceState.changeset(attrs) + |> Repo.update() + end + end + end + + def upsert_account_presence_state( + _remote_actor_id, + _status, + _activities, + _updated_at, + _remote_domain, + _ttl_ms, + _context + ), + do: {:error, :invalid_event_payload} + + def upsert_room_presence_state( + conversation_id, + remote_actor_id, + status, + activities, + updated_at, + remote_domain, + ttl_ms, + context + ) + when is_integer(conversation_id) and is_integer(remote_actor_id) and is_binary(status) and + is_binary(remote_domain) and is_map(context) do + effective_updated_at = updated_at || DateTime.utc_now() + + attrs = %{ + conversation_id: conversation_id, + remote_actor_id: remote_actor_id, + status: status, + origin_domain: String.downcase(remote_domain), + updated_at_remote: effective_updated_at, + expires_at_remote: presence_expiry_from_ttl(effective_updated_at, ttl_ms, context), + activities: %{"items" => normalize_presence_activities(activities)} + } + + case Repo.get_by(FederationRoomPresenceState, + conversation_id: conversation_id, + remote_actor_id: remote_actor_id + ) do + nil -> + %FederationRoomPresenceState{} + |> FederationRoomPresenceState.changeset(attrs) + |> Repo.insert() + + %FederationRoomPresenceState{} = state -> + if stale_remote_update?(state.updated_at_remote, effective_updated_at) do + {:ok, state} + else + state + |> FederationRoomPresenceState.changeset(attrs) + |> Repo.update() + end + end + end + + def upsert_room_presence_state( + _conversation_id, + _remote_actor_id, + _status, + _activities, + _updated_at, + _remote_domain, + _ttl_ms, + _context + ), + do: {:error, :invalid_event_payload} + + def upsert_membership_state( + conversation_id, + remote_actor_id, + remote_domain, + role, + state, + joined_at, + updated_at, + metadata + ) + when is_integer(conversation_id) and is_integer(remote_actor_id) and + is_binary(remote_domain) and is_binary(role) and is_binary(state) and + is_map(metadata) do + effective_updated_at = updated_at || DateTime.utc_now() + + attrs = %{ + conversation_id: conversation_id, + remote_actor_id: remote_actor_id, + origin_domain: String.downcase(remote_domain), + role: role, + state: state, + joined_at_remote: joined_at, + updated_at_remote: effective_updated_at, + metadata: metadata + } + + case Repo.get_by(FederationMembershipState, + conversation_id: conversation_id, + remote_actor_id: remote_actor_id + ) do + nil -> + %FederationMembershipState{} + |> FederationMembershipState.changeset(attrs) + |> Repo.insert() + + %FederationMembershipState{} = membership_state -> + if stale_remote_update?(membership_state.updated_at_remote, effective_updated_at) do + {:ok, membership_state} + else + membership_state + |> FederationMembershipState.changeset(attrs) + |> Repo.update() + end + end + end + + def upsert_membership_state( + _conversation_id, + _remote_actor_id, + _remote_domain, + _role, + _state, + _joined_at, + _updated_at, + _metadata + ), + do: {:error, :invalid_event_payload} + + def upsert_invite_state( + conversation_id, + remote_domain, + actor_payload, + target_payload, + role, + state, + invited_at, + updated_at, + metadata + ) + when is_integer(conversation_id) and is_binary(remote_domain) and is_map(actor_payload) and + is_map(target_payload) and is_binary(role) and is_binary(state) and + is_map(metadata) do + actor_uri = normalize_optional_string(actor_payload["uri"] || actor_payload["id"]) + target_uri = normalize_optional_string(target_payload["uri"] || target_payload["id"]) + + effective_updated_at = updated_at || DateTime.utc_now() + + attrs = %{ + conversation_id: conversation_id, + origin_domain: String.downcase(remote_domain), + actor_uri: actor_uri, + actor_payload: actor_payload, + target_uri: target_uri, + target_payload: target_payload, + role: role, + state: state, + invited_at_remote: invited_at, + updated_at_remote: effective_updated_at, + metadata: metadata + } + + if !is_binary(actor_uri) or !is_binary(target_uri) do + {:error, :invalid_event_payload} + else + case Repo.get_by(FederationInviteState, + conversation_id: conversation_id, + target_uri: target_uri + ) do + nil -> + %FederationInviteState{} + |> FederationInviteState.changeset(attrs) + |> Repo.insert() + + %FederationInviteState{} = invite_state -> + if stale_remote_update?(invite_state.updated_at_remote, effective_updated_at) do + {:ok, invite_state} + else + invite_state + |> FederationInviteState.changeset(attrs) + |> Repo.update() + end + end + end + end + + def upsert_invite_state( + _conversation_id, + _remote_domain, + _actor_payload, + _target_payload, + _role, + _state, + _invited_at, + _updated_at, + _metadata + ), + do: {:error, :invalid_event_payload} + + def persist_local_invite_projection( + conversation_id, + target_user_id, + actor_user_id, + state, + role, + metadata, + context + ) + when is_integer(conversation_id) and is_integer(target_user_id) and + is_integer(actor_user_id) and is_binary(state) and is_binary(role) and + is_map(metadata) and is_map(context) do + with %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id), + %User{} = target_user <- Repo.get(User, target_user_id), + %User{} = actor_user <- Repo.get(User, actor_user_id), + {:ok, _invite_state} <- + upsert_invite_state( + conversation.id, + call(context, :local_domain, []), + sender_payload(actor_user), + sender_payload(target_user), + role, + state, + DateTime.utc_now() |> DateTime.truncate(:second), + DateTime.utc_now() |> DateTime.truncate(:second), + metadata + ) do + :ok + else + nil -> {:error, :invalid_event_payload} + {:error, reason} -> {:error, reason} + _ -> {:error, :invalid_event_payload} + end + end + + def persist_local_invite_projection( + _conversation_id, + _target_user_id, + _actor_user_id, + _state, + _role, + _metadata, + _context + ), + do: {:error, :invalid_event_payload} + + def persist_local_extension_projection(conversation_id, event_type, payload, context) + when is_integer(conversation_id) and is_binary(event_type) and is_map(payload) and + is_map(context) do + canonical_event_type = Elektrine.Messaging.ArblargSDK.canonical_event_type(event_type) + + normalized_event_type = + Map.get( + Elektrine.Messaging.ArblargSDK.schema_bindings(), + canonical_event_type, + canonical_event_type + ) + + with %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id), + event_key when is_binary(event_key) <- + local_extension_event_key(normalized_event_type, payload, conversation), + occurred_at <- local_extension_occurred_at(normalized_event_type, payload), + status <- local_extension_status(normalized_event_type, payload), + {:ok, _projection} <- + upsert_extension_projection( + canonical_event_type, + event_key, + payload, + call(context, :local_domain, []), + conversation.server_id, + conversation.id, + occurred_at, + status + ) do + :ok + else + nil -> {:error, :invalid_event_payload} + {:error, reason} -> {:error, reason} + _ -> {:error, :invalid_event_payload} + end + end + + def persist_local_extension_projection(_conversation_id, _event_type, _payload, _context), + do: {:error, :invalid_event_payload} + + def local_presence_subscriber_domains_for_user(user_id) when is_integer(user_id) do + from(f in Follow, + join: actor in ActivityPubActor, + on: actor.id == f.remote_actor_id, + where: + f.followed_id == ^user_id and is_nil(f.follower_id) and not is_nil(f.remote_actor_id) and + f.pending == false, + where: not is_nil(actor.domain), + select: actor.domain, + distinct: true + ) + |> Repo.all() + |> Enum.filter(&is_binary/1) + end + + def local_presence_subscriber_domains_for_user(_user_id), do: [] + + def local_presence_subscriber_user_ids(remote_actor_id) when is_integer(remote_actor_id) do + from(f in Follow, + where: + f.remote_actor_id == ^remote_actor_id and not is_nil(f.follower_id) and + is_nil(f.followed_id) and f.pending == false, + select: f.follower_id, + distinct: true + ) + |> Repo.all() + end + + def local_presence_subscriber_user_ids(_remote_actor_id), do: [] + + def server_ids_for_remote_actor(remote_actor_id) when is_integer(remote_actor_id) do + from(state in FederationMembershipState, + join: conversation in ChatConversation, + on: conversation.id == state.conversation_id, + where: state.remote_actor_id == ^remote_actor_id and state.state == "active", + where: not is_nil(conversation.server_id), + select: conversation.server_id, + distinct: true + ) + |> Repo.all() + end + + def server_ids_for_remote_actor(_remote_actor_id), do: [] + + defp local_extension_event_key("role.upsert", payload, %ChatConversation{} = conversation) do + case get_in(payload, ["role", "id"]) do + role_id when is_binary(role_id) -> "role:#{role_id}:channel:#{conversation.id}" + _ -> nil + end + end + + defp local_extension_event_key( + "role.assignment.upsert", + payload, + %ChatConversation{} = conversation + ) do + with role_id when is_binary(role_id) <- get_in(payload, ["assignment", "role_id"]), + target_type when is_binary(target_type) <- + get_in(payload, ["assignment", "target", "type"]), + target_id when is_binary(target_id) <- get_in(payload, ["assignment", "target", "id"]) do + "role_assignment:#{role_id}:#{target_type}:#{target_id}:channel:#{conversation.id}" + else + _ -> nil + end + end + + defp local_extension_event_key( + "permission.overwrite.upsert", + payload, + %ChatConversation{} = conversation + ) do + case get_in(payload, ["overwrite", "id"]) do + overwrite_id when is_binary(overwrite_id) -> + "overwrite:#{overwrite_id}:channel:#{conversation.id}" + + _ -> + nil + end + end + + defp local_extension_event_key("thread.upsert", payload, %ChatConversation{} = conversation) do + case get_in(payload, ["thread", "id"]) do + thread_id when is_binary(thread_id) -> "thread:#{thread_id}:channel:#{conversation.id}" + _ -> nil + end + end + + defp local_extension_event_key("thread.archive", payload, %ChatConversation{} = conversation) do + case payload["thread_id"] do + thread_id when is_binary(thread_id) -> "thread:#{thread_id}:channel:#{conversation.id}" + _ -> nil + end + end + + defp local_extension_event_key("pin.upsert", payload, %ChatConversation{} = conversation) do + case get_in(payload, ["pin", "message_id"]) do + message_id when is_binary(message_id) -> "pin:#{message_id}:channel:#{conversation.id}" + _ -> nil + end + end + + defp local_extension_event_key( + "moderation.action.recorded", + payload, + %ChatConversation{} = conversation + ) do + case get_in(payload, ["action", "id"]) do + action_id when is_binary(action_id) -> "moderation:#{action_id}:channel:#{conversation.id}" + _ -> nil + end + end + + defp local_extension_event_key(_event_type, _payload, _conversation), do: nil + + defp local_extension_occurred_at("role.upsert", payload) do + parse_datetime(get_in(payload, ["role", "updated_at"])) || DateTime.utc_now() + end + + defp local_extension_occurred_at("thread.archive", payload) do + parse_datetime(payload["archived_at"]) || DateTime.utc_now() + end + + defp local_extension_occurred_at("pin.upsert", payload) do + parse_datetime(get_in(payload, ["pin", "updated_at"])) || + parse_datetime(get_in(payload, ["pin", "pinned_at"])) || DateTime.utc_now() + end + + defp local_extension_occurred_at("moderation.action.recorded", payload) do + parse_datetime(get_in(payload, ["action", "occurred_at"])) || DateTime.utc_now() + end + + defp local_extension_occurred_at(_event_type, _payload), do: DateTime.utc_now() + + defp local_extension_status("role.assignment.upsert", payload), + do: get_in(payload, ["assignment", "state"]) + + defp local_extension_status("thread.upsert", payload), do: get_in(payload, ["thread", "state"]) + defp local_extension_status("thread.archive", _payload), do: "archived" + defp local_extension_status("pin.upsert", payload), do: get_in(payload, ["pin", "state"]) + + defp local_extension_status("moderation.action.recorded", payload), + do: get_in(payload, ["action", "kind"]) + + defp local_extension_status(_event_type, _payload), do: nil + + def maybe_broadcast_presence_update( + subscriber_user_ids, + server_ids, + remote_actor_id, + status, + activities, + updated_at, + context + ) + when is_list(subscriber_user_ids) and is_list(server_ids) and is_integer(remote_actor_id) and + is_binary(status) and is_map(context) do + actor = get_remote_actor(remote_actor_id) + username = if actor, do: actor.username, else: "remote" + domain = if actor, do: actor.domain, else: call(context, :local_domain, []) + handle = "@#{username}@#{domain}" + + label = + case actor && normalize_optional_string(actor.display_name) do + nil -> handle + display_name -> "#{display_name} (#{handle})" + end + + payload = %{ + server_ids: Enum.filter(server_ids, &is_integer/1), + remote_actor_id: remote_actor_id, + handle: handle, + label: label, + avatar_url: if(actor, do: actor.avatar_url, else: nil), + status: status, + activities: normalize_presence_activities(activities), + updated_at: updated_at || DateTime.utc_now() + } + + subscriber_user_ids + |> Enum.filter(&is_integer/1) + |> Enum.uniq() + |> Enum.each(fn user_id -> + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}", + {:federation_presence_update, payload} + ) + end) + + :ok + end + + def maybe_broadcast_presence_update( + _subscriber_user_ids, + _server_ids, + _remote_actor_id, + _status, + _activities, + _updated_at, + _context + ), + do: :ok + + def maybe_broadcast_room_presence_update( + conversation_id, + remote_actor_id, + status, + activities, + updated_at, + context + ) + when is_integer(conversation_id) and is_integer(remote_actor_id) and is_binary(status) and + is_map(context) do + case get_remote_actor(remote_actor_id) do + %ActivityPubActor{} = actor -> + handle = "@#{actor.username}@#{actor.domain}" + + label = + case normalize_optional_string(actor.display_name) do + nil -> handle + display_name -> "#{display_name} (#{handle})" + end + + payload = %{ + conversation_id: conversation_id, + remote_actor_id: remote_actor_id, + handle: handle, + label: label, + avatar_url: actor.avatar_url, + status: status, + activities: normalize_presence_activities(activities), + updated_at: updated_at || DateTime.utc_now() + } + + call(context, :broadcast_conversation_event, [ + conversation_id, + {:federation_presence_update, payload} + ]) + + :ok + + _ -> + :ok + end + end + + def maybe_broadcast_room_presence_update( + _conversation_id, + _remote_actor_id, + _status, + _activities, + _updated_at, + _context + ), + do: :ok + + def maybe_broadcast_membership_state(conversation_id, membership_state, context) + when is_integer(conversation_id) and is_map(context) do + case get_remote_actor(membership_state.remote_actor_id) do + %ActivityPubActor{} = actor -> + payload = %{ + conversation_id: conversation_id, + remote_actor_id: membership_state.remote_actor_id, + handle: "@#{actor.username}@#{actor.domain}", + role: membership_state.role, + state: membership_state.state, + joined_at: membership_state.joined_at_remote, + updated_at: membership_state.updated_at_remote, + avatar_url: actor.avatar_url + } + + call(context, :broadcast_conversation_event, [ + conversation_id, + {:federation_membership_update, payload} + ]) + + :ok + + _ -> + :ok + end + end + + def maybe_broadcast_membership_state(_conversation_id, _membership_state, _context), do: :ok + + def maybe_broadcast_remote_typing_started(conversation_id, remote_actor_id, context) + when is_integer(conversation_id) and is_integer(remote_actor_id) and is_map(context) do + case get_remote_actor(remote_actor_id) do + %ActivityPubActor{} = actor -> + label = + case normalize_optional_string(actor.display_name) do + nil -> "@#{actor.username}@#{actor.domain}" + display_name -> "#{display_name} (@#{actor.username}@#{actor.domain})" + end + + call(context, :broadcast_conversation_event, [ + conversation_id, + {:user_typing, remote_actor_typing_key(actor), label} + ]) + + call(context, :broadcast_conversation_event, [ + conversation_id, + {:user_typing, conversation_id, remote_actor_typing_key(actor), label} + ]) + + :ok + + _ -> + :ok + end + end + + def maybe_broadcast_remote_typing_started(_conversation_id, _remote_actor_id, _context), do: :ok + + def maybe_broadcast_remote_typing_stopped(conversation_id, remote_actor_id, context) + when is_integer(conversation_id) and is_integer(remote_actor_id) and is_map(context) do + case get_remote_actor(remote_actor_id) do + %ActivityPubActor{} = actor -> + call(context, :broadcast_conversation_event, [ + conversation_id, + {:user_stopped_typing, remote_actor_typing_key(actor)} + ]) + + call(context, :broadcast_conversation_event, [ + conversation_id, + {:user_stopped_typing, conversation_id, remote_actor_typing_key(actor)} + ]) + + :ok + + _ -> + :ok + end + end + + def maybe_broadcast_remote_typing_stopped(_conversation_id, _remote_actor_id, _context), do: :ok + + def normalize_presence_activities(activities) when is_list(activities) do + activities + |> Enum.filter(&is_map/1) + |> Enum.take(10) + end + + def normalize_presence_activities(%{"items" => activities}) when is_list(activities) do + normalize_presence_activities(activities) + end + + def normalize_presence_activities(%{items: activities}) when is_list(activities) do + normalize_presence_activities(activities) + end + + def normalize_presence_activities(_activities), do: [] + + def invite_membership_state(state) when state in ["pending"], do: "invited" + def invite_membership_state(state) when state in ["accepted"], do: "active" + def invite_membership_state(_state), do: "left" + + def ban_membership_state(state) when state == "active", do: "banned" + def ban_membership_state(_state), do: "left" + + def expired_presence_state?(%DateTime{} = expires_at) do + DateTime.compare(expires_at, DateTime.utc_now()) != :gt + end + + def expired_presence_state?(_expires_at), do: false + + defp presence_expiry_from_ttl(%DateTime{} = updated_at, ttl_ms, context) do + effective_ttl_ms = + case ttl_ms do + ttl when is_integer(ttl) and ttl > 0 -> ttl + _ -> call(context, :presence_ttl_seconds, []) * 1_000 + end + + DateTime.add(updated_at, effective_ttl_ms, :millisecond) + end + + defp presence_expiry_from_ttl(_updated_at, _ttl_ms, _context), do: nil + + defp remote_actor_typing_key(%ActivityPubActor{} = actor) do + "remote:#{actor.id}:#{actor.domain}" + end + + defp get_remote_actor(actor_id) when is_integer(actor_id) do + AppCache.get_activitypub_actor_by_id(actor_id, fn -> + Repo.get(ActivityPubActor, actor_id) + end) + end + + defp get_remote_actor(_actor_id), do: nil + + defp normalize_positive_int(value) when is_integer(value) and value > 0, do: value + defp normalize_positive_int(_value), do: nil + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_value), do: nil + + defp stale_remote_update?(%DateTime{} = current_at, %DateTime{} = incoming_at) do + DateTime.compare(current_at, incoming_at) == :gt + end + + defp stale_remote_update?(_current_at, _incoming_at), do: false + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/transport.ex b/apps/elektrine/lib/elektrine/messaging/federation/transport.ex new file mode 100644 index 0000000..97aa84b --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/transport.ex @@ -0,0 +1,287 @@ +defmodule Elektrine.Messaging.Federation.Transport do + @moduledoc false + + alias Elektrine.Messaging.{ArblargProfiles, ArblargSDK} + + @fallback_statuses [404, 406, 410, 415, 426, 501] + @supported_transports [ + "session_websocket", + "events_batch_cbor", + "events_batch_json", + "events_json" + ] + + def event_result_success?(%{"status" => status}, successful_statuses) + when is_binary(status) and is_struct(successful_statuses, MapSet) do + MapSet.member?(successful_statuses, status) + end + + def event_result_success?(_result, _successful_statuses), do: false + + def peer_supports?(peer, feature, default) when is_map(peer) and is_binary(feature) do + case Map.fetch(peer_features(peer), feature) do + {:ok, value} -> truthy_feature_flag?(value) + :error -> default + end + end + + def peer_supports?(_peer, _feature, default), do: default + + def peer_supports_event_type?(peer, event_type) when is_map(peer) and is_binary(event_type) do + canonical_event_type = ArblargSDK.canonical_event_type(event_type) + + cond do + canonical_event_type in ArblargSDK.core_event_types() -> + true + + canonical_event_type in ArblargSDK.supported_event_types() -> + supported_event_type_advertised?(peer, canonical_event_type) or + supported_extension_advertised?( + peer, + ArblargProfiles.extension_urn_for_event_type(canonical_event_type) + ) or + supported_profile_advertised?( + peer, + ArblargProfiles.required_profile_for_event_type(canonical_event_type) + ) + + true -> + false + end + end + + def peer_supports_event_type?(_peer, _event_type), do: false + + def event_transport_order(peer, transport_profiles) + when is_map(peer) and is_map(transport_profiles) do + preferred = peer_preferred_transport_order(peer) + fallback = peer_fallback_transport_order(peer) + + advertised_fallback = + transport_profiles + |> Map.get("fallback_order", []) + |> List.wrap() + |> Enum.filter(&is_binary/1) + + (preferred ++ fallback ++ advertised_fallback) + |> Enum.filter(&(&1 in @supported_transports)) + |> Enum.uniq() + end + + def event_transport_order(_peer, _transport_profiles), do: default_transport_order() + + def ephemeral_transport_order(peer, transport_profiles) + when is_map(peer) and is_map(transport_profiles) do + event_transport_order(peer, transport_profiles) + end + + def ephemeral_transport_order(_peer, _transport_profiles), do: default_transport_order() + + def peer_batch_limit(peer, default), do: peer_limit(peer, "max_batch_events", default) + + def peer_ephemeral_limit(peer, default), + do: peer_limit(peer, "max_ephemeral_items", default) + + def transport_fallback_reason?({:http_error, status, _body}) when status in @fallback_statuses, + do: true + + def transport_fallback_reason?({:http_error, status}) when status in @fallback_statuses, + do: true + + def transport_fallback_reason?(:session_transport_unavailable), do: true + def transport_fallback_reason?(:session_transport_failed), do: true + def transport_fallback_reason?(:session_closed), do: true + def transport_fallback_reason?(:session_timeout), do: true + def transport_fallback_reason?(:unsupported_transport_profile), do: true + def transport_fallback_reason?(:no_compatible_transport), do: true + def transport_fallback_reason?(_reason), do: false + + defp default_transport_order do + ["events_batch_cbor", "events_batch_json", "events_json"] + end + + defp peer_preferred_transport_order(peer) when is_map(peer) do + peer + |> peer_transport_profiles() + |> Map.get("preferred_order", []) + |> List.wrap() + |> Enum.filter(&is_binary/1) + end + + defp peer_fallback_transport_order(peer) when is_map(peer) do + peer + |> peer_transport_profiles() + |> Map.get("fallback_order", []) + |> List.wrap() + |> Enum.filter(&is_binary/1) + end + + defp peer_transport_profiles(peer) when is_map(peer) do + peer_features(peer) + |> Map.get("transport_profiles", %{}) + |> case do + %{} = profiles -> profiles + _ -> %{} + end + end + + defp peer_limit(peer, key, default) when is_map(peer) and is_binary(key) do + case peer_features(peer) do + %{"limits" => %{} = limits} -> + case Map.get(limits, key) do + value when is_integer(value) and value > 0 -> value + _ -> default + end + + _ -> + default + end + end + + defp peer_limit(_peer, _key, default), do: default + + defp peer_features(peer) when is_map(peer) do + case Map.get(peer, :features) || Map.get(peer, "features") do + features when is_map(features) -> + Enum.reduce(features, %{}, fn {key, value}, acc -> + normalized_key = + case key do + binary when is_binary(binary) -> binary + atom when is_atom(atom) -> Atom.to_string(atom) + other -> to_string(other) + end + + Map.put(acc, normalized_key, value) + end) + + _ -> + %{} + end + end + + defp supported_event_type_advertised?(peer, event_type) when is_binary(event_type) do + event_type in peer_supported_event_types(peer) + end + + defp supported_extension_advertised?(peer, urn) when is_binary(urn) do + peer + |> peer_extensions() + |> Enum.any?(&extension_supports_urn?(&1, urn)) + end + + defp supported_extension_advertised?(_peer, _urn), do: false + + defp supported_profile_advertised?(peer, profile_id) when is_binary(profile_id) do + profile_id in peer_compatibility_claims(peer) + end + + defp supported_profile_advertised?(_peer, _profile_id), do: false + + defp peer_supported_event_types(peer) when is_map(peer) do + direct_supported = + capability_list(peer, "supported_event_types") + |> Enum.filter(&is_binary/1) + |> Enum.map(&ArblargSDK.canonical_event_type/1) + + event_supported = + peer_features(peer) + |> Map.get("events", %{}) + |> case do + %{} = events -> Map.get(events, "supported", []) + _ -> [] + end + |> List.wrap() + |> Enum.filter(&is_binary/1) + |> Enum.map(&ArblargSDK.canonical_event_type/1) + + Enum.uniq(direct_supported ++ event_supported) + end + + defp peer_supported_event_types(_peer), do: [] + + defp peer_extensions(peer) when is_map(peer) do + capability_list(peer, "extensions") + end + + defp peer_extensions(_peer), do: [] + + defp peer_compatibility_claims(peer) when is_map(peer) do + capability_list(peer, "compatibility_claims") + |> Enum.filter(&is_binary/1) + end + + defp peer_compatibility_claims(_peer), do: [] + + defp capability_list(peer, key) when is_map(peer) and is_binary(key) do + direct = + case capability_value(peer, key) do + values when is_list(values) -> values + _ -> [] + end + + nested = + peer_features(peer) + |> Map.get(key, []) + |> case do + values when is_list(values) -> values + _ -> [] + end + + direct ++ nested + end + + defp capability_list(_peer, _key), do: [] + + defp capability_value(map, key) when is_map(map) and is_binary(key) do + case Map.get(map, key) do + nil -> + map + |> Enum.find(fn + {atom_key, _value} when is_atom(atom_key) -> Atom.to_string(atom_key) == key + _ -> false + end) + |> case do + {_, value} -> value + nil -> nil + end + + value -> + value + end + end + + defp extension_supports_urn?(extension, urn) when is_binary(extension) and is_binary(urn) do + extension == urn + end + + defp extension_supports_urn?(%{} = extension, urn) when is_binary(urn) do + advertised_urn = extension["urn"] || extension[:urn] + + cond do + advertised_urn != urn -> + false + + Map.get(extension, "supported") in [true, "true", "1", 1] -> + true + + Map.get(extension, :supported) in [true, "true", "1", 1] -> + true + + true -> + case get_in(extension, ["conformance", "status"]) || + get_in(extension, [:conformance, :status]) do + nil -> true + status when status in ["passing", "supported", "compatible"] -> true + _ -> false + end + end + end + + defp extension_supports_urn?(_extension, _urn), do: false + + defp truthy_feature_flag?(value) + when value in [true, 1, "1", "true", "TRUE", "yes", "YES"], + do: true + + defp truthy_feature_flag?(_value), do: false +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/utils.ex b/apps/elektrine/lib/elektrine/messaging/federation/utils.ex new file mode 100644 index 0000000..854de16 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/utils.ex @@ -0,0 +1,721 @@ +defmodule Elektrine.Messaging.Federation.Utils do + @moduledoc false + + alias Elektrine.Domains + alias Elektrine.Messaging.ArblargSDK + alias Elektrine.Messaging.ChatMessages + alias Elektrine.Profiles + alias Elektrine.Repo + + def next_outbound_sequence(stream_id) do + sql = + "INSERT INTO messaging_federation_stream_counters (stream_id, next_sequence, inserted_at, updated_at)\nVALUES ($1, 2, NOW(), NOW())\nON CONFLICT (stream_id)\nDO UPDATE\n SET next_sequence = messaging_federation_stream_counters.next_sequence + 1,\n updated_at = NOW()\nRETURNING next_sequence - 1\n" + + case Ecto.Adapters.SQL.query(Repo, sql, [stream_id]) do + {:ok, %{rows: [[sequence]]}} when is_integer(sequence) -> sequence + _ -> 1 + end + end + + def server_stream_id(server_id) do + "server:" <> server_federation_id(server_id) + end + + def channel_stream_id(channel_id) do + "channel:" <> channel_federation_id(channel_id) + end + + def dm_stream_id(conversation_id, opts \\ []) do + "dm:" <> dm_federation_id(conversation_id, opts) + end + + def server_payload(server) do + %{ + "id" => server.federation_id || server_federation_id(server.id), + "name" => server.name, + "description" => server.description, + "icon_url" => server.icon_url, + "is_public" => server.is_public, + "member_count" => server.member_count + } + end + + def channel_payload(channel) do + %{ + "id" => channel.federated_source || channel_federation_id(channel.id), + "name" => channel.name, + "description" => channel.description, + "topic" => channel.channel_topic, + "position" => channel.channel_position, + "is_public" => channel.is_public, + "approval_mode_enabled" => channel.approval_mode_enabled + } + end + + def event_refs_payload(server, channel) do + %{ + "server_id" => server.federation_id || server_federation_id(server.id), + "channel_id" => channel.federated_source || channel_federation_id(channel.id) + } + end + + def message_payload(message, channel) do + %{ + "id" => message.federated_source || message_federation_id(message.id), + "channel_id" => channel.federated_source || channel_federation_id(channel.id), + "content" => message.content || "", + "message_type" => message.message_type, + "attachments" => attachment_payloads(message), + "created_at" => format_created_at(message.inserted_at), + "edited_at" => format_created_at(message.edited_at), + "deleted_at" => format_created_at(message.deleted_at), + "sender" => message_sender_payload(message) + } + |> maybe_put_thread_ref(message) + |> maybe_put_client_encryption(message) + end + + def event_message_payload(message) do + %{ + "id" => message.federated_source || message_federation_id(message.id), + "content" => message.content || "", + "message_type" => message.message_type, + "attachments" => attachment_payloads(message), + "created_at" => format_created_at(message.inserted_at), + "edited_at" => format_created_at(message.edited_at), + "sender" => message_sender_payload(message) + } + |> maybe_put_thread_ref(message) + |> maybe_put_client_encryption(message) + end + + # Thread messages carry an optional thread reference (spec section 7.6); + # receivers without thread support treat them as regular channel messages. + defp maybe_put_thread_ref(payload, message) do + case Elektrine.Messaging.ChatThreads.message_thread_ref(message) do + ref when is_binary(ref) -> Map.put(payload, "thread_id", ref) + _ -> payload + end + end + + def attachment_payloads(message) do + metadata = attachment_metadata_source(message) + metadata_attachments = normalize_metadata_attachments(metadata["attachments"]) + media_urls = normalize_media_urls(message.media_urls || []) + + if metadata_attachments != [] do + metadata_attachments + else + media_urls + |> Enum.with_index() + |> Enum.map(fn {url, index} -> + %{ + "id" => attachment_id(metadata, url, index), + "url" => url, + "mime_type" => attachment_mime_type(metadata, index), + "byte_size" => attachment_numeric(metadata, "byte_sizes", index), + "sha256" => attachment_text(metadata, "sha256", index), + "authorization" => attachment_authorization(metadata, index), + "retention" => attachment_retention(metadata, index), + "expires_at" => attachment_text(metadata, "expires_at", index), + "alt_text" => attachment_text(metadata, "alt_texts", index), + "width" => attachment_numeric(metadata, "widths", index), + "height" => attachment_numeric(metadata, "heights", index), + "duration_ms" => attachment_numeric(metadata, "duration_ms", index) + } + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + end) + end + end + + def sender_payload(user, opts \\ []) + + def sender_payload(user, opts) when is_list(opts) do + domain = sender_domain(opts) + base_url = base_url_for_domain(domain) + uri = "#{base_url}/users/#{user.username}" + handle = "#{user.username}@#{domain}" + + %{ + "id" => uri, + "uri" => uri, + "username" => user.username, + "display_name" => user.display_name || user.username, + "domain" => domain, + "handle" => handle + } + |> maybe_put_is_bot(user) + |> maybe_put_chat_encryption_devices(user) + end + + defp maybe_put_client_encryption(payload, %{client_encrypted_payload: encrypted_payload}) + when is_map(payload) and is_map(encrypted_payload) do + payload + |> Map.put("client_encrypted_payload", encrypted_payload) + |> Map.put("e2ee", %{ + "version" => 1, + "payload" => encrypted_payload, + "key_packages" => + encrypted_payload["federated_key_packages"] || encrypted_payload[:federated_key_packages] || + [] + }) + end + + defp maybe_put_client_encryption(payload, _message), do: payload + + defp maybe_put_chat_encryption_devices(payload, %{id: user_id}) when is_integer(user_id) do + case ChatMessages.list_chat_encryption_devices_for_user(user_id) do + [] -> + payload + + devices -> + Map.put(payload, "chat_encryption_devices", Enum.map(devices, &string_key_device/1)) + end + end + + defp maybe_put_chat_encryption_devices(payload, _user), do: payload + + defp string_key_device(device) when is_map(device) do + %{ + "device_id" => device[:device_id] || device["device_id"], + "public_key" => device[:public_key] || device["public_key"], + "key_algorithm" => device[:key_algorithm] || device["key_algorithm"], + "fingerprint" => device[:fingerprint] || device["fingerprint"], + "signing_public_key" => device[:signing_public_key] || device["signing_public_key"], + "device_signature" => device[:device_signature] || device["device_signature"], + "label" => device[:label] || device["label"] + } + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Enum.into(%{}) + end + + def truncate(nil) do + "" + end + + def truncate(body) when is_binary(body) do + if byte_size(body) > 180 do + binary_part(body, 0, 180) <> "..." + else + body + end + end + + def truncate(body) do + inspect(body) + end + + def normalize_message_type(type) when type in ["text", "image", "file", "voice", "system"] do + type + end + + def normalize_message_type(_) do + "text" + end + + def canonical_path(nil) do + "/" + end + + def canonical_path(path) when is_binary(path) do + trimmed = String.trim(path) + + cond do + not Elektrine.Strings.present?(trimmed) -> "/" + String.starts_with?(trimmed, "/") -> trimmed + true -> "/" <> trimmed + end + end + + def canonical_path(path) do + canonical_path(to_string(path)) + end + + def canonical_query_string(nil) do + "" + end + + def canonical_query_string(query) when is_binary(query) do + String.trim(query) + end + + def canonical_query_string(query) do + to_string(query) + end + + def canonical_content_digest(nil) do + body_digest("") + end + + def canonical_content_digest(content_digest) when is_binary(content_digest) do + case String.trim(content_digest) do + "" -> body_digest("") + value -> value + end + end + + def canonical_content_digest(content_digest) do + canonical_content_digest(to_string(content_digest)) + end + + def normalize_media_urls(values) when is_list(values) do + values + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(not Elektrine.Strings.present?(&1))) + |> Enum.take(10) + end + + def normalize_media_urls(_values), do: [] + + def server_federation_id(server_id) do + "#{local_base_url()}/_arblarg/servers/#{server_id}" + end + + def channel_federation_id(channel_id) do + "#{local_base_url()}/_arblarg/channels/#{channel_id}" + end + + def thread_federation_id(thread_id) do + "#{local_base_url()}/_arblarg/threads/#{thread_id}" + end + + def message_federation_id(message_id, opts \\ []) do + "#{base_url_for_domain(sender_domain(opts))}/_arblarg/messages/#{message_id}" + end + + def dm_federation_id(conversation_id, opts \\ []) do + "#{base_url_for_domain(sender_domain(opts))}/_arblarg/dms/#{conversation_id}" + end + + defp attachment_metadata_source(message) do + case Map.get(message, :media_metadata) do + %{} = metadata -> metadata + _ -> %{} + end + end + + defp normalize_metadata_attachments(attachments) when is_list(attachments) do + attachments + |> Enum.filter(&is_map/1) + |> Enum.map(fn attachment -> + attachment + |> Enum.reduce(%{}, fn {key, value}, acc -> + if is_nil(value), do: acc, else: Map.put(acc, to_string(key), value) + end) + end) + |> Enum.filter(fn attachment -> + is_binary(attachment["id"]) and + is_binary(attachment["url"]) and + is_binary(attachment["mime_type"]) + end) + end + + defp normalize_metadata_attachments(_attachments), do: [] + + defp attachment_id(metadata, url, index) do + attachment_text(metadata, "ids", index) || + attachment_text(metadata, "attachment_ids", index) || + "#{index}-#{:crypto.hash(:sha256, url) |> Base.url_encode64(padding: false)}" + end + + defp attachment_mime_type(metadata, index) do + attachment_text(metadata, "mime_types", index) || "application/octet-stream" + end + + defp attachment_authorization(metadata, index) do + attachment_text(metadata, "authorization", index) || "public" + end + + defp attachment_retention(metadata, index) do + attachment_text(metadata, "retention", index) || "origin" + end + + defp attachment_text(metadata, key, index) when is_map(metadata) and is_binary(key) do + case Map.get(metadata, key) do + %{} = values -> values[to_string(index)] || values[index] + values when is_list(values) -> Enum.at(values, index) + value when is_binary(value) and index == 0 -> value + _ -> nil + end + |> case do + value when is_binary(value) -> + trimmed = String.trim(value) + if Elektrine.Strings.present?(trimmed), do: trimmed, else: nil + + _ -> + nil + end + end + + defp attachment_text(_metadata, _key, _index), do: nil + + defp attachment_numeric(metadata, key, index) when is_map(metadata) and is_binary(key) do + case Map.get(metadata, key) do + %{} = values -> values[to_string(index)] || values[index] + values when is_list(values) -> Enum.at(values, index) + value when is_integer(value) and index == 0 -> value + _ -> nil + end + |> case do + value when is_integer(value) and value >= 0 -> value + _ -> nil + end + end + + defp attachment_numeric(_metadata, _key, _index), do: nil + + def format_sender(nil) do + nil + end + + def format_sender(sender) do + uri = "#{local_base_url()}/users/#{sender.username}" + handle = "#{sender.username}@#{local_domain()}" + + %{ + "id" => uri, + "uri" => uri, + "username" => sender.username, + "display_name" => sender.display_name || sender.username, + "domain" => local_domain(), + "handle" => handle + } + |> maybe_put_is_bot(sender) + |> maybe_put_chat_encryption_devices(sender) + end + + def message_sender_payload(message) when is_map(message) do + webhook_sender_payload(message) || + format_sender(Map.get(message, :sender)) || + embedded_sender_payload(Map.get(message, :media_metadata)) + end + + def message_sender_payload(_message), do: nil + + # Webhook-authored messages federate as normal message.create events; the + # sender payload carries the webhook display metadata. The message.create + # schema requires uri/username/domain/handle on the sender and already + # allows the optional avatar_url/display_name fields; the extra "is_bot" + # flag is permitted because the schema does not restrict additional + # properties. + def webhook_sender_payload(message) when is_map(message) do + metadata = Map.get(message, :media_metadata) + + sender = + if is_map(metadata), + do: metadata["webhook_sender"] || metadata[:webhook_sender], + else: nil + + if is_map(sender) do + webhook_id = + Map.get(message, :webhook_id) || sender["webhook_id"] || sender[:webhook_id] || "unknown" + + name = to_string(sender["name"] || sender[:name] || "Webhook") + avatar_url = sender["avatar_url"] || sender[:avatar_url] + uri = "#{local_base_url()}/chat/webhooks/#{webhook_id}" + + %{ + "id" => uri, + "uri" => uri, + "username" => name, + "display_name" => name, + "domain" => local_domain(), + "handle" => "webhook-#{webhook_id}@#{local_domain()}", + "is_bot" => true + } + |> maybe_put_avatar_url(avatar_url) + else + nil + end + end + + def webhook_sender_payload(_message), do: nil + + defp maybe_put_avatar_url(payload, avatar_url) when is_binary(avatar_url) do + Map.put(payload, "avatar_url", avatar_url) + end + + defp maybe_put_avatar_url(payload, _avatar_url), do: payload + + defp maybe_put_is_bot(payload, sender) do + if is_map(sender) and Map.get(sender, :is_bot) == true do + Map.put(payload, "is_bot", true) + else + payload + end + end + + def format_created_at(nil) do + nil + end + + def format_created_at(%DateTime{} = datetime) do + DateTime.to_iso8601(datetime) + end + + # Ecto `timestamps()` are UTC-naive; emit a full ISO 8601 UTC timestamp so + # receivers validating `format: date-time` (including our own SDK) accept it. + def format_created_at(%NaiveDateTime{} = datetime) do + datetime + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.to_iso8601() + end + + def parse_datetime(nil), do: nil + + def parse_datetime(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, datetime, _offset} -> datetime + _ -> nil + end + end + + def parse_datetime(_), do: nil + + def parse_int(value, _default) when is_integer(value) do + value + end + + def parse_int(value, default) when is_binary(value) do + case Integer.parse(value) do + {int, _} -> int + :error -> default + end + end + + def parse_int(_, default) do + default + end + + def embedded_sender_payload(metadata) when is_map(metadata) do + case metadata["remote_sender"] || metadata[:remote_sender] do + %{} = sender -> + uri = + normalize_optional_string(sender["uri"] || sender[:uri] || sender["id"] || sender[:id]) + + username = + normalize_optional_string( + sender["username"] || sender[:username] || sender["handle"] || sender[:handle] + ) + + domain = + normalize_optional_string(sender["domain"] || sender[:domain]) || + embedded_sender_domain_from_uri(uri) + + handle = + normalize_optional_string(sender["handle"] || sender[:handle]) || + embedded_sender_handle(username, domain) + + display_name = + normalize_optional_string(sender["display_name"] || sender[:display_name]) || username + + if is_binary(uri) or is_binary(username) or is_binary(handle) do + %{ + "id" => uri || handle || username, + "uri" => uri || handle || username, + "username" => username || handle || "remote", + "display_name" => display_name || username || handle || "remote", + "domain" => domain, + "handle" => handle + } + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + else + nil + end + + _ -> + nil + end + end + + def embedded_sender_payload(_metadata), do: nil + + def preferred_dm_origin_domain_for_user(%{id: user_id}) when is_integer(user_id) do + case Profiles.preferred_verified_domain_for_user(user_id) do + %{domain: domain} when is_binary(domain) -> + if Elektrine.Strings.present?(domain), do: domain, else: local_domain() + + _ -> + local_domain() + end + end + + def preferred_dm_origin_domain_for_user(_), do: local_domain() + + def infer_remote_server_id(payload) when is_map(payload) do + payload_data = payload["payload"] || %{} + refs = payload_data["refs"] || %{} + + server_id_from_data = + (get_in(payload_data, ["server", "id"]) || refs["server_id"]) + |> extract_trailing_integer() + + stream_id = payload["stream_id"] + + server_id_from_stream = + case stream_id do + "server:" <> server_federation_id -> extract_trailing_integer(server_federation_id) + _ -> nil + end + + case server_id_from_data || server_id_from_stream do + nil -> {:error, :cannot_infer_snapshot_server_id} + id -> {:ok, id} + end + end + + def infer_remote_server_id(_) do + {:error, :cannot_infer_snapshot_server_id} + end + + def infer_room_origin_domain(payload) when is_map(payload) do + payload_data = payload["payload"] || payload + refs = payload_data["refs"] || %{} + + server_host = + (get_in(payload_data, ["server", "id"]) || refs["server_id"]) + |> uri_host() + + channel_host = + (get_in(payload_data, ["channel", "id"]) || refs["channel_id"]) + |> uri_host() + + cond do + is_binary(server_host) and is_binary(channel_host) and server_host == channel_host -> + server_host + + is_binary(channel_host) -> + channel_host + + is_binary(server_host) -> + server_host + + true -> + nil + end + end + + def infer_room_origin_domain(_payload), do: nil + + defp sender_domain(opts) when is_list(opts) do + case Keyword.get(opts, :domain) do + domain when is_binary(domain) -> + trimmed = String.trim(domain) + if Elektrine.Strings.present?(trimmed), do: String.downcase(trimmed), else: local_domain() + + _ -> + local_domain() + end + end + + defp base_url_for_domain(domain) when is_binary(domain) do + local_base_url() + |> URI.parse() + |> Map.put(:host, domain) + |> URI.to_string() + |> String.trim_trailing("/") + end + + def infer_remote_server_id_from_federation_id(federation_id) when is_binary(federation_id) do + case extract_trailing_integer(federation_id) do + nil -> {:error, :cannot_infer_snapshot_server_id} + id -> {:ok, id} + end + end + + def infer_remote_server_id_from_federation_id(_) do + {:error, :cannot_infer_snapshot_server_id} + end + + def extract_trailing_integer(nil) do + nil + end + + def extract_trailing_integer(value) when is_binary(value) do + value + |> String.trim_trailing("/") + |> String.split("/") + |> List.last() + |> case do + nil -> + nil + + candidate -> + case Integer.parse(candidate) do + {int, ""} -> int + _ -> nil + end + end + end + + def extract_trailing_integer(_) do + nil + end + + def uri_host(value) when is_binary(value) do + case URI.parse(value) do + %URI{host: host} when is_binary(host) -> + if Elektrine.Strings.present?(host), do: String.downcase(host), else: nil + + _ -> + nil + end + end + + def uri_host(_value), do: nil + + defp embedded_sender_domain_from_uri(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{host: host} when is_binary(host) -> + if Elektrine.Strings.present?(host), do: String.downcase(host), else: nil + + _ -> + nil + end + end + + defp embedded_sender_domain_from_uri(_uri), do: nil + + defp embedded_sender_handle(username, domain) when is_binary(username) and is_binary(domain) do + if Elektrine.Strings.present?(username) and Elektrine.Strings.present?(domain) do + "#{username}@#{domain}" + else + nil + end + end + + defp embedded_sender_handle(_username, _domain), do: nil + + defp body_digest(body) when is_binary(body) do + "sha-256=" <> ArblargSDK.body_digest(body) + end + + defp local_domain do + Elektrine.Messaging.Federation.local_domain() + end + + defp local_base_url do + configured = + federation_config() + |> Keyword.get(:base_url) + |> normalize_optional_string() + + configured || infer_local_base_url(local_domain()) + end + + defp infer_local_base_url(domain) when is_binary(domain) do + Domains.inferred_base_url_for_domain(domain) + end + + defp federation_config do + Application.get_env(:elektrine, :messaging_federation, []) + end + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/validation.ex b/apps/elektrine/lib/elektrine/messaging/federation/validation.ex new file mode 100644 index 0000000..40b4dcb --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/validation.ex @@ -0,0 +1,892 @@ +defmodule Elektrine.Messaging.Federation.Validation do + @moduledoc false + + alias Elektrine.Messaging.ArblargSDK + + def validate_snapshot_payload(payload, remote_domain, context) + when is_map(payload) and is_binary(remote_domain) and is_map(context) do + origin_domain = payload["origin_domain"] + server = payload["server"] || %{} + channels = payload["channels"] || [] + messages = payload["messages"] || [] + governance = payload["governance"] + stream_positions = payload["stream_positions"] + reactions = payload["reactions"] || [] + read_cursors = payload["read_cursors"] || [] + message_deletions = payload["message_deletions"] || [] + extensions = payload["extensions"] || [] + + governance_entry_count = + context + |> call(:snapshot_governance_entries, [governance]) + |> length() + + with true <- payload["version"] == 1 or {:error, :unsupported_version}, + true <- origin_domain == remote_domain or {:error, :origin_domain_mismatch}, + true <- is_map(server) or {:error, :invalid_server_payload}, + true <- + (is_binary(server["id"]) and is_binary(server["name"])) or + {:error, :invalid_server_payload}, + true <- is_list(channels) or {:error, :invalid_payload}, + true <- is_list(messages) or {:error, :invalid_payload}, + true <- is_list(reactions) or {:error, :invalid_payload}, + true <- is_list(read_cursors) or {:error, :invalid_payload}, + true <- is_list(message_deletions) or {:error, :invalid_payload}, + true <- is_list(extensions) or {:error, :invalid_payload}, + true <- is_map(governance) or {:error, :invalid_snapshot_governance}, + true <- snapshot_governance_present?(payload) or {:error, :invalid_snapshot_governance}, + true <- + snapshot_governance_list?(governance, "memberships") or + {:error, :invalid_snapshot_governance}, + true <- + snapshot_governance_list?(governance, "invites") or + {:error, :invalid_snapshot_governance}, + true <- + snapshot_governance_list?(governance, "bans") or + {:error, :invalid_snapshot_governance}, + true <- is_list(stream_positions) or {:error, :invalid_snapshot_stream_positions}, + true <- + snapshot_stream_positions_present?(payload) or + {:error, :invalid_snapshot_stream_positions}, + true <- stream_positions != [] or {:error, :invalid_snapshot_stream_positions}, + true <- + length(channels) <= call(context, :snapshot_channel_limit, []) or + {:error, :snapshot_limit_exceeded}, + true <- + length(messages) <= call(context, :snapshot_message_limit, []) or + {:error, :snapshot_limit_exceeded}, + true <- + governance_entry_count <= call(context, :snapshot_governance_limit, []) or + {:error, :snapshot_limit_exceeded}, + :ok <- validate_snapshot_signature(payload, remote_domain, context), + :ok <- validate_snapshot_message_actors(messages, remote_domain, context), + :ok <- validate_snapshot_origin_owned_identifiers(payload, remote_domain), + :ok <- validate_snapshot_stream_positions(stream_positions, remote_domain), + :ok <- validate_snapshot_governance_shape(governance, remote_domain, context), + :ok <- validate_snapshot_reactions(reactions, remote_domain, context), + :ok <- validate_snapshot_read_cursors(read_cursors, remote_domain, context), + :ok <- validate_snapshot_message_deletions(message_deletions, remote_domain, context), + :ok <- validate_snapshot_extensions(extensions, remote_domain, context) do + :ok + else + {:error, reason} -> {:error, reason} + false -> {:error, :invalid_payload} + end + end + + def validate_snapshot_payload(_payload, _remote_domain, _context), + do: {:error, :invalid_payload} + + def validate_event_payload(payload, remote_domain, context) + when is_map(payload) and is_binary(remote_domain) and is_map(context) do + payload = call(context, :normalize_incoming_event_payload, [payload]) + + if payload["origin_domain"] != remote_domain do + {:error, :origin_domain_mismatch} + else + case ArblargSDK.validate_event_envelope(payload) do + :ok -> + case maybe_require_event_signature(payload) do + :ok -> + with :ok <- maybe_verify_envelope_signature(payload, remote_domain, context), + :ok <- validate_origin_bound_actors(payload, remote_domain, context), + :ok <- validate_origin_owned_identifiers(payload, remote_domain, context) do + validate_stream_origin(payload["stream_id"], remote_domain) + end + + error -> + error + end + + {:error, :unsupported_version} -> + {:error, :unsupported_version} + + {:error, :unsupported_protocol} -> + {:error, :unsupported_protocol} + + {:error, :unsupported_event_type} -> + {:error, :unsupported_event_type} + + {:error, :invalid_event_id} -> + {:error, :invalid_event_id} + + {:error, :invalid_stream_id} -> + {:error, :invalid_stream_id} + + {:error, :invalid_sequence} -> + {:error, :invalid_sequence} + + {:error, :invalid_idempotency_key} -> + {:error, :invalid_idempotency_key} + + {:error, :invalid_event_payload} -> + {:error, :invalid_event_payload} + + {:error, :invalid_signature} -> + {:error, :invalid_event_signature} + + {:error, _} -> + {:error, :invalid_payload} + end + end + end + + def validate_event_payload(_payload, _remote_domain, _context), do: {:error, :invalid_payload} + + def validate_snapshot_governance_payload(event_type, payload, remote_domain, context) + when is_binary(event_type) and is_map(payload) and is_binary(remote_domain) and + is_map(context) do + with :ok <- ArblargSDK.validate_event_payload(event_type, payload), + :ok <- validate_snapshot_governance_actors(event_type, payload, remote_domain, context), + :ok <- + validate_origin_owned_identifiers_in_event_data( + event_type, + payload, + remote_domain, + context + ) do + :ok + else + {:error, reason} -> {:error, reason} + end + end + + def validate_snapshot_governance_payload(_event_type, _payload, _remote_domain, _context), + do: {:error, :invalid_snapshot_governance} + + defp validate_snapshot_governance_actors("membership.upsert", payload, _remote_domain, _context) + when is_map(payload) do + actor = get_in(payload, ["membership", "actor"]) + + if actor_domain_matches_origin?(actor, actor_origin_domain(actor)) do + :ok + else + {:error, :origin_actor_domain_mismatch} + end + end + + defp validate_snapshot_governance_actors(event_type, payload, remote_domain, context) do + validate_snapshot_origin_bound_actors_in_event_data( + event_type, + payload, + remote_domain, + context + ) + end + + def validate_origin_bound_actors_in_event_data(event_type, data, remote_domain, context) + when is_binary(event_type) and is_map(data) and is_binary(remote_domain) and is_map(context) do + actors = + event_type + |> normalized_event_name() + |> origin_bound_actors(data) + + if Enum.all?(actors, &actor_domain_matches_origin?(&1, remote_domain)) do + :ok + else + {:error, :origin_actor_domain_mismatch} + end + end + + def validate_origin_bound_actors_in_event_data(_event_type, _data, _remote_domain, _context), + do: {:error, :invalid_payload} + + def validate_origin_owned_identifiers_in_event_data(event_type, data, remote_domain, context) + when is_binary(event_type) and is_map(data) and is_binary(remote_domain) and is_map(context) do + identifiers = + event_type + |> normalized_event_name() + |> origin_owned_event_identifiers(data, context) + + if Enum.all?(identifiers, &origin_owned_absolute_uri?(&1, remote_domain)) do + :ok + else + {:error, :origin_identifier_host_mismatch} + end + end + + def validate_origin_owned_identifiers_in_event_data( + _event_type, + _data, + _remote_domain, + _context + ), + do: {:error, :invalid_payload} + + defp maybe_verify_envelope_signature(payload, remote_domain, context) do + case payload["signature"] do + %{} -> + case call(context, :incoming_peer, [remote_domain]) do + %{} = peer -> + key_lookup = + fn key_id -> + call(context, :incoming_verification_materials_for_key_id, [peer, key_id]) + end + + if ArblargSDK.verify_event_envelope_signature(payload, key_lookup) do + :ok + else + {:error, :invalid_event_signature} + end + + _ -> + {:error, :unknown_peer} + end + + _ -> + :ok + end + end + + defp validate_origin_bound_actors(payload, remote_domain, context) + when is_map(payload) and is_binary(remote_domain) do + event_type = ArblargSDK.canonical_event_type(payload["event_type"]) + data = payload["payload"] || %{} + + validate_origin_bound_actors_in_event_data(event_type, data, remote_domain, context) + end + + defp validate_origin_bound_actors(_payload, _remote_domain, _context), + do: {:error, :invalid_payload} + + defp origin_bound_actors(event_type, data) when is_binary(event_type) and is_map(data) do + case event_type do + "message.create" -> + [get_in(data, ["message", "sender"])] + + "message.update" -> + [get_in(data, ["message", "sender"])] + + "reaction.add" -> + [get_in(data, ["reaction", "actor"])] + + "reaction.remove" -> + [get_in(data, ["reaction", "actor"])] + + "read.cursor" -> + [data["actor"]] + + "membership.upsert" -> + [get_in(data, ["membership", "actor"])] + + "invite.upsert" -> + [get_in(data, ["invite", "actor"])] + + "ban.upsert" -> + [get_in(data, ["ban", "actor"])] + + "role.upsert" -> + [data["actor"]] + + "role.assignment.upsert" -> + [data["actor"]] + + "permission.overwrite.upsert" -> + [data["actor"]] + + "thread.upsert" -> + [get_in(data, ["thread", "owner"])] + + "thread.archive" -> + [data["actor"]] + + "presence.update" -> + [get_in(data, ["presence", "actor"])] + + "typing.start" -> + [data["actor"]] + + "typing.stop" -> + [data["actor"]] + + "moderation.action.recorded" -> + [get_in(data, ["action", "actor"])] + + "dm.message.create" -> + [get_in(data, ["dm", "sender"]), get_in(data, ["message", "sender"])] + + "dm.call.invite" -> + [ + get_in(data, ["dm", "sender"]), + get_in(data, ["dm", "recipient"]), + get_in(data, ["call", "actor"]) + ] + + "dm.call.accept" -> + [get_in(data, ["dm", "sender"]), get_in(data, ["dm", "recipient"]), data["actor"]] + + "dm.call.reject" -> + [get_in(data, ["dm", "sender"]), get_in(data, ["dm", "recipient"]), data["actor"]] + + "dm.call.end" -> + [get_in(data, ["dm", "sender"]), get_in(data, ["dm", "recipient"]), data["actor"]] + + "dm.call.signal" -> + [get_in(data, ["dm", "sender"]), get_in(data, ["dm", "recipient"]), data["actor"]] + + _ -> + [] + end + end + + defp actor_domain_matches_origin?(nil, _remote_domain), do: true + + defp actor_domain_matches_origin?(actor, remote_domain) + when is_map(actor) and is_binary(remote_domain) do + actor_domain = normalize_optional_string(actor["domain"] || actor[:domain]) + actor_uri = normalize_optional_string(actor["uri"] || actor[:uri]) + + case actor_domain do + nil -> + false + + normalized_actor_domain -> + String.downcase(normalized_actor_domain) == String.downcase(remote_domain) and + origin_owned_absolute_uri?(actor_uri, remote_domain) + end + end + + defp actor_domain_matches_origin?(_actor, _remote_domain), do: false + + defp validate_origin_owned_identifiers(payload, remote_domain, context) + when is_map(payload) and is_binary(remote_domain) do + event_type = ArblargSDK.canonical_event_type(payload["event_type"]) + data = payload["payload"] || %{} + + validate_origin_owned_identifiers_in_event_data(event_type, data, remote_domain, context) + end + + defp validate_origin_owned_identifiers(_payload, _remote_domain, _context), + do: {:error, :invalid_payload} + + defp validate_stream_origin(stream_id, remote_domain) + when is_binary(stream_id) and is_binary(remote_domain) do + case String.split(stream_id, ":", parts: 2) do + ["channel", identifier] when identifier != "" -> + if absolute_http_uri?(identifier) do + :ok + else + {:error, :origin_stream_host_mismatch} + end + + [scope, identifier] when scope in ["server", "dm"] and identifier != "" -> + if origin_owned_absolute_uri?(identifier, remote_domain) do + :ok + else + {:error, :origin_stream_host_mismatch} + end + + _ -> + {:error, :invalid_stream_id} + end + end + + defp validate_stream_origin(_stream_id, _remote_domain), do: {:error, :invalid_stream_id} + + defp origin_owned_event_identifiers(event_type, data, context) + when is_binary(event_type) and is_map(data) and is_map(context) do + base_context_ids = + if multi_origin_room_event_type?(event_type) or + room_scoped_presence_event_type?(event_type, data) do + [] + else + [ + call(context, :event_server_id, [data]), + call(context, :event_channel_id, [data]) + ] + |> Enum.filter(&is_binary/1) + end + + event_specific_ids = + case event_type do + "server.upsert" -> + [get_in(data, ["server", "id"]) | Enum.map(data["channels"] || [], &Map.get(&1, "id"))] + + "message.create" -> + [get_in(data, ["message", "id"])] + + "message.update" -> + [get_in(data, ["message", "id"])] + + "message.delete" -> + [data["message_id"]] + + "dm.message.create" -> + [ + get_in(data, ["dm", "id"]), + get_in(data, ["message", "id"]), + get_in(data, ["message", "dm_id"]) + ] + + "dm.call.invite" -> + [ + get_in(data, ["dm", "id"]), + get_in(data, ["call", "id"]), + get_in(data, ["call", "dm_id"]) + ] + + "dm.call.accept" -> + [ + get_in(data, ["dm", "id"]), + data["call_id"] + ] + + "dm.call.reject" -> + [ + get_in(data, ["dm", "id"]), + data["call_id"] + ] + + "dm.call.end" -> + [ + get_in(data, ["dm", "id"]), + data["call_id"] + ] + + "dm.call.signal" -> + [ + get_in(data, ["dm", "id"]), + data["call_id"] + ] + + _ -> + [] + end + + (base_context_ids ++ event_specific_ids) + |> Enum.filter(&is_binary/1) + |> Enum.uniq() + end + + defp origin_owned_event_identifiers(_event_type, _data, _context), do: [] + + defp validate_snapshot_origin_owned_identifiers(payload, remote_domain) + when is_map(payload) and is_binary(remote_domain) do + server_ids = [get_in(payload, ["server", "id"])] + channel_ids = Enum.map(payload["channels"] || [], &Map.get(&1, "id")) + message_channel_ids = Enum.map(payload["messages"] || [], &Map.get(&1, "channel_id")) + + deletion_channel_ids = + Enum.map(payload["message_deletions"] || [], &get_in(&1, ["refs", "channel_id"])) + + identifiers = + (server_ids ++ channel_ids ++ message_channel_ids ++ deletion_channel_ids) + |> Enum.filter(&is_binary/1) + + if Enum.all?(identifiers, &origin_owned_absolute_uri?(&1, remote_domain)) do + :ok + else + {:error, :origin_identifier_host_mismatch} + end + end + + defp validate_snapshot_origin_owned_identifiers(_payload, _remote_domain), + do: {:error, :invalid_payload} + + defp validate_snapshot_message_actors(messages, remote_domain, context) + when is_list(messages) and is_binary(remote_domain) and is_map(context) do + Enum.reduce_while(messages, :ok, fn message, :ok -> + case snapshot_message_origin_valid?(message, remote_domain) do + true -> {:cont, :ok} + false -> {:halt, {:error, :origin_actor_domain_mismatch}} + end + end) + end + + defp validate_snapshot_message_actors(_messages, _remote_domain, _context), + do: {:error, :invalid_payload} + + defp validate_snapshot_governance_shape(governance, remote_domain, context) + when is_map(governance) and is_binary(remote_domain) and is_map(context) do + call(context, :snapshot_governance_entries, [governance]) + |> Enum.reduce_while(:ok, fn {event_type, payload}, :ok -> + case validate_snapshot_governance_payload(event_type, payload, remote_domain, context) do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp validate_snapshot_governance_shape(_governance, _remote_domain, _context), + do: {:error, :invalid_snapshot_governance} + + defp validate_snapshot_reactions(reactions, remote_domain, context) + when is_list(reactions) and is_binary(remote_domain) and is_map(context) do + validate_snapshot_event_list( + reactions, + "reaction.add", + remote_domain, + context, + :invalid_payload + ) + end + + defp validate_snapshot_reactions(_reactions, _remote_domain, _context), + do: {:error, :invalid_payload} + + defp validate_snapshot_read_cursors(read_cursors, remote_domain, context) + when is_list(read_cursors) and is_binary(remote_domain) and is_map(context) do + validate_snapshot_event_list( + read_cursors, + "read.cursor", + remote_domain, + context, + :invalid_payload + ) + end + + defp validate_snapshot_read_cursors(_read_cursors, _remote_domain, _context), + do: {:error, :invalid_payload} + + defp validate_snapshot_message_deletions(message_deletions, remote_domain, context) + when is_list(message_deletions) and is_binary(remote_domain) and is_map(context) do + validate_snapshot_event_list( + message_deletions, + "message.delete", + remote_domain, + context, + :invalid_payload + ) + end + + defp validate_snapshot_message_deletions(_message_deletions, _remote_domain, _context), + do: {:error, :invalid_payload} + + defp validate_snapshot_extensions(extensions, remote_domain, context) + when is_list(extensions) and is_binary(remote_domain) and is_map(context) do + Enum.reduce_while(extensions, :ok, fn extension_entry, :ok -> + case extension_entry do + %{"event_type" => event_type, "payload" => payload} + when is_binary(event_type) and is_map(payload) -> + case validate_snapshot_event_entry(event_type, payload, remote_domain, context) do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + + _ -> + {:halt, {:error, :invalid_payload}} + end + end) + end + + defp validate_snapshot_extensions(_extensions, _remote_domain, _context), + do: {:error, :invalid_payload} + + defp validate_snapshot_stream_positions(stream_positions, remote_domain) + when is_list(stream_positions) and is_binary(remote_domain) do + if Enum.all?(stream_positions, &valid_snapshot_stream_position?(&1, remote_domain)) do + :ok + else + {:error, :invalid_snapshot_stream_positions} + end + end + + defp validate_snapshot_stream_positions(_stream_positions, _remote_domain), + do: {:error, :invalid_snapshot_stream_positions} + + defp validate_snapshot_event_list(entries, event_type, remote_domain, context, error_reason) + when is_list(entries) and is_binary(event_type) and is_binary(remote_domain) and + is_map(context) do + Enum.reduce_while(entries, :ok, fn payload, :ok -> + case validate_snapshot_event_entry(event_type, payload, remote_domain, context) do + :ok -> {:cont, :ok} + {:error, _reason} -> {:halt, {:error, error_reason}} + end + end) + end + + defp validate_snapshot_event_entry(event_type, payload, remote_domain, context) + when is_binary(event_type) and is_map(payload) and is_binary(remote_domain) and + is_map(context) do + with :ok <- ArblargSDK.validate_event_payload(event_type, payload), + :ok <- + validate_snapshot_origin_bound_actors_in_event_data( + event_type, + payload, + remote_domain, + context + ) do + validate_origin_owned_identifiers_in_event_data( + event_type, + payload, + remote_domain, + context + ) + end + end + + defp validate_snapshot_event_entry(_event_type, _payload, _remote_domain, _context), + do: {:error, :invalid_payload} + + defp validate_snapshot_origin_bound_actors_in_event_data( + event_type, + data, + remote_domain, + context + ) + when is_binary(event_type) and is_map(data) and is_binary(remote_domain) and + is_map(context) do + normalized_event_type = normalized_event_name(event_type) + + if multi_origin_room_event_type?(normalized_event_type) do + actors = origin_bound_actors(normalized_event_type, data) + + if Enum.all?(actors, &snapshot_actor_origin_valid?/1) do + :ok + else + {:error, :origin_actor_domain_mismatch} + end + else + validate_origin_bound_actors_in_event_data(event_type, data, remote_domain, context) + end + end + + defp validate_snapshot_origin_bound_actors_in_event_data( + _event_type, + _data, + _remote_domain, + _context + ), + do: {:error, :invalid_payload} + + defp validate_snapshot_signature(payload, remote_domain, context) + when is_map(payload) and is_binary(remote_domain) and is_map(context) do + with %{"algorithm" => algorithm, "key_id" => key_id, "value" => value} <- payload["signature"], + true <- algorithm == ArblargSDK.signature_algorithm(), + true <- Elektrine.Strings.present?(key_id), + true <- Elektrine.Strings.present?(value), + %{} = peer <- call(context, :incoming_peer, [remote_domain]) do + verification_materials = + call(context, :incoming_verification_materials_for_key_id, [peer, key_id]) + + if Enum.any?(verification_materials, fn material -> + ArblargSDK.verify_payload_signature( + call(context, :snapshot_signature_payload, [payload]), + material, + value + ) + end) do + :ok + else + {:error, :invalid_snapshot_signature} + end + else + false -> {:error, :invalid_snapshot_signature} + nil -> {:error, :unknown_peer} + _ -> {:error, :invalid_snapshot_signature} + end + end + + defp validate_snapshot_signature(_payload, _remote_domain, _context), + do: {:error, :invalid_snapshot_signature} + + defp origin_owned_absolute_uri?(value, remote_domain) + when is_binary(value) and is_binary(remote_domain) do + case URI.parse(value) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + host_belongs_to_domain?(host, remote_domain) + + _ -> + false + end + end + + defp origin_owned_absolute_uri?(_value, _remote_domain), do: false + + defp absolute_http_uri?(value) when is_binary(value) do + case URI.parse(value) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + true + + _ -> + false + end + end + + defp absolute_http_uri?(_value), do: false + + defp host_belongs_to_domain?(host, remote_domain) + when is_binary(host) and is_binary(remote_domain) do + normalized_host = String.downcase(host) + normalized_domain = String.downcase(remote_domain) + + normalized_host == normalized_domain or + String.ends_with?(normalized_host, "." <> normalized_domain) + end + + defp host_belongs_to_domain?(_host, _remote_domain), do: false + + defp maybe_require_event_signature(payload) when is_map(payload) do + if is_map(payload["signature"]), do: :ok, else: {:error, :invalid_event_signature} + end + + defp snapshot_governance_present?(payload) when is_map(payload) do + Map.has_key?(payload, "governance") or Map.has_key?(payload, :governance) + end + + defp snapshot_governance_list?(governance, key) when is_map(governance) and is_binary(key) do + Map.has_key?(governance, key) and is_list(governance[key]) + end + + defp snapshot_governance_list?(_governance, _key), do: false + + defp snapshot_stream_positions_present?(payload) when is_map(payload) do + Map.has_key?(payload, "stream_positions") or Map.has_key?(payload, :stream_positions) + end + + defp valid_snapshot_stream_position?(position, remote_domain) + when is_map(position) and is_binary(remote_domain) do + origin_domain = + normalize_optional_string(position["origin_domain"] || position[:origin_domain]) || + remote_domain + + stream_id = position["stream_id"] || position[:stream_id] + last_sequence = position["last_sequence"] || position[:last_sequence] + + case {origin_domain, stream_id, last_sequence} do + {origin_domain, stream_id, last_sequence} + when is_binary(origin_domain) and is_binary(stream_id) and is_integer(last_sequence) and + last_sequence >= 0 -> + case String.split(stream_id, ":", parts: 2) do + ["channel", identifier] when identifier != "" -> + absolute_http_uri?(identifier) + + [scope, identifier] when scope in ["server", "dm"] and identifier != "" -> + origin_owned_absolute_uri?(identifier, origin_domain) + + _ -> + false + end + + _ -> + false + end + end + + defp valid_snapshot_stream_position?(_position, _remote_domain), do: false + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_value), do: nil + + defp snapshot_message_origin_valid?(message, snapshot_origin_domain) + when is_map(message) and is_binary(snapshot_origin_domain) do + message_id = normalize_optional_string(message["id"]) + + cond do + !is_binary(message_id) -> + false + + is_map(message["sender"]) -> + sender_domain = actor_origin_domain(message["sender"]) + + is_binary(sender_domain) and + actor_domain_matches_origin?(message["sender"], sender_domain) and + origin_owned_absolute_uri?(message_id, sender_domain) + + true -> + origin_owned_absolute_uri?(message_id, snapshot_origin_domain) + end + end + + defp snapshot_message_origin_valid?(_message, _snapshot_origin_domain), do: false + + defp snapshot_actor_origin_valid?(nil), do: true + + defp snapshot_actor_origin_valid?(actor) when is_map(actor) do + case actor_origin_domain(actor) do + origin_domain when is_binary(origin_domain) -> + actor_domain_matches_origin?(actor, origin_domain) + + _ -> + false + end + end + + defp snapshot_actor_origin_valid?(_actor), do: false + + defp actor_origin_domain(actor) when is_map(actor) do + normalize_optional_string(actor["domain"] || actor[:domain]) || + actor + |> actor_uri() + |> actor_uri_host() + end + + defp actor_origin_domain(_actor), do: nil + + defp actor_uri(actor) when is_map(actor) do + normalize_optional_string(actor["uri"] || actor[:uri] || actor["id"] || actor[:id]) + end + + defp actor_uri_host(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{host: host} when is_binary(host) and host != "" -> String.downcase(host) + _ -> nil + end + end + + defp actor_uri_host(_uri), do: nil + + defp room_participation_event_type?(event_type) + when event_type in [ + "message.create", + "message.update", + "message.delete", + "reaction.add", + "reaction.remove", + "read.cursor", + "membership.upsert", + "typing.start", + "typing.stop" + ], + do: true + + defp room_participation_event_type?(_event_type), do: false + + defp shared_room_governance_event_type?(event_type) + when event_type in [ + "invite.upsert", + "ban.upsert", + "role.upsert", + "role.assignment.upsert", + "permission.overwrite.upsert", + "thread.upsert", + "thread.archive", + "moderation.action.recorded" + ], + do: true + + defp shared_room_governance_event_type?(_event_type), do: false + + defp multi_origin_room_event_type?(event_type) do + normalized_event_type = normalized_event_name(event_type) + + room_participation_event_type?(normalized_event_type) or + shared_room_governance_event_type?(normalized_event_type) + end + + defp room_scoped_presence_event_type?(event_type, data) + when is_binary(event_type) and is_map(data) do + normalized_event_name(event_type) == "presence.update" and + (is_binary(get_in(data, ["refs", "channel_id"])) or + is_binary(get_in(data, ["channel", "id"]))) + end + + defp room_scoped_presence_event_type?(_event_type, _data), do: false + + defp normalized_event_name(event_type) when is_binary(event_type) do + canonical_event_type = ArblargSDK.canonical_event_type(event_type) + Map.get(ArblargSDK.schema_bindings(), canonical_event_type, canonical_event_type) + end + + defp normalized_event_name(event_type), do: event_type + + defp call(context, key, args) do + context + |> Map.fetch!(key) + |> Kernel.apply(args) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/visibility.ex b/apps/elektrine/lib/elektrine/messaging/federation/visibility.ex new file mode 100644 index 0000000..ea21db9 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/visibility.ex @@ -0,0 +1,408 @@ +defmodule Elektrine.Messaging.Federation.Visibility do + @moduledoc false + + import Ecto.Query, warn: false + + alias Elektrine.Messaging.{ + ChatConversation, + FederationInviteState, + FederationMembershipState, + Server + } + + alias Elektrine.ActivityPub.Actor + alias Elektrine.Repo + + @snapshot_invite_states ["pending", "accepted"] + @active_membership_state "active" + @room_replay_event_types [ + "message.create", + "message.update", + "message.delete", + "reaction.add", + "reaction.remove", + "read.cursor", + "membership.upsert", + "role.upsert", + "role.assignment.upsert", + "permission.overwrite.upsert", + "thread.upsert", + "thread.archive", + "moderation.action.recorded" + ] + + def public_bootstrap_channels(%Server{} = server) do + from(c in ChatConversation, + where: + c.server_id == ^server.id and c.type == "channel" and c.is_federated_mirror != true and + c.is_public == true, + order_by: [asc: c.channel_position, asc: c.inserted_at] + ) + |> Repo.all() + end + + def public_bootstrap_channels(_server), do: [] + + def visible_channels_for_peer(%Server{} = server, peer_or_domain) do + peer_domain = normalize_peer_domain(peer_or_domain) + + channels = + from(c in ChatConversation, + where: + c.server_id == ^server.id and c.type == "channel" and c.is_federated_mirror != true, + order_by: [asc: c.channel_position, asc: c.inserted_at] + ) + |> Repo.all() + + case peer_domain do + nil -> + channels + + _ -> + visible_channel_ids = + snapshot_visible_channel_ids(Enum.map(channels, & &1.id), peer_domain) + |> MapSet.new() + + Enum.filter(channels, fn channel -> + public_channel?(server, channel) or MapSet.member?(visible_channel_ids, channel.id) + end) + end + end + + def visible_channels_for_peer(_server, _peer_or_domain), do: [] + + def target_domains_for_room(%ChatConversation{} = conversation) do + conversation = maybe_preload_server(conversation) + + case conversation.server do + %Server{is_federated_mirror: true, origin_domain: origin_domain} + when is_binary(origin_domain) -> + (active_membership_origin_domains(conversation.id) ++ [normalize_domain(origin_domain)]) + |> Enum.reject(&(is_nil(&1) or &1 == local_domain())) + |> Enum.uniq() + + %Server{} -> + active_membership_origin_domains(conversation.id) + |> Enum.reject(&(is_nil(&1) or &1 == local_domain())) + |> Enum.uniq() + + _ -> + [] + end + end + + def target_domains_for_room(_conversation), do: [] + + def target_domains_for_invite(%ChatConversation{} = conversation, target_payload) do + (target_domains_for_room(conversation) ++ List.wrap(target_actor_domain(target_payload))) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + def target_domains_for_invite(_conversation, _target_payload), do: [] + + def target_domains_for_event(%{"event_type" => event_type, "payload" => payload}) + when is_binary(event_type) and is_map(payload) do + case normalized_event_name(event_type) do + event_type + when event_type in [ + "message.create", + "message.update", + "message.delete", + "reaction.add", + "reaction.remove", + "read.cursor", + "membership.upsert", + "role.upsert", + "role.assignment.upsert", + "permission.overwrite.upsert", + "thread.upsert", + "thread.archive", + "moderation.action.recorded", + "typing.start", + "typing.stop" + ] -> + case conversation_for_event_payload(payload) do + %ChatConversation{} = conversation -> target_domains_for_room(conversation) + _ -> nil + end + + "invite.upsert" -> + with %ChatConversation{} = conversation <- conversation_for_event_payload(payload), + %{} = invite_payload <- payload["invite"] do + target_domains_for_invite(conversation, invite_payload["target"]) + else + _ -> nil + end + + "ban.upsert" -> + with %ChatConversation{} = conversation <- conversation_for_event_payload(payload), + %{} = ban_payload <- payload["ban"] do + target_domains_for_invite(conversation, ban_payload["target"]) + else + _ -> nil + end + + _ -> + nil + end + end + + def target_domains_for_event(_event), do: nil + + def visible_stream_events_query(stream_id, peer_or_domain) when is_binary(stream_id) do + peer_domain = normalize_peer_domain(peer_or_domain) + + case peer_domain do + nil -> + nil + + domain -> + replay_visibility_filter(stream_id, domain) + end + end + + def visible_stream_events_query(_stream_id, _peer_or_domain), do: nil + + def conversation_for_event_payload(payload) when is_map(payload) do + case event_channel_id(payload) do + channel_id when is_binary(channel_id) -> conversation_for_channel_id(channel_id) + _ -> nil + end + end + + def conversation_for_event_payload(_payload), do: nil + + def conversation_for_channel_id(channel_id) when is_binary(channel_id) do + case Repo.get_by(ChatConversation, type: "channel", federated_source: channel_id) do + %ChatConversation{} = conversation -> + maybe_preload_server(conversation) + + nil -> + case local_channel_id_from_federation_id(channel_id) do + conversation_id when is_integer(conversation_id) -> + case Repo.get(ChatConversation, conversation_id) do + %ChatConversation{type: "channel"} = conversation -> + maybe_preload_server(conversation) + + _ -> + nil + end + + _ -> + nil + end + end + end + + def conversation_for_channel_id(_channel_id), do: nil + + def public_channel?(%Server{} = server, %ChatConversation{} = channel) do + server.is_public == true and channel.is_public == true + end + + def public_channel?(_server, _channel), do: false + + defp replay_visibility_filter(stream_id, peer_domain) + when is_binary(stream_id) and is_binary(peer_domain) do + row_targeted = dynamic([o], fragment("? = ANY(?)", ^peer_domain, o.target_domains)) + + case replay_room_visible?(stream_id, peer_domain) do + true -> + dynamic( + [o], + ^row_targeted or o.event_type in ^room_replay_event_types() + ) + + false -> + row_targeted + end + end + + defp replay_visibility_filter(_stream_id, _peer_domain), do: nil + + defp replay_room_visible?(stream_id, peer_domain) + when is_binary(stream_id) and is_binary(peer_domain) do + with "channel:" <> channel_id <- stream_id, + %ChatConversation{} = conversation <- conversation_for_channel_id(channel_id), + %Server{} = server <- conversation.server do + public_channel?(server, conversation) or + conversation.id in snapshot_visible_channel_ids([conversation.id], peer_domain) + else + _ -> false + end + end + + defp replay_room_visible?(_stream_id, _peer_domain), do: false + + defp active_membership_origin_domains(conversation_id) when is_integer(conversation_id) do + from(state in FederationMembershipState, + where: + state.conversation_id == ^conversation_id and state.state == ^@active_membership_state, + select: state.origin_domain, + distinct: true + ) + |> Repo.all() + |> Enum.map(&normalize_domain/1) + |> Enum.reject(&is_nil/1) + end + + defp active_membership_origin_domains(_conversation_id), do: [] + + defp snapshot_visible_channel_ids([], _peer_domain), do: [] + + defp snapshot_visible_channel_ids(channel_ids, peer_domain) when is_binary(peer_domain) do + banned_targets = + banned_target_uris(channel_ids, peer_domain) + |> MapSet.new() + + membership_ids = + from(state in FederationMembershipState, + where: + state.conversation_id in ^channel_ids and state.state == ^@active_membership_state and + state.origin_domain == ^peer_domain, + select: state.conversation_id, + distinct: true + ) + |> Repo.all() + + invite_ids = + from(invite in FederationInviteState, + where: + invite.conversation_id in ^channel_ids and invite.state in ^@snapshot_invite_states, + select: {invite.conversation_id, invite.target_payload, invite.target_uri} + ) + |> Repo.all() + |> Enum.reduce([], fn {conversation_id, target_payload, target_uri}, acc -> + normalized_target_uri = normalize_optional_string(target_uri) + + if target_domain_matches?(target_payload, target_uri, peer_domain) and + not MapSet.member?(banned_targets, {conversation_id, normalized_target_uri}) do + [conversation_id | acc] + else + acc + end + end) + + membership_ids ++ invite_ids + end + + defp snapshot_visible_channel_ids(_channel_ids, _peer_domain), do: [] + + defp banned_target_uris(channel_ids, peer_domain) + when is_list(channel_ids) and is_binary(peer_domain) do + from(state in FederationMembershipState, + join: actor in Actor, + on: actor.id == state.remote_actor_id, + where: state.conversation_id in ^channel_ids and state.state == "banned", + where: actor.domain == ^peer_domain, + select: {state.conversation_id, actor.uri}, + distinct: true + ) + |> Repo.all() + |> Enum.map(fn {conversation_id, actor_uri} -> + {conversation_id, normalize_optional_string(actor_uri)} + end) + end + + defp banned_target_uris(_channel_ids, _peer_domain), do: [] + + defp target_actor_domain(%{} = target_payload) do + normalize_peer_domain( + target_payload["domain"] || target_payload[:domain] || target_payload["uri"] || + target_payload[:uri] || target_payload["id"] || target_payload[:id] + ) + end + + defp target_actor_domain(value) when is_binary(value), do: normalize_peer_domain(value) + defp target_actor_domain(_value), do: nil + + defp target_domain_matches?(target_payload, target_uri, peer_domain) + when is_map(target_payload) and is_binary(peer_domain) do + target_domain = + target_actor_domain(target_payload) || + normalize_peer_domain(target_uri) + + target_domain == peer_domain + end + + defp target_domain_matches?(_target_payload, _target_uri, _peer_domain), do: false + + defp event_channel_id(payload) when is_map(payload) do + refs = payload["refs"] || %{} + get_in(payload, ["channel", "id"]) || refs["channel_id"] + end + + defp local_channel_id_from_federation_id(channel_id) when is_binary(channel_id) do + with %URI{host: host, path: path} <- URI.parse(channel_id), + true <- normalize_domain(host) == local_domain(), + ["", "_arblarg", "channels", local_id] <- String.split(path || "", "/"), + {parsed_id, ""} <- Integer.parse(local_id) do + parsed_id + else + _ -> nil + end + end + + defp maybe_preload_server(%ChatConversation{server: %Server{}} = conversation), do: conversation + + defp maybe_preload_server(%ChatConversation{} = conversation), + do: Repo.preload(conversation, :server) + + defp canonical_event_type(event_type), + do: Elektrine.Messaging.ArblargSDK.canonical_event_type(event_type) + + defp normalized_event_name(event_type) when is_binary(event_type) do + canonical = canonical_event_type(event_type) + Map.get(Elektrine.Messaging.ArblargSDK.schema_bindings(), canonical, canonical) + end + + defp room_replay_event_types do + canonical_room_replay_event_types = + Enum.map(@room_replay_event_types, &canonical_event_type/1) + + @room_replay_event_types ++ canonical_room_replay_event_types + end + + defp local_domain do + Elektrine.Messaging.Federation.local_domain() + |> normalize_domain() + end + + defp normalize_peer_domain(%{} = peer) do + normalize_peer_domain(peer[:domain] || peer["domain"]) + end + + defp normalize_peer_domain(value) when is_binary(value) do + value + |> String.trim() + |> case do + "" -> + nil + + trimmed -> + case URI.parse(trimmed) do + %URI{host: host} when is_binary(host) and host != "" -> normalize_domain(host) + _ -> normalize_domain(trimmed) + end + end + end + + defp normalize_peer_domain(_value), do: nil + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_value), do: nil + + defp normalize_domain(value) when is_binary(value) do + value + |> String.trim() + |> case do + "" -> nil + trimmed -> String.downcase(trimmed) + end + end + + defp normalize_domain(_value), do: nil +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation/voice_calls.ex b/apps/elektrine/lib/elektrine/messaging/federation/voice_calls.ex new file mode 100644 index 0000000..9a00cb2 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation/voice_calls.ex @@ -0,0 +1,583 @@ +defmodule Elektrine.Messaging.Federation.VoiceCalls do + @moduledoc false + + import Ecto.Query, warn: false + + alias Elektrine.Accounts + alias Elektrine.Accounts.User + alias Elektrine.Calls + alias Elektrine.Messaging.{ChatConversation, ChatConversationMember, FederationCallSession} + alias Elektrine.Messaging.Federation + alias Elektrine.Messaging.Federation.DirectMessageState + alias Elektrine.Messaging.Federation.Utils + alias Elektrine.Profiles + alias Elektrine.PubSubTopics + alias Elektrine.Repo + + @active_statuses ~w(initiated ringing active) + @valid_call_types ~w(audio video) + + def start_outbound_session(local_user_id, conversation_id, call_type) + when is_integer(local_user_id) and is_integer(conversation_id) and is_binary(call_type) do + with true <- call_type in @valid_call_types or {:error, :invalid_call_type}, + %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id), + true <- conversation.type == "dm" or {:error, :unsupported_conversation_type}, + remote_handle when is_binary(remote_handle) <- + DirectMessageState.remote_dm_handle_from_source(conversation.federated_source), + true <- dm_membership_active?(conversation.id, local_user_id) or {:error, :not_member}, + %User{} = local_user <- Repo.get(User, local_user_id), + {:ok, remote_recipient} <- DirectMessageState.normalize_remote_dm_handle(remote_handle), + true <- + is_nil(local_user_busy_reason(local_user_id)) or {:error, :local_call_already_active}, + false <- active_session_exists?(local_user_id, conversation_id) do + origin_domain = Utils.preferred_dm_origin_domain_for_user(local_user) + + attrs = %{ + conversation_id: conversation.id, + local_user_id: local_user.id, + federated_call_id: federated_call_id(origin_domain), + origin_domain: origin_domain, + remote_domain: remote_recipient.domain, + remote_handle: remote_recipient.handle, + remote_actor: DirectMessageState.dm_actor_payload(remote_recipient), + call_type: call_type, + direction: "outbound", + status: "initiated", + metadata: %{} + } + + %FederationCallSession{} + |> FederationCallSession.changeset(attrs) + |> Repo.insert() + |> preload_session() + else + {:error, _reason} = error -> error + nil -> {:error, :not_found} + false -> {:error, :remote_call_already_active} + true -> {:error, :remote_call_already_active} + _ -> {:error, :invalid_remote_call} + end + end + + def get_session(session_id) when is_integer(session_id) do + FederationCallSession + |> Repo.get(session_id) + |> maybe_preload_session() + end + + def get_session_for_local_user(session_id, local_user_id) + when is_integer(session_id) and is_integer(local_user_id) do + from(s in FederationCallSession, + where: s.id == ^session_id and s.local_user_id == ^local_user_id + ) + |> Repo.one() + |> maybe_preload_session() + end + + def get_active_session_for_local_user(local_user_id) when is_integer(local_user_id) do + from(s in FederationCallSession, + where: s.local_user_id == ^local_user_id and s.status in ^@active_statuses, + order_by: [desc: s.updated_at], + limit: 1 + ) + |> Repo.one() + |> maybe_preload_session() + end + + def local_user_busy_reason(local_user_id) when is_integer(local_user_id) do + cond do + match?(%{}, Calls.get_active_call(local_user_id)) -> + :local_call_active + + match?(%{}, get_active_session_for_local_user(local_user_id)) -> + :federated_call_active + + true -> + nil + end + end + + def accept_session(session_id, local_user_id) + when is_integer(session_id) and is_integer(local_user_id) do + with %FederationCallSession{} = session <- + get_session_for_local_user(session_id, local_user_id), + true <- session.status in ["initiated", "ringing"] do + update_session(session, %{ + status: "active", + started_at_remote: DateTime.utc_now() |> DateTime.truncate(:second) + }) + else + nil -> {:error, :not_found} + false -> {:error, :invalid_transition} + end + end + + def reject_session(session_id, local_user_id, reason \\ nil) + when is_integer(session_id) and is_integer(local_user_id) do + with %FederationCallSession{} = session <- + get_session_for_local_user(session_id, local_user_id), + true <- session.status in ["initiated", "ringing"] do + update_session(session, %{ + status: "rejected", + ended_at_remote: DateTime.utc_now() |> DateTime.truncate(:second), + metadata: put_optional_reason(session.metadata || %{}, reason) + }) + else + nil -> {:error, :not_found} + false -> {:error, :invalid_transition} + end + end + + def end_session(session_id, local_user_id, reason \\ "ended") + when is_integer(session_id) and is_integer(local_user_id) do + with %FederationCallSession{} = session <- + get_session_for_local_user(session_id, local_user_id), + true <- session.status in @active_statuses do + update_session(session, %{ + status: "ended", + ended_at_remote: DateTime.utc_now() |> DateTime.truncate(:second), + metadata: put_optional_reason(session.metadata || %{}, reason) + }) + else + nil -> {:error, :not_found} + false -> {:error, :invalid_transition} + end + end + + def fail_session(session_id, local_user_id, reason \\ "failed") + when is_integer(session_id) and is_integer(local_user_id) do + with %FederationCallSession{} = session <- + get_session_for_local_user(session_id, local_user_id), + true <- session.status in @active_statuses do + update_session(session, %{ + status: "failed", + ended_at_remote: DateTime.utc_now() |> DateTime.truncate(:second), + metadata: put_optional_reason(session.metadata || %{}, reason) + }) + else + nil -> {:error, :not_found} + false -> {:error, :invalid_transition} + end + end + + def ensure_inbound_session(local_user, conversation, remote_sender, call_payload, remote_domain) + when is_map(remote_sender) and is_map(call_payload) and is_binary(remote_domain) do + federated_call_id = normalize_optional_string(call_payload["id"]) + call_type = normalize_optional_string(call_payload["call_type"]) + initiated_at = parse_datetime(call_payload["initiated_at"]) + metadata = Map.get(call_payload, "metadata") || %{} + + with %User{} = local_user <- local_user, + %ChatConversation{} = conversation <- conversation, + true <- is_binary(federated_call_id), + true <- call_type in @valid_call_types do + existing_session = + Repo.get_by(FederationCallSession, + local_user_id: local_user.id, + federated_call_id: federated_call_id + ) + + if is_nil(existing_session) and local_user_busy_reason(local_user.id) do + {:error, :busy} + else + attrs = %{ + conversation_id: conversation.id, + local_user_id: local_user.id, + federated_call_id: federated_call_id, + origin_domain: String.downcase(remote_domain), + remote_domain: String.downcase(remote_domain), + remote_handle: Map.get(remote_sender, :handle) || Map.get(remote_sender, "handle"), + remote_actor: DirectMessageState.dm_actor_payload(remote_sender), + call_type: call_type, + direction: "inbound", + status: "ringing", + started_at_remote: initiated_at, + metadata: metadata + } + + session = + case existing_session do + %FederationCallSession{} = existing -> + {:ok, updated} = + existing + |> FederationCallSession.changeset(attrs) + |> Repo.update() + + updated + + nil -> + {:ok, inserted} = + %FederationCallSession{} + |> FederationCallSession.changeset(attrs) + |> Repo.insert() + + inserted + end + |> maybe_preload_session() + + broadcast_incoming_call(session) + {:ok, session} + end + else + {:error, :busy} -> + {:error, :busy} + + _ -> + {:error, :invalid_event_payload} + end + end + + def reject_inbound_invite( + local_user, + conversation, + remote_sender, + call_payload, + remote_domain, + reason + ) + when is_map(remote_sender) and is_map(call_payload) and is_binary(remote_domain) do + with %User{} = local_user <- local_user, + %ChatConversation{} = conversation <- conversation, + call_id when is_binary(call_id) <- normalize_optional_string(call_payload["id"]), + call_type when call_type in @valid_call_types <- + normalize_optional_string(call_payload["call_type"]) do + session = + case Repo.get_by(FederationCallSession, + local_user_id: local_user.id, + federated_call_id: call_id + ) do + %FederationCallSession{} = existing -> + {:ok, updated} = + existing + |> FederationCallSession.changeset(%{ + status: "rejected", + ended_at_remote: DateTime.utc_now() |> DateTime.truncate(:second), + metadata: put_optional_reason(existing.metadata || %{}, reason) + }) + |> Repo.update() + + updated + + nil -> + {:ok, inserted} = + %FederationCallSession{} + |> FederationCallSession.changeset(%{ + conversation_id: conversation.id, + local_user_id: local_user.id, + federated_call_id: call_id, + origin_domain: String.downcase(remote_domain), + remote_domain: String.downcase(remote_domain), + remote_handle: + Map.get(remote_sender, :handle) || Map.get(remote_sender, "handle"), + remote_actor: DirectMessageState.dm_actor_payload(remote_sender), + call_type: call_type, + direction: "inbound", + status: "rejected", + metadata: put_optional_reason(%{}, reason), + ended_at_remote: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert() + + inserted + end + |> maybe_preload_session() + + :ok = Federation.publish_dm_call_reject(session.id) + {:ok, session} + else + _ -> {:error, :invalid_event_payload} + end + end + + def apply_remote_accept(dm_payload, call_id, _actor_payload, accepted_at, remote_domain) + when is_map(dm_payload) and is_binary(call_id) and is_binary(remote_domain) do + with {:ok, local_user} <- resolve_local_dm_participant(dm_payload), + %FederationCallSession{} = session <- + Repo.get_by(FederationCallSession, + local_user_id: local_user.id, + federated_call_id: call_id + ), + true <- session.remote_domain == String.downcase(remote_domain), + {:ok, _updated} <- + update_session(session, %{ + status: "active", + started_at_remote: parse_datetime(accepted_at) || DateTime.utc_now() + }) do + broadcast_peer_ready(session) + :ok + else + nil -> {:error, :not_found} + false -> {:error, :origin_domain_mismatch} + {:error, _reason} = error -> error + _ -> {:error, :invalid_event_payload} + end + end + + def apply_remote_reject(dm_payload, call_id, _actor_payload, rejected_at, reason, remote_domain) + when is_map(dm_payload) and is_binary(call_id) and is_binary(remote_domain) do + with {:ok, local_user} <- resolve_local_dm_participant(dm_payload), + %FederationCallSession{} = session <- + Repo.get_by(FederationCallSession, + local_user_id: local_user.id, + federated_call_id: call_id + ), + true <- session.remote_domain == String.downcase(remote_domain), + {:ok, updated} <- + update_session(session, %{ + status: "rejected", + ended_at_remote: parse_datetime(rejected_at) || DateTime.utc_now(), + metadata: put_optional_reason(session.metadata || %{}, reason) + }) do + broadcast_terminal_event(updated, :call_rejected) + :ok + else + nil -> {:error, :not_found} + false -> {:error, :origin_domain_mismatch} + {:error, _reason} = error -> error + _ -> {:error, :invalid_event_payload} + end + end + + def apply_remote_end(dm_payload, call_id, _actor_payload, ended_at, reason, remote_domain) + when is_map(dm_payload) and is_binary(call_id) and is_binary(remote_domain) do + with {:ok, local_user} <- resolve_local_dm_participant(dm_payload), + %FederationCallSession{} = session <- + Repo.get_by(FederationCallSession, + local_user_id: local_user.id, + federated_call_id: call_id + ), + true <- session.remote_domain == String.downcase(remote_domain), + {:ok, updated} <- + update_session(session, %{ + status: "ended", + ended_at_remote: parse_datetime(ended_at) || DateTime.utc_now(), + metadata: put_optional_reason(session.metadata || %{}, reason) + }) do + broadcast_terminal_event(updated, :call_ended) + :ok + else + nil -> {:error, :not_found} + false -> {:error, :origin_domain_mismatch} + {:error, _reason} = error -> error + _ -> {:error, :invalid_event_payload} + end + end + + def apply_remote_signal(dm_payload, call_id, actor_payload, signal, remote_domain) + when is_map(dm_payload) and is_binary(call_id) and is_map(signal) and + is_binary(remote_domain) do + with {:ok, local_user} <- resolve_local_dm_participant(dm_payload), + %FederationCallSession{} = session <- + Repo.get_by(FederationCallSession, + local_user_id: local_user.id, + federated_call_id: call_id + ), + true <- session.remote_domain == String.downcase(remote_domain), + signal_kind when is_binary(signal_kind) <- signal["kind"] do + PubSubTopics.broadcast( + PubSubTopics.call(session.id), + :federated_call_signal, + %{ + kind: signal_kind, + payload: signal["payload"] || %{}, + actor: actor_to_ui_user(actor_payload) + } + ) + + :ok + else + nil -> {:error, :not_found} + false -> {:error, :origin_domain_mismatch} + _ -> {:error, :invalid_event_payload} + end + end + + def mark_session_ringing(session_id, local_user_id) + when is_integer(session_id) and is_integer(local_user_id) do + with %FederationCallSession{} = session <- + get_session_for_local_user(session_id, local_user_id), + true <- session.status == "initiated" do + update_session(session, %{status: "ringing"}) + else + nil -> {:error, :not_found} + false -> {:error, :invalid_transition} + end + end + + def ui_call(%FederationCallSession{} = session) do + session = maybe_preload_session(session) + local_user = session.local_user + remote_user = actor_to_ui_user(session.remote_actor) + + caller_is_local? = local_origin_domain?(session.origin_domain, local_user) + + if caller_is_local? do + %{ + id: session.id, + source: :federated, + federated_call_id: session.federated_call_id, + conversation_id: session.conversation_id, + call_type: session.call_type, + status: session.status, + caller_id: session.local_user_id, + callee_id: nil, + caller: local_user, + callee: remote_user + } + else + %{ + id: session.id, + source: :federated, + federated_call_id: session.federated_call_id, + conversation_id: session.conversation_id, + call_type: session.call_type, + status: session.status, + caller_id: nil, + callee_id: session.local_user_id, + caller: remote_user, + callee: local_user + } + end + end + + defp broadcast_incoming_call(%FederationCallSession{} = session) do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{session.local_user_id}", + {:incoming_call, ui_call(session)} + ) + end + + defp broadcast_terminal_event(%FederationCallSession{} = session, event) do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{session.local_user_id}", + {event, ui_call(session)} + ) + end + + defp broadcast_peer_ready(%FederationCallSession{} = session) do + PubSubTopics.broadcast(PubSubTopics.call(session.id), :federated_peer_ready, %{ + session_id: session.id + }) + end + + defp update_session(%FederationCallSession{} = session, attrs) do + session + |> FederationCallSession.changeset(attrs) + |> Repo.update() + |> preload_session() + end + + defp preload_session({:ok, %FederationCallSession{} = session}) do + {:ok, maybe_preload_session(session)} + end + + defp preload_session(other), do: other + + defp maybe_preload_session(%FederationCallSession{} = session) do + Repo.preload(session, [:conversation, :local_user]) + end + + defp maybe_preload_session(other), do: other + + def resolve_local_dm_participant(%{"sender" => sender, "recipient" => recipient}) do + case local_actor_payload(sender) || local_actor_payload(recipient) do + %User{} = user -> {:ok, user} + _ -> {:error, :user_not_found} + end + end + + def resolve_local_dm_participant(_payload), do: {:error, :invalid_event_payload} + + defp local_actor_payload(actor) when is_map(actor) do + actor_domain = normalize_optional_string(actor["domain"] || actor[:domain]) + username = normalize_optional_string(actor["username"] || actor[:username]) + normalized_actor_domain = if is_binary(actor_domain), do: String.downcase(actor_domain) + + cond do + normalized_actor_domain == String.downcase(Federation.local_domain()) and + is_binary(username) -> + Accounts.get_user_by_username(username) + + is_binary(normalized_actor_domain) and is_binary(username) -> + case Profiles.get_verified_custom_domain(normalized_actor_domain) do + %{user: %{username: ^username} = user} -> user + _ -> nil + end + + true -> + nil + end + end + + defp local_actor_payload(_actor), do: nil + + defp actor_to_ui_user(actor) when is_map(actor) do + %{ + username: + Map.get(actor, "display_name") || Map.get(actor, :display_name) || + Map.get(actor, "username") || Map.get(actor, :username) || "remote", + display_name: + Map.get(actor, "display_name") || Map.get(actor, :display_name) || + Map.get(actor, "username") || Map.get(actor, :username) || "remote", + avatar_url: Map.get(actor, "avatar_url") || Map.get(actor, :avatar_url), + handle: Map.get(actor, "handle") || Map.get(actor, :handle), + remote_handle: Map.get(actor, "handle") || Map.get(actor, :handle), + domain: Map.get(actor, "domain") || Map.get(actor, :domain), + origin_domain: Map.get(actor, "domain") || Map.get(actor, :domain) + } + end + + defp actor_to_ui_user(_actor), do: %{username: "remote", display_name: "remote"} + + defp dm_membership_active?(conversation_id, user_id) do + from(cm in ChatConversationMember, + where: + cm.conversation_id == ^conversation_id and cm.user_id == ^user_id and is_nil(cm.left_at), + select: count(cm.id) + ) + |> Repo.one() > 0 + end + + defp active_session_exists?(local_user_id, conversation_id) do + from(s in FederationCallSession, + where: + s.local_user_id == ^local_user_id and s.conversation_id == ^conversation_id and + s.status in ^@active_statuses, + select: count(s.id) + ) + |> Repo.one() > 0 + end + + defp federated_call_id(local_domain) when is_binary(local_domain) do + "https://#{local_domain}/_arblarg/calls/#{Ecto.UUID.generate()}" + end + + defp local_origin_domain?(origin_domain, %User{} = local_user) when is_binary(origin_domain) do + normalized_origin = String.downcase(origin_domain) + + normalized_origin == String.downcase(Federation.local_domain()) or + normalized_origin == String.downcase(Utils.preferred_dm_origin_domain_for_user(local_user)) + end + + defp local_origin_domain?(_, _), do: false + + defp parse_datetime(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, datetime, _offset} -> datetime + _ -> nil + end + end + + defp parse_datetime(_value), do: nil + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_value), do: nil + + defp put_optional_reason(metadata, nil), do: metadata + defp put_optional_reason(metadata, ""), do: metadata + defp put_optional_reason(metadata, reason), do: Map.put(metadata, "reason", reason) +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_account_presence_state.ex b/apps/elektrine/lib/elektrine/messaging/federation_account_presence_state.ex new file mode 100644 index 0000000..069fad7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_account_presence_state.ex @@ -0,0 +1,44 @@ +defmodule Elektrine.Messaging.FederationAccountPresenceState do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @valid_statuses ~w(online idle dnd offline invisible) + + schema "messaging_federation_account_presence_states" do + field :origin_domain, :string + field :status, :string + field :activities, :map, default: %{} + field :updated_at_remote, :utc_datetime + field :expires_at_remote, :utc_datetime + + belongs_to :remote_actor, Elektrine.ActivityPub.Actor + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(state, attrs) do + state + |> cast(attrs, [ + :origin_domain, + :status, + :activities, + :updated_at_remote, + :expires_at_remote, + :remote_actor_id + ]) + |> validate_required([ + :origin_domain, + :status, + :updated_at_remote, + :remote_actor_id + ]) + |> validate_length(:origin_domain, max: 255) + |> validate_inclusion(:status, @valid_statuses) + |> foreign_key_constraint(:remote_actor_id) + |> unique_constraint([:remote_actor_id], + name: :messaging_federation_account_presence_states_unique + ) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_call_session.ex b/apps/elektrine/lib/elektrine/messaging/federation_call_session.ex new file mode 100644 index 0000000..b635127 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_call_session.ex @@ -0,0 +1,71 @@ +defmodule Elektrine.Messaging.FederationCallSession do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @valid_call_types ~w(audio video) + @valid_directions ~w(inbound outbound) + @valid_statuses ~w(initiated ringing active ended rejected missed failed) + + schema "messaging_federation_call_sessions" do + field :federated_call_id, :string + field :origin_domain, :string + field :remote_domain, :string + field :remote_handle, :string + field :remote_actor, :map, default: %{} + field :call_type, :string + field :direction, :string + field :status, :string + field :metadata, :map, default: %{} + field :started_at_remote, :utc_datetime + field :ended_at_remote, :utc_datetime + + belongs_to :conversation, Elektrine.Messaging.ChatConversation + belongs_to :local_user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(session, attrs) do + session + |> cast(attrs, [ + :conversation_id, + :local_user_id, + :federated_call_id, + :origin_domain, + :remote_domain, + :remote_handle, + :remote_actor, + :call_type, + :direction, + :status, + :metadata, + :started_at_remote, + :ended_at_remote + ]) + |> validate_required([ + :conversation_id, + :local_user_id, + :federated_call_id, + :origin_domain, + :remote_domain, + :remote_handle, + :call_type, + :direction, + :status + ]) + |> validate_length(:federated_call_id, max: 500) + |> validate_length(:origin_domain, max: 255) + |> validate_length(:remote_domain, max: 255) + |> validate_length(:remote_handle, max: 255) + |> validate_inclusion(:call_type, @valid_call_types) + |> validate_inclusion(:direction, @valid_directions) + |> validate_inclusion(:status, @valid_statuses) + |> foreign_key_constraint(:conversation_id) + |> foreign_key_constraint(:local_user_id) + |> unique_constraint([:local_user_id, :federated_call_id], + name: :messaging_federation_call_sessions_user_call_unique + ) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_discovered_peer.ex b/apps/elektrine/lib/elektrine/messaging/federation_discovered_peer.ex new file mode 100644 index 0000000..ce971f6 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_discovered_peer.ex @@ -0,0 +1,82 @@ +defmodule Elektrine.Messaging.FederationDiscoveredPeer do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "messaging_federation_discovered_peers" do + field :domain, :string + field :base_url, :string + field :discovery_url, :string + field :protocol, :string + field :protocol_id, :string + field :protocol_version, :string + field :trust_state, :string, default: "trusted" + field :identity_fingerprint, :string + field :previous_identity_fingerprint, :string + field :last_key_change_at, :utc_datetime + field :identity, :map, default: %{} + field :endpoints, :map, default: %{} + field :features, :map, default: %{} + field :last_discovered_at, :utc_datetime + field :last_error, :string + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(peer, attrs) do + peer + |> cast(attrs, [ + :domain, + :base_url, + :discovery_url, + :protocol, + :protocol_id, + :protocol_version, + :trust_state, + :identity_fingerprint, + :previous_identity_fingerprint, + :last_key_change_at, + :identity, + :endpoints, + :features, + :last_discovered_at, + :last_error + ]) + |> update_change(:domain, &normalize_domain/1) + |> validate_required([ + :domain, + :base_url, + :discovery_url, + :trust_state, + :identity_fingerprint, + :identity, + :last_discovered_at + ]) + |> validate_format(:domain, ~r/^[a-z0-9.-]+$/, message: "must be a valid domain") + |> validate_length(:domain, max: 255) + |> validate_length(:base_url, max: 500) + |> validate_length(:discovery_url, max: 500) + |> validate_length(:protocol, max: 50) + |> validate_length(:protocol_id, max: 50) + |> validate_length(:protocol_version, max: 50) + |> validate_inclusion(:trust_state, ["trusted", "rotated", "replaced"]) + |> validate_length(:identity_fingerprint, max: 128) + |> validate_length(:previous_identity_fingerprint, max: 128) + |> validate_length(:last_error, max: 1000) + |> unique_constraint(:domain, name: :messaging_federation_discovered_peers_domain_unique) + end + + defp normalize_domain(domain) when is_binary(domain) do + domain + |> String.trim() + |> String.downcase() + |> String.replace(~r/^https?:\/\//, "") + |> String.split("/", parts: 2) + |> List.first() + |> to_string() + |> String.trim(".") + end + + defp normalize_domain(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_event.ex b/apps/elektrine/lib/elektrine/messaging/federation_event.ex new file mode 100644 index 0000000..ec4d259 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_event.ex @@ -0,0 +1,53 @@ +defmodule Elektrine.Messaging.FederationEvent do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "messaging_federation_events" do + field(:protocol_version, :string) + field(:event_id, :string) + field(:idempotency_key, :string) + field(:origin_domain, :string) + field(:event_type, :string) + field(:stream_id, :string) + field(:sequence, :integer) + field(:payload, :map) + field(:received_at, :utc_datetime) + + timestamps(updated_at: false) + end + + @doc false + def changeset(event, attrs) do + event + |> cast(attrs, [ + :protocol_version, + :event_id, + :idempotency_key, + :origin_domain, + :event_type, + :stream_id, + :sequence, + :payload, + :received_at + ]) + |> validate_required([ + :protocol_version, + :event_id, + :idempotency_key, + :origin_domain, + :event_type, + :stream_id, + :sequence, + :payload, + :received_at + ]) + |> validate_number(:sequence, greater_than: 0) + |> unique_constraint(:event_id, + name: :messaging_federation_events_origin_event_id_unique + ) + |> unique_constraint(:idempotency_key, + name: :messaging_federation_events_origin_idempotency_unique + ) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_extension_event.ex b/apps/elektrine/lib/elektrine/messaging/federation_extension_event.ex new file mode 100644 index 0000000..3aee0b2 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_extension_event.ex @@ -0,0 +1,43 @@ +defmodule Elektrine.Messaging.FederationExtensionEvent do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "messaging_federation_extension_events" do + field :event_type, :string + field :origin_domain, :string + field :event_key, :string + field :status, :string + field :payload, :map, default: %{} + field :occurred_at, :utc_datetime + + belongs_to :server, Elektrine.Messaging.Server + belongs_to :conversation, Elektrine.Messaging.ChatConversation + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(event, attrs) do + event + |> cast(attrs, [ + :event_type, + :origin_domain, + :event_key, + :status, + :payload, + :occurred_at, + :server_id, + :conversation_id + ]) + |> validate_required([:event_type, :origin_domain, :event_key]) + |> validate_length(:event_type, max: 160) + |> validate_length(:origin_domain, max: 255) + |> validate_length(:event_key, max: 500) + |> foreign_key_constraint(:server_id) + |> foreign_key_constraint(:conversation_id) + |> unique_constraint([:event_type, :origin_domain, :event_key], + name: :messaging_federation_extension_events_unique + ) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_invite_state.ex b/apps/elektrine/lib/elektrine/messaging/federation_invite_state.ex new file mode 100644 index 0000000..d434a06 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_invite_state.ex @@ -0,0 +1,63 @@ +defmodule Elektrine.Messaging.FederationInviteState do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @valid_roles ~w(owner admin moderator member readonly) + @valid_states ~w(pending accepted declined revoked) + + schema "messaging_federation_invite_states" do + field :origin_domain, :string + field :actor_uri, :string + field :actor_payload, :map, default: %{} + field :target_uri, :string + field :target_payload, :map, default: %{} + field :role, :string + field :state, :string + field :invited_at_remote, :utc_datetime + field :updated_at_remote, :utc_datetime + field :metadata, :map, default: %{} + + belongs_to :conversation, Elektrine.Messaging.ChatConversation + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(invite_state, attrs) do + invite_state + |> cast(attrs, [ + :origin_domain, + :actor_uri, + :actor_payload, + :target_uri, + :target_payload, + :role, + :state, + :invited_at_remote, + :updated_at_remote, + :metadata, + :conversation_id + ]) + |> validate_required([ + :origin_domain, + :actor_uri, + :actor_payload, + :target_uri, + :target_payload, + :role, + :state, + :updated_at_remote, + :conversation_id + ]) + |> validate_length(:origin_domain, max: 255) + |> validate_length(:actor_uri, max: 500) + |> validate_length(:target_uri, max: 500) + |> validate_inclusion(:role, @valid_roles) + |> validate_inclusion(:state, @valid_states) + |> foreign_key_constraint(:conversation_id) + |> unique_constraint([:conversation_id, :target_uri], + name: :messaging_federation_invite_states_unique + ) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_membership_state.ex b/apps/elektrine/lib/elektrine/messaging/federation_membership_state.ex new file mode 100644 index 0000000..7707190 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_membership_state.ex @@ -0,0 +1,53 @@ +defmodule Elektrine.Messaging.FederationMembershipState do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @valid_roles ~w(owner admin moderator member readonly) + @valid_states ~w(active invited left banned) + + schema "messaging_federation_membership_states" do + field :origin_domain, :string + field :role, :string + field :state, :string + field :joined_at_remote, :utc_datetime + field :updated_at_remote, :utc_datetime + field :metadata, :map, default: %{} + + belongs_to :conversation, Elektrine.Messaging.ChatConversation + belongs_to :remote_actor, Elektrine.ActivityPub.Actor + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(state, attrs) do + state + |> cast(attrs, [ + :origin_domain, + :role, + :state, + :joined_at_remote, + :updated_at_remote, + :metadata, + :conversation_id, + :remote_actor_id + ]) + |> validate_required([ + :origin_domain, + :role, + :state, + :updated_at_remote, + :conversation_id, + :remote_actor_id + ]) + |> validate_length(:origin_domain, max: 255) + |> validate_inclusion(:role, @valid_roles) + |> validate_inclusion(:state, @valid_states) + |> foreign_key_constraint(:conversation_id) + |> foreign_key_constraint(:remote_actor_id) + |> unique_constraint([:conversation_id, :remote_actor_id], + name: :messaging_federation_membership_states_unique + ) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_outbox_event.ex b/apps/elektrine/lib/elektrine/messaging/federation_outbox_event.ex new file mode 100644 index 0000000..4578e77 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_outbox_event.ex @@ -0,0 +1,61 @@ +defmodule Elektrine.Messaging.FederationOutboxEvent do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "messaging_federation_outbox_events" do + field :event_id, :string + field :event_type, :string + field :stream_id, :string + field :sequence, :integer + field :payload, :map + field :target_domains, {:array, :string}, default: [] + field :delivered_domains, {:array, :string}, default: [] + field :attempt_count, :integer, default: 0 + field :max_attempts, :integer, default: 8 + field :status, :string, default: "pending" + field :next_retry_at, :utc_datetime + field :last_error, :string + field :partition_month, :date + field :dispatched_at, :utc_datetime + + timestamps() + end + + @doc false + def changeset(outbox_event, attrs) do + outbox_event + |> cast(attrs, [ + :event_id, + :event_type, + :stream_id, + :sequence, + :payload, + :target_domains, + :delivered_domains, + :attempt_count, + :max_attempts, + :status, + :next_retry_at, + :last_error, + :partition_month, + :dispatched_at + ]) + |> validate_required([ + :event_id, + :event_type, + :stream_id, + :sequence, + :payload, + :target_domains, + :status, + :next_retry_at, + :partition_month + ]) + |> validate_number(:sequence, greater_than: 0) + |> validate_number(:attempt_count, greater_than_or_equal_to: 0) + |> validate_number(:max_attempts, greater_than: 0) + |> validate_inclusion(:status, ["pending", "delivered", "failed"]) + |> unique_constraint(:event_id, name: :messaging_federation_outbox_event_id_unique) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_outbox_retry_worker.ex b/apps/elektrine/lib/elektrine/messaging/federation_outbox_retry_worker.ex new file mode 100644 index 0000000..61c1d6d --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_outbox_retry_worker.ex @@ -0,0 +1,17 @@ +defmodule Elektrine.Messaging.FederationOutboxRetryWorker do + @moduledoc """ + Periodically re-enqueues due messaging federation outbox rows. + """ + + use Oban.Worker, + queue: :messaging_federation, + max_attempts: 1 + + alias Elektrine.Messaging.Federation + + @impl Oban.Worker + def perform(%Oban.Job{}) do + Federation.enqueue_due_outbox_events(500) + :ok + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_outbox_worker.ex b/apps/elektrine/lib/elektrine/messaging/federation_outbox_worker.ex new file mode 100644 index 0000000..14aef87 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_outbox_worker.ex @@ -0,0 +1,51 @@ +defmodule Elektrine.Messaging.FederationOutboxWorker do + @moduledoc """ + Processes one messaging federation outbox row. + """ + + use Oban.Worker, + queue: :messaging_federation, + max_attempts: 1, + unique: [period: 300, fields: [:args], keys: [:outbox_event_id]] + + alias Elektrine.Messaging.Federation + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"outbox_event_id" => outbox_event_id}}) do + case Federation.process_outbox_event(outbox_event_id) do + :delivered -> :ok + :pending_retry -> :ok + :already_delivered -> {:discard, :already_delivered} + :already_failed -> {:discard, :already_failed} + :not_due -> {:discard, :not_due} + :not_found -> {:discard, :not_found} + {:error, reason} -> {:error, reason} + other -> {:error, {:unexpected_result, other}} + end + end + + @doc """ + Enqueue a single outbox event for immediate processing. + """ + def enqueue(outbox_event_id) when is_integer(outbox_event_id) do + %{outbox_event_id: outbox_event_id} + |> new() + |> Elektrine.JobQueue.insert() + end + + @doc """ + Enqueue many outbox events in one DB call. + """ + def enqueue_many(outbox_event_ids) when is_list(outbox_event_ids) do + jobs = + outbox_event_ids + |> Enum.uniq() + |> Enum.map(fn id -> new(%{outbox_event_id: id}) end) + + if jobs == [] do + {:ok, []} + else + Elektrine.JobQueue.insert_all(jobs) + end + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_peer_policy.ex b/apps/elektrine/lib/elektrine/messaging/federation_peer_policy.ex new file mode 100644 index 0000000..64465f5 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_peer_policy.ex @@ -0,0 +1,51 @@ +defmodule Elektrine.Messaging.FederationPeerPolicy do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "messaging_federation_peer_policies" do + field :domain, :string + field :allow_incoming, :boolean + field :allow_outgoing, :boolean + field :blocked, :boolean, default: false + field :reason, :string + + belongs_to :updated_by, Elektrine.Accounts.User + + timestamps() + end + + @doc false + def changeset(policy, attrs) do + policy + |> cast(attrs, [ + :domain, + :allow_incoming, + :allow_outgoing, + :blocked, + :reason, + :updated_by_id + ]) + |> validate_required([:domain]) + |> update_change(:domain, &normalize_domain/1) + |> validate_required([:domain]) + |> validate_format(:domain, ~r/^[a-z0-9.-]+$/, message: "must be a valid domain") + |> validate_length(:domain, max: 255) + |> validate_length(:reason, max: 500) + |> unique_constraint(:domain, name: :messaging_federation_peer_policies_domain_unique) + |> foreign_key_constraint(:updated_by_id) + end + + defp normalize_domain(domain) when is_binary(domain) do + domain + |> String.trim() + |> String.downcase() + |> String.replace(~r/^https?:\/\//, "") + |> String.split("/", parts: 2) + |> List.first() + |> to_string() + |> String.trim(".") + end + + defp normalize_domain(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_read_cursor.ex b/apps/elektrine/lib/elektrine/messaging/federation_read_cursor.ex new file mode 100644 index 0000000..a02e5bf --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_read_cursor.ex @@ -0,0 +1,45 @@ +defmodule Elektrine.Messaging.FederationReadCursor do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "messaging_federation_read_cursors" do + field :origin_domain, :string + field :read_at, :utc_datetime + field :read_through_sequence, :integer + + belongs_to :conversation, Elektrine.Messaging.ChatConversation + belongs_to :chat_message, Elektrine.Messaging.ChatMessage + belongs_to :remote_actor, Elektrine.ActivityPub.Actor + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(cursor, attrs) do + cursor + |> cast(attrs, [ + :origin_domain, + :read_at, + :read_through_sequence, + :conversation_id, + :chat_message_id, + :remote_actor_id + ]) + |> validate_required([ + :origin_domain, + :read_at, + :conversation_id, + :chat_message_id, + :remote_actor_id + ]) + |> validate_length(:origin_domain, max: 255) + |> validate_number(:read_through_sequence, greater_than: 0) + |> foreign_key_constraint(:conversation_id) + |> foreign_key_constraint(:chat_message_id) + |> foreign_key_constraint(:remote_actor_id) + |> unique_constraint([:conversation_id, :remote_actor_id], + name: :messaging_federation_read_cursors_unique + ) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_request_replay.ex b/apps/elektrine/lib/elektrine/messaging/federation_request_replay.ex new file mode 100644 index 0000000..5e4fe73 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_request_replay.ex @@ -0,0 +1,43 @@ +defmodule Elektrine.Messaging.FederationRequestReplay do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "messaging_federation_request_replays" do + field :nonce, :string + field :origin_domain, :string + field :key_id, :string + field :http_method, :string + field :request_path, :string + field :timestamp, :integer + field :seen_at, :utc_datetime + field :expires_at, :utc_datetime + + timestamps(updated_at: false) + end + + @doc false + def changeset(entry, attrs) do + entry + |> cast(attrs, [ + :nonce, + :origin_domain, + :key_id, + :http_method, + :request_path, + :timestamp, + :seen_at, + :expires_at + ]) + |> validate_required([ + :nonce, + :origin_domain, + :http_method, + :request_path, + :timestamp, + :seen_at, + :expires_at + ]) + |> unique_constraint(:nonce, name: :messaging_federation_request_replays_nonce_unique) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_retention_worker.ex b/apps/elektrine/lib/elektrine/messaging/federation_retention_worker.ex new file mode 100644 index 0000000..8907ca7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_retention_worker.ex @@ -0,0 +1,17 @@ +defmodule Elektrine.Messaging.FederationRetentionWorker do + @moduledoc """ + Daily retention/archival for messaging federation event and outbox tables. + """ + + use Oban.Worker, + queue: :default, + max_attempts: 3 + + alias Elektrine.Messaging.Federation + + @impl Oban.Worker + def perform(%Oban.Job{}) do + Federation.run_retention() + :ok + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_room_presence_state.ex b/apps/elektrine/lib/elektrine/messaging/federation_room_presence_state.ex new file mode 100644 index 0000000..719ec90 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_room_presence_state.ex @@ -0,0 +1,48 @@ +defmodule Elektrine.Messaging.FederationRoomPresenceState do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @valid_statuses ~w(online idle dnd offline invisible) + + schema "messaging_federation_room_presence_states" do + field :origin_domain, :string + field :status, :string + field :activities, :map, default: %{} + field :updated_at_remote, :utc_datetime + field :expires_at_remote, :utc_datetime + + belongs_to :conversation, Elektrine.Messaging.ChatConversation + belongs_to :remote_actor, Elektrine.ActivityPub.Actor + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(state, attrs) do + state + |> cast(attrs, [ + :conversation_id, + :remote_actor_id, + :origin_domain, + :status, + :activities, + :updated_at_remote, + :expires_at_remote + ]) + |> validate_required([ + :conversation_id, + :remote_actor_id, + :origin_domain, + :status, + :updated_at_remote + ]) + |> validate_length(:origin_domain, max: 255) + |> validate_inclusion(:status, @valid_statuses) + |> foreign_key_constraint(:conversation_id) + |> foreign_key_constraint(:remote_actor_id) + |> unique_constraint([:conversation_id, :remote_actor_id], + name: :messaging_federation_room_presence_states_unique + ) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_session_client.ex b/apps/elektrine/lib/elektrine/messaging/federation_session_client.ex new file mode 100644 index 0000000..8517b3d --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_session_client.ex @@ -0,0 +1,995 @@ +defmodule Elektrine.Messaging.FederationSessionClient do + @moduledoc false + use GenServer + import Bitwise + + alias Elektrine.Messaging.Federation + alias Elektrine.Messaging.Federation.Config + alias Elektrine.Security.URLValidator + + @default_timeout 12_000 + @connect_timeout 5_000 + @default_max_inflight_batches 4 + @default_max_inflight_events 128 + @websocket_magic "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + @subprotocol "arblarg.session.v1" + + defstruct [ + :domain, + :peer, + :socket, + :transport, + :request_path, + :query_string, + :encoding, + buffer: <<>>, + pending_requests: %{}, + pending_deliveries: %{}, + queued_deliveries: :queue.new(), + inflight_batches: 0, + inflight_events: 0, + inflight_streams: MapSet.new(), + max_inflight_batches: @default_max_inflight_batches, + max_inflight_events: @default_max_inflight_events, + hello_received?: false + ] + + def start_link(%{} = peer) do + GenServer.start_link(__MODULE__, peer, name: via(peer.domain)) + end + + def send_request(peer, operation, payload, opts \\ []) + when is_map(peer) and is_binary(operation) and is_list(opts) do + timeout = Keyword.get(opts, :timeout, @default_timeout) + + with {:ok, pid} <- ensure_started(peer) do + try do + GenServer.call(pid, {:request, peer, operation, payload, opts}, timeout + 1_000) + catch + :exit, {:timeout, _} -> {:error, :session_timeout} + end + end + end + + def send_delivery(peer, operation, payload, opts \\ []) + when is_map(peer) and is_binary(operation) and is_list(opts) do + timeout = Keyword.get(opts, :timeout, @default_timeout) + + with {:ok, pid} <- ensure_started(peer) do + try do + GenServer.call(pid, {:delivery, peer, operation, payload, opts}, timeout + 1_000) + catch + :exit, {:timeout, _} -> {:error, :session_timeout} + end + end + end + + @impl true + def init(%{} = peer) do + {:ok, + %__MODULE__{ + domain: String.downcase(to_string(peer.domain)), + peer: peer, + encoding: preferred_encoding(peer) + }} + end + + @impl true + def handle_call({:request, peer, operation, payload, opts}, from, state) do + timeout = Keyword.get(opts, :timeout, @default_timeout) + state = %{state | peer: peer, encoding: preferred_encoding(peer)} + + with {:ok, state} <- ensure_connected(state), + request_id <- Ecto.UUID.generate(), + :ok <- send_request_frame(state, operation, request_id, payload) do + notify(state, {:request_sent, operation, request_id}) + state = put_pending_request(state, request_id, from, timeout) + {:noreply, state} + else + {:error, reason} -> + {:reply, {:error, reason}, fail_and_disconnect(state, reason)} + end + end + + def handle_call({:delivery, peer, operation, payload, opts}, from, state) do + state = %{state | peer: peer, encoding: preferred_encoding(peer)} + + with {:ok, state} <- ensure_connected(state), + {:ok, state} <- enqueue_or_send_delivery(state, from, operation, payload, opts) do + {:noreply, state} + else + {:error, reason} -> + {:reply, {:error, reason}, fail_and_disconnect(state, reason)} + end + end + + @impl true + def handle_info({:tcp, socket, data}, %__MODULE__{socket: socket} = state) do + state + |> append_socket_data(data) + |> continue_after_socket_data(:gen_tcp) + end + + def handle_info({:ssl, socket, data}, %__MODULE__{socket: socket} = state) do + state + |> append_socket_data(data) + |> continue_after_socket_data(:ssl) + end + + def handle_info({:tcp_closed, socket}, %__MODULE__{socket: socket} = state) do + {:noreply, fail_and_disconnect(state, :session_closed)} + end + + def handle_info({:ssl_closed, socket}, %__MODULE__{socket: socket} = state) do + {:noreply, fail_and_disconnect(state, :session_closed)} + end + + def handle_info({:tcp_error, socket, reason}, %__MODULE__{socket: socket} = state) do + {:noreply, fail_and_disconnect(state, {:session_transport_failed, reason})} + end + + def handle_info({:ssl_error, socket, reason}, %__MODULE__{socket: socket} = state) do + {:noreply, fail_and_disconnect(state, {:session_transport_failed, reason})} + end + + def handle_info({:session_timeout, :request, request_id}, state) do + {:noreply, fail_pending_request(state, request_id, :session_timeout)} + end + + def handle_info({:session_timeout, :delivery, delivery_id}, state) do + {:noreply, fail_pending_delivery(state, delivery_id, :session_timeout)} + end + + def handle_info(_message, state), do: {:noreply, state} + + @impl true + def terminate(_reason, state) do + _ = close_socket(state) + :ok + end + + defp ensure_started(%{} = peer) do + domain = String.downcase(to_string(peer.domain)) + + case Registry.lookup(Elektrine.Messaging.FederationSessionRegistry, domain) do + [{pid, _value}] -> + {:ok, pid} + + [] -> + case DynamicSupervisor.start_child( + Elektrine.Messaging.FederationSessionSupervisor, + {__MODULE__, peer} + ) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + {:error, reason} -> {:error, reason} + end + end + end + + defp via(domain) when is_binary(domain) do + {:via, Registry, {Elektrine.Messaging.FederationSessionRegistry, String.downcase(domain)}} + end + + defp ensure_connected(%__MODULE__{socket: socket} = state) when not is_nil(socket), + do: {:ok, state} + + defp ensure_connected(%__MODULE__{peer: peer} = state) do + with url when is_binary(url) <- Config.outbound_session_websocket_url(peer), + {:ok, uri} <- parse_uri(url), + {:ok, transport, socket} <- open_socket(uri), + {:ok, response, request_path, query_string} <- + websocket_handshake(peer, transport, socket, uri) do + case validate_handshake_response(response) do + {:ok, buffer} -> + state = %{ + state + | socket: socket, + transport: transport, + request_path: request_path, + query_string: query_string, + buffer: buffer + } + + with :ok <- activate_socket(state), + {:ok, state} <- process_buffer(state) do + {:ok, state} + else + {:error, reason} -> + _ = close_socket(state) + {:error, reason} + end + + {:error, reason} -> + _ = close_socket(%{state | socket: socket, transport: transport}) + {:error, reason} + end + else + nil -> {:error, :session_transport_unavailable} + {:error, reason} -> {:error, reason} + _ -> {:error, :session_transport_unavailable} + end + end + + defp enqueue_or_send_delivery(state, from, operation, payload, opts) do + event_count = delivery_event_count(operation, payload) + stream_key = delivery_stream_key(operation, payload) + + if can_send_delivery?(state, event_count, stream_key) do + send_delivery_now(state, from, operation, payload, opts, event_count, stream_key) + else + queued = + :queue.in( + %{ + from: from, + operation: operation, + payload: payload, + opts: opts, + event_count: event_count, + stream_key: stream_key + }, + state.queued_deliveries + ) + + {:ok, %{state | queued_deliveries: queued}} + end + end + + defp send_delivery_now(state, from, operation, payload, opts, event_count, stream_key) do + timeout = Keyword.get(opts, :timeout, @default_timeout) + delivery_id = Ecto.UUID.generate() + + with :ok <- send_delivery_frame(state, operation, delivery_id, payload) do + notify(state, {:delivery_sent, operation, delivery_id}) + timer_ref = Process.send_after(self(), {:session_timeout, :delivery, delivery_id}, timeout) + + state = + state + |> put_pending_delivery(delivery_id, from, timer_ref, event_count, stream_key) + |> increment_inflight(event_count, stream_key) + + {:ok, state} + end + end + + defp continue_after_socket_data({:ok, state}, transport) do + with :ok <- activate_socket(%{state | transport: transport}), + {:ok, state} <- process_buffer(state), + {:ok, state} <- drain_delivery_queue(state) do + {:noreply, state} + else + {:error, reason} -> + {:noreply, fail_and_disconnect(state, reason)} + end + end + + defp append_socket_data(%__MODULE__{} = state, data) when is_binary(data) do + {:ok, %{state | buffer: state.buffer <> data}} + end + + defp process_buffer(%__MODULE__{} = state) do + case decode_server_frame(state.buffer) do + {:ok, frame, rest} -> + state = %{state | buffer: rest} + + with {:ok, state} <- route_frame(state, frame) do + process_buffer(state) + end + + :more -> + {:ok, state} + + {:error, reason} -> + {:error, reason} + end + end + + defp route_frame(state, {:ping, payload}) do + case socket_send(state.transport, state.socket, encode_client_control(:pong, payload)) do + :ok -> {:ok, state} + {:error, reason} -> {:error, {:session_receive_failed, reason}} + end + end + + defp route_frame(state, {:pong, _payload}), do: {:ok, state} + defp route_frame(_state, {:close, _payload}), do: {:error, :session_closed} + + defp route_frame(state, {:text, payload}) do + with {:ok, frame} <- decode_text_payload(payload) do + route_payload(state, frame) + end + end + + defp route_frame(state, {:binary, payload}) do + with {:ok, frame} <- decode_binary_payload(payload) do + route_payload(state, frame) + end + end + + defp route_payload(state, %{"op" => "hello"} = payload) do + notify(state, {:hello_received, payload["mode"] || "unknown"}) + {:ok, apply_hello_frame(state, payload)} + end + + defp route_payload(state, %{ + "op" => "response", + "request_id" => request_id, + "status" => "ok", + "payload" => response + }) + when is_binary(request_id) do + notify(state, {:response_received, request_id, :ok}) + {:ok, fulfill_pending_request(state, request_id, {:ok, response})} + end + + defp route_payload(state, %{ + "op" => "response", + "request_id" => request_id, + "status" => "error", + "code" => code + }) + when is_binary(request_id) do + notify(state, {:response_received, request_id, {:error, code}}) + {:ok, fulfill_pending_request(state, request_id, {:error, response_reason(code)})} + end + + defp route_payload( + state, + %{"op" => "ack", "delivery_id" => delivery_id, "status" => "ok", "payload" => payload} + ) + when is_binary(delivery_id) do + notify(state, {:ack_received, delivery_id, :ok}) + + state = + state + |> fulfill_pending_delivery(delivery_id, {:ok, payload}) + |> maybe_drain_after_ack() + + {:ok, state} + end + + defp route_payload( + state, + %{"op" => "ack", "delivery_id" => delivery_id, "status" => "error", "code" => code} + ) + when is_binary(delivery_id) do + notify(state, {:ack_received, delivery_id, {:error, code}}) + + state = + state + |> fulfill_pending_delivery(delivery_id, {:error, response_reason(code)}) + |> maybe_drain_after_ack() + + {:ok, state} + end + + defp route_payload(state, _payload), do: {:ok, state} + + defp apply_hello_frame(state, payload) do + transport_profile = + payload + |> Map.get("transport_profiles", %{}) + |> Map.get("session_websocket", %{}) + + flow_control = + payload["flow_control"] || + transport_profile["flow_control"] || + %{} + + advertised_encodings = + payload + |> Map.get("encodings", []) + |> List.wrap() + |> Enum.filter(&is_binary/1) + + %{ + state + | max_inflight_batches: + parse_positive_integer( + flow_control["max_inflight_batches"], + @default_max_inflight_batches + ), + max_inflight_events: + parse_positive_integer( + flow_control["max_inflight_events"], + @default_max_inflight_events + ), + encoding: negotiate_encoding(state.encoding, advertised_encodings), + hello_received?: true + } + end + + defp maybe_drain_after_ack(state) do + case drain_delivery_queue(state) do + {:ok, state} -> state + {:error, _reason} -> state + end + end + + defp drain_delivery_queue(state) do + case :queue.out(state.queued_deliveries) do + {:empty, _queue} -> + {:ok, state} + + {{:value, queued}, rest} -> + if can_send_delivery?(state, queued.event_count, queued.stream_key) do + state = %{state | queued_deliveries: rest} + + with {:ok, state} <- + send_delivery_now( + state, + queued.from, + queued.operation, + queued.payload, + queued.opts, + queued.event_count, + queued.stream_key + ) do + drain_delivery_queue(state) + end + else + {:ok, state} + end + end + end + + defp can_send_delivery?(state, event_count, stream_key) do + state.inflight_batches < max(state.max_inflight_batches, 1) and + state.inflight_events + event_count <= max(state.max_inflight_events, 1) and + delivery_stream_available?(state, stream_key) + end + + defp increment_inflight(state, event_count, stream_key) do + %{ + state + | inflight_batches: state.inflight_batches + 1, + inflight_events: state.inflight_events + max(event_count, 0), + inflight_streams: maybe_track_inflight_stream(state.inflight_streams, stream_key) + } + end + + defp decrement_inflight(state, event_count, stream_key) do + %{ + state + | inflight_batches: max(state.inflight_batches - 1, 0), + inflight_events: max(state.inflight_events - max(event_count, 0), 0), + inflight_streams: maybe_release_inflight_stream(state.inflight_streams, stream_key) + } + end + + defp put_pending_request(state, request_id, from, timeout) do + timer_ref = Process.send_after(self(), {:session_timeout, :request, request_id}, timeout) + entry = %{from: from, timer_ref: timer_ref} + %{state | pending_requests: Map.put(state.pending_requests, request_id, entry)} + end + + defp put_pending_delivery(state, delivery_id, from, timer_ref, event_count, stream_key) do + entry = %{from: from, timer_ref: timer_ref, event_count: event_count, stream_key: stream_key} + %{state | pending_deliveries: Map.put(state.pending_deliveries, delivery_id, entry)} + end + + defp fulfill_pending_request(state, request_id, result) do + case Map.pop(state.pending_requests, request_id) do + {nil, pending_requests} -> + %{state | pending_requests: pending_requests} + + {%{from: from, timer_ref: timer_ref}, pending_requests} -> + _ = Process.cancel_timer(timer_ref) + GenServer.reply(from, result) + %{state | pending_requests: pending_requests} + end + end + + defp fulfill_pending_delivery(state, delivery_id, result) do + case Map.pop(state.pending_deliveries, delivery_id) do + {nil, pending_deliveries} -> + %{state | pending_deliveries: pending_deliveries} + + {%{from: from, timer_ref: timer_ref, event_count: event_count, stream_key: stream_key}, + pending_deliveries} -> + _ = Process.cancel_timer(timer_ref) + GenServer.reply(from, result) + + state + |> Map.put(:pending_deliveries, pending_deliveries) + |> decrement_inflight(event_count, stream_key) + end + end + + defp fail_pending_request(state, request_id, reason) do + state = fulfill_pending_request(state, request_id, {:error, reason}) + fail_and_disconnect(state, reason) + end + + defp fail_pending_delivery(state, delivery_id, reason) do + state = fulfill_pending_delivery(state, delivery_id, {:error, reason}) + fail_and_disconnect(state, reason) + end + + defp fail_and_disconnect(state, reason) do + state + |> fail_all_pending(reason) + |> disconnect() + end + + defp fail_all_pending(state, reason) do + pending_requests = + Enum.reduce(state.pending_requests, %{}, fn {request_id, + %{from: from, timer_ref: timer_ref}}, + acc -> + _ = Process.cancel_timer(timer_ref) + GenServer.reply(from, {:error, reason}) + Map.delete(acc, request_id) + end) + + pending_deliveries = + Enum.reduce(state.pending_deliveries, %{}, fn {delivery_id, + %{from: from, timer_ref: timer_ref}}, + acc -> + _ = Process.cancel_timer(timer_ref) + GenServer.reply(from, {:error, reason}) + Map.delete(acc, delivery_id) + end) + + %{ + state + | pending_requests: pending_requests, + pending_deliveries: pending_deliveries, + queued_deliveries: :queue.new(), + inflight_batches: 0, + inflight_events: 0, + inflight_streams: MapSet.new() + } + end + + defp delivery_stream_key("stream_batch", %{"stream_id" => stream_id}) when is_binary(stream_id), + do: stream_id + + defp delivery_stream_key(_operation, _payload), do: nil + + defp delivery_stream_available?(_state, nil), do: true + + defp delivery_stream_available?(state, stream_key) do + not MapSet.member?(state.inflight_streams, stream_key) + end + + defp maybe_track_inflight_stream(streams, nil), do: streams + defp maybe_track_inflight_stream(streams, stream_key), do: MapSet.put(streams, stream_key) + + defp maybe_release_inflight_stream(streams, nil), do: streams + defp maybe_release_inflight_stream(streams, stream_key), do: MapSet.delete(streams, stream_key) + + defp parse_uri(url) when is_binary(url) do + allow_insecure_transport = allow_insecure_transport?() + + case URI.parse(url) do + %URI{scheme: "wss", host: host} = uri when is_binary(host) -> + {:ok, uri} + + %URI{scheme: "ws", host: host} = uri when is_binary(host) -> + if allow_insecure_transport do + {:ok, uri} + else + {:error, :invalid_session_endpoint} + end + + _ -> + {:error, :invalid_session_endpoint} + end + end + + defp allow_insecure_transport? do + Application.get_env(:elektrine, :messaging_federation, []) + |> Config.allow_insecure_transport?() + end + + defp open_socket(%URI{scheme: "ws", host: host, port: port} = uri) do + port = port || 80 + + with :ok <- validate_session_websocket_url(uri), + {:ok, address} <- resolve_session_address(host) do + case :gen_tcp.connect( + address, + port, + [:binary, active: false, packet: :raw], + @connect_timeout + ) do + {:ok, socket} -> {:ok, :gen_tcp, socket} + {:error, reason} -> {:error, {:session_connect_failed, reason}} + end + end + end + + defp open_socket(%URI{scheme: "wss", host: host, port: port} = uri) do + port = port || 443 + + ssl_opts = [ + :binary, + active: false, + packet: :raw, + verify: :verify_peer, + cacerts: :public_key.cacerts_get(), + server_name_indication: String.to_charlist(host), + customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)] + ] + + with :ok <- validate_session_websocket_url(uri), + {:ok, address} <- resolve_session_address(host) do + case :ssl.connect(address, port, ssl_opts, @connect_timeout) do + {:ok, socket} -> {:ok, :ssl, socket} + {:error, reason} -> {:error, {:session_connect_failed, reason}} + end + end + end + + defp validate_session_websocket_url(%URI{} = uri) do + URLValidator.validate_websocket(URI.to_string(uri), + allow_insecure_transport: allow_insecure_transport?(), + allow_localhost: Elektrine.RuntimeEnv.dev_or_test?() + ) + end + + defp resolve_session_address(host) when is_binary(host) do + if Elektrine.RuntimeEnv.dev_or_test?() and session_localhost?(host) do + URLValidator.resolve_address(host) + else + URLValidator.resolve_public_address(host) + end + end + + defp session_localhost?(host) do + normalized_host = + host + |> String.downcase() + |> String.trim_leading("[") + |> String.trim_trailing("]") + |> String.trim_trailing(".") + + normalized_host in ["localhost", "127.0.0.1", "::1"] + end + + defp websocket_handshake(peer, transport, socket, %URI{host: host, port: port} = uri) do + request_path = uri.path || "/" + query_string = uri.query || "" + request_target = request_target(request_path, query_string) + websocket_key = :crypto.strong_rand_bytes(16) |> Base.encode64() + + headers = + peer + |> Federation.signed_headers("GET", request_path, query_string, "") + |> Enum.reject(fn {key, _value} -> String.downcase(key) == "content-type" end) + |> Enum.concat([ + {"host", host_header(host, uri.scheme, port)}, + {"connection", "Upgrade"}, + {"upgrade", "websocket"}, + {"sec-websocket-version", "13"}, + {"sec-websocket-key", websocket_key}, + {"sec-websocket-protocol", @subprotocol} + ]) + + request = [ + "GET ", + request_target, + " HTTP/1.1\r\n", + Enum.map(headers, fn {key, value} -> [key, ": ", value, "\r\n"] end), + "\r\n" + ] + + with :ok <- socket_send(transport, socket, request), + {:ok, response} <- recv_http_response(transport, socket, <<>>, @connect_timeout) do + {:ok, Map.put(response, :websocket_key, websocket_key), request_path, query_string} + end + end + + defp validate_handshake_response(%{ + status: 101, + headers: headers, + rest: rest, + websocket_key: key + }) do + accept = + key + |> Kernel.<>(@websocket_magic) + |> then(&:crypto.hash(:sha, &1)) + |> Base.encode64() + + case {header_value(headers, "sec-websocket-accept"), + header_value(headers, "sec-websocket-protocol")} do + {^accept, nil} -> {:ok, rest} + {^accept, @subprotocol} -> {:ok, rest} + _ -> {:error, :invalid_session_handshake} + end + end + + defp validate_handshake_response(%{status: status}) when is_integer(status) do + {:error, {:http_error, status}} + end + + defp send_request_frame(state, operation, request_id, payload) do + send_data_frame(state, %{"op" => operation, "request_id" => request_id, "payload" => payload}) + end + + defp send_delivery_frame(state, operation, delivery_id, payload) do + with {:ok, normalized_payload} <- normalize_delivery_payload(operation, payload, delivery_id) do + send_data_frame(state, %{ + "op" => operation, + "delivery_id" => delivery_id, + "payload" => normalized_payload + }) + end + end + + defp send_data_frame(state, payload) do + frame = + case state.encoding do + :cbor -> encode_client_frame(:binary, CBOR.encode(payload)) + _ -> encode_client_frame(:text, Jason.encode!(payload)) + end + + socket_send(state.transport, state.socket, frame) + end + + defp normalize_delivery_payload(operation, payload, delivery_id) + when operation in ["stream_batch", "deliver_ephemeral"] and is_map(payload) do + {:ok, + payload + |> Map.put("version", 1) + |> Map.put("delivery_id", delivery_id)} + end + + defp normalize_delivery_payload(_operation, payload, _delivery_id) when is_map(payload), + do: {:ok, payload} + + defp normalize_delivery_payload(_operation, _payload, _delivery_id), + do: {:error, :invalid_payload} + + defp recv_http_response(transport, socket, buffer, timeout) do + case :binary.match(buffer, "\r\n\r\n") do + {index, 4} -> + header_block = binary_part(buffer, 0, index) + rest = binary_part(buffer, index + 4, byte_size(buffer) - index - 4) + parse_http_response(header_block, rest) + + :nomatch -> + case socket_recv(transport, socket, timeout) do + {:ok, data} -> recv_http_response(transport, socket, buffer <> data, timeout) + {:error, reason} -> {:error, {:session_handshake_failed, reason}} + end + end + end + + defp parse_http_response(header_block, rest) do + [status_line | header_lines] = String.split(header_block, "\r\n", trim: true) + + with ["HTTP/1.1", status_code | _] <- String.split(status_line, " ", parts: 3), + {status, ""} <- Integer.parse(status_code) do + headers = + Enum.reduce(header_lines, %{}, fn line, acc -> + case String.split(line, ":", parts: 2) do + [key, value] -> + Map.put(acc, String.downcase(String.trim(key)), String.trim(value)) + + _ -> + acc + end + end) + + {:ok, %{status: status, headers: headers, rest: rest}} + else + _ -> {:error, :invalid_session_handshake} + end + end + + defp decode_text_payload(payload) when is_binary(payload) do + case Jason.decode(payload) do + {:ok, decoded} -> {:ok, decoded} + _ -> {:error, :invalid_session_frame} + end + end + + defp decode_binary_payload(payload) when is_binary(payload) do + case CBOR.decode(payload) do + {:ok, decoded, ""} -> {:ok, decoded} + _ -> {:error, :invalid_session_frame} + end + end + + defp activate_socket(%{transport: :gen_tcp, socket: socket}) when not is_nil(socket) do + :inet.setopts(socket, active: :once) + end + + defp activate_socket(%{transport: :ssl, socket: socket}) when not is_nil(socket) do + :ssl.setopts(socket, active: :once) + end + + defp activate_socket(_state), do: :ok + + defp encode_client_frame(opcode, payload) + when opcode in [:text, :binary] and is_binary(payload) do + opcode_byte = if(opcode == :text, do: 0x1, else: 0x2) + mask = :crypto.strong_rand_bytes(4) + masked_payload = mask_payload(payload, mask) + [<<0x80 ||| opcode_byte>>, length_prefix(byte_size(payload), true), mask, masked_payload] + end + + defp encode_client_control(opcode, payload) + when opcode in [:pong, :ping] and is_binary(payload) do + opcode_byte = if(opcode == :ping, do: 0x9, else: 0xA) + mask = :crypto.strong_rand_bytes(4) + masked_payload = mask_payload(payload, mask) + [<<0x80 ||| opcode_byte>>, length_prefix(byte_size(payload), true), mask, masked_payload] + end + + defp decode_server_frame(<<>>), do: :more + + defp decode_server_frame(<>) do + fin? = (first &&& 0x80) == 0x80 + opcode = first &&& 0x0F + masked? = (second &&& 0x80) == 0x80 + payload_length = second &&& 0x7F + + with true <- fin?, + {:ok, payload_length, rest} <- parse_payload_length(payload_length, rest), + {:ok, mask, rest} <- parse_mask(masked?, rest), + {:ok, payload, rest} <- parse_payload(rest, payload_length) do + payload = if masked?, do: mask_payload(payload, mask), else: payload + + case opcode do + 0x1 -> {:ok, {:text, payload}, rest} + 0x2 -> {:ok, {:binary, payload}, rest} + 0x8 -> {:ok, {:close, payload}, rest} + 0x9 -> {:ok, {:ping, payload}, rest} + 0xA -> {:ok, {:pong, payload}, rest} + _ -> {:error, :invalid_session_frame} + end + else + :more -> :more + false -> {:error, :invalid_session_frame} + end + end + + defp parse_payload_length(126, <>), do: {:ok, length, rest} + defp parse_payload_length(126, _buffer), do: :more + defp parse_payload_length(127, <>), do: {:ok, length, rest} + defp parse_payload_length(127, _buffer), do: :more + defp parse_payload_length(length, rest) when is_integer(length), do: {:ok, length, rest} + + defp parse_mask(true, <>), do: {:ok, mask, rest} + defp parse_mask(true, _buffer), do: :more + defp parse_mask(false, rest), do: {:ok, nil, rest} + + defp parse_payload(buffer, payload_length) when byte_size(buffer) < payload_length, do: :more + + defp parse_payload(buffer, payload_length) do + payload = binary_part(buffer, 0, payload_length) + rest = binary_part(buffer, payload_length, byte_size(buffer) - payload_length) + {:ok, payload, rest} + end + + defp response_reason(code) when is_binary(code) do + case Federation.error_reason(code) do + nil -> :session_transport_failed + reason -> reason + end + end + + defp response_reason(_code), do: :session_transport_failed + + defp request_target(path, ""), do: path + defp request_target(path, query_string), do: path <> "?" <> query_string + + defp host_header(host, "ws", port) when port in [nil, 80], do: host + defp host_header(host, "wss", port) when port in [nil, 443], do: host + defp host_header(host, _scheme, port), do: "#{host}:#{port}" + + defp socket_send(:gen_tcp, socket, data), do: :gen_tcp.send(socket, data) + defp socket_send(:ssl, socket, data), do: :ssl.send(socket, data) + + defp socket_recv(:gen_tcp, socket, timeout), do: :gen_tcp.recv(socket, 0, timeout) + defp socket_recv(:ssl, socket, timeout), do: :ssl.recv(socket, 0, timeout) + + defp close_socket(%{socket: nil}), do: :ok + defp close_socket(%{socket: socket, transport: :gen_tcp}), do: :gen_tcp.close(socket) + defp close_socket(%{socket: socket, transport: :ssl}), do: :ssl.close(socket) + defp close_socket(_state), do: :ok + + defp disconnect(state) do + _ = close_socket(state) + + %{ + state + | socket: nil, + transport: nil, + request_path: nil, + query_string: nil, + buffer: <<>>, + inflight_batches: 0, + inflight_events: 0, + hello_received?: false + } + end + + defp preferred_encoding(peer) when is_map(peer) do + features = + Map.get(peer, :features) || Map.get(peer, "features") || %{} + + case Map.get(features, "binary_event_batches") do + true -> :cbor + "true" -> :cbor + 1 -> :cbor + _ -> :json + end + end + + defp preferred_encoding(_peer), do: :json + + defp negotiate_encoding(current, advertised_encodings) when is_list(advertised_encodings) do + cond do + current == :cbor and "cbor" in advertised_encodings -> :cbor + "json" in advertised_encodings -> :json + "cbor" in advertised_encodings -> :cbor + true -> current + end + end + + defp notify(%__MODULE__{peer: peer}, message) do + notify(peer, message) + end + + defp notify(peer, message) when is_map(peer) do + case Map.get(peer, :debug_notify) || Map.get(peer, "debug_notify") do + pid when is_pid(pid) -> send(pid, {:federation_session_client, message}) + _ -> :ok + end + end + + defp notify(_peer, _message), do: :ok + + defp header_value(headers, key) when is_map(headers), do: Map.get(headers, String.downcase(key)) + + defp length_prefix(length, masked?) when length < 126 do + mask_bit = if(masked?, do: 0x80, else: 0x00) + <> + end + + defp length_prefix(length, masked?) when length <= 0xFFFF do + mask_bit = if(masked?, do: 0x80, else: 0x00) + <> + end + + defp length_prefix(length, masked?) do + mask_bit = if(masked?, do: 0x80, else: 0x00) + <> + end + + defp mask_payload(payload, mask) when is_binary(payload) and is_binary(mask) do + mask_bytes = :binary.bin_to_list(mask) + + payload + |> :binary.bin_to_list() + |> Enum.with_index() + |> Enum.map(fn {byte, index} -> + Bitwise.bxor(byte, Enum.at(mask_bytes, rem(index, 4))) + end) + |> :erlang.list_to_binary() + end + + defp parse_positive_integer(value, _default) when is_integer(value) and value > 0, do: value + + defp parse_positive_integer(value, default) when is_binary(value) do + case Integer.parse(value) do + {parsed, ""} when parsed > 0 -> parsed + _ -> default + end + end + + defp parse_positive_integer(_value, default), do: default + + defp delivery_event_count("stream_batch", %{"events" => events}) when is_list(events), + do: max(length(events), 1) + + defp delivery_event_count("deliver_ephemeral", %{"items" => items}) when is_list(items), + do: max(length(items), 1) + + defp delivery_event_count(_operation, _payload), do: 1 +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_session_websock.ex b/apps/elektrine/lib/elektrine/messaging/federation_session_websock.ex new file mode 100644 index 0000000..f7d87eb --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_session_websock.ex @@ -0,0 +1,248 @@ +defmodule Elektrine.Messaging.FederationSessionWebSock do + @moduledoc false + @behaviour WebSock + + alias Elektrine.Messaging.Federation + + @hello_frame %{ + "op" => "hello", + "protocol" => "arblarg", + "transport" => "session_websocket", + "session_version" => 1, + "mode" => "stream_session", + "encodings" => ["json", "cbor"] + } + + @impl true + def init(%{} = state) do + hello = + @hello_frame + |> Map.put("limits", Federation.discovery_limits_for_transport()) + |> Map.put("transport_profiles", Federation.transport_profiles_for_transport()) + |> Map.put("flow_control", Federation.session_flow_control_for_transport()) + |> encode_frame(:text) + + {:push, hello, state} + end + + @impl true + def handle_in({payload, [opcode: opcode]}, %{} = state) when opcode in [:text, :binary] do + {encoding, decoded_payload} = decode_frame_payload(payload, opcode) + + response = + case decoded_payload do + {:ok, %{"op" => op} = request} when is_binary(op) -> + dispatch_frame(op, request, state, encoding) + + {:ok, _} -> + error_frame(nil, :invalid_session_frame, encoding) + + {:error, reason} -> + error_frame(nil, reason, encoding) + end + + {:push, response, state} + end + + def handle_in({_payload, _meta}, state) do + {:push, error_frame(nil, :invalid_session_frame, :json), state} + end + + @impl true + def handle_control({_payload, _meta}, state), do: {:ok, state} + + @impl true + def handle_info(_message, state), do: {:ok, state} + + defp dispatch_frame("stream_batch", %{"delivery_id" => delivery_id} = request, state, encoding) + when is_binary(delivery_id) do + dispatch_delivery_request("stream_batch", request, state, delivery_id, encoding) + end + + defp dispatch_frame( + "deliver_ephemeral", + %{"delivery_id" => delivery_id} = request, + state, + encoding + ) + when is_binary(delivery_id) do + dispatch_delivery_request("deliver_ephemeral", request, state, delivery_id, encoding) + end + + defp dispatch_frame(op, %{"request_id" => request_id} = request, state, encoding) + when is_binary(op) and is_binary(request_id) do + dispatch_control_request(op, request, state, request_id, encoding) + end + + defp dispatch_frame(_op, %{"delivery_id" => delivery_id}, _state, encoding) + when is_binary(delivery_id) do + ack_error_frame(delivery_id, :unsupported_operation, encoding) + end + + defp dispatch_frame(_op, %{"request_id" => request_id}, _state, encoding) + when is_binary(request_id) do + error_frame(request_id, :unsupported_operation, encoding) + end + + defp dispatch_frame(_op, _request, _state, encoding) do + error_frame(nil, :invalid_session_frame, encoding) + end + + defp dispatch_control_request("events_batch", request, state, request_id, encoding) do + remote_domain = state.remote_domain + + case Federation.receive_event_batch(Map.get(request, "payload"), remote_domain) do + {:ok, payload} -> ok_frame(request_id, payload, encoding) + {:error, reason} -> error_frame(request_id, reason, encoding) + end + end + + defp dispatch_control_request("ephemeral_batch", request, state, request_id, encoding) do + remote_domain = state.remote_domain + + case Federation.receive_ephemeral_batch(Map.get(request, "payload"), remote_domain) do + {:ok, payload} -> ok_frame(request_id, payload, encoding) + {:error, reason} -> error_frame(request_id, reason, encoding) + end + end + + defp dispatch_control_request("stream_events", request, state, request_id, encoding) do + payload = Map.get(request, "payload") || %{} + + case Map.get(payload, "stream_id") do + stream_id when is_binary(stream_id) -> + response = + Federation.export_stream_events(stream_id, + after_sequence: payload["after_sequence"] || 0, + limit: payload["limit"] || 128, + peer: Map.get(state, :peer) + ) + + ok_frame(request_id, response, encoding) + + _ -> + error_frame(request_id, :invalid_payload, encoding) + end + end + + defp dispatch_control_request("snapshot", request, state, request_id, encoding) do + payload = Map.get(request, "payload") || %{} + + with server_id when is_integer(server_id) <- parse_server_id(payload["server_id"]), + {:ok, snapshot} <- + Federation.build_server_snapshot(server_id, peer: Map.get(state, :peer)) do + ok_frame(request_id, snapshot, encoding) + else + nil -> error_frame(request_id, :invalid_server_id, encoding) + {:error, reason} -> error_frame(request_id, reason, encoding) + _ -> error_frame(request_id, :invalid_server_id, encoding) + end + end + + defp dispatch_control_request("ping", _request, _state, request_id, encoding) do + ok_frame(request_id, %{"status" => "pong"}, encoding) + end + + defp dispatch_control_request(_op, _request, _state, request_id, encoding) do + error_frame(request_id, :unsupported_operation, encoding) + end + + defp dispatch_delivery_request("stream_batch", request, state, delivery_id, encoding) do + remote_domain = state.remote_domain + payload = Map.get(request, "payload") || %{} + + case Federation.receive_session_stream_batch(payload, remote_domain, delivery_id) do + {:ok, ack_payload} -> ack_frame(delivery_id, ack_payload, encoding) + {:error, reason} -> ack_error_frame(delivery_id, reason, encoding) + end + end + + defp dispatch_delivery_request("deliver_ephemeral", request, state, delivery_id, encoding) do + remote_domain = state.remote_domain + payload = Map.get(request, "payload") || %{} + + case Federation.receive_session_ephemeral_batch(payload, remote_domain, delivery_id) do + {:ok, ack_payload} -> ack_frame(delivery_id, ack_payload, encoding) + {:error, reason} -> ack_error_frame(delivery_id, reason, encoding) + end + end + + defp ok_frame(request_id, payload, encoding) do + encode_frame( + %{ + "op" => "response", + "request_id" => request_id, + "status" => "ok", + "payload" => payload + }, + encoding + ) + end + + defp error_frame(request_id, reason, encoding) do + encode_frame( + %{ + "op" => "response", + "request_id" => request_id, + "status" => "error", + "code" => Federation.error_code(reason) + }, + encoding + ) + end + + defp ack_frame(delivery_id, payload, encoding) do + encode_frame( + %{ + "op" => "ack", + "delivery_id" => delivery_id, + "status" => "ok", + "payload" => payload + }, + encoding + ) + end + + defp ack_error_frame(delivery_id, reason, encoding) do + encode_frame( + %{ + "op" => "ack", + "delivery_id" => delivery_id, + "status" => "error", + "code" => Federation.error_code(reason) + }, + encoding + ) + end + + defp decode_frame_payload(payload, :text) when is_binary(payload) do + case Jason.decode(payload) do + {:ok, decoded} -> {:json, {:ok, decoded}} + _ -> {:json, {:error, :invalid_session_frame}} + end + end + + defp decode_frame_payload(payload, :binary) when is_binary(payload) do + case CBOR.decode(payload) do + {:ok, decoded, ""} -> {:cbor, {:ok, decoded}} + _ -> {:cbor, {:error, :invalid_session_frame}} + end + end + + defp decode_frame_payload(_payload, _opcode), do: {:json, {:error, :invalid_session_frame}} + + defp encode_frame(payload, :cbor), do: {:binary, CBOR.encode(payload)} + defp encode_frame(payload, :json), do: {:text, Jason.encode!(payload)} + defp encode_frame(payload, :text), do: {:text, Jason.encode!(payload)} + + defp parse_server_id(value) when is_integer(value), do: value + + defp parse_server_id(value) when is_binary(value) do + case Integer.parse(value) do + {int, ""} -> int + _ -> nil + end + end + + defp parse_server_id(_value), do: nil +end diff --git a/apps/elektrine/lib/elektrine/messaging/federation_stream_position.ex b/apps/elektrine/lib/elektrine/messaging/federation_stream_position.ex new file mode 100644 index 0000000..540bd69 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/federation_stream_position.ex @@ -0,0 +1,22 @@ +defmodule Elektrine.Messaging.FederationStreamPosition do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "messaging_federation_stream_positions" do + field :origin_domain, :string + field :stream_id, :string + field :last_sequence, :integer, default: 0 + + timestamps() + end + + @doc false + def changeset(position, attrs) do + position + |> cast(attrs, [:origin_domain, :stream_id, :last_sequence]) + |> validate_required([:origin_domain, :stream_id, :last_sequence]) + |> validate_number(:last_sequence, greater_than_or_equal_to: 0) + |> unique_constraint(:stream_id, name: :messaging_federation_stream_positions_unique) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/link_preview_broadcast.ex b/apps/elektrine/lib/elektrine/messaging/link_preview_broadcast.ex new file mode 100644 index 0000000..b56a68e --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/link_preview_broadcast.ex @@ -0,0 +1,67 @@ +defmodule Elektrine.Messaging.LinkPreviewBroadcast do + @moduledoc """ + Shared helpers for polling a pending link preview and broadcasting the + updated message to a conversation's PubSub topic. + + Extracted from `Elektrine.Messaging.ChatMessages` and + `Elektrine.Social.Messages`, which had near-identical private helpers. The + per-context differences (preload list, post-preload transform, and the + PubSub topic) are passed in as options so the broadcast payloads and topics + remain byte-identical to the originals. + """ + + alias Elektrine.Repo + alias Elektrine.Social.LinkPreview + + @doc """ + Polls for a pending preview and broadcasts the update once it succeeds. + + `opts` are forwarded to `broadcast_update/2`. + """ + def poll_and_broadcast(_message, _preview_id, 0, _opts), do: :ok + + def poll_and_broadcast(message, preview_id, attempts_left, opts) do + :timer.sleep(1000) + + case Repo.get(LinkPreview, preview_id) do + %{status: "success"} = preview -> + broadcast_update(message, preview, opts) + + :ok + + %{status: "pending"} -> + poll_and_broadcast(message, preview_id, attempts_left - 1, opts) + + _ -> + :ok + end + end + + @doc """ + Attaches the resolved preview to the message, applies the per-context + preloads/transform, and broadcasts `{:message_link_preview_updated, message}` + on the given topic. + + ## Options + + * `:preload` - preload list passed to `Repo.preload/3` (default `[]`) + * `:transform` - 1-arity function applied after preloading (default identity) + * `:topic` - 1-arity function mapping the message to its PubSub topic + """ + def broadcast_update(message, preview, opts) do + preload = Keyword.get(opts, :preload, []) + transform = Keyword.get(opts, :transform, & &1) + topic_fun = Keyword.fetch!(opts, :topic) + + updated_message = + %{message | link_preview: preview} + |> Repo.preload(preload, force: true) + |> transform.() + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + topic_fun.(message), + {:message_link_preview_updated, updated_message} + ) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/member_schema_base.ex b/apps/elektrine/lib/elektrine/messaging/member_schema_base.ex new file mode 100644 index 0000000..1ee5412 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/member_schema_base.ex @@ -0,0 +1,129 @@ +defmodule Elektrine.Messaging.MemberSchemaBase do + @moduledoc """ + Shared schema/changeset/predicate definitions for conversation member schemas. + + Used by both `Elektrine.Messaging.ChatConversationMember` and + `Elektrine.Social.ConversationMember`, which were previously near-identical. + + ## Usage + + use Elektrine.Messaging.MemberSchemaBase, + table: "chat_conversation_members", + conversation: Elektrine.Messaging.ChatConversation, + message: Elektrine.Messaging.ChatMessage + + Options: + + * `:table` (required) - the database table name. + * `:conversation` (required) - the module for the `belongs_to :conversation` + association. + * `:message` (required) - the module for the `belongs_to :last_read_message` + association. + + All injected functions reference `__MODULE__`, so `%__MODULE__{}` patterns + resolve to the using module, preserving struct-specific dispatch. + """ + + defmacro __using__(opts) do + table = Keyword.fetch!(opts, :table) + conversation = Keyword.fetch!(opts, :conversation) + message = Keyword.fetch!(opts, :message) + + quote do + use Ecto.Schema + import Ecto.Changeset + + schema unquote(table) do + field :role, :string, default: "member" + field :joined_at, :utc_datetime + field :left_at, :utc_datetime + field :last_read_at, :utc_datetime + field :notifications_enabled, :boolean, default: true + field :pinned, :boolean, default: false + + belongs_to :conversation, unquote(conversation) + belongs_to :user, Elektrine.Accounts.User + belongs_to :last_read_message, unquote(message) + + timestamps() + end + + @doc false + def changeset(member, attrs) do + member + |> cast(attrs, [ + :conversation_id, + :user_id, + :role, + :joined_at, + :left_at, + :last_read_at, + :last_read_message_id, + :notifications_enabled, + :pinned + ]) + |> truncate_utc_datetimes([:joined_at, :left_at, :last_read_at]) + |> validate_required([:conversation_id, :user_id]) + |> validate_inclusion(:role, ["owner", "admin", "moderator", "member", "readonly"]) + |> foreign_key_constraint(:conversation_id) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:last_read_message_id) + |> unique_constraint([:conversation_id, :user_id]) + |> maybe_set_joined_at() + end + + def add_member_changeset(conversation_id, user_id, role \\ "member") do + %__MODULE__{} + |> changeset(%{ + conversation_id: conversation_id, + user_id: user_id, + role: role, + joined_at: Elektrine.Time.utc_now() + }) + end + + def remove_member_changeset(member) do + member + |> changeset(%{left_at: Elektrine.Time.utc_now()}) + end + + def mark_as_read_changeset(member) do + member + |> changeset(%{last_read_at: Elektrine.Time.utc_now()}) + end + + def update_last_read_message_changeset(member, message_id) do + member + |> changeset(%{ + last_read_at: Elektrine.Time.utc_now(), + last_read_message_id: message_id + }) + end + + def active?(%__MODULE__{left_at: nil}), do: true + def active?(%__MODULE__{}), do: false + + def can_send_messages?(%__MODULE__{role: "readonly"}), do: false + def can_send_messages?(%__MODULE__{left_at: nil}), do: true + def can_send_messages?(%__MODULE__{}), do: false + + def admin?(%__MODULE__{role: "admin"}), do: true + def admin?(%__MODULE__{}), do: false + + defp maybe_set_joined_at(changeset) do + case get_field(changeset, :joined_at) do + nil -> put_change(changeset, :joined_at, Elektrine.Time.utc_now()) + _ -> changeset + end + end + + defp truncate_utc_datetimes(changeset, fields) do + Enum.reduce(fields, changeset, fn field, changeset -> + update_change(changeset, field, &Elektrine.Time.truncate/1) + end) + end + + defoverridable can_send_messages?: 1, admin?: 1 + end + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/membership.ex b/apps/elektrine/lib/elektrine/messaging/membership.ex new file mode 100644 index 0000000..f3b0090 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/membership.ex @@ -0,0 +1,651 @@ +defmodule Elektrine.Messaging.Membership do + @moduledoc """ + Generic, schema-parameterized core for conversation member management shared + by the chat (`Elektrine.Messaging.ChatConversations`) and social + (`Elektrine.Social.Conversations`) contexts. + + After Phase 2 both member schemas expose an identical interface + (`add_member_changeset/3`, `remove_member_changeset/1`, `changeset/2`, and the + same fields), so the orchestration here works for both by taking the member + and conversation schema modules as parameters. Federation publishing and + authorization are already shared (`Elektrine.Messaging.Federation`, + `Elektrine.Messaging.MembershipAuthz`), so they are called directly. + + Genuinely context-specific behaviour is NOT folded in here. In particular: + + * `builtin_room_role_definition/1` differs between contexts (different + permission lists), so role-assignment publishing takes the definition + lookup as a function argument (`role_def_fun`). + + * `update_member_count/3` handles the common group/channel teardown + + member-count update; the social context's `"community"` archive branch is + handled by an optional `on_empty` callback. + + * Predicates whose role lists differ (`admin?/2`) and per-context message + schema queries stay in the contexts. + """ + + import Ecto.Query, warn: false + + alias Elektrine.Messaging.Federation + alias Elektrine.Messaging.Federation.Utils, as: FederationUtils + alias Elektrine.Messaging.MembershipAuthz + alias Elektrine.Repo + + @doc """ + Adds a member to a conversation. When `added_by_user_id` is supplied, enforces + the manager authz check and the add-to-group privacy check before inserting. + """ + def add_member( + member_schema, + conversation_schema, + conversation_id, + user_id, + role, + added_by_user_id + ) do + if added_by_user_id do + with :ok <- ensure_can_manage_members(member_schema, conversation_id, added_by_user_id), + {:ok, :allowed} <- Elektrine.Privacy.can_add_to_group?(added_by_user_id, user_id) do + do_add_member( + member_schema, + conversation_schema, + conversation_id, + user_id, + role, + added_by_user_id, + true + ) + else + {:error, reason} -> {:error, reason} + end + else + do_add_member( + member_schema, + conversation_schema, + conversation_id, + user_id, + role, + nil, + true + ) + end + end + + @doc "Adds a member without publishing federation membership state." + def add_member_without_federation( + member_schema, + conversation_schema, + conversation_id, + user_id, + role + ) do + do_add_member( + member_schema, + conversation_schema, + conversation_id, + user_id, + role, + nil, + false + ) + end + + @doc """ + Core add-member orchestration: bans, insert-or-reactivate, member-count + update, federation publishing, and PubSub broadcasts. + """ + def do_add_member( + member_schema, + conversation_schema, + conversation_id, + user_id, + role, + added_by_user_id, + publish_federation? + ) do + if Elektrine.Messaging.Moderation.user_banned?(conversation_id, user_id) do + {:error, :banned} + else + existing_member = + Repo.get_by(member_schema, conversation_id: conversation_id, user_id: user_id) + + result = + case existing_member do + nil -> + member_schema.add_member_changeset(conversation_id, user_id, role) + |> Repo.insert() + + member -> + member + |> member_schema.changeset(%{ + left_at: nil, + joined_at: DateTime.utc_now(), + role: role + }) + |> Repo.update() + end + + case result do + {:ok, member} -> + update_member_count(member_schema, conversation_schema, conversation_id) + + maybe_publish_federation_membership( + publish_federation?, + conversation_id, + user_id, + added_by_user_id, + role + ) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{conversation_id}", + {:member_joined, %{user_id: user_id, conversation_id: conversation_id}} + ) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}", + {:added_to_conversation, %{conversation_id: conversation_id}} + ) + + {:ok, member} + + error -> + error + end + end + end + + @doc """ + Removes a member from a conversation. An actor may remove a member if it is a + manager (owner/admin/moderator) or it is removing itself; a nil actor skips + the check (internal/self-service callers). + """ + def remove_member(member_schema, conversation_schema, conversation_id, user_id, actor_user_id) do + with :ok <- + ensure_can_remove_member(member_schema, conversation_id, user_id, actor_user_id) do + do_remove_member(member_schema, conversation_schema, conversation_id, user_id) + end + end + + @doc "Core remove-member orchestration." + def do_remove_member(member_schema, conversation_schema, conversation_id, user_id) do + member = + from(cm in member_schema, + where: cm.conversation_id == ^conversation_id and cm.user_id == ^user_id + ) + |> Repo.one() + + case member do + nil -> + {:error, :not_found} + + member -> + member + |> member_schema.remove_member_changeset() + |> Repo.update() + |> case do + {:ok, updated_member} -> + update_member_count(member_schema, conversation_schema, conversation_id) + + _ = + Federation.publish_membership_state( + conversation_id, + user_id, + membership_state_for_departure(updated_member), + updated_member.role || "member" + ) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{conversation_id}", + {:member_left, %{user_id: user_id, conversation_id: conversation_id}} + ) + + {:ok, updated_member} + + error -> + error + end + end + end + + @doc "Gets an active conversation member record." + def get_member(member_schema, conversation_id, user_id) do + from(cm in member_schema, + where: + cm.conversation_id == ^conversation_id and cm.user_id == ^user_id and is_nil(cm.left_at) + ) + |> Repo.one() + end + + @doc "Gets all active members of a conversation with user details." + def get_members(member_schema, conversation_id) do + from(cm in member_schema, + join: u in Elektrine.Accounts.User, + on: u.id == cm.user_id, + where: cm.conversation_id == ^conversation_id and is_nil(cm.left_at), + select: %{ + user_id: u.id, + username: u.username, + handle: u.handle, + display_name: u.display_name, + avatar: u.avatar, + verified: u.verified, + joined_at: cm.joined_at, + role: cm.role + }, + order_by: [desc: cm.role, asc: cm.joined_at] + ) + |> Repo.all() + end + + @doc """ + Promotes a member to admin. Requires the promoter to be an admin of the + conversation. `admin_fun` is the context's `admin?/2` predicate and + `role_def_fun` its `builtin_room_role_definition/1` lookup. + """ + def promote_to_admin( + member_schema, + conversation_schema, + conversation_id, + user_id, + promoter_id, + admin_fun, + role_def_fun + ) do + with {:ok, _conversation} <- get_conversation_basic(conversation_schema, conversation_id), + true <- admin_fun.(conversation_id, promoter_id) do + member = get_member(member_schema, conversation_id, user_id) + + case member do + nil -> + {:error, :not_found} + + member -> + member + |> member_schema.changeset(%{role: "admin"}) + |> Repo.update() + |> case do + {:ok, updated_member} -> + _ = Federation.publish_membership_state(conversation_id, user_id, "active", "admin") + + maybe_publish_role_assignment( + conversation_schema, + conversation_id, + user_id, + "admin", + promoter_id, + role_def_fun + ) + + {:ok, updated_member} + + error -> + error + end + end + else + false -> {:error, :unauthorized} + error -> error + end + end + + @doc """ + Demotes an admin to member. Requires the demoter to be an admin and the target + not to be the conversation creator. + """ + def demote_from_admin( + member_schema, + conversation_schema, + conversation_id, + user_id, + demoter_id, + admin_fun, + role_def_fun + ) do + with {:ok, conversation} <- get_conversation_basic(conversation_schema, conversation_id), + true <- admin_fun.(conversation_id, demoter_id), + false <- conversation.creator_id == user_id do + member = get_member(member_schema, conversation_id, user_id) + + case member do + nil -> + {:error, :not_found} + + member -> + member + |> member_schema.changeset(%{role: "member"}) + |> Repo.update() + |> case do + {:ok, updated_member} -> + _ = + Federation.publish_membership_state(conversation_id, user_id, "active", "member") + + maybe_publish_role_assignment( + conversation_schema, + conversation_id, + user_id, + "member", + demoter_id, + role_def_fun + ) + + {:ok, updated_member} + + error -> + error + end + end + else + true -> {:error, :cannot_demote_creator} + false -> {:error, :unauthorized} + error -> error + end + end + + @doc """ + Updates a member's role. Enforces the stricter role-change authz and creator + protection before updating. + """ + def update_member_role( + member_schema, + conversation_schema, + conversation_id, + user_id, + new_role, + actor_user_id, + role_def_fun + ) do + with :ok <- ensure_can_change_role(member_schema, conversation_id, actor_user_id), + :ok <- + ensure_not_protected_target( + conversation_schema, + conversation_id, + user_id, + actor_user_id + ), + member when not is_nil(member) <- get_member(member_schema, conversation_id, user_id) do + member + |> member_schema.changeset(%{role: new_role}) + |> Repo.update() + |> case do + {:ok, updated_member} -> + _ = Federation.publish_membership_state(conversation_id, user_id, "active", new_role) + + maybe_publish_role_assignment( + conversation_schema, + conversation_id, + user_id, + new_role, + actor_user_id, + role_def_fun + ) + + {:ok, updated_member} + + error -> + error + end + else + nil -> {:error, :member_not_found} + {:error, _reason} = error -> error + end + end + + @doc """ + Recomputes the conversation member count. Empty group/channel conversations + are torn down (messages + members deleted, conversation removed). When the + conversation is empty but not a deletable group/channel, the optional + `on_empty` callback (arity 1, receiving the conversation) may handle it and + return a result tuple; returning `nil` falls through to the normal count + update. + + `message_schema` is the per-context message schema used for teardown. + """ + def update_member_count( + member_schema, + conversation_schema, + conversation_id, + message_schema \\ nil, + on_empty \\ nil + ) do + count = + from(cm in member_schema, + where: cm.conversation_id == ^conversation_id and is_nil(cm.left_at), + select: count() + ) + |> Repo.one() + + conversation = Repo.get(conversation_schema, conversation_id) + + empty_result = + if count == 0 && conversation && is_function(on_empty, 1), + do: on_empty.(conversation), + else: nil + + cond do + count == 0 && conversation && conversation.type in ["group", "channel"] -> + if message_schema do + from(m in message_schema, where: m.conversation_id == ^conversation_id) + |> Repo.delete_all() + end + + from(cm in member_schema, where: cm.conversation_id == ^conversation_id) + |> Repo.delete_all() + + Repo.delete(conversation) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversations:all", + {:conversation_deleted, conversation_id} + ) + + {:deleted, 0} + + not is_nil(empty_result) -> + empty_result + + true -> + from(c in conversation_schema, where: c.id == ^conversation_id) + |> Repo.update_all(set: [member_count: count]) + + {:updated, count} + end + end + + @doc "Departure membership state for a member (currently always \"left\")." + def membership_state_for_departure(%{role: role}) when role in ["owner", "admin"], do: "left" + def membership_state_for_departure(_member), do: "left" + + @doc """ + Pins a conversation for a user. Returns `{:error, :unauthorized}` when the user + is not an active member. + """ + def pin(member_schema, conversation_id, user_id) do + set_pinned(member_schema, conversation_id, user_id, true) + end + + @doc """ + Unpins a conversation for a user. Returns `{:error, :unauthorized}` when the + user is not an active member. + """ + def unpin(member_schema, conversation_id, user_id) do + set_pinned(member_schema, conversation_id, user_id, false) + end + + defp set_pinned(member_schema, conversation_id, user_id, pinned) do + case get_member(member_schema, conversation_id, user_id) do + nil -> {:error, :unauthorized} + member -> member |> member_schema.changeset(%{pinned: pinned}) |> Repo.update() + end + end + + @doc """ + Joins a public channel. Verifies the conversation is a public channel and the + user is not already an active member, then delegates the actual join to + `add_member_fun` (arity 3: `channel_id`, `user_id`, `role`). + """ + def join_channel(member_schema, conversation_schema, channel_id, user_id, add_member_fun) do + with {:ok, conversation} <- get_conversation_basic(conversation_schema, channel_id), + true <- conversation.type == "channel", + true <- conversation.is_public, + nil <- + from(cm in member_schema, + where: + cm.conversation_id == ^channel_id and cm.user_id == ^user_id and is_nil(cm.left_at) + ) + |> Repo.one() do + add_member_fun.(channel_id, user_id, "readonly") + else + false -> {:error, :not_public_channel} + %{__struct__: ^member_schema} -> {:error, :already_member} + error -> error + end + end + + # Authorization helpers ---------------------------------------------------- + + @doc "Manager authz: owner/admin/moderator. Errors with :unauthorized." + def ensure_can_manage_members(member_schema, conversation_id, actor_user_id) do + if MembershipAuthz.can_manage_members?( + get_member(member_schema, conversation_id, actor_user_id) + ) do + :ok + else + {:error, :unauthorized} + end + end + + defp ensure_can_remove_member(_member_schema, _conversation_id, _user_id, nil), do: :ok + defp ensure_can_remove_member(_member_schema, _conversation_id, user_id, user_id), do: :ok + + defp ensure_can_remove_member(member_schema, conversation_id, _user_id, actor_user_id), + do: ensure_can_manage_members(member_schema, conversation_id, actor_user_id) + + defp ensure_can_change_role(_member_schema, _conversation_id, nil), do: :ok + + defp ensure_can_change_role(member_schema, conversation_id, actor_user_id) do + if MembershipAuthz.can_change_role?(get_member(member_schema, conversation_id, actor_user_id)) do + :ok + else + {:error, :unauthorized} + end + end + + defp ensure_not_protected_target(_conversation_schema, _conversation_id, _user_id, nil), do: :ok + + defp ensure_not_protected_target(conversation_schema, conversation_id, user_id, actor_user_id) do + case get_conversation_basic(conversation_schema, conversation_id) do + {:ok, conversation} -> + if MembershipAuthz.protected_target?(conversation, user_id, actor_user_id) do + {:error, :cannot_modify_creator} + else + :ok + end + + _ -> + :ok + end + end + + defp get_conversation_basic(conversation_schema, conversation_id) do + case Repo.get(conversation_schema, conversation_id) do + nil -> {:error, :not_found} + conversation -> {:ok, conversation} + end + end + + # Federation publishing ---------------------------------------------------- + + defp maybe_publish_invite_acceptance(_conversation_id, _user_id, nil, _role), do: :ok + + defp maybe_publish_invite_acceptance(conversation_id, user_id, added_by_user_id, role) + when is_integer(conversation_id) and is_integer(user_id) and is_integer(added_by_user_id) do + if user_id != added_by_user_id do + Federation.publish_invite_state( + conversation_id, + user_id, + added_by_user_id, + "accepted", + role, + %{"source" => "local_member_add"} + ) + else + :ok + end + end + + defp maybe_publish_federation_membership(true, conversation_id, user_id, added_by_user_id, role) do + maybe_publish_invite_acceptance(conversation_id, user_id, added_by_user_id, role) + _ = Federation.publish_membership_state(conversation_id, user_id, "active", role) + :ok + end + + defp maybe_publish_federation_membership(false, _conversation_id, _user_id, _actor_id, _role), + do: :ok + + # NOTE (latent federation divergence): with a nil actor this no-ops while the + # caller still fires publish_membership_state with the new role, so mirrors + # would receive the membership role without the matching role.upsert/ + # assignment events. This is currently unreachable (no caller invokes a role + # change with a nil actor), so it is documented rather than reworked. + defp maybe_publish_role_assignment(_conv_schema, _conversation_id, _user_id, _new_role, nil, _), + do: :ok + + defp maybe_publish_role_assignment( + conversation_schema, + conversation_id, + user_id, + new_role, + actor_user_id, + role_def_fun + ) + when is_integer(conversation_id) and is_integer(user_id) and is_binary(new_role) and + is_integer(actor_user_id) do + with %{type: "channel", server_id: server_id} <- + Repo.get(conversation_schema, conversation_id), + true <- is_integer(server_id), + %Elektrine.Accounts.User{} = target_user <- Repo.get(Elektrine.Accounts.User, user_id), + %Elektrine.Accounts.User{} = actor_user <- + Repo.get(Elektrine.Accounts.User, actor_user_id), + %{} = role_definition <- role_def_fun.(new_role) do + role_upsert_payload = %{"role" => role_definition} + + role_assignment_payload = %{ + "assignment" => %{ + "role_id" => role_definition["id"], + "target" => %{ + "type" => "member", + "id" => FederationUtils.sender_payload(target_user)["uri"] + }, + "state" => "assigned" + } + } + + _ = + Federation.publish_extension_event( + conversation_id, + actor_user.id, + "role.upsert", + role_upsert_payload + ) + + _ = + Federation.publish_extension_event( + conversation_id, + actor_user.id, + "role.assignment.upsert", + role_assignment_payload + ) + end + + :ok + end + + defp maybe_publish_role_assignment(_conv_schema, _cid, _uid, _new_role, _actor_user_id, _fun), + do: :ok +end diff --git a/apps/elektrine/lib/elektrine/messaging/membership_authz.ex b/apps/elektrine/lib/elektrine/messaging/membership_authz.ex new file mode 100644 index 0000000..89007a9 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/membership_authz.ex @@ -0,0 +1,54 @@ +defmodule Elektrine.Messaging.MembershipAuthz do + @moduledoc """ + Shared, security-critical authorization predicates for conversation member + management. These role lists are the single source of truth for both the + chat (`Elektrine.Messaging.ChatConversations`) and social + (`Elektrine.Social.Conversations`) contexts; keeping them divergent in two + places previously caused a real authz bug. + + Role semantics: + + * Member **management** (add/remove/kick members) is permitted for the + roles `"owner"`, `"admin"`, and `"moderator"`. A moderator may move + members in and out but must not be able to grant or revoke roles. + + * Role **changes** (granting/revoking roles, promoting/demoting) are + stricter: only `"owner"` and `"admin"` may do so. This aligns with the + `admin?/2` gate used by `promote_to_admin/3`. + + * The conversation **creator** is protected: their role may only be changed + by the creator themselves (or by internal/federation callers with no + local actor). This prevents an admin from demoting or seizing the owner. + + All functions are pure predicates that operate on already-fetched data (no + Repo lookups). They use map patterns so they work for both member struct + types (`ChatConversationMember` and `ConversationMember`). A `nil` member + (no membership found) is treated as unauthorized. + """ + + @manage_roles ["owner", "admin", "moderator"] + @role_change_roles ["owner", "admin"] + + @doc """ + Returns `true` when the given member may manage conversation membership + (owner/admin/moderator). A `nil` member returns `false`. + """ + def can_manage_members?(%{role: role}) when role in @manage_roles, do: true + def can_manage_members?(_member), do: false + + @doc """ + Returns `true` when the given member may grant or revoke roles + (owner/admin only). A `nil` member returns `false`. + """ + def can_change_role?(%{role: role}) when role in @role_change_roles, do: true + def can_change_role?(_member), do: false + + @doc """ + Returns `true` when `target_user_id` is the conversation creator and the + actor is someone other than the creator, meaning the target's role is + protected from modification. + """ + def protected_target?(conversation, target_user_id, actor_user_id) do + conversation.creator_id == target_user_id and conversation.creator_id != actor_user_id + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/message_reaction.ex b/apps/elektrine/lib/elektrine/messaging/message_reaction.ex new file mode 100644 index 0000000..da6b9cc --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/message_reaction.ex @@ -0,0 +1,88 @@ +defmodule Elektrine.Social.MessageReaction do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "message_reactions" do + field(:emoji, :string) + field(:federated, :boolean, default: false) + # Custom emoji URL for federated reactions (like Akkoma's emoji reactions) + # Format: https://instance.com/emoji/custom/blobcat.png + field(:emoji_url, :string) + + belongs_to(:message, Elektrine.Social.Message) + belongs_to(:user, Elektrine.Accounts.User) + belongs_to(:remote_actor, Elektrine.ActivityPub.Actor) + + timestamps() + end + + @doc false + def changeset(reaction, attrs) do + reaction + |> cast(attrs, [:message_id, :user_id, :remote_actor_id, :emoji, :federated, :emoji_url]) + |> validate_required([:message_id, :emoji]) + # Increased for custom emoji shortcodes + |> validate_length(:emoji, min: 1, max: 100) + |> validate_emoji_url() + |> foreign_key_constraint(:message_id) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:remote_actor_id) + |> validate_user_or_remote_actor() + |> unique_constraint(:remote_actor_id, name: :message_reactions_federated_unique_index) + |> unique_constraint([:message_id, :user_id, :emoji]) + end + + # Validate emoji_url if provided + defp validate_emoji_url(changeset) do + case get_field(changeset, :emoji_url) do + nil -> + changeset + + url when is_binary(url) -> + if valid_emoji_url?(url) do + changeset + else + add_error(changeset, :emoji_url, "must be a valid HTTPS URL") + end + end + end + + defp valid_emoji_url?(url) do + case URI.parse(url) do + %URI{scheme: "https", host: host} when is_binary(host) -> true + # Allow HTTP for dev + %URI{scheme: "http", host: host} when is_binary(host) -> true + _ -> false + end + end + + # Ensure either user_id or remote_actor_id is present (but not both) + defp validate_user_or_remote_actor(changeset) do + user_id = get_field(changeset, :user_id) + remote_actor_id = get_field(changeset, :remote_actor_id) + + cond do + user_id && remote_actor_id -> + add_error(changeset, :user_id, "cannot have both user_id and remote_actor_id") + + !user_id && !remote_actor_id -> + add_error(changeset, :user_id, "must have either user_id or remote_actor_id") + + true -> + changeset + end + end + + @doc """ + Creates a changeset for adding a reaction to a message. + """ + def add_reaction_changeset(message_id, user_id, emoji) do + %__MODULE__{} + |> changeset(%{ + message_id: message_id, + user_id: user_id, + emoji: emoji + }) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/moderation.ex b/apps/elektrine/lib/elektrine/messaging/moderation.ex new file mode 100644 index 0000000..666447d --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/moderation.ex @@ -0,0 +1,644 @@ +defmodule Elektrine.Messaging.Moderation do + @moduledoc """ + Context for moderation features - timeouts, kicks, bans, and moderation logs. + """ + + import Ecto.Query, warn: false + alias Elektrine.Repo + + alias Elektrine.Messaging.{ + ChatConversation, + ChatModerationAction, + ChatUserTimeout, + CommunityBan, + CommunityFlair, + Federation, + ModerationAction, + UserTimeout + } + + alias Elektrine.Social.ConversationMember + + ## User Timeouts + + @doc """ + Creates a timeout for a user in a specific conversation or globally. + """ + def timeout_user(user_id, created_by_id, duration_seconds, opts \\ []) do + conversation_id = Keyword.get(opts, :conversation_id) + reason = Keyword.get(opts, :reason) + + timeout_until = DateTime.utc_now() |> DateTime.add(duration_seconds, :second) + + attrs = %{ + user_id: user_id, + conversation_id: conversation_id, + timeout_until: timeout_until, + reason: reason, + created_by_id: created_by_id + } + + # Check if timeout already exists and delete it first + remove_timeout(user_id, conversation_id) + + case %UserTimeout{} + |> UserTimeout.changeset(attrs) + |> Repo.insert() do + {:ok, timeout} -> + # Log the timeout action + ModerationAction.log_action("timeout", user_id, created_by_id, + conversation_id: conversation_id, + reason: reason, + duration: duration_seconds + ) + + # Broadcast timeout event + broadcast_timeout_event(:timeout_added, timeout) + + {:ok, timeout} + + error -> + error + end + end + + def timeout_chat_user(conversation_id, user_id, created_by_id, duration_seconds, reason \\ nil) + when is_integer(conversation_id) and is_integer(user_id) and is_integer(created_by_id) and + is_integer(duration_seconds) do + if chat_conversation_moderator?(conversation_id, created_by_id) do + do_timeout_chat_user(conversation_id, user_id, created_by_id, duration_seconds, reason) + else + {:error, :unauthorized} + end + end + + # Verifies the actor is a moderator/admin/owner of the chat conversation + # before allowing a timeout to be issued. + defp chat_conversation_moderator?(conversation_id, user_id) do + member = + from(cm in Elektrine.Messaging.ChatConversationMember, + where: + cm.conversation_id == ^conversation_id and cm.user_id == ^user_id and is_nil(cm.left_at) + ) + |> Repo.one() + + match?(%{role: role} when role in ["owner", "admin", "moderator"], member) + end + + defp do_timeout_chat_user(conversation_id, user_id, created_by_id, duration_seconds, reason) do + timeout_until = DateTime.utc_now() |> DateTime.add(duration_seconds, :second) + + from(t in ChatUserTimeout, + where: t.user_id == ^user_id and t.conversation_id == ^conversation_id + ) + |> Repo.delete_all() + + attrs = %{ + user_id: user_id, + conversation_id: conversation_id, + timeout_until: timeout_until, + reason: reason, + created_by_id: created_by_id + } + + case %ChatUserTimeout{} |> ChatUserTimeout.changeset(attrs) |> Repo.insert() do + {:ok, timeout} -> + %ChatModerationAction{} + |> ChatModerationAction.changeset(%{ + action_type: "timeout", + target_user_id: user_id, + moderator_id: created_by_id, + conversation_id: conversation_id, + reason: reason, + duration: duration_seconds, + details: %{} + }) + |> Repo.insert() + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{conversation_id}", + {:timeout_added, timeout} + ) + + {:ok, timeout} + + error -> + error + end + end + + @doc """ + Checks if a user is currently timed out in a conversation or globally. + """ + def user_timed_out?(user_id, conversation_id \\ nil) do + now = DateTime.utc_now() + + query = + from t in UserTimeout, + where: t.user_id == ^user_id and t.timeout_until > ^now + + query = + if conversation_id do + where(query, [t], t.conversation_id == ^conversation_id or is_nil(t.conversation_id)) + else + where(query, [t], is_nil(t.conversation_id)) + end + + Repo.exists?(query) + end + + @doc """ + Batch check if multiple users are timed out in a conversation. + Returns a map of user_id => boolean. + + This is much more efficient than calling user_timed_out?/2 for each user + as it uses a single database query. + """ + def users_timed_out(user_ids, conversation_id) when is_list(user_ids) do + if Enum.empty?(user_ids) do + %{} + else + if Repo.get(ChatConversation, conversation_id) do + timed_out_user_ids = + from(t in ChatUserTimeout, + where: + t.user_id in ^user_ids and + t.timeout_until > ^DateTime.utc_now() and + t.conversation_id == ^conversation_id, + select: t.user_id + ) + |> Repo.all() + |> MapSet.new() + + user_ids + |> Enum.map(fn user_id -> {user_id, MapSet.member?(timed_out_user_ids, user_id)} end) + |> Map.new() + else + do_users_timed_out(user_ids, conversation_id) + end + end + end + + defp do_users_timed_out(user_ids, conversation_id) do + now = DateTime.utc_now() + + timed_out_user_ids = + from(t in UserTimeout, + where: + t.user_id in ^user_ids and + t.timeout_until > ^now and + (t.conversation_id == ^conversation_id or is_nil(t.conversation_id)), + select: t.user_id + ) + |> Repo.all() + |> MapSet.new() + + user_ids + |> Enum.map(fn user_id -> {user_id, MapSet.member?(timed_out_user_ids, user_id)} end) + |> Map.new() + end + + @doc """ + Removes timeout for a user. + """ + def remove_timeout(user_id, conversation_id \\ nil) do + do_remove_timeout(user_id, conversation_id) + end + + defp do_remove_timeout(user_id, conversation_id) do + query = + from t in UserTimeout, + where: t.user_id == ^user_id + + query = + if conversation_id do + where(query, [t], t.conversation_id == ^conversation_id) + else + where(query, [t], is_nil(t.conversation_id)) + end + + # Get the timeout before deleting for broadcast + timeout = Repo.one(query |> preload([:user, :conversation])) + + result = Repo.delete_all(query) + + # Broadcast removal if timeout existed + if timeout do + broadcast_timeout_event(:timeout_removed, timeout) + end + + result + end + + def remove_chat_timeout(conversation_id, user_id) + when is_integer(conversation_id) and is_integer(user_id) do + query = + from t in ChatUserTimeout, + where: t.user_id == ^user_id and t.conversation_id == ^conversation_id + + timeout = Repo.one(query) + result = Repo.delete_all(query) + + if timeout do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{conversation_id}", + {:timeout_removed, user_id} + ) + end + + result + end + + @doc """ + Gets active timeouts for a user. + """ + def get_user_timeouts(user_id) do + now = DateTime.utc_now() + + from(t in UserTimeout, + where: t.user_id == ^user_id and t.timeout_until > ^now, + preload: [:conversation, :created_by], + order_by: [desc: t.inserted_at] + ) + |> Repo.all() + end + + @doc """ + Remove member from conversation (kick). + """ + def remove_member(conversation_id, user_id, current_user) do + # First remove from conversation + member = + from(cm in ConversationMember, + where: cm.conversation_id == ^conversation_id and cm.user_id == ^user_id + ) + |> Repo.one() + + case member do + nil -> + {:error, :not_found} + + member -> + member + |> ConversationMember.remove_member_changeset() + |> Repo.update() + |> case do + {:ok, result} -> + # Update member count + update_member_count(conversation_id) + + # Log the kick action + ModerationAction.log_action("kick", user_id, current_user.id, + conversation_id: conversation_id, + reason: "Kicked by admin" + ) + + # Broadcast kick event + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{conversation_id}", + {:user_kicked, %{user_id: user_id, conversation_id: conversation_id}} + ) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}", + {:kicked_from_conversation, %{conversation_id: conversation_id}} + ) + + # Also broadcast member_left event for consistency + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{conversation_id}", + {:member_left, %{user_id: user_id, conversation_id: conversation_id}} + ) + + {:ok, result} + + error -> + error + end + end + end + + ## Bans + + @doc """ + Bans a user from a community. + Only moderators can ban users. + """ + def ban_user_from_community( + community_id, + user_id, + banned_by_id, + reason \\ nil, + expires_at \\ nil + ) do + member = get_conversation_member(community_id, banned_by_id) + is_mod = member && member.role in ["moderator", "admin", "owner"] + + if is_mod do + # Don't allow banning moderators or owners + target_member = get_conversation_member(community_id, user_id) + + if target_member && target_member.role in ["owner", "admin", "moderator"] do + {:error, :cannot_ban_moderator} + else + attrs = %{ + conversation_id: community_id, + user_id: user_id, + banned_by_id: banned_by_id, + origin_domain: nil, + actor_payload: %{}, + metadata: %{}, + reason: reason, + expires_at: expires_at, + banned_at_remote: nil, + updated_at_remote: nil + } + + %CommunityBan{} + |> CommunityBan.changeset(attrs) + |> Repo.insert( + on_conflict: + {:replace, + [ + :banned_by_id, + :origin_domain, + :actor_payload, + :metadata, + :reason, + :expires_at, + :banned_at_remote, + :updated_at_remote, + :updated_at + ]}, + conflict_target: [:conversation_id, :user_id] + ) + |> case do + {:ok, ban} -> + maybe_remove_banned_member(target_member) + + _ = + Federation.publish_ban_state( + community_id, + user_id, + banned_by_id, + "active", + reason, + expires_at + ) + + _ = Federation.publish_membership_state(community_id, user_id, "banned") + {:ok, ban} + + error -> + error + end + end + else + {:error, :unauthorized} + end + end + + @doc """ + Unbans a user from a community. + Only moderators can unban users. + """ + def unban_user_from_community(community_id, user_id, unbanned_by_id) do + member = get_conversation_member(community_id, unbanned_by_id) + is_mod = member && member.role in ["moderator", "admin", "owner"] + + if is_mod do + from(b in CommunityBan, + where: b.conversation_id == ^community_id and b.user_id == ^user_id + ) + |> Repo.delete_all() + + _ = Federation.publish_ban_state(community_id, user_id, unbanned_by_id, "lifted") + _ = Federation.publish_membership_state(community_id, user_id, "left") + {:ok, :unbanned} + else + {:error, :unauthorized} + end + end + + @doc """ + Checks if a user is banned from a community. + """ + def user_banned?(community_id, user_id) do + now = DateTime.utc_now() + + from(b in CommunityBan, + where: + b.conversation_id == ^community_id and + b.user_id == ^user_id and + (is_nil(b.expires_at) or b.expires_at > ^now) + ) + |> Repo.exists?() + end + + @doc """ + Lists banned users for a community. + """ + def list_community_bans(community_id) do + now = DateTime.utc_now() + + from(b in CommunityBan, + where: + b.conversation_id == ^community_id and + (is_nil(b.expires_at) or b.expires_at > ^now), + preload: [:user, :banned_by] + ) + |> Repo.all() + end + + ## Moderation Log + + @doc """ + Gets moderation actions for a conversation or user. + """ + def get_moderation_log(opts \\ []) do + conversation_id = Keyword.get(opts, :conversation_id) + target_user_id = Keyword.get(opts, :target_user_id) + limit = Keyword.get(opts, :limit, 50) + + query = + from ma in ModerationAction, + preload: [:target_user, :moderator, :conversation], + order_by: [desc: ma.inserted_at], + limit: ^limit + + query = + if conversation_id do + where(query, [ma], ma.conversation_id == ^conversation_id) + else + query + end + + query = + if target_user_id do + where(query, [ma], ma.target_user_id == ^target_user_id) + else + query + end + + Repo.all(query) + end + + @doc """ + Log a moderation action. + """ + def log_moderation_action(action_type, target_user_id, moderator_id, opts \\ []) do + ModerationAction.log_action(action_type, target_user_id, moderator_id, opts) + end + + ## Community Flairs + + @doc """ + Lists all flairs for a community. + """ + def list_community_flairs(community_id) do + from(f in CommunityFlair, + where: f.community_id == ^community_id, + order_by: [asc: f.position, asc: f.name] + ) + |> Repo.all() + end + + @doc """ + Lists enabled flairs for a community. + """ + def list_enabled_community_flairs(community_id) do + from(f in CommunityFlair, + where: f.community_id == ^community_id and f.is_enabled == true, + order_by: [asc: f.position, asc: f.name] + ) + |> Repo.all() + end + + @doc """ + Gets a single community flair. + """ + def get_community_flair!(id) do + Repo.get!(CommunityFlair, id) + end + + @doc """ + Lists flairs available to a user for a community. + """ + def list_available_flairs(community_id, user_id) do + # Check if user is a moderator + member = get_conversation_member(community_id, user_id) + is_mod = member && member.role in ["moderator", "admin", "owner"] + + query = + from(f in CommunityFlair, + where: f.community_id == ^community_id and f.is_enabled == true + ) + + # If not a mod, exclude mod-only flairs + query = + if is_mod do + query + else + from f in query, where: f.is_mod_only == false + end + + query + |> order_by([f], asc: f.position, asc: f.name) + |> Repo.all() + end + + @doc """ + Gets a single flair. + """ + def get_flair!(id), do: Repo.get!(CommunityFlair, id) + + @doc """ + Creates a flair for a community. + """ + def create_community_flair(attrs \\ %{}) do + %CommunityFlair{} + |> CommunityFlair.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a flair. + """ + def update_community_flair(%CommunityFlair{} = flair, attrs) do + flair + |> CommunityFlair.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a flair. + """ + def delete_community_flair(%CommunityFlair{} = flair) do + Repo.delete(flair) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking flair changes. + """ + def change_community_flair(%CommunityFlair{} = flair, attrs \\ %{}) do + CommunityFlair.changeset(flair, attrs) + end + + ## Private Helpers + + defp get_conversation_member(conversation_id, user_id) do + from(cm in ConversationMember, + where: + cm.conversation_id == ^conversation_id and + cm.user_id == ^user_id and + is_nil(cm.left_at) + ) + |> Repo.one() + end + + defp update_member_count(conversation_id) do + count = + from(cm in ConversationMember, + where: cm.conversation_id == ^conversation_id and is_nil(cm.left_at), + select: count() + ) + |> Repo.one() + + from(c in Elektrine.Social.Conversation, where: c.id == ^conversation_id) + |> Repo.update_all(set: [member_count: count]) + end + + defp maybe_remove_banned_member(%ConversationMember{} = member) do + member + |> ConversationMember.remove_member_changeset() + |> Repo.update() + + update_member_count(member.conversation_id) + :ok + end + + defp maybe_remove_banned_member(_member), do: :ok + + defp broadcast_timeout_event(event_type, timeout) do + # Broadcast to conversation if it's conversation-specific + if timeout.conversation_id do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{timeout.conversation_id}", + {event_type, timeout} + ) + end + + # Also broadcast globally for the user + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{timeout.user_id}", + {event_type, timeout} + ) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/moderation_action.ex b/apps/elektrine/lib/elektrine/messaging/moderation_action.ex new file mode 100644 index 0000000..079de95 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/moderation_action.ex @@ -0,0 +1,109 @@ +defmodule Elektrine.Messaging.ModerationAction do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + alias Elektrine.Accounts.User + alias Elektrine.Messaging.Federation + alias Elektrine.Messaging.Federation.Utils, as: FederationUtils + alias Elektrine.Repo + + schema "moderation_actions" do + field :action_type, :string + field :reason, :string + field :duration, :integer + field :details, :map + + belongs_to :target_user, Elektrine.Accounts.User + belongs_to :moderator, Elektrine.Accounts.User + belongs_to :conversation, Elektrine.Social.Conversation + + timestamps(type: :utc_datetime) + end + + @valid_actions ~w(timeout kick delete_message ban warn) + + @doc false + def changeset(moderation_action, attrs) do + moderation_action + |> cast(attrs, [ + :action_type, + :target_user_id, + :moderator_id, + :conversation_id, + :reason, + :duration, + :details + ]) + |> validate_required([:action_type, :target_user_id, :moderator_id]) + |> validate_inclusion(:action_type, @valid_actions) + end + + def log_action(action_type, target_user_id, moderator_id, opts \\ []) do + attrs = %{ + action_type: action_type, + target_user_id: target_user_id, + moderator_id: moderator_id, + conversation_id: Keyword.get(opts, :conversation_id), + reason: Keyword.get(opts, :reason), + duration: Keyword.get(opts, :duration), + details: Keyword.get(opts, :details, %{}) + } + + %__MODULE__{} + |> changeset(attrs) + |> Elektrine.Repo.insert() + |> case do + {:ok, action} -> + maybe_publish_federation_action(action, moderator_id, target_user_id) + {:ok, action} + + error -> + error + end + end + + defp maybe_publish_federation_action( + %__MODULE__{conversation_id: conversation_id, action_type: action_type} = action, + moderator_id, + target_user_id + ) + when is_integer(conversation_id) and is_integer(moderator_id) and + is_integer(target_user_id) do + with %User{} = target_user <- Repo.get(User, target_user_id), + action_kind when is_binary(action_kind) <- action_kind(action_type) do + payload = %{ + "action" => %{ + "id" => "moderation:#{action.id}", + "kind" => action_kind, + "target" => %{ + "type" => "member", + "id" => FederationUtils.sender_payload(target_user)["uri"] + }, + "occurred_at" => DateTime.to_iso8601(action.inserted_at || DateTime.utc_now()), + "duration_seconds" => action.duration, + "reason" => action.reason + } + } + + _ = + Federation.publish_extension_event( + conversation_id, + moderator_id, + "moderation.action.recorded", + payload + ) + end + + :ok + end + + defp maybe_publish_federation_action(_action, _moderator_id, _target_user_id), do: :ok + + defp action_kind("timeout"), do: "timeout" + defp action_kind("kick"), do: "kick" + defp action_kind("delete_message"), do: "delete_message" + defp action_kind("ban"), do: "ban" + defp action_kind("warn"), do: "warn" + defp action_kind(_action_type), do: nil +end diff --git a/apps/elektrine/lib/elektrine/messaging/moderation_tools.ex b/apps/elektrine/lib/elektrine/messaging/moderation_tools.ex new file mode 100644 index 0000000..15a2ffe --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/moderation_tools.ex @@ -0,0 +1,714 @@ +defmodule Elektrine.Messaging.ModerationTools do + @moduledoc """ + Comprehensive moderation tools for community management. + """ + import Ecto.Query + alias Elektrine.Repo + + alias Elektrine.Messaging.{ + AutoModRule, + ModeratorNote, + UserPostTimestamp, + UserWarning + } + + alias Elektrine.Social.{Conversation, Message} + + # ====== THREAD LOCKING ====== + + @doc """ + Locks a thread to prevent new replies. + """ + def lock_thread(message_id, moderator_id, reason \\ nil) do + message = Repo.get!(Message, message_id) + member = Elektrine.Messaging.get_conversation_member(message.conversation_id, moderator_id) + is_mod = member && member.role in ["moderator", "admin", "owner"] + user = Repo.get(Elektrine.Accounts.User, moderator_id) + is_admin = user && user.is_admin + + if is_mod || is_admin do + case message + |> Ecto.Changeset.change(%{ + locked_at: DateTime.utc_now() |> DateTime.truncate(:second), + locked_by_id: moderator_id, + lock_reason: reason + }) + |> Repo.update() do + {:ok, updated_message} -> + log_moderation_action( + message.conversation_id, + moderator_id, + nil, + message_id, + "lock", + reason + ) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{message.conversation_id}", + {:thread_locked, updated_message} + ) + + {:ok, updated_message} + + error -> + error + end + else + {:error, :unauthorized} + end + end + + @doc """ + Unlocks a locked thread. + """ + def unlock_thread(message_id, moderator_id) do + message = Repo.get!(Message, message_id) + member = Elektrine.Messaging.get_conversation_member(message.conversation_id, moderator_id) + is_mod = member && member.role in ["moderator", "admin", "owner"] + user = Repo.get(Elektrine.Accounts.User, moderator_id) + is_admin = user && user.is_admin + + if is_mod || is_admin do + case message + |> Ecto.Changeset.change(%{ + locked_at: nil, + locked_by_id: nil, + lock_reason: nil + }) + |> Repo.update() do + {:ok, updated_message} -> + log_moderation_action( + message.conversation_id, + moderator_id, + nil, + message_id, + "unlock", + nil + ) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{message.conversation_id}", + {:thread_unlocked, updated_message} + ) + + {:ok, updated_message} + + error -> + error + end + else + {:error, :unauthorized} + end + end + + @doc """ + Checks if a thread is locked. + """ + def thread_locked?(%Message{locked_at: nil}), do: false + def thread_locked?(%Message{locked_at: _}), do: true + + # ====== USER TIMEOUTS/MUTES ====== + + @doc """ + Timeout/mute a user in a community. + """ + def timeout_user(conversation_id, user_id, moderator_id, duration_minutes, reason \\ nil) do + member = Elektrine.Messaging.get_conversation_member(conversation_id, moderator_id) + is_mod = member && member.role in ["moderator", "admin", "owner"] + user = Repo.get(Elektrine.Accounts.User, moderator_id) + is_admin = user && user.is_admin + + if is_mod || is_admin do + expires_at = DateTime.add(DateTime.utc_now(), duration_minutes * 60, :second) + + # Delete any existing timeout first + from(t in Elektrine.Messaging.UserTimeout, + where: t.conversation_id == ^conversation_id and t.user_id == ^user_id + ) + |> Repo.delete_all() + + # Create new timeout + attrs = %{ + user_id: user_id, + conversation_id: conversation_id, + timeout_until: expires_at, + reason: reason, + created_by_id: moderator_id + } + + case %Elektrine.Messaging.UserTimeout{} + |> Elektrine.Messaging.UserTimeout.changeset(attrs) + |> Repo.insert() do + {:ok, timeout} -> + log_moderation_action(conversation_id, moderator_id, user_id, nil, "timeout", reason, %{ + duration_minutes: duration_minutes + }) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{conversation_id}", + {:user_timed_out, timeout} + ) + + {:ok, timeout} + + error -> + error + end + else + {:error, :unauthorized} + end + end + + @doc """ + Remove a timeout from a user. + """ + def remove_timeout(conversation_id, user_id, moderator_id) do + member = Elektrine.Messaging.get_conversation_member(conversation_id, moderator_id) + is_mod = member && member.role in ["moderator", "admin", "owner"] + user = Repo.get(Elektrine.Accounts.User, moderator_id) + is_admin = user && user.is_admin + + if is_mod || is_admin do + {count, _} = + from(t in Elektrine.Messaging.UserTimeout, + where: t.conversation_id == ^conversation_id and t.user_id == ^user_id + ) + |> Repo.delete_all() + + if count > 0 do + log_moderation_action(conversation_id, moderator_id, user_id, nil, "remove_timeout", nil) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{conversation_id}", + {:timeout_removed, user_id} + ) + + {:ok, :removed} + else + {:error, :not_found} + end + else + {:error, :unauthorized} + end + end + + @doc """ + Check if a user is currently timed out in a community. + """ + def user_timed_out?(conversation_id, user_id) do + now = DateTime.utc_now() + + Repo.exists?( + from t in Elektrine.Messaging.UserTimeout, + where: + t.conversation_id == ^conversation_id and + t.user_id == ^user_id and + t.timeout_until > ^now + ) + end + + # ====== USER WARNINGS ====== + + @doc """ + Issue a warning to a user. + """ + def warn_user(conversation_id, user_id, moderator_id, reason, opts \\ []) do + member = Elektrine.Messaging.get_conversation_member(conversation_id, moderator_id) + is_mod = member && member.role in ["moderator", "admin", "owner"] + user = Repo.get(Elektrine.Accounts.User, moderator_id) + is_admin = user && user.is_admin + + if is_mod || is_admin do + severity = Keyword.get(opts, :severity, "low") + message_id = Keyword.get(opts, :message_id) + + attrs = %{ + conversation_id: conversation_id, + user_id: user_id, + warned_by_id: moderator_id, + reason: reason, + severity: severity, + related_message_id: message_id + } + + case %UserWarning{} + |> UserWarning.changeset(attrs) + |> Repo.insert() do + {:ok, warning} -> + log_moderation_action( + conversation_id, + moderator_id, + user_id, + message_id, + "warn", + reason, + %{severity: severity} + ) + + # Check for auto-escalation (3+ warnings = temp ban) + warning_count = count_user_warnings(conversation_id, user_id) + + if warning_count >= 3 do + # Auto-ban for 7 days + Elektrine.Messaging.ban_user_from_community( + conversation_id, + user_id, + moderator_id, + "Auto-banned: 3 warnings received", + DateTime.add(DateTime.utc_now(), 7 * 24 * 60 * 60, :second) + ) + end + + {:ok, warning} + + error -> + error + end + else + {:error, :unauthorized} + end + end + + @doc """ + Get warnings for a user in a community. + """ + def list_user_warnings(conversation_id, user_id) do + from(w in UserWarning, + where: w.conversation_id == ^conversation_id and w.user_id == ^user_id, + order_by: [desc: w.inserted_at], + preload: [:warned_by, :related_message] + ) + |> Repo.all() + end + + @doc """ + Count unacknowledged warnings for a user. + """ + def count_user_warnings(conversation_id, user_id) do + from(w in UserWarning, + where: + w.conversation_id == ^conversation_id and + w.user_id == ^user_id and + is_nil(w.acknowledged_at) + ) + |> Repo.aggregate(:count) + end + + # ====== MODERATOR NOTES ====== + + @doc """ + Add a private moderator note about a user. + """ + def add_moderator_note( + conversation_id, + target_user_id, + moderator_id, + note, + is_important \\ false + ) do + # Check if user is a community moderator or site admin + member = Elektrine.Messaging.get_conversation_member(conversation_id, moderator_id) + is_mod = member && member.role in ["moderator", "admin", "owner"] + user = Repo.get(Elektrine.Accounts.User, moderator_id) + is_admin = user && user.is_admin + + if is_mod || is_admin do + %ModeratorNote{} + |> ModeratorNote.changeset(%{ + conversation_id: conversation_id, + target_user_id: target_user_id, + created_by_id: moderator_id, + note: note, + is_important: is_important + }) + |> Repo.insert() + else + {:error, :unauthorized} + end + end + + @doc """ + Get all moderator notes for a user in a community. + """ + def list_moderator_notes(conversation_id, target_user_id) do + from(n in ModeratorNote, + where: n.conversation_id == ^conversation_id and n.target_user_id == ^target_user_id, + order_by: [desc: n.is_important, desc: n.inserted_at], + preload: [:created_by] + ) + |> Repo.all() + end + + @doc """ + Delete a moderator note. + """ + def delete_moderator_note(note_id, moderator_id) do + note = Repo.get!(ModeratorNote, note_id) + member = Elektrine.Messaging.get_conversation_member(note.conversation_id, moderator_id) + is_mod = member && member.role in ["moderator", "admin", "owner"] + user = Repo.get(Elektrine.Accounts.User, moderator_id) + is_admin = user && user.is_admin + + if is_mod || is_admin do + Repo.delete(note) + else + {:error, :unauthorized} + end + end + + # ====== AUTO-MODERATION RULES ====== + + @doc """ + Create an auto-moderation rule. + """ + def create_auto_mod_rule(attrs) do + %AutoModRule{} + |> AutoModRule.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Update an auto-moderation rule. + """ + def update_auto_mod_rule(rule, attrs) do + rule + |> AutoModRule.changeset(attrs) + |> Repo.update() + end + + @doc """ + List auto-moderation rules for a community. + """ + def list_auto_mod_rules(conversation_id) do + from(r in AutoModRule, + where: r.conversation_id == ^conversation_id, + order_by: [desc: r.inserted_at], + preload: [:created_by] + ) + |> Repo.all() + end + + @doc """ + Check content against auto-mod rules. + Returns {:ok, :allowed} | {:flagged, rule} | {:blocked, rule} + """ + def check_auto_mod_rules(conversation_id, content) do + rules = + from(r in AutoModRule, + where: r.conversation_id == ^conversation_id and r.enabled == true + ) + |> Repo.all() + + Enum.reduce_while(rules, {:ok, :allowed}, fn rule, _acc -> + if matches_rule?(content, rule) do + case rule.action do + "remove" -> {:halt, {:blocked, rule}} + "hold_for_review" -> {:halt, {:hold, rule}} + "flag" -> {:halt, {:flagged, rule}} + _ -> {:cont, {:ok, :allowed}} + end + else + {:cont, {:ok, :allowed}} + end + end) + end + + defp matches_rule?(content, %AutoModRule{rule_type: "keyword", pattern: pattern}) do + keywords = String.split(pattern, ",") |> Enum.map(&String.trim/1) + lower_content = String.downcase(content) + + Enum.any?(keywords, fn keyword -> + String.contains?(lower_content, String.downcase(keyword)) + end) + end + + defp matches_rule?(content, %AutoModRule{rule_type: "link_domain", pattern: pattern}) do + domains = String.split(pattern, ",") |> Enum.map(&String.trim/1) + + Enum.any?(domains, fn domain -> + String.contains?(content, domain) + end) + end + + defp matches_rule?(_content, _rule), do: false + + # ====== SLOW MODE ====== + + @doc """ + Check if a user can post in slow mode. + Returns {:ok, :allowed} | {:error, :slow_mode_active, seconds_remaining} + """ + def check_slow_mode(conversation_id, user_id) do + conversation = Repo.get!(Conversation, conversation_id) + + if conversation.slow_mode_seconds > 0 do + case Repo.get_by(UserPostTimestamp, conversation_id: conversation_id, user_id: user_id) do + nil -> + {:ok, :allowed} + + timestamp -> + time_since_last = DateTime.diff(DateTime.utc_now(), timestamp.last_post_at, :second) + + if time_since_last >= conversation.slow_mode_seconds do + {:ok, :allowed} + else + {:error, :slow_mode_active, conversation.slow_mode_seconds - time_since_last} + end + end + else + {:ok, :allowed} + end + end + + @doc """ + Update user's last post timestamp for slow mode. + """ + def update_post_timestamp(conversation_id, user_id) do + %UserPostTimestamp{} + |> UserPostTimestamp.changeset(%{ + conversation_id: conversation_id, + user_id: user_id, + last_post_at: DateTime.utc_now() + }) + |> Repo.insert( + on_conflict: {:replace, [:last_post_at]}, + conflict_target: [:conversation_id, :user_id] + ) + end + + # ====== APPROVAL QUEUE ====== + + @doc """ + Check if a post needs approval. + """ + def needs_approval?(conversation_id, user_id) do + conversation = Repo.get!(Conversation, conversation_id) + + if conversation.approval_mode_enabled do + # Check if user has enough approved posts + approved_count = + from(m in Message, + where: + m.conversation_id == ^conversation_id and + m.sender_id == ^user_id and + m.approval_status == "approved" + ) + |> Repo.aggregate(:count) + + approved_count < conversation.approval_threshold_posts + else + false + end + end + + @doc """ + Approve a post. + """ + def approve_post(message_id, moderator_id) do + message = Repo.get!(Message, message_id) + member = Elektrine.Messaging.get_conversation_member(message.conversation_id, moderator_id) + is_mod = member && member.role in ["moderator", "admin", "owner"] + user = Repo.get(Elektrine.Accounts.User, moderator_id) + is_admin = user && user.is_admin + + if is_mod || is_admin do + case message + |> Ecto.Changeset.change(%{ + approval_status: "approved", + approved_by_id: moderator_id, + approved_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.update() do + {:ok, updated_message} -> + log_moderation_action( + message.conversation_id, + moderator_id, + message.sender_id, + message_id, + "approve", + nil + ) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{message.conversation_id}", + {:post_approved, updated_message} + ) + + {:ok, updated_message} + + error -> + error + end + else + {:error, :unauthorized} + end + end + + @doc """ + Reject a post. + """ + def reject_post(message_id, moderator_id, reason \\ nil) do + message = Repo.get!(Message, message_id) + member = Elektrine.Messaging.get_conversation_member(message.conversation_id, moderator_id) + is_mod = member && member.role in ["moderator", "admin", "owner"] + user = Repo.get(Elektrine.Accounts.User, moderator_id) + is_admin = user && user.is_admin + + if is_mod || is_admin do + case message + |> Ecto.Changeset.change(%{ + approval_status: "rejected", + approved_by_id: moderator_id, + approved_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.update() do + {:ok, updated_message} -> + log_moderation_action( + message.conversation_id, + moderator_id, + message.sender_id, + message_id, + "reject", + reason + ) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{message.conversation_id}", + {:post_rejected, updated_message} + ) + + {:ok, updated_message} + + error -> + error + end + else + {:error, :unauthorized} + end + end + + @doc """ + List pending posts awaiting approval. + """ + def list_pending_posts(conversation_id) do + from(m in Message, + where: m.conversation_id == ^conversation_id and m.approval_status == "pending", + order_by: [asc: m.inserted_at], + preload: [:sender, :flair, :poll, :link_preview, sender: :profile, poll: [options: []]] + ) + |> Repo.all() + end + + # ====== MODERATION LOG ====== + + @doc """ + Log a moderation action to the audit trail. + """ + def log_moderation_action( + conversation_id, + moderator_id, + target_user_id, + target_message_id, + action_type, + reason, + metadata \\ %{} + ) do + %Elektrine.Messaging.ModerationAction{} + |> Elektrine.Messaging.ModerationAction.changeset(%{ + conversation_id: conversation_id, + moderator_id: moderator_id, + target_user_id: target_user_id, + target_message_id: target_message_id, + action_type: action_type, + reason: reason, + details: metadata + }) + |> Repo.insert() + end + + @doc """ + Get moderation log for a community. + """ + def get_moderation_log(conversation_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + action_type = Keyword.get(opts, :action_type) + + query = + from(a in Elektrine.Messaging.ModerationAction, + where: a.conversation_id == ^conversation_id, + order_by: [desc: a.inserted_at], + limit: ^limit, + preload: [:moderator, :target_user] + ) + + query = + if action_type do + from a in query, where: a.action_type == ^action_type + else + query + end + + Repo.all(query) + end + + # ====== COMMUNITY SETTINGS ====== + + @doc """ + Update slow mode for a community. + """ + def update_slow_mode(conversation_id, moderator_id, seconds) do + conversation = Repo.get!(Conversation, conversation_id) + member = Elektrine.Messaging.get_conversation_member(conversation_id, moderator_id) + is_mod = member && member.role in ["moderator", "admin", "owner"] + user = Repo.get(Elektrine.Accounts.User, moderator_id) + is_admin = user && user.is_admin + + if is_mod || is_admin do + conversation + |> Ecto.Changeset.change(%{slow_mode_seconds: seconds}) + |> Repo.update() + else + {:error, :unauthorized} + end + end + + @doc """ + Toggle approval mode for a community. + """ + def update_approval_mode(conversation_id, moderator_id, enabled, threshold \\ 3) do + conversation = Repo.get!(Conversation, conversation_id) + member = Elektrine.Messaging.get_conversation_member(conversation_id, moderator_id) + is_mod = member && member.role in ["moderator", "admin", "owner"] + user = Repo.get(Elektrine.Accounts.User, moderator_id) + is_admin = user && user.is_admin + + if is_mod || is_admin do + conversation + |> Ecto.Changeset.change(%{ + approval_mode_enabled: enabled, + approval_threshold_posts: threshold + }) + |> Repo.update() + else + {:error, :unauthorized} + end + end + + # ====== BULK ACTIONS ====== + + @doc """ + Bulk delete messages by IDs. + """ + def bulk_delete_messages(message_ids, moderator_id) when is_list(message_ids) do + Enum.map(message_ids, fn message_id -> + Elektrine.Messaging.delete_message(message_id, moderator_id) + end) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/moderator_note.ex b/apps/elektrine/lib/elektrine/messaging/moderator_note.ex new file mode 100644 index 0000000..cf556ee --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/moderator_note.ex @@ -0,0 +1,29 @@ +defmodule Elektrine.Messaging.ModeratorNote do + @moduledoc """ + Schema for private notes about users visible only to moderators. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "moderator_notes" do + field :note, :string + field :is_important, :boolean, default: false + + belongs_to :conversation, Elektrine.Social.Conversation + belongs_to :target_user, Elektrine.Accounts.User + belongs_to :created_by, Elektrine.Accounts.User + + timestamps(updated_at: false) + end + + @doc false + def changeset(note, attrs) do + note + |> cast(attrs, [:conversation_id, :target_user_id, :created_by_id, :note, :is_important]) + |> validate_required([:conversation_id, :target_user_id, :created_by_id, :note]) + |> validate_length(:note, min: 1, max: 1000) + |> foreign_key_constraint(:conversation_id) + |> foreign_key_constraint(:target_user_id) + |> foreign_key_constraint(:created_by_id) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/rate_limiter.ex b/apps/elektrine/lib/elektrine/messaging/rate_limiter.ex new file mode 100644 index 0000000..e80440a --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/rate_limiter.ex @@ -0,0 +1,208 @@ +defmodule Elektrine.Messaging.RateLimiter do + @moduledoc """ + Rate limiting for messaging system to prevent spam and abuse. + """ + + use GenServer + + # Rate limits per user + @message_limit_per_minute 60 + @reaction_limit_per_minute 100 + @timeline_post_limit_per_hour 20 + @cross_context_promotion_limit_per_hour 5 + @dm_creation_limit_per_hour 50 + @webhook_execution_limit_per_minute 30 + + def start_link(_opts) do + GenServer.start_link(__MODULE__, %{}, name: __MODULE__) + end + + def init(state) do + # Clean up old entries every minute + :timer.send_interval(60_000, self(), :cleanup) + {:ok, state} + end + + @doc """ + Checks if a user can send a message. + """ + def can_send_message?(user_id) do + GenServer.call(__MODULE__, {:check_rate_limit, user_id, :message, @message_limit_per_minute}) + end + + @doc """ + Checks if a user can add a reaction. + """ + def can_add_reaction?(user_id) do + GenServer.call( + __MODULE__, + {:check_rate_limit, user_id, :reaction, @reaction_limit_per_minute} + ) + end + + @doc """ + Checks if an incoming webhook can post another message. + """ + def can_execute_webhook?(webhook_id) do + GenServer.call( + __MODULE__, + {:check_rate_limit, {:webhook, webhook_id}, :webhook_execution, + @webhook_execution_limit_per_minute} + ) + end + + @doc """ + Records an incoming webhook execution for rate limiting. + """ + def record_webhook_execution(webhook_id) do + GenServer.cast(__MODULE__, {:record_action, {:webhook, webhook_id}, :webhook_execution}) + end + + @doc """ + Records a message send for rate limiting. + """ + def record_message(user_id) do + GenServer.cast(__MODULE__, {:record_action, user_id, :message}) + end + + @doc """ + Records a reaction for rate limiting. + """ + def record_reaction(user_id) do + GenServer.cast(__MODULE__, {:record_action, user_id, :reaction}) + end + + @doc """ + Checks if a user can create a timeline post. + """ + def can_create_timeline_post?(user_id) do + GenServer.call( + __MODULE__, + {:check_rate_limit_hourly, user_id, :timeline_post, @timeline_post_limit_per_hour} + ) + end + + @doc """ + Checks if a user can perform cross-context promotions. + """ + def can_promote_cross_context?(user_id) do + GenServer.call( + __MODULE__, + {:check_rate_limit_hourly, user_id, :cross_promotion, + @cross_context_promotion_limit_per_hour} + ) + end + + @doc """ + Checks if a user can create new DM conversations. + """ + def can_create_dm?(user_id) do + GenServer.call( + __MODULE__, + {:check_rate_limit_hourly, user_id, :dm_creation, @dm_creation_limit_per_hour} + ) + end + + @doc """ + Records a timeline post creation. + """ + def record_timeline_post(user_id) do + GenServer.cast(__MODULE__, {:record_action_hourly, user_id, :timeline_post}) + end + + @doc """ + Records a cross-context promotion. + """ + def record_cross_promotion(user_id) do + GenServer.cast(__MODULE__, {:record_action_hourly, user_id, :cross_promotion}) + end + + @doc """ + Records a DM creation. + """ + def record_dm_creation(user_id) do + GenServer.cast(__MODULE__, {:record_action_hourly, user_id, :dm_creation}) + end + + # GenServer callbacks + + def handle_call({:check_rate_limit, user_id, action_type, limit}, _from, state) do + key = {user_id, action_type} + current_minute = div(System.system_time(:second), 60) + + user_actions = Map.get(state, key, %{}) + current_count = Map.get(user_actions, current_minute, 0) + + can_proceed = current_count < limit + {:reply, can_proceed, state} + end + + def handle_call({:check_rate_limit_hourly, user_id, action_type, limit}, _from, state) do + key = {user_id, action_type, :hourly} + current_hour = div(System.system_time(:second), 3600) + + user_actions = Map.get(state, key, %{}) + current_count = Map.get(user_actions, current_hour, 0) + + can_proceed = current_count < limit + {:reply, can_proceed, state} + end + + def handle_cast({:record_action, user_id, action_type}, state) do + key = {user_id, action_type} + current_minute = div(System.system_time(:second), 60) + + user_actions = Map.get(state, key, %{}) + current_count = Map.get(user_actions, current_minute, 0) + + updated_actions = Map.put(user_actions, current_minute, current_count + 1) + updated_state = Map.put(state, key, updated_actions) + + {:noreply, updated_state} + end + + def handle_cast({:record_action_hourly, user_id, action_type}, state) do + key = {user_id, action_type, :hourly} + current_hour = div(System.system_time(:second), 3600) + + user_actions = Map.get(state, key, %{}) + current_count = Map.get(user_actions, current_hour, 0) + + updated_actions = Map.put(user_actions, current_hour, current_count + 1) + updated_state = Map.put(state, key, updated_actions) + + {:noreply, updated_state} + end + + def handle_info(:cleanup, state) do + current_minute = div(System.system_time(:second), 60) + current_hour = div(System.system_time(:second), 3600) + # Keep 5 minutes of history + cutoff_minute = current_minute - 5 + # Keep 2 hours of history + cutoff_hour = current_hour - 2 + + cleaned_state = + Enum.reduce(state, %{}, fn {key, user_actions}, acc -> + cutoff = + case key do + {_user_id, _action_type, :hourly} -> cutoff_hour + _ -> cutoff_minute + end + + cleaned_actions = + Enum.filter(user_actions, fn {time_unit, _count} -> + time_unit > cutoff + end) + |> Enum.into(%{}) + + if map_size(cleaned_actions) > 0 do + Map.put(acc, key, cleaned_actions) + else + acc + end + end) + + {:noreply, cleaned_state} + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/reactions.ex b/apps/elektrine/lib/elektrine/messaging/reactions.ex new file mode 100644 index 0000000..acc64b8 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/reactions.ex @@ -0,0 +1,183 @@ +defmodule Elektrine.Messaging.Reactions do + @moduledoc "Context for managing message reactions.\n" + import Ecto.Query, warn: false + alias Elektrine.ActivityPub + alias Elektrine.Messaging.RateLimiter + alias Elektrine.Repo + alias Elektrine.Social.MessageReaction + @doc "Adds a reaction to a message.\n" + def add_reaction(message_id, user_id, emoji) do + if RateLimiter.can_add_reaction?(user_id) do + MessageReaction.add_reaction_changeset(message_id, user_id, emoji) + |> Repo.insert() + |> case do + {:ok, reaction} -> + RateLimiter.record_reaction(user_id) + broadcast_reaction_add(reaction) + notify_post_owner_of_reaction(reaction) + federate_emoji_reaction(reaction) + {:ok, reaction} + + {:error, %{errors: [emoji: {_, constraint: :unique, constraint_name: _}]}} -> + remove_reaction(message_id, user_id, emoji) + + error -> + error + end + else + {:error, :rate_limited} + end + end + + @doc "Removes a reaction from a message.\n" + def remove_reaction(message_id, user_id, emoji) do + case Repo.get_by(MessageReaction, message_id: message_id, user_id: user_id, emoji: emoji) do + nil -> + {:error, :not_found} + + reaction -> + case Repo.delete(reaction) do + {:ok, deleted_reaction} -> + broadcast_reaction_remove(deleted_reaction) + unfederate_emoji_reaction(deleted_reaction, user_id, emoji) + {:ok, deleted_reaction} + + error -> + error + end + end + end + + defp broadcast_reaction_add(reaction) do + reaction = Repo.preload(reaction, [:message, :user, :remote_actor]) + + if reaction.message.conversation_id do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{reaction.message.conversation_id}", + {:reaction_added, reaction} + ) + end + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "post:#{reaction.message_id}", + {:post_reaction_added, reaction} + ) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "timeline:public", + {:post_reaction_added, reaction} + ) + end + + defp broadcast_reaction_remove(reaction) do + reaction = Repo.preload(reaction, [:message, :user, :remote_actor]) + + if reaction.message.conversation_id do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{reaction.message.conversation_id}", + {:reaction_removed, reaction} + ) + end + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "post:#{reaction.message_id}", + {:post_reaction_removed, reaction} + ) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "timeline:public", + {:post_reaction_removed, reaction} + ) + end + + defp notify_post_owner_of_reaction(reaction) do + reaction = Repo.preload(reaction, [:message, :user]) + message = reaction.message + reactor = reaction.user + + if message && message.sender_id && reactor do + source_type = + cond do + message.conversation_id -> "post" + message.federated -> "post" + true -> "post" + end + + Elektrine.Notifications.notify_reaction( + message.sender_id, + reactor, + reaction.emoji, + source_type, + message.id + ) + end + end + + defp federate_emoji_reaction(reaction) do + reaction = Repo.preload(reaction, [:message, :user]) + message = reaction.message + + if message.federated && message.activitypub_id && reaction.user do + user = reaction.user + + emoji_react = + Elektrine.ActivityPub.Builder.build_emoji_react_activity( + user, + message.activitypub_id, + reaction.emoji + ) + + if message.remote_actor_id do + message = Repo.preload(message, :remote_actor) + + if message.remote_actor && message.remote_actor.inbox_url do + Elektrine.ActivityPub.Publisher.publish(emoji_react, user, [ + message.remote_actor.inbox_url + ]) + end + end + end + end + + defp unfederate_emoji_reaction(reaction, user_id, emoji) do + message = Elektrine.Messaging.get_message(reaction.message_id) + + if message && message.federated && message.activitypub_id do + user = Elektrine.Accounts.get_user!(user_id) + + original_react = + case ActivityPub.get_latest_local_activity(user.id, "EmojiReact", message.activitypub_id, + content: emoji + ) do + %{data: data} when is_map(data) -> + data + + _ -> + %{ + "type" => "EmojiReact", + "actor" => "#{ActivityPub.instance_url()}/users/#{user.username}", + "object" => message.activitypub_id, + "content" => emoji + } + end + + undo_activity = Elektrine.ActivityPub.Builder.build_undo_activity(user, original_react) + + if message.remote_actor_id do + message = Repo.preload(message, :remote_actor) + + if message.remote_actor && message.remote_actor.inbox_url do + Elektrine.ActivityPub.Publisher.publish(undo_activity, user, [ + message.remote_actor.inbox_url + ]) + end + end + end + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/reference_peer.ex b/apps/elektrine/lib/elektrine/messaging/reference_peer.ex new file mode 100644 index 0000000..60e0a64 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/reference_peer.ex @@ -0,0 +1,323 @@ +defmodule Elektrine.Messaging.ReferencePeer do + @moduledoc """ + Minimal independent Arblarg reference peer used for interop smoke tests. + + This module intentionally does not depend on the federation persistence layer. + It maintains its own event ordering, deduplication, and replay state in memory. + """ + + alias Elektrine.Messaging.ReferencePeerProtocol + + defstruct domain: nil, + key_id: "ref-k1", + private_key: nil, + public_key: nil, + features: %{}, + positions: %{}, + events: %{}, + seen_event_ids: MapSet.new() + + @type t :: %__MODULE__{} + + def new(opts \\ []) when is_list(opts) do + domain = Keyword.get(opts, :domain, "reference.test") + key_id = Keyword.get(opts, :key_id, "ref-k1") + secret = Keyword.get(opts, :secret, "#{domain}:#{key_id}") + {public_key, private_key} = ReferencePeerProtocol.derive_keypair_from_secret(secret) + + %__MODULE__{ + domain: domain, + key_id: key_id, + private_key: private_key, + public_key: public_key, + features: Map.merge(default_features(), Keyword.get(opts, :features, %{})) + } + end + + def discovery_document(%__MODULE__{} = peer, opts \\ []) do + base_url = Keyword.get(opts, :base_url, "https://#{peer.domain}") + + session_url = + Keyword.get(opts, :session_websocket, "wss://#{peer.domain}/_arblarg/session") + + unsigned = %{ + "protocol" => ReferencePeerProtocol.protocol_name(), + "protocol_id" => ReferencePeerProtocol.protocol_id(), + "protocol_labels" => [ReferencePeerProtocol.protocol_label()], + "default_protocol_label" => ReferencePeerProtocol.protocol_label(), + "protocol_versions" => [ReferencePeerProtocol.protocol_version()], + "default_protocol_version" => ReferencePeerProtocol.protocol_version(), + "version" => 1, + "domain" => peer.domain, + "identity" => %{ + "algorithm" => ReferencePeerProtocol.signature_algorithm(), + "current_key_id" => peer.key_id, + "keys" => [ + %{ + "id" => peer.key_id, + "algorithm" => ReferencePeerProtocol.signature_algorithm(), + "public_key" => Base.url_encode64(peer.public_key, padding: false) + } + ] + }, + "endpoints" => %{ + "well_known" => "#{base_url}/.well-known/_arblarg", + "well_known_versioned" => + "#{base_url}/.well-known/_arblarg/#{ReferencePeerProtocol.protocol_version()}", + "profiles" => "#{base_url}/_arblarg/profiles", + "events" => "#{base_url}/_arblarg/events", + "events_batch" => "#{base_url}/_arblarg/events/batch", + "ephemeral" => "#{base_url}/_arblarg/ephemeral", + "sync" => "#{base_url}/_arblarg/sync", + "stream_events" => "#{base_url}/_arblarg/streams/events", + "session_websocket" => session_url, + "public_servers" => "#{base_url}/_arblarg/servers/public", + "snapshot_template" => "#{base_url}/_arblarg/servers/{server_id}/snapshot", + "schema_template" => "#{base_url}/_arblarg/{version}/schemas/{name}", + "schemas" => "#{base_url}/_arblarg/#{ReferencePeerProtocol.protocol_version()}/schemas" + }, + "features" => peer.features, + "transport_profiles" => %{ + "preferred_order" => [ + "session_websocket", + "events_batch_cbor", + "events_batch_json", + "events_json" + ], + "fallback_order" => [ + "session_websocket", + "events_batch_cbor", + "events_batch_json", + "events_json" + ], + "session_websocket" => %{ + "mode" => "preferred", + "framing" => "arblarg_websocket_stream_session", + "request_path" => "/_arblarg/session", + "subprotocol" => "arblarg.session.v1", + "encodings" => ["json", "cbor"], + "flow_control" => %{ + "max_inflight_batches" => 8, + "max_inflight_events" => 256 + } + } + } + } + + Map.put(unsigned, "signature", %{ + "algorithm" => ReferencePeerProtocol.signature_algorithm(), + "key_id" => peer.key_id, + "value" => + unsigned + |> ReferencePeerProtocol.canonical_json_payload() + |> ReferencePeerProtocol.sign_payload(peer.private_key) + }) + end + + def key_lookup(%__MODULE__{} = peer, key_id) do + if is_nil(key_id) or key_id == peer.key_id do + [peer.public_key] + else + [] + end + end + + def key_lookup_from_identity(%{"keys" => keys}) when is_list(keys) do + fn requested_key_id -> + keys + |> Enum.filter(fn key -> + is_nil(requested_key_id) or requested_key_id == key["id"] + end) + |> Enum.map(& &1["public_key"]) + end + end + + def key_lookup_from_identity(_identity), do: fn _key_id -> [] end + + def signed_event(%__MODULE__{} = peer, event_type, stream_id, sequence, payload, opts \\ []) + when is_binary(event_type) and is_binary(stream_id) and is_integer(sequence) and + is_map(payload) and sequence > 0 do + origin_domain = Keyword.get(opts, :origin_domain, peer.domain) + event_id = Keyword.get(opts, :event_id, "evt-#{Ecto.UUID.generate()}") + idempotency_key = Keyword.get(opts, :idempotency_key, "idem-#{Ecto.UUID.generate()}") + sent_at = Keyword.get(opts, :sent_at, DateTime.utc_now() |> DateTime.truncate(:second)) + + %{ + "protocol" => ReferencePeerProtocol.protocol_name(), + "protocol_id" => ReferencePeerProtocol.protocol_id(), + "protocol_label" => ReferencePeerProtocol.protocol_label(), + "protocol_version" => ReferencePeerProtocol.protocol_version(), + "event_id" => event_id, + "event_type" => event_type, + "origin_domain" => origin_domain, + "stream_id" => stream_id, + "sequence" => sequence, + "sent_at" => DateTime.to_iso8601(sent_at), + "idempotency_key" => idempotency_key, + "payload" => payload + } + |> ReferencePeerProtocol.sign_event_envelope(peer.key_id, peer.private_key) + end + + def receive_event(%__MODULE__{} = peer, envelope, remote_key_lookup_fun) + when is_function(remote_key_lookup_fun, 1) do + with :ok <- ReferencePeerProtocol.validate_event_envelope(envelope), + true <- + ReferencePeerProtocol.verify_event_envelope_signature(envelope, remote_key_lookup_fun) do + event_id = envelope["event_id"] + origin_domain = envelope["origin_domain"] + stream_id = envelope["stream_id"] + sequence = envelope["sequence"] + last_sequence = Map.get(peer.positions, stream_id, 0) + dedupe_key = {origin_domain, event_id} + + cond do + MapSet.member?(peer.seen_event_ids, dedupe_key) -> + {:ok, peer, :duplicate} + + sequence <= last_sequence -> + {:ok, peer, :stale} + + sequence > last_sequence + 1 -> + {:error, :sequence_gap} + + true -> + updated_peer = %{ + peer + | positions: Map.put(peer.positions, stream_id, sequence), + events: Map.update(peer.events, stream_id, [envelope], &(&1 ++ [envelope])), + seen_event_ids: MapSet.put(peer.seen_event_ids, dedupe_key) + } + + {:ok, updated_peer, :applied} + end + else + false -> {:error, :invalid_event_signature} + {:error, reason} -> {:error, reason} + end + end + + def receive_event_batch(%__MODULE__{} = peer, payload, remote_key_lookup_fun) + when is_map(payload) and is_function(remote_key_lookup_fun, 1) do + with batch_id when is_binary(batch_id) <- payload["batch_id"], + events when is_list(events) <- payload["events"] do + {updated_peer, results} = + Enum.reduce(events, {peer, []}, fn event, {acc_peer, acc_results} -> + case receive_event(acc_peer, event, remote_key_lookup_fun) do + {:ok, next_peer, status} -> + {next_peer, + acc_results ++ [%{"event_id" => event["event_id"], "status" => to_string(status)}]} + + {:error, reason} -> + {acc_peer, + acc_results ++ + [ + %{ + "event_id" => event["event_id"], + "status" => "error", + "code" => to_string(reason) + } + ]} + end + end) + + counts = summarize_result_counts(results) + error_counts = summarize_result_errors(results) + + {:ok, updated_peer, + %{ + "version" => 1, + "batch_id" => batch_id, + "event_count" => length(events), + "counts" => counts, + "error_counts" => error_counts, + "results" => results + }} + else + _ -> {:error, :invalid_batch} + end + end + + def export_stream_events(%__MODULE__{} = peer, stream_id, opts \\ []) + when is_binary(stream_id) do + after_sequence = Keyword.get(opts, :after_sequence, 0) + limit = Keyword.get(opts, :limit, 128) + + events = + peer.events + |> Map.get(stream_id, []) + |> Enum.filter(&(&1["sequence"] > after_sequence)) + |> Enum.take(limit) + + last_sequence = Map.get(peer.positions, stream_id, after_sequence) + + next_after_sequence = + events |> List.last() |> then(&if(&1, do: &1["sequence"], else: after_sequence)) + + %{ + "version" => 1, + "stream_id" => stream_id, + "after_sequence" => after_sequence, + "next_after_sequence" => next_after_sequence, + "last_sequence" => last_sequence, + "has_more" => next_after_sequence < last_sequence, + "events" => events + } + end + + def summary(%__MODULE__{} = peer) do + event_count = + peer.events + |> Map.values() + |> Enum.reduce(0, fn events, acc -> acc + length(events) end) + + %{ + domain: peer.domain, + streams: map_size(peer.positions), + events: event_count, + trustable_features: peer.features + } + end + + defp default_features do + %{ + "batched_event_delivery" => true, + "stream_catch_up" => true, + "compact_event_refs" => true, + "binary_event_batches" => true, + "read_cursors" => true, + "ephemeral_lane" => true, + "session_transport" => true, + "dynamic_peer_discovery" => true, + "open_domain_bootstrap" => true + } + end + + defp summarize_result_counts(results) when is_list(results) do + Enum.reduce(results, %{}, fn result, acc -> + status = + result["status"] + |> to_string() + |> String.split(":", parts: 2) + |> List.first() + + Map.update(acc, status, 1, &(&1 + 1)) + end) + end + + defp summarize_result_counts(_results), do: %{} + + defp summarize_result_errors(results) when is_list(results) do + Enum.reduce(results, %{}, fn result, acc -> + case result["code"] do + code when is_binary(code) -> + Map.update(acc, code, 1, &(&1 + 1)) + + _ -> + acc + end + end) + end + + defp summarize_result_errors(_results), do: %{} +end diff --git a/apps/elektrine/lib/elektrine/messaging/reference_peer_protocol.ex b/apps/elektrine/lib/elektrine/messaging/reference_peer_protocol.ex new file mode 100644 index 0000000..3aac807 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/reference_peer_protocol.ex @@ -0,0 +1,475 @@ +defmodule Elektrine.Messaging.ReferencePeerProtocol do + @moduledoc false + + @protocol_name "arblarg" + @protocol_id "arblarg" + @protocol_version "1.0" + @protocol_label "arblarg/1.0" + @signature_algorithm "ed25519" + @clock_skew_seconds 300 + + def protocol_name, do: @protocol_name + def protocol_id, do: @protocol_id + def protocol_version, do: @protocol_version + def protocol_label, do: @protocol_label + def signature_algorithm, do: @signature_algorithm + + def derive_keypair_from_secret(secret) when is_binary(secret) do + seed = :crypto.hash(:sha256, secret) |> binary_part(0, 32) + :crypto.generate_key(:eddsa, :ed25519, seed) + end + + def canonical_json_payload(value) do + canonical_json(value) + end + + def body_digest(body) when is_binary(body) do + :crypto.hash(:sha256, body) |> Base.url_encode64(padding: false) + end + + def body_digest(_body), do: body_digest("") + + def canonical_request_signature_payload( + domain, + method, + request_path, + query_string, + timestamp, + content_digest \\ "", + request_id \\ "" + ) do + [ + String.downcase(to_string(domain || "")), + String.downcase(to_string(method || "")), + canonical_path(request_path), + canonical_query_string(query_string), + to_string(timestamp || "") |> String.trim(), + canonical_content_digest(content_digest), + to_string(request_id || "") |> String.trim() + ] + |> Enum.join("\n") + end + + def sign_payload(payload, private_key_material) when is_binary(payload) do + case normalize_private_key(private_key_material) do + {:ok, private_key} -> + :crypto.sign(:eddsa, :none, payload, [private_key, :ed25519]) + |> Base.url_encode64(padding: false) + + _ -> + "" + end + end + + def verify_payload_signature(payload, public_key_material, signature) + when is_binary(payload) and is_binary(signature) do + with {:ok, public_key} <- normalize_public_key(public_key_material), + {:ok, raw_signature} <- Base.url_decode64(String.trim(signature), padding: false) do + :crypto.verify(:eddsa, :none, payload, raw_signature, [public_key, :ed25519]) + else + _ -> false + end + end + + def verify_payload_signature(_payload, _public_key_material, _signature), do: false + + def sign_event_envelope(envelope, key_id, private_key_material) when is_map(envelope) do + signature_value = + envelope + |> canonical_event_signature_payload() + |> sign_payload(private_key_material) + + Map.put(envelope, "signature", %{ + "algorithm" => @signature_algorithm, + "key_id" => to_string(key_id || ""), + "value" => signature_value + }) + end + + def validate_event_envelope(envelope) when is_map(envelope) do + idempotency_key = envelope["idempotency_key"] || envelope["event_id"] + signature = envelope["signature"] || %{} + + cond do + envelope["protocol"] != @protocol_name -> + {:error, :unsupported_protocol} + + envelope["protocol_id"] != @protocol_id -> + {:error, :unsupported_protocol} + + envelope["protocol_version"] != @protocol_version -> + {:error, :unsupported_version} + + !non_empty_binary?(envelope["event_id"]) -> + {:error, :invalid_payload} + + !non_empty_binary?(envelope["event_type"]) -> + {:error, :unsupported_event_type} + + !non_empty_binary?(envelope["origin_domain"]) -> + {:error, :invalid_payload} + + !non_empty_binary?(envelope["stream_id"]) -> + {:error, :invalid_payload} + + !is_integer(envelope["sequence"]) or envelope["sequence"] < 1 -> + {:error, :invalid_payload} + + !non_empty_binary?(idempotency_key) -> + {:error, :invalid_payload} + + !valid_iso8601?(envelope["sent_at"]) -> + {:error, :invalid_payload} + + !is_map(envelope["payload"]) -> + {:error, :invalid_payload} + + !valid_signature_map?(signature) -> + {:error, :invalid_signature} + + true -> + validate_event_payload(envelope["event_type"], envelope["payload"]) + end + end + + def validate_event_envelope(_envelope), do: {:error, :invalid_payload} + + def verify_event_envelope_signature(envelope, key_lookup_fun) + when is_map(envelope) and is_function(key_lookup_fun, 1) do + signature = envelope["signature"] || %{} + key_id = signature["key_id"] + algorithm = signature["algorithm"] + value = signature["value"] + + if is_binary(key_id) and is_binary(value) and algorithm == @signature_algorithm do + envelope_without_signature = Map.delete(envelope, "signature") + verification_materials = key_lookup_fun.(key_id) |> List.wrap() + + Enum.any?(verification_materials, fn public_key_material -> + verify_payload_signature( + canonical_event_signature_payload(envelope_without_signature), + public_key_material, + value + ) + end) + else + false + end + end + + def verify_event_envelope_signature(_envelope, _key_lookup_fun), do: false + + def signed_headers(domain, key_id, private_key_material, method, path, query_string, body) do + timestamp = Integer.to_string(System.system_time(:second)) + request_id = Ecto.UUID.generate() + content_digest = body_digest(body) + + signature = + canonical_request_signature_payload( + domain, + method, + path, + query_string, + timestamp, + content_digest, + request_id + ) + |> sign_payload(private_key_material) + + [ + {"x-arblarg-domain", domain}, + {"x-arblarg-key-id", key_id}, + {"x-arblarg-timestamp", timestamp}, + {"x-arblarg-content-digest", content_digest}, + {"x-arblarg-request-id", request_id}, + {"x-arblarg-signature-algorithm", @signature_algorithm}, + {"x-arblarg-signature", signature} + ] + end + + def verify_signed_headers(headers, method, path, query_string, body, key_lookup_fun) + when is_list(headers) and is_function(key_lookup_fun, 1) do + normalized = + Enum.reduce(headers, %{}, fn {key, value}, acc -> + Map.put(acc, String.downcase(to_string(key)), to_string(value)) + end) + + domain = Map.get(normalized, "x-arblarg-domain") + key_id = Map.get(normalized, "x-arblarg-key-id") + timestamp = Map.get(normalized, "x-arblarg-timestamp") + content_digest = Map.get(normalized, "x-arblarg-content-digest") + request_id = Map.get(normalized, "x-arblarg-request-id") + algorithm = Map.get(normalized, "x-arblarg-signature-algorithm") + signature = Map.get(normalized, "x-arblarg-signature") + + cond do + !non_empty_binary?(domain) -> + {:error, :missing_domain} + + !non_empty_binary?(key_id) -> + {:error, :missing_key_id} + + !valid_timestamp?(timestamp) -> + {:error, :invalid_timestamp} + + canonical_content_digest(content_digest) != body_digest(body) -> + {:error, :invalid_content_digest} + + !non_empty_binary?(request_id) -> + {:error, :missing_request_id} + + algorithm != @signature_algorithm -> + {:error, :invalid_signature_algorithm} + + !non_empty_binary?(signature) -> + {:error, :missing_signature} + + true -> + payload = + canonical_request_signature_payload( + domain, + method, + path, + query_string, + timestamp, + content_digest, + request_id + ) + + materials = key_lookup_fun.(key_id) |> List.wrap() + + if Enum.any?(materials, &verify_payload_signature(payload, &1, signature)) do + {:ok, domain} + else + {:error, :invalid_signature} + end + end + end + + def verify_signed_headers(_headers, _method, _path, _query_string, _body, _key_lookup_fun) do + {:error, :invalid_signature} + end + + def valid_timestamp?(timestamp, skew_seconds \\ @clock_skew_seconds) + + def valid_timestamp?(timestamp, skew_seconds) when is_binary(timestamp) do + case Integer.parse(timestamp) do + {ts, ""} when is_integer(skew_seconds) and skew_seconds >= 0 -> + abs(System.system_time(:second) - ts) <= skew_seconds + + _ -> + false + end + end + + def valid_timestamp?(_timestamp, _skew_seconds), do: false + + defp validate_event_payload("message.create", payload), do: validate_message_payload(payload) + defp validate_event_payload("message.update", payload), do: validate_message_payload(payload) + defp validate_event_payload("message.delete", payload), do: validate_delete_payload(payload) + defp validate_event_payload(_event_type, payload) when is_map(payload), do: :ok + defp validate_event_payload(_event_type, _payload), do: {:error, :invalid_event_payload} + + defp validate_message_payload(payload) when is_map(payload) do + message = payload["message"] + + cond do + !is_map(message) -> + {:error, :invalid_event_payload} + + !non_empty_binary?(message["id"]) -> + {:error, :invalid_event_payload} + + !is_binary(message["content"] || "") -> + {:error, :invalid_event_payload} + + !is_map(message["sender"]) -> + {:error, :invalid_event_payload} + + !valid_attachment_list?(message["attachments"]) -> + {:error, :invalid_event_payload} + + true -> + :ok + end + end + + defp validate_message_payload(_payload), do: {:error, :invalid_event_payload} + + defp validate_delete_payload(payload) when is_map(payload) do + if non_empty_binary?(payload["message_id"]), do: :ok, else: {:error, :invalid_event_payload} + end + + defp validate_delete_payload(_payload), do: {:error, :invalid_event_payload} + + defp valid_attachment_list?(nil), do: true + + defp valid_attachment_list?(attachments) when is_list(attachments) do + Enum.all?(attachments, &valid_attachment?/1) + end + + defp valid_attachment_list?(_attachments), do: false + + defp valid_attachment?(attachment) when is_map(attachment) do + authorization = attachment["authorization"] + retention = attachment["retention"] + + non_empty_binary?(attachment["id"]) and + non_empty_binary?(attachment["url"]) and + non_empty_binary?(attachment["mime_type"]) and + authorization in ["public", "signed", "origin-authenticated"] and + retention in ["origin", "rehosted", "expiring"] + end + + defp valid_attachment?(_attachment), do: false + + defp valid_signature_map?(signature) when is_map(signature) do + non_empty_binary?(signature["key_id"]) and + non_empty_binary?(signature["value"]) and + signature["algorithm"] == @signature_algorithm + end + + defp valid_signature_map?(_signature), do: false + + defp canonical_event_signature_payload(envelope) do + payload = envelope["payload"] || %{} + idempotency_key = envelope["idempotency_key"] || envelope["event_id"] + + [ + @protocol_id, + to_string(envelope["protocol_version"] || ""), + to_string(envelope["event_type"] || ""), + to_string(envelope["event_id"] || ""), + to_string(envelope["origin_domain"] || ""), + to_string(envelope["stream_id"] || ""), + to_string(parse_int(envelope["sequence"], 0)), + to_string(envelope["sent_at"] || ""), + to_string(idempotency_key || ""), + canonical_json(payload) + ] + |> Enum.join("\n") + end + + defp parse_int(value, _default) when is_integer(value), do: value + + defp parse_int(value, default) when is_binary(value) do + case Integer.parse(value) do + {parsed, ""} -> parsed + _ -> default + end + end + + defp parse_int(_value, default), do: default + + defp canonical_json(value) when is_map(value) do + value + |> Enum.map(fn {key, val} -> {to_string(key), val} end) + |> Enum.sort_by(&elem(&1, 0)) + |> Enum.map_join(",", fn {key, val} -> + Jason.encode!(key) <> ":" <> canonical_json(val) + end) + |> then(fn body -> "{" <> body <> "}" end) + end + + defp canonical_json(value) when is_list(value) do + value + |> Enum.map_join(",", &canonical_json/1) + |> then(fn body -> "[" <> body <> "]" end) + end + + defp canonical_json(value), do: Jason.encode!(value) + + defp canonical_path(nil), do: "/" + + defp canonical_path(path) when is_binary(path) do + trimmed = String.trim(path) + + cond do + not Elektrine.Strings.present?(trimmed) -> "/" + String.starts_with?(trimmed, "/") -> trimmed + true -> "/" <> trimmed + end + end + + defp canonical_path(path), do: canonical_path(to_string(path)) + + defp canonical_query_string(nil), do: "" + defp canonical_query_string(query) when is_binary(query), do: String.trim(query) + defp canonical_query_string(query), do: to_string(query) + + defp canonical_content_digest(nil), do: body_digest("") + + defp canonical_content_digest(content_digest) when is_binary(content_digest) do + case String.trim(content_digest) do + "" -> body_digest("") + value -> value + end + end + + defp canonical_content_digest(content_digest), + do: canonical_content_digest(to_string(content_digest)) + + defp normalize_private_key(key) when is_binary(key) and byte_size(key) == 32, + do: {:ok, key} + + defp normalize_private_key(%{private_key: key}), do: normalize_private_key(key) + defp normalize_private_key(%{secret: secret}), do: normalize_private_key(secret) + + defp normalize_private_key(key) when is_binary(key) do + case decode_32_byte_key(String.trim(key)) do + {:ok, decoded} -> + {:ok, decoded} + + :error -> + {_public_key, private_key} = derive_keypair_from_secret(key) + {:ok, private_key} + end + end + + defp normalize_private_key(_key), do: {:error, :invalid_private_key} + + defp normalize_public_key(key) when is_binary(key) and byte_size(key) == 32, + do: {:ok, key} + + defp normalize_public_key(%{public_key: key}), do: normalize_public_key(key) + defp normalize_public_key(%{secret: secret}), do: normalize_public_key(secret) + + defp normalize_public_key(key) when is_binary(key) do + case decode_32_byte_key(String.trim(key)) do + {:ok, decoded} -> + {:ok, decoded} + + :error -> + {public_key, _private_key} = derive_keypair_from_secret(key) + {:ok, public_key} + end + end + + defp normalize_public_key(_key), do: {:error, :invalid_public_key} + + defp decode_32_byte_key(encoded) when is_binary(encoded) do + case Base.url_decode64(encoded, padding: false) do + {:ok, raw} when byte_size(raw) == 32 -> {:ok, raw} + _ -> decode_32_byte_key_standard(encoded) + end + end + + defp decode_32_byte_key_standard(encoded) when is_binary(encoded) do + case Base.decode64(encoded) do + {:ok, raw} when byte_size(raw) == 32 -> {:ok, raw} + _ -> :error + end + end + + defp non_empty_binary?(value) when is_binary(value), do: Elektrine.Strings.present?(value) + defp non_empty_binary?(_value), do: false + + defp valid_iso8601?(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, _datetime, _offset} -> true + _ -> false + end + end + + defp valid_iso8601?(_value), do: false +end diff --git a/apps/elektrine/lib/elektrine/messaging/reference_peer_session_server.ex b/apps/elektrine/lib/elektrine/messaging/reference_peer_session_server.ex new file mode 100644 index 0000000..72658ae --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/reference_peer_session_server.ex @@ -0,0 +1,644 @@ +defmodule Elektrine.Messaging.ReferencePeerSessionServer do + @moduledoc false + use GenServer + import Bitwise + + alias Elektrine.Messaging.ReferencePeer + alias Elektrine.Messaging.ReferencePeerProtocol + + @subprotocol "arblarg.session.v1" + @websocket_magic "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + @hello_frame %{ + "op" => "hello", + "protocol" => "arblarg", + "transport" => "session_websocket", + "session_version" => 1, + "mode" => "stream_session", + "encodings" => ["json", "cbor"], + "flow_control" => %{ + "mode" => "ack_window", + "max_inflight_batches" => 8, + "max_inflight_events" => 256 + } + } + + def start_link(opts) when is_list(opts) do + GenServer.start_link(__MODULE__, opts) + end + + def port(server), do: GenServer.call(server, :port) + def current_peer(server), do: GenServer.call(server, :current_peer) + + @impl true + def init(opts) do + peer = Keyword.fetch!(opts, :peer) + remote_key_lookup_fun = Keyword.fetch!(opts, :remote_key_lookup_fun) + notify = Keyword.get(opts, :notify) + port = Keyword.get(opts, :port, 0) + + {:ok, listener} = + :gen_tcp.listen(port, [:binary, active: false, packet: :raw, reuseaddr: true]) + + {:ok, actual_port} = :inet.port(listener) + server = self() + acceptor = spawn_link(fn -> accept_loop(server, listener, remote_key_lookup_fun, notify) end) + + {:ok, + %{ + listener: listener, + acceptor: acceptor, + port: actual_port, + peer: peer, + remote_key_lookup_fun: remote_key_lookup_fun, + notify: notify + }} + end + + @impl true + def handle_call(:port, _from, state), do: {:reply, state.port, state} + def handle_call(:current_peer, _from, state), do: {:reply, state.peer, state} + + def handle_call({:receive_stream_batch, delivery_id, payload}, _from, state) do + notify(state.notify, {:server_call, :receive_stream_batch, delivery_id}) + + result = + case payload do + %{ + "version" => 1, + "delivery_id" => ^delivery_id, + "stream_id" => stream_id, + "events" => events + } + when is_binary(delivery_id) and is_binary(stream_id) and is_list(events) and events != [] -> + case ReferencePeer.receive_event_batch( + state.peer, + %{"batch_id" => delivery_id, "events" => events}, + state.remote_key_lookup_fun + ) do + {:ok, next_peer, batch_result} -> + {:ok, %{state | peer: next_peer}, batch_result} + + {:error, reason} -> + {:error, state, reason} + end + + _ -> + {:error, state, :invalid_payload} + end + + case result do + {:ok, next_state, ack_payload} -> + notify(state.notify, {:server_reply, :receive_stream_batch, delivery_id, :ok}) + {:reply, {:ok, ack_payload}, next_state} + + {:error, next_state, reason} -> + notify( + state.notify, + {:server_reply, :receive_stream_batch, delivery_id, {:error, reason}} + ) + + {:reply, {:error, reason}, next_state} + end + end + + def handle_call({:receive_ephemeral, delivery_id, payload}, _from, state) do + result = + case payload do + %{"version" => 1, "delivery_id" => ^delivery_id, "items" => items} + when is_binary(delivery_id) and is_list(items) and items != [] -> + results = + Enum.map(items, fn item -> + %{"event_type" => item["event_type"], "status" => "applied"} + end) + + {:ok, + %{ + "version" => 1, + "batch_id" => delivery_id, + "event_count" => length(items), + "results" => results, + "counts" => %{"applied" => length(items)}, + "error_counts" => %{} + }} + + _ -> + {:error, :invalid_payload} + end + + {:reply, result, state} + end + + def handle_call({:stream_events, stream_id, after_sequence, limit}, _from, state) do + {:reply, + {:ok, + ReferencePeer.export_stream_events(state.peer, stream_id, + after_sequence: after_sequence, + limit: limit + )}, state} + end + + def handle_call(_request, _from, state), do: {:reply, {:error, :unsupported_operation}, state} + + @impl true + def handle_info(_message, state), do: {:noreply, state} + + @impl true + def terminate(_reason, state) do + _ = :gen_tcp.close(state.listener) + :ok + end + + defp accept_loop(server, listener, remote_key_lookup_fun, notify) do + case :gen_tcp.accept(listener) do + {:ok, socket} -> + session_pid = + spawn_link(fn -> + receive do + :socket_ready -> session_loop(socket, server, remote_key_lookup_fun, notify) + end + end) + + case :gen_tcp.controlling_process(socket, session_pid) do + :ok -> + send(session_pid, :socket_ready) + + _ -> + :gen_tcp.close(socket) + end + + accept_loop(server, listener, remote_key_lookup_fun, notify) + + _ -> + :ok + end + end + + defp session_loop(socket, server, remote_key_lookup_fun, notify) do + with {:ok, request} <- recv_http_request(socket, <<>>), + :ok <- verify_session_request(request, remote_key_lookup_fun), + :ok <- + send_handshake_response(socket, request.websocket_key, request.subprotocol_requested?), + :ok <- send_frame(socket, {:text, Jason.encode!(@hello_frame)}) do + notify(notify, {:handshake_complete, request.path}) + receive_frames(socket, server, notify, <<>>) + else + error -> + notify(notify, {:session_failed, error}) + :gen_tcp.close(socket) + end + end + + defp receive_frames(socket, server, notify, buffer) do + case recv_frame(socket, buffer) do + {:ok, {:text, payload}, rest} -> + with {:ok, decoded} <- Jason.decode(payload), + :ok <- handle_frame(socket, server, notify, decoded, :json) do + receive_frames(socket, server, notify, rest) + else + error -> + notify(notify, {:receive_failed, error}) + :gen_tcp.close(socket) + end + + {:ok, {:binary, payload}, rest} -> + with {:ok, decoded, ""} <- CBOR.decode(payload), + :ok <- handle_frame(socket, server, notify, decoded, :cbor) do + receive_frames(socket, server, notify, rest) + else + error -> + notify(notify, {:receive_failed, error}) + :gen_tcp.close(socket) + end + + {:ok, {:ping, payload}, rest} -> + _ = send_control(socket, :pong, payload) + receive_frames(socket, server, notify, rest) + + {:ok, {:pong, _payload}, rest} -> + receive_frames(socket, server, notify, rest) + + {:ok, {:close, _payload}, _rest} -> + notify(notify, :peer_closed) + :gen_tcp.close(socket) + + error -> + notify(notify, {:receive_failed, error}) + :gen_tcp.close(socket) + end + end + + defp handle_frame( + socket, + server, + notify, + %{"op" => "stream_batch", "delivery_id" => delivery_id, "payload" => payload}, + encoding + ) + when is_binary(delivery_id) do + notify(notify, {:frame, "stream_batch", delivery_id}) + + case GenServer.call(server, {:receive_stream_batch, delivery_id, payload || %{}}) do + {:ok, ack_payload} -> + notify(notify, {:ack_sent, delivery_id, :ok}) + + send_frame( + socket, + encode_payload( + %{ + "op" => "ack", + "delivery_id" => delivery_id, + "status" => "ok", + "payload" => ack_payload + }, + encoding + ) + ) + + {:error, reason} -> + notify(notify, {:ack_sent, delivery_id, {:error, reason}}) + + send_frame( + socket, + encode_payload( + %{ + "op" => "ack", + "delivery_id" => delivery_id, + "status" => "error", + "code" => to_string(reason) + }, + encoding + ) + ) + end + end + + defp handle_frame( + socket, + server, + notify, + %{"op" => "deliver_ephemeral", "delivery_id" => delivery_id, "payload" => payload}, + encoding + ) + when is_binary(delivery_id) do + notify(notify, {:frame, "deliver_ephemeral", delivery_id}) + + case GenServer.call(server, {:receive_ephemeral, delivery_id, payload || %{}}) do + {:ok, ack_payload} -> + notify(notify, {:ack_sent, delivery_id, :ok}) + + send_frame( + socket, + encode_payload( + %{ + "op" => "ack", + "delivery_id" => delivery_id, + "status" => "ok", + "payload" => ack_payload + }, + encoding + ) + ) + + {:error, reason} -> + notify(notify, {:ack_sent, delivery_id, {:error, reason}}) + + send_frame( + socket, + encode_payload( + %{ + "op" => "ack", + "delivery_id" => delivery_id, + "status" => "error", + "code" => to_string(reason) + }, + encoding + ) + ) + end + end + + defp handle_frame( + socket, + server, + notify, + %{"op" => "stream_events", "request_id" => request_id, "payload" => payload}, + encoding + ) + when is_binary(request_id) do + notify(notify, {:frame, "stream_events", request_id}) + + response = + case payload do + %{"stream_id" => stream_id} when is_binary(stream_id) -> + after_sequence = payload["after_sequence"] || 0 + limit = payload["limit"] || 128 + + case GenServer.call(server, {:stream_events, stream_id, after_sequence, limit}) do + {:ok, replay_payload} -> + %{ + "op" => "response", + "request_id" => request_id, + "status" => "ok", + "payload" => replay_payload + } + + {:error, reason} -> + %{ + "op" => "response", + "request_id" => request_id, + "status" => "error", + "code" => to_string(reason) + } + end + + _ -> + %{ + "op" => "response", + "request_id" => request_id, + "status" => "error", + "code" => "invalid_payload" + } + end + + send_frame(socket, encode_payload(response, encoding)) + end + + defp handle_frame( + socket, + _server, + _notify, + %{"op" => "ping", "request_id" => request_id}, + encoding + ) + when is_binary(request_id) do + send_frame( + socket, + encode_payload( + %{ + "op" => "response", + "request_id" => request_id, + "status" => "ok", + "payload" => %{"status" => "pong"} + }, + encoding + ) + ) + end + + defp handle_frame(socket, _server, _notify, %{"request_id" => request_id}, encoding) + when is_binary(request_id) do + send_frame( + socket, + encode_payload( + %{ + "op" => "response", + "request_id" => request_id, + "status" => "error", + "code" => "unsupported_operation" + }, + encoding + ) + ) + end + + defp handle_frame(_socket, _server, _notify, _frame, _encoding), do: :ok + + defp verify_session_request(request, remote_key_lookup_fun) do + ReferencePeerProtocol.verify_signed_headers( + request.headers, + "GET", + request.path, + request.query_string, + "", + remote_key_lookup_fun + ) + |> case do + {:ok, _domain} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp recv_http_request(socket, buffer) do + case :binary.match(buffer, "\r\n\r\n") do + {index, 4} -> + header_block = binary_part(buffer, 0, index) + parse_http_request(header_block) + + :nomatch -> + case :gen_tcp.recv(socket, 0, 5_000) do + {:ok, data} -> recv_http_request(socket, buffer <> data) + {:error, reason} -> {:error, reason} + end + end + end + + defp parse_http_request(header_block) do + [request_line | header_lines] = String.split(header_block, "\r\n", trim: true) + + with ["GET", request_target, "HTTP/1.1"] <- String.split(request_line, " ", parts: 3), + {path, query_string} <- split_request_target(request_target) do + headers = + Enum.map(header_lines, fn line -> + case String.split(line, ":", parts: 2) do + [key, value] -> {String.downcase(String.trim(key)), String.trim(value)} + _ -> nil + end + end) + |> Enum.reject(&is_nil/1) + + websocket_key = + headers + |> Enum.find_value(fn + {"sec-websocket-key", value} -> value + _ -> nil + end) + + subprotocol_requested? = requested_subprotocol?(headers) + + if is_binary(websocket_key) and path == "/_arblarg/session" do + {:ok, + %{ + path: path, + query_string: query_string, + headers: headers, + websocket_key: websocket_key, + subprotocol_requested?: subprotocol_requested? + }} + else + {:error, :invalid_session_handshake} + end + else + _ -> {:error, :invalid_session_handshake} + end + end + + defp split_request_target(request_target) when is_binary(request_target) do + case String.split(request_target, "?", parts: 2) do + [path, query] -> {path, query} + [path] -> {path, ""} + end + end + + defp send_handshake_response(socket, websocket_key, subprotocol_requested?) do + accept = + websocket_key + |> Kernel.<>(@websocket_magic) + |> then(&:crypto.hash(:sha, &1)) + |> Base.encode64() + + response = [ + "HTTP/1.1 101 Switching Protocols\r\n", + "connection: Upgrade\r\n", + "upgrade: websocket\r\n", + "sec-websocket-accept: ", + accept, + "\r\n" + ] + + response = + if subprotocol_requested? do + response ++ ["sec-websocket-protocol: ", @subprotocol, "\r\n"] + else + response + end + + response = response ++ ["\r\n"] + + :gen_tcp.send(socket, response) + end + + defp requested_subprotocol?(headers) when is_list(headers) do + headers + |> Enum.find_value(fn + {"sec-websocket-protocol", value} -> value + _ -> nil + end) + |> case do + value when is_binary(value) -> + value + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.member?(@subprotocol) + + _ -> + false + end + end + + defp requested_subprotocol?(_headers), do: false + + defp encode_payload(payload, :cbor), do: {:binary, CBOR.encode(payload)} + defp encode_payload(payload, _encoding), do: {:text, Jason.encode!(payload)} + + defp send_frame(socket, {:text, payload}) when is_binary(payload) do + :gen_tcp.send(socket, [<<0x81>>, length_prefix(byte_size(payload), false), payload]) + end + + defp send_frame(socket, {:binary, payload}) when is_binary(payload) do + :gen_tcp.send(socket, [<<0x82>>, length_prefix(byte_size(payload), false), payload]) + end + + defp send_control(socket, opcode, payload) + when opcode in [:pong, :ping] and is_binary(payload) do + opcode_byte = if(opcode == :ping, do: 0x9, else: 0xA) + + :gen_tcp.send(socket, [ + <<0x80 ||| opcode_byte>>, + length_prefix(byte_size(payload), false), + payload + ]) + end + + defp recv_frame(socket, buffer) do + case decode_client_frame(buffer) do + {:ok, frame, rest} -> + {:ok, frame, rest} + + :more -> + case :gen_tcp.recv(socket, 0, 5_000) do + {:ok, data} -> recv_frame(socket, buffer <> data) + {:error, reason} -> {:error, reason} + end + + {:error, reason} -> + {:error, reason} + end + end + + defp decode_client_frame(<<>>), do: :more + + defp decode_client_frame(<>) do + fin? = (first &&& 0x80) == 0x80 + opcode = first &&& 0x0F + masked? = (second &&& 0x80) == 0x80 + payload_length = second &&& 0x7F + + with true <- fin?, + true <- masked?, + {:ok, payload_length, rest} <- parse_payload_length(payload_length, rest), + {:ok, mask, rest} <- parse_mask(rest), + {:ok, payload, rest} <- parse_payload(rest, payload_length) do + payload = mask_payload(payload, mask) + + case opcode do + 0x1 -> {:ok, {:text, payload}, rest} + 0x2 -> {:ok, {:binary, payload}, rest} + 0x8 -> {:ok, {:close, payload}, rest} + 0x9 -> {:ok, {:ping, payload}, rest} + 0xA -> {:ok, {:pong, payload}, rest} + _ -> {:error, :invalid_session_frame} + end + else + :more -> :more + false -> {:error, :invalid_session_frame} + end + end + + defp parse_payload_length(126, <>), do: {:ok, length, rest} + defp parse_payload_length(126, _buffer), do: :more + defp parse_payload_length(127, <>), do: {:ok, length, rest} + defp parse_payload_length(127, _buffer), do: :more + defp parse_payload_length(length, rest) when is_integer(length), do: {:ok, length, rest} + + defp parse_mask(<>), do: {:ok, mask, rest} + defp parse_mask(_buffer), do: :more + + defp parse_payload(buffer, payload_length) when byte_size(buffer) < payload_length, do: :more + + defp parse_payload(buffer, payload_length) do + payload = binary_part(buffer, 0, payload_length) + rest = binary_part(buffer, payload_length, byte_size(buffer) - payload_length) + {:ok, payload, rest} + end + + defp length_prefix(length, masked?) when length < 126 do + mask_bit = if(masked?, do: 0x80, else: 0x00) + <> + end + + defp length_prefix(length, masked?) when length <= 0xFFFF do + mask_bit = if(masked?, do: 0x80, else: 0x00) + <> + end + + defp length_prefix(length, masked?) do + mask_bit = if(masked?, do: 0x80, else: 0x00) + <> + end + + defp mask_payload(payload, mask) when is_binary(payload) and is_binary(mask) do + mask_bytes = :binary.bin_to_list(mask) + + payload + |> :binary.bin_to_list() + |> Enum.with_index() + |> Enum.map(fn {byte, index} -> + Bitwise.bxor(byte, Enum.at(mask_bytes, rem(index, 4))) + end) + |> :erlang.list_to_binary() + end + + defp notify(nil, _message), do: :ok + defp notify(pid, message) when is_pid(pid), do: send(pid, {:reference_peer_session, message}) + defp notify(_notify, _message), do: :ok +end diff --git a/apps/elektrine/lib/elektrine/messaging/room_acl.ex b/apps/elektrine/lib/elektrine/messaging/room_acl.ex new file mode 100644 index 0000000..ecec0d7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/room_acl.ex @@ -0,0 +1,578 @@ +defmodule Elektrine.Messaging.RoomACL do + @moduledoc false + + import Ecto.Query, warn: false + + alias Elektrine.Accounts.User + alias Elektrine.ActivityPub.Actor + + alias Elektrine.Messaging.{ + ChatConversation, + ChatConversationMember, + FederationExtensionEvent, + FederationMembershipState + } + + alias Elektrine.Social.{Conversation, ConversationMember} + + alias Elektrine.Messaging.Federation.Utils + alias Elektrine.Repo + + @builtin_roles %{ + "builtin:owner" => %{ + position: 100, + permissions: + MapSet.new([ + "manage_roles", + "manage_permissions", + "manage_moderation", + "manage_messages", + "manage_channels", + "manage_threads", + "create_threads", + "view_audit_log", + "manage_webhooks", + "manage_server", + "invite_members", + "send_messages", + "send_tts_messages", + "send_voice_signaling", + "attach_files", + "embed_links", + "mention_everyone", + "use_external_emoji" + ]) + }, + "builtin:admin" => %{ + position: 80, + permissions: + MapSet.new([ + "manage_roles", + "manage_permissions", + "manage_moderation", + "manage_messages", + "manage_channels", + "manage_threads", + "create_threads", + "view_audit_log", + "manage_webhooks", + "invite_members", + "send_messages", + "send_tts_messages", + "send_voice_signaling", + "attach_files", + "embed_links", + "mention_everyone", + "use_external_emoji" + ]) + }, + "builtin:moderator" => %{ + position: 60, + permissions: + MapSet.new([ + "manage_moderation", + "manage_messages", + "manage_threads", + "invite_members", + "send_messages", + "attach_files", + "embed_links", + "use_external_emoji" + ]) + }, + "builtin:member" => %{ + position: 10, + permissions: + MapSet.new(["send_messages", "attach_files", "embed_links", "use_external_emoji"]) + }, + "builtin:readonly" => %{ + position: 0, + permissions: MapSet.new(["read_messages"]) + } + } + + @base_role_ids %{ + "owner" => "builtin:owner", + "admin" => "builtin:admin", + "moderator" => "builtin:moderator", + "member" => "builtin:member", + "readonly" => "builtin:readonly" + } + + @acl_event_types [ + "urn:arblarg:ext:roles:1#role.upsert", + "urn:arblarg:ext:roles:1#role.assignment.upsert", + "urn:arblarg:ext:permissions:1#overwrite.upsert" + ] + + @role_upsert_event_type "urn:arblarg:ext:roles:1#role.upsert" + @role_assignment_event_type "urn:arblarg:ext:roles:1#role.assignment.upsert" + @permission_overwrite_event_type "urn:arblarg:ext:permissions:1#overwrite.upsert" + + def authorize_local_user_action(conversation_id, user_id, action) + when is_integer(conversation_id) and is_integer(user_id) and is_atom(action) do + case Repo.get(ChatConversation, conversation_id) do + %ChatConversation{} = conversation -> + with %ChatConversationMember{role: base_role} <- + from(member in ChatConversationMember, + where: + member.conversation_id == ^conversation_id and member.user_id == ^user_id and + is_nil(member.left_at), + limit: 1 + ) + |> Repo.one(), + actor_uri when is_binary(actor_uri) <- local_actor_uri(user_id), + true <- allowed?(conversation, base_role, actor_uri, action, %{}) do + :ok + else + nil -> {:error, :unauthorized} + false -> {:error, :unauthorized} + _ -> {:error, :unauthorized} + end + + nil -> + with %Conversation{} = conversation <- Repo.get(Conversation, conversation_id), + %ConversationMember{role: base_role} <- + from(member in ConversationMember, + where: + member.conversation_id == ^conversation_id and member.user_id == ^user_id and + is_nil(member.left_at), + limit: 1 + ) + |> Repo.one(), + actor_uri when is_binary(actor_uri) <- local_actor_uri(user_id), + true <- allowed?(conversation, base_role, actor_uri, action, %{}) do + :ok + else + nil -> {:error, :unauthorized} + false -> {:error, :unauthorized} + _ -> {:error, :unauthorized} + end + end + end + + def authorize_local_user_action(_conversation_id, _user_id, _action), + do: {:error, :unauthorized} + + def authorize_remote_actor_action(conversation, remote_actor_id, action, options \\ %{}) + + def authorize_remote_actor_action( + %Conversation{} = conversation, + remote_actor_id, + action, + options + ) + when is_integer(remote_actor_id) and is_atom(action) and is_map(options) do + authorize_remote_actor_for_conversation(conversation, remote_actor_id, action, options) + end + + def authorize_remote_actor_action( + %ChatConversation{} = conversation, + remote_actor_id, + action, + options + ) + when is_integer(remote_actor_id) and is_atom(action) and is_map(options) do + authorize_remote_actor_for_conversation(conversation, remote_actor_id, action, options) + end + + def authorize_remote_actor_action(_conversation, _remote_actor_id, _action, _options), + do: {:error, :not_authorized_for_room} + + defp authorize_remote_actor_for_conversation(conversation, remote_actor_id, action, options) do + membership = + Repo.get_by(FederationMembershipState, + conversation_id: conversation.id, + remote_actor_id: remote_actor_id, + state: "active" + ) + + actor = Repo.get(Actor, remote_actor_id) + + with %FederationMembershipState{role: base_role} <- membership, + %Actor{uri: actor_uri} when is_binary(actor_uri) <- actor, + true <- + allowed?( + conversation, + base_role, + actor_uri, + action, + Map.put(options, :remote_actor_id, remote_actor_id) + ) do + :ok + else + nil -> {:error, :not_authorized_for_room} + false -> {:error, :not_authorized_for_room} + _ -> {:error, :not_authorized_for_room} + end + end + + defp allowed?(%Conversation{type: type}, _base_role, _actor_uri, action, _options) + when type != "channel" and action in [:participate, :write, :send_messages], + do: true + + # Voice channels have no text timeline; any active member may participate in + # voice signaling. Full permission-overwrite evaluation is intentionally not + # applied here (the builtin member role does not carry send_voice_signaling, + # which is reserved for gating via explicit overwrites in later iterations). + defp allowed?( + %ChatConversation{type: "voice_channel"}, + _base_role, + _actor_uri, + action, + _options + ), + do: action in [:participate, :send_voice_signaling] + + defp allowed?(%ChatConversation{type: type}, _base_role, _actor_uri, action, _options) + when type != "channel" and action in [:participate, :write, :send_messages], + do: true + + defp allowed?(%Conversation{type: type}, _base_role, _actor_uri, _action, _options) + when type != "channel", + do: false + + defp allowed?(%ChatConversation{type: type}, _base_role, _actor_uri, _action, _options) + when type != "channel", + do: false + + defp allowed?(%Conversation{id: conversation_id}, base_role, actor_uri, action, options) + when is_integer(conversation_id) and is_binary(actor_uri) and is_atom(action) and + is_map(options) do + permissions = effective_permissions(conversation_id, base_role, actor_uri) + + case action do + :participate -> + true + + action when action in [:write, :send_messages] -> + MapSet.member?(permissions, "send_messages") + + :attach_files -> + MapSet.member?(permissions, "attach_files") + + :embed_links -> + MapSet.member?(permissions, "embed_links") + + :mention_everyone -> + MapSet.member?(permissions, "mention_everyone") + + :manage_messages -> + MapSet.member?(permissions, "manage_messages") + + :manage_channels -> + MapSet.member?(permissions, "manage_channels") + + :manage_webhooks -> + MapSet.member?(permissions, "manage_webhooks") + + :manage_server -> + MapSet.member?(permissions, "manage_server") + + :invite -> + MapSet.member?(permissions, "invite_members") + + action when action in [:ban, :moderation_action] -> + MapSet.member?(permissions, "manage_moderation") + + action when action in [:role_upsert, :role_assignment] -> + MapSet.member?(permissions, "manage_roles") + + :permission_overwrite -> + MapSet.member?(permissions, "manage_permissions") + + :thread_upsert -> + MapSet.member?(permissions, "manage_threads") or + MapSet.member?(permissions, "manage_moderation") or owner_matches?(options, actor_uri) + + :thread_archive -> + MapSet.member?(permissions, "manage_threads") or + MapSet.member?(permissions, "manage_moderation") or owner_matches?(options, actor_uri) + + :create_threads -> + MapSet.member?(permissions, "create_threads") or + MapSet.member?(permissions, "manage_threads") or + MapSet.member?(permissions, "manage_moderation") + + :manage_threads -> + MapSet.member?(permissions, "manage_threads") or + MapSet.member?(permissions, "manage_moderation") + + _ -> + false + end + end + + defp allowed?(%ChatConversation{id: conversation_id}, base_role, actor_uri, action, options) + when is_integer(conversation_id) and is_binary(actor_uri) and is_atom(action) and + is_map(options) do + permissions = effective_permissions(conversation_id, base_role, actor_uri) + + case action do + :participate -> + true + + action when action in [:write, :send_messages] -> + MapSet.member?(permissions, "send_messages") + + :attach_files -> + MapSet.member?(permissions, "attach_files") + + :embed_links -> + MapSet.member?(permissions, "embed_links") + + :mention_everyone -> + MapSet.member?(permissions, "mention_everyone") + + :manage_messages -> + MapSet.member?(permissions, "manage_messages") + + :manage_channels -> + MapSet.member?(permissions, "manage_channels") + + :manage_webhooks -> + MapSet.member?(permissions, "manage_webhooks") + + :manage_server -> + MapSet.member?(permissions, "manage_server") + + :invite -> + MapSet.member?(permissions, "invite_members") + + action when action in [:ban, :moderation_action] -> + MapSet.member?(permissions, "manage_moderation") + + action when action in [:role_upsert, :role_assignment] -> + MapSet.member?(permissions, "manage_roles") + + :permission_overwrite -> + MapSet.member?(permissions, "manage_permissions") + + :thread_upsert -> + MapSet.member?(permissions, "manage_threads") or + MapSet.member?(permissions, "manage_moderation") or owner_matches?(options, actor_uri) + + :thread_archive -> + MapSet.member?(permissions, "manage_threads") or + MapSet.member?(permissions, "manage_moderation") or owner_matches?(options, actor_uri) + + :create_threads -> + MapSet.member?(permissions, "create_threads") or + MapSet.member?(permissions, "manage_threads") or + MapSet.member?(permissions, "manage_moderation") + + :manage_threads -> + MapSet.member?(permissions, "manage_threads") or + MapSet.member?(permissions, "manage_moderation") + + _ -> + false + end + end + + defp allowed?(_conversation, _base_role, _actor_uri, _action, _options), do: false + + defp effective_permissions(conversation_id, base_role, actor_uri) + when is_integer(conversation_id) and is_binary(actor_uri) do + acl_state = load_acl_state(conversation_id) + assigned_role_ids = assigned_role_ids(actor_uri, acl_state.role_assignments) + effective_role_ids = effective_role_ids(base_role, assigned_role_ids) + + permissions = + effective_role_ids + |> Enum.reduce(MapSet.new(), fn role_id, acc -> + MapSet.union(acc, role_permissions(role_id, acl_state.role_definitions)) + end) + |> apply_role_overwrites(effective_role_ids, acl_state.permission_overwrites) + + apply_member_overwrites(permissions, actor_uri, acl_state.permission_overwrites) + end + + defp effective_permissions(_conversation_id, _base_role, _actor_uri), do: MapSet.new() + + defp load_acl_state(conversation_id) when is_integer(conversation_id) do + from(event in FederationExtensionEvent, + where: event.conversation_id == ^conversation_id and event.event_type in ^@acl_event_types, + select: {event.event_type, event.payload} + ) + |> Repo.all() + |> Enum.reduce( + %{role_definitions: %{}, role_assignments: [], permission_overwrites: []}, + fn + {@role_upsert_event_type, payload}, acc -> + case get_in(payload, ["role", "id"]) do + role_id when is_binary(role_id) -> + permissions = + payload + |> get_in(["role", "permissions"]) + |> normalize_permissions() + + put_in(acc, [:role_definitions, role_id], permissions) + + _ -> + acc + end + + {@role_assignment_event_type, payload}, acc -> + role_id = get_in(payload, ["assignment", "role_id"]) + target_type = get_in(payload, ["assignment", "target", "type"]) + target_id = get_in(payload, ["assignment", "target", "id"]) + state = get_in(payload, ["assignment", "state"]) + + if is_binary(role_id) and is_binary(target_type) and is_binary(target_id) and + is_binary(state) do + %{ + acc + | role_assignments: [ + %{ + role_id: role_id, + target_type: target_type, + target_id: target_id, + state: state + } + | acc.role_assignments + ] + } + else + acc + end + + {@permission_overwrite_event_type, payload}, acc -> + target_type = get_in(payload, ["overwrite", "target", "type"]) + target_id = get_in(payload, ["overwrite", "target", "id"]) + + if is_binary(target_type) and is_binary(target_id) do + overwrite = %{ + target_type: target_type, + target_id: target_id, + allow: normalize_permissions(get_in(payload, ["overwrite", "allow"])), + deny: normalize_permissions(get_in(payload, ["overwrite", "deny"])) + } + + %{acc | permission_overwrites: [overwrite | acc.permission_overwrites]} + else + acc + end + + _, acc -> + acc + end + ) + end + + defp assigned_role_ids(actor_uri, role_assignments) + when is_binary(actor_uri) and is_list(role_assignments) do + role_assignments + |> Enum.filter(fn assignment -> + assignment.state == "assigned" and assignment.target_type in ["member", "user"] and + assignment.target_id == actor_uri + end) + |> Enum.map(& &1.role_id) + |> Enum.uniq() + end + + defp assigned_role_ids(_actor_uri, _role_assignments), do: [] + + defp effective_role_ids(base_role, assigned_role_ids) + when is_binary(base_role) and is_list(assigned_role_ids) do + {builtin_ids, custom_ids} = Enum.split_with(assigned_role_ids, &builtin_role?/1) + + effective_builtin_id = + case builtin_ids do + [] -> + Map.get(@base_role_ids, base_role, "builtin:member") + + _ -> + Enum.max_by(builtin_ids, &role_position/1) + end + + Enum.uniq([effective_builtin_id | custom_ids]) + end + + defp effective_role_ids(_base_role, assigned_role_ids), do: Enum.uniq(assigned_role_ids) + + defp role_permissions(role_id, role_definitions) + when is_binary(role_id) and is_map(role_definitions) do + case Map.fetch(@builtin_roles, role_id) do + {:ok, %{permissions: permissions}} -> + permissions + + :error -> + Map.get(role_definitions, role_id, MapSet.new()) + end + end + + defp role_permissions(_role_id, _role_definitions), do: MapSet.new() + + defp apply_role_overwrites(permissions, effective_role_ids, permission_overwrites) + when is_list(effective_role_ids) and is_list(permission_overwrites) do + {allow, deny} = + permission_overwrites + |> Enum.filter(fn overwrite -> + overwrite.target_type == "role" and overwrite.target_id in effective_role_ids + end) + |> Enum.reduce({MapSet.new(), MapSet.new()}, fn overwrite, {allow, deny} -> + {MapSet.union(allow, overwrite.allow), MapSet.union(deny, overwrite.deny)} + end) + + permissions + |> MapSet.difference(deny) + |> MapSet.union(allow) + end + + defp apply_role_overwrites(permissions, _effective_role_ids, _permission_overwrites), + do: permissions + + defp apply_member_overwrites(permissions, actor_uri, permission_overwrites) + when is_binary(actor_uri) and is_list(permission_overwrites) do + {allow, deny} = + permission_overwrites + |> Enum.filter(fn overwrite -> + overwrite.target_type in ["member", "user"] and overwrite.target_id == actor_uri + end) + |> Enum.reduce({MapSet.new(), MapSet.new()}, fn overwrite, {allow, deny} -> + {MapSet.union(allow, overwrite.allow), MapSet.union(deny, overwrite.deny)} + end) + + permissions + |> MapSet.difference(deny) + |> MapSet.union(allow) + end + + defp apply_member_overwrites(permissions, _actor_uri, _permission_overwrites), do: permissions + + defp local_actor_uri(user_id) when is_integer(user_id) do + case Repo.get(User, user_id) do + %User{} = user -> Utils.sender_payload(user)["uri"] + _ -> nil + end + end + + defp owner_matches?(options, actor_uri) when is_map(options) and is_binary(actor_uri) do + options[:owner_actor_uri] == actor_uri or + options[:thread_owner_actor_uri] == actor_uri or + options[:owner_remote_actor_id] == options[:remote_actor_id] or + options[:thread_owner_remote_actor_id] == options[:remote_actor_id] + end + + defp owner_matches?(_options, _actor_uri), do: false + + defp normalize_permissions(values) when is_list(values) do + values + |> Enum.filter(&is_binary/1) + |> MapSet.new() + end + + defp normalize_permissions(_values), do: MapSet.new() + + defp builtin_role?(role_id) when is_binary(role_id), do: Map.has_key?(@builtin_roles, role_id) + defp builtin_role?(_role_id), do: false + + defp role_position(role_id) when is_binary(role_id) do + role_id + |> then(&Map.get(@builtin_roles, &1, %{position: 0})) + |> Map.get(:position, 0) + end + + defp role_position(_role_id), do: 0 +end diff --git a/apps/elektrine/lib/elektrine/messaging/server.ex b/apps/elektrine/lib/elektrine/messaging/server.ex new file mode 100644 index 0000000..2074ebc --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/server.ex @@ -0,0 +1,80 @@ +defmodule Elektrine.Messaging.Server do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "messaging_servers" do + field :name, :string + field :description, :string + field :icon_url, :string + field :is_public, :boolean, default: false + field :member_count, :integer, default: 0 + field :last_activity_at, :utc_datetime + field :last_federated_at, :utc_datetime + field :federation_id, :string + field :origin_domain, :string + field :is_federated_mirror, :boolean, default: false + + belongs_to :creator, Elektrine.Accounts.User + has_many :members, Elektrine.Messaging.ServerMember, foreign_key: :server_id + has_many :channels, Elektrine.Messaging.ChatConversation, foreign_key: :server_id + + timestamps() + end + + @doc false + def changeset(server, attrs) do + server + |> cast(attrs, [ + :name, + :description, + :icon_url, + :is_public, + :member_count, + :last_activity_at, + :last_federated_at, + :federation_id, + :origin_domain, + :is_federated_mirror, + :creator_id + ]) + |> validate_required([:name]) + |> validate_length(:name, min: 2, max: 80) + |> validate_length(:description, max: 500) + |> validate_name_security() + |> maybe_validate_creator_id() + |> foreign_key_constraint(:creator_id) + |> unique_constraint(:federation_id, name: :messaging_servers_federation_id_unique) + end + + defp validate_name_security(changeset) do + case get_change(changeset, :name) do + nil -> + changeset + + name when is_binary(name) -> + cleaned_name = + name + |> String.replace(~r/<[^>]+>/, "") + |> String.replace(~r/javascript:/i, "") + |> String.trim() + + if Elektrine.Strings.present?(cleaned_name) do + put_change(changeset, :name, cleaned_name) + else + add_error(changeset, :name, "cannot be empty") + end + + _ -> + changeset + end + end + + defp maybe_validate_creator_id(changeset) do + if get_field(changeset, :is_federated_mirror) do + changeset + else + validate_required(changeset, [:creator_id]) + end + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/server_member.ex b/apps/elektrine/lib/elektrine/messaging/server_member.ex new file mode 100644 index 0000000..c295205 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/server_member.ex @@ -0,0 +1,56 @@ +defmodule Elektrine.Messaging.ServerMember do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "messaging_server_members" do + field :role, :string, default: "member" + field :joined_at, :utc_datetime + field :left_at, :utc_datetime + field :notifications_enabled, :boolean, default: true + + belongs_to :server, Elektrine.Messaging.Server + belongs_to :user, Elektrine.Accounts.User + + timestamps() + end + + @doc false + def changeset(member, attrs) do + member + |> cast(attrs, [ + :server_id, + :user_id, + :role, + :joined_at, + :left_at, + :notifications_enabled + ]) + |> validate_required([:server_id, :user_id]) + |> validate_inclusion(:role, ["owner", "admin", "moderator", "member"]) + |> foreign_key_constraint(:server_id) + |> foreign_key_constraint(:user_id) + |> unique_constraint([:server_id, :user_id]) + |> maybe_set_joined_at() + end + + @doc """ + Creates a changeset for adding a member to a server. + """ + def add_member_changeset(server_id, user_id, role \\ "member") do + %__MODULE__{} + |> changeset(%{ + server_id: server_id, + user_id: user_id, + role: role, + joined_at: DateTime.utc_now() + }) + end + + defp maybe_set_joined_at(changeset) do + case get_field(changeset, :joined_at) do + nil -> put_change(changeset, :joined_at, DateTime.utc_now()) + _ -> changeset + end + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/servers.ex b/apps/elektrine/lib/elektrine/messaging/servers.ex new file mode 100644 index 0000000..53612ec --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/servers.ex @@ -0,0 +1,706 @@ +defmodule Elektrine.Messaging.Servers do + @moduledoc """ + Context for community servers and server-scoped channels. + """ + + import Ecto.Query, warn: false + require Logger + alias Elektrine.HTTP.SafeFetch + alias Elektrine.Repo + + alias Elektrine.Messaging.{ + ChannelCategories, + ChatConversation, + ChatConversations, + Federation, + Server, + ServerMember + } + + @default_discovery_timeout_ms 5_000 + + @doc """ + Lists all servers where the user has an active membership. + """ + def list_servers(user_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + + from(s in Server, + join: sm in ServerMember, + on: sm.server_id == s.id and sm.user_id == ^user_id and is_nil(sm.left_at), + order_by: [desc: sm.inserted_at], + limit: ^limit, + preload: [:creator] + ) + |> Repo.all() + end + + @doc """ + Lists public servers the user can discover and join. + """ + def list_public_servers(user_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + query = normalize_search_query(Keyword.get(opts, :query)) + _ = maybe_sync_remote_public_servers(query, limit, opts) + + joined_server_ids_query = + from(sm in ServerMember, + where: sm.user_id == ^user_id and is_nil(sm.left_at), + select: sm.server_id + ) + + from(s in Server, + where: s.is_public == true and s.id not in subquery(joined_server_ids_query), + order_by: [desc: s.member_count, desc: s.last_activity_at, desc: s.inserted_at], + limit: ^limit, + preload: [:creator] + ) + |> maybe_filter_discoverable_servers(query) + |> Repo.all() + end + + @doc """ + Lists local public servers for federation directory export. + """ + def list_public_directory_servers(opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + query = normalize_search_query(Keyword.get(opts, :query)) + + from(s in Server, + where: s.is_public == true and s.is_federated_mirror != true, + order_by: [desc: s.member_count, desc: s.last_activity_at, desc: s.inserted_at], + limit: ^limit, + preload: [:creator] + ) + |> maybe_filter_discoverable_servers(query) + |> Repo.all() + end + + @doc """ + Gets a server with channels for a user. + """ + def get_server(server_id, user_id) do + channel_query = + from(c in ChatConversation, + where: c.type == "channel", + order_by: [asc: c.channel_position, asc: c.inserted_at] + ) + + from(s in Server, + join: sm in ServerMember, + on: sm.server_id == s.id and sm.user_id == ^user_id and is_nil(sm.left_at), + where: s.id == ^server_id, + preload: [:creator, channels: ^channel_query] + ) + |> Repo.one() + |> case do + nil -> {:error, :not_found} + server -> {:ok, server} + end + end + + @doc """ + Gets the active membership record for a user in a server. + """ + def get_server_member(server_id, user_id) do + from(sm in ServerMember, + where: sm.server_id == ^server_id and sm.user_id == ^user_id and is_nil(sm.left_at) + ) + |> Repo.one() + end + + @doc """ + Creates a server and seeds it with a default `#general` channel. + """ + def create_server(creator_id, attrs) do + attrs = Map.put(attrs, :creator_id, creator_id) + + Repo.transaction(fn -> + with {:ok, server} <- %Server{} |> Server.changeset(attrs) |> Repo.insert(), + {:ok, _owner_member} <- do_add_member(server.id, creator_id, "owner"), + {:ok, _general_channel} <- do_create_channel(server.id, creator_id, %{}) do + get_server(server.id, creator_id) + else + {:error, reason} -> + Repo.rollback(reason) + end + end) + |> case do + {:ok, {:ok, server}} -> + Federation.maybe_push_for_server(server.id) + {:ok, server} + + {:ok, {:error, reason}} -> + {:error, reason} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Creates a text channel inside a server. + Only owners/admins/moderators can create channels. + """ + def create_server_channel(server_id, creator_id, attrs) do + with %Server{} <- Repo.get(Server, server_id), + %ServerMember{} = member <- get_server_member(server_id, creator_id), + true <- member.role in ["owner", "admin", "moderator"] do + Repo.transaction(fn -> + do_create_channel(server_id, creator_id, attrs) + end) + |> case do + {:ok, {:ok, channel}} -> + Federation.maybe_push_for_server(server_id) + {:ok, channel} + + {:ok, {:error, reason}} -> + {:error, reason} + + {:error, reason} -> + {:error, reason} + end + else + nil -> {:error, :not_found} + false -> {:error, :unauthorized} + end + end + + @doc """ + Joins a public server and auto-joins all current server channels. + """ + def join_server(server_id, user_id) do + case Repo.get(Server, server_id) do + nil -> + {:error, :not_found} + + %Server{is_public: false} -> + {:error, :not_public} + + %Server{} = server -> + with :ok <- maybe_hydrate_mirror_server(server) do + Repo.transaction(fn -> + with {:ok, member} <- do_add_member(server_id, user_id, "member"), + :ok <- maybe_add_user_to_server_channels(server, user_id) do + {:ok, member} + else + {:error, reason} -> Repo.rollback(reason) + end + end) + |> case do + {:ok, {:ok, member}} -> + Federation.maybe_push_for_server(server_id) + {:ok, member} + + {:ok, other} -> + other + + {:error, reason} -> + {:error, reason} + end + end + end + end + + defp do_create_channel(server_id, creator_id, attrs) do + next_position = next_channel_position(server_id) + attrs = sanitize_channel_category(attrs, server_id) + + channel_attrs = + attrs + |> Map.take([ + :name, + :description, + :avatar_url, + :channel_topic, + :channel_position, + :category_id, + :is_public + ]) + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + |> Map.put(:creator_id, creator_id) + |> Map.put(:server_id, server_id) + # Community-server default: channels are visible to all members unless marked private. + |> Map.put_new(:is_public, true) + |> Map.put_new(:name, "general") + |> Map.put_new(:description, "Default server channel") + |> Map.put_new(:channel_position, next_position) + + with {:ok, channel} <- + %ChatConversation{} + |> channel_type_changeset(Map.get(attrs, :type), channel_attrs) + |> Repo.insert(), + :ok <- add_all_server_members_to_channel(server_id, channel.id) do + {:ok, channel} + end + end + + # Voice channels are local-only: they are never exported through federation + # bootstrap (which only serializes type == "channel" conversations). + defp channel_type_changeset(conversation, "voice_channel", channel_attrs) do + ChatConversation.voice_channel_changeset(conversation, channel_attrs) + end + + defp channel_type_changeset(conversation, _type, channel_attrs) do + ChatConversation.channel_changeset(conversation, channel_attrs) + end + + # Drops :category_id unless it references a category of this server. + defp sanitize_channel_category(attrs, server_id) do + case ChannelCategories.resolve_category_for_server(Map.get(attrs, :category_id), server_id) do + {:ok, category_id} -> Map.put(attrs, :category_id, category_id) + {:error, _} -> Map.delete(attrs, :category_id) + end + end + + defp next_channel_position(server_id) do + from(c in ChatConversation, + where: c.server_id == ^server_id and c.type in ["channel", "voice_channel"], + select: max(c.channel_position) + ) + |> Repo.one() + |> case do + nil -> 0 + position -> position + 1 + end + end + + defp add_all_server_members_to_channel(server_id, channel_id) do + user_ids = + from(sm in ServerMember, + where: sm.server_id == ^server_id and is_nil(sm.left_at), + select: sm.user_id + ) + |> Repo.all() + + Enum.reduce_while(user_ids, :ok, fn user_id, :ok -> + case ChatConversations.add_member_to_conversation(channel_id, user_id, "member") do + {:ok, _member} -> {:cont, :ok} + {:error, :banned} -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp add_user_to_all_server_channels(server_id, user_id) do + channel_ids = + from(c in ChatConversation, + where: c.server_id == ^server_id and c.type in ["channel", "voice_channel"], + select: c.id + ) + |> Repo.all() + + Enum.reduce_while(channel_ids, :ok, fn channel_id, :ok -> + case ChatConversations.add_member_to_conversation(channel_id, user_id, "member") do + {:ok, _member} -> {:cont, :ok} + {:error, :banned} -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + # Remote mirrored servers no longer auto-join all rooms. Room access is now + # explicitly granted per channel by the authoritative origin. + defp maybe_add_user_to_server_channels(%Server{is_federated_mirror: true}, _user_id), do: :ok + + defp maybe_add_user_to_server_channels(%Server{id: server_id}, user_id) + when is_integer(server_id) and is_integer(user_id) do + add_user_to_all_server_channels(server_id, user_id) + end + + defp maybe_add_user_to_server_channels(_server, _user_id), do: {:error, :invalid_event_payload} + + defp do_add_member(server_id, user_id, role) do + existing_member = + Repo.get_by(ServerMember, server_id: server_id, user_id: user_id) + + case existing_member do + nil -> + ServerMember.add_member_changeset(server_id, user_id, role) + |> Repo.insert() + |> case do + {:ok, member} -> + update_member_count(server_id) + {:ok, member} + + {:error, reason} -> + {:error, reason} + end + + %ServerMember{left_at: nil} -> + {:error, :already_member} + + member -> + member + |> ServerMember.changeset(%{ + left_at: nil, + joined_at: DateTime.utc_now(), + role: role + }) + |> Repo.update() + |> case do + {:ok, updated_member} -> + update_member_count(server_id) + {:ok, updated_member} + + {:error, reason} -> + {:error, reason} + end + end + end + + defp update_member_count(server_id) do + count = + from(sm in ServerMember, + where: sm.server_id == ^server_id and is_nil(sm.left_at), + select: count() + ) + |> Repo.one() + + from(s in Server, where: s.id == ^server_id) + |> Repo.update_all(set: [member_count: count]) + end + + defp maybe_filter_discoverable_servers(query, nil), do: query + + defp maybe_filter_discoverable_servers(query, search_term) do + pattern = "%#{search_term}%" + + from(s in query, + where: + ilike(s.name, ^pattern) or ilike(s.description, ^pattern) or + ilike(s.origin_domain, ^pattern) + ) + end + + defp normalize_search_query(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp normalize_search_query(_), do: nil + + defp maybe_sync_remote_public_servers(query, limit, opts) do + include_remote = Keyword.get(opts, :include_remote, true) + + if include_remote and Federation.enabled?() do + remote_servers = + case Keyword.get(opts, :remote_discovery_fn) do + fun when is_function(fun, 2) -> + fun.(query, limit) + + _ -> + fetch_remote_public_servers(query, limit) + end + + remote_servers + |> normalize_remote_public_servers() + |> Enum.each(fn attrs -> + case upsert_discovered_remote_server(attrs) do + {:ok, _server} -> + :ok + + {:error, :federation_origin_conflict} -> + Logger.warning( + "Skipping remote discovery entry due to federation origin conflict: #{attrs.federation_id}" + ) + + {:error, reason} -> + Logger.warning( + "Failed to upsert remote discovery entry #{attrs.federation_id}: #{inspect(reason)}" + ) + end + end) + end + + :ok + rescue + error -> + Logger.warning("Remote public server sync failed: #{Exception.message(error)}") + :ok + end + + defp fetch_remote_public_servers(query, limit) do + peers = Federation.outgoing_peers() + timeout_ms = remote_discovery_timeout_ms() + + if peers == [] do + [] + else + peers + |> Task.async_stream( + fn peer -> fetch_remote_public_servers_from_peer(peer, query, limit, timeout_ms) end, + max_concurrency: min(length(peers), 4), + timeout: timeout_ms + 1_000, + ordered: false + ) + |> Enum.flat_map(fn + {:ok, {:ok, servers}} -> + servers + + {:ok, {:error, peer_domain, reason}} -> + Logger.warning( + "Remote server directory fetch failed for #{peer_domain}: #{inspect(reason)}" + ) + + [] + + {:exit, reason} -> + Logger.warning("Remote server directory fetch task exited: #{inspect(reason)}") + [] + end) + end + end + + defp fetch_remote_public_servers_from_peer(peer, query, limit, timeout_ms) do + path = "/_arblarg/servers/public" + query_string = build_remote_discovery_query_string(query, limit) + url = remote_public_servers_url(peer, path, query_string) + headers = Federation.signed_headers(peer, "GET", path, query_string, "") + request = Finch.build(:get, url, headers) + + case SafeFetch.request(request, Elektrine.Finch, + receive_timeout: timeout_ms, + pool_timeout: 2_000 + ) do + {:ok, %Finch.Response{status: status, body: body}} when status in 200..299 -> + case Jason.decode(body) do + {:ok, payload} -> + {:ok, parse_remote_discovery_payload(payload, peer)} + + _ -> + {:error, peer.domain, :invalid_json} + end + + {:ok, %Finch.Response{status: status, body: body}} -> + {:error, peer.domain, {:http_error, status, truncate(body)}} + + {:error, reason} -> + {:error, peer.domain, reason} + end + end + + defp parse_remote_discovery_payload(%{"servers" => servers}, peer) when is_list(servers) do + Enum.map(servers, &normalize_remote_public_server(&1, peer)) + |> Enum.reject(&is_nil/1) + end + + defp parse_remote_discovery_payload(%{servers: servers}, peer) when is_list(servers) do + Enum.map(servers, &normalize_remote_public_server(&1, peer)) + |> Enum.reject(&is_nil/1) + end + + defp parse_remote_discovery_payload(_payload, _peer), do: [] + + defp normalize_remote_public_servers(servers) when is_list(servers) do + Enum.map(servers, &normalize_remote_public_server(&1, nil)) + |> Enum.reject(&is_nil/1) + end + + defp normalize_remote_public_servers(_), do: [] + + defp normalize_remote_public_server(entry, peer) when is_map(entry) do + peer_domain = + case peer do + %{domain: domain} when is_binary(domain) -> String.downcase(domain) + _ -> normalize_optional_string(value_from(entry, :origin_domain)) + end + + origin_domain = + normalize_optional_string(value_from(entry, :origin_domain)) + |> case do + nil -> peer_domain + value -> String.downcase(value) + end + + remote_server_id = parse_int(value_from(entry, :server_id), nil) + + federation_id = + normalize_optional_string(value_from(entry, :federation_id)) || + normalize_optional_string(value_from(entry, :id)) || + remote_server_federation_id(peer, remote_server_id) + + name = normalize_optional_string(value_from(entry, :name)) + is_public = parse_bool(value_from(entry, :is_public), true) + + cond do + is_nil(name) -> + nil + + is_nil(federation_id) -> + nil + + is_nil(origin_domain) -> + nil + + is_binary(peer_domain) and origin_domain != peer_domain -> + nil + + is_public != true -> + nil + + true -> + %{ + name: name, + description: normalize_optional_string(value_from(entry, :description)), + icon_url: normalize_optional_string(value_from(entry, :icon_url)), + is_public: true, + member_count: max(parse_int(value_from(entry, :member_count), 0), 0), + federation_id: federation_id, + origin_domain: origin_domain + } + end + end + + defp normalize_remote_public_server(_entry, _peer), do: nil + + defp upsert_discovered_remote_server(attrs) when is_map(attrs) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + attrs = + attrs + |> Map.put(:is_federated_mirror, true) + |> Map.put(:last_federated_at, now) + + case Repo.get_by(Server, federation_id: attrs.federation_id) do + nil -> + %Server{} |> Server.changeset(attrs) |> Repo.insert() + + %Server{origin_domain: existing_origin_domain} = server -> + normalized_existing = normalize_optional_string(existing_origin_domain) + + if is_binary(normalized_existing) and + String.downcase(normalized_existing) != String.downcase(attrs.origin_domain) do + {:error, :federation_origin_conflict} + else + server |> Server.changeset(attrs) |> Repo.update() + end + end + end + + defp maybe_hydrate_mirror_server(%Server{is_federated_mirror: true} = server) do + if server_has_channels?(server.id) do + :ok + else + case Federation.refresh_mirror_server_snapshot(server) do + {:ok, _server} -> + :ok + + {:error, reason} -> + Logger.warning( + "Failed to hydrate mirror server #{server.id} (#{server.federation_id}): #{inspect(reason)}" + ) + + {:error, :federation_sync_unavailable} + end + end + end + + defp maybe_hydrate_mirror_server(_server), do: :ok + + defp server_has_channels?(server_id) do + from(c in ChatConversation, + where: c.server_id == ^server_id and c.type == "channel", + select: c.id, + limit: 1 + ) + |> Repo.exists?() + end + + defp remote_public_servers_url(peer, path, query_string) do + query_suffix = + case query_string do + value when is_binary(value) -> + if(Elektrine.Strings.present?(value), do: "?" <> value, else: "") + + _ -> + "" + end + + endpoint = + case peer do + %{directory_endpoint: endpoint} when is_binary(endpoint) -> + if Elektrine.Strings.present?(endpoint), + do: String.trim_trailing(endpoint, "/"), + else: peer.base_url <> path + + _ -> + peer.base_url <> path + end + + endpoint <> query_suffix + end + + defp build_remote_discovery_query_string(query, limit) do + [ + {"limit", Integer.to_string(limit)}, + {"query", query} + ] + |> Enum.reject(fn {_key, value} -> + is_nil(value) or (is_binary(value) and not Elektrine.Strings.present?(value)) + end) + |> URI.encode_query() + end + + defp remote_server_federation_id(%{base_url: base_url}, remote_server_id) + when is_binary(base_url) and is_integer(remote_server_id) do + "#{base_url}/_arblarg/servers/#{remote_server_id}" + end + + defp remote_server_federation_id(_, _), do: nil + + defp remote_discovery_timeout_ms do + Application.get_env(:elektrine, :messaging_federation, []) + |> Keyword.get(:discovery_timeout_ms, @default_discovery_timeout_ms) + end + + defp truncate(nil), do: "" + + defp truncate(body) when is_binary(body) do + if byte_size(body) > 180 do + binary_part(body, 0, 180) <> "..." + else + body + end + end + + defp truncate(body), do: inspect(body) + + defp value_from(data, key) when is_map(data) do + Map.get(data, key) || Map.get(data, Atom.to_string(key)) + end + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(_), do: nil + + defp parse_int(value, _default) when is_integer(value), do: value + + defp parse_int(value, default) when is_binary(value) do + case Integer.parse(value) do + {int, _} -> int + :error -> default + end + end + + defp parse_int(_value, default), do: default + + defp parse_bool(value, _default) when is_boolean(value), do: value + + defp parse_bool(value, default) when is_binary(value) do + case String.downcase(String.trim(value)) do + "true" -> true + "1" -> true + "false" -> false + "0" -> false + _ -> default + end + end + + defp parse_bool(nil, default), do: default + defp parse_bool(_value, default), do: default +end diff --git a/apps/elektrine/lib/elektrine/messaging/slash_commands.ex b/apps/elektrine/lib/elektrine/messaging/slash_commands.ex new file mode 100644 index 0000000..0b7dfe4 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/slash_commands.ex @@ -0,0 +1,80 @@ +defmodule Elektrine.Messaging.SlashCommands do + @moduledoc """ + Parses and resolves chat slash commands. + """ + + @type result :: {:send, String.t()} | {:noop, String.t()} | {:error, String.t()} + + @spec process(String.t(), keyword()) :: result + def process(input, opts \\ []) when is_binary(input) do + trimmed = String.trim(input) + + if not Elektrine.Strings.present?(trimmed) or not String.starts_with?(trimmed, "/") do + {:send, trimmed} + else + execute(trimmed, opts) + end + end + + defp execute(trimmed, opts) do + parts = String.split(trimmed, ~r/\s+/, trim: true) + command = parts |> List.first() |> String.downcase() + arg_text = String.replace_prefix(trimmed, List.first(parts) || "", "") |> String.trim() + + case command do + "/help" -> + {:noop, + "Commands: /help, /me , /shrug [text], /tableflip [text], /unflip [text], /invite"} + + "/me" -> + format_me(arg_text, opts) + + "/shrug" -> + {:send, append_tail(arg_text, "¯\\_(ツ)_/¯")} + + "/tableflip" -> + {:send, append_tail(arg_text, "(╯°□°)╯︵ ┻━┻")} + + "/unflip" -> + {:send, append_tail(arg_text, "┬─┬ ノ( ゜-゜ノ)")} + + "/invite" -> + build_invite_link(opts) + + _ -> + {:error, "Unknown command. Use /help to see available commands."} + end + end + + defp format_me("", _opts), do: {:error, "Usage: /me "} + + defp format_me(action, opts) do + actor = + opts[:user_display] || + opts[:user_handle] || + opts[:username] || + "someone" + + {:send, "*#{actor} #{action}*"} + end + + defp append_tail("", suffix), do: suffix + defp append_tail(prefix, suffix), do: "#{prefix} #{suffix}" + + defp build_invite_link(opts) do + conversation = opts[:conversation] + endpoint_url = opts[:endpoint_url] + + cond do + is_nil(conversation) -> + {:error, "No active conversation to share."} + + not Elektrine.Strings.present?(endpoint_url) -> + {:error, "Invite link is unavailable right now."} + + true -> + identifier = conversation.hash || conversation.id + {:send, "#{endpoint_url}/chat/join/#{identifier}"} + end + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/social_conversation.ex b/apps/elektrine/lib/elektrine/messaging/social_conversation.ex new file mode 100644 index 0000000..f828fe9 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/social_conversation.ex @@ -0,0 +1,343 @@ +defmodule Elektrine.Social.Conversation do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + alias Elektrine.Messaging.ChatConversation + + schema "social_conversations" do + field :name, :string + field :description, :string + field :type, :string + field :avatar_url, :string + field :is_public, :boolean, default: false + field :member_count, :integer, default: 0 + field :last_message_at, :utc_datetime + field :archived, :boolean, default: false + field :hash, :string + + # Community features (for type = "community") + field :community_category, :string + field :allow_public_posts, :boolean, default: false + field :discussion_style, :string, default: "chat" + field :community_rules, :string + + # Moderation settings + field :slow_mode_seconds, :integer, default: 0 + field :approval_mode_enabled, :boolean, default: false + field :approval_threshold_posts, :integer, default: 3 + + # Server-scoped channel metadata + field :channel_topic, :string + field :channel_position, :integer, default: 0 + + # Federated community mirroring (for Lemmy, Guppe groups, etc.) + # ActivityPub Group URI + field :federated_source, :string + # True if mirrors remote community + field :is_federated_mirror, :boolean, default: false + # Link to Group actor + belongs_to :remote_group_actor, Elektrine.ActivityPub.Actor + + belongs_to :creator, Elektrine.Accounts.User + belongs_to :server, Elektrine.Messaging.Server + has_many :channels, __MODULE__, foreign_key: :server_id + has_many :members, Elektrine.Social.ConversationMember, foreign_key: :conversation_id + has_many :users, through: [:members, :user] + has_many :messages, Elektrine.Social.Message, foreign_key: :conversation_id + + timestamps() + end + + @doc false + def changeset(conversation, attrs) do + conversation + |> cast(attrs, [ + :name, + :description, + :type, + :creator_id, + :avatar_url, + :is_public, + :member_count, + :last_message_at, + :archived, + :community_category, + :allow_public_posts, + :discussion_style, + :community_rules, + :slow_mode_seconds, + :approval_mode_enabled, + :approval_threshold_posts, + :channel_topic, + :channel_position, + :server_id, + :federated_source, + :is_federated_mirror, + :remote_group_actor_id + ]) + |> validate_required([:type]) + |> validate_inclusion(:type, ["dm", "group", "channel", "community", "timeline"]) + |> validate_length(:channel_topic, max: 300) + |> validate_number(:channel_position, greater_than_or_equal_to: 0) + |> validate_inclusion(:discussion_style, ["chat", "forum", "hybrid"]) + |> validate_inclusion(:community_category, [ + nil, + "tech", + "gaming", + "art", + "science", + "music", + "general", + "sports", + "food", + "travel", + "fitness", + "movies", + "tv", + "books", + "education", + "business", + "finance", + "politics", + "news", + "fashion", + "photography", + "anime", + "comics", + "diy", + "crafts", + "automotive", + "pets", + "nature", + "history", + "philosophy", + "psychology", + "health", + "medicine", + "law", + "writing", + "programming", + "design", + "marketing", + "crypto", + "stocks", + "realestate", + "career", + "relationships", + "parenting", + "religion", + "spirituality", + "comedy", + "memes", + "conspiracy", + "paranormal", + "space", + "environment", + "energy" + ]) + |> validate_length(:name, max: 100) + |> validate_length(:description, max: 500) + |> validate_name_security() + |> validate_description_security() + |> downcase_name() + |> foreign_key_constraint(:creator_id) + |> foreign_key_constraint(:server_id) + |> unique_constraint(:name, + name: :conversations_community_name_ci_unique, + message: "A community with this name already exists" + ) + |> unique_constraint(:federated_source, + name: :conversations_channel_federated_source_unique, + message: "Channel already exists from this federated source" + ) + |> generate_hash_if_needed() + end + + @doc """ + Creates a changeset for a direct message conversation. + """ + def dm_changeset(conversation \\ %__MODULE__{}, attrs) do + conversation + |> changeset(Map.put(attrs, :type, "dm")) + end + + @doc """ + Creates a changeset for a group conversation. + Also used for communities - respects the type if already set to "community". + """ + def group_changeset(conversation \\ %__MODULE__{}, attrs) do + # Use the provided type if it's "community", otherwise default to "group" + type = Map.get(attrs, :type, "group") + + conversation + |> changeset(Map.put(attrs, :type, type)) + |> validate_required([:name]) + |> maybe_validate_creator_id() + end + + @doc """ + Creates a changeset for a channel conversation. + """ + def channel_changeset(conversation \\ %__MODULE__{}, attrs) do + conversation + |> changeset(Map.put(attrs, :type, "channel")) + |> validate_required([:name]) + |> maybe_validate_creator_id() + end + + @doc """ + Returns display name for the conversation from a user's perspective. + """ + def display_name(%__MODULE__{type: "dm", members: members}, current_user_id) + when is_list(members) do + case Enum.find(members, fn member -> + member.user_id != current_user_id and is_nil(member.left_at) + end) do + %{user: user} -> user.display_name || user.username + nil -> "Unknown User" + end + end + + def display_name( + %__MODULE__{type: "dm", federated_source: federated_source, name: name}, + _current_user_id + ) do + cond do + Elektrine.Strings.present?(name) -> + name + + is_binary(federated_source) and String.starts_with?(federated_source, "arblarg:dm:") -> + "@" <> String.replace_prefix(federated_source, "arblarg:dm:", "") + + true -> + "Unknown User" + end + end + + def display_name(%__MODULE__{name: name}, _current_user_id) do + name || "Unnamed Conversation" + end + + def display_name(%ChatConversation{} = conversation, current_user_id) do + conversation + |> Map.from_struct() + |> Map.put(:__struct__, __MODULE__) + |> display_name(current_user_id) + end + + @doc """ + Returns the avatar URL for the conversation from a user's perspective. + """ + def avatar_url(%__MODULE__{type: "dm", members: members}, current_user_id) + when is_list(members) do + case Enum.find(members, fn member -> + member.user_id != current_user_id and is_nil(member.left_at) + end) do + %{user: user} -> user.avatar + nil -> nil + end + end + + def avatar_url(%__MODULE__{type: "dm", avatar_url: avatar_url}, _current_user_id) + when is_binary(avatar_url) do + if Elektrine.Strings.present?(avatar_url), do: avatar_url, else: nil + end + + def avatar_url(%__MODULE__{avatar_url: avatar_url}, _current_user_id) do + avatar_url + end + + def avatar_url(%ChatConversation{} = conversation, current_user_id) do + conversation + |> Map.from_struct() + |> Map.put(:__struct__, __MODULE__) + |> avatar_url(current_user_id) + end + + # Security validation functions + + defp validate_name_security(changeset) do + case get_change(changeset, :name) do + nil -> + changeset + + name when is_binary(name) -> + # Remove potentially dangerous content from names + cleaned_name = + name + # Remove HTML tags + |> String.replace(~r/<[^>]+>/, "") + |> String.replace(~r/javascript:/i, "") + |> String.trim() + + if String.length(cleaned_name) != String.length(name) do + add_error(changeset, :name, "contains invalid characters") + else + put_change(changeset, :name, cleaned_name) + end + + _ -> + changeset + end + end + + defp validate_description_security(changeset) do + case get_change(changeset, :description) do + nil -> + changeset + + description when is_binary(description) -> + # Remove potentially dangerous content from descriptions + cleaned_description = + description + |> String.replace(~r/)<[^<]*)*<\/script>/mi, "") + |> String.replace(~r/)<[^<]*)*<\/iframe>/mi, "") + |> String.replace(~r/javascript:/i, "") + |> String.trim() + + if String.length(cleaned_description) != String.length(description) do + add_error(changeset, :description, "contains potentially unsafe content") + else + put_change(changeset, :description, cleaned_description) + end + + _ -> + changeset + end + end + + # Convert community name to lowercase for case-insensitive uniqueness + defp downcase_name(changeset) do + case get_change(changeset, :name) do + nil -> + changeset + + name when is_binary(name) -> + put_change(changeset, :name, String.downcase(name)) + + _ -> + changeset + end + end + + # Generate a unique hash for the conversation if it doesn't have one + defp generate_hash_if_needed(changeset) do + case get_field(changeset, :hash) do + nil -> + hash = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) + put_change(changeset, :hash, hash) + + _ -> + changeset + end + end + + defp maybe_validate_creator_id(changeset) do + if get_field(changeset, :is_federated_mirror) do + changeset + else + validate_required(changeset, [:creator_id]) + end + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/social_message.ex b/apps/elektrine/lib/elektrine/messaging/social_message.ex new file mode 100644 index 0000000..10c6f33 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/social_message.ex @@ -0,0 +1,1004 @@ +defmodule Elektrine.Social.Message do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + alias Elektrine.ActivityPub.Mentions + alias Elektrine.Security.SafeExternalURL + alias Elektrine.Social.EngagementCounts + + @varchar_limit 255 + @local_content_max 20_000 + @federated_content_max 20_000 + + schema "social_messages" do + field :content, :string + field :encrypted_content, :map + field :search_index, {:array, :string}, default: [] + field :message_type, :string, default: "text" + field :media_urls, {:array, :string}, default: [] + field :media_metadata, :map, default: %{} + field :edited_at, :utc_datetime + field :deleted_at, :utc_datetime + + # Social features + field :visibility, :string, default: "conversation" + field :post_type, :string, default: "message" + field :like_count, :integer, default: 0 + field :dislike_count, :integer, default: 0 + field :reply_count, :integer, default: 0 + field :share_count, :integer, default: 0 + field :quote_count, :integer, default: 0 + field :remote_like_count, :integer + field :remote_reply_count, :integer + field :remote_share_count, :integer + field :remote_quote_count, :integer + field :remote_counts_fetched_at, :utc_datetime + + belongs_to :conversation, Elektrine.Social.Conversation + belongs_to :sender, Elektrine.Accounts.User + belongs_to :reply_to, __MODULE__ + + belongs_to :link_preview, + {"link_previews", Elektrine.Social.LinkPreview} + + has_many :replies, __MODULE__, foreign_key: :reply_to_id + has_many :reactions, Elektrine.Social.MessageReaction, foreign_key: :message_id + has_one :message_stat, Elektrine.Social.MessageStat, foreign_key: :message_id + + # Cross-context promotion fields + belongs_to :original_message, __MODULE__ + belongs_to :shared_message, __MODULE__ + # Quote posts (Mastodon-style quoting) + belongs_to :quoted_message, __MODULE__ + has_many :quoted_by, __MODULE__, foreign_key: :quoted_message_id + field :promoted_from, :string + field :share_type, :string + field :promoted_from_community_name, :string + field :promoted_from_community_hash, :string + + # Post titles (Reddit-style) + field :title, :string + field :auto_title, :boolean, default: false + + # Social features + field :extracted_urls, {:array, :string}, default: [] + field :extracted_hashtags, {:array, :string}, default: [] + + # Discussion voting + field :upvotes, :integer, default: 0 + field :downvotes, :integer, default: 0 + field :score, :integer, default: 0 + + # Community flair + belongs_to :flair, Elektrine.Messaging.CommunityFlair + + # Pinned post fields + field :is_pinned, :boolean, default: false + field :pinned_at, :utc_datetime + belongs_to :pinned_by, Elektrine.Accounts.User + + # Thread locking fields + field :locked_at, :utc_datetime + belongs_to :locked_by, Elektrine.Accounts.User + field :lock_reason, :string + + # Post approval fields + field :approval_status, :string + belongs_to :approved_by, Elektrine.Accounts.User + field :approved_at, :utc_datetime + + # Post type specific fields + # For link-type posts + field :primary_url, :string + has_one :poll, {"polls", Elektrine.Social.Poll} + + many_to_many :hashtags, {"hashtags", Elektrine.Social.Hashtag}, + join_through: "post_hashtags", + on_replace: :delete + + # ActivityPub Federation + # Text in DB (long URLs) + field :activitypub_id, :string + field :activitypub_id_canonical, :string + # Text in DB (long URLs) + field :activitypub_url, :string + field :activitypub_url_canonical, :string + # Text in DB (at://did/app.bsky.feed.post/rkey) + field :bluesky_uri, :string + field :bluesky_cid, :string + field :federated, :boolean, default: false + belongs_to :remote_actor, Elektrine.ActivityPub.Actor + + # Content warnings (ActivityPub sensitive content) + # Text in DB (can be long) + field :content_warning, :string + field :sensitive, :boolean, default: false + + # Gallery post category (photography, art, design, anime, meme, other) + field :category, :string + + # Draft support + field :is_draft, :boolean, default: false + field :scheduled_at, :utc_datetime + + timestamps() + end + + @doc false + def changeset(message, attrs) do + message + |> cast(attrs, [ + :conversation_id, + :sender_id, + :content, + :encrypted_content, + :search_index, + :message_type, + :media_urls, + :media_metadata, + :reply_to_id, + :edited_at, + :deleted_at, + :visibility, + :post_type, + :like_count, + :dislike_count, + :reply_count, + :share_count, + :remote_like_count, + :remote_reply_count, + :remote_share_count, + :remote_quote_count, + :remote_counts_fetched_at, + :link_preview_id, + :extracted_urls, + :extracted_hashtags, + :upvotes, + :downvotes, + :score, + :original_message_id, + :shared_message_id, + :quoted_message_id, + :quote_count, + :promoted_from, + :share_type, + :promoted_from_community_name, + :promoted_from_community_hash, + :title, + :auto_title, + :flair_id, + :is_pinned, + :pinned_at, + :pinned_by_id, + :locked_at, + :locked_by_id, + :lock_reason, + :approval_status, + :approved_by_id, + :approved_at, + :primary_url, + :activitypub_id, + :activitypub_url, + :bluesky_uri, + :bluesky_cid, + :federated, + :remote_actor_id, + :content_warning, + :sensitive, + :category, + :is_draft, + :scheduled_at + ]) + |> truncate_utc_datetimes([ + :edited_at, + :deleted_at, + :pinned_at, + :locked_at, + :approved_at, + :remote_counts_fetched_at, + :scheduled_at + ]) + |> validate_required([:conversation_id, :sender_id]) + |> validate_category() + |> validate_inclusion(:message_type, ["text", "image", "file", "system"]) + |> validate_inclusion(:visibility, [ + "conversation", + "direct", + "followers", + "friends", + "private", + "public", + "unlisted" + ]) + |> validate_inclusion(:post_type, [ + "message", + "post", + "comment", + "share", + "discussion", + "link", + "poll", + "gallery" + ]) + |> validate_length(:content, max: @local_content_max) + |> validate_content_security() + |> validate_media_urls_security() + |> validate_optional_safe_href(:primary_url) + |> validate_content_or_media() + |> clamp_remote_counts() + |> normalize_activitypub_refs() + |> foreign_key_constraint(:conversation_id) + |> foreign_key_constraint(:sender_id) + |> foreign_key_constraint(:reply_to_id) + |> unique_constraint(:activitypub_id, + name: :activitypub_id_index, + match: :suffix + ) + end + + @doc """ + Changeset for federated messages from ActivityPub. + Does not require conversation_id or sender_id. + """ + def federated_changeset(message, attrs) when is_map(attrs) do + attrs = normalize_federated_attrs(attrs) + + message + |> cast(attrs, [ + :content, + :title, + :conversation_id, + :message_type, + :media_urls, + :media_metadata, + :reply_to_id, + :visibility, + :post_type, + :activitypub_id, + :activitypub_url, + :federated, + :remote_actor_id, + :inserted_at, + :extracted_hashtags, + :like_count, + :dislike_count, + :reply_count, + :share_count, + :upvotes, + :downvotes, + :score, + :quote_count, + :remote_like_count, + :remote_reply_count, + :remote_share_count, + :remote_quote_count, + :remote_counts_fetched_at, + :quoted_message_id, + :content_warning, + :sensitive, + :primary_url + ]) + |> truncate_utc_datetimes([:inserted_at]) + |> normalize_federated_columns() + |> clamp_remote_counts() + |> normalize_activitypub_refs() + |> validate_required([:activitypub_id, :remote_actor_id]) + |> put_change(:federated, true) + |> validate_inclusion(:visibility, ["public", "unlisted", "followers", "private", "direct"]) + |> validate_length(:content, max: @federated_content_max) + |> validate_optional_safe_href(:primary_url) + |> foreign_key_constraint(:conversation_id) + |> unique_constraint(:activitypub_id, + name: :activitypub_id_index, + match: :suffix + ) + end + + def federated_changeset(message, attrs), do: federated_changeset(message, Map.new(attrs)) + + defp normalize_federated_attrs(attrs) do + normalize_optional_attr(attrs, :activitypub_url, &coerce_federated_remote_url_input/1) + end + + defp normalize_optional_attr(attrs, key, normalizer) when is_map(attrs) do + string_key = Atom.to_string(key) + + cond do + Map.has_key?(attrs, key) -> + Map.put(attrs, key, normalizer.(Map.get(attrs, key))) + + Map.has_key?(attrs, string_key) -> + Map.put(attrs, string_key, normalizer.(Map.get(attrs, string_key))) + + true -> + attrs + end + end + + defp coerce_federated_remote_url_input(url) when is_binary(url), do: url + + defp coerce_federated_remote_url_input([first | rest]), + do: coerce_federated_remote_url_input(first) || coerce_federated_remote_url_input(rest) + + defp coerce_federated_remote_url_input([]), do: nil + + defp coerce_federated_remote_url_input(%{"href" => href}), + do: coerce_federated_remote_url_input(href) + + defp coerce_federated_remote_url_input(%{href: href}), + do: coerce_federated_remote_url_input(href) + + defp coerce_federated_remote_url_input(%{"url" => url}), + do: coerce_federated_remote_url_input(url) + + defp coerce_federated_remote_url_input(%{url: url}), do: coerce_federated_remote_url_input(url) + defp coerce_federated_remote_url_input(%{"id" => id}), do: coerce_federated_remote_url_input(id) + defp coerce_federated_remote_url_input(%{id: id}), do: coerce_federated_remote_url_input(id) + defp coerce_federated_remote_url_input(_), do: nil + + # Keep federated inserts safe when upstream payloads exceed varchar-backed fields. + defp normalize_federated_columns(changeset) do + changeset + |> update_change(:title, &truncate_varchar/1) + |> update_change(:activitypub_url, &sanitize_federated_remote_url/1) + |> update_change(:media_urls, &sanitize_federated_media_urls/1) + end + + defp clamp_remote_counts(changeset) do + Enum.reduce(EngagementCounts.remote_fields(), changeset, fn field, acc -> + update_change(acc, field, &EngagementCounts.nullable_remote_count/1) + end) + end + + defp truncate_varchar(value) when is_binary(value) do + if String.length(value) > @varchar_limit do + String.slice(value, 0, @varchar_limit) + else + value + end + end + + defp truncate_varchar(value), do: value + + defp sanitize_federated_media_urls(urls) when is_list(urls) do + Enum.filter(urls, fn + url when is_binary(url) -> String.length(url) <= @varchar_limit + _ -> false + end) + end + + defp sanitize_federated_media_urls(_), do: [] + + defp sanitize_federated_remote_url(url) when is_binary(url) do + case SafeExternalURL.normalize_href(url) do + {:ok, safe_url} -> safe_url + {:error, _reason} -> nil + end + end + + defp sanitize_federated_remote_url(_), do: nil + + defp validate_content_or_media(changeset) do + content = get_field(changeset, :content) + encrypted_content = get_field(changeset, :encrypted_content) + media_urls = get_field(changeset, :media_urls) || [] + post_type = get_field(changeset, :post_type) + primary_url = get_field(changeset, :primary_url) + is_draft = get_field(changeset, :is_draft) + + # Check if this is valid based on post type + has_content = Elektrine.Strings.present?(content) + has_encrypted = !is_nil(encrypted_content) + has_media = !Enum.empty?(media_urls) + has_url = Elektrine.Strings.present?(primary_url) + + cond do + # Drafts can be empty - they're work in progress + is_draft == true -> + changeset + + # Poll posts need to have post_type set (will be validated separately with poll data) + post_type == "poll" -> + changeset + + # Link posts need primary_url + post_type == "link" && has_url -> + changeset + + # Link posts without URL + post_type == "link" && !has_url -> + add_error(changeset, :primary_url, "is required for link posts") + + # Shared posts (boosts) don't need content + !is_nil(get_field(changeset, :shared_message_id)) -> + changeset + + # All other posts need content or media + has_content or has_encrypted or has_media -> + changeset + + # Nothing provided + true -> + add_error(changeset, :content, "must have either content or media") + end + end + + @doc """ + Creates a changeset for a text message. + """ + def text_changeset( + conversation_id, + sender_id, + content, + reply_to_id \\ nil, + conversation_type \\ nil + ) do + attrs = + %{ + conversation_id: conversation_id, + sender_id: sender_id, + content: content, + message_type: "text", + reply_to_id: reply_to_id + } + |> encrypt_content(sender_id, conversation_type) + + %__MODULE__{} + |> changeset(attrs) + end + + @doc """ + Creates a changeset for a media message. + """ + def media_changeset( + conversation_id, + sender_id, + media_urls, + content \\ nil, + media_metadata \\ %{}, + conversation_type \\ nil + ) do + message_type = + if Enum.any?(media_urls, &String.match?(&1, ~r/\.(jpg|jpeg|png|gif|webp|heic|heif|avif)$/i)) do + "image" + else + "file" + end + + attrs = + %{ + conversation_id: conversation_id, + sender_id: sender_id, + content: content, + message_type: message_type, + media_urls: media_urls, + media_metadata: media_metadata + } + |> encrypt_content(sender_id, conversation_type) + + %__MODULE__{} + |> changeset(attrs) + end + + @doc """ + Creates a changeset for a system message. + """ + def system_changeset(conversation_id, content) do + %__MODULE__{} + |> changeset(%{ + conversation_id: conversation_id, + content: content, + message_type: "system" + }) + end + + @doc """ + Creates a changeset for editing a message. + """ + def edit_changeset(message, new_content) do + # Get conversation type to determine if we should encrypt + message_with_conversation = Elektrine.Repo.preload(message, :conversation) + conversation_type = message_with_conversation.conversation.type + + attrs = + %{ + content: new_content, + edited_at: Elektrine.Time.utc_now() + } + |> encrypt_content(message.sender_id, conversation_type) + + message + |> changeset(attrs) + end + + @doc """ + Creates a changeset for deleting a message. + """ + def delete_changeset(message) do + message + |> changeset(%{ + deleted_at: Elektrine.Time.utc_now() + }) + end + + defp truncate_utc_datetimes(changeset, fields) do + Enum.reduce(fields, changeset, fn field, changeset -> + update_change(changeset, field, &Elektrine.Time.truncate/1) + end) + end + + @doc """ + Creates a changeset for updating metadata fields only. + Does not require conversation_id or sender_id, making it safe for federated messages. + """ + def metadata_changeset(message, attrs) do + message + |> cast(attrs, [ + :media_metadata, + :like_count, + :dislike_count, + :reply_count, + :share_count, + :upvotes, + :downvotes, + :score + ]) + |> normalize_activitypub_refs() + end + + @doc """ + Checks if the message is deleted. + """ + def deleted?(%__MODULE__{deleted_at: nil}), do: false + def deleted?(%__MODULE__{}), do: true + + defp normalize_activitypub_refs(changeset) do + changeset + |> put_change( + :activitypub_id_canonical, + canonicalize_activitypub_ref(get_field(changeset, :activitypub_id)) + ) + |> put_change( + :activitypub_url_canonical, + canonicalize_activitypub_ref(get_field(changeset, :activitypub_url)) + ) + end + + defp canonicalize_activitypub_ref(ref) when is_binary(ref) do + ref + |> String.trim() + |> String.split("#", parts: 2) + |> hd() + |> String.split("?", parts: 2) + |> hd() + |> String.trim_trailing("/") + |> case do + "" -> nil + value -> value + end + end + + defp canonicalize_activitypub_ref(_), do: nil + + @doc """ + Checks if the message has been edited. + """ + def edited?(%__MODULE__{edited_at: nil}), do: false + def edited?(%__MODULE__{}), do: true + + @doc """ + Returns the display content for the message. + """ + def display_content(%__MODULE__{deleted_at: deleted_at}) when not is_nil(deleted_at) do + "This message was deleted" + end + + def display_content(%__MODULE__{content: content, message_type: "system"}) do + content + end + + def display_content(%__MODULE__{content: content, message_type: "text"}) do + content + end + + def display_content(%__MODULE__{content: content, message_type: type, media_urls: media_urls}) + when type in ["image", "file"] do + media_text = + case type do + "image" -> + # Check if it's a GIF based on URL + is_gif = Enum.any?(media_urls, &String.contains?(&1, ".gif")) + if is_gif, do: "GIF", else: "Photo" + + "file" -> + "File" + end + + if Elektrine.Strings.present?(content) do + "#{media_text}: #{content}" + else + media_text + end + end + + @doc """ + Extracts direct image URLs from message content. + Returns a list of image URLs found in the message. + """ + def extract_image_urls(content) when is_binary(content) do + # Regex to match URLs + url_regex = ~r/https?:\/\/[^\s<>"{}|\\^`\[\]]+/i + + # Common image extensions + image_extensions = ~r/\.(jpg|jpeg|png|gif|webp|heic|heif|avif|svg|bmp|ico)$/i + + # Find all URLs + Regex.scan(url_regex, content) + |> Enum.map(&List.first/1) + |> Enum.filter(fn url -> + # Check if URL ends with image extension + # Check for common image hosting patterns + # Check for Discord CDN images + # Check for GitHub user content + String.match?(url, image_extensions) || + String.contains?(url, ["imgur.com", "i.imgur.com", "tenor.com"]) || + String.contains?(url, "cdn.discordapp.com/attachments") || + String.contains?(url, "user-images.githubusercontent.com") + end) + |> Enum.uniq() + end + + def extract_image_urls(_), do: [] + + @doc """ + Checks if a URL is likely an image URL based on extension or common patterns. + """ + def image_url?(url) when is_binary(url) do + image_extensions = ~r/\.(jpg|jpeg|png|gif|webp|heic|heif|avif|svg|bmp|ico)(\?.*)?$/i + + String.match?(url, image_extensions) || + String.contains?(url, ["imgur.com", "i.imgur.com", "tenor.com"]) || + String.contains?(url, "cdn.discordapp.com/attachments") || + String.contains?(url, "user-images.githubusercontent.com") + end + + def image_url?(_), do: false + + @doc """ + Extracts YouTube URL from content and converts to embed format. + Returns nil if no YouTube URL found. + """ + def extract_youtube_embed_url(content) when is_binary(content) do + # Match various YouTube URL formats + cond do + # youtube.com/watch?v=VIDEO_ID + Regex.match?(~r/youtube\.com\/watch\?v=([a-zA-Z0-9_-]+)/, content) -> + case Regex.run(~r/youtube\.com\/watch\?v=([a-zA-Z0-9_-]+)/, content) do + [_, video_id] -> "https://www.youtube.com/embed/#{video_id}" + _ -> nil + end + + # youtu.be/VIDEO_ID + Regex.match?(~r/youtu\.be\/([a-zA-Z0-9_-]+)/, content) -> + case Regex.run(~r/youtu\.be\/([a-zA-Z0-9_-]+)/, content) do + [_, video_id] -> "https://www.youtube.com/embed/#{video_id}" + _ -> nil + end + + # youtube.com/embed/VIDEO_ID (already embed format) + Regex.match?(~r/youtube\.com\/embed\/([a-zA-Z0-9_-]+)/, content) -> + case Regex.run(~r/(https?:\/\/[^\s]+youtube\.com\/embed\/[a-zA-Z0-9_-]+)/, content) do + [url | _] -> url + _ -> nil + end + + true -> + nil + end + end + + def extract_youtube_embed_url(_), do: nil + + @doc """ + Returns true if the message can be edited by the given user. + """ + def can_edit?(%__MODULE__{sender_id: sender_id, message_type: "text", deleted_at: nil}, user_id) do + sender_id == user_id + end + + def can_edit?(%__MODULE__{}, _user_id), do: false + + @doc """ + Returns true if the message can be deleted by the given user. + Admins can delete any message, users can only delete their own messages. + """ + def can_delete?(message, user_id, is_admin \\ false) + + def can_delete?(%__MODULE__{sender_id: sender_id, deleted_at: nil}, user_id, is_admin) do + is_admin || sender_id == user_id + end + + def can_delete?(%__MODULE__{}, _user_id, _is_admin), do: false + + @doc """ + Encrypts message content and creates search index. + Returns updated attrs with encrypted_content and search_index. + Clears the plaintext content field to avoid storing unencrypted data. + + Chat-specific storage is handled by `Elektrine.Messaging.ChatMessage`. + Email encryption is handled separately in `Elektrine.Email.Message`. + """ + def encrypt_content(attrs, user_id, conversation_type \\ nil) do + is_chat_conversation = conversation_type in ["dm", "group", "channel"] + + # Social timeline/community posts are plaintext for federation and discovery. + # Keep encryption enabled only for unknown callers that don't pass a conversation type. + should_encrypt = + case conversation_type do + "timeline" -> false + "community" -> false + "dm" -> false + "group" -> false + "channel" -> false + _ -> true + end + + if should_encrypt do + case Map.get(attrs, :content) do + nil -> + attrs + + "" -> + attrs + + content -> + encrypted = Elektrine.Encryption.encrypt(content, user_id) + search_index = Elektrine.Encryption.index_content(content, user_id) + + attrs + |> Map.put(:encrypted_content, encrypted) + |> Map.put(:search_index, search_index) + # Clear plaintext + |> Map.put(:content, nil) + end + else + # Older chat rows stay readable here; current chat writes use ChatMessage. + if is_chat_conversation do + case Map.get(attrs, :content) do + nil -> + attrs + |> Map.put(:encrypted_content, nil) + |> Map.put(:search_index, []) + + "" -> + attrs + |> Map.put(:encrypted_content, nil) + |> Map.put(:search_index, []) + + content -> + attrs + |> Map.put(:encrypted_content, nil) + |> Map.put(:search_index, Elektrine.Encryption.index_content(content, user_id)) + end + else + attrs + end + end + end + + @doc """ + Decrypts message content if encrypted. + Returns the message with decrypted content in the :content field. + + OPTIMIZATION: Timeline/community posts are stored as plaintext, so this is usually a no-op. + """ + def decrypt_content(%__MODULE__{content: content} = message) when not is_nil(content) do + # Already has plaintext content - no decryption needed + message + end + + def decrypt_content(%__MODULE__{encrypted_content: nil} = message), do: message + + def decrypt_content(%__MODULE__{encrypted_content: encrypted, sender_id: sender_id} = message) + when not is_nil(encrypted) do + case Elektrine.Encryption.decrypt(encrypted, sender_id) do + {:ok, content} -> %{message | content: content} + {:error, _} -> %{message | content: "[Decryption failed]"} + end + end + + def decrypt_content(message), do: message + + @doc """ + Decrypts a list of messages. + """ + def decrypt_messages(messages) when is_list(messages) do + Enum.map(messages, &decrypt_content/1) + end + + # Security validation functions + + defp validate_content_security(changeset) do + case get_change(changeset, :content) do + nil -> + changeset + + content when is_binary(content) -> + # Basic XSS prevention - strip potentially dangerous content + cleaned_content = + content + |> String.replace(~r/)<[^<]*)*<\/script>/mi, "") + |> String.replace(~r/)<[^<]*)*<\/iframe>/mi, "") + |> String.replace(~r/)<[^<]*)*<\/object>/mi, "") + |> String.replace(~r//mi, "") + |> String.replace(~r/javascript:/i, "") + |> String.replace(~r/data:(?!image)/i, "") + + # Check for excessive mentions or spam patterns + if String.length(cleaned_content) != String.length(content) do + add_error(changeset, :content, "contains potentially unsafe content") + else + validate_spam_patterns(changeset, cleaned_content) + end + + _ -> + changeset + end + end + + defp validate_media_urls_security(changeset) do + case get_change(changeset, :media_urls) do + nil -> + changeset + + urls when is_list(urls) -> + # Validate all URLs are from trusted domains + invalid_urls = + Enum.filter(urls, fn url -> + not trusted_media_url?(url, get_field(changeset, :sender_id)) + end) + + if invalid_urls != [] do + add_error(changeset, :media_urls, "contains untrusted media URLs") + else + changeset + end + + _ -> + changeset + end + end + + defp validate_optional_safe_href(changeset, field) do + validate_change(changeset, field, fn + _, nil -> [] + _, "" -> [] + _, url -> safe_href_errors(field, url) + end) + end + + defp safe_href_errors(field, url) do + case SafeExternalURL.normalize_href(url) do + {:ok, _url} -> [] + {:error, :userinfo_not_allowed} -> [{field, "must not include username or password"}] + {:error, _reason} -> [{field, "must be a valid HTTP or HTTPS URL"}] + end + end + + defp validate_spam_patterns(changeset, content) do + cond do + # Too many mentions + Mentions.count_mentions(content) > 10 -> + add_error(changeset, :content, "contains too many mentions") + + # Too many emojis + String.graphemes(content) |> Enum.count(&emoji?/1) > 50 -> + add_error(changeset, :content, "contains too many emojis") + + true -> + changeset + end + end + + defp trusted_media_url?(url, sender_id) do + # Allow local uploads (paths starting with /uploads/) + cond do + String.starts_with?(url, "/uploads/") -> + url + |> String.trim_leading("/uploads/") + |> owned_public_media_key?(sender_id) + + # Allow S3-compatible keys (paths starting with various attachment folders) + String.starts_with?(url, "timeline-attachments/") or + String.starts_with?(url, "discussion-attachments/") or + String.starts_with?(url, "gallery-attachments/") or + String.starts_with?(url, "avatars/") or + String.starts_with?(url, "backgrounds/") -> + owned_public_media_key?(url, sender_id) + + # Allow HTTPS URLs from trusted domains + true -> + trusted_domains = [ + "avatars.githubusercontent.com", + "user-images.githubusercontent.com", + "raw.githubusercontent.com", + "i.imgur.com", + "imgur.com", + Application.get_env(:elektrine, :uploads)[:domain] || "localhost" + ] + + case SafeExternalURL.normalize_href(url) do + {:ok, safe_url} -> + uri = URI.parse(safe_url) + uri.scheme == "https" and uri.host in trusted_domains + + {:error, _reason} -> + false + end + end + rescue + _ -> false + end + + defp owned_public_media_key?(key, sender_id) when is_binary(key) and is_integer(sender_id) do + normalized = key |> String.trim() |> String.trim_leading("/") + + case String.split(normalized, "/", parts: 2) do + [prefix, filename] + when prefix in ["timeline-attachments", "discussion-attachments", "gallery-attachments"] -> + safe_public_media_filename?(filename) and String.starts_with?(filename, "#{sender_id}_") + + [prefix, _filename] when prefix in ["avatars", "backgrounds"] -> + safe_public_media_key?(normalized) + + _ -> + false + end + end + + defp owned_public_media_key?(_key, _sender_id), do: false + + defp safe_public_media_key?(key) when is_binary(key) do + not String.contains?(key, ["..", "\\"]) and not Regex.match?(~r/[\x00-\x1F\x7F]/, key) + end + + defp safe_public_media_filename?(filename) when is_binary(filename) do + safe_public_media_key?(filename) and not String.contains?(filename, "/") + end + + defp emoji?(grapheme) do + # Simple emoji detection - check for common emoji codepoints + codepoint = String.to_charlist(grapheme) |> List.first() + # Emoticons + # Misc Symbols + # Transport + # Misc symbols + # Dingbats + codepoint != nil and + ((codepoint >= 0x1F600 and codepoint <= 0x1F64F) or + (codepoint >= 0x1F300 and codepoint <= 0x1F5FF) or + (codepoint >= 0x1F680 and codepoint <= 0x1F6FF) or + (codepoint >= 0x2600 and codepoint <= 0x26FF) or + (codepoint >= 0x2700 and codepoint <= 0x27BF)) + rescue + _ -> false + end + + # Category is optional but must be valid if provided + defp validate_category(changeset) do + case get_change(changeset, :category) do + nil -> + changeset + + category -> + valid_categories = ["photography", "art", "design", "anime", "meme", "other"] + + if category in valid_categories do + changeset + else + add_error(changeset, :category, "must be one of: #{Enum.join(valid_categories, ", ")}") + end + end + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/sync_remote_counts_worker.ex b/apps/elektrine/lib/elektrine/messaging/sync_remote_counts_worker.ex new file mode 100644 index 0000000..2230bfc --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/sync_remote_counts_worker.ex @@ -0,0 +1,53 @@ +defmodule Elektrine.Messaging.SyncRemoteCountsWorker do + @moduledoc """ + Persists freshly fetched remote count data through Oban so LiveViews don't + spawn ad hoc tasks for metadata sync. + """ + + use Oban.Worker, + queue: :federation, + max_attempts: 2, + unique: [ + period: 300, + keys: [:activitypub_id], + states: [:available, :scheduled, :executing, :retryable] + ] + + alias Elektrine.ActivityPub.RefreshCountsWorker + alias Elektrine.Messaging + + def enqueue(post_object) when is_map(post_object) do + activitypub_id = post_object["id"] || post_object["url"] + + %{"activitypub_id" => activitypub_id} + |> new() + |> Elektrine.JobQueue.insert() + end + + def enqueue(_post_object), do: {:error, :invalid_post_object} + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"activitypub_id" => activitypub_id}}) + when is_binary(activitypub_id) do + refresh_cached_message_counts(activitypub_id) + :ok + end + + def perform(%Oban.Job{args: %{"post_object" => post_object}}) when is_map(post_object) do + refresh_cached_message_counts(post_object["id"] || post_object["url"]) + :ok + end + + defp refresh_cached_message_counts(activitypub_ref) when is_binary(activitypub_ref) do + case Messaging.get_message_by_activitypub_ref(activitypub_ref) do + %{id: message_id} when is_integer(message_id) -> + _ = RefreshCountsWorker.refresh_now(message_id) + :ok + + _ -> + :ok + end + end + + defp refresh_cached_message_counts(_), do: :ok +end diff --git a/apps/elektrine/lib/elektrine/messaging/user_hidden_message.ex b/apps/elektrine/lib/elektrine/messaging/user_hidden_message.ex new file mode 100644 index 0000000..23ae082 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/user_hidden_message.ex @@ -0,0 +1,24 @@ +defmodule Elektrine.Messaging.UserHiddenMessage do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "user_hidden_messages" do + field :hidden_at, :naive_datetime + + belongs_to :user, Elektrine.Accounts.User + belongs_to :message, Elektrine.Social.Message + + timestamps() + end + + @doc false + def changeset(hidden_message, attrs) do + hidden_message + |> cast(attrs, [:user_id, :message_id, :hidden_at]) + |> validate_required([:user_id, :message_id]) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:message_id) + |> unique_constraint([:user_id, :message_id]) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/user_post_timestamp.ex b/apps/elektrine/lib/elektrine/messaging/user_post_timestamp.ex new file mode 100644 index 0000000..827a652 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/user_post_timestamp.ex @@ -0,0 +1,26 @@ +defmodule Elektrine.Messaging.UserPostTimestamp do + @moduledoc """ + Schema for tracking when users last posted in a community (for slow mode). + """ + use Ecto.Schema + import Ecto.Changeset + + schema "user_post_timestamps" do + field :last_post_at, :utc_datetime + + belongs_to :conversation, Elektrine.Social.Conversation + belongs_to :user, Elektrine.Accounts.User + + timestamps(updated_at: false) + end + + @doc false + def changeset(timestamp, attrs) do + timestamp + |> cast(attrs, [:conversation_id, :user_id, :last_post_at]) + |> validate_required([:conversation_id, :user_id, :last_post_at]) + |> unique_constraint([:conversation_id, :user_id]) + |> foreign_key_constraint(:conversation_id) + |> foreign_key_constraint(:user_id) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/user_timeout.ex b/apps/elektrine/lib/elektrine/messaging/user_timeout.ex new file mode 100644 index 0000000..bfacce7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/user_timeout.ex @@ -0,0 +1,44 @@ +defmodule Elektrine.Messaging.UserTimeout do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "user_timeouts" do + belongs_to :user, Elektrine.Accounts.User + belongs_to :conversation, Elektrine.Social.Conversation + belongs_to :created_by, Elektrine.Accounts.User + field :timeout_until, :utc_datetime + field :reason, :string + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(user_timeout, attrs) do + user_timeout + |> cast(attrs, [:user_id, :conversation_id, :timeout_until, :reason, :created_by_id]) + |> validate_required([:user_id, :timeout_until, :created_by_id]) + |> validate_future_timeout() + |> unique_constraint([:user_id, :conversation_id], + name: :user_timeouts_user_conversation_unique + ) + end + + defp validate_future_timeout(changeset) do + case get_field(changeset, :timeout_until) do + nil -> + changeset + + timeout_until -> + if DateTime.compare(timeout_until, DateTime.utc_now()) == :gt do + changeset + else + add_error(changeset, :timeout_until, "must be in the future") + end + end + end + + def active?(user_timeout) do + DateTime.compare(user_timeout.timeout_until, DateTime.utc_now()) == :gt + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/user_warning.ex b/apps/elektrine/lib/elektrine/messaging/user_warning.ex new file mode 100644 index 0000000..638c804 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/user_warning.ex @@ -0,0 +1,40 @@ +defmodule Elektrine.Messaging.UserWarning do + @moduledoc """ + Schema for tracking warnings issued to users in communities. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "user_warnings" do + field :reason, :string + field :severity, :string, default: "low" + field :acknowledged_at, :utc_datetime + + belongs_to :conversation, Elektrine.Social.Conversation + belongs_to :user, Elektrine.Accounts.User + belongs_to :warned_by, Elektrine.Accounts.User + belongs_to :related_message, Elektrine.Social.Message + + timestamps(updated_at: false) + end + + @doc false + def changeset(warning, attrs) do + warning + |> cast(attrs, [ + :conversation_id, + :user_id, + :warned_by_id, + :reason, + :severity, + :acknowledged_at, + :related_message_id + ]) + |> validate_required([:conversation_id, :user_id, :warned_by_id, :reason]) + |> validate_inclusion(:severity, ["low", "medium", "high"]) + |> foreign_key_constraint(:conversation_id) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:warned_by_id) + |> foreign_key_constraint(:related_message_id) + end +end diff --git a/apps/elektrine/lib/elektrine/messaging/voice_channels.ex b/apps/elektrine/lib/elektrine/messaging/voice_channels.ex new file mode 100644 index 0000000..9be84d7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/messaging/voice_channels.ex @@ -0,0 +1,96 @@ +defmodule Elektrine.Messaging.VoiceChannels do + @moduledoc """ + Domain rules for persistent voice channels inside community servers. + + Voice channels are `chat_conversations` rows with `type: "voice_channel"`. + They carry no message timeline; occupancy is tracked at the transport layer + (Phoenix Presence on the `voice:` topic) and is therefore + not persisted here. This module owns join authorization and the mesh + occupancy cap. + + Voice channels are local-only in this iteration: they are excluded from + federation bootstrap payloads (which only export `type == "channel"` rows) + and joins on federated mirrors are rejected. + """ + + alias Elektrine.Messaging.{ChatConversation, RoomACL} + alias Elektrine.Repo + + @default_max_occupants 8 + + @doc """ + Maximum number of concurrent occupants in a voice channel (mesh topology). + + Configurable via `config :elektrine, :voice_channels, max_occupants: n`. + """ + def max_occupants do + :elektrine + |> Application.get_env(:voice_channels, []) + |> Keyword.get(:max_occupants, @default_max_occupants) + end + + @doc """ + Fetches a conversation only when it is a voice channel. + """ + def get_voice_channel(conversation_id) when is_integer(conversation_id) do + case Repo.get(ChatConversation, conversation_id) do + %ChatConversation{type: "voice_channel"} = conversation -> conversation + _ -> nil + end + end + + def get_voice_channel(_conversation_id), do: nil + + @doc """ + Authorizes a local user to join a voice channel. + + Requires the conversation to be a local (non-mirror) voice channel and the + user to be an active member of it; the membership check goes through + `RoomACL` with the `:send_voice_signaling` action. + + Returns `:ok`, `{:error, :not_found}`, `{:error, :remote_mirror}` or + `{:error, :unauthorized}`. + """ + def authorize_join(conversation_id, user_id) + when is_integer(conversation_id) and is_integer(user_id) do + case get_voice_channel(conversation_id) do + nil -> + {:error, :not_found} + + %ChatConversation{is_federated_mirror: true} -> + {:error, :remote_mirror} + + %ChatConversation{} -> + RoomACL.authorize_local_user_action(conversation_id, user_id, :send_voice_signaling) + end + end + + def authorize_join(_conversation_id, _user_id), do: {:error, :unauthorized} + + @doc """ + Checks the mesh occupancy cap against the currently connected occupants. + + `occupant_user_ids` is the list of user ids currently tracked in the + channel's presence. Returns `:ok`, `{:error, :already_joined}` when the + user is already connected, or `{:error, :channel_full}` when the cap is + reached. + """ + def check_capacity(occupant_user_ids, user_id) when is_list(occupant_user_ids) do + distinct = Enum.uniq(occupant_user_ids) + + cond do + user_id in distinct -> {:error, :already_joined} + length(distinct) >= max_occupants() -> {:error, :channel_full} + true -> :ok + end + end + + @doc """ + Combined join check: authorization plus occupancy cap. + """ + def authorize_join(conversation_id, user_id, occupant_user_ids) do + with :ok <- authorize_join(conversation_id, user_id) do + check_capacity(occupant_user_ids, user_id) + end + end +end diff --git a/apps/elektrine/lib/elektrine/notifications.ex b/apps/elektrine/lib/elektrine/notifications.ex new file mode 100644 index 0000000..7cc2bc4 --- /dev/null +++ b/apps/elektrine/lib/elektrine/notifications.ex @@ -0,0 +1,1393 @@ +defmodule Elektrine.Notifications do + @moduledoc """ + The Notifications context. + """ + + require Logger + + import Ecto.Query, warn: false + alias Elektrine.Accounts.{User, UserBlock, UserMute} + alias Elektrine.ActivityPub.Actor + alias Elektrine.Messaging + alias Elektrine.Notifications.Notification + alias Elektrine.Repo + alias Elektrine.Social.Message + alias Elektrine.Telemetry.Events + + @doc """ + Creates a notification. + """ + def create_notification(attrs \\ %{}) do + started_at = System.monotonic_time(:millisecond) + attrs = put_default_group_key(attrs) + + if notification_policy_allowed?(attrs) do + notification = + %Notification{} + |> Notification.changeset(attrs) + |> Repo.insert() + + case notification do + {:ok, notif} -> + # Invalidate notification cache + Elektrine.AppCache.invalidate_notification_cache(notif.user_id) + + # Broadcast to user's notification channel + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{notif.user_id}:notifications", + {:new_notification, notif} + ) + + # Also broadcast count update + new_count = get_visible_unread_count(notif.user_id) + + Events.db_hot_path( + :notifications, + :create_notification, + System.monotonic_time(:millisecond) - started_at, + %{user_id: notif.user_id, type: notif.type} + ) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{notif.user_id}:notification_count", + {:notification_count_updated, new_count} + ) + + push_payload = + notif + |> push_payload(new_count) + |> maybe_redact_push_payload(notif.user_id) + + # Send push notification if user is offline + if Elektrine.Push.should_send_push?(notif.user_id) do + Elektrine.Push.notify_user(notif.user_id, push_payload) + Elektrine.Push.notify_web_user(notif.user_id, push_payload) + end + + Elektrine.Async.run(fn -> + maybe_emit_developer_webhook(notif, new_count) + end) + + {:ok, notif} + + {:error, changeset} = error -> + Logger.error("Failed to create notification: #{inspect(changeset.errors)}") + error + end + else + {:ok, :notification_filtered} + end + end + + defp put_default_group_key(attrs) when is_map(attrs) do + case Map.get(attrs, :group_key) || Map.get(attrs, "group_key") do + value when is_binary(value) and value != "" -> + attrs + + _ -> + Map.put(attrs, :group_key, notification_group_key(attrs)) + end + end + + defp notification_group_key(attrs) do + type = Map.get(attrs, :type) || Map.get(attrs, "type") + source_type = Map.get(attrs, :source_type) || Map.get(attrs, "source_type") + source_id = Map.get(attrs, :source_id) || Map.get(attrs, "source_id") + actor_id = Map.get(attrs, :actor_id) || Map.get(attrs, "actor_id") + + cond do + type in ["like", "boost", "reaction"] and source_type in ["message", "post", "discussion"] and + not is_nil(source_id) -> + "social:#{type}:#{source_type}:#{source_id}" + + type == "follow" and not is_nil(actor_id) -> + "social:follow:#{actor_id}" + + source_type in ["message", "post", "discussion"] and not is_nil(source_id) -> + "social:#{type}:#{source_type}:#{source_id}" + + true -> + nil + end + end + + @doc """ + Creates a notification for multiple users. + """ + def create_bulk_notifications(user_ids, attrs) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + notifications = + user_ids + |> Enum.uniq() + |> Enum.map(fn user_id -> + attrs + |> Map.put(:user_id, user_id) + |> put_default_group_key() + |> Map.put(:inserted_at, now) + |> Map.put(:updated_at, now) + end) + |> Enum.filter(¬ification_policy_allowed?/1) + + {count, _} = + case notifications do + [] -> {0, nil} + _ -> Repo.insert_all(Notification, notifications) + end + + delivered_user_ids = + notifications + |> Enum.map(&(Map.get(&1, :user_id) || Map.get(&1, "user_id"))) + |> Enum.uniq() + + Enum.each(delivered_user_ids, &Elektrine.AppCache.invalidate_notification_cache/1) + + # Broadcast to all users + Enum.each(delivered_user_ids, fn user_id -> + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}:notifications", + :notification_created + ) + end) + + {:ok, count} + end + + @doc """ + Gets grouped notifications for a user. + Groups chat messages by conversation, emails by sender. + """ + def list_grouped_notifications(user_id, opts \\ []) do + filter = Keyword.get(opts, :filter, :all) + source_filter = Keyword.get(opts, :source_filter, "all") + + # Get raw notifications + notifications = + list_notifications(user_id, filter: filter, source_filter: source_filter, limit: 200) + + # Group notifications + groups = + notifications + |> Enum.group_by(fn n -> + case n.type do + type when type in ["new_message", "reply"] and n.source_type == "message" -> + # Group chat messages by conversation (extract from URL) + conversation_id = extract_conversation_id(n.url) + {:chat, conversation_id} + + "email_received" -> + # Group emails by sender (actor_id) + {:email, n.actor_id} + + type + when type in [ + "like", + "boost", + "reaction", + "status", + "poll", + "update", + "mention", + "comment", + "discussion_reply", + "reply" + ] -> + # Group social activity by target source/post so multiple actors collapse into + # a compact "X and N others" notification group. + {:social, type, n.source_type, n.source_id || n.url || n.id} + + _ -> + # Don't group other types + {:single, n.id} + end + end) + |> Enum.flat_map(fn {key, notifs} -> + case key do + {:chat, conversation_id} when conversation_id != nil -> + [build_chat_group(notifs, conversation_id)] + + {:email, actor_id} when actor_id != nil -> + [build_email_group(notifs)] + + {:social, _type, _source_type, target} when not is_nil(target) -> + [build_social_group(notifs)] + + {:single, _} -> + # Single notification (not grouped) + [ + %{ + type: :single, + notification: hd(notifs), + count: 1, + latest_at: hd(notifs).inserted_at + } + ] + + _ -> + # Handle edge cases (nil conversation_id, nil actor_id, etc) + # Treat as individual notifications + Enum.map(notifs, fn n -> + %{ + type: :single, + notification: n, + count: 1, + latest_at: n.inserted_at + } + end) + end + end) + |> Enum.sort_by(& &1.latest_at, :desc) + + groups + end + + @doc """ + Gets grouped notifications with stable API group keys. + """ + def list_notification_groups(user_id, opts \\ []) do + user_id + |> list_grouped_notifications(opts) + |> Enum.map(&put_api_group_key/1) + end + + @doc """ + Gets one notification group by its API group key. + """ + def get_notification_group(user_id, group_key, opts \\ []) + + def get_notification_group(user_id, group_key, opts) when is_binary(group_key) do + user_id + |> list_notification_groups(Keyword.put(opts, :limit, 200)) + |> Enum.find(&(Map.get(&1, :group_key) == group_key)) + |> case do + nil -> {:error, :not_found} + group -> {:ok, group} + end + end + + def get_notification_group(_user_id, _group_key, _opts), do: {:error, :not_found} + + @doc """ + Lists unique actors represented in a notification group. + """ + def list_notification_group_accounts(user_id, group_key) do + case get_notification_group(user_id, group_key) do + {:ok, group} -> notification_group_actors(group) + {:error, :not_found} -> [] + end + end + + @doc """ + Dismisses all notifications in a group. + """ + def dismiss_notification_group(user_id, group_key) do + with {:ok, group} <- get_notification_group(user_id, group_key) do + group + |> notification_group_notifications() + |> Enum.map(& &1.id) + |> dismiss_notifications(user_id) + end + end + + @doc """ + Counts unread notification groups, not raw notification rows. + """ + def get_unread_group_count(user_id, opts \\ []) do + user_id + |> list_notification_groups(Keyword.put(opts, :filter, :unread)) + |> length() + end + + defp extract_conversation_id(url) when is_binary(url) do + case Regex.run(~r/\/chat\/(\d+)/, url) do + [_, id] -> parse_positive_integer(id) + _ -> nil + end + end + + defp extract_conversation_id(_), do: nil + + defp parse_positive_integer(value) when is_binary(value) do + case Integer.parse(value) do + {id, ""} when id > 0 -> id + _ -> nil + end + end + + defp parse_positive_integer(value) when is_integer(value) and value > 0, do: value + defp parse_positive_integer(_value), do: nil + + defp build_chat_group(notifs, conversation_id) do + sorted = Enum.sort_by(notifs, & &1.inserted_at, :desc) + latest = hd(sorted) + unread_count = Enum.count(sorted, &is_nil(&1.read_at)) + + # Get conversation name from first notification + conversation_name = + case Regex.run(~r/(?:in |from )(.+?)(?:\s*$|"|message)/, latest.title) do + [_, name] -> name + _ -> "Chat" + end + + %{ + type: :chat_group, + conversation_id: conversation_id, + conversation_name: conversation_name, + notifications: sorted, + count: length(sorted), + unread_count: unread_count, + latest_at: latest.inserted_at, + latest_notification: latest, + actors: + notifs + |> Enum.map(¬ification_actor/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq_by(¬ification_actor_key/1) + } + end + + defp build_email_group(notifs) do + sorted = Enum.sort_by(notifs, & &1.inserted_at, :desc) + latest = hd(sorted) + unread_count = Enum.count(sorted, &is_nil(&1.read_at)) + + %{ + type: :email_group, + notifications: sorted, + count: length(sorted), + unread_count: unread_count, + latest_at: latest.inserted_at, + latest_notification: latest, + sender: notification_actor(latest) + } + end + + defp build_social_group(notifs) do + sorted = Enum.sort_by(notifs, & &1.inserted_at, :desc) + latest = hd(sorted) + unread_count = Enum.count(sorted, &is_nil(&1.read_at)) + + %{ + type: :social_group, + social_type: latest.type, + source_type: latest.source_type, + source_id: latest.source_id, + notifications: sorted, + count: length(sorted), + unread_count: unread_count, + latest_at: latest.inserted_at, + latest_notification: latest, + actors: + notifs + |> Enum.map(¬ification_actor/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq_by(¬ification_actor_key/1) + } + end + + defp put_api_group_key(%{type: :chat_group, conversation_id: conversation_id} = group) do + Map.put(group, :group_key, "chat:conversation:#{conversation_id}") + end + + defp put_api_group_key(%{type: :email_group, sender: %{id: actor_id}} = group) do + Map.put(group, :group_key, "email:actor:#{actor_id}") + end + + defp put_api_group_key(%{type: :email_group, latest_notification: notification} = group) do + Map.put(group, :group_key, notification_group_key_or_ungrouped(notification)) + end + + defp put_api_group_key(%{type: :social_group, latest_notification: notification} = group) do + Map.put(group, :group_key, notification_group_key_or_ungrouped(notification)) + end + + defp put_api_group_key(%{type: :single, notification: notification} = group) do + Map.put(group, :group_key, notification_group_key_or_ungrouped(notification)) + end + + defp put_api_group_key(group), do: group + + defp notification_group_key_or_ungrouped(%Notification{group_key: group_key}) + when is_binary(group_key) and group_key != "", + do: group_key + + defp notification_group_key_or_ungrouped(%Notification{} = notification) do + notification_group_key(Map.from_struct(notification)) || "ungrouped-#{notification.id}" + end + + defp notification_group_notifications(%{notifications: notifications}) + when is_list(notifications), + do: notifications + + defp notification_group_notifications(%{notification: %Notification{} = notification}), + do: [notification] + + defp notification_group_notifications(_group), do: [] + + defp notification_group_actors(group) do + group + |> notification_group_notifications() + |> Enum.map(¬ification_actor/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq_by(¬ification_actor_key/1) + end + + @doc """ + Resolves the local or remote actor represented by a notification. + """ + def notification_actor(%Notification{actor: %User{} = actor}), do: actor + + def notification_actor(%Notification{} = notification) do + notification + |> remote_actor_id() + |> case do + id when is_integer(id) -> Repo.get(Actor, id) + _ -> nil + end + end + + def notification_actor(_notification), do: nil + + defp remote_actor_id(%Notification{metadata: metadata}) when is_map(metadata) do + metadata + |> Map.get(:remote_actor_id, Map.get(metadata, "remote_actor_id")) + |> parse_positive_integer() + end + + defp remote_actor_id(_notification), do: nil + + defp notification_actor_key(%User{id: id}), do: {:user, id} + defp notification_actor_key(%Actor{id: id}), do: {:remote_actor, id} + + @doc """ + Gets notifications for a user. + """ + def list_notifications(user_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + offset = Keyword.get(opts, :offset, 0) + filter = Keyword.get(opts, :filter, :all) + source_filter = Keyword.get(opts, :source_filter, "all") + fetch_limit = limit * 3 + + query = + from(n in Notification, + where: n.user_id == ^user_id and is_nil(n.dismissed_at), + order_by: [desc: n.inserted_at], + limit: ^fetch_limit, + offset: ^offset, + preload: [:actor] + ) + + query = + case filter do + :unread -> where(query, [n], is_nil(n.read_at)) + :unseen -> where(query, [n], is_nil(n.seen_at)) + _ -> query + end + + query = apply_source_filter(query, source_filter) + query = apply_actor_safety_filter(query, user_id) + + query + |> Repo.all() + |> Enum.filter(¬ification_policy_allowed?/1) + |> Enum.take(limit) + |> resolve_message_notification_urls() + end + + @doc """ + Gets one visible notification for a user. + """ + def get_notification(notification_id, user_id) do + query = + from(n in Notification, + where: n.id == ^notification_id and n.user_id == ^user_id and is_nil(n.dismissed_at), + preload: [:actor], + limit: 1 + ) + |> apply_actor_safety_filter(user_id) + + case Repo.one(query) do + %Notification{} = notification -> + if notification_policy_allowed?(notification) do + notification + |> List.wrap() + |> resolve_message_notification_urls() + |> List.first() + else + nil + end + + nil -> + nil + end + end + + defp apply_actor_safety_filter(query, user_id) do + from(n in query, + left_join: mute in UserMute, + on: + mute.muter_id == ^user_id and mute.muted_id == n.actor_id and + mute.mute_notifications == true, + left_join: block in UserBlock, + on: block.blocker_id == ^user_id and block.blocked_id == n.actor_id, + where: is_nil(mute.id) and is_nil(block.id) + ) + end + + defp apply_source_filter(query, source_filter) do + case source_filter do + "chat" -> + where( + query, + [n], + n.type == "new_message" or (n.type == "reply" and n.source_type == "message") + ) + + "email" -> + where(query, [n], n.type == "email_received") + + "requests" -> + where(query, [n], n.type == "follow") + + "social" -> + where( + query, + [n], + n.type in [ + "mention", + "like", + "boost", + "reaction", + "status", + "poll", + "update", + "comment", + "discussion_reply" + ] or + (n.type == "reply" and n.source_type != "message") + ) + + "system" -> + where(query, [n], n.type in ["system", "admin.sign_up", "admin.report"]) + + _ -> + query + end + end + + @doc """ + Resolves the canonical navigation URL for a message-backed notification. + """ + def resolve_message_notification_url(message_id, notification_type \\ nil) + + def resolve_message_notification_url(message_id, notification_type) + when is_integer(message_id) do + {messages_by_id, parents_by_id} = load_messages_with_parents([message_id]) + + case Map.get(messages_by_id, message_id) do + %Message{} = message -> + build_message_notification_url( + message, + Map.get(parents_by_id, message.reply_to_id), + notification_type + ) + + nil -> + nil + end + end + + def resolve_message_notification_url(_, _), do: nil + + @doc """ + Gets unread notification count for a user. + """ + def get_unread_count(user_id) do + started_at = System.monotonic_time(:millisecond) + + case Elektrine.AppCache.get_notification_unread_count(user_id, fn -> + query_unread_count(user_id) + end) do + {:ok, count} -> + Events.db_hot_path( + :notifications, + :get_unread_count, + System.monotonic_time(:millisecond) - started_at, + %{user_id: user_id, cache: :hit} + ) + + count + + _ -> + count = query_unread_count(user_id) + + Events.db_hot_path( + :notifications, + :get_unread_count, + System.monotonic_time(:millisecond) - started_at, + %{user_id: user_id, cache: :miss} + ) + + count + end + end + + @doc """ + Gets unread notification count after applying current notification policy. + """ + def get_visible_unread_count(user_id) do + Notification + |> where([n], n.user_id == ^user_id and is_nil(n.read_at) and is_nil(n.dismissed_at)) + |> preload([:actor]) + |> apply_actor_safety_filter(user_id) + |> Repo.all() + |> Enum.count(¬ification_policy_allowed?/1) + end + + @doc """ + Marks a notification as read. + """ + def mark_as_read(notification_id, user_id) do + now = Elektrine.Time.utc_now() + + notification = + Repo.get_by(Notification, + id: notification_id, + user_id: user_id + ) + + sync_notification_sources_as_read(user_id, [notification]) + + from(n in Notification, + where: n.id == ^notification_id and n.user_id == ^user_id + ) + |> Repo.update_all(set: [read_at: now, seen_at: now]) + + # Invalidate notification cache + Elektrine.AppCache.invalidate_notification_cache(user_id) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}:notifications", + :notification_updated + ) + + # Broadcast count update + new_count = get_visible_unread_count(user_id) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}:notification_count", + {:notification_count_updated, new_count} + ) + + :ok + end + + @doc """ + Marks all visible notifications up to and including an id as read. + """ + def mark_as_read_up_to(user_id, max_id) when is_integer(max_id) and max_id > 0 do + now = Elektrine.Time.utc_now() + + unread_notifications = + from(n in Notification, + where: + n.user_id == ^user_id and + n.id <= ^max_id and + is_nil(n.read_at) and + is_nil(n.dismissed_at) + ) + |> Repo.all() + + sync_notification_sources_as_read(user_id, unread_notifications) + + {count, _} = + from(n in Notification, + where: + n.user_id == ^user_id and + n.id <= ^max_id and + is_nil(n.read_at) and + is_nil(n.dismissed_at) + ) + |> Repo.update_all(set: [read_at: now, seen_at: now]) + + if count > 0 do + Elektrine.AppCache.invalidate_notification_cache(user_id) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}:notifications", + :notification_updated + ) + + new_count = get_visible_unread_count(user_id) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}:notification_count", + {:notification_count_updated, new_count} + ) + end + + {:ok, count} + end + + def mark_as_read_up_to(_user_id, _max_id), do: {:ok, 0} + + @doc """ + Marks notifications as read by source. + Useful for clearing email notifications when the email is read. + """ + def mark_as_read_by_source(user_id, source_type, source_id) do + now = Elektrine.Time.utc_now() + + result = + from(n in Notification, + where: + n.user_id == ^user_id and + n.source_type == ^source_type and + n.source_id == ^source_id and + is_nil(n.read_at) + ) + |> Repo.update_all(set: [read_at: now, seen_at: now]) + + # Broadcast count update if any notifications were marked as read + case result do + {count, _} when count > 0 -> + # Invalidate notification cache + Elektrine.AppCache.invalidate_notification_cache(user_id) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}:notifications", + :notification_updated + ) + + new_count = get_visible_unread_count(user_id) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}:notification_count", + {:notification_count_updated, new_count} + ) + + {:ok, count} + + _ -> + {:ok, 0} + end + end + + @doc """ + Marks notifications as read by multiple source IDs (bulk operation). + Useful for clearing all message notifications in a conversation. + """ + def mark_as_read_by_sources(user_id, source_type, source_ids) when is_list(source_ids) do + now = Elektrine.Time.utc_now() + + result = + from(n in Notification, + where: + n.user_id == ^user_id and + n.source_type == ^source_type and + n.source_id in ^source_ids and + is_nil(n.read_at) + ) + |> Repo.update_all(set: [read_at: now, seen_at: now]) + + # Broadcast count update if any notifications were marked as read + case result do + {count, _} when count > 0 -> + # Invalidate notification cache + Elektrine.AppCache.invalidate_notification_cache(user_id) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}:notifications", + :notification_updated + ) + + new_count = get_visible_unread_count(user_id) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}:notification_count", + {:notification_count_updated, new_count} + ) + + {:ok, count} + + _ -> + {:ok, 0} + end + end + + @doc """ + Marks all notifications as read for a user. + """ + def mark_all_as_read(user_id) do + now = Elektrine.Time.utc_now() + + unread_notifications = + from(n in Notification, + where: n.user_id == ^user_id and is_nil(n.read_at) + ) + |> Repo.all() + + from(n in Notification, + where: n.user_id == ^user_id and is_nil(n.read_at) + ) + |> Repo.update_all(set: [read_at: now, seen_at: now]) + + # Invalidate notification cache + Elektrine.AppCache.invalidate_notification_cache(user_id) + + # Mark the notification rows first so linked source updates cannot emit a + # sequence of intermediate unread totals while this bulk action runs. + sync_notification_sources_as_read(user_id, unread_notifications) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}:notifications", + :all_notifications_read + ) + + # Broadcast count update (now 0) + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}:notification_count", + {:notification_count_updated, 0} + ) + + :ok + end + + defp sync_notification_sources_as_read(user_id, notifications) do + notifications = + notifications + |> List.wrap() + |> Enum.reject(&is_nil/1) + |> Enum.filter(&is_nil(&1.read_at)) + + mark_chat_notification_sources_as_read(user_id, notifications) + mark_email_notification_sources_as_read(user_id, notifications) + + :ok + end + + defp mark_chat_notification_sources_as_read(user_id, notifications) do + notifications + |> Enum.each(fn + %Notification{type: type, source_type: "message", source_id: message_id} + when type in ["new_message", "reply"] and is_integer(message_id) -> + case Messaging.get_chat_message(message_id) do + %{conversation_id: conversation_id, sender_id: sender_id, id: id} + when is_integer(conversation_id) and sender_id != user_id -> + Messaging.mark_chat_messages_read(conversation_id, user_id, id) + + _ -> + :ok + end + + _ -> + :ok + end) + end + + defp mark_email_notification_sources_as_read(user_id, notifications) do + with true <- email_source_available?(), + %{id: mailbox_id} <- Elektrine.Email.get_user_mailbox(user_id) do + Enum.each(notifications, fn + %Notification{type: "email_received", source_type: "email", source_id: message_id} + when is_integer(message_id) -> + case Elektrine.Email.get_message_internal(message_id) do + %{mailbox_id: ^mailbox_id, read: false} = message -> + Elektrine.Email.mark_as_read(message) + + _ -> + :ok + end + + _ -> + :ok + end) + else + _ -> :ok + end + end + + defp email_source_available? do + Code.ensure_loaded?(Elektrine.Email) and + function_exported?(Elektrine.Email, :get_user_mailbox, 1) and + function_exported?(Elektrine.Email, :get_message_internal, 1) and + function_exported?(Elektrine.Email, :mark_as_read, 1) + end + + @doc """ + Dismisses a notification. + """ + def dismiss_notification(notification_id, user_id) do + now = Elektrine.Time.utc_now() + + # Check if this notification was unread before dismissing + was_unread = + from(n in Notification, + where: n.id == ^notification_id and n.user_id == ^user_id and is_nil(n.read_at), + select: count(n.id) + ) + |> Repo.one() + |> Kernel.>(0) + + from(n in Notification, + where: n.id == ^notification_id and n.user_id == ^user_id + ) + |> Repo.update_all(set: [dismissed_at: now]) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}:notifications", + :notification_dismissed + ) + + # If it was unread, broadcast count update + if was_unread do + Elektrine.AppCache.invalidate_notification_cache(user_id) + new_count = get_visible_unread_count(user_id) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}:notification_count", + {:notification_count_updated, new_count} + ) + end + + :ok + end + + @doc """ + Dismisses all notifications for a user. + """ + def dismiss_all_notifications(user_id) do + now = Elektrine.Time.utc_now() + + from(n in Notification, + where: n.user_id == ^user_id and is_nil(n.dismissed_at) + ) + |> Repo.update_all(set: [dismissed_at: now]) + + Elektrine.AppCache.invalidate_notification_cache(user_id) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}:notifications", + :all_notifications_dismissed + ) + + # Broadcast count update (now 0) + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}:notification_count", + {:notification_count_updated, 0} + ) + + :ok + end + + @doc """ + Dismisses multiple notifications for a user. + """ + def dismiss_notifications(notification_ids, user_id) when is_list(notification_ids) do + ids = + notification_ids + |> Enum.map(&parse_notification_id/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + + if ids == [] do + {:ok, 0} + else + now = Elektrine.Time.utc_now() + + {count, _} = + from(n in Notification, + where: + n.id in ^ids and n.user_id == ^user_id and + is_nil(n.dismissed_at) + ) + |> Repo.update_all(set: [dismissed_at: now]) + + if count > 0 do + Elektrine.AppCache.invalidate_notification_cache(user_id) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}:notifications", + :notifications_dismissed + ) + + new_count = get_visible_unread_count(user_id) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}:notification_count", + {:notification_count_updated, new_count} + ) + end + + {:ok, count} + end + end + + def dismiss_notifications(_notification_ids, _user_id), do: {:ok, 0} + + defp parse_notification_id(value) when is_integer(value) and value > 0, do: value + + defp parse_notification_id(value) when is_binary(value) do + case Integer.parse(value) do + {id, ""} when id > 0 -> id + _ -> nil + end + end + + defp parse_notification_id(_value), do: nil + + defp query_unread_count(user_id) do + from(n in Notification, + where: n.user_id == ^user_id and is_nil(n.read_at) and is_nil(n.dismissed_at), + select: count(n.id) + ) + |> Repo.one() + end + + @doc """ + Creates common notification types. + """ + def notify_new_message(recipient_id, sender, conversation_name, message_preview) do + # Check if user wants to be notified about direct messages + user = Elektrine.Accounts.get_user!(recipient_id) + + if notification_preference_enabled?(user, :notify_on_direct_message) do + create_notification(%{ + type: "new_message", + title: "New message from @#{sender.handle || sender.username}", + body: String.slice(message_preview, 0, 100), + url: Elektrine.Paths.chat_path(conversation_name), + icon: "hero-chat-bubble-left", + user_id: recipient_id, + actor_id: sender.id, + source_type: "message", + priority: "normal" + }) + else + {:ok, :notification_disabled} + end + end + + def notify_mention(mentioned_user_id, actor, source_type, source_id, context) do + # Check if user wants to be notified about mentions + user = Elektrine.Accounts.get_user!(mentioned_user_id) + + if notification_preference_enabled?(user, :notify_on_mention) do + create_notification(%{ + type: "mention", + title: "@#{actor.handle || actor.username} mentioned you", + body: context, + url: build_mention_url(source_type, source_id), + icon: "hero-at-symbol", + user_id: mentioned_user_id, + actor_id: actor.id, + source_type: source_type, + source_id: source_id, + priority: "high" + }) + else + {:ok, :notification_disabled} + end + end + + def notify_follow(followed_user_id, follower) do + # Check if user wants to be notified about new followers + user = Elektrine.Accounts.get_user!(followed_user_id) + + if notification_preference_enabled?(user, :notify_on_new_follower) do + create_notification(%{ + type: "follow", + title: "@#{follower.handle || follower.username} started following you", + body: nil, + url: "/#{follower.handle || follower.username}", + icon: "hero-user-plus", + user_id: followed_user_id, + actor_id: follower.id, + source_type: "user", + source_id: follower.id, + priority: "normal" + }) + else + {:ok, :notification_disabled} + end + end + + def notify_reaction(content_owner_id, reactor, emoji, source_type, source_id) do + # Don't notify if reacting to own content + if content_owner_id == reactor.id do + {:ok, :self_reaction} + else + create_notification(%{ + type: "reaction", + title: "@#{reactor.handle || reactor.username} reacted #{emoji} to your #{source_type}", + body: nil, + url: build_content_url(source_type, source_id), + icon: "hero-face-smile", + user_id: content_owner_id, + actor_id: reactor.id, + source_type: source_type, + source_id: source_id, + priority: "low" + }) + end + end + + defp build_mention_url("message", message_id), + do: Elektrine.Paths.chat_root_message_path(message_id) + + defp build_mention_url("post", post_id), do: Elektrine.Paths.post_path(post_id) + + defp build_mention_url("discussion", discussion_id), + do: Elektrine.Paths.post_path(discussion_id) + + defp build_mention_url(_, _), do: "/" + + defp build_content_url("post", post_id), do: Elektrine.Paths.post_path(post_id) + + defp build_content_url("discussion", discussion_id), + do: Elektrine.Paths.post_path(discussion_id) + + defp build_content_url(_, _), do: "/" + + defp resolve_message_notification_urls(notifications) when is_list(notifications) do + source_ids = + notifications + |> Enum.filter(&needs_message_notification_url_resolution?/1) + |> Enum.map(& &1.source_id) + |> Enum.uniq() + + if source_ids == [] do + notifications + else + {messages_by_id, parents_by_id} = load_messages_with_parents(source_ids) + + Enum.map(notifications, fn notification -> + if needs_message_notification_url_resolution?(notification) do + case Map.get(messages_by_id, notification.source_id) do + %Message{} = message -> + resolved_url = + build_message_notification_url( + message, + Map.get(parents_by_id, message.reply_to_id), + notification.type + ) + + if is_binary(resolved_url) and resolved_url != "" do + %{notification | url: resolved_url} + else + notification + end + + nil -> + notification + end + else + notification + end + end) + end + end + + defp needs_message_notification_url_resolution?(%Notification{ + source_type: "message", + source_id: source_id, + url: url + }) + when is_integer(source_id) do + is_nil(url) or url == "" or + String.starts_with?(url, ["/timeline/post/", "/remote/post/", "/post/", "#message-"]) + end + + defp needs_message_notification_url_resolution?(_), do: false + + defp load_messages_with_parents(message_ids) when is_list(message_ids) do + messages = + from(m in Message, where: m.id in ^message_ids, preload: [:conversation]) + |> Repo.all() + + parent_ids = + messages + |> Enum.map(& &1.reply_to_id) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + + parents = + if parent_ids == [] do + [] + else + from(m in Message, where: m.id in ^parent_ids, preload: [:conversation]) + |> Repo.all() + end + + {Map.new(messages, &{&1.id, &1}), Map.new(parents, &{&1.id, &1})} + end + + defp build_message_notification_url( + %Message{federated: true} = message, + %Message{} = parent, + notification_type + ) + when notification_type in ["reply", "mention", "comment", "discussion_reply"] do + Elektrine.Paths.remote_post_path(parent.id) <> threaded_message_anchor(parent, message) + end + + defp build_message_notification_url( + %Message{} = message, + %Message{} = parent, + notification_type + ) + when notification_type in ["reply", "mention", "comment", "discussion_reply"] do + build_message_path(parent) <> threaded_message_anchor(parent, message) + end + + defp build_message_notification_url(%Message{} = message, _parent, _notification_type) do + build_message_path(message) + end + + defp build_message_path(%Message{ + id: id, + conversation: %{type: type} = conversation + }) + when type in ["dm", "group", "channel"] do + conversation_ref = conversation.hash || conversation.id + Elektrine.Paths.chat_message_path(conversation_ref, id) + end + + defp build_message_path(%Message{ + id: id, + conversation: %{type: "community", name: name} + }) + when is_binary(name) and name != "" do + Elektrine.Paths.discussion_post_path(name, id) + end + + defp build_message_path(%Message{federated: true, id: id}), + do: Elektrine.Paths.remote_post_path(id) + + defp build_message_path(%Message{} = message), do: Elektrine.Paths.post_path(message) + + defp threaded_message_anchor(%Message{conversation: %{type: "community"}}, %Message{id: id}), + do: Elektrine.Paths.post_anchor(id) + + defp threaded_message_anchor(_parent, %Message{id: id}), + do: Elektrine.Paths.post_anchor(id) + + defp notification_preference_enabled?(subject, field) when is_atom(field) do + Map.get(subject, field) != false + end + + defp maybe_emit_developer_webhook(%Notification{} = notif, unread_count) do + case notification_webhook_event(notif.type) do + nil -> + :ok + + event -> + payload = %{ + notification_id: notif.id, + type: notif.type, + title: notif.title, + body: notif.body, + url: notif.url, + source_type: notif.source_type, + source_id: notif.source_id, + actor_id: notif.actor_id, + unread_count: unread_count, + inserted_at: notif.inserted_at + } + + _ = Elektrine.Developer.deliver_event(notif.user_id, event, payload) + :ok + end + rescue + _ -> :ok + end + + defp notification_webhook_event("email_received"), do: "email.received" + defp notification_webhook_event("new_message"), do: "message.received" + defp notification_webhook_event("reply"), do: "message.received" + defp notification_webhook_event("like"), do: "post.liked" + defp notification_webhook_event("follow"), do: "follow.new" + defp notification_webhook_event(_), do: nil + + defp push_payload(%Notification{} = notif, badge) do + %{ + title: notif.title, + body: notif.body, + badge: badge, + icon: notif.icon, + type: notif.type, + actor_id: notif.actor_id, + data: %{ + type: notif.type, + url: notif.url, + source_type: notif.source_type, + source_id: notif.source_id, + notification_id: notif.id + } + } + end + + @doc """ + Redacts notification delivery payload content when the recipient has enabled + private notification previews. + """ + def redact_delivery_payload(payload, user_id) when is_map(payload) do + maybe_redact_push_payload(payload, user_id) + end + + def redact_delivery_payload(payload, _user_id), do: payload + + defp maybe_redact_push_payload(payload, user_id) do + case notification_content_hidden?(user_id) do + true -> + payload + |> Map.put(:title, "New notification") + |> Map.put(:body, "Open Elektrine to view it.") + + false -> + payload + end + end + + defp notification_content_hidden?(user_id) when is_integer(user_id) do + User + |> where([u], u.id == ^user_id) + |> select([u], u.hide_notification_contents) + |> Repo.one() + |> Kernel.==(true) + end + + defp notification_content_hidden?(_user_id), do: false + + defp notification_policy_allowed?(attrs) when is_map(attrs) do + module = Module.concat([Elektrine.Social, NotificationPolicy]) + + if Code.ensure_loaded?(module) do + module.should_deliver?(attrs) + else + true + end + rescue + _ -> true + end +end diff --git a/apps/elektrine/lib/elektrine/notifications/federation_notifications.ex b/apps/elektrine/lib/elektrine/notifications/federation_notifications.ex new file mode 100644 index 0000000..c5fca67 --- /dev/null +++ b/apps/elektrine/lib/elektrine/notifications/federation_notifications.ex @@ -0,0 +1,174 @@ +defmodule Elektrine.Notifications.FederationNotifications do + @moduledoc """ + Handles notifications for ActivityPub federation events. + """ + + alias Elektrine.ActivityPub + alias Elektrine.Notifications + + @doc """ + Notifies a user when a remote user follows them. + """ + def notify_remote_follow(followed_user_id, remote_actor_id) do + actor = Elektrine.Repo.get(ActivityPub.Actor, remote_actor_id) + + if actor do + Notifications.create_notification(%{ + user_id: followed_user_id, + type: "follow", + title: "#{remote_actor_handle(actor)} is now following you", + body: nil, + url: remote_actor_profile_path(actor), + source_type: "activitypub_actor", + source_id: actor.id, + metadata: %{remote_actor_id: actor.id}, + priority: "normal" + }) + end + end + + @doc """ + Notifies a user when their follow request is accepted. + """ + def notify_follow_accepted(user_id, remote_actor_uri) do + case ActivityPub.get_actor_by_uri(remote_actor_uri) do + nil -> + :ok + + actor -> + Notifications.create_notification(%{ + user_id: user_id, + type: "follow", + title: "#{remote_actor_handle(actor)} accepted your follow request", + body: nil, + url: remote_actor_profile_path(actor), + source_type: "activitypub_actor", + source_id: actor.id, + metadata: %{remote_actor_id: actor.id}, + priority: "normal" + }) + end + end + + @doc """ + Notifies a user when a remote user likes their post. + """ + def notify_remote_like(message_id, remote_actor_id) do + message = Elektrine.Messaging.get_message(message_id) + actor = Elektrine.Repo.get(ActivityPub.Actor, remote_actor_id) + + if message && message.sender_id && actor do + Notifications.create_notification(%{ + user_id: message.sender_id, + type: "like", + title: "Like from the fediverse", + body: "@#{actor.username}@#{actor.domain} liked your post", + url: Elektrine.Paths.post_path(message_id), + source_type: "message", + source_id: message_id, + metadata: %{remote_actor_id: actor.id}, + priority: "low" + }) + end + end + + @doc """ + Notifies a user when a remote user reacts to their post with an emoji. + """ + def notify_remote_reaction(message_id, remote_actor_id, emoji) do + message = Elektrine.Messaging.get_message(message_id) + actor = Elektrine.Repo.get(ActivityPub.Actor, remote_actor_id) + + if message && message.sender_id && actor do + Notifications.create_notification(%{ + user_id: message.sender_id, + type: "reaction", + title: "Reaction from the fediverse", + body: "@#{actor.username}@#{actor.domain} reacted #{emoji} to your post", + url: Elektrine.Paths.post_path(message_id), + source_type: "message", + source_id: message_id, + metadata: %{remote_actor_id: actor.id}, + priority: "low" + }) + end + end + + @doc """ + Notifies a user when a remote user boosts/announces their post. + """ + def notify_remote_announce(message_id, remote_actor_id) do + message = Elektrine.Messaging.get_message(message_id) + actor = Elektrine.Repo.get(ActivityPub.Actor, remote_actor_id) + + if message && message.sender_id && actor do + Notifications.create_notification(%{ + user_id: message.sender_id, + type: "boost", + title: "Boost from the fediverse", + body: "@#{actor.username}@#{actor.domain} boosted your post", + url: Elektrine.Paths.post_path(message_id), + source_type: "message", + source_id: message_id, + metadata: %{remote_actor_id: actor.id}, + priority: "normal" + }) + end + end + + @doc """ + Notifies a user when a remote user replies to their post. + """ + def notify_remote_reply(message_id, remote_actor_id) do + message = Elektrine.Messaging.get_message(message_id) + + parent = + if message.reply_to_id, do: Elektrine.Messaging.get_message(message.reply_to_id), else: nil + + actor = Elektrine.Repo.get(ActivityPub.Actor, remote_actor_id) + + if parent && parent.sender_id && actor do + Notifications.create_notification(%{ + user_id: parent.sender_id, + type: "reply", + title: "Reply from the fediverse", + body: "@#{actor.username}@#{actor.domain} replied to your post", + url: Notifications.resolve_message_notification_url(message_id, "reply"), + source_type: "message", + source_id: message_id, + metadata: %{remote_actor_id: actor.id}, + priority: "normal" + }) + end + end + + @doc """ + Notifies a user when a remote user mentions them in a post. + """ + def notify_remote_mention(user_id, message_id, remote_actor_id) do + actor = Elektrine.Repo.get(ActivityPub.Actor, remote_actor_id) + + if actor do + Notifications.create_notification(%{ + user_id: user_id, + type: "mention", + title: "Mentioned in a post", + body: "@#{actor.username}@#{actor.domain} mentioned you in a post", + url: Notifications.resolve_message_notification_url(message_id, "mention"), + source_type: "message", + source_id: message_id, + metadata: %{remote_actor_id: actor.id}, + priority: "normal" + }) + end + end + + defp remote_actor_handle(%ActivityPub.Actor{} = actor) do + prefix = if actor.actor_type == "Group", do: "!", else: "@" + "#{prefix}#{actor.username}@#{actor.domain}" + end + + defp remote_actor_profile_path(%ActivityPub.Actor{} = actor) do + Elektrine.Paths.profile_path(actor) || "/remote/#{remote_actor_handle(actor)}" + end +end diff --git a/apps/elektrine/lib/elektrine/notifications/notification.ex b/apps/elektrine/lib/elektrine/notifications/notification.ex new file mode 100644 index 0000000..459b815 --- /dev/null +++ b/apps/elektrine/lib/elektrine/notifications/notification.ex @@ -0,0 +1,83 @@ +defmodule Elektrine.Notifications.Notification do + @moduledoc """ + Schema for user notifications across the platform. + Supports various notification types including messages, mentions, follows, and system alerts with priority levels and read/seen tracking. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "notifications" do + field :type, :string + field :title, :string + field :body, :string + field :url, :string + field :icon, :string + field :priority, :string, default: "normal" + + field :read_at, :utc_datetime + field :seen_at, :utc_datetime + field :dismissed_at, :utc_datetime + + belongs_to :user, Elektrine.Accounts.User + belongs_to :actor, Elektrine.Accounts.User + + field :source_type, :string + field :source_id, :integer + field :group_key, :string + + field :metadata, :map, default: %{} + + timestamps() + end + + @doc false + def changeset(notification, attrs) do + notification + |> cast(attrs, [ + :type, + :title, + :body, + :url, + :icon, + :priority, + :user_id, + :actor_id, + :source_type, + :source_id, + :group_key, + :metadata, + :read_at, + :seen_at, + :dismissed_at + ]) + |> truncate_utc_datetimes([:read_at, :seen_at, :dismissed_at]) + |> validate_required([:type, :title, :user_id]) + |> validate_inclusion(:type, [ + "new_message", + "mention", + "reply", + "follow", + "like", + "boost", + "reaction", + "status", + "poll", + "update", + "admin.sign_up", + "admin.report", + "comment", + "discussion_reply", + "email_received", + "system", + "uptime_down", + "uptime_recovered" + ]) + |> validate_inclusion(:priority, ["low", "normal", "high", "urgent"]) + end + + defp truncate_utc_datetimes(changeset, fields) do + Enum.reduce(fields, changeset, fn field, changeset -> + update_change(changeset, field, &Elektrine.Time.truncate/1) + end) + end +end diff --git a/apps/elektrine/lib/elektrine/oauth.ex b/apps/elektrine/lib/elektrine/oauth.ex new file mode 100644 index 0000000..0d453c5 --- /dev/null +++ b/apps/elektrine/lib/elektrine/oauth.ex @@ -0,0 +1,177 @@ +defmodule Elektrine.OAuth do + @moduledoc """ + The OAuth context for OpenID Connect clients. + + This module provides OAuth 2.0 functionality for third-party apps. + """ + + alias Elektrine.Accounts.User + alias Elektrine.OAuth.App + alias Elektrine.OAuth.Authorization + alias Elektrine.OAuth.Scopes + alias Elektrine.OAuth.Token + + # Re-export key modules + defdelegate valid_scopes, to: Scopes + defdelegate parse_scopes(scope_string, default \\ ["read"]), to: Scopes + defdelegate fetch_scopes(params, default \\ ["read"]), to: Scopes + + # App functions + + @doc """ + Creates a new OAuth app. + """ + @spec create_app(map()) :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} + def create_app(params), do: App.create(params) + + @doc """ + Gets an app by its client_id. + """ + @spec get_app_by_client_id(String.t()) :: App.t() | nil + def get_app_by_client_id(client_id), do: App.get_by_client_id(client_id) + + @doc """ + Gets an app by its credentials (client_id and client_secret). + """ + @spec get_app_by_credentials(String.t(), String.t()) :: App.t() | nil + def get_app_by_credentials(client_id, client_secret) do + App.get_by_credentials(client_id, client_secret) + end + + @doc """ + Gets all apps owned by a user. + """ + @spec get_user_apps(User.t()) :: [App.t()] + def get_user_apps(user), do: App.get_user_apps(user) + + @doc """ + Gets a specific app owned by a user. + """ + @spec get_user_app(User.t(), pos_integer()) :: App.t() | nil + def get_user_app(user, id), do: App.get_user_app(user, id) + + @doc """ + Deletes an OAuth app. + """ + @spec delete_app(integer()) :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} | nil + def delete_app(id), do: App.delete(id) + + @doc """ + Updates an OAuth app owned by a user. + """ + @spec update_user_app(User.t(), pos_integer(), map()) :: + {:ok, App.t()} | {:error, Ecto.Changeset.t()} | nil + def update_user_app(user, id, params), do: App.update_user_app(user, id, params) + + @doc """ + Rotates the secret for an OAuth app owned by a user. + """ + @spec rotate_app_secret(User.t(), pos_integer()) :: + {:ok, App.t()} | {:error, Ecto.Changeset.t()} | nil + def rotate_app_secret(user, id), do: App.rotate_secret(user, id) + + # Authorization functions + + @doc """ + Creates an authorization code for the OAuth flow. + """ + @spec create_authorization(App.t(), User.t(), [String.t()] | map() | nil) :: + {:ok, Authorization.t()} | {:error, Ecto.Changeset.t()} + def create_authorization(app, user, scopes \\ nil) do + Authorization.create_authorization(app, user, scopes) + end + + @doc """ + Gets an authorization by its token for a specific app. + """ + @spec get_authorization(App.t(), String.t()) :: {:ok, Authorization.t()} | {:error, :not_found} + def get_authorization(app, token), do: Authorization.get_by_token(app, token) + + @doc """ + Atomically consumes an authorization code for a specific app. + """ + @spec consume_authorization(App.t(), String.t()) :: {:ok, Authorization.t()} | {:error, any()} + def consume_authorization(app, token), do: Authorization.consume_token(app, token) + + # Token functions + + @doc """ + Creates an access token directly (for password grant or client credentials). + """ + @spec create_token(App.t(), User.t() | nil, map()) :: + {:ok, Token.t()} | {:error, Ecto.Changeset.t()} + def create_token(app, user, attrs \\ %{}), do: Token.create(app, user, attrs) + + @doc """ + Exchanges an authorization code for an access token. + """ + @spec exchange_token(App.t(), Authorization.t()) :: + {:ok, Token.t()} | {:error, Ecto.Changeset.t()} | {:error, String.t()} + def exchange_token(app, auth), do: Token.exchange_token(app, auth) + + @doc """ + Gets a token by its access token string. + """ + @spec get_token(String.t()) :: {:ok, Token.t()} | {:error, :not_found} + def get_token(token), do: Token.get_by_token(token) + + @doc """ + Gets all tokens for a user. + """ + @spec get_user_tokens(User.t()) :: [Token.t()] + def get_user_tokens(user), do: Token.get_user_tokens(user) + + @doc """ + Revokes all grants a user has issued to an app. + """ + @spec revoke_user_app_grants(User.t(), pos_integer()) :: :ok + def revoke_user_app_grants(user, app_id) do + Token.delete_user_app_tokens(user, app_id) + Authorization.delete_user_app_authorizations(user, app_id) + :ok + end + + @doc """ + Refreshes an access token using a refresh token. + """ + @spec refresh_token(App.t(), String.t()) :: {:ok, Token.t()} | {:error, any()} + def refresh_token(app, refresh_token), do: Token.refresh(app, refresh_token) + + @doc """ + Revokes an access token. + """ + @spec revoke_token(String.t()) :: :ok | {:error, :not_found} + def revoke_token(token), do: Token.revoke(token) + + @spec revoke_token(App.t(), String.t()) :: :ok | {:error, :not_found} + def revoke_token(app, token), do: Token.revoke(app, token) + + @doc """ + Deletes all tokens and authorizations for a user. + """ + @spec delete_user_tokens(User.t()) :: :ok + def delete_user_tokens(user) do + Token.delete_user_tokens(user) + Authorization.delete_user_authorizations(user) + :ok + end + + @doc """ + Validates that a token has the required scopes. + """ + @spec validate_scopes(Token.t(), [String.t()]) :: :ok | {:error, :insufficient_scope} + def validate_scopes(%Token{scopes: token_scopes}, required_scopes) do + if Scopes.all_satisfied?(token_scopes, required_scopes) do + :ok + else + {:error, :insufficient_scope} + end + end + + @doc """ + Checks if a token is valid (not expired). + """ + @spec token_valid?(Token.t()) :: boolean() + def token_valid?(%Token{} = token), do: not Token.expired?(token) + def token_valid?(_), do: false +end diff --git a/apps/elektrine/lib/elektrine/oauth/app.ex b/apps/elektrine/lib/elektrine/oauth/app.ex new file mode 100644 index 0000000..6f9fa8a --- /dev/null +++ b/apps/elektrine/lib/elektrine/oauth/app.ex @@ -0,0 +1,362 @@ +defmodule Elektrine.OAuth.App do + @moduledoc """ + OAuth application registration. + + This module handles the registration and management of third-party applications + that need OAuth client credentials. Each app gets a unique client_id and + client_secret that can be used to authenticate users. + """ + + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + + alias Elektrine.Accounts.User + alias Elektrine.OAuth.Scopes + alias Elektrine.Repo + + @type t :: %__MODULE__{} + schema "oauth_apps" do + field(:client_name, :string) + field(:redirect_uris, :string) + field(:scopes, {:array, :string}, default: ["read"]) + field(:website, :string) + field(:client_id, :string) + field(:client_secret, :string) + field(:plain_client_secret, :string, virtual: true) + field(:trusted, :boolean, default: false) + + belongs_to(:user, User) + + has_many(:oauth_authorizations, Elektrine.OAuth.Authorization, on_delete: :delete_all) + has_many(:oauth_tokens, Elektrine.OAuth.Token, on_delete: :delete_all) + + timestamps(type: :utc_datetime) + end + + @doc """ + Changeset for updating an existing app. + """ + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(struct, params) do + struct + |> cast(params, [:client_name, :redirect_uris, :scopes, :website, :user_id]) + |> validate_length(:client_name, max: 255) + |> validate_length(:website, max: 2048) + |> validate_scopes() + |> validate_redirect_uris() + end + + @doc """ + Changeset for registering a new app. Generates client_id and client_secret. + """ + @spec register_changeset(t(), map()) :: Ecto.Changeset.t() + def register_changeset(struct, params \\ %{}) do + changeset = + struct + |> changeset(params) + |> validate_required([:client_name, :redirect_uris, :scopes]) + + if changeset.valid? do + client_secret = generate_token() + + changeset + |> put_change(:client_id, generate_token()) + |> put_change(:client_secret, hash_secret(client_secret)) + |> put_change(:plain_client_secret, client_secret) + else + changeset + end + end + + @doc """ + Creates a new OAuth app. + """ + @spec create(map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} + def create(params) do + %__MODULE__{} + |> register_changeset(params) + |> Repo.insert() + end + + @doc """ + Updates an existing OAuth app. + """ + @spec update(pos_integer(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} | nil + def update(id, params) do + case Repo.get(__MODULE__, id) do + %__MODULE__{} = app -> + app + |> changeset(params) + |> Repo.update() + + nil -> + nil + end + end + + @doc """ + Updates an existing OAuth app owned by a user. + """ + @spec update_user_app(User.t(), pos_integer(), map()) :: + {:ok, t()} | {:error, Ecto.Changeset.t()} | nil + def update_user_app(%User{} = user, id, params) do + case get_user_app(user, id) do + %__MODULE__{} = app -> + app + |> changeset(params) + |> Repo.update() + + nil -> + nil + end + end + + @doc """ + Gets an app by client_id. + """ + @spec get_by_client_id(String.t()) :: t() | nil + def get_by_client_id(client_id) do + Repo.get_by(__MODULE__, client_id: client_id) + end + + @doc """ + Returns the registered redirect URIs for an app. + """ + @spec redirect_uri_list(t()) :: [String.t()] + def redirect_uri_list(%__MODULE__{redirect_uris: redirect_uris}) + when is_binary(redirect_uris) do + redirect_uris + |> String.split() + |> Enum.reject(&(&1 == "")) + end + + def redirect_uri_list(_), do: [] + + @doc """ + Returns the default redirect URI when exactly one is registered. + """ + @spec default_redirect_uri(t()) :: String.t() | nil + def default_redirect_uri(%__MODULE__{} = app) do + case redirect_uri_list(app) do + [redirect_uri] -> redirect_uri + _ -> nil + end + end + + @doc """ + Checks whether the provided redirect URI is registered for the app. + """ + @spec redirect_uri_allowed?(t(), String.t()) :: boolean() + def redirect_uri_allowed?(%__MODULE__{} = app, redirect_uri) when is_binary(redirect_uri) do + redirect_uri in redirect_uri_list(app) + end + + def redirect_uri_allowed?(_, _), do: false + + @doc """ + Gets an app by client_id and client_secret. + """ + @spec get_by_credentials(String.t(), String.t()) :: t() | nil + def get_by_credentials(client_id, client_secret) do + from(a in __MODULE__, + where: a.client_id == ^client_id and a.client_secret == ^hash_secret(client_secret) + ) + |> Repo.one() + end + + @doc """ + Gets an existing app or creates a new one with the given attributes. + Updates scopes if the app already exists. + """ + @spec get_or_create(map(), list(String.t())) :: {:ok, t()} | {:error, Ecto.Changeset.t()} + def get_or_create(attrs, scopes) do + case Repo.get_by(__MODULE__, Map.take(attrs, [:client_name, :redirect_uris])) do + %__MODULE__{} = app -> + update_scopes(app, scopes) + + nil -> + %__MODULE__{} + |> register_changeset(Map.put(attrs, :scopes, scopes)) + |> Repo.insert() + end + end + + @doc """ + Gets all apps owned by a user. + """ + @spec get_user_apps(User.t()) :: [t()] + def get_user_apps(%User{id: user_id}) do + from(a in __MODULE__, where: a.user_id == ^user_id) + |> Repo.all() + end + + @doc """ + Gets a specific app owned by a user. + """ + @spec get_user_app(User.t(), pos_integer()) :: t() | nil + def get_user_app(%User{id: user_id}, id) do + Repo.get_by(__MODULE__, id: id, user_id: user_id) + end + + @doc """ + Deletes an OAuth app. + """ + @spec delete(pos_integer()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} | nil + def delete(id) do + case Repo.get(__MODULE__, id) do + %__MODULE__{} = app -> Repo.delete(app) + nil -> nil + end + end + + @doc """ + Rotates the client secret for an app owned by a user. + """ + @spec rotate_secret(User.t(), pos_integer()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} | nil + def rotate_secret(%User{} = user, id) do + case get_user_app(user, id) do + %__MODULE__{} = app -> + client_secret = generate_token() + + app + |> change(%{ + client_secret: hash_secret(client_secret), + plain_client_secret: client_secret + }) + |> Repo.update() + + nil -> + nil + end + end + + @doc """ + Searches for apps with pagination and optional filters. + """ + @spec search(map()) :: {:ok, [t()], non_neg_integer()} + def search(params) do + page = Map.get(params, :page, 1) + page_size = Map.get(params, :page_size, 20) + + query = from(a in __MODULE__) + + query = + if params[:client_name] do + from(a in query, where: a.client_name == ^params[:client_name]) + else + query + end + + query = + if params[:client_id] do + from(a in query, where: a.client_id == ^params[:client_id]) + else + query + end + + query = + if Map.has_key?(params, :trusted) do + from(a in query, where: a.trusted == ^params[:trusted]) + else + query + end + + count = Repo.aggregate(__MODULE__, :count, :id) + + apps = + from(a in query, + limit: ^page_size, + offset: ^((page - 1) * page_size), + order_by: [desc: a.inserted_at] + ) + |> Repo.all() + + {:ok, apps, count} + end + + def client_secret_value(%__MODULE__{plain_client_secret: plain}) when is_binary(plain), + do: plain + + def client_secret_value(_), do: nil + + def client_secret_fingerprint(%__MODULE__{client_secret: secret}) when is_binary(secret) do + "sha256:" <> String.slice(secret, 0, 12) <> "..." + end + + def client_secret_fingerprint(_), do: "not available" + + defp hash_secret(secret) when is_binary(secret) do + :crypto.hash(:sha256, secret) |> Base.encode16(case: :lower) + end + + # Private functions + + defp update_scopes(%__MODULE__{scopes: current_scopes} = app, new_scopes) + when current_scopes == new_scopes or new_scopes == [] do + {:ok, app} + end + + defp update_scopes(%__MODULE__{} = app, scopes) do + app + |> changeset(%{scopes: scopes}) + |> Repo.update() + end + + defp validate_scopes(changeset) do + validate_change(changeset, :scopes, fn :scopes, scopes -> + invalid_scopes = scopes |> List.wrap() |> Enum.reject(&Scopes.valid?/1) + privileged_scopes = scopes |> List.wrap() |> Enum.reject(&Scopes.user_grantable?/1) + + cond do + invalid_scopes != [] -> + [scopes: "contains unknown scopes: #{Enum.join(invalid_scopes, ", ")}"] + + privileged_scopes != [] -> + [scopes: "contains privileged scopes: #{Enum.join(privileged_scopes, ", ")}"] + + true -> + [] + end + end) + end + + defp validate_redirect_uris(changeset) do + case get_change(changeset, :redirect_uris) do + nil -> + changeset + + uris -> + uris + |> String.split() + |> Enum.all?(&valid_redirect_uri?/1) + |> case do + true -> changeset + false -> add_error(changeset, :redirect_uris, "contains invalid URI") + end + end + end + + defp valid_redirect_uri?(uri) do + case URI.parse(uri) do + %URI{scheme: "https", host: host, fragment: nil} when not is_nil(host) -> + true + + %URI{scheme: "http", host: "localhost", fragment: nil} -> + true + + %URI{scheme: "http", host: "127.0.0.1", fragment: nil} -> + true + + %URI{scheme: "http", host: "::1", fragment: nil} -> + true + + _ -> + false + end + end + + defp generate_token do + :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + end +end diff --git a/apps/elektrine/lib/elektrine/oauth/authorization.ex b/apps/elektrine/lib/elektrine/oauth/authorization.ex new file mode 100644 index 0000000..0d346d0 --- /dev/null +++ b/apps/elektrine/lib/elektrine/oauth/authorization.ex @@ -0,0 +1,226 @@ +defmodule Elektrine.OAuth.Authorization do + @moduledoc """ + OAuth authorization codes. + + Authorization codes are short-lived tokens that get exchanged for + access tokens during the OAuth authorization code flow. + """ + + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Elektrine.Accounts.User + alias Elektrine.OAuth.App + alias Elektrine.OAuth.Authorization + alias Elektrine.Repo + + @type t :: %__MODULE__{} + + # Authorization codes expire after 10 minutes + @authorization_lifetime 600 + + schema "oauth_authorizations" do + field(:token, :string) + field(:plain_token, :string, virtual: true) + field(:scopes, {:array, :string}, default: []) + field(:valid_until, :utc_datetime) + field(:used, :boolean, default: false) + field(:redirect_uri, :string) + field(:state, :string) + field(:nonce, :string) + field(:code_challenge, :string) + field(:code_challenge_method, :string) + field(:identity_subject, :string) + field(:identity_domain, :string) + field(:identity_did, :string) + + belongs_to(:user, User) + belongs_to(:app, App, foreign_key: :app_id) + + timestamps(type: :utc_datetime) + end + + @doc """ + Creates a new authorization for the given app and user. + """ + @spec create_authorization(App.t(), User.t(), [String.t()] | map() | nil) :: + {:ok, Authorization.t()} | {:error, Ecto.Changeset.t()} + def create_authorization(%App{} = app, %User{} = user, attrs_or_scopes \\ nil) do + attrs = + case attrs_or_scopes do + nil -> %{} + scopes when is_list(scopes) -> %{scopes: scopes} + attrs when is_map(attrs) -> attrs + end + + attrs + |> Map.put_new(:scopes, app.scopes) + |> Map.put(:user_id, user.id) + |> Map.put(:app_id, app.id) + |> create_changeset() + |> Repo.insert() + end + + @doc """ + Creates a changeset for a new authorization. + """ + @spec create_changeset(map()) :: Ecto.Changeset.t() + def create_changeset(attrs \\ %{}) do + %Authorization{} + |> cast(attrs, [ + :user_id, + :app_id, + :scopes, + :valid_until, + :redirect_uri, + :state, + :nonce, + :code_challenge, + :code_challenge_method, + :identity_subject, + :identity_domain, + :identity_did + ]) + |> validate_required([:app_id, :scopes]) + |> validate_inclusion(:code_challenge_method, ["S256"]) + |> validate_code_challenge_requirements() + |> add_token() + |> add_lifetime() + end + + @doc """ + Atomically consumes an authorization code and returns it if valid. + """ + @spec consume_token(App.t(), String.t()) :: {:ok, t()} | {:error, :not_found | String.t()} + def consume_token(%App{id: app_id}, token) do + Repo.transaction(fn -> + query = + from(a in __MODULE__, + where: a.app_id == ^app_id and a.token == ^hash_secret(token), + where: a.used == false and a.valid_until > ^DateTime.utc_now(), + lock: "FOR UPDATE" + ) + + case Repo.one(query) do + nil -> + Repo.rollback(:not_found) + + auth -> + case Repo.update(use_changeset(auth, %{used: true})) do + {:ok, used_auth} -> used_auth + {:error, changeset} -> Repo.rollback(changeset) + end + end + end) + |> case do + {:ok, auth} -> {:ok, auth} + {:error, :not_found} -> {:error, :not_found} + {:error, %Ecto.Changeset{} = changeset} -> {:error, changeset} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Gets an authorization by token for a specific app. + """ + @spec get_by_token(App.t(), String.t()) :: {:ok, t()} | {:error, :not_found} + def get_by_token(%App{id: app_id}, token) do + query = + from(a in __MODULE__, + where: a.app_id == ^app_id and a.token == ^hash_secret(token), + where: a.used == false and a.valid_until > ^DateTime.utc_now() + ) + + case Repo.one(query) do + nil -> {:error, :not_found} + auth -> {:ok, auth} + end + end + + @doc """ + Gets a pre-existing unused authorization for an app and user. + """ + @spec get_existing(App.t(), User.t()) :: {:ok, t()} | {:error, :not_found} + def get_existing(%App{id: app_id}, %User{id: user_id}) do + query = + from(a in __MODULE__, + where: a.app_id == ^app_id and a.user_id == ^user_id and a.used == false, + where: a.valid_until > ^DateTime.utc_now(), + limit: 1 + ) + + case Repo.one(query) do + nil -> {:error, :not_found} + auth -> {:ok, auth} + end + end + + @doc """ + Deletes all authorizations for a user. + """ + @spec delete_user_authorizations(User.t()) :: {integer(), any()} + def delete_user_authorizations(%User{id: user_id}) do + from(a in __MODULE__, where: a.user_id == ^user_id) + |> Repo.delete_all() + end + + @doc """ + Deletes all authorizations for a user and app. + """ + @spec delete_user_app_authorizations(User.t(), pos_integer()) :: {integer(), any()} + def delete_user_app_authorizations(%User{id: user_id}, app_id) do + from(a in __MODULE__, where: a.user_id == ^user_id and a.app_id == ^app_id) + |> Repo.delete_all() + end + + # Private functions + + defp use_changeset(%Authorization{} = auth, params) do + auth + |> cast(params, [:used]) + |> validate_required([:used]) + end + + defp add_token(changeset) do + token = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + + changeset + |> put_change(:token, hash_secret(token)) + |> put_change(:plain_token, token) + end + + defp add_lifetime(changeset) do + valid_until = + DateTime.utc_now() + |> DateTime.truncate(:second) + |> DateTime.add(@authorization_lifetime, :second) + + put_change(changeset, :valid_until, valid_until) + end + + def token_value(%Authorization{plain_token: token}) when is_binary(token), do: token + def token_value(_), do: nil + + defp hash_secret(secret) when is_binary(secret) do + :crypto.hash(:sha256, secret) |> Base.encode16(case: :lower) + end + + defp validate_code_challenge_requirements(changeset) do + code_challenge = get_field(changeset, :code_challenge) + code_challenge_method = get_field(changeset, :code_challenge_method) + + cond do + is_binary(code_challenge) and code_challenge != "" and is_nil(code_challenge_method) -> + put_change(changeset, :code_challenge_method, "S256") + + is_binary(code_challenge_method) and code_challenge_method != "" and + not is_binary(code_challenge) -> + add_error(changeset, :code_challenge, "is required when code_challenge_method is set") + + true -> + changeset + end + end +end diff --git a/apps/elektrine/lib/elektrine/oauth/scopes.ex b/apps/elektrine/lib/elektrine/oauth/scopes.ex new file mode 100644 index 0000000..ec1dfe6 --- /dev/null +++ b/apps/elektrine/lib/elektrine/oauth/scopes.ex @@ -0,0 +1,184 @@ +defmodule Elektrine.OAuth.Scopes do + @moduledoc """ + OAuth scope definitions and validation. + + Scopes control what actions an OAuth token is allowed to perform. + """ + + # All valid OAuth scopes + @valid_scopes [ + # OpenID Connect scopes + "openid", + "profile", + "email", + # Read scopes + "read", + "read:accounts", + "read:blocks", + "read:bookmarks", + "read:favourites", + "read:filters", + "read:follows", + "read:lists", + "read:mutes", + "read:notifications", + "read:search", + "read:statuses", + # Write scopes + "write", + "write:accounts", + "write:blocks", + "write:bookmarks", + "write:conversations", + "write:favourites", + "write:filters", + "write:follows", + "write:lists", + "write:media", + "write:mutes", + "write:notifications", + "write:reports", + "write:statuses", + # Follow scope (special for follow/unfollow actions) + "follow", + # Push notifications + "push", + # Admin scopes + "admin:read", + "admin:read:accounts", + "admin:read:reports", + "admin:read:domain_allows", + "admin:read:domain_blocks", + "admin:read:ip_blocks", + "admin:read:email_domain_blocks", + "admin:read:canonical_email_blocks", + "admin:write", + "admin:write:accounts", + "admin:write:reports", + "admin:write:domain_allows", + "admin:write:domain_blocks", + "admin:write:ip_blocks", + "admin:write:email_domain_blocks", + "admin:write:canonical_email_blocks" + ] + + @user_grantable_scopes Enum.reject(@valid_scopes, &String.starts_with?(&1, "admin:")) + + @doc """ + Returns all valid OAuth scopes. + """ + @spec valid_scopes() :: [String.t()] + def valid_scopes, do: @valid_scopes + + @doc """ + Checks if a scope is valid. + """ + @spec valid?(String.t()) :: boolean() + def valid?(scope), do: scope in @valid_scopes + + @doc "Checks if a scope may be granted to self-service OAuth/OIDC clients." + @spec user_grantable?(String.t()) :: boolean() + def user_grantable?(scope), do: scope in @user_grantable_scopes + + @doc """ + Filters a list of scopes to only include valid ones. + """ + @spec filter_valid([String.t()]) :: [String.t()] + def filter_valid(scopes) when is_list(scopes) do + Enum.filter(scopes, &valid?/1) + end + + @doc """ + Fetches scopes from params, with a default fallback. + Handles both string and list formats. + """ + @spec fetch_scopes(map(), [String.t()]) :: [String.t()] + def fetch_scopes(params, default \\ ["read"]) + + def fetch_scopes(%{scope: scope}, default) when is_binary(scope) do + parse_scopes(scope, default) + end + + def fetch_scopes(%{scopes: scopes}, default) when is_binary(scopes) do + parse_scopes(scopes, default) + end + + def fetch_scopes(%{scopes: scopes}, default) when is_list(scopes) do + case filter_valid(scopes) do + [] -> default + valid -> valid + end + end + + def fetch_scopes(%{"scope" => scope}, default) when is_binary(scope) do + parse_scopes(scope, default) + end + + def fetch_scopes(%{"scopes" => scopes}, default) when is_binary(scopes) do + parse_scopes(scopes, default) + end + + def fetch_scopes(%{"scopes" => scopes}, default) when is_list(scopes) do + case filter_valid(scopes) do + [] -> default + valid -> valid + end + end + + def fetch_scopes(_, default), do: default + + @doc """ + Parses a space-separated scope string. + """ + @spec parse_scopes(String.t(), [String.t()]) :: [String.t()] + def parse_scopes(scope_string, default \\ ["read"]) when is_binary(scope_string) do + scopes = + scope_string + |> String.split(~r/[\s,]+/, trim: true) + |> filter_valid() + + case scopes do + [] -> default + valid -> valid + end + end + + @doc """ + Checks if one scope contains another (scope hierarchy). + + For example, "read" contains "read:accounts". + """ + @spec contains?(String.t(), String.t()) :: boolean() + def contains?(container, contained) when container == contained, do: true + + def contains?(container, contained) do + case String.split(contained, ":") do + [base | _rest] when base == container -> true + _ -> false + end + end + + @doc """ + Checks if a list of scopes satisfies a required scope. + """ + @spec satisfied?([String.t()], String.t()) :: boolean() + def satisfied?(scopes, required) when is_list(scopes) do + Enum.any?(scopes, fn scope -> contains?(scope, required) end) + end + + @doc """ + Checks if a list of scopes satisfies all required scopes. + """ + @spec all_satisfied?([String.t()], [String.t()]) :: boolean() + def all_satisfied?(scopes, required_scopes) do + Enum.all?(required_scopes, fn required -> satisfied?(scopes, required) end) + end + + @doc """ + Converts a list of scopes to a space-separated string. + """ + @spec to_string([String.t()]) :: String.t() + def to_string(scopes) when is_list(scopes) do + Enum.join(scopes, " ") + end +end diff --git a/apps/elektrine/lib/elektrine/oauth/token.ex b/apps/elektrine/lib/elektrine/oauth/token.ex new file mode 100644 index 0000000..bbdd976 --- /dev/null +++ b/apps/elektrine/lib/elektrine/oauth/token.ex @@ -0,0 +1,414 @@ +defmodule Elektrine.OAuth.Token do + @moduledoc """ + OAuth access tokens. + + Access tokens are long-lived tokens that authenticate API requests. + They can be obtained through the OAuth authorization code flow. + """ + + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Elektrine.Accounts.{Authentication, User} + alias Elektrine.OAuth.App + alias Elektrine.OAuth.Authorization + alias Elektrine.OAuth.Token + alias Elektrine.Repo + + @type t :: %__MODULE__{} + + # Default token lifetime: 30 days + @default_token_lifetime 30 * 24 * 60 * 60 + @default_refresh_token_lifetime 30 * 24 * 60 * 60 + + schema "oauth_tokens" do + field(:token, :string) + field(:refresh_token, :string) + field(:plain_token, :string, virtual: true) + field(:plain_refresh_token, :string, virtual: true) + field(:scopes, {:array, :string}, default: []) + field(:valid_until, :utc_datetime) + field(:oidc_nonce, :string) + field(:oidc_auth_time, :utc_datetime) + field(:identity_subject, :string) + field(:identity_domain, :string) + field(:identity_did, :string) + + belongs_to(:user, User) + belongs_to(:app, App, foreign_key: :app_id) + + timestamps(type: :utc_datetime) + end + + @doc """ + Gets the configured token lifetime in seconds. + """ + @spec lifespan() :: integer() + def lifespan do + Application.get_env(:elektrine, :oauth_token_lifetime, @default_token_lifetime) + end + + @doc """ + Gets a token by its access token string. + """ + @spec get_by_token(String.t()) :: {:ok, t()} | {:error, :not_found} + def get_by_token(token) do + query = + from(t in __MODULE__, + where: t.token == ^hash_secret(token), + preload: [:user, :app] + ) + + case Repo.one(query) do + nil -> {:error, :not_found} + token -> {:ok, token} + end + end + + @doc """ + Gets a token for an app by access token. + """ + @spec get_by_token(App.t(), String.t()) :: {:ok, t()} | {:error, :not_found} + def get_by_token(%App{id: app_id}, token) do + query = + from(t in __MODULE__, + where: t.app_id == ^app_id and t.token == ^hash_secret(token), + preload: [:user, :app] + ) + + case Repo.one(query) do + nil -> {:error, :not_found} + token -> {:ok, token} + end + end + + @doc """ + Gets a token for an app by refresh token. + """ + @spec get_by_refresh_token(App.t(), String.t()) :: {:ok, t()} | {:error, :not_found} + def get_by_refresh_token(%App{id: app_id}, refresh_token) do + query = + from(t in __MODULE__, + where: t.app_id == ^app_id and t.refresh_token == ^hash_secret(refresh_token), + where: t.valid_until > ^DateTime.utc_now(), + preload: [:user, :app] + ) + + case Repo.one(query) do + nil -> {:error, :not_found} + token -> {:ok, token} + end + end + + @doc """ + Exchanges an authorization code for an access token. + """ + @spec exchange_token(App.t(), Authorization.t()) :: + {:ok, Token.t()} | {:error, Ecto.Changeset.t()} | {:error, String.t()} + def exchange_token(%App{} = app, %Authorization{} = auth) do + if auth.app_id == app.id do + user = if auth.user_id, do: Repo.get(User, auth.user_id), else: nil + + if token_older_than_auth_boundary?(user, auth) do + {:error, "authorization code invalidated"} + else + create(app, user, %{ + scopes: auth.scopes, + oidc_nonce: auth.nonce, + oidc_auth_time: auth.inserted_at, + identity_subject: auth.identity_subject, + identity_domain: auth.identity_domain, + identity_did: auth.identity_did + }) + end + else + {:error, "app mismatch"} + end + end + + @doc """ + Gets a pre-existing valid token for an app and user. + """ + @spec get_existing(App.t(), User.t()) :: {:ok, t()} | {:error, :not_found} + def get_existing(%App{id: app_id}, %User{id: user_id}) do + query = + from(t in __MODULE__, + where: t.app_id == ^app_id and t.user_id == ^user_id, + where: t.valid_until > ^DateTime.utc_now(), + order_by: [desc: t.inserted_at], + limit: 1, + preload: [:user, :app] + ) + + case Repo.one(query) do + nil -> {:error, :not_found} + token -> {:ok, token} + end + end + + @doc """ + Gets or exchanges a token. If auth was already used, get existing token. + """ + @spec get_or_exchange_token(Authorization.t(), App.t(), User.t()) :: + {:ok, t()} | {:error, any()} + def get_or_exchange_token(%Authorization{used: true}, %App{} = app, %User{} = user) do + get_existing(app, user) + end + + def get_or_exchange_token(%Authorization{} = auth, %App{} = app, _user) do + exchange_token(app, auth) + end + + @doc """ + Creates a new access token for an app and user. + """ + @spec create(App.t(), User.t() | nil, map()) :: {:ok, Token.t()} | {:error, Ecto.Changeset.t()} + def create(%App{} = app, user, attrs \\ %{}) do + with :ok <- ensure_active_user(user), + :ok <- validate_requested_scopes(attrs[:scopes] || app.scopes, app.scopes) do + user_id = if user, do: user.id, else: nil + + %__MODULE__{user_id: user_id, app_id: app.id} + |> cast(%{scopes: attrs[:scopes] || app.scopes}, [:scopes]) + |> validate_required([:scopes, :app_id]) + |> put_valid_until(attrs) + |> put_oidc_attrs(attrs) + |> put_identity_attrs(attrs) + |> put_token() + |> put_refresh_token(attrs) + |> Repo.insert() + |> case do + {:ok, token} -> {:ok, Repo.preload(token, [:user, :app])} + error -> error + end + end + end + + @doc """ + Refreshes an existing token by creating a new one. + """ + @spec refresh(App.t(), String.t()) :: {:ok, t()} | {:error, any()} + def refresh(%App{} = app, refresh_token) do + Repo.transaction(fn -> + query = + from(t in __MODULE__, + where: t.app_id == ^app.id and t.refresh_token == ^hash_secret(refresh_token), + where: t.valid_until > ^DateTime.utc_now(), + lock: "FOR UPDATE", + preload: [:user, :app] + ) + + with %__MODULE__{} = old_token <- Repo.one(query), + :ok <- ensure_active_user(old_token.user), + false <- token_older_than_auth_boundary?(old_token.user, old_token), + {1, _} <- Repo.delete_all(from(t in __MODULE__, where: t.id == ^old_token.id)), + {:ok, new_token} <- + create(app, old_token.user, %{ + scopes: old_token.scopes, + oidc_nonce: old_token.oidc_nonce, + oidc_auth_time: old_token.oidc_auth_time, + identity_subject: old_token.identity_subject, + identity_domain: old_token.identity_domain, + identity_did: old_token.identity_did + }) do + new_token + else + nil -> Repo.rollback(:not_found) + {:error, reason} -> Repo.rollback(reason) + {0, _} -> Repo.rollback(:not_found) + end + end) + |> case do + {:ok, token} -> {:ok, token} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Deletes all tokens for a user. + """ + @spec delete_user_tokens(User.t()) :: {integer(), any()} + def delete_user_tokens(%User{id: user_id}) do + from(t in __MODULE__, where: t.user_id == ^user_id) + |> Repo.delete_all() + end + + @doc """ + Deletes a specific token for a user. + """ + @spec delete_user_token(User.t(), integer()) :: {integer(), any()} + def delete_user_token(%User{id: user_id}, token_id) do + from(t in __MODULE__, where: t.user_id == ^user_id and t.id == ^token_id) + |> Repo.delete_all() + end + + @doc """ + Gets all tokens for a user. + """ + @spec get_user_tokens(User.t()) :: [t()] + def get_user_tokens(%User{id: user_id}) do + from(t in __MODULE__, + where: t.user_id == ^user_id, + preload: [:app], + order_by: [desc: t.inserted_at] + ) + |> Repo.all() + end + + @doc """ + Gets all tokens granted by a user to a specific app. + """ + @spec get_user_app_tokens(User.t(), pos_integer()) :: [t()] + def get_user_app_tokens(%User{id: user_id}, app_id) do + from(t in __MODULE__, + where: t.user_id == ^user_id and t.app_id == ^app_id, + preload: [:app], + order_by: [desc: t.inserted_at] + ) + |> Repo.all() + end + + @doc """ + Deletes all tokens for a user granted to a specific app. + """ + @spec delete_user_app_tokens(User.t(), pos_integer()) :: {integer(), any()} + def delete_user_app_tokens(%User{id: user_id}, app_id) do + from(t in __MODULE__, where: t.user_id == ^user_id and t.app_id == ^app_id) + |> Repo.delete_all() + end + + @doc """ + Revokes (deletes) a token by its access token string. + """ + @spec revoke(String.t()) :: :ok | {:error, :not_found} + def revoke(token) do + case from(t in __MODULE__, + where: t.token == ^hash_secret(token) or t.refresh_token == ^hash_secret(token) + ) + |> Repo.delete_all() do + {0, _} -> {:error, :not_found} + {_, _} -> :ok + end + end + + @spec revoke(App.t(), String.t()) :: :ok | {:error, :not_found} + def revoke(%App{id: app_id}, token) do + case from(t in __MODULE__, + where: + t.app_id == ^app_id and + (t.token == ^hash_secret(token) or t.refresh_token == ^hash_secret(token)) + ) + |> Repo.delete_all() do + {0, _} -> {:error, :not_found} + {_, _} -> :ok + end + end + + @doc """ + Checks if a token is expired. + """ + @spec expired?(t()) :: boolean() + def expired?(%__MODULE__{valid_until: valid_until}) do + DateTime.compare(DateTime.utc_now(), valid_until) == :gt + end + + def expired?(_), do: true + + defp ensure_active_user(nil), do: :ok + + defp ensure_active_user(%User{} = user) do + Authentication.ensure_user_active(user) + end + + defp token_older_than_auth_boundary?(%User{auth_valid_after: %DateTime{} = valid_after}, token) do + DateTime.compare(token.inserted_at, valid_after) == :lt + end + + defp token_older_than_auth_boundary?(_, _), do: false + + # Private functions + + defp put_valid_until(changeset, attrs) do + valid_until = + Map.get_lazy(attrs, :valid_until, fn -> + DateTime.utc_now() + |> DateTime.truncate(:second) + |> DateTime.add(min(lifespan(), refresh_token_lifetime()), :second) + end) + + put_change(changeset, :valid_until, valid_until) + end + + defp put_oidc_attrs(changeset, attrs) do + changeset + |> put_change(:oidc_nonce, Map.get(attrs, :oidc_nonce)) + |> put_change(:oidc_auth_time, truncate_datetime(Map.get(attrs, :oidc_auth_time))) + end + + defp put_identity_attrs(changeset, attrs) do + changeset + |> put_change(:identity_subject, Map.get(attrs, :identity_subject)) + |> put_change(:identity_domain, Map.get(attrs, :identity_domain)) + |> put_change(:identity_did, Map.get(attrs, :identity_did)) + end + + defp put_token(changeset) do + token = generate_token() + + changeset + |> put_change(:token, hash_secret(token)) + |> put_change(:plain_token, token) + |> validate_required([:token]) + |> unique_constraint(:token) + end + + defp put_refresh_token(changeset, attrs) do + refresh_token = Map.get(attrs, :refresh_token, generate_token()) + + changeset + |> put_change(:refresh_token, hash_secret(refresh_token)) + |> put_change(:plain_refresh_token, refresh_token) + |> validate_required([:refresh_token]) + |> unique_constraint(:refresh_token) + end + + defp generate_token do + :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + end + + def access_token_value(%__MODULE__{plain_token: token}) when is_binary(token), do: token + def access_token_value(_), do: nil + + def refresh_token_value(%__MODULE__{plain_refresh_token: token}) when is_binary(token), + do: token + + def refresh_token_value(_), do: nil + + defp hash_secret(secret) when is_binary(secret) do + :crypto.hash(:sha256, secret) |> Base.encode16(case: :lower) + end + + defp truncate_datetime(%DateTime{} = datetime), do: DateTime.truncate(datetime, :second) + defp truncate_datetime(value), do: value + + defp validate_requested_scopes(requested_scopes, allowed_scopes) do + requested = MapSet.new(List.wrap(requested_scopes)) + allowed = MapSet.new(List.wrap(allowed_scopes)) + + if MapSet.subset?(requested, allowed) do + :ok + else + {:error, :invalid_scope} + end + end + + defp refresh_token_lifetime do + Application.get_env( + :elektrine, + :oauth_refresh_token_lifetime, + @default_refresh_token_lifetime + ) + end +end diff --git a/apps/elektrine/lib/elektrine/oidc.ex b/apps/elektrine/lib/elektrine/oidc.ex new file mode 100644 index 0000000..04ed616 --- /dev/null +++ b/apps/elektrine/lib/elektrine/oidc.ex @@ -0,0 +1,261 @@ +defmodule Elektrine.OIDC do + @moduledoc """ + OpenID Connect helpers built on top of the existing OAuth tables. + """ + + alias Elektrine.Accounts.User + alias Elektrine.ActivityPub.SigningKey, as: ActivityPubSigningKey + alias Elektrine.Domains + alias Elektrine.EmailAddresses + alias Elektrine.OAuth.Scopes + alias Elektrine.OAuth.Token + alias Elektrine.OIDC.SigningKey + alias Elektrine.Uploads + + @openid_scopes ["openid", "profile", "email"] + + @spec openid_scopes() :: [String.t()] + def openid_scopes, do: @openid_scopes + + @spec openid_request?([String.t()]) :: boolean() + def openid_request?(scopes) when is_list(scopes), do: "openid" in scopes + + @spec issue_id_token( + Token.t(), + User.t(), + String.t(), + String.t(), + String.t() | nil, + DateTime.t() | nil + ) :: String.t() + def issue_id_token( + %Token{} = token, + %User{} = user, + issuer, + client_id, + nonce \\ nil, + auth_time \\ nil, + opts \\ [] + ) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + claims = %{ + "iss" => issuer, + "sub" => subject_for_user(user, opts), + "aud" => client_id, + "exp" => DateTime.to_unix(token.valid_until), + "iat" => DateTime.to_unix(now), + "auth_time" => DateTime.to_unix(auth_time || token.oidc_auth_time || now) + } + + claims = + if is_binary(nonce) and nonce != "" do + Map.put(claims, "nonce", nonce) + else + claims + end + + claims = maybe_add_domain_identity_claims(claims, opts) + + jwt_rs256(claims) + end + + @spec userinfo_claims(User.t(), [String.t()], String.t()) :: map() + def userinfo_claims(%User{} = user, scopes, issuer, opts \\ []) do + %{"sub" => subject_for_user(user, opts)} + |> maybe_add_domain_identity_claims(opts) + |> maybe_add_profile_claims(user, scopes, issuer) + |> maybe_add_email_claims(user, scopes) + end + + @spec discovery_document(String.t()) :: map() + def discovery_document(issuer) do + %{ + issuer: issuer, + authorization_endpoint: issuer <> "/oauth/authorize", + token_endpoint: issuer <> "/oauth/token", + userinfo_endpoint: issuer <> "/oauth/userinfo", + jwks_uri: issuer <> "/oauth/jwks", + response_types_supported: ["code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + scopes_supported: Enum.uniq(openid_scopes() ++ Scopes.valid_scopes()), + claims_supported: [ + "sub", + "preferred_username", + "nickname", + "name", + "profile", + "picture", + "domain", + "did", + "updated_at", + "zoneinfo", + "locale", + "email", + "email_verified" + ], + token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"], + grant_types_supported: ["authorization_code", "refresh_token"] + } + end + + @spec jwks() :: map() + def jwks do + %{ + keys: [jwk_for_signing_key(current_signing_key())] + } + end + + @doc """ + Signs arbitrary JSON claims with the active OIDC issuer key. + + Non-login issuer artifacts, such as Atomine proof bundles, use this so + verifiers can trust one issuer JWKS instead of learning another key endpoint. + """ + def sign_claims(claims, typ \\ "JWT") when is_map(claims) and is_binary(typ) do + jwt_rs256(claims, typ) + end + + defp maybe_add_profile_claims(claims, user, scopes, issuer) do + if "profile" in scopes do + profile_claims = %{ + "preferred_username" => user.handle || user.username, + "nickname" => user.username, + "name" => user.display_name || user.username, + "profile" => profile_url(user, issuer), + "picture" => user.avatar |> Uploads.avatar_url() |> absolutize_url(), + "updated_at" => DateTime.to_unix(user.updated_at), + "zoneinfo" => user.timezone, + "locale" => user.locale + } + + Map.merge(claims, compact_map(profile_claims)) + else + claims + end + end + + defp maybe_add_email_claims(claims, user, scopes) do + if "email" in scopes do + Map.merge(claims, %{ + "email" => EmailAddresses.primary_for_user(user), + "email_verified" => user.verified == true + }) + else + claims + end + end + + defp current_signing_key do + SigningKey.current() || generate_signing_key() + end + + defp generate_signing_key do + {public_key_pem, private_key_pem} = ActivityPubSigningKey.generate_key_pair() + + SigningKey.create!(%{ + kid: "oidc-" <> Base.url_encode64(:crypto.strong_rand_bytes(12), padding: false), + alg: "RS256", + public_key_pem: public_key_pem, + private_key_pem: private_key_pem, + active: true + }) + end + + defp jwk_for_signing_key(signing_key) do + {:RSAPublicKey, modulus, exponent} = decode_public_key!(signing_key.public_key_pem) + + %{ + kty: "RSA", + use: "sig", + alg: signing_key.alg, + kid: signing_key.kid, + n: encode_integer_component(modulus), + e: encode_integer_component(exponent) + } + end + + defp subject_for_user(user, opts) do + Keyword.get(opts, :subject) || subject_for_user(user) + end + + defp subject_for_user(%User{unique_id: unique_id}) + when is_binary(unique_id) and unique_id != "" do + unique_id + end + + defp subject_for_user(%User{id: id}), do: to_string(id) + + defp maybe_add_domain_identity_claims(claims, opts) do + claims + |> maybe_put_claim("domain", Keyword.get(opts, :identity_domain)) + |> maybe_put_claim("did", Keyword.get(opts, :identity_did)) + end + + defp maybe_put_claim(claims, _key, nil), do: claims + defp maybe_put_claim(claims, _key, ""), do: claims + defp maybe_put_claim(claims, key, value), do: Map.put(claims, key, value) + + defp jwt_rs256(claims, typ \\ "JWT") do + signing_key = current_signing_key() + header = %{"alg" => signing_key.alg, "typ" => typ, "kid" => signing_key.kid} + encoded_header = base64url_json(header) + encoded_claims = base64url_json(claims) + signing_input = encoded_header <> "." <> encoded_claims + + signature = + signing_key.private_key_pem + |> decode_private_key!() + |> then(&:public_key.sign(signing_input, :sha256, &1)) + |> Base.url_encode64(padding: false) + + signing_input <> "." <> signature + end + + defp decode_public_key!(pem) do + [entry] = :public_key.pem_decode(pem) + :public_key.pem_entry_decode(entry) + end + + defp decode_private_key!(pem) do + [entry] = :public_key.pem_decode(pem) + :public_key.pem_entry_decode(entry) + end + + defp encode_integer_component(integer) when is_integer(integer) do + integer + |> :binary.encode_unsigned() + |> Base.url_encode64(padding: false) + end + + defp base64url_json(data) do + data + |> Jason.encode!() + |> Base.url_encode64(padding: false) + end + + defp compact_map(map) do + map + |> Enum.reject(fn {_key, value} -> is_nil(value) or value == "" end) + |> Map.new() + end + + defp absolutize_url(nil), do: nil + defp absolutize_url(""), do: nil + defp absolutize_url("http://" <> _ = url), do: url + defp absolutize_url("https://" <> _ = url), do: url + + defp absolutize_url("/" <> path), + do: Domains.public_base_url() <> "/" <> String.trim_leading(path, "/") + + defp absolutize_url(path), + do: Domains.public_base_url() <> "/" <> String.trim_leading(path, "/") + + defp profile_url(user, issuer) do + case Domains.profile_url_for_user(user) do + nil -> issuer <> "/" <> (user.handle || user.username) + url -> url + end + end +end diff --git a/apps/elektrine/lib/elektrine/oidc/signing_key.ex b/apps/elektrine/lib/elektrine/oidc/signing_key.ex new file mode 100644 index 0000000..1ec709d --- /dev/null +++ b/apps/elektrine/lib/elektrine/oidc/signing_key.ex @@ -0,0 +1,37 @@ +defmodule Elektrine.OIDC.SigningKey do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + + alias Elektrine.Repo + + schema "oidc_signing_keys" do + field(:kid, :string) + field(:alg, :string, default: "RS256") + field(:public_key_pem, :string) + field(:private_key_pem, :string) + field(:active, :boolean, default: true) + + timestamps(type: :utc_datetime) + end + + def changeset(signing_key, attrs) do + signing_key + |> cast(attrs, [:kid, :alg, :public_key_pem, :private_key_pem, :active]) + |> validate_required([:kid, :alg, :public_key_pem, :private_key_pem, :active]) + |> unique_constraint(:kid) + end + + def current do + from(k in __MODULE__, where: k.active == true, order_by: [desc: k.inserted_at], limit: 1) + |> Repo.one() + end + + def create!(attrs) do + %__MODULE__{} + |> changeset(attrs) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/lib/elektrine/own_root.ex b/apps/elektrine/lib/elektrine/own_root.ex new file mode 100644 index 0000000..3baa5e4 --- /dev/null +++ b/apps/elektrine/lib/elektrine/own_root.ex @@ -0,0 +1,248 @@ +defmodule Elektrine.OwnRoot do + @moduledoc """ + Public, domain-rooted identity document for portable user accounts. + + An OwnRoot document says "this domain is the user's durable identity; + this Elektrine deployment is only the current provider for auth, profile, + federation, and recovery surfaces." + """ + + alias Elektrine.Accounts.User + alias Elektrine.ActivityPub + alias Elektrine.Domains + + @version 1 + @did_context "https://www.w3.org/ns/did/v1" + + @doc """ + Builds the public OwnRoot document for a verified user domain. + """ + def document(user, domain, opts \\ []) + + def document(%User{} = user, domain, opts) when is_binary(domain) do + domain = normalize_domain(domain) + account_base_url = "https://#{domain}" + provider_base_url = opts[:provider_base_url] || Domains.public_base_url() + handle = user_handle(user) + + %{ + "version" => @version, + "id" => account_base_url <> "/", + "subject" => "domain:#{domain}", + "domain" => domain, + "portable" => true, + "hosted_by" => provider_base_url, + "user" => user_document(user, handle), + "profile" => %{ + "url" => account_base_url <> "/", + "avatar_url" => public_avatar_url(user) + }, + "auth" => auth_document(provider_base_url, domain), + "atomine" => atomine_document(provider_base_url, domain, account_base_url), + "federation" => federation_document(user, domain, account_base_url), + "email" => email_document(user, domain), + "per_site_identities" => per_site_identity_document(domain, opts[:per_site_identities]), + "recovery" => recovery_document(provider_base_url) + } + |> prune_nil_values() + end + + def document(_, _, _), do: nil + + @doc """ + Builds a did:web document for a verified user domain. + """ + def did_document(user, domain, opts \\ []) + + def did_document(%User{} = user, domain, opts) when is_binary(domain) do + domain = normalize_domain(domain) + account_base_url = "https://#{domain}" + provider_base_url = opts[:provider_base_url] || Domains.public_base_url() + did = did_for_domain(domain) + activitypub_actor = ActivityPub.actor_uri(user, account_base_url) + + %{ + "@context" => [@did_context], + "id" => did, + "alsoKnownAs" => + [ + account_base_url <> "/", + "acct:#{ActivityPub.actor_identifier(user)}@#{domain}", + activitypub_actor + ] + |> Enum.reject(&is_nil/1), + "service" => [ + %{ + "id" => did <> "#own-root", + "type" => "OwnRoot", + "serviceEndpoint" => account_base_url <> "/.well-known/own-root" + }, + %{ + "id" => did <> "#openid-connect", + "type" => "OpenIDConnectIssuer", + "serviceEndpoint" => provider_base_url + }, + %{ + "id" => did <> "#activitypub", + "type" => "ActivityPubActor", + "serviceEndpoint" => activitypub_actor + }, + %{ + "id" => did <> "#arblarg", + "type" => "Arblarg", + "serviceEndpoint" => account_base_url <> "/.well-known/_arblarg" + } + ] + } + end + + def did_document(_, _, _), do: nil + + @doc """ + Returns the did:web identifier for a domain. + """ + def did_for_domain(domain) when is_binary(domain) do + "did:web:" <> (domain |> normalize_domain() |> String.replace(":", "%3A")) + end + + def did_for_domain(_), do: nil + + @doc """ + The stable subject relying parties should store for domain sign-in. + """ + def subject(domain) when is_binary(domain), do: "domain:#{normalize_domain(domain)}" + def subject(_), do: nil + + defp user_document(user, handle) do + %{ + "handle" => handle, + "username" => user.username, + "display_name" => blank_to_nil(user.display_name || user.handle || user.username) + } + end + + defp auth_document(provider_base_url, domain) do + %{ + "subject" => subject(domain), + "identity_domain" => domain, + "oidc_issuer" => provider_base_url, + "oidc_discovery" => provider_base_url <> "/.well-known/openid-configuration", + "authorization_endpoint" => authorization_endpoint(provider_base_url, domain), + "authorization_endpoint_base" => provider_base_url <> "/oauth/authorize", + "token_endpoint" => provider_base_url <> "/oauth/token", + "userinfo_endpoint" => provider_base_url <> "/oauth/userinfo", + "jwks_uri" => provider_base_url <> "/oauth/jwks" + } + end + + defp authorization_endpoint(provider_base_url, domain) do + provider_base_url <> "/oauth/authorize?" <> URI.encode_query(%{"identity_domain" => domain}) + end + + defp atomine_document(provider_base_url, domain, account_base_url) do + %{ + "issuer" => provider_base_url, + "proof_bundle" => account_base_url <> "/.well-known/atomine", + "subject" => subject(domain), + "jwks_uri" => provider_base_url <> "/oauth/jwks" + } + end + + defp federation_document(user, domain, account_base_url) do + actor = ActivityPub.actor_uri(user, account_base_url) + + %{ + "activitypub_actor" => actor, + "activitypub_webfinger" => "acct:#{ActivityPub.actor_identifier(user)}@#{domain}", + "webfinger" => account_base_url <> "/.well-known/webfinger", + "arblarg" => account_base_url <> "/.well-known/_arblarg", + "atproto_did" => bluesky_did_for_user(user) + } + |> prune_nil_values() + end + + defp email_document(user, domain) do + %{ + "primary_address" => "#{user.username}@#{domain}", + "domain" => domain + } + end + + defp per_site_identity_document(domain, identities) do + %{ + "subdomain_template" => "{site}.#{domain}", + "subject_template" => "domain:{site}.#{domain}", + "did_template" => "did:web:{site}.#{domain}", + "email_template" => "{site}@#{domain}", + "identities" => format_per_site_identities(identities, domain) + } + end + + defp format_per_site_identities(identities, domain) when is_list(identities) do + identities + |> Enum.filter(&(&1.base_domain == domain)) + |> Enum.map(fn identity -> + %{ + "site_key" => identity.site_key, + "domain" => identity.domain, + "subject" => identity.subject, + "did" => identity.did, + "email_alias" => identity.email_alias, + "display_name" => blank_to_nil(identity.display_name), + "avatar_url" => blank_to_nil(identity.avatar), + "claims" => identity.claims || %{}, + "enabled" => identity.enabled + } + |> prune_nil_values() + end) + end + + defp format_per_site_identities(_, _), do: [] + + defp recovery_document(provider_base_url) do + %{ + "export_available" => true, + "export_url" => provider_base_url <> "/settings/developer/exports", + "portable_root" => "dns" + } + end + + defp public_avatar_url(%{avatar: avatar}) when is_binary(avatar) and avatar != "" do + avatar + end + + defp public_avatar_url(_), do: nil + + defp bluesky_did_for_user(%{bluesky_did: "did:" <> _ = did}), do: String.trim(did) + defp bluesky_did_for_user(%{bluesky_identifier: "did:" <> _ = did}), do: String.trim(did) + defp bluesky_did_for_user(_), do: nil + + defp user_handle(%User{handle: handle, username: username}) do + blank_to_nil(handle) || username + end + + defp normalize_domain(domain) do + domain + |> String.trim() + |> String.downcase() + |> String.trim_trailing(".") + end + + defp blank_to_nil(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp blank_to_nil(_), do: nil + + defp prune_nil_values(map) do + map + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new(fn + {key, value} when is_map(value) -> {key, prune_nil_values(value)} + pair -> pair + end) + end +end diff --git a/apps/elektrine/lib/elektrine/paths.ex b/apps/elektrine/lib/elektrine/paths.ex new file mode 100644 index 0000000..dc72693 --- /dev/null +++ b/apps/elektrine/lib/elektrine/paths.ex @@ -0,0 +1,372 @@ +defmodule Elektrine.Paths do + @moduledoc false + + alias Elektrine.{Accounts, Domains} + alias Elektrine.Social.Messages + + def post_path(%{federated: true, id: id}) when is_integer(id), do: remote_post_path(id) + + def post_path(%{id: id, reply_to_id: reply_to_id, conversation: %{type: "timeline"}}) + when is_integer(id) and not is_nil(reply_to_id) do + anchored_post_path(reply_to_id, id) + end + + def post_path(%{id: id, conversation: %{type: "timeline"}}) when is_integer(id), + do: post_path(id) + + def post_path(%{ + id: id, + reply_to_id: reply_to_id, + conversation: %{type: "community", name: name} + }) + when is_integer(id) and is_binary(name) and not is_nil(reply_to_id) do + discussion_message_path(name, reply_to_id, id) + end + + def post_path(%{id: id, title: title, conversation: %{type: "community", name: name}}) + when is_integer(id) and is_binary(name) do + discussion_post_path(name, id, title) + end + + def post_path(%{id: id, conversation: %{type: "community", name: name}}) + when is_integer(id) and is_binary(name) do + discussion_post_path(name, id) + end + + def post_path(%{id: id, conversation: %{type: "chat", hash: hash}}) + when is_integer(id) and is_binary(hash) and hash != "" do + chat_message_path(hash, id) + end + + def post_path(%{id: id, conversation: %{type: "chat", id: conversation_id}}) + when is_integer(id) and is_integer(conversation_id) do + chat_message_path(conversation_id, id) + end + + def post_path(%{activitypub_id: activitypub_id}) + when is_binary(activitypub_id) and activitypub_id != "" do + remote_post_path(activitypub_id) + end + + def post_path(%{id: id}) when is_integer(id), do: post_path(id) + + def post_path(ref) when is_integer(ref), do: "/post/#{ref}" + + def post_path(ref) when is_binary(ref) do + normalized_ref = + ref + |> String.trim() + |> decode_remote_post_ref() + + case Integer.parse(normalized_ref) do + {id, ""} -> post_path(id) + _ when normalized_ref == "" -> nil + _ -> remote_post_path(normalized_ref) + end + end + + def post_path(ref), do: post_path(to_string(ref)) + + def post_path_or_external(%{id: id} = post) when is_integer(id), do: post_path(post) + + def post_path_or_external(%{activitypub_url: activitypub_url}) + when is_binary(activitypub_url) and activitypub_url != "" do + post_path_or_external(activitypub_url) + end + + def post_path_or_external(%{activitypub_id: activitypub_id}) + when is_binary(activitypub_id) and activitypub_id != "" do + post_path_or_external(activitypub_id) + end + + def post_path_or_external(ref) when is_integer(ref), do: post_path(ref) + + def post_path_or_external("/" <> _ = path), do: path + + def post_path_or_external(ref) when is_binary(ref) do + normalized_ref = + ref + |> String.trim() + |> decode_remote_post_ref() + + case Integer.parse(normalized_ref) do + {id, ""} -> + post_path(id) + + _ when normalized_ref == "" -> + nil + + _ -> + case Messages.get_message_by_activitypub_ref(normalized_ref) do + %{id: id} when is_integer(id) -> + remote_post_path(id) + + _ -> + if external_url?(normalized_ref), + do: normalized_ref, + else: remote_post_path(normalized_ref) + end + end + end + + def post_path_or_external(ref), do: post_path_or_external(to_string(ref)) + + def remote_post_path(ref) when is_integer(ref), do: "/remote/post/#{ref}" + + def remote_post_path(ref) when is_binary(ref) do + normalized_ref = + ref + |> String.trim() + |> decode_remote_post_ref() + + case Integer.parse(normalized_ref) do + {id, ""} -> remote_post_path(id) + _ when normalized_ref == "" -> nil + _ -> canonical_remote_post_path(normalized_ref) + end + end + + def remote_post_path(ref), do: remote_post_path(to_string(ref)) + + def post_anchor(message_id) when is_integer(message_id), do: "#message-#{message_id}" + + def post_anchor(message_id) when is_binary(message_id) do + case Integer.parse(message_id) do + {id, ""} -> post_anchor(id) + _ -> "" + end + end + + def post_anchor(_), do: "" + + def anchored_post_path(post_ref, anchor_message_id) do + case post_path(post_ref) do + nil -> nil + path -> path <> post_anchor(anchor_message_id) + end + end + + def chat_path(%{hash: hash}) when is_binary(hash) and hash != "", do: chat_path(hash) + def chat_path(%{id: id}) when is_integer(id), do: chat_path(id) + def chat_path(ref) when is_integer(ref), do: "/chat/#{ref}" + def chat_path(ref) when is_binary(ref), do: "/chat/#{URI.encode_www_form(String.trim(ref))}" + def chat_path(ref), do: chat_path(to_string(ref)) + + def chat_message_path(conversation_ref, message_id) do + case chat_path(conversation_ref) do + nil -> nil + path -> path <> post_anchor(message_id) + end + end + + def chat_root_message_path(message_id), do: "/chat" <> post_anchor(message_id) + + def discussion_path(community_name) when is_binary(community_name) do + "/discussions/#{URI.encode_www_form(String.trim(community_name))}" + end + + def discussion_post_path(community_name, post_id) when is_binary(community_name) do + "/communities/#{URI.encode_www_form(String.trim(community_name))}/post/#{post_id}" + end + + def discussion_post_path(community_name, post_id, title) when is_binary(community_name) do + slug = Elektrine.Utils.Slug.discussion_url_slug(post_id, title) + "/communities/#{URI.encode_www_form(String.trim(community_name))}/post/#{slug}" + end + + def discussion_message_path(community_name, post_id, message_id) + when is_binary(community_name) do + discussion_post_path(community_name, post_id) <> post_anchor(message_id) + end + + def email_view_path(%{hash: hash}) when is_binary(hash) and hash != "", + do: email_view_path(hash) + + def email_view_path(%{id: id}) when is_integer(id), do: email_view_path(id) + def email_view_path(ref) when is_integer(ref), do: "/email/view/#{ref}" + + def email_view_path(ref) when is_binary(ref), + do: "/email/view/#{URI.encode_www_form(String.trim(ref))}" + + def email_view_path(ref), do: email_view_path(to_string(ref)) + + def notifications_path, do: "/notifications" + def paige_path, do: "/paige" + def search_path, do: "/search" + def login_path, do: "/login" + def timeline_path, do: "/timeline" + def timeline_path(params) when is_list(params), do: with_query(timeline_path(), params) + def chat_root_path, do: "/chat" + def chat_root_path(params) when is_list(params), do: with_query(chat_root_path(), params) + def chat_join_path(ref), do: chat_root_path() <> "/join/" <> URI.encode_www_form(to_string(ref)) + def email_index_path, do: "/email" + def email_index_path(params) when is_list(params), do: with_query(email_index_path(), params) + def email_compose_path(params \\ []), do: with_query("/email/compose", params) + def friends_path, do: "/friends" + def friends_path(params) when is_list(params), do: with_query(friends_path(), params) + def lists_path, do: "/lists" + + def lists_path(fragment) when is_binary(fragment), + do: lists_path() <> "#" <> String.trim_leading(fragment, "#") + + def calendar_path, do: "/calendar" + def calendar_path(params) when is_list(params), do: with_query(calendar_path(), params) + def vpn_path, do: "/vpn" + def vpn_policy_path, do: "/vpn/policy" + + def community_path(name) when is_binary(name), + do: "/communities/#{URI.encode_www_form(String.trim(name))}" + + def remote_community_path(%{username: username, domain: domain}) + when is_binary(username) and is_binary(domain) do + remote_community_path(username, domain) + end + + def remote_community_path(username, domain) when is_binary(username) and is_binary(domain) do + "/remote/!#{URI.encode_www_form(String.trim(username))}@#{URI.encode_www_form(String.trim(domain))}" + end + + def admin_path, do: "/pripyat" + def admin_path(:users), do: "/pripyat/users" + def admin_path(:reports), do: "/pripyat/reports" + def admin_path(:content_moderation), do: "/pripyat/content-moderation" + def admin_path(:chat_messages), do: "/pripyat/arblarg/messages" + def admin_path(:communities), do: "/pripyat/communities" + def admin_path(:vpn), do: "/pripyat/vpn" + + def profile_path(handle) when is_binary(handle) do + case parse_handle(handle) do + {:ok, username, domain} -> profile_path(username, domain) + :error -> nil + end + end + + def profile_path(%{username: username, domain: domain} = actor) + when is_binary(username) and is_binary(domain) do + profile_path(prefixed_username(actor), domain) + end + + def profile_path(username, domain) when is_binary(username) and is_binary(domain) do + local_profile_path(username, domain) || remote_profile_path(username, domain) + end + + def profile_path(_, _), do: nil + + def local_profile_path(handle) when is_binary(handle) do + case parse_handle(handle) do + {:ok, username, domain} -> local_profile_path(username, domain) + :error -> nil + end + end + + def local_profile_path(%{username: username, domain: domain} = actor) + when is_binary(username) and is_binary(domain) do + local_profile_path(prefixed_username(actor), domain) + end + + def local_profile_path(username, domain) when is_binary(username) and is_binary(domain) do + clean_username = normalize_username(username) + clean_domain = normalize_domain(domain) + + cond do + clean_username == "" or clean_domain == "" -> + nil + + not Domains.local_profile_domain?(clean_domain) -> + nil + + String.starts_with?(clean_username, "!") -> + "/communities/#{URI.encode_www_form(String.trim_leading(clean_username, "!"))}" + + true -> + local_user_profile_path(clean_username) + end + end + + def local_profile_path(_, _), do: nil + + def remote_profile_path(username, domain) when is_binary(username) and is_binary(domain) do + clean_username = normalize_username(username) + clean_domain = normalize_domain(domain) + + if clean_username == "" or clean_domain == "" do + nil + else + "/remote/#{clean_username}@#{clean_domain}" + end + end + + def remote_profile_path(_, _), do: nil + + defp local_user_profile_path(username) do + case Accounts.get_user_by_username_or_handle(username) do + %{handle: handle} when is_binary(handle) and handle != "" -> + "/#{URI.encode_www_form(handle)}" + + %{username: canonical_username} + when is_binary(canonical_username) and canonical_username != "" -> + "/#{URI.encode_www_form(canonical_username)}" + + _ -> + "/#{URI.encode_www_form(username)}" + end + end + + defp parse_handle(handle) do + cleaned = handle |> String.trim() |> String.trim_leading("@") + + case String.split(cleaned, "@", parts: 2) do + [username, domain] when username != "" and domain != "" -> + {:ok, normalize_username(username), normalize_domain(domain)} + + _ -> + :error + end + end + + defp prefixed_username(%{actor_type: "Group", username: username}) when is_binary(username) do + if String.starts_with?(username, "!"), do: username, else: "!" <> username + end + + defp prefixed_username(%{username: username}), do: username + + defp normalize_username(username) do + username + |> String.trim() + |> String.trim_leading("@") + end + + defp normalize_domain(domain) do + domain + |> String.trim() + |> String.downcase() + end + + defp decode_remote_post_ref(ref) when is_binary(ref) do + URI.decode_www_form(ref) + rescue + ArgumentError -> ref + end + + defp canonical_remote_post_path(ref) when is_binary(ref) do + case Messages.get_message_by_activitypub_ref(ref) do + %{id: id} when is_integer(id) -> "/remote/post/#{id}" + _ -> "/remote/post/#{URI.encode_www_form(ref)}" + end + end + + defp external_url?(url) when is_binary(url) do + case URI.parse(url) do + %URI{scheme: scheme, host: host} when scheme in ["http", "https"] and is_binary(host) -> + true + + _ -> + false + end + end + + defp with_query(path, params) when is_binary(path) and is_list(params) do + query = URI.encode_query(params) + if query == "", do: path, else: path <> "?" <> query + end +end diff --git a/apps/elektrine/lib/elektrine/platform/e_nav.ex b/apps/elektrine/lib/elektrine/platform/e_nav.ex new file mode 100644 index 0000000..eb6e143 --- /dev/null +++ b/apps/elektrine/lib/elektrine/platform/e_nav.ex @@ -0,0 +1,333 @@ +defmodule Elektrine.Platform.ENav do + @moduledoc false + + alias Elektrine.Accounts.Storage + alias Elektrine.{Friends, Messaging, Notifications, Profiles} + alias Elektrine.Platform.Modules + alias Elektrine.Profiles.CustomDomains + + def primary_items do + [ + %{ + id: "paige", + label: "Paige", + href: "/paige", + platform_module: nil, + access_module: nil, + icon: "hero-magnifying-glass", + active_icon: "hero-magnifying-glass-solid" + }, + %{ + id: "chat", + label: "Chat", + href: "/chat", + platform_module: :chat, + access_module: :chat, + icon: "hero-chat-bubble-left-right", + active_icon: "hero-chat-bubble-left-right-solid" + }, + %{ + id: "timeline", + label: "Timeline", + href: "/timeline", + platform_module: :social, + access_module: :timeline, + icon: "hero-rectangle-stack", + active_icon: "hero-rectangle-stack-solid" + }, + %{ + id: "discussions", + label: "Communities", + href: "/communities", + platform_module: :social, + access_module: :communities, + icon: "hero-chat-bubble-bottom-center-text", + active_icon: "hero-chat-bubble-bottom-center-text-solid" + }, + %{ + id: "gallery", + label: "Gallery", + href: "/gallery", + platform_module: :social, + access_module: :gallery, + icon: "hero-photo", + active_icon: "hero-photo-solid" + }, + %{ + id: "videos", + label: "Videos", + href: "/videos", + platform_module: :social, + access_module: :gallery, + icon: "hero-video-camera", + active_icon: "hero-video-camera-solid" + }, + %{ + id: "email", + label: "Email", + href: "/email", + platform_module: :email, + access_module: :email, + icon: "hero-envelope", + active_icon: "hero-envelope-solid" + }, + %{ + id: "dns", + label: "DNS", + href: "/dns", + platform_module: :dns, + access_module: :dns, + icon: "hero-globe-alt", + active_icon: "hero-globe-alt-solid" + }, + %{ + id: "vpn", + label: "VPN", + href: "/vpn", + platform_module: :vpn, + access_module: :vpn, + icon: "hero-shield-check", + active_icon: "hero-shield-check-solid" + }, + %{ + id: "uptime", + label: "Uptime", + href: "/uptime", + platform_module: :uptime, + access_module: :uptime, + icon: "hero-chart-bar", + active_icon: "hero-chart-bar-solid" + }, + %{ + id: "kairo", + label: "Kairo", + href: "/kairo", + platform_module: :kairo, + access_module: :kairo, + icon: "hero-circle-stack", + active_icon: "hero-circle-stack-solid" + } + ] + end + + def secondary_items do + [ + %{ + id: "account", + label: "Account", + href: "/account", + icon: "hero-cog-6-tooth", + active_icon: "hero-cog-6-tooth-solid" + }, + %{ + id: "profile", + label: "Profile", + href: "/account/profile/edit", + icon: "hero-user-circle", + active_icon: "hero-user-circle-solid" + }, + %{ + id: "friends", + label: "Friends", + href: "/friends", + platform_module: :chat, + access_module: :friends, + icon: "hero-user-group", + active_icon: "hero-user-group-solid" + }, + %{ + id: "lists", + label: "Lists", + href: "/lists", + platform_module: :social, + access_module: :lists, + icon: "hero-queue-list", + active_icon: "hero-queue-list-solid" + }, + %{ + id: "nerve", + label: "Nerve", + href: "/account/nerve", + platform_module: :nerve, + access_module: :nerve, + icon: "hero-arrows-right-left", + active_icon: "hero-arrows-right-left-solid" + }, + %{ + id: "profile-analytics", + label: "Analytics", + href: "/analytics/profile", + icon: "hero-chart-bar", + active_icon: "hero-chart-bar-solid" + }, + %{ + id: "profile-domains", + label: "Domains", + href: "/domains", + icon: "hero-globe-alt", + active_icon: "hero-globe-alt-solid" + }, + %{ + id: "storage", + label: "Storage", + href: "/account/storage", + access_module: :storage, + icon: "hero-circle-stack", + active_icon: "hero-circle-stack-solid" + }, + %{ + id: "drive", + label: "Drive", + href: "/account/drive", + access_module: :drive, + icon: "hero-folder", + active_icon: "hero-folder-solid" + }, + %{ + id: "proofs", + label: "Identity", + href: "/account/proofs", + icon: "hero-identification", + active_icon: "hero-identification-solid" + } + ] + end + + def with_badge_counts(items, badge_counts) when is_map(badge_counts) do + Enum.map(items, fn item -> + count = Map.get(badge_counts, item.id, 0) + + if count > 0, + do: Map.put(item, :badge_count, count), + else: item + end) + end + + def with_badge_counts(items, _badge_counts), do: items + + def visible?(item, current_user) do + platform_visible? = + case item[:platform_module] do + nil -> true + module -> Modules.enabled?(module) + end + + access_visible? = + case item[:access_module] do + nil -> true + module -> Elektrine.System.user_can_access_module?(current_user, module) + end + + platform_visible? and access_visible? + end + + def notification_badge_counts(nil), do: %{} + + def notification_badge_counts(%{id: user_id} = current_user) when is_integer(user_id) do + %{ + "timeline" => safe_count(fn -> Notifications.get_visible_unread_count(user_id) end), + "chat" => safe_count(fn -> Messaging.get_unread_count(user_id) end), + "friends" => safe_count(fn -> friend_request_count(user_id) end), + "email" => safe_count(fn -> email_unread_count(user_id) end), + "account" => account_alert_count(current_user), + "profile-domains" => safe_count(fn -> pending_domain_count(user_id) end), + "storage" => safe_count(fn -> storage_alert_count(user_id) end), + "proofs" => safe_count(fn -> pending_proof_count(user_id) end) + } + end + + def notification_badge_counts(_current_user), do: %{} + + defp friend_request_count(user_id) do + Friends.get_pending_request_count(user_id) + + length(Profiles.get_pending_follow_requests(user_id)) + end + + defp email_unread_count(user_id) do + if module_exported?(Elektrine.Email, :get_user_mailbox, 1) and + module_exported?(Elektrine.Email, :unread_inbox_count, 1) do + case Elektrine.Email.get_user_mailbox(user_id) do + %{id: mailbox_id} when is_integer(mailbox_id) -> + Elektrine.Email.unread_inbox_count(mailbox_id) + + _ -> + 0 + end + else + 0 + end + end + + defp pending_domain_count(user_id) do + profile_domains = + user_id + |> CustomDomains.list_user_custom_domains() + |> Enum.count(&pending_status?/1) + + profile_domains + pending_email_domain_count(user_id) + end + + defp pending_email_domain_count(user_id) do + if module_exported?(Elektrine.Email, :list_user_custom_domains, 1) do + user_id + |> Elektrine.Email.list_user_custom_domains() + |> Enum.count(&pending_status?/1) + else + 0 + end + end + + defp storage_alert_count(user_id) do + case Storage.get_storage_info(user_id) do + %{over_limit: true} -> 1 + _ -> 0 + end + end + + defp pending_proof_count(user_id) do + if module_exported?(Atomine.Personhood, :list_proofs, 1) do + user_id + |> Atomine.Personhood.list_proofs() + |> Enum.count(&match?(%{status: "pending"}, &1)) + else + 0 + end + end + + defp account_alert_count(current_user) do + [ + recovery_email_needs_verification?(current_user), + Map.get(current_user, :email_sending_restricted) == true + ] + |> Enum.count(& &1) + end + + defp recovery_email_needs_verification?(%{ + recovery_email: email, + recovery_email_verified: verified + }) + when is_binary(email) do + String.trim(email) != "" and verified != true + end + + defp recovery_email_needs_verification?(_current_user), do: false + + defp pending_status?(%{status: "verified"}), do: false + defp pending_status?(%{status: status}) when is_binary(status), do: true + defp pending_status?(_domain), do: false + + defp module_exported?(module, function, arity) do + Code.ensure_loaded?(module) and function_exported?(module, function, arity) + end + + defp safe_count(fun) when is_function(fun, 0) do + fun.() + |> normalize_count() + rescue + _error -> 0 + end + + defp normalize_count(count) when is_integer(count), do: max(count, 0) + defp normalize_count(count) when is_list(count), do: length(count) + defp normalize_count(_count), do: 0 +end diff --git a/apps/elektrine/lib/elektrine/platform/e_nav_component.ex b/apps/elektrine/lib/elektrine/platform/e_nav_component.ex new file mode 100644 index 0000000..f1dfede --- /dev/null +++ b/apps/elektrine/lib/elektrine/platform/e_nav_component.ex @@ -0,0 +1,199 @@ +defmodule Elektrine.Platform.ENavComponent do + @moduledoc false + + use Phoenix.Component + + attr :active_tab, :string, required: true + attr :class, :string, default: "mb-4" + attr :items, :list, required: true + attr :secondary_items, :list, default: [] + + def render(assigns) do + secondary_active? = + Enum.any?(assigns.secondary_items, &(&1.id == assigns.active_tab)) + + secondary_badge_total = + assigns.secondary_items + |> Enum.map(&Map.get(&1, :badge_count, 0)) + |> Enum.filter(&is_integer/1) + |> Enum.sum() + + assigns = + assigns + |> assign(:secondary_active?, secondary_active?) + |> assign(:secondary_badge_total, secondary_badge_total) + + ~H""" + + """ + end + + attr :name, :string, required: true + attr :class, :string, default: nil + + def nav_icon(assigns) do + ~H""" + + """ + end + + attr :count, :any, default: 0 + + def nav_badge(assigns) do + ~H""" + + {format_badge_count(@count)} + + """ + end + + defp tab_class(active_tab, tab_id) do + [ + "e-nav-link group relative flex min-h-9 min-w-9 shrink-0 items-center justify-center gap-1.5 px-2 text-sm font-medium whitespace-nowrap transition-colors sm:px-2.5", + if(active_tab == tab_id, + do: + "text-base-content after:absolute after:inset-x-1.5 after:bottom-0 after:h-0.5 after:rounded-full after:bg-primary", + else: "text-base-content/60 hover:text-base-content" + ) + ] + end + + defp more_trigger_class(secondary_active?) do + [ + "e-nav-link group relative flex min-h-9 min-w-9 cursor-pointer items-center justify-center gap-1.5 px-2 text-sm font-medium whitespace-nowrap transition-colors sm:px-2.5", + if(secondary_active?, + do: + "text-base-content after:absolute after:inset-x-1.5 after:bottom-0 after:h-0.5 after:rounded-full after:bg-primary", + else: "text-base-content/60 hover:text-base-content" + ) + ] + end + + defp more_item_class(active_tab, tab_id) do + [ + "e-nav-link group relative flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors", + if(active_tab == tab_id, + do: "bg-primary/10 text-base-content", + else: "text-base-content/80 hover:bg-base-200/80 hover:text-base-content" + ) + ] + end + + defp label_class(active_tab, tab_id) do + if active_tab == tab_id do + "min-w-0 max-w-[7rem] truncate sm:max-w-[9rem]" + else + # Hide inactive labels until wide desktops so mid-size screens stay + # single-row icon-first without wrapping into a tall stack. + "hidden min-w-0 max-w-[9rem] truncate xl:inline" + end + end + + defp icon_class(active_tab, tab_id) do + [ + "h-5 w-5 shrink-0 transition-colors sm:h-4 sm:w-4", + if(active_tab == tab_id, + do: "text-primary", + else: "text-base-content/60 group-hover:text-base-content/85" + ) + ] + end + + defp more_icon_class(secondary_active?) do + [ + "h-5 w-5 shrink-0 transition-colors sm:h-4 sm:w-4", + if(secondary_active?, + do: "text-primary", + else: "text-base-content/60 group-hover:text-base-content/85" + ) + ] + end + + defp show_badge?(count) when is_integer(count), do: count > 0 + defp show_badge?(_count), do: false + + defp format_badge_count(count) when is_integer(count) and count > 99, do: "99+" + defp format_badge_count(count) when is_integer(count), do: Integer.to_string(count) +end diff --git a/apps/elektrine/lib/elektrine/platform/integrations.ex b/apps/elektrine/lib/elektrine/platform/integrations.ex new file mode 100644 index 0000000..819f7f5 --- /dev/null +++ b/apps/elektrine/lib/elektrine/platform/integrations.ex @@ -0,0 +1,13 @@ +defmodule Elektrine.Platform.Integrations do + @moduledoc false + + alias Elektrine.Platform.ModuleProviders + + def send_vpn_quota_notification(:suspended, user, user_config) do + ModuleProviders.send_vpn_quota_notification(:suspended, [user, user_config]) + end + + def send_vpn_quota_notification(:warning, user, user_config, threshold) do + ModuleProviders.send_vpn_quota_notification(:warning, [user, user_config, threshold]) + end +end diff --git a/apps/elektrine/lib/elektrine/platform/module_provider.ex b/apps/elektrine/lib/elektrine/platform/module_provider.ex new file mode 100644 index 0000000..c643333 --- /dev/null +++ b/apps/elektrine/lib/elektrine/platform/module_provider.ex @@ -0,0 +1,14 @@ +defmodule Elektrine.Platform.ModuleProvider do + @moduledoc false + + @callback core_children() :: [Supervisor.child_spec() | module() | tuple()] + @callback web_children() :: [Supervisor.child_spec() | module() | tuple()] + @callback mail_children() :: [Supervisor.child_spec() | module() | tuple()] + @callback optional_delegate(atom()) :: module() | nil + @callback send_vpn_quota_notification(atom(), list()) :: term() + + @optional_callbacks web_children: 0, + mail_children: 0, + optional_delegate: 1, + send_vpn_quota_notification: 2 +end diff --git a/apps/elektrine/lib/elektrine/platform/module_providers.ex b/apps/elektrine/lib/elektrine/platform/module_providers.ex new file mode 100644 index 0000000..5fbd832 --- /dev/null +++ b/apps/elektrine/lib/elektrine/platform/module_providers.ex @@ -0,0 +1,52 @@ +defmodule Elektrine.Platform.ModuleProviders do + @moduledoc false + + alias Elektrine.Platform.Modules + + def core_children do + children_for(:core_children) + end + + def web_children do + children_for(:web_children) + end + + def mail_children do + children_for(:mail_children) + end + + def optional_delegate(name) when is_atom(name) do + Enum.find_value(enabled_provider_modules(), fn provider -> + if function_exported?(provider, :optional_delegate, 1) do + provider.optional_delegate(name) + end + end) + end + + def send_vpn_quota_notification(kind, args) when is_atom(kind) and is_list(args) do + Enum.find_value(enabled_provider_modules(), :ok, fn provider -> + if function_exported?(provider, :send_vpn_quota_notification, 2) do + provider.send_vpn_quota_notification(kind, args) + end + end) + end + + defp children_for(function_name) do + Enum.flat_map(enabled_provider_modules(), fn provider -> + if function_exported?(provider, function_name, 0) do + provider + |> apply(function_name, []) + |> List.wrap() + else + [] + end + end) + end + + defp enabled_provider_modules do + Modules.enabled_specs() + |> Enum.map(&Map.get(&1, :provider)) + |> Enum.reject(&is_nil/1) + |> Enum.filter(&Code.ensure_loaded?/1) + end +end diff --git a/apps/elektrine/lib/elektrine/platform/modules.ex b/apps/elektrine/lib/elektrine/platform/modules.ex new file mode 100644 index 0000000..b3cef13 --- /dev/null +++ b/apps/elektrine/lib/elektrine/platform/modules.ex @@ -0,0 +1,213 @@ +defmodule Elektrine.Platform.Modules do + @moduledoc """ + Runtime registry for hoster-selectable product modules. + + This is the control plane for turning Elektrine into a composable platform: + features stay compiled, but hosters can decide which product modules are + exposed and which module-specific runtimes are started. + """ + + @type module_id :: + :chat | :social | :email | :nerve | :vpn | :dns | :uptime | :atomine | :kairo + + @modules [ + %{ + id: :chat, + label: "Chat", + app: :arblarg, + description: "Direct messages, group conversations, and messaging federation." + }, + %{ + id: :social, + label: "Social", + app: :elektrine_social, + provider: :"Elixir.ElektrineSocial.Platform", + description: "Timeline, communities, gallery, lists, remote profiles, and ActivityPub." + }, + %{ + id: :email, + label: "Email", + app: :elektrine_email, + provider: :"Elixir.ElektrineEmail.Platform", + description: "Mailbox UI, aliases, message APIs, Haraka, and JMAP." + }, + %{ + id: :nerve, + label: "Nerve", + app: :elektrine_nerve, + description: "Client-side encrypted secrets and browser access." + }, + %{ + id: :vpn, + label: "VPN", + app: :elektrine_vpn, + provider: :"Elixir.ElektrineVPN.Platform", + description: "WireGuard VPN management, configs, and host integrations." + }, + %{ + id: :dns, + label: "DNS", + app: :elektrine_dns, + description: "Managed authoritative DNS zones, records, and delegation." + }, + %{ + id: :uptime, + label: "Uptime", + app: :elektrine_uptime, + description: "HTTP/TCP/ping uptime monitors with check history and incidents." + }, + %{ + id: :atomine, + label: "Identity", + app: :atomine, + description: "Proofs, account trust, and credit-based capability gates." + }, + %{ + id: :kairo, + label: "Kairo", + app: :kairo, + description: "Personal data OS for ingesting, compiling, and querying durable knowledge." + } + ] + + @module_ids Enum.map(@modules, & &1.id) + @module_specs Map.new(@modules, &{&1.id, &1}) + @availability_markers %{ + social: Elektrine.Social, + email: Elektrine.Email, + nerve: Elektrine.Nerve, + vpn: Elektrine.VPN, + dns: Elektrine.DNS, + uptime: Elektrine.Uptime, + atomine: Atomine.Personhood, + kairo: Kairo + } + + @spec all() :: [module_id()] + def all, do: @module_ids + + @spec compiled() :: [module_id()] + def compiled do + :elektrine + |> Application.get_env(:compiled_platform_modules, @module_ids) + |> normalize_enabled_modules() + |> Enum.filter(&module_available?/1) + end + + @spec compiled_specs() :: [map()] + def compiled_specs do + compiled() + |> Enum.map(&spec/1) + |> Enum.reject(&is_nil/1) + end + + @spec compiled?(module_id() | atom() | String.t() | nil) :: boolean() + def compiled?(nil), do: true + + def compiled?(module_id) do + normalized = normalize_module_id(module_id) + normalized in compiled() + end + + @spec default_enabled() :: [module_id()] + def default_enabled, do: compiled() + + @spec specs() :: [map()] + def specs, do: @modules + + @spec spec(module_id()) :: map() | nil + def spec(module_id), do: Map.get(@module_specs, normalize_module_id(module_id)) + + @spec known?(atom() | String.t()) :: boolean() + def known?(module_id), do: not is_nil(spec(module_id)) + + @spec enabled() :: [module_id()] + def enabled do + :elektrine + |> Application.get_env(:platform_modules, []) + |> Keyword.get(:enabled, default_enabled()) + |> normalize_enabled_modules() + |> Enum.filter(&compiled?/1) + end + + @spec enabled_specs() :: [map()] + def enabled_specs do + enabled() + |> Enum.map(&spec/1) + |> Enum.reject(&is_nil/1) + end + + @spec enabled?(module_id() | atom() | String.t() | nil) :: boolean() + def enabled?(nil), do: true + + def enabled?(module_id) do + normalized = normalize_module_id(module_id) + normalized in enabled() + end + + @spec normalize_enabled_modules(term()) :: [module_id()] + def normalize_enabled_modules(value) + + def normalize_enabled_modules(nil), do: default_enabled() + def normalize_enabled_modules(:all), do: default_enabled() + def normalize_enabled_modules("all"), do: default_enabled() + def normalize_enabled_modules("*"), do: default_enabled() + def normalize_enabled_modules(:none), do: [] + def normalize_enabled_modules("none"), do: [] + def normalize_enabled_modules(""), do: [] + + def normalize_enabled_modules(value) when is_atom(value) do + normalize_enabled_modules([value]) + end + + def normalize_enabled_modules(value) when is_binary(value) do + value + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> normalize_enabled_modules() + end + + def normalize_enabled_modules(value) when is_list(value) do + value + |> Enum.map(&normalize_module_id/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + def normalize_enabled_modules(_value), do: default_enabled() + + defp module_available?(:chat), do: true + + defp module_available?(module_id) when is_atom(module_id) do + case Map.fetch(@availability_markers, module_id) do + {:ok, marker_module} -> Code.ensure_loaded?(marker_module) + :error -> true + end + end + + defp module_available?(_module_id), do: false + + defp normalize_module_id(value) when value in @module_ids, do: value + + defp normalize_module_id(value) when is_binary(value) do + value + |> String.trim() + |> String.downcase() + |> case do + "chat" -> :chat + "social" -> :social + "email" -> :email + "nerve" -> :nerve + "vpn" -> :vpn + "dns" -> :dns + "uptime" -> :uptime + "atomine" -> :atomine + "proofs" -> :atomine + "personhood" -> :atomine + "kairo" -> :kairo + _ -> nil + end + end + + defp normalize_module_id(_value), do: nil +end diff --git a/apps/elektrine/lib/elektrine/platform/runtime_config_validator.ex b/apps/elektrine/lib/elektrine/platform/runtime_config_validator.ex new file mode 100644 index 0000000..d47482f --- /dev/null +++ b/apps/elektrine/lib/elektrine/platform/runtime_config_validator.ex @@ -0,0 +1,288 @@ +defmodule Elektrine.Platform.RuntimeConfigValidator do + @moduledoc false + + alias Elektrine.Platform.Modules + + @haraka_outbound_keys [ + "HARAKA_HTTP_API_KEY", + "HARAKA_OUTBOUND_API_KEY", + "HARAKA_API_KEY", + "INTERNAL_API_KEY" + ] + @haraka_inbound_keys [ + "PHOENIX_API_KEY", + "HARAKA_INBOUND_API_KEY", + "HARAKA_API_KEY", + "INTERNAL_API_KEY" + ] + @derived_secret_roots ["ELEKTRINE_MASTER_SECRET"] + @known_placeholder_values [ + "change-me", + "replace-me", + "replace-with-long-random-secret", + "example-secret-access-key", + "magpie", + "", + "", + "" + ] + @s3_secret_keys [ + "S3_ACCESS_KEY_ID", + "S3_SECRET_ACCESS_KEY", + "MAGPIE_S3_ACCESS_KEY_ID", + "MAGPIE_S3_SECRET_ACCESS_KEY" + ] + @min_secret_lengths %{ + "DB_PASSWORD" => 16, + "ELEKTRINE_MASTER_SECRET" => 32, + "SECRET_KEY_BASE" => 32, + "SESSION_SIGNING_SALT" => 16, + "SESSION_ENCRYPTION_SALT" => 16, + "ENCRYPTION_MASTER_SECRET" => 32, + "INTERNAL_API_KEY" => 24, + "CADDY_EDGE_API_KEY" => 24, + "S3_SECRET_ACCESS_KEY" => 16 + } + @encryption_secret_keys [ + "ENCRYPTION_MASTER_SECRET", + "ENCRYPTION_KEY_SALT", + "ENCRYPTION_SEARCH_SALT" + ] + @unencrypted_prod_override_key "ELEKTRINE_ALLOW_UNENCRYPTED_PROD_DATA" + + def validate!(opts \\ []) do + case validate(opts) do + :ok -> + :ok + + {:error, errors} -> + raise ArgumentError, format_errors(errors) + end + end + + def validate(opts) when is_list(opts) do + env = Keyword.get(opts, :env, %{}) + environment = Keyword.get(opts, :environment, :prod) + + compiled_modules = + opts + |> Keyword.get(:compiled_modules, Modules.all()) + |> Modules.normalize_enabled_modules() + + enabled_modules = + opts + |> Keyword.get(:enabled_modules, compiled_modules) + |> Modules.normalize_enabled_modules() + |> Enum.filter(&(&1 in compiled_modules)) + + errors = + [] + |> maybe_validate_placeholder_secrets(environment, env) + |> maybe_validate_secret_lengths(environment, env) + |> maybe_validate_session_secrets(environment, env) + |> maybe_validate_encryption_secrets(environment, env) + |> maybe_validate_email(enabled_modules, env) + |> maybe_validate_vpn(enabled_modules, env) + + case errors do + [] -> :ok + _ -> {:error, Enum.reverse(errors)} + end + end + + defp maybe_validate_session_secrets(errors, :prod, env) do + errors + |> require_any( + env, + ["SESSION_SIGNING_SALT" | @derived_secret_roots], + "production requires SESSION_SIGNING_SALT or ELEKTRINE_MASTER_SECRET so LiveView and cookie sessions stay consistent across instances" + ) + |> require_any( + env, + ["SESSION_ENCRYPTION_SALT" | @derived_secret_roots], + "production requires SESSION_ENCRYPTION_SALT or ELEKTRINE_MASTER_SECRET so cookie sessions can be decrypted consistently across instances" + ) + end + + defp maybe_validate_session_secrets(errors, _environment, _env), do: errors + + defp maybe_validate_encryption_secrets(errors, :prod, env) do + cond do + override_enabled?(env, @unencrypted_prod_override_key) -> + errors + + Enum.any?(@derived_secret_roots, &present?(env_value(env, &1))) -> + errors + + Enum.all?(@encryption_secret_keys, &present?(env_value(env, &1))) -> + errors + + true -> + [ + "production requires ENCRYPTION_MASTER_SECRET, ENCRYPTION_KEY_SALT, and ENCRYPTION_SEARCH_SALT, or ELEKTRINE_MASTER_SECRET; set ELEKTRINE_ALLOW_UNENCRYPTED_PROD_DATA=true only if unencrypted production data is intentional" + | errors + ] + end + end + + defp maybe_validate_encryption_secrets(errors, _environment, _env), do: errors + + defp maybe_validate_email(errors, enabled_modules, env) do + if :email in enabled_modules do + errors + |> require_any(env, ["PRIMARY_DOMAIN"], "email module requires PRIMARY_DOMAIN") + |> maybe_validate_haraka(env) + else + errors + end + end + + defp maybe_validate_haraka(errors, env) do + errors + |> require_present( + env_value(env, "HARAKA_BASE_URL"), + "email module requires HARAKA_BASE_URL" + ) + |> require_any( + env, + @haraka_outbound_keys ++ @derived_secret_roots, + "email module requires one of #{Enum.join(@haraka_outbound_keys, ", ")} or ELEKTRINE_MASTER_SECRET" + ) + |> require_any( + env, + @haraka_inbound_keys ++ @derived_secret_roots, + "email module requires one of #{Enum.join(@haraka_inbound_keys, ", ")} or ELEKTRINE_MASTER_SECRET" + ) + end + + defp maybe_validate_vpn(errors, enabled_modules, env) do + if :vpn in enabled_modules do + fleet_key = env_value(env, "VPN_FLEET_REGISTRATION_KEY") + self_host_ip = env_value(env, "VPN_SELFHOST_PUBLIC_IP") + self_host_endpoint = env_value(env, "VPN_SELFHOST_ENDPOINT_HOST") + self_host_key = env_value(env, "VPN_SELFHOST_PUBLIC_KEY") + self_host_private_key = env_value(env, "VPN_SELFHOST_PRIVATE_KEY") + + cond do + present?(fleet_key) -> + errors + + (present?(self_host_ip) or present?(self_host_endpoint)) and + (present?(self_host_key) or present?(self_host_private_key)) -> + errors + + present?(self_host_private_key) or present?(self_host_key) -> + errors + + true -> + [ + "vpn module requires either VPN_FLEET_REGISTRATION_KEY or self-hosted WireGuard credentials via VPN_SELFHOST_PRIVATE_KEY/VPN_SELFHOST_PUBLIC_KEY; set VPN_SELFHOST_ENDPOINT_HOST or VPN_SELFHOST_PUBLIC_IP to avoid endpoint autodetection" + | errors + ] + end + else + errors + end + end + + defp maybe_validate_placeholder_secrets(errors, :prod, env) do + Enum.reduce(env, errors, fn {key, value}, acc -> + if secret_key?(key) and validate_placeholder_secret?(key, env) and known_placeholder?(value) do + ["production secret #{key} uses a known placeholder value" | acc] + else + acc + end + end) + end + + defp maybe_validate_placeholder_secrets(errors, _environment, _env), do: errors + + defp maybe_validate_secret_lengths(errors, :prod, env) do + Enum.reduce(@min_secret_lengths, errors, fn {key, min_length}, acc -> + value = env_value(env, key) + + if validate_secret_length?(key, env) and present?(value) and + String.length(value) < min_length do + ["production secret #{key} must be at least #{min_length} characters" | acc] + else + acc + end + end) + end + + defp maybe_validate_secret_lengths(errors, _environment, _env), do: errors + + defp require_any(errors, env, keys, message) do + if Enum.any?(keys, &present?(env_value(env, &1))) do + errors + else + [message | errors] + end + end + + defp secret_key?(key) when is_binary(key) do + key in ["DATABASE_URL", "DB_PASSWORD"] or + String.contains?(key, ["SECRET", "PASSWORD", "TOKEN", "API_KEY", "ACCESS_KEY"]) + end + + defp secret_key?(_), do: false + + defp validate_placeholder_secret?(key, _env) when key in @s3_secret_keys, do: false + + defp validate_placeholder_secret?(_key, _env), do: true + + defp validate_secret_length?(key, _env) when key in @s3_secret_keys, do: false + + defp validate_secret_length?(_key, _env), do: true + + defp known_placeholder?(value) when is_binary(value) do + normalized = value |> String.trim() |> String.downcase() + normalized in @known_placeholder_values + end + + defp known_placeholder?(_), do: false + + defp require_present(errors, value, _message) when is_binary(value) and byte_size(value) > 0, + do: errors + + defp require_present(errors, value, message) when is_binary(value) do + if Elektrine.Strings.present?(value) do + errors + else + [message | errors] + end + end + + defp require_present(errors, _value, message), do: [message | errors] + + defp env_value(env, key) do + case Map.get(env, key) do + value when is_binary(value) -> String.trim(value) + _ -> nil + end + end + + defp present?(value) when is_binary(value), do: Elektrine.Strings.present?(value) + defp present?(_value), do: false + + defp override_enabled?(env, key) do + env + |> env_value(key) + |> truthy?() + end + + defp truthy?(value) when is_binary(value) do + normalized = value |> String.trim() |> String.downcase() + normalized in ["1", "true", "yes"] + end + + defp truthy?(_value), do: false + + defp format_errors(errors) do + """ + invalid runtime configuration for enabled platform modules: + + #{Enum.map_join(errors, "\n", &"- #{&1}")} + """ + end +end diff --git a/apps/elektrine/lib/elektrine/privacy.ex b/apps/elektrine/lib/elektrine/privacy.ex new file mode 100644 index 0000000..8fb2449 --- /dev/null +++ b/apps/elektrine/lib/elektrine/privacy.ex @@ -0,0 +1,331 @@ +defmodule Elektrine.Privacy do + @moduledoc """ + Centralized privacy control system for Elektrine. + + Handles all privacy checks for: + - Direct messages + - Audio/video calls + - Friend requests + - Group/channel invites + - Mentions + - Profile viewing + + Privacy levels: + - "everyone" - Anyone can interact + - "followers" - Only users who follow you + - "following" - Only users you follow + - "mutual" - Only mutual followers + - "friends" - Only accepted friends + - "nobody" - Disabled + """ + + alias Elektrine.{Accounts, Friends, Profiles} + + @doc """ + Checks if a user can send a direct message to another user. + + Returns: + - {:ok, :allowed} - Can send DM + - {:error, :blocked} - User is blocked + - {:error, :privacy_restricted} - Privacy settings prevent DMs + """ + def can_send_dm?(sender_id, recipient_id) do + # Can't DM yourself + if sender_id == recipient_id do + {:error, :cannot_dm_self} + else + recipient = Accounts.get_user!(recipient_id) + + # Check if blocked + if Accounts.user_blocked?(sender_id, recipient_id) or + Accounts.user_blocked?(recipient_id, sender_id) do + {:error, :blocked} + else + check_privacy_setting(sender_id, recipient, :allow_direct_messages_from) + end + end + end + + @doc """ + Checks if a user can initiate a call to another user. + + Returns: + - {:ok, :allowed} - Can call + - {:error, :blocked} - User is blocked + - {:error, :privacy_restricted} - Privacy settings prevent calls + - {:error, :not_friends} - Calls limited to friends only + """ + def can_call?(caller_id, callee_id) do + # Can't call yourself + if caller_id == callee_id do + {:error, :cannot_call_self} + else + callee = Accounts.get_user!(callee_id) + + # Check if blocked + if Accounts.user_blocked?(caller_id, callee_id) or + Accounts.user_blocked?(callee_id, caller_id) do + {:error, :blocked} + else + check_privacy_setting(caller_id, callee, :allow_calls_from) + end + end + end + + @doc """ + Checks if a user can send a friend request to another user. + + Returns: + - {:ok, :allowed} - Can send request + - {:error, :blocked} - User is blocked + - {:error, :privacy_restricted} - Privacy settings prevent requests + - {:error, :already_friends} - Already friends + """ + def can_send_friend_request?(requester_id, recipient_id) do + # Can't friend yourself + if requester_id == recipient_id do + {:error, :cannot_friend_self} + else + recipient = Accounts.get_user!(recipient_id) + + # Check if blocked + if Accounts.user_blocked?(requester_id, recipient_id) or + Accounts.user_blocked?(recipient_id, requester_id) do + {:error, :blocked} + else + # Check if already friends + if Friends.are_friends?(requester_id, recipient_id) do + {:error, :already_friends} + else + check_privacy_setting(requester_id, recipient, :allow_friend_requests_from) + end + end + end + end + + @doc """ + Checks if a user can add another user to a group/channel. + + Returns: + - {:ok, :allowed} - Can add + - {:error, :blocked} - User is blocked + - {:error, :privacy_restricted} - Privacy settings prevent adds + """ + def can_add_to_group?(adder_id, user_to_add_id) do + # Can always add yourself + if adder_id == user_to_add_id do + {:ok, :allowed} + else + user_to_add = Accounts.get_user!(user_to_add_id) + + # Check if blocked + if Accounts.user_blocked?(adder_id, user_to_add_id) or + Accounts.user_blocked?(user_to_add_id, adder_id) do + {:error, :blocked} + else + check_privacy_setting(adder_id, user_to_add, :allow_group_adds_from) + end + end + end + + @doc """ + Checks if a user can mention another user. + + Returns: + - {:ok, :allowed} - Can mention + - {:error, :blocked} - User is blocked + - {:error, :privacy_restricted} - Privacy settings prevent mentions + """ + def can_mention?(mentioner_id, mentioned_id) do + # Can always mention yourself + if mentioner_id == mentioned_id do + {:ok, :allowed} + else + mentioned = Accounts.get_user!(mentioned_id) + + # Check if blocked + if Accounts.user_blocked?(mentioner_id, mentioned_id) or + Accounts.user_blocked?(mentioned_id, mentioner_id) do + {:error, :blocked} + else + check_privacy_setting(mentioner_id, mentioned, :allow_mentions_from) + end + end + end + + @doc """ + Checks if a user can view another user's profile. + + Returns: + - {:ok, :allowed} - Can view + - {:error, :blocked} - User is blocked + - {:error, :privacy_restricted} - Profile is private + """ + def can_view_profile?(viewer_id, profile_owner_id) do + # Can always view your own profile + if viewer_id == profile_owner_id do + {:ok, :allowed} + else + owner = Accounts.get_user!(profile_owner_id) + + # Check if blocked + if Accounts.user_blocked?(viewer_id, profile_owner_id) or + Accounts.user_blocked?(profile_owner_id, viewer_id) do + {:error, :blocked} + else + case owner.profile_visibility do + "public" -> + {:ok, :allowed} + + "followers" -> + if Profiles.following?(viewer_id, profile_owner_id) do + {:ok, :allowed} + else + {:error, :privacy_restricted} + end + + "private" -> + {:error, :privacy_restricted} + + _ -> + {:ok, :allowed} + end + end + end + end + + # Private helper function to check privacy settings + defp check_privacy_setting(actor_id, target_user, setting_field) do + privacy_level = Map.get(target_user, setting_field) + + case privacy_level do + "everyone" -> + {:ok, :allowed} + + "friends" -> + if Friends.are_friends?(actor_id, target_user.id) do + {:ok, :allowed} + else + {:error, :privacy_restricted} + end + + "followers" -> + if Profiles.following?(target_user.id, actor_id) do + {:ok, :allowed} + else + {:error, :privacy_restricted} + end + + "following" -> + if Profiles.following?(actor_id, target_user.id) do + {:ok, :allowed} + else + {:error, :privacy_restricted} + end + + "mutual" -> + if Profiles.following?(actor_id, target_user.id) and + Profiles.following?(target_user.id, actor_id) do + {:ok, :allowed} + else + {:error, :privacy_restricted} + end + + "nobody" -> + {:error, :privacy_restricted} + + _ -> + # Default to everyone if invalid setting + {:ok, :allowed} + end + end + + @doc """ + Gets privacy error message for display to users. + """ + def privacy_error_message(error_type) do + case error_type do + :blocked -> + "You have blocked this user or they have blocked you" + + :privacy_restricted -> + "This user's privacy settings prevent this action" + + :privacy_settings_changed -> + "This user has changed their privacy settings and is no longer accepting friend requests" + + :not_friends -> + "You must be friends with this user" + + :cannot_dm_self -> + "You cannot send messages to yourself" + + :insufficient_dm_credits -> + "You need 1 Identity Credit to message someone new. Earn credits from Account > Identity by running daily browser work or verifying a domain, web page, or social/profile proof" + + :cannot_call_self -> + "You cannot call yourself" + + :cannot_friend_self -> + "You cannot send a friend request to yourself" + + :already_friends -> + "You are already friends with this user" + + :unauthorized -> + "You do not have permission to do that" + + :not_authorized_for_room -> + "You do not have permission to do that in this room" + + :not_found -> + "That item could not be found" + + :rate_limited -> + "Slow down and try again in a moment" + + :not_authenticated -> + "Sign in to continue" + + error when is_binary(error) and error != "" -> + error + + %Ecto.Changeset{} = changeset -> + changeset_error_message(changeset) + + {:error, reason} -> + privacy_error_message(reason) + + {reason, detail} when is_atom(reason) -> + "#{humanize_reason(reason)}: #{humanize_reason(detail)}" + + reason when is_atom(reason) -> + humanize_reason(reason) + + reason -> + "Could not complete action: #{inspect(reason)}" + end + end + + defp changeset_error_message(%Ecto.Changeset{errors: [{field, error} | _]}) do + "#{Phoenix.Naming.humanize(field)} #{translate_changeset_error(error)}" + end + + defp changeset_error_message(%Ecto.Changeset{}), do: "Could not save changes" + + defp translate_changeset_error({message, opts}) do + Enum.reduce(opts, message, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end + + defp humanize_reason(reason) when is_atom(reason) do + reason + |> Atom.to_string() + |> String.replace("_", " ") + |> Phoenix.Naming.humanize() + end + + defp humanize_reason(reason) when is_binary(reason), do: reason + defp humanize_reason(reason), do: inspect(reason) +end diff --git a/apps/elektrine/lib/elektrine/profiles.ex b/apps/elektrine/lib/elektrine/profiles.ex new file mode 100644 index 0000000..453dcd0 --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles.ex @@ -0,0 +1,2336 @@ +defmodule Elektrine.Profiles do + @moduledoc """ + The Profiles context for managing customizable user profile pages. + """ + + require Logger + import Ecto.Query, warn: false + alias Elektrine.Repo + + alias Elektrine.Profiles.{ + Follow, + ProfileLink, + ProfileSiteVisit, + ProfileView, + ProfileWidget, + SitePageVisit, + SiteSession, + UserBadge, + UserProfile + } + + alias Elektrine.Accounts.User + + @analytics_bot_user_agent_fragments [ + "ahrefsbot", + "amazonbot", + "applebot", + "baiduspider", + "barkrowler", + "bingbot", + "bytespider", + "ccbot", + "claudebot", + "crawler", + "dataforseobot", + "dotbot", + "duckduckbot", + "facebookexternalhit", + "googlebot", + "gptbot", + "mj12bot", + "meta-externalagent", + "perplexitybot", + "petalbot", + "semrushbot", + "serankingbacklinksbot", + "sogou", + "spider", + "yandexbot" + ] + + @default_site_analytics_retention_days 30 + @default_profile_analytics_retention_days 90 + @default_analytics_retention_batch_size 5_000 + @default_analytics_retention_max_batches 100 + @public_site_rollup_days 30 + + # Custom profile domains + defdelegate list_user_custom_domains(user_id), to: Elektrine.Profiles.CustomDomains + defdelegate list_custom_domains_for_recheck(limit), to: Elektrine.Profiles.CustomDomains + defdelegate get_custom_domain(id, user_id), to: Elektrine.Profiles.CustomDomains + defdelegate get_verified_custom_domain(domain), to: Elektrine.Profiles.CustomDomains + defdelegate get_verified_custom_domain_for_host(host), to: Elektrine.Profiles.CustomDomains + defdelegate verified_domains_for_user(user_or_id), to: Elektrine.Profiles.CustomDomains + + defdelegate preferred_verified_domain_for_user(user_or_id), + to: Elektrine.Profiles.CustomDomains + + defdelegate create_custom_domain(user_or_id, attrs), to: Elektrine.Profiles.CustomDomains + defdelegate verify_custom_domain(custom_domain), to: Elektrine.Profiles.CustomDomains + defdelegate delete_custom_domain(custom_domain), to: Elektrine.Profiles.CustomDomains + defdelegate verification_host(custom_domain), to: Elektrine.Profiles.CustomDomains + defdelegate verification_value(custom_domain), to: Elektrine.Profiles.CustomDomains + defdelegate dns_records_for_custom_domain(custom_domain), to: Elektrine.Profiles.CustomDomains + + # Per-site portable identities + defdelegate list_user_per_site_identities(user_or_id), + to: Elektrine.Profiles.PerSiteIdentities + + defdelegate get_per_site_identity(id, user_id), to: Elektrine.Profiles.PerSiteIdentities + + defdelegate create_per_site_identity(user, attrs), to: Elektrine.Profiles.PerSiteIdentities + + defdelegate update_per_site_identity(identity, attrs), to: Elektrine.Profiles.PerSiteIdentities + + defdelegate delete_per_site_identity(identity), to: Elektrine.Profiles.PerSiteIdentities + + defdelegate available_per_site_base_domains(user), + to: Elektrine.Profiles.PerSiteIdentities, + as: :available_base_domains + + defp profile_links_query(:all) do + from(l in ProfileLink, order_by: [desc: l.pinned, asc: l.position, asc: l.id]) + end + + defp profile_links_query(:visible) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + from(l in ProfileLink, + where: l.is_active == true, + where: is_nil(l.active_from) or l.active_from <= ^now, + where: is_nil(l.active_until) or l.active_until > ^now, + order_by: [desc: l.pinned, asc: l.position, asc: l.id] + ) + end + + defp profile_links_query(_mode), do: profile_links_query(:visible) + + @doc """ + Gets a user's profile page. + """ + def get_user_profile(user_id, opts \\ []) do + links_query = profile_links_query(Keyword.get(opts, :links, :visible)) + widgets_query = from(w in ProfileWidget, where: w.is_active == true, order_by: w.position) + + UserProfile + |> where([p], p.user_id == ^user_id) + |> preload(links: ^links_query, widgets: ^widgets_query) + |> Repo.one() + end + + @doc """ + Gets a public user profile by the user's handle. + Used for subdomain profile lookups (e.g., handle.example.com). + """ + def get_profile_by_handle(handle) do + links_query = profile_links_query(:visible) + widgets_query = from(w in ProfileWidget, where: w.is_active == true, order_by: w.position) + + UserProfile + |> join(:inner, [p], u in assoc(p, :user)) + |> where([p, u], u.handle == ^handle) + |> where([p], p.is_public == true) + |> preload([:user, links: ^links_query, widgets: ^widgets_query]) + |> Repo.one() + end + + @doc """ + Creates or updates a user's profile. + """ + def upsert_user_profile(user_id, attrs) do + case get_user_profile(user_id) do + nil -> + create_user_profile(user_id, attrs) + + profile -> + update_user_profile(profile, attrs) + end + end + + @doc """ + Creates a profile. + """ + def create_user_profile(user_id, attrs) do + %UserProfile{} + |> UserProfile.changeset(Map.put(attrs, :user_id, user_id)) + |> Repo.insert() + end + + @doc """ + Updates a profile. + """ + def update_user_profile(profile, attrs) do + result = + profile + |> UserProfile.changeset(attrs) + |> Repo.update() + + # Broadcast profile update so profile show page can refresh + case result do + {:ok, updated_profile} -> + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{updated_profile.user_id}:profile", + {:profile_updated, updated_profile.user_id} + ) + + # Federate profile update to ActivityPub + Elektrine.ActivityPub.Outbox.federate_profile_update(updated_profile.user_id) + + result + + _ -> + result + end + end + + @doc """ + Creates a link for a profile. + """ + def create_profile_link(profile_id, attrs) do + %ProfileLink{} + |> ProfileLink.changeset(Map.put(attrs, "profile_id", profile_id)) + |> Repo.insert() + end + + @doc """ + Updates a link. + """ + def update_profile_link(link, attrs) do + link + |> ProfileLink.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a link. + """ + def delete_profile_link(link) do + Repo.delete(link) + end + + @doc """ + Tracks a profile view with deduplication. + Only counts unique views per viewer within 24 hours. + + ## Options + - :viewer_user_id - ID of logged-in viewer (optional) + - :viewer_session_id - Session ID for anonymous viewers (optional) + - :ip_address - IP address of viewer + - :user_agent - User agent string + - :referer - HTTP referer + + Returns {:ok, :tracked} if view was counted, {:ok, :duplicate} if skipped + """ + def track_profile_view(profile_user_id, opts \\ []) do + viewer_user_id = Keyword.get(opts, :viewer_user_id) + viewer_session_id = Keyword.get(opts, :viewer_session_id) + ip_address = Keyword.get(opts, :ip_address) + user_agent = Keyword.get(opts, :user_agent) + referer = Keyword.get(opts, :referer) + + cond do + analytics_bot_user_agent?(user_agent) -> + {:ok, :bot} + + # Don't track if viewer is the profile owner + viewer_user_id && viewer_user_id == profile_user_id -> + {:ok, :own_profile} + + true -> + # Check if this viewer already viewed within last 24 hours + twenty_four_hours_ago = DateTime.utc_now() |> DateTime.add(-24, :hour) + + duplicate = + cond do + viewer_user_id -> + # Check by user ID + from(pv in ProfileView, + where: + pv.profile_user_id == ^profile_user_id and + pv.viewer_user_id == ^viewer_user_id and + pv.inserted_at > ^twenty_four_hours_ago, + limit: 1 + ) + |> Repo.one() + + is_binary(viewer_session_id) and viewer_session_id != "" -> + # Check by session ID + from(pv in ProfileView, + where: + pv.profile_user_id == ^profile_user_id and + pv.viewer_session_id == ^viewer_session_id and + pv.inserted_at > ^twenty_four_hours_ago, + limit: 1 + ) + |> Repo.one() + + true -> + nil + end + + if duplicate do + {:ok, :duplicate} + else + attrs = %{ + profile_user_id: profile_user_id, + viewer_user_id: viewer_user_id, + viewer_session_id: viewer_session_id, + ip_address: ip_address, + user_agent: user_agent, + referer: referer + } + + case %ProfileView{} + |> ProfileView.changeset(attrs) + |> Repo.insert() do + {:ok, _view} -> + # Successfully tracked new view, increment counter + from(p in UserProfile, where: p.user_id == ^profile_user_id) + |> Repo.update_all(inc: [page_views: 1]) + + {:ok, :tracked} + + {:error, changeset} -> + {:error, changeset} + end + end + end + end + + @doc """ + Gets total view count for a profile user. + """ + def get_profile_view_count(user_id) do + from(pv in ProfileView, + where: pv.profile_user_id == ^user_id, + select: count(pv.id) + ) + |> Repo.one() + end + + @doc """ + Gets unique viewer count for a profile user. + Counts unique authenticated users who viewed the profile. + """ + def get_unique_viewer_count(user_id) do + from(pv in ProfileView, + where: pv.profile_user_id == ^user_id and not is_nil(pv.viewer_user_id), + select: count(pv.viewer_user_id, :distinct) + ) + |> Repo.one() + end + + @doc """ + Gets view count for a specific time period. + """ + def get_profile_views_since(user_id, since_datetime) do + from(pv in ProfileView, + where: pv.profile_user_id == ^user_id and pv.inserted_at > ^since_datetime, + select: count(pv.id) + ) + |> Repo.one() + end + + @doc """ + Gets recent profile viewers (last 50). + """ + def get_recent_viewers(user_id, limit \\ 50) do + from(pv in ProfileView, + where: pv.profile_user_id == ^user_id and not is_nil(pv.viewer_user_id), + join: u in User, + on: u.id == pv.viewer_user_id, + order_by: [desc: pv.inserted_at], + limit: ^limit, + distinct: pv.viewer_user_id, + select: %{ + user_id: u.id, + username: u.username, + handle: u.handle, + display_name: u.display_name, + avatar: u.avatar, + viewed_at: pv.inserted_at + } + ) + |> Repo.all() + end + + @doc """ + Gets view statistics for a profile. + """ + def get_profile_view_stats(user_id) do + now = DateTime.utc_now() + today_start = DateTime.new!(Date.utc_today(), ~T[00:00:00]) + week_ago = DateTime.add(now, -7, :day) + month_ago = DateTime.add(now, -30, :day) + + %{ + total_views: get_profile_view_count(user_id), + unique_viewers: get_unique_viewer_count(user_id), + views_today: get_profile_views_since(user_id, today_start), + views_this_week: get_profile_views_since(user_id, week_ago), + views_this_month: get_profile_views_since(user_id, month_ago) + } + end + + @doc """ + Gets top referrers for a profile. + """ + def get_top_referrers(user_id, limit \\ 10) do + from(pv in ProfileView, + where: pv.profile_user_id == ^user_id and not is_nil(pv.referer), + group_by: pv.referer, + select: %{referer: pv.referer, count: count(pv.id)}, + order_by: [desc: count(pv.id)], + limit: ^limit + ) + |> Repo.all() + end + + @doc """ + Gets daily view counts for the last N days. + Returns data suitable for charting with all days included (zero-filled). + """ + def get_daily_view_counts(user_id, days \\ 30) do + start_date = Date.utc_today() |> Date.add(-days + 1) + end_date = Date.utc_today() + + start_datetime = DateTime.new!(start_date, ~T[00:00:00]) + + # Get actual view counts from database + actual_views = + from(pv in ProfileView, + where: pv.profile_user_id == ^user_id and pv.inserted_at >= ^start_datetime, + group_by: fragment("DATE(?)", pv.inserted_at), + select: %{date: fragment("DATE(?)", pv.inserted_at), count: count(pv.id)} + ) + |> Repo.all() + |> Map.new(fn %{date: date, count: count} -> {date, count} end) + + # Generate all dates in range + Date.range(start_date, end_date) + |> Enum.map(fn date -> + %{ + date: date, + count: Map.get(actual_views, date, 0) + } + end) + end + + @doc """ + Gets most clicked profile links. + """ + def get_top_links(user_id, limit \\ 10) do + profile = get_user_profile(user_id) + + if profile do + from(l in ProfileLink, + where: l.profile_id == ^profile.id, + order_by: [desc: l.clicks], + limit: ^limit, + select: %{ + id: l.id, + title: l.title, + url: l.url, + platform: l.platform, + clicks: l.clicks, + impressions: l.impressions, + last_clicked_at: l.last_clicked_at + } + ) + |> Repo.all() + else + [] + end + end + + @doc """ + Gets viewer breakdown (authenticated vs anonymous). + """ + def get_viewer_breakdown(user_id) do + total = get_profile_view_count(user_id) + + authenticated = + from(pv in ProfileView, + where: pv.profile_user_id == ^user_id and not is_nil(pv.viewer_user_id), + select: count(pv.id) + ) + |> Repo.one() + + %{ + total: total, + authenticated: authenticated, + anonymous: total - authenticated + } + end + + @doc """ + Tracks a page-level visit for profile domains and static sites. + """ + def track_profile_site_visit(profile_user_id, opts \\ []) do + if analytics_bot_user_agent?(Keyword.get(opts, :user_agent)) do + {:ok, :bot} + else + do_track_profile_site_visit(profile_user_id, opts) + end + end + + defp do_track_profile_site_visit(profile_user_id, opts) do + attrs = %{ + profile_user_id: profile_user_id, + viewer_user_id: Keyword.get(opts, :viewer_user_id), + visitor_id: Keyword.get(opts, :visitor_id), + ip_address: Keyword.get(opts, :ip_address), + user_agent: Keyword.get(opts, :user_agent), + referer: Keyword.get(opts, :referer), + request_host: normalize_request_host(Keyword.get(opts, :request_host)), + request_path: normalize_request_path(Keyword.get(opts, :request_path)) + } + + %ProfileSiteVisit{} + |> ProfileSiteVisit.changeset(attrs) + |> Repo.insert() + end + + def get_site_view_stats(user_id, request_host \\ nil) do + now = DateTime.utc_now() + today_start = DateTime.new!(Date.utc_today(), ~T[00:00:00]) + week_ago = DateTime.add(now, -7, :day) + + %{ + total_views: get_site_view_count(user_id, request_host), + unique_visitors: get_site_unique_visitor_count(user_id, request_host), + views_today: get_site_views_since(user_id, today_start, request_host), + views_this_week: get_site_views_since(user_id, week_ago, request_host) + } + end + + def get_site_view_count(user_id, request_host \\ nil) do + user_id + |> profile_site_visits_query(request_host) + |> select([sv], count(sv.id)) + |> Repo.one() + end + + def get_site_unique_visitor_count(user_id, request_host \\ nil) do + user_id + |> profile_site_visits_query(request_host) + |> select( + [ + sv + ], + count( + fragment( + "COALESCE(CAST(? AS text), ?, ?)", + sv.viewer_user_id, + sv.visitor_id, + sv.ip_address + ), + :distinct + ) + ) + |> Repo.one() + end + + def get_site_views_since(user_id, since_datetime, request_host \\ nil) do + user_id + |> profile_site_visits_query(request_host) + |> where([sv], sv.inserted_at > ^since_datetime) + |> select([sv], count(sv.id)) + |> Repo.one() + end + + def get_tracked_site_hosts(user_id) do + user_id + |> profile_site_visits_query(nil) + |> distinct([sv], sv.request_host) + |> order_by([sv], asc: sv.request_host) + |> select([sv], sv.request_host) + |> Repo.all() + end + + @doc """ + Tracks a site-wide HTML page visit for public app, profile, and static-site routes. + """ + def track_site_page_visit(opts \\ []) do + if analytics_bot_user_agent?(Keyword.get(opts, :user_agent)) do + {:ok, :bot} + else + do_track_site_page_visit(opts) + end + end + + defp do_track_site_page_visit(opts) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + attrs = %{ + viewer_user_id: Keyword.get(opts, :viewer_user_id), + visitor_id: Keyword.get(opts, :visitor_id), + session_id: Keyword.get(opts, :session_id), + ip_address: Keyword.get(opts, :ip_address), + user_agent: Keyword.get(opts, :user_agent), + referer: Keyword.get(opts, :referer), + request_host: normalize_request_host(Keyword.get(opts, :request_host)), + request_path: normalize_request_path(Keyword.get(opts, :request_path)), + status: Keyword.get(opts, :status) + } + + Repo.transaction(fn -> + with {:ok, visit} <- + %SitePageVisit{} + |> SitePageVisit.changeset(attrs) + |> Repo.insert(), + {:ok, _session} <- upsert_site_session(attrs, now) do + visit + else + {:error, reason} -> Repo.rollback(reason) + end + end) + end + + def get_public_site_view_stats(request_host \\ nil) do + today_start = DateTime.new!(Date.utc_today(), ~T[00:00:00]) + week_ago = DateTime.utc_now() |> DateTime.add(-7, :day) + + stats = + if public_site_rollups_available?(public_site_rollup_start_date(), Date.utc_today()) do + get_public_site_rollup_view_stats(request_host, today_start, week_ago) + else + get_public_site_session_view_stats(request_host, today_start, week_ago) + end + + sessions = stats.sessions || 0 + bounces = stats.bounces || 0 + + %{ + total_views: stats.total_views || 0, + unique_visitors: sessions, + sessions: sessions, + avg_session_duration_seconds: stats.avg_session_duration_seconds || 0.0, + bounce_rate: if(sessions > 0, do: bounces / sessions * 100, else: 0.0), + views_today: stats.views_today || 0, + views_this_week: stats.views_this_week || 0 + } + end + + defp get_public_site_session_view_stats(request_host, today_start, week_ago) do + request_host + |> site_sessions_query() + |> select([ss], %{ + total_views: fragment("COALESCE(SUM(?), 0)", ss.page_views), + sessions: count(), + avg_session_duration_seconds: fragment("COALESCE(AVG(?)::float, 0)", ss.duration_seconds), + bounces: fragment("COUNT(*) FILTER (WHERE ? = 1)", ss.page_views), + views_today: + fragment( + "COALESCE(SUM(?) FILTER (WHERE ? > ?), 0)", + ss.page_views, + ss.started_at, + ^today_start + ), + views_this_week: + fragment( + "COALESCE(SUM(?) FILTER (WHERE ? > ?), 0)", + ss.page_views, + ss.started_at, + ^week_ago + ) + }) + |> Repo.one() + end + + def get_public_site_view_count(request_host \\ nil) do + request_host + |> site_page_visits_query() + |> select([sv], count(sv.id)) + |> Repo.one() + end + + def get_public_site_unique_visitor_count(request_host \\ nil) do + request_host + |> site_sessions_query() + |> select([_ss], count()) + |> Repo.one() + end + + def get_public_site_session_count(request_host \\ nil) do + request_host + |> site_sessions_query() + |> select([_ss], count()) + |> Repo.one() + end + + def get_public_site_daily_view_counts(days \\ 30, request_host \\ nil) do + start_date = Date.utc_today() |> Date.add(-days + 1) + end_date = Date.utc_today() + start_datetime = DateTime.new!(start_date, ~T[00:00:00]) + + actual_views = + if public_site_rollups_available?(start_date, end_date) do + get_public_site_rollup_daily_view_counts(start_date, end_date, request_host) + else + request_host + |> site_sessions_query() + |> where([ss], ss.started_at >= ^start_datetime) + |> group_by([ss], fragment("DATE(?)", ss.started_at)) + |> select([ss], %{ + date: fragment("DATE(?)", ss.started_at), + count: fragment("COALESCE(SUM(?), 0)", ss.page_views) + }) + |> Repo.all() + |> Map.new(fn %{date: date, count: count} -> {date, count} end) + end + + Date.range(start_date, end_date) + |> Enum.map(fn date -> %{date: date, count: Map.get(actual_views, date, 0)} end) + end + + def get_public_site_top_pages(request_host \\ nil, limit \\ 10) do + start_date = public_site_rollup_start_date() + start_datetime = DateTime.new!(start_date, ~T[00:00:00]) + end_date = Date.utc_today() + + if public_site_rollups_available?(start_date, end_date) do + get_public_site_rollup_top_pages(request_host, start_date, end_date, limit) + else + request_host + |> site_sessions_query() + |> where([ss], ss.started_at >= ^start_datetime) + |> group_by([ss], [ss.entry_host, ss.entry_path]) + |> order_by([ss], desc: fragment("COALESCE(SUM(?), 0)", ss.page_views)) + |> limit(^limit) + |> select([ss], %{ + host: ss.entry_host, + path: ss.entry_path, + views: fragment("COALESCE(SUM(?), 0)", ss.page_views), + unique_visitors: count() + }) + |> Repo.all() + end + end + + def get_public_site_top_referrers(request_host \\ nil, limit \\ 10) do + start_date = public_site_rollup_start_date() + start_datetime = DateTime.new!(start_date, ~T[00:00:00]) + end_date = Date.utc_today() + + if public_site_rollups_available?(start_date, end_date) do + get_public_site_rollup_top_referrers(request_host, start_date, end_date, limit) + else + request_host + |> site_sessions_query() + |> where([ss], ss.started_at >= ^start_datetime) + |> where([ss], not is_nil(ss.referer)) + |> group_by([ss], ss.referer) + |> order_by([_ss], desc: count()) + |> limit(^limit) + |> select([ss], %{referer: ss.referer, count: count()}) + |> Repo.all() + end + end + + def get_public_site_domain_breakdown([]), do: [] + + def get_public_site_domain_breakdown(hosts) when is_list(hosts) do + today_start = DateTime.new!(Date.utc_today(), ~T[00:00:00]) + + if public_site_rollups_available?(public_site_rollup_start_date(), Date.utc_today()) do + get_public_site_rollup_domain_breakdown(hosts, today_start) + else + site_sessions_query(hosts) + |> group_by([ss], ss.entry_host) + |> select([ss], %{ + host: ss.entry_host, + views: fragment("COALESCE(SUM(?), 0)", ss.page_views), + unique_visitors: count(), + views_today: + fragment( + "COALESCE(SUM(?) FILTER (WHERE ? >= ?), 0)", + ss.page_views, + ss.started_at, + ^today_start + ) + }) + |> Repo.all() + end + end + + def get_public_site_domain_breakdown(_hosts), do: [] + + @doc """ + Refreshes public site analytics rollups from raw site sessions. + """ + def refresh_public_site_analytics_rollups(opts \\ []) do + days = Keyword.get(opts, :days, @public_site_rollup_days) + end_date = Keyword.get(opts, :end_date, Date.utc_today()) + start_date = Date.add(end_date, -days + 1) + + {:ok, counts} = + Repo.transaction(fn -> + delete_public_site_rollups(start_date, end_date) + + %{ + daily: insert_public_site_daily_rollups(start_date, end_date), + pages: insert_public_site_page_rollups(start_date, end_date), + referrers: insert_public_site_referrer_rollups(start_date, end_date), + dates: mark_public_site_rollup_dates(start_date, end_date) + } + end) + + counts + end + + defp public_site_rollup_start_date, + do: Date.utc_today() |> Date.add(-@public_site_rollup_days + 1) + + defp public_site_rollups_available?(start_date, end_date) do + expected_dates = Date.diff(end_date, start_date) + 1 + + %{rows: [[available_dates]]} = + Repo.query!( + """ + SELECT COUNT(*) + FROM site_analytics_rollup_dates + WHERE date BETWEEN $1 AND $2 + """, + [start_date, end_date] + ) + + available_dates == expected_dates + rescue + Postgrex.Error -> false + end + + defp get_public_site_rollup_view_stats(request_host, today_start, week_ago) do + today = DateTime.to_date(today_start) + week_start = DateTime.to_date(week_ago) + start_date = public_site_rollup_start_date() + end_date = Date.utc_today() + + case rollup_host_filter(request_host) do + :none -> + empty_public_site_rollup_stats() + + {all_hosts?, hosts} -> + %{ + rows: [[total_views, sessions, duration_seconds, bounces, views_today, views_this_week]] + } = + Repo.query!( + """ + SELECT + COALESCE(SUM(views), 0)::bigint, + COALESCE(SUM(sessions), 0)::bigint, + COALESCE(SUM(duration_seconds), 0)::bigint, + COALESCE(SUM(bounces), 0)::bigint, + COALESCE(SUM(views) FILTER (WHERE date >= $5), 0)::bigint, + COALESCE(SUM(views) FILTER (WHERE date >= $6), 0)::bigint + FROM site_analytics_daily_rollups + WHERE date BETWEEN $1 AND $2 + AND ($3 OR host = ANY($4::text[])) + """, + [start_date, end_date, all_hosts?, hosts, today, week_start] + ) + + %{ + total_views: total_views, + sessions: sessions, + avg_session_duration_seconds: + if(sessions > 0, do: duration_seconds / sessions, else: 0.0), + bounces: bounces, + views_today: views_today, + views_this_week: views_this_week + } + end + end + + defp empty_public_site_rollup_stats do + %{ + total_views: 0, + sessions: 0, + avg_session_duration_seconds: 0.0, + bounces: 0, + views_today: 0, + views_this_week: 0 + } + end + + defp get_public_site_rollup_daily_view_counts(start_date, end_date, request_host) do + case rollup_host_filter(request_host) do + :none -> + %{} + + {all_hosts?, hosts} -> + %{rows: rows} = + Repo.query!( + """ + SELECT date, COALESCE(SUM(views), 0)::bigint + FROM site_analytics_daily_rollups + WHERE date BETWEEN $1 AND $2 + AND ($3 OR host = ANY($4::text[])) + GROUP BY date + """, + [start_date, end_date, all_hosts?, hosts] + ) + + Map.new(rows, fn [date, count] -> {date, count} end) + end + end + + defp get_public_site_rollup_top_pages(request_host, start_date, end_date, limit) do + case rollup_host_filter(request_host) do + :none -> + [] + + {all_hosts?, hosts} -> + %{rows: rows} = + Repo.query!( + """ + SELECT host, path, COALESCE(SUM(views), 0)::bigint, COALESCE(SUM(sessions), 0)::bigint + FROM site_analytics_page_rollups + WHERE date BETWEEN $1 AND $2 + AND ($3 OR host = ANY($4::text[])) + GROUP BY host, path + ORDER BY COALESCE(SUM(views), 0) DESC + LIMIT $5 + """, + [start_date, end_date, all_hosts?, hosts, limit] + ) + + Enum.map(rows, fn [host, path, views, sessions] -> + %{host: host, path: path, views: views, unique_visitors: sessions} + end) + end + end + + defp get_public_site_rollup_top_referrers(request_host, start_date, end_date, limit) do + case rollup_host_filter(request_host) do + :none -> + [] + + {all_hosts?, hosts} -> + %{rows: rows} = + Repo.query!( + """ + SELECT referrer, COALESCE(SUM(sessions), 0)::bigint + FROM site_analytics_referrer_rollups + WHERE date BETWEEN $1 AND $2 + AND ($3 OR host = ANY($4::text[])) + GROUP BY referrer + ORDER BY COALESCE(SUM(sessions), 0) DESC + LIMIT $5 + """, + [start_date, end_date, all_hosts?, hosts, limit] + ) + + Enum.map(rows, fn [referer, count] -> %{referer: referer, count: count} end) + end + end + + defp get_public_site_rollup_domain_breakdown(hosts, today_start) do + today = DateTime.to_date(today_start) + start_date = public_site_rollup_start_date() + end_date = Date.utc_today() + + case rollup_host_filter(hosts) do + :none -> + [] + + {all_hosts?, normalized_hosts} -> + %{rows: rows} = + Repo.query!( + """ + SELECT + host, + COALESCE(SUM(views), 0)::bigint, + COALESCE(SUM(sessions), 0)::bigint, + COALESCE(SUM(views) FILTER (WHERE date >= $5), 0)::bigint + FROM site_analytics_daily_rollups + WHERE date BETWEEN $1 AND $2 + AND ($3 OR host = ANY($4::text[])) + GROUP BY host + """, + [start_date, end_date, all_hosts?, normalized_hosts, today] + ) + + Enum.map(rows, fn [host, views, unique_visitors, views_today] -> + %{host: host, views: views, unique_visitors: unique_visitors, views_today: views_today} + end) + end + end + + defp rollup_host_filter(hosts) when is_list(hosts) do + case normalize_request_hosts(hosts) do + [] -> :none + normalized_hosts -> {false, normalized_hosts} + end + end + + defp rollup_host_filter(host) do + case normalize_request_hosts(host) do + [] -> {true, [""]} + normalized_hosts -> {false, normalized_hosts} + end + end + + defp delete_public_site_rollups(start_date, end_date) do + Enum.each( + [ + "site_analytics_daily_rollups", + "site_analytics_page_rollups", + "site_analytics_referrer_rollups", + "site_analytics_rollup_dates" + ], + fn table -> + Repo.query!("DELETE FROM #{table} WHERE date BETWEEN $1 AND $2", [start_date, end_date]) + end + ) + end + + defp insert_public_site_daily_rollups(start_date, end_date) do + %{num_rows: inserted} = + Repo.query!( + """ + INSERT INTO site_analytics_daily_rollups ( + host, date, views, sessions, bounces, duration_seconds, inserted_at, updated_at + ) + SELECT + entry_host, + started_at::date, + COALESCE(SUM(page_views), 0)::bigint, + COUNT(*)::bigint, + COUNT(*) FILTER (WHERE page_views = 1)::bigint, + COALESCE(SUM(duration_seconds), 0)::bigint, + timezone('UTC', now())::timestamp(0), + timezone('UTC', now())::timestamp(0) + FROM site_sessions + WHERE entry_host IS NOT NULL + AND started_at >= $1::date + AND started_at < ($2::date + interval '1 day') + GROUP BY entry_host, started_at::date + """, + [start_date, end_date] + ) + + inserted + end + + defp insert_public_site_page_rollups(start_date, end_date) do + %{num_rows: inserted} = + Repo.query!( + """ + INSERT INTO site_analytics_page_rollups ( + host, path, date, views, sessions, inserted_at, updated_at + ) + SELECT + entry_host, + entry_path, + started_at::date, + COALESCE(SUM(page_views), 0)::bigint, + COUNT(*)::bigint, + timezone('UTC', now())::timestamp(0), + timezone('UTC', now())::timestamp(0) + FROM site_sessions + WHERE entry_host IS NOT NULL + AND entry_path IS NOT NULL + AND started_at >= $1::date + AND started_at < ($2::date + interval '1 day') + GROUP BY entry_host, entry_path, started_at::date + """, + [start_date, end_date] + ) + + inserted + end + + defp insert_public_site_referrer_rollups(start_date, end_date) do + %{num_rows: inserted} = + Repo.query!( + """ + INSERT INTO site_analytics_referrer_rollups ( + host, referrer, date, sessions, inserted_at, updated_at + ) + SELECT + entry_host, + referer, + started_at::date, + COUNT(*)::bigint, + timezone('UTC', now())::timestamp(0), + timezone('UTC', now())::timestamp(0) + FROM site_sessions + WHERE entry_host IS NOT NULL + AND referer IS NOT NULL + AND started_at >= $1::date + AND started_at < ($2::date + interval '1 day') + GROUP BY entry_host, referer, started_at::date + """, + [start_date, end_date] + ) + + inserted + end + + defp mark_public_site_rollup_dates(start_date, end_date) do + %{num_rows: inserted} = + Repo.query!( + """ + INSERT INTO site_analytics_rollup_dates (date, refreshed_at) + SELECT date::date, timezone('UTC', now())::timestamp(0) + FROM generate_series($1::date, $2::date, interval '1 day') AS date + """, + [start_date, end_date] + ) + + inserted + end + + @doc """ + Returns true when a user agent belongs to a crawler that should be excluded from raw analytics. + """ + def analytics_bot_user_agent?(user_agent) when is_binary(user_agent) do + normalized_user_agent = String.downcase(user_agent) + + Enum.any?(@analytics_bot_user_agent_fragments, fn fragment -> + String.contains?(normalized_user_agent, fragment) + end) + end + + def analytics_bot_user_agent?(_user_agent), do: false + + @doc """ + Prunes raw analytics rows after their configured retention windows. + """ + def prune_analytics_retention(opts \\ []) do + retention_config = Application.get_env(:elektrine, :analytics_retention, []) + + site_retention_days = + Keyword.get(opts, :site_retention_days) || + Keyword.get( + retention_config, + :site_retention_days, + @default_site_analytics_retention_days + ) + + profile_retention_days = + Keyword.get(opts, :profile_retention_days) || + Keyword.get( + retention_config, + :profile_retention_days, + @default_profile_analytics_retention_days + ) + + batch_size = + Keyword.get(opts, :batch_size) || + Keyword.get( + retention_config, + :batch_size, + @default_analytics_retention_batch_size + ) + + max_batches = + Keyword.get(opts, :max_batches) || + Keyword.get( + retention_config, + :max_batches, + @default_analytics_retention_max_batches + ) + + now = Keyword.get(opts, :now, DateTime.utc_now() |> DateTime.truncate(:second)) + site_cutoff = now |> DateTime.add(-site_retention_days, :day) |> DateTime.to_naive() + profile_cutoff = now |> DateTime.add(-profile_retention_days, :day) |> DateTime.to_naive() + + %{ + site_page_visits: + prune_analytics_table(:site_page_visits, site_cutoff, batch_size, max_batches), + site_sessions: prune_analytics_table(:site_sessions, site_cutoff, batch_size, max_batches), + profile_site_visits: + prune_analytics_table(:profile_site_visits, profile_cutoff, batch_size, max_batches), + profile_views: + prune_analytics_table(:profile_views, profile_cutoff, batch_size, max_batches) + } + end + + defp prune_analytics_table(_table, _cutoff, batch_size, max_batches) + when batch_size < 1 or max_batches < 1, + do: 0 + + defp prune_analytics_table(table, cutoff, batch_size, max_batches) do + {table_name, timestamp_column} = + case table do + :site_page_visits -> {"site_page_visits", "inserted_at"} + :site_sessions -> {"site_sessions", "started_at"} + :profile_site_visits -> {"profile_site_visits", "inserted_at"} + :profile_views -> {"profile_views", "inserted_at"} + end + + sql = """ + WITH doomed AS ( + SELECT ctid + FROM #{table_name} + WHERE #{timestamp_column} < $1 + LIMIT $2 + ) + DELETE FROM #{table_name} + WHERE ctid IN (SELECT ctid FROM doomed) + """ + + Enum.reduce_while(1..max_batches, 0, fn _batch, total_deleted -> + %{num_rows: deleted} = Repo.query!(sql, [cutoff, batch_size]) + new_total = total_deleted + deleted + + if deleted < batch_size do + {:halt, new_total} + else + {:cont, new_total} + end + end) + end + + defp upsert_site_session(attrs, now) do + %SiteSession{} + |> SiteSession.changeset(%{ + session_id: attrs.session_id, + viewer_user_id: attrs.viewer_user_id, + visitor_id: attrs.visitor_id, + ip_address: attrs.ip_address, + user_agent: attrs.user_agent, + referer: attrs.referer, + entry_host: attrs.request_host, + entry_path: attrs.request_path, + exit_host: attrs.request_host, + exit_path: attrs.request_path, + page_views: 1, + started_at: now, + last_seen_at: now, + duration_seconds: 0 + }) + |> Repo.insert( + on_conflict: site_session_conflict_query(), + conflict_target: :session_id + ) + end + + defp site_session_conflict_query do + from(ss in SiteSession, + update: [ + set: [ + viewer_user_id: fragment("COALESCE(EXCLUDED.viewer_user_id, ?)", ss.viewer_user_id), + exit_host: fragment("EXCLUDED.exit_host"), + exit_path: fragment("EXCLUDED.exit_path"), + last_seen_at: fragment("EXCLUDED.last_seen_at"), + duration_seconds: + fragment( + "GREATEST(EXTRACT(EPOCH FROM (EXCLUDED.last_seen_at - ?))::integer, 0)", + ss.started_at + ), + updated_at: fragment("EXCLUDED.updated_at") + ], + inc: [page_views: 1] + ] + ) + end + + defp site_page_visits_query(request_hosts) when is_list(request_hosts) do + base = + from(sv in SitePageVisit, + where: not is_nil(sv.request_host) + ) + + case normalize_request_hosts(request_hosts) do + hosts when is_list(hosts) and hosts != [] -> where(base, [sv], sv.request_host in ^hosts) + _ -> where(base, [_sv], fragment("FALSE")) + end + end + + defp site_page_visits_query(request_host) do + base = + from(sv in SitePageVisit, + where: not is_nil(sv.request_host) + ) + + case normalize_request_hosts(request_host) do + hosts when is_list(hosts) and hosts != [] -> where(base, [sv], sv.request_host in ^hosts) + _ -> base + end + end + + defp site_sessions_query(request_hosts) when is_list(request_hosts) do + base = + from(ss in SiteSession, + where: not is_nil(ss.entry_host) + ) + + case normalize_request_hosts(request_hosts) do + hosts when is_list(hosts) and hosts != [] -> where(base, [ss], ss.entry_host in ^hosts) + _ -> where(base, [_ss], fragment("FALSE")) + end + end + + defp site_sessions_query(request_host) do + base = + from(ss in SiteSession, + where: not is_nil(ss.entry_host) + ) + + case normalize_request_hosts(request_host) do + hosts when is_list(hosts) and hosts != [] -> where(base, [ss], ss.entry_host in ^hosts) + _ -> base + end + end + + defp profile_site_visits_query(user_id, request_hosts) when is_list(request_hosts) do + base = + from(sv in ProfileSiteVisit, + where: sv.profile_user_id == ^user_id and not is_nil(sv.request_host) + ) + + case normalize_request_hosts(request_hosts) do + hosts when is_list(hosts) and hosts != [] -> where(base, [sv], sv.request_host in ^hosts) + _ -> where(base, [_sv], fragment("FALSE")) + end + end + + defp profile_site_visits_query(user_id, request_host) do + base = + from(sv in ProfileSiteVisit, + where: sv.profile_user_id == ^user_id and not is_nil(sv.request_host) + ) + + case normalize_request_hosts(request_host) do + hosts when is_list(hosts) and hosts != [] -> where(base, [sv], sv.request_host in ^hosts) + _ -> base + end + end + + defp normalize_request_hosts(hosts) when is_list(hosts) do + hosts + |> Enum.map(&normalize_request_host/1) + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.uniq() + end + + defp normalize_request_hosts(host) do + case normalize_request_host(host) do + host when is_binary(host) and host != "" -> [host] + _ -> [] + end + end + + defp normalize_request_host(host) when is_binary(host) do + host + |> String.trim() + |> String.downcase() + |> String.split(":", parts: 2) + |> List.first() + end + + defp normalize_request_host(_), do: nil + + defp normalize_request_path(path) when is_binary(path) and path != "", do: path + defp normalize_request_path(_), do: "/" + + @doc """ + Increments click count for a link. + """ + def increment_link_clicks(link_id) do + from(l in ProfileLink, where: l.id == ^link_id) + |> Repo.update_all( + inc: [clicks: 1], + set: [last_clicked_at: DateTime.utc_now() |> DateTime.truncate(:second)] + ) + end + + @doc """ + Increments impression counters for visible profile links. + """ + def increment_link_impressions(link_ids) when is_list(link_ids) do + ids = + link_ids + |> Enum.filter(&is_integer/1) + |> Enum.uniq() + + if ids == [] do + {0, nil} + else + from(l in ProfileLink, where: l.id in ^ids) + |> Repo.update_all(inc: [impressions: 1]) + end + end + + def increment_link_impressions(_link_ids), do: {0, nil} + + @doc """ + Follow a user. + """ + def follow_user(follower_id, followed_id) do + case Repo.get_by(Follow, follower_id: follower_id, followed_id: followed_id) do + %Follow{} = follow -> + {:ok, follow} + + nil -> + with :ok <- validate_local_follow_allowed(follower_id, followed_id) do + result = + %Follow{} + |> Follow.changeset(%{follower_id: follower_id, followed_id: followed_id}) + |> Repo.insert() + + case result do + {:ok, follow} -> + # Create notification for the followed user + follower = Elektrine.Accounts.get_user!(follower_id) + Elektrine.Notifications.notify_follow(followed_id, follower) + notify_home_feed_policy_changed(follower_id, :followed) + + Elektrine.Async.run(fn -> + _ = Elektrine.Bluesky.OutboundWorker.enqueue_follow(follower_id, followed_id) + end) + + {:ok, follow} + + {:error, %Ecto.Changeset{errors: [follower_id: {"has already been taken", _}]}} -> + case Repo.get_by(Follow, follower_id: follower_id, followed_id: followed_id) do + nil -> result + follow -> {:ok, follow} + end + + error -> + error + end + end + end + end + + defp validate_local_follow_allowed(follower_id, followed_id) do + cond do + Elektrine.Accounts.user_blocked?(follower_id, followed_id) or + Elektrine.Accounts.user_blocked?(followed_id, follower_id) -> + {:error, :blocked} + + protected_profile?(followed_id) -> + {:error, :follow_requires_approval} + + true -> + :ok + end + end + + defp protected_profile?(user_id) do + case Elektrine.Accounts.get_user!(user_id) do + %{profile_visibility: visibility} when visibility in ["followers", "private"] -> true + _ -> false + end + end + + @doc """ + Unfollow a user. + """ + def unfollow_user(follower_id, followed_id) do + result = + Follow + |> where([f], f.follower_id == ^follower_id and f.followed_id == ^followed_id) + |> Repo.delete_all() + + case result do + {count, _} when count > 0 -> + Elektrine.Async.run(fn -> + _ = Elektrine.Bluesky.OutboundWorker.enqueue_unfollow(follower_id, followed_id) + end) + + notify_home_feed_policy_changed(follower_id, :unfollowed) + {:ok, :unfollowed} + + {0, _} -> + {:ok, :not_following} + end + end + + @doc """ + Check if a user is following another user. + Only returns true for accepted follows (pending == false). + """ + def following?(follower_id, followed_id) do + Follow + |> where( + [f], + f.follower_id == ^follower_id and f.followed_id == ^followed_id and f.pending == false + ) + |> Repo.exists?() + end + + defp notify_home_feed_policy_changed(user_id, reason) when is_integer(user_id) do + module = Module.concat([Elektrine, Social, HomeFeedInvalidationWorker]) + + if Code.ensure_loaded?(module) do + _ = module.clear_user(user_id, reason) + end + + :ok + rescue + _ -> :ok + end + + @doc """ + Check which users from a list are being followed by the given user. + Returns a map of user_id => boolean. + Only counts accepted follows (pending == false). + """ + def following_many?(follower_id, followed_ids) when is_list(followed_ids) do + if Enum.empty?(followed_ids) do + %{} + else + # Get all follows in a single query + followed_set = + Follow + |> where( + [f], + f.follower_id == ^follower_id and f.followed_id in ^followed_ids and f.pending == false + ) + |> select([f], f.followed_id) + |> Repo.all() + |> MapSet.new() + + # Build result map with true/false for each user_id + followed_ids + |> Enum.uniq() + |> Enum.map(fn uid -> {uid, MapSet.member?(followed_set, uid)} end) + |> Map.new() + end + end + + @doc """ + Get local accounts the viewer follows that also follow the target user. + """ + def get_familiar_followers(target_user_id, viewer_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 20) + + Follow + |> where( + [target_follow], + target_follow.followed_id == ^target_user_id and target_follow.pending == false and + not is_nil(target_follow.follower_id) + ) + |> join(:inner, [target_follow], viewer_follow in Follow, + on: + viewer_follow.follower_id == ^viewer_id and + viewer_follow.followed_id == target_follow.follower_id and + viewer_follow.pending == false + ) + |> join(:inner, [target_follow, _viewer_follow], user in User, + on: user.id == target_follow.follower_id + ) + |> where( + [target_follow, _viewer_follow, _user], + target_follow.follower_id != ^target_user_id and target_follow.follower_id != ^viewer_id + ) + |> order_by([target_follow], desc: target_follow.inserted_at) + |> limit(^limit) + |> select([_target_follow, _viewer_follow, user], user) + |> Repo.all() + end + + @doc """ + Removes a follower from a local account. + """ + def remove_follower(followed_id, follower_id) + when is_integer(followed_id) and is_integer(follower_id) and followed_id != follower_id do + {count, _} = + Follow + |> where( + [f], + f.followed_id == ^followed_id and f.follower_id == ^follower_id and + is_nil(f.remote_actor_id) + ) + |> Repo.delete_all() + + if count > 0 do + notify_home_feed_policy_changed(follower_id, :removed_follower) + {:ok, :removed} + else + {:ok, :not_following} + end + end + + def remove_follower(_followed_id, _follower_id), do: {:error, :invalid_follower} + + @doc """ + Get followers for a user (includes both local and remote followers). + Only returns accepted follows (pending == false). + """ + def get_followers(user_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 20) + offset = Keyword.get(opts, :offset, 0) + before_id = Keyword.get(opts, :before_id) + since_id = Keyword.get(opts, :since_id) + min_id = Keyword.get(opts, :min_id) + + # Get local followers (only accepted) + local_followers = + Follow + |> where( + [f], + f.followed_id == ^user_id and not is_nil(f.follower_id) and f.pending == false + ) + |> join(:inner, [f], u in User, on: f.follower_id == u.id) + |> select([f, u], %{ + type: "local", + user: u, + remote_actor: nil, + followed_at: f.inserted_at + }) + |> Repo.all() + + # Get remote followers - only accepted + remote_followers = + Follow + |> where( + [f], + f.followed_id == ^user_id and not is_nil(f.remote_actor_id) and f.pending == false + ) + |> join(:inner, [f], a in Elektrine.ActivityPub.Actor, on: f.remote_actor_id == a.id) + |> select([f, a], %{ + type: "remote", + user: nil, + remote_actor: a, + followed_at: f.inserted_at + }) + |> Repo.all() + + # Combine and sort + (local_followers ++ remote_followers) + |> Enum.sort_by(& &1.followed_at, {:desc, NaiveDateTime}) + |> filter_follow_account_ids(before_id, since_id || min_id) + |> Enum.drop(offset) + |> Enum.take(limit) + end + + @doc """ + Get users that a user is following (includes both local and remote users). + Returns accepted follows, plus older remote follows to actors that auto-accept. + """ + def get_following(user_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 20) + offset = Keyword.get(opts, :offset, 0) + before_id = Keyword.get(opts, :before_id) + since_id = Keyword.get(opts, :since_id) + min_id = Keyword.get(opts, :min_id) + + # Get local users being followed (only accepted) + local_following = + Follow + |> where( + [f], + f.follower_id == ^user_id and not is_nil(f.followed_id) and f.pending == false + ) + |> join(:inner, [f], u in User, on: f.followed_id == u.id) + |> select([f, u], %{ + type: "local", + user: u, + remote_actor: nil, + followed_at: f.inserted_at + }) + |> Repo.all() + + # Treat older pending rows for auto-accepting remote actors as accepted. + remote_following = + Follow + |> join(:inner, [f], a in Elektrine.ActivityPub.Actor, on: f.remote_actor_id == a.id) + |> where( + [f, a], + f.follower_id == ^user_id and not is_nil(f.remote_actor_id) and + (f.pending == false or a.manually_approves_followers == false) + ) + |> select([f, a], %{ + type: "remote", + user: nil, + remote_actor: a, + followed_at: f.inserted_at + }) + |> Repo.all() + + # Combine and sort + (local_following ++ remote_following) + |> Enum.sort_by(& &1.followed_at, {:desc, NaiveDateTime}) + |> filter_follow_account_ids(before_id, since_id || min_id) + |> Enum.drop(offset) + |> Enum.take(limit) + end + + defp filter_follow_account_ids(entries, before_id, since_id) do + entries + |> filter_before_account_id(before_id) + |> filter_since_account_id(since_id) + end + + defp filter_before_account_id(entries, id) when is_integer(id) do + Enum.filter(entries, &(follow_account_id(&1) < id)) + end + + defp filter_before_account_id(entries, _id), do: entries + + defp filter_since_account_id(entries, id) when is_integer(id) do + Enum.filter(entries, &(follow_account_id(&1) > id)) + end + + defp filter_since_account_id(entries, _id), do: entries + + defp follow_account_id(%{user: %{id: id}}), do: id + defp follow_account_id(%{remote_actor: %{id: id}}), do: id + defp follow_account_id(_entry), do: 0 + + @doc """ + Get follower count for a user (includes both local and remote). + Only counts accepted follows (pending == false). + """ + def get_follower_count(user_id) do + Follow + |> where([f], f.followed_id == ^user_id and f.pending == false) + |> Repo.aggregate(:count, :id) + end + + @doc """ + Get following count for a user (includes both local and remote). + Counts accepted follows, plus older remote follows to actors that auto-accept. + """ + def get_following_count(user_id) do + local_following_count = + Follow + |> where( + [f], + f.follower_id == ^user_id and not is_nil(f.followed_id) and f.pending == false + ) + |> Repo.aggregate(:count, :id) + + remote_following_count = + Follow + |> join(:inner, [f], a in Elektrine.ActivityPub.Actor, on: f.remote_actor_id == a.id) + |> where( + [f, a], + f.follower_id == ^user_id and not is_nil(f.remote_actor_id) and + (f.pending == false or a.manually_approves_followers == false) + ) + |> Repo.aggregate(:count, :id) + + local_following_count + remote_following_count + end + + # Badge Management + + def list_user_badges(user_id) do + UserBadge + |> where([b], b.user_id == ^user_id) + |> order_by([b], b.position) + |> Repo.all() + end + + def list_visible_user_badges(user_id) do + UserBadge + |> where([b], b.user_id == ^user_id and b.visible == true) + |> order_by([b], b.position) + |> Repo.all() + end + + def create_badge(attrs) do + %UserBadge{} + |> UserBadge.changeset(attrs) + |> Repo.insert() + end + + def update_badge(badge, attrs) do + badge + |> UserBadge.changeset(attrs) + |> Repo.update() + end + + def delete_badge(badge_id) do + Repo.get!(UserBadge, badge_id) + |> Repo.delete() + end + + @doc """ + Grants a predefined badge type to a user with default properties. + """ + def grant_badge(user_id, badge_type, granted_by_admin_id) + when badge_type in ~w(staff verified admin moderator supporter developer contributor beta_tester) do + defaults = UserBadge.default_badge_properties(badge_type) + + attrs = %{ + user_id: user_id, + badge_type: badge_type, + badge_text: defaults.badge_text, + badge_color: defaults.badge_color, + badge_icon: defaults.badge_icon, + tooltip: defaults.tooltip, + granted_by_id: granted_by_admin_id, + visible: true, + position: 0 + } + + create_badge(attrs) + end + + @doc """ + Checks if a user has a specific badge type. + """ + def has_badge?(user_id, badge_type) do + Repo.exists?( + from b in UserBadge, + where: b.user_id == ^user_id and b.badge_type == ^badge_type and b.visible == true + ) + end + + @doc """ + Revokes all badges of a specific type from a user. + """ + def revoke_badge(user_id, badge_type) do + from(b in UserBadge, + where: b.user_id == ^user_id and b.badge_type == ^badge_type + ) + |> Repo.delete_all() + end + + # Widget Management + + def create_widget(attrs) do + %ProfileWidget{} + |> ProfileWidget.changeset(attrs) + |> Repo.insert() + end + + def update_widget(widget, attrs) do + widget + |> ProfileWidget.changeset(attrs) + |> Repo.update() + end + + def delete_widget(widget_id) do + Repo.get!(ProfileWidget, widget_id) + |> Repo.delete() + end + + # ActivityPub Federation Support + + @doc """ + Creates a follow from a remote actor. + """ + def create_remote_follow( + remote_actor_id, + followed_user_id, + pending \\ false, + activitypub_id \\ nil + ) do + %Follow{} + |> Follow.changeset(%{ + remote_actor_id: remote_actor_id, + followed_id: followed_user_id, + pending: pending, + activitypub_id: activitypub_id + }) + |> Repo.insert() + end + + @doc """ + Deletes a follow from a remote actor. + """ + def delete_remote_follow(remote_actor_id, followed_user_id) do + Follow + |> where([f], f.remote_actor_id == ^remote_actor_id and f.followed_id == ^followed_user_id) + |> Repo.delete_all() + end + + @doc """ + Gets a follow by remote actor and followed user. + This is for when a REMOTE actor follows a LOCAL user. + """ + def get_follow_by_remote_actor(remote_actor_id, followed_user_id) do + Follow + |> where([f], f.remote_actor_id == ^remote_actor_id and f.followed_id == ^followed_user_id) + |> Repo.one() + end + + @doc """ + Gets a follow where a LOCAL user follows a REMOTE actor. + Returns the follow record regardless of pending status. + """ + def get_follow_to_remote_actor(follower_id, remote_actor_id) do + Follow + |> where( + [f], + f.follower_id == ^follower_id and f.remote_actor_id == ^remote_actor_id and + is_nil(f.followed_id) + ) + |> Repo.one() + end + + def get_follow_to_remote_actor_by_identity(follower_id, %{id: remote_actor_id} = remote_actor) + when is_integer(remote_actor_id) do + remote_actor_uri = Map.get(remote_actor, :uri) + remote_actor_username = Map.get(remote_actor, :username) + remote_actor_domain = Map.get(remote_actor, :domain) + + Follow + |> join(:inner, [f], a in Elektrine.ActivityPub.Actor, on: f.remote_actor_id == a.id) + |> where([f], f.follower_id == ^follower_id and is_nil(f.followed_id)) + |> where( + ^remote_actor_identity_query( + remote_actor_id, + remote_actor_uri, + remote_actor_username, + remote_actor_domain + ) + ) + |> order_by([f, a], desc: f.inserted_at, desc: f.id, desc: a.id) + |> Repo.one() + end + + def get_follow_to_remote_actor_by_identity(follower_id, remote_actor_id) + when is_integer(remote_actor_id) do + get_follow_to_remote_actor(follower_id, remote_actor_id) + end + + @doc """ + Checks if a LOCAL user is actively following a REMOTE actor. + Treats older pending rows for auto-accepting remote actors as accepted. + """ + def following_remote_actor?(follower_id, remote_actor_id) do + Follow + |> join(:inner, [f], a in Elektrine.ActivityPub.Actor, on: f.remote_actor_id == a.id) + |> where( + [f, a], + f.follower_id == ^follower_id and f.remote_actor_id == ^remote_actor_id and + is_nil(f.followed_id) and (f.pending == false or a.manually_approves_followers == false) + ) + |> Repo.exists?() + end + + def following_remote_actor_by_identity?(follower_id, %{id: remote_actor_id} = remote_actor) + when is_integer(remote_actor_id) do + remote_actor_uri = Map.get(remote_actor, :uri) + remote_actor_username = Map.get(remote_actor, :username) + remote_actor_domain = Map.get(remote_actor, :domain) + + Follow + |> join(:inner, [f], a in Elektrine.ActivityPub.Actor, on: f.remote_actor_id == a.id) + |> where([f], f.follower_id == ^follower_id and is_nil(f.followed_id)) + |> where( + ^remote_actor_identity_query( + remote_actor_id, + remote_actor_uri, + remote_actor_username, + remote_actor_domain + ) + ) + |> where([f, a], f.pending == false or a.manually_approves_followers == false) + |> Repo.exists?() + end + + def following_remote_actor_by_identity?(follower_id, remote_actor_id) + when is_integer(remote_actor_id) do + following_remote_actor?(follower_id, remote_actor_id) + end + + @doc """ + Returns true when any local user actively follows a remote actor. + """ + def any_local_following_remote_actor?(remote_actor_id) when is_integer(remote_actor_id) do + Follow + |> join(:inner, [f], a in Elektrine.ActivityPub.Actor, on: f.remote_actor_id == a.id) + |> where( + [f, a], + f.remote_actor_id == ^remote_actor_id and not is_nil(f.follower_id) and + is_nil(f.followed_id) and (f.pending == false or a.manually_approves_followers == false) + ) + |> Repo.exists?() + end + + def any_local_following_remote_actor?(_), do: false + + defp remote_actor_identity_query( + remote_actor_id, + remote_actor_uri, + remote_actor_username, + remote_actor_domain + ) do + base_query = dynamic([f, _a], f.remote_actor_id == ^remote_actor_id) + + base_query = + if is_binary(remote_actor_uri) and remote_actor_uri != "" do + dynamic([f, a], ^base_query or a.uri == ^remote_actor_uri) + else + base_query + end + + if is_binary(remote_actor_username) and remote_actor_username != "" and + is_binary(remote_actor_domain) and remote_actor_domain != "" do + dynamic( + [f, a], + ^base_query or + (a.username == ^remote_actor_username and a.domain == ^remote_actor_domain) + ) + else + base_query + end + end + + @doc """ + Follow a remote actor (local user following remote user). + Sends ActivityPub Follow activity to the remote server. + """ + def follow_remote_actor(follower_id, remote_actor_id) do + require Logger + + with {:ok, follower} <- get_follower(follower_id), + {:ok, follower} <- Elektrine.ActivityPub.KeyManager.ensure_user_has_keys(follower), + {:ok, remote_actor} <- get_remote_actor(remote_actor_id), + {:ok, :not_following} <- check_not_already_following(follower_id, remote_actor_id) do + pending = remote_actor.manually_approves_followers == true + + # Build Follow activity + follow_activity = + Elektrine.ActivityPub.Builder.build_follow_activity(follower, remote_actor.uri) + + # Public actors often never send an Accept, so only keep pending when approval is required. + case %Follow{} + |> Ecto.Changeset.change(%{ + follower_id: follower_id, + remote_actor_id: remote_actor_id, + activitypub_id: follow_activity["id"], + pending: pending + }) + |> Repo.insert() do + {:ok, follow} -> + # Send to remote inbox + {:ok, _activity_record} = + Elektrine.ActivityPub.Publisher.publish(follow_activity, follower, [ + remote_actor.inbox_url + ]) + + Logger.info( + "Successfully sent Follow to #{remote_actor.username}@#{remote_actor.domain}" + ) + + maybe_join_remote_group_mirror(follower_id, remote_actor) + maybe_trigger_community_fetch(remote_actor) + notify_home_feed_policy_changed(follower_id, :followed_remote_actor) + + {:ok, follow} + + {:error, changeset} -> + Logger.error("Failed to create follow record: #{inspect(changeset)}") + {:error, :database_error} + end + else + {:error, :already_following} -> + {:error, :already_following} + + {:error, :follower_not_found} -> + Logger.error("Follower #{follower_id} not found") + {:error, :follower_not_found} + + {:error, :remote_actor_not_found} -> + Logger.error("Remote actor #{remote_actor_id} not found") + {:error, :remote_actor_not_found} + + {:error, reason} = error -> + Logger.error("Failed to follow remote actor: #{inspect(reason)}") + error + end + end + + defp maybe_join_remote_group_mirror( + user_id, + %Elektrine.ActivityPub.Actor{ + actor_type: "Group" + } = remote_actor + ) do + _ = {user_id, remote_actor} + :ok + end + + defp maybe_join_remote_group_mirror(_user_id, _remote_actor), do: :ok + + defp maybe_trigger_community_fetch(%Elektrine.ActivityPub.Actor{actor_type: "Group"}), do: :ok + + defp maybe_trigger_community_fetch(_remote_actor), do: :ok + + defp get_follower(follower_id) do + case Repo.get(Elektrine.Accounts.User, follower_id) do + nil -> {:error, :follower_not_found} + user -> {:ok, user} + end + end + + defp get_remote_actor(remote_actor_id) do + case Repo.get(Elektrine.ActivityPub.Actor, remote_actor_id) do + nil -> {:error, :remote_actor_not_found} + actor -> {:ok, actor} + end + end + + defp check_not_already_following(follower_id, remote_actor_id) do + case get_follow_to_remote_actor(follower_id, remote_actor_id) do + nil -> {:ok, :not_following} + %Follow{} -> {:error, :already_following} + end + end + + @doc """ + Unfollow a remote actor (local user unfollowing remote user). + Sends ActivityPub Undo activity to the remote server. + """ + def unfollow_remote_actor(follower_id, remote_actor_id) do + with follow when not is_nil(follow) <- + get_follow_to_remote_actor(follower_id, remote_actor_id), + follower <- Elektrine.Accounts.get_user!(follower_id), + remote_actor <- Repo.get(Elektrine.ActivityPub.Actor, remote_actor_id) do + # Build Undo activity + original_follow = %{ + "id" => follow.activitypub_id, + "type" => "Follow", + "actor" => "#{Elektrine.ActivityPub.instance_url()}/users/#{follower.username}", + "object" => remote_actor.uri + } + + undo_activity = Elektrine.ActivityPub.Builder.build_undo_activity(follower, original_follow) + + # Delete the follow from database + Follow + |> where( + [f], + f.follower_id == ^follower_id and f.remote_actor_id == ^remote_actor_id and + is_nil(f.followed_id) + ) + |> Repo.delete_all() + + # Send Undo to remote inbox + Elektrine.ActivityPub.Publisher.publish(undo_activity, follower, [remote_actor.inbox_url]) + notify_home_feed_policy_changed(follower_id, :unfollowed_remote_actor) + + {:ok, :unfollowed} + else + nil -> {:error, :not_following} + end + end + + @doc """ + Lists accepted remote followers for a user (for sending activities). + """ + def list_remote_followers(user_id) do + Follow + |> where( + [f], + f.followed_id == ^user_id and not is_nil(f.remote_actor_id) and f.pending == false + ) + |> Repo.all() + end + + @doc """ + Gets pending follow requests for a user (from remote actors). + """ + def get_pending_follow_requests(user_id) do + Follow + |> where( + [f], + f.followed_id == ^user_id and f.pending == true and not is_nil(f.remote_actor_id) + ) + |> join(:inner, [f], a in Elektrine.ActivityPub.Actor, on: f.remote_actor_id == a.id) + |> select([f, a], %{ + id: f.id, + remote_actor: a, + activitypub_id: f.activitypub_id, + inserted_at: f.inserted_at + }) + |> order_by([f], desc: f.inserted_at) + |> Repo.all() + end + + @doc """ + Counts pending follow requests for a user. + """ + def count_pending_follow_requests(user_id) do + Follow + |> where( + [f], + f.followed_id == ^user_id and f.pending == true and not is_nil(f.remote_actor_id) + ) + |> Repo.aggregate(:count, :id) + end + + @doc """ + Gets pending outgoing follow requests from a local user to remote actors. + """ + def get_pending_outgoing_follow_requests(user_id) do + Follow + |> where( + [f], + f.follower_id == ^user_id and f.pending == true and not is_nil(f.remote_actor_id) and + is_nil(f.followed_id) + ) + |> join(:inner, [f], a in Elektrine.ActivityPub.Actor, on: f.remote_actor_id == a.id) + |> select([f, a], %{ + id: f.id, + remote_actor: a, + activitypub_id: f.activitypub_id, + inserted_at: f.inserted_at + }) + |> order_by([f], desc: f.inserted_at) + |> Repo.all() + end + + @doc """ + Accepts a pending follow request. + """ + def accept_follow_request(follow_id) do + followed_id = pending_follow_owner_id(follow_id) + + result = + Follow + |> where([f], f.id == ^follow_id) + |> Repo.update_all(set: [pending: false]) + + broadcast_follow_requests_updated(followed_id) + result + end + + @doc """ + Rejects and deletes a pending follow request. + """ + def reject_follow_request(follow_id) do + followed_id = pending_follow_owner_id(follow_id) + + result = + Follow + |> where([f], f.id == ^follow_id) + |> Repo.delete_all() + + broadcast_follow_requests_updated(followed_id) + result + end + + defp pending_follow_owner_id(follow_id) do + Follow + |> where([f], f.id == ^follow_id) + |> select([f], f.followed_id) + |> Repo.one() + end + + defp broadcast_follow_requests_updated(user_id) when is_integer(user_id) do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}", + {:friend_requests_updated, count_pending_follow_requests(user_id)} + ) + end + + defp broadcast_follow_requests_updated(_user_id), do: :ok + + @doc """ + Accepts a follow request by activity ID. + """ + def accept_follow_by_activity_id(activity_id) do + # Get the follow before updating to broadcast to the user + follow = + Follow + |> where([f], f.activitypub_id == ^activity_id) + |> Repo.one() + + result = + Follow + |> where([f], f.activitypub_id == ^activity_id) + |> Repo.update_all(set: [pending: false]) + + # Broadcast follow acceptance to user's timeline if they're a local user + if follow && follow.follower_id do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{follow.follower_id}:timeline", + {:follow_accepted, follow.remote_actor_id} + ) + end + + if follow do + broadcast_follow_requests_updated(follow.followed_id) + end + + result + end + + @doc """ + Deletes a follow by activity ID. + """ + def delete_follow_by_activity_id(activity_id) do + follow = + Follow + |> where([f], f.activitypub_id == ^activity_id) + |> Repo.one() + + result = + Follow + |> where([f], f.activitypub_id == ^activity_id) + |> Repo.delete_all() + + if follow do + broadcast_follow_requests_updated(follow.followed_id) + end + + result + end + + @doc """ + Returns following status for multiple local users in batch. + Returns a list of {followed_id, :following | :pending | :not_following} tuples. + """ + def following_status_batch(follower_id, followed_ids) when is_list(followed_ids) do + if Enum.empty?(followed_ids) do + [] + else + follows = + Follow + |> where([f], f.follower_id == ^follower_id and f.followed_id in ^followed_ids) + |> select([f], {f.followed_id, f.pending}) + |> Repo.all() + |> Map.new() + + Enum.map(followed_ids, fn followed_id -> + case Map.get(follows, followed_id) do + nil -> {followed_id, :not_following} + false -> {followed_id, :following} + true -> {followed_id, :pending} + end + end) + end + end + + @doc """ + Returns following status for multiple remote actors in batch. + Returns a list of {remote_actor_id, :following | :pending | :not_following} tuples. + """ + def remote_following_status_batch(follower_id, remote_actor_ids) + when is_list(remote_actor_ids) do + if Enum.empty?(remote_actor_ids) do + [] + else + follows = + Follow + |> join(:inner, [f], a in Elektrine.ActivityPub.Actor, on: f.remote_actor_id == a.id) + |> where( + [f, _a], + f.follower_id == ^follower_id and f.remote_actor_id in ^remote_actor_ids and + is_nil(f.followed_id) + ) + |> select([f, a], {f.remote_actor_id, f.pending, a.manually_approves_followers}) + |> Repo.all() + |> Map.new(fn {actor_id, pending, manually_approves_followers} -> + {actor_id, {pending, manually_approves_followers}} + end) + + Enum.map(remote_actor_ids, fn actor_id -> + case Map.get(follows, actor_id) do + nil -> {actor_id, :not_following} + {false, _} -> {actor_id, :following} + {true, false} -> {actor_id, :following} + {true, true} -> {actor_id, :pending} + end + end) + end + end +end diff --git a/apps/elektrine/lib/elektrine/profiles/analytics_retention_worker.ex b/apps/elektrine/lib/elektrine/profiles/analytics_retention_worker.ex new file mode 100644 index 0000000..3f1130f --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/analytics_retention_worker.ex @@ -0,0 +1,17 @@ +defmodule Elektrine.Profiles.AnalyticsRetentionWorker do + @moduledoc """ + Prunes raw analytics rows after the configured retention windows. + """ + + use Oban.Worker, queue: :default, max_attempts: 1, priority: 9 + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{}) do + counts = Elektrine.Profiles.prune_analytics_retention() + Logger.info("Pruned raw analytics rows: #{inspect(counts)}") + + :ok + end +end diff --git a/apps/elektrine/lib/elektrine/profiles/custom_domain.ex b/apps/elektrine/lib/elektrine/profiles/custom_domain.ex new file mode 100644 index 0000000..11b0dbd --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/custom_domain.ex @@ -0,0 +1,91 @@ +defmodule Elektrine.Profiles.CustomDomain do + @moduledoc """ + Schema for user-managed custom profile domains. + """ + + use Ecto.Schema + import Ecto.Changeset + + @statuses ~w(pending verified) + @domain_regex ~r/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/ + + schema "profile_custom_domains" do + field :domain, :string + field :verification_token, :string + field :status, :string, default: "pending" + field :verified_at, :utc_datetime + field :last_checked_at, :utc_datetime + field :last_error, :string + field :failing_since, :utc_datetime + + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + def changeset(custom_domain, attrs) do + custom_domain + |> cast(attrs, [ + :domain, + :verification_token, + :status, + :verified_at, + :last_checked_at, + :last_error, + :failing_since, + :user_id + ]) + |> update_change(:domain, &normalize_domain/1) + |> validate_required([:domain, :verification_token, :status, :user_id]) + |> validate_length(:domain, max: 253) + |> validate_length(:verification_token, max: 255) + |> validate_length(:last_error, max: 255) + |> validate_format(:domain, @domain_regex, message: "must be a valid domain name") + |> validate_inclusion(:status, @statuses) + |> validate_change(:domain, &validate_profile_domain/2) + |> unique_constraint(:domain, + name: :profile_custom_domains_domain_ci_unique, + message: "is already claimed" + ) + |> foreign_key_constraint(:user_id) + end + + defp validate_profile_domain(:domain, domain) when is_binary(domain) do + cond do + reserved_profile_edge_domain?(domain) -> + [domain: "is reserved for profile routing"] + + String.starts_with?(domain, "www.") -> + [domain: "must be the root domain without www"] + + domain in Elektrine.Domains.supported_email_domains() -> + [domain: "is already managed by the system"] + + not is_nil(Elektrine.Domains.profile_base_domain_for_host(domain)) -> + [domain: "conflicts with an existing profile host"] + + true -> + [] + end + end + + defp validate_profile_domain(_field, _value), do: [] + + defp reserved_profile_edge_domain?(domain) do + edge_target = Elektrine.Domains.profile_custom_domain_edge_target() + + is_binary(edge_target) and domain in [edge_target, "www.#{edge_target}"] + end + + defp normalize_domain(nil), do: nil + + defp normalize_domain(domain) when is_binary(domain) do + domain + |> String.trim() + |> String.trim_leading(".") + |> String.trim_trailing(".") + |> String.downcase() + end + + defp normalize_domain(domain), do: domain +end diff --git a/apps/elektrine/lib/elektrine/profiles/custom_domains.ex b/apps/elektrine/lib/elektrine/profiles/custom_domains.ex new file mode 100644 index 0000000..7071272 --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/custom_domains.ex @@ -0,0 +1,369 @@ +defmodule Elektrine.Profiles.CustomDomains do + @moduledoc """ + User-owned custom profile domains with TXT-based verification. + """ + + import Ecto.Query, warn: false + + alias Elektrine.Accounts.User + alias Elektrine.Profiles.CustomDomain + alias Elektrine.Repo + + @pending_status "pending" + @verified_status "verified" + @verification_label "_elektrine-profile" + + # A verified domain that starts failing re-verification is kept verified for + # this grace window before demotion, so transient DNS hiccups don't take a + # working profile domain offline. + @verification_grace_period_seconds 72 * 60 * 60 + + @type lookup_result :: {:ok, [String.t()]} | {:error, term()} + + @doc """ + Behaviour for DNS TXT lookups during profile-domain verification. + """ + @callback lookup_txt(String.t()) :: lookup_result + + defmodule DNSResolver do + @moduledoc false + @behaviour Elektrine.Profiles.CustomDomains + + @impl true + def lookup_txt(host) when is_binary(host) do + host + |> String.to_charlist() + |> :inet_res.lookup(:in, :txt, timeout: 5_000) + |> Enum.map(&normalize_txt_record/1) + |> then(&{:ok, &1}) + rescue + error -> {:error, error} + end + + defp normalize_txt_record(record) when is_binary(record), do: record + + defp normalize_txt_record(record) when is_list(record) do + if Enum.all?(record, &is_integer/1) do + to_string(record) + else + Enum.map_join(record, "", &normalize_txt_record/1) + end + end + + defp normalize_txt_record(record), do: to_string(record) + end + + def list_user_custom_domains(user_id) when is_integer(user_id) do + CustomDomain + |> where(user_id: ^user_id) + |> order_by([d], asc: d.domain) + |> Repo.all() + end + + def list_user_custom_domains(_), do: [] + + @doc """ + Lists profile custom domains for the admin console, across all users. + + Mirrors `Elektrine.Email.CustomDomains.list_custom_domains_admin/4`: supports a + domain/owner search, a status filter (all/verified/pending/attention), and + pagination. Returns `{domains, total_count}` with the owning user preloaded. + """ + def list_custom_domains_admin( + search_query \\ "", + status_filter \\ "all", + page \\ 1, + per_page \\ 20 + ) do + normalized_search = normalize_admin_search(search_query) + normalized_status = normalize_admin_status_filter(status_filter) + safe_page = max(page, 1) + safe_per_page = max(per_page, 1) + + base_query = + CustomDomain + |> join(:left, [d], u in assoc(d, :user)) + |> maybe_filter_admin_status(normalized_status) + |> maybe_search_admin(normalized_search) + + total_count = Repo.aggregate(base_query, :count, :id) + + domains = + base_query + |> order_by([d, _u], desc: d.inserted_at, asc: d.domain) + |> preload([_d, u], user: u) + |> limit(^safe_per_page) + |> offset(^((safe_page - 1) * safe_per_page)) + |> Repo.all() + + {domains, total_count} + end + + @doc """ + Aggregate counts of profile custom domains for the admin overview. + """ + def custom_domain_admin_stats do + total = Repo.aggregate(CustomDomain, :count, :id) + + verified = + Repo.aggregate(from(d in CustomDomain, where: d.status == ^@verified_status), :count, :id) + + pending = + Repo.aggregate(from(d in CustomDomain, where: d.status == ^@pending_status), :count, :id) + + attention = + Repo.aggregate( + from(d in CustomDomain, + where: (not is_nil(d.last_error) and d.last_error != "") or not is_nil(d.failing_since) + ), + :count, + :id + ) + + %{total: total, verified: verified, pending: pending, attention: attention} + end + + defp maybe_search_admin(query, ""), do: query + + defp maybe_search_admin(query, search_query) do + search_pattern = "%#{search_query}%" + + from([d, u] in query, + where: + ilike(d.domain, ^search_pattern) or + ilike(fragment("coalesce(?, '')", u.username), ^search_pattern) + ) + end + + defp maybe_filter_admin_status(query, "all"), do: query + + defp maybe_filter_admin_status(query, "attention") do + from([d, _u] in query, + where: (not is_nil(d.last_error) and d.last_error != "") or not is_nil(d.failing_since) + ) + end + + defp maybe_filter_admin_status(query, status) do + from([d, _u] in query, where: d.status == ^status) + end + + defp normalize_admin_search(search_query) when is_binary(search_query), + do: String.trim(search_query) + + defp normalize_admin_search(_), do: "" + + defp normalize_admin_status_filter(status_filter) + when status_filter in ["all", "pending", "verified", "attention"], + do: status_filter + + defp normalize_admin_status_filter(_), do: "all" + + @doc """ + Returns custom domains due for a periodic DNS re-verification, oldest check + first so the stalest records are revisited before newer ones. + """ + def list_custom_domains_for_recheck(limit \\ 500) when is_integer(limit) do + CustomDomain + |> order_by([d], asc_nulls_first: d.last_checked_at) + |> limit(^max(limit, 0)) + |> Repo.all() + end + + def get_custom_domain(id, user_id) when is_integer(id) and is_integer(user_id) do + CustomDomain + |> where(id: ^id, user_id: ^user_id) + |> Repo.one() + end + + def get_custom_domain(_, _), do: nil + + def get_verified_custom_domain(domain) when is_binary(domain) do + normalized_domain = normalize_host(domain) + + CustomDomain + |> where( + [d], + fragment("lower(?)", d.domain) == ^normalized_domain and d.status == ^@verified_status + ) + |> preload(:user) + |> Repo.one() + end + + def get_verified_custom_domain(_), do: nil + + def get_verified_custom_domain_for_host(host) when is_binary(host) do + normalized_host = normalize_host(host) + + cond do + normalized_host == "" -> + nil + + String.starts_with?(normalized_host, "www.") -> + get_verified_custom_domain(String.trim_leading(normalized_host, "www.")) + + true -> + get_verified_custom_domain(normalized_host) + end + end + + def get_verified_custom_domain_for_host(_), do: nil + + def verified_domains_for_user(%User{id: user_id}), do: verified_domains_for_user(user_id) + + def verified_domains_for_user(user_id) when is_integer(user_id) do + CustomDomain + |> where(user_id: ^user_id, status: ^@verified_status) + |> order_by([d], asc: d.domain) + |> Repo.all() + end + + def verified_domains_for_user(_), do: [] + + def preferred_verified_domain_for_user(%User{id: user_id}), + do: preferred_verified_domain_for_user(user_id) + + def preferred_verified_domain_for_user(user_id) when is_integer(user_id) do + case verified_domains_for_user(user_id) do + [%CustomDomain{} = custom_domain] -> custom_domain + _ -> nil + end + end + + def preferred_verified_domain_for_user(_), do: nil + + def create_custom_domain(%User{id: user_id}, attrs), do: create_custom_domain(user_id, attrs) + + def create_custom_domain(user_id, attrs) when is_integer(user_id) and is_map(attrs) do + %CustomDomain{} + |> CustomDomain.changeset(%{ + domain: attrs[:domain] || attrs["domain"], + verification_token: generate_verification_token(), + status: @pending_status, + user_id: user_id + }) + |> Repo.insert() + end + + def verify_custom_domain(%CustomDomain{} = custom_domain) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + expected_value = verification_value(custom_domain) + verification_host = verification_host(custom_domain) + + case lookup_txt(verification_host) do + {:ok, values} -> + if expected_value in values do + custom_domain + |> CustomDomain.changeset(%{ + status: @verified_status, + verified_at: custom_domain.verified_at || now, + last_checked_at: now, + last_error: nil, + failing_since: nil + }) + |> Repo.update() + else + persist_verification_failure(custom_domain, now, "Verification TXT record not found") + end + + {:error, reason} -> + persist_verification_failure(custom_domain, now, "DNS lookup failed: #{inspect(reason)}") + end + end + + def delete_custom_domain(%CustomDomain{} = custom_domain) do + Repo.delete(custom_domain) + end + + def verification_host(%CustomDomain{domain: domain}) when is_binary(domain) do + "#{@verification_label}.#{domain}" + end + + def verification_host(_), do: nil + + def verification_value(%CustomDomain{verification_token: token}) when is_binary(token) do + "elektrine-profile-verification=#{token}" + end + + def verification_value(_), do: nil + + def dns_records_for_custom_domain(%CustomDomain{} = custom_domain) do + [ + %{ + type: "TXT", + host: verification_host(custom_domain), + value: verification_value(custom_domain), + label: "Ownership verification", + priority: nil + } + | routing_dns_records_for_custom_domain(custom_domain.domain) + ] + end + + defp persist_verification_failure(custom_domain, now, error_message) do + custom_domain + |> CustomDomain.changeset(verification_failure_attrs(custom_domain, now, error_message)) + |> Repo.update() + end + + # Pending domains simply record the failure. Verified domains stay verified + # until they have been failing continuously past the grace window, at which + # point they are demoted back to pending. + defp verification_failure_attrs(custom_domain, now, error_message) do + base = %{last_checked_at: now, last_error: error_message} + + if custom_domain.status == @verified_status do + failing_since = custom_domain.failing_since || now + + if DateTime.diff(now, failing_since, :second) >= @verification_grace_period_seconds do + Map.merge(base, %{status: @pending_status, verified_at: nil, failing_since: nil}) + else + Map.put(base, :failing_since, failing_since) + end + else + Map.merge(base, %{status: @pending_status, failing_since: nil}) + end + end + + defp generate_verification_token do + 24 + |> :crypto.strong_rand_bytes() + |> Base.url_encode64(padding: false) + end + + defp lookup_txt(host) do + txt_resolver().lookup_txt(host) + end + + defp txt_resolver do + Application.get_env(:elektrine, :profile_custom_domain_txt_resolver, DNSResolver) + end + + defp normalize_host(host) when is_binary(host) do + host + |> String.trim() + |> String.downcase() + |> String.split(":", parts: 2) + |> List.first() + end + + defp routing_dns_records_for_custom_domain(domain) do + [ + %{ + type: "ALIAS", + host: domain, + value: Elektrine.Domains.profile_custom_domain_routing_target(), + label: "Stable routing target for the root domain using apex alias flattening.", + priority: nil + } + ] ++ + [ + %{ + type: "CNAME", + host: "www.#{domain}", + value: domain, + label: "Optional www redirect target", + priority: nil + } + ] + end +end diff --git a/apps/elektrine/lib/elektrine/profiles/follow.ex b/apps/elektrine/lib/elektrine/profiles/follow.ex new file mode 100644 index 0000000..7684ea5 --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/follow.ex @@ -0,0 +1,67 @@ +defmodule Elektrine.Profiles.Follow do + @moduledoc """ + Schema representing follower/followed relationships between users. + Prevents self-following and enforces unique follow relationships. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "follows" do + belongs_to :follower, Elektrine.Accounts.User + belongs_to :followed, Elektrine.Accounts.User + belongs_to :remote_actor, Elektrine.ActivityPub.Actor + + field :activitypub_id, :string + field :pending, :boolean, default: false + + timestamps() + end + + @doc false + def changeset(follow, attrs) do + follow + |> cast(attrs, [:follower_id, :followed_id, :remote_actor_id, :activitypub_id, :pending]) + |> validate_required_relationship() + |> validate_not_self_follow() + |> unique_constraint([:follower_id, :followed_id]) + end + + # Validate that the relationship makes sense + defp validate_required_relationship(changeset) do + follower_id = get_field(changeset, :follower_id) + followed_id = get_field(changeset, :followed_id) + remote_actor_id = get_field(changeset, :remote_actor_id) + + cond do + # Case 1: Local user follows local user (normal follow) + not is_nil(follower_id) and not is_nil(followed_id) and is_nil(remote_actor_id) -> + changeset + + # Case 2: Remote user follows local user + is_nil(follower_id) and not is_nil(followed_id) and not is_nil(remote_actor_id) -> + changeset + + # Case 3: Local user follows remote user + not is_nil(follower_id) and is_nil(followed_id) and not is_nil(remote_actor_id) -> + changeset + + true -> + add_error( + changeset, + :base, + "invalid follow relationship: must be local-local, remote-local, or local-remote" + ) + end + end + + defp validate_not_self_follow(changeset) do + follower_id = get_field(changeset, :follower_id) + followed_id = get_field(changeset, :followed_id) + + if follower_id == followed_id do + add_error(changeset, :followed_id, "cannot follow yourself") + else + changeset + end + end +end diff --git a/apps/elektrine/lib/elektrine/profiles/per_site_identities.ex b/apps/elektrine/lib/elektrine/profiles/per_site_identities.ex new file mode 100644 index 0000000..2415e9f --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/per_site_identities.ex @@ -0,0 +1,100 @@ +defmodule Elektrine.Profiles.PerSiteIdentities do + @moduledoc """ + Context functions for per-site portable domain identities. + """ + + import Ecto.Query, warn: false + + alias Elektrine.Accounts.User + alias Elektrine.Domains + alias Elektrine.Profiles + alias Elektrine.Profiles.PerSiteIdentity + alias Elektrine.Repo + + def list_user_per_site_identities(%User{id: user_id}), + do: list_user_per_site_identities(user_id) + + def list_user_per_site_identities(user_id) when is_integer(user_id) do + PerSiteIdentity + |> where(user_id: ^user_id) + |> order_by([i], asc: i.base_domain, asc: i.site_key) + |> Repo.all() + end + + def list_user_per_site_identities(_), do: [] + + def get_per_site_identity(id, user_id) when is_integer(id) and is_integer(user_id) do + PerSiteIdentity + |> where(id: ^id, user_id: ^user_id) + |> Repo.one() + end + + def get_per_site_identity(_, _), do: nil + + def create_per_site_identity(%User{id: user_id} = user, attrs) when is_map(attrs) do + attrs = normalize_attrs(attrs) + + with :ok <- validate_base_domain(user, attrs["base_domain"] || attrs[:base_domain]) do + %PerSiteIdentity{} + |> PerSiteIdentity.changeset(put_user_id(attrs, user_id)) + |> Repo.insert() + end + end + + def update_per_site_identity(%PerSiteIdentity{} = identity, attrs) when is_map(attrs) do + identity + |> PerSiteIdentity.changeset(attrs) + |> Repo.update() + end + + def delete_per_site_identity(%PerSiteIdentity{} = identity), do: Repo.delete(identity) + + def available_base_domains(%User{} = user) do + [built_in_domain(user) | Enum.map(Profiles.verified_domains_for_user(user), & &1.domain)] + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + def built_in_domain(%User{} = user) do + handle = user.handle || user.username + + if is_binary(handle) and String.trim(handle) != "" do + "#{String.trim(handle)}.#{Domains.default_profile_domain()}" |> String.downcase() + end + end + + defp validate_base_domain(user, base_domain) when is_binary(base_domain) do + normalized = + base_domain + |> String.trim() + |> String.trim_leading(".") + |> String.trim_trailing(".") + |> String.downcase() + + if normalized in available_base_domains(user) do + :ok + else + {:error, :invalid_base_domain} + end + end + + defp validate_base_domain(_, _), do: {:error, :invalid_base_domain} + + defp normalize_attrs(attrs) do + attrs + |> Enum.map(fn {key, value} -> {normalize_key(key), value} end) + |> Map.new() + end + + defp normalize_key(key) when is_binary(key), do: key + defp normalize_key(key) when is_atom(key), do: key + defp normalize_key(key), do: to_string(key) + + defp put_user_id(attrs, user_id) do + if Enum.any?(Map.keys(attrs), &is_binary/1) do + Map.put(attrs, "user_id", user_id) + else + Map.put(attrs, :user_id, user_id) + end + end +end diff --git a/apps/elektrine/lib/elektrine/profiles/per_site_identity.ex b/apps/elektrine/lib/elektrine/profiles/per_site_identity.ex new file mode 100644 index 0000000..ae57eab --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/per_site_identity.ex @@ -0,0 +1,106 @@ +defmodule Elektrine.Profiles.PerSiteIdentity do + @moduledoc """ + A per-site domain identity derived from a user's OwnRoot. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias Elektrine.OwnRoot + + @site_key_regex ~r/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ + @domain_regex ~r/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/ + + schema "profile_per_site_identities" do + field :site_key, :string + field :base_domain, :string + field :domain, :string + field :subject, :string + field :did, :string + field :email_alias, :string + field :display_name, :string + field :avatar, :string + field :claims, :map, default: %{} + field :enabled, :boolean, default: true + + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + def changeset(identity, attrs) do + identity + |> cast(attrs, [ + :site_key, + :base_domain, + :display_name, + :avatar, + :claims, + :enabled, + :user_id + ]) + |> normalize_change(:site_key) + |> normalize_change(:base_domain) + |> normalize_claims() + |> validate_required([:site_key, :base_domain, :user_id]) + |> validate_format(:site_key, @site_key_regex, + message: "must use lowercase letters, numbers, or hyphens" + ) + |> validate_format(:base_domain, @domain_regex, message: "must be a valid domain name") + |> validate_length(:site_key, max: 63) + |> validate_length(:base_domain, max: 253) + |> validate_length(:domain, max: 253) + |> validate_length(:subject, max: 255) + |> validate_length(:did, max: 255) + |> validate_length(:email_alias, max: 255) + |> validate_length(:display_name, max: 120) + |> validate_length(:avatar, max: 500) + |> put_derived_fields() + |> unique_constraint(:site_key, + name: :profile_per_site_identities_user_base_site_unique, + message: "already exists for that domain" + ) + |> foreign_key_constraint(:user_id) + end + + defp normalize_change(changeset, field) do + update_change(changeset, field, fn + value when is_binary(value) -> + value + |> String.trim() + |> String.trim_leading(".") + |> String.trim_trailing(".") + |> String.downcase() + + value -> + value + end) + end + + defp normalize_claims(changeset) do + case get_field(changeset, :claims) do + nil -> put_change(changeset, :claims, %{}) + claims when is_map(claims) -> changeset + _ -> add_error(changeset, :claims, "must be an object") + end + end + + defp put_derived_fields(changeset) do + site_key = get_field(changeset, :site_key) + base_domain = get_field(changeset, :base_domain) + + if valid_string?(site_key) and valid_string?(base_domain) do + domain = "#{site_key}.#{base_domain}" + + changeset + |> put_change(:domain, domain) + |> put_change(:subject, OwnRoot.subject(domain)) + |> put_change(:did, OwnRoot.did_for_domain(domain)) + |> put_change(:email_alias, "#{site_key}@#{base_domain}") + else + changeset + end + end + + defp valid_string?(value), do: is_binary(value) and String.trim(value) != "" +end diff --git a/apps/elektrine/lib/elektrine/profiles/profile_link.ex b/apps/elektrine/lib/elektrine/profiles/profile_link.ex new file mode 100644 index 0000000..a2f6b15 --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/profile_link.ex @@ -0,0 +1,315 @@ +defmodule Elektrine.Profiles.ProfileLink do + @moduledoc """ + Schema for social media and website links displayed on user profiles. + Supports multiple platforms with custom icons, tracking, and URL safety validation. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "profile_links" do + field :title, :string + field :url, :string + field :description, :string + field :icon, :string, default: "hero-link" + field :platform, :string + field :position, :integer, default: 0 + field :clicks, :integer, default: 0 + field :impressions, :integer, default: 0 + field :last_clicked_at, :utc_datetime + field :is_active, :boolean, default: true + field :pinned, :boolean, default: false + field :active_from, :utc_datetime + field :active_until, :utc_datetime + field :last_checked_at, :utc_datetime + field :last_check_status, :string + field :last_check_error, :string + field :section, :string + field :thumbnail_url, :string + # "circular", "full_width", or nil (use profile default) + field :display_style, :string + # "none", "glow", "pulse", "border", "shine", or nil (use profile default) + field :highlight_effect, :string + + belongs_to :profile, Elektrine.Profiles.UserProfile + + timestamps() + end + + @doc false + def changeset(link, attrs) do + # Convert all keys to atoms to ensure consistency + attrs = + for {key, value} <- attrs, into: %{} do + {to_existing_atom_safe(key), value} + end + + link + |> cast(attrs, [ + :profile_id, + :title, + :url, + :description, + :icon, + :platform, + :position, + :is_active, + :pinned, + :active_from, + :active_until, + :impressions, + :last_clicked_at, + :last_checked_at, + :last_check_status, + :last_check_error, + :section, + :thumbnail_url, + :display_style, + :highlight_effect + ]) + |> update_change(:url, &String.trim/1) + |> validate_required([:profile_id, :title, :url]) + |> validate_length(:title, max: 50) + |> validate_length(:description, max: 100) + |> validate_number(:clicks, greater_than_or_equal_to: 0) + |> validate_number(:impressions, greater_than_or_equal_to: 0) + |> validate_inclusion(:last_check_status, ["ok", "broken", "unchecked", nil], allow_nil: true) + |> validate_active_window() + |> validate_url() + |> validate_inclusion(:display_style, ["circular", "full_width", nil], allow_nil: true) + |> validate_inclusion(:highlight_effect, ["none", "glow", "pulse", "border", "shine", nil], + allow_nil: true + ) + |> validate_inclusion(:platform, [ + "twitter", + "instagram", + "github", + "linkedin", + "youtube", + "tiktok", + "discord", + "spotify", + "soundcloud", + "twitch", + "facebook", + "snapchat", + "telegram", + "whatsapp", + "email", + "phone", + "website", + "blog", + "portfolio", + "custom", + "paypal", + "adobe", + "vk", + "pinterest", + "reddit", + "steam", + "bitcoin", + "ethereum", + "gitlab", + "threads", + "signal", + "litecoin" + ]) + |> foreign_key_constraint(:profile_id) + end + + # Safely convert keys to atoms, only for known schema fields + # Unknown keys are kept as strings to prevent atom exhaustion DoS + defp to_existing_atom_safe(key) when is_atom(key), do: key + + defp to_existing_atom_safe(key) when is_binary(key) do + case key do + "profile_id" -> :profile_id + "title" -> :title + "url" -> :url + "description" -> :description + "icon" -> :icon + "platform" -> :platform + "position" -> :position + "is_active" -> :is_active + "pinned" -> :pinned + "active_from" -> :active_from + "active_until" -> :active_until + "impressions" -> :impressions + "last_clicked_at" -> :last_clicked_at + "last_checked_at" -> :last_checked_at + "last_check_status" -> :last_check_status + "last_check_error" -> :last_check_error + "section" -> :section + "thumbnail_url" -> :thumbnail_url + "display_style" -> :display_style + "highlight_effect" -> :highlight_effect + # Return string key for unknown fields - do not create atoms + _ -> key + end + end + + defp validate_active_window(changeset) do + active_from = get_field(changeset, :active_from) + active_until = get_field(changeset, :active_until) + + if active_from && active_until && DateTime.compare(active_from, active_until) != :lt do + add_error(changeset, :active_until, "must be after active from") + else + changeset + end + end + + # Validate URL to prevent javascript:, header splitting, and malformed redirects. + defp validate_url(changeset) do + case get_change(changeset, :url) do + nil -> + changeset + + url when is_binary(url) -> + validate_profile_link_url(changeset, url) + + _ -> + add_error(changeset, :url, "must be a valid URL, email, or phone number") + end + end + + defp validate_profile_link_url(changeset, url) do + if String.contains?(url, ["\r", "\n", "\0"]) or Regex.match?(~r/[\x00-\x1F\x7F\s]/, url) do + add_error(changeset, :url, "must not contain whitespace or control characters") + else + case URI.parse(url) do + %URI{scheme: scheme, host: host, userinfo: nil} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + changeset + + %URI{scheme: scheme, path: path} when scheme in ["mailto", "tel"] -> + validate_contact_link_url(changeset, scheme, path) + + _ -> + add_error(changeset, :url, "must be a valid URL, email, or phone number") + end + end + rescue + _ -> add_error(changeset, :url, "must be a valid URL, email, or phone number") + end + + defp validate_contact_link_url(changeset, "mailto", path) when is_binary(path) do + if Regex.match?(~r/^[^@<>"']+@[^@<>"']+\.[^@<>"']+$/, path) do + changeset + else + add_error(changeset, :url, "must be a valid email link") + end + end + + defp validate_contact_link_url(changeset, "tel", path) when is_binary(path) do + if Regex.match?(~r/^\+?[0-9().-]{3,32}$/, path) do + changeset + else + add_error(changeset, :url, "must be a valid phone link") + end + end + + defp validate_contact_link_url(changeset, _scheme, _path) do + add_error(changeset, :url, "must be a valid URL, email, or phone number") + end + + @doc """ + Gets the brand color for a platform. + """ + def get_platform_color(platform) do + Elektrine.Theme.platform_brand_color(platform) + end + + @doc """ + Gets the SVG content for a platform icon. + """ + def get_platform_svg(platform) do + case platform do + "youtube" -> + ~s|| + + "discord" -> + ~s|| + + "instagram" -> + ~s|| + + "twitter" -> + ~s|| + + "tiktok" -> + ~s|| + + "telegram" -> + ~s|| + + "soundcloud" -> + ~s|| + + "paypal" -> + ~s|| + + "github" -> + ~s|| + + "adobe" -> + ~s|| + + "vk" -> + ~s|| + + "threads" -> + ~s|| + + "twitch" -> + ~s|| + + "reddit" -> + ~s|| + + "spotify" -> + ~s|| + + "pinterest" -> + ~s|| + + "linkedin" -> + ~s|| + + "steam" -> + ~s|| + + "bitcoin" -> + ~s|| + + "ethereum" -> + ~s|| + + "gitlab" -> + ~s|| + + "facebook" -> + ~s|| + + "whatsapp" -> + ~s|| + + "signal" -> + ~s|| + + "litecoin" -> + ~s|| + + "snapchat" -> + ~s|| + + "email" -> + ~s|| + + "website" -> + ~s|| + + _ -> + ~s|| + end + end +end diff --git a/apps/elektrine/lib/elektrine/profiles/profile_site_visit.ex b/apps/elektrine/lib/elektrine/profiles/profile_site_visit.ex new file mode 100644 index 0000000..b8a5e4c --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/profile_site_visit.ex @@ -0,0 +1,59 @@ +defmodule Elektrine.Profiles.ProfileSiteVisit do + @moduledoc """ + Schema for page-level visits to profile and custom-domain sites. + """ + + use Ecto.Schema + import Ecto.Changeset + + schema "profile_site_visits" do + field :visitor_id, :string + field :ip_address, :string + field :user_agent, :string + field :referer, :string + field :request_host, :string + field :request_path, :string + + belongs_to :profile_user, Elektrine.Accounts.User + belongs_to :viewer_user, Elektrine.Accounts.User + + timestamps(updated_at: false) + end + + @doc false + def changeset(profile_site_visit, attrs) do + profile_site_visit + |> cast(attrs, [ + :profile_user_id, + :viewer_user_id, + :visitor_id, + :ip_address, + :user_agent, + :referer, + :request_host, + :request_path + ]) + |> validate_required([:profile_user_id, :request_host, :request_path]) + |> validate_at_least_one_visitor_identifier() + |> foreign_key_constraint(:profile_user_id) + |> foreign_key_constraint(:viewer_user_id) + end + + defp validate_at_least_one_visitor_identifier(changeset) do + viewer_user_id = get_field(changeset, :viewer_user_id) + visitor_id = get_field(changeset, :visitor_id) + ip_address = get_field(changeset, :ip_address) + + if is_nil(viewer_user_id) && blank?(visitor_id) && blank?(ip_address) do + add_error( + changeset, + :visitor_id, + "must have either viewer_user_id, visitor_id, or ip_address" + ) + else + changeset + end + end + + defp blank?(value), do: value in [nil, ""] +end diff --git a/apps/elektrine/lib/elektrine/profiles/profile_view.ex b/apps/elektrine/lib/elektrine/profiles/profile_view.ex new file mode 100644 index 0000000..b677665 --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/profile_view.ex @@ -0,0 +1,52 @@ +defmodule Elektrine.Profiles.ProfileView do + @moduledoc """ + Schema for tracking profile page views and visitor analytics. + Records both authenticated users and anonymous sessions with IP, user agent, and referrer information. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "profile_views" do + field :viewer_session_id, :string + field :ip_address, :string + field :user_agent, :string + field :referer, :string + + belongs_to :profile_user, Elektrine.Accounts.User + belongs_to :viewer_user, Elektrine.Accounts.User + + timestamps(updated_at: false) + end + + @doc false + def changeset(profile_view, attrs) do + profile_view + |> cast(attrs, [ + :profile_user_id, + :viewer_user_id, + :viewer_session_id, + :ip_address, + :user_agent, + :referer + ]) + |> validate_required([:profile_user_id]) + |> validate_at_least_one_viewer_identifier() + |> foreign_key_constraint(:profile_user_id) + |> foreign_key_constraint(:viewer_user_id) + end + + defp validate_at_least_one_viewer_identifier(changeset) do + viewer_user_id = get_field(changeset, :viewer_user_id) + viewer_session_id = get_field(changeset, :viewer_session_id) + + if is_nil(viewer_user_id) && is_nil(viewer_session_id) do + add_error( + changeset, + :viewer_session_id, + "must have either viewer_user_id or viewer_session_id" + ) + else + changeset + end + end +end diff --git a/apps/elektrine/lib/elektrine/profiles/profile_widget.ex b/apps/elektrine/lib/elektrine/profiles/profile_widget.ex new file mode 100644 index 0000000..44ab6e5 --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/profile_widget.ex @@ -0,0 +1,60 @@ +defmodule Elektrine.Profiles.ProfileWidget do + @moduledoc """ + Schema for embeddable widgets on user profiles. + Supports text, images, Spotify, YouTube, GitHub stats, and Discord status widgets with customizable settings. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "profile_widgets" do + field :widget_type, :string + field :title, :string + field :content, :string + field :url, :string + field :position, :integer, default: 0 + field :is_active, :boolean, default: true + field :settings, :map, default: %{} + + belongs_to :profile, Elektrine.Profiles.UserProfile + + timestamps() + end + + @widget_types ~w(text image spotify youtube github_stats discord_status) + + def changeset(widget, attrs) do + widget + |> cast(attrs, [ + :profile_id, + :widget_type, + :title, + :content, + :url, + :position, + :is_active, + :settings + ]) + |> validate_required([:profile_id, :widget_type]) + |> validate_inclusion(:widget_type, @widget_types) + |> validate_length(:title, max: 200) + |> validate_length(:content, max: 5000) + |> validate_discord_status_content() + |> foreign_key_constraint(:profile_id) + end + + def widget_types, do: @widget_types + + defp validate_discord_status_content(changeset) do + case {get_field(changeset, :widget_type), get_field(changeset, :content)} do + {"discord_status", content} when is_binary(content) -> + if String.match?(content, ~r/^[0-9]{17,20}$/) do + changeset + else + add_error(changeset, :content, "must be a valid Discord user ID") + end + + _ -> + changeset + end + end +end diff --git a/apps/elektrine/lib/elektrine/profiles/public_site_analytics_rollup_worker.ex b/apps/elektrine/lib/elektrine/profiles/public_site_analytics_rollup_worker.ex new file mode 100644 index 0000000..8aa7172 --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/public_site_analytics_rollup_worker.ex @@ -0,0 +1,33 @@ +defmodule Elektrine.Profiles.PublicSiteAnalyticsRollupWorker do + @moduledoc """ + Refreshes public site analytics rollups used by domain analytics pages. + """ + + use Oban.Worker, queue: :default, max_attempts: 1, priority: 8 + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{args: args}) do + days = + args + |> Map.get("days", 2) + |> parse_positive_int(2) + + counts = Elektrine.Profiles.refresh_public_site_analytics_rollups(days: days) + Logger.info("Refreshed public site analytics rollups for #{days} days: #{inspect(counts)}") + + :ok + end + + defp parse_positive_int(value, _default) when is_integer(value) and value > 0, do: value + + defp parse_positive_int(value, default) when is_binary(value) do + case Integer.parse(value) do + {int, ""} when int > 0 -> int + _ -> default + end + end + + defp parse_positive_int(_value, default), do: default +end diff --git a/apps/elektrine/lib/elektrine/profiles/site_page_visit.ex b/apps/elektrine/lib/elektrine/profiles/site_page_visit.ex new file mode 100644 index 0000000..200b915 --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/site_page_visit.ex @@ -0,0 +1,61 @@ +defmodule Elektrine.Profiles.SitePageVisit do + @moduledoc """ + Schema for site-wide HTML page visits across public app and profile routes. + """ + + use Ecto.Schema + import Ecto.Changeset + + schema "site_page_visits" do + field :visitor_id, :string + field :ip_address, :string + field :user_agent, :string + field :referer, :string + field :session_id, :string + field :request_host, :string + field :request_path, :string + field :status, :integer + + belongs_to :viewer_user, Elektrine.Accounts.User + + timestamps(updated_at: false) + end + + @doc false + def changeset(site_page_visit, attrs) do + site_page_visit + |> cast(attrs, [ + :viewer_user_id, + :visitor_id, + :ip_address, + :user_agent, + :referer, + :session_id, + :request_host, + :request_path, + :status + ]) + |> validate_required([:session_id, :request_host, :request_path, :status]) + |> validate_number(:status, greater_than_or_equal_to: 100, less_than: 600) + |> validate_at_least_one_visitor_identifier() + |> foreign_key_constraint(:viewer_user_id) + end + + defp validate_at_least_one_visitor_identifier(changeset) do + viewer_user_id = get_field(changeset, :viewer_user_id) + visitor_id = get_field(changeset, :visitor_id) + ip_address = get_field(changeset, :ip_address) + + if is_nil(viewer_user_id) && blank?(visitor_id) && blank?(ip_address) do + add_error( + changeset, + :visitor_id, + "must have either viewer_user_id, visitor_id, or ip_address" + ) + else + changeset + end + end + + defp blank?(value), do: value in [nil, ""] +end diff --git a/apps/elektrine/lib/elektrine/profiles/site_session.ex b/apps/elektrine/lib/elektrine/profiles/site_session.ex new file mode 100644 index 0000000..bba7ab8 --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/site_session.ex @@ -0,0 +1,83 @@ +defmodule Elektrine.Profiles.SiteSession do + @moduledoc """ + Rollup schema for site-wide visitor sessions. + """ + + use Ecto.Schema + import Ecto.Changeset + + schema "site_sessions" do + field :session_id, :string + field :visitor_id, :string + field :ip_address, :string + field :user_agent, :string + field :referer, :string + field :entry_host, :string + field :entry_path, :string + field :exit_host, :string + field :exit_path, :string + field :page_views, :integer, default: 0 + field :started_at, :utc_datetime + field :last_seen_at, :utc_datetime + field :duration_seconds, :integer, default: 0 + + belongs_to :viewer_user, Elektrine.Accounts.User + + timestamps() + end + + @doc false + def changeset(site_session, attrs) do + site_session + |> cast(attrs, [ + :session_id, + :viewer_user_id, + :visitor_id, + :ip_address, + :user_agent, + :referer, + :entry_host, + :entry_path, + :exit_host, + :exit_path, + :page_views, + :started_at, + :last_seen_at, + :duration_seconds + ]) + |> validate_required([ + :session_id, + :entry_host, + :entry_path, + :exit_host, + :exit_path, + :page_views, + :started_at, + :last_seen_at, + :duration_seconds + ]) + |> validate_number(:page_views, greater_than: 0) + |> validate_number(:duration_seconds, greater_than_or_equal_to: 0) + |> validate_at_least_one_visitor_identifier() + |> unique_constraint(:session_id) + |> foreign_key_constraint(:viewer_user_id) + end + + defp validate_at_least_one_visitor_identifier(changeset) do + viewer_user_id = get_field(changeset, :viewer_user_id) + visitor_id = get_field(changeset, :visitor_id) + ip_address = get_field(changeset, :ip_address) + + if is_nil(viewer_user_id) && blank?(visitor_id) && blank?(ip_address) do + add_error( + changeset, + :visitor_id, + "must have either viewer_user_id, visitor_id, or ip_address" + ) + else + changeset + end + end + + defp blank?(value), do: value in [nil, ""] +end diff --git a/apps/elektrine/lib/elektrine/profiles/static_site_deploy.ex b/apps/elektrine/lib/elektrine/profiles/static_site_deploy.ex new file mode 100644 index 0000000..e8b44e7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/static_site_deploy.ex @@ -0,0 +1,63 @@ +defmodule Elektrine.Profiles.StaticSiteDeploy do + @moduledoc """ + Immutable deploy history entry for a linked static site. + Successful rows can carry a snapshot ZIP for rollback. + """ + + use Ecto.Schema + import Ecto.Changeset + + schema "static_site_deploys" do + field :status, :string + field :trigger, :string, default: "manual" + field :repo_owner, :string + field :repo_name, :string + field :branch, :string + field :site_dir, :string + field :commit_sha, :string + field :commit_url, :string + field :commit_message, :string + field :log, :string + field :error, :string + field :snapshot_storage_key, :string + field :file_count, :integer, default: 0 + field :storage_bytes, :integer, default: 0 + field :deployed_at, :utc_datetime + + belongs_to :user, Elektrine.Accounts.User + belongs_to :deployment, Elektrine.Profiles.StaticSiteDeployment + + timestamps() + end + + def changeset(deploy, attrs) do + deploy + |> cast(attrs, [ + :user_id, + :deployment_id, + :status, + :trigger, + :repo_owner, + :repo_name, + :branch, + :site_dir, + :commit_sha, + :commit_url, + :commit_message, + :log, + :error, + :snapshot_storage_key, + :file_count, + :storage_bytes, + :deployed_at + ]) + |> validate_required([:user_id, :deployment_id, :status, :trigger]) + |> validate_inclusion(:status, ["queued", "deploying", "deployed", "failed", "rolled_back"]) + |> validate_inclusion(:trigger, ["manual", "github", "webhook", "rollback"]) + |> validate_length(:commit_sha, max: 80) + |> validate_number(:file_count, greater_than_or_equal_to: 0) + |> validate_number(:storage_bytes, greater_than_or_equal_to: 0) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:deployment_id) + end +end diff --git a/apps/elektrine/lib/elektrine/profiles/static_site_deployment.ex b/apps/elektrine/lib/elektrine/profiles/static_site_deployment.ex new file mode 100644 index 0000000..53f76c1 --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/static_site_deployment.ex @@ -0,0 +1,137 @@ +defmodule Elektrine.Profiles.StaticSiteDeployment do + @moduledoc """ + Linked repository allowed to deploy a user's static site. + """ + + use Ecto.Schema + import Ecto.Changeset + alias Elektrine.Secrets.EncryptedString + + schema "static_site_deployments" do + field :provider, :string, default: "github" + field :repo_owner, :string + field :repo_name, :string + field :branch, :string, default: "main" + field :site_dir, :string, default: "auto" + field :build_command, :string + field :webhook_secret, EncryptedString + field :webhook_id, :string + field :deploy_status, :string, default: "idle" + field :last_deploy_error, :string + field :last_deploy_log, :string + field :last_commit_sha, :string + field :last_commit_url, :string + field :last_commit_message, :string + field :last_deployed_at, :utc_datetime + + belongs_to :user, Elektrine.Accounts.User + has_many :deploys, Elektrine.Profiles.StaticSiteDeploy, foreign_key: :deployment_id + + timestamps() + end + + def changeset(deployment, attrs) do + deployment + |> cast(attrs, [ + :user_id, + :provider, + :repo_owner, + :repo_name, + :branch, + :site_dir, + :build_command, + :webhook_secret, + :webhook_id, + :deploy_status, + :last_deploy_error, + :last_deploy_log, + :last_commit_sha, + :last_commit_url, + :last_commit_message, + :last_deployed_at + ]) + |> update_change(:provider, &normalize/1) + |> update_change(:repo_owner, &normalize/1) + |> update_change(:repo_name, &normalize_repo/1) + |> update_change(:branch, &normalize_branch/1) + |> update_change(:site_dir, &normalize_site_dir/1) + |> maybe_generate_webhook_secret() + |> validate_required([ + :user_id, + :provider, + :repo_owner, + :repo_name, + :branch, + :site_dir, + :webhook_secret, + :deploy_status + ]) + |> validate_inclusion(:provider, ["github"]) + |> validate_inclusion(:deploy_status, ["idle", "queued", "deploying", "deployed", "failed"]) + |> validate_format(:repo_owner, ~r/^[a-z0-9_.-]+$/) + |> validate_format(:repo_name, ~r/^[a-z0-9_.-]+$/) + |> validate_format(:branch, ~r/^[A-Za-z0-9._\/-]+$/) + |> validate_change(:branch, &validate_branch_path/2) + |> validate_length(:build_command, max: 2_000) + |> validate_length(:last_commit_sha, max: 80) + |> foreign_key_constraint(:user_id) + |> unique_constraint([:provider, :repo_owner, :repo_name]) + end + + defp normalize(value) when is_binary(value), do: value |> String.trim() |> String.downcase() + defp normalize(value), do: value + + defp normalize_repo(value) when is_binary(value) do + value |> normalize() |> String.replace_suffix(".git", "") + end + + defp normalize_repo(value), do: value + + defp normalize_branch(value) when is_binary(value) do + case String.trim(value) do + "" -> "main" + branch -> branch + end + end + + defp normalize_branch(value), do: value + + defp validate_branch_path(:branch, branch) when is_binary(branch) do + if valid_branch_segments?(branch) do + [] + else + [branch: "contains invalid path segment"] + end + end + + defp validate_branch_path(:branch, _branch), do: [branch: "is invalid"] + + defp valid_branch_segments?(branch) do + branch + |> String.split("/") + |> Enum.all?(&(&1 not in ["", ".", ".."])) + end + + defp normalize_site_dir(value) when is_binary(value) do + case String.trim(value) do + "" -> "auto" + dir -> dir + end + end + + defp normalize_site_dir(value), do: value + + defp maybe_generate_webhook_secret(changeset) do + case get_field(changeset, :webhook_secret) do + secret when is_binary(secret) and secret != "" -> + changeset + + _ -> + put_change( + changeset, + :webhook_secret, + Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) + ) + end + end +end diff --git a/apps/elektrine/lib/elektrine/profiles/static_site_file.ex b/apps/elektrine/lib/elektrine/profiles/static_site_file.ex new file mode 100644 index 0000000..945dbc1 --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/static_site_file.ex @@ -0,0 +1,109 @@ +defmodule Elektrine.Profiles.StaticSiteFile do + @moduledoc """ + Schema for static site files uploaded by users. + Stores metadata about uploaded HTML, CSS, JS, and asset files. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "static_site_files" do + field :path, :string + field :storage_key, :string + field :content_type, :string + field :size, :integer + + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(file, attrs) do + file + |> cast(attrs, [:user_id, :path, :storage_key, :content_type, :size]) + |> validate_required([:user_id, :path, :storage_key, :content_type, :size]) + |> validate_length(:path, max: 500) + |> validate_path() + |> validate_content_type() + |> validate_number(:size, greater_than: 0, less_than_or_equal_to: 10_000_000) + |> foreign_key_constraint(:user_id) + |> unique_constraint([:user_id, :path]) + end + + # Validate path is safe and doesn't allow directory traversal + defp validate_path(changeset) do + case get_change(changeset, :path) do + nil -> + changeset + + path -> + cond do + String.contains?(path, "..") -> + add_error(changeset, :path, "cannot contain '..'") + + String.starts_with?(path, "/") -> + add_error(changeset, :path, "cannot start with '/'") + + String.contains?(path, "//") -> + add_error(changeset, :path, "cannot contain '//'") + + !Regex.match?(~r/^[a-zA-Z0-9_\-\.\/]+$/, path) -> + add_error(changeset, :path, "contains invalid characters") + + true -> + changeset + end + end + end + + # Only allow safe content types for static sites + @allowed_content_types [ + "text/html", + "text/css", + "text/javascript", + "application/javascript", + "application/json", + "application/manifest+json", + "application/xml", + "application/rss+xml", + "application/atom+xml", + "text/plain", + "text/markdown", + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/avif", + "image/svg+xml", + "image/x-icon", + "image/bmp", + "font/woff", + "font/woff2", + "font/ttf", + "font/otf", + "application/vnd.ms-fontobject", + "application/font-woff", + "application/font-woff2", + "application/wasm", + "application/pdf", + "audio/mpeg", + "audio/wav", + "audio/ogg", + "video/mp4", + "video/webm" + ] + + defp validate_content_type(changeset) do + case get_change(changeset, :content_type) do + nil -> + changeset + + content_type -> + if content_type in @allowed_content_types do + changeset + else + add_error(changeset, :content_type, "is not allowed for static sites") + end + end + end +end diff --git a/apps/elektrine/lib/elektrine/profiles/user_badge.ex b/apps/elektrine/lib/elektrine/profiles/user_badge.ex new file mode 100644 index 0000000..3318372 --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/user_badge.ex @@ -0,0 +1,147 @@ +defmodule Elektrine.Profiles.UserBadge do + @moduledoc """ + Schema for user badges indicating roles, achievements, or special statuses. + Supports verified, staff, admin, moderator, supporter, developer, and custom badge types with colors and icons. + """ + use Ecto.Schema + import Ecto.Changeset + + @default_badge_color "#5f87b8" + @staff_badge_color "#a56b68" + @admin_badge_color "#a56b68" + @moderator_badge_color "#6f8b74" + @supporter_badge_color "#c99152" + @developer_badge_color "#5f87b8" + @contributor_badge_color @default_badge_color + @beta_tester_badge_color "#c9853f" + + schema "user_badges" do + field :badge_type, :string + field :badge_text, :string + field :badge_color, :string, default: @default_badge_color + field :badge_icon, :string + field :tooltip, :string + field :position, :integer, default: 0 + field :visible, :boolean, default: true + + belongs_to :user, Elektrine.Accounts.User + belongs_to :granted_by, Elektrine.Accounts.User + + timestamps() + end + + @badge_types ~w(verified supporter developer admin moderator contributor beta_tester staff custom) + + def changeset(badge, attrs) do + badge + |> cast(attrs, [ + :user_id, + :badge_type, + :badge_text, + :badge_color, + :badge_icon, + :tooltip, + :granted_by_id, + :position, + :visible + ]) + |> validate_required([:user_id, :badge_type]) + |> validate_inclusion(:badge_type, @badge_types) + |> validate_format(:badge_color, ~r/^#[0-9a-fA-F]{6}$/, message: "must be a valid hex color") + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:granted_by_id) + end + + def badge_types, do: @badge_types + + def default(field) when is_atom(field) do + Map.get(%__MODULE__{}, field) + end + + @doc """ + Returns default properties for a given badge type. + """ + def default_badge_properties(badge_type) do + case badge_type do + "staff" -> + %{ + badge_text: "Staff", + badge_color: @staff_badge_color, + badge_icon: "hero-check-badge", + tooltip: "Elektrine Staff Member" + } + + "verified" -> + %{ + badge_text: "Verified", + badge_color: @default_badge_color, + badge_icon: "hero-check-badge", + tooltip: "Verified Account" + } + + "admin" -> + %{ + badge_text: "Admin", + badge_color: @admin_badge_color, + badge_icon: "hero-check-badge", + tooltip: "Administrator" + } + + "moderator" -> + %{ + badge_text: "Moderator", + badge_color: @moderator_badge_color, + badge_icon: "hero-shield-exclamation", + tooltip: "Community Moderator" + } + + "supporter" -> + %{ + badge_text: "Supporter", + badge_color: @supporter_badge_color, + badge_icon: "hero-heart", + tooltip: "Platform Supporter" + } + + "developer" -> + %{ + badge_text: "Developer", + badge_color: @developer_badge_color, + badge_icon: "hero-code-bracket", + tooltip: "Developer" + } + + "contributor" -> + %{ + badge_text: "Contributor", + badge_color: @contributor_badge_color, + badge_icon: "hero-star", + tooltip: "Platform Contributor" + } + + "beta_tester" -> + %{ + badge_text: "Beta Tester", + badge_color: @beta_tester_badge_color, + badge_icon: "hero-beaker", + tooltip: "Beta Tester" + } + + "custom" -> + %{ + badge_text: "Custom", + badge_color: @default_badge_color, + badge_icon: nil, + tooltip: "Custom Badge" + } + + _ -> + %{ + badge_text: "", + badge_color: @default_badge_color, + badge_icon: nil, + tooltip: "" + } + end + end +end diff --git a/apps/elektrine/lib/elektrine/profiles/user_profile.ex b/apps/elektrine/lib/elektrine/profiles/user_profile.ex new file mode 100644 index 0000000..737e137 --- /dev/null +++ b/apps/elektrine/lib/elektrine/profiles/user_profile.ex @@ -0,0 +1,524 @@ +defmodule Elektrine.Profiles.UserProfile do + @moduledoc """ + Schema for customizable user profiles with extensive theming and display options. + Manages profile appearance, links, widgets, Discord integration, music embeds, and visual effects. + """ + use Ecto.Schema + import Ecto.Changeset + alias Elektrine.Markdown + + @default_accent_color "#5f87b8" + @default_text_color "#ffffff" + @default_background_color "#121214" + @default_icon_color "#5f87b8" + @default_username_glow_color "#5f87b8" + @default_username_shadow_color "#121214" + @default_tick_color "#5f87b8" + + schema "user_profiles" do + field :display_name, :string + field :description, :string + field :location, :string + field :page_title, :string + field :favicon_url, :string + field :theme, :string, default: "blue" + field :accent_color, :string, default: @default_accent_color + field :text_color, :string, default: @default_text_color + field :background_color, :string, default: @default_background_color + field :icon_color, :string, default: @default_icon_color + field :profile_opacity, :float, default: 1.0 + field :profile_blur, :integer, default: 0 + field :container_background_color, :string + field :container_opacity, :float, default: 0.4 + field :container_pattern, :string, default: "none" + field :pattern_color, :string + field :pattern_opacity, :float, default: 0.2 + field :pattern_animated, :boolean, default: false + field :pattern_animation_speed, :string, default: "normal" + field :font_family, :string + field :cursor_style, :string, default: "default" + field :monochrome_icons, :boolean, default: false + field :volume_control, :boolean, default: false + field :use_discord_avatar, :boolean, default: false + field :avatar_url, :string + field :avatar_size, :integer, default: 0 + field :avatar_alt_text, :string + field :avatar_focal_x, :float, default: 50.0 + field :avatar_focal_y, :float, default: 50.0 + field :avatar_effect, :string, default: "none" + field :banner_url, :string + field :banner_size, :integer, default: 0 + field :banner_alt_text, :string + field :background_url, :string + field :background_size, :integer, default: 0 + field :background_type, :string, default: "gradient" + field :background_alt_text, :string + field :background_focal_x, :float, default: 50.0 + field :background_focal_y, :float, default: 50.0 + field :background_brightness, :integer, default: 100 + field :background_overlay_blur, :integer, default: 0 + field :music_url, :string + field :music_title, :string + field :discord_user_id, :string + field :show_discord_presence, :boolean, default: false + field :is_public, :boolean, default: true + field :hide_view_counter, :boolean, default: false + field :hide_uid, :boolean, default: false + field :hide_followers, :boolean, default: false + field :hide_avatar, :boolean, default: false + field :hide_timeline, :boolean, default: false + field :hide_community_posts, :boolean, default: false + field :hide_share_button, :boolean, default: false + field :text_background, :boolean, default: false + field :page_views, :integer, default: 0 + + # Username text effects + field :username_effect, :string, default: "none" + field :username_glow_color, :string, default: @default_username_glow_color + field :username_glow_intensity, :integer, default: 10 + field :username_shadow_color, :string, default: @default_username_shadow_color + field :username_gradient_from, :string + field :username_gradient_to, :string + field :username_animation_speed, :string, default: "normal" + + # Verified badge color + field :tick_color, :string, default: @default_tick_color + + # Typewriter effect + field :typewriter_effect, :boolean, default: false + field :typewriter_speed, :string, default: "normal" + field :typewriter_title, :boolean, default: false + + # Link display style + field :link_display_style, :string, default: "circular" + field :link_highlight_effect, :string, default: "none" + + # Layout options + field :extend_layout, :boolean, default: true + + # Profile mode: "builder" = drag & drop builder, "static" = custom uploaded files + field :profile_mode, :string, default: "builder" + field :static_site_index, :string + + belongs_to :user, Elektrine.Accounts.User + + has_many :links, Elektrine.Profiles.ProfileLink, + foreign_key: :profile_id, + preload_order: [asc: :position] + + has_many :widgets, Elektrine.Profiles.ProfileWidget, + foreign_key: :profile_id, + preload_order: [asc: :position] + + timestamps() + end + + @doc false + def default(field) when is_atom(field) do + Map.get(%__MODULE__{}, field) + end + + @doc false + def changeset(profile, attrs) do + profile + |> cast(attrs, [ + :user_id, + :display_name, + :description, + :location, + :page_title, + :favicon_url, + :theme, + :accent_color, + :text_color, + :background_color, + :icon_color, + :profile_opacity, + :profile_blur, + :container_background_color, + :container_opacity, + :container_pattern, + :pattern_color, + :pattern_opacity, + :pattern_animated, + :pattern_animation_speed, + :font_family, + :cursor_style, + :avatar_url, + :avatar_size, + :avatar_alt_text, + :avatar_focal_x, + :avatar_focal_y, + :avatar_effect, + :banner_url, + :banner_size, + :banner_alt_text, + :background_url, + :background_size, + :background_type, + :background_alt_text, + :background_focal_x, + :background_focal_y, + :background_brightness, + :background_overlay_blur, + :music_url, + :music_title, + :discord_user_id, + :show_discord_presence, + :hide_view_counter, + :hide_uid, + :hide_followers, + :hide_avatar, + :hide_timeline, + :hide_community_posts, + :hide_share_button, + :text_background, + :monochrome_icons, + :volume_control, + :use_discord_avatar, + :username_effect, + :username_glow_color, + :username_glow_intensity, + :username_shadow_color, + :username_gradient_from, + :username_gradient_to, + :username_animation_speed, + :tick_color, + :typewriter_effect, + :typewriter_speed, + :typewriter_title, + :link_display_style, + :link_highlight_effect, + :extend_layout, + :profile_mode, + :static_site_index, + :is_public + ]) + |> validate_required([:user_id]) + |> validate_length(:display_name, max: 50) + |> validate_length(:description, max: 1000) + |> validate_length(:location, max: 100) + |> validate_length(:page_title, max: 100) + |> validate_length(:avatar_alt_text, max: 250) + |> validate_length(:banner_alt_text, max: 250) + |> validate_length(:background_alt_text, max: 250) + |> validate_inclusion(:theme, [ + "purple", + "blue", + "green", + "orange", + "red", + "pink", + "dark", + "light" + ]) + |> validate_inclusion(:background_type, ["gradient", "solid", "image", "video"]) + |> validate_inclusion( + :font_family, + [ + nil, + "Inter", + "Arial", + "Helvetica", + "Georgia", + "Times New Roman", + "Courier New", + "Verdana", + "Trebuchet MS", + "Comic Sans MS", + "Impact", + "Palatino", + "Garamond", + "Bookman", + "Consolas", + "Monaco" + ], + allow_nil: true + ) + |> validate_inclusion(:cursor_style, [ + "default", + "pointer", + "crosshair", + "help", + "wait", + "grab", + "text", + "move", + "not-allowed", + "cell", + "context-menu", + "vertical-text", + "alias", + "copy", + "no-drop" + ]) + |> validate_format(:accent_color, ~r/^#[0-9a-fA-F]{6}$/, message: "must be a valid hex color") + |> validate_format(:text_color, ~r/^#[0-9a-fA-F]{6}$/, message: "must be a valid hex color") + |> validate_format(:background_color, ~r/^#[0-9a-fA-F]{6}$/, + message: "must be a valid hex color" + ) + |> validate_format(:icon_color, ~r/^#[0-9a-fA-F]{6}$/, message: "must be a valid hex color") + |> validate_format(:container_background_color, ~r/^#[0-9a-fA-F]{6}$/, + message: "must be a valid hex color", + allow_nil: true + ) + |> validate_format(:pattern_color, ~r/^#[0-9a-fA-F]{6}$/, + message: "must be a valid hex color", + allow_nil: true + ) + |> validate_number(:profile_opacity, + greater_than_or_equal_to: 0.5, + less_than_or_equal_to: 1.0 + ) + |> validate_number(:profile_blur, greater_than_or_equal_to: 0, less_than_or_equal_to: 100) + |> validate_number(:avatar_focal_x, greater_than_or_equal_to: 0, less_than_or_equal_to: 100) + |> validate_number(:avatar_focal_y, greater_than_or_equal_to: 0, less_than_or_equal_to: 100) + |> validate_number(:background_focal_x, + greater_than_or_equal_to: 0, + less_than_or_equal_to: 100 + ) + |> validate_number(:background_focal_y, + greater_than_or_equal_to: 0, + less_than_or_equal_to: 100 + ) + |> validate_number(:background_brightness, + greater_than_or_equal_to: 25, + less_than_or_equal_to: 150 + ) + |> validate_number(:background_overlay_blur, + greater_than_or_equal_to: 0, + less_than_or_equal_to: 40 + ) + |> validate_number(:container_opacity, + greater_than_or_equal_to: 0.0, + less_than_or_equal_to: 1.0 + ) + |> validate_number(:pattern_opacity, + greater_than_or_equal_to: 0.0, + less_than_or_equal_to: 1.0 + ) + |> validate_inclusion(:container_pattern, [ + "none", + "dots", + "grid", + "diagonal_lines", + "zigzag", + "waves", + "crosses", + "houndstooth" + ]) + |> validate_inclusion(:pattern_animation_speed, ["slow", "normal", "fast"], allow_nil: true) + |> validate_inclusion(:avatar_effect, [ + "none", + "glow", + "rainbow", + "fire", + "ice", + "sparkle", + "holographic", + "gold_frame", + "pulse", + "rotate_border" + ]) + |> validate_inclusion(:username_effect, [ + "none", + "glow", + "rainbow", + "neon", + "shadow", + "gradient", + "glitch", + "fire", + "ice", + "chrome", + "holographic", + "outline", + "double", + "retro", + "pixelated" + ]) + |> validate_format(:username_glow_color, ~r/^#[0-9a-fA-F]{6}$/, + message: "must be a valid hex color", + allow_nil: true + ) + |> validate_number(:username_glow_intensity, + greater_than_or_equal_to: 0, + less_than_or_equal_to: 50 + ) + |> validate_format(:username_shadow_color, ~r/^#[0-9a-fA-F]{6}$/, + message: "must be a valid hex color", + allow_nil: true + ) + |> validate_format(:username_gradient_from, ~r/^#[0-9a-fA-F]{6}$/, + message: "must be a valid hex color", + allow_nil: true + ) + |> validate_format(:username_gradient_to, ~r/^#[0-9a-fA-F]{6}$/, + message: "must be a valid hex color", + allow_nil: true + ) + |> validate_inclusion(:username_animation_speed, ["slow", "normal", "fast"], allow_nil: true) + |> validate_format(:tick_color, ~r/^#[0-9a-fA-F]{6}$/, message: "must be a valid hex color") + |> validate_inclusion(:typewriter_speed, ["slow", "normal", "fast"], allow_nil: true) + |> validate_inclusion(:link_display_style, ["circular", "full_width"]) + |> validate_inclusion(:link_highlight_effect, ["none", "glow", "pulse", "border", "shine"]) + |> validate_inclusion(:profile_mode, ["builder", "static"]) + |> validate_markdown_description() + |> validate_music_url() + |> validate_discord_user_id() + |> validate_safe_urls() + |> foreign_key_constraint(:user_id) + |> unique_constraint(:user_id) + end + + # Validate markdown description content + defp validate_markdown_description(changeset) do + case get_change(changeset, :description) do + nil -> + changeset + + description when is_binary(description) -> + case Markdown.validate(description) do + {:ok, _} -> + changeset + + {:error, message} -> + add_error(changeset, :description, message) + end + + _ -> + changeset + end + end + + # Validate music_url to only allow safe embed URLs + defp validate_music_url(changeset) do + case get_change(changeset, :music_url) do + nil -> + changeset + + "" -> + changeset + + url when is_binary(url) -> + # Only allow embed URLs from trusted music platforms + safe_domains = [ + "open.spotify.com/embed/", + "youtube.com/embed/", + "youtube-nocookie.com/embed/", + "w.soundcloud.com/player/", + "bandcamp.com/EmbeddedPlayer/", + "music.apple.com/embed/" + ] + + is_safe = + Enum.any?(safe_domains, fn domain -> + String.contains?(url, domain) && String.starts_with?(url, "https://") + end) + + if is_safe do + changeset + else + add_error( + changeset, + :music_url, + "must be a valid embed URL from Spotify, YouTube, SoundCloud, Bandcamp, or Apple Music" + ) + end + + _ -> + changeset + end + end + + # Validate discord_user_id to only allow numeric Discord IDs + defp validate_discord_user_id(changeset) do + case get_change(changeset, :discord_user_id) do + nil -> + changeset + + "" -> + changeset + + id when is_binary(id) -> + # Discord IDs are numeric strings, 17-20 characters + if String.match?(id, ~r/^[0-9]{17,20}$/) do + changeset + else + add_error(changeset, :discord_user_id, "must be a valid numeric Discord user ID") + end + + _ -> + changeset + end + end + + # Validate that uploaded file URLs are from our own storage system + defp validate_safe_urls(changeset) do + changeset + |> validate_uploaded_url(:background_url) + |> validate_uploaded_url(:avatar_url) + |> validate_uploaded_url(:banner_url) + |> validate_uploaded_url(:favicon_url) + end + + defp validate_uploaded_url(changeset, field) do + case get_change(changeset, field) do + nil -> + changeset + + "" -> + changeset + + url when is_binary(url) -> + is_safe = + url + |> extract_upload_key() + |> safe_upload_key_for_field?(field) + + if is_safe do + changeset + else + add_error(changeset, field, "must be from the upload system") + end + + _ -> + changeset + end + end + + defp extract_upload_key(url) when is_binary(url) do + cond do + String.starts_with?(url, "http://") or String.starts_with?(url, "https://") -> + nil + + String.starts_with?(url, "/uploads/") -> + String.trim_leading(url, "/uploads/") + + String.starts_with?(url, "uploads/") -> + String.trim_leading(url, "uploads/") + + String.starts_with?(url, "/") -> + String.trim_leading(url, "/") + + true -> + url + end + end + + defp safe_upload_key_for_field?("", _field), do: false + defp safe_upload_key_for_field?(nil, _field), do: false + + defp safe_upload_key_for_field?(key, field) when is_binary(key) do + allowed_prefixes = + case field do + :avatar_url -> ["avatars/"] + :background_url -> ["backgrounds/"] + :banner_url -> ["backgrounds/"] + :favicon_url -> ["favicons/"] + _ -> [] + end + + Enum.any?(allowed_prefixes, &String.starts_with?(key, &1)) + end +end diff --git a/apps/elektrine/lib/elektrine/proxy_protocol.ex b/apps/elektrine/lib/elektrine/proxy_protocol.ex new file mode 100644 index 0000000..74caf90 --- /dev/null +++ b/apps/elektrine/lib/elektrine/proxy_protocol.ex @@ -0,0 +1,168 @@ +defmodule Elektrine.ProxyProtocol do + @moduledoc """ + PROXY protocol v1 parser for TCP services behind a proxy. + + The PROXY protocol allows proxies to pass the real client IP address + to backend services over TCP connections for services like IMAP, SMTP, + and POP3. + + Format: PROXY TCP4 \r\n + Example: PROXY TCP4 192.168.1.1 172.16.17.162 12345 143\r\n + + Reference: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt + """ + + import Bitwise + require Logger + + @doc """ + Reads and parses the PROXY protocol header from a TCP socket. + + Returns `{:ok, client_ip_string}` if PROXY header is present and valid. + Returns `{:ok, peer_ip_string}` if no PROXY header (falls back to peer IP). + Returns `{:error, reason}` if the connection cannot be read. + + ## Examples + + iex> {:ok, socket} = :gen_tcp.accept(listen_socket) + iex> {:ok, client_ip} = Elektrine.ProxyProtocol.parse_client_ip(socket) + iex> client_ip + "192.168.1.1" + """ + def parse_client_ip(socket, opts \\ []) do + # Get the peer IP as fallback + {peer_ip, peer_ip_string} = + case :inet.peername(socket) do + {:ok, {ip, _port}} -> + {ip, :inet.ntoa(ip) |> to_string()} + + {:error, _} -> + {nil, "unknown"} + end + + # Try to read the PROXY protocol header with a short timeout (1 second) + # The header should be sent immediately by the proxy + case :gen_tcp.recv(socket, 0, 1_000) do + {:ok, data} -> + line = to_string(data) + + if String.starts_with?(line, "PROXY ") do + if trusted_proxy_peer?(peer_ip, opts) do + parse_proxy_header(line, peer_ip_string) + else + Logger.warning("Ignoring PROXY protocol header from untrusted peer #{peer_ip_string}") + {:ok, peer_ip_string, line} + end + else + # No PROXY header - this is the actual client data + # We need to "unread" this data by putting it back in the buffer + # Since we can't do that with :gen_tcp, we'll return the peer IP + # and the caller needs to handle the first line of data + Logger.warning("Expected PROXY protocol header but got: #{String.slice(line, 0..50)}") + {:ok, peer_ip_string, line} + end + + {:error, :timeout} -> + # No data received - likely no PROXY protocol, use peer IP + {:ok, peer_ip_string, nil} + + {:error, :closed} -> + # Connection closed before sending data - likely health check or PROXY not enabled + {:error, :closed} + + {:error, reason} -> + # Other errors (unlikely) + {:error, reason} + end + end + + # Parses a PROXY protocol v1 header line. + # Format: PROXY TCP4 \r\n + # Returns the client IP if valid, or the fallback IP if parsing fails. + defp parse_proxy_header(line, fallback_ip) do + line = String.trim(line) + + case String.split(line, " ") do + ["PROXY", "TCP4", client_ip, _proxy_ip, _client_port, _proxy_port] -> + {:ok, client_ip, nil} + + ["PROXY", "TCP6", client_ip, _proxy_ip, _client_port, _proxy_port] -> + {:ok, client_ip, nil} + + ["PROXY", "UNKNOWN" | _rest] -> + {:ok, fallback_ip, nil} + + _ -> + Logger.warning("Invalid PROXY protocol header: #{line}") + {:ok, fallback_ip, line} + end + end + + defp trusted_proxy_peer?(ip, opts) when is_tuple(ip) do + opts + |> Keyword.get(:trusted_proxy_cidrs, configured_trusted_proxy_cidrs()) + |> Enum.map(&parse_cidr/1) + |> Enum.reject(&is_nil/1) + |> Enum.any?(&ip_in_cidr?(ip, &1)) + end + + defp trusted_proxy_peer?(_, _), do: false + + defp configured_trusted_proxy_cidrs do + Application.get_env( + :elektrine, + :proxy_protocol_trusted_cidrs, + Application.get_env(:elektrine, :trusted_proxy_cidrs, []) + ) + end + + defp parse_cidr(value) when is_binary(value) do + value = String.trim(value) + + {ip_string, prefix_string} = + case String.split(value, "/", parts: 2) do + [ip, prefix] -> {ip, prefix} + [ip] -> {ip, nil} + end + + with {:ok, ip} <- :inet.parse_address(String.to_charlist(ip_string)), + max_bits <- ip_max_bits(ip), + {:ok, prefix} <- parse_prefix(prefix_string, max_bits) do + {ip, prefix} + else + _ -> nil + end + end + + defp parse_cidr(_), do: nil + + defp parse_prefix(nil, max_bits), do: {:ok, max_bits} + + defp parse_prefix(value, max_bits) do + case Integer.parse(value) do + {prefix, ""} when prefix >= 0 and prefix <= max_bits -> {:ok, prefix} + _ -> :error + end + end + + defp ip_in_cidr?(ip, {network, prefix}) do + ip_max_bits(ip) == ip_max_bits(network) and + ip_to_integer(ip) >>> (ip_max_bits(ip) - prefix) == + ip_to_integer(network) >>> (ip_max_bits(network) - prefix) + end + + defp ip_max_bits(ip) when tuple_size(ip) == 4, do: 32 + defp ip_max_bits(ip) when tuple_size(ip) == 8, do: 128 + + defp ip_to_integer(ip) when tuple_size(ip) == 4 do + ip + |> Tuple.to_list() + |> Enum.reduce(0, fn octet, acc -> (acc <<< 8) + octet end) + end + + defp ip_to_integer(ip) when tuple_size(ip) == 8 do + ip + |> Tuple.to_list() + |> Enum.reduce(0, fn segment, acc -> (acc <<< 16) + segment end) + end +end diff --git a/apps/elektrine/lib/elektrine/pubsub_topics.ex b/apps/elektrine/lib/elektrine/pubsub_topics.ex new file mode 100644 index 0000000..ada3f83 --- /dev/null +++ b/apps/elektrine/lib/elektrine/pubsub_topics.ex @@ -0,0 +1,140 @@ +defmodule Elektrine.PubSubTopics do + @moduledoc """ + Centralized PubSub topic naming and management. + Provides consistent topic names and subscription helpers across the application. + """ + + @doc """ + Topic for a user's personal timeline updates (posts from followed users). + """ + def user_timeline(user_id), do: "user:#{user_id}:timeline" + + @doc """ + Topic for a user's profile updates (display name, avatar, bio changes). + """ + def user_profile(user_id), do: "user:#{user_id}:profile" + + @doc """ + Topic for a user's follow updates (new followers, following changes). + """ + def user_follows(user_id), do: "user:#{user_id}:follows" + + @doc """ + Topic for a user's notification updates. + """ + def user_notifications(user_id), do: "user:#{user_id}:notifications" + + @doc """ + Topic for a specific conversation's messages. + """ + def conversation(conversation_id), do: "chat:#{conversation_id}" + + @doc """ + Topic for conversation member updates (joins, leaves, role changes). + """ + def conversation_members(conversation_id), do: "conversation:#{conversation_id}:members" + + @doc """ + Topic for public timeline (all public posts). + """ + def timeline_public, do: "timeline:public" + + @doc """ + Topic for all timeline activity (for live updates). + """ + def timeline_all, do: "timeline:all" + + @doc """ + Topic for presence tracking in a conversation. + """ + def presence(conversation_id), do: "presence:chat:#{conversation_id}" + + @doc """ + Topic for a specific discussion/community. + """ + def discussion(conversation_id), do: "discussion:#{conversation_id}" + + @doc """ + Topic for discussion post updates. + """ + def discussion_post(message_id), do: "discussion:post:#{message_id}" + + @doc """ + Topic for moderation actions in a community. + """ + def moderation(conversation_id), do: "moderation:#{conversation_id}" + + @doc """ + Topic for call events. + """ + def call(call_id), do: "call:#{call_id}" + + @doc """ + Topic for user online status updates. + """ + def user_status(user_id), do: "user:#{user_id}:status" + + @doc """ + Shared topic for global user presence updates. + + This is used by the older Phoenix Presence tracker and federated ARBP + presence fanout so LiveViews can consume a single presence bus. + """ + def users_presence, do: "users" + + @doc """ + Topic carrying aggregated per-user presence snapshots. + + `ElektrineWeb.Presence` broadcasts `{:presence_changed, user_id, snapshot}` + here (node-locally) whenever a user's aggregate status actually changes. + Subscribe to this instead of `users_presence/0` to observe presence without + receiving every raw per-device diff. + """ + def users_presence_updates, do: "users:presence_updates" + + @doc """ + Topic for global announcements. + """ + def announcements, do: "announcements:all" + + @doc """ + Helper to broadcast to a topic. + """ + def broadcast(topic, event, payload) do + Phoenix.PubSub.broadcast(Elektrine.PubSub, topic, {event, payload}) + end + + @doc """ + Helper to subscribe to a topic. + """ + def subscribe(topic) do + Phoenix.PubSub.subscribe(Elektrine.PubSub, topic) + end + + @doc """ + Helper to unsubscribe from a topic. + """ + def unsubscribe(topic) do + Phoenix.PubSub.unsubscribe(Elektrine.PubSub, topic) + end + + @doc """ + Topic for a user's email updates (new emails, status changes). + """ + def user_email(user_id), do: "user:#{user_id}:email" + + @doc """ + Topic for a user's VPN updates (config lifecycle and connection changes). + """ + def user_vpn(user_id), do: "user:#{user_id}:vpn" + + @doc """ + Topic for self-hosted VPN reconcile triggers. + """ + def vpn_self_hosted_reconcile, do: "vpn:self_hosted:reconcile" + + @doc """ + Topic for mailbox-specific updates. + """ + def mailbox(mailbox_id), do: "mailbox:#{mailbox_id}" +end diff --git a/apps/elektrine/lib/elektrine/push.ex b/apps/elektrine/lib/elektrine/push.ex new file mode 100644 index 0000000..f20ba1d --- /dev/null +++ b/apps/elektrine/lib/elektrine/push.ex @@ -0,0 +1,739 @@ +defmodule Elektrine.Push do + @moduledoc """ + Push notification context for mobile apps. + Handles device registration and notification delivery via APNs and FCM. + """ + + import Ecto.Query, warn: false + require Logger + + alias Elektrine.Profiles + alias Elektrine.Push.DeviceToken + alias Elektrine.Push.WebSubscription + alias Elektrine.Repo + + @device_token_attr_keys %{ + "token" => :token, + "token_hash" => :token_hash, + "platform" => :platform, + "app_version" => :app_version, + "device_name" => :device_name, + "device_model" => :device_model, + "os_version" => :os_version, + "bundle_id" => :bundle_id, + "user_id" => :user_id, + "enabled" => :enabled, + "last_used_at" => :last_used_at, + "failed_count" => :failed_count, + "last_error" => :last_error + } + + # Device token management + + @doc """ + Registers or updates a device for push notifications. + If the token already exists, updates its metadata. + """ + def register_device(user_id, attrs) do + token = Map.get(attrs, :token) || Map.get(attrs, "token") + token_hash = device_token_hash(token) + + # Upsert - update if token exists, create if new + case Repo.get_by(DeviceToken, token_hash: token_hash) do + nil -> + attrs = + attrs + |> normalize_attrs() + |> Map.put(:user_id, user_id) + |> Map.put(:token_hash, token_hash) + |> Map.put(:last_used_at, DateTime.utc_now() |> DateTime.truncate(:second)) + + %DeviceToken{} + |> DeviceToken.changeset(attrs) + |> Repo.insert() + + existing -> + attrs = + attrs + |> normalize_attrs() + |> Map.put(:user_id, user_id) + |> Map.put(:token_hash, token_hash) + |> Map.put(:last_used_at, DateTime.utc_now() |> DateTime.truncate(:second)) + |> Map.put(:enabled, true) + |> Map.put(:failed_count, 0) + |> Map.put(:last_error, nil) + + existing + |> DeviceToken.changeset(attrs) + |> Repo.update() + end + end + + @doc """ + Unregisters a device by token. + """ + def unregister_device(token) do + case get_device_by_token(token) do + nil -> {:error, :not_found} + device -> Repo.delete(device) + end + end + + @doc """ + Lists all enabled devices for a user. + """ + def list_user_devices(user_id) do + DeviceToken + |> where(user_id: ^user_id, enabled: true) + |> order_by(desc: :last_used_at) + |> Repo.all() + end + + @doc """ + Gets a device by its token. + """ + def get_device_by_token(token) do + Repo.get_by(DeviceToken, token_hash: device_token_hash(token)) + end + + @doc """ + Disables a device and records the error reason. + """ + def disable_device(token, error_reason \\ nil) do + case get_device_by_token(token) do + nil -> + {:error, :not_found} + + device -> + device + |> Ecto.Changeset.change(%{ + enabled: false, + failed_count: device.failed_count + 1, + last_error: error_reason + }) + |> Repo.update() + end + end + + @doc """ + Records a failed push attempt. + Disables device after 5 consecutive failures. + """ + def record_push_failure(token, error_reason) do + case get_device_by_token(token) do + nil -> + {:error, :not_found} + + device -> + new_count = device.failed_count + 1 + + changes = + if new_count >= 5 do + %{ + failed_count: new_count, + last_error: error_reason, + enabled: false + } + else + %{ + failed_count: new_count, + last_error: error_reason + } + end + + device + |> Ecto.Changeset.change(changes) + |> Repo.update() + end + end + + @doc """ + Resets failure count on successful push. + """ + def record_push_success(token) do + case get_device_by_token(token) do + nil -> + {:error, :not_found} + + device -> + device + |> Ecto.Changeset.change(%{ + failed_count: 0, + last_error: nil, + last_used_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.update() + end + end + + def device_token_hash(token) when is_binary(token) do + token + |> String.trim() + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + end + + def device_token_hash(_token), do: nil + + # Browser Web Push subscription management + + @default_web_alerts %{ + "follow" => true, + "favourite" => true, + "reblog" => true, + "mention" => true, + "poll" => true, + "status" => false, + "update" => false, + "admin.sign_up" => false, + "admin.report" => false + } + + @doc """ + Creates or updates a browser Web Push subscription. + """ + def upsert_web_subscription(user_id, attrs) when is_integer(user_id) and is_map(attrs) do + attrs = normalize_web_subscription_attrs(user_id, attrs) + + case Repo.get_by(WebSubscription, endpoint_hash: attrs.endpoint_hash) do + nil -> + %WebSubscription{} + |> WebSubscription.changeset(attrs) + |> Repo.insert() + + %WebSubscription{} = subscription -> + subscription + |> WebSubscription.changeset(attrs) + |> Repo.update() + end + end + + def upsert_web_subscription(_user_id, _attrs), do: {:error, :invalid_subscription} + + @doc """ + Gets the most recently used enabled browser Web Push subscription for a user. + """ + def get_web_subscription(user_id) when is_integer(user_id) do + WebSubscription + |> where(user_id: ^user_id, enabled: true) + |> order_by(desc: :last_used_at, desc: :updated_at, desc: :id) + |> limit(1) + |> Repo.one() + end + + def get_web_subscription(_user_id), do: nil + + @doc """ + Updates the current browser Web Push subscription policy for a user. + """ + def update_web_subscription(user_id, attrs) when is_integer(user_id) and is_map(attrs) do + case get_web_subscription(user_id) do + nil -> + {:error, :not_found} + + %WebSubscription{} = subscription -> + subscription + |> WebSubscription.update_changeset(normalize_web_subscription_policy(attrs)) + |> Repo.update() + end + end + + def update_web_subscription(_user_id, _attrs), do: {:error, :not_found} + + @doc """ + Deletes the current browser Web Push subscription for a user. + """ + def delete_web_subscription(user_id) when is_integer(user_id) do + case get_web_subscription(user_id) do + nil -> {:error, :not_found} + %WebSubscription{} = subscription -> Repo.delete(subscription) + end + end + + def delete_web_subscription(_user_id), do: {:error, :not_found} + + @doc """ + Deletes the user's browser Web Push subscription matching the given endpoint. + """ + def delete_web_subscription_by_endpoint(user_id, endpoint) + when is_integer(user_id) and is_binary(endpoint) do + case Repo.get_by(WebSubscription, + user_id: user_id, + endpoint_hash: web_push_endpoint_hash(endpoint) + ) do + nil -> {:error, :not_found} + %WebSubscription{} = subscription -> Repo.delete(subscription) + end + end + + def delete_web_subscription_by_endpoint(_user_id, _endpoint), do: {:error, :not_found} + + @doc """ + Lists enabled browser Web Push subscriptions for a user. + """ + def list_web_subscriptions(user_id) when is_integer(user_id) do + WebSubscription + |> where(user_id: ^user_id, enabled: true) + |> order_by(desc: :last_used_at, desc: :updated_at, desc: :id) + |> Repo.all() + end + + def list_web_subscriptions(_user_id), do: [] + + @doc """ + Sends a notification payload to all eligible browser Web Push subscriptions. + """ + def notify_web_user(user_id, notification) when is_integer(user_id) and is_map(notification) do + subscriptions = + user_id + |> list_web_subscriptions() + |> Enum.filter(&web_subscription_allows?(&1, notification)) + + Enum.each(subscriptions, fn subscription -> + Elektrine.Async.run(fn -> + send_to_web_subscription(subscription, notification) + end) + end) + + {:ok, length(subscriptions)} + end + + def notify_web_user(_user_id, _notification), do: {:ok, 0} + + @doc """ + Sends a notification payload to one browser Web Push subscription. + """ + def send_to_web_subscription(%WebSubscription{} = subscription, notification) + when is_map(notification) do + payload = build_web_push_payload(notification) + + case deliver_web_push(subscription, payload) do + {:ok, _result} -> + record_web_push_success(subscription) + :ok + + :ok -> + record_web_push_success(subscription) + :ok + + {:error, :subscription_gone} -> + # The push service reported 404/410: the browser subscription no + # longer exists, so retrying is pointless. + subscription + |> Ecto.Changeset.change(%{enabled: false, last_error: "subscription_gone"}) + |> Repo.update() + + {:error, :subscription_gone} + + {:error, reason} -> + record_web_push_failure(subscription, inspect(reason)) + {:error, reason} + end + rescue + error -> + reason = Exception.message(error) + _ = record_web_push_failure(subscription, reason) + {:error, reason} + end + + def web_push_endpoint_hash(endpoint) when is_binary(endpoint) do + endpoint + |> String.trim() + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + end + + def web_push_endpoint_hash(_endpoint), do: nil + + def record_web_push_success(%WebSubscription{} = subscription) do + subscription + |> Ecto.Changeset.change(%{ + failed_count: 0, + last_error: nil, + last_used_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.update() + end + + def record_web_push_failure(%WebSubscription{} = subscription, error_reason) do + new_count = subscription.failed_count + 1 + + changes = + if new_count >= 5 do + %{ + failed_count: new_count, + last_error: error_reason, + enabled: false + } + else + %{ + failed_count: new_count, + last_error: error_reason + } + end + + subscription + |> Ecto.Changeset.change(changes) + |> Repo.update() + end + + # Push notification sending + + @doc """ + Sends a push notification to all of a user's devices. + Returns the number of devices notified. + """ + def notify_user(user_id, notification) do + devices = list_user_devices(user_id) + + # Send to each device asynchronously + Enum.each(devices, fn device -> + Elektrine.Async.start(fn -> + send_to_device(device, notification) + end) + end) + + {:ok, length(devices)} + end + + @doc """ + Sends a push notification to a specific device. + """ + def send_to_device(%DeviceToken{platform: "ios"} = device, notification) do + if push_enabled?() do + send_apns(device.token, notification) + else + Logger.debug("Push notifications disabled, skipping APNs send") + :ok + end + end + + def send_to_device(%DeviceToken{platform: "android"} = device, notification) do + if push_enabled?() do + send_fcm(device.token, notification) + else + Logger.debug("Push notifications disabled, skipping FCM send") + :ok + end + end + + def send_to_device(_device, _notification) do + {:error, :unknown_platform} + end + + # User online detection + + @doc """ + Checks if a user has an active WebSocket connection. + Used to decide whether to send push or rely on WebSocket. + """ + def user_has_active_connection?(user_id) do + user_has_active_connection?(user_id, []) + end + + @doc """ + Returns true if user should receive push notifications. + (i.e., user is offline) + """ + def should_send_push?(user_id) do + not user_has_active_connection?(user_id) + end + + @doc false + def user_has_active_connection?(user_id, opts) when is_list(opts) do + presence_module = Keyword.get(opts, :presence_module, ElektrineWeb.Presence) + web_enabled? = Keyword.get_lazy(opts, :web_enabled?, fn -> component_enabled?(:web) end) + + presence_running? = + Keyword.get_lazy(opts, :presence_running?, fn -> + not is_nil(Process.whereis(presence_module)) + end) + + if web_enabled? and presence_running? do + case safe_presence_get_by_key(presence_module, "mobile:users", to_string(user_id)) do + [] -> false + nil -> false + _ -> true + end + else + false + end + end + + # APNs and FCM implementation + + defp send_apns(device_token, notification) do + # Pigeon is optional in some environments, so use apply/3 to avoid compile warnings. + if Code.ensure_loaded?(Pigeon.APNS) do + payload = build_apns_payload(notification) + args = [payload, device_token, apns_topic()] + + # credo:disable-for-next-line Credo.Check.Refactor.Apply + apns_notification = apply(Pigeon.APNS.Notification, :new, args) + + # credo:disable-for-next-line Credo.Check.Refactor.Apply + case apply(Pigeon, :push, [apns_notification]) do + %{response: :success} = _result -> + Logger.info("APNs push sent successfully") + record_push_success(device_token) + :ok + + %{response: :bad_device_token} = _result -> + Logger.warning("APNs bad device token") + disable_device(device_token, "bad_device_token") + {:error, :bad_device_token} + + %{response: reason} = _result -> + Logger.error("APNs push failed: #{inspect(reason)}") + record_push_failure(device_token, to_string(reason)) + {:error, reason} + end + else + Logger.warning("Pigeon not available for APNs push") + {:error, :pigeon_not_available} + end + end + + defp send_fcm(device_token, notification) do + # Pigeon is optional in some environments, so use apply/3 to avoid compile warnings. + if Code.ensure_loaded?(Pigeon.FCM) do + payload = build_fcm_payload(notification) + + # credo:disable-for-next-line Credo.Check.Refactor.Apply + fcm_notification = apply(Pigeon.FCM.Notification, :new, [device_token, payload]) + + # credo:disable-for-next-line Credo.Check.Refactor.Apply + case apply(Pigeon, :push, [fcm_notification]) do + %{response: :success} = _result -> + Logger.info("FCM push sent successfully") + record_push_success(device_token) + :ok + + %{response: :not_registered} = _result -> + Logger.warning("FCM device not registered") + disable_device(device_token, "not_registered") + {:error, :not_registered} + + %{response: reason} = _result -> + Logger.error("FCM push failed: #{inspect(reason)}") + record_push_failure(device_token, to_string(reason)) + {:error, reason} + end + else + Logger.warning("Pigeon not available for FCM push") + {:error, :pigeon_not_available} + end + end + + defp build_apns_payload(notification) do + %{ + "aps" => %{ + "alert" => %{ + "title" => notification[:title] || notification["title"], + "body" => notification[:body] || notification["body"] + }, + "badge" => notification[:badge] || 1, + "sound" => notification[:sound] || "default", + "category" => notification[:category] + }, + "data" => notification[:data] || %{} + } + end + + defp build_fcm_payload(notification) do + %{ + "notification" => %{ + "title" => notification[:title] || notification["title"], + "body" => notification[:body] || notification["body"] + }, + "data" => notification[:data] || %{}, + "android" => %{ + "priority" => "high" + } + } + end + + defp build_web_push_payload(notification) do + %{ + title: notification[:title] || notification["title"], + body: notification[:body] || notification["body"], + badge: notification[:badge] || notification["badge"], + icon: notification[:icon] || notification["icon"], + data: notification[:data] || notification["data"] || %{} + } + end + + defp deliver_web_push(%WebSubscription{} = subscription, payload) do + case web_push_client() do + {module, opts} when is_atom(module) -> module.deliver(subscription, payload, opts) + module when is_atom(module) -> module.deliver(subscription, payload, []) + end + end + + defp web_push_client do + Application.get_env(:elektrine, :web_push_client, Elektrine.Push.WebPushClient) + end + + defp push_enabled? do + Application.get_env(:elektrine, :push, [])[:enabled] == true + end + + defp apns_topic do + Application.get_env(:elektrine, :push, [])[:apns_topic] || "com.elektrine.app" + end + + defp safe_presence_get_by_key(presence_module, topic, key) do + presence_module.get_by_key(topic, key) + rescue + error in ArgumentError -> + Logger.warning( + "Presence tracker unavailable while checking push eligibility: #{Exception.message(error)}" + ) + + [] + end + + defp component_enabled?(component) do + :elektrine + |> Application.get_env(:runtime_components, []) + |> Keyword.get(component, true) + end + + defp normalize_attrs(attrs) when is_map(attrs) do + attrs + |> Enum.flat_map(fn + {k, v} when is_binary(k) -> + case Map.fetch(@device_token_attr_keys, k) do + {:ok, atom_key} -> [{atom_key, v}] + :error -> [] + end + + {k, v} when is_atom(k) -> + [{k, v}] + + _ -> + [] + end) + |> Map.new() + end + + defp normalize_web_subscription_attrs(user_id, attrs) do + subscription = Map.get(attrs, "subscription") || Map.get(attrs, :subscription) || attrs + keys = Map.get(subscription, "keys") || Map.get(subscription, :keys) || %{} + data = Map.get(attrs, "data") || Map.get(attrs, :data) || %{} + endpoint = Map.get(subscription, "endpoint") || Map.get(subscription, :endpoint) + + %{ + user_id: user_id, + endpoint: endpoint, + endpoint_hash: web_push_endpoint_hash(endpoint), + p256dh: Map.get(keys, "p256dh") || Map.get(keys, :p256dh), + auth: Map.get(keys, "auth") || Map.get(keys, :auth), + alerts: normalize_web_alerts(Map.get(data, "alerts") || Map.get(data, :alerts)), + policy: normalize_web_policy(Map.get(data, "policy") || Map.get(data, :policy)), + enabled: true, + last_used_at: DateTime.utc_now() |> DateTime.truncate(:second), + failed_count: 0, + last_error: nil + } + end + + defp normalize_web_subscription_policy(attrs) do + data = Map.get(attrs, "data") || Map.get(attrs, :data) || attrs + + %{ + alerts: normalize_web_alerts(Map.get(data, "alerts") || Map.get(data, :alerts)), + policy: normalize_web_policy(Map.get(data, "policy") || Map.get(data, :policy)), + enabled: true, + last_used_at: DateTime.utc_now() |> DateTime.truncate(:second) + } + end + + defp normalize_web_alerts(nil), do: @default_web_alerts + + defp normalize_web_alerts(alerts) when is_map(alerts) do + alerts + |> Map.new(fn {key, value} -> {to_string(key), value in [true, "true", "1", 1, "on"]} end) + |> then(&Map.merge(@default_web_alerts, &1)) + end + + defp normalize_web_alerts(_alerts), do: @default_web_alerts + + defp normalize_web_policy(policy) when policy in ["all", "followed", "follower", "none"], + do: policy + + defp normalize_web_policy(_policy), do: "all" + + defp web_subscription_allows?(%WebSubscription{} = subscription, notification) do + alert_enabled?(subscription, notification) and policy_allows?(subscription, notification) + end + + defp alert_enabled?(%WebSubscription{alerts: alerts}, notification) when is_map(alerts) do + type = notification[:type] || notification["type"] + + type + |> alert_keys_for_type() + |> Enum.reduce_while(:missing, fn key, _status -> + case Map.fetch(alerts, key) do + {:ok, value} -> {:halt, value == true} + :error -> {:cont, :missing} + end + end) + |> case do + :missing -> true + allowed? -> allowed? + end + end + + defp alert_enabled?(_subscription, _notification), do: true + + defp alert_keys_for_type(type) do + case to_string(type || "") do + "like" -> ["like", "favourite", "favorite"] + "reaction" -> ["reaction", "favourite", "favorite"] + "boost" -> ["boost", "reblog"] + "share" -> ["share", "reblog"] + "new_message" -> ["new_message", "direct", "chat"] + type -> [type] + end + end + + defp policy_allows?(%WebSubscription{policy: "none"}, _notification), do: false + defp policy_allows?(%WebSubscription{policy: "all"}, _notification), do: true + + defp policy_allows?(%WebSubscription{policy: "followed", user_id: user_id}, notification) do + case notification_actor_id(notification) do + actor_id when is_integer(actor_id) -> Profiles.following?(user_id, actor_id) + _ -> true + end + rescue + _ -> false + end + + defp policy_allows?(%WebSubscription{policy: "follower", user_id: user_id}, notification) do + case notification_actor_id(notification) do + actor_id when is_integer(actor_id) -> Profiles.following?(actor_id, user_id) + _ -> true + end + rescue + _ -> false + end + + defp policy_allows?(_subscription, _notification), do: true + + defp notification_actor_id(notification) do + case notification[:actor_id] || notification["actor_id"] do + actor_id when is_integer(actor_id) -> + actor_id + + actor_id when is_binary(actor_id) -> + case Integer.parse(actor_id) do + {id, ""} -> id + _ -> nil + end + + _ -> + nil + end + end +end diff --git a/apps/elektrine/lib/elektrine/push/device_token.ex b/apps/elektrine/lib/elektrine/push/device_token.ex new file mode 100644 index 0000000..8ea5d5c --- /dev/null +++ b/apps/elektrine/lib/elektrine/push/device_token.ex @@ -0,0 +1,52 @@ +defmodule Elektrine.Push.DeviceToken do + @moduledoc """ + Schema for device tokens used for push notifications. + """ + use Ecto.Schema + import Ecto.Changeset + alias Elektrine.Secrets.EncryptedString + + schema "device_tokens" do + field :token, EncryptedString + field :token_hash, :string + field :platform, :string + field :app_version, :string + field :device_name, :string + field :device_model, :string + field :os_version, :string + field :bundle_id, :string + field :enabled, :boolean, default: true + field :last_used_at, :utc_datetime + field :failed_count, :integer, default: 0 + field :last_error, :string + + belongs_to :user, Elektrine.Accounts.User + + timestamps() + end + + @doc false + def changeset(device_token, attrs) do + device_token + |> cast(attrs, [ + :token, + :token_hash, + :platform, + :app_version, + :device_name, + :device_model, + :os_version, + :bundle_id, + :user_id, + :enabled, + :last_used_at, + :failed_count, + :last_error + ]) + |> validate_required([:token, :token_hash, :platform, :user_id]) + |> validate_length(:token_hash, is: 64) + |> validate_inclusion(:platform, ["ios", "android"]) + |> unique_constraint(:token_hash) + |> foreign_key_constraint(:user_id) + end +end diff --git a/apps/elektrine/lib/elektrine/push/web_push_client.ex b/apps/elektrine/lib/elektrine/push/web_push_client.ex new file mode 100644 index 0000000..273716a --- /dev/null +++ b/apps/elektrine/lib/elektrine/push/web_push_client.ex @@ -0,0 +1,197 @@ +defmodule Elektrine.Push.WebPushClient do + @moduledoc """ + Web Push delivery adapter implementing RFC 8291 (aes128gcm message + encryption) and RFC 8292 (VAPID) with OTP `:crypto`, no external deps. + + Requires VAPID keys in config: + + config :elektrine, :push, + web_push_public_key: "", + web_push_private_key: "", + web_push_subject: "mailto:admin@example.com" + + Generate a pair with `Elektrine.Push.WebPushClient.generate_vapid_keys/0`. + An alternative adapter can still be swapped in via + `config :elektrine, :web_push_client, MyApp.WebPushClient`. + """ + + require Logger + + @curve :prime256v1 + @record_size 4096 + @jwt_ttl_seconds 12 * 60 * 60 + + def deliver(subscription, payload, opts \\ []) do + config = Application.get_env(:elektrine, :push, []) + public_key = decode_key(config[:web_push_public_key]) + private_key = decode_key(config[:web_push_private_key]) + + if is_nil(public_key) or is_nil(private_key) do + Logger.debug("Web Push VAPID keys not configured; skipping subscription #{subscription.id}") + + {:ok, :not_configured} + else + send_push(subscription, payload, {public_key, private_key, vapid_subject(config)}, opts) + end + end + + @doc """ + Generates a VAPID keypair, returned as base64url strings suitable for + WEB_PUSH_PUBLIC_KEY / WEB_PUSH_PRIVATE_KEY. + """ + def generate_vapid_keys do + {public, private} = :crypto.generate_key(:ecdh, @curve) + + %{ + public_key: Base.url_encode64(public, padding: false), + private_key: Base.url_encode64(private, padding: false) + } + end + + defp send_push(subscription, payload, vapid, opts) do + with {:ok, ua_public} <- decode_subscription_key(subscription.p256dh, 65), + {:ok, auth_secret} <- decode_subscription_key(subscription.auth, 16) do + body = encrypt(Jason.encode!(payload), ua_public, auth_secret) + headers = build_headers(subscription.endpoint, vapid, opts) + + :post + |> Finch.build(subscription.endpoint, headers, body) + |> Finch.request(Elektrine.Finch, receive_timeout: 15_000) + |> handle_response(subscription) + end + end + + defp handle_response({:ok, %Finch.Response{status: status}}, _subscription) + when status in 200..299, + do: {:ok, :delivered} + + defp handle_response({:ok, %Finch.Response{status: status}}, subscription) + when status in [404, 410] do + Logger.info("Web Push subscription #{subscription.id} is gone (#{status})") + {:error, :subscription_gone} + end + + defp handle_response({:ok, %Finch.Response{status: status, body: body}}, _subscription), + do: {:error, {:http_error, status, String.slice(to_string(body), 0, 200)}} + + defp handle_response({:error, reason}, _subscription), do: {:error, reason} + + # --- RFC 8291 / RFC 8188 aes128gcm encryption --- + + @doc false + def encrypt(plaintext, ua_public, auth_secret) do + {as_public, as_private} = :crypto.generate_key(:ecdh, @curve) + ecdh_secret = :crypto.compute_key(:ecdh, ua_public, as_private, @curve) + salt = :crypto.strong_rand_bytes(16) + + prk_key = hmac(auth_secret, ecdh_secret) + key_info = "WebPush: info" <> <<0>> <> ua_public <> as_public + ikm = hkdf_expand(prk_key, key_info, 32) + + prk = hmac(salt, ikm) + cek = hkdf_expand(prk, "Content-Encoding: aes128gcm" <> <<0>>, 16) + nonce = hkdf_expand(prk, "Content-Encoding: nonce" <> <<0>>, 12) + + # single record: payload + 0x02 delimiter (last record), no padding + {ciphertext, tag} = + :crypto.crypto_one_time_aead(:aes_128_gcm, cek, nonce, plaintext <> <<2>>, <<>>, true) + + header = salt <> <<@record_size::unsigned-32, byte_size(as_public)::unsigned-8>> <> as_public + header <> ciphertext <> tag + end + + defp hmac(key, data), do: :crypto.mac(:hmac, :sha256, key, data) + + defp hkdf_expand(prk, info, length) do + prk |> hmac(info <> <<1>>) |> binary_part(0, length) + end + + # --- RFC 8292 VAPID --- + + defp build_headers(endpoint, {public_key, private_key, subject}, opts) do + %URI{scheme: scheme, host: host, port: port} = URI.parse(endpoint) + audience = "#{scheme}://#{host}#{audience_port(scheme, port)}" + exp = System.system_time(:second) + @jwt_ttl_seconds + + jwt = + sign_jwt( + %{"typ" => "JWT", "alg" => "ES256"}, + %{"aud" => audience, "exp" => exp, "sub" => subject}, + private_key + ) + + [ + {"authorization", "vapid t=#{jwt}, k=#{Base.url_encode64(public_key, padding: false)}"}, + {"content-encoding", "aes128gcm"}, + {"content-type", "application/octet-stream"}, + {"ttl", to_string(Keyword.get(opts, :ttl, 86_400))}, + {"urgency", Keyword.get(opts, :urgency, "normal")} + ] + end + + defp audience_port("https", 443), do: "" + defp audience_port("http", 80), do: "" + defp audience_port(_scheme, port), do: ":#{port}" + + @doc false + def sign_jwt(header, claims, private_key) do + signing_input = + Base.url_encode64(Jason.encode!(header), padding: false) <> + "." <> Base.url_encode64(Jason.encode!(claims), padding: false) + + der_signature = + :crypto.sign(:ecdsa, :sha256, signing_input, [private_key, @curve]) + + signing_input <> "." <> Base.url_encode64(der_to_raw(der_signature), padding: false) + end + + # JOSE ES256 signatures are raw r || s (32 bytes each); :crypto emits DER. + defp der_to_raw(der) do + {:"ECDSA-Sig-Value", r, s} = :public_key.der_decode(:"ECDSA-Sig-Value", der) + pad_to_32(:binary.encode_unsigned(r)) <> pad_to_32(:binary.encode_unsigned(s)) + end + + defp pad_to_32(bin) when byte_size(bin) >= 32, do: binary_part(bin, byte_size(bin) - 32, 32) + defp pad_to_32(bin), do: :binary.copy(<<0>>, 32 - byte_size(bin)) <> bin + + defp decode_subscription_key(value, expected_size) when is_binary(value) do + with {:ok, decoded} <- decode_base64_any(value), + true <- byte_size(decoded) == expected_size do + {:ok, decoded} + else + _ -> {:error, :invalid_subscription_keys} + end + end + + defp decode_subscription_key(_value, _size), do: {:error, :invalid_subscription_keys} + + defp decode_key(nil), do: nil + defp decode_key(""), do: nil + + defp decode_key(value) when is_binary(value) do + case decode_base64_any(value) do + {:ok, decoded} -> decoded + _ -> nil + end + end + + defp decode_base64_any(value) do + trimmed = String.trim(value) + + with :error <- Base.url_decode64(trimmed, padding: false), + :error <- Base.url_decode64(trimmed), + :error <- Base.decode64(trimmed, padding: false) do + Base.decode64(trimmed) + end + end + + defp vapid_subject(config) do + config[:web_push_subject] || "mailto:admin@#{primary_domain()}" + end + + defp primary_domain do + Application.get_env(:elektrine, :primary_domain) || + System.get_env("PRIMARY_DOMAIN") || + "localhost" + end +end diff --git a/apps/elektrine/lib/elektrine/push/web_subscription.ex b/apps/elektrine/lib/elektrine/push/web_subscription.ex new file mode 100644 index 0000000..959a42a --- /dev/null +++ b/apps/elektrine/lib/elektrine/push/web_subscription.ex @@ -0,0 +1,87 @@ +defmodule Elektrine.Push.WebSubscription do + @moduledoc """ + Browser Web Push subscription for API clients. + """ + + use Ecto.Schema + + import Ecto.Changeset + + alias Elektrine.Secrets.EncryptedString + alias Elektrine.Security.URLValidator + + @policies ["all", "followed", "follower", "none"] + + schema "web_push_subscriptions" do + field :endpoint, EncryptedString + field :endpoint_hash, :string + field :p256dh, EncryptedString + field :auth, EncryptedString + field :alerts, :map, default: %{} + field :policy, :string, default: "all" + field :enabled, :boolean, default: true + field :last_used_at, :utc_datetime + field :failed_count, :integer, default: 0 + field :last_error, :string + + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + def changeset(subscription, attrs) do + subscription + |> cast(attrs, [ + :endpoint, + :endpoint_hash, + :p256dh, + :auth, + :alerts, + :policy, + :enabled, + :last_used_at, + :failed_count, + :last_error, + :user_id + ]) + |> validate_required([:endpoint, :endpoint_hash, :p256dh, :auth, :user_id]) + |> validate_endpoint() + |> validate_length(:endpoint_hash, is: 64) + |> validate_inclusion(:policy, @policies) + |> validate_alerts() + |> unique_constraint(:endpoint_hash) + |> foreign_key_constraint(:user_id) + end + + defp validate_endpoint(changeset) do + validate_change(changeset, :endpoint, fn :endpoint, endpoint -> + case URI.parse(endpoint) do + %URI{scheme: "https", host: host} when is_binary(host) -> + if (Mix.env() == :test and String.ends_with?(host, ".example")) or + URLValidator.validate(endpoint) == :ok, + do: [], + else: [endpoint: "is not a safe public URL"] + + _ -> + [endpoint: "must be an HTTPS public URL"] + end + end) + end + + def update_changeset(subscription, attrs) do + subscription + |> cast(attrs, [:alerts, :policy, :enabled, :last_used_at, :failed_count, :last_error]) + |> validate_inclusion(:policy, @policies) + |> validate_alerts() + end + + defp validate_alerts(changeset) do + validate_change(changeset, :alerts, fn :alerts, alerts -> + if is_map(alerts) and Enum.all?(alerts, fn {_key, value} -> is_boolean(value) end) do + [] + else + [alerts: "must be a map of boolean alert flags"] + end + end) + end +end diff --git a/apps/elektrine/lib/elektrine/rate_limiter.ex b/apps/elektrine/lib/elektrine/rate_limiter.ex new file mode 100644 index 0000000..edfdde4 --- /dev/null +++ b/apps/elektrine/lib/elektrine/rate_limiter.ex @@ -0,0 +1,355 @@ +defmodule Elektrine.RateLimiter do + @moduledoc """ + Unified rate limiting functionality for all Elektrine systems. + + Uses ETS with GenServer for reliable, fast, in-memory rate limiting with automatic cleanup. + + ## Features + + - Multiple time windows (minute, hour, day) + - Configurable lockout on threshold breach + - Automatic cleanup of expired entries + - Standardized error format + - Per-identifier tracking (IP, user_id, etc.) + + ## Usage + + Define a rate limiter by using this module: + + defmodule MyApp.Auth.RateLimiter do + use Elektrine.RateLimiter, + table: :auth_rate_limit, + limits: [ + {:minute, 5}, + {:hour, 10} + ], + lockout: {:minutes, 30}, + cleanup_interval: {:minutes, 5} + end + + Then call functions: + + MyApp.Auth.RateLimiter.check_rate_limit("user@example.com") + MyApp.Auth.RateLimiter.record_attempt("user@example.com") + MyApp.Auth.RateLimiter.clear_limits("user@example.com") + """ + + defmacro __using__(opts) do + quote bind_quoted: [opts: opts] do + use GenServer + require Logger + + @table_name Keyword.fetch!(opts, :table) + @limits Keyword.get(opts, :limits, []) + @lockout Keyword.get(opts, :lockout) + @cleanup_interval Keyword.get(opts, :cleanup_interval, {:minutes, 5}) + + # Convert limits to standard format: [{window_seconds, max_attempts}, ...] + @parsed_limits Enum.map(@limits, fn + {:minute, max} -> + {60, max} + + {:hour, max} -> + {3600, max} + + {:day, max} -> + {86_400, max} + + {window_seconds, max} when is_integer(window_seconds) -> + {window_seconds, max} + end) + + # Convert lockout duration to seconds + @lockout_seconds (case @lockout do + nil -> nil + {:seconds, n} -> n + {:minutes, n} -> n * 60 + {:hours, n} -> n * 3600 + {:days, n} -> n * 86_400 + n when is_integer(n) -> n + end) + + # Convert cleanup interval to milliseconds + @cleanup_ms (case @cleanup_interval do + {:seconds, n} -> n * 1000 + {:minutes, n} -> n * 60 * 1000 + {:hours, n} -> n * 3600 * 1000 + n when is_integer(n) -> n + end) + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(_opts) do + table = :ets.new(@table_name, [:named_table, :public, :set]) + + # Schedule cleanup + if @cleanup_ms > 0 do + Process.send_after(self(), :cleanup, @cleanup_ms) + end + + {:ok, %{table: table}} + end + + @impl true + def handle_info(:cleanup, state) do + cleanup_expired_entries() + + # Schedule next cleanup + if @cleanup_ms > 0 do + Process.send_after(self(), :cleanup, @cleanup_ms) + end + + {:noreply, state} + end + + @doc """ + Checks if an identifier can proceed based on rate limits. + + Returns: + - `{:ok, :allowed}` - Can proceed + - `{:error, {:rate_limited, retry_after_seconds, reason}}` - Rate limited + """ + def check_rate_limit(identifier) do + case :ets.lookup(@table_name, identifier) do + [] -> + {:ok, :allowed} + + [{^identifier, attempts, locked_until}] -> + now = System.system_time(:second) + + # Check if locked out + if locked_until && now < locked_until do + retry_after = locked_until - now + {:error, {:rate_limited, retry_after, :locked_out}} + else + # Check each rate limit window + check_limits(attempts, now) + end + end + end + + @doc "Atomically checks and records an attempt for an identifier." + def check_and_record(identifier) do + :global.trans({__MODULE__, identifier}, fn -> + case check_rate_limit(identifier) do + {:ok, :allowed} = result -> + record_attempt(identifier) + result + + error -> + error + end + end) + end + + @doc """ + Records an attempt for rate limiting. + Automatically locks out if limits exceeded. + """ + if @lockout_seconds do + def record_attempt(identifier) do + now = System.system_time(:second) + + case :ets.lookup(@table_name, identifier) do + [] -> + # First attempt + :ets.insert(@table_name, {identifier, [now], nil}) + + [{^identifier, attempts, _locked_until}] -> + # Add new attempt and filter old ones + new_attempts = [now | filter_recent_attempts(attempts, now)] + + # Check if we should lock out + locked_until = calculate_lockout(new_attempts, now) + + if locked_until do + Logger.warning( + "Rate limit lockout for #{identifier} until #{DateTime.from_unix!(locked_until)}" + ) + end + + :ets.insert(@table_name, {identifier, new_attempts, locked_until}) + end + + :ok + end + else + def record_attempt(identifier) do + now = System.system_time(:second) + + case :ets.lookup(@table_name, identifier) do + [] -> + # First attempt + :ets.insert(@table_name, {identifier, [now], nil}) + + [{^identifier, attempts, _locked_until}] -> + # Add new attempt and filter old ones + new_attempts = [now | filter_recent_attempts(attempts, now)] + :ets.insert(@table_name, {identifier, new_attempts, nil}) + end + + :ok + end + end + + @doc """ + Clears all rate limiting data for an identifier. + Typically called on successful authentication. + """ + def clear_limits(identifier) do + :ets.delete(@table_name, identifier) + :ok + end + + @doc """ + Gets the current rate limit status for an identifier. + """ + def get_status(identifier) do + case :ets.lookup(@table_name, identifier) do + [] -> + %{ + locked: false, + locked_until: nil, + attempts: %{} + } + + [{^identifier, attempts, locked_until}] -> + now = System.system_time(:second) + + attempt_counts = + Enum.map(@parsed_limits, fn {window, max} -> + recent = count_recent(attempts, now, window) + {window, %{count: recent, limit: max, remaining: max(0, max - recent)}} + end) + |> Map.new() + + %{ + locked: locked_until && now < locked_until, + locked_until: locked_until, + attempts: attempt_counts + } + end + end + + @doc """ + Returns the configuration for this rate limiter. + """ + def config do + %{ + table: @table_name, + limits: @limits, + lockout: @lockout, + cleanup_interval: @cleanup_interval + } + end + + # Private functions + + defp check_limits(attempts, now) do + # Check each limit window + violation = + Enum.find_value(@parsed_limits, fn {window_seconds, max_attempts} -> + recent_count = count_recent(attempts, now, window_seconds) + + if recent_count >= max_attempts do + {window_seconds, recent_count, max_attempts} + end + end) + + case violation do + nil -> + {:ok, :allowed} + + {window_seconds, count, max} -> + reason = format_limit_reason(window_seconds, count, max) + {:error, {:rate_limited, window_seconds, reason}} + end + end + + defp count_recent(attempts, now, window_seconds) do + cutoff = now - window_seconds + Enum.count(attempts, fn timestamp -> timestamp > cutoff end) + end + + defp filter_recent_attempts(attempts, now) do + # Keep attempts from the largest window + max_window = + @parsed_limits + |> Enum.map(fn {window, _} -> window end) + |> Enum.max(fn -> 3600 end) + + cutoff = now - max_window + Enum.filter(attempts, fn timestamp -> timestamp > cutoff end) + end + + if @lockout_seconds do + defp calculate_lockout(attempts, now) do + # Check if any limit is exceeded + exceeded? = + Enum.any?(@parsed_limits, fn {window_seconds, max_attempts} -> + count_recent(attempts, now, window_seconds) >= max_attempts + end) + + if exceeded? do + now + @lockout_seconds + else + nil + end + end + else + defp calculate_lockout(_attempts, _now), do: nil + end + + defp format_limit_reason(60, count, max), do: "#{count}/#{max} attempts in last minute" + defp format_limit_reason(3600, count, max), do: "#{count}/#{max} attempts in last hour" + defp format_limit_reason(86_400, count, max), do: "#{count}/#{max} attempts in last day" + + defp format_limit_reason(seconds, count, max), + do: "#{count}/#{max} attempts in last #{seconds}s" + + defp cleanup_expired_entries do + now = System.system_time(:second) + + # Get the longest window we care about + max_window = + @parsed_limits + |> Enum.map(fn {window, _} -> window end) + |> Enum.max(fn -> 3600 end) + + # Double it for safety margin + cutoff = now - max_window * 2 + + # Get all entries and clean up old ones + :ets.tab2list(@table_name) + |> Enum.each(fn {identifier, attempts, locked_until} -> + # Remove if all attempts are old and not locked + oldest_attempt = List.last(attempts) + + if oldest_attempt && oldest_attempt < cutoff && (!locked_until || now >= locked_until) do + :ets.delete(@table_name, identifier) + else + # Clean up old attempts from the list + recent_attempts = Enum.filter(attempts, fn t -> t > cutoff end) + + if recent_attempts != attempts do + new_locked_until = + if locked_until && now >= locked_until, do: nil, else: locked_until + + :ets.insert(@table_name, {identifier, recent_attempts, new_locked_until}) + end + end + end) + end + + # Allow overriding in child modules + defoverridable check_rate_limit: 1, + check_and_record: 1, + record_attempt: 1, + clear_limits: 1, + get_status: 1 + end + end +end diff --git a/apps/elektrine/lib/elektrine/release.ex b/apps/elektrine/lib/elektrine/release.ex new file mode 100644 index 0000000..5680338 --- /dev/null +++ b/apps/elektrine/lib/elektrine/release.ex @@ -0,0 +1,98 @@ +defmodule Elektrine.Release do + @moduledoc """ + Used for executing DB release tasks when run in production without Mix + installed. + """ + @app :elektrine + @default_migration_pool_size 2 + @default_migration_timeout_ms 120_000 + @default_retry_attempts 30 + @default_retry_delay_ms 3_000 + + def migrate do + load_app() + + for repo <- repos() do + with_db_retry(fn -> + {:ok, _, _} = + Ecto.Migrator.with_repo( + repo, + &Ecto.Migrator.run(&1, :up, all: true), + migration_repo_opts() + ) + end) + end + end + + def rollback(repo, version) do + load_app() + + with_db_retry(fn -> + {:ok, _, _} = + Ecto.Migrator.with_repo( + repo, + &Ecto.Migrator.run(&1, :down, to: version), + migration_repo_opts() + ) + end) + end + + defp repos do + Application.fetch_env!(@app, :ecto_repos) + end + + defp migration_repo_opts do + opts = [ + pool_size: env_int("MIGRATION_POOL_SIZE", @default_migration_pool_size), + queue_target: env_int("MIGRATION_QUEUE_TARGET_MS", 10_000), + queue_interval: env_int("MIGRATION_QUEUE_INTERVAL_MS", 10_000), + timeout: env_int("MIGRATION_TIMEOUT_MS", @default_migration_timeout_ms), + pool_timeout: env_int("MIGRATION_POOL_TIMEOUT_MS", 30_000), + connect_timeout: env_int("MIGRATION_CONNECT_TIMEOUT_MS", 60_000) + ] + + case System.get_env("MIGRATION_DATABASE_URL") do + nil -> opts + "" -> opts + migration_database_url -> Keyword.put(opts, :url, migration_database_url) + end + end + + defp with_db_retry(fun, attempt \\ 1) do + max_attempts = env_int("MIGRATION_DB_RETRIES", @default_retry_attempts) + delay_ms = env_int("MIGRATION_RETRY_DELAY_MS", @default_retry_delay_ms) + + try do + fun.() + rescue + exception in [DBConnection.ConnectionError, Postgrex.Error] -> + if attempt < max_attempts do + IO.puts( + "Migration attempt #{attempt}/#{max_attempts} failed: #{Exception.message(exception)}" + ) + + Process.sleep(delay_ms) + with_db_retry(fun, attempt + 1) + else + reraise exception, __STACKTRACE__ + end + end + end + + defp env_int(name, default) do + case System.get_env(name) do + nil -> + default + + value -> + case Integer.parse(value) do + {int, ""} when int > 0 -> int + _ -> default + end + end + end + + defp load_app do + Application.load(@app) + end +end diff --git a/apps/elektrine/lib/elektrine/repo.ex b/apps/elektrine/lib/elektrine/repo.ex new file mode 100644 index 0000000..b8c8c13 --- /dev/null +++ b/apps/elektrine/lib/elektrine/repo.ex @@ -0,0 +1,5 @@ +defmodule Elektrine.Repo do + use Ecto.Repo, + otp_app: :elektrine, + adapter: Ecto.Adapters.Postgres +end diff --git a/apps/elektrine/lib/elektrine/reports.ex b/apps/elektrine/lib/elektrine/reports.ex new file mode 100644 index 0000000..330705b --- /dev/null +++ b/apps/elektrine/lib/elektrine/reports.ex @@ -0,0 +1,680 @@ +defmodule Elektrine.Reports do + @moduledoc """ + The Reports context for handling user reports across the platform. + """ + + import Ecto.Query, warn: false + alias Elektrine.Accounts + alias Elektrine.Accounts.TrustLevel + alias Elektrine.Accounts.User + alias Elektrine.Messaging + alias Elektrine.Messaging.ChatMessage + alias Elektrine.Notifications + alias Elektrine.Repo + alias Elektrine.Reports.Report + alias Elektrine.Social.{Conversation, Message} + alias Elektrine.Telemetry.Events + + @doc """ + Creates a report for any reportable entity with spam prevention. + """ + def create_report(attrs \\ %{}) do + reporter_id = report_attr(attrs, :reporter_id) + + with :ok <- validate_report_rate_limit(reporter_id), + :ok <- validate_not_spam_reporting(reporter_id), + {:ok, report} <- do_create_report(attrs) do + maybe_record_report_creation(report) + notify_admins_of_report(report) + emit_report_event(:create, :success, report_metadata(report)) + {:ok, report} + else + {:error, :rate_limited} -> + emit_report_event(:create, :failure, report_failure_metadata(attrs, :rate_limited)) + {:error, :rate_limited} + + {:error, :spam_detected} -> + emit_report_event(:create, :failure, report_failure_metadata(attrs, :spam_detected)) + {:error, :spam_detected} + + {:error, %Ecto.Changeset{}} = error -> + emit_report_event(:create, :failure, report_failure_metadata(attrs, :invalid)) + error + + error -> + emit_report_event(:create, :failure, report_failure_metadata(attrs, :unknown)) + error + end + end + + defp do_create_report(attrs) do + %Report{} + |> Report.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Gets a single report. + """ + def get_report!(id), do: Repo.get!(Report, id) + + @doc """ + Gets a report with associations preloaded. + """ + def get_report_with_preloads!(id) do + Report + |> Repo.get!(id) + |> Repo.preload([:reporter, :reviewed_by]) + end + + @doc """ + Returns a paginated slice of reports for admin surfaces. + """ + def paginate_reports(filters \\ %{}, page \\ 1, per_page \\ 50) do + page = normalize_page(page) + per_page = normalize_per_page(per_page) + query = reports_query(filters) + total_count = Repo.aggregate(query, :count, :id) + total_pages = total_pages(total_count, per_page) + safe_page = min(page, total_pages) + safe_offset = (safe_page - 1) * per_page + + entries = + query + |> order_by([r], desc: r.inserted_at) + |> preload([:reporter, :reviewed_by]) + |> limit(^per_page) + |> offset(^safe_offset) + |> Repo.all() + + %{ + entries: entries, + page: safe_page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages + } + end + + @doc """ + Lists pending reports count for admin dashboard. + """ + def count_pending_reports do + Report + |> where([r], r.status == "pending") + |> Repo.aggregate(:count, :id) + end + + @doc """ + Counts reports matching optional filters without loading rows. + """ + def count_reports(filters \\ %{}) do + filters + |> reports_query() + |> Repo.aggregate(:count, :id) + end + + @doc """ + Returns aggregate counts used by the admin reports dashboard. + """ + def dashboard_stats do + status_counts = + Report + |> group_by([r], r.status) + |> select([r], {r.status, count(r.id)}) + |> Repo.all() + |> Map.new() + + %{ + pending: Map.get(status_counts, "pending", 0), + reviewing: Map.get(status_counts, "reviewing", 0), + resolved: Map.get(status_counts, "resolved", 0), + critical: count_reports(%{priority: "critical", status: "pending"}) + } + end + + @doc """ + Checks if a user has already reported specific content. + """ + def already_reported?(reporter_id, reportable_type, reportable_id) do + Report + |> where([r], r.reporter_id == ^reporter_id) + |> where([r], r.reportable_type == ^reportable_type) + |> where([r], r.reportable_id == ^reportable_id) + |> where([r], r.status in ["pending", "reviewing"]) + |> Repo.exists?() + end + + @doc """ + Updates a report's review status. + """ + def review_report(%Report{} = report, attrs) do + attrs = Map.put(attrs, :reviewed_at, DateTime.utc_now()) + + report + |> Report.review_changeset(attrs) + |> Repo.update() + |> case do + {:ok, updated_report} = result -> + maybe_record_report_resolution(report, updated_report) + emit_report_event(:review, :success, report_review_metadata(report, updated_report)) + result + + {:error, %Ecto.Changeset{} = changeset} = error -> + emit_report_event( + :review, + :failure, + report_metadata(report) + |> Map.merge(%{reason: :invalid, errors: length(changeset.errors)}) + ) + + error + + error -> + emit_report_event( + :review, + :failure, + Map.merge(report_metadata(report), %{reason: :unknown}) + ) + + error + end + end + + @doc """ + Resolves a report and applies the selected moderation action when it has a + concrete side effect. + """ + def resolve_report(%Report{} = report, %User{} = reviewer, attrs \\ %{}) do + attrs = + attrs + |> atomize_review_attrs() + |> Map.merge(%{status: "resolved", reviewed_by_id: reviewer.id}) + + action = Map.get(attrs, :action_taken) + + case apply_resolution_action(report, reviewer, action) do + :ok -> + emit_report_event( + :action, + :success, + report_metadata(report) + |> Map.merge(%{action: normalize_report_action(action), reviewer_id: reviewer.id}) + ) + + review_report(report, attrs) + + {:error, reason} = error -> + emit_report_event( + :action, + :failure, + report_metadata(report) + |> Map.merge(%{ + action: normalize_report_action(action), + reviewer_id: reviewer.id, + reason: reason + }) + ) + + error + end + end + + @doc """ + Reopens a reviewed report and clears review metadata. + """ + def reopen_report(%Report{} = report) do + report + |> Report.review_changeset(%{ + status: "pending", + reviewed_by_id: nil, + reviewed_at: nil, + resolution_notes: nil, + action_taken: nil + }) + |> Repo.update() + |> case do + {:ok, updated_report} = result -> + emit_report_event(:reopen, :success, report_review_metadata(report, updated_report)) + result + + {:error, %Ecto.Changeset{} = changeset} = error -> + emit_report_event( + :reopen, + :failure, + report_metadata(report) + |> Map.merge(%{reason: :invalid, errors: length(changeset.errors)}) + ) + + error + end + end + + @doc """ + Gets reports for a specific entity. + """ + def get_reports_for(reportable_type, reportable_id) do + Report + |> where([r], r.reportable_type == ^reportable_type) + |> where([r], r.reportable_id == ^reportable_id) + |> preload([:reporter, :reviewed_by]) + |> order_by([r], desc: r.inserted_at) + |> Repo.all() + end + + # Private helper functions + + defp reports_query(filters) do + Report + |> filter_by_status(filters[:status]) + |> filter_by_priority(filters[:priority]) + |> filter_by_reportable_type(filters[:reportable_type]) + |> filter_by_reporter(filters[:reporter_id]) + end + + defp filter_by_status(query, nil), do: query + + defp filter_by_status(query, status) do + where(query, [r], r.status == ^status) + end + + defp filter_by_priority(query, nil), do: query + + defp filter_by_priority(query, priority) do + where(query, [r], r.priority == ^priority) + end + + defp filter_by_reportable_type(query, nil), do: query + + defp filter_by_reportable_type(query, type) do + where(query, [r], r.reportable_type == ^type) + end + + defp filter_by_reporter(query, nil), do: query + + defp filter_by_reporter(query, reporter_id) do + where(query, [r], r.reporter_id == ^reporter_id) + end + + defp notify_admins_of_report(%Report{} = report) do + admin_ids = + User + |> where([u], u.is_admin == true and u.banned == false and u.suspended == false) + |> select([u], u.id) + |> Repo.all() + + case admin_ids do + [] -> + :ok + + ids -> + {:ok, _count} = + Notifications.create_bulk_notifications(ids, %{ + type: "system", + title: "New report", + body: report_notification_body(report), + url: "/pripyat/reports", + icon: "hero-flag", + priority: report_notification_priority(report.priority), + actor_id: report.reporter_id, + source_type: "report", + source_id: report.id, + metadata: %{ + "report_id" => report.id, + "reportable_type" => report.reportable_type, + "reportable_id" => report.reportable_id, + "reason" => report.reason, + "priority" => report.priority + } + }) + + :ok + end + end + + defp report_notification_body(%Report{} = report) do + "A #{report.reason || "policy"} report was filed for #{report.reportable_type || "content"} ##{report.reportable_id}." + end + + defp report_notification_priority("critical"), do: "urgent" + defp report_notification_priority("high"), do: "high" + defp report_notification_priority(_), do: "normal" + + defp atomize_review_attrs(attrs) when is_map(attrs) do + Enum.reduce(attrs, %{}, fn + {"status", value}, acc -> Map.put(acc, :status, value) + {"priority", value}, acc -> Map.put(acc, :priority, value) + {"reviewed_by_id", value}, acc -> Map.put(acc, :reviewed_by_id, value) + {"reviewed_at", value}, acc -> Map.put(acc, :reviewed_at, value) + {"resolution_notes", value}, acc -> Map.put(acc, :resolution_notes, value) + {"action_taken", value}, acc -> Map.put(acc, :action_taken, value) + {key, value}, acc when is_atom(key) -> Map.put(acc, key, value) + {_key, _value}, acc -> acc + end) + end + + defp apply_resolution_action(_report, _reviewer, action) + when action in [nil, "", "warned", "no_action"], + do: :ok + + defp apply_resolution_action(%Report{} = report, %User{} = reviewer, "content_removed") do + if report.reportable_type == "message" and is_integer(report.reportable_id) do + case Messaging.admin_delete_message(report.reportable_id, reviewer) do + {:ok, _message} -> :ok + {:error, :already_deleted} -> :ok + {:error, reason} -> {:error, reason} + end + else + {:error, :unsupported_report_target} + end + end + + defp apply_resolution_action(%Report{} = report, _reviewer, "suspended") do + with {:ok, user} <- report_target_user(report) do + suspended_until = DateTime.utc_now() |> DateTime.add(7, :day) |> DateTime.truncate(:second) + + case Accounts.suspend_user(user, %{ + suspended_until: suspended_until, + suspension_reason: "Suspended via report ##{report.id}" + }) do + {:ok, _user} -> :ok + {:error, reason} -> {:error, reason} + end + end + end + + defp apply_resolution_action(%Report{} = report, _reviewer, "banned") do + with {:ok, user} <- report_target_user(report) do + case Accounts.ban_user(user, %{banned_reason: "Banned via report ##{report.id}"}) do + {:ok, _user} -> :ok + {:error, reason} -> {:error, reason} + end + end + end + + defp apply_resolution_action(_report, _reviewer, _action), do: {:error, :invalid_action} + + defp report_target_user(%Report{reportable_type: "user", reportable_id: user_id}) + when is_integer(user_id) and user_id > 0 do + fetch_report_target_user(user_id) + end + + defp report_target_user(%Report{reportable_type: "message", reportable_id: message_id}) + when is_integer(message_id) and message_id > 0 do + case Repo.get(Message, message_id) || Repo.get(ChatMessage, message_id) do + %{sender_id: sender_id} when is_integer(sender_id) -> fetch_report_target_user(sender_id) + _message -> {:error, :target_user_not_found} + end + end + + defp report_target_user(%Report{} = report) do + report.metadata + |> metadata_value("account_id") + |> parse_report_int() + |> case do + user_id when is_integer(user_id) and user_id > 0 -> fetch_report_target_user(user_id) + _ -> {:error, :target_user_not_found} + end + end + + defp fetch_report_target_user(user_id) do + case Repo.get(User, user_id) do + %User{} = user -> {:ok, user} + nil -> {:error, :target_user_not_found} + end + end + + defp metadata_value(metadata, key) when is_map(metadata) do + Map.get(metadata, key) || Map.get(metadata, metadata_atom_key(key)) + end + + defp metadata_value(_metadata, _key), do: nil + + defp metadata_atom_key("account_id"), do: :account_id + defp metadata_atom_key("status_ids"), do: :status_ids + defp metadata_atom_key("forward"), do: :forward + defp metadata_atom_key("rule_ids"), do: :rule_ids + defp metadata_atom_key(_key), do: nil + + defp parse_report_int(value) when is_integer(value), do: value + + defp parse_report_int(value) when is_binary(value) do + case Integer.parse(value) do + {int, ""} -> int + _ -> nil + end + end + + defp parse_report_int(_value), do: nil + + defp report_attr(attrs, key) when is_map(attrs) do + Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key)) + end + + defp report_metadata(%Report{} = report) do + %{ + report_id: report.id, + reporter_id: report.reporter_id, + reportable_type: report.reportable_type, + reportable_id: report.reportable_id, + reason: report.reason, + priority: report.priority, + status: report.status, + action: normalize_report_action(report.action_taken) + } + end + + defp report_review_metadata(%Report{} = report, %Report{} = updated_report) do + report_metadata(updated_report) + |> Map.merge(%{ + previous_status: report.status, + previous_action: normalize_report_action(report.action_taken), + reviewer_id: updated_report.reviewed_by_id + }) + end + + defp report_failure_metadata(attrs, reason) when is_map(attrs) do + %{ + reporter_id: report_attr(attrs, :reporter_id), + reportable_type: report_attr(attrs, :reportable_type), + reportable_id: report_attr(attrs, :reportable_id), + reason: reason + } + end + + defp normalize_report_action(action) when action in [nil, ""], do: :none + defp normalize_report_action(action), do: action + + defp emit_report_event(operation, outcome, metadata) do + Events.report(operation, outcome, metadata) + end + + defp normalize_page(page) when is_integer(page) and page > 0, do: page + defp normalize_page(_page), do: 1 + + defp normalize_per_page(per_page) when is_integer(per_page) and per_page > 0, do: per_page + defp normalize_per_page(_per_page), do: 50 + + defp total_pages(total_count, per_page) when total_count > 0 and per_page > 0 do + div(total_count + per_page - 1, per_page) + end + + defp total_pages(_, _), do: 1 + + @doc """ + Builds metadata for different reportable types. + """ + def build_metadata("user", user_id) do + case Elektrine.Accounts.get_user!(user_id) do + nil -> + %{} + + user -> + %{ + "username" => user.username, + "display_name" => user.display_name + } + end + rescue + _ -> %{} + end + + def build_metadata("message", message_id) do + # Add logic to fetch message details when needed + %{"message_id" => message_id} + end + + def build_metadata("conversation", conversation_id) do + # Add logic to fetch conversation details when needed + %{"conversation_id" => conversation_id} + end + + def build_metadata(_, _), do: %{} + + defp maybe_record_report_creation(%Report{} = report) do + if local_reporter?(report) do + TrustLevel.increment_stat(report.reporter_id, :flags_given) + end + + report + |> reported_user_ids() + |> Enum.each(&TrustLevel.increment_stat(&1, :flags_received)) + end + + defp maybe_record_report_resolution(%Report{} = report, %Report{} = updated_report) do + if local_reporter?(report) && actionable_transition?(report, updated_report) do + TrustLevel.increment_stat(report.reporter_id, :flags_agreed) + end + end + + defp local_reporter?(%Report{metadata: metadata}) when is_map(metadata) do + not Map.has_key?(metadata, "remote_reporter") + end + + defp local_reporter?(_), do: true + + defp actionable_transition?(%Report{} = report, %Report{} = updated_report) do + not actionable_action?(report.action_taken) and + actionable_action?(updated_report.action_taken) + end + + defp actionable_action?(action), + do: action in ["warned", "suspended", "banned", "content_removed"] + + defp reported_user_ids(%Report{reportable_type: "user", reportable_id: user_id}) + when is_integer(user_id), + do: [user_id] + + defp reported_user_ids(%Report{reportable_type: reportable_type, reportable_id: reportable_id}) + when reportable_type in ["message", "post"] do + case Repo.get(Message, reportable_id) do + %Message{sender_id: sender_id} when is_integer(sender_id) -> [sender_id] + _ -> [] + end + end + + defp reported_user_ids(%Report{reportable_type: "conversation", reportable_id: conversation_id}) do + case Repo.get(Conversation, conversation_id) do + %Conversation{creator_id: creator_id} when is_integer(creator_id) -> [creator_id] + _ -> [] + end + end + + defp reported_user_ids(_report), do: [] + + # Spam Prevention Functions + + @doc """ + Rate limiting: Maximum 5 reports per hour per user + """ + def validate_report_rate_limit(reporter_id) do + one_hour_ago = DateTime.utc_now() |> DateTime.add(-3600, :second) + + recent_reports_count = + Report + |> where([r], r.reporter_id == ^reporter_id) + |> where([r], r.inserted_at > ^one_hour_ago) + |> Repo.aggregate(:count, :id) + + if recent_reports_count >= 5 do + {:error, :rate_limited} + else + :ok + end + end + + @doc """ + Check if user is spam reporting (too many dismissed reports) + """ + def validate_not_spam_reporting(reporter_id) do + # Check dismissed report ratio in last 30 days + thirty_days_ago = DateTime.utc_now() |> DateTime.add(-30 * 86_400, :second) + + total_reports = + Report + |> where([r], r.reporter_id == ^reporter_id) + |> where([r], r.inserted_at > ^thirty_days_ago) + |> Repo.aggregate(:count, :id) + + dismissed_reports = + Report + |> where([r], r.reporter_id == ^reporter_id) + |> where([r], r.inserted_at > ^thirty_days_ago) + |> where([r], r.status == "dismissed") + |> Repo.aggregate(:count, :id) + + # If more than 80% of reports are dismissed and user has made >10 reports, flag as spam + if total_reports > 10 && dismissed_reports / total_reports > 0.8 do + {:error, :spam_detected} + else + :ok + end + end + + @doc """ + Get time until user can report again (in seconds) + """ + def get_report_cooldown(reporter_id) do + last_report = + Report + |> where([r], r.reporter_id == ^reporter_id) + |> order_by([r], desc: r.inserted_at) + |> limit(1) + |> Repo.one() + + case last_report do + nil -> + 0 + + report -> + # 30 second cooldown between reports + cooldown_seconds = 30 + elapsed = DateTime.diff(DateTime.utc_now(), report.inserted_at, :second) + max(0, cooldown_seconds - elapsed) + end + end + + @doc """ + Check if user can submit a report (combines all spam checks) + """ + def can_user_report?(reporter_id) do + with :ok <- validate_report_rate_limit(reporter_id), + :ok <- validate_not_spam_reporting(reporter_id), + 0 <- get_report_cooldown(reporter_id) do + {:ok, true} + else + {:error, :rate_limited} -> + {:error, "You have exceeded the report limit. Please try again later."} + + {:error, :spam_detected} -> + {:error, + "Your reporting privileges have been temporarily restricted due to excessive false reports."} + + cooldown when is_integer(cooldown) and cooldown > 0 -> + {:error, "Please wait #{cooldown} seconds before submitting another report."} + + _ -> + {:error, "Unable to submit report at this time."} + end + end +end diff --git a/apps/elektrine/lib/elektrine/reports/report.ex b/apps/elektrine/lib/elektrine/reports/report.ex new file mode 100644 index 0000000..ba521dc --- /dev/null +++ b/apps/elektrine/lib/elektrine/reports/report.ex @@ -0,0 +1,76 @@ +defmodule Elektrine.Reports.Report do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + alias Elektrine.Accounts.User + + @reasons ~w(spam harassment inappropriate violence hate_speech impersonation self_harm misinformation other) + @statuses ~w(pending reviewing resolved dismissed) + @priorities ~w(low normal high critical) + @actions ~w(warned suspended banned content_removed no_action) + + schema "reports" do + belongs_to :reporter, User + belongs_to :reviewed_by, User + + field :reportable_type, :string + field :reportable_id, :integer + + field :reason, :string + field :description, :string + field :screenshots, {:array, :string}, default: [] + + field :status, :string, default: "pending" + field :priority, :string, default: "normal" + + field :reviewed_at, :utc_datetime + field :resolution_notes, :string + field :action_taken, :string + + field :metadata, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + def changeset(report, attrs) do + report + |> cast(attrs, [ + :reporter_id, + :reportable_type, + :reportable_id, + :reason, + :description, + :screenshots, + :status, + :priority, + :metadata + ]) + |> validate_required([:reporter_id, :reportable_type, :reportable_id, :reason]) + |> validate_inclusion(:reason, @reasons) + |> validate_inclusion(:status, @statuses) + |> validate_inclusion(:priority, @priorities) + |> validate_length(:description, max: 5000) + |> foreign_key_constraint(:reporter_id) + end + + def review_changeset(report, attrs) do + report + |> cast(attrs, [ + :status, + :priority, + :reviewed_by_id, + :reviewed_at, + :resolution_notes, + :action_taken + ]) + |> validate_inclusion(:status, @statuses) + |> validate_inclusion(:priority, @priorities) + |> validate_inclusion(:action_taken, @actions) + |> foreign_key_constraint(:reviewed_by_id) + end + + def reasons, do: @reasons + def statuses, do: @statuses + def priorities, do: @priorities + def actions, do: @actions +end diff --git a/apps/elektrine/lib/elektrine/rss.ex b/apps/elektrine/lib/elektrine/rss.ex new file mode 100644 index 0000000..3ae7d80 --- /dev/null +++ b/apps/elektrine/lib/elektrine/rss.ex @@ -0,0 +1,294 @@ +defmodule Elektrine.RSS do + @moduledoc """ + Context for RSS feed subscriptions and items. + """ + + import Ecto.Query + import Ecto.Changeset + alias Elektrine.Repo + alias Elektrine.RSS.{Feed, Item, Subscription} + alias Elektrine.Security.URLValidator + + ## Feeds + + @doc """ + Gets or creates a feed by URL. + """ + def get_or_create_feed(url) do + normalized_url = normalize_url(url) + + case URLValidator.validate(normalized_url) do + :ok -> + case Repo.get_by(Feed, url: normalized_url) do + nil -> + %Feed{} + |> Feed.changeset(%{url: normalized_url, status: "pending"}) + |> Repo.insert() + + feed -> + {:ok, feed} + end + + {:error, _reason} -> + {:error, invalid_feed_url_changeset(normalized_url)} + end + end + + @doc """ + Gets a feed by ID. + """ + def get_feed(id), do: Repo.get(Feed, id) + + @doc """ + Gets a feed by URL. + """ + def get_feed_by_url(url) do + normalized_url = normalize_url(url) + + case URLValidator.validate(normalized_url) do + :ok -> Repo.get_by(Feed, url: normalized_url) + {:error, _reason} -> nil + end + end + + @doc """ + Updates a feed. + """ + def update_feed(%Feed{} = feed, attrs) do + feed + |> Feed.changeset(attrs) + |> Repo.update() + end + + @doc """ + Lists all feeds that need to be fetched (stale feeds). + """ + def list_stale_feeds(limit \\ 50) do + now = DateTime.utc_now() + # Default threshold: feeds not fetched in the last 60 minutes + threshold = DateTime.add(now, -60, :minute) + + from(f in Feed, + where: f.status == "active", + where: is_nil(f.last_fetched_at) or f.last_fetched_at < ^threshold, + order_by: [asc: f.last_fetched_at], + limit: ^limit + ) + |> Repo.all() + end + + ## Subscriptions + + @doc """ + Subscribe a user to a feed URL. + Creates the feed if it doesn't exist. + """ + def subscribe(user_id, url, opts \\ []) do + display_name = Keyword.get(opts, :display_name) + folder = Keyword.get(opts, :folder) + + with {:ok, feed} <- get_or_create_feed(url) do + %Subscription{} + |> Subscription.changeset(%{ + user_id: user_id, + feed_id: feed.id, + display_name: display_name, + folder: folder + }) + |> Repo.insert() + |> case do + {:ok, subscription} -> {:ok, %{subscription | feed: feed}} + {:error, changeset} -> {:error, changeset} + end + end + end + + @doc """ + Unsubscribe a user from a feed. + """ + def unsubscribe(user_id, feed_id) do + from(s in Subscription, + where: s.user_id == ^user_id and s.feed_id == ^feed_id + ) + |> Repo.delete_all() + |> case do + {0, _} -> {:error, :not_subscribed} + {count, _} -> {:ok, count} + end + end + + @doc """ + Lists all subscriptions for a user. + """ + def list_subscriptions(user_id) do + from(s in Subscription, + where: s.user_id == ^user_id, + preload: [:feed], + order_by: [asc: s.folder, asc: s.display_name] + ) + |> Repo.all() + end + + @doc """ + Updates a subscription. + """ + def update_subscription(%Subscription{} = subscription, attrs) do + subscription + |> Subscription.changeset(attrs) + |> Repo.update() + end + + ## Items + + @doc """ + Creates or updates an item for a feed. + Uses guid to detect duplicates. + """ + def upsert_item(feed_id, attrs) do + guid = attrs[:guid] || attrs["guid"] + + case Repo.get_by(Item, feed_id: feed_id, guid: guid) do + nil -> + %Item{} + |> Item.changeset(Map.put(attrs, :feed_id, feed_id)) + |> Repo.insert() + + item -> + # Only update if content might have changed + item + |> Item.changeset(attrs) + |> Repo.update() + end + end + + @doc """ + Gets items for a feed. + """ + def list_feed_items(feed_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + offset = Keyword.get(opts, :offset, 0) + + from(i in Item, + where: i.feed_id == ^feed_id, + order_by: [desc: i.published_at, desc: i.inserted_at], + limit: ^limit, + offset: ^offset + ) + |> Repo.all() + end + + @doc """ + Gets items for all feeds a user is subscribed to. + Only returns items from subscriptions where show_in_timeline is true. + """ + def list_user_items(user_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 20) + offset = Keyword.get(opts, :offset, 0) + + from(i in Item, + join: s in Subscription, + on: s.feed_id == i.feed_id and s.user_id == ^user_id and s.show_in_timeline == true, + order_by: [desc: i.published_at, desc: i.inserted_at], + limit: ^limit, + offset: ^offset, + preload: [feed: []] + ) + |> Repo.all() + end + + @doc """ + Gets items for display in timeline, with feed info. + Returns items as a list of maps suitable for timeline display. + """ + def get_timeline_items(user_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 20) + before = Keyword.get(opts, :before) + sort = Keyword.get(opts, :sort, "new") + order_by = timeline_order_by(sort) + + query = + from(i in Item, + join: s in Subscription, + on: s.feed_id == i.feed_id and s.user_id == ^user_id and s.show_in_timeline == true, + join: f in Feed, + on: f.id == i.feed_id, + order_by: ^order_by, + limit: ^limit + ) + |> select_timeline_item() + + query = + if before do + where(query, [i], i.published_at < ^before or i.inserted_at < ^before) + else + query + end + + Repo.all(query) + end + + @doc """ + Counts total items for a user's subscriptions. + """ + def count_user_items(user_id) do + from(i in Item, + join: s in Subscription, + on: s.feed_id == i.feed_id and s.user_id == ^user_id, + select: count(i.id) + ) + |> Repo.one() + end + + defp select_timeline_item(query) do + select(query, [i, _s, f], %{ + id: i.id, + type: :rss_item, + title: i.title, + content: i.content, + summary: i.summary, + url: i.url, + author: i.author, + published_at: i.published_at, + inserted_at: i.inserted_at, + image_url: i.image_url, + enclosure_url: i.enclosure_url, + enclosure_type: i.enclosure_type, + categories: i.categories, + feed_id: f.id, + feed_title: f.title, + feed_url: f.url, + feed_image_url: f.image_url, + feed_favicon_url: f.favicon_url, + feed_site_url: f.site_url + }) + end + + ## Helpers + + defp normalize_url(url) do + url + |> String.trim() + |> URI.parse() + |> then(fn uri -> + # Ensure scheme + uri = + if uri.scheme do + uri + else + %{uri | scheme: "https"} + end + + # Build normalized URL + URI.to_string(uri) + end) + end + + defp invalid_feed_url_changeset(url) do + %Feed{} + |> Feed.changeset(%{url: url}) + |> add_error(:url, "must be a public http or https URL") + end + + defp timeline_order_by("old"), do: [asc_nulls_last: :published_at, asc: :inserted_at] + defp timeline_order_by(_sort), do: [desc_nulls_last: :published_at, desc: :inserted_at] +end diff --git a/apps/elektrine/lib/elektrine/rss/feed.ex b/apps/elektrine/lib/elektrine/rss/feed.ex new file mode 100644 index 0000000..a97d5f1 --- /dev/null +++ b/apps/elektrine/lib/elektrine/rss/feed.ex @@ -0,0 +1,88 @@ +defmodule Elektrine.RSS.Feed do + @moduledoc """ + Schema for RSS/Atom feeds. + Stores feed metadata and fetch status. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "rss_feeds" do + field :url, :string + field :title, :string + field :description, :string + field :site_url, :string + field :favicon_url, :string + field :image_url, :string + field :last_fetched_at, :utc_datetime + field :last_error, :string + field :fetch_interval_minutes, :integer, default: 60 + field :status, :string, default: "active" + field :etag, :string + field :last_modified, :string + + has_many :subscriptions, Elektrine.RSS.Subscription + has_many :items, Elektrine.RSS.Item + + timestamps() + end + + @doc false + def changeset(feed, attrs) do + feed + |> cast(attrs, [ + :url, + :title, + :description, + :site_url, + :favicon_url, + :image_url, + :last_fetched_at, + :last_error, + :fetch_interval_minutes, + :status, + :etag, + :last_modified + ]) + |> update_change(:last_fetched_at, &Elektrine.Time.truncate/1) + |> validate_required([:url]) + |> unique_constraint(:url) + |> validate_inclusion(:status, ["pending", "active", "paused", "error"]) + end + + @doc """ + Changeset for updating feed metadata after a successful fetch. + """ + def metadata_changeset(feed, attrs) do + feed + |> cast(attrs, [ + :title, + :description, + :site_url, + :favicon_url, + :image_url, + :etag, + :last_modified + ]) + end + + @doc """ + Changeset for marking a feed as fetched. + """ + def fetched_changeset(feed, attrs \\ %{}) do + feed + |> cast(attrs, [:last_fetched_at, :last_error, :status, :etag, :last_modified]) + |> put_change(:last_fetched_at, DateTime.utc_now() |> DateTime.truncate(:second)) + |> put_change(:last_error, nil) + |> put_change(:status, "active") + end + + @doc """ + Changeset for recording a fetch error. + """ + def error_changeset(feed, error_message) do + feed + |> change() + |> put_change(:last_error, error_message) + |> put_change(:status, "error") + end +end diff --git a/apps/elektrine/lib/elektrine/rss/fetch_feed_worker.ex b/apps/elektrine/lib/elektrine/rss/fetch_feed_worker.ex new file mode 100644 index 0000000..378c6c2 --- /dev/null +++ b/apps/elektrine/lib/elektrine/rss/fetch_feed_worker.ex @@ -0,0 +1,187 @@ +defmodule Elektrine.RSS.FetchFeedWorker do + @moduledoc """ + Oban worker that fetches and parses an RSS/Atom feed, storing new items. + """ + use Oban.Worker, queue: :rss, max_attempts: 3 + + require Logger + + alias Elektrine.HTTP.SafeFetch + alias Elektrine.RSS + alias Elektrine.RSS.Feed + alias Elektrine.Security.URLValidator + + @user_agent "Elektrine/1.0 (RSS Reader)" + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"feed_id" => feed_id}}) do + case RSS.get_feed(feed_id) do + nil -> + Logger.warning("Feed not found: #{feed_id}") + {:discard, :feed_not_found} + + feed -> + fetch_and_process(feed) + end + end + + defp fetch_and_process(%Feed{} = feed) do + headers = build_headers(feed) + + request = Finch.build(:get, feed.url, headers) + + case SafeFetch.request(request, Elektrine.Finch, receive_timeout: 30_000) do + {:ok, %Finch.Response{status: 200, body: body, headers: resp_headers}} -> + process_feed(feed, body, resp_headers) + + {:ok, %Finch.Response{status: 304}} -> + # Not modified, just update last_fetched_at + RSS.update_feed(feed, %{last_fetched_at: Elektrine.Time.utc_now()}) + :ok + + {:ok, %Finch.Response{status: status, headers: redirect_headers}} when status in 301..308 -> + # Handle redirects + case get_header(redirect_headers, "location") do + nil -> + RSS.update_feed(feed, %{ + last_error: "Redirect without location header", + last_fetched_at: Elektrine.Time.utc_now() + }) + + {:error, :redirect_without_location} + + new_url -> + case validate_redirect_target(new_url) do + {:ok, safe_url} -> + Logger.info("Feed #{feed.id} redirected to #{safe_url}") + RSS.update_feed(feed, %{url: safe_url}) + {:snooze, 5} + + {:error, _reason} -> + RSS.update_feed(feed, %{ + last_error: "Unsafe redirect target", + last_fetched_at: Elektrine.Time.utc_now() + }) + + {:discard, :unsafe_redirect_target} + end + end + + {:ok, %Finch.Response{status: status}} -> + RSS.update_feed(feed, %{ + last_error: "HTTP #{status}", + last_fetched_at: DateTime.utc_now() + }) + + if status in 400..499 and status != 429 do + {:discard, {:http_error, status}} + else + {:error, {:http_error, status}} + end + + {:error, reason} -> + RSS.update_feed(feed, %{ + last_error: inspect(reason), + last_fetched_at: DateTime.utc_now() + }) + + {:error, reason} + end + end + + defp build_headers(%Feed{} = feed) do + headers = [ + {"user-agent", @user_agent}, + {"accept", "application/rss+xml, application/atom+xml, application/xml, text/xml, */*"} + ] + + headers = + if feed.etag do + [{"if-none-match", feed.etag} | headers] + else + headers + end + + headers = + if feed.last_modified do + [{"if-modified-since", feed.last_modified} | headers] + else + headers + end + + headers + end + + defp process_feed(feed, body, resp_headers) do + case Elektrine.RSS.Parser.parse(body) do + {:ok, parsed_feed} -> + # Update feed metadata + feed_attrs = %{ + title: parsed_feed.title || feed.title, + description: parsed_feed.subtitle || feed.description, + site_url: parsed_feed.link || feed.site_url, + image_url: parsed_feed.image_url || feed.image_url, + last_fetched_at: DateTime.utc_now(), + last_error: nil, + etag: get_header(resp_headers, "etag"), + last_modified: get_header(resp_headers, "last-modified"), + status: "active" + } + + RSS.update_feed(feed, feed_attrs) + + # Store items + Enum.each(parsed_feed.entries, fn entry -> + item_attrs = %{ + guid: entry.guid, + title: entry.title, + content: entry.content, + summary: entry.summary, + url: entry.link, + author: entry.author, + published_at: entry.published_at, + image_url: entry[:image_url] || detect_image(entry), + enclosure_url: entry.enclosure_url, + enclosure_type: entry.enclosure_type, + categories: entry.categories || [] + } + + RSS.upsert_item(feed.id, item_attrs) + end) + + :ok + + {:error, reason} -> + RSS.update_feed(feed, %{ + last_error: "Parse error: #{inspect(reason)}", + last_fetched_at: DateTime.utc_now() + }) + + {:error, {:parse_error, reason}} + end + end + + defp get_header(headers, name) do + case List.keyfind(headers, name, 0) do + {_, value} -> value + nil -> nil + end + end + + # Detect image from enclosure if it's an image type + defp detect_image(%{enclosure_type: type, enclosure_url: url}) + when is_binary(url) and is_binary(type) do + if String.starts_with?(type, "image/"), do: url, else: nil + end + + defp detect_image(_), do: nil + + defp validate_redirect_target(url) when is_binary(url) do + case URLValidator.validate(url) do + :ok -> {:ok, String.trim(url)} + {:error, reason} -> {:error, reason} + end + end + + defp validate_redirect_target(_), do: {:error, :invalid_url} +end diff --git a/apps/elektrine/lib/elektrine/rss/item.ex b/apps/elektrine/lib/elektrine/rss/item.ex new file mode 100644 index 0000000..8fb4c91 --- /dev/null +++ b/apps/elektrine/lib/elektrine/rss/item.ex @@ -0,0 +1,47 @@ +defmodule Elektrine.RSS.Item do + @moduledoc """ + Schema for individual RSS/Atom feed items. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "rss_items" do + belongs_to :feed, Elektrine.RSS.Feed + + field :guid, :string + field :title, :string + field :content, :string + field :summary, :string + field :url, :string + field :author, :string + field :published_at, :utc_datetime + field :image_url, :string + field :enclosure_url, :string + field :enclosure_type, :string + field :categories, {:array, :string}, default: [] + + timestamps() + end + + @doc false + def changeset(item, attrs) do + item + |> cast(attrs, [ + :feed_id, + :guid, + :title, + :content, + :summary, + :url, + :author, + :published_at, + :image_url, + :enclosure_url, + :enclosure_type, + :categories + ]) + |> validate_required([:feed_id, :guid]) + |> unique_constraint([:feed_id, :guid]) + |> foreign_key_constraint(:feed_id) + end +end diff --git a/apps/elektrine/lib/elektrine/rss/parser.ex b/apps/elektrine/lib/elektrine/rss/parser.ex new file mode 100644 index 0000000..57067fb --- /dev/null +++ b/apps/elektrine/lib/elektrine/rss/parser.ex @@ -0,0 +1,355 @@ +defmodule Elektrine.RSS.Parser do + @moduledoc "Simple RSS/Atom feed parser using SweetXml.\nHandles RSS 2.0, RSS 1.0, and Atom feeds.\n" + import SweetXml + + @doc "Parses an RSS or Atom feed from XML content.\nReturns {:ok, feed_map} or {:error, reason}.\n" + def parse(xml_content) when is_binary(xml_content) do + with {:ok, parsed_xml} <- parse_xml(xml_content) do + case detect_format(xml_content) do + :rss2 -> parse_rss2(parsed_xml) + :rss1 -> parse_rss1(parsed_xml) + :atom -> parse_atom(parsed_xml) + :unknown -> {:error, :unknown_format} + end + end + end + + defp parse_xml(xml_content) do + {:ok, SweetXml.parse(xml_content, dtd: :none, quiet: true)} + rescue + e -> {:error, {:parse_error, Exception.message(e)}} + catch + :exit, reason -> {:error, {:parse_error, inspect(reason)}} + end + + defp detect_format(xml) do + cond do + String.contains?(xml, " + :atom + + String.contains?(xml, " + :rss2 + + String.contains?(xml, "xmlns=\"http://purl.org/rss/1.0/\"") -> + :rss1 + + String.contains?(xml, " + :atom + + true -> + :unknown + end + end + + defp parse_rss2(xml) do + feed = + xml + |> xpath(~x"//channel"e, + title: ~x"./title/text()"os, + description: ~x"./description/text()"os, + link: ~x"./link/text()"os, + image_url: ~x"./image/url/text()"os, + entries: [ + ~x"./item"l, + guid: ~x"./guid/text()"os, + title: ~x"./title/text()"os, + link: ~x"./link/text()"os, + description: ~x"string(./description)"os, + content: ~x"string(./content:encoded)"os, + content_encoded: ~x"string(./*[local-name()='encoded'])"os, + author: ~x"./author/text()"os |> transform_by(&clean_author/1), + dc_creator: ~x"./dc:creator/text()"os, + pub_date: ~x"./pubDate/text()"os, + enclosure_url: ~x"./enclosure/@url"os, + enclosure_type: ~x"./enclosure/@type"os, + media_content_url: ~x"./*[local-name()='content']/@url"os, + media_content_type: ~x"./*[local-name()='content']/@type"os, + media_thumbnail_url: ~x"./*[local-name()='thumbnail']/@url"os, + itunes_image_url: ~x"./*[local-name()='image']/@href"os, + categories: ~x"./category/text()"ls + ] + ) + + entries = + Enum.map(feed.entries, fn entry -> + %{ + guid: entry.guid || entry.link, + title: clean_text(entry.title), + link: entry.link, + content: first_present([entry.content, entry.content_encoded, entry.description]), + summary: entry.description, + author: entry.author || entry.dc_creator, + published_at: parse_date(entry.pub_date), + image_url: entry_image_url(entry, feed.link), + enclosure_url: entry.enclosure_url, + enclosure_type: entry.enclosure_type, + categories: entry.categories + } + end) + + {:ok, + %{ + title: clean_text(feed.title), + subtitle: clean_text(feed.description), + link: feed.link, + image_url: feed.image_url, + entries: entries + }} + end + + defp parse_rss1(xml) do + feed = + xml + |> xpath(~x"/*[local-name()='RDF']"e, + title: ~x"./*[local-name()='channel']/*[local-name()='title']/text()"os, + description: ~x"./*[local-name()='channel']/*[local-name()='description']/text()"os, + link: ~x"./*[local-name()='channel']/*[local-name()='link']/text()"os, + entries: [ + ~x"./*[local-name()='item']"l, + title: ~x"./*[local-name()='title']/text()"os, + link: ~x"./*[local-name()='link']/text()"os, + description: ~x"string(./*[local-name()='description'])"os, + content_encoded: ~x"string(./*[local-name()='encoded'])"os, + media_content_url: ~x"./*[local-name()='content']/@url"os, + media_content_type: ~x"./*[local-name()='content']/@type"os, + media_thumbnail_url: ~x"./*[local-name()='thumbnail']/@url"os, + dc_date: ~x"./*[local-name()='date']/text()"os + ] + ) + + entries = + Enum.map(feed.entries || [], fn entry -> + %{ + guid: entry.link, + title: clean_text(entry.title), + link: entry.link, + content: first_present([entry.content_encoded, entry.description]), + summary: entry.description, + author: nil, + published_at: parse_date(entry.dc_date), + image_url: entry_image_url(entry, feed.link), + enclosure_url: nil, + enclosure_type: nil, + categories: [] + } + end) + + {:ok, + %{ + title: clean_text(feed.title), + subtitle: clean_text(feed.description), + link: feed.link, + image_url: nil, + entries: entries + }} + end + + defp parse_atom(xml) do + feed = + xml + |> xpath(~x"/*[local-name()='feed']"e, + title: ~x"./*[local-name()='title']/text()"os, + subtitle: ~x"./*[local-name()='subtitle']/text()"os, + link: ~x"./*[local-name()='link' and @rel='alternate']/@href"os, + link_self: ~x"./*[local-name()='link' and @rel='self']/@href"os, + icon: ~x"./*[local-name()='icon']/text()"os, + logo: ~x"./*[local-name()='logo']/text()"os, + entries: [ + ~x"./*[local-name()='entry']"l, + id: ~x"./*[local-name()='id']/text()"os, + title: ~x"./*[local-name()='title']/text()"os, + link: ~x"./*[local-name()='link' and @rel='alternate']/@href"os, + link_default: ~x"./*[local-name()='link']/@href"os, + image_link: ~x"./*[local-name()='link' and starts-with(@type, 'image/')]/@href"os, + enclosure_image_url: + ~x"./*[local-name()='link' and @rel='enclosure' and starts-with(@type, 'image/')]/@href"os, + media_content_url: ~x"./*[local-name()='content']/@url"os, + media_content_type: ~x"./*[local-name()='content']/@type"os, + media_thumbnail_url: ~x"./*[local-name()='thumbnail']/@url"os, + content: ~x"string(./*[local-name()='content'])"os, + content_text: ~x"./*[local-name()='content']//*[text()]/text()"ls, + summary: ~x"string(./*[local-name()='summary'])"os, + author_name: ~x"./*[local-name()='author']/*[local-name()='name']/text()"os, + updated: ~x"./*[local-name()='updated']/text()"os, + published: ~x"./*[local-name()='published']/text()"os, + categories: ~x"./*[local-name()='category']/@term"ls + ] + ) + + entries = + Enum.map(feed.entries || [], fn entry -> + link = entry.link || entry.link_default + + %{ + guid: entry.id || link, + title: clean_text(entry.title), + link: link, + content: first_present([entry.content, joined_text(entry.content_text), entry.summary]), + summary: entry.summary, + author: entry.author_name, + published_at: parse_date(entry.published || entry.updated), + image_url: entry_image_url(entry, link || feed.link || feed.link_self), + enclosure_url: nil, + enclosure_type: nil, + categories: entry.categories || [] + } + end) + + {:ok, + %{ + title: clean_text(feed.title), + subtitle: clean_text(feed.subtitle), + link: feed.link || feed.link_self, + image_url: feed.logo || feed.icon, + entries: entries + }} + end + + defp clean_text(nil) do + nil + end + + defp clean_text(text) when is_binary(text) do + text |> String.trim() |> HtmlEntities.decode() + end + + defp first_present(values) do + Enum.find_value(values, fn + value when is_binary(value) -> + value = String.trim(value) + if value == "", do: nil, else: value + + _value -> + nil + end) + end + + defp joined_text(values) when is_list(values) do + values + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.join("\n\n") + end + + defp joined_text(_values), do: nil + + defp clean_author(nil) do + nil + end + + defp clean_author(author) when is_binary(author) do + author = String.trim(author) + + cond do + String.contains?(author, "(") -> + case Regex.run(~r/\(([^)]+)\)/, author) do + [_, name] -> String.trim(name) + _ -> author + end + + String.contains?(author, "@") -> + case String.split(author, "@") do + [username | _] -> username + _ -> author + end + + true -> + author + end + end + + defp entry_image_url(entry, base_url) do + [ + image_media_url(entry.media_content_url, Map.get(entry, :media_content_type)), + Map.get(entry, :media_thumbnail_url), + Map.get(entry, :itunes_image_url), + Map.get(entry, :image_link), + Map.get(entry, :enclosure_image_url), + image_enclosure_url(Map.get(entry, :enclosure_url), Map.get(entry, :enclosure_type)), + html_image_url(Map.get(entry, :content)), + html_image_url(Map.get(entry, :description)), + html_image_url(Map.get(entry, :summary)) + ] + |> Enum.find_value(&absolute_image_url(&1, base_url)) + end + + defp image_media_url(url, type) when is_binary(url) and is_binary(type) do + if String.starts_with?(String.downcase(type), "image/"), do: url + end + + defp image_media_url(url, _type) when is_binary(url), do: url + defp image_media_url(_url, _type), do: nil + + defp image_enclosure_url(url, type) when is_binary(url) and is_binary(type) do + if String.starts_with?(String.downcase(type), "image/"), do: url + end + + defp image_enclosure_url(_url, _type), do: nil + + defp html_image_url(html) when is_binary(html) do + case Regex.run(~r/]*\bsrc=["']([^"']+)["']/i, html) do + [_, url] -> url + _ -> nil + end + end + + defp html_image_url(_html), do: nil + + defp absolute_image_url(nil, _base_url), do: nil + defp absolute_image_url("", _base_url), do: nil + + defp absolute_image_url(url, base_url) when is_binary(url) do + url = String.trim(url) + + cond do + String.starts_with?(url, "//") -> "https:#{url}" + URI.parse(url).scheme in ["http", "https"] -> url + is_binary(base_url) -> URI.merge(base_url, url) |> URI.to_string() + true -> nil + end + end + + defp parse_date(nil) do + nil + end + + defp parse_date("") do + nil + end + + defp parse_date(date_string) when is_binary(date_string) do + date_string = String.trim(date_string) + + case DateTime.from_iso8601(date_string) do + {:ok, datetime, _} -> datetime + _ -> parse_rfc2822(date_string) + end + end + + defp parse_rfc2822(date_string) do + date_string = date_string |> String.replace(~r/\s+/, " ") |> String.trim() + + try do + date_string = + if String.match?(date_string, ~r/^\w{3},\s/) do + String.replace(date_string, ~r/^\w{3},\s*/, "") + else + date_string + end + + case :httpd_util.convert_request_date(String.to_charlist(date_string)) do + :bad_date -> + nil + + {{year, month, day}, {hour, min, sec}} -> + case NaiveDateTime.new(year, month, day, hour, min, sec) do + {:ok, naive} -> DateTime.from_naive!(naive, "Etc/UTC") + _ -> nil + end + end + rescue + _ -> nil + end + end +end diff --git a/apps/elektrine/lib/elektrine/rss/scheduler_worker.ex b/apps/elektrine/lib/elektrine/rss/scheduler_worker.ex new file mode 100644 index 0000000..0e396b1 --- /dev/null +++ b/apps/elektrine/lib/elektrine/rss/scheduler_worker.ex @@ -0,0 +1,25 @@ +defmodule Elektrine.RSS.SchedulerWorker do + @moduledoc """ + Oban worker that runs periodically to enqueue fetch jobs for stale RSS feeds. + """ + use Oban.Worker, queue: :rss, max_attempts: 1 + + alias Elektrine.RSS + alias Elektrine.RSS.FetchFeedWorker + + @impl Oban.Worker + def perform(_job) do + jobs = + RSS.list_stale_feeds(50) + |> Enum.map(fn feed -> + %{feed_id: feed.id} + |> FetchFeedWorker.new() + end) + + if jobs != [] do + _ = Elektrine.JobQueue.insert_all(jobs) + end + + :ok + end +end diff --git a/apps/elektrine/lib/elektrine/rss/subscription.ex b/apps/elektrine/lib/elektrine/rss/subscription.ex new file mode 100644 index 0000000..ca9efe3 --- /dev/null +++ b/apps/elektrine/lib/elektrine/rss/subscription.ex @@ -0,0 +1,36 @@ +defmodule Elektrine.RSS.Subscription do + @moduledoc """ + Schema for user subscriptions to RSS feeds. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "rss_subscriptions" do + belongs_to :user, Elektrine.Accounts.User + belongs_to :feed, Elektrine.RSS.Feed + + field :display_name, :string + field :folder, :string + field :notify_new_items, :boolean, default: false + field :show_in_timeline, :boolean, default: true + + timestamps() + end + + @doc false + def changeset(subscription, attrs) do + subscription + |> cast(attrs, [ + :user_id, + :feed_id, + :display_name, + :folder, + :notify_new_items, + :show_in_timeline + ]) + |> validate_required([:user_id, :feed_id]) + |> unique_constraint([:user_id, :feed_id]) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:feed_id) + end +end diff --git a/apps/elektrine/lib/elektrine/runtime_env.ex b/apps/elektrine/lib/elektrine/runtime_env.ex new file mode 100644 index 0000000..1fa0478 --- /dev/null +++ b/apps/elektrine/lib/elektrine/runtime_env.ex @@ -0,0 +1,57 @@ +defmodule Elektrine.RuntimeEnv do + @moduledoc false + + def environment do + Application.get_env(:elektrine, :environment, :dev) + end + + def prod? do + environment() == :prod + end + + def dev_or_test? do + environment() in [:dev, :test] + end + + def enforce_https? do + app_config(:enforce_https, false) + end + + def app_config(key, default \\ nil) when is_atom(key) do + Application.get_env(:elektrine, key, default) + end + + def module_config(module, default \\ nil) when is_atom(module) do + Application.get_env(:elektrine, module, default) + end + + def present(name, env \\ System.get_env()) when is_binary(name) and is_map(env) do + case Map.get(env, name) do + value when is_binary(value) -> + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + + _ -> + nil + end + end + + def first_present(names, env \\ System.get_env()) when is_list(names) and is_map(env) do + Enum.find_value(names, &present(&1, env)) + end + + def optional_boolean(name, env \\ System.get_env()) when is_binary(name) and is_map(env) do + case present(name, env) do + nil -> nil + value when value in ["1", "true", "TRUE", "yes", "YES", "on", "ON"] -> true + value when value in ["0", "false", "FALSE", "no", "NO", "off", "OFF"] -> false + _ -> nil + end + end + + def truthy?(name, env \\ System.get_env()) when is_binary(name) and is_map(env) do + optional_boolean(name, env) == true + end +end diff --git a/apps/elektrine/lib/elektrine/runtime_secrets.ex b/apps/elektrine/lib/elektrine/runtime_secrets.ex new file mode 100644 index 0000000..3f21a58 --- /dev/null +++ b/apps/elektrine/lib/elektrine/runtime_secrets.ex @@ -0,0 +1,68 @@ +defmodule Elektrine.RuntimeSecrets do + @moduledoc false + + def master_secret(env \\ System.get_env()) do + env_value(env, "ELEKTRINE_MASTER_SECRET") + end + + def secret_key_base(env \\ System.get_env()), + do: env_value(env, "SECRET_KEY_BASE") || derive(env, "secret_key_base", 48) + + def session_signing_salt(env \\ System.get_env()), + do: env_value(env, "SESSION_SIGNING_SALT") || derive(env, "session_signing_salt", 24) + + def session_encryption_salt(env \\ System.get_env()), + do: env_value(env, "SESSION_ENCRYPTION_SALT") || derive(env, "session_encryption_salt", 24) + + def encryption_master_secret(env \\ System.get_env()), + do: env_value(env, "ENCRYPTION_MASTER_SECRET") || derive(env, "encryption_master_secret", 48) + + def encryption_key_salt(env \\ System.get_env()), + do: env_value(env, "ENCRYPTION_KEY_SALT") || derive(env, "encryption_key_salt", 24) + + def encryption_search_salt(env \\ System.get_env()), + do: env_value(env, "ENCRYPTION_SEARCH_SALT") || derive(env, "encryption_search_salt", 24) + + def internal_api_key(env \\ System.get_env()), + do: env_value(env, "INTERNAL_API_KEY") || derive(env, "internal_api_key", 32) + + def haraka_internal_signing_secret(env \\ System.get_env()), + do: + env_value(env, "HARAKA_INTERNAL_SIGNING_SECRET") || + derive(env, "haraka_internal_signing_secret", 32) + + def email_receiver_webhook_secret(env \\ System.get_env()), + do: + env_value(env, "EMAIL_RECEIVER_WEBHOOK_SECRET") || + derive(env, "email_receiver_webhook_secret", 32) + + def turn_shared_secret(env \\ System.get_env()), + do: env_value(env, "TURN_SHARED_SECRET") + + def derive(env, label, bytes) when is_map(env) and is_binary(label) and bytes > 0 do + case master_secret(env) do + nil -> + nil + + secret -> + derive_bytes(secret, "elektrine:" <> label, bytes) |> Base.url_encode64(padding: false) + end + end + + def env_value(env, key) when is_map(env) and is_binary(key) do + case Map.get(env, key) do + value when is_binary(value) and value != "" -> value + _ -> nil + end + end + + defp derive_bytes(secret, info, bytes), do: derive_blocks(secret, info, bytes, 1, <<>>) + + defp derive_blocks(_secret, _info, bytes, _counter, acc) when byte_size(acc) >= bytes, + do: binary_part(acc, 0, bytes) + + defp derive_blocks(secret, info, bytes, counter, acc) do + block = :crypto.mac(:hmac, :sha256, secret, <>) + derive_blocks(secret, info, bytes, counter + 1, acc <> block) + end +end diff --git a/apps/elektrine/lib/elektrine/search.ex b/apps/elektrine/lib/elektrine/search.ex new file mode 100644 index 0000000..9a92871 --- /dev/null +++ b/apps/elektrine/lib/elektrine/search.ex @@ -0,0 +1,1175 @@ +defmodule Elektrine.Search do + @moduledoc """ + Global search functionality with access control. + + Searches across all user-accessible data including: + - People + - Chat conversations and messages + - Timeline posts and replies + - Discussion threads and posts + - Email messages and mailboxes + - Email attachments and files + - Settings and command actions + + SECURITY: All search functions enforce proper authorization: + - Chat: Only messages in conversations where user is an active member + - Timeline: Respects visibility settings (public/followers-only/private) + - Discussions: Only public discussions or private ones where user is a member + - Email: Only emails in mailboxes owned by the user + - Deleted content is never searchable + """ + + import Ecto.Query, warn: false + alias Elektrine.{AuditLog, Notifications} + alias Elektrine.Platform.Modules + alias Elektrine.Repo + + @doc """ + Perform a global search across all accessible data for a user + """ + def global_search(user, query, opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + scopes = Keyword.get(opts, :scopes, []) |> normalize_scopes() + strict_scopes? = Keyword.get(opts, :enforce_scopes, false) + + safe_query = sanitize_search_term(query) + trimmed_query = String.trim(safe_query) + command_mode = String.starts_with?(trimmed_query, ">") + command_query = normalize_command_query(trimmed_query) + + if !command_mode && String.length(trimmed_query) < 2 do + %{results: [], total_count: 0} + else + search_term = "%#{trimmed_query}%" + search_patterns = wildcard_search_patterns(trimmed_query) + + results = [] + + results = + if command_mode do + results + else + results + |> maybe_append_module_search_results( + user, + :friends, + scopes, + strict_scopes?, + ["read:social"], + fn -> + search_people(user, search_patterns, limit) + end + ) + |> maybe_append_module_search_results( + user, + :chat, + scopes, + strict_scopes?, + ["read:chat"], + fn -> + search_chat_messages(user, search_term, limit) + end + ) + |> maybe_append_module_search_results( + user, + :timeline, + scopes, + strict_scopes?, + ["read:social"], + fn -> + search_timeline_posts(user, search_term, limit) + end + ) + |> maybe_append_module_search_results( + user, + :communities, + scopes, + strict_scopes?, + ["read:social"], + fn -> + search_discussions(user, search_term, limit) + end + ) + |> maybe_append_module_search_results( + user, + :communities, + scopes, + strict_scopes?, + ["read:social"], + fn -> + search_communities(user, search_term, limit) + end + ) + |> maybe_append_module_search_results( + user, + :timeline, + scopes, + strict_scopes?, + ["read:social"], + fn -> + search_federated_posts(search_patterns, limit) + end + ) + |> maybe_append_module_search_results( + user, + :email, + scopes, + strict_scopes?, + ["read:email"], + fn -> + search_emails(user, search_term, limit) + end + ) + |> maybe_append_module_search_results( + user, + :email, + scopes, + strict_scopes?, + ["read:email"], + fn -> + search_files(user, search_term, limit) + end + ) + end + + results = results ++ search_settings(user, command_query, limit, scopes, strict_scopes?) + results = results ++ search_actions(user, command_query, limit, scopes, strict_scopes?) + + # Sort by relevance and limit results + sorted_results = + results + |> Enum.sort_by(&(-&1.relevance)) + |> Enum.take(limit) + + %{ + results: sorted_results, + total_count: length(results) + } + end + end + + defp search_people(user, search_patterns, limit) do + people_filter = person_match_dynamic(search_patterns) + + from(u in Elektrine.Accounts.User, + where: u.id != ^user.id, + where: ^people_filter, + select: %{ + id: u.id, + type: "person", + username: u.username, + display_name: u.display_name, + handle: u.handle, + updated_at: u.updated_at, + relevance: 0.95 + }, + limit: ^max(div(limit, 8), 3), + order_by: [desc: u.updated_at] + ) + |> Repo.all() + |> Enum.map(fn result -> + username = result.username || "user" + handle = result.handle || username + + display_name = + if is_binary(result.display_name), do: String.trim(result.display_name), else: "" + + title = + if display_name == "" do + "@#{username}" + else + display_name + end + + %{ + id: result.id, + type: "person", + title: title, + content: "@#{handle}", + url: "/#{handle}", + updated_at: result.updated_at, + relevance: result.relevance + } + end) + end + + # Search chat messages - ONLY messages in conversations the user is a member of + defp search_chat_messages(user, search_term, limit) do + # Extract keywords and hash them for blind index search + keywords = Elektrine.Encryption.extract_keywords(search_term) + + if Enum.empty?(keywords) do + [] + else + keyword_hashes = + Enum.map(keywords, fn kw -> + Elektrine.Encryption.hash_keyword(kw, user.id) + end) + + from(m in Elektrine.Social.Message, + join: cm in Elektrine.Social.ConversationMember, + on: cm.conversation_id == m.conversation_id, + join: c in Elektrine.Social.Conversation, + on: c.id == m.conversation_id, + # User must be an active member + # Don't search deleted messages + where: + cm.user_id == ^user.id and + is_nil(cm.left_at) and + c.type in ["dm", "group", "channel"] and + fragment("? && ?", m.search_index, ^keyword_hashes) and + is_nil(m.deleted_at), + select: %{ + id: m.id, + type: "chat", + title: + fragment("CASE WHEN ? IS NOT NULL THEN ? ELSE 'Chat Message' END", c.name, c.name), + content: "Chat message", + url: + fragment( + "CONCAT(?, COALESCE(?, CAST(? AS text)))", + type(^(Elektrine.Paths.chat_root_path() <> "/"), :string), + c.hash, + c.id + ), + updated_at: m.inserted_at, + relevance: 0.9, + sender_id: m.sender_id + }, + limit: ^div(limit, 9), + order_by: [desc: m.inserted_at] + ) + |> Repo.all() + |> decrypt_message_content(user.id) + end + end + + # Search timeline posts - respect visibility settings + defp search_timeline_posts(user, search_term, limit) do + # Extract keywords and hash them for blind index search + keywords = Elektrine.Encryption.extract_keywords(search_term) + + if Enum.empty?(keywords) do + [] + else + keyword_hashes = + Enum.map(keywords, fn kw -> + Elektrine.Encryption.hash_keyword(kw, user.id) + end) + + from(m in Elektrine.Social.Message, + join: c in Elektrine.Social.Conversation, + on: c.id == m.conversation_id, + left_join: u in Elektrine.Accounts.User, + on: u.id == m.sender_id, + left_join: f in Elektrine.Profiles.Follow, + on: f.follower_id == ^user.id and f.followed_id == m.sender_id, + # Don't search deleted posts + # Public posts are visible to all + # Followers-only posts require following + # Users can see their own posts + where: + c.type == "timeline" and + m.post_type == "post" and + is_nil(m.deleted_at) and + (m.visibility == "public" or + (m.visibility == "followers" and not is_nil(f.id)) or + m.sender_id == ^user.id) and + (fragment("? && ?", m.search_index, ^keyword_hashes) or + (not is_nil(m.title) and ilike(m.title, ^search_term))), + select: %{ + id: m.id, + type: "timeline", + title: + fragment( + "CASE WHEN ? IS NOT NULL THEN ? ELSE CONCAT('@', ?) END", + m.title, + m.title, + u.username + ), + content: "Timeline post", + url: fragment("CONCAT(?, ?)", type(^"/post/", :string), m.id), + updated_at: m.inserted_at, + relevance: 0.85, + sender_id: m.sender_id + }, + limit: ^div(limit, 9), + order_by: [desc: m.inserted_at] + ) + |> Repo.all() + |> decrypt_message_content(user.id) + end + end + + # Search discussion posts - only in communities the user has access to + defp search_discussions(user, search_term, limit) do + # Extract keywords and hash them for blind index search + keywords = Elektrine.Encryption.extract_keywords(search_term) + + if Enum.empty?(keywords) do + [] + else + keyword_hashes = + Enum.map(keywords, fn kw -> + Elektrine.Encryption.hash_keyword(kw, user.id) + end) + + from(m in Elektrine.Social.Message, + join: c in Elektrine.Social.Conversation, + on: c.id == m.conversation_id, + left_join: cm in Elektrine.Social.ConversationMember, + on: cm.conversation_id == c.id and cm.user_id == ^user.id, + # Don't search deleted posts + # Public discussions are visible to all + # Private discussions require membership + where: + c.type == "community" and + is_nil(m.deleted_at) and + (c.is_public == true or + (c.is_public == false and not is_nil(cm.id) and is_nil(cm.left_at))) and + (fragment("? && ?", m.search_index, ^keyword_hashes) or + (not is_nil(m.title) and ilike(m.title, ^search_term)) or + ilike(c.name, ^search_term)), + select: %{ + id: m.id, + type: "discussion", + title: fragment("CASE WHEN ? IS NOT NULL THEN ? ELSE ? END", m.title, m.title, c.name), + content: "Discussion post", + url: + fragment( + "CONCAT(?, ?, ?, ?)", + type(^(Elektrine.Paths.discussion_path("") <> "/"), :string), + c.name, + type(^"/post/", :string), + m.id + ), + updated_at: m.inserted_at, + relevance: 0.8, + sender_id: m.sender_id + }, + limit: ^div(limit, 9), + order_by: [desc: m.inserted_at] + ) + |> Repo.all() + |> decrypt_message_content(user.id) + end + end + + # Search communities - only public communities or ones user is a member of + defp search_communities(user, search_term, limit) do + from(c in Elektrine.Social.Conversation, + left_join: cm in Elektrine.Social.ConversationMember, + on: cm.conversation_id == c.id and cm.user_id == ^user.id, + where: + c.type == "community" and + (c.is_public == true or not is_nil(cm.id)) and + (ilike(c.name, ^search_term) or + ilike(c.description, ^search_term)), + select: %{ + id: c.id, + type: "community", + title: c.name, + content: c.description, + url: + fragment( + "CONCAT(?, ?)", + type(^(Elektrine.Paths.discussion_path("") <> "/"), :string), + c.name + ), + updated_at: c.updated_at, + relevance: 0.8 + }, + limit: ^div(limit, 10), + order_by: [desc: c.member_count] + ) + |> Repo.all() + end + + # Search federated posts from remote instances + # These are public posts received via ActivityPub, not encrypted + defp search_federated_posts(search_patterns, limit) do + federated_filter = federated_post_match_dynamic(search_patterns) + + from(m in Elektrine.Social.Message, + left_join: a in Elektrine.ActivityPub.Actor, + on: a.id == m.remote_actor_id, + where: + m.federated == true and + m.visibility in ["public", "unlisted"] and + fragment("COALESCE((?->>'indexable')::boolean, true)", m.media_metadata) and + is_nil(m.deleted_at), + where: ^federated_filter, + select: %{ + id: m.id, + type: "federated", + title: fragment("COALESCE(?, CONCAT('@', ?, '@', ?))", m.title, a.username, a.domain), + content: fragment("LEFT(?, 200)", m.content), + updated_at: m.inserted_at, + relevance: 0.75, + actor_username: a.username, + actor_domain: a.domain, + actor_display_name: a.display_name, + activitypub_id: m.activitypub_id + }, + limit: ^div(limit, 8), + order_by: [desc: m.inserted_at] + ) + |> Repo.all() + |> Enum.map(fn result -> + # Construct URL with properly encoded activitypub_id + url = + if result.activitypub_id do + Elektrine.Paths.post_path(result) + else + Elektrine.Paths.post_path(result.id) + end + + Map.put(result, :url, url) + end) + end + + # Search email messages and mailboxes + defp search_emails(user, search_term, limit) do + if Elektrine.Platform.Modules.compiled?(:email) do + keywords = Elektrine.Encryption.extract_keywords(search_term) + + messages = + if Enum.empty?(keywords) do + [] + else + keyword_hashes = + Enum.map(keywords, fn kw -> + Elektrine.Encryption.hash_keyword(kw, user.id) + end) + + from(m in Elektrine.Email.Message, + join: mb in Elektrine.Email.Mailbox, + on: m.mailbox_id == mb.id, + where: + mb.user_id == ^user.id and + (ilike(m.subject, ^search_term) or + ilike(m.from, ^search_term) or + ilike(m.to, ^search_term) or + fragment("? && ?", m.search_index, ^keyword_hashes)), + select: %{ + id: m.id, + type: "email", + title: m.subject, + content: "Email message", + url: + fragment( + "CONCAT(?, ?)", + type(^Elektrine.Paths.email_view_path(""), :string), + m.id + ), + updated_at: m.inserted_at, + relevance: 0.8, + mailbox_id: m.mailbox_id + }, + limit: ^div(limit, 6) + ) + |> Repo.all() + |> decrypt_email_content(user.id) + end + + mailboxes = + from(mb in Elektrine.Email.Mailbox, + where: + mb.user_id == ^user.id and + ilike(mb.email, ^search_term), + select: %{ + id: mb.id, + type: "mailbox", + title: mb.email, + content: "Email mailbox", + url: ^Elektrine.Paths.email_index_path(), + updated_at: mb.inserted_at, + relevance: 0.9 + }, + limit: ^div(limit, 6) + ) + |> Repo.all() + + messages ++ mailboxes + else + [] + end + end + + defp search_files(user, search_term, limit) do + if Elektrine.Platform.Modules.compiled?(:email) do + from(m in Elektrine.Email.Message, + join: mb in Elektrine.Email.Mailbox, + on: m.mailbox_id == mb.id, + where: + mb.user_id == ^user.id and + m.has_attachments == true and + m.deleted == false and + (ilike(m.subject, ^search_term) or ilike(m.from, ^search_term) or + fragment("CAST(? AS text) ILIKE ?", m.attachments, ^search_term)), + select: %{ + id: m.id, + type: "file", + subject: m.subject, + from: m.from, + attachments: m.attachments, + updated_at: m.inserted_at, + relevance: 0.72 + }, + limit: ^max(div(limit, 8), 3), + order_by: [desc: m.inserted_at] + ) + |> Repo.all() + |> Enum.map(fn result -> + attachment_name = first_attachment_name(result.attachments) + subject = if is_binary(result.subject), do: String.trim(result.subject), else: "" + from = if is_binary(result.from), do: String.trim(result.from), else: "Unknown sender" + + title = + cond do + attachment_name != nil -> attachment_name + subject != "" -> subject + true -> "Email attachment" + end + + %{ + id: result.id, + type: "file", + title: title, + content: "File in email from #{from}", + url: Elektrine.Paths.email_view_path(result.id), + updated_at: result.updated_at, + relevance: result.relevance + } + end) + else + [] + end + end + + defp search_settings(user, query, limit, scopes, strict_scopes?) do + query = normalize_command_query(query) + + if scope_allowed?(scopes, strict_scopes?, ["read:account"]) do + setting_entries() + |> Enum.filter(&(entry_module_allowed?(user, &1) and entry_matches?(&1, query))) + |> Enum.take(max(div(limit, 6), 4)) + else + [] + end + end + + defp search_actions(user, query, limit, scopes, strict_scopes?) do + query = normalize_command_query(query) + + list_actions(scopes: scopes, enforce_scopes: strict_scopes?, current_user: user) + |> Enum.filter(&entry_matches?(&1, query)) + |> Enum.take(max(div(limit, 6), 4)) + end + + @doc """ + Get search suggestions based on partial query + """ + def get_suggestions(user, partial_query, limit \\ 10) do + if String.length(String.trim(partial_query)) < 1 do + [] + else + # Sanitize search term to prevent LIKE pattern injection + safe_query = sanitize_search_term(partial_query) + trimmed_query = String.trim(safe_query) + search_term = "#{trimmed_query}%" + search_patterns = prefix_search_patterns(trimmed_query) + + if String.starts_with?(trimmed_query, ">") do + action_entries() + |> Enum.filter( + &(entry_module_allowed?(user, &1) and + entry_matches?(&1, normalize_command_query(trimmed_query))) + ) + |> Enum.take(limit) + |> Enum.map(fn action -> %{text: action.title, type: "action"} end) + else + email_domains = + if Elektrine.Platform.Modules.compiled?(:email) and module_allowed?(user, :email) do + from(m in Elektrine.Email.Message, + join: mb in Elektrine.Email.Mailbox, + on: m.mailbox_id == mb.id, + where: + mb.user_id == ^user.id and + ilike(m.from, ^search_term), + select: %{ + text: fragment("SPLIT_PART(?, '@', 2)", m.from), + type: "email_domain" + }, + distinct: fragment("SPLIT_PART(?, '@', 2)", m.from), + limit: ^limit + ) + |> Repo.all() + |> Enum.filter(&(&1.text != "")) + else + [] + end + + people_filter = person_match_dynamic(search_patterns) + + people = + if module_allowed?(user, :friends) do + from(u in Elektrine.Accounts.User, + where: u.id != ^user.id, + where: ^people_filter, + select: %{ + text: fragment("CONCAT('@', COALESCE(?, ?))", u.handle, u.username), + type: "person" + }, + limit: ^max(div(limit, 2), 2) + ) + |> Repo.all() + else + [] + end + + commands = + (action_entries() ++ setting_entries()) + |> Enum.filter( + &(entry_module_allowed?(user, &1) and + entry_matches?(&1, normalize_command_query(trimmed_query))) + ) + |> Enum.take(max(div(limit, 2), 2)) + |> Enum.map(fn entry -> %{text: entry.title, type: entry.type} end) + + (people ++ commands ++ email_domains) + |> Enum.uniq_by(&String.downcase(to_string(&1.text))) + |> Enum.take(limit) + end + end + end + + @doc """ + Lists command palette actions, optionally filtered by PAT scopes. + """ + def list_actions(opts \\ []) do + scopes = Keyword.get(opts, :scopes, []) |> normalize_scopes() + strict_scopes? = Keyword.get(opts, :enforce_scopes, false) + + current_user = Keyword.get(opts, :current_user) + + action_entries() + |> Enum.filter( + &(entry_module_allowed?(current_user, &1) and action_allowed?(&1, scopes, strict_scopes?)) + ) + |> Enum.sort_by(&(-&1.relevance)) + end + + @doc """ + Executes a command palette action. + + Supports command strings (for example `>open chat`) and action ids. + """ + def execute_action(user, command_or_id, opts \\ []) + + def execute_action(nil, _command_or_id, _opts), do: {:error, :unauthorized} + + def execute_action(user, command_or_id, opts) do + normalized = normalize_command_query(command_or_id) + + if normalized == "" do + {:error, :unknown_action} + else + scopes = Keyword.get(opts, :scopes, []) |> normalize_scopes() + strict_scopes? = Keyword.get(opts, :enforce_scopes, false) + + case resolve_action(normalized) do + nil -> + {:error, :unknown_action} + + action -> + if action_allowed?(action, scopes, strict_scopes?) and + entry_module_allowed?(user, action) do + result = run_action(user, action) + audit_action_execution(user, action, result, opts) + result + else + {:error, :insufficient_scope} + end + end + end + end + + defp normalize_command_query(query) when is_binary(query) do + query + |> String.trim() + |> String.trim_leading(">") + |> String.trim() + |> String.downcase() + end + + defp normalize_command_query(_), do: "" + + defp normalize_scopes(scopes) when is_list(scopes) do + scopes + |> Enum.map(&to_string/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> MapSet.new() + end + + defp normalize_scopes(_), do: MapSet.new() + + defp maybe_append_search_results(results, scopes, strict_scopes?, required_scopes, fun) + when is_list(results) and is_list(required_scopes) and is_function(fun, 0) do + if scope_allowed?(scopes, strict_scopes?, required_scopes) do + results ++ fun.() + else + results + end + end + + defp maybe_append_module_search_results( + results, + user, + module, + scopes, + strict_scopes?, + required_scopes, + fun + ) + when is_function(fun, 0) do + if module_allowed?(user, module) do + maybe_append_search_results(results, scopes, strict_scopes?, required_scopes, fun) + else + results + end + end + + defp entry_module_allowed?(user, entry) do + case entry[:module] do + nil -> true + module -> module_allowed?(user, module) + end + end + + defp module_allowed?(user, module) do + platform_module = platform_module_for_access_module(module) + + Modules.enabled?(platform_module) and Elektrine.System.user_can_access_module?(user, module) + end + + defp platform_module_for_access_module(module) + when module in [:timeline, :communities, :gallery, :lists, :friends], + do: :social + + defp platform_module_for_access_module(:storage), do: nil + + defp platform_module_for_access_module(:paige), do: nil + + defp platform_module_for_access_module(module), do: module + + defp scope_allowed?(scopes, strict_scopes?, required_scopes) do + cond do + required_scopes == [] -> + true + + not strict_scopes? and MapSet.size(scopes) == 0 -> + true + + true -> + Enum.any?(required_scopes, &MapSet.member?(scopes, &1)) + end + end + + defp entry_matches?(_entry, ""), do: true + + defp entry_matches?(entry, query) do + haystack = + [entry.title, entry.content, Enum.join(entry.keywords || [], " ")] + |> Enum.join(" ") + |> String.downcase() + + String.contains?(haystack, query) + end + + defp action_entries do + [ + %{ + id: "action_compose_email", + type: "action", + title: "Compose Email", + command: "compose email", + aliases: ["new email", "send email"], + execution: :navigate, + module: :email, + required_scopes: ["write:email"], + content: "Start a new email message", + url: Elektrine.Paths.email_compose_path(return_to: "search"), + updated_at: DateTime.utc_now(), + relevance: 1.1, + keywords: ["compose", "email", "send", "message"] + }, + %{ + id: "action_open_chat", + type: "action", + title: "Open Chat", + command: "open chat", + aliases: ["chat"], + execution: :navigate, + module: :chat, + required_scopes: ["read:chat"], + content: "Jump into your conversations", + url: Elektrine.Paths.chat_root_path(), + updated_at: DateTime.utc_now(), + relevance: 1.08, + keywords: ["chat", "dm", "message", "conversation"] + }, + %{ + id: "action_open_notifications", + type: "action", + title: "Open Notifications", + command: "open notifications", + aliases: ["notifications", "alerts"], + execution: :navigate, + required_scopes: ["read:account"], + content: "Review unread alerts", + url: Elektrine.Paths.notifications_path(), + updated_at: DateTime.utc_now(), + relevance: 1.06, + keywords: ["alerts", "notifications", "activity"] + }, + %{ + id: "action_mark_all_notifications_read", + type: "action", + title: "Mark Notifications Read", + command: "mark notifications read", + aliases: ["clear notifications", "read all notifications"], + execution: :operation, + operation: :mark_notifications_read, + required_scopes: ["write:account"], + content: "Mark all notifications as read", + url: Elektrine.Paths.notifications_path(), + updated_at: DateTime.utc_now(), + relevance: 1.05, + keywords: ["notification", "read", "clear", "inbox zero"] + }, + %{ + id: "action_open_vpn", + type: "action", + title: "Open VPN", + command: "open vpn", + aliases: ["vpn"], + execution: :navigate, + module: :vpn, + required_scopes: ["read:account"], + content: "Manage your WireGuard profiles", + url: Elektrine.Paths.vpn_path(), + updated_at: DateTime.utc_now(), + relevance: 1.04, + keywords: ["vpn", "wireguard", "security", "network"] + }, + %{ + id: "action_open_timeline", + type: "action", + title: "Open Timeline", + command: "open timeline", + aliases: ["timeline", "home"], + execution: :navigate, + required_scopes: ["read:account"], + content: "Go back to your home feed", + url: Elektrine.Paths.timeline_path(), + updated_at: DateTime.utc_now(), + relevance: 1.02, + keywords: ["timeline", "home", "feed"] + }, + %{ + id: "action_open_maid", + type: "action", + title: "Open Paige", + command: "open paige", + aliases: ["paige", "search", "web search"], + execution: :navigate, + module: :paige, + required_scopes: ["read:account"], + content: "Search Elektrine and the web", + url: Elektrine.Paths.paige_path(), + updated_at: DateTime.utc_now(), + relevance: 1.01, + keywords: ["paige", "search", "web", "find"] + } + ] + end + + defp resolve_action(normalized_command) do + action_entries() + |> Enum.find(fn action -> + action_commands = + [action[:id], action[:command], action[:title]] + |> Kernel.++(action[:aliases] || []) + |> Enum.map(&normalize_command_query/1) + + normalized_command in action_commands + end) + end + + defp action_allowed?(action, scopes, strict_scopes?) do + required_scopes = action[:required_scopes] || [] + + cond do + required_scopes == [] -> + true + + not strict_scopes? and MapSet.size(scopes) == 0 -> + true + + true -> + Enum.all?(required_scopes, &MapSet.member?(scopes, &1)) + end + end + + defp run_action(_user, %{execution: :navigate} = action) do + {:ok, + %{ + action_id: action.id, + mode: :navigate, + url: action.url, + message: action.content + }} + end + + defp run_action(user, %{execution: :operation, operation: :mark_notifications_read} = action) do + :ok = Notifications.mark_all_as_read(user.id) + + {:ok, + %{ + action_id: action.id, + mode: :operation, + message: "Marked all notifications as read", + url: action.url + }} + end + + defp run_action(_user, _action), do: {:error, :unknown_action} + + defp audit_action_execution(user, action, result, opts) do + status = + case result do + {:ok, _} -> "ok" + {:error, reason} -> "error:#{reason}" + end + + _ = + AuditLog.log(user.id, "search_action.execute", "search_action", + details: %{ + action_id: action.id, + command: action[:command], + mode: to_string(action[:execution]), + status: status, + source: opts[:source] || "search" + }, + ip_address: opts[:ip_address], + user_agent: opts[:user_agent] + ) + + :ok + rescue + _ -> :ok + end + + defp setting_entries do + [ + %{ + id: "settings_profile", + type: "settings", + title: "Profile Settings", + content: "Edit your profile, avatar, and bio", + url: "/account/profile/edit", + updated_at: DateTime.utc_now(), + relevance: 1.03, + keywords: ["profile", "avatar", "bio", "display name"] + }, + %{ + id: "settings_security", + type: "settings", + title: "Security Settings", + content: "Manage password and security options", + url: "/account/password", + updated_at: DateTime.utc_now(), + relevance: 1.01, + keywords: ["security", "password", "account", "login"] + }, + %{ + id: "settings_two_factor", + type: "settings", + title: "Two-Factor Authentication", + content: "Set up and manage 2FA", + url: "/account/two_factor", + updated_at: DateTime.utc_now(), + relevance: 1.0, + keywords: ["2fa", "two factor", "authenticator", "security"] + }, + %{ + id: "settings_passkeys", + type: "settings", + title: "Passkeys", + content: "Manage WebAuthn passkeys", + url: "/account/passkeys", + updated_at: DateTime.utc_now(), + relevance: 0.99, + keywords: ["passkey", "webauthn", "security"] + }, + %{ + id: "settings_email", + type: "settings", + title: "Email Settings", + content: "Configure signatures, aliases, and inbox behavior", + module: :email, + url: Elektrine.Paths.email_index_path() <> "/settings", + updated_at: DateTime.utc_now(), + relevance: 0.98, + keywords: ["email", "signature", "alias", "filters"] + }, + %{ + id: "settings_storage", + type: "settings", + title: "Storage", + content: "View usage and storage limits", + module: :storage, + url: "/account/storage", + updated_at: DateTime.utc_now(), + relevance: 0.97, + keywords: ["storage", "quota", "usage", "space"] + } + ] + end + + defp first_attachment_name(attachments) when is_map(attachments) do + attachments + |> Map.values() + |> Enum.find_value(fn attachment -> + if is_map(attachment) do + attachment["filename"] || attachment[:filename] || attachment["name"] || + attachment[:name] + else + nil + end + end) + |> case do + name when is_binary(name) and name != "" -> name + _ -> nil + end + end + + defp first_attachment_name(_), do: nil + + defp wildcard_search_patterns(query), do: build_search_patterns(query, &"%#{&1}%") + + defp prefix_search_patterns(query), do: build_search_patterns(query, &"#{&1}%") + + defp build_search_patterns(query, formatter) when is_function(formatter, 1) do + query + |> search_aliases() + |> Enum.map(formatter) + end + + defp search_aliases(query) when is_binary(query) do + trimmed_query = String.trim(query) + + mention_alias = + case trimmed_query do + "@" <> rest -> String.trim(rest) + _ -> nil + end + + [trimmed_query, mention_alias] + |> Enum.filter(&is_binary/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + defp search_aliases(_), do: [] + + defp person_match_dynamic(search_patterns) do + Enum.reduce(search_patterns, dynamic(false), fn search_pattern, dynamic -> + dynamic( + [u], + ^dynamic or ilike(u.username, ^search_pattern) or ilike(u.display_name, ^search_pattern) or + ilike(u.handle, ^search_pattern) + ) + end) + end + + defp federated_post_match_dynamic(search_patterns) do + Enum.reduce(search_patterns, dynamic(false), fn search_pattern, dynamic -> + dynamic( + [m, a], + ^dynamic or + ilike(m.content, ^search_pattern) or + (not is_nil(m.title) and ilike(m.title, ^search_pattern)) or + (not is_nil(a.display_name) and ilike(a.display_name, ^search_pattern)) or + (not is_nil(a.username) and ilike(a.username, ^search_pattern)) or + (not is_nil(a.domain) and ilike(a.domain, ^search_pattern)) or + (not is_nil(a.username) and not is_nil(a.domain) and + ilike(fragment("CONCAT(?, '@', ?)", a.username, a.domain), ^search_pattern)) or + (not is_nil(a.username) and not is_nil(a.domain) and + ilike( + fragment("CONCAT('@', ?, '@', ?)", a.username, a.domain), + ^search_pattern + )) + ) + end) + end + + # Sanitize search terms to prevent LIKE pattern injection + defp sanitize_search_term(term), do: Elektrine.TextHelpers.sanitize_search_term(term) + + # Decrypt message content for search results + defp decrypt_message_content(results, _user_id) when is_list(results) do + Enum.map(results, fn result -> + if result[:id] && result[:sender_id] do + # Fetch and decrypt the message + case Repo.get(Elektrine.Social.Message, result.id) do + nil -> + result + + message -> + decrypted = Elektrine.Social.Message.decrypt_content(message) + # Update content with first 200 chars of decrypted content + content = + if decrypted.content do + String.slice(decrypted.content, 0, 200) + else + result.content + end + + Map.put(result, :content, content) + end + else + result + end + end) + end + + # Decrypt email content for search results + defp decrypt_email_content(results, user_id) when is_list(results) do + Enum.map(results, fn result -> + if result[:id] && result[:mailbox_id] do + # Fetch and decrypt the email + case Repo.get(Elektrine.Email.Message, result.id) do + nil -> + result + + message -> + decrypted = Elektrine.Email.Message.decrypt_content(message, user_id) + # Update content with first 200 chars of decrypted content + content = + if decrypted.text_body do + String.slice(decrypted.text_body, 0, 200) + else + result.content + end + + Map.put(result, :content, content) + end + else + result + end + end) + end +end diff --git a/apps/elektrine/lib/elektrine/search/cached.ex b/apps/elektrine/lib/elektrine/search/cached.ex new file mode 100644 index 0000000..5b7614b --- /dev/null +++ b/apps/elektrine/lib/elektrine/search/cached.ex @@ -0,0 +1,120 @@ +defmodule Elektrine.Search.Cached do + @moduledoc """ + Cached search functionality for emails and other data. + Provides fast access to search results and recent searches. + """ + + alias Elektrine.AppCache + alias Elektrine.Email + + @doc """ + Performs cached email search with pagination. + """ + def search_messages(user_id, mailbox_id, query, page \\ 1, per_page \\ 20) do + case AppCache.get_search_results(user_id, query, page, per_page, fn -> + # This would need to be implemented in Email context + # Run a lightweight local search until Email-backed search is integrated. + perform_email_search(mailbox_id, query, page, per_page) + end) do + {:ok, results} -> + # Update recent searches in background + Elektrine.Async.start(fn -> + add_to_recent_searches(user_id, query) + end) + + results + + {:error, _reason} -> + # Fallback to direct search if cache fails + perform_email_search(mailbox_id, query, page, per_page) + end + end + + @doc """ + Gets cached recent searches for a user. + """ + def get_recent_searches(user_id) do + case AppCache.get_recent_searches(user_id, fn -> + # This would load from database or return empty list + [] + end) do + {:ok, searches} -> searches + {:error, _reason} -> [] + end + end + + @doc """ + Invalidates search cache for a user. + """ + def invalidate_search_cache(user_id) do + AppCache.invalidate_search_cache(user_id) + end + + # Private functions + + defp perform_email_search(mailbox_id, query, page, per_page) do + # Simulate search - in real implementation this would: + # 1. Use full-text search on email content + # 2. Search across subject, body, sender, etc. + # 3. Apply ranking/relevance scoring + # 4. Return paginated results + + offset = (page - 1) * per_page + + # Use simple subject/sender matching for the fallback search path. + messages = + Email.list_messages(mailbox_id, per_page * 2, 0) + |> Enum.filter(fn message -> + query_lower = String.downcase(query) + + subject_match = + case message.subject do + nil -> false + "" -> false + subject -> subject |> String.downcase() |> String.contains?(query_lower) + end + + sender_match = + case message.from do + nil -> false + "" -> false + from -> from |> String.downcase() |> String.contains?(query_lower) + end + + subject_match or sender_match + end) + |> Enum.drop(offset) + |> Enum.take(per_page) + + total_count = length(messages) + total_pages = ceil(total_count / per_page) + + %{ + messages: messages, + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages, + has_next: page < total_pages, + has_prev: page > 1, + query: query + } + end + + defp add_to_recent_searches(user_id, query) do + # Add query to user's recent searches + recent = get_recent_searches(user_id) + + # Remove query if it already exists, then add to front + updated_recent = + [query | Enum.reject(recent, &(&1 == query))] + # Keep only last 10 searches + |> Enum.take(10) + + # This would save to database in real implementation + case AppCache.get_recent_searches(user_id, fn -> updated_recent end) do + {:ok, _} -> :ok + {:error, _} -> :ok + end + end +end diff --git a/apps/elektrine/lib/elektrine/search/domain_rule.ex b/apps/elektrine/lib/elektrine/search/domain_rule.ex new file mode 100644 index 0000000..b21b2fc --- /dev/null +++ b/apps/elektrine/lib/elektrine/search/domain_rule.ex @@ -0,0 +1,45 @@ +defmodule Elektrine.Search.DomainRule do + @moduledoc """ + A per-user ranking rule for a web-search domain: block it entirely, lower + or raise its ranking, or pin it to the top of results. + """ + + use Ecto.Schema + import Ecto.Changeset + + @actions [:block, :lower, :raise, :pin] + + schema "search_domain_rules" do + field :domain, :string + field :action, Ecto.Enum, values: @actions + + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + def actions, do: @actions + + def changeset(rule, attrs) do + rule + |> cast(attrs, [:domain, :action]) + |> update_change(:domain, &normalize_domain/1) + |> validate_required([:domain, :action]) + |> validate_length(:domain, max: 253) + |> validate_format(:domain, ~r/^(?!-)[a-z0-9-]{1,63}(? unique_constraint([:user_id, :domain]) + end + + @doc "Lowercases and strips a leading `www.`/trailing dot so rules match hosts." + def normalize_domain(domain) when is_binary(domain) do + domain + |> String.trim() + |> String.downcase() + |> String.replace_prefix("www.", "") + |> String.trim_trailing(".") + end + + def normalize_domain(domain), do: domain +end diff --git a/apps/elektrine/lib/elektrine/search/domain_rules.ex b/apps/elektrine/lib/elektrine/search/domain_rules.ex new file mode 100644 index 0000000..53b8239 --- /dev/null +++ b/apps/elektrine/lib/elektrine/search/domain_rules.ex @@ -0,0 +1,94 @@ +defmodule Elektrine.Search.DomainRules do + @moduledoc """ + Per-user domain rules for web search: block, lower, raise, or pin a domain + across all searches. A rule matches its domain and every subdomain, so a + rule on `example.com` also covers `docs.example.com`. + """ + + import Ecto.Query + + alias Elektrine.Repo + alias Elektrine.Search.DomainRule + + # Relevance scale context: internal app results score ~0.7-1.1 and external + # web results ~0.6 and below, so pins must clear 1.1 and the boost/penalty + # moves a result across roughly half that band. + @pin_relevance 5.0 + @raise_boost 0.35 + @lower_penalty 0.35 + @max_rules_per_user 500 + + def list_rules(user_id) do + DomainRule + |> where(user_id: ^user_id) + |> order_by(asc: :domain) + |> Repo.all() + end + + @doc "Returns the user's rules as a `%{\"domain\" => action}` map." + def rules_map(nil), do: %{} + + def rules_map(user_id) do + user_id + |> list_rules() + |> Map.new(&{&1.domain, &1.action}) + end + + def set_rule(user, domain, action) do + if Repo.aggregate(where(DomainRule, user_id: ^user.id), :count) >= @max_rules_per_user do + {:error, :rule_limit_reached} + else + %DomainRule{user_id: user.id} + |> DomainRule.changeset(%{domain: domain, action: action}) + |> Repo.insert( + on_conflict: {:replace, [:action, :updated_at]}, + conflict_target: [:user_id, :domain], + returning: true + ) + end + end + + def remove_rule(user, domain) do + domain = DomainRule.normalize_domain(domain) + + DomainRule + |> where(user_id: ^user.id, domain: ^domain) + |> Repo.delete_all() + + :ok + end + + @doc """ + Re-ranks search-result maps (anything with `:url` and `:relevance` keys) + according to a `rules_map/1`. Blocked domains are dropped entirely. + """ + def apply_rules(results, rules) when rules == %{} or is_nil(rules), do: results + + def apply_rules(results, rules) do + Enum.flat_map(results, fn result -> + case rule_for(result, rules) do + :block -> [] + :pin -> [Map.put(result, :relevance, @pin_relevance)] + :raise -> [Map.update(result, :relevance, @raise_boost, &(&1 + @raise_boost))] + :lower -> [Map.update(result, :relevance, -@lower_penalty, &(&1 - @lower_penalty))] + nil -> [result] + end + end) + end + + defp rule_for(%{url: url}, rules) when is_binary(url) do + case URI.parse(url) do + %URI{host: host} when is_binary(host) -> + host = DomainRule.normalize_domain(host) + + Enum.find_value(rules, fn {domain, action} -> + if host == domain or String.ends_with?(host, "." <> domain), do: action + end) + + _uri -> + nil + end + end + + defp rule_for(_result, _rules), do: nil +end diff --git a/apps/elektrine/lib/elektrine/search/paige_rate_limiter.ex b/apps/elektrine/lib/elektrine/search/paige_rate_limiter.ex new file mode 100644 index 0000000..74195f7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/search/paige_rate_limiter.ex @@ -0,0 +1,49 @@ +defmodule Elektrine.Search.PaigeRateLimiter do + @moduledoc """ + Bounds requests that fan out to Paige's external search providers. + + This limiter is intentionally separate from local search suggestions: a + remote meta-search can consume paid API quota and considerably more work. + """ + + use Elektrine.RateLimiter, + table: :paige_search_rate_limiter, + limits: [ + {:minute, 30}, + {:hour, 500} + ], + lockout: {:minutes, 2}, + cleanup_interval: {:minutes, 2} + + # Use the calling process as the lock requester. A shared requester ID makes + # :global treat concurrent calls as re-entrant, defeating atomic admission. + def check_and_record(identifier) do + :global.trans({{__MODULE__, identifier}, self()}, fn -> + case check_rate_limit(identifier) do + {:ok, :allowed} = result -> + record_attempt(identifier) + result + + error -> + error + end + end) + end + + @doc "Atomically checks and records one external Paige search." + def allow_query(identifier) do + if :ets.whereis(:paige_search_rate_limiter) == :undefined do + :ok + else + case check_and_record(identifier) do + {:ok, :allowed} -> :ok + {:error, {:rate_limited, retry_after, _reason}} -> {:error, retry_after} + _unexpected -> :ok + end + end + rescue + ArgumentError -> :ok + catch + :exit, _reason -> :ok + end +end diff --git a/apps/elektrine/lib/elektrine/search/rate_limiter.ex b/apps/elektrine/lib/elektrine/search/rate_limiter.ex new file mode 100644 index 0000000..69d867d --- /dev/null +++ b/apps/elektrine/lib/elektrine/search/rate_limiter.ex @@ -0,0 +1,38 @@ +defmodule Elektrine.Search.RateLimiter do + @moduledoc """ + Read-path rate limiting for global search and live suggestions. + + Limits are tuned for interactive typing while still bounding runaway loops. + """ + + use Elektrine.RateLimiter, + table: :search_rate_limiter, + limits: [ + {:minute, 200}, + {:hour, 4000} + ], + lockout: {:minutes, 2}, + cleanup_interval: {:minutes, 2} + + @doc """ + Checks and records a search read in one call. + Returns :ok when allowed, or {:error, retry_after_seconds} when limited. + """ + def allow_query(identifier) do + if :ets.whereis(:search_rate_limiter) == :undefined do + :ok + else + case check_rate_limit(identifier) do + {:ok, :allowed} -> + record_attempt(identifier) + :ok + + {:error, {:rate_limited, retry_after, _reason}} -> + {:error, retry_after} + end + end + rescue + ArgumentError -> + :ok + end +end diff --git a/apps/elektrine/lib/elektrine/secrets/backfill.ex b/apps/elektrine/lib/elektrine/secrets/backfill.ex new file mode 100644 index 0000000..8875c93 --- /dev/null +++ b/apps/elektrine/lib/elektrine/secrets/backfill.ex @@ -0,0 +1,166 @@ +defmodule Elektrine.Secrets.Backfill do + @moduledoc false + + alias Elektrine.Repo + alias Elektrine.Secrets.EncryptedString + + @spec run(keyword()) :: %{updated: non_neg_integer(), scanned: non_neg_integer(), fields: map()} + def run(opts \\ []) do + dry_run? = Keyword.get(opts, :dry_run, true) + + field_results = + Enum.map(secret_fields(), fn field -> + {field.name, backfill_field(field, dry_run?)} + end) + + %{ + updated: total(field_results, :updated), + scanned: total(field_results, :scanned), + fields: Map.new(field_results) + } + end + + defp backfill_field(field, dry_run?) do + if field[:update] == :actor_metadata_private_key do + backfill_actor_metadata_private_key(field, dry_run?) + else + backfill_scalar_field(field, dry_run?) + end + end + + defp backfill_scalar_field(field, dry_run?) do + rows = Repo.query!(select_sql(field), []).rows + + updated = + if dry_run? do + 0 + else + Enum.reduce(rows, 0, fn [id, plaintext], count -> + case EncryptedString.encrypt(plaintext) do + {:ok, encrypted} -> + Repo.query!(update_sql(field), [encrypted, id]) + count + 1 + + :error -> + count + end + end) + end + + %{scanned: length(rows), updated: updated} + end + + defp backfill_actor_metadata_private_key(_field, dry_run?) do + rows = + Repo.query!( + "SELECT id, metadata::text FROM activitypub_actors WHERE metadata::text LIKE '%private_key%'", + [] + ).rows + |> Enum.filter(fn [_id, metadata_text] -> + metadata_text + |> decode_json_map() + |> Map.get("private_key") + |> case do + private_key when is_binary(private_key) -> not EncryptedString.encrypted?(private_key) + _ -> false + end + end) + + updated = + if dry_run? do + 0 + else + Enum.reduce(rows, 0, fn [id, metadata_text], count -> + encrypted_metadata = + metadata_text + |> decode_json_map() + |> Map.update("private_key", nil, fn private_key -> + case EncryptedString.encrypt(private_key) do + {:ok, encrypted} -> encrypted + :error -> private_key + end + end) + + Repo.query!( + "UPDATE activitypub_actors SET metadata = $1, updated_at = NOW() WHERE id = $2", + [Jason.encode!(encrypted_metadata), id] + ) + + count + 1 + end) + end + + %{scanned: length(rows), updated: updated} + end + + defp total(field_results, key) do + Enum.reduce(field_results, 0, fn {_name, result}, acc -> acc + Map.fetch!(result, key) end) + end + + defp select_sql(field) do + "SELECT #{field.pk}, #{field.column} FROM #{field.table} WHERE #{field.column} IS NOT NULL AND #{field.column} NOT LIKE 'enc:v1:%'" + end + + defp update_sql(field) do + "UPDATE #{field.table} SET #{field.column} = $1, updated_at = NOW() WHERE #{field.pk} = $2" + end + + defp decode_json_map(value) when is_binary(value) do + with {:ok, decoded} <- Jason.decode(value), + {:ok, normalized} <- normalize_decoded_json_map(decoded) do + normalized + else + _ -> %{} + end + end + + defp normalize_decoded_json_map(decoded) when is_map(decoded), do: {:ok, decoded} + + defp normalize_decoded_json_map(decoded) when is_binary(decoded) do + case Jason.decode(decoded) do + {:ok, nested} when is_map(nested) -> {:ok, nested} + _ -> :error + end + end + + defp normalize_decoded_json_map(_), do: :error + + defp secret_fields do + [ + %{ + name: :users_activitypub_private_key, + table: "users", + pk: "id", + column: "activitypub_private_key" + }, + %{ + name: :users_bluesky_app_password, + table: "users", + pk: "id", + column: "bluesky_app_password" + }, + %{ + name: :users_two_factor_secret, + table: "users", + pk: "id", + column: "two_factor_secret" + }, + %{ + name: :signing_keys_private_key, + table: "signing_keys", + pk: "key_id", + column: "private_key" + }, + %{ + name: :email_custom_domains_dkim_private_key, + table: "email_custom_domains", + pk: "id", + column: "dkim_private_key" + }, + %{ + name: :activitypub_actors_metadata_private_key, + update: :actor_metadata_private_key + } + ] + end +end diff --git a/apps/elektrine/lib/elektrine/secrets/encrypted_string.ex b/apps/elektrine/lib/elektrine/secrets/encrypted_string.ex new file mode 100644 index 0000000..0e10e82 --- /dev/null +++ b/apps/elektrine/lib/elektrine/secrets/encrypted_string.ex @@ -0,0 +1,92 @@ +defmodule Elektrine.Secrets.EncryptedString do + @moduledoc false + + @behaviour Ecto.Type + + @prefix "enc:v1:" + @aad "ElektrineSecretStringV1" + + def type, do: :string + + def cast(nil), do: {:ok, nil} + def cast(value) when is_binary(value), do: {:ok, value} + def cast(_value), do: :error + + def load(nil), do: {:ok, nil} + + def load(value) when is_binary(value) do + case decrypt(value) do + {:ok, decrypted} -> {:ok, decrypted} + :error -> {:ok, value} + end + end + + def load(_value), do: :error + + def dump(nil), do: {:ok, nil} + + def dump(value) when is_binary(value) do + case encrypt(value) do + {:ok, encrypted} -> {:ok, encrypted} + :error -> :error + end + end + + def dump(_value), do: :error + + def equal?(left, right), do: left == right + + def embed_as(_format), do: :self + + def encrypted?(value) when is_binary(value), do: String.starts_with?(value, @prefix) + def encrypted?(_value), do: false + + def encrypt(value) when is_binary(value) do + with {:ok, key} <- key() do + iv = :crypto.strong_rand_bytes(12) + + {ciphertext, tag} = + :crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, value, @aad, true) + + payload = Base.url_encode64(iv <> tag <> ciphertext, padding: false) + {:ok, @prefix <> payload} + end + rescue + _ -> :error + end + + def encrypt(_value), do: :error + + def decrypt(value) when is_binary(value) do + with true <- encrypted?(value), + payload <- String.replace_prefix(value, @prefix, ""), + {:ok, binary} <- Base.url_decode64(payload, padding: false), + true <- byte_size(binary) > 28, + <> <- binary, + {:ok, key} <- key(), + plaintext when is_binary(plaintext) <- + :crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, ciphertext, @aad, tag, false) do + {:ok, plaintext} + else + _ -> :error + end + rescue + _ -> :error + end + + def decrypt(_value), do: :error + + defp key do + master_secret = Application.get_env(:elektrine, :encryption_master_secret) + key_salt = Application.get_env(:elektrine, :encryption_key_salt) + + if present?(master_secret) and present?(key_salt) do + {:ok, :crypto.pbkdf2_hmac(:sha256, master_secret, key_salt <> "secret_fields", 100_000, 32)} + else + :error + end + end + + defp present?(value) when is_binary(value), do: String.trim(value) != "" + defp present?(_value), do: false +end diff --git a/apps/elektrine/lib/elektrine/security/content_validator.ex b/apps/elektrine/lib/elektrine/security/content_validator.ex new file mode 100644 index 0000000..f0ccea8 --- /dev/null +++ b/apps/elektrine/lib/elektrine/security/content_validator.ex @@ -0,0 +1,195 @@ +defmodule Elektrine.Security.ContentValidator do + @moduledoc """ + Advanced content validation and security for all contexts. + Prevents spam, abuse, and malicious content across Chat, Timeline, and Discussions. + """ + + alias Elektrine.ActivityPub.Mentions + + # Spam patterns + @spam_patterns [ + # Excessive repeated characters + ~r/(.)\1{10,}/, + # Too many caps + ~r/[A-Z]{20,}/, + # Excessive mentions + ~r/(@\w+.*){6,}/, + # Suspicious links patterns + ~r/bit\.ly|tinyurl|t\.co|goo\.gl/i, + # Cryptocurrency spam + ~r/bitcoin|crypto|ethereum|nft|pump.*dump/i, + # Common spam phrases + ~r/click.*here.*now|limited.*time.*offer|act.*now|free.*money/i + ] + + # Malicious patterns + @malicious_patterns [ + # XSS attempts + ~r/ error + end + end + + @doc """ + Validates title content for discussions and timeline posts. + """ + def validate_title(title) when is_binary(title) do + cond do + String.length(title) > 200 -> + {:error, :title_too_long} + + not Elektrine.Strings.present?(title) -> + {:error, :title_empty} + + Regex.match?(~r/[<>"]/, title) -> + {:error, :title_invalid_chars} + + true -> + {:ok, String.trim(title)} + end + end + + # Private functions + + defp check_length(content, context) do + max_length = + case context do + :chat -> 2000 + :timeline -> 20_000 + :discussion -> 20_000 + _ -> 2000 + end + + if String.length(content) > max_length do + {:error, {:content_too_long, "Content exceeds #{max_length} character limit"}} + else + :ok + end + end + + defp check_malicious_content(content) do + malicious_found = Enum.find(@malicious_patterns, &Regex.match?(&1, content)) + + case malicious_found do + nil -> :ok + _pattern -> {:error, :malicious_content} + end + end + + defp check_spam_patterns(content) do + spam_found = Enum.find(@spam_patterns, &Regex.match?(&1, content)) + + case spam_found do + nil -> :ok + _pattern -> {:error, :spam_detected} + end + end + + defp check_context_specific_rules(content, context) do + case context do + :timeline -> + # Timeline-specific rules + cond do + count_hashtags(content) > 10 -> + {:error, :too_many_hashtags} + + count_mentions(content) > 5 -> + {:error, :too_many_mentions} + + true -> + :ok + end + + :discussion -> + # Discussion-specific rules + if String.length(String.trim(content)) < 10 do + {:error, :discussion_too_short} + else + :ok + end + + _ -> + :ok + end + end + + defp sanitize_content(content) do + content + # Remove null bytes + |> String.replace(~r/\0/, "") + # Normalize whitespace but preserve newlines + # Replace multiple spaces/tabs (but not newlines) with single space + |> String.replace(~r/[^\S\n]+/, " ") + # Remove trailing whitespace from each line + |> String.split("\n") + |> Enum.map_join("\n", &String.trim_trailing/1) + # Trim leading/trailing whitespace from entire content + |> String.trim() + end + + defp count_hashtags(content) do + Regex.scan(~r/#\w+/, content) |> length() + end + + defp count_mentions(content) do + Mentions.count_mentions(content) + end + + @doc """ + Validates URL safety for link previews and shared content. + """ + def validate_url(url) when is_binary(url) do + uri = URI.parse(url) + + cond do + uri.scheme not in ["http", "https"] -> + {:error, :invalid_protocol} + + String.contains?(uri.host || "", ["localhost", "127.0.0.1", "0.0.0.0"]) -> + {:error, :localhost_not_allowed} + + String.length(url) > 2000 -> + {:error, :url_too_long} + + true -> + {:ok, url} + end + end + + @doc """ + Checks if a user is allowed to perform an action based on their account status. + """ + def check_user_permissions(user, _action) do + cond do + user.banned -> + {:error, :user_banned} + + user.suspended -> + {:error, :user_suspended} + + true -> + :ok + end + end +end diff --git a/apps/elektrine/lib/elektrine/security/file_path.ex b/apps/elektrine/lib/elektrine/security/file_path.ex new file mode 100644 index 0000000..15f5a65 --- /dev/null +++ b/apps/elektrine/lib/elektrine/security/file_path.ex @@ -0,0 +1,49 @@ +defmodule Elektrine.Security.FilePath do + @moduledoc """ + Helpers for constraining stored filesystem paths to an expected directory. + """ + + @doc """ + Returns `{:ok, expanded_path}` only when `path` is a regular file inside + `base_dir`. + """ + def validate_existing_file(path, base_dir) do + with {:ok, expanded_path} <- validate_child_path(path, base_dir), + {:ok, %File.Stat{type: :regular}} <- File.lstat(expanded_path) do + {:ok, expanded_path} + else + {:ok, %File.Stat{type: :symlink}} -> {:error, :unsafe_path} + {:ok, %File.Stat{}} -> {:error, :not_file} + {:error, :enoent} -> {:error, :file_not_found} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Returns `{:ok, expanded_path}` only when `path` expands below `base_dir`. + """ + def validate_child_path(path, base_dir) when is_binary(path) and is_binary(base_dir) do + if String.contains?(path, "\0") or String.contains?(base_dir, "\0") do + {:error, :unsafe_path} + else + expanded_path = Path.expand(path) + expanded_base = Path.expand(base_dir) + + if child_path?(expanded_path, expanded_base) do + {:ok, expanded_path} + else + {:error, :unsafe_path} + end + end + end + + def validate_child_path(_path, _base_dir), do: {:error, :unsafe_path} + + defp child_path?(expanded_path, expanded_base) do + expanded_path != expanded_base and + String.starts_with?(expanded_path, base_prefix(expanded_base)) + end + + defp base_prefix("/"), do: "/" + defp base_prefix(base), do: base <> "/" +end diff --git a/apps/elektrine/lib/elektrine/security/safe_external_url.ex b/apps/elektrine/lib/elektrine/security/safe_external_url.ex new file mode 100644 index 0000000..269a0be --- /dev/null +++ b/apps/elektrine/lib/elektrine/security/safe_external_url.ex @@ -0,0 +1,52 @@ +defmodule Elektrine.Security.SafeExternalURL do + @moduledoc false + + alias Elektrine.Security.URLValidator + + def normalize(url) when is_binary(url) do + normalized = String.trim(url) + + if Regex.match?(~r/[\x00-\x1F\x7F]/, normalized) do + {:error, :invalid_url} + else + case URI.parse(normalized) do + %URI{scheme: scheme, host: host, userinfo: nil} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + case URLValidator.validate(normalized) do + :ok -> {:ok, normalized} + {:error, reason} -> {:error, reason} + end + + %URI{userinfo: userinfo} when not is_nil(userinfo) -> + {:error, :userinfo_not_allowed} + + _ -> + {:error, :invalid_url} + end + end + end + + def normalize(_), do: {:error, :invalid_url} + + def normalize_href(url) when is_binary(url) do + normalized = String.trim(url) + + if Regex.match?(~r/[\x00-\x1F\x7F]/, normalized) do + {:error, :invalid_url} + else + case URI.parse(normalized) do + %URI{scheme: scheme, host: host, userinfo: nil} = parsed + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + {:ok, URI.to_string(parsed)} + + %URI{userinfo: userinfo} when not is_nil(userinfo) -> + {:error, :userinfo_not_allowed} + + _ -> + {:error, :invalid_url} + end + end + end + + def normalize_href(_), do: {:error, :invalid_url} +end diff --git a/apps/elektrine/lib/elektrine/security/url_validator.ex b/apps/elektrine/lib/elektrine/security/url_validator.ex new file mode 100644 index 0000000..69cee3b --- /dev/null +++ b/apps/elektrine/lib/elektrine/security/url_validator.ex @@ -0,0 +1,496 @@ +defmodule Elektrine.Security.URLValidator do + @moduledoc """ + URL validation for SSRF (Server-Side Request Forgery) protection. + + Validates URLs before making external HTTP requests to prevent + access to internal networks, cloud metadata endpoints, and other + sensitive resources. + """ + + import Bitwise + + @doc """ + Validates a URL for SSRF protection. + + Returns `:ok` if the URL is safe to fetch, or `{:error, reason}` if it should be blocked. + + ## Examples + + iex> URLValidator.validate("https://example.com") + :ok + + iex> URLValidator.validate("http://127.0.0.1/admin") + {:error, :private_ip} + + iex> URLValidator.validate("http://169.254.169.254/latest/meta-data/") + {:error, :private_ip} + """ + def validate(url) when is_binary(url) do + url + |> URI.parse() + |> validate_uri() + end + + def validate(_), do: {:error, :invalid_url} + + @doc """ + Validates a WebSocket URL for SSRF protection. + + `wss://` is treated like `https://` and `ws://` is treated like `http://` + for host and port validation. Plaintext `ws://` is rejected unless + `:allow_insecure_transport` is true. + """ + def validate_websocket(url, opts \\ []) + + def validate_websocket(url, opts) when is_binary(url) and is_list(opts) do + allow_insecure_transport = Keyword.get(opts, :allow_insecure_transport, false) + allow_localhost = Keyword.get(opts, :allow_localhost, false) + + case URI.parse(url) do + %URI{scheme: scheme, host: host, userinfo: userinfo} = uri + when scheme in ["ws", "wss"] and is_binary(host) and host != "" and is_nil(userinfo) -> + cond do + scheme == "ws" and not allow_insecure_transport -> + {:error, :invalid_scheme} + + allow_localhost and websocket_localhost?(host) -> + websocket_uri_for_http_validation(uri) + |> validate_request_port_only() + + true -> + uri + |> websocket_uri_for_http_validation() + |> validate_uri() + end + + %URI{userinfo: userinfo} when not is_nil(userinfo) -> + {:error, :userinfo_not_allowed} + + %URI{scheme: nil} -> + {:error, :missing_scheme} + + %URI{scheme: _scheme} -> + {:error, :invalid_scheme} + end + end + + def validate_websocket(_, _), do: {:error, :invalid_url} + + defp validate_uri(%URI{scheme: scheme, host: host} = uri) + when scheme in ["http", "https"] and is_binary(host) do + with :ok <- validate_host(host), + false <- dangerous_port?(uri) do + :ok + else + true -> + {:error, :dangerous_port} + + {:error, reason} -> + {:error, reason} + end + end + + defp validate_uri(%URI{scheme: nil}), do: {:error, :missing_scheme} + + defp validate_uri(%URI{scheme: scheme}) when scheme not in ["http", "https"], + do: {:error, :invalid_scheme} + + defp validate_uri(_), do: {:error, :invalid_url} + + defp websocket_uri_for_http_validation(%URI{scheme: "ws"} = uri), do: %{uri | scheme: "http"} + defp websocket_uri_for_http_validation(%URI{scheme: "wss"} = uri), do: %{uri | scheme: "https"} + + defp validate_request_port_only(%URI{} = uri) do + if dangerous_port?(uri), do: {:error, :dangerous_port}, else: :ok + end + + defp websocket_localhost?(host) when is_binary(host) do + normalize_host(host) in ["localhost", "127.0.0.1", "::1"] + end + + defp validate_host(host) when is_binary(host) do + normalized_host = normalize_host(host) + + cond do + private_ip?(normalized_host) -> + {:error, :private_ip} + + is_private_domain?(normalized_host) -> + {:error, :private_domain} + + true -> + case resolve_host_ips(normalized_host) do + {:ok, ips} -> + if Enum.any?(ips, &private_or_special_ip?/1) do + {:error, :private_ip} + else + :ok + end + + {:error, _} -> + {:error, :unresolvable_host} + end + end + end + + @doc """ + Validates a URL and raises on failure. + """ + def validate!(url) do + case validate(url) do + :ok -> :ok + {:error, reason} -> raise ArgumentError, "URL validation failed: #{reason}" + end + end + + @doc "Resolves a validated public host or URL to a pinned IP address." + def resolve_public_address(url_or_host) when is_binary(url_or_host) do + case URI.parse(url_or_host) do + %URI{host: host} when is_binary(host) and host != "" -> resolve_public_host(host) + _ -> resolve_public_host(url_or_host) + end + end + + def resolve_public_address(%URI{host: host}) when is_binary(host) and host != "" do + resolve_public_host(host) + end + + def resolve_public_address(_), do: {:error, :invalid_url} + + @doc "Resolves any host or URL to a pinned IP address without public-host checks." + def resolve_address(url_or_host) when is_binary(url_or_host) do + case URI.parse(url_or_host) do + %URI{host: host} when is_binary(host) and host != "" -> resolve_host(url_or_host, host) + _ -> resolve_host(url_or_host, url_or_host) + end + end + + def resolve_address(%URI{host: host}) when is_binary(host) and host != "" do + resolve_host(host, host) + end + + def resolve_address(_), do: {:error, :invalid_url} + + @doc """ + Checks if a host is a private/internal IP address. + """ + def private_ip?(host) when is_binary(host) do + host = normalize_host(host) + + case parse_address(host) do + {:ok, ip} -> + private_or_special_ip?(ip) + + :error -> + # Hostname checks for known internal patterns. + ipv4_private?(host) or + ipv6_private?(host) or + localhost?(host) or + link_local?(host) or + cloud_metadata?(host) + end + end + + def private_ip?(_), do: false + + defp normalize_host(host) do + host + |> String.downcase() + |> String.trim() + |> String.trim_leading("[") + |> String.trim_trailing("]") + |> String.trim_trailing(".") + |> String.split("%", parts: 2) + |> List.first() + end + + defp resolve_public_host(host) do + normalized_host = normalize_host(host) + + cond do + private_ip?(normalized_host) -> + {:error, :private_ip} + + is_private_domain?(normalized_host) -> + {:error, :private_domain} + + true -> + case resolve_host_ips(normalized_host) do + {:ok, [ip | _] = ips} -> + if Enum.any?(ips, &private_or_special_ip?/1) do + {:error, :private_ip} + else + {:ok, ip} + end + + {:error, reason} -> + {:error, reason} + end + end + end + + defp resolve_host(_original_host, host) do + normalized_host = normalize_host(host) + + case resolve_host_ips(normalized_host) do + {:ok, [ip | _]} -> {:ok, ip} + {:error, reason} -> {:error, reason} + end + end + + defp parse_address(host) do + case :inet.parse_address(String.to_charlist(host)) do + {:ok, ip} -> {:ok, ip} + {:error, _} -> :error + end + end + + defp resolve_host_ips(host) do + case parse_address(host) do + {:ok, ip} -> + {:ok, [ip]} + + :error -> + try do + a_records = :inet_res.lookup(String.to_charlist(host), :in, :a) + aaaa_records = :inet_res.lookup(String.to_charlist(host), :in, :aaaa) + records = Enum.uniq(a_records ++ aaaa_records) + + if records == [] do + {:error, :dns_resolution_failed} + else + {:ok, records} + end + rescue + _ -> {:error, :dns_resolution_failed} + end + end + end + + defp private_or_special_ip?({a, b, c, d}) do + ipv4_private_tuple?({a, b, c, d}) + end + + defp private_or_special_ip?({a, b, c, d, e, f, g, h}) do + ipv6_private_tuple?({a, b, c, d, e, f, g, h}) + end + + defp private_or_special_ip?(_), do: true + + defp ipv4_private_tuple?({a, b, _c, _d}) do + cond do + # 10.0.0.0/8 + a == 10 -> + true + + # 172.16.0.0/12 + a == 172 and b >= 16 and b <= 31 -> + true + + # 192.168.0.0/16 + a == 192 and b == 168 -> + true + + # 127.0.0.0/8 loopback + a == 127 -> + true + + # 0.0.0.0/8 + a == 0 -> + true + + # 169.254.0.0/16 link-local + a == 169 and b == 254 -> + true + + # 100.64.0.0/10 CGNAT + a == 100 and b >= 64 and b <= 127 -> + true + + # 198.18.0.0/15 benchmarking + a == 198 and b in [18, 19] -> + true + + # 224.0.0.0/4 multicast/reserved + a >= 224 -> + true + + true -> + false + end + end + + defp ipv6_private_tuple?({0, 0, 0, 0, 0, 0, 0, 1}), do: true + defp ipv6_private_tuple?({0, 0, 0, 0, 0, 0, 0, 0}), do: true + + # IPv4-mapped IPv6 ::ffff:w.x.y.z + defp ipv6_private_tuple?({0, 0, 0, 0, 0, 65_535, g, h}) do + mapped_v4 = {g >>> 8, g &&& 0xFF, h >>> 8, h &&& 0xFF} + ipv4_private_tuple?(mapped_v4) + end + + defp ipv6_private_tuple?({a, _b, _c, _d, _e, _f, _g, _h}) do + cond do + # fc00::/7 unique-local + (a &&& 0xFE00) == 0xFC00 -> + true + + # fe80::/10 link-local + (a &&& 0xFFC0) == 0xFE80 -> + true + + # ff00::/8 multicast + (a &&& 0xFF00) == 0xFF00 -> + true + + true -> + false + end + end + + defp ipv4_private?(host) do + # 10.0.0.0/8 + # 172.16.0.0/12 + # 192.168.0.0/16 + # 127.0.0.0/8 (loopback) + # 0.0.0.0/8 + # 100.64.0.0/10 (Carrier-grade NAT) + # 169.254.0.0/16 (link-local) + # 224.0.0.0/4 (multicast) + String.starts_with?(host, "10.") or + Regex.match?(~r/^172\.(1[6-9]|2[0-9]|3[0-1])\./, host) or + String.starts_with?(host, "192.168.") or + String.starts_with?(host, "127.") or + String.starts_with?(host, "0.") or + Regex.match?(~r/^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./, host) or + String.starts_with?(host, "169.254.") or + multicast_ipv4?(host) + end + + defp multicast_ipv4?(host) do + case String.split(host, ".", parts: 2) do + [first | _] -> + case Integer.parse(first) do + {n, ""} when n >= 224 and n <= 239 -> true + _ -> false + end + + _ -> + false + end + end + + defp ipv6_private?(host) do + # Remove brackets for IPv6 + host = String.trim(host, "[]") + + String.starts_with?(host, [ + # Loopback + "::1", + # Unique local addresses + "fc00:", + "fd00:", + # Link-local + "fe80:", + # IPv4-mapped IPv6 + "::ffff:127.", + "::ffff:10.", + "::ffff:192.168.", + "::ffff:169.254.", + # Discard prefix + "100::", + # Documentation + "2001:db8:" + ]) + end + + defp localhost?(host) do + host in [ + "localhost", + "localhost.localdomain", + "ip6-localhost", + "ip6-loopback" + ] + end + + defp link_local?(host) do + String.ends_with?(host, [ + ".local", + ".localhost", + ".internal", + ".lan", + ".home", + ".corp", + ".localdomain" + ]) + end + + defp cloud_metadata?(host) do + # AWS, GCP, Azure metadata endpoints + host in [ + "169.254.169.254", + "metadata.google.internal", + "metadata", + "169.254.170.2" + ] or + String.ends_with?(host, ".internal") or + String.contains?(host, "metadata") + end + + @doc """ + Checks if a domain is a private/internal domain. + """ + def is_private_domain?(host) when is_binary(host) do + host = String.downcase(host) + + String.ends_with?(host, [ + ".local", + ".localhost", + ".internal", + ".lan", + ".home", + ".corp", + ".localdomain", + ".intranet", + ".private" + ]) or + host in [ + "localhost", + "localhost.localdomain", + "broadcasthost", + "ip6-localhost", + "ip6-loopback", + "ip6-localnet", + "ip6-mcastprefix", + "ip6-allnodes", + "ip6-allrouters" + ] + end + + # Common internal service ports that should be blocked + @dangerous_ports [ + 22, + 23, + 25, + 110, + 143, + 445, + 3306, + 5432, + 6379, + 27_017, + 9200, + 9300, + 11_211, + 2379, + 2380 + ] + + def dangerous_port?(%URI{port: nil}), do: false + def dangerous_port?(%URI{port: 80}), do: false + def dangerous_port?(%URI{port: 443}), do: false + def dangerous_port?(%URI{port: 8080}), do: false + def dangerous_port?(%URI{port: 8443}), do: false + def dangerous_port?(%URI{port: port}) when port in @dangerous_ports, do: true + def dangerous_port?(_), do: false +end diff --git a/apps/elektrine/lib/elektrine/security_alerts.ex b/apps/elektrine/lib/elektrine/security_alerts.ex new file mode 100644 index 0000000..9fea278 --- /dev/null +++ b/apps/elektrine/lib/elektrine/security_alerts.ex @@ -0,0 +1,315 @@ +defmodule Elektrine.SecurityAlerts do + @moduledoc """ + Sends security alerts to users via their mailbox and recovery email. + Security alerts are always sent regardless of notification preferences. + """ + + require Logger + import Swoosh.Email + + alias Elektrine.Accounts + alias Elektrine.Email.MailboxAdapter + alias Elektrine.EmailAddresses + alias Elektrine.Platform.Modules + alias Elektrine.Theme + + # Rate limit: 1 alert per type per user per hour + @rate_limit_seconds 3600 + + @doc """ + Send a spoofing alert when someone tries to send email from a user's address. + """ + def send_spoofing_alert(spoofed_address, recipient_address, subject) do + if email_module_compiled?() do + # Find the user who owns the spoofed address + case find_owner(spoofed_address) do + {:ok, user} -> + # Check rate limit + cache_key = "security_alert:spoofing:#{user.id}:#{spoofed_address}" + + if rate_limited?(cache_key) do + Logger.debug("Rate limited spoofing alert for #{spoofed_address}") + {:ok, :rate_limited} + else + case send_spoofing_alert_to_user(user, spoofed_address, recipient_address, subject) do + :ok -> + set_rate_limit(cache_key) + {:ok, :sent} + + {:error, reason} -> + Logger.error( + "Failed to deliver spoofing alert for #{spoofed_address}: #{inspect(reason)}" + ) + + {:error, reason} + end + end + + {:error, :not_found} -> + Logger.debug("No owner found for spoofed address: #{spoofed_address}") + {:error, :no_owner} + end + else + {:error, :email_module_unavailable} + end + end + + defp email_module_compiled? do + Modules.compiled?(:email) + end + + defp send_spoofing_alert_to_user(user, spoofed_address, recipient_address, subject) do + results = + [send_to_mailbox(user, spoofed_address, recipient_address, subject)] ++ + if(user.recovery_email, + do: [send_to_recovery_email(user, spoofed_address, recipient_address, subject)], + else: [] + ) + + if Enum.any?(results, &(&1 == :ok)) do + :ok + else + {:error, :delivery_failed} + end + end + + defp send_to_mailbox(user, spoofed_address, recipient_address, subject) do + # Get user's primary mailbox + case Elektrine.Email.get_user_mailbox(user.id) do + %{id: _mailbox_id, email: _mailbox_email} = mailbox -> + alert_subject = "Security Alert: Email spoofing attempt detected" + + html_body = spoofing_alert_html(user, spoofed_address, recipient_address, subject) + text_body = spoofing_alert_text(user, spoofed_address, recipient_address, subject) + + message_attrs = %{ + "message_id" => + "security-alert-#{:rand.uniform(1_000_000)}-#{System.system_time(:millisecond)}", + "from" => "Elektrine Security <#{EmailAddresses.local("security")}>", + "to" => mailbox.email, + "subject" => alert_subject, + "text_body" => text_body, + "html_body" => html_body, + "mailbox_id" => mailbox.id, + "status" => "received", + "spam" => false, + "metadata" => %{ + "type" => "security_alert", + "alert_type" => "spoofing", + "spoofed_address" => spoofed_address + } + } + + case MailboxAdapter.create_message(message_attrs) do + {:ok, _message} -> + Logger.info("Sent spoofing alert to mailbox for user #{user.username}") + :ok + + {:error, reason} -> + Logger.error("Failed to send spoofing alert to mailbox: #{inspect(reason)}") + :error + end + + nil -> + Logger.warning("User #{user.username} has no mailbox for security alert") + :error + end + end + + defp send_to_recovery_email(user, spoofed_address, recipient_address, subject) do + alert_subject = "Security Alert: Email spoofing attempt detected" + + email = + new() + |> to(user.recovery_email) + |> from({"Elektrine Security", EmailAddresses.local("security")}) + |> subject(alert_subject) + |> html_body(spoofing_alert_html(user, spoofed_address, recipient_address, subject)) + |> text_body(spoofing_alert_text(user, spoofed_address, recipient_address, subject)) + |> header("List-Id", EmailAddresses.list_id("elektrine-security")) + + case Elektrine.Mailer.deliver_later(email) do + {:ok, _} -> + Logger.info("Queued spoofing alert to recovery email for user #{user.username}") + :ok + + {:error, reason} -> + Logger.error("Failed to send spoofing alert to recovery email: #{inspect(reason)}") + :error + end + end + + defp find_owner(email_address) do + clean_email = email_address |> String.trim() |> String.downcase() + + # Check if it's a mailbox + case Elektrine.Email.get_mailbox_by_email(clean_email) do + %{user_id: user_id} when not is_nil(user_id) -> + try do + {:ok, Accounts.get_user!(user_id)} + rescue + Ecto.NoResultsError -> {:error, :not_found} + end + + _ -> + # Check if it's an alias + case Elektrine.Email.get_alias_by_email(clean_email) do + %{user_id: user_id} when not is_nil(user_id) -> + try do + {:ok, Accounts.get_user!(user_id)} + rescue + Ecto.NoResultsError -> {:error, :not_found} + end + + _ -> + {:error, :not_found} + end + end + end + + # Rate limiting using Cachex + defp rate_limited?(cache_key) do + case Cachex.get(:security_alerts_cache, cache_key) do + {:ok, nil} -> false + {:ok, _} -> true + _ -> false + end + end + + defp set_rate_limit(cache_key) do + Cachex.put(:security_alerts_cache, cache_key, true, ttl: :timer.seconds(@rate_limit_seconds)) + end + + # Email templates + defp spoofing_alert_html(user, spoofed_address, recipient_address, original_subject) do + timestamp = DateTime.utc_now() |> Calendar.strftime("%B %d, %Y at %H:%M UTC") + palette = Theme.email_palette(user, :error) + + """ + + + + + + + + + + + + +
    + + + + +
    + + + + + +
    +

    Security Alert

    +

    Email Spoofing Attempt Detected

    +
    + + + + + + +
    +

    + Hello #{user.username}, +

    +

    + We blocked an email that attempted to impersonate your address. Someone from outside Elektrine tried to send an email pretending to be from your account. +

    + + + + + + +
    +

    Attempt Details

    + + + + + + + + + + + + + + + + + +
    Spoofed From:#{spoofed_address}
    Sent To:#{recipient_address}
    Subject:#{original_subject || "(No subject)"}
    Time:#{timestamp}
    +
    + +

    + This email was blocked and was not delivered. +

    + +

    + What should you do?
    + No action is required. This is an informational alert. The spoofed email was automatically blocked by our security systems. +

    + +

    + If you see multiple alerts like this, someone may be targeting your identity. Consider enabling additional security measures in your account settings. +

    +
    + + + + + + +
    +

    + This is an automated security alert from Elektrine. You cannot unsubscribe from security alerts. +

    +
    +
    +
    + + + """ + end + + defp spoofing_alert_text(user, spoofed_address, recipient_address, original_subject) do + timestamp = DateTime.utc_now() |> Calendar.strftime("%B %d, %Y at %H:%M UTC") + + """ + SECURITY ALERT: Email Spoofing Attempt Detected + + Hello #{user.username}, + + We blocked an email that attempted to impersonate your address. Someone from outside Elektrine tried to send an email pretending to be from your account. + + ATTEMPT DETAILS: + - Spoofed From: #{spoofed_address} + - Sent To: #{recipient_address} + - Subject: #{original_subject || "(No subject)"} + - Time: #{timestamp} + + This email was blocked and was not delivered. + + WHAT SHOULD YOU DO? + No action is required. This is an informational alert. The spoofed email was automatically blocked by our security systems. + + If you see multiple alerts like this, someone may be targeting your identity. Consider enabling additional security measures in your account settings. + + --- + This is an automated security alert from Elektrine. You cannot unsubscribe from security alerts. + """ + end +end diff --git a/apps/elektrine/lib/elektrine/security_alerts/cache.ex b/apps/elektrine/lib/elektrine/security_alerts/cache.ex new file mode 100644 index 0000000..1a6568c --- /dev/null +++ b/apps/elektrine/lib/elektrine/security_alerts/cache.ex @@ -0,0 +1,24 @@ +defmodule Elektrine.SecurityAlerts.Cache do + @moduledoc """ + Cache for rate limiting security alerts. + """ + + @cache_name :security_alerts_cache + + def start_link(_opts) do + Cachex.start_link(@cache_name, + limit: 10_000, + ttl: :timer.hours(1) + ) + end + + def child_spec(opts) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, [opts]}, + type: :worker, + restart: :permanent, + shutdown: 5000 + } + end +end diff --git a/apps/elektrine/lib/elektrine/social/attachment_cleanup_worker.ex b/apps/elektrine/lib/elektrine/social/attachment_cleanup_worker.ex new file mode 100644 index 0000000..4a99e54 --- /dev/null +++ b/apps/elektrine/lib/elektrine/social/attachment_cleanup_worker.ex @@ -0,0 +1,64 @@ +defmodule Elektrine.Social.AttachmentCleanupWorker do + @moduledoc """ + Deletes local upload objects that are no longer referenced by active messages. + """ + + use Oban.Worker, + queue: :default, + max_attempts: 3, + priority: 8, + unique: [ + period: 3_600, + keys: [:message_id], + states: [:available, :scheduled, :executing, :retryable] + ] + + import Ecto.Query + + alias Elektrine.Repo + alias Elektrine.Social.Message + alias Elektrine.Uploads + + def enqueue(message_id, media_urls, opts \\ []) + + def enqueue(message_id, media_urls, opts) when is_integer(message_id) and is_list(media_urls) do + urls = + media_urls + |> Enum.filter(&is_binary/1) + |> Enum.uniq() + + %{"message_id" => message_id, "media_urls" => urls} + |> new(Keyword.put_new(opts, :schedule_in, 60)) + |> Elektrine.JobQueue.insert() + end + + def enqueue(_message_id, _media_urls, _opts), do: {:error, :invalid_cleanup_args} + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"message_id" => message_id, "media_urls" => media_urls}}) + when is_list(media_urls) do + media_urls + |> Enum.filter(&local_upload?/1) + |> Enum.reject(&referenced_by_active_message?(&1, message_id)) + |> Enum.each(&Uploads.delete_uploaded_file/1) + + :ok + end + + defp referenced_by_active_message?(url, deleted_message_id) do + Repo.exists?( + from m in Message, + where: m.id != ^deleted_message_id, + where: is_nil(m.deleted_at), + where: fragment("? && ?", m.media_urls, ^[url]) + ) + end + + defp local_upload?("/uploads/" <> _), do: true + defp local_upload?("chat-attachments/" <> _), do: true + defp local_upload?("timeline-attachments/" <> _), do: true + defp local_upload?("discussion-attachments/" <> _), do: true + defp local_upload?("gallery-attachments/" <> _), do: true + defp local_upload?("voice-messages/" <> _), do: true + defp local_upload?(_), do: false +end diff --git a/apps/elektrine/lib/elektrine/social/conversations.ex b/apps/elektrine/lib/elektrine/social/conversations.ex new file mode 100644 index 0000000..71bc5c9 --- /dev/null +++ b/apps/elektrine/lib/elektrine/social/conversations.ex @@ -0,0 +1,1348 @@ +defmodule Elektrine.Social.Conversations do + @moduledoc "Context for managing conversations - creation, updates, membership, and discovery.\n" + import Ecto.Query, warn: false + alias Elektrine.Accounts + alias Elektrine.AppCache + alias Elektrine.Repo + + alias Elektrine.Messaging.{ + ChatMessage, + ChatUserHiddenMessage, + Federation, + FederationInviteState, + FederationMembershipState, + Membership, + RateLimiter, + UserHiddenMessage + } + + alias Elektrine.Social.{Conversation, ConversationMember, Message} + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor, as: ActivityPubActor + alias Elektrine.Messaging.Federation.DirectMessageState + alias Elektrine.Messaging.Federation.State, as: FederationState + alias Elektrine.Profiles + + alias Elektrine.Accounts.User + @doc "Returns the list of conversations for a user.\n" + def list_conversations(user_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + + query = + from(c in Conversation, + join: cm in ConversationMember, + on: c.id == cm.conversation_id and cm.user_id == ^user_id, + where: is_nil(cm.left_at), + order_by: [desc: cm.pinned, desc: c.last_message_at, desc: c.updated_at], + limit: ^limit, + preload: [members: [user: [:profile]]] + ) + + conversations = Repo.all(query) + + chat_conversation_ids = + conversations + |> Enum.filter(&(&1.type in ["dm", "group", "channel"])) + |> Enum.map(& &1.id) + + non_chat_conversation_ids = + conversations + |> Enum.reject(&(&1.type in ["dm", "group", "channel"])) + |> Enum.map(& &1.id) + + latest_chat_messages = + from(m in ChatMessage, + left_join: h in ChatUserHiddenMessage, + on: h.chat_message_id == m.id and h.user_id == ^user_id, + where: + m.conversation_id in ^chat_conversation_ids and is_nil(m.deleted_at) and is_nil(h.id), + distinct: m.conversation_id, + order_by: [asc: m.conversation_id, desc: m.inserted_at], + preload: [sender: [:profile]] + ) + |> Repo.all() + |> ChatMessage.decrypt_messages() + |> Enum.group_by(& &1.conversation_id) + + latest_non_chat_messages = + from(m in Message, + left_join: h in UserHiddenMessage, + on: h.message_id == m.id and h.user_id == ^user_id, + where: + m.conversation_id in ^non_chat_conversation_ids and is_nil(m.deleted_at) and + is_nil(h.id), + distinct: m.conversation_id, + order_by: [asc: m.conversation_id, desc: m.inserted_at], + preload: [sender: [:profile]] + ) + |> Repo.all() + |> Message.decrypt_messages() + |> Enum.group_by(& &1.conversation_id) + + latest_messages = Map.merge(latest_non_chat_messages, latest_chat_messages) + + Enum.map(conversations, fn conversation -> + messages = Map.get(latest_messages, conversation.id, []) + %{conversation | messages: messages} + end) + |> filter_blocked_conversations(user_id) + end + + defp filter_blocked_conversations(conversations, user_id) do + Enum.filter(conversations, fn conversation -> + case conversation.type do + "dm" -> + other_user = + Enum.find(conversation.members, fn member -> + member.user_id != user_id and is_nil(member.left_at) + end) + + case other_user do + nil -> + true + + %{user_id: other_user_id} -> + not (Elektrine.Accounts.user_blocked?(user_id, other_user_id) or + Elektrine.Accounts.user_blocked?(other_user_id, user_id)) + end + + _ -> + true + end + end) + end + + @doc "Gets a single conversation with members and recent messages.\n\nNote: For chat loading, prefer `get_conversation_for_chat!/2` which is faster\nas it doesn't preload messages (messages are loaded separately with pagination).\n" + def get_conversation!(id, user_id) do + query = + from(c in Conversation, + join: cm in ConversationMember, + on: c.id == cm.conversation_id and cm.user_id == ^user_id, + where: c.id == ^id and is_nil(cm.left_at), + preload: [creator: [], members: [user: [:profile]]] + ) + + case Repo.one(query) do + nil -> + {:error, :not_found} + + conversation -> + {:ok, + %{conversation | messages: load_recent_conversation_messages(conversation, user_id)}} + end + end + + @doc "Gets a conversation for chat display without preloading messages.\n\nThis is a lightweight version of get_conversation!/2 optimized for the chat view\nwhere messages are loaded separately via get_conversation_messages/3.\n\nReturns the conversation with members preloaded, but messages as empty list.\n" + def get_conversation_for_chat!(id, user_id) do + query = + from(c in Conversation, + join: cm in ConversationMember, + on: c.id == cm.conversation_id and cm.user_id == ^user_id, + where: c.id == ^id and is_nil(cm.left_at), + preload: [creator: [], members: [user: [:profile]]] + ) + + case Repo.one(query) do + nil -> {:error, :not_found} + conversation -> {:ok, %{conversation | messages: []}} + end + end + + @doc "Gets a conversation by its hash.\n" + def get_conversation_by_hash(hash) do + from(c in Conversation, where: c.hash == ^hash, preload: [:creator, members: :user]) + |> Repo.one() + end + + @doc """ + Gets a conversation for chat display by hash for a specific user. + + This mirrors `get_conversation_for_chat!/2` behavior while avoiding an extra + lookup when the route already contains the conversation hash. + """ + def get_conversation_for_chat_by_hash!(hash, user_id) do + query = + from(c in Conversation, + join: cm in ConversationMember, + on: c.id == cm.conversation_id and cm.user_id == ^user_id, + where: c.hash == ^hash and is_nil(cm.left_at), + preload: [creator: [], members: [user: [:profile]]] + ) + + case Repo.one(query) do + nil -> {:error, :not_found} + conversation -> {:ok, %{conversation | messages: []}} + end + end + + defp load_recent_conversation_messages(%Conversation{type: type, id: conversation_id}, user_id) + when type in ["dm", "group", "channel"] do + from(m in ChatMessage, + left_join: h in ChatUserHiddenMessage, + on: h.chat_message_id == m.id and h.user_id == ^user_id, + where: m.conversation_id == ^conversation_id and is_nil(m.deleted_at) and is_nil(h.id), + order_by: [desc: m.inserted_at], + limit: 50, + preload: [:sender, :link_preview, reply_to: [:sender], reactions: [:user, :remote_actor]] + ) + |> Repo.all() + |> ChatMessage.decrypt_messages() + end + + defp load_recent_conversation_messages(%Conversation{id: conversation_id}, user_id) do + from(m in Message, + left_join: h in UserHiddenMessage, + on: h.message_id == m.id and h.user_id == ^user_id, + where: m.conversation_id == ^conversation_id and is_nil(m.deleted_at) and is_nil(h.id), + order_by: [desc: m.inserted_at], + limit: 50, + preload: [ + sender: [:profile], + reply_to: [sender: [:profile]], + reactions: [user: []], + link_preview: [], + shared_message: [sender: [:profile], conversation: []] + ] + ) + |> Repo.all() + |> Message.decrypt_messages() + end + + @doc "Gets a conversation by its name (case-insensitive, for communities).\n" + def get_conversation_by_name(name) do + normalized_name = String.downcase(name) + + from(c in Conversation, + where: fragment("LOWER(?)", c.name) == ^normalized_name, + preload: [:creator] + ) + |> Repo.one() + end + + @doc "Gets moderators and admins for a community (for ActivityPub moderators collection).\n" + def get_community_moderators(community_id) do + from(cm in ConversationMember, + where: + cm.conversation_id == ^community_id and cm.role in ["owner", "admin", "moderator"] and + is_nil(cm.left_at), + preload: [:user], + order_by: [ + asc: + fragment( + "CASE WHEN ? = 'owner' THEN 1 WHEN ? = 'admin' THEN 2 ELSE 3 END", + cm.role, + cm.role + ) + ] + ) + |> Repo.all() + end + + @doc "Gets a basic conversation without preloads.\n" + def get_conversation_basic(conversation_id) do + case Repo.get(Conversation, conversation_id) do + nil -> {:error, :not_found} + conversation -> {:ok, conversation} + end + end + + @doc "Creates a direct message conversation between two users.\n" + def create_dm_conversation(user1_id, user2_id) do + if RateLimiter.can_create_dm?(user1_id) do + case Elektrine.Privacy.can_send_dm?(user1_id, user2_id) do + {:error, reason} -> + {:error, reason} + + {:ok, :allowed} -> + existing_dm = + from(c in Conversation, + join: cm1 in ConversationMember, + on: c.id == cm1.conversation_id, + join: cm2 in ConversationMember, + on: c.id == cm2.conversation_id, + where: + c.type == "dm" and cm1.user_id == ^user1_id and is_nil(cm1.left_at) and + cm2.user_id == ^user2_id and is_nil(cm2.left_at), + limit: 1 + ) + + case Repo.one(existing_dm) do + %Conversation{} = conversation -> + {:ok, conversation} + + nil -> + RateLimiter.record_dm_creation(user1_id) + + Repo.transaction(fn -> + {:ok, conversation} = + %Conversation{} + |> Conversation.dm_changeset(%{creator_id: user1_id}) + |> Repo.insert() + + {:ok, _} = add_member_to_conversation(conversation.id, user1_id) + {:ok, _} = add_member_to_conversation(conversation.id, user2_id) + conversation + end) + end + end + else + {:error, :rate_limited} + end + end + + @doc "Creates or gets a cross-instance DM conversation for a remote Arblarg handle.\n" + def create_remote_dm_conversation(local_user_id, remote_handle, attrs \\ %{}) do + with :ok <- ensure_dm_creation_allowed(local_user_id), + {:ok, recipient} <- normalize_remote_dm_handle(remote_handle), + :ok <- ensure_remote_recipient_domain(local_user_id, recipient), + %{} <- Federation.outgoing_peer(recipient.domain) do + remote_source = DirectMessageState.remote_dm_source(recipient.handle) + display_name = remote_dm_display_name(attrs, recipient) + avatar_url = remote_dm_avatar_url(attrs) + + existing_remote_dm = + from(c in Conversation, + join: cm in ConversationMember, + on: c.id == cm.conversation_id, + where: + c.type == "dm" and c.federated_source == ^remote_source and + cm.user_id == ^local_user_id and is_nil(cm.left_at), + limit: 1 + ) + + case Repo.one(existing_remote_dm) do + %Conversation{} = conversation -> + {:ok, conversation} + + nil -> + RateLimiter.record_dm_creation(local_user_id) + + Repo.transaction(fn -> + {:ok, conversation} = + %Conversation{} + |> Conversation.dm_changeset(%{ + creator_id: local_user_id, + name: display_name, + avatar_url: avatar_url, + federated_source: remote_source + }) + |> Repo.insert() + + {:ok, _} = add_member_to_conversation(conversation.id, local_user_id) + update_member_count(conversation.id) + conversation + end) + end + else + {:redirect_local_dm, remote_user_id} -> + create_dm_conversation(local_user_id, remote_user_id) + + nil -> + {:error, :unknown_peer} + + {:error, _} = error -> + error + + false -> + {:error, :rate_limited} + end + end + + @doc "Returns true when a conversation is a remote federated DM.\n" + def remote_dm_conversation?(%Conversation{type: "dm", federated_source: source}) + when is_binary(source) do + is_binary(DirectMessageState.remote_dm_handle_from_source(source)) + end + + def remote_dm_conversation?(_), do: false + + @doc "Returns normalized remote DM handle for federated DM conversations.\n" + def remote_dm_handle(%Conversation{} = conversation) do + if remote_dm_conversation?(conversation) do + DirectMessageState.remote_dm_handle_from_source(conversation.federated_source) + else + nil + end + end + + def remote_dm_handle(_), do: nil + + @doc "Checks if user can create more conversations of the given type.\nReturns :ok if allowed, {:error, :limit_exceeded} otherwise.\n" + def check_creation_limit(user_id, type) do + max_channels = 10 + max_groups = 20 + + case type do + "channel" -> + count = + from(c in Conversation, + where: c.creator_id == ^user_id and c.type == "channel", + select: count(c.id) + ) + |> Repo.one() + + if count < max_channels do + :ok + else + {:error, :limit_exceeded} + end + + "group" -> + count = + from(c in Conversation, + where: c.creator_id == ^user_id and c.type == "group", + select: count(c.id) + ) + |> Repo.one() + + if count < max_groups do + :ok + else + {:error, :limit_exceeded} + end + + _ -> + :ok + end + end + + @doc "Creates a group conversation.\n" + def create_group_conversation(creator_id, attrs, member_ids \\ []) do + case check_creation_limit(creator_id, "group") do + :ok -> + attrs = Map.put(attrs, :creator_id, creator_id) + + Repo.transaction(fn -> + {:ok, conversation} = + %Conversation{} |> Conversation.group_changeset(attrs) |> Repo.insert() + + {:ok, _} = add_member_to_conversation(conversation.id, creator_id, "admin") + + {successful_adds, failed_adds} = + Enum.reduce(member_ids, {[], []}, fn user_id, {success, failed} -> + case add_member_to_conversation(conversation.id, user_id, "member", creator_id) do + {:ok, _} -> {[user_id | success], failed} + {:error, _reason} -> {success, [user_id | failed]} + end + end) + + update_member_count(conversation.id) + all_member_ids = [creator_id | successful_adds] + + Enum.each(all_member_ids, fn user_id -> + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}", + {:added_to_conversation, %{conversation_id: conversation.id}} + ) + end) + + {conversation, length(failed_adds)} + end) + |> case do + {:ok, {conversation, 0}} -> {:ok, conversation} + {:ok, {conversation, failed_count}} -> {:ok, conversation, failed_count} + error -> error + end + + {:error, :limit_exceeded} -> + {:error, :group_limit_exceeded} + end + end + + @doc "Creates a channel.\n" + def create_channel(creator_id, attrs) do + case check_creation_limit(creator_id, "channel") do + :ok -> + attrs = Map.put(attrs, :creator_id, creator_id) + + Repo.transaction(fn -> + {:ok, conversation} = + %Conversation{} |> Conversation.channel_changeset(attrs) |> Repo.insert() + + {:ok, _} = add_member_to_conversation(conversation.id, creator_id, "admin") + update_member_count(conversation.id) + conversation + end) + + {:error, :limit_exceeded} -> + {:error, :channel_limit_exceeded} + end + end + + @doc "Updates a conversation (name, description, etc.).\n" + def update_conversation(conversation, attrs) do + conversation |> Conversation.changeset(attrs) |> Repo.update() + end + + @doc "Deletes a conversation (admin/creator only).\n" + def delete_conversation(conversation_id) do + case Repo.get(Conversation, conversation_id) do + nil -> {:error, :not_found} + conversation -> Repo.delete(conversation) + end + end + + @doc "Lists public channels.\n" + def list_public_channels(opts \\ []) do + limit = Keyword.get(opts, :limit, 20) + + from(c in Conversation, + where: c.type == "channel" and c.is_public == true and is_nil(c.server_id), + order_by: [desc: c.member_count, desc: c.last_message_at], + limit: ^limit, + preload: [:creator] + ) + |> Repo.all() + end + + @doc "Lists public groups.\n" + def list_public_groups(opts \\ []) do + limit = Keyword.get(opts, :limit, 20) + + from(c in Conversation, + where: + c.type == "group" and c.is_public == true and c.type not in ["timeline", "community"], + order_by: [desc: c.member_count, desc: c.last_message_at], + limit: ^limit, + preload: [:creator] + ) + |> Repo.all() + end + + @doc "Searches for public groups and channels that the user can join.\n" + def search_public_conversations(query, current_user_id, limit \\ 10) do + search_term = "%#{query}%" + + user_conversation_ids = + from(cm in ConversationMember, + where: cm.user_id == ^current_user_id and is_nil(cm.left_at), + select: cm.conversation_id + ) + |> Repo.all() + + from(c in Conversation, + where: + c.is_public == true and c.type in ["group", "channel"] and is_nil(c.server_id) and + c.id not in ^user_conversation_ids and + (ilike(c.name, ^search_term) or ilike(c.description, ^search_term)), + order_by: [desc: c.member_count, desc: c.last_message_at], + limit: ^limit, + preload: [:creator] + ) + |> Repo.all() + end + + @doc "Adds a member to a conversation.\n" + def add_member_to_conversation( + conversation_id, + user_id, + role \\ "member", + added_by_user_id \\ nil + ) do + Membership.add_member( + ConversationMember, + Conversation, + conversation_id, + user_id, + role, + added_by_user_id + ) + end + + def add_member_to_conversation_without_federation(conversation_id, user_id, role \\ "member") do + Membership.add_member_without_federation( + ConversationMember, + Conversation, + conversation_id, + user_id, + role + ) + end + + @doc "Removes a member from a conversation.\n" + def remove_member_from_conversation(conversation_id, user_id) do + remove_member_from_conversation(conversation_id, user_id, nil) + end + + def remove_member_from_conversation(conversation_id, user_id, actor_user_id) do + Membership.remove_member( + ConversationMember, + Conversation, + conversation_id, + user_id, + actor_user_id + ) + end + + @doc "Gets a conversation member record.\n" + def get_conversation_member(conversation_id, user_id), + do: Membership.get_member(ConversationMember, conversation_id, user_id) + + @doc "Gets all members of a conversation.\n" + def get_conversation_members(conversation_id), + do: Membership.get_members(ConversationMember, conversation_id) + + @doc "Lists pending remote join requests for a locally authoritative channel.\n" + def list_pending_remote_join_requests(conversation_id) when is_integer(conversation_id) do + case remote_join_review_conversation(conversation_id) do + {:ok, _conversation} -> + local_domain = Federation.local_domain() + + from(state in FederationMembershipState, + where: state.conversation_id == ^conversation_id and state.state == "invited", + where: state.origin_domain != ^local_domain, + preload: [:remote_actor], + order_by: [asc: state.inserted_at] + ) + |> Repo.all() + |> Enum.filter(&pending_remote_join_request?/1) + |> Enum.map(&format_pending_remote_join_request/1) + + {:error, _reason} -> + [] + end + end + + def list_pending_remote_join_requests(_conversation_id), do: [] + + @doc "Approves a pending remote join request for a locally authoritative channel.\n" + def approve_remote_join_request(conversation_id, remote_actor_id, reviewer_user_id) + when is_integer(conversation_id) and is_integer(remote_actor_id) and + is_integer(reviewer_user_id) do + review_remote_join_request(conversation_id, remote_actor_id, reviewer_user_id, "accepted") + end + + def approve_remote_join_request(_conversation_id, _remote_actor_id, _reviewer_user_id), + do: {:error, :not_found} + + @doc "Declines a pending remote join request for a locally authoritative channel.\n" + def decline_remote_join_request(conversation_id, remote_actor_id, reviewer_user_id) + when is_integer(conversation_id) and is_integer(remote_actor_id) and + is_integer(reviewer_user_id) do + review_remote_join_request(conversation_id, remote_actor_id, reviewer_user_id, "declined") + end + + def decline_remote_join_request(_conversation_id, _remote_actor_id, _reviewer_user_id), + do: {:error, :not_found} + + @doc "Promotes a member to admin role.\n" + def promote_to_admin(conversation_id, user_id, promoter_id) do + Membership.promote_to_admin( + ConversationMember, + Conversation, + conversation_id, + user_id, + promoter_id, + &admin?/2, + &builtin_room_role_definition/1 + ) + end + + @doc "Demotes an admin to regular member.\n" + def demote_from_admin(conversation_id, user_id, demoter_id) do + Membership.demote_from_admin( + ConversationMember, + Conversation, + conversation_id, + user_id, + demoter_id, + &admin?/2, + &builtin_room_role_definition/1 + ) + end + + @doc "Updates a member's role in a conversation.\n" + def update_member_role(conversation_id, user_id, new_role) do + update_member_role(conversation_id, user_id, new_role, nil) + end + + def update_member_role(conversation_id, user_id, new_role, actor_user_id) do + Membership.update_member_role( + ConversationMember, + Conversation, + conversation_id, + user_id, + new_role, + actor_user_id, + &builtin_room_role_definition/1 + ) + end + + @doc "Promotes a user to moderator. Pass `actor_user_id` to enforce owner/admin authz.\n" + def promote_to_moderator(conversation_id, user_id, actor_user_id \\ nil) do + update_member_role(conversation_id, user_id, "moderator", actor_user_id) + end + + @doc "Demotes a moderator to member. Pass `actor_user_id` to enforce owner/admin authz.\n" + def demote_from_moderator(conversation_id, user_id, actor_user_id \\ nil) do + update_member_role(conversation_id, user_id, "member", actor_user_id) + end + + defp builtin_room_role_definition("owner") do + %{ + "id" => "builtin:owner", + "name" => "Owner", + "permissions" => [ + "manage_roles", + "manage_permissions", + "manage_moderation", + "invite_members" + ], + "position" => 100 + } + end + + defp builtin_room_role_definition("admin") do + %{ + "id" => "builtin:admin", + "name" => "Admin", + "permissions" => [ + "manage_roles", + "manage_permissions", + "manage_moderation", + "invite_members" + ], + "position" => 80 + } + end + + defp builtin_room_role_definition("moderator") do + %{ + "id" => "builtin:moderator", + "name" => "Moderator", + "permissions" => ["manage_moderation", "invite_members"], + "position" => 60 + } + end + + defp builtin_room_role_definition("member") do + %{ + "id" => "builtin:member", + "name" => "Member", + "permissions" => ["send_messages"], + "position" => 10 + } + end + + defp builtin_room_role_definition("readonly") do + %{ + "id" => "builtin:readonly", + "name" => "Readonly", + "permissions" => ["read_messages"], + "position" => 0 + } + end + + defp builtin_room_role_definition(_role), do: nil + + @doc "Joins a public conversation (channel or group).\n" + def join_conversation(conversation_id, user_id) do + case get_conversation_basic(conversation_id) do + {:error, _} = error -> + error + + {:ok, conversation} -> + cond do + remote_mirror_channel_join?(conversation) -> + request_mirrored_channel_join(conversation, user_id) + + conversation.type == "channel" and not is_nil(conversation.server_id) -> + {:error, :must_join_server} + + conversation.type not in ["channel", "group", "community"] -> + {:error, :not_joinable} + + conversation.is_public != true -> + {:error, :not_public_channel} + + true -> + existing_member = + from(cm in ConversationMember, + where: cm.conversation_id == ^conversation_id and cm.user_id == ^user_id + ) + |> Repo.one() + + case existing_member do + nil -> + role = + if conversation.type == "channel" do + "readonly" + else + "member" + end + + result = add_member_to_conversation(conversation_id, user_id, role) + + case result do + {:ok, _} -> + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{conversation_id}", + {:member_joined, user_id} + ) + + _ -> + :ok + end + + result + + %ConversationMember{left_at: nil} -> + {:error, :already_member} + + %ConversationMember{left_at: _left_at} -> + result = + existing_member + |> ConversationMember.changeset(%{left_at: nil, joined_at: DateTime.utc_now()}) + |> Repo.update() + + update_member_count(conversation_id) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{conversation_id}", + {:member_joined, user_id} + ) + + result + end + end + end + end + + @doc "Joins a public channel.\n" + def join_channel(channel_id, user_id) do + Membership.join_channel( + ConversationMember, + Conversation, + channel_id, + user_id, + &add_member_to_conversation(&1, &2, &3) + ) + end + + @doc "Pins a conversation for a user.\n" + def pin_conversation(conversation_id, user_id), + do: Membership.pin(ConversationMember, conversation_id, user_id) + + @doc "Unpins a conversation for a user.\n" + def unpin_conversation(conversation_id, user_id), + do: Membership.unpin(ConversationMember, conversation_id, user_id) + + @doc "Allows a user to leave a conversation.\nSets the left_at timestamp on their membership record.\n" + def leave_conversation(conversation_id, user_id) do + case get_conversation_member(conversation_id, user_id) do + nil -> + {:error, :not_a_member} + + member -> + conversation = Repo.get(Conversation, conversation_id) + + if conversation && conversation.creator_id == user_id && + conversation.type in ["group", "channel"] do + other_members = + from(cm in ConversationMember, + where: + cm.conversation_id == ^conversation_id and cm.user_id != ^user_id and + is_nil(cm.left_at) + ) + |> Repo.all() + + if other_members != [] do + {:error, :owner_must_transfer} + else + result = member |> ConversationMember.remove_member_changeset() |> Repo.update() + + case result do + {:ok, _} -> + update_member_count(conversation_id) + _ = Federation.publish_membership_state(conversation_id, user_id, "left") + result + + error -> + error + end + end + else + result = member |> ConversationMember.remove_member_changeset() |> Repo.update() + + case result do + {:ok, _} -> + update_member_count(conversation_id) + _ = Federation.publish_membership_state(conversation_id, user_id, "left") + result + + error -> + error + end + end + end + end + + @doc "Checks if a user is the owner of a community.\n" + def community_owner?(conversation_id, user_id) do + conversation = Repo.get(Conversation, conversation_id) + conversation && conversation.creator_id == user_id + end + + defp remote_mirror_channel_join?(%Conversation{ + type: "channel", + is_federated_mirror: true, + server_id: server_id + }) + when is_integer(server_id), + do: true + + defp remote_mirror_channel_join?(_conversation), do: false + + defp request_mirrored_channel_join(%Conversation{} = conversation, user_id) + when is_integer(user_id) do + case get_conversation_member(conversation.id, user_id) do + %ConversationMember{} -> + {:error, :already_member} + + nil -> + role = "member" + + with false <- local_join_request_pending?(conversation.id, user_id), + %User{} = user <- Repo.get(User, user_id), + :ok <- + FederationState.persist_local_invite_projection( + conversation.id, + user.id, + user.id, + "pending", + role, + %{"source" => "remote_join_request"}, + %{local_domain: &Federation.local_domain/0} + ) do + _ = Federation.publish_membership_state(conversation.id, user.id, "invited", role) + {:ok, :pending} + else + true -> + {:ok, :pending} + + nil -> + {:error, :not_found} + + {:error, reason} -> + {:error, reason} + + _ -> + {:error, :invalid_event_payload} + end + end + end + + defp request_mirrored_channel_join(_conversation, _user_id), + do: {:error, :invalid_event_payload} + + defp review_remote_join_request(conversation_id, remote_actor_id, reviewer_user_id, decision) + when decision in ["accepted", "declined"] do + with {:ok, conversation} <- remote_join_review_conversation(conversation_id), + %User{} <- Repo.get(User, reviewer_user_id), + %FederationMembershipState{} = membership_state <- + pending_remote_join_membership_state(conversation_id, remote_actor_id), + %ActivityPubActor{} = remote_actor <- get_remote_actor(remote_actor_id) do + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + membership_state_value = if decision == "accepted", do: "active", else: "left" + + updated_metadata = + membership_state.metadata + |> normalize_remote_join_metadata() + |> Map.merge(%{ + "join_request" => false, + "join_decision" => decision, + "reviewed_by_user_id" => reviewer_user_id, + "reviewed_at" => DateTime.to_iso8601(timestamp) + }) + + updated_joined_at = + if decision == "accepted" do + membership_state.joined_at_remote || timestamp + else + membership_state.joined_at_remote + end + + case membership_state + |> FederationMembershipState.changeset(%{ + state: membership_state_value, + joined_at_remote: updated_joined_at, + updated_at_remote: timestamp, + metadata: updated_metadata + }) + |> Repo.update() do + {:ok, updated_membership_state} -> + _ = broadcast_remote_join_request_update(conversation.id, updated_membership_state) + + _ = + Federation.publish_remote_invite_state( + conversation.id, + remote_join_actor_payload(remote_actor), + reviewer_user_id, + decision, + membership_state.role || "member", + outbound_remote_join_decision_metadata(updated_metadata) + ) + + {:ok, format_pending_remote_join_request(updated_membership_state)} + + {:error, _changeset} -> + {:error, :update_failed} + end + else + {:error, reason} -> {:error, reason} + nil -> {:error, :not_found} + false -> {:error, :not_found} + _ -> {:error, :not_found} + end + end + + defp pending_remote_join_membership_state(conversation_id, remote_actor_id) + when is_integer(conversation_id) and is_integer(remote_actor_id) do + from(state in FederationMembershipState, + where: + state.conversation_id == ^conversation_id and state.remote_actor_id == ^remote_actor_id and + state.state == "invited", + preload: [:remote_actor] + ) + |> Repo.one() + |> case do + %FederationMembershipState{} = membership_state -> + if pending_remote_join_request?(membership_state), do: membership_state, else: nil + + nil -> + nil + end + end + + defp pending_remote_join_membership_state(_conversation_id, _remote_actor_id), do: nil + + defp remote_join_review_conversation(conversation_id) when is_integer(conversation_id) do + case Repo.get(Conversation, conversation_id) do + %Conversation{ + type: "channel", + is_federated_mirror: false, + server_id: server_id + } = conversation + when is_integer(server_id) -> + {:ok, conversation} + + _ -> + {:error, :not_found} + end + end + + defp remote_join_review_conversation(_conversation_id), do: {:error, :not_found} + + defp pending_remote_join_request?(%FederationMembershipState{metadata: metadata}) + when is_map(metadata) do + metadata["join_request"] in [true, "true"] + end + + defp pending_remote_join_request?(_membership_state), do: false + + defp format_pending_remote_join_request(%FederationMembershipState{} = membership_state) do + actor = membership_state.remote_actor + + handle = + case actor do + %ActivityPubActor{username: username, domain: domain} + when is_binary(username) and is_binary(domain) -> + "@#{username}@#{domain}" + + _ -> + nil + end + + display_name = + case actor do + %ActivityPubActor{display_name: display_name} when is_binary(display_name) -> + if Elektrine.Strings.present?(display_name), do: display_name, else: nil + + %ActivityPubActor{username: username} when is_binary(username) -> + if Elektrine.Strings.present?(username), do: username, else: nil + + _ -> + handle || "remote user" + end + |> case do + nil -> handle || "remote user" + value -> value + end + + %{ + remote_actor_id: membership_state.remote_actor_id, + actor_uri: actor && actor.uri, + origin_domain: membership_state.origin_domain, + role: membership_state.role, + state: membership_state.state, + handle: handle, + display_name: display_name, + display_label: remote_join_display_label(display_name, handle), + avatar_url: actor && actor.avatar_url, + requested_at: membership_state.inserted_at, + updated_at: membership_state.updated_at_remote || membership_state.updated_at, + metadata: membership_state.metadata || %{} + } + end + + defp format_pending_remote_join_request(_membership_state), do: %{} + + defp remote_join_display_label(display_name, handle) + when is_binary(display_name) and is_binary(handle) do + if Elektrine.Strings.present?(display_name) and display_name != handle do + "#{display_name} (#{handle})" + else + handle + end + end + + defp remote_join_display_label(display_name, _handle) when is_binary(display_name), + do: display_name + + defp normalize_remote_join_metadata(metadata) when is_map(metadata), do: metadata + + defp outbound_remote_join_decision_metadata(metadata) when is_map(metadata) do + metadata + |> Map.drop(["reviewed_by_user_id", "reviewed_at"]) + |> Map.put("source", "moderator_review") + end + + defp outbound_remote_join_decision_metadata(_metadata), do: %{"source" => "moderator_review"} + + defp remote_join_actor_payload(%ActivityPubActor{} = actor) do + %{ + "uri" => actor.uri, + "username" => actor.username, + "domain" => actor.domain, + "handle" => "#{actor.username}@#{actor.domain}", + "display_name" => actor.display_name || actor.username + } + |> maybe_put_actor_avatar(actor.avatar_url) + end + + defp maybe_put_actor_avatar(payload, avatar_url) when is_binary(avatar_url) do + if Elektrine.Strings.present?(avatar_url), + do: Map.put(payload, "avatar_url", avatar_url), + else: payload + end + + defp maybe_put_actor_avatar(payload, _avatar_url), do: payload + + defp get_remote_actor(actor_id) when is_integer(actor_id) do + AppCache.get_activitypub_actor_by_id(actor_id, fn -> + Repo.get(ActivityPubActor, actor_id) + end) + end + + defp get_remote_actor(_actor_id), do: nil + + defp broadcast_remote_join_request_update( + conversation_id, + %FederationMembershipState{} = membership_state + ) + when is_integer(conversation_id) do + case get_remote_actor(membership_state.remote_actor_id) do + %ActivityPubActor{} = actor -> + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{conversation_id}", + {:federation_membership_update, + %{ + conversation_id: conversation_id, + remote_actor_id: membership_state.remote_actor_id, + handle: "@#{actor.username}@#{actor.domain}", + role: membership_state.role, + state: membership_state.state, + joined_at: membership_state.joined_at_remote, + updated_at: membership_state.updated_at_remote, + avatar_url: actor.avatar_url + }} + ) + + :ok + + _ -> + :ok + end + end + + defp broadcast_remote_join_request_update(_conversation_id, _membership_state), do: :ok + + defp local_join_request_pending?(conversation_id, user_id) + when is_integer(conversation_id) and is_integer(user_id) do + case Repo.get(User, user_id) do + %User{} = user -> + target_uri = local_actor_uri(user) + + Repo.exists?( + from(invite in FederationInviteState, + where: + invite.conversation_id == ^conversation_id and + invite.target_uri == ^target_uri and + invite.state == "pending" + ) + ) + + _ -> + false + end + end + + defp local_join_request_pending?(_conversation_id, _user_id), do: false + + defp local_actor_uri(%User{username: username}) when is_binary(username) do + "#{ActivityPub.instance_url()}/users/#{username}" + end + + @doc "Checks if a user has any community memberships.\nFast check for loading skeleton optimization.\n" + def user_has_communities?(user_id) do + from(cm in ConversationMember, + join: c in Conversation, + on: c.id == cm.conversation_id, + where: cm.user_id == ^user_id and c.type == "community" and is_nil(cm.left_at), + limit: 1, + select: 1 + ) + |> Repo.exists?() + end + + @doc "Checks if there are any communities in the system.\nFast check for loading skeleton optimization.\n" + def has_any_communities? do + from(c in Conversation, where: c.type == "community", limit: 1, select: 1) |> Repo.exists?() + end + + @doc "Checks if a user has any chat conversations (excludes timeline/community).\nFast check for loading skeleton optimization.\n" + def user_has_conversations?(user_id) do + from(cm in ConversationMember, + join: c in Conversation, + on: c.id == cm.conversation_id, + where: + cm.user_id == ^user_id and is_nil(cm.left_at) and c.type not in ["timeline", "community"], + limit: 1, + select: 1 + ) + |> Repo.exists?() + end + + @doc "Checks if a user is an admin of a conversation.\n" + def admin?(conversation_id, user_id) do + member = + from(cm in ConversationMember, + where: + cm.conversation_id == ^conversation_id and cm.user_id == ^user_id and is_nil(cm.left_at) + ) + |> Repo.one() + + case member do + %ConversationMember{role: "admin"} -> true + _ -> false + end + end + + defp update_member_count(conversation_id) do + Membership.update_member_count( + ConversationMember, + Conversation, + conversation_id, + Message, + &archive_empty_community/1 + ) + end + + # Social-specific empty-conversation handling: a community with no members is + # archived (not deleted) rather than torn down like a group/channel. + defp archive_empty_community(%Conversation{id: conversation_id, type: "community"}) do + from(c in Conversation, where: c.id == ^conversation_id) + |> Repo.update_all(set: [archived: true, member_count: 0]) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversations:all", + {:conversation_archived, conversation_id} + ) + + {:archived, 0} + end + + defp archive_empty_community(_conversation), do: nil + + defp normalize_remote_dm_handle(handle) when is_binary(handle) do + normalized = + handle + |> String.trim() + |> String.trim_leading("@") + |> String.downcase() + + case Regex.run(~r/^([a-z0-9_]{1,64})@([a-z0-9.-]+\.[a-z]{2,})$/, normalized) do + [_, username, domain] -> + {:ok, %{username: username, domain: domain, handle: "#{username}@#{domain}"}} + + _ -> + {:error, :invalid_remote_handle} + end + end + + defp normalize_remote_dm_handle(_), do: {:error, :invalid_remote_handle} + + defp ensure_dm_creation_allowed(user_id) do + if RateLimiter.can_create_dm?(user_id), do: :ok, else: {:error, :rate_limited} + end + + defp ensure_remote_recipient_domain(_local_user_id, recipient) do + case local_recipient_user(recipient) do + %User{} = user -> {:redirect_local_dm, user.id} + :local_domain -> {:error, :user_not_found} + :local_custom_domain -> {:error, :user_not_found} + _ -> :ok + end + end + + defp local_domain?(domain) when is_binary(domain) do + String.downcase(domain) == Federation.local_domain() + end + + defp local_domain?(_), do: false + + defp local_recipient_user(%{domain: domain, username: username}) + when is_binary(domain) and is_binary(username) do + normalized_domain = String.downcase(domain) + + if local_domain?(normalized_domain) do + Accounts.get_user_by_username(username) || :local_domain + else + case Profiles.get_verified_custom_domain(normalized_domain) do + %{user: %{username: ^username} = user} -> user + %{domain: ^normalized_domain} -> :local_custom_domain + _ -> nil + end + end + end + + defp local_recipient_user(_recipient), do: nil + + defp remote_dm_display_name(attrs, recipient) when is_map(attrs) do + display_name = + attrs[:display_name] || + attrs["display_name"] || + attrs[:name] || + attrs["name"] + + if Elektrine.Strings.present?(display_name) do + String.trim(display_name) + else + "@" <> recipient.handle + end + end + + defp remote_dm_display_name(_attrs, recipient), do: "@" <> recipient.handle + + defp remote_dm_avatar_url(attrs) when is_map(attrs) do + attrs[:avatar_url] || attrs["avatar_url"] + end + + defp remote_dm_avatar_url(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/social/engagement_count_repair.ex b/apps/elektrine/lib/elektrine/social/engagement_count_repair.ex new file mode 100644 index 0000000..6dfb1ac --- /dev/null +++ b/apps/elektrine/lib/elektrine/social/engagement_count_repair.ex @@ -0,0 +1,181 @@ +defmodule Elektrine.Social.EngagementCountRepair do + @moduledoc """ + Repairs cached social engagement counters from durable local rows and remote baselines. + """ + + import Ecto.Query + + alias Elektrine.Repo + alias Elektrine.Social.{EngagementCounts, Message, PostBoost, PostLike} + + @type result :: %{seen: non_neg_integer(), changed: non_neg_integer()} + + @doc """ + Repairs social message counters in keyset batches. + + Options: + + * `:dry_run` - scan and report changes without writing + * `:limit` - maximum messages to inspect + * `:batch_size` - number of messages per query, defaults to 500 + * `:progress_fun` - optional function called with the running result + """ + @spec run(keyword()) :: result() + def run(opts \\ []) do + dry_run? = Keyword.get(opts, :dry_run, false) + limit = Keyword.get(opts, :limit) + batch_size = Keyword.get(opts, :batch_size, 500) + progress_fun = Keyword.get(opts, :progress_fun) + + repair_batches(%{seen: 0, changed: 0, last_id: 0}, limit, batch_size, dry_run?, progress_fun) + |> Map.delete(:last_id) + end + + defp repair_batches(acc, limit, batch_size, dry_run?, progress_fun) do + remaining = + case limit do + nil -> batch_size + limit -> min(batch_size, max(limit - acc.seen, 0)) + end + + if remaining <= 0 do + acc + else + batch = + from(m in Message, + where: m.id > ^acc.last_id, + order_by: [asc: m.id], + limit: ^remaining + ) + |> Repo.all() + + case batch do + [] -> + acc + + messages -> + next_acc = + Enum.reduce(messages, acc, fn message, acc -> + changed? = repair_message(message, dry_run?) + + %{ + acc + | seen: acc.seen + 1, + changed: acc.changed + if(changed?, do: 1, else: 0), + last_id: message.id + } + end) + + if is_function(progress_fun, 1) and rem(next_acc.seen, batch_size * 10) == 0 do + progress_fun.(Map.delete(next_acc, :last_id)) + end + + repair_batches(next_acc, limit, batch_size, dry_run?, progress_fun) + end + end + end + + defp repair_message(%Message{} = message, dry_run?) do + remote_like_count = remote_count(message, :remote_like_count, "original_like_count") + remote_reply_count = remote_count(message, :remote_reply_count, "original_reply_count") + remote_share_count = remote_count(message, :remote_share_count, "original_share_count") + remote_quote_count = remote_count(message, :remote_quote_count, "quotes_count") + + local_like_count = local_like_count(message) + local_reply_count = local_reply_count(message) + local_share_count = local_share_count(message) + + attrs = %{ + remote_like_count: nullable_remote_count(remote_like_count), + remote_reply_count: nullable_remote_count(remote_reply_count), + remote_share_count: nullable_remote_count(remote_share_count), + remote_quote_count: nullable_remote_count(remote_quote_count), + like_count: known_count(message.like_count, remote_like_count, local_like_count), + reply_count: known_count(message.reply_count, remote_reply_count, local_reply_count), + share_count: known_count(message.share_count, remote_share_count, local_share_count), + quote_count: max(message.quote_count || 0, remote_quote_count) + } + + attrs = + attrs + |> Enum.reject(fn {field, value} -> Map.get(message, field) == value end) + |> Map.new() + + if map_size(attrs) > 0 do + unless dry_run? do + from(m in Message, where: m.id == ^message.id) + |> Repo.update_all( + set: Map.put(attrs, :updated_at, DateTime.utc_now() |> DateTime.truncate(:second)) + ) + end + + true + else + false + end + end + + defp remote_count(message, field, metadata_key) do + max( + non_negative_integer(Map.get(message, field)), + non_negative_integer(Map.get(message.media_metadata || %{}, metadata_key)) + ) + end + + defp nullable_remote_count(count), do: EngagementCounts.nullable_remote_count(count) + + defp known_count(current_count, remote_count, local_count) do + Enum.max([current_count || 0, remote_count + local_count, local_count, remote_count]) + end + + defp local_like_count(%Message{id: message_id, remote_counts_fetched_at: nil}) do + from(l in PostLike, where: l.message_id == ^message_id, select: count(l.id)) + |> Repo.one() + end + + defp local_like_count(%Message{id: message_id, remote_counts_fetched_at: fetched_at}) do + from(l in PostLike, + where: l.message_id == ^message_id and (is_nil(l.created_at) or l.created_at > ^fetched_at), + select: count(l.id) + ) + |> Repo.one() + end + + defp local_reply_count(%Message{id: message_id, remote_counts_fetched_at: nil}) do + from(m in Message, + where: m.reply_to_id == ^message_id and is_nil(m.deleted_at), + select: count(m.id) + ) + |> Repo.one() + end + + defp local_reply_count(%Message{id: message_id, remote_counts_fetched_at: fetched_at}) do + fetched_at = DateTime.to_naive(fetched_at) + + from(m in Message, + where: + m.reply_to_id == ^message_id and is_nil(m.deleted_at) and + (is_nil(m.inserted_at) or m.inserted_at > ^fetched_at), + select: count(m.id) + ) + |> Repo.one() + end + + defp local_share_count(%Message{id: message_id, remote_counts_fetched_at: nil}) do + from(b in PostBoost, where: b.message_id == ^message_id, select: count(b.id)) + |> Repo.one() + end + + defp local_share_count(%Message{id: message_id, remote_counts_fetched_at: fetched_at}) do + fetched_at = DateTime.to_naive(fetched_at) + + from(b in PostBoost, + where: + b.message_id == ^message_id and (is_nil(b.inserted_at) or b.inserted_at > ^fetched_at), + select: count(b.id) + ) + |> Repo.one() + end + + defp non_negative_integer(value), do: EngagementCounts.remote_count(value) +end diff --git a/apps/elektrine/lib/elektrine/social/engagement_count_repair_worker.ex b/apps/elektrine/lib/elektrine/social/engagement_count_repair_worker.ex new file mode 100644 index 0000000..40b3b2c --- /dev/null +++ b/apps/elektrine/lib/elektrine/social/engagement_count_repair_worker.ex @@ -0,0 +1,30 @@ +defmodule Elektrine.Social.EngagementCountRepairWorker do + @moduledoc """ + Periodically reconciles cached social engagement counts with remote baselines. + """ + + use Oban.Worker, + queue: :default, + max_attempts: 1, + unique: [ + period: 3_600, + keys: [:limit, :batch_size], + states: [:available, :scheduled, :executing, :retryable] + ] + + require Logger + + alias Elektrine.Social.EngagementCountRepair + + @impl Oban.Worker + def perform(%Oban.Job{args: args}) do + limit = Map.get(args, "limit", 50_000) + batch_size = Map.get(args, "batch_size", 500) + + result = EngagementCountRepair.run(limit: limit, batch_size: batch_size) + + Logger.info("EngagementCountRepairWorker repaired counters #{inspect(result)}") + + :ok + end +end diff --git a/apps/elektrine/lib/elektrine/social/engagement_counts.ex b/apps/elektrine/lib/elektrine/social/engagement_counts.ex new file mode 100644 index 0000000..ece86e9 --- /dev/null +++ b/apps/elektrine/lib/elektrine/social/engagement_counts.ex @@ -0,0 +1,41 @@ +defmodule Elektrine.Social.EngagementCounts do + @moduledoc """ + Normalization helpers for local and remote engagement counters. + + Remote servers can report bogus or stale counters. Store their values separately, + clamp them, and only merge them with locally durable interaction rows through + explicit reconciliation code. + """ + + @max_remote_count 100_000_000 + + @remote_fields [ + :remote_like_count, + :remote_reply_count, + :remote_share_count, + :remote_quote_count + ] + + def remote_fields, do: @remote_fields + def max_remote_count, do: @max_remote_count + + def remote_count(value), do: non_negative_integer(value) |> min(@max_remote_count) + + def nullable_remote_count(value) do + case remote_count(value) do + 0 -> nil + count -> count + end + end + + def non_negative_integer(value) when is_integer(value), do: max(value, 0) + + def non_negative_integer(value) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {count, _} -> max(count, 0) + :error -> 0 + end + end + + def non_negative_integer(_), do: 0 +end diff --git a/apps/elektrine/lib/elektrine/social/expired_data_cleanup_worker.ex b/apps/elektrine/lib/elektrine/social/expired_data_cleanup_worker.ex new file mode 100644 index 0000000..f8871cb --- /dev/null +++ b/apps/elektrine/lib/elektrine/social/expired_data_cleanup_worker.ex @@ -0,0 +1,61 @@ +defmodule Elektrine.Social.ExpiredDataCleanupWorker do + @moduledoc """ + Prunes expired social/auth support rows that don't need permanent retention. + """ + + use Oban.Worker, queue: :default, max_attempts: 1, priority: 9 + + alias Elektrine.Repo + + @impl Oban.Worker + def perform(%Oban.Job{}) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + stale_preview_cutoff = DateTime.add(now, -30, :day) + revoked_token_cutoff = DateTime.add(now, -30, :day) + + prune_expired_api_tokens(now, revoked_token_cutoff) + prune_expired_oauth_tokens(now) + prune_expired_social_filters(now) + prune_stale_failed_link_previews(stale_preview_cutoff) + + :ok + end + + defp prune_expired_api_tokens(now, revoked_cutoff) do + Repo.query!( + """ + delete from api_tokens + where (expires_at is not null and expires_at < $1) + or (revoked_at is not null and revoked_at < $2) + """, + [now, revoked_cutoff] + ) + end + + defp prune_expired_oauth_tokens(now) do + Repo.query!("delete from oauth_tokens where valid_until < $1", [now]) + end + + defp prune_expired_social_filters(now) do + Repo.query!( + """ + delete from social_filters + where expires_at is not null + and expires_at < $1 + """, + [now] + ) + end + + defp prune_stale_failed_link_previews(cutoff) do + Repo.query!( + """ + delete from link_previews + where status = 'failed' + and fetched_at is not null + and fetched_at < $1 + """, + [cutoff] + ) + end +end diff --git a/apps/elektrine/lib/elektrine/social/hashtag.ex b/apps/elektrine/lib/elektrine/social/hashtag.ex new file mode 100644 index 0000000..4baf3b8 --- /dev/null +++ b/apps/elektrine/lib/elektrine/social/hashtag.ex @@ -0,0 +1,39 @@ +defmodule Elektrine.Social.Hashtag do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "hashtags" do + field :name, :string + field :normalized_name, :string + field :use_count, :integer, default: 0 + field :last_used_at, :utc_datetime + + many_to_many :messages, Elektrine.Social.Message, + join_through: "post_hashtags", + on_replace: :delete + + timestamps() + end + + @doc false + def changeset(hashtag, attrs) do + hashtag + |> cast(attrs, [:name, :normalized_name, :use_count, :last_used_at]) + |> update_change(:last_used_at, &Elektrine.Time.truncate/1) + |> validate_required([:name, :normalized_name]) + |> validate_length(:name, min: 1, max: 50) + |> validate_format(:name, ~r/^[A-Za-z0-9_]+$/, + message: "can only contain letters, numbers, and underscores" + ) + |> put_normalized_name() + |> unique_constraint(:normalized_name) + end + + defp put_normalized_name(changeset) do + case get_change(changeset, :name) do + nil -> changeset + name -> put_change(changeset, :normalized_name, String.downcase(name)) + end + end +end diff --git a/apps/elektrine/lib/elektrine/social/link_preview.ex b/apps/elektrine/lib/elektrine/social/link_preview.ex new file mode 100644 index 0000000..37ecede --- /dev/null +++ b/apps/elektrine/lib/elektrine/social/link_preview.ex @@ -0,0 +1,102 @@ +defmodule Elektrine.Social.LinkPreview do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + alias Elektrine.Security.SafeExternalURL + + @max_varchar_length 255 + @max_url_length 2048 + + schema "link_previews" do + field :url, :string + field :title, :string + field :description, :string + field :image_url, :string + field :site_name, :string + field :favicon_url, :string + field :status, :string, default: "pending" + field :error_message, :string + field :fetched_at, :utc_datetime + + timestamps() + end + + @doc false + def changeset(preview, attrs) do + preview + |> cast(attrs, [ + :url, + :title, + :description, + :image_url, + :site_name, + :favicon_url, + :status, + :error_message, + :fetched_at + ]) + |> trim_field(:url) + |> trim_field(:image_url) + |> trim_field(:favicon_url) + |> update_change(:fetched_at, &Elektrine.Time.truncate/1) + |> truncate_field(:title, @max_varchar_length) + |> truncate_field(:site_name, @max_varchar_length) + |> nilify_overlong_field(:image_url, @max_url_length) + |> nilify_overlong_field(:favicon_url, @max_url_length) + |> validate_required([:url]) + |> validate_inclusion(:status, ["pending", "success", "failed"]) + |> validate_safe_href(:url) + |> validate_optional_safe_href(:image_url) + |> validate_optional_safe_href(:favicon_url) + |> unique_constraint(:url) + end + + defp trim_field(changeset, field) do + update_change(changeset, field, fn + value when is_binary(value) -> String.trim(value) + value -> value + end) + end + + defp truncate_field(changeset, field, max_length) do + update_change(changeset, field, fn value -> + if is_binary(value) and String.length(value) > max_length do + String.slice(value, 0, max_length) + else + value + end + end) + end + + defp nilify_overlong_field(changeset, field, max_length) do + update_change(changeset, field, fn value -> + if is_binary(value) and String.length(value) > max_length do + nil + else + value + end + end) + end + + defp validate_safe_href(changeset, field) do + validate_change(changeset, field, fn _, url -> + safe_href_errors(field, url) + end) + end + + defp validate_optional_safe_href(changeset, field) do + validate_change(changeset, field, fn + _, nil -> [] + _, "" -> [] + _, url -> safe_href_errors(field, url) + end) + end + + defp safe_href_errors(field, url) do + case SafeExternalURL.normalize_href(url) do + {:ok, _url} -> [] + {:error, :userinfo_not_allowed} -> [{field, "must not include username or password"}] + {:error, _reason} -> [{field, "must be a valid HTTP or HTTPS URL"}] + end + end +end diff --git a/apps/elektrine/lib/elektrine/social/message_stat.ex b/apps/elektrine/lib/elektrine/social/message_stat.ex new file mode 100644 index 0000000..33bf8f1 --- /dev/null +++ b/apps/elektrine/lib/elektrine/social/message_stat.ex @@ -0,0 +1,49 @@ +defmodule Elektrine.Social.MessageStat do + @moduledoc """ + Durable counter row for social messages. + """ + + use Ecto.Schema + import Ecto.Changeset + + schema "social_message_stats" do + belongs_to :message, Elektrine.Social.Message + + field :like_count, :integer, default: 0 + field :reply_count, :integer, default: 0 + field :share_count, :integer, default: 0 + field :quote_count, :integer, default: 0 + field :remote_like_count, :integer + field :remote_reply_count, :integer + field :remote_share_count, :integer + field :remote_quote_count, :integer + field :remote_counts_fetched_at, :utc_datetime + + timestamps() + end + + def changeset(stat, attrs) do + stat + |> cast(attrs, [ + :message_id, + :like_count, + :reply_count, + :share_count, + :quote_count, + :remote_like_count, + :remote_reply_count, + :remote_share_count, + :remote_quote_count, + :remote_counts_fetched_at + ]) + |> validate_required([:message_id]) + |> clamp_remote_counts() + |> unique_constraint(:message_id) + end + + defp clamp_remote_counts(changeset) do + Enum.reduce(Elektrine.Social.EngagementCounts.remote_fields(), changeset, fn field, acc -> + update_change(acc, field, &Elektrine.Social.EngagementCounts.nullable_remote_count/1) + end) + end +end diff --git a/apps/elektrine/lib/elektrine/social/message_stats.ex b/apps/elektrine/lib/elektrine/social/message_stats.ex new file mode 100644 index 0000000..675c702 --- /dev/null +++ b/apps/elektrine/lib/elektrine/social/message_stats.ex @@ -0,0 +1,84 @@ +defmodule Elektrine.Social.MessageStats do + @moduledoc """ + Write helpers for the `social_message_stats` counter table. + """ + + alias Elektrine.Repo + alias Elektrine.Social.{EngagementCounts, MessageStat} + + @count_fields [:like_count, :reply_count, :share_count, :quote_count] + + def upsert_counts(message_id, counts) when is_integer(message_id) and is_map(counts) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + count_attrs = normalize_counts(counts) + remote_attrs = normalize_remote_counts(counts) + fetched_attrs = remote_fetched_at_attrs(counts) + + attrs = + %{ + message_id: message_id, + like_count: 0, + reply_count: 0, + share_count: 0, + quote_count: 0, + inserted_at: now, + updated_at: now + } + |> Map.merge(count_attrs) + |> Map.merge(remote_attrs) + |> Map.merge(fetched_attrs) + + conflict_set = + count_attrs + |> Map.merge(remote_attrs) + |> Map.merge(fetched_attrs) + |> Map.put(:updated_at, now) + |> Map.to_list() + + if conflict_set == [updated_at: now] do + :ok + else + Repo.insert_all(MessageStat, [attrs], + on_conflict: [set: conflict_set], + conflict_target: [:message_id] + ) + end + + :ok + end + + def upsert_counts(_message_id, _counts), do: :ok + + defp normalize_counts(counts) do + @count_fields + |> Enum.reduce(%{}, fn field, acc -> + case Map.get(counts, field) do + value when not is_nil(value) -> + Map.put(acc, field, EngagementCounts.non_negative_integer(value)) + + nil -> + acc + end + end) + end + + defp normalize_remote_counts(counts) do + EngagementCounts.remote_fields() + |> Enum.reduce(%{}, fn field, acc -> + case Map.get(counts, field) do + value when not is_nil(value) -> + Map.put(acc, field, EngagementCounts.nullable_remote_count(value)) + + nil -> + acc + end + end) + end + + defp remote_fetched_at_attrs(%{remote_counts_fetched_at: fetched_at}) + when not is_nil(fetched_at) do + %{remote_counts_fetched_at: fetched_at} + end + + defp remote_fetched_at_attrs(_), do: %{} +end diff --git a/apps/elektrine/lib/elektrine/social/messages.ex b/apps/elektrine/lib/elektrine/social/messages.ex new file mode 100644 index 0000000..ffc295d --- /dev/null +++ b/apps/elektrine/lib/elektrine/social/messages.ex @@ -0,0 +1,2604 @@ +defmodule Elektrine.Social.Messages do + @moduledoc """ + Context for managing messages - creation, editing, deletion, and retrieval. + """ + + require Logger + + import Ecto.Query, warn: false + alias Elektrine.{Accounts, AppCache} + alias Elektrine.Repo + + alias Elektrine.Messaging.{LinkPreviewBroadcast, RateLimiter, UserHiddenMessage} + alias Elektrine.Social.{Conversation, ConversationMember, Message, MessageStats} + + alias Elektrine.Social.{FetchLinkPreviewWorker, LinkPreview, LinkPreviewFetcher} + @mention_pattern ~r/(?:^|[^A-Za-z0-9_])@([A-Za-z0-9_]{1,30})/ + + @doc """ + Creates a text message in a conversation. + """ + def create_text_message(conversation_id, sender_id, content, reply_to_id \\ nil, opts \\ []) do + # Check rate limiting first (with fallback if rate limiter not running) + can_send = + try do + RateLimiter.can_send_message?(sender_id) + rescue + # Fallback to allowing if rate limiter not available + _ -> true + catch + # Fallback if process not alive + :exit, _ -> true + end + + if can_send do + # Get conversation to check type and for privacy validation + conversation = Repo.get(Conversation, conversation_id) + + # For DM conversations, check privacy settings + privacy_check = + if conversation && conversation.type == "dm" do + # Get the other user in the DM + other_user_id = + from(cm in ConversationMember, + where: + cm.conversation_id == ^conversation_id and cm.user_id != ^sender_id and + is_nil(cm.left_at), + select: cm.user_id, + limit: 1 + ) + |> Repo.one() + + if other_user_id do + Elektrine.Privacy.can_send_dm?(sender_id, other_user_id) + else + {:ok, :allowed} + end + else + {:ok, :allowed} + end + + case privacy_check do + {:error, reason} -> + {:error, reason} + + {:ok, :allowed} -> + # Verify sender is a member of the conversation + case get_conversation_member(conversation_id, sender_id) do + nil -> + {:error, :unauthorized} + + member -> + if ConversationMember.can_send_messages?(member) do + result = + Message.text_changeset( + conversation_id, + sender_id, + content, + reply_to_id, + conversation.type + ) + |> Repo.insert() + + case result do + {:ok, message} -> + # Side-effect (link previews): skip in tests to avoid sandbox/network noise. + Elektrine.Async.run(fn -> extract_and_attach_link_preview(message) end) + + # Update reply count if this is a reply + if reply_to_id do + from(m in Message, + where: m.id == ^reply_to_id, + update: [inc: [reply_count: 1]] + ) + |> Repo.update_all([]) + end + + # Record for rate limiting (with fallback) + try do + RateLimiter.record_message(sender_id) + rescue + # Ignore if rate limiter not available + _ -> :ok + catch + # Ignore if process not alive + :exit, _ -> :ok + end + + # Update conversation's last_message_at + update_conversation_timestamp(conversation_id) + + # Create notifications for other conversation members + notify_conversation_members( + conversation_id, + sender_id, + content, + message.id, + reply_to_id + ) + + Accounts.notify_subscribers_for_message(message) + + # Broadcast the message (unless explicitly skipped) + unless opts[:skip_broadcast] do + broadcast_message(message) + end + + # Federate to ActivityPub (async) + if conversation.type == "timeline" && + message.visibility in ["public", "unlisted", "followers"] do + Elektrine.Async.start(fn -> + preloaded = Repo.preload(message, :sender) + Elektrine.ActivityPub.Outbox.federate_post(preloaded) + end) + end + + # Ensure link_preview is loaded (might be nil initially) + preloaded_message = + Repo.preload(message, [:sender, :reply_to, :reactions, :link_preview]) + + decrypted_message = Message.decrypt_content(preloaded_message) + {:ok, decrypted_message} + + error -> + error + end + else + {:error, :unauthorized} + end + end + end + else + {:error, :rate_limited} + end + end + + @doc """ + Creates a media message in a conversation. + """ + def create_media_message( + conversation_id, + sender_id, + media_urls, + content \\ nil, + media_metadata \\ %{} + ) do + # Check rate limiting first (with fallback if rate limiter not running) + can_send = + try do + RateLimiter.can_send_message?(sender_id) + rescue + # Fallback to allowing if rate limiter not available + _ -> true + catch + # Fallback if process not alive + :exit, _ -> true + end + + if can_send do + # Get conversation to check type and for privacy validation + conversation = Repo.get(Conversation, conversation_id) + + # For DM conversations, check privacy settings + privacy_check = + if conversation && conversation.type == "dm" do + # Get the other user in the DM + other_user_id = + from(cm in ConversationMember, + where: + cm.conversation_id == ^conversation_id and cm.user_id != ^sender_id and + is_nil(cm.left_at), + select: cm.user_id, + limit: 1 + ) + |> Repo.one() + + if other_user_id do + Elektrine.Privacy.can_send_dm?(sender_id, other_user_id) + else + {:ok, :allowed} + end + else + {:ok, :allowed} + end + + case privacy_check do + {:error, reason} -> + {:error, reason} + + {:ok, :allowed} -> + case get_conversation_member(conversation_id, sender_id) do + nil -> + {:error, :unauthorized} + + member -> + if ConversationMember.can_send_messages?(member) do + result = + Message.media_changeset( + conversation_id, + sender_id, + media_urls, + content, + media_metadata, + conversation.type + ) + |> Repo.insert() + + case result do + {:ok, message} -> + # Record for rate limiting (with fallback) + try do + RateLimiter.record_message(sender_id) + rescue + # Ignore if rate limiter not available + _ -> :ok + catch + # Ignore if process not alive + :exit, _ -> :ok + end + + update_conversation_timestamp(conversation_id) + + # Notify conversation members if content provided + if content do + notify_conversation_members( + conversation_id, + sender_id, + content, + message.id, + nil + ) + end + + broadcast_message(message) + preloaded_message = Repo.preload(message, [:sender, :reply_to, :reactions]) + decrypted_message = Message.decrypt_content(preloaded_message) + {:ok, decrypted_message} + + {:error, changeset} = error -> + Logger.error("Failed to create media message: #{inspect(changeset.errors)}") + error + + error -> + Logger.error("Failed to create media message: #{inspect(error)}") + error + end + else + {:error, :unauthorized} + end + end + end + else + {:error, :rate_limited} + end + end + + @doc """ + Creates a voice message in a conversation. + """ + def create_voice_message(conversation_id, sender_id, audio_url, duration, mime_type) do + # Check rate limiting first (with fallback if rate limiter not running) + can_send = + try do + RateLimiter.can_send_message?(sender_id) + rescue + _ -> true + catch + :exit, _ -> true + end + + if can_send do + # Verify sender is a member of the conversation + case get_conversation_member(conversation_id, sender_id) do + nil -> + {:error, :unauthorized} + + member -> + if ConversationMember.can_send_messages?(member) do + result = + %Message{} + |> Message.changeset(%{ + conversation_id: conversation_id, + sender_id: sender_id, + content: nil, + message_type: "voice", + visibility: "conversation", + post_type: "message", + media_urls: [audio_url], + media_metadata: %{ + "duration" => duration, + "mime_type" => mime_type + } + }) + |> Repo.insert() + + case result do + {:ok, message} -> + # Record for rate limiting + try do + RateLimiter.record_message(sender_id) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + + # Update conversation's last_message_at + update_conversation_timestamp(conversation_id) + + # Create notifications for other conversation members + notify_conversation_members( + conversation_id, + sender_id, + "[Voice message]", + message.id, + nil + ) + + # Broadcast the message + broadcast_message(message) + + # Preload and return + preloaded_message = Repo.preload(message, [:sender, :reply_to, :reactions]) + {:ok, preloaded_message} + + error -> + error + end + else + {:error, :unauthorized} + end + end + else + {:error, :rate_limited} + end + end + + @doc """ + Creates a system message in a conversation (e.g., call logs, join/leave notifications). + System messages are displayed differently but still need a sender_id (use first admin or creator). + """ + def create_system_message(conversation_id, content, metadata \\ %{}) do + # Get first admin user as sender for system messages + sender_id = + from(u in Elektrine.Accounts.User, + where: u.is_admin == true, + select: u.id, + limit: 1 + ) + # Fallback to user ID 1 + |> Repo.one() || 1 + + %Message{} + |> Message.changeset(%{ + conversation_id: conversation_id, + sender_id: sender_id, + content: content, + message_type: "system", + visibility: "conversation", + post_type: "message", + media_metadata: metadata + }) + |> Repo.insert() + |> case do + {:ok, message} = result -> + # Update conversation's last message time + from(c in Conversation, where: c.id == ^conversation_id) + |> Repo.update_all(set: [last_message_at: Elektrine.Time.utc_now()]) + + # Broadcast to conversation + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{conversation_id}", + {:new_message, message} + ) + + result + + error -> + error + end + end + + @doc """ + Edits a message. + """ + def edit_message(message_id, user_id, new_content) do + case Repo.get(Message, message_id) do + nil -> + {:error, :not_found} + + message -> + if Message.can_edit?(message, user_id) do + message + |> Message.edit_changeset(new_content) + |> Repo.update() + |> case do + {:ok, updated_message} -> + broadcast_message_edit(updated_message) + + # Side-effect (federation): skip in tests. + Elektrine.Async.start(fn -> + preloaded = Repo.preload(updated_message, :sender) + Elektrine.ActivityPub.Outbox.federate_update(preloaded) + _ = Elektrine.Bluesky.OutboundWorker.enqueue_post_update(updated_message.id) + end) + + preloaded_message = Repo.preload(updated_message, [:sender, :reply_to, :reactions]) + decrypted_message = Message.decrypt_content(preloaded_message) + {:ok, decrypted_message} + + error -> + error + end + else + {:error, :unauthorized} + end + end + end + + @doc """ + Deletes a message. + """ + def delete_message(message_id, user_id, is_admin \\ false) do + case Repo.get(Message, message_id) |> Repo.preload(:conversation) do + nil -> + {:error, :not_found} + + message -> + # Check if user is a moderator in the conversation + member = get_conversation_member(message.conversation_id, user_id) + is_mod = member && member.role in ["moderator", "admin", "owner"] + + if Message.can_delete?(message, user_id, is_admin) || is_mod do + message + |> Message.delete_changeset() + |> Repo.update() + |> case do + {:ok, deleted_message} -> + _ = + Elektrine.Social.AttachmentCleanupWorker.enqueue( + deleted_message.id, + deleted_message.media_urls || [] + ) + + maybe_record_moderated_delete(deleted_message, user_id, is_admin, is_mod) + broadcast_message_delete(deleted_message) + notify_home_feed_message_deleted(deleted_message) + + # Side-effect (federation): skip in tests. + Elektrine.Async.start(fn -> + preloaded = Repo.preload(deleted_message, :sender) + Elektrine.ActivityPub.Outbox.federate_delete(preloaded) + _ = Elektrine.Bluesky.OutboundWorker.enqueue_post_delete(deleted_message.id) + end) + + {:ok, deleted_message} + + error -> + error + end + else + {:error, :unauthorized} + end + end + end + + @doc """ + Admin deletes a message (bypasses ownership check). + """ + def admin_delete_message(message_id, admin_user) do + if admin_user.is_admin do + case Repo.get(Message, message_id) |> Repo.preload(:conversation) do + nil -> + {:error, :not_found} + + message -> + if message.deleted_at do + {:error, :already_deleted} + else + message + |> Message.delete_changeset() + |> Repo.update() + |> case do + {:ok, deleted_message} -> + _ = + Elektrine.Social.AttachmentCleanupWorker.enqueue( + deleted_message.id, + deleted_message.media_urls || [] + ) + + maybe_record_moderated_delete(deleted_message, admin_user.id, true, true) + broadcast_message_delete(deleted_message) + {:ok, deleted_message} + + error -> + error + end + end + end + else + {:error, :unauthorized} + end + end + + @doc """ + Get messages for a conversation with pagination support. + Returns messages in reverse chronological order (newest first). + """ + def get_conversation_messages(conversation_id, user_id, opts \\ []) do + # Verify user is a member of the conversation before allowing access + case get_conversation_member(conversation_id, user_id) do + nil -> + # User is not a member - return empty result + %{ + messages: [], + has_more_older: false, + has_more_newer: false, + oldest_id: nil, + newest_id: nil + } + + _member -> + # User is authorized, proceed with fetching messages + fetch_conversation_messages(conversation_id, user_id, opts) + end + end + + defp maybe_record_moderated_delete(%Message{} = message, actor_user_id, is_admin, is_mod) do + if counts_as_moderated_delete?(message, actor_user_id, is_admin, is_mod) do + Accounts.TrustLevel.increment_stat(message.sender_id, :posts_deleted) + end + + :ok + end + + defp counts_as_moderated_delete?( + %Message{sender_id: sender_id, conversation: conversation}, + actor_user_id, + is_admin, + is_mod + ) do + sender_id != actor_user_id && + is_integer(sender_id) && + (is_admin || is_mod) && + conversation_type_counts_for_delete?(conversation) + end + + defp conversation_type_counts_for_delete?(%Conversation{type: type}), + do: type in ["timeline", "community"] + + defp conversation_type_counts_for_delete?(_), do: false + + defp fetch_conversation_messages(conversation_id, user_id, opts) do + limit = Keyword.get(opts, :limit, 50) + before_id = Keyword.get(opts, :before_id, nil) + after_id = Keyword.get(opts, :after_id, nil) + + base_query = + from(m in Message, + left_join: h in UserHiddenMessage, + on: h.message_id == m.id and h.user_id == ^user_id, + where: m.conversation_id == ^conversation_id and is_nil(m.deleted_at) and is_nil(h.id), + limit: ^limit, + preload: [ + sender: [:profile], + reply_to: [sender: [:profile]], + reactions: [user: []], + link_preview: [], + shared_message: [sender: [:profile], conversation: []] + ] + ) + + query = + cond do + before_id -> + # Load older messages (scrolling up) + from(m in base_query, + where: m.id < ^before_id, + order_by: [desc: m.inserted_at] + ) + + after_id -> + # Load newer messages (new messages since last load) + from(m in base_query, + where: m.id > ^after_id, + order_by: [asc: m.inserted_at] + ) + + true -> + # Initial load - get most recent messages + from(m in base_query, + order_by: [desc: m.inserted_at] + ) + end + + messages = Repo.all(query) + + # If we loaded newer messages (after_id), reverse to maintain desc order + messages = if after_id, do: Enum.reverse(messages), else: messages + + # Decrypt messages + messages = Message.decrypt_messages(messages) + + # Check if there are more messages available + has_more_older = + if before_id || is_nil(after_id) do + oldest_message = List.last(messages) + + if oldest_message do + from(m in Message, + left_join: h in UserHiddenMessage, + on: h.message_id == m.id and h.user_id == ^user_id, + where: + m.conversation_id == ^conversation_id and + m.id < ^oldest_message.id and + is_nil(m.deleted_at) and + is_nil(h.id), + select: m.id, + limit: 1 + ) + |> Repo.one() + |> case do + nil -> false + _ -> true + end + else + false + end + else + false + end + + has_more_newer = + if after_id do + newest_message = List.first(messages) + + if newest_message do + from(m in Message, + left_join: h in UserHiddenMessage, + on: h.message_id == m.id and h.user_id == ^user_id, + where: + m.conversation_id == ^conversation_id and + m.id > ^newest_message.id and + is_nil(m.deleted_at) and + is_nil(h.id), + select: m.id, + limit: 1 + ) + |> Repo.one() + |> case do + nil -> false + _ -> true + end + else + false + end + else + false + end + + oldest_id = + case messages do + [] -> nil + _ -> List.last(messages).id + end + + newest_id = + case messages do + [%{id: id} | _] -> id + [] -> nil + end + + %{ + messages: messages, + has_more_older: has_more_older, + has_more_newer: has_more_newer, + oldest_id: oldest_id, + newest_id: newest_id + } + end + + @doc """ + Gets messages for a conversation with pagination. + """ + def get_messages(conversation_id, user_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + before_id = Keyword.get(opts, :before_id) + # Default to showing only regular messages (not discussions) for older clients + post_types = Keyword.get(opts, :post_types, ["message", nil]) + + # Verify user is a member of the conversation + case get_conversation_member(conversation_id, user_id) do + nil -> + {:error, :unauthorized} + + _member -> + query = + from(m in Message, + left_join: h in UserHiddenMessage, + on: h.message_id == m.id and h.user_id == ^user_id, + where: + m.conversation_id == ^conversation_id and is_nil(m.deleted_at) and is_nil(h.id), + order_by: [desc: m.inserted_at], + limit: ^limit, + preload: [ + sender: [:profile], + reply_to: [sender: [:profile]], + reactions: [user: []], + link_preview: [], + shared_message: [sender: [:profile], conversation: []] + ] + ) + + # Apply post_type filter + query = + if nil in post_types do + # If nil is in the list, we need to handle it specially + non_nil_types = Enum.reject(post_types, &is_nil/1) + from(m in query, where: m.post_type in ^non_nil_types or is_nil(m.post_type)) + else + from(m in query, where: m.post_type in ^post_types) + end + + query = + if before_id do + from(m in query, where: m.id < ^before_id) + else + query + end + + messages = Repo.all(query) + decrypted_messages = Message.decrypt_messages(messages) + {:ok, Enum.reverse(decrypted_messages)} + end + end + + @doc """ + Searches messages within a specific conversation. + """ + def search_messages_in_conversation(conversation_id, user_id, query, opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + + # Verify user is a member of the conversation + case get_conversation_member(conversation_id, user_id) do + nil -> + {:error, :unauthorized} + + _member -> + # Extract keywords from query and hash them for blind index search + keywords = Elektrine.Encryption.extract_keywords(query) + + if Enum.empty?(keywords) do + # No valid keywords (all stop words or too short), return empty + {:ok, []} + else + # Hash each keyword for this user + keyword_hashes = + Enum.map(keywords, fn kw -> + Elektrine.Encryption.hash_keyword(kw, user_id) + end) + + # Search for messages where ANY of the keyword hashes appear in search_index + # Using PostgreSQL array overlap operator && + messages = + from(m in Message, + where: + m.conversation_id == ^conversation_id and + is_nil(m.deleted_at) and + fragment("? && ?", m.search_index, ^keyword_hashes), + order_by: [desc: m.inserted_at], + limit: ^limit, + preload: [sender: [:profile]] + ) + |> Repo.all() + |> Message.decrypt_messages() + + {:ok, messages} + end + end + end + + @doc """ + Marks messages as read for a user in a conversation. + """ + def mark_as_read(conversation_id, user_id) do + case get_conversation_member(conversation_id, user_id) do + nil -> + {:error, :unauthorized} + + member -> + member + |> ConversationMember.mark_as_read_changeset() + |> Repo.update() + end + end + + @doc """ + Updates the last read message for a user in a conversation. + """ + def update_last_read_message(conversation_id, user_id, message_id) do + case get_conversation_member(conversation_id, user_id) do + nil -> + {:error, :unauthorized} + + member -> + member + |> ConversationMember.update_last_read_message_changeset(message_id) + |> Repo.update() + end + end + + @doc """ + Gets the last read message ID for a user in a conversation. + """ + def get_last_read_message_id(conversation_id, user_id) do + from(cm in ConversationMember, + where: + cm.conversation_id == ^conversation_id and + cm.user_id == ^user_id and + is_nil(cm.left_at), + select: cm.last_read_message_id + ) + |> Repo.one() + end + + @doc """ + Clears message history for a specific user (marks messages as hidden for them). + """ + def clear_history_for_user(conversation_id, user_id) do + case get_conversation_member(conversation_id, user_id) do + nil -> + {:error, :unauthorized} + + _member -> + # Get all messages in the conversation + message_ids = + from(m in Message, + where: m.conversation_id == ^conversation_id and is_nil(m.deleted_at), + select: m.id + ) + |> Repo.all() + + # Create hidden records for all messages + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + hidden_records = + Enum.map(message_ids, fn message_id -> + %{ + user_id: user_id, + message_id: message_id, + hidden_at: now, + inserted_at: now, + updated_at: now + } + end) + + case Repo.insert_all(UserHiddenMessage, hidden_records, on_conflict: :nothing) do + {_count, _} -> {:ok, :cleared} + end + end + end + + @doc """ + Gets users who have read a specific message. + """ + def get_message_readers(message_id, conversation_id) do + message = Repo.get!(Message, message_id) + + from(cm in ConversationMember, + join: u in Elektrine.Accounts.User, + on: u.id == cm.user_id, + where: + cm.conversation_id == ^conversation_id and + is_nil(cm.left_at) and + cm.user_id != ^message.sender_id and + (not is_nil(cm.last_read_at) and cm.last_read_at >= ^message.inserted_at), + select: %{ + user_id: u.id, + username: u.username, + avatar: u.avatar, + read_at: cm.last_read_at + } + ) + |> Repo.all() + end + + @doc """ + Gets read status for messages in a conversation. + """ + def get_read_status_for_messages(message_ids, conversation_id) do + # Get all members and their last read times + members_with_read_times = + from(cm in ConversationMember, + join: u in Elektrine.Accounts.User, + on: u.id == cm.user_id, + where: cm.conversation_id == ^conversation_id and is_nil(cm.left_at), + select: %{ + user_id: u.id, + username: u.username, + avatar: u.avatar, + last_read_at: cm.last_read_at + } + ) + |> Repo.all() + + # Get messages with their timestamps + messages = + from(m in Message, + where: m.id in ^message_ids, + select: %{id: m.id, inserted_at: m.inserted_at, sender_id: m.sender_id} + ) + |> Repo.all() + + # Build read status map + Enum.reduce(messages, %{}, fn message, acc -> + readers = + Enum.filter(members_with_read_times, fn member -> + member.user_id != message.sender_id and + member.last_read_at != nil and + NaiveDateTime.compare(member.last_read_at, message.inserted_at) != :lt + end) + + Map.put(acc, message.id, readers) + end) + end + + @doc """ + Gets read status for last messages across multiple conversations. + Takes a list of {conversation_id, message_id, message_inserted_at} tuples. + Returns a map of conversation_id => reader_count. + """ + def get_batch_last_message_read_status(message_info_list) do + if Enum.empty?(message_info_list) do + %{} + else + conversation_ids = + Enum.map(message_info_list, fn {conv_id, _msg_id, _inserted_at} -> conv_id end) + + # Get all members with their last read times for all conversations at once + members_by_conversation = + from(cm in ConversationMember, + where: cm.conversation_id in ^conversation_ids and is_nil(cm.left_at), + select: %{ + conversation_id: cm.conversation_id, + user_id: cm.user_id, + last_read_at: cm.last_read_at + } + ) + |> Repo.all() + |> Enum.group_by(& &1.conversation_id) + + # For each message, count how many members have read it + message_info_list + |> Enum.map(fn {conv_id, _msg_id, inserted_at} -> + members = Map.get(members_by_conversation, conv_id, []) + + reader_count = + Enum.count(members, fn member -> + member.last_read_at != nil and + NaiveDateTime.compare(member.last_read_at, inserted_at) != :lt + end) + + # Subtract 1 to exclude the sender (who is also a member) + reader_count = max(0, reader_count - 1) + {conv_id, %{is_read: reader_count > 0, reader_count: reader_count}} + end) + |> Map.new() + end + end + + @doc """ + Gets unread message count for a specific conversation and user. + """ + def get_conversation_unread_count(conversation_id, user_id) do + # Get the user's last read timestamp for this conversation + member = + from(cm in ConversationMember, + where: cm.conversation_id == ^conversation_id and cm.user_id == ^user_id, + select: cm.last_read_at + ) + |> Repo.one() + + case member do + nil -> + 0 + + last_read_at -> + # Count messages after last read timestamp + from(m in Message, + where: m.conversation_id == ^conversation_id, + where: m.sender_id != ^user_id, + where: m.inserted_at > ^last_read_at, + select: count(m.id) + ) + |> Repo.one() + end + end + + @doc """ + Gets unread message counts for multiple conversations in a single query. + Returns a map of conversation_id => unread_count. + """ + def get_conversation_unread_counts(conversation_ids, user_id) when is_list(conversation_ids) do + if Enum.empty?(conversation_ids) do + %{} + else + # Count unread messages for all conversations in a single query + # Joins with ConversationMember to get last_read_at per conversation + # Counts messages that are: + # - From other users (not the current user) + # - Not deleted + # - Either: user has never read (nil last_read_at) OR message is after last_read_at + counts_query = + from(m in Message, + join: cm in ConversationMember, + on: cm.conversation_id == m.conversation_id and cm.user_id == ^user_id, + where: m.conversation_id in ^conversation_ids, + where: m.sender_id != ^user_id, + where: is_nil(m.deleted_at), + where: is_nil(cm.last_read_at) or m.inserted_at > cm.last_read_at, + group_by: m.conversation_id, + select: {m.conversation_id, count(m.id)} + ) + + counts = Repo.all(counts_query) |> Map.new() + + # Return map with 0 for conversations with no unread messages + conversation_ids + |> Enum.map(fn conv_id -> + count = Map.get(counts, conv_id, 0) + {conv_id, count} + end) + |> Map.new() + end + end + + @doc """ + Gets unread message count for a user across all conversations. + """ + def get_unread_count(user_id) do + subquery = + from(cm in ConversationMember, + join: c in Conversation, + on: c.id == cm.conversation_id, + join: m in Message, + on: m.conversation_id == c.id, + where: + cm.user_id == ^user_id and + is_nil(cm.left_at) and + (is_nil(cm.last_read_at) or m.inserted_at > cm.last_read_at) and + m.sender_id != ^user_id and + is_nil(m.deleted_at), + select: count(m.id) + ) + + Repo.one(subquery) || 0 + end + + @doc """ + Pins a message in a community (moderators only). + Only one message can be pinned at a time - unpins any existing pinned message. + """ + def pin_message(message_id, user_id) do + message = Repo.get!(Message, message_id) + + member = get_conversation_member(message.conversation_id, user_id) + is_mod = member && member.role in ["moderator", "admin", "owner"] + + if is_mod do + # First, unpin any existing pinned messages in this conversation + previously_pinned = + from(m in Message, + where: + m.conversation_id == ^message.conversation_id and m.is_pinned == true and + m.id != ^message_id + ) + |> Repo.all() + + Enum.each(previously_pinned, fn old_pinned -> + old_pinned + |> Ecto.Changeset.change(%{is_pinned: false, pinned_at: nil, pinned_by_id: nil}) + |> Repo.update() + + # Broadcast unpin for the old message + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{message.conversation_id}", + {:message_unpinned, old_pinned} + ) + end) + + # Now pin the new message + case message + |> Ecto.Changeset.change(%{ + is_pinned: true, + pinned_at: DateTime.utc_now() |> DateTime.truncate(:second), + pinned_by_id: user_id + }) + |> Repo.update() do + {:ok, updated_message} -> + # Broadcast pin update to all connected users + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{message.conversation_id}", + {:message_pinned, updated_message} + ) + + {:ok, updated_message} + + error -> + error + end + else + {:error, :unauthorized} + end + end + + @doc """ + Unpins a message in a community (moderators only). + """ + def unpin_message(message_id, user_id) do + message = Repo.get!(Message, message_id) + + member = get_conversation_member(message.conversation_id, user_id) + is_mod = member && member.role in ["moderator", "admin", "owner"] + + if is_mod do + case message + |> Ecto.Changeset.change(%{ + is_pinned: false, + pinned_at: nil, + pinned_by_id: nil + }) + |> Repo.update() do + {:ok, updated_message} -> + # Broadcast unpin update to all connected users + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{message.conversation_id}", + {:message_unpinned, updated_message} + ) + + {:ok, updated_message} + + error -> + error + end + else + {:error, :unauthorized} + end + end + + @doc """ + Lists pinned messages for a conversation. + """ + def list_pinned_messages(conversation_id) do + from(m in Message, + where: m.conversation_id == ^conversation_id and m.is_pinned == true, + order_by: [desc: m.pinned_at], + preload: [:sender, :pinned_by] + ) + |> Repo.all() + |> Message.decrypt_messages() + end + + @doc """ + Generates a friendly URL path for a discussion post. + """ + def discussion_post_path(community_name, post_id, title) do + Elektrine.Paths.discussion_post_path(community_name, post_id, title) + end + + @doc """ + Gets a user's discussion posts across all communities. + Returns most recent discussion posts by the user. + """ + def get_user_discussion_posts(user_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 10) + viewer_id = Keyword.get(opts, :viewer_id) + + query = + from(m in Message, + join: c in Conversation, + on: c.id == m.conversation_id, + where: + m.sender_id == ^user_id and + m.post_type == "discussion" and + is_nil(m.deleted_at) and + c.type == "community", + order_by: [desc: m.inserted_at], + limit: ^limit, + preload: [ + sender: [:profile], + conversation: [], + link_preview: [], + flair: [], + hashtags: [] + ] + ) + + query = + if is_integer(viewer_id) do + from([m, c] in query, + left_join: cm in ConversationMember, + on: cm.conversation_id == c.id and cm.user_id == ^viewer_id, + where: c.is_public == true or m.sender_id == ^viewer_id or not is_nil(cm.id) + ) + else + from([_m, c] in query, where: c.is_public == true) + end + + query + |> Repo.all() + |> Message.decrypt_messages() + end + + ## Private Helpers + + defp get_conversation_member(conversation_id, user_id) do + from(cm in ConversationMember, + where: + cm.conversation_id == ^conversation_id and + cm.user_id == ^user_id and + is_nil(cm.left_at) + ) + |> Repo.one() + end + + defp update_conversation_timestamp(conversation_id) do + from(c in Conversation, where: c.id == ^conversation_id) + |> Repo.update_all(set: [last_message_at: Elektrine.Time.utc_now()]) + end + + defp broadcast_message(message) do + # Ensure message has all associations loaded for the UI + message_with_data = + Repo.preload(message, + sender: [:profile], + reply_to: [sender: [:profile]], + reactions: [user: []], + link_preview: [], + hashtags: [] + ) + + # Check the conversation type to determine broadcast channel + conversation = Repo.get!(Elektrine.Social.Conversation, message.conversation_id) + + # Only broadcast to conversation channel for chat messages (dm/group/channel) + # Communities and timelines use their own channels + is_chat_conversation = conversation.type in ["dm", "group", "channel"] + + if is_chat_conversation do + # Broadcast to conversation participants for chat + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{message.conversation_id}", + {:new_message, message_with_data} + ) + + # Broadcast to each participant's user topic for conversation list updates + # Only fetch user_ids, not full ConversationMember structs + member_user_ids = + from(cm in ConversationMember, + where: + cm.conversation_id == ^message.conversation_id and + is_nil(cm.left_at) and + cm.user_id != ^message.sender_id, + select: cm.user_id + ) + |> Repo.all() + + Enum.each(member_user_ids, fn user_id -> + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "user:#{user_id}", + {:new_message_notification, message_with_data} + ) + end) + end + end + + defp broadcast_message_edit(message) do + message_with_data = + Repo.preload(message, + sender: [:profile], + reply_to: [sender: [:profile]], + reactions: [user: []], + link_preview: [] + ) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{message.conversation_id}", + {:message_edited, message_with_data} + ) + end + + defp broadcast_message_delete(message) do + message_with_data = + Repo.preload(message, + sender: [:profile], + reply_to: [sender: [:profile]], + reactions: [user: []], + link_preview: [] + ) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "conversation:#{message.conversation_id}", + {:message_deleted, message_with_data} + ) + end + + defp extract_and_attach_link_preview(message) do + decrypted = Message.decrypt_content(message) + + urls = + [message.primary_url, decrypted.content] + |> Enum.filter(&is_binary/1) + |> Enum.flat_map(&LinkPreviewFetcher.extract_urls/1) + |> Enum.uniq() + + case urls do + [url | _] -> + _ = FetchLinkPreviewWorker.enqueue(url, message.id) + + preview = Repo.get_by(LinkPreview, url: url) + + if preview do + updated_message = attach_link_preview(message, preview) + + case preview.status do + "success" -> + broadcast_preview_update(updated_message, preview) + + "pending" -> + Elektrine.Async.start(fn -> + poll_and_broadcast_preview(updated_message, preview.id, 15) + end) + + _ -> + :ok + end + + :ok + else + :ok + end + + _ -> + :ok + end + end + + defp poll_and_broadcast_preview(message, preview_id, attempts_left) do + LinkPreviewBroadcast.poll_and_broadcast( + message, + preview_id, + attempts_left, + link_preview_broadcast_opts() + ) + end + + defp attach_link_preview(message, preview) do + if message.link_preview_id == preview.id do + %{message | link_preview: preview} + else + {:ok, updated_message} = + message + |> Message.changeset(%{link_preview_id: preview.id}) + |> Repo.update() + + %{updated_message | link_preview: preview} + end + end + + defp broadcast_preview_update(message, preview) do + LinkPreviewBroadcast.broadcast_update(message, preview, link_preview_broadcast_opts()) + end + + defp link_preview_broadcast_opts do + [ + preload: [:sender, :reply_to, :reactions, :hashtags], + topic: fn message -> "conversation:#{message.conversation_id}" end + ] + end + + # Notifies conversation members about a new message + defp notify_conversation_members(conversation_id, sender_id, content, message_id, reply_to_id) do + # Get conversation details and sender in a single preload + conversation = Repo.get!(Conversation, conversation_id) + + # Don't create notifications for community or timeline posts + if conversation.type not in ["community", "timeline"] do + notify_chat_members(conversation, sender_id, content, message_id, reply_to_id) + end + end + + defp notify_chat_members(conversation, sender_id, content, message_id, reply_to_id) do + sender = Accounts.get_user!(sender_id) + conversation_id = conversation.id + + # Get original message sender_id once if this is a reply + original_sender_id = + if reply_to_id do + from(m in Message, where: m.id == ^reply_to_id, select: m.sender_id) + |> Repo.one() + end + + # If this is a reply, notify the original message author + if original_sender_id && original_sender_id != sender_id do + # Check if user wants to be notified about replies + user = Elektrine.Accounts.get_user!(original_sender_id) + + if notification_preference_enabled?(user, :notify_on_reply) do + Elektrine.Notifications.create_notification(%{ + user_id: original_sender_id, + actor_id: sender_id, + type: "reply", + title: "New reply from @#{sender.handle || sender.username}", + body: + "@#{sender.handle || sender.username} replied to your message: #{String.slice(content, 0, 50)}#{if String.length(content) > 50, do: "...", else: ""}", + url: Elektrine.Paths.chat_message_path(conversation_id, message_id), + source_type: "message", + source_id: message_id, + priority: "normal" + }) + end + end + + # Get all members except the sender with user preferences in a single query + members_with_preferences = + from(cm in ConversationMember, + join: u in Accounts.User, + on: u.id == cm.user_id, + where: + cm.conversation_id == ^conversation_id and + cm.user_id != ^sender_id and + is_nil(cm.left_at), + select: %{ + user_id: u.id, + username: u.username, + handle: u.handle, + notify_on_direct_message: u.notify_on_direct_message + } + ) + |> Repo.all() + + # Create notifications in batch + Enum.each(members_with_preferences, fn member -> + # Skip notification if user already got a reply notification + unless reply_to_id && member.user_id == original_sender_id do + # Only send DM notifications if user has the preference enabled + should_notify = + case conversation.type do + "dm" -> notification_preference_enabled?(member, :notify_on_direct_message) + # Group/channel notifications always sent for now + _ -> true + end + + if should_notify do + {title, message_text} = + case conversation.type do + "dm" -> + {"Message from #{sender.username}", + "#{String.slice(content, 0, 100)}#{if String.length(content) > 100, do: "...", else: ""}"} + + "group" -> + {"#{conversation.name || "Group"} message", + "#{sender.username}: #{String.slice(content, 0, 80)}#{if String.length(content) > 80, do: "...", else: ""}"} + + "channel" -> + {"##{conversation.name || "channel"}", + "#{sender.username}: #{String.slice(content, 0, 80)}#{if String.length(content) > 80, do: "...", else: ""}"} + + _ -> + {"New message", "From #{sender.username}"} + end + + Elektrine.Notifications.create_notification(%{ + user_id: member.user_id, + actor_id: sender_id, + type: "new_message", + title: title, + body: message_text, + url: Elektrine.Paths.chat_message_path(conversation_id, message_id), + source_type: "message", + source_id: message_id, + priority: if(conversation.type == "dm", do: "normal", else: "low") + }) + end + end + end) + + notify_mentions_in_chat( + content, + members_with_preferences, + sender, + sender_id, + message_id + ) + end + + defp notification_preference_enabled?(subject, field) when is_atom(field) do + Map.get(subject, field) != false + end + + defp notify_mentions_in_chat(content, members_with_preferences, sender, sender_id, message_id) do + mentioned_user_ids = + extract_mentioned_user_ids(content, members_with_preferences) + |> Enum.reject(&(&1 == sender_id)) + + context = mention_context(content) + + Enum.each(mentioned_user_ids, fn mentioned_user_id -> + case Elektrine.Privacy.can_mention?(sender_id, mentioned_user_id) do + {:ok, :allowed} -> + Elektrine.Notifications.notify_mention( + mentioned_user_id, + sender, + "message", + message_id, + context + ) + + _ -> + :ok + end + end) + end + + defp extract_mentioned_user_ids(content, members_with_preferences) when is_binary(content) do + lookup = + Enum.reduce(members_with_preferences, %{}, fn member, acc -> + acc + |> put_mention_lookup(member.username, member.user_id) + |> put_mention_lookup(member.handle, member.user_id) + end) + + Regex.scan(@mention_pattern, content, capture: :all_but_first) + |> List.flatten() + |> Enum.map(&String.downcase/1) + |> Enum.uniq() + |> Enum.flat_map(fn mention -> Map.get(lookup, mention, []) end) + |> Enum.uniq() + end + + defp extract_mentioned_user_ids(_, _), do: [] + + defp put_mention_lookup(lookup, identifier, user_id) when is_binary(identifier) do + key = String.downcase(identifier) + Map.update(lookup, key, [user_id], &[user_id | &1]) + end + + defp put_mention_lookup(lookup, _identifier, _user_id), do: lookup + + defp mention_context(content) when is_binary(content) do + snippet = String.trim(content) |> String.slice(0, 120) + "Mentioned you in chat: #{snippet}" + end + + defp mention_context(_), do: "Mentioned you in chat" + + # ActivityPub Federation Support + + @doc """ + Creates a message from a federated source (ActivityPub). + """ + def create_federated_message(attrs) do + case create_federated_message_with_status(attrs) do + {:ok, message, _status} -> {:ok, message} + error -> error + end + end + + @doc """ + Like `create_federated_message/1`, but reports whether the row was freshly + inserted (`:created`) or already existed under this `activitypub_id` + (`:existing`). + + Callers that run "new message" side effects (reply-count increments, + notifications, fan-out) MUST use this and skip those effects on `:existing`. + Because the insert is `on_conflict: :nothing`, a duplicate returns + `{:ok, %Message{id: nil}}`, which is otherwise indistinguishable from a fresh + insert and would silently re-run every side effect on each redelivery/boost. + """ + def create_federated_message_with_status(attrs) do + insert_opts = [ + on_conflict: :nothing, + conflict_target: {:unsafe_fragment, "(activitypub_id) WHERE activitypub_id IS NOT NULL"}, + returning: true + ] + + %Message{} + |> Message.federated_changeset(attrs) + |> Repo.insert(insert_opts) + |> case do + {:ok, %Message{id: nil}} -> + case get_message_by_activitypub_id(activitypub_id_from_attrs(attrs)) do + %Message{} = message -> {:ok, message, :existing} + nil -> {:error, :federated_message_conflict} + end + + {:ok, message} -> + invalidate_activitypub_ref_cache_for_message(message) + Accounts.notify_subscribers_for_message(message) + {:ok, message, :created} + + error -> + error + end + rescue + error in Postgrex.Error -> + activitypub_id = activitypub_id_from_attrs(attrs) + + if unique_activitypub_violation?(error) && is_binary(activitypub_id) do + case get_message_by_activitypub_id(activitypub_id) do + %Message{} = message -> {:ok, message, :existing} + nil -> reraise error, __STACKTRACE__ + end + else + reraise error, __STACKTRACE__ + end + end + + defp unique_activitypub_violation?(%Postgrex.Error{postgres: postgres}) when is_map(postgres) do + postgres[:code] in [:unique_violation, "unique_violation", "23505"] && + to_string(postgres[:constraint] || "") == "messages_activitypub_id_index" + end + + defp unique_activitypub_violation?(_), do: false + + defp activitypub_id_from_attrs(attrs) when is_map(attrs) do + Map.get(attrs, :activitypub_id) || Map.get(attrs, "activitypub_id") + end + + @doc """ + Gets a message by its ActivityPub ID. + """ + def get_message_by_activitypub_id(activitypub_id) + when not is_binary(activitypub_id) or activitypub_id == "", + do: nil + + def get_message_by_activitypub_id(activitypub_id) do + from(m in Message, + where: m.activitypub_id == ^activitypub_id, + order_by: [desc: m.updated_at, desc: m.id], + limit: 1 + ) + |> Repo.one() + end + + @doc """ + Gets a message by an ActivityPub reference that may be either the canonical ID + or the URL form used by some servers. + """ + def get_message_by_activitypub_ref(activitypub_ref, opts \\ []) + + def get_message_by_activitypub_ref(activitypub_ref, opts) when is_binary(activitypub_ref) do + case canonical_activitypub_ref(activitypub_ref) do + nil -> + nil + + canonical_ref -> + if Keyword.get(opts, :cache, true) do + AppCache.get_activitypub_message_ref(activitypub_ref, fn -> + lookup_message_by_activitypub_ref(canonical_ref) + end) + else + lookup_message_by_activitypub_ref(canonical_ref) + end + end + end + + def get_message_by_activitypub_ref(_, _), do: nil + + defp lookup_message_by_activitypub_ref(canonical_ref) when is_binary(canonical_ref) do + case message_id_by_activitypub_ref(canonical_ref, [ + :activitypub_id_canonical, + :activitypub_id, + :activitypub_url_canonical, + :activitypub_url + ]) do + id when is_integer(id) -> + load_activitypub_lookup_message(id) + + nil -> + case message_id_by_activitypub_ref_field(canonical_ref, [ + :activitypub_id, + :activitypub_url + ]) do + id when is_integer(id) -> load_activitypub_lookup_message(id) + nil -> nil + end + end + end + + defp message_id_by_activitypub_ref(ref, field_names) + when is_binary(ref) and is_list(field_names) do + Enum.find_value(field_names, &message_id_by_exact_activitypub_ref(ref, &1)) + end + + defp message_id_by_exact_activitypub_ref(ref, field_name) + when is_binary(ref) and is_atom(field_name) do + from(m in Message, + where: field(m, ^field_name) == ^ref, + select: m.id, + limit: 1 + ) + |> Repo.one() + end + + defp message_id_by_activitypub_ref_field(ref, field_names) + when is_binary(ref) and is_list(field_names) do + Enum.find_value(field_names, &message_id_by_activitypub_ref_field(ref, &1)) + end + + defp message_id_by_activitypub_ref_field(ref, field_name) + when is_binary(ref) and is_atom(field_name) do + from(m in Message, + where: + fragment( + "trim(trailing '/' from split_part(split_part(?, '#', 1), chr(63), 1))", + field(m, ^field_name) + ) == ^ref, + select: m.id, + limit: 1 + ) + |> Repo.one() + end + + defp load_activitypub_lookup_message(id) when is_integer(id) do + Message + |> Repo.get(id) + |> Repo.preload([:sender, :remote_actor]) + end + + @doc """ + Gets multiple messages by their ActivityPub IDs. + """ + def get_messages_by_activitypub_ids(activitypub_ids) when is_list(activitypub_ids) do + from(m in Message, + where: m.activitypub_id in ^activitypub_ids + ) + |> Repo.all() + end + + defp canonical_activitypub_ref(ref) do + ref + |> String.trim() + |> String.split("#", parts: 2) + |> hd() + |> String.split("?", parts: 2) + |> hd() + |> String.trim_trailing("/") + |> case do + "" -> nil + value -> value + end + end + + defp activitypub_ref_variants(ref) do + canonical_ref = canonical_activitypub_ref(ref) + + [ref, canonical_ref] + |> Enum.reject(&(&1 in ["", nil])) + |> Enum.uniq() + end + + defp activitypub_ref_lookup_values(refs) when is_list(refs) do + refs + |> Enum.filter(&is_binary/1) + |> Enum.flat_map(&activitypub_ref_variants/1) + |> Enum.uniq() + end + + @doc """ + Gets local replies to messages with the given ActivityPub IDs. + Returns local messages (where sender_id is not nil) that reply to messages + with matching activitypub_ids. + """ + def get_local_replies_to_activitypub_ids(activitypub_ids) when is_list(activitypub_ids) do + lookup_values = activitypub_ref_lookup_values(activitypub_ids) + + # Resolve parents across ids and urls so reply threads still load when the + # detail view was opened through a different AP reference for the same post. + parent_messages = + from(m in Message, + where: + m.activitypub_id in ^lookup_values or + m.activitypub_id_canonical in ^lookup_values or + m.activitypub_url in ^lookup_values or + m.activitypub_url_canonical in ^lookup_values, + select: %{id: m.id, activitypub_id: coalesce(m.activitypub_id, m.activitypub_url)} + ) + |> Repo.all() + + parent_ids = Enum.map(parent_messages, & &1.id) + parent_id_to_apid = Map.new(parent_messages, fn m -> {m.id, m.activitypub_id} end) + + if Enum.empty?(parent_ids) do + [] + else + # Get local replies (sender_id not nil means it's from a local user) + from(m in Message, + where: m.reply_to_id in ^parent_ids and not is_nil(m.sender_id), + preload: [:sender] + ) + |> Repo.all() + |> Enum.map(fn msg -> + # Attach parent ActivityPub ID for threading + Map.put(msg, :parent_activitypub_id, Map.get(parent_id_to_apid, msg.reply_to_id)) + end) + end + end + + @doc """ + Gets cached replies (local and federated) to messages with the given ActivityPub IDs. + Returns messages that reply to matched parents and annotates each with `:parent_activitypub_id` + for thread reconstruction in ActivityPub-like views. + """ + def get_cached_replies_to_activitypub_ids(activitypub_ids) when is_list(activitypub_ids) do + sanitized_ids = activitypub_ref_lookup_values(activitypub_ids) + + if Enum.empty?(sanitized_ids) do + [] + else + parent_messages = + from(m in Message, + where: + m.activitypub_id in ^sanitized_ids or + m.activitypub_id_canonical in ^sanitized_ids or + m.activitypub_url in ^sanitized_ids or + m.activitypub_url_canonical in ^sanitized_ids, + select: %{id: m.id, activitypub_id: coalesce(m.activitypub_id, m.activitypub_url)} + ) + |> Repo.all() + + parent_ids = Enum.map(parent_messages, & &1.id) + parent_id_to_apid = Map.new(parent_messages, fn m -> {m.id, m.activitypub_id} end) + + if Enum.empty?(parent_ids) do + [] + else + from(m in Message, + where: + m.reply_to_id in ^parent_ids and + is_nil(m.deleted_at) and + (m.approval_status == "approved" or is_nil(m.approval_status)), + order_by: [asc: m.inserted_at], + preload: [:sender, :remote_actor] + ) + |> Repo.all() + |> Enum.map(fn msg -> + Map.put(msg, :parent_activitypub_id, Map.get(parent_id_to_apid, msg.reply_to_id)) + end) + end + end + end + + @doc """ + Gets a message by ID. + """ + def get_message(id) when is_integer(id) do + AppCache.get_social_message(id, fn -> + Repo.get(Message, id) + end) + end + + def get_message(_id), do: nil + + def get_message!(id) when is_integer(id) do + case get_message(id) do + %Message{} = message -> message + _ -> Repo.get!(Message, id) + end + end + + @doc """ + Updates a message. + """ + def update_message(message, attrs) do + old_refs = cacheable_activitypub_refs(message) + activitypub_id = activitypub_id_from_attrs(attrs) + + message + |> Message.changeset(attrs) + |> Repo.update() + |> case do + {:ok, updated_message} = result -> + AppCache.invalidate_social_message(message.id) + AppCache.invalidate_social_message(updated_message.id) + invalidate_activitypub_ref_cache(old_refs) + invalidate_activitypub_ref_cache_for_message(updated_message) + notify_home_feed_message_changed(updated_message) + result + + {:error, %Ecto.Changeset{} = changeset} = error -> + if duplicate_activitypub_id_changeset?(changeset) && is_binary(activitypub_id) do + case get_message_by_activitypub_id(activitypub_id) do + %Message{} = existing -> {:error, {:duplicate_activitypub_id, existing}} + nil -> error + end + else + error + end + + error -> + error + end + rescue + error in Postgrex.Error -> + activitypub_id = activitypub_id_from_attrs(attrs) + + if unique_activitypub_violation?(error) && is_binary(activitypub_id) do + case get_message_by_activitypub_id(activitypub_id) do + %Message{} = existing -> {:error, {:duplicate_activitypub_id, existing}} + nil -> reraise error, __STACKTRACE__ + end + else + reraise error, __STACKTRACE__ + end + end + + defp duplicate_activitypub_id_changeset?(%Ecto.Changeset{errors: errors}) do + Keyword.has_key?(errors, :activitypub_id) + end + + @doc """ + Updates message metadata fields only (like media_metadata, engagement counts). + Safe for both regular and federated messages since it doesn't require conversation_id/sender_id. + """ + def update_message_metadata(message, attrs) do + old_refs = cacheable_activitypub_refs(message) + + message + |> Message.metadata_changeset(attrs) + |> Repo.update() + |> case do + {:ok, updated_message} = result -> + AppCache.invalidate_social_message(message.id) + AppCache.invalidate_social_message(updated_message.id) + invalidate_activitypub_ref_cache(old_refs) + invalidate_activitypub_ref_cache_for_message(updated_message) + result + + error -> + error + end + end + + defp invalidate_activitypub_ref_cache_for_message(%Message{} = message) do + message + |> cacheable_activitypub_refs() + |> invalidate_activitypub_ref_cache() + end + + defp invalidate_activitypub_ref_cache(refs) when is_list(refs) do + refs + |> Enum.each(&AppCache.invalidate_activitypub_message_ref/1) + end + + defp cacheable_activitypub_refs(%Message{} = message) do + [message.activitypub_id, message.activitypub_url] + |> Enum.flat_map(fn + ref when is_binary(ref) -> activitypub_ref_variants(ref) + _ -> [] + end) + |> Enum.uniq() + end + + @doc """ + Creates a like from a federated source. + """ + def create_federated_like(message_id, remote_actor_id, activitypub_id \\ nil) do + alias Elektrine.Messaging.FederatedLike + + # Check if already liked + existing = + Repo.get_by(FederatedLike, message_id: message_id, remote_actor_id: remote_actor_id) + + if existing do + maybe_backfill_federated_activity_id(existing, activitypub_id) + {:ok, :already_liked} + else + # Create like record + case %FederatedLike{} + |> FederatedLike.changeset(%{ + message_id: message_id, + remote_actor_id: remote_actor_id, + activitypub_id: activitypub_id + }) + |> Repo.insert() do + {:ok, _like} -> + # Increment like count + from(m in Message, + where: m.id == ^message_id, + update: [inc: [like_count: 1]] + ) + |> Repo.update_all([]) + + sync_stat_count(message_id, :like_count) + {:ok, :liked} + + {:error, %Ecto.Changeset{errors: [message_id: _]}} -> + # Race condition - already liked + {:ok, :already_liked} + + {:error, changeset} -> + {:error, changeset} + end + end + end + + @doc """ + Deletes a like from a federated source. + """ + def delete_federated_like(message_id, remote_actor_id) do + alias Elektrine.Messaging.FederatedLike + + # Delete like record + case Repo.get_by(FederatedLike, message_id: message_id, remote_actor_id: remote_actor_id) do + nil -> + {:ok, :not_liked} + + like -> + Repo.delete(like) + + # Decrement like count + from(m in Message, + where: m.id == ^message_id and m.like_count > 0, + update: [inc: [like_count: -1]] + ) + |> Repo.update_all([]) + + sync_stat_count(message_id, :like_count) + {:ok, :unliked} + end + end + + @doc """ + Creates a dislike (downvote) from a federated source. + Used by Lemmy and other platforms that support downvotes. + """ + def create_federated_dislike(message_id, remote_actor_id, activitypub_id \\ nil) do + alias Elektrine.Messaging.FederatedDislike + + # Check if already disliked + existing = + Repo.get_by(FederatedDislike, message_id: message_id, remote_actor_id: remote_actor_id) + + if existing do + maybe_backfill_federated_activity_id(existing, activitypub_id) + {:ok, :already_disliked} + else + # Create dislike record + case %FederatedDislike{} + |> FederatedDislike.changeset(%{ + message_id: message_id, + remote_actor_id: remote_actor_id, + activitypub_id: activitypub_id + }) + |> Repo.insert() do + {:ok, _dislike} -> + # Increment dislike count + from(m in Message, + where: m.id == ^message_id, + update: [inc: [dislike_count: 1]] + ) + |> Repo.update_all([]) + + sync_stat_count(message_id, :dislike_count) + {:ok, :disliked} + + {:error, %Ecto.Changeset{errors: [message_id: _]}} -> + # Race condition - already disliked + {:ok, :already_disliked} + + {:error, changeset} -> + {:error, changeset} + end + end + end + + @doc """ + Deletes a dislike from a federated source. + """ + def delete_federated_dislike(message_id, remote_actor_id) do + alias Elektrine.Messaging.FederatedDislike + + # Delete dislike record + case Repo.get_by(FederatedDislike, message_id: message_id, remote_actor_id: remote_actor_id) do + nil -> + {:ok, :not_disliked} + + dislike -> + Repo.delete(dislike) + + # Decrement dislike count + from(m in Message, + where: m.id == ^message_id and m.dislike_count > 0, + update: [inc: [dislike_count: -1]] + ) + |> Repo.update_all([]) + + sync_stat_count(message_id, :dislike_count) + {:ok, :undisliked} + end + end + + @doc """ + Creates a boost (announce) record from a federated source. + Tracks which remote actors have boosted a local post. + """ + def create_federated_boost(message_id, remote_actor_id, activitypub_id \\ nil) do + alias Elektrine.Messaging.FederatedBoost + + Repo.transaction(fn -> + message = + from(m in Message, + where: m.id == ^message_id and is_nil(m.deleted_at), + lock: "FOR UPDATE" + ) + |> Repo.one() + + if is_nil(message) do + Repo.rollback(:message_deleted) + end + + existing = + Repo.get_by(FederatedBoost, message_id: message_id, remote_actor_id: remote_actor_id) + + if existing do + maybe_backfill_federated_activity_id(existing, activitypub_id) + :already_boosted + else + case %FederatedBoost{} + |> FederatedBoost.changeset(%{ + message_id: message_id, + remote_actor_id: remote_actor_id, + activitypub_id: activitypub_id + }) + |> Repo.insert() do + {:ok, _boost} -> + from(m in Message, + where: m.id == ^message_id and is_nil(m.deleted_at), + update: [inc: [share_count: 1]] + ) + |> Repo.update_all([]) + + AppCache.invalidate_social_message(message_id) + MessageStats.upsert_counts(message_id, %{share_count: share_count(message_id)}) + :boosted + + {:error, %Ecto.Changeset{errors: [message_id: _]}} -> + :already_boosted + + {:error, changeset} -> + Repo.rollback(changeset) + end + end + end) + |> case do + {:ok, result} -> {:ok, result} + {:error, :message_deleted} -> {:ok, :ignored_deleted} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Deletes a boost from a federated source. + """ + def delete_federated_boost(message_id, remote_actor_id) do + alias Elektrine.Messaging.FederatedBoost + + # Delete boost record + case Repo.get_by(FederatedBoost, message_id: message_id, remote_actor_id: remote_actor_id) do + nil -> + {:ok, :not_boosted} + + boost -> + Repo.delete(boost) + + # Decrement share count + from(m in Message, + where: m.id == ^message_id and m.share_count > 0, + update: [inc: [share_count: -1]] + ) + |> Repo.update_all([]) + + AppCache.invalidate_social_message(message_id) + MessageStats.upsert_counts(message_id, %{share_count: share_count(message_id)}) + {:ok, :unboosted} + end + end + + defp maybe_backfill_federated_activity_id(record, activitypub_id) + when is_binary(activitypub_id) and activitypub_id != "" do + if is_nil(record.activitypub_id) do + record + |> Ecto.Changeset.change(activitypub_id: activitypub_id) + |> Repo.update() + else + {:ok, record} + end + end + + defp maybe_backfill_federated_activity_id(_record, _activitypub_id), do: :ok + + @doc """ + Creates an emoji reaction from a remote actor (EmojiReact activity). + Supports custom emoji with URLs (4th argument). + """ + def create_federated_emoji_reaction(message_id, remote_actor_id, emoji, emoji_url \\ nil) do + alias Elektrine.Social.MessageReaction + + # Check if already reacted with this emoji + existing = + Repo.get_by(MessageReaction, + message_id: message_id, + remote_actor_id: remote_actor_id, + emoji: emoji + ) + + if existing do + {:ok, :already_reacted} + else + insert_result = + try do + %MessageReaction{} + |> MessageReaction.changeset(%{ + message_id: message_id, + remote_actor_id: remote_actor_id, + emoji: emoji, + emoji_url: emoji_url, + federated: true + }) + |> Repo.insert() + rescue + err in [Postgrex.Error] -> + case err do + %Postgrex.Error{postgres: %{code: :not_null_violation}} -> + Logger.error( + "Cannot store federated emoji reaction because message_reactions.user_id is NOT NULL. " <> + "Run migrations (especially make_message_reactions_user_id_nullable) so remote reactions can persist. " <> + "Details: #{Exception.message(err)}" + ) + + {:error, :user_id_not_nullable} + + _ -> + reraise(err, __STACKTRACE__) + end + end + + # Create reaction record + case insert_result do + {:ok, reaction} -> + {:ok, reaction} + + {:error, %Ecto.Changeset{errors: [message_id: _]}} -> + # Race condition - already reacted + {:ok, :already_reacted} + + {:error, %Postgrex.Error{postgres: %{code: :not_null_violation}} = err} -> + Logger.error( + "Cannot store federated emoji reaction because message_reactions.user_id is NOT NULL. " <> + "Run migrations (especially make_message_reactions_user_id_nullable) so remote reactions can persist. " <> + "Details: #{Exception.message(err)}" + ) + + {:error, :user_id_not_nullable} + + {:error, :user_id_not_nullable} -> + {:error, :user_id_not_nullable} + + {:error, changeset} -> + {:error, changeset} + end + end + end + + @doc """ + Deletes an emoji reaction from a remote actor (Undo EmojiReact). + """ + def delete_federated_emoji_reaction(message_id, remote_actor_id, emoji) do + alias Elektrine.Social.MessageReaction + + # Delete reaction record + case Repo.get_by(MessageReaction, + message_id: message_id, + remote_actor_id: remote_actor_id, + emoji: emoji + ) do + nil -> + {:ok, :not_reacted} + + reaction -> + Repo.delete(reaction) + {:ok, :unreacted} + end + end + + @doc """ + Increments the share count for a message. + """ + def increment_share_count(message_id) do + result = + from(m in Message, + where: m.id == ^message_id, + update: [inc: [share_count: 1]] + ) + |> Repo.update_all([]) + + AppCache.invalidate_social_message(message_id) + result + end + + @doc """ + Decrements the share count for a message. + """ + def decrement_share_count(message_id) do + result = + from(m in Message, + where: m.id == ^message_id and m.share_count > 0, + update: [inc: [share_count: -1]] + ) + |> Repo.update_all([]) + + AppCache.invalidate_social_message(message_id) + result + end + + @doc """ + Increments the quote count for a message. + """ + def increment_quote_count(message_id) do + result = + from(m in Message, + where: m.id == ^message_id, + update: [inc: [quote_count: 1]] + ) + |> Repo.update_all([]) + + AppCache.invalidate_social_message(message_id) + result + end + + ## Post Loading Helpers + + @doc """ + Gets a discussion post by ID with standard preloads for display. + """ + def get_discussion_post(message_id) do + get_message(message_id) + |> Repo.preload(discussion_post_preloads()) + |> Message.decrypt_content() + end + + @doc """ + Gets a discussion post by ID with standard preloads, using force reload. + """ + def get_discussion_post!(message_id, opts \\ []) do + force = Keyword.get(opts, :force, false) + + message = if(force, do: Repo.get!(Message, message_id), else: get_message!(message_id)) + + if force do + Repo.preload(message, discussion_post_preloads(), force: true) + else + Repo.preload(message, discussion_post_preloads()) + end + |> Message.decrypt_content() + end + + @doc """ + Gets a timeline post by ID with standard preloads for display. + """ + def get_timeline_post(message_id) do + get_message(message_id) + |> Repo.preload(timeline_post_preloads()) + |> Message.decrypt_content() + end + + @doc """ + Gets a timeline post by ID with standard preloads, using force reload. + """ + def get_timeline_post!(message_id, opts \\ []) do + force = Keyword.get(opts, :force, false) + + message = if(force, do: Repo.get!(Message, message_id), else: get_message!(message_id)) + + if force do + Repo.preload(message, timeline_post_preloads(), force: true) + else + Repo.preload(message, timeline_post_preloads()) + end + |> Message.decrypt_content() + end + + @doc """ + Standard preloads for discussion posts. + """ + def discussion_post_preloads do + [ + sender: [:profile], + remote_actor: [], + conversation: [], + link_preview: [], + hashtags: [], + flair: [], + reply_to: [sender: [:profile], remote_actor: []], + quoted_message: [sender: [:profile], remote_actor: [], link_preview: []], + shared_message: [ + sender: [:profile], + conversation: [], + remote_actor: [], + link_preview: [], + poll: [options: []], + quoted_message: [sender: [:profile], remote_actor: [], link_preview: []] + ], + poll: [options: []] + ] + end + + @doc """ + Standard preloads for timeline posts. + """ + def timeline_post_preloads do + [ + sender: [:profile], + conversation: [], + remote_actor: [], + link_preview: [], + hashtags: [], + # Preload a short ancestor chain to avoid per-post lookups in timeline rendering. + reply_to: timeline_ancestor_preloads(3), + quoted_message: [sender: [:profile], remote_actor: [], link_preview: []], + shared_message: [ + sender: [:profile], + conversation: [], + remote_actor: [], + link_preview: [], + poll: [options: []], + quoted_message: [sender: [:profile], remote_actor: [], link_preview: []] + ], + poll: [options: []] + ] + end + + @doc """ + Lightweight preloads for high-traffic timeline feed queries. + """ + def timeline_feed_preloads do + [ + sender: [:profile], + remote_actor: [], + link_preview: [], + # Keep feed ancestor context cheap by loading up to three reply ancestors in bulk. + reply_to: timeline_ancestor_preloads(3), + quoted_message: [sender: [:profile], remote_actor: [], link_preview: []], + shared_message: [ + sender: [:profile], + remote_actor: [], + link_preview: [], + poll: [options: []], + quoted_message: [sender: [:profile], remote_actor: [], link_preview: []] + ], + poll: [options: []] + ] + end + + @doc """ + Standard preloads for timeline replies. + """ + def timeline_reply_preloads do + [ + sender: [:profile], + remote_actor: [], + link_preview: [], + hashtags: [], + quoted_message: [sender: [:profile], remote_actor: [], link_preview: []], + shared_message: [ + sender: [:profile], + conversation: [], + remote_actor: [], + link_preview: [], + poll: [options: []], + quoted_message: [sender: [:profile], remote_actor: [], link_preview: []] + ], + poll: [options: []] + ] + end + + @doc """ + Lightweight preloads for inline timeline reply previews. + """ + def timeline_reply_preview_preloads do + [ + sender: [:profile], + remote_actor: [], + link_preview: [], + quoted_message: [sender: [:profile], remote_actor: []] + ] + end + + defp timeline_ancestor_preloads(0), do: [sender: [:profile], remote_actor: []] + + defp timeline_ancestor_preloads(depth) when depth > 0 do + [sender: [:profile], remote_actor: [], reply_to: timeline_ancestor_preloads(depth - 1)] + end + + @doc """ + Broadcasts canonical engagement counts for a post to timeline topics. + """ + def broadcast_post_counts_updated(message_id, counts) + when is_integer(message_id) and is_map(counts) do + normalized_counts = %{ + like_count: normalize_non_negative_count(Map.get(counts, :like_count)), + share_count: normalize_non_negative_count(Map.get(counts, :share_count)), + reply_count: normalize_non_negative_count(Map.get(counts, :reply_count)), + quote_count: normalize_non_negative_count(Map.get(counts, :quote_count)) + } + + payload = {:post_counts_updated, %{message_id: message_id, counts: normalized_counts}} + + Phoenix.PubSub.broadcast(Elektrine.PubSub, "timeline:public", payload) + MessageStats.upsert_counts(message_id, normalized_counts) + + :ok + end + + defp share_count(message_id) do + from(m in Message, where: m.id == ^message_id, select: coalesce(m.share_count, 0)) + |> Repo.one() + |> Kernel.||(0) + end + + # Reads the current canonical count for one field and mirrors it into the + # MessageStats display table, so the two never drift on like/dislike/unboost. + defp sync_stat_count(message_id, field) when field in [:like_count, :dislike_count] do + current = + from(m in Message, where: m.id == ^message_id, select: field(m, ^field)) + |> Repo.one() + |> Kernel.||(0) + + MessageStats.upsert_counts(message_id, %{field => current}) + end + + defp normalize_non_negative_count(value) when is_integer(value), do: max(value, 0) + + defp normalize_non_negative_count(value) when is_binary(value) do + case Integer.parse(value) do + {parsed, _} -> max(parsed, 0) + :error -> 0 + end + end + + defp normalize_non_negative_count(_), do: 0 + + defp notify_home_feed_message_deleted(%Message{id: message_id}) when is_integer(message_id) do + module = Module.concat([Elektrine, Social, HomeFeedInvalidationWorker]) + + if Code.ensure_loaded?(module) do + _ = module.remove_message(message_id) + end + + :ok + rescue + _ -> :ok + end + + defp notify_home_feed_message_changed(%Message{id: message_id}) when is_integer(message_id) do + module = Module.concat([Elektrine, Social, HomeFeedInvalidationWorker]) + + if Code.ensure_loaded?(module) do + _ = module.message_changed(message_id) + end + + :ok + rescue + _ -> :ok + end +end diff --git a/apps/elektrine/lib/elektrine/social/poll.ex b/apps/elektrine/lib/elektrine/social/poll.ex new file mode 100644 index 0000000..83685a4 --- /dev/null +++ b/apps/elektrine/lib/elektrine/social/poll.ex @@ -0,0 +1,81 @@ +defmodule Elektrine.Social.Poll do + @moduledoc """ + Schema for polls attached to discussion posts. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "polls" do + field(:question, :string) + field(:closes_at, :utc_datetime) + field(:allow_multiple, :boolean, default: false) + field(:hide_totals, :boolean, default: false) + field(:total_votes, :integer, default: 0) + field(:voters_count, :integer, default: 0) + field(:voter_uris, {:array, :string}, default: []) + field(:last_fetched_at, :utc_datetime) + + belongs_to(:message, Elektrine.Social.Message) + has_many(:options, Elektrine.Social.PollOption, foreign_key: :poll_id) + has_many(:votes, Elektrine.Social.PollVote, foreign_key: :poll_id) + + timestamps() + end + + @doc false + def changeset(poll, attrs) do + poll + |> cast(attrs, [ + :message_id, + :question, + :closes_at, + :allow_multiple, + :hide_totals, + :total_votes, + :voters_count, + :voter_uris, + :last_fetched_at + ]) + |> validate_required([:message_id, :question]) + |> validate_length(:question, min: 3, max: 300) + |> foreign_key_constraint(:message_id) + end + + def record_voter(%__MODULE__{voter_uris: uris} = poll, actor_uri) when is_binary(actor_uri) do + if actor_uri in (uris || []) do + {:ok, poll} + else + new_uris = [actor_uri | uris || []] |> Enum.uniq() + + poll + |> Ecto.Changeset.change(%{voter_uris: new_uris, voters_count: length(new_uris)}) + |> Elektrine.Repo.update() + end + end + + def has_voted?(%__MODULE__{voter_uris: uris}, actor_uri) do + actor_uri in (uris || []) + end + + def open?(%{closes_at: nil}), do: true + + def open?(%{closes_at: %DateTime{} = closes_at}) do + DateTime.compare(DateTime.utc_now(), closes_at) == :lt + end + + def open?(%{closes_at: %NaiveDateTime{} = closes_at}) do + closes_at + |> DateTime.from_naive!("Etc/UTC") + |> then(&(DateTime.compare(DateTime.utc_now(), &1) == :lt)) + end + + def open?(%{closes_at: closes_at}) when is_binary(closes_at) do + case DateTime.from_iso8601(closes_at) do + {:ok, datetime, _offset} -> DateTime.compare(DateTime.utc_now(), datetime) == :lt + _ -> false + end + end + + def open?(_poll), do: false + def closed?(poll), do: !open?(poll) +end diff --git a/apps/elektrine/lib/elektrine/social/poll_option.ex b/apps/elektrine/lib/elektrine/social/poll_option.ex new file mode 100644 index 0000000..901f876 --- /dev/null +++ b/apps/elektrine/lib/elektrine/social/poll_option.ex @@ -0,0 +1,27 @@ +defmodule Elektrine.Social.PollOption do + @moduledoc """ + Schema for individual options in a poll. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "poll_options" do + field :option_text, :string + field :position, :integer, default: 0 + field :vote_count, :integer, default: 0 + + belongs_to :poll, Elektrine.Social.Poll + has_many :votes, Elektrine.Social.PollVote, foreign_key: :option_id + + timestamps() + end + + @doc false + def changeset(poll_option, attrs) do + poll_option + |> cast(attrs, [:poll_id, :option_text, :position, :vote_count]) + |> validate_required([:poll_id, :option_text]) + |> validate_length(:option_text, min: 1, max: 200) + |> foreign_key_constraint(:poll_id) + end +end diff --git a/apps/elektrine/lib/elektrine/social/poll_vote.ex b/apps/elektrine/lib/elektrine/social/poll_vote.ex new file mode 100644 index 0000000..160a881 --- /dev/null +++ b/apps/elektrine/lib/elektrine/social/poll_vote.ex @@ -0,0 +1,28 @@ +defmodule Elektrine.Social.PollVote do + @moduledoc """ + Schema for tracking individual votes on poll options. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "poll_votes" do + belongs_to :poll, Elektrine.Social.Poll + belongs_to :option, Elektrine.Social.PollOption + belongs_to :user, Elektrine.Accounts.User + + timestamps(updated_at: false) + end + + @doc false + def changeset(poll_vote, attrs) do + poll_vote + |> cast(attrs, [:poll_id, :option_id, :user_id]) + |> validate_required([:poll_id, :option_id, :user_id]) + |> unique_constraint([:poll_id, :user_id, :option_id], + name: :poll_votes_poll_id_user_id_option_id_index + ) + |> foreign_key_constraint(:poll_id) + |> foreign_key_constraint(:option_id) + |> foreign_key_constraint(:user_id) + end +end diff --git a/apps/elektrine/lib/elektrine/social/post_hashtag.ex b/apps/elektrine/lib/elektrine/social/post_hashtag.ex new file mode 100644 index 0000000..2a42b2f --- /dev/null +++ b/apps/elektrine/lib/elektrine/social/post_hashtag.ex @@ -0,0 +1,20 @@ +defmodule Elektrine.Social.PostHashtag do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "post_hashtags" do + belongs_to :message, Elektrine.Social.Message + belongs_to :hashtag, Elektrine.Social.Hashtag + + timestamps(updated_at: false) + end + + @doc false + def changeset(post_hashtag, attrs) do + post_hashtag + |> cast(attrs, [:message_id, :hashtag_id]) + |> validate_required([:message_id, :hashtag_id]) + |> unique_constraint([:message_id, :hashtag_id]) + end +end diff --git a/apps/elektrine/lib/elektrine/social/scrobble.ex b/apps/elektrine/lib/elektrine/social/scrobble.ex new file mode 100644 index 0000000..f00bd15 --- /dev/null +++ b/apps/elektrine/lib/elektrine/social/scrobble.ex @@ -0,0 +1,73 @@ +defmodule Elektrine.Social.Scrobble do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + alias Elektrine.Security.URLValidator + + @visibilities ~w(public unlisted followers private) + + schema "social_scrobbles" do + field :title, :string + field :artist, :string + field :album, :string + field :length, :integer + field :external_link, :string + field :visibility, :string, default: "public" + + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + def changeset(scrobble, attrs) do + scrobble + |> cast(attrs, [:user_id, :title, :artist, :album, :length, :external_link, :visibility]) + |> normalize_blank_strings([:title, :artist, :album, :external_link]) + |> validate_required([:user_id, :title, :visibility]) + |> validate_length(:title, min: 1, max: 300) + |> validate_length(:artist, max: 300) + |> validate_length(:album, max: 300) + |> validate_number(:length, greater_than_or_equal_to: 0) + |> validate_length(:external_link, max: 2_048) + |> validate_inclusion(:visibility, @visibilities) + |> validate_external_link() + |> foreign_key_constraint(:user_id) + end + + defp normalize_blank_strings(changeset, fields) do + Enum.reduce(fields, changeset, fn field, acc -> + update_change(acc, field, fn + value when is_binary(value) -> + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + + value -> + value + end) + end) + end + + defp validate_external_link(changeset) do + validate_change(changeset, :external_link, fn :external_link, value -> + uri = URI.parse(value) + + cond do + uri.scheme not in ["http", "https"] -> + [external_link: "must be an http or https URL"] + + !is_binary(uri.host) or uri.host == "" -> + [external_link: "must include a host"] + + URLValidator.private_ip?(uri.host) -> + [external_link: "must be a public URL"] + + true -> + [] + end + end) + end +end diff --git a/apps/elektrine/lib/elektrine/social/scrobbles.ex b/apps/elektrine/lib/elektrine/social/scrobbles.ex new file mode 100644 index 0000000..738b7a9 --- /dev/null +++ b/apps/elektrine/lib/elektrine/social/scrobbles.ex @@ -0,0 +1,88 @@ +defmodule Elektrine.Social.Scrobbles do + @moduledoc """ + Music listen/scrobble records for social API clients. + """ + + import Ecto.Query + + alias Elektrine.Accounts.User + alias Elektrine.Repo + alias Elektrine.Social.Scrobble + + @default_limit 20 + @max_limit 80 + + def create_scrobble(%User{} = user, attrs) when is_map(attrs) do + attrs = + attrs + |> stringify_keys() + |> Map.put("user_id", user.id) + |> Map.put_new("visibility", "public") + |> normalize_external_link_alias() + + %Scrobble{} + |> Scrobble.changeset(attrs) + |> Repo.insert() + end + + def list_public_scrobbles(%User{id: user_id}, params \\ %{}) do + limit = parse_limit(params["limit"] || params[:limit]) + + Scrobble + |> where([scrobble], scrobble.user_id == ^user_id) + |> where([scrobble], scrobble.visibility in ["public", "unlisted"]) + |> maybe_max_id(params["max_id"] || params[:max_id]) + |> maybe_since_id(params["since_id"] || params[:since_id]) + |> order_by([scrobble], desc: scrobble.id) + |> limit(^limit) + |> Repo.all() + |> Repo.preload(:user) + end + + defp maybe_max_id(query, nil), do: query + defp maybe_max_id(query, ""), do: query + + defp maybe_max_id(query, value) do + case parse_integer(value) do + nil -> query + id -> where(query, [scrobble], scrobble.id < ^id) + end + end + + defp maybe_since_id(query, nil), do: query + defp maybe_since_id(query, ""), do: query + + defp maybe_since_id(query, value) do + case parse_integer(value) do + nil -> query + id -> where(query, [scrobble], scrobble.id > ^id) + end + end + + defp parse_limit(value) do + case parse_integer(value) do + nil -> @default_limit + limit -> limit |> max(1) |> min(@max_limit) + end + end + + defp parse_integer(value) when is_integer(value), do: value + + defp parse_integer(value) when is_binary(value) do + case Integer.parse(value) do + {int, ""} -> int + _ -> nil + end + end + + defp parse_integer(_), do: nil + + defp normalize_external_link_alias(%{"external_link" => _} = attrs), do: attrs + + defp normalize_external_link_alias(%{"externalLink" => external_link} = attrs), + do: Map.put(attrs, "external_link", external_link) + + defp normalize_external_link_alias(attrs), do: attrs + + defp stringify_keys(attrs), do: Map.new(attrs, fn {key, value} -> {to_string(key), value} end) +end diff --git a/apps/elektrine/lib/elektrine/static_sites.ex b/apps/elektrine/lib/elektrine/static_sites.ex new file mode 100644 index 0000000..7afae4d --- /dev/null +++ b/apps/elektrine/lib/elektrine/static_sites.ex @@ -0,0 +1,1215 @@ +defmodule Elektrine.StaticSites do + @moduledoc """ + Context for managing static site files. + Handles uploading, storing, and serving user-uploaded static site content. + """ + + import Ecto.Query, warn: false + alias Elektrine.Accounts.User + alias Elektrine.Profiles.StaticSiteDeploy + alias Elektrine.Profiles.StaticSiteDeployment + alias Elektrine.Profiles.StaticSiteFile + alias Elektrine.Profiles.UserProfile + alias Elektrine.Repo + + require Logger + + # Up to 1000 files + @max_files 1000 + # Maximum decompression ratio to prevent zip bombs (100:1) + @max_decompression_ratio 100 + # Maximum size of a single file (10MB, aligned with StaticSiteFile changeset) + @max_single_file_size 10_000_000 + # Maximum accepted zip upload and extracted payload size. + @max_zip_archive_size 100 * 1024 * 1024 + @default_max_zip_uncompressed_size 100 * 1024 * 1024 + @auto_site_dirs ~w(dist build public out zig-out .) + + # Magic bytes for content type validation + @magic_bytes %{ + # Images + "image/png" => <<0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A>>, + "image/jpeg" => <<0xFF, 0xD8, 0xFF>>, + "image/gif" => <<"GIF">>, + "image/webp" => <<"RIFF">> + # Note: SVG and text files validated differently (as text) + } + + # Allowed file extensions and their expected MIME types + @allowed_extensions %{ + ".html" => "text/html", + ".htm" => "text/html", + ".css" => "text/css", + ".js" => "application/javascript", + ".mjs" => "application/javascript", + ".json" => "application/json", + ".webmanifest" => "application/manifest+json", + ".xml" => "application/xml", + ".rss" => "application/rss+xml", + ".atom" => "application/atom+xml", + ".map" => "application/json", + ".txt" => "text/plain", + ".md" => "text/markdown", + ".png" => "image/png", + ".jpg" => "image/jpeg", + ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".avif" => "image/avif", + ".svg" => "image/svg+xml", + ".ico" => "image/x-icon", + ".bmp" => "image/bmp", + ".woff" => "font/woff", + ".woff2" => "font/woff2", + ".ttf" => "font/ttf", + ".otf" => "font/otf", + ".eot" => "application/vnd.ms-fontobject", + ".wasm" => "application/wasm", + ".pdf" => "application/pdf", + ".mp3" => "audio/mpeg", + ".wav" => "audio/wav", + ".ogg" => "audio/ogg", + ".mp4" => "video/mp4", + ".webm" => "video/webm" + } + + @doc """ + Gets a static site file by user_id and path. + """ + def get_file(user_id, path) do + Repo.get_by(StaticSiteFile, user_id: user_id, path: path) + end + + @doc """ + Gets all static site files for a user. + """ + def list_files(user_id) do + StaticSiteFile + |> where([f], f.user_id == ^user_id) + |> order_by([f], asc: f.path) + |> Repo.all() + end + + @doc """ + Gets total storage used by a user's static site. + """ + def total_storage_used(user_id) do + StaticSiteFile + |> where([f], f.user_id == ^user_id) + |> select([f], sum(f.size)) + |> Repo.one() || 0 + end + + @doc """ + Gets file count for a user's static site. + """ + def file_count(user_id) do + StaticSiteFile + |> where([f], f.user_id == ^user_id) + |> Repo.aggregate(:count) + end + + def get_static_site_deployment(user_id) do + Repo.get_by(StaticSiteDeployment, user_id: user_id, provider: "github") + end + + def list_static_site_deploys(deployment, limit \\ 10) + + def list_static_site_deploys(%StaticSiteDeployment{} = deployment, limit) do + StaticSiteDeploy + |> where([d], d.deployment_id == ^deployment.id) + |> order_by([d], desc: d.deployed_at, desc: d.inserted_at) + |> limit(^limit) + |> Repo.all() + end + + def list_static_site_deploys(nil, _limit), do: [] + + def get_static_site_deploy_for_user(user_id, deploy_id) do + StaticSiteDeploy + |> where([d], d.user_id == ^user_id and d.id == ^deploy_id) + |> Repo.one() + end + + def get_static_site_deployment_by_github_repo(repo_owner, repo_name) do + Repo.get_by(StaticSiteDeployment, + provider: "github", + repo_owner: normalize_repo_part(repo_owner), + repo_name: normalize_repo_name(repo_name) + ) + |> Repo.preload(:user) + end + + def upsert_github_deployment(user_id, attrs) do + attrs = + attrs + |> Map.put(:user_id, user_id) + |> Map.put(:provider, "github") + + case get_static_site_deployment(user_id) do + nil -> + %StaticSiteDeployment{} + |> StaticSiteDeployment.changeset(attrs) + |> Repo.insert() + + deployment -> + deployment + |> StaticSiteDeployment.changeset(attrs) + |> Repo.update() + end + end + + def mark_deployment_deployed(%StaticSiteDeployment{} = deployment, attrs \\ %{}) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + deployment + |> StaticSiteDeployment.changeset( + Map.merge( + %{ + deploy_status: "deployed", + last_deploy_error: nil, + last_deploy_log: Map.get(attrs, :log) || Map.get(attrs, "log"), + last_deployed_at: now + }, + commit_deployment_attrs(attrs) + ) + ) + |> Repo.update() + end + + def mark_deployment_queued(%StaticSiteDeployment{} = deployment) do + update_deployment_status(deployment, "queued", nil) + end + + def mark_deployment_deploying(%StaticSiteDeployment{} = deployment) do + update_deployment_status(deployment, "deploying", nil) + end + + def mark_deployment_failed(%StaticSiteDeployment{} = deployment, reason) do + update_deployment_status(deployment, "failed", inspect(reason)) + end + + def record_static_site_deploy(%StaticSiteDeployment{} = deployment, attrs) do + %StaticSiteDeploy{} + |> StaticSiteDeploy.changeset( + attrs + |> Map.new() + |> Map.merge(%{ + user_id: deployment.user_id, + deployment_id: deployment.id, + repo_owner: deployment.repo_owner, + repo_name: deployment.repo_name, + branch: deployment.branch, + site_dir: deployment.site_dir + }) + ) + |> Repo.insert() + end + + def snapshot_current_site(%User{} = user, %StaticSiteDeployment{} = deployment, attrs \\ %{}) do + files = list_files(user.id) + + with {:ok, zip_binary} <- current_site_zip(files), + storage_key <- generate_snapshot_storage_key(user.id, deployment.id), + :ok <- put_storage(storage_key, zip_binary, "application/zip") do + record_static_site_deploy( + deployment, + attrs + |> Map.new() + |> Map.merge(%{ + status: Map.get(attrs, :status, "deployed"), + snapshot_storage_key: storage_key, + file_count: length(files), + storage_bytes: Enum.reduce(files, 0, &(&1.size + &2)), + deployed_at: + Map.get(attrs, :deployed_at) || DateTime.utc_now() |> DateTime.truncate(:second) + }) + ) + end + end + + def rollback_static_site(%User{} = user, %StaticSiteDeploy{} = deploy) do + if deploy.snapshot_storage_key && deploy.user_id == user.id do + with {:ok, zip_binary} <- get_storage(deploy.snapshot_storage_key), + {:ok, count} <- replace_with_zip(user, zip_binary), + %StaticSiteDeployment{} = deployment <- + Repo.get(StaticSiteDeployment, deploy.deployment_id), + {:ok, _history} <- + snapshot_current_site(user, deployment, %{ + status: "rolled_back", + trigger: "rollback", + commit_sha: deploy.commit_sha, + commit_url: deploy.commit_url, + commit_message: deploy.commit_message, + log: "Rolled back to deploy ##{deploy.id}", + deployed_at: DateTime.utc_now() |> DateTime.truncate(:second) + }), + {:ok, deployment} <- + deployment + |> StaticSiteDeployment.changeset(%{ + deploy_status: "deployed", + last_deploy_error: nil, + last_deploy_log: "Rolled back to deploy ##{deploy.id}", + last_commit_sha: deploy.commit_sha, + last_commit_url: deploy.commit_url, + last_commit_message: deploy.commit_message, + last_deployed_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.update() do + {:ok, %{deployment: deployment, file_count: count}} + end + else + {:error, :snapshot_not_found} + end + end + + def update_deployment_webhook(%StaticSiteDeployment{} = deployment, webhook_id) do + deployment + |> StaticSiteDeployment.changeset(%{webhook_id: webhook_id}) + |> Repo.update() + end + + def enqueue_github_deploy(%StaticSiteDeployment{} = deployment) do + with {:ok, deployment} <- mark_deployment_queued(deployment) do + %{deployment_id: deployment.id} + |> Elektrine.StaticSites.GitHubDeployWorker.new() + |> Oban.insert() + end + end + + @doc """ + Uploads a static site file. + """ + def upload_file(user, path, binary, _content_type) do + size = byte_size(binary) + + with {:ok, path} <- normalize_site_path(path), + :ok <- validate_limits(user.id, size), + :ok <- validate_file_extension(path), + content_type <- static_site_content_type(path), + :ok <- validate_content_type(path, binary, content_type), + storage_key <- generate_storage_key(user.id, path), + :ok <- put_storage(storage_key, binary, content_type) do + # Delete existing file at this path if any + case get_file(user.id, path) do + nil -> :ok + existing -> delete_file_record(existing) + end + + # Create new file record + changeset = + StaticSiteFile.changeset(%StaticSiteFile{}, %{ + user_id: user.id, + path: path, + storage_key: storage_key, + content_type: content_type, + size: size + }) + + case Repo.insert(changeset) do + {:ok, file} -> + {:ok, file} + + {:error, _changeset} = error -> + delete_storage(storage_key) + error + end + end + end + + defp normalize_site_path(path) when is_binary(path) do + decoded = URI.decode(path) |> String.trim() + + normalized = + decoded + |> String.replace("\\", "/") + |> Path.expand("/") + |> String.trim_leading("/") + + cond do + decoded == "" -> + {:error, :invalid_path} + + String.starts_with?(decoded, ["/", "\\"]) -> + {:error, :invalid_path} + + String.contains?(decoded, ["..", "\0", "//", "\\"]) -> + {:error, :invalid_path} + + normalized == "" -> + {:error, :invalid_path} + + not Regex.match?(~r/^[A-Za-z0-9._\-\/]+$/, normalized) -> + {:error, :invalid_path} + + true -> + {:ok, normalized} + end + end + + defp normalize_site_path(_), do: {:error, :invalid_path} + + # Validates that the file extension is allowed + defp validate_file_extension(path) do + ext = Path.extname(path) |> String.downcase() + + if Map.has_key?(@allowed_extensions, ext) do + :ok + else + {:error, :invalid_file_type} + end + end + + defp static_site_content_type(path) do + path + |> Path.extname() + |> String.downcase() + |> then(&Map.fetch!(@allowed_extensions, &1)) + end + + # Validates that the content matches the declared MIME type + defp validate_content_type(path, binary, _declared_content_type) do + ext = Path.extname(path) |> String.downcase() + expected_mime = Map.get(@allowed_extensions, ext) + + cond do + # Text-based files - verify they're valid UTF-8 text + expected_mime in [ + "text/html", + "text/css", + "application/javascript", + "application/json", + "application/manifest+json", + "application/xml", + "application/rss+xml", + "application/atom+xml", + "text/markdown", + "text/plain" + ] -> + if String.valid?(binary) or valid_utf8_with_bom?(binary) do + :ok + else + Logger.warning("Static site upload rejected: #{path} - invalid text encoding") + {:error, :invalid_content} + end + + # SVG - must be valid XML text + expected_mime == "image/svg+xml" -> + if String.valid?(binary) and String.contains?(binary, " + expected_magic = Map.get(@magic_bytes, expected_mime) + + if binary_part_safe(binary, 0, byte_size(expected_magic)) == expected_magic do + :ok + else + Logger.warning( + "Static site upload rejected: #{path} - magic bytes mismatch for #{expected_mime}" + ) + + {:error, :invalid_content} + end + + # ICO files - check for valid ICO header + expected_mime == "image/x-icon" -> + if byte_size(binary) >= 4 and + binary_part_safe(binary, 0, 4) in [<<0, 0, 1, 0>>, <<0, 0, 2, 0>>] do + :ok + else + Logger.warning("Static site upload rejected: #{path} - invalid ICO header") + {:error, :invalid_content} + end + + # Font files - basic validation (non-empty binary) + expected_mime in [ + "font/woff", + "font/woff2", + "font/ttf", + "font/otf", + "application/vnd.ms-fontobject", + "application/wasm", + "application/pdf", + "image/avif", + "image/bmp", + "audio/mpeg", + "audio/wav", + "audio/ogg", + "video/mp4", + "video/webm" + ] -> + if byte_size(binary) > 0 do + :ok + else + {:error, :invalid_content} + end + + true -> + {:error, :invalid_file_type} + end + end + + defp valid_utf8_with_bom?(binary) do + # Check for UTF-8 BOM and strip it + case binary do + <<0xEF, 0xBB, 0xBF, rest::binary>> -> String.valid?(rest) + _ -> false + end + end + + defp binary_part_safe(binary, start, length) do + if byte_size(binary) >= start + length do + binary_part(binary, start, length) + else + <<>> + end + end + + defp put_storage(key, binary, content_type) do + case get_storage_config() do + {:local, dir} -> + path = Path.join(dir, key) + + with :ok <- File.mkdir_p(Path.dirname(path)), + :ok <- File.write(path, binary), + # Store content type in a sidecar file for local storage + :ok <- File.write(path <> ".meta", content_type) do + :ok + else + {:error, reason} -> {:error, {:upload_failed, reason}} + end + + {:s3, bucket} -> + case ExAws.S3.put_object(bucket, key, binary, content_type: content_type) + |> ExAws.request() do + {:ok, _} -> :ok + {:error, reason} -> {:error, {:upload_failed, reason}} + end + end + end + + defp get_storage(key) do + case get_storage_config() do + {:local, dir} -> + path = Path.join(dir, key) + + if File.exists?(path) do + {:ok, File.read!(path)} + else + {:error, :not_found} + end + + {:s3, bucket} -> + case ExAws.S3.get_object(bucket, key) |> ExAws.request() do + {:ok, %{body: body}} -> {:ok, body} + {:error, reason} -> {:error, reason} + end + end + end + + defp delete_storage(key) do + case get_storage_config() do + {:local, dir} -> + path = Path.join(dir, key) + File.rm(path) + File.rm(path <> ".meta") + :ok + + {:s3, bucket} -> + ExAws.S3.delete_object(bucket, key) |> ExAws.request() + :ok + end + end + + defp get_storage_config do + config = Application.get_env(:elektrine, :uploads, []) + + case Keyword.get(config, :adapter) do + :local -> {:local, Keyword.get(config, :uploads_dir, "tmp/uploads")} + _ -> {:s3, Keyword.get(config, :bucket)} + end + end + + defp normalize_repo_part(value) when is_binary(value), + do: value |> String.trim() |> String.downcase() + + defp normalize_repo_part(value), do: value + + defp normalize_repo_name(value) when is_binary(value), + do: value |> normalize_repo_part() |> String.replace_suffix(".git", "") + + defp normalize_repo_name(value), do: value + + @doc """ + Uploads multiple files from a zip archive. + """ + def upload_zip(user, zip_binary) do + with {:ok, entries} <- preflight_zip(user.id, zip_binary), + :ok <- validate_zip_limits(user.id, entries), + :ok <- validate_zip_contents(zip_binary, entries) do + upload_zip_entries(user, zip_binary, entries) + end + end + + @doc """ + Replaces a user's static site with files from a zip archive. + + The archive is preflighted before existing files are deleted, so invalid zips + do not clear the currently published site. + """ + def replace_with_zip(user, zip_binary) do + with {:ok, entries} <- preflight_zip(user.id, zip_binary), + :ok <- validate_zip_limits(user.id, entries), + :ok <- validate_zip_contents(zip_binary, entries), + {_count, nil} <- delete_all_files(user.id) do + upload_zip_entries(user, zip_binary, entries) + else + {_count, error} when not is_nil(error) -> {:error, error} + error -> error + end + end + + @doc """ + Replaces a user's static site with a selected folder from a GitHub archive zip. + """ + def replace_with_repo_archive(user, zip_binary, site_dir \\ "auto") do + with {:ok, site_zip_binary} <- repo_archive_site_zip(zip_binary, site_dir) do + replace_with_zip(user, site_zip_binary) + end + end + + @doc """ + Deletes a static site file. + """ + def delete_file(user_id, path) do + case get_file(user_id, path) do + nil -> + {:error, :not_found} + + file -> + delete_storage(file.storage_key) + Repo.delete(file) + end + end + + @doc """ + Deletes all static site files for a user. + """ + def delete_all_files(user_id) do + files = list_files(user_id) + + Enum.each(files, fn file -> + delete_storage(file.storage_key) + end) + + StaticSiteFile + |> where([f], f.user_id == ^user_id) + |> Repo.delete_all() + end + + @doc """ + Gets file content for serving. + """ + def get_file_content(file) do + get_storage(file.storage_key) + end + + @doc """ + Sets the profile to static mode. + """ + def enable_static_mode(user_id) do + case Repo.get_by(UserProfile, user_id: user_id) do + nil -> + {:error, :profile_not_found} + + profile -> + profile + |> Ecto.Changeset.change(profile_mode: "static") + |> Repo.update() + end + end + + @doc """ + Sets the profile to builder mode. + """ + def enable_builder_mode(user_id) do + case Repo.get_by(UserProfile, user_id: user_id) do + nil -> + {:error, :profile_not_found} + + profile -> + profile + |> Ecto.Changeset.change(profile_mode: "builder") + |> Repo.update() + end + end + + # Private functions + + defp validate_limits(user_id, new_file_size) do + current_usage = total_storage_used(user_id) + current_count = file_count(user_id) + storage_limit = user_storage_limit(user_id) + + cond do + current_usage + new_file_size > storage_limit -> + {:error, :storage_limit_exceeded} + + current_count >= @max_files -> + {:error, :file_limit_exceeded} + + true -> + :ok + end + end + + defp update_deployment_status(%StaticSiteDeployment{} = deployment, status, error) do + deployment + |> StaticSiteDeployment.changeset(%{ + deploy_status: status, + last_deploy_error: error, + last_deploy_log: deploy_status_log(status, error) + }) + |> Repo.update() + end + + defp deploy_status_log(status, nil), do: "Deployment #{status}" + defp deploy_status_log(status, error), do: "Deployment #{status}: #{error}" + + defp commit_deployment_attrs(attrs) do + %{ + last_commit_sha: Map.get(attrs, :commit_sha) || Map.get(attrs, "commit_sha"), + last_commit_url: Map.get(attrs, :commit_url) || Map.get(attrs, "commit_url"), + last_commit_message: Map.get(attrs, :commit_message) || Map.get(attrs, "commit_message") + } + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + end + + defp current_site_zip([]), do: {:error, :no_files_to_snapshot} + + defp current_site_zip(files) do + files + |> Enum.reduce_while({:ok, []}, fn file, {:ok, entries} -> + case get_file_content(file) do + {:ok, content} -> {:cont, {:ok, [{String.to_charlist(file.path), content} | entries]}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + {:ok, entries} -> create_site_zip(Enum.reverse(entries)) + {:error, reason} -> {:error, reason} + end + end + + defp generate_snapshot_storage_key(user_id, deployment_id) do + random_bytes = :crypto.strong_rand_bytes(16) + hex = Base.encode16(random_bytes, case: :lower) + "static_site_snapshots/#{user_id}/#{deployment_id}/#{hex}.zip" + end + + defp repo_archive_site_zip(zip_binary, site_dir) do + case :zip.zip_open(zip_binary, [:memory]) do + {:ok, handle} -> + try do + with {:ok, file_list} <- :zip.zip_list_dir(handle), + zip_entries <- zip_file_entries(file_list), + {:ok, entries} <- repo_archive_entries(zip_entries), + {:ok, prefix} <- resolve_site_dir(entries, site_dir), + {:ok, files} <- repo_archive_site_files(handle, entries, prefix) do + create_site_zip(files) + else + {:error, reason} -> {:error, reason} + {:zip_error, reason} -> {:error, reason} + end + after + :zip.zip_close(handle) + end + + {:error, reason} -> + {:error, {:invalid_zip, reason}} + end + end + + defp repo_archive_entries(zip_entries) do + with {:ok, entries} <- collect_zip_entries(zip_entries), + normalized <- normalize_repo_archive_entries(entries), + :ok <- validate_zip_entry_count(normalized) do + {:ok, normalized} + end + end + + defp normalize_repo_archive_entries(entries) do + all_files = Enum.map(entries, &{&1.raw_path, ""}) + + Enum.map(entries, fn entry -> + Map.put(entry, :repo_path, normalize_zip_path(entry.raw_path, all_files)) + end) + end + + defp resolve_site_dir(entries, site_dir) do + paths = MapSet.new(Enum.map(entries, & &1.repo_path)) + + site_dir + |> normalize_repo_site_dir() + |> candidate_site_dirs() + |> Enum.find(&site_dir_has_index?(paths, &1)) + |> case do + nil -> {:error, :site_dir_not_found} + prefix -> {:ok, prefix} + end + end + + defp normalize_repo_site_dir(value) when is_binary(value) do + value = String.trim(value) + + cond do + value == "" -> "auto" + String.downcase(value) == "auto" -> "auto" + String.starts_with?(value, ["/", "\\"]) -> "auto" + String.contains?(value, ["..", "\0", "\\"]) -> "auto" + true -> String.trim(value, "/") + end + end + + defp normalize_repo_site_dir(_value), do: "auto" + + defp candidate_site_dirs("auto"), do: @auto_site_dirs + defp candidate_site_dirs(site_dir), do: [site_dir] + + defp site_dir_has_index?(paths, "."), do: MapSet.member?(paths, "index.html") + + defp site_dir_has_index?(paths, site_dir) do + MapSet.member?(paths, site_dir <> "/index.html") + end + + defp repo_archive_site_files(handle, entries, prefix) do + selected_entries = + Enum.filter(entries, &(repo_archive_site_path(&1.repo_path, prefix) != nil)) + + total_size = Enum.reduce(selected_entries, 0, &(&1.size + &2)) + + if total_size > max_zip_uncompressed_size() do + {:error, :storage_limit_exceeded} + else + selected_entries + |> Enum.reduce_while({:ok, []}, fn entry, {:ok, files} -> + case repo_archive_site_path(entry.repo_path, prefix) do + nil -> + {:cont, {:ok, files}} + + path -> + with {:ok, content} <- zip_entry_content(handle, entry), + {:ok, path} <- normalize_site_path(path), + :ok <- validate_file_extension(path) do + {:cont, {:ok, [{String.to_charlist(path), content} | files]}} + else + {:error, reason} -> {:halt, {:error, reason}} + end + end + end) + |> case do + {:ok, []} -> {:error, :site_dir_not_found} + {:ok, files} -> {:ok, Enum.reverse(files)} + {:error, reason} -> {:error, reason} + end + end + end + + defp repo_archive_site_path(path, "."), do: path + + defp repo_archive_site_path(path, prefix) do + prefix = prefix <> "/" + + if String.starts_with?(path, prefix) do + String.replace_prefix(path, prefix, "") + end + end + + defp create_site_zip(files) do + case :zip.create(~c"site.zip", files, [:memory]) do + {:ok, {_name, zip_binary}} -> {:ok, zip_binary} + {:error, reason} -> {:error, {:invalid_zip, reason}} + end + end + + defp validate_zip_limits(user_id, entries) do + total_size = Enum.reduce(entries, 0, fn entry, acc -> acc + entry.size end) + new_file_count = length(entries) + current_file_count = file_count(user_id) + current_usage = total_storage_used(user_id) + storage_limit = user_storage_limit(user_id) + + cond do + total_size > max_zip_uncompressed_size() -> + {:error, :storage_limit_exceeded} + + current_usage + total_size > storage_limit -> + {:error, :storage_limit_exceeded} + + current_file_count + new_file_count > @max_files -> + {:error, :file_limit_exceeded} + + true -> + :ok + end + end + + defp validate_zip_contents(zip_binary, entries) do + case :zip.zip_open(zip_binary, [:memory]) do + {:ok, handle} -> + try do + entries + |> Enum.reduce_while({:ok, 0}, fn entry, {:ok, extracted_size} -> + with {:ok, content} <- zip_entry_content(handle, entry), + new_extracted_size <- extracted_size + byte_size(content), + :ok <- validate_extracted_zip_size(new_extracted_size), + content_type <- static_site_content_type(entry.path), + :ok <- validate_content_type(entry.path, content, content_type) do + {:cont, {:ok, new_extracted_size}} + else + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + {:ok, _extracted_size} -> :ok + {:error, reason} -> {:error, reason} + end + after + :zip.zip_close(handle) + end + + {:error, reason} -> + {:error, {:invalid_zip, reason}} + end + end + + defp generate_storage_key(user_id, path) do + # Use cryptographically secure random bytes instead of timestamp + random_bytes = :crypto.strong_rand_bytes(16) + hex = Base.encode16(random_bytes, case: :lower) + # Sanitize filename to prevent path traversal in S3 + safe_filename = Path.basename(path) |> String.replace(~r/[^a-zA-Z0-9_\-\.]/, "_") + "static_sites/#{user_id}/#{hex}/#{safe_filename}" + end + + defp preflight_zip(user_id, zip_binary) do + zip_size = byte_size(zip_binary) + storage_limit = user_storage_limit(user_id) + + if zip_size > @max_zip_archive_size do + Logger.warning("Zip archive too large: #{zip_size} bytes exceeds limit") + {:error, :storage_limit_exceeded} + else + do_preflight_zip(user_id, zip_binary, zip_size, storage_limit) + end + end + + defp do_preflight_zip(_user_id, zip_binary, zip_size, storage_limit) do + case :zip.zip_open(zip_binary, [:memory]) do + {:ok, handle} -> + try do + with {:ok, file_list} <- :zip.zip_list_dir(handle), + zip_entries <- zip_file_entries(file_list), + :ok <- validate_zip_entry_count(zip_entries), + {:ok, entries} <- build_zip_entries(zip_entries), + :ok <- validate_zip_uncompressed_size(entries, zip_size, storage_limit) do + {:ok, entries} + else + {:error, reason} -> {:error, {:invalid_zip, reason}} + {:zip_error, reason} -> {:error, reason} + end + after + :zip.zip_close(handle) + end + + {:error, reason} -> + {:error, {:invalid_zip, reason}} + end + end + + defp zip_file_entries(file_list) do + Enum.filter(file_list, fn + {:zip_file, _, _, _, _, _} -> true + _ -> false + end) + end + + defp validate_zip_entry_count(zip_entries) do + if length(zip_entries) > @max_files do + Logger.warning("Zip contains too many entries: #{length(zip_entries)}") + {:zip_error, :file_limit_exceeded} + else + :ok + end + end + + defp build_zip_entries(zip_entries) do + with {:ok, entries} <- collect_zip_entries(zip_entries) do + normalize_zip_entries(entries) + end + end + + defp collect_zip_entries(zip_entries) do + zip_entries + |> Enum.reduce_while({:ok, []}, fn {:zip_file, zip_name, file_info, _comment, _offset, + _comp_size}, + {:ok, entries} -> + with {:ok, raw_path} <- zip_entry_name(zip_name), + {:ok, entry} <- zip_entry(zip_name, raw_path, file_info) do + case entry do + :skip -> {:cont, {:ok, entries}} + entry -> {:cont, {:ok, [entry | entries]}} + end + else + {:error, reason} -> {:halt, {:zip_error, reason}} + end + end) + |> case do + {:ok, entries} -> {:ok, Enum.reverse(entries)} + {:zip_error, reason} -> {:zip_error, reason} + end + end + + defp zip_entry(zip_name, raw_path, file_info) do + type = zip_entry_type(file_info) + + cond do + skippable_zip_path?(raw_path) or zip_entry_directory?(raw_path, type) -> + {:ok, :skip} + + type != :regular -> + {:error, :invalid_zip_entry} + + true -> + case zip_entry_size(file_info) do + size when is_integer(size) and size >= 0 and size <= @max_single_file_size -> + {:ok, %{zip_name: zip_name, raw_path: raw_path, size: size}} + + size when is_integer(size) and size > @max_single_file_size -> + {:error, :file_too_large} + + _ -> + {:error, :invalid_zip_entry} + end + end + end + + defp zip_entry_name(name) when is_binary(name), do: {:ok, name} + + defp zip_entry_name(name) when is_list(name) do + {:ok, List.to_string(name)} + rescue + _ -> {:error, :invalid_path} + end + + defp zip_entry_name(_name), do: {:error, :invalid_path} + + defp zip_entry_size( + {:file_info, size, _type, _access, _atime, _mtime, _ctime, _mode, _links, _major_device, + _minor_device, _inode, _uid, _gid} + ), + do: size + + defp zip_entry_size(_file_info), do: :unknown + + defp zip_entry_type( + {:file_info, _size, type, _access, _atime, _mtime, _ctime, _mode, _links, _major_device, + _minor_device, _inode, _uid, _gid} + ), + do: type + + defp zip_entry_type(_file_info), do: :unknown + + defp zip_entry_directory?(path, type), do: type == :directory or String.ends_with?(path, "/") + + defp skippable_zip_path?(path) do + String.contains?(path, "__MACOSX") or String.starts_with?(Path.basename(path), ".") + end + + defp normalize_zip_entries([]), do: {:ok, []} + + defp normalize_zip_entries(entries) do + all_files = Enum.map(entries, &{&1.raw_path, ""}) + + entries + |> Enum.reduce_while({:ok, [], MapSet.new()}, fn entry, + {:ok, normalized_entries, seen_paths} -> + raw_path = normalize_zip_path(entry.raw_path, all_files) + + with {:ok, path} <- normalize_site_path(raw_path), + :ok <- validate_file_extension(path), + :ok <- validate_unique_zip_path(path, seen_paths) do + {:cont, + {:ok, [Map.put(entry, :path, path) | normalized_entries], MapSet.put(seen_paths, path)}} + else + {:error, reason} -> {:halt, {:zip_error, reason}} + end + end) + |> case do + {:ok, entries, _seen_paths} -> {:ok, Enum.reverse(entries)} + {:zip_error, reason} -> {:zip_error, reason} + end + end + + defp validate_unique_zip_path(path, seen_paths) do + if MapSet.member?(seen_paths, path) do + {:error, :duplicate_zip_path} + else + :ok + end + end + + defp validate_zip_uncompressed_size(entries, zip_size, storage_limit) do + total_uncompressed = Enum.reduce(entries, 0, fn entry, acc -> acc + entry.size end) + hard_limit = min(storage_limit, max_zip_uncompressed_size()) + decompression_ratio = if zip_size > 0, do: total_uncompressed / zip_size, else: 0 + + cond do + total_uncompressed > hard_limit -> + Logger.warning("Zip too large: #{total_uncompressed} bytes exceeds limit") + {:zip_error, :storage_limit_exceeded} + + decompression_ratio > @max_decompression_ratio -> + Logger.warning("Zip bomb detected: ratio #{decompression_ratio}:1 exceeds limit") + {:zip_error, :zip_bomb_detected} + + true -> + :ok + end + end + + defp upload_zip_entries(user, zip_binary, entries) do + case :zip.zip_open(zip_binary, [:memory]) do + {:ok, handle} -> + try do + case extract_and_upload_zip_entries(handle, user, entries) do + {:ok, results} -> + errors = Enum.filter(results, &match?({:error, _}, &1)) + + if Enum.empty?(errors) do + {:ok, length(results)} + else + {:error, :partial_upload, errors} + end + + {:error, reason} -> + {:error, reason} + end + after + :zip.zip_close(handle) + end + + {:error, reason} -> + {:error, {:invalid_zip, reason}} + end + end + + defp extract_and_upload_zip_entries(handle, user, entries) do + entries + |> Enum.reduce_while({:ok, [], 0}, fn entry, {:ok, results, extracted_size} -> + with {:ok, content} <- zip_entry_content(handle, entry), + new_extracted_size <- extracted_size + byte_size(content), + :ok <- validate_extracted_zip_size(new_extracted_size) do + content_type = static_site_content_type(entry.path) + result = upload_file(user, entry.path, content, content_type) + {:cont, {:ok, [result | results], new_extracted_size}} + else + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + {:ok, results, _extracted_size} -> {:ok, Enum.reverse(results)} + {:error, reason} -> {:error, reason} + end + end + + defp zip_entry_content(handle, entry) do + case :zip.zip_get(entry.zip_name, handle) do + {:ok, {_name, content}} when is_binary(content) -> + cond do + byte_size(content) != entry.size -> + {:error, {:invalid_zip, :size_mismatch}} + + byte_size(content) > @max_single_file_size -> + {:error, :file_too_large} + + true -> + {:ok, content} + end + + {:ok, _unexpected} -> + {:error, {:invalid_zip, :invalid_entry}} + + {:error, reason} -> + {:error, {:invalid_zip, reason}} + end + end + + defp validate_extracted_zip_size(size) do + if size > max_zip_uncompressed_size() do + {:error, :storage_limit_exceeded} + else + :ok + end + end + + defp max_zip_uncompressed_size do + config = Application.get_env(:elektrine, :static_sites, []) || [] + + case Keyword.get(config, :max_zip_uncompressed_size) do + limit when is_integer(limit) and limit > 0 -> limit + _ -> @default_max_zip_uncompressed_size + end + end + + defp user_storage_limit(user_id) do + case Repo.get(User, user_id) do + %User{storage_limit_bytes: limit} when is_integer(limit) and limit > 0 -> limit + _ -> default_storage_limit() + end + end + + defp default_storage_limit, do: 524_288_000 + + defp normalize_zip_path(path, all_files) do + # If all files share a common root directory, strip it + paths = Enum.map(all_files, fn {p, _} -> to_string(p) end) + + case find_common_prefix(paths) do + nil -> + path + + prefix -> + String.replace_prefix(path, prefix, "") + end + end + + defp find_common_prefix(paths) do + # Check if all paths start with the same directory + case Enum.map(paths, &String.split(&1, "/", parts: 2)) do + [[first | _] | rest] when first != "" -> + if Enum.all?(rest, fn + [^first | _] -> true + _ -> false + end) do + first <> "/" + else + nil + end + + _ -> + nil + end + end + + defp delete_file_record(file) do + delete_storage(file.storage_key) + Repo.delete(file) + end +end diff --git a/apps/elektrine/lib/elektrine/static_sites/github_deploy_worker.ex b/apps/elektrine/lib/elektrine/static_sites/github_deploy_worker.ex new file mode 100644 index 0000000..52aaed7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/static_sites/github_deploy_worker.ex @@ -0,0 +1,199 @@ +defmodule Elektrine.StaticSites.GitHubDeployWorker do + @moduledoc """ + Deploys a linked static site from a GitHub repository archive. + """ + + use Oban.Worker, queue: :default, max_attempts: 3 + + alias Elektrine.Profiles + alias Elektrine.Profiles.StaticSiteDeployment + alias Elektrine.Repo + alias Elektrine.StaticSites + + require Logger + + @max_archive_size 100 * 1024 * 1024 + @archive_body_key :elektrine_github_archive_body + @archive_body_size_key :elektrine_github_archive_body_size + @archive_too_large_key :elektrine_github_archive_too_large + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"deployment_id" => deployment_id}}) do + deployment = + StaticSiteDeployment + |> Repo.get(deployment_id) + |> Repo.preload(:user) + + case deployment do + %StaticSiteDeployment{} = deployment -> deploy(deployment) + nil -> {:error, :deployment_not_found} + end + end + + defp deploy(%StaticSiteDeployment{} = deployment) do + _ = StaticSites.mark_deployment_deploying(deployment) + commit = fetch_commit_metadata(deployment) + + with {:ok, archive} <- fetch_archive(deployment), + {:ok, _count} <- + StaticSites.replace_with_repo_archive(deployment.user, archive, deployment.site_dir), + {:ok, _profile} <- ensure_static_profile_mode(deployment), + {:ok, deployment} <- + StaticSites.mark_deployment_deployed( + deployment, + Map.merge(commit, %{log: "Deployed GitHub archive successfully"}) + ), + {:ok, _deploy} <- + StaticSites.snapshot_current_site( + deployment.user, + deployment, + Map.merge(commit, %{ + status: "deployed", + trigger: "github", + log: "Deployed GitHub archive successfully" + }) + ) do + :ok + else + {:error, reason} -> + _ = StaticSites.mark_deployment_failed(deployment, reason) + + _ = + StaticSites.record_static_site_deploy(deployment, %{ + status: "failed", + trigger: "github", + error: inspect(reason), + log: "Deploy failed: #{inspect(reason)}", + deployed_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + {:error, reason} + end + end + + @doc false + def fetch_archive(deployment, opts \\ []) + + def fetch_archive(%StaticSiteDeployment{} = deployment, opts) do + url = + "https://codeload.github.com/#{deployment.repo_owner}/#{deployment.repo_name}/zip/refs/heads/#{deployment.branch}" + + request_fun = Keyword.get(opts, :request_fun, &Req.get/2) + + case request_fun.(url, archive_request_options()) do + {:ok, %Req.Response{status: status} = response} when status in 200..299 -> + archive_body(response) + + {:ok, %{status: status, body: body}} when status in 200..299 and is_binary(body) -> + archive_body(body) + + {:ok, %{status: status}} -> + {:error, {:github_archive_status, status}} + + {:error, reason} -> + Logger.warning("GitHub static deploy fetch failed: #{inspect(reason)}") + {:error, reason} + end + end + + def fetch_archive(_deployment, _opts), do: {:error, :invalid_deployment} + + defp archive_request_options do + [ + headers: [ + {"accept", "application/zip"}, + {"accept-encoding", "identity"}, + {"user-agent", "Elektrine"} + ], + compressed: false, + decode_body: false, + raw: true, + into: bounded_archive_collector(@max_archive_size), + receive_timeout: :timer.seconds(30) + ] + end + + defp bounded_archive_collector(max_archive_size) do + fn {:data, data}, {request, response} -> + size = Req.Response.get_private(response, @archive_body_size_key, 0) + byte_size(data) + + response = + response + |> Req.Response.update_private(@archive_body_key, [data], &[data | &1]) + |> Req.Response.put_private(@archive_body_size_key, size) + + if size > max_archive_size do + {:halt, {request, Req.Response.put_private(response, @archive_too_large_key, true)}} + else + {:cont, {request, response}} + end + end + end + + defp archive_body(%Req.Response{} = response) do + if Req.Response.get_private(response, @archive_too_large_key, false) do + {:error, :archive_too_large} + else + body = + response + |> Req.Response.get_private(@archive_body_key, []) + |> Enum.reverse() + |> IO.iodata_to_binary() + + if byte_size(body) > @max_archive_size do + {:error, :archive_too_large} + else + {:ok, body} + end + end + end + + defp archive_body(body) when is_binary(body) do + if byte_size(body) > @max_archive_size do + {:error, :archive_too_large} + else + {:ok, body} + end + end + + defp fetch_commit_metadata(%StaticSiteDeployment{} = deployment) do + url = + "https://api.github.com/repos/#{deployment.repo_owner}/#{deployment.repo_name}/commits/#{deployment.branch}" + + case Req.get(url, + headers: [ + {"accept", "application/vnd.github+json"}, + {"user-agent", "Elektrine"} + ], + receive_timeout: :timer.seconds(10) + ) do + {:ok, %Req.Response{status: status, body: body}} when status in 200..299 and is_map(body) -> + %{ + commit_sha: body["sha"], + commit_url: body["html_url"], + commit_message: get_in(body, ["commit", "message"]) + } + + _ -> + %{} + end + rescue + _ -> %{} + end + + defp ensure_static_profile_mode(%StaticSiteDeployment{user: user}) do + case StaticSites.enable_static_mode(user.id) do + {:ok, profile} -> + {:ok, profile} + + {:error, :profile_not_found} -> + Profiles.create_user_profile(user.id, %{ + profile_mode: "static", + display_name: user.username + }) + + {:error, reason} -> + {:error, reason} + end + end +end diff --git a/apps/elektrine/lib/elektrine/static_sites/request_resolver.ex b/apps/elektrine/lib/elektrine/static_sites/request_resolver.ex new file mode 100644 index 0000000..b75decd --- /dev/null +++ b/apps/elektrine/lib/elektrine/static_sites/request_resolver.ex @@ -0,0 +1,120 @@ +defmodule Elektrine.StaticSites.RequestResolver do + @moduledoc """ + Resolves profile static-site request paths into uploaded files. + + Static-profile hosts behave like a dedicated web root: uploaded files are served + with normal static-site fallbacks, while only platform runtime endpoints are + reserved for the application. + """ + + alias Elektrine.StaticSites + + @type host_mode :: :subdomain | :custom_domain + @type result :: {:ok, struct()} | :reserved | :not_found + + @doc """ + Resolves a request path for a static site. + + Lookup order is: + + * exact file path + * directory `index.html` for paths ending in `/` + * extensionless `.html` + * extensionless `/index.html` + """ + @spec resolve(pos_integer(), binary(), keyword()) :: result() + def resolve(user_id, request_path, opts \\ []) + + def resolve(user_id, request_path, opts) when is_integer(user_id) and is_binary(request_path) do + mode = Keyword.get(opts, :mode, :subdomain) + + with {:ok, site_path} <- normalize_request_path(request_path), + false <- platform_path?(site_path, mode) do + site_path + |> lookup_candidates() + |> Enum.find_value(:not_found, fn path -> + case StaticSites.get_file(user_id, path) do + nil -> nil + file -> {:ok, file} + end + end) + else + true -> :reserved + :error -> :not_found + end + end + + def resolve(_, _, _), do: :not_found + + @doc """ + Returns true when a normalized static-site path is reserved for the platform. + """ + @spec platform_path?(binary(), host_mode()) :: boolean() + def platform_path?("", _mode), do: false + + def platform_path?(path, :custom_domain) when is_binary(path) do + critical_platform_path?(path) + end + + def platform_path?(path, :subdomain) when is_binary(path) do + critical_platform_path?(path) or String.starts_with?(path, ".well-known/") + end + + def platform_path?(path, _mode) when is_binary(path), do: platform_path?(path, :subdomain) + + @doc """ + Builds file lookup candidates for a normalized static-site path. + """ + @spec lookup_candidates(binary()) :: [binary()] + def lookup_candidates("") do + ["index.html"] + end + + def lookup_candidates(path) when is_binary(path) do + cond do + String.ends_with?(path, "/") -> + [path <> "index.html"] + + Path.extname(path) != "" -> + [path] + + true -> + [path, path <> ".html", path <> "/index.html"] + end + end + + defp normalize_request_path(request_path) do + decoded_path = URI.decode(request_path) |> String.trim() + site_path = String.trim_leading(decoded_path, "/") + normalized = Path.expand(site_path, "/") + + cond do + String.contains?(decoded_path, ["..", "\0", "//", "\\"]) -> + :error + + not String.starts_with?(normalized, "/") -> + :error + + normalized != "/" <> String.trim_trailing(site_path, "/") and site_path != "" -> + :error + + not Regex.match?(~r/^[a-zA-Z0-9_\-\.\/]*$/, site_path) -> + :error + + true -> + {:ok, site_path} + end + end + + defp critical_platform_path?(path) do + String.starts_with?(path, "live") or + String.starts_with?(path, "socket") or + String.starts_with?(path, "phoenix") or + String.starts_with?(path, "profiles/") or + String.starts_with?(path, "uploads") or + String.starts_with?(path, "users/") or + String.starts_with?(path, "relay") or + String.starts_with?(path, "nodeinfo") or + path == "inbox" + end +end diff --git a/apps/elektrine/lib/elektrine/strings.ex b/apps/elektrine/lib/elektrine/strings.ex new file mode 100644 index 0000000..3077f4e --- /dev/null +++ b/apps/elektrine/lib/elektrine/strings.ex @@ -0,0 +1,16 @@ +defmodule Elektrine.Strings do + @moduledoc false + + def present(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + def present(_), do: nil + + def present?(value) do + not is_nil(present(value)) + end +end diff --git a/apps/elektrine/lib/elektrine/subscriptions.ex b/apps/elektrine/lib/elektrine/subscriptions.ex new file mode 100644 index 0000000..9c6a590 --- /dev/null +++ b/apps/elektrine/lib/elektrine/subscriptions.ex @@ -0,0 +1,1231 @@ +defmodule Elektrine.Subscriptions do + @moduledoc """ + Context for managing user subscriptions. + + This is a universal subscription system that can be used for any product. + Products and prices are managed via the admin panel. + Integrates with Stripe for payment processing. + """ + import Ecto.Query + alias Ecto.Changeset + require Logger + alias Elektrine.Accounts.User + alias Elektrine.EmailAddresses + alias Elektrine.Repo + alias Elektrine.Subscriptions.{Product, RegistrationCheckout, Subscription} + + # =========================================================================== + # Product Management + # =========================================================================== + + @doc """ + List all subscription products. + """ + def list_products do + from(p in Product, order_by: [asc: p.sort_order, asc: p.name]) + |> Repo.all() + end + + @doc """ + Get a product by ID. + """ + def get_product(id), do: Repo.get(Product, id) + + @doc """ + Get a product by slug. + """ + def get_product_by_slug(slug) when is_binary(slug) do + Repo.get_by(Product, slug: slug) + end + + @doc """ + Get an active product by slug. + """ + def get_active_product_by_slug(slug) when is_binary(slug) do + from(p in Product, where: p.slug == ^slug and p.active == true) + |> Repo.one() + end + + @doc """ + Get the active one-time product used for paid registration. + """ + def get_active_registration_product do + case get_active_product_by_slug("registration") do + %Product{} = product -> + if Product.one_time?(product), do: product, else: nil + + _ -> + nil + end + end + + @doc """ + Create a new subscription product. + """ + def create_product(attrs) do + attrs = normalize_product_attrs(attrs) + + case sync_product_pricing(%Product{}, attrs) do + {:ok, synced_attrs} -> + %Product{} + |> Product.create_changeset(synced_attrs) + |> Repo.insert() + + {:error, errors} -> + {:error, product_changeset_with_errors(%Product{}, attrs, errors)} + end + end + + @doc """ + Update a subscription product. + """ + def update_product(%Product{} = product, attrs) do + attrs = normalize_product_attrs(attrs) + + case sync_product_pricing(product, attrs) do + {:ok, synced_attrs} -> + product + |> Product.update_changeset(synced_attrs) + |> Repo.update() + + {:error, errors} -> + {:error, product_changeset_with_errors(product, attrs, errors)} + end + end + + @doc """ + Delete a subscription product. + """ + def delete_product(%Product{} = product) do + if product_has_subscriptions?(product) do + {:error, :has_subscriptions} + else + Repo.delete(product) + end + end + + @doc """ + Get a product changeset for forms. + """ + def change_product(%Product{} = product, attrs \\ %{}) do + attrs = normalize_product_attrs(attrs) + + if product.id do + Product.update_changeset(product, attrs) + else + Product.create_changeset(product, attrs) + end + end + + # =========================================================================== + # Subscription Management + # =========================================================================== + + @doc """ + Get a user's subscription for a specific product. + """ + def get_subscription(user_id, product) when is_integer(user_id) and is_binary(product) do + Repo.get_by(Subscription, user_id: user_id, product: product) + end + + def get_subscription(%User{id: user_id}, product), do: get_subscription(user_id, product) + + @doc """ + Get subscription by Stripe subscription ID. + """ + def get_subscription_by_stripe_id(stripe_subscription_id) do + Repo.get_by(Subscription, stripe_subscription_id: stripe_subscription_id) + end + + @doc """ + Get subscription by Stripe customer ID and product. + """ + def get_subscription_by_customer(stripe_customer_id, product) do + Repo.get_by(Subscription, stripe_customer_id: stripe_customer_id, product: product) + end + + @doc """ + Check if a user has access to a product. + Admins always have access. Otherwise checks for active subscription. + """ + def has_access?(nil, _product), do: false + + def has_access?(%User{is_admin: true}, _product), do: true + + def has_access?(%User{id: user_id}, product) do + has_access?(user_id, product) + end + + def has_access?(user_id, product) when is_integer(user_id) do + case get_subscription(user_id, product) do + %Subscription{} = sub -> Subscription.has_access?(sub) + nil -> false + end + end + + @doc """ + Get or create a Stripe customer for a user. + Returns {:ok, stripe_customer_id} or {:error, reason}. + """ + def get_or_create_stripe_customer(%User{} = user, _product) do + case user_stripe_customer_id(user) do + cid when is_binary(cid) -> + {:ok, cid} + + nil -> + case existing_customer_id_for_user(user.id) do + {:single, cid} -> + case maybe_store_user_stripe_customer_id(user, cid) do + {:ok, _user} -> {:ok, cid} + {:error, reason} -> {:error, reason} + end + + {:multiple, cid} -> + Logger.warning( + "Stripe billing: multiple customer records detected for user #{user.id}; reusing #{inspect(cid)}" + ) + + {:ok, cid} + + :none -> + create_stripe_customer(user) + end + end + end + + defp create_stripe_customer(user) do + customer_params = %{ + email: billing_email_for(user), + name: user.display_name || user.username, + metadata: %{ + user_id: to_string(user.id), + username: user.username + } + } + + case stripe_client().create_customer(customer_params) do + {:ok, %{id: customer_id}} -> + case maybe_store_user_stripe_customer_id(user, customer_id) do + {:ok, _user} -> {:ok, customer_id} + {:error, reason} -> {:error, reason} + end + + {:error, error} -> + {:error, error} + end + end + + @doc """ + Create a Stripe Checkout session for a product subscription. + Returns {:ok, checkout_session} or {:error, reason}. + """ + def create_checkout_session(%User{} = user, product, price_id, opts \\ []) do + success_url = + Keyword.get(opts, :success_url, "#{base_url()}/subscribe/#{product}?success=true") + + cancel_url = Keyword.get(opts, :cancel_url, "#{base_url()}/subscribe/#{product}") + checkout_mode = normalize_checkout_mode(Keyword.get(opts, :checkout_mode, :subscription)) + metadata = checkout_metadata(user.id, product, price_id, checkout_mode) + + with {:ok, customer_id} <- get_or_create_stripe_customer(user, product) do + session_params = + %{ + customer: customer_id, + mode: checkout_mode, + line_items: [ + %{ + price: price_id, + quantity: 1 + } + ], + success_url: success_url, + cancel_url: cancel_url, + client_reference_id: to_string(user.id), + metadata: metadata + } + |> maybe_put_subscription_data(checkout_mode, metadata) + |> maybe_put_payment_data(checkout_mode, metadata) + + stripe_client().create_checkout_session(session_params) + end + end + + @doc """ + Create a guest Stripe Checkout session for a paid registration invite. + Returns {:ok, checkout_session} or {:error, reason}. + """ + def create_registration_checkout_session(%Product{} = product, opts \\ []) do + with true <- Product.one_time?(product), + true <- Product.has_one_time?(product) do + lookup_token = generate_lookup_token() + + success_url = + Keyword.get( + opts, + :success_url, + "#{base_url()}/register/purchase/success?checkout_session_id={CHECKOUT_SESSION_ID}&access=#{lookup_token}" + ) + + cancel_url = Keyword.get(opts, :cancel_url, "#{base_url()}/register") + + metadata = + registration_checkout_metadata( + product.slug, + product.stripe_one_time_price_id, + lookup_token + ) + + session_params = + %{ + customer_creation: "always", + mode: "payment", + line_items: [ + %{ + price: product.stripe_one_time_price_id, + quantity: 1 + } + ], + success_url: success_url, + cancel_url: cancel_url, + metadata: metadata + } + |> maybe_put_payment_data("payment", metadata) + + case stripe_client().create_checkout_session(session_params) do + {:ok, session} -> + case ensure_registration_checkout_record(product.slug, session, lookup_token) do + {:ok, _checkout} -> {:ok, session} + {:error, _reason} -> {:ok, session} + end + + {:error, reason} -> + {:error, reason} + end + else + _ -> {:error, :product_not_available} + end + end + + @doc """ + Get a registration checkout by Stripe session ID and access token. + """ + def get_registration_checkout(session_id, lookup_token) + when is_binary(session_id) and is_binary(lookup_token) do + from(c in RegistrationCheckout, + where: + c.stripe_checkout_session_id == ^String.trim(session_id) and + c.lookup_token == ^RegistrationCheckout.hash_lookup_token(String.trim(lookup_token)) + ) + |> Repo.one() + |> case do + %RegistrationCheckout{} = checkout -> Repo.preload(checkout, :invite_code) + nil -> nil + end + end + + @doc """ + Get a registration checkout by access token. + """ + def get_registration_checkout_by_token(lookup_token) when is_binary(lookup_token) do + lookup_token = String.trim(lookup_token) + + if lookup_token == "" do + nil + else + from(c in RegistrationCheckout, + where: c.lookup_token == ^RegistrationCheckout.hash_lookup_token(lookup_token) + ) + |> Repo.one() + |> case do + %RegistrationCheckout{} = checkout -> Repo.preload(checkout, :invite_code) + nil -> nil + end + end + end + + @doc """ + Create a Stripe Customer Portal session for managing subscriptions. + Returns {:ok, portal_session} or {:error, reason}. + """ + def create_portal_session(%User{} = user, product, opts \\ []) do + return_url = Keyword.get(opts, :return_url, "#{base_url()}/subscribe/#{product}") + + case get_subscription(user.id, product) do + %Subscription{stripe_customer_id: customer_id} when is_binary(customer_id) -> + stripe_client().create_billing_portal_session(%{ + customer: customer_id, + return_url: return_url + }) + + %Subscription{} -> + case user_stripe_customer_id(user) do + nil -> + {:error, :no_subscription} + + customer_id -> + stripe_client().create_billing_portal_session(%{ + customer: customer_id, + return_url: return_url + }) + end + + _ -> + case user_stripe_customer_id(user) do + nil -> + {:error, :no_subscription} + + customer_id -> + stripe_client().create_billing_portal_session(%{ + customer: customer_id, + return_url: return_url + }) + end + end + end + + @doc """ + Process a Stripe webhook event. + """ + def process_webhook_event(%{type: type, data: data}) do + case type do + "customer.subscription.created" -> + handle_subscription_created(data.object) + + "customer.subscription.updated" -> + handle_subscription_updated(data.object) + + "customer.subscription.deleted" -> + handle_subscription_deleted(data.object) + + "invoice.payment_succeeded" -> + handle_payment_succeeded(data.object) + + "invoice.payment_failed" -> + handle_payment_failed(data.object) + + "checkout.session.completed" -> + handle_checkout_session_completed(data.object) + + "checkout.session.async_payment_succeeded" -> + handle_checkout_session_completed(data.object) + + _ -> + # Ignore unhandled event types + {:ok, :ignored} + end + end + + defp handle_subscription_created(stripe_sub) do + price_id = get_price_id(stripe_sub) + stripe_subscription_id = stripe_sub[:id] || stripe_sub["id"] + + product = + get_in(stripe_sub, [:metadata, "product"]) || get_in(stripe_sub, ["metadata", "product"]) || + product_from_price_id(price_id) + + if !is_binary(product) or product == "" do + Logger.warning( + "Stripe webhook: missing/unknown product; ignoring event (stripe_subscription_id=#{inspect(stripe_subscription_id)}, price_id=#{inspect(price_id)})" + ) + + {:ok, :ignored} + else + create_or_update_subscription_from_stripe(product, stripe_sub, price_id) + end + end + + defp create_or_update_subscription_from_stripe(product, stripe_sub, price_id) do + customer_id = stripe_field(stripe_sub, :customer) + + # Find existing subscription by customer ID or create new + case get_subscription_by_customer(customer_id, product) do + %Subscription{} = sub -> + update_from_stripe(sub, stripe_sub) + + nil -> + case resolve_webhook_user(customer_id, stripe_sub) do + %User{} = user -> + maybe_store_user_stripe_customer_id(user, customer_id) + + %Subscription{} + |> Subscription.create_changeset(%{ + user_id: user.id, + product: product, + stripe_customer_id: customer_id, + stripe_subscription_id: stripe_field(stripe_sub, :id), + stripe_price_id: price_id, + status: stripe_field(stripe_sub, :status), + current_period_start: from_unix(stripe_field(stripe_sub, :current_period_start)), + current_period_end: from_unix(stripe_field(stripe_sub, :current_period_end)), + cancel_at_period_end: stripe_field(stripe_sub, :cancel_at_period_end) + }) + |> Repo.insert() + + nil -> + {:error, :no_user_id} + end + end + end + + defp product_from_price_id(nil), do: nil + + defp product_from_price_id(price_id) when is_binary(price_id) do + from(p in Product, + where: + p.stripe_monthly_price_id == ^price_id or + p.stripe_yearly_price_id == ^price_id or + p.stripe_one_time_price_id == ^price_id, + select: p.slug + ) + |> Repo.one() + end + + defp handle_checkout_session_completed(session) do + cond do + stripe_field(session, :mode) != "payment" -> + {:ok, :ignored} + + stripe_field(session, :payment_status) != "paid" -> + {:ok, :ignored} + + registration_checkout_session?(session) -> + fulfill_registration_checkout(session) + + true -> + create_or_update_one_time_purchase_from_checkout(session) + end + end + + defp fulfill_registration_checkout(session) do + price_id = metadata_value(session, :price_id) + product_slug = metadata_value(session, :product) || product_from_price_id(price_id) + + with %Product{} = product <- get_product_by_slug(product_slug), + true <- Product.one_time?(product), + {:ok, %RegistrationCheckout{} = checkout} <- + ensure_registration_checkout_record(product.slug, session) do + Repo.transaction(fn -> + checkout = + from(c in RegistrationCheckout, + where: c.id == ^checkout.id, + lock: "FOR UPDATE" + ) + |> Repo.one() + |> Repo.preload(:invite_code) + + case checkout.status do + "fulfilled" -> + ensure_registration_invite_code(checkout) + + _ -> + with {:ok, invite_code_id} <- ensure_registration_invite_code_id(checkout), + {:ok, updated_checkout} <- + checkout + |> RegistrationCheckout.fulfill_changeset(%{ + stripe_customer_id: stripe_field(session, :customer), + stripe_payment_intent_id: stripe_field(session, :payment_intent), + customer_email: registration_customer_email(session), + fulfilled_at: + from_unix(stripe_field(session, :created)) || + DateTime.utc_now() |> DateTime.truncate(:second), + status: "fulfilled", + invite_code_id: invite_code_id + }) + |> Repo.update() do + Repo.preload(updated_checkout, :invite_code) + else + {:error, reason} -> + Repo.rollback(reason) + end + end + end) + else + _ -> + {:ok, :ignored} + end + end + + defp create_or_update_one_time_purchase_from_checkout(session) do + customer_id = stripe_field(session, :customer) + price_id = metadata_value(session, :price_id) + product_slug = metadata_value(session, :product) || product_from_price_id(price_id) + + with %Product{} = product <- get_product_by_slug(product_slug), + true <- Product.one_time?(product), + %User{} = user <- resolve_webhook_user(customer_id, session) do + maybe_store_user_stripe_customer_id(user, customer_id) + + attrs = %{ + user_id: user.id, + product: product.slug, + stripe_customer_id: customer_id, + stripe_subscription_id: nil, + stripe_price_id: price_id, + status: "active", + current_period_start: + from_unix(stripe_field(session, :created)) || + DateTime.utc_now() |> DateTime.truncate(:second), + current_period_end: nil, + canceled_at: nil, + cancel_at_period_end: false, + metadata: one_time_checkout_metadata(session) + } + + case get_subscription(user.id, product.slug) || + get_subscription_by_customer(customer_id, product.slug) do + %Subscription{} = subscription -> + subscription + |> Subscription.webhook_changeset(Map.delete(attrs, :user_id)) + |> Repo.update() + + nil -> + %Subscription{} + |> Subscription.create_changeset(attrs) + |> Repo.insert() + end + else + _ -> + {:ok, :ignored} + end + end + + defp handle_subscription_updated(stripe_sub) do + sub_id = stripe_field(stripe_sub, :id) + + case get_subscription_by_stripe_id(sub_id) do + %Subscription{} = sub -> + update_from_stripe(sub, stripe_sub) + + nil -> + # Subscription not found, try to create it + handle_subscription_created(stripe_sub) + end + end + + defp handle_subscription_deleted(stripe_sub) do + sub_id = stripe_field(stripe_sub, :id) + + case get_subscription_by_stripe_id(sub_id) do + %Subscription{} = sub -> + sub + |> Subscription.webhook_changeset(%{ + status: "canceled", + canceled_at: + from_unix(stripe_field(stripe_sub, :canceled_at)) || + DateTime.utc_now() |> DateTime.truncate(:second), + cancel_at_period_end: false + }) + |> Repo.update() + + nil -> + {:ok, :not_found} + end + end + + defp handle_payment_succeeded(invoice) do + subscription_id = stripe_field(invoice, :subscription) + + if subscription_id do + case get_subscription_by_stripe_id(subscription_id) do + %Subscription{} = sub -> + # Update period dates if this is a renewal + sub + |> Subscription.webhook_changeset(%{ + status: "active", + current_period_start: from_unix(stripe_field(invoice, :period_start)), + current_period_end: from_unix(stripe_field(invoice, :period_end)) + }) + |> Repo.update() + + nil -> + {:ok, :not_found} + end + else + {:ok, :no_subscription} + end + end + + defp handle_payment_failed(invoice) do + subscription_id = stripe_field(invoice, :subscription) + + if subscription_id do + case get_subscription_by_stripe_id(subscription_id) do + %Subscription{} = sub -> + sub + |> Subscription.webhook_changeset(%{status: "past_due"}) + |> Repo.update() + + nil -> + {:ok, :not_found} + end + else + {:ok, :no_subscription} + end + end + + defp update_from_stripe(subscription, stripe_sub) do + maybe_backfill_customer_from_subscription(subscription) + + subscription + |> Subscription.webhook_changeset(%{ + stripe_subscription_id: stripe_field(stripe_sub, :id), + stripe_price_id: get_price_id(stripe_sub), + status: stripe_field(stripe_sub, :status), + current_period_start: from_unix(stripe_field(stripe_sub, :current_period_start)), + current_period_end: from_unix(stripe_field(stripe_sub, :current_period_end)), + canceled_at: from_unix(stripe_field(stripe_sub, :canceled_at)), + cancel_at_period_end: stripe_field(stripe_sub, :cancel_at_period_end) + }) + |> Repo.update() + end + + defp get_price_id(stripe_sub) do + items = stripe_field(stripe_sub, :items) || %{} + data = stripe_field(items, :data) || [] + + case data do + [item | _] -> + item + |> stripe_field(:price) + |> stripe_field(:id) + + _ -> + nil + end + rescue + _ -> nil + end + + defp from_unix(nil), do: nil + + defp from_unix(timestamp) when is_integer(timestamp) do + DateTime.from_unix!(timestamp) |> DateTime.truncate(:second) + end + + defp from_unix(timestamp) when is_binary(timestamp) do + case Integer.parse(timestamp) do + {parsed, ""} -> from_unix(parsed) + _ -> nil + end + end + + defp base_url do + if Code.ensure_loaded?(ElektrineWeb.Endpoint) do + ElektrineWeb.Endpoint.url() + else + "https://#{Elektrine.Domains.instance_domain()}" + end + end + + @doc """ + List all subscriptions for a user. + """ + def list_user_subscriptions(user_id) when is_integer(user_id) do + from(s in Subscription, where: s.user_id == ^user_id, order_by: [desc: s.inserted_at]) + |> Repo.all() + end + + def list_user_subscriptions(%User{id: user_id}), do: list_user_subscriptions(user_id) + + @doc """ + Cancel a subscription at period end. + """ + def cancel_subscription(%Subscription{stripe_subscription_id: sub_id} = subscription) + when is_binary(sub_id) do + case stripe_client().update_subscription(sub_id, %{cancel_at_period_end: true}) do + {:ok, stripe_sub} -> + update_from_stripe(subscription, stripe_sub) + + {:error, error} -> + {:error, error} + end + end + + def cancel_subscription(_), do: {:error, :no_stripe_subscription} + + @doc """ + Resume a canceled subscription (if still in period). + """ + def resume_subscription(%Subscription{stripe_subscription_id: sub_id} = subscription) + when is_binary(sub_id) do + case stripe_client().update_subscription(sub_id, %{cancel_at_period_end: false}) do + {:ok, stripe_sub} -> + update_from_stripe(subscription, stripe_sub) + + {:error, error} -> + {:error, error} + end + end + + def resume_subscription(_), do: {:error, :no_stripe_subscription} + + defp stripe_client do + Application.get_env( + :elektrine, + :stripe_client, + Elektrine.Subscriptions.StripeClient.Live + ) + end + + defp user_stripe_customer_id(%User{stripe_customer_id: cid}) when is_binary(cid) and cid != "", + do: cid + + defp user_stripe_customer_id(_user), do: nil + + defp billing_email_for(%User{} = user) do + cond do + present?(user.recovery_email) and user.recovery_email_verified -> + String.trim(user.recovery_email) + + present?(user.preferred_email_domain) -> + EmailAddresses.primary_for_user(user) + + true -> + EmailAddresses.primary_for_user(user) + end + end + + defp maybe_store_user_stripe_customer_id(%User{} = user, customer_id) + when is_binary(customer_id) and customer_id != "" do + cond do + user.stripe_customer_id == customer_id -> + {:ok, user} + + present?(user.stripe_customer_id) and user.stripe_customer_id != customer_id -> + Logger.warning( + "Stripe billing: user #{user.id} already has customer #{inspect(user.stripe_customer_id)}, ignoring #{inspect(customer_id)}" + ) + + {:ok, user} + + customer_id_conflicts?(user.id, customer_id) -> + Logger.warning( + "Stripe billing: not backfilling customer #{inspect(customer_id)} for user #{user.id} because multiple customer IDs already exist" + ) + + {:ok, user} + + true -> + user + |> Changeset.change(stripe_customer_id: customer_id) + |> Repo.update() + end + end + + defp maybe_store_user_stripe_customer_id(user, _customer_id), do: {:ok, user} + + defp maybe_backfill_customer_from_subscription(%Subscription{ + user_id: user_id, + stripe_customer_id: customer_id + }) + when is_binary(customer_id) and customer_id != "" do + case Repo.get(User, user_id) do + %User{} = user -> maybe_store_user_stripe_customer_id(user, customer_id) + nil -> {:ok, :not_found} + end + end + + defp maybe_backfill_customer_from_subscription(_subscription), do: {:ok, :noop} + + defp existing_customer_id_for_user(user_id) do + customer_ids = + from(s in Subscription, + where: s.user_id == ^user_id and not is_nil(s.stripe_customer_id), + distinct: true, + select: s.stripe_customer_id + ) + |> Repo.all() + + case customer_ids do + [] -> + :none + + [cid] -> + {:single, cid} + + _ -> + most_recent_customer_id = + from(s in Subscription, + where: s.user_id == ^user_id and not is_nil(s.stripe_customer_id), + order_by: [desc: s.updated_at, desc: s.inserted_at], + limit: 1, + select: s.stripe_customer_id + ) + |> Repo.one() + + {:multiple, most_recent_customer_id} + end + end + + defp customer_id_conflicts?(user_id, customer_id) do + case existing_customer_id_for_user(user_id) do + :none -> false + {:single, ^customer_id} -> false + {:single, _other} -> true + {:multiple, _cid} -> true + end + end + + defp resolve_webhook_user(customer_id, stripe_sub) do + metadata_user_id = + stripe_sub + |> metadata_value(:user_id) + |> parse_user_id() + + metadata_user = + if is_integer(metadata_user_id) do + Repo.get(User, metadata_user_id) + end + + cond do + metadata_user -> + metadata_user + + present?(customer_id) -> + get_user_by_stripe_customer_id(customer_id) || + get_user_by_subscription_customer_id(customer_id) + + true -> + nil + end + end + + defp get_user_by_stripe_customer_id(customer_id) do + Repo.get_by(User, stripe_customer_id: customer_id) + end + + defp get_user_by_subscription_customer_id(customer_id) do + from(s in Subscription, + join: u in User, + on: u.id == s.user_id, + where: s.stripe_customer_id == ^customer_id, + limit: 1, + select: u + ) + |> Repo.one() + end + + defp metadata_value(stripe_obj, key) do + stripe_obj + |> stripe_field(:metadata) + |> stripe_field(key) + end + + defp registration_checkout_session?(session) do + metadata_value(session, :purpose) == "registration_invite" + end + + defp parse_user_id(nil), do: nil + defp parse_user_id(value) when is_integer(value), do: value + + defp parse_user_id(value) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {parsed, ""} -> parsed + _ -> nil + end + end + + defp parse_user_id(value), do: parse_user_id(to_string(value)) + + defp stripe_field(nil, _key), do: nil + + defp stripe_field(data, key) when is_atom(key) do + case data do + %{} -> + Map.get(data, key) || Map.get(data, Atom.to_string(key)) + + _ -> + nil + end + end + + defp normalize_product_attrs(attrs) when is_map(attrs) do + attrs + |> normalize_string_attr(:name) + |> normalize_string_attr(:slug) + |> normalize_string_attr(:description) + |> normalize_string_attr(:billing_type) + |> normalize_string_attr(:stripe_monthly_price_id) + |> normalize_string_attr(:stripe_yearly_price_id) + |> normalize_string_attr(:stripe_one_time_price_id) + |> normalize_string_attr(:currency) + end + + defp sync_product_pricing(_product, attrs) do + Enum.reduce_while( + [ + {:stripe_monthly_price_id, :monthly_price_cents, "month"}, + {:stripe_yearly_price_id, :yearly_price_cents, "year"}, + {:stripe_one_time_price_id, :one_time_price_cents, :one_time} + ], + {:ok, attrs, nil}, + fn {price_id_key, cents_key, interval}, {:ok, acc_attrs, currency} -> + case sync_price_from_stripe(acc_attrs, price_id_key, cents_key, interval, currency) do + {:ok, next_attrs, next_currency} -> + {:cont, {:ok, next_attrs, next_currency}} + + {:error, error} -> + {:halt, {:error, [error]}} + end + end + ) + |> case do + {:ok, synced_attrs, _currency} -> {:ok, synced_attrs} + {:error, errors} -> {:error, errors} + end + end + + defp sync_price_from_stripe(attrs, price_id_key, cents_key, interval, current_currency) do + case attr_value(attrs, price_id_key) do + price_id when is_binary(price_id) and price_id != "" -> + with {:ok, stripe_price} <- stripe_client().retrieve_price(price_id), + {:ok, unit_amount} <- stripe_price_amount(stripe_price, price_id_key), + {:ok, stripe_currency} <- stripe_price_currency(stripe_price, price_id_key), + :ok <- validate_price_interval(stripe_price, interval, price_id_key), + :ok <- validate_price_currency(stripe_currency, current_currency, price_id_key) do + synced_attrs = + attrs + |> put_attr(cents_key, unit_amount) + |> put_attr(:currency, stripe_currency) + + {:ok, synced_attrs, stripe_currency} + else + {:error, reason} when not is_binary(reason) -> + {:error, + {price_id_key, "could not sync Stripe price: #{format_stripe_error(reason)}"}} + + {:error, message} -> + {:error, {price_id_key, message}} + end + + _ -> + {:ok, attrs, current_currency} + end + end + + defp stripe_price_amount(stripe_price, _field) do + case stripe_field(stripe_price, :unit_amount) do + amount when is_integer(amount) and amount >= 0 -> {:ok, amount} + _ -> {:error, "must point to a Stripe price with a fixed unit amount"} + end + end + + defp stripe_price_currency(stripe_price, _field) do + case stripe_field(stripe_price, :currency) do + currency when is_binary(currency) and currency != "" -> {:ok, String.downcase(currency)} + _ -> {:error, "must point to a Stripe price with a currency"} + end + end + + defp validate_price_interval(stripe_price, expected_interval, _field) do + recurring = stripe_field(stripe_price, :recurring) + interval = stripe_field(recurring, :interval) + + case expected_interval do + :one_time -> + if is_nil(recurring) do + :ok + else + {:error, "must point to a one-time Stripe price"} + end + + "year" -> + if interval == expected_interval do + :ok + else + {:error, "must point to a recurring yearly Stripe price"} + end + + "month" -> + if interval == expected_interval do + :ok + else + {:error, "must point to a recurring monthly Stripe price"} + end + end + end + + defp validate_price_currency(_stripe_currency, nil, _field), do: :ok + defp validate_price_currency(stripe_currency, stripe_currency, _field), do: :ok + + defp validate_price_currency(_stripe_currency, _current_currency, _field) do + {:error, "must use the same currency as the other Stripe prices for this product"} + end + + defp product_changeset_with_errors(product, attrs, errors) do + changeset = change_product(product, attrs) + + Enum.reduce(errors, changeset, fn {field, message}, acc -> + Changeset.add_error(acc, field, message) + end) + end + + defp product_has_subscriptions?(%Product{slug: slug}) do + Repo.exists?(from(s in Subscription, where: s.product == ^slug)) + end + + defp normalize_string_attr(attrs, key) when is_map(attrs) do + case attr_value(attrs, key) do + value when is_binary(value) -> + put_attr(attrs, key, normalize_optional_string(value)) + + _ -> + attrs + end + end + + defp attr_value(attrs, key) when is_atom(key) do + Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key)) + end + + defp put_attr(attrs, key, value) when is_atom(key) do + cond do + Map.has_key?(attrs, key) -> + Map.put(attrs, key, value) + + Map.has_key?(attrs, Atom.to_string(key)) -> + Map.put(attrs, Atom.to_string(key), value) + + true -> + Map.put(attrs, key, value) + end + end + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp present?(value) when is_binary(value), do: Elektrine.Strings.present?(value) + defp present?(value), do: not is_nil(value) + + defp normalize_checkout_mode(:subscription), do: "subscription" + defp normalize_checkout_mode(:payment), do: "payment" + defp normalize_checkout_mode("payment"), do: "payment" + defp normalize_checkout_mode(_), do: "subscription" + + defp maybe_put_subscription_data(params, "subscription", metadata) do + Map.put(params, :subscription_data, %{ + metadata: Map.drop(metadata, [:checkout_mode, :price_id]) + }) + end + + defp maybe_put_subscription_data(params, _mode, _metadata), do: params + + defp maybe_put_payment_data(params, "payment", metadata) do + Map.put(params, :payment_intent_data, %{metadata: metadata}) + end + + defp maybe_put_payment_data(params, _mode, _metadata), do: params + + defp checkout_metadata(user_id, product, price_id, checkout_mode) do + %{ + user_id: to_string(user_id), + product: product, + price_id: price_id, + checkout_mode: checkout_mode + } + end + + defp registration_checkout_metadata(product, price_id, lookup_token) do + %{ + purpose: "registration_invite", + product: product, + price_id: price_id, + checkout_mode: "payment", + registration_lookup_token: lookup_token + } + end + + defp ensure_registration_checkout_record(product_slug, session, lookup_token_override \\ nil) do + session_id = stripe_field(session, :id) + lookup_token = lookup_token_override || metadata_value(session, :registration_lookup_token) + + with true <- present?(session_id), + true <- present?(lookup_token) do + attrs = %{ + stripe_checkout_session_id: session_id, + lookup_token: RegistrationCheckout.hash_lookup_token(lookup_token), + plain_lookup_token: lookup_token, + product_slug: product_slug, + stripe_customer_id: stripe_field(session, :customer), + stripe_payment_intent_id: stripe_field(session, :payment_intent), + customer_email: registration_customer_email(session), + status: "pending" + } + + _ = + %RegistrationCheckout{} + |> RegistrationCheckout.create_changeset(attrs) + |> Repo.insert( + on_conflict: :nothing, + conflict_target: :stripe_checkout_session_id + ) + + case get_registration_checkout_by_session_id(session_id) do + %RegistrationCheckout{} = checkout -> {:ok, checkout} + nil -> {:error, :not_found} + end + else + _ -> {:error, :missing_registration_lookup_token} + end + end + + defp get_registration_checkout_by_session_id(session_id) when is_binary(session_id) do + case Repo.get_by(RegistrationCheckout, stripe_checkout_session_id: String.trim(session_id)) do + %RegistrationCheckout{} = checkout -> Repo.preload(checkout, :invite_code) + nil -> nil + end + end + + defp registration_customer_email(session) do + stripe_field(session, :customer_email) || + session + |> stripe_field(:customer_details) + |> stripe_field(:email) + end + + defp generate_lookup_token do + :crypto.strong_rand_bytes(24) + |> Base.url_encode64(padding: false) + end + + defp one_time_checkout_metadata(session) do + %{ + "billing_type" => "one_time", + "checkout_mode" => stripe_field(session, :mode), + "checkout_session_id" => stripe_field(session, :id), + "payment_intent_id" => stripe_field(session, :payment_intent), + "completed_at" => + case from_unix(stripe_field(session, :created)) do + %DateTime{} = dt -> DateTime.to_iso8601(dt) + _ -> nil + end + } + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + end + + defp format_stripe_error(%Stripe.Error{message: message}), do: message + defp format_stripe_error(reason), do: inspect(reason) + + defp ensure_registration_invite_code(%RegistrationCheckout{} = checkout) do + case ensure_registration_invite_code_id(checkout) do + {:ok, _invite_code_id} -> Repo.preload(checkout, :invite_code) + {:error, reason} -> Repo.rollback(reason) + end + end + + defp ensure_registration_invite_code_id(%RegistrationCheckout{invite_code_id: invite_code_id}) + when is_integer(invite_code_id), + do: {:ok, invite_code_id} + + defp ensure_registration_invite_code_id(%RegistrationCheckout{} = checkout) do + case Elektrine.Accounts.create_invite_code(%{ + max_uses: 1, + note: + "Paid registration checkout #{checkout.stripe_checkout_session_id || checkout.id}" + }) do + {:ok, invite_code} -> {:ok, invite_code.id} + {:error, reason} -> {:error, reason} + end + end +end diff --git a/apps/elektrine/lib/elektrine/subscriptions/product.ex b/apps/elektrine/lib/elektrine/subscriptions/product.ex new file mode 100644 index 0000000..7c80765 --- /dev/null +++ b/apps/elektrine/lib/elektrine/subscriptions/product.ex @@ -0,0 +1,230 @@ +defmodule Elektrine.Subscriptions.Product do + @moduledoc """ + Schema for subscription products. + + Products are managed via admin panel and define what users can subscribe to. + Products can be billed as recurring subscriptions or one-time purchases. + """ + use Ecto.Schema + import Ecto.Changeset + + @billing_types ~w(recurring one_time) + + schema "subscription_products" do + field :name, :string + field :slug, :string + field :description, :string + field :features, {:array, :string}, default: [] + field :billing_type, :string, default: "recurring" + field :stripe_monthly_price_id, :string + field :stripe_yearly_price_id, :string + field :stripe_one_time_price_id, :string + field :monthly_price_cents, :integer + field :yearly_price_cents, :integer + field :one_time_price_cents, :integer + field :currency, :string, default: "usd" + field :active, :boolean, default: true + field :sort_order, :integer, default: 0 + + timestamps(type: :utc_datetime) + end + + @doc """ + Changeset for creating a product. + """ + def create_changeset(product, attrs) do + product + |> cast(attrs, [ + :name, + :slug, + :description, + :features, + :billing_type, + :stripe_monthly_price_id, + :stripe_yearly_price_id, + :stripe_one_time_price_id, + :monthly_price_cents, + :yearly_price_cents, + :one_time_price_cents, + :currency, + :active, + :sort_order + ]) + |> normalize_string_fields() + |> validate_required([:name, :slug]) + |> validate_length(:name, min: 1, max: 100) + |> validate_length(:slug, min: 1, max: 50) + |> validate_format(:slug, ~r/^[a-z0-9-]+$/, + message: "only lowercase letters, numbers, and hyphens" + ) + |> validate_inclusion(:billing_type, @billing_types) + |> validate_number(:monthly_price_cents, greater_than_or_equal_to: 0) + |> validate_number(:yearly_price_cents, greater_than_or_equal_to: 0) + |> validate_number(:one_time_price_cents, greater_than_or_equal_to: 0) + |> validate_billing_type_pricing() + |> unique_constraint(:slug) + end + + @doc """ + Changeset for updating an existing product. + """ + def update_changeset(product, attrs) do + product + |> cast(attrs, [ + :name, + :description, + :features, + :billing_type, + :stripe_monthly_price_id, + :stripe_yearly_price_id, + :stripe_one_time_price_id, + :monthly_price_cents, + :yearly_price_cents, + :one_time_price_cents, + :currency, + :active, + :sort_order + ]) + |> normalize_string_fields() + |> reject_slug_change(attrs) + |> validate_required([:name, :slug]) + |> validate_length(:name, min: 1, max: 100) + |> validate_length(:slug, min: 1, max: 50) + |> validate_format(:slug, ~r/^[a-z0-9-]+$/, + message: "only lowercase letters, numbers, and hyphens" + ) + |> validate_inclusion(:billing_type, @billing_types) + |> validate_number(:monthly_price_cents, greater_than_or_equal_to: 0) + |> validate_number(:yearly_price_cents, greater_than_or_equal_to: 0) + |> validate_number(:one_time_price_cents, greater_than_or_equal_to: 0) + |> validate_billing_type_pricing() + |> unique_constraint(:slug) + end + + defp normalize_string_fields(changeset) do + changeset + |> update_change(:name, &normalize_optional_string/1) + |> update_change(:slug, &normalize_optional_string/1) + |> update_change(:description, &normalize_optional_string/1) + |> update_change(:billing_type, &normalize_optional_string/1) + |> update_change(:stripe_monthly_price_id, &normalize_optional_string/1) + |> update_change(:stripe_yearly_price_id, &normalize_optional_string/1) + |> update_change(:stripe_one_time_price_id, &normalize_optional_string/1) + |> update_change(:currency, &normalize_optional_string/1) + end + + defp validate_billing_type_pricing(changeset) do + case get_field(changeset, :billing_type) do + "one_time" -> + if recurring_pricing_present?(changeset) do + add_error( + changeset, + :billing_type, + "one-time products cannot include monthly or yearly pricing" + ) + else + changeset + end + + _ -> + if one_time_pricing_present?(changeset) do + add_error( + changeset, + :billing_type, + "recurring products cannot include one-time pricing" + ) + else + changeset + end + end + end + + defp recurring_pricing_present?(changeset) do + present?(get_field(changeset, :stripe_monthly_price_id)) or + present?(get_field(changeset, :stripe_yearly_price_id)) or + not is_nil(get_field(changeset, :monthly_price_cents)) or + not is_nil(get_field(changeset, :yearly_price_cents)) + end + + defp one_time_pricing_present?(changeset) do + present?(get_field(changeset, :stripe_one_time_price_id)) or + not is_nil(get_field(changeset, :one_time_price_cents)) + end + + defp present?(value) when is_binary(value), do: Elektrine.Strings.present?(value) + defp present?(value), do: not is_nil(value) + + defp reject_slug_change(changeset, attrs) do + case attr_value(attrs, :slug) do + nil -> + changeset + + slug when slug == changeset.data.slug -> + changeset + + _ -> + add_error(changeset, :slug, "cannot be changed after creation") + end + end + + defp attr_value(attrs, key) when is_atom(key) do + Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key)) + end + + defp normalize_optional_string(value) when is_binary(value), + do: Elektrine.Strings.present(value) + + defp normalize_optional_string(value), do: value + + @doc """ + Format price in cents as a display string. + """ + def format_price(nil, _currency), do: nil + + def format_price(cents, currency) when is_integer(cents) do + dollars = cents / 100 + symbol = currency_symbol(currency) + "#{symbol}#{:erlang.float_to_binary(dollars, decimals: 2)}" + end + + def billing_types, do: @billing_types + + def recurring?(%__MODULE__{billing_type: "one_time"}), do: false + def recurring?(%__MODULE__{}), do: true + def recurring?(_), do: false + + def one_time?(%__MODULE__{billing_type: "one_time"}), do: true + def one_time?(_), do: false + + defp currency_symbol("usd"), do: "$" + defp currency_symbol("eur"), do: "EUR " + defp currency_symbol("gbp"), do: "GBP " + defp currency_symbol(_), do: "" + + @doc """ + Check if product has monthly pricing configured. + """ + def has_monthly?(%__MODULE__{billing_type: "recurring", stripe_monthly_price_id: id}) + when is_binary(id) and id != "", + do: true + + def has_monthly?(_), do: false + + @doc """ + Check if product has yearly pricing configured. + """ + def has_yearly?(%__MODULE__{billing_type: "recurring", stripe_yearly_price_id: id}) + when is_binary(id) and id != "", + do: true + + def has_yearly?(_), do: false + + @doc """ + Check if product has one-time pricing configured. + """ + def has_one_time?(%__MODULE__{billing_type: "one_time", stripe_one_time_price_id: id}) + when is_binary(id) and id != "", + do: true + + def has_one_time?(_), do: false +end diff --git a/apps/elektrine/lib/elektrine/subscriptions/registration_checkout.ex b/apps/elektrine/lib/elektrine/subscriptions/registration_checkout.ex new file mode 100644 index 0000000..0ab24f5 --- /dev/null +++ b/apps/elektrine/lib/elektrine/subscriptions/registration_checkout.ex @@ -0,0 +1,67 @@ +defmodule Elektrine.Subscriptions.RegistrationCheckout do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + alias Elektrine.Accounts.User + + @statuses ~w(pending fulfilled) + + schema "registration_checkouts" do + field :stripe_checkout_session_id, :string + field :lookup_token, :string + field :plain_lookup_token, :string, virtual: true + field :product_slug, :string + field :stripe_customer_id, :string + field :stripe_payment_intent_id, :string + field :customer_email, :string + field :status, :string, default: "pending" + field :fulfilled_at, :utc_datetime + field :redeemed_at, :utc_datetime + + belongs_to :invite_code, Elektrine.Accounts.InviteCode + belongs_to :redeemed_by_user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + def create_changeset(checkout, attrs) do + checkout + |> cast(attrs, [ + :stripe_checkout_session_id, + :lookup_token, + :product_slug, + :stripe_customer_id, + :stripe_payment_intent_id, + :customer_email, + :status, + :fulfilled_at, + :invite_code_id, + :redeemed_at, + :redeemed_by_user_id + ]) + |> validate_required([:stripe_checkout_session_id, :lookup_token, :product_slug, :status]) + |> validate_inclusion(:status, @statuses) + |> unique_constraint(:stripe_checkout_session_id) + |> unique_constraint(:lookup_token) + end + + def fulfill_changeset(checkout, attrs) do + checkout + |> cast(attrs, [ + :stripe_customer_id, + :stripe_payment_intent_id, + :customer_email, + :status, + :fulfilled_at, + :invite_code_id, + :redeemed_at, + :redeemed_by_user_id + ]) + |> validate_required([:status]) + |> validate_inclusion(:status, @statuses) + end + + def hash_lookup_token(token) when is_binary(token), do: User.hash_sensitive_token(token) +end diff --git a/apps/elektrine/lib/elektrine/subscriptions/stripe_client.ex b/apps/elektrine/lib/elektrine/subscriptions/stripe_client.ex new file mode 100644 index 0000000..890b2ae --- /dev/null +++ b/apps/elektrine/lib/elektrine/subscriptions/stripe_client.ex @@ -0,0 +1,9 @@ +defmodule Elektrine.Subscriptions.StripeClient do + @moduledoc false + + @callback create_customer(map()) :: {:ok, map()} | {:error, term()} + @callback create_checkout_session(map()) :: {:ok, map()} | {:error, term()} + @callback create_billing_portal_session(map()) :: {:ok, map()} | {:error, term()} + @callback update_subscription(binary(), map()) :: {:ok, map()} | {:error, term()} + @callback retrieve_price(binary()) :: {:ok, map()} | {:error, term()} +end diff --git a/apps/elektrine/lib/elektrine/subscriptions/stripe_client/live.ex b/apps/elektrine/lib/elektrine/subscriptions/stripe_client/live.ex new file mode 100644 index 0000000..5117dbd --- /dev/null +++ b/apps/elektrine/lib/elektrine/subscriptions/stripe_client/live.ex @@ -0,0 +1,20 @@ +defmodule Elektrine.Subscriptions.StripeClient.Live do + @moduledoc false + @behaviour Elektrine.Subscriptions.StripeClient + + @impl true + def create_customer(params), do: Stripe.Customer.create(params) + + @impl true + def create_checkout_session(params), do: Stripe.Checkout.Session.create(params) + + @impl true + def create_billing_portal_session(params), do: Stripe.BillingPortal.Session.create(params) + + @impl true + def update_subscription(subscription_id, params), + do: Stripe.Subscription.update(subscription_id, params) + + @impl true + def retrieve_price(price_id), do: Stripe.Price.retrieve(price_id) +end diff --git a/apps/elektrine/lib/elektrine/subscriptions/subscription.ex b/apps/elektrine/lib/elektrine/subscriptions/subscription.ex new file mode 100644 index 0000000..bf8fc00 --- /dev/null +++ b/apps/elektrine/lib/elektrine/subscriptions/subscription.ex @@ -0,0 +1,102 @@ +defmodule Elektrine.Subscriptions.Subscription do + @moduledoc """ + Schema for user subscriptions. + + This is a universal subscription system that can be used for any product. + Each subscription tracks a user's access to a specific product. + Products are managed via admin panel in the subscription_products table. + """ + use Ecto.Schema + import Ecto.Changeset + + @statuses ~w( + incomplete + incomplete_expired + trialing + active + past_due + canceled + unpaid + paused + ) + + schema "subscriptions" do + field :product, :string + field :stripe_customer_id, :string + field :stripe_subscription_id, :string + field :stripe_price_id, :string + field :status, :string, default: "incomplete" + field :current_period_start, :utc_datetime + field :current_period_end, :utc_datetime + field :canceled_at, :utc_datetime + field :cancel_at_period_end, :boolean, default: false + field :metadata, :map, default: %{} + + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + @doc """ + Returns list of valid subscription statuses. + """ + def statuses, do: @statuses + + @doc """ + Returns statuses that grant access to the product. + """ + def active_statuses, do: ~w(active trialing) + + @doc """ + Changeset for creating a new subscription. + """ + def create_changeset(subscription, attrs) do + subscription + |> cast(attrs, [ + :user_id, + :product, + :stripe_customer_id, + :stripe_subscription_id, + :stripe_price_id, + :status, + :current_period_start, + :current_period_end, + :canceled_at, + :cancel_at_period_end, + :metadata + ]) + |> validate_required([:user_id, :product]) + |> validate_length(:product, min: 1, max: 50) + |> validate_inclusion(:status, @statuses) + |> unique_constraint([:user_id, :product]) + |> unique_constraint(:stripe_subscription_id) + end + + @doc """ + Changeset for updating subscription from Stripe webhook. + """ + def webhook_changeset(subscription, attrs) do + subscription + |> cast(attrs, [ + :stripe_customer_id, + :stripe_subscription_id, + :stripe_price_id, + :status, + :current_period_start, + :current_period_end, + :canceled_at, + :cancel_at_period_end, + :metadata + ]) + |> validate_inclusion(:status, @statuses) + end + + @doc """ + Check if subscription grants access to the product. + """ + def has_access?(%__MODULE__{status: status}) do + status in active_statuses() + end + + def has_access?(_), do: false +end diff --git a/apps/elektrine/lib/elektrine/system.ex b/apps/elektrine/lib/elektrine/system.ex new file mode 100644 index 0000000..28f63c7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/system.ex @@ -0,0 +1,191 @@ +defmodule Elektrine.System do + @moduledoc """ + The System context for managing system-wide configuration. + """ + + @self_service_invite_min_trust_level_default 1 + @module_access_defaults %{ + chat: 0, + timeline: 0, + communities: 0, + gallery: 0, + lists: 0, + friends: 0, + email: 0, + nerve: 0, + dns: 0, + storage: 0, + drive: 0, + paige: 0, + vpn: 0, + uptime: 0, + kairo: 0 + } + + import Ecto.Query, warn: false + alias Elektrine.Repo + alias Elektrine.System.Config + + @doc """ + Gets a configuration value by key. + Returns the parsed value based on the type. + """ + def get_config(key, default \\ nil) do + case Repo.get_by(Config, key: key) do + nil -> default + config -> Config.parse_value(config) + end + end + + @doc """ + Sets a configuration value. + """ + def set_config(key, value, type \\ "string", description \\ nil) do + config = Repo.get_by(Config, key: key) || %Config{key: key} + + attrs = %{ + value: to_string(value), + type: type, + description: description || config.description + } + + config + |> Config.changeset(attrs) + |> Repo.insert_or_update() + end + + @doc """ + Checks if invite codes are enabled for registration. + """ + def invite_codes_enabled? do + get_config("invite_codes_enabled", true) + end + + @doc """ + Enables or disables the invite code system. + """ + def set_invite_codes_enabled(enabled) when is_boolean(enabled) do + set_config( + "invite_codes_enabled", + enabled, + "boolean", + "Enable or disable the invite code system for user registration" + ) + end + + @doc """ + Returns the minimum trust level required for user-created invite codes. + """ + def self_service_invite_min_trust_level do + case get_config( + "self_service_invite_min_trust_level", + @self_service_invite_min_trust_level_default + ) do + level when is_integer(level) and level in 0..4 -> level + _other -> @self_service_invite_min_trust_level_default + end + end + + @doc """ + Sets the minimum trust level required for user-created invite codes. + """ + def set_self_service_invite_min_trust_level(level) when is_integer(level) and level in 0..4 do + set_config( + "self_service_invite_min_trust_level", + level, + "integer", + "Minimum trust level required for self-service invite code creation" + ) + end + + def set_self_service_invite_min_trust_level(_level), do: {:error, :invalid_level} + + @doc """ + Returns the admin-managed minimum trust levels for user-facing modules. + """ + def module_access_rules do + @module_access_defaults + |> Enum.map(fn {module, default_level} -> + %{ + module: module, + label: module_access_label(module), + min_trust_level: module_min_trust_level(module, default_level) + } + end) + |> Enum.sort_by(& &1.label) + end + + def module_min_trust_level(module, default \\ nil) do + module = normalize_module(module) + default = default || Map.get(@module_access_defaults, module, 0) + + case get_config(module_access_key(module), default) do + level when is_integer(level) and level in 0..4 -> level + _other -> default + end + end + + def set_module_min_trust_level(module, level) when is_integer(level) and level in 0..4 do + module = normalize_module(module) + + if Map.has_key?(@module_access_defaults, module) do + set_config( + module_access_key(module), + level, + "integer", + "Minimum trust level required to access #{module_access_label(module)}" + ) + else + {:error, :unknown_module} + end + end + + def set_module_min_trust_level(_module, _level), do: {:error, :invalid_level} + + def user_can_access_module?(%{is_admin: true}, _module), do: true + + def user_can_access_module?(nil, module), do: module_min_trust_level(module) == 0 + + def user_can_access_module?(%{trust_level: trust_level}, module) when is_integer(trust_level), + do: trust_level >= module_min_trust_level(module) + + def user_can_access_module?(_user, _module), do: false + + defp module_access_key(module), do: "module_access_min_trust_level_#{module}" + + defp normalize_module(module) when is_atom(module), do: module + + defp normalize_module(module) when is_binary(module) do + module = String.trim(module) + + Enum.find(Map.keys(@module_access_defaults), module, &(to_string(&1) == module)) + end + + defp normalize_module(module), do: module + + defp module_access_label(:dns), do: "DNS" + defp module_access_label(:vpn), do: "VPN" + defp module_access_label(:uptime), do: "Uptime" + defp module_access_label(:email), do: "Email" + defp module_access_label(:nerve), do: "Nerve" + defp module_access_label(:kairo), do: "Kairo" + defp module_access_label(:drive), do: "Drive" + defp module_access_label(:paige), do: "Paige Web Search" + defp module_access_label(:storage), do: "Storage" + defp module_access_label(:chat), do: "Chat" + defp module_access_label(:timeline), do: "Timeline" + defp module_access_label(:communities), do: "Communities" + defp module_access_label(:gallery), do: "Gallery" + defp module_access_label(:lists), do: "Lists" + defp module_access_label(:friends), do: "Friends" + defp module_access_label(module), do: module |> to_string() |> String.capitalize() + + @doc """ + Gets all configuration entries. + """ + def list_configs do + Config + |> order_by(asc: :key) + |> Repo.all() + end +end diff --git a/apps/elektrine/lib/elektrine/system/config.ex b/apps/elektrine/lib/elektrine/system/config.ex new file mode 100644 index 0000000..bf478c8 --- /dev/null +++ b/apps/elektrine/lib/elektrine/system/config.ex @@ -0,0 +1,43 @@ +defmodule Elektrine.System.Config do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "system_config" do + field :key, :string + field :value, :string + field :type, :string, default: "string" + field :description, :string + + timestamps() + end + + @doc false + def changeset(config, attrs) do + config + |> cast(attrs, [:key, :value, :type, :description]) + |> validate_required([:key, :value]) + |> unique_constraint(:key) + |> validate_inclusion(:type, ["string", "boolean", "integer", "json"]) + end + + def parse_value(%__MODULE__{type: "boolean", value: value}) do + value in ["true", "1", "yes", "on"] + end + + def parse_value(%__MODULE__{type: "integer", value: value}) do + case Integer.parse(value) do + {int, ""} -> int + _ -> 0 + end + end + + def parse_value(%__MODULE__{type: "json", value: value}) do + case Jason.decode(value) do + {:ok, json} -> json + _ -> nil + end + end + + def parse_value(%__MODULE__{value: value}), do: value +end diff --git a/apps/elektrine/lib/elektrine/telemetry/events.ex b/apps/elektrine/lib/elektrine/telemetry/events.ex new file mode 100644 index 0000000..2c98791 --- /dev/null +++ b/apps/elektrine/lib/elektrine/telemetry/events.ex @@ -0,0 +1,237 @@ +defmodule Elektrine.Telemetry.Events do + @moduledoc """ + Shared telemetry emitters for application business events. + + These helpers keep event naming and tag formats consistent across modules. + """ + + @spec auth(atom() | binary(), atom() | binary(), map()) :: :ok + def auth(flow, outcome, metadata \\ %{}) do + :telemetry.execute( + [:elektrine, :auth, :flow], + %{count: 1}, + metadata + |> Map.merge(%{ + flow: normalize_tag(flow), + outcome: normalize_tag(outcome) + }) + |> normalize_metadata() + ) + + :ok + end + + @spec email_inbound(atom() | binary(), atom() | binary(), integer() | nil, map()) :: :ok + def email_inbound(stage, outcome, duration_ms \\ nil, metadata \\ %{}) do + :telemetry.execute( + [:elektrine, :email, :inbound], + base_measurements(duration_ms), + metadata + |> Map.merge(%{ + stage: normalize_tag(stage), + outcome: normalize_tag(outcome) + }) + |> normalize_metadata() + ) + + :ok + end + + @spec email_outbound(atom() | binary(), atom() | binary(), integer() | nil, map()) :: :ok + def email_outbound(stage, outcome, duration_ms \\ nil, metadata \\ %{}) do + :telemetry.execute( + [:elektrine, :email, :outbound], + base_measurements(duration_ms), + metadata + |> Map.merge(%{ + stage: normalize_tag(stage), + outcome: normalize_tag(outcome) + }) + |> normalize_metadata() + ) + + :ok + end + + @spec federation( + atom() | binary(), + atom() | binary(), + atom() | binary(), + integer() | nil, + map() + ) :: + :ok + def federation(component, event, outcome, duration_ms \\ nil, metadata \\ %{}) do + :telemetry.execute( + [:elektrine, :federation, :event], + base_measurements(duration_ms), + metadata + |> Map.merge(%{ + component: normalize_tag(component), + event: normalize_tag(event), + outcome: normalize_tag(outcome) + }) + |> normalize_metadata() + ) + + :ok + end + + @spec cert(atom() | binary(), atom() | binary(), atom() | binary(), integer() | nil, map()) :: + :ok + def cert(component, event, outcome, duration_ms \\ nil, metadata \\ %{}) do + :telemetry.execute( + [:elektrine, :cert, :lifecycle], + base_measurements(duration_ms), + metadata + |> Map.merge(%{ + component: normalize_tag(component), + event: normalize_tag(event), + outcome: normalize_tag(outcome) + }) + |> normalize_metadata() + ) + + :ok + end + + @spec cert_status(non_neg_integer(), non_neg_integer(), map()) :: :ok + def cert_status(expiring_count, total_count, metadata \\ %{}) do + :telemetry.execute( + [:elektrine, :cert, :status], + %{expiring: expiring_count, total: total_count}, + normalize_metadata(metadata) + ) + + :ok + end + + @spec upload(atom() | binary(), atom() | binary(), integer() | nil, map()) :: :ok + def upload(type, outcome, bytes \\ nil, metadata \\ %{}) do + measurements = + %{count: 1} + |> maybe_put_bytes(bytes) + + :telemetry.execute( + [:elektrine, :upload, :operation], + measurements, + metadata + |> Map.merge(%{ + type: normalize_tag(type), + outcome: normalize_tag(outcome) + }) + |> normalize_metadata() + ) + + :ok + end + + @spec cache(atom() | binary(), atom() | binary(), atom() | binary(), map()) :: :ok + def cache(cache_name, operation, result, metadata \\ %{}) do + :telemetry.execute( + [:elektrine, :cache, :request], + %{count: 1}, + metadata + |> Map.merge(%{ + cache: normalize_tag(cache_name), + op: normalize_tag(operation), + result: normalize_tag(result) + }) + |> normalize_metadata() + ) + + :ok + end + + @spec api_request(integer(), integer(), map()) :: :ok + def api_request(duration_ms, status_code, metadata \\ %{}) do + :telemetry.execute( + [:elektrine, :api, :request], + %{count: 1, duration: duration_ms}, + metadata + |> Map.merge(%{ + status_class: status_class(status_code) + }) + |> normalize_metadata() + ) + + :ok + end + + @spec dav_request(integer(), integer(), map()) :: :ok + def dav_request(duration_ms, status_code, metadata \\ %{}) do + :telemetry.execute( + [:elektrine, :dav, :request], + %{count: 1, duration: duration_ms}, + metadata + |> Map.merge(%{ + status_class: status_class(status_code) + }) + |> normalize_metadata() + ) + + :ok + end + + @spec db_hot_path(atom() | binary(), atom() | binary(), integer() | nil, map()) :: :ok + def db_hot_path(component, operation, duration_ms \\ nil, metadata \\ %{}) do + :telemetry.execute( + [:elektrine, :db, :hot_path], + base_measurements(duration_ms), + metadata + |> Map.merge(%{ + component: normalize_tag(component), + operation: normalize_tag(operation) + }) + |> normalize_metadata() + ) + + :ok + end + + @spec report(atom() | binary(), atom() | binary(), map()) :: :ok + def report(operation, outcome, metadata \\ %{}) do + :telemetry.execute( + [:elektrine, :reports, :operation], + %{count: 1}, + metadata + |> Map.merge(%{ + operation: normalize_tag(operation), + outcome: normalize_tag(outcome) + }) + |> normalize_metadata() + ) + + :ok + end + + defp base_measurements(nil), do: %{count: 1} + defp base_measurements(duration_ms), do: %{count: 1, duration: duration_ms} + + defp maybe_put_bytes(measurements, nil), do: measurements + + defp maybe_put_bytes(measurements, bytes) when is_integer(bytes), + do: Map.put(measurements, :bytes, bytes) + + defp maybe_put_bytes(measurements, _), do: measurements + + defp normalize_metadata(metadata) do + metadata + |> Enum.into(%{}, fn {key, value} -> {key, normalize_tag(value)} end) + end + + defp normalize_tag(value) when is_atom(value), do: Atom.to_string(value) + defp normalize_tag(value) when is_binary(value), do: value + defp normalize_tag(value) when is_integer(value), do: Integer.to_string(value) + + defp normalize_tag(value) when is_float(value), + do: :erlang.float_to_binary(value, [:compact, decimals: 2]) + + defp normalize_tag(value), do: inspect(value) + + defp status_class(code) when is_integer(code) do + "#{div(code, 100)}xx" + end + + defp status_class(_), do: "unknown" +end diff --git a/apps/elektrine/lib/elektrine/text_helpers.ex b/apps/elektrine/lib/elektrine/text_helpers.ex new file mode 100644 index 0000000..19d2309 --- /dev/null +++ b/apps/elektrine/lib/elektrine/text_helpers.ex @@ -0,0 +1,296 @@ +defmodule Elektrine.TextHelpers do + @moduledoc """ + Shared helper functions for text processing, sanitization, and formatting. + """ + + @doc """ + Truncates text to `max_length` characters of content, adding "..." if truncated. + + `max_length` bounds the content, so a truncated result is up to three + characters longer. Use `truncate_to/2` when `max_length` must bound the whole + rendered string. + + ## Examples + + iex> TextHelpers.truncate("Hello world", 5) + "Hello..." + + iex> TextHelpers.truncate("Hi", 10) + "Hi" + + iex> TextHelpers.truncate(nil, 10) + "" + """ + def truncate(nil, _max_length), do: "" + def truncate("", _max_length), do: "" + + def truncate(text, max_length) when is_binary(text) and is_integer(max_length) do + if String.length(text) > max_length do + text |> String.slice(0, max_length) |> String.trim_trailing() |> Kernel.<>("...") + else + text + end + end + + def truncate(text, _), do: to_string(text) + + @doc """ + Truncates text so the result, including any "...", is at most `max_length` + characters. + + Use this where `max_length` is a display budget. Where it bounds the content + instead, use `truncate/2`. + + ## Examples + + iex> TextHelpers.truncate_to("Hello world", 8) + "Hello..." + + iex> TextHelpers.truncate_to("Hello world", 3) + "Hel" + """ + def truncate_to(text, max_length) when is_binary(text) and is_integer(max_length) do + cond do + String.length(text) <= max_length -> text + max_length <= 3 -> String.slice(text, 0, max_length) + true -> truncate(text, max_length - 3) + end + end + + def truncate_to(_text, _max_length), do: "" + + @doc """ + Abbreviates a count for display, using `K` and `M` suffixes. + + Trailing zeroes are dropped, so 2000 renders as "2K" rather than "2.0K". + This is the single spelling for counts across the UI — chart labels, + follower counts, and admin stats must not diverge on it. + + ## Examples + + iex> TextHelpers.abbreviate_count(2_000) + "2K" + + iex> TextHelpers.abbreviate_count(1_500) + "1.5K" + + iex> TextHelpers.abbreviate_count(nil) + "0" + """ + def abbreviate_count(value) when is_number(value) and value >= 1_000_000, + do: "#{format_decimal(value / 1_000_000)}M" + + def abbreviate_count(value) when is_number(value) and value >= 1_000, + do: "#{format_decimal(value / 1_000)}K" + + def abbreviate_count(value) when is_integer(value), do: Integer.to_string(value) + def abbreviate_count(value) when is_float(value), do: to_string(format_decimal(value)) + def abbreviate_count(_), do: "0" + + defp format_decimal(value) do + rounded = Float.round(value, 1) + + if rounded == Float.round(rounded, 0), do: trunc(rounded), else: rounded + end + + @doc """ + Sanitizes a search term for safe use in SQL LIKE queries. + + Escapes special characters: %, _, and \\ + + ## Examples + + iex> TextHelpers.sanitize_search_term("hello%world") + "hello\\\\%world" + """ + def sanitize_search_term(term) when is_binary(term) do + term + |> String.replace("\\", "\\\\") + |> String.replace("%", "\\%") + |> String.replace("_", "\\_") + end + + def sanitize_search_term(_), do: "" + + @doc """ + Strips HTML tags from text, leaving plain text content. + + This is a simple implementation - use specialized functions for + complex HTML processing (e.g., preserving links, handling entities). + + ## Options + - `:decode_entities` - whether to decode HTML entities (default: true) + - `:normalize_whitespace` - whether to collapse multiple spaces (default: true) + """ + def strip_html(nil), do: nil + def strip_html(""), do: "" + + def strip_html(html, opts \\ []) when is_binary(html) do + decode_entities = Keyword.get(opts, :decode_entities, true) + normalize_ws = Keyword.get(opts, :normalize_whitespace, true) + + result = + html + |> String.replace(~r/]*>.*?<\/script>/is, " ") + |> String.replace(~r/]*>.*?<\/style>/is, " ") + |> String.replace(~r//, "\n") + |> String.replace(~r/<\/p>/, "\n") + |> String.replace(~r/<[^>]+>/, " ") + + result = + if decode_entities do + decode_html_entities(result) + else + result + end + + if normalize_ws do + result + |> String.replace(~r/\s+/, " ") + |> String.trim() + else + String.trim(result) + end + end + + @doc """ + Decodes common HTML entities to their character equivalents. + """ + def decode_html_entities(text) when is_binary(text) do + text + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + |> String.replace(""", "\"") + |> String.replace("'", "'") + |> String.replace("'", "'") + |> String.replace(" ", " ") + |> String.replace("–", "-") + |> String.replace("—", "-") + |> String.replace("…", "...") + |> String.replace("™", "TM") + |> String.replace("©", "(c)") + |> String.replace("®", "(R)") + |> String.replace("“", "\"") + |> String.replace("”", "\"") + |> String.replace("‘", "'") + |> String.replace("’", "'") + |> String.replace("•", "*") + |> String.replace("·", "*") + end + + def decode_html_entities(text), do: text + + @doc """ + Extracts domain from a URL string. + + ## Examples + + iex> TextHelpers.extract_domain_from_url("https://example.com/path") + "example.com" + + iex> TextHelpers.extract_domain_from_url(nil) + nil + """ + def extract_domain_from_url(nil), do: nil + def extract_domain_from_url(""), do: nil + + def extract_domain_from_url(url) when is_binary(url) do + case URI.parse(url) do + %URI{host: host} when is_binary(host) -> host + _ -> nil + end + end + + @doc """ + Formats a relative time string like "5 minutes ago". + + Returns full words (e.g., "5 minutes") - append " ago" as needed. + """ + def time_ago_in_words(datetime) + + def time_ago_in_words(%NaiveDateTime{} = ndt) do + ndt + |> DateTime.from_naive!("Etc/UTC") + |> time_ago_in_words() + end + + def time_ago_in_words(%DateTime{} = datetime) do + now = DateTime.utc_now() + diff = DateTime.diff(now, datetime, :second) + + cond do + diff < 60 -> "#{diff} seconds" + diff < 3600 -> "#{div(diff, 60)} minutes" + diff < 86_400 -> "#{div(diff, 3600)} hours" + diff < 2_592_000 -> "#{div(diff, 86400)} days" + diff < 31_536_000 -> "#{div(diff, 2_592_000)} months" + true -> "#{div(diff, 31_536_000)} years" + end + end + + def time_ago_in_words(_), do: "" + + @doc """ + Formats a relative time string in abbreviated form. + + Returns abbreviated format (e.g., "5m", "2h", "3d"). + For older content, returns a date string. + """ + def time_ago_short(datetime) + + def time_ago_short(%NaiveDateTime{} = ndt) do + ndt + |> DateTime.from_naive!("Etc/UTC") + |> time_ago_short() + end + + def time_ago_short(%DateTime{} = datetime) do + now = DateTime.utc_now() + diff = DateTime.diff(now, datetime, :second) + + cond do + diff < 60 -> "#{diff}s" + diff < 3600 -> "#{div(diff, 60)}m" + diff < 86_400 -> "#{div(diff, 3600)}h" + diff < 2_592_000 -> "#{div(diff, 86400)}d" + true -> Calendar.strftime(datetime, "%b %d") + end + end + + def time_ago_short(_), do: "" + + @doc """ + Formats a relative time in compact "ago" form. + + Returns "just now", then "5m ago", "2h ago", "3d ago", "2w ago", "4mo ago". + This is the single spelling for elapsed time in the UI; `time_ago_short/1` + drops the "ago" and switches to a date after a month. + """ + def time_ago_compact(datetime) + + def time_ago_compact(%NaiveDateTime{} = ndt) do + ndt + |> DateTime.from_naive!("Etc/UTC") + |> time_ago_compact() + end + + def time_ago_compact(%DateTime{} = datetime) do + DateTime.utc_now() + |> DateTime.diff(datetime, :second) + |> time_ago_compact() + end + + def time_ago_compact(seconds) when is_integer(seconds) do + cond do + seconds < 60 -> "just now" + seconds < 3_600 -> "#{div(seconds, 60)}m ago" + seconds < 86_400 -> "#{div(seconds, 3_600)}h ago" + seconds < 604_800 -> "#{div(seconds, 86_400)}d ago" + seconds < 2_592_000 -> "#{div(seconds, 604_800)}w ago" + true -> "#{div(seconds, 2_592_000)}mo ago" + end + end + + def time_ago_compact(_), do: "" +end diff --git a/apps/elektrine/lib/elektrine/theme.ex b/apps/elektrine/lib/elektrine/theme.ex new file mode 100644 index 0000000..04b56ee --- /dev/null +++ b/apps/elektrine/lib/elektrine/theme.ex @@ -0,0 +1,456 @@ +defmodule Elektrine.Theme do + @moduledoc """ + Shared website theme defaults, editable override fields, and CSS variable helpers. + """ + + import Ecto.Changeset + + @hex_color_regex ~r/^#[0-9a-fA-F]{6}$/ + @light_text_color "#ffffff" + @dark_text_color "#000000" + @modes ~w(system light dark custom) + @dark_meta_theme_color "#121214" + @platform_brand_colors %{ + "youtube" => "#ff0000", + "discord" => "#5865f2", + "instagram" => "#e4405f", + "twitter" => "#000000", + "tiktok" => "#000000", + "telegram" => "#26a5e4", + "soundcloud" => "#ff5500", + "twitch" => "#9146ff", + "reddit" => "#ff4500", + "spotify" => "#1db954", + "pinterest" => "#e60023", + "linkedin" => "#0a66c2", + "steam" => "#000000", + "bitcoin" => "#f7931a", + "ethereum" => "#627eea", + "gitlab" => "#fc6d26", + "facebook" => "#1877f2", + "whatsapp" => "#25d366", + "threads" => "#000000", + "github" => "#000000", + "paypal" => "#00457c", + "adobe" => "#ff0000", + "vk" => "#0077ff", + "email" => "#ea4335", + "signal" => "#3a76f0", + "litecoin" => "#345d9d", + "snapchat" => "#fffc00" + } + @brand_colors %{ + "google_blue" => "#4285f4", + "google_green" => "#34a853", + "google_yellow" => "#fbbc05", + "google_red" => "#ea4335", + "authy_red" => "#ec1c24", + "microsoft_red" => "#f25022", + "microsoft_green" => "#7fba00", + "microsoft_blue" => "#00a4ef", + "microsoft_yellow" => "#ffb900" + } + + @editor_fields [ + %{ + key: "color_primary", + css_var: "--theme-override-color-primary", + label: "Primary", + description: "Main brand color for buttons, focus states, and accents", + default: "#4f7098" + }, + %{ + key: "color_secondary", + css_var: "--theme-override-color-secondary", + label: "Secondary", + description: "Secondary accent used in gradients and supporting actions", + default: "#a8662d" + }, + %{ + key: "color_accent", + css_var: "--theme-override-color-accent", + label: "Accent", + description: "Extra accent color for highlights and decorative UI", + default: "#62779f" + }, + %{ + key: "color_base_100", + css_var: "--theme-override-color-base-100", + label: "Base 100", + description: "Main page background", + default: "#f5f7fa" + }, + %{ + key: "color_base_200", + css_var: "--theme-override-color-base-200", + label: "Base 200", + description: "Panel and card background", + default: "#edf1f5" + }, + %{ + key: "color_base_300", + css_var: "--theme-override-color-base-300", + label: "Base 300", + description: "Borders and elevated surfaces", + default: "#d2dae4" + }, + %{ + key: "color_base_content", + css_var: "--theme-override-color-base-content", + label: "Text", + description: "Primary text and foreground color", + default: "#17202a" + }, + %{ + key: "color_info", + css_var: "--theme-override-color-info", + label: "Info", + description: "Informational badges and alerts", + default: "#3f6fa8" + }, + %{ + key: "color_success", + css_var: "--theme-override-color-success", + label: "Success", + description: "Success states and confirmation UI", + default: "#3f7a52" + }, + %{ + key: "color_warning", + css_var: "--theme-override-color-warning", + label: "Warning", + description: "Warnings and caution states", + default: "#9a5b18" + }, + %{ + key: "color_error", + css_var: "--theme-override-color-error", + label: "Error", + description: "Errors and destructive actions", + default: "#a34b48" + } + ] + + @allowed_override_keys MapSet.new(Enum.map(@editor_fields, & &1.key)) + @default_overrides Map.new(@editor_fields, &{&1.key, &1.default}) + @css_var_by_key Map.new(@editor_fields, &{&1.key, &1.css_var}) + + def editor_fields, do: @editor_fields + + def default_overrides, do: @default_overrides + + def default_value(key), do: Map.get(@default_overrides, key) + + @doc """ + The supported site theme modes. `system` follows the OS color scheme, + `light`/`dark` pin a structural theme, and `custom` applies the user's + personal palette from `theme_overrides`. + """ + def modes, do: @modes + + @doc """ + Picks the structural light or dark theme a custom palette renders on, + from the effective page background's luminance. Unlike the palette itself + this is deterministic: a custom theme never follows the OS or a toggle. + """ + def custom_scheme(overrides) do + background = overrides |> effective_overrides() |> Map.fetch!("color_base_100") + {red, green, blue} = hex_to_rgb(background) + luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255 + + if luminance >= 0.55, do: :light, else: :dark + end + + def inverse_text_color, do: @light_text_color + + def dark_text_color, do: @dark_text_color + + def platform_brand_color(platform) when is_binary(platform) do + Map.get(@platform_brand_colors, platform, default_value("color_warning")) + end + + def platform_brand_color(_), do: default_value("color_warning") + + def brand_color(name) when is_binary(name) do + Map.get(@brand_colors, name, default_value("color_primary")) + end + + def brand_color(_), do: default_value("color_primary") + + def calendar_default_color, do: default_value("color_primary") + + def trust_level_color(level) do + case level do + 0 -> default_value("color_base_content") + 1 -> default_value("color_info") + 2 -> default_value("color_success") + 3 -> default_value("color_warning") + 4 -> default_value("color_error") + _ -> default_value("color_base_content") + end + end + + def proof_palette(level) do + accent = trust_level_color(level) + + %{ + accent: accent, + surface: mix(accent, inverse_text_color(), 0.9), + glow: rgba(accent, 0.28) + } + end + + def value(overrides, key) when is_map(overrides), + do: Map.get(overrides, key, default_value(key)) + + def value(_, key), do: default_value(key) + + def validate_overrides(changeset, field \\ :theme_overrides) do + overrides = get_field(changeset, field) || %{} + + cond do + overrides == %{} -> + put_change(changeset, field, %{}) + + not is_map(overrides) -> + add_error(changeset, field, "must be a map") + + true -> + case normalize_overrides(overrides) do + {:ok, normalized} -> put_change(changeset, field, normalized) + {:error, message} -> add_error(changeset, field, message) + end + end + end + + def style_attribute(overrides) when is_map(overrides) do + overrides + |> Enum.reduce([], fn {key, value}, acc -> + case Map.fetch(@css_var_by_key, key) do + {:ok, css_var} when is_binary(value) -> ["#{css_var}: #{value}" | acc] + _ -> acc + end + end) + |> Kernel.++(derived_content_css_vars(overrides)) + |> Enum.reverse() + |> Enum.join("; ") + end + + def style_attribute(_), do: "" + + def effective_overrides(overrides \\ %{}) do + @default_overrides + |> Map.merge(configured_default_overrides()) + |> Map.merge(sanitize_overrides(overrides)) + end + + def effective_style_attribute(overrides \\ %{}) do + overrides + |> effective_overrides() + |> style_attribute() + end + + def meta_theme_color(overrides) do + value(overrides, "color_base_100") + end + + def effective_meta_theme_color(overrides \\ %{}) do + overrides + |> effective_overrides() + |> meta_theme_color() + end + + def dark_meta_theme_color, do: @dark_meta_theme_color + + def api_payload(source) do + overrides = extract_overrides(source) + + %{ + mode: extract_mode(source), + overrides: overrides, + values: effective_overrides(overrides) + } + end + + def hex_to_rgb("#" <> hex) when byte_size(hex) == 6 do + {red, ""} = Integer.parse(String.slice(hex, 0, 2), 16) + {green, ""} = Integer.parse(String.slice(hex, 2, 2), 16) + {blue, ""} = Integer.parse(String.slice(hex, 4, 2), 16) + {red, green, blue} + end + + def hex_to_rgb(_), do: default_value("color_primary") |> hex_to_rgb() + + def rgb_to_hex(red, green, blue) do + red = red |> round() |> max(0) |> min(255) + green = green |> round() |> max(0) |> min(255) + blue = blue |> round() |> max(0) |> min(255) + + "#" <> + String.pad_leading(Integer.to_string(red, 16), 2, "0") <> + String.pad_leading(Integer.to_string(green, 16), 2, "0") <> + String.pad_leading(Integer.to_string(blue, 16), 2, "0") + end + + def mix(first_hex, second_hex, ratio_to_second) do + {first_red, first_green, first_blue} = hex_to_rgb(first_hex) + {second_red, second_green, second_blue} = hex_to_rgb(second_hex) + ratio = ratio_to_second |> max(0.0) |> min(1.0) + first_ratio = 1.0 - ratio + + rgb_to_hex( + first_red * first_ratio + second_red * ratio, + first_green * first_ratio + second_green * ratio, + first_blue * first_ratio + second_blue * ratio + ) + end + + def lighten(hex, factor), do: mix(hex, @light_text_color, factor) + + def darken(hex, factor), do: mix(hex, @dark_text_color, factor) + + def rgba(hex, alpha) do + {red, green, blue} = hex_to_rgb(hex) + "rgba(#{red}, #{green}, #{blue}, #{alpha})" + end + + def gradient_pair(hex) do + {darken(hex, 0.5), darken(hex, 0.35)} + end + + def contrast_text(hex, opts \\ []) do + {red, green, blue} = hex_to_rgb(hex) + threshold = Keyword.get(opts, :threshold, 0.5) + light = Keyword.get(opts, :light, @light_text_color) + dark = Keyword.get(opts, :dark, @dark_text_color) + luminance = 0.2126 * (red / 255) + 0.7152 * (green / 255) + 0.0722 * (blue / 255) + + if luminance > threshold, do: dark, else: light + end + + def email_palette(source, variant \\ :default) do + overrides = active_overrides(source) + primary = value(overrides, "color_primary") + success = value(overrides, "color_success") + warning = value(overrides, "color_warning") + error = value(overrides, "color_error") + base_100 = value(overrides, "color_base_100") + base_200 = value(overrides, "color_base_200") + base_300 = value(overrides, "color_base_300") + base_content = value(overrides, "color_base_content") + + accent = + case variant do + :warning -> warning + :error -> error + :success -> success + _ -> primary + end + + %{ + page_bg: darken(base_100, 0.28), + card_bg: darken(base_100, 0.16), + card_border: mix(base_300, base_100, 0.28), + divider: mix(base_300, base_100, 0.28), + text_heading: accent, + text_strong: mix(base_content, @light_text_color, 0.08), + text_body: mix(base_content, base_100, 0.12), + text_muted: mix(base_content, base_100, 0.34), + text_subtle: mix(base_content, base_100, 0.52), + button_bg: primary, + button_text: contrast_text(primary), + accent_link: primary, + notice_text: warning, + success_text: success, + alert_bg: mix(accent, base_100, 0.82), + alert_border: accent, + alert_text: mix(accent, @light_text_color, 0.45), + card_subtle_bg: mix(base_200, base_100, 0.22), + detail_highlight: mix(error, @light_text_color, 0.3) + } + end + + def action_text_color(hex) do + contrast_text(hex, + threshold: 0.72, + light: @light_text_color, + dark: "#101317" + ) + end + + defp normalize_overrides(overrides) do + Enum.reduce_while(overrides, {:ok, %{}}, fn {key, value}, {:ok, acc} -> + key = to_string(key) + value = normalize_value(value) + + cond do + not MapSet.member?(@allowed_override_keys, key) -> + {:halt, {:error, "contains unsupported theme keys"}} + + value in [nil, ""] -> + {:cont, {:ok, acc}} + + not Regex.match?(@hex_color_regex, value) -> + {:halt, {:error, "must use 6-digit hex colors"}} + + true -> + {:cont, {:ok, Map.put(acc, key, String.downcase(value))}} + end + end) + end + + defp normalize_value(value) when is_binary(value), do: String.trim(value) + defp normalize_value(value) when is_atom(value), do: value |> Atom.to_string() |> String.trim() + defp normalize_value(_), do: nil + + defp derived_content_css_vars(overrides) do + [ + {"color_primary", "--theme-override-color-primary-content"}, + {"color_secondary", "--theme-override-color-secondary-content"}, + {"color_accent", "--theme-override-color-accent-content"}, + {"color_info", "--theme-override-color-info-content"}, + {"color_success", "--theme-override-color-success-content"}, + {"color_warning", "--theme-override-color-warning-content"}, + {"color_error", "--theme-override-color-error-content"} + ] + |> Enum.reduce([], fn {override_key, css_var}, acc -> + case Map.get(overrides, override_key) do + value when is_binary(value) and value != "" -> + ["#{css_var}: #{action_text_color(value)}" | acc] + + _ -> + acc + end + end) + end + + defp configured_default_overrides do + :elektrine + |> Application.get_env(:theme_defaults, %{}) + |> sanitize_overrides() + end + + defp sanitize_overrides(overrides) when is_map(overrides) do + case normalize_overrides(overrides) do + {:ok, normalized} -> normalized + {:error, _message} -> %{} + end + end + + defp sanitize_overrides(_), do: %{} + + defp extract_overrides(%{theme_overrides: overrides}) when is_map(overrides), do: overrides + defp extract_overrides(overrides) when is_map(overrides), do: overrides + defp extract_overrides(_), do: %{} + + defp extract_mode(%{theme_mode: mode}) when mode in @modes, do: mode + defp extract_mode(_), do: "system" + + # A user's custom palette only takes effect in custom mode; sources without + # a mode (plain override maps) are treated as already-active palettes. + defp active_overrides(%{theme_mode: mode} = source) do + if mode == "custom", do: extract_overrides(source), else: %{} + end + + defp active_overrides(source), do: extract_overrides(source) +end diff --git a/apps/elektrine/lib/elektrine/time.ex b/apps/elektrine/lib/elektrine/time.ex new file mode 100644 index 0000000..ea7f5ba --- /dev/null +++ b/apps/elektrine/lib/elektrine/time.ex @@ -0,0 +1,11 @@ +defmodule Elektrine.Time do + @moduledoc false + + def utc_now do + DateTime.utc_now() |> DateTime.truncate(:second) + end + + def truncate(nil), do: nil + def truncate(%DateTime{} = datetime), do: DateTime.truncate(datetime, :second) + def truncate(value), do: value +end diff --git a/apps/elektrine/lib/elektrine/timeline/rate_limiter.ex b/apps/elektrine/lib/elektrine/timeline/rate_limiter.ex new file mode 100644 index 0000000..99bad10 --- /dev/null +++ b/apps/elektrine/lib/elektrine/timeline/rate_limiter.ex @@ -0,0 +1,32 @@ +defmodule Elektrine.Timeline.RateLimiter do + @moduledoc """ + Read-path rate limiter for timeline-style endpoints and LiveView tab switching. + + Limits are intentionally generous so normal scrolling and filter switching + remain smooth while bursts and loops are bounded. + """ + + use Elektrine.RateLimiter, + table: :timeline_rate_limiter, + limits: [ + {:minute, 180}, + {:hour, 4000} + ], + lockout: {:minutes, 2}, + cleanup_interval: {:minutes, 2} + + @doc """ + Checks and records a timeline read in one call. + Returns :ok when allowed, or {:error, retry_after_seconds} when limited. + """ + def allow_read(identifier) do + case check_rate_limit(identifier) do + {:ok, :allowed} -> + record_attempt(identifier) + :ok + + {:error, {:rate_limited, retry_after, _reason}} -> + {:error, retry_after} + end + end +end diff --git a/apps/elektrine/lib/elektrine/uploads.ex b/apps/elektrine/lib/elektrine/uploads.ex new file mode 100644 index 0000000..6869794 --- /dev/null +++ b/apps/elektrine/lib/elektrine/uploads.ex @@ -0,0 +1,1619 @@ +defmodule Elektrine.Uploads do + @moduledoc "Handles file uploads with support for both local storage and S3.\n\nConfiguration determines which adapter to use:\n- :local for development (stores files locally)\n- :s3 for production (stores files in S3)\n" + require Logger + import Bitwise + import Ecto.Query + + alias Elektrine.Accounts + alias Elektrine.Accounts.Storage + alias Elektrine.Constants + alias Elektrine.Messaging.{ChatConversationMember, ChatMessage} + alias Elektrine.Repo + alias Elektrine.Security.FilePath + alias Elektrine.Telemetry.Events + @default_max_file_size 5 * 1024 * 1024 + @default_max_background_size 10 * 1024 * 1024 + @allowed_mime_types ~w[ + image/jpeg + image/jpg + image/png + image/gif + image/webp + ] + @allowed_extensions ~w[.jpg .jpeg .png .gif .webp] + @chat_attachment_mime_types ~w[ + image/jpeg + image/jpg + image/png + image/gif + image/webp + image/heic + image/heif + image/avif + video/mp4 + video/webm + video/ogg + video/quicktime + audio/mpeg + audio/mp3 + audio/ogg + audio/wav + audio/webm + audio/aac + audio/m4a + audio/flac + application/pdf + application/msword + application/vnd.openxmlformats-officedocument.wordprocessingml.document + application/vnd.ms-excel + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + text/plain + ] + @chat_attachment_extensions ~w[.jpg .jpeg .png .gif .webp .heic .heif .avif .mp4 .webm .ogv .mov .mp3 .wav .m4a .aac .flac .pdf .doc .docx .xls .xlsx .txt] + @kairo_source_mime_types @chat_attachment_mime_types ++ + ~w[ + application/json + application/markdown + text/markdown + ] + @kairo_source_extensions @chat_attachment_extensions ++ ~w[.json .md .markdown] + @voice_message_mime_types ~w[ + audio/webm + audio/mp4 + audio/ogg + ] + @background_mime_types ~w[ + image/jpeg + image/jpg + image/png + image/gif + image/webp + video/mp4 + video/webm + ] + @background_extensions ~w[.jpg .jpeg .png .gif .webp .mp4 .webm] + @favicon_mime_types ~w[ + image/png + image/x-icon + image/vnd.microsoft.icon + image/jpeg + image/jpg + ] + @favicon_extensions ~w[.png .ico .jpg .jpeg] + @private_attachment_prefixes ~w[ + attachments/ + chat-attachments/ + timeline-attachments/ + discussion-attachments/ + gallery-attachments/ + kairo-sources/ + voice-messages/ + ] + @private_attachment_token_salt "private attachment" + @private_attachment_token_ttl_seconds 3600 + @malicious_patterns [ + ~r/ [<<255, 216, 255>>], + "image/png" => [<<137, 80, 78, 71, 13, 10, 26, 10>>], + "image/gif" => ["GIF87a", "GIF89a"], + "image/webp" => ["RIFF"], + "application/pdf" => [<<37, 80, 68, 70>>], + "application/msword" => [<<208, 207, 17, 224, 161, 177, 26, 225>>], + "application/vnd.ms-excel" => [<<208, 207, 17, 224, 161, 177, 26, 225>>], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => ["PK\x03\x04"], + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => ["PK\x03\x04"], + "video/webm" => [<<26, 69, 223, 163>>], + "audio/webm" => [<<26, 69, 223, 163>>], + "audio/ogg" => ["OggS"], + "video/ogg" => ["OggS"], + "audio/wav" => ["RIFF"], + "audio/flac" => ["fLaC"] + } + @doc "Checks if user has enough storage quota for an upload.\nReturns :ok or {:error, :storage_limit_exceeded}\nAdmins bypass storage limits.\n" + def check_user_storage_limit(user_id, file_size) do + user = Accounts.get_user!(user_id) + + if user.is_admin || not Storage.would_exceed_limit?(user_id, file_size) do + :ok + else + {:error, :storage_limit_exceeded} + end + end + + @doc "Uploads a file and returns the public URL.\n\nReturns {:ok, url} on success or {:error, reason} on failure.\n" + def upload_avatar(%Plug.Upload{} = upload, user_id) do + result = + with {:ok, %File.Stat{size: file_size}} <- File.stat(upload.path), + :ok <- check_user_storage_limit(user_id, file_size), + :ok <- validate_upload(upload, user_id), + {:ok, cropped_path} <- crop_avatar_to_square(upload.path) do + {:ok, %File.Stat{size: cropped_size}} = File.stat(cropped_path) + cropped_upload = %{upload | path: cropped_path} + upload_result = upload_s3(cropped_upload, user_id, "avatars") + File.rm(cropped_path) + + case upload_result do + {:ok, key} -> + {:ok, + %{ + key: key, + filename: anonymized_filename_from_key(key), + content_type: upload.content_type, + size: cropped_size + }} + + error -> + error + end + else + error -> + Logger.error("Avatar upload failed for user #{user_id}: #{inspect(error)}") + error + end + + emit_upload_result(:avatar, result) + result + end + + def upload_background(%Plug.Upload{} = upload, user_id) do + result = + with {:ok, %File.Stat{size: file_size}} <- File.stat(upload.path), + :ok <- validate_background_upload(upload, user_id) do + store_upload_with_metadata(upload, user_id, "backgrounds", file_size) + end + + emit_upload_result(:background, result) + result + end + + def upload_favicon(%Plug.Upload{} = upload, user_id) do + result = + with {:ok, %File.Stat{size: file_size}} <- File.stat(upload.path), + :ok <- validate_favicon_upload(upload, user_id) do + store_upload_with_metadata(upload, user_id, "favicons", file_size) + end + + emit_upload_result(:favicon, result) + result + end + + @doc "Uploads a chat attachment (image or file) and returns the public URL.\n\nReturns {:ok, url} on success or {:error, reason} on failure.\n" + def upload_chat_attachment(%Plug.Upload{} = upload, user_id) do + result = + with {:ok, %File.Stat{size: file_size}} <- File.stat(upload.path), + :ok <- validate_chat_attachment_upload(upload, user_id) do + store_upload_with_metadata(upload, user_id, "chat-attachments", file_size) + else + error -> + Logger.error("Chat attachment upload failed for user #{user_id}: #{inspect(error)}") + error + end + + emit_upload_result(:chat_attachment, result) + result + end + + @doc "Uploads a Kairo source file and returns private attachment metadata." + def upload_kairo_source(%Plug.Upload{} = upload, user_id) do + result = + with {:ok, %File.Stat{size: file_size}} <- File.stat(upload.path), + :ok <- check_user_storage_limit(user_id, file_size), + :ok <- validate_kairo_source_upload(upload, user_id) do + store_upload_with_metadata(upload, user_id, "kairo-sources", file_size) + else + error -> + Logger.error("Kairo source upload failed for user #{user_id}: #{inspect(error)}") + error + end + + emit_upload_result(:kairo_source, result) + result + end + + @doc "Uploads a timeline post attachment (images, media).\n" + def upload_timeline_attachment(%Plug.Upload{} = upload, user_id) do + result = + with {:ok, %File.Stat{size: file_size}} <- File.stat(upload.path), + :ok <- validate_chat_attachment_upload(upload, user_id) do + store_upload_with_metadata(upload, user_id, "timeline-attachments", file_size) + else + error -> + Logger.error("Timeline attachment upload failed for user #{user_id}: #{inspect(error)}") + error + end + + emit_upload_result(:timeline_attachment, result) + result + end + + @doc "Uploads a gallery photo.\n" + def upload_gallery_photo(%Plug.Upload{} = upload, user_id) do + result = + with {:ok, %File.Stat{size: file_size}} <- File.stat(upload.path), + :ok <- validate_chat_attachment_upload(upload, user_id) do + store_upload_with_metadata(upload, user_id, "gallery-attachments", file_size) + else + error -> + Logger.error("Gallery photo upload failed for user #{user_id}: #{inspect(error)}") + error + end + + emit_upload_result(:gallery_photo, result) + result + end + + @doc "Uploads a voice message from binary audio data.\nReturns {:ok, metadata} on success or {:error, reason} on failure.\n" + def upload_voice_message(audio_binary, filename, mime_type, user_id) + when is_binary(audio_binary) do + max_size = 10 * 1024 * 1024 + + result = + with :ok <- validate_voice_message(audio_binary, mime_type, max_size, user_id) do + store_voice_message(audio_binary, filename, mime_type, user_id) + end + + emit_upload_result(:voice_message, result) + result + end + + defp validate_voice_message(audio_binary, mime_type, max_size, user_id) do + file_size = byte_size(audio_binary) + + cond do + file_size == 0 -> + {:error, {:empty_file, "File is empty"}} + + file_size > max_size -> + {:error, :file_too_large} + + mime_type not in @voice_message_mime_types -> + {:error, + {:invalid_file_type, + "File type #{mime_type} not allowed. Allowed types: #{Enum.join(@voice_message_mime_types, ", ")}"}} + + true -> + with :ok <- check_user_storage_limit(user_id, file_size), + :ok <- validate_magic_bytes(audio_binary, mime_type) do + scan_for_malicious_content(audio_binary) + end + end + end + + defp store_voice_message(audio_binary, filename, mime_type, user_id) do + file_size = byte_size(audio_binary) + content_hash = sha256_hex(audio_binary) + + upload_result = + case get_config(:adapter) do + :local -> upload_binary_local(audio_binary, filename, user_id, "voice-messages") + :s3 -> upload_binary_s3(audio_binary, filename, mime_type, user_id, "voice-messages") + end + + case upload_result do + {:ok, key} -> + {:ok, + %{ + key: key, + filename: anonymized_filename(filename, content_hash), + content_type: mime_type, + size: file_size, + sha256: content_hash + }} + + error -> + error + end + end + + defp upload_binary_local(binary, filename, user_id, folder) do + uploads_dir = get_config(:uploads_dir) || "priv/static/uploads" + target_dir = Path.join(uploads_dir, folder) + content_hash = sha256_hex(binary) + relative_path = content_addressed_relative_path(content_hash, filename, user_id) + filepath = Path.join(target_dir, relative_path) + + with :ok <- File.mkdir_p(Path.dirname(filepath)), + :ok <- write_if_missing(filepath, binary) do + {:ok, "/uploads/#{folder}/#{relative_path}"} + else + {:error, reason} -> {:error, reason} + end + end + + defp upload_binary_s3(binary, filename, mime_type, user_id, folder) do + bucket = get_config(:bucket) + _endpoint = get_config(:endpoint) || raise "S3_ENDPOINT not configured" + content_hash = sha256_hex(binary) + key = "#{folder}/#{content_addressed_relative_path(content_hash, filename, user_id)}" + + case ExAws.S3.put_object(bucket, key, binary, s3_put_options(key, mime_type)) + |> ExAws.request() do + {:ok, _response} -> {:ok, key} + {:error, reason} -> {:error, "Upload failed: #{inspect(reason)}"} + end + end + + defp store_upload_with_metadata(upload, user_id, folder, file_size) do + with :ok <- validate_image_dimensions(upload), + {:ok, processed_path} <- strip_metadata_if_image(upload) do + upload_to_use = + if processed_path == upload.path do + upload + else + %{upload | path: processed_path} + end + + try do + case store_upload(upload_to_use, user_id, folder) do + {:ok, key} -> + {:ok, build_upload_result_metadata(upload, upload_to_use.path, key, file_size)} + + error -> + error + end + after + cleanup_processed_upload(processed_path, upload.path) + end + end + end + + defp store_upload(upload, user_id, folder) do + case get_config(:adapter) do + :local -> upload_local(upload, user_id, folder) + :s3 -> upload_s3(upload, user_id, folder) + end + end + + defp cleanup_processed_upload(processed_path, original_path) + when processed_path == original_path, + do: :ok + + defp cleanup_processed_upload(processed_path, _original_path), do: File.rm(processed_path) + + defp build_upload_result_metadata(upload, upload_path, key, file_size) do + content_hash = file_sha256(upload_path) + + %{ + key: key, + filename: anonymized_filename(upload.filename, content_hash), + content_type: upload.content_type, + size: file_size, + sha256: content_hash + } + |> maybe_put_image_dimensions(upload_path, upload.content_type) + end + + defp maybe_put_image_dimensions(metadata, upload_path, content_type) do + case image_dimensions(upload_path, content_type) do + {width, height} when is_integer(width) and is_integer(height) -> + metadata + |> Map.put(:width, width) + |> Map.put(:height, height) + + _ -> + metadata + end + end + + defp image_dimensions(path, content_type) + when is_binary(path) and is_binary(content_type) do + if image_content_type?(content_type) and image_open_available?() do + image = Image.open!(path) + {Image.width(image), Image.height(image)} + else + nil + end + rescue + _ -> nil + end + + defp image_dimensions(_path, _content_type), do: nil + + @doc "Uploads a discussion post attachment.\n" + def upload_discussion_attachment(%Plug.Upload{} = upload, user_id) do + result = + with {:ok, %File.Stat{size: file_size}} <- File.stat(upload.path), + :ok <- validate_chat_attachment_upload(upload, user_id) do + store_upload_with_metadata(upload, user_id, "discussion-attachments", file_size) + else + error -> + Logger.error( + "Discussion attachment upload failed for user #{user_id}: #{inspect(error)}" + ) + + error + end + + emit_upload_result(:discussion_attachment, result) + result + end + + defp validate_upload(%Plug.Upload{} = upload, user_id) do + with :ok <- validate_file_size(upload, :avatar, user_id), + :ok <- validate_file_type(upload, @allowed_mime_types), + :ok <- validate_file_extension(upload, @allowed_extensions) do + with :ok <- validate_avatar_content(upload), do: validate_image_dimensions(upload) + end + end + + defp validate_image_dimensions(%Plug.Upload{path: path, content_type: content_type}) do + if image_content_type?(content_type) and image_open_available?() do + image = Image.open!(path) + width = Image.width(image) + height = Image.height(image) + max_width = get_config(:max_image_width) || 2048 + max_height = get_config(:max_image_height) || 2048 + + if width <= max_width and height <= max_height and width * height <= max_width * max_height, + do: :ok, + else: {:error, :image_dimensions_exceeded} + else + :ok + end + rescue + _ -> if Mix.env() == :test, do: :ok, else: {:error, :invalid_image} + end + + defp validate_background_upload(%Plug.Upload{} = upload, user_id) do + with :ok <- validate_file_size(upload, :background, user_id), + :ok <- validate_file_type(upload, @background_mime_types), + :ok <- validate_file_extension(upload, @background_extensions) do + validate_background_content(upload) + end + end + + defp validate_favicon_upload(%Plug.Upload{} = upload, user_id) do + with :ok <- validate_file_size(upload, :favicon, user_id), + :ok <- validate_file_type(upload, @favicon_mime_types), + :ok <- validate_file_extension(upload, @favicon_extensions) do + validate_favicon_content(upload) + end + end + + defp validate_chat_attachment_upload(%Plug.Upload{} = upload, user_id) do + with :ok <- validate_file_size(upload, :chat_attachment, user_id), + :ok <- validate_file_type(upload, @chat_attachment_mime_types), + :ok <- validate_file_extension(upload, @chat_attachment_extensions) do + validate_attachment_content(upload) + end + end + + defp validate_kairo_source_upload(%Plug.Upload{} = upload, user_id) do + with :ok <- validate_file_size(upload, :kairo_source, user_id), + :ok <- validate_file_type(upload, @kairo_source_mime_types), + :ok <- validate_file_extension(upload, @kairo_source_extensions) do + validate_attachment_content(upload) + end + end + + defp validate_file_size(%Plug.Upload{} = upload, type, user_id) do + user = Accounts.get_user!(user_id) + + if user.is_admin do + :ok + else + upload.path |> validate_size_against_limit(max_file_size_for_upload_type(type)) + end + end + + defp max_file_size_for_upload_type(:background) do + get_config(:max_background_size) || @default_max_background_size + end + + defp max_file_size_for_upload_type(:chat_attachment) do + Constants.max_chat_attachment_size() + end + + defp max_file_size_for_upload_type(:kairo_source) do + Constants.max_chat_attachment_size() + end + + defp max_file_size_for_upload_type(:favicon) do + Constants.max_favicon_size() + end + + defp max_file_size_for_upload_type(_) do + get_config(:max_file_size) || @default_max_file_size + end + + defp validate_size_against_limit(path, max_file_size) do + case File.stat(path) do + {:ok, %File.Stat{size: size}} when size > max_file_size -> + {:error, + {:file_too_large, + "File size #{Float.round(size / (1024 * 1024), 2)}MB exceeds #{max_file_size / (1024 * 1024)}MB limit"}} + + {:ok, %File.Stat{size: 0}} -> + {:error, {:empty_file, "File is empty"}} + + {:ok, %File.Stat{size: _size}} -> + :ok + + {:error, reason} -> + {:error, {:file_access_error, reason}} + end + end + + defp validate_file_type(%Plug.Upload{content_type: content_type}, allowed_types) do + if content_type in allowed_types do + :ok + else + {:error, + {:invalid_file_type, + "File type #{content_type} not allowed. Allowed types: #{Enum.join(allowed_types, ", ")}"}} + end + end + + defp validate_file_extension(%Plug.Upload{filename: filename}, allowed_extensions) do + extension = filename |> Path.extname() |> String.downcase() + + if extension in allowed_extensions do + :ok + else + {:error, + {:invalid_extension, + "File extension #{extension} not allowed. Allowed extensions: #{Enum.join(allowed_extensions, ", ")}"}} + end + end + + defp validate_avatar_content(%Plug.Upload{} = upload) do + with {:ok, content} <- File.read(upload.path), + :ok <- validate_magic_bytes(content, upload.content_type) do + scan_for_malicious_content(content) + end + end + + defp validate_background_content(%Plug.Upload{} = upload) do + with {:ok, content} <- File.read(upload.path), + :ok <- validate_magic_bytes(content, upload.content_type) do + scan_for_malicious_content(content) + end + end + + defp validate_attachment_content(%Plug.Upload{} = upload) do + with {:ok, content} <- File.read(upload.path), + :ok <- validate_magic_bytes(content, upload.content_type) do + scan_for_malicious_content(content) + end + end + + defp validate_favicon_content(%Plug.Upload{} = upload) do + with {:ok, content} <- File.read(upload.path), + :ok <- validate_magic_bytes(content, upload.content_type) do + scan_for_malicious_content(content) + end + end + + defp validate_magic_bytes(content, content_type) do + normalized_type = + case content_type do + "image/jpg" -> "image/jpeg" + "audio/mp3" -> "audio/mpeg" + "audio/m4a" -> "audio/mp4" + type -> type + end + + if magic_bytes_match?(content, normalized_type) do + :ok + else + {:error, + {:invalid_file_format, "File content does not match declared type #{content_type}"}} + end + end + + defp magic_bytes_match?(content, type) when type in ["video/mp4", "video/quicktime"] do + iso_base_media_match?(content, ["isom", "iso2", "mp41", "mp42", "qt "]) + end + + defp magic_bytes_match?(content, type) when type in ["image/heic", "image/heif"] do + iso_base_media_match?(content, ["heic", "heix", "hevc", "hevx", "mif1", "msf1"]) + end + + defp magic_bytes_match?(content, "image/avif") do + iso_base_media_match?(content, ["avif", "avis"]) + end + + defp magic_bytes_match?(content, type) when type in ["audio/mp4", "audio/aac"] do + iso_base_media_match?(content, ["M4A ", "mp42", "isom"]) or aac_adts_match?(content) + end + + defp magic_bytes_match?(content, "audio/mpeg") do + starts_with_any?(content, ["ID3"]) or mp3_frame_sync_match?(content) + end + + defp magic_bytes_match?(content, "image/webp") do + starts_with_any?(content, ["RIFF"]) and binary_at?(content, 8, "WEBP") + end + + defp magic_bytes_match?(content, "audio/wav") do + starts_with_any?(content, ["RIFF"]) and binary_at?(content, 8, "WAVE") + end + + defp magic_bytes_match?(content, "text/plain") do + :binary.match(content, <<0>>) == :nomatch + end + + defp magic_bytes_match?(content, type) + when type in ["application/json", "application/markdown", "text/markdown"] do + :binary.match(content, <<0>>) == :nomatch + end + + defp magic_bytes_match?(content, type) do + case Map.get(@magic_bytes, type) do + nil -> true + expected_signatures -> starts_with_any?(content, expected_signatures) + end + end + + defp iso_base_media_match?(content, allowed_brands) do + byte_size(content) >= 12 and binary_at?(content, 4, "ftyp") and + allowed_brands + |> Enum.any?(fn brand -> + binary_at?(content, 8, brand) or binary_part_present?(content, brand, 16) + end) + end + + defp starts_with_any?(content, signatures) do + Enum.any?(signatures, fn signature -> + byte_size(content) >= byte_size(signature) and + :binary.part(content, 0, byte_size(signature)) == signature + end) + end + + defp binary_at?(content, offset, expected) do + byte_size(content) >= offset + byte_size(expected) and + :binary.part(content, offset, byte_size(expected)) == expected + end + + defp binary_part_present?(content, expected, max_offset) do + search_size = min(byte_size(content), max_offset) + + content + |> :binary.part(0, search_size) + |> :binary.match(expected) + |> Kernel.!==(:nomatch) + end + + defp aac_adts_match?(<<255, second, _rest::binary>>) when second in [241, 249], do: true + defp aac_adts_match?(_), do: false + + defp mp3_frame_sync_match?(<<255, second, _rest::binary>>) when (second &&& 224) == 224, + do: true + + defp mp3_frame_sync_match?(_), do: false + + defp scan_for_malicious_content(content) do + scan_content = + if byte_size(content) > 1024 do + :binary.part(content, 0, 1024) + else + content + end + + content_str = + case :unicode.characters_to_binary(scan_content, :latin1, :utf8) do + result when is_binary(result) -> result + _ -> scan_content + end + + malicious_found = + Enum.find(@malicious_patterns, fn pattern -> Regex.match?(pattern, content_str) end) + + case malicious_found do + nil -> :ok + _pattern -> {:error, {:malicious_content, "File contains potentially malicious content"}} + end + end + + defp upload_local(%Plug.Upload{} = upload, user_id, folder) do + uploads_dir = get_config(:uploads_dir) || "priv/static/uploads" + target_dir = Path.join(uploads_dir, folder) + content_hash = file_sha256(upload.path) + relative_path = content_addressed_relative_path(content_hash, upload.filename, user_id) + filepath = Path.join(target_dir, relative_path) + + with :ok <- File.mkdir_p(Path.dirname(filepath)), + :ok <- copy_if_missing(upload.path, filepath) do + {:ok, "/uploads/#{folder}/#{relative_path}"} + else + {:error, reason} -> {:error, reason} + end + end + + defp upload_s3(%Plug.Upload{} = upload, user_id, folder) do + bucket = get_config(:bucket) + _endpoint = get_config(:endpoint) || raise "S3_ENDPOINT not configured" + + case File.read(upload.path) do + {:ok, file_content} -> + content_type = upload.content_type || MIME.from_path(upload.filename) + content_hash = sha256_hex(file_content) + + key = + "#{folder}/#{content_addressed_relative_path(content_hash, upload.filename, user_id)}" + + case ExAws.S3.put_object(bucket, key, file_content, s3_put_options(key, content_type)) + |> ExAws.request() do + {:ok, _response} -> {:ok, key} + {:error, reason} -> {:error, "Upload failed: #{inspect(reason)}"} + end + + {:error, reason} -> + {:error, "Failed to read file: #{inspect(reason)}"} + end + end + + @doc "Deletes an avatar file from storage.\n\nTakes the full URL and extracts the necessary information to delete the file.\n" + def delete_avatar(nil) do + :ok + end + + def delete_avatar("") do + :ok + end + + def delete_avatar(url) when is_binary(url) do + result = delete_storage_object(url) + + emit_upload_result(:delete_avatar, normalize_delete_result(result)) + result + end + + @doc "Deletes a background file from storage.\n\nTakes the full URL and extracts the necessary information to delete the file.\n" + def delete_background(nil) do + :ok + end + + def delete_background("") do + :ok + end + + def delete_background(url) when is_binary(url) do + result = delete_storage_object(url) + + emit_upload_result(:delete_background, normalize_delete_result(result)) + result + end + + @doc """ + Deletes an uploaded file from storage. + + Accepts: + - S3 key (for example `chat-attachments/123_file.jpg`) + - Local upload path (`/uploads/chat-attachments/123_file.jpg`) + - Full URL (`https://bucket.endpoint/chat-attachments/123_file.jpg`) + """ + def delete_uploaded_file(nil), do: :ok + def delete_uploaded_file(""), do: :ok + + def delete_uploaded_file(value) when is_binary(value) do + result = delete_storage_object(value) + emit_upload_result(:delete_uploaded_file, normalize_delete_result(result)) + result + end + + defp delete_storage_object(value) when is_binary(value) do + case get_config(:adapter) do + :local -> delete_local_object(value) + :s3 -> delete_s3_object(value) + end + end + + defp delete_local_object(value) do + uploads_dir = get_config(:uploads_dir) || "priv/static/uploads" + uploads_root = Path.expand(uploads_dir) + + case normalize_local_delete_key(value) do + {:ok, key} -> + cond do + invalid_delete_key?(key) -> + {:error, :invalid_upload_key} + + not allowed_delete_prefix?(key) -> + {:error, :invalid_upload_key} + + true -> + filepath = Path.expand(Path.join(uploads_root, key)) + + if String.starts_with?(filepath, uploads_root <> "/") do + case File.rm(filepath) do + :ok -> :ok + {:error, :enoent} -> :ok + {:error, reason} -> {:error, reason} + end + else + {:error, :invalid_upload_key} + end + end + + {:error, reason} -> + {:error, reason} + end + end + + defp delete_s3_object(value) do + bucket = get_config(:bucket) + + case normalize_s3_delete_key(value) do + {:ok, key} -> + cond do + invalid_delete_key?(key) -> + {:error, :invalid_s3_key} + + not allowed_delete_prefix?(key) -> + {:error, :invalid_s3_key} + + true -> + case ExAws.S3.delete_object(bucket, key) |> ExAws.request() do + {:ok, _response} -> :ok + {:error, reason} -> {:error, reason} + end + end + + {:error, reason} -> + {:error, reason} + end + end + + defp normalize_local_delete_key(value) when is_binary(value) do + key = + cond do + String.starts_with?(value, "/uploads/") -> + strip_exact_prefix(value, "/uploads/") + + String.starts_with?(value, "uploads/") -> + strip_exact_prefix(value, "uploads/") + + String.starts_with?(value, "http://") or String.starts_with?(value, "https://") -> + value + |> URI.parse() + |> Map.get(:path, "") + |> normalize_local_delete_path() + + String.starts_with?(value, "/") -> + strip_exact_prefix(value, "/") + + true -> + value + end + + if key == "" do + {:error, :invalid_upload_key} + else + {:ok, key} + end + end + + defp normalize_s3_delete_key(value) when is_binary(value) do + key = + cond do + String.starts_with?(value, "http://") or String.starts_with?(value, "https://") -> + value + |> URI.parse() + |> Map.get(:path, "") + |> normalize_s3_delete_path() + + String.starts_with?(value, "/uploads/") -> + strip_exact_prefix(value, "/uploads/") + + String.starts_with?(value, "uploads/") -> + strip_exact_prefix(value, "uploads/") + + String.starts_with?(value, "/") -> + strip_exact_prefix(value, "/") + + true -> + value + end + + if key == "" do + {:error, :invalid_s3_key} + else + {:ok, key} + end + end + + defp normalize_local_delete_path(path) when is_binary(path) do + cond do + String.starts_with?(path, "/uploads/") -> strip_exact_prefix(path, "/uploads/") + String.starts_with?(path, "/") -> strip_exact_prefix(path, "/") + true -> path + end + end + + defp normalize_s3_delete_path(path) when is_binary(path) do + path + |> normalize_local_delete_path() + |> strip_exact_prefix("uploads/") + end + + defp strip_exact_prefix(value, prefix) when is_binary(value) and is_binary(prefix) do + if String.starts_with?(value, prefix) do + binary_part(value, byte_size(prefix), byte_size(value) - byte_size(prefix)) + else + value + end + end + + defp allowed_delete_prefix?(key) when is_binary(key) do + Enum.any?(@deletable_upload_prefixes, &String.starts_with?(key, &1)) + end + + defp invalid_delete_key?(key) when is_binary(key) do + String.contains?(key, ["..", "\\", <<0>>]) or String.starts_with?(key, "/") or + String.contains?(key, "//") or String.match?(key, ~r/[\x00-\x1F\x7F]/) + end + + defp allowed_private_attachment_prefix?(key) when is_binary(key) do + Enum.any?(@private_attachment_prefixes, &String.starts_with?(key, &1)) + end + + @doc "Returns the public URL for an avatar.\n\nThe avatar value could be a filename or a full URL depending on storage adapter.\n" + def avatar_url(nil) do + nil + end + + def avatar_url("") do + nil + end + + def avatar_url(avatar) when is_binary(avatar) do + case get_config(:adapter) do + :local -> local_upload_url(avatar, "avatars") + :s3 -> remote_upload_url(avatar, "avatars") + end + end + + @doc "Returns the public URL for media attachments (timeline, chat, discussion).\n\nHandles S3 keys like \"timeline-attachments/user123/abc.jpg\" and converts them to full URLs.\n" + def media_url(nil) do + nil + end + + def media_url("") do + nil + end + + def media_url(key) when is_binary(key) do + if String.starts_with?(key, "http") do + key + else + public_storage_url(key) + end + end + + @doc "Returns the public URL for a background.\n\nThe background value could be a filename or a full URL depending on storage adapter.\n" + def background_url(nil) do + nil + end + + def background_url("") do + nil + end + + def background_url(background) when is_binary(background) do + case get_config(:adapter) do + :local -> local_upload_url(background, "backgrounds") + :s3 -> remote_upload_url(background, "backgrounds") + end + end + + def favicon_url(nil) do + nil + end + + def favicon_url("") do + nil + end + + def favicon_url(favicon) when is_binary(favicon) do + case get_config(:adapter) do + :local -> local_upload_url(favicon, "favicons") + :s3 -> remote_upload_url(favicon, "favicons") + end + end + + @doc "Returns the URL for a chat attachment.\nUses direct URL for public/group content, presigned URL for private DMs.\n\nThe attachment value could be a filename or a full URL depending on storage adapter.\n" + def attachment_url(nil) do + nil + end + + def attachment_url("") do + nil + end + + def attachment_url(attachment, conversation \\ nil) + + def attachment_url(attachment, conversation) when is_binary(attachment) do + is_private = private_attachment_context?(conversation) + + if is_private do + attachment_url_presigned(attachment) + else + attachment_url_direct(attachment) + end + end + + defp private_attachment_context?(%{type: "dm"}), do: true + + defp private_attachment_context?(%{visibility: visibility}) + when visibility in ["followers", "friends", "private"], + do: true + + defp private_attachment_context?(_), do: false + + defp attachment_url_direct(attachment) when is_binary(attachment) do + case get_config(:adapter) do + :s3 -> remote_attachment_url(attachment) + _ -> local_attachment_url(attachment) + end + end + + defp local_upload_url(value, folder) do + if String.starts_with?(value, "/") do + value + else + "/uploads/#{folder}/#{value}" + end + end + + defp remote_upload_url(value, prefix) do + if String.starts_with?(value, "http") do + value + else + value |> prefixed_key(prefix) |> public_storage_url() + end + end + + defp local_attachment_url(attachment) do + cond do + String.starts_with?(attachment, "http") -> + {:error, :private_attachment_url_not_allowed} + + String.starts_with?(attachment, "/") -> + attachment + + true -> + key = local_attachment_key(attachment) + + case local_private_attachment_url(key) do + url when is_binary(url) -> url + _ -> "/uploads/#{key}" + end + end + end + + defp local_attachment_key(attachment) do + if prefixed_attachment_key?(attachment) do + attachment + else + "attachments/#{attachment}" + end + end + + defp prefixed_attachment_key?(attachment) do + Enum.any?( + [ + "chat-attachments/", + "timeline-attachments/", + "discussion-attachments/", + "gallery-attachments/", + "kairo-sources/" + ], + &String.starts_with?(attachment, &1) + ) + end + + defp remote_attachment_url(attachment) do + if String.starts_with?(attachment, "http") do + attachment + else + key = + if String.contains?(attachment, "/") do + attachment + else + "chat-attachments/#{attachment}" + end + + public_storage_url(key) + end + end + + defp prefixed_key(value, prefix) do + if String.starts_with?(value, "#{prefix}/") do + value + else + "#{prefix}/#{value}" + end + end + + defp public_storage_url(key) do + case get_config(:public_url) do + nil -> + bucket = get_config(:bucket) || raise "S3_BUCKET_NAME not configured" + endpoint = get_config(:endpoint) || raise "S3_ENDPOINT not configured" + "https://#{bucket}.#{endpoint}/#{key}" + + public_url -> + "#{public_url}/#{key}" + end + end + + defp attachment_url_presigned(attachment) do + if String.starts_with?(attachment, "http") do + attachment + else + attachment + |> private_attachment_storage_key() + |> local_private_attachment_url() + end + end + + defp s3_put_options(key, content_type) do + [content_type: content_type] + |> maybe_put_private_acl(key) + end + + defp maybe_put_private_acl(opts, key) when is_binary(key) do + if allowed_private_attachment_prefix?(key), do: Keyword.put(opts, :acl, :private), else: opts + end + + def verify_private_attachment_token(token) when is_binary(token) do + with {:ok, key} <- + Phoenix.Token.verify( + ElektrineWeb.Endpoint, + @private_attachment_token_salt, + token, + max_age: @private_attachment_token_ttl_seconds + ), + {:ok, normalized_key} <- normalize_local_delete_key(key), + false <- invalid_delete_key?(normalized_key), + true <- allowed_private_attachment_prefix?(normalized_key) do + {:ok, normalized_key} + else + true -> {:error, :invalid_attachment_key} + {:error, reason} -> {:error, reason} + _ -> {:error, :invalid_token} + end + end + + def verify_private_attachment_token(_token), do: {:error, :invalid_token} + + def private_attachment_accessible_by_user?(key, user_id) + when is_binary(key) and is_integer(user_id) do + normalized_upload_path = "/uploads/#{key}" + + chat_query = + from(message in ChatMessage, + join: member in ChatConversationMember, + on: + member.conversation_id == message.conversation_id and member.user_id == ^user_id and + is_nil(member.left_at), + where: + fragment("? = ANY(?)", ^key, message.media_urls) or + fragment("? = ANY(?)", ^normalized_upload_path, message.media_urls), + select: 1, + limit: 1 + ) + + kairo_query = + from(source in "kairo_sources", + where: field(source, :user_id) == ^user_id, + where: + fragment("?->>'key' = ?", field(source, :metadata), ^key) or + fragment("?->>'storage_key' = ?", field(source, :metadata), ^key) or + fragment("?->>'url' = ?", field(source, :metadata), ^normalized_upload_path), + select: 1, + limit: 1 + ) + + Repo.exists?(chat_query) or Repo.exists?(kairo_query) + end + + def private_attachment_accessible_by_user?(_, _), do: false + + def private_attachment_local_path(key) when is_binary(key) do + if get_config(:adapter) != :local do + {:error, :unsupported_adapter} + else + uploads_dir = get_config(:uploads_dir) || "priv/static/uploads" + uploads_root = Path.expand(uploads_dir) + filepath = Path.expand(Path.join(uploads_root, key)) + + cond do + invalid_delete_key?(key) -> + {:error, :invalid_attachment_key} + + not allowed_private_attachment_prefix?(key) -> + {:error, :invalid_attachment_key} + + not String.starts_with?(filepath, uploads_root <> "/") -> + {:error, :invalid_attachment_key} + + true -> + case FilePath.validate_existing_file(filepath, uploads_root) do + {:ok, filepath} -> {:ok, filepath} + {:error, :file_not_found} -> {:error, :not_found} + {:error, _reason} -> {:error, :invalid_attachment_key} + end + end + end + end + + def private_attachment_local_path(_key), do: {:error, :invalid_attachment_key} + + def private_attachment_content(key) when is_binary(key) do + with {:ok, normalized_key} <- normalize_local_delete_key(key), + false <- invalid_delete_key?(normalized_key), + true <- allowed_private_attachment_prefix?(normalized_key) do + case get_config(:adapter) do + :local -> read_local_private_attachment(normalized_key) + :s3 -> read_s3_private_attachment(normalized_key) + _ -> {:error, :unsupported_adapter} + end + else + true -> {:error, :invalid_attachment_key} + false -> {:error, :invalid_attachment_key} + {:error, reason} -> {:error, reason} + _ -> {:error, :invalid_attachment_key} + end + end + + def private_attachment_content(_key), do: {:error, :invalid_attachment_key} + + defp local_private_attachment_url(attachment) do + with {:ok, key} <- normalize_local_delete_key(attachment), + false <- invalid_delete_key?(key), + true <- allowed_private_attachment_prefix?(key) do + token = Phoenix.Token.sign(ElektrineWeb.Endpoint, @private_attachment_token_salt, key) + "/api/private-attachments/#{token}" + else + _ -> attachment_url_direct(attachment) + end + end + + defp private_attachment_storage_key(attachment) when is_binary(attachment) do + if String.contains?(attachment, "/") do + attachment + else + "chat-attachments/#{attachment}" + end + end + + defp read_local_private_attachment(key) do + with {:ok, filepath} <- private_attachment_local_path(key), + {:ok, body} <- File.read(filepath) do + {:ok, + %{ + body: body, + content_type: MIME.from_path(filepath), + filename: Path.basename(filepath) + }} + else + {:error, reason} -> {:error, reason} + end + end + + defp read_s3_private_attachment(key) do + bucket = get_config(:bucket) + + if is_binary(bucket) and bucket != "" do + case ExAws.S3.get_object(bucket, key) |> ExAws.request() do + {:ok, %{body: body} = response} -> + {:ok, + %{ + body: body, + content_type: s3_object_content_type(response) || MIME.from_path(key), + filename: Path.basename(key) + }} + + {:error, reason} -> + {:error, reason} + end + else + {:error, :missing_bucket} + end + end + + defp s3_object_content_type(%{headers: headers}) when is_list(headers) do + Enum.find_value(headers, fn + {key, value} when is_binary(value) -> + if String.downcase(to_string(key)) == "content-type", do: value + + _ -> + nil + end) + end + + defp s3_object_content_type(_response), do: nil + + defp crop_avatar_to_square(image_path) do + output_path = image_path <> "_cropped.jpg" + process_avatar_crop(image_path, output_path) + rescue + e -> final_avatar_crop_fallback(image_path, e) + end + + defp process_avatar_crop(image_path, output_path) do + if image_thumbnail_available?() do + crop_avatar_with_image_lib(image_path, output_path) + else + copy_avatar_with_fallback(image_path, output_path) + end + end + + defp image_thumbnail_available? do + Code.ensure_loaded?(Image) and function_exported?(Image, :thumbnail!, 3) + end + + defp crop_avatar_with_image_lib(image_path, output_path) do + thumb = Image.thumbnail!(image_path, 256, height: 256, crop: :center, resize: :both) + Image.write!(thumb, output_path, suffix: ".jpg", quality: 90, strip_metadata: true) + {:ok, output_path} + rescue + e -> + Logger.warning("Image processing failed (#{Exception.message(e)}), using fallback") + copy_avatar_with_fallback(image_path, output_path) + end + + defp copy_avatar_with_fallback(source, destination) do + case File.cp(source, destination) do + :ok -> {:ok, destination} + {:error, reason} -> {:error, "Failed to process avatar: #{inspect(reason)}"} + end + end + + defp final_avatar_crop_fallback(image_path, exception) do + output_path = image_path <> "_cropped.jpg" + + case File.cp(image_path, output_path) do + :ok -> {:ok, output_path} + {:error, _reason} -> {:error, "Avatar cropping failed: #{Exception.message(exception)}"} + end + end + + defp sanitize_filename(filename) when is_binary(filename) do + basename = Path.basename(filename) + extension = sanitized_extension(Path.extname(basename)) + + basename + |> Path.rootname() + |> sanitized_filename_root() + |> then(&(&1 <> extension)) + |> String.slice(0, 100) + end + + defp sanitize_filename(_) do + "file" + end + + defp content_addressed_relative_path(content_hash, filename, user_id) + when is_binary(content_hash) do + filename = content_hash <> upload_extension(filename) + + Path.join([ + to_string(user_id), + binary_part(content_hash, 0, 2), + binary_part(content_hash, 2, 2), + binary_part(content_hash, 4, 2), + filename + ]) + end + + defp upload_extension(filename) do + filename + |> sanitize_filename() + |> Path.extname() + |> String.downcase() + |> case do + "" -> ".bin" + ext -> ext + end + end + + defp anonymized_filename(filename, content_hash) when is_binary(content_hash) do + content_hash <> upload_extension(filename) + end + + defp anonymized_filename_from_key(key) when is_binary(key) do + key + |> Path.basename() + |> sanitize_filename() + end + + defp file_sha256(path) when is_binary(path) do + path + |> File.read!() + |> sha256_hex() + end + + defp sha256_hex(binary) when is_binary(binary) do + binary + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + end + + defp copy_if_missing(source, destination) do + if File.exists?(destination), do: :ok, else: File.cp(source, destination) + end + + defp write_if_missing(destination, binary) do + if File.exists?(destination), do: :ok, else: File.write(destination, binary) + end + + defp sanitized_filename_root(name) do + name + |> String.replace(~r/[^A-Za-z0-9_-]+/, "_") + |> String.trim("_") + |> case do + "" -> "file" + "." -> "file" + ".." -> "file" + safe_name -> safe_name + end + end + + defp sanitized_extension(extension) do + extension = + extension + |> String.downcase() + |> String.replace(~r/[^A-Za-z0-9]+/, "") + |> String.slice(0, 16) + + if extension == "", do: "", else: ".#{extension}" + end + + defp strip_metadata_if_image(%Plug.Upload{content_type: content_type, path: path}) do + if image_content_type?(content_type) do + maybe_strip_image_metadata(path, content_type) + else + {:ok, path} + end + rescue + _e -> {:ok, path} + end + + defp image_content_type?(content_type) do + value = content_type || "" + String.starts_with?(value, "image/") and not String.contains?(value, "svg") + end + + defp maybe_strip_image_metadata(path, content_type) do + ext = extension_for_content_type(content_type) + output_path = path <> "_stripped" <> ext + + if image_open_available?() do + strip_with_image_lib(path, output_path, ext) + else + {:ok, path} + end + end + + defp extension_for_content_type("image/jpeg") do + ".jpg" + end + + defp extension_for_content_type("image/jpg") do + ".jpg" + end + + defp extension_for_content_type("image/png") do + ".png" + end + + defp extension_for_content_type("image/webp") do + ".webp" + end + + defp extension_for_content_type("image/gif") do + ".gif" + end + + defp extension_for_content_type("image/heic") do + ".heic" + end + + defp extension_for_content_type("image/heif") do + ".heif" + end + + defp extension_for_content_type("image/avif") do + ".avif" + end + + defp extension_for_content_type(_) do + ".jpg" + end + + defp image_open_available? do + Code.ensure_loaded?(Image) and function_exported?(Image, :open, 2) + end + + defp strip_with_image_lib(path, output_path, ext) do + img = Image.open!(path) + Image.write!(img, output_path, suffix: ext, strip_metadata: true) + {:ok, output_path} + rescue + _ -> {:ok, path} + end + + defp get_config(key) do + Application.get_env(:elektrine, :uploads, []) |> Keyword.get(key) + end + + defp emit_upload_result(type, result) do + metadata = + case result do + {:error, reason} -> %{reason: normalize_reason(reason)} + _ -> %{} + end + + outcome = + case result do + {:ok, _} -> :success + _ -> :failure + end + + Events.upload(type, outcome, result_size(result), metadata) + end + + defp result_size({:ok, %{size: size}}) when is_integer(size) do + size + end + + defp result_size(_) do + nil + end + + defp normalize_reason(reason) when is_atom(reason) do + reason + end + + defp normalize_reason({reason, _}) when is_atom(reason) do + reason + end + + defp normalize_reason({reason, _, _}) when is_atom(reason) do + reason + end + + defp normalize_reason(reason) do + inspect(reason) + end + + defp normalize_delete_result(:ok) do + {:ok, :deleted} + end + + defp normalize_delete_result(error) do + error + end +end diff --git a/apps/elektrine/lib/elektrine/user_notifier.ex b/apps/elektrine/lib/elektrine/user_notifier.ex new file mode 100644 index 0000000..f1ad353 --- /dev/null +++ b/apps/elektrine/lib/elektrine/user_notifier.ex @@ -0,0 +1,147 @@ +defmodule Elektrine.UserNotifier do + @moduledoc """ + Delivers user notifications via email. + """ + + import Swoosh.Email + alias Elektrine.EmailAddresses + alias Elektrine.Theme + + @doc """ + Deliver password reset instructions to the given user. + """ + def password_reset_instructions(user, reset_token) do + if user.recovery_email do + new() + |> to(user.recovery_email) + |> from(system_from_email()) + |> subject("Reset your Elektrine password") + |> html_body(password_reset_html_body(user, reset_token)) + |> text_body(password_reset_text_body(user, reset_token)) + |> header("List-Id", EmailAddresses.list_id("elektrine-password-reset")) + else + raise ArgumentError, "User #{user.username} has no recovery email set" + end + end + + defp password_reset_html_body(user, reset_token) do + reset_url = ElektrineWeb.Endpoint.url() <> "/password/reset/#{reset_token}" + palette = Theme.email_palette(user) + + """ + + + + + + + + + + + + + +
    + + + + +
    + + + + + +
    +

    Password Reset Request

    +
    + + + + + + +
    +

    + Hello #{user.username}, +

    +

    + You recently requested to reset your password for your Elektrine account. Click the button below to reset it: +

    + + + + + + +
    + + Reset Your Password + +
    + +

    + If the button doesn't work, you can copy and paste the following link into your browser: +

    + + + + + + +
    + #{reset_url} +
    + +

    + This link will expire in 1 hour for security reasons. +

    +

    + If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged. +

    +
    + + + + + + +
    +

    + This is an automated message from Elektrine. Please do not reply to this email. +

    +
    +
    +
    + + + """ + end + + defp password_reset_text_body(user, reset_token) do + reset_url = ElektrineWeb.Endpoint.url() <> "/password/reset/#{reset_token}" + + """ + Password Reset Request + + Hello #{user.username}, + + You recently requested to reset your password for your Elektrine account. + + To reset your password, visit the following link: + #{reset_url} + + This link will expire in 1 hour for security reasons. + + If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged. + + This is an automated message from Elektrine. Please do not reply to this email. + """ + end + + # Get system from email based on configuration + defp system_from_email do + {"Elektrine", EmailAddresses.local("noreply")} + end +end diff --git a/apps/elektrine/lib/elektrine/utils/safe_convert.ex b/apps/elektrine/lib/elektrine/utils/safe_convert.ex new file mode 100644 index 0000000..2ab28b5 --- /dev/null +++ b/apps/elektrine/lib/elektrine/utils/safe_convert.ex @@ -0,0 +1,178 @@ +defmodule Elektrine.Utils.SafeConvert do + @moduledoc """ + Provides safe conversion functions that handle invalid user input gracefully. + + These functions prevent crashes from invalid `String.to_integer()` calls by + returning default values or error tuples instead of raising ArgumentError. + """ + + @doc """ + Safely converts a string to an integer, returning a default value on failure. + + ## Examples + + iex> SafeConvert.to_integer("123") + 123 + + iex> SafeConvert.to_integer("invalid") + nil + + iex> SafeConvert.to_integer("invalid", 0) + 0 + + iex> SafeConvert.to_integer(123) + 123 + """ + def to_integer(value, default \\ nil) + + def to_integer(value, _default) when is_integer(value), do: value + + def to_integer(value, default) when is_binary(value) do + case Integer.parse(value) do + {int, ""} -> int + _ -> default + end + end + + def to_integer(_value, default), do: default + + @doc """ + Safely converts a string to an integer, always returning a value (never nil or raises). + + This is useful when you need to ensure you always get either the converted integer + or a specific default value. + + ## Examples + + iex> SafeConvert.to_integer!("123", 1) + 123 + + iex> SafeConvert.to_integer!("invalid", 1) + 1 + + iex> SafeConvert.to_integer!(nil, 1) + 1 + """ + def to_integer!(value, default) do + to_integer(value, default) + end + + @doc """ + Parses a page number from params, with safe defaults. + + ## Examples + + iex> SafeConvert.parse_page(%{"page" => "2"}) + 2 + + iex> SafeConvert.parse_page(%{"page" => "invalid"}) + 1 + + iex> SafeConvert.parse_page(%{}) + 1 + + iex> SafeConvert.parse_page(%{"p" => "3"}, "p") + 3 + """ + def parse_page(params, key \\ "page", default \\ 1) do + params + |> Map.get(key, "#{default}") + |> to_integer(default) + # Ensure page is at least 1 + |> max(1) + end + + @doc """ + Parses an ID string and returns a result tuple. + + ## Examples + + iex> SafeConvert.parse_id("123") + {:ok, 123} + + iex> SafeConvert.parse_id("invalid") + {:error, :invalid_id} + + iex> SafeConvert.parse_id(123) + {:ok, 123} + """ + def parse_id(value) when is_integer(value), do: {:ok, value} + + def parse_id(value) when is_binary(value) do + case Integer.parse(value) do + {int, ""} when int > 0 -> {:ok, int} + _ -> {:error, :invalid_id} + end + end + + def parse_id(_value), do: {:error, :invalid_id} + + @doc """ + Parses multiple IDs from a list of strings. + + ## Examples + + iex> SafeConvert.parse_ids(["1", "2", "3"]) + {:ok, [1, 2, 3]} + + iex> SafeConvert.parse_ids(["1", "invalid", "3"]) + {:error, :invalid_id} + """ + def parse_ids(values) when is_list(values) do + results = Enum.map(values, &parse_id/1) + + if Enum.all?(results, &match?({:ok, _}, &1)) do + {:ok, Enum.map(results, fn {:ok, id} -> id end)} + else + {:error, :invalid_id} + end + end + + @doc """ + Parses a limit parameter with validation and max cap. + + ## Examples + + iex> SafeConvert.parse_limit("50", 100) + 50 + + iex> SafeConvert.parse_limit("200", 100) + 100 + + iex> SafeConvert.parse_limit("invalid", 100) + 100 + """ + def parse_limit(value, max_limit) when is_binary(value) do + value + |> to_integer(max_limit) + |> min(max_limit) + |> max(1) + end + + def parse_limit(_value, max_limit), do: max_limit + + @doc """ + Parses a timeout value in seconds with validation. + + ## Examples + + iex> SafeConvert.parse_timeout("30") + 30 + + iex> SafeConvert.parse_timeout("invalid") + 30 + + iex> SafeConvert.parse_timeout("5", min: 10) + 10 + """ + def parse_timeout(value, opts \\ []) do + default = Keyword.get(opts, :default, 30) + min_timeout = Keyword.get(opts, :min, 1) + max_timeout = Keyword.get(opts, :max, 300) + + value + |> to_integer(default) + |> max(min_timeout) + |> min(max_timeout) + end +end diff --git a/apps/elektrine/lib/elektrine/utils/slug.ex b/apps/elektrine/lib/elektrine/utils/slug.ex new file mode 100644 index 0000000..d529045 --- /dev/null +++ b/apps/elektrine/lib/elektrine/utils/slug.ex @@ -0,0 +1,60 @@ +defmodule Elektrine.Utils.Slug do + @moduledoc """ + Utility functions for generating SEO-friendly slugs from titles and content. + """ + + @doc """ + Generates a SEO-friendly slug from a title. + """ + def slugify(text) when is_binary(text) do + text + |> String.downcase() + # Remove special chars except word chars, spaces, hyphens + |> String.replace(~r/[^\w\s-]/, "") + # Replace spaces with hyphens + |> String.replace(~r/\s+/, "-") + # Replace multiple hyphens with single + |> String.replace(~r/-+/, "-") + # Remove leading/trailing hyphens + |> String.trim("-") + # Limit length for URLs + |> String.slice(0, 60) + end + + def slugify(_), do: "" + + @doc """ + Generates a discussion URL slug combining post ID with title slug for SEO. + Format: /discussions/{community_hash}/{post_id}-{title_slug} + """ + def discussion_url_slug(post_id, title) when is_binary(title) and title != "" do + title_slug = slugify(title) + + if title_slug != "" do + "#{post_id}-#{title_slug}" + else + "#{post_id}" + end + end + + def discussion_url_slug(post_id, _), do: "#{post_id}" + + @doc """ + Extracts post ID from a discussion URL slug. + Handles both formats: "123-title-slug" and "123" + """ + def extract_post_id_from_slug(slug) when is_binary(slug) do + case String.split(slug, "-", parts: 2) do + [id_str | _] -> + case Integer.parse(id_str) do + {id, ""} -> id + _ -> nil + end + + _ -> + nil + end + end + + def extract_post_id_from_slug(_), do: nil +end diff --git a/apps/elektrine/lib/elektrine/vault.ex b/apps/elektrine/lib/elektrine/vault.ex new file mode 100644 index 0000000..1a7969c --- /dev/null +++ b/apps/elektrine/lib/elektrine/vault.ex @@ -0,0 +1,86 @@ +defmodule Elektrine.Vault do + @moduledoc """ + Encrypted data vault context. + + A user's password derives a key that wraps a single Master Data Key + (MDK); per-feature keys (Nerve, Kairo, email private storage) are derived from + the MDK in the browser via HKDF. The server only ever stores and returns the + wrapped MDK blobs - it never sees the passphrase, the recovery code, or the + MDK. Unlock, rotation, and recovery all happen client-side. + """ + import Ecto.Query, warn: false + + alias Elektrine.Accounts.User + alias Elektrine.Repo + alias Elektrine.Vault.MasterKey + + @doc "Whether the user has set up encrypted data." + def configured?(%User{id: user_id}), do: configured?(user_id) + + def configured?(user_id) when is_integer(user_id) do + Repo.exists?(from(mk in MasterKey, where: mk.user_id == ^user_id)) + end + + @doc "Returns the user's master key record (with the wrapped blobs), or nil." + def get(%User{id: user_id}), do: get(user_id) + + def get(user_id) when is_integer(user_id) do + Repo.get_by(MasterKey, user_id: user_id) + end + + @doc """ + Creates the user's master key for the first time. + + Fails if one already exists - replacing it would orphan everything encrypted + under the old MDK. Use `rotate/2` to change the passphrase or `reset/1` to + deliberately discard all encrypted data. + """ + def setup(%User{id: user_id}, attrs), do: setup(user_id, attrs) + + def setup(user_id, attrs) when is_integer(user_id) and is_map(attrs) do + if configured?(user_id) do + {:error, :already_configured} + else + %MasterKey{} + |> MasterKey.setup_changeset(normalize(attrs, user_id)) + |> Repo.insert() + end + end + + @doc """ + Re-wraps the MDK after a passphrase change or recovery. + + The client unwraps the MDK (with the old passphrase or the recovery code), + re-wraps it under the new passphrase, and sends the new blobs - the underlying + MDK is unchanged, so no encrypted data needs to be touched. + """ + def rotate(%User{id: user_id}, attrs), do: rotate(user_id, attrs) + + def rotate(user_id, attrs) when is_integer(user_id) and is_map(attrs) do + case get(user_id) do + nil -> + {:error, :not_configured} + + master_key -> + master_key |> MasterKey.rewrap_changeset(normalize(attrs, user_id)) |> Repo.update() + end + end + + @doc """ + Deletes the user's master key. This permanently loses access to everything + encrypted under it; intended only for a deliberate "start over" / lost-secret + reset by the user. + """ + def reset(%User{id: user_id}), do: reset(user_id) + + def reset(user_id) when is_integer(user_id) do + {count, _} = from(mk in MasterKey, where: mk.user_id == ^user_id) |> Repo.delete_all() + {:ok, count > 0} + end + + defp normalize(attrs, user_id) do + attrs + |> Map.new(fn {k, v} -> {to_string(k), v} end) + |> Map.put("user_id", user_id) + end +end diff --git a/apps/elektrine/lib/elektrine/vault/master_key.ex b/apps/elektrine/lib/elektrine/vault/master_key.ex new file mode 100644 index 0000000..ee6e77e --- /dev/null +++ b/apps/elektrine/lib/elektrine/vault/master_key.ex @@ -0,0 +1,107 @@ +defmodule Elektrine.Vault.MasterKey do + @moduledoc """ + Per-user master encryption key, stored zero-knowledge. + + Holds the user's Master Data Key (MDK) wrapped two ways - under a key derived + from the user's password (`wrapped_dek`) and under a key derived from a + one-time recovery code (`wrapped_dek_recovery`). Both are client-encrypted + AES-256-GCM payloads; the server never sees the passphrase, recovery code, or + the MDK itself. Unlock and recovery happen entirely in the browser, and the + GCM auth tag on a wrapped blob doubles as the verifier (a wrong key fails to + unwrap). + + Per-feature keys are derived in the browser via HKDF over the MDK, so one + password unlocks Nerve, Kairo, and email private storage. + """ + use Ecto.Schema + import Ecto.Changeset + + alias Elektrine.Accounts.User + + schema "account_master_keys" do + field :wrapped_dek, :map + field :wrapped_dek_recovery, :map + + belongs_to :user, User + + timestamps(type: :utc_datetime) + end + + @doc """ + Changeset for creating or replacing a user's master key. + """ + def setup_changeset(master_key, attrs) do + master_key + |> cast(attrs, [:wrapped_dek, :wrapped_dek_recovery, :user_id]) + |> validate_required([:wrapped_dek, :wrapped_dek_recovery, :user_id]) + |> validate_wrapped_payload(:wrapped_dek) + |> validate_wrapped_payload(:wrapped_dek_recovery) + |> foreign_key_constraint(:user_id) + |> unique_constraint(:user_id) + end + + @doc """ + Changeset for re-wrapping the MDK (passphrase rotation / recovery), without + touching any encrypted data elsewhere. + """ + def rewrap_changeset(master_key, attrs) do + master_key + |> cast(attrs, [:wrapped_dek, :wrapped_dek_recovery]) + |> validate_required([:wrapped_dek, :wrapped_dek_recovery]) + |> validate_wrapped_payload(:wrapped_dek) + |> validate_wrapped_payload(:wrapped_dek_recovery) + end + + defp validate_wrapped_payload(changeset, field) do + case get_field(changeset, field) do + payload when is_map(payload) -> + if valid_client_payload?(payload) do + changeset + else + add_error(changeset, field, "must be a valid client-encrypted payload") + end + + _ -> + add_error(changeset, field, "must be a valid client-encrypted payload") + end + end + + defp valid_client_payload?(payload) do + version = payload_value(payload, "version", :version) + algorithm = payload_value(payload, "algorithm", :algorithm) + kdf = payload_value(payload, "kdf", :kdf) + iterations = payload_value(payload, "iterations", :iterations) + salt = payload_value(payload, "salt", :salt) + iv = payload_value(payload, "iv", :iv) + ciphertext = payload_value(payload, "ciphertext", :ciphertext) + + valid_version?(version) and algorithm == "AES-GCM" and kdf == "PBKDF2-SHA256" and + is_integer(iterations) and iterations >= 100_000 and iterations <= 1_000_000 and + valid_base64_bytes?(salt, min_size: 16) and valid_base64_bytes?(iv, exact_size: 12) and + valid_base64_bytes?(ciphertext, min_size: 1) + end + + defp payload_value(payload, string_key, atom_key) do + Map.get(payload, string_key) || Map.get(payload, atom_key) + end + + defp valid_version?(version) when is_integer(version), do: version >= 1 + defp valid_version?(version) when is_float(version), do: version >= 1 + defp valid_version?(_version), do: false + + defp valid_base64_bytes?(value, opts) when is_binary(value) do + case Base.decode64(value) do + {:ok, bytes} -> + size = byte_size(bytes) + min_size = Keyword.get(opts, :min_size, 0) + exact_size = Keyword.get(opts, :exact_size) + + size >= min_size and (is_nil(exact_size) or size == exact_size) + + :error -> + false + end + end + + defp valid_base64_bytes?(_value, _opts), do: false +end diff --git a/apps/elektrine/lib/elektrine/web_index.ex b/apps/elektrine/lib/elektrine/web_index.ex new file mode 100644 index 0000000..80fdab7 --- /dev/null +++ b/apps/elektrine/lib/elektrine/web_index.ex @@ -0,0 +1,313 @@ +defmodule Elektrine.WebIndex do + @moduledoc """ + Storage, scheduling, and full-text retrieval for Paige's independent web index. + """ + + import Ecto.Query, warn: false + + alias Elektrine.Repo + alias Elektrine.WebIndex.{CrawlWorker, Document, Host} + + @default_limit 10 + @max_limit 50 + @max_url_bytes 4_096 + + @doc "Adds a public HTTP(S) URL to the frontier and schedules it for crawling." + def seed(url, opts \\ []) do + with {:ok, canonical_url, host} <- normalize_url(url), + {:ok, _host} <- ensure_host(host), + {:ok, document} <- upsert_document(canonical_url, host, opts), + :ok <- maybe_enqueue(document, opts) do + {:ok, document} + end + end + + @doc "Searches indexed documents using PostgreSQL's weighted English full-text index." + def search(query, opts \\ []) + + def search(query, opts) when is_binary(query) do + query = String.trim(query) + limit = opts |> Keyword.get(:limit, @default_limit) |> normalize_limit() + page = opts |> Keyword.get(:page, 1) |> normalize_page() + + if query == "" do + [] + else + from(document in Document, + where: document.status == "indexed", + where: fragment("search_vector @@ websearch_to_tsquery('english', ?)", ^query), + order_by: [ + desc: + fragment( + "ts_rank_cd(search_vector, websearch_to_tsquery('english', ?), 32)", + ^query + ), + desc: document.fetched_at, + asc: document.id + ], + limit: ^limit, + offset: ^((page - 1) * limit), + select: %{ + title: document.title, + url: document.canonical_url, + description: document.description, + content: document.content, + fetched_at: document.fetched_at, + language: document.language, + score: + fragment( + "ts_rank_cd(search_vector, websearch_to_tsquery('english', ?), 32)", + ^query + ) + } + ) + |> Repo.all() + end + end + + def search(_query, _opts), do: [] + + @doc false + def get_document(id), do: Repo.get(Document, id) + + @doc false + def mark_fetching(%Document{} = document) do + document + |> Document.changeset(%{status: "fetching", attempts: document.attempts + 1}) + |> Repo.update() + end + + @doc false + def store_page(%Document{} = document, attrs) do + now = DateTime.utc_now(:microsecond) + content_hash = Map.fetch!(attrs, :content_hash) + canonical_url = Map.get(attrs, :canonical_url, document.canonical_url) + + Repo.transaction(fn -> + content_duplicate = + from(other in Document, + where: other.id != ^document.id, + where: other.status == "indexed", + where: other.content_hash == ^content_hash, + select: other.id, + limit: 1 + ) + |> Repo.one() + + canonical_duplicate = + from(other in Document, + where: other.id != ^document.id, + where: other.canonical_url == ^canonical_url, + select: other.id, + limit: 1 + ) + |> Repo.one() + + duplicate = content_duplicate || canonical_duplicate + status = if duplicate, do: "duplicate", else: "indexed" + + attrs = + if duplicate, + do: Map.delete(attrs, :canonical_url), + else: Map.put(attrs, :canonical_url, canonical_url) + + document + |> Document.changeset( + attrs + |> Map.put(:status, status) + |> Map.put(:fetched_at, now) + |> Map.put(:next_fetch_at, DateTime.add(now, recrawl_seconds(), :second)) + |> Map.put(:last_error, nil) + ) + |> Repo.update!() + end) + end + + @doc false + def mark_status(%Document{} = document, status, attrs \\ %{}) do + now = DateTime.utc_now(:microsecond) + + document + |> Document.changeset( + attrs + |> Map.put(:status, status) + |> Map.put_new(:fetched_at, now) + |> Map.put_new(:next_fetch_at, DateTime.add(now, retry_seconds(status), :second)) + ) + |> Repo.update() + end + + @doc false + def robots_for(host), do: Repo.get(Host, host) + + @doc false + def store_robots(host, url, body, crawl_delay_ms) do + now = DateTime.utc_now(:microsecond) + + host + |> robots_for() + |> Host.changeset(%{ + robots_url: url, + robots_body: body, + robots_fetched_at: now, + crawl_delay_ms: crawl_delay_ms + }) + |> Repo.update() + end + + @doc "Atomically reserves the host's next request slot across all application nodes." + def claim_host(host, delay_ms) do + Repo.transaction(fn -> + record = Repo.one!(from item in Host, where: item.host == ^host, lock: "FOR UPDATE") + now = DateTime.utc_now(:microsecond) + + if record.next_allowed_at && DateTime.compare(record.next_allowed_at, now) == :gt do + max(DateTime.diff(record.next_allowed_at, now, :second), 1) + else + next_allowed_at = DateTime.add(now, delay_ms, :millisecond) + + record + |> Host.changeset(%{next_allowed_at: next_allowed_at, crawl_delay_ms: delay_ms}) + |> Repo.update!() + + :ok + end + end) + |> case do + {:ok, result} -> result + {:error, reason} -> {:error, reason} + end + end + + @doc false + def enqueue_due(limit \\ 100) do + now = DateTime.utc_now(:microsecond) + + from(document in Document, + where: document.status in ["pending", "failed", "fetching", "indexed"], + where: is_nil(document.next_fetch_at) or document.next_fetch_at <= ^now, + order_by: [asc: document.next_fetch_at, asc: document.id], + limit: ^limit + ) + |> Repo.all() + |> Enum.reduce(0, fn document, count -> + case enqueue(document) do + :ok -> count + 1 + {:error, _reason} -> count + end + end) + end + + @doc false + def normalize_url(url) when is_binary(url) do + url = String.trim(url) + + if byte_size(url) > @max_url_bytes or String.match?(url, ~r/[\x00-\x20\x7F]/u) do + {:error, :invalid_url} + else + uri = URI.parse(url) + scheme = if is_binary(uri.scheme), do: String.downcase(uri.scheme) + + if scheme in ["http", "https"] and is_binary(uri.host) and uri.host != "" and + uri.userinfo in [nil, ""] do + host = String.downcase(uri.host) + path = normalize_path(uri.path) + port = normalize_port(scheme, uri.port) + query = normalize_query(uri.query) + {:ok, "#{scheme}://#{host}#{port}#{path}#{query}", host} + else + {:error, :invalid_url} + end + end + rescue + _error -> {:error, :invalid_url} + end + + def normalize_url(_url), do: {:error, :invalid_url} + + @doc false + def ensure_host(host) do + %Host{} + |> Host.changeset(%{host: host}) + |> Repo.insert(on_conflict: :nothing, conflict_target: :host) + |> case do + {:ok, %Host{host: nil}} -> {:ok, Repo.get!(Host, host)} + result -> result + end + end + + defp upsert_document(url, host, opts) do + attrs = %{ + url: url, + canonical_url: url, + host: host, + depth: Keyword.get(opts, :depth, 0), + discovered_from: Keyword.get(opts, :discovered_from) + } + + %Document{} + |> Document.changeset(attrs) + |> Repo.insert( + on_conflict: {:replace, [:url, :updated_at]}, + conflict_target: :canonical_url, + returning: true + ) + end + + defp maybe_enqueue(document, opts) do + if Keyword.get(opts, :enqueue?, true), do: enqueue(document), else: :ok + end + + defp enqueue(%Document{} = document) do + %{document_id: document.id} + |> CrawlWorker.new(unique: [period: 300, fields: [:worker, :args]]) + |> Oban.insert() + |> case do + {:ok, _job} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp normalize_limit(limit) when is_integer(limit), do: limit |> max(1) |> min(@max_limit) + defp normalize_limit(_limit), do: @default_limit + defp normalize_page(page) when is_integer(page), do: max(page, 1) + defp normalize_page(_page), do: 1 + + defp normalize_path(path) when path in [nil, ""], do: "/" + defp normalize_path(path), do: path + defp normalize_port("http", port) when port in [nil, 80], do: "" + defp normalize_port("https", port) when port in [nil, 443], do: "" + defp normalize_port(_scheme, nil), do: "" + defp normalize_port(_scheme, port), do: ":#{port}" + + defp normalize_query(query) when query in [nil, ""], do: "" + + defp normalize_query(query) do + params = + query + |> URI.query_decoder() + |> Enum.reject(fn {key, _value} -> tracking_parameter?(key) end) + |> Enum.sort() + + if params == [], do: "", else: "?#{URI.encode_query(params)}" + rescue + _error -> "?#{query}" + end + + defp tracking_parameter?(key) do + key = String.downcase(key) + String.starts_with?(key, "utm_") or key in ["fbclid", "gclid", "mc_cid", "mc_eid"] + end + + defp recrawl_seconds do + :elektrine + |> Application.get_env(:web_index, []) + |> Keyword.get(:recrawl_seconds, 7 * 24 * 60 * 60) + end + + defp retry_seconds("indexed"), do: recrawl_seconds() + defp retry_seconds("gone"), do: 30 * 24 * 60 * 60 + defp retry_seconds("blocked"), do: 7 * 24 * 60 * 60 + defp retry_seconds("noindex"), do: 7 * 24 * 60 * 60 + defp retry_seconds(_status), do: 60 * 60 +end diff --git a/apps/elektrine/lib/elektrine/web_index/crawl_worker.ex b/apps/elektrine/lib/elektrine/web_index/crawl_worker.ex new file mode 100644 index 0000000..df7fb98 --- /dev/null +++ b/apps/elektrine/lib/elektrine/web_index/crawl_worker.ex @@ -0,0 +1,12 @@ +defmodule Elektrine.WebIndex.CrawlWorker do + @moduledoc "Oban worker for one independent-index page fetch." + + use Oban.Worker, queue: :crawler, max_attempts: 4 + + alias Elektrine.WebIndex.Crawler + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"document_id" => document_id}}) do + Crawler.crawl_document(document_id) + end +end diff --git a/apps/elektrine/lib/elektrine/web_index/crawler.ex b/apps/elektrine/lib/elektrine/web_index/crawler.ex new file mode 100644 index 0000000..2775b2f --- /dev/null +++ b/apps/elektrine/lib/elektrine/web_index/crawler.ex @@ -0,0 +1,314 @@ +defmodule Elektrine.WebIndex.Crawler do + @moduledoc "Polite crawl pipeline for HTML pages and declared XML sitemaps." + + alias Elektrine.WebIndex + alias Elektrine.WebIndex.{Document, Extractor, Fetcher, Robots, SitemapWorker} + + @robots_ttl_seconds 12 * 60 * 60 + @default_delay_ms 1_000 + + def crawl_document(document_id, opts \\ []) do + case WebIndex.get_document(document_id) do + nil -> + {:discard, :document_not_found} + + %Document{} = document -> + with :ok <- within_depth(document), + {:ok, policy} <- policy(document.url, document.host, opts), + true <- Robots.allowed?(policy, document.url), + :ok <- reserve_host(document.host, Robots.crawl_delay_ms(policy), opts), + {:ok, document} <- WebIndex.mark_fetching(document) do + fetch_document(document, opts) + else + false -> block(document, :robots_disallowed) + {:snooze, _seconds} = snooze -> snooze + {:error, reason} -> fail(document, reason) + end + end + end + + def crawl_sitemap(url, depth, opts \\ []) do + with {:ok, normalized_url, host} <- WebIndex.normalize_url(url), + {:ok, _host_record} <- WebIndex.ensure_host(host), + {:ok, policy} <- policy(normalized_url, host, opts), + true <- Robots.allowed?(policy, normalized_url), + :ok <- reserve_host(host, Robots.crawl_delay_ms(policy), opts), + {:ok, %{status: 200, body: body}} <- + Fetcher.get( + normalized_url, + Keyword.put(opts, :accept, "application/xml,text/xml,*/*;q=0.1") + ) do + body + |> sitemap_locations() + |> Enum.each(&schedule_sitemap_location(&1, host, depth, opts)) + + :ok + else + false -> {:discard, :robots_disallowed} + {:snooze, _seconds} = snooze -> snooze + {:ok, %{status: status}} -> {:error, {:http_status, status}} + {:error, reason} -> {:error, reason} + end + end + + defp fetch_document(document, opts) do + case Fetcher.get(document.url, opts) do + {:ok, %{status: 200} = response} -> index_response(document, response, opts) + {:ok, %{status: status}} when status in [404, 410] -> gone(document, status) + {:ok, %{status: 401}} -> block(document, :http_401) + {:ok, %{status: 403}} -> block(document, :http_403) + {:ok, %{status: 429, headers: headers}} -> throttle(document, headers) + {:ok, %{status: status}} -> fail(document, {:http_status, status}, %{http_status: status}) + {:error, reason} -> fail(document, reason) + end + end + + defp index_response(document, response, opts) do + if html_response?(response.headers) do + with {:ok, extracted} <- Extractor.extract(response.body, response.url), + :ok <- indexable(extracted) do + attrs = %{ + canonical_url: extracted.canonical_url, + title: extracted.title || document.host, + description: extracted.description, + content: extracted.content, + content_hash: extracted.content_hash, + language: extracted.language, + http_status: 200 + } + + case WebIndex.store_page(document, attrs) do + {:ok, stored} -> + discover_links(stored, extracted.links, opts) + :ok + + {:error, reason} -> + {:error, reason} + end + else + {:error, :noindex} -> noindex(document) + {:error, reason} -> fail(document, reason) + end + else + noindex(document, :unsupported_content_type) + end + end + + defp policy(url, host, opts) do + record = WebIndex.robots_for(host) + + if fresh_robots?(record) do + {:ok, Robots.parse(record.robots_body || "")} + else + fetch_robots(url, host, record, opts) + end + end + + defp fetch_robots(url, host, record, opts) do + delay = if record, do: record.crawl_delay_ms, else: @default_delay_ms + + with :ok <- reserve_host(host, delay, opts), + robots_url <- robots_url(url), + {:ok, response} <- + Fetcher.get( + robots_url, + opts + |> Keyword.put(:accept, "text/plain,*/*;q=0.1") + |> Keyword.put(:max_body_bytes, 512_000) + ), + {:ok, body} <- robots_body(response), + parsed <- Robots.parse(body), + crawl_delay <- Robots.crawl_delay_ms(parsed, delay), + {:ok, _record} <- WebIndex.store_robots(host, robots_url, body, crawl_delay) do + schedule_sitemaps(Robots.sitemaps(parsed), host, opts) + + if Keyword.get(opts, :pacing?, true) do + {:snooze, max(div(crawl_delay + 999, 1_000), 1)} + else + {:ok, parsed} + end + end + end + + defp robots_body(%{status: 200, body: body}), do: {:ok, body} + + defp robots_body(%{status: 429, headers: headers}), + do: {:snooze, retry_after_seconds(headers)} + + defp robots_body(%{status: status}) when status in [401, 403], + do: {:ok, "User-agent: *\nDisallow: /"} + + defp robots_body(%{status: status}) when status in 400..499, do: {:ok, ""} + defp robots_body(%{status: status}), do: {:error, {:robots_http_status, status}} + + defp fresh_robots?(nil), do: false + + defp fresh_robots?(%{robots_fetched_at: nil}), do: false + + defp fresh_robots?(%{robots_fetched_at: fetched_at}) do + DateTime.diff(DateTime.utc_now(), fetched_at, :second) < @robots_ttl_seconds + end + + defp reserve_host(host, delay, opts) when is_list(opts) do + if Keyword.get(opts, :pacing?, true) do + case WebIndex.claim_host(host, delay) do + :ok -> :ok + seconds when is_integer(seconds) -> {:snooze, seconds} + {:error, reason} -> {:error, reason} + end + else + :ok + end + end + + defp within_depth(%Document{depth: depth}) do + if depth <= max_depth(), do: :ok, else: {:error, :maximum_depth} + end + + defp indexable(%{noindex?: true}), do: {:error, :noindex} + defp indexable(%{content: content}) when byte_size(content) < 40, do: {:error, :empty_content} + defp indexable(_extracted), do: :ok + + defp discover_links(%Document{depth: depth} = stored, links, opts) do + if depth < max_depth() do + Enum.each(links, fn link -> + _result = + WebIndex.seed(link, + depth: depth + 1, + discovered_from: stored.canonical_url, + enqueue?: Keyword.get(opts, :enqueue_discovered?, true) + ) + end) + end + end + + defp schedule_sitemaps(urls, host, opts) do + if Keyword.get(opts, :enqueue_discovered?, true) do + urls + |> Enum.take(10) + |> Enum.each(fn url -> + case WebIndex.normalize_url(url) do + {:ok, normalized, ^host} -> + _result = + Oban.insert( + SitemapWorker.new(%{url: normalized, depth: 0}, + unique: [period: 3_600, fields: [:worker, :args]] + ) + ) + + _invalid_or_external -> + :ok + end + end) + end + end + + defp schedule_sitemap_location(url, host, depth, opts) do + case WebIndex.normalize_url(url) do + {:ok, normalized, ^host} -> + if sitemap_url?(normalized) and depth < 2 and + Keyword.get(opts, :enqueue_discovered?, true) do + _result = + Oban.insert( + SitemapWorker.new(%{url: normalized, depth: depth + 1}, + unique: [period: 3_600, fields: [:worker, :args]] + ) + ) + else + _result = + WebIndex.seed(normalized, + depth: 0, + enqueue?: Keyword.get(opts, :enqueue_discovered?, true) + ) + end + + _invalid_or_external -> + :ok + end + end + + defp sitemap_locations(body) do + case Floki.parse_document(body) do + {:ok, tree} -> tree |> Floki.find("loc") |> Enum.map(&Floki.text/1) |> Enum.take(1_000) + {:error, _reason} -> [] + end + end + + defp robots_url(url) do + uri = URI.parse(url) + %{uri | path: "/robots.txt", query: nil, fragment: nil} |> URI.to_string() + end + + defp sitemap_url?(url), + do: url |> URI.parse() |> Map.get(:path) |> to_string() |> String.ends_with?(".xml") + + defp html_response?(headers) do + case header(headers, "content-type") do + nil -> + true + + content_type -> + String.starts_with?(String.downcase(content_type), ["text/html", "application/xhtml+xml"]) + end + end + + defp header(headers, wanted) do + Enum.find_value(headers, fn {name, value} -> + if String.downcase(name) == wanted, do: value + end) + end + + defp retry_after_seconds(headers) do + case header(headers, "retry-after") do + value when is_binary(value) -> + case Integer.parse(String.trim(value)) do + {seconds, ""} when seconds > 0 -> min(seconds, 3_600) + _invalid -> 60 + end + + _missing -> + 60 + end + end + + defp gone(document, status) do + _result = + WebIndex.mark_status(document, "gone", %{http_status: status, last_error: "HTTP #{status}"}) + + :ok + end + + defp block(document, reason) do + _result = WebIndex.mark_status(document, "blocked", %{last_error: inspect(reason)}) + {:discard, reason} + end + + defp noindex(document, reason \\ :noindex) do + _result = WebIndex.mark_status(document, "noindex", %{last_error: inspect(reason)}) + :ok + end + + defp throttle(document, headers) do + seconds = retry_after_seconds(headers) + next_fetch_at = DateTime.add(DateTime.utc_now(:microsecond), seconds, :second) + + _result = + WebIndex.mark_status(document, "failed", %{ + http_status: 429, + last_error: "HTTP 429", + next_fetch_at: next_fetch_at + }) + + {:snooze, seconds} + end + + defp fail(document, reason, attrs \\ %{}) do + attrs = Map.put(attrs, :last_error, inspect(reason)) + _result = WebIndex.mark_status(document, "failed", attrs) + {:error, reason} + end + + defp max_depth do + :elektrine |> Application.get_env(:web_index, []) |> Keyword.get(:max_depth, 2) + end +end diff --git a/apps/elektrine/lib/elektrine/web_index/document.ex b/apps/elektrine/lib/elektrine/web_index/document.ex new file mode 100644 index 0000000..e87e900 --- /dev/null +++ b/apps/elektrine/lib/elektrine/web_index/document.ex @@ -0,0 +1,56 @@ +defmodule Elektrine.WebIndex.Document do + @moduledoc "A URL in Paige's crawl frontier and independent search index." + + use Ecto.Schema + import Ecto.Changeset + + @statuses ~w(pending fetching indexed duplicate blocked failed gone noindex) + + schema "web_index_documents" do + field :url, :string + field :canonical_url, :string + field :host, :string + field :discovered_from, :string + field :depth, :integer, default: 0 + field :status, :string, default: "pending" + field :title, :string + field :description, :string + field :content, :string + field :content_hash, :binary + field :language, :string + field :http_status, :integer + field :attempts, :integer, default: 0 + field :fetched_at, :utc_datetime_usec + field :next_fetch_at, :utc_datetime_usec + field :last_error, :string + + timestamps(type: :utc_datetime_usec) + end + + def changeset(document, attrs) do + document + |> cast(attrs, [ + :url, + :canonical_url, + :host, + :discovered_from, + :depth, + :status, + :title, + :description, + :content, + :content_hash, + :language, + :http_status, + :attempts, + :fetched_at, + :next_fetch_at, + :last_error + ]) + |> validate_required([:url, :canonical_url, :host, :depth, :status]) + |> validate_inclusion(:status, @statuses) + |> validate_number(:depth, greater_than_or_equal_to: 0) + |> unique_constraint(:canonical_url) + |> foreign_key_constraint(:host) + end +end diff --git a/apps/elektrine/lib/elektrine/web_index/extractor.ex b/apps/elektrine/lib/elektrine/web_index/extractor.ex new file mode 100644 index 0000000..c2e7e90 --- /dev/null +++ b/apps/elektrine/lib/elektrine/web_index/extractor.ex @@ -0,0 +1,117 @@ +defmodule Elektrine.WebIndex.Extractor do + @moduledoc "Extracts indexable text, metadata, and same-site links from HTML." + + @max_content_chars 120_000 + @max_links 60 + @ignored_extensions ~w(.7z .avi .css .csv .doc .docx .exe .gif .gz .ico .jpeg .jpg .js .json .m4a .mkv .mov .mp3 .mp4 .pdf .png .ppt .pptx .rar .rss .svg .tar .tgz .webm .webp .xml .zip) + + def extract(html, fetched_url) when is_binary(html) and is_binary(fetched_url) do + with {:ok, tree} <- Floki.parse_document(html) do + cleaned = + Floki.filter_out(tree, "script,style,noscript,svg,canvas,nav,footer,header,aside,form") + + title = cleaned |> text("title") |> truncate(500) + description = cleaned |> attribute("meta[name='description']", "content") |> truncate(1_000) + canonical_url = canonical_url(cleaned, fetched_url) + + content = + cleaned |> Floki.text(sep: " ") |> clean_text() |> String.slice(0, @max_content_chars) + + {:ok, + %{ + title: title, + description: description, + canonical_url: canonical_url, + content: content, + content_hash: :crypto.hash(:sha256, content), + language: attribute(cleaned, "html", "lang") |> normalize_language(), + noindex?: noindex?(tree), + links: links(tree, fetched_url) + }} + end + end + + def extract(_html, _fetched_url), do: {:error, :invalid_html} + + defp links(tree, fetched_url) do + fetched_host = URI.parse(fetched_url).host + + tree + |> Floki.find("a[href]") + |> Floki.attribute("href") + |> Enum.flat_map(&absolute_url(fetched_url, &1)) + |> Enum.filter(&(URI.parse(&1).host == fetched_host)) + |> Enum.reject(&ignored_url?/1) + |> Enum.uniq() + |> Enum.take(@max_links) + end + + defp absolute_url(base_url, href) do + href = String.trim(href) + + if href == "" or String.starts_with?(href, ["#", "mailto:", "tel:", "javascript:", "data:"]) do + [] + else + url = + base_url |> URI.parse() |> URI.merge(href) |> Map.put(:fragment, nil) |> URI.to_string() + + case Elektrine.WebIndex.normalize_url(url) do + {:ok, normalized, _host} -> [normalized] + {:error, _reason} -> [] + end + end + rescue + _error -> [] + end + + defp canonical_url(tree, fetched_url) do + case attribute(tree, "link[rel='canonical']", "href") do + nil -> + fetched_url + + href -> + candidate = absolute_url(fetched_url, href) |> List.first() + + if candidate && URI.parse(candidate).host == URI.parse(fetched_url).host, + do: candidate, + else: fetched_url + end + end + + defp noindex?(tree) do + tree + |> Floki.find("meta[name='robots'],meta[name='googlebot']") + |> Floki.attribute("content") + |> Enum.any?(fn value -> + value + |> String.downcase() + |> String.split([",", " "], trim: true) + |> Enum.member?("noindex") + end) + end + + defp ignored_url?(url) do + path = url |> URI.parse() |> Map.get(:path) |> to_string() |> String.downcase() + Enum.any?(@ignored_extensions, &String.ends_with?(path, &1)) + end + + defp text(tree, selector) do + tree |> Floki.find(selector) |> Floki.text(sep: " ") |> clean_text() |> present() + end + + defp attribute(tree, selector, name) do + tree |> Floki.find(selector) |> Floki.attribute(name) |> List.first() |> present() + end + + defp clean_text(value), do: value |> String.replace(~r/\s+/u, " ") |> String.trim() + defp present(nil), do: nil + defp present(value), do: if(String.trim(value) == "", do: nil, else: String.trim(value)) + + defp normalize_language(nil), do: nil + + defp normalize_language(value), + do: value |> String.downcase() |> String.split("-", parts: 2) |> hd() + + defp truncate(nil, _limit), do: nil + defp truncate(value, limit), do: String.slice(value, 0, limit) +end diff --git a/apps/elektrine/lib/elektrine/web_index/fetcher.ex b/apps/elektrine/lib/elektrine/web_index/fetcher.ex new file mode 100644 index 0000000..f7f5260 --- /dev/null +++ b/apps/elektrine/lib/elektrine/web_index/fetcher.ex @@ -0,0 +1,78 @@ +defmodule Elektrine.WebIndex.Fetcher do + @moduledoc "SSRF-safe, size-limited HTTP fetching for the Paige crawler." + + alias Elektrine.HTTP.SafeFetch + + @user_agent "PaigeBot/1.0 (+https://elektrine.com/paige)" + @max_redirects 4 + @max_body_bytes 2 * 1024 * 1024 + + def get(url, opts \\ []) do + request_fun = Keyword.get(opts, :request_fun, &request/3) + follow(url, request_fun, opts, 0) + end + + defp follow(url, request_fun, opts, redirects) when redirects <= @max_redirects do + headers = [ + {"user-agent", @user_agent}, + {"accept", Keyword.get(opts, :accept, "text/html,application/xhtml+xml;q=0.9,*/*;q=0.1")} + ] + + case request_fun.(url, headers, opts) do + {:ok, %{status: status, headers: response_headers}} when status in 300..399 -> + with location when is_binary(location) <- header(response_headers, "location"), + {:ok, target} <- redirect_url(url, location), + :ok <- validate_redirect_scope(url, target, opts) do + follow(target, request_fun, opts, redirects + 1) + else + _missing_or_invalid -> {:error, :invalid_redirect} + end + + {:ok, %{status: status, body: body} = response} when is_binary(body) -> + {:ok, %{status: status, body: body, headers: Map.get(response, :headers, []), url: url}} + + {:error, reason} -> + {:error, reason} + + _response -> + {:error, :invalid_response} + end + end + + defp follow(_url, _request_fun, _opts, _redirects), do: {:error, :too_many_redirects} + + defp request(url, headers, opts) do + :get + |> Finch.build(url, headers) + |> SafeFetch.request(Elektrine.Finch, + receive_timeout: Keyword.get(opts, :receive_timeout, 20_000), + max_body_bytes: Keyword.get(opts, :max_body_bytes, @max_body_bytes) + ) + end + + defp redirect_url(base_url, location) do + target = base_url |> URI.parse() |> URI.merge(location) |> URI.to_string() + + case Elektrine.WebIndex.normalize_url(target) do + {:ok, normalized, _host} -> {:ok, normalized} + {:error, reason} -> {:error, reason} + end + rescue + _error -> {:error, :invalid_redirect} + end + + defp validate_redirect_scope(source, target, opts) do + if Keyword.get(opts, :same_host_redirects?, true) and + URI.parse(source).host != URI.parse(target).host do + {:error, :cross_host_redirect} + else + :ok + end + end + + defp header(headers, wanted) do + Enum.find_value(headers, fn {name, value} -> + if String.downcase(name) == wanted, do: value + end) + end +end diff --git a/apps/elektrine/lib/elektrine/web_index/host.ex b/apps/elektrine/lib/elektrine/web_index/host.ex new file mode 100644 index 0000000..ca8b2af --- /dev/null +++ b/apps/elektrine/lib/elektrine/web_index/host.ex @@ -0,0 +1,35 @@ +defmodule Elektrine.WebIndex.Host do + @moduledoc "Persistent robots.txt policy and request pacing for a crawled host." + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:host, :string, autogenerate: false} + schema "web_index_hosts" do + field :robots_url, :string + field :robots_body, :string + field :robots_fetched_at, :utc_datetime_usec + field :next_allowed_at, :utc_datetime_usec + field :crawl_delay_ms, :integer, default: 1_000 + + timestamps(type: :utc_datetime_usec) + end + + def changeset(host, attrs) do + host + |> cast(attrs, [ + :host, + :robots_url, + :robots_body, + :robots_fetched_at, + :next_allowed_at, + :crawl_delay_ms + ]) + |> validate_required([:host, :crawl_delay_ms]) + |> validate_number(:crawl_delay_ms, + greater_than_or_equal_to: 250, + less_than_or_equal_to: 30_000 + ) + |> unique_constraint(:host) + end +end diff --git a/apps/elektrine/lib/elektrine/web_index/provider.ex b/apps/elektrine/lib/elektrine/web_index/provider.ex new file mode 100644 index 0000000..7a6114c --- /dev/null +++ b/apps/elektrine/lib/elektrine/web_index/provider.ex @@ -0,0 +1,32 @@ +defmodule Elektrine.WebIndex.Provider do + @moduledoc "Paige provider backed by Elektrine's independent PostgreSQL web index." + + alias Elektrine.WebIndex + + def search(query, opts \\ []) do + results = + query + |> WebIndex.search(opts) + |> Enum.map(fn result -> + %{ + title: result.title, + url: result.url, + snippet: snippet(result), + source: "Paige Index", + score: result.score, + published_at: result.fetched_at, + metadata: %{kind: :web, language: result.language, independent_index: true} + } + end) + + {:ok, results} + rescue + _error -> {:error, :index_unavailable} + end + + defp snippet(%{description: description}) when is_binary(description) and description != "", + do: String.slice(description, 0, 320) + + defp snippet(%{content: content}) when is_binary(content), do: String.slice(content, 0, 320) + defp snippet(_result), do: nil +end diff --git a/apps/elektrine/lib/elektrine/web_index/robots.ex b/apps/elektrine/lib/elektrine/web_index/robots.ex new file mode 100644 index 0000000..cd614cf --- /dev/null +++ b/apps/elektrine/lib/elektrine/web_index/robots.ex @@ -0,0 +1,126 @@ +defmodule Elektrine.WebIndex.Robots do + @moduledoc "Parses and evaluates the robots.txt directives used by PaigeBot." + + def parse(body) when is_binary(body) do + lines = + body + |> String.split(~r/\r?\n/u) + |> Enum.map(&parse_line/1) + |> Enum.reject(&is_nil/1) + + %{ + groups: parse_groups(lines), + sitemaps: for({"sitemap", value} <- lines, do: value) + } + end + + def parse(_body), do: %{groups: [], sitemaps: []} + + def allowed?(policy, url) do + path = request_path(url) + + policy + |> matching_group() + |> Map.get(:rules, []) + |> Enum.filter(fn {_kind, pattern} -> matches?(pattern, path) end) + |> Enum.max_by(fn {kind, pattern} -> {rule_length(pattern), kind == :allow} end, fn -> nil end) + |> case do + {:disallow, _pattern} -> false + _rule -> true + end + end + + def crawl_delay_ms(policy, default \\ 1_000) do + case matching_group(policy) do + %{crawl_delay: delay} when is_number(delay) -> + delay |> Kernel.*(1_000) |> round() |> max(250) |> min(30_000) + + _group -> + default + end + end + + def sitemaps(policy), do: Map.get(policy, :sitemaps, []) + + defp parse_groups(lines) do + {groups, current} = + Enum.reduce(lines, {[], empty_group()}, fn + {"user-agent", value}, {groups, %{rules: [], crawl_delay: nil} = current} -> + {groups, %{current | agents: current.agents ++ [String.downcase(value)]}} + + {"user-agent", value}, {groups, current} -> + {[current | groups], %{empty_group() | agents: [String.downcase(value)]}} + + {"allow", value}, {groups, current} -> + {groups, %{current | rules: current.rules ++ [{:allow, value}]}} + + {"disallow", ""}, accumulator -> + accumulator + + {"disallow", value}, {groups, current} -> + {groups, %{current | rules: current.rules ++ [{:disallow, value}]}} + + {"crawl-delay", value}, {groups, current} -> + {groups, %{current | crawl_delay: parse_delay(value)}} + + _directive, accumulator -> + accumulator + end) + + [current | groups] + |> Enum.reverse() + |> Enum.reject(&(&1.agents == [])) + end + + defp matching_group(%{groups: groups}) do + Enum.find(groups, &("paigebot" in &1.agents)) || + Enum.find(groups, &("*" in &1.agents)) || empty_group() + end + + defp matching_group(_policy), do: empty_group() + + defp parse_line(line) do + line = line |> String.split("#", parts: 2) |> hd() |> String.trim() + + case String.split(line, ":", parts: 2) do + [name, value] -> + name = name |> String.trim() |> String.downcase() + + if name in ["user-agent", "allow", "disallow", "crawl-delay", "sitemap"], + do: {name, String.trim(value)} + + _parts -> + nil + end + end + + defp parse_delay(value) do + case Float.parse(value) do + {delay, ""} when delay >= 0 -> delay + _invalid -> nil + end + end + + defp matches?(pattern, path) do + end_anchored? = String.ends_with?(pattern, "$") + pattern = String.trim_trailing(pattern, "$") + + source = + pattern + |> Regex.escape() + |> String.replace("\\*", ".*") + + suffix = if end_anchored?, do: "$", else: ".*" + Regex.match?(Regex.compile!("^#{source}#{suffix}"), path) + end + + defp rule_length(pattern), do: pattern |> String.replace(["*", "$"], "") |> String.length() + + defp request_path(url) do + uri = URI.parse(url) + path = if uri.path in [nil, ""], do: "/", else: uri.path + if uri.query in [nil, ""], do: path, else: "#{path}?#{uri.query}" + end + + defp empty_group, do: %{agents: [], rules: [], crawl_delay: nil} +end diff --git a/apps/elektrine/lib/elektrine/web_index/scheduler_worker.ex b/apps/elektrine/lib/elektrine/web_index/scheduler_worker.ex new file mode 100644 index 0000000..6c9662b --- /dev/null +++ b/apps/elektrine/lib/elektrine/web_index/scheduler_worker.ex @@ -0,0 +1,22 @@ +defmodule Elektrine.WebIndex.SchedulerWorker do + @moduledoc "Seeds configured sites and schedules due independent-index crawls." + + use Oban.Worker, queue: :crawler, max_attempts: 1 + + alias Elektrine.WebIndex + + @impl Oban.Worker + def perform(%Oban.Job{}) do + config = Application.get_env(:elektrine, :web_index, []) + + if Keyword.get(config, :enabled, false) do + config + |> Keyword.get(:seeds, []) + |> Enum.each(fn url -> _result = WebIndex.seed(url, enqueue?: false) end) + + _count = WebIndex.enqueue_due(Keyword.get(config, :schedule_batch_size, 100)) + end + + :ok + end +end diff --git a/apps/elektrine/lib/elektrine/web_index/sitemap_worker.ex b/apps/elektrine/lib/elektrine/web_index/sitemap_worker.ex new file mode 100644 index 0000000..80e8eb6 --- /dev/null +++ b/apps/elektrine/lib/elektrine/web_index/sitemap_worker.ex @@ -0,0 +1,12 @@ +defmodule Elektrine.WebIndex.SitemapWorker do + @moduledoc "Oban worker for a robots-declared sitemap or sitemap index." + + use Oban.Worker, queue: :crawler, max_attempts: 4 + + alias Elektrine.WebIndex.Crawler + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"url" => url, "depth" => depth}}) do + Crawler.crawl_sitemap(url, depth) + end +end diff --git a/apps/elektrine/lib/elektrine/webhook/rate_limiter.ex b/apps/elektrine/lib/elektrine/webhook/rate_limiter.ex new file mode 100644 index 0000000..926ab7a --- /dev/null +++ b/apps/elektrine/lib/elektrine/webhook/rate_limiter.ex @@ -0,0 +1,16 @@ +defmodule Elektrine.Webhook.RateLimiter do + @moduledoc """ + Rate limiter for webhook endpoints (Postal, Haraka). + Limits requests per IP address to prevent abuse. + """ + + use Elektrine.RateLimiter, + table: :webhook_rate_limit, + limits: [ + # 1000 requests per minute per IP + {:minute, 1000} + ], + # No lockout for webhooks, just deny + lockout: nil, + cleanup_interval: {:minutes, 2} +end diff --git a/apps/elektrine/lib/mix/tasks/activitypub.backfill_signing_keys.ex b/apps/elektrine/lib/mix/tasks/activitypub.backfill_signing_keys.ex new file mode 100644 index 0000000..b6182e1 --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/activitypub.backfill_signing_keys.ex @@ -0,0 +1,109 @@ +defmodule Mix.Tasks.Activitypub.BackfillSigningKeys do + @moduledoc false + use Mix.Task + + import Ecto.Query + + alias Elektrine.Accounts.User + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.SigningKey + alias Elektrine.Repo + + @shortdoc "Backfill ActivityPub signing_keys rows from existing local and remote keys" + + @impl Mix.Task + def run(args) do + Mix.Task.run("app.start") + + {opts, _argv, invalid} = + OptionParser.parse(args, + strict: [dry_run: :boolean, local_only: :boolean, remote_only: :boolean], + aliases: [d: :dry_run] + ) + + if invalid != [] do + Mix.raise("Invalid arguments: #{inspect(invalid)}") + end + + local_only? = Keyword.get(opts, :local_only, false) + remote_only? = Keyword.get(opts, :remote_only, false) + + if local_only? and remote_only? do + Mix.raise("Use at most one of --local-only or --remote-only.") + end + + dry_run? = Keyword.get(opts, :dry_run, false) + + local_users = + if remote_only? do + [] + else + Repo.all( + from(u in User, + where: u.activitypub_enabled == true, + where: not is_nil(u.activitypub_public_key) and u.activitypub_public_key != "", + where: not is_nil(u.activitypub_private_key) and u.activitypub_private_key != "" + ) + ) + end + + remote_actors = + if local_only? do + [] + else + Repo.all( + from(a in Actor, + where: not is_nil(a.uri) and a.uri != "", + where: not is_nil(a.public_key) and a.public_key != "" + ) + ) + end + + Mix.shell().info("ActivityPub signing key backfill") + Mix.shell().info(" Instance URL: #{Elektrine.ActivityPub.instance_url()}") + Mix.shell().info(" Local users: #{length(local_users)}") + Mix.shell().info(" Remote actors: #{length(remote_actors)}") + Mix.shell().info(" Dry run: #{dry_run?}") + + unless dry_run? do + Enum.each(local_users, &upsert_local_key/1) + Enum.each(remote_actors, &upsert_remote_key/1) + end + end + + defp upsert_local_key(user) do + case SigningKey.upsert_local_key( + user, + user.activitypub_public_key, + user.activitypub_private_key + ) do + {:ok, _key} -> + Mix.shell().info("Upserted local signing key for #{user.username}.") + + {:error, reason} -> + Mix.raise("Failed to upsert local signing key for #{user.username}: #{inspect(reason)}") + end + end + + defp upsert_remote_key(actor) do + attrs = %{ + key_id: actor.uri <> "#main-key", + remote_actor_id: actor.id, + public_key: actor.public_key + } + + case %SigningKey{} + |> SigningKey.remote_changeset(attrs) + |> Repo.insert( + on_conflict: {:replace, [:public_key, :updated_at]}, + conflict_target: :key_id, + returning: true + ) do + {:ok, _key} -> + Mix.shell().info("Upserted remote signing key for #{actor.uri}.") + + {:error, reason} -> + Mix.raise("Failed to upsert remote signing key for #{actor.uri}: #{inspect(reason)}") + end + end +end diff --git a/apps/elektrine/lib/mix/tasks/activitypub.migrate_domain.ex b/apps/elektrine/lib/mix/tasks/activitypub.migrate_domain.ex new file mode 100644 index 0000000..5d93c42 --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/activitypub.migrate_domain.ex @@ -0,0 +1,82 @@ +defmodule Mix.Tasks.Activitypub.MigrateDomain do + @moduledoc false + use Mix.Task + + @shortdoc "Broadcast ActivityPub Move activities from an old domain to the current domain" + + @impl Mix.Task + def run(args) do + Mix.Task.run("app.start") + + {opts, _argv, _invalid} = + OptionParser.parse(args, + strict: [ + from: :string, + to: :string, + dry_run: :boolean, + usernames: :string, + limit: :integer + ], + aliases: [f: :from, t: :to, d: :dry_run, u: :usernames, l: :limit] + ) + + usernames = + opts + |> Keyword.get(:usernames, "") + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + + migration_opts = + [] + |> maybe_put(:from_domain, opts[:from]) + |> maybe_put(:to_domain, opts[:to]) + |> maybe_put(:limit, opts[:limit]) + |> Keyword.put(:dry_run, Keyword.get(opts, :dry_run, false)) + |> Keyword.put(:usernames, usernames) + + case Elektrine.ActivityPub.DomainMigration.broadcast_moves(migration_opts) do + {:ok, summary} -> + print_summary(summary) + + {:error, :missing_from_domain} -> + Mix.raise("Missing source domain. Pass --from or set ACTIVITYPUB_MOVE_FROM_DOMAIN.") + + {:error, :missing_to_domain} -> + Mix.raise("Missing target domain. Pass --to or set INSTANCE_DOMAIN.") + + {:error, :same_domain} -> + Mix.raise("Source and target domains must be different.") + end + end + + defp print_summary(summary) do + Mix.shell().info("ActivityPub domain migration summary") + Mix.shell().info(" From: #{summary.from_domain}") + Mix.shell().info(" To: #{summary.to_domain}") + Mix.shell().info(" Dry run: #{summary.dry_run}") + Mix.shell().info(" Users processed: #{summary.users_processed}") + + Mix.shell().info( + " Users without remote followers: #{summary.users_without_remote_followers}" + ) + + Mix.shell().info(" Deliveries attempted: #{summary.deliveries_attempted}") + Mix.shell().info(" Deliveries succeeded: #{summary.deliveries_succeeded}") + Mix.shell().info(" Deliveries failed: #{summary.deliveries_failed}") + + if summary.deliveries_failed > 0 do + Mix.shell().info(" Recent errors:") + + summary.errors + |> Enum.take(20) + |> Enum.reverse() + |> Enum.each(fn error -> + Mix.shell().info(" #{error.username} -> #{error.inbox} (#{error.reason})") + end) + end + end + + defp maybe_put(opts, _key, nil), do: opts + defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value) +end diff --git a/apps/elektrine/lib/mix/tasks/activitypub.repair_submitted_links.ex b/apps/elektrine/lib/mix/tasks/activitypub.repair_submitted_links.ex new file mode 100644 index 0000000..b985a12 --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/activitypub.repair_submitted_links.ex @@ -0,0 +1,31 @@ +defmodule Mix.Tasks.Activitypub.RepairSubmittedLinks do + @moduledoc """ + Enqueue repair jobs for federated messages missing submitted links. + """ + + @shortdoc "Enqueue repair jobs for missing submitted links" + + use Mix.Task + + @impl Mix.Task + def run(args) do + Mix.Task.run("app.start") + + limit = + case args do + [value | _] -> + case Integer.parse(value) do + {parsed, ""} when parsed > 0 -> parsed + _ -> 100 + end + + _ -> + 100 + end + + case Elektrine.ActivityPub.SubmittedLinkRepairWorker.enqueue_batch(limit) do + {:ok, _job} -> Mix.shell().info("Enqueued submitted link repair batch (limit=#{limit})") + {:error, reason} -> Mix.raise("Failed to enqueue repair batch: #{inspect(reason)}") + end + end +end diff --git a/apps/elektrine/lib/mix/tasks/admin.make.ex b/apps/elektrine/lib/mix/tasks/admin.make.ex new file mode 100644 index 0000000..cbb8bd3 --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/admin.make.ex @@ -0,0 +1,44 @@ +defmodule Mix.Tasks.Admin.Make do + @moduledoc """ + Make a user an admin. + + ## Examples + + mix admin.make username + + """ + use Mix.Task + + alias Elektrine.Accounts + + @shortdoc "Make a user an admin" + + def run([username]) when is_binary(username) do + Mix.Task.run("app.start") + + case Accounts.get_user_by_username(username) do + nil -> + Mix.shell().error("User '#{username}' not found.") + + %{is_admin: true} = user -> + Mix.shell().info("User '#{user.username}' is already an admin.") + + user -> + case Accounts.update_user_admin_status(user, true) do + {:ok, _updated_user} -> + Mix.shell().info("Successfully made '#{user.username}' an admin.") + + {:error, changeset} -> + Mix.shell().error("Failed to make user admin: #{inspect(changeset.errors)}") + end + end + end + + def run([]) do + Mix.shell().error("Usage: mix admin.make ") + end + + def run(_) do + Mix.shell().error("Usage: mix admin.make ") + end +end diff --git a/apps/elektrine/lib/mix/tasks/analytics.seed_site_load.ex b/apps/elektrine/lib/mix/tasks/analytics.seed_site_load.ex new file mode 100644 index 0000000..6e30d8b --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/analytics.seed_site_load.ex @@ -0,0 +1,274 @@ +defmodule Mix.Tasks.Analytics.SeedSiteLoad do + @shortdoc "Seeds synthetic site analytics traffic for local performance testing" + + @moduledoc """ + Seeds synthetic `site_sessions` and `site_page_visits` rows for local analytics testing. + + This task is intended for development and test databases only. It writes rows with + a synthetic user agent so they can be deleted safely afterward. + + Examples: + + mix analytics.seed_site_load --host elektrine.com --sessions 100000 --visits 1000000 --clean --benchmark + mix analytics.seed_site_load --host elektrine.com --only-clean + + Options: + + * `--host` - request host to seed, defaults to `elektrine.com` + * `--visits` - number of `site_page_visits` rows, defaults to `1_000_000` + * `--sessions` - number of `site_sessions` rows, defaults to `100_000` + * `--batch-size` - insert batch size, defaults to `50_000` + * `--clean` - delete existing synthetic rows for the host before seeding + * `--only-clean` - delete existing synthetic rows for the host and exit + * `--benchmark` - time the domain analytics functions after seeding + """ + + use Mix.Task + + alias Ecto.Adapters.SQL + alias Elektrine.{Profiles, Repo} + + @synthetic_user_agent "ElektrineSyntheticAnalyticsLoadTest/1.0" + @default_host "elektrine.com" + @default_visits 1_000_000 + @default_sessions 100_000 + @default_batch_size 50_000 + + @switches [ + host: :string, + visits: :integer, + sessions: :integer, + batch_size: :integer, + clean: :boolean, + only_clean: :boolean, + benchmark: :boolean + ] + + @impl Mix.Task + def run(args) do + prevent_production!() + start_repo!() + + {opts, _argv, invalid} = OptionParser.parse(args, strict: @switches) + + if invalid != [] do + Mix.raise("Invalid options: #{inspect(invalid)}") + end + + host = Keyword.get(opts, :host, @default_host) + visits = Keyword.get(opts, :visits, @default_visits) + sessions = Keyword.get(opts, :sessions, @default_sessions) + batch_size = Keyword.get(opts, :batch_size, @default_batch_size) + + validate_positive!(:visits, visits) + validate_positive!(:sessions, sessions) + validate_positive!(:batch_size, batch_size) + + sessions = min(sessions, visits) + + if Keyword.get(opts, :clean, false) or Keyword.get(opts, :only_clean, false) do + clean_host(host) + end + + unless Keyword.get(opts, :only_clean, false) do + run_id = Integer.to_string(System.system_time(:millisecond)) + session_prefix = "synthetic:#{host}:#{run_id}" + + seed_sessions(host, session_prefix, sessions, visits, batch_size) + seed_visits(host, session_prefix, sessions, visits, batch_size) + + Mix.shell().info("Seeded #{sessions} synthetic sessions and #{visits} visits for #{host}.") + + if Keyword.get(opts, :benchmark, false) do + benchmark(host) + end + end + end + + defp prevent_production! do + if Mix.env() == :prod do + Mix.raise("analytics.seed_site_load is disabled in prod") + end + end + + defp start_repo! do + Mix.Task.run("app.config") + {:ok, _apps} = Application.ensure_all_started(:ecto_sql) + + case Repo.start_link() do + {:ok, _pid} -> :ok + {:error, {:already_started, _pid}} -> :ok + {:error, reason} -> Mix.raise("Failed to start repo: #{inspect(reason)}") + end + end + + defp validate_positive!(_name, value) when is_integer(value) and value > 0, do: :ok + + defp validate_positive!(name, value) do + Mix.raise( + "--#{String.replace(to_string(name), "_", "-")} must be a positive integer, got #{inspect(value)}" + ) + end + + defp clean_host(host) do + Mix.shell().info("Deleting synthetic analytics rows for #{host}...") + + %{num_rows: visits_deleted} = + query!( + """ + DELETE FROM site_page_visits + WHERE request_host = $1 AND user_agent = $2 + """, + [host, @synthetic_user_agent] + ) + + %{num_rows: sessions_deleted} = + query!( + """ + DELETE FROM site_sessions + WHERE entry_host = $1 AND user_agent = $2 + """, + [host, @synthetic_user_agent] + ) + + Mix.shell().info("Deleted #{sessions_deleted} sessions and #{visits_deleted} visits.") + end + + defp seed_sessions(host, session_prefix, sessions, visits, batch_size) do + Mix.shell().info("Seeding #{sessions} synthetic sessions for #{host}...") + + for {first, last} <- ranges(sessions, batch_size) do + query!( + """ + INSERT INTO site_sessions ( + session_id, + viewer_user_id, + visitor_id, + ip_address, + user_agent, + referer, + entry_host, + entry_path, + exit_host, + exit_path, + page_views, + started_at, + last_seen_at, + duration_seconds, + inserted_at, + updated_at + ) + SELECT + $1 || ':' || gs, + NULL, + 'synthetic-visitor-' || gs, + '203.0.113.' || ((gs % 250) + 1), + $2, + CASE WHEN gs % 5 = 0 THEN 'https://referrer.example/' || (gs % 25) ELSE NULL END, + $3, + CASE + WHEN gs % 11 = 0 THEN '/pricing' + WHEN gs % 7 = 0 THEN '/about' + WHEN gs % 5 = 0 THEN '/timeline' + ELSE '/' + END, + $3, + CASE + WHEN gs % 13 = 0 THEN '/account' + WHEN gs % 11 = 0 THEN '/pricing' + WHEN gs % 7 = 0 THEN '/about' + ELSE '/' + END, + (($4::bigint / $5::bigint) + CASE WHEN gs <= ($4::bigint % $5::bigint) THEN 1 ELSE 0 END)::integer, + (timezone('UTC', now()) - ((gs % 2592000) * interval '1 second'))::timestamp(0), + (timezone('UTC', now()) - ((gs % 2592000) * interval '1 second') + interval '30 seconds')::timestamp(0), + (30 + (gs % 600))::integer, + (timezone('UTC', now()) - ((gs % 2592000) * interval '1 second'))::timestamp(0), + (timezone('UTC', now()) - ((gs % 2592000) * interval '1 second'))::timestamp(0) + FROM generate_series($6::bigint, $7::bigint) AS gs + """, + [session_prefix, @synthetic_user_agent, host, visits, sessions, first, last] + ) + end + end + + defp seed_visits(host, session_prefix, sessions, visits, batch_size) do + Mix.shell().info("Seeding #{visits} synthetic page visits for #{host}...") + + for {first, last} <- ranges(visits, batch_size) do + query!( + """ + INSERT INTO site_page_visits ( + session_id, + viewer_user_id, + visitor_id, + ip_address, + user_agent, + referer, + request_host, + request_path, + status, + inserted_at + ) + SELECT + $1 || ':' || (((gs - 1) % $4::bigint) + 1), + NULL, + 'synthetic-visitor-' || (((gs - 1) % $4::bigint) + 1), + '203.0.113.' || ((gs % 250) + 1), + $2, + CASE WHEN gs % 5 = 0 THEN 'https://referrer.example/' || (gs % 25) ELSE NULL END, + $3, + CASE + WHEN gs % 11 = 0 THEN '/pricing' + WHEN gs % 7 = 0 THEN '/about' + WHEN gs % 5 = 0 THEN '/timeline' + ELSE '/' + END, + 200, + (timezone('UTC', now()) - ((gs % 2592000) * interval '1 second'))::timestamp(0) + FROM generate_series($5::bigint, $6::bigint) AS gs + """, + [session_prefix, @synthetic_user_agent, host, sessions, first, last] + ) + end + end + + defp ranges(total, batch_size) do + Stream.iterate(1, &(&1 + batch_size)) + |> Stream.take_while(&(&1 <= total)) + |> Enum.map(fn first -> {first, min(first + batch_size - 1, total)} end) + end + + defp benchmark(host) do + Mix.shell().info("Benchmarking analytics functions for #{host}...") + + time("get_public_site_view_stats", fn -> Profiles.get_public_site_view_stats(host) end) + + time("get_public_site_daily_view_counts", fn -> + Profiles.get_public_site_daily_view_counts(30, host) + end) + + time("get_public_site_domain_breakdown", fn -> + Profiles.get_public_site_domain_breakdown([host]) + end) + + time("get_public_site_top_pages", fn -> Profiles.get_public_site_top_pages(host, 10) end) + + time("get_public_site_top_referrers", fn -> + Profiles.get_public_site_top_referrers(host, 10) + end) + end + + defp time(label, fun) do + {duration_us, result} = :timer.tc(fun) + Mix.shell().info("#{label}: #{Float.round(duration_us / 1000, 1)}ms #{summarize(result)}") + end + + defp summarize(result) when is_list(result), do: "(#{length(result)} rows)" + defp summarize(result) when is_map(result), do: inspect(result) + defp summarize(_result), do: "" + + defp query!(sql, params) do + SQL.query!(Repo, sql, params, timeout: :infinity, pool_timeout: :infinity) + end +end diff --git a/apps/elektrine/lib/mix/tasks/arblarg.conformance.ex b/apps/elektrine/lib/mix/tasks/arblarg.conformance.ex new file mode 100644 index 0000000..9b47859 --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/arblarg.conformance.ex @@ -0,0 +1,113 @@ +defmodule Mix.Tasks.Arblarg.Conformance do + use Mix.Task + + alias Elektrine.Messaging.ArblargProfiles + + @shortdoc "Runs Arblarg conformance suites and writes badge artifacts" + + @moduledoc """ + Runs Arblarg conformance test suites and writes profile + extension badge artifacts. + + This task is intended as an operator and CI hard-gate workflow. + """ + + @core_conformance_test "apps/elektrine_web/test/elektrine_web/controllers/arblarg_conformance_test.exs" + @extension_conformance_test "apps/elektrine/test/elektrine/messaging/arblarg_extension_conformance_test.exs" + @badge_dir "external/arblarg/conformance/badges" + + @impl true + def run(_args) do + run_test_suite(@core_conformance_test) + run_test_suite(@extension_conformance_test) + + extension_statuses = ArblargProfiles.conformance_verified_extension_statuses() + + write_profile_badges(extension_statuses) + write_extension_badges(extension_statuses) + print_operator_flags() + end + + defp run_test_suite(path) do + Mix.shell().info("Running Arblarg conformance suite: #{path}") + Mix.Task.reenable("test") + Mix.Task.run("test", [path]) + end + + defp write_profile_badges(extension_statuses) do + ArblargProfiles.profile_badges(core_passed?: true, extension_statuses: extension_statuses) + |> Enum.each(fn badge -> + file_name = profile_badge_file_name(badge["id"]) + + payload = %{ + profile: badge["id"], + status: badge["status"], + suite_version: badge["suite_version"], + conformance_test_command: badge["conformance_test_command"], + generated_at: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + } + + write_badge(file_name, payload) + end) + end + + defp write_extension_badges(extension_statuses) do + ArblargProfiles.extension_registry(core_passed?: true, extension_statuses: extension_statuses) + |> Enum.each(fn extension -> + conformance = extension["conformance"] || %{} + + payload = %{ + extension: extension["urn"], + stability: extension["stability"], + status: conformance["status"], + suite_version: conformance["suite_version"], + conformance_test_command: conformance["test_command"], + generated_at: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + } + + write_badge("extension-#{slugify(extension["urn"])}.json", payload) + end) + end + + defp write_badge(file_name, payload) do + badge_file = Path.join(@badge_dir, file_name) + + badge_file + |> Path.dirname() + |> File.mkdir_p!() + + File.write!(badge_file, Jason.encode_to_iodata!(payload, pretty: true)) + Mix.shell().info("Wrote Arblarg badge artifact: #{badge_file}") + end + + defp print_operator_flags do + flags = + ArblargProfiles.extension_registry() + |> Enum.map(& &1["conformance"]) + |> Enum.reject(&is_nil/1) + |> Enum.map(& &1["env_flag"]) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + + Mix.shell().info("Set these environment flags only after CI validation passes:") + + Enum.each(flags, fn flag -> + Mix.shell().info(" - #{flag}=true") + end) + end + + defp slugify(value) do + value + |> to_string() + |> String.downcase() + |> String.replace(~r/[^a-z0-9]+/, "-") + |> String.trim("-") + end + + defp profile_badge_file_name(profile_id) do + case to_string(profile_id) do + "arblarg-core/1.0" -> "arblarg-core-1.0.json" + "arblarg-community/1.0" -> "arblarg-community-1.0.json" + other -> "#{slugify(other)}.json" + end + end +end diff --git a/apps/elektrine/lib/mix/tasks/badge.grant.ex b/apps/elektrine/lib/mix/tasks/badge.grant.ex new file mode 100644 index 0000000..f272a85 --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/badge.grant.ex @@ -0,0 +1,101 @@ +defmodule Mix.Tasks.Badge.Grant do + @moduledoc """ + Grants a badge to a user. + + Usage: + mix badge.grant USERNAME BADGE_TYPE [GRANTED_BY_USERNAME] + + Badge types: + - staff + - verified + - admin + - moderator + - supporter + - developer + - contributor + - beta_tester + + Examples: + mix badge.grant alice staff admin + mix badge.grant bob verified admin + """ + @shortdoc "Grants a badge to a user" + + use Mix.Task + import Ecto.Query + alias Elektrine.{Accounts, Profiles, Repo} + + @requirements ["app.repo"] + + @impl Mix.Task + def run([username, badge_type | rest]) do + granted_by_username = List.first(rest) + + case Accounts.get_user_by_username(username) do + nil -> + Mix.shell().error("User not found: #{username}") + System.halt(1) + + user -> + # Get admin user who's granting the badge + granted_by_id = + if granted_by_username do + case Accounts.get_user_by_username(granted_by_username) do + nil -> + Mix.shell().error("Admin user not found: #{granted_by_username}") + System.halt(1) + + admin -> + if admin.is_admin do + admin.id + else + Mix.shell().error("User #{granted_by_username} is not an admin") + System.halt(1) + end + end + else + # Default to first admin user + admin = Repo.one(from(u in Accounts.User, where: u.is_admin == true, limit: 1)) + + if admin do + admin.id + else + Mix.shell().error("No admin user found in database") + System.halt(1) + end + end + + # Check if badge already exists + if Profiles.has_badge?(user.id, badge_type) do + Mix.shell().info("User #{username} already has a #{badge_type} badge") + else + case Profiles.grant_badge(user.id, badge_type, granted_by_id) do + {:ok, badge} -> + Mix.shell().info("✓ Successfully granted #{badge_type} badge to #{username}") + Mix.shell().info(" Badge: #{badge.badge_text}") + Mix.shell().info(" Color: #{badge.badge_color}") + Mix.shell().info(" Icon: #{badge.badge_icon}") + + {:error, changeset} -> + Mix.shell().error("Failed to grant badge:") + Mix.shell().error(inspect(changeset.errors)) + System.halt(1) + end + end + end + end + + def run(_) do + Mix.shell().error("Usage: mix badge.grant USERNAME BADGE_TYPE [GRANTED_BY_USERNAME]") + Mix.shell().info("\nAvailable badge types:") + Mix.shell().info(" - staff") + Mix.shell().info(" - verified") + Mix.shell().info(" - admin") + Mix.shell().info(" - moderator") + Mix.shell().info(" - supporter") + Mix.shell().info(" - developer") + Mix.shell().info(" - contributor") + Mix.shell().info(" - beta_tester") + System.halt(1) + end +end diff --git a/apps/elektrine/lib/mix/tasks/badge.revoke.ex b/apps/elektrine/lib/mix/tasks/badge.revoke.ex new file mode 100644 index 0000000..df70eca --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/badge.revoke.ex @@ -0,0 +1,40 @@ +defmodule Mix.Tasks.Badge.Revoke do + @moduledoc """ + Revokes a badge from a user. + + Usage: + mix badge.revoke USERNAME BADGE_TYPE + + Examples: + mix badge.revoke alice staff + mix badge.revoke bob verified + """ + @shortdoc "Revokes a badge from a user" + + use Mix.Task + alias Elektrine.{Accounts, Profiles} + + @requirements ["app.repo"] + + @impl Mix.Task + def run([username, badge_type]) do + case Accounts.get_user_by_username(username) do + nil -> + Mix.shell().error("User not found: #{username}") + System.halt(1) + + user -> + if Profiles.has_badge?(user.id, badge_type) do + {count, _} = Profiles.revoke_badge(user.id, badge_type) + Mix.shell().info("✓ Revoked #{count} #{badge_type} badge(s) from #{username}") + else + Mix.shell().info("User #{username} does not have a #{badge_type} badge") + end + end + end + + def run(_) do + Mix.shell().error("Usage: mix badge.revoke USERNAME BADGE_TYPE") + System.halt(1) + end +end diff --git a/apps/elektrine/lib/mix/tasks/cleanup_duplicate_mailboxes.ex b/apps/elektrine/lib/mix/tasks/cleanup_duplicate_mailboxes.ex new file mode 100644 index 0000000..470d489 --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/cleanup_duplicate_mailboxes.ex @@ -0,0 +1,122 @@ +defmodule Mix.Tasks.Elektrine.CleanupDuplicateMailboxes do + @moduledoc false + use Mix.Task + import Ecto.Query + alias Elektrine.Email.Mailbox + alias Elektrine.Repo + + @shortdoc "Find and optionally clean up duplicate mailboxes" + + def run(args) do + Mix.Task.run("app.start") + + action = if "--fix" in args, do: :fix, else: :report + + IO.puts("\n🔍 Searching for duplicate mailboxes...\n") + + # Find all mailboxes grouped by lowercase email + mailboxes = + Mailbox + |> preload(:user) + |> Repo.all() + + # Group by lowercase email + grouped = + mailboxes + |> Enum.group_by(&String.downcase(&1.email)) + |> Enum.filter(fn {_email, boxes} -> length(boxes) > 1 end) + + if Enum.empty?(grouped) do + IO.puts("✅ No duplicate mailboxes found!") + else + IO.puts("⚠️ Found #{length(grouped)} email addresses with duplicate mailboxes:\n") + + Enum.each(grouped, fn {email, boxes} -> + IO.puts("📧 Email: #{email}") + IO.puts(" Found #{length(boxes)} mailboxes:") + + Enum.each(boxes, fn box -> + user_info = + if box.user do + "User: #{box.user.username} (ID: #{box.user.id})" + else + "User: (no user associated)" + end + + message_count = + Elektrine.Email.Message + |> where([m], m.mailbox_id == ^box.id) + |> Repo.aggregate(:count, :id) + + IO.puts(" - Mailbox ID: #{box.id}, #{user_info}") + IO.puts(" Created: #{box.inserted_at}") + IO.puts(" Messages: #{message_count}") + IO.puts(" Email in DB: #{box.email} (note case)") + end) + + if action == :fix do + IO.puts("\n 🔧 Fixing duplicates...") + fix_duplicates(boxes) + end + + IO.puts("") + end) + + if action == :report do + IO.puts( + "\n💡 To fix these duplicates, run: mix elektrine.cleanup_duplicate_mailboxes --fix" + ) + + IO.puts( + " This will keep the mailbox with the most messages and merge the others into it." + ) + end + end + end + + defp fix_duplicates(boxes) do + # Sort by message count (descending) to keep the one with most messages + sorted_boxes = + Enum.sort_by( + boxes, + fn box -> + Elektrine.Email.Message + |> where([m], m.mailbox_id == ^box.id) + |> Repo.aggregate(:count, :id) + end, + :desc + ) + + [keep | to_merge] = sorted_boxes + + if Enum.empty?(to_merge) do + IO.puts(" Nothing to merge") + else + IO.puts(" Keeping mailbox ID: #{keep.id} (has the most messages)") + + Enum.each(to_merge, fn box -> + message_count = + Elektrine.Email.Message + |> where([m], m.mailbox_id == ^box.id) + |> Repo.aggregate(:count, :id) + + if message_count > 0 do + IO.puts( + " Merging #{message_count} messages from mailbox ID: #{box.id} to mailbox ID: #{keep.id}" + ) + + # Update all messages to point to the kept mailbox + Elektrine.Email.Message + |> where([m], m.mailbox_id == ^box.id) + |> Repo.update_all(set: [mailbox_id: keep.id]) + end + + # Delete the duplicate mailbox + IO.puts(" Deleting duplicate mailbox ID: #{box.id}") + Repo.delete!(box) + end) + + IO.puts(" ✅ Duplicates merged successfully!") + end + end +end diff --git a/apps/elektrine/lib/mix/tasks/discussions.demo_posts.ex b/apps/elektrine/lib/mix/tasks/discussions.demo_posts.ex new file mode 100644 index 0000000..f99988f --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/discussions.demo_posts.ex @@ -0,0 +1,225 @@ +defmodule Mix.Tasks.Discussions.DemoPosts do + @moduledoc """ + Creates demo posts of all types in a community. + + Usage: + mix discussions.demo_posts [community_name] + + Example: + mix discussions.demo_posts elektrine + """ + use Mix.Task + + @shortdoc "Creates demo posts of all types in a community" + + def run(args) do + Mix.Task.run("app.start") + + community_name = List.first(args) || "elektrine" + + # Get community + community = + Elektrine.Repo.get_by(Elektrine.Social.Conversation, + name: community_name, + type: "community" + ) + + if !community do + Mix.shell().error("Community '#{community_name}' not found") + Mix.shell().info("Creating community '#{community_name}'...") + + # Get first user + user = Elektrine.Repo.all(Elektrine.Accounts.User) |> List.first() + + if !user do + Mix.shell().error("No users found. Please create a user first.") + System.halt(1) + end + + {:ok, community} = + Elektrine.Messaging.create_group_conversation( + user.id, + %{ + name: community_name, + description: "Demo community for testing post types", + type: "community", + is_public: true, + allow_public_posts: true, + discussion_style: "forum", + community_category: "tech" + }, + [] + ) + + Mix.shell().info("Created community: #{community.name}") + end + + user = Elektrine.Repo.get!(Elektrine.Accounts.User, community.creator_id) + + Mix.shell().info("Creating demo posts in community '#{community.name}'...") + + # 1. TEXT POST + Mix.shell().info("1. Creating text post...") + + {:ok, text_msg} = + Elektrine.Messaging.create_text_message( + community.id, + user.id, + "This is a comprehensive discussion about the benefits of functional programming. Elixir brings together the best of both worlds: the productivity of dynamic languages and the robustness of functional programming paradigms.", + nil, + skip_broadcast: true + ) + + {:ok, _} = + text_msg + |> Elektrine.Social.Message.changeset(%{ + title: "Why Functional Programming Matters in 2025", + post_type: "discussion", + visibility: "public" + }) + |> Elektrine.Repo.update() + + Mix.shell().info(" ✓ Text post created") + + # 2. LINK POST + Mix.shell().info("2. Creating link post...") + + {:ok, link_msg} = + Elektrine.Messaging.create_text_message( + community.id, + user.id, + "https://elixir-lang.org", + nil, + skip_broadcast: true + ) + + {:ok, _} = + link_msg + |> Elektrine.Social.Message.changeset(%{ + title: "Elixir Programming Language - Official Website", + post_type: "link", + primary_url: "https://elixir-lang.org", + visibility: "public" + }) + |> Elektrine.Repo.update() + + Mix.shell().info(" ✓ Link post created") + + # 3. IMAGE POST + Mix.shell().info("3. Creating image post...") + + {:ok, img_msg} = + Elektrine.Messaging.create_text_message( + community.id, + user.id, + "Check out this Elixir architecture diagram!", + nil, + skip_broadcast: true + ) + + {:ok, _} = + img_msg + |> Elektrine.Social.Message.changeset(%{ + title: "Elixir/Phoenix Architecture Diagram", + post_type: "discussion", + message_type: "image", + # Elixir logo + media_urls: ["https://avatars.githubusercontent.com/u/1481354?s=200&v=4"], + visibility: "public" + }) + |> Elektrine.Repo.update() + + Mix.shell().info(" ✓ Image post created") + + # 4. POLL POST + Mix.shell().info("4. Creating poll post...") + + {:ok, poll_msg} = + Elektrine.Messaging.create_text_message( + community.id, + user.id, + # Placeholder content for poll posts + "Poll", + nil, + skip_broadcast: true + ) + + {:ok, poll_msg} = + poll_msg + |> Elektrine.Social.Message.changeset(%{ + title: "Community Poll: Favorite Web Framework", + post_type: "poll", + visibility: "public", + # Clear placeholder content + content: "" + }) + |> Elektrine.Repo.update() + + # Create poll + closes_at = DateTime.add(DateTime.utc_now(), 7 * 24 * 60 * 60, :second) + + {:ok, poll} = + Elektrine.Social.create_poll( + poll_msg.id, + "What's your favorite web framework for building real-time applications?", + ["Phoenix/Elixir", "Rails/Ruby", "Django/Python", "Express/Node.js", "ASP.NET Core"], + closes_at: closes_at, + allow_multiple: false + ) + + Mix.shell().info(" ✓ Poll post created with #{length(poll.options)} options") + + # 5. MULTI-CHOICE POLL + Mix.shell().info("5. Creating multi-choice poll post...") + + {:ok, multi_poll_msg} = + Elektrine.Messaging.create_text_message( + community.id, + user.id, + # Placeholder content for poll posts + "Poll", + nil, + skip_broadcast: true + ) + + {:ok, multi_poll_msg} = + multi_poll_msg + |> Elektrine.Social.Message.changeset(%{ + title: "What features do you want? (Select all that apply)", + post_type: "poll", + visibility: "public", + # Clear placeholder content + content: "" + }) + |> Elektrine.Repo.update() + + {:ok, _multi_poll} = + Elektrine.Social.create_poll( + multi_poll_msg.id, + "Which features would you like to see implemented?", + [ + "Dark theme toggle", + "Mobile notifications", + "Video calls", + "Screen sharing", + "File sharing", + "Code snippets" + ], + # Never closes + closes_at: nil, + allow_multiple: true + ) + + Mix.shell().info(" ✓ Multi-choice poll created (never expires)") + + Mix.shell().info("") + Mix.shell().info("✅ Successfully created 5 demo posts:") + Mix.shell().info(" • 1 Text post") + Mix.shell().info(" • 1 Link post") + Mix.shell().info(" • 1 Image post") + Mix.shell().info(" • 2 Poll posts (single + multi-choice)") + Mix.shell().info("") + Mix.shell().info("Visit: http://localhost:4000/discussions/#{community.name}") + Mix.shell().info("") + end +end diff --git a/apps/elektrine/lib/mix/tasks/elektrine.db.repair_indexes.ex b/apps/elektrine/lib/mix/tasks/elektrine.db.repair_indexes.ex new file mode 100644 index 0000000..9ddc6a3 --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/elektrine.db.repair_indexes.ex @@ -0,0 +1,53 @@ +defmodule Mix.Tasks.Elektrine.Db.RepairIndexes do + @moduledoc """ + Repairs PostgreSQL indexes with REINDEX. + + Usage: + + mix elektrine.db.repair_indexes + mix elektrine.db.repair_indexes --index activitypub_actors_domain_index + mix elektrine.db.repair_indexes --table activitypub_actors + mix elektrine.db.repair_indexes --no-concurrently + """ + + use Mix.Task + + @shortdoc "Repairs PostgreSQL indexes" + + @impl Mix.Task + def run(args) do + Mix.Task.run("app.start") + + {opts, _argv, invalid} = + OptionParser.parse(args, + strict: [index: :string, table: :string, concurrently: :boolean], + aliases: [i: :index, t: :table] + ) + + if invalid != [] do + Mix.raise("Invalid options: #{inspect(invalid)}") + end + + concurrently? = Keyword.get(opts, :concurrently, true) + + result = + cond do + index = Keyword.get(opts, :index) -> + Elektrine.Database.IndexRepair.reindex_index(index, concurrently: concurrently?) + + table = Keyword.get(opts, :table) -> + Elektrine.Database.IndexRepair.reindex_table(table, concurrently: concurrently?) + + true -> + Elektrine.Database.IndexRepair.reindex_database(concurrently: concurrently?) + end + + case result do + {:ok, %{command: command}} -> + Mix.shell().info("Index repair complete: #{command}") + + {:error, %{command: command, reason: reason}} -> + Mix.raise("Index repair failed for #{command}: #{inspect(reason)}") + end + end +end diff --git a/apps/elektrine/lib/mix/tasks/email.inbound_dlq.ex b/apps/elektrine/lib/mix/tasks/email.inbound_dlq.ex new file mode 100644 index 0000000..9b65fcf --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/email.inbound_dlq.ex @@ -0,0 +1,172 @@ +defmodule Mix.Tasks.Email.InboundDlq do + use Mix.Task + + import Ecto.Query + + alias Elektrine.Repo + alias Oban.Job + + @shortdoc "Inspect and replay discarded Haraka inbound jobs" + + @moduledoc """ + Manage discarded jobs in the `email_inbound` queue. + + Examples: + mix email.inbound_dlq + mix email.inbound_dlq --limit 50 + mix email.inbound_dlq --requeue 12345 + mix email.inbound_dlq --requeue-all --limit 25 + """ + + @impl Mix.Task + def run(args) do + Mix.Task.run("app.start") + + {opts, _argv, invalid} = + OptionParser.parse(args, + strict: [limit: :integer, requeue: :integer, requeue_all: :boolean] + ) + + if invalid != [] do + Mix.raise("Invalid options: #{inspect(invalid)}") + end + + limit = max(1, Keyword.get(opts, :limit, 20)) + + cond do + is_integer(opts[:requeue]) -> + replay_job(opts[:requeue]) + + opts[:requeue_all] -> + replay_all(limit) + + true -> + list_discarded(limit) + end + end + + defp list_discarded(limit) do + jobs = discarded_jobs(limit) + + if jobs == [] do + Mix.shell().info("No discarded jobs in queue email_inbound.") + else + Mix.shell().info("Discarded jobs in queue email_inbound:") + + Enum.each(jobs, fn job -> + Mix.shell().info( + " id=#{job.id} attempts=#{job.attempt}/#{job.max_attempts} inserted_at=#{job.inserted_at} reason=#{last_error(job)}" + ) + end) + end + end + + defp replay_all(limit) do + jobs = discarded_jobs(limit) + + if jobs == [] do + Mix.shell().info("No discarded jobs to replay.") + else + Mix.shell().info("Replaying #{length(jobs)} discarded job(s)...") + + Enum.each(jobs, &replay_job(&1.id)) + end + end + + defp replay_job(job_id) do + case Repo.get(Job, job_id) do + %Job{queue: "email_inbound", state: "discarded"} = job -> + payload = job.args["payload"] + remote_ip = job.args["remote_ip"] + + if is_map(payload) do + case enqueue_replay(payload, remote_ip) do + {:ok, replay_job, outcome} -> + Mix.shell().info( + "Replayed discarded job #{job.id} -> new_job=#{replay_job.id} outcome=#{outcome}" + ) + + {:error, reason} -> + Mix.shell().error("Failed replay for job #{job.id}: #{inspect(reason)}") + end + else + Mix.shell().error("Job #{job.id} has no valid payload; cannot replay.") + end + + %Job{} -> + Mix.shell().error("Job #{job_id} is not a discarded email_inbound job.") + + nil -> + Mix.shell().error("Job #{job_id} not found.") + end + end + + defp enqueue_replay(payload, remote_ip) do + worker_module = Module.concat([ElektrineEmail, HarakaInboundWorker]) + + if Code.ensure_loaded?(worker_module) and function_exported?(worker_module, :enqueue, 2) do + worker_module.enqueue(payload, remote_ip: remote_ip) + else + args = %{ + "payload" => payload, + "received_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "remote_ip" => + if(is_binary(remote_ip), do: remote_ip, else: to_string(remote_ip || "unknown")), + "idempotency_key" => replay_idempotency_key(payload) + } + + job = + Oban.Job.new(args, + worker: "Elixir.ElektrineEmail.HarakaInboundWorker", + queue: "email_inbound", + max_attempts: 10 + ) + + case Elektrine.JobQueue.insert(job) do + {:ok, inserted} -> {:ok, inserted, :queued} + {:error, reason} -> {:error, reason} + end + end + end + + defp discarded_jobs(limit) do + from(j in Job, + where: j.queue == "email_inbound" and j.state == "discarded", + order_by: [desc: j.id], + limit: ^limit + ) + |> Repo.all() + end + + defp last_error(%Job{errors: errors}) when is_list(errors) and errors != [] do + case List.last(errors) do + %{"error" => error} -> error + %{error: error} -> error + other -> inspect(other) + end + end + + defp last_error(_), do: "unknown" + + defp replay_idempotency_key(payload) when is_map(payload) do + base = + [ + payload["message_id"], + payload["from"] || payload["mail_from"], + payload["rcpt_to"] || payload["to"], + payload["subject"] + ] + |> Enum.map_join("|", fn value -> + case value do + nil -> "" + value when is_binary(value) -> String.trim(value) + value -> to_string(value) + end + end) + + :crypto.hash(:sha256, base) + |> Base.encode16(case: :lower) + end + + defp replay_idempotency_key(_), do: Ecto.UUID.generate() |> String.replace("-", "") +end diff --git a/apps/elektrine/lib/mix/tasks/email.pubsub_test.ex b/apps/elektrine/lib/mix/tasks/email.pubsub_test.ex new file mode 100644 index 0000000..380cbb1 --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/email.pubsub_test.ex @@ -0,0 +1,34 @@ +defmodule Mix.Tasks.Email.PubsubTest do + @moduledoc false + use Mix.Task + require Logger + + @shortdoc "Test PubSub email broadcasting" + def run([user_id]) do + Mix.Task.run("app.start") + + # Create a test email message + test_message = %{ + id: "test-#{System.unique_integer([:positive])}", + message_id: "test-message-#{System.unique_integer([:positive])}@example.com", + subject: "Test PubSub Email", + from: "test@example.com", + to: ["user@example.com"], + html_body: "

    This is a test email for PubSub

    ", + plain_body: "This is a test email for PubSub", + read: false, + inserted_at: DateTime.utc_now() + } + + Logger.info("Broadcasting test email to user:#{user_id}") + + # Broadcast the test message + Phoenix.PubSub.broadcast!( + Elektrine.PubSub, + "user:#{user_id}", + {:new_email, test_message} + ) + + Logger.info("Test email broadcast complete!") + end +end diff --git a/apps/elektrine/lib/mix/tasks/email.recalculate_storage.ex b/apps/elektrine/lib/mix/tasks/email.recalculate_storage.ex new file mode 100644 index 0000000..8c7b321 --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/email.recalculate_storage.ex @@ -0,0 +1,64 @@ +defmodule Mix.Tasks.Email.RecalculateStorage do + @moduledoc """ + Recalculates storage usage for all users (centralized storage tracking). + + This task will: + - Go through all users in the system + - Calculate the actual storage used across emails, chat, and profile + - Update the storage_used_bytes field for each user + + Usage: + mix email.recalculate_storage + """ + + use Mix.Task + + alias Elektrine.Accounts.Storage + alias Elektrine.Accounts.User + alias Elektrine.Repo + + require Logger + + @shortdoc "Recalculates storage usage for all users" + + def run(_args) do + Mix.Task.run("app.start") + + Logger.info("Starting centralized storage recalculation for all users...") + + # Get all users + users = Repo.all(User) + + total_users = length(users) + Logger.info("Found #{total_users} users to process") + + # Process each user + {updated_count, error_count} = + users + |> Enum.with_index(1) + |> Enum.reduce({0, 0}, fn {user, index}, {updated_acc, error_acc} -> + Logger.info("Processing user #{index}/#{total_users}: #{user.username}") + + try do + # Calculate and update storage + {:ok, total_bytes} = Storage.update_user_storage(user.id) + Logger.info(" ✓ Updated storage: #{Storage.format_bytes(total_bytes)}") + {updated_acc + 1, error_acc} + rescue + e -> + Logger.error(" ✗ Error processing user: #{inspect(e)}") + {updated_acc, error_acc + 1} + end + end) + + Logger.info("Storage recalculation completed!") + Logger.info("Successfully updated: #{updated_count} users") + Logger.info("Errors: #{error_count} users") + + if error_count > 0 do + Logger.warning("Some users had errors during recalculation. Please check the logs above.") + else + Logger.info("All users processed successfully!") + end + end +end diff --git a/apps/elektrine/lib/mix/tasks/email.recategorize.ex b/apps/elektrine/lib/mix/tasks/email.recategorize.ex new file mode 100644 index 0000000..4653d9f --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/email.recategorize.ex @@ -0,0 +1,172 @@ +defmodule Mix.Tasks.Email.Recategorize do + @moduledoc """ + Re-categorizes existing messages using updated categorization rules. + + Usage: + mix email.recategorize # Recategorize all mailboxes + mix email.recategorize --user-id=123 # Recategorize specific user's mailbox + mix email.recategorize --mailbox-id=456 # Recategorize specific mailbox + mix email.recategorize --dry-run # Show what would be changed without applying + """ + + use Mix.Task + + alias Elektrine.Email + + @shortdoc "Re-categorizes existing messages using updated categorization rules" + + def run(args) do + # Start the application + Application.ensure_all_started(:elektrine) + + {opts, _argv, _errors} = + OptionParser.parse(args, + strict: [ + user_id: :integer, + mailbox_id: :integer, + dry_run: :boolean, + help: :boolean + ] + ) + + if opts[:help] do + print_help() + else + recategorize(opts) + end + end + + defp recategorize(opts) do + dry_run = opts[:dry_run] || false + + mailbox_ids = + cond do + opts[:mailbox_id] -> + [opts[:mailbox_id]] + + opts[:user_id] -> + case Email.get_user_mailbox(opts[:user_id]) do + nil -> + Mix.shell().error("No mailbox found for user ID #{opts[:user_id]}") + [] + + mailbox -> + [mailbox.id] + end + + true -> + # Get all mailbox IDs + Email.list_all_mailboxes() + |> Enum.map(& &1.id) + end + + if Enum.empty?(mailbox_ids) do + Mix.shell().error("No mailboxes found to process") + else + process_mailboxes(mailbox_ids, dry_run) + end + end + + defp process_mailboxes(mailbox_ids, dry_run) do + Mix.shell().info( + "#{if dry_run, do: "DRY RUN: ", else: ""}Processing #{length(mailbox_ids)} mailbox(es)..." + ) + + {total_processed, total_changed} = + Enum.reduce(mailbox_ids, {0, 0}, fn mailbox_id, {acc_processed, acc_changed} -> + Mix.shell().info("Processing mailbox #{mailbox_id}...") + + {processed, changed} = + if dry_run do + dry_run_recategorize(mailbox_id) + else + Email.recategorize_messages(mailbox_id) + end + + Mix.shell().info(" Processed: #{processed}, Changed: #{changed}") + {acc_processed + processed, acc_changed + changed} + end) + + Mix.shell().info("\n#{if dry_run, do: "DRY RUN ", else: ""}SUMMARY:") + Mix.shell().info("Total messages processed: #{total_processed}") + Mix.shell().info("Total messages changed: #{total_changed}") + + if dry_run do + Mix.shell().info("\nRun without --dry-run to apply these changes.") + else + Mix.shell().info("\nRecategorization complete!") + + # Invalidate caches for all affected mailboxes + Mix.shell().info("Clearing caches...") + + Enum.each(mailbox_ids, fn mailbox_id -> + case Email.get_mailbox_internal(mailbox_id) do + nil -> + :ok + + mailbox -> + if mailbox.user_id do + Elektrine.Email.Cached.invalidate_message_caches(mailbox_id, mailbox.user_id) + end + end + end) + + Mix.shell().info("Cache clearing complete!") + end + end + + defp dry_run_recategorize(mailbox_id) do + # Get all messages + messages = + Email.list_messages(mailbox_id, 10_000, 0) + # Skip spam messages + |> Enum.reject(& &1.spam) + + processed = length(messages) + + changed = + Enum.count(messages, fn message -> + # Create message attributes for categorization + message_attrs = %{ + "subject" => message.subject, + "from" => message.from, + "text_body" => message.text_body, + "html_body" => message.html_body + } + + # Apply categorization + categorized_attrs = Email.categorize_message(message_attrs) + + # Check if anything would change + would_change_category = categorized_attrs["category"] != message.category + + would_change_receipt = + Map.get(categorized_attrs, "is_receipt", false) != message.is_receipt + + would_change_newsletter = + Map.get(categorized_attrs, "is_newsletter", false) != message.is_newsletter + + would_change_notification = + Map.get(categorized_attrs, "is_notification", false) != message.is_notification + + if would_change_category or would_change_receipt or would_change_newsletter or + would_change_notification do + Mix.shell().info(" Would change: #{message.subject}") + + Mix.shell().info( + " From: #{message.category || "inbox"} -> #{categorized_attrs["category"] || "inbox"}" + ) + + true + else + false + end + end) + + {processed, changed} + end + + defp print_help do + Mix.shell().info(@moduledoc) + end +end diff --git a/apps/elektrine/lib/mix/tasks/email.seed_plaintext.ex b/apps/elektrine/lib/mix/tasks/email.seed_plaintext.ex new file mode 100644 index 0000000..e2f873d --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/email.seed_plaintext.ex @@ -0,0 +1,258 @@ +defmodule Mix.Tasks.Email.SeedPlaintext do + @shortdoc "Seeds test plaintext emails for testing display and formatting" + + use Mix.Task + alias Elektrine.Email + alias Elektrine.EmailAddresses + alias Elektrine.Repo + + @moduledoc """ + Mix task to seed test plaintext emails for display testing. + + Usage: + mix email.seed_plaintext [username] + + Examples: + mix email.seed_plaintext # Seeds to first user + mix email.seed_plaintext maxfield # Seeds to specific user + """ + + def run(args) do + Mix.Task.run("app.start") + + username = + case args do + [user] -> + user + + [] -> + get_first_user_username() + + _ -> + Mix.shell().error("Usage: mix email.seed_plaintext [username]") + System.halt(1) + end + + case Elektrine.Accounts.get_user_by_username(username) do + nil -> + Mix.shell().error("User '#{username}' not found") + System.halt(1) + + user -> + seed_plaintext_emails(user) + end + end + + defp get_first_user_username do + case Repo.all(Elektrine.Accounts.User, limit: 1) do + [user | _] -> + user.username + + [] -> + Mix.shell().error("No users found in database") + System.halt(1) + end + end + + defp seed_plaintext_emails(user) do + mailbox = Email.get_user_mailbox(user.id) + + if mailbox do + Mix.shell().info( + "Seeding plaintext test emails for #{EmailAddresses.primary_for_user(user)}..." + ) + + test_emails(mailbox.email) + |> Enum.each(fn email_data -> + attrs = Map.put(email_data, :mailbox_id, mailbox.id) + + case Email.create_message(attrs) do + {:ok, _message} -> + Mix.shell().info("✓ Created: #{email_data.subject}") + + {:error, changeset} -> + Mix.shell().error("✗ Failed: #{email_data.subject} - #{inspect(changeset.errors)}") + end + end) + + Mix.shell().info("Plaintext email seeding completed!") + else + Mix.shell().error("No mailbox found for user #{user.username}") + System.halt(1) + end + end + + defp test_emails(target_email) do + [ + %{ + message_id: EmailAddresses.uid("plaintext-test-1-#{System.unique_integer([:positive])}"), + from: "alice@example.com", + to: target_email, + subject: "Simple plaintext message", + text_body: + "Hello! This is a simple plaintext email with no HTML formatting.\n\nIt has multiple paragraphs and should display with proper line breaks.\n\nBest regards,\nAlice", + html_body: nil, + status: "received" + }, + %{ + message_id: EmailAddresses.uid("plaintext-test-2-#{System.unique_integer([:positive])}"), + from: "support@techcompany.com", + to: target_email, + subject: "Code snippet and formatting test", + text_body: """ + Hi there, + + Here's some code for you to review: + + And here's a list of items: + - First item + - Second item + - Third item + + Some ASCII art: + ┌─────────────┐ + │ SUCCESS │ + └─────────────┘ + + Long lines should wrap properly: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. + + Thanks! + Support Team + """, + html_body: nil, + status: "received" + }, + %{ + message_id: EmailAddresses.uid("plaintext-test-3-#{System.unique_integer([:positive])}"), + from: "newsletter@community.org", + to: target_email, + subject: "Weekly newsletter with mixed content", + text_body: """ + WEEKLY COMMUNITY NEWSLETTER + =========================== + + Welcome to this week's update! + + 📰 TOP STORIES: + --------------- + • Major breakthrough in quantum computing + • New programming language releases + • Community event announcements + + 🎯 FEATURED PROJECT: + ------------------- + Project Name: Elektrine Email Platform + Description: A modern email system with smart categorization + GitHub: https://github.com/example/elektrine + + 💻 CODE SNIPPET OF THE WEEK: + ---------------------------- + # Elixir pattern matching + case user_input do + {:ok, data} -> process_data(data) + {:error, reason} -> handle_error(reason) + _ -> default_action() + end + + 🔗 USEFUL LINKS: + ---------------- + - Documentation: https://docs.example.com + - Community Forum: https://forum.example.com + - Support: support@example.com + + That's all for this week! See you next time. + + --- + Unsubscribe: https://example.com/unsubscribe + """, + html_body: nil, + status: "received", + is_newsletter: true + }, + %{ + message_id: EmailAddresses.uid("plaintext-test-4-#{System.unique_integer([:positive])}"), + from: "system@bank.com", + to: target_email, + subject: "Account statement - Transaction details", + text_body: """ + ACCOUNT STATEMENT + ================= + + Account Number: ****-****-****-1234 + Statement Period: September 1-30, 2025 + + TRANSACTION SUMMARY: + ------------------- + Opening Balance: $1,250.00 + Total Deposits: $2,500.00 + Total Withdrawals: $ 750.00 + Closing Balance: $3,000.00 + + RECENT TRANSACTIONS: + ------------------- + 09/25 DEPOSIT Online Transfer +$1,500.00 + 09/24 PURCHASE Coffee Shop -$ 4.50 + 09/23 PURCHASE Gas Station -$ 45.00 + 09/22 DEPOSIT Salary Direct Deposit +$1,000.00 + 09/20 PURCHASE Grocery Store -$ 89.25 + + IMPORTANT NOTICE: + ---------------- + Your account is in good standing. + + If you have any questions about your account, please contact us at: + Phone: 1-800-555-BANK + Email: support@bank.com + Website: https://bank.com/support + + Thank you for banking with us! + + --- + This is an automated message. Please do not reply to this email. + """, + html_body: nil, + status: "received", + is_receipt: true + }, + %{ + message_id: EmailAddresses.uid("plaintext-test-5-#{System.unique_integer([:positive])}"), + from: "notifications@platform.com", + to: target_email, + subject: "Security alert: New login detected", + text_body: """ + SECURITY ALERT + ============== + + We detected a new login to your account: + + Time: September 10, 2025 at 2:30 PM PST + Location: San Francisco, CA, USA + Device: Chrome on macOS + IP Address: 192.168.1.100 + + Was this you? + + If you recognize this activity, you can ignore this message. + + If you don't recognize this activity: + 1. Change your password immediately + 2. Review your account for suspicious activity + 3. Contact our support team + + IMMEDIATE ACTIONS: + • Change Password: https://platform.com/change-password + • Review Activity: https://platform.com/security + • Contact Support: security@platform.com + + For your security, this is an automated message that cannot be replied to. + + Stay safe! + Platform Security Team + """, + html_body: nil, + status: "received", + is_notification: true + } + ] + end +end diff --git a/apps/elektrine/lib/mix/tasks/email.seed_realistic.ex b/apps/elektrine/lib/mix/tasks/email.seed_realistic.ex new file mode 100644 index 0000000..0fa9e93 --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/email.seed_realistic.ex @@ -0,0 +1,829 @@ +defmodule Mix.Tasks.Email.SeedRealistic do + @shortdoc "Seeds realistic email messages for local testing" + + use Mix.Task + + alias Elektrine.{Accounts, Email, EmailAddresses, Repo} + alias Elektrine.Email.Message + + @image_seed_version 3 + + @moduledoc """ + Seeds a realistic mailbox for testing email rendering, categories, search, and folders. + + Usage: + + mix email.seed_realistic + mix email.seed_realistic username + + The task is idempotent per mailbox. Existing seed messages are skipped by + their stable `message_id` values unless this task's seed data version has + changed, in which case the seed message is replaced. + """ + + @impl Mix.Task + def run(args) do + Mix.Task.run("app.start") + + username = parse_username!(args) + user = get_user!(username) + mailbox = ensure_mailbox!(user) + now = DateTime.utc_now() |> DateTime.truncate(:second) + + Mix.shell().info("Seeding realistic emails for #{mailbox.email}...") + + results = + mailbox.email + |> realistic_emails(EmailAddresses.primary_for_user(user) || mailbox.email, now) + |> Enum.map(&ensure_seed_email(mailbox.id, &1)) + + created = Enum.count(results, &(&1 == :created)) + refreshed = Enum.count(results, &(&1 == :refreshed)) + existing = Enum.count(results, &(&1 == :existing)) + failed = Enum.filter(results, &match?({:error, _}, &1)) + + Mix.shell().info( + "Created #{created} realistic emails; refreshed #{refreshed}; skipped #{existing} existing emails." + ) + + if failed != [] do + Enum.each(failed, fn {:error, {subject, reason}} -> + Mix.shell().error("Failed: #{subject} - #{inspect(reason)}") + end) + + System.halt(1) + end + end + + defp parse_username!([]), do: first_username!() + + defp parse_username!([username]), do: username + + defp parse_username!(_args) do + Mix.shell().error("Usage: mix email.seed_realistic [username]") + System.halt(1) + end + + defp first_username! do + case Repo.all(Accounts.User, limit: 1) do + [%{username: username} | _] -> username + [] -> Mix.raise("No users found. Create a user first, then rerun the seed task.") + end + end + + defp get_user!(username) do + Accounts.get_user_by_username(username) || + Mix.raise("User #{inspect(username)} not found") + end + + defp ensure_mailbox!(user) do + case Email.ensure_user_has_mailbox(user) do + {:ok, mailbox} -> mailbox + %{} = mailbox -> mailbox + {:error, reason} -> Mix.raise("Failed to ensure mailbox: #{inspect(reason)}") + other -> Mix.raise("Failed to ensure mailbox: #{inspect(other)}") + end + end + + defp ensure_seed_email(mailbox_id, attrs) do + attrs = + attrs + |> Map.put(:mailbox_id, mailbox_id) + |> Map.put(:updated_at, Map.fetch!(attrs, :inserted_at)) + + message_id = Map.fetch!(attrs, :message_id) + + case Repo.get_by(Message, mailbox_id: mailbox_id, message_id: message_id) do + nil -> + case Email.create_message(attrs) do + {:ok, message} -> + Mix.shell().info("Created: #{message.subject}") + :created + + {:error, reason} -> + {:error, {Map.get(attrs, :subject, message_id), reason}} + end + + message -> + if refresh_seed_email?(message) do + replace_seed_email(message, attrs) + else + :existing + end + end + end + + defp replace_seed_email(message, attrs) do + with {:ok, _deleted_message} <- Email.delete_message(message), + {:ok, recreated_message} <- Email.create_message(attrs) do + Mix.shell().info("Refreshed: #{recreated_message.subject}") + :refreshed + else + {:error, reason} -> + {:error, {Map.get(attrs, :subject, Map.fetch!(attrs, :message_id)), reason}} + end + end + + defp refresh_seed_email?(message) do + metadata = Map.get(message, :metadata) || %{} + + Map.get(metadata, "seed") == "realistic" and + seed_version(metadata) < @image_seed_version + end + + defp seed_version(metadata) do + case Map.get(metadata, "image_seed_version") do + version when is_integer(version) -> version + version when is_binary(version) -> parse_seed_version(version) + _ -> 0 + end + end + + defp parse_seed_version(version) do + case Integer.parse(version) do + {parsed, ""} -> parsed + _ -> 0 + end + end + + defp realistic_emails(target_email, sender_email, now) do + [ + %{ + seed_key: "personal-launch-handoff", + from: "Maya Patel ", + to: target_email, + cc: "ops@northstar.studio, design@brightline.example", + subject: "Launch handoff: vendor notes and Thursday blockers", + text_body: """ + Hey, + + I pulled the vendor notes into the launch doc and left three comments for the checkout copy. The only real blocker is legal approval on the revised refund language. + + Can you look at the risk table before noon tomorrow? If it looks good, I will send the final packet to procurement. + + Thanks, + Maya + """, + html_body: personal_handoff_html(), + attachments: %{ + "northstar-logo" => + inline_svg_attachment( + "northstar-logo@seed", + "northstar-logo.svg", + northstar_logo_svg() + ) + }, + category: "inbox", + priority: "high", + read: false, + status: "received", + metadata: %{"seed" => "realistic", "headers" => %{}} + }, + %{ + seed_key: "reply-later-contractor", + from: "Elliot Vargas ", + to: target_email, + subject: "Re: patio electrical estimate", + text_body: """ + The revised estimate is attached below in plain text so you can review on mobile. + + Permit fee: $185 + Trenching and conduit: $640 + Weatherproof outlets: $220 + Labor: $780 + + I can hold the Friday morning slot until tomorrow at 4 PM. + """, + html_body: contractor_estimate_html(), + category: "inbox", + reply_later_at: DateTime.add(now, 2 * 24 * 60 * 60, :second), + reply_later_reminder: true, + read: false, + status: "received", + metadata: %{"seed" => "realistic", "headers" => %{}} + }, + %{ + seed_key: "calendar-design-review", + from: "Priya Raman ", + to: target_email, + subject: "Updated invitation: Product design review", + text_body: """ + Product design review has been moved to Friday at 10:30 AM. + + Agenda: + 1. New onboarding flow + 2. Billing page empty states + 3. Mobile email rendering bugs + + Join: https://meet.luma.example/design-review + """, + html_body: calendar_update_html(), + category: "inbox", + is_notification: true, + read: true, + status: "received", + metadata: %{ + "seed" => "realistic", + "headers" => %{"X-Auto-Response-Suppress" => "All"} + } + }, + %{ + seed_key: "stripe-receipt", + from: "Stripe ", + to: target_email, + subject: "Your receipt from Acme Cloud, Inc. #2481-1942", + text_body: """ + Receipt from Acme Cloud, Inc. + + Pro workspace subscription + Amount paid: $49.00 + Paid with Visa ending in 4242 + Receipt number: 2481-1942 + """, + html_body: stripe_receipt_html(), + category: "ledger", + is_receipt: true, + read: false, + status: "received", + metadata: %{"seed" => "realistic", "headers" => %{}} + }, + %{ + seed_key: "bank-statement", + from: "Riverside Credit Union ", + to: target_email, + subject: "Your April statement is ready", + text_body: """ + Your April statement is ready. + + Ending balance: $8,431.22 + Deposits: $6,250.00 + Withdrawals and purchases: $4,102.48 + + The attached statement includes transaction details and regulatory notices. + """, + html_body: bank_statement_html(), + attachments: %{ + "statement-april" => %{ + "filename" => "Riverside-Statement-April.pdf", + "content_type" => "application/pdf", + "disposition" => "attachment", + "encoding" => "base64", + "data" => Base.encode64("%PDF-1.4\nSeed statement for local testing\n%%EOF") + } + }, + category: "ledger", + is_receipt: true, + read: true, + status: "received", + metadata: %{"seed" => "realistic", "headers" => %{}} + }, + %{ + seed_key: "shipping-update", + from: "Track & Ship ", + to: target_email, + subject: "Out for delivery: order TS-10492", + text_body: """ + Your package is out for delivery and should arrive today between 1:15 PM and 4:45 PM. + + Tracking number: 1Z999AA10123456784 + Driver note: Leave behind side gate if no answer. + """, + html_body: shipping_update_html(), + category: "inbox", + is_notification: true, + read: false, + status: "received", + metadata: %{"seed" => "realistic", "headers" => %{}} + }, + %{ + seed_key: "github-security", + from: "GitHub ", + to: target_email, + subject: "Security alert: new SSH key added to your account", + text_body: """ + A new SSH key named framework-laptop was added to your account. + + Time: May 3, 2026 7:42 PM UTC + Location: Austin, Texas, United States + + If this was not you, remove the key and reset your password immediately. + """, + html_body: github_security_html(), + category: "inbox", + is_notification: true, + priority: "high", + read: false, + status: "received", + metadata: %{ + "seed" => "realistic", + "headers" => %{"X-GitHub-Recipient" => target_email} + } + }, + %{ + seed_key: "postgres-weekly", + from: "Postgres Weekly ", + to: target_email, + subject: "Postgres Weekly: indexes, VACUUM, and logical replication", + text_body: """ + This week in Postgres: + + - Why partial indexes are still underused + - Practical VACUUM tuning for write-heavy tables + - Logical replication failover notes + + Sponsored: observability for database teams. + """, + html_body: postgres_weekly_html(), + category: "feed", + is_newsletter: true, + read: false, + status: "received", + metadata: %{ + "seed" => "realistic", + "headers" => %{ + "List-Id" => "Postgres Weekly ", + "List-Unsubscribe" => "" + } + } + }, + %{ + seed_key: "indeed-broken-css", + from: "Indeed ", + to: target_email, + subject: "Find immediate job opportunities", + text_body: indeed_broken_css_text(), + html_body: nil, + category: "feed", + is_newsletter: true, + read: false, + status: "received", + metadata: %{ + "seed" => "realistic", + "headers" => %{"List-Unsubscribe" => ""} + } + }, + %{ + seed_key: "retail-promo", + from: "Field Supply ", + to: target_email, + subject: "Early access: spring gear is 30% off", + text_body: """ + Early access starts now. + + Jackets, packs, and trail tools are 30% off through Sunday. Members get free shipping over $50. + + Manage preferences or unsubscribe any time. + """, + html_body: retail_promo_html(), + category: "feed", + is_newsletter: true, + read: true, + status: "received", + metadata: %{ + "seed" => "realistic", + "headers" => %{"List-Unsubscribe" => ""} + } + }, + %{ + seed_key: "stack-longform", + from: "Nadia Brooks ", + to: target_email, + subject: "Long read: incident review notes", + text_body: """ + I wrote up the incident review while it was still fresh. No rush today; this is better as a focused read. + + The short version: the retry worker behaved correctly, but our dashboard grouped two unrelated failure modes together. + """, + html_body: incident_review_html(), + category: "stack", + stack_at: DateTime.add(now, -3 * 60 * 60, :second), + stack_reason: "Long-form review to read after standup", + read: false, + status: "received", + metadata: %{"seed" => "realistic", "headers" => %{}} + }, + %{ + seed_key: "archived-github-thread", + from: "GitHub ", + to: target_email, + subject: "[elektrine] PR #482: tighten email iframe rendering", + text_body: """ + ci-bot commented on pull request #482. + + All checks passed. Coverage changed by +0.3%. + """, + html_body: github_pr_html(), + category: "inbox", + is_notification: true, + archived: true, + read: true, + status: "received", + metadata: %{ + "seed" => "realistic", + "headers" => %{"List-Id" => "elektrine.github.com"} + } + }, + %{ + seed_key: "spam-phishing", + from: "Security Desk ", + to: target_email, + subject: "Final warning: account access limited", + text_body: """ + Your account access has been limited. Verify your payment details within 2 hours to avoid permanent suspension. + + https://paypa1-alerts.example/verify + """, + html_body: phishing_html(), + spam: true, + read: false, + status: "received", + metadata: %{"seed" => "realistic", "headers" => %{}} + }, + %{ + seed_key: "sent-vendor-followup", + from: sender_email, + to: "Jordan Lee ", + subject: "Re: proofs for the conference badges", + text_body: """ + Thanks Jordan, + + The second proof looks good. Please proceed with the matte finish and ship to the Austin office. + + Best, + #{sender_name(sender_email)} + """, + html_body: sent_vendor_html(sender_email), + category: "inbox", + read: true, + status: "sent", + metadata: %{"seed" => "realistic", "headers" => %{}} + }, + %{ + seed_key: "draft-client-proposal", + from: sender_email, + to: "Rae Chen ", + subject: "Proposal notes for Atlas Coffee", + text_body: """ + Hi Rae, + + I started outlining the migration plan below. I still need to confirm the DNS cutover window before sending this. + + Proposed phases: + 1. Audit existing inbox rules + 2. Import historical mail + 3. Run dual delivery for 48 hours + """, + html_body: draft_proposal_html(), + category: "inbox", + read: true, + status: "draft", + metadata: %{"seed" => "realistic", "headers" => %{}} + } + ] + |> Enum.with_index() + |> Enum.map(fn {attrs, index} -> + seed_key = Map.fetch!(attrs, :seed_key) + inserted_at = DateTime.add(now, -(index + 1) * 75 * 60, :second) + + attrs + |> Map.delete(:seed_key) + |> put_seed_metadata() + |> Map.put(:message_id, EmailAddresses.uid("realistic-seed-#{seed_key}")) + |> Map.put(:inserted_at, inserted_at) + end) + end + + defp put_seed_metadata(attrs) do + Map.update(attrs, :metadata, seed_metadata(), fn metadata -> + metadata + |> Map.put("seed", "realistic") + |> Map.put("image_seed_version", @image_seed_version) + end) + end + + defp seed_metadata, do: %{"seed" => "realistic", "image_seed_version" => @image_seed_version} + + defp inline_svg_attachment(content_id, filename, svg) do + %{ + "filename" => filename, + "content_type" => "image/svg+xml", + "content_id" => "<#{content_id}>", + "disposition" => "inline", + "encoding" => "base64", + "data" => Base.encode64(svg) + } + end + + defp northstar_logo_svg do + """ + + + + + """ + end + + defp sender_name(email) do + email + |> String.split("@", parts: 2) + |> hd() + |> String.replace([".", "_", "-"], " ") + |> String.split() + |> Enum.map_join(" ", &String.capitalize/1) + end + + defp shell(title, body) do + """ + + + + + + + + +
    +
    +
    + + + + + +
    #{title}
    +
    +
    #{body}
    +
    +
    + + + """ + end + + defp personal_handoff_html do + shell( + "Launch handoff", + """ + Northstar Studio logo +

    Hey,

    +

    I pulled the vendor notes into the launch doc and left three comments for the checkout copy. The only real blocker is legal approval on the revised refund language.

    +

    Can you look at the risk table before noon tomorrow? If it looks good, I will send the final packet to procurement.

    +

    Thanks,
    Maya

    + """ + ) + end + + defp contractor_estimate_html do + shell( + "Patio electrical estimate", + """ +

    The revised estimate is below so you can review on mobile.

    + + + + + +
    Permit fee$185
    Trenching and conduit$640
    Weatherproof outlets$220
    Labor$780
    +

    I can hold the Friday morning slot until tomorrow at 4 PM.

    + """ + ) + end + + defp calendar_update_html do + shell( + "Updated invitation", + """ + Product design review calendar banner +

    Product design review has moved to Friday at 10:30 AM.

    +
      +
    1. New onboarding flow
    2. +
    3. Billing page empty states
    4. +
    5. Mobile email rendering bugs
    6. +
    +

    Join meeting

    + """ + ) + end + + defp stripe_receipt_html do + shell( + "Receipt from Acme Cloud, Inc.", + """ + Acme Cloud receipt banner + + + + +
    Pro workspace subscription$49.00
    Tax$0.00
    Total paid$49.00
    +

    Paid with Visa ending in 4242. Receipt number 2481-1942.

    + """ + ) + end + + defp bank_statement_html do + shell( + "Your April statement is ready", + """ +

    Your April statement is ready. The attached PDF includes transaction details and regulatory notices.

    + + + + +
    Ending balance$8,431.22
    Deposits$6,250.00
    Withdrawals and purchases$4,102.48
    + """ + ) + end + + defp shipping_update_html do + shell( + "Out for delivery", + """ + Delivery truck illustration +

    Your package should arrive today between 1:15 PM and 4:45 PM.

    +

    Tracking number: 1Z999AA10123456784

    +

    Track package

    + """ + ) + end + + defp github_security_html do + shell( + "Security alert", + """ + Security alert banner +

    A new SSH key named framework-laptop was added to your account.

    + + + +
    TimeMay 3, 2026 7:42 PM UTC
    LocationAustin, Texas, United States
    +

    If this was not you, remove the key and reset your password immediately.

    + """ + ) + end + + defp postgres_weekly_html do + """ + + + + + + + +
    +

    Postgres Weekly

    Indexes, VACUUM, and logical replication

    + Postgres Weekly feature image +

    Why partial indexes are still underused

    A practical guide to keeping hot queries fast without indexing every row.

    +

    VACUUM tuning for write-heavy tables

    How one team reduced table bloat during peak ingest windows.

    +

    You are receiving this because you subscribed to Postgres Weekly.

    +
    + + + """ + end + + defp indeed_broken_css_text do + """ + 700&display=swap'); + + /* iOS BLUE LINKS */ + a[x-apple-data-detectors] { + color: inherit !important; + font-size: inherit !important; + font-family: inherit !important; + font-weight: inherit !important; + line-height: inherit !important; + } + + /* Samsung blue Links */ + #MessageViewBody a { + color: inherit; + text-decoration: none; + font-size: inherit; + font-family: inherit; + font-weight: inherit; + line-height: inherit; + } + + @media all and (max-width: 600px) { + .outerContainer { width: 100% !important } + .hide { display: none !important } + .ph-16 { padding-left: 16px !important; padding-right: 16px !important } + } + + [style*='Noto Sans'] { + font-family: 'Indeed Sans', 'Noto Sans', Helvetica, Arial, sans-serif !important; + } + + Find immediate job opportunities ‌ ‌ ‌ + + Find JobsSign in + + Apply now to companies hiring fast + + We have crunched the data from the past few months to find companies that prioritize speed. From engineering to retail, see our 2026 list of employers with the shortest posting-to-hire timelines. + + Get the list + + Your next opportunity is just a minute away + + Looking for your next job should not mean spending hours on applications. + """ + end + + defp retail_promo_html do + shell( + "Field Supply member preview", + """ +
    +

    Early access

    +

    Spring gear is 30% off

    +
    + Outdoor gear product lineup +

    Jackets, packs, and trail tools are marked down through Sunday. Members get free shipping over $50.

    +

    Shop the preview

    + """ + ) + end + + defp incident_review_html do + shell( + "Incident review notes", + """ +

    I wrote up the incident review while it was still fresh. No rush today; this is better as a focused read.

    +
    The retry worker behaved correctly, but our dashboard grouped two unrelated failure modes together.
    +

    The full write-up has proposed follow-ups for alert names, runbook links, and backfill limits.

    + """ + ) + end + + defp github_pr_html do + shell( + "Pull request checks", + """ +

    ci-bot commented on pull request #482.

    +
    All checks passed\nCoverage changed by +0.3%
    +

    View pull request

    + """ + ) + end + + defp phishing_html do + shell( + "Account access limited", + """ +

    Your account access has been limited. Verify your payment details within 2 hours to avoid permanent suspension.

    +

    Verify account

    +

    This message intentionally looks suspicious for spam-folder testing.

    + """ + ) + end + + defp sent_vendor_html(sender_email) do + shell( + "Re: proofs for the conference badges", + """ +

    Thanks Jordan,

    +

    The second proof looks good. Please proceed with the matte finish and ship to the Austin office.

    +

    Best,
    #{sender_name(sender_email)}

    + """ + ) + end + + defp draft_proposal_html do + shell( + "Proposal notes for Atlas Coffee", + """ +

    Hi Rae,

    +

    I started outlining the migration plan below. I still need to confirm the DNS cutover window before sending this.

    +
      +
    1. Audit existing inbox rules
    2. +
    3. Import historical mail
    4. +
    5. Run dual delivery for 48 hours
    6. +
    + """ + ) + end +end diff --git a/apps/elektrine/lib/mix/tasks/email.test.ex b/apps/elektrine/lib/mix/tasks/email.test.ex new file mode 100644 index 0000000..f3d9a08 --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/email.test.ex @@ -0,0 +1,106 @@ +defmodule Mix.Tasks.Email.Test do + @moduledoc false + use Mix.Task + + @shortdoc "Test the email system by sending and receiving test emails" + + def run(_) do + # Start the application + Mix.shell().info("Starting the application...") + Mix.Task.run("app.start") + + # Get a user to test with + user = + case Elektrine.Accounts.list_users() do + [user | _] -> user + [] -> create_test_user() + end + + # Ensure the user has a mailbox + mailbox = + Elektrine.Email.get_user_mailbox(user.id) || + create_test_mailbox(user) + + Mix.shell().info("Using mailbox: #{mailbox.email}") + + # Test sending an email + test_send_email(mailbox) + + # Test receiving an email + test_receive_email(mailbox) + + Mix.shell().info("Email tests completed! Check the mailbox.") + end + + defp create_test_user do + Mix.shell().info("Creating a test user...") + + {:ok, user} = + Elektrine.Accounts.create_user(%{ + username: "testuser", + password: "password123", + password_confirmation: "password123" + }) + + user + end + + defp create_test_mailbox(user) do + Mix.shell().info("Creating a test mailbox...") + + {:ok, mailbox} = Elektrine.Email.create_mailbox(user) + mailbox + end + + defp test_send_email(mailbox) do + Mix.shell().info("Testing email sending...") + + # Send a test email to the same mailbox (loopback test) + params = %{ + from: mailbox.email, + to: mailbox.email, + subject: "Test Email #{DateTime.utc_now()}", + text_body: "This is a test email sent from the Elektrine email system.", + html_body: + "

    This is a test email sent from the Elektrine email system.

    " + } + + case Elektrine.Email.HarakaClient.send_email(params) do + {:ok, result} -> + Mix.shell().info("Email sent successfully! Message ID: #{result.message_id}") + + {:error, reason} -> + Mix.shell().error("Failed to send email: #{inspect(reason)}") + end + end + + defp test_receive_email(mailbox) do + Mix.shell().info("Testing email receiving (via webhook)...") + + # Create a mock webhook payload + webhook_payload = %{ + "id" => "test-#{System.system_time(:millisecond)}", + "from" => "test-sender@example.com", + "to" => mailbox.email, + "subject" => "Test Webhook Email #{DateTime.utc_now()}", + "text_part" => "This is a test email received via webhook.", + "html_part" => "

    This is a test email received via webhook.

    ", + "received_with_ssl" => true + } + + conn = %Plug.Conn{ + remote_ip: {127, 0, 0, 1}, + req_headers: [{"x-real-ip", "127.0.0.1"}] + } + + # Process the webhook through the controller action. Controller actions + # return a Plug.Conn, not an {:ok, message} tuple. + case ElektrineEmailWeb.HarakaWebhookController.create(conn, webhook_payload) do + %{status: 200, resp_body: body} -> + Mix.shell().info("Test email received successfully! Response: #{body}") + + %{status: status, resp_body: body} -> + Mix.shell().error("Failed to process test email: status=#{status} response=#{body}") + end + end +end diff --git a/apps/elektrine/lib/mix/tasks/fix_username_duplicates.ex b/apps/elektrine/lib/mix/tasks/fix_username_duplicates.ex new file mode 100644 index 0000000..583c5de --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/fix_username_duplicates.ex @@ -0,0 +1,161 @@ +defmodule Mix.Tasks.Elektrine.FixUsernameDuplicates do + @moduledoc false + use Mix.Task + import Ecto.Query + alias Elektrine.Accounts.User + alias Elektrine.Email.Mailbox + alias Elektrine.Repo + + @shortdoc "Find and fix username duplicates (case-insensitive)" + + def run(args) do + Mix.Task.run("app.start") + + action = + cond do + "--fix" in args -> :fix + "--report" in args -> :report + true -> :report + end + + IO.puts("\n🔍 Checking for username duplicates (case-insensitive)...\n") + + # Find all usernames that have case-only duplicates + duplicates = + User + |> select([u], %{ + username: u.username, + username_lower: fragment("lower(?)", u.username), + id: u.id, + inserted_at: u.inserted_at, + last_login: u.last_login_at, + banned: u.banned + }) + |> Repo.all() + |> Enum.group_by(& &1.username_lower) + |> Enum.filter(fn {_username_lower, users} -> length(users) > 1 end) + + if Enum.empty?(duplicates) do + IO.puts("✅ No username duplicates found!\n") + + if action == :report do + IO.puts("You can now safely add a case-insensitive unique index:") + IO.puts("CREATE UNIQUE INDEX users_username_ci_index ON users (lower(username));") + end + else + IO.puts("⚠️ Found #{length(duplicates)} usernames with case-only duplicates:\n") + + Enum.each(duplicates, fn {username_lower, users} -> + IO.puts("📝 Username (lowercase): #{username_lower}") + IO.puts(" Found #{length(users)} variations:") + + sorted_users = Enum.sort_by(users, & &1.inserted_at) + + Enum.each(sorted_users, fn user -> + # Get mailbox info + mailbox = Repo.one(from m in Mailbox, where: m.user_id == ^user.id, limit: 1) + + message_count = + if mailbox do + Repo.one( + from m in Elektrine.Email.Message, + where: m.mailbox_id == ^mailbox.id, + select: count(m.id) + ) || 0 + else + 0 + end + + status = + cond do + user.banned -> "🚫 BANNED" + user.last_login -> "✅ Active" + true -> "👻 Never logged in" + end + + IO.puts(" - ID: #{user.id}, Username: \"#{user.username}\" #{status}") + IO.puts(" Registered: #{Calendar.strftime(user.inserted_at, "%Y-%m-%d %H:%M")}") + + if user.last_login do + IO.puts(" Last login: #{Calendar.strftime(user.last_login, "%Y-%m-%d %H:%M")}") + end + + IO.puts(" Email messages: #{message_count}") + end) + + if action == :fix do + IO.puts("\n 🔧 Recommended action:") + # The first registered user should keep their username + [keep | to_rename] = sorted_users + + IO.puts(" Keep: #{keep.username} (ID: #{keep.id}) - registered first") + + Enum.each(to_rename, fn user -> + suggested_new = suggest_new_username(user.username, username_lower) + IO.puts(" Rename: #{user.username} (ID: #{user.id}) -> #{suggested_new}") + end) + + if confirm("\n Apply these changes?") do + apply_username_fixes(keep, to_rename, username_lower) + end + end + + IO.puts("") + end) + + if action == :report do + IO.puts("\n💡 To fix these duplicates automatically, run:") + IO.puts(" mix elektrine.fix_username_duplicates --fix") + IO.puts("\n⚠️ Manual resolution may be needed for complex cases.") + end + end + end + + defp suggest_new_username(_original, base) do + # Try adding numbers until we find a unique username + Enum.find(1..999, fn n -> + candidate = "#{base}#{n}" + !Repo.exists?(from u in User, where: fragment("lower(?)", u.username) == ^candidate) + end) + |> case do + nil -> "#{base}_#{:rand.uniform(9999)}" + n -> "#{base}#{n}" + end + end + + defp apply_username_fixes(_keep, to_rename, _username_lower) do + Enum.each(to_rename, fn user -> + new_username = suggest_new_username(user.username, String.downcase(user.username)) + + IO.puts(" Renaming user #{user.id}: #{user.username} -> #{new_username}") + + # Update the user's username + user + |> User.admin_changeset(%{username: new_username}) + |> Repo.update!() + + # Update their mailbox email if it exists + case Repo.one(from m in Mailbox, where: m.user_id == ^user.id, limit: 1) do + nil -> + IO.puts(" No mailbox to update") + + mailbox -> + updated_email = Elektrine.EmailAddresses.local(new_username) + + mailbox + |> Ecto.Changeset.change(email: updated_email) + |> Repo.update!() + + IO.puts(" Updated mailbox email to #{updated_email}") + end + end) + + IO.puts("\n✅ Fixes applied successfully!") + end + + defp confirm(prompt) do + IO.write(prompt <> " (y/n) ") + response = IO.gets("") |> String.trim() |> String.downcase() + response in ["y", "yes"] + end +end diff --git a/apps/elektrine/lib/mix/tasks/messaging.arblarg.sync_artifacts.ex b/apps/elektrine/lib/mix/tasks/messaging.arblarg.sync_artifacts.ex new file mode 100644 index 0000000..4516d0a --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/messaging.arblarg.sync_artifacts.ex @@ -0,0 +1,49 @@ +defmodule Mix.Tasks.Messaging.Arblarg.SyncArtifacts do + @moduledoc """ + Syncs versioned Arblarg schema artifacts from the live SDK into `external/arblarg`. + """ + use Mix.Task + + @shortdoc "Syncs published Arblarg schema artifacts from the live SDK" + + alias Elektrine.Messaging.ArblargSDK + + @impl true + def run(_args) do + Mix.Task.run("compile") + + version = ArblargSDK.protocol_version() + major_version = version |> String.split(".") |> List.first() + + schema_dir = + Path.expand("../../../../../external/arblarg/schemas/v#{major_version}", __DIR__) + + File.mkdir_p!(schema_dir) + + expected_files = + ArblargSDK.schema_names(version) + |> Enum.map(fn schema_name -> + schema = ArblargSDK.schema(version, schema_name) + path = Path.join(schema_dir, "#{schema_name}.json") + + encoded_schema = + schema + |> Jason.encode_to_iodata!(pretty: true) + |> IO.iodata_to_binary() + + File.write!(path, encoded_schema <> "\n") + Path.basename(path) + end) + |> MapSet.new() + + schema_dir + |> File.ls!() + |> Enum.filter(&String.ends_with?(&1, ".json")) + |> Enum.reject(&MapSet.member?(expected_files, &1)) + |> Enum.each(fn stale_file -> + File.rm!(Path.join(schema_dir, stale_file)) + end) + + Mix.shell().info("Synced Arblarg schema artifacts in #{schema_dir}") + end +end diff --git a/apps/elektrine/lib/mix/tasks/messaging.federation.interop_smoke.ex b/apps/elektrine/lib/mix/tasks/messaging.federation.interop_smoke.ex new file mode 100644 index 0000000..002cbf6 --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/messaging.federation.interop_smoke.ex @@ -0,0 +1,193 @@ +defmodule Mix.Tasks.Messaging.Federation.InteropSmoke do + @shortdoc "Runs a local Arblarg interop smoke test against the reference peer" + @moduledoc """ + Runs a local Arblarg interoperability smoke test between the main federation + implementation and the in-memory reference peer. + + Example: + + mix messaging.federation.interop_smoke --report external/arblarg/interop/smoke.json + """ + + use Mix.Task + + alias Elektrine.Messaging.ArblargSDK + alias Elektrine.Messaging.Federation + alias Elektrine.Messaging.Federation.Config + alias Elektrine.Messaging.ReferencePeer + + @switches [ + domain: :string, + secret: :string, + report: :string + ] + + @impl true + def run(args) do + Mix.Task.run("app.start") + + {opts, _argv, _invalid} = OptionParser.parse(args, strict: @switches) + domain = Keyword.get(opts, :domain, "reference.test") + secret = Keyword.get(opts, :secret, "reference-secret") + report_path = Keyword.get(opts, :report, "external/arblarg/interop/smoke.json") + + peer = ReferencePeer.new(domain: domain, secret: secret) + previous = Application.get_env(:elektrine, :messaging_federation, []) + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.merge(previous, + enabled: true, + peers: [], + discovery_fetcher: fn + ^domain, _urls -> {:ok, ReferencePeer.discovery_document(peer)} + _other, _urls -> {:error, :not_found} + end + ) + ) + + try do + {local_to_reference_status, updated_reference_peer} = run_local_to_reference(peer) + reference_to_local = run_reference_to_local(peer) + + report = %{ + generated_at: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + local_domain: Federation.local_domain(), + reference_domain: domain, + local_to_reference: local_to_reference_status, + reference_to_local: reference_to_local, + reference_summary: ReferencePeer.summary(updated_reference_peer) + } + + write_report(report_path, report) + print_report(report) + after + Application.put_env(:elektrine, :messaging_federation, previous) + end + end + + defp run_local_to_reference(peer) do + local_identity = Federation.local_discovery_document()["identity"] + + event = + signed_local_event( + "message.create", + "channel:https://#{Federation.local_domain()}/_arblarg/channels/interop-local-task", + 1, + message_payload(Federation.local_domain(), "interop-local-task", "hello from local task") + ) + + case ReferencePeer.receive_event( + peer, + event, + ReferencePeer.key_lookup_from_identity(local_identity) + ) do + {:ok, updated_peer, status} -> {status, updated_peer} + {:error, reason} -> Mix.raise("reference peer rejected local event: #{inspect(reason)}") + end + end + + defp run_reference_to_local(peer) do + case Federation.discover_peer(peer.domain, force: true) do + {:ok, _peer} -> :ok + {:error, reason} -> Mix.raise("failed to discover reference peer: #{inspect(reason)}") + end + + event = + ReferencePeer.signed_event( + peer, + "message.create", + "channel:https://#{peer.domain}/_arblarg/channels/interop-ref-task", + 1, + message_payload(peer.domain, "interop-ref-task", "hello from reference task") + ) + + case Federation.receive_event(event, peer.domain) do + {:ok, status} -> + status + + {:error, reason} -> + Mix.raise("federation rejected reference peer event: #{inspect(reason)}") + end + end + + defp signed_local_event(event_type, stream_id, sequence, payload) do + {key_id, private_key} = local_signing_material() + + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => ArblargSDK.protocol_id(), + "protocol_label" => ArblargSDK.protocol_label(), + "protocol_version" => ArblargSDK.protocol_version(), + "event_id" => "evt-#{Ecto.UUID.generate()}", + "event_type" => event_type, + "origin_domain" => Federation.local_domain(), + "stream_id" => stream_id, + "sequence" => sequence, + "sent_at" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "idempotency_key" => "idem-#{Ecto.UUID.generate()}", + "payload" => payload + } + |> ArblargSDK.sign_event_envelope(key_id, private_key) + end + + defp local_signing_material do + config = Application.get_env(:elektrine, :messaging_federation, []) + key_id = Config.local_identity_key_id(config) + + [key | _] = + Config.local_identity_keys( + config, + key_id, + Federation.local_domain(), + true, + false + ) + + {key.id, key.private_key} + end + + defp message_payload(domain, suffix, content) do + %{ + "server" => %{ + "id" => "https://#{domain}/_arblarg/servers/#{suffix}", + "name" => "interop-#{suffix}", + "is_public" => true + }, + "channel" => %{ + "id" => "https://#{domain}/_arblarg/channels/#{suffix}", + "name" => "general", + "position" => 0 + }, + "message" => %{ + "id" => "https://#{domain}/_arblarg/messages/#{suffix}", + "channel_id" => "https://#{domain}/_arblarg/channels/#{suffix}", + "content" => content, + "message_type" => "text", + "media_urls" => [], + "media_metadata" => %{}, + "sender" => %{ + "uri" => "https://#{domain}/users/alice", + "username" => "alice", + "domain" => domain, + "handle" => "alice@#{domain}" + } + } + } + end + + defp write_report(path, payload) do + path + |> Path.dirname() + |> File.mkdir_p!() + + File.write!(path, Jason.encode_to_iodata!(payload, pretty: true)) + end + + defp print_report(report) do + Mix.shell().info("Arblarg interop smoke test passed") + Mix.shell().info(" local -> reference: #{report.local_to_reference}") + Mix.shell().info(" reference -> local: #{report.reference_to_local}") + end +end diff --git a/apps/elektrine/lib/mix/tasks/messaging.federation.load_test.ex b/apps/elektrine/lib/mix/tasks/messaging.federation.load_test.ex new file mode 100644 index 0000000..c67a58c --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/messaging.federation.load_test.ex @@ -0,0 +1,400 @@ +defmodule Mix.Tasks.Messaging.Federation.LoadTest do + @shortdoc "Runs a local Arblarg federation benchmark and writes performance artifacts" + @moduledoc """ + Runs a synthetic load test against `Elektrine.Messaging.Federation.receive_event/2`. + + The benchmark now measures the signed Arblarg hot path, including: + - throughput + - p50/p95/p99 latency + - gap rate + - bytes per event for single-event JSON/CBOR + - bytes per event for batched JSON/CBOR + + Example: + + mix messaging.federation.load_test --events 5000 --event-type message.create --batch-size 32 + """ + + use Mix.Task + + import Ecto.Query, warn: false + + alias Elektrine.Messaging.ArblargSDK + alias Elektrine.Messaging.{ChatMessage, Server} + alias Elektrine.Messaging.Federation + alias Elektrine.Messaging.FederationEvent + alias Elektrine.Messaging.FederationStreamPosition + alias Elektrine.Repo + alias Elektrine.Social.Conversation + + @switches [ + events: :integer, + gap_every: :integer, + domain: :string, + event_type: :string, + batch_size: :integer, + message_size: :integer, + report: :string + ] + + @impl true + def run(args) do + Mix.Task.run("app.start") + + {opts, _argv, _invalid} = OptionParser.parse(args, strict: @switches) + previous = Application.get_env(:elektrine, :messaging_federation, []) + + events = max(Keyword.get(opts, :events, 3_000), 10) + gap_every = max(Keyword.get(opts, :gap_every, 0), 0) + batch_size = max(Keyword.get(opts, :batch_size, 32), 1) + message_size = max(Keyword.get(opts, :message_size, 160), 1) + domain = Keyword.get(opts, :domain, "load.remote") + event_type = normalize_event_type(Keyword.get(opts, :event_type, "message.create")) + report_path = Keyword.get(opts, :report, "external/arblarg/benchmarks/latest.json") + remote_secret = "load-secret:#{domain}" + run_id = Integer.to_string(System.system_time(:millisecond)) + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.merge(previous, + enabled: true, + peers: [ + %{ + "domain" => domain, + "base_url" => "https://#{domain}", + "shared_secret" => remote_secret, + "allow_incoming" => true, + "allow_outgoing" => false + } + ] + ) + ) + + try do + scenario = build_scenario(domain, event_type, run_id, message_size) + cleanup(domain, scenario) + remote_key_id = inbound_key_id_for(domain) + + {duration_us, state} = + :timer.tc(fn -> + Enum.reduce(1..events, init_state(), fn i, state -> + state = + if gap_every > 0 and rem(i, gap_every) == 0 do + run_one_event( + state, + scenario, + state.sequence + 1, + "gap-#{i}", + remote_key_id, + remote_secret + ) + else + state + end + + run_one_event( + state, + scenario, + state.sequence, + "ok-#{i}", + remote_key_id, + remote_secret + ) + end) + end) + + report = + benchmark_report( + state, + duration_us, + scenario, + events, + batch_size, + remote_key_id, + remote_secret + ) + + write_report(report_path, report) + print_report(report) + after + Application.put_env(:elektrine, :messaging_federation, previous) + end + end + + defp init_state do + %{ + sequence: 1, + calls: 0, + applied: 0, + gaps: 0, + failures: 0, + latencies_us: [], + failure_reasons: %{} + } + end + + defp run_one_event(state, scenario, sequence, label, remote_key_id, remote_secret) do + payload = event_payload(scenario, sequence, label, remote_key_id, remote_secret) + started_us = System.monotonic_time(:microsecond) + result = Federation.receive_event(payload, scenario.domain) + latency_us = System.monotonic_time(:microsecond) - started_us + + base = %{ + state + | calls: state.calls + 1, + latencies_us: [latency_us | state.latencies_us] + } + + case result do + {:ok, :applied} -> + %{base | applied: base.applied + 1, sequence: sequence + 1} + + {:error, :sequence_gap} -> + %{base | gaps: base.gaps + 1} + + {:ok, :duplicate} -> + base + + {:ok, :stale} -> + base + + {:error, reason} -> + %{ + base + | failures: base.failures + 1, + failure_reasons: Map.update(base.failure_reasons, inspect(reason), 1, &(&1 + 1)) + } + + other -> + %{ + base + | failures: base.failures + 1, + failure_reasons: Map.update(base.failure_reasons, inspect(other), 1, &(&1 + 1)) + } + end + end + + defp benchmark_report( + state, + duration_us, + scenario, + requested_events, + batch_size, + remote_key_id, + remote_secret + ) do + total_seconds = duration_us / 1_000_000 + total_calls = state.calls + applied_calls = state.applied + gaps = state.gaps + failures = state.failures + + p50_ms = percentile_ms(state.latencies_us, 50) + p95_ms = percentile_ms(state.latencies_us, 95) + p99_ms = percentile_ms(state.latencies_us, 99) + + calls_per_second = if total_seconds > 0, do: total_calls / total_seconds, else: 0.0 + applied_per_second = if total_seconds > 0, do: applied_calls / total_seconds, else: 0.0 + gap_rate = if total_calls > 0, do: gaps / total_calls * 100.0, else: 0.0 + wire_sizes = wire_sizes(scenario, batch_size, remote_key_id, remote_secret) + + %{ + generated_at: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + event_type: scenario.event_type, + origin_domain: scenario.domain, + requested_events: requested_events, + calls: total_calls, + applied: applied_calls, + gaps: gaps, + failures: failures, + duration_s: Float.round(total_seconds, 3), + calls_per_s: Float.round(calls_per_second, 2), + applied_per_s: Float.round(applied_per_second, 2), + p50_ms: Float.round(p50_ms, 3), + p95_ms: Float.round(p95_ms, 3), + p99_ms: Float.round(p99_ms, 3), + gap_rate_pct: Float.round(gap_rate, 2), + failure_reasons: state.failure_reasons, + wire_sizes: wire_sizes + } + end + + defp print_report(report) do + IO.puts("Messaging Federation Load Test") + IO.puts("================================") + IO.puts("event_type: #{report.event_type}") + IO.puts("calls: #{report.calls}") + IO.puts("applied: #{report.applied}") + IO.puts("gaps: #{report.gaps}") + IO.puts("failures: #{report.failures}") + IO.puts("duration_s: #{report.duration_s}") + IO.puts("calls_per_s: #{report.calls_per_s}") + IO.puts("applied_per_s: #{report.applied_per_s}") + IO.puts("p50_ms: #{report.p50_ms}") + IO.puts("p95_ms: #{report.p95_ms}") + IO.puts("p99_ms: #{report.p99_ms}") + IO.puts("gap_rate_pct: #{report.gap_rate_pct}") + IO.puts("failure_reasons: #{inspect(report.failure_reasons)}") + IO.puts("single_json_bytes: #{report.wire_sizes.single_json_bytes}") + IO.puts("single_cbor_bytes: #{report.wire_sizes.single_cbor_bytes}") + IO.puts("batch_json_bytes_per_event: #{report.wire_sizes.batch_json_bytes_per_event}") + IO.puts("batch_cbor_bytes_per_event: #{report.wire_sizes.batch_cbor_bytes_per_event}") + end + + defp write_report(path, payload) do + path + |> Path.dirname() + |> File.mkdir_p!() + + File.write!(path, Jason.encode_to_iodata!(payload, pretty: true)) + end + + defp wire_sizes(scenario, batch_size, remote_key_id, remote_secret) do + sample_event = event_payload(scenario, 1, "sample", remote_key_id, remote_secret) + + sample_batch = + %{ + "version" => 1, + "batch_id" => "batch-sample", + "sent_at" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "events" => + Enum.map(1..batch_size, fn index -> + event_payload(scenario, index, "batch-#{index}", remote_key_id, remote_secret) + end) + } + + single_json_bytes = byte_size(Jason.encode!(sample_event)) + single_cbor_bytes = byte_size(CBOR.encode(sample_event)) + batch_json_bytes = byte_size(Jason.encode!(sample_batch)) + batch_cbor_bytes = byte_size(CBOR.encode(sample_batch)) + + %{ + single_json_bytes: single_json_bytes, + single_cbor_bytes: single_cbor_bytes, + batch_json_bytes: batch_json_bytes, + batch_cbor_bytes: batch_cbor_bytes, + batch_json_bytes_per_event: Float.round(batch_json_bytes / batch_size, 2), + batch_cbor_bytes_per_event: Float.round(batch_cbor_bytes / batch_size, 2) + } + end + + defp event_payload(scenario, sequence, label, remote_key_id, remote_secret) do + payload = + case scenario.event_type do + "server.upsert" -> + %{ + "server" => %{ + "id" => scenario.server_id, + "name" => "load-#{label}", + "description" => "federation load test", + "is_public" => true, + "member_count" => sequence + }, + "channels" => [] + } + + "message.create" -> + %{ + "server" => %{ + "id" => scenario.server_id, + "name" => "load-channel", + "is_public" => true + }, + "channel" => %{ + "id" => scenario.channel_id, + "name" => "general", + "position" => 0 + }, + "message" => %{ + "id" => "#{scenario.message_prefix}-#{sequence}", + "channel_id" => scenario.channel_id, + "content" => scenario.message_content, + "message_type" => "text", + "media_urls" => [], + "media_metadata" => %{}, + "sender" => %{ + "uri" => "https://#{scenario.domain}/users/loadbot", + "username" => "loadbot", + "domain" => scenario.domain, + "handle" => "loadbot@#{scenario.domain}" + } + } + } + end + + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => ArblargSDK.protocol_id(), + "protocol_label" => ArblargSDK.protocol_label(), + "protocol_version" => ArblargSDK.protocol_version(), + "event_id" => "load-#{label}-#{Ecto.UUID.generate()}", + "event_type" => scenario.event_type, + "origin_domain" => scenario.domain, + "stream_id" => scenario.stream_id, + "sequence" => sequence, + "sent_at" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "idempotency_key" => "idem-#{label}-#{sequence}", + "payload" => payload + } + |> ArblargSDK.sign_event_envelope(remote_key_id, remote_secret) + end + + defp inbound_key_id_for(domain) when is_binary(domain) do + case Federation.incoming_peer(domain) do + %{keys: [%{id: id} | _]} when is_binary(id) -> id + _ -> "k1" + end + end + + defp build_scenario(domain, "server.upsert", run_id, _message_size) do + server_id = "https://#{domain}/_arblarg/servers/load-#{run_id}" + + %{ + domain: domain, + event_type: "server.upsert", + stream_id: "server:" <> server_id, + server_id: server_id + } + end + + defp build_scenario(domain, "message.create", run_id, message_size) do + server_id = "https://#{domain}/_arblarg/servers/load-#{run_id}" + channel_id = "https://#{domain}/_arblarg/channels/load-#{run_id}" + + %{ + domain: domain, + event_type: "message.create", + stream_id: "channel:" <> channel_id, + server_id: server_id, + channel_id: channel_id, + message_prefix: "https://#{domain}/_arblarg/messages/load-#{run_id}", + message_content: String.duplicate("x", message_size) + } + end + + defp cleanup(domain, scenario) do + Repo.delete_all(from(p in FederationStreamPosition, where: p.origin_domain == ^domain)) + Repo.delete_all(from(e in FederationEvent, where: e.origin_domain == ^domain)) + Repo.delete_all(from(m in ChatMessage, where: m.origin_domain == ^domain)) + + Repo.delete_all( + from(c in Conversation, + where: c.federated_source == ^Map.get(scenario, :channel_id, "__no_channel__") + ) + ) + + Repo.delete_all(from(s in Server, where: s.federation_id == ^scenario.server_id)) + end + + defp normalize_event_type("server.upsert"), do: "server.upsert" + defp normalize_event_type(_), do: "message.create" + + defp percentile_ms([], _), do: 0.0 + + defp percentile_ms(latencies_us, percentile) do + sorted = Enum.sort(latencies_us) + index = max(trunc(Float.ceil(length(sorted) * percentile / 100.0)) - 1, 0) + sorted |> Enum.at(index, 0) |> Kernel./(1_000) + end +end diff --git a/apps/elektrine/lib/mix/tasks/migrate_email_attachments.ex b/apps/elektrine/lib/mix/tasks/migrate_email_attachments.ex new file mode 100644 index 0000000..240bd02 --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/migrate_email_attachments.ex @@ -0,0 +1,143 @@ +defmodule Mix.Tasks.Elektrine.MigrateEmailAttachments do + @moduledoc """ + Migrates existing email attachments from database storage to S3 API storage. + + Usage: + mix elektrine.migrate_email_attachments [--batch-size 100] [--dry-run] + + Options: + --batch-size: Number of messages to process at once (default: 100) + --dry-run: Show what would be migrated without actually doing it + """ + + use Mix.Task + import Ecto.Query + require Logger + + alias Elektrine.Email.{AttachmentStorage, Message} + alias Elektrine.Repo + + @shortdoc "Migrates email attachments from database to S3 API storage" + + def run(args) do + Mix.Task.run("app.start") + + {opts, _, _} = + OptionParser.parse(args, + switches: [batch_size: :integer, dry_run: :boolean], + aliases: [b: :batch_size, d: :dry_run] + ) + + batch_size = opts[:batch_size] || 100 + dry_run = opts[:dry_run] || false + + Logger.info("Starting email attachment migration to S3 API storage") + Logger.info("Batch size: #{batch_size}") + Logger.info("Dry run: #{dry_run}") + + migrate_attachments(batch_size, dry_run) + end + + defp migrate_attachments(batch_size, dry_run) do + # Find messages with attachments that haven't been migrated + query = + from m in Message, + where: m.has_attachments == true, + order_by: [asc: m.id], + limit: ^batch_size + + messages = Repo.all(query) + total_count = length(messages) + + if total_count == 0 do + Logger.info("No messages with attachments found to migrate") + else + Logger.info("Processing #{total_count} messages with attachments") + + Enum.each(messages, fn message -> + migrate_message_attachments(message, dry_run) + end) + + Logger.info("Migration batch completed") + + # Continue with next batch if there are more + if total_count == batch_size do + Logger.info("Processing next batch...") + migrate_attachments(batch_size, dry_run) + else + Logger.info("All messages processed") + end + end + end + + defp migrate_message_attachments(message, dry_run) do + if message.attachments && map_size(message.attachments) > 0 do + Logger.info( + "Processing message #{message.id} with #{map_size(message.attachments)} attachments" + ) + + updated_attachments = + Enum.reduce(message.attachments, %{}, fn {attachment_id, attachment_data}, acc -> + # Check if already migrated to S3 + if Map.get(attachment_data, "storage_type") == "s3" do + Map.put(acc, attachment_id, attachment_data) + else + # Check if has data to migrate + if Map.get(attachment_data, "data") do + if dry_run do + Logger.info( + "Would migrate attachment #{attachment_id} (#{Map.get(attachment_data, "size", 0)} bytes)" + ) + + Map.put(acc, attachment_id, attachment_data) + else + migrate_single_attachment(message, attachment_id, attachment_data, acc) + end + else + Logger.warning("Attachment #{attachment_id} has no data, skipping") + Map.put(acc, attachment_id, attachment_data) + end + end + end) + + unless dry_run do + # Update message with migrated attachments + case Repo.update(Message.changeset(message, %{attachments: updated_attachments})) do + {:ok, _updated} -> + Logger.info("Successfully updated message #{message.id}") + + {:error, reason} -> + Logger.error("Failed to update message #{message.id}: #{inspect(reason)}") + end + end + end + end + + defp migrate_single_attachment(message, attachment_id, attachment_data, acc) do + Logger.info("Migrating attachment #{attachment_id}...") + + case AttachmentStorage.migrate_attachment_to_s3( + message.mailbox_id, + message.id, + attachment_id, + attachment_data + ) do + {:ok, storage_metadata} -> + Logger.info("Successfully migrated attachment #{attachment_id} to S3") + + # Update attachment metadata with S3 info + updated_data = + attachment_data + |> Map.merge(storage_metadata) + # Remove base64 data + |> Map.delete("data") + + Map.put(acc, attachment_id, updated_data) + + {:error, error} -> + Logger.error("Failed to migrate attachment #{attachment_id}: #{inspect(error)}") + # Keep original on failure + Map.put(acc, attachment_id, attachment_data) + end + end +end diff --git a/apps/elektrine/lib/mix/tasks/secrets.backfill.ex b/apps/elektrine/lib/mix/tasks/secrets.backfill.ex new file mode 100644 index 0000000..35d98b9 --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/secrets.backfill.ex @@ -0,0 +1,28 @@ +defmodule Mix.Tasks.Secrets.Backfill do + @moduledoc "Encrypts remaining plaintext secret fields in place." + @shortdoc "Encrypts remaining plaintext secret fields in place" + + use Mix.Task + + alias Elektrine.Secrets.Backfill + + @impl Mix.Task + def run(args) do + Mix.Task.run("app.start") + + dry_run? = not Enum.member?(args, "--apply") + result = Backfill.run(dry_run: dry_run?) + + Mix.shell().info("Secrets backfill #{if(dry_run?, do: "dry-run", else: "applied")}") + Mix.shell().info("Scanned: #{result.scanned}") + Mix.shell().info("Updated: #{result.updated}") + + Enum.each(result.fields, fn {name, stats} -> + Mix.shell().info("#{name}: scanned=#{stats.scanned} updated=#{stats.updated}") + end) + + if dry_run? do + Mix.shell().info("Run with --apply to persist encrypted values.") + end + end +end diff --git a/apps/elektrine/lib/mix/tasks/social.backfill_lemmy_comment_votes.ex b/apps/elektrine/lib/mix/tasks/social.backfill_lemmy_comment_votes.ex new file mode 100644 index 0000000..2d9397e --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/social.backfill_lemmy_comment_votes.ex @@ -0,0 +1,53 @@ +defmodule Mix.Tasks.Social.BackfillLemmyCommentVotes do + @moduledoc false + use Mix.Task + + alias Elektrine.ActivityPub.LemmyCommentBackfill + + @shortdoc "Backfills stored Lemmy comment vote counts" + + @impl Mix.Task + def run(args) do + Mix.Task.run("app.config") + start_task_services() + + {opts, _argv, invalid} = + OptionParser.parse(args, + strict: [dry_run: :boolean], + aliases: [d: :dry_run] + ) + + if invalid != [] do + Mix.raise("Invalid arguments: #{inspect(invalid)}") + end + + dry_run? = Keyword.get(opts, :dry_run, false) + result = LemmyCommentBackfill.run(dry_run: dry_run?) + + Mix.shell().info("Lemmy comment vote backfill") + Mix.shell().info(" Dry run: #{dry_run?}") + Mix.shell().info(" Lemmy posts scanned: #{result.posts}") + Mix.shell().info(" Comments updated: #{result.comments}") + Mix.shell().info(" Remote count refreshes: #{result.remote_comments}") + Mix.shell().info(" Fallback upvote seeds: #{result.fallback_comments}") + end + + defp start_task_services do + Enum.each( + [:crypto, :ssl, :inets, :telemetry, :ecto, :ecto_sql, :postgrex, :phoenix_pubsub], + fn app -> + {:ok, _} = Application.ensure_all_started(app) + end + ) + + {:ok, _pid} = + Supervisor.start_link( + [ + Elektrine.Repo, + {Phoenix.PubSub, name: Elektrine.PubSub}, + {Finch, name: Elektrine.Finch} + ], + strategy: :one_for_one + ) + end +end diff --git a/apps/elektrine/lib/mix/tasks/social.recalculate_reply_counts.ex b/apps/elektrine/lib/mix/tasks/social.recalculate_reply_counts.ex new file mode 100644 index 0000000..3223eed --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/social.recalculate_reply_counts.ex @@ -0,0 +1,69 @@ +defmodule Mix.Tasks.Social.RecalculateReplyCounts do + @moduledoc """ + Recalculates reply_count for all timeline posts based on actual replies. + + Usage: mix social.recalculate_reply_counts + """ + use Mix.Task + import Ecto.Query + alias Elektrine.Repo + alias Elektrine.Social.Message + + @shortdoc "Recalculates reply counts for all timeline posts" + + @impl Mix.Task + def run(_args) do + Mix.Task.run("app.start") + + IO.puts("Recalculating reply counts for all posts (including nested)...") + + # Get all posts (not just timeline) + posts = + from(m in Message, + where: m.post_type == "post", + select: m.id + ) + |> Repo.all() + + total = length(posts) + IO.puts("Found #{total} posts to process...") + + # For each post, count all nested replies and update + Enum.with_index(posts, 1) + |> Enum.each(fn {post_id, index} -> + # Count all replies including nested + reply_count = count_nested_replies_recursive(post_id) + + # Update the count + from(m in Message, where: m.id == ^post_id) + |> Repo.update_all(set: [reply_count: reply_count]) + + if rem(index, 100) == 0 do + IO.puts("Processed #{index}/#{total} posts...") + end + end) + + IO.puts("Done! Recalculated reply counts for #{total} posts.") + end + + # Count all replies including nested + defp count_nested_replies_recursive(post_id) do + # Get direct replies + direct_reply_ids = + from(m in Message, + where: m.reply_to_id == ^post_id and is_nil(m.deleted_at), + select: m.id + ) + |> Repo.all() + + # Count direct + nested + direct_count = length(direct_reply_ids) + + nested_count = + Enum.reduce(direct_reply_ids, 0, fn reply_id, acc -> + acc + count_nested_replies_recursive(reply_id) + end) + + direct_count + nested_count + end +end diff --git a/apps/elektrine/lib/mix/tasks/social.repair_engagement_counts.ex b/apps/elektrine/lib/mix/tasks/social.repair_engagement_counts.ex new file mode 100644 index 0000000..6a0fa61 --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/social.repair_engagement_counts.ex @@ -0,0 +1,48 @@ +defmodule Mix.Tasks.Social.RepairEngagementCounts do + @moduledoc """ + Repairs cached social engagement counters. + + Usage: + + mix social.repair_engagement_counts + mix social.repair_engagement_counts --dry-run + mix social.repair_engagement_counts --limit 10000 --batch-size 500 + """ + + use Mix.Task + + alias Elektrine.Social.EngagementCountRepair + + @shortdoc "Repairs cached social engagement counters" + + @impl Mix.Task + def run(args) do + Mix.Task.run("app.start") + + {opts, _argv, _invalid} = + OptionParser.parse(args, + strict: [dry_run: :boolean, limit: :integer, batch_size: :integer], + aliases: [n: :limit] + ) + + dry_run? = Keyword.get(opts, :dry_run, false) + limit = Keyword.get(opts, :limit) + batch_size = Keyword.get(opts, :batch_size, 500) + + Mix.shell().info("Repairing engagement counts#{if dry_run?, do: " (dry run)", else: ""}...") + + progress_fun = fn %{seen: seen, changed: changed} -> + Mix.shell().info("Scanned #{seen}; changed #{changed}...") + end + + %{seen: seen, changed: changed} = + EngagementCountRepair.run( + dry_run: dry_run?, + limit: limit, + batch_size: batch_size, + progress_fun: progress_fun + ) + + Mix.shell().info("Done. Scanned #{seen} messages; #{changed} would change/changed.") + end +end diff --git a/apps/elektrine/lib/mix/tasks/test_2fa.ex b/apps/elektrine/lib/mix/tasks/test_2fa.ex new file mode 100644 index 0000000..9b7434c --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/test_2fa.ex @@ -0,0 +1,43 @@ +defmodule Mix.Tasks.Test2fa do + @moduledoc false + @shortdoc "Tests 2FA secret generation and current codes" + + use Mix.Task + + def run([secret]) do + Mix.Task.run("app.start") + + IO.puts("Secret length: #{String.length(secret)}") + + # Generate current code + current_code = NimbleTOTP.verification_code(secret) + reveal? = System.get_env("TEST_2FA_REVEAL_CODES") in ["1", "true", "TRUE", "yes", "YES"] + + if reveal? do + IO.puts("Current server code: #{current_code}") + else + IO.puts("Current server code: [redacted; set TEST_2FA_REVEAL_CODES=true to print]") + end + + # Test codes for next few time periods + current_time = System.os_time(:second) + IO.puts("\nCodes for next few periods:") + + for offset <- -2..2 do + time = current_time + offset * 30 + code = NimbleTOTP.verification_code(secret, time: time) + timestamp = DateTime.from_unix!(time) |> DateTime.to_string() + if reveal?, do: IO.puts("#{timestamp}: #{code}"), else: IO.puts("#{timestamp}: [redacted]") + end + + # Test validation with window + IO.puts("\nTesting validation with window=2:") + test_result = NimbleTOTP.valid?(secret, current_code, window: 2) + IO.puts("Self-validation result: #{test_result}") + end + + def run([]) do + IO.puts("Usage: mix test_2fa ") + IO.puts("Example: mix test_2fa ABCD1234EFGH5678IJKL") + end +end diff --git a/apps/elektrine/lib/mix/tasks/users.import_existing.ex b/apps/elektrine/lib/mix/tasks/users.import_existing.ex new file mode 100644 index 0000000..58dc837 --- /dev/null +++ b/apps/elektrine/lib/mix/tasks/users.import_existing.ex @@ -0,0 +1,87 @@ +defmodule Mix.Tasks.Users.ImportExisting do + @moduledoc false + use Mix.Task + + import Ecto.Query + + alias Elektrine.Accounts.User + alias Elektrine.Repo + + @shortdoc "Import pre-hashed users from an explicit string or file" + + @impl Mix.Task + def run(args) do + Mix.Task.run("app.start") + + {opts, _argv, invalid} = + OptionParser.parse(args, + strict: [users: :string, file: :string, dry_run: :boolean], + aliases: [u: :users, f: :file, d: :dry_run] + ) + + if invalid != [] do + Mix.raise("Invalid arguments: #{inspect(invalid)}") + end + + users_spec = + opts[:users] || + read_users_file(opts[:file]) || + Mix.raise("Pass --users or --file with data in \"username,hash|username,hash\" format.") + + users_data = parse_users(users_spec) + dry_run? = Keyword.get(opts, :dry_run, false) + + if Enum.empty?(users_data) do + Mix.shell().info("No users were parsed from the provided input.") + else + Enum.each(users_data, fn {username, password_hash} -> + import_user(username, password_hash, dry_run?) + end) + end + end + + defp import_user(username, password_hash, dry_run?) do + existing_user = Repo.one(from(u in User, where: u.username == ^username)) + + cond do + existing_user -> + Mix.shell().info("Skipping #{username}: user already exists.") + + dry_run? -> + Mix.shell().info("Would import #{username}.") + + true -> + %User{} + |> User.import_changeset(%{username: username, password_hash: password_hash}) + |> Repo.insert!() + + Mix.shell().info("Imported #{username}.") + end + end + + defp read_users_file(nil), do: nil + + defp read_users_file(path) do + path + |> File.read!() + |> String.trim() + end + + defp parse_users(users_spec) do + users_spec + |> String.split("|", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.map(&parse_user_entry/1) + end + + defp parse_user_entry(entry) do + case String.split(entry, ",", parts: 2) do + [username, password_hash] -> + {String.trim(username), String.trim(password_hash)} + + _ -> + Mix.raise("Invalid user entry: #{inspect(entry)}") + end + end +end diff --git a/apps/elektrine/mix.exs b/apps/elektrine/mix.exs new file mode 100644 index 0000000..89acb69 --- /dev/null +++ b/apps/elektrine/mix.exs @@ -0,0 +1,153 @@ +defmodule Elektrine.MixProject do + use Mix.Project + + def project do + [ + app: :elektrine, + version: "0.1.0", + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + elixir: "~> 1.19", + elixirc_paths: elixirc_paths(Mix.env()), + elixirc_options: elixirc_options(), + start_permanent: Mix.env() == :prod, + consolidate_protocols: Mix.env() == :prod, + aliases: aliases(), + deps: deps(), + listeners: [Phoenix.CodeReloader] + ] + end + + def application do + [ + mod: {Elektrine.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + defp elixirc_options do + opts = [no_warn_undefined: no_warn_undefined()] + + if Mix.env() == :test do + Keyword.put(opts, :ignore_module_conflict, true) + else + opts + end + end + + defp no_warn_undefined do + [ + Elektrine.Email, + Elektrine.Email.Alias, + Elektrine.Email.AttachmentStorage, + Elektrine.Email.Cached, + Elektrine.Email.CustomDomains, + Elektrine.Email.HarakaClient, + Elektrine.Email.Mailbox, + Elektrine.Email.MailboxAdapter, + Elektrine.Email.Message, + Atomine.CreditEarningPolicy, + Atomine.Credits, + Atomine.Personhood, + Atomine.CreditPolicy, + Kairo, + Elektrine.Social, + Elektrine.Social.FetchLinkPreviewWorker, + Elektrine.Social.Hashtag, + Elektrine.Social.LinkPreview, + Elektrine.Social.LinkPreviewFetcher, + Elektrine.Social.MessageVote, + Elektrine.Social.Poll, + Elektrine.Social.PollOption, + Elektrine.Social.PostBoost, + Elektrine.Social.PostLike, + ElektrineWeb.Endpoint, + ElektrineEmailWeb.HarakaWebhookController, + ElektrineWeb.Presence + ] + end + + defp deps do + [ + {:phoenix, "== 1.8.9"}, + {:phoenix_ecto, "== 4.7.0"}, + {:ecto_sql, "== 3.14.0"}, + {:postgrex, "== 0.22.3"}, + {:phoenix_html, "== 4.3.0"}, + {:phoenix_live_reload, "== 1.6.2", only: :dev}, + {:phoenix_live_view, "== 1.1.30"}, + {:floki, "== 0.38.4"}, + {:lazy_html, "== 0.1.11", only: :test}, + {:phoenix_live_dashboard, "== 0.8.7"}, + {:esbuild, "== 0.10.0", runtime: Mix.env() == :dev}, + {:tailwind, "== 0.5.1", runtime: Mix.env() == :dev}, + {:swoosh, "~> 1.26"}, + {:finch, "== 0.23.0"}, + {:req, "== 0.6.3"}, + # Calendar availability resolves local time explicitly through + # Tz.TimeZoneDatabase; the default database stays UTC-only on purpose + # (see config/config.exs). + {:tz, "== 0.28.2"}, + {:mail, "~> 0.5.1"}, + {:telemetry_metrics, "== 1.1.0"}, + {:telemetry_poller, "== 1.3.0"}, + {:gettext, "== 1.0.2"}, + {:jason, "== 1.4.5"}, + {:dns_cluster, "== 0.2.0"}, + {:bandit, "== 1.12.3"}, + {:bcrypt_elixir, "== 3.3.2"}, + {:argon2_elixir, "== 4.1.3"}, + {:gen_smtp, "== 1.3.0"}, + {:oban, "== 2.22.1"}, + {:oban_live_dashboard, "~> 0.1"}, + {:ex_aws, "~> 2.7"}, + {:ex_aws_s3, "== 2.5.9"}, + {:sweet_xml, "== 0.7.5"}, + {:html_sanitize_ex, "== 1.5.2"}, + {:nimble_totp, "== 1.0.0"}, + {:wax_, "~> 0.6"}, + {:eqrcode, "== 0.2.1"}, + {:cachex, "== 4.1.1"}, + {:mdex, "== 0.13.4"}, + {:image, "== 0.71.0"}, + {:html_entities, "~> 0.5"}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, + {:wallaby, "~> 0.30", only: :test, runtime: false}, + {:httpoison, "~> 3.0", only: :test, override: true}, + {:web_driver_client, "~> 0.3", only: :test, override: true}, + {:hackney, "== 4.5.2", override: true}, + {:stripity_stripe, "~> 3.3"} + ] + end + + defp aliases do + [ + setup: [ + "deps.get", + "ecto.setup", + "assets.setup", + "assets.build" + ], + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + "assets.setup": [ + "cmd --cd assets npm ci", + "tailwind.install --if-missing", + "esbuild.install --if-missing" + ], + "assets.build": ["tailwind elektrine", "esbuild elektrine"], + "assets.deploy": [ + "tailwind elektrine --minify", + "esbuild elektrine --minify", + "phx.digest" + ] + ] + end +end diff --git a/apps/elektrine/priv/gettext/default.pot b/apps/elektrine/priv/gettext/default.pot new file mode 100644 index 0000000..c9d56ee --- /dev/null +++ b/apps/elektrine/priv/gettext/default.pot @@ -0,0 +1,3159 @@ +## This file is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here as no +## effect: edit the PO (`.po`) files in `priv/gettext/*/LC_MESSAGES` instead. +msgid "" +msgstr "" + +#: lib/elektrine_web/live/email_live/inbox.ex:139 +msgid "Messages refreshed" +msgstr "" + +#: lib/elektrine_web/live/email_live/inbox.ex:162 +msgid "Message deleted successfully" +msgstr "" + +#: lib/elektrine_web/live/email_live/inbox.ex:165, lib/elektrine_web/live/email_live/inbox.ex:175 +msgid "Message not found" +msgstr "" + +#: lib/elektrine_web/live/email_live/inbox.ex:187 +msgid "Message marked as spam" +msgstr "" + +#: lib/elektrine_web/live/email_live/inbox.ex:212 +msgid "Message archived" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:254 +msgid "Email sent successfully!" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:261 +msgid "You have reached your daily limit of 100 emails. Please try again tomorrow." +msgstr "" + +#: lib/elektrine_web/components/layouts/app.html.heex:400 +#: lib/elektrine_web/live/email_live/email_helpers.ex:403 +#: lib/elektrine_web/live/email_live/home.ex:1515 +#: lib/elektrine_web/live/email_live/home.html.heex:256 +#, elixir-autogen +msgid "Inbox" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:420 +#: lib/elektrine_web/live/email_live/home.ex:1520 +#, elixir-autogen +msgid "Sent" +msgstr "" + +#: lib/elektrine_web/components/layouts/app.html.heex:405 +#: lib/elektrine_web/live/email_live/email_helpers.ex:499 +#, elixir-autogen +msgid "Compose" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:468 +#: lib/elektrine_web/live/email_live/home.ex:1523 +#: lib/elektrine_web/live/email_live/home.html.heex:71 +#: lib/elektrine_web/live/email_live/home.html.heex:181 +#: lib/elektrine_web/live/email_live/search.html.heex:157 +#, elixir-autogen +msgid "Archive" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:444 +#: lib/elektrine_web/live/email_live/home.ex:1521 +#, elixir-autogen +msgid "Spam" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:317 +#, elixir-autogen +msgid "Subject" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:89 +#: lib/elektrine_web/live/email_live/home.html.heex:823 +#, elixir-autogen +msgid "To" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:63 +#: lib/elektrine_web/live/email_live/home.html.heex:1117 +#, elixir-autogen +msgid "From" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:333 +#, elixir-autogen +msgid "Message" +msgstr "" + +msgid "Send" +msgstr "" + +#: lib/elektrine_web/live/discussions_live/home.html.heex:360 +#: lib/elektrine_web/live/discussions_live/home.html.heex:696 +#: lib/elektrine_web/live/email_live/home.html.heex:577 +#: lib/elektrine_web/live/email_live/home.html.heex:1165 +#: lib/elektrine_web/live/email_live/show.html.heex:182 +#: lib/elektrine_web/live/email_live/show.html.heex:315 +#: lib/elektrine_web/live/timeline_live/home.html.heex:336 +#: lib/elektrine_web/live/timeline_live/home.html.heex:1191 +#, elixir-autogen +msgid "Cancel" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:900 +#: lib/elektrine_web/live/email_live/search.html.heex:167 +#: lib/elektrine_web/live/email_live/show.html.heex:49 +#: lib/elektrine_web/live/email_live/show.html.heex:247 +#: lib/elektrine_web/live/timeline_live/home.html.heex:1195 +#, elixir-autogen +msgid "Reply" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:916 +#: lib/elektrine_web/live/email_live/search.html.heex:177 +#: lib/elektrine_web/live/email_live/show.html.heex:256 +#, elixir-autogen +msgid "Forward" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:83 +#: lib/elektrine_web/live/email_live/home.html.heex:234 +#: lib/elektrine_web/live/email_live/home.html.heex:681 +#: lib/elektrine_web/live/email_live/show.html.heex:95 +#, elixir-autogen +msgid "Delete" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:77 +#: lib/elektrine_web/live/email_live/home.html.heex:213 +#, elixir-autogen +msgid "Mark as Spam" +msgstr "" + +msgid "Admin" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:353 +#: lib/elektrine_web/controllers/page_html/home.html.heex:476 +#, elixir-autogen +msgid "Users" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:81 +#, elixir-autogen +msgid "Messages" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:73 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:3 +#, elixir-autogen +msgid "Mailboxes" +msgstr "" + +msgid "Login" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:3 +#, elixir-autogen +msgid "Register" +msgstr "" + +msgid "Logout" +msgstr "" + +msgid "Account" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:12 +#, elixir-autogen +msgid "Settings" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:20 +#, elixir-autogen +msgid "Profile" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:525 +#, elixir-autogen +msgid "Language" +msgstr "" + +msgid "English" +msgstr "" + +msgid "Spanish" +msgstr "" + +msgid "French" +msgstr "" + +msgid "German" +msgstr "" + +msgid "Chinese" +msgstr "" + +msgid "Japanese" +msgstr "" + +#: lib/elektrine_web/components/ui/table.ex:46 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:60 +#: lib/elektrine_web/live/email_live/home.html.heex:50 +#: lib/elektrine_web/live/email_live/home.html.heex:158 +#, elixir-autogen, elixir-format +msgid "Actions" +msgstr "" + +#: lib/elektrine_web/components/ui/modal.ex:64 +#, elixir-autogen, elixir-format +msgid "close" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:36 +#, elixir-autogen, elixir-format +msgid "(%{count} emails left today)" +msgstr "" + +#: lib/elektrine_web/live/email_live/raw.html.heex:72 +#: lib/elektrine_web/live/email_live/show.html.heex:33 +#, elixir-autogen, elixir-format +msgid "(No Subject)" +msgstr "" + +#: lib/elektrine_web/components/layouts/app.html.heex:454 +#: lib/elektrine_web/controllers/user_settings_html/edit.html.heex:3 +#: lib/elektrine_web/live/user_settings_live.html.heex:3 +#, elixir-autogen, elixir-format +msgid "Account Settings" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:3 +#, elixir-autogen, elixir-format +msgid "Admin Dashboard" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:92 +#: lib/elektrine_web/live/email_live/home.html.heex:341 +#, elixir-autogen, elixir-format +msgid "Aliases" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:116 +#, elixir-autogen, elixir-format +msgid "Already have an account? Log in" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:52 +#, elixir-autogen, elixir-format +msgid "Approaching daily limit!" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:1272 +#: lib/elektrine_web/live/email_live/compose.ex:1274 +#: lib/elektrine_web/live/email_live/show.ex:449 +#: lib/elektrine_web/live/email_live/show.ex:451 +#, elixir-autogen, elixir-format +msgid "Back to Inbox" +msgstr "" + +#: lib/elektrine_web/controllers/password_reset_html/new.html.heex:42 +#, elixir-autogen, elixir-format +msgid "Back to Login" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:235 +#, elixir-autogen, elixir-format +msgid "Blind carbon copy" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:169 +#, elixir-autogen, elixir-format +msgid "Carbon copy" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:34 +#, elixir-autogen, elixir-format +msgid "Confirm password" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:35 +#, elixir-autogen, elixir-format +msgid "Confirm your password" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:508 +#, elixir-autogen, elixir-format +msgid "Create Alias" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:440 +#, elixir-autogen, elixir-format +msgid "Create New Alias" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:108 +#, elixir-autogen, elixir-format +msgid "Create account" +msgstr "" + +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:59 +#, elixir-autogen, elixir-format +msgid "Create an account" +msgstr "" + +#: lib/elektrine_web/controllers/user_settings_html/edit.html.heex:31 +#, elixir-autogen, elixir-format +msgid "Current avatar" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:49 +#, elixir-autogen, elixir-format +msgid "Daily limit reached!" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:489 +#, elixir-autogen, elixir-format +msgid "Description (optional)" +msgstr "" + +#: lib/elektrine_web/components/platform/elektrine_nav.ex:36 +#: lib/elektrine_web/live/email_live/home.ex:1527 +#: lib/elektrine_web/live/user_settings_live.html.heex:65 +#, elixir-autogen, elixir-format +msgid "Email" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1525 +#: lib/elektrine_web/live/email_live/home.html.heex:427 +#: lib/elektrine_web/live/user_settings_live.html.heex:774 +#: lib/elektrine_web/live/user_settings_live.html.heex:781 +#, elixir-autogen, elixir-format +msgid "Email Aliases" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:497 +#, elixir-autogen, elixir-format +msgid "Enable immediately" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:44 +#, elixir-autogen, elixir-format +msgid "Enter your invite code" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:22 +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:18 +#, elixir-autogen, elixir-format +msgid "Enter your password" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:14 +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:11 +#, elixir-autogen, elixir-format +msgid "Enter your username" +msgstr "" + +#: lib/elektrine_web/controllers/password_reset_html/new.html.heex:15 +#, elixir-autogen, elixir-format +msgid "Enter your username or recovery email" +msgstr "" + +#: lib/elektrine_web/controllers/password_reset_html/new.html.heex:6 +#, elixir-autogen, elixir-format +msgid "Enter your username or recovery email address and we'll send you a link to reset your password." +msgstr "" + +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:33 +#, elixir-autogen, elixir-format +msgid "Forgot your password?" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:475 +#, elixir-autogen, elixir-format +msgid "Forward To (optional)" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:30 +#, elixir-autogen, elixir-format +msgid "Forward this message to someone" +msgstr "" + +#: lib/elektrine_web/components/layouts/app.html.heex:43 +#: lib/elektrine_web/components/layouts/app.html.heex:235 +#, elixir-autogen, elixir-format +msgid "Home" +msgstr "" + +#: lib/elektrine_web/live/email_live/show.html.heex:304 +#, elixir-autogen, elixir-format +msgid "In 3 Days" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:43 +#, elixir-autogen, elixir-format +msgid "Invite Code" +msgstr "" + +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:28 +#, elixir-autogen, elixir-format +msgid "Keep me logged in" +msgstr "" + +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:3 +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:51 +#, elixir-autogen, elixir-format +msgid "Log in" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:361 +#, elixir-autogen, elixir-format +msgid "Main Email Forwarding" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:614 +#, elixir-autogen, elixir-format +msgid "Main Mailbox" +msgstr "" + +#: lib/elektrine_web/controllers/user_settings_html/edit.html.heex:4 +#: lib/elektrine_web/live/user_settings_live.html.heex:4 +#, elixir-autogen, elixir-format +msgid "Manage your account preferences and security settings" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:65 +#: lib/elektrine_web/live/email_live/home.html.heex:173 +#: lib/elektrine_web/live/email_live/home.html.heex:976 +#: lib/elektrine_web/live/email_live/show.html.heex:268 +#, elixir-autogen, elixir-format +msgid "Mark as Unread" +msgstr "" + +#: lib/elektrine_web/live/notifications_dropdown_live.ex:112 +#: lib/elektrine_web/live/notifications_live.html.heex:292 +#, elixir-autogen, elixir-format +msgid "Mark as read" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:1017 +#: lib/elektrine_web/live/email_live/home.html.heex:1058 +#, elixir-autogen, elixir-format +msgid "Next" +msgstr "" + +#: lib/elektrine_web/live/email_live/show.html.heex:310 +#, elixir-autogen, elixir-format +msgid "Next Month" +msgstr "" + +#: lib/elektrine_web/live/email_live/show.html.heex:307 +#, elixir-autogen, elixir-format +msgid "Next Week" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:521 +#, elixir-autogen, elixir-format +msgid "No email aliases yet" +msgstr "" + +#: lib/elektrine_web/controllers/password_reset_html/new.html.heex:39 +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:112 +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:56 +#, elixir-autogen, elixir-format +msgid "OR" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:7 +#: lib/elektrine_web/controllers/user_settings_html/edit.html.heex:18 +#, elixir-autogen, elixir-format +msgid "Oops, something went wrong! Please check the errors below." +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:210 +#: lib/elektrine_web/live/email_live/search.html.heex:87 +#: lib/elektrine_web/live/email_live/search.html.heex:210 +#, elixir-autogen, elixir-format +msgid "Page %{current} of %{total}" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:21 +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:17 +#: lib/elektrine_web/live/user_settings_live.html.heex:234 +#, elixir-autogen, elixir-format +msgid "Password" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:27 +#, elixir-autogen, elixir-format +msgid "Password must be at least 12 characters long" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:139 +#, elixir-autogen, elixir-format +msgid "Pending Account Deletion Requests" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:100 +#, elixir-autogen, elixir-format +msgid "Please complete the captcha verification" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:1031 +#, elixir-autogen, elixir-format +msgid "Previous" +msgstr "" + +#: lib/elektrine_web/controllers/user_settings_html/edit.html.heex:13 +#: lib/elektrine_web/live/user_settings_live.html.heex:100 +#, elixir-autogen, elixir-format +msgid "Profile Information" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:12 +#, elixir-autogen, elixir-format +msgid "Quick Search" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:455 +#: lib/elektrine_web/live/email_live/compose.html.heex:669 +#, elixir-autogen, elixir-format +msgid "Quote" +msgstr "" + +#: lib/elektrine_web/live/email_live/show.html.heex:264 +#, elixir-autogen, elixir-format +msgid "Reply Later" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:28 +#, elixir-autogen, elixir-format +msgid "Reply to this message" +msgstr "" + +#: lib/elektrine_web/controllers/password_reset_html/new.html.heex:3 +#, elixir-autogen, elixir-format +msgid "Reset Password" +msgstr "" + +#: lib/elektrine_web/live/email_live/show.html.heex:288 +#, elixir-autogen, elixir-format +msgid "Schedule Reply Later" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:33 +#, elixir-autogen, elixir-format +msgid "Search mailboxes..." +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:47 +#: lib/elektrine_web/live/email_live/home.html.heex:99 +#, elixir-autogen, elixir-format +msgid "Search messages..." +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:19 +#, elixir-autogen, elixir-format +msgid "Search users..." +msgstr "" + +#: lib/elektrine_web/components/layouts/app.html.heex:213 +#, elixir-autogen, elixir-format +msgid "Search..." +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:795 +#, elixir-autogen, elixir-format +msgid "Send Message" +msgstr "" + +#: lib/elektrine_web/controllers/password_reset_html/new.html.heex:34 +#, elixir-autogen, elixir-format +msgid "Send Reset Link" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:32 +#, elixir-autogen, elixir-format +msgid "Send an email to someone" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:197 +#: lib/elektrine_web/live/email_live/home.html.heex:939 +#: lib/elektrine_web/live/email_live/show.html.heex:260 +#, elixir-autogen, elixir-format +msgid "Set Aside" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:59 +#: lib/elektrine_web/live/email_live/show.html.heex:301 +#, elixir-autogen, elixir-format +msgid "Tomorrow" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:65 +#, elixir-autogen, elixir-format +msgid "Total Users" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:13 +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:10 +#: lib/elektrine_web/live/user_settings_live.html.heex:749 +#, elixir-autogen, elixir-format +msgid "Username" +msgstr "" + +#: lib/elektrine_web/controllers/password_reset_html/new.html.heex:14 +#, elixir-autogen, elixir-format +msgid "Username or Recovery Email" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:5 +#, elixir-autogen, elixir-format +msgid "Welcome back, %{username}" +msgstr "" + +#: lib/elektrine_web/live/email_live/show.html.heex:296 +#, elixir-autogen, elixir-format +msgid "When would you like to be reminded to reply to this message?" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:53 +#, elixir-autogen, elixir-format +msgid "You have %{count} emails remaining today." +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:50 +#, elixir-autogen, elixir-format +msgid "You have sent %{count} emails today." +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:82 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:139 +#: lib/elektrine_web/live/email_live/home.html.heex:624 +#, elixir-autogen, elixir-format +msgid "Active" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:410 +#, elixir-autogen, elixir-format +msgid "All emails to %{email} and %{alt_email} are being forwarded to %{forward_to}" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:678 +#, elixir-autogen, elixir-format +msgid "Are you sure you want to delete this alias? This action cannot be undone." +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:430 +#, elixir-autogen, elixir-format +msgid "Create additional email addresses on @example.com, @example.net, or @example.org that can forward to specific destinations or deliver to your main mailbox" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:524 +#, elixir-autogen, elixir-format +msgid "Create your first email alias above to start organizing your email with dedicated addresses for different purposes." +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:629 +#: lib/elektrine_web/live/user_settings_live.html.heex:276 +#, elixir-autogen, elixir-format +msgid "Disabled" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:388 +#, elixir-autogen, elixir-format +msgid "Enable Forwarding" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:364 +#, elixir-autogen, elixir-format +msgid "Forward all emails from %{email} and %{alt_email} to another address" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:380 +#, elixir-autogen, elixir-format +msgid "Forward to Email" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:480 +#, elixir-autogen, elixir-format +msgid "Leave empty to deliver emails to your main mailbox instead of forwarding" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:401 +#, elixir-autogen, elixir-format +msgid "Update Settings" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:490 +#, elixir-autogen, elixir-format +msgid "e.g., Shopping accounts, newsletters, work projects..." +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:476 +#, elixir-autogen, elixir-format +msgid "your.email@example.com (leave empty to use main mailbox)" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:381 +#, elixir-autogen, elixir-format +msgid "your.personal@example.com" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:41 +#, elixir-autogen, elixir-format +msgid "%{count} mailbox found" +msgid_plural "%{count} mailboxes found" +msgstr[0] "" +msgstr[1] "" + +#: lib/elektrine_web/live/email_live/search.html.heex:85 +#, elixir-autogen, elixir-format +msgid "%{count} result" +msgid_plural "%{count} results" +msgstr[0] "" +msgstr[1] "" + +#: lib/elektrine_web/live/email_live/home.html.heex:1147 +#, elixir-autogen, elixir-format +msgid "1 week" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:1155 +#, elixir-autogen, elixir-format +msgid "2 weeks" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:1139 +#, elixir-autogen, elixir-format +msgid "3 days" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1540 +#, elixir-autogen, elixir-format +msgid "Archived messages" +msgstr "" + +#: lib/elektrine_web/live/email_live/show.html.heex:93 +#, elixir-autogen, elixir-format +msgid "Are you sure you want to delete this message?" +msgstr "" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:271 +#, elixir-autogen, elixir-format +msgid "Attachment (%{count})" +msgid_plural "Attachments (%{count})" +msgstr[0] "" +msgstr[1] "" + +#: lib/elektrine_web/live/email_live/show.html.heex:193 +#, elixir-autogen, elixir-format +msgid "Attachments (%{count})" +msgstr "" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:239 +#: lib/elektrine_web/live/email_live/show.html.heex:140 +#, elixir-autogen, elixir-format +msgid "BCC:" +msgstr "" + +#: lib/elektrine_web/live/email_live/raw.html.heex:37 +#, elixir-autogen, elixir-format +msgid "Back to Email" +msgstr "" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:232 +#: lib/elektrine_web/live/email_live/show.html.heex:128 +#, elixir-autogen, elixir-format +msgid "CC:" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:59 +#: lib/elektrine_web/live/vpn_live/home.html.heex:75 +#, elixir-autogen, elixir-format +msgid "Created" +msgstr "" + +#: lib/elektrine_web/live/email_live/raw.html.heex:57 +#, elixir-autogen, elixir-format +msgid "Created at:" +msgstr "" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:245 +#, elixir-autogen, elixir-format +msgid "Date:" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:91 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:175 +#, elixir-autogen, elixir-format +msgid "Delete orphaned mailbox" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:102 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:174 +#, elixir-autogen, elixir-format +msgid "Delete orphaned mailbox %{email}?" +msgstr "" + +#: lib/elektrine_web/live/email_live/show.html.heex:220 +#: lib/elektrine_web/live/vpn_live/home.html.heex:240 +#, elixir-autogen, elixir-format +msgid "Download" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:56 +#, elixir-autogen, elixir-format +msgid "Email Address" +msgstr "" + +#: lib/elektrine_web/live/email_live/search.html.heex:234 +#, elixir-autogen, elixir-format +msgid "Enter keywords to find messages by sender, subject, or content." +msgstr "" + +#: lib/elektrine_web/live/email_live/search.html.heex:23 +#, elixir-autogen, elixir-format +msgid "Find emails by sender, subject, or content" +msgstr "" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:221 +#: lib/elektrine_web/live/email_live/raw.html.heex:63 +#: lib/elektrine_web/live/email_live/show.html.heex:107 +#: lib/elektrine_web/live/email_live/show.html.heex:291 +#, elixir-autogen, elixir-format +msgid "From:" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:68 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:132 +#, elixir-autogen, elixir-format +msgid "ID:" +msgstr "" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:288 +#, elixir-autogen, elixir-format +msgid "Mailbox:" +msgstr "" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:253 +#: lib/elektrine_web/live/email_live/raw.html.heex:52 +#, elixir-autogen, elixir-format +msgid "Message ID:" +msgstr "" + +#: lib/elektrine_web/live/email_live/search.html.heex:220 +#, elixir-autogen, elixir-format +msgid "Next »" +msgstr "" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:6 +#: lib/elektrine_web/controllers/email_html/print.html.heex:216 +#, elixir-autogen, elixir-format +msgid "No Subject" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1572 +#, elixir-autogen, elixir-format +msgid "No archived messages" +msgstr "" + +#: lib/elektrine_web/live/email_live/search.html.heex:77 +#, elixir-autogen, elixir-format +msgid "No results found for \"%{query}\"" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1570 +#, elixir-autogen, elixir-format +msgid "No sent messages" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1571 +#, elixir-autogen, elixir-format +msgid "No spam messages" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:73 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:150 +#, elixir-autogen, elixir-format +msgid "No user association" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:206 +#, elixir-autogen, elixir-format +msgid "Not Spam" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:72 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:149 +#, elixir-autogen, elixir-format +msgid "ORPHANED" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:4 +#, elixir-autogen, elixir-format +msgid "Overview of all user mailboxes in the system." +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:57 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:146 +#, elixir-autogen, elixir-format +msgid "Owner" +msgstr "" + +#: lib/elektrine_web/live/email_live/show.html.heex:62 +#, elixir-autogen, elixir-format +msgid "Print" +msgstr "" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:6 +#, elixir-autogen, elixir-format +msgid "Print Email" +msgstr "" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:286 +#, elixir-autogen, elixir-format +msgid "Printed from Elektrine Email System" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:110 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:181 +#, elixir-autogen, elixir-format +msgid "Protected" +msgstr "" + +#: lib/elektrine_web/live/email_live/show.html.heex:69 +#, elixir-autogen, elixir-format +msgid "Raw" +msgstr "" + +#: lib/elektrine_web/live/email_live/raw.html.heex:82 +#, elixir-autogen, elixir-format +msgid "Raw Email Data" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:279 +#, elixir-autogen, elixir-format +msgid "Read" +msgstr "" + +#: lib/elektrine_web/live/email_live/show.html.heex:53 +#: lib/elektrine_web/live/email_live/show.html.heex:251 +#, elixir-autogen, elixir-format +msgid "Reply All" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:432 +#: lib/elektrine_web/live/email_live/home.ex:1524 +#: lib/elektrine_web/live/email_live/home.html.heex:105 +#: lib/elektrine_web/live/email_live/search.html.heex:21 +#: lib/elektrine_web/live/email_live/search.html.heex:58 +#, elixir-autogen, elixir-format +msgid "Search" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:20 +#, elixir-autogen, elixir-format +msgid "Search by email address or username..." +msgstr "" + +#: lib/elektrine_web/live/email_live/search.html.heex:65 +#, elixir-autogen, elixir-format +msgid "Search tips:" +msgstr "" + +#: lib/elektrine_web/live/email_live/search.html.heex:232 +#, elixir-autogen, elixir-format +msgid "Search your emails" +msgstr "" + +#: lib/elektrine_web/live/email_live/search.html.heex:36 +#, elixir-autogen, elixir-format +msgid "Search your emails..." +msgstr "" + +#: lib/elektrine_web/live/email_live/search.html.heex:56 +#, elixir-autogen, elixir-format +msgid "Searching..." +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:243 +#, elixir-autogen, elixir-format +msgid "Showing %{start}-%{end} of %{total} mailboxes" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:40 +#, elixir-autogen, elixir-format +msgid "Showing results for \"%{query}\"" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:58 +#, elixir-autogen, elixir-format +msgid "Status" +msgstr "" + +#: lib/elektrine_web/live/email_live/raw.html.heex:71 +#, elixir-autogen, elixir-format +msgid "Subject:" +msgstr "" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:226 +#: lib/elektrine_web/live/email_live/raw.html.heex:67 +#: lib/elektrine_web/live/email_live/show.html.heex:117 +#, elixir-autogen, elixir-format +msgid "To:" +msgstr "" + +#: lib/elektrine_web/live/email_live/search.html.heex:79 +#, elixir-autogen, elixir-format +msgid "Try searching for:" +msgstr "" + +#: lib/elektrine_web/live/email_live/search.html.heex:66 +#, elixir-autogen, elixir-format +msgid "Use multiple words to narrow results • Search works across sender, subject, and message content" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:890 +#: lib/elektrine_web/live/notifications_dropdown_live.ex:102 +#, elixir-autogen, elixir-format +msgid "View" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:291 +#, elixir-autogen, elixir-format +msgid "Your Mailbox" +msgstr "" + +#: lib/elektrine_web/live/email_live/search.html.heex:85 +#, elixir-autogen, elixir-format +msgid "for \"%{query}\"" +msgstr "" + +#: lib/elektrine_web/live/email_live/search.html.heex:205 +#, elixir-autogen, elixir-format +msgid "« Previous" +msgstr "" + +#: lib/elektrine_web/components/layouts/app.html.heex:444 +#, elixir-autogen, elixir-format +msgid "Admin Panel" +msgstr "" + +#: lib/elektrine_web/components/layouts/app.html.heex:441 +#, elixir-autogen, elixir-format +msgid "Administration" +msgstr "" + +#: lib/elektrine_web/components/layouts/app.html.heex:467 +#, elixir-autogen, elixir-format +msgid "Log out" +msgstr "" + +#: lib/elektrine_web/components/layouts/app.html.heex:462 +#, elixir-autogen, elixir-format +msgid "Report Issue" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:508 +#, elixir-autogen, elixir-format +msgid "Keyboard shortcuts (Shift + /)" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:510 +#, elixir-autogen, elixir-format +msgid "Shortcuts" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:236 +#, elixir-autogen, elixir-format +msgid "%{total} total mailboxes" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:1131 +#, elixir-autogen, elixir-format +msgid "1 day" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:1007 +#, elixir-autogen, elixir-format +msgid "1 message" +msgid_plural "%{count} messages" +msgstr[0] "" +msgstr[1] "" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:101 +#, elixir-autogen, elixir-format +msgid "Active Users" +msgstr "" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:728 +#, elixir-autogen, elixir-format +msgid "Admin Delete" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:447 +#, elixir-autogen, elixir-format +msgid "Alias Username" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1588 +#, elixir-autogen, elixir-format +msgid "Archived messages will appear here" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:521 +#, elixir-autogen, elixir-format +msgid "Attachments to forward:" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:1269 +#: lib/elektrine_web/live/email_live/show.ex:446 +#, elixir-autogen, elixir-format +msgid "Back to Aliases" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:1261 +#: lib/elektrine_web/live/email_live/show.ex:438 +#, elixir-autogen, elixir-format +msgid "Back to Archive" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:1268 +#: lib/elektrine_web/live/email_live/show.ex:445 +#, elixir-autogen, elixir-format +msgid "Back to Boomerang" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:1265 +#: lib/elektrine_web/live/email_live/show.ex:442 +#, elixir-autogen, elixir-format +msgid "Back to Bulk Mail" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:1266 +#: lib/elektrine_web/live/email_live/show.ex:443 +#, elixir-autogen, elixir-format +msgid "Back to Paper Trail" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:1271 +#: lib/elektrine_web/live/email_live/show.ex:448 +#, elixir-autogen, elixir-format +msgid "Back to Read" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:1262 +#: lib/elektrine_web/live/email_live/show.ex:439 +#, elixir-autogen, elixir-format +msgid "Back to Search" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:1259 +#: lib/elektrine_web/live/email_live/show.ex:436 +#, elixir-autogen, elixir-format +msgid "Back to Sent" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:1260 +#: lib/elektrine_web/live/email_live/show.ex:437 +#, elixir-autogen, elixir-format +msgid "Back to Spam" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:1267 +#: lib/elektrine_web/live/email_live/show.ex:444 +#, elixir-autogen, elixir-format +msgid "Back to The Pile" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:1270 +#: lib/elektrine_web/live/email_live/show.ex:447 +#, elixir-autogen, elixir-format +msgid "Back to Unread" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1519 +#: lib/elektrine_web/live/email_live/home.html.heex:328 +#: lib/elektrine_web/live/email_live/home.html.heex:963 +#, elixir-autogen, elixir-format +msgid "Boomerang" +msgstr "" + +#: lib/elektrine_web/components/layouts/app.html.heex:424 +#: lib/elektrine_web/components/platform/z_nav.ex:46 +#, elixir-autogen, elixir-format +msgid "Chat" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:952 +#, elixir-autogen, elixir-format +msgid "Clear Boomerang" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:390 +#: lib/elektrine_web/live/email_live/compose.html.heex:604 +#, elixir-autogen, elixir-format +msgid "Code" +msgstr "" + +#: lib/elektrine_web/components/layouts/app.html.heex:434 +#: lib/elektrine_web/components/platform/z_nav.ex:58 +#: lib/elektrine_web/live/discussions_live/home.html.heex:28 +#, elixir-autogen, elixir-format +msgid "Communities" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:1080 +#, elixir-autogen, elixir-format +msgid "Compose Email" +msgstr "" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:680 +#, elixir-autogen, elixir-format +msgid "Copy Link" +msgstr "" + +#: lib/elektrine_web/live/discussions_live/home.html.heex:364 +#: lib/elektrine_web/live/discussions_live/home.html.heex:549 +#, elixir-autogen, elixir-format +msgid "Create Community" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1590 +#, elixir-autogen, elixir-format +msgid "Create email aliases to organize your incoming mail and forward emails to different addresses." +msgstr "" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:695 +#, elixir-autogen, elixir-format +msgid "Delete Post" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:768 +#, elixir-autogen, elixir-format +msgid "Deselect all messages" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:666 +#, elixir-autogen, elixir-format +msgid "Disable" +msgstr "" + +#: lib/elektrine_web/live/email_live/show.html.heex:275 +#, elixir-autogen, elixir-format +msgid "Download EML" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:669 +#, elixir-autogen, elixir-format +msgid "Enable" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:325 +#, elixir-autogen, elixir-format +msgid "Enter email subject (optional)" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:114 +#, elixir-autogen, elixir-format +msgid "Found %{count} result for \"%{query}\"" +msgid_plural "Found %{count} results for \"%{query}\"" +msgstr[0] "" +msgstr[1] "" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:111 +#, elixir-autogen, elixir-format +msgid "Last 24 Hours" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:119 +#, elixir-autogen, elixir-format +msgid "Last 30 Days" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:115 +#, elixir-autogen, elixir-format +msgid "Last 7 Days" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1542 +#, elixir-autogen, elixir-format +msgid "Manage your email aliases and forwarding rules" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1538 +#, elixir-autogen, elixir-format +msgid "Messages you have sent" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1586 +#, elixir-autogen, elixir-format +msgid "Messages you send will appear here" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1537 +#, elixir-autogen, elixir-format +msgid "Messages you've scheduled to be reminded about for replies" +msgstr "" + +#: lib/elektrine_web/live/discussions_live/home.html.heex:66 +#, elixir-autogen, elixir-format +msgid "My Communities" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:123 +#, elixir-autogen, elixir-format +msgid "Never Logged In" +msgstr "" + +#: lib/elektrine_web/live/discussions_live/home.html.heex:15 +#: lib/elektrine_web/live/discussions_live/home.html.heex:46 +#, elixir-autogen, elixir-format +msgid "New Community" +msgstr "" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:44 +#: lib/elektrine_web/live/timeline_live/home.html.heex:116 +#, elixir-autogen, elixir-format +msgid "New Post" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:1015 +#: lib/elektrine_web/live/email_live/home.html.heex:1056 +#, elixir-autogen, elixir-format +msgid "Next page" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1574 +#, elixir-autogen, elixir-format +msgid "No email aliases" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:193 +#, elixir-autogen, elixir-format +msgid "No mailboxes found" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:198 +#, elixir-autogen, elixir-format +msgid "No mailboxes have been created yet." +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:196 +#, elixir-autogen, elixir-format +msgid "No mailboxes match your search criteria." +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1575 +#, elixir-autogen, elixir-format +msgid "No messages" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1565 +#, elixir-autogen, elixir-format +msgid "No messages in your inbox" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1573 +#, elixir-autogen, elixir-format +msgid "No search results" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1569 +#, elixir-autogen, elixir-format +msgid "Nothing scheduled" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:80 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:137 +#, elixir-autogen, elixir-format +msgid "Orphaned" +msgstr "" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:340 +#, elixir-autogen, elixir-format +msgid "Post" +msgstr "" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:1175 +#, elixir-autogen, elixir-format +msgid "Post your reply..." +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:1001 +#, elixir-autogen, elixir-format +msgid "Prev" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:998 +#: lib/elektrine_web/live/email_live/home.html.heex:1028 +#, elixir-autogen, elixir-format +msgid "Previous page" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:1123 +#, elixir-autogen, elixir-format +msgid "Remind me to reply in:" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:190 +#: lib/elektrine_web/live/email_live/home.html.heex:928 +#, elixir-autogen, elixir-format +msgid "Remove from Pile" +msgstr "" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:756 +#: lib/elektrine_web/live/timeline_live/home.html.heex:1131 +#, elixir-autogen, elixir-format +msgid "Replying to" +msgstr "" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:708 +#, elixir-autogen, elixir-format +msgid "Report Post" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:1099 +#, elixir-autogen, elixir-format +msgid "Schedule Reply" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:1112 +#, elixir-autogen, elixir-format +msgid "Schedule a reminder to reply to this message:" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1585 +#, elixir-autogen, elixir-format +msgid "Schedule reminders for emails that need replies. Use the 'Boomerang' action on any message to set a follow-up reminder." +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:38 +#, elixir-autogen, elixir-format +msgid "Search Email" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1541 +#: lib/elektrine_web/live/email_live/home.html.heex:40 +#, elixir-autogen, elixir-format +msgid "Search through your messages" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:770 +#, elixir-autogen, elixir-format +msgid "Select all messages" +msgstr "" + +#: lib/elektrine_web/live/discussions_live/home.html.heex:564 +#: lib/elektrine_web/live/discussions_live/home.html.heex:726 +#, elixir-autogen, elixir-format +msgid "Sign In" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1539 +#, elixir-autogen, elixir-format +msgid "Spam and junk messages" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1587 +#, elixir-autogen, elixir-format +msgid "Spam messages will be moved here automatically" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:331 +#, elixir-autogen, elixir-format +msgid "Storage Used" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:373 +#, elixir-autogen, elixir-format +msgid "Storage limit exceeded" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:378 +#, elixir-autogen, elixir-format +msgid "Storage nearly full" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:543 +#, elixir-autogen, elixir-format +msgid "These attachments from the original message will be included when you send this forwarded email." +msgstr "" + +#: lib/elektrine_web/components/layouts/app.html.heex:429 +#: lib/elektrine_web/components/platform/z_nav.ex:50 +#, elixir-autogen, elixir-format +msgid "Timeline" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1589 +#, elixir-autogen, elixir-format +msgid "Try searching for a specific term" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:269 +#: lib/elektrine_web/live/notifications_live.html.heex:54 +#, elixir-autogen, elixir-format +msgid "Unread" +msgstr "" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:106 +#, elixir-autogen, elixir-format +msgid "View All" +msgstr "" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:668 +#, elixir-autogen, elixir-format +msgid "View Post" +msgstr "" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:284 +#, elixir-autogen, elixir-format +msgid "What's on your mind?" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1581 +#, elixir-autogen, elixir-format +msgid "When you receive emails, they will appear here" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:537 +#, elixir-autogen, elixir-format +msgid "Will be forwarded" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1544 +#, elixir-autogen, elixir-format +msgid "Your email messages" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1533 +#, elixir-autogen, elixir-format +msgid "Your incoming messages" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1591 +#, elixir-autogen, elixir-format +msgid "Your messages will appear here" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:453 +#, elixir-autogen, elixir-format +msgid "shopping, newsletter, work" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:549 +#, elixir-autogen, elixir-format +msgid "ACTIVE" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:535 +#, elixir-autogen, elixir-format +msgid "Atoms" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:100 +#, elixir-autogen, elixir-format +msgid "Available on @example.com, @example.net, and @example.org" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:577 +#, elixir-autogen, elixir-format +msgid "Business account features" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:195 +#: lib/elektrine_web/controllers/page_html/home.html.heex:283 +#, elixir-autogen, elixir-format +msgid "CHAT" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:163 +#, elixir-autogen, elixir-format +msgid "CREATE ACCOUNT" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:361 +#: lib/elektrine_web/controllers/page_html/home.html.heex:484 +#, elixir-autogen, elixir-format +msgid "Chat Messages" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:96 +#: lib/elektrine_web/controllers/page_html/home.html.heex:131 +#, elixir-autogen, elixir-format +msgid "EMAIL" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:357 +#: lib/elektrine_web/controllers/page_html/home.html.heex:480 +#, elixir-autogen, elixir-format +msgid "Email Messages" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:565 +#, elixir-autogen, elixir-format +msgid "Email automation rules" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:573 +#, elixir-autogen, elixir-format +msgid "FUTURE" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:206 +#, elixir-autogen, elixir-format +msgid "Follow friends" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:548 +#, elixir-autogen, elixir-format +msgid "IN PROGRESS" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:349 +#: lib/elektrine_web/controllers/page_html/home.html.heex:472 +#, elixir-autogen, elixir-format +msgid "Launch Date" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:207 +#, elixir-autogen, elixir-format +msgid "Like & comment" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:143 +#, elixir-autogen, elixir-format +msgid "MY ACCOUNT" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:388 +#: lib/elektrine_web/controllers/page_html/home.html.heex:511 +#, elixir-autogen, elixir-format +msgid "Memory" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:578 +#, elixir-autogen, elixir-format +msgid "Mini-app ecosystem" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:553 +#, elixir-autogen, elixir-format +msgid "Mobile app (iOS/Android)" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:561 +#, elixir-autogen, elixir-format +msgid "NEXT" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:560 +#, elixir-autogen, elixir-format +msgid "PLANNED" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:346 +#: lib/elektrine_web/controllers/page_html/home.html.heex:469 +#, elixir-autogen, elixir-format +msgid "PLATFORM" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:392 +#: lib/elektrine_web/controllers/page_html/home.html.heex:515 +#, elixir-autogen, elixir-format +msgid "Processes" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:543 +#, elixir-autogen, elixir-format +msgid "ROADMAP" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:157 +#, elixir-autogen, elixir-format +msgid "SIGN IN" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:574 +#, elixir-autogen, elixir-format +msgid "SOMEDAY" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:381 +#: lib/elektrine_web/controllers/page_html/home.html.heex:504 +#, elixir-autogen, elixir-format +msgid "SYSTEM" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:205 +#, elixir-autogen, elixir-format +msgid "Share posts & updates" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:203 +#: lib/elektrine_web/controllers/page_html/home.html.heex:289 +#, elixir-autogen, elixir-format +msgid "TIMELINE" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:365 +#: lib/elektrine_web/controllers/page_html/home.html.heex:488 +#, elixir-autogen, elixir-format +msgid "Timeline Posts" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:438 +#, elixir-autogen, elixir-format +msgid "UPDATES" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:552 +#, elixir-autogen, elixir-format +msgid "Z social platform" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:200 +#, elixir-autogen, elixir-format +msgid "Account Recovery" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:158 +#, elixir-autogen, elixir-format +msgid "Add CC/BCC" +msgstr "" + +#: lib/elektrine_web/live/notifications_live.html.heex:47 +#: lib/elektrine_web/live/timeline_live/home.html.heex:380 +#: lib/elektrine_web/live/timeline_live/home.html.heex:389 +#, elixir-autogen, elixir-format +msgid "All" +msgstr "" + +#: lib/elektrine_web/live/settings_live/app_passwords.ex:76 +#: lib/elektrine_web/live/user_settings_live.html.heex:297 +#, elixir-autogen, elixir-format +msgid "App Passwords" +msgstr "" + +#: lib/elektrine_web/live/email_live/show.html.heex:84 +#, elixir-autogen, elixir-format +msgid "Are you sure you want to permanently delete this message? This cannot be undone." +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:310 +#, elixir-autogen, elixir-format +msgid "BLOG.ELEKTRINE.NET" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:372 +#: lib/elektrine_web/live/email_live/compose.html.heex:586 +#, elixir-autogen, elixir-format +msgid "Bold (Ctrl+B)" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:422 +#: lib/elektrine_web/live/email_live/compose.html.heex:636 +#, elixir-autogen, elixir-format +msgid "Bullet list" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:464 +#: lib/elektrine_web/live/email_live/compose.html.heex:678 +#, elixir-autogen, elixir-format +msgid "Code block" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:227 +#: lib/elektrine_web/live/email_live/show.html.heex:86 +#, elixir-autogen, elixir-format +msgid "Delete Permanently" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1516 +#: lib/elektrine_web/live/email_live/home.html.heex:289 +#, elixir-autogen, elixir-format +msgid "Digest" +msgstr "" + +#: lib/elektrine_web/live/notifications_dropdown_live.ex:121 +#: lib/elektrine_web/live/notifications_live.html.heex:301 +#, elixir-autogen, elixir-format +msgid "Dismiss" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:655 +#, elixir-autogen, elixir-format +msgid "Edit Forward-To" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:561 +#: lib/elektrine_web/live/user_settings_live.html.heex:260 +#, elixir-autogen, elixir-format +msgid "Enabled" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:440 +#: lib/elektrine_web/live/email_live/compose.html.heex:654 +#, elixir-autogen, elixir-format +msgid "Insert link" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:381 +#: lib/elektrine_web/live/email_live/compose.html.heex:595 +#, elixir-autogen, elixir-format +msgid "Italic (Ctrl+I)" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:405 +#, elixir-autogen, elixir-format +msgid "LATEST UPDATES" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:553 +#, elixir-autogen, elixir-format +msgid "Leave empty for main mailbox" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1517 +#: lib/elektrine_web/live/email_live/home.html.heex:302 +#, elixir-autogen, elixir-format +msgid "Ledger" +msgstr "" + +#: lib/elektrine_web/live/settings_live/app_passwords.ex:77 +#, elixir-autogen, elixir-format +msgid "Manage app-specific passwords for email clients" +msgstr "" + +#: lib/elektrine_web/live/notifications_live.html.heex:25 +#: lib/elektrine_web/live/notifications_live.html.heex:129 +#: lib/elektrine_web/live/notifications_live.html.heex:214 +#, elixir-autogen, elixir-format +msgid "Mark all as read" +msgstr "" + +#: lib/elektrine_web/live/notifications_dropdown_live.ex:42 +#: lib/elektrine_web/live/notifications_dropdown_live.ex:45 +#, elixir-autogen, elixir-format +msgid "Mark all read" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:59 +#: lib/elektrine_web/live/email_live/home.html.heex:167 +#, elixir-autogen, elixir-format +msgid "Mark as Read" +msgstr "" + +#: lib/elektrine_web/live/email_live/raw.html.heex:46 +#, elixir-autogen, elixir-format +msgid "Message Metadata" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1536 +#, elixir-autogen, elixir-format +msgid "Messages you've manually saved to process when you have more time" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1534 +#, elixir-autogen, elixir-format +msgid "Newsletters, updates, and automated messages from services and subscriptions" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1566 +#, elixir-autogen, elixir-format +msgid "No digest emails" +msgstr "" + +#: lib/elektrine_web/live/notifications_dropdown_live.ex:62 +#, elixir-autogen, elixir-format +msgid "No notifications" +msgstr "" + +#: lib/elektrine_web/live/notifications_live.html.heex:71 +#, elixir-autogen, elixir-format +msgid "No notifications yet" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1567 +#, elixir-autogen, elixir-format +msgid "No receipts or records" +msgstr "" + +#: lib/elektrine_web/live/notifications_live.html.heex:69 +#, elixir-autogen, elixir-format +msgid "No unread notifications" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:489 +#: lib/elektrine_web/live/email_live/compose.html.heex:702 +#, elixir-autogen, elixir-format +msgid "Nothing to preview yet..." +msgstr "" + +#: lib/elektrine_web/components/layouts/app.html.heex:352 +#: lib/elektrine_web/components/notification/bell.ex:20 +#: lib/elektrine_web/components/notification/bell.ex:42 +#: lib/elektrine_web/live/notifications_dropdown_live.ex:22 +#: lib/elektrine_web/live/notifications_dropdown_live.ex:36 +#: lib/elektrine_web/live/notifications_live.html.heex:7 +#: lib/elektrine_web/live/user_settings_live.html.heex:56 +#: lib/elektrine_web/live/user_settings_live.html.heex:609 +#, elixir-autogen, elixir-format +msgid "Notifications" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:431 +#: lib/elektrine_web/live/email_live/compose.html.heex:645 +#, elixir-autogen, elixir-format +msgid "Numbered list" +msgstr "" + +#: lib/elektrine_web/live/email_live/raw.html.heex:29 +#, elixir-autogen, elixir-format +msgid "Original email data and metadata" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:355 +#: lib/elektrine_web/live/email_live/compose.html.heex:569 +#, elixir-autogen, elixir-format +msgid "Preview" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:320 +#, elixir-autogen, elixir-format +msgid "Privacy Settings" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:151 +#, elixir-autogen, elixir-format +msgid "Public Profile" +msgstr "" + +#: lib/elektrine_web/live/email_live/raw.html.heex:26 +#, elixir-autogen, elixir-format +msgid "Raw Message" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1535 +#, elixir-autogen, elixir-format +msgid "Receipts, invoices, and financial records automatically filed for you" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:221 +#: lib/elektrine_web/live/email_live/show.html.heex:78 +#, elixir-autogen, elixir-format +msgid "Recover" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:573 +#, elixir-autogen, elixir-format +msgid "Save" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:359 +#: lib/elektrine_web/live/email_live/compose.html.heex:573 +#, elixir-autogen, elixir-format +msgid "Split" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1518 +#: lib/elektrine_web/live/email_live/home.html.heex:315 +#, elixir-autogen, elixir-format +msgid "Stack" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1568 +#, elixir-autogen, elixir-format +msgid "Stack is empty" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:456 +#: lib/elektrine_web/live/email_live/home.ex:1522 +#, elixir-autogen, elixir-format +msgid "Trash" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:314 +#, elixir-autogen, elixir-format +msgid "VIEW ALL" +msgstr "" + +#: lib/elektrine_web/live/notifications_dropdown_live.ex:141 +#, elixir-autogen, elixir-format +msgid "View all notifications" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:351 +#: lib/elektrine_web/live/email_live/compose.html.heex:565 +#, elixir-autogen, elixir-format +msgid "Write" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1583 +#, elixir-autogen, elixir-format +msgid "Your automatic filing cabinet for receipts, invoices, and financial records. Never lose an important transaction again." +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1582 +#, elixir-autogen, elixir-format +msgid "Your daily digest of newsletters, updates, and notifications from subscriptions and services - all automatically organized in one place." +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1584 +#, elixir-autogen, elixir-format +msgid "Your personal stack of saved emails. Use the 'Stack' action to save messages here for when you have more time." +msgstr "" + +#: lib/elektrine_web/live/notifications_live.html.heex:11 +#, elixir-autogen, elixir-format +msgid "unread" +msgid_plural "unread" +msgstr[0] "" +msgstr[1] "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:716 +#, elixir-autogen, elixir-format +msgid "Attachments" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:737 +#, elixir-autogen, elixir-format +msgid "Choose Files" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:733 +#, elixir-autogen, elixir-format +msgid "Drag and drop files here or click to browse" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:1359 +#, elixir-autogen, elixir-format +msgid "File is too large (max 10MB)" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:1361 +#, elixir-autogen, elixir-format +msgid "File type not accepted" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:832 +#: lib/elektrine_web/live/email_live/search.html.heex:127 +#, elixir-autogen, elixir-format +msgid "Has attachments" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:765 +#, elixir-autogen, elixir-format +msgid "Remove" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:1360 +#, elixir-autogen, elixir-format +msgid "Too many files (max 5)" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.ex:1362 +#, elixir-autogen, elixir-format +msgid "Upload error: %{error}" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:776 +#, elixir-autogen, elixir-format +msgid "%{count} configured" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:586 +#, elixir-autogen, elixir-format +msgid "12-hour (3:45 PM)" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:587 +#, elixir-autogen, elixir-format +msgid "24-hour (15:45)" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:300 +#, elixir-autogen, elixir-format +msgid "Access your email from POP3/IMAP clients without 2FA." +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:917 +#, elixir-autogen, elixir-format +msgid "Account deletion pending" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:279 +#, elixir-autogen, elixir-format +msgid "Add an extra layer of security to your account." +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:113 +#: lib/elektrine_web/live/email_live/compose.html.heex:193 +#: lib/elektrine_web/live/email_live/compose.html.heex:259 +#, elixir-autogen, elixir-format +msgid "Add email..." +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:572 +#, elixir-autogen, elixir-format +msgid "All times will be displayed in your selected timezone" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:722 +#, elixir-autogen, elixir-format +msgid "Allowed: Images, PDFs, Office docs, Text, Archives" +msgstr "" + +#: lib/elektrine_web/live/notifications_live.html.heex:31 +#, elixir-autogen, elixir-format +msgid "Are you sure you want to clear all notifications?" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:552 +#, elixir-autogen, elixir-format +msgid "Auto-detect (browser timezone)" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:560 +#, elixir-autogen, elixir-format +msgid "Berlin" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:159 +#, elixir-autogen, elixir-format +msgid "Can change in %{days} days" +msgstr "" + +#: lib/elektrine_web/live/email_live/show.html.heex:179 +#, elixir-autogen, elixir-format +msgid "Cancel scheduled return" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:555 +#, elixir-autogen, elixir-format +msgid "Central Time (US & Canada)" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:244 +#, elixir-autogen, elixir-format +msgid "Change Password" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:591 +#, elixir-autogen, elixir-format +msgid "Choose how times are displayed" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:622 +#, elixir-autogen, elixir-format +msgid "Choose which events you want to be notified about" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:536 +#, elixir-autogen, elixir-format +msgid "Choose your preferred language for the interface" +msgstr "" + +#: lib/elektrine_web/live/notifications_live.html.heex:34 +#, elixir-autogen, elixir-format +msgid "Clear all" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:553 +#, elixir-autogen, elixir-format +msgid "Common Timezones" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:434 +#, elixir-autogen, elixir-format +msgid "Control who can @mention you in posts and messages" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:348 +#, elixir-autogen, elixir-format +msgid "Control who can add you to group conversations and channels" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:371 +#, elixir-autogen, elixir-format +msgid "Control who can start direct message conversations with you" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:475 +#, elixir-autogen, elixir-format +msgid "Control who can view your profile page" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:303 +#: lib/elektrine_web/live/email_live/email_helpers.ex:321 +#, elixir-autogen, elixir-format +msgid "Copy to clipboard" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:74 +#: lib/elektrine_web/live/user_settings_live.html.heex:910 +#, elixir-autogen, elixir-format +msgid "Danger Zone" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:189 +#, elixir-autogen, elixir-format +msgid "Display Name" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1639 +#, elixir-autogen, elixir-format +msgid "Due Today" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:554 +#, elixir-autogen, elixir-format +msgid "Eastern Time (US & Canada)" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:787 +#, elixir-autogen, elixir-format +msgid "Email section" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:286 +#, elixir-autogen, elixir-format +msgid "Enable 2FA" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:339 +#: lib/elektrine_web/live/user_settings_live.html.heex:362 +#: lib/elektrine_web/live/user_settings_live.html.heex:385 +#: lib/elektrine_web/live/user_settings_live.html.heex:405 +#: lib/elektrine_web/live/user_settings_live.html.heex:425 +#, elixir-autogen, elixir-format +msgid "Everyone" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:406 +#: lib/elektrine_web/live/user_settings_live.html.heex:449 +#: lib/elektrine_web/live/user_settings_live.html.heex:470 +#, elixir-autogen, elixir-format +msgid "Followers only" +msgstr "" + +#: lib/elektrine_web/components/platform/z_nav.ex:42 +#, elixir-autogen, elixir-format +msgid "Friends" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:156 +#, elixir-autogen, elixir-format +msgid "Handle" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:158 +#, elixir-autogen, elixir-format +msgid "Hide CC/BCC" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:563 +#, elixir-autogen, elixir-format +msgid "Hong Kong" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:62 +#, elixir-autogen, elixir-format +msgid "In %{count} days" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:71 +#, elixir-autogen, elixir-format +msgid "In %{count} months" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:68 +#, elixir-autogen, elixir-format +msgid "In %{count} weeks" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:118 +#: lib/elektrine_web/live/email_live/compose.html.heex:198 +#: lib/elektrine_web/live/email_live/compose.html.heex:264 +#, elixir-autogen, elixir-format +msgid "Invalid email address" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:237 +#, elixir-autogen, elixir-format +msgid "Keep your account secure with a strong password." +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:558 +#, elixir-autogen, elixir-format +msgid "London" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:270 +#, elixir-autogen, elixir-format +msgid "Manage 2FA" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:307 +#, elixir-autogen, elixir-format +msgid "Manage App Passwords" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:787 +#, elixir-autogen, elixir-format +msgid "Manage email addresses and aliases in the" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:719 +#, elixir-autogen, elixir-format +msgid "Max 10MB per file, 5 files, 20 files per hour" +msgstr "" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:362 +#: lib/elektrine_web/live/timeline_live/home.html.heex:407 +#, elixir-autogen, elixir-format +msgid "Media" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:176 +#, elixir-autogen, elixir-format +msgid "Mentions:" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:556 +#, elixir-autogen, elixir-format +msgid "Mountain Time (US & Canada)" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:343 +#: lib/elektrine_web/live/user_settings_live.html.heex:366 +#: lib/elektrine_web/live/user_settings_live.html.heex:429 +#, elixir-autogen, elixir-format +msgid "Mutual follows only" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:342 +#: lib/elektrine_web/live/user_settings_live.html.heex:365 +#: lib/elektrine_web/live/user_settings_live.html.heex:428 +#, elixir-autogen, elixir-format +msgid "My followers" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:65 +#, elixir-autogen, elixir-format +msgid "Next week" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:344 +#: lib/elektrine_web/live/user_settings_live.html.heex:367 +#: lib/elektrine_web/live/user_settings_live.html.heex:387 +#: lib/elektrine_web/live/user_settings_live.html.heex:407 +#: lib/elektrine_web/live/user_settings_live.html.heex:430 +#, elixir-autogen, elixir-format +msgid "Nobody" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:782 +#, elixir-autogen, elixir-format +msgid "None configured" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:645 +#, elixir-autogen, elixir-format +msgid "Notify me for new direct messages" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:657 +#, elixir-autogen, elixir-format +msgid "Notify me when I'm mentioned" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:633 +#, elixir-autogen, elixir-format +msgid "Notify me when someone follows me" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:933 +#, elixir-autogen, elixir-format +msgid "Once deleted, your account cannot be recovered." +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:50 +#: lib/elektrine_web/live/email_live/home.ex:1638 +#, elixir-autogen, elixir-format +msgid "Overdue" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:557 +#, elixir-autogen, elixir-format +msgid "Pacific Time (US & Canada)" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:559 +#, elixir-autogen, elixir-format +msgid "Paris" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:341 +#: lib/elektrine_web/live/user_settings_live.html.heex:364 +#: lib/elektrine_web/live/user_settings_live.html.heex:427 +#, elixir-autogen, elixir-format +msgid "People I follow" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:114 +#, elixir-autogen, elixir-format +msgid "Please fix the following errors:" +msgstr "" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:356 +#: lib/elektrine_web/live/timeline_live/home.html.heex:395 +#, elixir-autogen, elixir-format +msgid "Posts" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:47 +#: lib/elektrine_web/live/user_settings_live.html.heex:512 +#, elixir-autogen, elixir-format +msgid "Preferences" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:766 +#, elixir-autogen, elixir-format +msgid "Primary Email" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:38 +#, elixir-autogen, elixir-format +msgid "Privacy" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:451 +#: lib/elektrine_web/live/user_settings_live.html.heex:471 +#, elixir-autogen, elixir-format +msgid "Private" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:175 +#, elixir-autogen, elixir-format +msgid "Profile URL:" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:463 +#, elixir-autogen, elixir-format +msgid "Profile visibility" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:448 +#: lib/elektrine_web/live/user_settings_live.html.heex:469 +#, elixir-autogen, elixir-format +msgid "Public" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:926 +#, elixir-autogen, elixir-format +msgid "Reason:" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:209 +#, elixir-autogen, elixir-format +msgid "Recovery Email" +msgstr "" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:359 +#: lib/elektrine_web/live/timeline_live/home.html.heex:401 +#, elixir-autogen, elixir-format +msgid "Replies" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:937 +#, elixir-autogen, elixir-format +msgid "Request Deletion" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:919 +#, elixir-autogen, elixir-format +msgid "Requested:" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:220 +#: lib/elektrine_web/live/user_settings_live.html.heex:500 +#: lib/elektrine_web/live/user_settings_live.html.heex:597 +#: lib/elektrine_web/live/user_settings_live.html.heex:722 +#, elixir-autogen, elixir-format +msgid "Save Changes" +msgstr "" + +#: lib/elektrine_web/live/email_live/show.html.heex:163 +#, elixir-autogen, elixir-format +msgid "Scheduled to return to inbox" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:29 +#, elixir-autogen, elixir-format +msgid "Security" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:562 +#, elixir-autogen, elixir-format +msgid "Shanghai" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:488 +#: lib/elektrine_web/live/user_settings_live.html.heex:86 +#, elixir-autogen, elixir-format +msgid "Storage" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:564 +#, elixir-autogen, elixir-format +msgid "Sydney" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:580 +#, elixir-autogen, elixir-format +msgid "Time Format" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:544 +#, elixir-autogen, elixir-format +msgid "Timezone" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:56 +#, elixir-autogen, elixir-format +msgid "Today" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:561 +#, elixir-autogen, elixir-format +msgid "Tokyo" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:254 +#, elixir-autogen, elixir-format +msgid "Two-Factor Authentication" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:753 +#, elixir-autogen, elixir-format +msgid "Unique ID" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1640 +#, elixir-autogen, elixir-format +msgid "Upcoming" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:145 +#, elixir-autogen, elixir-format +msgid "Upload a new avatar image (max 5MB) - Will be automatically cropped to square" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:214 +#, elixir-autogen, elixir-format +msgid "Used for password reset. Must be an external email address." +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:757 +#, elixir-autogen, elixir-format +msgid "Username is for login only. Your handle (%{handle}) is your public identity." +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:333 +#, elixir-autogen, elixir-format +msgid "Who can add you to groups/channels" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:419 +#, elixir-autogen, elixir-format +msgid "Who can mention you" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:356 +#, elixir-autogen, elixir-format +msgid "Who can send you direct messages" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:53 +#, elixir-autogen, elixir-format +msgid "Within an hour" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:263 +#, elixir-autogen, elixir-format +msgid "Your account is protected with 2FA." +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:195 +#, elixir-autogen, elixir-format +msgid "Your public display name. Can be changed anytime." +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:198 +#, elixir-autogen, elixir-format +msgid "Audio & video calls" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:253 +#, elixir-autogen, elixir-format +msgid "Check out some profiles:" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:391 +#, elixir-autogen, elixir-format +msgid "Control who can initiate audio and video calls with you" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:411 +#, elixir-autogen, elixir-format +msgid "Control who can send you friend requests" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:442 +#, elixir-autogen, elixir-format +msgid "Default post visibility" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:455 +#, elixir-autogen, elixir-format +msgid "Default visibility for your timeline posts (can be changed per post)" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:531 +#, elixir-autogen, elixir-format +msgid "ETS Tables" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:340 +#: lib/elektrine_web/live/user_settings_live.html.heex:363 +#: lib/elektrine_web/live/user_settings_live.html.heex:386 +#: lib/elektrine_web/live/user_settings_live.html.heex:426 +#: lib/elektrine_web/live/user_settings_live.html.heex:450 +#, elixir-autogen, elixir-format +msgid "Friends only" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:197 +#, elixir-autogen, elixir-format +msgid "Messaging & group chats" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:396 +#: lib/elektrine_web/controllers/page_html/home.html.heex:519 +#, elixir-autogen, elixir-format +msgid "Reductions" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:523 +#, elixir-autogen, elixir-format +msgid "Run Queue" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:527 +#, elixir-autogen, elixir-format +msgid "Schedulers" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:199 +#, elixir-autogen, elixir-format +msgid "Screen sharing" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:243 +#, elixir-autogen, elixir-format +msgid "The internet is shifting. AI-generated content is everywhere, and it's getting harder to tell what's real. Most platforms optimize for viral reach and follower counts, metrics that mean nothing when bots can fake them." +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:102 +#, elixir-autogen, elixir-format +msgid "Use our webmail or connect with your preferred client like Thunderbird or Mutt:" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:118 +#, elixir-autogen, elixir-format +msgid "What This Is" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:379 +#, elixir-autogen, elixir-format +msgid "Who can call you" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:399 +#, elixir-autogen, elixir-format +msgid "Who can send you friend requests" +msgstr "" + +#: lib/elektrine_web/live/email_live/compose.html.heex:336 +#, elixir-autogen, elixir-format +msgid "%{count} words" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:831 +#, elixir-autogen, elixir-format +msgid "Automatically appended to all outgoing emails with '-- ' separator" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:566 +#, elixir-autogen, elixir-format +msgid "Calendar sync" +msgstr "" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:480 +#: lib/elektrine_web/live/email_live/home.ex:1526 +#, elixir-autogen, elixir-format +msgid "Contacts" +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:468 +#, elixir-autogen, elixir-format +msgid "Create an alias for the selected domain (max 15 aliases)" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:736 +#, elixir-autogen, elixir-format +msgid "Email Settings" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:814 +#, elixir-autogen, elixir-format +msgid "Email Signature" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:848 +#, elixir-autogen, elixir-format +msgid "Email Subscription Preferences" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:98 +#, elixir-autogen, elixir-format +msgid "Full-featured email with up to 15 aliases, forwarding, spam filtering, and smart categorization." +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:564 +#, elixir-autogen, elixir-format +msgid "JMAP support" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:384 +#: lib/elektrine_web/controllers/page_html/home.html.heex:507 +#, elixir-autogen, elixir-format +msgid "Last Deployed" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:852 +#, elixir-autogen, elixir-format +msgid "Manage subscription preferences for all your email addresses. Transactional emails cannot be unsubscribed." +msgstr "" + +#: lib/elektrine_web/live/email_live/home.ex:1543 +#, elixir-autogen, elixir-format +msgid "Manage your email contacts" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:820 +#, elixir-autogen, elixir-format +msgid "Max 500 characters" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:897 +#, elixir-autogen, elixir-format +msgid "No email addresses found for your account." +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:884 +#, elixir-autogen, elixir-format +msgid "Required" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:837 +#, elixir-autogen, elixir-format +msgid "Save Settings" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:819 +#, elixir-autogen, elixir-format +msgid "Signature" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:877 +#, elixir-autogen, elixir-format +msgid "Subscribed" +msgstr "" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:265 +#, elixir-autogen, elixir-format +msgid "Title (optional)" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:880 +#, elixir-autogen, elixir-format +msgid "Unsubscribed" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:241 +#, elixir-autogen, elixir-format +msgid "Why Z Exists" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:827 +#, elixir-autogen, elixir-format +msgid "Your signature will be automatically appended to outgoing emails.\n\nExample:\nBest regards,\nYour Name\nYour Title" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:83 +#, elixir-autogen, elixir-format +msgid "Z Profile" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:246 +#, elixir-autogen, elixir-format +msgid "Z is designed around smaller circles of people you actually know and trust. We're building reputation systems based on your interactions with your community: who you talk to, who vouches for you, your actual contribution history. Public moderation logs, AI content labels, and trust networks instead of algorithmic feeds. Think less 'going viral' and more 'building credibility with people who matter.' The internet works better when trust is local, not global." +msgstr "" + +#: lib/elektrine_web/live/email_live/home.html.heex:778 +#, elixir-autogen, elixir-format +msgid "1 result" +msgid_plural "%{count} results" +msgstr[0] "" +msgstr[1] "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:296 +#, elixir-autogen, elixir-format +msgid "Activate the tunnel in WireGuard" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:30 +#, elixir-autogen, elixir-format +msgid "Available Servers" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:330 +#, elixir-autogen, elixir-format +msgid "Close" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:295 +#, elixir-autogen, elixir-format +msgid "Connect" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:174 +#, elixir-autogen, elixir-format +msgid "Connected" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:84 +#, elixir-autogen, elixir-format +msgid "Create Config" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:290 +#, elixir-autogen, elixir-format +msgid "Desktop: Download .conf file" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:112 +#, elixir-autogen, elixir-format +msgid "Freemium VPN. WireGuard servers worldwide, 10GB/month*, 50 Mbps*." +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:289 +#, elixir-autogen, elixir-format +msgid "Import Config" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:282 +#, elixir-autogen, elixir-format +msgid "Install WireGuard" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:320 +#, elixir-autogen, elixir-format +msgid "Instructions" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:186 +#, elixir-autogen, elixir-format +msgid "Last connected" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:291 +#, elixir-autogen, elixir-format +msgid "Mobile: Scan QR code" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:266 +#, elixir-autogen, elixir-format +msgid "No VPN Configurations" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:93 +#, elixir-autogen, elixir-format +msgid "No servers available" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:693 +#, elixir-autogen, elixir-format +msgid "Notify me when I receive an email" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:717 +#, elixir-autogen, elixir-format +msgid "Notify me when someone comments on my timeline post" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:681 +#, elixir-autogen, elixir-format +msgid "Notify me when someone likes my post" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:705 +#, elixir-autogen, elixir-format +msgid "Notify me when someone replies to my discussion post" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:669 +#, elixir-autogen, elixir-format +msgid "Notify me when someone replies to my message" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:322 +#, elixir-autogen, elixir-format +msgid "Open WireGuard app" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:248 +#, elixir-autogen, elixir-format +msgid "QR Code" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:309 +#, elixir-autogen, elixir-format +msgid "QR Code - %{name}" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:324 +#, elixir-autogen, elixir-format +msgid "Scan this code" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:312 +#, elixir-autogen, elixir-format +msgid "Scan this code with the WireGuard mobile app" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:268 +#, elixir-autogen, elixir-format +msgid "Select a server from the sidebar to create a configuration" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:278 +#, elixir-autogen, elixir-format +msgid "Setup Instructions" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:323 +#, elixir-autogen, elixir-format +msgid "Tap '+' and select 'Create from QR code'" +msgstr "" + +#: lib/elektrine_web/components/layouts/app.html.heex:410 +#: lib/elektrine_web/components/platform/elektrine_nav.ex:40 +#: lib/elektrine_web/controllers/page_html/home.html.heex:110 +#: lib/elektrine_web/controllers/page_html/home.html.heex:137 +#, elixir-autogen, elixir-format +msgid "VPN" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:112 +#, elixir-autogen, elixir-format +msgid "VPN Configurations" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:369 +#: lib/elektrine_web/controllers/page_html/home.html.heex:492 +#, elixir-autogen, elixir-format +msgid "VPN Servers" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:11 +#, elixir-autogen, elixir-format +msgid "VPN Service" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:114 +#, elixir-autogen, elixir-format +msgid "During beta, subject to change" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:91 +#, elixir-autogen, elixir-format +msgid "3.6 roentgen" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:231 +#, elixir-autogen, elixir-format +msgid "ActivityPub Federation" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:211 +#: lib/elektrine_web/controllers/page_html/home.html.heex:295 +#, elixir-autogen, elixir-format +msgid "COMMUNITIES" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:190 +#, elixir-autogen, elixir-format +msgid "Chat, timelines, communities, and galleries in one platform" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:215 +#, elixir-autogen, elixir-format +msgid "Community moderation" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:796 +#, elixir-autogen, elixir-format +msgid "Compose From" +msgstr "" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:275 +#, elixir-autogen, elixir-format +msgid "Content warning (e.g., 'spoilers', 'politics', etc.)" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:213 +#, elixir-autogen, elixir-format +msgid "Create & join communities" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:810 +#, elixir-autogen, elixir-format +msgid "Default 'From' address when composing new emails" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:791 +#, elixir-autogen, elixir-format +msgid "Default Email Domain" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:223 +#, elixir-autogen, elixir-format +msgid "Discover visual content" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:214 +#, elixir-autogen, elixir-format +msgid "Discussion threads" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:120 +#, elixir-autogen, elixir-format +msgid "Elektrine provides email and VPN services built from the ground up with Elixir: custom SMTP/IMAP servers, MTA components, spam filtering, and webmail. Z is the social platform layer with real-time chat, timelines, communities, and photo galleries. Everything runs on the same account." +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:219 +#: lib/elektrine_web/controllers/page_html/home.html.heex:301 +#, elixir-autogen, elixir-format +msgid "GALLERY" +msgstr "" + +#: lib/elektrine_web/components/platform/z_nav.ex:62 +#, elixir-autogen, elixir-format +msgid "Gallery" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:59 +#, elixir-autogen, elixir-format +msgid "I agree to the" +msgstr "" + +#: lib/elektrine_web/components/platform/z_nav.ex:54 +#, elixir-autogen, elixir-format +msgid "Lists" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:491 +#, elixir-autogen, elixir-format +msgid "Manually approve followers from the fediverse" +msgstr "" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:656 +#, elixir-autogen, elixir-format +msgid "Open on Remote Instance" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:222 +#, elixir-autogen, elixir-format +msgid "Organize albums" +msgstr "" + +#: lib/elektrine_web/components/platform/z_nav.ex:38 +#, elixir-autogen, elixir-format +msgid "Overview" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:59 +#, elixir-autogen, elixir-format +msgid "Privacy Policy" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:150 +#, elixir-autogen, elixir-format +msgid "SIGN OUT" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:221 +#, elixir-autogen, elixir-format +msgid "Share photo collections" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:59 +#, elixir-autogen, elixir-format +msgid "Terms" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:373 +#: lib/elektrine_web/controllers/page_html/home.html.heex:496 +#, elixir-autogen, elixir-format +msgid "VPN Bandwidth" +msgstr "" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:18 +#, elixir-autogen, elixir-format +msgid "View Service Policy & Details" +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:493 +#, elixir-autogen, elixir-format +msgid "When enabled, you must approve follow requests from Mastodon and other ActivityPub instances" +msgstr "" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:68 +#, elixir-autogen, elixir-format +msgid "You must agree to the Terms of Service" +msgstr "" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:233 +#, elixir-autogen, elixir-format +msgid "Z federates with Mastodon and the fediverse. Follow users on any instance, see their posts in your timeline, and interact across platforms." +msgstr "" + +#: lib/elektrine_web/live/user_settings_live.html.heex:177 +#, elixir-autogen, elixir-format +msgid "1-20 chars, alphanumeric + underscore" +msgstr "" diff --git a/apps/elektrine/priv/gettext/errors.pot b/apps/elektrine/priv/gettext/errors.pot new file mode 100644 index 0000000..eef2de2 --- /dev/null +++ b/apps/elektrine/priv/gettext/errors.pot @@ -0,0 +1,109 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/apps/elektrine/priv/gettext/zh/LC_MESSAGES/default.po b/apps/elektrine/priv/gettext/zh/LC_MESSAGES/default.po new file mode 100644 index 0000000..6b4c8c7 --- /dev/null +++ b/apps/elektrine/priv/gettext/zh/LC_MESSAGES/default.po @@ -0,0 +1,3154 @@ +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: zh\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Poedit 3.7\n" + +#: lib/elektrine_web/live/email_live/inbox.ex:139 +msgid "Messages refreshed" +msgstr "消息已刷新" + +#: lib/elektrine_web/live/email_live/inbox.ex:162 +msgid "Message deleted successfully" +msgstr "消息成功删除" + +#: lib/elektrine_web/live/email_live/inbox.ex:165, lib/elektrine_web/live/email_live/inbox.ex:175 +msgid "Message not found" +msgstr "未找到消息" + +#: lib/elektrine_web/live/email_live/inbox.ex:187 +msgid "Message marked as spam" +msgstr "该消息已被标记为垃圾邮件" + +#: lib/elektrine_web/live/email_live/inbox.ex:212 +msgid "Message archived" +msgstr "消息已归档" + +#: lib/elektrine_web/live/email_live/compose.ex:254 +msgid "Email sent successfully!" +msgstr "电子邮件已成功发送!" + +#: lib/elektrine_web/live/email_live/compose.ex:261 +msgid "You have reached your daily limit of 100 emails. Please try again tomorrow." +msgstr "您已达到每日100封邮件的发送上限。请明天再次尝试。" + +#: lib/elektrine_web/components/layouts/app.html.heex:400 +#: lib/elektrine_web/live/email_live/email_helpers.ex:403 +#: lib/elektrine_web/live/email_live/home.ex:1515 +#: lib/elektrine_web/live/email_live/home.html.heex:256 +#, elixir-autogen +msgid "Inbox" +msgstr "收件箱" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:420 +#: lib/elektrine_web/live/email_live/home.ex:1520 +#, elixir-autogen +msgid "Sent" +msgstr "已发送" + +#: lib/elektrine_web/components/layouts/app.html.heex:405 +#: lib/elektrine_web/live/email_live/email_helpers.ex:499 +#, elixir-autogen +msgid "Compose" +msgstr "写信" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:468 +#: lib/elektrine_web/live/email_live/home.ex:1523 +#: lib/elektrine_web/live/email_live/home.html.heex:71 +#: lib/elektrine_web/live/email_live/home.html.heex:181 +#: lib/elektrine_web/live/email_live/search.html.heex:157 +#, elixir-autogen +msgid "Archive" +msgstr "存档" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:444 +#: lib/elektrine_web/live/email_live/home.ex:1521 +#, elixir-autogen +msgid "Spam" +msgstr "垃圾箱" + +#: lib/elektrine_web/live/email_live/compose.html.heex:317 +#, elixir-autogen +msgid "Subject" +msgstr "主题" + +#: lib/elektrine_web/live/email_live/compose.html.heex:89 +#: lib/elektrine_web/live/email_live/home.html.heex:823 +#, elixir-autogen +msgid "To" +msgstr "收件人" + +#: lib/elektrine_web/live/email_live/compose.html.heex:63 +#: lib/elektrine_web/live/email_live/home.html.heex:1117 +#, elixir-autogen +msgid "From" +msgstr "发件人" + +#: lib/elektrine_web/live/email_live/compose.html.heex:333 +#, elixir-autogen +msgid "Message" +msgstr "正文" + +msgid "Send" +msgstr "发送" + +#: lib/elektrine_web/live/discussions_live/home.html.heex:360 +#: lib/elektrine_web/live/discussions_live/home.html.heex:696 +#: lib/elektrine_web/live/email_live/home.html.heex:577 +#: lib/elektrine_web/live/email_live/home.html.heex:1165 +#: lib/elektrine_web/live/email_live/show.html.heex:182 +#: lib/elektrine_web/live/email_live/show.html.heex:315 +#: lib/elektrine_web/live/timeline_live/home.html.heex:336 +#: lib/elektrine_web/live/timeline_live/home.html.heex:1191 +#, elixir-autogen +msgid "Cancel" +msgstr "取消" + +#: lib/elektrine_web/live/email_live/home.html.heex:900 +#: lib/elektrine_web/live/email_live/search.html.heex:167 +#: lib/elektrine_web/live/email_live/show.html.heex:49 +#: lib/elektrine_web/live/email_live/show.html.heex:247 +#: lib/elektrine_web/live/timeline_live/home.html.heex:1195 +#, elixir-autogen +msgid "Reply" +msgstr "回复" + +#: lib/elektrine_web/live/email_live/home.html.heex:916 +#: lib/elektrine_web/live/email_live/search.html.heex:177 +#: lib/elektrine_web/live/email_live/show.html.heex:256 +#, elixir-autogen +msgid "Forward" +msgstr "转发" + +#: lib/elektrine_web/live/email_live/home.html.heex:83 +#: lib/elektrine_web/live/email_live/home.html.heex:234 +#: lib/elektrine_web/live/email_live/home.html.heex:681 +#: lib/elektrine_web/live/email_live/show.html.heex:95 +#, elixir-autogen +msgid "Delete" +msgstr "删除" + +#: lib/elektrine_web/live/email_live/home.html.heex:77 +#: lib/elektrine_web/live/email_live/home.html.heex:213 +#, elixir-autogen +msgid "Mark as Spam" +msgstr "标记为垃圾邮件" + +msgid "Admin" +msgstr "管理员" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:353 +#: lib/elektrine_web/controllers/page_html/home.html.heex:476 +#, elixir-autogen +msgid "Users" +msgstr "用户数" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:81 +#, elixir-autogen +msgid "Messages" +msgstr "消息" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:73 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:3 +#, elixir-autogen +msgid "Mailboxes" +msgstr "邮箱" + +msgid "Login" +msgstr "登录" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:3 +#, elixir-autogen +msgid "Register" +msgstr "注册" + +msgid "Logout" +msgstr "退出" + +msgid "Account" +msgstr "账户" + +#: lib/elektrine_web/live/user_settings_live.html.heex:12 +#, elixir-autogen +msgid "Settings" +msgstr "设置" + +#: lib/elektrine_web/live/user_settings_live.html.heex:20 +#, elixir-autogen +msgid "Profile" +msgstr "个人资料" + +#: lib/elektrine_web/live/user_settings_live.html.heex:525 +#, elixir-autogen +msgid "Language" +msgstr "语言" + +msgid "English" +msgstr "英语" + +msgid "Spanish" +msgstr "西班牙语" + +msgid "French" +msgstr "法语" + +msgid "German" +msgstr "德语" + +msgid "Chinese" +msgstr "中文" + +msgid "Japanese" +msgstr "日语" + +#: lib/elektrine_web/components/ui/table.ex:46 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:60 +#: lib/elektrine_web/live/email_live/home.html.heex:50 +#: lib/elektrine_web/live/email_live/home.html.heex:158 +#, elixir-autogen, elixir-format +msgid "Actions" +msgstr "操作" + +#: lib/elektrine_web/components/ui/modal.ex:64 +#, elixir-autogen, elixir-format +msgid "close" +msgstr "关闭" + +#: lib/elektrine_web/live/email_live/compose.html.heex:36 +#, elixir-autogen, elixir-format +msgid "(%{count} emails left today)" +msgstr "今日剩余 %{count} 封邮件" + +#: lib/elektrine_web/live/email_live/raw.html.heex:72 +#: lib/elektrine_web/live/email_live/show.html.heex:33 +#, elixir-autogen, elixir-format +msgid "(No Subject)" +msgstr "(无主题)" + +#: lib/elektrine_web/components/layouts/app.html.heex:454 +#: lib/elektrine_web/controllers/user_settings_html/edit.html.heex:3 +#: lib/elektrine_web/live/user_settings_live.html.heex:3 +#, elixir-autogen, elixir-format +msgid "Account Settings" +msgstr "账户设置" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:3 +#, elixir-autogen, elixir-format +msgid "Admin Dashboard" +msgstr "管理面板" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:92 +#: lib/elektrine_web/live/email_live/home.html.heex:341 +#, elixir-autogen, elixir-format +msgid "Aliases" +msgstr "别名" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:116 +#, elixir-autogen, elixir-format +msgid "Already have an account? Log in" +msgstr "已经拥有账户?点此登录" + +#: lib/elektrine_web/live/email_live/compose.html.heex:52 +#, elixir-autogen, elixir-format +msgid "Approaching daily limit!" +msgstr "即将达到每日上限!" + +#: lib/elektrine_web/live/email_live/compose.ex:1272 +#: lib/elektrine_web/live/email_live/compose.ex:1274 +#: lib/elektrine_web/live/email_live/show.ex:449 +#: lib/elektrine_web/live/email_live/show.ex:451 +#, elixir-autogen, elixir-format +msgid "Back to Inbox" +msgstr "返回收件箱" + +#: lib/elektrine_web/controllers/password_reset_html/new.html.heex:42 +#, elixir-autogen, elixir-format +msgid "Back to Login" +msgstr "返回登录" + +#: lib/elektrine_web/live/email_live/compose.html.heex:235 +#, elixir-autogen, elixir-format +msgid "Blind carbon copy" +msgstr "密送" + +#: lib/elektrine_web/live/email_live/compose.html.heex:169 +#, elixir-autogen, elixir-format +msgid "Carbon copy" +msgstr "副本" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:34 +#, elixir-autogen, elixir-format +msgid "Confirm password" +msgstr "确认密码" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:35 +#, elixir-autogen, elixir-format +msgid "Confirm your password" +msgstr "确认您的密码" + +#: lib/elektrine_web/live/email_live/home.html.heex:508 +#, elixir-autogen, elixir-format +msgid "Create Alias" +msgstr "创建别名" + +#: lib/elektrine_web/live/email_live/home.html.heex:440 +#, elixir-autogen, elixir-format +msgid "Create New Alias" +msgstr "创建新的别名" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:108 +#, elixir-autogen, elixir-format +msgid "Create account" +msgstr "创建账户" + +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:59 +#, elixir-autogen, elixir-format +msgid "Create an account" +msgstr "创建一个账户" + +#: lib/elektrine_web/controllers/user_settings_html/edit.html.heex:31 +#, elixir-autogen, elixir-format +msgid "Current avatar" +msgstr "当前头像" + +#: lib/elektrine_web/live/email_live/compose.html.heex:49 +#, elixir-autogen, elixir-format +msgid "Daily limit reached!" +msgstr "已达每日上限!" + +#: lib/elektrine_web/live/email_live/home.html.heex:489 +#, elixir-autogen, elixir-format +msgid "Description (optional)" +msgstr "描述(可选)" + +#: lib/elektrine_web/components/platform/elektrine_nav.ex:36 +#: lib/elektrine_web/live/email_live/home.ex:1527 +#: lib/elektrine_web/live/user_settings_live.html.heex:65 +#, elixir-autogen, elixir-format +msgid "Email" +msgstr "邮件" + +#: lib/elektrine_web/live/email_live/home.ex:1525 +#: lib/elektrine_web/live/email_live/home.html.heex:427 +#: lib/elektrine_web/live/user_settings_live.html.heex:774 +#: lib/elektrine_web/live/user_settings_live.html.heex:781 +#, elixir-autogen, elixir-format +msgid "Email Aliases" +msgstr "电子邮件别名" + +#: lib/elektrine_web/live/email_live/home.html.heex:497 +#, elixir-autogen, elixir-format +msgid "Enable immediately" +msgstr "立即启用" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:44 +#, elixir-autogen, elixir-format +msgid "Enter your invite code" +msgstr "输入您的邀请码" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:22 +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:18 +#, elixir-autogen, elixir-format +msgid "Enter your password" +msgstr "输入您的密码" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:14 +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:11 +#, elixir-autogen, elixir-format +msgid "Enter your username" +msgstr "输入您的用户名" + +#: lib/elektrine_web/controllers/password_reset_html/new.html.heex:15 +#, elixir-autogen, elixir-format +msgid "Enter your username or recovery email" +msgstr "输入您的用户名或恢复邮箱" + +#: lib/elektrine_web/controllers/password_reset_html/new.html.heex:6 +#, elixir-autogen, elixir-format +msgid "Enter your username or recovery email address and we'll send you a link to reset your password." +msgstr "输入您的用户名或恢复邮箱,我们将向您发送重置密码的链接。" + +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:33 +#, elixir-autogen, elixir-format +msgid "Forgot your password?" +msgstr "忘记密码?" + +#: lib/elektrine_web/live/email_live/home.html.heex:475 +#, elixir-autogen, elixir-format +msgid "Forward To (optional)" +msgstr "转发至(可选)" + +#: lib/elektrine_web/live/email_live/compose.html.heex:30 +#, elixir-autogen, elixir-format +msgid "Forward this message to someone" +msgstr "将此信息转发给" + +#: lib/elektrine_web/components/layouts/app.html.heex:43 +#: lib/elektrine_web/components/layouts/app.html.heex:235 +#, elixir-autogen, elixir-format +msgid "Home" +msgstr "首页" + +#: lib/elektrine_web/live/email_live/show.html.heex:304 +#, elixir-autogen, elixir-format +msgid "In 3 Days" +msgstr "3 天内" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:43 +#, elixir-autogen, elixir-format +msgid "Invite Code" +msgstr "邀请码" + +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:28 +#, elixir-autogen, elixir-format +msgid "Keep me logged in" +msgstr "保持我的登录状态" + +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:3 +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:51 +#, elixir-autogen, elixir-format +msgid "Log in" +msgstr "登录" + +#: lib/elektrine_web/live/email_live/home.html.heex:361 +#, elixir-autogen, elixir-format +msgid "Main Email Forwarding" +msgstr "主电子邮件地址转发" + +#: lib/elektrine_web/live/email_live/home.html.heex:614 +#, elixir-autogen, elixir-format +msgid "Main Mailbox" +msgstr "主邮箱" + +#: lib/elektrine_web/controllers/user_settings_html/edit.html.heex:4 +#: lib/elektrine_web/live/user_settings_live.html.heex:4 +#, elixir-autogen, elixir-format +msgid "Manage your account preferences and security settings" +msgstr "管理首选账户和安全设置" + +#: lib/elektrine_web/live/email_live/home.html.heex:65 +#: lib/elektrine_web/live/email_live/home.html.heex:173 +#: lib/elektrine_web/live/email_live/home.html.heex:976 +#: lib/elektrine_web/live/email_live/show.html.heex:268 +#, elixir-autogen, elixir-format +msgid "Mark as Unread" +msgstr "标记为未读" + +#: lib/elektrine_web/live/notifications_dropdown_live.ex:112 +#: lib/elektrine_web/live/notifications_live.html.heex:292 +#, elixir-autogen, elixir-format +msgid "Mark as read" +msgstr "标记为已读" + +#: lib/elektrine_web/live/email_live/home.html.heex:1017 +#: lib/elektrine_web/live/email_live/home.html.heex:1058 +#, elixir-autogen, elixir-format +msgid "Next" +msgstr "下一个" + +#: lib/elektrine_web/live/email_live/show.html.heex:310 +#, elixir-autogen, elixir-format +msgid "Next Month" +msgstr "下一个月" + +#: lib/elektrine_web/live/email_live/show.html.heex:307 +#, elixir-autogen, elixir-format +msgid "Next Week" +msgstr "下一周" + +#: lib/elektrine_web/live/email_live/home.html.heex:521 +#, elixir-autogen, elixir-format +msgid "No email aliases yet" +msgstr "没有电子邮件别名" + +#: lib/elektrine_web/controllers/password_reset_html/new.html.heex:39 +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:112 +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:56 +#, elixir-autogen, elixir-format +msgid "OR" +msgstr "或" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:7 +#: lib/elektrine_web/controllers/user_settings_html/edit.html.heex:18 +#, elixir-autogen, elixir-format +msgid "Oops, something went wrong! Please check the errors below." +msgstr "哎呀,出错了!请检查以下错误。" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:210 +#: lib/elektrine_web/live/email_live/search.html.heex:87 +#: lib/elektrine_web/live/email_live/search.html.heex:210 +#, elixir-autogen, elixir-format +msgid "Page %{current} of %{total}" +msgstr "当前%{current} / %{total}页" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:21 +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:17 +#: lib/elektrine_web/live/user_settings_live.html.heex:234 +#, elixir-autogen, elixir-format +msgid "Password" +msgstr "密码" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:27 +#, elixir-autogen, elixir-format +msgid "Password must be at least 12 characters long" +msgstr "密码长度至少为 12 个字符" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:139 +#, elixir-autogen, elixir-format +msgid "Pending Account Deletion Requests" +msgstr "待处理的账户删除申请" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:100 +#, elixir-autogen, elixir-format +msgid "Please complete the captcha verification" +msgstr "请完成验证码确认" + +#: lib/elektrine_web/live/email_live/home.html.heex:1031 +#, elixir-autogen, elixir-format +msgid "Previous" +msgstr "上一页" + +#: lib/elektrine_web/controllers/user_settings_html/edit.html.heex:13 +#: lib/elektrine_web/live/user_settings_live.html.heex:100 +#, elixir-autogen, elixir-format +msgid "Profile Information" +msgstr "个人简介" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:12 +#, elixir-autogen, elixir-format +msgid "Quick Search" +msgstr "快速搜索" + +#: lib/elektrine_web/live/email_live/compose.html.heex:455 +#: lib/elektrine_web/live/email_live/compose.html.heex:669 +#, elixir-autogen, elixir-format +msgid "Quote" +msgstr "引用" + +#: lib/elektrine_web/live/email_live/show.html.heex:264 +#, elixir-autogen, elixir-format +msgid "Reply Later" +msgstr "稍后回复" + +#: lib/elektrine_web/live/email_live/compose.html.heex:28 +#, elixir-autogen, elixir-format +msgid "Reply to this message" +msgstr "回复此信息" + +#: lib/elektrine_web/controllers/password_reset_html/new.html.heex:3 +#, elixir-autogen, elixir-format +msgid "Reset Password" +msgstr "重置密码" + +#: lib/elektrine_web/live/email_live/show.html.heex:288 +#, elixir-autogen, elixir-format +msgid "Schedule Reply Later" +msgstr "稍后回复" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:33 +#, elixir-autogen, elixir-format +msgid "Search mailboxes..." +msgstr "搜索邮箱..." + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:47 +#: lib/elektrine_web/live/email_live/home.html.heex:99 +#, elixir-autogen, elixir-format +msgid "Search messages..." +msgstr "搜索信息..." + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:19 +#, elixir-autogen, elixir-format +msgid "Search users..." +msgstr "搜索用户..." + +#: lib/elektrine_web/components/layouts/app.html.heex:213 +#, elixir-autogen, elixir-format +msgid "Search..." +msgstr "搜索..." + +#: lib/elektrine_web/live/email_live/compose.html.heex:795 +#, elixir-autogen, elixir-format +msgid "Send Message" +msgstr "发送消息" + +#: lib/elektrine_web/controllers/password_reset_html/new.html.heex:34 +#, elixir-autogen, elixir-format +msgid "Send Reset Link" +msgstr "发送重置链接" + +#: lib/elektrine_web/live/email_live/compose.html.heex:32 +#, elixir-autogen, elixir-format +msgid "Send an email to someone" +msgstr "给某人发送邮件" + +#: lib/elektrine_web/live/email_live/home.html.heex:197 +#: lib/elektrine_web/live/email_live/home.html.heex:939 +#: lib/elektrine_web/live/email_live/show.html.heex:260 +#, elixir-autogen, elixir-format +msgid "Set Aside" +msgstr "暂存" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:59 +#: lib/elektrine_web/live/email_live/show.html.heex:301 +#, elixir-autogen, elixir-format +msgid "Tomorrow" +msgstr "明天" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:65 +#, elixir-autogen, elixir-format +msgid "Total Users" +msgstr "用户总数" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:13 +#: lib/elektrine_web/controllers/user_session_html/new.html.heex:10 +#: lib/elektrine_web/live/user_settings_live.html.heex:749 +#, elixir-autogen, elixir-format +msgid "Username" +msgstr "用户名" + +#: lib/elektrine_web/controllers/password_reset_html/new.html.heex:14 +#, elixir-autogen, elixir-format +msgid "Username or Recovery Email" +msgstr "用户名或恢复邮箱" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:5 +#, elixir-autogen, elixir-format +msgid "Welcome back, %{username}" +msgstr "欢迎回来,%{username}" + +#: lib/elektrine_web/live/email_live/show.html.heex:296 +#, elixir-autogen, elixir-format +msgid "When would you like to be reminded to reply to this message?" +msgstr "您希望何时被提醒回复此信息?" + +#: lib/elektrine_web/live/email_live/compose.html.heex:53 +#, elixir-autogen, elixir-format +msgid "You have %{count} emails remaining today." +msgstr "您今天还剩下 %{count} 封电子邮件未读。" + +#: lib/elektrine_web/live/email_live/compose.html.heex:50 +#, elixir-autogen, elixir-format +msgid "You have sent %{count} emails today." +msgstr "您今天发送了 %{count} 封电子邮件。" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:82 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:139 +#: lib/elektrine_web/live/email_live/home.html.heex:624 +#, elixir-autogen, elixir-format +msgid "Active" +msgstr "激活" + +#: lib/elektrine_web/live/email_live/home.html.heex:410 +#, elixir-autogen, elixir-format +msgid "All emails to %{email} and %{alt_email} are being forwarded to %{forward_to}" +msgstr "所有发送至 %{email} 和 %{alt_email} 的邮件均被转发至 %{forward_to}" + +#: lib/elektrine_web/live/email_live/home.html.heex:678 +#, elixir-autogen, elixir-format +msgid "Are you sure you want to delete this alias? This action cannot be undone." +msgstr "您确定要删除此别名吗?此操作无法撤销。" + +#: lib/elektrine_web/live/email_live/home.html.heex:430 +#, elixir-autogen, elixir-format +msgid "Create additional email addresses on @example.com, @example.net, or @example.org that can forward to specific destinations or deliver to your main mailbox" +msgstr "在 @example.com、@example.net 或 @example.org 上创建其他电子邮件地址,可转发到特定邮箱地或发送到您的主邮箱" + +#: lib/elektrine_web/live/email_live/home.html.heex:524 +#, elixir-autogen, elixir-format +msgid "Create your first email alias above to start organizing your email with dedicated addresses for different purposes." +msgstr "在这创建您的第一个电子邮件别名,不同的用途选择不同的别名。" + +#: lib/elektrine_web/live/email_live/home.html.heex:629 +#: lib/elektrine_web/live/user_settings_live.html.heex:276 +#, elixir-autogen, elixir-format +msgid "Disabled" +msgstr "已禁用" + +#: lib/elektrine_web/live/email_live/home.html.heex:388 +#, elixir-autogen, elixir-format +msgid "Enable Forwarding" +msgstr "启用转发" + +#: lib/elektrine_web/live/email_live/home.html.heex:364 +#, elixir-autogen, elixir-format +msgid "Forward all emails from %{email} and %{alt_email} to another address" +msgstr "将 %{email} 和 %{alt_email} 的所有电子邮件转发到另一个邮件地址" + +#: lib/elektrine_web/live/email_live/home.html.heex:380 +#, elixir-autogen, elixir-format +msgid "Forward to Email" +msgstr "转发到电子邮件" + +#: lib/elektrine_web/live/email_live/home.html.heex:480 +#, elixir-autogen, elixir-format +msgid "Leave empty to deliver emails to your main mailbox instead of forwarding" +msgstr "留空则将电子邮件发送到您的主邮箱,而不是转发" + +#: lib/elektrine_web/live/email_live/home.html.heex:401 +#, elixir-autogen, elixir-format +msgid "Update Settings" +msgstr "更新设置" + +#: lib/elektrine_web/live/email_live/home.html.heex:490 +#, elixir-autogen, elixir-format +msgid "e.g., Shopping accounts, newsletters, work projects..." +msgstr "例如,购物账户、通讯、工作项目......" + +#: lib/elektrine_web/live/email_live/home.html.heex:476 +#, elixir-autogen, elixir-format +msgid "your.email@example.com (leave empty to use main mailbox)" +msgstr "your.email@example.com (留空以使用主邮箱)" + +#: lib/elektrine_web/live/email_live/home.html.heex:381 +#, elixir-autogen, elixir-format +msgid "your.personal@example.com" +msgstr "your.personal@example.com" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:41 +#, elixir-autogen, elixir-format +msgid "%{count} mailbox found" +msgid_plural "%{count} mailboxes found" +msgstr[0] "找到 %{count} 个邮箱" + +#: lib/elektrine_web/live/email_live/search.html.heex:85 +#, elixir-autogen, elixir-format +msgid "%{count} result" +msgid_plural "%{count} results" +msgstr[0] "%{count} 个结果" + +#: lib/elektrine_web/live/email_live/home.html.heex:1147 +#, elixir-autogen, elixir-format +msgid "1 week" +msgstr "1周" + +#: lib/elektrine_web/live/email_live/home.html.heex:1155 +#, elixir-autogen, elixir-format +msgid "2 weeks" +msgstr "2周" + +#: lib/elektrine_web/live/email_live/home.html.heex:1139 +#, elixir-autogen, elixir-format +msgid "3 days" +msgstr "3天" + +#: lib/elektrine_web/live/email_live/home.ex:1540 +#, elixir-autogen, elixir-format +msgid "Archived messages" +msgstr "存档信息" + +#: lib/elektrine_web/live/email_live/show.html.heex:93 +#, elixir-autogen, elixir-format +msgid "Are you sure you want to delete this message?" +msgstr "您确定要删除这条信息吗?" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:271 +#, elixir-autogen, elixir-format +msgid "Attachment (%{count})" +msgid_plural "Attachments (%{count})" +msgstr[0] "附件(%{count})" + +#: lib/elektrine_web/live/email_live/show.html.heex:193 +#, elixir-autogen, elixir-format +msgid "Attachments (%{count})" +msgstr "附件 (%{count})" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:239 +#: lib/elektrine_web/live/email_live/show.html.heex:140 +#, elixir-autogen, elixir-format +msgid "BCC:" +msgstr "密送:" + +#: lib/elektrine_web/live/email_live/raw.html.heex:37 +#, elixir-autogen, elixir-format +msgid "Back to Email" +msgstr "返回到邮件" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:232 +#: lib/elektrine_web/live/email_live/show.html.heex:128 +#, elixir-autogen, elixir-format +msgid "CC:" +msgstr "抄送:" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:59 +#: lib/elektrine_web/live/vpn_live/home.html.heex:75 +#, elixir-autogen, elixir-format +msgid "Created" +msgstr "创建" + +#: lib/elektrine_web/live/email_live/raw.html.heex:57 +#, elixir-autogen, elixir-format +msgid "Created at:" +msgstr "创建于:" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:245 +#, elixir-autogen, elixir-format +msgid "Date:" +msgstr "日期:" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:91 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:175 +#, elixir-autogen, elixir-format +msgid "Delete orphaned mailbox" +msgstr "删除孤立邮箱" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:102 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:174 +#, elixir-autogen, elixir-format +msgid "Delete orphaned mailbox %{email}?" +msgstr "删除孤立邮箱 %{email}?" + +#: lib/elektrine_web/live/email_live/show.html.heex:220 +#: lib/elektrine_web/live/vpn_live/home.html.heex:240 +#, elixir-autogen, elixir-format +msgid "Download" +msgstr "下载" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:56 +#, elixir-autogen, elixir-format +msgid "Email Address" +msgstr "邮件地址" + +#: lib/elektrine_web/live/email_live/search.html.heex:234 +#, elixir-autogen, elixir-format +msgid "Enter keywords to find messages by sender, subject, or content." +msgstr "输入关键字,按发件人、主题或内容查找信息。" + +#: lib/elektrine_web/live/email_live/search.html.heex:23 +#, elixir-autogen, elixir-format +msgid "Find emails by sender, subject, or content" +msgstr "按发件人、主题或内容查找电子邮件" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:221 +#: lib/elektrine_web/live/email_live/raw.html.heex:63 +#: lib/elektrine_web/live/email_live/show.html.heex:107 +#: lib/elektrine_web/live/email_live/show.html.heex:291 +#, elixir-autogen, elixir-format +msgid "From:" +msgstr "来自:" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:68 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:132 +#, elixir-autogen, elixir-format +msgid "ID:" +msgstr "ID:" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:288 +#, elixir-autogen, elixir-format +msgid "Mailbox:" +msgstr "邮箱:" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:253 +#: lib/elektrine_web/live/email_live/raw.html.heex:52 +#, elixir-autogen, elixir-format +msgid "Message ID:" +msgstr "邮件 ID:" + +#: lib/elektrine_web/live/email_live/search.html.heex:220 +#, elixir-autogen, elixir-format +msgid "Next »" +msgstr "下一个 »" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:6 +#: lib/elektrine_web/controllers/email_html/print.html.heex:216 +#, elixir-autogen, elixir-format +msgid "No Subject" +msgstr "无主题" + +#: lib/elektrine_web/live/email_live/home.ex:1572 +#, elixir-autogen, elixir-format +msgid "No archived messages" +msgstr "无存档信息" + +#: lib/elektrine_web/live/email_live/search.html.heex:77 +#, elixir-autogen, elixir-format +msgid "No results found for \"%{query}\"" +msgstr "未找到\"%{query}\"的结果" + +#: lib/elektrine_web/live/email_live/home.ex:1570 +#, elixir-autogen, elixir-format +msgid "No sent messages" +msgstr "没有已发送的邮件" + +#: lib/elektrine_web/live/email_live/home.ex:1571 +#, elixir-autogen, elixir-format +msgid "No spam messages" +msgstr "无垃圾邮件" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:73 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:150 +#, elixir-autogen, elixir-format +msgid "No user association" +msgstr "未关联用户" + +#: lib/elektrine_web/live/email_live/home.html.heex:206 +#, elixir-autogen, elixir-format +msgid "Not Spam" +msgstr "不是垃圾邮件" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:72 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:149 +#, elixir-autogen, elixir-format +msgid "ORPHANED" +msgstr "孤立" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:4 +#, elixir-autogen, elixir-format +msgid "Overview of all user mailboxes in the system." +msgstr "系统中所有用户邮箱的概览。" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:57 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:146 +#, elixir-autogen, elixir-format +msgid "Owner" +msgstr "拥有者" + +#: lib/elektrine_web/live/email_live/show.html.heex:62 +#, elixir-autogen, elixir-format +msgid "Print" +msgstr "打印" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:6 +#, elixir-autogen, elixir-format +msgid "Print Email" +msgstr "打印邮件" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:286 +#, elixir-autogen, elixir-format +msgid "Printed from Elektrine Email System" +msgstr "从 Elektrine 电子邮件系统打印" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:110 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:181 +#, elixir-autogen, elixir-format +msgid "Protected" +msgstr "受保护" + +#: lib/elektrine_web/live/email_live/show.html.heex:69 +#, elixir-autogen, elixir-format +msgid "Raw" +msgstr "查看原文" + +#: lib/elektrine_web/live/email_live/raw.html.heex:82 +#, elixir-autogen, elixir-format +msgid "Raw Email Data" +msgstr "原始电子邮件数据" + +#: lib/elektrine_web/live/email_live/home.html.heex:279 +#, elixir-autogen, elixir-format +msgid "Read" +msgstr "阅读" + +#: lib/elektrine_web/live/email_live/show.html.heex:53 +#: lib/elektrine_web/live/email_live/show.html.heex:251 +#, elixir-autogen, elixir-format +msgid "Reply All" +msgstr "全部回复" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:432 +#: lib/elektrine_web/live/email_live/home.ex:1524 +#: lib/elektrine_web/live/email_live/home.html.heex:105 +#: lib/elektrine_web/live/email_live/search.html.heex:21 +#: lib/elektrine_web/live/email_live/search.html.heex:58 +#, elixir-autogen, elixir-format +msgid "Search" +msgstr "搜索" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:20 +#, elixir-autogen, elixir-format +msgid "Search by email address or username..." +msgstr "按电子邮件地址或用户名搜索..." + +#: lib/elektrine_web/live/email_live/search.html.heex:65 +#, elixir-autogen, elixir-format +msgid "Search tips:" +msgstr "搜索提示:" + +#: lib/elektrine_web/live/email_live/search.html.heex:232 +#, elixir-autogen, elixir-format +msgid "Search your emails" +msgstr "搜索您的电子邮件" + +#: lib/elektrine_web/live/email_live/search.html.heex:36 +#, elixir-autogen, elixir-format +msgid "Search your emails..." +msgstr "搜索您的电子邮件..." + +#: lib/elektrine_web/live/email_live/search.html.heex:56 +#, elixir-autogen, elixir-format +msgid "Searching..." +msgstr "搜索中" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:243 +#, elixir-autogen, elixir-format +msgid "Showing %{start}-%{end} of %{total} mailboxes" +msgstr "显示 %{start}-%{end}的 %{total}邮箱" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:40 +#, elixir-autogen, elixir-format +msgid "Showing results for \"%{query}\"" +msgstr "显示 \"%{query}\" 的结果" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:58 +#, elixir-autogen, elixir-format +msgid "Status" +msgstr "状态" + +#: lib/elektrine_web/live/email_live/raw.html.heex:71 +#, elixir-autogen, elixir-format +msgid "Subject:" +msgstr "主题:" + +#: lib/elektrine_web/controllers/email_html/print.html.heex:226 +#: lib/elektrine_web/live/email_live/raw.html.heex:67 +#: lib/elektrine_web/live/email_live/show.html.heex:117 +#, elixir-autogen, elixir-format +msgid "To:" +msgstr "收件人:" + +#: lib/elektrine_web/live/email_live/search.html.heex:79 +#, elixir-autogen, elixir-format +msgid "Try searching for:" +msgstr "尝试搜索:" + +#: lib/elektrine_web/live/email_live/search.html.heex:66 +#, elixir-autogen, elixir-format +msgid "Use multiple words to narrow results • Search works across sender, subject, and message content" +msgstr "使用多个单词缩小搜索结果范围 - 搜索范围涵盖发件人、主题和邮件内容" + +#: lib/elektrine_web/live/email_live/home.html.heex:890 +#: lib/elektrine_web/live/notifications_dropdown_live.ex:102 +#, elixir-autogen, elixir-format +msgid "View" +msgstr "查看" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:291 +#, elixir-autogen, elixir-format +msgid "Your Mailbox" +msgstr "您的邮箱" + +#: lib/elektrine_web/live/email_live/search.html.heex:85 +#, elixir-autogen, elixir-format +msgid "for \"%{query}\"" +msgstr "搜索关键词“%{query}”" + +#: lib/elektrine_web/live/email_live/search.html.heex:205 +#, elixir-autogen, elixir-format +msgid "« Previous" +msgstr "« 上一页" + +#: lib/elektrine_web/components/layouts/app.html.heex:444 +#, elixir-autogen, elixir-format +msgid "Admin Panel" +msgstr "管理面板" + +#: lib/elektrine_web/components/layouts/app.html.heex:441 +#, elixir-autogen, elixir-format +msgid "Administration" +msgstr "管理员" + +#: lib/elektrine_web/components/layouts/app.html.heex:467 +#, elixir-autogen, elixir-format +msgid "Log out" +msgstr "注销" + +#: lib/elektrine_web/components/layouts/app.html.heex:462 +#, elixir-autogen, elixir-format +msgid "Report Issue" +msgstr "报告问题" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:508 +#, elixir-autogen, elixir-format +msgid "Keyboard shortcuts (Shift + /)" +msgstr "键盘快捷键(Shift + /)" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:510 +#, elixir-autogen, elixir-format +msgid "Shortcuts" +msgstr "快捷键" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:236 +#, elixir-autogen, elixir-format +msgid "%{total} total mailboxes" +msgstr "共有 %{total} 个邮箱" + +#: lib/elektrine_web/live/email_live/home.html.heex:1131 +#, elixir-autogen, elixir-format +msgid "1 day" +msgstr "1天" + +#: lib/elektrine_web/live/email_live/home.html.heex:1007 +#, elixir-autogen, elixir-format +msgid "1 message" +msgid_plural "%{count} messages" +msgstr[0] "正文" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:101 +#, elixir-autogen, elixir-format +msgid "Active Users" +msgstr "活跃用户" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:728 +#, elixir-autogen, elixir-format +msgid "Admin Delete" +msgstr "管理员删除" + +#: lib/elektrine_web/live/email_live/home.html.heex:447 +#, elixir-autogen, elixir-format +msgid "Alias Username" +msgstr "别名用户名" + +#: lib/elektrine_web/live/email_live/home.ex:1588 +#, elixir-autogen, elixir-format +msgid "Archived messages will appear here" +msgstr "已归档的邮件将显示在这里" + +#: lib/elektrine_web/live/email_live/compose.html.heex:521 +#, elixir-autogen, elixir-format +msgid "Attachments to forward:" +msgstr "将要转发的附件:" + +#: lib/elektrine_web/live/email_live/compose.ex:1269 +#: lib/elektrine_web/live/email_live/show.ex:446 +#, elixir-autogen, elixir-format +msgid "Back to Aliases" +msgstr "返回到邮件" + +#: lib/elektrine_web/live/email_live/compose.ex:1261 +#: lib/elektrine_web/live/email_live/show.ex:438 +#, elixir-autogen, elixir-format +msgid "Back to Archive" +msgstr "返回归档" + +#: lib/elektrine_web/live/email_live/compose.ex:1268 +#: lib/elektrine_web/live/email_live/show.ex:445 +#, elixir-autogen, elixir-format +msgid "Back to Boomerang" +msgstr "返回定时提醒" + +#: lib/elektrine_web/live/email_live/compose.ex:1265 +#: lib/elektrine_web/live/email_live/show.ex:442 +#, elixir-autogen, elixir-format +msgid "Back to Bulk Mail" +msgstr "返回批量邮件" + +#: lib/elektrine_web/live/email_live/compose.ex:1266 +#: lib/elektrine_web/live/email_live/show.ex:443 +#, elixir-autogen, elixir-format +msgid "Back to Paper Trail" +msgstr "返回到邮件" + +#: lib/elektrine_web/live/email_live/compose.ex:1271 +#: lib/elektrine_web/live/email_live/show.ex:448 +#, elixir-autogen, elixir-format +msgid "Back to Read" +msgstr "返回到邮件" + +#: lib/elektrine_web/live/email_live/compose.ex:1262 +#: lib/elektrine_web/live/email_live/show.ex:439 +#, elixir-autogen, elixir-format +msgid "Back to Search" +msgstr "返回搜索" + +#: lib/elektrine_web/live/email_live/compose.ex:1259 +#: lib/elektrine_web/live/email_live/show.ex:436 +#, elixir-autogen, elixir-format +msgid "Back to Sent" +msgstr "返回收件箱" + +#: lib/elektrine_web/live/email_live/compose.ex:1260 +#: lib/elektrine_web/live/email_live/show.ex:437 +#, elixir-autogen, elixir-format +msgid "Back to Spam" +msgstr "返回到邮件" + +#: lib/elektrine_web/live/email_live/compose.ex:1267 +#: lib/elektrine_web/live/email_live/show.ex:444 +#, elixir-autogen, elixir-format +msgid "Back to The Pile" +msgstr "返回待办箱" + +#: lib/elektrine_web/live/email_live/compose.ex:1270 +#: lib/elektrine_web/live/email_live/show.ex:447 +#, elixir-autogen, elixir-format +msgid "Back to Unread" +msgstr "标记为未读" + +#: lib/elektrine_web/live/email_live/home.ex:1519 +#: lib/elektrine_web/live/email_live/home.html.heex:328 +#: lib/elektrine_web/live/email_live/home.html.heex:963 +#, elixir-autogen, elixir-format +msgid "Boomerang" +msgstr "定时提醒" + +#: lib/elektrine_web/components/layouts/app.html.heex:424 +#: lib/elektrine_web/components/platform/z_nav.ex:46 +#, elixir-autogen, elixir-format +msgid "Chat" +msgstr "聊天" + +#: lib/elektrine_web/live/email_live/home.html.heex:952 +#, elixir-autogen, elixir-format +msgid "Clear Boomerang" +msgstr "清除定时提醒" + +#: lib/elektrine_web/live/email_live/compose.html.heex:390 +#: lib/elektrine_web/live/email_live/compose.html.heex:604 +#, elixir-autogen, elixir-format +msgid "Code" +msgstr "代码" + +#: lib/elektrine_web/components/layouts/app.html.heex:434 +#: lib/elektrine_web/components/platform/z_nav.ex:58 +#: lib/elektrine_web/live/discussions_live/home.html.heex:28 +#, elixir-autogen, elixir-format +msgid "Communities" +msgstr "社区" + +#: lib/elektrine_web/live/email_live/home.html.heex:1080 +#, elixir-autogen, elixir-format +msgid "Compose Email" +msgstr "写信" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:680 +#, elixir-autogen, elixir-format +msgid "Copy Link" +msgstr "复制链接" + +#: lib/elektrine_web/live/discussions_live/home.html.heex:364 +#: lib/elektrine_web/live/discussions_live/home.html.heex:549 +#, elixir-autogen, elixir-format +msgid "Create Community" +msgstr "创建社区" + +#: lib/elektrine_web/live/email_live/home.ex:1590 +#, elixir-autogen, elixir-format +msgid "Create email aliases to organize your incoming mail and forward emails to different addresses." +msgstr "创建电子邮件别名以整理收件,并将邮件转发到不同的地址。" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:695 +#, elixir-autogen, elixir-format +msgid "Delete Post" +msgstr "删除帖子" + +#: lib/elektrine_web/live/email_live/home.html.heex:768 +#, elixir-autogen, elixir-format +msgid "Deselect all messages" +msgstr "取消选择所有消息" + +#: lib/elektrine_web/live/email_live/home.html.heex:666 +#, elixir-autogen, elixir-format +msgid "Disable" +msgstr "已禁用" + +#: lib/elektrine_web/live/email_live/show.html.heex:275 +#, elixir-autogen, elixir-format +msgid "Download EML" +msgstr "下载EML" + +#: lib/elektrine_web/live/email_live/home.html.heex:669 +#, elixir-autogen, elixir-format +msgid "Enable" +msgstr "启用" + +#: lib/elektrine_web/live/email_live/compose.html.heex:325 +#, elixir-autogen, elixir-format +msgid "Enter email subject (optional)" +msgstr "输入您的邮件主题(可选)" + +#: lib/elektrine_web/live/email_live/home.html.heex:114 +#, elixir-autogen, elixir-format +msgid "Found %{count} result for \"%{query}\"" +msgid_plural "Found %{count} results for \"%{query}\"" +msgstr[0] "找到 %{count} 条关于“%{query}”的结果" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:111 +#, elixir-autogen, elixir-format +msgid "Last 24 Hours" +msgstr "过去 24 小时" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:119 +#, elixir-autogen, elixir-format +msgid "Last 30 Days" +msgstr "过去 30 天" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:115 +#, elixir-autogen, elixir-format +msgid "Last 7 Days" +msgstr "过去 7 天" + +#: lib/elektrine_web/live/email_live/home.ex:1542 +#, elixir-autogen, elixir-format +msgid "Manage your email aliases and forwarding rules" +msgstr "管理您的邮箱别名和转发规则" + +#: lib/elektrine_web/live/email_live/home.ex:1538 +#, elixir-autogen, elixir-format +msgid "Messages you have sent" +msgstr "已发送邮件" + +#: lib/elektrine_web/live/email_live/home.ex:1586 +#, elixir-autogen, elixir-format +msgid "Messages you send will appear here" +msgstr "您发送的邮件会显示在此处" + +#: lib/elektrine_web/live/email_live/home.ex:1537 +#, elixir-autogen, elixir-format +msgid "Messages you've scheduled to be reminded about for replies" +msgstr "您设定提醒以便稍后回复的邮件" + +#: lib/elektrine_web/live/discussions_live/home.html.heex:66 +#, elixir-autogen, elixir-format +msgid "My Communities" +msgstr "我的社区" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:123 +#, elixir-autogen, elixir-format +msgid "Never Logged In" +msgstr "从未登录" + +#: lib/elektrine_web/live/discussions_live/home.html.heex:15 +#: lib/elektrine_web/live/discussions_live/home.html.heex:46 +#, elixir-autogen, elixir-format +msgid "New Community" +msgstr "新建社区" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:44 +#: lib/elektrine_web/live/timeline_live/home.html.heex:116 +#, elixir-autogen, elixir-format +msgid "New Post" +msgstr "新帖子" + +#: lib/elektrine_web/live/email_live/home.html.heex:1015 +#: lib/elektrine_web/live/email_live/home.html.heex:1056 +#, elixir-autogen, elixir-format +msgid "Next page" +msgstr "下一页" + +#: lib/elektrine_web/live/email_live/home.ex:1574 +#, elixir-autogen, elixir-format +msgid "No email aliases" +msgstr "没有电子邮件别名" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:193 +#, elixir-autogen, elixir-format +msgid "No mailboxes found" +msgstr "未找到邮箱" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:198 +#, elixir-autogen, elixir-format +msgid "No mailboxes have been created yet." +msgstr "尚未创建任何邮箱。" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:196 +#, elixir-autogen, elixir-format +msgid "No mailboxes match your search criteria." +msgstr "没有符合搜索条件的邮箱。" + +#: lib/elektrine_web/live/email_live/home.ex:1575 +#, elixir-autogen, elixir-format +msgid "No messages" +msgstr "尚无留言" + +#: lib/elektrine_web/live/email_live/home.ex:1565 +#, elixir-autogen, elixir-format +msgid "No messages in your inbox" +msgstr "收件箱暂无邮件" + +#: lib/elektrine_web/live/email_live/home.ex:1573 +#, elixir-autogen, elixir-format +msgid "No search results" +msgstr "没有搜索结果" + +#: lib/elektrine_web/live/email_live/home.ex:1569 +#, elixir-autogen, elixir-format +msgid "Nothing scheduled" +msgstr "暂无安排" + +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:80 +#: lib/elektrine_web/controllers/admin_html/mailboxes.html.heex:137 +#, elixir-autogen, elixir-format +msgid "Orphaned" +msgstr "孤立" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:340 +#, elixir-autogen, elixir-format +msgid "Post" +msgstr "发布" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:1175 +#, elixir-autogen, elixir-format +msgid "Post your reply..." +msgstr "发表你的回复..." + +#: lib/elektrine_web/live/email_live/home.html.heex:1001 +#, elixir-autogen, elixir-format +msgid "Prev" +msgstr "上一页" + +#: lib/elektrine_web/live/email_live/home.html.heex:998 +#: lib/elektrine_web/live/email_live/home.html.heex:1028 +#, elixir-autogen, elixir-format +msgid "Previous page" +msgstr "上一页" + +#: lib/elektrine_web/live/email_live/home.html.heex:1123 +#, elixir-autogen, elixir-format +msgid "Remind me to reply in:" +msgstr "提醒我在以下时间内回复:" + +#: lib/elektrine_web/live/email_live/home.html.heex:190 +#: lib/elektrine_web/live/email_live/home.html.heex:928 +#, elixir-autogen, elixir-format +msgid "Remove from Pile" +msgstr "从待办箱移出" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:756 +#: lib/elektrine_web/live/timeline_live/home.html.heex:1131 +#, elixir-autogen, elixir-format +msgid "Replying to" +msgstr "回复" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:708 +#, elixir-autogen, elixir-format +msgid "Report Post" +msgstr "举报帖子" + +#: lib/elektrine_web/live/email_live/home.html.heex:1099 +#, elixir-autogen, elixir-format +msgid "Schedule Reply" +msgstr "稍后回复" + +#: lib/elektrine_web/live/email_live/home.html.heex:1112 +#, elixir-autogen, elixir-format +msgid "Schedule a reminder to reply to this message:" +msgstr "为此邮件设置回复提醒:" + +#: lib/elektrine_web/live/email_live/home.ex:1585 +#, elixir-autogen, elixir-format +msgid "Schedule reminders for emails that need replies. Use the 'Boomerang' action on any message to set a follow-up reminder." +msgstr "为需要回复的邮件安排定时提醒。对任何邮件使用「定时提醒」操作即可设置后续提醒。" + +#: lib/elektrine_web/live/email_live/home.html.heex:38 +#, elixir-autogen, elixir-format +msgid "Search Email" +msgstr "搜索邮箱" + +#: lib/elektrine_web/live/email_live/home.ex:1541 +#: lib/elektrine_web/live/email_live/home.html.heex:40 +#, elixir-autogen, elixir-format +msgid "Search through your messages" +msgstr "在邮件中搜索" + +#: lib/elektrine_web/live/email_live/home.html.heex:770 +#, elixir-autogen, elixir-format +msgid "Select all messages" +msgstr "选择所有消息" + +#: lib/elektrine_web/live/discussions_live/home.html.heex:564 +#: lib/elektrine_web/live/discussions_live/home.html.heex:726 +#, elixir-autogen, elixir-format +msgid "Sign In" +msgstr "登录" + +#: lib/elektrine_web/live/email_live/home.ex:1539 +#, elixir-autogen, elixir-format +msgid "Spam and junk messages" +msgstr "垃圾邮件和无用信息" + +#: lib/elektrine_web/live/email_live/home.ex:1587 +#, elixir-autogen, elixir-format +msgid "Spam messages will be moved here automatically" +msgstr "垃圾邮件会自动移到这里" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:331 +#, elixir-autogen, elixir-format +msgid "Storage Used" +msgstr "已用存储" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:373 +#, elixir-autogen, elixir-format +msgid "Storage limit exceeded" +msgstr "存储容量已超出" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:378 +#, elixir-autogen, elixir-format +msgid "Storage nearly full" +msgstr "存储容量即将用尽" + +#: lib/elektrine_web/live/email_live/compose.html.heex:543 +#, elixir-autogen, elixir-format +msgid "These attachments from the original message will be included when you send this forwarded email." +msgstr "发送此转发邮件时将包含原邮件中的这些附件。" + +#: lib/elektrine_web/components/layouts/app.html.heex:429 +#: lib/elektrine_web/components/platform/z_nav.ex:50 +#, elixir-autogen, elixir-format +msgid "Timeline" +msgstr "动态" + +#: lib/elektrine_web/live/email_live/home.ex:1589 +#, elixir-autogen, elixir-format +msgid "Try searching for a specific term" +msgstr "尝试搜索:" + +#: lib/elektrine_web/live/email_live/home.html.heex:269 +#: lib/elektrine_web/live/notifications_live.html.heex:54 +#, elixir-autogen, elixir-format +msgid "Unread" +msgstr "未读" + +#: lib/elektrine_web/controllers/admin_html/dashboard.html.heex:106 +#, elixir-autogen, elixir-format +msgid "View All" +msgstr "查看全部" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:668 +#, elixir-autogen, elixir-format +msgid "View Post" +msgstr "查看帖子" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:284 +#, elixir-autogen, elixir-format +msgid "What's on your mind?" +msgstr "你在想什么?" + +#: lib/elektrine_web/live/email_live/home.ex:1581 +#, elixir-autogen, elixir-format +msgid "When you receive emails, they will appear here" +msgstr "当您收到邮件时,它们会显示在这里" + +#: lib/elektrine_web/live/email_live/compose.html.heex:537 +#, elixir-autogen, elixir-format +msgid "Will be forwarded" +msgstr "将被转发" + +#: lib/elektrine_web/live/email_live/home.ex:1544 +#, elixir-autogen, elixir-format +msgid "Your email messages" +msgstr "您的邮件" + +#: lib/elektrine_web/live/email_live/home.ex:1533 +#, elixir-autogen, elixir-format +msgid "Your incoming messages" +msgstr "您的来信" + +#: lib/elektrine_web/live/email_live/home.ex:1591 +#, elixir-autogen, elixir-format +msgid "Your messages will appear here" +msgstr "您的消息会显示在这里" + +#: lib/elektrine_web/live/email_live/home.html.heex:453 +#, elixir-autogen, elixir-format +msgid "shopping, newsletter, work" +msgstr "购物、简报、工作" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:549 +#, elixir-autogen, elixir-format +msgid "ACTIVE" +msgstr "进行中" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:535 +#, elixir-autogen, elixir-format +msgid "Atoms" +msgstr "原子数" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:100 +#, elixir-autogen, elixir-format +msgid "Available on @example.com, @example.net, and @example.org" +msgstr "@example.com、@example.net 和 @example.org 均可用" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:577 +#, elixir-autogen, elixir-format +msgid "Business account features" +msgstr "企业账户功能" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:195 +#: lib/elektrine_web/controllers/page_html/home.html.heex:283 +#, elixir-autogen, elixir-format +msgid "CHAT" +msgstr "聊天" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:163 +#, elixir-autogen, elixir-format +msgid "CREATE ACCOUNT" +msgstr "创建账户" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:361 +#: lib/elektrine_web/controllers/page_html/home.html.heex:484 +#, elixir-autogen, elixir-format +msgid "Chat Messages" +msgstr "聊天消息数" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:96 +#: lib/elektrine_web/controllers/page_html/home.html.heex:131 +#, elixir-autogen, elixir-format +msgid "EMAIL" +msgstr "邮箱" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:357 +#: lib/elektrine_web/controllers/page_html/home.html.heex:480 +#, elixir-autogen, elixir-format +msgid "Email Messages" +msgstr "邮件数" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:565 +#, elixir-autogen, elixir-format +msgid "Email automation rules" +msgstr "邮件自动化规则" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:573 +#, elixir-autogen, elixir-format +msgid "FUTURE" +msgstr "未来" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:206 +#, elixir-autogen, elixir-format +msgid "Follow friends" +msgstr "关注好友" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:548 +#, elixir-autogen, elixir-format +msgid "IN PROGRESS" +msgstr "开发中" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:349 +#: lib/elektrine_web/controllers/page_html/home.html.heex:472 +#, elixir-autogen, elixir-format +msgid "Launch Date" +msgstr "上线日期" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:207 +#, elixir-autogen, elixir-format +msgid "Like & comment" +msgstr "点赞与评论" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:143 +#, elixir-autogen, elixir-format +msgid "MY ACCOUNT" +msgstr "我的账户" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:388 +#: lib/elektrine_web/controllers/page_html/home.html.heex:511 +#, elixir-autogen, elixir-format +msgid "Memory" +msgstr "内存" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:578 +#, elixir-autogen, elixir-format +msgid "Mini-app ecosystem" +msgstr "小程序生态" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:553 +#, elixir-autogen, elixir-format +msgid "Mobile app (iOS/Android)" +msgstr "移动应用(iOS/Android)" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:561 +#, elixir-autogen, elixir-format +msgid "NEXT" +msgstr "下一阶段" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:560 +#, elixir-autogen, elixir-format +msgid "PLANNED" +msgstr "规划中" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:346 +#: lib/elektrine_web/controllers/page_html/home.html.heex:469 +#, elixir-autogen, elixir-format +msgid "PLATFORM" +msgstr "平台" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:392 +#: lib/elektrine_web/controllers/page_html/home.html.heex:515 +#, elixir-autogen, elixir-format +msgid "Processes" +msgstr "进程数" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:543 +#, elixir-autogen, elixir-format +msgid "ROADMAP" +msgstr "路线图" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:157 +#, elixir-autogen, elixir-format +msgid "SIGN IN" +msgstr "登录" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:574 +#, elixir-autogen, elixir-format +msgid "SOMEDAY" +msgstr "未来某天" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:381 +#: lib/elektrine_web/controllers/page_html/home.html.heex:504 +#, elixir-autogen, elixir-format +msgid "SYSTEM" +msgstr "系统" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:205 +#, elixir-autogen, elixir-format +msgid "Share posts & updates" +msgstr "分享帖子与动态" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:203 +#: lib/elektrine_web/controllers/page_html/home.html.heex:289 +#, elixir-autogen, elixir-format +msgid "TIMELINE" +msgstr "动态" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:365 +#: lib/elektrine_web/controllers/page_html/home.html.heex:488 +#, elixir-autogen, elixir-format +msgid "Timeline Posts" +msgstr "动态数" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:438 +#, elixir-autogen, elixir-format +msgid "UPDATES" +msgstr "更新" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:552 +#, elixir-autogen, elixir-format +msgid "Z social platform" +msgstr "Z 社交平台" + +#: lib/elektrine_web/live/user_settings_live.html.heex:200 +#, elixir-autogen, elixir-format +msgid "Account Recovery" +msgstr "账户恢复" + +#: lib/elektrine_web/live/email_live/compose.html.heex:158 +#, elixir-autogen, elixir-format +msgid "Add CC/BCC" +msgstr "添加抄送/密送" + +#: lib/elektrine_web/live/notifications_live.html.heex:47 +#: lib/elektrine_web/live/timeline_live/home.html.heex:380 +#: lib/elektrine_web/live/timeline_live/home.html.heex:389 +#, elixir-autogen, elixir-format +msgid "All" +msgstr "全部" + +#: lib/elektrine_web/live/settings_live/app_passwords.ex:76 +#: lib/elektrine_web/live/user_settings_live.html.heex:297 +#, elixir-autogen, elixir-format +msgid "App Passwords" +msgstr "应用密码" + +#: lib/elektrine_web/live/email_live/show.html.heex:84 +#, elixir-autogen, elixir-format +msgid "Are you sure you want to permanently delete this message? This cannot be undone." +msgstr "您确定要永久删除此邮件吗?此操作无法撤销。" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:310 +#, elixir-autogen, elixir-format +msgid "BLOG.ELEKTRINE.NET" +msgstr "BLOG.ELEKTRINE.NET" + +#: lib/elektrine_web/live/email_live/compose.html.heex:372 +#: lib/elektrine_web/live/email_live/compose.html.heex:586 +#, elixir-autogen, elixir-format +msgid "Bold (Ctrl+B)" +msgstr "粗体 (Ctrl+B)" + +#: lib/elektrine_web/live/email_live/compose.html.heex:422 +#: lib/elektrine_web/live/email_live/compose.html.heex:636 +#, elixir-autogen, elixir-format +msgid "Bullet list" +msgstr "无序列表" + +#: lib/elektrine_web/live/email_live/compose.html.heex:464 +#: lib/elektrine_web/live/email_live/compose.html.heex:678 +#, elixir-autogen, elixir-format +msgid "Code block" +msgstr "代码块" + +#: lib/elektrine_web/live/email_live/home.html.heex:227 +#: lib/elektrine_web/live/email_live/show.html.heex:86 +#, elixir-autogen, elixir-format +msgid "Delete Permanently" +msgstr "永久删除" + +#: lib/elektrine_web/live/email_live/home.ex:1516 +#: lib/elektrine_web/live/email_live/home.html.heex:289 +#, elixir-autogen, elixir-format +msgid "Digest" +msgstr "摘要" + +#: lib/elektrine_web/live/notifications_dropdown_live.ex:121 +#: lib/elektrine_web/live/notifications_live.html.heex:301 +#, elixir-autogen, elixir-format +msgid "Dismiss" +msgstr "关闭" + +#: lib/elektrine_web/live/email_live/home.html.heex:655 +#, elixir-autogen, elixir-format +msgid "Edit Forward-To" +msgstr "编辑转发地址" + +#: lib/elektrine_web/live/email_live/home.html.heex:561 +#: lib/elektrine_web/live/user_settings_live.html.heex:260 +#, elixir-autogen, elixir-format +msgid "Enabled" +msgstr "已启用" + +#: lib/elektrine_web/live/email_live/compose.html.heex:440 +#: lib/elektrine_web/live/email_live/compose.html.heex:654 +#, elixir-autogen, elixir-format +msgid "Insert link" +msgstr "插入链接" + +#: lib/elektrine_web/live/email_live/compose.html.heex:381 +#: lib/elektrine_web/live/email_live/compose.html.heex:595 +#, elixir-autogen, elixir-format +msgid "Italic (Ctrl+I)" +msgstr "斜体 (Ctrl+I)" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:405 +#, elixir-autogen, elixir-format +msgid "LATEST UPDATES" +msgstr "最新动态" + +#: lib/elektrine_web/live/email_live/home.html.heex:553 +#, elixir-autogen, elixir-format +msgid "Leave empty for main mailbox" +msgstr "留空则使用主邮箱" + +#: lib/elektrine_web/live/email_live/home.ex:1517 +#: lib/elektrine_web/live/email_live/home.html.heex:302 +#, elixir-autogen, elixir-format +msgid "Ledger" +msgstr "账本" + +#: lib/elektrine_web/live/settings_live/app_passwords.ex:77 +#, elixir-autogen, elixir-format +msgid "Manage app-specific passwords for email clients" +msgstr "管理邮件客户端的应用专用密码" + +#: lib/elektrine_web/live/notifications_live.html.heex:25 +#: lib/elektrine_web/live/notifications_live.html.heex:129 +#: lib/elektrine_web/live/notifications_live.html.heex:214 +#, elixir-autogen, elixir-format +msgid "Mark all as read" +msgstr "全部标记为已读" + +#: lib/elektrine_web/live/notifications_dropdown_live.ex:42 +#: lib/elektrine_web/live/notifications_dropdown_live.ex:45 +#, elixir-autogen, elixir-format +msgid "Mark all read" +msgstr "全部标记为已读" + +#: lib/elektrine_web/live/email_live/home.html.heex:59 +#: lib/elektrine_web/live/email_live/home.html.heex:167 +#, elixir-autogen, elixir-format +msgid "Mark as Read" +msgstr "标记为已读" + +#: lib/elektrine_web/live/email_live/raw.html.heex:46 +#, elixir-autogen, elixir-format +msgid "Message Metadata" +msgstr "邮件元数据" + +#: lib/elektrine_web/live/email_live/home.ex:1536 +#, elixir-autogen, elixir-format +msgid "Messages you've manually saved to process when you have more time" +msgstr "您特意留待有空时处理的邮件" + +#: lib/elektrine_web/live/email_live/home.ex:1534 +#, elixir-autogen, elixir-format +msgid "Newsletters, updates, and automated messages from services and subscriptions" +msgstr "来自服务和订阅的时事通讯、更新和自动消息" + +#: lib/elektrine_web/live/email_live/home.ex:1566 +#, elixir-autogen, elixir-format +msgid "No digest emails" +msgstr "暂无摘要邮件" + +#: lib/elektrine_web/live/notifications_dropdown_live.ex:62 +#, elixir-autogen, elixir-format +msgid "No notifications" +msgstr "无通知" + +#: lib/elektrine_web/live/notifications_live.html.heex:71 +#, elixir-autogen, elixir-format +msgid "No notifications yet" +msgstr "暂无通知" + +#: lib/elektrine_web/live/email_live/home.ex:1567 +#, elixir-autogen, elixir-format +msgid "No receipts or records" +msgstr "暂无收据或记录" + +#: lib/elektrine_web/live/notifications_live.html.heex:69 +#, elixir-autogen, elixir-format +msgid "No unread notifications" +msgstr "暂无未读通知" + +#: lib/elektrine_web/live/email_live/compose.html.heex:489 +#: lib/elektrine_web/live/email_live/compose.html.heex:702 +#, elixir-autogen, elixir-format +msgid "Nothing to preview yet..." +msgstr "暂无预览内容..." + +#: lib/elektrine_web/components/layouts/app.html.heex:352 +#: lib/elektrine_web/components/notification/bell.ex:20 +#: lib/elektrine_web/components/notification/bell.ex:42 +#: lib/elektrine_web/live/notifications_dropdown_live.ex:22 +#: lib/elektrine_web/live/notifications_dropdown_live.ex:36 +#: lib/elektrine_web/live/notifications_live.html.heex:7 +#: lib/elektrine_web/live/user_settings_live.html.heex:56 +#: lib/elektrine_web/live/user_settings_live.html.heex:609 +#, elixir-autogen, elixir-format +msgid "Notifications" +msgstr "通知" + +#: lib/elektrine_web/live/email_live/compose.html.heex:431 +#: lib/elektrine_web/live/email_live/compose.html.heex:645 +#, elixir-autogen, elixir-format +msgid "Numbered list" +msgstr "有序列表" + +#: lib/elektrine_web/live/email_live/raw.html.heex:29 +#, elixir-autogen, elixir-format +msgid "Original email data and metadata" +msgstr "原始邮件数据和元数据" + +#: lib/elektrine_web/live/email_live/compose.html.heex:355 +#: lib/elektrine_web/live/email_live/compose.html.heex:569 +#, elixir-autogen, elixir-format +msgid "Preview" +msgstr "预览" + +#: lib/elektrine_web/live/user_settings_live.html.heex:320 +#, elixir-autogen, elixir-format +msgid "Privacy Settings" +msgstr "隐私设置" + +#: lib/elektrine_web/live/user_settings_live.html.heex:151 +#, elixir-autogen, elixir-format +msgid "Public Profile" +msgstr "公开资料" + +#: lib/elektrine_web/live/email_live/raw.html.heex:26 +#, elixir-autogen, elixir-format +msgid "Raw Message" +msgstr "原始邮件" + +#: lib/elektrine_web/live/email_live/home.ex:1535 +#, elixir-autogen, elixir-format +msgid "Receipts, invoices, and financial records automatically filed for you" +msgstr "自动为您归档的收据、发票和财务记录" + +#: lib/elektrine_web/live/email_live/home.html.heex:221 +#: lib/elektrine_web/live/email_live/show.html.heex:78 +#, elixir-autogen, elixir-format +msgid "Recover" +msgstr "恢复" + +#: lib/elektrine_web/live/email_live/home.html.heex:573 +#, elixir-autogen, elixir-format +msgid "Save" +msgstr "保存" + +#: lib/elektrine_web/live/email_live/compose.html.heex:359 +#: lib/elektrine_web/live/email_live/compose.html.heex:573 +#, elixir-autogen, elixir-format +msgid "Split" +msgstr "分栏" + +#: lib/elektrine_web/live/email_live/home.ex:1518 +#: lib/elektrine_web/live/email_live/home.html.heex:315 +#, elixir-autogen, elixir-format +msgid "Stack" +msgstr "待办箱" + +#: lib/elektrine_web/live/email_live/home.ex:1568 +#, elixir-autogen, elixir-format +msgid "Stack is empty" +msgstr "待办箱为空" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:456 +#: lib/elektrine_web/live/email_live/home.ex:1522 +#, elixir-autogen, elixir-format +msgid "Trash" +msgstr "回收站" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:314 +#, elixir-autogen, elixir-format +msgid "VIEW ALL" +msgstr "查看全部" + +#: lib/elektrine_web/live/notifications_dropdown_live.ex:141 +#, elixir-autogen, elixir-format +msgid "View all notifications" +msgstr "查看所有通知" + +#: lib/elektrine_web/live/email_live/compose.html.heex:351 +#: lib/elektrine_web/live/email_live/compose.html.heex:565 +#, elixir-autogen, elixir-format +msgid "Write" +msgstr "撰写" + +#: lib/elektrine_web/live/email_live/home.ex:1583 +#, elixir-autogen, elixir-format +msgid "Your automatic filing cabinet for receipts, invoices, and financial records. Never lose an important transaction again." +msgstr "您的收据、发票和财务记录自动归档柜。不再丢失重要交易记录。" + +#: lib/elektrine_web/live/email_live/home.ex:1582 +#, elixir-autogen, elixir-format +msgid "Your daily digest of newsletters, updates, and notifications from subscriptions and services - all automatically organized in one place." +msgstr "您的时事通讯、更新和通知的每日摘要,来自订阅和服务——全部自动整理在一个地方。" + +#: lib/elektrine_web/live/email_live/home.ex:1584 +#, elixir-autogen, elixir-format +msgid "Your personal stack of saved emails. Use the 'Stack' action to save messages here for when you have more time." +msgstr "您的个人邮件待办箱。使用「待办箱」操作将邮件保存到这里,有空时再处理。" + +#: lib/elektrine_web/live/notifications_live.html.heex:11 +#, elixir-autogen, elixir-format +msgid "unread" +msgid_plural "unread" +msgstr[0] "未读" + +#: lib/elektrine_web/live/email_live/compose.html.heex:716 +#, elixir-autogen, elixir-format +msgid "Attachments" +msgstr "附件" + +#: lib/elektrine_web/live/email_live/compose.html.heex:737 +#, elixir-autogen, elixir-format +msgid "Choose Files" +msgstr "选择文件" + +#: lib/elektrine_web/live/email_live/compose.html.heex:733 +#, elixir-autogen, elixir-format +msgid "Drag and drop files here or click to browse" +msgstr "拖放文件到此处或点击浏览" + +#: lib/elektrine_web/live/email_live/compose.ex:1359 +#, elixir-autogen, elixir-format +msgid "File is too large (max 10MB)" +msgstr "文件过大(最大 10MB)" + +#: lib/elektrine_web/live/email_live/compose.ex:1361 +#, elixir-autogen, elixir-format +msgid "File type not accepted" +msgstr "不支持的文件类型" + +#: lib/elektrine_web/live/email_live/home.html.heex:832 +#: lib/elektrine_web/live/email_live/search.html.heex:127 +#, elixir-autogen, elixir-format +msgid "Has attachments" +msgstr "包含附件" + +#: lib/elektrine_web/live/email_live/compose.html.heex:765 +#, elixir-autogen, elixir-format +msgid "Remove" +msgstr "移除" + +#: lib/elektrine_web/live/email_live/compose.ex:1360 +#, elixir-autogen, elixir-format +msgid "Too many files (max 5)" +msgstr "文件过多(最多 5 个)" + +#: lib/elektrine_web/live/email_live/compose.ex:1362 +#, elixir-autogen, elixir-format +msgid "Upload error: %{error}" +msgstr "上传错误:%{error}" + +#: lib/elektrine_web/live/user_settings_live.html.heex:776 +#, elixir-autogen, elixir-format +msgid "%{count} configured" +msgstr "已配置 %{count} 个" + +#: lib/elektrine_web/live/user_settings_live.html.heex:586 +#, elixir-autogen, elixir-format +msgid "12-hour (3:45 PM)" +msgstr "12小时制 (下午 3:45)" + +#: lib/elektrine_web/live/user_settings_live.html.heex:587 +#, elixir-autogen, elixir-format +msgid "24-hour (15:45)" +msgstr "24小时制 (15:45)" + +#: lib/elektrine_web/live/user_settings_live.html.heex:300 +#, elixir-autogen, elixir-format +msgid "Access your email from POP3/IMAP clients without 2FA." +msgstr "从 POP3/IMAP 客户端访问您的邮件,无需 2FA 验证" + +#: lib/elektrine_web/live/user_settings_live.html.heex:917 +#, elixir-autogen, elixir-format +msgid "Account deletion pending" +msgstr "账户删除待处理" + +#: lib/elektrine_web/live/user_settings_live.html.heex:279 +#, elixir-autogen, elixir-format +msgid "Add an extra layer of security to your account." +msgstr "为账户增加一层额外的安全保护" + +#: lib/elektrine_web/live/email_live/compose.html.heex:113 +#: lib/elektrine_web/live/email_live/compose.html.heex:193 +#: lib/elektrine_web/live/email_live/compose.html.heex:259 +#, elixir-autogen, elixir-format +msgid "Add email..." +msgstr "添加邮箱..." + +#: lib/elektrine_web/live/user_settings_live.html.heex:572 +#, elixir-autogen, elixir-format +msgid "All times will be displayed in your selected timezone" +msgstr "所有时间将以您选择的时区显示" + +#: lib/elektrine_web/live/email_live/compose.html.heex:722 +#, elixir-autogen, elixir-format +msgid "Allowed: Images, PDFs, Office docs, Text, Archives" +msgstr "允许:图片、PDF、Office文档、文本、压缩包" + +#: lib/elektrine_web/live/notifications_live.html.heex:31 +#, elixir-autogen, elixir-format +msgid "Are you sure you want to clear all notifications?" +msgstr "确定要清除所有通知吗?" + +#: lib/elektrine_web/live/user_settings_live.html.heex:552 +#, elixir-autogen, elixir-format +msgid "Auto-detect (browser timezone)" +msgstr "自动检测(浏览器时区)" + +#: lib/elektrine_web/live/user_settings_live.html.heex:560 +#, elixir-autogen, elixir-format +msgid "Berlin" +msgstr "柏林" + +#: lib/elektrine_web/live/user_settings_live.html.heex:159 +#, elixir-autogen, elixir-format +msgid "Can change in %{days} days" +msgstr "可在 %{days} 天后更改" + +#: lib/elektrine_web/live/email_live/show.html.heex:179 +#, elixir-autogen, elixir-format +msgid "Cancel scheduled return" +msgstr "取消定时返回" + +#: lib/elektrine_web/live/user_settings_live.html.heex:555 +#, elixir-autogen, elixir-format +msgid "Central Time (US & Canada)" +msgstr "中部时间(美国&加拿大)" + +#: lib/elektrine_web/live/user_settings_live.html.heex:244 +#, elixir-autogen, elixir-format +msgid "Change Password" +msgstr "修改密码" + +#: lib/elektrine_web/live/user_settings_live.html.heex:591 +#, elixir-autogen, elixir-format +msgid "Choose how times are displayed" +msgstr "选择时间显示方式" + +#: lib/elektrine_web/live/user_settings_live.html.heex:622 +#, elixir-autogen, elixir-format +msgid "Choose which events you want to be notified about" +msgstr "选择您希望接收通知的事件" + +#: lib/elektrine_web/live/user_settings_live.html.heex:536 +#, elixir-autogen, elixir-format +msgid "Choose your preferred language for the interface" +msgstr "选择您偏好的界面语言" + +#: lib/elektrine_web/live/notifications_live.html.heex:34 +#, elixir-autogen, elixir-format +msgid "Clear all" +msgstr "全部清除" + +#: lib/elektrine_web/live/user_settings_live.html.heex:553 +#, elixir-autogen, elixir-format +msgid "Common Timezones" +msgstr "常用时区" + +#: lib/elektrine_web/live/user_settings_live.html.heex:434 +#, elixir-autogen, elixir-format +msgid "Control who can @mention you in posts and messages" +msgstr "控制谁可以在帖子和消息中 @提及您" + +#: lib/elektrine_web/live/user_settings_live.html.heex:348 +#, elixir-autogen, elixir-format +msgid "Control who can add you to group conversations and channels" +msgstr "控制谁可以将您加入群聊和频道" + +#: lib/elektrine_web/live/user_settings_live.html.heex:371 +#, elixir-autogen, elixir-format +msgid "Control who can start direct message conversations with you" +msgstr "控制谁可以与您开始私信对话" + +#: lib/elektrine_web/live/user_settings_live.html.heex:475 +#, elixir-autogen, elixir-format +msgid "Control who can view your profile page" +msgstr "控制谁可以查看您的个人资料页面" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:303 +#: lib/elektrine_web/live/email_live/email_helpers.ex:321 +#, elixir-autogen, elixir-format +msgid "Copy to clipboard" +msgstr "复制到剪贴板" + +#: lib/elektrine_web/live/user_settings_live.html.heex:74 +#: lib/elektrine_web/live/user_settings_live.html.heex:910 +#, elixir-autogen, elixir-format +msgid "Danger Zone" +msgstr "危险区域" + +#: lib/elektrine_web/live/user_settings_live.html.heex:189 +#, elixir-autogen, elixir-format +msgid "Display Name" +msgstr "显示名称" + +#: lib/elektrine_web/live/email_live/home.ex:1639 +#, elixir-autogen, elixir-format +msgid "Due Today" +msgstr "今日到期" + +#: lib/elektrine_web/live/user_settings_live.html.heex:554 +#, elixir-autogen, elixir-format +msgid "Eastern Time (US & Canada)" +msgstr "东部时间(美国&加拿大)" + +#: lib/elektrine_web/live/user_settings_live.html.heex:787 +#, elixir-autogen, elixir-format +msgid "Email section" +msgstr "邮件页面" + +#: lib/elektrine_web/live/user_settings_live.html.heex:286 +#, elixir-autogen, elixir-format +msgid "Enable 2FA" +msgstr "启用2FA" + +#: lib/elektrine_web/live/user_settings_live.html.heex:339 +#: lib/elektrine_web/live/user_settings_live.html.heex:362 +#: lib/elektrine_web/live/user_settings_live.html.heex:385 +#: lib/elektrine_web/live/user_settings_live.html.heex:405 +#: lib/elektrine_web/live/user_settings_live.html.heex:425 +#, elixir-autogen, elixir-format +msgid "Everyone" +msgstr "所有人" + +#: lib/elektrine_web/live/user_settings_live.html.heex:406 +#: lib/elektrine_web/live/user_settings_live.html.heex:449 +#: lib/elektrine_web/live/user_settings_live.html.heex:470 +#, elixir-autogen, elixir-format +msgid "Followers only" +msgstr "仅关注者" + +#: lib/elektrine_web/components/platform/z_nav.ex:42 +#, elixir-autogen, elixir-format +msgid "Friends" +msgstr "好友" + +#: lib/elektrine_web/live/user_settings_live.html.heex:156 +#, elixir-autogen, elixir-format +msgid "Handle" +msgstr "账号名" + +#: lib/elektrine_web/live/email_live/compose.html.heex:158 +#, elixir-autogen, elixir-format +msgid "Hide CC/BCC" +msgstr "隐藏抄送/密送" + +#: lib/elektrine_web/live/user_settings_live.html.heex:563 +#, elixir-autogen, elixir-format +msgid "Hong Kong" +msgstr "香港" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:62 +#, elixir-autogen, elixir-format +msgid "In %{count} days" +msgstr "%{count} 天后" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:71 +#, elixir-autogen, elixir-format +msgid "In %{count} months" +msgstr "%{count} 个月后" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:68 +#, elixir-autogen, elixir-format +msgid "In %{count} weeks" +msgstr "%{count} 周后" + +#: lib/elektrine_web/live/email_live/compose.html.heex:118 +#: lib/elektrine_web/live/email_live/compose.html.heex:198 +#: lib/elektrine_web/live/email_live/compose.html.heex:264 +#, elixir-autogen, elixir-format +msgid "Invalid email address" +msgstr "无效的邮箱地址" + +#: lib/elektrine_web/live/user_settings_live.html.heex:237 +#, elixir-autogen, elixir-format +msgid "Keep your account secure with a strong password." +msgstr "使用强密码保护您的账户安全。" + +#: lib/elektrine_web/live/user_settings_live.html.heex:558 +#, elixir-autogen, elixir-format +msgid "London" +msgstr "伦敦" + +#: lib/elektrine_web/live/user_settings_live.html.heex:270 +#, elixir-autogen, elixir-format +msgid "Manage 2FA" +msgstr "管理2FA" + +#: lib/elektrine_web/live/user_settings_live.html.heex:307 +#, elixir-autogen, elixir-format +msgid "Manage App Passwords" +msgstr "管理应用密码" + +#: lib/elektrine_web/live/user_settings_live.html.heex:787 +#, elixir-autogen, elixir-format +msgid "Manage email addresses and aliases in the" +msgstr "在以下位置管理邮箱地址和别名:" + +#: lib/elektrine_web/live/email_live/compose.html.heex:719 +#, elixir-autogen, elixir-format +msgid "Max 10MB per file, 5 files, 20 files per hour" +msgstr "单文件最大 10MB,单次最多 5 个文件,每小时最多 20 个文件" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:362 +#: lib/elektrine_web/live/timeline_live/home.html.heex:407 +#, elixir-autogen, elixir-format +msgid "Media" +msgstr "媒体" + +#: lib/elektrine_web/live/user_settings_live.html.heex:176 +#, elixir-autogen, elixir-format +msgid "Mentions:" +msgstr "提及:" + +#: lib/elektrine_web/live/user_settings_live.html.heex:556 +#, elixir-autogen, elixir-format +msgid "Mountain Time (US & Canada)" +msgstr "山地时间(美国&加拿大)" + +#: lib/elektrine_web/live/user_settings_live.html.heex:343 +#: lib/elektrine_web/live/user_settings_live.html.heex:366 +#: lib/elektrine_web/live/user_settings_live.html.heex:429 +#, elixir-autogen, elixir-format +msgid "Mutual follows only" +msgstr "仅互相关注" + +#: lib/elektrine_web/live/user_settings_live.html.heex:342 +#: lib/elektrine_web/live/user_settings_live.html.heex:365 +#: lib/elektrine_web/live/user_settings_live.html.heex:428 +#, elixir-autogen, elixir-format +msgid "My followers" +msgstr "我的关注者" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:65 +#, elixir-autogen, elixir-format +msgid "Next week" +msgstr "下一周" + +#: lib/elektrine_web/live/user_settings_live.html.heex:344 +#: lib/elektrine_web/live/user_settings_live.html.heex:367 +#: lib/elektrine_web/live/user_settings_live.html.heex:387 +#: lib/elektrine_web/live/user_settings_live.html.heex:407 +#: lib/elektrine_web/live/user_settings_live.html.heex:430 +#, elixir-autogen, elixir-format +msgid "Nobody" +msgstr "禁止所有人" + +#: lib/elektrine_web/live/user_settings_live.html.heex:782 +#, elixir-autogen, elixir-format +msgid "None configured" +msgstr "未配置" + +#: lib/elektrine_web/live/user_settings_live.html.heex:645 +#, elixir-autogen, elixir-format +msgid "Notify me for new direct messages" +msgstr "收到新私信时通知我" + +#: lib/elektrine_web/live/user_settings_live.html.heex:657 +#, elixir-autogen, elixir-format +msgid "Notify me when I'm mentioned" +msgstr "被提及时通知我" + +#: lib/elektrine_web/live/user_settings_live.html.heex:633 +#, elixir-autogen, elixir-format +msgid "Notify me when someone follows me" +msgstr "有人关注我时通知我" + +#: lib/elektrine_web/live/user_settings_live.html.heex:933 +#, elixir-autogen, elixir-format +msgid "Once deleted, your account cannot be recovered." +msgstr "账户一旦删除,将无法恢复。" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:50 +#: lib/elektrine_web/live/email_live/home.ex:1638 +#, elixir-autogen, elixir-format +msgid "Overdue" +msgstr "已逾期" + +#: lib/elektrine_web/live/user_settings_live.html.heex:557 +#, elixir-autogen, elixir-format +msgid "Pacific Time (US & Canada)" +msgstr "太平洋时间(美国&加拿大)" + +#: lib/elektrine_web/live/user_settings_live.html.heex:559 +#, elixir-autogen, elixir-format +msgid "Paris" +msgstr "巴黎" + +#: lib/elektrine_web/live/user_settings_live.html.heex:341 +#: lib/elektrine_web/live/user_settings_live.html.heex:364 +#: lib/elektrine_web/live/user_settings_live.html.heex:427 +#, elixir-autogen, elixir-format +msgid "People I follow" +msgstr "我关注的人" + +#: lib/elektrine_web/live/user_settings_live.html.heex:114 +#, elixir-autogen, elixir-format +msgid "Please fix the following errors:" +msgstr "请修正以下错误:" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:356 +#: lib/elektrine_web/live/timeline_live/home.html.heex:395 +#, elixir-autogen, elixir-format +msgid "Posts" +msgstr "帖子" + +#: lib/elektrine_web/live/user_settings_live.html.heex:47 +#: lib/elektrine_web/live/user_settings_live.html.heex:512 +#, elixir-autogen, elixir-format +msgid "Preferences" +msgstr "偏好设置" + +#: lib/elektrine_web/live/user_settings_live.html.heex:766 +#, elixir-autogen, elixir-format +msgid "Primary Email" +msgstr "主邮箱" + +#: lib/elektrine_web/live/user_settings_live.html.heex:38 +#, elixir-autogen, elixir-format +msgid "Privacy" +msgstr "隐私" + +#: lib/elektrine_web/live/user_settings_live.html.heex:451 +#: lib/elektrine_web/live/user_settings_live.html.heex:471 +#, elixir-autogen, elixir-format +msgid "Private" +msgstr "私密" + +#: lib/elektrine_web/live/user_settings_live.html.heex:175 +#, elixir-autogen, elixir-format +msgid "Profile URL:" +msgstr "个人资料链接" + +#: lib/elektrine_web/live/user_settings_live.html.heex:463 +#, elixir-autogen, elixir-format +msgid "Profile visibility" +msgstr "个人资料可见性" + +#: lib/elektrine_web/live/user_settings_live.html.heex:448 +#: lib/elektrine_web/live/user_settings_live.html.heex:469 +#, elixir-autogen, elixir-format +msgid "Public" +msgstr "公开" + +#: lib/elektrine_web/live/user_settings_live.html.heex:926 +#, elixir-autogen, elixir-format +msgid "Reason:" +msgstr "原因:" + +#: lib/elektrine_web/live/user_settings_live.html.heex:209 +#, elixir-autogen, elixir-format +msgid "Recovery Email" +msgstr "恢复邮箱" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:359 +#: lib/elektrine_web/live/timeline_live/home.html.heex:401 +#, elixir-autogen, elixir-format +msgid "Replies" +msgstr "回复" + +#: lib/elektrine_web/live/user_settings_live.html.heex:937 +#, elixir-autogen, elixir-format +msgid "Request Deletion" +msgstr "请求删除" + +#: lib/elektrine_web/live/user_settings_live.html.heex:919 +#, elixir-autogen, elixir-format +msgid "Requested:" +msgstr "请求时间:" + +#: lib/elektrine_web/live/user_settings_live.html.heex:220 +#: lib/elektrine_web/live/user_settings_live.html.heex:500 +#: lib/elektrine_web/live/user_settings_live.html.heex:597 +#: lib/elektrine_web/live/user_settings_live.html.heex:722 +#, elixir-autogen, elixir-format +msgid "Save Changes" +msgstr "保存更改" + +#: lib/elektrine_web/live/email_live/show.html.heex:163 +#, elixir-autogen, elixir-format +msgid "Scheduled to return to inbox" +msgstr "定时返回收件箱" + +#: lib/elektrine_web/live/user_settings_live.html.heex:29 +#, elixir-autogen, elixir-format +msgid "Security" +msgstr "安全" + +#: lib/elektrine_web/live/user_settings_live.html.heex:562 +#, elixir-autogen, elixir-format +msgid "Shanghai" +msgstr "上海" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:488 +#: lib/elektrine_web/live/user_settings_live.html.heex:86 +#, elixir-autogen, elixir-format +msgid "Storage" +msgstr "存储空间" + +#: lib/elektrine_web/live/user_settings_live.html.heex:564 +#, elixir-autogen, elixir-format +msgid "Sydney" +msgstr "悉尼" + +#: lib/elektrine_web/live/user_settings_live.html.heex:580 +#, elixir-autogen, elixir-format +msgid "Time Format" +msgstr "时间格式" + +#: lib/elektrine_web/live/user_settings_live.html.heex:544 +#, elixir-autogen, elixir-format +msgid "Timezone" +msgstr "时区" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:56 +#, elixir-autogen, elixir-format +msgid "Today" +msgstr "今天" + +#: lib/elektrine_web/live/user_settings_live.html.heex:561 +#, elixir-autogen, elixir-format +msgid "Tokyo" +msgstr "东京" + +#: lib/elektrine_web/live/user_settings_live.html.heex:254 +#, elixir-autogen, elixir-format +msgid "Two-Factor Authentication" +msgstr "双因素验证" + +#: lib/elektrine_web/live/user_settings_live.html.heex:753 +#, elixir-autogen, elixir-format +msgid "Unique ID" +msgstr "唯一标识" + +#: lib/elektrine_web/live/email_live/home.ex:1640 +#, elixir-autogen, elixir-format +msgid "Upcoming" +msgstr "即将到来" + +#: lib/elektrine_web/live/user_settings_live.html.heex:145 +#, elixir-autogen, elixir-format +msgid "Upload a new avatar image (max 5MB) - Will be automatically cropped to square" +msgstr "上传新头像图片(最大 5MB)- 将自动裁剪为正方形" + +#: lib/elektrine_web/live/user_settings_live.html.heex:214 +#, elixir-autogen, elixir-format +msgid "Used for password reset. Must be an external email address." +msgstr "用于密码重置。必须是外部邮箱地址。" + +#: lib/elektrine_web/live/user_settings_live.html.heex:757 +#, elixir-autogen, elixir-format +msgid "Username is for login only. Your handle (%{handle}) is your public identity." +msgstr "用户名仅用于登录。您的用户名(%{handle})是您的公开身份。" + +#: lib/elektrine_web/live/user_settings_live.html.heex:333 +#, elixir-autogen, elixir-format +msgid "Who can add you to groups/channels" +msgstr "谁可以将您添加到群组/频道" + +#: lib/elektrine_web/live/user_settings_live.html.heex:419 +#, elixir-autogen, elixir-format +msgid "Who can mention you" +msgstr "谁可以提及您" + +#: lib/elektrine_web/live/user_settings_live.html.heex:356 +#, elixir-autogen, elixir-format +msgid "Who can send you direct messages" +msgstr "谁可以给您发送私信" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:53 +#, elixir-autogen, elixir-format +msgid "Within an hour" +msgstr "一小时内" + +#: lib/elektrine_web/live/user_settings_live.html.heex:263 +#, elixir-autogen, elixir-format +msgid "Your account is protected with 2FA." +msgstr "您的账户已启用2FA保护。" + +#: lib/elektrine_web/live/user_settings_live.html.heex:195 +#, elixir-autogen, elixir-format +msgid "Your public display name. Can be changed anytime." +msgstr "您的公开显示名称。可随时更改。" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:198 +#, elixir-autogen, elixir-format +msgid "Audio & video calls" +msgstr "音频与视频通话" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:253 +#, elixir-autogen, elixir-format +msgid "Check out some profiles:" +msgstr "查看一些个人资料:" + +#: lib/elektrine_web/live/user_settings_live.html.heex:391 +#, elixir-autogen, elixir-format +msgid "Control who can initiate audio and video calls with you" +msgstr "控制谁可以与您发起音频和视频通话" + +#: lib/elektrine_web/live/user_settings_live.html.heex:411 +#, elixir-autogen, elixir-format +msgid "Control who can send you friend requests" +msgstr "控制谁可以向您发送好友请求" + +#: lib/elektrine_web/live/user_settings_live.html.heex:442 +#, elixir-autogen, elixir-format +msgid "Default post visibility" +msgstr "默认帖子可见性" + +#: lib/elektrine_web/live/user_settings_live.html.heex:455 +#, elixir-autogen, elixir-format +msgid "Default visibility for your timeline posts (can be changed per post)" +msgstr "动态的默认可见性(可为每条动态单独更改)" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:531 +#, elixir-autogen, elixir-format +msgid "ETS Tables" +msgstr "ETS 表数" + +#: lib/elektrine_web/live/user_settings_live.html.heex:340 +#: lib/elektrine_web/live/user_settings_live.html.heex:363 +#: lib/elektrine_web/live/user_settings_live.html.heex:386 +#: lib/elektrine_web/live/user_settings_live.html.heex:426 +#: lib/elektrine_web/live/user_settings_live.html.heex:450 +#, elixir-autogen, elixir-format +msgid "Friends only" +msgstr "仅好友" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:197 +#, elixir-autogen, elixir-format +msgid "Messaging & group chats" +msgstr "消息与群聊" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:396 +#: lib/elektrine_web/controllers/page_html/home.html.heex:519 +#, elixir-autogen, elixir-format +msgid "Reductions" +msgstr "归约数" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:523 +#, elixir-autogen, elixir-format +msgid "Run Queue" +msgstr "运行队列长度" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:527 +#, elixir-autogen, elixir-format +msgid "Schedulers" +msgstr "调度器数" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:199 +#, elixir-autogen, elixir-format +msgid "Screen sharing" +msgstr "屏幕共享" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:243 +#, elixir-autogen, elixir-format +msgid "The internet is shifting. AI-generated content is everywhere, and it's getting harder to tell what's real. Most platforms optimize for viral reach and follower counts, metrics that mean nothing when bots can fake them." +msgstr "互联网正在转变。AI 生成的内容无处不在,越来越难分辨真伪。大多数平台优化的是病毒式传播和粉丝数量——当机器人可以伪造这些指标时,它们就变得毫无意义。" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:102 +#, elixir-autogen, elixir-format +msgid "Use our webmail or connect with your preferred client like Thunderbird or Mutt:" +msgstr "使用我们的网页邮箱,或者使用您喜欢的客户端连接,如 Thunderbird 或 Mutt:" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:118 +#, elixir-autogen, elixir-format +msgid "What This Is" +msgstr "这是什么" + +#: lib/elektrine_web/live/user_settings_live.html.heex:379 +#, elixir-autogen, elixir-format +msgid "Who can call you" +msgstr "谁可以给您打电话" + +#: lib/elektrine_web/live/user_settings_live.html.heex:399 +#, elixir-autogen, elixir-format +msgid "Who can send you friend requests" +msgstr "谁可以向您发送好友请求" + +#: lib/elektrine_web/live/email_live/compose.html.heex:336 +#, elixir-autogen, elixir-format +msgid "%{count} words" +msgstr "%{count} 个字" + +#: lib/elektrine_web/live/user_settings_live.html.heex:831 +#, elixir-autogen, elixir-format +msgid "Automatically appended to all outgoing emails with '-- ' separator" +msgstr "自动添加到所有外发邮件,使用 '-- ' 分隔符" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:566 +#, elixir-autogen, elixir-format +msgid "Calendar sync" +msgstr "日历同步" + +#: lib/elektrine_web/live/email_live/email_helpers.ex:480 +#: lib/elektrine_web/live/email_live/home.ex:1526 +#, elixir-autogen, elixir-format +msgid "Contacts" +msgstr "联系人" + +#: lib/elektrine_web/live/email_live/home.html.heex:468 +#, elixir-autogen, elixir-format +msgid "Create an alias for the selected domain (max 15 aliases)" +msgstr "为所选域名创建别名(最多 15 个别名)" + +#: lib/elektrine_web/live/user_settings_live.html.heex:736 +#, elixir-autogen, elixir-format +msgid "Email Settings" +msgstr "邮件设置" + +#: lib/elektrine_web/live/user_settings_live.html.heex:814 +#, elixir-autogen, elixir-format +msgid "Email Signature" +msgstr "邮件签名" + +#: lib/elektrine_web/live/user_settings_live.html.heex:848 +#, elixir-autogen, elixir-format +msgid "Email Subscription Preferences" +msgstr "邮件订阅偏好" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:98 +#, elixir-autogen, elixir-format +msgid "Full-featured email with up to 15 aliases, forwarding, spam filtering, and smart categorization." +msgstr "功能完善的邮件服务,支持最多 15 个别名、转发、垃圾邮件过滤和智能分类。" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:564 +#, elixir-autogen, elixir-format +msgid "JMAP support" +msgstr "JMAP 支持" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:384 +#: lib/elektrine_web/controllers/page_html/home.html.heex:507 +#, elixir-autogen, elixir-format +msgid "Last Deployed" +msgstr "最后部署时间" + +#: lib/elektrine_web/live/user_settings_live.html.heex:852 +#, elixir-autogen, elixir-format +msgid "Manage subscription preferences for all your email addresses. Transactional emails cannot be unsubscribed." +msgstr "管理所有邮箱地址的订阅偏好。事务性邮件无法取消订阅。" + +#: lib/elektrine_web/live/email_live/home.ex:1543 +#, elixir-autogen, elixir-format +msgid "Manage your email contacts" +msgstr "管理你的邮件联系人" + +#: lib/elektrine_web/live/user_settings_live.html.heex:820 +#, elixir-autogen, elixir-format +msgid "Max 500 characters" +msgstr "最多 500 个字符" + +#: lib/elektrine_web/live/user_settings_live.html.heex:897 +#, elixir-autogen, elixir-format +msgid "No email addresses found for your account." +msgstr "未找到与您的账户关联的电子邮件地址。" + +#: lib/elektrine_web/live/user_settings_live.html.heex:884 +#, elixir-autogen, elixir-format +msgid "Required" +msgstr "必需" + +#: lib/elektrine_web/live/user_settings_live.html.heex:837 +#, elixir-autogen, elixir-format +msgid "Save Settings" +msgstr "保存设置" + +#: lib/elektrine_web/live/user_settings_live.html.heex:819 +#, elixir-autogen, elixir-format +msgid "Signature" +msgstr "签名" + +#: lib/elektrine_web/live/user_settings_live.html.heex:877 +#, elixir-autogen, elixir-format +msgid "Subscribed" +msgstr "已订阅" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:265 +#, elixir-autogen, elixir-format +msgid "Title (optional)" +msgstr "标题(可选)" + +#: lib/elektrine_web/live/user_settings_live.html.heex:880 +#, elixir-autogen, elixir-format +msgid "Unsubscribed" +msgstr "已取消订阅" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:241 +#, elixir-autogen, elixir-format +msgid "Why Z Exists" +msgstr "为什么存在 Z" + +#: lib/elektrine_web/live/user_settings_live.html.heex:827 +#, elixir-autogen, elixir-format +msgid "Your signature will be automatically appended to outgoing emails.\n\nExample:\nBest regards,\nYour Name\nYour Title" +msgstr "你的签名将自动添加到外发邮件中。\n\n示例:\n此致敬礼,\n你的名字\n你的职位" + +#: lib/elektrine_web/live/user_settings_live.html.heex:83 +#, elixir-autogen, elixir-format +msgid "Z Profile" +msgstr "Z 个人资料" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:246 +#, elixir-autogen, elixir-format +msgid "Z is designed around smaller circles of people you actually know and trust. We're building reputation systems based on your interactions with your community: who you talk to, who vouches for you, your actual contribution history. Public moderation logs, AI content labels, and trust networks instead of algorithmic feeds. Think less 'going viral' and more 'building credibility with people who matter.' The internet works better when trust is local, not global." +msgstr "Z 致力于围绕你真正认识和信任的小圈子进行设计。我们正在构建基于你与社区互动的声誉体系——包括你与谁交流、谁为你背书,以及你的实际贡献记录。我们采用公开的内容管理日志、AI 内容标签和信任网络,而不是算法推荐的信息流。与其追求“病毒式传播”,我们更重视在重要的人群中建立信誉。互联网在本地化的信任机制下运作得更好,而不是依靠全球性的泛化信任。" + +#: lib/elektrine_web/live/email_live/home.html.heex:778 +#, elixir-autogen, elixir-format +msgid "1 result" +msgid_plural "%{count} results" +msgstr[0] "%{count} 个结果" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:296 +#, elixir-autogen, elixir-format +msgid "Activate the tunnel in WireGuard" +msgstr "在 WireGuard 中激活隧道" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:30 +#, elixir-autogen, elixir-format +msgid "Available Servers" +msgstr "可用服务器" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:330 +#, elixir-autogen, elixir-format +msgid "Close" +msgstr "关闭" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:295 +#, elixir-autogen, elixir-format +msgid "Connect" +msgstr "连接" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:174 +#, elixir-autogen, elixir-format +msgid "Connected" +msgstr "已连接" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:84 +#, elixir-autogen, elixir-format +msgid "Create Config" +msgstr "创建配置" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:290 +#, elixir-autogen, elixir-format +msgid "Desktop: Download .conf file" +msgstr "桌面端:下载 .conf 文件" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:112 +#, elixir-autogen, elixir-format +msgid "Freemium VPN. WireGuard servers worldwide, 10GB/month*, 50 Mbps*." +msgstr "免费增值 VPN。全球 WireGuard 服务器,10GB/月*,50 Mbps*。" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:289 +#, elixir-autogen, elixir-format +msgid "Import Config" +msgstr "导入配置" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:282 +#, elixir-autogen, elixir-format +msgid "Install WireGuard" +msgstr "安装 WireGuard" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:320 +#, elixir-autogen, elixir-format +msgid "Instructions" +msgstr "操作说明" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:186 +#, elixir-autogen, elixir-format +msgid "Last connected" +msgstr "最后连接时间" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:291 +#, elixir-autogen, elixir-format +msgid "Mobile: Scan QR code" +msgstr "移动端:扫描二维码" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:266 +#, elixir-autogen, elixir-format +msgid "No VPN Configurations" +msgstr "没有 VPN 配置" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:93 +#, elixir-autogen, elixir-format +msgid "No servers available" +msgstr "没有可用服务器" + +#: lib/elektrine_web/live/user_settings_live.html.heex:693 +#, elixir-autogen, elixir-format +msgid "Notify me when I receive an email" +msgstr "收到邮件时通知我" + +#: lib/elektrine_web/live/user_settings_live.html.heex:717 +#, elixir-autogen, elixir-format +msgid "Notify me when someone comments on my timeline post" +msgstr "有人评论我的动态时通知我" + +#: lib/elektrine_web/live/user_settings_live.html.heex:681 +#, elixir-autogen, elixir-format +msgid "Notify me when someone likes my post" +msgstr "有人点赞我的帖子时通知我" + +#: lib/elektrine_web/live/user_settings_live.html.heex:705 +#, elixir-autogen, elixir-format +msgid "Notify me when someone replies to my discussion post" +msgstr "有人回复我的讨论帖时通知我" + +#: lib/elektrine_web/live/user_settings_live.html.heex:669 +#, elixir-autogen, elixir-format +msgid "Notify me when someone replies to my message" +msgstr "有人回复我的消息时通知我" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:322 +#, elixir-autogen, elixir-format +msgid "Open WireGuard app" +msgstr "打开 WireGuard 应用" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:248 +#, elixir-autogen, elixir-format +msgid "QR Code" +msgstr "二维码" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:309 +#, elixir-autogen, elixir-format +msgid "QR Code - %{name}" +msgstr "二维码 - %{name}" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:324 +#, elixir-autogen, elixir-format +msgid "Scan this code" +msgstr "扫描此二维码" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:312 +#, elixir-autogen, elixir-format +msgid "Scan this code with the WireGuard mobile app" +msgstr "使用 WireGuard 移动应用扫描此二维码" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:268 +#, elixir-autogen, elixir-format +msgid "Select a server from the sidebar to create a configuration" +msgstr "从侧边栏选择服务器以创建配置" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:278 +#, elixir-autogen, elixir-format +msgid "Setup Instructions" +msgstr "设置说明" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:323 +#, elixir-autogen, elixir-format +msgid "Tap '+' and select 'Create from QR code'" +msgstr "点击 '+' 并选择 '从二维码创建'" + +#: lib/elektrine_web/components/layouts/app.html.heex:410 +#: lib/elektrine_web/components/platform/elektrine_nav.ex:40 +#: lib/elektrine_web/controllers/page_html/home.html.heex:110 +#: lib/elektrine_web/controllers/page_html/home.html.heex:137 +#, elixir-autogen, elixir-format +msgid "VPN" +msgstr "VPN" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:112 +#, elixir-autogen, elixir-format +msgid "VPN Configurations" +msgstr "VPN 配置管理" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:369 +#: lib/elektrine_web/controllers/page_html/home.html.heex:492 +#, elixir-autogen, elixir-format +msgid "VPN Servers" +msgstr "VPN 服务器" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:11 +#, elixir-autogen, elixir-format +msgid "VPN Service" +msgstr "VPN 服务" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:114 +#, elixir-autogen, elixir-format +msgid "During beta, subject to change" +msgstr "测试期间,可能会有变动" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:91 +#, elixir-autogen, elixir-format +msgid "3.6 roentgen" +msgstr "3.6 roentgen" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:231 +#, elixir-autogen, elixir-format +msgid "ActivityPub Federation" +msgstr "ActivityPub 联合协议" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:211 +#: lib/elektrine_web/controllers/page_html/home.html.heex:295 +#, elixir-autogen, elixir-format +msgid "COMMUNITIES" +msgstr "社区" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:190 +#, elixir-autogen, elixir-format +msgid "Chat, timelines, communities, and galleries in one platform" +msgstr "聊天、动态、社区、相册,一站式平台" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:215 +#, elixir-autogen, elixir-format +msgid "Community moderation" +msgstr "社区管理" + +#: lib/elektrine_web/live/user_settings_live.html.heex:796 +#, elixir-autogen, elixir-format +msgid "Compose From" +msgstr "发件人" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:275 +#, elixir-autogen, elixir-format +msgid "Content warning (e.g., 'spoilers', 'politics', etc.)" +msgstr "内容警告(例如:“剧透”、“政治”等)" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:213 +#, elixir-autogen, elixir-format +msgid "Create & join communities" +msgstr "创建与加入社区" + +#: lib/elektrine_web/live/user_settings_live.html.heex:810 +#, elixir-autogen, elixir-format +msgid "Default 'From' address when composing new emails" +msgstr "撰写新邮件时的默认“发件人”地址" + +#: lib/elektrine_web/live/user_settings_live.html.heex:791 +#, elixir-autogen, elixir-format +msgid "Default Email Domain" +msgstr "默认邮箱域名" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:223 +#, elixir-autogen, elixir-format +msgid "Discover visual content" +msgstr "探索精彩内容" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:214 +#, elixir-autogen, elixir-format +msgid "Discussion threads" +msgstr "讨论帖" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:120 +#, elixir-autogen, elixir-format +msgid "Elektrine provides email and VPN services built from the ground up with Elixir: custom SMTP/IMAP servers, MTA components, spam filtering, and webmail. Z is the social platform layer with real-time chat, timelines, communities, and photo galleries. Everything runs on the same account." +msgstr "Elektrine 提供基于 Elixir 从头构建的电子邮件和 VPN 服务——自定义 SMTP/IMAP 服务器、MTA 组件、垃圾邮件过滤和网页邮箱。Z 是社交平台层,包含实时聊天、动态、社区和相册。所有功能均使用同一个账户。" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:219 +#: lib/elektrine_web/controllers/page_html/home.html.heex:301 +#, elixir-autogen, elixir-format +msgid "GALLERY" +msgstr "相册" + +#: lib/elektrine_web/components/platform/z_nav.ex:62 +#, elixir-autogen, elixir-format +msgid "Gallery" +msgstr "相册" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:59 +#, elixir-autogen, elixir-format +msgid "I agree to the" +msgstr "我同意" + +#: lib/elektrine_web/components/platform/z_nav.ex:54 +#, elixir-autogen, elixir-format +msgid "Lists" +msgstr "列表" + +#: lib/elektrine_web/live/user_settings_live.html.heex:491 +#, elixir-autogen, elixir-format +msgid "Manually approve followers from the fediverse" +msgstr "手动批准来自联合网络的关注请求" + +#: lib/elektrine_web/live/timeline_live/home.html.heex:656 +#, elixir-autogen, elixir-format +msgid "Open on Remote Instance" +msgstr "在远程实例上打开" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:222 +#, elixir-autogen, elixir-format +msgid "Organize albums" +msgstr "整理相册" + +#: lib/elektrine_web/components/platform/z_nav.ex:38 +#, elixir-autogen, elixir-format +msgid "Overview" +msgstr "概览" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:59 +#, elixir-autogen, elixir-format +msgid "Privacy Policy" +msgstr "隐私政策" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:150 +#, elixir-autogen, elixir-format +msgid "SIGN OUT" +msgstr "退出登录" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:221 +#, elixir-autogen, elixir-format +msgid "Share photo collections" +msgstr "分享照片集" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:59 +#, elixir-autogen, elixir-format +msgid "Terms" +msgstr "条款" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:373 +#: lib/elektrine_web/controllers/page_html/home.html.heex:496 +#, elixir-autogen, elixir-format +msgid "VPN Bandwidth" +msgstr "VPN 带宽" + +#: lib/elektrine_web/live/vpn_live/home.html.heex:18 +#, elixir-autogen, elixir-format +msgid "View Service Policy & Details" +msgstr "查看服务政策和详情" + +#: lib/elektrine_web/live/user_settings_live.html.heex:493 +#, elixir-autogen, elixir-format +msgid "When enabled, you must approve follow requests from Mastodon and other ActivityPub instances" +msgstr "启用后,您必须批准来自 Mastodon 和其他 ActivityPub 实例的关注请求" + +#: lib/elektrine_web/controllers/user_registration_html/new.html.heex:68 +#, elixir-autogen, elixir-format +msgid "You must agree to the Terms of Service" +msgstr "您必须同意服务条款" + +#: lib/elektrine_web/controllers/page_html/home.html.heex:233 +#, elixir-autogen, elixir-format +msgid "Z federates with Mastodon and the fediverse. Follow users on any instance, see their posts in your timeline, and interact across platforms." +msgstr "Z 与 Mastodon 和联合网络互通。关注任何实例上的用户,在您的动态中查看他们的帖子,并进行跨平台互动。" + +#: lib/elektrine_web/live/user_settings_live.html.heex:177 +#, elixir-autogen, elixir-format +msgid "1-20 chars, alphanumeric + underscore" +msgstr "1-20个字符,字母数字+下划线" diff --git a/apps/elektrine/priv/gettext/zh/LC_MESSAGES/errors.po b/apps/elektrine/priv/gettext/zh/LC_MESSAGES/errors.po new file mode 100644 index 0000000..ec22b6f --- /dev/null +++ b/apps/elektrine/priv/gettext/zh/LC_MESSAGES/errors.po @@ -0,0 +1,102 @@ +# # "msgid"s in this file come from POT (.pot) files. +# ## +# ## Do not add, change, or remove "msgid"s manually here as +# ## they're tied to the ones in the corresponding POT file +# ## (with the same domain). +# ## +# ## Use "mix gettext.extract --merge" or "mix gettext.merge" +# ## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: zh\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"Project-Id-Version: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.0\n" + +msgid "can't be blank" +msgstr "不能为空" + +msgid "has already been taken" +msgstr "已被占用" + +msgid "is invalid" +msgstr "无效" + +msgid "must be accepted" +msgstr "必须接受" + +msgid "has invalid format" +msgstr "格式无效" + +msgid "has an invalid entry" +msgstr "包含无效条目" + +msgid "is reserved" +msgstr "已被保留" + +msgid "does not match confirmation" +msgstr "与确认值不匹配" + +msgid "is still associated with this entry" +msgstr "仍关联到该条目" + +msgid "are still associated with this entry" +msgstr "仍关联到该条目" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "应该有 %{count} 个项目" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "应为 %{count} 字符" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "应为 %{count} 字节" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "应至少有 %{count} 个项目" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "至少应为 %{count} 字符" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "至少应为 %{count} 字节" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "应最多有 %{count} 个项目" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "最多应包含 %{count} 个字符" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "最多为 %{count} 字节" + +msgid "must be less than %{number}" +msgstr "必须小于 %{number}" + +msgid "must be greater than %{number}" +msgstr "必须大于 %{number}" + +msgid "must be less than or equal to %{number}" +msgstr "必须小于或等于 %{number}" + +msgid "must be greater than or equal to %{number}" +msgstr "必须大于或等于 %{number}" + +msgid "must be equal to %{number}" +msgstr "必须等于 %{number}" diff --git a/apps/elektrine/priv/repo/baseline_schema.sql b/apps/elektrine/priv/repo/baseline_schema.sql new file mode 100644 index 0000000..3ec2469 --- /dev/null +++ b/apps/elektrine/priv/repo/baseline_schema.sql @@ -0,0 +1,15533 @@ +-- +-- PostgreSQL database dump +-- + + +-- Dumped from database version 16.13 (Ubuntu 16.13-0ubuntu0.24.04.1) +-- Dumped by pg_dump version 16.13 (Ubuntu 16.13-0ubuntu0.24.04.1) + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: oban_job_state; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.oban_job_state AS ENUM ( + 'available', + 'scheduled', + 'executing', + 'retryable', + 'completed', + 'discarded', + 'cancelled' +); + + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: account_deletion_requests; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.account_deletion_requests ( + id bigint NOT NULL, + user_id bigint NOT NULL, + reason text, + status character varying(255) DEFAULT 'pending'::character varying NOT NULL, + requested_at timestamp(0) without time zone NOT NULL, + reviewed_at timestamp(0) without time zone, + reviewed_by_id bigint, + admin_notes text, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: account_deletion_requests_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.account_deletion_requests_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: account_deletion_requests_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.account_deletion_requests_id_seq OWNED BY public.account_deletion_requests.id; + + +-- +-- Name: activitypub_activities; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.activitypub_activities ( + id bigint NOT NULL, + activity_id character varying(255) NOT NULL, + activity_type character varying(255) NOT NULL, + actor_uri character varying(255) NOT NULL, + object_id character varying(255), + data jsonb NOT NULL, + local boolean DEFAULT false, + internal_user_id bigint, + internal_message_id bigint, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + processed boolean DEFAULT false, + processed_at timestamp(0) without time zone, + process_error character varying(255), + process_attempts integer DEFAULT 0 +); + + +-- +-- Name: activitypub_activities_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.activitypub_activities_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: activitypub_activities_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.activitypub_activities_id_seq OWNED BY public.activitypub_activities.id; + + +-- +-- Name: activitypub_actors; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.activitypub_actors ( + id bigint NOT NULL, + uri character varying(255) NOT NULL, + username character varying(255) NOT NULL, + domain character varying(255) NOT NULL, + display_name character varying(255), + summary text, + avatar_url character varying(255), + header_url character varying(255), + inbox_url character varying(255) NOT NULL, + outbox_url character varying(255), + followers_url character varying(255), + following_url character varying(255), + public_key text NOT NULL, + manually_approves_followers boolean DEFAULT false, + actor_type character varying(255) DEFAULT 'Person'::character varying, + last_fetched_at timestamp(0) without time zone, + metadata jsonb DEFAULT '{}'::jsonb, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + published_at timestamp(0) without time zone, + community_id bigint, + moderators_url character varying(255), + CONSTRAINT actor_type_must_be_valid CHECK (((actor_type)::text = ANY ((ARRAY['Person'::character varying, 'Group'::character varying, 'Organization'::character varying, 'Service'::character varying, 'Application'::character varying])::text[]))) +); + + +-- +-- Name: activitypub_actors_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.activitypub_actors_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: activitypub_actors_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.activitypub_actors_id_seq OWNED BY public.activitypub_actors.id; + + +-- +-- Name: activitypub_deliveries; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.activitypub_deliveries ( + id bigint NOT NULL, + activity_id bigint NOT NULL, + inbox_url character varying(255) NOT NULL, + status character varying(255) DEFAULT 'pending'::character varying, + attempts integer DEFAULT 0, + last_attempt_at timestamp(0) without time zone, + next_retry_at timestamp(0) without time zone, + error_message text, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: activitypub_deliveries_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.activitypub_deliveries_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: activitypub_deliveries_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.activitypub_deliveries_id_seq OWNED BY public.activitypub_deliveries.id; + + +-- +-- Name: activitypub_instances; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.activitypub_instances ( + id bigint NOT NULL, + domain character varying(255) NOT NULL, + blocked boolean DEFAULT false, + silenced boolean DEFAULT false, + reason text, + blocked_by_id bigint, + blocked_at timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + media_removal boolean DEFAULT false, + media_nsfw boolean DEFAULT false, + federated_timeline_removal boolean DEFAULT false, + followers_only boolean DEFAULT false, + report_removal boolean DEFAULT false, + avatar_removal boolean DEFAULT false, + banner_removal boolean DEFAULT false, + reject_deletes boolean DEFAULT false, + policy_applied_at timestamp(0) without time zone, + policy_applied_by_id bigint, + notes text, + unreachable_since timestamp(0) without time zone, + failure_count integer DEFAULT 0, + nodeinfo jsonb DEFAULT '{}'::jsonb, + favicon character varying(255), + metadata_updated_at timestamp(0) without time zone +); + + +-- +-- Name: activitypub_instances_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.activitypub_instances_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: activitypub_instances_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.activitypub_instances_id_seq OWNED BY public.activitypub_instances.id; + + +-- +-- Name: activitypub_relay_subscriptions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.activitypub_relay_subscriptions ( + id bigint NOT NULL, + relay_uri character varying(255) NOT NULL, + follow_activity_id character varying(255), + status character varying(255) DEFAULT 'pending'::character varying, + relay_inbox character varying(255), + relay_name character varying(255), + relay_software character varying(255), + accepted boolean DEFAULT false, + error_message text, + subscribed_by_id bigint, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: activitypub_relay_subscriptions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.activitypub_relay_subscriptions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: activitypub_relay_subscriptions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.activitypub_relay_subscriptions_id_seq OWNED BY public.activitypub_relay_subscriptions.id; + + +-- +-- Name: activitypub_user_blocks; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.activitypub_user_blocks ( + id bigint NOT NULL, + user_id bigint NOT NULL, + blocked_uri character varying(255) NOT NULL, + block_type character varying(255) DEFAULT 'user'::character varying, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: activitypub_user_blocks_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.activitypub_user_blocks_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: activitypub_user_blocks_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.activitypub_user_blocks_id_seq OWNED BY public.activitypub_user_blocks.id; + + +-- +-- Name: announcement_dismissals; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.announcement_dismissals ( + id bigint NOT NULL, + dismissed_at timestamp(0) without time zone NOT NULL, + user_id bigint NOT NULL, + announcement_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: announcement_dismissals_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.announcement_dismissals_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: announcement_dismissals_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.announcement_dismissals_id_seq OWNED BY public.announcement_dismissals.id; + + +-- +-- Name: announcements; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.announcements ( + id bigint NOT NULL, + title character varying(255) NOT NULL, + content text NOT NULL, + type character varying(255) DEFAULT 'info'::character varying NOT NULL, + starts_at timestamp(0) without time zone, + ends_at timestamp(0) without time zone, + active boolean DEFAULT true NOT NULL, + created_by_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: announcements_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.announcements_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: announcements_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.announcements_id_seq OWNED BY public.announcements.id; + + +-- +-- Name: api_token_revocations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.api_token_revocations ( + id bigint NOT NULL, + token_hash character varying(255) NOT NULL, + expires_at timestamp(0) without time zone NOT NULL, + revoked_at timestamp(0) without time zone NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: api_token_revocations_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.api_token_revocations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: api_token_revocations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.api_token_revocations_id_seq OWNED BY public.api_token_revocations.id; + + +-- +-- Name: api_tokens; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.api_tokens ( + id bigint NOT NULL, + user_id bigint NOT NULL, + name character varying(255) NOT NULL, + token_hash character varying(255) NOT NULL, + token_prefix character varying(255) NOT NULL, + scopes character varying(255)[] DEFAULT ARRAY[]::character varying[] NOT NULL, + last_used_at timestamp(0) without time zone, + last_used_ip character varying(255), + expires_at timestamp(0) without time zone, + revoked_at timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: api_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.api_tokens_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: api_tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.api_tokens_id_seq OWNED BY public.api_tokens.id; + + +-- +-- Name: app_passwords; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.app_passwords ( + id bigint NOT NULL, + user_id bigint NOT NULL, + name character varying(255) NOT NULL, + token_hash character varying(255) NOT NULL, + last_used_at timestamp(0) without time zone, + last_used_ip character varying(255), + expires_at timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: app_passwords_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.app_passwords_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: app_passwords_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.app_passwords_id_seq OWNED BY public.app_passwords.id; + + +-- +-- Name: audit_logs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_logs ( + id bigint NOT NULL, + admin_id bigint NOT NULL, + target_user_id bigint, + action character varying(255) NOT NULL, + resource_type character varying(255) NOT NULL, + resource_id integer, + details jsonb, + ip_address character varying(255), + user_agent character varying(255), + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: audit_logs_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.audit_logs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: audit_logs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.audit_logs_id_seq OWNED BY public.audit_logs.id; + + +-- +-- Name: auto_mod_rules; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.auto_mod_rules ( + id bigint NOT NULL, + conversation_id bigint NOT NULL, + name character varying(255) NOT NULL, + rule_type character varying(255) NOT NULL, + pattern text NOT NULL, + action character varying(255) NOT NULL, + enabled boolean DEFAULT true, + created_by_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: auto_mod_rules_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.auto_mod_rules_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: auto_mod_rules_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.auto_mod_rules_id_seq OWNED BY public.auto_mod_rules.id; + + +-- +-- Name: bluesky_inbound_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.bluesky_inbound_events ( + id bigint NOT NULL, + user_id bigint NOT NULL, + event_id character varying(255) NOT NULL, + reason character varying(255), + related_post_uri text, + processed_at timestamp(0) without time zone NOT NULL, + metadata jsonb DEFAULT '{}'::jsonb, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: bluesky_inbound_events_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.bluesky_inbound_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: bluesky_inbound_events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.bluesky_inbound_events_id_seq OWNED BY public.bluesky_inbound_events.id; + + +-- +-- Name: calendar_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.calendar_events ( + id bigint NOT NULL, + calendar_id bigint NOT NULL, + uid character varying(255) NOT NULL, + etag character varying(255), + summary character varying(255), + description text, + location character varying(255), + url character varying(255), + dtstart timestamp(0) without time zone NOT NULL, + dtend timestamp(0) without time zone, + duration character varying(255), + all_day boolean DEFAULT false, + timezone character varying(255), + rrule character varying(255), + rdate timestamp(0) without time zone[] DEFAULT ARRAY[]::timestamp without time zone[], + exdate timestamp(0) without time zone[] DEFAULT ARRAY[]::timestamp without time zone[], + recurrence_id timestamp(0) without time zone, + status character varying(255) DEFAULT 'CONFIRMED'::character varying, + transparency character varying(255) DEFAULT 'OPAQUE'::character varying, + classification character varying(255) DEFAULT 'PUBLIC'::character varying, + priority integer DEFAULT 0, + alarms jsonb[] DEFAULT ARRAY[]::jsonb[], + attendees jsonb[] DEFAULT ARRAY[]::jsonb[], + organizer jsonb, + categories character varying(255)[] DEFAULT ARRAY[]::character varying[], + icalendar_data text, + sequence integer DEFAULT 0, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: calendar_events_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.calendar_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: calendar_events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.calendar_events_id_seq OWNED BY public.calendar_events.id; + + +-- +-- Name: calendars; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.calendars ( + id bigint NOT NULL, + user_id bigint NOT NULL, + name character varying(255) NOT NULL, + color character varying(255) DEFAULT '#3b82f6'::character varying, + description text, + timezone character varying(255) DEFAULT 'UTC'::character varying, + is_default boolean DEFAULT false, + ctag character varying(255), + "order" integer DEFAULT 0, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: calendars_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.calendars_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: calendars_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.calendars_id_seq OWNED BY public.calendars.id; + + +-- +-- Name: calls; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.calls ( + id bigint NOT NULL, + caller_id bigint NOT NULL, + callee_id bigint NOT NULL, + conversation_id bigint, + call_type character varying(255) NOT NULL, + status character varying(255) NOT NULL, + started_at timestamp(0) without time zone, + ended_at timestamp(0) without time zone, + duration_seconds integer, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: calls_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.calls_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: calls_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.calls_id_seq OWNED BY public.calls.id; + + +-- +-- Name: chat_conversation_members; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.chat_conversation_members ( + id bigint NOT NULL, + conversation_id bigint NOT NULL, + user_id bigint NOT NULL, + role character varying(255) DEFAULT 'member'::character varying, + joined_at timestamp(0) without time zone DEFAULT now(), + left_at timestamp(0) without time zone, + last_read_at timestamp(0) without time zone, + last_read_message_id bigint, + notifications_enabled boolean DEFAULT true, + pinned boolean DEFAULT false, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: chat_conversation_members_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.chat_conversation_members_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: chat_conversation_members_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.chat_conversation_members_id_seq OWNED BY public.chat_conversation_members.id; + + +-- +-- Name: chat_conversations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.chat_conversations ( + id bigint NOT NULL, + name character varying(255), + description text, + type character varying(255) NOT NULL, + creator_id bigint, + avatar_url character varying(255), + is_public boolean DEFAULT false, + member_count integer DEFAULT 0, + last_message_at timestamp(0) without time zone, + archived boolean DEFAULT false, + hash character varying(255), + slow_mode_seconds integer DEFAULT 0, + approval_mode_enabled boolean DEFAULT false, + approval_threshold_posts integer DEFAULT 3, + channel_topic character varying(255), + channel_position integer DEFAULT 0, + server_id bigint, + federated_source character varying(255), + is_federated_mirror boolean DEFAULT false, + remote_group_actor_id bigint, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: chat_conversations_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.chat_conversations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: chat_conversations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.chat_conversations_id_seq OWNED BY public.chat_conversations.id; + + +-- +-- Name: chat_message_reactions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.chat_message_reactions ( + id bigint NOT NULL, + chat_message_id bigint NOT NULL, + user_id bigint, + remote_actor_id bigint, + emoji character varying(255) NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: chat_message_reactions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.chat_message_reactions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: chat_message_reactions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.chat_message_reactions_id_seq OWNED BY public.chat_message_reactions.id; + + +-- +-- Name: chat_message_reads; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.chat_message_reads ( + chat_message_id bigint NOT NULL, + user_id bigint NOT NULL, + read_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: chat_messages; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.chat_messages ( + id bigint NOT NULL, + conversation_id bigint NOT NULL, + sender_id bigint, + content text, + encrypted_content jsonb, + search_index character varying(255)[] DEFAULT ARRAY[]::character varying[], + message_type character varying(255) DEFAULT 'text'::character varying, + media_urls character varying(255)[] DEFAULT ARRAY[]::character varying[], + media_metadata jsonb DEFAULT '{}'::jsonb, + reply_to_id bigint, + edited_at timestamp(0) without time zone, + deleted_at timestamp(0) without time zone, + audio_duration integer, + audio_mime_type character varying(255), + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + federated_source character varying(255), + origin_domain character varying(255), + is_federated_mirror boolean DEFAULT false NOT NULL, + link_preview_id bigint +); + + +-- +-- Name: chat_messages_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.chat_messages_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: chat_messages_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.chat_messages_id_seq OWNED BY public.chat_messages.id; + + +-- +-- Name: chat_moderation_actions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.chat_moderation_actions ( + id bigint NOT NULL, + action_type character varying(255), + reason character varying(255), + duration integer, + details jsonb, + target_user_id bigint, + moderator_id bigint, + conversation_id bigint, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: chat_moderation_actions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.chat_moderation_actions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: chat_moderation_actions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.chat_moderation_actions_id_seq OWNED BY public.chat_moderation_actions.id; + + +-- +-- Name: chat_user_hidden_messages; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.chat_user_hidden_messages ( + id bigint NOT NULL, + user_id bigint NOT NULL, + chat_message_id bigint NOT NULL, + hidden_at timestamp(0) without time zone NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: chat_user_hidden_messages_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.chat_user_hidden_messages_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: chat_user_hidden_messages_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.chat_user_hidden_messages_id_seq OWNED BY public.chat_user_hidden_messages.id; + + +-- +-- Name: chat_user_timeouts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.chat_user_timeouts ( + id bigint NOT NULL, + user_id bigint, + conversation_id bigint, + created_by_id bigint, + timeout_until timestamp(0) without time zone, + reason character varying(255), + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: chat_user_timeouts_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.chat_user_timeouts_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: chat_user_timeouts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.chat_user_timeouts_id_seq OWNED BY public.chat_user_timeouts.id; + + +-- +-- Name: community_bans; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.community_bans ( + id bigint NOT NULL, + conversation_id bigint NOT NULL, + user_id bigint NOT NULL, + banned_by_id bigint, + reason text, + expires_at timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + origin_domain character varying(255), + actor_payload jsonb DEFAULT '{}'::jsonb NOT NULL, + metadata jsonb DEFAULT '{}'::jsonb NOT NULL, + banned_at_remote timestamp(0) without time zone, + updated_at_remote timestamp(0) without time zone +); + + +-- +-- Name: community_bans_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.community_bans_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: community_bans_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.community_bans_id_seq OWNED BY public.community_bans.id; + + +-- +-- Name: community_flairs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.community_flairs ( + id bigint NOT NULL, + name character varying(255) NOT NULL, + text_color character varying(255) DEFAULT '#FFFFFF'::character varying, + background_color character varying(255) DEFAULT '#4B5563'::character varying, + community_id bigint NOT NULL, + "position" integer DEFAULT 0, + is_mod_only boolean DEFAULT false, + is_enabled boolean DEFAULT true, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: community_flairs_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.community_flairs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: community_flairs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.community_flairs_id_seq OWNED BY public.community_flairs.id; + + +-- +-- Name: contact_groups; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.contact_groups ( + id bigint NOT NULL, + user_id bigint NOT NULL, + name character varying(255) NOT NULL, + color character varying(255) DEFAULT '#3b82f6'::character varying, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: contact_groups_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.contact_groups_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: contact_groups_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.contact_groups_id_seq OWNED BY public.contact_groups.id; + + +-- +-- Name: contacts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.contacts ( + id bigint NOT NULL, + user_id bigint NOT NULL, + name character varying(255) NOT NULL, + email character varying(255) NOT NULL, + phone character varying(255), + organization character varying(255), + notes text, + favorite boolean DEFAULT false, + group_id bigint, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + uid character varying(255), + etag character varying(255), + prefix character varying(255), + suffix character varying(255), + nickname character varying(255), + formatted_name character varying(255), + first_name character varying(255), + last_name character varying(255), + middle_name character varying(255), + emails jsonb[] DEFAULT ARRAY[]::jsonb[], + phones jsonb[] DEFAULT ARRAY[]::jsonb[], + addresses jsonb[] DEFAULT ARRAY[]::jsonb[], + urls jsonb[] DEFAULT ARRAY[]::jsonb[], + social_profiles jsonb[] DEFAULT ARRAY[]::jsonb[], + birthday date, + anniversary date, + photo_type character varying(255), + photo_data text, + photo_content_type character varying(255), + title character varying(255), + department character varying(255), + role character varying(255), + categories character varying(255)[] DEFAULT ARRAY[]::character varying[], + geo jsonb, + vcard_data text, + revision timestamp(0) without time zone, + pgp_public_key text, + pgp_key_id character varying(255), + pgp_fingerprint character varying(255), + pgp_key_source character varying(255), + pgp_key_fetched_at timestamp(0) without time zone, + pgp_encrypt_by_default boolean DEFAULT false +); + + +-- +-- Name: contacts_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.contacts_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: contacts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.contacts_id_seq OWNED BY public.contacts.id; + + +-- +-- Name: conversation_members; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.conversation_members ( + id bigint NOT NULL, + conversation_id bigint NOT NULL, + user_id bigint NOT NULL, + role character varying(255) DEFAULT 'member'::character varying, + joined_at timestamp(0) without time zone DEFAULT now(), + left_at timestamp(0) without time zone, + last_read_at timestamp(0) without time zone, + notifications_enabled boolean DEFAULT true, + pinned boolean DEFAULT false, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + last_read_message_id bigint +); + + +-- +-- Name: conversation_members_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.conversation_members_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: conversation_members_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.conversation_members_id_seq OWNED BY public.conversation_members.id; + + +-- +-- Name: conversations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.conversations ( + id bigint NOT NULL, + name character varying(255), + description text, + type character varying(255) NOT NULL, + creator_id bigint, + avatar_url character varying(255), + is_public boolean DEFAULT false, + member_count integer DEFAULT 0, + last_message_at timestamp(0) without time zone, + archived boolean DEFAULT false, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + hash character varying(32), + community_category character varying(255), + allow_public_posts boolean DEFAULT false, + discussion_style character varying(255) DEFAULT 'chat'::character varying, + community_rules text, + slow_mode_seconds integer DEFAULT 0, + approval_mode_enabled boolean DEFAULT false, + approval_threshold_posts integer DEFAULT 3, + federated_source character varying(255), + remote_group_actor_id bigint, + is_federated_mirror boolean DEFAULT false, + server_id bigint, + channel_topic text, + channel_position integer DEFAULT 0 NOT NULL +); + + +-- +-- Name: conversations_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.conversations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: conversations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.conversations_id_seq OWNED BY public.conversations.id; + + +-- +-- Name: creator_satisfaction; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.creator_satisfaction ( + id bigint NOT NULL, + user_id bigint NOT NULL, + creator_id bigint, + remote_actor_id bigint, + followed_after_viewing boolean DEFAULT false, + continued_engagement boolean DEFAULT false, + immediate_leave boolean DEFAULT false, + total_posts_viewed integer DEFAULT 0, + total_dwell_time_ms integer DEFAULT 0, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: creator_satisfaction_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.creator_satisfaction_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: creator_satisfaction_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.creator_satisfaction_id_seq OWNED BY public.creator_satisfaction.id; + + +-- +-- Name: custom_emojis; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.custom_emojis ( + id bigint NOT NULL, + shortcode character varying(255) NOT NULL, + image_url character varying(255) NOT NULL, + instance_domain character varying(255), + category character varying(255), + visible_in_picker boolean DEFAULT true, + disabled boolean DEFAULT false, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: custom_emojis_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.custom_emojis_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: custom_emojis_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.custom_emojis_id_seq OWNED BY public.custom_emojis.id; + + +-- +-- Name: data_exports; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.data_exports ( + id bigint NOT NULL, + user_id bigint NOT NULL, + export_type character varying(255) NOT NULL, + format character varying(255) DEFAULT 'json'::character varying NOT NULL, + status character varying(255) DEFAULT 'pending'::character varying NOT NULL, + file_path character varying(255), + file_size bigint, + item_count integer, + filters jsonb DEFAULT '{}'::jsonb, + download_token character varying(255), + download_count integer DEFAULT 0, + expires_at timestamp(0) without time zone, + started_at timestamp(0) without time zone, + completed_at timestamp(0) without time zone, + error text, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: data_exports_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.data_exports_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: data_exports_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.data_exports_id_seq OWNED BY public.data_exports.id; + + +-- +-- Name: developer_webhook_deliveries; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.developer_webhook_deliveries ( + id bigint NOT NULL, + webhook_id bigint NOT NULL, + user_id bigint NOT NULL, + event character varying(255) NOT NULL, + event_id character varying(255) NOT NULL, + payload jsonb DEFAULT '{}'::jsonb NOT NULL, + status character varying(255) DEFAULT 'pending'::character varying NOT NULL, + attempt_count integer DEFAULT 0 NOT NULL, + response_status integer, + error text, + duration_ms integer, + last_attempted_at timestamp(0) without time zone, + delivered_at timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: developer_webhook_deliveries_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.developer_webhook_deliveries_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: developer_webhook_deliveries_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.developer_webhook_deliveries_id_seq OWNED BY public.developer_webhook_deliveries.id; + + +-- +-- Name: developer_webhooks; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.developer_webhooks ( + id bigint NOT NULL, + user_id bigint NOT NULL, + name character varying(255) NOT NULL, + url character varying(255) NOT NULL, + events character varying(255)[] DEFAULT ARRAY[]::character varying[] NOT NULL, + secret character varying(255) NOT NULL, + enabled boolean DEFAULT true NOT NULL, + last_triggered_at timestamp(0) without time zone, + last_response_status integer, + last_error text, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: developer_webhooks_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.developer_webhooks_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: developer_webhooks_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.developer_webhooks_id_seq OWNED BY public.developer_webhooks.id; + + +-- +-- Name: device_tokens; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.device_tokens ( + id bigint NOT NULL, + user_id bigint NOT NULL, + token character varying(255) NOT NULL, + platform character varying(255) NOT NULL, + app_version character varying(255), + device_name character varying(255), + device_model character varying(255), + os_version character varying(255), + bundle_id character varying(255), + enabled boolean DEFAULT true, + last_used_at timestamp(0) without time zone, + failed_count integer DEFAULT 0, + last_error character varying(255), + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: device_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.device_tokens_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: device_tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.device_tokens_id_seq OWNED BY public.device_tokens.id; + + +-- +-- Name: dns_query_stats; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.dns_query_stats ( + id bigint NOT NULL, + query_date date NOT NULL, + qname character varying(255) NOT NULL, + qtype character varying(255) NOT NULL, + rcode character varying(255) NOT NULL, + transport character varying(255) NOT NULL, + query_count integer DEFAULT 0 NOT NULL, + zone_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: dns_query_stats_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.dns_query_stats_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: dns_query_stats_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.dns_query_stats_id_seq OWNED BY public.dns_query_stats.id; + + +-- +-- Name: dns_records; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.dns_records ( + id bigint NOT NULL, + name character varying(255) NOT NULL, + type character varying(255) NOT NULL, + ttl integer DEFAULT 300 NOT NULL, + content text NOT NULL, + priority integer, + weight integer, + port integer, + flags integer, + tag character varying(255), + zone_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + source character varying(255) DEFAULT 'user'::character varying NOT NULL, + service character varying(255), + managed boolean DEFAULT false NOT NULL, + managed_key character varying(255), + required boolean DEFAULT false NOT NULL, + metadata jsonb DEFAULT '{}'::jsonb NOT NULL, + protocol integer, + algorithm integer, + key_tag integer, + digest_type integer, + usage integer, + selector integer, + matching_type integer +); + + +-- +-- Name: dns_records_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.dns_records_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: dns_records_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.dns_records_id_seq OWNED BY public.dns_records.id; + + +-- +-- Name: dns_zone_service_configs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.dns_zone_service_configs ( + id bigint NOT NULL, + service character varying(255) NOT NULL, + enabled boolean DEFAULT true NOT NULL, + mode character varying(255) DEFAULT 'managed'::character varying NOT NULL, + status character varying(255) DEFAULT 'pending'::character varying NOT NULL, + settings jsonb DEFAULT '{}'::jsonb NOT NULL, + last_applied_at timestamp(0) without time zone, + last_error character varying(255), + zone_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: dns_zone_service_configs_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.dns_zone_service_configs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: dns_zone_service_configs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.dns_zone_service_configs_id_seq OWNED BY public.dns_zone_service_configs.id; + + +-- +-- Name: dns_zones; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.dns_zones ( + id bigint NOT NULL, + domain character varying(255) NOT NULL, + status character varying(255) DEFAULT 'provisioning'::character varying NOT NULL, + kind character varying(255) DEFAULT 'native'::character varying NOT NULL, + serial bigint DEFAULT 1 NOT NULL, + default_ttl integer DEFAULT 300 NOT NULL, + verified_at timestamp(0) without time zone, + last_checked_at timestamp(0) without time zone, + last_published_at timestamp(0) without time zone, + last_error character varying(255), + user_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + soa_mname character varying(255), + soa_rname character varying(255), + soa_refresh integer DEFAULT 3600 NOT NULL, + soa_retry integer DEFAULT 600 NOT NULL, + soa_expire integer DEFAULT 1209600 NOT NULL, + soa_minimum integer DEFAULT 300 NOT NULL, + force_https boolean DEFAULT false NOT NULL +); + + +-- +-- Name: dns_zones_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.dns_zones_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: dns_zones_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.dns_zones_id_seq OWNED BY public.dns_zones.id; + + +-- +-- Name: email_aliases; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_aliases ( + id bigint NOT NULL, + alias_email character varying(255) NOT NULL, + target_email character varying(255), + user_id bigint NOT NULL, + enabled boolean DEFAULT true NOT NULL, + description character varying(255), + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: email_aliases_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_aliases_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_aliases_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_aliases_id_seq OWNED BY public.email_aliases.id; + + +-- +-- Name: email_auto_replies; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_auto_replies ( + id bigint NOT NULL, + enabled boolean DEFAULT false NOT NULL, + subject character varying(255), + body text NOT NULL, + html_body text, + start_date date, + end_date date, + only_contacts boolean DEFAULT false NOT NULL, + exclude_mailing_lists boolean DEFAULT true NOT NULL, + reply_once_per_sender boolean DEFAULT true NOT NULL, + user_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: email_auto_replies_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_auto_replies_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_auto_replies_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_auto_replies_id_seq OWNED BY public.email_auto_replies.id; + + +-- +-- Name: email_auto_reply_log; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_auto_reply_log ( + id bigint NOT NULL, + sender_email character varying(255) NOT NULL, + sent_at timestamp(0) without time zone NOT NULL, + user_id bigint NOT NULL +); + + +-- +-- Name: email_auto_reply_log_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_auto_reply_log_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_auto_reply_log_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_auto_reply_log_id_seq OWNED BY public.email_auto_reply_log.id; + + +-- +-- Name: email_blocked_senders; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_blocked_senders ( + id bigint NOT NULL, + email character varying(255), + domain character varying(255), + reason character varying(255), + user_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: email_blocked_senders_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_blocked_senders_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_blocked_senders_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_blocked_senders_id_seq OWNED BY public.email_blocked_senders.id; + + +-- +-- Name: email_category_preferences; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_category_preferences ( + id bigint NOT NULL, + email character varying(255), + domain character varying(255), + category character varying(255) NOT NULL, + confidence double precision DEFAULT 0.7 NOT NULL, + learned_count integer DEFAULT 1 NOT NULL, + source character varying(255) DEFAULT 'manual_move'::character varying NOT NULL, + last_learned_at timestamp(0) without time zone, + user_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + CONSTRAINT email_category_preferences_category_check CHECK (((category)::text = ANY ((ARRAY['feed'::character varying, 'ledger'::character varying])::text[]))), + CONSTRAINT email_category_preferences_email_or_domain_check CHECK ((((email IS NOT NULL) AND (domain IS NULL)) OR ((email IS NULL) AND (domain IS NOT NULL)))) +); + + +-- +-- Name: email_category_preferences_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_category_preferences_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_category_preferences_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_category_preferences_id_seq OWNED BY public.email_category_preferences.id; + + +-- +-- Name: email_custom_domains; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_custom_domains ( + id bigint NOT NULL, + domain character varying(255) NOT NULL, + verification_token character varying(255) NOT NULL, + status character varying(255) DEFAULT 'pending'::character varying NOT NULL, + verified_at timestamp(0) without time zone, + last_checked_at timestamp(0) without time zone, + last_error character varying(255), + user_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + dkim_selector character varying(255) DEFAULT NULL::character varying, + dkim_public_key text, + dkim_private_key text, + dkim_synced_at timestamp(0) without time zone, + dkim_last_error character varying(255) +); + + +-- +-- Name: email_custom_domains_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_custom_domains_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_custom_domains_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_custom_domains_id_seq OWNED BY public.email_custom_domains.id; + + +-- +-- Name: email_exports; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_exports ( + id bigint NOT NULL, + status character varying(255) DEFAULT 'pending'::character varying NOT NULL, + format character varying(255) DEFAULT 'mbox'::character varying NOT NULL, + file_path character varying(255), + file_size integer, + message_count integer, + started_at timestamp(0) without time zone, + completed_at timestamp(0) without time zone, + error text, + filters jsonb DEFAULT '{}'::jsonb, + user_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: email_exports_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_exports_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_exports_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_exports_id_seq OWNED BY public.email_exports.id; + + +-- +-- Name: email_filters; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_filters ( + id bigint NOT NULL, + name character varying(255) NOT NULL, + enabled boolean DEFAULT true NOT NULL, + priority integer DEFAULT 0 NOT NULL, + stop_processing boolean DEFAULT false NOT NULL, + conditions jsonb DEFAULT '{}'::jsonb NOT NULL, + actions jsonb DEFAULT '{}'::jsonb NOT NULL, + user_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: email_filters_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_filters_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_filters_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_filters_id_seq OWNED BY public.email_filters.id; + + +-- +-- Name: email_folders; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_folders ( + id bigint NOT NULL, + name character varying(255) NOT NULL, + color character varying(255), + icon character varying(255), + parent_id bigint, + user_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: email_folders_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_folders_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_folders_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_folders_id_seq OWNED BY public.email_folders.id; + + +-- +-- Name: email_jobs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_jobs ( + id bigint NOT NULL, + user_id bigint NOT NULL, + status character varying(255) DEFAULT 'pending'::character varying, + email_attrs jsonb NOT NULL, + attachments jsonb, + attempts integer DEFAULT 0, + max_attempts integer DEFAULT 3, + error character varying(255), + completed_at timestamp(0) without time zone, + scheduled_for timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: email_jobs_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_jobs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_jobs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_jobs_id_seq OWNED BY public.email_jobs.id; + + +-- +-- Name: email_labels; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_labels ( + id bigint NOT NULL, + name character varying(255) NOT NULL, + color character varying(255) DEFAULT '#3b82f6'::character varying, + user_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: email_labels_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_labels_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_labels_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_labels_id_seq OWNED BY public.email_labels.id; + + +-- +-- Name: email_message_labels; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_message_labels ( + message_id bigint NOT NULL, + label_id bigint NOT NULL +); + + +-- +-- Name: email_messages; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_messages ( + id bigint NOT NULL, + message_id character varying(500) NOT NULL, + "from" character varying(500) NOT NULL, + "to" text, + cc text, + bcc text, + subject character varying(500), + text_body text, + html_body text, + status character varying(255) DEFAULT 'received'::character varying NOT NULL, + read boolean DEFAULT false, + metadata jsonb DEFAULT '{}'::jsonb, + mailbox_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + category character varying(255) DEFAULT 'inbox'::character varying, + stack_at timestamp(0) without time zone, + stack_reason character varying(255), + reply_later_at timestamp(0) without time zone, + reply_later_reminder boolean DEFAULT false, + is_receipt boolean DEFAULT false, + is_notification boolean DEFAULT false, + is_newsletter boolean DEFAULT false, + opened_at timestamp(0) without time zone, + first_opened_at timestamp(0) without time zone, + open_count integer DEFAULT 0, + spam boolean DEFAULT false, + archived boolean DEFAULT false, + attachments jsonb DEFAULT '{}'::jsonb, + has_attachments boolean DEFAULT false, + hash character varying(32), + flagged boolean DEFAULT false NOT NULL, + deleted boolean DEFAULT false NOT NULL, + answered boolean DEFAULT false NOT NULL, + encrypted_text_body jsonb, + encrypted_html_body jsonb, + search_index text[] DEFAULT ARRAY[]::text[], + thread_id bigint, + in_reply_to character varying(255), + "references" text, + jmap_blob_id character varying(255), + priority character varying(255) DEFAULT 'normal'::character varying, + folder_id bigint, + scheduled_at timestamp(0) without time zone, + expires_at timestamp(0) without time zone, + undo_send_until timestamp(0) without time zone, + client_encrypted_payload jsonb +); + + +-- +-- Name: email_messages_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_messages_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_messages_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_messages_id_seq OWNED BY public.email_messages.id; + + +-- +-- Name: email_safe_senders; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_safe_senders ( + id bigint NOT NULL, + email character varying(255), + domain character varying(255), + user_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: email_safe_senders_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_safe_senders_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_safe_senders_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_safe_senders_id_seq OWNED BY public.email_safe_senders.id; + + +-- +-- Name: email_submissions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_submissions ( + id bigint NOT NULL, + mailbox_id bigint NOT NULL, + email_id bigint, + identity_id character varying(255) NOT NULL, + envelope_from character varying(255) NOT NULL, + envelope_to character varying(255)[] DEFAULT ARRAY[]::character varying[] NOT NULL, + send_at timestamp(0) without time zone, + undo_status character varying(255) DEFAULT 'pending'::character varying, + delivery_status jsonb DEFAULT '{}'::jsonb, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: email_submissions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_submissions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_submissions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_submissions_id_seq OWNED BY public.email_submissions.id; + + +-- +-- Name: email_suppressions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_suppressions ( + id bigint NOT NULL, + user_id bigint NOT NULL, + email character varying(255) NOT NULL, + reason character varying(255) NOT NULL, + source character varying(255) DEFAULT 'manual'::character varying NOT NULL, + note text, + metadata jsonb DEFAULT '{}'::jsonb NOT NULL, + last_event_at timestamp(0) without time zone NOT NULL, + expires_at timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: email_suppressions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_suppressions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_suppressions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_suppressions_id_seq OWNED BY public.email_suppressions.id; + + +-- +-- Name: email_templates; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_templates ( + id bigint NOT NULL, + name character varying(255) NOT NULL, + subject character varying(255), + body text NOT NULL, + html_body text, + user_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: email_templates_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_templates_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_templates_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_templates_id_seq OWNED BY public.email_templates.id; + + +-- +-- Name: email_threads; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_threads ( + id bigint NOT NULL, + mailbox_id bigint NOT NULL, + subject_hash character varying(255) NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: email_threads_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_threads_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_threads_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_threads_id_seq OWNED BY public.email_threads.id; + + +-- +-- Name: email_unsubscribes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.email_unsubscribes ( + id bigint NOT NULL, + email character varying(255) NOT NULL, + user_id bigint, + list_id character varying(255), + token character varying(255) NOT NULL, + unsubscribed_at timestamp(0) without time zone NOT NULL, + ip_address character varying(255), + user_agent text, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: email_unsubscribes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.email_unsubscribes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_unsubscribes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.email_unsubscribes_id_seq OWNED BY public.email_unsubscribes.id; + + +-- +-- Name: federated_boosts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.federated_boosts ( + id bigint NOT NULL, + message_id bigint NOT NULL, + remote_actor_id bigint NOT NULL, + activitypub_id character varying(255), + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: federated_boosts_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.federated_boosts_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: federated_boosts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.federated_boosts_id_seq OWNED BY public.federated_boosts.id; + + +-- +-- Name: federated_dislikes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.federated_dislikes ( + id bigint NOT NULL, + message_id bigint NOT NULL, + remote_actor_id bigint NOT NULL, + activitypub_id character varying(255), + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: federated_dislikes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.federated_dislikes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: federated_dislikes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.federated_dislikes_id_seq OWNED BY public.federated_dislikes.id; + + +-- +-- Name: federated_likes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.federated_likes ( + id bigint NOT NULL, + message_id bigint NOT NULL, + remote_actor_id bigint NOT NULL, + activitypub_id text, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: federated_likes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.federated_likes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: federated_likes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.federated_likes_id_seq OWNED BY public.federated_likes.id; + + +-- +-- Name: federated_quotes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.federated_quotes ( + id bigint NOT NULL, + message_id bigint NOT NULL, + remote_actor_id bigint NOT NULL, + activitypub_id character varying(255) NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: federated_quotes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.federated_quotes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: federated_quotes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.federated_quotes_id_seq OWNED BY public.federated_quotes.id; + + +-- +-- Name: file_shares; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.file_shares ( + id bigint NOT NULL, + stored_file_id bigint NOT NULL, + user_id bigint NOT NULL, + token character varying(255) NOT NULL, + revoked_at timestamp(0) without time zone, + download_count integer DEFAULT 0 NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + expires_at timestamp(0) without time zone, + access_level character varying(255) DEFAULT 'download'::character varying NOT NULL, + password_hash text +); + + +-- +-- Name: file_shares_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.file_shares_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: file_shares_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.file_shares_id_seq OWNED BY public.file_shares.id; + + +-- +-- Name: follows; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.follows ( + id bigint NOT NULL, + follower_id integer, + followed_id integer, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + activitypub_id text, + remote_actor_id bigint, + pending boolean DEFAULT false +); + + +-- +-- Name: follows_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.follows_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: follows_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.follows_id_seq OWNED BY public.follows.id; + + +-- +-- Name: forwarded_messages; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.forwarded_messages ( + id bigint NOT NULL, + message_id character varying(255), + from_address character varying(255), + subject character varying(255), + original_recipient character varying(255), + final_recipient character varying(255), + forwarding_chain jsonb, + total_hops integer, + alias_id bigint, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: forwarded_messages_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.forwarded_messages_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: forwarded_messages_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.forwarded_messages_id_seq OWNED BY public.forwarded_messages.id; + + +-- +-- Name: friend_requests; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.friend_requests ( + id bigint NOT NULL, + requester_id bigint NOT NULL, + recipient_id bigint NOT NULL, + status character varying(255) DEFAULT 'pending'::character varying NOT NULL, + message text, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: friend_requests_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.friend_requests_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: friend_requests_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.friend_requests_id_seq OWNED BY public.friend_requests.id; + + +-- +-- Name: group_follows; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.group_follows ( + id bigint NOT NULL, + remote_actor_id bigint NOT NULL, + group_actor_id bigint NOT NULL, + activitypub_id character varying(255), + pending boolean DEFAULT false, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: group_follows_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.group_follows_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: group_follows_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.group_follows_id_seq OWNED BY public.group_follows.id; + + +-- +-- Name: handle_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.handle_history ( + id bigint NOT NULL, + user_id bigint NOT NULL, + handle character varying(255) NOT NULL, + used_from timestamp(0) without time zone NOT NULL, + used_until timestamp(0) without time zone, + reserved_until timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: handle_history_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.handle_history_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: handle_history_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.handle_history_id_seq OWNED BY public.handle_history.id; + + +-- +-- Name: hashtag_follows; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.hashtag_follows ( + id bigint NOT NULL, + user_id bigint NOT NULL, + hashtag_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: hashtag_follows_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.hashtag_follows_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: hashtag_follows_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.hashtag_follows_id_seq OWNED BY public.hashtag_follows.id; + + +-- +-- Name: hashtags; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.hashtags ( + id bigint NOT NULL, + name character varying(255) NOT NULL, + normalized_name character varying(255) NOT NULL, + use_count integer DEFAULT 0, + last_used_at timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: hashtags_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.hashtags_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: hashtags_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.hashtags_id_seq OWNED BY public.hashtags.id; + + +-- +-- Name: imap_subscriptions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.imap_subscriptions ( + id bigint NOT NULL, + user_id bigint NOT NULL, + folder_name character varying(255) NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: imap_subscriptions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.imap_subscriptions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: imap_subscriptions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.imap_subscriptions_id_seq OWNED BY public.imap_subscriptions.id; + + +-- +-- Name: invite_code_uses; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.invite_code_uses ( + id bigint NOT NULL, + invite_code_id bigint NOT NULL, + user_id bigint NOT NULL, + used_at timestamp(0) without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: invite_code_uses_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.invite_code_uses_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: invite_code_uses_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.invite_code_uses_id_seq OWNED BY public.invite_code_uses.id; + + +-- +-- Name: invite_codes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.invite_codes ( + id bigint NOT NULL, + code character varying(255) NOT NULL, + max_uses integer DEFAULT 1, + uses_count integer DEFAULT 0, + expires_at timestamp(0) without time zone, + created_by_id bigint, + note text, + is_active boolean DEFAULT true, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: invite_codes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.invite_codes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: invite_codes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.invite_codes_id_seq OWNED BY public.invite_codes.id; + + +-- +-- Name: jmap_email_changes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.jmap_email_changes ( + id bigint NOT NULL, + mailbox_id bigint NOT NULL, + email_id bigint NOT NULL, + change_type character varying(255) NOT NULL, + state_counter bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: jmap_email_changes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.jmap_email_changes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: jmap_email_changes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.jmap_email_changes_id_seq OWNED BY public.jmap_email_changes.id; + + +-- +-- Name: jmap_email_tombstones; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.jmap_email_tombstones ( + id bigint NOT NULL, + mailbox_id bigint NOT NULL, + email_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: jmap_email_tombstones_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.jmap_email_tombstones_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: jmap_email_tombstones_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.jmap_email_tombstones_id_seq OWNED BY public.jmap_email_tombstones.id; + + +-- +-- Name: jmap_state_tracking; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.jmap_state_tracking ( + id bigint NOT NULL, + mailbox_id bigint NOT NULL, + entity_type character varying(255) NOT NULL, + state_counter bigint DEFAULT 0 NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: jmap_state_tracking_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.jmap_state_tracking_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: jmap_state_tracking_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.jmap_state_tracking_id_seq OWNED BY public.jmap_state_tracking.id; + + +-- +-- Name: lemmy_counts_cache; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.lemmy_counts_cache ( + id bigint NOT NULL, + activitypub_id text NOT NULL, + upvotes integer DEFAULT 0, + downvotes integer DEFAULT 0, + score integer DEFAULT 0, + comments integer DEFAULT 0, + top_comments jsonb DEFAULT '[]'::jsonb, + fetched_at timestamp(0) without time zone NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: lemmy_counts_cache_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.lemmy_counts_cache_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: lemmy_counts_cache_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.lemmy_counts_cache_id_seq OWNED BY public.lemmy_counts_cache.id; + + +-- +-- Name: link_preview_jobs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.link_preview_jobs ( + id bigint NOT NULL, + url character varying(255) NOT NULL, + status character varying(255) DEFAULT 'pending'::character varying, + message_id bigint, + attempts integer DEFAULT 0, + max_attempts integer DEFAULT 3, + error character varying(255), + link_preview_id bigint, + completed_at timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: link_preview_jobs_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.link_preview_jobs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: link_preview_jobs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.link_preview_jobs_id_seq OWNED BY public.link_preview_jobs.id; + + +-- +-- Name: link_previews; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.link_previews ( + id bigint NOT NULL, + url text NOT NULL, + title character varying(255), + description text, + image_url character varying(255), + site_name character varying(255), + favicon_url character varying(255), + status character varying(255) DEFAULT 'pending'::character varying, + error_message text, + fetched_at timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: link_previews_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.link_previews_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: link_previews_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.link_previews_id_seq OWNED BY public.link_previews.id; + + +-- +-- Name: list_members; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.list_members ( + id bigint NOT NULL, + list_id bigint NOT NULL, + user_id bigint, + remote_actor_id bigint, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + CONSTRAINT user_or_remote_actor CHECK ((((user_id IS NOT NULL) AND (remote_actor_id IS NULL)) OR ((user_id IS NULL) AND (remote_actor_id IS NOT NULL)))) +); + + +-- +-- Name: list_members_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.list_members_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: list_members_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.list_members_id_seq OWNED BY public.list_members.id; + + +-- +-- Name: lists; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.lists ( + id bigint NOT NULL, + user_id bigint NOT NULL, + name character varying(255) NOT NULL, + description text, + visibility character varying(255) DEFAULT 'public'::character varying NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: lists_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.lists_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: lists_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.lists_id_seq OWNED BY public.lists.id; + + +-- +-- Name: mailboxes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.mailboxes ( + id bigint NOT NULL, + email character varying(255) NOT NULL, + user_id bigint, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + forward_to character varying(255), + forward_enabled boolean DEFAULT false NOT NULL, + username character varying(255), + private_storage_enabled boolean DEFAULT false NOT NULL, + private_storage_public_key text, + private_storage_wrapped_private_key jsonb, + private_storage_verifier jsonb +); + + +-- +-- Name: mailboxes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.mailboxes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: mailboxes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.mailboxes_id_seq OWNED BY public.mailboxes.id; + + +-- +-- Name: message_reactions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.message_reactions ( + id bigint NOT NULL, + message_id bigint NOT NULL, + user_id bigint, + emoji character varying(255) NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + remote_actor_id bigint, + federated boolean DEFAULT false, + emoji_url character varying(255) +); + + +-- +-- Name: message_reactions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.message_reactions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: message_reactions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.message_reactions_id_seq OWNED BY public.message_reactions.id; + + +-- +-- Name: message_votes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.message_votes ( + id bigint NOT NULL, + user_id bigint NOT NULL, + message_id bigint NOT NULL, + vote_type character varying(255) NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: message_votes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.message_votes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: message_votes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.message_votes_id_seq OWNED BY public.message_votes.id; + + +-- +-- Name: messages; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messages ( + id bigint NOT NULL, + conversation_id integer, + sender_id integer, + content text, + message_type character varying(255) DEFAULT 'text'::character varying, + media_urls character varying(255)[] DEFAULT ARRAY[]::character varying[], + reply_to_id bigint, + edited_at timestamp(0) without time zone, + deleted_at timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + visibility character varying(255) DEFAULT 'conversation'::character varying, + post_type character varying(255) DEFAULT 'message'::character varying, + like_count integer DEFAULT 0, + reply_count integer DEFAULT 0, + share_count integer DEFAULT 0, + link_preview_id bigint, + extracted_urls character varying(255)[] DEFAULT ARRAY[]::character varying[], + extracted_hashtags character varying(255)[] DEFAULT ARRAY[]::character varying[], + upvotes integer DEFAULT 0, + downvotes integer DEFAULT 0, + score integer DEFAULT 0, + original_message_id bigint, + shared_message_id bigint, + promoted_from character varying(255), + share_type character varying(255), + title character varying(255), + auto_title boolean DEFAULT false, + promoted_from_community_name character varying(255), + promoted_from_community_hash character varying(255), + flair_id bigint, + is_pinned boolean DEFAULT false, + pinned_at timestamp(0) without time zone, + pinned_by_id bigint, + media_metadata jsonb DEFAULT '{}'::jsonb, + encrypted_content jsonb, + search_index text[] DEFAULT ARRAY[]::text[], + primary_url text, + locked_at timestamp(0) without time zone, + locked_by_id bigint, + lock_reason text, + approval_status character varying(255), + approved_by_id bigint, + approved_at timestamp(0) without time zone, + activitypub_id text, + activitypub_url text, + federated boolean DEFAULT false, + remote_actor_id bigint, + content_warning text, + sensitive boolean DEFAULT false, + dislike_count integer DEFAULT 0, + quoted_message_id bigint, + quote_count integer DEFAULT 0, + category character varying(255), + is_draft boolean DEFAULT false, + scheduled_at timestamp(0) without time zone, + bluesky_uri text, + bluesky_cid character varying(255), + activitypub_id_canonical text, + activitypub_url_canonical text, + CONSTRAINT messages_post_type_check CHECK (((post_type)::text = ANY ((ARRAY['message'::character varying, 'post'::character varying, 'comment'::character varying, 'share'::character varying, 'discussion'::character varying, 'link'::character varying, 'poll'::character varying, 'gallery'::character varying])::text[]))) +); + + +-- +-- Name: messages_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messages_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messages_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messages_id_seq OWNED BY public.messages.id; + + +-- +-- Name: messaging_federation_account_presence_states; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_federation_account_presence_states ( + id bigint NOT NULL, + remote_actor_id bigint NOT NULL, + origin_domain character varying(255) NOT NULL, + status character varying(255) NOT NULL, + activities jsonb DEFAULT '{}'::jsonb NOT NULL, + updated_at_remote timestamp(0) without time zone NOT NULL, + expires_at_remote timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: messaging_federation_account_presence_states_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_federation_account_presence_states_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_federation_account_presence_states_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_federation_account_presence_states_id_seq OWNED BY public.messaging_federation_account_presence_states.id; + + +-- +-- Name: messaging_federation_call_sessions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_federation_call_sessions ( + id bigint NOT NULL, + conversation_id bigint NOT NULL, + local_user_id bigint NOT NULL, + federated_call_id character varying(255) NOT NULL, + origin_domain character varying(255) NOT NULL, + remote_domain character varying(255) NOT NULL, + remote_handle character varying(255) NOT NULL, + remote_actor jsonb DEFAULT '{}'::jsonb NOT NULL, + call_type character varying(255) NOT NULL, + direction character varying(255) NOT NULL, + status character varying(255) NOT NULL, + metadata jsonb DEFAULT '{}'::jsonb NOT NULL, + started_at_remote timestamp(0) without time zone, + ended_at_remote timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: messaging_federation_call_sessions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_federation_call_sessions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_federation_call_sessions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_federation_call_sessions_id_seq OWNED BY public.messaging_federation_call_sessions.id; + + +-- +-- Name: messaging_federation_discovered_peers; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_federation_discovered_peers ( + id bigint NOT NULL, + domain character varying(255) NOT NULL, + base_url character varying(255) NOT NULL, + discovery_url character varying(255) NOT NULL, + protocol character varying(255), + protocol_id character varying(255), + protocol_version character varying(255), + trust_state character varying(255) DEFAULT 'trusted'::character varying NOT NULL, + identity_fingerprint character varying(255) NOT NULL, + previous_identity_fingerprint character varying(255), + last_key_change_at timestamp(0) without time zone, + identity jsonb DEFAULT '{}'::jsonb NOT NULL, + endpoints jsonb DEFAULT '{}'::jsonb NOT NULL, + features jsonb DEFAULT '{}'::jsonb NOT NULL, + last_discovered_at timestamp(0) without time zone NOT NULL, + last_error text, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: messaging_federation_discovered_peers_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_federation_discovered_peers_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_federation_discovered_peers_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_federation_discovered_peers_id_seq OWNED BY public.messaging_federation_discovered_peers.id; + + +-- +-- Name: messaging_federation_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_federation_events ( + id bigint NOT NULL, + event_id character varying(255) NOT NULL, + origin_domain character varying(255) NOT NULL, + event_type character varying(255) NOT NULL, + stream_id character varying(255) NOT NULL, + sequence bigint NOT NULL, + payload jsonb NOT NULL, + received_at timestamp(0) without time zone NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + protocol_version character varying(255) DEFAULT '1.0'::character varying NOT NULL, + idempotency_key character varying(255) NOT NULL +); + + +-- +-- Name: messaging_federation_events_archive; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_federation_events_archive ( + id bigint NOT NULL, + event_id character varying(255) NOT NULL, + origin_domain character varying(255) NOT NULL, + event_type character varying(255) NOT NULL, + stream_id character varying(255) NOT NULL, + sequence bigint NOT NULL, + payload jsonb NOT NULL, + received_at timestamp(0) without time zone NOT NULL, + partition_month date NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + protocol_version character varying(255) DEFAULT '1.0'::character varying NOT NULL, + idempotency_key character varying(255) NOT NULL +); + + +-- +-- Name: messaging_federation_events_archive_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_federation_events_archive_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_federation_events_archive_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_federation_events_archive_id_seq OWNED BY public.messaging_federation_events_archive.id; + + +-- +-- Name: messaging_federation_events_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_federation_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_federation_events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_federation_events_id_seq OWNED BY public.messaging_federation_events.id; + + +-- +-- Name: messaging_federation_extension_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_federation_extension_events ( + id bigint NOT NULL, + event_type character varying(255) NOT NULL, + origin_domain character varying(255) NOT NULL, + event_key character varying(255) NOT NULL, + status character varying(255), + payload jsonb DEFAULT '{}'::jsonb NOT NULL, + occurred_at timestamp(0) without time zone, + server_id bigint, + conversation_id bigint, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: messaging_federation_extension_events_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_federation_extension_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_federation_extension_events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_federation_extension_events_id_seq OWNED BY public.messaging_federation_extension_events.id; + + +-- +-- Name: messaging_federation_invite_states; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_federation_invite_states ( + id bigint NOT NULL, + conversation_id bigint NOT NULL, + origin_domain character varying(255) NOT NULL, + actor_uri character varying(255) NOT NULL, + actor_payload jsonb DEFAULT '{}'::jsonb NOT NULL, + target_uri character varying(255) NOT NULL, + target_payload jsonb DEFAULT '{}'::jsonb NOT NULL, + role character varying(255) NOT NULL, + state character varying(255) NOT NULL, + invited_at_remote timestamp(0) without time zone, + updated_at_remote timestamp(0) without time zone NOT NULL, + metadata jsonb DEFAULT '{}'::jsonb NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: messaging_federation_invite_states_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_federation_invite_states_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_federation_invite_states_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_federation_invite_states_id_seq OWNED BY public.messaging_federation_invite_states.id; + + +-- +-- Name: messaging_federation_membership_states; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_federation_membership_states ( + id bigint NOT NULL, + conversation_id bigint NOT NULL, + remote_actor_id bigint NOT NULL, + origin_domain character varying(255) NOT NULL, + role character varying(255) NOT NULL, + state character varying(255) NOT NULL, + joined_at_remote timestamp(0) without time zone, + updated_at_remote timestamp(0) without time zone NOT NULL, + metadata jsonb DEFAULT '{}'::jsonb NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: messaging_federation_membership_states_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_federation_membership_states_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_federation_membership_states_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_federation_membership_states_id_seq OWNED BY public.messaging_federation_membership_states.id; + + +-- +-- Name: messaging_federation_outbox_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_federation_outbox_events ( + id bigint NOT NULL, + event_id character varying(255) NOT NULL, + event_type character varying(255) NOT NULL, + stream_id character varying(255) NOT NULL, + sequence bigint NOT NULL, + payload jsonb NOT NULL, + target_domains character varying(255)[] DEFAULT ARRAY[]::character varying[] NOT NULL, + delivered_domains character varying(255)[] DEFAULT ARRAY[]::character varying[] NOT NULL, + attempt_count integer DEFAULT 0 NOT NULL, + max_attempts integer DEFAULT 8 NOT NULL, + status character varying(255) DEFAULT 'pending'::character varying NOT NULL, + next_retry_at timestamp(0) without time zone NOT NULL, + last_error text, + partition_month date DEFAULT (date_trunc('month'::text, now()))::date NOT NULL, + dispatched_at timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: messaging_federation_outbox_events_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_federation_outbox_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_federation_outbox_events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_federation_outbox_events_id_seq OWNED BY public.messaging_federation_outbox_events.id; + + +-- +-- Name: messaging_federation_peer_policies; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_federation_peer_policies ( + id bigint NOT NULL, + domain character varying(255) NOT NULL, + allow_incoming boolean, + allow_outgoing boolean, + blocked boolean DEFAULT false NOT NULL, + reason text, + updated_by_id bigint, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: messaging_federation_peer_policies_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_federation_peer_policies_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_federation_peer_policies_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_federation_peer_policies_id_seq OWNED BY public.messaging_federation_peer_policies.id; + + +-- +-- Name: messaging_federation_presence_states; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_federation_presence_states ( + id bigint NOT NULL, + server_id bigint NOT NULL, + remote_actor_id bigint NOT NULL, + origin_domain character varying(255) NOT NULL, + status character varying(255) NOT NULL, + activities jsonb DEFAULT '{}'::jsonb NOT NULL, + updated_at_remote timestamp(0) without time zone NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + expires_at_remote timestamp(0) without time zone +); + + +-- +-- Name: messaging_federation_presence_states_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_federation_presence_states_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_federation_presence_states_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_federation_presence_states_id_seq OWNED BY public.messaging_federation_presence_states.id; + + +-- +-- Name: messaging_federation_read_cursors; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_federation_read_cursors ( + id bigint NOT NULL, + conversation_id bigint NOT NULL, + chat_message_id bigint NOT NULL, + remote_actor_id bigint NOT NULL, + origin_domain character varying(255) NOT NULL, + read_at timestamp(0) without time zone NOT NULL, + read_through_sequence integer, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: messaging_federation_read_cursors_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_federation_read_cursors_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_federation_read_cursors_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_federation_read_cursors_id_seq OWNED BY public.messaging_federation_read_cursors.id; + + +-- +-- Name: messaging_federation_read_receipts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_federation_read_receipts ( + id bigint NOT NULL, + chat_message_id bigint NOT NULL, + remote_actor_id bigint NOT NULL, + origin_domain character varying(255) NOT NULL, + read_at timestamp(0) without time zone NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: messaging_federation_read_receipts_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_federation_read_receipts_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_federation_read_receipts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_federation_read_receipts_id_seq OWNED BY public.messaging_federation_read_receipts.id; + + +-- +-- Name: messaging_federation_request_replays; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_federation_request_replays ( + id bigint NOT NULL, + nonce character varying(255) NOT NULL, + origin_domain character varying(255) NOT NULL, + key_id character varying(255), + http_method character varying(255) NOT NULL, + request_path character varying(255) NOT NULL, + "timestamp" bigint NOT NULL, + seen_at timestamp(0) without time zone NOT NULL, + expires_at timestamp(0) without time zone NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: messaging_federation_request_replays_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_federation_request_replays_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_federation_request_replays_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_federation_request_replays_id_seq OWNED BY public.messaging_federation_request_replays.id; + + +-- +-- Name: messaging_federation_room_presence_states; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_federation_room_presence_states ( + id bigint NOT NULL, + conversation_id bigint NOT NULL, + remote_actor_id bigint NOT NULL, + origin_domain character varying(255) NOT NULL, + status character varying(255) NOT NULL, + activities jsonb DEFAULT '{}'::jsonb NOT NULL, + updated_at_remote timestamp(0) without time zone NOT NULL, + expires_at_remote timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: messaging_federation_room_presence_states_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_federation_room_presence_states_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_federation_room_presence_states_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_federation_room_presence_states_id_seq OWNED BY public.messaging_federation_room_presence_states.id; + + +-- +-- Name: messaging_federation_stream_counters; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_federation_stream_counters ( + id bigint NOT NULL, + stream_id character varying(255) NOT NULL, + next_sequence bigint DEFAULT 1 NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: messaging_federation_stream_counters_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_federation_stream_counters_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_federation_stream_counters_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_federation_stream_counters_id_seq OWNED BY public.messaging_federation_stream_counters.id; + + +-- +-- Name: messaging_federation_stream_positions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_federation_stream_positions ( + id bigint NOT NULL, + origin_domain character varying(255) NOT NULL, + stream_id character varying(255) NOT NULL, + last_sequence bigint DEFAULT 0 NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: messaging_federation_stream_positions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_federation_stream_positions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_federation_stream_positions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_federation_stream_positions_id_seq OWNED BY public.messaging_federation_stream_positions.id; + + +-- +-- Name: messaging_server_members; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_server_members ( + id bigint NOT NULL, + server_id bigint NOT NULL, + user_id bigint NOT NULL, + role character varying(255) DEFAULT 'member'::character varying NOT NULL, + joined_at timestamp(0) without time zone DEFAULT now(), + left_at timestamp(0) without time zone, + notifications_enabled boolean DEFAULT true NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: messaging_server_members_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_server_members_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_server_members_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_server_members_id_seq OWNED BY public.messaging_server_members.id; + + +-- +-- Name: messaging_servers; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.messaging_servers ( + id bigint NOT NULL, + name character varying(255) NOT NULL, + description text, + icon_url character varying(255), + is_public boolean DEFAULT false NOT NULL, + member_count integer DEFAULT 0 NOT NULL, + last_activity_at timestamp(0) without time zone, + creator_id bigint, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + last_federated_at timestamp(0) without time zone, + federation_id character varying(255), + origin_domain character varying(255), + is_federated_mirror boolean DEFAULT false NOT NULL +); + + +-- +-- Name: messaging_servers_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.messaging_servers_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: messaging_servers_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.messaging_servers_id_seq OWNED BY public.messaging_servers.id; + + +-- +-- Name: moderation_actions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.moderation_actions ( + id bigint NOT NULL, + action_type character varying(255) NOT NULL, + target_user_id bigint NOT NULL, + moderator_id bigint NOT NULL, + conversation_id bigint, + reason character varying(255), + duration integer, + details jsonb, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + target_message_id bigint +); + + +-- +-- Name: moderation_actions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.moderation_actions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: moderation_actions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.moderation_actions_id_seq OWNED BY public.moderation_actions.id; + + +-- +-- Name: moderator_notes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.moderator_notes ( + id bigint NOT NULL, + conversation_id bigint NOT NULL, + target_user_id bigint NOT NULL, + created_by_id bigint NOT NULL, + note text NOT NULL, + is_important boolean DEFAULT false, + inserted_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: moderator_notes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.moderator_notes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: moderator_notes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.moderator_notes_id_seq OWNED BY public.moderator_notes.id; + + +-- +-- Name: notifications; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.notifications ( + id bigint NOT NULL, + type character varying(255) NOT NULL, + title character varying(255) NOT NULL, + body text, + url character varying(255), + icon character varying(255), + priority character varying(255) DEFAULT 'normal'::character varying, + read_at timestamp(0) without time zone, + seen_at timestamp(0) without time zone, + dismissed_at timestamp(0) without time zone, + user_id bigint NOT NULL, + actor_id bigint, + source_type character varying(255), + source_id integer, + metadata jsonb DEFAULT '{}'::jsonb, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: notifications_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.notifications_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: notifications_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.notifications_id_seq OWNED BY public.notifications.id; + + +-- +-- Name: oauth_apps; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.oauth_apps ( + id bigint NOT NULL, + client_name character varying(255) NOT NULL, + redirect_uris text NOT NULL, + scopes character varying(255)[] DEFAULT ARRAY['read'::character varying] NOT NULL, + website character varying(255), + client_id character varying(255) NOT NULL, + client_secret character varying(255) NOT NULL, + trusted boolean DEFAULT false NOT NULL, + user_id bigint, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: oauth_apps_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.oauth_apps_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: oauth_apps_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.oauth_apps_id_seq OWNED BY public.oauth_apps.id; + + +-- +-- Name: oauth_authorizations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.oauth_authorizations ( + id bigint NOT NULL, + token character varying(255) NOT NULL, + scopes character varying(255)[] DEFAULT ARRAY[]::character varying[] NOT NULL, + valid_until timestamp(0) without time zone NOT NULL, + used boolean DEFAULT false NOT NULL, + user_id bigint, + app_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + redirect_uri text, + state text, + nonce text, + code_challenge text, + code_challenge_method character varying(255) +); + + +-- +-- Name: oauth_authorizations_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.oauth_authorizations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: oauth_authorizations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.oauth_authorizations_id_seq OWNED BY public.oauth_authorizations.id; + + +-- +-- Name: oauth_tokens; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.oauth_tokens ( + id bigint NOT NULL, + token character varying(255) NOT NULL, + refresh_token character varying(255) NOT NULL, + scopes character varying(255)[] DEFAULT ARRAY[]::character varying[] NOT NULL, + valid_until timestamp(0) without time zone NOT NULL, + user_id bigint, + app_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + oidc_nonce text, + oidc_auth_time timestamp(0) without time zone +); + + +-- +-- Name: oauth_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.oauth_tokens_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: oauth_tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.oauth_tokens_id_seq OWNED BY public.oauth_tokens.id; + + +-- +-- Name: oban_jobs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.oban_jobs ( + id bigint NOT NULL, + state public.oban_job_state DEFAULT 'available'::public.oban_job_state NOT NULL, + queue text DEFAULT 'default'::text NOT NULL, + worker text NOT NULL, + args jsonb DEFAULT '{}'::jsonb NOT NULL, + errors jsonb[] DEFAULT ARRAY[]::jsonb[] NOT NULL, + attempt integer DEFAULT 0 NOT NULL, + max_attempts integer DEFAULT 20 NOT NULL, + inserted_at timestamp without time zone DEFAULT timezone('UTC'::text, now()) NOT NULL, + scheduled_at timestamp without time zone DEFAULT timezone('UTC'::text, now()) NOT NULL, + attempted_at timestamp without time zone, + completed_at timestamp without time zone, + attempted_by text[], + discarded_at timestamp without time zone, + priority integer DEFAULT 0 NOT NULL, + tags text[] DEFAULT ARRAY[]::text[], + meta jsonb DEFAULT '{}'::jsonb, + cancelled_at timestamp without time zone, + CONSTRAINT attempt_range CHECK (((attempt >= 0) AND (attempt <= max_attempts))), + CONSTRAINT positive_max_attempts CHECK ((max_attempts > 0)), + CONSTRAINT queue_length CHECK (((char_length(queue) > 0) AND (char_length(queue) < 128))), + CONSTRAINT worker_length CHECK (((char_length(worker) > 0) AND (char_length(worker) < 128))) +); + + +-- +-- Name: TABLE oban_jobs; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.oban_jobs IS '12'; + + +-- +-- Name: oban_jobs_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.oban_jobs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: oban_jobs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.oban_jobs_id_seq OWNED BY public.oban_jobs.id; + + +-- +-- Name: oban_peers; Type: TABLE; Schema: public; Owner: - +-- + +CREATE UNLOGGED TABLE public.oban_peers ( + name text NOT NULL, + node text NOT NULL, + started_at timestamp without time zone NOT NULL, + expires_at timestamp without time zone NOT NULL +); + + +-- +-- Name: oidc_signing_keys; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.oidc_signing_keys ( + id bigint NOT NULL, + kid character varying(255) NOT NULL, + alg character varying(255) DEFAULT 'RS256'::character varying NOT NULL, + public_key_pem text NOT NULL, + private_key_pem text NOT NULL, + active boolean DEFAULT true NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: oidc_signing_keys_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.oidc_signing_keys_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: oidc_signing_keys_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.oidc_signing_keys_id_seq OWNED BY public.oidc_signing_keys.id; + + +-- +-- Name: passkey_credentials; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.passkey_credentials ( + id bigint NOT NULL, + user_id bigint NOT NULL, + credential_id bytea NOT NULL, + public_key bytea NOT NULL, + sign_count integer DEFAULT 0 NOT NULL, + user_handle bytea NOT NULL, + name character varying(255) DEFAULT 'Passkey'::character varying, + aaguid bytea, + transports character varying(255)[] DEFAULT ARRAY[]::character varying[], + last_used_at timestamp(0) without time zone, + created_from_ip character varying(255), + created_user_agent character varying(255), + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: passkey_credentials_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.passkey_credentials_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: passkey_credentials_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.passkey_credentials_id_seq OWNED BY public.passkey_credentials.id; + + +-- +-- Name: nerve_entries; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.nerve_entries ( + id bigint NOT NULL, + user_id bigint NOT NULL, + title character varying(255) NOT NULL, + login_username character varying(255), + website character varying(255), + encrypted_metadata jsonb, + encrypted_password jsonb NOT NULL, + encrypted_notes jsonb, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: nerve_entries_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.nerve_entries_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: nerve_entries_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.nerve_entries_id_seq OWNED BY public.nerve_entries.id; + + +-- +-- Name: nerve_settings; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.nerve_settings ( + id bigint NOT NULL, + user_id bigint NOT NULL, + encrypted_verifier jsonb NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: nerve_settings_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.nerve_settings_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: nerve_settings_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.nerve_settings_id_seq OWNED BY public.nerve_settings.id; + + +-- +-- Name: pgp_key_cache; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.pgp_key_cache ( + id bigint NOT NULL, + email character varying(255) NOT NULL, + public_key text, + key_id character varying(255), + fingerprint character varying(255), + source character varying(255), + status character varying(255) DEFAULT 'found'::character varying, + expires_at timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: pgp_key_cache_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.pgp_key_cache_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: pgp_key_cache_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.pgp_key_cache_id_seq OWNED BY public.pgp_key_cache.id; + + +-- +-- Name: platform_updates; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.platform_updates ( + id bigint NOT NULL, + title character varying(255) NOT NULL, + description text, + badge character varying(255), + items character varying(255)[] DEFAULT ARRAY[]::character varying[], + published boolean DEFAULT true, + created_by_id bigint, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: platform_updates_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.platform_updates_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: platform_updates_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.platform_updates_id_seq OWNED BY public.platform_updates.id; + + +-- +-- Name: poll_options; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.poll_options ( + id bigint NOT NULL, + poll_id bigint NOT NULL, + option_text character varying(255) NOT NULL, + "position" integer DEFAULT 0, + vote_count integer DEFAULT 0, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: poll_options_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.poll_options_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: poll_options_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.poll_options_id_seq OWNED BY public.poll_options.id; + + +-- +-- Name: poll_votes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.poll_votes ( + id bigint NOT NULL, + poll_id bigint NOT NULL, + option_id bigint NOT NULL, + user_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: poll_votes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.poll_votes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: poll_votes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.poll_votes_id_seq OWNED BY public.poll_votes.id; + + +-- +-- Name: polls; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.polls ( + id bigint NOT NULL, + message_id bigint NOT NULL, + question text NOT NULL, + closes_at timestamp(0) without time zone, + allow_multiple boolean DEFAULT false, + total_votes integer DEFAULT 0, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + voters_count integer DEFAULT 0, + voter_uris character varying(255)[] DEFAULT ARRAY[]::character varying[], + hide_totals boolean DEFAULT false NOT NULL, + last_fetched_at timestamp(0) without time zone +); + + +-- +-- Name: polls_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.polls_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: polls_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.polls_id_seq OWNED BY public.polls.id; + + +-- +-- Name: post_boosts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.post_boosts ( + id bigint NOT NULL, + user_id bigint NOT NULL, + message_id bigint NOT NULL, + activitypub_id text, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: post_boosts_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.post_boosts_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: post_boosts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.post_boosts_id_seq OWNED BY public.post_boosts.id; + + +-- +-- Name: post_dismissals; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.post_dismissals ( + id bigint NOT NULL, + user_id bigint NOT NULL, + message_id bigint NOT NULL, + dismissal_type character varying(255) NOT NULL, + dwell_time_ms integer, + inserted_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: post_dismissals_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.post_dismissals_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: post_dismissals_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.post_dismissals_id_seq OWNED BY public.post_dismissals.id; + + +-- +-- Name: post_hashtags; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.post_hashtags ( + id bigint NOT NULL, + message_id bigint NOT NULL, + hashtag_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: post_hashtags_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.post_hashtags_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: post_hashtags_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.post_hashtags_id_seq OWNED BY public.post_hashtags.id; + + +-- +-- Name: post_likes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.post_likes ( + id bigint NOT NULL, + user_id bigint NOT NULL, + message_id bigint NOT NULL, + created_at timestamp(0) without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: post_likes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.post_likes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: post_likes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.post_likes_id_seq OWNED BY public.post_likes.id; + + +-- +-- Name: post_views; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.post_views ( + id bigint NOT NULL, + user_id bigint NOT NULL, + message_id bigint NOT NULL, + view_duration_seconds integer, + completed boolean DEFAULT false, + inserted_at timestamp(0) without time zone NOT NULL, + dwell_time_ms integer, + scroll_depth double precision, + expanded boolean DEFAULT false, + source character varying(255) +); + + +-- +-- Name: post_views_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.post_views_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: post_views_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.post_views_id_seq OWNED BY public.post_views.id; + + +-- +-- Name: profile_custom_domains; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.profile_custom_domains ( + id bigint NOT NULL, + domain character varying(255) NOT NULL, + verification_token character varying(255) NOT NULL, + status character varying(255) DEFAULT 'pending'::character varying NOT NULL, + verified_at timestamp(0) without time zone, + last_checked_at timestamp(0) without time zone, + last_error character varying(255), + user_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: profile_custom_domains_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.profile_custom_domains_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: profile_custom_domains_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.profile_custom_domains_id_seq OWNED BY public.profile_custom_domains.id; + + +-- +-- Name: profile_links; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.profile_links ( + id bigint NOT NULL, + profile_id bigint NOT NULL, + title character varying(255) NOT NULL, + url character varying(255) NOT NULL, + description character varying(255), + icon character varying(255) DEFAULT 'hero-link'::character varying, + platform character varying(255), + "position" integer DEFAULT 0, + clicks integer DEFAULT 0, + is_active boolean DEFAULT true, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + section character varying(255), + thumbnail_url character varying(255), + display_style character varying(255), + highlight_effect character varying(255) +); + + +-- +-- Name: profile_links_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.profile_links_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: profile_links_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.profile_links_id_seq OWNED BY public.profile_links.id; + + +-- +-- Name: profile_site_visits; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.profile_site_visits ( + id bigint NOT NULL, + profile_user_id bigint NOT NULL, + viewer_user_id bigint, + visitor_id character varying(255), + ip_address character varying(255), + user_agent text, + referer text, + request_host character varying(255) NOT NULL, + request_path text NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: profile_site_visits_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.profile_site_visits_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: profile_site_visits_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.profile_site_visits_id_seq OWNED BY public.profile_site_visits.id; + + +-- +-- Name: profile_views; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.profile_views ( + id bigint NOT NULL, + profile_user_id bigint NOT NULL, + viewer_user_id bigint, + viewer_session_id character varying(255), + ip_address character varying(255), + user_agent text, + referer text, + inserted_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: profile_views_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.profile_views_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: profile_views_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.profile_views_id_seq OWNED BY public.profile_views.id; + + +-- +-- Name: profile_widgets; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.profile_widgets ( + id bigint NOT NULL, + profile_id bigint NOT NULL, + widget_type character varying(255) NOT NULL, + title character varying(255), + content text, + url character varying(255), + "position" integer DEFAULT 0, + is_active boolean DEFAULT true, + settings jsonb DEFAULT '{}'::jsonb, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: profile_widgets_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.profile_widgets_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: profile_widgets_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.profile_widgets_id_seq OWNED BY public.profile_widgets.id; + + +-- +-- Name: registration_checkouts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.registration_checkouts ( + id bigint NOT NULL, + stripe_checkout_session_id character varying(255) NOT NULL, + lookup_token character varying(255) NOT NULL, + product_slug character varying(255) NOT NULL, + stripe_customer_id character varying(255), + stripe_payment_intent_id character varying(255), + customer_email character varying(255), + status character varying(255) DEFAULT 'pending'::character varying NOT NULL, + fulfilled_at timestamp(0) without time zone, + invite_code_id bigint, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + redeemed_at timestamp(0) without time zone, + redeemed_by_user_id bigint +); + + +-- +-- Name: registration_checkouts_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.registration_checkouts_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: registration_checkouts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.registration_checkouts_id_seq OWNED BY public.registration_checkouts.id; + + +-- +-- Name: remote_interactions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.remote_interactions ( + id bigint NOT NULL, + interaction_type character varying(255) NOT NULL, + actor_uri character varying(255) NOT NULL, + emoji character varying(255), + message_id bigint NOT NULL, + remote_actor_id bigint, + inserted_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: remote_interactions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.remote_interactions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: remote_interactions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.remote_interactions_id_seq OWNED BY public.remote_interactions.id; + + +-- +-- Name: reports; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.reports ( + id bigint NOT NULL, + reporter_id bigint NOT NULL, + reportable_type character varying(255) NOT NULL, + reportable_id integer NOT NULL, + reason character varying(255) NOT NULL, + description text, + screenshots character varying(255)[] DEFAULT ARRAY[]::character varying[], + status character varying(255) DEFAULT 'pending'::character varying, + priority character varying(255) DEFAULT 'normal'::character varying, + reviewed_by_id bigint, + reviewed_at timestamp(0) without time zone, + resolution_notes text, + action_taken character varying(255), + metadata jsonb DEFAULT '{}'::jsonb, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: reports_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.reports_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: reports_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.reports_id_seq OWNED BY public.reports.id; + + +-- +-- Name: rss_feeds; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.rss_feeds ( + id bigint NOT NULL, + url text NOT NULL, + title character varying(255), + description text, + site_url character varying(255), + favicon_url character varying(255), + image_url character varying(255), + last_fetched_at timestamp(0) without time zone, + last_error text, + fetch_interval_minutes integer DEFAULT 60, + status character varying(255) DEFAULT 'active'::character varying, + etag character varying(255), + last_modified character varying(255), + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: rss_feeds_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.rss_feeds_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: rss_feeds_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.rss_feeds_id_seq OWNED BY public.rss_feeds.id; + + +-- +-- Name: rss_items; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.rss_items ( + id bigint NOT NULL, + feed_id bigint NOT NULL, + guid character varying(255) NOT NULL, + title character varying(255), + content text, + summary text, + url text, + author character varying(255), + published_at timestamp(0) without time zone, + image_url character varying(255), + enclosure_url text, + enclosure_type character varying(255), + categories character varying(255)[] DEFAULT ARRAY[]::character varying[], + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: rss_items_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.rss_items_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: rss_items_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.rss_items_id_seq OWNED BY public.rss_items.id; + + +-- +-- Name: rss_subscriptions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.rss_subscriptions ( + id bigint NOT NULL, + user_id bigint NOT NULL, + feed_id bigint NOT NULL, + display_name character varying(255), + folder character varying(255), + notify_new_items boolean DEFAULT false, + show_in_timeline boolean DEFAULT true, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: rss_subscriptions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.rss_subscriptions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: rss_subscriptions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.rss_subscriptions_id_seq OWNED BY public.rss_subscriptions.id; + + +-- +-- Name: saved_items; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.saved_items ( + id bigint NOT NULL, + user_id bigint NOT NULL, + message_id bigint, + rss_item_id bigint, + folder character varying(255), + notes text, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + CONSTRAINT saved_items_one_reference CHECK ((((message_id IS NOT NULL) AND (rss_item_id IS NULL)) OR ((message_id IS NULL) AND (rss_item_id IS NOT NULL)))) +); + + +-- +-- Name: saved_items_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.saved_items_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: saved_items_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.saved_items_id_seq OWNED BY public.saved_items.id; + + +-- +-- Name: signing_keys; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.signing_keys ( + key_id character varying(255) NOT NULL, + user_id bigint, + remote_actor_id bigint, + public_key text NOT NULL, + private_key text, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + CONSTRAINT must_have_owner CHECK ((((user_id IS NOT NULL) AND (remote_actor_id IS NULL)) OR ((user_id IS NULL) AND (remote_actor_id IS NOT NULL)))) +); + + +-- +-- Name: static_site_files; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.static_site_files ( + id bigint NOT NULL, + user_id bigint NOT NULL, + path character varying(255) NOT NULL, + storage_key character varying(255) NOT NULL, + content_type character varying(255) NOT NULL, + size integer NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: static_site_files_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.static_site_files_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: static_site_files_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.static_site_files_id_seq OWNED BY public.static_site_files.id; + + +-- +-- Name: stored_files; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.stored_files ( + id bigint NOT NULL, + user_id bigint NOT NULL, + path character varying(255) NOT NULL, + storage_key character varying(255) NOT NULL, + original_filename character varying(255) NOT NULL, + content_type character varying(255) NOT NULL, + size bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: stored_files_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.stored_files_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: stored_files_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.stored_files_id_seq OWNED BY public.stored_files.id; + + +-- +-- Name: stored_folders; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.stored_folders ( + id bigint NOT NULL, + user_id bigint NOT NULL, + path character varying(255) NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: stored_folders_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.stored_folders_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: stored_folders_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.stored_folders_id_seq OWNED BY public.stored_folders.id; + + +-- +-- Name: subscription_products; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.subscription_products ( + id bigint NOT NULL, + name character varying(255) NOT NULL, + slug character varying(255) NOT NULL, + description text, + features character varying(255)[] DEFAULT ARRAY[]::character varying[], + stripe_monthly_price_id character varying(255), + stripe_yearly_price_id character varying(255), + monthly_price_cents integer, + yearly_price_cents integer, + currency character varying(255) DEFAULT 'usd'::character varying, + active boolean DEFAULT true, + sort_order integer DEFAULT 0, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + billing_type character varying(255) DEFAULT 'recurring'::character varying NOT NULL, + stripe_one_time_price_id character varying(255), + one_time_price_cents integer +); + + +-- +-- Name: subscription_products_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.subscription_products_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: subscription_products_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.subscription_products_id_seq OWNED BY public.subscription_products.id; + + +-- +-- Name: subscriptions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.subscriptions ( + id bigint NOT NULL, + user_id bigint NOT NULL, + product character varying(255) NOT NULL, + stripe_customer_id character varying(255), + stripe_subscription_id character varying(255), + stripe_price_id character varying(255), + status character varying(255) DEFAULT 'incomplete'::character varying NOT NULL, + current_period_start timestamp(0) without time zone, + current_period_end timestamp(0) without time zone, + canceled_at timestamp(0) without time zone, + cancel_at_period_end boolean DEFAULT false, + metadata jsonb DEFAULT '{}'::jsonb, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: subscriptions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.subscriptions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: subscriptions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.subscriptions_id_seq OWNED BY public.subscriptions.id; + + +-- +-- Name: system_config; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.system_config ( + id bigint NOT NULL, + key character varying(255) NOT NULL, + value text, + type character varying(255) DEFAULT 'string'::character varying, + description text, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: system_config_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.system_config_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: system_config_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.system_config_id_seq OWNED BY public.system_config.id; + + +-- +-- Name: trust_level_logs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.trust_level_logs ( + id bigint NOT NULL, + user_id bigint NOT NULL, + old_level integer NOT NULL, + new_level integer NOT NULL, + reason character varying(255), + changed_by_user_id bigint, + notes text, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: trust_level_logs_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.trust_level_logs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: trust_level_logs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.trust_level_logs_id_seq OWNED BY public.trust_level_logs.id; + + +-- +-- Name: trusted_devices; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.trusted_devices ( + id bigint NOT NULL, + user_id bigint NOT NULL, + device_token character varying(255) NOT NULL, + device_name character varying(255), + user_agent character varying(255), + ip_address character varying(255), + last_used_at timestamp(0) without time zone, + expires_at timestamp(0) without time zone, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: trusted_devices_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.trusted_devices_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: trusted_devices_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.trusted_devices_id_seq OWNED BY public.trusted_devices.id; + + +-- +-- Name: user_activity_stats; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_activity_stats ( + id bigint NOT NULL, + user_id bigint NOT NULL, + posts_created integer DEFAULT 0, + topics_created integer DEFAULT 0, + replies_created integer DEFAULT 0, + likes_given integer DEFAULT 0, + likes_received integer DEFAULT 0, + replies_received integer DEFAULT 0, + posts_read integer DEFAULT 0, + topics_entered integer DEFAULT 0, + time_read_seconds integer DEFAULT 0, + days_visited integer DEFAULT 0, + last_visit_date date, + flags_given integer DEFAULT 0, + flags_received integer DEFAULT 0, + flags_agreed integer DEFAULT 0, + posts_deleted integer DEFAULT 0, + suspensions_count integer DEFAULT 0, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: user_activity_stats_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_activity_stats_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_activity_stats_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_activity_stats_id_seq OWNED BY public.user_activity_stats.id; + + +-- +-- Name: user_badges; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_badges ( + id bigint NOT NULL, + user_id bigint NOT NULL, + badge_type character varying(255) NOT NULL, + badge_text character varying(255), + badge_color character varying(255) DEFAULT '#8b5cf6'::character varying, + badge_icon character varying(255), + tooltip character varying(255), + granted_by_id bigint, + "position" integer DEFAULT 0, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + visible boolean DEFAULT true +); + + +-- +-- Name: user_badges_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_badges_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_badges_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_badges_id_seq OWNED BY public.user_badges.id; + + +-- +-- Name: user_blocks; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_blocks ( + id bigint NOT NULL, + blocker_id bigint NOT NULL, + blocked_id bigint NOT NULL, + reason character varying(255), + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: user_blocks_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_blocks_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_blocks_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_blocks_id_seq OWNED BY public.user_blocks.id; + + +-- +-- Name: user_hidden_messages; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_hidden_messages ( + id bigint NOT NULL, + user_id bigint NOT NULL, + message_id bigint NOT NULL, + hidden_at timestamp(0) without time zone DEFAULT now(), + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: user_hidden_messages_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_hidden_messages_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_hidden_messages_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_hidden_messages_id_seq OWNED BY public.user_hidden_messages.id; + + +-- +-- Name: user_integrations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_integrations ( + id bigint NOT NULL, + user_id bigint NOT NULL, + provider character varying(255) NOT NULL, + provider_user_id character varying(255), + username character varying(255), + avatar_url character varying(255), + access_token bytea, + refresh_token bytea, + token_expires_at timestamp(0) without time zone, + scopes character varying(255)[] DEFAULT ARRAY[]::character varying[], + metadata jsonb DEFAULT '{}'::jsonb, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: user_integrations_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_integrations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_integrations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_integrations_id_seq OWNED BY public.user_integrations.id; + + +-- +-- Name: user_mutes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_mutes ( + id bigint NOT NULL, + muter_id bigint NOT NULL, + muted_id bigint NOT NULL, + mute_notifications boolean DEFAULT false NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: user_mutes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_mutes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_mutes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_mutes_id_seq OWNED BY public.user_mutes.id; + + +-- +-- Name: user_post_timestamps; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_post_timestamps ( + id bigint NOT NULL, + conversation_id bigint NOT NULL, + user_id bigint NOT NULL, + last_post_at timestamp(0) without time zone NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: user_post_timestamps_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_post_timestamps_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_post_timestamps_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_post_timestamps_id_seq OWNED BY public.user_post_timestamps.id; + + +-- +-- Name: user_profiles; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_profiles ( + id bigint NOT NULL, + user_id bigint NOT NULL, + display_name character varying(255), + description text, + theme character varying(255) DEFAULT 'purple'::character varying, + accent_color character varying(255) DEFAULT '#8b5cf6'::character varying, + font_family character varying(255), + cursor_style character varying(255) DEFAULT 'default'::character varying, + avatar_url character varying(255), + banner_url character varying(255), + background_url character varying(255), + background_type character varying(255) DEFAULT 'gradient'::character varying, + music_url character varying(255), + music_title character varying(255), + discord_user_id character varying(255), + show_discord_presence boolean DEFAULT false, + is_public boolean DEFAULT true, + page_views integer DEFAULT 0, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + location character varying(255), + page_title character varying(255), + favicon_url character varying(255), + text_color character varying(255) DEFAULT '#ffffff'::character varying, + background_color character varying(255) DEFAULT '#000000'::character varying, + icon_color character varying(255) DEFAULT '#ffffff'::character varying, + profile_opacity double precision DEFAULT 1.0, + profile_blur integer DEFAULT 0, + monochrome_icons boolean DEFAULT false, + volume_control boolean DEFAULT false, + use_discord_avatar boolean DEFAULT false, + hide_view_counter boolean DEFAULT false, + hide_uid boolean DEFAULT false, + avatar_size integer DEFAULT 0, + banner_size integer DEFAULT 0, + background_size integer DEFAULT 0, + username_effect character varying(255) DEFAULT 'none'::character varying, + username_glow_color character varying(255) DEFAULT '#8b5cf6'::character varying, + username_glow_intensity integer DEFAULT 10, + username_shadow_color character varying(255) DEFAULT '#000000'::character varying, + username_gradient_from character varying(255), + username_gradient_to character varying(255), + username_animation_speed character varying(255) DEFAULT 'normal'::character varying, + typewriter_effect boolean DEFAULT false, + typewriter_speed character varying(255) DEFAULT 'normal'::character varying, + typewriter_title boolean DEFAULT false, + link_display_style character varying(255) DEFAULT 'circular'::character varying, + container_background_color character varying(255), + container_opacity double precision DEFAULT 0.4, + container_pattern character varying(255) DEFAULT 'none'::character varying, + pattern_color character varying(255), + pattern_animated boolean DEFAULT false, + pattern_animation_speed character varying(255) DEFAULT 'normal'::character varying, + hide_followers boolean DEFAULT false, + hide_avatar boolean DEFAULT false, + hide_timeline boolean DEFAULT false, + text_background boolean DEFAULT false, + pattern_opacity double precision DEFAULT 0.2, + avatar_effect character varying(255) DEFAULT 'none'::character varying, + tick_color character varying(255) DEFAULT '#1d9bf0'::character varying, + hide_share_button boolean DEFAULT false, + extend_layout boolean DEFAULT true NOT NULL, + hide_community_posts boolean DEFAULT false NOT NULL, + link_highlight_effect character varying(255) DEFAULT 'none'::character varying NOT NULL, + circular_links_position character varying(255) DEFAULT 'top'::character varying NOT NULL, + profile_mode character varying(255) DEFAULT 'builder'::character varying NOT NULL, + static_site_index character varying(255) +); + + +-- +-- Name: user_profiles_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_profiles_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_profiles_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_profiles_id_seq OWNED BY public.user_profiles.id; + + +-- +-- Name: user_timeouts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_timeouts ( + id bigint NOT NULL, + user_id bigint NOT NULL, + conversation_id bigint, + timeout_until timestamp(0) without time zone NOT NULL, + reason character varying(255), + created_by_id bigint NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: user_timeouts_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_timeouts_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_timeouts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_timeouts_id_seq OWNED BY public.user_timeouts.id; + + +-- +-- Name: user_warnings; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_warnings ( + id bigint NOT NULL, + conversation_id bigint NOT NULL, + user_id bigint NOT NULL, + warned_by_id bigint NOT NULL, + reason text NOT NULL, + severity character varying(255) DEFAULT 'low'::character varying, + acknowledged_at timestamp(0) without time zone, + related_message_id bigint, + inserted_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: user_warnings_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_warnings_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_warnings_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_warnings_id_seq OWNED BY public.user_warnings.id; + + +-- +-- Name: username_history; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.username_history ( + id bigint NOT NULL, + username character varying(255) NOT NULL, + user_id bigint NOT NULL, + changed_at timestamp(0) without time zone NOT NULL, + previous_username character varying(255), + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: username_history_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.username_history_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: username_history_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.username_history_id_seq OWNED BY public.username_history.id; + + +-- +-- Name: users; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.users ( + id bigint NOT NULL, + username character varying(255) NOT NULL, + password_hash character varying(255) NOT NULL, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + avatar character varying(255), + is_admin boolean DEFAULT false NOT NULL, + banned boolean DEFAULT false NOT NULL, + banned_at timestamp(0) without time zone, + banned_reason character varying(255), + two_factor_enabled boolean DEFAULT false NOT NULL, + two_factor_secret character varying(255), + two_factor_backup_codes character varying(255)[], + two_factor_enabled_at timestamp(0) without time zone, + registration_ip text, + last_login_ip text, + last_login_at timestamp(0) without time zone, + login_count integer DEFAULT 0, + display_name character varying(100), + recovery_email character varying(255), + password_reset_token character varying(255), + password_reset_token_expires_at timestamp(0) without time zone, + locale character varying(5) DEFAULT 'en'::character varying, + last_password_change timestamp(0) without time zone, + suspended boolean DEFAULT false NOT NULL, + suspended_until timestamp(0) without time zone, + suspension_reason text, + handle character varying(255), + unique_id character varying(255), + handle_changed_at timestamp(0) without time zone, + allow_group_adds_from character varying(255) DEFAULT 'everyone'::character varying, + allow_direct_messages_from character varying(255) DEFAULT 'everyone'::character varying, + allow_mentions_from character varying(255) DEFAULT 'everyone'::character varying, + profile_visibility character varying(255) DEFAULT 'public'::character varying, + notify_on_new_follower boolean DEFAULT true, + notify_on_direct_message boolean DEFAULT true, + notify_on_mention boolean DEFAULT true, + storage_used_bytes bigint DEFAULT 0 NOT NULL, + storage_limit_bytes bigint DEFAULT 524288000 NOT NULL, + storage_last_calculated_at timestamp(0) without time zone, + avatar_size integer DEFAULT 0, + timezone character varying(255), + time_format character varying(255) DEFAULT '12'::character varying, + last_imap_access timestamp(0) without time zone, + last_pop3_access timestamp(0) without time zone, + status character varying(255) DEFAULT 'online'::character varying NOT NULL, + status_message character varying(255), + status_updated_at timestamp(0) without time zone, + last_seen_at timestamp(0) without time zone, + verified boolean DEFAULT false, + allow_calls_from character varying(255) DEFAULT 'friends'::character varying, + allow_friend_requests_from character varying(255) DEFAULT 'everyone'::character varying, + default_post_visibility character varying(255) DEFAULT 'followers'::character varying, + onboarding_completed boolean DEFAULT false, + onboarding_completed_at timestamp(0) without time zone, + onboarding_step integer DEFAULT 1, + email_signature text, + trust_level integer DEFAULT 0 NOT NULL, + trust_level_locked boolean DEFAULT false, + promoted_at timestamp(0) without time zone, + notify_on_reply boolean DEFAULT true, + notify_on_like boolean DEFAULT true, + notify_on_email_received boolean DEFAULT true, + notify_on_discussion_reply boolean DEFAULT true, + notify_on_comment boolean DEFAULT true, + preferred_email_domain character varying(255) DEFAULT 'example.com'::character varying, + activitypub_enabled boolean DEFAULT true, + activitypub_private_key text, + activitypub_public_key text, + activitypub_manually_approve_followers boolean DEFAULT true, + email_sending_restricted boolean DEFAULT false, + email_rate_limit_violations integer DEFAULT 0, + email_restriction_reason character varying(255), + email_restricted_at timestamp(0) without time zone, + recovery_email_verified boolean DEFAULT false, + recovery_email_verification_token character varying(255), + recovery_email_verification_sent_at timestamp(0) without time zone, + addressbook_ctag character varying(255), + pgp_public_key text, + pgp_key_id character varying(255), + pgp_fingerprint character varying(255), + pgp_key_uploaded_at timestamp(0) without time zone, + registered_via_onion boolean DEFAULT false NOT NULL, + pgp_wkd_hash character varying(255), + bluesky_enabled boolean DEFAULT false NOT NULL, + bluesky_identifier character varying(255), + bluesky_app_password text, + bluesky_did character varying(255), + bluesky_pds_url character varying(255), + bluesky_inbound_cursor text, + bluesky_inbound_last_polled_at timestamp(0) without time zone, + stripe_customer_id character varying(255), + theme_overrides jsonb DEFAULT '{}'::jsonb NOT NULL, + auth_valid_after timestamp(0) without time zone, + built_in_subdomain_mode character varying(255) DEFAULT 'platform'::character varying NOT NULL +); + + +-- +-- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.users_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id; + + +-- +-- Name: vpn_connection_logs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.vpn_connection_logs ( + id bigint NOT NULL, + vpn_user_config_id bigint NOT NULL, + connected_at timestamp(0) without time zone NOT NULL, + disconnected_at timestamp(0) without time zone, + bytes_sent bigint DEFAULT 0, + bytes_received bigint DEFAULT 0, + client_ip character varying(255), + metadata jsonb DEFAULT '{}'::jsonb, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL +); + + +-- +-- Name: vpn_connection_logs_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.vpn_connection_logs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: vpn_connection_logs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.vpn_connection_logs_id_seq OWNED BY public.vpn_connection_logs.id; + + +-- +-- Name: vpn_servers; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.vpn_servers ( + id bigint NOT NULL, + name character varying(255) NOT NULL, + location character varying(255) NOT NULL, + country_code character varying(2), + city character varying(255), + public_ip character varying(255) NOT NULL, + public_key character varying(255) NOT NULL, + endpoint_port integer DEFAULT 443 NOT NULL, + internal_ip_range character varying(255) NOT NULL, + dns_servers character varying(255) DEFAULT '1.1.1.1, 1.0.0.1'::character varying, + status character varying(255) DEFAULT 'active'::character varying NOT NULL, + max_users integer DEFAULT 100, + current_users integer DEFAULT 0, + api_endpoint character varying(255), + api_key character varying(255), + metadata jsonb DEFAULT '{}'::jsonb, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + minimum_trust_level integer DEFAULT 0 NOT NULL, + endpoint_host character varying(255), + client_mtu integer DEFAULT 1280 +); + + +-- +-- Name: vpn_servers_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.vpn_servers_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: vpn_servers_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.vpn_servers_id_seq OWNED BY public.vpn_servers.id; + + +-- +-- Name: vpn_user_configs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.vpn_user_configs ( + id bigint NOT NULL, + user_id bigint NOT NULL, + vpn_server_id bigint NOT NULL, + public_key character varying(255) NOT NULL, + private_key bytea, + allocated_ip character varying(255) NOT NULL, + allowed_ips character varying(255) DEFAULT '0.0.0.0/0, ::/0'::character varying, + status character varying(255) DEFAULT 'active'::character varying NOT NULL, + last_handshake_at timestamp(0) without time zone, + bytes_sent bigint DEFAULT 0, + bytes_received bigint DEFAULT 0, + persistent_keepalive integer DEFAULT 25, + metadata jsonb DEFAULT '{}'::jsonb, + inserted_at timestamp(0) without time zone NOT NULL, + updated_at timestamp(0) without time zone NOT NULL, + bandwidth_quota_bytes bigint DEFAULT '10737418240'::bigint NOT NULL, + quota_period_start timestamp(0) without time zone, + quota_used_bytes bigint DEFAULT 0 NOT NULL, + rate_limit_mbps integer DEFAULT 50 NOT NULL +); + + +-- +-- Name: vpn_user_configs_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.vpn_user_configs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: vpn_user_configs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.vpn_user_configs_id_seq OWNED BY public.vpn_user_configs.id; + + +-- +-- Name: account_deletion_requests id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.account_deletion_requests ALTER COLUMN id SET DEFAULT nextval('public.account_deletion_requests_id_seq'::regclass); + + +-- +-- Name: activitypub_activities id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_activities ALTER COLUMN id SET DEFAULT nextval('public.activitypub_activities_id_seq'::regclass); + + +-- +-- Name: activitypub_actors id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_actors ALTER COLUMN id SET DEFAULT nextval('public.activitypub_actors_id_seq'::regclass); + + +-- +-- Name: activitypub_deliveries id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_deliveries ALTER COLUMN id SET DEFAULT nextval('public.activitypub_deliveries_id_seq'::regclass); + + +-- +-- Name: activitypub_instances id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_instances ALTER COLUMN id SET DEFAULT nextval('public.activitypub_instances_id_seq'::regclass); + + +-- +-- Name: activitypub_relay_subscriptions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_relay_subscriptions ALTER COLUMN id SET DEFAULT nextval('public.activitypub_relay_subscriptions_id_seq'::regclass); + + +-- +-- Name: activitypub_user_blocks id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_user_blocks ALTER COLUMN id SET DEFAULT nextval('public.activitypub_user_blocks_id_seq'::regclass); + + +-- +-- Name: announcement_dismissals id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.announcement_dismissals ALTER COLUMN id SET DEFAULT nextval('public.announcement_dismissals_id_seq'::regclass); + + +-- +-- Name: announcements id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.announcements ALTER COLUMN id SET DEFAULT nextval('public.announcements_id_seq'::regclass); + + +-- +-- Name: api_token_revocations id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_token_revocations ALTER COLUMN id SET DEFAULT nextval('public.api_token_revocations_id_seq'::regclass); + + +-- +-- Name: api_tokens id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_tokens ALTER COLUMN id SET DEFAULT nextval('public.api_tokens_id_seq'::regclass); + + +-- +-- Name: app_passwords id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.app_passwords ALTER COLUMN id SET DEFAULT nextval('public.app_passwords_id_seq'::regclass); + + +-- +-- Name: audit_logs id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_logs ALTER COLUMN id SET DEFAULT nextval('public.audit_logs_id_seq'::regclass); + + +-- +-- Name: auto_mod_rules id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.auto_mod_rules ALTER COLUMN id SET DEFAULT nextval('public.auto_mod_rules_id_seq'::regclass); + + +-- +-- Name: bluesky_inbound_events id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bluesky_inbound_events ALTER COLUMN id SET DEFAULT nextval('public.bluesky_inbound_events_id_seq'::regclass); + + +-- +-- Name: calendar_events id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.calendar_events ALTER COLUMN id SET DEFAULT nextval('public.calendar_events_id_seq'::regclass); + + +-- +-- Name: calendars id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.calendars ALTER COLUMN id SET DEFAULT nextval('public.calendars_id_seq'::regclass); + + +-- +-- Name: calls id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.calls ALTER COLUMN id SET DEFAULT nextval('public.calls_id_seq'::regclass); + + +-- +-- Name: chat_conversation_members id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_conversation_members ALTER COLUMN id SET DEFAULT nextval('public.chat_conversation_members_id_seq'::regclass); + + +-- +-- Name: chat_conversations id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_conversations ALTER COLUMN id SET DEFAULT nextval('public.chat_conversations_id_seq'::regclass); + + +-- +-- Name: chat_message_reactions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_message_reactions ALTER COLUMN id SET DEFAULT nextval('public.chat_message_reactions_id_seq'::regclass); + + +-- +-- Name: chat_messages id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_messages ALTER COLUMN id SET DEFAULT nextval('public.chat_messages_id_seq'::regclass); + + +-- +-- Name: chat_moderation_actions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_moderation_actions ALTER COLUMN id SET DEFAULT nextval('public.chat_moderation_actions_id_seq'::regclass); + + +-- +-- Name: chat_user_hidden_messages id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_user_hidden_messages ALTER COLUMN id SET DEFAULT nextval('public.chat_user_hidden_messages_id_seq'::regclass); + + +-- +-- Name: chat_user_timeouts id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_user_timeouts ALTER COLUMN id SET DEFAULT nextval('public.chat_user_timeouts_id_seq'::regclass); + + +-- +-- Name: community_bans id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.community_bans ALTER COLUMN id SET DEFAULT nextval('public.community_bans_id_seq'::regclass); + + +-- +-- Name: community_flairs id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.community_flairs ALTER COLUMN id SET DEFAULT nextval('public.community_flairs_id_seq'::regclass); + + +-- +-- Name: contact_groups id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contact_groups ALTER COLUMN id SET DEFAULT nextval('public.contact_groups_id_seq'::regclass); + + +-- +-- Name: contacts id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contacts ALTER COLUMN id SET DEFAULT nextval('public.contacts_id_seq'::regclass); + + +-- +-- Name: conversation_members id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.conversation_members ALTER COLUMN id SET DEFAULT nextval('public.conversation_members_id_seq'::regclass); + + +-- +-- Name: conversations id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.conversations ALTER COLUMN id SET DEFAULT nextval('public.conversations_id_seq'::regclass); + + +-- +-- Name: creator_satisfaction id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.creator_satisfaction ALTER COLUMN id SET DEFAULT nextval('public.creator_satisfaction_id_seq'::regclass); + + +-- +-- Name: custom_emojis id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.custom_emojis ALTER COLUMN id SET DEFAULT nextval('public.custom_emojis_id_seq'::regclass); + + +-- +-- Name: data_exports id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.data_exports ALTER COLUMN id SET DEFAULT nextval('public.data_exports_id_seq'::regclass); + + +-- +-- Name: developer_webhook_deliveries id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.developer_webhook_deliveries ALTER COLUMN id SET DEFAULT nextval('public.developer_webhook_deliveries_id_seq'::regclass); + + +-- +-- Name: developer_webhooks id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.developer_webhooks ALTER COLUMN id SET DEFAULT nextval('public.developer_webhooks_id_seq'::regclass); + + +-- +-- Name: device_tokens id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.device_tokens ALTER COLUMN id SET DEFAULT nextval('public.device_tokens_id_seq'::regclass); + + +-- +-- Name: dns_query_stats id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dns_query_stats ALTER COLUMN id SET DEFAULT nextval('public.dns_query_stats_id_seq'::regclass); + + +-- +-- Name: dns_records id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dns_records ALTER COLUMN id SET DEFAULT nextval('public.dns_records_id_seq'::regclass); + + +-- +-- Name: dns_zone_service_configs id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dns_zone_service_configs ALTER COLUMN id SET DEFAULT nextval('public.dns_zone_service_configs_id_seq'::regclass); + + +-- +-- Name: dns_zones id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dns_zones ALTER COLUMN id SET DEFAULT nextval('public.dns_zones_id_seq'::regclass); + + +-- +-- Name: email_aliases id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_aliases ALTER COLUMN id SET DEFAULT nextval('public.email_aliases_id_seq'::regclass); + + +-- +-- Name: email_auto_replies id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_auto_replies ALTER COLUMN id SET DEFAULT nextval('public.email_auto_replies_id_seq'::regclass); + + +-- +-- Name: email_auto_reply_log id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_auto_reply_log ALTER COLUMN id SET DEFAULT nextval('public.email_auto_reply_log_id_seq'::regclass); + + +-- +-- Name: email_blocked_senders id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_blocked_senders ALTER COLUMN id SET DEFAULT nextval('public.email_blocked_senders_id_seq'::regclass); + + +-- +-- Name: email_category_preferences id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_category_preferences ALTER COLUMN id SET DEFAULT nextval('public.email_category_preferences_id_seq'::regclass); + + +-- +-- Name: email_custom_domains id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_custom_domains ALTER COLUMN id SET DEFAULT nextval('public.email_custom_domains_id_seq'::regclass); + + +-- +-- Name: email_exports id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_exports ALTER COLUMN id SET DEFAULT nextval('public.email_exports_id_seq'::regclass); + + +-- +-- Name: email_filters id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_filters ALTER COLUMN id SET DEFAULT nextval('public.email_filters_id_seq'::regclass); + + +-- +-- Name: email_folders id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_folders ALTER COLUMN id SET DEFAULT nextval('public.email_folders_id_seq'::regclass); + + +-- +-- Name: email_jobs id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_jobs ALTER COLUMN id SET DEFAULT nextval('public.email_jobs_id_seq'::regclass); + + +-- +-- Name: email_labels id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_labels ALTER COLUMN id SET DEFAULT nextval('public.email_labels_id_seq'::regclass); + + +-- +-- Name: email_messages id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_messages ALTER COLUMN id SET DEFAULT nextval('public.email_messages_id_seq'::regclass); + + +-- +-- Name: email_safe_senders id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_safe_senders ALTER COLUMN id SET DEFAULT nextval('public.email_safe_senders_id_seq'::regclass); + + +-- +-- Name: email_submissions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_submissions ALTER COLUMN id SET DEFAULT nextval('public.email_submissions_id_seq'::regclass); + + +-- +-- Name: email_suppressions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_suppressions ALTER COLUMN id SET DEFAULT nextval('public.email_suppressions_id_seq'::regclass); + + +-- +-- Name: email_templates id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_templates ALTER COLUMN id SET DEFAULT nextval('public.email_templates_id_seq'::regclass); + + +-- +-- Name: email_threads id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_threads ALTER COLUMN id SET DEFAULT nextval('public.email_threads_id_seq'::regclass); + + +-- +-- Name: email_unsubscribes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_unsubscribes ALTER COLUMN id SET DEFAULT nextval('public.email_unsubscribes_id_seq'::regclass); + + +-- +-- Name: federated_boosts id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.federated_boosts ALTER COLUMN id SET DEFAULT nextval('public.federated_boosts_id_seq'::regclass); + + +-- +-- Name: federated_dislikes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.federated_dislikes ALTER COLUMN id SET DEFAULT nextval('public.federated_dislikes_id_seq'::regclass); + + +-- +-- Name: federated_likes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.federated_likes ALTER COLUMN id SET DEFAULT nextval('public.federated_likes_id_seq'::regclass); + + +-- +-- Name: federated_quotes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.federated_quotes ALTER COLUMN id SET DEFAULT nextval('public.federated_quotes_id_seq'::regclass); + + +-- +-- Name: file_shares id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.file_shares ALTER COLUMN id SET DEFAULT nextval('public.file_shares_id_seq'::regclass); + + +-- +-- Name: follows id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.follows ALTER COLUMN id SET DEFAULT nextval('public.follows_id_seq'::regclass); + + +-- +-- Name: forwarded_messages id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.forwarded_messages ALTER COLUMN id SET DEFAULT nextval('public.forwarded_messages_id_seq'::regclass); + + +-- +-- Name: friend_requests id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.friend_requests ALTER COLUMN id SET DEFAULT nextval('public.friend_requests_id_seq'::regclass); + + +-- +-- Name: group_follows id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.group_follows ALTER COLUMN id SET DEFAULT nextval('public.group_follows_id_seq'::regclass); + + +-- +-- Name: handle_history id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.handle_history ALTER COLUMN id SET DEFAULT nextval('public.handle_history_id_seq'::regclass); + + +-- +-- Name: hashtag_follows id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.hashtag_follows ALTER COLUMN id SET DEFAULT nextval('public.hashtag_follows_id_seq'::regclass); + + +-- +-- Name: hashtags id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.hashtags ALTER COLUMN id SET DEFAULT nextval('public.hashtags_id_seq'::regclass); + + +-- +-- Name: imap_subscriptions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.imap_subscriptions ALTER COLUMN id SET DEFAULT nextval('public.imap_subscriptions_id_seq'::regclass); + + +-- +-- Name: invite_code_uses id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invite_code_uses ALTER COLUMN id SET DEFAULT nextval('public.invite_code_uses_id_seq'::regclass); + + +-- +-- Name: invite_codes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invite_codes ALTER COLUMN id SET DEFAULT nextval('public.invite_codes_id_seq'::regclass); + + +-- +-- Name: jmap_email_changes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.jmap_email_changes ALTER COLUMN id SET DEFAULT nextval('public.jmap_email_changes_id_seq'::regclass); + + +-- +-- Name: jmap_email_tombstones id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.jmap_email_tombstones ALTER COLUMN id SET DEFAULT nextval('public.jmap_email_tombstones_id_seq'::regclass); + + +-- +-- Name: jmap_state_tracking id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.jmap_state_tracking ALTER COLUMN id SET DEFAULT nextval('public.jmap_state_tracking_id_seq'::regclass); + + +-- +-- Name: lemmy_counts_cache id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.lemmy_counts_cache ALTER COLUMN id SET DEFAULT nextval('public.lemmy_counts_cache_id_seq'::regclass); + + +-- +-- Name: link_preview_jobs id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.link_preview_jobs ALTER COLUMN id SET DEFAULT nextval('public.link_preview_jobs_id_seq'::regclass); + + +-- +-- Name: link_previews id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.link_previews ALTER COLUMN id SET DEFAULT nextval('public.link_previews_id_seq'::regclass); + + +-- +-- Name: list_members id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.list_members ALTER COLUMN id SET DEFAULT nextval('public.list_members_id_seq'::regclass); + + +-- +-- Name: lists id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.lists ALTER COLUMN id SET DEFAULT nextval('public.lists_id_seq'::regclass); + + +-- +-- Name: mailboxes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.mailboxes ALTER COLUMN id SET DEFAULT nextval('public.mailboxes_id_seq'::regclass); + + +-- +-- Name: message_reactions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.message_reactions ALTER COLUMN id SET DEFAULT nextval('public.message_reactions_id_seq'::regclass); + + +-- +-- Name: message_votes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.message_votes ALTER COLUMN id SET DEFAULT nextval('public.message_votes_id_seq'::regclass); + + +-- +-- Name: messages id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messages ALTER COLUMN id SET DEFAULT nextval('public.messages_id_seq'::regclass); + + +-- +-- Name: messaging_federation_account_presence_states id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_account_presence_states ALTER COLUMN id SET DEFAULT nextval('public.messaging_federation_account_presence_states_id_seq'::regclass); + + +-- +-- Name: messaging_federation_call_sessions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_call_sessions ALTER COLUMN id SET DEFAULT nextval('public.messaging_federation_call_sessions_id_seq'::regclass); + + +-- +-- Name: messaging_federation_discovered_peers id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_discovered_peers ALTER COLUMN id SET DEFAULT nextval('public.messaging_federation_discovered_peers_id_seq'::regclass); + + +-- +-- Name: messaging_federation_events id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_events ALTER COLUMN id SET DEFAULT nextval('public.messaging_federation_events_id_seq'::regclass); + + +-- +-- Name: messaging_federation_events_archive id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_events_archive ALTER COLUMN id SET DEFAULT nextval('public.messaging_federation_events_archive_id_seq'::regclass); + + +-- +-- Name: messaging_federation_extension_events id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_extension_events ALTER COLUMN id SET DEFAULT nextval('public.messaging_federation_extension_events_id_seq'::regclass); + + +-- +-- Name: messaging_federation_invite_states id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_invite_states ALTER COLUMN id SET DEFAULT nextval('public.messaging_federation_invite_states_id_seq'::regclass); + + +-- +-- Name: messaging_federation_membership_states id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_membership_states ALTER COLUMN id SET DEFAULT nextval('public.messaging_federation_membership_states_id_seq'::regclass); + + +-- +-- Name: messaging_federation_outbox_events id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_outbox_events ALTER COLUMN id SET DEFAULT nextval('public.messaging_federation_outbox_events_id_seq'::regclass); + + +-- +-- Name: messaging_federation_peer_policies id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_peer_policies ALTER COLUMN id SET DEFAULT nextval('public.messaging_federation_peer_policies_id_seq'::regclass); + + +-- +-- Name: messaging_federation_presence_states id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_presence_states ALTER COLUMN id SET DEFAULT nextval('public.messaging_federation_presence_states_id_seq'::regclass); + + +-- +-- Name: messaging_federation_read_cursors id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_read_cursors ALTER COLUMN id SET DEFAULT nextval('public.messaging_federation_read_cursors_id_seq'::regclass); + + +-- +-- Name: messaging_federation_read_receipts id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_read_receipts ALTER COLUMN id SET DEFAULT nextval('public.messaging_federation_read_receipts_id_seq'::regclass); + + +-- +-- Name: messaging_federation_request_replays id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_request_replays ALTER COLUMN id SET DEFAULT nextval('public.messaging_federation_request_replays_id_seq'::regclass); + + +-- +-- Name: messaging_federation_room_presence_states id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_room_presence_states ALTER COLUMN id SET DEFAULT nextval('public.messaging_federation_room_presence_states_id_seq'::regclass); + + +-- +-- Name: messaging_federation_stream_counters id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_stream_counters ALTER COLUMN id SET DEFAULT nextval('public.messaging_federation_stream_counters_id_seq'::regclass); + + +-- +-- Name: messaging_federation_stream_positions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_stream_positions ALTER COLUMN id SET DEFAULT nextval('public.messaging_federation_stream_positions_id_seq'::regclass); + + +-- +-- Name: messaging_server_members id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_server_members ALTER COLUMN id SET DEFAULT nextval('public.messaging_server_members_id_seq'::regclass); + + +-- +-- Name: messaging_servers id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_servers ALTER COLUMN id SET DEFAULT nextval('public.messaging_servers_id_seq'::regclass); + + +-- +-- Name: moderation_actions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.moderation_actions ALTER COLUMN id SET DEFAULT nextval('public.moderation_actions_id_seq'::regclass); + + +-- +-- Name: moderator_notes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.moderator_notes ALTER COLUMN id SET DEFAULT nextval('public.moderator_notes_id_seq'::regclass); + + +-- +-- Name: notifications id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.notifications ALTER COLUMN id SET DEFAULT nextval('public.notifications_id_seq'::regclass); + + +-- +-- Name: oauth_apps id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_apps ALTER COLUMN id SET DEFAULT nextval('public.oauth_apps_id_seq'::regclass); + + +-- +-- Name: oauth_authorizations id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_authorizations ALTER COLUMN id SET DEFAULT nextval('public.oauth_authorizations_id_seq'::regclass); + + +-- +-- Name: oauth_tokens id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_tokens ALTER COLUMN id SET DEFAULT nextval('public.oauth_tokens_id_seq'::regclass); + + +-- +-- Name: oban_jobs id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oban_jobs ALTER COLUMN id SET DEFAULT nextval('public.oban_jobs_id_seq'::regclass); + + +-- +-- Name: oidc_signing_keys id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oidc_signing_keys ALTER COLUMN id SET DEFAULT nextval('public.oidc_signing_keys_id_seq'::regclass); + + +-- +-- Name: passkey_credentials id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.passkey_credentials ALTER COLUMN id SET DEFAULT nextval('public.passkey_credentials_id_seq'::regclass); + + +-- +-- Name: nerve_entries id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.nerve_entries ALTER COLUMN id SET DEFAULT nextval('public.nerve_entries_id_seq'::regclass); + + +-- +-- Name: nerve_settings id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.nerve_settings ALTER COLUMN id SET DEFAULT nextval('public.nerve_settings_id_seq'::regclass); + + +-- +-- Name: pgp_key_cache id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pgp_key_cache ALTER COLUMN id SET DEFAULT nextval('public.pgp_key_cache_id_seq'::regclass); + + +-- +-- Name: platform_updates id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.platform_updates ALTER COLUMN id SET DEFAULT nextval('public.platform_updates_id_seq'::regclass); + + +-- +-- Name: poll_options id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.poll_options ALTER COLUMN id SET DEFAULT nextval('public.poll_options_id_seq'::regclass); + + +-- +-- Name: poll_votes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.poll_votes ALTER COLUMN id SET DEFAULT nextval('public.poll_votes_id_seq'::regclass); + + +-- +-- Name: polls id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.polls ALTER COLUMN id SET DEFAULT nextval('public.polls_id_seq'::regclass); + + +-- +-- Name: post_boosts id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_boosts ALTER COLUMN id SET DEFAULT nextval('public.post_boosts_id_seq'::regclass); + + +-- +-- Name: post_dismissals id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_dismissals ALTER COLUMN id SET DEFAULT nextval('public.post_dismissals_id_seq'::regclass); + + +-- +-- Name: post_hashtags id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_hashtags ALTER COLUMN id SET DEFAULT nextval('public.post_hashtags_id_seq'::regclass); + + +-- +-- Name: post_likes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_likes ALTER COLUMN id SET DEFAULT nextval('public.post_likes_id_seq'::regclass); + + +-- +-- Name: post_views id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_views ALTER COLUMN id SET DEFAULT nextval('public.post_views_id_seq'::regclass); + + +-- +-- Name: profile_custom_domains id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_custom_domains ALTER COLUMN id SET DEFAULT nextval('public.profile_custom_domains_id_seq'::regclass); + + +-- +-- Name: profile_links id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_links ALTER COLUMN id SET DEFAULT nextval('public.profile_links_id_seq'::regclass); + + +-- +-- Name: profile_site_visits id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_site_visits ALTER COLUMN id SET DEFAULT nextval('public.profile_site_visits_id_seq'::regclass); + + +-- +-- Name: profile_views id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_views ALTER COLUMN id SET DEFAULT nextval('public.profile_views_id_seq'::regclass); + + +-- +-- Name: profile_widgets id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_widgets ALTER COLUMN id SET DEFAULT nextval('public.profile_widgets_id_seq'::regclass); + + +-- +-- Name: registration_checkouts id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.registration_checkouts ALTER COLUMN id SET DEFAULT nextval('public.registration_checkouts_id_seq'::regclass); + + +-- +-- Name: remote_interactions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.remote_interactions ALTER COLUMN id SET DEFAULT nextval('public.remote_interactions_id_seq'::regclass); + + +-- +-- Name: reports id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reports ALTER COLUMN id SET DEFAULT nextval('public.reports_id_seq'::regclass); + + +-- +-- Name: rss_feeds id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.rss_feeds ALTER COLUMN id SET DEFAULT nextval('public.rss_feeds_id_seq'::regclass); + + +-- +-- Name: rss_items id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.rss_items ALTER COLUMN id SET DEFAULT nextval('public.rss_items_id_seq'::regclass); + + +-- +-- Name: rss_subscriptions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.rss_subscriptions ALTER COLUMN id SET DEFAULT nextval('public.rss_subscriptions_id_seq'::regclass); + + +-- +-- Name: saved_items id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.saved_items ALTER COLUMN id SET DEFAULT nextval('public.saved_items_id_seq'::regclass); + + +-- +-- Name: static_site_files id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.static_site_files ALTER COLUMN id SET DEFAULT nextval('public.static_site_files_id_seq'::regclass); + + +-- +-- Name: stored_files id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.stored_files ALTER COLUMN id SET DEFAULT nextval('public.stored_files_id_seq'::regclass); + + +-- +-- Name: stored_folders id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.stored_folders ALTER COLUMN id SET DEFAULT nextval('public.stored_folders_id_seq'::regclass); + + +-- +-- Name: subscription_products id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscription_products ALTER COLUMN id SET DEFAULT nextval('public.subscription_products_id_seq'::regclass); + + +-- +-- Name: subscriptions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscriptions ALTER COLUMN id SET DEFAULT nextval('public.subscriptions_id_seq'::regclass); + + +-- +-- Name: system_config id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.system_config ALTER COLUMN id SET DEFAULT nextval('public.system_config_id_seq'::regclass); + + +-- +-- Name: trust_level_logs id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.trust_level_logs ALTER COLUMN id SET DEFAULT nextval('public.trust_level_logs_id_seq'::regclass); + + +-- +-- Name: trusted_devices id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.trusted_devices ALTER COLUMN id SET DEFAULT nextval('public.trusted_devices_id_seq'::regclass); + + +-- +-- Name: user_activity_stats id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_activity_stats ALTER COLUMN id SET DEFAULT nextval('public.user_activity_stats_id_seq'::regclass); + + +-- +-- Name: user_badges id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_badges ALTER COLUMN id SET DEFAULT nextval('public.user_badges_id_seq'::regclass); + + +-- +-- Name: user_blocks id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_blocks ALTER COLUMN id SET DEFAULT nextval('public.user_blocks_id_seq'::regclass); + + +-- +-- Name: user_hidden_messages id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_hidden_messages ALTER COLUMN id SET DEFAULT nextval('public.user_hidden_messages_id_seq'::regclass); + + +-- +-- Name: user_integrations id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_integrations ALTER COLUMN id SET DEFAULT nextval('public.user_integrations_id_seq'::regclass); + + +-- +-- Name: user_mutes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_mutes ALTER COLUMN id SET DEFAULT nextval('public.user_mutes_id_seq'::regclass); + + +-- +-- Name: user_post_timestamps id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_post_timestamps ALTER COLUMN id SET DEFAULT nextval('public.user_post_timestamps_id_seq'::regclass); + + +-- +-- Name: user_profiles id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_profiles ALTER COLUMN id SET DEFAULT nextval('public.user_profiles_id_seq'::regclass); + + +-- +-- Name: user_timeouts id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_timeouts ALTER COLUMN id SET DEFAULT nextval('public.user_timeouts_id_seq'::regclass); + + +-- +-- Name: user_warnings id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_warnings ALTER COLUMN id SET DEFAULT nextval('public.user_warnings_id_seq'::regclass); + + +-- +-- Name: username_history id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.username_history ALTER COLUMN id SET DEFAULT nextval('public.username_history_id_seq'::regclass); + + +-- +-- Name: users id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass); + + +-- +-- Name: vpn_connection_logs id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.vpn_connection_logs ALTER COLUMN id SET DEFAULT nextval('public.vpn_connection_logs_id_seq'::regclass); + + +-- +-- Name: vpn_servers id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.vpn_servers ALTER COLUMN id SET DEFAULT nextval('public.vpn_servers_id_seq'::regclass); + + +-- +-- Name: vpn_user_configs id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.vpn_user_configs ALTER COLUMN id SET DEFAULT nextval('public.vpn_user_configs_id_seq'::regclass); + + +-- +-- Name: account_deletion_requests account_deletion_requests_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.account_deletion_requests + ADD CONSTRAINT account_deletion_requests_pkey PRIMARY KEY (id); + + +-- +-- Name: activitypub_activities activitypub_activities_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_activities + ADD CONSTRAINT activitypub_activities_pkey PRIMARY KEY (id); + + +-- +-- Name: activitypub_actors activitypub_actors_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_actors + ADD CONSTRAINT activitypub_actors_pkey PRIMARY KEY (id); + + +-- +-- Name: activitypub_deliveries activitypub_deliveries_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_deliveries + ADD CONSTRAINT activitypub_deliveries_pkey PRIMARY KEY (id); + + +-- +-- Name: activitypub_instances activitypub_instances_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_instances + ADD CONSTRAINT activitypub_instances_pkey PRIMARY KEY (id); + + +-- +-- Name: activitypub_relay_subscriptions activitypub_relay_subscriptions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_relay_subscriptions + ADD CONSTRAINT activitypub_relay_subscriptions_pkey PRIMARY KEY (id); + + +-- +-- Name: activitypub_user_blocks activitypub_user_blocks_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_user_blocks + ADD CONSTRAINT activitypub_user_blocks_pkey PRIMARY KEY (id); + + +-- +-- Name: announcement_dismissals announcement_dismissals_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.announcement_dismissals + ADD CONSTRAINT announcement_dismissals_pkey PRIMARY KEY (id); + + +-- +-- Name: announcements announcements_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.announcements + ADD CONSTRAINT announcements_pkey PRIMARY KEY (id); + + +-- +-- Name: api_token_revocations api_token_revocations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_token_revocations + ADD CONSTRAINT api_token_revocations_pkey PRIMARY KEY (id); + + +-- +-- Name: api_tokens api_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_tokens + ADD CONSTRAINT api_tokens_pkey PRIMARY KEY (id); + + +-- +-- Name: app_passwords app_passwords_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.app_passwords + ADD CONSTRAINT app_passwords_pkey PRIMARY KEY (id); + + +-- +-- Name: audit_logs audit_logs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_logs + ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); + + +-- +-- Name: auto_mod_rules auto_mod_rules_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.auto_mod_rules + ADD CONSTRAINT auto_mod_rules_pkey PRIMARY KEY (id); + + +-- +-- Name: bluesky_inbound_events bluesky_inbound_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bluesky_inbound_events + ADD CONSTRAINT bluesky_inbound_events_pkey PRIMARY KEY (id); + + +-- +-- Name: calendar_events calendar_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.calendar_events + ADD CONSTRAINT calendar_events_pkey PRIMARY KEY (id); + + +-- +-- Name: calendars calendars_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.calendars + ADD CONSTRAINT calendars_pkey PRIMARY KEY (id); + + +-- +-- Name: calls calls_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.calls + ADD CONSTRAINT calls_pkey PRIMARY KEY (id); + + +-- +-- Name: chat_conversation_members chat_conversation_members_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_conversation_members + ADD CONSTRAINT chat_conversation_members_pkey PRIMARY KEY (id); + + +-- +-- Name: chat_conversations chat_conversations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_conversations + ADD CONSTRAINT chat_conversations_pkey PRIMARY KEY (id); + + +-- +-- Name: chat_message_reactions chat_message_reactions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_message_reactions + ADD CONSTRAINT chat_message_reactions_pkey PRIMARY KEY (id); + + +-- +-- Name: chat_messages chat_messages_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_messages + ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id); + + +-- +-- Name: chat_moderation_actions chat_moderation_actions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_moderation_actions + ADD CONSTRAINT chat_moderation_actions_pkey PRIMARY KEY (id); + + +-- +-- Name: chat_user_hidden_messages chat_user_hidden_messages_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_user_hidden_messages + ADD CONSTRAINT chat_user_hidden_messages_pkey PRIMARY KEY (id); + + +-- +-- Name: chat_user_timeouts chat_user_timeouts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_user_timeouts + ADD CONSTRAINT chat_user_timeouts_pkey PRIMARY KEY (id); + + +-- +-- Name: community_bans community_bans_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.community_bans + ADD CONSTRAINT community_bans_pkey PRIMARY KEY (id); + + +-- +-- Name: community_flairs community_flairs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.community_flairs + ADD CONSTRAINT community_flairs_pkey PRIMARY KEY (id); + + +-- +-- Name: contact_groups contact_groups_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contact_groups + ADD CONSTRAINT contact_groups_pkey PRIMARY KEY (id); + + +-- +-- Name: contacts contacts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contacts + ADD CONSTRAINT contacts_pkey PRIMARY KEY (id); + + +-- +-- Name: conversation_members conversation_members_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.conversation_members + ADD CONSTRAINT conversation_members_pkey PRIMARY KEY (id); + + +-- +-- Name: conversations conversations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.conversations + ADD CONSTRAINT conversations_pkey PRIMARY KEY (id); + + +-- +-- Name: creator_satisfaction creator_satisfaction_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.creator_satisfaction + ADD CONSTRAINT creator_satisfaction_pkey PRIMARY KEY (id); + + +-- +-- Name: custom_emojis custom_emojis_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.custom_emojis + ADD CONSTRAINT custom_emojis_pkey PRIMARY KEY (id); + + +-- +-- Name: data_exports data_exports_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.data_exports + ADD CONSTRAINT data_exports_pkey PRIMARY KEY (id); + + +-- +-- Name: developer_webhook_deliveries developer_webhook_deliveries_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.developer_webhook_deliveries + ADD CONSTRAINT developer_webhook_deliveries_pkey PRIMARY KEY (id); + + +-- +-- Name: developer_webhooks developer_webhooks_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.developer_webhooks + ADD CONSTRAINT developer_webhooks_pkey PRIMARY KEY (id); + + +-- +-- Name: device_tokens device_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.device_tokens + ADD CONSTRAINT device_tokens_pkey PRIMARY KEY (id); + + +-- +-- Name: dns_query_stats dns_query_stats_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dns_query_stats + ADD CONSTRAINT dns_query_stats_pkey PRIMARY KEY (id); + + +-- +-- Name: dns_records dns_records_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dns_records + ADD CONSTRAINT dns_records_pkey PRIMARY KEY (id); + + +-- +-- Name: dns_zone_service_configs dns_zone_service_configs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dns_zone_service_configs + ADD CONSTRAINT dns_zone_service_configs_pkey PRIMARY KEY (id); + + +-- +-- Name: dns_zones dns_zones_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dns_zones + ADD CONSTRAINT dns_zones_pkey PRIMARY KEY (id); + + +-- +-- Name: email_aliases email_aliases_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_aliases + ADD CONSTRAINT email_aliases_pkey PRIMARY KEY (id); + + +-- +-- Name: email_auto_replies email_auto_replies_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_auto_replies + ADD CONSTRAINT email_auto_replies_pkey PRIMARY KEY (id); + + +-- +-- Name: email_auto_reply_log email_auto_reply_log_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_auto_reply_log + ADD CONSTRAINT email_auto_reply_log_pkey PRIMARY KEY (id); + + +-- +-- Name: email_blocked_senders email_blocked_senders_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_blocked_senders + ADD CONSTRAINT email_blocked_senders_pkey PRIMARY KEY (id); + + +-- +-- Name: email_category_preferences email_category_preferences_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_category_preferences + ADD CONSTRAINT email_category_preferences_pkey PRIMARY KEY (id); + + +-- +-- Name: email_custom_domains email_custom_domains_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_custom_domains + ADD CONSTRAINT email_custom_domains_pkey PRIMARY KEY (id); + + +-- +-- Name: email_exports email_exports_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_exports + ADD CONSTRAINT email_exports_pkey PRIMARY KEY (id); + + +-- +-- Name: email_filters email_filters_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_filters + ADD CONSTRAINT email_filters_pkey PRIMARY KEY (id); + + +-- +-- Name: email_folders email_folders_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_folders + ADD CONSTRAINT email_folders_pkey PRIMARY KEY (id); + + +-- +-- Name: email_jobs email_jobs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_jobs + ADD CONSTRAINT email_jobs_pkey PRIMARY KEY (id); + + +-- +-- Name: email_labels email_labels_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_labels + ADD CONSTRAINT email_labels_pkey PRIMARY KEY (id); + + +-- +-- Name: email_messages email_messages_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_messages + ADD CONSTRAINT email_messages_pkey PRIMARY KEY (id); + + +-- +-- Name: email_safe_senders email_safe_senders_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_safe_senders + ADD CONSTRAINT email_safe_senders_pkey PRIMARY KEY (id); + + +-- +-- Name: email_submissions email_submissions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_submissions + ADD CONSTRAINT email_submissions_pkey PRIMARY KEY (id); + + +-- +-- Name: email_suppressions email_suppressions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_suppressions + ADD CONSTRAINT email_suppressions_pkey PRIMARY KEY (id); + + +-- +-- Name: email_templates email_templates_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_templates + ADD CONSTRAINT email_templates_pkey PRIMARY KEY (id); + + +-- +-- Name: email_threads email_threads_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_threads + ADD CONSTRAINT email_threads_pkey PRIMARY KEY (id); + + +-- +-- Name: email_unsubscribes email_unsubscribes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_unsubscribes + ADD CONSTRAINT email_unsubscribes_pkey PRIMARY KEY (id); + + +-- +-- Name: federated_boosts federated_boosts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.federated_boosts + ADD CONSTRAINT federated_boosts_pkey PRIMARY KEY (id); + + +-- +-- Name: federated_dislikes federated_dislikes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.federated_dislikes + ADD CONSTRAINT federated_dislikes_pkey PRIMARY KEY (id); + + +-- +-- Name: federated_likes federated_likes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.federated_likes + ADD CONSTRAINT federated_likes_pkey PRIMARY KEY (id); + + +-- +-- Name: federated_quotes federated_quotes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.federated_quotes + ADD CONSTRAINT federated_quotes_pkey PRIMARY KEY (id); + + +-- +-- Name: file_shares file_shares_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.file_shares + ADD CONSTRAINT file_shares_pkey PRIMARY KEY (id); + + +-- +-- Name: follows follows_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.follows + ADD CONSTRAINT follows_pkey PRIMARY KEY (id); + + +-- +-- Name: forwarded_messages forwarded_messages_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.forwarded_messages + ADD CONSTRAINT forwarded_messages_pkey PRIMARY KEY (id); + + +-- +-- Name: friend_requests friend_requests_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.friend_requests + ADD CONSTRAINT friend_requests_pkey PRIMARY KEY (id); + + +-- +-- Name: group_follows group_follows_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.group_follows + ADD CONSTRAINT group_follows_pkey PRIMARY KEY (id); + + +-- +-- Name: handle_history handle_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.handle_history + ADD CONSTRAINT handle_history_pkey PRIMARY KEY (id); + + +-- +-- Name: hashtag_follows hashtag_follows_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.hashtag_follows + ADD CONSTRAINT hashtag_follows_pkey PRIMARY KEY (id); + + +-- +-- Name: hashtags hashtags_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.hashtags + ADD CONSTRAINT hashtags_pkey PRIMARY KEY (id); + + +-- +-- Name: imap_subscriptions imap_subscriptions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.imap_subscriptions + ADD CONSTRAINT imap_subscriptions_pkey PRIMARY KEY (id); + + +-- +-- Name: invite_code_uses invite_code_uses_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invite_code_uses + ADD CONSTRAINT invite_code_uses_pkey PRIMARY KEY (id); + + +-- +-- Name: invite_codes invite_codes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invite_codes + ADD CONSTRAINT invite_codes_pkey PRIMARY KEY (id); + + +-- +-- Name: jmap_email_changes jmap_email_changes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.jmap_email_changes + ADD CONSTRAINT jmap_email_changes_pkey PRIMARY KEY (id); + + +-- +-- Name: jmap_email_tombstones jmap_email_tombstones_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.jmap_email_tombstones + ADD CONSTRAINT jmap_email_tombstones_pkey PRIMARY KEY (id); + + +-- +-- Name: jmap_state_tracking jmap_state_tracking_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.jmap_state_tracking + ADD CONSTRAINT jmap_state_tracking_pkey PRIMARY KEY (id); + + +-- +-- Name: lemmy_counts_cache lemmy_counts_cache_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.lemmy_counts_cache + ADD CONSTRAINT lemmy_counts_cache_pkey PRIMARY KEY (id); + + +-- +-- Name: link_preview_jobs link_preview_jobs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.link_preview_jobs + ADD CONSTRAINT link_preview_jobs_pkey PRIMARY KEY (id); + + +-- +-- Name: link_previews link_previews_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.link_previews + ADD CONSTRAINT link_previews_pkey PRIMARY KEY (id); + + +-- +-- Name: list_members list_members_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.list_members + ADD CONSTRAINT list_members_pkey PRIMARY KEY (id); + + +-- +-- Name: lists lists_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.lists + ADD CONSTRAINT lists_pkey PRIMARY KEY (id); + + +-- +-- Name: mailboxes mailboxes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.mailboxes + ADD CONSTRAINT mailboxes_pkey PRIMARY KEY (id); + + +-- +-- Name: message_reactions message_reactions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.message_reactions + ADD CONSTRAINT message_reactions_pkey PRIMARY KEY (id); + + +-- +-- Name: message_votes message_votes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.message_votes + ADD CONSTRAINT message_votes_pkey PRIMARY KEY (id); + + +-- +-- Name: messages messages_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_federation_account_presence_states messaging_federation_account_presence_states_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_account_presence_states + ADD CONSTRAINT messaging_federation_account_presence_states_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_federation_call_sessions messaging_federation_call_sessions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_call_sessions + ADD CONSTRAINT messaging_federation_call_sessions_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_federation_discovered_peers messaging_federation_discovered_peers_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_discovered_peers + ADD CONSTRAINT messaging_federation_discovered_peers_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_federation_events_archive messaging_federation_events_archive_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_events_archive + ADD CONSTRAINT messaging_federation_events_archive_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_federation_events messaging_federation_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_events + ADD CONSTRAINT messaging_federation_events_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_federation_extension_events messaging_federation_extension_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_extension_events + ADD CONSTRAINT messaging_federation_extension_events_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_federation_invite_states messaging_federation_invite_states_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_invite_states + ADD CONSTRAINT messaging_federation_invite_states_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_federation_membership_states messaging_federation_membership_states_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_membership_states + ADD CONSTRAINT messaging_federation_membership_states_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_federation_outbox_events messaging_federation_outbox_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_outbox_events + ADD CONSTRAINT messaging_federation_outbox_events_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_federation_peer_policies messaging_federation_peer_policies_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_peer_policies + ADD CONSTRAINT messaging_federation_peer_policies_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_federation_presence_states messaging_federation_presence_states_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_presence_states + ADD CONSTRAINT messaging_federation_presence_states_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_federation_read_cursors messaging_federation_read_cursors_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_read_cursors + ADD CONSTRAINT messaging_federation_read_cursors_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_federation_read_receipts messaging_federation_read_receipts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_read_receipts + ADD CONSTRAINT messaging_federation_read_receipts_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_federation_request_replays messaging_federation_request_replays_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_request_replays + ADD CONSTRAINT messaging_federation_request_replays_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_federation_room_presence_states messaging_federation_room_presence_states_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_room_presence_states + ADD CONSTRAINT messaging_federation_room_presence_states_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_federation_stream_counters messaging_federation_stream_counters_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_stream_counters + ADD CONSTRAINT messaging_federation_stream_counters_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_federation_stream_positions messaging_federation_stream_positions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_stream_positions + ADD CONSTRAINT messaging_federation_stream_positions_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_server_members messaging_server_members_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_server_members + ADD CONSTRAINT messaging_server_members_pkey PRIMARY KEY (id); + + +-- +-- Name: messaging_servers messaging_servers_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_servers + ADD CONSTRAINT messaging_servers_pkey PRIMARY KEY (id); + + +-- +-- Name: moderation_actions moderation_actions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.moderation_actions + ADD CONSTRAINT moderation_actions_pkey PRIMARY KEY (id); + + +-- +-- Name: moderator_notes moderator_notes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.moderator_notes + ADD CONSTRAINT moderator_notes_pkey PRIMARY KEY (id); + + +-- +-- Name: oban_jobs non_negative_priority; Type: CHECK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE public.oban_jobs + ADD CONSTRAINT non_negative_priority CHECK ((priority >= 0)) NOT VALID; + + +-- +-- Name: notifications notifications_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.notifications + ADD CONSTRAINT notifications_pkey PRIMARY KEY (id); + + +-- +-- Name: oauth_apps oauth_apps_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_apps + ADD CONSTRAINT oauth_apps_pkey PRIMARY KEY (id); + + +-- +-- Name: oauth_authorizations oauth_authorizations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_authorizations + ADD CONSTRAINT oauth_authorizations_pkey PRIMARY KEY (id); + + +-- +-- Name: oauth_tokens oauth_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_tokens + ADD CONSTRAINT oauth_tokens_pkey PRIMARY KEY (id); + + +-- +-- Name: oban_jobs oban_jobs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oban_jobs + ADD CONSTRAINT oban_jobs_pkey PRIMARY KEY (id); + + +-- +-- Name: oban_peers oban_peers_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oban_peers + ADD CONSTRAINT oban_peers_pkey PRIMARY KEY (name); + + +-- +-- Name: oidc_signing_keys oidc_signing_keys_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oidc_signing_keys + ADD CONSTRAINT oidc_signing_keys_pkey PRIMARY KEY (id); + + +-- +-- Name: passkey_credentials passkey_credentials_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.passkey_credentials + ADD CONSTRAINT passkey_credentials_pkey PRIMARY KEY (id); + + +-- +-- Name: nerve_entries nerve_entries_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.nerve_entries + ADD CONSTRAINT nerve_entries_pkey PRIMARY KEY (id); + + +-- +-- Name: nerve_settings nerve_settings_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.nerve_settings + ADD CONSTRAINT nerve_settings_pkey PRIMARY KEY (id); + + +-- +-- Name: pgp_key_cache pgp_key_cache_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pgp_key_cache + ADD CONSTRAINT pgp_key_cache_pkey PRIMARY KEY (id); + + +-- +-- Name: platform_updates platform_updates_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.platform_updates + ADD CONSTRAINT platform_updates_pkey PRIMARY KEY (id); + + +-- +-- Name: poll_options poll_options_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.poll_options + ADD CONSTRAINT poll_options_pkey PRIMARY KEY (id); + + +-- +-- Name: poll_votes poll_votes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.poll_votes + ADD CONSTRAINT poll_votes_pkey PRIMARY KEY (id); + + +-- +-- Name: polls polls_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.polls + ADD CONSTRAINT polls_pkey PRIMARY KEY (id); + + +-- +-- Name: post_boosts post_boosts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_boosts + ADD CONSTRAINT post_boosts_pkey PRIMARY KEY (id); + + +-- +-- Name: post_dismissals post_dismissals_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_dismissals + ADD CONSTRAINT post_dismissals_pkey PRIMARY KEY (id); + + +-- +-- Name: post_hashtags post_hashtags_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_hashtags + ADD CONSTRAINT post_hashtags_pkey PRIMARY KEY (id); + + +-- +-- Name: post_likes post_likes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_likes + ADD CONSTRAINT post_likes_pkey PRIMARY KEY (id); + + +-- +-- Name: post_views post_views_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_views + ADD CONSTRAINT post_views_pkey PRIMARY KEY (id); + + +-- +-- Name: profile_custom_domains profile_custom_domains_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_custom_domains + ADD CONSTRAINT profile_custom_domains_pkey PRIMARY KEY (id); + + +-- +-- Name: profile_links profile_links_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_links + ADD CONSTRAINT profile_links_pkey PRIMARY KEY (id); + + +-- +-- Name: profile_site_visits profile_site_visits_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_site_visits + ADD CONSTRAINT profile_site_visits_pkey PRIMARY KEY (id); + + +-- +-- Name: profile_views profile_views_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_views + ADD CONSTRAINT profile_views_pkey PRIMARY KEY (id); + + +-- +-- Name: profile_widgets profile_widgets_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_widgets + ADD CONSTRAINT profile_widgets_pkey PRIMARY KEY (id); + + +-- +-- Name: registration_checkouts registration_checkouts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.registration_checkouts + ADD CONSTRAINT registration_checkouts_pkey PRIMARY KEY (id); + + +-- +-- Name: remote_interactions remote_interactions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.remote_interactions + ADD CONSTRAINT remote_interactions_pkey PRIMARY KEY (id); + + +-- +-- Name: reports reports_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reports + ADD CONSTRAINT reports_pkey PRIMARY KEY (id); + + +-- +-- Name: rss_feeds rss_feeds_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.rss_feeds + ADD CONSTRAINT rss_feeds_pkey PRIMARY KEY (id); + + +-- +-- Name: rss_items rss_items_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.rss_items + ADD CONSTRAINT rss_items_pkey PRIMARY KEY (id); + + +-- +-- Name: rss_subscriptions rss_subscriptions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.rss_subscriptions + ADD CONSTRAINT rss_subscriptions_pkey PRIMARY KEY (id); + + +-- +-- Name: saved_items saved_items_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.saved_items + ADD CONSTRAINT saved_items_pkey PRIMARY KEY (id); + + +-- +-- Name: signing_keys signing_keys_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.signing_keys + ADD CONSTRAINT signing_keys_pkey PRIMARY KEY (key_id); + + +-- +-- Name: static_site_files static_site_files_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.static_site_files + ADD CONSTRAINT static_site_files_pkey PRIMARY KEY (id); + + +-- +-- Name: stored_files stored_files_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.stored_files + ADD CONSTRAINT stored_files_pkey PRIMARY KEY (id); + + +-- +-- Name: stored_folders stored_folders_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.stored_folders + ADD CONSTRAINT stored_folders_pkey PRIMARY KEY (id); + + +-- +-- Name: subscription_products subscription_products_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscription_products + ADD CONSTRAINT subscription_products_pkey PRIMARY KEY (id); + + +-- +-- Name: subscriptions subscriptions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscriptions + ADD CONSTRAINT subscriptions_pkey PRIMARY KEY (id); + + +-- +-- Name: system_config system_config_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.system_config + ADD CONSTRAINT system_config_pkey PRIMARY KEY (id); + + +-- +-- Name: trust_level_logs trust_level_logs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.trust_level_logs + ADD CONSTRAINT trust_level_logs_pkey PRIMARY KEY (id); + + +-- +-- Name: trusted_devices trusted_devices_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.trusted_devices + ADD CONSTRAINT trusted_devices_pkey PRIMARY KEY (id); + + +-- +-- Name: user_activity_stats user_activity_stats_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_activity_stats + ADD CONSTRAINT user_activity_stats_pkey PRIMARY KEY (id); + + +-- +-- Name: user_badges user_badges_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_badges + ADD CONSTRAINT user_badges_pkey PRIMARY KEY (id); + + +-- +-- Name: user_blocks user_blocks_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_blocks + ADD CONSTRAINT user_blocks_pkey PRIMARY KEY (id); + + +-- +-- Name: user_hidden_messages user_hidden_messages_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_hidden_messages + ADD CONSTRAINT user_hidden_messages_pkey PRIMARY KEY (id); + + +-- +-- Name: user_integrations user_integrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_integrations + ADD CONSTRAINT user_integrations_pkey PRIMARY KEY (id); + + +-- +-- Name: user_mutes user_mutes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_mutes + ADD CONSTRAINT user_mutes_pkey PRIMARY KEY (id); + + +-- +-- Name: user_post_timestamps user_post_timestamps_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_post_timestamps + ADD CONSTRAINT user_post_timestamps_pkey PRIMARY KEY (id); + + +-- +-- Name: user_profiles user_profiles_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_profiles + ADD CONSTRAINT user_profiles_pkey PRIMARY KEY (id); + + +-- +-- Name: user_timeouts user_timeouts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_timeouts + ADD CONSTRAINT user_timeouts_pkey PRIMARY KEY (id); + + +-- +-- Name: user_warnings user_warnings_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_warnings + ADD CONSTRAINT user_warnings_pkey PRIMARY KEY (id); + + +-- +-- Name: username_history username_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.username_history + ADD CONSTRAINT username_history_pkey PRIMARY KEY (id); + + +-- +-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + + +-- +-- Name: vpn_connection_logs vpn_connection_logs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.vpn_connection_logs + ADD CONSTRAINT vpn_connection_logs_pkey PRIMARY KEY (id); + + +-- +-- Name: vpn_servers vpn_servers_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.vpn_servers + ADD CONSTRAINT vpn_servers_pkey PRIMARY KEY (id); + + +-- +-- Name: vpn_user_configs vpn_user_configs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.vpn_user_configs + ADD CONSTRAINT vpn_user_configs_pkey PRIMARY KEY (id); + + +-- +-- Name: account_deletion_requests_requested_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX account_deletion_requests_requested_at_index ON public.account_deletion_requests USING btree (requested_at); + + +-- +-- Name: account_deletion_requests_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX account_deletion_requests_status_index ON public.account_deletion_requests USING btree (status); + + +-- +-- Name: account_deletion_requests_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX account_deletion_requests_user_id_index ON public.account_deletion_requests USING btree (user_id); + + +-- +-- Name: activitypub_activities_activity_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX activitypub_activities_activity_id_index ON public.activitypub_activities USING btree (activity_id); + + +-- +-- Name: activitypub_activities_activity_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_activities_activity_type_index ON public.activitypub_activities USING btree (activity_type); + + +-- +-- Name: activitypub_activities_actor_uri_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_activities_actor_uri_index ON public.activitypub_activities USING btree (actor_uri); + + +-- +-- Name: activitypub_activities_internal_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_activities_internal_message_id_index ON public.activitypub_activities USING btree (internal_message_id); + + +-- +-- Name: activitypub_activities_internal_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_activities_internal_user_id_index ON public.activitypub_activities USING btree (internal_user_id); + + +-- +-- Name: activitypub_activities_local_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_activities_local_index ON public.activitypub_activities USING btree (local); + + +-- +-- Name: activitypub_activities_object_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_activities_object_id_index ON public.activitypub_activities USING btree (object_id); + + +-- +-- Name: activitypub_activities_pending_remote_inserted_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_activities_pending_remote_inserted_idx ON public.activitypub_activities USING btree (inserted_at, id) WHERE ((processed = false) AND (local = false) AND (process_attempts < 2)); + + +-- +-- Name: activitypub_activities_process_attempts_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_activities_process_attempts_index ON public.activitypub_activities USING btree (process_attempts) WHERE ((processed = false) AND (process_attempts < 3)); + + +-- +-- Name: activitypub_activities_processed_local_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_activities_processed_local_index ON public.activitypub_activities USING btree (processed, local) WHERE ((processed = false) AND (local = false)); + + +-- +-- Name: activitypub_actors_actor_type_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_actors_actor_type_id_idx ON public.activitypub_actors USING btree (actor_type, id); + + +-- +-- Name: activitypub_actors_community_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_actors_community_id_index ON public.activitypub_actors USING btree (community_id); + + +-- +-- Name: activitypub_actors_domain_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_actors_domain_index ON public.activitypub_actors USING btree (domain); + + +-- +-- Name: activitypub_actors_uri_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX activitypub_actors_uri_index ON public.activitypub_actors USING btree (uri); + + +-- +-- Name: activitypub_actors_username_domain_unique_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX activitypub_actors_username_domain_unique_index ON public.activitypub_actors USING btree (username, domain); + + +-- +-- Name: activitypub_actors_username_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_actors_username_index ON public.activitypub_actors USING btree (username); + + +-- +-- Name: activitypub_deliveries_activity_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_deliveries_activity_id_index ON public.activitypub_deliveries USING btree (activity_id); + + +-- +-- Name: activitypub_deliveries_next_retry_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_deliveries_next_retry_at_index ON public.activitypub_deliveries USING btree (next_retry_at); + + +-- +-- Name: activitypub_deliveries_pending_retry_updated_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_deliveries_pending_retry_updated_idx ON public.activitypub_deliveries USING btree (next_retry_at, updated_at, id) WHERE (((status)::text = 'pending'::text) AND (attempts < 10)); + + +-- +-- Name: activitypub_deliveries_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_deliveries_status_index ON public.activitypub_deliveries USING btree (status); + + +-- +-- Name: activitypub_instances_blocked_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_instances_blocked_index ON public.activitypub_instances USING btree (blocked); + + +-- +-- Name: activitypub_instances_domain_ci_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX activitypub_instances_domain_ci_unique ON public.activitypub_instances USING btree (lower((domain)::text)); + + +-- +-- Name: activitypub_instances_domain_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX activitypub_instances_domain_index ON public.activitypub_instances USING btree (domain); + + +-- +-- Name: activitypub_instances_federated_timeline_removal_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_instances_federated_timeline_removal_index ON public.activitypub_instances USING btree (federated_timeline_removal); + + +-- +-- Name: activitypub_instances_silenced_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_instances_silenced_index ON public.activitypub_instances USING btree (silenced); + + +-- +-- Name: activitypub_instances_unreachable_since_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_instances_unreachable_since_index ON public.activitypub_instances USING btree (unreachable_since) WHERE (unreachable_since IS NOT NULL); + + +-- +-- Name: activitypub_relay_subscriptions_relay_uri_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX activitypub_relay_subscriptions_relay_uri_index ON public.activitypub_relay_subscriptions USING btree (relay_uri); + + +-- +-- Name: activitypub_relay_subscriptions_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_relay_subscriptions_status_index ON public.activitypub_relay_subscriptions USING btree (status); + + +-- +-- Name: activitypub_user_blocks_blocked_uri_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_user_blocks_blocked_uri_index ON public.activitypub_user_blocks USING btree (blocked_uri); + + +-- +-- Name: activitypub_user_blocks_user_id_blocked_uri_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX activitypub_user_blocks_user_id_blocked_uri_index ON public.activitypub_user_blocks USING btree (user_id, blocked_uri); + + +-- +-- Name: activitypub_user_blocks_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX activitypub_user_blocks_user_id_index ON public.activitypub_user_blocks USING btree (user_id); + + +-- +-- Name: announcement_dismissals_announcement_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX announcement_dismissals_announcement_id_index ON public.announcement_dismissals USING btree (announcement_id); + + +-- +-- Name: announcement_dismissals_user_id_announcement_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX announcement_dismissals_user_id_announcement_id_index ON public.announcement_dismissals USING btree (user_id, announcement_id); + + +-- +-- Name: announcement_dismissals_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX announcement_dismissals_user_id_index ON public.announcement_dismissals USING btree (user_id); + + +-- +-- Name: announcements_active_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX announcements_active_index ON public.announcements USING btree (active); + + +-- +-- Name: announcements_created_by_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX announcements_created_by_id_index ON public.announcements USING btree (created_by_id); + + +-- +-- Name: announcements_ends_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX announcements_ends_at_index ON public.announcements USING btree (ends_at); + + +-- +-- Name: announcements_starts_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX announcements_starts_at_index ON public.announcements USING btree (starts_at); + + +-- +-- Name: announcements_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX announcements_type_index ON public.announcements USING btree (type); + + +-- +-- Name: api_token_revocations_expires_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX api_token_revocations_expires_at_index ON public.api_token_revocations USING btree (expires_at); + + +-- +-- Name: api_token_revocations_token_hash_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX api_token_revocations_token_hash_index ON public.api_token_revocations USING btree (token_hash); + + +-- +-- Name: api_tokens_token_hash_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX api_tokens_token_hash_index ON public.api_tokens USING btree (token_hash); + + +-- +-- Name: api_tokens_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX api_tokens_user_id_index ON public.api_tokens USING btree (user_id); + + +-- +-- Name: api_tokens_user_id_revoked_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX api_tokens_user_id_revoked_at_index ON public.api_tokens USING btree (user_id, revoked_at); + + +-- +-- Name: app_passwords_expires_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX app_passwords_expires_at_index ON public.app_passwords USING btree (expires_at); + + +-- +-- Name: app_passwords_token_hash_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX app_passwords_token_hash_index ON public.app_passwords USING btree (token_hash); + + +-- +-- Name: app_passwords_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX app_passwords_user_id_index ON public.app_passwords USING btree (user_id); + + +-- +-- Name: audit_logs_action_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_logs_action_index ON public.audit_logs USING btree (action); + + +-- +-- Name: audit_logs_admin_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_logs_admin_id_index ON public.audit_logs USING btree (admin_id); + + +-- +-- Name: audit_logs_admin_id_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_logs_admin_id_inserted_at_index ON public.audit_logs USING btree (admin_id, inserted_at); + + +-- +-- Name: audit_logs_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_logs_inserted_at_index ON public.audit_logs USING btree (inserted_at); + + +-- +-- Name: audit_logs_resource_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_logs_resource_type_index ON public.audit_logs USING btree (resource_type); + + +-- +-- Name: audit_logs_target_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_logs_target_user_id_index ON public.audit_logs USING btree (target_user_id); + + +-- +-- Name: auto_mod_rules_conversation_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX auto_mod_rules_conversation_id_index ON public.auto_mod_rules USING btree (conversation_id); + + +-- +-- Name: auto_mod_rules_enabled_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX auto_mod_rules_enabled_index ON public.auto_mod_rules USING btree (enabled); + + +-- +-- Name: bluesky_inbound_events_related_post_uri_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bluesky_inbound_events_related_post_uri_index ON public.bluesky_inbound_events USING btree (related_post_uri); + + +-- +-- Name: bluesky_inbound_events_user_id_event_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX bluesky_inbound_events_user_id_event_id_index ON public.bluesky_inbound_events USING btree (user_id, event_id); + + +-- +-- Name: bluesky_inbound_events_user_id_processed_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bluesky_inbound_events_user_id_processed_at_index ON public.bluesky_inbound_events USING btree (user_id, processed_at); + + +-- +-- Name: calendar_events_calendar_id_dtstart_dtend_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX calendar_events_calendar_id_dtstart_dtend_index ON public.calendar_events USING btree (calendar_id, dtstart, dtend); + + +-- +-- Name: calendar_events_calendar_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX calendar_events_calendar_id_index ON public.calendar_events USING btree (calendar_id); + + +-- +-- Name: calendar_events_calendar_id_uid_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX calendar_events_calendar_id_uid_index ON public.calendar_events USING btree (calendar_id, uid); + + +-- +-- Name: calendar_events_dtend_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX calendar_events_dtend_index ON public.calendar_events USING btree (dtend); + + +-- +-- Name: calendar_events_dtstart_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX calendar_events_dtstart_index ON public.calendar_events USING btree (dtstart); + + +-- +-- Name: calendars_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX calendars_user_id_index ON public.calendars USING btree (user_id); + + +-- +-- Name: calendars_user_id_name_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX calendars_user_id_name_index ON public.calendars USING btree (user_id, name); + + +-- +-- Name: calls_callee_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX calls_callee_id_index ON public.calls USING btree (callee_id); + + +-- +-- Name: calls_caller_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX calls_caller_id_index ON public.calls USING btree (caller_id); + + +-- +-- Name: calls_conversation_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX calls_conversation_id_index ON public.calls USING btree (conversation_id); + + +-- +-- Name: calls_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX calls_inserted_at_index ON public.calls USING btree (inserted_at); + + +-- +-- Name: calls_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX calls_status_index ON public.calls USING btree (status); + + +-- +-- Name: chat_conversation_members_conversation_id_left_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_conversation_members_conversation_id_left_at_index ON public.chat_conversation_members USING btree (conversation_id, left_at); + + +-- +-- Name: chat_conversation_members_conversation_id_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX chat_conversation_members_conversation_id_user_id_index ON public.chat_conversation_members USING btree (conversation_id, user_id); + + +-- +-- Name: chat_conversation_members_last_read_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_conversation_members_last_read_message_id_index ON public.chat_conversation_members USING btree (last_read_message_id); + + +-- +-- Name: chat_conversation_members_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_conversation_members_user_id_index ON public.chat_conversation_members USING btree (user_id); + + +-- +-- Name: chat_conversations_creator_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_conversations_creator_id_index ON public.chat_conversations USING btree (creator_id); + + +-- +-- Name: chat_conversations_hash_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX chat_conversations_hash_index ON public.chat_conversations USING btree (hash); + + +-- +-- Name: chat_conversations_is_public_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_conversations_is_public_index ON public.chat_conversations USING btree (is_public); + + +-- +-- Name: chat_conversations_last_message_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_conversations_last_message_at_index ON public.chat_conversations USING btree (last_message_at); + + +-- +-- Name: chat_conversations_server_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_conversations_server_id_index ON public.chat_conversations USING btree (server_id); + + +-- +-- Name: chat_conversations_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_conversations_type_index ON public.chat_conversations USING btree (type); + + +-- +-- Name: chat_message_reactions_chat_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_message_reactions_chat_message_id_index ON public.chat_message_reactions USING btree (chat_message_id); + + +-- +-- Name: chat_message_reactions_remote_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX chat_message_reactions_remote_unique ON public.chat_message_reactions USING btree (chat_message_id, remote_actor_id, emoji) WHERE (remote_actor_id IS NOT NULL); + + +-- +-- Name: chat_message_reactions_user_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX chat_message_reactions_user_unique ON public.chat_message_reactions USING btree (chat_message_id, user_id, emoji) WHERE (user_id IS NOT NULL); + + +-- +-- Name: chat_message_reads_chat_message_id_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX chat_message_reads_chat_message_id_user_id_index ON public.chat_message_reads USING btree (chat_message_id, user_id); + + +-- +-- Name: chat_message_reads_user_id_read_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_message_reads_user_id_read_at_index ON public.chat_message_reads USING btree (user_id, read_at); + + +-- +-- Name: chat_messages_active_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_messages_active_idx ON public.chat_messages USING btree (conversation_id, inserted_at) WHERE (deleted_at IS NULL); + + +-- +-- Name: chat_messages_conversation_federated_source_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX chat_messages_conversation_federated_source_unique ON public.chat_messages USING btree (conversation_id, federated_source) WHERE (federated_source IS NOT NULL); + + +-- +-- Name: chat_messages_conversation_id_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_messages_conversation_id_inserted_at_index ON public.chat_messages USING btree (conversation_id, inserted_at); + + +-- +-- Name: chat_messages_link_preview_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_messages_link_preview_id_index ON public.chat_messages USING btree (link_preview_id); + + +-- +-- Name: chat_messages_origin_domain_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_messages_origin_domain_index ON public.chat_messages USING btree (origin_domain); + + +-- +-- Name: chat_messages_reply_to_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_messages_reply_to_id_index ON public.chat_messages USING btree (reply_to_id); + + +-- +-- Name: chat_messages_sender_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_messages_sender_id_index ON public.chat_messages USING btree (sender_id); + + +-- +-- Name: chat_moderation_actions_conversation_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_moderation_actions_conversation_id_index ON public.chat_moderation_actions USING btree (conversation_id); + + +-- +-- Name: chat_moderation_actions_moderator_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_moderation_actions_moderator_id_index ON public.chat_moderation_actions USING btree (moderator_id); + + +-- +-- Name: chat_moderation_actions_target_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_moderation_actions_target_user_id_index ON public.chat_moderation_actions USING btree (target_user_id); + + +-- +-- Name: chat_user_hidden_messages_chat_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_user_hidden_messages_chat_message_id_index ON public.chat_user_hidden_messages USING btree (chat_message_id); + + +-- +-- Name: chat_user_hidden_messages_user_id_hidden_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX chat_user_hidden_messages_user_id_hidden_at_index ON public.chat_user_hidden_messages USING btree (user_id, hidden_at); + + +-- +-- Name: chat_user_hidden_messages_user_message_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX chat_user_hidden_messages_user_message_unique ON public.chat_user_hidden_messages USING btree (user_id, chat_message_id); + + +-- +-- Name: chat_user_timeouts_user_conversation_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX chat_user_timeouts_user_conversation_unique ON public.chat_user_timeouts USING btree (user_id, conversation_id); + + +-- +-- Name: community_bans_conversation_id_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX community_bans_conversation_id_user_id_index ON public.community_bans USING btree (conversation_id, user_id); + + +-- +-- Name: community_bans_expires_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX community_bans_expires_at_index ON public.community_bans USING btree (expires_at); + + +-- +-- Name: community_bans_origin_domain_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX community_bans_origin_domain_index ON public.community_bans USING btree (origin_domain); + + +-- +-- Name: community_bans_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX community_bans_user_id_index ON public.community_bans USING btree (user_id); + + +-- +-- Name: community_flairs_community_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX community_flairs_community_id_index ON public.community_flairs USING btree (community_id); + + +-- +-- Name: community_flairs_community_id_name_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX community_flairs_community_id_name_index ON public.community_flairs USING btree (community_id, name); + + +-- +-- Name: contact_groups_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX contact_groups_user_id_index ON public.contact_groups USING btree (user_id); + + +-- +-- Name: contacts_group_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX contacts_group_id_index ON public.contacts USING btree (group_id); + + +-- +-- Name: contacts_pgp_fingerprint_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX contacts_pgp_fingerprint_index ON public.contacts USING btree (pgp_fingerprint); + + +-- +-- Name: contacts_uid_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX contacts_uid_index ON public.contacts USING btree (uid); + + +-- +-- Name: contacts_user_id_email_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX contacts_user_id_email_index ON public.contacts USING btree (user_id, email); + + +-- +-- Name: contacts_user_id_favorite_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX contacts_user_id_favorite_index ON public.contacts USING btree (user_id, favorite); + + +-- +-- Name: contacts_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX contacts_user_id_index ON public.contacts USING btree (user_id); + + +-- +-- Name: contacts_user_id_uid_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX contacts_user_id_uid_index ON public.contacts USING btree (user_id, uid); + + +-- +-- Name: conversation_members_active_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX conversation_members_active_index ON public.conversation_members USING btree (user_id, conversation_id) WHERE (left_at IS NULL); + + +-- +-- Name: conversation_members_conversation_id_left_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX conversation_members_conversation_id_left_at_index ON public.conversation_members USING btree (conversation_id, left_at); + + +-- +-- Name: conversation_members_conversation_id_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX conversation_members_conversation_id_user_id_index ON public.conversation_members USING btree (conversation_id, user_id); + + +-- +-- Name: conversation_members_last_read_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX conversation_members_last_read_at_index ON public.conversation_members USING btree (last_read_at); + + +-- +-- Name: conversation_members_last_read_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX conversation_members_last_read_message_id_index ON public.conversation_members USING btree (last_read_message_id); + + +-- +-- Name: conversation_members_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX conversation_members_user_id_index ON public.conversation_members USING btree (user_id); + + +-- +-- Name: conversations_channel_federated_source_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX conversations_channel_federated_source_unique ON public.conversations USING btree (federated_source) WHERE (((type)::text = 'channel'::text) AND (federated_source IS NOT NULL)); + + +-- +-- Name: conversations_community_category_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX conversations_community_category_index ON public.conversations USING btree (community_category); + + +-- +-- Name: conversations_creator_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX conversations_creator_id_index ON public.conversations USING btree (creator_id); + + +-- +-- Name: conversations_federated_source_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX conversations_federated_source_index ON public.conversations USING btree (federated_source); + + +-- +-- Name: conversations_hash_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX conversations_hash_index ON public.conversations USING btree (hash); + + +-- +-- Name: conversations_is_federated_mirror_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX conversations_is_federated_mirror_index ON public.conversations USING btree (is_federated_mirror); + + +-- +-- Name: conversations_is_public_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX conversations_is_public_index ON public.conversations USING btree (is_public); + + +-- +-- Name: conversations_last_message_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX conversations_last_message_at_index ON public.conversations USING btree (last_message_at); + + +-- +-- Name: conversations_remote_group_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX conversations_remote_group_actor_id_index ON public.conversations USING btree (remote_group_actor_id); + + +-- +-- Name: conversations_server_id_channel_position_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX conversations_server_id_channel_position_index ON public.conversations USING btree (server_id, channel_position); + + +-- +-- Name: conversations_server_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX conversations_server_id_index ON public.conversations USING btree (server_id); + + +-- +-- Name: conversations_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX conversations_type_index ON public.conversations USING btree (type); + + +-- +-- Name: creator_satisfaction_creator_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX creator_satisfaction_creator_id_index ON public.creator_satisfaction USING btree (creator_id); + + +-- +-- Name: creator_satisfaction_remote_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX creator_satisfaction_remote_actor_id_index ON public.creator_satisfaction USING btree (remote_actor_id); + + +-- +-- Name: creator_satisfaction_user_id_creator_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX creator_satisfaction_user_id_creator_id_index ON public.creator_satisfaction USING btree (user_id, creator_id) WHERE ((creator_id IS NOT NULL) AND (remote_actor_id IS NULL)); + + +-- +-- Name: creator_satisfaction_user_id_remote_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX creator_satisfaction_user_id_remote_actor_id_index ON public.creator_satisfaction USING btree (user_id, remote_actor_id) WHERE (remote_actor_id IS NOT NULL); + + +-- +-- Name: custom_emojis_instance_domain_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX custom_emojis_instance_domain_index ON public.custom_emojis USING btree (instance_domain); + + +-- +-- Name: custom_emojis_shortcode_instance_domain_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX custom_emojis_shortcode_instance_domain_index ON public.custom_emojis USING btree (shortcode, instance_domain); + + +-- +-- Name: custom_emojis_visible_in_picker_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX custom_emojis_visible_in_picker_index ON public.custom_emojis USING btree (visible_in_picker); + + +-- +-- Name: data_exports_download_token_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX data_exports_download_token_index ON public.data_exports USING btree (download_token); + + +-- +-- Name: data_exports_expires_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX data_exports_expires_at_index ON public.data_exports USING btree (expires_at); + + +-- +-- Name: data_exports_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX data_exports_user_id_index ON public.data_exports USING btree (user_id); + + +-- +-- Name: data_exports_user_id_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX data_exports_user_id_status_index ON public.data_exports USING btree (user_id, status); + + +-- +-- Name: developer_webhook_deliveries_event_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX developer_webhook_deliveries_event_id_index ON public.developer_webhook_deliveries USING btree (event_id); + + +-- +-- Name: developer_webhook_deliveries_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX developer_webhook_deliveries_inserted_at_index ON public.developer_webhook_deliveries USING btree (inserted_at); + + +-- +-- Name: developer_webhook_deliveries_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX developer_webhook_deliveries_status_index ON public.developer_webhook_deliveries USING btree (status); + + +-- +-- Name: developer_webhook_deliveries_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX developer_webhook_deliveries_user_id_index ON public.developer_webhook_deliveries USING btree (user_id); + + +-- +-- Name: developer_webhook_deliveries_webhook_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX developer_webhook_deliveries_webhook_id_index ON public.developer_webhook_deliveries USING btree (webhook_id); + + +-- +-- Name: developer_webhooks_user_id_enabled_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX developer_webhooks_user_id_enabled_index ON public.developer_webhooks USING btree (user_id, enabled); + + +-- +-- Name: developer_webhooks_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX developer_webhooks_user_id_index ON public.developer_webhooks USING btree (user_id); + + +-- +-- Name: device_tokens_enabled_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX device_tokens_enabled_index ON public.device_tokens USING btree (enabled); + + +-- +-- Name: device_tokens_platform_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX device_tokens_platform_index ON public.device_tokens USING btree (platform); + + +-- +-- Name: device_tokens_token_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX device_tokens_token_index ON public.device_tokens USING btree (token); + + +-- +-- Name: device_tokens_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX device_tokens_user_id_index ON public.device_tokens USING btree (user_id); + + +-- +-- Name: dns_query_stats_daily_rollup_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX dns_query_stats_daily_rollup_unique ON public.dns_query_stats USING btree (zone_id, query_date, qname, qtype, rcode, transport); + + +-- +-- Name: dns_query_stats_zone_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX dns_query_stats_zone_id_index ON public.dns_query_stats USING btree (zone_id); + + +-- +-- Name: dns_query_stats_zone_id_query_date_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX dns_query_stats_zone_id_query_date_index ON public.dns_query_stats USING btree (zone_id, query_date); + + +-- +-- Name: dns_records_identity_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX dns_records_identity_unique ON public.dns_records USING btree (zone_id, name, type, content); + + +-- +-- Name: dns_records_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX dns_records_type_index ON public.dns_records USING btree (type); + + +-- +-- Name: dns_records_zone_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX dns_records_zone_id_index ON public.dns_records USING btree (zone_id); + + +-- +-- Name: dns_records_zone_id_service_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX dns_records_zone_id_service_index ON public.dns_records USING btree (zone_id, service); + + +-- +-- Name: dns_records_zone_managed_key_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX dns_records_zone_managed_key_unique ON public.dns_records USING btree (zone_id, managed_key) WHERE (managed_key IS NOT NULL); + + +-- +-- Name: dns_zone_service_configs_zone_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX dns_zone_service_configs_zone_id_index ON public.dns_zone_service_configs USING btree (zone_id); + + +-- +-- Name: dns_zone_service_configs_zone_service_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX dns_zone_service_configs_zone_service_unique ON public.dns_zone_service_configs USING btree (zone_id, service); + + +-- +-- Name: dns_zones_domain_ci_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX dns_zones_domain_ci_unique ON public.dns_zones USING btree (lower((domain)::text)); + + +-- +-- Name: dns_zones_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX dns_zones_status_index ON public.dns_zones USING btree (status); + + +-- +-- Name: dns_zones_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX dns_zones_user_id_index ON public.dns_zones USING btree (user_id); + + +-- +-- Name: email_aliases_alias_email_ci_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_aliases_alias_email_ci_unique ON public.email_aliases USING btree (lower((alias_email)::text)); + + +-- +-- Name: email_aliases_alias_email_enabled_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_aliases_alias_email_enabled_index ON public.email_aliases USING btree (alias_email, enabled); + + +-- +-- Name: email_aliases_alias_email_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_aliases_alias_email_index ON public.email_aliases USING btree (alias_email); + + +-- +-- Name: email_aliases_alias_email_lower_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_aliases_alias_email_lower_idx ON public.email_aliases USING btree (lower((alias_email)::text)); + + +-- +-- Name: email_aliases_enabled_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_aliases_enabled_index ON public.email_aliases USING btree (enabled); + + +-- +-- Name: email_aliases_target_email_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_aliases_target_email_index ON public.email_aliases USING btree (target_email); + + +-- +-- Name: email_aliases_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_aliases_user_id_index ON public.email_aliases USING btree (user_id); + + +-- +-- Name: email_auto_replies_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_auto_replies_user_id_index ON public.email_auto_replies USING btree (user_id); + + +-- +-- Name: email_auto_reply_log_sender_email_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_auto_reply_log_sender_email_index ON public.email_auto_reply_log USING btree (sender_email); + + +-- +-- Name: email_auto_reply_log_sent_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_auto_reply_log_sent_at_index ON public.email_auto_reply_log USING btree (sent_at); + + +-- +-- Name: email_auto_reply_log_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_auto_reply_log_user_id_index ON public.email_auto_reply_log USING btree (user_id); + + +-- +-- Name: email_auto_reply_log_user_id_sender_email_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_auto_reply_log_user_id_sender_email_index ON public.email_auto_reply_log USING btree (user_id, sender_email); + + +-- +-- Name: email_blocked_senders_domain_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_blocked_senders_domain_index ON public.email_blocked_senders USING btree (domain); + + +-- +-- Name: email_blocked_senders_email_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_blocked_senders_email_index ON public.email_blocked_senders USING btree (email); + + +-- +-- Name: email_blocked_senders_user_id_domain_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_blocked_senders_user_id_domain_index ON public.email_blocked_senders USING btree (user_id, domain) WHERE (domain IS NOT NULL); + + +-- +-- Name: email_blocked_senders_user_id_email_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_blocked_senders_user_id_email_index ON public.email_blocked_senders USING btree (user_id, email) WHERE (email IS NOT NULL); + + +-- +-- Name: email_blocked_senders_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_blocked_senders_user_id_index ON public.email_blocked_senders USING btree (user_id); + + +-- +-- Name: email_category_preferences_domain_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_category_preferences_domain_index ON public.email_category_preferences USING btree (domain); + + +-- +-- Name: email_category_preferences_email_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_category_preferences_email_index ON public.email_category_preferences USING btree (email); + + +-- +-- Name: email_category_preferences_user_domain_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_category_preferences_user_domain_idx ON public.email_category_preferences USING btree (user_id, domain) WHERE (domain IS NOT NULL); + + +-- +-- Name: email_category_preferences_user_email_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_category_preferences_user_email_idx ON public.email_category_preferences USING btree (user_id, email) WHERE (email IS NOT NULL); + + +-- +-- Name: email_category_preferences_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_category_preferences_user_id_index ON public.email_category_preferences USING btree (user_id); + + +-- +-- Name: email_custom_domains_domain_ci_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_custom_domains_domain_ci_unique ON public.email_custom_domains USING btree (lower((domain)::text)); + + +-- +-- Name: email_custom_domains_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_custom_domains_status_index ON public.email_custom_domains USING btree (status); + + +-- +-- Name: email_custom_domains_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_custom_domains_user_id_index ON public.email_custom_domains USING btree (user_id); + + +-- +-- Name: email_exports_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_exports_status_index ON public.email_exports USING btree (status); + + +-- +-- Name: email_exports_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_exports_user_id_index ON public.email_exports USING btree (user_id); + + +-- +-- Name: email_filters_enabled_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_filters_enabled_index ON public.email_filters USING btree (enabled); + + +-- +-- Name: email_filters_priority_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_filters_priority_index ON public.email_filters USING btree (priority); + + +-- +-- Name: email_filters_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_filters_user_id_index ON public.email_filters USING btree (user_id); + + +-- +-- Name: email_folders_parent_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_folders_parent_id_index ON public.email_folders USING btree (parent_id); + + +-- +-- Name: email_folders_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_folders_user_id_index ON public.email_folders USING btree (user_id); + + +-- +-- Name: email_folders_user_id_name_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_folders_user_id_name_index ON public.email_folders USING btree (user_id, name); + + +-- +-- Name: email_jobs_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_jobs_status_index ON public.email_jobs USING btree (status); + + +-- +-- Name: email_jobs_status_scheduled_for_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_jobs_status_scheduled_for_index ON public.email_jobs USING btree (status, scheduled_for) WHERE ((status)::text = 'pending'::text); + + +-- +-- Name: email_jobs_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_jobs_user_id_index ON public.email_jobs USING btree (user_id); + + +-- +-- Name: email_labels_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_labels_user_id_index ON public.email_labels USING btree (user_id); + + +-- +-- Name: email_labels_user_id_name_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_labels_user_id_name_index ON public.email_labels USING btree (user_id, name); + + +-- +-- Name: email_message_labels_label_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_message_labels_label_id_index ON public.email_message_labels USING btree (label_id); + + +-- +-- Name: email_message_labels_message_id_label_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_message_labels_message_id_label_id_index ON public.email_message_labels USING btree (message_id, label_id); + + +-- +-- Name: email_messages_active_inserted_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_active_inserted_idx ON public.email_messages USING btree (inserted_at DESC) WHERE ((spam = false) AND (archived = false)); + + +-- +-- Name: email_messages_answered_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_answered_index ON public.email_messages USING btree (answered); + + +-- +-- Name: email_messages_archived_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_archived_index ON public.email_messages USING btree (archived); + + +-- +-- Name: email_messages_boomerang_performance_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_boomerang_performance_idx ON public.email_messages USING btree (mailbox_id, reply_later_at, spam, archived, deleted) WHERE ((reply_later_at IS NOT NULL) AND (NOT spam) AND (NOT archived) AND (NOT deleted)); + + +-- +-- Name: email_messages_category_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_category_index ON public.email_messages USING btree (category); + + +-- +-- Name: email_messages_deleted_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_deleted_index ON public.email_messages USING btree (deleted); + + +-- +-- Name: email_messages_expires_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_expires_at_index ON public.email_messages USING btree (expires_at); + + +-- +-- Name: email_messages_feed_performance_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_feed_performance_idx ON public.email_messages USING btree (mailbox_id, category, spam, archived, deleted, inserted_at) WHERE (((category)::text = 'feed'::text) AND (NOT spam) AND (NOT archived) AND (NOT deleted)); + + +-- +-- Name: email_messages_folder_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_folder_id_index ON public.email_messages USING btree (folder_id); + + +-- +-- Name: email_messages_folder_mailbox_active_inserted_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_folder_mailbox_active_inserted_idx ON public.email_messages USING btree (folder_id, mailbox_id, inserted_at DESC) WHERE (deleted = false); + + +-- +-- Name: email_messages_from_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_from_index ON public.email_messages USING btree ("from"); + + +-- +-- Name: email_messages_has_attachments_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_has_attachments_index ON public.email_messages USING btree (has_attachments); + + +-- +-- Name: email_messages_hash_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_messages_hash_index ON public.email_messages USING btree (hash); + + +-- +-- Name: email_messages_imap_inbox_mailbox_id_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_imap_inbox_mailbox_id_id_idx ON public.email_messages USING btree (mailbox_id, id) WHERE ((reply_later_at IS NULL) AND (NOT spam) AND (NOT archived) AND (NOT deleted)); + + +-- +-- Name: email_messages_in_reply_to_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_in_reply_to_index ON public.email_messages USING btree (in_reply_to); + + +-- +-- Name: email_messages_inbox_performance_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_inbox_performance_idx ON public.email_messages USING btree (mailbox_id, category, spam, archived, deleted, reply_later_at, inserted_at) WHERE ((NOT spam) AND (NOT archived) AND (NOT deleted) AND ((category)::text <> ALL ((ARRAY['feed'::character varying, 'ledger'::character varying, 'stack'::character varying])::text[])) AND (reply_later_at IS NULL)); + + +-- +-- Name: email_messages_inbox_unread_folderless_inserted_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_inbox_unread_folderless_inserted_idx ON public.email_messages USING btree (mailbox_id, inserted_at DESC) WHERE ((read = false) AND (spam = false) AND (archived = false) AND (deleted = false) AND (reply_later_at IS NULL) AND (folder_id IS NULL) AND ((category IS NULL) OR ((category)::text <> ALL ((ARRAY['feed'::character varying, 'ledger'::character varying, 'stack'::character varying])::text[])))); + + +-- +-- Name: email_messages_is_newsletter_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_is_newsletter_index ON public.email_messages USING btree (is_newsletter); + + +-- +-- Name: email_messages_is_notification_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_is_notification_index ON public.email_messages USING btree (is_notification); + + +-- +-- Name: email_messages_is_receipt_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_is_receipt_index ON public.email_messages USING btree (is_receipt); + + +-- +-- Name: email_messages_jmap_blob_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_jmap_blob_id_index ON public.email_messages USING btree (jmap_blob_id); + + +-- +-- Name: email_messages_ledger_performance_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_ledger_performance_idx ON public.email_messages USING btree (mailbox_id, category, spam, archived, deleted, inserted_at) WHERE (((category)::text = 'ledger'::text) AND (NOT spam) AND (NOT archived) AND (NOT deleted)); + + +-- +-- Name: email_messages_mailbox_active_inserted_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_mailbox_active_inserted_idx ON public.email_messages USING btree (mailbox_id, inserted_at DESC) WHERE ((spam = false) AND (archived = false) AND (deleted = false)); + + +-- +-- Name: email_messages_mailbox_id_deleted_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_mailbox_id_deleted_index ON public.email_messages USING btree (mailbox_id, deleted); + + +-- +-- Name: email_messages_mailbox_id_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_mailbox_id_id_idx ON public.email_messages USING btree (mailbox_id, id); + + +-- +-- Name: email_messages_mailbox_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_mailbox_id_index ON public.email_messages USING btree (mailbox_id); + + +-- +-- Name: email_messages_message_id_mailbox_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_messages_message_id_mailbox_id_index ON public.email_messages USING btree (message_id, mailbox_id); + + +-- +-- Name: email_messages_priority_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_priority_index ON public.email_messages USING btree (priority); + + +-- +-- Name: email_messages_read_performance_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_read_performance_idx ON public.email_messages USING btree (mailbox_id, read, spam, archived, deleted, inserted_at) WHERE (read AND (NOT spam) AND (NOT archived) AND (NOT deleted)); + + +-- +-- Name: email_messages_reply_later_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_reply_later_at_index ON public.email_messages USING btree (reply_later_at); + + +-- +-- Name: email_messages_scheduled_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_scheduled_at_index ON public.email_messages USING btree (scheduled_at); + + +-- +-- Name: email_messages_search_index_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_search_index_index ON public.email_messages USING gin (search_index); + + +-- +-- Name: email_messages_spam_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_spam_index ON public.email_messages USING btree (spam); + + +-- +-- Name: email_messages_stack_performance_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_stack_performance_idx ON public.email_messages USING btree (mailbox_id, category, stack_at, spam, archived, deleted) WHERE (((category)::text = 'stack'::text) AND (stack_at IS NOT NULL) AND (NOT spam) AND (NOT archived) AND (NOT deleted)); + + +-- +-- Name: email_messages_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_status_index ON public.email_messages USING btree (status); + + +-- +-- Name: email_messages_status_inserted_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_status_inserted_at_idx ON public.email_messages USING btree (status, inserted_at DESC); + + +-- +-- Name: email_messages_thread_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_thread_id_index ON public.email_messages USING btree (thread_id); + + +-- +-- Name: email_messages_to_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_to_index ON public.email_messages USING btree ("to"); + + +-- +-- Name: email_messages_unread_performance_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_messages_unread_performance_idx ON public.email_messages USING btree (mailbox_id, read, spam, archived, deleted, inserted_at) WHERE ((NOT read) AND (NOT spam) AND (NOT archived) AND (NOT deleted)); + + +-- +-- Name: email_safe_senders_domain_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_safe_senders_domain_index ON public.email_safe_senders USING btree (domain); + + +-- +-- Name: email_safe_senders_email_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_safe_senders_email_index ON public.email_safe_senders USING btree (email); + + +-- +-- Name: email_safe_senders_user_id_domain_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_safe_senders_user_id_domain_index ON public.email_safe_senders USING btree (user_id, domain) WHERE (domain IS NOT NULL); + + +-- +-- Name: email_safe_senders_user_id_email_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_safe_senders_user_id_email_index ON public.email_safe_senders USING btree (user_id, email) WHERE (email IS NOT NULL); + + +-- +-- Name: email_safe_senders_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_safe_senders_user_id_index ON public.email_safe_senders USING btree (user_id); + + +-- +-- Name: email_submissions_email_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_submissions_email_id_index ON public.email_submissions USING btree (email_id); + + +-- +-- Name: email_submissions_mailbox_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_submissions_mailbox_id_index ON public.email_submissions USING btree (mailbox_id); + + +-- +-- Name: email_suppressions_email_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_suppressions_email_index ON public.email_suppressions USING btree (email); + + +-- +-- Name: email_suppressions_expires_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_suppressions_expires_at_index ON public.email_suppressions USING btree (expires_at); + + +-- +-- Name: email_suppressions_user_id_email_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_suppressions_user_id_email_index ON public.email_suppressions USING btree (user_id, email); + + +-- +-- Name: email_suppressions_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_suppressions_user_id_index ON public.email_suppressions USING btree (user_id); + + +-- +-- Name: email_templates_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_templates_user_id_index ON public.email_templates USING btree (user_id); + + +-- +-- Name: email_templates_user_id_name_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_templates_user_id_name_index ON public.email_templates USING btree (user_id, name); + + +-- +-- Name: email_threads_mailbox_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_threads_mailbox_id_index ON public.email_threads USING btree (mailbox_id); + + +-- +-- Name: email_threads_mailbox_id_subject_hash_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_threads_mailbox_id_subject_hash_index ON public.email_threads USING btree (mailbox_id, subject_hash); + + +-- +-- Name: email_unsubscribes_email_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_unsubscribes_email_index ON public.email_unsubscribes USING btree (email); + + +-- +-- Name: email_unsubscribes_email_list_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX email_unsubscribes_email_list_id_index ON public.email_unsubscribes USING btree (email, list_id); + + +-- +-- Name: email_unsubscribes_token_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_unsubscribes_token_index ON public.email_unsubscribes USING btree (token); + + +-- +-- Name: email_unsubscribes_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX email_unsubscribes_user_id_index ON public.email_unsubscribes USING btree (user_id); + + +-- +-- Name: federated_boosts_activitypub_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX federated_boosts_activitypub_id_index ON public.federated_boosts USING btree (activitypub_id); + + +-- +-- Name: federated_boosts_message_id_remote_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX federated_boosts_message_id_remote_actor_id_index ON public.federated_boosts USING btree (message_id, remote_actor_id); + + +-- +-- Name: federated_boosts_remote_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX federated_boosts_remote_actor_id_index ON public.federated_boosts USING btree (remote_actor_id); + + +-- +-- Name: federated_dislikes_activitypub_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX federated_dislikes_activitypub_id_index ON public.federated_dislikes USING btree (activitypub_id); + + +-- +-- Name: federated_dislikes_message_id_remote_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX federated_dislikes_message_id_remote_actor_id_index ON public.federated_dislikes USING btree (message_id, remote_actor_id); + + +-- +-- Name: federated_dislikes_remote_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX federated_dislikes_remote_actor_id_index ON public.federated_dislikes USING btree (remote_actor_id); + + +-- +-- Name: federated_likes_activitypub_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX federated_likes_activitypub_id_index ON public.federated_likes USING btree (activitypub_id); + + +-- +-- Name: federated_likes_message_id_remote_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX federated_likes_message_id_remote_actor_id_index ON public.federated_likes USING btree (message_id, remote_actor_id); + + +-- +-- Name: federated_likes_remote_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX federated_likes_remote_actor_id_index ON public.federated_likes USING btree (remote_actor_id); + + +-- +-- Name: federated_quotes_activitypub_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX federated_quotes_activitypub_id_index ON public.federated_quotes USING btree (activitypub_id); + + +-- +-- Name: federated_quotes_message_id_remote_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX federated_quotes_message_id_remote_actor_id_index ON public.federated_quotes USING btree (message_id, remote_actor_id); + + +-- +-- Name: federated_quotes_remote_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX federated_quotes_remote_actor_id_index ON public.federated_quotes USING btree (remote_actor_id); + + +-- +-- Name: file_shares_access_level_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX file_shares_access_level_index ON public.file_shares USING btree (access_level); + + +-- +-- Name: file_shares_expires_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX file_shares_expires_at_index ON public.file_shares USING btree (expires_at); + + +-- +-- Name: file_shares_stored_file_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX file_shares_stored_file_id_index ON public.file_shares USING btree (stored_file_id); + + +-- +-- Name: file_shares_token_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX file_shares_token_index ON public.file_shares USING btree (token); + + +-- +-- Name: file_shares_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX file_shares_user_id_index ON public.file_shares USING btree (user_id); + + +-- +-- Name: follows_followed_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX follows_followed_id_index ON public.follows USING btree (followed_id); + + +-- +-- Name: follows_follower_id_followed_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX follows_follower_id_followed_id_index ON public.follows USING btree (follower_id, followed_id); + + +-- +-- Name: follows_follower_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX follows_follower_id_index ON public.follows USING btree (follower_id); + + +-- +-- Name: follows_follower_id_remote_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX follows_follower_id_remote_actor_id_index ON public.follows USING btree (follower_id, remote_actor_id); + + +-- +-- Name: follows_pending_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX follows_pending_index ON public.follows USING btree (pending); + + +-- +-- Name: follows_remote_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX follows_remote_actor_id_index ON public.follows USING btree (remote_actor_id); + + +-- +-- Name: forwarded_messages_alias_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX forwarded_messages_alias_id_index ON public.forwarded_messages USING btree (alias_id); + + +-- +-- Name: forwarded_messages_final_recipient_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX forwarded_messages_final_recipient_index ON public.forwarded_messages USING btree (final_recipient); + + +-- +-- Name: forwarded_messages_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX forwarded_messages_inserted_at_index ON public.forwarded_messages USING btree (inserted_at); + + +-- +-- Name: forwarded_messages_original_recipient_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX forwarded_messages_original_recipient_index ON public.forwarded_messages USING btree (original_recipient); + + +-- +-- Name: friend_requests_recipient_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX friend_requests_recipient_id_index ON public.friend_requests USING btree (recipient_id); + + +-- +-- Name: friend_requests_requester_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX friend_requests_requester_id_index ON public.friend_requests USING btree (requester_id); + + +-- +-- Name: friend_requests_requester_id_recipient_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX friend_requests_requester_id_recipient_id_index ON public.friend_requests USING btree (requester_id, recipient_id); + + +-- +-- Name: friend_requests_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX friend_requests_status_index ON public.friend_requests USING btree (status); + + +-- +-- Name: group_follows_activitypub_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX group_follows_activitypub_id_index ON public.group_follows USING btree (activitypub_id); + + +-- +-- Name: group_follows_group_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX group_follows_group_actor_id_index ON public.group_follows USING btree (group_actor_id); + + +-- +-- Name: group_follows_remote_actor_id_group_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX group_follows_remote_actor_id_group_actor_id_index ON public.group_follows USING btree (remote_actor_id, group_actor_id); + + +-- +-- Name: handle_history_handle_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX handle_history_handle_index ON public.handle_history USING btree (handle); + + +-- +-- Name: handle_history_reserved_until_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX handle_history_reserved_until_index ON public.handle_history USING btree (reserved_until); + + +-- +-- Name: handle_history_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX handle_history_user_id_index ON public.handle_history USING btree (user_id); + + +-- +-- Name: hashtag_follows_hashtag_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX hashtag_follows_hashtag_id_index ON public.hashtag_follows USING btree (hashtag_id); + + +-- +-- Name: hashtag_follows_user_id_hashtag_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX hashtag_follows_user_id_hashtag_id_index ON public.hashtag_follows USING btree (user_id, hashtag_id); + + +-- +-- Name: hashtags_last_used_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX hashtags_last_used_at_index ON public.hashtags USING btree (last_used_at); + + +-- +-- Name: hashtags_normalized_name_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX hashtags_normalized_name_idx ON public.hashtags USING btree (normalized_name); + + +-- +-- Name: hashtags_normalized_name_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX hashtags_normalized_name_index ON public.hashtags USING btree (normalized_name); + + +-- +-- Name: hashtags_use_count_desc_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX hashtags_use_count_desc_index ON public.hashtags USING btree (use_count DESC); + + +-- +-- Name: hashtags_use_count_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX hashtags_use_count_index ON public.hashtags USING btree (use_count); + + +-- +-- Name: imap_subscriptions_user_id_folder_name_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX imap_subscriptions_user_id_folder_name_index ON public.imap_subscriptions USING btree (user_id, folder_name); + + +-- +-- Name: imap_subscriptions_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX imap_subscriptions_user_id_index ON public.imap_subscriptions USING btree (user_id); + + +-- +-- Name: invite_code_uses_invite_code_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX invite_code_uses_invite_code_id_index ON public.invite_code_uses USING btree (invite_code_id); + + +-- +-- Name: invite_code_uses_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX invite_code_uses_user_id_index ON public.invite_code_uses USING btree (user_id); + + +-- +-- Name: invite_code_uses_user_id_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX invite_code_uses_user_id_unique ON public.invite_code_uses USING btree (user_id); + + +-- +-- Name: invite_codes_code_upper_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX invite_codes_code_upper_unique ON public.invite_codes USING btree (upper((code)::text)); + + +-- +-- Name: invite_codes_expires_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX invite_codes_expires_at_index ON public.invite_codes USING btree (expires_at); + + +-- +-- Name: invite_codes_is_active_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX invite_codes_is_active_index ON public.invite_codes USING btree (is_active); + + +-- +-- Name: jmap_email_changes_mailbox_id_email_id_state_counter_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX jmap_email_changes_mailbox_id_email_id_state_counter_index ON public.jmap_email_changes USING btree (mailbox_id, email_id, state_counter); + + +-- +-- Name: jmap_email_changes_mailbox_id_state_counter_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX jmap_email_changes_mailbox_id_state_counter_index ON public.jmap_email_changes USING btree (mailbox_id, state_counter); + + +-- +-- Name: jmap_email_tombstones_mailbox_id_email_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX jmap_email_tombstones_mailbox_id_email_id_index ON public.jmap_email_tombstones USING btree (mailbox_id, email_id); + + +-- +-- Name: jmap_email_tombstones_mailbox_id_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX jmap_email_tombstones_mailbox_id_inserted_at_index ON public.jmap_email_tombstones USING btree (mailbox_id, inserted_at); + + +-- +-- Name: jmap_state_tracking_mailbox_id_entity_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX jmap_state_tracking_mailbox_id_entity_type_index ON public.jmap_state_tracking USING btree (mailbox_id, entity_type); + + +-- +-- Name: lemmy_counts_cache_activitypub_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX lemmy_counts_cache_activitypub_id_index ON public.lemmy_counts_cache USING btree (activitypub_id); + + +-- +-- Name: lemmy_counts_cache_fetched_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX lemmy_counts_cache_fetched_at_index ON public.lemmy_counts_cache USING btree (fetched_at); + + +-- +-- Name: link_preview_jobs_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX link_preview_jobs_message_id_index ON public.link_preview_jobs USING btree (message_id); + + +-- +-- Name: link_preview_jobs_status_attempts_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX link_preview_jobs_status_attempts_index ON public.link_preview_jobs USING btree (status, attempts) WHERE (((status)::text = 'pending'::text) AND (attempts < 3)); + + +-- +-- Name: link_preview_jobs_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX link_preview_jobs_status_index ON public.link_preview_jobs USING btree (status); + + +-- +-- Name: link_preview_jobs_url_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX link_preview_jobs_url_index ON public.link_preview_jobs USING btree (url); + + +-- +-- Name: link_previews_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX link_previews_status_index ON public.link_previews USING btree (status); + + +-- +-- Name: link_previews_url_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX link_previews_url_index ON public.link_previews USING btree (url); + + +-- +-- Name: list_members_list_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX list_members_list_id_index ON public.list_members USING btree (list_id); + + +-- +-- Name: list_members_list_id_remote_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX list_members_list_id_remote_actor_id_index ON public.list_members USING btree (list_id, remote_actor_id) WHERE (remote_actor_id IS NOT NULL); + + +-- +-- Name: list_members_list_id_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX list_members_list_id_user_id_index ON public.list_members USING btree (list_id, user_id) WHERE (user_id IS NOT NULL); + + +-- +-- Name: list_members_remote_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX list_members_remote_actor_id_index ON public.list_members USING btree (remote_actor_id); + + +-- +-- Name: list_members_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX list_members_user_id_index ON public.list_members USING btree (user_id); + + +-- +-- Name: lists_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX lists_user_id_index ON public.lists USING btree (user_id); + + +-- +-- Name: lists_user_id_name_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX lists_user_id_name_index ON public.lists USING btree (user_id, name); + + +-- +-- Name: mailboxes_email_ci_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX mailboxes_email_ci_unique ON public.mailboxes USING btree (lower((email)::text)); + + +-- +-- Name: mailboxes_email_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX mailboxes_email_index ON public.mailboxes USING btree (email); + + +-- +-- Name: mailboxes_forward_enabled_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX mailboxes_forward_enabled_index ON public.mailboxes USING btree (forward_enabled); + + +-- +-- Name: mailboxes_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX mailboxes_user_id_index ON public.mailboxes USING btree (user_id); + + +-- +-- Name: mailboxes_username_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX mailboxes_username_index ON public.mailboxes USING btree (username); + + +-- +-- Name: message_reactions_federated_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_reactions_federated_index ON public.message_reactions USING btree (federated); + + +-- +-- Name: message_reactions_federated_unique_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX message_reactions_federated_unique_index ON public.message_reactions USING btree (message_id, remote_actor_id, emoji) WHERE (remote_actor_id IS NOT NULL); + + +-- +-- Name: message_reactions_message_id_emoji_emoji_url_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_reactions_message_id_emoji_emoji_url_index ON public.message_reactions USING btree (message_id, emoji, emoji_url); + + +-- +-- Name: message_reactions_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_reactions_message_id_index ON public.message_reactions USING btree (message_id); + + +-- +-- Name: message_reactions_message_id_user_id_emoji_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX message_reactions_message_id_user_id_emoji_index ON public.message_reactions USING btree (message_id, user_id, emoji); + + +-- +-- Name: message_reactions_remote_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_reactions_remote_actor_id_index ON public.message_reactions USING btree (remote_actor_id); + + +-- +-- Name: message_votes_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_votes_inserted_at_index ON public.message_votes USING btree (inserted_at); + + +-- +-- Name: message_votes_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_votes_message_id_index ON public.message_votes USING btree (message_id); + + +-- +-- Name: message_votes_message_id_vote_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_votes_message_id_vote_type_index ON public.message_votes USING btree (message_id, vote_type); + + +-- +-- Name: message_votes_message_id_vote_type_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_votes_message_id_vote_type_inserted_at_index ON public.message_votes USING btree (message_id, vote_type, inserted_at); + + +-- +-- Name: message_votes_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_votes_user_id_index ON public.message_votes USING btree (user_id); + + +-- +-- Name: message_votes_user_id_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX message_votes_user_id_message_id_index ON public.message_votes USING btree (user_id, message_id); + + +-- +-- Name: message_votes_vote_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_votes_vote_type_index ON public.message_votes USING btree (vote_type); + + +-- +-- Name: messages_activitypub_id_canonical_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_activitypub_id_canonical_idx ON public.messages USING btree (activitypub_id_canonical) WHERE (activitypub_id_canonical IS NOT NULL); + + +-- +-- Name: messages_activitypub_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messages_activitypub_id_index ON public.messages USING btree (activitypub_id) WHERE (activitypub_id IS NOT NULL); + + +-- +-- Name: messages_activitypub_url_canonical_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_activitypub_url_canonical_idx ON public.messages USING btree (activitypub_url_canonical) WHERE (activitypub_url_canonical IS NOT NULL); + + +-- +-- Name: messages_activitypub_url_not_null_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_activitypub_url_not_null_idx ON public.messages USING btree (activitypub_url) WHERE (activitypub_url IS NOT NULL); + + +-- +-- Name: messages_approval_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_approval_status_index ON public.messages USING btree (approval_status); + + +-- +-- Name: messages_approval_visibility_inserted_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_approval_visibility_inserted_idx ON public.messages USING btree (approval_status, visibility, inserted_at); + + +-- +-- Name: messages_bluesky_cid_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_bluesky_cid_index ON public.messages USING btree (bluesky_cid); + + +-- +-- Name: messages_bluesky_uri_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messages_bluesky_uri_index ON public.messages USING btree (bluesky_uri) WHERE (bluesky_uri IS NOT NULL); + + +-- +-- Name: messages_category_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_category_index ON public.messages USING btree (category) WHERE (category IS NOT NULL); + + +-- +-- Name: messages_community_actor_uri_fed_public_top_level_inserted_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_community_actor_uri_fed_public_top_level_inserted_idx ON public.messages USING btree (((media_metadata ->> 'community_actor_uri'::text)), inserted_at DESC) WHERE ((federated = true) AND ((visibility)::text = 'public'::text) AND (deleted_at IS NULL) AND (reply_to_id IS NULL) AND ((media_metadata ->> 'community_actor_uri'::text) IS NOT NULL)); + + +-- +-- Name: messages_community_actor_uri_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_community_actor_uri_idx ON public.messages USING btree (((media_metadata ->> 'community_actor_uri'::text))) WHERE ((media_metadata ->> 'community_actor_uri'::text) IS NOT NULL); + + +-- +-- Name: messages_conv_visibility_deleted_inserted_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_conv_visibility_deleted_inserted_idx ON public.messages USING btree (conversation_id, visibility, deleted_at, inserted_at); + + +-- +-- Name: messages_conversation_id_deleted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_conversation_id_deleted_at_index ON public.messages USING btree (conversation_id, deleted_at); + + +-- +-- Name: messages_conversation_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_conversation_id_index ON public.messages USING btree (conversation_id); + + +-- +-- Name: messages_conversation_id_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_conversation_id_inserted_at_index ON public.messages USING btree (conversation_id, inserted_at); + + +-- +-- Name: messages_conversation_id_is_pinned_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_conversation_id_is_pinned_index ON public.messages USING btree (conversation_id, is_pinned); + + +-- +-- Name: messages_conversation_timeline_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_conversation_timeline_idx ON public.messages USING btree (conversation_id, inserted_at, deleted_at) WHERE (deleted_at IS NULL); + + +-- +-- Name: messages_extracted_hashtags_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_extracted_hashtags_index ON public.messages USING btree (extracted_hashtags); + + +-- +-- Name: messages_federated_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_federated_index ON public.messages USING btree (federated); + + +-- +-- Name: messages_flair_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_flair_id_index ON public.messages USING btree (flair_id); + + +-- +-- Name: messages_inserted_at_desc_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_inserted_at_desc_index ON public.messages USING btree (inserted_at DESC); + + +-- +-- Name: messages_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_inserted_at_index ON public.messages USING btree (inserted_at); + + +-- +-- Name: messages_like_count_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_like_count_index ON public.messages USING btree (like_count); + + +-- +-- Name: messages_link_preview_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_link_preview_id_index ON public.messages USING btree (link_preview_id); + + +-- +-- Name: messages_locked_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_locked_at_index ON public.messages USING btree (locked_at); + + +-- +-- Name: messages_locked_by_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_locked_by_id_index ON public.messages USING btree (locked_by_id); + + +-- +-- Name: messages_media_metadata_gin_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_media_metadata_gin_idx ON public.messages USING gin (media_metadata jsonb_path_ops); + + +-- +-- Name: messages_original_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_original_message_id_index ON public.messages USING btree (original_message_id); + + +-- +-- Name: messages_post_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_post_type_index ON public.messages USING btree (post_type); + + +-- +-- Name: messages_post_type_visibility_active_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_post_type_visibility_active_index ON public.messages USING btree (post_type, visibility) WHERE (deleted_at IS NULL); + + +-- +-- Name: messages_primary_url_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_primary_url_index ON public.messages USING btree (primary_url); + + +-- +-- Name: messages_promoted_from_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_promoted_from_index ON public.messages USING btree (promoted_from); + + +-- +-- Name: messages_quoted_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_quoted_message_id_index ON public.messages USING btree (quoted_message_id); + + +-- +-- Name: messages_remote_actor_fed_public_top_level_inserted_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_remote_actor_fed_public_top_level_inserted_idx ON public.messages USING btree (remote_actor_id, inserted_at DESC) WHERE ((federated = true) AND ((visibility)::text = 'public'::text) AND (deleted_at IS NULL) AND (reply_to_id IS NULL) AND (remote_actor_id IS NOT NULL)); + + +-- +-- Name: messages_remote_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_remote_actor_id_index ON public.messages USING btree (remote_actor_id); + + +-- +-- Name: messages_remote_actor_visibility_deleted_inserted_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_remote_actor_visibility_deleted_inserted_idx ON public.messages USING btree (remote_actor_id, visibility, deleted_at, inserted_at) WHERE (remote_actor_id IS NOT NULL); + + +-- +-- Name: messages_reply_to_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_reply_to_id_index ON public.messages USING btree (reply_to_id); + + +-- +-- Name: messages_reply_to_id_inserted_at_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_reply_to_id_inserted_at_id_idx ON public.messages USING btree (reply_to_id, inserted_at, id) WHERE (reply_to_id IS NOT NULL); + + +-- +-- Name: messages_score_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_score_index ON public.messages USING btree (score); + + +-- +-- Name: messages_search_index_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_search_index_index ON public.messages USING gin (search_index); + + +-- +-- Name: messages_sender_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_sender_id_index ON public.messages USING btree (sender_id); + + +-- +-- Name: messages_sender_id_is_draft_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_sender_id_is_draft_index ON public.messages USING btree (sender_id, is_draft) WHERE (is_draft = true); + + +-- +-- Name: messages_sender_visibility_deleted_inserted_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_sender_visibility_deleted_inserted_idx ON public.messages USING btree (sender_id, visibility, deleted_at, inserted_at); + + +-- +-- Name: messages_sensitive_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_sensitive_index ON public.messages USING btree (sensitive); + + +-- +-- Name: messages_shared_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_shared_message_id_index ON public.messages USING btree (shared_message_id); + + +-- +-- Name: messages_title_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_title_index ON public.messages USING btree (title); + + +-- +-- Name: messages_upvotes_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_upvotes_index ON public.messages USING btree (upvotes); + + +-- +-- Name: messages_user_timeline_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_user_timeline_idx ON public.messages USING btree (sender_id, post_type, visibility, deleted_at, inserted_at) WHERE (((post_type)::text = 'post'::text) AND (deleted_at IS NULL)); + + +-- +-- Name: messages_visibility_deleted_inserted_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_visibility_deleted_inserted_idx ON public.messages USING btree (visibility, deleted_at, inserted_at); + + +-- +-- Name: messages_visibility_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messages_visibility_index ON public.messages USING btree (visibility); + + +-- +-- Name: messaging_federation_account_presence_states_expires_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_account_presence_states_expires_at_idx ON public.messaging_federation_account_presence_states USING btree (expires_at_remote); + + +-- +-- Name: messaging_federation_account_presence_states_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_account_presence_states_unique ON public.messaging_federation_account_presence_states USING btree (remote_actor_id); + + +-- +-- Name: messaging_federation_account_presence_states_updated_at_remote_; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_account_presence_states_updated_at_remote_ ON public.messaging_federation_account_presence_states USING btree (updated_at_remote); + + +-- +-- Name: messaging_federation_call_sessions_conversation_inserted_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_call_sessions_conversation_inserted_at_idx ON public.messaging_federation_call_sessions USING btree (conversation_id, inserted_at); + + +-- +-- Name: messaging_federation_call_sessions_remote_domain_status_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_call_sessions_remote_domain_status_idx ON public.messaging_federation_call_sessions USING btree (remote_domain, status); + + +-- +-- Name: messaging_federation_call_sessions_user_call_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_call_sessions_user_call_unique ON public.messaging_federation_call_sessions USING btree (local_user_id, federated_call_id); + + +-- +-- Name: messaging_federation_discovered_peers_domain_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_discovered_peers_domain_unique ON public.messaging_federation_discovered_peers USING btree (domain); + + +-- +-- Name: messaging_federation_events_archive_origin_event_id_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_events_archive_origin_event_id_unique ON public.messaging_federation_events_archive USING btree (origin_domain, event_id); + + +-- +-- Name: messaging_federation_events_archive_origin_idempotency_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_events_archive_origin_idempotency_idx ON public.messaging_federation_events_archive USING btree (origin_domain, idempotency_key); + + +-- +-- Name: messaging_federation_events_archive_partition_month_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_events_archive_partition_month_idx ON public.messaging_federation_events_archive USING btree (partition_month); + + +-- +-- Name: messaging_federation_events_archive_stream_seq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_events_archive_stream_seq_idx ON public.messaging_federation_events_archive USING btree (origin_domain, stream_id, sequence); + + +-- +-- Name: messaging_federation_events_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_events_inserted_at_index ON public.messaging_federation_events USING btree (inserted_at); + + +-- +-- Name: messaging_federation_events_origin_domain_stream_id_sequence_in; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_events_origin_domain_stream_id_sequence_in ON public.messaging_federation_events USING btree (origin_domain, stream_id, sequence); + + +-- +-- Name: messaging_federation_events_origin_event_id_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_events_origin_event_id_unique ON public.messaging_federation_events USING btree (origin_domain, event_id); + + +-- +-- Name: messaging_federation_events_origin_idempotency_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_events_origin_idempotency_unique ON public.messaging_federation_events USING btree (origin_domain, idempotency_key); + + +-- +-- Name: messaging_federation_extension_events_conversation_type_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_extension_events_conversation_type_idx ON public.messaging_federation_extension_events USING btree (conversation_id, event_type); + + +-- +-- Name: messaging_federation_extension_events_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_extension_events_occurred_at_idx ON public.messaging_federation_extension_events USING btree (occurred_at); + + +-- +-- Name: messaging_federation_extension_events_server_type_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_extension_events_server_type_idx ON public.messaging_federation_extension_events USING btree (server_id, event_type); + + +-- +-- Name: messaging_federation_extension_events_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_extension_events_unique ON public.messaging_federation_extension_events USING btree (event_type, origin_domain, event_key); + + +-- +-- Name: messaging_federation_invite_states_origin_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_invite_states_origin_idx ON public.messaging_federation_invite_states USING btree (origin_domain); + + +-- +-- Name: messaging_federation_invite_states_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_invite_states_unique ON public.messaging_federation_invite_states USING btree (conversation_id, target_uri); + + +-- +-- Name: messaging_federation_membership_states_actor_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_membership_states_actor_idx ON public.messaging_federation_membership_states USING btree (remote_actor_id); + + +-- +-- Name: messaging_federation_membership_states_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_membership_states_unique ON public.messaging_federation_membership_states USING btree (conversation_id, remote_actor_id); + + +-- +-- Name: messaging_federation_outbox_event_id_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_outbox_event_id_unique ON public.messaging_federation_outbox_events USING btree (event_id); + + +-- +-- Name: messaging_federation_outbox_partition_month_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_outbox_partition_month_idx ON public.messaging_federation_outbox_events USING btree (partition_month); + + +-- +-- Name: messaging_federation_outbox_status_retry_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_outbox_status_retry_idx ON public.messaging_federation_outbox_events USING btree (status, next_retry_at); + + +-- +-- Name: messaging_federation_peer_policies_blocked_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_peer_policies_blocked_index ON public.messaging_federation_peer_policies USING btree (blocked); + + +-- +-- Name: messaging_federation_peer_policies_domain_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_peer_policies_domain_unique ON public.messaging_federation_peer_policies USING btree (domain); + + +-- +-- Name: messaging_federation_peer_policies_updated_by_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_peer_policies_updated_by_id_index ON public.messaging_federation_peer_policies USING btree (updated_by_id); + + +-- +-- Name: messaging_federation_presence_states_expires_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_presence_states_expires_at_idx ON public.messaging_federation_presence_states USING btree (expires_at_remote); + + +-- +-- Name: messaging_federation_presence_states_server_status_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_presence_states_server_status_idx ON public.messaging_federation_presence_states USING btree (server_id, status); + + +-- +-- Name: messaging_federation_presence_states_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_presence_states_unique ON public.messaging_federation_presence_states USING btree (server_id, remote_actor_id); + + +-- +-- Name: messaging_federation_presence_states_updated_at_remote_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_presence_states_updated_at_remote_idx ON public.messaging_federation_presence_states USING btree (updated_at_remote); + + +-- +-- Name: messaging_federation_read_cursors_actor_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_read_cursors_actor_idx ON public.messaging_federation_read_cursors USING btree (remote_actor_id); + + +-- +-- Name: messaging_federation_read_cursors_conversation_message_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_read_cursors_conversation_message_idx ON public.messaging_federation_read_cursors USING btree (conversation_id, chat_message_id); + + +-- +-- Name: messaging_federation_read_cursors_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_read_cursors_unique ON public.messaging_federation_read_cursors USING btree (conversation_id, remote_actor_id); + + +-- +-- Name: messaging_federation_read_receipts_actor_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_read_receipts_actor_idx ON public.messaging_federation_read_receipts USING btree (remote_actor_id); + + +-- +-- Name: messaging_federation_read_receipts_message_read_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_read_receipts_message_read_at_idx ON public.messaging_federation_read_receipts USING btree (chat_message_id, read_at); + + +-- +-- Name: messaging_federation_read_receipts_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_read_receipts_unique ON public.messaging_federation_read_receipts USING btree (chat_message_id, remote_actor_id); + + +-- +-- Name: messaging_federation_request_replays_expires_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_request_replays_expires_at_idx ON public.messaging_federation_request_replays USING btree (expires_at); + + +-- +-- Name: messaging_federation_request_replays_nonce_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_request_replays_nonce_unique ON public.messaging_federation_request_replays USING btree (nonce); + + +-- +-- Name: messaging_federation_request_replays_origin_inserted_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_request_replays_origin_inserted_idx ON public.messaging_federation_request_replays USING btree (origin_domain, inserted_at); + + +-- +-- Name: messaging_federation_room_presence_states_conversation_expires_; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_room_presence_states_conversation_expires_ ON public.messaging_federation_room_presence_states USING btree (conversation_id, expires_at_remote); + + +-- +-- Name: messaging_federation_room_presence_states_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_room_presence_states_unique ON public.messaging_federation_room_presence_states USING btree (conversation_id, remote_actor_id); + + +-- +-- Name: messaging_federation_room_presence_states_updated_at_remote_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_federation_room_presence_states_updated_at_remote_idx ON public.messaging_federation_room_presence_states USING btree (updated_at_remote); + + +-- +-- Name: messaging_federation_stream_counters_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_stream_counters_unique ON public.messaging_federation_stream_counters USING btree (stream_id); + + +-- +-- Name: messaging_federation_stream_positions_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_federation_stream_positions_unique ON public.messaging_federation_stream_positions USING btree (origin_domain, stream_id); + + +-- +-- Name: messaging_server_members_server_id_left_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_server_members_server_id_left_at_index ON public.messaging_server_members USING btree (server_id, left_at); + + +-- +-- Name: messaging_server_members_server_id_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_server_members_server_id_user_id_index ON public.messaging_server_members USING btree (server_id, user_id); + + +-- +-- Name: messaging_server_members_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_server_members_user_id_index ON public.messaging_server_members USING btree (user_id); + + +-- +-- Name: messaging_servers_creator_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_servers_creator_id_index ON public.messaging_servers USING btree (creator_id); + + +-- +-- Name: messaging_servers_federation_id_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX messaging_servers_federation_id_unique ON public.messaging_servers USING btree (federation_id) WHERE (federation_id IS NOT NULL); + + +-- +-- Name: messaging_servers_is_public_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_servers_is_public_index ON public.messaging_servers USING btree (is_public); + + +-- +-- Name: messaging_servers_member_count_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_servers_member_count_index ON public.messaging_servers USING btree (member_count); + + +-- +-- Name: messaging_servers_origin_domain_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX messaging_servers_origin_domain_index ON public.messaging_servers USING btree (origin_domain); + + +-- +-- Name: moderation_actions_action_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX moderation_actions_action_type_index ON public.moderation_actions USING btree (action_type); + + +-- +-- Name: moderation_actions_conversation_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX moderation_actions_conversation_id_index ON public.moderation_actions USING btree (conversation_id); + + +-- +-- Name: moderation_actions_moderator_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX moderation_actions_moderator_id_index ON public.moderation_actions USING btree (moderator_id); + + +-- +-- Name: moderation_actions_target_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX moderation_actions_target_message_id_index ON public.moderation_actions USING btree (target_message_id); + + +-- +-- Name: moderation_actions_target_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX moderation_actions_target_user_id_index ON public.moderation_actions USING btree (target_user_id); + + +-- +-- Name: moderator_notes_conversation_id_target_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX moderator_notes_conversation_id_target_user_id_index ON public.moderator_notes USING btree (conversation_id, target_user_id); + + +-- +-- Name: moderator_notes_created_by_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX moderator_notes_created_by_id_index ON public.moderator_notes USING btree (created_by_id); + + +-- +-- Name: moderator_notes_target_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX moderator_notes_target_user_id_index ON public.moderator_notes USING btree (target_user_id); + + +-- +-- Name: notifications_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX notifications_inserted_at_index ON public.notifications USING btree (inserted_at); + + +-- +-- Name: notifications_source_type_source_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX notifications_source_type_source_id_index ON public.notifications USING btree (source_type, source_id); + + +-- +-- Name: notifications_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX notifications_user_id_index ON public.notifications USING btree (user_id); + + +-- +-- Name: notifications_user_id_read_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX notifications_user_id_read_at_index ON public.notifications USING btree (user_id, read_at); + + +-- +-- Name: notifications_user_id_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX notifications_user_id_type_index ON public.notifications USING btree (user_id, type); + + +-- +-- Name: notifications_user_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX notifications_user_status_index ON public.notifications USING btree (user_id, read_at, dismissed_at); + + +-- +-- Name: oauth_apps_client_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX oauth_apps_client_id_index ON public.oauth_apps USING btree (client_id); + + +-- +-- Name: oauth_apps_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX oauth_apps_user_id_index ON public.oauth_apps USING btree (user_id); + + +-- +-- Name: oauth_authorizations_app_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX oauth_authorizations_app_id_index ON public.oauth_authorizations USING btree (app_id); + + +-- +-- Name: oauth_authorizations_token_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX oauth_authorizations_token_index ON public.oauth_authorizations USING btree (token); + + +-- +-- Name: oauth_authorizations_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX oauth_authorizations_user_id_index ON public.oauth_authorizations USING btree (user_id); + + +-- +-- Name: oauth_tokens_app_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX oauth_tokens_app_id_index ON public.oauth_tokens USING btree (app_id); + + +-- +-- Name: oauth_tokens_refresh_token_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX oauth_tokens_refresh_token_index ON public.oauth_tokens USING btree (refresh_token); + + +-- +-- Name: oauth_tokens_token_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX oauth_tokens_token_index ON public.oauth_tokens USING btree (token); + + +-- +-- Name: oauth_tokens_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX oauth_tokens_user_id_index ON public.oauth_tokens USING btree (user_id); + + +-- +-- Name: oauth_tokens_valid_until_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX oauth_tokens_valid_until_index ON public.oauth_tokens USING btree (valid_until); + + +-- +-- Name: oban_jobs_args_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX oban_jobs_args_index ON public.oban_jobs USING gin (args); + + +-- +-- Name: oban_jobs_email_inbound_idempotency_key_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX oban_jobs_email_inbound_idempotency_key_idx ON public.oban_jobs USING btree (worker, queue, ((args ->> 'idempotency_key'::text)), state) WHERE (queue = 'email_inbound'::text); + + +-- +-- Name: oban_jobs_meta_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX oban_jobs_meta_index ON public.oban_jobs USING gin (meta); + + +-- +-- Name: oban_jobs_state_queue_priority_scheduled_at_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX oban_jobs_state_queue_priority_scheduled_at_id_index ON public.oban_jobs USING btree (state, queue, priority, scheduled_at, id); + + +-- +-- Name: oidc_signing_keys_active_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX oidc_signing_keys_active_index ON public.oidc_signing_keys USING btree (active); + + +-- +-- Name: oidc_signing_keys_kid_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX oidc_signing_keys_kid_index ON public.oidc_signing_keys USING btree (kid); + + +-- +-- Name: passkey_credentials_credential_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX passkey_credentials_credential_id_index ON public.passkey_credentials USING btree (credential_id); + + +-- +-- Name: passkey_credentials_user_handle_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX passkey_credentials_user_handle_index ON public.passkey_credentials USING btree (user_handle); + + +-- +-- Name: passkey_credentials_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX passkey_credentials_user_id_index ON public.passkey_credentials USING btree (user_id); + + +-- +-- Name: nerve_entries_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX nerve_entries_user_id_index ON public.nerve_entries USING btree (user_id); + + +-- +-- Name: nerve_entries_user_id_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX nerve_entries_user_id_inserted_at_index ON public.nerve_entries USING btree (user_id, inserted_at); + + +-- +-- Name: nerve_settings_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX nerve_settings_user_id_index ON public.nerve_settings USING btree (user_id); + + +-- +-- Name: pgp_key_cache_email_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX pgp_key_cache_email_index ON public.pgp_key_cache USING btree (email); + + +-- +-- Name: pgp_key_cache_expires_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX pgp_key_cache_expires_at_index ON public.pgp_key_cache USING btree (expires_at); + + +-- +-- Name: platform_updates_created_by_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX platform_updates_created_by_id_index ON public.platform_updates USING btree (created_by_id); + + +-- +-- Name: platform_updates_published_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX platform_updates_published_inserted_at_index ON public.platform_updates USING btree (published, inserted_at); + + +-- +-- Name: poll_options_poll_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX poll_options_poll_id_index ON public.poll_options USING btree (poll_id); + + +-- +-- Name: poll_votes_option_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX poll_votes_option_id_index ON public.poll_votes USING btree (option_id); + + +-- +-- Name: poll_votes_poll_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX poll_votes_poll_id_index ON public.poll_votes USING btree (poll_id); + + +-- +-- Name: poll_votes_poll_id_user_id_option_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX poll_votes_poll_id_user_id_option_id_index ON public.poll_votes USING btree (poll_id, user_id, option_id); + + +-- +-- Name: poll_votes_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX poll_votes_user_id_index ON public.poll_votes USING btree (user_id); + + +-- +-- Name: polls_last_fetched_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX polls_last_fetched_at_index ON public.polls USING btree (last_fetched_at); + + +-- +-- Name: polls_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX polls_message_id_index ON public.polls USING btree (message_id); + + +-- +-- Name: polls_voter_uris_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX polls_voter_uris_index ON public.polls USING gin (voter_uris); + + +-- +-- Name: post_boosts_activitypub_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX post_boosts_activitypub_id_index ON public.post_boosts USING btree (activitypub_id); + + +-- +-- Name: post_boosts_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX post_boosts_message_id_index ON public.post_boosts USING btree (message_id); + + +-- +-- Name: post_boosts_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX post_boosts_user_id_index ON public.post_boosts USING btree (user_id); + + +-- +-- Name: post_boosts_user_id_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX post_boosts_user_id_message_id_index ON public.post_boosts USING btree (user_id, message_id); + + +-- +-- Name: post_dismissals_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX post_dismissals_message_id_index ON public.post_dismissals USING btree (message_id); + + +-- +-- Name: post_dismissals_user_id_dismissal_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX post_dismissals_user_id_dismissal_type_index ON public.post_dismissals USING btree (user_id, dismissal_type); + + +-- +-- Name: post_dismissals_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX post_dismissals_user_id_index ON public.post_dismissals USING btree (user_id); + + +-- +-- Name: post_dismissals_user_id_message_id_dismissal_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX post_dismissals_user_id_message_id_dismissal_type_index ON public.post_dismissals USING btree (user_id, message_id, dismissal_type); + + +-- +-- Name: post_hashtags_hashtag_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX post_hashtags_hashtag_id_index ON public.post_hashtags USING btree (hashtag_id); + + +-- +-- Name: post_hashtags_hashtag_message_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX post_hashtags_hashtag_message_idx ON public.post_hashtags USING btree (hashtag_id, message_id); + + +-- +-- Name: post_hashtags_message_id_hashtag_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX post_hashtags_message_id_hashtag_id_index ON public.post_hashtags USING btree (message_id, hashtag_id); + + +-- +-- Name: post_likes_by_message_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX post_likes_by_message_idx ON public.post_likes USING btree (message_id, created_at); + + +-- +-- Name: post_likes_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX post_likes_message_id_index ON public.post_likes USING btree (message_id); + + +-- +-- Name: post_likes_user_id_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX post_likes_user_id_message_id_index ON public.post_likes USING btree (user_id, message_id); + + +-- +-- Name: post_views_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX post_views_inserted_at_index ON public.post_views USING btree (inserted_at); + + +-- +-- Name: post_views_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX post_views_message_id_index ON public.post_views USING btree (message_id); + + +-- +-- Name: post_views_message_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX post_views_message_index ON public.post_views USING btree (message_id); + + +-- +-- Name: post_views_user_id_dwell_time_ms_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX post_views_user_id_dwell_time_ms_index ON public.post_views USING btree (user_id, dwell_time_ms); + + +-- +-- Name: post_views_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX post_views_user_id_index ON public.post_views USING btree (user_id); + + +-- +-- Name: post_views_user_id_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX post_views_user_id_message_id_index ON public.post_views USING btree (user_id, message_id); + + +-- +-- Name: post_views_user_message_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX post_views_user_message_index ON public.post_views USING btree (user_id, message_id); + + +-- +-- Name: profile_custom_domains_domain_ci_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX profile_custom_domains_domain_ci_unique ON public.profile_custom_domains USING btree (lower((domain)::text)); + + +-- +-- Name: profile_custom_domains_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_custom_domains_status_index ON public.profile_custom_domains USING btree (status); + + +-- +-- Name: profile_custom_domains_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_custom_domains_user_id_index ON public.profile_custom_domains USING btree (user_id); + + +-- +-- Name: profile_links_is_active_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_links_is_active_index ON public.profile_links USING btree (is_active); + + +-- +-- Name: profile_links_position_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_links_position_index ON public.profile_links USING btree ("position"); + + +-- +-- Name: profile_links_profile_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_links_profile_id_index ON public.profile_links USING btree (profile_id); + + +-- +-- Name: profile_site_visits_profile_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_site_visits_profile_user_id_index ON public.profile_site_visits USING btree (profile_user_id); + + +-- +-- Name: profile_site_visits_profile_user_id_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_site_visits_profile_user_id_inserted_at_index ON public.profile_site_visits USING btree (profile_user_id, inserted_at); + + +-- +-- Name: profile_site_visits_profile_user_id_request_host_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_site_visits_profile_user_id_request_host_index ON public.profile_site_visits USING btree (profile_user_id, request_host); + + +-- +-- Name: profile_site_visits_profile_user_id_request_host_inserted_at_in; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_site_visits_profile_user_id_request_host_inserted_at_in ON public.profile_site_visits USING btree (profile_user_id, request_host, inserted_at); + + +-- +-- Name: profile_views_profile_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_views_profile_user_id_index ON public.profile_views USING btree (profile_user_id); + + +-- +-- Name: profile_views_profile_user_id_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_views_profile_user_id_inserted_at_index ON public.profile_views USING btree (profile_user_id, inserted_at); + + +-- +-- Name: profile_views_profile_user_id_viewer_session_id_inserted_at_ind; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_views_profile_user_id_viewer_session_id_inserted_at_ind ON public.profile_views USING btree (profile_user_id, viewer_session_id, inserted_at); + + +-- +-- Name: profile_views_profile_user_id_viewer_user_id_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_views_profile_user_id_viewer_user_id_inserted_at_index ON public.profile_views USING btree (profile_user_id, viewer_user_id, inserted_at); + + +-- +-- Name: profile_views_viewer_session_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_views_viewer_session_id_index ON public.profile_views USING btree (viewer_session_id); + + +-- +-- Name: profile_views_viewer_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_views_viewer_user_id_index ON public.profile_views USING btree (viewer_user_id); + + +-- +-- Name: profile_widgets_position_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_widgets_position_index ON public.profile_widgets USING btree ("position"); + + +-- +-- Name: profile_widgets_profile_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_widgets_profile_id_index ON public.profile_widgets USING btree (profile_id); + + +-- +-- Name: profile_widgets_widget_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_widgets_widget_type_index ON public.profile_widgets USING btree (widget_type); + + +-- +-- Name: registration_checkouts_lookup_token_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX registration_checkouts_lookup_token_index ON public.registration_checkouts USING btree (lookup_token); + + +-- +-- Name: registration_checkouts_product_slug_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX registration_checkouts_product_slug_index ON public.registration_checkouts USING btree (product_slug); + + +-- +-- Name: registration_checkouts_redeemed_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX registration_checkouts_redeemed_at_index ON public.registration_checkouts USING btree (redeemed_at); + + +-- +-- Name: registration_checkouts_redeemed_by_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX registration_checkouts_redeemed_by_user_id_index ON public.registration_checkouts USING btree (redeemed_by_user_id); + + +-- +-- Name: registration_checkouts_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX registration_checkouts_status_index ON public.registration_checkouts USING btree (status); + + +-- +-- Name: registration_checkouts_stripe_checkout_session_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX registration_checkouts_stripe_checkout_session_id_index ON public.registration_checkouts USING btree (stripe_checkout_session_id); + + +-- +-- Name: remote_interactions_message_id_actor_uri_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX remote_interactions_message_id_actor_uri_index ON public.remote_interactions USING btree (message_id, actor_uri); + + +-- +-- Name: remote_interactions_message_id_emoji_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX remote_interactions_message_id_emoji_index ON public.remote_interactions USING btree (message_id, emoji) WHERE ((interaction_type)::text = 'emoji_react'::text); + + +-- +-- Name: remote_interactions_message_id_interaction_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX remote_interactions_message_id_interaction_type_index ON public.remote_interactions USING btree (message_id, interaction_type); + + +-- +-- Name: remote_interactions_unique_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX remote_interactions_unique_index ON public.remote_interactions USING btree (message_id, actor_uri, interaction_type, emoji); + + +-- +-- Name: reports_priority_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX reports_priority_index ON public.reports USING btree (priority); + + +-- +-- Name: reports_reportable_type_reportable_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX reports_reportable_type_reportable_id_index ON public.reports USING btree (reportable_type, reportable_id); + + +-- +-- Name: reports_reporter_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX reports_reporter_id_index ON public.reports USING btree (reporter_id); + + +-- +-- Name: reports_reviewed_by_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX reports_reviewed_by_id_index ON public.reports USING btree (reviewed_by_id); + + +-- +-- Name: reports_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX reports_status_index ON public.reports USING btree (status); + + +-- +-- Name: reports_unique_pending_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX reports_unique_pending_index ON public.reports USING btree (reporter_id, reportable_type, reportable_id, status) WHERE ((status)::text = 'pending'::text); + + +-- +-- Name: rss_feeds_last_fetched_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX rss_feeds_last_fetched_at_index ON public.rss_feeds USING btree (last_fetched_at); + + +-- +-- Name: rss_feeds_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX rss_feeds_status_index ON public.rss_feeds USING btree (status); + + +-- +-- Name: rss_feeds_url_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX rss_feeds_url_index ON public.rss_feeds USING btree (url); + + +-- +-- Name: rss_items_feed_id_guid_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX rss_items_feed_id_guid_index ON public.rss_items USING btree (feed_id, guid); + + +-- +-- Name: rss_items_feed_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX rss_items_feed_id_index ON public.rss_items USING btree (feed_id); + + +-- +-- Name: rss_items_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX rss_items_inserted_at_index ON public.rss_items USING btree (inserted_at); + + +-- +-- Name: rss_items_published_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX rss_items_published_at_index ON public.rss_items USING btree (published_at); + + +-- +-- Name: rss_subscriptions_feed_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX rss_subscriptions_feed_id_index ON public.rss_subscriptions USING btree (feed_id); + + +-- +-- Name: rss_subscriptions_user_id_feed_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX rss_subscriptions_user_id_feed_id_index ON public.rss_subscriptions USING btree (user_id, feed_id); + + +-- +-- Name: rss_subscriptions_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX rss_subscriptions_user_id_index ON public.rss_subscriptions USING btree (user_id); + + +-- +-- Name: saved_items_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX saved_items_inserted_at_index ON public.saved_items USING btree (inserted_at); + + +-- +-- Name: saved_items_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX saved_items_user_id_index ON public.saved_items USING btree (user_id); + + +-- +-- Name: saved_items_user_message_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX saved_items_user_message_unique ON public.saved_items USING btree (user_id, message_id) WHERE (message_id IS NOT NULL); + + +-- +-- Name: saved_items_user_rss_item_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX saved_items_user_rss_item_unique ON public.saved_items USING btree (user_id, rss_item_id) WHERE (rss_item_id IS NOT NULL); + + +-- +-- Name: signing_keys_remote_actor_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX signing_keys_remote_actor_id_index ON public.signing_keys USING btree (remote_actor_id) WHERE (remote_actor_id IS NOT NULL); + + +-- +-- Name: signing_keys_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX signing_keys_user_id_index ON public.signing_keys USING btree (user_id) WHERE (user_id IS NOT NULL); + + +-- +-- Name: static_site_files_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX static_site_files_user_id_index ON public.static_site_files USING btree (user_id); + + +-- +-- Name: static_site_files_user_id_path_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX static_site_files_user_id_path_index ON public.static_site_files USING btree (user_id, path); + + +-- +-- Name: stored_files_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX stored_files_user_id_index ON public.stored_files USING btree (user_id); + + +-- +-- Name: stored_files_user_id_path_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX stored_files_user_id_path_index ON public.stored_files USING btree (user_id, path); + + +-- +-- Name: stored_folders_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX stored_folders_user_id_index ON public.stored_folders USING btree (user_id); + + +-- +-- Name: stored_folders_user_id_path_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX stored_folders_user_id_path_index ON public.stored_folders USING btree (user_id, path); + + +-- +-- Name: subscription_products_active_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX subscription_products_active_index ON public.subscription_products USING btree (active); + + +-- +-- Name: subscription_products_billing_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX subscription_products_billing_type_index ON public.subscription_products USING btree (billing_type); + + +-- +-- Name: subscription_products_slug_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX subscription_products_slug_index ON public.subscription_products USING btree (slug); + + +-- +-- Name: subscriptions_product_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX subscriptions_product_index ON public.subscriptions USING btree (product); + + +-- +-- Name: subscriptions_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX subscriptions_status_index ON public.subscriptions USING btree (status); + + +-- +-- Name: subscriptions_stripe_customer_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX subscriptions_stripe_customer_id_index ON public.subscriptions USING btree (stripe_customer_id); + + +-- +-- Name: subscriptions_stripe_subscription_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX subscriptions_stripe_subscription_id_index ON public.subscriptions USING btree (stripe_subscription_id) WHERE (stripe_subscription_id IS NOT NULL); + + +-- +-- Name: subscriptions_user_id_product_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX subscriptions_user_id_product_index ON public.subscriptions USING btree (user_id, product); + + +-- +-- Name: system_config_key_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX system_config_key_index ON public.system_config USING btree (key); + + +-- +-- Name: trust_level_logs_inserted_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX trust_level_logs_inserted_at_index ON public.trust_level_logs USING btree (inserted_at); + + +-- +-- Name: trust_level_logs_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX trust_level_logs_user_id_index ON public.trust_level_logs USING btree (user_id); + + +-- +-- Name: trusted_devices_device_token_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX trusted_devices_device_token_index ON public.trusted_devices USING btree (device_token); + + +-- +-- Name: trusted_devices_expires_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX trusted_devices_expires_at_index ON public.trusted_devices USING btree (expires_at); + + +-- +-- Name: trusted_devices_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX trusted_devices_user_id_index ON public.trusted_devices USING btree (user_id); + + +-- +-- Name: user_activity_stats_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX user_activity_stats_user_id_index ON public.user_activity_stats USING btree (user_id); + + +-- +-- Name: user_badges_badge_type_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_badges_badge_type_index ON public.user_badges USING btree (badge_type); + + +-- +-- Name: user_badges_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_badges_user_id_index ON public.user_badges USING btree (user_id); + + +-- +-- Name: user_blocks_blocked_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_blocks_blocked_id_index ON public.user_blocks USING btree (blocked_id); + + +-- +-- Name: user_blocks_blocker_id_blocked_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX user_blocks_blocker_id_blocked_id_index ON public.user_blocks USING btree (blocker_id, blocked_id); + + +-- +-- Name: user_blocks_blocker_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_blocks_blocker_id_index ON public.user_blocks USING btree (blocker_id); + + +-- +-- Name: user_hidden_messages_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_hidden_messages_message_id_index ON public.user_hidden_messages USING btree (message_id); + + +-- +-- Name: user_hidden_messages_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_hidden_messages_user_id_index ON public.user_hidden_messages USING btree (user_id); + + +-- +-- Name: user_hidden_messages_user_id_message_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX user_hidden_messages_user_id_message_id_index ON public.user_hidden_messages USING btree (user_id, message_id); + + +-- +-- Name: user_integrations_provider_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_integrations_provider_index ON public.user_integrations USING btree (provider); + + +-- +-- Name: user_integrations_user_id_provider_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX user_integrations_user_id_provider_index ON public.user_integrations USING btree (user_id, provider); + + +-- +-- Name: user_mutes_muted_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_mutes_muted_id_index ON public.user_mutes USING btree (muted_id); + + +-- +-- Name: user_mutes_muter_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_mutes_muter_id_index ON public.user_mutes USING btree (muter_id); + + +-- +-- Name: user_mutes_muter_id_muted_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX user_mutes_muter_id_muted_id_index ON public.user_mutes USING btree (muter_id, muted_id); + + +-- +-- Name: user_post_timestamps_conversation_id_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX user_post_timestamps_conversation_id_user_id_index ON public.user_post_timestamps USING btree (conversation_id, user_id); + + +-- +-- Name: user_post_timestamps_last_post_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_post_timestamps_last_post_at_index ON public.user_post_timestamps USING btree (last_post_at); + + +-- +-- Name: user_profiles_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX user_profiles_user_id_index ON public.user_profiles USING btree (user_id); + + +-- +-- Name: user_timeouts_conversation_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_timeouts_conversation_id_index ON public.user_timeouts USING btree (conversation_id); + + +-- +-- Name: user_timeouts_timeout_until_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_timeouts_timeout_until_index ON public.user_timeouts USING btree (timeout_until); + + +-- +-- Name: user_timeouts_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_timeouts_user_id_index ON public.user_timeouts USING btree (user_id); + + +-- +-- Name: user_warnings_conversation_id_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_warnings_conversation_id_user_id_index ON public.user_warnings USING btree (conversation_id, user_id); + + +-- +-- Name: user_warnings_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_warnings_user_id_index ON public.user_warnings USING btree (user_id); + + +-- +-- Name: user_warnings_warned_by_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_warnings_warned_by_id_index ON public.user_warnings USING btree (warned_by_id); + + +-- +-- Name: username_history_user_id_changed_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX username_history_user_id_changed_at_index ON public.username_history USING btree (user_id, changed_at); + + +-- +-- Name: username_history_username_changed_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX username_history_username_changed_at_index ON public.username_history USING btree (username, changed_at); + + +-- +-- Name: username_history_username_user_id_changed_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX username_history_username_user_id_changed_at_index ON public.username_history USING btree (username, user_id, changed_at); + + +-- +-- Name: users_bluesky_did_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_bluesky_did_index ON public.users USING btree (bluesky_did); + + +-- +-- Name: users_bluesky_enabled_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_bluesky_enabled_index ON public.users USING btree (bluesky_enabled); + + +-- +-- Name: users_bluesky_identifier_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_bluesky_identifier_index ON public.users USING btree (bluesky_identifier); + + +-- +-- Name: users_email_sending_restricted_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_email_sending_restricted_index ON public.users USING btree (email_sending_restricted) WHERE (email_sending_restricted = true); + + +-- +-- Name: users_handle_ci_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX users_handle_ci_unique ON public.users USING btree (lower((handle)::text)); + + +-- +-- Name: users_handle_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX users_handle_index ON public.users USING btree (handle); + + +-- +-- Name: users_last_imap_access_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_last_imap_access_index ON public.users USING btree (last_imap_access); + + +-- +-- Name: users_last_login_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_last_login_at_index ON public.users USING btree (last_login_at); + + +-- +-- Name: users_last_login_ip_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_last_login_ip_index ON public.users USING btree (last_login_ip); + + +-- +-- Name: users_last_password_change_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_last_password_change_index ON public.users USING btree (last_password_change); + + +-- +-- Name: users_last_pop3_access_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_last_pop3_access_index ON public.users USING btree (last_pop3_access); + + +-- +-- Name: users_last_seen_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_last_seen_at_index ON public.users USING btree (last_seen_at); + + +-- +-- Name: users_onboarding_completed_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_onboarding_completed_index ON public.users USING btree (onboarding_completed); + + +-- +-- Name: users_password_reset_token_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX users_password_reset_token_index ON public.users USING btree (password_reset_token); + + +-- +-- Name: users_pgp_fingerprint_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_pgp_fingerprint_index ON public.users USING btree (pgp_fingerprint); + + +-- +-- Name: users_pgp_wkd_hash_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_pgp_wkd_hash_index ON public.users USING btree (pgp_wkd_hash) WHERE (pgp_wkd_hash IS NOT NULL); + + +-- +-- Name: users_registered_via_onion_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_registered_via_onion_index ON public.users USING btree (registered_via_onion); + + +-- +-- Name: users_registration_ip_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_registration_ip_index ON public.users USING btree (registration_ip); + + +-- +-- Name: users_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_status_index ON public.users USING btree (status); + + +-- +-- Name: users_storage_used_bytes_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_storage_used_bytes_index ON public.users USING btree (storage_used_bytes); + + +-- +-- Name: users_stripe_customer_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX users_stripe_customer_id_index ON public.users USING btree (stripe_customer_id) WHERE (stripe_customer_id IS NOT NULL); + + +-- +-- Name: users_suspended_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_suspended_index ON public.users USING btree (suspended); + + +-- +-- Name: users_suspended_until_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_suspended_until_index ON public.users USING btree (suspended_until); + + +-- +-- Name: users_unique_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_unique_id_index ON public.users USING btree (unique_id); + + +-- +-- Name: users_username_ci_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX users_username_ci_unique ON public.users USING btree (lower((username)::text)); + + +-- +-- Name: vpn_connection_logs_connected_at_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX vpn_connection_logs_connected_at_index ON public.vpn_connection_logs USING btree (connected_at); + + +-- +-- Name: vpn_connection_logs_vpn_user_config_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX vpn_connection_logs_vpn_user_config_id_index ON public.vpn_connection_logs USING btree (vpn_user_config_id); + + +-- +-- Name: vpn_servers_country_code_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX vpn_servers_country_code_index ON public.vpn_servers USING btree (country_code); + + +-- +-- Name: vpn_servers_minimum_trust_level_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX vpn_servers_minimum_trust_level_index ON public.vpn_servers USING btree (minimum_trust_level); + + +-- +-- Name: vpn_servers_public_ip_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX vpn_servers_public_ip_index ON public.vpn_servers USING btree (public_ip); + + +-- +-- Name: vpn_servers_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX vpn_servers_status_index ON public.vpn_servers USING btree (status); + + +-- +-- Name: vpn_user_configs_public_key_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX vpn_user_configs_public_key_index ON public.vpn_user_configs USING btree (public_key); + + +-- +-- Name: vpn_user_configs_status_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX vpn_user_configs_status_index ON public.vpn_user_configs USING btree (status); + + +-- +-- Name: vpn_user_configs_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX vpn_user_configs_user_id_index ON public.vpn_user_configs USING btree (user_id); + + +-- +-- Name: vpn_user_configs_user_id_vpn_server_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX vpn_user_configs_user_id_vpn_server_id_index ON public.vpn_user_configs USING btree (user_id, vpn_server_id); + + +-- +-- Name: vpn_user_configs_vpn_server_id_allocated_ip_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX vpn_user_configs_vpn_server_id_allocated_ip_index ON public.vpn_user_configs USING btree (vpn_server_id, allocated_ip); + + +-- +-- Name: vpn_user_configs_vpn_server_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX vpn_user_configs_vpn_server_id_index ON public.vpn_user_configs USING btree (vpn_server_id); + + +-- +-- Name: account_deletion_requests account_deletion_requests_reviewed_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.account_deletion_requests + ADD CONSTRAINT account_deletion_requests_reviewed_by_id_fkey FOREIGN KEY (reviewed_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: account_deletion_requests account_deletion_requests_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.account_deletion_requests + ADD CONSTRAINT account_deletion_requests_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: activitypub_activities activitypub_activities_internal_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_activities + ADD CONSTRAINT activitypub_activities_internal_message_id_fkey FOREIGN KEY (internal_message_id) REFERENCES public.messages(id) ON DELETE CASCADE; + + +-- +-- Name: activitypub_activities activitypub_activities_internal_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_activities + ADD CONSTRAINT activitypub_activities_internal_user_id_fkey FOREIGN KEY (internal_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: activitypub_actors activitypub_actors_community_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_actors + ADD CONSTRAINT activitypub_actors_community_id_fkey FOREIGN KEY (community_id) REFERENCES public.conversations(id) ON DELETE CASCADE; + + +-- +-- Name: activitypub_deliveries activitypub_deliveries_activity_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_deliveries + ADD CONSTRAINT activitypub_deliveries_activity_id_fkey FOREIGN KEY (activity_id) REFERENCES public.activitypub_activities(id) ON DELETE CASCADE; + + +-- +-- Name: activitypub_instances activitypub_instances_blocked_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_instances + ADD CONSTRAINT activitypub_instances_blocked_by_id_fkey FOREIGN KEY (blocked_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: activitypub_instances activitypub_instances_policy_applied_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_instances + ADD CONSTRAINT activitypub_instances_policy_applied_by_id_fkey FOREIGN KEY (policy_applied_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: activitypub_relay_subscriptions activitypub_relay_subscriptions_subscribed_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_relay_subscriptions + ADD CONSTRAINT activitypub_relay_subscriptions_subscribed_by_id_fkey FOREIGN KEY (subscribed_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: activitypub_user_blocks activitypub_user_blocks_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activitypub_user_blocks + ADD CONSTRAINT activitypub_user_blocks_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: announcement_dismissals announcement_dismissals_announcement_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.announcement_dismissals + ADD CONSTRAINT announcement_dismissals_announcement_id_fkey FOREIGN KEY (announcement_id) REFERENCES public.announcements(id) ON DELETE CASCADE; + + +-- +-- Name: announcement_dismissals announcement_dismissals_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.announcement_dismissals + ADD CONSTRAINT announcement_dismissals_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: announcements announcements_created_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.announcements + ADD CONSTRAINT announcements_created_by_id_fkey FOREIGN KEY (created_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: api_tokens api_tokens_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_tokens + ADD CONSTRAINT api_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: app_passwords app_passwords_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.app_passwords + ADD CONSTRAINT app_passwords_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: audit_logs audit_logs_admin_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_logs + ADD CONSTRAINT audit_logs_admin_id_fkey FOREIGN KEY (admin_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: audit_logs audit_logs_target_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_logs + ADD CONSTRAINT audit_logs_target_user_id_fkey FOREIGN KEY (target_user_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: auto_mod_rules auto_mod_rules_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.auto_mod_rules + ADD CONSTRAINT auto_mod_rules_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.conversations(id) ON DELETE CASCADE; + + +-- +-- Name: auto_mod_rules auto_mod_rules_created_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.auto_mod_rules + ADD CONSTRAINT auto_mod_rules_created_by_id_fkey FOREIGN KEY (created_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: bluesky_inbound_events bluesky_inbound_events_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bluesky_inbound_events + ADD CONSTRAINT bluesky_inbound_events_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: calendar_events calendar_events_calendar_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.calendar_events + ADD CONSTRAINT calendar_events_calendar_id_fkey FOREIGN KEY (calendar_id) REFERENCES public.calendars(id) ON DELETE CASCADE; + + +-- +-- Name: calendars calendars_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.calendars + ADD CONSTRAINT calendars_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: calls calls_callee_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.calls + ADD CONSTRAINT calls_callee_id_fkey FOREIGN KEY (callee_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: calls calls_caller_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.calls + ADD CONSTRAINT calls_caller_id_fkey FOREIGN KEY (caller_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: calls calls_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.calls + ADD CONSTRAINT calls_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.chat_conversations(id) ON DELETE CASCADE; + + +-- +-- Name: chat_conversation_members chat_conversation_members_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_conversation_members + ADD CONSTRAINT chat_conversation_members_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.chat_conversations(id) ON DELETE CASCADE; + + +-- +-- Name: chat_conversation_members chat_conversation_members_last_read_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_conversation_members + ADD CONSTRAINT chat_conversation_members_last_read_message_id_fkey FOREIGN KEY (last_read_message_id) REFERENCES public.chat_messages(id) ON DELETE SET NULL; + + +-- +-- Name: chat_conversation_members chat_conversation_members_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_conversation_members + ADD CONSTRAINT chat_conversation_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: chat_conversations chat_conversations_creator_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_conversations + ADD CONSTRAINT chat_conversations_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: chat_conversations chat_conversations_remote_group_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_conversations + ADD CONSTRAINT chat_conversations_remote_group_actor_id_fkey FOREIGN KEY (remote_group_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE SET NULL; + + +-- +-- Name: chat_conversations chat_conversations_server_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_conversations + ADD CONSTRAINT chat_conversations_server_id_fkey FOREIGN KEY (server_id) REFERENCES public.messaging_servers(id) ON DELETE CASCADE; + + +-- +-- Name: chat_message_reactions chat_message_reactions_chat_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_message_reactions + ADD CONSTRAINT chat_message_reactions_chat_message_id_fkey FOREIGN KEY (chat_message_id) REFERENCES public.chat_messages(id) ON DELETE CASCADE; + + +-- +-- Name: chat_message_reactions chat_message_reactions_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_message_reactions + ADD CONSTRAINT chat_message_reactions_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: chat_message_reactions chat_message_reactions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_message_reactions + ADD CONSTRAINT chat_message_reactions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: chat_message_reads chat_message_reads_chat_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_message_reads + ADD CONSTRAINT chat_message_reads_chat_message_id_fkey FOREIGN KEY (chat_message_id) REFERENCES public.chat_messages(id) ON DELETE CASCADE; + + +-- +-- Name: chat_message_reads chat_message_reads_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_message_reads + ADD CONSTRAINT chat_message_reads_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: chat_messages chat_messages_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_messages + ADD CONSTRAINT chat_messages_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.chat_conversations(id) ON DELETE CASCADE; + + +-- +-- Name: chat_messages chat_messages_link_preview_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_messages + ADD CONSTRAINT chat_messages_link_preview_id_fkey FOREIGN KEY (link_preview_id) REFERENCES public.link_previews(id) ON DELETE SET NULL; + + +-- +-- Name: chat_messages chat_messages_reply_to_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_messages + ADD CONSTRAINT chat_messages_reply_to_id_fkey FOREIGN KEY (reply_to_id) REFERENCES public.chat_messages(id) ON DELETE SET NULL; + + +-- +-- Name: chat_messages chat_messages_sender_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_messages + ADD CONSTRAINT chat_messages_sender_id_fkey FOREIGN KEY (sender_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: chat_moderation_actions chat_moderation_actions_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_moderation_actions + ADD CONSTRAINT chat_moderation_actions_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.chat_conversations(id) ON DELETE CASCADE; + + +-- +-- Name: chat_moderation_actions chat_moderation_actions_moderator_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_moderation_actions + ADD CONSTRAINT chat_moderation_actions_moderator_id_fkey FOREIGN KEY (moderator_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: chat_moderation_actions chat_moderation_actions_target_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_moderation_actions + ADD CONSTRAINT chat_moderation_actions_target_user_id_fkey FOREIGN KEY (target_user_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: chat_user_hidden_messages chat_user_hidden_messages_chat_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_user_hidden_messages + ADD CONSTRAINT chat_user_hidden_messages_chat_message_id_fkey FOREIGN KEY (chat_message_id) REFERENCES public.chat_messages(id) ON DELETE CASCADE; + + +-- +-- Name: chat_user_hidden_messages chat_user_hidden_messages_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_user_hidden_messages + ADD CONSTRAINT chat_user_hidden_messages_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: chat_user_timeouts chat_user_timeouts_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_user_timeouts + ADD CONSTRAINT chat_user_timeouts_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.chat_conversations(id) ON DELETE CASCADE; + + +-- +-- Name: chat_user_timeouts chat_user_timeouts_created_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_user_timeouts + ADD CONSTRAINT chat_user_timeouts_created_by_id_fkey FOREIGN KEY (created_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: chat_user_timeouts chat_user_timeouts_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_user_timeouts + ADD CONSTRAINT chat_user_timeouts_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: community_bans community_bans_banned_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.community_bans + ADD CONSTRAINT community_bans_banned_by_id_fkey FOREIGN KEY (banned_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: community_bans community_bans_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.community_bans + ADD CONSTRAINT community_bans_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.conversations(id) ON DELETE CASCADE; + + +-- +-- Name: community_bans community_bans_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.community_bans + ADD CONSTRAINT community_bans_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: community_flairs community_flairs_community_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.community_flairs + ADD CONSTRAINT community_flairs_community_id_fkey FOREIGN KEY (community_id) REFERENCES public.conversations(id) ON DELETE CASCADE; + + +-- +-- Name: contact_groups contact_groups_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contact_groups + ADD CONSTRAINT contact_groups_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: contacts contacts_group_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contacts + ADD CONSTRAINT contacts_group_id_fkey FOREIGN KEY (group_id) REFERENCES public.contact_groups(id) ON DELETE SET NULL; + + +-- +-- Name: contacts contacts_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contacts + ADD CONSTRAINT contacts_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: conversation_members conversation_members_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.conversation_members + ADD CONSTRAINT conversation_members_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.conversations(id) ON DELETE CASCADE; + + +-- +-- Name: conversation_members conversation_members_last_read_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.conversation_members + ADD CONSTRAINT conversation_members_last_read_message_id_fkey FOREIGN KEY (last_read_message_id) REFERENCES public.messages(id) ON DELETE SET NULL; + + +-- +-- Name: conversation_members conversation_members_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.conversation_members + ADD CONSTRAINT conversation_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: conversations conversations_creator_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.conversations + ADD CONSTRAINT conversations_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: conversations conversations_remote_group_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.conversations + ADD CONSTRAINT conversations_remote_group_actor_id_fkey FOREIGN KEY (remote_group_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE SET NULL; + + +-- +-- Name: conversations conversations_server_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.conversations + ADD CONSTRAINT conversations_server_id_fkey FOREIGN KEY (server_id) REFERENCES public.messaging_servers(id) ON DELETE CASCADE; + + +-- +-- Name: creator_satisfaction creator_satisfaction_creator_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.creator_satisfaction + ADD CONSTRAINT creator_satisfaction_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: creator_satisfaction creator_satisfaction_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.creator_satisfaction + ADD CONSTRAINT creator_satisfaction_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: creator_satisfaction creator_satisfaction_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.creator_satisfaction + ADD CONSTRAINT creator_satisfaction_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: data_exports data_exports_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.data_exports + ADD CONSTRAINT data_exports_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: developer_webhook_deliveries developer_webhook_deliveries_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.developer_webhook_deliveries + ADD CONSTRAINT developer_webhook_deliveries_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: developer_webhook_deliveries developer_webhook_deliveries_webhook_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.developer_webhook_deliveries + ADD CONSTRAINT developer_webhook_deliveries_webhook_id_fkey FOREIGN KEY (webhook_id) REFERENCES public.developer_webhooks(id) ON DELETE CASCADE; + + +-- +-- Name: developer_webhooks developer_webhooks_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.developer_webhooks + ADD CONSTRAINT developer_webhooks_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: device_tokens device_tokens_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.device_tokens + ADD CONSTRAINT device_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: dns_query_stats dns_query_stats_zone_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dns_query_stats + ADD CONSTRAINT dns_query_stats_zone_id_fkey FOREIGN KEY (zone_id) REFERENCES public.dns_zones(id) ON DELETE CASCADE; + + +-- +-- Name: dns_records dns_records_zone_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dns_records + ADD CONSTRAINT dns_records_zone_id_fkey FOREIGN KEY (zone_id) REFERENCES public.dns_zones(id) ON DELETE CASCADE; + + +-- +-- Name: dns_zone_service_configs dns_zone_service_configs_zone_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dns_zone_service_configs + ADD CONSTRAINT dns_zone_service_configs_zone_id_fkey FOREIGN KEY (zone_id) REFERENCES public.dns_zones(id) ON DELETE CASCADE; + + +-- +-- Name: dns_zones dns_zones_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dns_zones + ADD CONSTRAINT dns_zones_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: email_aliases email_aliases_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_aliases + ADD CONSTRAINT email_aliases_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: email_auto_replies email_auto_replies_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_auto_replies + ADD CONSTRAINT email_auto_replies_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: email_auto_reply_log email_auto_reply_log_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_auto_reply_log + ADD CONSTRAINT email_auto_reply_log_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: email_blocked_senders email_blocked_senders_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_blocked_senders + ADD CONSTRAINT email_blocked_senders_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: email_category_preferences email_category_preferences_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_category_preferences + ADD CONSTRAINT email_category_preferences_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: email_custom_domains email_custom_domains_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_custom_domains + ADD CONSTRAINT email_custom_domains_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: email_exports email_exports_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_exports + ADD CONSTRAINT email_exports_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: email_filters email_filters_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_filters + ADD CONSTRAINT email_filters_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: email_folders email_folders_parent_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_folders + ADD CONSTRAINT email_folders_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES public.email_folders(id) ON DELETE SET NULL; + + +-- +-- Name: email_folders email_folders_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_folders + ADD CONSTRAINT email_folders_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: email_jobs email_jobs_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_jobs + ADD CONSTRAINT email_jobs_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: email_labels email_labels_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_labels + ADD CONSTRAINT email_labels_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: email_message_labels email_message_labels_label_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_message_labels + ADD CONSTRAINT email_message_labels_label_id_fkey FOREIGN KEY (label_id) REFERENCES public.email_labels(id) ON DELETE CASCADE; + + +-- +-- Name: email_message_labels email_message_labels_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_message_labels + ADD CONSTRAINT email_message_labels_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.email_messages(id) ON DELETE CASCADE; + + +-- +-- Name: email_messages email_messages_folder_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_messages + ADD CONSTRAINT email_messages_folder_id_fkey FOREIGN KEY (folder_id) REFERENCES public.email_folders(id) ON DELETE SET NULL; + + +-- +-- Name: email_messages email_messages_thread_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_messages + ADD CONSTRAINT email_messages_thread_id_fkey FOREIGN KEY (thread_id) REFERENCES public.email_threads(id) ON DELETE SET NULL; + + +-- +-- Name: email_safe_senders email_safe_senders_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_safe_senders + ADD CONSTRAINT email_safe_senders_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: email_submissions email_submissions_email_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_submissions + ADD CONSTRAINT email_submissions_email_id_fkey FOREIGN KEY (email_id) REFERENCES public.email_messages(id) ON DELETE SET NULL; + + +-- +-- Name: email_submissions email_submissions_mailbox_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_submissions + ADD CONSTRAINT email_submissions_mailbox_id_fkey FOREIGN KEY (mailbox_id) REFERENCES public.mailboxes(id) ON DELETE CASCADE; + + +-- +-- Name: email_suppressions email_suppressions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_suppressions + ADD CONSTRAINT email_suppressions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: email_templates email_templates_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_templates + ADD CONSTRAINT email_templates_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: email_threads email_threads_mailbox_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_threads + ADD CONSTRAINT email_threads_mailbox_id_fkey FOREIGN KEY (mailbox_id) REFERENCES public.mailboxes(id) ON DELETE CASCADE; + + +-- +-- Name: email_unsubscribes email_unsubscribes_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.email_unsubscribes + ADD CONSTRAINT email_unsubscribes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: federated_boosts federated_boosts_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.federated_boosts + ADD CONSTRAINT federated_boosts_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.messages(id) ON DELETE CASCADE; + + +-- +-- Name: federated_boosts federated_boosts_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.federated_boosts + ADD CONSTRAINT federated_boosts_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: federated_dislikes federated_dislikes_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.federated_dislikes + ADD CONSTRAINT federated_dislikes_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.messages(id) ON DELETE CASCADE; + + +-- +-- Name: federated_dislikes federated_dislikes_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.federated_dislikes + ADD CONSTRAINT federated_dislikes_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: federated_likes federated_likes_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.federated_likes + ADD CONSTRAINT federated_likes_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.messages(id) ON DELETE CASCADE; + + +-- +-- Name: federated_likes federated_likes_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.federated_likes + ADD CONSTRAINT federated_likes_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: federated_quotes federated_quotes_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.federated_quotes + ADD CONSTRAINT federated_quotes_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.messages(id) ON DELETE CASCADE; + + +-- +-- Name: federated_quotes federated_quotes_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.federated_quotes + ADD CONSTRAINT federated_quotes_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: file_shares file_shares_stored_file_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.file_shares + ADD CONSTRAINT file_shares_stored_file_id_fkey FOREIGN KEY (stored_file_id) REFERENCES public.stored_files(id) ON DELETE CASCADE; + + +-- +-- Name: file_shares file_shares_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.file_shares + ADD CONSTRAINT file_shares_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: follows follows_followed_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.follows + ADD CONSTRAINT follows_followed_id_fkey FOREIGN KEY (followed_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: follows follows_follower_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.follows + ADD CONSTRAINT follows_follower_id_fkey FOREIGN KEY (follower_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: follows follows_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.follows + ADD CONSTRAINT follows_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: forwarded_messages forwarded_messages_alias_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.forwarded_messages + ADD CONSTRAINT forwarded_messages_alias_id_fkey FOREIGN KEY (alias_id) REFERENCES public.email_aliases(id); + + +-- +-- Name: friend_requests friend_requests_recipient_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.friend_requests + ADD CONSTRAINT friend_requests_recipient_id_fkey FOREIGN KEY (recipient_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: friend_requests friend_requests_requester_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.friend_requests + ADD CONSTRAINT friend_requests_requester_id_fkey FOREIGN KEY (requester_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: group_follows group_follows_group_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.group_follows + ADD CONSTRAINT group_follows_group_actor_id_fkey FOREIGN KEY (group_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: group_follows group_follows_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.group_follows + ADD CONSTRAINT group_follows_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: handle_history handle_history_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.handle_history + ADD CONSTRAINT handle_history_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: hashtag_follows hashtag_follows_hashtag_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.hashtag_follows + ADD CONSTRAINT hashtag_follows_hashtag_id_fkey FOREIGN KEY (hashtag_id) REFERENCES public.hashtags(id) ON DELETE CASCADE; + + +-- +-- Name: hashtag_follows hashtag_follows_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.hashtag_follows + ADD CONSTRAINT hashtag_follows_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: imap_subscriptions imap_subscriptions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.imap_subscriptions + ADD CONSTRAINT imap_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: invite_code_uses invite_code_uses_invite_code_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invite_code_uses + ADD CONSTRAINT invite_code_uses_invite_code_id_fkey FOREIGN KEY (invite_code_id) REFERENCES public.invite_codes(id) ON DELETE CASCADE; + + +-- +-- Name: invite_code_uses invite_code_uses_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invite_code_uses + ADD CONSTRAINT invite_code_uses_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: invite_codes invite_codes_created_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invite_codes + ADD CONSTRAINT invite_codes_created_by_id_fkey FOREIGN KEY (created_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: jmap_email_changes jmap_email_changes_mailbox_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.jmap_email_changes + ADD CONSTRAINT jmap_email_changes_mailbox_id_fkey FOREIGN KEY (mailbox_id) REFERENCES public.mailboxes(id) ON DELETE CASCADE; + + +-- +-- Name: jmap_email_tombstones jmap_email_tombstones_mailbox_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.jmap_email_tombstones + ADD CONSTRAINT jmap_email_tombstones_mailbox_id_fkey FOREIGN KEY (mailbox_id) REFERENCES public.mailboxes(id) ON DELETE CASCADE; + + +-- +-- Name: jmap_state_tracking jmap_state_tracking_mailbox_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.jmap_state_tracking + ADD CONSTRAINT jmap_state_tracking_mailbox_id_fkey FOREIGN KEY (mailbox_id) REFERENCES public.mailboxes(id) ON DELETE CASCADE; + + +-- +-- Name: link_preview_jobs link_preview_jobs_link_preview_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.link_preview_jobs + ADD CONSTRAINT link_preview_jobs_link_preview_id_fkey FOREIGN KEY (link_preview_id) REFERENCES public.link_previews(id) ON DELETE SET NULL; + + +-- +-- Name: link_preview_jobs link_preview_jobs_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.link_preview_jobs + ADD CONSTRAINT link_preview_jobs_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.messages(id) ON DELETE CASCADE; + + +-- +-- Name: list_members list_members_list_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.list_members + ADD CONSTRAINT list_members_list_id_fkey FOREIGN KEY (list_id) REFERENCES public.lists(id) ON DELETE CASCADE; + + +-- +-- Name: list_members list_members_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.list_members + ADD CONSTRAINT list_members_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: list_members list_members_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.list_members + ADD CONSTRAINT list_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: lists lists_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.lists + ADD CONSTRAINT lists_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: mailboxes mailboxes_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.mailboxes + ADD CONSTRAINT mailboxes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: message_reactions message_reactions_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.message_reactions + ADD CONSTRAINT message_reactions_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.messages(id) ON DELETE CASCADE; + + +-- +-- Name: message_reactions message_reactions_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.message_reactions + ADD CONSTRAINT message_reactions_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: message_reactions message_reactions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.message_reactions + ADD CONSTRAINT message_reactions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: message_votes message_votes_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.message_votes + ADD CONSTRAINT message_votes_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.messages(id) ON DELETE CASCADE; + + +-- +-- Name: message_votes message_votes_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.message_votes + ADD CONSTRAINT message_votes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: messages messages_approved_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_approved_by_id_fkey FOREIGN KEY (approved_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: messages messages_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.conversations(id) ON DELETE CASCADE; + + +-- +-- Name: messages messages_flair_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_flair_id_fkey FOREIGN KEY (flair_id) REFERENCES public.community_flairs(id) ON DELETE SET NULL; + + +-- +-- Name: messages messages_link_preview_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_link_preview_id_fkey FOREIGN KEY (link_preview_id) REFERENCES public.link_previews(id) ON DELETE SET NULL; + + +-- +-- Name: messages messages_locked_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_locked_by_id_fkey FOREIGN KEY (locked_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: messages messages_original_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_original_message_id_fkey FOREIGN KEY (original_message_id) REFERENCES public.messages(id) ON DELETE SET NULL; + + +-- +-- Name: messages messages_pinned_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_pinned_by_id_fkey FOREIGN KEY (pinned_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: messages messages_quoted_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_quoted_message_id_fkey FOREIGN KEY (quoted_message_id) REFERENCES public.messages(id) ON DELETE SET NULL; + + +-- +-- Name: messages messages_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE SET NULL; + + +-- +-- Name: messages messages_reply_to_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_reply_to_id_fkey FOREIGN KEY (reply_to_id) REFERENCES public.messages(id) ON DELETE SET NULL; + + +-- +-- Name: messages messages_sender_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_sender_id_fkey FOREIGN KEY (sender_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: messages messages_shared_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_shared_message_id_fkey FOREIGN KEY (shared_message_id) REFERENCES public.messages(id) ON DELETE SET NULL; + + +-- +-- Name: messaging_federation_account_presence_states messaging_federation_account_presence_states_remote_actor_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_account_presence_states + ADD CONSTRAINT messaging_federation_account_presence_states_remote_actor_id_fk FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_federation_call_sessions messaging_federation_call_sessions_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_call_sessions + ADD CONSTRAINT messaging_federation_call_sessions_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.chat_conversations(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_federation_call_sessions messaging_federation_call_sessions_local_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_call_sessions + ADD CONSTRAINT messaging_federation_call_sessions_local_user_id_fkey FOREIGN KEY (local_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_federation_extension_events messaging_federation_extension_events_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_extension_events + ADD CONSTRAINT messaging_federation_extension_events_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.chat_conversations(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_federation_extension_events messaging_federation_extension_events_server_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_extension_events + ADD CONSTRAINT messaging_federation_extension_events_server_id_fkey FOREIGN KEY (server_id) REFERENCES public.messaging_servers(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_federation_invite_states messaging_federation_invite_states_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_invite_states + ADD CONSTRAINT messaging_federation_invite_states_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.chat_conversations(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_federation_membership_states messaging_federation_membership_states_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_membership_states + ADD CONSTRAINT messaging_federation_membership_states_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.chat_conversations(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_federation_membership_states messaging_federation_membership_states_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_membership_states + ADD CONSTRAINT messaging_federation_membership_states_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_federation_peer_policies messaging_federation_peer_policies_updated_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_peer_policies + ADD CONSTRAINT messaging_federation_peer_policies_updated_by_id_fkey FOREIGN KEY (updated_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: messaging_federation_presence_states messaging_federation_presence_states_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_presence_states + ADD CONSTRAINT messaging_federation_presence_states_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_federation_presence_states messaging_federation_presence_states_server_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_presence_states + ADD CONSTRAINT messaging_federation_presence_states_server_id_fkey FOREIGN KEY (server_id) REFERENCES public.messaging_servers(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_federation_read_cursors messaging_federation_read_cursors_chat_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_read_cursors + ADD CONSTRAINT messaging_federation_read_cursors_chat_message_id_fkey FOREIGN KEY (chat_message_id) REFERENCES public.chat_messages(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_federation_read_cursors messaging_federation_read_cursors_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_read_cursors + ADD CONSTRAINT messaging_federation_read_cursors_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.chat_conversations(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_federation_read_cursors messaging_federation_read_cursors_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_read_cursors + ADD CONSTRAINT messaging_federation_read_cursors_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_federation_read_receipts messaging_federation_read_receipts_chat_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_read_receipts + ADD CONSTRAINT messaging_federation_read_receipts_chat_message_id_fkey FOREIGN KEY (chat_message_id) REFERENCES public.chat_messages(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_federation_read_receipts messaging_federation_read_receipts_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_read_receipts + ADD CONSTRAINT messaging_federation_read_receipts_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_federation_room_presence_states messaging_federation_room_presence_states_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_room_presence_states + ADD CONSTRAINT messaging_federation_room_presence_states_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.chat_conversations(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_federation_room_presence_states messaging_federation_room_presence_states_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_federation_room_presence_states + ADD CONSTRAINT messaging_federation_room_presence_states_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_server_members messaging_server_members_server_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_server_members + ADD CONSTRAINT messaging_server_members_server_id_fkey FOREIGN KEY (server_id) REFERENCES public.messaging_servers(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_server_members messaging_server_members_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_server_members + ADD CONSTRAINT messaging_server_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: messaging_servers messaging_servers_creator_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.messaging_servers + ADD CONSTRAINT messaging_servers_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: moderation_actions moderation_actions_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.moderation_actions + ADD CONSTRAINT moderation_actions_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.conversations(id) ON DELETE CASCADE; + + +-- +-- Name: moderation_actions moderation_actions_moderator_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.moderation_actions + ADD CONSTRAINT moderation_actions_moderator_id_fkey FOREIGN KEY (moderator_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: moderation_actions moderation_actions_target_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.moderation_actions + ADD CONSTRAINT moderation_actions_target_message_id_fkey FOREIGN KEY (target_message_id) REFERENCES public.messages(id) ON DELETE SET NULL; + + +-- +-- Name: moderation_actions moderation_actions_target_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.moderation_actions + ADD CONSTRAINT moderation_actions_target_user_id_fkey FOREIGN KEY (target_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: moderator_notes moderator_notes_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.moderator_notes + ADD CONSTRAINT moderator_notes_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.conversations(id) ON DELETE CASCADE; + + +-- +-- Name: moderator_notes moderator_notes_created_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.moderator_notes + ADD CONSTRAINT moderator_notes_created_by_id_fkey FOREIGN KEY (created_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: moderator_notes moderator_notes_target_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.moderator_notes + ADD CONSTRAINT moderator_notes_target_user_id_fkey FOREIGN KEY (target_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: notifications notifications_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.notifications + ADD CONSTRAINT notifications_actor_id_fkey FOREIGN KEY (actor_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: notifications notifications_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.notifications + ADD CONSTRAINT notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: oauth_apps oauth_apps_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_apps + ADD CONSTRAINT oauth_apps_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: oauth_authorizations oauth_authorizations_app_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_authorizations + ADD CONSTRAINT oauth_authorizations_app_id_fkey FOREIGN KEY (app_id) REFERENCES public.oauth_apps(id) ON DELETE CASCADE; + + +-- +-- Name: oauth_authorizations oauth_authorizations_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_authorizations + ADD CONSTRAINT oauth_authorizations_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: oauth_tokens oauth_tokens_app_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_tokens + ADD CONSTRAINT oauth_tokens_app_id_fkey FOREIGN KEY (app_id) REFERENCES public.oauth_apps(id) ON DELETE CASCADE; + + +-- +-- Name: oauth_tokens oauth_tokens_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_tokens + ADD CONSTRAINT oauth_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: passkey_credentials passkey_credentials_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.passkey_credentials + ADD CONSTRAINT passkey_credentials_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: nerve_entries nerve_entries_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.nerve_entries + ADD CONSTRAINT nerve_entries_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: nerve_settings nerve_settings_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.nerve_settings + ADD CONSTRAINT nerve_settings_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: platform_updates platform_updates_created_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.platform_updates + ADD CONSTRAINT platform_updates_created_by_id_fkey FOREIGN KEY (created_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: poll_options poll_options_poll_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.poll_options + ADD CONSTRAINT poll_options_poll_id_fkey FOREIGN KEY (poll_id) REFERENCES public.polls(id) ON DELETE CASCADE; + + +-- +-- Name: poll_votes poll_votes_option_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.poll_votes + ADD CONSTRAINT poll_votes_option_id_fkey FOREIGN KEY (option_id) REFERENCES public.poll_options(id) ON DELETE CASCADE; + + +-- +-- Name: poll_votes poll_votes_poll_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.poll_votes + ADD CONSTRAINT poll_votes_poll_id_fkey FOREIGN KEY (poll_id) REFERENCES public.polls(id) ON DELETE CASCADE; + + +-- +-- Name: poll_votes poll_votes_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.poll_votes + ADD CONSTRAINT poll_votes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: polls polls_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.polls + ADD CONSTRAINT polls_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.messages(id) ON DELETE CASCADE; + + +-- +-- Name: post_boosts post_boosts_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_boosts + ADD CONSTRAINT post_boosts_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.messages(id) ON DELETE CASCADE; + + +-- +-- Name: post_boosts post_boosts_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_boosts + ADD CONSTRAINT post_boosts_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: post_dismissals post_dismissals_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_dismissals + ADD CONSTRAINT post_dismissals_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.messages(id) ON DELETE CASCADE; + + +-- +-- Name: post_dismissals post_dismissals_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_dismissals + ADD CONSTRAINT post_dismissals_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: post_hashtags post_hashtags_hashtag_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_hashtags + ADD CONSTRAINT post_hashtags_hashtag_id_fkey FOREIGN KEY (hashtag_id) REFERENCES public.hashtags(id) ON DELETE CASCADE; + + +-- +-- Name: post_hashtags post_hashtags_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_hashtags + ADD CONSTRAINT post_hashtags_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.messages(id) ON DELETE CASCADE; + + +-- +-- Name: post_likes post_likes_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_likes + ADD CONSTRAINT post_likes_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.messages(id) ON DELETE CASCADE; + + +-- +-- Name: post_likes post_likes_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_likes + ADD CONSTRAINT post_likes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: post_views post_views_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_views + ADD CONSTRAINT post_views_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.messages(id) ON DELETE CASCADE; + + +-- +-- Name: post_views post_views_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_views + ADD CONSTRAINT post_views_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: profile_custom_domains profile_custom_domains_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_custom_domains + ADD CONSTRAINT profile_custom_domains_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: profile_links profile_links_profile_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_links + ADD CONSTRAINT profile_links_profile_id_fkey FOREIGN KEY (profile_id) REFERENCES public.user_profiles(id) ON DELETE CASCADE; + + +-- +-- Name: profile_site_visits profile_site_visits_profile_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_site_visits + ADD CONSTRAINT profile_site_visits_profile_user_id_fkey FOREIGN KEY (profile_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: profile_site_visits profile_site_visits_viewer_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_site_visits + ADD CONSTRAINT profile_site_visits_viewer_user_id_fkey FOREIGN KEY (viewer_user_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: profile_views profile_views_profile_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_views + ADD CONSTRAINT profile_views_profile_user_id_fkey FOREIGN KEY (profile_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: profile_views profile_views_viewer_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_views + ADD CONSTRAINT profile_views_viewer_user_id_fkey FOREIGN KEY (viewer_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: profile_widgets profile_widgets_profile_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_widgets + ADD CONSTRAINT profile_widgets_profile_id_fkey FOREIGN KEY (profile_id) REFERENCES public.user_profiles(id) ON DELETE CASCADE; + + +-- +-- Name: registration_checkouts registration_checkouts_invite_code_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.registration_checkouts + ADD CONSTRAINT registration_checkouts_invite_code_id_fkey FOREIGN KEY (invite_code_id) REFERENCES public.invite_codes(id) ON DELETE SET NULL; + + +-- +-- Name: registration_checkouts registration_checkouts_redeemed_by_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.registration_checkouts + ADD CONSTRAINT registration_checkouts_redeemed_by_user_id_fkey FOREIGN KEY (redeemed_by_user_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: remote_interactions remote_interactions_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.remote_interactions + ADD CONSTRAINT remote_interactions_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.messages(id) ON DELETE CASCADE; + + +-- +-- Name: remote_interactions remote_interactions_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.remote_interactions + ADD CONSTRAINT remote_interactions_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE SET NULL; + + +-- +-- Name: reports reports_reporter_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reports + ADD CONSTRAINT reports_reporter_id_fkey FOREIGN KEY (reporter_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: reports reports_reviewed_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reports + ADD CONSTRAINT reports_reviewed_by_id_fkey FOREIGN KEY (reviewed_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: rss_items rss_items_feed_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.rss_items + ADD CONSTRAINT rss_items_feed_id_fkey FOREIGN KEY (feed_id) REFERENCES public.rss_feeds(id) ON DELETE CASCADE; + + +-- +-- Name: rss_subscriptions rss_subscriptions_feed_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.rss_subscriptions + ADD CONSTRAINT rss_subscriptions_feed_id_fkey FOREIGN KEY (feed_id) REFERENCES public.rss_feeds(id) ON DELETE CASCADE; + + +-- +-- Name: rss_subscriptions rss_subscriptions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.rss_subscriptions + ADD CONSTRAINT rss_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: saved_items saved_items_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.saved_items + ADD CONSTRAINT saved_items_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.messages(id) ON DELETE CASCADE; + + +-- +-- Name: saved_items saved_items_rss_item_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.saved_items + ADD CONSTRAINT saved_items_rss_item_id_fkey FOREIGN KEY (rss_item_id) REFERENCES public.rss_items(id) ON DELETE CASCADE; + + +-- +-- Name: saved_items saved_items_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.saved_items + ADD CONSTRAINT saved_items_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: signing_keys signing_keys_remote_actor_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.signing_keys + ADD CONSTRAINT signing_keys_remote_actor_id_fkey FOREIGN KEY (remote_actor_id) REFERENCES public.activitypub_actors(id) ON DELETE CASCADE; + + +-- +-- Name: signing_keys signing_keys_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.signing_keys + ADD CONSTRAINT signing_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: static_site_files static_site_files_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.static_site_files + ADD CONSTRAINT static_site_files_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: stored_files stored_files_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.stored_files + ADD CONSTRAINT stored_files_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: stored_folders stored_folders_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.stored_folders + ADD CONSTRAINT stored_folders_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: subscriptions subscriptions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscriptions + ADD CONSTRAINT subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: trust_level_logs trust_level_logs_changed_by_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.trust_level_logs + ADD CONSTRAINT trust_level_logs_changed_by_user_id_fkey FOREIGN KEY (changed_by_user_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: trust_level_logs trust_level_logs_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.trust_level_logs + ADD CONSTRAINT trust_level_logs_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: trusted_devices trusted_devices_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.trusted_devices + ADD CONSTRAINT trusted_devices_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_activity_stats user_activity_stats_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_activity_stats + ADD CONSTRAINT user_activity_stats_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_badges user_badges_granted_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_badges + ADD CONSTRAINT user_badges_granted_by_id_fkey FOREIGN KEY (granted_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: user_badges user_badges_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_badges + ADD CONSTRAINT user_badges_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_blocks user_blocks_blocked_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_blocks + ADD CONSTRAINT user_blocks_blocked_id_fkey FOREIGN KEY (blocked_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_blocks user_blocks_blocker_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_blocks + ADD CONSTRAINT user_blocks_blocker_id_fkey FOREIGN KEY (blocker_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_hidden_messages user_hidden_messages_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_hidden_messages + ADD CONSTRAINT user_hidden_messages_message_id_fkey FOREIGN KEY (message_id) REFERENCES public.messages(id) ON DELETE CASCADE; + + +-- +-- Name: user_hidden_messages user_hidden_messages_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_hidden_messages + ADD CONSTRAINT user_hidden_messages_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_integrations user_integrations_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_integrations + ADD CONSTRAINT user_integrations_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_mutes user_mutes_muted_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_mutes + ADD CONSTRAINT user_mutes_muted_id_fkey FOREIGN KEY (muted_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_mutes user_mutes_muter_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_mutes + ADD CONSTRAINT user_mutes_muter_id_fkey FOREIGN KEY (muter_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_post_timestamps user_post_timestamps_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_post_timestamps + ADD CONSTRAINT user_post_timestamps_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.conversations(id) ON DELETE CASCADE; + + +-- +-- Name: user_post_timestamps user_post_timestamps_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_post_timestamps + ADD CONSTRAINT user_post_timestamps_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_profiles user_profiles_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_profiles + ADD CONSTRAINT user_profiles_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_timeouts user_timeouts_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_timeouts + ADD CONSTRAINT user_timeouts_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.conversations(id) ON DELETE CASCADE; + + +-- +-- Name: user_timeouts user_timeouts_created_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_timeouts + ADD CONSTRAINT user_timeouts_created_by_id_fkey FOREIGN KEY (created_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: user_timeouts user_timeouts_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_timeouts + ADD CONSTRAINT user_timeouts_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_warnings user_warnings_conversation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_warnings + ADD CONSTRAINT user_warnings_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES public.conversations(id) ON DELETE CASCADE; + + +-- +-- Name: user_warnings user_warnings_related_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_warnings + ADD CONSTRAINT user_warnings_related_message_id_fkey FOREIGN KEY (related_message_id) REFERENCES public.messages(id) ON DELETE SET NULL; + + +-- +-- Name: user_warnings user_warnings_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_warnings + ADD CONSTRAINT user_warnings_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_warnings user_warnings_warned_by_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_warnings + ADD CONSTRAINT user_warnings_warned_by_id_fkey FOREIGN KEY (warned_by_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: username_history username_history_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.username_history + ADD CONSTRAINT username_history_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: vpn_connection_logs vpn_connection_logs_vpn_user_config_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.vpn_connection_logs + ADD CONSTRAINT vpn_connection_logs_vpn_user_config_id_fkey FOREIGN KEY (vpn_user_config_id) REFERENCES public.vpn_user_configs(id) ON DELETE CASCADE; + + +-- +-- Name: vpn_user_configs vpn_user_configs_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.vpn_user_configs + ADD CONSTRAINT vpn_user_configs_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: vpn_user_configs vpn_user_configs_vpn_server_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.vpn_user_configs + ADD CONSTRAINT vpn_user_configs_vpn_server_id_fkey FOREIGN KEY (vpn_server_id) REFERENCES public.vpn_servers(id) ON DELETE CASCADE; + + +-- +-- PostgreSQL database dump complete +-- + diff --git a/apps/elektrine/priv/repo/generate_activitypub_keys.exs b/apps/elektrine/priv/repo/generate_activitypub_keys.exs new file mode 100644 index 0000000..b5e1ff9 --- /dev/null +++ b/apps/elektrine/priv/repo/generate_activitypub_keys.exs @@ -0,0 +1,35 @@ +# Script to generate ActivityPub RSA keys for existing users +# Run with: mix run priv/repo/generate_activitypub_keys.exs + +require Logger +import Ecto.Query + +alias Elektrine.Accounts.User +alias Elektrine.ActivityPub.HTTPSignature +alias Elektrine.Repo + +# Find all users without ActivityPub keys +users_without_keys = + from(u in User, + where: is_nil(u.activitypub_public_key) or is_nil(u.activitypub_private_key) + ) + |> Repo.all() + +Logger.info("Found #{length(users_without_keys)} users without ActivityPub keys") + +Enum.each(users_without_keys, fn user -> + Logger.info("Generating keys for user: #{user.username}") + + {public_key, private_key} = HTTPSignature.generate_key_pair() + + user + |> Ecto.Changeset.change(%{ + activitypub_public_key: public_key, + activitypub_private_key: private_key + }) + |> Repo.update!() + + Logger.info("✓ Generated keys for #{user.username}") +end) + +Logger.info("Done! Generated keys for #{length(users_without_keys)} users") diff --git a/apps/elektrine/priv/repo/migrations/.formatter.exs b/apps/elektrine/priv/repo/migrations/.formatter.exs new file mode 100644 index 0000000..49f9151 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/apps/elektrine/priv/repo/migrations/20250511044001_create_users_table.exs b/apps/elektrine/priv/repo/migrations/20250511044001_create_users_table.exs new file mode 100644 index 0000000..9581175 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250511044001_create_users_table.exs @@ -0,0 +1,14 @@ +defmodule Elektrine.Repo.Migrations.CreateUsersTable do + use Ecto.Migration + + def change do + create table(:users) do + add :username, :string, null: false + add :password_hash, :string, null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:users, [:username]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250512000001_create_mailboxes.exs b/apps/elektrine/priv/repo/migrations/20250512000001_create_mailboxes.exs new file mode 100644 index 0000000..c4db12a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250512000001_create_mailboxes.exs @@ -0,0 +1,16 @@ +defmodule Elektrine.Repo.Migrations.CreateMailboxes do + use Ecto.Migration + + def change do + create table(:mailboxes) do + add :email, :string, null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:mailboxes, [:email]) + # Each user has exactly one mailbox + create unique_index(:mailboxes, [:user_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250512000002_create_email_messages.exs b/apps/elektrine/priv/repo/migrations/20250512000002_create_email_messages.exs new file mode 100644 index 0000000..51964e2 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250512000002_create_email_messages.exs @@ -0,0 +1,28 @@ +defmodule Elektrine.Repo.Migrations.CreateEmailMessages do + use Ecto.Migration + + def change do + create table(:email_messages) do + add :message_id, :string, null: false + add :from, :string, null: false + add :to, :string, null: false + add :cc, :string + add :bcc, :string + add :subject, :string + add :text_body, :text + add :html_body, :text + add :status, :string, null: false, default: "received" + add :read, :boolean, default: false + add :metadata, :map, default: %{} + add :mailbox_id, references(:mailboxes, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:email_messages, [:message_id, :mailbox_id]) + create index(:email_messages, [:mailbox_id]) + create index(:email_messages, [:status]) + create index(:email_messages, [:from]) + create index(:email_messages, [:to]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250513061546_create_temporary_mailboxes.exs b/apps/elektrine/priv/repo/migrations/20250513061546_create_temporary_mailboxes.exs new file mode 100644 index 0000000..7fc58d7 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250513061546_create_temporary_mailboxes.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.CreateTemporaryMailboxes do + use Ecto.Migration + + def change do + create table(:temporary_mailboxes) do + add :email, :string, null: false + add :token, :string, null: false + add :expires_at, :utc_datetime, null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:temporary_mailboxes, [:email]) + create unique_index(:temporary_mailboxes, [:token]) + create index(:temporary_mailboxes, [:expires_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250513064926_make_mailbox_user_id_nullable.exs b/apps/elektrine/priv/repo/migrations/20250513064926_make_mailbox_user_id_nullable.exs new file mode 100644 index 0000000..97732bc --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250513064926_make_mailbox_user_id_nullable.exs @@ -0,0 +1,22 @@ +defmodule Elektrine.Repo.Migrations.MakeMailboxUserIdNullable do + use Ecto.Migration + + def change do + # We need to drop the existing foreign key constraint first + execute "ALTER TABLE mailboxes DROP CONSTRAINT IF EXISTS mailboxes_user_id_fkey" + + # Remove the NOT NULL constraint from the user_id column + alter table(:mailboxes) do + modify :user_id, references(:users, on_delete: :nilify_all), null: true + end + + # Remove the unique index on user_id (since we now want to allow nulls) + drop_if_exists index(:mailboxes, [:user_id], unique: true) + create_if_not_exists index(:mailboxes, [:user_id]) + + # Add a new column to identify if this is a temporary mailbox + alter table(:mailboxes) do + add_if_not_exists :temporary, :boolean, default: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250513072500_update_messages_for_temporary_mailboxes.exs b/apps/elektrine/priv/repo/migrations/20250513072500_update_messages_for_temporary_mailboxes.exs new file mode 100644 index 0000000..ad57755 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250513072500_update_messages_for_temporary_mailboxes.exs @@ -0,0 +1,16 @@ +defmodule Elektrine.Repo.Migrations.UpdateMessagesForTemporaryMailboxes do + use Ecto.Migration + + def change do + # First drop the existing foreign key constraint + execute "ALTER TABLE email_messages DROP CONSTRAINT IF EXISTS email_messages_mailbox_id_fkey" + + # Add a new polymorphic column to identify the mailbox type + alter table(:email_messages) do + add :mailbox_type, :string, default: "regular" + end + + # Create index for faster searching + create index(:email_messages, [:mailbox_type, :mailbox_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250529050458_add_avatar_to_users.exs b/apps/elektrine/priv/repo/migrations/20250529050458_add_avatar_to_users.exs new file mode 100644 index 0000000..7115dba --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250529050458_add_avatar_to_users.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddAvatarToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :avatar, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250608202809_add_hey_features_to_email_messages.exs b/apps/elektrine/priv/repo/migrations/20250608202809_add_hey_features_to_email_messages.exs new file mode 100644 index 0000000..b5e36f1 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250608202809_add_hey_features_to_email_messages.exs @@ -0,0 +1,43 @@ +defmodule Elektrine.Repo.Migrations.AddHeyFeaturesToEmailMessages do + use Ecto.Migration + + def change do + alter table(:email_messages) do + # The Screener - for new senders + # pending, approved, rejected + add :screener_status, :string, default: "pending" + add :sender_approved, :boolean, default: false + + # Hey.com organization categories + # inbox, feed, paper_trail, set_aside + add :category, :string, default: "inbox" + + # Set Aside feature + add :set_aside_at, :utc_datetime + add :set_aside_reason, :string + + # Reply Later + add :reply_later_at, :utc_datetime + add :reply_later_reminder, :boolean, default: false + + # Paper Trail auto-categorization + add :is_receipt, :boolean, default: false + add :is_notification, :boolean, default: false + add :is_newsletter, :boolean, default: false + + # Tracking + add :opened_at, :utc_datetime + add :first_opened_at, :utc_datetime + add :open_count, :integer, default: 0 + end + + create index(:email_messages, [:screener_status]) + create index(:email_messages, [:category]) + create index(:email_messages, [:sender_approved]) + create index(:email_messages, [:set_aside_at]) + create index(:email_messages, [:reply_later_at]) + create index(:email_messages, [:is_receipt]) + create index(:email_messages, [:is_notification]) + create index(:email_messages, [:is_newsletter]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250608202829_create_approved_senders.exs b/apps/elektrine/priv/repo/migrations/20250608202829_create_approved_senders.exs new file mode 100644 index 0000000..1c6a31d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250608202829_create_approved_senders.exs @@ -0,0 +1,20 @@ +defmodule Elektrine.Repo.Migrations.CreateApprovedSenders do + use Ecto.Migration + + def change do + create table(:approved_senders) do + add :email_address, :string, null: false + add :mailbox_id, references(:mailboxes, on_delete: :delete_all), null: false + add :approved_at, :utc_datetime, null: false + add :last_email_at, :utc_datetime + add :email_count, :integer, default: 0 + add :notes, :text + + timestamps(type: :utc_datetime) + end + + create unique_index(:approved_senders, [:email_address, :mailbox_id]) + create index(:approved_senders, [:mailbox_id]) + create index(:approved_senders, [:email_address]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250609031912_add_spam_and_archived_to_email_messages.exs b/apps/elektrine/priv/repo/migrations/20250609031912_add_spam_and_archived_to_email_messages.exs new file mode 100644 index 0000000..51742b0 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250609031912_add_spam_and_archived_to_email_messages.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.AddSpamAndArchivedToEmailMessages do + use Ecto.Migration + + def change do + alter table(:email_messages) do + add :spam, :boolean, default: false + add :archived, :boolean, default: false + end + + create index(:email_messages, [:spam]) + create index(:email_messages, [:archived]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250615002423_import_existing_users.exs b/apps/elektrine/priv/repo/migrations/20250615002423_import_existing_users.exs new file mode 100644 index 0000000..eb26091 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250615002423_import_existing_users.exs @@ -0,0 +1,12 @@ +defmodule Elektrine.Repo.Migrations.ImportExistingUsers do + use Ecto.Migration + + @moduledoc false + + # This migration used to import host-specific users from IMPORT_USERS. + # That made schema history depend on ephemeral deploy-time input, so the + # import now lives in `mix users.import_existing`. + + def up, do: :ok + def down, do: :ok +end diff --git a/apps/elektrine/priv/repo/migrations/20250615202324_add_username_change_tracking.exs b/apps/elektrine/priv/repo/migrations/20250615202324_add_username_change_tracking.exs new file mode 100644 index 0000000..e7802da --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250615202324_add_username_change_tracking.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddUsernameChangeTracking do + use Ecto.Migration + + def change do + alter table(:users) do + add :last_username_change_at, :utc_datetime + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250615235823_add_admin_role_to_users.exs b/apps/elektrine/priv/repo/migrations/20250615235823_add_admin_role_to_users.exs new file mode 100644 index 0000000..0618f49 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250615235823_add_admin_role_to_users.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddAdminRoleToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :is_admin, :boolean, default: false, null: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250616000949_add_banned_to_users.exs b/apps/elektrine/priv/repo/migrations/20250616000949_add_banned_to_users.exs new file mode 100644 index 0000000..054a028 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250616000949_add_banned_to_users.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddBannedToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :banned, :boolean, default: false, null: false + add :banned_at, :utc_datetime + add :banned_reason, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250622162141_create_email_aliases.exs b/apps/elektrine/priv/repo/migrations/20250622162141_create_email_aliases.exs new file mode 100644 index 0000000..38276f2 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250622162141_create_email_aliases.exs @@ -0,0 +1,20 @@ +defmodule Elektrine.Repo.Migrations.CreateEmailAliases do + use Ecto.Migration + + def change do + create table(:email_aliases) do + add :alias_email, :string, null: false + add :target_email, :string, null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + add :enabled, :boolean, default: true, null: false + add :description, :string + + timestamps() + end + + create unique_index(:email_aliases, [:alias_email]) + create index(:email_aliases, [:user_id]) + create index(:email_aliases, [:target_email]) + create index(:email_aliases, [:enabled]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250622164337_add_forwarding_to_mailboxes.exs b/apps/elektrine/priv/repo/migrations/20250622164337_add_forwarding_to_mailboxes.exs new file mode 100644 index 0000000..51544ae --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250622164337_add_forwarding_to_mailboxes.exs @@ -0,0 +1,12 @@ +defmodule Elektrine.Repo.Migrations.AddForwardingToMailboxes do + use Ecto.Migration + + def change do + alter table(:mailboxes) do + add :forward_to, :string + add :forward_enabled, :boolean, default: false, null: false + end + + create index(:mailboxes, [:forward_enabled]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250622165252_make_target_email_nullable.exs b/apps/elektrine/priv/repo/migrations/20250622165252_make_target_email_nullable.exs new file mode 100644 index 0000000..6b4a96b --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250622165252_make_target_email_nullable.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.MakeTargetEmailNullable do + use Ecto.Migration + + def change do + alter table(:email_aliases) do + modify :target_email, :string, null: true + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250622180610_add_alias_username_conflict_indexes.exs b/apps/elektrine/priv/repo/migrations/20250622180610_add_alias_username_conflict_indexes.exs new file mode 100644 index 0000000..1f91bc5 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250622180610_add_alias_username_conflict_indexes.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddAliasUsernameConflictIndexes do + use Ecto.Migration + + def change do + # Add index for enabled aliases to speed up conflict checking + create index(:email_aliases, [:alias_email, :enabled]) + + # The usernames table already has a unique index on username + # which is sufficient for our conflict checking + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250622233020_add_attachments_to_email_messages.exs b/apps/elektrine/priv/repo/migrations/20250622233020_add_attachments_to_email_messages.exs new file mode 100644 index 0000000..ec097ce --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250622233020_add_attachments_to_email_messages.exs @@ -0,0 +1,12 @@ +defmodule Elektrine.Repo.Migrations.AddAttachmentsToEmailMessages do + use Ecto.Migration + + def change do + alter table(:email_messages) do + add :attachments, :map, default: %{} + add :has_attachments, :boolean, default: false + end + + create index(:email_messages, [:has_attachments]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250624065910_create_account_deletion_requests.exs b/apps/elektrine/priv/repo/migrations/20250624065910_create_account_deletion_requests.exs new file mode 100644 index 0000000..ee453db --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250624065910_create_account_deletion_requests.exs @@ -0,0 +1,21 @@ +defmodule Elektrine.Repo.Migrations.CreateAccountDeletionRequests do + use Ecto.Migration + + def change do + create table(:account_deletion_requests) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :reason, :text + add :status, :string, default: "pending", null: false + add :requested_at, :utc_datetime, null: false + add :reviewed_at, :utc_datetime + add :reviewed_by_id, references(:users, on_delete: :nilify_all) + add :admin_notes, :text + + timestamps(type: :utc_datetime) + end + + create index(:account_deletion_requests, [:user_id]) + create index(:account_deletion_requests, [:status]) + create index(:account_deletion_requests, [:requested_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250628155713_add_two_factor_auth_to_users.exs b/apps/elektrine/priv/repo/migrations/20250628155713_add_two_factor_auth_to_users.exs new file mode 100644 index 0000000..852777d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250628155713_add_two_factor_auth_to_users.exs @@ -0,0 +1,12 @@ +defmodule Elektrine.Repo.Migrations.AddTwoFactorAuthToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :two_factor_enabled, :boolean, default: false, null: false + add :two_factor_secret, :string + add :two_factor_backup_codes, {:array, :string} + add :two_factor_enabled_at, :utc_datetime + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250630003407_create_invite_codes.exs b/apps/elektrine/priv/repo/migrations/20250630003407_create_invite_codes.exs new file mode 100644 index 0000000..e5c6340 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250630003407_create_invite_codes.exs @@ -0,0 +1,21 @@ +defmodule Elektrine.Repo.Migrations.CreateInviteCodes do + use Ecto.Migration + + def change do + create table(:invite_codes) do + add :code, :string, null: false + add :max_uses, :integer, default: 1 + add :uses_count, :integer, default: 0 + add :expires_at, :utc_datetime + add :created_by_id, references(:users, on_delete: :nilify_all) + add :note, :text + add :is_active, :boolean, default: true + + timestamps() + end + + create unique_index(:invite_codes, [:code]) + create index(:invite_codes, [:is_active]) + create index(:invite_codes, [:expires_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250630003424_create_invite_code_uses.exs b/apps/elektrine/priv/repo/migrations/20250630003424_create_invite_code_uses.exs new file mode 100644 index 0000000..db43c73 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250630003424_create_invite_code_uses.exs @@ -0,0 +1,15 @@ +defmodule Elektrine.Repo.Migrations.CreateInviteCodeUses do + use Ecto.Migration + + def change do + create table(:invite_code_uses) do + add :invite_code_id, references(:invite_codes, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + add :used_at, :utc_datetime, default: fragment("NOW()"), null: false + end + + create index(:invite_code_uses, [:invite_code_id]) + create index(:invite_code_uses, [:user_id]) + create unique_index(:invite_code_uses, [:user_id], name: :invite_code_uses_user_id_unique) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250630011535_create_system_config.exs b/apps/elektrine/priv/repo/migrations/20250630011535_create_system_config.exs new file mode 100644 index 0000000..22d0df3 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250630011535_create_system_config.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Repo.Migrations.CreateSystemConfig do + use Ecto.Migration + + def change do + create table(:system_config) do + add :key, :string, null: false + add :value, :text + add :type, :string, default: "string" + add :description, :text + + timestamps() + end + + create unique_index(:system_config, [:key]) + + # Insert default configuration + execute """ + INSERT INTO system_config (key, value, type, description, inserted_at, updated_at) + VALUES ('invite_codes_enabled', 'true', 'boolean', 'Enable or disable the invite code system for user registration', NOW(), NOW()) + """, + "" + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250706183626_create_rejected_senders.exs b/apps/elektrine/priv/repo/migrations/20250706183626_create_rejected_senders.exs new file mode 100644 index 0000000..52a5644 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250706183626_create_rejected_senders.exs @@ -0,0 +1,20 @@ +defmodule Elektrine.Repo.Migrations.CreateRejectedSenders do + use Ecto.Migration + + def change do + create table(:rejected_senders) do + add :email_address, :string, null: false + add :rejected_at, :utc_datetime, null: false + add :rejection_count, :integer, default: 1 + add :last_rejection_at, :utc_datetime + add :notes, :text + + add :mailbox_id, references(:mailboxes, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:rejected_senders, [:email_address, :mailbox_id]) + create index(:rejected_senders, [:mailbox_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250706184034_add_user_to_temporary_mailboxes.exs b/apps/elektrine/priv/repo/migrations/20250706184034_add_user_to_temporary_mailboxes.exs new file mode 100644 index 0000000..efdd9b3 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250706184034_add_user_to_temporary_mailboxes.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddUserToTemporaryMailboxes do + use Ecto.Migration + + def change do + alter table(:temporary_mailboxes) do + add :user_id, references(:users, on_delete: :delete_all) + end + + create index(:temporary_mailboxes, [:user_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250712172013_add_bluesky_identity_to_users.exs b/apps/elektrine/priv/repo/migrations/20250712172013_add_bluesky_identity_to_users.exs new file mode 100644 index 0000000..010c95b --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250712172013_add_bluesky_identity_to_users.exs @@ -0,0 +1,18 @@ +defmodule Elektrine.Repo.Migrations.AddBlueskyIdentityToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :bluesky_did, :string + add :bluesky_handle, :string + add :bluesky_access_token, :text + add :bluesky_refresh_token, :text + add :bluesky_token_expires_at, :utc_datetime + add :bluesky_dpop_key, :text + add :bluesky_connected_at, :utc_datetime + end + + create index(:users, [:bluesky_did]) + create index(:users, [:bluesky_handle]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250720223936_add_github_token_to_users.exs b/apps/elektrine/priv/repo/migrations/20250720223936_add_github_token_to_users.exs new file mode 100644 index 0000000..bb29977 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250720223936_add_github_token_to_users.exs @@ -0,0 +1,12 @@ +defmodule Elektrine.Repo.Migrations.AddGithubTokenToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :github_access_token, :text + add :github_refresh_token, :text + add :github_token_expires_at, :utc_datetime + add :github_username, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250726184803_remove_screener_feature.exs b/apps/elektrine/priv/repo/migrations/20250726184803_remove_screener_feature.exs new file mode 100644 index 0000000..dfeb9ce --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250726184803_remove_screener_feature.exs @@ -0,0 +1,15 @@ +defmodule Elektrine.Repo.Migrations.RemoveScreenerFeature do + use Ecto.Migration + + def change do + alter table(:email_messages) do + remove :screener_status + remove :sender_approved + end + + drop_if_exists index(:email_messages, [:screener_status]) + drop_if_exists index(:email_messages, [:sender_approved]) + + drop_if_exists table(:rejected_senders) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250817001342_create_siem_tables.exs b/apps/elektrine/priv/repo/migrations/20250817001342_create_siem_tables.exs new file mode 100644 index 0000000..d168810 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250817001342_create_siem_tables.exs @@ -0,0 +1,201 @@ +defmodule Elektrine.Repo.Migrations.CreateSiemTables do + use Ecto.Migration + + def change do + # SIEM event sources - systems/applications generating events + create table(:siem_sources) do + add :name, :string, null: false + # web, application, infrastructure, network, database + add :type, :string, null: false + add :description, :text + add :active, :boolean, default: true + # source-specific configuration + add :config, :map, default: %{} + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:siem_sources, [:user_id]) + create index(:siem_sources, [:type]) + create unique_index(:siem_sources, [:user_id, :name]) + + # SIEM events - raw security events collected from various sources + create table(:siem_events) do + # unique identifier from source + add :event_id, :string, null: false + add :timestamp, :utc_datetime, null: false + # login, scan, attack, alert, etc. + add :event_type, :string, null: false + # critical, high, medium, low, info + add :severity, :string, null: false + add :source_ip, :string + add :destination_ip, :string + add :user_agent, :text + add :method, :string + add :url, :text + add :status_code, :integer + add :response_size, :integer + add :response_time, :integer + add :username, :string + add :action, :string + add :resource, :string + # success, failure, blocked + add :result, :string + add :message, :text + # original log entry + add :raw_data, :text + # additional structured data + add :metadata, :map, default: %{} + add :processed, :boolean, default: false + add :correlated, :boolean, default: false + add :source_id, references(:siem_sources, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:siem_events, [:source_id]) + create index(:siem_events, [:timestamp]) + create index(:siem_events, [:event_type]) + create index(:siem_events, [:severity]) + create index(:siem_events, [:source_ip]) + create index(:siem_events, [:username]) + create index(:siem_events, [:processed]) + create index(:siem_events, [:correlated]) + create unique_index(:siem_events, [:source_id, :event_id]) + + # SIEM alerts - processed events that require attention + create table(:siem_alerts) do + add :title, :string, null: false + add :description, :text + add :severity, :string, null: false + # anomaly, threshold, correlation, pattern + add :alert_type, :string, null: false + # open, investigating, resolved, false_positive + add :status, :string, default: "open" + # confidence score 0.0-1.0 + add :confidence, :float + # calculated risk score + add :risk_score, :float + add :first_seen, :utc_datetime, null: false + add :last_seen, :utc_datetime, null: false + add :event_count, :integer, default: 1 + add :source_ips, {:array, :string}, default: [] + add :affected_users, {:array, :string}, default: [] + add :affected_resources, {:array, :string}, default: [] + add :tags, {:array, :string}, default: [] + add :assignee_id, references(:users, on_delete: :nilify_all) + add :resolved_at, :utc_datetime + add :resolution_notes, :text + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:siem_alerts, [:user_id]) + create index(:siem_alerts, [:severity]) + create index(:siem_alerts, [:status]) + create index(:siem_alerts, [:alert_type]) + create index(:siem_alerts, [:first_seen]) + create index(:siem_alerts, [:assignee_id]) + + # Junction table linking alerts to events + create table(:siem_alert_events) do + add :alert_id, references(:siem_alerts, on_delete: :delete_all), null: false + add :event_id, references(:siem_events, on_delete: :delete_all), null: false + + timestamps() + end + + create unique_index(:siem_alert_events, [:alert_id, :event_id]) + + # SIEM rules - detection rules for generating alerts + create table(:siem_rules) do + add :name, :string, null: false + add :description, :text + # threshold, correlation, anomaly, pattern + add :rule_type, :string, null: false + add :severity, :string, null: false + add :active, :boolean, default: true + # rule logic and conditions + add :conditions, :map, null: false + # for threshold rules + add :threshold_count, :integer + # time window in seconds + add :threshold_window, :integer + # suppression period in seconds + add :suppression_window, :integer + add :tags, {:array, :string}, default: [] + add :mitre_tactics, {:array, :string}, default: [] + add :mitre_techniques, {:array, :string}, default: [] + add :last_triggered, :utc_datetime + add :trigger_count, :integer, default: 0 + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:siem_rules, [:user_id]) + create index(:siem_rules, [:active]) + create index(:siem_rules, [:rule_type]) + create index(:siem_rules, [:severity]) + create unique_index(:siem_rules, [:user_id, :name]) + + # SIEM dashboards - custom dashboards for monitoring + create table(:siem_dashboards) do + add :name, :string, null: false + add :description, :text + # dashboard layout and widgets + add :config, :map, null: false + add :is_default, :boolean, default: false + add :shared, :boolean, default: false + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:siem_dashboards, [:user_id]) + create index(:siem_dashboards, [:shared]) + create unique_index(:siem_dashboards, [:user_id, :name]) + + # SIEM incidents - grouped alerts representing security incidents + create table(:siem_incidents) do + add :title, :string, null: false + add :description, :text + add :severity, :string, null: false + # open, investigating, contained, resolved + add :status, :string, default: "open" + # data_breach, malware, dos_attack, etc. + add :incident_type, :string + add :first_detected, :utc_datetime, null: false + add :last_activity, :utc_datetime, null: false + # low, medium, high, critical + add :estimated_impact, :string + add :affected_systems, {:array, :string}, default: [] + add :affected_users, {:array, :string}, default: [] + add :response_actions, :text + add :lessons_learned, :text + add :assignee_id, references(:users, on_delete: :nilify_all) + add :resolved_at, :utc_datetime + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:siem_incidents, [:user_id]) + create index(:siem_incidents, [:severity]) + create index(:siem_incidents, [:status]) + create index(:siem_incidents, [:first_detected]) + create index(:siem_incidents, [:assignee_id]) + + # Junction table linking incidents to alerts + create table(:siem_incident_alerts) do + add :incident_id, references(:siem_incidents, on_delete: :delete_all), null: false + add :alert_id, references(:siem_alerts, on_delete: :delete_all), null: false + + timestamps() + end + + create unique_index(:siem_incident_alerts, [:incident_id, :alert_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250817191916_create_threat_intelligence_tables.exs b/apps/elektrine/priv/repo/migrations/20250817191916_create_threat_intelligence_tables.exs new file mode 100644 index 0000000..ecaa41e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250817191916_create_threat_intelligence_tables.exs @@ -0,0 +1,273 @@ +defmodule Elektrine.Repo.Migrations.CreateThreatIntelligenceTables do + use Ecto.Migration + + def change do + # Threat intelligence feeds - external sources of threat data + create table(:threat_feeds) do + add :name, :string, null: false + # malware, ip_reputation, domain_reputation, ioc, ransomware + add :type, :string, null: false + add :url, :string + add :description, :text + add :active, :boolean, default: true + # seconds + add :update_frequency, :integer, default: 3600 + add :last_updated, :utc_datetime + # success, failure, pending + add :last_sync_status, :string, default: "pending" + # feed-specific configuration + add :config, :map, default: %{} + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:threat_feeds, [:user_id]) + create index(:threat_feeds, [:type]) + create index(:threat_feeds, [:active]) + create unique_index(:threat_feeds, [:user_id, :name]) + + # Threat indicators - specific threat intelligence data points + create table(:threat_indicators) do + # ip, domain, url, file_hash, email, user_agent + add :type, :string, null: false + # the actual indicator value + add :value, :string, null: false + # malware, ransomware, phishing, botnet, c2, etc. + add :threat_type, :string, null: false + # critical, high, medium, low + add :severity, :string, null: false + # confidence score 0.0-1.0 + add :confidence, :float + add :first_seen, :utc_datetime, null: false + add :last_seen, :utc_datetime, null: false + # active, expired, false_positive + add :status, :string, default: "active" + add :description, :text + # e.g., "Emotet", "Ryuk", "Conti" + add :malware_family, :string + # e.g., "email", "web", "usb" + add :attack_vector, :string + # healthcare, finance, government + add :target_sector, {:array, :string}, default: [] + # affected countries + add :countries, {:array, :string}, default: [] + add :tags, {:array, :string}, default: [] + # URLs to reports/analysis + add :references, {:array, :string}, default: [] + # MITRE ATT&CK TTPs + add :ttps, {:array, :string}, default: [] + # reconnaissance, weaponization, delivery, etc. + add :kill_chain_phase, :string + # when this indicator expires + add :expires_at, :utc_datetime + # manually whitelisted + add :whitelisted, :boolean, default: false + add :feed_id, references(:threat_feeds, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:threat_indicators, [:feed_id]) + create index(:threat_indicators, [:type]) + create index(:threat_indicators, [:value]) + create index(:threat_indicators, [:threat_type]) + create index(:threat_indicators, [:severity]) + create index(:threat_indicators, [:status]) + create index(:threat_indicators, [:first_seen]) + create index(:threat_indicators, [:expires_at]) + create index(:threat_indicators, [:malware_family]) + create unique_index(:threat_indicators, [:feed_id, :type, :value]) + + # Threat campaigns - organized threat actor campaigns + create table(:threat_campaigns) do + add :name, :string, null: false + add :description, :text + # APT group, criminal organization, etc. + add :threat_actor, :string + # financial, espionage, disruption, etc. + add :motivation, :string + # low, medium, high, nation_state + add :sophistication, :string + add :first_activity, :utc_datetime + add :last_activity, :utc_datetime + # active, dormant, disrupted + add :status, :string, default: "active" + add :target_sectors, {:array, :string}, default: [] + add :target_countries, {:array, :string}, default: [] + add :ttps, {:array, :string}, default: [] + add :malware_families, {:array, :string}, default: [] + add :attack_vectors, {:array, :string}, default: [] + # other names for this campaign + add :aliases, {:array, :string}, default: [] + add :references, {:array, :string}, default: [] + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:threat_campaigns, [:user_id]) + create index(:threat_campaigns, [:threat_actor]) + create index(:threat_campaigns, [:status]) + create index(:threat_campaigns, [:first_activity]) + create unique_index(:threat_campaigns, [:user_id, :name]) + + # Junction table linking campaigns to indicators + create table(:threat_campaign_indicators) do + add :campaign_id, references(:threat_campaigns, on_delete: :delete_all), null: false + add :indicator_id, references(:threat_indicators, on_delete: :delete_all), null: false + # primary, secondary, infrastructure, tool + add :role, :string + + timestamps() + end + + create unique_index(:threat_campaign_indicators, [:campaign_id, :indicator_id]) + + # Threat matches - when SIEM events match threat intelligence + create table(:threat_matches) do + add :matched_at, :utc_datetime, null: false + # exact, partial, behavioral + add :match_type, :string, null: false + # match confidence score + add :confidence, :float + # additional context about the match + add :context, :text + # blocked, alerted, logged, investigated + add :action_taken, :string + add :false_positive, :boolean, default: false + add :notes, :text + add :event_id, references(:siem_events, on_delete: :delete_all), null: false + add :indicator_id, references(:threat_indicators, on_delete: :delete_all), null: false + # created alert if any + add :alert_id, references(:siem_alerts, on_delete: :nilify_all) + + timestamps() + end + + create index(:threat_matches, [:event_id]) + create index(:threat_matches, [:indicator_id]) + create index(:threat_matches, [:alert_id]) + create index(:threat_matches, [:matched_at]) + create index(:threat_matches, [:match_type]) + create unique_index(:threat_matches, [:event_id, :indicator_id]) + + # Data breach tracking - track known data breaches and compromised credentials + create table(:data_breaches) do + # e.g., "Equifax 2017", "LinkedIn 2012" + add :name, :string, null: false + add :organization, :string, null: false + add :breach_date, :date + add :discovery_date, :date + add :disclosure_date, :date + add :records_affected, :bigint + # emails, passwords, ssn, cc_numbers + add :data_types, {:array, :string}, default: [] + # hack, insider, lost_device, misconfiguration + add :breach_type, :string + # phishing, malware, sql_injection, etc. + add :attack_vector, :string + # healthcare, finance, retail, etc. + add :sector, :string + add :country, :string + add :description, :text + # critical, high, medium, low + add :severity, :string + # active, contained, resolved + add :status, :string, default: "active" + add :references, {:array, :string}, default: [] + add :estimated_cost, :decimal + add :regulatory_action, :text + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:data_breaches, [:user_id]) + create index(:data_breaches, [:organization]) + create index(:data_breaches, [:breach_date]) + create index(:data_breaches, [:severity]) + create index(:data_breaches, [:sector]) + create unique_index(:data_breaches, [:user_id, :name]) + + # Compromised credentials from breaches + create table(:compromised_credentials) do + add :email, :string, null: false + add :password_hash, :string + add :domain, :string + # which breach this came from + add :source_breach, :string + add :breach_date, :date + # phone, address, etc. + add :additional_data, :map, default: %{} + # verified as genuine + add :verified, :boolean, default: false + add :severity, :string, default: "medium" + add :breach_id, references(:data_breaches, on_delete: :delete_all) + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:compromised_credentials, [:user_id]) + create index(:compromised_credentials, [:email]) + create index(:compromised_credentials, [:domain]) + create index(:compromised_credentials, [:breach_id]) + create index(:compromised_credentials, [:breach_date]) + create unique_index(:compromised_credentials, [:user_id, :email, :source_breach]) + + # Ransomware tracking - specific ransomware threat tracking + create table(:ransomware_threats) do + # Ryuk, Conti, LockBit, etc. + add :family, :string, null: false + # specific variant or version + add :variant, :string + add :description, :text + add :first_seen, :utc_datetime, null: false + add :last_activity, :utc_datetime + # active, disrupted, dormant + add :status, :string, default: "active" + # .locked, .encrypted, etc. + add :file_extensions, {:array, :string}, default: [] + # HOW_TO_DECRYPT.txt + add :ransom_note_names, {:array, :string}, default: [] + # AES, RSA, etc. + add :encryption_algorithm, :string + # bitcoin, monero + add :payment_methods, {:array, :string}, default: [] + # average ransom amount in USD + add :average_ransom, :decimal + # email, rdp, web, etc. + add :attack_vectors, {:array, :string}, default: [] + add :target_sectors, {:array, :string}, default: [] + # threat actor or group + add :attribution, :string + # file hashes, C2 domains + add :iocs, {:array, :string}, default: [] + # MITRE techniques + add :ttps, {:array, :string}, default: [] + add :references, {:array, :string}, default: [] + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:ransomware_threats, [:user_id]) + create index(:ransomware_threats, [:family]) + create index(:ransomware_threats, [:status]) + create index(:ransomware_threats, [:first_seen]) + create unique_index(:ransomware_threats, [:user_id, :family, :variant]) + + # Junction table linking ransomware to indicators + create table(:ransomware_indicators) do + add :ransomware_id, references(:ransomware_threats, on_delete: :delete_all), null: false + add :indicator_id, references(:threat_indicators, on_delete: :delete_all), null: false + # payload, c2, dropper, lateral_movement + add :indicator_role, :string + + timestamps() + end + + create unique_index(:ransomware_indicators, [:ransomware_id, :indicator_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250817210439_add_cybersecurity_news.exs b/apps/elektrine/priv/repo/migrations/20250817210439_add_cybersecurity_news.exs new file mode 100644 index 0000000..1d7dda7 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250817210439_add_cybersecurity_news.exs @@ -0,0 +1,90 @@ +defmodule Elektrine.Repo.Migrations.AddCybersecurityNews do + use Ecto.Migration + + def change do + # Cybersecurity news articles + create table(:cybersecurity_news) do + add :title, :string, null: false + add :summary, :text + add :content, :text + add :url, :string, null: false + add :published_at, :utc_datetime, null: false + # "bleepingcomputer", "krebsonsecurity", etc. + add :source, :string, null: false + add :author, :string + # "data_breach", "malware", "ransomware", "vulnerability", etc. + add :category, :string + # "critical", "high", "medium", "low", "info" + add :severity, :string + add :tags, {:array, :string}, default: [] + # threat actors/malware mentioned + add :mentioned_threats, {:array, :string}, default: [] + # CVEs mentioned in article + add :mentioned_cves, {:array, :string}, default: [] + # companies mentioned + add :mentioned_companies, {:array, :string}, default: [] + # "positive", "negative", "neutral" + add :sentiment, :string + # how relevant to security operations (0.0-1.0) + add :relevance_score, :float + # unread, read, bookmarked + add :read_status, :string, default: "unread" + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:cybersecurity_news, [:user_id]) + create index(:cybersecurity_news, [:published_at]) + create index(:cybersecurity_news, [:source]) + create index(:cybersecurity_news, [:category]) + create index(:cybersecurity_news, [:severity]) + create index(:cybersecurity_news, [:read_status]) + create index(:cybersecurity_news, [:relevance_score]) + create unique_index(:cybersecurity_news, [:user_id, :url]) + + # News feeds configuration + create table(:news_feeds) do + add :name, :string, null: false + # RSS/API URL + add :url, :string, null: false + # "rss", "api", "json" + add :type, :string, null: false + add :description, :text + add :active, :boolean, default: true + # 30 minutes + add :update_frequency, :integer, default: 1800 + add :last_updated, :utc_datetime + add :last_sync_status, :string, default: "pending" + # feed-specific configuration + add :config, :map, default: %{} + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:news_feeds, [:user_id]) + create index(:news_feeds, [:type]) + create index(:news_feeds, [:active]) + create unique_index(:news_feeds, [:user_id, :name]) + + # News-threat correlations - link news articles to threat indicators + create table(:news_threat_correlations) do + # "mention", "analysis", "attribution" + add :correlation_type, :string, null: false + # correlation confidence + add :confidence, :float + # how they're related + add :context, :text + add :news_id, references(:cybersecurity_news, on_delete: :delete_all), null: false + add :indicator_id, references(:threat_indicators, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:news_threat_correlations, [:news_id]) + create index(:news_threat_correlations, [:indicator_id]) + create index(:news_threat_correlations, [:correlation_type]) + create unique_index(:news_threat_correlations, [:news_id, :indicator_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250817212722_add_underground_threat_intelligence.exs b/apps/elektrine/priv/repo/migrations/20250817212722_add_underground_threat_intelligence.exs new file mode 100644 index 0000000..97f8e60 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250817212722_add_underground_threat_intelligence.exs @@ -0,0 +1,305 @@ +defmodule Elektrine.Repo.Migrations.AddUndergroundThreatIntelligence do + use Ecto.Migration + + def change do + # Ransomware group tracking with .onion infrastructure and victims + create table(:ransomware_groups) do + # "LockBit", "BlackCat", etc. + add :name, :string, null: false + # other known names + add :aliases, {:array, :string}, default: [] + add :description, :text + add :first_seen, :utc_datetime, null: false + add :last_activity, :utc_datetime + # active, disrupted, dormant + add :status, :string, default: "active" + # .onion leak sites + add :onion_addresses, {:array, :string}, default: [] + # clearnet mirrors + add :clearnet_addresses, {:array, :string}, default: [] + # TOX, Jabber, etc. + add :contact_methods, {:array, :string}, default: [] + # crypto wallets + add :payment_addresses, {:array, :string}, default: [] + # AES, RSA, etc. + add :encryption_methods, {:array, :string}, default: [] + # .locked, .encrypted + add :file_extensions, {:array, :string}, default: [] + add :ransom_note_filenames, {:array, :string}, default: [] + add :target_countries, {:array, :string}, default: [] + add :target_sectors, {:array, :string}, default: [] + add :attack_vectors, {:array, :string}, default: [] + add :total_victims, :integer, default: 0 + # total ransom collected + add :estimated_revenue, :decimal + # average demand + add :average_ransom, :decimal + # MITRE techniques + add :ttps, {:array, :string}, default: [] + # threat actor attribution + add :attribution, :string + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:ransomware_groups, [:user_id]) + create index(:ransomware_groups, [:name]) + create index(:ransomware_groups, [:status]) + create index(:ransomware_groups, [:last_activity]) + create unique_index(:ransomware_groups, [:user_id, :name]) + + # Ransomware victims tracked from leak sites + create table(:ransomware_victims) do + add :organization_name, :string, null: false + add :domain, :string + add :sector, :string + add :country, :string + # small, medium, large, enterprise + add :victim_size, :string + add :attack_date, :date + add :leak_date, :date + # if known + add :ransom_amount, :decimal + # if known + add :paid_ransom, :boolean + # financial, personal, medical + add :data_types, {:array, :string}, default: [] + add :estimated_records, :bigint + # URL to leak post + add :leak_url, :string + # active, removed, expired + add :leak_status, :string, default: "active" + add :description, :text + + add :ransomware_group_id, references(:ransomware_groups, on_delete: :delete_all), + null: false + + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:ransomware_victims, [:user_id]) + create index(:ransomware_victims, [:ransomware_group_id]) + create index(:ransomware_victims, [:organization_name]) + create index(:ransomware_victims, [:sector]) + create index(:ransomware_victims, [:attack_date]) + create index(:ransomware_victims, [:leak_date]) + create unique_index(:ransomware_victims, [:user_id, :organization_name, :attack_date]) + + # Underground forums and markets (.onion sites) + create table(:underground_forums) do + add :name, :string, null: false + # "forum", "market", "service", "leak_site" + add :type, :string, null: false + add :onion_address, :string + add :clearnet_mirrors, {:array, :string}, default: [] + add :description, :text + add :language, :string, default: "en" + # active, offline, seized, unknown + add :status, :string, default: "active" + add :last_checked, :utc_datetime + add :registration_required, :boolean, default: true + # btc, xmr, etc. + add :payment_methods, {:array, :string}, default: [] + # 0.0-1.0 based on reliability + add :reputation_score, :float + # if known + add :member_count, :integer + # carding, malware, etc. + add :categories, {:array, :string}, default: [] + # low, medium, high, critical + add :threat_level, :string, default: "medium" + add :first_seen, :utc_datetime, null: false + add :tags, {:array, :string}, default: [] + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:underground_forums, [:user_id]) + create index(:underground_forums, [:type]) + create index(:underground_forums, [:status]) + create index(:underground_forums, [:threat_level]) + create index(:underground_forums, [:last_checked]) + create unique_index(:underground_forums, [:user_id, :name]) + + # Malware distribution servers and C2 infrastructure + create table(:malware_servers) do + add :ip_address, :string + add :domain, :string + add :url, :string + # "c2", "distribution", "panel", "gate" + add :server_type, :string, null: false + # associated malware + add :malware_family, :string + add :port, :integer + # http, https, tcp, udp + add :protocol, :string + # active, offline, sinkholed + add :status, :string, default: "active" + add :first_seen, :utc_datetime, null: false + add :last_seen, :utc_datetime + add :last_checked, :utc_datetime + add :hosting_provider, :string + add :country, :string + add :threat_level, :string, default: "high" + # confidence in assessment + add :confidence, :float + # malware samples count + add :samples_distributed, :integer, default: 0 + # bot count if C2 + add :infected_hosts, :integer, default: 0 + # SSL cert details if HTTPS + add :ssl_certificate, :text + # HTTP headers + add :response_headers, :map, default: %{} + add :notes, :text + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:malware_servers, [:user_id]) + create index(:malware_servers, [:ip_address]) + create index(:malware_servers, [:domain]) + create index(:malware_servers, [:server_type]) + create index(:malware_servers, [:malware_family]) + create index(:malware_servers, [:status]) + create index(:malware_servers, [:last_seen]) + create unique_index(:malware_servers, [:user_id, :ip_address, :port]) + + # Breached database intelligence + create table(:breached_databases) do + # database name/source + add :name, :string, null: false + add :organization, :string + add :total_records, :bigint, null: false + add :breach_date, :date + # when data appeared underground + add :leak_date, :date + # when indexed by criminals + add :indexed_date, :date + # emails, passwords, etc. + add :data_types, {:array, :string}, default: [] + # sql, csv, txt, json + add :file_format, :string + # in bytes + add :file_size, :bigint + # plaintext, md5, bcrypt, etc. + add :password_format, :string + # selling price if commercial + add :price, :decimal + # usd, btc, etc. + add :currency, :string + # underground seller name + add :seller, :string + # where it's being sold + add :marketplace, :string + # sample data for verification + add :sample_records, :text + # verified, unverified, fake + add :verification_status, :string, default: "unverified" + # times downloaded + add :download_count, :integer, default: 0 + add :sectors, {:array, :string}, default: [] + add :countries, {:array, :string}, default: [] + add :severity, :string, default: "high" + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:breached_databases, [:user_id]) + create index(:breached_databases, [:organization]) + create index(:breached_databases, [:breach_date]) + create index(:breached_databases, [:leak_date]) + create index(:breached_databases, [:indexed_date]) + create index(:breached_databases, [:verification_status]) + create index(:breached_databases, [:severity]) + create unique_index(:breached_databases, [:user_id, :name, :breach_date]) + + # Malicious Telegram channels monitoring + create table(:telegram_channels) do + # @channelname + add :channel_name, :string, null: false + # telegram channel ID + add :channel_id, :string + # t.me/channelname + add :channel_link, :string + # display title + add :title, :string + add :description, :text + # "ransomware", "malware", "carding", "dumps" + add :channel_type, :string, null: false + add :language, :string, default: "en" + add :member_count, :integer + # active, banned, private, deleted + add :status, :string, default: "active" + add :first_seen, :utc_datetime, null: false + add :last_activity, :utc_datetime + add :last_checked, :utc_datetime + # "high", "medium", "low" + add :post_frequency, :string + add :threat_level, :string, default: "medium" + # ransomware groups + add :associated_groups, {:array, :string}, default: [] + # what they discuss + add :topics, {:array, :string}, default: [] + # verified, unverified + add :verification_status, :string, default: "unverified" + add :admin_usernames, {:array, :string}, default: [] + add :related_channels, {:array, :string}, default: [] + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:telegram_channels, [:user_id]) + create index(:telegram_channels, [:channel_name]) + create index(:telegram_channels, [:channel_type]) + create index(:telegram_channels, [:status]) + create index(:telegram_channels, [:threat_level]) + create index(:telegram_channels, [:last_activity]) + create unique_index(:telegram_channels, [:user_id, :channel_name]) + + # Telegram message logs for threat intelligence + create table(:telegram_messages) do + # telegram message ID + add :message_id, :string, null: false + add :sender_username, :string + add :message_text, :text + add :message_date, :utc_datetime, null: false + # photo, document, video, etc. + add :media_type, :string + # if media file + add :file_hash, :string + # extracted keywords + add :threat_keywords, {:array, :string}, default: [] + # ransomware groups mentioned + add :mentioned_groups, {:array, :string}, default: [] + # malware mentioned + add :mentioned_malware, {:array, :string}, default: [] + # URLs in message + add :urls_extracted, {:array, :string}, default: [] + # crypto wallets mentioned + add :crypto_addresses, {:array, :string}, default: [] + # positive, negative, neutral + add :sentiment, :string + # calculated threat relevance + add :threat_score, :float + add :channel_id, references(:telegram_channels, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:telegram_messages, [:user_id]) + create index(:telegram_messages, [:channel_id]) + create index(:telegram_messages, [:message_date]) + create index(:telegram_messages, [:sender_username]) + create index(:telegram_messages, [:threat_score]) + create unique_index(:telegram_messages, [:channel_id, :message_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250902065912_create_subscriptions.exs b/apps/elektrine/priv/repo/migrations/20250902065912_create_subscriptions.exs new file mode 100644 index 0000000..cf42fdd --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250902065912_create_subscriptions.exs @@ -0,0 +1,19 @@ +defmodule Elektrine.Repo.Migrations.CreateSubscriptions do + use Ecto.Migration + + def change do + create table(:subscriptions) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :plan, :string, null: false + add :status, :string, null: false, default: "active" + add :expires_at, :utc_datetime, null: false + + timestamps() + end + + create index(:subscriptions, [:user_id]) + create index(:subscriptions, [:status]) + create index(:subscriptions, [:expires_at]) + create index(:subscriptions, [:user_id, :status]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250903172225_create_user_profiles_and_links.exs b/apps/elektrine/priv/repo/migrations/20250903172225_create_user_profiles_and_links.exs new file mode 100644 index 0000000..9051dd7 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250903172225_create_user_profiles_and_links.exs @@ -0,0 +1,50 @@ +defmodule Elektrine.Repo.Migrations.CreateUserProfilesAndLinks do + use Ecto.Migration + + def change do + create table(:user_profiles) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :display_name, :string + add :description, :text + add :custom_css, :text + add :theme, :string, default: "purple" + add :accent_color, :string, default: "#8b5cf6" + add :font_family, :string, default: "Inter" + add :cursor_style, :string, default: "default" + add :avatar_url, :string + add :banner_url, :string + add :background_url, :string + add :background_type, :string, default: "gradient" + add :music_url, :string + add :music_title, :string + add :discord_user_id, :string + add :show_discord_presence, :boolean, default: false + add :is_public, :boolean, default: true + add :page_views, :integer, default: 0 + + timestamps() + end + + create table(:profile_links) do + add :profile_id, references(:user_profiles, on_delete: :delete_all), null: false + add :title, :string, null: false + add :url, :string, null: false + add :description, :string + add :icon, :string, default: "hero-link" + # "twitter", "instagram", "github", etc. + add :platform, :string + add :position, :integer, default: 0 + add :clicks, :integer, default: 0 + add :is_active, :boolean, default: true + # Custom CSS for this specific link + add :custom_style, :text + + timestamps() + end + + create unique_index(:user_profiles, [:user_id]) + create index(:profile_links, [:profile_id]) + create index(:profile_links, [:position]) + create index(:profile_links, [:is_active]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250904070740_add_location_to_user_profiles.exs b/apps/elektrine/priv/repo/migrations/20250904070740_add_location_to_user_profiles.exs new file mode 100644 index 0000000..a907f61 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250904070740_add_location_to_user_profiles.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddLocationToUserProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :location, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250904171431_add_favicon_and_title_to_profiles.exs b/apps/elektrine/priv/repo/migrations/20250904171431_add_favicon_and_title_to_profiles.exs new file mode 100644 index 0000000..9c2c15e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250904171431_add_favicon_and_title_to_profiles.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.AddFaviconAndTitleToProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :page_title, :string + add :favicon_url, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250905054459_add_advanced_customization_to_profiles.exs b/apps/elektrine/priv/repo/migrations/20250905054459_add_advanced_customization_to_profiles.exs new file mode 100644 index 0000000..0009f1e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250905054459_add_advanced_customization_to_profiles.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.AddAdvancedCustomizationToProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :text_color, :string, default: "#ffffff" + add :background_color, :string, default: "#000000" + add :icon_color, :string, default: "#ffffff" + add :profile_opacity, :float, default: 1.0 + add :profile_blur, :integer, default: 0 + add :monochrome_icons, :boolean, default: false + add :animated_title, :boolean, default: false + add :volume_control, :boolean, default: false + add :use_discord_avatar, :boolean, default: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250905060932_add_ip_tracking_to_users.exs b/apps/elektrine/priv/repo/migrations/20250905060932_add_ip_tracking_to_users.exs new file mode 100644 index 0000000..5b54807 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250905060932_add_ip_tracking_to_users.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.AddIpTrackingToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :registration_ip, :text + add :last_login_ip, :text + add :last_login_at, :utc_datetime + add :login_count, :integer, default: 0 + end + + # Create indexes for better performance on IP lookups + create index(:users, [:registration_ip]) + create index(:users, [:last_login_ip]) + create index(:users, [:last_login_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250906055118_create_platform_updates.exs b/apps/elektrine/priv/repo/migrations/20250906055118_create_platform_updates.exs new file mode 100644 index 0000000..fba658e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250906055118_create_platform_updates.exs @@ -0,0 +1,19 @@ +defmodule Elektrine.Repo.Migrations.CreatePlatformUpdates do + use Ecto.Migration + + def change do + create table(:platform_updates) do + add :title, :string, null: false + add :description, :text + add :badge, :string + add :items, {:array, :string}, default: [] + add :published, :boolean, default: true + add :created_by_id, references(:users, on_delete: :nilify_all) + + timestamps() + end + + create index(:platform_updates, [:created_by_id]) + create index(:platform_updates, [:published, :inserted_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250906081641_create_audit_logs.exs b/apps/elektrine/priv/repo/migrations/20250906081641_create_audit_logs.exs new file mode 100644 index 0000000..0e5786f --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250906081641_create_audit_logs.exs @@ -0,0 +1,25 @@ +defmodule Elektrine.Repo.Migrations.CreateAuditLogs do + use Ecto.Migration + + def change do + create table(:audit_logs) do + add :admin_id, references(:users, on_delete: :nilify_all), null: false + add :target_user_id, references(:users, on_delete: :nilify_all) + add :action, :string, null: false + add :resource_type, :string, null: false + add :resource_id, :integer + add :details, :map + add :ip_address, :string + add :user_agent, :string + + timestamps() + end + + create index(:audit_logs, [:admin_id]) + create index(:audit_logs, [:target_user_id]) + create index(:audit_logs, [:action]) + create index(:audit_logs, [:resource_type]) + create index(:audit_logs, [:inserted_at]) + create index(:audit_logs, [:admin_id, :inserted_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250907053034_create_announcements.exs b/apps/elektrine/priv/repo/migrations/20250907053034_create_announcements.exs new file mode 100644 index 0000000..73eaa10 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250907053034_create_announcements.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Repo.Migrations.CreateAnnouncements do + use Ecto.Migration + + def change do + create table(:announcements) do + add :title, :string, null: false + add :content, :text, null: false + add :type, :string, null: false, default: "info" + add :starts_at, :utc_datetime + add :ends_at, :utc_datetime + add :active, :boolean, default: true, null: false + add :created_by_id, references(:users, on_delete: :nilify_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:announcements, [:created_by_id]) + create index(:announcements, [:active]) + create index(:announcements, [:starts_at]) + create index(:announcements, [:ends_at]) + create index(:announcements, [:type]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250907074626_create_announcement_dismissals.exs b/apps/elektrine/priv/repo/migrations/20250907074626_create_announcement_dismissals.exs new file mode 100644 index 0000000..71be195 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250907074626_create_announcement_dismissals.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.CreateAnnouncementDismissals do + use Ecto.Migration + + def change do + create table(:announcement_dismissals) do + add :dismissed_at, :utc_datetime, null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + add :announcement_id, references(:announcements, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:announcement_dismissals, [:user_id]) + create index(:announcement_dismissals, [:announcement_id]) + create unique_index(:announcement_dismissals, [:user_id, :announcement_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250907193037_add_display_name_to_users.exs b/apps/elektrine/priv/repo/migrations/20250907193037_add_display_name_to_users.exs new file mode 100644 index 0000000..a91488d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250907193037_add_display_name_to_users.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddDisplayNameToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :display_name, :string, size: 100 + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250907232526_add_recovery_email_to_users.exs b/apps/elektrine/priv/repo/migrations/20250907232526_add_recovery_email_to_users.exs new file mode 100644 index 0000000..b67175f --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250907232526_add_recovery_email_to_users.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.AddRecoveryEmailToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :recovery_email, :string + add :password_reset_token, :string + add :password_reset_token_expires_at, :utc_datetime + end + + create index(:users, [:password_reset_token], unique: true) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250909041315_add_locale_to_users.exs b/apps/elektrine/priv/repo/migrations/20250909041315_add_locale_to_users.exs new file mode 100644 index 0000000..a4dec20 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250909041315_add_locale_to_users.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddLocaleToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :locale, :string, size: 5, default: "en" + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250909163235_remove_temporary_mailbox_system.exs b/apps/elektrine/priv/repo/migrations/20250909163235_remove_temporary_mailbox_system.exs new file mode 100644 index 0000000..5e1760e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250909163235_remove_temporary_mailbox_system.exs @@ -0,0 +1,52 @@ +defmodule Elektrine.Repo.Migrations.RemoveTemporaryMailboxSystem do + use Ecto.Migration + + def up do + # First, clean up any messages marked as temporary + execute """ + DELETE FROM email_messages + WHERE mailbox_type = 'temporary' + OR metadata->>'temporary' = 'true'; + """ + + # Drop the temporary_mailboxes table + drop_if_exists table(:temporary_mailboxes) + + # Remove temporary column from mailboxes table if it exists + alter table(:mailboxes) do + remove_if_exists :temporary, :boolean + end + + # Remove mailbox_type column from messages table if it exists + alter table(:email_messages) do + remove_if_exists :mailbox_type, :string + end + end + + def down do + # Recreate temporary_mailboxes table + create table(:temporary_mailboxes) do + add :email, :string, null: false + add :token, :string, null: false + add :expires_at, :utc_datetime, null: false + add :user_id, references(:users, on_delete: :delete_all) + + timestamps(type: :utc_datetime) + end + + create unique_index(:temporary_mailboxes, [:email]) + create unique_index(:temporary_mailboxes, [:token]) + create index(:temporary_mailboxes, [:user_id]) + create index(:temporary_mailboxes, [:expires_at]) + + # Add back temporary column to mailboxes + alter table(:mailboxes) do + add_if_not_exists :temporary, :boolean, default: false + end + + # Add back mailbox_type to messages + alter table(:email_messages) do + add_if_not_exists :mailbox_type, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250911070737_create_username_history.exs b/apps/elektrine/priv/repo/migrations/20250911070737_create_username_history.exs new file mode 100644 index 0000000..4c36539 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250911070737_create_username_history.exs @@ -0,0 +1,21 @@ +defmodule Elektrine.Repo.Migrations.CreateUsernameHistory do + use Ecto.Migration + + def change do + create table(:username_history) do + add :username, :string, null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + add :changed_at, :utc_datetime, null: false + add :previous_username, :string + + timestamps(type: :utc_datetime) + end + + # Index for checking username availability + create index(:username_history, [:username, :changed_at]) + create index(:username_history, [:user_id, :changed_at]) + + # Unique constraint to prevent duplicate entries + create unique_index(:username_history, [:username, :user_id, :changed_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250912165836_add_storage_tracking_to_mailboxes.exs b/apps/elektrine/priv/repo/migrations/20250912165836_add_storage_tracking_to_mailboxes.exs new file mode 100644 index 0000000..1f4489e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250912165836_add_storage_tracking_to_mailboxes.exs @@ -0,0 +1,14 @@ +defmodule Elektrine.Repo.Migrations.AddStorageTrackingToMailboxes do + use Ecto.Migration + + def change do + alter table(:mailboxes) do + add :storage_used_bytes, :bigint, default: 0, null: false + # 5MB = 5 * 1024 * 1024 bytes + add :storage_limit_bytes, :bigint, default: 5_242_880, null: false + end + + # Add index for efficient querying of storage usage + create index(:mailboxes, [:storage_used_bytes]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250912173431_update_storage_limit_to_5mb.exs b/apps/elektrine/priv/repo/migrations/20250912173431_update_storage_limit_to_5mb.exs new file mode 100644 index 0000000..6b3afeb --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250912173431_update_storage_limit_to_5mb.exs @@ -0,0 +1,8 @@ +defmodule Elektrine.Repo.Migrations.UpdateStorageLimitTo5mb do + use Ecto.Migration + + def change do + # Update all existing mailboxes to have 5MB storage limit + execute "UPDATE mailboxes SET storage_limit_bytes = 5242880 WHERE storage_limit_bytes = 2097152" + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250913160643_remove_bluesky_from_users.exs b/apps/elektrine/priv/repo/migrations/20250913160643_remove_bluesky_from_users.exs new file mode 100644 index 0000000..ddaa7d6 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250913160643_remove_bluesky_from_users.exs @@ -0,0 +1,18 @@ +defmodule Elektrine.Repo.Migrations.RemoveBlueskyFromUsers do + use Ecto.Migration + + def change do + drop index(:users, [:bluesky_did]) + drop index(:users, [:bluesky_handle]) + + alter table(:users) do + remove :bluesky_did, :string + remove :bluesky_handle, :string + remove :bluesky_access_token, :text + remove :bluesky_refresh_token, :text + remove :bluesky_token_expires_at, :utc_datetime + remove :bluesky_dpop_key, :text + remove :bluesky_connected_at, :utc_datetime + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250913163916_remove_custom_css_from_profiles.exs b/apps/elektrine/priv/repo/migrations/20250913163916_remove_custom_css_from_profiles.exs new file mode 100644 index 0000000..a9bb232 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250913163916_remove_custom_css_from_profiles.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.RemoveCustomCssFromProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + remove :custom_css + end + + alter table(:profile_links) do + remove :custom_style + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250914003149_add_privacy_settings_to_profiles.exs b/apps/elektrine/priv/repo/migrations/20250914003149_add_privacy_settings_to_profiles.exs new file mode 100644 index 0000000..6ed1ea0 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250914003149_add_privacy_settings_to_profiles.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.AddPrivacySettingsToProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :hide_view_counter, :boolean, default: false + add :hide_uid, :boolean, default: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250914005156_create_messaging_system.exs b/apps/elektrine/priv/repo/migrations/20250914005156_create_messaging_system.exs new file mode 100644 index 0000000..f2ecd7a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250914005156_create_messaging_system.exs @@ -0,0 +1,85 @@ +defmodule Elektrine.Repo.Migrations.CreateMessagingSystem do + use Ecto.Migration + + def change do + # Conversations table - supports DMs, group chats, and channels + create table(:conversations) do + # null for DMs, set for groups/channels + add :name, :string + # for groups/channels + add :description, :text + # "dm", "group", "channel" + add :type, :string, null: false + add :creator_id, references(:users, on_delete: :nilify_all) + # for groups/channels + add :avatar_url, :string + # for channels + add :is_public, :boolean, default: false + add :member_count, :integer, default: 0 + add :last_message_at, :utc_datetime + add :archived, :boolean, default: false + + timestamps() + end + + # Conversation participants/members + create table(:conversation_members) do + add :conversation_id, references(:conversations, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + # "admin", "member", "readonly" (for channels) + add :role, :string, default: "member" + add :joined_at, :utc_datetime, default: fragment("now()") + # null if still member + add :left_at, :utc_datetime + add :last_read_at, :utc_datetime + add :notifications_enabled, :boolean, default: true + add :pinned, :boolean, default: false + + timestamps() + end + + # Messages table + create table(:messages) do + add :conversation_id, references(:conversations, on_delete: :delete_all), null: false + add :sender_id, references(:users, on_delete: :nilify_all), null: false + add :content, :text + # "text", "image", "file", "system" + add :message_type, :string, default: "text" + add :media_urls, {:array, :string}, default: [] + add :reply_to_id, references(:messages, on_delete: :nilify_all) + add :edited_at, :utc_datetime + add :deleted_at, :utc_datetime + + timestamps() + end + + # Message reactions + create table(:message_reactions) do + add :message_id, references(:messages, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + add :emoji, :string, null: false + + timestamps() + end + + # Indexes for performance + create index(:conversations, [:type]) + create index(:conversations, [:creator_id]) + create index(:conversations, [:last_message_at]) + # for public channels + create index(:conversations, [:is_public]) + + create unique_index(:conversation_members, [:conversation_id, :user_id]) + create index(:conversation_members, [:user_id]) + create index(:conversation_members, [:conversation_id, :left_at]) + + create index(:messages, [:conversation_id]) + create index(:messages, [:sender_id]) + create index(:messages, [:inserted_at]) + create index(:messages, [:reply_to_id]) + create index(:messages, [:conversation_id, :inserted_at]) + + create unique_index(:message_reactions, [:message_id, :user_id, :emoji]) + create index(:message_reactions, [:message_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250914031911_add_hidden_messages.exs b/apps/elektrine/priv/repo/migrations/20250914031911_add_hidden_messages.exs new file mode 100644 index 0000000..6991f88 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250914031911_add_hidden_messages.exs @@ -0,0 +1,18 @@ +defmodule Elektrine.Repo.Migrations.AddHiddenMessages do + use Ecto.Migration + + def change do + # Table to track which messages are hidden for specific users + create table(:user_hidden_messages) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :message_id, references(:messages, on_delete: :delete_all), null: false + add :hidden_at, :utc_datetime, default: fragment("now()") + + timestamps() + end + + create unique_index(:user_hidden_messages, [:user_id, :message_id]) + create index(:user_hidden_messages, [:user_id]) + create index(:user_hidden_messages, [:message_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250914073452_add_hash_to_conversations.exs b/apps/elektrine/priv/repo/migrations/20250914073452_add_hash_to_conversations.exs new file mode 100644 index 0000000..f8d22d5 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250914073452_add_hash_to_conversations.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddHashToConversations do + use Ecto.Migration + + def change do + alter table(:conversations) do + add :hash, :string, size: 32 + end + + create unique_index(:conversations, [:hash]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250914073519_add_hash_to_email_messages.exs b/apps/elektrine/priv/repo/migrations/20250914073519_add_hash_to_email_messages.exs new file mode 100644 index 0000000..ef2b945 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250914073519_add_hash_to_email_messages.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddHashToEmailMessages do + use Ecto.Migration + + def change do + alter table(:email_messages) do + add :hash, :string, size: 32 + end + + create unique_index(:email_messages, [:hash]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250914074416_populate_existing_hashes.exs b/apps/elektrine/priv/repo/migrations/20250914074416_populate_existing_hashes.exs new file mode 100644 index 0000000..1168210 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250914074416_populate_existing_hashes.exs @@ -0,0 +1,28 @@ +defmodule Elektrine.Repo.Migrations.PopulateExistingHashes do + use Ecto.Migration + import Ecto.Query + + def up do + # Populate conversation hashes + conversations = + from(c in "conversations", where: is_nil(c.hash), select: c.id) |> repo().all() + + for id <- conversations do + hash = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) + repo().update_all(from(c in "conversations", where: c.id == ^id), set: [hash: hash]) + end + + # Populate email message hashes + messages = from(m in "email_messages", where: is_nil(m.hash), select: m.id) |> repo().all() + + for id <- messages do + hash = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) + repo().update_all(from(m in "email_messages", where: m.id == ^id), set: [hash: hash]) + end + end + + def down do + repo().update_all("conversations", set: [hash: nil]) + repo().update_all("email_messages", set: [hash: nil]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250914081434_remove_username_change_fields.exs b/apps/elektrine/priv/repo/migrations/20250914081434_remove_username_change_fields.exs new file mode 100644 index 0000000..a925317 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250914081434_remove_username_change_fields.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.RemoveUsernameChangeFields do + use Ecto.Migration + + def change do + alter table(:users) do + remove :last_username_change_at + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250914091730_add_username_to_mailboxes.exs b/apps/elektrine/priv/repo/migrations/20250914091730_add_username_to_mailboxes.exs new file mode 100644 index 0000000..27bd588 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250914091730_add_username_to_mailboxes.exs @@ -0,0 +1,26 @@ +defmodule Elektrine.Repo.Migrations.AddUsernameToMailboxes do + use Ecto.Migration + + def up do + # Add username field + alter table(:mailboxes) do + add :username, :string + end + + # Populate username from email field for existing mailboxes + execute """ + UPDATE mailboxes + SET username = split_part(email, '@', 1) + WHERE username IS NULL AND email IS NOT NULL + """ + + # Create index on username for lookups + create index(:mailboxes, [:username]) + end + + def down do + alter table(:mailboxes) do + remove :username + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250914172837_create_user_blocks.exs b/apps/elektrine/priv/repo/migrations/20250914172837_create_user_blocks.exs new file mode 100644 index 0000000..50dabf3 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250914172837_create_user_blocks.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.CreateUserBlocks do + use Ecto.Migration + + def change do + create table(:user_blocks) do + add :blocker_id, references(:users, on_delete: :delete_all), null: false + add :blocked_id, references(:users, on_delete: :delete_all), null: false + add :reason, :string + + timestamps() + end + + create unique_index(:user_blocks, [:blocker_id, :blocked_id]) + create index(:user_blocks, [:blocker_id]) + create index(:user_blocks, [:blocked_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250915010911_add_last_password_change_to_users.exs b/apps/elektrine/priv/repo/migrations/20250915010911_add_last_password_change_to_users.exs new file mode 100644 index 0000000..dda35a9 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250915010911_add_last_password_change_to_users.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddLastPasswordChangeToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :last_password_change, :utc_datetime + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250915011003_add_index_to_last_password_change.exs b/apps/elektrine/priv/repo/migrations/20250915011003_add_index_to_last_password_change.exs new file mode 100644 index 0000000..b471299 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250915011003_add_index_to_last_password_change.exs @@ -0,0 +1,7 @@ +defmodule Elektrine.Repo.Migrations.AddIndexToLastPasswordChange do + use Ecto.Migration + + def change do + create index(:users, [:last_password_change]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250915070107_add_moderation_system.exs b/apps/elektrine/priv/repo/migrations/20250915070107_add_moderation_system.exs new file mode 100644 index 0000000..fcae1be --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250915070107_add_moderation_system.exs @@ -0,0 +1,36 @@ +defmodule Elektrine.Repo.Migrations.AddModerationSystem do + use Ecto.Migration + + def change do + create table(:user_timeouts) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :conversation_id, references(:conversations, on_delete: :delete_all), null: true + add :timeout_until, :utc_datetime, null: false + add :reason, :string + add :created_by_id, references(:users, on_delete: :nilify_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:user_timeouts, [:user_id]) + create index(:user_timeouts, [:conversation_id]) + create index(:user_timeouts, [:timeout_until]) + + create table(:moderation_actions) do + add :action_type, :string, null: false + add :target_user_id, references(:users, on_delete: :delete_all), null: false + add :moderator_id, references(:users, on_delete: :delete_all), null: false + add :conversation_id, references(:conversations, on_delete: :delete_all), null: true + add :reason, :string + add :duration, :integer + add :details, :map + + timestamps(type: :utc_datetime) + end + + create index(:moderation_actions, [:target_user_id]) + create index(:moderation_actions, [:moderator_id]) + create index(:moderation_actions, [:conversation_id]) + create index(:moderation_actions, [:action_type]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250916052139_update_storage_limit_to_500mb.exs b/apps/elektrine/priv/repo/migrations/20250916052139_update_storage_limit_to_500mb.exs new file mode 100644 index 0000000..3ea895b --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250916052139_update_storage_limit_to_500mb.exs @@ -0,0 +1,8 @@ +defmodule Elektrine.Repo.Migrations.UpdateStorageLimitTo500mb do + use Ecto.Migration + + def change do + # Update existing mailboxes to 500MB limit (500 * 1024 * 1024 = 524288000 bytes) + execute "UPDATE mailboxes SET storage_limit_bytes = 524288000 WHERE storage_limit_bytes = 5242880" + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250916171831_create_follows.exs b/apps/elektrine/priv/repo/migrations/20250916171831_create_follows.exs new file mode 100644 index 0000000..603fc8f --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250916171831_create_follows.exs @@ -0,0 +1,16 @@ +defmodule Elektrine.Repo.Migrations.CreateFollows do + use Ecto.Migration + + def change do + create table(:follows) do + add :follower_id, references(:users, on_delete: :delete_all), null: false + add :followed_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:follows, [:follower_id]) + create index(:follows, [:followed_id]) + create unique_index(:follows, [:follower_id, :followed_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250918055140_create_reports.exs b/apps/elektrine/priv/repo/migrations/20250918055140_create_reports.exs new file mode 100644 index 0000000..ef6fc21 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250918055140_create_reports.exs @@ -0,0 +1,52 @@ +defmodule Elektrine.Repo.Migrations.CreateReports do + use Ecto.Migration + + def change do + create table(:reports) do + # Reporter info + add :reporter_id, references(:users, on_delete: :delete_all), null: false + + # What is being reported (polymorphic) + # "user", "message", "conversation", "post", etc. + add :reportable_type, :string, null: false + add :reportable_id, :integer, null: false + + # Report details + # "spam", "harassment", "inappropriate", "violence", "hate_speech", "other" + add :reason, :string, null: false + add :description, :text + add :screenshots, {:array, :string}, default: [] + + # Status tracking + # "pending", "reviewing", "resolved", "dismissed" + add :status, :string, default: "pending" + # "low", "normal", "high", "critical" + add :priority, :string, default: "normal" + + # Admin handling + add :reviewed_by_id, references(:users, on_delete: :nilify_all) + add :reviewed_at, :utc_datetime + add :resolution_notes, :text + # "warned", "suspended", "banned", "content_removed", "no_action" + add :action_taken, :string + + # Additional metadata + # Store additional context like URLs, conversation IDs, etc. + add :metadata, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + create index(:reports, [:reporter_id]) + create index(:reports, [:reportable_type, :reportable_id]) + create index(:reports, [:status]) + create index(:reports, [:priority]) + create index(:reports, [:reviewed_by_id]) + + # Prevent duplicate reports from same user for same content + create unique_index(:reports, [:reporter_id, :reportable_type, :reportable_id, :status], + name: :reports_unique_pending_index, + where: "status = 'pending'" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250918063443_add_suspension_to_users.exs b/apps/elektrine/priv/repo/migrations/20250918063443_add_suspension_to_users.exs new file mode 100644 index 0000000..a289be2 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250918063443_add_suspension_to_users.exs @@ -0,0 +1,15 @@ +defmodule Elektrine.Repo.Migrations.AddSuspensionToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :suspended, :boolean, default: false, null: false + add :suspended_until, :utc_datetime + add :suspension_reason, :text + end + + # Add indexes for quick lookups + create index(:users, [:suspended]) + create index(:users, [:suspended_until]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250918165958_add_social_features_to_messages.exs b/apps/elektrine/priv/repo/migrations/20250918165958_add_social_features_to_messages.exs new file mode 100644 index 0000000..58c70bd --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250918165958_add_social_features_to_messages.exs @@ -0,0 +1,45 @@ +defmodule Elektrine.Repo.Migrations.AddSocialFeaturesToMessages do + use Ecto.Migration + + def change do + # Extend messages table for timeline posts and social features + alter table(:messages) do + add :visibility, :string, default: "conversation" + add :post_type, :string, default: "message" + add :like_count, :integer, default: 0 + add :reply_count, :integer, default: 0 + add :share_count, :integer, default: 0 + end + + # Extend conversations table for community features + alter table(:conversations) do + add :space_type, :string, default: "chat" + add :community_category, :string + add :allow_public_posts, :boolean, default: false + add :discussion_style, :string, default: "chat" + end + + # Create user follows table + create table(:user_follows) do + add :follower_id, references(:users, on_delete: :delete_all), null: false + add :followed_id, references(:users, on_delete: :delete_all), null: false + add :created_at, :utc_datetime, default: fragment("NOW()"), null: false + end + + # Create post likes table + create table(:post_likes) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :message_id, references(:messages, on_delete: :delete_all), null: false + add :created_at, :utc_datetime, default: fragment("NOW()"), null: false + end + + # Add indexes for performance + create unique_index(:user_follows, [:follower_id, :followed_id]) + create unique_index(:post_likes, [:user_id, :message_id]) + create index(:messages, [:visibility]) + create index(:messages, [:post_type]) + create index(:messages, [:like_count]) + create index(:conversations, [:space_type]) + create index(:conversations, [:community_category]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250918173523_remove_duplicate_user_follows_table.exs b/apps/elektrine/priv/repo/migrations/20250918173523_remove_duplicate_user_follows_table.exs new file mode 100644 index 0000000..b7c6a01 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250918173523_remove_duplicate_user_follows_table.exs @@ -0,0 +1,8 @@ +defmodule Elektrine.Repo.Migrations.RemoveDuplicateUserFollowsTable do + use Ecto.Migration + + def change do + # Remove the duplicate user_follows table since we're using the existing follows table + drop table(:user_follows) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250919043723_add_link_previews.exs b/apps/elektrine/priv/repo/migrations/20250919043723_add_link_previews.exs new file mode 100644 index 0000000..ee6dccb --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250919043723_add_link_previews.exs @@ -0,0 +1,30 @@ +defmodule Elektrine.Repo.Migrations.AddLinkPreviews do + use Ecto.Migration + + def change do + create table(:link_previews) do + add :url, :text, null: false + add :title, :string + add :description, :text + add :image_url, :string + add :site_name, :string + add :favicon_url, :string + add :status, :string, default: "pending" + add :error_message, :text + add :fetched_at, :utc_datetime + + timestamps() + end + + # Add link preview association to messages + alter table(:messages) do + add :link_preview_id, references(:link_previews, on_delete: :nilify_all) + add :extracted_urls, {:array, :string}, default: [] + end + + # Add indexes for performance + create unique_index(:link_previews, [:url]) + create index(:link_previews, [:status]) + create index(:messages, [:link_preview_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250919050445_add_hashtag_system.exs b/apps/elektrine/priv/repo/migrations/20250919050445_add_hashtag_system.exs new file mode 100644 index 0000000..f2cc0b9 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250919050445_add_hashtag_system.exs @@ -0,0 +1,37 @@ +defmodule Elektrine.Repo.Migrations.AddHashtagSystem do + use Ecto.Migration + + def change do + # Create hashtags table + create table(:hashtags) do + add :name, :string, null: false + # lowercase for consistent lookup + add :normalized_name, :string, null: false + add :use_count, :integer, default: 0 + add :last_used_at, :utc_datetime + + timestamps() + end + + # Create post_hashtags join table + create table(:post_hashtags) do + add :message_id, references(:messages, on_delete: :delete_all), null: false + add :hashtag_id, references(:hashtags, on_delete: :delete_all), null: false + + timestamps(updated_at: false) + end + + # Add hashtag fields to messages + alter table(:messages) do + add :extracted_hashtags, {:array, :string}, default: [] + end + + # Add indexes for performance + create unique_index(:hashtags, [:normalized_name]) + create index(:hashtags, [:use_count]) + create index(:hashtags, [:last_used_at]) + create unique_index(:post_hashtags, [:message_id, :hashtag_id]) + create index(:post_hashtags, [:hashtag_id]) + create index(:messages, [:extracted_hashtags]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250919124426_add_discussion_voting_system.exs b/apps/elektrine/priv/repo/migrations/20250919124426_add_discussion_voting_system.exs new file mode 100644 index 0000000..69851fe --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250919124426_add_discussion_voting_system.exs @@ -0,0 +1,30 @@ +defmodule Elektrine.Repo.Migrations.AddDiscussionVotingSystem do + use Ecto.Migration + + def change do + # Add voting fields to messages for discussion posts + alter table(:messages) do + add :upvotes, :integer, default: 0 + add :downvotes, :integer, default: 0 + # upvotes - downvotes + add :score, :integer, default: 0 + end + + # Create message votes table for tracking individual votes + create table(:message_votes) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :message_id, references(:messages, on_delete: :delete_all), null: false + # "up" or "down" + add :vote_type, :string, null: false + + timestamps(updated_at: false) + end + + # Add indexes for performance + create unique_index(:message_votes, [:user_id, :message_id]) + create index(:message_votes, [:message_id]) + create index(:message_votes, [:vote_type]) + create index(:messages, [:score]) + create index(:messages, [:upvotes]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250919130802_add_cross_context_promotion_fields.exs b/apps/elektrine/priv/repo/migrations/20250919130802_add_cross_context_promotion_fields.exs new file mode 100644 index 0000000..07cee76 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250919130802_add_cross_context_promotion_fields.exs @@ -0,0 +1,31 @@ +defmodule Elektrine.Repo.Migrations.AddCrossContextPromotionFields do + use Ecto.Migration + + def change do + alter table(:messages) do + # Cross-context promotion tracking + add :original_message_id, references(:messages, on_delete: :nilify_all) + add :shared_message_id, references(:messages, on_delete: :nilify_all) + # "chat", "timeline", "dm", etc. + add :promoted_from, :string + # "dm_share", "discussion_share", "timeline_reshare", etc. + add :share_type, :string + end + + # Update post_type validation to include new types + execute( + "ALTER TABLE messages DROP CONSTRAINT IF EXISTS messages_post_type_check", + "ALTER TABLE messages ADD CONSTRAINT messages_post_type_check CHECK (post_type IN ('message', 'post', 'comment', 'share', 'discussion'))" + ) + + execute( + "ALTER TABLE messages ADD CONSTRAINT messages_post_type_check CHECK (post_type IN ('message', 'post', 'comment', 'share', 'discussion'))", + "ALTER TABLE messages DROP CONSTRAINT messages_post_type_check" + ) + + # Add index for efficient cross-context queries + create index(:messages, [:original_message_id]) + create index(:messages, [:shared_message_id]) + create index(:messages, [:promoted_from]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250919132803_add_titles_to_messages.exs b/apps/elektrine/priv/repo/migrations/20250919132803_add_titles_to_messages.exs new file mode 100644 index 0000000..6887ce9 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250919132803_add_titles_to_messages.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.AddTitlesToMessages do + use Ecto.Migration + + def change do + alter table(:messages) do + add :title, :string + # true if title was auto-extracted from link + add :auto_title, :boolean, default: false + end + + create index(:messages, [:title]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250920125417_add_unique_constraint_to_community_names.exs b/apps/elektrine/priv/repo/migrations/20250920125417_add_unique_constraint_to_community_names.exs new file mode 100644 index 0000000..d7783e9 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250920125417_add_unique_constraint_to_community_names.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddUniqueConstraintToCommunityNames do + use Ecto.Migration + + def change do + # Add unique index on name for community conversations only + create unique_index(:conversations, [:name], + where: "space_type = 'community'", + name: :conversations_community_name_unique_index + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250920154313_add_promoted_from_community_fields.exs b/apps/elektrine/priv/repo/migrations/20250920154313_add_promoted_from_community_fields.exs new file mode 100644 index 0000000..ed9c842 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250920154313_add_promoted_from_community_fields.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.AddPromotedFromCommunityFields do + use Ecto.Migration + + def change do + alter table(:messages) do + add :promoted_from_community_name, :string + add :promoted_from_community_hash, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250920190238_add_last_read_message_id_to_conversation_members.exs b/apps/elektrine/priv/repo/migrations/20250920190238_add_last_read_message_id_to_conversation_members.exs new file mode 100644 index 0000000..e69e001 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250920190238_add_last_read_message_id_to_conversation_members.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddLastReadMessageIdToConversationMembers do + use Ecto.Migration + + def change do + alter table(:conversation_members) do + add :last_read_message_id, references(:messages, on_delete: :nilify_all) + end + + create index(:conversation_members, [:last_read_message_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250923055335_create_notifications.exs b/apps/elektrine/priv/repo/migrations/20250923055335_create_notifications.exs new file mode 100644 index 0000000..63ac338 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250923055335_create_notifications.exs @@ -0,0 +1,45 @@ +defmodule Elektrine.Repo.Migrations.CreateNotifications do + use Ecto.Migration + + def change do + create table(:notifications) do + add :type, :string, null: false + + # Types: new_message, mention, reply, follow, like, comment, discussion_reply, email_received, system + + add :title, :string, null: false + add :body, :text + # Where to navigate when clicked + add :url, :string + # Icon name or URL + add :icon, :string + # low, normal, high, urgent + add :priority, :string, default: "normal" + + add :read_at, :utc_datetime + add :seen_at, :utc_datetime + add :dismissed_at, :utc_datetime + + # References + add :user_id, references(:users, on_delete: :delete_all), null: false + # Who triggered the notification + add :actor_id, references(:users, on_delete: :nilify_all) + + # Polymorphic reference to the source + # "message", "post", "discussion", "email", etc. + add :source_type, :string + add :source_id, :integer + + # Additional metadata + add :metadata, :map, default: %{} + + timestamps() + end + + create index(:notifications, [:user_id]) + create index(:notifications, [:user_id, :read_at]) + create index(:notifications, [:user_id, :type]) + create index(:notifications, [:source_type, :source_id]) + create index(:notifications, [:inserted_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250923155428_add_case_insensitive_unique_index_to_aliases.exs b/apps/elektrine/priv/repo/migrations/20250923155428_add_case_insensitive_unique_index_to_aliases.exs new file mode 100644 index 0000000..c4cc688 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250923155428_add_case_insensitive_unique_index_to_aliases.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddCaseInsensitiveUniqueIndexToAliases do + use Ecto.Migration + + def change do + # This migration is now handled by FixDuplicateAliasesBeforeIndex + # which first removes duplicates before creating the index + # Leaving this as a no-op to preserve migration history + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250923155500_add_case_insensitive_unique_index_to_users.exs b/apps/elektrine/priv/repo/migrations/20250923155500_add_case_insensitive_unique_index_to_users.exs new file mode 100644 index 0000000..9036964 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250923155500_add_case_insensitive_unique_index_to_users.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddCaseInsensitiveUniqueIndexToUsers do + use Ecto.Migration + + def change do + # Create a unique index on lowercase username to prevent case-sensitive duplicates + create unique_index(:users, ["lower(username)"], name: :users_username_ci_unique) + + # Also create a unique index on lowercase mailbox email + create unique_index(:mailboxes, ["lower(email)"], name: :mailboxes_email_ci_unique) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250923160000_fix_duplicate_aliases_before_index.exs b/apps/elektrine/priv/repo/migrations/20250923160000_fix_duplicate_aliases_before_index.exs new file mode 100644 index 0000000..d74569b --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250923160000_fix_duplicate_aliases_before_index.exs @@ -0,0 +1,33 @@ +defmodule Elektrine.Repo.Migrations.FixDuplicateAliasesBeforeIndex do + use Ecto.Migration + + def up do + # Find and remove duplicate aliases (keep the one with the lowest ID) + execute """ + DELETE FROM email_aliases a1 + WHERE EXISTS ( + SELECT 1 + FROM email_aliases a2 + WHERE lower(a1.alias_email) = lower(a2.alias_email) + AND a1.id > a2.id + ) + """ + + # Check if the index already exists before creating it + execute """ + CREATE UNIQUE INDEX IF NOT EXISTS email_aliases_alias_email_ci_unique + ON email_aliases (lower(alias_email)) + """ + + # Also add a regular index for performance (if it doesn't exist) + execute """ + CREATE INDEX IF NOT EXISTS email_aliases_alias_email_lower_idx + ON email_aliases (lower(alias_email)) + """ + end + + def down do + execute "DROP INDEX IF EXISTS email_aliases_alias_email_lower_idx" + execute "DROP INDEX IF EXISTS email_aliases_alias_email_ci_unique" + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250924000001_add_social_identity_to_users.exs b/apps/elektrine/priv/repo/migrations/20250924000001_add_social_identity_to_users.exs new file mode 100644 index 0000000..99c2208 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250924000001_add_social_identity_to_users.exs @@ -0,0 +1,62 @@ +defmodule Elektrine.Repo.Migrations.AddSocialIdentityToUsers do + use Ecto.Migration + + def change do + # Add social identity fields to users + alter table(:users) do + add_if_not_exists :handle, :string + add_if_not_exists :display_name, :string + add_if_not_exists :unique_id, :string + add_if_not_exists :handle_changed_at, :utc_datetime + end + + # Create handle history table for 90-day reservation + create table(:handle_history) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :handle, :string, null: false + add :used_from, :utc_datetime, null: false + add :used_until, :utc_datetime + # 90 days after change + add :reserved_until, :utc_datetime + + timestamps() + end + + # Create indexes (but not unique yet - will add after grandfather migration) + create index(:users, [:handle]) + create index(:users, [:unique_id]) + create index(:handle_history, [:handle]) + create index(:handle_history, [:user_id]) + create index(:handle_history, [:reserved_until]) + + # Flush before next operations + flush() + + # Generate unique_id for existing users + execute """ + UPDATE users + SET unique_id = 'usr_' || substr(md5(random()::text || id::text), 1, 8) + WHERE unique_id IS NULL + """, + "" + + # Set display_name to username for existing users (they can change later) + execute """ + UPDATE users + SET display_name = username + WHERE display_name IS NULL + """, + "" + end + + def down do + alter table(:users) do + remove :handle + remove :display_name + remove :unique_id + remove :handle_changed_at + end + + drop table(:handle_history) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250924000002_grandfather_user_handles.exs b/apps/elektrine/priv/repo/migrations/20250924000002_grandfather_user_handles.exs new file mode 100644 index 0000000..f93f625 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250924000002_grandfather_user_handles.exs @@ -0,0 +1,73 @@ +defmodule Elektrine.Repo.Migrations.GrandfatherUserHandles do + use Ecto.Migration + + def up do + # Assign handles to all existing users who don't have one + execute(""" + WITH handle_assignments AS ( + SELECT + id, + username, + CASE + -- First user with this username (case-insensitive) gets the exact handle + WHEN ROW_NUMBER() OVER (PARTITION BY LOWER(username) ORDER BY inserted_at) = 1 + THEN LOWER(username) + -- Subsequent users get numbered variants + ELSE LOWER(username) || ROW_NUMBER() OVER (PARTITION BY LOWER(username) ORDER BY inserted_at) + END as new_handle + FROM users + WHERE handle IS NULL + ) + UPDATE users + SET + handle = handle_assignments.new_handle, + updated_at = NOW() + FROM handle_assignments + WHERE users.id = handle_assignments.id + """) + + # Ensure all users have unique_id + execute(""" + UPDATE users + SET unique_id = 'usr_' || substr(md5(random()::text || id::text), 1, 8) + WHERE unique_id IS NULL + """) + + # Ensure all users have display_name (default to username) + execute(""" + UPDATE users + SET display_name = username + WHERE display_name IS NULL + """) + + # Record initial handles in handle_history + execute(""" + INSERT INTO handle_history (user_id, handle, used_from, inserted_at, updated_at) + SELECT + id, + handle, + COALESCE(handle_changed_at, inserted_at), + NOW(), + NOW() + FROM users + WHERE handle IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM handle_history + WHERE handle_history.user_id = users.id + AND handle_history.handle = users.handle + ) + """) + + # Now add the unique constraints since all users have handles + create_if_not_exists unique_index(:users, [:handle]) + create_if_not_exists unique_index(:users, [:unique_id]) + end + + def down do + # Remove the constraints + drop_if_exists unique_index(:users, [:handle]) + drop_if_exists unique_index(:users, [:unique_id]) + + # Note: We don't remove the handles themselves as that would be destructive + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250924065350_regenerate_missing_handles_and_ids.exs b/apps/elektrine/priv/repo/migrations/20250924065350_regenerate_missing_handles_and_ids.exs new file mode 100644 index 0000000..816919a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250924065350_regenerate_missing_handles_and_ids.exs @@ -0,0 +1,115 @@ +defmodule Elektrine.Repo.Migrations.RegenerateMissingHandlesAndIds do + use Ecto.Migration + + def up do + # Regenerate unique IDs for users who don't have one + execute(""" + UPDATE users + SET + unique_id = 'usr_' || substr(md5(random()::text || id::text), 1, 8), + updated_at = NOW() + WHERE unique_id IS NULL OR unique_id = '' + """) + + # Regenerate handles for users who don't have one + # Use a more complex approach to ensure uniqueness + execute(""" + WITH handle_assignments AS ( + SELECT + id, + username, + CASE + -- First try the lowercase username + WHEN NOT EXISTS ( + SELECT 1 FROM users u2 + WHERE u2.handle = LOWER(users.username) + AND u2.id != users.id + ) + THEN LOWER(username) + -- Otherwise, add numbers until we find an available one + ELSE ( + SELECT LOWER(username) || COALESCE( + (SELECT MIN(n::text) + FROM generate_series(1, 10000) n + WHERE NOT EXISTS ( + SELECT 1 FROM users u3 + WHERE u3.handle = LOWER(users.username) || n::text + )), + '10001' + ) + ) + END as new_handle + FROM users + WHERE handle IS NULL OR handle = '' + ) + UPDATE users + SET + handle = handle_assignments.new_handle, + updated_at = NOW() + FROM handle_assignments + WHERE users.id = handle_assignments.id + """) + + # Ensure all users have display_name (default to username) + execute(""" + UPDATE users + SET + display_name = username, + updated_at = NOW() + WHERE display_name IS NULL OR display_name = '' + """) + + # Record any newly assigned handles in handle_history + # Skip if the record already exists + execute(""" + INSERT INTO handle_history (user_id, handle, used_from, inserted_at, updated_at) + SELECT + id, + handle, + COALESCE(handle_changed_at, inserted_at), + NOW(), + NOW() + FROM users + WHERE handle IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM handle_history + WHERE handle_history.user_id = users.id + AND handle_history.handle = users.handle + ) + """) + + # Log summary of changes + execute(""" + DO $$ + DECLARE + updated_unique_ids INT; + updated_handles INT; + updated_display_names INT; + BEGIN + SELECT COUNT(*) INTO updated_unique_ids + FROM users + WHERE updated_at >= NOW() - INTERVAL '1 minute' + AND unique_id IS NOT NULL; + + SELECT COUNT(*) INTO updated_handles + FROM users + WHERE updated_at >= NOW() - INTERVAL '1 minute' + AND handle IS NOT NULL; + + SELECT COUNT(*) INTO updated_display_names + FROM users + WHERE updated_at >= NOW() - INTERVAL '1 minute' + AND display_name IS NOT NULL; + + RAISE NOTICE 'Regenerated % unique IDs, % handles, and % display names', + updated_unique_ids, updated_handles, updated_display_names; + END $$; + """) + end + + def down do + # We don't remove the generated data on rollback + # as that would be destructive + :ok + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250925151252_migrate_email_attachments_to_s3.exs b/apps/elektrine/priv/repo/migrations/20250925151252_migrate_email_attachments_to_s3.exs new file mode 100644 index 0000000..3d0a9cf --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250925151252_migrate_email_attachments_to_s3.exs @@ -0,0 +1,20 @@ +defmodule Elektrine.Repo.Migrations.MigrateEmailAttachmentsToS3 do + use Ecto.Migration + + def change do + # This migration doesn't change the database schema + # It's a marker for when we started using S3-compatible storage for attachments + # Existing attachments will continue to work with fallback to database storage + # New attachments will be stored in S3-compatible object storage + + # Optional: Add a column to track migration status per message + # This is commented out by default to avoid locking the messages table + # Uncomment if you want to track migration status per message + + # alter table(:email_messages) do + # add :attachments_migrated_to_s3, :boolean, default: false + # end + + # create index(:email_messages, [:attachments_migrated_to_s3]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250926045456_add_privacy_settings_to_users.exs b/apps/elektrine/priv/repo/migrations/20250926045456_add_privacy_settings_to_users.exs new file mode 100644 index 0000000..bb6c23c --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250926045456_add_privacy_settings_to_users.exs @@ -0,0 +1,29 @@ +defmodule Elektrine.Repo.Migrations.AddPrivacySettingsToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + # Privacy settings for group/channel additions + add :allow_group_adds_from, :string, default: "everyone" + # Options: "everyone", "following", "followers", "mutual", "nobody" + + # Privacy settings for direct messages + add :allow_direct_messages_from, :string, default: "everyone" + # Options: "everyone", "following", "followers", "mutual", "nobody" + + # Privacy settings for mentions + add :allow_mentions_from, :string, default: "everyone" + # Options: "everyone", "following", "followers", "mutual", "nobody" + + # Privacy settings for profile visibility + add :profile_visibility, :string, default: "public" + # Options: "public", "followers", "private" + + # Settings for notifications + add :email_on_new_follower, :boolean, default: true + add :email_on_direct_message, :boolean, default: true + add :email_on_mention, :boolean, default: true + add :email_on_group_invite, :boolean, default: true + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250926052516_set_default_privacy_settings.exs b/apps/elektrine/priv/repo/migrations/20250926052516_set_default_privacy_settings.exs new file mode 100644 index 0000000..3fb6811 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250926052516_set_default_privacy_settings.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Repo.Migrations.SetDefaultPrivacySettings do + use Ecto.Migration + + def up do + # Set defaults for existing users where fields are NULL + execute """ + UPDATE users + SET + profile_visibility = COALESCE(profile_visibility, 'public'), + allow_group_adds_from = COALESCE(allow_group_adds_from, 'everyone'), + allow_direct_messages_from = COALESCE(allow_direct_messages_from, 'everyone'), + allow_mentions_from = COALESCE(allow_mentions_from, 'everyone'), + email_on_new_follower = COALESCE(email_on_new_follower, true), + email_on_direct_message = COALESCE(email_on_direct_message, true), + email_on_mention = COALESCE(email_on_mention, true), + email_on_group_invite = COALESCE(email_on_group_invite, true) + """ + end + + def down do + # We can't undo this data migration + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250926053327_rename_email_notification_fields_to_notify.exs b/apps/elektrine/priv/repo/migrations/20250926053327_rename_email_notification_fields_to_notify.exs new file mode 100644 index 0000000..b9f5fd8 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250926053327_rename_email_notification_fields_to_notify.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.RenameEmailNotificationFieldsToNotify do + use Ecto.Migration + + def change do + # Rename the columns from email_on_* to notify_on_* + rename table(:users), :email_on_new_follower, to: :notify_on_new_follower + rename table(:users), :email_on_direct_message, to: :notify_on_direct_message + rename table(:users), :email_on_mention, to: :notify_on_mention + rename table(:users), :email_on_group_invite, to: :notify_on_group_invite + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250927050233_add_community_flairs.exs b/apps/elektrine/priv/repo/migrations/20250927050233_add_community_flairs.exs new file mode 100644 index 0000000..4dc7bf3 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250927050233_add_community_flairs.exs @@ -0,0 +1,28 @@ +defmodule Elektrine.Repo.Migrations.AddCommunityFlairs do + use Ecto.Migration + + def change do + # Create community flairs table + create table(:community_flairs) do + add :name, :string, null: false + add :text_color, :string, default: "#FFFFFF" + add :background_color, :string, default: "#4B5563" + add :community_id, references(:conversations, on_delete: :delete_all), null: false + add :position, :integer, default: 0 + add :is_mod_only, :boolean, default: false + add :is_enabled, :boolean, default: true + + timestamps() + end + + create index(:community_flairs, [:community_id]) + create unique_index(:community_flairs, [:community_id, :name]) + + # Add flair_id to messages table for posts in communities + alter table(:messages) do + add :flair_id, references(:community_flairs, on_delete: :nilify_all) + end + + create index(:messages, [:flair_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250927065000_update_reply_counts.exs b/apps/elektrine/priv/repo/migrations/20250927065000_update_reply_counts.exs new file mode 100644 index 0000000..0c3b007 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250927065000_update_reply_counts.exs @@ -0,0 +1,27 @@ +defmodule Elektrine.Repo.Migrations.UpdateReplyCounts do + use Ecto.Migration + + def up do + # Update reply counts for all messages + execute """ + UPDATE messages m + SET reply_count = ( + SELECT COUNT(*) + FROM messages r + WHERE r.reply_to_id = m.id + AND r.deleted_at IS NULL + ) + WHERE m.post_type = 'discussion' + AND m.reply_to_id IS NULL + """ + end + + def down do + # Reset all reply counts to 0 + execute """ + UPDATE messages + SET reply_count = 0 + WHERE post_type = 'discussion' + """ + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250927070000_add_vote_indexes.exs b/apps/elektrine/priv/repo/migrations/20250927070000_add_vote_indexes.exs new file mode 100644 index 0000000..922e975 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250927070000_add_vote_indexes.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.AddVoteIndexes do + use Ecto.Migration + + def change do + # Add indexes for efficient vote queries + create index(:message_votes, [:message_id, :vote_type]) + create index(:message_votes, [:user_id]) + create index(:message_votes, [:inserted_at]) + + # Composite index for the most common query pattern + create index(:message_votes, [:message_id, :vote_type, :inserted_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250927071000_add_community_rules.exs b/apps/elektrine/priv/repo/migrations/20250927071000_add_community_rules.exs new file mode 100644 index 0000000..e8ad3c4 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250927071000_add_community_rules.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddCommunityRules do + use Ecto.Migration + + def change do + alter table(:conversations) do + add :community_rules, :text + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250927153135_add_pinned_to_messages.exs b/apps/elektrine/priv/repo/migrations/20250927153135_add_pinned_to_messages.exs new file mode 100644 index 0000000..fd4052d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250927153135_add_pinned_to_messages.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.AddPinnedToMessages do + use Ecto.Migration + + def change do + alter table(:messages) do + add :is_pinned, :boolean, default: false + add :pinned_at, :utc_datetime + add :pinned_by_id, references(:users, on_delete: :nilify_all) + end + + create index(:messages, [:conversation_id, :is_pinned]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250927153154_create_community_bans.exs b/apps/elektrine/priv/repo/migrations/20250927153154_create_community_bans.exs new file mode 100644 index 0000000..aceaf49 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250927153154_create_community_bans.exs @@ -0,0 +1,19 @@ +defmodule Elektrine.Repo.Migrations.CreateCommunityBans do + use Ecto.Migration + + def change do + create table(:community_bans) do + add :conversation_id, references(:conversations, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + add :banned_by_id, references(:users, on_delete: :nilify_all), null: false + add :reason, :text + add :expires_at, :utc_datetime + + timestamps() + end + + create unique_index(:community_bans, [:conversation_id, :user_id]) + create index(:community_bans, [:user_id]) + create index(:community_bans, [:expires_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250927161505_rename_email_categories.exs b/apps/elektrine/priv/repo/migrations/20250927161505_rename_email_categories.exs new file mode 100644 index 0000000..6ed1eef --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250927161505_rename_email_categories.exs @@ -0,0 +1,33 @@ +defmodule Elektrine.Repo.Migrations.RenameEmailCategories do + use Ecto.Migration + + def change do + # Update category values from paper_trail to ledger + execute """ + UPDATE email_messages + SET category = 'ledger' + WHERE category = 'paper_trail' + """, + """ + UPDATE email_messages + SET category = 'paper_trail' + WHERE category = 'ledger' + """ + + # Update category values from set_aside to stack + execute """ + UPDATE email_messages + SET category = 'stack' + WHERE category = 'set_aside' + """, + """ + UPDATE email_messages + SET category = 'set_aside' + WHERE category = 'stack' + """ + + # Rename set_aside columns to stack + rename table(:email_messages), :set_aside_at, to: :stack_at + rename table(:email_messages), :set_aside_reason, to: :stack_reason + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250927195300_rename_feed_to_digest.exs b/apps/elektrine/priv/repo/migrations/20250927195300_rename_feed_to_digest.exs new file mode 100644 index 0000000..81fc40d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250927195300_rename_feed_to_digest.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.RenameFeedToDigest do + use Ecto.Migration + + def change do + # Update category values from feed to digest + execute """ + UPDATE email_messages + SET category = 'digest' + WHERE category = 'feed' + """, + """ + UPDATE email_messages + SET category = 'feed' + WHERE category = 'digest' + """ + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250930065342_create_app_passwords.exs b/apps/elektrine/priv/repo/migrations/20250930065342_create_app_passwords.exs new file mode 100644 index 0000000..6d080ad --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250930065342_create_app_passwords.exs @@ -0,0 +1,20 @@ +defmodule Elektrine.Repo.Migrations.CreateAppPasswords do + use Ecto.Migration + + def change do + create table(:app_passwords) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :name, :string, null: false + add :token_hash, :string, null: false + add :last_used_at, :utc_datetime + add :last_used_ip, :string + add :expires_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create index(:app_passwords, [:user_id]) + create unique_index(:app_passwords, [:token_hash]) + create index(:app_passwords, [:expires_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250930145634_add_flagged_to_email_messages.exs b/apps/elektrine/priv/repo/migrations/20250930145634_add_flagged_to_email_messages.exs new file mode 100644 index 0000000..f8cfeff --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250930145634_add_flagged_to_email_messages.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddFlaggedToEmailMessages do + use Ecto.Migration + + def change do + alter table(:email_messages) do + add :flagged, :boolean, default: false, null: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250930153632_allow_null_to_in_email_messages.exs b/apps/elektrine/priv/repo/migrations/20250930153632_allow_null_to_in_email_messages.exs new file mode 100644 index 0000000..f4d1d89 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250930153632_allow_null_to_in_email_messages.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AllowNullToInEmailMessages do + use Ecto.Migration + + def change do + alter table(:email_messages) do + modify :to, :string, null: true, from: {:string, null: false} + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250930163613_add_deleted_to_email_messages.exs b/apps/elektrine/priv/repo/migrations/20250930163613_add_deleted_to_email_messages.exs new file mode 100644 index 0000000..711e609 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250930163613_add_deleted_to_email_messages.exs @@ -0,0 +1,12 @@ +defmodule Elektrine.Repo.Migrations.AddDeletedToEmailMessages do + use Ecto.Migration + + def change do + alter table(:email_messages) do + add :deleted, :boolean, default: false, null: false + end + + create index(:email_messages, [:deleted]) + create index(:email_messages, [:mailbox_id, :deleted]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20250930171320_add_answered_to_email_messages.exs b/apps/elektrine/priv/repo/migrations/20250930171320_add_answered_to_email_messages.exs new file mode 100644 index 0000000..25d3911 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20250930171320_add_answered_to_email_messages.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddAnsweredToEmailMessages do + use Ecto.Migration + + def change do + alter table(:email_messages) do + add :answered, :boolean, default: false, null: false + end + + create index(:email_messages, [:answered]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251004205738_create_profile_views.exs b/apps/elektrine/priv/repo/migrations/20251004205738_create_profile_views.exs new file mode 100644 index 0000000..6756d5d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251004205738_create_profile_views.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Repo.Migrations.CreateProfileViews do + use Ecto.Migration + + def change do + create table(:profile_views) do + add :profile_user_id, references(:users, on_delete: :delete_all), null: false + add :viewer_user_id, references(:users, on_delete: :delete_all), null: true + add :viewer_session_id, :string, null: true + add :ip_address, :string + add :user_agent, :text + add :referer, :text + + timestamps(updated_at: false) + end + + create index(:profile_views, [:profile_user_id]) + create index(:profile_views, [:viewer_user_id]) + create index(:profile_views, [:viewer_session_id]) + create index(:profile_views, [:profile_user_id, :inserted_at]) + create index(:profile_views, [:profile_user_id, :viewer_user_id, :inserted_at]) + create index(:profile_views, [:profile_user_id, :viewer_session_id, :inserted_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251005051754_add_user_storage_tracking.exs b/apps/elektrine/priv/repo/migrations/20251005051754_add_user_storage_tracking.exs new file mode 100644 index 0000000..72bbd51 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251005051754_add_user_storage_tracking.exs @@ -0,0 +1,14 @@ +defmodule Elektrine.Repo.Migrations.AddUserStorageTracking do + use Ecto.Migration + + def change do + alter table(:users) do + add :storage_used_bytes, :bigint, default: 0, null: false + # 500MB default + add :storage_limit_bytes, :bigint, default: 524_288_000, null: false + add :storage_last_calculated_at, :utc_datetime + end + + create index(:users, [:storage_used_bytes]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251005052144_remove_mailbox_storage_fields.exs b/apps/elektrine/priv/repo/migrations/20251005052144_remove_mailbox_storage_fields.exs new file mode 100644 index 0000000..dd3400b --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251005052144_remove_mailbox_storage_fields.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.RemoveMailboxStorageFields do + use Ecto.Migration + + def change do + alter table(:mailboxes) do + remove :storage_used_bytes + remove :storage_limit_bytes + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251005053001_update_user_storage_limits_to_500mb.exs b/apps/elektrine/priv/repo/migrations/20251005053001_update_user_storage_limits_to_500mb.exs new file mode 100644 index 0000000..75ae193 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251005053001_update_user_storage_limits_to_500mb.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.UpdateUserStorageLimitsTo500mb do + use Ecto.Migration + + def up do + # Update all existing users with old 25MB limit to new 500MB limit + execute "UPDATE users SET storage_limit_bytes = 524288000 WHERE storage_limit_bytes < 524288000" + end + + def down do + # Revert to 25MB limit + execute "UPDATE users SET storage_limit_bytes = 26214400 WHERE storage_limit_bytes = 524288000" + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251005145316_add_media_metadata_to_messages.exs b/apps/elektrine/priv/repo/migrations/20251005145316_add_media_metadata_to_messages.exs new file mode 100644 index 0000000..72a5d7a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251005145316_add_media_metadata_to_messages.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddMediaMetadataToMessages do + use Ecto.Migration + + def change do + alter table(:messages) do + add :media_metadata, :map, default: %{} + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251005152310_add_file_sizes_to_avatars_and_backgrounds.exs b/apps/elektrine/priv/repo/migrations/20251005152310_add_file_sizes_to_avatars_and_backgrounds.exs new file mode 100644 index 0000000..8311caf --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251005152310_add_file_sizes_to_avatars_and_backgrounds.exs @@ -0,0 +1,15 @@ +defmodule Elektrine.Repo.Migrations.AddFileSizesToAvatarsAndBackgrounds do + use Ecto.Migration + + def change do + alter table(:users) do + add :avatar_size, :integer, default: 0 + end + + alter table(:user_profiles) do + add :avatar_size, :integer, default: 0 + add :banner_size, :integer, default: 0 + add :background_size, :integer, default: 0 + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251005153018_remove_animated_title_from_user_profiles.exs b/apps/elektrine/priv/repo/migrations/20251005153018_remove_animated_title_from_user_profiles.exs new file mode 100644 index 0000000..bb07e11 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251005153018_remove_animated_title_from_user_profiles.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.RemoveAnimatedTitleFromUserProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + remove :animated_title + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251006034404_add_case_insensitive_handle_index.exs b/apps/elektrine/priv/repo/migrations/20251006034404_add_case_insensitive_handle_index.exs new file mode 100644 index 0000000..1d7ca21 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251006034404_add_case_insensitive_handle_index.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddCaseInsensitiveHandleIndex do + use Ecto.Migration + + def change do + # Drop the old case-sensitive unique index on handle + drop_if_exists index(:users, [:handle], unique: true) + + # Create case-insensitive unique index on handle + create unique_index(:users, ["lower(handle)"], name: :users_handle_ci_unique) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251006034431_add_case_insensitive_community_name_index.exs b/apps/elektrine/priv/repo/migrations/20251006034431_add_case_insensitive_community_name_index.exs new file mode 100644 index 0000000..d46ff48 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251006034431_add_case_insensitive_community_name_index.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.AddCaseInsensitiveCommunityNameIndex do + use Ecto.Migration + + def change do + # Drop the old case-sensitive unique index on community names + drop_if_exists index(:conversations, [:name], + where: "space_type = 'community'", + name: :conversations_community_name_unique_index + ) + + # Create case-insensitive unique index on community names + create unique_index(:conversations, ["lower(name)"], + where: "space_type = 'community'", + name: :conversations_community_name_ci_unique + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251008000912_add_timezone_to_users.exs b/apps/elektrine/priv/repo/migrations/20251008000912_add_timezone_to_users.exs new file mode 100644 index 0000000..97339a3 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251008000912_add_timezone_to_users.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddTimezoneToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :timezone, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251008012454_add_time_format_to_users.exs b/apps/elektrine/priv/repo/migrations/20251008012454_add_time_format_to_users.exs new file mode 100644 index 0000000..4005fda --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251008012454_add_time_format_to_users.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddTimeFormatToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :time_format, :string, default: "12" + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251008014427_remove_notify_on_group_invite_from_users.exs b/apps/elektrine/priv/repo/migrations/20251008014427_remove_notify_on_group_invite_from_users.exs new file mode 100644 index 0000000..5af16cb --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251008014427_remove_notify_on_group_invite_from_users.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.RemoveNotifyOnGroupInviteFromUsers do + use Ecto.Migration + + def change do + alter table(:users) do + remove :notify_on_group_invite + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251008150636_add_encryption_to_messages.exs b/apps/elektrine/priv/repo/migrations/20251008150636_add_encryption_to_messages.exs new file mode 100644 index 0000000..12b6220 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251008150636_add_encryption_to_messages.exs @@ -0,0 +1,12 @@ +defmodule Elektrine.Repo.Migrations.AddEncryptionToMessages do + use Ecto.Migration + + def change do + alter table(:messages) do + add :encrypted_content, :map + add :search_index, {:array, :text}, default: [] + end + + create index(:messages, [:search_index], using: :gin) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251008150659_add_encryption_to_email_messages.exs b/apps/elektrine/priv/repo/migrations/20251008150659_add_encryption_to_email_messages.exs new file mode 100644 index 0000000..3fd26d6 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251008150659_add_encryption_to_email_messages.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.AddEncryptionToEmailMessages do + use Ecto.Migration + + def change do + alter table(:email_messages) do + add :encrypted_text_body, :map + add :encrypted_html_body, :map + add :search_index, {:array, :text}, default: [] + end + + create index(:email_messages, [:search_index], using: :gin) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251008181136_add_email_protocol_tracking.exs b/apps/elektrine/priv/repo/migrations/20251008181136_add_email_protocol_tracking.exs new file mode 100644 index 0000000..a68addc --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251008181136_add_email_protocol_tracking.exs @@ -0,0 +1,14 @@ +defmodule Elektrine.Repo.Migrations.AddEmailProtocolTracking do + use Ecto.Migration + + def change do + alter table(:users) do + add :last_imap_access, :utc_datetime + add :last_pop3_access, :utc_datetime + end + + # Create indexes for efficient querying + create index(:users, [:last_imap_access]) + create index(:users, [:last_pop3_access]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251009063632_add_user_status.exs b/apps/elektrine/priv/repo/migrations/20251009063632_add_user_status.exs new file mode 100644 index 0000000..d1c21c9 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251009063632_add_user_status.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.AddUserStatus do + use Ecto.Migration + + def change do + alter table(:users) do + add :status, :string, default: "online", null: false + add :status_message, :string + add :status_updated_at, :utc_datetime + end + + create index(:users, [:status]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251009190614_add_last_seen_to_users.exs b/apps/elektrine/priv/repo/migrations/20251009190614_add_last_seen_to_users.exs new file mode 100644 index 0000000..6da4260 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251009190614_add_last_seen_to_users.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddLastSeenToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :last_seen_at, :utc_datetime + end + + create index(:users, [:last_seen_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251010031206_add_username_effects_to_profiles.exs b/apps/elektrine/priv/repo/migrations/20251010031206_add_username_effects_to_profiles.exs new file mode 100644 index 0000000..5cf5b83 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251010031206_add_username_effects_to_profiles.exs @@ -0,0 +1,16 @@ +defmodule Elektrine.Repo.Migrations.AddUsernameEffectsToProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + # Username text effects + add :username_effect, :string, default: "none" + add :username_glow_color, :string, default: "#8b5cf6" + add :username_glow_intensity, :integer, default: 10 + add :username_shadow_color, :string, default: "#000000" + add :username_gradient_from, :string + add :username_gradient_to, :string + add :username_animation_speed, :string, default: "normal" + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251010035439_create_user_badges.exs b/apps/elektrine/priv/repo/migrations/20251010035439_create_user_badges.exs new file mode 100644 index 0000000..952bc70 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251010035439_create_user_badges.exs @@ -0,0 +1,21 @@ +defmodule Elektrine.Repo.Migrations.CreateUserBadges do + use Ecto.Migration + + def change do + create table(:user_badges) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :badge_type, :string, null: false + add :badge_text, :string + add :badge_color, :string, default: "#8b5cf6" + add :badge_icon, :string + add :tooltip, :string + add :granted_by_id, references(:users, on_delete: :nilify_all) + add :position, :integer, default: 0 + + timestamps() + end + + create index(:user_badges, [:user_id]) + create index(:user_badges, [:badge_type]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251010035441_add_typewriter_effect_to_profiles.exs b/apps/elektrine/priv/repo/migrations/20251010035441_add_typewriter_effect_to_profiles.exs new file mode 100644 index 0000000..45e57ec --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251010035441_add_typewriter_effect_to_profiles.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.AddTypewriterEffectToProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :typewriter_effect, :boolean, default: false + add :typewriter_speed, :string, default: "normal" + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251010035442_create_profile_widgets.exs b/apps/elektrine/priv/repo/migrations/20251010035442_create_profile_widgets.exs new file mode 100644 index 0000000..868164e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251010035442_create_profile_widgets.exs @@ -0,0 +1,22 @@ +defmodule Elektrine.Repo.Migrations.CreateProfileWidgets do + use Ecto.Migration + + def change do + create table(:profile_widgets) do + add :profile_id, references(:user_profiles, on_delete: :delete_all), null: false + add :widget_type, :string, null: false + add :title, :string + add :content, :text + add :url, :string + add :position, :integer, default: 0 + add :is_active, :boolean, default: true + add :settings, :map, default: %{} + + timestamps() + end + + create index(:profile_widgets, [:profile_id]) + create index(:profile_widgets, [:widget_type]) + create index(:profile_widgets, [:position]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251010050458_add_typewriter_title_to_profiles.exs b/apps/elektrine/priv/repo/migrations/20251010050458_add_typewriter_title_to_profiles.exs new file mode 100644 index 0000000..841bf29 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251010050458_add_typewriter_title_to_profiles.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddTypewriterTitleToProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :typewriter_title, :boolean, default: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251010103546_add_link_display_style_and_sections.exs b/apps/elektrine/priv/repo/migrations/20251010103546_add_link_display_style_and_sections.exs new file mode 100644 index 0000000..84058d1 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251010103546_add_link_display_style_and_sections.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.AddLinkDisplayStyleAndSections do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :link_display_style, :string, default: "circular" + end + + alter table(:profile_links) do + add :section, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251010104829_add_container_background_to_profiles.exs b/apps/elektrine/priv/repo/migrations/20251010104829_add_container_background_to_profiles.exs new file mode 100644 index 0000000..663c52c --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251010104829_add_container_background_to_profiles.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.AddContainerBackgroundToProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :container_background_color, :string + add :container_opacity, :float, default: 0.4 + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251010110017_add_container_pattern_to_profiles.exs b/apps/elektrine/priv/repo/migrations/20251010110017_add_container_pattern_to_profiles.exs new file mode 100644 index 0000000..345c913 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251010110017_add_container_pattern_to_profiles.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.AddContainerPatternToProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :container_pattern, :string, default: "none" + add :pattern_color, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251010110645_add_pattern_animation_to_profiles.exs b/apps/elektrine/priv/repo/migrations/20251010110645_add_pattern_animation_to_profiles.exs new file mode 100644 index 0000000..e935324 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251010110645_add_pattern_animation_to_profiles.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.AddPatternAnimationToProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :pattern_animated, :boolean, default: false + add :pattern_animation_speed, :string, default: "normal" + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251010133611_add_visibility_toggles_to_profiles.exs b/apps/elektrine/priv/repo/migrations/20251010133611_add_visibility_toggles_to_profiles.exs new file mode 100644 index 0000000..1442c1a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251010133611_add_visibility_toggles_to_profiles.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddVisibilityTogglesToProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :hide_followers, :boolean, default: false + add :hide_avatar, :boolean, default: false + add :hide_timeline, :boolean, default: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251010134141_add_text_background_to_profiles.exs b/apps/elektrine/priv/repo/migrations/20251010134141_add_text_background_to_profiles.exs new file mode 100644 index 0000000..9dd04ca --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251010134141_add_text_background_to_profiles.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddTextBackgroundToProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :text_background, :boolean, default: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251010134855_update_default_font_to_system.exs b/apps/elektrine/priv/repo/migrations/20251010134855_update_default_font_to_system.exs new file mode 100644 index 0000000..0c3188b --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251010134855_update_default_font_to_system.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.UpdateDefaultFontToSystem do + use Ecto.Migration + + def up do + # Update existing profiles with "Inter" to use system default (NULL) + execute "UPDATE user_profiles SET font_family = NULL WHERE font_family = 'Inter'" + end + + def down do + # Revert back to Inter for profiles that had NULL + execute "UPDATE user_profiles SET font_family = 'Inter' WHERE font_family IS NULL" + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251010135407_add_visible_to_user_badges.exs b/apps/elektrine/priv/repo/migrations/20251010135407_add_visible_to_user_badges.exs new file mode 100644 index 0000000..a909340 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251010135407_add_visible_to_user_badges.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddVisibleToUserBadges do + use Ecto.Migration + + def change do + alter table(:user_badges) do + add :visible, :boolean, default: true + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251010135908_add_pattern_opacity_to_user_profiles.exs b/apps/elektrine/priv/repo/migrations/20251010135908_add_pattern_opacity_to_user_profiles.exs new file mode 100644 index 0000000..e95df14 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251010135908_add_pattern_opacity_to_user_profiles.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddPatternOpacityToUserProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :pattern_opacity, :float, default: 0.2 + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251010150620_add_verified_to_users.exs b/apps/elektrine/priv/repo/migrations/20251010150620_add_verified_to_users.exs new file mode 100644 index 0000000..fdd1155 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251010150620_add_verified_to_users.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddVerifiedToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :verified, :boolean, default: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251010173540_rename_early_adopter_to_beta_tester.exs b/apps/elektrine/priv/repo/migrations/20251010173540_rename_early_adopter_to_beta_tester.exs new file mode 100644 index 0000000..d182f03 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251010173540_rename_early_adopter_to_beta_tester.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.RenameEarlyAdopterToBetaTester do + use Ecto.Migration + + def up do + execute "UPDATE user_badges SET badge_type = 'beta_tester', badge_text = 'Beta Tester', tooltip = 'Beta tester' WHERE badge_type = 'early_adopter'" + end + + def down do + execute "UPDATE user_badges SET badge_type = 'early_adopter', badge_text = 'Early Adopter', tooltip = 'Early adopter' WHERE badge_type = 'beta_tester'" + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251010204959_remove_font_family_default.exs b/apps/elektrine/priv/repo/migrations/20251010204959_remove_font_family_default.exs new file mode 100644 index 0000000..4cc0591 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251010204959_remove_font_family_default.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.RemoveFontFamilyDefault do + use Ecto.Migration + + def up do + # Remove the default value from the font_family column + execute "ALTER TABLE user_profiles ALTER COLUMN font_family DROP DEFAULT" + end + + def down do + # Restore the Inter default if rolled back + execute "ALTER TABLE user_profiles ALTER COLUMN font_family SET DEFAULT 'Inter'" + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251011035521_add_avatar_effect_to_profiles.exs b/apps/elektrine/priv/repo/migrations/20251011035521_add_avatar_effect_to_profiles.exs new file mode 100644 index 0000000..b0db8c7 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251011035521_add_avatar_effect_to_profiles.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddAvatarEffectToProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :avatar_effect, :string, default: "none" + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251011050154_add_tick_color_to_profiles.exs b/apps/elektrine/priv/repo/migrations/20251011050154_add_tick_color_to_profiles.exs new file mode 100644 index 0000000..a3d040a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251011050154_add_tick_color_to_profiles.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddTickColorToProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :tick_color, :string, default: "#1d9bf0" + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251011063738_add_comprehensive_privacy_controls.exs b/apps/elektrine/priv/repo/migrations/20251011063738_add_comprehensive_privacy_controls.exs new file mode 100644 index 0000000..87d7b27 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251011063738_add_comprehensive_privacy_controls.exs @@ -0,0 +1,19 @@ +defmodule Elektrine.Repo.Migrations.AddComprehensivePrivacyControls do + use Ecto.Migration + + def change do + alter table(:users) do + # Call privacy settings + add :allow_calls_from, :string, default: "friends" + # Options: "everyone", "friends", "nobody" + + # Friend request privacy + add :allow_friend_requests_from, :string, default: "everyone" + # Options: "everyone", "followers", "nobody" + + # Timeline post visibility (separate from profile_visibility) + add :default_post_visibility, :string, default: "followers" + # Options: "public", "followers", "friends", "private" + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251011150823_set_defaults_for_new_privacy_fields.exs b/apps/elektrine/priv/repo/migrations/20251011150823_set_defaults_for_new_privacy_fields.exs new file mode 100644 index 0000000..aa72536 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251011150823_set_defaults_for_new_privacy_fields.exs @@ -0,0 +1,22 @@ +defmodule Elektrine.Repo.Migrations.SetDefaultsForNewPrivacyFields do + use Ecto.Migration + + def up do + # Set defaults for existing users where fields are NULL + # These fields were added in later migrations and might not have defaults for existing users + execute """ + UPDATE users + SET + allow_calls_from = COALESCE(allow_calls_from, 'friends'), + allow_friend_requests_from = COALESCE(allow_friend_requests_from, 'everyone'), + default_post_visibility = COALESCE(default_post_visibility, 'followers') + WHERE allow_calls_from IS NULL + OR allow_friend_requests_from IS NULL + OR default_post_visibility IS NULL + """ + end + + def down do + # We can't undo this data migration + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251011212159_create_forwarded_messages.exs b/apps/elektrine/priv/repo/migrations/20251011212159_create_forwarded_messages.exs new file mode 100644 index 0000000..ed9c274 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251011212159_create_forwarded_messages.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Repo.Migrations.CreateForwardedMessages do + use Ecto.Migration + + def change do + create table(:forwarded_messages) do + add :message_id, :string + add :from_address, :string + add :subject, :string + add :original_recipient, :string + add :final_recipient, :string + add :forwarding_chain, :jsonb + add :total_hops, :integer + add :alias_id, references(:email_aliases, on_delete: :nothing) + + timestamps() + end + + create index(:forwarded_messages, [:original_recipient]) + create index(:forwarded_messages, [:final_recipient]) + create index(:forwarded_messages, [:alias_id]) + create index(:forwarded_messages, [:inserted_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251012163307_add_hide_share_button_to_user_profiles.exs b/apps/elektrine/priv/repo/migrations/20251012163307_add_hide_share_button_to_user_profiles.exs new file mode 100644 index 0000000..0c1fd8d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251012163307_add_hide_share_button_to_user_profiles.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddHideShareButtonToUserProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :hide_share_button, :boolean, default: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251012172541_add_extend_layout_to_user_profiles.exs b/apps/elektrine/priv/repo/migrations/20251012172541_add_extend_layout_to_user_profiles.exs new file mode 100644 index 0000000..3144e69 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251012172541_add_extend_layout_to_user_profiles.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddExtendLayoutToUserProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :extend_layout, :boolean, default: true, null: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251015150616_add_hide_community_posts_to_user_profiles.exs b/apps/elektrine/priv/repo/migrations/20251015150616_add_hide_community_posts_to_user_profiles.exs new file mode 100644 index 0000000..771d828 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251015150616_add_hide_community_posts_to_user_profiles.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddHideCommunityPostsToUserProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :hide_community_posts, :boolean, default: false, null: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251015165346_add_link_highlight_effect_to_user_profiles.exs b/apps/elektrine/priv/repo/migrations/20251015165346_add_link_highlight_effect_to_user_profiles.exs new file mode 100644 index 0000000..60310a7 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251015165346_add_link_highlight_effect_to_user_profiles.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddLinkHighlightEffectToUserProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :link_highlight_effect, :string, default: "none", null: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251016063756_add_thumbnail_url_to_profile_links.exs b/apps/elektrine/priv/repo/migrations/20251016063756_add_thumbnail_url_to_profile_links.exs new file mode 100644 index 0000000..2d4d371 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251016063756_add_thumbnail_url_to_profile_links.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddThumbnailUrlToProfileLinks do + use Ecto.Migration + + def change do + alter table(:profile_links) do + add :thumbnail_url, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251016151551_add_display_style_to_profile_links.exs b/apps/elektrine/priv/repo/migrations/20251016151551_add_display_style_to_profile_links.exs new file mode 100644 index 0000000..67b2575 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251016151551_add_display_style_to_profile_links.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddDisplayStyleToProfileLinks do + use Ecto.Migration + + def change do + alter table(:profile_links) do + add :display_style, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251016152338_add_highlight_effect_to_profile_links.exs b/apps/elektrine/priv/repo/migrations/20251016152338_add_highlight_effect_to_profile_links.exs new file mode 100644 index 0000000..15ba7f4 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251016152338_add_highlight_effect_to_profile_links.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddHighlightEffectToProfileLinks do + use Ecto.Migration + + def change do + alter table(:profile_links) do + add :highlight_effect, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251016154738_add_circular_links_position_to_user_profiles.exs b/apps/elektrine/priv/repo/migrations/20251016154738_add_circular_links_position_to_user_profiles.exs new file mode 100644 index 0000000..a7ef7d8 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251016154738_add_circular_links_position_to_user_profiles.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddCircularLinksPositionToUserProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :circular_links_position, :string, default: "top", null: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251017051314_consolidate_conversation_type.exs b/apps/elektrine/priv/repo/migrations/20251017051314_consolidate_conversation_type.exs new file mode 100644 index 0000000..3271f8b --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251017051314_consolidate_conversation_type.exs @@ -0,0 +1,54 @@ +defmodule Elektrine.Repo.Migrations.ConsolidateConversationType do + use Ecto.Migration + + def up do + # Update conversations where space_type is "timeline" to have type "timeline" + execute """ + UPDATE conversations + SET type = 'timeline' + WHERE space_type = 'timeline' + """ + + # Update conversations where space_type is "community" to have type "community" + execute """ + UPDATE conversations + SET type = 'community' + WHERE space_type = 'community' + """ + + # For chat space_type (or null), the type field already has the correct value (dm/group/channel) + # So no update needed for those + + # Remove the space_type column since it's now redundant + # Keep community-related fields as they're still useful for type = "community" + alter table(:conversations) do + remove :space_type + end + end + + def down do + # Re-add the space_type column + alter table(:conversations) do + add :space_type, :string, default: "chat" + end + + # Restore space_type based on type + execute """ + UPDATE conversations + SET space_type = 'timeline' + WHERE type = 'timeline' + """ + + execute """ + UPDATE conversations + SET space_type = 'community' + WHERE type = 'community' + """ + + execute """ + UPDATE conversations + SET space_type = 'chat' + WHERE type IN ('dm', 'group', 'channel') + """ + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251017063439_add_show_qr_code_to_user_profiles.exs b/apps/elektrine/priv/repo/migrations/20251017063439_add_show_qr_code_to_user_profiles.exs new file mode 100644 index 0000000..bbb38ce --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251017063439_add_show_qr_code_to_user_profiles.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddShowQrCodeToUserProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + add :show_qr_code, :boolean, default: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251017070808_remove_show_qr_code_from_user_profiles.exs b/apps/elektrine/priv/repo/migrations/20251017070808_remove_show_qr_code_from_user_profiles.exs new file mode 100644 index 0000000..edb2e5a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251017070808_remove_show_qr_code_from_user_profiles.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.RemoveShowQrCodeFromUserProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + remove :show_qr_code + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251017160525_drop_approved_senders_table.exs b/apps/elektrine/priv/repo/migrations/20251017160525_drop_approved_senders_table.exs new file mode 100644 index 0000000..a589ad9 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251017160525_drop_approved_senders_table.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.DropApprovedSendersTable do + use Ecto.Migration + + def change do + # Drop the approved_senders table - orphaned after screener feature removal + # This table was created in 20250608202829 but never removed when screener was removed + drop_if_exists table(:approved_senders) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251017160604_cleanup_orphaned_indexes.exs b/apps/elektrine/priv/repo/migrations/20251017160604_cleanup_orphaned_indexes.exs new file mode 100644 index 0000000..c78ee54 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251017160604_cleanup_orphaned_indexes.exs @@ -0,0 +1,21 @@ +defmodule Elektrine.Repo.Migrations.CleanupOrphanedIndexes do + use Ecto.Migration + + def change do + # Drop orphaned indexes from removed screener feature + # These columns (screener_status, sender_approved) were removed in 20250726184803_remove_screener_feature.exs + # but the indexes may still exist if the migration didn't fully clean them up + drop_if_exists index(:email_messages, [:screener_status]) + drop_if_exists index(:email_messages, [:sender_approved]) + + # Drop orphaned index from renamed column + # set_aside_at was renamed to stack_at in 20250927161505_rename_email_categories.exs + # but the old index may still exist + drop_if_exists index(:email_messages, [:set_aside_at]) + + # Drop indexes for approved_senders table (being dropped in previous migration) + drop_if_exists index(:approved_senders, [:mailbox_id]) + drop_if_exists index(:approved_senders, [:email_address]) + drop_if_exists index(:approved_senders, [:email_address, :mailbox_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251018050726_add_post_types_support.exs b/apps/elektrine/priv/repo/migrations/20251018050726_add_post_types_support.exs new file mode 100644 index 0000000..5e25008 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251018050726_add_post_types_support.exs @@ -0,0 +1,68 @@ +defmodule Elektrine.Repo.Migrations.AddPostTypesSupport do + use Ecto.Migration + + def change do + # Add primary_url field for link posts + alter table(:messages) do + # Main URL for link-type posts + add :primary_url, :text + end + + create index(:messages, [:primary_url]) + + # Create polls table for poll-type posts + create table(:polls) do + add :message_id, references(:messages, on_delete: :delete_all), null: false + add :question, :text, null: false + # nil means never closes + add :closes_at, :utc_datetime + # Allow multiple selections + add :allow_multiple, :boolean, default: false + # Cached total for performance + add :total_votes, :integer, default: 0 + + timestamps() + end + + create unique_index(:polls, [:message_id]) + + # Create poll options + create table(:poll_options) do + add :poll_id, references(:polls, on_delete: :delete_all), null: false + add :option_text, :string, null: false + # Display order + add :position, :integer, default: 0 + # Cached count for performance + add :vote_count, :integer, default: 0 + + timestamps() + end + + create index(:poll_options, [:poll_id]) + + # Create poll votes + create table(:poll_votes) do + add :poll_id, references(:polls, on_delete: :delete_all), null: false + add :option_id, references(:poll_options, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps(updated_at: false) + end + + create unique_index(:poll_votes, [:poll_id, :user_id, :option_id]) + create index(:poll_votes, [:poll_id]) + create index(:poll_votes, [:user_id]) + create index(:poll_votes, [:option_id]) + + # Update post_type constraint to include new types + execute( + "ALTER TABLE messages DROP CONSTRAINT IF EXISTS messages_post_type_check", + "ALTER TABLE messages ADD CONSTRAINT messages_post_type_check CHECK (post_type IN ('message', 'post', 'comment', 'share', 'discussion'))" + ) + + execute( + "ALTER TABLE messages ADD CONSTRAINT messages_post_type_check CHECK (post_type IN ('message', 'post', 'comment', 'share', 'discussion', 'link', 'poll'))", + "ALTER TABLE messages DROP CONSTRAINT messages_post_type_check" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251018124609_add_comprehensive_moderation_features.exs b/apps/elektrine/priv/repo/migrations/20251018124609_add_comprehensive_moderation_features.exs new file mode 100644 index 0000000..96248e1 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251018124609_add_comprehensive_moderation_features.exs @@ -0,0 +1,99 @@ +defmodule Elektrine.Repo.Migrations.AddComprehensiveModerationFeatures do + use Ecto.Migration + + def change do + # ===== Thread Locking ===== + alter table(:messages) do + add :locked_at, :utc_datetime + add :locked_by_id, references(:users, on_delete: :nilify_all) + add :lock_reason, :text + # "pending", "approved", "rejected" + add :approval_status, :string + add :approved_by_id, references(:users, on_delete: :nilify_all) + add :approved_at, :utc_datetime + end + + create index(:messages, [:locked_at]) + create index(:messages, [:locked_by_id]) + create index(:messages, [:approval_status]) + + # ===== Community Settings ===== + alter table(:conversations) do + # 0 = disabled + add :slow_mode_seconds, :integer, default: 0 + add :approval_mode_enabled, :boolean, default: false + # Auto-approve after X posts + add :approval_threshold_posts, :integer, default: 3 + end + + # ===== User Warnings ===== + create table(:user_warnings) do + add :conversation_id, references(:conversations, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + add :warned_by_id, references(:users, on_delete: :nilify_all), null: false + add :reason, :text, null: false + # "low", "medium", "high" + add :severity, :string, default: "low" + add :acknowledged_at, :utc_datetime + add :related_message_id, references(:messages, on_delete: :nilify_all) + + timestamps(updated_at: false) + end + + create index(:user_warnings, [:conversation_id, :user_id]) + create index(:user_warnings, [:user_id]) + create index(:user_warnings, [:warned_by_id]) + + # ===== Moderator Notes ===== + create table(:moderator_notes) do + add :conversation_id, references(:conversations, on_delete: :delete_all), null: false + add :target_user_id, references(:users, on_delete: :delete_all), null: false + add :created_by_id, references(:users, on_delete: :nilify_all), null: false + add :note, :text, null: false + add :is_important, :boolean, default: false + + timestamps(updated_at: false) + end + + create index(:moderator_notes, [:conversation_id, :target_user_id]) + create index(:moderator_notes, [:target_user_id]) + create index(:moderator_notes, [:created_by_id]) + + # ===== Auto-Moderation Rules ===== + create table(:auto_mod_rules) do + add :conversation_id, references(:conversations, on_delete: :delete_all), null: false + add :name, :string, null: false + # "keyword", "regex", "link_domain", "spam_pattern" + add :rule_type, :string, null: false + add :pattern, :text, null: false + # "flag", "remove", "hold_for_review" + add :action, :string, null: false + add :enabled, :boolean, default: true + add :created_by_id, references(:users, on_delete: :nilify_all), null: false + + timestamps() + end + + create index(:auto_mod_rules, [:conversation_id]) + create index(:auto_mod_rules, [:enabled]) + + # ===== Enhance existing moderation_actions table ===== + alter table(:moderation_actions) do + add :target_message_id, references(:messages, on_delete: :nilify_all) + end + + create index(:moderation_actions, [:target_message_id]) + + # ===== Post Rate Limiting (for slow mode) ===== + create table(:user_post_timestamps) do + add :conversation_id, references(:conversations, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + add :last_post_at, :utc_datetime, null: false + + timestamps(updated_at: false) + end + + create unique_index(:user_post_timestamps, [:conversation_id, :user_id]) + create index(:user_post_timestamps, [:last_post_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251018162421_add_onboarding_fields.exs b/apps/elektrine/priv/repo/migrations/20251018162421_add_onboarding_fields.exs new file mode 100644 index 0000000..e7ba2b3 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251018162421_add_onboarding_fields.exs @@ -0,0 +1,20 @@ +defmodule Elektrine.Repo.Migrations.AddOnboardingFields do + use Ecto.Migration + + def change do + alter table(:users) do + add :onboarding_completed, :boolean, default: false + add :onboarding_completed_at, :utc_datetime + add :onboarding_step, :integer, default: 1 + end + + create index(:users, [:onboarding_completed]) + + # Mark all existing users as having completed onboarding + # (Only new users created after this migration will go through onboarding) + execute( + "UPDATE users SET onboarding_completed = true, onboarding_completed_at = NOW() WHERE onboarding_completed IS NULL OR onboarding_completed = false", + "UPDATE users SET onboarding_completed = false, onboarding_completed_at = NULL WHERE onboarding_completed = true" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251018234059_add_contacts_system.exs b/apps/elektrine/priv/repo/migrations/20251018234059_add_contacts_system.exs new file mode 100644 index 0000000..ce1bc87 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251018234059_add_contacts_system.exs @@ -0,0 +1,35 @@ +defmodule Elektrine.Repo.Migrations.AddContactsSystem do + use Ecto.Migration + + def change do + # Contact groups/categories + create table(:contact_groups) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :name, :string, null: false + add :color, :string, default: "#3b82f6" + + timestamps() + end + + create index(:contact_groups, [:user_id]) + + # Contacts + create table(:contacts) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :name, :string, null: false + add :email, :string, null: false + add :phone, :string + add :organization, :string + add :notes, :text + add :favorite, :boolean, default: false + add :group_id, references(:contact_groups, on_delete: :nilify_all) + + timestamps() + end + + create index(:contacts, [:user_id]) + create index(:contacts, [:user_id, :favorite]) + create index(:contacts, [:group_id]) + create unique_index(:contacts, [:user_id, :email]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251019041333_add_email_signature_to_users.exs b/apps/elektrine/priv/repo/migrations/20251019041333_add_email_signature_to_users.exs new file mode 100644 index 0000000..24df4aa --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251019041333_add_email_signature_to_users.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddEmailSignatureToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :email_signature, :text + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251021165630_create_email_unsubscribes.exs b/apps/elektrine/priv/repo/migrations/20251021165630_create_email_unsubscribes.exs new file mode 100644 index 0000000..7e35e4b --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251021165630_create_email_unsubscribes.exs @@ -0,0 +1,22 @@ +defmodule Elektrine.Repo.Migrations.CreateEmailUnsubscribes do + use Ecto.Migration + + def change do + create table(:email_unsubscribes) do + add :email, :string, null: false + add :user_id, references(:users, on_delete: :delete_all) + add :list_id, :string + add :token, :string, null: false + add :unsubscribed_at, :utc_datetime, null: false + add :ip_address, :string + add :user_agent, :text + + timestamps() + end + + create index(:email_unsubscribes, [:email]) + create index(:email_unsubscribes, [:user_id]) + create index(:email_unsubscribes, [:token]) + create unique_index(:email_unsubscribes, [:email, :list_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251025131913_create_vpn_tables.exs b/apps/elektrine/priv/repo/migrations/20251025131913_create_vpn_tables.exs new file mode 100644 index 0000000..b6acaf1 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251025131913_create_vpn_tables.exs @@ -0,0 +1,82 @@ +defmodule Elektrine.Repo.Migrations.CreateVpnTables do + use Ecto.Migration + + def change do + # VPN Servers table + create table(:vpn_servers) do + add :name, :string, null: false + # e.g., "US - New York" + add :location, :string, null: false + # e.g., "US" + add :country_code, :string, size: 2 + add :city, :string + add :public_ip, :string, null: false + add :public_key, :string, null: false + add :endpoint_port, :integer, null: false, default: 51820 + # e.g., "10.8.0.0/24" + add :internal_ip_range, :string, null: false + add :dns_servers, :string, default: "1.1.1.1, 1.0.0.1" + # active, maintenance, offline + add :status, :string, null: false, default: "active" + add :max_users, :integer, default: 100 + add :current_users, :integer, default: 0 + # URL for server management API + add :api_endpoint, :string + # Encrypted API key for authentication + add :api_key, :string + add :metadata, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + create unique_index(:vpn_servers, [:public_ip]) + create index(:vpn_servers, [:status]) + create index(:vpn_servers, [:country_code]) + + # VPN User Configurations table + create table(:vpn_user_configs) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :vpn_server_id, references(:vpn_servers, on_delete: :restrict), null: false + add :public_key, :string, null: false + # Encrypted WireGuard private key + add :private_key, :binary + # e.g., "10.8.0.2/32" + add :allocated_ip, :string, null: false + # Full tunnel by default + add :allowed_ips, :string, default: "0.0.0.0/0, ::/0" + # active, suspended, revoked + add :status, :string, null: false, default: "active" + add :last_handshake_at, :utc_datetime + add :bytes_sent, :bigint, default: 0 + add :bytes_received, :bigint, default: 0 + add :persistent_keepalive, :integer, default: 25 + add :metadata, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + create unique_index(:vpn_user_configs, [:user_id, :vpn_server_id]) + create unique_index(:vpn_user_configs, [:vpn_server_id, :allocated_ip]) + create unique_index(:vpn_user_configs, [:public_key]) + create index(:vpn_user_configs, [:user_id]) + create index(:vpn_user_configs, [:vpn_server_id]) + create index(:vpn_user_configs, [:status]) + + # VPN Connection Logs table (for analytics and monitoring) + create table(:vpn_connection_logs) do + add :vpn_user_config_id, references(:vpn_user_configs, on_delete: :delete_all), null: false + add :connected_at, :utc_datetime, null: false + add :disconnected_at, :utc_datetime + add :bytes_sent, :bigint, default: 0 + add :bytes_received, :bigint, default: 0 + # The actual IP the client connected from + add :client_ip, :string + add :metadata, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + create index(:vpn_connection_logs, [:vpn_user_config_id]) + create index(:vpn_connection_logs, [:connected_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251026013643_add_bandwidth_quota_to_vpn_user_configs.exs b/apps/elektrine/priv/repo/migrations/20251026013643_add_bandwidth_quota_to_vpn_user_configs.exs new file mode 100644 index 0000000..1b28cb6 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251026013643_add_bandwidth_quota_to_vpn_user_configs.exs @@ -0,0 +1,16 @@ +defmodule Elektrine.Repo.Migrations.AddBandwidthQuotaToVpnUserConfigs do + use Ecto.Migration + + def change do + alter table(:vpn_user_configs) do + # Bandwidth quota in bytes (default 10GB = 10737418240 bytes) + add :bandwidth_quota_bytes, :bigint, default: 10_737_418_240, null: false + + # When the quota period started (for monthly resets) + add :quota_period_start, :utc_datetime + + # Total bandwidth used in current period (bytes_sent + bytes_received since last reset) + add :quota_used_bytes, :bigint, default: 0, null: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251026014738_add_rate_limit_to_vpn_user_configs.exs b/apps/elektrine/priv/repo/migrations/20251026014738_add_rate_limit_to_vpn_user_configs.exs new file mode 100644 index 0000000..f088013 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251026014738_add_rate_limit_to_vpn_user_configs.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.AddRateLimitToVpnUserConfigs do + use Ecto.Migration + + def change do + alter table(:vpn_user_configs) do + # Rate limit in Mbps (default 50 Mbps to prevent bandwidth hogging) + add :rate_limit_mbps, :integer, default: 50, null: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251026025047_add_trust_level_system.exs b/apps/elektrine/priv/repo/migrations/20251026025047_add_trust_level_system.exs new file mode 100644 index 0000000..670c942 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251026025047_add_trust_level_system.exs @@ -0,0 +1,69 @@ +defmodule Elektrine.Repo.Migrations.AddTrustLevelSystem do + use Ecto.Migration + + def change do + # Add trust level to users table + alter table(:users) do + add :trust_level, :integer, default: 0, null: false + # Prevent auto-promotion if manually set + add :trust_level_locked, :boolean, default: false + # When they reached current level + add :promoted_at, :utc_datetime + end + + # User activity stats for trust level calculation + create table(:user_activity_stats) do + add :user_id, references(:users, on_delete: :delete_all), null: false + + # Content creation + add :posts_created, :integer, default: 0 + add :topics_created, :integer, default: 0 + add :replies_created, :integer, default: 0 + + # Engagement metrics + add :likes_given, :integer, default: 0 + add :likes_received, :integer, default: 0 + add :replies_received, :integer, default: 0 + + # Reading metrics + add :posts_read, :integer, default: 0 + add :topics_entered, :integer, default: 0 + add :time_read_seconds, :integer, default: 0 + + # Visit tracking + add :days_visited, :integer, default: 0 + add :last_visit_date, :date + + # Moderation + add :flags_given, :integer, default: 0 + add :flags_received, :integer, default: 0 + # When your flags were valid + add :flags_agreed, :integer, default: 0 + + # Penalties + add :posts_deleted, :integer, default: 0 + add :suspensions_count, :integer, default: 0 + + timestamps(type: :utc_datetime) + end + + create unique_index(:user_activity_stats, [:user_id]) + + # Trust level change log + create table(:trust_level_logs) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :old_level, :integer, null: false + add :new_level, :integer, null: false + # "automatic", "manual", "penalty" + add :reason, :string + # If manually changed + add :changed_by_user_id, references(:users, on_delete: :nilify_all) + add :notes, :text + + timestamps(type: :utc_datetime) + end + + create index(:trust_level_logs, [:user_id]) + create index(:trust_level_logs, [:inserted_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251026043133_remove_siem_intel_news_tables.exs b/apps/elektrine/priv/repo/migrations/20251026043133_remove_siem_intel_news_tables.exs new file mode 100644 index 0000000..7190c4a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251026043133_remove_siem_intel_news_tables.exs @@ -0,0 +1,44 @@ +defmodule Elektrine.Repo.Migrations.RemoveSiemIntelNewsTables do + use Ecto.Migration + + def change do + # Drop tables that depend on both SIEM and threat intel first + drop_if_exists table(:threat_matches) + drop_if_exists table(:news_threat_correlations) + + # Drop SIEM tables (children first) + drop_if_exists table(:siem_alert_events) + drop_if_exists table(:siem_incident_alerts) + drop_if_exists table(:siem_incidents) + drop_if_exists table(:siem_alerts) + drop_if_exists table(:siem_events) + drop_if_exists table(:siem_rules) + drop_if_exists table(:siem_sources) + drop_if_exists table(:siem_dashboards) + + # Drop ransomware tables (depends on threat_indicators) + drop_if_exists table(:ransomware_indicators) + drop_if_exists table(:ransomware_victims) + drop_if_exists table(:ransomware_threats) + drop_if_exists table(:ransomware_groups) + + # Drop threat intel tables + drop_if_exists table(:threat_campaign_indicators) + drop_if_exists table(:threat_campaigns) + drop_if_exists table(:threat_indicators) + drop_if_exists table(:threat_feeds) + + # Drop underground intel tables + drop_if_exists table(:telegram_messages) + drop_if_exists table(:telegram_channels) + drop_if_exists table(:malware_servers) + drop_if_exists table(:compromised_credentials) + drop_if_exists table(:breached_databases) + drop_if_exists table(:data_breaches) + drop_if_exists table(:underground_forums) + + # Drop cybersec news tables + drop_if_exists table(:cybersecurity_news) + drop_if_exists table(:news_feeds) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251026052454_remove_github_fields_from_users.exs b/apps/elektrine/priv/repo/migrations/20251026052454_remove_github_fields_from_users.exs new file mode 100644 index 0000000..ca15744 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251026052454_remove_github_fields_from_users.exs @@ -0,0 +1,12 @@ +defmodule Elektrine.Repo.Migrations.RemoveGithubFieldsFromUsers do + use Ecto.Migration + + def change do + alter table(:users) do + remove_if_exists :github_access_token, :string + remove_if_exists :github_refresh_token, :string + remove_if_exists :github_token_expires_at, :utc_datetime + remove_if_exists :github_username, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251026081602_add_minimum_trust_level_to_vpn_servers.exs b/apps/elektrine/priv/repo/migrations/20251026081602_add_minimum_trust_level_to_vpn_servers.exs new file mode 100644 index 0000000..2ecee55 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251026081602_add_minimum_trust_level_to_vpn_servers.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddMinimumTrustLevelToVpnServers do + use Ecto.Migration + + def change do + alter table(:vpn_servers) do + add :minimum_trust_level, :integer, default: 0, null: false + end + + create index(:vpn_servers, [:minimum_trust_level]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251026090202_change_vpn_server_foreign_key_to_cascade.exs b/apps/elektrine/priv/repo/migrations/20251026090202_change_vpn_server_foreign_key_to_cascade.exs new file mode 100644 index 0000000..c8605f6 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251026090202_change_vpn_server_foreign_key_to_cascade.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Repo.Migrations.ChangeVpnServerForeignKeyToCascade do + use Ecto.Migration + + def up do + # Drop the existing foreign key constraint + execute "ALTER TABLE vpn_user_configs DROP CONSTRAINT vpn_user_configs_vpn_server_id_fkey" + + # Add new constraint with cascade delete + alter table(:vpn_user_configs) do + modify :vpn_server_id, references(:vpn_servers, on_delete: :delete_all), null: false + end + end + + def down do + # Drop the cascade constraint + execute "ALTER TABLE vpn_user_configs DROP CONSTRAINT vpn_user_configs_vpn_server_id_fkey" + + # Restore the original restrict constraint + alter table(:vpn_user_configs) do + modify :vpn_server_id, references(:vpn_servers, on_delete: :restrict), null: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251027022325_add_notification_preferences.exs b/apps/elektrine/priv/repo/migrations/20251027022325_add_notification_preferences.exs new file mode 100644 index 0000000..3d415b7 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251027022325_add_notification_preferences.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddNotificationPreferences do + use Ecto.Migration + + def change do + alter table(:users) do + add :notify_on_reply, :boolean, default: true + add :notify_on_like, :boolean, default: true + add :notify_on_email_received, :boolean, default: true + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251027023409_add_discussion_and_comment_notification_preferences.exs b/apps/elektrine/priv/repo/migrations/20251027023409_add_discussion_and_comment_notification_preferences.exs new file mode 100644 index 0000000..d1c8e67 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251027023409_add_discussion_and_comment_notification_preferences.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.AddDiscussionAndCommentNotificationPreferences do + use Ecto.Migration + + def change do + alter table(:users) do + add :notify_on_discussion_reply, :boolean, default: true + add :notify_on_comment, :boolean, default: true + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251028160556_add_email_performance_indexes.exs b/apps/elektrine/priv/repo/migrations/20251028160556_add_email_performance_indexes.exs new file mode 100644 index 0000000..a72f8a2 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251028160556_add_email_performance_indexes.exs @@ -0,0 +1,77 @@ +defmodule Elektrine.Repo.Migrations.AddEmailPerformanceIndexes do + use Ecto.Migration + + def change do + # Composite indexes for common email queries to improve performance + # These support the most frequent query patterns in digest, ledger, stack, and boomerang views + + # Index for Feed (digest) queries: mailbox_id + category + flags + inserted_at + create_if_not_exists index( + :email_messages, + [:mailbox_id, :category, :spam, :archived, :deleted, :inserted_at], + where: + "category = 'feed' AND NOT spam AND NOT archived AND NOT deleted", + name: :email_messages_feed_performance_idx + ) + + # Index for Ledger queries: mailbox_id + category + flags + inserted_at + create_if_not_exists index( + :email_messages, + [:mailbox_id, :category, :spam, :archived, :deleted, :inserted_at], + where: + "category = 'ledger' AND NOT spam AND NOT archived AND NOT deleted", + name: :email_messages_ledger_performance_idx + ) + + # Index for Stack queries: mailbox_id + category + stack_at + flags + create_if_not_exists index( + :email_messages, + [:mailbox_id, :category, :stack_at, :spam, :archived, :deleted], + where: + "category = 'stack' AND stack_at IS NOT NULL AND NOT spam AND NOT archived AND NOT deleted", + name: :email_messages_stack_performance_idx + ) + + # Index for Boomerang (reply_later) queries: mailbox_id + reply_later_at + flags + create_if_not_exists index( + :email_messages, + [:mailbox_id, :reply_later_at, :spam, :archived, :deleted], + where: + "reply_later_at IS NOT NULL AND NOT spam AND NOT archived AND NOT deleted", + name: :email_messages_boomerang_performance_idx + ) + + # Index for unread messages: mailbox_id + read + flags + inserted_at + create_if_not_exists index( + :email_messages, + [:mailbox_id, :read, :spam, :archived, :deleted, :inserted_at], + where: "NOT read AND NOT spam AND NOT archived AND NOT deleted", + name: :email_messages_unread_performance_idx + ) + + # Index for read messages: mailbox_id + read + flags + inserted_at + create_if_not_exists index( + :email_messages, + [:mailbox_id, :read, :spam, :archived, :deleted, :inserted_at], + where: "read AND NOT spam AND NOT archived AND NOT deleted", + name: :email_messages_read_performance_idx + ) + + # Index for inbox queries (complex filter): mailbox_id + multiple conditions + create_if_not_exists index( + :email_messages, + [ + :mailbox_id, + :category, + :spam, + :archived, + :deleted, + :reply_later_at, + :inserted_at + ], + where: + "NOT spam AND NOT archived AND NOT deleted AND category NOT IN ('feed', 'ledger', 'stack') AND reply_later_at IS NULL", + name: :email_messages_inbox_performance_idx + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251101202100_add_gallery_to_post_type_constraint.exs b/apps/elektrine/priv/repo/migrations/20251101202100_add_gallery_to_post_type_constraint.exs new file mode 100644 index 0000000..9feab4a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251101202100_add_gallery_to_post_type_constraint.exs @@ -0,0 +1,25 @@ +defmodule Elektrine.Repo.Migrations.AddGalleryToPostTypeConstraint do + use Ecto.Migration + + def up do + # Drop the old check constraint + execute "ALTER TABLE messages DROP CONSTRAINT IF EXISTS messages_post_type_check" + + # Add the new check constraint with "gallery" included + execute """ + ALTER TABLE messages ADD CONSTRAINT messages_post_type_check + CHECK (post_type IN ('message', 'post', 'comment', 'share', 'discussion', 'link', 'poll', 'gallery')) + """ + end + + def down do + # Drop the new constraint + execute "ALTER TABLE messages DROP CONSTRAINT IF EXISTS messages_post_type_check" + + # Restore the old constraint without "gallery" + execute """ + ALTER TABLE messages ADD CONSTRAINT messages_post_type_check + CHECK (post_type IN ('message', 'post', 'comment', 'share', 'discussion', 'link', 'poll')) + """ + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251102020000_create_post_views.exs b/apps/elektrine/priv/repo/migrations/20251102020000_create_post_views.exs new file mode 100644 index 0000000..8c176d7 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251102020000_create_post_views.exs @@ -0,0 +1,22 @@ +defmodule Elektrine.Repo.Migrations.CreatePostViews do + use Ecto.Migration + + def change do + create table(:post_views) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :message_id, references(:messages, on_delete: :delete_all), null: false + # How long they viewed it + add :view_duration_seconds, :integer + # Did they scroll to bottom / view fully + add :completed, :boolean, default: false + + # Only track created_at, views don't update + timestamps(updated_at: false) + end + + create index(:post_views, [:user_id]) + create index(:post_views, [:message_id]) + create index(:post_views, [:user_id, :message_id]) + create index(:post_views, [:inserted_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251102040000_add_performance_indexes.exs b/apps/elektrine/priv/repo/migrations/20251102040000_add_performance_indexes.exs new file mode 100644 index 0000000..e763c65 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251102040000_add_performance_indexes.exs @@ -0,0 +1,81 @@ +defmodule Elektrine.Repo.Migrations.AddPerformanceIndexes do + use Ecto.Migration + + def change do + # Messages table indexes + create_if_not_exists index(:messages, [:sender_id]) + create_if_not_exists index(:messages, [:conversation_id, :deleted_at]) + create_if_not_exists index(:messages, [:reply_to_id]) + create_if_not_exists index(:messages, [:shared_message_id]) + create_if_not_exists index(:messages, [:link_preview_id]) + + # Descending index for chronological sorting + execute "CREATE INDEX IF NOT EXISTS messages_inserted_at_desc_index ON messages (inserted_at DESC)", + "DROP INDEX IF EXISTS messages_inserted_at_desc_index" + + # Composite index for common query pattern + create_if_not_exists index(:messages, [:post_type, :visibility], + where: "deleted_at IS NULL", + name: :messages_post_type_visibility_active_index + ) + + # Post likes indexes + create_if_not_exists index(:post_likes, [:message_id]) + create_if_not_exists index(:post_likes, [:user_id, :message_id]) + + # Conversation members indexes + create_if_not_exists index(:conversation_members, [:user_id, :conversation_id], + where: "left_at IS NULL", + name: :conversation_members_active_index + ) + + create_if_not_exists index(:conversation_members, [:last_read_at]) + + # Follows indexes + create_if_not_exists index(:follows, [:follower_id, :followed_id]) + + # Notifications indexes + create_if_not_exists index(:notifications, [:user_id, :read_at, :dismissed_at], + name: :notifications_user_status_index + ) + + create_if_not_exists index(:notifications, [:source_type, :source_id]) + + # Post views indexes (only if table exists) + execute """ + DO $$ + BEGIN + IF EXISTS (SELECT FROM pg_tables WHERE tablename = 'post_views') THEN + CREATE INDEX IF NOT EXISTS post_views_user_message_index ON post_views (user_id, message_id); + CREATE INDEX IF NOT EXISTS post_views_message_index ON post_views (message_id); + CREATE INDEX IF NOT EXISTS post_views_inserted_at_index ON post_views (inserted_at); + END IF; + END $$; + """, + "" + + # Hashtags indexes (only if table exists) + execute """ + DO $$ + BEGIN + IF EXISTS (SELECT FROM pg_tables WHERE tablename = 'hashtags') THEN + CREATE INDEX IF NOT EXISTS hashtags_normalized_name_index ON hashtags (normalized_name); + CREATE INDEX IF NOT EXISTS hashtags_use_count_desc_index ON hashtags (use_count DESC); + END IF; + END $$; + """, + "" + + # Message hashtags indexes (only if table exists) + execute """ + DO $$ + BEGIN + IF EXISTS (SELECT FROM pg_tables WHERE tablename = 'message_hashtags') THEN + CREATE INDEX IF NOT EXISTS message_hashtags_message_index ON message_hashtags (message_id); + CREATE INDEX IF NOT EXISTS message_hashtags_hashtag_index ON message_hashtags (hashtag_id); + END IF; + END $$; + """, + "" + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251102050000_add_preferred_email_domain_to_users.exs b/apps/elektrine/priv/repo/migrations/20251102050000_add_preferred_email_domain_to_users.exs new file mode 100644 index 0000000..f079288 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251102050000_add_preferred_email_domain_to_users.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddPreferredEmailDomainToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :preferred_email_domain, :string, default: "example.com" + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251106000000_create_trusted_devices.exs b/apps/elektrine/priv/repo/migrations/20251106000000_create_trusted_devices.exs new file mode 100644 index 0000000..39b8cf4 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251106000000_create_trusted_devices.exs @@ -0,0 +1,21 @@ +defmodule Elektrine.Repo.Migrations.CreateTrustedDevices do + use Ecto.Migration + + def change do + create table(:trusted_devices) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :device_token, :string, null: false + add :device_name, :string + add :user_agent, :string + add :ip_address, :string + add :last_used_at, :utc_datetime + add :expires_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create index(:trusted_devices, [:user_id]) + create unique_index(:trusted_devices, [:device_token]) + create index(:trusted_devices, [:expires_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251108000001_create_activitypub_tables.exs b/apps/elektrine/priv/repo/migrations/20251108000001_create_activitypub_tables.exs new file mode 100644 index 0000000..9eb845e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251108000001_create_activitypub_tables.exs @@ -0,0 +1,154 @@ +defmodule Elektrine.Repo.Migrations.CreateActivitypubTables do + use Ecto.Migration + + def change do + # Remote actors (users from other instances) + create table(:activitypub_actors) do + # Full ActivityPub ID (e.g., https://mastodon.social/users/alice) + add :uri, :string, null: false + # Local username part + add :username, :string, null: false + # Remote domain (e.g., mastodon.social) + add :domain, :string, null: false + add :display_name, :string + # Bio + add :summary, :text + add :avatar_url, :string + add :header_url, :string + add :inbox_url, :string, null: false + add :outbox_url, :string + add :followers_url, :string + add :following_url, :string + add :public_key, :text, null: false + add :manually_approves_followers, :boolean, default: false + # Person, Service, Group, etc. + add :actor_type, :string, default: "Person" + add :last_fetched_at, :utc_datetime + # Store additional fields + add :metadata, :map, default: %{} + + timestamps() + end + + create unique_index(:activitypub_actors, [:uri]) + create index(:activitypub_actors, [:domain]) + create index(:activitypub_actors, [:username, :domain]) + + # Activities (incoming and outgoing) + create table(:activitypub_activities) do + # Full ActivityPub ID + add :activity_id, :string, null: false + # Create, Follow, Like, Announce, etc. + add :activity_type, :string, null: false + add :actor_uri, :string, null: false + # The object being acted upon + add :object_id, :string + # Full JSON-LD activity + add :data, :map, null: false + # Created locally vs received + add :local, :boolean, default: false + # For local activities + add :internal_user_id, references(:users, on_delete: :delete_all) + # Link to local message + add :internal_message_id, references(:messages, on_delete: :delete_all) + + timestamps() + end + + create unique_index(:activitypub_activities, [:activity_id]) + create index(:activitypub_activities, [:activity_type]) + create index(:activitypub_activities, [:actor_uri]) + create index(:activitypub_activities, [:object_id]) + create index(:activitypub_activities, [:internal_user_id]) + create index(:activitypub_activities, [:internal_message_id]) + create index(:activitypub_activities, [:local]) + + # Activity deliveries (track outgoing activity delivery status) + create table(:activitypub_deliveries) do + add :activity_id, references(:activitypub_activities, on_delete: :delete_all), null: false + add :inbox_url, :string, null: false + # pending, delivered, failed + add :status, :string, default: "pending" + add :attempts, :integer, default: 0 + add :last_attempt_at, :utc_datetime + add :next_retry_at, :utc_datetime + add :error_message, :text + + timestamps() + end + + create index(:activitypub_deliveries, [:activity_id]) + create index(:activitypub_deliveries, [:status]) + create index(:activitypub_deliveries, [:next_retry_at]) + + # Instance blocks/allows for moderation + create table(:activitypub_instances) do + add :domain, :string, null: false + add :blocked, :boolean, default: false + # Allow but don't show in public timelines + add :silenced, :boolean, default: false + add :reason, :text + add :blocked_by_id, references(:users, on_delete: :nilify_all) + add :blocked_at, :utc_datetime + + timestamps() + end + + create unique_index(:activitypub_instances, [:domain]) + create index(:activitypub_instances, [:blocked]) + + # User-level instance/user blocks + create table(:activitypub_user_blocks) do + add :user_id, references(:users, on_delete: :delete_all), null: false + # Can be an actor URI or domain + add :blocked_uri, :string, null: false + # user or domain + add :block_type, :string, default: "user" + + timestamps() + end + + create index(:activitypub_user_blocks, [:user_id]) + create index(:activitypub_user_blocks, [:blocked_uri]) + create unique_index(:activitypub_user_blocks, [:user_id, :blocked_uri]) + + # Add ActivityPub fields to users table + alter table(:users) do + add :activitypub_enabled, :boolean, default: true + # RSA private key for HTTP signatures + add :activitypub_private_key, :text + # RSA public key + add :activitypub_public_key, :text + add :activitypub_manually_approve_followers, :boolean, default: false + end + + # Add ActivityPub fields to messages table for tracking federated content + alter table(:messages) do + # Full ActivityPub ID for this message + add :activitypub_id, :string + # Public URL for this message + add :activitypub_url, :string + # Whether this came from federation + add :federated, :boolean, default: false + # If from remote user + add :remote_actor_id, references(:activitypub_actors, on_delete: :nilify_all) + end + + create unique_index(:messages, [:activitypub_id], where: "activitypub_id IS NOT NULL") + create index(:messages, [:remote_actor_id]) + create index(:messages, [:federated]) + + # Add ActivityPub tracking to follows + alter table(:follows) do + # ActivityPub Follow activity ID + add :activitypub_id, :string + # If following/followed by remote user + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all) + # For follow requests that need approval + add :pending, :boolean, default: false + end + + create index(:follows, [:remote_actor_id]) + create index(:follows, [:pending]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251108000002_generate_activitypub_keys_for_existing_users.exs b/apps/elektrine/priv/repo/migrations/20251108000002_generate_activitypub_keys_for_existing_users.exs new file mode 100644 index 0000000..3887953 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251108000002_generate_activitypub_keys_for_existing_users.exs @@ -0,0 +1,14 @@ +defmodule Elektrine.Repo.Migrations.GenerateActivitypubKeysForExistingUsers do + use Ecto.Migration + + def up do + # This migration will be run after the schema migration + # We'll generate keys for existing users in a separate task + # Run: mix run priv/repo/generate_activitypub_keys.exs + :ok + end + + def down do + :ok + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251108000003_make_follower_id_nullable.exs b/apps/elektrine/priv/repo/migrations/20251108000003_make_follower_id_nullable.exs new file mode 100644 index 0000000..d1ce764 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251108000003_make_follower_id_nullable.exs @@ -0,0 +1,12 @@ +defmodule Elektrine.Repo.Migrations.MakeFollowerIdNullable do + use Ecto.Migration + + def change do + # Make follower_id and followed_id nullable to support remote follows + # Remote follows use remote_actor_id in combination with one of these + alter table(:follows) do + modify :follower_id, :integer, null: true + modify :followed_id, :integer, null: true + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251108000004_make_conversation_id_nullable_for_federation.exs b/apps/elektrine/priv/repo/migrations/20251108000004_make_conversation_id_nullable_for_federation.exs new file mode 100644 index 0000000..81d9ff8 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251108000004_make_conversation_id_nullable_for_federation.exs @@ -0,0 +1,12 @@ +defmodule Elektrine.Repo.Migrations.MakeConversationIdNullableForFederation do + use Ecto.Migration + + def change do + # Make conversation_id and sender_id nullable to support federated messages + # Federated messages use remote_actor_id instead + alter table(:messages) do + modify :conversation_id, :integer, null: true + modify :sender_id, :integer, null: true + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251108000005_make_followed_id_nullable.exs b/apps/elektrine/priv/repo/migrations/20251108000005_make_followed_id_nullable.exs new file mode 100644 index 0000000..960f5ce --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251108000005_make_followed_id_nullable.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.MakeFollowedIdNullable do + use Ecto.Migration + + def change do + # Make followed_id nullable for local->remote follows + alter table(:follows) do + modify :followed_id, :integer, null: true + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251108000006_create_post_boosts.exs b/apps/elektrine/priv/repo/migrations/20251108000006_create_post_boosts.exs new file mode 100644 index 0000000..1c85a4e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251108000006_create_post_boosts.exs @@ -0,0 +1,19 @@ +defmodule Elektrine.Repo.Migrations.CreatePostBoosts do + use Ecto.Migration + + def change do + create table(:post_boosts) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :message_id, references(:messages, on_delete: :delete_all), null: false + # Track the Announce activity ID + add :activitypub_id, :string + + timestamps(type: :naive_datetime) + end + + create unique_index(:post_boosts, [:user_id, :message_id]) + create index(:post_boosts, [:message_id]) + create index(:post_boosts, [:user_id]) + create index(:post_boosts, [:activitypub_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251108000007_create_activitypub_relays.exs b/apps/elektrine/priv/repo/migrations/20251108000007_create_activitypub_relays.exs new file mode 100644 index 0000000..94df30e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251108000007_create_activitypub_relays.exs @@ -0,0 +1,26 @@ +defmodule Elektrine.Repo.Migrations.CreateActivitypubRelays do + use Ecto.Migration + + def change do + create table(:activitypub_relays) do + # Relay actor URL + add :url, :string, null: false + add :inbox_url, :string, null: false + add :domain, :string, null: false + # pending, accepted, rejected + add :state, :string, default: "pending" + # Track our Follow activity + add :follow_activity_id, :string + add :enabled, :boolean, default: true + add :last_error, :text + add :last_successful_at, :utc_datetime + + timestamps() + end + + create unique_index(:activitypub_relays, [:url]) + create index(:activitypub_relays, [:domain]) + create index(:activitypub_relays, [:state]) + create index(:activitypub_relays, [:enabled]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251109164600_set_manually_approve_followers_default_true.exs b/apps/elektrine/priv/repo/migrations/20251109164600_set_manually_approve_followers_default_true.exs new file mode 100644 index 0000000..8ae1ce7 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251109164600_set_manually_approve_followers_default_true.exs @@ -0,0 +1,22 @@ +defmodule Elektrine.Repo.Migrations.SetManuallyApproveFollowersDefaultTrue do + use Ecto.Migration + + def up do + # Update the default value for new records + alter table(:users) do + modify :activitypub_manually_approve_followers, :boolean, default: true + end + + # Update existing users to manually approve followers by default + execute "UPDATE users SET activitypub_manually_approve_followers = true WHERE activitypub_manually_approve_followers = false OR activitypub_manually_approve_followers IS NULL" + end + + def down do + # Revert the default value + alter table(:users) do + modify :activitypub_manually_approve_followers, :boolean, default: false + end + + # Don't revert the data - keep user preferences + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251109200000_drop_relays_table.exs b/apps/elektrine/priv/repo/migrations/20251109200000_drop_relays_table.exs new file mode 100644 index 0000000..cd133fc --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251109200000_drop_relays_table.exs @@ -0,0 +1,24 @@ +defmodule Elektrine.Repo.Migrations.DropRelaysTable do + use Ecto.Migration + + def up do + drop table(:activitypub_relays) + end + + def down do + create table(:activitypub_relays) do + add :url, :string, null: false + add :inbox_url, :string, null: false + add :domain, :string, null: false + add :state, :string, default: "pending" + add :enabled, :boolean, default: true + add :follow_activity_id, :string + add :last_successful_at, :utc_datetime + add :last_error, :text + + timestamps() + end + + create unique_index(:activitypub_relays, [:url]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251109203000_create_federated_likes.exs b/apps/elektrine/priv/repo/migrations/20251109203000_create_federated_likes.exs new file mode 100644 index 0000000..e2619fc --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251109203000_create_federated_likes.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.CreateFederatedLikes do + use Ecto.Migration + + def change do + create table(:federated_likes) do + add :message_id, references(:messages, on_delete: :delete_all), null: false + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all), null: false + add :activitypub_id, :string + + timestamps() + end + + create unique_index(:federated_likes, [:message_id, :remote_actor_id]) + create index(:federated_likes, [:remote_actor_id]) + create index(:federated_likes, [:activitypub_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251110000001_create_lists.exs b/apps/elektrine/priv/repo/migrations/20251110000001_create_lists.exs new file mode 100644 index 0000000..16e37d5 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251110000001_create_lists.exs @@ -0,0 +1,45 @@ +defmodule Elektrine.Repo.Migrations.CreateLists do + use Ecto.Migration + + def change do + # Lists table for curated user collections + create table(:lists) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :name, :string, null: false + add :description, :text + # "private" or "public" + add :visibility, :string, default: "public", null: false + + timestamps() + end + + create index(:lists, [:user_id]) + create unique_index(:lists, [:user_id, :name]) + + # List members - can be local users or remote actors + create table(:list_members) do + add :list_id, references(:lists, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all) + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all) + + timestamps() + end + + create index(:list_members, [:list_id]) + create index(:list_members, [:user_id]) + create index(:list_members, [:remote_actor_id]) + + # Ensure either user_id or remote_actor_id is set, but not both + create constraint(:list_members, :user_or_remote_actor, + check: + "(user_id IS NOT NULL AND remote_actor_id IS NULL) OR (user_id IS NULL AND remote_actor_id IS NOT NULL)" + ) + + # Prevent duplicates in a list + create unique_index(:list_members, [:list_id, :user_id], where: "user_id IS NOT NULL") + + create unique_index(:list_members, [:list_id, :remote_actor_id], + where: "remote_actor_id IS NOT NULL" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251110000002_add_published_at_to_activitypub_actors.exs b/apps/elektrine/priv/repo/migrations/20251110000002_add_published_at_to_activitypub_actors.exs new file mode 100644 index 0000000..2678dd3 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251110000002_add_published_at_to_activitypub_actors.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.AddPublishedAtToActivitypubActors do + use Ecto.Migration + + def change do + alter table(:activitypub_actors) do + add :published_at, :utc_datetime + end + + # Parse published date from metadata for existing actors + execute """ + UPDATE activitypub_actors + SET published_at = (metadata->>'published')::timestamp + WHERE metadata->>'published' IS NOT NULL + """, + "" + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251111120000_create_custom_emojis.exs b/apps/elektrine/priv/repo/migrations/20251111120000_create_custom_emojis.exs new file mode 100644 index 0000000..54cc68c --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251111120000_create_custom_emojis.exs @@ -0,0 +1,21 @@ +defmodule Elektrine.Repo.Migrations.CreateCustomEmojis do + use Ecto.Migration + + def change do + create table(:custom_emojis) do + add :shortcode, :string, null: false + add :image_url, :string, null: false + add :instance_domain, :string + add :category, :string + add :visible_in_picker, :boolean, default: true + add :disabled, :boolean, default: false + + timestamps() + end + + # Shortcode must be unique per instance (local emojis have null domain) + create unique_index(:custom_emojis, [:shortcode, :instance_domain]) + create index(:custom_emojis, [:instance_domain]) + create index(:custom_emojis, [:visible_in_picker]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251113000001_add_content_warning_to_messages.exs b/apps/elektrine/priv/repo/migrations/20251113000001_add_content_warning_to_messages.exs new file mode 100644 index 0000000..aea52b5 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251113000001_add_content_warning_to_messages.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.AddContentWarningToMessages do + use Ecto.Migration + + def change do + alter table(:messages) do + add :content_warning, :string + add :sensitive, :boolean, default: false + end + + # Add index for querying sensitive content + create index(:messages, [:sensitive]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251113000002_add_federation_to_message_reactions.exs b/apps/elektrine/priv/repo/migrations/20251113000002_add_federation_to_message_reactions.exs new file mode 100644 index 0000000..b7b60ec --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251113000002_add_federation_to_message_reactions.exs @@ -0,0 +1,20 @@ +defmodule Elektrine.Repo.Migrations.AddFederationToMessageReactions do + use Ecto.Migration + + def change do + alter table(:message_reactions) do + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all) + add :federated, :boolean, default: false + end + + # Add index for querying federated reactions + create index(:message_reactions, [:remote_actor_id]) + create index(:message_reactions, [:federated]) + + # Add unique constraint for federated reactions (one emoji per remote actor per message) + create unique_index(:message_reactions, [:message_id, :remote_actor_id, :emoji], + name: :message_reactions_federated_unique_index, + where: "remote_actor_id IS NOT NULL" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251116000000_add_community_support_to_activitypub.exs b/apps/elektrine/priv/repo/migrations/20251116000000_add_community_support_to_activitypub.exs new file mode 100644 index 0000000..3123202 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251116000000_add_community_support_to_activitypub.exs @@ -0,0 +1,48 @@ +defmodule Elektrine.Repo.Migrations.AddCommunitySupportToActivitypub do + use Ecto.Migration + + def up do + # Add community support to ActivityPub actors + alter table(:activitypub_actors) do + add :community_id, references(:conversations, on_delete: :delete_all), null: true + add :moderators_url, :string + end + + # Add index for community lookups + create index(:activitypub_actors, [:community_id]) + + # Drop constraint if it exists, then recreate with Group support + execute """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'actor_type_must_be_valid' + ) THEN + ALTER TABLE activitypub_actors DROP CONSTRAINT actor_type_must_be_valid; + END IF; + END $$; + """ + + # Create new constraint with Group support + create constraint(:activitypub_actors, :actor_type_must_be_valid, + check: "actor_type IN ('Person', 'Group', 'Organization', 'Service', 'Application')" + ) + end + + def down do + # Remove community support + drop index(:activitypub_actors, [:community_id]) + + alter table(:activitypub_actors) do + remove :community_id + remove :moderators_url + end + + # Restore original constraint + drop constraint(:activitypub_actors, :actor_type_must_be_valid) + + create constraint(:activitypub_actors, :actor_type_must_be_valid, + check: "actor_type = 'Person'" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251117000001_add_federated_source_to_conversations.exs b/apps/elektrine/priv/repo/migrations/20251117000001_add_federated_source_to_conversations.exs new file mode 100644 index 0000000..559c039 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251117000001_add_federated_source_to_conversations.exs @@ -0,0 +1,19 @@ +defmodule Elektrine.Repo.Migrations.AddFederatedSourceToConversations do + use Ecto.Migration + + def change do + alter table(:conversations) do + # ActivityPub Group URI (e.g., https://lemmy.ml/c/technology) + add :federated_source, :string + # Link to Group actor + add :remote_group_actor_id, references(:activitypub_actors, on_delete: :nilify_all) + # True if this mirrors a remote community + add :is_federated_mirror, :boolean, default: false + end + + # Index for looking up mirror communities by remote source + create index(:conversations, [:federated_source]) + create index(:conversations, [:remote_group_actor_id]) + create index(:conversations, [:is_federated_mirror]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251117000002_regenerate_activitypub_keys_for_lemmy.exs b/apps/elektrine/priv/repo/migrations/20251117000002_regenerate_activitypub_keys_for_lemmy.exs new file mode 100644 index 0000000..8c47e93 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251117000002_regenerate_activitypub_keys_for_lemmy.exs @@ -0,0 +1,21 @@ +defmodule Elektrine.Repo.Migrations.RegenerateActivitypubKeysForLemmy do + use Ecto.Migration + + def up do + # Clear existing ActivityPub keys to force regeneration in PKCS#8 format + # The KeyManager.generate_key_pair/0 function will regenerate them properly + # when needed, using openssl to create Lemmy-compatible PUBLIC KEY format + + execute """ + UPDATE users + SET activitypub_public_key = NULL, + activitypub_private_key = NULL + WHERE activitypub_public_key IS NOT NULL + """ + end + + def down do + # Can't restore old keys, but they'll be regenerated on next use + :ok + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251118000001_change_activitypub_fields_to_text.exs b/apps/elektrine/priv/repo/migrations/20251118000001_change_activitypub_fields_to_text.exs new file mode 100644 index 0000000..a95acfe --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251118000001_change_activitypub_fields_to_text.exs @@ -0,0 +1,25 @@ +defmodule Elektrine.Repo.Migrations.ChangeActivitypubFieldsToText do + use Ecto.Migration + + def change do + # Change activitypub_id and activitypub_url from varchar(255) to text + # ActivityPub IDs can be very long URLs from various instances + alter table(:messages) do + modify :activitypub_id, :text + modify :activitypub_url, :text + modify :content_warning, :text + end + + alter table(:follows) do + modify :activitypub_id, :text + end + + alter table(:post_boosts) do + modify :activitypub_id, :text + end + + alter table(:federated_likes) do + modify :activitypub_id, :text + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251119000001_make_message_reactions_user_id_nullable.exs b/apps/elektrine/priv/repo/migrations/20251119000001_make_message_reactions_user_id_nullable.exs new file mode 100644 index 0000000..eb2a99c --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251119000001_make_message_reactions_user_id_nullable.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.MakeMessageReactionsUserIdNullable do + use Ecto.Migration + + def change do + alter table(:message_reactions) do + # Drop the NOT NULL without touching the existing foreign key to avoid + # recreating the constraint and hitting "constraint already exists" + modify :user_id, :bigint, null: true, from: {:bigint, null: false} + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251203150755_add_federated_dislikes.exs b/apps/elektrine/priv/repo/migrations/20251203150755_add_federated_dislikes.exs new file mode 100644 index 0000000..3ef188a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251203150755_add_federated_dislikes.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Repo.Migrations.AddFederatedDislikes do + use Ecto.Migration + + def change do + # Add dislike_count column to messages table + alter table(:messages) do + add :dislike_count, :integer, default: 0 + end + + # Create federated_dislikes table + create table(:federated_dislikes) do + add :message_id, references(:messages, on_delete: :delete_all), null: false + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all), null: false + add :activitypub_id, :string + + timestamps() + end + + create unique_index(:federated_dislikes, [:message_id, :remote_actor_id]) + create index(:federated_dislikes, [:remote_actor_id]) + create index(:federated_dislikes, [:activitypub_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251203150938_add_federated_boosts.exs b/apps/elektrine/priv/repo/migrations/20251203150938_add_federated_boosts.exs new file mode 100644 index 0000000..52d1d0c --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251203150938_add_federated_boosts.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.AddFederatedBoosts do + use Ecto.Migration + + def change do + create table(:federated_boosts) do + add :message_id, references(:messages, on_delete: :delete_all), null: false + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all), null: false + add :activitypub_id, :string + + timestamps() + end + + create unique_index(:federated_boosts, [:message_id, :remote_actor_id]) + create index(:federated_boosts, [:remote_actor_id]) + create index(:federated_boosts, [:activitypub_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251206171650_create_group_follows.exs b/apps/elektrine/priv/repo/migrations/20251206171650_create_group_follows.exs new file mode 100644 index 0000000..91fbfa6 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251206171650_create_group_follows.exs @@ -0,0 +1,18 @@ +defmodule Elektrine.Repo.Migrations.CreateGroupFollows do + use Ecto.Migration + + def change do + create table(:group_follows) do + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all), null: false + add :group_actor_id, references(:activitypub_actors, on_delete: :delete_all), null: false + add :activitypub_id, :string + add :pending, :boolean, default: false + + timestamps() + end + + create unique_index(:group_follows, [:remote_actor_id, :group_actor_id]) + create index(:group_follows, [:group_actor_id]) + create index(:group_follows, [:activitypub_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251209000000_add_missing_performance_indexes.exs b/apps/elektrine/priv/repo/migrations/20251209000000_add_missing_performance_indexes.exs new file mode 100644 index 0000000..5c58190 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251209000000_add_missing_performance_indexes.exs @@ -0,0 +1,48 @@ +defmodule Elektrine.Repo.Migrations.AddMissingPerformanceIndexes do + use Ecto.Migration + + def change do + # Composite index for user timeline queries (get_user_timeline_posts) + # Filters: sender_id, post_type, visibility, deleted_at, order by inserted_at + create_if_not_exists index( + :messages, + [:sender_id, :post_type, :visibility, :deleted_at, :inserted_at], + name: :messages_user_timeline_idx, + where: "post_type = 'post' AND deleted_at IS NULL" + ) + + # Composite index for conversation message queries with better ordering + # Many queries filter by conversation_id and order by inserted_at + create_if_not_exists index(:messages, [:conversation_id, :inserted_at, :deleted_at], + name: :messages_conversation_timeline_idx, + where: "deleted_at IS NULL" + ) + + # Only create these indexes if the tables exist (they're from a later migration) + if table_exists?(:user_follows) do + create_if_not_exists index(:user_follows, [:followed_id, :follower_id], + name: :user_follows_reverse_idx + ) + end + + if table_exists?(:post_likes) do + create_if_not_exists index(:post_likes, [:message_id, :created_at], + name: :post_likes_by_message_idx + ) + end + end + + defp table_exists?(table_name) when table_name in [:user_follows, :post_likes] do + query = """ + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + ) + """ + + result = Ecto.Adapters.SQL.query!(Elektrine.Repo, query, [Atom.to_string(table_name)]) + [[exists]] = result.rows + exists + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251213071259_extend_email_message_fields.exs b/apps/elektrine/priv/repo/migrations/20251213071259_extend_email_message_fields.exs new file mode 100644 index 0000000..a153884 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251213071259_extend_email_message_fields.exs @@ -0,0 +1,16 @@ +defmodule Elektrine.Repo.Migrations.ExtendEmailMessageFields do + use Ecto.Migration + + def change do + # Extend email address fields to support many recipients + # to/cc/bcc can have many addresses, so use text + alter table(:email_messages) do + modify :from, :string, size: 500 + modify :to, :text + modify :cc, :text + modify :bcc, :text + modify :subject, :string, size: 500 + modify :message_id, :string, size: 500 + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251213170721_add_activitypub_performance_indexes.exs b/apps/elektrine/priv/repo/migrations/20251213170721_add_activitypub_performance_indexes.exs new file mode 100644 index 0000000..d1b6977 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251213170721_add_activitypub_performance_indexes.exs @@ -0,0 +1,41 @@ +defmodule Elektrine.Repo.Migrations.AddActivitypubPerformanceIndexes do + use Ecto.Migration + + def change do + # Index on activitypub_actors.domain for faster lookups by domain + create_if_not_exists index(:activitypub_actors, [:domain]) + + # Index on activitypub_actors.uri for faster lookups + create_if_not_exists index(:activitypub_actors, [:uri]) + + # Index on activitypub_actors.username for username lookups + create_if_not_exists index(:activitypub_actors, [:username]) + + # Composite index for username+domain lookups + create_if_not_exists index(:activitypub_actors, [:username, :domain]) + + # Index on messages.activitypub_id for faster federation lookups + create_if_not_exists index(:messages, [:activitypub_id]) + + # Index on messages.remote_actor_id for fetching posts by remote actor + create_if_not_exists index(:messages, [:remote_actor_id]) + + # GIN index on media_metadata for JSONB queries (community_actor_uri, inReplyTo, etc.) + execute( + "CREATE INDEX IF NOT EXISTS messages_media_metadata_gin_idx ON messages USING GIN (media_metadata jsonb_path_ops)", + "DROP INDEX IF EXISTS messages_media_metadata_gin_idx" + ) + + # Expression index on community_actor_uri for faster community post lookups + execute( + "CREATE INDEX IF NOT EXISTS messages_community_actor_uri_idx ON messages ((media_metadata->>'community_actor_uri')) WHERE media_metadata->>'community_actor_uri' IS NOT NULL", + "DROP INDEX IF EXISTS messages_community_actor_uri_idx" + ) + + # Index on follows.remote_actor_id for faster follower lookups + create_if_not_exists index(:follows, [:remote_actor_id]) + + # Index on follows for follower_id + remote_actor_id combinations + create_if_not_exists index(:follows, [:follower_id, :remote_actor_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251216160458_drop_old_username_index.exs b/apps/elektrine/priv/repo/migrations/20251216160458_drop_old_username_index.exs new file mode 100644 index 0000000..ad16c18 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251216160458_drop_old_username_index.exs @@ -0,0 +1,7 @@ +defmodule Elektrine.Repo.Migrations.DropOldUsernameIndex do + use Ecto.Migration + + def change do + drop_if_exists unique_index(:users, [:username], name: :users_username_index) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251217070133_enhance_recommendation_tracking.exs b/apps/elektrine/priv/repo/migrations/20251217070133_enhance_recommendation_tracking.exs new file mode 100644 index 0000000..218d4e6 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251217070133_enhance_recommendation_tracking.exs @@ -0,0 +1,64 @@ +defmodule Elektrine.Repo.Migrations.EnhanceRecommendationTracking do + use Ecto.Migration + + def change do + # Enhance post_views with dwell time tracking + alter table(:post_views) do + # milliseconds spent on post + add :dwell_time_ms, :integer + # 0.0-1.0 how much of post was visible + add :scroll_depth, :float + # clicked to expand/read more + add :expanded, :boolean, default: false + # where they saw the post (feed, profile, search, etc.) + add :source, :string + end + + # Create post_dismissals for negative signals + create table(:post_dismissals) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :message_id, references(:messages, on_delete: :delete_all), null: false + # scrolled_past, hidden, not_interested + add :dismissal_type, :string, null: false + # how long before dismissing + add :dwell_time_ms, :integer + + timestamps(updated_at: false) + end + + create index(:post_dismissals, [:user_id]) + create index(:post_dismissals, [:message_id]) + create index(:post_dismissals, [:user_id, :dismissal_type]) + create unique_index(:post_dismissals, [:user_id, :message_id, :dismissal_type]) + + # Create creator_satisfaction for tracking satisfaction signals + create table(:creator_satisfaction) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :creator_id, references(:users, on_delete: :delete_all) + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all) + add :followed_after_viewing, :boolean, default: false + # kept viewing their content + add :continued_engagement, :boolean, default: false + # regret signal + add :immediate_leave, :boolean, default: false + add :total_posts_viewed, :integer, default: 0 + add :total_dwell_time_ms, :integer, default: 0 + + timestamps() + end + + create unique_index(:creator_satisfaction, [:user_id, :creator_id], + where: "creator_id IS NOT NULL AND remote_actor_id IS NULL" + ) + + create unique_index(:creator_satisfaction, [:user_id, :remote_actor_id], + where: "remote_actor_id IS NOT NULL" + ) + + create index(:creator_satisfaction, [:creator_id]) + create index(:creator_satisfaction, [:remote_actor_id]) + + # Add index for efficient dwell time queries + create index(:post_views, [:user_id, :dwell_time_ms]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251218053438_add_processed_to_activities.exs b/apps/elektrine/priv/repo/migrations/20251218053438_add_processed_to_activities.exs new file mode 100644 index 0000000..c430f61 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251218053438_add_processed_to_activities.exs @@ -0,0 +1,22 @@ +defmodule Elektrine.Repo.Migrations.AddProcessedToActivities do + use Ecto.Migration + + def change do + alter table(:activitypub_activities) do + add :processed, :boolean, default: false + add :processed_at, :utc_datetime + add :process_error, :string + add :process_attempts, :integer, default: 0 + end + + # Index for finding unprocessed activities efficiently + create index(:activitypub_activities, [:processed, :local], + where: "processed = false AND local = false" + ) + + # Index for retry logic + create index(:activitypub_activities, [:process_attempts], + where: "processed = false AND process_attempts < 3" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251218055225_add_background_job_tables.exs b/apps/elektrine/priv/repo/migrations/20251218055225_add_background_job_tables.exs new file mode 100644 index 0000000..26a1d02 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251218055225_add_background_job_tables.exs @@ -0,0 +1,51 @@ +defmodule Elektrine.Repo.Migrations.AddBackgroundJobTables do + use Ecto.Migration + + def change do + # Email send jobs queue + create table(:email_jobs) do + add :user_id, references(:users, on_delete: :delete_all), null: false + # pending, processing, completed, failed + add :status, :string, default: "pending" + # to, subject, body, etc. + add :email_attrs, :map, null: false + # attachment metadata + add :attachments, :map + add :attempts, :integer, default: 0 + add :max_attempts, :integer, default: 3 + add :error, :string + add :completed_at, :utc_datetime + # for delayed sending + add :scheduled_for, :utc_datetime + + timestamps() + end + + create index(:email_jobs, [:status]) + create index(:email_jobs, [:user_id]) + create index(:email_jobs, [:status, :scheduled_for], where: "status = 'pending'") + + # Link preview fetch jobs queue + create table(:link_preview_jobs) do + add :url, :string, null: false + # pending, processing, completed, failed + add :status, :string, default: "pending" + add :message_id, references(:messages, on_delete: :delete_all) + add :attempts, :integer, default: 0 + add :max_attempts, :integer, default: 3 + add :error, :string + add :link_preview_id, references(:link_previews, on_delete: :nilify_all) + add :completed_at, :utc_datetime + + timestamps() + end + + create index(:link_preview_jobs, [:status]) + create index(:link_preview_jobs, [:url]) + create index(:link_preview_jobs, [:message_id]) + + create index(:link_preview_jobs, [:status, :attempts], + where: "status = 'pending' AND attempts < 3" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251218172906_add_oban_jobs_table.exs b/apps/elektrine/priv/repo/migrations/20251218172906_add_oban_jobs_table.exs new file mode 100644 index 0000000..486410c --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251218172906_add_oban_jobs_table.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddObanJobsTable do + use Ecto.Migration + + def up do + Oban.Migration.up(version: 12) + end + + def down do + Oban.Migration.down(version: 1) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251223045423_add_email_restriction_fields.exs b/apps/elektrine/priv/repo/migrations/20251223045423_add_email_restriction_fields.exs new file mode 100644 index 0000000..6014554 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251223045423_add_email_restriction_fields.exs @@ -0,0 +1,18 @@ +defmodule Elektrine.Repo.Migrations.AddEmailRestrictionFields do + use Ecto.Migration + + def change do + alter table(:users) do + add :email_sending_restricted, :boolean, default: false + add :email_rate_limit_violations, :integer, default: 0 + add :email_restriction_reason, :string + add :email_restricted_at, :utc_datetime + add :recovery_email_verified, :boolean, default: false + add :recovery_email_verification_token, :string + add :recovery_email_verification_sent_at, :utc_datetime + end + + # Index for finding restricted users + create index(:users, [:email_sending_restricted], where: "email_sending_restricted = true") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251224161418_add_unique_actor_username_domain.exs b/apps/elektrine/priv/repo/migrations/20251224161418_add_unique_actor_username_domain.exs new file mode 100644 index 0000000..00f44f4 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251224161418_add_unique_actor_username_domain.exs @@ -0,0 +1,130 @@ +defmodule Elektrine.Repo.Migrations.AddUniqueActorUsernameDomain do + use Ecto.Migration + + def up do + # First, find and merge duplicate actors (keep the oldest, update references) + # This raw SQL handles the deduplication before adding the unique constraint + + execute """ + -- Update messages to reference the oldest actor for each username/domain pair + UPDATE messages + SET remote_actor_id = keeper.id + FROM ( + SELECT username, domain, MIN(id) as id + FROM activitypub_actors + GROUP BY username, domain + ) keeper + JOIN activitypub_actors dup ON dup.username = keeper.username AND dup.domain = keeper.domain + WHERE messages.remote_actor_id = dup.id AND dup.id != keeper.id; + """ + + execute """ + -- Update follows to reference the oldest actor + UPDATE follows + SET remote_actor_id = keeper.id + FROM ( + SELECT username, domain, MIN(id) as id + FROM activitypub_actors + GROUP BY username, domain + ) keeper + JOIN activitypub_actors dup ON dup.username = keeper.username AND dup.domain = keeper.domain + WHERE follows.remote_actor_id = dup.id AND dup.id != keeper.id; + """ + + execute """ + -- Update federated_boosts to reference the oldest actor + UPDATE federated_boosts + SET remote_actor_id = keeper.id + FROM ( + SELECT username, domain, MIN(id) as id + FROM activitypub_actors + GROUP BY username, domain + ) keeper + JOIN activitypub_actors dup ON dup.username = keeper.username AND dup.domain = keeper.domain + WHERE federated_boosts.remote_actor_id = dup.id AND dup.id != keeper.id; + """ + + execute """ + -- Update federated_dislikes to reference the oldest actor + UPDATE federated_dislikes + SET remote_actor_id = keeper.id + FROM ( + SELECT username, domain, MIN(id) as id + FROM activitypub_actors + GROUP BY username, domain + ) keeper + JOIN activitypub_actors dup ON dup.username = keeper.username AND dup.domain = keeper.domain + WHERE federated_dislikes.remote_actor_id = dup.id AND dup.id != keeper.id; + """ + + execute """ + -- Update message_reactions to reference the oldest actor + UPDATE message_reactions + SET remote_actor_id = keeper.id + FROM ( + SELECT username, domain, MIN(id) as id + FROM activitypub_actors + GROUP BY username, domain + ) keeper + JOIN activitypub_actors dup ON dup.username = keeper.username AND dup.domain = keeper.domain + WHERE message_reactions.remote_actor_id = dup.id AND dup.id != keeper.id; + """ + + execute """ + -- Update list_members to reference the oldest actor + UPDATE list_members + SET remote_actor_id = keeper.id + FROM ( + SELECT username, domain, MIN(id) as id + FROM activitypub_actors + GROUP BY username, domain + ) keeper + JOIN activitypub_actors dup ON dup.username = keeper.username AND dup.domain = keeper.domain + WHERE list_members.remote_actor_id = dup.id AND dup.id != keeper.id; + """ + + execute """ + -- Update conversations to reference the oldest actor + UPDATE conversations + SET remote_group_actor_id = keeper.id + FROM ( + SELECT username, domain, MIN(id) as id + FROM activitypub_actors + GROUP BY username, domain + ) keeper + JOIN activitypub_actors dup ON dup.username = keeper.username AND dup.domain = keeper.domain + WHERE conversations.remote_group_actor_id = dup.id AND dup.id != keeper.id; + """ + + # Now delete the duplicate actors (keeping the oldest) + execute """ + DELETE FROM activitypub_actors + WHERE id NOT IN ( + SELECT MIN(id) + FROM activitypub_actors + GROUP BY username, domain + ); + """ + + # Drop the existing non-unique index first + drop_if_exists index(:activitypub_actors, [:username, :domain], + name: :activitypub_actors_username_domain_index + ) + + # Add unique constraint on username + domain + create unique_index(:activitypub_actors, [:username, :domain], + name: :activitypub_actors_username_domain_unique_index + ) + end + + def down do + drop_if_exists index(:activitypub_actors, [:username, :domain], + name: :activitypub_actors_username_domain_unique_index + ) + + # Recreate the original non-unique index + create_if_not_exists index(:activitypub_actors, [:username, :domain], + name: :activitypub_actors_username_domain_index + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251225205440_create_device_tokens.exs b/apps/elektrine/priv/repo/migrations/20251225205440_create_device_tokens.exs new file mode 100644 index 0000000..1a3af0c --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251225205440_create_device_tokens.exs @@ -0,0 +1,28 @@ +defmodule Elektrine.Repo.Migrations.CreateDeviceTokens do + use Ecto.Migration + + def change do + create table(:device_tokens) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :token, :string, null: false + # "ios" or "android" + add :platform, :string, null: false + add :app_version, :string + add :device_name, :string + add :device_model, :string + add :os_version, :string + add :bundle_id, :string + add :enabled, :boolean, default: true + add :last_used_at, :utc_datetime + add :failed_count, :integer, default: 0 + add :last_error, :string + + timestamps() + end + + create unique_index(:device_tokens, [:token]) + create index(:device_tokens, [:user_id]) + create index(:device_tokens, [:platform]) + create index(:device_tokens, [:enabled]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251228000001_fix_duplicate_handles.exs b/apps/elektrine/priv/repo/migrations/20251228000001_fix_duplicate_handles.exs new file mode 100644 index 0000000..2a34219 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251228000001_fix_duplicate_handles.exs @@ -0,0 +1,39 @@ +defmodule Elektrine.Repo.Migrations.FixDuplicateHandles do + use Ecto.Migration + + def up do + # First, identify and fix duplicate handles + execute """ + WITH duplicates AS ( + SELECT handle, COUNT(*) as count + FROM users + WHERE handle IS NOT NULL + GROUP BY handle + HAVING COUNT(*) > 1 + ), + ranked_duplicates AS ( + SELECT u.id, u.handle, u.inserted_at, + ROW_NUMBER() OVER (PARTITION BY u.handle ORDER BY u.inserted_at ASC) as rn + FROM users u + INNER JOIN duplicates d ON u.handle = d.handle + ) + UPDATE users + SET handle = CONCAT(handle, '_', id) + WHERE id IN ( + SELECT id FROM ranked_duplicates WHERE rn > 1 + ); + """ + + # Now ensure the unique index exists + # Drop if exists first to avoid errors + drop_if_exists index(:users, [:handle]) + + # Create unique index + create unique_index(:users, [:handle]) + end + + def down do + # Remove the unique index + drop_if_exists index(:users, [:handle]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251228000002_add_calls_table.exs b/apps/elektrine/priv/repo/migrations/20251228000002_add_calls_table.exs new file mode 100644 index 0000000..321cc93 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251228000002_add_calls_table.exs @@ -0,0 +1,27 @@ +defmodule Elektrine.Repo.Migrations.AddCallsTable do + use Ecto.Migration + + def change do + create table(:calls) do + add :caller_id, references(:users, on_delete: :delete_all), null: false + add :callee_id, references(:users, on_delete: :delete_all), null: false + add :conversation_id, references(:conversations, on_delete: :delete_all) + # "audio" or "video" + add :call_type, :string, null: false + # "initiated", "ringing", "active", "ended", "rejected", "missed", "failed" + add :status, :string, null: false + add :started_at, :utc_datetime + add :ended_at, :utc_datetime + # Calculated duration in seconds + add :duration_seconds, :integer + + timestamps(type: :utc_datetime) + end + + create index(:calls, [:caller_id]) + create index(:calls, [:callee_id]) + create index(:calls, [:conversation_id]) + create index(:calls, [:inserted_at]) + create index(:calls, [:status]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251228000003_add_friend_requests_table.exs b/apps/elektrine/priv/repo/migrations/20251228000003_add_friend_requests_table.exs new file mode 100644 index 0000000..5e675f5 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251228000003_add_friend_requests_table.exs @@ -0,0 +1,21 @@ +defmodule Elektrine.Repo.Migrations.AddFriendRequestsTable do + use Ecto.Migration + + def change do + create table(:friend_requests) do + add :requester_id, references(:users, on_delete: :delete_all), null: false + add :recipient_id, references(:users, on_delete: :delete_all), null: false + # "pending", "accepted", "rejected" + add :status, :string, null: false, default: "pending" + # Optional message with friend request + add :message, :text + + timestamps(type: :utc_datetime) + end + + create index(:friend_requests, [:requester_id]) + create index(:friend_requests, [:recipient_id]) + create index(:friend_requests, [:status]) + create unique_index(:friend_requests, [:requester_id, :recipient_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251229015026_create_chat_messages.exs b/apps/elektrine/priv/repo/migrations/20251229015026_create_chat_messages.exs new file mode 100644 index 0000000..8f7879f --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251229015026_create_chat_messages.exs @@ -0,0 +1,87 @@ +defmodule Elektrine.Repo.Migrations.CreateChatMessages do + use Ecto.Migration + + def change do + create table(:chat_messages) do + # Core fields + add :conversation_id, references(:conversations, on_delete: :delete_all), null: false + add :sender_id, references(:users, on_delete: :nilify_all) + + # Content - encrypted for privacy + # Plaintext (cleared after encryption for DMs) + add :content, :text + # Encrypted content blob + add :encrypted_content, :map + # Searchable tokens + add :search_index, {:array, :string}, default: [] + + # Message metadata + # text, image, file, voice, system + add :message_type, :string, default: "text" + add :media_urls, {:array, :string}, default: [] + # alt texts, dimensions, durations, etc. + add :media_metadata, :map, default: %{} + + # Threading + add :reply_to_id, references(:chat_messages, on_delete: :nilify_all) + + # State + add :edited_at, :utc_datetime + add :deleted_at, :utc_datetime + + # Voice message specific + # Duration in seconds + add :audio_duration, :integer + add :audio_mime_type, :string + + timestamps() + end + + # Primary index for fetching messages in a conversation + create index(:chat_messages, [:conversation_id, :inserted_at]) + + # Index for fetching messages by sender + create index(:chat_messages, [:sender_id]) + + # Index for replies + create index(:chat_messages, [:reply_to_id]) + + # Index for undeleted messages (partial index) + create index(:chat_messages, [:conversation_id, :inserted_at], + where: "deleted_at IS NULL", + name: :chat_messages_active_idx + ) + + # Create chat_message_reactions table (separate from existing message_reactions) + create table(:chat_message_reactions) do + add :chat_message_id, references(:chat_messages, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all) + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all) + add :emoji, :string, null: false + + timestamps() + end + + create unique_index(:chat_message_reactions, [:chat_message_id, :user_id, :emoji], + where: "user_id IS NOT NULL", + name: :chat_message_reactions_user_unique + ) + + create unique_index(:chat_message_reactions, [:chat_message_id, :remote_actor_id, :emoji], + where: "remote_actor_id IS NOT NULL", + name: :chat_message_reactions_remote_unique + ) + + create index(:chat_message_reactions, [:chat_message_id]) + + # Create chat_message_reads table for read receipts + create table(:chat_message_reads, primary_key: false) do + add :chat_message_id, references(:chat_messages, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + add :read_at, :utc_datetime, null: false + end + + create unique_index(:chat_message_reads, [:chat_message_id, :user_id]) + create index(:chat_message_reads, [:user_id, :read_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251229020637_migrate_chat_messages_data.exs b/apps/elektrine/priv/repo/migrations/20251229020637_migrate_chat_messages_data.exs new file mode 100644 index 0000000..f3860c0 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251229020637_migrate_chat_messages_data.exs @@ -0,0 +1,119 @@ +defmodule Elektrine.Repo.Migrations.MigrateChatMessagesData do + use Ecto.Migration + + @moduledoc """ + Migrates existing chat messages from the `messages` table to the new `chat_messages` table. + + This migration: + 1. Copies messages from DM/group/channel conversations to chat_messages + 2. Preserves original IDs so reply_to_id references work + 3. Copies reactions from message_reactions to chat_message_reactions + 4. Updates sequences for the new tables + """ + + def up do + # Step 1: Copy messages from DM/group/channel conversations + # We preserve the original IDs to maintain reply_to_id relationships + execute """ + INSERT INTO chat_messages ( + id, + conversation_id, + sender_id, + content, + encrypted_content, + search_index, + message_type, + media_urls, + media_metadata, + reply_to_id, + edited_at, + deleted_at, + inserted_at, + updated_at + ) + SELECT + m.id, + m.conversation_id, + m.sender_id, + m.content, + m.encrypted_content, + COALESCE(m.search_index, ARRAY[]::varchar[]), + m.message_type, + COALESCE(m.media_urls, ARRAY[]::varchar[]), + COALESCE(m.media_metadata, '{}'::jsonb), + m.reply_to_id, + m.edited_at, + m.deleted_at, + m.inserted_at, + m.updated_at + FROM messages m + INNER JOIN conversations c ON m.conversation_id = c.id + WHERE c.type IN ('dm', 'group', 'channel') + ON CONFLICT (id) DO NOTHING + """ + + # Step 2: Update the sequence to avoid ID conflicts for new messages + execute """ + SELECT setval( + 'chat_messages_id_seq', + GREATEST( + (SELECT COALESCE(MAX(id), 0) FROM chat_messages), + (SELECT last_value FROM chat_messages_id_seq) + ) + ) + """ + + # Step 3: Copy reactions for migrated messages + execute """ + INSERT INTO chat_message_reactions ( + chat_message_id, + user_id, + remote_actor_id, + emoji, + inserted_at, + updated_at + ) + SELECT + mr.message_id, + mr.user_id, + mr.remote_actor_id, + mr.emoji, + mr.inserted_at, + mr.updated_at + FROM message_reactions mr + INNER JOIN chat_messages cm ON mr.message_id = cm.id + ON CONFLICT DO NOTHING + """ + + # Step 4: Update the reactions sequence + execute """ + SELECT setval( + 'chat_message_reactions_id_seq', + GREATEST( + (SELECT COALESCE(MAX(id), 0) FROM chat_message_reactions), + (SELECT last_value FROM chat_message_reactions_id_seq) + ) + ) + """ + end + + def down do + # Remove migrated data (but keep the tables) + execute """ + DELETE FROM chat_message_reactions + WHERE chat_message_id IN ( + SELECT cm.id FROM chat_messages cm + INNER JOIN conversations c ON cm.conversation_id = c.id + WHERE c.type IN ('dm', 'group', 'channel') + ) + """ + + execute """ + DELETE FROM chat_messages + WHERE conversation_id IN ( + SELECT id FROM conversations + WHERE type IN ('dm', 'group', 'channel') + ) + """ + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251230180851_create_calendars.exs b/apps/elektrine/priv/repo/migrations/20251230180851_create_calendars.exs new file mode 100644 index 0000000..f3f8364 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251230180851_create_calendars.exs @@ -0,0 +1,86 @@ +defmodule Elektrine.Repo.Migrations.CreateCalendars do + use Ecto.Migration + + def change do + # User calendars + create table(:calendars) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :name, :string, null: false + add :color, :string, default: "#3b82f6" + add :description, :text + add :timezone, :string, default: "UTC" + add :is_default, :boolean, default: false + # Collection sync token for CalDAV + add :ctag, :string + add :order, :integer, default: 0 + + timestamps(type: :utc_datetime) + end + + create index(:calendars, [:user_id]) + create unique_index(:calendars, [:user_id, :name]) + + # Calendar events + create table(:calendar_events) do + add :calendar_id, references(:calendars, on_delete: :delete_all), null: false + # iCalendar UID + add :uid, :string, null: false + # Entity tag for sync + add :etag, :string + # Event title + add :summary, :string + add :description, :text + add :location, :string + add :url, :string + + # Time fields + add :dtstart, :utc_datetime, null: false + add :dtend, :utc_datetime + # ISO 8601 duration + add :duration, :string + add :all_day, :boolean, default: false + add :timezone, :string + + # Recurrence + # RRULE string + add :rrule, :string + add :rdate, {:array, :utc_datetime}, default: [] + add :exdate, {:array, :utc_datetime}, default: [] + # For recurring event exceptions + add :recurrence_id, :utc_datetime + + # Status and classification + # TENTATIVE, CONFIRMED, CANCELLED + add :status, :string, default: "CONFIRMED" + # OPAQUE, TRANSPARENT + add :transparency, :string, default: "OPAQUE" + # PUBLIC, PRIVATE, CONFIDENTIAL + add :classification, :string, default: "PUBLIC" + add :priority, :integer, default: 0 + + # Alarms stored as JSON array + add :alarms, {:array, :map}, default: [] + + # Attendees stored as JSON array + add :attendees, {:array, :map}, default: [] + add :organizer, :map + + # Categories/tags + add :categories, {:array, :string}, default: [] + + # Raw iCalendar data for faithful round-trip + add :icalendar_data, :text + + # Sequence for updates + add :sequence, :integer, default: 0 + + timestamps(type: :utc_datetime) + end + + create index(:calendar_events, [:calendar_id]) + create unique_index(:calendar_events, [:calendar_id, :uid]) + create index(:calendar_events, [:dtstart]) + create index(:calendar_events, [:dtend]) + create index(:calendar_events, [:calendar_id, :dtstart, :dtend]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251230181000_extend_contacts_for_carddav.exs b/apps/elektrine/priv/repo/migrations/20251230181000_extend_contacts_for_carddav.exs new file mode 100644 index 0000000..26e3d94 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251230181000_extend_contacts_for_carddav.exs @@ -0,0 +1,77 @@ +defmodule Elektrine.Repo.Migrations.ExtendContactsForCarddav do + use Ecto.Migration + + def change do + alter table(:contacts) do + # CardDAV sync fields + # vCard UID for CardDAV + add :uid, :string + # Entity tag for sync + add :etag, :string + + # Name components (vCard N property) + # Mr., Dr., etc. + add :prefix, :string + # Jr., III, etc. + add :suffix, :string + add :nickname, :string + # vCard FN property + add :formatted_name, :string + add :first_name, :string + add :last_name, :string + add :middle_name, :string + + # Multiple emails/phones/addresses stored as JSON arrays + # Format: [%{type: "work", value: "email@example.com", primary: true}, ...] + add :emails, {:array, :map}, default: [] + add :phones, {:array, :map}, default: [] + add :addresses, {:array, :map}, default: [] + + # Social and web + # [%{type: "home", value: "https://..."}] + add :urls, {:array, :map}, default: [] + # [%{type: "twitter", value: "@handle"}] + add :social_profiles, {:array, :map}, default: [] + + # Important dates + add :birthday, :date + add :anniversary, :date + + # Photo + # "url", "base64", "binary" + add :photo_type, :string + # Base64 encoded photo or URL + add :photo_data, :text + # "image/jpeg", "image/png", etc. + add :photo_content_type, :string + + # Work info + # Job title + add :title, :string + add :department, :string + add :role, :string + + # Categories/tags (separate from groups) + add :categories, {:array, :string}, default: [] + + # Location + # %{latitude: 0.0, longitude: 0.0} + add :geo, :map + + # Raw vCard data for faithful round-trip + add :vcard_data, :text + + # vCard revision timestamp + add :revision, :utc_datetime + end + + # Index for CardDAV lookups + create unique_index(:contacts, [:user_id, :uid]) + create index(:contacts, [:uid]) + + # Add addressbook sync token to users + alter table(:users) do + add :addressbook_ctag, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20251231174204_add_pgp_support.exs b/apps/elektrine/priv/repo/migrations/20251231174204_add_pgp_support.exs new file mode 100644 index 0000000..6019d47 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20251231174204_add_pgp_support.exs @@ -0,0 +1,49 @@ +defmodule Elektrine.Repo.Migrations.AddPgpSupport do + use Ecto.Migration + + def change do + # Add PGP public key to users table (user's own key for others to encrypt to them) + alter table(:users) do + add :pgp_public_key, :text + # Short key ID (last 8 hex chars of fingerprint) + add :pgp_key_id, :string + # Full 40-char fingerprint + add :pgp_fingerprint, :string + add :pgp_key_uploaded_at, :utc_datetime + end + + # Add PGP public key to contacts (for encrypting outgoing mail to them) + alter table(:contacts) do + add :pgp_public_key, :text + add :pgp_key_id, :string + add :pgp_fingerprint, :string + # "manual", "wkd", "keyserver" + add :pgp_key_source, :string + add :pgp_key_fetched_at, :utc_datetime + add :pgp_encrypt_by_default, :boolean, default: false + end + + # Create index for looking up contacts by fingerprint + create index(:contacts, [:pgp_fingerprint]) + create index(:users, [:pgp_fingerprint]) + + # Cache table for WKD lookups (to avoid repeated network requests) + create table(:pgp_key_cache) do + add :email, :string, null: false + # null if no key found + add :public_key, :text + add :key_id, :string + add :fingerprint, :string + # "wkd", "keyserver", "dns" + add :source, :string + # "found", "not_found", "error" + add :status, :string, default: "found" + add :expires_at, :utc_datetime + + timestamps() + end + + create unique_index(:pgp_key_cache, [:email]) + create index(:pgp_key_cache, [:expires_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260102051253_add_quote_support_to_messages.exs b/apps/elektrine/priv/repo/migrations/20260102051253_add_quote_support_to_messages.exs new file mode 100644 index 0000000..7786035 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260102051253_add_quote_support_to_messages.exs @@ -0,0 +1,28 @@ +defmodule Elektrine.Repo.Migrations.AddQuoteSupportToMessages do + use Ecto.Migration + + def change do + alter table(:messages) do + # Reference to the quoted message (quote posts) + add :quoted_message_id, references(:messages, on_delete: :nilify_all) + # Track quote count on original posts + add :quote_count, :integer, default: 0 + end + + # Index for looking up quotes of a message + create index(:messages, [:quoted_message_id]) + + # Table to track remote quotes (from federated servers) + create table(:federated_quotes) do + add :message_id, references(:messages, on_delete: :delete_all), null: false + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all), null: false + add :activitypub_id, :string, null: false + + timestamps() + end + + create unique_index(:federated_quotes, [:message_id, :remote_actor_id]) + create index(:federated_quotes, [:remote_actor_id]) + create unique_index(:federated_quotes, [:activitypub_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260110225401_create_email_threads.exs b/apps/elektrine/priv/repo/migrations/20260110225401_create_email_threads.exs new file mode 100644 index 0000000..129c6e1 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260110225401_create_email_threads.exs @@ -0,0 +1,15 @@ +defmodule Elektrine.Repo.Migrations.CreateEmailThreads do + use Ecto.Migration + + def change do + create table(:email_threads) do + add :mailbox_id, references(:mailboxes, on_delete: :delete_all), null: false + add :subject_hash, :string, null: false + + timestamps(type: :utc_datetime) + end + + create index(:email_threads, [:mailbox_id]) + create unique_index(:email_threads, [:mailbox_id, :subject_hash]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260110225402_add_jmap_fields_to_messages.exs b/apps/elektrine/priv/repo/migrations/20260110225402_add_jmap_fields_to_messages.exs new file mode 100644 index 0000000..010646b --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260110225402_add_jmap_fields_to_messages.exs @@ -0,0 +1,16 @@ +defmodule Elektrine.Repo.Migrations.AddJmapFieldsToMessages do + use Ecto.Migration + + def change do + alter table(:email_messages) do + add :thread_id, references(:email_threads, on_delete: :nilify_all) + add :in_reply_to, :string + add :references, :text + add :jmap_blob_id, :string + end + + create index(:email_messages, [:thread_id]) + create index(:email_messages, [:jmap_blob_id]) + create index(:email_messages, [:in_reply_to]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260110225403_create_jmap_state_tracking.exs b/apps/elektrine/priv/repo/migrations/20260110225403_create_jmap_state_tracking.exs new file mode 100644 index 0000000..e51fff1 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260110225403_create_jmap_state_tracking.exs @@ -0,0 +1,15 @@ +defmodule Elektrine.Repo.Migrations.CreateJmapStateTracking do + use Ecto.Migration + + def change do + create table(:jmap_state_tracking) do + add :mailbox_id, references(:mailboxes, on_delete: :delete_all), null: false + add :entity_type, :string, null: false + add :state_counter, :bigint, null: false, default: 0 + + timestamps(type: :utc_datetime) + end + + create unique_index(:jmap_state_tracking, [:mailbox_id, :entity_type]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260110225404_create_email_submissions.exs b/apps/elektrine/priv/repo/migrations/20260110225404_create_email_submissions.exs new file mode 100644 index 0000000..c2537ad --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260110225404_create_email_submissions.exs @@ -0,0 +1,21 @@ +defmodule Elektrine.Repo.Migrations.CreateEmailSubmissions do + use Ecto.Migration + + def change do + create table(:email_submissions) do + add :mailbox_id, references(:mailboxes, on_delete: :delete_all), null: false + add :email_id, references(:email_messages, on_delete: :nilify_all) + add :identity_id, :string, null: false + add :envelope_from, :string, null: false + add :envelope_to, {:array, :string}, null: false, default: [] + add :send_at, :utc_datetime + add :undo_status, :string, default: "pending" + add :delivery_status, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + create index(:email_submissions, [:mailbox_id]) + create index(:email_submissions, [:email_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260111043859_add_profile_mode_to_user_profiles.exs b/apps/elektrine/priv/repo/migrations/20260111043859_add_profile_mode_to_user_profiles.exs new file mode 100644 index 0000000..c7f031c --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260111043859_add_profile_mode_to_user_profiles.exs @@ -0,0 +1,26 @@ +defmodule Elektrine.Repo.Migrations.AddProfileModeToUserProfiles do + use Ecto.Migration + + def change do + alter table(:user_profiles) do + # "builder" = existing drag & drop builder, "static" = custom uploaded files + add :profile_mode, :string, default: "builder", null: false + # Storage key for the index.html file when in static mode + add :static_site_index, :string + end + + # Create table for static site files + create table(:static_site_files) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :path, :string, null: false + add :storage_key, :string, null: false + add :content_type, :string, null: false + add :size, :integer, null: false + + timestamps(type: :utc_datetime) + end + + create index(:static_site_files, [:user_id]) + create unique_index(:static_site_files, [:user_id, :path]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260112200000_add_email_features.exs b/apps/elektrine/priv/repo/migrations/20260112200000_add_email_features.exs new file mode 100644 index 0000000..ceef958 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260112200000_add_email_features.exs @@ -0,0 +1,172 @@ +defmodule Elektrine.Repo.Migrations.AddEmailFeatures do + use Ecto.Migration + + def change do + # 1. Blocked Senders table + create table(:email_blocked_senders) do + add :email, :string + add :domain, :string + add :reason, :string + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:email_blocked_senders, [:user_id]) + create index(:email_blocked_senders, [:email]) + create index(:email_blocked_senders, [:domain]) + create unique_index(:email_blocked_senders, [:user_id, :email], where: "email IS NOT NULL") + create unique_index(:email_blocked_senders, [:user_id, :domain], where: "domain IS NOT NULL") + + # 2. Safe Senders (Whitelist) table + create table(:email_safe_senders) do + add :email, :string + add :domain, :string + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:email_safe_senders, [:user_id]) + create index(:email_safe_senders, [:email]) + create index(:email_safe_senders, [:domain]) + create unique_index(:email_safe_senders, [:user_id, :email], where: "email IS NOT NULL") + create unique_index(:email_safe_senders, [:user_id, :domain], where: "domain IS NOT NULL") + + # 3. Email Filters/Rules table + create table(:email_filters) do + add :name, :string, null: false + add :enabled, :boolean, default: true, null: false + add :priority, :integer, default: 0, null: false + add :stop_processing, :boolean, default: false, null: false + + # Conditions (JSON) + add :conditions, :map, default: %{}, null: false + + # Actions (JSON) + add :actions, :map, default: %{}, null: false + + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:email_filters, [:user_id]) + create index(:email_filters, [:enabled]) + create index(:email_filters, [:priority]) + + # 4. Auto-reply settings table + create table(:email_auto_replies) do + add :enabled, :boolean, default: false, null: false + add :subject, :string + add :body, :text, null: false + add :html_body, :text + add :start_date, :date + add :end_date, :date + add :only_contacts, :boolean, default: false, null: false + add :exclude_mailing_lists, :boolean, default: true, null: false + add :reply_once_per_sender, :boolean, default: true, null: false + + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create unique_index(:email_auto_replies, [:user_id]) + + # 5. Auto-reply tracking (to implement reply_once_per_sender) + create table(:email_auto_reply_log) do + add :sender_email, :string, null: false + add :sent_at, :utc_datetime, null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + end + + create index(:email_auto_reply_log, [:user_id]) + create index(:email_auto_reply_log, [:sender_email]) + create index(:email_auto_reply_log, [:sent_at]) + create unique_index(:email_auto_reply_log, [:user_id, :sender_email]) + + # 6. Email Templates table + create table(:email_templates) do + add :name, :string, null: false + add :subject, :string + add :body, :text, null: false + add :html_body, :text + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:email_templates, [:user_id]) + create unique_index(:email_templates, [:user_id, :name]) + + # 7. Custom Folders table + create table(:email_folders) do + add :name, :string, null: false + add :color, :string + add :icon, :string + add :parent_id, references(:email_folders, on_delete: :nilify_all) + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:email_folders, [:user_id]) + create index(:email_folders, [:parent_id]) + create unique_index(:email_folders, [:user_id, :name]) + + # 8. Labels/Tags table + create table(:email_labels) do + add :name, :string, null: false + add :color, :string, default: "#3b82f6" + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:email_labels, [:user_id]) + create unique_index(:email_labels, [:user_id, :name]) + + # 9. Message-Label junction table + create table(:email_message_labels, primary_key: false) do + add :message_id, references(:email_messages, on_delete: :delete_all), null: false + add :label_id, references(:email_labels, on_delete: :delete_all), null: false + end + + create unique_index(:email_message_labels, [:message_id, :label_id]) + create index(:email_message_labels, [:label_id]) + + # 10. Add priority/importance and other fields to email_messages + alter table(:email_messages) do + add :priority, :string, default: "normal" + add :folder_id, references(:email_folders, on_delete: :nilify_all) + add :scheduled_at, :utc_datetime + add :expires_at, :utc_datetime + add :undo_send_until, :utc_datetime + end + + create index(:email_messages, [:priority]) + create index(:email_messages, [:folder_id]) + create index(:email_messages, [:scheduled_at]) + create index(:email_messages, [:expires_at]) + + # 11. Email exports tracking table + create table(:email_exports) do + add :status, :string, default: "pending", null: false + add :format, :string, default: "mbox", null: false + add :file_path, :string + add :file_size, :integer + add :message_count, :integer + add :started_at, :utc_datetime + add :completed_at, :utc_datetime + add :error, :text + add :filters, :map, default: %{} + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:email_exports, [:user_id]) + create index(:email_exports, [:status]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260113024544_create_rss_and_saved_items.exs b/apps/elektrine/priv/repo/migrations/20260113024544_create_rss_and_saved_items.exs new file mode 100644 index 0000000..a0b706d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260113024544_create_rss_and_saved_items.exs @@ -0,0 +1,97 @@ +defmodule Elektrine.Repo.Migrations.CreateRssAndSavedItems do + use Ecto.Migration + + def change do + # RSS Feeds - global catalog of known feeds + create table(:rss_feeds) do + add :url, :text, null: false + add :title, :string + add :description, :text + add :site_url, :string + add :favicon_url, :string + add :image_url, :string + add :last_fetched_at, :utc_datetime + add :last_error, :text + add :fetch_interval_minutes, :integer, default: 60 + add :status, :string, default: "active" + add :etag, :string + add :last_modified, :string + + timestamps() + end + + create unique_index(:rss_feeds, [:url]) + create index(:rss_feeds, [:status]) + create index(:rss_feeds, [:last_fetched_at]) + + # RSS Subscriptions - per-user feed subscriptions + create table(:rss_subscriptions) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :feed_id, references(:rss_feeds, on_delete: :delete_all), null: false + add :display_name, :string + add :folder, :string + add :notify_new_items, :boolean, default: false + add :show_in_timeline, :boolean, default: true + + timestamps() + end + + create unique_index(:rss_subscriptions, [:user_id, :feed_id]) + create index(:rss_subscriptions, [:user_id]) + create index(:rss_subscriptions, [:feed_id]) + + # RSS Items - individual feed entries + create table(:rss_items) do + add :feed_id, references(:rss_feeds, on_delete: :delete_all), null: false + add :guid, :string, null: false + add :title, :string + add :content, :text + add :summary, :text + add :url, :text + add :author, :string + add :published_at, :utc_datetime + add :image_url, :string + add :enclosure_url, :text + add :enclosure_type, :string + add :categories, {:array, :string}, default: [] + + timestamps() + end + + create unique_index(:rss_items, [:feed_id, :guid]) + create index(:rss_items, [:feed_id]) + create index(:rss_items, [:published_at]) + create index(:rss_items, [:inserted_at]) + + # Saved Items - bookmarks for messages and RSS items (polymorphic) + create table(:saved_items) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :message_id, references(:messages, on_delete: :delete_all) + add :rss_item_id, references(:rss_items, on_delete: :delete_all) + add :folder, :string + add :notes, :text + + timestamps() + end + + # Unique indexes - only one bookmark per user per item + create unique_index(:saved_items, [:user_id, :message_id], + where: "message_id IS NOT NULL", + name: :saved_items_user_message_unique + ) + + create unique_index(:saved_items, [:user_id, :rss_item_id], + where: "rss_item_id IS NOT NULL", + name: :saved_items_user_rss_item_unique + ) + + create index(:saved_items, [:user_id]) + create index(:saved_items, [:inserted_at]) + + # Constraint: exactly one of message_id or rss_item_id must be set + create constraint(:saved_items, :saved_items_one_reference, + check: + "(message_id IS NOT NULL AND rss_item_id IS NULL) OR (message_id IS NULL AND rss_item_id IS NOT NULL)" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260115100000_add_registered_via_onion_to_users.exs b/apps/elektrine/priv/repo/migrations/20260115100000_add_registered_via_onion_to_users.exs new file mode 100644 index 0000000..e27134d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260115100000_add_registered_via_onion_to_users.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddRegisteredViaOnionToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :registered_via_onion, :boolean, default: false, null: false + end + + create index(:users, [:registered_via_onion]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260117120000_create_user_integrations.exs b/apps/elektrine/priv/repo/migrations/20260117120000_create_user_integrations.exs new file mode 100644 index 0000000..6499536 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260117120000_create_user_integrations.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Repo.Migrations.CreateUserIntegrations do + use Ecto.Migration + + def change do + create table(:user_integrations) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :provider, :string, null: false + add :provider_user_id, :string + add :username, :string + add :avatar_url, :string + add :access_token, :binary + add :refresh_token, :binary + add :token_expires_at, :utc_datetime + add :scopes, {:array, :string}, default: [] + add :metadata, :map, default: %{} + + timestamps() + end + + create unique_index(:user_integrations, [:user_id, :provider]) + create index(:user_integrations, [:provider]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260118215247_add_composite_performance_indexes.exs b/apps/elektrine/priv/repo/migrations/20260118215247_add_composite_performance_indexes.exs new file mode 100644 index 0000000..f649438 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260118215247_add_composite_performance_indexes.exs @@ -0,0 +1,65 @@ +defmodule Elektrine.Repo.Migrations.AddCompositePerformanceIndexes do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def change do + # These indexes are created CONCURRENTLY to avoid locking the tables + # during migration on production databases + + # Index for user profile timeline queries (sender_id + visibility + deleted_at + inserted_at) + # Used in: get_user_timeline_posts(), profile pages + create_if_not_exists index(:messages, [:sender_id, :visibility, :deleted_at, :inserted_at], + name: :messages_sender_visibility_deleted_inserted_idx, + concurrently: true + ) + + # Index for conversation queries with visibility filtering + # Used in: communities index, discussions, conversation views + create_if_not_exists index( + :messages, + [:conversation_id, :visibility, :deleted_at, :inserted_at], + name: :messages_conv_visibility_deleted_inserted_idx, + concurrently: true + ) + + # Index for public timeline and hashtag queries + # Used in: hashtag pages, public timeline, federated timeline + create_if_not_exists index(:messages, [:visibility, :deleted_at, :inserted_at], + name: :messages_visibility_deleted_inserted_idx, + concurrently: true + ) + + # Index for approval status filtering (moderated communities) + # Used in: queries that filter by approval_status + create_if_not_exists index(:messages, [:approval_status, :visibility, :inserted_at], + name: :messages_approval_visibility_inserted_idx, + concurrently: true + ) + + # Index for remote_actor_id queries (federated content) + # Used in: federated timeline, remote actor posts + create_if_not_exists index( + :messages, + [:remote_actor_id, :visibility, :deleted_at, :inserted_at], + name: :messages_remote_actor_visibility_deleted_inserted_idx, + concurrently: true, + where: "remote_actor_id IS NOT NULL" + ) + + # Index for hashtag lookups by normalized name + # Used in: hashtag pages, hashtag search + create_if_not_exists index(:hashtags, [:normalized_name], + name: :hashtags_normalized_name_idx, + concurrently: true + ) + + # Index for post_hashtags join table + # Used in: hashtag post queries + create_if_not_exists index(:post_hashtags, [:hashtag_id, :message_id], + name: :post_hashtags_hashtag_message_idx, + concurrently: true + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260125082934_add_category_to_messages.exs b/apps/elektrine/priv/repo/migrations/20260125082934_add_category_to_messages.exs new file mode 100644 index 0000000..5faa2fd --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260125082934_add_category_to_messages.exs @@ -0,0 +1,12 @@ +defmodule Elektrine.Repo.Migrations.AddCategoryToMessages do + use Ecto.Migration + + def change do + alter table(:messages) do + add :category, :string + end + + # Index for filtering gallery posts by category + create index(:messages, [:category], where: "category IS NOT NULL") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260125085508_create_api_tokens.exs b/apps/elektrine/priv/repo/migrations/20260125085508_create_api_tokens.exs new file mode 100644 index 0000000..82cc57a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260125085508_create_api_tokens.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Repo.Migrations.CreateApiTokens do + use Ecto.Migration + + def change do + create table(:api_tokens) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :name, :string, null: false + add :token_hash, :string, null: false + add :token_prefix, :string, null: false + add :scopes, {:array, :string}, default: [], null: false + add :last_used_at, :utc_datetime + add :last_used_ip, :string + add :expires_at, :utc_datetime + add :revoked_at, :utc_datetime + + timestamps() + end + + create unique_index(:api_tokens, [:token_hash]) + create index(:api_tokens, [:user_id]) + create index(:api_tokens, [:user_id, :revoked_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260125090103_create_data_exports.exs b/apps/elektrine/priv/repo/migrations/20260125090103_create_data_exports.exs new file mode 100644 index 0000000..8451e9e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260125090103_create_data_exports.exs @@ -0,0 +1,29 @@ +defmodule Elektrine.Repo.Migrations.CreateDataExports do + use Ecto.Migration + + def change do + create table(:data_exports) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :export_type, :string, null: false + add :format, :string, null: false, default: "json" + add :status, :string, null: false, default: "pending" + add :file_path, :string + add :file_size, :bigint + add :item_count, :integer + add :filters, :map, default: %{} + add :download_token, :string + add :download_count, :integer, default: 0 + add :expires_at, :utc_datetime + add :started_at, :utc_datetime + add :completed_at, :utc_datetime + add :error, :text + + timestamps() + end + + create index(:data_exports, [:user_id]) + create index(:data_exports, [:user_id, :status]) + create index(:data_exports, [:download_token]) + create index(:data_exports, [:expires_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260125232349_add_draft_status_to_messages.exs b/apps/elektrine/priv/repo/migrations/20260125232349_add_draft_status_to_messages.exs new file mode 100644 index 0000000..1e31d45 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260125232349_add_draft_status_to_messages.exs @@ -0,0 +1,15 @@ +defmodule Elektrine.Repo.Migrations.AddDraftStatusToMessages do + use Ecto.Migration + + def change do + alter table(:messages) do + # Draft status: nil = published, "draft" = draft + add :is_draft, :boolean, default: false + # Scheduled publishing time (optional, for future scheduling feature) + add :scheduled_at, :utc_datetime + end + + # Index for querying drafts by user + create index(:messages, [:sender_id, :is_draft], where: "is_draft = true") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260126072102_add_lemmy_counts_cache.exs b/apps/elektrine/priv/repo/migrations/20260126072102_add_lemmy_counts_cache.exs new file mode 100644 index 0000000..de42393 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260126072102_add_lemmy_counts_cache.exs @@ -0,0 +1,20 @@ +defmodule Elektrine.Repo.Migrations.AddLemmyCountsCache do + use Ecto.Migration + + def change do + create table(:lemmy_counts_cache) do + add :activitypub_id, :text, null: false + add :upvotes, :integer, default: 0 + add :downvotes, :integer, default: 0 + add :score, :integer, default: 0 + add :comments, :integer, default: 0 + add :top_comments, :jsonb, default: "[]" + add :fetched_at, :utc_datetime, null: false + + timestamps() + end + + create unique_index(:lemmy_counts_cache, [:activitypub_id]) + create index(:lemmy_counts_cache, [:fetched_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260127034957_create_signing_keys.exs b/apps/elektrine/priv/repo/migrations/20260127034957_create_signing_keys.exs new file mode 100644 index 0000000..cb4c62c --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260127034957_create_signing_keys.exs @@ -0,0 +1,31 @@ +defmodule Elektrine.Repo.Migrations.CreateSigningKeys do + use Ecto.Migration + + def change do + create table(:signing_keys, primary_key: false) do + # key_id is the primary key (e.g., "https://example.com/users/alice#main-key") + add :key_id, :string, primary_key: true, null: false + + # Foreign keys - a key belongs to either a local user OR a remote actor + add :user_id, references(:users, on_delete: :delete_all), null: true + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all), null: true + + # Key material + add :public_key, :text, null: false + # Private key only for local users + add :private_key, :text, null: true + + timestamps() + end + + # Index for looking up keys by user + create index(:signing_keys, [:user_id], where: "user_id IS NOT NULL") + create index(:signing_keys, [:remote_actor_id], where: "remote_actor_id IS NOT NULL") + + # Ensure a key belongs to exactly one entity + create constraint(:signing_keys, :must_have_owner, + check: + "(user_id IS NOT NULL AND remote_actor_id IS NULL) OR (user_id IS NULL AND remote_actor_id IS NOT NULL)" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260127035230_migrate_existing_keys_to_signing_keys.exs b/apps/elektrine/priv/repo/migrations/20260127035230_migrate_existing_keys_to_signing_keys.exs new file mode 100644 index 0000000..cb047e1 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260127035230_migrate_existing_keys_to_signing_keys.exs @@ -0,0 +1,12 @@ +defmodule Elektrine.Repo.Migrations.MigrateExistingKeysToSigningKeys do + use Ecto.Migration + + @moduledoc false + + # This migration used to derive signing key ids from INSTANCE_URL at migration + # time. The backfill is now an explicit operational step: + # `mix activitypub.backfill_signing_keys` + + def up, do: :ok + def down, do: :ok +end diff --git a/apps/elektrine/priv/repo/migrations/20260127090905_add_mrf_policies_to_instances.exs b/apps/elektrine/priv/repo/migrations/20260127090905_add_mrf_policies_to_instances.exs new file mode 100644 index 0000000..f032ada --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260127090905_add_mrf_policies_to_instances.exs @@ -0,0 +1,30 @@ +defmodule Elektrine.Repo.Migrations.AddMrfPoliciesToInstances do + use Ecto.Migration + + def change do + alter table(:activitypub_instances) do + # MRF policy flags - each represents a different level of restriction + # More granular than just blocked/silenced + add :media_removal, :boolean, default: false + add :media_nsfw, :boolean, default: false + add :federated_timeline_removal, :boolean, default: false + add :followers_only, :boolean, default: false + add :report_removal, :boolean, default: false + add :avatar_removal, :boolean, default: false + add :banner_removal, :boolean, default: false + add :reject_deletes, :boolean, default: false + + # Track who applied each policy and when + add :policy_applied_at, :utc_datetime + add :policy_applied_by_id, references(:users, on_delete: :nilify_all) + + # Notes field for additional context + add :notes, :text + end + + # Create index for quick policy lookups (if_not_exists to handle pre-existing indices) + create_if_not_exists index(:activitypub_instances, [:blocked]) + create_if_not_exists index(:activitypub_instances, [:silenced]) + create_if_not_exists index(:activitypub_instances, [:federated_timeline_removal]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260127091426_create_relay_subscriptions.exs b/apps/elektrine/priv/repo/migrations/20260127091426_create_relay_subscriptions.exs new file mode 100644 index 0000000..c3f7937 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260127091426_create_relay_subscriptions.exs @@ -0,0 +1,35 @@ +defmodule Elektrine.Repo.Migrations.CreateRelaySubscriptions do + use Ecto.Migration + + def change do + create table(:activitypub_relay_subscriptions) do + # The relay actor URI (e.g., https://relay.mastodon.host/actor) + add :relay_uri, :string, null: false + + # The Follow activity ID we sent + add :follow_activity_id, :string + + # Status: pending (awaiting Accept), active, rejected, error + add :status, :string, default: "pending" + + # Cached relay actor info + add :relay_inbox, :string + add :relay_name, :string + add :relay_software, :string + + # Whether we're accepted by the relay + add :accepted, :boolean, default: false + + # Error details if subscription failed + add :error_message, :text + + # Who subscribed to this relay + add :subscribed_by_id, references(:users, on_delete: :nilify_all) + + timestamps() + end + + create unique_index(:activitypub_relay_subscriptions, [:relay_uri]) + create index(:activitypub_relay_subscriptions, [:status]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260127100000_create_remote_interactions.exs b/apps/elektrine/priv/repo/migrations/20260127100000_create_remote_interactions.exs new file mode 100644 index 0000000..ab81fb6 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260127100000_create_remote_interactions.exs @@ -0,0 +1,36 @@ +defmodule Elektrine.Repo.Migrations.CreateRemoteInteractions do + use Ecto.Migration + + def change do + create table(:remote_interactions) do + add(:interaction_type, :string, null: false) + add(:actor_uri, :string, null: false) + add(:emoji, :string) + add(:message_id, references(:messages, on_delete: :delete_all), null: false) + add(:remote_actor_id, references(:activitypub_actors, on_delete: :nilify_all)) + + timestamps(type: :utc_datetime, updated_at: false) + end + + # Index for querying interactions by message + create(index(:remote_interactions, [:message_id, :interaction_type])) + + # Index for checking if an actor has interacted + create(index(:remote_interactions, [:message_id, :actor_uri])) + + # Unique constraint to prevent duplicate interactions + create( + unique_index(:remote_interactions, [:message_id, :actor_uri, :interaction_type, :emoji], + name: :remote_interactions_unique_index, + nulls_distinct: true + ) + ) + + # Index for emoji reactions + create( + index(:remote_interactions, [:message_id, :emoji], + where: "interaction_type = 'emoji_react'" + ) + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260127100100_add_voters_tracking_to_polls.exs b/apps/elektrine/priv/repo/migrations/20260127100100_add_voters_tracking_to_polls.exs new file mode 100644 index 0000000..58ffe93 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260127100100_add_voters_tracking_to_polls.exs @@ -0,0 +1,16 @@ +defmodule Elektrine.Repo.Migrations.AddVotersTrackingToPolls do + use Ecto.Migration + + def change do + alter table(:polls) do + # Track unique voter count (separate from total_votes for multi-choice polls) + add(:voters_count, :integer, default: 0) + + # Store voter actor URIs for federated polls (prevents double-voting) + add(:voter_uris, {:array, :string}, default: []) + end + + # Index for checking if a voter exists in the array + create(index(:polls, [:voter_uris], using: :gin)) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260127100200_add_emoji_url_to_message_reactions.exs b/apps/elektrine/priv/repo/migrations/20260127100200_add_emoji_url_to_message_reactions.exs new file mode 100644 index 0000000..2cd907c --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260127100200_add_emoji_url_to_message_reactions.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.AddEmojiUrlToMessageReactions do + use Ecto.Migration + + def change do + alter table(:message_reactions) do + # URL for custom emoji images (for federated reactions with custom emoji) + add(:emoji_url, :string) + end + + # Index for grouping reactions by emoji (including custom emoji URL) + create(index(:message_reactions, [:message_id, :emoji, :emoji_url])) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260127100300_add_reachability_to_instances.exs b/apps/elektrine/priv/repo/migrations/20260127100300_add_reachability_to_instances.exs new file mode 100644 index 0000000..377f909 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260127100300_add_reachability_to_instances.exs @@ -0,0 +1,15 @@ +defmodule Elektrine.Repo.Migrations.AddReachabilityToInstances do + use Ecto.Migration + + def change do + alter table(:activitypub_instances) do + add(:unreachable_since, :utc_datetime) + add(:failure_count, :integer, default: 0) + end + + # Index for querying unreachable instances + create( + index(:activitypub_instances, [:unreachable_since], where: "unreachable_since IS NOT NULL") + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260127100400_create_hashtag_follows.exs b/apps/elektrine/priv/repo/migrations/20260127100400_create_hashtag_follows.exs new file mode 100644 index 0000000..d216f1e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260127100400_create_hashtag_follows.exs @@ -0,0 +1,15 @@ +defmodule Elektrine.Repo.Migrations.CreateHashtagFollows do + use Ecto.Migration + + def change do + create table(:hashtag_follows) do + add(:user_id, references(:users, on_delete: :delete_all), null: false) + add(:hashtag_id, references(:hashtags, on_delete: :delete_all), null: false) + + timestamps() + end + + create(unique_index(:hashtag_follows, [:user_id, :hashtag_id])) + create(index(:hashtag_follows, [:hashtag_id])) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260127120000_create_oauth_tables.exs b/apps/elektrine/priv/repo/migrations/20260127120000_create_oauth_tables.exs new file mode 100644 index 0000000..b683ff5 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260127120000_create_oauth_tables.exs @@ -0,0 +1,56 @@ +defmodule Elektrine.Repo.Migrations.CreateOAuthTables do + use Ecto.Migration + + def change do + # OAuth Apps - Third-party application registrations + create table(:oauth_apps) do + add :client_name, :string, null: false + add :redirect_uris, :text, null: false + add :scopes, {:array, :string}, default: ["read"], null: false + add :website, :string + add :client_id, :string, null: false + add :client_secret, :string, null: false + add :trusted, :boolean, default: false, null: false + add :user_id, references(:users, on_delete: :nilify_all) + + timestamps(type: :utc_datetime) + end + + create unique_index(:oauth_apps, [:client_id]) + create index(:oauth_apps, [:user_id]) + + # OAuth Authorizations - Short-lived authorization codes + create table(:oauth_authorizations) do + add :token, :string, null: false + add :scopes, {:array, :string}, default: [], null: false + add :valid_until, :utc_datetime, null: false + add :used, :boolean, default: false, null: false + add :user_id, references(:users, on_delete: :delete_all) + add :app_id, references(:oauth_apps, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:oauth_authorizations, [:token]) + create index(:oauth_authorizations, [:app_id]) + create index(:oauth_authorizations, [:user_id]) + + # OAuth Tokens - Long-lived access tokens + create table(:oauth_tokens) do + add :token, :string, null: false + add :refresh_token, :string, null: false + add :scopes, {:array, :string}, default: [], null: false + add :valid_until, :utc_datetime, null: false + add :user_id, references(:users, on_delete: :delete_all) + add :app_id, references(:oauth_apps, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:oauth_tokens, [:token]) + create unique_index(:oauth_tokens, [:refresh_token]) + create index(:oauth_tokens, [:app_id]) + create index(:oauth_tokens, [:user_id]) + create index(:oauth_tokens, [:valid_until]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260128142101_add_nodeinfo_to_instances.exs b/apps/elektrine/priv/repo/migrations/20260128142101_add_nodeinfo_to_instances.exs new file mode 100644 index 0000000..651267d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260128142101_add_nodeinfo_to_instances.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddNodeinfoToInstances do + use Ecto.Migration + + def change do + alter table(:activitypub_instances) do + add :nodeinfo, :map, default: %{} + add :favicon, :string + add :metadata_updated_at, :utc_datetime + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260128144518_drop_subscriptions_table.exs b/apps/elektrine/priv/repo/migrations/20260128144518_drop_subscriptions_table.exs new file mode 100644 index 0000000..e5e8193 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260128144518_drop_subscriptions_table.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Repo.Migrations.DropSubscriptionsTable do + use Ecto.Migration + + def up do + drop_if_exists table(:subscriptions) + end + + def down do + create table(:subscriptions) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :plan, :string, null: false + add :status, :string, null: false, default: "active" + add :expires_at, :utc_datetime, null: false + + timestamps() + end + + create index(:subscriptions, [:user_id]) + create index(:subscriptions, [:status]) + create index(:subscriptions, [:expires_at]) + create index(:subscriptions, [:user_id, :status]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260129145416_create_passkey_credentials.exs b/apps/elektrine/priv/repo/migrations/20260129145416_create_passkey_credentials.exs new file mode 100644 index 0000000..6ef6578 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260129145416_create_passkey_credentials.exs @@ -0,0 +1,36 @@ +defmodule Elektrine.Repo.Migrations.CreatePasskeyCredentials do + use Ecto.Migration + + def change do + create table(:passkey_credentials) do + add :user_id, references(:users, on_delete: :delete_all), null: false + # WebAuthn credential ID (raw bytes) + add :credential_id, :binary, null: false + # Public key in COSE format (stored as term_to_binary) + add :public_key, :binary, null: false + # Counter for clone detection + add :sign_count, :integer, default: 0, null: false + # Stable random bytes per user for resident credentials + add :user_handle, :binary, null: false + # User-friendly name for the passkey + add :name, :string, default: "Passkey" + # Authenticator type identifier (AAGUID) + add :aaguid, :binary + # Transport hints: usb, nfc, ble, internal, hybrid + add :transports, {:array, :string}, default: [] + add :last_used_at, :utc_datetime + # Metadata for auditing + add :created_from_ip, :string + add :created_user_agent, :string + + timestamps() + end + + # Each credential_id must be unique across all users + create unique_index(:passkey_credentials, [:credential_id]) + # Efficient lookup by user + create index(:passkey_credentials, [:user_id]) + # Efficient lookup by user_handle for discoverable credentials + create index(:passkey_credentials, [:user_handle]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260203150001_create_billing_subscriptions.exs b/apps/elektrine/priv/repo/migrations/20260203150001_create_billing_subscriptions.exs new file mode 100644 index 0000000..fcf56bd --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260203150001_create_billing_subscriptions.exs @@ -0,0 +1,32 @@ +defmodule Elektrine.Repo.Migrations.CreateBillingSubscriptions do + use Ecto.Migration + + def change do + create table(:subscriptions) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :product, :string, null: false + add :stripe_customer_id, :string + add :stripe_subscription_id, :string + add :stripe_price_id, :string + add :status, :string, null: false, default: "incomplete" + add :current_period_start, :utc_datetime + add :current_period_end, :utc_datetime + add :canceled_at, :utc_datetime + add :cancel_at_period_end, :boolean, default: false + add :metadata, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + # One subscription per user per product + create unique_index(:subscriptions, [:user_id, :product]) + + create unique_index(:subscriptions, [:stripe_subscription_id], + where: "stripe_subscription_id IS NOT NULL" + ) + + create index(:subscriptions, [:stripe_customer_id]) + create index(:subscriptions, [:status]) + create index(:subscriptions, [:product]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260203160001_create_subscription_products.exs b/apps/elektrine/priv/repo/migrations/20260203160001_create_subscription_products.exs new file mode 100644 index 0000000..c478b9d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260203160001_create_subscription_products.exs @@ -0,0 +1,27 @@ +defmodule Elektrine.Repo.Migrations.CreateSubscriptionProducts do + use Ecto.Migration + + def change do + create table(:subscription_products) do + add :name, :string, null: false + add :slug, :string, null: false + add :description, :text + add :features, {:array, :string}, default: [] + add :stripe_monthly_price_id, :string + add :stripe_yearly_price_id, :string + add :monthly_price_cents, :integer + add :yearly_price_cents, :integer + add :currency, :string, default: "usd" + add :active, :boolean, default: true + add :sort_order, :integer, default: 0 + + timestamps(type: :utc_datetime) + end + + create unique_index(:subscription_products, [:slug]) + create index(:subscription_products, [:active]) + + # Update subscriptions table to reference products by slug instead of hardcoded string + # Keep existing product column for backwards compatibility + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260205100001_add_pgp_wkd_hash_to_users.exs b/apps/elektrine/priv/repo/migrations/20260205100001_add_pgp_wkd_hash_to_users.exs new file mode 100644 index 0000000..a08abfe --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260205100001_add_pgp_wkd_hash_to_users.exs @@ -0,0 +1,12 @@ +defmodule Elektrine.Repo.Migrations.AddPgpWkdHashToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :pgp_wkd_hash, :string + end + + # Create index for efficient WKD lookups + create index(:users, [:pgp_wkd_hash], where: "pgp_wkd_hash IS NOT NULL") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260209020000_remove_ai_pentest_feature.exs b/apps/elektrine/priv/repo/migrations/20260209020000_remove_ai_pentest_feature.exs new file mode 100644 index 0000000..f063934 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260209020000_remove_ai_pentest_feature.exs @@ -0,0 +1,66 @@ +defmodule Elektrine.Repo.Migrations.RemoveAiPentestFeature do + use Ecto.Migration + + # Drop legacy tables/columns left behind by the removed AI pentest feature. + # This is intentionally defensive (IF EXISTS + CASCADE) to support mixed DB states. + @tables [ + # Chat/session tables + "paige_messages", + "paige_sessions", + "paige_daily_usage", + "meido_messages", + "meido_sessions", + "meido_daily_usage", + # Usage/cost tracking + "paige_llm_usage", + # Code scanning tables + "paige_code_vulnerabilities", + "paige_code_scans", + "paige_code_rules", + "paige_secrets", + "paige_repositories", + # Scan/project tables (multiple historical schemas) + "paige_tool_executions", + "paige_agent_actions", + "paige_finding_comments", + "paige_findings", + "paige_scans", + "paige_projects", + "paige_vulnerabilities", + "paige_targets", + "paige_agents", + # Misc legacy tables + "paige_api_keys", + "paige_chat_messages" + ] + + def up do + execute(""" + DO $$ + BEGIN + IF to_regclass('public.siem_sources') IS NOT NULL THEN + ALTER TABLE siem_sources DROP COLUMN IF EXISTS agent_id; + END IF; + END $$; + """) + + execute(""" + DO $$ + BEGIN + IF to_regclass('public.users') IS NOT NULL THEN + ALTER TABLE users DROP COLUMN IF EXISTS paige_tokens_used; + ALTER TABLE users DROP COLUMN IF EXISTS paige_cost_cents; + ALTER TABLE users DROP COLUMN IF EXISTS paige_usage_limit_cents; + END IF; + END $$; + """) + + for table <- @tables do + execute("DROP TABLE IF EXISTS #{table} CASCADE") + end + end + + def down do + :ok + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260215110000_create_password_vault_entries.exs b/apps/elektrine/priv/repo/migrations/20260215110000_create_password_vault_entries.exs new file mode 100644 index 0000000..1270a48 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260215110000_create_password_vault_entries.exs @@ -0,0 +1,19 @@ +defmodule Elektrine.Repo.Migrations.CreatePasswordVaultEntries do + use Ecto.Migration + + def change do + create table(:password_vault_entries) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :title, :string, null: false + add :login_username, :string + add :website, :string + add :encrypted_password, :map, null: false + add :encrypted_notes, :map + + timestamps(type: :utc_datetime) + end + + create index(:password_vault_entries, [:user_id]) + create index(:password_vault_entries, [:user_id, :inserted_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260215123000_create_user_mutes.exs b/apps/elektrine/priv/repo/migrations/20260215123000_create_user_mutes.exs new file mode 100644 index 0000000..64dd11c --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260215123000_create_user_mutes.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.CreateUserMutes do + use Ecto.Migration + + def change do + create table(:user_mutes) do + add :muter_id, references(:users, on_delete: :delete_all), null: false + add :muted_id, references(:users, on_delete: :delete_all), null: false + add :mute_notifications, :boolean, default: false, null: false + + timestamps() + end + + create index(:user_mutes, [:muter_id]) + create index(:user_mutes, [:muted_id]) + create unique_index(:user_mutes, [:muter_id, :muted_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260215124000_create_developer_webhooks.exs b/apps/elektrine/priv/repo/migrations/20260215124000_create_developer_webhooks.exs new file mode 100644 index 0000000..2888b8e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260215124000_create_developer_webhooks.exs @@ -0,0 +1,22 @@ +defmodule Elektrine.Repo.Migrations.CreateDeveloperWebhooks do + use Ecto.Migration + + def change do + create table(:developer_webhooks) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :name, :string, null: false + add :url, :string, null: false + add :events, {:array, :string}, default: [], null: false + add :secret, :string, null: false + add :enabled, :boolean, default: true, null: false + add :last_triggered_at, :utc_datetime + add :last_response_status, :integer + add :last_error, :text + + timestamps() + end + + create index(:developer_webhooks, [:user_id]) + create index(:developer_webhooks, [:user_id, :enabled]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260215130000_create_messaging_servers.exs b/apps/elektrine/priv/repo/migrations/20260215130000_create_messaging_servers.exs new file mode 100644 index 0000000..43a6a92 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260215130000_create_messaging_servers.exs @@ -0,0 +1,45 @@ +defmodule Elektrine.Repo.Migrations.CreateMessagingServers do + use Ecto.Migration + + def change do + create table(:messaging_servers) do + add :name, :string, null: false + add :description, :text + add :icon_url, :string + add :is_public, :boolean, default: false, null: false + add :member_count, :integer, default: 0, null: false + add :last_activity_at, :utc_datetime + add :creator_id, references(:users, on_delete: :nilify_all) + + timestamps() + end + + create index(:messaging_servers, [:creator_id]) + create index(:messaging_servers, [:is_public]) + create index(:messaging_servers, [:member_count]) + + create table(:messaging_server_members) do + add :server_id, references(:messaging_servers, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + add :role, :string, default: "member", null: false + add :joined_at, :utc_datetime, default: fragment("now()") + add :left_at, :utc_datetime + add :notifications_enabled, :boolean, default: true, null: false + + timestamps() + end + + create unique_index(:messaging_server_members, [:server_id, :user_id]) + create index(:messaging_server_members, [:user_id]) + create index(:messaging_server_members, [:server_id, :left_at]) + + alter table(:conversations) do + add :server_id, references(:messaging_servers, on_delete: :delete_all) + add :channel_topic, :text + add :channel_position, :integer, default: 0, null: false + end + + create index(:conversations, [:server_id]) + create index(:conversations, [:server_id, :channel_position]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260215141000_add_federation_fields_to_messaging_servers.exs b/apps/elektrine/priv/repo/migrations/20260215141000_add_federation_fields_to_messaging_servers.exs new file mode 100644 index 0000000..99d6e31 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260215141000_add_federation_fields_to_messaging_servers.exs @@ -0,0 +1,37 @@ +defmodule Elektrine.Repo.Migrations.AddFederationFieldsToMessagingServers do + use Ecto.Migration + + def change do + alter table(:messaging_servers) do + add :last_federated_at, :utc_datetime + add :federation_id, :string + add :origin_domain, :string + add :is_federated_mirror, :boolean, default: false, null: false + end + + create index(:messaging_servers, [:origin_domain]) + + create unique_index(:messaging_servers, [:federation_id], + where: "federation_id IS NOT NULL", + name: :messaging_servers_federation_id_unique + ) + + create unique_index(:conversations, [:federated_source], + where: "type = 'channel' AND federated_source IS NOT NULL", + name: :conversations_channel_federated_source_unique + ) + + alter table(:chat_messages) do + add :federated_source, :string + add :origin_domain, :string + add :is_federated_mirror, :boolean, default: false, null: false + end + + create index(:chat_messages, [:origin_domain]) + + create unique_index(:chat_messages, [:conversation_id, :federated_source], + where: "federated_source IS NOT NULL", + name: :chat_messages_conversation_federated_source_unique + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260215152000_add_messaging_federation_event_stream_tables.exs b/apps/elektrine/priv/repo/migrations/20260215152000_add_messaging_federation_event_stream_tables.exs new file mode 100644 index 0000000..a30f857 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260215152000_add_messaging_federation_event_stream_tables.exs @@ -0,0 +1,44 @@ +defmodule Elektrine.Repo.Migrations.AddMessagingFederationEventStreamTables do + use Ecto.Migration + + def change do + create table(:messaging_federation_events) do + add :event_id, :string, null: false + add :origin_domain, :string, null: false + add :event_type, :string, null: false + add :stream_id, :string, null: false + add :sequence, :bigint, null: false + add :payload, :map, null: false + add :received_at, :utc_datetime, null: false + + timestamps(updated_at: false) + end + + create unique_index(:messaging_federation_events, [:event_id]) + create index(:messaging_federation_events, [:origin_domain, :stream_id, :sequence]) + create index(:messaging_federation_events, [:inserted_at]) + + create table(:messaging_federation_stream_positions) do + add :origin_domain, :string, null: false + add :stream_id, :string, null: false + add :last_sequence, :bigint, null: false, default: 0 + + timestamps() + end + + create unique_index(:messaging_federation_stream_positions, [:origin_domain, :stream_id], + name: :messaging_federation_stream_positions_unique + ) + + create table(:messaging_federation_stream_counters) do + add :stream_id, :string, null: false + add :next_sequence, :bigint, null: false, default: 1 + + timestamps() + end + + create unique_index(:messaging_federation_stream_counters, [:stream_id], + name: :messaging_federation_stream_counters_unique + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260215165000_add_messaging_federation_outbox_and_archive.exs b/apps/elektrine/priv/repo/migrations/20260215165000_add_messaging_federation_outbox_and_archive.exs new file mode 100644 index 0000000..3afad2d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260215165000_add_messaging_federation_outbox_and_archive.exs @@ -0,0 +1,65 @@ +defmodule Elektrine.Repo.Migrations.AddMessagingFederationOutboxAndArchive do + use Ecto.Migration + + def change do + create table(:messaging_federation_outbox_events) do + add :event_id, :string, null: false + add :event_type, :string, null: false + add :stream_id, :string, null: false + add :sequence, :bigint, null: false + add :payload, :map, null: false + add :target_domains, {:array, :string}, null: false, default: [] + add :delivered_domains, {:array, :string}, null: false, default: [] + add :attempt_count, :integer, null: false, default: 0 + add :max_attempts, :integer, null: false, default: 8 + add :status, :string, null: false, default: "pending" + add :next_retry_at, :utc_datetime, null: false + add :last_error, :text + + add :partition_month, :date, + null: false, + default: fragment("date_trunc('month', now())::date") + + add :dispatched_at, :utc_datetime + + timestamps() + end + + create unique_index(:messaging_federation_outbox_events, [:event_id], + name: :messaging_federation_outbox_event_id_unique + ) + + create index(:messaging_federation_outbox_events, [:status, :next_retry_at], + name: :messaging_federation_outbox_status_retry_idx + ) + + create index(:messaging_federation_outbox_events, [:partition_month], + name: :messaging_federation_outbox_partition_month_idx + ) + + create table(:messaging_federation_events_archive) do + add :event_id, :string, null: false + add :origin_domain, :string, null: false + add :event_type, :string, null: false + add :stream_id, :string, null: false + add :sequence, :bigint, null: false + add :payload, :map, null: false + add :received_at, :utc_datetime, null: false + add :partition_month, :date, null: false + + timestamps(updated_at: false) + end + + create unique_index(:messaging_federation_events_archive, [:event_id], + name: :messaging_federation_events_archive_event_id_unique + ) + + create index(:messaging_federation_events_archive, [:partition_month], + name: :messaging_federation_events_archive_partition_month_idx + ) + + create index(:messaging_federation_events_archive, [:origin_domain, :stream_id, :sequence], + name: :messaging_federation_events_archive_stream_seq_idx + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260216190000_create_api_token_revocations.exs b/apps/elektrine/priv/repo/migrations/20260216190000_create_api_token_revocations.exs new file mode 100644 index 0000000..4ded825 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260216190000_create_api_token_revocations.exs @@ -0,0 +1,16 @@ +defmodule Elektrine.Repo.Migrations.CreateApiTokenRevocations do + use Ecto.Migration + + def change do + create table(:api_token_revocations) do + add :token_hash, :string, null: false + add :expires_at, :utc_datetime, null: false + add :revoked_at, :utc_datetime, null: false + + timestamps(updated_at: false) + end + + create unique_index(:api_token_revocations, [:token_hash]) + create index(:api_token_revocations, [:expires_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260218090000_create_email_suppressions.exs b/apps/elektrine/priv/repo/migrations/20260218090000_create_email_suppressions.exs new file mode 100644 index 0000000..f42f7ed --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260218090000_create_email_suppressions.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Repo.Migrations.CreateEmailSuppressions do + use Ecto.Migration + + def change do + create table(:email_suppressions) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :email, :string, null: false + add :reason, :string, null: false + add :source, :string, null: false, default: "manual" + add :note, :text + add :metadata, :map, null: false, default: %{} + add :last_event_at, :utc_datetime, null: false + add :expires_at, :utc_datetime + + timestamps() + end + + create index(:email_suppressions, [:user_id]) + create index(:email_suppressions, [:email]) + create index(:email_suppressions, [:expires_at]) + create unique_index(:email_suppressions, [:user_id, :email]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260218123000_add_bluesky_bridge_fields.exs b/apps/elektrine/priv/repo/migrations/20260218123000_add_bluesky_bridge_fields.exs new file mode 100644 index 0000000..84c34d7 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260218123000_add_bluesky_bridge_fields.exs @@ -0,0 +1,25 @@ +defmodule Elektrine.Repo.Migrations.AddBlueskyBridgeFields do + use Ecto.Migration + + def change do + alter table(:users) do + add :bluesky_enabled, :boolean, default: false, null: false + add :bluesky_identifier, :string + add :bluesky_app_password, :text + add :bluesky_did, :string + add :bluesky_pds_url, :string + end + + create index(:users, [:bluesky_enabled]) + create index(:users, [:bluesky_identifier]) + create index(:users, [:bluesky_did]) + + alter table(:messages) do + add :bluesky_uri, :text + add :bluesky_cid, :string + end + + create unique_index(:messages, [:bluesky_uri], where: "bluesky_uri IS NOT NULL") + create index(:messages, [:bluesky_cid]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260218133000_add_bluesky_inbound_tracking.exs b/apps/elektrine/priv/repo/migrations/20260218133000_add_bluesky_inbound_tracking.exs new file mode 100644 index 0000000..7b2a953 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260218133000_add_bluesky_inbound_tracking.exs @@ -0,0 +1,25 @@ +defmodule Elektrine.Repo.Migrations.AddBlueskyInboundTracking do + use Ecto.Migration + + def change do + alter table(:users) do + add :bluesky_inbound_cursor, :text + add :bluesky_inbound_last_polled_at, :utc_datetime + end + + create table(:bluesky_inbound_events) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :event_id, :string, null: false + add :reason, :string + add :related_post_uri, :text + add :processed_at, :utc_datetime, null: false + add :metadata, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + create unique_index(:bluesky_inbound_events, [:user_id, :event_id]) + create index(:bluesky_inbound_events, [:user_id, :processed_at]) + create index(:bluesky_inbound_events, [:related_post_uri]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260222120000_create_chat_user_hidden_messages.exs b/apps/elektrine/priv/repo/migrations/20260222120000_create_chat_user_hidden_messages.exs new file mode 100644 index 0000000..10d1d69 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260222120000_create_chat_user_hidden_messages.exs @@ -0,0 +1,20 @@ +defmodule Elektrine.Repo.Migrations.CreateChatUserHiddenMessages do + use Ecto.Migration + + def change do + create table(:chat_user_hidden_messages) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :chat_message_id, references(:chat_messages, on_delete: :delete_all), null: false + add :hidden_at, :utc_datetime, null: false + + timestamps() + end + + create unique_index(:chat_user_hidden_messages, [:user_id, :chat_message_id], + name: :chat_user_hidden_messages_user_message_unique + ) + + create index(:chat_user_hidden_messages, [:user_id, :hidden_at]) + create index(:chat_user_hidden_messages, [:chat_message_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260222130000_create_messaging_federation_peer_policies.exs b/apps/elektrine/priv/repo/migrations/20260222130000_create_messaging_federation_peer_policies.exs new file mode 100644 index 0000000..5bbcc9e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260222130000_create_messaging_federation_peer_policies.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Repo.Migrations.CreateMessagingFederationPeerPolicies do + use Ecto.Migration + + def change do + create table(:messaging_federation_peer_policies) do + add :domain, :string, null: false + add :allow_incoming, :boolean + add :allow_outgoing, :boolean + add :blocked, :boolean, null: false, default: false + add :reason, :text + add :updated_by_id, references(:users, on_delete: :nilify_all) + + timestamps() + end + + create unique_index(:messaging_federation_peer_policies, [:domain], + name: :messaging_federation_peer_policies_domain_unique + ) + + create index(:messaging_federation_peer_policies, [:blocked]) + create index(:messaging_federation_peer_policies, [:updated_by_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260222143000_add_arblarg_replay_and_idempotency.exs b/apps/elektrine/priv/repo/migrations/20260222143000_add_arblarg_replay_and_idempotency.exs new file mode 100644 index 0000000..36d1a2f --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260222143000_add_arblarg_replay_and_idempotency.exs @@ -0,0 +1,104 @@ +defmodule Elektrine.Repo.Migrations.AddArblargReplayAndIdempotency do + use Ecto.Migration + + def up do + alter table(:messaging_federation_events) do + add :protocol_version, :string, null: false, default: "1.0" + add :idempotency_key, :string + end + + execute( + "UPDATE messaging_federation_events SET idempotency_key = event_id WHERE idempotency_key IS NULL", + "" + ) + + alter table(:messaging_federation_events) do + modify :idempotency_key, :string, null: false + end + + create unique_index( + :messaging_federation_events, + [:origin_domain, :idempotency_key], + name: :messaging_federation_events_origin_idempotency_unique + ) + + alter table(:messaging_federation_events_archive) do + add :protocol_version, :string, null: false, default: "1.0" + add :idempotency_key, :string + end + + execute( + "UPDATE messaging_federation_events_archive SET idempotency_key = event_id WHERE idempotency_key IS NULL", + "" + ) + + alter table(:messaging_federation_events_archive) do + modify :idempotency_key, :string, null: false + end + + create index( + :messaging_federation_events_archive, + [:origin_domain, :idempotency_key], + name: :messaging_federation_events_archive_origin_idempotency_idx + ) + + create table(:messaging_federation_request_replays) do + add :nonce, :string, null: false + add :origin_domain, :string, null: false + add :key_id, :string + add :http_method, :string, null: false + add :request_path, :string, null: false + add :timestamp, :bigint, null: false + add :seen_at, :utc_datetime, null: false + add :expires_at, :utc_datetime, null: false + + timestamps(updated_at: false) + end + + create unique_index(:messaging_federation_request_replays, [:nonce], + name: :messaging_federation_request_replays_nonce_unique + ) + + create index(:messaging_federation_request_replays, [:expires_at], + name: :messaging_federation_request_replays_expires_at_idx + ) + + create index(:messaging_federation_request_replays, [:origin_domain, :inserted_at], + name: :messaging_federation_request_replays_origin_inserted_idx + ) + end + + def down do + drop_if_exists index(:messaging_federation_request_replays, [:origin_domain, :inserted_at], + name: :messaging_federation_request_replays_origin_inserted_idx + ) + + drop_if_exists index(:messaging_federation_request_replays, [:expires_at], + name: :messaging_federation_request_replays_expires_at_idx + ) + + drop_if_exists index(:messaging_federation_request_replays, [:nonce], + name: :messaging_federation_request_replays_nonce_unique + ) + + drop table(:messaging_federation_request_replays) + + drop_if_exists index(:messaging_federation_events_archive, [:origin_domain, :idempotency_key], + name: :messaging_federation_events_archive_origin_idempotency_idx + ) + + alter table(:messaging_federation_events_archive) do + remove :idempotency_key + remove :protocol_version + end + + drop_if_exists index(:messaging_federation_events, [:origin_domain, :idempotency_key], + name: :messaging_federation_events_origin_idempotency_unique + ) + + alter table(:messaging_federation_events) do + remove :idempotency_key + remove :protocol_version + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260223010000_backfill_server_channel_visibility.exs b/apps/elektrine/priv/repo/migrations/20260223010000_backfill_server_channel_visibility.exs new file mode 100644 index 0000000..77f896e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260223010000_backfill_server_channel_visibility.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.BackfillServerChannelVisibility do + use Ecto.Migration + + def up do + execute(""" + UPDATE conversations + SET is_public = TRUE + WHERE type = 'channel' + AND server_id IS NOT NULL + AND is_public = FALSE + """) + end + + def down do + :ok + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260223101500_create_password_vault_settings.exs b/apps/elektrine/priv/repo/migrations/20260223101500_create_password_vault_settings.exs new file mode 100644 index 0000000..2a69685 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260223101500_create_password_vault_settings.exs @@ -0,0 +1,14 @@ +defmodule Elektrine.Repo.Migrations.CreatePasswordVaultSettings do + use Ecto.Migration + + def change do + create table(:password_vault_settings) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :encrypted_verifier, :map, null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:password_vault_settings, [:user_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260223143000_add_arbp_chat_projections.exs b/apps/elektrine/priv/repo/migrations/20260223143000_add_arbp_chat_projections.exs new file mode 100644 index 0000000..6cc5a94 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260223143000_add_arbp_chat_projections.exs @@ -0,0 +1,99 @@ +defmodule Elektrine.Repo.Migrations.AddArbpChatProjections do + use Ecto.Migration + + def change do + create table(:messaging_federation_extension_events) do + add :event_type, :string, null: false + add :origin_domain, :string, null: false + add :event_key, :string, null: false + add :status, :string + add :payload, :map, null: false, default: %{} + add :occurred_at, :utc_datetime + + add :server_id, references(:messaging_servers, on_delete: :delete_all) + add :conversation_id, references(:conversations, on_delete: :delete_all) + + timestamps(type: :utc_datetime) + end + + create unique_index( + :messaging_federation_extension_events, + [:event_type, :origin_domain, :event_key], + name: :messaging_federation_extension_events_unique + ) + + create index( + :messaging_federation_extension_events, + [:server_id, :event_type], + name: :messaging_federation_extension_events_server_type_idx + ) + + create index( + :messaging_federation_extension_events, + [:conversation_id, :event_type], + name: :messaging_federation_extension_events_conversation_type_idx + ) + + create index( + :messaging_federation_extension_events, + [:occurred_at], + name: :messaging_federation_extension_events_occurred_at_idx + ) + + create table(:messaging_federation_read_receipts) do + add :chat_message_id, references(:chat_messages, on_delete: :delete_all), null: false + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all), null: false + add :origin_domain, :string, null: false + add :read_at, :utc_datetime, null: false + + timestamps(type: :utc_datetime) + end + + create unique_index( + :messaging_federation_read_receipts, + [:chat_message_id, :remote_actor_id], + name: :messaging_federation_read_receipts_unique + ) + + create index( + :messaging_federation_read_receipts, + [:chat_message_id, :read_at], + name: :messaging_federation_read_receipts_message_read_at_idx + ) + + create index( + :messaging_federation_read_receipts, + [:remote_actor_id], + name: :messaging_federation_read_receipts_actor_idx + ) + + create table(:messaging_federation_presence_states) do + add :server_id, references(:messaging_servers, on_delete: :delete_all), null: false + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all), null: false + add :origin_domain, :string, null: false + add :status, :string, null: false + add :activities, :map, null: false, default: %{} + add :updated_at_remote, :utc_datetime, null: false + + timestamps(type: :utc_datetime) + end + + create unique_index( + :messaging_federation_presence_states, + [:server_id, :remote_actor_id], + name: :messaging_federation_presence_states_unique + ) + + create index( + :messaging_federation_presence_states, + [:server_id, :status], + name: :messaging_federation_presence_states_server_status_idx + ) + + create index( + :messaging_federation_presence_states, + [:updated_at_remote], + name: :messaging_federation_presence_states_updated_at_remote_idx + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260224083500_create_imap_subscriptions.exs b/apps/elektrine/priv/repo/migrations/20260224083500_create_imap_subscriptions.exs new file mode 100644 index 0000000..8284e50 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260224083500_create_imap_subscriptions.exs @@ -0,0 +1,15 @@ +defmodule Elektrine.Repo.Migrations.CreateImapSubscriptions do + use Ecto.Migration + + def change do + create table(:imap_subscriptions) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :folder_name, :string, null: false + + timestamps(type: :utc_datetime) + end + + create index(:imap_subscriptions, [:user_id]) + create unique_index(:imap_subscriptions, [:user_id, :folder_name]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260224105000_add_messages_activitypub_url_lookup_index.exs b/apps/elektrine/priv/repo/migrations/20260224105000_add_messages_activitypub_url_lookup_index.exs new file mode 100644 index 0000000..2b1abb4 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260224105000_add_messages_activitypub_url_lookup_index.exs @@ -0,0 +1,21 @@ +defmodule Elektrine.Repo.Migrations.AddMessagesActivitypubUrlLookupIndex do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + create_if_not_exists index(:messages, [:activitypub_url], + concurrently: true, + where: "activitypub_url IS NOT NULL", + name: :messages_activitypub_url_not_null_idx + ) + end + + def down do + drop_if_exists index(:messages, [:activitypub_url], + concurrently: true, + name: :messages_activitypub_url_not_null_idx + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260224112000_add_hot_path_feed_and_email_indexes.exs b/apps/elektrine/priv/repo/migrations/20260224112000_add_hot_path_feed_and_email_indexes.exs new file mode 100644 index 0000000..d3e0af4 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260224112000_add_hot_path_feed_and_email_indexes.exs @@ -0,0 +1,62 @@ +defmodule Elektrine.Repo.Migrations.AddHotPathFeedAndEmailIndexes do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS messages_remote_actor_fed_public_top_level_inserted_idx + ON messages (remote_actor_id, inserted_at DESC) + WHERE federated = TRUE + AND visibility = 'public' + AND deleted_at IS NULL + AND reply_to_id IS NULL + AND remote_actor_id IS NOT NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS messages_community_actor_uri_fed_public_top_level_inserted_idx + ON messages ((media_metadata->>'community_actor_uri'), inserted_at DESC) + WHERE federated = TRUE + AND visibility = 'public' + AND deleted_at IS NULL + AND reply_to_id IS NULL + AND (media_metadata->>'community_actor_uri') IS NOT NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS email_messages_mailbox_id_id_idx + ON email_messages (mailbox_id, id) + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS email_messages_mailbox_active_inserted_idx + ON email_messages (mailbox_id, inserted_at DESC) + WHERE spam = FALSE + AND archived = FALSE + AND deleted = FALSE + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS email_messages_active_inserted_idx + ON email_messages (inserted_at DESC) + WHERE spam = FALSE + AND archived = FALSE + """) + end + + def down do + execute( + "DROP INDEX CONCURRENTLY IF EXISTS messages_remote_actor_fed_public_top_level_inserted_idx" + ) + + execute( + "DROP INDEX CONCURRENTLY IF EXISTS messages_community_actor_uri_fed_public_top_level_inserted_idx" + ) + + execute("DROP INDEX CONCURRENTLY IF EXISTS email_messages_mailbox_id_id_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS email_messages_mailbox_active_inserted_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS email_messages_active_inserted_idx") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260224124500_add_activitypub_actor_type_hot_path_index.exs b/apps/elektrine/priv/repo/migrations/20260224124500_add_activitypub_actor_type_hot_path_index.exs new file mode 100644 index 0000000..b472ccc --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260224124500_add_activitypub_actor_type_hot_path_index.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.AddActivityPubActorTypeHotPathIndex do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS activitypub_actors_actor_type_id_idx + ON activitypub_actors (actor_type, id) + """) + end + + def down do + execute("DROP INDEX CONCURRENTLY IF EXISTS activitypub_actors_actor_type_id_idx") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260303110000_create_developer_webhook_deliveries.exs b/apps/elektrine/priv/repo/migrations/20260303110000_create_developer_webhook_deliveries.exs new file mode 100644 index 0000000..29b3f47 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260303110000_create_developer_webhook_deliveries.exs @@ -0,0 +1,28 @@ +defmodule Elektrine.Repo.Migrations.CreateDeveloperWebhookDeliveries do + use Ecto.Migration + + def change do + create table(:developer_webhook_deliveries) do + add :webhook_id, references(:developer_webhooks, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + add :event, :string, null: false + add :event_id, :string, null: false + add :payload, :map, null: false, default: %{} + add :status, :string, null: false, default: "pending" + add :attempt_count, :integer, null: false, default: 0 + add :response_status, :integer + add :error, :text + add :duration_ms, :integer + add :last_attempted_at, :utc_datetime + add :delivered_at, :utc_datetime + + timestamps() + end + + create index(:developer_webhook_deliveries, [:user_id]) + create index(:developer_webhook_deliveries, [:webhook_id]) + create index(:developer_webhook_deliveries, [:status]) + create index(:developer_webhook_deliveries, [:inserted_at]) + create unique_index(:developer_webhook_deliveries, [:event_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260305100000_create_email_category_preferences.exs b/apps/elektrine/priv/repo/migrations/20260305100000_create_email_category_preferences.exs new file mode 100644 index 0000000..c6faa17 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260305100000_create_email_category_preferences.exs @@ -0,0 +1,45 @@ +defmodule Elektrine.Repo.Migrations.CreateEmailCategoryPreferences do + use Ecto.Migration + + def change do + create table(:email_category_preferences) do + add :email, :string + add :domain, :string + add :category, :string, null: false + add :confidence, :float, null: false, default: 0.7 + add :learned_count, :integer, null: false, default: 1 + add :source, :string, null: false, default: "manual_move" + add :last_learned_at, :utc_datetime + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:email_category_preferences, [:user_id]) + create index(:email_category_preferences, [:email]) + create index(:email_category_preferences, [:domain]) + + create unique_index(:email_category_preferences, [:user_id, :email], + where: "email IS NOT NULL", + name: :email_category_preferences_user_email_idx + ) + + create unique_index(:email_category_preferences, [:user_id, :domain], + where: "domain IS NOT NULL", + name: :email_category_preferences_user_domain_idx + ) + + create constraint( + :email_category_preferences, + :email_category_preferences_email_or_domain_check, + check: + "(email IS NOT NULL AND domain IS NULL) OR (email IS NULL AND domain IS NOT NULL)" + ) + + create constraint( + :email_category_preferences, + :email_category_preferences_category_check, + check: "category IN ('feed', 'ledger')" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260308100000_add_arbp_fast_path_projections.exs b/apps/elektrine/priv/repo/migrations/20260308100000_add_arbp_fast_path_projections.exs new file mode 100644 index 0000000..01a2f78 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260308100000_add_arbp_fast_path_projections.exs @@ -0,0 +1,34 @@ +defmodule Elektrine.Repo.Migrations.AddArbpFastPathProjections do + use Ecto.Migration + + def change do + create table(:messaging_federation_read_cursors) do + add :conversation_id, references(:conversations, on_delete: :delete_all), null: false + add :chat_message_id, references(:chat_messages, on_delete: :delete_all), null: false + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all), null: false + add :origin_domain, :string, null: false + add :read_at, :utc_datetime, null: false + add :read_through_sequence, :integer + + timestamps(type: :utc_datetime) + end + + create unique_index( + :messaging_federation_read_cursors, + [:conversation_id, :remote_actor_id], + name: :messaging_federation_read_cursors_unique + ) + + create index( + :messaging_federation_read_cursors, + [:conversation_id, :chat_message_id], + name: :messaging_federation_read_cursors_conversation_message_idx + ) + + create index( + :messaging_federation_read_cursors, + [:remote_actor_id], + name: :messaging_federation_read_cursors_actor_idx + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260308113000_create_messaging_federation_discovered_peers.exs b/apps/elektrine/priv/repo/migrations/20260308113000_create_messaging_federation_discovered_peers.exs new file mode 100644 index 0000000..edb2d63 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260308113000_create_messaging_federation_discovered_peers.exs @@ -0,0 +1,31 @@ +defmodule Elektrine.Repo.Migrations.CreateMessagingFederationDiscoveredPeers do + use Ecto.Migration + + def change do + create table(:messaging_federation_discovered_peers) do + add :domain, :string, null: false + add :base_url, :string, null: false + add :discovery_url, :string, null: false + add :protocol, :string + add :protocol_id, :string + add :protocol_version, :string + add :trust_state, :string, null: false, default: "trusted" + add :identity_fingerprint, :string, null: false + add :previous_identity_fingerprint, :string + add :last_key_change_at, :utc_datetime + add :identity, :map, null: false, default: %{} + add :endpoints, :map, null: false, default: %{} + add :features, :map, null: false, default: %{} + add :last_discovered_at, :utc_datetime, null: false + add :last_error, :text + + timestamps(type: :utc_datetime) + end + + create unique_index( + :messaging_federation_discovered_peers, + [:domain], + name: :messaging_federation_discovered_peers_domain_unique + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260308120000_add_arbp_presence_expiry_and_membership_states.exs b/apps/elektrine/priv/repo/migrations/20260308120000_add_arbp_presence_expiry_and_membership_states.exs new file mode 100644 index 0000000..c7b9047 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260308120000_add_arbp_presence_expiry_and_membership_states.exs @@ -0,0 +1,40 @@ +defmodule Elektrine.Repo.Migrations.AddArbpPresenceExpiryAndMembershipStates do + use Ecto.Migration + + def change do + alter table(:messaging_federation_presence_states) do + add :expires_at_remote, :utc_datetime + end + + create index( + :messaging_federation_presence_states, + [:expires_at_remote], + name: :messaging_federation_presence_states_expires_at_idx + ) + + create table(:messaging_federation_membership_states) do + add :conversation_id, references(:conversations, on_delete: :delete_all), null: false + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all), null: false + add :origin_domain, :string, null: false + add :role, :string, null: false + add :state, :string, null: false + add :joined_at_remote, :utc_datetime + add :updated_at_remote, :utc_datetime, null: false + add :metadata, :map, null: false, default: %{} + + timestamps(type: :utc_datetime) + end + + create unique_index( + :messaging_federation_membership_states, + [:conversation_id, :remote_actor_id], + name: :messaging_federation_membership_states_unique + ) + + create index( + :messaging_federation_membership_states, + [:remote_actor_id], + name: :messaging_federation_membership_states_actor_idx + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260308121000_add_continuity_fields_to_messaging_federation_discovered_peers.exs b/apps/elektrine/priv/repo/migrations/20260308121000_add_continuity_fields_to_messaging_federation_discovered_peers.exs new file mode 100644 index 0000000..dfaf799 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260308121000_add_continuity_fields_to_messaging_federation_discovered_peers.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.AddContinuityFieldsToMessagingFederationDiscoveredPeers do + use Ecto.Migration + + def change do + alter table(:messaging_federation_discovered_peers) do + add_if_not_exists :trust_state, :string, default: "trusted" + add_if_not_exists :identity_fingerprint, :string + add_if_not_exists :previous_identity_fingerprint, :string + add_if_not_exists :last_key_change_at, :utc_datetime + end + + execute( + "UPDATE messaging_federation_discovered_peers SET trust_state = 'trusted' WHERE trust_state IS NULL", + "UPDATE messaging_federation_discovered_peers SET trust_state = NULL WHERE trust_state = 'trusted'" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260308133000_create_email_custom_domains.exs b/apps/elektrine/priv/repo/migrations/20260308133000_create_email_custom_domains.exs new file mode 100644 index 0000000..00e41f3 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260308133000_create_email_custom_domains.exs @@ -0,0 +1,24 @@ +defmodule Elektrine.Repo.Migrations.CreateEmailCustomDomains do + use Ecto.Migration + + def change do + create table(:email_custom_domains) do + add :domain, :string, null: false + add :verification_token, :string, null: false + add :status, :string, null: false, default: "pending" + add :verified_at, :utc_datetime + add :last_checked_at, :utc_datetime + add :last_error, :string + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:email_custom_domains, [:user_id]) + create index(:email_custom_domains, [:status]) + + create unique_index(:email_custom_domains, ["lower(domain)"], + name: :email_custom_domains_domain_ci_unique + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260308173000_add_dkim_fields_to_email_custom_domains.exs b/apps/elektrine/priv/repo/migrations/20260308173000_add_dkim_fields_to_email_custom_domains.exs new file mode 100644 index 0000000..c3310d2 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260308173000_add_dkim_fields_to_email_custom_domains.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.AddDkimFieldsToEmailCustomDomains do + use Ecto.Migration + + def change do + alter table(:email_custom_domains) do + add :dkim_selector, :string + add :dkim_public_key, :text + add :dkim_private_key, :text + add :dkim_synced_at, :utc_datetime + add :dkim_last_error, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260308174500_relax_dkim_constraints_on_email_custom_domains.exs b/apps/elektrine/priv/repo/migrations/20260308174500_relax_dkim_constraints_on_email_custom_domains.exs new file mode 100644 index 0000000..bf4b674 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260308174500_relax_dkim_constraints_on_email_custom_domains.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.RelaxDkimConstraintsOnEmailCustomDomains do + use Ecto.Migration + + def change do + alter table(:email_custom_domains) do + modify :dkim_selector, :string, null: true, default: nil + modify :dkim_public_key, :text, null: true + modify :dkim_private_key, :text, null: true + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260308210000_create_messaging_federation_invite_states.exs b/apps/elektrine/priv/repo/migrations/20260308210000_create_messaging_federation_invite_states.exs new file mode 100644 index 0000000..fb7970a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260308210000_create_messaging_federation_invite_states.exs @@ -0,0 +1,33 @@ +defmodule Elektrine.Repo.Migrations.CreateMessagingFederationInviteStates do + use Ecto.Migration + + def change do + create table(:messaging_federation_invite_states) do + add :conversation_id, references(:conversations, on_delete: :delete_all), null: false + add :origin_domain, :string, null: false + add :actor_uri, :string, null: false + add :actor_payload, :map, null: false, default: %{} + add :target_uri, :string, null: false + add :target_payload, :map, null: false, default: %{} + add :role, :string, null: false + add :state, :string, null: false + add :invited_at_remote, :utc_datetime + add :updated_at_remote, :utc_datetime, null: false + add :metadata, :map, null: false, default: %{} + + timestamps(type: :utc_datetime) + end + + create unique_index( + :messaging_federation_invite_states, + [:conversation_id, :target_uri], + name: :messaging_federation_invite_states_unique + ) + + create index( + :messaging_federation_invite_states, + [:origin_domain], + name: :messaging_federation_invite_states_origin_idx + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260309101500_scope_messaging_federation_event_ids_by_origin.exs b/apps/elektrine/priv/repo/migrations/20260309101500_scope_messaging_federation_event_ids_by_origin.exs new file mode 100644 index 0000000..3beac5a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260309101500_scope_messaging_federation_event_ids_by_origin.exs @@ -0,0 +1,25 @@ +defmodule Elektrine.Repo.Migrations.ScopeMessagingFederationEventIdsByOrigin do + use Ecto.Migration + + def change do + drop_if_exists(index(:messaging_federation_events, [:event_id])) + + create( + unique_index(:messaging_federation_events, [:origin_domain, :event_id], + name: :messaging_federation_events_origin_event_id_unique + ) + ) + + drop_if_exists( + index(:messaging_federation_events_archive, [:event_id], + name: :messaging_federation_events_archive_event_id_unique + ) + ) + + create( + unique_index(:messaging_federation_events_archive, [:origin_domain, :event_id], + name: :messaging_federation_events_archive_origin_event_id_unique + ) + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260310120000_hash_recovery_tokens.exs b/apps/elektrine/priv/repo/migrations/20260310120000_hash_recovery_tokens.exs new file mode 100644 index 0000000..78c381e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260310120000_hash_recovery_tokens.exs @@ -0,0 +1,62 @@ +defmodule Elektrine.Repo.Migrations.HashRecoveryTokens do + use Ecto.Migration + import Ecto.Query + + defmodule MigrationUser do + use Ecto.Schema + + @primary_key {:id, :id, autogenerate: false} + + schema "users" do + field(:password_reset_token, :string) + field(:recovery_email_verification_token, :string) + end + end + + def up do + repo() + |> all_token_rows() + |> Enum.each(fn user -> + updates = + %{} + |> maybe_put_hashed_token(:password_reset_token, user.password_reset_token) + |> maybe_put_hashed_token( + :recovery_email_verification_token, + user.recovery_email_verification_token + ) + + if updates != %{} do + from(u in MigrationUser, where: u.id == ^user.id) + |> repo().update_all(set: Map.to_list(updates)) + end + end) + end + + def down do + raise Ecto.MigrationError, "HashRecoveryTokens is irreversible" + end + + defp all_token_rows(repo) do + repo.all( + from(u in MigrationUser, + where: + not is_nil(u.password_reset_token) or not is_nil(u.recovery_email_verification_token), + select: %{ + id: u.id, + password_reset_token: u.password_reset_token, + recovery_email_verification_token: u.recovery_email_verification_token + } + ) + ) + end + + defp maybe_put_hashed_token(updates, _field, nil), do: updates + + defp maybe_put_hashed_token(updates, field, value) when is_binary(value) do + Map.put(updates, field, hash_token(value)) + end + + defp hash_token(token) do + :crypto.hash(:sha256, token) |> Base.encode16(case: :lower) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260310153000_add_private_mailbox_storage.exs b/apps/elektrine/priv/repo/migrations/20260310153000_add_private_mailbox_storage.exs new file mode 100644 index 0000000..68231c4 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260310153000_add_private_mailbox_storage.exs @@ -0,0 +1,16 @@ +defmodule Elektrine.Repo.Migrations.AddPrivateMailboxStorage do + use Ecto.Migration + + def change do + alter table(:mailboxes) do + add :private_storage_enabled, :boolean, default: false, null: false + add :private_storage_public_key, :text + add :private_storage_wrapped_private_key, :map + add :private_storage_verifier, :map + end + + alter table(:email_messages) do + add :client_encrypted_payload, :map + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260310170000_add_endpoint_host_and_client_mtu_to_vpn_servers.exs b/apps/elektrine/priv/repo/migrations/20260310170000_add_endpoint_host_and_client_mtu_to_vpn_servers.exs new file mode 100644 index 0000000..4e9bc76 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260310170000_add_endpoint_host_and_client_mtu_to_vpn_servers.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddEndpointHostAndClientMtuToVpnServers do + use Ecto.Migration + + def change do + alter table(:vpn_servers) do + add :endpoint_host, :string + add :client_mtu, :integer, default: 1280 + modify :endpoint_port, :integer, null: false, default: 443 + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260311103000_add_canonical_activitypub_refs_to_messages.exs b/apps/elektrine/priv/repo/migrations/20260311103000_add_canonical_activitypub_refs_to_messages.exs new file mode 100644 index 0000000..ac1beda --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260311103000_add_canonical_activitypub_refs_to_messages.exs @@ -0,0 +1,54 @@ +defmodule Elektrine.Repo.Migrations.AddCanonicalActivitypubRefsToMessages do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + alter table(:messages) do + add_if_not_exists :activitypub_id_canonical, :text + add_if_not_exists :activitypub_url_canonical, :text + end + + flush() + + execute(""" + UPDATE messages + SET activitypub_id_canonical = + NULLIF(trim(trailing '/' from split_part(split_part(btrim(activitypub_id), '#', 1), '?', 1)), ''), + activitypub_url_canonical = + NULLIF(trim(trailing '/' from split_part(split_part(btrim(activitypub_url), '#', 1), '?', 1)), '') + WHERE activitypub_id IS NOT NULL + OR activitypub_url IS NOT NULL + """) + + create_if_not_exists index(:messages, [:activitypub_id_canonical], + concurrently: true, + where: "activitypub_id_canonical IS NOT NULL", + name: :messages_activitypub_id_canonical_idx + ) + + create_if_not_exists index(:messages, [:activitypub_url_canonical], + concurrently: true, + where: "activitypub_url_canonical IS NOT NULL", + name: :messages_activitypub_url_canonical_idx + ) + end + + def down do + drop_if_exists index(:messages, [:activitypub_id_canonical], + concurrently: true, + name: :messages_activitypub_id_canonical_idx + ) + + drop_if_exists index(:messages, [:activitypub_url_canonical], + concurrently: true, + name: :messages_activitypub_url_canonical_idx + ) + + alter table(:messages) do + remove :activitypub_id_canonical + remove :activitypub_url_canonical + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260311112000_backfill_messages_canonical_activitypub_refs.exs b/apps/elektrine/priv/repo/migrations/20260311112000_backfill_messages_canonical_activitypub_refs.exs new file mode 100644 index 0000000..889a460 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260311112000_backfill_messages_canonical_activitypub_refs.exs @@ -0,0 +1,25 @@ +defmodule Elektrine.Repo.Migrations.BackfillMessagesCanonicalActivitypubRefs do + use Ecto.Migration + + def up do + execute(""" + UPDATE messages + SET activitypub_id_canonical = + COALESCE( + activitypub_id_canonical, + NULLIF(trim(trailing '/' from split_part(split_part(btrim(activitypub_id), '#', 1), '?', 1)), '') + ), + activitypub_url_canonical = + COALESCE( + activitypub_url_canonical, + NULLIF(trim(trailing '/' from split_part(split_part(btrim(activitypub_url), '#', 1), '?', 1)), '') + ) + WHERE (activitypub_id IS NOT NULL AND activitypub_id_canonical IS NULL) + OR (activitypub_url IS NOT NULL AND activitypub_url_canonical IS NULL) + """) + end + + def down do + :ok + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260314120000_create_jmap_email_tombstones.exs b/apps/elektrine/priv/repo/migrations/20260314120000_create_jmap_email_tombstones.exs new file mode 100644 index 0000000..80cfd42 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260314120000_create_jmap_email_tombstones.exs @@ -0,0 +1,15 @@ +defmodule Elektrine.Repo.Migrations.CreateJmapEmailTombstones do + use Ecto.Migration + + def change do + create table(:jmap_email_tombstones) do + add :mailbox_id, references(:mailboxes, on_delete: :delete_all), null: false + add :email_id, :bigint, null: false + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:jmap_email_tombstones, [:mailbox_id, :inserted_at]) + create index(:jmap_email_tombstones, [:mailbox_id, :email_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260314133000_create_jmap_email_changes.exs b/apps/elektrine/priv/repo/migrations/20260314133000_create_jmap_email_changes.exs new file mode 100644 index 0000000..e7c6e2e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260314133000_create_jmap_email_changes.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.CreateJmapEmailChanges do + use Ecto.Migration + + def change do + create table(:jmap_email_changes) do + add :mailbox_id, references(:mailboxes, on_delete: :delete_all), null: false + add :email_id, :bigint, null: false + add :change_type, :string, null: false + add :state_counter, :bigint, null: false + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:jmap_email_changes, [:mailbox_id, :state_counter]) + create index(:jmap_email_changes, [:mailbox_id, :email_id, :state_counter]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260314153000_normalize_invite_codes_case_insensitively.exs b/apps/elektrine/priv/repo/migrations/20260314153000_normalize_invite_codes_case_insensitively.exs new file mode 100644 index 0000000..f32c637 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260314153000_normalize_invite_codes_case_insensitively.exs @@ -0,0 +1,38 @@ +defmodule Elektrine.Repo.Migrations.NormalizeInviteCodesCaseInsensitively do + use Ecto.Migration + + def up do + execute(""" + DO $$ + DECLARE duplicate_codes text; + BEGIN + SELECT string_agg(normalized_code, ', ' ORDER BY normalized_code) + INTO duplicate_codes + FROM ( + SELECT upper(btrim(code)) AS normalized_code + FROM invite_codes + GROUP BY 1 + HAVING count(*) > 1 + ) duplicates; + + IF duplicate_codes IS NOT NULL THEN + RAISE EXCEPTION + 'Cannot normalize invite codes because duplicate case-insensitive values already exist: %', + duplicate_codes; + END IF; + END + $$; + """) + + execute("UPDATE invite_codes SET code = upper(btrim(code))") + + drop_if_exists(index(:invite_codes, [:code])) + + create(unique_index(:invite_codes, ["upper(code)"], name: :invite_codes_code_upper_unique)) + end + + def down do + drop_if_exists(index(:invite_codes, ["upper(code)"], name: :invite_codes_code_upper_unique)) + create(unique_index(:invite_codes, [:code])) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260314170000_add_stripe_customer_id_to_users.exs b/apps/elektrine/priv/repo/migrations/20260314170000_add_stripe_customer_id_to_users.exs new file mode 100644 index 0000000..2713e6b --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260314170000_add_stripe_customer_id_to_users.exs @@ -0,0 +1,34 @@ +defmodule Elektrine.Repo.Migrations.AddStripeCustomerIdToUsers do + use Ecto.Migration + + def up do + alter table(:users) do + add :stripe_customer_id, :string + end + + execute(""" + UPDATE users + SET stripe_customer_id = source.stripe_customer_id + FROM ( + SELECT user_id, MAX(stripe_customer_id) AS stripe_customer_id + FROM subscriptions + WHERE stripe_customer_id IS NOT NULL + GROUP BY user_id + HAVING COUNT(DISTINCT stripe_customer_id) = 1 + ) AS source + WHERE users.id = source.user_id + """) + + create unique_index(:users, [:stripe_customer_id], where: "stripe_customer_id IS NOT NULL") + end + + def down do + drop_if_exists unique_index(:users, [:stripe_customer_id], + where: "stripe_customer_id IS NOT NULL" + ) + + alter table(:users) do + remove :stripe_customer_id + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260314193000_add_one_time_support_to_subscription_products.exs b/apps/elektrine/priv/repo/migrations/20260314193000_add_one_time_support_to_subscription_products.exs new file mode 100644 index 0000000..b22cd55 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260314193000_add_one_time_support_to_subscription_products.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.AddOneTimeSupportToSubscriptionProducts do + use Ecto.Migration + + def change do + alter table(:subscription_products) do + add :billing_type, :string, null: false, default: "recurring" + add :stripe_one_time_price_id, :string + add :one_time_price_cents, :integer + end + + create index(:subscription_products, [:billing_type]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260314195000_create_registration_checkouts.exs b/apps/elektrine/priv/repo/migrations/20260314195000_create_registration_checkouts.exs new file mode 100644 index 0000000..0a51dc6 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260314195000_create_registration_checkouts.exs @@ -0,0 +1,24 @@ +defmodule Elektrine.Repo.Migrations.CreateRegistrationCheckouts do + use Ecto.Migration + + def change do + create table(:registration_checkouts) do + add :stripe_checkout_session_id, :string, null: false + add :lookup_token, :string, null: false + add :product_slug, :string, null: false + add :stripe_customer_id, :string + add :stripe_payment_intent_id, :string + add :customer_email, :string + add :status, :string, null: false, default: "pending" + add :fulfilled_at, :utc_datetime + add :invite_code_id, references(:invite_codes, on_delete: :nilify_all) + + timestamps(type: :utc_datetime) + end + + create unique_index(:registration_checkouts, [:stripe_checkout_session_id]) + create unique_index(:registration_checkouts, [:lookup_token]) + create index(:registration_checkouts, [:product_slug]) + create index(:registration_checkouts, [:status]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260314203000_normalize_post_views_for_trust_tracking.exs b/apps/elektrine/priv/repo/migrations/20260314203000_normalize_post_views_for_trust_tracking.exs new file mode 100644 index 0000000..151787e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260314203000_normalize_post_views_for_trust_tracking.exs @@ -0,0 +1,65 @@ +defmodule Elektrine.Repo.Migrations.NormalizePostViewsForTrustTracking do + use Ecto.Migration + + def up do + execute(""" + WITH duplicates AS ( + SELECT + MIN(id) AS keep_id, + user_id, + message_id, + MAX(view_duration_seconds) AS max_view_duration_seconds, + BOOL_OR(COALESCE(completed, FALSE)) AS any_completed, + SUM(COALESCE(dwell_time_ms, 0)) AS total_dwell_time_ms, + MAX(COALESCE(scroll_depth, 0)) AS max_scroll_depth, + BOOL_OR(COALESCE(expanded, FALSE)) AS any_expanded, + MAX(source) FILTER (WHERE source IS NOT NULL AND btrim(source) <> '') AS chosen_source + FROM post_views + GROUP BY user_id, message_id + HAVING COUNT(*) > 1 + ) + UPDATE post_views AS pv + SET + view_duration_seconds = duplicates.max_view_duration_seconds, + completed = duplicates.any_completed, + dwell_time_ms = CASE + WHEN duplicates.total_dwell_time_ms > 0 THEN duplicates.total_dwell_time_ms + ELSE pv.dwell_time_ms + END, + scroll_depth = CASE + WHEN duplicates.max_scroll_depth > 0 THEN duplicates.max_scroll_depth + ELSE pv.scroll_depth + END, + expanded = duplicates.any_expanded, + source = COALESCE(duplicates.chosen_source, pv.source) + FROM duplicates + WHERE pv.id = duplicates.keep_id + """) + + execute(""" + WITH duplicates AS ( + SELECT + MIN(id) AS keep_id, + user_id, + message_id + FROM post_views + GROUP BY user_id, message_id + HAVING COUNT(*) > 1 + ) + DELETE FROM post_views AS pv + USING duplicates + WHERE + pv.user_id = duplicates.user_id AND + pv.message_id = duplicates.message_id AND + pv.id <> duplicates.keep_id + """) + + drop_if_exists index(:post_views, [:user_id, :message_id]) + create unique_index(:post_views, [:user_id, :message_id]) + end + + def down do + drop_if_exists unique_index(:post_views, [:user_id, :message_id]) + create index(:post_views, [:user_id, :message_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260315100000_create_messaging_federation_account_presence_states.exs b/apps/elektrine/priv/repo/migrations/20260315100000_create_messaging_federation_account_presence_states.exs new file mode 100644 index 0000000..f32e258 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260315100000_create_messaging_federation_account_presence_states.exs @@ -0,0 +1,34 @@ +defmodule Elektrine.Repo.Migrations.CreateMessagingFederationAccountPresenceStates do + use Ecto.Migration + + def change do + create table(:messaging_federation_account_presence_states) do + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all), null: false + add :origin_domain, :string, null: false + add :status, :string, null: false + add :activities, :map, null: false, default: %{} + add :updated_at_remote, :utc_datetime, null: false + add :expires_at_remote, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index( + :messaging_federation_account_presence_states, + [:remote_actor_id], + name: :messaging_federation_account_presence_states_unique + ) + + create index( + :messaging_federation_account_presence_states, + [:expires_at_remote], + name: :messaging_federation_account_presence_states_expires_at_idx + ) + + create index( + :messaging_federation_account_presence_states, + [:updated_at_remote], + name: :messaging_federation_account_presence_states_updated_at_remote_idx + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260315113000_add_federated_fields_to_community_bans.exs b/apps/elektrine/priv/repo/migrations/20260315113000_add_federated_fields_to_community_bans.exs new file mode 100644 index 0000000..2528e01 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260315113000_add_federated_fields_to_community_bans.exs @@ -0,0 +1,20 @@ +defmodule Elektrine.Repo.Migrations.AddFederatedFieldsToCommunityBans do + use Ecto.Migration + + def change do + execute( + "ALTER TABLE community_bans ALTER COLUMN banned_by_id DROP NOT NULL", + "ALTER TABLE community_bans ALTER COLUMN banned_by_id SET NOT NULL" + ) + + alter table(:community_bans) do + add :origin_domain, :string + add :actor_payload, :map, default: %{}, null: false + add :metadata, :map, default: %{}, null: false + add :banned_at_remote, :utc_datetime + add :updated_at_remote, :utc_datetime + end + + create index(:community_bans, [:origin_domain]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260315133000_create_messaging_federation_room_presence_states.exs b/apps/elektrine/priv/repo/migrations/20260315133000_create_messaging_federation_room_presence_states.exs new file mode 100644 index 0000000..05503fe --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260315133000_create_messaging_federation_room_presence_states.exs @@ -0,0 +1,35 @@ +defmodule Elektrine.Repo.Migrations.CreateMessagingFederationRoomPresenceStates do + use Ecto.Migration + + def change do + create table(:messaging_federation_room_presence_states) do + add :conversation_id, references(:conversations, on_delete: :delete_all), null: false + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all), null: false + add :origin_domain, :string, null: false + add :status, :string, null: false + add :activities, :map, null: false, default: %{} + add :updated_at_remote, :utc_datetime, null: false + add :expires_at_remote, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index( + :messaging_federation_room_presence_states, + [:conversation_id, :remote_actor_id], + name: :messaging_federation_room_presence_states_unique + ) + + create index( + :messaging_federation_room_presence_states, + [:conversation_id, :expires_at_remote], + name: :messaging_federation_room_presence_states_conversation_expires_at_idx + ) + + create index( + :messaging_federation_room_presence_states, + [:updated_at_remote], + name: :messaging_federation_room_presence_states_updated_at_remote_idx + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260316091500_create_messaging_federation_call_sessions.exs b/apps/elektrine/priv/repo/migrations/20260316091500_create_messaging_federation_call_sessions.exs new file mode 100644 index 0000000..089136b --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260316091500_create_messaging_federation_call_sessions.exs @@ -0,0 +1,41 @@ +defmodule Elektrine.Repo.Migrations.CreateMessagingFederationCallSessions do + use Ecto.Migration + + def change do + create table(:messaging_federation_call_sessions) do + add :conversation_id, references(:conversations, on_delete: :delete_all), null: false + add :local_user_id, references(:users, on_delete: :delete_all), null: false + add :federated_call_id, :string, null: false + add :origin_domain, :string, null: false + add :remote_domain, :string, null: false + add :remote_handle, :string, null: false + add :remote_actor, :map, null: false, default: %{} + add :call_type, :string, null: false + add :direction, :string, null: false + add :status, :string, null: false + add :metadata, :map, null: false, default: %{} + add :started_at_remote, :utc_datetime + add :ended_at_remote, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index( + :messaging_federation_call_sessions, + [:local_user_id, :federated_call_id], + name: :messaging_federation_call_sessions_user_call_unique + ) + + create index( + :messaging_federation_call_sessions, + [:conversation_id, :inserted_at], + name: :messaging_federation_call_sessions_conversation_inserted_at_idx + ) + + create index( + :messaging_federation_call_sessions, + [:remote_domain, :status], + name: :messaging_federation_call_sessions_remote_domain_status_idx + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260316143000_create_profile_custom_domains.exs b/apps/elektrine/priv/repo/migrations/20260316143000_create_profile_custom_domains.exs new file mode 100644 index 0000000..0d45321 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260316143000_create_profile_custom_domains.exs @@ -0,0 +1,24 @@ +defmodule Elektrine.Repo.Migrations.CreateProfileCustomDomains do + use Ecto.Migration + + def change do + create table(:profile_custom_domains) do + add :domain, :string, null: false + add :verification_token, :string, null: false + add :status, :string, null: false, default: "pending" + add :verified_at, :utc_datetime + add :last_checked_at, :utc_datetime + add :last_error, :string + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:profile_custom_domains, [:user_id]) + create index(:profile_custom_domains, [:status]) + + create unique_index(:profile_custom_domains, ["lower(domain)"], + name: :profile_custom_domains_domain_ci_unique + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260320074000_add_db_hot_path_indexes.exs b/apps/elektrine/priv/repo/migrations/20260320074000_add_db_hot_path_indexes.exs new file mode 100644 index 0000000..a03d19a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260320074000_add_db_hot_path_indexes.exs @@ -0,0 +1,62 @@ +defmodule Elektrine.Repo.Migrations.AddDbHotPathIndexes do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS email_messages_inbox_unread_folderless_inserted_idx + ON email_messages (mailbox_id, inserted_at DESC) + WHERE read = FALSE + AND spam = FALSE + AND archived = FALSE + AND deleted = FALSE + AND reply_later_at IS NULL + AND folder_id IS NULL + AND (category IS NULL OR category NOT IN ('feed', 'ledger', 'stack')) + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS email_messages_folder_mailbox_active_inserted_idx + ON email_messages (folder_id, mailbox_id, inserted_at DESC) + WHERE deleted = FALSE + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS oban_jobs_email_inbound_idempotency_key_idx + ON oban_jobs (worker, queue, ((args->>'idempotency_key')), state) + WHERE queue = 'email_inbound' + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS activitypub_activities_pending_remote_inserted_idx + ON activitypub_activities (inserted_at, id) + WHERE processed = FALSE + AND local = FALSE + AND process_attempts < 2 + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS activitypub_deliveries_pending_retry_updated_idx + ON activitypub_deliveries (next_retry_at, updated_at, id) + WHERE status = 'pending' + AND attempts < 10 + """) + end + + def down do + execute( + "DROP INDEX CONCURRENTLY IF EXISTS email_messages_inbox_unread_folderless_inserted_idx" + ) + + execute("DROP INDEX CONCURRENTLY IF EXISTS email_messages_folder_mailbox_active_inserted_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS oban_jobs_email_inbound_idempotency_key_idx") + + execute( + "DROP INDEX CONCURRENTLY IF EXISTS activitypub_activities_pending_remote_inserted_idx" + ) + + execute("DROP INDEX CONCURRENTLY IF EXISTS activitypub_deliveries_pending_retry_updated_idx") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260320100000_add_redemption_fields_to_registration_checkouts.exs b/apps/elektrine/priv/repo/migrations/20260320100000_add_redemption_fields_to_registration_checkouts.exs new file mode 100644 index 0000000..9986232 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260320100000_add_redemption_fields_to_registration_checkouts.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.AddRedemptionFieldsToRegistrationCheckouts do + use Ecto.Migration + + def change do + alter table(:registration_checkouts) do + add :redeemed_at, :utc_datetime + add :redeemed_by_user_id, references(:users, on_delete: :nilify_all) + end + + create index(:registration_checkouts, [:redeemed_by_user_id]) + create index(:registration_checkouts, [:redeemed_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260322194500_create_dns_zones_and_records.exs b/apps/elektrine/priv/repo/migrations/20260322194500_create_dns_zones_and_records.exs new file mode 100644 index 0000000..c9bd4e6 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260322194500_create_dns_zones_and_records.exs @@ -0,0 +1,49 @@ +defmodule Elektrine.Repo.Migrations.CreateDnsZonesAndRecords do + use Ecto.Migration + + def change do + create table(:dns_zones) do + add :domain, :string, null: false + add :status, :string, null: false, default: "provisioning" + add :kind, :string, null: false, default: "native" + add :serial, :bigint, null: false, default: 1 + add :default_ttl, :integer, null: false, default: 300 + add :verification_method, :string, null: false, default: "nameserver" + add :verification_token, :string, null: false + add :verified_at, :utc_datetime + add :last_checked_at, :utc_datetime + add :last_published_at, :utc_datetime + add :last_error, :string + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:dns_zones, [:user_id]) + create index(:dns_zones, [:status]) + + create unique_index(:dns_zones, ["lower(domain)"], name: :dns_zones_domain_ci_unique) + + create table(:dns_records) do + add :name, :string, null: false + add :type, :string, null: false + add :ttl, :integer, null: false, default: 300 + add :content, :text, null: false + add :priority, :integer + add :weight, :integer + add :port, :integer + add :flags, :integer + add :tag, :string + add :zone_id, references(:dns_zones, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:dns_records, [:zone_id]) + create index(:dns_records, [:type]) + + create unique_index(:dns_records, [:zone_id, :name, :type, :content], + name: :dns_records_identity_unique + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260322200000_add_dns_zone_soa_fields.exs b/apps/elektrine/priv/repo/migrations/20260322200000_add_dns_zone_soa_fields.exs new file mode 100644 index 0000000..c0cee59 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260322200000_add_dns_zone_soa_fields.exs @@ -0,0 +1,14 @@ +defmodule Elektrine.Repo.Migrations.AddDnsZoneSoaFields do + use Ecto.Migration + + def change do + alter table(:dns_zones) do + add :soa_mname, :string + add :soa_rname, :string + add :soa_refresh, :integer, null: false, default: 3600 + add :soa_retry, :integer, null: false, default: 600 + add :soa_expire, :integer, null: false, default: 1_209_600 + add :soa_minimum, :integer, null: false, default: 300 + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260322203000_remove_dns_verification_fields.exs b/apps/elektrine/priv/repo/migrations/20260322203000_remove_dns_verification_fields.exs new file mode 100644 index 0000000..e2a0072 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260322203000_remove_dns_verification_fields.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.RemoveDnsVerificationFields do + use Ecto.Migration + + def change do + alter table(:dns_zones) do + remove :verification_method, :string + remove :verification_token, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260323033500_dedupe_activitypub_instances_and_enforce_ci_uniqueness.exs b/apps/elektrine/priv/repo/migrations/20260323033500_dedupe_activitypub_instances_and_enforce_ci_uniqueness.exs new file mode 100644 index 0000000..6bb06ba --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260323033500_dedupe_activitypub_instances_and_enforce_ci_uniqueness.exs @@ -0,0 +1,49 @@ +defmodule Elektrine.Repo.Migrations.DedupeActivitypubInstancesAndEnforceCiUniqueness do + use Ecto.Migration + + def up do + execute(""" + DELETE FROM activitypub_instances dup + USING activitypub_instances keep + WHERE lower(dup.domain) = lower(keep.domain) + AND dup.id <> keep.id + AND ( + COALESCE(dup.metadata_updated_at, dup.updated_at, dup.inserted_at, NOW()), + COALESCE(dup.updated_at, dup.inserted_at, NOW()), + dup.id + ) < ( + COALESCE(keep.metadata_updated_at, keep.updated_at, keep.inserted_at, NOW()), + COALESCE(keep.updated_at, keep.inserted_at, NOW()), + keep.id + ) + """) + + execute(""" + DELETE FROM activitypub_instances + WHERE id IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY lower(domain) + ORDER BY + COALESCE(metadata_updated_at, updated_at, inserted_at, NOW()) DESC, + COALESCE(updated_at, inserted_at, NOW()) DESC, + id DESC + ) AS row_num + FROM activitypub_instances + ) ranked + WHERE ranked.row_num > 1 + ) + """) + + create_if_not_exists unique_index(:activitypub_instances, ["lower(domain)"], + name: :activitypub_instances_domain_ci_unique + ) + end + + def down do + drop_if_exists index(:activitypub_instances, ["lower(domain)"], + name: :activitypub_instances_domain_ci_unique + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260323120000_add_oidc_fields_to_oauth_authorizations.exs b/apps/elektrine/priv/repo/migrations/20260323120000_add_oidc_fields_to_oauth_authorizations.exs new file mode 100644 index 0000000..22ce6f1 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260323120000_add_oidc_fields_to_oauth_authorizations.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.AddOidcFieldsToOauthAuthorizations do + use Ecto.Migration + + def change do + alter table(:oauth_authorizations) do + add :redirect_uri, :text + add :state, :text + add :nonce, :text + add :code_challenge, :text + add :code_challenge_method, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260323123000_create_oidc_signing_keys.exs b/apps/elektrine/priv/repo/migrations/20260323123000_create_oidc_signing_keys.exs new file mode 100644 index 0000000..8ebef61 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260323123000_create_oidc_signing_keys.exs @@ -0,0 +1,18 @@ +defmodule Elektrine.Repo.Migrations.CreateOidcSigningKeys do + use Ecto.Migration + + def change do + create table(:oidc_signing_keys) do + add :kid, :string, null: false + add :alg, :string, null: false, default: "RS256" + add :public_key_pem, :text, null: false + add :private_key_pem, :text, null: false + add :active, :boolean, null: false, default: true + + timestamps(type: :utc_datetime) + end + + create unique_index(:oidc_signing_keys, [:kid]) + create index(:oidc_signing_keys, [:active]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260323124000_add_oidc_fields_to_oauth_tokens.exs b/apps/elektrine/priv/repo/migrations/20260323124000_add_oidc_fields_to_oauth_tokens.exs new file mode 100644 index 0000000..113e661 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260323124000_add_oidc_fields_to_oauth_tokens.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.AddOidcFieldsToOauthTokens do + use Ecto.Migration + + def change do + alter table(:oauth_tokens) do + add :oidc_nonce, :text + add :oidc_auth_time, :utc_datetime + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260326143000_add_admin_email_message_indexes.exs b/apps/elektrine/priv/repo/migrations/20260326143000_add_admin_email_message_indexes.exs new file mode 100644 index 0000000..2f8b1d2 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260326143000_add_admin_email_message_indexes.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.AddAdminEmailMessageIndexes do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS email_messages_status_inserted_at_idx + ON email_messages (status, inserted_at DESC) + """) + end + + def down do + execute("DROP INDEX CONCURRENTLY IF EXISTS email_messages_status_inserted_at_idx") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260327121000_add_managed_dns_services.exs b/apps/elektrine/priv/repo/migrations/20260327121000_add_managed_dns_services.exs new file mode 100644 index 0000000..838c621 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260327121000_add_managed_dns_services.exs @@ -0,0 +1,40 @@ +defmodule Elektrine.Repo.Migrations.AddManagedDnsServices do + use Ecto.Migration + + def change do + alter table(:dns_records) do + add :source, :string, null: false, default: "user" + add :service, :string + add :managed, :boolean, null: false, default: false + add :managed_key, :string + add :required, :boolean, null: false, default: false + add :metadata, :map, null: false, default: %{} + end + + create index(:dns_records, [:zone_id, :service]) + + create unique_index(:dns_records, [:zone_id, :managed_key], + where: "managed_key IS NOT NULL", + name: :dns_records_zone_managed_key_unique + ) + + create table(:dns_zone_service_configs) do + add :service, :string, null: false + add :enabled, :boolean, null: false, default: true + add :mode, :string, null: false, default: "managed" + add :status, :string, null: false, default: "pending" + add :settings, :map, null: false, default: %{} + add :last_applied_at, :utc_datetime + add :last_error, :string + add :zone_id, references(:dns_zones, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:dns_zone_service_configs, [:zone_id]) + + create unique_index(:dns_zone_service_configs, [:zone_id, :service], + name: :dns_zone_service_configs_zone_service_unique + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260329173000_add_dnssec_and_mail_hardening_fields_to_dns_records.exs b/apps/elektrine/priv/repo/migrations/20260329173000_add_dnssec_and_mail_hardening_fields_to_dns_records.exs new file mode 100644 index 0000000..702b6cd --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260329173000_add_dnssec_and_mail_hardening_fields_to_dns_records.exs @@ -0,0 +1,15 @@ +defmodule Elektrine.Repo.Migrations.AddDnssecAndMailHardeningFieldsToDnsRecords do + use Ecto.Migration + + def change do + alter table(:dns_records) do + add :protocol, :integer + add :algorithm, :integer + add :key_tag, :integer + add :digest_type, :integer + add :usage, :integer + add :selector, :integer + add :matching_type, :integer + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260331110000_create_personal_files.exs b/apps/elektrine/priv/repo/migrations/20260331110000_create_personal_files.exs new file mode 100644 index 0000000..7fdf06c --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260331110000_create_personal_files.exs @@ -0,0 +1,33 @@ +defmodule Elektrine.Repo.Migrations.CreatePersonalFiles do + use Ecto.Migration + + def change do + create table(:stored_files) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :path, :string, null: false + add :storage_key, :string, null: false + add :original_filename, :string, null: false + add :content_type, :string, null: false + add :size, :bigint, null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:stored_files, [:user_id, :path]) + create index(:stored_files, [:user_id]) + + create table(:file_shares) do + add :stored_file_id, references(:stored_files, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + add :token, :string, null: false + add :revoked_at, :utc_datetime + add :download_count, :integer, null: false, default: 0 + + timestamps(type: :utc_datetime) + end + + create unique_index(:file_shares, [:token]) + create index(:file_shares, [:stored_file_id]) + create index(:file_shares, [:user_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260331123000_add_expires_at_to_file_shares.exs b/apps/elektrine/priv/repo/migrations/20260331123000_add_expires_at_to_file_shares.exs new file mode 100644 index 0000000..b460a5f --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260331123000_add_expires_at_to_file_shares.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddExpiresAtToFileShares do + use Ecto.Migration + + def change do + alter table(:file_shares) do + add :expires_at, :utc_datetime + end + + create index(:file_shares, [:expires_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260331150000_create_stored_folders.exs b/apps/elektrine/priv/repo/migrations/20260331150000_create_stored_folders.exs new file mode 100644 index 0000000..a5bfda0 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260331150000_create_stored_folders.exs @@ -0,0 +1,15 @@ +defmodule Elektrine.Repo.Migrations.CreateStoredFolders do + use Ecto.Migration + + def change do + create table(:stored_folders) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :path, :string, null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:stored_folders, [:user_id, :path]) + create index(:stored_folders, [:user_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260331151000_add_access_and_password_to_file_shares.exs b/apps/elektrine/priv/repo/migrations/20260331151000_add_access_and_password_to_file_shares.exs new file mode 100644 index 0000000..730a36a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260331151000_add_access_and_password_to_file_shares.exs @@ -0,0 +1,12 @@ +defmodule Elektrine.Repo.Migrations.AddAccessAndPasswordToFileShares do + use Ecto.Migration + + def change do + alter table(:file_shares) do + add :access_level, :string, null: false, default: "download" + add :password_hash, :text + end + + create index(:file_shares, [:access_level]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260402110000_add_theme_overrides_to_users.exs b/apps/elektrine/priv/repo/migrations/20260402110000_add_theme_overrides_to_users.exs new file mode 100644 index 0000000..c6184f1 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260402110000_add_theme_overrides_to_users.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddThemeOverridesToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :theme_overrides, :map, null: false, default: %{} + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260404121000_hash_oauth_secrets_and_tokens.exs b/apps/elektrine/priv/repo/migrations/20260404121000_hash_oauth_secrets_and_tokens.exs new file mode 100644 index 0000000..cb26d92 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260404121000_hash_oauth_secrets_and_tokens.exs @@ -0,0 +1,68 @@ +defmodule Elektrine.Repo.Migrations.HashOauthSecretsAndTokens do + use Ecto.Migration + + import Ecto.Query + + defmodule OAuthApp do + use Ecto.Schema + + schema "oauth_apps" do + field(:client_secret, :string) + end + end + + defmodule OAuthToken do + use Ecto.Schema + + schema "oauth_tokens" do + field(:token, :string) + field(:refresh_token, :string) + end + end + + defmodule OAuthAuthorization do + use Ecto.Schema + + schema "oauth_authorizations" do + field(:token, :string) + end + end + + def up do + flush() + + repo().transaction(fn -> + repo().all(OAuthApp) + |> Enum.each(fn app -> + repo().update_all( + from(a in OAuthApp, where: a.id == ^app.id), + set: [client_secret: hash_secret(app.client_secret)] + ) + end) + + repo().all(OAuthToken) + |> Enum.each(fn token -> + repo().update_all( + from(t in OAuthToken, where: t.id == ^token.id), + set: [token: hash_secret(token.token), refresh_token: hash_secret(token.refresh_token)] + ) + end) + + repo().all(OAuthAuthorization) + |> Enum.each(fn authorization -> + repo().update_all( + from(a in OAuthAuthorization, where: a.id == ^authorization.id), + set: [token: hash_secret(authorization.token)] + ) + end) + end) + end + + def down, do: :ok + + defp hash_secret(secret) when is_binary(secret) do + :crypto.hash(:sha256, secret) |> Base.encode16(case: :lower) + end + + defp hash_secret(_), do: nil +end diff --git a/apps/elektrine/priv/repo/migrations/20260404133000_add_auth_valid_after_to_users.exs b/apps/elektrine/priv/repo/migrations/20260404133000_add_auth_valid_after_to_users.exs new file mode 100644 index 0000000..d91bbc6 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260404133000_add_auth_valid_after_to_users.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddAuthValidAfterToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :auth_valid_after, :utc_datetime + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260404143000_hash_export_and_registration_tokens.exs b/apps/elektrine/priv/repo/migrations/20260404143000_hash_export_and_registration_tokens.exs new file mode 100644 index 0000000..e3ecf12 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260404143000_hash_export_and_registration_tokens.exs @@ -0,0 +1,51 @@ +defmodule Elektrine.Repo.Migrations.HashExportAndRegistrationTokens do + use Ecto.Migration + + import Ecto.Query + + defmodule DataExport do + use Ecto.Schema + + schema "data_exports" do + field(:download_token, :string) + end + end + + defmodule RegistrationCheckout do + use Ecto.Schema + + schema "registration_checkouts" do + field(:lookup_token, :string) + end + end + + def up do + flush() + + repo().transaction(fn -> + repo().all(DataExport) + |> Enum.each(fn export -> + repo().update_all( + from(e in DataExport, where: e.id == ^export.id), + set: [download_token: hash_token(export.download_token)] + ) + end) + + repo().all(RegistrationCheckout) + |> Enum.each(fn checkout -> + repo().update_all( + from(c in RegistrationCheckout, where: c.id == ^checkout.id), + set: [lookup_token: hash_token(checkout.lookup_token)] + ) + end) + end) + end + + def down, do: :ok + + defp hash_token(token) when is_binary(token) do + :crypto.hash(:sha256, token) |> Base.encode16(case: :lower) + end + + defp hash_token(_), do: nil +end diff --git a/apps/elektrine/priv/repo/migrations/20260404180000_create_dns_query_stats.exs b/apps/elektrine/priv/repo/migrations/20260404180000_create_dns_query_stats.exs new file mode 100644 index 0000000..08d53d2 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260404180000_create_dns_query_stats.exs @@ -0,0 +1,26 @@ +defmodule Elektrine.Repo.Migrations.CreateDnsQueryStats do + use Ecto.Migration + + def change do + create table(:dns_query_stats) do + add :query_date, :date, null: false + add :qname, :string, null: false + add :qtype, :string, null: false + add :rcode, :string, null: false + add :transport, :string, null: false + add :query_count, :integer, null: false, default: 0 + add :zone_id, references(:dns_zones, on_delete: :delete_all), null: false + + timestamps(updated_at: false, type: :utc_datetime) + end + + create index(:dns_query_stats, [:zone_id]) + create index(:dns_query_stats, [:zone_id, :query_date]) + + create unique_index( + :dns_query_stats, + [:zone_id, :query_date, :qname, :qtype, :rcode, :transport], + name: :dns_query_stats_daily_rollup_unique + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260405120000_add_link_preview_to_chat_messages.exs b/apps/elektrine/priv/repo/migrations/20260405120000_add_link_preview_to_chat_messages.exs new file mode 100644 index 0000000..b88b082 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260405120000_add_link_preview_to_chat_messages.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddLinkPreviewToChatMessages do + use Ecto.Migration + + def change do + alter table(:chat_messages) do + add :link_preview_id, references(:link_previews, on_delete: :nilify_all) + end + + create index(:chat_messages, [:link_preview_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260405143000_create_profile_site_visits.exs b/apps/elektrine/priv/repo/migrations/20260405143000_create_profile_site_visits.exs new file mode 100644 index 0000000..03015c4 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260405143000_create_profile_site_visits.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Repo.Migrations.CreateProfileSiteVisits do + use Ecto.Migration + + def change do + create table(:profile_site_visits) do + add :profile_user_id, references(:users, on_delete: :delete_all), null: false + add :viewer_user_id, references(:users, on_delete: :nilify_all) + add :visitor_id, :string + add :ip_address, :string + add :user_agent, :text + add :referer, :text + add :request_host, :string, null: false + add :request_path, :text, null: false + + timestamps(updated_at: false) + end + + create index(:profile_site_visits, [:profile_user_id]) + create index(:profile_site_visits, [:profile_user_id, :request_host]) + create index(:profile_site_visits, [:profile_user_id, :inserted_at]) + create index(:profile_site_visits, [:profile_user_id, :request_host, :inserted_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260407120000_add_force_https_to_dns_zones.exs b/apps/elektrine/priv/repo/migrations/20260407120000_add_force_https_to_dns_zones.exs new file mode 100644 index 0000000..f2893ca --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260407120000_add_force_https_to_dns_zones.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddForceHttpsToDnsZones do + use Ecto.Migration + + def change do + alter table(:dns_zones) do + add :force_https, :boolean, null: false, default: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260409130000_split_chat_conversations_from_social_spaces.exs b/apps/elektrine/priv/repo/migrations/20260409130000_split_chat_conversations_from_social_spaces.exs new file mode 100644 index 0000000..62f6fa9 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260409130000_split_chat_conversations_from_social_spaces.exs @@ -0,0 +1,149 @@ +defmodule Elektrine.Repo.Migrations.SplitChatConversationsFromSocialSpaces do + use Ecto.Migration + + def up do + create table(:chat_conversations) do + add :name, :string + add :description, :text + add :type, :string, null: false + add :creator_id, references(:users, on_delete: :nilify_all) + add :avatar_url, :string + add :is_public, :boolean, default: false + add :member_count, :integer, default: 0 + add :last_message_at, :utc_datetime + add :archived, :boolean, default: false + add :hash, :string + add :slow_mode_seconds, :integer, default: 0 + add :approval_mode_enabled, :boolean, default: false + add :approval_threshold_posts, :integer, default: 3 + add :channel_topic, :string + add :channel_position, :integer, default: 0 + add :server_id, references(:messaging_servers, on_delete: :delete_all) + add :federated_source, :string + add :is_federated_mirror, :boolean, default: false + add :remote_group_actor_id, references(:activitypub_actors, on_delete: :nilify_all) + + timestamps() + end + + create index(:chat_conversations, [:type]) + create index(:chat_conversations, [:creator_id]) + create index(:chat_conversations, [:last_message_at]) + create index(:chat_conversations, [:is_public]) + create index(:chat_conversations, [:server_id]) + create unique_index(:chat_conversations, [:hash]) + + create table(:chat_conversation_members) do + add :conversation_id, references(:chat_conversations, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + add :role, :string, default: "member" + add :joined_at, :utc_datetime, default: fragment("now()") + add :left_at, :utc_datetime + add :last_read_at, :utc_datetime + add :last_read_message_id, references(:chat_messages, on_delete: :nilify_all) + add :notifications_enabled, :boolean, default: true + add :pinned, :boolean, default: false + + timestamps() + end + + create unique_index(:chat_conversation_members, [:conversation_id, :user_id]) + create index(:chat_conversation_members, [:user_id]) + create index(:chat_conversation_members, [:conversation_id, :left_at]) + create index(:chat_conversation_members, [:last_read_message_id]) + + execute(""" + INSERT INTO chat_conversations ( + id, + name, + description, + type, + creator_id, + avatar_url, + is_public, + member_count, + last_message_at, + archived, + hash, + slow_mode_seconds, + approval_mode_enabled, + approval_threshold_posts, + channel_topic, + channel_position, + server_id, + federated_source, + is_federated_mirror, + remote_group_actor_id, + inserted_at, + updated_at + ) + SELECT + id, + name, + description, + type, + creator_id, + avatar_url, + is_public, + member_count, + last_message_at, + archived, + hash, + slow_mode_seconds, + approval_mode_enabled, + approval_threshold_posts, + channel_topic, + channel_position, + server_id, + federated_source, + is_federated_mirror, + remote_group_actor_id, + inserted_at, + updated_at + FROM conversations + WHERE type IN ('dm', 'group', 'channel') + """) + + execute(""" + INSERT INTO chat_conversation_members ( + conversation_id, + user_id, + role, + joined_at, + left_at, + last_read_at, + notifications_enabled, + pinned, + inserted_at, + updated_at + ) + SELECT + cm.conversation_id, + cm.user_id, + cm.role, + cm.joined_at, + cm.left_at, + cm.last_read_at, + cm.notifications_enabled, + cm.pinned, + cm.inserted_at, + cm.updated_at + FROM conversation_members cm + INNER JOIN conversations c ON c.id = cm.conversation_id + WHERE c.type IN ('dm', 'group', 'channel') + """) + + execute(""" + SELECT setval( + pg_get_serial_sequence('chat_conversations', 'id'), + COALESCE((SELECT MAX(id) FROM chat_conversations), 1), + true + ) + """) + end + + def down do + drop table(:chat_conversation_members) + drop table(:chat_conversations) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260409143000_repoint_chat_foreign_keys_to_chat_conversations.exs b/apps/elektrine/priv/repo/migrations/20260409143000_repoint_chat_foreign_keys_to_chat_conversations.exs new file mode 100644 index 0000000..9b5bab7 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260409143000_repoint_chat_foreign_keys_to_chat_conversations.exs @@ -0,0 +1,71 @@ +defmodule Elektrine.Repo.Migrations.RepointChatForeignKeysToChatConversations do + use Ecto.Migration + + def up do + execute "ALTER TABLE chat_messages DROP CONSTRAINT IF EXISTS chat_messages_conversation_id_fkey" + + execute "ALTER TABLE chat_messages ADD CONSTRAINT chat_messages_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE" + + execute "ALTER TABLE calls DROP CONSTRAINT IF EXISTS calls_conversation_id_fkey" + + execute "ALTER TABLE calls ADD CONSTRAINT calls_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE" + + execute "ALTER TABLE messaging_federation_extension_events DROP CONSTRAINT IF EXISTS messaging_federation_extension_events_conversation_id_fkey" + + execute "ALTER TABLE messaging_federation_extension_events ADD CONSTRAINT messaging_federation_extension_events_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE" + + execute "ALTER TABLE messaging_federation_read_cursors DROP CONSTRAINT IF EXISTS messaging_federation_read_cursors_conversation_id_fkey" + + execute "ALTER TABLE messaging_federation_read_cursors ADD CONSTRAINT messaging_federation_read_cursors_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE" + + execute "ALTER TABLE messaging_federation_invite_states DROP CONSTRAINT IF EXISTS messaging_federation_invite_states_conversation_id_fkey" + + execute "ALTER TABLE messaging_federation_invite_states ADD CONSTRAINT messaging_federation_invite_states_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE" + + execute "ALTER TABLE messaging_federation_membership_states DROP CONSTRAINT IF EXISTS messaging_federation_membership_states_conversation_id_fkey" + + execute "ALTER TABLE messaging_federation_membership_states ADD CONSTRAINT messaging_federation_membership_states_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE" + + execute "ALTER TABLE messaging_federation_room_presence_states DROP CONSTRAINT IF EXISTS messaging_federation_room_presence_states_conversation_id_fkey" + + execute "ALTER TABLE messaging_federation_room_presence_states ADD CONSTRAINT messaging_federation_room_presence_states_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE" + + execute "ALTER TABLE messaging_federation_call_sessions DROP CONSTRAINT IF EXISTS messaging_federation_call_sessions_conversation_id_fkey" + + execute "ALTER TABLE messaging_federation_call_sessions ADD CONSTRAINT messaging_federation_call_sessions_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE" + end + + def down do + execute "ALTER TABLE messaging_federation_call_sessions DROP CONSTRAINT IF EXISTS messaging_federation_call_sessions_conversation_id_fkey" + + execute "ALTER TABLE messaging_federation_call_sessions ADD CONSTRAINT messaging_federation_call_sessions_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE" + + execute "ALTER TABLE messaging_federation_room_presence_states DROP CONSTRAINT IF EXISTS messaging_federation_room_presence_states_conversation_id_fkey" + + execute "ALTER TABLE messaging_federation_room_presence_states ADD CONSTRAINT messaging_federation_room_presence_states_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE" + + execute "ALTER TABLE messaging_federation_membership_states DROP CONSTRAINT IF EXISTS messaging_federation_membership_states_conversation_id_fkey" + + execute "ALTER TABLE messaging_federation_membership_states ADD CONSTRAINT messaging_federation_membership_states_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE" + + execute "ALTER TABLE messaging_federation_invite_states DROP CONSTRAINT IF EXISTS messaging_federation_invite_states_conversation_id_fkey" + + execute "ALTER TABLE messaging_federation_invite_states ADD CONSTRAINT messaging_federation_invite_states_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE" + + execute "ALTER TABLE messaging_federation_read_cursors DROP CONSTRAINT IF EXISTS messaging_federation_read_cursors_conversation_id_fkey" + + execute "ALTER TABLE messaging_federation_read_cursors ADD CONSTRAINT messaging_federation_read_cursors_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE" + + execute "ALTER TABLE messaging_federation_extension_events DROP CONSTRAINT IF EXISTS messaging_federation_extension_events_conversation_id_fkey" + + execute "ALTER TABLE messaging_federation_extension_events ADD CONSTRAINT messaging_federation_extension_events_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE" + + execute "ALTER TABLE calls DROP CONSTRAINT IF EXISTS calls_conversation_id_fkey" + + execute "ALTER TABLE calls ADD CONSTRAINT calls_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE" + + execute "ALTER TABLE chat_messages DROP CONSTRAINT IF EXISTS chat_messages_conversation_id_fkey" + + execute "ALTER TABLE chat_messages ADD CONSTRAINT chat_messages_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE" + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260409153000_create_chat_moderation_tables_and_safety_checks.exs b/apps/elektrine/priv/repo/migrations/20260409153000_create_chat_moderation_tables_and_safety_checks.exs new file mode 100644 index 0000000..f4792d7 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260409153000_create_chat_moderation_tables_and_safety_checks.exs @@ -0,0 +1,168 @@ +defmodule Elektrine.Repo.Migrations.CreateChatModerationTablesAndSafetyChecks do + use Ecto.Migration + + def up do + create table(:chat_user_timeouts) do + add :user_id, references(:users, on_delete: :delete_all) + add :conversation_id, references(:chat_conversations, on_delete: :delete_all) + add :created_by_id, references(:users, on_delete: :nilify_all) + add :timeout_until, :utc_datetime + add :reason, :string + + timestamps(type: :utc_datetime) + end + + create unique_index(:chat_user_timeouts, [:user_id, :conversation_id], + name: :chat_user_timeouts_user_conversation_unique + ) + + create table(:chat_moderation_actions) do + add :action_type, :string + add :reason, :string + add :duration, :integer + add :details, :map + add :target_user_id, references(:users, on_delete: :nilify_all) + add :moderator_id, references(:users, on_delete: :nilify_all) + add :conversation_id, references(:chat_conversations, on_delete: :delete_all) + + timestamps(type: :utc_datetime) + end + + create index(:chat_moderation_actions, [:conversation_id]) + create index(:chat_moderation_actions, [:target_user_id]) + create index(:chat_moderation_actions, [:moderator_id]) + + execute(""" + INSERT INTO chat_user_timeouts ( + user_id, + conversation_id, + created_by_id, + timeout_until, + reason, + inserted_at, + updated_at + ) + SELECT + ut.user_id, + ut.conversation_id, + ut.created_by_id, + ut.timeout_until, + ut.reason, + ut.inserted_at, + ut.updated_at + FROM user_timeouts ut + INNER JOIN conversations c ON c.id = ut.conversation_id + WHERE c.type IN ('dm', 'group', 'channel') + ON CONFLICT (user_id, conversation_id) DO NOTHING + """) + + execute(""" + DELETE FROM user_timeouts ut + USING conversations c + WHERE c.id = ut.conversation_id + AND c.type IN ('dm', 'group', 'channel') + """) + + execute(""" + INSERT INTO chat_moderation_actions ( + action_type, + reason, + duration, + details, + target_user_id, + moderator_id, + conversation_id, + inserted_at, + updated_at + ) + SELECT + ma.action_type, + ma.reason, + ma.duration, + ma.details, + ma.target_user_id, + ma.moderator_id, + ma.conversation_id, + ma.inserted_at, + ma.updated_at + FROM moderation_actions ma + INNER JOIN conversations c ON c.id = ma.conversation_id + WHERE c.type IN ('dm', 'group', 'channel') + """) + + execute(""" + DELETE FROM moderation_actions ma + USING conversations c + WHERE c.id = ma.conversation_id + AND c.type IN ('dm', 'group', 'channel') + """) + + execute(""" + DO $$ + DECLARE + timeout_count bigint; + action_count bigint; + note_count bigint; + warning_count bigint; + timestamp_count bigint; + rule_count bigint; + ban_count bigint; + legacy_count bigint; + BEGIN + SELECT count(*) INTO timeout_count + FROM user_timeouts ut + JOIN conversations c ON c.id = ut.conversation_id + WHERE c.type IN ('dm','group','channel'); + + SELECT count(*) INTO action_count + FROM moderation_actions ma + JOIN conversations c ON c.id = ma.conversation_id + WHERE c.type IN ('dm','group','channel'); + + SELECT count(*) INTO note_count + FROM moderator_notes mn + JOIN conversations c ON c.id = mn.conversation_id + WHERE c.type IN ('dm','group','channel'); + + SELECT count(*) INTO warning_count + FROM user_warnings uw + JOIN conversations c ON c.id = uw.conversation_id + WHERE c.type IN ('dm','group','channel'); + + SELECT count(*) INTO timestamp_count + FROM user_post_timestamps upt + JOIN conversations c ON c.id = upt.conversation_id + WHERE c.type IN ('dm','group','channel'); + + SELECT count(*) INTO rule_count + FROM auto_mod_rules amr + JOIN conversations c ON c.id = amr.conversation_id + WHERE c.type IN ('dm','group','channel'); + + SELECT count(*) INTO ban_count + FROM community_bans cb + JOIN conversations c ON c.id = cb.conversation_id + WHERE c.type IN ('dm','group','channel'); + + legacy_count := timeout_count + action_count + note_count + warning_count + timestamp_count + rule_count + ban_count; + + IF legacy_count > 0 THEN + RAISE EXCEPTION 'Cannot finalize chat split: remaining legacy references total=% user_timeouts=% moderation_actions=% moderator_notes=% user_warnings=% user_post_timestamps=% auto_mod_rules=% community_bans=%', + legacy_count, + timeout_count, + action_count, + note_count, + warning_count, + timestamp_count, + rule_count, + ban_count; + END IF; + END $$; + """) + end + + def down do + drop table(:chat_moderation_actions) + drop table(:chat_user_timeouts) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260409154500_delete_legacy_chat_conversations.exs b/apps/elektrine/priv/repo/migrations/20260409154500_delete_legacy_chat_conversations.exs new file mode 100644 index 0000000..89da235 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260409154500_delete_legacy_chat_conversations.exs @@ -0,0 +1,88 @@ +defmodule Elektrine.Repo.Migrations.DeleteLegacyChatConversations do + use Ecto.Migration + + def up do + execute(""" + DELETE FROM messages m + USING conversations c + WHERE c.id = m.conversation_id + AND c.type IN ('dm', 'group', 'channel') + """) + + execute(""" + DO $$ + DECLARE + timeout_count bigint; + action_count bigint; + note_count bigint; + warning_count bigint; + timestamp_count bigint; + rule_count bigint; + ban_count bigint; + message_count bigint; + legacy_count bigint; + BEGIN + SELECT count(*) INTO timeout_count + FROM user_timeouts ut + JOIN conversations c ON c.id = ut.conversation_id + WHERE c.type IN ('dm','group','channel'); + + SELECT count(*) INTO action_count + FROM moderation_actions ma + JOIN conversations c ON c.id = ma.conversation_id + WHERE c.type IN ('dm','group','channel'); + + SELECT count(*) INTO note_count + FROM moderator_notes mn + JOIN conversations c ON c.id = mn.conversation_id + WHERE c.type IN ('dm','group','channel'); + + SELECT count(*) INTO warning_count + FROM user_warnings uw + JOIN conversations c ON c.id = uw.conversation_id + WHERE c.type IN ('dm','group','channel'); + + SELECT count(*) INTO timestamp_count + FROM user_post_timestamps upt + JOIN conversations c ON c.id = upt.conversation_id + WHERE c.type IN ('dm','group','channel'); + + SELECT count(*) INTO rule_count + FROM auto_mod_rules amr + JOIN conversations c ON c.id = amr.conversation_id + WHERE c.type IN ('dm','group','channel'); + + SELECT count(*) INTO ban_count + FROM community_bans cb + JOIN conversations c ON c.id = cb.conversation_id + WHERE c.type IN ('dm','group','channel'); + + SELECT count(*) INTO message_count + FROM messages m + JOIN conversations c ON c.id = m.conversation_id + WHERE c.type IN ('dm','group','channel'); + + legacy_count := timeout_count + action_count + note_count + warning_count + timestamp_count + rule_count + ban_count + message_count; + + IF legacy_count > 0 THEN + RAISE EXCEPTION 'Cannot delete legacy chat conversations: remaining legacy references total=% user_timeouts=% moderation_actions=% moderator_notes=% user_warnings=% user_post_timestamps=% auto_mod_rules=% community_bans=% messages=%', + legacy_count, + timeout_count, + action_count, + note_count, + warning_count, + timestamp_count, + rule_count, + ban_count, + message_count; + END IF; + END $$; + """) + + execute("DELETE FROM conversations WHERE type IN ('dm','group','channel')") + end + + def down do + raise "Irreversible migration" + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260411120000_add_mastodon_poll_fields.exs b/apps/elektrine/priv/repo/migrations/20260411120000_add_mastodon_poll_fields.exs new file mode 100644 index 0000000..4bbef85 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260411120000_add_mastodon_poll_fields.exs @@ -0,0 +1,12 @@ +defmodule Elektrine.Repo.Migrations.AddMastodonPollFields do + use Ecto.Migration + + def change do + alter table(:polls) do + add :hide_totals, :boolean, default: false, null: false + add :last_fetched_at, :utc_datetime + end + + create index(:polls, [:last_fetched_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260412214000_add_messages_reply_to_inserted_at_id_index.exs b/apps/elektrine/priv/repo/migrations/20260412214000_add_messages_reply_to_inserted_at_id_index.exs new file mode 100644 index 0000000..e0bf9a3 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260412214000_add_messages_reply_to_inserted_at_id_index.exs @@ -0,0 +1,14 @@ +defmodule Elektrine.Repo.Migrations.AddMessagesReplyToInsertedAtIdIndex do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def change do + create_if_not_exists index(:messages, [:reply_to_id, :inserted_at, :id], + name: :messages_reply_to_id_inserted_at_id_idx, + concurrently: true, + where: "reply_to_id IS NOT NULL" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260416143000_add_federation_and_imap_hot_path_indexes.exs b/apps/elektrine/priv/repo/migrations/20260416143000_add_federation_and_imap_hot_path_indexes.exs new file mode 100644 index 0000000..031131b --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260416143000_add_federation_and_imap_hot_path_indexes.exs @@ -0,0 +1,28 @@ +defmodule Elektrine.Repo.Migrations.AddFederationAndImapHotPathIndexes do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS messages_activitypub_url_not_null_idx + ON messages (activitypub_url) + WHERE activitypub_url IS NOT NULL + """) + + create_if_not_exists index(:email_messages, [:mailbox_id, :id], + concurrently: true, + where: + "reply_later_at IS NULL AND NOT spam AND NOT archived AND NOT deleted", + name: :email_messages_imap_inbox_mailbox_id_id_idx + ) + end + + def down do + drop_if_exists index(:email_messages, [:mailbox_id, :id], + concurrently: true, + name: :email_messages_imap_inbox_mailbox_id_id_idx + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260418165000_add_built_in_subdomain_mode_to_users.exs b/apps/elektrine/priv/repo/migrations/20260418165000_add_built_in_subdomain_mode_to_users.exs new file mode 100644 index 0000000..75dcb06 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260418165000_add_built_in_subdomain_mode_to_users.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddBuiltInSubdomainModeToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :built_in_subdomain_mode, :string, null: false, default: "platform" + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260420110000_create_notes.exs b/apps/elektrine/priv/repo/migrations/20260420110000_create_notes.exs new file mode 100644 index 0000000..c919f97 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260420110000_create_notes.exs @@ -0,0 +1,18 @@ +defmodule Elektrine.Repo.Migrations.CreateNotes do + use Ecto.Migration + + def change do + create table(:notes) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :title, :string + add :body, :text, null: false, default: "" + add :pinned, :boolean, null: false, default: false + + timestamps(type: :utc_datetime) + end + + create index(:notes, [:user_id]) + create index(:notes, [:user_id, :pinned]) + create index(:notes, [:user_id, :updated_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260420121500_create_note_shares.exs b/apps/elektrine/priv/repo/migrations/20260420121500_create_note_shares.exs new file mode 100644 index 0000000..88f275f --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260420121500_create_note_shares.exs @@ -0,0 +1,19 @@ +defmodule Elektrine.Repo.Migrations.CreateNoteShares do + use Ecto.Migration + + def change do + create table(:note_shares) do + add :note_id, references(:notes, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + add :token, :string, null: false + add :revoked_at, :utc_datetime + add :view_count, :integer, null: false, default: 0 + + timestamps(type: :utc_datetime) + end + + create unique_index(:note_shares, [:token]) + create index(:note_shares, [:note_id]) + create index(:note_shares, [:user_id, :note_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260421103000_add_protocol_to_vpn_servers.exs b/apps/elektrine/priv/repo/migrations/20260421103000_add_protocol_to_vpn_servers.exs new file mode 100644 index 0000000..c102667 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260421103000_add_protocol_to_vpn_servers.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddProtocolToVpnServers do + use Ecto.Migration + + def change do + alter table(:vpn_servers) do + add :protocol, :string, null: false, default: "wireguard" + end + + create index(:vpn_servers, [:protocol]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260421120000_allow_multiple_vpn_protocols_per_public_ip.exs b/apps/elektrine/priv/repo/migrations/20260421120000_allow_multiple_vpn_protocols_per_public_ip.exs new file mode 100644 index 0000000..9cb4f95 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260421120000_allow_multiple_vpn_protocols_per_public_ip.exs @@ -0,0 +1,8 @@ +defmodule Elektrine.Repo.Migrations.AllowMultipleVpnProtocolsPerPublicIp do + use Ecto.Migration + + def change do + drop_if_exists unique_index(:vpn_servers, [:public_ip]) + create unique_index(:vpn_servers, [:public_ip, :protocol]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260422163800_rename_drive_tables.exs b/apps/elektrine/priv/repo/migrations/20260422163800_rename_drive_tables.exs new file mode 100644 index 0000000..6858209 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260422163800_rename_drive_tables.exs @@ -0,0 +1,121 @@ +defmodule Elektrine.Repo.Migrations.RenameDriveTables do + use Ecto.Migration + + def up do + rename table(:stored_files), to: table(:drive_files) + rename table(:stored_folders), to: table(:drive_folders) + rename table(:file_shares), to: table(:drive_shares) + + rename table(:drive_shares), :stored_file_id, to: :drive_file_id + + rename index(:drive_files, [:user_id, :path], name: "stored_files_user_id_path_index"), + to: "drive_files_user_id_path_index" + + rename index(:drive_files, [:user_id], name: "stored_files_user_id_index"), + to: "drive_files_user_id_index" + + rename index(:drive_folders, [:user_id, :path], name: "stored_folders_user_id_path_index"), + to: "drive_folders_user_id_path_index" + + rename index(:drive_folders, [:user_id], name: "stored_folders_user_id_index"), + to: "drive_folders_user_id_index" + + rename index(:drive_shares, [:token], name: "file_shares_token_index"), + to: "drive_shares_token_index" + + rename index(:drive_shares, [:stored_file_id], name: "file_shares_stored_file_id_index"), + to: "drive_shares_drive_file_id_index" + + rename index(:drive_shares, [:user_id], name: "file_shares_user_id_index"), + to: "drive_shares_user_id_index" + + rename index(:drive_shares, [:expires_at], name: "file_shares_expires_at_index"), + to: "drive_shares_expires_at_index" + + rename index(:drive_shares, [:access_level], name: "file_shares_access_level_index"), + to: "drive_shares_access_level_index" + + execute( + "ALTER TABLE drive_shares RENAME CONSTRAINT file_shares_stored_file_id_fkey TO drive_shares_drive_file_id_fkey", + "ALTER TABLE drive_shares RENAME CONSTRAINT drive_shares_drive_file_id_fkey TO file_shares_stored_file_id_fkey" + ) + + execute( + "ALTER TABLE drive_shares RENAME CONSTRAINT file_shares_user_id_fkey TO drive_shares_user_id_fkey", + "ALTER TABLE drive_shares RENAME CONSTRAINT drive_shares_user_id_fkey TO file_shares_user_id_fkey" + ) + + execute( + "ALTER TABLE drive_files RENAME CONSTRAINT stored_files_user_id_fkey TO drive_files_user_id_fkey", + "ALTER TABLE drive_files RENAME CONSTRAINT drive_files_user_id_fkey TO stored_files_user_id_fkey" + ) + + execute( + "ALTER TABLE drive_folders RENAME CONSTRAINT stored_folders_user_id_fkey TO drive_folders_user_id_fkey", + "ALTER TABLE drive_folders RENAME CONSTRAINT drive_folders_user_id_fkey TO stored_folders_user_id_fkey" + ) + + execute("ALTER SEQUENCE IF EXISTS stored_files_id_seq RENAME TO drive_files_id_seq") + execute("ALTER SEQUENCE IF EXISTS stored_folders_id_seq RENAME TO drive_folders_id_seq") + execute("ALTER SEQUENCE IF EXISTS file_shares_id_seq RENAME TO drive_shares_id_seq") + end + + def down do + execute("ALTER SEQUENCE IF EXISTS drive_files_id_seq RENAME TO stored_files_id_seq") + execute("ALTER SEQUENCE IF EXISTS drive_folders_id_seq RENAME TO stored_folders_id_seq") + execute("ALTER SEQUENCE IF EXISTS drive_shares_id_seq RENAME TO file_shares_id_seq") + + execute( + "ALTER TABLE drive_folders RENAME CONSTRAINT drive_folders_user_id_fkey TO stored_folders_user_id_fkey", + "ALTER TABLE drive_folders RENAME CONSTRAINT stored_folders_user_id_fkey TO drive_folders_user_id_fkey" + ) + + execute( + "ALTER TABLE drive_files RENAME CONSTRAINT drive_files_user_id_fkey TO stored_files_user_id_fkey", + "ALTER TABLE drive_files RENAME CONSTRAINT stored_files_user_id_fkey TO drive_files_user_id_fkey" + ) + + execute( + "ALTER TABLE drive_shares RENAME CONSTRAINT drive_shares_user_id_fkey TO file_shares_user_id_fkey", + "ALTER TABLE drive_shares RENAME CONSTRAINT file_shares_user_id_fkey TO drive_shares_user_id_fkey" + ) + + execute( + "ALTER TABLE drive_shares RENAME CONSTRAINT drive_shares_drive_file_id_fkey TO file_shares_stored_file_id_fkey", + "ALTER TABLE drive_shares RENAME CONSTRAINT file_shares_stored_file_id_fkey TO drive_shares_drive_file_id_fkey" + ) + + rename index(:drive_shares, [:access_level], name: "drive_shares_access_level_index"), + to: "file_shares_access_level_index" + + rename index(:drive_shares, [:expires_at], name: "drive_shares_expires_at_index"), + to: "file_shares_expires_at_index" + + rename index(:drive_shares, [:user_id], name: "drive_shares_user_id_index"), + to: "file_shares_user_id_index" + + rename index(:drive_shares, [:drive_file_id], name: "drive_shares_drive_file_id_index"), + to: "file_shares_stored_file_id_index" + + rename index(:drive_shares, [:token], name: "drive_shares_token_index"), + to: "file_shares_token_index" + + rename index(:drive_folders, [:user_id], name: "drive_folders_user_id_index"), + to: "stored_folders_user_id_index" + + rename index(:drive_folders, [:user_id, :path], name: "drive_folders_user_id_path_index"), + to: "stored_folders_user_id_path_index" + + rename index(:drive_files, [:user_id], name: "drive_files_user_id_index"), + to: "stored_files_user_id_index" + + rename index(:drive_files, [:user_id, :path], name: "drive_files_user_id_path_index"), + to: "stored_files_user_id_path_index" + + rename table(:drive_shares), :drive_file_id, to: :stored_file_id + + rename table(:drive_shares), to: table(:file_shares) + rename table(:drive_folders), to: table(:stored_folders) + rename table(:drive_files), to: table(:stored_files) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260422165500_rename_social_messaging_tables.exs b/apps/elektrine/priv/repo/migrations/20260422165500_rename_social_messaging_tables.exs new file mode 100644 index 0000000..cce013f --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260422165500_rename_social_messaging_tables.exs @@ -0,0 +1,19 @@ +defmodule Elektrine.Repo.Migrations.RenameSocialMessagingTables do + use Ecto.Migration + + def up do + rename table(:messages), to: table(:social_messages) + rename table(:conversations), to: table(:social_conversations) + + execute("ALTER SEQUENCE IF EXISTS messages_id_seq RENAME TO social_messages_id_seq") + execute("ALTER SEQUENCE IF EXISTS conversations_id_seq RENAME TO social_conversations_id_seq") + end + + def down do + execute("ALTER SEQUENCE IF EXISTS social_messages_id_seq RENAME TO messages_id_seq") + execute("ALTER SEQUENCE IF EXISTS social_conversations_id_seq RENAME TO conversations_id_seq") + + rename table(:social_conversations), to: table(:conversations) + rename table(:social_messages), to: table(:messages) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260422170500_restore_conversations_table_name.exs b/apps/elektrine/priv/repo/migrations/20260422170500_restore_conversations_table_name.exs new file mode 100644 index 0000000..67c0564 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260422170500_restore_conversations_table_name.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.RestoreConversationsTableName do + use Ecto.Migration + + def up do + rename table(:social_conversations), to: table(:conversations) + execute("ALTER SEQUENCE IF EXISTS social_conversations_id_seq RENAME TO conversations_id_seq") + end + + def down do + rename table(:conversations), to: table(:social_conversations) + execute("ALTER SEQUENCE IF EXISTS conversations_id_seq RENAME TO social_conversations_id_seq") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260422171500_rename_conversations_to_social_conversations.exs b/apps/elektrine/priv/repo/migrations/20260422171500_rename_conversations_to_social_conversations.exs new file mode 100644 index 0000000..99a0e5c --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260422171500_rename_conversations_to_social_conversations.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.RenameConversationsToSocialConversations do + use Ecto.Migration + + def up do + rename table(:conversations), to: table(:social_conversations) + execute("ALTER SEQUENCE IF EXISTS conversations_id_seq RENAME TO social_conversations_id_seq") + end + + def down do + execute("ALTER SEQUENCE IF EXISTS social_conversations_id_seq RENAME TO conversations_id_seq") + rename table(:social_conversations), to: table(:conversations) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260422174500_rename_email_tables.exs b/apps/elektrine/priv/repo/migrations/20260422174500_rename_email_tables.exs new file mode 100644 index 0000000..51cc984 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260422174500_rename_email_tables.exs @@ -0,0 +1,93 @@ +defmodule Elektrine.Repo.Migrations.RenameEmailTables do + use Ecto.Migration + + def up do + rename table(:mailboxes), to: table(:email_mailboxes) + rename table(:contacts), to: table(:email_contacts) + + execute("ALTER SEQUENCE IF EXISTS mailboxes_id_seq RENAME TO email_mailboxes_id_seq") + execute("ALTER SEQUENCE IF EXISTS contacts_id_seq RENAME TO email_contacts_id_seq") + + rename index(:email_mailboxes, [:email], name: "mailboxes_email_index"), + to: "email_mailboxes_email_index" + + rename index(:email_mailboxes, [:user_id], name: "mailboxes_user_id_index"), + to: "email_mailboxes_user_id_index" + + rename index(:email_mailboxes, ["lower(email)"], name: "mailboxes_email_ci_unique"), + to: "email_mailboxes_email_ci_unique" + + rename index(:email_contacts, [:user_id], name: "contacts_user_id_index"), + to: "email_contacts_user_id_index" + + rename index(:email_contacts, [:user_id, :favorite], name: "contacts_user_id_favorite_index"), + to: "email_contacts_user_id_favorite_index" + + rename index(:email_contacts, [:group_id], name: "contacts_group_id_index"), + to: "email_contacts_group_id_index" + + rename index(:email_contacts, [:user_id, :email], name: "contacts_user_id_email_index"), + to: "email_contacts_user_id_email_index" + + execute( + "ALTER TABLE email_mailboxes RENAME CONSTRAINT mailboxes_user_id_fkey TO email_mailboxes_user_id_fkey", + "ALTER TABLE email_mailboxes RENAME CONSTRAINT email_mailboxes_user_id_fkey TO mailboxes_user_id_fkey" + ) + + execute( + "ALTER TABLE email_contacts RENAME CONSTRAINT contacts_user_id_fkey TO email_contacts_user_id_fkey", + "ALTER TABLE email_contacts RENAME CONSTRAINT email_contacts_user_id_fkey TO contacts_user_id_fkey" + ) + + execute( + "ALTER TABLE email_contacts RENAME CONSTRAINT contacts_group_id_fkey TO email_contacts_group_id_fkey", + "ALTER TABLE email_contacts RENAME CONSTRAINT email_contacts_group_id_fkey TO contacts_group_id_fkey" + ) + end + + def down do + execute( + "ALTER TABLE email_contacts RENAME CONSTRAINT email_contacts_group_id_fkey TO contacts_group_id_fkey", + "ALTER TABLE email_contacts RENAME CONSTRAINT contacts_group_id_fkey TO email_contacts_group_id_fkey" + ) + + execute( + "ALTER TABLE email_contacts RENAME CONSTRAINT email_contacts_user_id_fkey TO contacts_user_id_fkey", + "ALTER TABLE email_contacts RENAME CONSTRAINT contacts_user_id_fkey TO email_contacts_user_id_fkey" + ) + + execute( + "ALTER TABLE email_mailboxes RENAME CONSTRAINT email_mailboxes_user_id_fkey TO mailboxes_user_id_fkey", + "ALTER TABLE email_mailboxes RENAME CONSTRAINT mailboxes_user_id_fkey TO email_mailboxes_user_id_fkey" + ) + + rename index(:email_contacts, [:user_id, :email], name: "email_contacts_user_id_email_index"), + to: "contacts_user_id_email_index" + + rename index(:email_contacts, [:group_id], name: "email_contacts_group_id_index"), + to: "contacts_group_id_index" + + rename index(:email_contacts, [:user_id, :favorite], + name: "email_contacts_user_id_favorite_index" + ), + to: "contacts_user_id_favorite_index" + + rename index(:email_contacts, [:user_id], name: "email_contacts_user_id_index"), + to: "contacts_user_id_index" + + rename index(:email_mailboxes, ["lower(email)"], name: "email_mailboxes_email_ci_unique"), + to: "mailboxes_email_ci_unique" + + rename index(:email_mailboxes, [:user_id], name: "email_mailboxes_user_id_index"), + to: "mailboxes_user_id_index" + + rename index(:email_mailboxes, [:email], name: "email_mailboxes_email_index"), + to: "mailboxes_email_index" + + execute("ALTER SEQUENCE IF EXISTS email_contacts_id_seq RENAME TO contacts_id_seq") + execute("ALTER SEQUENCE IF EXISTS email_mailboxes_id_seq RENAME TO mailboxes_id_seq") + + rename table(:email_contacts), to: table(:contacts) + rename table(:email_mailboxes), to: table(:mailboxes) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260425000000_add_email_mailboxes_username_ci_unique.exs b/apps/elektrine/priv/repo/migrations/20260425000000_add_email_mailboxes_username_ci_unique.exs new file mode 100644 index 0000000..564561a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260425000000_add_email_mailboxes_username_ci_unique.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.AddEmailMailboxesUsernameCiUnique do + use Ecto.Migration + + def change do + create unique_index(:email_mailboxes, ["lower(username)"], + name: :email_mailboxes_username_ci_unique, + where: "username IS NOT NULL" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260425120000_add_encrypted_payload_to_note_shares.exs b/apps/elektrine/priv/repo/migrations/20260425120000_add_encrypted_payload_to_note_shares.exs new file mode 100644 index 0000000..74252db --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260425120000_add_encrypted_payload_to_note_shares.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddEncryptedPayloadToNoteShares do + use Ecto.Migration + + def change do + alter table(:note_shares) do + add :encrypted_payload, :map + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260425121000_add_pastebin_options_to_note_shares.exs b/apps/elektrine/priv/repo/migrations/20260425121000_add_pastebin_options_to_note_shares.exs new file mode 100644 index 0000000..c7b08e3 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260425121000_add_pastebin_options_to_note_shares.exs @@ -0,0 +1,12 @@ +defmodule Elektrine.Repo.Migrations.AddPastebinOptionsToNoteShares do + use Ecto.Migration + + def change do + alter table(:note_shares) do + add :expires_at, :utc_datetime + add :burn_after_read, :boolean, null: false, default: false + end + + create index(:note_shares, [:expires_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260425122000_add_burn_after_read_to_drive_shares.exs b/apps/elektrine/priv/repo/migrations/20260425122000_add_burn_after_read_to_drive_shares.exs new file mode 100644 index 0000000..0a675b4 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260425122000_add_burn_after_read_to_drive_shares.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddBurnAfterReadToDriveShares do + use Ecto.Migration + + def change do + alter table(:drive_shares) do + add :burn_after_read, :boolean, null: false, default: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260425133000_add_hourly_dns_query_stats.exs b/apps/elektrine/priv/repo/migrations/20260425133000_add_hourly_dns_query_stats.exs new file mode 100644 index 0000000..87b2304 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260425133000_add_hourly_dns_query_stats.exs @@ -0,0 +1,53 @@ +defmodule Elektrine.Repo.Migrations.AddHourlyDnsQueryStats do + use Ecto.Migration + + def up do + alter table(:dns_query_stats) do + add :query_hour, :utc_datetime + end + + execute(""" + UPDATE dns_query_stats + SET query_hour = query_date::timestamp + WHERE query_hour IS NULL + """) + + alter table(:dns_query_stats) do + modify :query_hour, :utc_datetime, null: false + end + + drop_if_exists index( + :dns_query_stats, + [:zone_id, :query_date, :qname, :qtype, :rcode, :transport], + name: :dns_query_stats_daily_rollup_unique + ) + + create index(:dns_query_stats, [:zone_id, :query_hour]) + + create unique_index( + :dns_query_stats, + [:zone_id, :query_hour, :qname, :qtype, :rcode, :transport], + name: :dns_query_stats_hourly_rollup_unique + ) + end + + def down do + drop_if_exists index( + :dns_query_stats, + [:zone_id, :query_hour, :qname, :qtype, :rcode, :transport], + name: :dns_query_stats_hourly_rollup_unique + ) + + drop_if_exists index(:dns_query_stats, [:zone_id, :query_hour]) + + create unique_index( + :dns_query_stats, + [:zone_id, :query_date, :qname, :qtype, :rcode, :transport], + name: :dns_query_stats_daily_rollup_unique + ) + + alter table(:dns_query_stats) do + remove :query_hour + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260425143000_add_alias_rules_engine_fields.exs b/apps/elektrine/priv/repo/migrations/20260425143000_add_alias_rules_engine_fields.exs new file mode 100644 index 0000000..b1cab99 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260425143000_add_alias_rules_engine_fields.exs @@ -0,0 +1,22 @@ +defmodule Elektrine.Repo.Migrations.AddAliasRulesEngineFields do + use Ecto.Migration + + def change do + alter table(:email_aliases) do + add :catch_all, :boolean, default: false, null: false + add :delivery_mode, :string, default: "deliver", null: false + add :auto_label, :string + add :expires_at, :utc_datetime + add :last_used_at, :utc_datetime + add :received_count, :integer, default: 0, null: false + add :forwarded_count, :integer, default: 0, null: false + end + + create index(:email_aliases, [:catch_all]) + create index(:email_aliases, [:expires_at]) + + create unique_index(:email_aliases, [:user_id, :catch_all, :alias_email], + where: "catch_all = true" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260425151000_add_global_email_unsubscribe_unique_index.exs b/apps/elektrine/priv/repo/migrations/20260425151000_add_global_email_unsubscribe_unique_index.exs new file mode 100644 index 0000000..7f70ae8 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260425151000_add_global_email_unsubscribe_unique_index.exs @@ -0,0 +1,25 @@ +defmodule Elektrine.Repo.Migrations.AddGlobalEmailUnsubscribeUniqueIndex do + use Ecto.Migration + + def up do + execute(""" + DELETE FROM email_unsubscribes a + USING email_unsubscribes b + WHERE a.list_id IS NULL + AND b.list_id IS NULL + AND a.email = b.email + AND a.id < b.id + """) + + create unique_index(:email_unsubscribes, [:email], + where: "list_id IS NULL", + name: :email_unsubscribes_email_global_unique_index + ) + end + + def down do + drop_if_exists index(:email_unsubscribes, [:email], + name: :email_unsubscribes_email_global_unique_index + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260425153000_allow_community_bans_for_chat_conversations.exs b/apps/elektrine/priv/repo/migrations/20260425153000_allow_community_bans_for_chat_conversations.exs new file mode 100644 index 0000000..f5d4afa --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260425153000_allow_community_bans_for_chat_conversations.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AllowCommunityBansForChatConversations do + use Ecto.Migration + + def up do + execute "ALTER TABLE community_bans DROP CONSTRAINT IF EXISTS community_bans_conversation_id_fkey" + end + + def down do + execute "ALTER TABLE community_bans ADD CONSTRAINT community_bans_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE" + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260425193000_add_legacy_activitypub_ref_indexes_to_social_messages.exs b/apps/elektrine/priv/repo/migrations/20260425193000_add_legacy_activitypub_ref_indexes_to_social_messages.exs new file mode 100644 index 0000000..3fd6e5d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260425193000_add_legacy_activitypub_ref_indexes_to_social_messages.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Repo.Migrations.AddLegacyActivitypubRefIndexesToSocialMessages do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_activitypub_id_legacy_ref_idx + ON social_messages ((trim(trailing '/' from split_part(split_part(activitypub_id, '#', 1), chr(63), 1)))) + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_activitypub_url_legacy_ref_idx + ON social_messages ((trim(trailing '/' from split_part(split_part(activitypub_url, '#', 1), chr(63), 1)))) + """) + end + + def down do + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_activitypub_url_legacy_ref_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_activitypub_id_legacy_ref_idx") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260425210000_add_literal_legacy_activitypub_ref_indexes.exs b/apps/elektrine/priv/repo/migrations/20260425210000_add_literal_legacy_activitypub_ref_indexes.exs new file mode 100644 index 0000000..01b0d5d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260425210000_add_literal_legacy_activitypub_ref_indexes.exs @@ -0,0 +1,30 @@ +defmodule Elektrine.Repo.Migrations.AddLiteralLegacyActivitypubRefIndexes do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_activitypub_id_legacy_ref_literal_idx + ON social_messages ((trim(trailing '/' from split_part(split_part(activitypub_id, '#', 1), '?', 1)))) + WHERE activitypub_id IS NOT NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_activitypub_url_legacy_ref_literal_idx + ON social_messages ((trim(trailing '/' from split_part(split_part(activitypub_url, '#', 1), '?', 1)))) + WHERE activitypub_url IS NOT NULL + """) + end + + def down do + execute( + "DROP INDEX CONCURRENTLY IF EXISTS social_messages_activitypub_url_legacy_ref_literal_idx" + ) + + execute( + "DROP INDEX CONCURRENTLY IF EXISTS social_messages_activitypub_id_legacy_ref_literal_idx" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260426190000_add_hot_path_social_feed_indexes.exs b/apps/elektrine/priv/repo/migrations/20260426190000_add_hot_path_social_feed_indexes.exs new file mode 100644 index 0000000..cff1b37 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260426190000_add_hot_path_social_feed_indexes.exs @@ -0,0 +1,73 @@ +defmodule Elektrine.Repo.Migrations.AddHotPathSocialFeedIndexes do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_public_top_federated_id_idx + ON social_messages (id DESC) + WHERE visibility = 'public' + AND deleted_at IS NULL + AND reply_to_id IS NULL + AND federated = TRUE + AND (approval_status = 'approved' OR approval_status IS NULL) + AND (media_metadata->>'inReplyTo') IS NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_public_top_community_actor_id_idx + ON social_messages (id DESC) + WHERE visibility = 'public' + AND deleted_at IS NULL + AND reply_to_id IS NULL + AND (approval_status = 'approved' OR approval_status IS NULL) + AND (media_metadata->>'inReplyTo') IS NULL + AND (media_metadata->>'community_actor_uri') IS NOT NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_public_reply_id_idx + ON social_messages (id DESC) + WHERE visibility = 'public' + AND deleted_at IS NULL + AND reply_to_id IS NOT NULL + AND (approval_status = 'approved' OR approval_status IS NULL) + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_public_inreplyto_id_idx + ON social_messages (id DESC) + WHERE visibility = 'public' + AND deleted_at IS NULL + AND (approval_status = 'approved' OR approval_status IS NULL) + AND (media_metadata->>'inReplyTo') IS NOT NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_public_federated_reply_id_idx + ON social_messages (id DESC) + WHERE visibility = 'public' + AND deleted_at IS NULL + AND federated = TRUE + AND (approval_status = 'approved' OR approval_status IS NULL) + AND (reply_to_id IS NOT NULL OR (media_metadata->>'inReplyTo') IS NOT NULL) + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS activitypub_instances_blocked_lower_domain_idx + ON activitypub_instances ((lower(domain))) + WHERE blocked = TRUE + """) + end + + def down do + execute("DROP INDEX CONCURRENTLY IF EXISTS activitypub_instances_blocked_lower_domain_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_public_federated_reply_id_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_public_inreplyto_id_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_public_reply_id_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_public_top_community_actor_id_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_public_top_federated_id_idx") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260426193000_add_broader_social_surface_indexes.exs b/apps/elektrine/priv/repo/migrations/20260426193000_add_broader_social_surface_indexes.exs new file mode 100644 index 0000000..c1a1935 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260426193000_add_broader_social_surface_indexes.exs @@ -0,0 +1,145 @@ +defmodule Elektrine.Repo.Migrations.AddBroaderSocialSurfaceIndexes do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_public_top_id_idx + ON social_messages (id DESC) + WHERE visibility = 'public' + AND deleted_at IS NULL + AND reply_to_id IS NULL + AND (approval_status = 'approved' OR approval_status IS NULL) + AND (media_metadata->>'inReplyTo') IS NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_timeline_post_id_idx + ON social_messages (conversation_id, id DESC) + WHERE post_type = 'post' + AND deleted_at IS NULL + AND reply_to_id IS NULL + AND (approval_status = 'approved' OR approval_status IS NULL) + AND (media_metadata->>'inReplyTo') IS NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_timeline_sender_id_idx + ON social_messages (sender_id, id DESC) + WHERE post_type = 'post' + AND deleted_at IS NULL + AND (approval_status = 'approved' OR approval_status IS NULL) + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_public_top_inserted_id_idx + ON social_messages (inserted_at DESC, id DESC) + WHERE visibility = 'public' + AND deleted_at IS NULL + AND reply_to_id IS NULL + AND (approval_status = 'approved' OR approval_status IS NULL) + AND (media_metadata->>'inReplyTo') IS NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_community_conversation_inserted_id_idx + ON social_messages (conversation_id, inserted_at DESC, id DESC) + WHERE visibility = 'public' + AND deleted_at IS NULL + AND reply_to_id IS NULL + AND (approval_status = 'approved' OR approval_status IS NULL) + AND (media_metadata->>'inReplyTo') IS NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_community_actor_inserted_id_idx + ON social_messages ((media_metadata->>'community_actor_uri'), inserted_at DESC, id DESC) + WHERE visibility = 'public' + AND deleted_at IS NULL + AND reply_to_id IS NULL + AND (approval_status = 'approved' OR approval_status IS NULL) + AND (media_metadata->>'inReplyTo') IS NULL + AND (media_metadata->>'community_actor_uri') IS NOT NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_gallery_public_inserted_idx + ON social_messages (inserted_at DESC) + WHERE post_type = 'gallery' + AND visibility = 'public' + AND deleted_at IS NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_gallery_federated_inserted_idx + ON social_messages (inserted_at DESC) + WHERE federated = TRUE + AND visibility IN ('public', 'unlisted') + AND deleted_at IS NULL + AND reply_to_id IS NULL + AND array_length(media_urls, 1) > 0 + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_gallery_federated_likes_idx + ON social_messages (like_count DESC, inserted_at DESC) + WHERE federated = TRUE + AND visibility IN ('public', 'unlisted') + AND deleted_at IS NULL + AND reply_to_id IS NULL + AND array_length(media_urls, 1) > 0 + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_gallery_sender_inserted_idx + ON social_messages (sender_id, inserted_at DESC) + WHERE post_type = 'gallery' + AND deleted_at IS NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_media_sender_inserted_idx + ON social_messages (sender_id, inserted_at DESC) + WHERE deleted_at IS NULL + AND (approval_status = 'approved' OR approval_status IS NULL) + AND array_length(media_urls, 1) > 0 + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_conversations_public_community_member_idx + ON social_conversations (member_count DESC, last_message_at DESC) + WHERE type = 'community' + AND is_public = TRUE + AND (is_federated_mirror IS NULL OR is_federated_mirror = FALSE) + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS conversation_members_user_active_community_idx + ON conversation_members (user_id, conversation_id) + WHERE left_at IS NULL + """) + end + + def down do + execute("DROP INDEX CONCURRENTLY IF EXISTS conversation_members_user_active_community_idx") + + execute("DROP INDEX CONCURRENTLY IF EXISTS social_conversations_public_community_member_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_media_sender_inserted_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_gallery_sender_inserted_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_gallery_federated_likes_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_gallery_federated_inserted_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_gallery_public_inserted_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_community_actor_inserted_id_idx") + + execute( + "DROP INDEX CONCURRENTLY IF EXISTS social_messages_community_conversation_inserted_id_idx" + ) + + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_public_top_inserted_id_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_timeline_sender_id_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_timeline_post_id_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_public_top_id_idx") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260428120000_create_site_page_visits.exs b/apps/elektrine/priv/repo/migrations/20260428120000_create_site_page_visits.exs new file mode 100644 index 0000000..29cb1ce --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260428120000_create_site_page_visits.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Repo.Migrations.CreateSitePageVisits do + use Ecto.Migration + + def change do + create table(:site_page_visits) do + add :viewer_user_id, references(:users, on_delete: :nilify_all) + add :visitor_id, :string + add :ip_address, :string + add :user_agent, :text + add :referer, :text + add :request_host, :string, null: false + add :request_path, :text, null: false + add :status, :integer, null: false + + timestamps(updated_at: false) + end + + create index(:site_page_visits, [:request_host]) + create index(:site_page_visits, [:inserted_at]) + create index(:site_page_visits, [:request_host, :inserted_at]) + create index(:site_page_visits, [:viewer_user_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260428121000_add_site_sessions.exs b/apps/elektrine/priv/repo/migrations/20260428121000_add_site_sessions.exs new file mode 100644 index 0000000..ed792e5 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260428121000_add_site_sessions.exs @@ -0,0 +1,36 @@ +defmodule Elektrine.Repo.Migrations.AddSiteSessions do + use Ecto.Migration + + def change do + create table(:site_sessions) do + add :session_id, :string, null: false + add :viewer_user_id, references(:users, on_delete: :nilify_all) + add :visitor_id, :string + add :ip_address, :string + add :user_agent, :text + add :referer, :text + add :entry_host, :string, null: false + add :entry_path, :text, null: false + add :exit_host, :string, null: false + add :exit_path, :text, null: false + add :page_views, :integer, null: false, default: 1 + add :started_at, :utc_datetime, null: false + add :last_seen_at, :utc_datetime, null: false + add :duration_seconds, :integer, null: false, default: 0 + + timestamps() + end + + create unique_index(:site_sessions, [:session_id]) + create index(:site_sessions, [:entry_host]) + create index(:site_sessions, [:started_at]) + create index(:site_sessions, [:entry_host, :started_at]) + create index(:site_sessions, [:viewer_user_id]) + + alter table(:site_page_visits) do + add :session_id, :string + end + + create index(:site_page_visits, [:session_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260430031000_create_atomine_proofs.exs b/apps/elektrine/priv/repo/migrations/20260430031000_create_atomine_proofs.exs new file mode 100644 index 0000000..435ad06 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260430031000_create_atomine_proofs.exs @@ -0,0 +1,36 @@ +defmodule Elektrine.Repo.Migrations.CreateAtomineProofs do + use Ecto.Migration + + def change do + create table(:atomine_proofs) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :kind, :string, null: false + add :claim_type, :string, null: false, default: "positive" + add :verification_method, :string, null: false, default: "manual" + add :subject, :string, null: false + add :status, :string, null: false, default: "pending" + add :challenge, :text, null: false + add :evidence_url, :text + add :score_weight, :integer, null: false, default: 0 + add :metadata, :map, null: false, default: %{} + add :checked_at, :utc_datetime + add :verified_at, :utc_datetime + add :rejected_at, :utc_datetime + add :revoked_at, :utc_datetime + add :reviewed_by_user_id, references(:users, on_delete: :nilify_all) + add :review_notes, :text + + timestamps(type: :utc_datetime) + end + + create index(:atomine_proofs, [:user_id]) + create index(:atomine_proofs, [:kind]) + create index(:atomine_proofs, [:status]) + create index(:atomine_proofs, [:reviewed_by_user_id]) + + create unique_index(:atomine_proofs, [:user_id, :kind, :subject], + where: "status IN ('pending', 'verified')", + name: :atomine_proofs_active_subject_unique + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260430032000_add_claim_fields_to_atomine_proofs.exs b/apps/elektrine/priv/repo/migrations/20260430032000_add_claim_fields_to_atomine_proofs.exs new file mode 100644 index 0000000..108e9ce --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260430032000_add_claim_fields_to_atomine_proofs.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddClaimFieldsToAtomineProofs do + use Ecto.Migration + + def change do + alter table(:atomine_proofs) do + add_if_not_exists :claim_type, :string, null: false, default: "positive" + add_if_not_exists :verification_method, :string, null: false, default: "manual" + add_if_not_exists :checked_at, :utc_datetime + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260430033000_add_live_mode_to_atomine_proofs.exs b/apps/elektrine/priv/repo/migrations/20260430033000_add_live_mode_to_atomine_proofs.exs new file mode 100644 index 0000000..017374c --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260430033000_add_live_mode_to_atomine_proofs.exs @@ -0,0 +1,18 @@ +defmodule Elektrine.Repo.Migrations.AddLiveModeToAtomineProofs do + use Ecto.Migration + + def change do + alter table(:atomine_proofs) do + add_if_not_exists :proof_mode, :string, null: false, default: "snapshot" + add_if_not_exists :live_status, :string + add_if_not_exists :last_seen_at, :utc_datetime + add_if_not_exists :next_check_at, :utc_datetime + add_if_not_exists :stale_at, :utc_datetime + add_if_not_exists :failed_check_count, :integer, null: false, default: 0 + end + + create index(:atomine_proofs, [:proof_mode]) + create index(:atomine_proofs, [:live_status]) + create index(:atomine_proofs, [:next_check_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260501090000_create_atomine_trust_sessions.exs b/apps/elektrine/priv/repo/migrations/20260501090000_create_atomine_trust_sessions.exs new file mode 100644 index 0000000..0be7546 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260501090000_create_atomine_trust_sessions.exs @@ -0,0 +1,31 @@ +defmodule Elektrine.Repo.Migrations.CreateAtomineTrustSessions do + use Ecto.Migration + + def change do + create table(:atomine_trust_sessions) do + add :public_id, :string, null: false + add :user_id, references(:users, on_delete: :nilify_all) + add :context, :string, null: false + add :merchant_id, :string + add :external_subject, :string + add :status, :string, null: false, default: "pending" + add :decision, :string, null: false, default: "review" + add :recommended_step_up, :string + add :score, :integer, null: false, default: 0 + add :level, :string, null: false, default: "unknown" + add :signals, :map, null: false, default: %{} + add :metadata, :map, null: false, default: %{} + add :expires_at, :utc_datetime + add :completed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:atomine_trust_sessions, [:public_id]) + create index(:atomine_trust_sessions, [:user_id]) + create index(:atomine_trust_sessions, [:context]) + create index(:atomine_trust_sessions, [:merchant_id]) + create index(:atomine_trust_sessions, [:status]) + create index(:atomine_trust_sessions, [:decision]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260501091000_create_connected_accounts.exs b/apps/elektrine/priv/repo/migrations/20260501091000_create_connected_accounts.exs new file mode 100644 index 0000000..ea031e4 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260501091000_create_connected_accounts.exs @@ -0,0 +1,30 @@ +defmodule Elektrine.Repo.Migrations.CreateConnectedAccounts do + use Ecto.Migration + + def change do + create table(:connected_accounts) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :provider, :string, null: false + add :provider_account_id, :string, null: false + add :username, :string + add :display_name, :string + add :email, :string + add :profile_url, :text + add :avatar_url, :text + add :scopes, {:array, :string}, null: false, default: [] + add :metadata, :map, null: false, default: %{} + add :last_verified_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:connected_accounts, [:provider, :provider_account_id]) + + create unique_index(:connected_accounts, [:user_id, :provider, :provider_account_id], + name: :connected_accounts_user_provider_account_unique + ) + + create index(:connected_accounts, [:user_id]) + create index(:connected_accounts, [:provider]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260502120000_create_atomine_attestations.exs b/apps/elektrine/priv/repo/migrations/20260502120000_create_atomine_attestations.exs new file mode 100644 index 0000000..f6a7467 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260502120000_create_atomine_attestations.exs @@ -0,0 +1,34 @@ +defmodule Elektrine.Repo.Migrations.CreateAtomineAttestations do + use Ecto.Migration + + def change do + create table(:atomine_attestations) do + add :public_id, :string, null: false + add :user_id, references(:users, on_delete: :nilify_all) + add :passkey_credential_id, references(:passkey_credentials, on_delete: :nilify_all) + add :kind, :string, null: false + add :status, :string, null: false, default: "issued" + add :issuer, :string, null: false + add :subject, :text + add :subject_hash, :string + add :artifact_hash, :string, null: false + add :artifact, :text, null: false + add :difficulty, :integer + add :metadata, :map, null: false, default: %{} + add :issued_at, :utc_datetime, null: false + add :expires_at, :utc_datetime, null: false + add :redeemed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:atomine_attestations, [:public_id]) + create unique_index(:atomine_attestations, [:artifact_hash]) + create index(:atomine_attestations, [:user_id]) + create index(:atomine_attestations, [:passkey_credential_id]) + create index(:atomine_attestations, [:kind]) + create index(:atomine_attestations, [:status]) + create index(:atomine_attestations, [:subject_hash]) + create index(:atomine_attestations, [:expires_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260502121000_add_global_dns_proof_uniqueness.exs b/apps/elektrine/priv/repo/migrations/20260502121000_add_global_dns_proof_uniqueness.exs new file mode 100644 index 0000000..a875a75 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260502121000_add_global_dns_proof_uniqueness.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.AddGlobalDnsProofUniqueness do + use Ecto.Migration + + def change do + create unique_index(:atomine_proofs, [:kind, :subject], + where: "kind = 'dns' AND status IN ('pending', 'verified')", + name: :atomine_proofs_active_dns_subject_unique + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260503000000_update_oban_to_v14.exs b/apps/elektrine/priv/repo/migrations/20260503000000_update_oban_to_v14.exs new file mode 100644 index 0000000..b712fef --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260503000000_update_oban_to_v14.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.UpdateObanToV14 do + use Ecto.Migration + + def up do + Oban.Migration.up(version: 14) + end + + def down do + Oban.Migration.down(version: 12) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260503120000_create_atomine_credits.exs b/apps/elektrine/priv/repo/migrations/20260503120000_create_atomine_credits.exs new file mode 100644 index 0000000..781126d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260503120000_create_atomine_credits.exs @@ -0,0 +1,66 @@ +defmodule Elektrine.Repo.Migrations.CreateAtomineCredits do + use Ecto.Migration + + def change do + create table(:atomine_credit_accounts) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :credit_type, :string, null: false + add :balance, :integer, null: false, default: 0 + add :lifetime_earned, :integer, null: false, default: 0 + add :lifetime_spent, :integer, null: false, default: 0 + add :metadata, :map, null: false, default: %{} + + timestamps(type: :utc_datetime) + end + + create unique_index(:atomine_credit_accounts, [:user_id, :credit_type]) + create index(:atomine_credit_accounts, [:credit_type]) + + create table(:atomine_credit_ledger_entries) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :credit_type, :string, null: false + add :amount, :integer, null: false + add :reason, :string, null: false + add :action, :string + add :reference_type, :string + add :reference_id, :string + add :metadata, :map, null: false, default: %{} + + timestamps(type: :utc_datetime) + end + + create index(:atomine_credit_ledger_entries, [:user_id, :credit_type]) + create index(:atomine_credit_ledger_entries, [:reason]) + create index(:atomine_credit_ledger_entries, [:action]) + + create unique_index( + :atomine_credit_ledger_entries, + [:user_id, :credit_type, :reason, :reference_type, :reference_id], + name: :atomine_credit_ledger_entries_grant_once_index, + where: "amount > 0 AND reference_type IS NOT NULL AND reference_id IS NOT NULL" + ) + + create table(:atomine_credit_spends) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :credit_type, :string, null: false + add :amount, :integer, null: false + add :action, :string, null: false + add :audience, :string, null: false + add :idempotency_key, :string + add :metadata, :map, null: false, default: %{} + + timestamps(type: :utc_datetime) + end + + create index(:atomine_credit_spends, [:user_id, :credit_type]) + create index(:atomine_credit_spends, [:action]) + create index(:atomine_credit_spends, [:audience]) + + create unique_index( + :atomine_credit_spends, + [:user_id, :credit_type, :action, :idempotency_key], + name: :atomine_credit_spends_idempotency_index, + where: "idempotency_key IS NOT NULL" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260503132000_add_chat_messages_search_index_gin.exs b/apps/elektrine/priv/repo/migrations/20260503132000_add_chat_messages_search_index_gin.exs new file mode 100644 index 0000000..ff5119b --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260503132000_add_chat_messages_search_index_gin.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.AddChatMessagesSearchIndexGin do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS chat_messages_search_index_gin_idx + ON chat_messages USING gin (search_index) + """) + end + + def down do + execute("DROP INDEX CONCURRENTLY IF EXISTS chat_messages_search_index_gin_idx") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260503134000_add_chat_client_encryption.exs b/apps/elektrine/priv/repo/migrations/20260503134000_add_chat_client_encryption.exs new file mode 100644 index 0000000..be3e020 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260503134000_add_chat_client_encryption.exs @@ -0,0 +1,67 @@ +defmodule Elektrine.Repo.Migrations.AddChatClientEncryption do + use Ecto.Migration + + def change do + create table(:chat_encryption_devices) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :device_id, :string, null: false + add :public_key, :map, null: false + add :key_algorithm, :string, null: false, default: "RSA-OAEP-SHA256" + add :label, :string + add :last_seen_at, :utc_datetime + add :revoked_at, :utc_datetime + + timestamps() + end + + create unique_index(:chat_encryption_devices, [:user_id, :device_id]) + + create index(:chat_encryption_devices, [:user_id], + where: "revoked_at IS NULL", + name: :chat_encryption_devices_active_user_idx + ) + + create table(:chat_conversation_encryption_keys) do + add :conversation_id, references(:chat_conversations, on_delete: :delete_all), null: false + add :key_uid, :string, null: false + add :created_by_id, references(:users, on_delete: :nilify_all) + add :algorithm, :string, null: false, default: "AES-256-GCM" + add :active, :boolean, null: false, default: true + add :metadata, :map, null: false, default: %{} + + timestamps() + end + + create unique_index(:chat_conversation_encryption_keys, [:conversation_id, :key_uid]) + create index(:chat_conversation_encryption_keys, [:conversation_id, :active]) + + create table(:chat_conversation_key_recipients) do + add :conversation_key_id, + references(:chat_conversation_encryption_keys, on_delete: :delete_all), + null: false + + add :user_id, references(:users, on_delete: :delete_all), null: false + add :device_id, :string, null: false + add :wrapped_key, :map, null: false + + timestamps() + end + + create unique_index(:chat_conversation_key_recipients, [ + :conversation_key_id, + :user_id, + :device_id + ]) + + create index(:chat_conversation_key_recipients, [:user_id, :device_id]) + + alter table(:chat_messages) do + add :client_encrypted_payload, :map + + add :client_encryption_key_id, + references(:chat_conversation_encryption_keys, on_delete: :nilify_all) + end + + create index(:chat_messages, [:client_encryption_key_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260503135000_add_chat_remote_encryption_devices.exs b/apps/elektrine/priv/repo/migrations/20260503135000_add_chat_remote_encryption_devices.exs new file mode 100644 index 0000000..a5bc0a7 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260503135000_add_chat_remote_encryption_devices.exs @@ -0,0 +1,29 @@ +defmodule Elektrine.Repo.Migrations.AddChatRemoteEncryptionDevices do + use Ecto.Migration + + def change do + create table(:chat_remote_encryption_devices) do + add :origin_domain, :string, null: false + add :remote_handle, :string, null: false + add :device_id, :string, null: false + add :public_key, :map, null: false + add :key_algorithm, :string, null: false, default: "RSA-OAEP-SHA256" + add :label, :string + add :last_seen_at, :utc_datetime + add :revoked_at, :utc_datetime + + timestamps() + end + + create unique_index(:chat_remote_encryption_devices, [ + :origin_domain, + :remote_handle, + :device_id + ]) + + create index(:chat_remote_encryption_devices, [:remote_handle], + where: "revoked_at IS NULL", + name: :chat_remote_encryption_devices_active_handle_idx + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260503170000_add_activitypub_request_replays.exs b/apps/elektrine/priv/repo/migrations/20260503170000_add_activitypub_request_replays.exs new file mode 100644 index 0000000..b32ef76 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260503170000_add_activitypub_request_replays.exs @@ -0,0 +1,32 @@ +defmodule Elektrine.Repo.Migrations.AddActivitypubRequestReplays do + use Ecto.Migration + + def change do + create table(:activitypub_request_replays) do + add :nonce, :string, null: false + add :key_id, :string, null: false + add :actor_uri, :string + add :http_method, :string, null: false + add :request_path, :string, null: false + add :query_string, :string + add :signature_timestamp, :string + add :digest, :string + add :seen_at, :utc_datetime, null: false + add :expires_at, :utc_datetime, null: false + + timestamps(type: :utc_datetime, updated_at: false) + end + + create unique_index(:activitypub_request_replays, [:nonce], + name: :activitypub_request_replays_nonce_unique + ) + + create index(:activitypub_request_replays, [:expires_at], + name: :activitypub_request_replays_expires_at_idx + ) + + create index(:activitypub_request_replays, [:actor_uri, :inserted_at], + name: :activitypub_request_replays_actor_inserted_idx + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260503171000_add_encrypted_password_vault_metadata.exs b/apps/elektrine/priv/repo/migrations/20260503171000_add_encrypted_password_vault_metadata.exs new file mode 100644 index 0000000..a9e2a1d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260503171000_add_encrypted_password_vault_metadata.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddEncryptedPasswordVaultMetadata do + use Ecto.Migration + + def change do + alter table(:password_vault_entries) do + add :encrypted_metadata, :map + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260503172000_add_chat_encryption_device_trust_fields.exs b/apps/elektrine/priv/repo/migrations/20260503172000_add_chat_encryption_device_trust_fields.exs new file mode 100644 index 0000000..fbfc3bb --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260503172000_add_chat_encryption_device_trust_fields.exs @@ -0,0 +1,20 @@ +defmodule Elektrine.Repo.Migrations.AddChatEncryptionDeviceTrustFields do + use Ecto.Migration + + def change do + alter table(:chat_encryption_devices) do + add :fingerprint, :string + add :signing_public_key, :map + add :device_signature, :map + end + + alter table(:chat_remote_encryption_devices) do + add :fingerprint, :string + add :signing_public_key, :map + add :device_signature, :map + end + + create index(:chat_encryption_devices, [:user_id, :fingerprint]) + create index(:chat_remote_encryption_devices, [:origin_domain, :remote_handle, :fingerprint]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260503173000_add_mailbox_category_filter_settings.exs b/apps/elektrine/priv/repo/migrations/20260503173000_add_mailbox_category_filter_settings.exs new file mode 100644 index 0000000..8805127 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260503173000_add_mailbox_category_filter_settings.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.AddMailboxCategoryFilterSettings do + use Ecto.Migration + + def change do + alter table(:email_mailboxes) do + add :digest_filter_enabled, :boolean, null: false, default: true + add :ledger_filter_enabled, :boolean, null: false, default: true + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260504120000_backfill_chat_conversation_hashes.exs b/apps/elektrine/priv/repo/migrations/20260504120000_backfill_chat_conversation_hashes.exs new file mode 100644 index 0000000..e364f9a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260504120000_backfill_chat_conversation_hashes.exs @@ -0,0 +1,15 @@ +defmodule Elektrine.Repo.Migrations.BackfillChatConversationHashes do + use Ecto.Migration + + def up do + execute(""" + UPDATE chat_conversations + SET hash = lower(md5(random()::text || clock_timestamp()::text || id::text)) + WHERE hash IS NULL OR hash = '' + """) + end + + def down do + :ok + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260507120000_change_activitypub_actor_profile_fields_to_text.exs b/apps/elektrine/priv/repo/migrations/20260507120000_change_activitypub_actor_profile_fields_to_text.exs new file mode 100644 index 0000000..f16f470 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260507120000_change_activitypub_actor_profile_fields_to_text.exs @@ -0,0 +1,50 @@ +defmodule Elektrine.Repo.Migrations.ChangeActivitypubActorProfileFieldsToText do + use Ecto.Migration + + def up do + alter table(:activitypub_actors) do + modify :uri, :text + modify :username, :text + modify :domain, :text + modify :display_name, :text + modify :avatar_url, :text + modify :header_url, :text + modify :inbox_url, :text + modify :outbox_url, :text + modify :followers_url, :text + modify :following_url, :text + modify :moderators_url, :text + end + end + + def down do + execute(""" + UPDATE activitypub_actors + SET uri = left(uri, 255), + username = left(username, 255), + domain = left(domain, 255), + display_name = left(display_name, 255), + avatar_url = left(avatar_url, 255), + header_url = left(header_url, 255), + inbox_url = left(inbox_url, 255), + outbox_url = left(outbox_url, 255), + followers_url = left(followers_url, 255), + following_url = left(following_url, 255), + moderators_url = left(moderators_url, 255) + """) + + alter table(:activitypub_actors) do + modify :uri, :string + modify :username, :string + modify :domain, :string + modify :display_name, :string + modify :avatar_url, :string + modify :header_url, :string + modify :inbox_url, :string + modify :outbox_url, :string + modify :followers_url, :string + modify :following_url, :string + modify :moderators_url, :string + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260509120000_ensure_fast_federated_timeline_index.exs b/apps/elektrine/priv/repo/migrations/20260509120000_ensure_fast_federated_timeline_index.exs new file mode 100644 index 0000000..9dd82c4 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260509120000_ensure_fast_federated_timeline_index.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Repo.Migrations.EnsureFastFederatedTimelineIndex do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_public_top_federated_id_idx + ON social_messages (id DESC) + WHERE visibility = 'public' + AND deleted_at IS NULL + AND reply_to_id IS NULL + AND federated = TRUE + AND (approval_status = 'approved' OR approval_status IS NULL) + AND (media_metadata->>'inReplyTo') IS NULL + """) + end + + def down do + :ok + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260510120000_encrypt_notes_content.exs b/apps/elektrine/priv/repo/migrations/20260510120000_encrypt_notes_content.exs new file mode 100644 index 0000000..cf20394 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260510120000_encrypt_notes_content.exs @@ -0,0 +1,72 @@ +defmodule Elektrine.Repo.Migrations.EncryptNotesContent do + use Ecto.Migration + + import Ecto.Query + + alias Elektrine.Secrets.EncryptedString + + defmodule Note do + use Ecto.Schema + + schema "notes" do + field(:title, :string) + field(:body, :string) + end + end + + def up do + alter table(:notes) do + modify :title, :text + end + + flush() + + repo().transaction(fn -> + repo().all(Note) + |> Enum.each(fn note -> + repo().update_all( + from(n in Note, where: n.id == ^note.id), + set: [title: encrypt_note_value(note.title), body: encrypt_note_value(note.body)] + ) + end) + end) + end + + def down do + repo().transaction(fn -> + repo().all(Note) + |> Enum.each(fn note -> + repo().update_all( + from(n in Note, where: n.id == ^note.id), + set: [title: decrypt_note_value(note.title), body: decrypt_note_value(note.body)] + ) + end) + end) + + alter table(:notes) do + modify :title, :string + end + end + + defp encrypt_note_value(nil), do: nil + + defp encrypt_note_value(value) when is_binary(value) do + if EncryptedString.encrypted?(value) do + value + else + case EncryptedString.encrypt(value) do + {:ok, encrypted} -> encrypted + :error -> raise "could not encrypt note content" + end + end + end + + defp decrypt_note_value(nil), do: nil + + defp decrypt_note_value(value) when is_binary(value) do + case EncryptedString.decrypt(value) do + {:ok, plaintext} -> plaintext + :error -> value + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260513011500_create_external_email_deliveries.exs b/apps/elektrine/priv/repo/migrations/20260513011500_create_external_email_deliveries.exs new file mode 100644 index 0000000..f5c79db --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260513011500_create_external_email_deliveries.exs @@ -0,0 +1,29 @@ +defmodule Elektrine.Repo.Migrations.CreateExternalEmailDeliveries do + use Ecto.Migration + + def change do + create table(:external_email_deliveries) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :mailbox_id, references(:email_mailboxes, on_delete: :delete_all), null: false + add :sent_message_id, references(:email_messages, on_delete: :delete_all), null: false + add :envelope_from, :string, null: false + add :to, {:array, :string}, null: false, default: [] + add :cc, {:array, :string}, null: false, default: [] + add :bcc, {:array, :string}, null: false, default: [] + add :params, :map, null: false, default: %{} + add :status, :string, null: false, default: "pending" + add :attempts, :integer, null: false, default: 0 + add :provider, :string + add :provider_message_id, :string + add :error, :text + add :last_attempted_at, :utc_datetime + add :delivered_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:external_email_deliveries, [:sent_message_id]) + create index(:external_email_deliveries, [:user_id, :status]) + create index(:external_email_deliveries, [:mailbox_id, :status]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260513013000_expand_external_email_deliveries.exs b/apps/elektrine/priv/repo/migrations/20260513013000_expand_external_email_deliveries.exs new file mode 100644 index 0000000..ed417ea --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260513013000_expand_external_email_deliveries.exs @@ -0,0 +1,48 @@ +defmodule Elektrine.Repo.Migrations.ExpandExternalEmailDeliveries do + use Ecto.Migration + + def change do + drop_if_exists unique_index(:external_email_deliveries, [:sent_message_id]) + + alter table(:external_email_deliveries) do + add :recipient, :string + add :recipient_type, :string, null: false, default: "to" + add :domain, :string + add :trace_id, :string + add :response_code, :string + end + + execute( + "UPDATE external_email_deliveries SET recipient = COALESCE(\"to\"[1], cc[1], bcc[1]), domain = split_part(COALESCE(\"to\"[1], cc[1], bcc[1]), '@', 2), trace_id = 'legacy-' || id WHERE recipient IS NULL", + "UPDATE external_email_deliveries SET recipient = NULL, domain = NULL, trace_id = NULL" + ) + + create unique_index( + :external_email_deliveries, + [:sent_message_id, :recipient_type, :recipient], + name: :external_email_deliveries_recipient_unique + ) + + create index(:external_email_deliveries, [:domain, :status]) + create index(:external_email_deliveries, [:trace_id]) + + create table(:external_email_delivery_attempts) do + add :delivery_id, references(:external_email_deliveries, on_delete: :delete_all), + null: false + + add :attempt, :integer, null: false + add :status, :string, null: false + add :provider, :string + add :provider_message_id, :string + add :response_code, :string + add :error, :text + add :metadata, :map, null: false, default: %{} + add :attempted_at, :utc_datetime, null: false + + timestamps(type: :utc_datetime) + end + + create index(:external_email_delivery_attempts, [:delivery_id, :attempt]) + create index(:external_email_delivery_attempts, [:status]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260513020000_create_external_email_ops_tables.exs b/apps/elektrine/priv/repo/migrations/20260513020000_create_external_email_ops_tables.exs new file mode 100644 index 0000000..cbb6e71 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260513020000_create_external_email_ops_tables.exs @@ -0,0 +1,36 @@ +defmodule Elektrine.Repo.Migrations.CreateExternalEmailOpsTables do + use Ecto.Migration + + def change do + create table(:external_email_delivery_controls) do + add :scope_type, :string, null: false + add :scope_value, :string, null: false + add :active, :boolean, null: false, default: true + add :reason, :text + add :paused_by_id, references(:users, on_delete: :nilify_all) + add :paused_at, :utc_datetime + add :resumed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:external_email_delivery_controls, [:scope_type, :scope_value], + name: :external_email_delivery_controls_scope_unique + ) + + create index(:external_email_delivery_controls, [:active]) + + create table(:external_email_metric_snapshots) do + add :metrics, :map, null: false, default: %{} + add :queue_depth, :integer, null: false, default: 0 + add :stuck_count, :integer, null: false, default: 0 + add :bounce_rate, :float, null: false, default: 0.0 + add :complaint_rate, :float, null: false, default: 0.0 + add :captured_at, :utc_datetime, null: false + + timestamps(type: :utc_datetime) + end + + create index(:external_email_metric_snapshots, [:captured_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260513021000_create_internal_email_deliveries.exs b/apps/elektrine/priv/repo/migrations/20260513021000_create_internal_email_deliveries.exs new file mode 100644 index 0000000..9df5613 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260513021000_create_internal_email_deliveries.exs @@ -0,0 +1,53 @@ +defmodule Elektrine.Repo.Migrations.CreateInternalEmailDeliveries do + use Ecto.Migration + + def change do + create table(:internal_email_deliveries) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :mailbox_id, references(:email_mailboxes, on_delete: :delete_all), null: false + add :sent_message_id, references(:email_messages, on_delete: :delete_all), null: false + + add :recipient_mailbox_id, references(:email_mailboxes, on_delete: :delete_all), null: false + + add :delivered_message_id, references(:email_messages, on_delete: :nilify_all) + add :recipient, :string, null: false + add :recipient_type, :string, null: false, default: "to" + add :params, :map, null: false, default: %{} + add :status, :string, null: false, default: "pending" + add :attempts, :integer, null: false, default: 0 + add :error, :text + add :last_attempted_at, :utc_datetime + add :delivered_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index( + :internal_email_deliveries, + [:sent_message_id, :recipient_type, :recipient], + name: :internal_email_deliveries_recipient_unique + ) + + create index(:internal_email_deliveries, [:user_id, :status]) + create index(:internal_email_deliveries, [:mailbox_id, :status]) + create index(:internal_email_deliveries, [:recipient_mailbox_id, :status]) + create index(:internal_email_deliveries, [:sent_message_id]) + + create table(:internal_email_delivery_attempts) do + add :delivery_id, references(:internal_email_deliveries, on_delete: :delete_all), + null: false + + add :attempt, :integer, null: false + add :status, :string, null: false + add :delivered_message_id, references(:email_messages, on_delete: :nilify_all) + add :error, :text + add :metadata, :map, null: false, default: %{} + add :attempted_at, :utc_datetime, null: false + + timestamps(type: :utc_datetime) + end + + create index(:internal_email_delivery_attempts, [:delivery_id, :attempt]) + create index(:internal_email_delivery_attempts, [:status]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260516100000_add_db_indexes_for_site_analytics_and_oban_queue_stats.exs b/apps/elektrine/priv/repo/migrations/20260516100000_add_db_indexes_for_site_analytics_and_oban_queue_stats.exs new file mode 100644 index 0000000..b25b186 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260516100000_add_db_indexes_for_site_analytics_and_oban_queue_stats.exs @@ -0,0 +1,29 @@ +defmodule Elektrine.Repo.Migrations.AddDbIndexesForSiteAnalyticsAndObanQueueStats do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS site_page_visits_host_visitor_stats_idx + ON site_page_visits ( + request_host, + (COALESCE(CAST(viewer_user_id AS text), visitor_id, ip_address)) + ) + INCLUDE (id, inserted_at) + WHERE request_host IS NOT NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS oban_jobs_available_queue_idx + ON oban_jobs (queue) + WHERE state = 'available' AND queue IS NOT NULL + """) + end + + def down do + execute("DROP INDEX CONCURRENTLY IF EXISTS oban_jobs_available_queue_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS site_page_visits_host_visitor_stats_idx") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260516110000_rename_password_vault_to_nerve.exs b/apps/elektrine/priv/repo/migrations/20260516110000_rename_password_vault_to_nerve.exs new file mode 100644 index 0000000..4ee3c3f --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260516110000_rename_password_vault_to_nerve.exs @@ -0,0 +1,109 @@ +defmodule Elektrine.Repo.Migrations.RenamePasswordVaultToNerve do + use Ecto.Migration + + def up do + rename_if_exists(:password_vault_entries, :nerve_entries) + rename_if_exists(:password_vault_settings, :nerve_settings) + + rename_sequence_if_exists(:password_vault_entries_id_seq, :nerve_entries_id_seq) + rename_sequence_if_exists(:password_vault_settings_id_seq, :nerve_settings_id_seq) + + rename_constraint_if_exists(:nerve_entries, :password_vault_entries_pkey, :nerve_entries_pkey) + + rename_constraint_if_exists( + :nerve_settings, + :password_vault_settings_pkey, + :nerve_settings_pkey + ) + + rename_index_if_exists(:password_vault_entries_user_id_index, :nerve_entries_user_id_index) + + rename_index_if_exists( + :password_vault_entries_user_id_inserted_at_index, + :nerve_entries_user_id_inserted_at_index + ) + + rename_index_if_exists(:password_vault_settings_user_id_index, :nerve_settings_user_id_index) + + rename_constraint_if_exists( + :nerve_entries, + :password_vault_entries_user_id_fkey, + :nerve_entries_user_id_fkey + ) + + rename_constraint_if_exists( + :nerve_settings, + :password_vault_settings_user_id_fkey, + :nerve_settings_user_id_fkey + ) + end + + def down do + rename_constraint_if_exists( + :nerve_settings, + :nerve_settings_pkey, + :password_vault_settings_pkey + ) + + rename_constraint_if_exists(:nerve_entries, :nerve_entries_pkey, :password_vault_entries_pkey) + + rename_constraint_if_exists( + :nerve_settings, + :nerve_settings_user_id_fkey, + :password_vault_settings_user_id_fkey + ) + + rename_constraint_if_exists( + :nerve_entries, + :nerve_entries_user_id_fkey, + :password_vault_entries_user_id_fkey + ) + + rename_index_if_exists(:nerve_settings_user_id_index, :password_vault_settings_user_id_index) + + rename_index_if_exists( + :nerve_entries_user_id_inserted_at_index, + :password_vault_entries_user_id_inserted_at_index + ) + + rename_index_if_exists(:nerve_entries_user_id_index, :password_vault_entries_user_id_index) + + rename_if_exists(:nerve_settings, :password_vault_settings) + rename_if_exists(:nerve_entries, :password_vault_entries) + + rename_sequence_if_exists(:nerve_settings_id_seq, :password_vault_settings_id_seq) + rename_sequence_if_exists(:nerve_entries_id_seq, :password_vault_entries_id_seq) + end + + defp rename_if_exists(from, to) do + execute( + "ALTER TABLE IF EXISTS #{from} RENAME TO #{to}", + "ALTER TABLE IF EXISTS #{to} RENAME TO #{from}" + ) + end + + defp rename_index_if_exists(from, to) do + execute("ALTER INDEX IF EXISTS #{from} RENAME TO #{to}") + end + + defp rename_sequence_if_exists(from, to) do + execute("ALTER SEQUENCE IF EXISTS #{from} RENAME TO #{to}") + end + + defp rename_constraint_if_exists(table, from, to) do + execute(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = '#{from}' + AND conrelid = to_regclass('#{table}') + ) THEN + ALTER TABLE #{table} RENAME CONSTRAINT #{from} TO #{to}; + END IF; + END + $$; + """) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260517043000_add_site_session_rollup_indexes_for_analytics.exs b/apps/elektrine/priv/repo/migrations/20260517043000_add_site_session_rollup_indexes_for_analytics.exs new file mode 100644 index 0000000..ed1dc52 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260517043000_add_site_session_rollup_indexes_for_analytics.exs @@ -0,0 +1,27 @@ +defmodule Elektrine.Repo.Migrations.AddSiteSessionRollupIndexesForAnalytics do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS site_sessions_host_stats_idx + ON site_sessions (entry_host) + INCLUDE (page_views, started_at, duration_seconds) + WHERE entry_host IS NOT NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS site_sessions_host_path_stats_idx + ON site_sessions (entry_host, entry_path) + INCLUDE (page_views, started_at) + WHERE entry_host IS NOT NULL + """) + end + + def down do + execute("DROP INDEX CONCURRENTLY IF EXISTS site_sessions_host_path_stats_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS site_sessions_host_stats_idx") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260518120000_add_global_atomine_proof_claim_grant_index.exs b/apps/elektrine/priv/repo/migrations/20260518120000_add_global_atomine_proof_claim_grant_index.exs new file mode 100644 index 0000000..7cdad28 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260518120000_add_global_atomine_proof_claim_grant_index.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.AddGlobalAtomineProofClaimGrantIndex do + use Ecto.Migration + + def change do + create unique_index( + :atomine_credit_ledger_entries, + [:credit_type, :reference_type, :reference_id], + name: :atomine_credit_ledger_entries_global_proof_claim_index, + where: + "amount > 0 AND credit_type = 'atomine_credit' AND reference_type = 'atomine_proof_claim'" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260520061000_create_static_site_deployments.exs b/apps/elektrine/priv/repo/migrations/20260520061000_create_static_site_deployments.exs new file mode 100644 index 0000000..47c66db --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260520061000_create_static_site_deployments.exs @@ -0,0 +1,21 @@ +defmodule Elektrine.Repo.Migrations.CreateStaticSiteDeployments do + use Ecto.Migration + + def change do + create table(:static_site_deployments) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :provider, :string, null: false, default: "github" + add :repo_owner, :string, null: false + add :repo_name, :string, null: false + add :branch, :string, null: false, default: "main" + add :site_dir, :string, null: false, default: "auto" + add :build_command, :text + add :last_deployed_at, :utc_datetime + + timestamps() + end + + create index(:static_site_deployments, [:user_id]) + create unique_index(:static_site_deployments, [:provider, :repo_owner, :repo_name]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260520062000_add_github_webhook_fields_to_static_site_deployments.exs b/apps/elektrine/priv/repo/migrations/20260520062000_add_github_webhook_fields_to_static_site_deployments.exs new file mode 100644 index 0000000..a4ee34f --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260520062000_add_github_webhook_fields_to_static_site_deployments.exs @@ -0,0 +1,21 @@ +defmodule Elektrine.Repo.Migrations.AddGithubWebhookFieldsToStaticSiteDeployments do + use Ecto.Migration + + def change do + alter table(:static_site_deployments) do + add :webhook_secret, :string + add :webhook_id, :string + add :deploy_status, :string, null: false, default: "idle" + add :last_deploy_error, :text + end + + execute( + "UPDATE static_site_deployments SET webhook_secret = md5(random()::text || clock_timestamp()::text) WHERE webhook_secret IS NULL", + "" + ) + + alter table(:static_site_deployments) do + modify :webhook_secret, :string, null: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260523214500_rename_atomine_shield_to_gate_in_dns_metadata.exs b/apps/elektrine/priv/repo/migrations/20260523214500_rename_atomine_shield_to_gate_in_dns_metadata.exs new file mode 100644 index 0000000..7959613 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260523214500_rename_atomine_shield_to_gate_in_dns_metadata.exs @@ -0,0 +1,29 @@ +defmodule Elektrine.Repo.Migrations.RenameAtomineShieldToGateInDNSMetadata do + use Ecto.Migration + + def up do + execute(""" + UPDATE dns_records + SET metadata = jsonb_set( + metadata #- '{proxy,atomine_shield}', + '{proxy,atomine_gate}', + metadata #> '{proxy,atomine_shield}', + true + ) + WHERE metadata #> '{proxy,atomine_shield}' IS NOT NULL + """) + end + + def down do + execute(""" + UPDATE dns_records + SET metadata = jsonb_set( + metadata #- '{proxy,atomine_gate}', + '{proxy,atomine_shield}', + metadata #> '{proxy,atomine_gate}', + true + ) + WHERE metadata #> '{proxy,atomine_gate}' IS NOT NULL + """) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260601000100_default_built_in_subdomain_mode_to_path.exs b/apps/elektrine/priv/repo/migrations/20260601000100_default_built_in_subdomain_mode_to_path.exs new file mode 100644 index 0000000..b005d34 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260601000100_default_built_in_subdomain_mode_to_path.exs @@ -0,0 +1,15 @@ +defmodule Elektrine.Repo.Migrations.DefaultBuiltInSubdomainModeToPath do + use Ecto.Migration + + def up do + alter table(:users) do + modify :built_in_subdomain_mode, :string, null: false, default: "path" + end + end + + def down do + alter table(:users) do + modify :built_in_subdomain_mode, :string, null: false, default: "platform" + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260603180000_restore_existing_platform_profile_subdomains.exs b/apps/elektrine/priv/repo/migrations/20260603180000_restore_existing_platform_profile_subdomains.exs new file mode 100644 index 0000000..1770a36 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260603180000_restore_existing_platform_profile_subdomains.exs @@ -0,0 +1,16 @@ +defmodule Elektrine.Repo.Migrations.RestoreExistingPlatformProfileSubdomains do + use Ecto.Migration + + def up do + execute(""" + UPDATE users + SET built_in_subdomain_mode = 'platform' + WHERE built_in_subdomain_mode = 'path' + AND inserted_at < TIMESTAMP '2026-06-01 00:01:00' + """) + end + + def down do + :ok + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260604120000_add_spam_filter_enabled_to_mailboxes.exs b/apps/elektrine/priv/repo/migrations/20260604120000_add_spam_filter_enabled_to_mailboxes.exs new file mode 100644 index 0000000..cc46034 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260604120000_add_spam_filter_enabled_to_mailboxes.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddSpamFilterEnabledToMailboxes do + use Ecto.Migration + + def change do + alter table(:email_mailboxes) do + add :spam_filter_enabled, :boolean, null: false, default: true + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260604121000_add_auto_reply_enabled_to_mailboxes.exs b/apps/elektrine/priv/repo/migrations/20260604121000_add_auto_reply_enabled_to_mailboxes.exs new file mode 100644 index 0000000..674a05a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260604121000_add_auto_reply_enabled_to_mailboxes.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddAutoReplyEnabledToMailboxes do + use Ecto.Migration + + def change do + alter table(:email_mailboxes) do + add :auto_reply_enabled, :boolean, null: false, default: true + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260606170000_add_oban_and_timeline_hot_path_indexes.exs b/apps/elektrine/priv/repo/migrations/20260606170000_add_oban_and_timeline_hot_path_indexes.exs new file mode 100644 index 0000000..00c37c4 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260606170000_add_oban_and_timeline_hot_path_indexes.exs @@ -0,0 +1,82 @@ +defmodule Elektrine.Repo.Migrations.AddObanAndTimelineHotPathIndexes do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS oban_jobs_available_queue_fetch_idx + ON oban_jobs (queue, priority, scheduled_at, id) + WHERE state = 'available' AND queue IS NOT NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS oban_jobs_prune_completed_scheduled_idx + ON oban_jobs (scheduled_at, id) + WHERE state = 'completed' AND queue IS NOT NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS oban_jobs_prune_cancelled_at_idx + ON oban_jobs (cancelled_at, id) + WHERE state = 'cancelled' AND queue IS NOT NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS oban_jobs_prune_discarded_at_idx + ON oban_jobs (discarded_at, id) + WHERE state = 'discarded' AND queue IS NOT NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS oban_jobs_worker_queue_state_inserted_idx + ON oban_jobs (worker, queue, state, inserted_at DESC) + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS oban_jobs_args_jsonb_path_idx + ON oban_jobs USING GIN (args jsonb_path_ops) + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_public_top_community_conversation_id_idx + ON social_messages (conversation_id, id DESC) + WHERE visibility = 'public' + AND is_draft IS NOT TRUE + AND deleted_at IS NULL + AND reply_to_id IS NULL + AND (approval_status = 'approved' OR approval_status IS NULL) + AND (media_metadata->>'inReplyTo') IS NULL + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_public_top_community_actor_id_idx_v2 + ON social_messages ((media_metadata->>'community_actor_uri'), id DESC) + WHERE visibility = 'public' + AND is_draft IS NOT TRUE + AND deleted_at IS NULL + AND reply_to_id IS NULL + AND (approval_status = 'approved' OR approval_status IS NULL) + AND (media_metadata->>'inReplyTo') IS NULL + AND (media_metadata->>'community_actor_uri') IS NOT NULL + """) + end + + def down do + execute( + "DROP INDEX CONCURRENTLY IF EXISTS social_messages_public_top_community_actor_id_idx_v2" + ) + + execute( + "DROP INDEX CONCURRENTLY IF EXISTS social_messages_public_top_community_conversation_id_idx" + ) + + execute("DROP INDEX CONCURRENTLY IF EXISTS oban_jobs_args_jsonb_path_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS oban_jobs_worker_queue_state_inserted_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS oban_jobs_prune_discarded_at_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS oban_jobs_prune_cancelled_at_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS oban_jobs_prune_completed_scheduled_idx") + execute("DROP INDEX CONCURRENTLY IF EXISTS oban_jobs_available_queue_fetch_idx") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260613000000_add_failing_since_to_custom_domains.exs b/apps/elektrine/priv/repo/migrations/20260613000000_add_failing_since_to_custom_domains.exs new file mode 100644 index 0000000..990cac6 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260613000000_add_failing_since_to_custom_domains.exs @@ -0,0 +1,13 @@ +defmodule Elektrine.Repo.Migrations.AddFailingSinceToCustomDomains do + use Ecto.Migration + + def change do + alter table(:email_custom_domains) do + add :failing_since, :utc_datetime + end + + alter table(:profile_custom_domains) do + add :failing_since, :utc_datetime + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260620000000_backfill_orphaned_thread_messages.exs b/apps/elektrine/priv/repo/migrations/20260620000000_backfill_orphaned_thread_messages.exs new file mode 100644 index 0000000..d39d74d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260620000000_backfill_orphaned_thread_messages.exs @@ -0,0 +1,47 @@ +defmodule Elektrine.Repo.Migrations.BackfillOrphanedThreadMessages do + use Ecto.Migration + + @moduledoc """ + Links messages that were left without a thread (thread_id IS NULL) into the + thread of any later message in the same mailbox that references their Message-ID + via In-Reply-To or References. + + This recovers conversation starters that were orphaned before Message-ID + reconciliation landed for internal delivery: the reply existed and was threaded, + but the starter never got attached because the linking header pointed at it. + + Header-based only (no subject-hash matching), so it never merges unrelated + conversations that merely share a title. `translate(.., '<>', '')` strips any + legacy angle brackets so normalized and bracketed Message-IDs compare equal. + """ + + def up do + execute(""" + UPDATE email_messages AS o + SET thread_id = sub.thread_id + FROM ( + SELECT DISTINCT ON (oo.id) oo.id AS orphan_id, cc.thread_id AS thread_id + FROM email_messages AS oo + JOIN email_messages AS cc + ON cc.mailbox_id = oo.mailbox_id + AND cc.id <> oo.id + AND cc.thread_id IS NOT NULL + AND ( + translate(cc.in_reply_to, '<>', '') = translate(oo.message_id, '<>', '') + OR translate(oo.message_id, '<>', '') = ANY( + regexp_split_to_array(translate(COALESCE(cc."references", ''), '<>', ''), '\\s+') + ) + ) + WHERE oo.thread_id IS NULL + AND oo.message_id IS NOT NULL + AND oo.message_id <> '' + ORDER BY oo.id, cc.inserted_at, cc.id + ) AS sub + WHERE o.id = sub.orphan_id + """) + end + + def down do + :ok + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260621000000_create_uptime_monitors.exs b/apps/elektrine/priv/repo/migrations/20260621000000_create_uptime_monitors.exs new file mode 100644 index 0000000..e484161 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260621000000_create_uptime_monitors.exs @@ -0,0 +1,36 @@ +defmodule Elektrine.Repo.Migrations.CreateUptimeMonitors do + use Ecto.Migration + + def change do + create table(:uptime_monitors) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :name, :string, null: false + add :check_type, :string, null: false + add :target, :string, null: false + add :port, :integer + add :expected_status, :integer, default: 200 + add :keyword, :string + add :interval_seconds, :integer, default: 300, null: false + add :timeout_ms, :integer, default: 10_000, null: false + add :enabled, :boolean, default: true, null: false + add :last_status, :string + add :last_checked_at, :utc_datetime + add :consecutive_failures, :integer, default: 0, null: false + add :failure_threshold, :integer, default: 2, null: false + add :notify_email, :boolean, default: false, null: false + add :notify_in_app, :boolean, default: true, null: false + add :public_slug, :string + add :visibility, :string, default: "private", null: false + + timestamps(type: :utc_datetime) + end + + create index(:uptime_monitors, [:user_id]) + create index(:uptime_monitors, [:enabled, :last_checked_at]) + + create unique_index(:uptime_monitors, [:public_slug], + where: "public_slug IS NOT NULL", + name: :uptime_monitors_public_slug_unique + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260621000100_create_uptime_checks.exs b/apps/elektrine/priv/repo/migrations/20260621000100_create_uptime_checks.exs new file mode 100644 index 0000000..c8c9b36 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260621000100_create_uptime_checks.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.CreateUptimeChecks do + use Ecto.Migration + + def change do + create table(:uptime_checks) do + add :monitor_id, references(:uptime_monitors, on_delete: :delete_all), null: false + add :status, :string, null: false + add :response_time_ms, :integer + add :status_code, :integer + add :error, :string + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:uptime_checks, [:monitor_id, :inserted_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260621000200_create_uptime_incidents.exs b/apps/elektrine/priv/repo/migrations/20260621000200_create_uptime_incidents.exs new file mode 100644 index 0000000..ec63062 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260621000200_create_uptime_incidents.exs @@ -0,0 +1,21 @@ +defmodule Elektrine.Repo.Migrations.CreateUptimeIncidents do + use Ecto.Migration + + def change do + create table(:uptime_incidents) do + add :monitor_id, references(:uptime_monitors, on_delete: :delete_all), null: false + add :started_at, :utc_datetime, null: false + add :resolved_at, :utc_datetime + add :last_error, :string + + timestamps(type: :utc_datetime) + end + + create index(:uptime_incidents, [:monitor_id]) + + create unique_index(:uptime_incidents, [:monitor_id], + where: "resolved_at IS NULL", + name: :uptime_incidents_open_unique + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260623000000_add_domain_identity_to_oauth_grants.exs b/apps/elektrine/priv/repo/migrations/20260623000000_add_domain_identity_to_oauth_grants.exs new file mode 100644 index 0000000..05df181 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260623000000_add_domain_identity_to_oauth_grants.exs @@ -0,0 +1,20 @@ +defmodule Elektrine.Repo.Migrations.AddDomainIdentityToOAuthGrants do + use Ecto.Migration + + def change do + alter table(:oauth_authorizations) do + add :identity_subject, :string + add :identity_domain, :string + add :identity_did, :string + end + + alter table(:oauth_tokens) do + add :identity_subject, :string + add :identity_domain, :string + add :identity_did, :string + end + + create index(:oauth_authorizations, [:identity_subject]) + create index(:oauth_tokens, [:identity_subject]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260623000100_create_profile_per_site_identities.exs b/apps/elektrine/priv/repo/migrations/20260623000100_create_profile_per_site_identities.exs new file mode 100644 index 0000000..8cde585 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260623000100_create_profile_per_site_identities.exs @@ -0,0 +1,28 @@ +defmodule Elektrine.Repo.Migrations.CreateProfilePerSiteIdentities do + use Ecto.Migration + + def change do + create table(:profile_per_site_identities) do + add :site_key, :string, null: false + add :base_domain, :string, null: false + add :domain, :string, null: false + add :subject, :string, null: false + add :did, :string, null: false + add :email_alias, :string, null: false + add :display_name, :string + add :avatar, :string + add :claims, :map, default: %{}, null: false + add :enabled, :boolean, default: true, null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:profile_per_site_identities, [:user_id]) + create index(:profile_per_site_identities, [:domain]) + + create unique_index(:profile_per_site_identities, [:user_id, :base_domain, :site_key], + name: :profile_per_site_identities_user_base_site_unique + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260623000200_hash_and_encrypt_share_tokens.exs b/apps/elektrine/priv/repo/migrations/20260623000200_hash_and_encrypt_share_tokens.exs new file mode 100644 index 0000000..2a8349d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260623000200_hash_and_encrypt_share_tokens.exs @@ -0,0 +1,122 @@ +defmodule Elektrine.Repo.Migrations.HashAndEncryptShareTokens do + use Ecto.Migration + + import Ecto.Query + + alias Elektrine.Secrets.EncryptedString + + defmodule DriveShare do + use Ecto.Schema + + schema "drive_shares" do + field(:token, :string) + field(:token_hash, :string) + end + end + + defmodule NoteShare do + use Ecto.Schema + + schema "note_shares" do + field(:token, :string) + field(:token_hash, :string) + end + end + + def up do + alter table(:drive_shares) do + add(:token_hash, :string) + end + + alter table(:note_shares) do + add(:token_hash, :string) + end + + flush() + + backfill_share_tokens(DriveShare) + backfill_share_tokens(NoteShare) + + alter table(:drive_shares) do + modify(:token_hash, :string, null: false) + end + + alter table(:note_shares) do + modify(:token_hash, :string, null: false) + end + + create(unique_index(:drive_shares, [:token_hash])) + create(unique_index(:note_shares, [:token_hash])) + end + + def down do + drop_if_exists(unique_index(:note_shares, [:token_hash])) + drop_if_exists(unique_index(:drive_shares, [:token_hash])) + + restore_share_tokens(NoteShare) + restore_share_tokens(DriveShare) + + alter table(:note_shares) do + remove(:token_hash) + end + + alter table(:drive_shares) do + remove(:token_hash) + end + end + + defp backfill_share_tokens(schema) do + repo().transaction(fn -> + schema + |> repo().all() + |> Enum.each(fn share -> + token = plaintext_token!(share.token) + + repo().update_all( + from(s in schema, where: s.id == ^share.id), + set: [token: encrypted_token!(token), token_hash: hash_token(token)] + ) + end) + end) + end + + defp restore_share_tokens(schema) do + repo().transaction(fn -> + schema + |> repo().all() + |> Enum.each(fn share -> + repo().update_all( + from(s in schema, where: s.id == ^share.id), + set: [token: plaintext_token!(share.token)] + ) + end) + end) + end + + defp plaintext_token!(token) when is_binary(token) do + case EncryptedString.decrypt(token) do + {:ok, plaintext} -> plaintext + :error -> token + end + end + + defp plaintext_token!(_token), do: raise("share token is missing") + + defp encrypted_token!(token) do + if EncryptedString.encrypted?(token) do + token + else + case EncryptedString.encrypt(token) do + {:ok, encrypted} -> encrypted + :error -> raise("could not encrypt share token") + end + end + end + + defp hash_token(token) when is_binary(token) do + token + |> String.trim() + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260623000300_encrypt_webhook_secrets.exs b/apps/elektrine/priv/repo/migrations/20260623000300_encrypt_webhook_secrets.exs new file mode 100644 index 0000000..e265a6f --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260623000300_encrypt_webhook_secrets.exs @@ -0,0 +1,88 @@ +defmodule Elektrine.Repo.Migrations.EncryptWebhookSecrets do + use Ecto.Migration + + import Ecto.Query + + alias Elektrine.Secrets.EncryptedString + + defmodule DeveloperWebhook do + use Ecto.Schema + + schema "developer_webhooks" do + field(:secret, :string) + end + end + + defmodule StaticSiteDeployment do + use Ecto.Schema + + schema "static_site_deployments" do + field(:webhook_secret, :string) + end + end + + def up do + encrypt_secret_field(DeveloperWebhook, :secret) + encrypt_secret_field(StaticSiteDeployment, :webhook_secret) + end + + def down do + decrypt_secret_field(StaticSiteDeployment, :webhook_secret) + decrypt_secret_field(DeveloperWebhook, :secret) + end + + defp encrypt_secret_field(schema, field) do + repo().transaction(fn -> + schema + |> repo().all() + |> Enum.each(fn row -> + value = Map.get(row, field) + + if present?(value) do + repo().update_all( + from(r in schema, where: r.id == ^row.id), + set: [{field, encrypted_secret!(value)}] + ) + end + end) + end) + end + + defp decrypt_secret_field(schema, field) do + repo().transaction(fn -> + schema + |> repo().all() + |> Enum.each(fn row -> + value = Map.get(row, field) + + if present?(value) do + repo().update_all( + from(r in schema, where: r.id == ^row.id), + set: [{field, plaintext_secret(value)}] + ) + end + end) + end) + end + + defp encrypted_secret!(value) when is_binary(value) do + if EncryptedString.encrypted?(value) do + value + else + case EncryptedString.encrypt(value) do + {:ok, encrypted} -> encrypted + :error -> raise("could not encrypt webhook secret") + end + end + end + + defp plaintext_secret(value) when is_binary(value) do + case EncryptedString.decrypt(value) do + {:ok, plaintext} -> plaintext + :error -> value + end + end + + defp present?(value) when is_binary(value), do: String.trim(value) != "" + defp present?(_value), do: false +end diff --git a/apps/elektrine/priv/repo/migrations/20260623000400_hash_email_unsubscribe_tokens.exs b/apps/elektrine/priv/repo/migrations/20260623000400_hash_email_unsubscribe_tokens.exs new file mode 100644 index 0000000..aefbc1f --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260623000400_hash_email_unsubscribe_tokens.exs @@ -0,0 +1,45 @@ +defmodule Elektrine.Repo.Migrations.HashEmailUnsubscribeTokens do + use Ecto.Migration + + import Ecto.Query + + defmodule EmailUnsubscribe do + use Ecto.Schema + + schema "email_unsubscribes" do + field(:token, :string) + end + end + + def up do + repo().transaction(fn -> + EmailUnsubscribe + |> repo().all() + |> Enum.each(fn unsubscribe -> + token = unsubscribe.token + + if is_binary(token) and not hashed_token?(token) do + repo().update_all( + from(u in EmailUnsubscribe, where: u.id == ^unsubscribe.id), + set: [token: hash_token(token)] + ) + end + end) + end) + end + + def down do + :ok + end + + defp hash_token(token) when is_binary(token) do + token + |> String.trim() + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + end + + defp hashed_token?(token) do + Regex.match?(~r/^[0-9a-f]{64}$/, token) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260623000500_hash_and_encrypt_device_tokens.exs b/apps/elektrine/priv/repo/migrations/20260623000500_hash_and_encrypt_device_tokens.exs new file mode 100644 index 0000000..da13166 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260623000500_hash_and_encrypt_device_tokens.exs @@ -0,0 +1,89 @@ +defmodule Elektrine.Repo.Migrations.HashAndEncryptDeviceTokens do + use Ecto.Migration + + import Ecto.Query + + alias Elektrine.Secrets.EncryptedString + + defmodule DeviceToken do + use Ecto.Schema + + schema "device_tokens" do + field(:token, :string) + field(:token_hash, :string) + end + end + + def up do + alter table(:device_tokens) do + add(:token_hash, :string) + end + + flush() + + repo().transaction(fn -> + DeviceToken + |> repo().all() + |> Enum.each(fn device -> + token = plaintext_token!(device.token) + + repo().update_all( + from(d in DeviceToken, where: d.id == ^device.id), + set: [token: encrypted_token!(token), token_hash: hash_token(token)] + ) + end) + end) + + alter table(:device_tokens) do + modify(:token_hash, :string, null: false) + end + + create(unique_index(:device_tokens, [:token_hash])) + end + + def down do + drop_if_exists(unique_index(:device_tokens, [:token_hash])) + + repo().transaction(fn -> + DeviceToken + |> repo().all() + |> Enum.each(fn device -> + repo().update_all( + from(d in DeviceToken, where: d.id == ^device.id), + set: [token: plaintext_token!(device.token)] + ) + end) + end) + + alter table(:device_tokens) do + remove(:token_hash) + end + end + + defp plaintext_token!(token) when is_binary(token) do + case EncryptedString.decrypt(token) do + {:ok, plaintext} -> plaintext + :error -> token + end + end + + defp plaintext_token!(_token), do: raise("device token is missing") + + defp encrypted_token!(token) do + if EncryptedString.encrypted?(token) do + token + else + case EncryptedString.encrypt(token) do + {:ok, encrypted} -> encrypted + :error -> raise("could not encrypt device token") + end + end + end + + defp hash_token(token) when is_binary(token) do + token + |> String.trim() + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260628000000_create_kairo_tables.exs b/apps/elektrine/priv/repo/migrations/20260628000000_create_kairo_tables.exs new file mode 100644 index 0000000..be5e3b2 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260628000000_create_kairo_tables.exs @@ -0,0 +1,44 @@ +defmodule Elektrine.Repo.Migrations.CreateKairoTables do + use Ecto.Migration + + def change do + create table(:kairo_projects) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :name, :string, null: false + add :slug, :string, null: false + add :description, :text + add :status, :string, null: false, default: "active" + add :autonomy_level, :integer, null: false, default: 1 + + timestamps(type: :utc_datetime) + end + + create index(:kairo_projects, [:user_id]) + create unique_index(:kairo_projects, [:user_id, :slug]) + + create table(:kairo_sources) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :project_id, references(:kairo_projects, on_delete: :delete_all) + add :source_type, :string, null: false + add :title, :string + add :url, :text + add :content, :text + add :content_format, :string + add :status, :string, null: false, default: "received" + add :tags, {:array, :string}, null: false, default: [] + add :metadata, :map, null: false, default: %{} + add :raw_hash, :string + add :error_message, :text + add :ingested_at, :utc_datetime + add :processed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create index(:kairo_sources, [:user_id]) + create index(:kairo_sources, [:project_id]) + create index(:kairo_sources, [:source_type]) + create index(:kairo_sources, [:status]) + create index(:kairo_sources, [:raw_hash]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260628120000_create_account_master_keys.exs b/apps/elektrine/priv/repo/migrations/20260628120000_create_account_master_keys.exs new file mode 100644 index 0000000..36d9bdb --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260628120000_create_account_master_keys.exs @@ -0,0 +1,18 @@ +defmodule Elektrine.Repo.Migrations.CreateAccountMasterKeys do + use Ecto.Migration + + def change do + create table(:account_master_keys) do + add :user_id, references(:users, on_delete: :delete_all), null: false + # The user's Master Data Key, wrapped under a passphrase-derived KEK and, + # separately, under a recovery-code-derived KEK. The server only ever sees + # these wrapped blobs (zero-knowledge); it never holds the passphrase or MDK. + add :wrapped_dek, :map, null: false + add :wrapped_dek_recovery, :map, null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:account_master_keys, [:user_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260628130000_add_kairo_source_encryption.exs b/apps/elektrine/priv/repo/migrations/20260628130000_add_kairo_source_encryption.exs new file mode 100644 index 0000000..d7f0716 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260628130000_add_kairo_source_encryption.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.AddKairoSourceEncryption do + use Ecto.Migration + + def change do + alter table(:kairo_sources) do + add :encrypted, :boolean, null: false, default: false + add :encrypted_content, :map + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260629120000_add_kairo_content_at_rest.exs b/apps/elektrine/priv/repo/migrations/20260629120000_add_kairo_content_at_rest.exs new file mode 100644 index 0000000..983e13f --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260629120000_add_kairo_content_at_rest.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddKairoContentAtRest do + use Ecto.Migration + + def change do + alter table(:kairo_sources) do + add :content_encrypted, :map + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260629140000_drop_notes_tables.exs b/apps/elektrine/priv/repo/migrations/20260629140000_drop_notes_tables.exs new file mode 100644 index 0000000..7e63667 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260629140000_drop_notes_tables.exs @@ -0,0 +1,43 @@ +defmodule Elektrine.Repo.Migrations.DropNotesTables do + use Ecto.Migration + + def up do + drop_if_exists table(:note_shares) + drop_if_exists table(:notes) + end + + def down do + create table(:notes) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :title, :text + add :body, :text, null: false, default: "" + add :pinned, :boolean, null: false, default: false + + timestamps(type: :utc_datetime) + end + + create index(:notes, [:user_id]) + create index(:notes, [:user_id, :pinned]) + create index(:notes, [:user_id, :updated_at]) + + create table(:note_shares) do + add :note_id, references(:notes, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + add :token, :string, null: false + add :token_hash, :string, null: false + add :encrypted_payload, :map + add :expires_at, :utc_datetime + add :burn_after_read, :boolean, null: false, default: false + add :revoked_at, :utc_datetime + add :view_count, :integer, null: false, default: 0 + + timestamps(type: :utc_datetime) + end + + create unique_index(:note_shares, [:token]) + create unique_index(:note_shares, [:token_hash]) + create index(:note_shares, [:note_id]) + create index(:note_shares, [:user_id, :note_id]) + create index(:note_shares, [:expires_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260630000000_add_remote_engagement_counts_to_social_messages.exs b/apps/elektrine/priv/repo/migrations/20260630000000_add_remote_engagement_counts_to_social_messages.exs new file mode 100644 index 0000000..5fbdf05 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260630000000_add_remote_engagement_counts_to_social_messages.exs @@ -0,0 +1,74 @@ +defmodule Elektrine.Repo.Migrations.AddRemoteEngagementCountsToSocialMessages do + use Ecto.Migration + + def up do + alter table(:social_messages) do + add :remote_like_count, :integer + add :remote_reply_count, :integer + add :remote_share_count, :integer + add :remote_quote_count, :integer + add :remote_counts_fetched_at, :utc_datetime + end + + # Databases where messages_activitypub_id_index went INVALID (a failed + # concurrent build) accumulated duplicate activitypub_id rows, and the + # still-enforcing index then rejects ANY row update that touches them. + # Soft-delete every duplicate except the oldest and clear its refs before + # the backfill below rewrites those rows. Setting activitypub_id to NULL + # keeps the row outside the partial unique index, so this update itself + # cannot conflict. + execute(""" + WITH duplicate_refs AS ( + SELECT activitypub_id + FROM social_messages + WHERE activitypub_id IS NOT NULL + GROUP BY activitypub_id + HAVING COUNT(*) > 1 + ), + ranked AS ( + SELECT m.id, + ROW_NUMBER() OVER (PARTITION BY m.activitypub_id ORDER BY m.id) AS row_rank + FROM social_messages m + JOIN duplicate_refs d ON d.activitypub_id = m.activitypub_id + ) + UPDATE social_messages m + SET activitypub_id = NULL, + activitypub_id_canonical = NULL, + deleted_at = COALESCE(m.deleted_at, NOW()) + FROM ranked r + WHERE m.id = r.id + AND r.row_rank > 1 + """) + + execute(""" + UPDATE social_messages + SET + remote_like_count = CASE + WHEN media_metadata->>'original_like_count' ~ '^[0-9]+$' + THEN (media_metadata->>'original_like_count')::integer + ELSE remote_like_count + END, + remote_reply_count = CASE + WHEN media_metadata->>'original_reply_count' ~ '^[0-9]+$' + THEN (media_metadata->>'original_reply_count')::integer + ELSE remote_reply_count + END, + remote_share_count = CASE + WHEN media_metadata->>'original_share_count' ~ '^[0-9]+$' + THEN (media_metadata->>'original_share_count')::integer + ELSE remote_share_count + END + WHERE media_metadata IS NOT NULL + """) + end + + def down do + alter table(:social_messages) do + remove :remote_counts_fetched_at + remove :remote_quote_count + remove :remote_share_count + remove :remote_reply_count + remove :remote_like_count + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260630001000_create_social_message_stats.exs b/apps/elektrine/priv/repo/migrations/20260630001000_create_social_message_stats.exs new file mode 100644 index 0000000..878ef13 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260630001000_create_social_message_stats.exs @@ -0,0 +1,67 @@ +defmodule Elektrine.Repo.Migrations.CreateSocialMessageStats do + use Ecto.Migration + + def change do + create table(:social_message_stats) do + add :message_id, references(:social_messages, on_delete: :delete_all), null: false + add :like_count, :integer, null: false, default: 0 + add :reply_count, :integer, null: false, default: 0 + add :share_count, :integer, null: false, default: 0 + add :quote_count, :integer, null: false, default: 0 + add :remote_like_count, :integer + add :remote_reply_count, :integer + add :remote_share_count, :integer + add :remote_quote_count, :integer + add :remote_counts_fetched_at, :utc_datetime + + timestamps() + end + + create unique_index(:social_message_stats, [:message_id]) + create index(:social_message_stats, [:remote_counts_fetched_at]) + + execute(""" + INSERT INTO social_message_stats ( + message_id, + like_count, + reply_count, + share_count, + quote_count, + remote_like_count, + remote_reply_count, + remote_share_count, + remote_quote_count, + remote_counts_fetched_at, + inserted_at, + updated_at + ) + SELECT + id, + GREATEST(COALESCE(like_count, 0), 0), + GREATEST(COALESCE(reply_count, 0), 0), + GREATEST(COALESCE(share_count, 0), 0), + GREATEST(COALESCE(quote_count, 0), 0), + CASE + WHEN remote_like_count IS NULL OR remote_like_count <= 0 THEN NULL + ELSE LEAST(remote_like_count, 100000000) + END, + CASE + WHEN remote_reply_count IS NULL OR remote_reply_count <= 0 THEN NULL + ELSE LEAST(remote_reply_count, 100000000) + END, + CASE + WHEN remote_share_count IS NULL OR remote_share_count <= 0 THEN NULL + ELSE LEAST(remote_share_count, 100000000) + END, + CASE + WHEN remote_quote_count IS NULL OR remote_quote_count <= 0 THEN NULL + ELSE LEAST(remote_quote_count, 100000000) + END, + remote_counts_fetched_at, + NOW(), + NOW() + FROM social_messages + ON CONFLICT (message_id) DO NOTHING + """) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260630001100_guard_boosts_against_deleted_messages.exs b/apps/elektrine/priv/repo/migrations/20260630001100_guard_boosts_against_deleted_messages.exs new file mode 100644 index 0000000..75e0782 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260630001100_guard_boosts_against_deleted_messages.exs @@ -0,0 +1,46 @@ +defmodule Elektrine.Repo.Migrations.GuardBoostsAgainstDeletedMessages do + use Ecto.Migration + + def up do + execute(""" + CREATE OR REPLACE FUNCTION reject_deleted_message_boost() + RETURNS trigger AS $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM social_messages + WHERE id = NEW.message_id + AND deleted_at IS NOT NULL + ) THEN + RAISE EXCEPTION 'cannot boost deleted social_message %', NEW.message_id + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + """) + + execute("DROP TRIGGER IF EXISTS reject_deleted_message_post_boost ON post_boosts;") + + execute(""" + CREATE TRIGGER reject_deleted_message_post_boost + BEFORE INSERT OR UPDATE OF message_id ON post_boosts + FOR EACH ROW EXECUTE FUNCTION reject_deleted_message_boost(); + """) + + execute("DROP TRIGGER IF EXISTS reject_deleted_message_federated_boost ON federated_boosts;") + + execute(""" + CREATE TRIGGER reject_deleted_message_federated_boost + BEFORE INSERT OR UPDATE OF message_id ON federated_boosts + FOR EACH ROW EXECUTE FUNCTION reject_deleted_message_boost(); + """) + end + + def down do + execute("DROP TRIGGER IF EXISTS reject_deleted_message_post_boost ON post_boosts;") + execute("DROP TRIGGER IF EXISTS reject_deleted_message_federated_boost ON federated_boosts;") + execute("DROP FUNCTION IF EXISTS reject_deleted_message_boost();") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260630001200_create_activitypub_tombstones.exs b/apps/elektrine/priv/repo/migrations/20260630001200_create_activitypub_tombstones.exs new file mode 100644 index 0000000..7064726 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260630001200_create_activitypub_tombstones.exs @@ -0,0 +1,22 @@ +defmodule Elektrine.Repo.Migrations.CreateActivitypubTombstones do + use Ecto.Migration + + def change do + create table(:activitypub_tombstones) do + add :activity_id, :text + add :actor_uri, :text, null: false + add :object_id, :text, null: false + add :data, :map, null: false, default: %{} + add :received_at, :utc_datetime, null: false + + timestamps() + end + + create unique_index(:activitypub_tombstones, [:actor_uri, :object_id], + name: :activitypub_tombstones_actor_object_unique + ) + + create index(:activitypub_tombstones, [:object_id]) + create index(:activitypub_tombstones, [:actor_uri]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701000000_create_activitypub_object_deliveries.exs b/apps/elektrine/priv/repo/migrations/20260701000000_create_activitypub_object_deliveries.exs new file mode 100644 index 0000000..6e8cd31 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701000000_create_activitypub_object_deliveries.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Repo.Migrations.CreateActivitypubObjectDeliveries do + use Ecto.Migration + + def change do + create table(:activitypub_object_deliveries) do + add :object_id, :text, null: false + add :inbox_url, :text, null: false + add :activity_id, references(:activitypub_activities, on_delete: :nilify_all) + add :first_seen_at, :utc_datetime, null: false + add :last_seen_at, :utc_datetime, null: false + add :last_delivered_at, :utc_datetime + + timestamps() + end + + create unique_index(:activitypub_object_deliveries, [:object_id, :inbox_url], + name: :activitypub_object_deliveries_object_inbox_unique + ) + + create index(:activitypub_object_deliveries, [:object_id]) + create index(:activitypub_object_deliveries, [:inbox_url]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701000100_create_social_thread_mutes.exs b/apps/elektrine/priv/repo/migrations/20260701000100_create_social_thread_mutes.exs new file mode 100644 index 0000000..aee9afc --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701000100_create_social_thread_mutes.exs @@ -0,0 +1,19 @@ +defmodule Elektrine.Repo.Migrations.CreateSocialThreadMutes do + use Ecto.Migration + + def change do + create table(:social_thread_mutes) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :thread_key, :text, null: false + add :message_id, references(:social_messages, on_delete: :delete_all) + + timestamps() + end + + create unique_index(:social_thread_mutes, [:user_id, :thread_key], + name: :social_thread_mutes_user_thread_unique + ) + + create index(:social_thread_mutes, [:message_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701000200_add_group_key_to_notifications.exs b/apps/elektrine/priv/repo/migrations/20260701000200_add_group_key_to_notifications.exs new file mode 100644 index 0000000..89c1255 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701000200_add_group_key_to_notifications.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddGroupKeyToNotifications do + use Ecto.Migration + + def change do + alter table(:notifications) do + add :group_key, :text + end + + create index(:notifications, [:user_id, :group_key, :inserted_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701000300_create_api_markers.exs b/apps/elektrine/priv/repo/migrations/20260701000300_create_api_markers.exs new file mode 100644 index 0000000..aa503f0 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701000300_create_api_markers.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.CreateApiMarkers do + use Ecto.Migration + + def change do + create table(:api_markers) do + add :timeline, :string, null: false + add :last_read_id, :string, null: false + add :version, :integer, null: false, default: 0 + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:api_markers, [:user_id, :timeline]) + create index(:api_markers, [:user_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701000400_create_social_filters.exs b/apps/elektrine/priv/repo/migrations/20260701000400_create_social_filters.exs new file mode 100644 index 0000000..c97d577 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701000400_create_social_filters.exs @@ -0,0 +1,20 @@ +defmodule Elektrine.Repo.Migrations.CreateSocialFilters do + use Ecto.Migration + + def change do + create table(:social_filters) do + add :kind, :string, null: false + add :value, :text + add :contexts, {:array, :string}, null: false, default: [] + add :action, :string, null: false, default: "hide" + add :whole_word, :boolean, null: false, default: false + add :expires_at, :utc_datetime + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:social_filters, [:user_id, :kind]) + create index(:social_filters, [:user_id, :expires_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701000500_add_expiration_to_user_mutes.exs b/apps/elektrine/priv/repo/migrations/20260701000500_add_expiration_to_user_mutes.exs new file mode 100644 index 0000000..c40fa5f --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701000500_add_expiration_to_user_mutes.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddExpirationToUserMutes do + use Ecto.Migration + + def change do + alter table(:user_mutes) do + add :expires_at, :utc_datetime + end + + create index(:user_mutes, [:expires_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701000600_create_account_notes.exs b/apps/elektrine/priv/repo/migrations/20260701000600_create_account_notes.exs new file mode 100644 index 0000000..d7e8e5e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701000600_create_account_notes.exs @@ -0,0 +1,28 @@ +defmodule Elektrine.Repo.Migrations.CreateAccountNotes do + use Ecto.Migration + + def change do + create table(:account_notes) do + add :comment, :text + add :source_user_id, references(:users, on_delete: :delete_all), null: false + add :target_user_id, references(:users, on_delete: :delete_all) + add :target_remote_actor_id, references(:activitypub_actors, on_delete: :delete_all) + + timestamps(type: :utc_datetime) + end + + create unique_index(:account_notes, [:source_user_id, :target_user_id], + where: "target_user_id IS NOT NULL" + ) + + create unique_index(:account_notes, [:source_user_id, :target_remote_actor_id], + where: "target_remote_actor_id IS NOT NULL" + ) + + create constraint(:account_notes, :account_notes_exactly_one_target, + check: + "(target_user_id IS NOT NULL AND target_remote_actor_id IS NULL) OR " <> + "(target_user_id IS NULL AND target_remote_actor_id IS NOT NULL)" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701000700_create_bookmark_folders.exs b/apps/elektrine/priv/repo/migrations/20260701000700_create_bookmark_folders.exs new file mode 100644 index 0000000..1b3a487 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701000700_create_bookmark_folders.exs @@ -0,0 +1,22 @@ +defmodule Elektrine.Repo.Migrations.CreateBookmarkFolders do + use Ecto.Migration + + def change do + create table(:bookmark_folders) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :name, :string, null: false + add :emoji, :string + + timestamps() + end + + create unique_index(:bookmark_folders, [:user_id, :name]) + create index(:bookmark_folders, [:user_id]) + + alter table(:saved_items) do + add :bookmark_folder_id, references(:bookmark_folders, on_delete: :nilify_all) + end + + create index(:saved_items, [:user_id, :bookmark_folder_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701000800_create_web_push_subscriptions.exs b/apps/elektrine/priv/repo/migrations/20260701000800_create_web_push_subscriptions.exs new file mode 100644 index 0000000..8af480c --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701000800_create_web_push_subscriptions.exs @@ -0,0 +1,25 @@ +defmodule Elektrine.Repo.Migrations.CreateWebPushSubscriptions do + use Ecto.Migration + + def change do + create table(:web_push_subscriptions) do + add :endpoint, :text, null: false + add :endpoint_hash, :string, null: false + add :p256dh, :text, null: false + add :auth, :text, null: false + add :alerts, :map, null: false, default: %{} + add :policy, :string, null: false, default: "all" + add :enabled, :boolean, null: false, default: true + add :last_used_at, :utc_datetime + add :failed_count, :integer, null: false, default: 0 + add :last_error, :text + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:web_push_subscriptions, [:endpoint_hash]) + create index(:web_push_subscriptions, [:user_id, :enabled]) + create index(:web_push_subscriptions, [:last_used_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701000900_create_suggested_account_dismissals.exs b/apps/elektrine/priv/repo/migrations/20260701000900_create_suggested_account_dismissals.exs new file mode 100644 index 0000000..e4cc034 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701000900_create_suggested_account_dismissals.exs @@ -0,0 +1,18 @@ +defmodule Elektrine.Repo.Migrations.CreateSuggestedAccountDismissals do + use Ecto.Migration + + def change do + create table(:suggested_account_dismissals) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :suggested_user_id, references(:users, on_delete: :delete_all), null: false + add :dismissed_at, :utc_datetime, null: false + + timestamps(type: :utc_datetime) + end + + create index(:suggested_account_dismissals, [:user_id]) + create index(:suggested_account_dismissals, [:suggested_user_id]) + + create unique_index(:suggested_account_dismissals, [:user_id, :suggested_user_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701001000_create_account_endorsements.exs b/apps/elektrine/priv/repo/migrations/20260701001000_create_account_endorsements.exs new file mode 100644 index 0000000..ac67a0a --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701001000_create_account_endorsements.exs @@ -0,0 +1,38 @@ +defmodule Elektrine.Repo.Migrations.CreateAccountEndorsements do + use Ecto.Migration + + def change do + create table(:account_endorsements) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :endorsed_user_id, references(:users, on_delete: :delete_all) + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all) + + timestamps(type: :utc_datetime) + end + + create constraint( + :account_endorsements, + :account_endorsements_exactly_one_target, + check: + "(endorsed_user_id IS NOT NULL AND remote_actor_id IS NULL) OR (endorsed_user_id IS NULL AND remote_actor_id IS NOT NULL)" + ) + + create constraint( + :account_endorsements, + :account_endorsements_not_self, + check: "endorsed_user_id IS NULL OR user_id <> endorsed_user_id" + ) + + create unique_index(:account_endorsements, [:user_id, :endorsed_user_id], + where: "endorsed_user_id IS NOT NULL", + name: :account_endorsements_user_local_unique_idx + ) + + create unique_index(:account_endorsements, [:user_id, :remote_actor_id], + where: "remote_actor_id IS NOT NULL", + name: :account_endorsements_user_remote_unique_idx + ) + + create index(:account_endorsements, [:user_id, :inserted_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701001100_create_account_subscriptions.exs b/apps/elektrine/priv/repo/migrations/20260701001100_create_account_subscriptions.exs new file mode 100644 index 0000000..402117d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701001100_create_account_subscriptions.exs @@ -0,0 +1,36 @@ +defmodule Elektrine.Repo.Migrations.CreateAccountSubscriptions do + use Ecto.Migration + + def change do + create table(:account_subscriptions) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :subscribed_user_id, references(:users, on_delete: :delete_all) + add :remote_actor_id, references(:activitypub_actors, on_delete: :delete_all) + + timestamps(type: :utc_datetime) + end + + create constraint( + :account_subscriptions, + :account_subscriptions_exactly_one_target, + check: + "(subscribed_user_id IS NOT NULL AND remote_actor_id IS NULL) OR (subscribed_user_id IS NULL AND remote_actor_id IS NOT NULL)" + ) + + create constraint( + :account_subscriptions, + :account_subscriptions_not_self, + check: "subscribed_user_id IS NULL OR user_id <> subscribed_user_id" + ) + + create unique_index(:account_subscriptions, [:user_id, :subscribed_user_id], + where: "subscribed_user_id IS NOT NULL", + name: :account_subscriptions_user_local_unique_idx + ) + + create unique_index(:account_subscriptions, [:user_id, :remote_actor_id], + where: "remote_actor_id IS NOT NULL", + name: :account_subscriptions_user_remote_unique_idx + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701001200_create_client_app_settings.exs b/apps/elektrine/priv/repo/migrations/20260701001200_create_client_app_settings.exs new file mode 100644 index 0000000..4597856 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701001200_create_client_app_settings.exs @@ -0,0 +1,16 @@ +defmodule Elektrine.Repo.Migrations.CreateClientAppSettings do + use Ecto.Migration + + def change do + create table(:client_app_settings) do + add :app, :string, null: false + add :settings, :map, null: false, default: %{} + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:client_app_settings, [:user_id, :app]) + create index(:client_app_settings, [:app]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701001300_create_social_scrobbles.exs b/apps/elektrine/priv/repo/migrations/20260701001300_create_social_scrobbles.exs new file mode 100644 index 0000000..6b0d6a0 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701001300_create_social_scrobbles.exs @@ -0,0 +1,20 @@ +defmodule Elektrine.Repo.Migrations.CreateSocialScrobbles do + use Ecto.Migration + + def change do + create table(:social_scrobbles) do + add :title, :string, null: false + add :artist, :string + add :album, :string + add :length, :integer + add :external_link, :text + add :visibility, :string, null: false, default: "public" + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:social_scrobbles, [:user_id, :inserted_at]) + create index(:social_scrobbles, [:visibility, :inserted_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701001400_add_birthday_fields_to_users.exs b/apps/elektrine/priv/repo/migrations/20260701001400_add_birthday_fields_to_users.exs new file mode 100644 index 0000000..66061e8 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701001400_add_birthday_fields_to_users.exs @@ -0,0 +1,25 @@ +defmodule Elektrine.Repo.Migrations.AddBirthdayFieldsToUsers do + use Ecto.Migration + + def up do + alter table(:users) do + add :birthday, :date + add :show_birthday, :boolean, default: false, null: false + end + + execute(""" + CREATE INDEX users_visible_birthday_month_day_idx + ON users ((date_part('month', birthday)), (date_part('day', birthday))) + WHERE show_birthday = true AND birthday IS NOT NULL + """) + end + + def down do + execute("DROP INDEX IF EXISTS users_visible_birthday_month_day_idx") + + alter table(:users) do + remove :show_birthday + remove :birthday + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701001500_add_account_migration_metadata_to_users.exs b/apps/elektrine/priv/repo/migrations/20260701001500_add_account_migration_metadata_to_users.exs new file mode 100644 index 0000000..5937ff1 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701001500_add_account_migration_metadata_to_users.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.AddAccountMigrationMetadataToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :also_known_as, {:array, :text}, default: [], null: false + add :moved_to, :text + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701001600_add_notification_privacy_settings_to_users.exs b/apps/elektrine/priv/repo/migrations/20260701001600_add_notification_privacy_settings_to_users.exs new file mode 100644 index 0000000..134a73d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701001600_add_notification_privacy_settings_to_users.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Repo.Migrations.AddNotificationPrivacySettingsToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :block_notifications_from_strangers, :boolean, default: false, null: false + add :hide_notification_contents, :boolean, default: false, null: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260701001700_add_account_list_privacy_settings_to_users.exs b/apps/elektrine/priv/repo/migrations/20260701001700_add_account_list_privacy_settings_to_users.exs new file mode 100644 index 0000000..9f5f9c4 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260701001700_add_account_list_privacy_settings_to_users.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddAccountListPrivacySettingsToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :hide_followers, :boolean, default: false, null: false + add :hide_follows, :boolean, default: false, null: false + add :hide_favorites, :boolean, default: false, null: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260704100001_create_messaging_channel_categories.exs b/apps/elektrine/priv/repo/migrations/20260704100001_create_messaging_channel_categories.exs new file mode 100644 index 0000000..0eb5836 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260704100001_create_messaging_channel_categories.exs @@ -0,0 +1,22 @@ +defmodule Elektrine.Repo.Migrations.CreateMessagingChannelCategories do + use Ecto.Migration + + def change do + create table(:messaging_channel_categories) do + add :name, :string, null: false + add :position, :integer, null: false, default: 0 + add :server_id, references(:messaging_servers, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:messaging_channel_categories, [:server_id]) + create index(:messaging_channel_categories, [:server_id, :position]) + + alter table(:chat_conversations) do + add :category_id, references(:messaging_channel_categories, on_delete: :nilify_all) + end + + create index(:chat_conversations, [:category_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260704100002_create_chat_message_pins.exs b/apps/elektrine/priv/repo/migrations/20260704100002_create_chat_message_pins.exs new file mode 100644 index 0000000..a274cf9 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260704100002_create_chat_message_pins.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.CreateChatMessagePins do + use Ecto.Migration + + def change do + create table(:chat_message_pins) do + add :conversation_id, references(:chat_conversations, on_delete: :delete_all), null: false + add :message_id, references(:chat_messages, on_delete: :delete_all), null: false + add :pinned_by_id, references(:users, on_delete: :nilify_all) + + timestamps(type: :utc_datetime, updated_at: false) + end + + create unique_index(:chat_message_pins, [:message_id]) + create index(:chat_message_pins, [:conversation_id, :inserted_at]) + create index(:chat_message_pins, [:pinned_by_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260704120001_create_chat_threads.exs b/apps/elektrine/priv/repo/migrations/20260704120001_create_chat_threads.exs new file mode 100644 index 0000000..b3a26a3 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260704120001_create_chat_threads.exs @@ -0,0 +1,28 @@ +defmodule Elektrine.Repo.Migrations.CreateChatThreads do + use Ecto.Migration + + def change do + create table(:chat_threads) do + add :conversation_id, references(:chat_conversations, on_delete: :delete_all), null: false + add :root_message_id, references(:chat_messages, on_delete: :nilify_all) + add :title, :string, null: false + add :creator_id, references(:users, on_delete: :nilify_all) + add :archived_at, :utc_datetime + add :last_activity_at, :utc_datetime + add :message_count, :integer, null: false, default: 0 + add :federation_id, :string + add :origin_domain, :string + + timestamps(type: :utc_datetime) + end + + create index(:chat_threads, [:conversation_id, :archived_at]) + create index(:chat_threads, [:creator_id]) + create unique_index(:chat_threads, [:root_message_id]) + + # Remote threads are keyed by the id the origin domain assigned them. + create unique_index(:chat_threads, [:federation_id, :origin_domain], + where: "federation_id IS NOT NULL" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260704120002_add_thread_id_to_chat_messages.exs b/apps/elektrine/priv/repo/migrations/20260704120002_add_thread_id_to_chat_messages.exs new file mode 100644 index 0000000..cc1c9f6 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260704120002_add_thread_id_to_chat_messages.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddThreadIdToChatMessages do + use Ecto.Migration + + def change do + alter table(:chat_messages) do + add :thread_id, references(:chat_threads, on_delete: :delete_all) + end + + create index(:chat_messages, [:thread_id], where: "thread_id IS NOT NULL") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260704130001_create_chat_webhooks.exs b/apps/elektrine/priv/repo/migrations/20260704130001_create_chat_webhooks.exs new file mode 100644 index 0000000..d0a5d9e --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260704130001_create_chat_webhooks.exs @@ -0,0 +1,20 @@ +defmodule Elektrine.Repo.Migrations.CreateChatWebhooks do + use Ecto.Migration + + def change do + create table(:chat_webhooks) do + add :conversation_id, references(:chat_conversations, on_delete: :delete_all), null: false + add :creator_id, references(:users, on_delete: :nilify_all) + add :name, :string, null: false + add :avatar_url, :string + add :token_hash, :string, null: false + add :active, :boolean, default: true, null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:chat_webhooks, [:token_hash]) + create index(:chat_webhooks, [:conversation_id]) + create index(:chat_webhooks, [:creator_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260704130002_add_webhook_id_to_chat_messages.exs b/apps/elektrine/priv/repo/migrations/20260704130002_add_webhook_id_to_chat_messages.exs new file mode 100644 index 0000000..1fd13e2 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260704130002_add_webhook_id_to_chat_messages.exs @@ -0,0 +1,11 @@ +defmodule Elektrine.Repo.Migrations.AddWebhookIdToChatMessages do + use Ecto.Migration + + def change do + alter table(:chat_messages) do + add :webhook_id, references(:chat_webhooks, on_delete: :nilify_all) + end + + create index(:chat_messages, [:webhook_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260704130003_add_is_bot_to_users.exs b/apps/elektrine/priv/repo/migrations/20260704130003_add_is_bot_to_users.exs new file mode 100644 index 0000000..333ba85 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260704130003_add_is_bot_to_users.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddIsBotToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :is_bot, :boolean, default: false, null: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260704140000_kairo_dedup_and_project_nilify.exs b/apps/elektrine/priv/repo/migrations/20260704140000_kairo_dedup_and_project_nilify.exs new file mode 100644 index 0000000..2df060c --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260704140000_kairo_dedup_and_project_nilify.exs @@ -0,0 +1,39 @@ +defmodule Elektrine.Repo.Migrations.KairoDedupAndProjectNilify do + use Ecto.Migration + + def up do + # Remove any duplicate ingests (keep the oldest row) so the unique index + # can be created, then enforce dedup at the database level. + execute """ + DELETE FROM kairo_sources a + USING kairo_sources b + WHERE a.user_id = b.user_id + AND a.raw_hash = b.raw_hash + AND a.raw_hash IS NOT NULL + AND a.id > b.id + """ + + drop index(:kairo_sources, [:raw_hash]) + + create unique_index(:kairo_sources, [:user_id, :raw_hash], where: "raw_hash IS NOT NULL") + + # Deleting a project should return its sources to the inbox, not destroy + # them - the sources are the durable substrate, projects are just grouping. + execute "ALTER TABLE kairo_sources DROP CONSTRAINT kairo_sources_project_id_fkey" + + alter table(:kairo_sources) do + modify :project_id, references(:kairo_projects, on_delete: :nilify_all) + end + end + + def down do + execute "ALTER TABLE kairo_sources DROP CONSTRAINT kairo_sources_project_id_fkey" + + alter table(:kairo_sources) do + modify :project_id, references(:kairo_projects, on_delete: :delete_all) + end + + drop index(:kairo_sources, [:user_id, :raw_hash]) + create index(:kairo_sources, [:raw_hash]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260704150000_add_profile_editing_deploy_link_image_features.exs b/apps/elektrine/priv/repo/migrations/20260704150000_add_profile_editing_deploy_link_image_features.exs new file mode 100644 index 0000000..240d6fb --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260704150000_add_profile_editing_deploy_link_image_features.exs @@ -0,0 +1,67 @@ +defmodule Elektrine.Repo.Migrations.AddProfileEditingDeployLinkImageFeatures do + use Ecto.Migration + + def change do + alter table(:static_site_deployments) do + add :last_commit_sha, :string + add :last_commit_url, :text + add :last_commit_message, :text + add :last_deploy_log, :text + end + + create table(:static_site_deploys) do + add :user_id, references(:users, on_delete: :delete_all), null: false + + add :deployment_id, references(:static_site_deployments, on_delete: :delete_all), + null: false + + add :status, :string, null: false + add :trigger, :string, null: false, default: "manual" + add :repo_owner, :string + add :repo_name, :string + add :branch, :string + add :site_dir, :string + add :commit_sha, :string + add :commit_url, :text + add :commit_message, :text + add :log, :text + add :error, :text + add :snapshot_storage_key, :text + add :file_count, :integer, null: false, default: 0 + add :storage_bytes, :bigint, null: false, default: 0 + add :deployed_at, :utc_datetime + + timestamps() + end + + create index(:static_site_deploys, [:deployment_id, :deployed_at]) + create index(:static_site_deploys, [:user_id, :deployed_at]) + + alter table(:profile_links) do + add :pinned, :boolean, null: false, default: false + add :active_from, :utc_datetime + add :active_until, :utc_datetime + add :impressions, :integer, null: false, default: 0 + add :last_clicked_at, :utc_datetime + add :last_checked_at, :utc_datetime + add :last_check_status, :string + add :last_check_error, :text + end + + create index(:profile_links, [:profile_id, :pinned]) + create index(:profile_links, [:profile_id, :active_from]) + create index(:profile_links, [:profile_id, :active_until]) + + alter table(:user_profiles) do + add :avatar_alt_text, :string + add :banner_alt_text, :string + add :background_alt_text, :string + add :avatar_focal_x, :float, null: false, default: 50.0 + add :avatar_focal_y, :float, null: false, default: 50.0 + add :background_focal_x, :float, null: false, default: 50.0 + add :background_focal_y, :float, null: false, default: 50.0 + add :background_brightness, :integer, null: false, default: 100 + add :background_overlay_blur, :integer, null: false, default: 0 + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260704160000_create_search_domain_rules.exs b/apps/elektrine/priv/repo/migrations/20260704160000_create_search_domain_rules.exs new file mode 100644 index 0000000..4806044 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260704160000_create_search_domain_rules.exs @@ -0,0 +1,15 @@ +defmodule Elektrine.Repo.Migrations.CreateSearchDomainRules do + use Ecto.Migration + + def change do + create table(:search_domain_rules) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :domain, :string, null: false + add :action, :string, null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:search_domain_rules, [:user_id, :domain]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260704170000_create_user_sessions.exs b/apps/elektrine/priv/repo/migrations/20260704170000_create_user_sessions.exs new file mode 100644 index 0000000..e3c2414 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260704170000_create_user_sessions.exs @@ -0,0 +1,24 @@ +defmodule Elektrine.Repo.Migrations.CreateUserSessions do + use Ecto.Migration + + def change do + create table(:user_sessions) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :auth_method, :string, null: false, default: "password" + add :device_label, :string + add :browser, :string + add :platform, :string + add :ip_address, :string + add :user_agent, :text + add :remembered, :boolean, null: false, default: false + add :last_seen_at, :utc_datetime, null: false + add :revoked_at, :utc_datetime + add :revoked_reason, :string + + timestamps(type: :utc_datetime) + end + + create index(:user_sessions, [:user_id, :revoked_at]) + create index(:user_sessions, [:user_id, :last_seen_at]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260705000000_add_social_messages_community_actor_uri_id_desc_index.exs b/apps/elektrine/priv/repo/migrations/20260705000000_add_social_messages_community_actor_uri_id_desc_index.exs new file mode 100644 index 0000000..d953643 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260705000000_add_social_messages_community_actor_uri_id_desc_index.exs @@ -0,0 +1,26 @@ +defmodule Elektrine.Repo.Migrations.AddSocialMessagesCommunityActorUriIdDescIndex do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_public_top_community_actor_uri_id_idx + ON social_messages (id DESC) + WHERE visibility = 'public' + AND is_draft IS NOT TRUE + AND deleted_at IS NULL + AND reply_to_id IS NULL + AND (approval_status = 'approved' OR approval_status IS NULL) + AND (media_metadata->>'inReplyTo') IS NULL + AND (media_metadata->>'community_actor_uri') IS NOT NULL + """) + end + + def down do + execute( + "DROP INDEX CONCURRENTLY IF EXISTS social_messages_public_top_community_actor_uri_id_idx" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260706000000_add_nameserver_set_to_dns_zones.exs b/apps/elektrine/priv/repo/migrations/20260706000000_add_nameserver_set_to_dns_zones.exs new file mode 100644 index 0000000..6998f7d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260706000000_add_nameserver_set_to_dns_zones.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddNameserverSetToDnsZones do + use Ecto.Migration + + def change do + alter table(:dns_zones) do + add :nameserver_set, :integer + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260707000000_create_social_recommendation_items.exs b/apps/elektrine/priv/repo/migrations/20260707000000_create_social_recommendation_items.exs new file mode 100644 index 0000000..96ea070 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260707000000_create_social_recommendation_items.exs @@ -0,0 +1,34 @@ +defmodule Elektrine.Repo.Migrations.CreateSocialRecommendationItems do + use Ecto.Migration + + def change do + create table(:social_recommendation_items) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :message_id, references(:social_messages, on_delete: :delete_all), null: false + add :filter, :string, null: false + add :rank, :integer, null: false + add :score, :integer, null: false, default: 0 + add :reason, :string + add :generated_at, :utc_datetime, null: false + add :expires_at, :utc_datetime, null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:social_recommendation_items, [:user_id, :filter, :message_id], + name: :social_recommendation_items_user_filter_message_unique + ) + + create unique_index(:social_recommendation_items, [:user_id, :filter, :rank], + name: :social_recommendation_items_user_filter_rank_unique + ) + + create index(:social_recommendation_items, [:user_id, :filter, :expires_at, :rank], + name: :social_recommendation_items_lookup_idx + ) + + create index(:social_recommendation_items, [:expires_at], + name: :social_recommendation_items_expires_at_idx + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260708061000_add_site_session_referrer_index_for_analytics.exs b/apps/elektrine/priv/repo/migrations/20260708061000_add_site_session_referrer_index_for_analytics.exs new file mode 100644 index 0000000..b07fe8c --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260708061000_add_site_session_referrer_index_for_analytics.exs @@ -0,0 +1,19 @@ +defmodule Elektrine.Repo.Migrations.AddSiteSessionReferrerIndexForAnalytics do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS site_sessions_host_referrer_stats_idx + ON site_sessions (entry_host, referer) + INCLUDE (started_at) + WHERE entry_host IS NOT NULL AND referer IS NOT NULL + """) + end + + def down do + execute("DROP INDEX CONCURRENTLY IF EXISTS site_sessions_host_referrer_stats_idx") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260708062000_create_public_site_analytics_rollups.exs b/apps/elektrine/priv/repo/migrations/20260708062000_create_public_site_analytics_rollups.exs new file mode 100644 index 0000000..b95df45 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260708062000_create_public_site_analytics_rollups.exs @@ -0,0 +1,47 @@ +defmodule Elektrine.Repo.Migrations.CreatePublicSiteAnalyticsRollups do + use Ecto.Migration + + def change do + create table(:site_analytics_daily_rollups) do + add :host, :string, null: false + add :date, :date, null: false + add :views, :bigint, null: false, default: 0 + add :sessions, :bigint, null: false, default: 0 + add :bounces, :bigint, null: false, default: 0 + add :duration_seconds, :bigint, null: false, default: 0 + + timestamps() + end + + create unique_index(:site_analytics_daily_rollups, [:host, :date]) + create index(:site_analytics_daily_rollups, [:date]) + + create table(:site_analytics_page_rollups) do + add :host, :string, null: false + add :path, :text, null: false + add :date, :date, null: false + add :views, :bigint, null: false, default: 0 + add :sessions, :bigint, null: false, default: 0 + + timestamps() + end + + create index(:site_analytics_page_rollups, [:host, :date]) + + create table(:site_analytics_referrer_rollups) do + add :host, :string, null: false + add :referrer, :text, null: false + add :date, :date, null: false + add :sessions, :bigint, null: false, default: 0 + + timestamps() + end + + create index(:site_analytics_referrer_rollups, [:host, :date]) + + create table(:site_analytics_rollup_dates, primary_key: false) do + add :date, :date, primary_key: true + add :refreshed_at, :utc_datetime, null: false + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260708094000_dedupe_hashtags_by_normalized_name.exs b/apps/elektrine/priv/repo/migrations/20260708094000_dedupe_hashtags_by_normalized_name.exs new file mode 100644 index 0000000..2ca91f4 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260708094000_dedupe_hashtags_by_normalized_name.exs @@ -0,0 +1,88 @@ +defmodule Elektrine.Repo.Migrations.DedupeHashtagsByNormalizedName do + use Ecto.Migration + + def up do + execute("DROP INDEX IF EXISTS hashtags_normalized_name_index") + execute("DROP INDEX IF EXISTS hashtags_normalized_name_idx") + + execute(""" + CREATE TEMP TABLE hashtag_dedupe_map ON COMMIT DROP AS + WITH normalized AS ( + SELECT + id, + lower(trim(normalized_name)) AS canonical_name + FROM hashtags + ), + keepers AS ( + SELECT + canonical_name, + min(id) AS keep_id + FROM normalized + GROUP BY canonical_name + ) + SELECT + normalized.id AS old_id, + keepers.keep_id, + normalized.canonical_name + FROM normalized + JOIN keepers USING (canonical_name) + """) + + execute("CREATE INDEX hashtag_dedupe_map_old_id_idx ON hashtag_dedupe_map (old_id)") + execute("CREATE INDEX hashtag_dedupe_map_keep_id_idx ON hashtag_dedupe_map (keep_id)") + + execute(""" + WITH stats AS ( + SELECT + dedupe.keep_id, + max(dedupe.canonical_name) AS canonical_name, + sum(coalesce(hashtags.use_count, 0)) AS use_count, + max(hashtags.last_used_at) AS last_used_at + FROM hashtag_dedupe_map dedupe + JOIN hashtags ON hashtags.id = dedupe.old_id + GROUP BY dedupe.keep_id + ) + UPDATE hashtags + SET + normalized_name = stats.canonical_name, + use_count = stats.use_count, + last_used_at = stats.last_used_at, + updated_at = now() + FROM stats + WHERE hashtags.id = stats.keep_id + """) + + execute(""" + INSERT INTO post_hashtags (message_id, hashtag_id, inserted_at) + SELECT + post_hashtags.message_id, + dedupe.keep_id, + min(post_hashtags.inserted_at) + FROM post_hashtags + JOIN hashtag_dedupe_map dedupe ON dedupe.old_id = post_hashtags.hashtag_id + WHERE dedupe.old_id <> dedupe.keep_id + GROUP BY post_hashtags.message_id, dedupe.keep_id + ON CONFLICT (message_id, hashtag_id) DO NOTHING + """) + + execute(""" + DELETE FROM post_hashtags + USING hashtag_dedupe_map dedupe + WHERE post_hashtags.hashtag_id = dedupe.old_id + AND dedupe.old_id <> dedupe.keep_id + """) + + execute(""" + DELETE FROM hashtags + USING hashtag_dedupe_map dedupe + WHERE hashtags.id = dedupe.old_id + AND dedupe.old_id <> dedupe.keep_id + """) + + create_if_not_exists(index(:hashtags, [:normalized_name], unique: true)) + end + + def down do + :ok + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260708100000_add_social_messages_remote_actor_feed_index.exs b/apps/elektrine/priv/repo/migrations/20260708100000_add_social_messages_remote_actor_feed_index.exs new file mode 100644 index 0000000..32d282d --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260708100000_add_social_messages_remote_actor_feed_index.exs @@ -0,0 +1,26 @@ +defmodule Elektrine.Repo.Migrations.AddSocialMessagesRemoteActorFeedIndex do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_public_top_remote_actor_inserted_id_idx + ON social_messages (remote_actor_id, inserted_at DESC, id DESC) + WHERE visibility = 'public' + AND is_draft IS NOT TRUE + AND deleted_at IS NULL + AND reply_to_id IS NULL + AND remote_actor_id IS NOT NULL + AND (approval_status = 'approved' OR approval_status IS NULL) + AND (media_metadata->>'inReplyTo') IS NULL + """) + end + + def down do + execute( + "DROP INDEX CONCURRENTLY IF EXISTS social_messages_public_top_remote_actor_inserted_id_idx" + ) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260710000000_add_federated_timeline_view_indexes.exs b/apps/elektrine/priv/repo/migrations/20260710000000_add_federated_timeline_view_indexes.exs new file mode 100644 index 0000000..5eec6eb --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260710000000_add_federated_timeline_view_indexes.exs @@ -0,0 +1,37 @@ +defmodule Elektrine.Repo.Migrations.AddFederatedTimelineViewIndexes do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_public_federated_media_id_idx + ON social_messages (id DESC) + WHERE visibility = 'public' + AND is_draft IS NOT TRUE + AND deleted_at IS NULL + AND reply_to_id IS NULL + AND federated = TRUE + AND (approval_status = 'approved' OR approval_status IS NULL) + AND (media_metadata->>'inReplyTo') IS NULL + AND array_length(media_urls, 1) > 0 + """) + + execute(""" + CREATE INDEX CONCURRENTLY IF NOT EXISTS social_messages_public_federated_reply_id_idx_v2 + ON social_messages (id DESC) + WHERE visibility = 'public' + AND is_draft IS NOT TRUE + AND deleted_at IS NULL + AND federated = TRUE + AND (approval_status = 'approved' OR approval_status IS NULL) + AND (reply_to_id IS NOT NULL OR (media_metadata->>'inReplyTo') IS NOT NULL) + """) + end + + def down do + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_public_federated_reply_id_idx_v2") + execute("DROP INDEX CONCURRENTLY IF EXISTS social_messages_public_federated_media_id_idx") + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260710010000_create_web_index.exs b/apps/elektrine/priv/repo/migrations/20260710010000_create_web_index.exs new file mode 100644 index 0000000..04b2bd0 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260710010000_create_web_index.exs @@ -0,0 +1,62 @@ +defmodule Elektrine.Repo.Migrations.CreateWebIndex do + use Ecto.Migration + + def up do + create table(:web_index_hosts, primary_key: false) do + add :host, :string, primary_key: true + add :robots_url, :text + add :robots_body, :text + add :robots_fetched_at, :utc_datetime_usec + add :next_allowed_at, :utc_datetime_usec + add :crawl_delay_ms, :integer, null: false, default: 1_000 + + timestamps(type: :utc_datetime_usec) + end + + create table(:web_index_documents) do + add :url, :text, null: false + add :canonical_url, :text, null: false + add :host, references(:web_index_hosts, column: :host, type: :string), null: false + add :discovered_from, :text + add :depth, :integer, null: false, default: 0 + add :status, :string, null: false, default: "pending" + add :title, :text + add :description, :text + add :content, :text + add :content_hash, :binary + add :language, :string + add :http_status, :integer + add :attempts, :integer, null: false, default: 0 + add :fetched_at, :utc_datetime_usec + add :next_fetch_at, :utc_datetime_usec + add :last_error, :text + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:web_index_documents, [:canonical_url]) + create index(:web_index_documents, [:host, :status]) + create index(:web_index_documents, [:status, :next_fetch_at]) + create index(:web_index_documents, [:content_hash]) + + execute(""" + ALTER TABLE web_index_documents + ADD COLUMN search_vector tsvector + GENERATED ALWAYS AS ( + setweight(to_tsvector('english', coalesce(title, '')), 'A') || + setweight(to_tsvector('english', coalesce(description, '')), 'B') || + setweight(to_tsvector('english', coalesce(content, '')), 'C') + ) STORED + """) + + execute(""" + CREATE INDEX web_index_documents_search_vector_index + ON web_index_documents USING GIN (search_vector) + """) + end + + def down do + drop table(:web_index_documents) + drop table(:web_index_hosts) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260712010000_add_encrypted_raw_source_to_email_messages.exs b/apps/elektrine/priv/repo/migrations/20260712010000_add_encrypted_raw_source_to_email_messages.exs new file mode 100644 index 0000000..353e9ae --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260712010000_add_encrypted_raw_source_to_email_messages.exs @@ -0,0 +1,9 @@ +defmodule Elektrine.Repo.Migrations.AddEncryptedRawSourceToEmailMessages do + use Ecto.Migration + + def change do + alter table(:email_messages) do + add :encrypted_raw_source, :map + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260712090000_add_theme_mode_to_users.exs b/apps/elektrine/priv/repo/migrations/20260712090000_add_theme_mode_to_users.exs new file mode 100644 index 0000000..276c435 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260712090000_add_theme_mode_to_users.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.Repo.Migrations.AddThemeModeToUsers do + use Ecto.Migration + + def up do + alter table(:users) do + add :theme_mode, :string, null: false, default: "system" + end + + execute("UPDATE users SET theme_mode = 'custom' WHERE theme_overrides <> '{}'") + end + + def down do + alter table(:users) do + remove :theme_mode + end + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260725120000_create_link_archives.exs b/apps/elektrine/priv/repo/migrations/20260725120000_create_link_archives.exs new file mode 100644 index 0000000..e865768 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260725120000_create_link_archives.exs @@ -0,0 +1,40 @@ +defmodule Elektrine.Repo.Migrations.CreateLinkArchives do + use Ecto.Migration + + def change do + create table(:link_archives) do + add :user_id, references(:users, on_delete: :delete_all), null: false + + # The URL as given, plus a hash of its canonical form. The hash is what + # uniqueness is enforced on, because URLs are long and vary by tracking + # parameters that do not change the document. + add :url, :text, null: false + add :url_hash, :string, null: false + add :canonical_url, :text + + add :status, :string, null: false, default: "pending" + add :title, :text + add :content_text, :text + add :content_html, :text + add :content_type, :string + add :byte_size, :integer + # Hash of the fetched body, so re-archiving an unchanged page is a no-op. + add :body_hash, :string + + # Where the link came from, for attribution and for deciding retention. + add :origin, :string + add :origin_id, :string + + add :error_message, :text + add :fetched_at, :utc_datetime + add :last_verified_at, :utc_datetime + + timestamps() + end + + create unique_index(:link_archives, [:user_id, :url_hash]) + create index(:link_archives, [:user_id, :inserted_at]) + create index(:link_archives, [:status]) + create index(:link_archives, [:origin, :origin_id]) + end +end diff --git a/apps/elektrine/priv/repo/migrations/20260725130000_create_booking_pages.exs b/apps/elektrine/priv/repo/migrations/20260725130000_create_booking_pages.exs new file mode 100644 index 0000000..584e6c2 --- /dev/null +++ b/apps/elektrine/priv/repo/migrations/20260725130000_create_booking_pages.exs @@ -0,0 +1,65 @@ +defmodule Elektrine.Repo.Migrations.CreateBookingPages do + use Ecto.Migration + + def change do + create table(:booking_pages) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :slug, :string, null: false + add :title, :string, null: false + add :description, :text + + # The owner's timezone. Windows below are local minutes from midnight in + # this zone, which is the only reading that survives a DST change. + add :timezone, :string, null: false, default: "Etc/UTC" + add :windows, :map, null: false, default: %{} + + add :slot_minutes, :integer, null: false, default: 30 + add :buffer_minutes, :integer, null: false, default: 0 + add :min_notice_minutes, :integer, null: false, default: 60 + add :max_days_ahead, :integer, null: false, default: 30 + add :max_per_day, :integer + + add :active, :boolean, null: false, default: true + + timestamps() + end + + create unique_index(:booking_pages, [:slug]) + create index(:booking_pages, [:user_id]) + + create table(:bookings) do + add :booking_page_id, references(:booking_pages, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + + add :start_at, :utc_datetime, null: false + add :end_at, :utc_datetime, null: false + add :status, :string, null: false, default: "held" + + add :invitee_name, :string + add :invitee_email, :string + add :note, :text + + # A hold is a short reservation taken while the invitee fills the form, + # so two people cannot both be told a slot is theirs. + add :hold_expires_at, :utc_datetime + add :confirmation_token, :string + add :cancellation_token, :string + add :calendar_event_id, references(:calendar_events, on_delete: :nilify_all) + + timestamps() + end + + create index(:bookings, [:booking_page_id, :start_at]) + create index(:bookings, [:user_id]) + create unique_index(:bookings, [:confirmation_token]) + create unique_index(:bookings, [:cancellation_token]) + + # The database, not the application, is what actually prevents two people + # taking the same slot: a partial unique index over live bookings only, so + # cancelled and expired ones free the slot again. + create unique_index(:bookings, [:booking_page_id, :start_at], + where: "status in ('held', 'confirmed')", + name: :bookings_live_slot_index + ) + end +end diff --git a/apps/elektrine/priv/repo/seed_fediverse_posts.exs b/apps/elektrine/priv/repo/seed_fediverse_posts.exs new file mode 100644 index 0000000..d4c36a0 --- /dev/null +++ b/apps/elektrine/priv/repo/seed_fediverse_posts.exs @@ -0,0 +1,279 @@ +alias Elektrine.{Messaging, Repo} +alias Elektrine.ActivityPub.Actor +alias Elektrine.Social.{Message, Poll, PollOption} + +import Ecto.Query + +unless Mix.env() == :dev do + raise "seed_fediverse_posts.exs is intended for development only" +end + +now = DateTime.utc_now() |> DateTime.truncate(:second) +ago = fn seconds -> DateTime.add(now, -seconds, :second) |> DateTime.truncate(:second) end +from_now = fn seconds -> DateTime.add(now, seconds, :second) |> DateTime.truncate(:second) end + +ensure_actor = fn attrs -> + case Repo.get_by(Actor, uri: attrs.uri) do + nil -> + %Actor{} + |> Actor.changeset(attrs) + |> Repo.insert!() + + actor -> + actor + |> Actor.changeset(attrs) + |> Repo.update!() + end +end + +ensure_post = fn actor, attrs -> + attrs = + attrs + |> Map.merge(%{ + remote_actor_id: actor.id, + federated: true, + visibility: Map.get(attrs, :visibility, "public"), + media_metadata: Map.get(attrs, :media_metadata, %{}), + like_count: Map.get(attrs, :like_count, 0), + reply_count: Map.get(attrs, :reply_count, 0), + share_count: Map.get(attrs, :share_count, 0), + quote_count: Map.get(attrs, :quote_count, 0), + upvotes: Map.get(attrs, :upvotes, 0), + downvotes: Map.get(attrs, :downvotes, 0), + score: Map.get(attrs, :score, 0) + }) + + case Messaging.get_message_by_activitypub_ref(attrs.activitypub_id) do + nil -> + {:ok, message} = Messaging.create_federated_message(attrs) + message + + %Message{} = message -> + message + |> Message.federated_changeset(attrs) + |> Repo.update!() + end +end + +ensure_poll = fn message, attrs -> + poll = + case Repo.preload(message, [poll: [:options]], force: true).poll do + nil -> + %Poll{} + |> Poll.changeset(%{ + message_id: message.id, + question: attrs.question, + closes_at: attrs.closes_at, + allow_multiple: Map.get(attrs, :allow_multiple, false), + hide_totals: Map.get(attrs, :hide_totals, false), + total_votes: Enum.reduce(attrs.options, 0, fn {_text, votes}, acc -> acc + votes end), + voters_count: Enum.reduce(attrs.options, 0, fn {_text, votes}, acc -> acc + votes end), + last_fetched_at: now + }) + |> Repo.insert!() + + existing -> + existing + |> Poll.changeset(%{ + question: attrs.question, + closes_at: attrs.closes_at, + allow_multiple: Map.get(attrs, :allow_multiple, false), + hide_totals: Map.get(attrs, :hide_totals, false), + total_votes: Enum.reduce(attrs.options, 0, fn {_text, votes}, acc -> acc + votes end), + voters_count: Enum.reduce(attrs.options, 0, fn {_text, votes}, acc -> acc + votes end), + last_fetched_at: now + }) + |> Repo.update!() + end + + from(option in PollOption, where: option.poll_id == ^poll.id) + |> Repo.delete_all() + + attrs.options + |> Enum.with_index() + |> Enum.each(fn {{option_text, vote_count}, position} -> + %PollOption{} + |> PollOption.changeset(%{ + poll_id: poll.id, + option_text: option_text, + position: position, + vote_count: vote_count + }) + |> Repo.insert!() + end) + + Repo.preload(poll, :options, force: true) +end + +pollbot = + ensure_actor.(%{ + uri: "https://mastodon.seed.local/users/pollbot", + username: "pollbot", + domain: "mastodon.seed.local", + display_name: "Poll Bot", + summary: "Development seed bot for federated timeline and remote profile testing.", + avatar_url: "/images/mark.png", + header_url: "/images/z1.png", + inbox_url: "https://mastodon.seed.local/users/pollbot/inbox", + outbox_url: "https://mastodon.seed.local/users/pollbot/outbox", + followers_url: "https://mastodon.seed.local/users/pollbot/followers", + following_url: "https://mastodon.seed.local/users/pollbot/following", + public_key: "seed-dev-public-key-pollbot", + actor_type: "Person", + published_at: ago.(90 * 86_400), + metadata: %{"followers" => 428, "following" => 12, "statuses_count" => 42} + }) + +photobot = + ensure_actor.(%{ + uri: "https://pixelfed.seed.local/users/photobot", + username: "photobot", + domain: "pixelfed.seed.local", + display_name: "Photo Bot", + summary: "Pixelfed-style media actor seeded for gallery and media-card testing.", + avatar_url: "/images/c1.png", + header_url: "/images/e1.png", + inbox_url: "https://pixelfed.seed.local/users/photobot/inbox", + outbox_url: "https://pixelfed.seed.local/users/photobot/outbox", + followers_url: "https://pixelfed.seed.local/users/photobot/followers", + following_url: "https://pixelfed.seed.local/users/photobot/following", + public_key: "seed-dev-public-key-photobot", + actor_type: "Person", + published_at: ago.(120 * 86_400), + metadata: %{"followers" => 981, "following" => 34, "statuses_count" => 87} + }) + +threadbot = + ensure_actor.(%{ + uri: "https://pleroma.seed.local/users/threadbot", + username: "threadbot", + domain: "pleroma.seed.local", + display_name: "Thread Bot", + summary: "Pleroma-style actor for reply/thread rendering smoke tests.", + avatar_url: "/images/e1.png", + header_url: "/images/c1.png", + inbox_url: "https://pleroma.seed.local/users/threadbot/inbox", + outbox_url: "https://pleroma.seed.local/users/threadbot/outbox", + followers_url: "https://pleroma.seed.local/users/threadbot/followers", + following_url: "https://pleroma.seed.local/users/threadbot/following", + public_key: "seed-dev-public-key-threadbot", + actor_type: "Person", + published_at: ago.(45 * 86_400), + metadata: %{"followers" => 217, "following" => 19, "statuses_count" => 31} + }) + +poll_post = + ensure_post.(pollbot, %{ + activitypub_id: "https://mastodon.seed.local/users/pollbot/statuses/seed-timeline-poll-1", + activitypub_url: "https://mastodon.seed.local/@pollbot/seed-timeline-poll-1", + post_type: "poll", + content: "Seeded remote poll for federated timeline smoke tests. #seedfediverse #polls", + extracted_hashtags: ["seedfediverse", "polls"], + inserted_at: ago.(900), + like_count: 8, + reply_count: 2, + share_count: 3 + }) + +ensure_poll.(poll_post, %{ + question: "Which remote timeline surface should get the most test coverage?", + options: [{"Profile sticky actions", 7}, {"Federated media cards", 5}, {"Remote replies", 3}], + closes_at: from_now.(3 * 86_400) +}) + +text_post = + ensure_post.(pollbot, %{ + activitypub_id: "https://mastodon.seed.local/users/pollbot/statuses/seed-fediverse-note-1", + activitypub_url: "https://mastodon.seed.local/@pollbot/seed-fediverse-note-1", + post_type: "post", + content: + "Remote seed note from Mastodon: testing profile sticky follow, federated filtering, and actor cards. #seedfediverse", + extracted_hashtags: ["seedfediverse"], + inserted_at: ago.(1_800), + like_count: 14, + reply_count: 2, + share_count: 4 + }) + +ensure_post.(threadbot, %{ + activitypub_id: "https://pleroma.seed.local/users/threadbot/statuses/reply-to-pollbot-1", + activitypub_url: "https://pleroma.seed.local/notice/reply-to-pollbot-1", + post_type: "post", + content: + "Replying from a different seeded remote actor so thread previews have federated context.", + reply_to_id: text_post.id, + media_metadata: %{"inReplyTo" => text_post.activitypub_id}, + inserted_at: ago.(1_650), + like_count: 3 +}) + +ensure_post.(photobot, %{ + activitypub_id: "https://pixelfed.seed.local/users/photobot/statuses/seed-media-1", + activitypub_url: "https://pixelfed.seed.local/p/photobot/seed-media-1", + post_type: "gallery", + message_type: "image", + content: + "Pixelfed-style remote media seed. This should exercise image cards and remote profile media. #seedfediverse #media", + media_urls: ["/images/z1.png", "/images/c1.png"], + media_metadata: %{ + "attachments" => [ + %{"type" => "Image", "url" => "/images/z1.png", "name" => "Seed media image one"}, + %{"type" => "Image", "url" => "/images/c1.png", "name" => "Seed media image two"} + ] + }, + extracted_hashtags: ["seedfediverse", "media"], + inserted_at: ago.(3_000), + like_count: 22, + reply_count: 1, + share_count: 6 +}) + +ensure_post.(threadbot, %{ + activitypub_id: "https://pleroma.seed.local/users/threadbot/statuses/seed-cw-1", + activitypub_url: "https://pleroma.seed.local/notice/seed-cw-1", + post_type: "post", + content: + "This seeded remote post has a content warning so spoiler/cw rendering can be checked in the timeline.", + content_warning: "Seeded CW example", + sensitive: true, + inserted_at: ago.(4_200), + like_count: 5, + reply_count: 0, + share_count: 1 +}) + +ensure_post.(threadbot, %{ + activitypub_id: "https://pleroma.seed.local/users/threadbot/statuses/seed-link-1", + activitypub_url: "https://pleroma.seed.local/notice/seed-link-1", + post_type: "link", + content: "Remote link-style post for layout testing: https://example.com/fediverse-seed", + primary_url: "https://example.com/fediverse-seed", + media_metadata: %{"type" => "Article"}, + inserted_at: ago.(5_400), + like_count: 9, + reply_count: 1, + share_count: 2 +}) + +federated_count = Repo.aggregate(from(m in Message, where: m.federated == true), :count, :id) + +seed_count = + Repo.aggregate( + from(m in Message, where: m.federated == true and like(m.activitypub_id, "%seed%")), + :count, + :id + ) + +IO.puts( + "✓ Seeded fediverse actors: @pollbot@mastodon.seed.local, @photobot@pixelfed.seed.local, @threadbot@pleroma.seed.local" +) + +IO.puts( + "✓ Seeded #{seed_count} seed federated messages (#{federated_count} federated messages total)" +) + +IO.puts(" Test URLs:") +IO.puts(" - /timeline?filter=federated") +IO.puts(" - /remote/pollbot@mastodon.seed.local") +IO.puts(" - /remote/photobot@pixelfed.seed.local") +IO.puts(" - /remote/threadbot@pleroma.seed.local") diff --git a/apps/elektrine/priv/repo/seed_platform_demo_screenshots.exs b/apps/elektrine/priv/repo/seed_platform_demo_screenshots.exs new file mode 100644 index 0000000..a7b9923 --- /dev/null +++ b/apps/elektrine/priv/repo/seed_platform_demo_screenshots.exs @@ -0,0 +1,808 @@ +# Script to seed a polished cross-platform account for product/demo screenshots. +# Run with: +# +# mix run priv/repo/seed_platform_demo_screenshots.exs + +import Ecto.Query + +alias Elektrine.{ + Accounts, + Calendar, + Email, + Messaging, + Notifications, + Nerve, + Profiles, + Repo, + Social +} + +alias Elektrine.Email.Message, as: EmailMessage +alias Elektrine.Messaging.{ChatMessage, Server} +alias Elektrine.Social.Conversation +alias Elektrine.Social.Message, as: SocialMessage +alias Elektrine.Notifications.Notification + +seed_password = "DevPass123!" +screenshot_username = "platformdemo" + +if Mix.env() == :dev do + Code.require_file("seeds.exs", __DIR__) + + now = DateTime.utc_now() |> DateTime.truncate(:second) + hours_ago = fn hours -> DateTime.add(now, -hours * 3_600, :second) end + days_ago = fn days -> DateTime.add(now, -days * 86_400, :second) end + hours_from_now = fn hours -> DateTime.add(now, hours * 3_600, :second) end + days_from_now = fn days -> DateTime.add(now, days * 86_400, :second) end + screenshot_message_id = fn key -> "platform-demo-#{key}@elektrine.dev" end + + ensure_seed_password = fn user -> + case Accounts.admin_reset_password(user, %{password: seed_password}) do + {:ok, updated_user} -> updated_user + {:error, _reason} -> user + end + end + + ensure_user = fn username -> + case Accounts.get_user_by_username(username) do + nil -> + case Accounts.create_user(%{ + username: username, + password: seed_password, + password_confirmation: seed_password + }) do + {:ok, user} -> + ensure_seed_password.(user) + + {:error, reason} -> + raise "Failed to create screenshot user #{username}: #{inspect(reason)}" + end + + user -> + ensure_seed_password.(user) + end + end + + ensure_mailbox = fn user -> + case Email.get_user_mailbox(user.id) do + nil -> + case Email.ensure_user_has_mailbox(user) do + {:ok, mailbox} -> mailbox + mailbox -> mailbox + end + + mailbox -> + mailbox + end + end + + ensure_profile = fn user, attrs -> + case Profiles.get_user_profile(user.id) do + nil -> + case Profiles.create_user_profile(user.id, attrs) do + {:ok, profile} -> + profile + + {:error, reason} -> + raise "Failed to create profile for #{user.username}: #{inspect(reason)}" + end + + profile -> + case Profiles.update_user_profile(profile, attrs) do + {:ok, updated_profile} -> + updated_profile + + {:error, reason} -> + raise "Failed to update profile for #{user.username}: #{inspect(reason)}" + end + end + end + + ensure_profile_link = fn profile, attrs -> + title = Map.fetch!(attrs, :title) + + case Repo.get_by(Profiles.ProfileLink, profile_id: profile.id, title: title) do + nil -> + case Profiles.create_profile_link(profile.id, attrs) do + {:ok, link} -> link + {:error, reason} -> raise "Failed to create profile link #{title}: #{inspect(reason)}" + end + + link -> + link + end + end + + ensure_follow = fn follower_id, followed_id -> + case Repo.get_by(Profiles.Follow, follower_id: follower_id, followed_id: followed_id) do + nil -> + case Profiles.follow_user(follower_id, followed_id) do + {:ok, _follow} -> + :created + + {:error, :already_following} -> + :existing + + {:error, reason} -> + raise "Failed to follow #{follower_id} -> #{followed_id}: #{inspect(reason)}" + end + + _follow -> + :existing + end + end + + ensure_email = fn mailbox_id, attrs -> + message_id = Map.fetch!(attrs, :message_id) + mailbox = Email.Mailboxes.get_mailbox(mailbox_id) + + case Repo.get_by(EmailMessage, mailbox_id: mailbox_id, message_id: message_id) do + nil -> + case Email.create_message(attrs) do + {:ok, message} -> message + {:error, reason} -> raise "Failed to create email #{message_id}: #{inspect(reason)}" + end + + %EmailMessage{} = message -> + decrypted = + case mailbox do + %{user_id: user_id} when is_integer(user_id) -> + EmailMessage.decrypt_content(message, user_id) + + _ -> + message + end + + if decrypted.text_body == "[Decryption failed]" or + decrypted.html_body == "[Decryption failed]" do + case Email.delete_message(message) do + {:ok, _message} -> + case Email.create_message(attrs) do + {:ok, recreated_message} -> + recreated_message + + {:error, reason} -> + raise "Failed to recreate email #{message_id}: #{inspect(reason)}" + end + + {:error, reason} -> + raise "Failed to replace email #{message_id}: #{inspect(reason)}" + end + else + message + end + end + end + + ensure_label = fn user_id, attrs -> + name = Map.fetch!(attrs, :name) + + case Email.get_label_by_name(name, user_id) do + nil -> + case Email.create_label(attrs) do + {:ok, label} -> label + {:error, reason} -> raise "Failed to create label #{name}: #{inspect(reason)}" + end + + label -> + label + end + end + + ensure_timeline_post = fn user_id, content, opts -> + post_type = Keyword.get(opts, :post_type, "post") + reply_to_id = Keyword.get(opts, :reply_to_id) + + existing_query = + from(m in SocialMessage, + join: c in Conversation, + on: c.id == m.conversation_id, + where: + c.type == "timeline" and + m.sender_id == ^user_id and + m.post_type == ^post_type and + m.content == ^content and + is_nil(m.deleted_at), + limit: 1 + ) + + existing_query = + if is_nil(reply_to_id) do + from(m in existing_query, where: is_nil(m.reply_to_id)) + else + from(m in existing_query, where: m.reply_to_id == ^reply_to_id) + end + + case Repo.one(existing_query) do + nil -> + case Social.create_timeline_post(user_id, content, opts) do + {:ok, post} -> post + {:error, reason} -> raise "Failed to create timeline post: #{inspect(reason)}" + end + + post -> + post + end + end + + ensure_server = fn creator_id, attrs -> + name = Map.fetch!(attrs, :name) + + case Repo.get_by(Server, creator_id: creator_id, name: name) do + nil -> + case Messaging.create_server(creator_id, attrs) do + {:ok, server} -> server + {:error, reason} -> raise "Failed to create server #{name}: #{inspect(reason)}" + end + + server -> + server + end + end + + ensure_server_member = fn server_id, user_id -> + case Messaging.get_server_member(server_id, user_id) do + nil -> + case Messaging.join_server(server_id, user_id) do + {:ok, _member} -> :created + {:error, :already_joined} -> :existing + {:error, reason} -> raise "Failed to join server #{server_id}: #{inspect(reason)}" + end + + _member -> + :existing + end + end + + ensure_server_channel = fn server_id, creator_id, attrs -> + name = attrs |> Map.fetch!(:name) |> String.downcase() + + case Repo.get_by(Conversation, server_id: server_id, type: "channel", name: name) do + nil -> + case Messaging.create_server_channel(server_id, creator_id, attrs) do + {:ok, channel} -> channel + {:error, reason} -> raise "Failed to create channel #{name}: #{inspect(reason)}" + end + + channel -> + channel + end + end + + ensure_chat_message = fn conversation_id, sender_id, content -> + case Repo.get_by(ChatMessage, + conversation_id: conversation_id, + sender_id: sender_id, + content: content + ) do + nil -> + case Messaging.create_text_message(conversation_id, sender_id, content, nil) do + {:ok, message} -> message + {:error, reason} -> raise "Failed to create chat message: #{inspect(reason)}" + end + + message -> + message + end + end + + ensure_calendar = fn user_id, attrs -> + name = Map.fetch!(attrs, :name) + + case Calendar.get_calendar_by_name(user_id, name) do + nil -> + case Calendar.create_calendar(attrs) do + {:ok, calendar} -> calendar + {:error, reason} -> raise "Failed to create calendar #{name}: #{inspect(reason)}" + end + + calendar -> + calendar + end + end + + ensure_event = fn calendar_id, uid, attrs -> + case Calendar.get_event_by_uid(calendar_id, uid) do + nil -> + case Calendar.create_event(Map.merge(attrs, %{calendar_id: calendar_id, uid: uid})) do + {:ok, event} -> event + {:error, reason} -> raise "Failed to create event #{uid}: #{inspect(reason)}" + end + + event -> + event + end + end + + ensure_notification = fn user_id, title, attrs -> + case Repo.get_by(Notification, user_id: user_id, title: title) do + nil -> + case Notifications.create_notification( + Map.merge(attrs, %{user_id: user_id, title: title}) + ) do + {:ok, notification} -> notification + {:error, reason} -> raise "Failed to create notification #{title}: #{inspect(reason)}" + end + + notification -> + notification + end + end + + seed_encrypted_payload = fn ciphertext -> + %{ + "version" => 1, + "algorithm" => "AES-GCM", + "kdf" => "PBKDF2-SHA256", + "iterations" => 150_000, + "salt" => Base.encode64("platform-demo-salt"), + "iv" => Base.encode64("demo-shot-iv"), + "ciphertext" => Base.encode64(ciphertext) + } + end + + test_user = + Accounts.get_user_by_username("testuser") || raise "Base seed user testuser is missing" + + orbitdev = + Accounts.get_user_by_username("orbitdev") || raise "Base seed user orbitdev is missing" + + pixelvera = + Accounts.get_user_by_username("pixelvera") || raise "Base seed user pixelvera is missing" + + opsnova = Accounts.get_user_by_username("opsnova") || raise "Base seed user opsnova is missing" + + local_mail_domain = + case Email.get_user_mailbox(test_user.id) do + %{email: email} -> + case String.split(email, "@", parts: 2) do + [_local, domain] when domain != "" -> domain + _ -> Elektrine.Domains.primary_email_domain() + end + + _ -> + Elektrine.Domains.primary_email_domain() + end + + screenshot_user = ensure_user.(screenshot_username) + screenshot_mailbox = ensure_mailbox.(screenshot_user) + + profile = + ensure_profile.(screenshot_user, %{ + display_name: "Platform Demo", + description: + "Curated cross-platform account for timeline, inbox, chat, calendar, nerve, and profile screenshots.", + location: "Detroit, MI", + theme: "blue", + accent_color: "#38bdf8", + background_color: "#0f172a", + icon_color: "#38bdf8", + font_family: "Trebuchet MS" + }) + + ensure_profile_link.(profile, %{ + title: "Platform Timeline", + url: "https://example.com/platformdemo", + platform: "website", + position: 0 + }) + + ensure_profile_link.(profile, %{ + title: "Demo Inbox", + url: "mailto:#{screenshot_username}@#{local_mail_domain}", + platform: "email", + position: 1 + }) + + Enum.each( + [ + {screenshot_user.id, orbitdev.id}, + {screenshot_user.id, pixelvera.id}, + {screenshot_user.id, opsnova.id}, + {orbitdev.id, screenshot_user.id}, + {pixelvera.id, screenshot_user.id} + ], + fn {follower_id, followed_id} -> ensure_follow.(follower_id, followed_id) end + ) + + launch_label = + ensure_label.(screenshot_user.id, %{ + user_id: screenshot_user.id, + name: "Launch", + color: "#f59e0b" + }) + + launch_brief = + ensure_email.(screenshot_mailbox.id, %{ + mailbox_id: screenshot_mailbox.id, + message_id: screenshot_message_id.("launch-brief"), + from: "orbitdev@#{local_mail_domain}", + to: screenshot_mailbox.email, + subject: "Launch brief is ready for the screenshot pass", + text_body: + "I folded the latest timeline copy, feed callouts, and nerve notes into one short brief so the screenshots all tell the same story.", + html_body: + "

    I folded the latest timeline copy, feed callouts, and nerve notes into one short brief so the screenshots all tell the same story.

    ", + category: "inbox", + status: "received", + priority: "high", + read: false, + flagged: true, + inserted_at: hours_ago.(2), + metadata: %{"sender_verified" => true} + }) + + mobile_pass = + ensure_email.(screenshot_mailbox.id, %{ + mailbox_id: screenshot_mailbox.id, + message_id: screenshot_message_id.("mobile-pass"), + from: "pixelvera@#{local_mail_domain}", + to: screenshot_mailbox.email, + subject: "Mobile pass with attachment counts and copy tweaks", + text_body: + "Attached the revised mobile references. The stacked cards and hover states feel consistent now.", + html_body: + "

    Attached the revised mobile references. The stacked cards and hover states feel consistent now.

    ", + attachments: %{ + "1" => %{ + "filename" => "mobile-reference-board.png", + "content_type" => "image/png", + "size" => 184_322, + "content_id" => "mobile-board@elektrine.dev" + } + }, + category: "inbox", + status: "received", + read: false, + inserted_at: hours_ago.(6) + }) + + _boomerang_followup = + ensure_email.(screenshot_mailbox.id, %{ + mailbox_id: screenshot_mailbox.id, + message_id: screenshot_message_id.("boomerang-followup"), + from: "opsnova@#{local_mail_domain}", + to: screenshot_mailbox.email, + subject: "Bring this back after the final launch checklist", + text_body: + "Parking this until tomorrow morning so the inbox shows an active boomerang state during capture.", + html_body: + "

    Parking this until tomorrow morning so the inbox shows an active boomerang state during capture.

    ", + category: "inbox", + status: "received", + read: true, + reply_later_at: hours_from_now.(16), + reply_later_reminder: true, + inserted_at: hours_ago.(4) + }) + + _digest_email = + ensure_email.(screenshot_mailbox.id, %{ + mailbox_id: screenshot_mailbox.id, + message_id: screenshot_message_id.("digest-briefing"), + from: "briefing@productsignals.dev", + to: screenshot_mailbox.email, + subject: "Weekly product briefing", + text_body: + "A compact roundup of launches, pricing changes, and design tools worth scanning before the next planning block.", + html_body: + "

    A compact roundup of launches, pricing changes, and design tools worth scanning before the next planning block.

    ", + category: "feed", + status: "received", + read: false, + is_newsletter: true, + inserted_at: days_ago.(1) + }) + + _ledger_email = + ensure_email.(screenshot_mailbox.id, %{ + mailbox_id: screenshot_mailbox.id, + message_id: screenshot_message_id.("ledger-invoice"), + from: "billing@renderedcloud.dev", + to: screenshot_mailbox.email, + subject: "Invoice #8841 for March platform hosting", + text_body: "Invoice total: $482.14. Auto-pay will process in three days.", + html_body: + "

    Invoice total: $482.14. Auto-pay will process in three days.

    ", + category: "ledger", + status: "received", + read: true, + is_receipt: true, + inserted_at: days_ago.(3) + }) + + :ok = Email.add_label_to_message(launch_brief.id, launch_label.id) + :ok = Email.add_label_to_message(mobile_pass.id, launch_label.id) + + platform_post = + ensure_timeline_post.( + screenshot_user.id, + "Platform demo account is ready for capture: timeline, inbox, chat, calendar, and nerve all share the same launch story now. #elektrine #product", + visibility: "public" + ) + + _orbit_post = + ensure_timeline_post.( + orbitdev.id, + "Trimmed the release notes so the timeline card reads cleanly in a full-width screenshot. #shipping", + visibility: "public" + ) + + _gallery_post = + ensure_timeline_post.( + pixelvera.id, + "Saved a new editorial gallery card for the screenshot set so the timeline has richer visual variety. #design", + visibility: "public", + post_type: "gallery", + category: "design" + ) + + _reply_post = + ensure_timeline_post.( + opsnova.id, + "Replying here so the post detail view has a realistic follow-up thread during capture.", + visibility: "public", + reply_to_id: platform_post.id + ) + + unless Social.Likes.user_liked_post?(orbitdev.id, platform_post.id) do + case Social.like_post(orbitdev.id, platform_post.id) do + {:ok, _like} -> :ok + {:error, :already_liked} -> :ok + {:error, reason} -> raise "Failed to like platform post: #{inspect(reason)}" + end + end + + unless Social.Bookmarks.post_saved?(screenshot_user.id, platform_post.id) do + case Social.save_post(screenshot_user.id, platform_post.id) do + {:ok, _bookmark} -> :ok + {:error, :already_saved} -> :ok + {:error, reason} -> raise "Failed to bookmark platform post: #{inspect(reason)}" + end + end + + dm_with_orbit = + case Messaging.create_dm_conversation(screenshot_user.id, orbitdev.id) do + {:ok, conversation} -> conversation + {:error, reason} -> raise "Failed to create screenshot DM: #{inspect(reason)}" + end + + screenshot_server = + ensure_server.(screenshot_user.id, %{ + name: "Platform Studio", + description: "Public demo server with launch, design, and screenshot chatter.", + is_public: true + }) + + Enum.each([orbitdev.id, pixelvera.id, opsnova.id], fn user_id -> + ensure_server_member.(screenshot_server.id, user_id) + end) + + captures_channel = + ensure_server_channel.(screenshot_server.id, screenshot_user.id, %{ + name: "captures", + description: "Final crop notes and screenshot shot list." + }) + + announcements_channel = + ensure_server_channel.(screenshot_server.id, screenshot_user.id, %{ + name: "announcements", + description: "Launch notes and cross-platform status updates." + }) + + ensure_chat_message.( + dm_with_orbit.id, + orbitdev.id, + "Do one more pass on the timeline screenshot before we lock the set." + ) + + ensure_chat_message.( + dm_with_orbit.id, + screenshot_user.id, + "Looks good. Inbox and timeline now match the launch copy." + ) + + ensure_chat_message.( + captures_channel.id, + screenshot_user.id, + "Collecting the final crops here so the demo pass has one clean thread." + ) + + ensure_chat_message.( + captures_channel.id, + pixelvera.id, + "Mobile email and timeline are the strongest shots right now." + ) + + ensure_chat_message.( + announcements_channel.id, + opsnova.id, + "Nerve, notifications, and calendar all have seeded data for capture." + ) + + work_calendar = + ensure_calendar.(screenshot_user.id, %{ + user_id: screenshot_user.id, + name: "Demo Work", + color: "#38bdf8", + description: "Launch reviews, recording blocks, and ship-room check-ins.", + timezone: "America/Detroit", + order: 1 + }) + + personal_calendar = + ensure_calendar.(screenshot_user.id, %{ + user_id: screenshot_user.id, + name: "Demo Personal", + color: "#22c55e", + description: "Travel and after-hours blocks so the calendar feels lived in.", + timezone: "America/Detroit", + order: 2 + }) + + _review_event = + ensure_event.(work_calendar.id, "platform-demo-review@elektrine.dev", %{ + summary: "Screenshot review", + description: + "Walk through timeline, email, and chat captures before publishing the demo set.", + location: "War Room", + dtstart: hours_from_now.(20), + dtend: hours_from_now.(21), + timezone: "America/Detroit", + attendees: [ + %{"email" => "orbitdev@#{local_mail_domain}", "name" => "Orbit Dev"}, + %{"email" => "pixelvera@#{local_mail_domain}", "name" => "Pixel Vera"} + ], + categories: ["screenshots", "review"] + }) + + _launch_event = + ensure_event.(work_calendar.id, "platform-demo-launch@elektrine.dev", %{ + summary: "Demo launch dry run", + description: "Final pass across the platform before recording and image export.", + location: "Studio B", + dtstart: days_from_now.(2), + dtend: DateTime.add(days_from_now.(2), 5_400, :second), + timezone: "America/Detroit", + categories: ["launch"] + }) + + _personal_event = + ensure_event.(personal_calendar.id, "platform-demo-travel@elektrine.dev", %{ + summary: "Train to Chicago", + description: "Keeps the personal calendar populated for screenshots.", + location: "Detroit Station", + dtstart: days_from_now.(3), + dtend: DateTime.add(days_from_now.(3), 7_200, :second), + timezone: "America/Detroit", + categories: ["travel"] + }) + + unless Nerve.nerve_configured?(screenshot_user.id) do + case Nerve.setup_nerve(screenshot_user.id, %{ + encrypted_verifier: seed_encrypted_payload.("platform-demo-nerve-verifier") + }) do + {:ok, _settings} -> :ok + {:error, reason} -> raise "Failed to set up screenshot nerve: #{inspect(reason)}" + end + end + + Enum.each( + [ + %{ + title: "Figma", + login_username: "platformdemo@#{local_mail_domain}", + website: "https://figma.com", + encrypted_metadata: seed_encrypted_payload.("figma-demo-metadata"), + encrypted_password: seed_encrypted_payload.("figma-demo-password"), + encrypted_notes: seed_encrypted_payload.("Shared with Pixel Vera for mobile review") + }, + %{ + title: "Docker", + login_username: "platform-ops@#{local_mail_domain}", + website: "https://docker.com", + encrypted_metadata: seed_encrypted_payload.("docker-demo-metadata"), + encrypted_password: seed_encrypted_payload.("docker-demo-password"), + encrypted_notes: + seed_encrypted_payload.("Check container health after the screenshot deploy") + }, + %{ + title: "PostHog", + login_username: "alerts@#{local_mail_domain}", + website: "https://posthog.com", + encrypted_metadata: seed_encrypted_payload.("posthog-demo-metadata"), + encrypted_password: seed_encrypted_payload.("posthog-demo-password"), + encrypted_notes: seed_encrypted_payload.("Use this for the demo product event capture") + } + ], + fn entry_attrs -> + case Repo.get_by(Nerve.NerveEntry, + user_id: screenshot_user.id, + title: entry_attrs.title + ) do + nil -> + case Nerve.create_entry(screenshot_user.id, entry_attrs) do + {:ok, _entry} -> + :ok + + {:error, reason} -> + raise "Failed to create nerve entry #{entry_attrs.title}: #{inspect(reason)}" + end + + _entry -> + :ok + end + end + ) + + ensure_notification.(screenshot_user.id, "@orbitdev started following you", %{ + type: "follow", + body: nil, + url: "/orbitdev", + icon: "hero-user-plus", + actor_id: orbitdev.id, + source_type: "user", + source_id: orbitdev.id, + priority: "normal" + }) + + ensure_notification.(screenshot_user.id, "@pixelvera mentioned the demo pass", %{ + type: "mention", + body: "The timeline and inbox now share the same visual direction.", + url: "/timeline/post/#{platform_post.id}", + icon: "hero-at-symbol", + actor_id: pixelvera.id, + source_type: "post", + source_id: platform_post.id, + priority: "high" + }) + + ensure_notification.(screenshot_user.id, "@opsnova replied to your platform post", %{ + type: "reply", + body: "The threaded detail view is ready for capture.", + url: "/timeline/post/#{platform_post.id}", + icon: "hero-chat-bubble-left", + actor_id: opsnova.id, + source_type: "post", + source_id: platform_post.id, + priority: "normal" + }) + + ensure_notification.(screenshot_user.id, "Screenshot seed refreshed", %{ + type: "system", + body: "Timeline, inbox, chat, calendar, notifications, and nerve now have curated demo data.", + url: "/timeline", + icon: "hero-sparkles", + source_type: "system", + source_id: screenshot_user.id, + priority: "high" + }) + + dm_identifier = dm_with_orbit.hash || dm_with_orbit.id + captures_identifier = captures_channel.hash || captures_channel.id + launch_brief_identifier = launch_brief.hash || launch_brief.id + + IO.puts("") + IO.puts("✓ Platform demo screenshot seed is ready") + IO.puts(" Account: #{screenshot_username}") + IO.puts(" Email: #{screenshot_mailbox.email}") + IO.puts(" Password: #{seed_password}") + IO.puts("") + IO.puts("Suggested routes:") + IO.puts(" /timeline") + IO.puts(" /email?tab=inbox&filter=unread") + IO.puts(" /email?tab=inbox&filter=boomerang") + IO.puts(" /email?tab=inbox&filter=digest") + IO.puts(" /email?tab=inbox&filter=ledger") + IO.puts(" /email/view/#{launch_brief_identifier}") + IO.puts(" /timeline") + IO.puts(" /timeline/post/#{platform_post.id}") + IO.puts(" /chat/#{dm_identifier}") + IO.puts(" /chat/#{captures_identifier}") + IO.puts(" /calendar") + IO.puts(" /notifications") + IO.puts(" /account/nerve # Nerve") + IO.puts(" /#{screenshot_username}") +else + IO.puts("Skipping screenshot seed - not in development environment") +end diff --git a/apps/elektrine/priv/repo/seed_test_emails.exs b/apps/elektrine/priv/repo/seed_test_emails.exs new file mode 100644 index 0000000..736822e --- /dev/null +++ b/apps/elektrine/priv/repo/seed_test_emails.exs @@ -0,0 +1,221 @@ +# Script to seed test emails for categorization testing +# Run with: mix run priv/repo/seed_test_emails.exs + +import Ecto.Query +alias Elektrine.Email.{Mailbox, Message} +alias Elektrine.{Accounts, Email, Repo} + +# Get or create a test user +user = + Accounts.get_user_by_username("sysadmin") || + Accounts.get_user_by_username("admin") || + Repo.one(Accounts.User, limit: 1) + +if user do + IO.puts("Using user: #{user.username}") + + # Ensure user has a mailbox + {:ok, mailbox} = Email.ensure_user_has_mailbox(user) + IO.puts("Using mailbox: #{mailbox.email}") + + # Test emails data - each should go to a specific category + test_emails = [ + # Should go to DIGEST (feed category) + %{ + subject: "Your Medium Daily Digest", + from: "noreply@medium.com", + to: mailbox.email, + text_body: "Here are today's top stories on Medium. Unsubscribe from these emails.", + headers: %{"List-Unsubscribe" => ""}, + expected_category: "feed", + description: "Medium Daily Digest - should go to Digest" + }, + %{ + subject: "Pangle Payment Reminder", + from: "billing@pangle.io", + to: mailbox.email, + text_body: "Your next payment is due soon. Click here to manage your subscription.", + headers: %{"List-Unsubscribe" => ""}, + expected_category: "feed", + description: "Pangle reminder - should go to Digest" + }, + %{ + subject: "Weekly Newsletter from TechCrunch", + from: "newsletter@techcrunch.com", + to: mailbox.email, + text_body: "This week in tech news. To unsubscribe, click here.", + headers: %{"List-Id" => "techcrunch.list-id.com"}, + expected_category: "feed", + description: "TechCrunch newsletter - should go to Digest" + }, + %{ + subject: "GitHub Activity Report", + from: "notifications@github.com", + to: mailbox.email, + text_body: + "Here's what happened in your repositories this week. Manage your email preferences.", + headers: %{"List-Unsubscribe" => ""}, + expected_category: "feed", + description: "GitHub activity - should go to Digest" + }, + + # Should go to LEDGER + %{ + subject: "Your Receipt from Amazon", + from: "auto-confirm@amazon.com", + to: mailbox.email, + text_body: "Order #123-456789\nTotal: $49.99\nShipping Address: 123 Main St", + headers: %{}, + expected_category: "ledger", + description: "Amazon receipt - should go to Ledger" + }, + %{ + subject: "Invoice #2024-001", + from: "billing@company.com", + to: mailbox.email, + text_body: "Invoice Date: 2024-03-15\nAmount Due: $500.00\nPayment Due Date: 2024-04-15", + headers: %{}, + expected_category: "ledger", + description: "Invoice - should go to Ledger" + }, + %{ + subject: "Payment Confirmation", + from: "payments@stripe.com", + to: mailbox.email, + text_body: "Payment of $29.99 has been processed. Transaction ID: ch_123456", + headers: %{}, + expected_category: "ledger", + description: "Stripe payment - should go to Ledger" + }, + + # Should stay in INBOX + %{ + subject: "Meeting tomorrow at 3pm", + from: "john.doe@example.com", + to: mailbox.email, + text_body: + "Hey, just wanted to confirm our meeting tomorrow. Let me know if you need to reschedule.", + headers: %{}, + expected_category: "inbox", + description: "Personal email - should stay in Inbox" + }, + %{ + subject: "Password Reset Request", + from: "security@yourbank.com", + to: mailbox.email, + text_body: + "You requested a password reset. Your verification code is: 123456. This code expires in 10 minutes.", + headers: %{}, + expected_category: "inbox", + description: "Security notification - should stay in Inbox" + }, + %{ + subject: "AWS Account Risk - Immediate Action Required", + from: "no-reply@amazonaws.com", + to: mailbox.email, + text_body: + "We've detected unusual activity on your AWS account. Please review immediately.", + headers: %{}, + expected_category: "inbox", + description: "AWS critical alert - should stay in Inbox" + }, + + # Edge cases - bulk emails that look like receipts + %{ + subject: "Your Spotify Premium Receipt", + from: "no-reply@spotify.com", + to: mailbox.email, + text_body: + "Thanks for your payment of $9.99. Your subscription continues. Manage preferences or unsubscribe.", + headers: %{"List-Unsubscribe" => ""}, + expected_category: "ledger", + description: "Spotify subscription receipt - should go to Ledger (receipt wins over bulk)" + }, + %{ + subject: "Medium Membership Payment Processed", + from: "noreply@medium.com", + to: mailbox.email, + text_body: + "Your payment of $5/month has been processed. Read unlimited stories. Unsubscribe anytime.", + headers: %{"List-Unsubscribe" => ""}, + expected_category: "ledger", + description: "Medium payment - should go to Ledger (payment receipt)" + } + ] + + IO.puts("\n=== Creating test emails ===\n") + + # Create each test email + Enum.each(test_emails, fn email_data -> + attrs = %{ + "message_id" => "test_#{:rand.uniform(1_000_000)}", + "from" => email_data.from, + "to" => email_data.to, + "subject" => email_data.subject, + "text_body" => email_data.text_body, + # Simple HTML for testing + "html_body" => email_data.text_body, + "status" => "received", + "read" => false, + "spam" => false, + "archived" => false, + "mailbox_id" => mailbox.id, + "metadata" => %{"headers" => email_data.headers} + } + + case Email.create_message(attrs) do + {:ok, message} -> + if message.category == email_data.expected_category do + IO.puts("✅ #{email_data.description}") + IO.puts(" Category: #{message.category} (CORRECT)") + else + IO.puts("❌ #{email_data.description}") + IO.puts(" Category: #{message.category} (EXPECTED: #{email_data.expected_category})") + end + + IO.puts( + " Newsletter: #{message.is_newsletter}, Receipt: #{message.is_receipt}, Notification: #{message.is_notification}" + ) + + IO.puts("") + + {:error, changeset} -> + IO.puts("❌ Failed to create: #{email_data.description}") + IO.puts(inspect(changeset.errors, pretty: true)) + IO.puts("") + end + end) + + IO.puts("\n=== Summary ===") + + # Show counts by category + inbox_count = + Repo.one( + from m in Message, + where: m.mailbox_id == ^mailbox.id and m.category == "inbox", + select: count(m.id) + ) + + feed_count = + Repo.one( + from m in Message, + where: m.mailbox_id == ^mailbox.id and m.category == "feed", + select: count(m.id) + ) + + ledger_count = + Repo.one( + from m in Message, + where: m.mailbox_id == ^mailbox.id and m.category == "ledger", + select: count(m.id) + ) + + IO.puts("Inbox: #{inbox_count} messages") + IO.puts("Digest (feed): #{feed_count} messages") + IO.puts("Ledger: #{ledger_count} messages") + + IO.puts("\nTo test the recategorizer job manually, run:") + IO.puts("iex> Elektrine.Jobs.EmailRecategorizer.run()") +else + IO.puts("No user found. Please create a user first.") +end diff --git a/apps/elektrine/priv/repo/seeds.exs b/apps/elektrine/priv/repo/seeds.exs new file mode 100644 index 0000000..516fda6 --- /dev/null +++ b/apps/elektrine/priv/repo/seeds.exs @@ -0,0 +1,2630 @@ +# Script for populating the database. You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of your +# repositories directly: +# +# Elektrine.Repo.insert!(%Elektrine.SomeSchema{}) +# +# We recommend using the bang functions (`insert!`, `update!` +# and so on) as they will fail if something goes wrong. + +alias Elektrine.{ + Accounts, + Calendar, + DNS, + Email, + Messaging, + Nerve, + Profiles, + Repo, + Social +} + +alias Elektrine.DNS.QueryStat +alias Elektrine.Email.Contacts +alias Elektrine.Profiles.{ProfileLink, ProfileView, SitePageVisit, SiteSession} +import Ecto.Query + +# Only run in development environment +if Mix.env() == :dev do + IO.puts("Seeding development data...") + + admin_password = "DevPass123!" + test_password = "DevPass123!" + + ensure_admin_user = fn user, username -> + admin_user = + if user.is_admin do + user + else + {:ok, updated_user} = Accounts.update_user_admin_status(user, true) + IO.puts("✓ Made existing user '#{username}' an admin") + updated_user + end + + case Accounts.admin_reset_password(admin_user, %{password: admin_password}) do + {:ok, updated_user} -> + IO.puts("✓ Set seed password for '#{username}'") + updated_user + + {:error, _password_error} -> + IO.puts("✗ Failed to set seed password for '#{username}'") + admin_user + end + end + + ensure_test_password = fn user -> + case Accounts.admin_reset_password(user, %{password: test_password}) do + {:ok, updated_user} -> + IO.puts("✓ Set seed password for 'testuser'") + updated_user + + {:error, _password_error} -> + IO.puts("✗ Failed to set seed password for 'testuser'") + user + end + end + + # Create admin user if it doesn't exist + admin_username = "admin" + + admin_user = + case Accounts.get_user_by_username(admin_username) do + nil -> + IO.puts("Creating admin user: #{admin_username}") + + case Accounts.create_user(%{ + username: admin_username, + password: admin_password, + password_confirmation: admin_password + }) do + {:ok, admin_user} -> + # Make the user an admin + {:ok, admin_user} = Accounts.update_user_admin_status(admin_user, true) + + IO.puts("✓ Admin user created with username: #{admin_username}") + + admin_user + + {:error, %Ecto.Changeset{errors: errors}} -> + IO.puts("✗ Failed to create admin user: #{inspect(errors)}") + IO.puts(" 'admin' is reserved; using alternative username 'devadmin' instead...") + + fallback_username = "devadmin" + + case Accounts.get_user_by_username(fallback_username) do + nil -> + case Accounts.create_user(%{ + username: fallback_username, + password: admin_password, + password_confirmation: admin_password + }) do + {:ok, fallback_user} -> + fallback_user = ensure_admin_user.(fallback_user, fallback_username) + IO.puts("✓ Admin user created with username: #{fallback_username}") + fallback_user + + {:error, fallback_errors} -> + IO.puts("✗ Failed to create fallback admin user: #{inspect(fallback_errors)}") + # Return nil and handle this case below + nil + end + + existing_fallback_user -> + IO.puts("✓ Fallback admin user '#{fallback_username}' already exists") + ensure_admin_user.(existing_fallback_user, fallback_username) + end + end + + existing_user -> + IO.puts("✓ Admin user '#{admin_username}' already exists") + ensure_admin_user.(existing_user, admin_username) + end + + # Create a test user if it doesn't exist + test_username = "testuser" + + test_user = + case Accounts.get_user_by_username(test_username) do + nil -> + IO.puts("Creating test user: #{test_username}") + + {:ok, test_user} = + Accounts.create_user(%{ + username: test_username, + password: test_password, + password_confirmation: test_password + }) + + IO.puts("✓ Test user created with username: #{test_username}") + test_user + + existing_user -> + IO.puts("✓ Test user '#{test_username}' already exists") + ensure_test_password.(existing_user) + end + + preferred_mailbox_email = fn user -> + available_domains = Elektrine.Domains.available_email_domains_for_user(user.id) + preferred_domain = String.trim(user.preferred_email_domain || "") + + mailbox_domain = + if preferred_domain != "" and preferred_domain in available_domains do + preferred_domain + else + List.first(available_domains) || Elektrine.Domains.default_user_handle_domain() + end + + "#{user.username}@#{mailbox_domain}" + end + + ensure_local_mailbox = fn user -> + expected_email = preferred_mailbox_email.(user) + + case Enum.find(Email.list_mailboxes(user.id), &(&1.email == expected_email)) do + %Email.Mailbox{} = mailbox -> + {:ok, mailbox} + + nil -> + Email.create_mailbox(%{email: expected_email, username: user.username, user_id: user.id}) + end + end + + # Create mailboxes for both users if they don't exist + admin_mailbox = + if admin_user do + case ensure_local_mailbox.(admin_user) do + {:ok, mailbox} -> + IO.puts("✓ Ensured admin mailbox: #{mailbox.email}") + mailbox + + {:error, reason} -> + raise "Failed to ensure admin mailbox for #{admin_user.username}: #{inspect(reason)}" + end + else + IO.puts("✗ Skipping admin mailbox creation (no admin user)") + nil + end + + test_mailbox = + case ensure_local_mailbox.(test_user) do + {:ok, mailbox} -> + IO.puts("✓ Ensured test mailbox: #{mailbox.email}") + mailbox + + {:error, reason} -> + raise "Failed to ensure test mailbox for #{test_user.username}: #{inspect(reason)}" + end + + IO.puts("Seeding email coverage...") + + now = DateTime.utc_now() |> DateTime.truncate(:second) + days_ago = fn days -> DateTime.add(now, -days * 86_400, :second) end + hours_ago = fn hours -> DateTime.add(now, -hours * 3_600, :second) end + days_from_now = fn days -> DateTime.add(now, days * 86_400, :second) end + hours_from_now = fn hours -> DateTime.add(now, hours * 3_600, :second) end + seed_message_id = fn seed_key -> "seed-#{seed_key}@elektrine.dev" end + + seed_message_decryption_failed? = fn + %Email.Message{status: "draft"} -> + false + + %Email.Message{mailbox_id: mailbox_id} = message -> + case Email.Mailboxes.get_mailbox(mailbox_id) do + %{user_id: user_id} when is_integer(user_id) -> + decrypted = Email.Message.decrypt_content(message, user_id) + + decrypted.text_body == "[Decryption failed]" or + decrypted.html_body == "[Decryption failed]" + + _ -> + false + end + end + + {paper_pile_feed_updates, _} = + from(m in Email.Message, + where: m.mailbox_id == ^test_mailbox.id and m.category == "paper_pile" + ) + |> Repo.update_all(set: [category: "feed"]) + + if paper_pile_feed_updates > 0 do + IO.puts("✓ Migrated #{paper_pile_feed_updates} older paper_pile messages to feed") + end + + create_seed_message = fn attrs -> + seed_key = Map.fetch!(attrs, :seed_key) + attrs = Map.delete(attrs, :seed_key) + inserted_at = Map.get(attrs, :inserted_at, hours_ago.(24)) + + attrs = + attrs + |> Map.put_new(:message_id, seed_message_id.(seed_key)) + |> Map.put_new(:inserted_at, inserted_at) + |> Map.put_new(:updated_at, inserted_at) + + mailbox_id = Map.fetch!(attrs, :mailbox_id) + message_id = Map.fetch!(attrs, :message_id) + + insert_seed_message = fn -> + case Map.get(attrs, :status) do + "draft" -> + attrs + |> then(&Email.Message.changeset(%Email.Message{}, &1)) + |> Repo.insert!() + + _ -> + case Email.create_message(attrs) do + {:ok, _message} -> + :ok + + {:error, reason} -> + raise "Failed to create seed email #{message_id}: #{inspect(reason)}" + end + end + end + + case Repo.get_by(Email.Message, mailbox_id: mailbox_id, message_id: message_id) do + nil -> + insert_seed_message.() + + :created + + %Email.Message{} = existing -> + if seed_message_decryption_failed?.(existing) do + case Email.delete_message(existing) do + {:ok, _message} -> + insert_seed_message.() + :repaired + + {:error, reason} -> + raise "Failed to replace undecryptable seed email #{message_id}: #{inspect(reason)}" + end + else + :existing + end + end + end + + launch_root_id = seed_message_id.("thread-launch-blockers-root") + launch_reply_id = seed_message_id.("thread-launch-blockers-reply") + + inbox_emails = [ + %{ + seed_key: "inbox-planning-meeting", + mailbox_id: test_mailbox.id, + from: "sarah@company.com", + to: "testuser@example.com", + cc: "ops@company.com, design@agency.com", + subject: "Q4 Planning Meeting Tomorrow", + text_body: + "Hi there,\n\nJust a reminder about our Q4 planning meeting tomorrow at 2 PM. Please bring your project updates and the revised launch timeline.\n\nBest,\nSarah", + html_body: + "

    Hi there,

    Just a reminder about our Q4 planning meeting tomorrow at 2 PM. Please bring your project updates and the revised launch timeline.

    Best,
    Sarah

    ", + category: "inbox", + status: "received", + priority: "high", + read: false, + inserted_at: hours_ago.(4), + metadata: %{"sender_verified" => true, "meeting" => true} + }, + %{ + seed_key: "inbox-contract-review", + mailbox_id: test_mailbox.id, + from: "john.doe@business.com", + to: "testuser@example.com", + subject: "Project Documents - Please Review", + text_body: """ + Hi there, + + Please find the attached documents for your review: + + 1. project-proposal.pdf + 2. mockup-design.png + 3. budget-breakdown.xlsx + + Let me know if you have any questions or need clarification on anything. + + Best regards, + John Doe + Senior Project Manager + """, + html_body: """ +

    Hi there,

    +

    Please find the attached documents for your review:

    +
      +
    1. project-proposal.pdf
    2. +
    3. mockup-design.png
    4. +
    5. budget-breakdown.xlsx
    6. +
    +

    Let me know if you have any questions or need clarification on anything.

    +

    Best regards,
    John Doe
    Senior Project Manager

    + """, + attachments: %{ + "1" => %{ + "filename" => "project-proposal.pdf", + "content_type" => "application/pdf", + "size" => 245_760, + "content_id" => "attachment1@business.com" + }, + "2" => %{ + "filename" => "mockup-design.png", + "content_type" => "image/png", + "size" => 89_432, + "content_id" => "attachment2@business.com" + }, + "3" => %{ + "filename" => "budget-breakdown.xlsx", + "content_type" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "size" => 12_856, + "content_id" => "attachment3@business.com" + } + }, + category: "inbox", + status: "received", + flagged: true, + read: false, + inserted_at: days_ago.(1), + metadata: %{ + "attachment_count" => 3, + "total_attachment_size" => 348_048, + "sender_verified" => true + } + }, + %{ + seed_key: "thread-launch-blockers-root", + mailbox_id: test_mailbox.id, + from: "nina@company.com", + to: "testuser@example.com", + subject: "Q2 launch blockers before Thursday", + text_body: """ + Hey, + + Before Thursday's review, can you send over: + + - the final risk register + - the fallback plan for payments + - the customer comms draft + + I only need rough bullets, not polished copy. + + Nina + """, + html_body: nil, + category: "inbox", + status: "received", + read: true, + answered: true, + open_count: 2, + first_opened_at: days_ago.(3), + opened_at: days_ago.(2), + inserted_at: days_ago.(3) + }, + %{ + seed_key: "thread-launch-blockers-followup", + mailbox_id: test_mailbox.id, + from: "nina@company.com", + to: "testuser@example.com", + subject: "Re: Q2 launch blockers before Thursday", + text_body: + "Thanks. The customer comms outline is enough. Can you add one note on rollback ownership before tomorrow morning?", + html_body: + "

    Thanks. The customer comms outline is enough. Can you add one note on rollback ownership before tomorrow morning?

    ", + category: "inbox", + status: "received", + read: false, + in_reply_to: launch_reply_id, + inserted_at: hours_ago.(18) + }, + %{ + seed_key: "inbox-plaintext-notes", + mailbox_id: test_mailbox.id, + from: "alice@example.com", + to: "testuser@example.com", + subject: "Simple plaintext message", + text_body: """ + Hello! This is a simple plaintext email with no HTML formatting. + + It has multiple paragraphs and should display with proper line breaks. + + The mobile show view should still feel good with: + - bullets + - long lines that wrap cleanly + - a signature block + + Best regards, + Alice + """, + html_body: nil, + category: "inbox", + status: "received", + read: false, + inserted_at: hours_ago.(9) + } + ] + + digest_emails = [ + %{ + seed_key: "digest-tech-roundup", + mailbox_id: test_mailbox.id, + from: "newsletter@techblog.com", + to: "testuser@example.com", + subject: "Weekly Tech Digest - AI Breakthroughs & More", + text_body: + "This week in tech: new AI models, startup funding rounds, and the latest gadget reviews.", + html_body: """ + + + + + + +
    + + + + + + + +
    +

    TechBlog Weekly

    +

    AI models, cloud spend, and the gadget cycle

    +

    A compact issue with three stories worth forwarding.

    +
    +

    Top Story

    +

    Leading labs are shipping smaller models with better reasoning-per-dollar, which is already changing internal tooling budgets.

    + + + + + +
    +
    +

    Funding

    +

    Enterprise infra startup closes a large Series C round.

    +
    +
    +
    +

    Review

    +

    A practical take on the newest flagship phone.

    +
    +
    +
    +
    + + + """, + category: "feed", + status: "received", + read: true, + is_newsletter: true, + open_count: 3, + first_opened_at: days_ago.(6), + opened_at: days_ago.(2), + inserted_at: days_ago.(6) + }, + %{ + seed_key: "digest-retail-sale", + mailbox_id: test_mailbox.id, + from: "deals@retailstore.com", + to: "testuser@example.com", + subject: "Weekend Sale - Up to 70% Off", + text_body: + "Don't miss our biggest sale of the month. Smart home gear, headphones, and travel accessories are all discounted.", + html_body: """ + + +
    +
    +

    RetailStore

    +

    Weekend Sale

    +

    Up to 70% off select gear

    +
    +
    +

    Fast picks for people who waited until the last minute.

    +
    +
    Smart Watch
    $89
    +
    Earbuds
    $44
    +
    Carry-on
    $59
    +
    +
    +
    + + + """, + category: "feed", + status: "received", + read: false, + is_newsletter: true, + inserted_at: days_ago.(2) + }, + %{ + seed_key: "digest-social-updates", + mailbox_id: test_mailbox.id, + from: "updates@socialmedia.com", + to: "testuser@example.com", + subject: "You have 5 new notifications", + text_body: + "Check out what's happening in your network. 3 likes, 1 comment, and 1 new follower.", + html_body: + "

    Check out what's happening in your network. 3 likes, 1 comment, and 1 new follower.

    ", + category: "feed", + status: "received", + read: false, + is_notification: true, + inserted_at: hours_ago.(14) + }, + %{ + seed_key: "digest-community-plaintext", + mailbox_id: test_mailbox.id, + from: "newsletter@community.org", + to: "testuser@example.com", + subject: "Weekly community update", + text_body: """ + WEEKLY COMMUNITY NEWSLETTER + ========================== + + - Tuesday: design systems meetup + - Wednesday: Elixir office hours + - Friday: shipping retrospective notes + + Featured link: + https://community.example.com/notes + + Unsubscribe: + https://community.example.com/unsubscribe + """, + html_body: nil, + category: "feed", + status: "received", + read: true, + is_newsletter: true, + inserted_at: days_ago.(9) + } + ] + + ledger_emails = [ + %{ + seed_key: "ledger-bank-statement", + mailbox_id: test_mailbox.id, + from: "security@bank.com", + to: "testuser@example.com", + subject: "Your Account Statement is Ready", + text_body: + "Your monthly account statement is now available for download in your online banking portal.", + html_body: + "

    Your monthly account statement is now available for download in your online banking portal.

    ", + attachments: %{ + "1" => %{ + "filename" => "statement-september.pdf", + "content_type" => "application/pdf", + "size" => 432_188, + "content_id" => "statement@bank.com" + } + }, + category: "ledger", + status: "received", + read: false, + is_receipt: true, + inserted_at: days_ago.(5) + }, + %{ + seed_key: "ledger-cloud-invoice", + mailbox_id: test_mailbox.id, + from: "billing@cloudvendor.com", + to: "testuser@example.com", + subject: "Invoice #48291 for March", + text_body: + "Your March invoice is attached. Total due: $429.33. Auto-pay will run in 3 days.", + html_body: """ + + +
    +
    +
    +

    CloudVendor

    +

    Invoice #48291

    +
    +
    +

    Amount due

    +

    $429.33

    +
    +
    + + + + +
    Compute$214.11
    Storage$87.42
    Bandwidth$127.80
    +
    + + + """, + attachments: %{ + "1" => %{ + "filename" => "cloudvendor-invoice-48291.pdf", + "content_type" => "application/pdf", + "size" => 182_440, + "content_id" => "invoice48291@cloudvendor.com" + } + }, + category: "ledger", + status: "received", + read: true, + is_receipt: true, + inserted_at: days_ago.(7), + metadata: %{"vendor" => "CloudVendor", "auto_pay" => true} + }, + %{ + seed_key: "ledger-flight-confirmation", + mailbox_id: test_mailbox.id, + from: "travel@airline.com", + to: "testuser@example.com", + subject: "Flight Confirmation - NYC to LA", + text_body: + "Your flight is confirmed. Flight AA123 on June 15th, departing at 8:00 AM from JFK.", + html_body: + "

    Your flight is confirmed. Flight AA123 on June 15th, departing at 8:00 AM from JFK.

    ", + category: "ledger", + status: "received", + read: true, + is_receipt: true, + inserted_at: days_ago.(12) + }, + %{ + seed_key: "ledger-domain-renewal", + mailbox_id: test_mailbox.id, + from: "receipts@registrar.com", + to: "testuser@example.com", + subject: "Receipt for domain renewal", + text_body: + "Your domain renewal for elektrine.dev has been processed successfully. Total charged: $18.99.", + html_body: + "

    Your domain renewal for elektrine.dev has been processed successfully. Total charged: $18.99.

    ", + category: "ledger", + status: "received", + read: false, + is_receipt: true, + inserted_at: hours_ago.(30) + } + ] + + stack_emails = [ + %{ + seed_key: "stack-design-feedback", + mailbox_id: test_mailbox.id, + from: "pm@client.org", + to: "testuser@example.com", + subject: "Need your eyes on the onboarding redesign", + text_body: + "Could you take a pass through the new onboarding flow when you have 20 minutes? The edge cases are in the attached notes.", + html_body: + "

    Could you take a pass through the new onboarding flow when you have 20 minutes? The edge cases are in the attached notes.

    ", + category: "stack", + stack_at: days_ago.(2), + stack_reason: "Waiting for stakeholder notes", + status: "received", + read: false, + inserted_at: days_ago.(4) + }, + %{ + seed_key: "stack-rfp-brief", + mailbox_id: test_mailbox.id, + from: "procurement@enterprise.com", + to: "testuser@example.com", + subject: "RFP response draft for review", + text_body: + "The draft RFP response is attached. I mainly need a gut check on scope, risk, and where we are promising too much.", + html_body: + "

    The draft RFP response is attached. I mainly need a gut check on scope, risk, and where we are promising too much.

    ", + attachments: %{ + "1" => %{ + "filename" => "rfp-response-draft.docx", + "content_type" => + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "size" => 98_440, + "content_id" => "rfp-response@enterprise.com" + } + }, + category: "stack", + stack_at: hours_ago.(16), + stack_reason: "Needs a long-form response", + status: "received", + priority: "high", + flagged: true, + read: false, + inserted_at: days_ago.(2) + }, + %{ + seed_key: "stack-founder-update", + mailbox_id: test_mailbox.id, + from: "founder@startup.io", + to: "testuser@example.com", + subject: "Async update before Monday", + text_body: + "No immediate action needed. Parking this here until after the retrospective because it will be easier to answer with the final numbers.", + html_body: + "

    No immediate action needed. Parking this here until after the retrospective because it will be easier to answer with the final numbers.

    ", + category: "stack", + stack_at: hours_ago.(6), + stack_reason: "Review after launch retrospective", + status: "received", + read: true, + inserted_at: hours_ago.(20) + } + ] + + boomerang_emails = [ + %{ + seed_key: "boomerang-vendor-contract", + mailbox_id: test_mailbox.id, + from: "procurement@vendor.com", + to: "testuser@example.com", + subject: "Reminder: vendor contract redlines", + text_body: + "Pinging this back up in a couple of days once legal has reviewed the draft. No need to answer immediately.", + html_body: + "

    Pinging this back up in a couple of days once legal has reviewed the draft. No need to answer immediately.

    ", + category: "inbox", + status: "received", + read: false, + priority: "high", + reply_later_at: days_from_now.(2), + reply_later_reminder: true, + inserted_at: hours_ago.(10) + }, + %{ + seed_key: "boomerang-school-form", + mailbox_id: test_mailbox.id, + from: "teacher@school.org", + to: "testuser@example.com", + subject: "Field trip form still needed", + text_body: + "This got snoozed for later, but the due date has already passed. Good sample for the overdue boomerang state.", + html_body: + "

    This got snoozed for later, but the due date has already passed. Good sample for the overdue boomerang state.

    ", + category: "inbox", + status: "received", + read: false, + reply_later_at: hours_ago.(12), + reply_later_reminder: true, + inserted_at: days_ago.(2) + }, + %{ + seed_key: "boomerang-recruiter-followup", + mailbox_id: test_mailbox.id, + from: "recruiter@company.dev", + to: "testuser@example.com", + subject: "Checking back on next week", + text_body: + "You asked to see this again later today after calendar cleanup. Following up here.", + html_body: + "

    You asked to see this again later today after calendar cleanup. Following up here.

    ", + category: "inbox", + status: "received", + read: true, + reply_later_at: hours_from_now.(6), + inserted_at: hours_ago.(3) + } + ] + + sent_emails = [ + %{ + seed_key: "thread-launch-blockers-reply", + mailbox_id: test_mailbox.id, + from: "testuser@example.com", + to: "nina@company.com", + cc: "product@company.com", + subject: "Re: Q2 launch blockers before Thursday", + text_body: + "Sending rough bullets now. I will tighten the rollback owner note tomorrow morning once payments signs off.", + html_body: + "

    Sending rough bullets now. I will tighten the rollback owner note tomorrow morning once payments signs off.

    ", + status: "sent", + read: true, + answered: true, + in_reply_to: launch_root_id, + inserted_at: days_ago.(2) + }, + %{ + seed_key: "sent-budget-proposal", + mailbox_id: test_mailbox.id, + from: "testuser@example.com", + to: "colleague@work.com", + subject: "Re: Budget Proposal Review", + text_body: + "Thanks for the feedback on the budget proposal. I've made the requested changes and attached the updated version.", + html_body: + "

    Thanks for the feedback on the budget proposal. I've made the requested changes and attached the updated version.

    ", + attachments: %{ + "1" => %{ + "filename" => "budget-proposal-v3.pdf", + "content_type" => "application/pdf", + "size" => 154_280, + "content_id" => "budget-v3@example.com" + } + }, + status: "sent", + read: true, + inserted_at: days_ago.(8) + }, + %{ + seed_key: "sent-dinner-plans", + mailbox_id: test_mailbox.id, + from: "testuser@example.com", + to: "friend@gmail.com", + bcc: "partner@example.com", + subject: "Dinner plans for Saturday?", + text_body: + "Hey! Are we still on for dinner this Saturday? Let me know if you need to reschedule.", + html_body: + "

    Hey! Are we still on for dinner this Saturday? Let me know if you need to reschedule.

    ", + status: "sent", + read: true, + inserted_at: days_ago.(1) + } + ] + + draft_emails = [ + %{ + seed_key: "draft-launch-plan", + mailbox_id: test_mailbox.id, + from: "testuser@example.com", + to: "leadership@company.com", + cc: "ops@company.com", + bcc: "archive@example.com", + subject: "Draft: launch plan risks", + text_body: """ + Draft outline: + + 1. Payment fallback owner + 2. Support coverage if migration slips + 3. Customer comms if we need a partial rollback + """, + status: "draft", + priority: "high", + inserted_at: hours_ago.(2) + }, + %{ + seed_key: "draft-weekend-note", + mailbox_id: test_mailbox.id, + from: "testuser@example.com", + subject: "Weekend notes", + text_body: """ + Quick scratch draft: + - send photos + - remember parking pass + - ask about Sunday brunch + """, + status: "draft", + inserted_at: hours_ago.(1) + } + ] + + archived_emails = [ + %{ + seed_key: "archive-benefits", + mailbox_id: test_mailbox.id, + from: "hr@oldcompany.com", + to: "testuser@example.com", + subject: "Final Paycheck and Benefits Information", + text_body: + "Please find attached your final paycheck details and information about continuing your benefits.", + html_body: + "

    Please find attached your final paycheck details and information about continuing your benefits.

    ", + category: "inbox", + status: "received", + read: true, + archived: true, + inserted_at: days_ago.(120) + }, + %{ + seed_key: "archive-travel-receipt", + mailbox_id: test_mailbox.id, + from: "travel@airline.com", + to: "testuser@example.com", + subject: "Trip receipt for Seattle itinerary", + text_body: "This trip receipt is archived but still useful for expense reports and audits.", + html_body: + "

    This trip receipt is archived but still useful for expense reports and audits.

    ", + category: "ledger", + status: "received", + read: true, + archived: true, + is_receipt: true, + inserted_at: days_ago.(45) + }, + %{ + seed_key: "archive-newsletter", + mailbox_id: test_mailbox.id, + from: "newsletter@designweekly.com", + to: "testuser@example.com", + subject: "Design Weekly archive issue", + text_body: + "Old newsletter issue kept around for reference while refreshing the design system.", + html_body: + "

    Old newsletter issue kept around for reference while refreshing the design system.

    ", + category: "feed", + status: "received", + read: true, + archived: true, + is_newsletter: true, + inserted_at: days_ago.(20) + } + ] + + trash_emails = [ + %{ + seed_key: "trash-rsvp", + mailbox_id: test_mailbox.id, + from: "events@conference.io", + to: "testuser@example.com", + subject: "Last call for RSVP", + text_body: + "This is a plain deleted message so the trash tab has a non-spam, non-draft example.", + html_body: + "

    This is a plain deleted message so the trash tab has a non-spam, non-draft example.

    ", + category: "inbox", + status: "received", + deleted: true, + read: false, + inserted_at: days_ago.(5) + }, + %{ + seed_key: "trash-boomerang-renewal", + mailbox_id: test_mailbox.id, + from: "subscriptions@app.com", + to: "testuser@example.com", + subject: "Renewal reminder", + text_body: + "Deleted boomerang sample. Trash should still show this even though reply_later_at is set.", + html_body: + "

    Deleted boomerang sample. Trash should still show this even though reply_later_at is set.

    ", + category: "inbox", + status: "received", + deleted: true, + reply_later_at: days_from_now.(1), + reply_later_reminder: true, + inserted_at: days_ago.(1) + } + ] + + spam_emails = [ + %{ + seed_key: "spam-lottery", + mailbox_id: test_mailbox.id, + from: "winner@lottery.fake", + to: "testuser@example.com", + subject: "CONGRATULATIONS! You've Won $1,000,000!", + text_body: + "You are the lucky winner of our international lottery! Send us your bank details to claim your prize.", + html_body: + "

    CONGRATULATIONS!

    You are the lucky winner of our international lottery! Send us your bank details to claim your prize.

    ", + category: "inbox", + status: "received", + read: false, + spam: true, + inserted_at: days_ago.(15) + }, + %{ + seed_key: "spam-phishing", + mailbox_id: test_mailbox.id, + from: "urgent@phishing.com", + to: "testuser@example.com", + subject: "URGENT: Verify Your Account Now!", + text_body: + "Your account will be suspended unless you click this link and verify your information immediately.", + html_body: + "

    URGENT: Your account will be suspended unless you click this link and verify your information immediately.

    ", + category: "inbox", + status: "received", + read: false, + spam: true, + inserted_at: days_ago.(11) + } + ] + + seed_groups = [ + {"inbox", inbox_emails}, + {"digest", digest_emails}, + {"ledger", ledger_emails}, + {"stack", stack_emails}, + {"boomerang", boomerang_emails}, + {"sent", sent_emails}, + {"draft", draft_emails}, + {"archived", archived_emails}, + {"trash", trash_emails}, + {"spam", spam_emails} + ] + + seed_results = + seed_groups + |> Enum.flat_map(fn {_group, emails} -> emails end) + |> Enum.reduce(%{created: 0, existing: 0, repaired: 0}, fn email_attrs, acc -> + case create_seed_message.(email_attrs) do + :created -> %{acc | created: acc.created + 1} + :repaired -> %{acc | repaired: acc.repaired + 1} + :existing -> %{acc | existing: acc.existing + 1} + end + end) + + configured_count = + seed_groups + |> Enum.map(fn {_group, emails} -> length(emails) end) + |> Enum.sum() + + IO.puts( + "✓ Seed email coverage configured for #{configured_count} scenarios (#{seed_results.created} created, #{seed_results.repaired} repaired, #{seed_results.existing} already present)" + ) + + Enum.each(seed_groups, fn {group, emails} -> + IO.puts(" - #{length(emails)} #{group} emails") + end) + + IO.puts("Seeding broader app coverage...") + + local_mail_domain = + case String.split(test_mailbox.email || "", "@", parts: 2) do + [_local, domain] when domain != "" -> domain + _ -> Elektrine.Domains.primary_email_domain() + end + + local_mail_domain_valid? = String.contains?(local_mail_domain, ".") + seed_contact_domain = if local_mail_domain_valid?, do: local_mail_domain, else: "example.com" + + ensure_seed_password = fn user, username -> + case Accounts.admin_reset_password(user, %{password: test_password}) do + {:ok, updated_user} -> + IO.puts("✓ Set seed password for '#{username}'") + updated_user + + {:error, _password_error} -> + IO.puts("✗ Failed to set seed password for '#{username}'") + user + end + end + + ensure_seed_user = fn username -> + case Accounts.get_user_by_username(username) do + nil -> + IO.puts("Creating seed user: #{username}") + + case Accounts.create_user(%{ + username: username, + password: test_password, + password_confirmation: test_password + }) do + {:ok, user} -> + IO.puts("✓ Seed user created with username: #{username}") + ensure_seed_password.(user, username) + + {:error, reason} -> + raise "Failed to create seed user #{username}: #{inspect(reason)}" + end + + existing_user -> + IO.puts("✓ Seed user '#{username}' already exists") + ensure_seed_password.(existing_user, username) + end + end + + ensure_user_mailbox = fn user -> + case ensure_local_mailbox.(user) do + {:ok, mailbox} -> + IO.puts("✓ Ensured mailbox for '#{user.username}': #{mailbox.email}") + mailbox + + {:error, reason} -> + raise "Failed to ensure mailbox for #{user.username}: #{inspect(reason)}" + end + end + + ensure_profile = fn user, attrs -> + case Profiles.get_user_profile(user.id) do + nil -> + case Profiles.create_user_profile(user.id, attrs) do + {:ok, profile} -> + profile + + {:error, reason} -> + raise "Failed to create profile for #{user.username}: #{inspect(reason)}" + end + + profile -> + profile + end + end + + ensure_profile_link = fn profile, attrs -> + title = Map.fetch!(attrs, :title) + + case Repo.get_by(ProfileLink, profile_id: profile.id, title: title) do + nil -> + case Profiles.create_profile_link(profile.id, attrs) do + {:ok, link} -> link + {:error, reason} -> raise "Failed to create profile link #{title}: #{inspect(reason)}" + end + + link -> + link + end + end + + ensure_follow = fn follower_id, followed_id -> + case Repo.get_by(Profiles.Follow, follower_id: follower_id, followed_id: followed_id) do + nil -> + case Profiles.follow_user(follower_id, followed_id) do + {:ok, _follow} -> + :created + + {:error, reason} -> + raise "Failed to create follow #{follower_id}->#{followed_id}: #{inspect(reason)}" + end + + _follow -> + :existing + end + end + + ensure_remote_actor = fn attrs -> + case Repo.get_by(Elektrine.ActivityPub.Actor, uri: attrs.uri) do + nil -> + %Elektrine.ActivityPub.Actor{} + |> Elektrine.ActivityPub.Actor.changeset(attrs) + |> Repo.insert!() + + actor -> + actor + |> Elektrine.ActivityPub.Actor.changeset(attrs) + |> Repo.update!() + end + end + + ensure_seed_poll_options = fn poll, option_specs -> + poll = Repo.preload(poll, :options, force: true) + + if Enum.map(poll.options, & &1.option_text) != Enum.map(option_specs, &elem(&1, 0)) do + from(option in Social.PollOption, where: option.poll_id == ^poll.id) |> Repo.delete_all() + + option_specs + |> Enum.with_index() + |> Enum.each(fn {{option_text, vote_count}, position} -> + %Social.PollOption{} + |> Social.PollOption.changeset(%{ + poll_id: poll.id, + option_text: option_text, + position: position, + vote_count: vote_count + }) + |> Repo.insert!() + end) + else + Enum.each(poll.options, fn option -> + vote_count = option_specs |> Enum.into(%{}) |> Map.fetch!(option.option_text) + + option + |> Ecto.Changeset.change(vote_count: vote_count) + |> Repo.update!() + end) + end + + Repo.preload(poll, :options, force: true) + end + + ensure_federated_poll_post = fn remote_actor, attrs -> + message_attrs = %{ + activitypub_id: attrs.activitypub_id, + activitypub_url: attrs.activitypub_url, + remote_actor_id: remote_actor.id, + post_type: "poll", + visibility: "public", + federated: true, + content: attrs.content, + inserted_at: attrs.inserted_at, + like_count: attrs.like_count, + reply_count: attrs.reply_count, + share_count: attrs.share_count, + media_metadata: %{} + } + + message = + case Messaging.get_message_by_activitypub_ref(attrs.activitypub_id) do + nil -> + {:ok, created_message} = Messaging.create_federated_message(message_attrs) + created_message + + existing_message -> + existing_message + |> Elektrine.Social.Message.federated_changeset(message_attrs) + |> Repo.update!() + end + + poll = + case Repo.preload(message, poll: [options: []]).poll do + nil -> + {:ok, created_poll} = + Social.create_poll( + message.id, + attrs.question, + Enum.map(attrs.options, &elem(&1, 0)), + closes_at: attrs.closes_at, + allow_multiple: false, + hide_totals: false + ) + + created_poll + + existing_poll -> + existing_poll + |> Social.Poll.changeset(%{ + question: attrs.question, + closes_at: attrs.closes_at, + allow_multiple: false, + hide_totals: false, + total_votes: Enum.reduce(attrs.options, 0, fn {_text, votes}, acc -> acc + votes end) + }) + |> Repo.update!() + end + + poll = + poll + |> Ecto.Changeset.change( + total_votes: Enum.reduce(attrs.options, 0, fn {_text, votes}, acc -> acc + votes end) + ) + |> Repo.update!() + + ensure_seed_poll_options.(poll, attrs.options) + Repo.preload(message, [:remote_actor, poll: [options: []]], force: true) + end + + ensure_timeline_post = fn user_id, content, opts -> + post_type = Keyword.get(opts, :post_type, "post") + reply_to_id = Keyword.get(opts, :reply_to_id) + + existing_post_query = + from(m in Elektrine.Social.Message, + join: c in Elektrine.Social.Conversation, + on: c.id == m.conversation_id, + where: + c.type == "timeline" and + m.sender_id == ^user_id and + m.post_type == ^post_type and + m.content == ^content and + is_nil(m.deleted_at), + limit: 1 + ) + + existing_post_query = + if is_nil(reply_to_id) do + from(m in existing_post_query, where: is_nil(m.reply_to_id)) + else + from(m in existing_post_query, where: m.reply_to_id == ^reply_to_id) + end + + existing_post = Repo.one(existing_post_query) + + case existing_post do + nil -> + case Social.create_timeline_post(user_id, content, opts) do + {:ok, post} -> + post + + {:error, reason} -> + raise "Failed to create timeline post for #{user_id}: #{inspect(reason)}" + end + + post -> + post + end + end + + ensure_timeline_draft = fn user_id, content, opts -> + existing_draft = + from(m in Elektrine.Social.Message, + join: c in Elektrine.Social.Conversation, + on: c.id == m.conversation_id, + where: + c.type == "timeline" and + m.sender_id == ^user_id and + m.content == ^content and + m.is_draft == true and + is_nil(m.deleted_at), + limit: 1 + ) + |> Repo.one() + + case existing_draft do + nil -> + case Social.Drafts.save_draft(user_id, Keyword.put(opts, :content, content)) do + {:ok, draft} -> draft + {:error, reason} -> raise "Failed to create draft for #{user_id}: #{inspect(reason)}" + end + + draft -> + draft + end + end + + ensure_list = fn user_id, attrs -> + name = Map.fetch!(attrs, :name) + + case Repo.get_by(Social.List, user_id: user_id, name: name) do + nil -> + case Social.create_list(attrs) do + {:ok, list} -> list + {:error, reason} -> raise "Failed to create list #{name}: #{inspect(reason)}" + end + + list -> + list + end + end + + ensure_list_member = fn list_id, user_id -> + case Repo.get_by(Social.ListMember, list_id: list_id, user_id: user_id) do + nil -> + case Social.Lists.add_to_list(list_id, %{user_id: user_id}) do + {:ok, _member} -> + :created + + {:error, reason} -> + raise "Failed to add user #{user_id} to list #{list_id}: #{inspect(reason)}" + end + + _member -> + :existing + end + end + + ensure_server = fn creator_id, attrs -> + name = Map.fetch!(attrs, :name) + + existing_server = + from(s in Messaging.Server, + where: s.creator_id == ^creator_id and s.name == ^name, + limit: 1 + ) + |> Repo.one() + + case existing_server do + nil -> + case Messaging.create_server(creator_id, attrs) do + {:ok, server} -> server + {:error, reason} -> raise "Failed to create server #{name}: #{inspect(reason)}" + end + + server -> + server + end + end + + ensure_server_membership = fn server_id, user_id -> + case Messaging.get_server_member(server_id, user_id) do + nil -> + case Messaging.join_server(server_id, user_id) do + {:ok, _member} -> + :created + + {:error, reason} -> + raise "Failed to join server #{server_id} for #{user_id}: #{inspect(reason)}" + end + + _member -> + :existing + end + end + + ensure_server_channel = fn server_id, creator_id, attrs -> + name = attrs |> Map.get(:name, "general") |> String.downcase() + + case Repo.get_by(Messaging.ChatConversation, + server_id: server_id, + type: "channel", + name: name + ) do + nil -> + case Messaging.create_server_channel(server_id, creator_id, attrs) do + {:ok, channel} -> channel + {:error, reason} -> raise "Failed to create server channel #{name}: #{inspect(reason)}" + end + + channel -> + channel + end + end + + ensure_group_conversation = fn creator_id, attrs, member_ids -> + attrs = Map.update!(attrs, :name, &String.downcase/1) + name = Map.fetch!(attrs, :name) + + existing_group = + from(c in Messaging.ChatConversation, + where: + c.creator_id == ^creator_id and + c.type == "group" and + c.name == ^name and + is_nil(c.server_id), + limit: 1 + ) + |> Repo.one() + + group = + case existing_group do + nil -> + case Messaging.create_chat_group_conversation(creator_id, attrs, member_ids) do + {:ok, conversation} -> + conversation + + {:ok, conversation, _failed_count} -> + conversation + + {:error, reason} -> + raise "Failed to create group conversation #{name}: #{inspect(reason)}" + end + + conversation -> + conversation + end + + group_members = [{creator_id, "admin"} | Enum.map(member_ids, &{&1, "member"})] + + Enum.each(group_members, fn {member_id, role} -> + case Messaging.ChatConversations.add_member_to_conversation(group.id, member_id, role) do + {:ok, _member} -> + :ok + + {:error, reason} -> + raise "Failed to add #{member_id} to group #{group.id}: #{inspect(reason)}" + end + end) + + group + end + + ensure_chat_message = fn conversation_id, sender_id, content, opts -> + reply_to_id = Keyword.get(opts, :reply_to_id) + + existing_message_query = + from(m in Messaging.ChatMessage, + where: + m.conversation_id == ^conversation_id and + m.sender_id == ^sender_id and + m.content == ^content, + limit: 1 + ) + + existing_message_query = + if is_nil(reply_to_id) do + from(m in existing_message_query, where: is_nil(m.reply_to_id)) + else + from(m in existing_message_query, where: m.reply_to_id == ^reply_to_id) + end + + case Repo.one(existing_message_query) do + nil -> + case Messaging.create_text_message(conversation_id, sender_id, content, reply_to_id) do + {:ok, message} -> + message + + {:error, reason} -> + raise "Failed to create chat message in #{conversation_id}: #{inspect(reason)}" + end + + message -> + message + end + end + + ensure_calendar = fn user_id, attrs -> + name = Map.fetch!(attrs, :name) + + case Calendar.get_calendar_by_name(user_id, name) do + nil -> + case Calendar.create_calendar(attrs) do + {:ok, calendar} -> calendar + {:error, reason} -> raise "Failed to create calendar #{name}: #{inspect(reason)}" + end + + calendar -> + calendar + end + end + + ensure_event = fn calendar_id, uid, attrs -> + case Calendar.get_event_by_uid(calendar_id, uid) do + nil -> + event_attrs = + attrs + |> Map.put(:calendar_id, calendar_id) + |> Map.put(:uid, uid) + + case Calendar.create_event(event_attrs) do + {:ok, event} -> event + {:error, reason} -> raise "Failed to create event #{uid}: #{inspect(reason)}" + end + + event -> + event + end + end + + ensure_contact_group = fn user_id, attrs -> + name = Map.fetch!(attrs, :name) + + case Repo.get_by(Email.ContactGroup, user_id: user_id, name: name) do + nil -> + case Contacts.create_contact_group(attrs) do + {:ok, group} -> group + {:error, reason} -> raise "Failed to create contact group #{name}: #{inspect(reason)}" + end + + group -> + group + end + end + + ensure_contact = fn user_id, attrs -> + email = Map.fetch!(attrs, :email) + + case Contacts.get_contact_by_email(user_id, email) do + nil -> + case Contacts.create_contact(attrs) do + {:ok, contact} -> contact + {:error, reason} -> raise "Failed to create contact #{email}: #{inspect(reason)}" + end + + contact -> + contact + end + end + + ensure_folder = fn user_id, attrs -> + name = Map.fetch!(attrs, :name) + + case Repo.get_by(Email.Folder, user_id: user_id, name: name) do + nil -> + case Email.create_custom_folder(attrs) do + {:ok, folder} -> folder + {:error, reason} -> raise "Failed to create folder #{name}: #{inspect(reason)}" + end + + folder -> + folder + end + end + + ensure_label = fn user_id, attrs -> + name = Map.fetch!(attrs, :name) + + case Email.get_label_by_name(name, user_id) do + nil -> + case Email.create_label(attrs) do + {:ok, label} -> label + {:error, reason} -> raise "Failed to create label #{name}: #{inspect(reason)}" + end + + label -> + label + end + end + + ensure_template = fn user_id, attrs -> + name = Map.fetch!(attrs, :name) + + case Email.get_template_by_name(name, user_id) do + nil -> + case Email.create_template(attrs) do + {:ok, template} -> template + {:error, reason} -> raise "Failed to create template #{name}: #{inspect(reason)}" + end + + template -> + template + end + end + + ensure_filter = fn user_id, attrs -> + name = Map.fetch!(attrs, :name) + + case Repo.get_by(Email.Filter, user_id: user_id, name: name) do + nil -> + case Email.create_filter(attrs) do + {:ok, filter} -> filter + {:error, reason} -> raise "Failed to create filter #{name}: #{inspect(reason)}" + end + + filter -> + filter + end + end + + ensure_alias = fn alias_email, create_attrs -> + case Email.get_alias_by_email(alias_email) do + nil -> + case Email.create_alias(create_attrs) do + {:ok, alias_record} -> alias_record + {:error, reason} -> raise "Failed to create alias #{alias_email}: #{inspect(reason)}" + end + + alias_record -> + alias_record + end + end + + demo_user_specs = [ + %{ + username: "orbitdev", + profile: %{ + display_name: "Orbit Dev", + description: "Shipping product, cleanup, and infrastructure notes in one place.", + location: "Detroit, MI", + theme: "blue", + accent_color: "#38bdf8", + background_color: "#0f172a", + icon_color: "#38bdf8", + font_family: "Consolas" + }, + links: [ + %{title: "Code Notes", url: "https://example.com/orbitdev", platform: "website"}, + %{title: "GitHub", url: "https://github.com/orbitdev", platform: "github"} + ] + }, + %{ + username: "mapsmith", + profile: %{ + display_name: "Map Smith", + description: "Calendar-heavy operator keeping launches, travel, and errands in sync.", + location: "Ann Arbor, MI", + theme: "green", + accent_color: "#34d399", + background_color: "#052e16", + icon_color: "#34d399", + font_family: "Verdana" + }, + links: [ + %{title: "Field Notes", url: "https://example.com/mapsmith", platform: "blog"}, + %{title: "Contact", url: "mailto:mapsmith@example.com", platform: "email"} + ] + }, + %{ + username: "pixelvera", + profile: %{ + display_name: "Pixel Vera", + description: "Collecting design references, gallery posts, and mobile layout edge cases.", + location: "Chicago, IL", + theme: "pink", + accent_color: "#f472b6", + background_color: "#500724", + icon_color: "#f472b6", + font_family: "Georgia" + }, + links: [ + %{title: "Portfolio", url: "https://example.com/pixelvera", platform: "portfolio"}, + %{title: "Design Mail", url: "mailto:pixelvera@example.com", platform: "email"} + ] + }, + %{ + username: "opsnova", + profile: %{ + display_name: "Ops Nova", + description: "Keeping launch rooms tidy, receipts filed, and follow-up loops short.", + location: "Cleveland, OH", + theme: "orange", + accent_color: "#fb923c", + background_color: "#431407", + icon_color: "#fb923c", + font_family: "Trebuchet MS" + }, + links: [ + %{title: "Runbook", url: "https://example.com/opsnova", platform: "website"}, + %{title: "GitLab", url: "https://gitlab.com/opsnova", platform: "gitlab"} + ] + } + ] + + demo_users = + Enum.reduce(demo_user_specs, %{}, fn spec, acc -> + user = ensure_seed_user.(spec.username) + mailbox = ensure_user_mailbox.(user) + + Map.put(acc, spec.username, %{ + user: user, + mailbox: mailbox, + profile: spec.profile, + links: spec.links + }) + end) + + profile_seed_specs = + [ + %{ + user: test_user, + profile: %{ + display_name: "Test User", + description: "Seed account with realistic inbox, social, chat, and planning data.", + location: "Detroit, MI", + theme: "blue", + accent_color: "#60a5fa", + background_color: "#111827", + icon_color: "#60a5fa", + font_family: "Inter" + }, + links: [ + %{title: "Status Page", url: "https://example.com/testuser", platform: "website"}, + %{ + title: "Inbox Alias", + url: "mailto:testuser@#{seed_contact_domain}", + platform: "email" + } + ] + } + ] ++ + Enum.map(demo_user_specs, fn spec -> + %{user: demo_users[spec.username].user, profile: spec.profile, links: spec.links} + end) + + Enum.each(profile_seed_specs, fn %{user: user, profile: profile_attrs, links: links} -> + profile = ensure_profile.(user, profile_attrs) + + links + |> Enum.with_index() + |> Enum.each(fn {link_attrs, position} -> + ensure_profile_link.(profile, Map.put(link_attrs, :position, position)) + end) + end) + + analytics_now = DateTime.utc_now() |> DateTime.truncate(:second) + seed_analytics_prefix = "seed-analytics" + + naive_utc = fn datetime -> + datetime + |> DateTime.to_naive() + |> NaiveDateTime.truncate(:second) + end + + truncate_to_hour = fn datetime -> + DateTime.new!(DateTime.to_date(datetime), Time.new!(datetime.hour, 0, 0), "Etc/UTC") + end + + at_seed_day = fn days_back, seconds_offset -> + analytics_now + |> DateTime.add(-(days_back * 86_400 + seconds_offset), :second) + |> DateTime.truncate(:second) + end + + analytics_base_domain = Elektrine.Domains.primary_profile_domain() + analytics_domain_candidate = "#{test_user.username}-analytics.#{analytics_base_domain}" + + analytics_domain = + if DNS.public_hostname?(analytics_domain_candidate) do + analytics_domain_candidate + else + "#{test_user.username}-analytics.elektrine.dev" + end + + ensure_analytics_zone = fn user -> + zone = + case DNS.get_zone_by_domain(analytics_domain) do + nil -> + case DNS.create_zone(user, %{"domain" => analytics_domain, "default_ttl" => 300}) do + {:ok, zone} -> zone + {:error, reason} -> raise "Failed to create analytics DNS zone: #{inspect(reason)}" + end + + zone when zone.user_id == user.id -> + zone + + zone -> + raise "Analytics DNS seed domain #{analytics_domain} is owned by user #{zone.user_id}" + end + + zone + |> Ecto.Changeset.change(%{ + status: "verified", + verified_at: zone.verified_at || analytics_now, + last_checked_at: analytics_now + }) + |> Repo.update!() + end + + seed_profile_analytics = fn profile_user, viewer_pool -> + profile_view_prefix = "#{seed_analytics_prefix}-profile-#{profile_user.username}-" + + from(view in ProfileView, + where: + view.profile_user_id == ^profile_user.id and + like(view.viewer_session_id, ^"#{profile_view_prefix}%") + ) + |> Repo.delete_all() + + referrers = [ + "https://news.ycombinator.com/item?id=313", + "https://github.com/elektrine/elektrine", + "https://search.example.com/?q=elektrine+profile", + "https://social.example.net/@orbitdev" + ] + + user_agents = [ + "Mozilla/5.0 seed analytics desktop", + "Mozilla/5.0 seed analytics mobile", + "curl/8.0 seed uptime check" + ] + + Enum.each(0..29, fn days_back -> + view_count = max(rem(days_back * 7 + 9, 13) + div(30 - days_back, 7) - 3, 0) + + if view_count > 0 do + Enum.each(1..view_count, fn view_index -> + viewed_at = at_seed_day.(days_back, view_index * 1_379) + viewer = Enum.at(viewer_pool, rem(days_back + view_index, length(viewer_pool))) + authenticated? = rem(days_back + view_index, 3) == 0 + + %ProfileView{ + profile_user_id: profile_user.id, + viewer_user_id: if(authenticated?, do: viewer.id, else: nil), + viewer_session_id: "#{profile_view_prefix}#{days_back}-#{view_index}", + ip_address: "198.51.100.#{rem(days_back * 17 + view_index, 220) + 20}", + user_agent: Enum.at(user_agents, rem(days_back + view_index, length(user_agents))), + referer: Enum.at(referrers, rem(days_back + view_index, length(referrers))), + inserted_at: naive_utc.(viewed_at) + } + |> Repo.insert!() + end) + end + end) + + profile = Profiles.get_user_profile(profile_user.id) + + if profile do + profile.links + |> Enum.with_index() + |> Enum.each(fn {link, index} -> + link + |> Ecto.Changeset.change(clicks: Enum.at([84, 37, 18], index, 9)) + |> Repo.update!() + end) + end + end + + seed_site_analytics = fn domain, viewer_pool -> + site_session_prefix = "#{seed_analytics_prefix}-site-" + + from(visit in SitePageVisit, where: like(visit.session_id, ^"#{site_session_prefix}%")) + |> Repo.delete_all() + + from(session in SiteSession, where: like(session.session_id, ^"#{site_session_prefix}%")) + |> Repo.delete_all() + + paths = ["/", "/pricing", "/docs", "/changelog", "/contact", "/status"] + + referrers = [ + "https://news.ycombinator.com/item?id=313", + "https://github.com/elektrine/elektrine", + "https://search.example.com/?q=private+email", + nil + ] + + Enum.each(0..29, fn days_back -> + session_count = max(rem(days_back * 5 + 11, 10) + div(30 - days_back, 8) - 2, 0) + + if session_count > 0 do + Enum.each(1..session_count, fn session_index -> + started_at = at_seed_day.(days_back, session_index * 2_413) + page_views = 1 + rem(days_back + session_index, 4) + + last_seen_at = + DateTime.add( + started_at, + page_views * (45 + rem(days_back * session_index, 240)), + :second + ) + + session_id = "#{site_session_prefix}#{days_back}-#{session_index}" + visitor_id = "seed-visitor-#{rem(days_back * 13 + session_index, 37)}" + viewer = Enum.at(viewer_pool, rem(days_back + session_index, length(viewer_pool))) + authenticated? = rem(days_back + session_index, 4) == 0 + entry_path = Enum.at(paths, rem(days_back + session_index, length(paths))) + exit_path = Enum.at(paths, rem(days_back + session_index + page_views, length(paths))) + + %SiteSession{ + session_id: session_id, + viewer_user_id: if(authenticated?, do: viewer.id, else: nil), + visitor_id: visitor_id, + ip_address: "203.0.113.#{rem(days_back * 19 + session_index, 220) + 20}", + user_agent: "Mozilla/5.0 seed analytics browser", + referer: Enum.at(referrers, rem(days_back + session_index, length(referrers))), + entry_host: domain, + entry_path: entry_path, + exit_host: domain, + exit_path: exit_path, + page_views: page_views, + started_at: started_at, + last_seen_at: last_seen_at, + duration_seconds: DateTime.diff(last_seen_at, started_at), + inserted_at: naive_utc.(started_at), + updated_at: naive_utc.(last_seen_at) + } + |> Repo.insert!() + + Enum.each(1..page_views, fn page_index -> + visited_at = DateTime.add(started_at, (page_index - 1) * 53, :second) + path = Enum.at(paths, rem(days_back + session_index + page_index, length(paths))) + + %SitePageVisit{ + session_id: session_id, + viewer_user_id: if(authenticated?, do: viewer.id, else: nil), + visitor_id: visitor_id, + ip_address: "203.0.113.#{rem(days_back * 19 + session_index, 220) + 20}", + user_agent: "Mozilla/5.0 seed analytics browser", + referer: Enum.at(referrers, rem(days_back + session_index, length(referrers))), + request_host: domain, + request_path: path, + status: + if(rem(days_back + session_index + page_index, 23) == 0, do: 404, else: 200), + inserted_at: naive_utc.(visited_at) + } + |> Repo.insert!() + end) + end) + end + end) + end + + seed_dns_analytics = fn zone -> + qnames = [ + zone.domain, + "www.#{zone.domain}", + "api.#{zone.domain}", + "missing.#{zone.domain}" + ] + + from(query_stat in QueryStat, + where: query_stat.zone_id == ^zone.id and query_stat.qname in ^qnames + ) + |> Repo.delete_all() + + upsert_query_stat = fn query_hour, qname, qtype, rcode, transport, query_count -> + attrs = %{ + zone_id: zone.id, + query_date: DateTime.to_date(query_hour), + query_hour: query_hour, + qname: qname, + qtype: qtype, + rcode: rcode, + transport: transport, + query_count: query_count + } + + %QueryStat{} + |> QueryStat.changeset(attrs) + |> Repo.insert!( + on_conflict: [set: [query_count: query_count, query_date: attrs.query_date]], + conflict_target: [:zone_id, :query_hour, :qname, :qtype, :rcode, :transport] + ) + end + + Enum.each(0..29, fn days_back -> + date = Date.add(Date.utc_today(), -days_back) + hour = if days_back == 0, do: min(analytics_now.hour, 12), else: 12 + query_hour = DateTime.new!(date, Time.new!(hour, 0, 0), "Etc/UTC") + qname = Enum.at(qnames, rem(days_back, length(qnames))) + rcode = if String.starts_with?(qname, "missing."), do: "NXDOMAIN", else: "NOERROR" + qtype = Enum.at(~w(A AAAA MX TXT), rem(days_back, 4)) + transport = if rem(days_back, 5) == 0, do: "tcp", else: "udp" + query_count = 70 + rem(days_back * 31, 120) + max(30 - days_back, 0) * 3 + + upsert_query_stat.(query_hour, qname, qtype, rcode, transport, query_count) + end) + + current_hour = truncate_to_hour.(analytics_now) + + Enum.each(0..23, fn hours_back -> + query_hour = DateTime.add(current_hour, -hours_back * 3_600, :second) + qname = Enum.at(qnames, rem(hours_back + 1, length(qnames))) + rcode = if String.starts_with?(qname, "missing."), do: "NXDOMAIN", else: "NOERROR" + qtype = Enum.at(~w(A AAAA TXT), rem(hours_back, 3)) + transport = if rem(hours_back, 6) == 0, do: "tcp", else: "udp" + query_count = 18 + rem(hours_back * 17, 55) + if(hours_back < 6, do: 18, else: 0) + + upsert_query_stat.(query_hour, qname, qtype, rcode, transport, query_count) + end) + end + + orbitdev = demo_users["orbitdev"].user + mapsmith = demo_users["mapsmith"].user + pixelvera = demo_users["pixelvera"].user + opsnova = demo_users["opsnova"].user + + analytics_viewers = [orbitdev, mapsmith, pixelvera, opsnova] + + analytics_zone = ensure_analytics_zone.(test_user) + seed_profile_analytics.(test_user, analytics_viewers) + seed_site_analytics.(analytics_zone.domain, analytics_viewers) + seed_dns_analytics.(analytics_zone) + + follow_pairs = [ + {test_user.id, orbitdev.id}, + {test_user.id, mapsmith.id}, + {test_user.id, pixelvera.id}, + {test_user.id, opsnova.id}, + {orbitdev.id, test_user.id}, + {mapsmith.id, test_user.id} + ] + + Enum.each(follow_pairs, fn {follower_id, followed_id} -> + ensure_follow.(follower_id, followed_id) + end) + + orbit_public_post = + ensure_timeline_post.( + orbitdev.id, + "Seed refresh check: timelines, chat, and calendars now have believable demo state. #elektrine #buildinpublic", + visibility: "public" + ) + + _orbit_followers_post = + ensure_timeline_post.( + orbitdev.id, + "Followers note: the dev seed now carries a clean DM thread and a war-room server. #shipping", + visibility: "followers" + ) + + _map_public_post = + ensure_timeline_post.( + mapsmith.id, + "Blocking out next week's travel and review windows in one calendar keeps the rest of the day calmer. #workflow", + visibility: "public" + ) + + _map_friends_post = + ensure_timeline_post.( + mapsmith.id, + "Friends-only note: lunch after the launch retro is still on if the calendar holds. #friends", + visibility: "friends" + ) + + pixel_gallery_post = + ensure_timeline_post.( + pixelvera.id, + "Pinned a fresh moodboard for the mobile email pass so the gallery has a real design sample. #design", + visibility: "public", + post_type: "gallery", + category: "design" + ) + + ops_public_post = + ensure_timeline_post.( + opsnova.id, + "The new server channels are a better home for launch checklists than another wandering thread. #ops", + visibility: "public" + ) + + _test_public_post = + ensure_timeline_post.( + test_user.id, + "Using the seed accounts as a smoke test: inbox, nerve, chat, and lists all have enough state to feel real. #qa", + visibility: "public" + ) + + seed_remote_actor = + ensure_remote_actor.(%{ + uri: "https://mastodon.seed.local/users/pollbot", + username: "pollbot", + domain: "mastodon.seed.local", + display_name: "Poll Bot", + summary: "Development seed bot for federated timeline poll testing.", + avatar_url: "https://mastodon.seed.local/avatars/pollbot.png", + inbox_url: "https://mastodon.seed.local/users/pollbot/inbox", + outbox_url: "https://mastodon.seed.local/users/pollbot/outbox", + followers_url: "https://mastodon.seed.local/users/pollbot/followers", + following_url: "https://mastodon.seed.local/users/pollbot/following", + public_key: "seed-dev-public-key-pollbot", + actor_type: "Person" + }) + + _seed_remote_poll = + ensure_federated_poll_post.(seed_remote_actor, %{ + activitypub_id: "https://mastodon.seed.local/users/pollbot/statuses/seed-timeline-poll-1", + activitypub_url: "https://mastodon.seed.local/@pollbot/seed-timeline-poll-1", + content: "", + question: "Timeline test poll: does the vote UI update immediately for you?", + options: [{"Yep, instant feedback", 5}, {"Not yet", 2}, {"I am testing now", 1}], + inserted_at: DateTime.add(DateTime.utc_now(), -900, :second) |> DateTime.truncate(:second), + closes_at: + DateTime.add(DateTime.utc_now(), 3 * 24 * 60 * 60, :second) |> DateTime.truncate(:second), + like_count: 3, + reply_count: 1, + share_count: 2 + }) + + _timeline_draft = + ensure_timeline_draft.( + test_user.id, + "Drafting a longer note about the seed audit once the last mobile pass settles.", + visibility: "followers", + title: "Seed Audit Notes" + ) + + launch_watch_list = + ensure_list.(test_user.id, %{ + user_id: test_user.id, + name: "Launch Watch", + description: "Seeded list with the demo accounts that post most of the launch chatter.", + visibility: "public" + }) + + Enum.each([orbitdev.id, mapsmith.id, pixelvera.id], fn user_id -> + ensure_list_member.(launch_watch_list.id, user_id) + end) + + unless Social.Likes.user_liked_post?(test_user.id, pixel_gallery_post.id) do + case Social.like_post(test_user.id, pixel_gallery_post.id) do + {:ok, _like} -> + :ok + + {:error, reason} -> + raise "Failed to like seed post #{pixel_gallery_post.id}: #{inspect(reason)}" + end + end + + unless Social.Bookmarks.post_saved?(test_user.id, ops_public_post.id) do + case Social.save_post(test_user.id, ops_public_post.id) do + {:ok, _saved} -> + :ok + + {:error, reason} -> + raise "Failed to bookmark seed post #{ops_public_post.id}: #{inspect(reason)}" + end + end + + unless Social.Boosts.user_boosted?(test_user.id, orbit_public_post.id) do + case Social.boost_post(test_user.id, orbit_public_post.id) do + {:ok, _boost} -> + :ok + + {:error, reason} -> + raise "Failed to boost seed post #{orbit_public_post.id}: #{inspect(reason)}" + end + end + + dm_with_orbit = + case Messaging.create_dm_conversation(test_user.id, orbitdev.id) do + {:ok, conversation} -> conversation + {:error, reason} -> raise "Failed to create DM conversation: #{inspect(reason)}" + end + + seed_group = + ensure_group_conversation.( + test_user.id, + %{ + name: "Seed QA Room", + description: "Small group thread for checking seeded UI states." + }, + [orbitdev.id, pixelvera.id] + ) + + seed_server = + ensure_server.(test_user.id, %{ + name: "Elektrine Crew", + description: "Seeded public server for launch, design, and ops chatter.", + is_public: true + }) + + Enum.each([orbitdev.id, mapsmith.id, pixelvera.id, opsnova.id], fn user_id -> + ensure_server_membership.(seed_server.id, user_id) + end) + + general_channel = ensure_server_channel.(seed_server.id, test_user.id, %{name: "general"}) + + product_updates_channel = + ensure_server_channel.(seed_server.id, test_user.id, %{ + name: "product-updates", + description: "Shipped work, launch notes, and seed script updates." + }) + + design_review_channel = + ensure_server_channel.(seed_server.id, test_user.id, %{ + name: "design-review", + description: "Layout feedback and mobile polish threads." + }) + + ensure_chat_message.( + dm_with_orbit.id, + orbitdev.id, + "Can you sanity-check the broader seed data before lunch?", + [] + ) + + ensure_chat_message.( + dm_with_orbit.id, + test_user.id, + "Yes. Timeline, inbox, and chat all look populated now.", + [] + ) + + ensure_chat_message.( + seed_group.id, + test_user.id, + "Dropping screenshots in here after the next seed run.", + [] + ) + + ensure_chat_message.( + seed_group.id, + pixelvera.id, + "Mobile email is much easier to review once the thread view has real content.", + [] + ) + + ensure_chat_message.( + general_channel.id, + test_user.id, + "Using this server as the seeded home for team chatter.", + [] + ) + + ensure_chat_message.( + general_channel.id, + opsnova.id, + "I left the launch checklist in product-updates.", + [] + ) + + ensure_chat_message.( + product_updates_channel.id, + orbitdev.id, + "Seed coverage now includes contacts, calendars, nerve entries, and social lists.", + [] + ) + + ensure_chat_message.( + design_review_channel.id, + pixelvera.id, + "The email show view finally has enough content to break layouts on purpose.", + [] + ) + + work_calendar = + ensure_calendar.(test_user.id, %{ + user_id: test_user.id, + name: "Work", + color: "#3b82f6", + description: "Launch reviews, stakeholder calls, and design crits.", + timezone: "America/Detroit", + order: 1 + }) + + personal_calendar = + ensure_calendar.(test_user.id, %{ + user_id: test_user.id, + name: "Personal", + color: "#22c55e", + description: "Travel, family, and after-hours plans.", + timezone: "America/Detroit", + order: 2 + }) + + _launch_review_event = + ensure_event.(work_calendar.id, "seed-launch-review@elektrine.dev", %{ + summary: "Launch review", + description: "Review blockers, fallback plan, and final comms copy.", + location: "War Room / Video", + dtstart: hours_from_now.(18), + dtend: hours_from_now.(19), + timezone: "America/Detroit", + attendees: [ + %{"email" => "orbitdev@#{seed_contact_domain}", "name" => "Orbit Dev"}, + %{"email" => "opsnova@#{seed_contact_domain}", "name" => "Ops Nova"} + ], + categories: ["launch", "review"] + }) + + _design_crit_event = + ensure_event.(work_calendar.id, "seed-design-crit@elektrine.dev", %{ + summary: "Design crit", + description: "Walk through mobile inbox and email show refinements.", + location: "Studio B", + dtstart: days_from_now.(2), + dtend: DateTime.add(days_from_now.(2), 3_600, :second), + timezone: "America/Detroit", + attendees: [ + %{"email" => "pixelvera@#{seed_contact_domain}", "name" => "Pixel Vera"} + ], + categories: ["design", "mobile"] + }) + + _retro_event = + ensure_event.(work_calendar.id, "seed-launch-retro@elektrine.dev", %{ + summary: "Launch retro", + description: "Capture what worked, what slipped, and what should be automated next.", + location: "Conference Room 2", + dtstart: days_from_now.(4), + dtend: DateTime.add(days_from_now.(4), 5_400, :second), + timezone: "America/Detroit", + categories: ["retro"] + }) + + _family_event = + ensure_event.(personal_calendar.id, "seed-family-dinner@elektrine.dev", %{ + summary: "Family dinner", + description: "Keep one non-work event in the seed so personal calendar views feel real.", + location: "Grand Rapids", + dtstart: days_from_now.(3), + dtend: DateTime.add(days_from_now.(3), 7_200, :second), + timezone: "America/Detroit", + categories: ["family"] + }) + + clients_group = + ensure_contact_group.(test_user.id, %{ + user_id: test_user.id, + name: "Clients", + color: "#0ea5e9" + }) + + friends_group = + ensure_contact_group.(test_user.id, %{ + user_id: test_user.id, + name: "Friends", + color: "#22c55e" + }) + + _nina_contact = + ensure_contact.(test_user.id, %{ + user_id: test_user.id, + group_id: clients_group.id, + name: "Nina Shah", + email: "nina@company.com", + organization: "Company Inc", + notes: "Owns launch reviews and escalation notes.", + favorite: true + }) + + _john_contact = + ensure_contact.(test_user.id, %{ + user_id: test_user.id, + group_id: clients_group.id, + name: "John Doe", + email: "john.doe@business.com", + organization: "Business Co", + notes: "Project documents and budget approvals." + }) + + _casey_contact = + ensure_contact.(test_user.id, %{ + user_id: test_user.id, + group_id: friends_group.id, + name: "Casey Rivera", + email: "casey@example.com", + phone: "+1-313-555-0199", + notes: "Weekend plans and travel check-ins." + }) + + _orbit_contact = + ensure_contact.(test_user.id, %{ + user_id: test_user.id, + group_id: friends_group.id, + name: "Orbit Dev", + email: "orbitdev@#{seed_contact_domain}", + organization: "Elektrine", + favorite: true + }) + + projects_folder = + ensure_folder.(test_user.id, %{ + user_id: test_user.id, + name: "Projects", + color: "#3b82f6", + icon: "briefcase" + }) + + travel_folder = + ensure_folder.(test_user.id, %{ + user_id: test_user.id, + name: "Travel", + color: "#10b981", + icon: "bookmark" + }) + + follow_up_label = + ensure_label.(test_user.id, %{ + user_id: test_user.id, + name: "FollowUp", + color: "#f59e0b" + }) + + travel_label = + ensure_label.(test_user.id, %{ + user_id: test_user.id, + name: "Travel", + color: "#06b6d4" + }) + + _weekly_status_template = + ensure_template.(test_user.id, %{ + user_id: test_user.id, + name: "Weekly status", + subject: "Weekly status update", + body: "Wins:\n- \n\nRisks:\n- \n\nNext:\n- " + }) + + _quick_intro_template = + ensure_template.(test_user.id, %{ + user_id: test_user.id, + name: "Quick intro", + subject: "Intro", + body: "Hi,\n\nMaking the introduction below.\n\nBest,\nTest User" + }) + + _cloud_invoice_filter = + ensure_filter.(test_user.id, %{ + user_id: test_user.id, + name: "Route cloud invoices", + priority: 10, + stop_processing: false, + conditions: %{ + "match_type" => "all", + "rules" => [ + %{"field" => "from", "operator" => "contains", "value" => "cloudvendor.com"} + ] + }, + actions: %{ + "move_to_ledger" => true, + "mark_as_read" => true + } + }) + + _review_filter = + ensure_filter.(test_user.id, %{ + user_id: test_user.id, + name: "Flag project reviews", + priority: 20, + stop_processing: false, + conditions: %{ + "match_type" => "any", + "rules" => [ + %{"field" => "subject", "operator" => "contains", "value" => "review"}, + %{"field" => "subject", "operator" => "contains", "value" => "documents"} + ] + }, + actions: %{ + "move_to_folder" => projects_folder.id, + "add_label" => follow_up_label.id, + "star" => true + } + }) + + _seed_alias = + if local_mail_domain_valid? do + ensure_alias.("testbriefs@#{local_mail_domain}", %{ + username: "testbriefs", + domain: local_mail_domain, + user_id: test_user.id, + description: "Seed alias for testing alternate mailbox routes" + }) + end + + contract_review_message = + Repo.get_by( + Email.Message, + mailbox_id: test_mailbox.id, + message_id: seed_message_id.("inbox-contract-review") + ) + + if contract_review_message do + :ok = Email.add_label_to_message(contract_review_message.id, follow_up_label.id) + + case Email.move_message_to_folder(contract_review_message.id, projects_folder.id) do + {:ok, _message} -> :ok + {:error, reason} -> raise "Failed to move contract review email: #{inspect(reason)}" + end + end + + flight_confirmation_message = + Repo.get_by( + Email.Message, + mailbox_id: test_mailbox.id, + message_id: seed_message_id.("ledger-flight-confirmation") + ) + + if flight_confirmation_message do + :ok = Email.add_label_to_message(flight_confirmation_message.id, travel_label.id) + + case Email.move_message_to_folder(flight_confirmation_message.id, travel_folder.id) do + {:ok, _message} -> :ok + {:error, reason} -> raise "Failed to move travel email: #{inspect(reason)}" + end + end + + seed_encrypted_payload = fn ciphertext -> + %{ + "version" => 1, + "algorithm" => "AES-GCM", + "kdf" => "PBKDF2-SHA256", + "iterations" => 150_000, + "salt" => Base.encode64("0123456789abcdef"), + "iv" => Base.encode64("seed-initvec"), + "ciphertext" => Base.encode64(ciphertext) + } + end + + unless Nerve.nerve_configured?(test_user.id) do + case Nerve.setup_nerve(test_user.id, %{ + encrypted_verifier: seed_encrypted_payload.("seed-nerve-verifier") + }) do + {:ok, _settings} -> :ok + {:error, reason} -> raise "Failed to set up nerve: #{inspect(reason)}" + end + end + + Enum.each( + [ + %{ + title: "Linear", + login_username: "testuser@#{seed_contact_domain}", + website: "https://linear.app", + encrypted_metadata: seed_encrypted_payload.("linear-metadata-seed"), + encrypted_password: seed_encrypted_payload.("linear-password-seed"), + encrypted_notes: seed_encrypted_payload.("Workspace owner: Orbit Dev") + }, + %{ + title: "Docker", + login_username: "deployments@#{seed_contact_domain}", + website: "https://docker.com", + encrypted_metadata: seed_encrypted_payload.("docker-metadata-seed"), + encrypted_password: seed_encrypted_payload.("docker-password-seed"), + encrypted_notes: + seed_encrypted_payload.("Remember to check container health after deploy") + } + ], + fn entry_attrs -> + case Repo.get_by(Nerve.NerveEntry, + user_id: test_user.id, + title: entry_attrs.title + ) do + nil -> + case Nerve.create_entry(test_user.id, entry_attrs) do + {:ok, _entry} -> + :ok + + {:error, reason} -> + raise "Failed to create nerve entry #{entry_attrs.title}: #{inspect(reason)}" + end + + _entry -> + :ok + end + end + ) + + IO.puts("✓ Seeded broader app coverage") + IO.puts(" - 4 demo users with profiles and mailboxes") + IO.puts(" - 7 timeline posts, 1 federated poll, 1 draft, and 1 public list") + IO.puts(" - 1 DM, 1 group conversation, 1 public server, and 3 server channels") + IO.puts(" - 2 calendars with 4 events and 4 contacts across 2 groups") + IO.puts(" - 2 folders, 2 labels, 2 templates, 2 filters, 1 alias, and 2 nerve entries") + IO.puts(" - Profile, domain, and DNS analytics for /analytics graphs") + + IO.puts("Development seeding complete!") + IO.puts("") + IO.puts("Available accounts:") + + if admin_user do + IO.puts(" Admin: #{admin_user.username} (#{admin_mailbox.email})") + else + IO.puts(" Admin: [FAILED TO CREATE]") + end + + IO.puts(" Test User: testuser (#{test_mailbox.email})") + + Enum.each(demo_user_specs, fn %{username: username} -> + IO.puts(" Demo: #{username} (#{username}@#{local_mail_domain})") + end) + + IO.puts("") + IO.puts("Seed password (admin + test + demo): #{admin_password}") +else + IO.puts("Skipping seeds - not in development environment") +end diff --git a/apps/elektrine/priv/repo/seeds_invite_codes.exs b/apps/elektrine/priv/repo/seeds_invite_codes.exs new file mode 100644 index 0000000..7d73a34 --- /dev/null +++ b/apps/elektrine/priv/repo/seeds_invite_codes.exs @@ -0,0 +1,41 @@ +# Script for creating initial invite codes +# Run `mix run priv/repo/seeds_invite_codes.exs` + +alias Elektrine.Accounts + +# Create a few sample invite codes +codes = [ + %{ + code: "WELCOME2025", + max_uses: 10, + note: "Initial welcome code for new users", + is_active: true + }, + %{ + code: "BETA2025", + max_uses: 5, + expires_at: DateTime.utc_now() |> DateTime.add(30, :day), + note: "Beta testing invite code - expires in 30 days", + is_active: true + }, + %{ + code: "TESTCODE", + max_uses: 1, + note: "Single use test code", + is_active: true + } +] + +Enum.each(codes, fn code_attrs -> + case Accounts.create_invite_code(code_attrs) do + {:ok, invite_code} -> + IO.puts("Created invite code: #{invite_code.code}") + + {:error, changeset} -> + IO.puts("Failed to create invite code: #{inspect(changeset.errors)}") + end +end) + +IO.puts("\nInvite code statistics:") +stats = Accounts.get_invite_code_stats() +IO.puts(inspect(stats, pretty: true)) diff --git a/apps/elektrine/priv/repo/test_recategorizer.exs b/apps/elektrine/priv/repo/test_recategorizer.exs new file mode 100644 index 0000000..0bc7ae4 --- /dev/null +++ b/apps/elektrine/priv/repo/test_recategorizer.exs @@ -0,0 +1,209 @@ +# Test script for the recategorizer - intentionally miscategorize emails +# Run with: mix run priv/repo/test_recategorizer.exs + +import Ecto.Query +alias Elektrine.Email.{Mailbox, Message} +alias Elektrine.{Accounts, Email, Repo} + +# Get or create a test user +user = + Accounts.get_user_by_username("sysadmin") || + Accounts.get_user_by_username("admin") || + Repo.one(Accounts.User, limit: 1) + +if user do + IO.puts("Using user: #{user.username}") + + # Ensure user has a mailbox + {:ok, mailbox} = Email.ensure_user_has_mailbox(user) + IO.puts("Using mailbox: #{mailbox.email}\n") + + # Clear existing messages + Repo.delete_all(Message) + IO.puts("Cleared existing messages\n") + + # Create emails with WRONG categories on purpose + miscategorized_emails = [ + # Should be in FEED but we'll put in INBOX + %{ + subject: "Your Medium Daily Digest - Top Stories Today", + from: "noreply@medium.com", + # WRONG! + forced_category: "inbox", + correct_category: "feed", + headers: %{"List-Unsubscribe" => ""} + }, + %{ + subject: "GitHub Activity - 5 new notifications", + from: "notifications@github.com", + # WRONG! + forced_category: "inbox", + correct_category: "feed", + headers: %{"List-Unsubscribe" => ""} + }, + %{ + subject: "TechCrunch Newsletter - This Week in Tech", + from: "newsletter@techcrunch.com", + # WRONG! + forced_category: "ledger", + correct_category: "feed", + headers: %{"List-Id" => "techcrunch.list-id.com"} + }, + + # Should be in LEDGER but we'll put in INBOX + %{ + subject: "Your Amazon Order #123-456789", + from: "auto-confirm@amazon.com", + # WRONG! + forced_category: "inbox", + correct_category: "ledger", + headers: %{} + }, + %{ + subject: "Receipt for your Uber ride", + from: "receipts@uber.com", + # WRONG! + forced_category: "feed", + correct_category: "ledger", + headers: %{} + }, + + # Should stay in INBOX (personal email) but we'll put elsewhere + %{ + subject: "Meeting tomorrow at 3pm?", + from: "john.smith@example.com", + # WRONG! + forced_category: "feed", + correct_category: "inbox", + headers: %{} + } + ] + + IO.puts("=== Creating intentionally miscategorized emails ===\n") + + # Create each email with the WRONG category + created_ids = + Enum.map(miscategorized_emails, fn email_data -> + # Create message directly with wrong category (bypass categorizer) + {:ok, message} = + Repo.insert(%Message{ + message_id: "test_#{:rand.uniform(1_000_000)}", + from: email_data.from, + to: mailbox.email, + subject: email_data.subject, + text_body: "Test email body with some content. Unsubscribe link here.", + html_body: "

    Test email body

    ", + status: "received", + read: false, + spam: false, + archived: false, + mailbox_id: mailbox.id, + # Force wrong category + category: email_data.forced_category, + is_newsletter: false, + is_receipt: false, + is_notification: false, + metadata: %{"headers" => email_data.headers} + }) + + IO.puts("❌ Created '#{String.slice(email_data.subject, 0, 40)}...'") + IO.puts(" From: #{email_data.from}") + + IO.puts( + " Forced category: #{email_data.forced_category} (should be #{email_data.correct_category})" + ) + + IO.puts("") + + {message.id, email_data.correct_category} + end) + + # Show current state + IO.puts("\n=== Current state (BEFORE recategorization) ===") + + inbox_count = + Repo.one( + from m in Message, + where: m.mailbox_id == ^mailbox.id and m.category == "inbox", + select: count(m.id) + ) + + feed_count = + Repo.one( + from m in Message, + where: m.mailbox_id == ^mailbox.id and m.category == "feed", + select: count(m.id) + ) + + ledger_count = + Repo.one( + from m in Message, + where: m.mailbox_id == ^mailbox.id and m.category == "ledger", + select: count(m.id) + ) + + IO.puts("Inbox: #{inbox_count} messages (should be 1)") + IO.puts("Feed: #{feed_count} messages (should be 3)") + IO.puts("Ledger: #{ledger_count} messages (should be 2)") + + # Now run the recategorizer + IO.puts("\n=== Running EmailRecategorizer job ===\n") + Elektrine.Jobs.EmailRecategorizer.run() + + # Check results after recategorization + IO.puts("\n=== Results AFTER recategorization ===\n") + + Enum.each(created_ids, fn {id, expected_category} -> + message = Repo.get!(Message, id) + + if message.category == expected_category do + IO.puts("✅ Message #{id}: '#{String.slice(message.subject, 0, 30)}...'") + IO.puts(" Now in: #{message.category} (CORRECT!)") + else + IO.puts("❌ Message #{id}: '#{String.slice(message.subject, 0, 30)}...'") + IO.puts(" Still in: #{message.category} (expected #{expected_category})") + end + + IO.puts( + " Flags - Newsletter: #{message.is_newsletter}, Receipt: #{message.is_receipt}, Notification: #{message.is_notification}" + ) + + IO.puts("") + end) + + # Final counts + IO.puts("\n=== Final category counts ===") + + inbox_count = + Repo.one( + from m in Message, + where: m.mailbox_id == ^mailbox.id and m.category == "inbox", + select: count(m.id) + ) + + feed_count = + Repo.one( + from m in Message, + where: m.mailbox_id == ^mailbox.id and m.category == "feed", + select: count(m.id) + ) + + ledger_count = + Repo.one( + from m in Message, + where: m.mailbox_id == ^mailbox.id and m.category == "ledger", + select: count(m.id) + ) + + IO.puts( + "Inbox: #{inbox_count} messages (should be 1) #{if inbox_count == 1, do: "✅", else: "❌"}" + ) + + IO.puts("Feed: #{feed_count} messages (should be 3) #{if feed_count == 3, do: "✅", else: "❌"}") + + IO.puts( + "Ledger: #{ledger_count} messages (should be 2) #{if ledger_count == 2, do: "✅", else: "❌"}" + ) +else + IO.puts("No user found. Please create a user first.") +end diff --git a/apps/elektrine/priv/static/favicon.ico b/apps/elektrine/priv/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7f372bfc21cdd8cb47585339d5fa4d9dd424402f GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=@t!V@Ar*{oFEH`~d50E!_s``s q?{G*w(7?#d#v@^nKnY_HKaYb01EZMZjMqTJ89ZJ6T-G@yGywoKK_h|y literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/Geist-Black.ttf b/apps/elektrine/priv/static/fonts/Geist-Black.ttf new file mode 100644 index 0000000000000000000000000000000000000000..3aa546260cfa132ff55fb1033602bf6abe375a3d GIT binary patch literal 90780 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 z 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
    !|`%U!f7uezClIUmjDofLz^U*!> z*}&@qV%%eNPm5hhZFDpHQ23^mA9kT(-b$5=c+(-}P=q&W=9g z#x)(ev--l^yn=!}xLHN+z)@lKy6XbcJUah)epy*Qt~+ITURbeD&;E{&28~u|`nJ7! zK3`sbPR^-JuQ!ul+Beiw@poa^>CMRS;_8pwS-row*dKcd-YGaW=->02jVBX)8R;KJ z(t;l&4bR00uLT!I8oc(~cyHu6_$%@qpUbDE$0F%Q0^h{@7b0oF6Oo26CCUrFhBSl_ zkrq4&=?N{3(|kdvk$yRn7L*oguw{|Ip3sL#zZp&QoVN`M1peEy) zDmsOA)^(JP*a0AKmB{NwULHRjl9y(oS!{VGiB7%0ZQJ{NCPeJ=xa~{KUaGckS+wf- NF6aC2cCH6X{{v6yw<-Vt literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/Geist-BlackItalic.ttf b/apps/elektrine/priv/static/fonts/Geist-BlackItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..865b65ef5ebf9f4e966be699d03ee7982a871a34 GIT binary patch literal 92616 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 bk0AFm?b@QZ6U?JhIJJKp2;tv{IgtGyedgxb literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/Geist-ExtraBold.ttf b/apps/elektrine/priv/static/fonts/Geist-ExtraBold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a4d91eb9cae5367150be89aced7178d3f9039530 GIT binary patch literal 90796 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 KCw%nz!WRH7R^H_R literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/Geist-Italic-cyrillic.woff2 b/apps/elektrine/priv/static/fonts/Geist-Italic-cyrillic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..c28b0dbbe8d980f4f2adee93c84c76f1417f7e23 GIT binary patch literal 15960 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 WQUyBNGDe*#F8|M$(?9*(i*PeNJl~E0 literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/Geist-Italic.ttf b/apps/elektrine/priv/static/fonts/Geist-Italic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..df5eb3799611818694bb8adba5d4169f9bd43924 GIT binary patch literal 92492 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 literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/Geist-Light.ttf b/apps/elektrine/priv/static/fonts/Geist-Light.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6d65f0f0b712a8a58874958980ed89ab9d322e7a GIT binary patch literal 90752 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�yrf~U_cZZiQ9nR=> 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≤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

    BPsR2O)Vg|Sdj2=)mVBvtr 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 literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/Geist-ThinItalic.ttf b/apps/elektrine/priv/static/fonts/Geist-ThinItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..787150a437498a65a2646eb5d13b916ccb0eb51d GIT binary patch literal 92648 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 literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/Geist-cyrillic.woff2 b/apps/elektrine/priv/static/fonts/Geist-cyrillic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..4b7a2bec63a95ed108dd2e8e79337bb6a2bfaf77 GIT binary patch literal 15084 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 OXZNpuUsPKB2><{xq*XQm literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/Geist-latin-ext.woff2 b/apps/elektrine/priv/static/fonts/Geist-latin-ext.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..ba90e209496d19069f05deeedac672fe02e1314e GIT binary patch literal 16512 zcmV)6K*+y$Pew8T0RR9106>5M6aWAK0FHP706+@>0RR9100000000000000000000 z0000Qh5;M65*&dnKS)+VQiC`@O;$ltJO*GtQ&d4zfj9t#Brkpu2nxq&ya)?}Q~)sJ z00A}vBm;*e1Rw>78V8Rp3r++25U zTDrmt_Z9i79qkl0l^)r_3)x$0@W5BkTxUi9ZAD~=43Xgrwa~q$?q(27(t&RMduWZC zY6M}ln%JSv3QbShI#wXE-y3Wec%{R3_D-@5|@MDj#HM5HW?su^?9%+Dgr>P5BdQh&%8 z`WfaTYk1n`KQ?juL@Q<@MiC{)!3jqlcpOIv1|U`}T}QJ0ieAN~PJ^$6ht*@tve@lp zs|>R)kZ|1$5qJcN4l*zd5D1B%w?F?* z_0L5~S71^M;btjk=@9Ab0n7Hb_k}>+N-0rF4mn_wWfBqvH=4ae%@Ru}08F&x;SKhp zPABi@$=JZH|Hr?2@8AI(zFE>$c=}~or%F+Mlo1vp7<7C7ZO19a5J87)*3527GfCS6 zCU^huUZ_BO04U7GZtVlHZ~#yM0qRtw#jGXQ^$hyeb~MM)$Ym@NO#%G#{QJAB3bh^YZRGwD2-PMCmd%xtxJR`lCS#0ld&pHC5gE)k)tBll zU&eN_JDu)HZGS+L-@-8o(CK_W-AVG2hip01&ddIDT1iL#m?%lR(HS`MMUODexO1tt8PBEbv4pA`I^@YG@BVF{eQ zd#48e&67U_uwZ>yC;(-laSU@A6em8SshCa4&H=ZfBwxyU6 zK0y>=gb_g)VT^He>}mI7>i4kPIsAQQQ9_a6i6`L`SIziq{_{?dgaEQE$j%6QhygL# zLk_qQ9|eiYAq7{+jTUm}4ter|^!%Uz!BD7hC{i>OD+x-G4t0|Y_0$LI#{lIqLPHIM zMi>o^H36DrGBnjpXtssWVoRU`rBJyFs7fug%t~mrwa|JSq0P2KJMDq?se_I>0iALV zy5JIY#Z~AZ|3FQekZv*~S=o@0xXAg?1q9s{`?7&{0Q z5Lq|xx#NFJ@YeWu`#yj_(2IMqLTyxNAX5+YOFsvv>>P=c5i&+LI%y~8jL5{IgOl|5X za}G}xY(Su>;eZSQ6JjWSUiGo8cs;O`l~|t3w~6K*Z&ACncK^MH@9Ylbb4Dy04VrZS zd+E76HAJ1F(lj8Ie2&%VbX>w^4BVJl_{M^C=tCn)deU*W!kTO|GGi*Yd2;aLFqP%2Tc z9Ogu=-jsbLS!i)H>$%Dl9$vr#0{-y#kf1Zz!es#wA9opGd+{K^O!3B|?Yyf62fC9c z40Hk6X#?R-AUnHxs0ZlK0KE&ig6+{<;H*;ydVpr(x`F(h&OKN}W7|x0v zZeSFRgMkmR5pX!25)QuOKs2P;^8h=05L~YZ2xPMg*gW(7%=gtZJIwxlsrd>Q7k~x? z6Ao4F6ddfqv2izj!fH?mr?>5BUKiT54Hd1xmDu75jCz2&mvvvX6klyPU5C7Pz;&>e z%`JQ~u$yiKB!C6#+RLg4Bso|u_&cvXAHp{NkAsm5DBcIx?OmJ2sf9De2*V0TfSn3O zBU4oM!$k1jHiYlaB+6LcnJ*Sp?jbje23D~Ls!_YrL0BP=Q8J-;91yXrE8|q%ukrhK8Ob7c| zG!Z^M!3hvG=V|JnEoO@F^A%H1-xpnrGu*eRZt?g!mQ1zmIj@@fr^GG!INytsiVgRH zuaxaTN;tHOvS!C>Gc5z&>O#4kf$DdGZ-+E#?I8s&Q`p^QI(xk4*Rj@g7T3F);ao`@ zebdUuU>Tt{xRVK@*74}!#63prE-8x+=wtcpay}f%@na8&0lslDuyF`T$hcBc(NTa% zsR|Mb5CbzC436X#6cN><&yewkO)=A4ORTWgy4uj`(oy0K8bw5vvj29xiQkc|ed|QL zTD%riB}WwU?2ZW6(WE3EIM%m!{?@En6Stz2I&Jc%xUruw0{qZY2YA&$tfu`-G02}N ze%ad5EbS^g4P_7U=2aI0^fZJTzB}3!P1HWCaYW!k{ITwFy>(a*)J|7@orJrDRrH!# zTr&Y8BH=!4LTpwiK>4%E2B=G{7F0WN%Ht!N(~Y>FXgH!R`c!^wUh8=IF5Y>h2>)_l1@kJkRwnKW z5z~UP$*Tv%udWc??Tt81&3%fIcbD*s@(XJIs`pW)TW!p<51>C${ZEPSR2%+j27=2e z%{1xgY1Eac=4#Jlw@!h`b$TW|YDQB^ugw^!2+qVcqFqW2LVEW>P4=l0+4y%dg1k?n zx;U}N$*|839BVI%Xf>e6@qdXVFlR+g+^# znu==hA1ONOvVYA+c-&Lx3V7l|V=Bb*a6h**T|BaS>*$Mup0|&WL`2VM53dK0-qrPC zw$HMt{zJBQVpZ#-nPHd9K01pZSetZQjHzcz>QEWn^H$r4ZNTWNzpJ$;${mU}%`z&y zEYZUD$*9@A2vZ}C=Yf%>jE?)&271_I4nP#uyOx)uwxpI`iDn7So@dg#LQ;D7-irg}4}hJV55F`l$~Tqd!!pERG;*x2lc+&jryd%bDw zV9trk#iF;mtm7FH4+W@0jF`9e8>6K=&IcZQiaN8XD{@@V=bm%Goy;^k7|UPJYj?rn z#V~I?n#mt?XVB+fn*sEXtaFXjPb6>MXZz3ldfkkr2JNXUGpk`%){A@C7q+hJeJ&BopZ5K;V(0g?Y$ZQB8hELtZGAfjA*r2cdVFY^2vBOyGLBq zpeL|GoGH}}(RLZ03pwj}Bt^D3ey`K;S-t|PzMog^bIekdmUx-5JZd_3m*gs6>Ph=V zHnqHc!u@@B(GlkHN(`F#`dzuU5_jLPK38h9BHHF?Qiqo3qYRV3??q^A;%W|2&bP~6 zm;E~@9XNY7)2`O}T$Oy>jNO`i(#6xEr;t`E&6+G%(;6g=*a zHib6Fw3zTGl>%cjfk5K#s2{bRKlg2ZKcl(=$~gWQlVoHyI$lb6)oL1{O5m2m4Ao}r z%s+5nKNsMcGw(zAIln*A1p4(zv9qM0CjF0x#`6s~HUcpoDElWh5)HH89Q%?nhvO)H zGiqa)g}H?U;xx(_6_wA6{Rn3p-(t2I&q!gn&2lZbGeZW`Z8xo|nNkS`Rk~qP-LPwB zL}0qrH4Dts+d7rC7%Vh0Ye%qbkyM+&=+Fiu)9giN;2`Fwd<0R+89QZ!**c2E9=ohj ztiTE-vh*R%$}RX$aTsNW9cra!+z1{HZi$#M*;ZxQ2zJe7!DpesUzG-`%r@21+5p5U z9JE^SWWBuf7(tO06BK3a#()>o&=KeeTy|Jk5$*FL$lF!)1|z(y zfs};zX9v!>xJY6y+hK?Jvl|u_ErK4bI4pYk+=tKD>dP(@mxVatKktSm^YZVMEV)=z z;YPgY>}XgKgcZF}y&~Q3djky-(Oze|>K{whs70r$Xwg%F9&~oH>vO5y$_Zj(lAxnK zlN}6voNg%M6np^iAUpW&K06q*fMOrJ<@SpBK)dzrLxL~HLxX>$&OWcT3sCG$#Vr5| zyRD4@r^4L0Xuk61{^$c=&R38cD}+xbO1IsfC*!5rYWD~7^vT%m6f$fWV6g zU?pHa5+We*-~($vDEL7PP(WTkw>jQ^`^f9Zv5%&BTe?b^EbJ2w3``Z*h+D*KnS5!L zbVVW+r^-5dJ)4$2m-83nPL4bEC1RHrqP^%G8mB`>lCj0uhPU9|ct1XbA?&bpwwdHP zS80%rcP0MufcW2X&v*~L*Ki+w7=2_1U$9@m&;e(aQ-kt9Yh^_MTz%;u6rE?FOmMwDP^5UiyTb8wQo25sf(uvBr@pS(;_pn!1?stei_)o;qLWtFby8k~ESfxbW3i2AZvvKa z98AJBnb33s(}>K#HHitTnl;lsvufMOJ~ZN9FkIbXu?nNG8)#R*TX^RwOFR z=il8p$1-9;#pHDNX4lk4&pOZYG+C)9d15}of9TnNE>NTYAw}>9g#P6MbLJ2H;n?gT z2#`Iv(IZh#frJO^Q2t#m`ybJ(WqxP{KC!;phyg;ic!rU?x~X;^+{>)ST8A;JdN zy8}c9oiNhEb6c*7z5wxa2JDJBSG6Bp~oZs!5RF~SUxZMamntKJ!&p1psrQ#3Qh;RNR=ITtW)N>s3> zVA3LkHqq)=cFrS3PpvJ;vH&5>a7x`SlD)vPOHVnk3H6(ToK;{o=^F*cCe0ReE#(WY zz7JWDeMFIbY$wpbfQW?}%paIg!e!>83VqV!j?sHc5pIZrQ89FCuy4>eDd)6|hV!x= zT3$+CU2na+e9~gzBMDkOTusWvF5zw-UwIA{EJ@3LlqtX3Bg+~JJrA#S^J(OQgPJ*d zXnkwZd9p@yae6Ff$cp)^=dXT?+5AbX{Z5J-v#`W?Yh)5VCNz%b!whX~c_!D~>@L5D zywTgAPX5DJSZU@ueZl`k(E;Lp9D5yj2?A;*MR$=as9>7S`K4_-)>FJ~P2`2Wg0Soa z{qxWkNqQn@=&))=Xnw(|O9wHo`Xg8Z6Go0-ooDhtGX{>!nU%ReU?*`Q?O0bRpU85k z&dOFM25s3brIhRKcG1ffgc*>h1SrJT17<_jNf4e4YRtfvb5|N7La{i=s1*!U-US>I z5=WL-zHskg1{4oA53U#sE`A53c7Af7&R@Noj)qr$CrDv} zAnoAMMC~MD(a#_-sf|SbY1-=Y=9z#1)=&QHezNm{r2QD`)@E3V2!@Pb!eH8^n zFkN-~IxIePw|8@t={HNtO{#tC0+9Pmm=rcB%!q^H&2EoA zqL)wedgW6iu)Z;Q@vj4w5T36sia7isrow) z{4u7vvs%%ls}Q4x5(f#PN-D&E$IZQ?Y}dS%F>nJiLu!pxhn@v?wsV3S>7v$qsG)Kb zmM^a|Btv=G-w`5Q;1D!bit1}sGH-j8$=0iY!lSevs#l~l<=5)0n(FM|>{%#}Oo*z) z_G(R?x3>cL?T0xO$F2D7h1-GU_GR{#3nlnsb}h_59Rl3X`Mxu9 z)vQFy@;M-l98T`V-%X#hIXI9t(*J%RF`oW6bkUtnX1(5SCD<2pU=36!{pT_n&q4?M z-bm*__;cP{>;$xeL(7X*6fJKi>=}8cTC}JT5f>cH^vui7H-Zd0+65~o;ka_ROOL7p zBBf)2n>x!Rlb9S)zdtFa^CM9%bW;~7i9k7^(+wfGcG%^jRDOJEsCA3U7kbPx!tV7{ z6xhqP<=Lc4lkL`)l_Som-{))x8FX_OteAx3is25u$0n;9f+m;3V7hQn^^sn^s-i(EkzH&oWZQAjapw#Ww} zkWNNcr{dUO8Y$UUhBEbz0C2?T+4Q0^#1e2iZ2|D>eI$UCt-XG&u^3*weB;CG6p`Fk zRpeE=aJ5xpl4-40R0S{|&I56)(UO!$*&4hE`P3-;Y~H1$^}X{=tAFIs!=!rwI9Il{ zrUmzL0H+MiUpK5H=B(S2hNKs(ZM6knnG07}ChB-aYMTXB1LkV9Aw4g%6>llY)9P}{ zSgWBogKp|xV+5B}noXuIxPS{+PU?bw^$-$|$K^fE&O*!XpylZ7mtAp3FV=&C!t^)r>NlK)qE`q!r`rP^0T-^ZN{sTl zdaPcB^RSsQw$_7y3p=^~;q#Nvd#}q(I`7}f?HIEn+#`srM%8KCTC&rxkMeImzh<`Z zNBD1NYe6+`8#JKXZ@*b?6!`k-BQYmN9|=^bHtly&#g$Py6xIO(m;$MPh$^qd>&!3= zQtqNA6cm12`n^l?J@^^JVt?PrT4n)~S?JO)kMOHtEv!IP;{u}-$>B&-eWXH4s`=X| zs?gMBa7Zk5$^^Y$6;UKGl0Sg=nRxOA@*|BjQwG~vZc4$zF!dCpI$HwLl}hZ2Xl(~_zg*Y zx=JjT*QZHt*l!s6D`3C=rhui4zbj^8S(qpEv4FS|;*(41|E7fy6$U$II2Yrb+}mnm zL;64I=LbPasD*4H+`CXa34q`U|}JMO)&TH82Z7TRGPZDEaII zLuQ^bIO|oL4aExRXio0dN=32B0Or;e>I>N}&Af5E4?Bo!=4UXNN;WUbuSrIWlFFIS zCK!B0UQLk(l=K-HMzGSVsy@wQ^MuS>dBu#l#ER2iX*!iNcpvFhMHLcbo)-lC=J6Hv>HS)uWz)!=>qK++4m{~%(aRc|KP^7ntRMP1Y^opW3yI_-a9kT#w2&1&=h z&OqtwJyqqBrTp0P^Sr>}{Mu-xVcsb-i|?UmVeY<5zaAHI z@GC<>uj+px`YZa*y-5pWH&_({)0q8oT%5ITu}U{vt8d$I(Ut!|v~$>tw1aMTV%{RA z2;t0{Rez4$f5`4-JeAU#w8^8%Z54G0Mw(-IjyuQn6wO<1I{X4jZ!rtS`}J1x^v2{GOFn(@ zTi^W0LV)w9Df%~r(AFW4w(QM9&R1jBS+!y$L~gre5x#(nU_%}eyt0_s+_M-{xdRnl)}WgW6O+q-r>2_*FdM!# z(sJx|+2%1||+~6@xGC*ci4=X}14!)$q`NtN!*m z>sd5wImxW~RJ)dcrfGGKn3!CveD==}7u~(rt2?@|^c%Lse9P#Q>-;8fTJfXnmr%KH zeG7h8Q{wqeA#zH*AmkyvZeMH3!o|o zJC`e8jJ`HmU3>0avcP5s_bI*;uwS{a8mafcz< zj(x`!>cvf0SiE%?JMwEIgU}X_bdwiy)2Dc{lhNUv z9JVJwo!va0wpr<|J8@pjE^vORlUsr zMt}A*Y-))l)<;F)7n~)H6OYVPTL1*4VXC8m4E?T}epidWFmlJq*7Jnr>I92^q7|M9 zT0oeRLBe3EkhgVxBt_032h`Gx327FmkFz9VoMl_Va2Xx-ttaaX0J?=UsYO48d6&?E zb{7d?O>myJkNzFht^~qxNq;F`F~9}-0l1csD+3b1n$W!L0vTO9eZN;!eNN!~a9K$! zLv{f1b@=*@_z8WVhn|84I?)PmfK#U-P$P);I4Nem0k?Gq8{}_Q-`{<-w0YfOv_n(9 zOhYyFj^!7t&*b!_Y$NuA(){||x=bzC+T=FQl8WsI_*)wq&Vedxr9J=#B8-l|l@&sJ zN#msF4nb`J{75}-Vf#rQMaT+5Byt|Y>F5yx?I3TWLW&#~MyW_GA(6v!Yv2t+UuD-8 zsapZgj7}D>c+kJ%$Zf=+EmI%C&nMi`ntMl$Byb8T;OBj|2?F^!0c`JRkew0DB3eYx zn3)E>9{J9$(eeKmbU=RjQ!x1L|Nc(nE>w!%hMD4o>zdv7F0vFy7H68%m#JBtrxjb) z@>gd~sCl)z-Zxn*YSPwH*Ao{BkwG$@e2V;o>=;@b8d8mp2hR>(N$s?ePNehcm*@}Z z(cvS=Byu@&8(YhMz<$sEJ7N;O6upIec$AOjw?-Tuu`r@7Q7MUCB{`%fsb88VeM0)0 z^b={v$mx;GBVAdM$MLiAt7J?L%02RAd7b=2`495{M;#xvGRjq6RK_W@mCq{QRu)FL z1Q7{xBq(TYw6U~Vv^T{U;&<_c`{?HxS&TA2oqG#q?RspMqrDI{NxvbT!ovh<*3L9eE*#UMJdkuRh`#Ae5Czn&nspD8V zK2AGlHRn~%bFMu%nTv8sZiG9AJCnPdyOn#G`vLbm?myh$JPuFJtLG8C5O07tmA8Sn zk9UT5i}xk(civ+@@JI1sKFROme=bN9kb=E}9|VsQ7zxP<;)J>cZ^D>_O$l!%G7?h~ zt%;GuWruQ>fisz*atqo8#W7ZW40;+zkh4lK^VT+{!)?N793pzZN~JcKZV+V=>KUWe9Qn1 z82Er31t6vc@0wtQFw&k!4=TmgS&eTzZ7VK&uPMX8;Y2@%gJ}~Tl!BW7xZ!Ty37JJh zr#@3E&Ytyl_xXOd#B1d+C;hLa5il%Lo*$)y9{^N>gR3KbBFs?u=-m`YJ%}SUlUz~x zl@Bsr-5uB$r&zh(9^bt)BLp>;(kO%=$T!Zk+V>FD{|%UZVaq2fcWfFJ(OU%e1~tyf z3sO&}p3)_~0#O_VrlHhcF@&PwR7i%L0ecFFxxAG)Fzq~gqbZ@W{V9}5-J$;c#9Jse z$XoPo6q4gR{;;`&twH#0EYr)XLV!$l9JpmUfWQ%E-JC9Raf9eSpSL@!C+16AF5w%7 zR{4*skDiWcv-;q>M?JaFl(v0u(*nJxxt!0KIruISzzFn1{|3%Z`}9EwNj#ivvX~TT z9pku>j8f98*Wd^{A@O<<6O6$D`#l`$MGFI+pX=l$nGEPN{_E&OjWe zd9okNsKjfkCb_*rngs~=9of@kw(USn&u3#T=^b%l6z-1f;0EgvF0Rk&)!IxRVn~vq zjCwBh5gyuKI?iMHWhOGmiw(6gP+Gjo^CD|DGeAf_+sm_ZA7eP1lybW6%Rwn2h6s*; ztfF*^3l_)g2Q%aK#h~sBs1ji-ba;V?u_#$gl+dM1#d|M4CMMjUTg@6^eI&+ekG;a4 z&W5@GMQDzeLx_$tT|ngt=nU#C1O6O%d;$fgUS_ zJ!7eCsa6CrmDib5%Wx_KP^GtZ0}|3?0BGzEP&$<`L&zBVUu;k?sOO99k?fxM^#?ro1d+z^&AI93EnMS)BO_K@NBtZ!|tU^fMMmY@J+pR2~MjCCkO2sm%qGMEXX4@ z75FdF1`X0t&O@+*=9-S*#>s3!me!G>cYQst{>>PWX?WnO3cwY59{1mU@}}hooZA9wOHjLAXlSbmRj7m7rS~W;6HB*1>Fra6 z%}t%5eo&5v1@l@C&T}9d394!eLqQliww1dg2MpiOuZyvM4cZGb_a;8u|gO@p3`dC9V1bHxB zIFOJa73N`HvTFB&h;4tLFhe(?Qp({`p)d)KtQEIvE?5?3VebHy$|%?+5}SfgO%!!o z%@ciSM=k@4Wpstp3|=t3Kvq*uZqdY}X2o;;YS#^m_aj{7de5|AkGWK>C=?yH(MEa_ z+Mib5?!bQivy<_zYX#-g=H}I-VnY=eB$KJeXh|c)@|cX)r_(8+NJDRq^%`X}`|$}6 z`9ZJAQ*Jb3sT>P2D+U*1Vb|WSe05svI_y^QZQlW(8 zU;dQ(8U%N8&Z}SlXIgePz_kPlK||(xILkbBeaEZki>)AvHzH@8&Dde*x|;_j$JlE= zFmXJBg&j>cMK`8!9-U+H2qGz|ug6*fPJ*`5F8=5L2SbZIKVRGkl8-Nbm>!)R##MS~ z0_s0Q0XT{<#+YXO?NbeW9!M-(5AO`xN1TN}YJ;sjS`%RBI}`0uzvy;eN!7KrJYP*G z4M8$a8x5oF1k}*f!eYoezz(ZC1`30Mok!-fL0HT*@NY_r=*dAewSq2PDiOwa>`2pB z`_usTM$PaZDPqfqxj|kwFVHpZ5ZAm6&ECM=dDy)GlB#XhSkSa{Ap?6%=pF5=K?PB& zrbK6em2j$xk)W72=RwGK+Tiw>$Aj;L+5vxVE;YWfA4(DZw7s8>;3J*$@FiWwa5eRw z*ZF00V5p(5ZBT#b(G}e|?t--c2c>?@3sO(&Lv9^i(l>$-Y5>r)qcZ)UKU)?A$Qi-r zwb$u{uasXt7Ks2qfim0zP_w{k$ zO5!LKAwWXB&cFE9c7|=<0SZVt{4?Ki1z1S;-W{j<3IE$RAHl09Q$ytL+aBqX9)XBO zIM5I_|>zz#CrOxotvS5f2gPU;hQ=(fzo0%gCGLIQD{LC6k zCDcM)_inEnotdE1gI-PdhU+z(+L(9h)uz_0*PlvpsMcYmZFl58<#~!uE$RiZ@WPgB zk;3ShdKG39#T^7&2{pq7T&r zG$2FWn#K2H+hg@)r3B|qm^f)p68&a}^LunyQKKG3BvmCfj?=SBud6CZG2)WW_iw1T zEr-CoO9MY>*MzEve#_%KUYp9lF7e6HahYZ$(l}kA+zX*_#z@|jld|JH?xB-5eN9Bs zU~t(6n3hfl;HJ|Hw`?V4AaSMLrt zWvWW!SRc{et;QJu6QaIu+6+ci%r-WWX(5m|Uz77fw}GAETldq(0byF;^?-H#bU zc`AdTE1P^*nrfdapU!D9L{aWFub_5g!+dA=M(SM&RrwbYWAm6yqy2+?xY_J*Q2#}g zTWrVq*ENtkhD^xW^eSUJ69WmpU|1&F00(3(moPbCiOeF>$Wss$oHBu)7*x!bts6HG z9i-FMqd;GfrZF+-gQ`JB33?$DbSM(Hc=N!^XFlxx(0s=B^h@RPLmNwlgMsc@Kwx^b z=nzIgFe4aY{Y2#(2Y_Zd&#Z!wNPq@#z#D|42wH~twdSe?nDR<$C&>gtp_a4J0SzAM z+wm5oSwNe$I~>7+NT~g~nlkja19^Z5-6JlwEOLW4J*6XF1oLab(m2X*Tm!}t`Eq_X zm9;e5RbyL+65?mTIBr*7)^=uP;XPSLN)4!>9Vo!#Vzxe*dy%&0$9dv|pxETSf|fY< z(hN($X{VjXDFSmC{z&vg5Nxm)Wc60%%M*$zNq{KouEgwIHf{C~*?6#wZur4AuK`OR z57*AU^V<ZaU#X$>kRFFg%Z61><>UGT46BKCpDjB)-l3XSjO{< z#7rrx=*)A5YtwA>l}Y+>$6p7gu)Muc02TAMoNgm{HR?L`atf`oLkElmomoz(gDpX{ zA<}Oa5tz|)UC3^&?zqFWm*47E8?Et(@`BN;Yx-=+jB~hyN!^T59Ol_bC0Xw9K|XAi z#b-GO%mF{N{|*+XvAhe+<*rgv2BP3?r($fckd(4m6!V2l_`Cs<^pQl>Aq%SuF>ka5(Z61)Pc8khJ4%^;mK0cAe_vyKl_vGU1WXFAy8&i= zM;tej9!h!Ai$_#3Yd4l{XW0@1?N)hE8ME28>)H1-jhYO5jy==Al7z0(r)wf3FYm@58SVdD7^l;keV|#&!kg2;YvfH zA2Q)1fVOSeEF^t1;T1dHJACvFK_zI;=(6FRB!i5X} z{2ID0oM$DK2$9Wc!yzsWlfuGl)5XnTQO@Ez6!LsC3qL$d1PKkr(9svDOk-vfNXnr` z_t+)OHK)q18P!Z01`E93BTx7@`(vi8Ny7WFD}5kJuo*TN?(DoBJ|TWT$8OKIeb2cF zsImkJaRn@SdS(kg*(sSm9B^&iSBox7TrV$xG$;xS zQ30E`yGo)rYfT$66oqJo&lI0u`=k+rf%B+dUT)iV;93041LT`@DW5zGhTa{0);vhB z_|RPx8Y?SGT0akE#!nj8ka9}2k@fHX+u%ljS;ZeYRJo_X?Oc zn#!r%rEs7nsaBaJad%l1JzW$mD3%GS8LdgGiTFd|Jp!E2-YYPYVKlLZiNadh((!IxUW%yBYE$9VSj0Gz z)0TDNdtD9o=Jxw9-$A|NgXe`k6Pm%@9Yb6tHK)-Z2JYx4vN(-#%T2|z7l_OE4s!>D}dRjBDDB=v>5I`xEhFT%7 zO~XT54aUrj%D$~JTA?v^4>B{#=wsy_f`O1kqlrSVb)P;)pWU$nP2AHuUW`_}$1oJ~ zbCVl-zQXT&U)pP6JOig(7*Gu3iBrzUL5HBr1(gCXiR$W}blY3h@u=W+V6+ye_D8BCoC6@^S4D?}fZ zAFxy;b}eK#o2y#Q$o{W>(cDp$BAxE2fo-&UUz>eJxGJS9e;XY?9%VPla|ic~5-MCS z@Nb?Vk7_R)MJ2O3D37kxEp(7U9xfRzZXF<-iNif*MHyQ`eq>%}*bATYGB=TJCbC(O zHz6o+x&R9{t9T!wn-GkS8R-Saw7+}&xN#6Ti$8Qq-+ga_1I?)h>+SM%O5U`6r_Nns zA%W-)OCp5tl78i8Y%jph9kOb$ic@E3KF&%HcucwiInPk|Wm>%#V4rVIvd}Eh3dC`4 z(%hz-RPgZ03U~S*y?S&K5&=Wzi;oQ=uB+O$gdv*$yCs90{EM?Ju)hZYUZw zLNqNY(ocsI-sR76@-n}1D=ofjG1gL`r*oSAWJ&9U1i@K`^kk3|9e2K{r&W|g7eE-o zEobdWQi&YL$m(dw z4J94DOB#j+KL12BXoJbhP#0Tv-jnh05iJMiXjuAP-9TccQ zfjFj%Tp}b0A;H^#F+QN}l-v10;jRwmpbf0H)940A##OM+F5fJc#*l=p+22xC_zyx@ zgbFUfD_wx;dvsJYigxs)F|R)}2a_h7{8|tTY94<{RYyvv2;|H10br%!Q@(6DK_@bY z(<0u%oLbn3PT4xn2STUwF0E~lWZ8FJr)3!;;u|Xx={mY5vvwo`dbc)hVI(9gs#L*P zp*_ACW(*T;w)F5O-_WW2IbP*GDcF8$W_c&id8j?htjY*M^iRX7g)7YY;+48pUuUGAHSy4e@ZgGBiBRUQ?5+oJCW;IpW6~8{R4&@|@mZmX>S2J_( z`3LneI5f@AxsOXw`#+!kAcRs0v7EiOvjxBSLmzsz%F?B0dhHcp%ClZ+A>;jB760NF zLnfa86XCn=A_sxM&M9!W-~LzCpGR#% zJ$G~1)-q=e3eo)sHRQvn0SbZclO=Iw_KV_reHq{$G%B^*WK&?2P*K!i~#{*J1a;NtO%nBW^KgnE1b$^UzC3j3wls*AUnVU` zIABTqcw%HAcJU!_i-6>x_n?vGoanYi44XB%LStJHV0IFrFcXO*Dg_c74<}0Rkzjj@ zl$hm^;d-I7$pz&&>Tw})2nD`PxRT39iD@owC?io3>W!L0C>m^fw8X^H89Uscf8bK8 zoQg4XZ`CBD%!8@tSp!hr!XFPS2Xgda-2X zDxMCRNX6j^ZWopLUOvC1^`50O>H1}5kfXHkKT8+RnXAk$v-psiSrLm8n5C?<&eVlO zGyAAkVoh>t;U|HGnyIEd{`g514Eyl%#q3h9-w)c8Oa1n#d>K_E@7h~fHs~R+Ta0Hp zv&Dviy^W=ue4v$XlRcdbU-e6`5GSb!woO|2zVzUiS!Zr z9!Zju!Y8SGla_RT$w+3h+!v7S|J^yy4C2k|1ntb+RD14u8GE7cm* z<9MUlYIm5sJs&p=hNJO>Z8~G0FP6S{=W4y#a_;sAuH)&9xcHHO;KqFqk59%+eU+>S?9jEU9 z{Y4X?bYV9Z!I)uRUm;{LlZBfhWJ=u37Jcjw`V0Kyqy~Ce?p6!(?`_ZncxDFBT?&e6 zL?YQEL~%sq;UxTdXVnc*Qd{*YjsC?KAf!hrqW7GCO)wj{W-U7XVyix#j5Zh+G1{HX ztp#S!%Bk(MT4UD=2B-i&>4sPKj?EKJeFUPXI5Xo-Vocgn?H04)O<7l|-@UF%GZI76 zDr2B3c&b@0K;vw$uN9z#_}HF-;JT%!!&`&?mbf5dD+I$0M%ysyZRkc>ora|0HCr&i zC~*v|ReD&L>{@wd|4ee}Lvyt-8xO~iBYVTZxg?XiNG%0!UfcHE+87O`ipF&Dv!6SW zc@FQUs~Fz~k~OK}8YM9C%set@t`#2SQNY5p*n{u@D&N&?X|IBQEpOExbsROPuo0U~ zqiI)i(Ny+ZPl7?cgx;&K*D!jGF3~K+$$Ns6bM;#5C2YR8bkN!(uN~6*2tWjHfB-=R zCpb@@r{a_IR6Yfp<~h7p>19)n@@o0Ji_8T*>Gpg&K-z*d+ujIMG6ESWm9_y{Yp#ai2uP(IB5z rzdl9YD{&$KN~fx%9Eu7#;QT+WKqf@#KpiB~;0#vO>i6aWAK0RadA0CHjg0RR9100000000000000000000 z0000Qh6NjepB@~COg~6gK~jfXKTTFaQalD=KT}jeRDn1Eg%B@(5eN$QXx<|WghBu? zfv9K!HUcCAi2wv31&A64i%ARz8zS))Y*%BTp1|g%HJ8sbD%h@O+zv$4`n_s4!iJ3l zAl@7k{eMzY8AB7;lNO*l-R=*yWFwRsIf8~9GL0FjwPHg^@2WWFJlFf012?ihsP#qZ z`;kyPGs$e>Hzyupcl@oUYGa#9SVz!vuHm}znb*V>SP;aAF)%QlYFJI3FdKLK;o9Vm zL%sj3B9mWq;uIuOmskyHi7k;>(vv|E#`_|7Mc32Pk68q`P5g+29AAWr)~F(~disD- z5k1a6Is!+ekc4EGfX3YEm(Rih&k@b}KYJIwgd~IzLI~mT03n#AG?)b~MN(7(MZA

    6yP-h*_UmWm>FB3IQU-1<}Iu{JlGWR<|)Z;EicO6DLr(A24;o z|0kXHy+I8KZ5c3%1D~?{4;Z`axhXP|kBj!oYnsoT1z>N$abyxS0Js^?qnz4|p)g~W zFi>Jxa3m)(Pys-F-N{7^VhI2eotQ_fAzGgel1Xjype6J*v<*o;?cWPxwlc#qI>O`v zgQ$Q!;@VF6eLAaOUEQLV*fQ;S$S||JK1BOn5M3B{Nty$K2q;6~__F)ooPK{LOOE3} zfj~?3(+L@Y9 z3zQ-Wp?~D|Ac92lwycGr-1ZCLpMY3vCY{t7N+DL31CN1_-%Joanp-FhGO^FhP`~~B zuVpFicmI0BD2~#ul&7SYc9cT#6_S_^5Je;?b~C-Bxxam*ko!`eljN8$ug;l;NXN0=^j2RkmXrBN76Bkk-Mly*c9;2}Myf({r{APW?3Lq8@0KYy(h4zJD*em<^1lqnS~i>l zFI28wx{HflfY@CS(gGy!7OS)XdFO@V2SuMiQt}btMN*!TLrio^A0U16Nu8`^I3J{Q z2%%^~$eoMQO`ogUMWu03d9Elsmyw>woA|)2cboLBzyi!-r{Et;u~)_AQ}=L53m{f=Ce83*GPL_fsnx zGP*{x4+$y~WeEhSB_{RV*S!O?Vk)fzExU7}3*h6`-wWXi080U%0M25g2$^?|=TyZ$J4i&9|nVX;K^$AC&Hto|M1y%e%imq%G?y z{Tlsx{SN&;W6oGKmW^vnH%^*qbKZ*Eh#j}*>;>m0=T_%-=Wgd7=Lr|t5BFF1lzscb z>VAB`xL>N+$2%3d60D>ueU<(ycGx={9Bx&Gs<*0EwHh{gbS+SmYf3GBU^{x;LAd^9 zYubuioTD^I!?d5S*QGv`VW3|NvDW22GK+lDzQ5UCpftNVL1r}K0$RfOgJdA}TBbY|W zQ_vXc(k0C(Wfs$X>`(mEpZS?j#mz)3m<3rNgpaQfd&x#pPnt

    4xJl4d-DN79mrX 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 G@d*GQ_e_ld literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistMono-Black.ttf b/apps/elektrine/priv/static/fonts/GeistMono-Black.ttf new file mode 100644 index 0000000000000000000000000000000000000000..48d2bc0b494651a463d6ce936a9a669804890f49 GIT binary patch literal 101444 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%0DS-Y^5p;=0LWgIxImR4oj)VSB3G|oC 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 HXYGFhQY47B literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistMono-BlackItalic.ttf b/apps/elektrine/priv/static/fonts/GeistMono-BlackItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a6b343acbdf8c3cfd967c2145b2ae1a0c98c54fa GIT binary patch literal 104716 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�HfN^(XXCGT8>|3tT4G&gr?%eJwM^z@7| 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 literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistMono-ExtraBold.ttf b/apps/elektrine/priv/static/fonts/GeistMono-ExtraBold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a0757cbed978e86258c5f5008c49eb3db4717070 GIT binary patch literal 101348 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 UfW9;!R@XJJWWM&5%OK)^0G$Mv(f|Me literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistMono-ExtraBoldItalic.ttf b/apps/elektrine/priv/static/fonts/GeistMono-ExtraBoldItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..29ace525635f42d9c93c634a26c0d892dec70fc8 GIT binary patch literal 104504 zcmcG%2YggT*FQcp_wH^sJt2e;LP$bF32n2;icOc^X_J>U6+BQ#}*v(CSS?eMOlpXT0DGo zFK>K@GjE`L9H7h?Jtlw5m*d{*fw~_dK44Z=X>G*84bvD4{hTrP{<)>~wMdUdekz_p zb1Rq3DP41UN5(d7X6(C_Bb4pIwdwE}WktJ{0lP@~Vc#KQG$-17pd+ z7xh?W&8*U&W(D-)fby=Vc+-_z&Fc|mys3#r;K|H| z{5O`)7PC+pv;7&e6e#)b?8o58gx|BzOgCJ{QT8ue8D`Ex%&OM~VKKY1dM=(aY}Tu= zq}>==+E87%L^cI6{&-{CZG&b^kLdd|GXN8R|DLevM)~|QGxy=&v(GLxTxM@3m=jL@ zNos;97DIk26N~1SH%M?54W*U5E%GDL>qIq55FS~TcQ=yoFvSEs9UyU~n9y{BoiCqk z2%pBhyb!6Jxn*g_?qZ@Tl0SD!#TXkms&FW~lqLPvqxjs1+|>L%Af- z$$>Lm)j@9w)(a_FaM^4j{Kf1t_*bwk@VBxb;QxdlXTR`4jPoIU9Q+9!W%w2RRrqi4 zH{kE$yW#KQd*Hvz_rm`d{}}$K`~dvV_!sd1&HoMm1pgWSX?_}hlR)1IrvObNNQA+U z6jAV7iB|Aii#G7%MI!tZkpe$eq`~hjy1-8t>F_m3X3(QjZGa)71oMTqPuQ6-UYR(LBsyCdJ zjYx3Iq#6u01~Z2_#+=N;%<1L~)*59U=K1DO^8#}ipa~}IC5hjySx>+!VWU_nt71#o zmFz}#2YZM;#dflH*?#sd`;{9!g16(Hc_#1AXY=*^O>puM|CawIOtd^x^cUxg*`ih~ z6)VKe;vVsccvieFJ``Vw@G@^|}Bi$%BE;F7nUNiO@2aT_dpG|J|GpCsqW}|t% zd7F8k`GonBx!e56JYt?OPdkDfJsf$CV#iEJwPT6nTE|+)J&rAoXB?k8esVHrsI!f; zqjQLJiStV5jm|rqo1ITNcRJs7e(wCn`K!xtMYuY+db;|#hPkG>s$Cblu5{h#`oMKE zC@E-s(CVP|K@SE!?l#?#?gV#dcc#0ad!hRp_s#B&?uXsm-JiL?asL`@1h)!K3{DTu z4qh01S@8A2w*}u9{Aln?!EXnD6nrH3M2H#EF{D>WpOEuI#)s5~Totk=WJAc7kf%dl z4S6r*K*-ObJTxq{ZD`lftkC|URiR5luMAxs`by|Kp`V6+8Tv!$*|4y%wqdDZp0NI5 z=ZB3Cn;BLec4636VK;_t4BH&`Y}o5zd&3TdeI535I13LAj|(3cUK(B*z9{_4@YUh} z2){r4(eNGNuZRCD{PXZ{!heY{BO)T=Bhn%~5q%>DMvRJhE#hos&&a&UqR8QqlOoF^ zH$)zZJQ0;0RTFh#)Rj@Iqy7=~P}H`l7ov7WeH8UY)bXg_qs{1u==kWg=;_gyN52=n zKl-caAEM8+3ToA=RYj|{t?p^{NULXB9cXp5)sHbeCNw5KCN0Jj(Sj_V=Z^nEV^LuMY>&VvaTF-5LOY4oTA8x(9^-HaPi%pC5#P*3Ti5(L=J$7Df zW9;Rzt7319y*GAi>Rv)#MxK5h49yYCZ15@HjQ z6S^m4CG<}il5kJLmV~Dhb|&miIFN8O;m3rt?cMF$v`=Z@qkZ4@1KW>kKec^D`-b+H zw76Hj+=b%^fJzC)J| znH~Cd7~ElehdCYUIxO#SZHL=C+}B}ihi5yy*5SPl2Ra<>@Jo`J6p<94l$JCz>Dr{5 zlAcR?HR*$-!%4p;MXr$Sw(8#&ns{rLxQ7F3Y>T-sMc! zh_3NndvzV$wV~_Mu2*-xrR!5&e@kzbo|xV>y)b=PdR6-M=?|qpn*M6~+v)#GKbn3b z{ZzM{ZvDGW?siSLySwe|cCb6^p5A>__m$nB?fy#llRYwe^zAXE$G9Fdd)(1uSI@AX zzMczuKHu{|ui#!idyVN;-Rp*4kN5hf*N?qUd3>G$o(9h)o|T?kJR3cmJui4pW^~OM zn{iLZrx_=`y}Wha)!x@LLo+*NF3o&7D>N%7>$0r(eGcDH-<`fa+1<03XJ41SF8ki> zt=T)W-^kvV{dxAcIiqqe%~_dqQ_lLF`*ODAJel)i&YL-Vb0c!wJunx8l6J&)ad{;qy-QOYS$i-yQw7^n178 zxBbraclYnq|NQ>r`_Jvaxc}Pz_w?V^|Hb~h`|t06azNVwBL-YOVD*4a1Ku8RDBqPI zo8K`%FTXf{Wd4=;oAdV-#1xDzm{u^qU~$2f1#1ePFZi_Jx5B)_iwbWjyt{CF;hTjY z6&@`-RTN&7R&-v`q@smI%Zjcky07T5qMb!=7kyOpQ?awSO|hrApm=)myyBaS_Z9zA zl3!9?a$(7xB|Az!Dfx1sIk5A)(xA%*JvQjALH`~cHrO|~ zcJTFsA02#fNXU?`L(U&^;gE-hd^F_r`5n(6dH%gaI}Y7AbkEQ;7hH6~Q^Q=tE*SRu zu=j`kG<@FhSB8H!qHx4*BaV&C89968^&@W|dH=|5BX^DbY~-m?eMb!))i7%9sOLw0 zIlANMg3(Jxzdj~;%=9sv#_S&(G&XPSg0c6FeQWIJW4{~c7?&}wWZdj=OUB(W?%{E- zkNaZW@$qrvyN*9^{L1lL#=koL;P_uB#7yWvVd;bqCQhF?f8x@Kt0q1?@wJJcPdq&- zc9Lh(`IE{fT{3C?q_-x0HJMNDF?r(T1(UCyykYValXp-4Zi<-FW=gjy1yjaNshqNW z%GxQLr@S=f?J1v3`D)5fQ~A{JsqLnAnd+OGKXusD$y3XxHcq{4>J3xZPu(>2sj06{ zeShlbQ%_7Yr^QT5o0d6k@U)3DPS3h?PQjdMb5_r-n0w{iHFG!4{pZ|g=kA$%c<#^T z?(#0>`Q;PKE6cAfzo-1!@^{KlR>W20R1B+_Rk65YWyS3kTPmKf*jsV5;^e&Gd5QCS z&MTNVVcvpySI)a_-ox`=n74c0!Fk8$ot+;%KX!hn`33W5%&(k(`TPy@AD+Ks{#)}8 z%>Q=&@0Agi$(5Ov=T}aztgBp6xxVs|%2z87RQ^`gs;Xzz;HrwMi>j`x+EDda)eBYc zRUNAOzUoZ1ySi<)uX=R#^y-DxH&$<`-dg=q^}E%dRexI}YGP_S)%2?wRZ~`TQOylC zch@{s^KQ+TH79GsYg21|wS#LX)K=DBRC{CX-L>0mU#u%0hdN)~@VZHL<#h||uBf}A?vA=g>RztfU)NNhUY}b(v3`F2 ziuzmX@2}rdzpwuH23JGthK>#1hEWZ54fi*E(Qvvkwz05rN#j+GH#gqX_)6o4jo&o> z(s*`Z=)$%OQx|q$n6g?ByUQ7H2I0-e8&*S-6VUM8pZ{&~gxA_qf=WqXO;v<963QxAbli{QGKa2J= zvz?i0_A!gFf}Lm9nXAoP&D+hp%`N73^BHRYN9JMksCgXiPfA>l_HRsTl@ynhkd%b> zcR~BTNqN-%hNR^!+8>Mdk4~PPyc+F)D0yr02K{@EQnQTs#C{u!yqQk&8) zO}i%TmbAyxwqquIF>M#xzmVF0t!lsM3LgC7@9h1&Dv)^g*x4=oY5s(j0x9Xmv1jA$ z&OUv1Gel`EV`pA~J7Se+{ybB1W~r6((@QdR=--ST-lftG-*x!PFZzDrKK$b0L5DN& zYjgO4!y6CZarm~wYY(qEG!iA-phhm-fkXQay>n>Kp|ywZI8=UU`l0bi8+NGpP~M@O zLv23)UBdf3{<9^Feb(V%_<_qAJBU5j!BB-q`UgJ$&0j|i#EjbyZJNX z3b93O6VHg9Vy}?9cV>7DuaRYB8v|ra{u%v@0%NE#M#m=U=LFx85wpErfx2Tfl zCdM4nJ@0tY@v>v5L+(B4&+)Q+h=(znPl``-Ae^$UQU?bRQHi1oK)3Iw^jJ?#w*t1^4)?+_)H@k;D zEdC8{ere1UpP94S`|KO+t$x5BHi)}<6p!IaJe7Cm-FXk}vxe}Y&^jZrtDVYg`2xO> zFXESoFO2iV5o3nfEZ)T4ag;FxyT*Qo&zxyw^Vh_$MyZiwRvNvHY1o6^YnB-mMwZxZ zR*Ad$>*6=DT3jz~6mPL;>;r>Y8|)loSsZJR@t(#yvmR^^D`Z8im~~`RSp}Pc{a^!| zYm8&{>;|@yUB|9x_p(KNJUhbnVPE()JIs!@`cVm-yPd0^n*ks;|P2*lx$4l4(Ud$T#V0IxN#+LFC>>@s#E#sru zrF<+~&d0Dz_-J-DpUke}lh~DfBKrrgVe5D$yOvL9xASVYmd|ImK`-3N>)CyL3A-P& z#&YP*%h*5prEEU$&u-*pI2ZYp_2ZE&kB6|4JdIt3(~1>*iaFOTH|w$codxY!jTvGN zcEkO#gD)`$LT8?befl7?*c^ZvWGMFT!^JqGg1^W2^56Mx{yk1Ce&j##pZRhAAN~vf zn19H>;Ky+8@g`0^-s11zyyJbo54-da_(%K*PC35hU-6^-YyKT}?ceYp_^;Tzhly|z zfzyo`oNC00wm8*jC)$gyIK}8Dx{Ds7r|^mloMz;RzBtE-6>UU65hb#5vXLwDL~rc- zvqT?}C=x^$(TkrH{rM>|fS(rm{ER5zZ;NQYN3`N+MIp{qig5H$EVw8ULJSlJPC-mD zSU7MF;u1q~&T@fpi(w*I3}@%@2zD25WE*%L+sGSO6#Ig;V*h3#>;Ma6pW&3~bDX^$ z!in2KoL7B-Ir}5#Vjtsl>l2($eaL!o&N8^cvbYoHXeV(xcbau$XK+?>mUU&P*kGLI z4dn5xocCq(ct19iXS3NnmzD86Hi!3Svv>}xj`+>_NViJ-{zw5AloHCcaGkEPfH+iyy^L;s>L@F+jX+-Z9=Z+ZsO_pPJ#u_huWjvzcthLrZ^UMjJnvai-5q zGWM8W;}bAz*k^V!+ZjJYm!CC0GMbE!jpvP*jSr1CjOUCGjMt4FW>2$&@sioY zOvKFJ+srUtHSCUyPNHe7tCCf#(JzLZZ~c-HX56ae;Qkib;jeylbBbYGM+G=Hn!tD z=`mv~&deU+nB7>mSS43=raX(h-_=QnIqTv)tHXkYI)Bh%gAH9Cv4aOCQ62=h3CbU4%&4&y0?)O3=baF%Us)!|4MW31QVD3)X_ z)#0`*(U_*f(0rlBC>?gQU}KOD2P521heHs~(&12q({(ru;Upan2d+>Zj$pCk7afjd zN#X+?j$*lDgAPX{eVGooLiutXj$vKI03B}4QgHrB?c%W*XUBB74U5Amt4fb!>HI+* zZX4h^#$6I$q0`&3cxX{oK7n<|cvIo_ERm<`a3W@>pLDnb=BtA`oP@ExU5AqcJme{X z{^A`myIJi@g&xrT3mqK9Ds|mXn2jdraA(Yl!*sX{ddzBXSLhmx&*_*oE!w&PPJ%Aq z9kXh%4);KaVLecZ8FUt#j~R6_aMvLQje^wK{^w{`hBMa+fBXW(XCvN-)KLh}MMy%a zV?&WTmz4uf09qhz400-1H5-UJ6_~FZuokI7yauy+fR4dPF9o~`P!&i`V)pcNLr_%P z%ULb?``RtrXou1=@9&}6Ib?@qPyc%;_L}Evae%Y-l7ZB}$8fG*vco!8{@+8j`q>W2 zN^4%b5PdHtsTxv~M14LNecu3is)LNwvwWmAAXJAEGQ9#T)-seVLwpwUYEZ_iT@UFh zWwlgq7|B%+9Y}U(AbhtqmD`~@>7rmnN_qTfl@0p z${(uqhf3Laz_doeAA8NtlK^-BPEM2jBSns_2DGvsyWd{;)liG8*&Kwbk)y_qHxihuqUw$P$pfHjk4yHThx@I$4gO9_Txb9IPTvG=NsAuNp~OYryw{zs#3y&L{b*!wy7pwE*pxQc+61 zRDpG#>{BVzW#CB?n}o2`y>k(}5YNAduXrl?TuIRW9^XLFUx5}#d?lzSrMwLLKZ*D6 z==q~0_ps@BOMdOreXc&w!91B^)BgdvPC5s)=DI@4i>luT@-zy&z@e;<4OU<-`Qx~< z(`63_TJ_hINIg|coU%rC67!-hUd*^khNWEEp^X4nYuG&ScNY3sN<$-_cJxa{pgl3SdOQb^Qz10eF85I4Upe{5P&A%4yg1AHdMACEZQGBky~Z zb^M7-L;C-rYx-2flWj178+?`Uo6a_OtUc1t;hH`}`dphEif|d+-_Y1}2Jp|}&RMSp z@Y?;+H62A-<6k>_f5|Wde~IS~;D?Oa9oF>|&gJZ~PBPh2)}+ix{#ouEZI^AOHfVZ@ z7K!g1JpI2)YdVGYodAxEKXJ)OkAjm=NlVB78`ty+@E-bW$HIWia)S{5pE#?Yel|4$ zmv(FpVAHWR zEoJkZ@+RfUE(@C9lBbei7C)%{E!!&Tx*hH=l#_F#)-5anY0^pk)f(xK<7v|$O}i0~ zgL4L)g{SEN%1a(dy#<{@Pl-qNoz%HjyFjTWT z>62}hI3zBqCqv<6pI8p@rWX*m+Ja}(TZs3<6MXl({~#>gF*vkGjwk9XH^OKy!JCM% zCBJ77mh$|y$|HTW7h#lVE?o!xOFY?!v<`5c;UxW*E|T&fc`Nf}JsHnO96Tc2BN2WF z?khOxJbVc_L4Fk8KcaoK;eJY=ou zG@cTstLCTxNdn?>@xCwAl-d~4X54RQW zYPijC55q}*+zWRHXqWtva`Oe;VYmZuGEMf6?8|T9WWH?w0JthRiE|vBq-PWyc+Hl> z4S_3!`!8|hT^8QJ1>D&-V#2AH;4T*v&H&%3OY!_zJoW2C{?~#58@hrHh z2)~RpJZ1hV(0=MIq}>m=cf!ebZ&1&s_jDTIvT11389evE{R6H6?m{>zBWLk^0`4BT z``~Us8u&o*Tky2%N?Dr=HySPkWg!35_jP!-hnoo(4JTzoI!RMK+$Ojsa9iN6({Z$m z;*t(Yvy>f4=L)!=;ZDN=7SVAf!liI%7w33-;XH7tBWXB^vS%Tezhg|Eo&`4$ag;xc z@q9KO`~m%^E&z|vwo~&|SU%4@f^Z_rOvDrYYq>K=5C)y6R>7hFe;I*pV{4{7ddl4M^{tR@Z>^Iqur$PUj7txL&IJB$j z7d$7!$#DwVV0m!4a8lkLhWi~Z7Vajvo8cl~Z^Ils!W%n+CBx@}V^62K z5j4|WCfg#N?5AFE;4P&~9^azk)CV#SUeg*{^0XlkCLSYhPy4elm3<6l;Vf9zlS%%x zNT;;(Rk}UT9wwNrbQuW)V?gHH%UJQ2X%_CkdP*7yhlGvs<9CuSnWpotGIm&`Q*;@k z%L?1t`FAkv@LGoLe75qewpwxPN$Gz=s~t`=9yKeEcNYiXU=x8iC5$J-al|*m{R+1m z?znPz`@`5SxI-u-Pf1b`mfyUrgo9lLx#4)ufm;Tbr^5!G-`d=F2rq_P0e2hRvv7;x zU<=U%dk^*|U}xgF2K}%DR2@O>jiBT>I}RFl0snB|coSt#vu}wCX-gof4#vslJZ2P? z3?0LgW-Y0!WHFVnrC>>TcZ@%L@gz%GVM!Oq*Ad42vbt)PUR7E*pCweyubPjyL=XtP zYj)x3P-RfdLFHj=62GC<-l)gIXNuMAHFh)J1v&Aahy%M|JUl%dRsmtF@fN9#^EKBx zP&&i;qC48P!L`--jB}g2FU}~(uwl*<930n;JBWk z?#(7?wPI@ffKuXuv=ZTb2ZuES--_}z_@=}9Me_gt7tH_c7ptMje3$^>t-SU74c~eE zx4$3o<&64$3t2e#?<~GEv3?3J$|>v`&*zv8lf`nh^-U2E{|WLpi#rg$i^4BSU(BcQ z*RWRM_>xBYE07}aK5{hPN-%uy(*RkxhONf;ENj_6@CD6Q{JO9=AfsK0&Yonk(hJ}B zbYmWT-!q8$@NLg*e77_j6+{5r2 zM6STy*PZS5AT`~c>Q2Nn&K>Ozbvxbo@WmY(bTa6tpyPme!o54_sQXCJ;h_CN9|Ro@ zdN*iS&`vy`4|*!-v7pUC_Xll+yFKV;IQ*!_G0=7bl$_8tSE2T*pcO%v0fvIGG-zSa z(xBR)c|o&-rfGN+f<~dn@t~nWCGZCX^$yB{52vV*KEdlrv?w@Tf>MII1SJH;21P13 z?)5>>`)Sw)zMKdO4l-S5UB3kdyMAze>-y65c~H-wcU_-IT3!2Gdt7h8f7!LewH^Le z*CzK7*S*Nu;2!2$=UV5U3UUcoxbw907v~B5jyaDw590R`xxLQa&exnT zA{83OxdqP$op-xehg{)Y@4OYi)y|d97ZH=cWzNOUI%lPGj&lY=i=C64W1PeA9ONu; z_H|}EJ4}svQd*OUczb^p9Xmi1R|0Ic5XSG{*$EQI4UG z62}0?W~BB8-YY_`aAY}eb@X(cL>Tl0YDlUaT^uQn1ZN^j$AXSXM=&`P^z1axN@xCN z{@{o;zcs&v|GDES^AmF)9Dd+(xnrSg1inXngLqz!@XM|SbBFUFzFyt#JZ5e+H<|b9 z-v-A?bDiT|a}97!Lk^zib-=dMyb6$Ch8(SgI|e65Nj79aN*yFqN*KbUFv8>rkuj1| zr0vJJIBG68FNE9$2aQ6w0aAN6Wa&CrgKW83O?~@X$a?g^t+I{a??zX6$TIvSZKzJh4D|W%3L3*;7{OifzLNio7>CAHS7mF`Y z@<#G+Ab*wg@jaMKUn6~XrHDp&HKnW;hY?;&;SJ=k!#9kaZ=zZ6F5^ao8wv8IL}49K zvKTWU=M6;XZ8V3jqIq+@af5_LR4t~t@&cmsT7tQTYOSI4>!|LH6kbP=S5w=E6E{ZD zY&(p~52rF63C<0aaxK*zO(`2SC31e*MB#_1{4&ZtNd7ePtI5BC;9oAIQ2E=5t1l2&w-Z;l z5m)b|+?PqBb{KLM@f^w327-Sl`Sk?9^G`6V z$;+waC6s#!<&L4;Z4`c((l=3f8Kpl!Dfd=yz6{3nc?0Dl9)yo+GoK_xd($vP_Y zfr)h|-)$Q3cNwtY;?aA6Bg3^EkjxhKWWG^+l@8wSBlr+R}uUH1oH`kwnJl-oZmrsS5xi+^4DwZvcH}; zcEI0C)ILM4dxGjdO6~fP`1UsO?JeTlla%|32^(YnzA1MK`^*&hPY{;vGJ2J+{?|0_FgxkIJBnNc9dz)n1yg>oSZ{zt#Q_>TL3^UKCJ-~a6oJ@zL*MQd~X z3aYGu{{A2QQXwmU{Y%6b?dSeQi~s%?D*o&Tss68iR;w&(12icrBzy}}f$p7bdP-k~ z(sf&-sYTI9PedD1Wy}p(!0iN(4KB|~INvGv?*eyVKw1t9xnf(0{XQ4c>W1@CsgG%wdk~sr zC;LKb8TJ+3(QuUZz28Xd2JF@4jSR;ne=tMHJ@5$;BEs1BQg^VQXg~a`v_)XQ3Armi ziCYMUvQxA-J|jkmQS7YL0yw?WJ7id7NWO4$cwmPtg78f;=P%-{qYRcf>Iby~tCrK& z4;t;i{~hG|$4;wS>=*n_(C--RWz_Eo&anR5-$8u!tbWH}>tOw;Ht5_7e0v$_1H2R3 z?M92b^EvRxi30dVIAh?j?24mw+-U);Mwt>Ws^I@7eYP7m44hZX@@zYw1*vFBuvV&g>1GzjdKAj&3xUbcZcdE7qgMNyikNbjUM~5p>2e z621R4>~r?xjAJ5hk~jpr9NE`Xpw$_hN_{fc#(_A#5z z;|f4yU$|^I4_rE2Dx5qO>xY(b-kVM}?Y|o`C)FD4jkuP|{HX+6Fk+s2}#se-YAUvLtobYs+6x?4Ye*iUk!gH5BI> z1<{d!Uo80vfjq0crYaJc!}a~E7T-!R|6X@vfnFIyqUie}** z=60MtH2cZjvANaHBk;=H6o~mx2V|)Yx^-wFsHS@68a%JF_!Q{RbA?nXoF+_@Cj#_q zfX$fo!@gesrfAp_!#E0$ko$T#`BQk|1D?MZ!H**S>!+W&u+)*ib)eC!f8G5dFRXb~ zs`W%nNx9hxeU{YX7Y|G5Pt165I!5vq{s6@#GaMX4Kg%8lZQq(f=-nbyn7ObFIt}Zi z(`FKS`7(<~l4^NUB;$av6m5{>agFhdlqqwM49gVr6V-7~GZFrh>i$H~Zj&ke8A`c_a$lwJyA(boYr%q1;@U%Ds6vF-612Bu81{xT zvt$2X^ zcgcT`{FgOkNugXn%aXEnKU4St3gaCOQeLH!((;(Irzw1b!tYU;_ldR_DE&q9?d6srI&tqqE2rCr1v)PHA%}>}7$(_wE zOcn-zORh`!J94h#@5*%ve~Hx2g>9Q z?&K~W#9oKhaxf3!p%^7Ic{prdBk*n|3SU%jVXLvC>;#+3CRkQRV>QqjtHaht%P}7tVi0hT|5ESyKnJC-hn6aWS)ZeCF$%p-VwIYX}l9w5nWg}-j%20JNn)1 zZMKJ<#2b`%FpKxV?F7BB&hT)2zs|jQ_t%STjo?1)FO z99U$J;-mQ(mdp0>v9Mt-=i}H1d_1ISBA>)3^C?*SPUF+Lw053}_np1@EZmq?hP7B9 ztWmzZ|z*ehpvAujSYA>-j3&&9Iu^2;1!n+);58znR~{Z{=(GI@loJhV|Az z_3SNi7LE5ErzYaS#);|EdK9-P3*lGWXE6yUd!);ef0fUvp>im z!n(blKg>77TKb=S3+{i|${&S=$`JNDe~drQxA7D%r;3fVt8^&MZJ7J^!Du0c?4!hnFu!ts`Www~V1n7Dp!%?_(9kRpT>y&9DA1W7`KOU zDscq1eampe+j7`+e+heTWdnT-7SP|q5?b0$pMWj(57^I4()QH9u*qx+b{@a7XZUZh zwLZyD;l12x>`u<`vp9KR*cjlr0jJdtjDHu-HQcaT4#6(YvZjv0-mDebR7;C#%dR>B zmeq-(gGj>r#uU*}Y6sCtYKLa_*1fP3&Vbdmw5;}F_m`tBt@~ilcOI;-`-=g%OJD=E zM}a65MX;zYfi?9YSW*v>dmnKDw8(Jmd`5~b1RRXi#l6OZHmi6_K% z@uYZ4JdK+up2ck|&xz;73*trbl6V<6GVGK$GKkl4Yr~sjmv~F;7H^9^I34~6&TCuY zjeToq$TlpF?rCU;Z*tzl?F;XVed1r@1Kdsbk@#4AB0k044hO_R@tOFX?tAz`9KkIO zUy859QSr4nCcY8hitliX-G9Uh@xAzgY`Ud&_pi85;dktae-bCfDa?bv;J%16;;d-G zZ7AFjhJjmQ9Jm+7Wds>+BiIPR9Wh}>xDjDQ8d10{rj-$cyBk{@u|^vs&S-1ItG}q$wmrpQ%N<_aHC3R+$z%*x94?(C42;KYe-}dLyO;onc!~h#II&yMt8jZ zjb{Cg9!5{27w+lEz)c;QxU0j5Zx9}1kFx@t7KK3fPh&G!KQ_R~!7Vj;xFw>G(bqT+ z_ea1&9e4e`%C@m5U{m`f?*7|^o&F1$bAn;FUcl}*3SpOC4D0lPuudP0TPM!Py&o4C z!!S>cFh=4YiqXaxSg?;X#^ZL3iN++{k}(DL?9+_t#ths^G1Hi3%r?r5Ik0mt$DI`O zaKA>SQDsycHL!(W0BiVqqX9cjH+Gd*v1{2%qY<}FTn7VcN2R6R#^99 z54(n~H?Cs0;|r7fjH}r?%!scUD~xN5mBzKkb;k9^D&q!YwQ-}d#<Pt2b=j9j2DfUU^)K^EaP8=W&G=~pnnr~{BIe%+0(|`#vWMaziYe) z8~^u>eXyPX054<5Oe5aRB!weg^CKL&jm)${#WQ4V(F|jH9rYKW2Ob+x+iv z)5?FalKtNJ0k-`=!CwCt*xLU_*7m2cralAf{U&T}Io8;QX=2;uG+ky8?C*ok5ZK{| z;YOAS+$a!bM&sUq7_+q*i@O2h%(hs;w=)yW_GY5l0jv9DGX-}WrJ8BD6`-@(1vd|* z;}(MMu>S99_A)(Y2KEP;W)^M{$i{61xwr|SH|_!Ii#sjk4HpB|w^66U>R`By%$M-|vg=MbB>WYlw{9^Si$ktDfetPvYOFlgrg+(f! zPjS>M)Nx%eKUc@|^;5&i_qy_{N@vy8RJ-zP=GIh~%?~M@RZ%yqv1(3b+2Y{B*)m1xrQ8~LTM1uBY<$E$c86iWOR|j08+LMu)?HpPVvq!u`bsnlV%)GVJ- zTs>E{sz@`YNVmR7GpI;+T9L&--Fbz@j^X7sb=A7ICcfCJrArlQ_7{7?hL<;1&n>NM ztg0++Y;X-%O>~Y@RE@Gx<;!-CrqqzpEgI>~(8T2xn`6L4=NMbHY(nkvX5>4^+Jv-N z(_ftJ7#pBhaVw*^M7O%wqF>WpTpTvG8NFk5myA_i;uu?3Q9ai=mO92cUXeRKK(2fI z?259wvigd8=lHpGr3=eKCI&JU%Sy7G6DdnaHGfNTos$S$@T6uEl%JDZ5JAF~SXigr3!dEuZ3Gxuth?z5F~?KEFUeHJtoRSE&~7 zQmtoa1*A22mY%6>21f8fc*=uFw};<_DnZ)pQgTxXLu%vStdF z==OMhdCs|nDP(Sd9Mx2BX7Jn=P`!j0iqL14O9fjVsN=4n5i(}f`HFx-&49u@SA`~^ zLaW*e8qtKro0+3jZnjdn^8x@B5yjLFDAyckrBY*+e#RiR&{?fgt8J-S*+!XP&MMY<8iRxMqsNOwqaMp#WV6;`8~>a0^#)!C@>D0x40y%u^F9> zx=R|hs%<1y+o)9SB1Pt+0GaMZ)>v~cQmS@IAXD{siO;#ju4*-3OY)o-61dPX zZJ}1Rg_dg7sy5q-J4@v_^@fxZ03G(LQm@u|C6u8e1v&OehCSl*N8});NMvcC5&m2Z z=|E9AzU?`7@c9Ll6JBbu#$HgN4k;y$s0=;)bwu%QLvr;}XLzLL- zFbN2SWrO7a&>8iWrS;_$b`mIG$Xq|C5cb#eMcT%>Ebmi*yqOe<2#k1tCMFCen@UuJ!~G*i(YNFr z^-5H|h`k4NM2Snw?RG(L}}U$3yUvNil1ot~rN=V`ckIv@Kt zsR**vKGBm^p!=afr>lJ<(sg;YH}hnvEr=&eZ6@&4^c5GAKFIPY@zoJEJ{6`+jUiL_ zbf(6XsX6bnBu3}w>3F7YL8j)IPs8z9aCFNuHODeFMVXr8KF!%o-4dVfp-j!uOe=1c z(;Uv!9M01CvNXOdO?Q?>w=SQh>CDn}W@);zG+kMmt}Kf#3%)?*5z_^dvY|MJPj{T=VK+RBu2w2(EKRS>1qWG ziP7Z?bvdYAz`9G>)~K*ORM3IsQl*OjR> z_nex>I!biUsaUAX)mJQ5}t!75JYxs$g}62B>gUH&)dV4hl#dGN5tDpu(X- z5(kx&IH;V$K|zIs3K9+(k{#jAP-7QQ#eoc;&y7}5`SMU|jSAO?$X4iZD1p`yg5$|m z9QNcY4tsJHhdsH9!=79P-;=92?8#Le_T(xKdva9|dva9|dves+^%SVFWJjK`C&=~LR@>+$AAmoKR;FRPxe9z827=9ac?mSoR~Z=N%^ zuB^17td1(R(pmPjcFohK+w&rIUS(N*JppKKOPKCYjn%28*gL506BUl0iH$~CW!apD z>DWIgs%y*YDr#m=Z}y;^xMoSy%N8`2R#HZk&IqJL&Z?=bskRk~DO)@XLv;Fr#fROngi(Yhveq@X^_mBWp(uxbMYSm&J`pwB5Cbw!zN`g(}Sh6 zY8S0`f~iFm46iS%st82G$|@@>YH^B0nIWnL6pm2g*)@x*DIR8#OR*>`=Fe}X@X6Cs zoJ-B7Qi_(0!m!%Xy0U87Q$0&78^S48M`In)Fn>2vCdK@j5i&Zztih@qspI|vt(w*D zxv0FNp)96343*fr1tgUk+Y+8ij+)z8QCV45RpUn-?N9NemaX#?XO^^q^yvZ8OuAdi zbQ>vhSjADAOoy`%g=i}yAWtz?lD%BGObnEXYzZfl@@yqyZ3(vIID4`kLA1)SC&f2Q zYDRX9%92CXHeOX?OUZ1dVcL~RyXI-lU`Jbd1dgOVzFAVU(tb!*5v4SmSF`d05F?3n zTiG_Y1Usd1_H+%0fVZ}1$S|cQQmWPnT7TKFgi$s+j|xXwF~1^HKrI2V$^}yG1VkuQ zevPI|sXhoaw0)qI5`$*kQ>|8Y?66~OwPesnuDzH-No8$~RFwk|sIp3Ip~@4eP$01x zZ5>)>2FejcMZ1zVJ5jNLa`ujlwk4M~5QNsYqyWd-*i!9q<8A4IAOT7V#HZVW_S!Mf zlTt~_IZ8gMo>V0zxq8W$k&)x9s;HK4SnA7W)l|=xx!#Nny%1BIJg?p~dNb8#4o|fJ z%v75^Z>Bn|MY#gAxUsH=fOrb@+C!~-y`BQKO4Mr$uP0xvCcSzG=P6KY3U6k1h}8(F z>>$}AQd_%9>*{J2HP$K$^Kx*SgE(nOos{dQBsop!lsuIpHM&m8Rw*-U8p>6uv39mq zTG5A>TB@u%0mLg9wKmGo^k%4?AI=Nankz%EjWP2`0`ZG`SLXWyb@Ps zLw$Ma>@o-WuG#a|Q}W(dSmKn&k(FhtsfC5E2H9y^vV7RcQ(TLMPi^GAKHbeewQWOO zBUSGRygt2o@u_X4*Qb~EKDGTuzHWrtUL#*KLv72EuN$K`M?SrI^6AZyPiUhBGQwK?2pB|e&k7j~8!14OjHXQl7lhgqe;+h_HK!mswXP?@BgC5|PR^m%l?I;isc^wz+qH;F!vgr`t;`0r;Z;0-zsml zM+=KOKm&a`U+XBJ)?q$%%z$=k`qlA>*OzI<)z-YnHCBw@N;*PFDwmUY|Oc!_$JN`L8$GK6S8#d`*|$digxMoKmB} zulc7pB^1bz))&p|$Q41|8t{j3fMQBK2C2a<^EcI(3fpE}L~UQLHO zwgFwb-9Fua>Uaut=yK}76Zmw#I?x1stKT*M^`VB(R~!trg6|lzd(K)SRSJRx3eOxj zy_c9(Q#I2~?^a|;mnoq4ahaY%n%q=9A`SJZ3nWNgHs4LJD{Qs1!pduE=9kW_S%_)d zf~n7xGcyW8@eXKaS!KSM!9)!&{>eN32{nX3uT z24NYtu*Vk8vxPHt*lQ~>M z)pEAW4QuumAl(0Ej54YH-U7vJuRikd7UmkGM-)cS#Ot8>g)~ph9^Ft^GrvrJD=oj6 z4(3Hh3KR04As$3G429!f8+ZPs;v}}KaMT$1=jTs?kB=IX*zFe#ADYDG4I5gN#Cne! zegVSxXW7Wb3taegFOjkY5cr=GCcad|{{W$H_`>jwTLixAi^3Owfo~k;Jx2EOuK!_q z=l`(0<8R8#ugoP)F~l9!hQDq>Tq#y_`~%s^{M}izs%rU8d78(cpM(83-zGl?;akXm zko>#JUoQ{O_^mZ{v#a^4`ikl~d<8n#%a_5=9|u2&4~3u03*hJR z-1_bFaDF%L-@+`pZFU6aeR~c6nlnjFJELY<2y~p z+@?J|1>axGfYWz>x z_|C6&eysC_aFv~hbWZK`Wv6|ecHpyT?5h zcYoZvxb(QVHn+D~(I&A?XzYgA>ta)4BU@kAy0-O{n8Pu9V&=t6XmzC3-qurEjcZlZ zDk%D7>nYK`@{P#y$Z-)zB0fkI5%VG@B#Q8F!w!lTIkTw{UMt})`i4`h~PEB%j4DsrvyjFt#jY& zUgPfMj=>E{8-oghJaOxA%g}b$Jl7Q0Fy}tp?la0cz_HKqa_D~Cjx!*1KP=#0#CMfm z*D!n``Yyh+oq#Wm8NQu<5Z`xC5GMZb*G9ey{UQ5A{x|sLuzG9|&!GQR3WsHYU=IjZEYG&UlB%)1O7u2w(MzZ;^;DoWGDXAVMKJM6 z2St9)a4%@;4$c@XHf)T=2eF^(ya0~OkdP3U5Sn8`t_2s?5yI$UhYJnihub#Mf@@5o zI?q{f5&t7x=~db9XD3Q>oF|wWwyfcte>m?I7*{$sD#4W zymwH;ju*YVJ-{cx7toceclQ&d=G=H1l*Hlxnw`~@D9$nVG;vS%#teckF+)a;jZ!v@ zE&&Y2Dq!#s9w{T1Oh}p;zFu!m>vmwvhTgWfTb0~SSPZmMazg%be336>GZ@0gWW*&8 zC?-=RH7to4Qc?{bpa;6qfZk6hdn`lj$2awolPzU>8%o8+S|doHoZ|O3;5tv@q7;e8 z;ujB;u<%Ef6E~zjR}}H6ap&ZQq@LmH|K{9y6tL65i`)HDFqzuPU{51pLvcvbfv@)` zOXxIa{4tAyHXlfs;)wtSZFc?z1&_3#U<f3kjX!!Hdw~2aI+4H6H7+Wm1X-|MJ;4sHpQH>`j zsJj;{jO)NDbu%V6B#n$c*i6emfVRtY>4Oq-vel%+jZQY`wo#5AlXWbqRrT&e+ygxj z*J704M=-6{$+G@2rg=YuO2`~wj$9-A8}~B#xgCi)3oEW*Dap}q1je<0Jl)em;_gOk zP8w7j~=5y~@E+vRsWQ}AcS?8P+HU@A; zYyr+zcAOFGfpY}4F_*@xT6YQj=UFx1Y_PPRKQ8eaGGAd+EvE7^r=>1JeWDaC4!afD zW9-<&uJ|+Tde&cWVfV*1cC0sO9bjQsu|Hr(eLwa%>R-8{l`tT)Va`DRMlBBXul&Ee zRPXi{{foPmG(K5Ewbq&wR1K7{@P$4|_~a^B;|oRm&oP^*nrmC&x>ZUYiw`f+QjH$b zQ~`=#e_G>Mu8~v;iA&)Vp`C~Vx#qSghyewOfcVFLx*03!5s-FI>t>NV3a8#6D=OYBmY_&4ecHPFhx$ zH)KEScB_4osZxml79Xrqs6iv!7XO1pV3b2sSaW%xeKh((lhjD6g}80Y;$-j?;$%vI zlfgp(XC{r+A*?r1tTexbsAj~~Eqe3@f+lHG^NvNms$tRYkAZSawiLV@{Pgh1HN34q zCL%Ga!BN>N#bX}%C!?A)hiujLEoi$=(I)drwU^I#*j<_Q%m0k^|Wq1}j1NG-P>{K$yx&`Y1-Za{f!@_SrJF>1P`a{`nDg zOj;-S=PH86U}LEITH(B$p!#u88HrcrlJ1H~Z`v2Y5Rm{1f)F2y(X$T!c`Q-Sz34%$ zw=ea#Q|a|fTlBzky(U&QWIN^P#TwI^VRTDH_yCOQSo`P=?}`5Ihq2IXz2(PzG1VkU zdd86QY~fM6FD>bs)@3bd!>xE4I}i6Zy&q_Icq+Bq6?7|fR}sra4<~BPCHF_N)~mrGgxch? zVR!>=I@e0Kff>#f!H2;KiOJHGQfoHnkHZG*&-qg)`2%~~dCmBvv~QiCKmNGdyU?EJ zkNh!g7$twY=>4d*L!!NuZCuv^_b55vL1L6nFeLxA?yuF@WuGYgwSJkjT3t(RHdQT@ z0!>7r1t&lw7aue|8lX?SL9!GCYv~l&PsbSe&(ApVkf&N|zEl!|yKeP-WW}3nrYe#Y ztA-t0dDB)KHc5XFbA@i=w;MY`H<%@XF`{K}zQ&|r%nS4;ZmE&dF3;~|9`u;8_fI6F zoVm?4Y$)tstD$LovXr1V^qH*1uX5eKNhIHvhL^d_ww6$FC9yiJaSK?XgBtOCfEwKX zajq4W;@2F1kEwQ*p~rjjtt^4xZOsBS+eq0hvr!;tV0FeM`^g{kQ;|SaJZGcAHXfll zB=+XxaTdyU7yVd5NcWH=|E@?;Im?Dwk9?AkmRf*L1B~`|y^B^Du(ThdP6MQ*V(xCH zgZ)Yb;8|74n$0_Nm$#y+TNgVz?C&EhG zNeq^pHpi`GTggRl8ZVCp#tZL498tRv`?_Zw_8`Nclfeh`3Y(TvEg1(~V+dnF*IL>_ zQ9|iz9#<678m6Td*+lf%^s$r-U{mxom!C1<3s(1HcR+S&JtTV;w1A_M1ByPx2{%?3 zHthbGmIZF!%;Ql@jU+w0i^l`p0Ud(ns-7W8&E;I-8r4G1N46OE)?RTW;lXa*8uyAq zD`ru_6Coe*cKP6;prSWuF>$i8%67IQ*AI-z|Fj|{PtJ}AX^A|S6b`qw^(oY7XUBrK zPXawIu{1w9RVyKe^{m7bm{9{U!bN!1x(V8X&hf22QFLC=f=@&J)Nt_(&+*692u)~4 zo9u_7dW4(@Oa`5gsFjbRNZ}YvG^%xgq}$?$RR*&g)}`@s_9S_+)_Dw?+mbf5a*!!4 z*C~T-G^#nEO*1N+%>hzgh*I0Rr(a$NDqd2VK(7Vlv*jEx5SXn|ZOOPylYJm{nxZE8 z57gA+RwH>|nB3_~YD`G8{Z!oA&lf9tsFb9~uI+8~S=t70EN)2(819t%BYiT>qEG6) zj?Ji(Jvu=5sNz!!C~dx$Eu{MXvlNmrOM_V4Q1}Z2xOjKqYOQev;~trha0a>>{{ws; zv_uj0zCmyg;x4qsR4*S=A>U-;W>CsuxHSg41Ta*|ZMgnv82-<+lpcxiUc~!ZdmBiZ zO2uvj+Em^xt>Vj=gU3OG%3oi9YB?@qk#BywL%&-k$Dqb>jBB;(lr6;jRNUS)6CH8A zxRC|pR;63<-^T6|58=(Lf}Vsj&)_Fv$T!x?Z9-gXL3K(Im~T@5!21b`_mDs(74rQ+ zFxAEhIcX+&1FRmw{7$7sVUWgUE_m1Wt0)V6I#n;b){ z@b;Xf7I!=;`kI|X$y7~|Z)a(K)>=dyp^<2VERIm`$p0Wl z^CRL25k*{u!2`vAS%TpXK&x-n9MtU(xHLzxQ=Eaa)_Y_x)ytq1J3N^pAxrsB#a9-A zSZvd)Hjc@B_0DEKMs4$ZM(lqbU{Akf6aJs2hcWDH-IagGg z9=ZN>0~S94Qt3zZI^GX6JN{KT!Cd@gR;ly_@h zh8r|j(|wuu;+Dyua9bnpk{7+9Q(N3CNOzZ=FK-?5b53%zH6)wk2D>H1VN1f~yDWK& z-c$IZ;TilVLs>EhtF7Kl-TRKY{AFSC zK*9oe<^8jPSPb6$24YT(V2PL3WBzo~QMlRE_8y96Bdzmr_h}N!f<7u2=H{lyE?QOyTL=V+M9g)C2g2a0UKj%M^mPoL$Yw@W6VH^O>lr z`a_-?OFpH-U8iXXLW;zAA)Mqb{);7iL*27SakVxZ=TB3xa&7%FK*Mr^FH#2f0^l1W zl>I;6-aN3);^-gOKC&&}k}S!xZcDNy-QN=O|lv&(uSR6DVg)Uu1RSa%ceGvDs?!>1r;!f2A()IXsH?sfsOFQuCF5I=cPr44D z?!>*TH%i;_=?l1nb+5DypYFhYtb3$u@#zHaX5B4agHN~Pp4J9jo{--B9x@AQ1?uB+5L$`oE>pUr6kLD7sw8eij@*ZQGc@0A;ud7wfgD+@`2ELp z-!Z!nHynRmxxx4jen;}%xLf(>crpd8Uy!Q*Y7M*C-MAO5Yq%Mg?vus+u>8)`J?g#c z8>inqs;qpy$}N{-1v~thHv%e_j!lbPu#oqyst`s4cjf@TVd5#(Jioa`>V1B_Xxeg*J4RkHno=hL`|}ajN@UwTc7RLyUW;7 z`4lUVpPNuWdTvIUdNx+1DXe&s7SF?qBWsVQD#P(2aj<6NtO-kGg>NP*8&-yDXw;jZ%#d=xjMhRP4&*(e-7wM>eX z_r=VF3}~R6BDIF2YR$oV7iw)D?ocdtL#AXSumy+NGRsjMjXdA$h}e_6Y_VsVqSaMr-SIn{GLB^ljw z9Sxa97GGAq^u(MQ^#l2_nwWvQnJdZ;VV&~Xnr}?cvDodTfqhdSNK2*XahtSi*yF1x zZ;+@%4chgjN+x4EdI_52;{Ls6P5IK@1FP5GmzkYeU+T#6`8rG|^X}Je8DIbH8)xo1 zq8X^GSbAM|b@j|{&B4QT;)A!$X=UYn-iRC6F&7*@%K1>hTd~>-xREdc%?TbC)0Yrw zg}7OiZc*ijTUn~!5r)@OE0~FoLBCGWFOg2cDAY@ph+0H8P%HWP_pZC4Zo_ROv6%%k zgPGN}Zt00_TgD#R)^?%6YMdk{8tA{bIR|#n9r&30jR@ksE?xu%s%9Rbj?@7@H@hA zlIn}WNj?=gr23!X@+8$94tZLs4z&|4)uGm&h^j?W9fE%c_e6)VBB>6+ufSd++fp9A%?GgBR7*5hI zTF;7VouvJCem3cRnv+z21+(XYY|MddH7?zlKe1H^%|5u_td;}^1G1bcaW5S_}P zoBdAyU#8YN&7S&Fn>O3&4P=~l&bp#!{l>tOJ%gPixv2^8hSN2c#OyLl-r&~WqK3@! zqJ-|1l?QgNd-%Gx+Rek+3%p<&Xc84};8#{D&O1T1A?VgU@|`T3^he|5 z0hWFUraV*S0^eo8H~Z4ETp9ER-D+%9wI#Of99Kn7(v=n_ z^{udFrZg_rUAyIqZ|&~dd3{1_b#Tenw&qG(RsL4_;9c6loN|&)-XCB8C_+dpSpk<# z0VkOi@YYMfTj0%;=XqySBjM^<>rLBwej%F)8Mu9se_9 z#$(&AS^vo1uIkNm6I$xams~fay2{bfFt>gD5Q33x3eQ=Ag-zO@G>+Qu*5K7^FuQ~5 zl;bBm3Rz)m=gH$Iy9(uJTDzntF{uemcH*&6@74KD`2e=L* zs3wN5yqIO;jP;663Br28NWJQSM(aE_|5hjax${=M9bxw$IU>(HLUiQixgKue?KG-p zajFYA&60q(Kze941e{i6f|KliS6Pt{(Tc46PV*w_k?it%kX_)Vz(cZoe(w!apGEaT zE465)3#Zs3z0>RpcxSlvtfltPzF7Bfx zD`R8sRmVD9Ipx~XlcV;-x@8N~i}dljT!Y75-Ohd`2lnj7c&VlT<@D=S==UMVl;D5h z^CRFb@Khec3jPnw&rRr=EK%tFadgMfV?Z4=+*k3@k0+NMKY9GSi+1jR_%r65tl^es z>neFJW}MgLEe`OWT4D2pCR6`_e4s_pgy4{mmr2^8weS=Y(hyi%IM?YWP99#xzJk?c za`_madtQd)J}7XXdX(c1IG*)EEed$E>VVLMGCkQO1V>Mw&}xWhI7LAR|Q)qjppUvu;E2Ta*N`ttFcSDPMM&8~};P2l}?>^5-zyRq^v z8bd5v*n$@L`l!?frDBiYyw<+r#POqRZ7WbGk3Ek%&&zq34PJwfXhfL}Zi_2$;${I? ztX@?tXonsYxH6&(S`l^}@R?f6@gc4u11lJWXz_uONXNx_ziwTx(@RqFIvddANS;Vp zG;?BWQc5f#$dP3)Oa_`MhLH^pnFo!eMO-P`y26U#SIiM%WPP-9b-~Du^H+@(jO?xV z)D~Ji+3uP`t6MjI#pt8A%-yZM`$ z40yPq-hUaUl|P2Vxph^e{Jt!zUg-B|EG|~NVd`&Dt%tA))z-vxA*%Fwy>~?Y{$=&8 z3fBIZ#;JF>#%)BDh%~MkE$KutT16*L{Sl)*8AUT>Ybm7{hR3QwJ|SsDwMr{W!?W-r zg0AqRn!6XN>R?%en#^NQsv2gF8MbQ3gE`UKX|I^Eu(GkvRn;{p>9RdGoldXM!kfPP zI7@UMa9!uP&ULFxH@af>7Z0qhyK+(A9sAbQ9SY1@dqu!$3k>6w+6|xJ=WrYGZvegtX1wNji`*<3SH>kLFQ52DmzcmIQteJJU$E$Ux z)1(+rUPHwoPC#3;ye-lbb9) z`4uh1n+lw?g@CtWPBTM=Nytc5TqIE;l@*aaXMH6pH&de;#HakoyX~B;Cu8oc0evTm@=XPbB z9b5(loW?2OtvJ(8vgw#I(ufo|yiPUA(D|iXLZv~K=+ZssXlZ-}vq`HM_t)VR!1N4; zc$&D9*b<&+FlZ*XLG2z*YZ)J9$xivx_|2=jZ(Ka+obM>M89e6BjJKqRi>gwWvZrQW zQFHW-xl3oxzM}fDDbHNdXAQQ06T?6h^k2Yt)5YJ8&r1_5{_$gx1L(&;f0ROZVZCeN(EH7;iKb_5k`PeY6Yxo5Nhd z7e^&pz(4!}~! zBA+!nX+qo?S<7~D4@1D|?uC#>-Ni>C;8AOww2QAY1i$zy1I#f>`aN2|?~3X@dX?G5 zqe-F{zREy)Co%r-sh48>IhUq@LD5J4fD*WQVsy1a5|=x-3M5%>KXy%gR)JT-_M_w* z?zsegq(d`m%e2;*I7@Q*U>T^T^iQke%?h<_TxKV+k^?UVj?q1TvC<7wk1M5V6trrI zR=TF%kAg>9?+|c4M`D!{v~8Sv_kvYw7mu+CYK1KKY{b~2Y2I&$%s55ZMCtZvGfTY- zc*vf=96W!Y!t>D0urt|xl@C}dCLfaTf(n0KvO$f5H*F{5ADHINOrS=3~Kfz8ksyaTWLqeJj6%ucV9h(ttBnk=BT>L@!;iej{+2Spn}L zIA}rM?{yei0%#FrZn(3|tx0jRTjUp<>9nKd{Mvj>hGmWRow5#{;lzO0O{pW0X8 zG)Ds7ij1NNocvV*uO~Pt`YHBE|A>C!Q4CUuR`aRLeac7hV8WbQkl5F6K`t zQQIc8l?<;8yrQh=2n@lBg5Sp?i$KD}Z?>*0A6{S8TK+$+M;6Lw>%8R-qc@{gdSdaS zmR0SZvrKZyudw~*VsC~^i*Z5aN=+z@f8&ivq{W4TP8~ z2Rds7R&E$hkvQ*paS@6QAuMukY(rql?t#7~xfBm+Kx8O4(2=s)%HmTp=3TG5FcwnZ zbWOviqwSy>?}h7Yx>JE7LVV{#ffKF*-V%ZXS74>Uf$L|)S)vu1F;TicRGP-XHH?6F z3OM!%mQ34M8O7$}^nH~D>LX|kV8E9MkA&BX-dR8`^ev*+R9eKFmn!jQ9r}oln~FY2 z4@4r4M^TI7mZFb~!Iy^0Q)_&VsC-D_%qZ_8dgYwY`w%`xs1K_3%dkfk@@;@|*w@&_YYA-~T`OAW z^U_$imP}ipMqeO`3*Abi7P?iLA4Mw>TDn(nNm1u1=O4>+~ekv`be5n#o61+rk z!81#VXW)Z4ranfFj}`kKxuG?@GC(dqSuxnVA=|$aTOX-XkcR<0{FgJ0Gdd3}duwKF zd{SbGWkZf5J|!;A&^W9wZcVe)l(c%S$%{r-pIIh z_L^mNc{vy{^<5{u1Du}av)@=lbRcMx^m-WDmJ))_;5;wN4rv8+SyWkH%*?aIMUfg! zQ8nf$HPE`6BJQG{xmu0V=PLE#FmyOI)Mu;o_b{|Ij7e1#v?W~j19exp?7X26evz^v z{310Tj;b-z7V7h)dN|z9{6Gl0LcJ>tT@dcGU;3>A^~KcRJBiWPUp2``w2bC8TmzI* z##t3b@rKtT&~xFkT~XtFDh%yZ`pk-Im*S7V<`Tg9NUaN(ZVzMF5!GKuib@qTbrv%` zOY(u|YLUG*056k!n8F8#`{xZuD6!+_<{XG;E^w!owi_aTRa~5(m{P;b2LquZY;jXS z>#gh$;DXg8&9mwleTv?kc&&T2{A5A%!g+16F{!C0OY*guW1Y2)v9U`nvl16uCC2Cpl2-hz%c zV}8b80u7hv23?7oBBw6x=od5B%}n>+ zdi+E~ifd`>tiHV7H6z}t-1HoeJLofclV>!h9zuB_VQs-9VpUzAzh zUzAHZTBYc(1ax(ANe*pdp1-((rk^y-D=8Sdva?~h_=MYQEy*@zyM4jbWA@vU%NIBH zT)(uUY-y{eFwa@lT~Jt3ToAwPYT^@Wzk<)^dqQ6M?gv5ZxjYDHYn&)+BwWA`0@@c7 zYM~^mY>OrWeKuUS9-&-GwwZD1q5Wzz_N#sVAVnC?XRhBRvK7hl zw-&GVb&oX_wHcjx>y`(%-rQ0&=jswfAJW`e_JT~khy8E+O&yb0j}A7iA1d-%Q>*6J zC9O{#+P8S|#M-K>ZN>HOtbA)xoyV6Ce)9sy3h@O?vW;jy% z7Zaz59^V3|{7Z0PA(lK2t(4vkLt9ZaR5k)_2}A#??hKcm*D1;_!$?A9p{HYL9f{O9 z8C7E>7;62PdTzL#`Pm`pT=mW{bO8%No2B0(r4kaT}lfyH@^r&JC)HRFcsBqe=t#Lw@P|DT((0sHGOuQDT))d zy&KlhiU4w@%tmQ?$9q~A6enufR?~}29iHCG#JfDZCEtdHy*DhYES+DQ)oNL$(Is2F zG{2=DF~885FJa$-A}Xnr8QBSE!~kVaRtUmHY8d4{%intT>6grX(6ZZRjXU-uD|^p6 zG2STc9_$@AnkPG?>hW<}VO~W$8^M`6*h*QrnNl?#mJvRZ>DvxLe?Y^9h^Kgv;p5i4 zws@JRbA3!> zUq4d0Cq2I=E7xbnYmV=O@$t!@2g?Gh%iGL_89Mzq=0@NK`-Z{OBWr~s{0bDdh5~K9 zC=_;Ys4ULuhRSwFm4y~nTBwhLVii}Qo#-<*OdmC0n{tqgeZfxg1sddHRAL$vivv{8s&Cw`K=|(?;eNP! ztb@0d0vYY$v~+XY*W)f&q9(!f;gP_r6MK)8K;TwG{iIm#EDR)$1mr(k*!ET0hRcQ~ z#`B8U10bWVb3W~gQr%O$C(&l3>K={(!S|8f!34Yow@y-)*%UC_4Bk@qs1dJF2mC=p zHsT=c_=)3>jcz_6Px#J$mo`UUSt0*m%i}L|?TT8QJA{|pI<=ig9H!vKqc!v*yxE?A z(s;(uM+W+caED0URaheZmr;*|x z%+Kl7h}?VPNrIo`y$X0U&a3e9zy|#)Y|er0fgPM*7bzRVH(6 zOBm%7n-A*Ui>vuaQWx}1jO)@_MFerly0ofX4Cs691B#U#L~q*jG8eh}4tI<-?Vl?z z&*|Sfdu#(uj&U70Mmo?9Z3n7^YoO;a?lj{9_lD;pP||Y(+QoN#XhdhEZz@m? zcFGYqQQA1e=bTDY9Ejp6;2x9sby-xc$gh$9FIKnVhmn>;ctq-Uy%qU2gnQ)Ij_|L0 zq&F38eKC!XPa^u(sM?KKQ)tdeXNwVb@;OsaeS|Y9IbrA^?5|MSP(N`o#M3d*lpjmO z;4%kx%o~y6@Si>QhR}}sd8SsxNng=OPI@1A(&L_uQR`B58424wtL5h#Ytpi7`pav& zOnLPK33|6xpPa1AwCR%Ho~4F&o_0y^ytlt%)0&~O(YZM*SI#NlQ!#ra%VesgSL2h4 zTP{t#0{QI5Sh!!=33-h8X9?DTP@n2)Ig`QjyUhFc?JaTI zvaZ5`p4^#ZbyYK%v2g;vY`WL$3}mBA^+Ne6rz4m(Hox!2l~psEQ{Eu7RBMmQP!1HuXvj$6C^f=jbCu)*2>N@iS?cThZ%WLqgqEy#y z^1a-J_B+b_rU|A^$*r&!4A#uNaczBDW88LmNyLMm1Ko$5HkGs(Yd36z=x=5cW zP|_y?+M2?BBz*#q*JTfQQ<22h+bScU4#`gM>p1g5`E+s8l zqc5oSR}PWgNWJoiCYvSjt|i$eXdBmq0@})BV=nA814G~~pnb`qvXxO~TX@vcg)v5# z?ZIXZoBBJb4GoBVQ0%mXR+UJGd{kWC zPczu5jEl7VokcKuMC&)HtMSQ%ECy9G@Kl_>^D+LreRq}o_(jl`su+`} zCZD`WCp34Jm{CvzGdhpYexHj&F?Z3>o($s8eDFjc_!GM;N?v9d3z3=WrQ-(o*R~|a zHJ@mRiMJr?lG=D;^FD{sad~okjjP(u0`kkIe52XYSkLk%-#De7JBRucEtBkE*kQXy zK*{SA&^EpX2q;OEfVS|JRY1wRAShPW+afD#K2}!X9j-Ams+~xm4Zn|ILMxhRy^E4> zAjcHf`zA)7|G8toAaUT@+U5ihqdr!X5g~^C@^8eK7kC-lDKX>@T#_>EPv>V32s{Yq zm4Z(10B8JLM%aa+Gk+(bgma{9!?6gI#wS|n`auL5DceC%J}Z0q3N2{b__OF0T2TC{ zFr_+`dB}>QHThF_avOw~rdYc|*Y>DcqkaXog*7@1l5Yl|#ld?m8y|j% z5s5m~-^M6D6HwwP0p$@3r7Uq2LBUZIoTFBeb#i_?4bPKqdjS}20Y*vCZ7h-*kjtcf zcMl%E|DT8Mo_7Z`Gxgnf%m0)=eCQ#n^D$PuYfwkeV@FEXs=<#j!_ZF7*l2Mjn~LU5 zbozgDpZo*Q(&mZr)bwojfxIG)O-z2ju7qY!v_YdnASon03Y11Apj=KAD2LKpw14OmkqJ2 z_e$oJ)lJFk2#wB1{&( z0U2Z~F3a?o)6ITIJp9Udv(@2Gx!u&TZ054w1g$n9N!yxXEVma2)G-NZrR~Kn-GxI{ zXa1g{@jB8?hQcg;S+F=I!owLk*A*ouCtabADfA|#CnsiAIveXNXO^U7 z6J-R(M6bPqUd9NNbf|!~y%~nmKBEF1dRsu3Va%bj@O==*N38FnHRdQaK9-tk4^nAo zm|OM&6PhE@XKSPdZY2n4OStS)+@})Ic|{RSLM?hDa9y+Zz)MT)T`vJVpM zNeII=*IUaCvNsXt2;}cWG}*u-E_YzWqTR%MEk3>0IT0Q?Aby@ENmC4Rs`#(hCAtIZB&W%&8w=R@>E< zdRQ(QSyjNwot2$A`9;o(uKfIb%$2~1IQEMid$Efypu{}_D)zw7hl(9-4&`xaQMQ}! zBa5=6PXx3+3Q9a6px9l8WhTn{@F`nWA!5?7(f$v#VICtZFv@%WdlJ37c(J_szaRj< z6K_vMZI@Ei7Nc^b#pt*aDQZ!~I+iHVB^Qm$h>Rd%eNi1@IW1A_sU=a? z7ceF${wR*XOT#bd=Gv+ zgVK{&>#SKdYr5$rm{nmndmZDwxCu=K>?!Jm{5 zmkn)cI-Fjd?aHFr{sav-@;=Ze`my6X@--;0lAl{u+b7r`oboTW;sl7uOy?!|`tnQE zp!Ee=wx~&&U=OKBMUJbRmH@ZfcARaSQ+BdZeiHQu-j$z66B2pdybm>HjSiA$Ok+`- zZ0tKWxliejrCoo${J!WFWB&%EY7_8ErP_#p!4fmL>2z0+4xZqgeq3B{L#4SO^&q=^ zOR(XjYtnVqk@+2?DeeiDZEVeFk4*kT{^JC`um|Md76yrf=o}s8@x@W5OXz#l>Fwx~ z%9lJ34L{2J7biOO&pwf>DC9ew@Pd|TQ1+l zQ=eeNch|`pL30JtH8%sT=vDZ9Ct-?o^!-H;wrZ1 z6;uaP?lSdkTim<2pn7?Ox@)lWfNsmiSZ}_uy29hxYV&3 zwYh6E-D4Do(Trm>@L~AQC8PXnGmD!zvE0GRj!v9dVV8e{{mI-I_AFAbUx<;B&LC=0 z?kM{`=Zad&(+$IE|3|=ic0T6wWAs8f)bs>OPz<+`aEh^k-7Mw1@^kl0{(a))1Y5zD z$#=?k(?%9)CaS|Z50b$-ubt;0{~7T8XiLxIcVwi4HN4;nO5t@Hv=(Fh9qg`&lUuI1 zGQDP??r^f*%KnAJn?dG4d9T(crB8mjuHujEUHK5- zapP_9nfR+R6XFC}3i#`C@E4sR^G9+ExRnV);S8C$VnMk%0#c>^;n1SI{>$3-&smVy zx4zYxQQ$E;Gxui{;>}%p_x`)Pmy8S?SY5MtKD{2cSN4?nvgkEC`%USMNh?wEEsO~B zj1is!Hqz7^kmI!&p@w#``OYQ!(I9s@ptc@Awe`zeP9ERy%6I*$9%TFl$XGKmaYX(j zX{J_G-+`LYKch-mm`?2@-9jjLKJSQ*b#;2R4gCeZ^AqBVP8J|9FT+~s*EWEg28;C0 zw2m%oedeE^$?)lH=}E>^mc>sV@KZp7@?oq3z|4L8;Fg{(*KY0Ea`3o<5yuK6q3HN^ ze6EP^P?pNmpBPQv@x5C*w_LrYYwMolH@He0Pc=l>S4JgG{V!Uf)93uOaj2D83<_t@ zxBL*)kZ;DbG{eMr6n8aoon4MP$#ilKbuPXLoGhB#y{xR~DCz$OcTsV_^u!Al3+oo_ z>^N+%&MUz3sGfpM;#MizN!AfZj?#!ldpvU{vZkv_GO{bt=Sh=wNvZ2z$nGJcqY-spGw2SdV8)8)%#Aw3H6jVv%oZw8?*`l)cC8a%w zmi}RXVM2a>jU%TpABUh6M5Tj6@~}Wwjc1~%u=7K0+R1WR&ZEPSbex|D`vwerE#zp2 z992 z#us{LcV4?7a5$%{$Oh!q{B{`74tmN&If^t#$h_ea$;ZW4-$<8v6Z<<%FF0Sw?^-^w zzuef=SASVYg29w+Hk;C*HNReQq+EVjdg8O0v*sQcIO43%v+)x({LC8HFxyE^LYe|I z11pl&NHtj$ihFhb{Vt<+mSP*f#3e7xvva5X^d)Y3K~L24T<8VfQ!{J|LpX1$0*g@S z>J^d0D%tGv|JujbtS(=A1HVru%UwtJ$z->iai0vkcjr!47VfIQo-UQCsp6N)K-*z$ z<8C_HkgY3eEvTClA76B`FgC%GuFJG1HcM594^KWR-7f#JEZJ^s@3J(IQph!A zm=d)K@^6rh^(^SJnd?i6yd&xxo$s5f&ITVK*sC#5JA2D!wYQmz+85Q*TgM`~>-f#b zU9P>ZWqO>v)oQi+EVoUk)y-YddFQe5PUTHULc|1K>M1$rA6)@YD+`#2+zsG8M=03cieviT*04Xc~{v$hX^JTdvftA`)^?ojSvQN6R| z^}@OJ^=tbI&`D0F{BL&UIvW|H8?to(-!y_OC2$%MV)f z3R|V@En6o4C_OKKx_o4CTiaoCvBw8fE@tW}w0sofG9W9~LEOT3n%rgS7*Vhir#ZpR z8tCdIiALlPdMme?8nRegRz72glbJT{ls+S&cW&?gzSf(ZQj$|X=b!j1%PTJ^49L$v zV>cM=&#(>hEjYN9dib#X(NcOEreY(YM?akaY4#N1W*O{j(H55zd9HYLeOz3W6km|4 z#y0Am4!btJEZu1;&Pa3G>{?5q(W%S5E9#_j^aTSfUZ1XW7!PSP@Sb5js5Q!Oh0ZNs zaOVJ4(6yk-S3qyFEy$iQKy*Z!BHLp-9iRU@b47P~t}{K|o+^I=o_vb2$4cvLhcld$ z?-Nyjg|+GiP&F3ziI(i}cD`==!GzQ7-SJGl>-NT@aYql{Wt6{k_=dy3K6p}lP~vOe z#6@gh_0-E~d3(6!aJylva5U5Gy1Vh=XxmU}Ey_Z~%J?U=Lzz*r5$s4#qwjQKYj|6e z?;StcS;&f7J5C)x#qW0M6fJTcJ?(~<(CNLHIDVEztZq2Nf;q>|!#Fr7*1%)^OkL<; zzqiZxgirQ;W$)gvM4j#fhA)I^2&+NRFI&{+X9z>sy@|7gB?rIu=Fo#Bhn0~=pD3i; zDb`A(e59UGJO0nJio~d&xRhSyKkE)W`5 zhfbCUmV|Yd66Flm8T23AmLgRtDwnCQ!VBuN2%T;n8a0Mex(9Q8K5T z`{%x7WSzV~!mc#3V@CO%ONoZ6EFiQGCm z1w|*w_=CNBW|2J*MZSe7byn z*6cl_W^1bYzWCCGU9H{TM2B&#cg~{xSyvW2ioI$6Yt~Aba=a{X7lC`BOx6X?j$f^T1GqT2APP2Kj%aLY+ zPAUagA-|6A^zn#>x^xpeW0mh+x<<0@m?P^zMNtM(a|xOYaim6=m!jqp>3SvJfRz{* zHkEE>DW(%8@;7={O4hO0o8)XXPg07!YAZ&Dy(b~B6gyET+t}?k`H@h65AWFVu;>$V z`qtkcYKbU8VVa8DmGyMHxx$zgxyoWu2m6 z=wBXd_{PSa8|N3^e)Rqtx#{SG*2y<;F|}TP9}9mxOOv6=nW`9gxDdxL2*(<* zK24iZvY{fTl@e(9WckWFyxyEVt9_=a$L+FaXSfWpccj&|l-Z~qG296hC6eAb~VhmiDC@}`M>3ENaRozH$ z@{`!Py(5{;2mWKp@)`J&q84KPs7ZWKD`K0xE#eLVZFm6yejC#mAm_&yy6<4@QTe_5 zCjY(Z#3n%y`QJn&H2)7&9Haem$sza*OB|beXX+fRR-W05+_{_F+Kij zcK_xR^G?i5?`)0PrL(e4!1xRD+Z|A4jC)u&h}sxw_=Y7fZd}UWbh5eV&6AsM?soiW ztkl(o@m|CBqViCT{8cs(Bh!6Up8=;Ok`416_7MHxJ@U@uHr>9lomlu53@?q<-Lmhy~=CM=TOHvFA3On0I=Z^T-$EmE^B0d1k0LgL9~e z=@ZAk2b$2`;VR_RDeaIHc-3DTzR${Dvz{4dU)Zrjw4&^3qm^-LMTsfXE)BlZ%uSMQ zJBc-HKD3?*_t``73wf;~o|srXZ^q7L!G|6i9hq_c(#i?!wwrMSPEl?)z2@46=hGT@cBA?w9_ zOy|cWG-k}~so@iwH<+^RKPbTrtz=)Ad}C~E$Lop)YDc}#i%1}@!D^}tGFoT&=JqDU zcu%<1@oq~-fiI~T{FmI2W=Uxa8UyJMO=x{7>AHl}gqJaaZ0c1YHjcIx2?@m(T$^^z zn>D<17=N1tHUcAz;u(xW$kfH+5t^dS*AI82X4kx(o7befZKq13>+=ywssywlxCH%j z-2y(NXhg#16C?6TRO1(dVlNEt9LjZX+|)nnnz!@q760TyGS&e7Key#iS6CAbJOIDn|Auj z=l5>E(q6{f!$>CIIIm5KJT}-XR(MWk;Vmk7DG>;9t4qF>F@f7BD*|=}J$CmtjvxC| zgZye3$ETqOkekip`>?q}x&Q(|@wrg=0iP*q!gXnxJzZdW z4;|scDnR*paXgn#Qu+CJx*Zi(4D!1|0f!6DV&a${`u?P0QANq{cxS`>vJ-xbnk8)ckFdOI)Lz#V(A zlIZO{(T%N)y2)-%J!Tjh?-Cf)Qg4C9k#36eFZop$6qszUWd+*X2^2rh_9dCvE!xrLp_JqH;UIic^yN!4rgBHU>@Ck71HZ; zcLhc8l=uLgPQ(X-W~>w8_&~dRpll@Ac;&25Hp=iuy7QyXmX&L_IUPp* zPquBNGi1-AJzCAFH5=l2uy2=>iw+alZ1(!;j$tJ>p!WL%IPwzEWTXAUm8P<6X3cg< z+Jl-T-7#HSLieoN{k4mZA31pS3%9){KWCA2mS5%<7w7--E~8#=yo=f7*Wdg;{o`4p z%6g1m{p8b(PY(-tMX=y}PKiDVu~ToYNH=6UooPmoA;p+y&^q1jw2ZPv7d;{IjU6Ua ziY{5N8#m~abt(2|E^%T4^E?X_A>FG)1-y}{fcn&|Q-#^5t)*4j0jpMLP5B)(|6EpF zQfb;@$dHecmii4=jH^+b?5s#kKto=4v|Y*$nsoMkCGwXq-*)-D{96y6u9o`_-WS;C zr-cW0+66|cdg@KIOj+3?+8}&ojX$zeG>1n_tXBM0;_y6I`@{hg~I1vDq z)*?6WjS1ZSqXq_|j+$$7?CQ{7XQXC~js3+Y|0KHp4s;P|L9FbQ4H+ASeN=WKTc57S zaz6PS&oaE}0iIc;Lo;E$+?uJ5 z&&#^iSN_mzu>&6li2_NoYjRTDtriJ@PH0viFPv5G9l@t zID%XFh??`XcC;)mHLx2D?_X5#o*5s1`4X}gwu8~TVh4+iS)#**SukS7qWlK9A;5HL zI9uhy9F~jboWP3wGh`c&Lm=;Zxp-EBmo2}!>S)HtJmmZ@84fYS4Ph!|b@7|Bv8zIt z04gzcIJ>md5xQKGZj{Gq{WQzY%!2gdk=;X=U+E0?7VoosXm!?>I*Mxwx&rnzr`|el zuIei3zvar-J?q=9TGVPBzc!(2-saZ!UR<429q1{Dy%zQ=HuWTAn8EtTHVd-weSS5+ zXAfo?uCT<(2YB?b-q|y`Ri5PTc#EsZS{H2eBz0n|;5rLK|OpU1e-yUcN=HUbB7m zF0+(vZYXf{XMc1qqe$n!lD#Jz*~6DzM*a`o@>2&|l|zEKg@oN^bNJpwPzPfW@%Z#W z(B${=&^(>uKr!4|UDz|;Ts+dqDv^`1wzFj6fP0Q>*q>QqbCufi@^Wh2?wTBDQ@yRv zI<#6>HQH1@P!g-x6m}L>4V73+yle6UCSQgt&x9vmv5aE3yTpdqb>0e?hiX+JaO>mo zbbbrwIQc&3adGtB6RX&0pZvv|MJvk|?iyIUymaBtfUT;?;c(ijiXDz?*efZrd;2w4 ztoin#{%bd``PQMnL$zZ!bd(0_#&&gT$7Ce!OKuF>C@-oJf**Zh1|y{@OB(;drZI@{LI@5@TdF7-IGD=QLatg9~SE$trb z&2r{@bkq~FJZYEXd3x&9FzCU}Od-w!<7pk!c3vpZ^}*$<@Ac)m%3b;SmDY;PQd_1c zza;r&diUm8tHz4vHpI4f%{r)Cw_a1=#{o@`_Zm}<%bV%*YMK`oFX*r6ElD&c^w-VW zTn9o;ox{rvP%Duv$kCu1JNvl!+pA%LAWL(|zqYZh-(5K97_!TgUkDu7#l9=|?mxf` z@6rhD7{MCUg|&haOdA4Gi~?t2Gb~m5N6(_XGnsiv4$kssmZhG)p8bzgex}LoT~sx^ zxM*mzF0iO6AtyV#r7+8%nUTm&9C#9~AncF?rcPhnZ&Tp^Km+NbXtB02s&$%kkUyb-;R*l=I+q`PyV~6`n zW|b#tGg2zn48*uK#MhjY?eo_A{V+SlDP+1?0TkPe7#v+Mm#Wr`nU@?K zk-y<$A2_~O8K=9TG+`l)o?$}Yj}0ME zF+JT7{pJ1j8_SoqO1>&b({&4$uPPj>jcc3Hb5obE%EeegUS%^J$Bnjp3u7L)zmzeD zZqDq6<*iA^1W&-*w5o1iS74wb*_hJTJabe1Axo(@JEyHY)5Dt6v!TCAa;!E`Iu*DM zhbSGr2I4l9kj~&KYYmcFXcdBjU)kPOYpYfr9`2g!KgE(k>JB1x-Q1etCAb?U&tH=X zQX{G>JwKh+Yx^%j>ll>*xU2>)lvUuylQUH-c~^=y3;N0_qdib&)_D9uqmkS)u|$TB zOfo~)`i8qY*Vp&C8AQgaU6he}hu)Rnw|0}WcEQ%(wHxg<^RGG4eD%Pg#;XQ8x{pXP zQgKgl{F@11#?98Ynn32+-B&j3+qtG;_f>dXO((TNI3>{D=QxZexYu+kAJ=N3jz3;M zdqF~=v_ikFY+6_Mup9CMv*m4Fo<<6mws@f{KbTwB)<0W&nI^-eL-JFW zHp^&07{jok(K8Lf+k5J5zPP0FY+rR-V0n!MEP;9bC6ge&oD9A~Wuy zsK4MqE}iJgtwGk`=M6r+eyFg#+?t(gIg=*K{bLC>gDK5pPd8rPnmG-D+jC|FO3ITH z+lF3G(VL7}+4{8D+KTfB3|$f$u?^XzVpe!$2ik&TVh+-3&o2uK(maIFJ5MmQbqmVt z>wP7uN#(gV6CPXH($ze_+S)bGYSnwJc5D8;>DcA=lowYTQ*w%Ry5y2Tao@_y=9<}N zlQGj^O3%#s?2Bq0CB{Ibjtd{U5J_24aSyKcB}Mrr=1q*X1jz?HQiuu?1?HmYA*C znHGyrtv5L>2BT~9G~8~t)fBo45|i@seUVOi4O9_2;R@7lEGwe9=YV zr!N7&7dz`0D}NvE&%7A?4C4P6gMaA~rFc$Vh})@7jf`eOcIF|7C-Qpvi5zj{;KL#hlVcOT($C0Tf>^Z{QlVuYX%Bt zKi1x9t)Ac9)dC4K*>WoqQ%ijH_2mowxctj*bL*|T9apdV=Kfys+TDD`#GIug%^Po< zvt&N|sHUZ2Wgq4$S*tJcCz~pZ%X@NX=Vam(7A6gWC+zxTj>x+HkI#en$0aOk-6d^l z%;=n{f3v@_gE2Js-;;j;$SxQ_H?tc6Ii)~`0r@sBb6SC{L#@X+D$rvQ;f;?rO%Vev>t~G9jhZSJx1l6c4t`4t_rAfy*7NR?3l{j){{_@;JiJ zSu1;Gyk8?U?VFslVd)n=wi^o)*@J9Dmi|G&2e6Uub$B_C=h)0MQn%75P)# zvkEy@p`uW?FurNKngMTpva`Gpk{+Lymf=VkHTKPHsxy@rm10kboi>cM)oswF8SO@$ zLu)shkveCHxh$o3W=k+Dm~Wl4;gL0b%#X6xn(iV)Mq;WVRd2A^<60VPinB_x3?@x# za_mC+k&?C;limy)t;=lIrx}g0)pb?AtjeS~pRK-!Jslz#?NU;-gtEYiVhTn6MHIPD zRRoH}D%5|PdvKMo2au15csqsoc^Etp%>Hjl5L8IO#%>*B91RM2}7j3l5{J_GOog(H#s8 z4r6-(v$=Qr*gl}Kol!M$ZumWZ->v*UB)H-4^!o#RtSP2RP3KpQ;VFpAJ!` z{NS|l!Yab7!=nvNcO0<;!LFJTpUYc5*Mlqsv)!FuYqq4NnXG9pMHn7swH39o zY5MFeqe-9g`cPY9a#?BF%v|{gW%(ry$q62NRehx|KQAe2$hD zr#GAQ+CPM-hta=`(cc7F$iaR@FZRF}sYcHq{pT?J|7SAX`}s1k{Xfy7_)!uPFb%7X zW|RxW;Qvk&aMT|`6FMW|$C`oA80nXf1;;e-^{Z*>7{trZSF|(o2=w^+sXN&==He@w zfV2Q|nwL2}B_MNA>p>2=SAi6w%mcjEeF|hY$~?j$XB5aRK%U~YzNA2|N39>TOunL> zk>{iL87T8rUgiNR13lgf$PbwR|ER}H{*xYO{{NuI)u9y)zP6QY_Ru-CG73#hkEm$; zK{Ko9?Wcf6(%Grnt(BgC=tV z{6r^|c7IPxU3z&@Sz3C6^i2blac7aqk_b09qKu1hol$&hQtU$NNm-jir}O@+1%s7; zzFM5l-501q%(P+wh}auZBciiewf4ul*S43|>Fc%k3}&#_kZvVx6-$ADH22;A-lV&nn6UO=()1R@VW?L!(1w$9AqVkf!446@pO0hi@9wdFqWA92F zInsPsWAZbE!M3hwQ@1=xn7Rvc$kerZ^j20YcrZ|stkV_eq!^jpM?=~m~%5;fh z-u6Zqqv;l3FeNGAuWKxv*VNlyzRqs->Mb^lHw|(HvT5}>CCTX(#pS)ZZH>Lxc0HZu z2Hnz-)MmuI$!3+z-ey+`ON?>~DK_b_vaeguby?ZhtnxVisnlnGjB;`EiJlcA*a#mh$BKTGY{Hf3BbN6T;IC(QT4U{2`53Ev^4Ra>-!H@at#N6j%^$O0UC;y`@gbU0F?m4sF0}hM>Hb2rgEJXOy2l@MUaA_q>-a-%f;?bh3v}SO zT!-Cb(iwDSPr|kYZ+Uxx-D^%WY7K60vG?6g`iu-c{T1irbXR54CrhRUbMBiu!yZ#p z{x@P%okCV&#`th{4gqFF5S46tuo7KVkq~uK3_bmIqb@W^FJL-+aWyRP6i{F`=n>$!BR61Vh>+I=@Nl;f8 zXVg|`ZRz9ch3D>t&3bMJR^C6T?!hYj)3dMQGu|iRwZL-w>D1+Tk2(9HY8~6kUQ#%a z(-{~+0cO)|^(X($>d?Tsx6N^f%%7f!yizk|mtBUPKKu{dkgAG|FOJ(^2)CyupCp{m zk{4D2xLm8jJecj7gnE?aDZNJ$v=2?7EH*O%CumhvxnwmZr6;dr+Ish-$v* zdBAheJ%HahwV15|9J{^4wBleM|qe+`(a?ncc=B@L+k(<#m)svfoq zaH>gg%nv~@lE-PJQ@_JVf`YrIU{t`y`zasCPgPIjUE-Mh0bT)}2jN-L=oE_xWkmdw zC1~S|j}^z;>`6ZchVM8=mgca-8{SnPksWZz0}ZGlBRQdEp|_!B))m9rf61Ku&dINR#Ev9P=((;iF;2}$nE02A%?|c zy-49v67)rEiZ*x+!2Zwu%Zt3-)t;P`eJR=HSsuU6Q(O{Ez0=sX=>U2=RXo63*X8*u zTXXc${Yp>Xa}Q?YY1N&>f zU`b^gW95SMESgxvJ{=>q8%ChOc23DTTetITdy!W!WR*;9b3IkNgo{nB__QL!>VT zg|{e?3C<0h6{(BGJ8iVT#S*WP|0SQwXMfL^Gg$(@lvPqaBAyT>ter?C|UD>yfZa#`1G2+uxAF}1D`@vBn&Zq<^KJ@0d zlw{?#XtT14o9K@u;Xai8ij)LNcvd1wcvdxoEeHGvxbk7Z{ea9wYmkI^NG$x{kB~$3 zCA4i;c46V!3i~3!XXL`qMXs#Me7Cpage$9}!0oLJy{CG!iW@KXNA#vK{EkZ$f{w)3 zAUDZIR3Cq5$P!j0Yn3v#DCzn%JWNS-8%Fa}DZsH-Mafur2dTW0T@xj1V_A94AXRg; zyhXRSchk`B)J5U@InuXL>F;wS@a^z$!gAOOIYj?7p2&k5u)IgfrFeoPAebe# zFV$D+j~2rbA))?)q)p%p;te+cd>x=jsiC4Gf&uvBS=H~@t?Wj~=CiPX{{pUCrFw;( zgWiQUCOdGIgKcrVa^L`d8C1O@=_0=jI@mqRFG=`iUgVb~hg2ed!D)jn>=o!%wAP8% z(jDwY$G(00(3(#58)-hfhq64O18^ZIPnO`Z7BjymlqZFrXX8b1Il5Wh;?XPaLz=+My>GfB+(C#Kk+W_PV8oXUmY}h-3N<;Ek^T3Q- z)*at)sI|FcL(x~ato-qPwY^{Z=IHuUZCFTfa`+Z@PSk7`w~%PCl?sc<&(`spw{N(q zrKwYTKf3B^x?^J5)OFGV>C?!mTAutN%qWZqK_oa>7no73X{aY5IXKxK%GaC)1Kq1q#kEMoj=q3BDrp#1bo}%Dj2jypheiX~)Pl0zEq*=CC zcf4wscRnh;zjyMhd%0eVDmTpUr**6C_P^}-i;eAgbi-b0U@uHTraCjVfxWJtO|7;2 z_0aTsGdp$ev$p47xaF^s_mvrs$)zx=m})c1eO;6r_6PM?74(m`zVLk8XSdAw3H!}s zGhUgb73V?KJM0y84ZI1}xZZ8@>K|kkzkWv=7?acS>(;3g>|ypc{o1T|R|NFzVfmN8 zW^Ye^b&S1(-yfcWy^dQ;VTrW6_3k(+4G(1hD|61Ns%lhkIU+wRKZjC>Q0hTZYPd2@ z^1JniP+$&oHXdaKtnkP=%#;L7-e!N`m_V-!T>Jrjpu+ngD?YNR{m64~OSA5g`-|Au z+E9AO)Jb6W5imn(ft%T@_nHF0@OQj8`)Kli?qOdmlKZJVVfq@!0e-N8tH!4bdiCrz z`6*U8R8^&)H}BRE*26zxyMJ`AEXXX^2(+?$%r9g-24Yj{A|k zjaDATO2h62x3RFtyqNtNHkMC%{1DlYk6%fx?_{?l`}`$P5Nm!jPs77bI|L&blzJAl zc<#?!eUo^TULxx*WZ~^&yWvw&RPJ#VgToG*ypu1^W}Q`}8Kl?$fQn4yV$4q;B>nf!#Qsv0>K0FyL=5yf<<1=J;RB z+%y`4>%y*Iv8Z1h;r;tp)#W=^QVDzUrLa`epJ<1!;R0u_m^6j{F-|I!C@n1uqfbpG6JgR}|H# z8Ou$-HGMX~Hl!iEP82VjIxOX=D+K2Z(kIi})*!OtEL>Lb)LHPE;#F zO`p0V#;4wJ1!$z+aDv)1Ov%zR^eOVzdHjv8I~KYPXGZ*F6&bYiGTd(mc>ekh-tV6h{s^Li^;d-u#ebCz9G)#xv{*1FA_lWuctTcE!F-1Q5j zCx&NL&diI8ol)PnykvhtsW)p&dY(Pgjy37Cb5oxp#_$+uM7h*akqHJorbj0}odH)i zDnuclB?}K;v;|)DOJOgX)8zs^%Z(|%2!(VqmUl|o{L+Ok^NYf&dssjh^N- zSEkxct1{-(e?T?D28H${>(xLzJK6Bb7{8H#dZs^lo>h7)esZTx z`nKoXoZGISm^_S*Cb#0{`cHB`xnBOqz4AY3n4v##$GPQLC09xB|G)OWJU+_m>VNJt zGnwp}eKM0|l9|b5UkPL-kOV>k36PzAC&aMH5|WTWSX2s%pr}}IsjZ4@-7EFgYF%)l z*iviVms0!It%|l04Pl=AzUMyAGnpi){qybbQ)OoIJonsl&pG$pbGLKP)g-G+2T!AC z4^jtV5~aRSg%a=ampG<8aej>ONr-Q^LwLe-q2-1hhkajMv>26IT!s?v;3H!0mp%o0 zrKJxjemp@aPVims5{g~Ec;shG_>}%_&bA=Jaxt2}@qB?Ci&u@}bR>V^OLSmO;4AhH zw0`bm7(uJ&`mp?uRh`AE8JK??p`#pYIVfTH(EN7rE<`|kW9EWSJK>b}$q&)C(M{ty<;;A*T+#rSD~Mkv&%;d^glF8)2Z+M)TKV{nB- zbJFf2l1_>z#70*93jTQUdWp8*E3_%UIEO-1!`I*xX4E84d8(0=j3t%tfvzAd1{K3cvw4n=*G ztc?;!gDv9jGhKJwa_BCN=A3=7Bvtc~9YdAkS8j!N75>@&B+LKvZ@%j6e5<=#e8o>3 zbd^d_Bm`bb&m}G;&G0>7*DQ6MpQi|_LDmi?1TZ;_Poj9zD2gF>`kx`JI_B+p62n?x*DSRrTHrsaPRKtaw_To!Es{=>1rQ_A0B;t#tH8OF1t5^C)!hUhx&}`vb4p zgR%>dg$d$htU{{?51tT&2OXc^cfNoDXa4()Fo=YJ!u20yEAmYa$s6<#z}b%)#X z;5y)2JB+W9q#@zs-(Q~p45~DkE3FYftGQbK1Ng+JC03MGVo`)%49tLXr@-RTDm6uF zjFW1PF?q7{strC-mhS)d42}zUZFmx;00`C z0mRd06uZUWzf}fcq28PNw+qWhkt4i7_%~C>5s1%+I3Q9dhZ={+nvjS49CT8@xkBkT z75u_X~=KJl|V#Lt-Z4rbluOTDl| zIPJ^9NMR@1|6zao_X07bruG(SS+Q@uQ#kGLb%hJf6~6Vb14j?-KeQD#Kz!x8?nAO3 zDy<14l~(8tZ%JHh*th44?b;X3^!?KLSVyOvDYQ5LTs=Zn0jH4Qj@vGO-V_GE8-jJuMn3!7@|z;IHC%_Hd+lT|B$fBlg9M zvm@EMcyWQpbj`ife}3qhW32Vi-P3Qs=9y!1Ed|(6swJt{lfH$B^MAt*)cKbrVt&hB zm3uwj?IiWQMW7qzCR0 z$a5n2B|KX48@%vB`Mp`V9sCV@R$IvD02y8H&F@@e& zQim(IMj9_s>+~%X8V~s%xYz6Z&cJ@<6@ycSKYE1_qgdl*18F~1UvlB6!XIw9bNaEX zpE)MpB>w@aioMuby#+g~3Dlk>^?-bmCge%60eKs6vCG9fV@QL!W~3*SPTrF z4Qk&M#TI<75WZw}c8bg$EQxBUAM4%i@GykV4todN(D#y0x$K=yhfru)PSH*TEA?;i zQpIv_Vsab~3R2>jN^bdOY1xUTwaL~vP4L{Qx{wHcqRTR4)}%$H`j~~&orzYxF$yY} z?TJBcp;jDjtI?u&Izz1su3oxned46LtXQ|kT&mT363h|d`b7FJ9Y22E!eX;A?jMfS zNXvvd>x<@0%Udw9y;&b^*GIdeH=+?!L)NFH&D%M3$GYZ&tBYN6v7Wm0X|vpBOIl^E z+ls|~weJ)3oF-uk#vm@li&B*!7K&}sn_?5PJh6$eK;!bDaY{Y=Wiw(!pVJ(q6Bh-$bIX&RNzn!~S(lu|7!O&g=)~adFs@Tm;=MMs zLQ{e}J2B3KW*cEk2>yG(<~|WJ+|*<$vv5241rsE7A0&lc<+h8OO2uZ-_JiCe6u~LK z{(gd6+$@%+Nq4y28Im=OLP%Y-lEi38O>d8OCdcA$qX}%d^OC1d*rvDW;oq1X2h(XV z>h;kMX9^%}B;$B@CNh$JF~mBPo!hSY1LS;8ZH=)IKLN6qAKR8^q$?sd3 z9L@{WV~08s=T31NncFtB?2*S1ajuK_rL|D32*O}KQdo9dcV8cIVAp(PvY z=FiTXS6mWl*X<5T&5NG&bF*`CR7|>lT4IWGcS4HEW^ubi^|PiH&#!SAlTxjvD@v=1 z3+GfOY={~cYf7#uGjB<<#UF@>xWY0%!4b=*84_G3lQ}&u$^)yQT7XL4hdDwdYFF}E zMfLJfQIA&C%$~Y+Hgg}|xvgv)%de}Pv7sC@fGXb;sMV;UrxAe<-tkbuxO*fW(o=Qj87U@Wj3Tc7OhOJ zpA?js;*N_jMQWo`i&JB>ll1zW_zBI~Y_`X(w?`#A?eVb3DjUw>x5+0wN&|bNv1e5Z zXlGGYP&&3eybl=^boP=B6ISeNY~P%}^2#X_SJkA}&YsvhBeiCA#nkx1nv9CdO?rKV z!vYU8OMLJ~>#Fd*-D@A(Ic;C}num8*))urKXj!|EPSLKNzjWrL!bO!yGpe=Dcxy_L zCn473(oSo_SQoNB%1>+3X_QR5gARLw+$#ZXiJa^eH&PNPmo{9h`K~Xing@+a^1Ajg zbM>-ujYlq-zAU%t=)9Iqh0dzd=1oQM)hT70i^r#Vk`~9AtZs+HZnD^p7Otwi^k`jS z>(mW5)s;=%S~p>KI==H~rPr|Zafz9!VI6I4cN+|PEGuf@Cw`Pi6`;s19i?IIb8A$~tZFj>O zhKUEimr}B0^;nOk_?j& z%JAgHJ3M&_tgW_k>WYHtRa06EHf1H-<1$n1acGkR@Voky&;NNG?SVN*R#tmV!*Xkmst ziw7|s__sIkfz_P)wb%RLgI@7>@*h=fMnU3@aRqU=|F8cqv77FCNim zGs5q3G-`74Aw0JnIl>+l_b`w6!ItaF-!Gf|e%bqM8Eci0{p;#7JmVi>3kEid0ZqGU z9NRLYM{QCArHqX|Db8e%9(k=}3vO)HyjS-A2gnZ@-z1)6VJyP;Ngr^P;hzfF5ZQy{ zqnq9$F;cK{A9r|ZRWfGLO|a=8FoA)3!5!I{Em?7dy_rCY87g0JZdWu zwZ&H@8nH-^cCEGAW%v4YyegP5SG(f*l zA^aKojLwku)nMZlBR?hrT2Ro#@7BB$ z_<(4n<1*E&79X|z%Zw8mveRd-D#A}jE#_vElQT0slasPv&bc**2`ejCmF3)$E6!}4 zw(6%D^%IJg*QDX6aCvRoCRb@nT9$iaYHHS96L**Fx~yn7%PYBT;_ltWmq8M7*kx>1 zCqfdFBuOOm)>we^nCP3)bD(F>5~QA8_JPvDQ=N7k<<3UFo%V^RXFn6QakWN$tDs5C zNgQVnjn4KK%}OoXwPej2jrvAGNX|{nvYN+dO_>#6dHDkMjtjdQm(<334Z08yCR@fN zr@?MEghXUik1tZPry)mbC7!8kbjHtJ`xO$nXYpj3u5xZ~U$WQ~US<++Ske+6HrXJg8N|CoB4RzMxnV~g zd)c<8&_ugACNe1_YR>w3GZEgSN$hT%X3MoSHHEmW_Si&s$lO)buB}k&E6}dPc!VAF z5U|U91OV?AgoTshNKjuaNy>Wo4N8JD6AHTqImNb+vSuH!{Yi7>Jbqxj6>zb{=^zqhGs zd_zG}zB45=e_>0@LgB^M#(CQ+zGZvtIY|jH6aq!;5pDrqzCOPUCSRcz1@KL7Oc6XL zh9?&KPx~vG6^VJ{PdbETMR>Qi2-sCRd^EZ-ZO60~JSmwxFqvG2yu zN=w4DHO(^+=gz+GG9g{ekB@^XIz4l`@Zy9t>!fJ$Hnz~19%(T8ZbDH*pW$mQbR?G6 zQC9GfN+SluXjz!*OIxK@hpQmfHm>WEDNQMNI-GII#?(fBC$O&hOzOGsr) z0m(}oPhM+pAq7z$RT;uWAza3h%xr4x3v8}uC+i*(-&)U7GuqhJHgwWG3&fw$SRz9u z2;+nad>1|j*tq49>LVGYOO{A_?st19XX5is4EsFhH2%nPd&>k@T=pcFGjsC0eSN>A zzZ0BUlR-z4YK<^a_F(s0hxx-^>+;4pC`eKnKyf~&m^_ZKmPR8VvL?%zevkOo)x)_A zg4g#hnuv@odn8J{o>s$6!>ei-9mCg4f-IL;>V$fyI4g>+ zrNwfgzz-|UyYq!QP(-6E&_P=vw5AtqXK&iYsyKEmSlk=O9`bJ4a`pc8sq3!Te;?EU z8lVyuD*2YtTDZK2!afjFkyR_!C$i7u#QDLjdrS2G`}SY8E@d4`ftL(?@8>BJ;3dkn zueG!H?Bdsp_AVCB#<2Hdwru$s0u@cWdDkwCNb^*e!QREZsck)EGDG#X zkjC!eBPhS$qK7NL?6;T)Kj!W33?ptOH^c;Y80S=YvW(%Ta8ooM=6!96Hb*2TwJbKr z7$ZE%a~1*TbXA35X0wUXR@%x?v$byVNgI2tsN&X3#eV^!k8Kfx(7qvqMt(n7p1SYX zM+!D)d9Gz!SiSflvxuLA!w}UXp^iNZ>XK-VBIz4-YM7ZzY8HgW2zheOeJ-gFbjkJz zCf;i89AA{%keyv&NT|=vo0QX#lUuHjpBrq+j)_XKMMZ}h7*-d<%?4{)j6P{mRq^CY zCKim#EvPImYb(vq%PrjQa5!R(@g}3$=rkED4top~pACJ3xPfiLOSGOlhdc5Vz#7>w zSwW#NQwvt5r#W1q_eU?9)t;W{jt~B&ebwQ;(FsCWO%-E%V`6>Z%$$mf|>F;ywnQ;~y?o(2w41I(19Df4VRB-K|+1qrqet}3#W2gULF1{pb`l_<5 zsb**9+9d^BudP7Tr5s15$LLCo%}6k~!Y}&hc@!{2loiW*^ zE}YTJn&?hS$BFW9vC_5&C)grz{*UJICD7K3%zTt_746q)e<1ylqLS?95`49!7~(Ba zk%k0I)YHQF1-?@(v#2<`xwyEPlJGW4@3KU(^%DzA*G!#QRJvwLeR@S=v?YB?qRk@W z)Bv_;k+ycq=romX=vB}>4Kzpbb?aQ5h^|*oLl4Id9653&x@~^Bjuqz*r5#L9vu2j3 zXQsvEmW74Im_vf`75pc`Gt>8h&zovXD@{sFHl>srf=&9+;9&mUlbB>lotWfJHK!`? zLrnS*9a}%XAfu%^J-;Bmr7FEXYkI0al7Go8=S)axnVFL{A$ji1+)Z?8)ABA*>p5KQ0m}(_EC9I)SHI|j|xO`rBgZ0>^S!*R8>kh z0UF>)*mF#FA_TeHjZIz;dw&X9p@N z5hYcjBcWTuQ#dvH<_h-J6yMM4 z_ReNo_I5V2o14U1*1J)>d;`jHfTu+YPvw4J515wcz!bDo55Eq(&^qkGLDUtXqTMf;wR7Vq)hB*->xvchp*$;w{tTa#e^Q<~nA@3jxKCLJ_fXx} z2Vz&wKN9=y)v?PL9*Ir=)vi8vf8S+~J-U0l*wD8d6q-~I2rJlGST49{@{>V)+9CC> zvTei^qafMKre@_E&5qJl3%u z`8tR5b(s)Z*_21FL$BBUx@vyoeDNdkxSdVk&Ne^sJlit_Un5wP4q=OMKcaQwd;>uQ~SrK<^dMT=c5Tc-!(qK8G5_=N;^^HR8D!nS<@> z7Ioj?cocf!_waRz0>9Zq5gTnLTT}NM_LyCK;qI0f*g*Gwb`8N|%u9okd+f+ z#Y-=o>g?`ByqdGVg+hw&Eu^Qao)!-BQ2`*SUF+1|wNuzTQ>-7b(x;o)PnsWRu~2i; zl^by=%Jvhg$I0`5CW?NjIFRrHZNm4#8+3b zJR>|t=VY;;G>JXC;}}#9C z`(x^|3|7Lh#S9T6!EKLo3FvWMgoa7cPyS6AuhT-)(>3 zJNtddA9}E(z`=Ibp7N|LwQ?-+#^DW37_j!=OK@q^PbRk@ATct)wuTL8xOY=Wk3He8jiV&fs5MG6wa!4BMn*NdqYB(*=AViGh39$Ye;&`}xB#ctZFh)goEPo_d z!%~&Y(n_^AOo^`(^)5)-#W}!y3_4*KBoGOKuag%LP@{f4(6`r#TaIVA#*t3caml_; zI*CX$S5nOrSHoNf7BZR&&YdhX-`zYPBp{PAq9oE`g(JC~w4qPL>xF3b-!M<2^Pt%n zxmV%5-xAdtRhQ~A)j^!ty-W3|>KWDVRBx(2Qk_)|V&q_A@hlyUdkUM)79dF7X0`)+ zE;k|q&V%ep_7YCLe8@g$*hr#SBg*MgY1vSkiWUUC4_pc1<7augiXAn7-awc@Jb~{6 z^L`)9f8R^;50pco+ydnp2ov}|uwK45Z-0~pb2E#@=L?C73c+WA{9?2G{7h(MWGFuC zN4;kr<%JCJwEQccR1#ziV<4GZf%uQf^28{L$FQJ?XEdPtW-eZc`22TVj*#VC<<$n| z&CALkjAqKbM*L~w%VS;*gMTt6?IPe8dBy${GmaI+#W?dza4fhgZRkC5uaKrb_g^cj zSo=VW@qL!$|EKrgYYD%n7yI|Ds5K$MYKxEeeWN#<_4qW&FM8$YUn}-w)O!}IypRDD z`yrlC5@ZZxAX%IOA!q%+pXK3E6pvwHvkWjAP<NxKO6MP9N0#EdWhJIoGCa4a~i=t;3tsMHw#YeoNSMa@C~P7m_$Sy6X( zwi_ScXE3EUd}hio_Q=nEvoPvCvnVe{*$?qIN`j1G4CI&>|M#;TQ+Se@9m7Je3@{o{ zeJK|&L_DZdjKFl|)ehy2zri!Zm^AT;F|UTf-yV~85%7z=V*iO5U;cNP9hTr&aI8J( zRkLvubuU&I$N18+%(~q*HM{HTcGcAEnqAdb*RZ|1dV51%UlmprhV#LR_{Y3(M_{VU znVFR}Gbd+OR@SW9)!Xap`>Lz^>g%>wuTQVZ9ao#4UOO(gCS8RR1gzS&2v=ccn0!{_ zRmoU!C{Rs?zx6Ct3r^0gQmt2Ys`^lVGC7n71!-yL5-!#V6U2XQ!_O1)f^gO01h~5Z zPA$W;;a_@PF#O2Dq~xR^Z9Ye*y9iod46fe_GwBLLqQ!lBXGB(rJz{H^QCAui5pvMv zF+@kiMGa{*6-=Xf*AO2bq4)l)LLC+~Sr^P^+oBv{iBSpSlZD3EtWeVg{B5#EIM_9l zfq2c2Zj^d$ZkQ$96XhHq z5*?n6_ok=>qdn3QdQ=DtD%AuFM-7RHDIWO+V#1ecw5&SR?9qcV@#)f_@X*3g1KaJ1 zcjSax#}|Yef=aa!;z>K`4mBmRvT}_Yi#M8&P)elHV}3m-80++5x+fwW;YkrLmr`@M^3OpT*89Z3ecr5;jnwwiR$fRe#-zr|SwZC6j4#0+9 zF_pd9>w7>rg>aSFZ9#7KH#s+t0z@l#l$W)OhuEi-6WLDnUU-6M16>^f<*6DG@yG?2 zR4TIr;D>rp+Fk0ZUf+0L7L}mtf3&NlbQoe6`*_*xxq%shjh;l=R+9nW1Qa&wP-z(a- z_V)D(r!G99z^gj^r2)PR&@M|dFR`PSt?%#M+NZAajqm-2mqRrMo;iNb1JOn$fF|*p zUhyui^ZZ}w1J6oc7cw0N*dMA5@WHr5B`GmKKcK6h#0BzXmVD!-x}jQv%sF#X4g0x6 z4Nu`1)Cx|6c581hFaZhAAtT`wsioSIDU!4U^r(4#$vi+cpdiB}@OtC9;jzN&dNWD2 z5#>>IEBe19p zQ$2!5DvRSGsvcqYQ9(ox@JQ(j9*%`zN_+Y_5TPMJS!#fjrRIl3i5#?{XA~@ZD3HIi zXsXocEdBky&lURH0psWIY(HDi0WM$(T$1}Sg)VBlh^=Kf+kd>D__Op2mJ8nk5mTMT z<01tQ@pl%=j@B9?l{1n*57%74lmZdqQHS_GC&2IJk4lt4QKSUR?F(=HJi?8lLkyZE zae0WW5|mz2cvvKfn|4?vrM4zejh^lA=R5h{BUI)o?DM=% zyB<6a8#IKGreW`mpdKN?oFd(kTgr%fRP=O2Jswl!AE2j}eqN8V{>k+`tba=Vz(zjB z>kp;F#*qyL15~!^DMtFCyl3nE?>Rj9CV0rl0(;os|5SfzPbvJKO~6+vONPhHP^6Ow z3DJlBd4uow8W z@E7`K*U&e$Uz`!5b^``kTK;&8*Jk(4C$2Y zuQ{HI{LpdW=Iw@?7b=3R0!l~iNf@Z~Jj){}fHx}AX0BNhZHi_E+F+!Nu_hpp3=cK% zfR-6R2dDFq3n;*GNV1v%#g+zGJk*|I z`8h0EDQb(Lkb?!t8E}#mxlY0?a#_NDmU3Am4@qhi$FKnaR5%W_wZkRnL8rjaadZGA zn}(iJ#fHk#N6B5VC=y2`o8wQFk~#hycHnz08;6tdanKP~aDneGll4dNb3kCtcX$zQs?dVqtojp9Md zOPX<3HNfSLT&=ifo#$~%lJ^b1y>Apu{R9TX zB$z6FUjQcuk*xdxtUOufF;;${2mcYwSy=h@(N_K}5=U8i$eh=dY~|0=Kqt`3j~I<8 zy)_>R$vVVykM6A-diZF>U!y#IxEEGN8B}+$+PsgEtvnb|>NMazTd%yAdIj*!HJhv| zb(YZU(td8`2VmtT$^3|w=aQ17rGd0G(4il&@+3L>@1j=T&x35`iB_`m1F-T6Ef=-& zG7V(q&%(;LU);(|Wna|FLyNiY%U1quk7DKhHkMmyZUJcYMh*284alT^Ty6t^Q7n`e zW@kVevhn(r@elU|{XIOH#+v>%gjC7OpG9V9&jZjv-kKHK8qp$2vwqmh`)%H+F(b#q z`H*ZG!Et*hG)PvS+YML%g;%mm+tl?nv%HBr7lL5Be@ye|X=CIa*KeR!NKa;9t=snzau1hJfXeen75M9WqYpy9gV{ z*ufE!W)0&*EnKwUlnEW7Ll@C=fCcI~jddmJlrezRxA9>OwFf?~;eE~MkXM^exf2)y3yb>0_MNr0l^6Cgu`TS^#+yeOu30RYt zNZ6awa)+cdeZ1{UIwOx{v>0pm^g$umoATU9Uf)nOhR=?u{r6*K1IT)mnHJ{&=Vp`| zR``%+`CMt0i9V?rS2cOUFRvXbvF2tEd- zh9~9tH^*bcE%kfGzNGg*KK31L6-Vis-!6^NH5w^!3HT*QaNwMve%ums^A=!!U~c#fm}>-|;Bpyp8Weivyr6?qBEyfQg~Ot*m07clTkGVyLGxy&dLL1> zrwac|bAWRA81Nug2nB`{XqQ&c=rq}5+`3)1cA<7^b6Q+Na@}-_@BEa|h;wbHaXWA6 zVV^2UQ+kOHo`znbg}seaL+Fv|UJT}qIpPpnwr@u%%!M;$XN1pq8V&%k1;w|=33W%0 zQX0eAeI}<-KDOxg9-fAhZWCtWJjBN+Y4UAU(&Qy5N%bwem7;PJHk@Tsf4g{JFW#hx zZz1gza+rlLc#l|+hu9u@x>J{5R^FcFSRl;2?Y7mc0rMqb@+BC=nFl?d0wG@k`ho*- zs9MBvyqIJ<%d6%i=1^MbeB%5dGb6(k<21zxlQNPDtJ6{nrkBTB65^s`QSwQw8y7($IMszZk#9B_b3*Xp zXqP@r9}yWHTx}}Pc6jt*hOj7G$aK?Gp;;dp8WJ7fP>$bFM?%9Cs_8R0H4`VC8aEgbQ@GuAE<4K!@rOg<5v=`}xXnyD>K09%>9uag0m#2(_Ux zF=1x4x=zr#W8Edd;0A`z;FU~X?NV5F_E|%wW1nrA+PR=>O&d}0H+C9PwjLwj`9?Y7 zNphYXqWf>bd6{m;b_vWor=Y{68shseG1&P-6{xHm@jXrp9Kq#~?;w`XYB)B5V9dR#F0POSMKCQu9+ZY0i==!nJBB zkxfQjqqc#If;GYSJn%64bLqM6guIn$W@|)fcuYu$VbXgkE>Ce>mL=U18xax`s;v(* z6Q(|#So#9D7?Dn+cKS?gAx`=%T106@Xgk+oUqVhRatLJ(c7|p{l&CwAYCxTVciqVLKeze7 zhB<+n{`D1C++W%3jg9rroGIte_2;K!JtOnaD?AYUI32iTtEi|AlS04c%q zL#+l!Xmqe%=dovc6NE~It{Xz^&{B<1EokEH@fcSLsxQFjJj`6}*duM$Xd!qd5$2@di5NX62se-REiRQVz?FSj>x%R zw#0<%!)zh?V6P?Ln_`kESZ(p@Lqjz}ty=Ag@f6V>>yyCTtlkaDp@zO+a)@5U%lztl zWp?@HH%;r9nA9fpkCxZNhYlfB33RnfHN?)b9i*#tM!zeDt&ADkxf3aEs&9luAxui~ z>e`~&ozdTHI7W60cdmYg(>vTQ(9K9*VajE`-{^(E`y3>hSJ1cR3m;+RO?J)A-M|r6 zAkY673o6aYoS1!VVs_TVY@xOwrLZuiK>7xDmIs@5isui=%{EY;ECrfqKQy$2r4GFV zn<86*Ji55_j|uS^8IzNLZn1hYGZL~d-ftobvD|OMK4Do5*#5@)*%QKFiC@wH<|;kz zWc^=$NooIJ{pc%s8uUr`kHyEgQ_{tIO1zbOL*TBU`^%J`56@HNXWqx*8Aqc*i`;+V zxss>LJrSO#@n^XY!86Ps@X59m&$IZmY!C4~n?K7nfafLjtVk5kOL_X(`!!?^0ntj* zU~YCFc6p_nDyZooY)G7OlPRCwccP``1l4jRbYLBH;79MF|7zWqqPG1_Yd06R?w>lT oeOCJHhDq&p84da!oo$cpn<;%yFW+#udFj0JjW;wenXgj)KS3Awvg*=jwv1y;oXHs9IBxp1SyiKoo@Oksi?M{w<7Uk%8yP#No3V8nj14<9ZPqY% zpYMKq7Vt^HvTWAu(%BzHzc?Ie--f@UwzXze(rb4dV=V4A#)95&sOemV@JPfXJqv7T zIc>$U=Sp@ncALrAN53}K*VJV-ymJ`wHz2;S5dkM22$%~0WcV{0Tf5fvxnx~G#s+Kx z(_gi;*Va@$wmyon&})D{w6$j4D)Vts3-}zsr?l30)im7y^i%K?-C=Dtt@VTNS@4{z;rzII&pgDiPx6cJ_^I&t8nM-c9GH^{3dN7O36#EA zX=gC8wxO|$&^2|{wD3&C$3hUqx8PYijpmNWvF8zDifZ;VbAZhY;bLr(dh;dB%||1Y zvmlv{-N?j=NdDL_%E#E;ndOt&6D;M&6G|TDSbkv&V^_R#5UHZYbAU?$ub|ArRT^Fl zcOF6tU<%nN*kjll*z4Fv*qhkhu=lcWVgDNsXUF(>#`#1(7j`uV4By1xfc+MK5B3NA z1K5A(hhcxjKZX4{{}T3B92oe&IWX`O!em?ohyd6jA_R80h=v^};$Zhdo#tYINP(Ru z(qLzZEZEs17j~WiuOeT7TTv*AV3&vz*ki;P*rlQrc7>>bJx)x9JzdOzJxk1jJx|Po zJzp$`{jvC%aiiL(hP}jC0(-fEaN`N%3C7Jy<}_xQGt5=6J5YDH*==?s?;NGZ7}^=5 zx)KS=nG%)(wwgK2TCnY4IS0yR|!*1&eI$4uLyayF=lQ z)$TC3gS9)H#qwj?9l_%HA?=Q2DO~f$qgXNDtiz)b-l*L%ERZkL?pT(@Eq>yBJo9*; ze0U!g%(eV^0!v2EqxkRZlQ~ahX=sNkydQd=ue3V}t>|U#?vHx#XYEdA8Elt!4?r)l zUAt3Q655o)lggr5opz_OEH+xZ(^)Xupn}O@$t+&GGeOsqPZnH^&1CJYmDR8|Hi32F z-GW}RmW^Zecy}UX3gX*fhO#om)gW%A&D9%RC0ctI>i}#S(zSrUlS6t-XF*%<-e6{d zuU2qo!SwX^mSzgl)FDkbQrg|U!OjG110X4<9lc6nGB7o;M&!`v>J8Iuz%)T}*1H_C zATHXFIz>vk0W`Wms{=GUSt-K0;OYPbg*Ty$>j7C0e=XwL0b`}^1nwHPN~W8Nm^PG7 zr{tMpD7Pu>bi6wdN9EfMh&ml!2Y;8QmLhXWP;0#$q#)OdmlUM6TrFN#4I_#vz)=H! zTHvZ-^MKou%E_|um40s$OYzn)l4%!m)rmfF7@l^S!#1`8t~SJ|no08L1if5@c0iU& zzMbHsO2ehFS#YgDinTI-Bt@#rO6s*pt1yt1EXj1h-6ds5sVAUpQ`j_3-;SAb;KPsu zsJXz`0qjkZdJ0CrTxn8locVKZaZ`g5Rx+$%%aJ?cb1m=?A0$3IKV{7R-I=N5_WKhqlr{+o~kqF04<9eC>5X_D@L;^*WVJJi-=`RZ(M zC0)c^C~Ycp|yo{dzHP#LMCx%)h=7zZpSB zoG}?QuWO9kj0Z6H`kV2Van$T@4mT??qgsKv*Lw3pbEkQud6)So^I7vX^8@o2M}Q;7 zk>?oY81JZd)H&K6r#sGb-0XPF@mI&2jzf+wopH`_&RNc-&Xvy7o##2XJFj!@aX#pL z()p6}9p`7x9|Hmd5(2UViUTSFrUfhvxH{m^0Y?M-2Tl!K5V#_6b>N=@pACF1@cqC~ z1HTK(3@QvN4Vn^E9aI~1cF@H^R|VY?bZ^k3LC*)h5gZU46Pz48IJhvlEO=({lHjJ` z?%=b7FAsho_=(^bgWnGRDELH3Tu5rj(2$alije6ci$WSh)`wgevNPnSko!U&3wb`| zUm?GQ282e3R)kIuT@>07+8O$X(5<1@h29?eVCXZUZ-gES{UY>-uz;|bu;j3zVWYw( zhb; z9|(Uu{Dts0!w-jl8U91WyohZPk4L-^@n*!Kh%X|JMGlK>iCh=?Smf7{zeG8sqN0+b z21j|K#zswwnj2LU)e^NfYGc&JQCCL&G3sE{x6y^srO}h4=S44%ZjD|S{dDv{qQ8qi z5fdDf5;G*GFs3wSa?FC5`j}NQXT)3>b9u}SG5celi+L^P^O$3?JT^IYaBN|0Y3$_K zb7Sv_-52|0?2EB)#f8O{#8t#ii(3%a7}puMKJJ3J9dXyk?TLFZ?#Z}Urn`yJ}{MZaT7A}K5>A!$a^qNIkTj-(Aq7bWdXx-sd_ zq=%B8O?oxyy`)c)zDfGEe?b43{>l9Z^{?zdqyOUmjs3Uu-_`%N{`>kr+5g4<@Am(+ z|9Ab5CkG|RCZ{D2O&*avF?mjMZSv~m4ar-RuS&i-`M%^Ql3z@IEBWK(?*{Mzp#u^J zWDh7DP&(ke0e22~V8D9=jt=;C!10u*l64_-jn%S<`0=CvXZjKX0>Nso^@^3Em`+weU|n8 zK*zwif&B;84csyCmVtW*K0WZ=Y%@DFyH9pnc18Ba?5nePXWyIsx9o%2-wlcyG-Ocz zpvpm02F)3?Vo=+l(+1r=Xy2e$2K{?*@ZjvhV+Pj@zHsopgFhY;HKgy5X+t&*xn#($ zA-4>McDGDj-S2U!kplDQ4RnfGf z>Y|#W=AzD`Gm9Q7`nc$);vvNi#b+1qE&ig!D49^Q$@^SUa!<)$OO6f?8J;zK>hKN2 z9~=Ji@PCeQj_5NYeZ;5{OGY%0IDN!LBW@dU|A?nYyfWgw5nqf97@0n@dgR3;caFSo zzahKyM{ zX7!kjV|I?&GiLvo7sk9l=Bu%evHiymA3JyKlCe!=&l$UI?2TjhjD2wIYh#a${jM~) zG_ka(w5+tb^ybp%%8as{vf{D@W!+_$mR(!+aM?R$KbH?GuP*N@-&($}{I&8E6$2|~ zSF}}ZuGm}gR%KY_pvp;=-IX^~K2rH!Rd7{t)rzX~s_w3OZ(QEE2getTUp#)>grEuK z6Rw(YXyUSoEfX)E#3s#~)H&(>$)3rZC%-ktF(q%x=qVGX%$>4w%K9l+PI-OG(WxO* zbEnRkde+qaQ{R~uF>T4TJEwg%y?A=v^sUpMn*Prj*)t~1SURI+#-}FMHm|d9&uNn0My9 zOXl4?@1c1w&O0>k`)XbtUfr)cyV_G-Rz0nHQFT*wclE~VORBG_zP);1_0!d_R)1Li zW%W<<1Lnuh&zN64fBgLU^Bd-$HUHv8t|hP6tf|>v^UU&Vm+xQxm*sCRKeGJmTC=uq z?a|ZN9Pjf##Q+KWhGIW%SB{ zD@U%Jy|R7fxht<)dDqJ4R=&ORtCh!FVp}p=Mz&0Cscq?O+1zqv%e^hnw!GW&b8B?# z;MTI%IjxPYXSeQXy|wj))+4Pyx5c$(wdJ>!woPrD-`3D}TH8f!yW3uD`=Q-v&uI6w z&uCxV-rC;Xeogz6?Ju{#*Zyhy533SZm8@!8b?2&QSADoTWOeE4sjC;RZeHEJddupY zR_|GT-|8n;zqI=8)rVGpy87$YKVkwJ*b&*0(2?FTq@$o?WXEJY>oFUmojuz3kvkUh zZ3fXU-Ep=7dui)2pDKZUCy(Jncpfj}qxpC~g|FwA@a_CYzL&quKNI~#5q6&bBpwyd ziw_Lb2sNUOenz^HZ4?_<8@C&e8c&#sW;$j*<>pfCBdx@a(`Dur=GEpO&HK%V%ty>8 z%va2J%%kRK=D)(nB&Q~yn|xjJPbsk}i7Ck`X(?GLgHl{6`6^UMvIn=^N0UY~h?=0ll}Wj>ktV&*FY*Sic?(yd#5Jb~R= zhFPc!bHy9j-8`P>@&aDMOR+yVUFQD=eh+_%eB^PgpSWd0vP{+Zd& z%rHlo73OlY8FRmFnECB8Z!+&T_nG@;{@*r_n4g+oA^#~D7q3gcAtfdyAtfm#1^LfL z{@p1hGXGsE8+znF9{Hb@x-fMc@_&2k-qib3Kl0{3$}j(Q=~*)WA;|x-jL$PpWS*UQ zLFVO|_hs(Od^Gck%omXVH8TGfsr-v#$l#>k@!kAPU*Pf2kKfIo=g(Lnyb86X2%Elx zk3WC>5mcq!jQ#d9%y$-q|NFN&zg=X7{QFgM9sZcHBiQk#-;upXb{w5_G~viAN9G+t zSA8Vw$i5@@9J%wzZAW$=x$*EaKx84sIG8UFA3glx;SUb)K78llwTIgduRz%1!?O>M zKU{S<>u?}pJ2dF;n;H9i?uY$9*v{C8{b8{4Z~Z>_*So(Rc<;dLj0Iz_6>Wg=M1B+B zFE)w0#RFo$ct#u$^c8{`F2ijU7=^}IE3GlwD8qbzw(=Vb^cy?e*oS4tg)k2opBrBq zUm5>4el~tJj-yTlo1p|pZ!_G)Hw-fpdzs8kgn7Yy**u`IoZ2?`0g}waC&T!Mp{DI6 z81rrgY3^anLFUhnCmc^Xo^d>9VQ@S}F68ztBUt?1SYVuvoP~(5v8wsNs1>h^FCc|O z;tlbp(QGsrP2wGcq}7)tqP1kOLaaT$_R3n$2fRu|ivib=Fzfale3FgSFO8 z?2qgY@o#Zhd~d84Uzwfkb@m1O5zBML`?~Hcw2wGjWQDe-$=dwlI#TN2mYzcR>4qnAp^GepuC$Q7`RCXqx&d%V| z*m^#boz3U44SY8H1E0mt=L^|+d;vR`&u3Tjc6KFiVHfeGY!`21SMZhWD&ESj=bh{p zej3}2QDX!8&5i6Xel}al$FNIzJ-+R{$42u=R>DKr416!z$mg=ne37}vTx)JL*P&-T z%{Qza&e$s!Yb08>PYsKghU zabmofAj(9!m?(ydAs7utiy$#o1dD0-1~grSiW&TUk;*^7H=i&u6JLa8i3l-UM2a~g zO3cN#t9c?uREt=QqVZxOz7j3Mn6g;(6-(GC9>H$p-RwHv!EWGPEQ%dvG3;X&!aiVO z?CtQp_mmh(bZhwog9p2>~IxyB!ivyF3%^Nr2MdBz4~qp`_YW7HWf#!6$3G0T`|%oH7FjPXx1 z#rWCmYkX%W8vi!qjjzo<#y4iPaoBjz_|l9r{%*Wud|?j29HYSa$xJqjO}Fs_<{VXK zzVW_UZX7X7jNi;O?7id}@0w-CA+yN%)r>Swn3=|h#@lALDU8ptJHyN@v(R*zgG|FX zW~Q3l9EdS{nDK#GYC6mz=(RJji{mi|o2K!L8D{*$3^zVCUNT-YKEeLZi^j*sLE~@c zC^Of1)f{OKHC{0%m}8AM%<<+J<8|XB<4xlQ%mQ+Zm(6kJXybqxU^>k}GYI{E2u6We z%n}mJ2#gWm8{ZnAnQ>;4@h{^?W4Cd)ahGwYafh)7^Qqg6dyGdgt9s11)p*Ky&Uo7R zi}8%{yz#8@SK~?JaeU>who6J5k#2ln9rXXu1Oayq%+OO9iX#)NjphH}youIIm{ZZD z@C=vw+zXqXFa1Z zdp#h(Wvu*AUMUY2pTrZ<8ukCspzoJu+B2V)pW1OM486-?#{D0eDHkkgQ1t7Cq~2uP=5t!Q1Mq z*KMUc)w{R0NnyRgS0SAGu%3ow)?0bm!|c4mE!z{NBRc=rb-4^U|1V9?dbXq&o=N^a z%aZcnGcEb44%%TYS(DsJwwByTW<6!oGhY_~W;@IvnCUQttIlQ!AB}ew4SEN`jDex{ zW*Mt|sK23lL}f#L5xpCasFO7sKli`Ag{6wns^h=2$%wx zsrpTQ0pTKA;Dcr9cQ)R6FvDO*!H`^=VWz-PpFw7_%@F)Jm`h<6!Jux+ew1jgg&}^3 zKhz;fZw20jgJ@Iz`VHnmm`7lUm%cE>EAd1AGMFHk888B-0fuBleI%I#Z73aNDor1} z$xrY%>oCHZq5YM3Q{5R2Lwphp@$$bkJV#Uu!V5g)?hT(k{KTi|XV7L(ya3opZ78oK zBk(1|iNDL0pAk;-=OgYHW!Mp)o5IL%4?C4x!V=7?DS9NCY)aW>}3VMD9(zvVC;CPf45+$-gw=%{99pGIy*iq>{M^NoGqB1 z`Lu`IaogQ?Ja*aIVX$w?aPo(-7qHilop!Bf#(hIJgTCN7=K0ImV7+FO^{@P>~PD?(jq=Dyk;WX5lS3?T`II6LAdR zvgk`6hMLxzj+HFFb!F>Hc}iYjm(YnAhl<5cw8;XZ>LMnF>=PStB5E7<#hox5d*s+? z4cUO5a2|XJ`;Fnwr*QUiyz^037C4g8Q;5Cu!T17@9PmDb*UuORJI08I9WL!011Ht_ zAfpO)j)7C|e2_Q_+ac&&{XEz>-;KTAY4}Pp4`wY(U<|JsB8Zr%oB46zU)tI3U>Xo~#+ zIue|BNP7}O1ipC8!f9ECFEm}K0T-aO`>`w7)%fPI7f&|yHD1InF>#kGH6(`Ni_8$_ z!WWtG%!98nb@?$< z;nP_QcDEO^R;iKDCL4|u_EF*zIs@~4B^eYMFcS@J71k)30b>J(!xTu9i&Ji6@uW$U z6c8_c!viAan_}n*4lrTxIFDQ5&L5rM!u;s`QkqYkhwy$E=3D0*u<-bOI=v*$%B4?vN9_>@mRvL?U_?=II+v~vlT;g9z3bL9GmzJNB z2yCAe#)4bKf|kWYuydlb0%o){#ZEWg6P-EIWI0o$ufmxq--ro39(ddt>kNmXCs6u@ z<5#C}3db=rj;|e`A=_S+Z6PH}5oe`|5|~ za(0Ft?K5-0X#*);AGf0?#zpQ0apwDS5|wa9crcXRPEzg_fbbshsx* z9yeFX*okJdV}HO{$J>(oXs3`l+UY1W>&zvfRE;l)(4FETa?HsQe}+3pY=S*T_KP0T z1b1KAUrvzr3TYQh_+sf!kdWch4v}`5v|Z93WLydigQUAkx~s^|E|zK24d`<5bU7ZT z%keN>j)dtFf4YRplrWhRCR4&>N|>P%CQpu;dD=}q=Fb!Y8>1*}>gh8jOs0g1k|9H+ zdx&&bNq3cWXGq8l8ImDGa%4!3OJ)oLwm42gs02E%WbzVW(Y8J;5T4`u9!(w!qI4wQDb3@J3O1hzt>Q`)3K zSZL6ksL*&p+Q7yOCFC%fYM8_}Ok*QXa*Xe!O{sEZsvHyAhkTF;?L&l+o??znm4j3Q zC%Q-<5}L5khb)l#kRx$AV(K_8M&C4ESK8tFsw z7f2tHze4(u{8iG26<$AA@Nlm`j8@-^dZFn(uWi&qz@@lY4#z~aN?jtq>~P$ z$iOKO1|2tLcHq4g2IqymVHCH~=BHCoYi#~i+TDzI9n2E#S7|UOmh~=1+K>a4jKGWs z?Umevq&-yHA<}k8d!Dp&VE<1#nKaLrvtQ#paUHZhPiLV#Rc!gMp7X?idNzpOp3}we zf4an}9>uH0fuf`EQi`_!>S+}J=~<3b7QH`naVF#UKA6q=JrjlR@x@Rr{XI`_vh6L* zl7*E=Up^GyM0ui@qJ?3LF5S~*8IpZELN-9I3eQ|AkGTk0j{G;0FNns+H>qavC#C(R zwAYa>(#ghYkDm~`7w5mA*(_seEz9{i5=JhJp$ARy!(a=n54mh-8R&ib;GEcGtTSl+ zkuTRD6Xg112745Gr*qj8>}i}IdlsvZ#W*4M88my2@c^7oip4pnt#TEz3n#+b*5~OML4XAvo5pd_PvC^&FiVJvfO&&oS%aFa8(L*IYl4h)jo5V_)O^89g6)pAT^= z==VNvl>4(d@%9DQpz}#mY@u9EuX25*+@HHK4oN|oWhsZ=dz%D zZ>*fWD<+4_3U`dOTvo2Jexel=*@Jy?y}NqS1IOdB_M#`jc_%%NXlaK6dnn}QloD~O z{3Aq$%Q!|9$nU%pt1k8Y+v_5~&*yU?oYG-!=6$69Q=IEo51sI~9=rQ}%CG$~4^m41 z$DTh6)_fHYt?}sjGt9#(ja`z2+rreF%Zj5n)^YS;^=3U-Io+V2U0APK&q3+hYHl?* z!_Z^J?6Bhl#*O+pQ@p2x_z2Q@oDevYI zJU_#Hg%ux3yxPjSEagqAtidx@gObN8a5NUr8kpgDn$6*mTQZDnHCCRm4uzB)g=PW8 zA?2$uxzd#_W6-uhSC%ncP!gk1xW1y?frqZ1t?Qm%qOFQ|*O2lSM`*E__&(fVK?L28mOFQ{QGa!>?s%+`b zmhQ);`*Ct3Ensk3l;~zkyW>P9+_cbN;^l|LnNO5(oWGZ#M=p(Hd}@dk?uj#Jxtp9GGv5w zkCyHM(w!#VInqr%Io4K^I`!2tYO`a-UiM$5GljYn_br21PpgZvjc>v%z~k6yz1=xeN)XnpiL)<*_^L#~hBk@Jvu zF+xT2_vCEk?{YSBNX|wM%h||BSQ%wwC6yy`v67;h$rrRf!X7ZzM_5l`eZ;?}c_04< zGnR?`TQONo=Evl`o_PIjW(sd)x+tAbfGShRJ#&JvPgv*YA3F8Wd@9x_qTSFDe+^pUBXO#KG}>kZAIo0H z`nZgj^9o)G?U!*l=|2HF<@ca|-GdZuSe-(bI6kz6cr(Gx;o> ztuJP8;FSHFIE6iz9pv*+f9CTAd?8hbGo==nmBJdd#^-pgw+qv(}IB zM&87marS-`&f!g8YgF9-ue=B)L!AQ zV%6{(bQ=z!&Aq|jm;48| zAKC^#@t^rG_)79C`v++%pzY(fD_VnKXTyPgjR5Rt1YsW|1R4Wj*e8j=ZU%Pmu#XXo z{vaN^B?-_8NQAzfrK^x4Qn7DB8VjV4U}-H3LH{sR`i|MqR+x+4qZ)k&RzO%CE*49$ z{#hn!uwJYcb)sIZ5Di#2Hi>4jQnZLxtSsBbDzRF0h)%5FyTuyZmatBohIQu|;!JUt zSTFv7)#lk^BkoYxB+kWZ^n9^dT!0(FF2cUW#bT?tL~Ik6;(mq8#13(}*eR|MSBk5| zE^#%qcSwWadg$=nC~gve6gP`o#BOn`xJ}%SyBO{ecZ$29wRMl!EAA!zALwsAh#MLn z5)VUDald#3+KP{fKa0o36XHqS+we4WyPg%#iN8RH@vq_q@i+0JcnP;Tydqu|uR-7G zb<%ed2gO_BZQStiu6R$pFFp_-va7}4p^p&@?VWh6W)rXqPQ0i**^9VzP<0kO8;?;HNnsd znZb4&Goke{8@eBJq5Dw{ovsDOLSvD!7~|7YV;Qu(mK(Lu52-g+K=-WCXo3#fN~6VS zHQJ1JXnd_UI*d-E%jkv{$y#W9od)f-GmJBhvyAo7G}!=Mla0nXSak+rg?S#kh+SxG zf-cuN&_g*7YtK)yyZ4mTMY+JZ(74FhVq9!&#aRCs3otG*wi%Zi+l|YN9r#+m6JO8& zg%$V1Si@cdJ*@%ka<+qA#%_h4*v(kaZfDmRmt)`XDt3#plU<3g($5)J7*`rs8M~kp zdW~_dah-9!af5NAag*^!<7VR)=y==;&5qll;c*8vJ??_W$34bg<6h%F<9=ukK4|PS z9x@&_{$%Xex*vZgO%LdMJO%xaXQ2D>9JD~5hX%+CxM}1?Xr#Psykfixy_5scJ9z_o zCkLUQ@;0<&-ZkE1e=^=TK7iiK-;G1ipgCe3g=Wgf&|vuobXh)wmf{!CQ~W2iQ2u3n zZF~bwl<%Ok_`Pw=_yM{oKS6u(7w9kkW*mq148tdIVH&0h?HDJtSxA>97`iN>W*9UY zBcRV1Wk#DZ&}@k_d+=iDXT1&GtoLwg;~nT@eaH@(7nxhki?Ld|1RB7XI!l*#K$ktByR9j&vb0jY z%L?_|rQdG-E}*w7ue?I}OQj#_%C%poD=pUkQvKF=O5Fjatu?hB?QH?2?G5d1^(#Zl zYnwW1yIWVZ)UOLJuWRqBsjaPV>vC4q)&L5xj`o@^XQlKhTxFWaa!spT<1W`cmKOw9 zdhzHanw_%JfJ#liQjyp3l6_ZRag}qNq!cpF#}$FN^4#v=aXqlQC1F=yWq~<gqcmE}73<;4LLG}#G?taCzFQ%hZa2%&Zrl)Ca=c_9=1lLN2h+Lf1I=$u?r+uc>~ zoa_|?Ld%>}Rp?Y(Xo1J5Y->=ts?dU|(7CVB0;gJcZ6#GBjjXk4(DrG;<}D=4=Sj zIop=3P?C1J^Gcm_Y?ZW9^Iuu$nB(JDNh`0iO6R)L;$QP!Ss6CRkKZ}ENam;_am?vx zYHM)Lkp<(Nr`Vn6V>f7CT~mEWeP>grb6!J7&6@g<`MyXcvZ?~-d>N&^TE11q&IJ-( z@B+UYR9aMA79lHFepxQAd~KOhI$rq;JeJ=%Gq*X1q&w19k7a;L;C4$-gs(<>Bl9ZVE}N%JqAm_EwK7j}#e@ZoUIG?J_*$(kvRo&u z^h+4wYne9KvSR7!!A^1b1pl-XywbC!EibisDiosdX?_V6x0N!_#o=>$iz~1psZ!Yr z_m;Pe3!mRhx+;|}!dI>mHnxXcWuUXhR)tHeR23;J((gR|&ev}bz0uF=##So*5V_Uw z>U5VzcrrH{D2x=y=!zoTkET>!L?pVI_qR*a@KpBU0!9GX0BWlDc4BLHFM>j z;Ce3t&9CO8tSq2j)2;XGU?tx!x2MF}ASs13_}Ecdb>{~+^uX$tgwYXt%tq>98+~bl znq&)^J#&&`pj-=}ydq_amR-FYQu7j?B=Ht<485?yVXv)firD>dJhMUHMC zpGr#TYISZaExt9sl~rNgetdT8BI(v$ZMW=dyH!`aR4lU!*GU zDv$FtdsnOFT2aMoX@;hs&o#utqNCevL?Mm}>pI0R# zl&7r7?#Z)zJYEkqV(E#j@nymrize*@rS@%)v7;|7lQH2nmT2sNigZYgl!%JZ&0l+z z+@&YDhJ-JjAvLlDq+8|QZOc8}-rT$Gxz`?*d&?tPuskwL(qrY`-81(ZROR0C$lO~V znS0A4bMN-gy%ncO+j8#?wdEcjU+&$$+#`G5+`FAEB!v7B)#$ZbHV^5h+*9+{5uGhH zosHfIbeNJ=YgCk(DE4R7ij%AsNBH_*!lEO~6_)UNdkGgu__|sfY*~r)MEH74>325C zS9p^jig0gVX~R%1C0ZP+dr6hN*#=OdNQAfh;ZpUWGGEc@?k!FhzRpvX%5b>1L92A9 z^^&eirHk-2TnQT`ry9DY*+O(nks&eujlvEk=_@AenABWAg(yra#2=F%ltiLJ6e}d1{m@$Wyb`f;`og7P$1gM8lO>@oEiKkf+A@0+)_=S$O2M(N&Nq`!u}OGN2$& zjZFnEHI^3S$q6nl-BXK~f;`y^1K#pmaB>cTa5;*&3iPt2K##5kZq1k5;#jqxp4dIxbDerRlgVd=`J2o=fwuR|^G& z8h?=vFVgr+G~N;&kF_W@>jJgrbrqE9awyZ`Y7LBV4X@Ujt^&23auukB5#E}=%1YTy z6u4A%(;n5R6{UPlAzzntzNVC~CGW9njE*nS{(PN-d@V7L#^bT@=$z$iiREjK^0mZ0 zTC({%Cmvlw`C6j+mfwQY63*8WF3|J}G`#}NcY(#XhA+^37HB>TG+zapuL8|ifyI}F zU(+wp{1jMxT5y)%%D+WN^Xt)cJerP2)A3mNEdDe-kLF)bdI}0PT#?RCk)~6k@s;R! z%&Jw5(Rj+VJj!&qn$4reX!vpsr)GnwDH=}CG7BoJR3-7)Ym8dV$^O$-;PH(t@Tk=d zHUnCh*VRZ?@=HrFQMJ78Jk@601yz!=yQoM?25*@YyrpFDmXgI=N)~U)G2XHy@RpLr zTT0Gdpcd2af1#|h_|9&P^jn^6cz`aUfk3)wQ*^ z24W*9S30`Zwo6BFS7S%L3JY4%-rXSsgH|-H(Qutj>l9okwyxTgv%U%c0-^xhnlwU1 zqpiEOL(-5AqCpN#gPe+nauE#)M>Hgyq9L7%h6I!}$VCO=&Qom{ZzX{|k0%JZlJJe8 zGB?WI8A7?x?of$Zdn6uLv68T>SV`DbtR(CzRuXmwlCY~-N!V4aO4wDb zO4wDT+ODfiwI!GC-CU~9x?QSw#9QOjHO8fDj7!%Tm#Q%?SE=St_5N;`yCk~tv{jAu zZA;ZFx236}rmtU+JtomVrlF(0rmMa~CW_Hf_OO2bVN30CkvguWzOz#zh_eMO^@hgl z&>F0R)KX9aM=!^sslKItMb}cSg%sDT>N}d+>z4YxWK4oz(9-(V-8C&TB1%X2LLzJ1 zTiV-fAhGr9YSBcOuI{ey>}qOnQ|WY|FI^vdh%I@%mChf*fRlp#<1S5bTbq2|u=macH=>yT|7-Y{=5%1G(+ zMn;f#Wqp^GHd6b&05N{4bJsRDb=AlE<4}QdJus=z_@4Mwa8yHgQ%g&IYrB_lv^T^{ znsVo=%&+Q$+NT?cU+s>ea2qSstP*4xg~QmJLbMg(t5303kR2|Z0(~%%J@G`!I2%N~ zEx;C>U=Owvh*lByphUkQKel646g5>_dsTrwYi5kbY41$>`G@)8j<(_?I$7Hj{et|U zy_hTz)oJ)Av*LUhBPHuL*gm!ZJEsZuaE(VIkF!URTZSgfP~9Ww{>w%sOh%*dByg1F z^LAtksZ{|iIA5rpfe1y)+oLHcRR+F{_V+=lYTze5m1~v8jyu+tikvof?O=+egtcW- zCH7&EiB({at~^Ns`2ziT8_+Y-2PZKq-c@b0GZpWHvlnEvEx4vjVu-T^`6SlI7HY?v zXbbl_C89K+f2qxBPaTVXk~&Emqv$QuQCEWdzSWd3FR#ei+SEp0i#qFT+uQ0W)}5E9 zCt_-m=hllxcfMN8;jJcs`D&5p&Q}}HfGaaAyF1z?5?7g?d#HJ@+f}A!iF$6~c9p8x zq+74xTxDub;m$7%u`+=!JCI6*dh39ij*j-V-K!LbB}LeqfnWBJI;c2E2GKB~LrPQ# z_2@dJP=zdS?`l-8?p1XbwBipR=oGBl&BW&;jEILk(R5qmY zBZ`ir9LjVG4P{e$W!^k`zUa~OMGxkSb#2S>RcWP;S7qVR^F>c_kweF>vO$$7NqLG@ zW%3lO`M;;QBA{i34X#*K7f-P&aSvvj9bLBg5=~95xv}l9W~ZJKH9Pf`s1op~wK(E6 zz7nM^OR`%B~<|}U7d|Jb@dKu2h^=pZ<4*Iyvj*it1a~^tL5bZT~uhgW_hrX zmwsI>JZd5D_UK~vsAU`cnyC8T;r8goi$^Uh-5x!)_o(GJ;&mp}@*4447;0ILc%2!& zIP&PllSeO(JZjmBbZTnvQQHG+|29DdKmf?ukg`_s1;Me@94I=ne zb@r&`H~7(o>(PaymchWM^If3v>BXQ&ZA*Y3&7VuhtBoqRM=uRLdXeaHS@d;b>cyqU zrRnL#sYfq9J!<;__$_!VKf1E04K(nl<8>e9(S4XlZ8IRBnt!!D;`ZcQezkP+sBI76 z(dDDIEg%mq*Fp_nXysomaXe}}0QuB-iY$5RaxKv5^-{^>*5PVn(Ctwhb9h_$wEXoV z+oLwN5U=^tOD~U0!>Mi*^tJr-qTHjlgCIBEnDQ)mE5Di#wSfmXi(fScc-)%reC^M- z{5pSX+X(W~<*BxHATLc%Z6BfDTJbu6YTF6rq4BB>O3>H%^%&$)+dwEMUCwHQ5O5lw z+K_}_=UZJPteoxYC{wFt#a4$*P9w1Ph~K= z6`aW^%&k3*x)eAiD!hw=>F0r}uwcBljC$|MpZVR5=`Eq*;^mA)#I_tHk z$nGh&d0c9HOM$u6##fX**EHG{hxvUv2={*FlaVt2?lL88x8Cw_mlqqerk6)A$H$$O z<#L>;o7L6PzOtU-)Htr$=M_epy03-@>icSrv!K!il_~6n@|m+?Pbysi8^<$J*sdwl zCa19Gsgo;G*zlRtrofFq2SzMDMZ=c2+{6JZ$N7B&=N1ET$}d=+MhwGgy$GC8jKbMP z-)T*{6~_)A@L$3^|4Vqse}E^QFyblJmWTg22MINp%khI$#QfFT+SXP48QRO^e_nyr zH-CT*w(z^9eVepzlJ+&Ub;ftLcht4NW>xWEjUA-fs^~$IDa={Zqgk=(o(rdFjPCIl3I$)AWyR^golUapQhB z{)zB3b|$pbH{$H&W}MsHiu3iC;mqZgxUJ?|+=+A}PF?QCY0^7zJJMd9Lwyh@Gxtj^ z(kIwcIB`n1J3%*-^bMg^KrU!BaJt)wxHaGvu=aE zENf;~Z05I_uMT`abA9IcOjl+?##b2!)3>Fcnf6uMj;5cY-PXNI?hJHvhqZwos;tTAp;=&zxlhrS=WBy@7<`yqFR zTp1D@B7(OEZ%DW@I4w9b;mV+!gSH1{1;qy57kEQpS)eQ7%7Ets_60NtEDD&4JAR&U z&UB7-yy18%^nJ%n$Jo&K%{TD>D;1_YU@A_+zKZk2)i_a2|9^2C&aqeH{(^6z<**gy zL1jY!e!YPS^BnP4l)nn$(0~xA6Tyf#q4#@^hNYXl4AirDq#{UeG{~4|rB8?h@FQFt z&~XH#FcLPR@w|-&-M^KE`hwm~rX*T`u~B;(5b-*N1;cP|TXAGjhrb7v7kW_1&{W=# z{QBqUNu;KJ%9k4o(Ol!d0fbQkj=YlLZt#=?$rvm?Y_=r_@r6wDI3&_enBXT2{*(}F z;e{56Fh<((qKEL}?VD`jHAv5x;k35K3;)S@5$nYZDI25x@RF1#s{G?@c{_L-Z0tgt zRBc$`jJcxmh*zp!zs!q*P*nX(2Py1yQM%9id`*1FFz_MUjMryz(B~lG6wmlL==03~z`;E|IJg^9$bxo>>PLL^@0R!t zq79BfUAC-~spI}mP=DG+y$aNO>#^>{I5Zj~br$lgMlY`OdZ(o2#VyN$;waA~4egd3 zXtzv3JtoTED3pDbfB7%5%3tbnPz|$cJ=f*Dr$^4M_P0mkrkdBJ%3tzu>ejbMmaM!V zg=NrP44OC2;!nKnlM5tFGZ)pKoZ#+e%rLe4XQre@Yt~1 zS~*J0o?oiLjNO9#yU-6LFxB5!^lp*dTDc=wZyVFH_iJyF+o`;b0Y1u+7_%_r3Z|MI z{k*STd)w0=d(^m_kSA(Kr*2<>&@vQ}n8O>%rmf$Pj0C;$pyy!jfJOG&r2GCGRE|KO zYLLv4)sj`s>UB)G!ACRVZqQ7#(~P(VG^fiv7R&akWGIX#O%u1`0A7tZ#QSxUu0ipN zn#!?+CysjdMMy7Gphk!71od<~^{`E+qOM2%Yb@$sKhXen=rPH8fJI&TPNI(VUg`<5 zd{cep8|L(tZ`3+p`2x4<4R`e@->axv$< zc2ccliQ!c!8=`~~(OdzGxBs--G0l+_1i_MlyhF1j2YqcE#DarlU_7~;cCY}{gEHod z+z?6sq^{N@!Yhj%-jY!K?m#Itd-))VfvPMel?FcXZ^@O8v$%@*l*Tn!l3bH{OhNp}8gBV)vQevp3%vYr zw(G<(A1@e(NMck;Z2 z%W=LMk&XV$>K!KQ7O>7L53gTfHczv2s_VXz)~j0F=jfXS+Gj)xpVe2WbZbz{5}eiH zdr>A7qFN8#x~Jya!)%|Dug4~0_!ljF7&RDFy{F6#;ZVk zCXIKfF{*3~lE3czS83{s8s%=4w@zBK>{XJUsZuGPB!?Cr;zn~M4xYHz#~*emNLGQ+ z`%Z%vd92|?pO6NA37YzU)u-W(XDtEC@82`E>T*>n?9>`h?6px_AgKj}?%}(PM?fMa^hrLFRTg8G62L=xK9VTHy0~Pu7xGqs|}Z zZg5#WJjG&n&)43xBq`4-&XS^lK0PELUh;86V{9)oDkZNK-V#&!sz-_E^1UpH-{ftL zmaOY-9C+m^-tv}*PrBAF(~CAPZ0!*}2T}KLkF|i!5u;gBNKQzKcU7dgthG_r?NZY1 z*+%7VfRSVGcaaMNcLq#Uy8%>p(USc7U~he{k+P?nY0VgAA1vc3Z#`%2HMTrh-t2Y6TO!Dx z)k1oV9J(ei?NR1S0Izr!7#D?6VCN5a8T7vP!9VoMbc=elK=?HW1VKOImj2II_1ONU z4rniJ^ijr8KiX7hX6rtRW{ghkloXPT0+08oAq#A6MbeQxsQN)1kaQ+XO8)ez-CKDl z`Y9*TpYEmKgfemmuD7o_wG8ktBAN!x3oW;r?Nd)})l+}k)jryMxNNmg`q~T6M&5fH zUFNa?eAg%=IhdPly_6ae=7LtOq^PK?vC__obgOY(^&)Z()3X=ZBl&s4#}CyEPzwg` zp6y^Z47op|W8c5QOyI0Z}2Gj zvS2W}u^j9!px*?3YOhxACVIJ3a~p=fyyrY+f{jNt24wkh={E*YeUY5n_C3Azb)1r= zgi)YzjW};(4|E$loi(GEsF0;^NW357_BYJ@;Vxwc-k77}UNVKR z6n@H$f_wn2aw_XUobulc|7dG%gIcCSu^K^dy44G{o;i2|dQf`k_EiPdhzn-~xX3~O zZiUT8+*fGVYSu|P#P?L(L$w+Of&X}C!MGpl7W@;|Uhy!#yed2?fZ30SaL^ZPW%j^N z;{eqryX^B#+ukN4+2daY0Ym+xsulEoK&?=)LoRzJy4O~hsMk+8X?|$!H>x=R!Bb4n zT8s3W3!0_io_O6WTb!j(3p29)c%wG76<^QQSCzLke}4N=xWmd?Nt0YbSA|F}teR_R zD8x%?`21?m$(p}gYtvCDwf2!RM@j-}l}fpxP{lv(Gu&(Qoob)q0LHDJJ01t*H&cdQ z-IDMDiAj#nkcXndaAO|PGH`zvrGQ(e!?#i8)0|lI;MaPLT-NtEwbSF@Vv0~&{yX(* zo+WR;L<`dMC^_@c^9peYyjXJ#B?F7{bElyEEn0`KXP`8Uc~HXkwyt_^kIaiTesJ-v zq@~6j8!aQsueR8F5%HaDi8jmv|0@8j@-9u}Xfpi3Bst>B?d`J^XLPf(o!0P^XlaS! z&M@`ugP^%lD%0h`_Lnh*D11~`f^5F{6ZUVXG>cc?Y)(hZwASl%13sl6rt65}G1rDA zgWqiP#h>`bRtjF6xZmyM`uBH)67C#t{j>a5yHM2R&7`P*fJX|1$#lZ<>D*bh;BPki zR5DimdrR(``O9Vy=GRc{AMe5y4m-YLYwr+S)VKaAAV zjK(}d^)!d%_-OGYu)EnL(`QP&{!&q4y5)M|RBlM_ZRkl?U^O@w^QkJ(D8w2(1N|$o z8<@)(>!m9UF4|2qrJvs9qj#8ohpM;uMZbU6?;rI0n0yDzyZCSqpS))eV=3JghZvQ2 z^t>#Y;6$T0Ot2f+6r3%3Ox|4g1bY%EjGmUa+C9hqg8iny;w;+V^!=i*&~2f(H~oI= zZcDn^lJ2v#ZnNa_E=#(}Qr%;zZm{Hk^W9v@UsJbM%9|gp`xZ~STTtIthC9i?=M>ag zI_)+9_n!`h^oF6|rTaO@;Xc&{+}3$9#)!+1i`}@d?rw~NZ{cpTBe+5H8}%m z-pICq?o#tgkt7?3TF!AV-)1RotG3bp7u^l`Fitn@$G_eYWD#aw!{x1~qx>+G5C(ZR zj8gl`?0gF6^lxRpaDj0#*1ExzU#D&_rQ1r8gIjzzl+yjA{4Osx0r|zA>Xuah8&dgI zplafu3f>0QcX9ih6Su#8guRVVa9eGpZm(3sV`1oR8q;ynW4;`fI8MJ=IS}a2OnFNw zZYm`>n5DR7HXj&ib&!D;Lh}}iQ?nk1`48qJY$;$U=FyX4IQX*gb2eIkS)emT3Kz|1 za<#PLNqV@8a~u4GEy;#W#a$|PNmwqfwqbRipwi-Uq_fJxg<9yNOE>cRe6iR~@%fx+ z>qJ-1+`QqkH)B(In#{YBo2_@mZLleTg@icuM5?r@2Oyc1`-Lcu1WLkzGlKWh?YHJ4 zKseCTEaHQ?%Ur18X-z=?oVbx~#(&=sjEPfU=kwXz2f_Jrq*UcWI{+l7445sNhd_i7 zMH+=jwoI0|q3#xxezo?Pihz5K; zBg#aeJZi1$NYBX_+`PXQF??ZOaqrZLt_l@3+FaK9E`g$6Sev#(D)MB}8u>rEh zhRGHig}mTqSDpfH<552bvp%wJCWvpv_n3nT+}Dc#)x^E6$6@~@euMp^_!aZ%dvM3> zF0>vKH_$$e`)V(N>n_|qd!^V4*PXbB_6l(^TzBA3+MQwxTzhao?d9SkxNgT?wL8Rx zaNUM`YcCTQz;!F`u-z^;GY4+C-HZEh&llM5L;c11Bjofc*nRn9u&+YDCHR&6PS{uQ zJ7Dj`w>*IxoNq@zvje^KKD;kOfBhgrw{z@S@Jn$&vjK_T%xRr=3GQwdxC8no*can& zXMsDRZ-9LfzaCH*qSw41z63(=!u)4<=Clik-iT%Ss&D+C|WGt2>m-p z(%xgL;y-^6o{zp>NU z_v=KrF_Xr#L9pmQ&79PibjIYzq#H(2QEHz-N5>v5c;%J6D4rN4p8IXMxZ<~_LoOH2 z$jcPQg=UtlZ2{=D2C@gd;g@>DXQ6fb!awzf*IMBxng#w-O^0Wh3lJWFo~`#(b@p)F za%)kqNBFZAb=-n$h0}hBkGe%Az(l5d4W~U$AN&hmJPnwG%J5#fJyohkoJIFxJL<*O zgqfi){5x-Wvmf;iZ}=oDoT!WEbhwjbW#!pc5>rmRX~TYn7t1`<10U5z-tf6z?89-p zxP?8yT!Nn1hyQ9X{A|$l;km#YK8Nt2E(M`3O+sCYz+MmPQfgd!RK7H+QR%2u`Efx% z^0K#IeDTonui`%$8~KoU&UO3~*YS^Bx^mG7RRbKEz!8O2M>?=X<-6nKVx7)(k2{_m z=>wtp<0;5WPxn}knn%0W44s-kW?|miRd?)JIDgsfJy%RyuwcO!@m$@)VG{;B2T%4a ztRFV1s&w+O@nw~?Y9D&yYuw#_kn{>F)3UNWxUvOw`UGTUrKdTaak267Kwi|x>Ey$v zc8?j`HFfD}@r6;##*JEBP_TH^g!(8?-1^7`yE{5}FFdW)oWE@9w)(nlQ&ucDS8sxh zBz9RYiI5Dnkik-JDxB(~3ZJRVW$1}v#ElMjnu|}6^m{_n?mS6fg;O2*|A>3<__&Je z4|M15YFD;p_0?LERaPr$C2iGK_1=4z)h)|%$K5y-(?ST~gpO%}5IQ)7A0)PS5IHu)^_pq%+!GkFFJEdIx_IxUAu1TcYo*5p_|Yyug}+GHpg0B zDL4~D?V>+|>vdZ%HK6noP|%gySOFYX(9D^Q5IX#4n%yE_#@5TQ=<|7nvqLYC4VN5jg#qiz*a9XpX{6=_+d3mgw6?{YlhlGKMGw4oMYZLe_ z+zYK)(b{I*Dj$Y_8iH?G(EFYcy!|Bb4i!#Vae7kwSj)?4winZ)bN;P}Uat+cK8!bx zhq0Lm!G}Vvm*K4gG+P4uN!ZFuwLeJZ!8MNgkMNeq*C4NODu=HHzu75a6&S2llFCC? zPaqI9XG+Y~y|)^#aqvynRBuQPB)6;$_MKW&x38;WJRr$4THk2g#2wp=qZf6q?a?*N z1jg^!x%ddT=JvJ8Z`qm zc$*{E=;B2h2tp7fSgfX4iP<`LS6A=ps-CWi_a}{aRE~Q+6V;_d9_i_ioGd1$WH5fs z?fZ7#IpN>Xq8py-y?lD+vab4lolg1t&*>^B1DFL`TV~ENqcGCW-YB;(RxRyH1$eyS-^(~j=9MNHua!D&VQnTA2fh$wEE3P z=Iq;FTf4una;iSom%O&IZmrk5wyu20{nL-n+sTZI&??!`5Y<1^efi9~%epJ}4LHp= z-Tq)9Hg<+Lf@tve3zFd^dlRf?13oPYCUY>M)sxIFPWE_`h zl)nSo;XIa9$vLl@k9n=;3qW~$oT3D!)kk{;!-+E}sfwha2?Xl2TMR$`IJ@WZ$KBEf zw|nk-H&Kn3fXAzS9Na{ zaN1i%>+0Sr;53H&%Os05NaSh!y>OE z{CQp8W)JVHZ~jx>SL=MWGG2m%^FN7P_psRoR~fg0nXp064H{`D;BtXuPWm5q%=xo; zYl^%8l?bla^-NOVThRxkNC-L>M^I=B5m19}+x$LwS{Owl@Sa=F(X(3OW$3s`{9DV6 z4=wj}JdpB0hiCaiJ<`zJC@{KB8pl}gn7apcL9g(B=4E^>uwX)G3+r0$X^|gTj`E|D zbQe{S=GFiUUW1QhM2rNMKXI9W6SoR@AFLA&$5_N}Kyc7YOJfnGi91B;K`IT2&?)u~ z%$5oK!8IVh@-2A@VeDA$OupUIR3#q?p?Sz{Eog5mZ9cRRUoQVY#P0qY);rE!M#@=I zx=8Lr*9s{`%-*xRvT}Ei_^cguyGLvJXZ*+=d-mNiGIGbhJ$H;0cV4!B_OdRa6VYf$ zdfGTmrWBe8i$TDNCIYUmU;(H7T)K-Ovgg`#EpzWVKWsT(m~}xoAz*!3CUj zZ~<3!Z~-UzA>gX~;JtI1PH)uP0M7CdW#PdC>&b zLA5U)8y+1Uw$~IHjYTzfdoVpcXt&YlVYcG+7hd>7{9}*&=rP~Aiv|ZToc0yBT)JiJ zWi2h2ZQXilvpJ-WVxhx7FC;|_olWPKhtQxc3DBm6(XsLQmThHa+gh%@Vq#`y;tJ`A zXQHllyjXsn4fk|)bBmfZIg+YLv_|)btQ7pBDC8tX_Wo#U^X91^{iu6aMa8b}UwLkE z545%PVm(z?Pk3&@DlLOlcFbSI*Cw4>hBnuz^(I-v)(XeX>Zy_ztkA0tdJo;*EuAGVC)(J;oLu1><60Kjd_m*{yo4cE)Hrr~p##e7`&;^IR z=4wZNd9f+k)f@~Ad8%g1bj987?y3TBeo|^hm%nttMWf>Q@KI5wI<1U+j=g}>s06$Z zWobqFLpC45Ve@?+ZY5m0H&nV>u@FV+vExL8V$78vb7iKP!b&k4qXG(#aqC#Ilk=d> z8xxAh##o*_xAuajs_m^8Tu?e(eD+yh#@=hLmZGPo%0FQ4(YmUsfV;5O-P7a#w7z}r z3+kf)eYnvF{7BP^md29)E=B)cIs^@E3-D7)V1(!MksuFPg>qR<(6Ha70OLk`q0(a9 z1(@SVsF>qc%rWVr34D&Z4n%gjq72!$?e~lg4UKvB#~(bvUX(q!Nn{g!A2^8qd0jpq z7b){0_8b8x3JW;t6*M1<;o!yyIPC_ayt*5-VlM)23S5=Li+fdf0|6)bD_Y^Z0VGZ) zMp&k8#t1QoB=1Pi3y(2LvE8j+;rp21da7q^U=TxY)mc}kFN<T*Gg`7im{@IF%NU4df^M~Md)m+qXuODRpb(ilW5!}Gt1fRi*A z@Ijod2vd~cG-fMwx`lg&pb706f+p&o(F%<@lCqOQ!b+4TUz8|40N9DWsCx;+dn+|$ z2XnrK+=RxbWh?*cdn5J@-(R^BbO%H%_**SU_KqmW%UbYSw)3aFBhi}zo1#a&Q*9d) zH=^30G&Xm;bQtR1@Z8XgTd@Lm6zosxOKq@1e``7;OYnjoJJtv>IhumCG@Gl)%CEr zb#*-mIPsEbMb(Z3oOnjSRjWv)!!ppp&&*`!nvJFGE_n<4mAr=iPq8%XE}pxA+l#y` zAJO3jBNA{Lk$|iIEdi$y2{^Y45&w!ue5D0cjM8Xot1f7_bziQ(IzT};+= ztV@u8!CbV1cC~g6P#?TCKEel-5eg5LfYS&CT(!yiffIUH;MhSwBF?ueJ18Viq~5>C z397gjn5S=lUETh^3gK30s3*5V{qUBjKR#~>m%;^SlScuV@;>-3@G!@;pL~@|z z0ay1qDi2J#CsedTGbCE+2$hBo!2KoyKBT}4=eI71EbLVJ781P>bK%mSg4P(|R^5*I zAH%jX=yo8-l5pU)Iko7yUWeL%k|t~6wS?Dohcc3B^cCr2+_{90k@_NU9QF0A(pMMn zYZ|z$;H}bg!a?zLQmY&Xf&&NcBP1NmymU~MR=iNdXoYb&`4UbX(Zc7{i=f0XdWrOW zs3YCFz{(QTQ`f(+Q)ooOQdmqq2ijc9P7!SjDNeOXil2ySD{4o|eo+f=*j}(t?d1EE zkeSpL$4XBoo1}d!t4-#DP8lQjgTjl(xkkFaX_B?zC zCzB-@M0|ogs647*g!bw68WF-E-QBBLGJXS(GrZ^RvJH1U*V0_qy}}o}b$D!jiZM&K zYFWB9dBuugpS!Hz@#Bh`+89f%t*R~7I^JJfsp}p+W$RlOlY=p%*OgOYaX7j>&3Q2% zTd|)wRbWKE$D~$8u|A@m;ZQrX z?Wz5SZY z8!?OThDs0oJ>15tA?V=ybca3ah}9>`HtK`W^2txhoi`DC|=Q2+0c|6)lnWS z9r1c>N8V&XkGCx5TNhpU?ehI&$vd|MDs;Ua<~mnd&x*wH4fW-^!GRq+t{9$i%nn-O zmIaEu9%h)>LMFFnBk<41?wG=@TwrMp8erNdEQ~-<&`$}@v1UiRf2Cem;Esy%w%Hrk z2LkJxdnYD({M|NNcT8Sw!Loz!P#;;ISC!Xz=D^^Y4X!h{Y&o-RV`Jk+uJ!e!$2d?d zW68ab7A%GT5K<^_-xvsNY%dusiSfqwR`#!3*Izjl>y0TLj%hustLs3Uv&XNiZ9Ze` z)-yVqbiu)5nkCUXtw2lzzNpqX!XWKxeIjqm_VD|N0jsTOiaWyoH;mfW+N!K zoR!U$9b8sWo97agBN|eS)(q-3qsL?snNa;Su(7#=lwvW9dc>uopRC`$eSJ|wG4Ik4 z>uD-blXEc;DyNL(?Y|_(%3Yberujd3(uuBn2FajhnyzD2SEJJnF++0i(`)1=E@7U ztz3I+ zQ&rHs#yz;d+O=!z__poqXRuXBnr2{<0M0TYZ(xC56}b|bS*)_+_RRtR#Q03L@y)MtODA; zB(y_;5+<}RL|I@G8cTTGov(#@lStl*vctMCl;o{|4k_*SFKBmiUFs6;4l-nyEu70* z)qO|I>6pNucj~FXWYS-$v4qlPD`0>2fDh>YNg5Cw7bfu zYg{A$iY602eXtF5J=l10WE((kBWZ#>Hd>fD?^c)JbJ&vqJbOs$D66p*^I(dzp3CBgOeHIvD%K!_?6lT2GwB#-?!q+z%} z6Rt2WnaSo0ZD$BU3F(ut%dOXYyPV~nR(EY%2U2k4XIXJwwtuWtKFlinr`Pq#&wvtE z(GJF(Af(>>*I5kUs|Zfkl>+abpWs%-1lYqu>x{vVjTz}MqabegBxoXKhVB7Rm=JQM z^c>5?8>V-Wwfwl-#*Uh7a;gn2i?Z0kloq;LXhsB-W?Hn{YYs!hy|ph11rF2#G`BEj z`5ZIVSFpIx&d^kM|(TQMs@y0 zx*(&L$OC+k_X{}IDN#-I(93&3v`Mn^RnK{YdC3`IFm7?P%9?jRDSQ2NFvCh#{SNH@ z_|p&3m@)T~mCv@Y75nCg_-t>1b&rYS|$LCUImKcs)!YRgtC|Vm9Ywj6m zl-J0gu_Fmmb*|M`Q$BYj0dfWXw4!<(c^4S?j*lao_ru}59|7+nyNb%Ae{Qq!^03*X zL#>2L>vhw~?}4t|Cc`>KP6t+mNf<~bi?EZ_bpg?*tPuR>vP;n`%8Pvg{fZTJzREIp zK~cIR-Cbx)x2H*o2c?$IzTTexk@fO-*?4b*wZ$dh%LY1o3Yzf?&EzybKIi+f=ut^( ziRfY4AsGyT9!Et_fxEul+g>CkUZVBX=LE(|XchLYbMyw}XJ|~KEt+9ST<}VlH4LTs z70^CTmFf9v>Nf<1M?2I4@t`O>h<1><1EYD!~yLv)rCMr@OU6{^w%U z4>=rpBF?+y7|@rhpTtqVMx@_IiT8#SBW7>fSW>dFNqi1WP7dIQltXCK;?qT1Fa6NS zM7u;AZv<@wlxQQMy__~2N>-f$-Iys_pdDW=3*CdavviG3si8h`2bkK<=7ca5x`)y_ zR$2%@l2QVbb_J?v8447AhRTM=()nr_N|H#lFw7(MVuV*JPy^z&I7fRz8x&Ec^R?ia zEb)~3RcnQxF4cedY3B>!mQ_5$Pls`UNSz1bFv2qYbnuH4pXTejem74z%`VWFl<^HI zeyq8>yV+HnlT+);wwp}$>@26r8=~~U4zXBdV3Ev+nNeuR$R)XG3hQS*$?H`83u97xdL zTiRV%*j?&JkKKo~<<*<2tF~05*LuG9h#Ao| zB8@8+seKXou0gLh+$q!`iMf|W`+HnXGam1{=B5ov%O%^}z8-Yv>zNoo#9T22O*S#e z!7lxca)KBi7Xl*tGY%RC^b%+o22wEuRk=b7VQ%#rrw^M7C@};b`yEOCVQ?eq z$2eI;CNvgEGg@!?gtdUu zSOm0>`?`k_9|hJS=mg(47MJZ+%2qEZi`*fMyPs3;W#k-V#}hUHWV^x7ZQoGZbVLF* z*&6+x@v6~nQ=N5deW!2l>|8VH8Le`;T8DcYng`iub?X8%eJx{AaXN2*U~;{qqpi5f z>gcGgY@O(m=|mX3rtB-}5@EwkhvJxCBO`6&l*(GSHTQD4ialLZE+x(VJ-@&vtQhTr znn{RPEvn14m5BQCKGw3Jji(C=p!M_qwb}TM=bqQX-vFauiKZ%8>@xd{3&WU5HE+K? z_tb*6XjV$+T{IHVA65wFYNA#^s~14WnuyZV@VvYaen&1eNjfa*Q0x|N7vvhmc2ByS z>)VFiq{K|eY@K9-@?%3o)BUV+?jPThnt48iXoa`~8Y*!|w%}=Mo47+jg;gcWhM~fK z5>VRPMA>$3DUntI>_X5{1xoFPd+U5L+#BsFqJ?4P7f8ra_!Qdul^7p1%f-=YKElNT z>UX-Q+|3Pqz=q}18+5kJ)k=_hiNQ-OB1}zKi2ew-SwV?U1(a+C+A9Tglw-WOZ09ZE z7HAxz?C`I{P}&m(bTDGf1f?-!FMN&fg_E=w3W}0_A?N`slS9iO!&QosWr2HaL#5%X zRZ0)=y;iiLwvRm$mMX(ZV2zXrp3#HXMQE1A{@nM#1HPY2iE;*eXYLnu0VgePi*!A)T_J_HDu&>JpfQhI_6x<~!$Td^}C9V-r$eU1E z;u--J8qwkwp!)zHq5CJQacC3vjUP{`XNXc^`Uxq`+m%Qvi57%Zs+=!lb6jH}C`qMna!wJYNh*obLMjO;@w9-F zRFX7xTvu8xBz>e+XcC7jmPiH8R`k*)U!2<>6=Rw$SR&S|-R$9KXp?-5YjZpMRBfMU42)6+RlxAH(`*~IzhjI=P&>01)`c()D4nar& z9I|AM^$}3w5Yf(tFqCtMfc7m3?F~b@Wk-g~`%2HpPcVPmxCjlP!G55Thp! z5v{i?eNyYILr{!!3Ho*ZDhwqK5iJZUEtE+Gp%#ck9^@P%`Xdezr3b=el!!wFbntWH z5bT54g%vOLLM;vY)xpvdcc$f9x$IBDptNT0E=#sey1^FmByw-k4>)(Kr#F=L(2Icg zXzt)RO+c4NKMGuAd(!=sqIX5V>$e6?3-vp%@wucwCaqS`D1V1l7Hlez#};E#iI{(6 zUBGKzL97XD_Dv4qD{YEZlaeLP0`$d1OaZLU58REcF!Yt*xdaG*8xEd254S@DqX;L=CX zo1nsDx`TT^7edkJZ~3@3P&*5sp-)i*Bd`!9csrP1k5R=k&w8Z8k;I4=m_3iTzP?f5aT*AT9Eq6bA_B6KgRiBS+O!SLv@l4>Gj zXMtY4lhen~#}(ExcXAz3XhYNv=}2fN0t2BP(6jyG*-v;Mt<;Bj7S=v?D%ywG`KgZB z<9G0#9~`t`uiLp`uUqO_#O}i>O0s<6C`Fs$eFbd-uR4rU_@ZfT=j(PZkeN$8i;!Zx zCsA+5{d}wy;WoqaiQ1Gj7qPFg$B>_$OFrLzSVt#Ii^<^*30O-JSqPtdDoR`jifs8# zgEP9Nw5D!lOtiFIpO6-3Sk_eJuGyKLpUb}HE-Z9KuL` z+)|r0MW30Po&ij*047I(2|qJa%P}EGIF8Gy-W`hn*qi(No9V|}Qi68`mawl)b@!~D z>g}G&Z>y?lYpbbh1N{myG#8|k9(o{+vIifOX&J|T9(0&7xWvKR9(J94p6Q{EQ_9Lt z>3Ha&@tK+Nhj8$~B!BD~udSK%xcfUhDKDJ78#w!n)>F|s!<{)I`x8P!oTgpR9+UT@ zCeOR(Pq}1$1Ene6P>b9$rIvP@vOiJFfXqd6!g_YI$wO655BzeDLJ!9PG} zrSqeh>Ffge8R~^KIUMqjd0!}<3~jyvrDN%21R^3h4w}THN2nO2)7^S~azk21l!pbI zJ+iljRlVlcXB|3{(pWBO=C&L;bm)k*LAI7bnDJ3kZkwL0O7(A^&4|A-j)f;*_dvP{ zI6qe_@RhoO3%%1Xj;tt3iUl$WW(%drO2*e-EIayJ`B{%Fm;Lr=*^6w3&B!;(H`1XB z@jKDB8o6|-C!8N3?>A4K7jE##Gq8#S1t-EAHdROYbj7`a-u1oh2M@ONPYe%CF83`f zpKWg0R9eteS=m!qvN7I0T&8o>=k|0ocexvz>jvBFbisbNZ>+@ET4YF!c60_xC;Y$! zo~(#7W(ihCXVXlq)3cm)wRo1x4*>Bs5)P!5;aZZpQBaK6;E|v|af-F!lQI_toFX>@ zF0zQ>=|m57>Xs;`nPxcU$B16+b@@)#T_OK~jmveONHU=2YTxJ?Hs_upIQ7DfFvao;;5m+)Ovl_ZOgR8{59}NT&ZK|wZPApyG_~B z)ZE1d1y906O?0HCv=AjL3k~;S}crl@Q_Oxx0Q3oAekfwu4?j zQJwy;@Z667m7aO;z|{#{k;8+s#6wC+Dh+&bM}*UJ1O88HSh+c}9>*F{P1FUqV4O-% zG)@w6tsb_{^QAL1$iujSCnTNK^IS`bX3t`=EPvo#+tj$u>mD2&KzL3YIw3&!Du7xX zW4?ccucBbUBF}T`o^#LLbLy!R>())2%D#`|Eb<4tHf`F?8OGlc?_@3jm#XA26z zlW!p=;mC`SiE>s20yNKK{NMxQvmRUZgx5P!ZL_uX=Qp}-Ajmz5ZX6cJFGua8jrHR$ zcSm1I1CNL)=ecxx%>mBQ#aK&LtR-ZdiTw%sJ}D8D)wn1{o$l!vVy9f%y|<=jZ};SB zDgMN@P2#4G7Tna4d=~3+yX9M!zODm=)GX+$7J+{BrGyLAfQ*vM%SS3n@|EnA->*H( z=uen!5gj$HP4t_-Hpx*yck=G@cj?w`5C>t`j_6J~ZS0OcyYCn!X+)Bhvaa=X@>~6z z-wJ@gvH!sj$ECS)5*+0q*MTK{&>n1}y_>l3gvhXgrwrZdNs24)c9smdJnglX`qIQ# z+1t|h<)6gI+sw9sYWZE3(%zVspCsQ)N4O||G##|AVFy#J}E z1}7$1o^ztHZn7AS`wa3?Hppswdb{ZifjB{WM95!^!s-_ZKs?{ZfZao-8rB96P3%w3 zi;F9aZy6ov@b{Z=w?T}z#Xhrpuk2FYP~m8J}mu6ekdWKDD#}%UppY( z0m6w@Nb>&QNCvu+;VRD++s{6G`xRHLnVw#A#pCR5={5OxA<6Iq7ZhY2W#vd6pa{vB z48F>e!0W{9$O}Q163Ah|>2=HnrG;s&N#>aKae>0`6a`~ve1j`;&EGa#Rt*2I$M=!6 z>f^Lm-3lc%=DWjs4k@gcJ@QP&8=&aWwF3{G-b=3sOx9 zri^9Jg5SoaadUoxJ2hhtcfetN(R*Y`^PxN#=&p@?PqZ%98S|4GJ6?OS6l&vh9(k_x zw?|7~q8;Z@q*A2nj)*p)RnnbBt7@BJz3Kz!+VzpD)d@|3Q*CI&!P^L7MJp}xz76)3 zYkE$OxO9WJ!=>oLn>WBf{3L^(pLCEy=fAYXRixW%)l*T3m^F;aBuA(GA!YlqHeLt=6&t1?W<6kWVh>e;XVda^~);dIKkopc!hPoZ-X z9zy5jcmPf|WB3FSopM=t!ieuS|K@BE{K24QCdPY|?=r3Un>4H((G7P7CQ z2VmC|suC^(TcF+W96hq#X5Z-_>ZofU8S4MVFUl(#s-JkEyt1_FK`F7VCfE?&UfIxU zbNk$7cE8i+gKRkuCdQ{C8VBowtoQS9<;nLEptG4|iFRjE0)A>K3-m#B)I%hHqE#7)It#!|$v>k(Ujeh1@0Vg`ExQ1Vfu_ZEk0? zWcRbb`l?il$eh@Ylv*`I*+Uv)Cs zb)p7*krMnom}}V8At-TxfKo)1`@?zbi*4%1a9Gn`Ex*Jn<#ldOhcD#Mh&BsavI|~H zB0O8?k7LCVkBeMWb>0yuZB0yDrL(bq_ZL>Z&7-rW6|lR3?z{4V5;tnvfCzC6YGP$5 zx7wHxCk`gqDqc){vBDkwl;^2vjB`2r4E0`-pw_%B$xh5ED5Ez+cU+OhgOlU)bi;?2uc2o>zTWrp$UJ%Sxwbf||<78*t?N zyp3**VHxm18_)uY&vEzbbL_{@QGy6>LG`8T$W$<2c9C`&(a8hdx{AN%&%ZdN$+ zUDUw-HSJeZvVdoV?fppd%3znr+gaqUtt@JEuXKME7oVG!)l&7+3r+r%>{zD5l)#Gx z1X9t`BD=fVeLMaay^5ZJrz&4gu)7UhC$jN6(HWhQ)i}baJ3W7pb4!RwRs zl&&zqg9jtlhH7^kA+@>g7&dVy+_yK3*zF??x8FWkTRV8Wl=xbHb8&HVzO6D4z#AwaG%B!EwNABa-#c!h7p@>q~U@KsI?1-{}#0fodmQBf>zvD+YfSwu!6fSiSCu4 z4aEGw4Fa)Wno@6|#%=&3D!4J(BsVDdC1TY!p|zD_XX07n@FKwg$GSx>AGN+kdsf)Ms=cim+=4;*xwuOp;flxGHz299y$#(85( zMk;vcpCZVparb(3Rc+KqW2w&F;Sfh{6xod(7&{w}R`HV%7(3+r0;o8#K=G+XCjn5_ z3PCpy{ZKt)F-2!DU|HBeF9+yF7PCpQnM3r##Ot{>Pm>cFW!O{JZ@2fCmD8tuz`gqN zTk9Gd>*^XB79>{3&}4mVx4%R~;<=St@^P|H6h7c`0~Rx7(PCKyO@^S?uh`4a+&nTf zGxD;$?Mg>a~t3qU)t7lTUjjZg* zbl`!T$)_5TbBhxyZnrLGLoLp$l=$VVS;8Tk#l!l=ITdKwztlI>)7h2F8%m_Vt9g0Q zF2DVsEaV&A7ZclFeDOeC-N40HF{|`#`JRE6<^fx|KT!52op*`~tpuWXy0Gx~kg1Br zKvxqfHnM@+$X2fjzC=SFcq@H0&X@lkt3P%m&>CaOIXB0$vh`WF?X8O6)cvOX_b>+D z`F$?_`~A~dX=zzcv(>Pab??xB)T>t2pwOq4-p8`v8a9sMcCrYQIZ54Y!kWJbAYjFe zX_E?5@@vb>%qCa7Hqn)mRaRMNO)32866y7y+n29QT$LCTV_UH@(UcS`f3kGGJtV)d z`FMU6<&i~N#Z2>S-!t*|MJH#aB*n%jMn43J9?hOePFrbRw?J}WOnhNE&ZdB>JjZO`yzEDV5aV- zq*twsutK@0BTE*J=|5WfWgjUiX>Q(xD%%uI1^itNZGiSgzF!IYc(vpwX34Mmej5K% z->YugvrO#0xqlpbi0nDFhL(E@5Zizz!s#mN?P8}M4b`;SBI}a({UF!#7g^0}mK^)U zIxHt5RqnQ1N*{l=!ZmVJ#wxpGg*8ij(LDe=Hi^V~h=U3iY7EhBLK-4om0= zkYKwM35M$w%+01iiN=l`2ijzZ=_Uq)t%qD7Pa8eUywMZ>;Q_X5Sw z%XNmxDD61A=4VSR8@>rFy;9J#&=)BuM(B$}JWE21TFt0H>^vgdLhKI|rN_#EhWVd! zC6>r-z#4_Upxmvl-mG4TTh+ZUzf68n>DswXD1!);)B+zO$|9Z?PE4mQ!~i#!ze)>H zLZ=jAW@kz!W8!VDc6-aY{R8K;*xOyV{XMtdms!3Urwl4Gef7ES(D3pPmJ96N_#K&F#1W7Cc*Isa~3{WX+@ze zE#cC*+#HWPP_gP#eXJ?bB3oWFKbKZs%pP+WI4auYx`e`Xm>>mm3T870?fS9m$kztb zCNfJ%s48ZolwMWiF0ibYN8R6%lIly?`SNMGg*GP368p2m0aitp;D=HUDB|NbBi~bl z#xP+PIRAr2Yd#T%dwIo)eP7Ft8uxIyucN4RrnaE3*_30Rb$dE&t}bUVSUT+W4wvS( z)vq>Zb`Hk}rmEneSZ0iNHrs;zu5?#cp~G*fEVNge;lyACEiO+-QL5WqXz`|Fe5JrF z3z#WBpZ(0lOeacyHDtiiI)~XD@~daJZm+1=-r9CbMa3y?WrJ?_U|H#q+dVWX#YN41 zw0Yvr?K|$AoV;_#_B$tvdoQ0_dwE~qaEns$?y2&QTx&;gXj7~| z_JUIy*ZYgsG__6_YuVw3U9-iR{wkZJxoC1!x8)4~M0ud6-jdtXTL4WC{U{W1M%wp+ z01BqU^UiloZUZPLzMeTwImh5JZ0h$9c|F4=)zzMM+nUzqk>&oCdnaqw21ffvw^VPB z-#Vmg>dJ2m6gTI&vny;hZFQx(j&WyyY5nku_?XRIwNqe|u*Y*b#XqP0X53^;XIqVU z?E(8PuDjaqTeAkaxn+0R*e9?S?)pExd4qD4Kjd+7%dI=H}y&GH7q+-!^WL4;%{31A;~8kY{kvnD>JKRno4>CalY8L z+KS1bv@)iA*xxi8EZfxBJ3Z0o8}d2_;v2rTbBkokyZXm`PzoOeeeHv)a_`v;jRa# zNM5V+odJ0T@0J(=)?FwxM+;%eZZdp6H1>~)m z2tbpG1$VVUTC9L9T#Z|RHM0>k2<8AM5D984Y4ILPXXK)en1P7L9F4s_O-(($jn=el zjinbaUs39{rq!)Er>W_jHT?71ZIe@5woFZK%Uk_vQEk_7MY2D;YbZ_Djo!I?_Z?$n zckIU9>!6B?$1;-kvk0s~ska!ti*;6JE6j2@5~f*17dNx77Zhg6l_zO2i@12d*>9oT zE`D~6){Ti94>Se?B+Oy~c{kJkZ=oy4W3MZ(XfhN=y9)Ci%QG{r_Tr?kNA{eb=}SvY z^A-8Z>&vxHM_z7HdRlzSDtE;j3y_HzUjg~Z1&twY1h<8ep&<*49DYax7TjWbJ8Rks zo#y9yH>mTaxFWZ^)=<4nc*ZU~q#CL9M-P*r16g4}jh5Hl>E z30Gx%TS;DErBv>=SeIE>mU%r5=8jsMKi!d5(9L&ttxm{(mHDe=r z0sCrus;|fyeBoqtiotrsERe|agDp;EQn*&lrFmbzlviqAFbOpW_IS+sp1o(hzo6;x zS|?9{i@b7+_z)5h)*?Q|$iU(X}zDg{OdYgc^SDC)zuZb8Tro6T4x~Wa0CO+ zTt~jmmhZ@mq(ZQy$RD%Zn(OlWUAflfG5(^GV1BOE>T+4Fxj)M?XPNOYOWwKw>!s!^ zETrL53X*-x7fU48C!(5->1nTBJZa^asv^vp-4ux_pQ5(v$|g&OGHHits`#AA_eSCe z&3z@-O*@?;(VY)H+#vlATroJFbprf;jdp%w3HSpztGpEaLD)e{!5=ya{D&ujKMZ`9 zYUh!Y!0}cK+|WVRHy;2WwbQbwivB}SE(Sq_;0CzUe(+~{;o4R`OD~M+qtfDhlcLM+^=1A5rW7 zP1OH7uYYL%Dy&1RvJOW;(Z}b%gLRmttV4p_3CMlC46Q?gT#s7!a|o?Nf?S9)ck^1b z4heDx$~?#+v&Ayt03t8;j$t79Y~n=OMw4EHjLHBFh@ zwZi&`@y{}*7gC&yLD{+HDkRk&BB#HXbv<>on@TBzPoz)20ygji#${Gfblj8WN& zNYUoDzM^r5yW@Kqv$obkeYW|Fp-QKxXS=Mn!UA29zAji*m2Y+ywz2ZcVy&g1C@;s6 zn`K>Xv1IBT?@wIr%_*yEXXPbM zZMMZ{vw1Vq|B(N!Prli+_W)E-tty|vm86x@oIzuN79P?% zVFzKS3Smk5X@rf+?6sZFd{qa{b9K}$(m`4If2VlT9RB~ZH^{jxi@k}vLipZ5Ia=i6 zC}IyBZ-Ge4a^gSG>8t-ksh5>fh@`9KyeJ#Pp8YbKJu{5=oATnQAboX-#)UV6S0c`V zRk@$%{ZXXpC^N`k)_@6*eR+m{#>JxPSNY3-!X+oo{pCN^Iet{PVnH44f_~y6dy3+H zM)nu=i2TP;_bz6@@1pAuRWvvb?l9i7A-vfe@-e2bk-ubyuYRS?aWg%&Bp)NhL7hu9 z;ab2VT+hT?h^T#RukOfiLv2O2^G~%+4U^B;$asJ7cPM5$>o)`DbJZ*saRJ%9D;y!2J+$+<v7utbZ-yoRlNeOa>;TQ$F4y3C94qA3;NY#UhyBV8j{#;{!sG!?V1dg*lDEU}H{U zr}yKxn;UC9-s;BYx36)J+B$thzS=BzX1#aF*J*RsRE#!G7?^&tX|%l7sU1Il8+4-M zf55(aL^Fv!^p&sZ6b`=U?!vc0dS!kqzIDgfKsMR&u3wRPLlng_HsgnQC<;Hv*PO|W zXR-{u6>o^$^SjgW-?dKXOPp8bQPjRMRGZgT>&c^`O20q-^lNyP`FYSg$fl(%!f5WB z$n)jj?57%ocH{h7(&KQsnT9<{c9!(|j|kJ>%t3@|wBT6{_{FMVs+mFZ7xhIy7e8D4 zoLr;xef2AyPqNOvex1|#A`sMpTO#XVbkh;FsHQxMs&hL1w~p-P^7uP64a*`d1eLKYY*Khi|>~`pZOlL?76NTzh$I?r#44 zt@(BMHb`%M^&!5svUUCnjOXv_cxIKc5F5)kY7ZQLS?fN&k`=k-*KX&-k+ZZT$H}nJ zK+VJZ$0t#fqBO?2$;nBA&QEfv9+_KQ7#rrD?n&P*w@eNm21cS5lFRaOGcD%;v~n@Q zsUF~%34&k*N4Y)?p)I8f-kGmQ4b+<#^`5|AI7mlWz+Wrw^g~DlsBkwMzS(L?r}b!c ztY-4XqLGopPd+Ie87X=Zc%mNlC*_KIwO(8Vgt!t`0UhFCtIdp8%$S^HL!p{qP`!S1 z8U@=flOj8_*gAL^f$J1uFExC8Mjly>w>j;OxSp2&5xp;}bbUk3SaE95m72M-W%6is zXLh#DkyFvLc2L{6p#%%4u-u=MkXM|0lw;VB`#YngY$YE-aR`x1(Tf#|4tQo1C7x)3 zIm;4F=Vf_{ToGN)=qGO}?nlc(b6UB#vfZ3zcjlCLh%Tk4(Gj1MFF^YtXsk3RUtkvh zp!pNqkJuos#Ko>Cd$J`N|BN}w$vJ;Y%1JhwlatNHtPlt8nc0@D$b*0FUFyC zq+)=kCWG~H>-Gm9+-}{uQ~73=wC_1?PCtA&{btAa((a+zd=_sGzl!~VbEAgr7Rp#) z)-rGVH}Jz!e~$ksR_{UKeDo8N7kotp&4wtfEG@oS;C#pV@=MFuGt1;N&v%~RNWW2t z@fDlx6@CQc2<0*`&Drx;u-7^NYlgw|XLDBHuJU-q(?$y(zm9d_##7Y9Ddpq152+s< zL_YtJ{=n?XNae?mE;T7Jm40wzi3Zm38umAE{U0^QvHJdq2Cf0+kAVw^0JlnioR`@( z#PwfcRU+bl95F*`o9EFCN3`!f$?vk89aJzywdCojb}7|`YAqsotl`$hbP*ZVrvk^=Izwdn|b(Z5rQCMB&}m6Vi9s|({6tE=cgtgc85PrAxdl+OOmTKi3C7}Wa& zdJkf4qxafHs|=%{VekbXRK4aju9k%ht}(R6)!y0Ks!psshZ#$KKrD5CL3zNOnCr1n z_ZWO)D%K&>XuwgdAM23#gL5Z~Vy`nF$yn0B&Ss-5N<=faLVNfi{A4RTkLhVQWSV5A zmm0!Pk|ih|;>pDPD-7&Ktqr5K33d}Hdkm}fO{tmDJAG-jQ`QifNL-wmcodnZw;j&( zTMDbpnU$HABu`mJW+11i(wtG1ZBF!-zL}J4%E&It$*`s*VB5UeyuHo*01U3NZrq}KV*qgfm4P8z%<{Szn0wzy$a835uS&Yey9BQhwP_w zzj1;SndYhapRy;lrF8QbZesN6r9}3mERR-HRBOL>seDxaUzE8XWv&%vh69O`)thiV z3XICtmqBH8TzdRM$c_6o@3FTz9+1ZSIVLEFh|>Mcb?I4Sm;UWNsrP2N+sy8#J^X9) z*8!`)@tiCLFB4)L^J zD0m@-7IY{6z8_jpzH}679H7J0`km|}_%1&J^~eXnGh>1l@x}hf>6-9;gFd8>NcU3p zuRGZh)FFS?(fPZjP1uW$YKHO6n3jDAo%lnV3#BbKMXH`h(1Hu?tOrH!5?ycDRwYc`0)L!-egzF5KCcUK-dB!6+wJ^1SQ`OhD) zKfphTTb@B{SF<+>gISB!Ph0=hl~-R~d3OhuT2x}cXz&@dclFhxA?o{QY?@t;9Zko* zLt4C|3N7J1XKSSLxbMf6OL#oHQIPy#eHW%tVhK5oD+u(2(uqWyo z9QH!FzOIS!#2}rfZT@yCR+|TjZp5x-1&_gAvnnTo^sw!?Gb5a0PCatx)O)t)AK!PX zqsv=8R=j`b;d{nL)(+frP50>N=(onS=Nvx=D)ss?S8I{JsKY-t>uPVR?Qpf!HFm)E zJurU^7Q;_K6Us)6u=N@GjD^iv*|M3<+@- z*`L*3PfcU<+aLQn3UitQfu@|o4!Iuh33b+%k2X!hQlDrXt*CL5aq6wlbZ6DVNbR&~ z$DuiXxf9cV7G|8XAVo%&pW#MM`~Z8($f6&4i}dkatTy@h6Rx>A_uT7lFY_;Fey{vk zh5Q&#RHI}y8VhW!Kh0mEk)?m=tlEyRUdHaK*BYO}9($Y^Hrx|3a2ECx(T6;_WCyU$ zK7-URhtwB(uBIN`5rrSZ^$p25_jmj}=4Si2Su4NK?QKwZJ9@?%S28^#`o)DG>bMq< z2qTe)U`OA>ZUxV7gn15Icn*tzdpjXNY0%I?R3z29)FRn!W5 zgck4#)my+P+GBHiZOB1$p)i}dt^HrMaQvwD@bN+I;S*XQ%bQxLQg}5fl3NK2?TJ34 zkuUFz8u>}cZRA$mM#nA^*6&^F-^jjR;02<*T+*RK9wcfXSAm+eB*wztq+@%dEMNXE z%5wH2HIE#7wB}La5rSVz>EP~19$~`|Kg_8f=`|p~5T`hUg`-macn$mp!p$IGM?M3r zEqExxzA4C2LMRF@J~_We8I|;qBWB3%nYV$5R&(+7gf4PHPXcM zpUIj>YNVP+Yn0hP;Jn!>pYh1e2DO2n=*utQ_^qe3(`E%fyq z|5;T37c*)X@bA1QnQj(d9I0lCWw~6>h^4#AeQ(I4BYZ(}H{|6NA$iF+13!aC=w&m< z-`6J3#Y!K$TrSpF!5Y2tFPF=|q~qX_@a?FFtZ`*8(ac2FMD6cIR^H=wFKmD|5RL8_ zd0gHOwE+n=y{Hw2mX7}=vZ?c4B^%RC2%cnEDP%(NlPq`E>b%vf(v8V!2&`lv zgknlJrevn&tX7A*T#ccq-wPuv1kEV0L}}RZ49v>6fa|ovr8tAA;&{Th1oMS43$r4! z6OQnxksjsSV4BNSID3Vg0zCN{BJvX%1>(P}h_6mFr`}sltcYF58NVt!15O!b;1nN< zj1{TBr>?eUyre9X69%geI@My<|A69Wa3*CJWUAl=-{zbY_9EzzhB>tmbCw|! zv8)p)uyl-T5e4{RGK*nunkLgweQ%mMEh9yQx>WROSQ&Y%QxF2f+~N0pd?n`=dX(7M zs?~^%rL4{)(wXRY6k0$=OHpQ4YP?g-_Yd^hmQd7e!RStisV#n*RhS7)<2df)VrwCN z>C_bDRUlK#-N!mQ-U11Shi;Mf4q)JF}nYxOCYSW8wJ z^tKvHwrhpfo~SdLGIMo0*JMM@x{|ue$cc<(>^xh;XEnC)Zyl${|PkVcxI-pP(wA{Dmn;nyf_pI6OlZu^oU%_N| z$Jkg0{5o1W4+7@5QU;H(;P+ZS0^(H>WKo6*w;kOAK)a3XsN3CKJ<`%NP*ye(D6Ze# zD*h-P_m^rPKHjE%n2iV5crxvdOly5%aM(+~<<}Rrl_*O=hDsm$UdRIK zljVFL!|urBgNRD+MO504bC4O5+U*1OfoVxE3?QF$%7e?6Cq)u2o{Lm)wG~IMHaB=YA%Pl zFi`{xqVbWeo00)RCFC$P1ZQSpmP+2m-%C$|S}M(wEu=0I-CjZ_S}|p+X-}VflKUA> z{~OjV-M1q3NG;Iy5)p|-T!dbiXhwvBPAcb_wVR8A!6J46eaaiz;nzZcoL&?>lkGtZ z>GRj{dmYJiS=YX`G&_)!ZPyv1am|gdGPll|X-}1wN81g{^{GZnZgpm@B- z(wP12)dguW1y-|jRbI8x*mK)iv$yT59=>VY=$SQ@0USRY8?M^km{pvi-Q2jjbycy~ zzp-QOw>wI=TsgM=@|L8eON;8-v&(8r*0^hXA&u#6)y0V1nvwrd$|W2R=kTzEk_?7p z8gy}<0Y|5W0E0^oL%{7?VmuVMIKFmYPJMA!enNk9!$4!-Gl?d=7{`*@RZ*NhTj?82 z{bfVhsK*sF`JJv{tT}#q)YDOGE-3e9HTHBgmW}!TmDW%gRaSCwOT=VdZx9}d*F zuyZquJy|Y)z`4o+(?{IY@;Y`AXlK(2Y6updWBkt?xF6RyRey)OgL&MeH8rDdX#UsD zZ6t13GyjyN!6-{LEtpGGR8rDtmc%=LM6wf~MkP0k#%SVk6_Na;#|w~9oP;+$N@2l< zses-W-Pc>tVC!5P9B@=tS5&%(OUnkmdhfE1ru^#M=Dw~qxpn@EvXZgYrLDz-!JN_@ zOK%OXA&9DMPjQ$nF-1v&=Sd zc60OD12`z!a?U_2ojq)A*;Y}pt%JYo*Jp3EN7wN4 z9j9!&bF#4is_AuC_V-`8Zu+YJJ%P1VRa1eydRJ7iY_hFws=6$yb%>(V8Jb6>a$roi zU=`Clzh+4w2?>hm)q*zxa{{X#TVgzmG32CWj*^-7qN4VhlG15HU ztE;zK*9R+e%xy&t9nCpbYfkf{wzhR8B{NiRU1`a>Hd{{Zwi^7a*;b2x{mffz%d|&- zAy z1IWQ&m%uhkfe5?Ug#B4Ldrlr&Eu8g9_@@8ah36FQOWt32j{LCo?EgzI_+tU1+t(gq z9rE|sq`C?`&QV}6WB`WvMd7E=VrTaJ8Y^DpLI0|)*3wdI zepwlNGOyZNSd*JuQ)sQubNCBMvKG><#WMRH&qbkVEwm=t<|$^sd+$9&175(BgNU5u zYun%=)@$3`SmP`i-rWZ7-PJVGIFgZCKq*sF#1lm7YVk}fp2O-t$Zu{o&?l|;U1;cC zu5F^^G?j#&f|ic5Q+b*L9U#_fcB9mvl~Rb1Q8}`1nJD`T+arIQ?Vf`zcjP9+=OdPc?q}vIrJMw8H-W2 zg(w4Qy9W^f9Fa9s(9?jQSgB&}pX@Jk+BdVm$i}}fh)gzVr(BVBEc+P#5v_2mBKBYr zb>cTP?4j$osTr+FY3ITF?t@`|78{oDzWza_DGEl3#(s7%qBYvl2O?XWCVaF>S~RJ& z#%?Fe{=Vn0zh1tZRRJsf!}ZPb4f69W$V%rf`~DZ%$FlKH1N|UQvy$JEs^=%B6LDj0 znv%c781j_hR`nW%vxB~IJqu=;erS=gJo#@d<%17wHXAFKpS$ZWTbFB(>xp|;GBe9+ z{Fndp*o!VY&+Wd&?Jm6NqG(nnKmClvaP8VFl;Mf<>djIvW{W&gnto;dnkZ)*0qpB$ z3&x^G3ud3+@X-c@a@*vWnOlDN&n!PTSAGK|&~(jzBrOLAYU#}OF#O*CMz+UFpH@~z6QTWj+-^^Yz5e!b0h)-Lq-QH@mx7h71_n3EOv&uc@J-f`iv*mMlnRneADu%)+ z5&k!9#^c@QJ**OycI`Cp0ww&2^I5dnpo9-QAgx}$vuDCU6T9EPe|?|P5!1Y<60Kjd_m*{yo4cE)Hrr~pj%m+0{*CtPtqnSuFXn2yHQ+EM zxtjce!Q#sGxJjtn*->rv<|d^ETLZXGm+#LX;sgfGl7XZI@>C2__|b-G7oldT8*u{L zZ@?M!8H1zilwirWuBor#hT^H_$^pB5pt5y6zoGa#hvRx&xc|{fE-5Cx^;77ruYsFk zp`hQ8A`o$69N}2(K9F{6t%~bb`%pg;5p~;-S~b)n;$vym?)qSLwJy*te7aPqueIX*vfp#> z{mo1=(DG;9k4YxU?|08V_ndRjJ?B2oz4x}2t5&VN?Uq2`7V%>Jiq4MZ5%X#Kh=tyZXh8{axne^v{8&=w<9P(15W==q<*+aiUZLKP*hV=_cqO z_Ii|$NyQNlktw~xcI`m6E3#-(=aPuMIb-(pnvU|Eyyu=yYw4kcqMaKb*wdoEb-^RTk0WfMDBb^$$M&ZZ|mR%grpI28I8 zmPh}aMx8D@)rg33B3HH^8RLbzbWSB4!W5v4VDN4HSH?WfBb!JgE>}X$qo>HmQC-4V zW2~eP=!n9==Wp1W$+3o3k2ilfemwqcq1C1459Kd-0~=1xZWEdGp^Pm=Ex^X1aVX71 zn~nX?ni-2_Q-SrKcr+zQ-7BsS<+WIQNHM`0^YfMJme*w#zbD1DJJ7ANf# zNoqM(hvPK?$8F%{djj{|tKi295jAOJ8;;{I$lpYMW?3ui$?y<~ASD6EmeZ zmjuS;7kY9_Dyhykt)VpJDe@JJmk;K+@$SO(Ns*ofCB-<|q^@T^ur4(^$)r~Fvw2+^ zlp%iQW_qCW)!M%x^?K|@cncN=eXxEP&e5_p*sk9;eOtrTsaGRqmiZ(F5Se8|&tjvP zM-QVdSFr`&lwk4ITp;(SdGbIFrZE;(14?bE9pGflm>>AtCQNAahZhu%o7q^^R$19r zRl8uUcVQ^p=^oP&9$Qn`gc$6Wv4NuCG|!l}jWsiB13fjZ6B}ErdjhpHYNj+cP6@Wv z)lH+~`qFSawS`L~CHNg~2Pc~_7kdbtU>8LEN zv3EK;cLXcLV&QaLyjv69J-w4_U-g7JN(enO+;7-GI7G#kLB@2%yC0+DpN7R<(aXXi z@paZh>s}b>)*_C0Di+{J_1K1v*&j)={4_(|4(9iR^d!B!u zeq*k|?;X4>*MRfMUol_7n1M&bYcLwNBA;ciNM0g|tasj5cYo)G@P?ZySI#7Jr_~mQ z;pH=D*Wk?Ae2!4y2q8Hh=DS&Wo`WmMeQ{k!?>Xnzg(z=DYp^FDS2vr_FIaWaJie6m zvdOK%8ji_cH2ewLE}z~}%zH>W8biY0Tw+T&t^p4thvXP~(>rBzw=U1Rbo>1B_opt$ z#6^3sy8+iRT-e<*7lYf#IZGCjZQ*w=pOqhUFWmHle)IROYkOy1-dY@LisUcd+EvqV z@tnwEwg5|Oz!QSTpbEk<4k}? z8VZ`-bpE7SvnEaI?ZwT-p{gpZ`F?v*-`quu=JqYBYM(Z(oj)cb;G&b$_#LPDt@qqo zK-=OGw6$S2UElPiEbQ6R=Q#u2TU%z#Xqnv8Gr799w%Q*EET-FP&2=Qg?d{=kS685< zs;UH^gZLjUpuJu{p9LSST^&=Wc67D+W1LxUV4I@GMHt!6k-Ii7Ld+mmt?2{k)f0$v zyI01?p?&e8=zl;^T;vxzAdXwZ5FqYX5GsC-t{fRNhxRqj$VWf!Nz`t-Vf2cL=>n=L zrkWp^C&@`p^I#pV#-EezlxLnWzb-aMZ$z#_<8qvXy-ej=ZS&#IB|E$yy+h`;LAuV& zr{{cT+r#wjpn1(~{vS(-0{#2txG9M>31!8jbr9TPZB#Q~5}xH&n9m7ZYJgmoC~J&Z zs&Z}eVRYoP0p#KY6-W6XCLga`1J+JVALLjO7upfdAUf zlj0(H%Ur(er~CIl^w8e@yLWT#Uo!j_{TUIe*%-C> zCwg!#O|u7Hu-Q$ammYiUrRR6=u6d#TQL*-swioxe?~dNFo45O5^tE49)5|!KWIXB! zZi9c|^`_b2^5hRD^KvkS-%Qno<5E*|3yP;Y8#AjKn!H6`ek_^ltuAbxcIaQ9ek-T3 zczj(}Ua7x4GL~}k8nNdl(^=~G`h%&JS7D)x$CjH!GeuD@kB_lp+02tI9QH(>!JVIk z=FvYre)Bd=C*5RTy&I$2m4aj@68K(vdy zVSjnX34qq5CI73s*N9VvSm|5b3^%2SZn$-9muR*o76R*z!|&LnG~wSzk` ziLo?9ZDx#90T-SnqOqe5y8J#L=7#Tynmv=g^(TovV$ zpKFbSGLlKbL4Ih_!nXGy7Jh%}GrQ=-b7RbU`7iUEi@g}L>A1qW+U4PnG2IVz`$hJ< z5TF+ZN$~A&!bK-FR5PregJ?<&k!SBgtSEQ+h-+~KovR&wzDI^0zxkF8;qWu4!zhRZ zR5yM##xOdXrP(PuIEJ1AjXsa(AQhIG(|v%<28F#Ag`51%99;MW5ZxL52qbRX_7iDg zrh#j&3(0lA-^1qRTww7sIy!N1j5*T>rePz}jMvbMe4p(xu(=y}KC1k&or9kZ?)?0< zZQItb-?oh>IgJN!ZvNx89ER)W82V-zp%st->(^%u(t*L9CZB4uAf%D%)DksVJF*;WeU zwYcekY%n7jD~X}vY3LhdkK_rku^N(iqwoH6gm zxo&qZKGA4;Zf-h0%k+zl`uSHGIXM~lTruiBm0K?~z_a?VdB9507?L2V%!2sRWO;fN z#Yrq!;u#HSyGM!_BL4g{DM!e1vGr=b_2y;kA1X~sGha@66$d|-ly(~M)4ZZDW5#4b zoQ|`Y1e3vyn&EfNZKB5Z=~q_NnoUl$7-zC1|DWEU(Gs4))193a9aCC5#xs6=^pm`T zf;@Z{>KB{!^HY}n81lst}xoKIv1=gzp>rJe|)7Ydmb6?V{IQZeDw9|l}<`sPzGroK_%*G{{3=S)Lz#I_W zDbBB~=vP)0r?4eh&NF$6v$LXQ;cyu~(LchJdhj_%zqm<1$IQa0_vE%-jItl*FRcWP zAqg_+#ovFH2Q8j7vq>y$)&QdcZR@0XA>vaG%Lw#ZueMrmVhx_0NonTONw4DIZzrXl z2K+Ry=*yV#<+EWnF2Q7Q%su28eOMFN%u!dPW0)v7@A}!Zub(&Xy4ka@>zlD<-hyjp z&b(&9ye%^@s}Rq}Gw;@1vYw$H9ceoP)4Z;=ZEb72y4JS0eZ6nyHS^|gnK^UI{CU^R z3{1IfdiRi# z#5c*HUAqcm_V6-5GZp}Fvx5AK=ZQFK$m78gb~3nE!&Ce(ziy5{jsVkW-KRk-(bI5g zT2^YMBhS1c)16-G$V*@2$g#moe%+GJCI(G>B#nG zUY(MWTA7-ia(-HFx+k+Bq8GU?%GA2)M-9zK5PBunOM%=M}Oh@s*SuyyqqS`UrYzi{62sPgy+RAoYF;@i^E%S zi}{}UuFUlc*64Wd*y#6BnwVj}i z!Gj0ITGN3gdPL14wE-9d7DmujVYiEbm97Xx7OXW5pE7rf6?CnXB-$qC?NoFCudMJy ze>ZuL-f@(?<}tAfpaYTUkm!y?w~N6D2RU(m8qoPUH}WC2fOB^vDr2+Ex)Z=rzsv^O z4kwf-&*(v%l`QozZ8o2*M|l8vI}%+i3zKCS$0dy%*AzQ(!Y)=teUnFuNNC77d zWqSdf$eVCGfVDwJ@l@cHAH*_uM~-ra=#P#@s80+;hdAGb5z!sp&U_fhX+Y+NMqL8> z8=NYion}SM8;?egN5o*%Z^0XfW2FIpljH#5z9{i<1o0@xB1ezo;KRj{3uQryYl&a- zz_ej~j%m6hV(zq;e14ceh}%{5q3Lk)T9e{~q)e5Z5DKUA9tB3baI}Q+>RyBB5op%HgB5S%b1_NJys%8I z?d<3?w4xGKv)$DdTQHfm79L83vlm5YhISEEI(bf^}9x8LODtm8-BdELb=CmDTUxhEgoErWHtq`9nn0`ou7x+&hjedhQ zqBnTOM4mLpErIosV=N^EN*c5@QA+%@`m8G1(pOb4BlTHUvZbX`3y=<;EG?DQ1{r}D zu2+=b1!=hP6I1hirhO(Fdfp)^X6I{Tna;sC1aFT|S6E5As zdMo7h=KAQX>nriCWG3a70=cEo3fpB;Zt{$fe!%jk0PDjttK8uo#P*_sAfFI;<84WjH0$}m6u z;=lMeeV>1`{-a~XmLpb^RBY8G(}fh&B;*B5EH74blqpr5>k-mczD>hO2${}pm933T zmu;R~7q?WeY^DQTjIx%pHc^taZd}eOS&D3ViCAJZD%Yg+NYZ#38^n}K8zk+4Y>=vL zX`YncDJyBPDP=hJbz1ur=d9gQ|1>sxNs=`{#Eyjvy284u?Cyx-y$LoGHEOkO39l@i zZm%n$irU*~`2*IHKi2@8VyUvIgYB%fk}FFJNp?wrYxb1swsI&sB<;{UMK`>PHr!rq zkKa+Y7ro`X|queE>Z zg_YDTP8ZWmhaGX7&cJh0~ImfVbx=-E>U{2^HAl> z)&a|`>r}gsk+z?$D1V<)-BHcKBL@#@eR=ofQb*f+_giKd;u5rjZ zXKM|Kp=#KTZLe24F^Wd*K9ojTHY>pfN!`lCZGroavh|?lG1t1^rW>B6Bfxc<9zjgGNA%9Lri|c~ zArpI?ab7JQNgU}abQmbIJ;z7@rH`~5Bl@P2`dFPwU5gR6H`eA<4_#qWtyx(ZyXv6I zIT~%nq)z#yEI1rh*)lyHgb5uvwIh8EW_6f0l#MwZt+{|^r&50$dIMlT+YvpR1Gv=^ zD`-^qjUjm|X9&O&qt-0!-{H9lOILaKFn=FDD%IPX)L?y-I;kf&^n4j8k|Whb3@WEK zgVubIo-MQBSt_a|_;t)@VZJ41=j2q&2n%D?TTUG8_0oO5Yg~lii zL@zepQ%|tjf7{B(74dIbc|B10n^rzP=8&bMW@MX>+bL-?l|7afuj*gg03PB?g;Aq- zB@fmZXjmeagxnB}$~D3RNqBCX^4s!4YOn!M#Oj0T0WuyEt8v@kd%XXkgAcJ$r`hw4 zxj^&uw2-~FqO!&wa!ePi+goaDYHC|Bz7xg)u@2{#J*#?%Jm%&8#ev4x`SP_r~7-n;bDtxVIykkBJ%l2Gb|)cT6bNa_ST zB-GhdRb5@x)X5Th4>9VS#ZlB7&zUBy(kAY2qCi{Hl=dgAM_ewB@Q%O^6rzs$aA4Q4 zj*6UI;V#N|=ZwurAD3TQRxJ7o$~{h(J*6zgUS3>Mfm%s}#FmIpWYoVtY<)@1xXwE9 z$%1K<7O_l^&?j`2xShv4{eHEPnpF>5!% zS5#*Ad;IyXf;2~2MkX-bPrdX6ZBTXMbKj541A42Nb7P#CfE{29tdw!0OibY2&77wN zd1RD*KX&PIo|fuwRwHG_@JTu)WGp@3+_Hi;)|&sACqhwdP7~1Uh4hGxZ_aR{rop#k zWcwc%Y@Fn2o}Q@TZ>?N;J(L_@Q1a0jcmvHS43io{XM>>!*2kxGJ8pmcr_(*yqy&0u zCb-98OkS9tHr6@8S0ol%v|XHEHYU%NYAdzbedCJDncD}z?c=t7v?2drYel0txWrYQ zn}cJ*JZMt^pSLtOry$Ffk2d9B2}&yLX#4f{iqz@iWL{2oc2Q|C;>^v;ESeCSc#lO3 z%Dxw6583!EQtlCHTht7R-^=!BZqD5A);G0@g&Q_3Su%eEc5(>gKIC{ZDF-BcpU&}I zTT}gr9J`R?QCk>gVEMJ|PGt_y<8x2Ke**vlX| zZ2J+|v%ZjiZfgT()KLuc8ST(B%Rb#ZiCPw2u8T$IBfnU9#TAPd zZBr5p)mai_UA__H{MWH!i1v+HC-;psm%5dfKSGW4FukKvnqjS|v37GU=b4C8%lK>q z3QN0(Xo6Z!wcd{x?MaCzp)Op;i)O>x{-%6@%9|?wDhV8wwVGsE>}*D zD_>mc%5%D0&O8`FqIBH#(Kq}UL#y_!Eny(Hpdc5YJDvIq#P#$V_CcHS0g6?^Z94G_5&EFgBpa=qAq{J&>rp5137Uhn(<-(U7K^GrSG%$YOinVDzKgmK1L z2Y4on9Z_0Ve)&&pFJ?@vV2m#tF@D^{6U)9t+_#6Zr12vrPA>k+mwEwXtL8E`V8ytJ z1ALu-I`It3Cj!dciIa*ZeRA^EUa0#H;=||HS1yV9aPw@&B0pvKRK- z2GRG|HO{Y`cYek*j2Yhp+|l~V6-&$=q6y{uqP(ZRx}|dA1J6H)IN=@8P+4DnVfLmy zj3qq5Soqu}jm<4Norh;JcDjeLenXZtRWGS=UWWE@z-O*Uc-sX#%*zpFyseGJ;K|Ho z{1=wRR`7ZcT@oV0RGsO|(3--3H3^V5=X4T6?SX9+DEX0$-X0r+t z?Z%kOmWGm*)D*<{1FfwW_u4flX7In60hsvvv|`(p^!zNR;I3cuPA(Hi%nnR2C!G2t zYTPmwFa2~TmM^SnA#k-Vm36!e@?+8Kl4_KYc&IAx$#&skiVF4{bAZH4L`B>8>@<3^ z(R?=Z@u5iNER0Gsb~6)gvHZ#3RgAGI6H3OgbD8ItHpL5bIKRZh*mZ9nK&?3O63Tf% zCkM`GRR_H#*Z`#D!sW4L@K><&;GfU7!{5Pvg#QzMoE_sM8Rw(<6!;Y!W%&90Mfkh; zF8I6oYw+LTZ@}Nf_rZUgzYG6;{sH`t_$Tl`<)6a;p8p&Eaef?rn?T5EcjmGh3^-G;13Z);1`J^_@$y0{zw6u z#b_~+abuJ*3jR1_9Q=s}rQB!S$GADz9L@~0*sO#<4^qO-1!f)mMso>T&6xpC4TK}v zhy}MyS%aa*DCRIHnKM|FImevKI-#t?Jk1G{J;v{K`-Pi4mM8N}p38^x1^h<-3OIR)f5U$fCR&~&hKbWewOA_7 z78i?a#BE}SctY$F?}#JfJMp{WGCCUFjXp-L@i*f!<0WIC@uBgh@o$rxL(SP{t=Vc` zZeDNRZa!!}YrbZ_V;(WTH;+5qj((0pN10>3qtUU-ahYSI<5tIZ$77E79X~mlGt$}F z*~2;7xzc%o^GfGU&bytDIA3t?aenOl+WE7~aK*T~yZXC^y2iR@yBb`pTo<^mbp6xy zyF1lg;ojih?B3>nFw7Yi7nTy%JIo(8JZx3iEZnQTw7ki25Sxr)U-(5#2euM|5R$UG(zk^P<;B z-w?eu`o8EVqF;{Q7yWVc*U`sfcuY)8QcOmSH)c@Gh?og6vtstfhQ|(!EsGr=J0o^M z?9$k6vERl1-l3?&sty-)Sl{7>4qH3y?C^Al-5uWU@JWZG9e#~7<6`2H;xgjAaf{;C z#eER>MclvQPIe6I7~ipb$7LOF>$s!i6CGdf_;tr)@jN~zJ}EvkJ|})?{HXYf`1$dT z@t4P6AAfuNbMbG)zZ-v|Q&^`?ojjfTbXwl&_D(xGJ>Kc1PJ0vF3I2rP38y7YNvKL_ zOjwn0LBdrDn-lI#*qQK5!kY;nCVZQ4ymLh7E}hdm59mCw^Ua;#PwbR9F0m!?oWx5K zuSwjK_BXcyN$)3pne*RQ)COXkVR z$-R<&$wQLMlE){{NZy(Jbn@=xx063fKAQY%H?vzzx1?^F-Ez7O?RI*%if;3}HFR6m z?SgLWyFJ|P`EGA^d#~H4Dg9CkQc6V9YUo!y`AzPtO|-9PUBP4}NY4o^o`9^@r3GX<=#UY2LIm(x#uSa@)pP7=`GqZo@z|5x1)tQ%MUYof&^V8l* zz5Djg?>(mXg5Dc?Z|Z$}?+1Dx$m*Qs%Nmk3GHZTTL)PV4+p}KHdMoSmtnadZ?i1c8 zu1~i<6@BLRIlIqYeO~DESzp#St?!t=&3*6c`$6AN`*rI#x!>G=4gFU2yRhH0{f_qc z^`GAVy8fRIa16*9P%+?~0hYy!Q?7d){xeGqa~= zpP#+g7v}5bo9X+z?@8ZJIfXeBb8gT1IyXOee(v_%Hh+$PrT;bm?|IYm?#X*J@5Q{m zc^~9`nfLE}o*$LpCI6iK?fDPqKa>AT{=WS8^N-|zn}007tzd9LX~CF+Nd+?tstW1~ zS_;l8IKSZ8f&&Gg6&x#UC|q6mLE*QBCkCE5$Q%?usN0~Pg9Z#L7*sT9^q`4@W)7+z zbjzUqgT5Nveem?b%Lo5s@R7kMhm;NZTi~~G$OA)OAM*Lo=%JZI#|~XHbobErhn^gk zG_23C{9%)ZtsHj2uxo~G9rog|y~7Rk!+$M`D#|G; zE*e)f-%hr`$U-o=CFHbLT zD8I4%j`BCkzZnrRqVov8(sj<|EgyCZ%X89#FH$mt_59eMxA!y}K6N*FbC)Phkr zk9vC4k(sp z8rOf^?c+|4Pa1#r__xkTKV#_`e?Mc_83)cde8%?^ToaNf6iiq-;o=GRP1rx-_lZ3w z&YgJ9#D^xClV(r4Y0?*yyG|Z4dBx-%laEY^nBtkze@gk3<|%8ZY@PD>l)Y2FofUz)21$(x_auXQ;$|SDpD#2R!prpuj1)xU8dbKZTqyRr|q3~bb8qI9@B?TubAF6 z{qpJmnEu@Kk7h*8$ed9;qjAQKGj`6{J>$@fUuVY7^vxVGbH>cZndi^kIP;#F&&)h9 z^P5?GR@AJdS-ocE&MKNUcGiqpHM3f0ojYsYtQ%(CG3&uu&&_&s*1=g{&H81wV|K^c zp4r~nrL!l^uAbdI`{LQxR}P;4(Sq|9Y+kTu;hhU#TKLw&Lko{CJYEx9lTlMxGq$Fo z=AxP{H4oJ6tNE%{)F#vx*H+e^Tf4FLp4#VX->&_%_P0ghi@Gn$Uo?8rj73WqowsP? zqWc!Ty6DiNpBINM?zT8<@sP!*FP^n{(c-I2Nz^>OvR>W9@&uU}Tbq5huwSL(m5Z)=EcNNdP%C~K%_sBKu) za8bk64O<(YYdF+!w9(O++Bl%GsBvQB{Kn?Sa~n4`-qZL@<9m(YFL5kMS(3A4ZP|WeRApErJpr9n^Kzcn#ML& zHnlWe-n6CZfu;jZN1L6^p5{Kyh0Pu4Q|c zeYotKWj`-#TkcvOv%K^2?#p{EAFw=s`LN|9mXBXvvAh<)jdH$2Gh&)Q#lxprZag!? zXoh=|t-(BWHD*JF@bBgwc^{t5^Z8IdlAq33^DFoUely>J`Q(?PtH>AA#G`VS^MPR+ zkw%=+)#zbl8U@A;#+}9!#xrJ;*~1)YmS7$>-&~A2&Q<1h<_+ep<^$%#=Huow=9}ib z<|pQt=6BJ=U$lXNK4C8M#KDF`Q!bXACupjWNa~9hq@K&4~bO7SX-!#XgWb;imufhDqT81EUZ7wgAHvJzIx%2*FJi`BBZtdg~`g~k-t z%&uUUu*=xx>^8QXPi05ges++3$qutG*jMa3F4)iPSJuY6LSuC1iR|Cdn?s;E{Lq|( zjH!GCpU-FWxxA9E;HU9b(72yL_r8tQ>F2B~)|V;ld)A%(z>?WfmdgIc(y+Goag+7u zjOB0#>&bp$19>DX;NfgAk7k2-6f5E#*>E1m#_;agh3Ljk<0-71cVgps4>kdN7Lza@ zr|>K`nfGQ>c^@{N_h&P?m(Ab<*lg}&O}v~f-J?Xbh{5hg zJa#n_MHlR9bQRr1Z|q|95q(8J(O>vPHg+@e#bE4XB#6#psOTW_u(MGh3dKOI`*X!0 zks^{srWn9~6T|rLVmLo8iuegp%wHFA{0-5OpA;q7Qz^yPLz&>BTnI5j7}y0d#VFyx zK8Q<7HqVI9~ftRwrBMPM%?ihYD#qK~onb_hFe zA7a1i0N&Z}Fc*85x!HTzpZX^oz&Xq22FvA6?4$jL-MQne4?BUql9Q}A`<;!#Zr%u< z#A^6pwg`Jy^LQSs;sval7qSI>Ae+zgSsfqF8h9~lYHt+@P zYF@*x;kE2qzKC7L7qUC}S?nMDOm-(fn{DH(#lOWd@q_r6_(}X|3^Rs{*Nq~hK&%s2 zhz-UvV~NpZv=}RlmBw;osnKk-8Vild#w=r|QErqPBaIR<-!zR6%?`%bX1H<0j4(bm zUB)5PZ5%cY?4G=4d|(P=kFnc02<_R&%rL$(W6a)W5913n-pn`Cjn~aw<1I7GIBIq@ z^UO5kRWrxfYi1hXn%wx_OfcRwUNO5E|1#b;qm3WT&Soz&)l7nx{@jc+el!zJzv(gF zFnz``Guimg>}2dWdzxL1e?ym_G~O}VjCYNvjOUDh8oP`qjRVHZ#uH|Lv%B%E+0RVD zn}490ZMJcRwE z`;8sgGrO1L?Z)!NI-1p)v=_P6)mMi(%W?@F7A(?vOot6N+IhDQo2X*o@N};>Cr6D*sjB|EZ*3p!yQwqU6)B@S^PI0?h@oV#+`@1 zr_;N#B>s#JC$ql%Y8~#zQus0*PGMeXYT2jI^a;@5D(qpI+-g@U-ojt#^fcyU59x3Z zyxlhIa60sW?qBF&H>=a>J@GcG(BWQqD_T6ygq|6$(|beL!YAqI_t)T+R9BCBTgc-LC#rz2j4cq>vTAiNMEg3`ps zAax?))MEE`1nSh{ecgh&NF(Bnc&i8L7=`poz^esS!Bh{kr=JQ!5Tiw> zy~ZDN{~F?{;8QS80;&1$zbYVW3c+zUf)U! ztzCkCCn;)x#CT+XFGRn$K$e;yAI+=?X)Opfp#-JZVzye1lGTXMM_wb!ShbrWS(R*w ztT$H5R6pdBOi~U>s|Vi<2v#E`wh=fQkfzeB5O2}gJgCtmODzvj;DNjH?ukHWe_f5%5R;+IbR`$G_&TCooQEWVN83&1@ic00w|^YI6fyfKUT+)Tp79W~BE= zY7=Ny{BH&i%5^ypRsfBt)xws`7874-M5=z6FJTe{;+w^*CWKogH>vh0jCBtir}5h% zQ+t9C`l8enKxzW?T8Z7mvOyU+kBzcW$}MUt(dU(@M?EMjnyCF{VRI@D9im$ zsp0JseyU#2$0{z{rvHPI?Kvg9P>n${qaX}|OijQ#aE$yW_{X=&sYaKbHhVbOw!fl= zG}aQyF>8!_@E-TV<$|X)%1A!#@Wz9?jcgIp=cBJlDq8WZK^lz*%ntq_uM4$ww?f|g zqgEqiaDdc&O>$J#A+`|k)cdnvVC45lDvgWY9IH}%`~Knoq5D8ounutff8t`0PA)>b z?s)$Hb-5ZwA)bHc`XZ0$vfTd!=Rnl6oEu@wwTICWc^t0OpE&TI`2T<9+J4orh@V~H z;&i+-o_3ds_$gf5&qzPp=Bjmg2%i7RS$)|4d8#_+16J`V9P#PGzilkz}vIq4K`1k(PKOGN&u+^Om`o*Gu$38dqc)_-)C?$b{>o`~PXTkrp| za{*p^cM2U8qLJP5alr9)37D|1dHgQwon}kTkxr0M#2#N`q%6z+Ps z$KjrZBiY>v_YfT9G0-mpVaTeSTR70>V=BL`4dI^=ruMIaqwzKej(ArI2VS$w;HJY7 zUH>g^OCI#|WOq^V`+hjUI&lQgi}5_9o+r`nlaPbscfx^JC*$BwgS!-EUPGBh2&3PQ zBmH=1r2P|gya;?UVoXIre2Vk>wxL@JEfLjgsGF&gX0dVMZ_A1UsMmcWj`n5*#&MBTq)d{aD-#DcK70WA>4g%7r{{G^lh7B(%Mm6R=r8IA%KsI8B+DUu za;PngI>#|)k0-&6LEcqxQNZ~Po{*)J;MH-^ed1}=203Qq5nc(G4~KekY?2k2nW%R2bTz$-bX z$Kg2&4s-|Rx+w^s21jy1bVkEb|IC7;F|Zkq#&mbU`xZ|s8v#f1;D9TI(=HLuGB}i%`VhR6xI5xW?HY{qf8$B?1Bc85jWoZL@0?aV(cd!sKAzcdRF2xd zLBk|kR>M&n(1!s%!2b7Pl2d%Oldz=Bo&uIVz3mIoau9Ae>R3834B-WE=mVKfeQ}M7 zOTA5T^jF*O%CW=2Fr`u4p7v*9S(eIcI2Js5a^iElbeTr|*sf0da&lg8!64Z>Mfp>u z+428bm}m&lXXh>GqC6|#Dr1L3bXjFc<^y3{JFPUUo*kZ*_GeE!pRF?O+i6d?!?uU* zaO}Lbm%+DnhObDByWn82!ExpUUr8BGD=>V4!7+8X6gZqF;n_(48typ4BjHYm>w@^> zaJOjJi07qn_rc+e3cDIE73J`qB;X>DM(1U|h5H`qc(7~W?qsYDCs}Z=K+;0*KkmYL zf-rpL|B)L!g-3&iuYtc2U*;2$)0g+;gHRKvOC*?Z>|ZWoMrrw&Nz5~UWm6rCuY)}W zc8N@sU^w+e$gD8hhB&?#F0O8BU|IE*O^aD_{o?w?_)>&HjPK3h3cfVUGN=Wc2wC6V z<)w~U9m1oY5$ChV*jjuKa)JVmZ$SdgTpP8P@yNsYlGNFK)P0mqcQrU4bN}X^?Vj!2 z=}vHNXOrAN;p@{_oWdw(gION)y2m*0aNYrng!KljA^1f`0{qp|-$u3q{AQyZ{uTpv z5`3Nb1pY(fg8)|833Ta+?^8VS7hpA6c<1NvTLX&TZd(N3)OG=tvgKqQ!ublcgD|NreTod4M`LBpVY9R0`Fch}#!&z$;q66Z^-pMr~W z3cJShG2RZT0=(dFi8zD}^7)-&2K-xP_&J$AOZtOgrNVKZf&3*%5jZz9QOfZg>3sv0TS)z{!~%_+`Rk<~5u(AUgX?OO*jQ*VBi2ajs`1^W#iU70#ZO%P1NxAmw>j@h zRN|}m=wMGqB!HpY>fQoO__?>ZH@UAy z9{q~l>)_V9&%`z-e|_Yyo8xvL=i748Y{F>vMX;c)l?&OmpryFdI)gjLNn)J}6J zyAuKsV%_2HShwjq>H5X>qlUM|^-b7V*JrMeUGKr)?|Q?v3qBmMtpf!3poTb6Y_-|- zoa+g=hg>^c+uV0s9FSB~xkT^MDU*&2E zD|R)2jv9K(&^&rdC^Ox$GUYj>%yh4VKgBiPHQH4QI~8&TE+3wyuD;Usba`A!$d7kL zyWDsR=kW-q^O*B{=U2`n@Q*k@biRY%KId!Bm+*T=y2qS5o!jx-2i zL(NO%?|kQK=L*DdFxOe>n~9PG?~ZYI*}C00W%r2Ve#hN}&9T+7CH$yk zljCZ~I>%c0=Q+-DEQ3!DM~c-i{IFvYepQay2vs;HIL0{2;f6Z~I&vNT9hpc?b0kMN z6<+6R$AMs2W8SLT>6i^_nIpjwi(j~OrX$uoY5pSJkLEXy1oJcVWBBix`^`7P;&Aei ze!IX8@WXr#TqrhoQE!+}xW=0gIbSk&m^+;N%x&gv=4Sod=)A<-=Gbj+0A*v4vn|4j zIN`YrkRd7Vqf%OE6vVloGB3n$jk(GlP7;Rx{{|xrQb}^@+ON_eXI04AXtq$gGtRxv zY(QuU$&hP2wcK%@?Az_K?FVp%Y#vg#IKQX3OgoQ!*`ImNCJLK1;V(Fw0BybO`4wmh zwPB+<)0_hM;~^2N&C%$^qmH9ysbee7p%s_~&?{@jqwp^f^bXn}K8N2Z4#4k=Hyr0z zNPT#Ys6}`c-aDL+BR#~o8Z+V7%iI;xzstB1DHqD{JPGqc@}buV{-xxz%Vph*WXk2@ z5u{%s!}ZeND9ApqMwU5W!l@B&Bc)DKHCs|xC26PmW+J&2Qk)Q@dcQuL7Rx>CZqNOI3 z8citkjO6(>k|!6-@FmipEmIyaE=HN1GUaR;USnK`loc}N9O+M#CGV8sZPH&YQ|^}G zd!&D^Olg+>PMp5x{1ypqtAw^$mbp#(D`lD8#=lVJWrOC8&lnVWCl1*% zJjp(}RoW-7k@m@V$ywh~X`lQZ*(dX2vQOsY$Ud2`BKu^_wPBykpCtQa{tVeC^JmFE znZH2x$vDRg`()(9J{hNeVV{hA*eByOFYJ@~yJVltKg7A&DE<-IC-YCqKAC?`_R0JU zvQOq;)4Y*?Pxi_D2UzK5@_%7`XYqfNeKJ2r_R0KbvQNedUCb@z8C^Q13;Sf8(}jJq z5M-Y$BFR2kM3H^6h^CpRh#{L~5lc47B93g5MHieVXRsMjZWErg!ds`~Yw;wv#TGwL zhsh3t+%mW&aEsuo;IQhHe|#8j4@Rr5fq~qhAWn zb^qrd&U*gIFAjG7|LreQ{Mio@{$KqpDlBTPc)F&6;9HOe>)!FoC;2LruG<<^2a=$hGCy zWI2HKFx_@=lsLn_lk3dyMTCfAKgb&nev)g>pXr=G`-QXv`wbTUW3XRD>(3KnyqLgF zk}kkmdutV%KyAh;Y8u4=Jk z_A(Gb2rB~h`wDgh)=$<3o%?`o9|L`W_q}#>r$k@A0RC8!1Aj7K z3co9?2Pi!Z{vb*LZ!t3#$>g)GunVBsD$28+d_JV2D|VlHfMdgOa>%;b`{{JAXJu z&2avi!Dff^VQ2}5rO!q1hjNtv3)Z!l4+)oJMX7!R=^3nHg+fYC zz&eyxpY+Rg^hcQn`Z?0zW;>GgFX2CiWGQD0D4wUw(W=z?nHFC2q!qH-jrHm;=8w|< zC75^WkezBO0cz8*e#X!IM)Aq~jGpGl&UtY1Ng>4n^JDWpN3!`IrI`DvK9xf$wJyfW zI1rNSqGi=}j4f9aIk6 zE_!Pmkp9bUi;=#Ze0Hn!_euYK>3<;or=|a)+`)cX`kzZ^%PGvSlW=a8>CemXKA9ff zR)*X{nbIc1q{lehOkt+p)(tY{OX=^H>90!vHwo>1>3<<}ze63)K9DIVD22XZAmy<1 zpOWbx%J6NH+P9>?Uxx|fgA(#>vg8Z0$srl-8C;TX_PWX3nM)EzQFEW{MY1$>@iEk%p7jfvM^yz*o&i*a^ z&>NE6NGC_%OsEXj6zJj8@h%_V_9?wW+W@ESMx0Hi+;)HFG9;w4S1@-l@Rh^Co%mMe zW-sGwRXC5}k@%`M4`0ph;W79g(E;a~;1A z`wd?m-o#tEA1wI?VBX-xm$Lrc$8%sMN%pyUkXf7?hy8kiwtn=nJ(x=l;)C&C9|{ZB zg?u=B3pW51^AcXl%Xm57?j!jqb|t$P(vpL(b-DPWF&cKgV^|)zbcFrR$6}p3p5?=O zc>it(b$~!SBR8 zyqVv{?}laZJ$yUvO4z~ggT@=pe&zS`2l!6@Ab*HIjM>^Fn4yhi3`6DR>u_b~nQY{XWbukKz>lWQ-F{svaVWM}+6?2doL`eeGcJwC=}u$fp%{LCKXzrZf} zH~u@mWgW+A;{-p6{RD=IJ~k2X!f{~yyRcgk1`FQ^tj8?N;|^G5b(HqUWL<38ASc5L zIYo3A9(5Vuuvu|WItAH`PwRZkhE4FiaW`Mvzu^Iq(~Ht z64)P?!S;9rw8$vv5UgcjaXeOx!%F51F#&rWlf+~(MNGx{ifLlHm?370Sz@-BBj$=q zF;C1FRiauf5DP_(s1=LEVo@jRMT2M*OK|I1lV}z#qE##t%f$+@QmhhZinDMR+G=r* zSR>9A=ZU|G^Th??LUEB;iyP7|5toX~V2e-o!0Ta=zd>9juExD-*NW@JMsdB^ByJEl zikrk{@prLB+$?S(YkqON*ed=Z?htp1ZMdo6ZgCH8Xt)F>=v(z*Tn1M4e=(sLF|Ebbv$hG z6R#CkRfYm;Q$cCb&pCH9NA#R2h8+~oGIcu%~Kdk{VlABvB}$MSB3PjJ86r{Xj5 zx%fhSDZUb4i*LlY;wbKX`ySTMKT7*&vV{H_cMtrE74A>sH}N}k?lIi0a6+6EZMZpv z8^SPfgNg%ph`0>55oUxN5x7q!%7`{%j98-sZWifi#N(dBPDX;!*+?|H7)iKmCE4g^ zq~KOLkCAGm;pUZeBg5!v^ui4+y^SoR53I&xaI--Qy9-+UR=f#r!OHp~7G?Cs7t=U4 z%;;zIHwNHNi)`F#k%N0J{P@u>GEG%rWNT{)TzRe51;!HWt7pyax9-EW%wB zbw<6>U^K#Rd?_r)n~fH%9K*0mybxz9E-_khGsE9tRlXeS$1kuC`JA*YUuB$WoMoJC ztTxWUyZuS#GS(R98t35#zVnR>*ahrb*tdR%HSHrW?)F>W<(Gj2Dw8viivz|A<@jJvcY_q}Ad4eRX(V9os?EV&$$KG2nxnAd zI8EQ@I1X!%Gt3F*MBMl|*_>idH7m?%=5%ug*4eXgvm$PQG%IPZ)~qtCv0F;0{)!K9zruUi zC;8Ys%RJj$ZJvX*(z)h&&Z2ou)yt|~tqrx=WkqG`S)8YzUj6jxXD&Ux*(IeaUL@nF zSEA#(UQvOL7wM;lQ{;0M)mP4MYHV;7H7;yys9qdVGQYNIerx@Ly6P3-B~^_rmGkFU zH?%lQ=U1W>9!-svEzU9-Q?QCPjU^gaiH2RGX)MVNFAKoYRWv!pMXoZ9zD%Ll`I3BZ zc0svwgv1mvB1jb#@n-vc;Un5X^-09u?6O>Q@Xnvdcq`n;qcqx43axWg zOKn|Mbp(O-<`#K#yx9?>L#qR>q}rREljj^$Ilr}~+Bqh`2Ba1{$Ewt^w$xm|QP!|f zwW?Gzrc}4SR5PelcUq~%K;3yIWsY$*jZF=@wkE#Js-;VnYWA0TqsG;=HY}`cYOSxU zY;AFkQ%!VEP*hE@QRUBbPL!z;6WcY?m#v8_EHfv8iOxy3YIzd1*Oy)7oNN=)GEIM3 zo?~*5Ud65KvU1((GK+pqcUf7~n@qBy2LTLskULEbF%Ch=Tt@R)F8QGQ>$vL zo2r{@o1Ie^HdQXGj+hqAR4gmcbxxC6I;#0wUf`TA!G%u`k)Wddg5nq{Tsg)4aq)58 z=|v+|JlAiU!s0u!xjKl;b zI*^%N=JVPj#S(Nubdl9~$}1o!81%AWK}>Me+A>RY#j=o!F~N~(D_dM3Bkjm3h#nPM zcT|9Twz?%nwn(W06g@7aqN27;wz(jB@~QH2Z9vLZv!Vn2E%TzMouXd3suvUNS6Mc` z9baXlv(hHQMdeCFiu3g|TR(I3(@#(6Sv}Z_WE?EF^scT~RH(`q73-&lQNg~?eR7Z=qF^Q6 zUZ20vxlm$?SQsQnHPx3BzOWrspF|8r=r?Of!PW%pgw@IsGHJqTihvT$fRaL2ttOyW ztJ+#Qq9qbvPQFsPc}nFj3IbF_l*x8Lx#m0Tlp3oGFb1h5&IXm*U`x%-GpYkBSF@^A z^P^O=tW@`3sl`&=k0s@f#-Iu-)r}~#YUxs?xB}xOTS(Qm*ox;#lu)(B&Q_b8mT9`n@*S-~Iu)0oYISSNEV?zlW#v(=A#}Ft zE@{=OwpFUyR;6l}D>9b{$qZX=jWy?TrD|6OGgW_=`<*N8s#f#0ywJHyf(u_2B1=%U zC0f;%SgKX4+B_@ntfX=3i>QB)pQCFM|=4vIk_?Sx1kY2qpG9 zECob{slhY=bVhSsWpj-TJ0&Q8#KHil5DwJy$J)j@mG>(^z8o2e36A(cCSE|YIl5$- zWRHwMGf9qEuOG>)f>4fFc@jiHOiOSy>Ph)M>V|#f37`j?e;UtxS%t z4FM4yP)arkC9>lRBDFe_InG740;LK?bYloCC1GWfPX*Dfr^=I@(QPVM4UP_s97W&C zQ`9S0^9dzoA}CYh+x4nUl=?L{TTPjBvz2Dg%~mtK z+-#+Oa=rRlsLK^v`D)#fo2}l9xn7;`wcyC9mNz$B>H$2})Hyd>>GNE#djI5R%SoX( z*Q+M{x!F>4p}ZBh%E|XG(xvA0=IW__u6`rs`ZQfWi*8-sr|I-*I(?chpQg*F>GD~0 zS@1P}pQguW(P@>l;#T`DJeppw#^cp^yc&B`k~i9&9kE|;&{ldtg=0~wk zS2JKpj4ofI%c&U@Bt@6gGqc>XawQ~wyTqskkW_`pCA zz*DvyPsvFv}mCURW&r$yRmuFUj|y1H_AYG zOHEU?N()=i*xDo$!xq#o)8(3LSEzE$*zRdi!RlK4{}@#e_lN7s5?e!SeUro?1B8PD z8V3ax4izFCvK--%RTc9}XEl?cx7AOvT3set#3set#^VQh(7OS!3 z)!NOgWY*_Z+7V9;PfLtfON>`bj8{pF*IT6NQ`+C>^%cg|tXxu4-7rTz`q$Mitn3n! zWY0+o%~{w~UD;CIBrA2)S@yK9p=opMd9gaLuDZEd0_bE*m=j1%(5aPJJE-N8EF3ov z3ytc!>IE%xuzpZfFR5;-ZLFFT@{l=+AxU$pm$p{c$&3y>|m`2-N}^j#1&N#^nt%9%Yd$V;!tm zAitx+N4urim#UJb6fM~$QA;YDsvD@M`d8MqM9Wx{9P5Zi1-emY%2*&XhN6qBTdcaV zIvyy{F{F0?|) z`NlRbmNGBrh}YK@@vmkmpl%tq(Q!X2zwK#?h+mH=4g zf~j@_Vic-?MpLC!9|RlPEm%s4K?wI$t5qF4?08!(3fjoE7gH!@Sz9Ajp+0uhS2`DWX zpJNN!YsW)Rl9Hr%6g_1%SRmz}LAVrr4+(~Cx5 zj#|v&sV0CqYLVy5QJb|WS8SHGHZ@8h-eNuXQ1f1&w^+>*_1wbeEmE^dpI*Uvi`AUM zmy;J^H3BNzO+7-|+Ev-q)VRELiK4JDAGnHDywz?@d-xFjj}bp*=ps7{X#Y8%GPtE?Bd*L?EW;>RxZR#sijqY zP4Z=zJ!{g^LSHO#}8n8i9@^@U&07ySkK4xPKiR;o~O%3q+?5B>r*|MwS^ zy6P6#$`vSa@fWBb_hYu%)MCpo)Y#OT8(ZaScIq!wvr~Ve>H)u6iz8pdD^%+bf1&C* zf1$=-SnjH8X|AcPs&+`iTD1Jawly^)>W&fmXx?!sMEA$`LU3faV-{pwUGDu zbvOIfvJG*KRDC1x`Ss$(ua=cQznKG;e%+aRaq0JJe0p)}*NacT z+I|3htGv}7Ei7sS4fN@Jt)u)}hxyeu1KO$SSKA{#e~uMbODDhD_5d8+KWf_o{Lp;O z)8+H5_Nyh1Uu_4Vof=NQ#ZTR@xw^hyD*1gnU2P2d{AyzkPYa&rzg}ef)y5X`HC=k? z<@f4xN{s@)=AT}a`_*<3_@)O_wpHG0uckw7;GvvFuX+dgeVXnZ9nZ1ix_xTf2>jCh zskU{%FO5%aA3<)deBC~^?S%f&aMcDS@N4+`9pqQrKXcE~hp;flueF4Nbte`d#y1Z)*7cW#LdOILDCJfBs5R zDF{kXc;<)6?-KJH>*s~ZZz~kiWs2qZxEybZe7UK5k~GwlT|gjp*`hG{xx!XEH>#$w zadG9m#$|YITQK#Wa!z(}B)$R7tFCKY9?bOR$30z?PCjLA&deqXU-woh+)_(}?liQ`M+=HYYD;u86usG8W) z)VR2s&PvmX>2O|Zq%lGF@o=}ii6#7K3wr9dP0=c*c?L z`>~gI{SV7K|A*xre^H)JnG;R%wl@6d79>_;HpdT8C-Xh?=hrXcFVJote`*2N-+U(> z2jSbLe~0vMk^YUedB(48Y^rMD>zZpD7Vx#`U>{!%KZm!#&*h8Y`}th>d3*}|d_D$# z0WXGM$P1dAv%MTWpPkJ%H@D7fW*eJZmo($7AoZ)dZ7CY35le9bz9-JFXTeHZ-Qk^w zGvI^hJSFTOOL3BVB7S3DFgXJ&XwN3wlU@30#ZpDrCD`6dc z4o)>*fb))*;#}I5IHPzC&LiG{v%P=E>E_$;UzhI0DbIUw8vOyB$$SKNFOe z(`1}(Vd7+Ihtu{Hf=vLAm7!FGVr0mJP_ztnM<_~$=yt0}8R~{mgbcxs0G1vEr7J>V zvehy01y0V=jaM9!M!vKMg$z=dZnaYX8{5z_NbxwNny5#&N6~Fk9H(vx4uxLIa%UdQ zT%WljGpW~0z3%U|3a+l#=w9hPKkK=_=M(sC@42PthMue8=k?6Y_$p&p#@1fW`@(r#7WlrA|prNsUbvo)5gQ}oBSiRy@HL4W@n4!_ z6X}09ZwTud7Vo|vw+|J&y@?xf_s>JFMXs5yvCjRt9cO}bxMRQLxyXaC=o=n+5cj@3 zgL9QW*I1ko-Gg(s6*y_kaCZ6*obRp>CjLj(7QPPsL4891IeiVR7+b_+=zo>MVHqHB zzZPx@KtFP`MVDnb9c)0(5>Sgua-%}#yeMNrJdZfi#q&Ck$|#5gjc`0_!$G%)^^`U! zswUw=85_3eQ6fRtu*xu;c2*Qw*a=%GmdD#+N!M6jl=OzS=vmYzJr!&XrD%A(6ed1d zpeVo@?gLGI!5M=kL`|~zKsR@w&ePz?T!Mr+K?uc!TnjF&BZSe<4i_3C0Jlqu1=pA^ z>pW?}Mf{I&ksE*uE*k?v;1ZWdtM=nW_FT}Eh5yi$q{pzp$!vw=@c>r?ag^qOM2 z(B44}J6`neLqR?PzJRVI3nL*8N=TvHcodW*;(vgh)RZXBG5l|K;vV(JTnU{pLq?4) zBpXI%5QDJ}7`ze>$%rKrL^H$b^-x+L0=7KpZF{>_$%iBs1Fcl;Bz!71mvW35h!YQF zj8cdi=D~ljhol-jNDp+Q0ll9Ei3J3-d5Gj}dzs#eQi%!H2qNAne(waXA%sh&5FU$P zJXpfQ-=RiwgY>zgh z%miW<1)UENOz~ilg3d4e2MX?OM*;o6u%58VP7@JH+w68laY z_Htl9mBzXo@1dcvXzPjgs<)TwQ^xL=xB{>xmno0hOnlQ}EsZi=*3kWL^pd(N&;zpn zXIuR*tsmwBO8Z{i)~@B&_}ivijyNc>X!!Hdw+*m~S7gtZ&f{&dgtj+=e3ALl$mPad zq-J4Ou@Uo_WXvC_Hl-4rJ3_SlKXf|fQYnQ;>u{r|4Z3ZVqsIuA)m~NacEr8V1BvZM z`Rx*>)jBF07-O3Eb7cvQId%i}w?VTTmBM53&ccicpiALI= zk1v$a&_yif@J5VXkH*FV-$>wdFrRCtU7DnPFt$b3CFmq6vPITNR+5Eslp4yPEkT?y z+kvyA9cRpqz&T#Fu|SSjHSZGmFRki*v)R&mfjD6>C|_YyEtcgK?od5|`jRF{bkw!L z9&g7Ub^f1W*KhqBE$o4~#*X=hoCjFgRSYc;#g6&`?1{2}X+}#hAk$IKVE=Yl5$s?3 zAGNaHrgqS-xB6F>P_4Dz391H4Sok9Ekoag8tno#n{ik@FsG8Tc!*wl59ZQNX*HVoh z(NqCSKz~}}nC3{T1mRNnL}X7%0nNEB3gSUQ3Luhsr85m z@Z#z~Pbhk?CJh>(gE$5pvX@jH#KfnPDxGIh6@&jZh;inz%R$V3XsibOw<)q39*VgS zX<4G#pnlfvR_i2FrAT}hAFNVD0cllR{15HKD2Jx7-sQpe$~3?RSk>wKn#>yvZdf%5uk_1 zZonPE0qSBgs=-lem7iA8nuF?H-j24*6m66*b&KMorD=j1MWr)n_W<@d$P}>C z3Q%K@gX!w2c_a4faePTT{1U&buO~F9EgXGiwiYnD321LM3y7^WXx(a-jDXTWkTD(Z8XsPQ@$Tj$CUGg zz`II9W3Vw)eN7x>>~9ik0Ea9?cvY^{T`^g0`-2!_l2Ol%_!x|yjkq;8MZbH|gIaH& z8)&D}>*u!Xfi-$gtZGm@Y4l=_X}w`|OGWf>Xqp83=#B1={vL|45HjBiU_M*cl#uir zgXG!5qgG#9(lxEC+tG&Ge>8R;?Q450*zV|b*>0EnTIjA)R)8K((V9!m@EJQZgtMz8 z91FLSX1$V+Gj)xo~0{Py=9QHL}x|x4y{Gi=%HKU)cn6ahWBKfTs925 z(56$(bX%C=TpNBEvPPIJO-Wjl;?R_uKMotLKj%*q@dx&{i$eILv~N>@KY_SfyT~=q zANgb0FcN<<^?KA=A<4Cr)bFb965yUd?>k70>KlXjuXR7}Rgz_?PZa(o0hzQ~P513V zGpbrBg_xw!f)k{Xivw-<1?dyJB(L1CmQI8HbiCn&PDlg2RD<-t(rFC=4p{L}%~UTX z#j0V)R@1h_hK=ZVGgss`zR7qZakt_3j#f+cvFoY@6UIzWPVFvG+Ml_wo#yZ0Ci+P1!4gzk|h;S+NiLNM`#Yh z9y%W9qikO>lqE;>jqn6kMT*M#Hq3flO1$mIsN4-Oy4m$ET4BJ_eze*RAW6l$J46Qu zq_kRbInqc* ztBar(wD?ZU}Wj0`({xB=kQYo8e! z+RSz%5OGZbm7qVO9bp-3-|HwXOORHC%or&lxJhOvX&pr~MkjVk@`y+7lkFsAdVmjF zCL|3iOcW45hDc1IQm6Em(#nLt!yoXE58$7UvEg&Cwy!xg57fJea0J$z2rF$z7%Vvr z#jRvp;^qV8c)33~UU;VDh+2i%HO~~RLB>KSgAeBUHZ7%EG6lFMNsK{VYiSEbiA-1T zaYdn=!?f2T+ax_UeN2)8Y>J*x9Xknp!R%hV7L;8%AEKTGE#N3|K+%V|#Esd74SOJ_ zWr3Sl^CZ-wp3{;6o%TSGJD@|b0@X7TQmC9OToc;K`5EoTy|q@ve;xrItk$h@uPC%) z78N`N@{weh4;~3B27(q7J1gsKdn+_UVQieNsosvjpe6EDQaH}m)<=M)s~rozJ_+W49UgbV>DL++2LnU8wMD;%RFRcan!r^_mXw;SfAN$71wn>Ej4(A<`^shI<%w4bNY zjc?Ryk`?t1=o~_2$UA`KMN(?p_YBDE2*o{FMvWjn4_DIqhj+jTV6{fICF7J9N=@n? zs9AzrjpY6`=?PF{LYnQn;?{b;Owl7t5j}QoZ==uBHh-XxZh~u1AEjCJkj(Byq=Z=-#KJ}x7;g9o;NpFOtCPkRj(cR@!5-*({KxD?&=RGx z_YDd6L)?Y7Le?vSRLI-W_yU>3aBB>731Fy_J8}KfSo}9;k{-f$8{$K)wGE_SrD8S0 zGci7`QmdJRCqjeLZ*U;B1{bl=m!H1S?^el4fN&J!TFpACh4`L|+neU0BQ6(LvT)q0 zbS?fX+0Eike0f#aJSg)ReguQQSSz;;angb$O?KX=|AF@v0j;K_g1!%g%R1O0mzs%g zbk!p`5GR*I6JLQ>rAR6)$u)E-c|yK~Y0c}@tWwV_#9q*1%`p@YEX)u83Fc4a zDl8PUA3baz>q>L`HD0Z?=|39)mwJoYaN!Cz+t*q7e%4w<9FZf@23Z`DJj}5mBMwNC zBCf&#okbWJ?f|rUQ@w-w`~jEdC{~JdQP%p7?34AfWr`Xx3Ld3UNXbf=Etb&sqK#ve zufEwV#;A_hIozPzLmB>;yB%hK85^gA0RGQX}fpV%f)RzsA z%LP~sPQiSt960i@22Y261$1!&Z>L5XI*AbW6|ieTae7jW%AC~C6Z-j$etxZ=U+CxO z@)<5~u%p}Sa628ff^Jwtj%qu7u}-0KVpsuk|PRQVmkoLPE=_W|J|Ixbrk;}Uu>E=iNqP!{5x}Wf^ z_IL5=+r4my7bqMJX{B>+-EkvmKk#h;G$-9yIRZC~F2wznm*eel6~4Z0#r2A&QaD(Q0d0*yjxMlJu z+}4P@;l)7c)OPm@%Dc->qg%%UoFi^_f@E{tV7F3o*pe{%mPNPdJ%SSrkKsQCQptSG zwgxh~0aWQYr3sw4u?`S$kE;5v6YQr#N|)!vC@q-YEX&jXT??iQ@FxDx?gS0J4-_}+ z)A_8oFcV_9`|Kl7b_lndM&duqeF4aS@p z!Gu@N#{%h6N8x5u+jl7WHqtr|cb|Gt798TFUsS(Esg%HMl`%?D6bbZ%11JCPqq|lw=R!4B=Y*AC{RC+8TBdpTvXnJLx-fyk%)Gsk1yh>y$UP>bps?Ci_Nw5C3cN9sIAvQTShqZ}Gm^f_qdi z#i%fG%j%uDlXVS3n{oQ@VsQ>aH{q_;wPH0wH{#yai^SOo-GDn-FBE4XvA%PGG zApr^n0u)HNX~I#;5pMdvl%r5yDDTVBlu`<`w6q0!aKE%Pg>sYl@%J;kj}AKt?fd)V z7m#F)c4ud2XJ=<-XZN`d_q6Vo&QQhR7S~&G=j$$MCpdTkZWESR5qkvhS+IO1+#LKv zykE=i#rrkv9=u=4?#BD2>@K_?WE9i;FRUxXUoKUwSak(Ec+|=h(wmPX-;h?IK0XgZ zD-qpgN_Uh(>W^BL#Er<2`;f1K=KHR=1sQiBN7gES|1sTn%A^2$>M%ke&^|h>WkFhS$gxRvhwvRw_J)9@Cxv1 z`5G8nNg9;3u$OcLbgx(w&%kUx3$g`WaMb$v8{Ag?Dm4FVxcl-A+K$|oEZzR9tie4(Z}7EPQaKklv!~TGSCMf%o@7iaF(w(E>`M7IW|e=lPyNV|E~k13 zD-tYX6`l-r5HlazU>$R%nAFVcHD)BRGaGwc&GPMQa#*%jI=E0L?O!1Qf*IhIn&cpwOW%``84B{j>lbX zGI_-xr6yC-{wFrf)voceq4CzU25-AJu*P%!y@u<2)9$VTJb`Ygy?)xI>kS6yJod7# z>RtA--s&1e8dToJk8nTutGG>CHC|NeuJ%jRn6A3iRJ2k`OHD@u-VCkwtCTJ^>+5{EUB*Cmw3*p8-BEBaK|Z{nN=NU-#or)?u1#*Y3I?25006SP&RZu z&6zP82?b7bM!o;FHIyu{M*0giu{H%2FoYU9c`|UPI!^!)`H*Fx=ITk)s z3OVh;#n(BFIPXB%L8E}LT0C8)L-}LE!CjoVfaPk{M%YLWX&UKNXj&qpsDYIQEP5M`~ zqRe&$eq9)@%ytDn6NW3Z9oXw=Ji_pP*by||e4NymhT&pX<7py`ALB;-SK$e$nv7a4 z<^v`K7bMINtMWVtU15C zrr)B|vA?j^#EO*RnbN?iH%_&z^Gx)`1XjB*J!Ag<3p$+JRvCju5F1X@8psY`r?~WZ z)hS-1u3#U#&meEhy~?L&r$Tb<=?lZG!)_;gsur_(1oW01e>Qu(&>Cu!H)Eyr+4NlX z>T|l*%z4`S>*o8c?VWWSdy}6weB4=CmXOuAHu20;=O5iWuxpEEa;Ec~+a_n%1$Xq? zlk0HK8U`n@m;k6jEV{Cv-AvIX_P|aEYPi#SgxL>>E$P;H@auf@AHpTN^W&~ zcjJlumhMjfd}qp2hL2;@N6$V`UjEQ`cAfCR1)Vh~_QtHAYuWj|@vWQcP8+wCZ`j)f zu9LprijjiWfW}dqjXJ!WQZkHQb<%!kM-saYvZ0GQj&kD1WIyw{q?hRHxzdTG1&a7o=0XuwKwpT5AdnPn`d_f!%BP$@xzh z9MVRIW8nrzNVeB-ebUVF1XR6Tmyyh`;rIo-S*(ac?zmx>s*_AP2$?o1k*}wqG@Go+es&~ z7v!po&ciqmrgu31=oyGu$5;mM0l{D8{1fnIJa6D(NFVmAxHOO@iZR;hg`JERjcRYo zbUgc6z4H1&Ng8j~`6WBgzg@w_y(CD*2a_WZ#^@~@9`Oi2;f@?KR-CR}{1B4On z2x!lj1Wgg`C{UedW^prOGUP+6@Dva(_dD^J2~!HBk?~J<$ZfdNxxdGm$*OzyJFkqB zUl@?a7N$Vg?b0gr`OXDwOEDT9oJUjEN7SZy159cAJD#Na^o}YM(%q}CvAR-7H4$h$k1rE+}eJ@Ir6(>r!QE8$FYa`Kv-^aTmbV%;CMcZoa>=trzdM_QYM=Hb1tv|BO>M|9Ed-Rr48lte=~0KJ(7?b2I2M z$Idx1t8hZhYXK)t2zY2-6MQST*9Ck6F(={>JSVA#%N)UB56D8>h*>SLkv$-4O(J?J zYJDX2b1PrKr=X=n@Bwb+3;4969RwcI<^+${DrZNi;A;T=4(%b6Js#OUneKVvn3Fn1jEC_j2bJt>}8uF+n^SEfG8b(eAY}Jtea&DbBzq)O> zvf7@Qjkv_=$p6H(%A3Sj7wx+7a zEf-zU91PUYUvPCtu+bPcacO2`;jMwsd(5HrAAJ}-+RG_vVco0X#lamhOGo3UoD)YMdLpV&d5D&#Ybkga)Ll4J2 zw06Gb{OM2kIrrV{*gR9$;27=gIn{qN=4>6s`kafG@YzZynN2EHtJ;-f4qFguN6lH* z=yiC$eVmEAlTFBX&mVX8g7OSd9suPzWUGZapfjfv;X0kktFR%8BN_W&nOg5&?N|F+ zbyA8mRnod!9%<^nxpmgr)p?6_aG z$>sPAMPqP3UxAax5bz-4t|XTk(4C-BfkStGNjkd>a?}htsy%vT1@~=|*#<|;;}Juk z(}{i*aoaf~A1)=F$&FjoYPY26o@id{T~X5MtM4(@ZXcc9B;i3<>b8+}YcrY}h9-@z zXD6;7URhw!=n4`7z2@GUtkS&vq~wy?%EpbpnW6YhgT|1tqPBCcJHISHDZMIS?buii zx;cfspJyxmY~uY9aO$Uk2O)>l&$2~3^+IJ?(!N~ zXNII&%b&w)n+Z8+T^}T;J zNIJvc_DcI;0B)vHS4yFG7jVsrJ%SLFYkh+1G$C(t19$IU|r z>^$!~)DFkUM~+UMgyU=qoJLN-m5A9<@R08-@X%^QrCG%|yBKd&pK@-g?N( z6mZh-qIGVoVh)o=y+_@S{+At}11XADGQ0TH)|#FgpEksTB|3d`+t{`=uq5Y-KQL=~ zp~NaBmf@;64u7P_@+!;YGj%bUD+=8O#4XW2t!h^(+)C#Xnty>zV7{>7)2 z($ov;oxswu_(>F;))4`3Tl@!y3vQ6FEO;9LC&X%XDPOI`@3dM8ZiRgB2Ke65Jn#2J zMx5eoqO=lcQ%06r7x0ir4r#>`<%G;bL&M%=za4aSu4G%>@_A6@Q!p(lt_5#^ms5N) zZdpPi>9e^RS5*Jglc4*H>%*>mm6I+fz^vTu{I6zs7Sm%WjY#_p>5)yL=_vCa;qJgx>Jdyf+7w z-t_R16maT|fHy0?mVi@l2u{6u5f+3Fo}RGPE`ofab>;Wa>{;H5fAPzxR!F`?D%qrkC} zzf1bt1N}9PmApvNUObw$ELK9%B7{o5I97~g=Hgr>M3Q)Pd}R0eB0K_Wc{_Zqp_>*c zPUPU!E{D@;A6j8OpcZt2S}G4})x>>Kn#N6FX$Y4_FL=a2z}p3U@#DoUOZHmkl(xBF z!*_I6s!swo%t6pJEBq9ywPd#ewa~VRLQ`oGZC-=?1ERD5ZA8aXMH?ZlPyiX2;BCMv zh_t~YDADjWijTyx@lm4kT8(I1#KEX-FUPVb6e$JvH5@y%3&+lPf0XzPl^5_e;RwnW zDH1^mVRxbj(ca(OBH@bm8GCpe!tmaG7xe+hs_ zuU2y}6Aqy<35U?MAsj+$5d6TBUS9+((K<=s7IU3Si@6@+w2&o&3z=C%GJ|!jV)0Wv zxj7lTA?2YJ-Q%KY5!p1@!y$jb)}hXGM}O3((WRt!=(`KdF?y{b zE6`usdvdnv!lsS&_3<0VH{P;JzAD3(kepbVYmP6rr2EIL_N#Z_KXA!tZzuIwwEa8K z^a>ySz={a;k6~!byJ4uhIt(3oUqCm5`cTi~3#y&_2QKB9V zL&x6!R>dRPuAKFT_~BA1>V*Md5G5(6;xGK(crQGS?&JL*{(QX+wOkm3y7SGuLW$rf}R6 z`*21sfp`)FcXVkBBI0RjwOlB5ynKo)RD_*W?M*K6wp(0Yjb3|Lt1T%s`Le5uCgjKL z)vNoqXmxo774@l(yom-^l{UtnzOLHWog6z;A1plloJMObtQd&z>$0D;d7`?vC)H}t zDs-pUdOY=-!JcZz(E1h0c{S;_@}>sO=!id%T9teK9XyqZudsa ztrR}D4kDKo+714K*{dUKEYtxdCgusn=|2c|$Lb5q_d7ZgOgnnUCanEi>)n%$CKe1j zM;nXV;)iNuzk^`<1DdYM4O870tgo>RCA z=nd`FX7J4h+Rj-PQyc=zPN;7hiir(6_Lt{s(t|@K_K~L24u9ovprkQ=-Bizo>udVz zV_Dro-5$NUsnqTm+1k)G>=-*a(A5VUrV=gQi54@U4J$)!l3XrrG%9e!U@Sb-)0JEn z7_Y6UF=}F)9sA32VlsX0#XDv@RjkOTDKh=-NQ52ZKv9-JOMh_5(JG|khang zr)YF|GzT`UG8v<{)IXn`s^~qbrM{+2p+ZqSD{MDMaad1VnNOlsm4s>0>io^v4`bHQUj5!vxISlQ^$(Rs^ zi6T*UGkOxjfOQ1&r`1Gi6h+mT%no6hMxYz1OsVrQMN-Lfs*zX&~}Z04llvmza>k-TOg5^ z7G>LF7BRX3jBc73-6jeyMs4}(#LjRi!$2rywsvx=ypIoWYz(_mFJB)T-u56L-fv0y zTP8YpV|+(zvfO-po4Uf|+m2kv#lK;_{1)clO0xIcF||^l?8yp207(tMC{6$K-0N0! zHvK?#%f=TlzZLg%-=g?n+DbdP{a$e@S+s1>>kt2OY@Z z@j((=cz%zdIRXtH6ACLajN!8|hR!G`WJ$r>5Cw%KDA0D;yD?!7srl+uhKwcnfJ=6e zkJcf_;uBk`XH|N6|G&>Jrmm=X`TxW+hP^746c4!&er0$Cg;bazAv}x^yFqQ;=V;OA zo*L|L*J~Rc&03wOscNt`E{H|*Dc04~Q=99_mXWEQWy!AaR1SLNXCVCwZtw@Y((nWZ z7Jta%LvZrp6nOLEF75%1gBEUeF+}od9>e5jxb>8v$#lcOoUmAEH{2LDq*=GET(9j) zmcP_9V}DF+diMY53k%b#ve`_!Mc$iLmYA4qL8}4}R#*jlpn_(;fRb#8Hk-M1xs(#< zl^<|=rXw{-7Q$2#9&V3@pr`@b5zWv>YD8N0zZ!u;yC`@%_}VU7CYci0+a+EW@vaDG z1a`6zKjRi6)yX3*qtt2ReaHhv4Wt{Hk#oNRr!t721kV9K5zP|&l7gL3LV5Z>xawD& zm@NNX&jz-|Sv#utS!@AzINh>v$kUvUUQ*r5$;wr522@XR3J88KS?&tFnYX3p{-Yn# zrR-lLa={}OCD_L<*zefSM(-&7v3%hQc6k%+oUrTy`Dd3mvu3meJ4^C&84><&aPb7i zOAbiDh?az3+jE%ubRpipMmy0mf@Fq>jMR{fY)d($XS&l#M1CwpL`p*6elJ9X_zXOf zmSQ0cc^{D2fU-kL@L}GnfH#pRN#zm6LAy$ML>F*KgkxC(9s2YGC92;J&FQ45zAMVu zHigaZrE_k{1k|5m&5h0Osj4tbYD3Owb!BC&rpwV46O)&l=dCbwI6ClWey*p?&>8< z8q{zoA~m2R4DC4*Dx1$`Ta;~!q473D-iYR+unb4(DtH3eDT2X7yI&9i(@Tc_QcrBV zR;Q5cIK`Tlza>CDXlS}o3*Oq}H(+hv=-V*SaluBPeQ57QU&r8OcmpQ{X5x=a@W)Jj z7Et1kfbx}zLp#W;RiG1jqU>hS7Agz-3TqJTD=wXcCoihTWNHX|Ir+f~o~fh|v_cI( zSbCp*+`q*mnDNc+dC>pvKxqcu!Nq;3a~1qgabk1lPu?UPVlJ4F)t zucey6>tt&N){*rmMvSz!=pQTrK#hz4#3p267&;6)EL1ksiWYn7u&Z!}=u>G7GM0z^ z^neOh$G`O4{ZaevJRMc!f22g5^RwpG-6 zlZrguW|QBZT~u0BZ_6=$^>a;3;Wv2ceQo~J=liXF9i{Up&-m=V4d=GF+_me^ZL6tb zF^YSRUVei4Nfs{mFWaNEoiKI1UbUB!R>BVYiYu)J`r^|&2d63rwlrFUrOeRyvTGbW zd%@$S9%$H~8XY>A65A@;Eu=KvL-7Hz7RWK^1!Cd0|d znjbrw)ET}m>)=pD@4UaML!+(#g=46}6lk#HJ-AQ3N`4`(w9Y630)sWN^1Dl$x8lx@6p~QCsmD}>~9|;Px?C2efiMWKCIuc4@AzMSWCDq4}Is- z<;PyT`daf*c9;+Cj8?)gX%!pyJcU*9FrXRJmF;?#x@wof89UYA zI}umd;A(ai1gn3of1#=>zQo)$P~cq`KRKvvYOJeEFU-wJNUgUQ`bsPgQ))qGe44eU zyt=y#6jG}ut{(;k-D5;IK^MtrvguJ}o0YPGC1rc;)JO0c z*44{o&2A^BNu-15&{v*EAKsCV%AiLmSEo+fl78;G;X&`3diC(?;J3#541IyMHDjX= zy~8{1@&&5;J#kyu=bN_;`0J;uH3{0GV8?t-rDdQd&|GQlt9H1{`>JI+=R5)mdZo*e zZ39|DHgF`8%xGH+`?>m<&5q4lU3%7k>*bw#_K{)*ACeP=fy3U13#VR!E$evrVvdJX zt~9U?gRrlcV1Bv`8`lbUHJzPFqs*Kpc$#X(YF|e2TSm9zZ^xioYW&Mz7XIfLEntjC zFq+^5MstFXe4md-a|E&(4ejyMxV1wbT#!xdvhWC(#|xbhj(_5B%#(qo*dfI8Ld|(`BC?-&kjCwXhWVkJ-%yWtO>N*06ABuhia8?Fq~@Z)Sx2l|-O4 zZv?c3N99D>2(&p0O13$b#kzVIUst&tw?mJC?l7M2g$VXYoBm%#ptP=vHajGUiYgO5 z-zHsyp2KDThQ1$_PH;Cd!{_j84KCm4DPd;(Mr0INC6?~0I4)--lEEc81%eKud4u57 zKZxc`^bgd9q1`VEDA61#>%TDqrTz&F9WO+nw1Ns~+s_FqI8E!b;A!9we0`py^;!Hz z>$BifyD|!OQJf}!3OhUD(ulY#rCX!Mj9L{q+F)rhJby!lQi6E0zmUK9knRakVf3g@l1ep7*(f}#HN}ji0T|l)^`+&h zMX4t^*Ck-HUA{HP4-YciSSQ8GdWn6u@B>>Z@mJuWUhRzPm4H&O1XT1&K&e**UD_*! z7UegcrW>~g9t*#)K}BhjC4pbqpaM#xDxhSINvfp9mvJ_SMiuA!wwrn8fV#vRW6x)A z$k!Oy9fmdXTNy0BfIVk%EF5~c>>+Ab)S`ZUTl9MI80?@i*--hx6yh<)6c6UMtOeiZ8p+9Yr*n*^Re`qt( z%WnNii{I|38t@u4adEL#e(ykrGu1cj>Ftb9PL5AV9@3|kXA~91YIMmp-L^oNx!?CZ zOD|nfTT@ z4bDh^^TX7mzsKOC|34y7(s=@ZyMn)tq(p&|&ijx{n&3L=JW;weiW-_70^0UE%?=%u z$6sM-b9-c`5+)=&NO;_2JG_6ET9aFFx&AVzGe&)u{E?o$rjL`VR%FGcW?Sr~D+^XE z{3Z_O2&e}(DTT*eC_Y6omv$cMA^3V8TM_UkJh_OMN6ZDSgC>F_<`NUa5*q~_TJZrW zEn8d-4>Mn7R8@(U&?`%a{Dy45GNSJpJ!;sRH>I(R=%IOL7m6^;`3M)L*ev z{+K4T+dix8t5c20em$_Etk^B3vXFmB(Hb6o4Wi}f-Kse~{TA7+%94JJ8e%VJR@wKV zow^8gjRGY+m}N>?;0f<7piO1JU=5EFj6=@TB!a@aCrU)bcNui{m{6rG=T`_rDPaJf zIT26hk>jPmMIy%(x2@-Gix}}5ira!aeqfA_D$Q}q#EL9p9fVQDI%at9h_2O^N3c(>P>jX>KzRw3!0!hC{A-=efSr%33=Sw&+~jgWptpLpy@%ui}r%)~XA ziIVCbwoB4Uzea{UT&E`PC6UK0e3Vdzkl2lnBnYvUc8_Dc_F|`6Ygkzu+mwrpK`EWO?B-Xyv=y?l&&AP+SiR(?U3z{ zu_n;TdM(+AXdx4C6<8w@s0HQ3DHaIfGb?A#k+7J#pPglpZ^^BDBsfvoa^9-@9uCge zwvRrF6CN4zKP$RjE!%<)`&dImBcup6C*edJz=?Jc5thzI8!<`?>dJlWzu^1gjJlGq z&P(vMjwyy~0@_y9!Qa{@uU3HpRB@V0x=hQY~ygxNqM6Jp=sWd`zC*%8+2y>P`Zy=0RqDlG0nZ(rV|v>EnN!nwLL+<;qlr?6*&p|?tUCFv zE9GZ#vPG8bSn0)eyV*LnPQFRLiEq*HzrjHp@;&K+97La|LEpSS8~VJ?!dUsJ=>&dYYm>AIJ;KkZpz<0 z<*sp9yKMEr?q-d5#9qywAB;{^&rGxw^!WU@4bepnl)N7w(1}V8$Gy(~^(}#6j?#X2w?j zJ;1;7Gi``fYgMyoIdZBPHWlU~Z!2j(JGkF@nf!f7J4d}4crdT%EEVdV&haQWI^cUY z|9&=pr?VSce*QzE{eS7(5^*1QjUgHde^7XWUPzO!+R&9(mdfV6^2M1=mES(WJ-5=4 zQ7FwWTv}H4Bz(f;Y@(gx;A8O?=aP>ySYe&yL&DZT!sv{dIg)3Frf(w>Tztwi&#$ofRX4Ro>Z-@}a@J?^k`lbJLpM3)a<~FxzwCx*CUV zB%4m5c$oiL-Dzf{s$obz1nU7(6y8VuoW^kkCPAh}b@QytM81A24gUjEv@qM#{rW2i%sPLW@{nV(1Ir;%yC z5jSGcC7ey)%jPa~?w8*$wiSn2aUTLIVl6PC<#?K3RO^@vfz$U;)0DmC{MGk8+_I^z zYvf_%7W}=u+tqt!tHZgbwZ0Mh>+e8ECpOfOH%R21LP1C-BI^qK1-6$L{<+`jJAci5 zY3G*qGk3RatnD1y&4nF^r+Vwwc9bcIL1Pe@tAUxXSHrl~AWS3F3BCx8W;(%ez3;ka zPUbJKPTq8VD^Yp_4oHPamHs1R;RZo2=>0XYc5R}v%8ojUE{7Z4vZDI%=$8nb>mfajYgpn99$hpkFAjZKGe}L-&5|W z*$}Mpq1{7hw-N2?u>xUDpwUJqj)`U^?-`F=N19CD=j=>6WH^-74ZYuJ9UAkW+HbV@ z$0{3|to)7v2QC-DAFp(^ckOI<)Xdg-t1xqw(|a1N3VH!NRI~*~$0*iH9{Ckvhof$R znKrO7!;g=16%6ZhION-oa~}*^vOqVq^ISc&)TA?dQxrCa92s#zi%6f6Oa}ShP*W4@ z!ne-!?kcIa?PI$f4z~L2?R53b>D!{Op4o9O`baA(Wx8uAi#+uG8*VgE?g~KpUe1$I z)F%m0s|rK*Lu6+v>To9CG8i?mzZhiiwa(78q~@{8&M|EaR|yTC;*N&Y4si}o`jLEp zW`e~SQ4BTJS*6MHeaOyw8+=*IbtXmMElYP1SE@@e`Uv{!QkI<1Wp%-(JWuD8tGOxP z-8Cui#GL}JxEl;>id%h^*_O(Z*80jUOXAGT_<_qd`&O+Uzx}cke2FxX*bs*4CHL$( z-|g>dcF%tM+Wzh!dP<)APV{*NbT_gJ=2GBli;P()43>^Z$`WzkNMl2Bvp1#Fd9AZ8IiY@_a&WZuEcq1!``D1EmYiyJ z>*<}1O_?RB@_o`{^213<75S3`Q&$a3cRC#8L1T9oXIUvzRM8e>6&`C|bsE^!2Kl@j zoc=v?CqT39-pe)HUa87%UwBD+RsQ2tZ$!Oe{C)$>(A%mM$EF@?p+t5}WA zWU|T6vD}i(%o6z>_AU7Wwvjn7dM}$87B)JuEbbS`z*_4iZ^VY6AZ#`?@61Q`DIRsN z)ke+dqtam6U3ngluPCoRFFwC6ucX24%r0s!jL+!&-Eqz}v*L`x%*<3zQc6Z)Moyk5 zJx6}w_@|udMoHN0;5u0v&|@Zs;Uy{>DvxlO;%MT#Z+6!z$ z99vNcoB5k8rh9n*2Nk<%CRS7Btcr;V25b}DZW8Sr*LV^>18fxd-NI)G>50fud9ID& zLv$x6b&_eO=wYP^aQOR6O|zP6m}WBFudsZ*P;Pw!=iPk$5FQ3jv??A(JJw3Fc0=<| zxiNr`3Hjv;ZOfyRlQHbY82RVNn_4|Kn|%B6CllxtRyWb{7S<`9CVV#zBqRDfS?H`3 z@7FO-h$*L>&_8S`VJtha3sD8$7UC3?*n9L#Ui^<^PB{7(&MsTtpD)HHOL}(=+nkv( zc71Vc-SA1iSY5%lCi*5VJtuhW!vRxcvtz_x+!DWW-P)T@ZP9C!^U_NkIa+=9&TiJV zX7y0hX;V&IN4Lh?)K%WT+S}SiTCEZ!t>n8^*>n#BY)=v7SaSPa=$Z$$9`wj;d+@lRpded%RD?KG@P@^E@mi2b`6`QbWM$>M~n=K1-!F=q!bELib9mBrD%B zLMw<6c-39o*r|H?2N@?y$(9V6GJixF>~~1eVTdsW!Zll^?kW(yLa_QN$Uj7?z@4n0v7p&F=(I#f5ZwJE^HYad1P@8%WZA9BC$h|~j`dHjO?NtyHWf*d$&n?`W)-d|4c4;Y!XkTB z{!>$XQQU>M+_GZ_X%yvz0>=GwF4xP?4@76sBQDM3pxC_&<>$jjeH*>m%zMLQ5!^3; zX`oIqay|UUHpjMu`@ZllTqmb_S?p!5JyJ6EMGY)g{#^c2e1h)-i=VOuM6(gmK^!8S zfue*VLix*KQy>(C_QbJNHmGXcT)cP$mUDFWFNFgrBxOo8BxmKvU&Mw^am;_QE7{}J zAhn*|1DfBL&#u9AW{U`oN*hG&b>SPDGMGv2Rj=4qzb=W@)NgaF)ysc$uGX(ZpAGDD zRR6UkJJ`#TY=h*M0|V`>Mr8!2Kn`i(%h88?J=>xm#vgpJu|p((Mt_mSiMVsVM$6;Q zEQ|@(W+vVcR}nb|LQfSc{;CxMQ8+y2a9$-%%Jw((7^0E?ho-*cn%!1}sBxbSl6vrm!+2lAS zCDWuIHj-Gf_ZJTB*kQSac(D%E3s93{7Lk47qhl7|b|)p(57>jlv1+rUOs(~L$~tNi zypC;$6B3KF-F1m&Nl!eHVvMgSN-tc=G&~0a{6)l&XB+Uj4!T@;=`dh-%<~rpoyZX+ zaa(8)a=od>F(Vc$Ag8x)a%{Q--6_h-GxQWi*XLc5ROR3W$=i~M1qFP|IVa{FryS0A z|N9`P(C?m)rnPu`{>Xp&@GtUmDkOVU3kh>*RP(*toL3gCBNETyK0ps z7|gdo(W?2F2wlCBV&O`p;%H3laR|Gd419ZLM=v;a_nuk8$93H@ob>!Um4qRKUxg+R zqX=PBA{2tO0mEH_wC7JuZdIs*7<2O2Lb}`{NK`NW0h++cv1L&nHR9AT^3hqY3rbSF z3U+O%+a{A43ZfwHtk(-}JSoZRMDB~T(l*lX-Pl=bADpaiwF`Y=A!VVFVhyhb>gpz{ zEIsXIa75vog8Fi_N7i==Wl~2tt3@b8c&2ESmSM2?cVLNoa?rcETfUPkhL82hp3&(v zZ?zkY3D(q3I6^w&FWoIU<}{j>NTaQilryA@ zV()w$JdKBhU=Gqba?smZhY^v3g{N1dR?0$HIOFDTB3)A6B%Qnk@bd9|VQ@@!AV z_Z{tK?}p~zY?F8X()!H9w^iS6@2aSKF2A;{VU=^?(3^3ue)6fg)oz0nV&@0_r}Iwe zZ>x~_&|U>nHn9)_Gv<}f6t+|WMtBZ)d!`kY$F*rg2cI10bm}`29f{Y+USF=xKhaMI zqgwSP?&5tGlaqBu&Ou3|Wu2X6PwE;Q#UZK3Y@ST?a6Nh$CtU^|L%u%E1rZ92o^Ftk z^?MzimrM+QYukC3jIQ!_U9wTn$_&zF^1XrD@lBSlmh$p9APG2iMzd=S93yNw@Ip)& zDRSN^c93B*E3pF|XF3zYof6r{yT0Di?JmdLA70IVQ>vQ;4{IKqx zW){;RfAXrHy{-RKG5-DO>4NO+f~Q$70thj0z5VuEZ=q>C;|3B!rz0qG03T=P(vX2k z1QJq~NlTdN*kzp1rdiWV>*?O~s$^}dGrhp;4Vcrc7a#L9#TQn?isa1X_;^d)isX!x zmGURYIbK1dJrAzTfh(}Lm&XjAo2#BoWGmvciqcc`D-+Y>gB*>ahy-$?}Sd8IVT~K89^o_IzfJp}R=_4FrFw%}T4#4z8z`8m{1532+Omjx^%Y zF@tL}jfTOZz)~BJ2-#Q>;v$BY zq}quYLhH0xF?poL#c0KRV}o-#kun<}A1HN{f>{~tgM~x;9)6fSty-m)n(KG$1@T+| z_?=2rx7NUR=wDfaz=8=LK|wKvJ;3a9YB0KT2e+Yg= zrzk=T_W+ZQV~WHRzR8-oD${0|F;2OYlNtu>&BHnkY#L3B*InM>C7Xt~aa_YDN_T2W zcjEh!7yzX3|M&C0ph0*N$o)|~iFTNOyU+(45JQ9)g1s2JTreD8A*CNmmgGn4aWO%T;rcvV zwrB3Vu1&kjf@8KD4IjkWR`uCeO*Y_0?jlcNoFlP$+C6vsSuGdt?7j5V{$z(Urv0?5 z`Zk;#IQi<4#Y-^&;!em)u z?^YskBJSiX0=Ez=LX4fzdc0}j8yS_$o%m*Ey33npE>-@dtW)Havg(eL8y(}m>Yj?) zbxmbM?HNVJ4Gw3I#olN0dTYmBuJPL9u3)Y)zjr**GwXK^*XYw?ZS5BCusz3KP+sXa zHI!Q$jLgnhS%=-(Q<3E`mYZBTT#i4I8bO&7E!zq1eSra&MjlJ^LQVh-3U%#Ub8nb zg0zl6n~|A7z2v)UNrq2yw%x1mDs>hb%C1U&(O}Pz5-L)i_JyGgJL)1XAdQ1o=%Vd@ zHO0c{3{of`mnXzfWX!0Gi?eiX8|ay}vd+%2GmX`m!@GL@!xf%WCI(Lo#Ix@kTvLNx zCSy~t%iA z>o>T%hW%TbRt*FU0pDy*u%~x@dUZ<6eA~=S&Dwybf2`*M%E%aKsTgZ+XmJ#`~MOR`>Ve=;WN~it6^^ z_7iG@1GQTUYtLop807m;&>Oe6&#WrzJ}I$$v{^F{Y}(ct==Ivz1rGU{ICo&$}d|)Rng5!`G)Al`S{k{m?8V zzX;TOL47{<`D(<7(*y7+fjQUp|^U$+NauD)!_DP8*nJ9aV@`kPp6w{{6N&L@t#c6;w+F#uT#3Em#9hyRBab&XgU1@gWY(6#L zH+@F$=2LR(rp~;+?X3RYt!MN%wayr_R^WQsEcxZ^FZCHK9m9Upix-^czxccp{1@!N z$0mC6TZk?_?Uio8#uh?xCY?*H%i+e0%ZCbZaI98)`N>NfyM^s8D=&~6mZ6Qp%lo?1 z=ruvGpnVi2kP#6PXdj_GLycX@GGgD?WI4-9t+lz{x~{&I^28cvMRAIyxYF$}9A8R6 zX{ynbl9Xn$msGfdKDDi~AUE5Xla!X_sC)hRRF&amJ&gd!!GvB;6fuBD>f!gfb+|-& z=}4pw%@kUzT;7I`_5@3;qq?%#S!s?nmzOsB-6a*Zg-Zy$uFIcSrq{VD%Bnn-n!Mt? zoFYqcNluxkz^V?|?_NeC#qfljkp6-tw*d~J7mgv*xp2$WvQhDsr}oD7ddlP@x);9t zvx@m84c{_T!kGMZVac=4%FBm6YJ3VPH(kggUl*u39dZW0i2+Hq{T6qP)tlgWHuk;l zvJ_86Wl?Lr!&Fg|yM%=6tlbWqD<^et>6Yr{1Uaxb^Jc(Am34pOK37zn(9ldN?T4?N?c`+-R3bC zb*{=YYH;Rcc-~#_Eh#QB))-3*J?+0-HZqZ!5>IDTce+TPjXG>BEtQTpEi3%v$QU@J zf5$miFD+^Emd3NUJB_7PJI?xW8TN>N$V9BjLD?S4n1sa>S^~!g@Gf{D#)n_)t&G(& z;mE=hT1pDb?5?JszPxm2ZF9TB)>7+qw4_xOyW9?==dvZVU6=2(7_EAJwYj*e(UV&c z8y~bgtfjd|YfXvKR{686oT7@ntm0C6%QDij7KCJ08Na5mF7-m0U|U2-*_nLq^EfmI zVi=bZyt!tO#;+x9>G=6uFn&HW#?Q5cV9CM`+$Yuy3*KQY2+7AiP~A28pH>7Qx6G`0+`n z)ok25dg6BPhP}P*yC$pFO|GKa?(ktG}JI=m+)rsryK7HbPDcM`^o*%BNvS|wQXF7~U9-FWMs)k)mw0mH9u1BCLdweCLsl`TOlECq`RC-nn!J`e?-N ze4lCv`zlM!q>!@#Ux}PFxPDmwgjYf~3-<#R<0V`a$`<*RYK7`}ePO_DEw-eVR5)uA z%TxM0>%6(O){=5(mgfD~jV-kmC2oIXX*ui{+A@OUf~3=N44j)V`prqQq`WOG+^rQ%mI^*#l~8fu}5|q}WoF0~<$E>8UEKaOw1A zdHyc;Tfr`pDF%K~elzi_U+@dthdSvFl?D8Wp;$CBykF$rn+LXlpGV(AKAhR*jrn&O zK$`@7*se)=LB}*@9#~4L_ugcY|NPB;zWGNEG0HQA*w|hynK}R2xNq#<)sf!CU^H>d zU9>cy!#e3sm6xOeInZD7TZ5_ht)ln;q~GsR`O$kVBEDa8xhsH2)q?%D5z4P0UeaTD z?ih9Wvmql$%VXOfm`Kpz!YB|DKv$>=*&v%C`fr6ZcQ({im>l)3MU@qvl(O#kVxM&+ zc&#;VOaFHpn`@ovd3`-iE_+#FiKV{^PPenR#Wvyg7#(g`aei1Zd@h%|B)_Syz?7f! zX6fG49GA_}ZIypwb=ZUPu?4xF#;Rg-wO(&ET73DXl{RBZjn$a@v(n_wQOzyy`!|UD|7RxL3~4BK2LdI022>SWLo>kRa##yiUEA1P z4|N5GFSz!5rCVV3S*+$y962@s03 zU@om*yp}z{%zQ1oQ=SCm+{N#)K32fjG687^kb7n zhcb6^$ejvg9*~E4t-BP+Uevmam$_SkOrgvzyv#iWfgW82$lc8Rf7IaiV{7mMN65fn z=6|8V)uFWv-gY)w>!H(aPRcxw1S7cTja)zG^*6z@KBsPtv%Jf0sVeVsR2C$BlE8ee zP5o!I?(RRUt)zaw$?foMY;?Pl3YoqDnoLtR$Ml>tvRjdT^b!n6$i`&tfjyaC2vDmTo z5GYfu4n+phf{3zZ@W&`(KULA05p79A!RqP)OO4$0AQ`zr221NrR^}5j=&);yd8;~$ zj3HCDuoMm?Oj)K<>>HlridCI*d$5LowW5C1^-e6q`J{3GN*1g?Ye!Y7(YgKXMvA5@ zHV`7|HDo&@=lSp@ zhL}eU@_({Mj$#W7{KEIL{0u9w+y493A~Mx3W-JVuQe@2{z>F9oc9m!c11AD> zim5|hEAl3=)%54)pkaj}(^qY1>^8@x#=D2Azc766YmZM!>hrP6%EZLVs?YSdKk9gN zs?nS3si{uRFHLsj2dY-Pdn#-#HB;ud^rjwGt?WWCr$5SfMG^9Gn%M!ZO>=uV0wiCN#uX6f{Hww|7Q zTS4!CJQM$}v)NwavMeV9%adU&97m|WoV={+pUymUKd-pBD0i#8?0l(!=v%l5Cn)Ii zb@r4>r=D0`L*A9V2aCg}6#I~#e~jpUZs8#4RxiAO$>x#F3f(H~UNUNtUO#1ZRq{{e zIfJI!aQKTjO?8}PUiicHHrtCJR&#hWY6ikJ(c`(Ox>O%?m=G-F=(T(GUtMF+?lpXM zExi+(%{JS2ZMJJ|Hqb^pu!(}U-x6)lFT6-JA4vv04sf&dJfxVo{`|uI_|!_z(~~uh zz*}ICaSQ3?1sWl<^z!0beCni^4}XMD^}_EKua?}>-$K3Hp!AH6M#z^K)TbL3m|kr) z9R2}&(;*k!!Mi3Gs3(uWMP;g=;?0X4PPN!~`)TUx)YYp2zh`kZhhw*QoS)**-P3MK zU7a#=+ij~y?*nD3XHjzxl5I;NA6!aQBkWSZsV2cOJ_JEeP!e!tC$i6kd@Tk+d8FP? z@J-^B9Dqm4b2T=Qk4TTQK@K&XoP^kf#8{Wp6`PSBw*@4=STQ+S{>dlhlam!Mf?U*o z46W{$ibd@vs)xO1;J^p#)g>hAQCpv$VW8SQ~?p?T&6r zawP<}>_%(1g|OGT%Ia;Y)NXY>+U?ljk)B>nk-x80U$PU~7%b*zu+RJ@#`b?z@33=` zHGsXa4WxU*1_CS2F=ka{(c3%5tgN!k!tBiQOw>4sndnYn+NB*flQ@{Y8AuX?lFHDT ze_C?I1Nw)0D|)XfzT!ds!@cEw%#?j=tDdT)on{sG0TKa6f_XzZPfN zRg#s{t%{mo;XR~k59;mAsWbrFy##M+@ z6UUu#Q51i}%xg(aE6PkuHGaeFdo8*@#fvkTgFehbv`_tso_7f7^J#%ZJFj>yBp{Fj zHisz)R~l2FPeGdce0EmY3@vCpD>co?)>H%>sd+BUL*J?iG5@VKnE#m#p4Y(uV+y1P zlEWm`S8P3>m#<)DBVBwBi(zlEYSP7I;hke(QwA0zo)y0t_Q@BK-!>cAZUfWOipo@} zOe^(9eoNI$4t?mi+{ItBXK0;(tN|yUq5gdX8I=sDSKpAj*a?(P04+fIi!eAuh%H0d zz?5Qgd!fsm=Qb8Ll;jo-S&Vf>#m>CKU`ckmxBiXP?9{BnilPcfdU|GNk=dA2mJYl( zVAffrLp9neE-OxESzP0-PGfsTwrs|fPe>8vcy)D#_7R<=s zikeO09upn5RACpHnYDX$xTbW-^}|Tj`+}%>*5Y2N3pFh~kB0mo3JVEi3fk>B#uOWA z4#veB+>Hir&}>Lenji4%17t@f!j7_5CL~&`{D#wDLwW1m$(7|~Lapgv1q*6b$b#zX zCJTyaFJ8QqeIk8G6etmGGT3z3CL$wFgfZzsKwjbL@URt@rtDyIh=S6%C<>B#iGsug zqCom^DG7ZuVG=x!tj_MN5(|xI7x-QR9$=kqLW()1B}pavPw)6NR(|~v=}^UyFDuk| znrL*naq&cYbr&zjN*>u-p^mM9ImlG^4IsD}t!D=tRJ&?RNC@BRJn`LF-+yMK|& z{%m?_0T1a!IXuaWT`kIuo4qMm7n1HMc!RxB@ZoohUzXTQrav$Ip2|JIlG(4+b?`-0 z!%4<8Q_=&h@FSKimDyyN7)!S0g(@)3J#;deH67{5Qf_!N6c zT}#<#t)?WGR!U|#3^-L^A5ibQLViX54a(erGS`VR`J-;1*YV{mCVX2uQ+ls z^v(mS57=Kg9nkJOIVC9Pa+@At_AAbrzT&SRNQ1Y?{YLh{YLwo!cs;24E2u(gL05(; z$&}^-b-&`p*4dtlc3;doPv1JX-qPLPFw}=H6^j1$zL!%$jRQc>8=pX zCqK^){QSuCq_3|C(~g>)dSem@Go>e9L49EQpWk)Yrf^4$YR}K*8Cri7I}JM+!pa)L zx^o$vP`xv(oeGKkl&2Qp~c(er$^TI2ov{$6gqzqpK^KK@2MEdx) z)gwQk7h(S`I|!?l;&acctkNc|Z_lb;!zW`l_7QtR`iRCu!N;Bu2BP$lVj+U*2N~|{ zf#vb6YPYlr_}W!HQayVE)P1#|sQYR=QD+1+oxLHb+v!E}v<_|s|B(Os-P%hHFUoKK zZC@QedtQ3#LFpV-VQkqjc z05(id%Q&tlsn#!E&OTAQh2*sIOlFbK%yJ%mwCd4EPv`Ha!mEEpu9U5zPw;31YJRGA zYf9ByF?L$@)*li+oU`KRfkiGmC7XHq!FBE?A`#I{woz8hX+~?@Y(iUbcg%OJ=D4|} zj#`bL-LJ1Ks%AfFX|61*l!JYBjHe%JRI!V9NLA_*vP!THVlQqS-lh4QPjCvwb?cLr zPafR&%KBA!z%?6~X#Ljc{XgD#YJ1<4h6kPFp5V|e)9Q1NoQoTphC1seUE02;zPUEpL2B#*E%jhu63LT&sixVM>^~_L*ur0 zlEz);T37c%yFznnzAl7_TnOo)>~1=TgA6@}Z5q7LCbdg1xxk>VmKFvK(#tE3?AmAF zx9}yJS@;9qT+D4?ZkPOcz5F=!GxWlIJaR2o$b9LL#;k5Q4D}1?&|+~{AKA5}&7)d8 zsx^7?I1Q=P@||d78taJg@>M~|RSaH4{lR!CEq>u){(dZ7ekmJ2a&mO?Z?B8+Eo9b>4$ENsMy@+?rBU9TATDAV`GJ3~}R}q)`vG9=Z4Tq%Q zWB=ltuq^z-puSIkf*!c9?z!>4iX-yEmi8+Z6q9uE-#6g))>Q4myI_)qY! zARN;}n3M4yOKC^`tXCHuzC!6AqI&XM;Z9-nYEiEa^~C8A)dpUx1miB^i-&7Nu|%yQ zX176pRQVbmpA3f>Nnf0b%!3_Lu8_|S-V(@A3}mT9Ctn*U{fD%0wL!XK#e%^g{Wk7~ zs{JeXIS*hGRvkc)F6Qu9lnl_!<{ZZJ{AiuR0{ihc^oSm-clerfpX?@A{Rh5CNXII5u6y-Xs& zdHRpxVs@|2j62q77O=&y|HxvjMPI)jA7lDDvH3N3S@jh@`S!5T-C~o^dUWMuagQu~ z5*{3B;cf6ER~Z}C1}ktOv|gy+g!yU(l$Ru!RS85!_Yc)&H-(FOQG1 zy855{OeVuj*2!ctlWo?_l4RCQk_lPJPIdwb`%WN$fC!|56kJe2Kx=(l1+CTBs&9pg z(oL&X(JEG}w$`QPwRNeqina7-?OWfn&6D5v+~;{FlLTA;eEa!S%*;H`J@?#m&pr2? zyPSJ2X@rfyw^5cw;xh3=!pA>Z@wqD@Oy~2Yh31zv9WrJ~I2DBQw@P!i8x^xb=h5mn zMSH}#%VsVMIP=ZS7|3YGtz}(!R<9Ydw1c^3Aof5 zC?Wm^8IW-plWZN@=_c^Ch~pk_MU@iBioUB?rwj2b^unQx3-0**40S|*;ctSlH+mp^ zu#(%+3T{VvIhC9T70TG*M(Bc@6S}|+C7^*afI?vP@y!{+oBH^GTG$wfZ-!3z#(nko zRUF7X_{M#a`$&dJCY8QLt7woR-mfGH8a*4=rKULf=Q*D&M~?;3jdn_%LA;}YMa)x@dd)w;e*S`9vt4)Y z`~36A6MN9-V%Ko&6Ri!6zr6@>ukeCv1om#-^$}0it1aTE=N{K-dOYX8uhTT2_sBXT zd?@ptv?Jns(y09~KZo9G_v{m`R&5*)V{*AqW@oi4+HNK`&eMiqd zpw%pPp1VIov*6rH1)+lZajNJJMF?c$s$ycdD$HJnPO3+JIUX~7Nu_-!I8z!64 zJGu^c-jbC|BVE-_23YQqG=8FSRQ&5gPgVSplj-p{1olF@Xw4QLN*97^acVj{nT6Y) zF5E9XRPj>!OTiPh;wSPCNR{|??6_Wz9aqeph8>cCfIpt9C>>Ujx$0o+#h(}1P1$)y zlilVDSVLtIleNI-izHolxePF(M{k zbJ=T~4yn*Wog$wIewx_$g|T2^ zVR}QP>e7WZB}K6hi-OsqX=`@YuUXVM($_ar7RkiEa;#>-4Lv*8c7JuGJreXsebIp> zr7lmXZ?WHlWo>o*G;C-tqSF*(jm3>|i6WxXUXxT=)SC`3()d+uNS|>q^4C=hotH#b|#C7g@rkJX1;$;T|O_@WV1QTtrg`Jc~%p& z0yKdW`pbwO5-urnnsO{A+biTx5PnnR@D|JMPK@fEHMLq133}~t=4CK7b#u_S12T6# zWG)JsQ(8K~opD?Joa$7qxFfa57b&)u-4stPdsb~#;i-^=K6xJ!<32VW%ZWf#7 zG~|1T$T5v5SqCTwS6LDD84&CBMN1<7NI{XwVdFYh*1b*%BF_XI)WN77@?5S`sNUDa z=dl)3ZY{OR!lO2k4zcuiL_Nv#%kMWg^^&dl6DMlC0 zCw9W}V`qE>O27b@PX~6H47sT;w?lnNSu9q@_Ry!ehCTS}#KV`%VmsLgFl3G%6tMDy z7?KO1r{see)WrP68T5)5hj+(L#nF50>Sp53mV_#x2Wbmrd|zj4y|eramiW3>@0h-5 zp*7m-DX=oE9b`r7t)=Czx^AzlEX!72>@sL;f5qD3?CTlEIXe}G?_MTUV8MlP17!4GwZf3tzLJgD^2h;IBF_uHZF_>-NlzI+0x|B4%Mx`wqwb{ z+Tp$T%xRkKGaA1dowc~kvmxK8q)uai(*>9OU*s-&v zt2k8N(3qFq+h04c%VVpE6jk??wM`4RH+ViPXe-s&8>(}M8cWKGb+)w#y!k zxdXn=sG|v*LA4z%d@0%|6DwnqPb)TPc!VsL-n?OL=l)g44uo%7_Z3z#v#VoE3nm43 z#Mhu*W24XD_PJlHrm8T1p?Z!TUG;c*eG^V(a_||dqjD{t>_uaggWvE|AfGIsU)kCl zoDnH#sf)GS`@1V5BbPMx*eff$`v>xBE2sAqd)mq?{ZlWQ(lyW3Ue!H!X4C9tn!dWc zu(!Znn3h%?aMbptXN5goOUnA9DUFTO5L&~c&-6yD-ddM7yP`DO?PKM!Xi;fiW2C$c z?NxwS9;;9bI{VR(Y>%WfuyTv=<;ocCIZC!?Qf%K(hqu(M+S|W+bIqz7I@`W9FS2A_ z>*e#pOGcVo?6m`t#zwzCRN7c>iiDzFxn-71()Vp!d31Nr{%tEC-rZB$zWtl?H*9I& ze)rttMmj=pw zeTB}B(ty?0@mOqmlF{K;2-V`JNS6=FtJgT*m?&QTPpE#cc~wQIr=hXX*;X31+uNM2Lsf&ftpd#hw=J2w zt1~zVYP&;oSb#;#oYhej?>6U&+b2?5I~W9|HFK~Zi1?ovEg|kZ+8O)r&4^`!skssS z!M7PJz~uda0px8y?NncnKbZMQu*X-MdWZOE%e*Q7J!L@)TZ4f2e__kSC)BJ({1(ep zi=VNn9*_6}aKFcn!DsQG-z_m1Yw-71#_Ihu$}6UOOCud#cC0>vmFl_*d;}UR-QMP^ z5;t}<%Gf*PWd!-;z8S1+X=EjP=Z}A+J)rGKyNvdL)OGwkL#n#&5$easu}=ITUnl1K z2FEd--zcvGtDaA!(QYJaO&cIA+#Bls2L9k4MAsc?>63VM4?d(76hMy@b2yIP!;d>~g*Mb3{VnxsC>dtQvt9QbvD+e0#w$ z9rwEMswl>i5t(s89HU;A@PLsdT9Ri-kk)gs(|tqmV25W9d#@~1+7T`f`Z|5ZVQXbk z#JW)b3mvGtPPh_E>dN?a?24&XO_#RTRySVS;;CL(-5XmN>wenrCvF8sTZD4hIn;1o zRn&wXPE#Pr?hPJ0cFQftz=OG}4#6O7g(q!3`k5hWav|OW|8yrmu-@k%vsjKEwTK_c zKRQ@GpcKV!%L zXNb?`!X~lacGi9t|5N`;0TzlDKzn06?#&2-M{+c3vyA5lM~||*#H-jm@$ff(AfuuU zGYQ!XH;!Q&eG1sXurX{ah#o{*21*$lTPUt%cO8BC&O62LuxV#(XV2Qs*bxo(PVptI z`%I1Ba^#HttR4TWFjB};We9D+M|a3&V!V*YJ>ki3lQEOF%B%&Gy;TmAn}0`5W<%6K ze`-84xA-y3`}3d6%gb4v`23+m8wdC9_$szWG2Yw;C|L5y3`yVYic3y58YJBEFWGz)#hFRlJ`HTFQe#b2_p z_~-}BQ&J+5AR@N*r?_Q>Vr#e{4af;iuc-P-ann*$pZcx;ZFk)ax4(bgv{;WPI;}B0 zySb#Ltzl(x#BpVo@IkzAVPwJ7?rT@Yf-|est?dznP3xZ3H$7$UTJXlHx&}IO1io7o zv&st(1uVr%nw%RKJ}er4V1VQU!K=*ZT>P6^LOo@k_QjPjXP)Vc$}1PvG&kd;<^H0b zMXYRwcZQ?rszPx^uea|ZUst4JL7P|p@>k5OX>5wlk4u{}cu32qg z4Nmy51KFasgA>ydoIr1kVU`8k4f}!Z#-Jcs_i8?ZgFA(Cm_6CN{|pecJj>M9|n zXMW32EW_PZSKsTJIx=V3S^+y3>glsv=K0Od4Fd}t?N=^R@3^qLXVqY)O`oE*Wi|GB zdZ$`k#YKi3Pfeh1W$luIbc;SkZ_b$7Ro)%2d2NQga6@U=D)P9%!q?%cJks(MXV%A%z`yY62!f5qD6DVwh+*>Pa}un47F$afxPb7myD?%?zX z_Vp#D#{Kaf3M=VGwkAE>o$s+sjdZQHMxDD>&Y$lx59-7_29}xgrt5?py|~^~*w#3I zWl>Gp_3Uf@oCcpQYMs$FW$wB;t-)r4UmWV0?wn=`1oCQ}kyuAl%HsK?Ct!JnYta+a zuq(n>z@eGBXBa$Rp!%hV6J(PmUPTW)G%(b;qwmXg?K^6h#agHDxKoEa1maKAr|{DS zb7ya^6aUCM0t?#P8@KuWU!w?YYMgJNn?OmY@u=`peBO(dQ;2ZAdhzLlFD_ftxoggW z#;#jBR@d~;`j35n|32ZTvwLQ2X%gRJ^CAlyr`Cg~7&r4fRygG=uvMne3vyjF(!qwY z3sX}Vn$X`e9-KB@-{1G3j{QmhBbVDhV@>^*QSr}uc3RIIwVvLZ=9%F@UT;Oktf^M< zSL_|wA7S+GC;=ryhvIY$SuGS1!xv^J*bU~KBDVucvR&!}TSFTjESajiBC;)eUW2&r zz;9|Qw3?a0nVQsM=fhtS%0*3OK&XoUvad_{X+v3VS+009+n$3dUx{?563wFM?w}%C zQyJkwm$->j2FB<*nD$GCbBeCKHE0R1o;j`4UK4Gr?)8<;sARY6#V_gDT79T^TF>%n zSADZ9G_yj}+*Y@HOV^i|*(0nMW=*YXLOHka7SU4V9%>Q34`!sPJiPxQZ$H$D!+Q3J zUVLRN;`x6DgFCTH`vDsl^Bk+65qL0V0WYlNHn4|m`-iK;tAC?s@984!UD5fL6gR1# zsfBD70VMB#{W|uTj;YBTPQbixiIC6!AY<|3HUwCXRfX)4!u8@w7qc#LvSFuq7aJ}n zZw|GpSJfyO*h?Uxe%1;hIz?i{khbyN26+L@Oe~cb-a%_z`S!OH!9S;U? zH%h;4L`jS4+k#W}bWiAsjbo1S^7c7sXi*giPV7aL8I>`%|&xe*e~e6<|>msaAediEM$Bma-|(AYYgst1s55@fTyb|2 zoAA#tgCZJdfezXrp;bPUj^Xy}xq7UriC62{!Sq02xZ-H~(cqKhk)g`PN4t`51+9?F zJ1gu>u}R0CNf*~{N8kH;Uw6V+UoBfTU6{01QnuxMy4XC+R_84;LDZPc+X)92~M0DEX4NU`FAL16>R>_M4# z02)#@53?eQX-L7cdmh@Y7mD;d9tu5l?8pK1?BIph)%ZgWK=MdVTrPPxLD(zA_Coka z>sENo zP7L9Hs(&`dAhoT18QRF^A=A1qSNR7nt*xBf;OuM(&a87Zq%D}yvUzT}-krK=XMdhQ zZ_#ywOKx9ZTNawJq9WE_+O@p8u^sKgu=aK(&b(#7qS|l<3HEO=A%&BVkJ3hu<{zX# z9I8e%|8RA%-{YvZJME5`&3QoR3&nrH7KW=sloXobbynLPc6>P5b@f&CL-qAlbt~(A z;r>vmJ3KSwbBnkGEM7Z>zvckmNPn=bgafFwqpyMH2GE?v*RmrxK|M=3JuR=1aCRk+ z4fzIDg0GX=Y5oUkcYA{tf334LR1mDQ6h(0u0v`oW308mnq*zjJE~_qfdriJ7OKwRK zwxx_EMF#Vec;l_Ixy#L^)n>fG``nVkY%{wqR^#by@l{WC_qF(Z(OE&CvvO9@>y)b; zid(XdxT#2 zQa+cv3Z9wIsV-r36Z#|YAH0iu9-h++rNWN*ZgfrLTrcbr z7D+jWjnI{94aHtOGaz0QW7iCbYQa_zfB!*tT|m6DhyBEX{a(7kY_TvySSRI@ya7q~ z|C}|$O+tM3j9$>_7sHK%*6+`LLbn77s&>@|+^xC@Jz7U|z8KSZ9avpqPmA~X*m7}~ zi(NC@HE;!c)*&`xDPUJXyb8HWR7Rms_>o+aI%Z_JV{?SyzU581*tT!!)eE z7+XK&mBJl$s8gR((l(4Ka1=%D&;|+wTVedW1F>1`>ev+y_N-H^;MKyI4|2L#n61>P znJL~N?Cx=%AM28wJg81BC~2y~y=`8j!|Y&K-&pGzV7p4hYuPHF_@AW@2B?%}PSlLr z)2R9uW_WJ&XVo?@)ESPTn&H~(Fdw<&)y!xOtnpz&EhD9-wcoXkl0Zesxvnp7mp7H2X9Ve zx2OH^hP~ns*&gxBM=&49oD8|PjpZ6PA|kVq&L%ooUHv|Ha3J`g*q6q>|3j9w_c+`0 z8=Ax0R4Wjz?hcGt3iv2eHn7N}T%w9;Frsiz*E3giVQhM%_&YtbNBhe=E^mM2*7sXK z_&U>sLj%#;j!>XC*s;Fu{wJS!;z_9uSD-x<%*yJcL+g-(MmcbdjGE|^${FR&BYmty zFTUNdAk@;z?E1faa7*pif5$YX-4QG$g{tR9s$0VMKl39Aa~IGr7f6T0wyxlP3y}iY zs|#_0k-f4~JaOZ5?At-O6Ibos3((O&;bV~kKfYKAadUkMU+QWA7kLt^DVt1;YU~b$Og_BZ#O@@YySF znW7#9r!8-+r$8tyEIs(!6At#2fJ7*s6VDKhC7^M+LL+()x#S(a@i@CQ zepW9`d3*2Pdx*lTP-AJuRi@A#Gqb;o_xsplaarE$Cs1dx_3tN8YlfM?tHLn6Z=qv> z4}A*3;T`|E|G1f{%r_mkh~lez_ioy>ckgDv-h{pOQFSK4&O>{_%@9MZPlq=TZ9X!* zdBvvmL+qtP4~Uh3REbA~`B+cJ3~m;d=!E&=k^3kTgRn* zB+}_bo`+Q@rwIGwTN5d7C8wwZ7v4}(gdO8jUcC@eQq**NyK0t9LCO|*|H$R2pGi*9 zfk+7ATX_LV zEt>W5ox=giRwM})M>Z__dRS&3spn6*Mvg(BD_o`1+ z=P_z9B5FiA)K4qxXNxda8(~*t>*XL~;~YgCw-?wO>^=4;hP5dvc8oOnl$H*q-&mJU zoPJGS2jL@Ud9upaJ>}oYFv)n5-zVq&JeYsKpWGJ7d`sr%=kY4JT|PJO-?B`mEd0ge zx~wc6{uaqEM&#$G^_iJ^{B4}@p1G74GQctUTYO1LkTFbxWKIR*Una}r6DXck1%*8m z0oC8+>;;R@o#A{0FXt<-HYsmjRDLk~q%`rxNw3DhKbn+w5%7z=V*iR6Co|$=HA`@C zGPtU2^lfpkP^SLhe``@Cj@}p1B%h~Y{uSSUt|t5(OZUaJXrA4kXSUhmpJnIfX5(+R z{9=dv{FtIYCcI}w$_p7l(I4XTN`j1G5+o~DAmpt7_p^L&0>zW6Fd_p?1XS^0}v z&xwl08&F+0**O>bL^BWT%Jzw5$o%=O&A&5U(VFIF64sz!KT&w!`mQq^kJM%7lj*LN5JgJ`W^ zLwlBRwni8s{%sz6u8QKlxeB|6ThXcf=RDAn2Z)TUrj>Gi(#YoDe0*mZMJxQ znn`EZX6x2z(=}x&ddTk>4+NOO*5wx@>_(|lrnXS@cnd4hCWyS=K!#<6a=FK zj%;17_)M8vukoa&vHccP4!j&{JgMm^r71e`MFZ&8X5_OjWP#JM@S)D6*8$6KL3dEl zY90Xgf;3Y`1M2EU;k53%8f%dMgBIhFYF5y;ADmmUnrvjI?|i4~@Zq|{hbzDH9o7PG z;$z}5<(t-bDSJOVOY2o5j7?!KR>IDTTI>?Pq-tT$3HvCem0>LoZ-ss0JL20s*UyE1 ztmh+b(7~n%9pc+?(?l-ZHqe6j<~&!kfeANLrugHmgs7aN8bYo`_=Od=qmdJH;=~DI16+(> z5x>GQMuE|o5J7Y;=bCqSV5Y^$7~awDIdyB$6tJ9yRy037%7Y@qyPQi(h| zk^@ev6eH;*+aW$$f${+GRwO>37sks_o#r%hPpMW=rJ+AzD1Nsv#QGzVl^_K*At?3p zsEKrP9$2Fs5)QHW&DtZUC`0`2QxVoLY>hub`Bp}R_V|9Phw3!j%5%fhVhw5$HAwk1 ztUDs^J{36~5e~(@3cTuMqBOv7<28WmGD!;1uQ&^j76eKEw!0U6_*mKDU)SI;;r}*NlFqGM1Yi^ z$x_x&-YkPuCR&^RojfHBor+YDTvb#+rnaIf&=Vz@>Zg4b4lTp79Wp4128WboNrp^d zWjiFKhsNWC4E1w=C6ZE;!*G67B@B(Kl+u-w35xn5mu0{(F83Aj`7&iBW1KRQb!rKM zu~b+%9wLk&RZ_oFFi~ktDvO9x(iKdEg;PY0$tgNSh&W_IA~^4)GG*#{+shT00MFJ? zO?iJr+04kh??)hU>CjLdJ2ITd?SMi}rCv)gkX(X@!_iTKkn1Wzkj!!@)eu#7>5#BA z-UMXW7ck;^Ndg-$k$M5%K10QguWvK(NcCk#%Mep>xTV4~@p~oMcpzJs&+9MUvTVwT8 zU`?PVQAbjZsOMV*`vmFe9;Oj8zzeCT!$+P@Kd@~F#AyeZW(fZQ=KScF*b?E41TzS3 zsPT6mY3vp35xq$}COpO)^#qbf^8DlwDCv+QiJar)e>X{U)^2{4qky0)N zNJpI%Ddo)u9-%JOt|*`DUFcg>flCLXH>6>5u(tQ~Vd3JoqQ^jPor8d`n?V)R%C+@n{}{fTaTt${+EN{uB1T|Ne!Y z@4wId(jUp_SY4Vy7oH`O)hY*4VseX-`~V-d8K>iWd*UDOY1yMtfs)UUIzU5@OapHh zYCWX|QFb%ai~oD!cl60_p-+;346LX(C0dddD=kTMAq6c7egPBlOND-cGP#ITJA&H; zgWf8^a0s4GeU-EgPv?D}dKdLn3-6{|sTfIH^465FkTf|==Xoi-=OtrF&?uQENsc&; z7tujPDc3<}-d z-AWFL4o*Adj@unHu3~vCvObojfGi8wM7(`C&qxw16;SZ^N<9ySkSzS8w%96xE1I6e zgMUd>!vZJt>8L89&rwsZuVwqtPxG~A15Ty5aN4H=ZkovD>s%T$8k;xG!O(?mu3pyZ ztBB0m+$4S#JOWO#1WG;}xxpDBy6F%32*_w8c*;llD+m?O2MI4`?WmPBHle%aATx9n zLBn(K5?C?Mz&49trQEzv7xTIimADw5=dn_mLU#hckrh+Pl#A0!lBW{i#Xk1URNAa6 z#;T%->}A3+Di>pP60wM7v+N@H#L)--y&Hq~k0Ta;kAKA9!(;rtWISX=35yBG%#Ifc z_2RoJ+hgZ%;C{wRS%5)VC`PC+iXR6~4kB5MVpt5)^3;E2?IT-^;tKqy5||Mdqg}BW zG=3y~!;DBAXELM~fM(`4gWv#zCLyDdkVifW;@jI-SdJlXIf`L9 zBwld~pz!M=mV@(-^DhPbOM!ePSq{plSPlB-yd>aBrbCf4nWvm8nP{>d#jqU;J&D#E zXFFs%RBBM+02K+_p|oS7Em8Vp;~|yLdj~YL+@`Ym7_a+Di_*XI(r}ayBBjv+wT7f= z6&)vOIIy0N9HkcHWuQ$Z7!JYHr8bvw@HQvy1dcHar(=JGBoh;8l+A}CcaqLZ(m{|- zXxN1201l_oNsr*4tVx)KmU5l|a5ArPYKYb*6j`N{ z(9((q7jI=zgXEjbxV*R_ua|)$K2uG`aPk!8Oei;L zs1LNmNDuvqk0Ut95)TzEJa)fx=*?B z$x)Ql*arR1!t4~HTaCrb2+yio!A`wLBVe3NUCKU0eW(RvQA9!kM)l>vCg0S|Rl?G% zuU@;Bpsc{lWGF3Q%&wS#^Pm#cI|@`Lcwf!c}Eu;72F+AdU;ar?ZF6{L%*y zszoes*%9(;cQ&GbWfi7P(HG^|i@ZXAdXe3buSs#HXbMgCFfjDNOZ}Me8Ta;=Lff-r z4MmwP>8-+N1JkB*3CU-lA!_~C=~MyQhKHx|Lg47kn~UWO<)sb*dq2!vQO-Uq4wR-_ zv>Ho>y~;+kKQX#a6(9XB(T;l)v`=JKW*(S6ol@={ZNxbCRVrD&E9u_)!i?68)}Qcl zAw@DoN@_~4igH{w8e)xrhlBk*a96r>j;^er6DORWpOyJjO4Ru_hp)IiGvF&NEp2|S z#F~+7E-1{+&#S9oUR#9DCf+z&%L>qU(AL1jV<(uki4%(0aeJlE<8^q7OA9R7>Dg9& zT9)UhmLePO56=i$eTIy*DO#s4ooMq7MH%~IPqwBOS)gN}9ie3Uj%C=sp`29&gU zdcmm^N~GIIZpF^fdG;GA&8@w43R|XQ)&48|$mc+;gld8N($jfM2m5`6Sez{cTy0QLrpOi47@$GTI&B}GQOiZ+8a!k^ItDrdM!lV=_65}n4eD6jL*N*gR_?ZH2-vP z&w=vC<@H`f1}{%SWpia08?BiIdabU|U@i0rvlY6A(+i6Y7EOvhMN^P#3s61NP|u)x z4*HT8`L@5@=Z&seMWkq#B9q*H>4X2r#RCBo}8lej2wfpP}h+}h;TYsVK8Lq zZLV--R#s+uq4Rcy60EBJ0VS8K^JLp9Ya_Z?>knIkExy{!p*y>)=LxenZ(guq*5=JL z^Zqk(=#Yb-`)&qLHHr>9iIc0Ml27=r&6|;9!2-(j7v!<1LnsC3=LeNil%tqqy;%Df zxzvLNp3H2{yn<|7k*7$aV206Gke)7a&SVYdfO9HfE>kyybJRkg%Q-~qk@T$Yb9q)i z5^O7L$Q%;>dm_KC-?(w!Jjm*8n1g?ay+T^+!@-{0^sGbA+9er3%qnp+&o31jKdfgV z-2WH23_Wo4EM$B)&ha8&2V`6;3{H(dlr4N1$FVYjS%fX@BjIt3w@9-&xiW^ji6(xr zXkmU%fn|F^zM;S@Y{@s}<5nH%6Fm<$Z42&kqWnp@+4jO@XzHPl;f}u1(W8ijBkO@E zT?G4PI->j)<=+M=Dkw0TFK#*UR<@kbPl!o|Qg!TaID9l_Eqhv8g6wa<|2?I7koMCg zYw20qGQ!g?ZY_abwurzE1BGYV_TjmeKg(7Q&u#o!wqST}r)R|$;Th@{&$1Q4a|eHx zwJDxw@Ml>k;u)qN<;iV;=Vkm^<|&?+^XEzTlqhus#4zVa9ea>Hz)!EiZlL>USzSqX zO?DM~@Qu#SH>eL}Lkc!R3Mj5y(rNC=r;b0p^h-6XZ<@1VBsP3gU&j>-A}i;2T(K}R coPG7yH9y?fCw-Q7UU~oG4J+}vXv3=i57ANC;{X5v literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistMono-Italic-cyrillic-ext.woff2 b/apps/elektrine/priv/static/fonts/GeistMono-Italic-cyrillic-ext.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..af983c6bc075e48c3f3a2fd03000240d4e3c40eb GIT binary patch literal 6616 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 W=lZLoX?X`SGs-{wo&WUX4*&qH7{ajt literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistMono-Italic-cyrillic.woff2 b/apps/elektrine/priv/static/fonts/GeistMono-Italic-cyrillic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..4a178e27540c1edc52e6ded97b56b10c3ed48676 GIT binary patch literal 14116 zcmV+E66aWAK0Aol105-1x0RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistMono-Italic-latin-ext.woff2 b/apps/elektrine/priv/static/fonts/GeistMono-Italic-latin-ext.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..6717e4b524d41c4182e09ddcfc30423e1a84deae GIT binary patch literal 16440 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 oDCEbwpi_yISpGHma=}yHOD_OvG|4>(^z# zx7bL1TRI>d3$}ErOZu{iXcTD}r4Yf@W# z!L*+C%st&amP#ZMTXnBDnw6NXZt11(0;0L|91R4PZ*Ix5glZ&_5KTsV`ps&m5ay_D z%$QUfwh5dpF%6X}{sdD>w|CU}YDR*seQK8>%;%$K#?nO-cvwOyh2$eDA?)+_K#SIK z)1VBBXD9P2TuaiRd=dxW#B4r=b0xjL6L|PU`I_ck7Y7>f@SAuTyu%45vk`4spS8Y2 z1FO7Z9;%V*7=yw;E)#tODDZgS%8N@ zroQKtgOOp{8}H}aN3#U6>^6Si(7FpOz?zkL@Ge$$s*=9nMZI2|2*~hrt1{}aPa-pz(q2B)&hFI2 z{VHaV`I|IW(qrQ9r&51rhl$-5QP1r^=D=r%Jh}N)fHZWXXpY5{O!lM#qE+FBxe9aj zhxW=_8q9MyBrsp~?NCimYB7l&t&f_x@b@v3S!TjeaXpQ~%2Vlt}5Z2ax2kIuIs@~XJn%3a~@nrCsF{g&=`=|h3QRla8~ z9K!q$aU~Go;ECX}aXkD6aYL5u2wAl~cwaZ~#l;hfKTqRefX6m3Y{mo<`Ej?*VhD+( z(0#|DVJF^Z)v?PxJi~i<_yR9j0qy$aJs$z(Q))EEaF-Bp=KM+SSvIFD8Hm-YrIDx< zt#HqZCu}i)c2pd(6M{AkS|+h6bPlCYNC*fewd|%E?hTpf(`Dmx{sb

    (P_+|0Yt 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 literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistMono-Italic-symbols2.woff2 b/apps/elektrine/priv/static/fonts/GeistMono-Italic-symbols2.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..916e0008e6e7cae02c25ba0fb93b2fbfca973587 GIT binary patch literal 5884 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 S$YodjBJjWeqbtcr00029?+XI} literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistMono-Italic-vietnamese.woff2 b/apps/elektrine/priv/static/fonts/GeistMono-Italic-vietnamese.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f724d57d34aec72b8a8b81ec673d531be48ddd55 GIT binary patch literal 8620 zcmV;dAyeLWPew8T0RR9103oaZ6aWAK08cmo03kU50RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistMono-Italic.ttf b/apps/elektrine/priv/static/fonts/GeistMono-Italic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..26178dae792da02fda5e691eeb26a7d54a62fae1 GIT binary patch literal 104520 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 Sd~a~s!q!V}7+khUrTSk5eO}uD literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistMono-Light.ttf b/apps/elektrine/priv/static/fonts/GeistMono-Light.ttf new file mode 100644 index 0000000000000000000000000000000000000000..92bcfaf7f0c68939501ebefe2072715c1fe12cf5 GIT binary patch literal 101500 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 V0aCGKjmGwiINR0JXTMrn`yWJH^acO` literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistMono-LightItalic.ttf b/apps/elektrine/priv/static/fonts/GeistMono-LightItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..7d7a32f0d6f0d78510c71b4c33956c21e62a2b22 GIT binary patch literal 104740 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 literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistMono-Medium.ttf b/apps/elektrine/priv/static/fonts/GeistMono-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..358c2f236c1fce5179b744894f5b085746012906 GIT binary patch literal 101552 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�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�W9Z9AFN_ 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 ZyPl~AsKTf>n%cAAS{FE9`+{Z=@js(|m!1Fs literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistMono-SemiBoldItalic.ttf b/apps/elektrine/priv/static/fonts/GeistMono-SemiBoldItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..41c6c9d4de1b1d59c6bfe87d36f4d60990ffd3e1 GIT binary patch literal 104608 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 A%>V!Z literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistMono-ThinItalic.ttf b/apps/elektrine/priv/static/fonts/GeistMono-ThinItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..79b73a87262a66a76d19053f2999ff9ec195529d GIT binary patch literal 104732 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 literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistMono-cyrillic.woff2 b/apps/elektrine/priv/static/fonts/GeistMono-cyrillic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..2fb7832f2fe964004e2bd9e0ecd24d659dce7775 GIT binary patch literal 12940 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 literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistMono-vietnamese.woff2 b/apps/elektrine/priv/static/fonts/GeistMono-vietnamese.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..49730a410873e9f63454ccb8f99e94dda0592b08 GIT binary patch literal 7696 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 K^Z(lMz-|S()T4+1 literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistPixel-Regular-VariableFont_ELSH.ttf b/apps/elektrine/priv/static/fonts/GeistPixel-Regular-VariableFont_ELSH.ttf new file mode 100644 index 0000000000000000000000000000000000000000..017376d6932626fb498b6ee4d8e77451cc71f7ea GIT binary patch literal 3656708 zcmeEv34m0^wRY9*ZMu6{bZ|!;Kp9sY1_2>10YoICMn#M!QAR*O*&HA)afwDvG%?W_ zmqfE@eqUnLtZJerM1u=%F=7^@Q8d9V3wc?Lz8Hr8)V4(+=JBs$t(&D)tegK0Ez`M;*Iz_v6PZHD;w!jsKl~ z>~T{rUi!1$lp44}sZ{c?*^`eMsE3SJN`IaB_dV*^ecR4dOMj$P@L4L)Id<0MS<~-4 zfxv^mP>CGE?1KJ&%ZBy2yW5(k|6vG$I`Z=Q83)$dRBvlg_kSWbTj6W%QP+pu8q z*{A;ZjQ!Rrblka#EjO(tg(RSWEMb#Bc0vUNV1W z`|=we9z*3{Qn__$`;z(B3|x1zQfcB(bSz)CV&%OLFMNfd-zI;2e0j(Gko%){Cx&3&;-w4dugS+(MESi{(Qf_yjiKMO?@yVpb?~+Rg84ds)3Tx zB1xY^Rbccs1l6dvSG%fK(&w;{BgknVR+0tgZecqv7rO(&tpH_d;+Yrijx{W@^5^A77t3ONm&*{(6=auAJt*_REqkpa+Rx!O+ zucOar^ovA!Nxwu0Mq{P6rDO(GOh2W6sh=iV5Vla%;uofWLN3#bLqe~bzwv|_V_zUW zlce*$Y9eWUyKd5Z=?VHEeV9H%&)4Vc&+9MfUj#b^6M_?h`+`S;7h>^PL##2jb!_+8 z4Y6BeKZ)HxZ1}KChJAI|Plx?;*#9*TY2LAUSo4VHQO$caw>FP$p45Cu^X2hYY-EE&s?PNPDd?jxO=hkb%{ z`pK|=412q|sd@Y6q0P;t)9$2GTl2(Fr)@)>t{i^r@b3-3bNGfXowi~+&CBWZ8Pdtz zA(S3KdcEtv&Z{@SVThgAb)K|&x6NDU89INW)TXa&x^mM6o6a?~m+y~iUOp!GckIi1 zy?pM=vtB;v<>X7RDD~2kmrkM2!7pWBO0R!r{j=-8wf<}CuTyIM=hk1c{_OR~ub-&Y z?*~2q@2BbR)o@;NEcN&o23)5qyk^zr&b`UZWy{<8jvK94LIK-bw2x|T=MmA0qaM@^tUKR`{U z>u|a{S{)m*hyEmJGiD)n)_M9+=`Tj{F$vARe7O#NIv zsh(EPsAtvh)QfbD{Ym{<{Z0K_{YNKsQfGCe-ddloKdl$*uj^yUs0>SO8{^`^Q}U8lZG zqx%N+HFcBvAN51^9rZo+eYI9Ss2)-etJ~EJ>JMtYdWr5AFYD{nD{8ZPM{QD_GzQ<$ zgXq5a6}pzMS84TC`sVt7bPaz~HK}jWJ^mK8jrum-6K+$xs5{lpbSG<8cc~rKchx9$ zpW02`OZUK^suuMVy1PA~_M-dnc=fp2Pd%!}smD~CTBAm*C+L3gOZ7grP93Ozr4ClV zQ3t7CtM{wls;TNZHBJ3q9ipCBN2m>Irurk@J6}`BslTY>)kZa2{Z*Zy{#SiO{ZoBd z{X?Co{;t~9+iH$_OD)lHbsF89=d1s!#q_PRh;GEE)3?kSxAUrv`aAl2`Um=(`WpQeeWSWSn>*`H`UB>!8BbIGtFg;-d9AjMt<2>EH86H+E+12q zVteQEaWyKI%;ghmN19bdI7zi@ur`-(P)7#m%I}xUrzyW@E}x-%_`MxZ>n!EB&CwgfZ>Y&?FtJncT87qaaXRLuB2szvXVyCB5K=Ae`iu|DdEi`#6?tIN>|xpDo+o~^JvU2 zqTJk&cC0#s~rP_tv{8f{n0!ck*5P1cCjwWgcwVp-o3~n>s9U{JFy2mY}Rx8z+ zIjx#=95%u{YO{)v=Mh>*X#YaWnbtGN{${$%7`>NLt{d6-rCW!+scv@IdIiyq^k%wG zjSf>Y)6ABe*QTBBTam}xsqc*6nn~kDr1wI~wNpBj`snB|zmj~?+)EEhzn)1_&Lk~Q zA*&aW2Tkv;q}*tt8lRp{-rScY8_#s8H$vY|5xkMr#uJO_p4YquZo@I1e7%Se%}6^m z^qCniXA^Ea)xCRc>?1};PkwHt?}b*@x|xlwX3^gCOT_;^>a$~L1ka?qaUqiJQQF;t zOPfk|Ef1|R9yK0sru%*?rSX(AW7PDn-L`|;oJRDyA@^kRkLh{S179gN)QK{aCSAU_E^J4XC`1w-!`CRz< ztEH=!bf{k}>zKDxJ-lK?>lpPD`fO7_pwF@DTlBe~`YL^nQ(vUd@#=E=oTxsrV%72$ z^u1xcm!bKNrkRe?jbUzk%9%NkrumMd?_HCl?@apUHMwml*AV8+Dl!%3$j92O<>)&z zXKgk_ZU{e3ji$Mk`AmeLW;ShRiRQC0{51V&R(r;0K}gv#-W>a4Y)U8XKqpHZJxSE$d0+U-MgS&FfQ za`OC|JTRJSHC#@0ZI;_w8 z*sPJG`p;797xi&e)-=n{`z9a_;?zeIOd{)}c1F%HO3^4#VT{H^Vy4r~|3#8ReWnj3 zUNaUV{Jn{~ZP=f2eT0h9ieoZS29i~Whoyt{%p8^O1+>zAKefxZP149S<6yEyONTU@ zZ!pnf)cRo7KA_oup!Qc!b!l&|mw?8N@l8N0nLT??b+Ni6^z0+*sod2$hI(Rx@w9H!(`iL`6h%r*&zO5c z_x)iXy6(*yu#HxO`_ZauJoUZlx#@J>A4lKk$J5HFJ@is^jsKR`Q!ko6BU=v)N7ZEN zVHI4eW(ITAs6tch5aKm$Zl}LJCsWs5KPSXDGTg~vO=9idjXg7XJCr%otO$d3)W2qB zxMA~i^jCG~^X1LY^(fQG;Asx|;BBHAn`C;o4%}vC?2qqGI^}IcI1)EsmVS@?miM3g zFSU(bsbN13M>M03)x*{T<8-4ONElYxI8Uke1xl^BmC^&0XwPuP-<4WPF~pU_mAdR`N{c9+t<+64!tSOu z)7_&f(OmZKlPH}}=~_zPq4Y2%ioxIgZ>651UAGtM@0*12ChZ2iNjPs3&YKrf`VuX> z&Qy9N&DBQkuJm3s<38v=N*_!!wS)Jh^Z`nzP&$Xw7bty;()~&wPP4|DTT!Ap%$xy~ zc2j!sUX-R%`Y@#xN`L$jrLVnD>DxCdeFv@V?w~pO9khbF<4C0+pc&!=g#7?vKX4JH zFH^dg(mJJIoI~j>nvFH9VC#RWVAr)OIAC8D9Q_cb7bv}{f@21#U_RyNQ~p%SpGvd0 zQxBwcoC?l;Pz9HLfzr1r(d_lI=TvaRt&|?1^gBv_SHX>2QKFgPjWk!hv7OS{l&+#g ze7D`Af`?9`^f5}bE_>)Zlpa>WuQ$+!$5E6PP`ZH9*C_pn(o-t<_m?UCfYM`>{-A<) z2>%_ze`m6aZAB5#t!7hNN{OPX!xmGzn9_}uenRP2l>VY(yHJF7m!Xs>O1j?zlzvC) z?y@U5RD8Ei6(2V#Yty@!~wOi{!ls-bKgVObszNg}oXq7o>CrV>f{P2A!O;hpN2T+C_RptQY84SP$a#*U$MG^I0CYJXZ$AN)s^ zn*Nb@rx^#U)J)n(o4Jb86_oB!sbgoU)QJ@TnZH`4&bWlqbt-k%fhzThV^r$ey;bV# z!&K_qTdC9??JD*CH|PY%xhnP3(^TrA@2b=P5m3sBJD)rCz(Q22IoG zKw33yP35h3RSiSFsT#K1lhPDQC#r^>uU8Etf1?`q_>OAW`zF;e=`hvs(Kl7Y1w^~% zXR6`5lU2jRt*YVG(^SJhmZ*kJ>r^`RC6ykuUZuC5Pw70B9@?nVBfp^1qkpE-2ToGy zlXg+*MW0pa^PX1e&u>)eTaQxd2d-A>Umc>-ub!&Xe>+>H{}os1&A(Ea)Iln<)mAFA z%WqU>kGU$-_BWN8JX>Xs`iaVX^e#$Is?5AwROYm2RpyMrDs$0kDs#m+mAQ#{fBavS zdGuwK`Q2kG^G2u2{O7MKyVZ>vj3{aD~?l*S017oul}NHyk>@K zy!Jn;@jBWg{qpsy@hkUGdYaP9l>SaJzC?-c z<>42x+I%Ces(biPzf8nk)uTF~ovlYG8ghq@>jcH9zC$s*-RQe~6YZbQ(@Ba#w&(_m zPrgC%rup=(zg9g)5zNPFXLx`fs0YzXeotD552pRXt!XcEFFk~IBlp(Z(vIkp>KE!i z6bUfVm>twUCc>sh>!E7jD9T2$MOyRgW{OdaplEg*?VmnP5$O7*X~){cDn3ZN^T+68 z^(@-GT|~R_vuXEaG406Go;>ZJETOo|G>Xe~YNeLx6ZDB{I_;3tDHYlSTuvvhDAG^+ z-e=HW_{n;XZl`^x4^SNB6xxlSr{}A~^{ILR&6j>l`vM=M-Q0z0Rk-(mx?Ze~pgsGg zdYL*>FIPu}JFqLlUE`x^M|PF|7~Or2q1}j&QY1jTz{VCdiK9TnLFEhKm z6lwX0`U{<^xmRC7r)oZ`Kd)Y+vk6zxzW>#9D&ZP^t-g*#5d?0Y1j9w`fGGH z;3hg>@D2U{^ey^-XgByHWYQ- zPHj)I@g2=M0NPi+Q{P2!&KE*HAp`T8f4KP5rO> zv1+C`*l;={a1EVOx|&W8w9_eq$?6O0M*Sc~G z@BWMEoDoIVpP_yEU(*?h-|A=8J^FXFPyama(Z4{aCD!Yg^vn7W`j2{penr2kU(>Ja zH}sz7v##Qx8c^UeZ3d zW7*Qw;vocfXEnC?>ckcY9E0c%JZ6_}J)3L05WpY|r z%1rC3Pfg3gOp9O){FrG|rbc4+KQueN8#bb~wPmJvHxJp{TH}YE(%z9hteZEO@qyHg z9Q2F`IyqzIqQ&#(XN>`^`%P{g+d3xuf$kO&`q=TwneB5|t(>2n8LFD2PDvgaQIBNQ z{l*PAYT>G-3)(wYEm_>YYGvxENMQ1qi1!%AJ8pdP*pQk%R)}mHv%e8CX4=GQ$yuz$ z__k1X+nCA8<3i65IL;DwT+X`VBI^>zbu3!CAbDJ9WAgY&*74o4(#OwRG{0m1ibX4u z$1mt;|JeNO3Eh>+6GBp?$D#WtPYj!9PPF_ydBXlv1{vp#89QaP*|DO%y{Nq3xO_Qz z%xJSum5n@*%ZEmV`NpvMxba~jIm7%Jmz@y`3-g0#bn6rn2F}Q99To@8=+Y?HHq<-J zH|DxKSBSil;|iS;=9AOSpYhr0p-aPjq*Pm5SQykjwz?|EOlxZm3k{P`o-x|&;WZqW z%Ou_D;UAiadg)T|X5C3H5cU7e3=i1MY+D9ES<&bD3jGLHU z(2W<;#*UKDl^ZMUWtYMBqSGwz6lnc7ReYpA-X=s zN7u(`-58O)u@jPu!~RMxW@B~i)a25Lx|C7(8$V!~HDs4XvXitIYOb&j#yf68az#kZ zt`H*I#!O7EWSry2Mw*V9l3W#C7pp8Gt8xaeiVRGw3a^V*(RFcVB;(9(8R;|gBP@Al zbX}a?U70+aUl$XT=Y-8O=U5)6>tbr|x|o{34szGU_^3R8&6Z8PU9#J_ zY$`+^HUitaWmAJL*=@&||XS)0u5*toE zdC_RIsZO`(C8x}5568jS$rEWrb{D5iO`kKrW0`3%F@5T?RUMfG^$!Bw;Fm|Yg>3zroSN{eN}|tNQdr!=%Hc#m@yNQOBO9Pqj<&q zxyzQ$%TdQif9VTp+NzFaVWF)xB(}Cr8JBAB=va2)xX8y{XlW81o}pa`LDLhf&*QQMR$ zD%?NJ(0bv-+tgyBap!q& z%+WhS-m}6_(_%5TIhFp+iJeF(`e#bBVn@afqc%5(b%tXK@#f{2mgk4k&W{}sN%k9Vi-Rvy4=fJ89wO!R&f80}|C5t!Th8!|4xv6% zf-h2UP9d$Q#4Zj#7kYe3@M*&MbZ}8<=ak@FV{7m+8igm*HKEldwELme>fjmrG&?$U zjRbQje?9HSYJF~4`&M{$uBPkB)DkDr&Got=tlf~yn`?Bnq3E5%v4Ta6TIW$2lZ zvU5ZaDXn4K)v>!xTO*MWLLCf6XF?q^A!ThySsPN;8cO)=4m}qWyNX&}AM$Jrc{YYT z8x2KugvcEsa!1H>Zuq%#_&Gb|pB?hg4k=T^&zr-~*08oUtZgNVIoV^Lw^0-WxSIZ& zCvCn!>*?EQg>Ig-`6jJ_U#E4ZdD7+&6uD`m7!N&TLuaPw85=q`MbFq!+=ZU8q1eka zv~C@(U!s2$i6X=gP&$OtLF})|n`G+t&*e?IjkGv9^xGwo_rg!JOJ@8)cTlADz#U|3;Ih^55=s8qEGTLihA>F(8@!yNVv-Gr$ z`S(Qb@4Db&`qvZpI(kmW=6yI=M<*HI^{=b-y}8`I)Gm){Xb}qkyfftFJVpbH4=J_!j$@=e{>YicoG2X5Q}^FnidgrBY9XC|T>9zrmmYt1L!jZD7P)asqXym>l}uCS0YJIqfFKW`2{ zTj?{P=wzwxvoBI~aCA6lHkz1Z?&0QKDt$MEpWV+f(!5b0Mpt`4HRhiAHbo{=^sno` z&3A6Tv#X$@5}h(r^b9P0M*l^*Jm==m8S3VR2bdTp2x8W=0gXmeS_+6rGMt>&pYVz z9{Q&V=SBMb6aCW^k^LL>zz(#p5F^x)^m&Lrgzi8A-Ny`ijP6*pZwh2+(#p&g{!IUp zA&L%BG<~b>6Si**y%*V}DNdhJ-ymymrE~|Sdnmm~=}(mYw)r`o*nFo>Qfeg6eA=9W z2qw{Y?GAMBJc4#KE~JR}U3ALz4IQJMj|sG=a5}}^zecg`XY^l#R4_bvUvONoEVwNA zM)1?%x53|I+1PHegJUPeR>ZD|eKYnz?D^Q6@j>xD;#1=vjh`96Dt=r1q4@gv|0S9d z`y{3(+7lm7T$A{2;?cw(6PuFTCC4NWPtH$Xko;2ehsh_CucdTqr_{LAQK?0#Po}<- z`cdlX)Snv?4ZAcZQ}{n7;J2qy*jM*{i;Ku0)8E*e*ezf}0!0sVdT@%2B_zfO__01o2sulQ*EBYP1Y`K?rb_LYnD@uh1pPLMuzdf(g!ke^#Xf7Sc3vi*Yk z%a@M&aX9FAbbl)qzpp;%`^)#fov^lwQda3zw-iS zq()=Q>Tk}Z`6#lnteU?wDTNF$&cnNb(~HT|w@fURjKz|v7-=@sE{^JVMPZ}+a=)~XQdCX6UbR9q==&3bW64hM+vNcQ1mj;_MNc4>MHfkPL)cgz7xn+!Bx`d z+O-0<-xu8@F}U=-?HzabOYhje+Vpk1Y?p#LcG)hu+itL#ds`C5^%lm}PR*i&5;e6= zNW0A-jDeQjzRP;VkG6>rKhme%+e%0uUplh41^6G)kso~Z@%5W8KC&0-kLab6TPlAd zJrO?AAB6*v-w+Pc2jL@rq<;d8PoxhTS4eKH(2>2!&xpTNbQIPt6+Xi8m0K$QN{tgV z{!zapI`Su?`}z&x=$%0nghy0(RD;ubo(FR{KhGl`PUj&SPUm?fm-AfB?BaBuN4jwy z9_it7*E4*M^9G}H9Nw1GIS!|DOit(dP9DNS0yvQ7t$78fcisX_;0?;1w-n;>KweQu zUVjBxQ%GYgddM5-qXDYbCB-<&3U69QbdCkGEd|jyZDs@cmP=UPfsilP(9y*&3p zDuUNb38x&}R=Xq`ilURZ_m#pl8^|s>6|!0Fl4vMIids>$k;-3kiWill=(cOMt-EL| zif*XueV_!gXlo<&wj^wG!3cFk(QUV2rUy*J*a|)LWFAJLAFiSob8Jwnd!Ro?$LM>a zKT5~vyXkj#;jFdjdHfc7Q3&Cf7l!Co)-GS4q8Eg4yl(oFdHUv|!5lg=p4WFBy}ZRd zC>z<)E&p+PP^(+7Zk#6!4!wZa@V{L@dLA7;#}O+u14i^xt+WxoFCFRA0#@LNj^rYI zbgC2ZU5JiW)W{Dl;Cey&ApSbNZyw}9 z{qEbpO`zWqehly*pnVc+BZ3R#N^__1OO+eRd6qEaIlUJ1au-EJ zCPmSXhx$|sUQu-73f>Nz*)9oQQFQW(h^>>?St*2yqTRH&B>^gmHmcKMHmj@T42=r~ zM9~H7WEGYDuP6qcFwpj!sO% zoBm=~+a>p)6-S!&TD!53^d2wLPbs{|UcRK1Z;YY8#tcdo%c+!qT0o2n#k!Ea2&Yc( zn>`BDFXg7Aaa(S=2;X1*G9M}9;7TPl9kFKB!sy04xH2ifJTC)#82#XChg|s579 zAW>^K6}-+$AKu;r(GMA$XI(C>y=Pr;#2&3E5go0td{=7a=0__~wBkeh)H)rle34yE zAd-go5q=EdpmBxpQGcQSMffd1AB2PS@x@2}M0O!M@-xyO>FH}%srZrq(Ktc&A{?YA z!bg5X_`dy!^zrQ%G#-$iNG_uL;v;_|eGos=2c4Qn{FS03J$>y({Aj!(I`YFvaNk2X zEr9=N77^tE1D@v#dX(qE0M3Kx2%qzpJI{6CvN)aF!cWJdC?dz3=Ty6{Z73b$XoX+w2D@M4DoS)rQwmNxxUnznlGJ@w70`2deCpv!9IZn6t-62x>&TH9;LT|bC(7C^DhtAk# z(cG?-v3bFqd%Mb|;ag$`MsJCspMA~0U4_#h&A%mv-qKoc_v>{8Me?p{|enoIU!ULgThh@7+5t-eOTg9zQ^N9<|}o z78Gyb$A?fHq7XX5=WTi9j>kxlEu5e0$@9D|*MZZy4*U!sKS#(PYcB+c<8ywV=duug zA#~2e^VYkcc||dK?({-%xDyNE=MJg{{z7!9HXc5pt4%kgj|^stb-et^G*MmkVJuWi2ku! zJrS5rr$?p_Odp>R&`haaXh+ zM}9+os1!faANe2o6WN96s2`D^5gp50Yzl8bPB`4JA%AK|0^ z_2ozUp!*@huQodJ53(2Wqki$l>973AKh-uK>iX2<-ecR^#tsd77q3RwuP^;%7Wd{M zLmoHgVX#8-@V%$#cxEA>oQKE0c<`OmQNA9yk0;Uz`k!??_bm^m^O$ozaG!}BR}b93 zdjRplR1JAPpsPWjLUibh2bD#<%Z?P5qa*}w;X=0q|UOQvFUYZi{HZ%$<5u~e6L z?4c@rRo|Q_>-K^?bu7HU1=-G?6HxE9V!z*{zR{OK=+dv@I$Hid9WFUXm}8shfNCM=R639V^&+9>tAiUaecU+<<9ed zrUSnQ1K`*0Nqg#8Z zCpwVxV2^ky{bZg_FT0o&zU(5BzfR8%Uv?q&eZug+R}WvKLwsocg6QZFD~e~Lbzciu z?^GK<(z90RX#I%vN9$13zX;!VU61tf9S>;SB0Z5@#E5u%0@R8pTKZ=7PeJVvqdivUn z_z`_1==WNW_aeJ0MMvWkT`#5LN8=gEt<$4@+u`84K>kGig~pqHo*lwPaWEeAM0pHKgsm&M&_JX#08Zh#uI&x#iB2oNgCj#T2EaExoZxyq#LU zj@njRyzHH(VXcbwf*H2CN<)h&! z`daHzAQX*3cA+Ruwb5fBDuwK-6?zk}3-u$4me-2CNYDByS@#3xJc__05Y?9FdX_tn zqBT6g!Fh_wb3d2cE{@OTT5&qgRF1s8{;*5V*?8Yn3MtiD6!8zCq|>MFrUI!-iuJ>x zjsw+pZmzy7*@bT3L=~*ju z-;+X3o^yVL@4F^K{zUzS`qy{dA{?ZTFFx`ovdb6WSASoA)Q`wNh#$#q0prcrZ%7|s ze%j3jo$JOAweiDl zoS)X$_kOod3c_`(Ut4W>HLHdr*js3rVX#BXW|V5gG>uP8d3%C}b9%yvoO3R^|3ySRlXaXq~(jw(5|c2-Jn z2qWY{JW(x=NQvqa7WK`x8C!V!r96Yyxxd|9{C=C4qBfFO(-JcKXkqjAwj`AAEsWhx z9-VYwV&a&Y&`1>Jtw>Z0N%ScED0<}m((qCEUBgG=AEO7zS9j6PBkvJ?t$DQF(CJZh z^E+R8`cw2syXxY@ByS-)s23 z!7N|@pr9C{BYk}3*7-|&ze1Z0wSJ#L`lADdJoLiD9<`q5VVGLCh3n7l>dQP2ebvgC z;r=N`XO6@Bq89VkBeC4Da_8-}wOta+-}iX2?i=EEUQnHi92$xP0C zII}AA`OK}EHJKMOZ)La2?v2$p7g2MdPGSFKraa z*Pl(`dPn_O>vUwVuixtY?s1Ev#3-ul>raG(=tv)5xpjVbzx(>fcRo<-_n(RE=rE7A z@n99FFDZbZw=GAW>%*gt<;ZLF>OrmqH*(Vh%tO3_YsVYcdY;=@>$Y(Hxm|skukQo6 zUrfRKsG9Q?+Xs+ucodO4I||nON*kyAC8wt47w?kbRBh8=Jemrm_gc~R)O*c(VQ+n7 zCKaQ(l<-QQ73&)_C3JOlZOqUyZS(%lIWcprd(ZH=cX+IuN<>%Jmv+O(e-p%a(OTzs z2LW0@P!Z9QKkLCdx1W*Us*TQ%>Q(a+q8Q!ylC>C|YHr*APd|v`AMS5t!QB77<93I; zRz;6qnMb&GP-E2*YJs{?-JtGLPpLO_Oz*5G=%e-N`V##$eUE-d|20Sj!-Mw)#|6uR z%YttNKMj5x{5_V9?G`&Yc0z1L?26boV-LihkG&Zm6yGC0HU81~nenUQx5XcduaEy< zqA9UYVtS%I@$tkpiSH&JP5d#jDY;#8O!DyL{Nx46FC~ANd@}i3N~d;8jY}PsT9o=^ z>MN-q75?azZ_v8h?#I=p58AY;Ha(F(NdI!rU(hevm3xz<1FBReqI1{I%yyZDAjW^(Q z&0l%6=`}WZIahzmbC>ofySOcKyC>6;#ZLQ#JADt)E+4(XY4dYR5#8(nM)b9t?+jnmL`-T!7k`UhL!7HsO0Q8J zVSTtm*i~=nU3ltxMt?BE%gHe>ev0HgnU}M9s9H_Y$B3TP>z_w3^U;iL{ zgyYMPt|Nqx9@9Yls9%tu5gqBNpJ$83N-e6;vX(D~DwX&3iM4j;jSI>1=tCj=#qe;a zTT1f^?sYCqBPQz?-gs-&06qV~UrWv<^V94cUrt+n76dghiLI}l**iu=kJ+7Fj#94$ zdNg#hib{7;L@314+4hecNz~>JI!5ocoe(X5^v(HY488BxEPo>UTHQ(ujp$Isr2cRy zq6th@&{u1nUfRtaG_^rfCtrH0`pb8U=c{L(KN?r)dPL*ImyV|1$p7W$M@u4P7t#lf zb6>k^1qbQrOGno=!f65D7rr>CAANBUzb_rxi}XbNXgnjo`Ok})2! zIIW4I*N@JKg}W%0&|a|zERcmL*~MuF2<(XGOE zQR^-`<=B-X+~&r0UuvT523wG*br*|h>J}@!YGQ))+f^pUH%d(kV}egnx<*dp91}eaD4Sd z_!EHKO7T})e1I=6_!6X6@_ZdvDxC}Q3Fj#!&wt3f5Pr_X?Wz>LCRddeI}PBTbC$2x zYua3l8a~rzF%!0%x=KPp7ptqz3FA{KcuQE!ofsGr9%McsIx#@&1oMlx(dyuBDn;~1 z^lbVS+-NmnRtFNqY7WI;J?~H-`;3f1bwTc*#yEzIEWwNquEWR@ME5{ zA*5%2r8m|6Gi1m%BSuuJe_LwA|LD3#`Xl;wz+Qy!OGk2jaeVnxo_6`_iSW_=xl;Um zwZ~T~d=}5?JkMwS{G2bRbDVO}`HmmQuT&n{TPb~f<)Z%e#o_#g_=NKmlCSmEs(gl4 zEJ4J~-CjJ1N>Owp31(6Bk>k@;XNo`;g8HmjUz=y*_^f_0-QY7A#BjEox=KPp7ptqz z3FA{KcuQDR7lmu^+bgp}SISow_V)KA8^7Q9c;hRLn+NPLV60NUb3rt>Lv%D(L;SvU zH0SokK`{t~gXX4)KLzH{h`&ycb{^6AMEh`$B+4Sl~L zendw&zWN}1ME8|jDt(Y#q$lck)Q=NDe<6HCZvr^T57kEZy^s0okNk#kcnp-so_Gw4 z=Y3;j#b6fGmh0S?`9gFql|JaH2aXTla>1F(oriDK;P3nl2C?Te%B>N?_Z{8{2hpu( zL^u-?%;|h=@uy!n4$pIb9y8`VJkR+#o#*)zK3q4>!}Dlwg!6E@2$S>hwuR)&(U$AU z@p&HU#(ju*ilM_nhCsP77LOGKwIhWkjNugnRfJL^x>}4Dm1^r_eU+G7J~g}^VR83Z z=f7>w3Z=!fC4F|T&#iQI*1DguvfV5!5MCFxq7y`|D0cqRmehCqTee)GmMFS?CEVcy z?Bo@=LMKslA_-pib>T#^D}{z`T%kl!ugMlzUPX6jdO-2|SD zlme}iOBDqv6+Xg2cA-^1!l~2y_pTHg559D?8{>;pZTu}Dnuwy9=>AY`b|HOGKO%d5 z{e$EpzahB@2kC?OW1xQ#KZ-6R9AEv*&5ynpkl&EKzIq~jMECVS;;+;DX7|?JI^)e?bqw_MQ?ZsuT!Q?k2pzpoX8X$#KEy{ceR_zHt5(JKse zrt1|3oc^e~KB9NM5G~>lUt!P#&J%{8ek5TOz1rZ?+J6iKzXkyXF%TUsYS3Z|E#Aw` zj~4sB`XD`Pg+2i+T9N)Jc!Ky5J_;_OafR?vf1&2nXroi;w(?>_T*3{gIx& zc2$ZW$*mQ73m9)me{{W|@rm$##~aeecl@Ani|!9dE}|oSkROmgefg0-DCmdyD@7j; z#v$@Uwb4;OB7Y+Or&-vDhi`bEhiOorM=&@Kq9c6HU+z5Dfy?4_ZVL}l@x#oVpX2bh zoQLN*zb~DK{J1P0X5w`I(}ljWI6ueabZ$$z^IQimi-$*fXp;9A??;5g?czKfhx?Q3 zz~kQ>hx710;&hb9;X-x+qsE9Hj4Prs0_8n`csM<{)L!|!N@Afemc@2U!CR(EA*D)+ zdZW`^;kQi>+uU|ZnAH190VIk}++dDf?J5aeVXNIz@Rq4kNU4%ye;*^l&700~_U;w} zvI4A_qEn4xfJIvo%HH&Kr(pR~VT%2QF}^T{INgdNMs)MTsS$l`E{16Hx9Bw#DO{~m zdJVlM)x18{u21O)LA!^-7-LF51lm0r)?XgJUbX%znDA9F1CAK5V8Dd~ZWwUafTsq$ zF)*eST3`CE7wi1dp&PXR^Ystv7qo6h{Ix9ZUzaf5v9|JgOTp@haU#Nc(ehbhC;UIl{ z@sU4~U5M_hKho3Ju1fJExwS%X0pktnkFFOqJ`uj}ctiU5jvq8`(ft9*MRcSO@&odx zFF(=;eeWawO3{adaftj-ZFJO+$e)P+HWtZ5G1pq87xhE7+~7j;ohL9a@)#|TfO9$r zMGLcPY3)aqBH}Dxq#~;Otk`){ zseJ5#BhQ%&Yn@0wdsI%`l1Xs_^}bRlUnRx1PK)tUN8dXWLzmE~& z=K5}_a{w2xTN*tn@h!9K8T)0!Ww*-im7SJ7IeT{Y>g*laN3t(x-)Y>Y zao@%njdL5%YrKyCV1?YJ9Juqq2?LKFc>2Ig27YbeJp-Q^_}4+HLBj{VZ_sgrmJPaW z&^HGCbkJ`H{e5tD@NR<-9(=;!6@#xB{LR4+41Rv_n_CU4Ke;h9h;}AXWDZ48YMt&I zaYPYQp-3>oZvl}%goE_)#Yg@`cKPBX{gJ)ME`)>hNBxWBA{?X- zqWk&>;UKy%zONq;KhmdGBI`&`UwaWh8b7}MH4@whS^&LN_!9uWuRci6I^F%cAK!jN z*JGU?`Puh+MESp6@rOyI33AFXB&81 zo-fPOdfxBVo0plj~fLJnu)2&#wT^Q%s)woXaXlp35pm z=R(?AKf_f>17GLz3!CQ)kyQ*2f3TT56y@uW_QsN_7@h19yKBW1`};X)9ky>G|yGrdhTZ?2<6L5|u)Q z>MXji4yW7PO{>#fd0(^*EPqLOrThYI3Gz3u?Ar2|DlNal?w4O5<#)I(K|Ji=xxZa4 zFOE%rvFp|BTKthIdHa!gJP(a(x)!BcjLsjA5l!b1;m)qnGVE{@KOJw z7&hWZ_@&aP-1w!k7wL)U$e&0bgoDl?Ap9mUt`NU3-B%xk-vaa;3Fzp$@a0Exk)FPE zgpc~ycRcW)5JEwzTBq~qXsz3V^hY+9ijD%ArNT!zh1kp6@;qzf zNB&BXU0aXoj2OA!e&Y@Z#->Ea3#j|KcYZFn-+b51Xf4;`xpqbT z2p{Ad@#a3O2vYUAPScCK5g==GS6dz{o`I;mu=^yl%!Vy{xgEkrFUrB;>5-NFy1+6!kd zot;hVF`a_0w|Y#c`_8n5n9dn$=H@$t_r$MW;L|;5<$&mD=8b0RXhvRce%~2C(x=wx zzAG-@6(GX*U8y2Jqw$0K*LU0^9HfsgKJq8B%NHN%k8phLsuVwxTPySy5Q#wgqw59r zFT(d7Z%7~C@q@-Q(i6!=bfgc$NB;EXNBW>Q+93W)(fjK=?NR?CI_h_HeIYvPM_+t2 zo)JFcM{*Gkvdfo_@O|$CZ?SiE^XL`N^OGqk&!b?R2hkBe=P!4j>%e7kI=6*Co{CQK za2(#2WAZ%b_oegqsq=^-$Kg8gw+wI|UmZ9m!r_?Z$a5VyKBsftcz^MJ3= zE%k@Q3ks5XMJ46)UZSx$f+QbqcE&V&Un!IjMJKL&@Y!azO9EHe>f|j`rO>EKiv4|z z2sc;F!|~2RCX_CwC=NTyXYl21S3VM2jA+r;d6H7_+TIbz9|fHXtaUcDdtM?d-oRZx(m1q6E$ zJ{mtLc!l^8eyQ{+H$Jkr$71YQa2+^LA^LE9p66{jod@u_ z4oEI<%XxV4pVN!Ub6GrJKge3d!^tAZ@m?!#VeHs3A1~)Pn^xm7-`TdtQboH~xI#n@ zMXkH&^I)_aNyP62u)D3hRzwwrxuVt$B?5Kk8kGrpHi!--JJds z-Nn&dwCeJuqt%HojxWEjT(o-gm0Kx(bTqG0`jkp8>X(sVb&L8P;UGHlC*nu+7NDmu zK8h+Kxs{@$A8D$TK1ePaKPc*kj+CN)LHI3TJoxgX>kEx5Bp1;UeyRA;I6?SLz;8&; zQqfZtlIyE~rS$ZbyBqMQFAf^#==w$Yg&f7@kq;gfx0rOSm5qS$+t1du4oQDe&~yd+=?Z&)~Ii9|SI#JCqxR$Ev3`%&((YsLRQ1mc1b z{oQfFi2k&?Ua2meyD456(bv8!ju_FSIO3B25=WG$L^HQ$)?{ADyp`QbDYTL))rz+T ztSIXA=)5BGKUxvJ*XS`YevsdM#}&dsen$AdI4ClOqlrYUs_PfwBYj%H{m>T&@%xS+gi|X0D}|5z(BkQTq`xmd z;`gN^eNcZP`XwN&#DgY0n8HIeoTpNGu3M#a;BvWt3el|?9@Li8OO;19a(jLAnuGE@ z0^-IX9*)ELdDyTTc(`snk8~@BUPxQ+PtMQtNKe!j@uR*#IK|L;Tb@UDSvp&1Jni6z zGm0WAYoh2huR1NzYi*_Nl3QmduZU#%@CvDR({hw5Ezl|7S!sKtpRziQ6gG>Zv#GPv zXZ$*Wd^8e5eVPhhQS{N!r>TwWDmhW@hUy>+ORMlW>kewHIzlZ_7pfc7UFs?IhK}i- z^#pyiK3!j;zozfe&*;AfsbF~UzTmiES#VkKjo_!jZ-c+bva#J_2ggo`t%zL_`)2Hc z*z>VB8QU-p% zC|F2a&ck&mL}$cf9qQts_I%1NIVWPGQWTv)^38RkZ9gdbouzUcEN1$m=x!@&-EBp! zIECe|wfnp9l2EYlhUMq%1J#5GI&2fJX2OU0P7V+4UwA|qUR6iNP2V#XLfi9 zXAV8&SHV?La(?oH2^Og@=>Eu~XCrN*U>N-au#GWC_zk5W&k{@jpg*rj1&!?6uZ z8a~xgM|Ppv5Smr_;-Cj25KhW-){nN4ef3BBR2v=nr&M(0 z&r;#{R~+Ow^dMLh80Y=fKhMuk``q%rhZ(bYu7F#g$MNuJn{+w1J{KfF_^_2Bs z8d{B6PtRD7MR6$Jn$x+eh==o6D$n(-lnzKP*G=;v0k1%ftBuZ|0^_f}Lbf1!wb^Cq z<2W5q_*zgbxC(Dr8cq-KPF7J_Owl&17$mQ@BDmnyRxpc)Xhdyw7QM{x|KT!s7mCP} z=+XY~o!m26bHcFD+~`>@o#}eV6Q@5$?{dn&`)N+hJhK(?ucc?Xy6NU!QHI{4*BE|! zXVe-!yNCLeeh{>K$k5HZra-$V^X({XWx`N-J=W>gu4l@)evhlCdzAk~DCe*!ELMHt zETvGW1BG_blERn2#d9gS9pLv}`XGK^eUKjz9pU@Zkspv;L@)Oe5ZQ(FNBt55{f_KH zauFTrgYc1_2p{2~5T-8<(jVdb>W@M}$gWc9gYeP)3h^WTef{ig_I$z6jI?p4yoTrf2Gb7zN4u5?Q zm%ASLjmMoh4sXlp9EZ~j(GA9$5Rml@2$Ilw3p1@aB>uu5j->fZxOoN2!x)(ryWy>^ z7M8ru6M%iFi!|!f&H)U`;^I)2Jn0Uv)YsCu6&Fo%%c|jG!yoLAR3TROj zFV=7uMR=RbD|j&c<8t9#w=Q_1d?ot8a0dv^Bf>>b%hvM*=fY22o9-^Lk@ za~scVyiO_KIX9X^qd6R+qq!7XtNY^k+J)wrC4uRgx~ zXieS%*6O}=WEY|%d=%sG9sj=mLHMQWU*rd0I?@Nxeen^$FMbOcR|p63qkchp`r>Q{ z^hEgOrX&B9TdpsDrSz__!1yT@9gWXY;Uk<%$*mQAYF$qh<3r=TRP(nKxL%Mx zDCYPyi-PdL2MWRQkQxu!pimg+fkCUsnG%|7!CT{}U|4G|0s(OzE`aBY;o+xvI3|yd zBHcJY%Ja5d7Sb8f3u()B;QDhq&vRKk&w051oX#J+;&jf>b>=*U$l^Ra&vAV7rNZHI zi@|)awJnw3I1bMj<4=ym^Hy}53xKyi-kRq(-N0^r!8leBaVZhQ5)m%JEQ*5Jj(>?t zAx9Jivscj_LiK|3Vs<;j4dpi9jjYeL9WA&;u^366-?k;$C1IhED{AegB3dhWMbQl` zn1#7sf&`R%TnVj&TmfZUFDgY-n^#ndro|M60mVSM+m_D+w_JCv?PfP`pVgMnRpmn# z&gnBj;_|fnQ&b8a-9>TFuz{mPo3TT}Lz~NThc?g6A95tRIh+~M*LEM~Wc+kElMYKp zhcnl*cINPA)b2skP8}IO>pP^nH;C{-Nwj!Ji&NjA6FQ)S!cTpCBU4lHseuC{dqkXP^q+%_(O z^Kd$PaFp}#JnsXf1E=%;;B=13>0EzK=lXMcA^pVLqJxl}hvQq(D5NxRYz63f1xMvT z)&SuZya5N|F5>*0htqi%^UI0jSjTED6D;+LS!;;ZlD*b&FH@Yn0=Ebx(Nq9Cl@Lu! z#_I&KD|M?~wo5(=+AUorfhvl2%aT=!e(61K@Aa#M%-(D3(X#U-o7pZo!v!M9X18=! ziVn3~ssqfUlX_$2q611bdCr{B%n#8^HG@Prz7YqcC!(i3{e$#G_`WlHG~+C{T%e|Ct$(X~|5^d^ocz^Lg`bK71>z)Gebfw|76gkkTEG;+ zcS?cqksr`h2H}*7j^s80KcMI=vdfo_{Eu*a`^6Ur@%z$|-&%m5QU4;jsJ~GEA{@kz z{On6da*-c=`4JAHmr8$xQ!2T>_{eXle@B9GI04X+K8PRDkzAxd;x82)?Z%*8mtvM5 z)z-EgOSM|D5!vob=ZAiI+d}f(SNxDL-%sH@JkOWJJkNQ!4t)QD^Z3f*m}svA;Uhgc zo#S)ec%I|)ww#XaEu_CV4%Y{@<%htzo?JJc=elt^m&NlOlhZj4KU~Z)3&~ptg|`rn ztj=gfpJ`S1j`h>C!tYz~jvXWuZQVs@Q}-L!?53`g4CBckKM|*c-5QHpuNa`%KoC&w zTEQ%e;wMZ5l%rckL1lw}Xc3L*Xz_@GB#6IGuWY{~|HnX30`a$a@}pn{!bib6Bp2zAf*8JXE2TfO zcetnjQ4j|C0X-dz_-|wT+~OVoX>PR?t%@SLc^h&>UrV+5hl9KM&BMW?qKAXo zL&e?osp#S19^^dOBVI~BnWt|as?QCN_C)g6(TPIU1I`nMUoA9t1eaEJwU(Tpyde3d z8ZeQ}LYhH^s`VsjD@gupYUy$FBT;xy0@9Sq| z7t$Z`BYk{Vj0mUPbfl-Ry@=np-;uo&!1dnZNk`)Z$wl}hf!uP_k)Ft|a?_DMsNa3> z8_53%2hq{^MEd)VH^h(dpJovsz8dFwzA{I7zUJmUh>q|%f4TEq2QG`#xh*`3!6P7? zpX2bhoQLN*zb~D?=ML$^b>Qz(D?~2m;dyktgVXs@53U2};h4yF6v^Ukc^(FWIFoPQ z`^j)VQjjoiG4~MX=RBOwy~5l2<~b&p<(n@Ahx72!TF9XtZWrh0n1~)+YDXwsB@uuS zThn$+!CR(EA*D)+dZW`^;kQi>+uU|ZnAH190VIk}Tp8lBneCFm6}F06cd_%APH0ar ziznTMXs0A+rF4z}mO)8Tt=Bri1$Qw;`OxAPVvjP2+qu83sECLQ&Ag5{*}wux6m8z# zmdXMuY?eLiz7j-Zp`a+rJBFxs7u_8f1!;pz!=Uo#DLk9fkL82I^c<%N(vDIQ{V6({ zlMCuv{NZEbJ>WcnE@dZ{pub>Ih1M1SITFh zyrKZkPPTQjOTrKG2`hcBlU=kHMZ2kdz(+7UFLcoZqN%%9Mh<1{%jr7V*fxm*8x*L> zz0m4f6R6M+|Z|B%;1e;S__C)N2{l1X&E#oY13_b0lMpZp{Zt znCzr}u!mMj{bWR?fXyTdoJ3eDy^h}7_io84{kXb~(etvC`iTyut~7eaPOJX#OT)y5 zV;hzGf>4T={<;M4wi}Xiygpc@6~z9T;)ebD^@`3=cM%W>o%gzr0k5I-9KzWzZt$e+IW$Ug`l*^Bs*pMCX2 z_@$yFKcI2tYZt=zr6YS$zaV^MmoGl@1Jb8d_~^cY@DUy9iR|+Ao9{SB{e|@R5nV@3pnrYGCmL61{($h&I6?X&9CRN*_98mM*LSff zx%GoHJW9z!pPbHPsGNuAIe$6m9FyzABdA;l9yH~2j)Ulkhs)*oh46D(JWS5%2&Wi2 zj3jfxFoq4o>PRKzB2MRRc`%%7#CZ@MVRC-X!|6PaWO1HC@`d1Voy2>u=*0&j0P7PIFg=GO1+4%8d@8gDAQ?<6WCb;-^e($pX6fl>$f<-P>vIS`VGvKw=mO6^kj_ zD$2J`x{>X+u9CoUu~z0s&V+*QqOHE*l}ENkwY%sG^-L*vki-Bn)m7^pqDTfFf z(|yYl>d5Oy?#&B!Q54RUV1nCLQ|^)rEYVi(Wy{urU45u)zq-bLPzk?11w|}TWB^4l z5I@4-4n$@UKfrC1)d%sH z3g0)<>#Gmav)28F{DAE8^)vEY4BYpI13KbI;~dFF`XhTUVH*M{uEWDIJgmd{IS;3E ze$K<`oS*Y>I_Kv+oL;Fs(z7r1(P%TlSBY{MgD`nV^4K2YFBKi_HgJ5N=RWL9I+w+D z=8q9@-FV)L@bU&6hzsDI!RhiTcY9$bmQwwwQdm|cMW=(SM91=}QH1sGh=Hhe%5he@ zZFe^9eWi>n3niSug4bPiB9*C926IGL358`y%2rCooa|Z&b+NNrO`)d-l-^h!=l;td z2|_bSG!sQL?^5ym&WwHek)EZZqexi`SRs^~j`TtHqW(oVh~Kwg5Y7bPH-wMqzV`a+ zgY-mlefeX+52eCKenb6UD!DDdE<{IuX!68C`XfIeexxTl6N&JVU5Foz&$m2R{(OPP z7aDx6Pzar`GdL!vBTSBu@*D^86hcQhTsM9=1<|=I#KUFrJZj5v_;|Js@+AW0q&-vR_H8hJGZta$rY7g7bm*xwr*abhuu`}=-ah+ zDSFQyE!50o+uz7Nj=nm4oI6^;<>~a;`ONTbee}~TW?2_;C-u|eqv8>-c^KVb(8K5v zZ@2`^Bbc9qA!IsGkVme$0|n^O2QWV=zWlYW5As`orKA3B@tnTbI)Akt zD5zAwpmC1+*H=$;e?ZG`L`Qo1>Vx=8g&zagkuQF^m;J~OzWwfNmoGjV56I8HenU9u zKtU7eUo_5I^{Sk1+FMMoA$o4exe{5izUO$wC_$7432C5#> z8|m*UCY(g?DO^|WKk!h<8wM$02BxNHs$VL)?-HO?a($N)NKZ6%NBl_tQt{WiKFAOK zm5#Xuq;q*%7k)D02Pd${Ei6_zt`X5|KoX#&8PUjuT^N6S1bgl!}nMdZh4m@AF zP~N3>jG?zBXH=tb(2@mRY>`gws-RM+P$fmD535AS@~KgT^!#)kkafJX$)P9+^HceSG>1rO@7Dw(g!VL5LR#~ZQ>UB3t)+2yMz!a;Ho z9mz%ZHi3RY`XD;ONBl_tQsGpa+!XLL8gGc-_x>LPauE*VNBa2o7qS=8k=%(a=)f1@ zd;!ntJc8xRQwTpl7gdNXgjo(c*Nyw69C@UFF|xQnd7fkX(yd?(QX0{@FwVpCTsfXc zJe_&J^9^QT1%;VGsq$LE8R>(76wm(zKk>%d=k&ha@vr*pa7hujb1 z0V|(Hybo{h7yP|%=@aM>wuoZyb+B97B_X8Git@*l%O}{U2b{ifR*EKUxbRel+Js{4Jhq0EAO+I`RY3AFWl8e^9?5I?~6Nj{Ju7MEJ;FUwq_G zq)(~vQLG2yBRbL(+2!juG#-#XsJ}|Zk8nywNBXyb`-U$a9SuZstBu}Y^+(quI!cb{ z==y2`3c%Cn@ zkwUTBmGlgT=~+`{S(Qp zHhO>6A6<_qhJ)xaFisF1jazgdK;sSRgYeO~Y6AM}=fQy{zLMqf0Z!)&SfI<&Q7#Ga*+!4jldD}wrNG@utxu<#KQsu26 zPpLpTjwQhAOI*Xf!5zTqsK+f--hiK3;(;gkxg&Ro$X?(%SAXJch~RbCidCm*>#lXG z*!fGl+Aaw>qG7i-6uj=DQ-{v|oz+Dk7lB)ZRCiM`@@y5$Q4;Eu zLxhd#jy1{4M}~uB#Agd}ZDF18t^^a@w%$?`H6c6$G`p`~yrmRcb0WI$+z#<0y6+qm z;nWHp&52WBPF!tt-!+DBzo2ngDnB57&^SbNq^B<(*@ec5FCF3g(vcq!PPzG8Jg*lN zQ$YG(!j__FZp-J@d}_<{s4eFyN4^l<${`nF`uc(E&)fRuxjr1TkUU!J@V6Rqe6Cv| zbpCP`&JWj2_N2ltIp-gu(p?l29l`9b70ksHo#qt->~1SJEZy9qwg{cwD_f@=TdG|u zRYUiRSV*aoqFShCZeAD+sg57gO+J}?Eu~XCrN*U>N-au#GWC_zk5W&k{@jpg*rkxy z+hzu2S~8RWKYMQjW7}2Td7g7`)xGucJ`8304GJG^9Q!uPkeFEXi%n-}nkFQTX_{eh z;z0yR1_40`VTPdtjw}Sv2t|ZO5sDFmAZ9F-mwTKKnWuR8nGy|-S;lHcBI|M!2bz4pH6thLWRr#4=?@#c;9=09~y z_c@9uP9^WJ#J}uvig%ZNToagIlGA#i^+V4JYWa=gY_h+Wob=MTC=Mojh`*Mc?5xF4 zJhXnu9{SzUy2|pCon%ifeBV5@-sssQ*)uPB*8jEaB)uf3^)`X)YBv8Qy>z`Ly@b!& zL+gs{Bstkh`0V@=5BY)kvv$&YAitSs-IO?Ll1dm&9Q!V-J$>sUe)WUevwf@V*idJc zE0M}e%G9?+t{5uQ_mK%tavFD->v)l4i^LxH;RG_G#$!gW12K zO(gZ4PLmpHh!jMciBv~Gqu0J#R$a=Y_7G=pfAM@lb{N`Q!rhkstW0gkIIuOj(-16G zm*Iv?@t&jYT3&PJmUpyBN8_sgeFpy|*umWc?od;ru@=-#gr%)ha}L=rsk79v#;pz< zSFM2#8#X#@$|eVPRjbz4oL1XtYwo(?ddAi1J1x6=ud}~kfA`MYG=JXQzVn{Su6*|1 z%uGIgZ|3INJ2qB2ZvV1*P|%OC+;ib=7t20s@1MSBbHnt%ragPmrz%!IvHHc;Z?2vj zof<(o1q$hqRwa`vqhV{k{m_H~+H#F0$*5=AGoU9tfY66F;pJ;-_^&^GiIWcLMvB zq?h=!c(Ui^$z2@ELRH?SteLR#XvV9UzU68|woJAuk787wE#sRKN<_-kp@dhA^7JiN zd#J275Ju%6l0~m$QH*lxmW*PkuktHiIkl|2af8ckQV@szace!MbN^tuTmt0Gh+7?a zL8{K?R5ecA9F^O0aILQD-p+BYelKbJv38bh%{jWKMyKV#s?Ig3s(&#d*E#dSn$YRa zS0T1mWAiJsHzTHJwwCTsl5=jKjhQGWqnL^K37?gdJrwhioa`k2T5=jM*;7kCgxH$K zMS2NOIly7XlYjb_XLHe7{wJR7cxfFT?S1V2x$;H(9c9;Bt@V)QAv~?O>~)y#BQ#%R z59yu2^_lb%e-=;n(B6lpW}3d~p9xX9rh!yeyz*;V4}&Paq^x>N@Ff_vP4TKrF@4L` zhHRN^Qy#^rJX=26i+Pm4(oIg(y%L%LU1SHk7xRi*X;zrpStYiX=;*Rm*E3ZOZAdX4 zZ5(Z^495?DLDkLC(Z)Kev0ZlOJjt^GGVzd{?4*F2>>+-__svgwX}pAA7CHH! z#yc-L`IGGF8&4ZBWKYQ-EYpB>G>zLw>%svUi@_b zmoFS!-nHqGIPxzp^m)zTde>*t{AGL1&XMPP+Fi@1hn3rn9n@d$%4eL-(`JXBr}7^E z5aj$m_(m}S-BXWZvfYOf(`GMqQC;5%= z4<^r~X&gf_6U8xvS5E4ym|Eo$d$PJ}#eDklF|||qRjy_AAwAqtypGYx%^qSHrp%+U zVgOT-D)dA}Jv~Nq;c>or@7N76r^)(^Gt z?b+-jlKm&_GkHN9{ZMO z$3^zg{B9-vujjc;_$;ZFhU6M_Ki)`l*+Xk1wvh)3nE8meVgrukP1hWe_! zghvfkJmrasS6y0GjB+YQ%ZgXIo~2SAJ-BGT_N0FZh)7kgPS>(3)3SMBqLwPDb|B2) z<2LcIwGPRS>l#->t#Q}5wH|RQD|BH0?VQ-=F0J*(>z-FRqxSf?c*cwnTdOhTabt7U zOxF-wtI;hkXRg~daW6$Wu=`efJm;>}RULDx>Zq)v+WTzXG4Je@JYnZsIaklVv@?B> z_Q1|Vsb^d`8g+p0-|@}{MWhs=QA9@(HR1c_@7o?4*SzGEiOkEMzV%L$nF6izTI+=D zA-&o8qTdV2$sWSfd=Z}bhcGYXXOa^?*+V>cNhYYFriP#znkq)+C3wYTc~n=IGU@HY zS<+X>u6TWUgKX2jnqw$llP%?^1V}N;Um{nG+Ms3S)Uu9ua9bE4ELWq8ZYow?U|5m4 zKdLY=bZWzScG$RTnQYDV+<)W1y#i&KY%Nr3p%+hz4gcFT{Qb&KIDBt&bMx5?@;i3U zZtcpy&~7|@T5R{c*-SoaADF&=@a&xjinB9hk8iGRUeLU{d3*E0=2OiB&3D>i`^5Hi z`=a)B?V0w&?cMEH+V2gxZm8ee?Hi5BYgsa#}z0vZrso6Iiz-r*&Ry zon-Z9=Zk(XBqw_aKL&pip7@8rLw+VX@smBobC)C?8XT4ckQ#huH|v#Oc{E5?jM}Dn z#i+alub3>4>grM^yJqHTrja zU31uX;ToOs6{|;iTj{Ipc2U@xn~?oC_LR3ZubgFEgOB(54ozvs_2{4c*CU*3uU~uD z+V8LZ$=YA9eSdv@{mS*9U;pLxH?IHo`km{aTmKrrt3!8wickno5!PYFlYjb_Q)Eqg zYx$peNH57LVxq)@I}^T^qm^GdRjy@qif$zlrbMnUN2(2aaMgTOQAztKs}ahtgIBqh`*4c#t8MC_ z65EugkFq&kTup&_{Wad`iCbNIy&zR*bE+DjvUF7Ll%;ERRkxwzTKD?X6J1>ERCS?> z`#MFg>RjtuVD4s zS=Ic93vYS%_?eyZEyANsz9$Ug58cov#H7-uLaihwn?#cR+3^xjc3hP7)8+~#*<=rG z-VnZT{;Z!D4WC_awbnzHhw!xCXq^xbtrMCr;vu~gxXzGX;?LsAo-;?AAFJ!DtLwGW zNt3s0?yBh_DJ#J%MtS;{tNm0~8wjIv-8;~+C`LK;EQw;MuktHiIkl{RnbO9ZVjbR%+92bhN2Idf+9)abMvSk-wsRrQB_a-Em@FF?-TOx^jA|Ljw% z(?b!{H&gFzzO{4Y-pWVq7k57Kdc*Xyt!JkPKrW&mXg>R>=)ahL`2QDo-fo?t=YBxn z9v9)``q{XT;tt}WxFZ|a^vyp^#${W8r*RRU;z8oc;_0h3#82}}dTCyWhxFE3CyT~U z^G0e!|muNiWGyC$EQD9{uAK z6z*#%uNW;;8ldmkmf-0buJWiZN?p`u#p^M6lT#{)I_m(km=ZhQ{nOUhOf@!x z%NH*XAHR0|j`4@apDurGWBngDj&Dphp1twXjW=(+m;dM=#dj2EW#z z-ie3w)>7Ekj+@+?2$X}qMD zE$;ztKDr-+^P4rx zH7=Y}oqg7L?{NoqgK<>{KEU~Lnq4(E(}7*p^|ju*^fre_?O#kcXWuhBBsKhv!?!uR z@dq3CZ2axULz|D;{He|7ZoYi;H#dKG^N%*au=#hJ|8nec$A0G6FCF{!W50Fm_m2JX zu`eC_`(x*iKmPc&$6s*#)yLm{{Da3ob^L+j-#IZn@x&9SPrT^pxYotjcxd~D(pAE5 zCDS^>Pm(-YJn5yhl;otBwmZmgl-^PrPI$7D@U`qAf0AAr*97LB)*+3Tct}q3mBo+2 ze&We)PhA20v@Jt^CVyt6T*|6c<6OIT36)%3Cu6)CBJ3mG+w$s z5I>D8%TInFdus95T5n`0jVtRnS`Xw0ny*@T;;AJk`zP>w$;#eao}Qa0cx%m*C&R+?sVzjJ-?;_W}C1uh}eYfv~2c1gPrEGpGU5Kc!QDEkh#!QQg z7L?JTr26~Q-=!`hEo)yb>t$HwM+faoKOgV5(EX;lYmOhaII!K0cU$*iqkF>U9*l)u zZQ~wKOf1-0=%0|dL*LFQ*SSm8xzSY}vmJuN+D1ROhv0nt9)DwekLeFxX?wR*T5=jM*+Y1e)4C=5X@`~Y^ODne$sWQli#$7z*>MqnEqV63T}v;m2a?kc zH`zlxS$y{Qp4B^n`(xHl;vqTNNl!e6U?=eqp4QtL*~X%M9Nj_EZI(sDm)NG5Y?=H} z!lS+_;ZeNuxAf6P1$8Nt;x3%jmvoiL6+>m+A0$kPTn zmpZ7SZr9J|Qehgm)Q!&g1FMGqOin0FFBi=U%Rkj^yNkS66t=vfO52nbn!TRHtC>b zfr9@mKk-oTPxyJsX}n~AEjf*s>>)hKY2L~HqfMm!ZI+V%Yss_onAJ=Cwd9o4*WxE0 zTIVD`+CL4l~ZJJ$hR;NufbF5fht?RO+T(a(!=V-TXIK4W%guTCNxP6gQd$e0u zdT2G})^LL2JtNz-yyomH?`V;Z##N7Y>qf)jXgvF|@3C9gp0$3pHRsk;wFA@i%-PlF zU$gV4j~48Y8SS-&*fg}AL18zA@04B={)m5n+bFb6Kz^Q=oYuoQc?KejKMnk3Pu4%Q zZplvieGz_Ma`MBntT(csLKK_Rw|`=_P)`lV1A0(>xLn z>769~PyA#L$%!YsT}XZ&!njCp`zhH!qx_a~=0(G6?oGBSCR-*yl<=rMB|M5({_UG& z!nC1QnH1OJS02(;B3BHR^?iN9l*rX)vzwIdtTRuq@G7S&E|C)kU;m{JYUt56*3mZB z#>()KEuna;CY83wRIb%kQ;I5r!>xwcM%%T#=BzL8XpxS_Rgbo@O1H6^pWXS>+IJkx zmtFZ!VNsM^E4n3qiYBwsHu01F%PuGXkUtNrJUj1MzY(6+&oG%aW$mYIjj}|N(|X9_ z`}WU+lA~xOSrX3H!mF-6$`m>Gp)31-ihdtW?ID}#MJAdHU-((_N4qA#ERv4I(S)IKnU{o?FT5486kOn|!jwAJUW zzH;?#tM6a^#OfDUzqxvDbZYeU(ep>I8r?ej!03~s`$yj%x8t7}KV$sDrlFh9yyO%V z%*&pvUfMdQaZwOKc#_kjF~ZZhvi4+yFVb6!hk{4aTT4zsC5>xYi+lq;&u`KCV-9;d~v^h{Y*%)rz@?H6qp%Vr`?XIa=H3zv5?G%WI*Ma+>`jL-lUL ztd5>~CX|F0WEvgTRo$m|69)RHOYI%Dv#o7E*4C%V?`}f-<>|lQzmODvb|)#e@@Z0h z!z?MjdYTl^(%)V5sC{r(x|0RfyNd>KQN?Nk^&^(eogwKwb@LB#Wega7m`IGpw^HK{> z_K=+Pk{_}>#7}bKr}dDP(>xLn*+ce|-$?JW@Yk}lZyxeL%~w`V_S5wGW37Ho6%Kv6(F#hs~%7 zZP zYj^hNZXDcpFrVRUnk~pgDWasvlp*hBuObx7mN+E4ge{C)f9jAXGUN)>rYgsYfZJQ_`B%Va~Ar`EXC9^zDt z+DUT#gQ<#FewFLFX_XUyR<4{{9=u}TxA`yzsMeSva@bVWJ-&1SSc)nJTYo!j*yymX z8e80f*ngSH=?$5CYIG*J>K=d2^TGq;TZaE}8kqjm&I98wG~a4&zVK*x7O3vZr;mnr zPEpSu7^`%4iu&L0oVZ?qQ`8*p(rq^@r`tN+Mziv)J%p#zeG~=|&yhU-4)Rtq%)8Y5 zNxv78lYeO4_AMuSC|u9lGlBJ!<;nV!#`1@E`EZ&K&ONWVWt>vS$ib((a|UR}L!=ats~&z#=1@=^Qe zH(z|w^}fOEobC)gYIo1+TI|*BJvTSaxV@*{c=+YmM>LGuKixUoJL_}yVZB$k_f7@l z_Fm|7|8AdYDtLD1?C`FwUpDBAR*l;)&CUt`*w{wxUrcrU+nwhj2b?$9>SBH!dt?VUb3I?Bqw_aPx1+b^EB^UfF~Z3ll_Fx%87rRv_Csv zgeUuH{S!~te!|oIl0C#j@~r*DL-Y8Mq)i$bQxvNhJ!@4G0c&67*GQT$B-g%VgJM*# zauSB{I(B+$p?ypEOYkK&lP>M6_N!jSs9ZS-LwKDR&8CbE<^zg0qKkvH5bGkvgJR0) zaeqrC?i#m-u86z3);<1Ag+q7H8+J|2p0mkSt)7MSxqJ3zEpC2w=WV->FWv`h_WBzf z>+9=RuK)b{FR#CG{kNOu3Wyu%emMa=$?1Mf_^h1l94GC};>k|BuM?j1_8n)CUXqjj zG{0L(|7ZEhPO^vaSwGX`yzDxpbwYNMob*n>|HM;EJ}oGy%C%N*g4Xr-g(*Y=mNEJhU6;o2K#Y4Jk@e>cpv-aq`XkU8! zj_^8nR91dn^orMUshsrQEkAhWSG@8lMs;af$4++Ie9+chvKv=ZS#_uj@FdUj5I&oK5D(cohJ1*4 zP6M9g#838+UgDwsB*JIqwem3X4~>`PWIwGFk`qr>FZqY&h4^V7op=aOA4exY(7a^v z6Zl=wcnMGb%#MqAvhz-M(zuA9ZnSo5b{2LzIhV zK{s&}uQRA-;9+?^=&c>-!^kSvrKw)h!Khr5ZI!pz*ibNxFkKKCV#7ukdFgffV$Alf)RjrL-qjQYyX={!rRSlVJW1N#)tUpX)TVo4d_Ts8>U#llPUQmrF zoT+%>#WuvP&2y(8G=9y_q2$L;Keh6L=GD#Hn-4ahY9465(+=Awwx`<{wXbW>v>$Ho zZokrgZ!jKQF?i;=ib*t>}`7{j$A#_vD%IPMaCuEO#D6Ju`hSM7EYMC&~qj(Cf zRjwhr_SLfD)drPoSuvV#D5qjnPCSayvhrw(p=HHrU)81M5{&v>$EbQ$t~O|%X0lvT zteL8kDqa<Y~MGoT7?ds2RuHU1C}nEIYk%)mi1D`?#-7 z3>?-ry5ZTzF7}67U8Ad7BevgZ>&`K@F=Ps%uBWRyPdFRg`_it}QCTb68*M!V`#+rO z^t!&W8E)cT-$kS`;_J6wI(s$tmv&wTp1okZFuCmR-=-bEmLCvO>_Ras z#aM)|CC}PfOK&Y6@*Bm(S@{;^66CimKgGUTp1$$5#!K@>>w#i=!e`~QCq?7U+E4Sl zEOJ^`K{Hg9Q`uyDN~&e*oRyatmvvSbeAa%#XMMGOEBvbV_*j)q7+lzH8_R2zXbwQ3 z&*Ku7C!pm6vB-pq5E?dGmA2+Ox~fZDGhH21Ty=V4aBo(`YUkCO8LrE^oyixz9<`f< zKlVZrK0QXp?ed|u|-Om|MllA5+J1PA1bH+2y zhrev~wH;j#?3~Yi(c&iL<9CcdH2(DX!SQ$3R@a`qb{#)-&IY!WSWv**H-9aADBvag zvw>;DR{ll((=lAL&GUb22`zb?6u zCUcZjsk{WQ7){`aM={!0@q|%1CV2`{QdOoHEi1kcxpJyKnz`eafr7Gzs$%U+MwH-{ zM~_>poOrs(jf2Q3=A5@$wEZRcL6Ax0B9&`jbLw3+)2Rf-C=tosL-#Q2nSWzX*2d3R z!(qD=I>frXFvQks40)XKaT*&kxuLnL?P_ZwG}Gt? z@nBqAwhl*zNVFkJ4+@-oGn7|AfKivoDD6Xc)I&e&oICDaS?4voDC>B^J$&R_t>B z#q`VO|9R)F>4iKd@yM>s^y})$xRv72teoQOEDz-YS)MF@0)8WTmVeptl$T`3Rm-2W z-m-EUSC*$1p7K%h8~t8LPCR6PEj;nmlGAv{@Vl-hC;Q1B!ju1LeG)&7i}YrBvi4;8 z37?hI?}+>}gmq%}Q8Y6vDQl`oc*^_;Px2C7)VG$rWGtEutIgT6o+!!oRlM4)wy9jp z+E>f!Ly{||q^x>N@Pr{AwNo)#&T?vBDwp_~`jS0+obhlePF%GqO+9SPR<`Z*&-rAA6(2v_AYs zhm-F9$+YQllM5RjyVhLX@UVIx`IBkh%GrA%KQpzgX|I_cV(In05PrvlLO2SQDYT=| zk??)<_iYc2YhH3n8RlhA-+CvIN|2n^d98IqsSWvo>?AzRZ`S@T@EhsPZgrlK2Y?z~ zY80%|rD9ZGf>%tIM|E{6lin_zC4F`5iq}WBWSjQYcNmmMF)Ar|?!R?KG2ZHpmD-IATP@@|d z-H=G0<;mi+db8uDz=C+_MoV@QzHk1%?V)kaOHKjSyzJ>)?*sxtlG8e`wN8d$5BY)Y zBs|S;*8Z)eo!J2T+l!wYdgL74 z?%R!PFJ627+Pl_%f9+4!{(9~E`j>^*?_B@f`q$R~k>7u$dpF4`HlSFVVhrLZeBb<} zm&Qx@Ws#HrX}t52lRwFxzVX>ulwxvP&or*=d=Z}PCp`I+>?b_QDW@Pm5YMv6$!|2? zMU&HduH}DPhqe4bcG5Z|IoX+&)3|7zWaY%4m6IQcr*HfuxradS1(W@QRyGkdq1Cb` zftnoBxiP}4ocI-^x>Vjrxx}8VUiFXmrE!rQU!v3uo;vHm6r*zU60bItSgjc9tC&8@ zIK3L zZmMW)X`fr8DXxG}o=G1{~ zt0$_31|~Yjuai413sT)Zbi1>Ycy;stOiwpVntNvN_3peQe83n6Gixa%PGSmy@V$}5Wa8zTJ{hR$!pDT zEq>ye!1_N0a>CPiNlx|0IS0DVxXY+Cl%)vQCng2d}UpNtZyP9+R^oBwn~i_jXoh zEODqGjx-g`hBk$2!bTUeX0Xm8HyTImz^>|?*!<*9Y+3QFET4X*>v#5gzm>cXQ=FBx zhwP;LD)EySB0;wL%TNqV#Ltes>}E&D0%pmEW>kUb=)ab@LX=Y#S@#7 z4(}rFJ`r|x_l1zx5yjPdOiqs_c4M`TanKR+#$BzNsOFS3|7qt`*((laPp9k;vOjXF zjQ4=(8b;Sbl4tJ~#8b;2y7tpGnC$6W-nX4w5L?l>=pIY>Ns^y^#=m zlg*Bo?99$P>8-UMvi8ut^lj%5)_*PgPXj-VH>)?h9%|vqev)VB(d;2=Ag2Ma26c+j zfSEAFLvrQOatXc!qnsMV>R5D)iYI>Ut9a$nzAD$gDpwvYE04-Ex1G*-LO;D(7SHdGFWFs*fnhQuh2Q- zRMjeqYK}I7+BWAh$K$HDtF495OSI7qJ5>$6>t-#3bw-3;!^U&}V4H0%RMY&z&aUp| zi*-_pzoSLj2xWtWrz|Bar|crjL-=f#)i=*6$i7H#R!&*pIH@-~FIhaz3#~)KlYfYZ z{6Kh;lU|Z%?a%UT!Tge(_(`7iLw3AbezJ26e#qKG>zVjZf!-m=iHF8T_K@Fbyo4vc zG%k`8KmOfEO(iuo)U6jy>9nkURjxc*u7y#(%C9!7JbP?k`;smlOP4JK<=3+Et34zq zj3&-xr}{@R%1OQ@n>CYH9Q!AT|6SQqRgChuXtcj-7zD>^GhH*- zSZmz6$7g85G$d?vYVCM3Jspv&y4NTju>-rRwaGQQs>N=<)7AgU*4@#p)K`QolQY9r z-EdvifkR^3=!jf(`>_`7^tdUp*i572buaZeA3JdKg64+vuW8Q?EG6a4mu@LMI+8!`^2;Chl&AFV2TB|I)_cUizxl280?v{Dcm02~{`a>IY5mY=Z7Gc; z`2^BwN{`7N!e{v@-KF%K>><7SCxSG~SoX4JG|N5~wZG{79_=u%^0G{Tc?hpYYgwJx zMXr5yPeS=MUvJOanBRrwms&OV-pir)&LMQiAzbh-lZow zAAfgkwP`5iq9-I25)luDR!8#PP@eou{-F>yJ1;a}%OcO7i(Zy-k)1TZv`&^qz6Gh{ zve;kC&Pmb_l#0{(>HE5IwAHnHJ*RoROCBO>vZ6_hCORr#gfcy3rLpKkQyo_y<)Un~ z55+n$ACd`Hd9AYAUyC!#LzAsf#kH(>E$djcynScSPRtJ2W()0ow(hc$u+d=`v)W~V zcJv2bbN)PN^;VxVCQcO{xMH>DXFV#K<)~usxK`_DTXVd&(bn2qY~6L8SJe&HnbEv) z5zn-bpD}*n__g`JDeb>hvhlYY4{bhX^QShSyZQ3X-{hkS6yH)DUMudUxQ_VA9+K0z zNH670#Ir1N8gJkB)4cR8r$?|^y_64Td6pet%byd-$LT$N!Y`Vf?59WOWIye{W$|QB z-}?!**2x&wNtU1dPy9pBOV>%l({+R9k@(3@!q1C8tC#R3r+FlN)<1poko{v=Z?vw6 zhwLYOR!;VipJ`lI!g#Nf9SYsL(47U{>QH$WU(#1O`!4G@8M3;xuZ~O0o^EcR(Nnc%3xvnt9 zsEcWlbdl@EukL!QTs%;CZ7{!s8|{XSt+|a5+vqk;Y-89n zeuE<<@*7AN+=y zV5RzY0S~UXM%&uCKeP2%Q;4nA=-xMT7KK_IHm+uvGT2zFUqf!e#ho44RXufRv~6tu zU30_q?^xY2zwgHIi9E_+D5IhbhB73|gpTC#OE=`tEyy5`{2BYRZ~xGI(eEWIpTNA+ z?>8%_9TCz?<0U`Sc!{6z#837Up8P}h5T5w6JGJ_S-ICl!bDm`>)3}z!ezj9Kk2L3` zvi4O!l$5ov@^_Kz-<($-wWmaGPRVHp8v@yyi^FWA52+ovV%0@>6`38CKQq(1jR^-o z^i_W%%r1ba=3Em&ZCgCo=iX}3p_)vi!@BDJl{z97(b5UNyRP z^nuYQNB57uJ#HWQlfJv(37NepwD#n+>(*Yp_WHGVt^NMmpRE1$+V|Jj*RSMcC3|zG zoAzOqQ!qlo)M1rp*9qatKNM`zx~j!bJfwFFNdwtWa^fMqWapyslbzXdW!DMe`?e>` zpS6?lV_1h|58;VFE6?(0@!4?^f0kzmekT5{B>oiO>32=`(0q}dluVQT#7}mfkq6S6 z!)P)zud>>s$=|%n>Ibq-FZ@`sXZv=+m-N*+Qae>nJc^;Ra_WYJa%$hK zT>EM{%cFQI6F=egFou5rRehpm~4{2 z3v8&%)2-J+yQ?*3h@82utE+~CaadRF(AAk3GR?nn{}wopxvJ%jYGKDrqjS9L8dtY} z&YI}ZenZhUyQ(`J5g#*heO-0ieG}MP={^bEym)6DYQg?q3cp*He?N=@^lSo|#b*Ot z3aqnw3Eww=RxkM{%R_jQlOM<)!Y@02cD#h2K*CA>p#+uiB+t%EmWRfhwV&)He!|zX zpZK%(5Wbe36IfTIcV6=BdLaK~<>VjYA^&7~vUu7U(ogAXzNU$v$~EsJx%Ta{VNs$> zZP2pzC3{q^eYH$HD(`|<8@iOq9<`0iC4Cj6c9w7|UU{^y$_Za0*Y&1*5hZ;UlP&95 zvSoThM(tPsD8JgNa`V2=;BuRN*qTdRdTKlvE!KEj3oYme8mc@*jYrDpR+u~PdwxwvWHUAdC4gyrFkSd`G>|uc;csd$>NEJBo$p&jn5gqsvM z{SF)wyRMGdRYPK{rcTZ2PkndATHu&c!**(2-?$oDVH*#+=3n=7E7Fb2IWSd?x5S+y zS8Z-uuG2dD(%AIt8W1*9TCwbM3Y#hXUUqrk=@0ocE8l{3NIaBA(fTAFnqR_`oOmWM zE=uRK{G^xUWDog`oubd@v<=4JiCLZle7?pRym-N*w8M0q_6t8kED-V@*vrNBkV>Y4G5mY7xD%bre z{XFmB;|HBwM*3Xt7(x4QbH=!;OGaI5s=CL^G26cyjn!$gP}k_H8En|q64_d!ql3C? zNb9h!>gFoktctsaMjS}vfhciKNa(asTXU>#R$OabbyMi5tlAjs^fdq5GJlC^{pZ(z zdHs#+zrB9v`sdcaw*HT^KQGoa*^^Kd!e`|a0%v*Vg{LRX6vAiyOsNCONiX5E@-d|B zlq%8u5>M71ns-WlviyW6Ka)LKJo$&@WM@`R^F{WPoa`k0yyWDcto`)6rgcboT0dk@ zEji6Q*+bVo@+Vy%$bRC<%4u9%a2+H57VtE`G+yE%KMX-H;mLj)FY%C^_-Q@V8gH%Z z1I-uNN$ZE~LH_r4*mBd%Uw05{mCYw&bVtB^2v@b~HiOEwv&t1uW%IX{l!!X0T$O2G zl`CHNIuuWG+R#zF@>5x-QuS(CPaCPcOIiJ(=aH0?m%*K|cxjX*S8ZyMIpLvYh zlCo;kGHOP+xiose zH0AyFtrbxPjmrPk zqgb_Di;jAa^RNDtoSmAe=Jf1w!qYOjzRkaHuAY85eE~jM#UG=he1qcbTJmgsUrTS6 zhx|kN`Ml(mAJ(#m^6IRA2v2faCxjLdrM{)IKTP9dH5`v9>t~$2E@koA z`$(2Qizolk`XRry&ygqPnkHt;l+J3}s+^kQt6bmXP&wg=pVD8#6F=FbkF(WIO~F-8 z_$)v9**tew#hTZv98W5s268ECxE0nGYS4ouYAb5E7Bo-?b7ZGT!%d=rSam!`gEo*h zGY`6Ybd!gONM2$o8H8tBv;&n%G2wMh>44P{=JbcLZg5+e#_#d0bC2ebx~IlHo(sGD z-&OsyTi4uQ_1s57cf^Xj4LSafP-xcv8}}IR{)xk$@zz+_HEfKhCvFXi_uuG-x__ct zJr~ZPaA5JAxmLG{=vq@%w|N=bXd4gX*5wwYBow++3PyO6Q_4^L%ObBet}G9wR9U}e z^%8yv>uR#(^20hVniop>X^V+|@5>^m@zU?8?{U$*kY2*mxN6C>dI_JE)9;bSRm*Sl z;-Tv;t?a=5`;hF+=q{S>`VU2qP&SV`oQRuqx(nS9=GG47(ZkD!traz?-+k*{OR$7q$i;RVs5rO&h3e&bKK@55cKiSD0dG?@{}bXG-LH zJX819{J}Gu8@go7HfC~H+cVC#E!LUfkDOU>|21dMeib_3Io4D)c4mrdLBJ{JIIx|N z0~?T4>ugF{;rd$QJ+_)+MQ$r423KqyTyFvXwpL>^ne804H?}u6k8iGR zUeLU{d3*E0=2OiB&3D>i`^5Hi`=a)B?V0w&?cMEH+V2g38y{7o|p5T+r*Ez^3%5TR-4Mno4vy5P}`=&@A#*Fdt zojx4erPTG@TdgZ>t@*W`zq^da=3HXihrvOj(5I^C!lKixO zh`*LRJ1$yRBq#q9p6nqx@sGj(#8V5OwTI@B{6lsU58+8pJQMI|mWTLBp4Gcm${7M% zJEpMF*>AC~(JexYb(0z{0Nc~n9FMEE8zI^@P9GHBF#Y>yvz^1M+y6X$MmTBzQ*-r705`-svNKX7?xCjwXEqvCV zte?qF;vqcAiDv@-%<>RF$+LR5D%p8PZW?;gEh$sNN`3XhuNQlj>&>KZx!Sqxn*_C9 zk_m%)7pQl@hFx7_m#V$xIi|4Djn!gZ_8x#w@}L%4v^1+vx+hxo}Jx-ip? zo#b=_Avx*I`hoDoe+uR$%R_!9|B#&gla-VGq&MrIA^45_ki|a;H)_2}>J3eIvGt~^ z7%i9J^`@15Q)t(ZJeiyRDHXWn#Q(v`5-`3S&LC2`__=X(6nP6`=;a$r@ zeNN}Py8D#7i@BXv*Eh^19o8D<8uw4+>~*7YYU0sY3mvhmIwrq2XsP^Xg2)w1HXZ2j%lgl)971k|k$ z%hQ}}u0H>o>HhDdOtHR6%F|75Bsonh;YnVLXAE~jy3^7!BObD!@VfkTsc2d63@X=h z7Nd6+UAnrwmET}CR)*uz#eF=x!fY+EZWB;!$ z$L#Nd@U+h9ayo?HQFi^4Ui!T(i+l_Gvn=+Lo!R-#@=U;QFP991u2Cx2>lIzcR8Ds;7 zzXpYSe_n)N?4ip5{oF4#Io(b#H9yoE?-*_)B&YD7@FYJ2w<*2E&a14qf_d4{MK9i( z#OLnz;xYwyRqRrAin>s_zS}>Sxw5;?3sOz_J>|;gx}9%fo$a1e*>7Q$ya~%$7)8Ys zxC~|SlqJz+i}1DVAs)IclAVMnIq{G^D%UWAFe+C(@hC?7>Q1R*w5*)kSLIYz zo|3*Lc;!*N+MpOME1u-aqdU(feTlO~PM8vVvKZB+eQk7RYcBjsG*;Whb|$!LD%Og@ zc5qS6*Zfc{wqx&P|Ex7$txR4EB=(aj9{#y1B58dWTPJYPZiHGE5 z|Ec79AbyfhfSeaq=uN7r7uc4g(nPuF&`Gb<;4vY-4<_LDty?;yRzPxcVc z5bkBfLwbpy#!G%CJlR8f2~YN40e;YXtKJK`liQVXIW9XOWH>R)XG}C8NRsV9*y#BPp#_^5G#dGS-KK=zZJY8q8%BONo?X;{IO)pih6{QcRYgbg4XBRvW0V+N>CoXEEd}<~AEu749nsQ`w&Gc=9-)V0dKB?=Wq`lrYXqr>WtY=aQPuU7RfFwNGlf_f^n&rvj z$$rYh2%nV`Kly>=S-phs+aBU4dq_^}a}2+WzSjfkB{_{ND<^-FUXpjo`Za{61Vu9) z#j9K&S`&uk%2R^rg4e!U?nAHoGpj3WkMe7|gvUL>wl~@E&g~jqH6(J_xa#7SRNxph zcd#+j)w#HyO^!HYQ;1EuIAn5Tbq2duSIrO~WW-1N+;nH7I~d_>-AS@MS^O!uV`q74 z;cNMm>>>U!_<`iapOyD*5AkR1%tks{e!`Re<0YT3_O-IJs$quS?p3ZKfy%RZ<7|XSQ$F9_80^36HxAhxggAvBM49b#>LFkniD-tUJ$S=FW8u zr*a8HzRPCcX>V_wzKi(y=Gx{3&8wTYHy><1)jZIA$9}vv|5u>smNhT=RdCCimpxg% zv`L{?%r0f~sI?0b_4TiY+22whweZK=hr;RKd!GHC?%VtK)ZX2GrTyMuJh)=;%;o=W zW!Ey{m;EvmbdQ| z_g{k!osaJi8_3&xg=Y z|FFvG(nY(smzsRhF5!A{)6K?tmEBKuzZZc1`z?*3&A*v_(rfsn>7O%9UhM|&9(;80 znZZ{FKNxNfuNpqfpPtJHlAj4r{-kHc z+3}KI;wL{456Q{STK=hJC;6ZFXSst>V{7HH>kkVUpmokM3T{x+)>eBE* z@rqHoma~18Q~Ro%!ZgLG4HO;{Kk+EOiw!0GWV7FIcDHa>b@y=B>Z;*q*c`TE)d!@G z+D0tfe`xC=xV<9;yS}MchW(cb9hcSXLZ=Yx8qdAeQn}_@jr->reA)K3wa{F*-nReB z_MJwUvzI)tIj9J;g*zkNaY#;gimaUYvv;Vh-dcFNGiJxN1@l67l7F)HP((yLq?ha@ z9*P)=pZrO3;vqY;{-=nG__Ov9f0l>vVm@F&)siLCR5xS?Ve4w=ght zjXj2#|8Dm?A_o& z&+?FeXugP_<|~UQy(A}p5}xF`k*iUaZUAc3rqLYXRjzpAQ4E!dlklob%ZgV{#b{aa zS-E+wOOZD1DFTwG?M)fyd9 zTn%lAySifrA=5k>+gq$wTXSQxjZrP^=(c3tYuc`Bs_ML*>YD=FvAeE`MhmsIkkvw) z1xPU{RHIun-3m!QhLDN)YvHr@klw7E#x(&yWaVT}cD!U~mY?j;+CzBa&#qhIAwQ5k zB+v2_eq3pt5I@Q1rI+j>In5X0v-3rG(o1skC*eunK1X))HT=^ZU=86YTvWXBD4uu} zLuKM5yz0`j;+0b|T2_2k-fCx6rg=q4Y^{owN9C$3TkeBL?Nob6m-R)^gwe)Cm8u`cTI;__&RyY%L7L+|h2^bHBAvV?^q# zaXd#=J=PWZF=}&OjG$4QqdA&6ottSwGdudfF~4(JaY93Z5(PdK=+ON`(5TJP93?cG(40R%OK6(go12?)`@c8M zxsU8hrr*H6xtZ)rc3M7r?jy60k+x^2U)K3@_RBg=Lnme^Fq>CEPJtN(PV)-D`qrEE zf7Wm0Pm-IHDECOTq@P08J55Phqa4NS7s^y#Qr3u}1Wyrw9{ecbN!?l9B{5aCm)ca< z6xf|mNcD}c@f}7myEtCB1YSM6IPP8=r~e+C$(!5x(#Q{?=@LkC`sve8kmU4JtEHFt zPs78%GxDp}Uy+vem!Q91Eh}Ej)R*M`ODcBR*qU3Nw$awymv3xi$P=&XE=w@GLr z*-7@0f5^|oL-R;Hgx`YsrTHSBA@C5M{H9;t*Gq<$_0mIf#b{aadb!au;YqHSKjP88 zgi*Pc)n<*2l~XZPR?gh_M8Yd-d~XiB+D7-Ho4dlgs_p6;T>x#d&R%!XcTDlycCOW_ zjSpNqMgB``uGtP@(e57fYo@DFR-Wajt5^1Fn!S3_xag`+c9PyKKk<+s$WG#+t3J(F zmWS}`waEOu%hp;s=tx@G0r zt0(by*~2H!KE``sd+aXmbrH5}?NUu;8%v?6IWfDgH#f)Eb-s2}RJdr@O1d_Yoqfxf zzCF`^*#D8xi=hnt*}O;li#PO^<>*zTTSp%l zeX?n?w?_(>Xvxx90>Wn#G2$5~!=t9UJ|F6Gg(Vze(|O5||_X`hBTtUo#6n!~;ho2t6!N^#%N zik=!X7FiRbnq!TiQCpmSxK>LPS`aq+Cvk!Hy~h&( zH~8j{W@*~s6@%-$oOXG%ziz&tG;MVM=-cCV{1fA6j9)l@?f4zz4~;)PesKKVwbivJ zuU)tH;Wb^7?%DX;jfXZLv-wk-&)t0a=5KEP?&cqDeqr&JfU z*zXrmAAkJuYmdL+_^Xe<{rCruf9m)H$G>x8c;bmCPM>(uiPxPtbK=7% z?mqFA6W=>IK6%B-XP$h?$v2#Q_sNf*{LIO(p8UaOo0na6*|RRY>9RLn_MXc=cG>4I z`}$=+e9XzmJoPcpdCbcm^OncFkCRUdKC*HOo=9FRXv^A3K`rr+ofOPv`H6@0l0S*( z6a>x0LvpewD;cGi+-$8{Bqmw2-B?C-9YpKI~dnwMHUS^twC=sKUBFWP+1+Bq*g`KNDtXg!c# zny;*UwddbE*-vu1PZ54uu*6ZC1us6c>L=K+JVYSRI985R?e)v z#AfAGyz1S)sl!u2C8e?|BMix@udWmwq?XO!-XjGi@(=awAhp5FhK|0E^5CS5tPcgs zdG6byOrMhLoT)2X^}B+!vtiZVi(GcEw#AQ*Tx->QW7@aB3|! zs&%obOJ8Sj7b@2`FJMBz6J6zRb=Ua28xoY!&&Ay8`e9~gPQ*js$>+maAv_(r>fwYB6@+?o* z9>SA6YfpAuq&Le$czT3GdTGm*@WfB{kUxo^@WfB{5I>DKD<^xh_7k4$Uv@c-m;8AO z9?4{R2v7bbdq_^}hxkcO{9E7$!qa#Oe^}-8sBaAZtR<&MlH})FdWk=~9>{)L=h^Ep z*-4L7$^J!?lOM>Reaq?k*|%O=Cp2HQ{;z`HDXpLExQK`3WIy?tub*{C%ua4yL|4=@M9}cTx(Lv5z0>(wNvq(&fTaoD%0rzHKL@f ztD^+3811WeD#jc`A#Z61!l(i*>(8N8A}uRu`&wCZ1X8(bQ@L8I3KXvx?Mrx)D<_rp zT%2AE+LD8DYG{|TDpo^v@LE<~)K@X;pmU#>ne^{pyC)I$DT!-z)e!4WuY}kPZ+L>k z5xJ*Fp$*%A7&bd*SMBluEL7XcDraQIbD>d=_rR*wDEACDv)(L`v)?Tmr^Z!XUsrWh z{wRP$hgLc4ysB;ubuq&Z*G*rF*!8u$tOPwIguM4!i%gY_-^$(`g&aYmUa5zWsBS$u*~{smrVb zJ1aw@!bWFth;=r2Uo^k5^EH;s59a|7{tgOlT4gtZVy7a*T@hcC_qhd%` ziCi(}`4K754m1G8C=cQ1CD(+J3C9 zXPCM?yNKV44Q+B8CK-zz)>T8T4%_8nSZ15aVVxDO8iQSHI9fM7AvVJs5`~Q!EzT_= zZ`kOF;;OUIfkQosMmIA~OM8o@vNZ=zG{(O<#~VAsfn7CW)Bbz<$7ybKuIlu-(OHAt zCEisn);$qx-nR4Ck?uR38@+pfc6<7lp(<^;P^ywmorvd1{ZokzMKE!uc#UG;5eExlPDx?g1FG{2`Hejs`KUfH9kojl$BqMb9vEMvK3?29}u zdL)sxxsUlJ-;yrEXUEb7Pd1QTcbdEC&Gsc9W_h~c57LG{bamnE(pT3KmGx-@Vf^0@ zw|h@+PwS|1xAW-Cbgix$gX0}f*SAYG^ki&uO0C;l zb$vrC!bV3luWD%H{u}2toFl!#<;OW^j|Yzq-K82oX-YAtt}hPko}r|y@>kH<=;jU0 z4d?%+J$u-nz~sMhNSQZfsD!U2r$hQ#{t0BpSsub?`Lp<}|7k~)c1B4~JY)~yvvTrJ zmZz4TWDohRZ~QR1p2^NJ@DQHHb*d5%%`fq1=QlfE(wpTWztMPUT%?!ewRp%*dXi6a zIxI}~lbq&-?99q1u%5}DEPgA=pZz@&p5!zx!e`~=ADTz9hvt!Z2v5HYTAw5*9oAo^vXFw!l+#Nx9=2(nzxA*rM>k6pdM=V zIggn^{X>%qQVm+xvF&lX-fd;LO%vCes-~z?U}uKg*>SCLHQp<>TcG7Nr?Z!i2^}GH z|BZY6EfhQIjG7EK)DjZ8soy_QsKqsQS?7+*8a%ko*8Svk`H@)WsOEg?ERI|K2wZ2Y zHf-ECH0D6HlBz@uCy2J8|a3hfmyn;wvY>+uUpAv2I8`(*E$xcelX&y;0;j{K^!3IKB zFO8S%Avx(KJo$m_p?N30w4p(G+6W^1i6^U<@HAfXPj>wi4~>iPWIy4v{-kw6{vmrv zp0%eIp8S)YNAgeBPU6YhLwfrjZ`RIn^7=`58ZXJo55!+fPWBK#tp~yr|FZK8lk0^1 zFae&df3kR*uUhh~e`@K?@=U=0SsuEN5D)o>?4j!z;c2}Q53L^>*H+SR+3{xi$$s)b z`G@f22l7AJLwK@>@Y%0z(T2KiQtOtu;uWKEEtg#lr>V(qJg zQ64o;@haB`h{{7{ecGTH^XNzwD5ylPhL-eIXDMEJRId(Loke|>M+dKczadX~lt=Y; zk*f`=S2-2ahhE}T8?>zRqJ6cjHfvuhE1q~1qdbaN+xo!hV>sehIoU&U?OReNpOo|^ zObI7F$|X*nBgLp(%W8wlwX8f;CcN@%U&50-%d^L0S-TtWcHCXnV%-xa+vuJq*hWX| zsv*`rcgS@0XX`sq`M;46nrs_w&DnqMtzmPbu@-cXXJyX*xEk6N&xRvD_mPmxpDH5LO)Lx7bB~~4kH5v%UX#%5 z4ibbtot3U?yShoRjjm^N{q*F(X9w5M=i~r?D3EqjNKU&r%PyzgyIOMEg(CSD>;e*= zqf2GrklSir?T3wn|k^VMEnt) zFvf8q&r($FfNZTjYwJ;MDFz!G84WM~NP5&W*6Fef7g5K3U8|#Vt#LKnR7s33;=vb{@0-oWQ!u^7M^A zowSqYB`ZG)+Ba5K*H>59DS%XYHi%Wc2BKZc6tt_IR91WX2-=lj1MKX0j{@?CzcP6g zkU!#z@UIpnLRJ?TyD0j=0`gc`3XlE#AqQf8PbG=%52 zao|Qb5V|na4UO=%_(?AXZdtvAuf zvOnu*;vqkf{n;0^bn``TyL6k@C!-n?l=LmZD?f2+_(686F6C6Y@~a>8yKLs=F72QZ zlqyiUMtx+o##o9`UCO`5Uk|Z&3U>?Ie`EOUF4W>~{24k_VG2^m~4OVG$Y#K$JZEoi%f};VtTG7=ui|?CH_khF2>>6FwE*;nCs&3`Gsso=tKU;75 z@jKJi`OnScn`@gFG_P*n-h8n6RP#Xdop#thu|3_ssC`{~ru}excl(w0dxP=dior7n zFB!aH@b1A!2cH>yb?}4X=J2ZFvxYYf-!y#B@MFWz55GSA;mXOCr>;C_Ih|zIt7d!k;XRIl1^^YoJv}^S*fxG5Z zwUZ&uzin=qzOZl~Z`sw@6j`}<kUUx|Z zs*$OdOZsLp8tsxU!YID)GL5Tmy~?jAPt_+>*1l>#mFaY<;)%0FuE%Z_ul9G5>kB~2 zQ_FszjU_@}7Z1B?NaVk@9D-{#ws(X~&i;8-7v(Llc>Mlbuhz3s&GAN1+vYmDYKYx` z;~uYr?VmX8>Bi=&Vb}dPHXoinry2g=i$rcT>tI$JJ&zA{#^gnIfJe`R?&tns@T&r6#Ap`@HOap!I(Wen-o`PG}x!yjlOydLX}% zUg9Bp2tR~*B>WcOi6?7+-*~z&kl$#$Sv!fJUZ5Cm#BJ5kK*eoUW6^PxcW1X_yz{Cpp%F4;k z!3W?-k#0`bD(mK_ZmxDIpZlN;Kn*2jDzAlCP92QO7p+WVpO>Ajer%#|BXrAOKbAqC zEvsdZ`7oHyfUX)fNEIty%ZgDRmD7nceKw&^A%2pR&18?BP$N!#uB2m8&b}v1_0FrT zelQD24H~>uu7ld3-cmVvo$zXfVzNBC;?y236Q^QIR;xZa)XR&?m6P<+uZQ|7KXJMz z0d4U(@0k4~G_YT}aIo7G&^g`)CSYGiU41{F zpr_57Wsz?sHwCksVYK-~n|!tKcBfTUiev;FsU{+4^MdQlKX?|&3B%i>#qIoAC zvWIv`FY)w^C%xH2M&zGGlee#hy$H>hYn3U#)*M`Q>D~olh*Q6rOFLe=&!BpBzd~)G zvf7X>lU|i8r`kX}b2@hItK-$Z9o;igKWks}haS|pKFa=>vzk^&@XSVFjwx0f29TE5*4k@qy}ah^b5%FA*rl#DHB_h9^>uzpC6R6jPSJr?n_p|L zKL0n1_A={!PZT!|g$#5HrBI9TLkM{&#G3$~LN>w^KiS#0JnQGIJ>& zgwOKRddu=>*AMY${h!sF#gm;`KNG%hIi(Kd=f34+5BY)Si+Bj1ok!xK>k{#kJUia3 zJ!B{8&Ekh}->JnrFR13B02eCE6GFllOG6AcG6Y>@zb~nPwVp`$mBJPqhw99 zO2trL!fW4>GVz!6B^!uS@rvm~uADj+)vKp86hnPUPMj*IzN(kX%0rkEIq|4%x>G}P z)kPT9rDf`?JhTI%eTh?dUP|!FqvevmYO|KLuZ~6KiYL8_F=v@{+ex>MbW2G)#~JV* zdp+B~8tpVVY72F{1-8-k>{3l_7C1$2-@%#e!2Ugc*X*ixypGv6hCThEfkfAk*EPCo zPg+7+XTpM12jCRNRcEsUZ$H**+T%P;<|pc~ z&JL?~`wwkBsx`mTT;1F}xP0)6KL4hc|9tdbU&JffMC4CUQy@)p3it_6athS5_^dtI z1e5F}y(Fh`kv$Vg-01JOX7y(Al(>;SgeO0cJ>&TZp0$*R1htlPG_J*!)rB|KS-*`3RF&f;}nOb4cp(~UYUx9TBnpz`*$ zve&AXQil?`*}p95P=ZmnsG+z|s~uDU$(7XXM{0u-ZGTC8r>xo`Th>|5_9c85P8ydP zJng6JZ&ZoYi)yrDNUnWL%F0uM?*pUb>Vsc7X}tOI|LZe?xX8p&GYTZBO_6aYU)A8&0aP16QPS%+ATK zwPH1Ptepg>)_({v1ULW3^z>~rxb4#VqhS14rSEi<(p1t*X)57Kp55-uZZFcfh$m}j z)(_cjAmY!;$q(dzlGn1I>>)XgH;W&`_EVOJwnyjXH}VgSm+<5V`n?dI_{pB^x*|M1 zo}tGW#6#oFK31Xikd>3)$j|eV*Rr#39`gSLt`FqT>~$c^PkzYS-!~8WC##q6^t&cM z99B79#}4aw&&VFHZnJ5bw$l`&4 zLvq!t81v9WLFyF!et>pXgR+Amo-T58THc(5S61EqHBYm&L0wT&Rs~(ks*89^Fgi7g zSGn>V*OhcojFwe_mP;^dkm9wh&sWUXKNWP4WwXw@DAtjc@c3_Ex-A}84R?4P)@>|h zx`x@TcE36s&t%Vltm(=!4s7OG=B^B}p^R#gm=HLvpg8_=n(U z;-{xlgeN)8`*pGls~aZsD$^!U$reT*m=Zsb%_=7z#i;!%r?PUEU=&Yf z#848@P^D{h%gMEdO3(d6*u2L#yxlonx6i%v$=PNZ|85l@T&0knjNRebMif^b!yKE(m{@ zY%yuttL0YnPt8}gY)%zv1C{j@nU+$H1SgpRW|?Q^hnbU(;cGA`}*!I@8nPfOPMJ}jD5=~;_X{b5pUmm7mYu= z9%xBDh%M|D+uW1MeoflYHyimacU-KlB^CTCMhtpj(k!Z9`fs=2z|Xzy!p z%!EW7=FnwzUUIrT&&!^yUb?LjPjJQPz1eY* z-mIMLB%V`AKhP5x;-?EWtsn9m<;TRI^+T42>><6xPkzYqkl%=h^pc%@^N`-FV11H3 z#4`jQTK~j90Uq-IL-HI+Z>%NhnK}OoJj$tMeFUd+Dr?$F7?Nwwtr*SYRc;>5X@f=~ z`s_&M=2?(7D3O*4V?KgS9n=cK&@gq1X(|b$avgwqtVITCXMMb;axJR@AHZe88;5n< z=&m(Ybvxj$Z`|s@u3FJZ$Ly+!kTQg3Xv3!gjwnvgtx2}#h-{-XA;j9AwV;m2RjtnU z7F&;<60e=+Cu=`#;V7N%R&+PK)Z}#6rYPvJ$|*|8t{?IzMOoy}tX|@$^_<1)HV)lg z^=^5PWwl4|-UnHxC{g{Oc4}GeRJrz5Oo@DthX?jzzDTw3E;L_5-A9B%OI_pkJ0sH_ z*h1SIZQa)V-@mY}%{9|I?d|Q^_P)M%&g$KP=?Y3$vcoE;I{@7=mR(NcUH18{H7~Ru zQ;UDuc}gM_y1VKfwePappm+7Y%erAxYm6H8D5vV8acN(|9HiX;;iB-S95&i}@IluW z$}|@;9kegX^(~awBD0%cY;KtT1)s|Yujo6ZTlvxI*6Pz%pSSwT)wiv_fAtfqUtIm> z>bcRW(bGrIAH8aH>*xccPmb;%eS6%Fe`5TM@e7-#g+~B%3#U+zZbg)&*W#xTgydu= zjf;3leyQ;fVf~PQvhz;(!zw4g(Yji8IgNMO=eO3p9HkJu-!~%EYnzYTz!<)KefiId6IIfE*gvWCCov}+xw!> zHjKgd-xxotv<5jXnP;@FYy9ZFFRlJDturcAYa8cV3++B=(~gqZ+TRy!etP;`;_5!% zx162m<%E<1MUqb-F(EnePyj}F;-^HI@Wh{$6FUoemd}>_iDW-YgsXR|1ZJN8!O7GW#S~c+S7$+ z5%^1NP=A)-OMId}Jc`Bi4@4ZrVh{hVj4+^@Q!MriyQe$m@v1wY?#OSX(Y2rMb#xE8 z)Z}!JrF-&WmD9`3G+v4&X}u8-`JecSC##ouvU-V!#zlCN)9;b^NuK2)fA-CvwKI#a zH7?S-1vx9>X}yu0{GXMRJtQBr1sky%C~81Bud>>sf$l+;&1>?cm<&}pMeVw?O8g~q zog~GpUd5v0bKvbfrypSRGtP#!J&*UMZ`?B6eQ&2}eo zk!lNUsCzr!dd=DGs@1TT$ksxg`-X-cLzS*Eu2xf%A#x_!uD0fXP`1?(xoZ4?#(^!d ztu+gpW>UC9VbT!78^Tjqd#TAOJfm>yu*xaTr16s9XuV~5h@W^)!TKjW$+LQihtdSX zlbn8!WGBh9Jmk+TKlxz{^Od!e@L9ctCw`I-D~*f%OmecHJP_Ns3Xuic#CNOqddR35Il)^i_SMl|JmQ&zJ5_& zRc{)K3@8%KMr;&GPy|ExWtWrR$j^PtYx$Grk>oTk@(=MGRyoZJ`DfYXG>^1SYRSoO zZO1-p3A_7H?=rv88rPV5!sPn)SG7*+Pfv>3 z9i+83me-ul@{WBVD;abC{OkkogUhFOB^d3if9XeKIR7T8y=uGbt-Je%Sld`$ zb1H4egK2bHTs6F}IuE(7ajOHTs?I1^ZJso{-%bBk>NQ>NYkWteD*#>bmPJliPO_h_ zczw&s9-5b0ayp(u_AHuQ??igBqziN3a!owdo|3ZKR)SZ|_MHWHB~_3u`@4+0w%Ln( z|221cvaP|AZ(1lRd;k@?zW1XIF!w~R~iYQM^>>WFp9%l}v-d9WvLtn# zXI9mz^SZBd@9pl}54)SDo4$1WLJ?7DO&rm28E^zt^utQGwu7?b?0~B-sB@d!w{PR% zs_SE95fMRjMv--PRvpI~#6=MW6;K=>C=SE!z(L!4bf){%rK-+GWK>rEBQpL`85LO- zSy{jP;WU|15&ww%$D=Z{;`ja6WuLRd^&{8s++!t|&eh9ZFPF}>lS_|fhw~fP9`1$f zdFCFX&P>*dA+*Pf=?;r!#< z{<`dR&y$;5?go|KNOQkF(3@T^KTYTA;_Ptg)aD}c(00;mr^hr8{V{>IjULnd^f)9B z_oK(?$4LiM2XV)=L0q1YbYJ__(bPe-4V=v?`Kf(svr1X6UTP;K4}Ds$(jJ%~k z?cwZj&noFO4|g-n<)P{HhL#?4Ho2SODrIRN zdQ96x>!soFry7?zAv55vnTY1CqzF3g9<6SeVZ(>>JvG(MxB8?8*f zLHtOVbnjcT>kT(x4nzD&$Wk3=f(qv%kfRhefAz{_#ye$F!fb!(A8mn8Nw(EK@I+pG)W3BPlgRkE`UTcIYv+ zL(}PTl{{7QbJs}QPwS$`^!c6Ur}ffwdQ9zb#~ikR+8@7%bRIQCf9|FiN%K%c+_7)| zw%V!Whg}+sN`H!7l{4dMdsUjPx6^qM#%m6bl1{Te9|q(#iq?3lJ3>D*s?k5x&kXk( z?|uwJMrPyGDC<5oEW1z5cTD;vbl$C>yUyJ87@z8fj1P@d<7$mhjnlO<>~pcn%admx zeRKD6JKh%Vp4dIV`$fAi-2L|5AK3lZyYJlni`~bLoj&%d$G+s)y~kd0?1%kT*4$k- z(%nx>tL~q(%v1QPU)Q>|-}gsr-~1RS&0Gh#w~UJ(|Gsa3&ok>xUFqCgCEszrXurX} znn%8GwYmOnS~@o`xO7f-*0k$dbbH=UUSDyqj@B)`(yOL5^V^48biE<{OWz@f%!`mV zgw(s}vXvNl+>3SU6z-U^E@)@aW9=6e&QM4rs+3*cW1QuXMrPfx3EMF;xBj$ypYD$)of*z=Qqz*> z31qg6pR_bZTF5qD{}=1rgA@1S!}o=SuYKQoIs05Y&oVE1xECLseXgCpFT8y1bMhG{8T?k^V4J69-2<~ z2hu#WE_zJ!aL3d>Ez7l&=Akx2?9j6GnAWw3V|G&}Die<&{hfY)r-~=W7nqm6GhS`! zk2%IO(CC%UkDVVHrn=YYx%IQrU+d?cNAz>UnDfJrIzM%OXjJ2V4dk@s{LpxB;moUl zsGk|l)j!nFoLOgpYMhpwhK&!+UeygR{>*63;w#i^FlUqg>Eh2KUw-##`X!D(Pd`td zBe6Tp)+#3n_h`*M!g1-okBHo@IcLYWUf=v&dpQ4a?d0rmkD9)AIQyI(-*nDC*M49B zaP9G}m$T!W&W#i9dbvj|-+H<0UGyV2*G{fIvGQ~E`lfT&wZ*(z;_Umrdh@N98;8E> z+;#Ps>pjb)bJxq+=hC@)>$1=F7k6D;x^I6yMjlV-BP%_w^4Ll9aF54TY|_VgYLh-| z(R7L#qOTihewwFB8>oFML8R&QnASzpKTdp0)9EqSo(|2#9n*^0=XJX4h!&tVhop0j z5haS_i2AWmBG1t#Lo+6xC zYx~g0kn_pgd7VZr#%VxNA4^8_jiScslG>zK<2v(cpKiyeq>a51_wsEQ#)z6Gd z^$%TV#ym9l|Cr!k_p2_W73MoOqb}prC~SOczA-YfWcoE4wvsQ;WQI@cUb8H|?mW90 z{C6>Mw{%Efjx30~RfYeT<9}a2bH7sh+G%TkjOY2rE%!=@d-cYppJyKReII?f{J!?N zR{-2A4{ly?_1?x@FK35q50{_YWk4T9R`HlVoULN}xi)v`lPQ;Jndwb-H2YeFYjj9D zElWd0(axZqrvbBRCTfVLo4?c=ckt_Wy6)Tdjs9LqcXZz5d?+<8v3;k`tFP-r;~k0N zMQ1HWUgK0KAOy&~^X^qqK=|Gp`Q9UP_i&sY?w-sye_eKb>*ezMrgMJd?xDGKu3pYR z+<4&H!`bK3eeL+>=YGcL+QU7AarMSZ-(sG5Vzq~>*Vq4?9j?E){M`8G#`6~APv3gE z{B_ws$F!46=dPFY59d!V-PaE1A7A^NKRG*GI@f;AKIdmHo%8=$rXPDuI_EbE%BBxO z+yfGQx}tfwbZV0x)3Uzlv@AWQc4&L(G5s-`=BIY(lO_G(nC9Wi(zj7)MA_(+X#FtIrGl{q0vC&)M?kq>mHZp?hEsE zlwMQvPqVmfA6Sjo9oyf!{je);8%JXH-(tbtzHzsu-0dfq&fUhwvd`W2a(?i=F79@l zv%}TP`N#LM)wfQLq;{y9h^Ev0)J~<>pR@oqN{_h;XdZf8B|pu>m8E&; zaa;3i8`EgVgn(g;S0Bcy{w~A#(EXakr>HZp^FyOa&b&r@oq0Vz^f9}f=8j*aH(x(< zwn5LTpE(OI=R==bjJ)ns=amK&b=vj#P|vEL8O_r_H1%Ejr_Q1c_|T^b#ymvP-sVMk51fh;>jnTbK;v$yz0b{pZKK{e{kaCCwnJvKKay>&pr7qCm%ZblP7=mye^%;nO#$9FFOqSMbakLkXT-M;PQ{7_eWeB00E=h{=(^>TJN|M<3(d+g@ybM4{M z>$1bWHsI>@^#f;zOXvJ{o|!M49WLG1KIdo755D!D^SmyuUd})GS;_qx&$qugKXd)U z%`YxLXP+ClzUiEOu07oNcYbBmdG+r zCF0BOaA3p?6I)FNt9^;2Vi-YLMqN9mAj!UO`=y6-~vp)pB-T8LkcoA&uRX08j zIW@N1I~!%}BR8rwEE%6V4bT6fyHcYK?o*FOcy)*Sw+^FCCa&N3*x5!SuW{-u>TaV? zQDy!5`=)b0QF7PiYu`6N*B;Iew;uZD_w5(XKe6oiZq?!1&-snJ zu0`A7u8V7D=ZihxsM1$6^z}-Uk9}YLH0@fb&*?F3Cr#&q&QL#a?;W`p#hN6ARz?{f zTwz)V{lrM(bT!tAAFLQ4ce9Wp%%}b$Ot!78tMn^(vEpBp%sc-o2)K zUo#)slccT(f%D9R442N`AaOTxu;YG^gnw4cKioKPYkH4aA6ET(x$EK{JXbxPdjYcQ z*Y)|H&*k(ngg*1obb5?D8~vTW_}_y4%-LT=I(;l(gneq0*0s!I4JfJ(7oKu z2k)(4;i;Y6wtDVXnR}Ez&)jmKW70YM+^rpF$M=;2S1)IuyDr~!&OX;3E`8Dd=dO!u z50}5^+3&vB#kB`^)T;^358Qm=<{g*r>wm62-1y<@UAJ`3Kb$|2uEsyN7lIqN^pRZC zwb17x?zx&vr;ji+4?U*#=`r^+B+WyQtJvi7aOo7RN9&@;TprFQE!%`Vv<>u__8mRu z+E2?;$sH{l>zMw2PRA8(bFAa>lbM$^=A({z?!4(YK6Ibzck}MN?oZvn5W35HeHzeY z<2OX({e;nq@g-+X`iE{)&C6?CtFwXnhx(b*&fHn`g3CVhxTa+{t$KaNMKu2h^0UlS zlkZ(1cbCJZb9aqgy|AP1GT|Th)8?x9hihkB(|exl%&Pc-yRNprHQ;uHuln`Ur*ZCq zi>A|K`XEEo=`r%)2XZaU#kH;18|%8xG4q9c=C7-rteV{S;i+l) ztGoL@(I(_|Hc+;xO!n{zMU^z zzt`nw`pY?eZisa}{#x=-7ON4oUJc7dpSZYVx?!5jL!ZhPaZE2@5%%dda&^&br+Mfx zZ5x+P?a+E@*;UVD>;unygThKmPID-79yu%cXO7TU`FS>gB*i+*2l3FPF}>pG)W3!`*dq?d0rm z^>TK&{M_}PWu6+(G3nfOooDjHj(VEm{KNT^>tAl(ap~N+;_Btn>uL|z&Q(wM?O)E% z>z2;#ZQ#~1`V_9AujzvgeL$ke^g)O!cWGIAOxe;jKRxbHnfCYuc_yciVDy;QpjqPJ z;+oa&DN+T!AnknsZ4|AW9#hZIW2&*{@^kj7ox1W+A5r@nbDTPr9&5g%rRi~pUNS8m z>zH0L*9G(fmU#?W`8+=ZJ0GcxCoJO>vU28!cipGPPssX*`kCQi{X_lCBdcC;*=H-$ zP?uwji)j9jTsmims~2|MTdU!p)$$KF&fA)PmU+Fh>etI%S6d&0 z>v|p2H2-uv%u^|Sf}&5W^cZ>En(<}&hxW^&kLgqEGV7xCF8Wy86tU>a0A5!bZsRe+ z`DD08d5ueL$Z52_0mk(MyiOzz$^V!|Jo`ZRX-x&MZ@qu(6WjgmTem-d`}y17y8ZC> zPi_C&_6N8Bc4uej_MI=-c>xb*=kA%gd#dxyy*KxhDrbkQmwT4y(m6X^y{lq}`>B|_ zUf=ZdOh5YC;qr6#xpBhTS#*A`UM`&*KiqhWWoK31#c_V)(qs8ER(rVX^-cGkM|Igh z@43z_I=}C_%Jn<94sd>3v>ncGTswW!eXq;+dg)dM`uIzq5@|Y(PtN6`uL|ffwL{C& zADZjRLw~TQHo0T2omKMF9U3%0y~Zl(oF8c0xa;!GqwQOujiZKWI^9UY<)If$kEtD6 zwyukzb~q=}Hqc|PoiqjLFYO%#+)mw{+W2|_1Yxq-q(HW>b~{jV2j+{DR-C4-7RzZ zeeL_^=jx4>ex7;C^G#oL`_D4%Ty(u{wclg>Sy#W;Wry=WXWut}U3R#)cAR}K-S;gA zcfEDJHLA-Fw@z|?;MRXGKj#N7zptOU?-B00xb|>%xb|@A+;ws3u;Ui6`TD2x60%c> zK0(u`UHVi@(`kO1hfAkrn|4gw+_bjQ_Hb`MXdc=ZG@TwVI*+CXqZQNRDo$T?p78}T z18Czo57GBMT>dI~=!MZ^YLgywFIQ=vDs~o~$HY_W4|UF`!P-7F427J!cX7Ebhm`F+ z;&hz5;%0t2BhZS`Xs`D&?$>a}r;D>}TtYKXo#Sf!sPp5HE7A??XNFzphelrasnPZB z%q#I}@(i&V?6u;x*fp_Vi}`Qr;vOefC7pXr;vQF4J)L`;;QH&S%7*FM*e+@=}&OtOgMD$g*BsF!=L^KH+f z^K;kkTQ6saOXuEBaP?M6r+Ivj-$|bBeamwB>9sfXA+5gK=Nj*b-4DYd_vv2GbH)|9 zTV-VSIyHVU_bMBb*{Ijp<6(R{<8ja2!(@0%A7}HP85cXR8CN-@iY7fmala68w+5@8 z&i!)4-D0eIx^I7R*XwJC%fIUOx$EN67k#~4y>+E?k3^h(?s~aLBF>I)er{ebI-Pq2 zTy(v@_PKuPK8M_+(tFEAA9KHNExKOr8Ns(bi_Xva!M9${4wue7V{-LYNvC;ykAIbn zPv5d!etPYAhN>RT&ba?JZfo%jRo#<@>x@(5DGAR|Vhu1{lsrFU60FSbZ2uptxR<2d zH#wKTY3bY@pYI*bqVsbwYR;eBb@}G^y`$vD1DEb=pSvzD-S>%s>qp;qa(1|Oa{c0K zhx0R6FPERIchTuwJ30R>I-T$;Lym8{wq=$!j#gGBy-LM&H!00e>!Qb8z4U3G z=Aky}F=vz7p?SEnG(F^))Av>4{9O8Z=GlYG&s~>qeqTFW zzxdkm^)r{Bt9Q}qoS!-WEIOU@57*8`=RePkXU-44`MK+Anw@iuAGr2&>D+a3`8hvx z`MLSRjT5dtoE`6o5A<29u4C@`kUkUo9*+;m+aB6bT3MC!Diu?V9nDYcqQ_jl^e23p zhuWmaoK0$n=HbfH^pIm(7j3`q@rOy@QJb`FRnob7X<3?wrqkn)JX~K?vCq{@{lM9$ zdGJzXm;S=9KYTf#%#06>Q>P&#FJ6jl_0+T`0Sa^ny3P!5y0TXP%rIKxsqyr$H_52S z`Js;?pS&yZQsfK8MvNwf+1ZYh`3u(?iP5})9dPY?$M#G>0G~a z7FM~7u+2-y*rO}Oz+@hwSm@4e}1KYTjnus5B1M7 zkF{Wcv_Zbd)NxhHa{0M*{l-#1GwwlG;&fqGU_6_7Im5lR-q;YXUqRe6<0|7eMz7uN zQ={P_ofl%tsMmP-GAt)wm^?dUl1F}+@x4jnzFd*69%i_kZ{M4kRmtz$@7(VczTnji0urH|=4D8=t=8oXhX~J0sT~Zv5O!o@MAgaFyH1W#(aTQAzuk*&h0<3H94D zkLmSN|19&kLo1^V@;#=Gt5TNB&!y`(i29jvueB1V3%dg2w$#fR?)z>$%WQ4tA&>O# zbDg<;YIILX=Y^Ou>Ye#4BY&8=92X>;B-|dW^seQhryy)SLTu@>$(q(OE4ibD6u(PP@S zDmUW3dDt8DfG0UlR7$TU5h%$>>0#ip_k#NAZJy$6kTZ?!7*_LzIXRcZgC z+sQo>*L6>~>UOw!=X+gTKXP`s{9DX;=AJ3Jbnd#i{76^7-*V4noPW6d+NLt!Tk1vU zr?<|FuB(du@sr830=+Meb*#m5G(zZP3lTETEB!Qof+S>t(R;)xb+iTzq0lItxs(Cw{PA4{O#v&f9v+c+dsAa zYug{({@b0Mo!fW5VCMxpFWY&|&RceVedkYi{(kqw?)lv>+I`{fx9|SI?!VrB=k8zZ zK6dQ%u}?krCCBbP_KIUaoFv>Wj_)lLcPqwy<#YC*%G|nf`F-o<^2bW&u8T|e^$%w! zR{D9Sf4Onuo6gzi9wB_w>$2}_$2b2L<7e1Wk0`$W=`sG~?D)=8ZX7PUzkJ*8+fJ_i zi%#dRi%aL)!{u*UIyc_9{_;)d{NuYmbNRXUbNRV+Upv0n#q~SvEN5Nq+)Unb(3b@C zIloFeeI}>*>932NO`5;1WBM+jE}PU2cU|-v=`k%!+r}MJ`&=GspXTA}qUrRQD;p~h zt(P8iV~lG*mrhAY)IPW0n&w%=G401i*ypZ``iCCVHqf?l$J9QThuWukxVmUMJ?6^B z%0ug=$HsdQHs`h4iq z*ea@u4~cxx$E_gC$W&1i(%8#MZq zOW)woHy7O72l{0D!^9?is-*U*9hy$-1a6T+^^m|n8x3$A8bfXm~XP6q`YjI=I#%-N?GMh74ru(T}APX`)J z*FwY5cS+-~CH>mHPrvi8er7xd7^k`&<3r=rnOFbN`HY}v)z6G7jZNM;wYFG-%>}PP0PQAvHZYbWPtuAN*u*B;LPs-(B|dYjf?i*Apv{T{R4o@3Iv@x1E!Kc75^(8n9@ zm_BY*d90##Lh{qHi#Vo_ZB!vd+wXf!A0xT4^jc^e=rJuz^K0%rGaho*{8aap;SBx5dC#1Wf4WhR`i-o{sne{{ zAfu@JQzLVYr|x=<9Wgc5>o#R*;@p|NJ~bw=S6O%F&UZVz-(7fVpB^10cXsdV>sx9Yv6 zpBdfZ@nN!)`SGKMAAc$NgXH7<)&=fyiSsg-pSx{dbpD6P?Jn&J>V29{Z<%Nw+C%g< zkN#R6t*Ku3SwDf&m^Ucq7H0?Or(e$~7=AoTVe_o>Z zcn#_3$%^@=Y{mSoY{mSo}9fUoWzx_vp zQSMuh8!P9S#qd0n?%PgoBL~+`&OTi{>3E{Y+_xT0rwb-mmZsB1Rr~GymJ)gj1lV^cScs@bU|bNc($&~XS*AcCnwKIzA1TC^5aSN?4j;R{Kq*rK7GfV z?>OY_bK{n?&)MPf(--%2Nu>7<+)XWAbLo0b?^6IrD zdvdu=KI*=;=seW^mc?BqI9^@hj5xs%hIXA z<)LmQCT>u2>m!%W zO&;I;Tzh=&b9OjCaQ5l<0UdmFjpmNI{J!bbJ}pc4$If_J;#{=#f#=L>eCR%P+I1Jr zzDF+48t(4z5pL+kO6P_iw{ABrotq-us~S3NeUIrdr{4>89aH;0w^;g+u6|~W_UZ-a z&1qDaY$wka_hoOl+?TOG@o@dkrE^Ozm)|#?vroq#?N@qC#~)4Sj%j{#d>F1ZPL2Bm z<5TykapNI6?arf5NnU&OXQk)4I!aXaQ3-$&JNf9rsd~`>SN^1C>@e?7;?wl@Z|E-R|538ht^AN(jQXkD+HQO>oTT= zF@Al-Q}zs=|VUrF}&?w2Q9{jW^6{=w%aTR-%~ zWb4o0uKjJ_`nJUS_tM`@wqN@n^uG_jA=$3@x3m4mWaqx;mH&S1`;(pj=Q``(?&fBq z{ay2w$!`94uQ;ZEuldqsch=t@eJI&w{*Ilvm{k7zwy#W%{hK!?$NuUYt$)XF`Nbsr z`^MwR@wfbCa{MFW|112RxaDcdi7&Y)Ir04;u>bw$&mn7{<`F3)xR(PzT{;7 z_dTMV{Co4~=zs5jW^(Glf$u-|A{{H>j zlWPvXG`aS!&rPm<#S@cj-~5Zowg2TClk0A|JGt)b{xZ4l_2TcpyeT=iH%!jG;2p`i zH(W~2edvA3xx-gyf7hS=zU2C^`B?Gy&2LDqfACGo^*`~n^52`^m0bTek)Qtk*7K6< zKXiR^!^WlLhEKbq{=WE^lN;{+r^yXJC;s01&yyP-`TpdFtGmgKJMTzteB9;a#?Sg! z$&FurO>*PQzCXF~N4Jw3fBgrO8~3kIZn|zax#^3B#ozO9Np6yVFMM}$(>K1n_NoFw?6l6$*p2-x%K1MCy)D#|0Q|c zOW%<^?wxN>Zaeq6$!#xqV{+S%{&jNOFWsHo_M3l|-1axmP9DGc(d6;M_TyjpX!7{) zenay3pZL<`@xT9<$?eH^B)325XVl-9JubQZ>%Kd={pEj>-2S%bCAa_K=OlMr|AWaL zFZtZ$jyFFux#LgYn>^w4cJhRObY1d<*M3ZV#XUZ`^C^!ecYez?$(?T$=YPDJoImzw z$@ym-Cg;E7y~+8X-%rl}&35v{&$u&r;tL;0p7`oNOP=`7HzrRUzc{(;?ypJi`s!yT zcU`|L%7rcfaq;)!&Eyd2;uEedfG>$A9mJ*WLNzCqMat zjpUAV`_D*{pMFMi-Jd_BbNu(7k=*p1_jPZ0-7}JF-}8*#oi7%jy{fyh*}K^5Z1lSq zd*k8g=wfelb##Pe_yhw2vjIy1mPdRy;ENG=1-K=`Z6fZGrw<4ZzN*`k?idZPj>LZl zpB!E6cKf}HNQECT9xyMk5@1EdHv_&QaZiAIBiti4;I8=6gInE=jg4L>>Aq_BXnZst z4Tr;Va&!sF@CgP4W&@T2ERXnZz!xL#3UEt=+eF+WPahD(*J3y6KD0TO4-B2ogWKW^ zY48EY0p@@>GXTq{}umPQo@Bu^-inJ zJP^?VJ<$MBees_qIezMZj`ToG2Z#p5b%69hR0nh}M0SAcf~AimTkP494<1(<>pJql{K0owMSpku;$7nUU1* zil-G%>rMULrW;A;^L=3?oxcN>k<_cxi;Ac9W{YEpk#w%%M_Q83J%%Sn(x8bTj7T4Z zxL`k>X9f}%^wW8#ac(3HSqr2QEGW_kL8IVcfCXhR#)5(cr5Ov#R(^-_Kxf>&wEM}& z9vh64?$*xE#-)vMH$fVF(42A5w~WamZD<+0NV~Kf;sdM)SQGIbfbW3jd zDb`6_n!C-m^|`QDl&zZ2R(93X#Lel051u&^@bpI?`J2hVM+H0m=rR)F5HTPy7%&kq z5ik+(jexI6b$@_w1bic`=Qo0k6>#!hDptVBTUD%py+ai%;KX+#RsbR*AW8&6WFS5W zf}w>QfzApH>9(-MJZ~*}Z0U7vbu&)9B2KYNR_&WwO2;4$& z3&AY}w-DSya0@B7P_g75`jHIb6ceNcmYf2aWdNZ#6d^eVqJp%*k^@UFTZnR^UJQ;@ zbe7y%8&4Aa8SrN$LMc;3m*6BG2@U1Xlz=Uzgo5BAec*A$W%5+*{F$O{lctqgZzP!f z8Sl#xJ}8wj5H)V<*JMGNU8YpcBtbBEsAr7}oQ>caE`^X+x;}b2!9c6CnhPeQKy*zQ z`T;l{&@UtiCtK@7h7xc(hrsCwtP~cMgTYxFp9?S#2$l=s@T!Y{o70yzaknk4Exxy1 zn=Nf|*J~5K*PZ4+KWsj*r7g}cRoiUyRn@X}<%4)1;DQOW0XlsED?sp&lL7MN_Q{hI zsk}Y6qm;MTZ7AjKjo+q}x7UoJyoK@>%3COJp}d9i7RuX3C~wgX=!Wja9#&+m$bL%! zB>Rx;L$VLaJ|z2)>_f8eA=$r(ycfGzHLz-|{i*?Y2jCrmcL3f2cn9DefOo`(cVG-+ z3}Ot9h61IL99_a}s5~1${Ykp;0ek=w6-ZPdQGrAS5*0{PlthK^1^f4cB6Pj_--6JU zqk&>jfifg>J%;0#Z7_Bv_L!cKW9(*=)0i9-W5;9>#6Ur$ z%TRHa90K8{0n98}+&Kis&by3OWP-6P87TqvN(qx9WI@r4=&lwn;1UdsU8$a#2}{A) zQEJ_^NAspJ7`xIHvqn;F6y#=0_3#Pl9A&g3DciP+P0LtmAx0X?#niks50)T0QPTJB6-xp<(c4^n&2UX{h+t0Q>s+v}`xoTThJD<%j z>*fPoFP*EaJ)Y5*v-=VtV}OhSG6uW$2oPsLoB?qbUj~>6mxq;FKN_%v45etXkQUH`TP})Fg1Emcp zE}*z;55)x@djveTxP=_7!94*0&;URK01W^%h?~HTR>qCORd5wJ#o!csLz=$FB)`^5GSKJKq4<= zj({E^PDW~o>Vjw^0PNk41ixmTA87=rE{He+y7@GW<^ZsFMHfUJ0iDj1=V9*>QeosOcxaE6>C=QIV5(~!);|Fsc@uLPsxwvGFlPo z18Wxh6-xwo?h{q-lS?U+1eaA{5`jsS!wB5f(KsChLPvo~l#?kCI+_Lnhy^q@4FV7g zVrm+Qo&|Rm3yNG*!xxk%ODn#s5}>01WC9SkvjZQ%2i-0BAk+uT0J*yB@CQa#wP(v$ z_qt)Lqe!a?cdaL%f4w%}*WH$)j-q`rGHcPSd=$(ulf1Pu~2NYEfbg9Oyg59)^8D@cO`iEj<4 zJ0MJfFg02e!W70j#`-Fcbr8}(NCP1agftM+fc*mYtIgOi(5XPD0)+__CQz6_VQL$N zNzsNL`jZUu5tB6o+K@usL`1E~F%T2C1lmyNg7AOD5g=GQaYq1XL*32`7BvC@k8r{x z+%STiPEf!j@{}nBp;|&(tsdTL29F3aB30KF;Su&~rtpYrSF9Py*a{jvVjd&2;Sn>e zN*~O~!sSqS2}+|W0VlL8W~z}sC^IQIB!$IDI*XrSDWeq;J}6}=p)X1aqzD$2CM+oD zxX}i(08k}zsRyd02C{(s!tf>w&?4zUAg3ek1#&vlKw1k>C2}%_je>(gI+k-XU?`nC z+_w)Ua4f*FaBF|7YJ{7f&#hth_d(tE>hYgV+igqpzutP2K$%zyWg^p(pT45B2{Z>Q}3_vgd!2kpU z5DY*t0KuTm1Otrk(D4lh1Q-xtK!5=O1_T%oZDT-SU9+!iij8skU0`Em9*BvC02?C% zGsHwgs4nRDiZ(2n76LX#J`I=b_<~6yyCC8S=n6bZG5n%5L^L2uz0+#Hq#g&pp-yh1Zp&)0p=3M>CO&(uq+>dg98o@$SEMF zfSj@vatb(2;53Ql_R&Oq>s(%w!I05{u6c)|1)cq%q6MA%BSi~3^KsCEKnnsb2(%#3 zf zK)(Y03iK<`fkNp(@E|-0e%NRz*f7b_CCrA<*#O!YXk(!A*r`153;Y5K8z^j`usyU1 z9~3@Nw6=%;ID;g0B7%x8#6x5$Qd5qJiUukY4JepVF?vFJAV$5@YC&rQtu3F1(S{;u zZK4aJErMCMla(+|LoP&iKGr4istR?||7C-o@= zHVr;CK@{JNsdkWw1Kuj}=M54MO?N^@mq0{C^{*9nB*)m2$g94mG8 z1%)wktsS`trXQGok{$`BADDh%`iY4!2GdWtU>}gs{lhenlR8d?BZKge@>xvw#@t4( zJs6DT@@^PeH0KcPs(IK|Zk}I=PeV9@A@zooE!tcUTd;0>A=l<#);2b5X;ppYF1y;k zs_Fye1G946g%99^Zc@bu;6#EG2~Omik=xb;P>0I|lyTjlj2QE+AM+V^^YlH6ySeuY z#oavnUd7!!^-JJxg1ZUsCb*k`zq#OVAS8m22twlM=;CTj60jP?z&?>pDlwcc=aR^$p zY3az?$F|1WXWoI<5p9gCF$j>)pPd8FOI?hUs$8I|`OuekPMFPqwzQ`E4{f&hZ$15H zV|Xnx4g4GMZ@|9+|K{j2$Qi9CXP}GG#cm)_##XuJ9g3}T_JfM8a_)~5Tjk8h!BzoV z1#A_tRlrtR4QvD`FQB}D^0G3N7fjdC=?YE`=op}5fR3?1I>yCr5B`OJ!66zAvC?8C zSizM5d?WCUz&8Tl2z(>Zc|hlhP3OTF#2CaF91R7VB{{kTRux!PP2#ekE#PK>n+0wb zxLM8QW?_BqJgC@V5C3t7?1@Q^0y|7}L5??y!|;-0fC7B-)+`MqbRn_>I;snncD?1x z1v@NTh?YL`NKb$rCb}Tv6cBx{reVaz=t5)%biQgjAZzUjLS&l*{39Sm20%$OGMm36 z&`0T&>ZqfEv}|o>n}q_gcH#Oom|H?5V@AY720w?*-*xk!{~Ce$i}~B#+Sw_-E8JtK z{_z5)Bbbh0I)dp4repFbfPZe_ACQn3As8V?v6sq>yLsY8io1FIw-tBu)C(1N^Y~|i zy9w?lxSQZ^g1ZUsX3HRfAohXS2aXgtQY*-j0ts+*vD=z{0~HKZFi^oj1p^ffM5gu- znZOeQPY8?%Fnqx9X+Oh9aZnyQp5X_i__|izwFl1t92BDdkAnb^V<6%P=n4KoK42m> zM0G)|DbN!Hh{z65T@cX$-E1Msy%3`z$LJJ*&U?6#21?xpJY%cStuTw#}C@JWD5?GzLChNAgMCAz9c}pJz4W%^D1ybv7y}-(TBnyh>wn7a*nJT4( zNxkvG5yvtYOj<7-5+vd~NWHa>h7vHHOF&7Cq;snv=&h(k?}3fvjx+C9ny%ABrRlo% z^-9xq`c7!Npy`693z{xyx}fQ5nWhVubFtUK%C`C|8^nnaCqkSEabjykZjdatNwSFE zL~o8R_C|vSeM>_|3mGkBw2;w4Mhh8jdt|ieOZ4UL(RlTjb?CvN2ZtUUdT{8$+ph=5 zJjXm=);w42froF(B)>70=U@*g;d`dKk0ZU8V}J^;m#zQ@djRZ#=Wh(`qNv zFy34g4T$Oh0l=6-Na8f)Mu!84dc3mYe1qZ};;YBgKmk2)6ao++Q4B$TOam|q8JVEL zIEvEDU_c8_9EDbfK|Zt#M^SQHr35S}y}J1}R`Q2?Jf_SDt;|c|7Zj~xYtyoE60)Gk z#vghvDCL&iXye;VTHgYw04OB~`=FHUAEy0Y81N(tih-8 zU}f%kUd);JeN?o?-R8RITTyRSANaQqprp$nw5%wh1s#Wu6Ns-z6G1w|EPfP&0fS-9 zCxT>l?R`WsyY_BZ%&xQdD`wZJ`@!r2vkS~FkZo3G1HIfm6=)GIl$iDGw~DEf%_eU005~JO97YK_ z7^GwA<^a@4dbl5k8Ub(yz!^!)0dS_@OT(~30M5vTEOcE;TY9|R!+Pyj#y)*K3eMF5Kc7J*hS0+RP}?e8nz z$2I>+@jh>w9Y$LFZz&09=lcP)E%781Qab++dFd#4> zMnge*!15>{Q|me#z}Es_3(O-hkH9=q%p=SN%mvH^%!RSMGww8NE+`(`tMAD$NHJ+# z;IWA=$T?KY#h~TY$}s>Q+oEAqc^~(Cmi`8hEnA43KFU8_&|n3RO>}{Ea4y*K!u+IK zvJ%E=$c4Nm#S~p`LkcqGa_h~%3OY{@skgnnJ&yCVF~~_!IhHFerDO?Ku$pEy=Ygex zgs>}V-V}RF@_P7cg7H|AHD`y-Rcux+9G!u-JeSjYL%^l@K({(`c4baa$F3cw-&i>(AK zf}M(~jj4^Pjqf-y3osGFCxRsQZvLVo_HOT z1NRTyzd-IEm_+NVvF-5&Cyo^rPa^_I29hy1GGX9(C>+VLZA}3jrJfN1O~as9#ZTfl{>-vYE$~UZ)9xx zCI_@{5u1;&Ap{#j)K6u9^EZzs!WWmZg}~TCkjUS__CBJ11KYb@{RVdSe)SvJsr&I8 z7=8o8Z(vLO4b1NsGvr3tL4+Md*uiy=xrm(u*g1fm1A#jS5K{~>#Sl|0c1$tM!Wgp< zyQZ*f3X$9p$qk{3u!nHDdkEog_`6D!IP5jWUek5kYl`b_R&*Hj2l}IF5sQ{FCe@zz z%lBc=d**?dTfVX9y@&-8Y4g46f{=h9VgQA-9Z`|;X&B}Bmf;2yHD`9Lnmk9FqUKZ%iC{z@3XPg`s71{&1DViR3`WJ%Xg%qkCBlPK4rN%C zT>89A`2a>QOVW;Ff-c63z58! z-LF@?kDb3(ypLm_P`r;T zA-9Iy8ggsMt!t70LK_Ng=rVLrr~~>xAN`-w%{};uOs*0$QV3$jDACq}q$;_T$fXIB zW5B6*sbVC=26S`(Q|{s=^aK$bxOFQfJ;@|N2rhEH1((I0BfX+2O#F?gkUU!J<*xba z359y8dYM7KIbRmh)k||2N!?{;`y`#6#ibKREZn42E-Abr>>8hq)ayQoAR;gYp z`B58NGptG<%*ZmyQL08I1gMu(y}|0GyAAb{Ygk~nH{>I7CP6;Z3i-(0B8rp>A~jpZ zVea2|?`^j2>uq{kv46F=wslLZyN?S0VXyDbj%{2m!!NdaZE4GBi+>%n#w7MOV{bF| zHe+vd8}~LNh8wo4^+t7VS3^uO#1unJF>HUuda{9?@JrkYk8RS}CXH><*d~o_(xU-Z z0;~jB2|QNs#NF{N10qCty>7;gDAT!I)c*Zt}GOvG@z7ctys>e7@7(ns!QsND(` zhJqRjYAC3op!O5BLe~jhCv4j?4LdMV}*4m$CvXYpQwh(I#`Znm>zBbof z)i3W_4Gn;E7d=EpB0Q37`@U5@V(j>rf*Pj=V3v_)gjRD7lv&p&eGufw+C@dB%PEOC zw1myQ8+3ULRmVWYfk znxcwwLkLs%hdkJlYgi7M;`kN?2p9$NbHZq{zwE3 zM9{#-Z{5XyRP0B^epKv7UCaHb?bub@+^fKTU+nkAeqZeO#eQGE{l3UuIpj?(uEPFm z?5_^l8;i2on;mm+HpXA)LA5{q(mwX5XC6TOl|{#1$Twi?K`)fwu|FO0S6TijpA`?y5ctpkjcE0V)O+O(h*1ghe%x%V$tvm-HYounR&_^m}(z zk8QJPWk<2QyTC=;(x6~y{_cJB5&CEqR3=Rtr?$1ONTceOkdP2i#y}YZWek)t!0IQD zHcIOgS9+BxjjsEUQW{`f=$a2gX#}Maltxe*L1_e~QR9_HXkdGy7@#lF zm)(m!eDBQhy#vK16qit3LU9Sjph!Kh zeiKMNAoWNO#6%T!K;SS7#&JOBLZo`9)!sH^mWc1Fy1_WJP(WoE5=dLaE2+QRbes@kV}rCMfmiJ#ZTZg( zEGR3spx9xI>DF*#ATd~0@>{7)IU((df7D1h3(Zl6ww2I*e-KUq)E;x0$ zpvugUKAtReZTUazf=s%4Sy((XJy09O}WU2t{5)dg1o#1_g_YK}Rc;Dcz&cR&;vlz@` zFpEb=7cqAhxfB5J45%wmjuw>T#cmI&K)nL>3e+oBf{R_O1k;rOkY7N40r>?a5|GIC zfJEXdLB$0XcO=np$EoS9cC@K=WxBX#{jS;DCxQ zh&Tnrlv2|$+6d@&Zge;R^vU5y%0teo(Yn$w!q00G4XE*rRP zVA8Gfx8VaD$sKziQOvHr+ZD6x?EQ+_b?SaFyTI%MvkS~FFuTAVS|oP}wt+Vbz?%VE z1#A_tRRB~0OEbmN09ZBxrby5-2l!^F@XY{-6Ch52I053c8cdVMLs!s1Fp|JXs+Exh zjuALUV0MHtJK#U~4;(0PpnN${iuH5(z6`Mulk@`C4<@owuxPDSZ_&~!b znG;UyV%s=LGvbWfh;yQ+VWR*GitN8zrS{)7hQWsbA3~0ibUZlNKXeclMk99)xopqK z;?9xoALP`N`C5_&v_5NE8t8(N^nu$;ZWrvQfiCE$z5Zd4AJh4KU$`C%3KkSO%5R-D z5rAv~cBO7ve>3gbuo4A!pgM}6JYI^jwmNKSMZL}R)w;FM)kI75Vb(m0#OS@Ex@ng_ z7|q~@g%|q0;&?eNQ(dt9dZ$+>;fBRUM`#H6lHf~8X{rEy6KcUAxP0WX!}4c#op5(6^F0Y>4aYHdx~wpHXtX3)9(dfa8S zil@NJ)CswC@+lQovBXQQP)1{EN?xjO8Lf!)K{XdJ)qt0}HN=9FFDTY2S(-f7p`4O2{Dp}ky{V;(-3~I zY&;;qqcI@B;{hPR11u;4hK&Vf{}5F1Lr}%V7wcWs?@&OtE|ZF@(p24qT7Tkg)q3mB zS6oN@HWzIH_4m?l;e)#S9QN8yO`d;UbB_)88eyBiZhOTTFUs2Lu%#7ctJZ;hiyZg1 zKC-3R`mJidqHHl<+-d0ZMfQ2&`*?l^As&!*K-K|S2V@Uq3L799ip0-5#z&TK6jjc2=(47d;<1y}SZN-9u1w{^FIhlfnQUjyBRsTj% zGh@yLrKpB3D6^)%b>?G1>GgyUg4WbwT0P2}`dTSEP<_v&K@&kVKFz+ZOtT(Vafc$t zLD(p`a`n6xR8|WRtFLup-XqpupbHS|?_e;_zC=TYQo1j!$Zz6#jUY4^LUYNmp9mh> z>mzt*{}2nx;eHyprXtoK7L+WMG8UBn;rXg}D2VvDsK{tdh=hb|E22>OM-(f{+WOLM z&mE1-UDjRiGTKsi`)qAS8X|g*yYK;g&`sb2_yG4R{@?rf4qfc`2O^0gk|;vNAw(QP z#8nFshlrAhC@I#( z#P1+}E#lWAKqCS)B0wVoG$KIb3I=FIggr#qLxep<*hADNL~TOUrUgZ9LR6vkuo;MZfVcY_YIYay-0EXFKOjxJ#-2we)GaE8Je3TG&sp>T%684Bl#V!EnO4+>`}oS|@r z!Wjx@D4ct4;p3I7B7;>qs04|*y3m>>Qz)`I(mBd#MN+nH?uw^IQnqc=Ebbga9BJ2! z@@~_cmP(y;h!LS|wRcZdtEmvG2KiC?!0n|O*OvympeKD0$dOZ zNFVr?(TYIf4282qCP3lbU2MQ>D51ktiSQr%S6xBebYB$;;zD^FlIDNCHl?wxEg|`A zWkb@6_W55gEenM+6wXjML*WdCGZfBHI49!E;BuyLKKo%6!}-iu#c;mv4^<53GoOMO z&WPcR7|w{{3=J(bw2X!p$xzutWe-Zq)zJ~;^pMj-P7gUffCd5@2xuUnfq(|GDl`zR zS6HvO^{Uqu-{%iL{HhGl&=E1{V}zfV)q)fQQVd8jAjNVYHT>WR(P=N``3|twjd252Cf?oQ?n^0RM<_5HJchC9)?J z{Id!0&-UJmfq!hACbFPp9^c7owG3&p! zpiIr?MoJ0gp%l8HY{yzqicy{ng&TwDGY4rHfT!O}`;uM~K=A>~>0mz%#94vmB<2BF zPUAruh_jMpKP)JLE*PW%%VCBiO9R(bEGS|g#D~KrqXz`(1XwHhpqsSa2R5t_MgI8W z!|Jvd(iW6$)*5CT8@4p`TeJGjzdx3XWGmWNwcaocn(vxev~Rif(zyk68_;b)w*lP- zbQ{oZK(}S+wlnxfz&C>bH$q1IJM%0>{5$EF#*iM|eIpA&NW+ zMRE+pbU+7WGmy=)g(xQ2OLjpVp4Oz=$ux{N7exc2I-v7S*|jgh0l^U^rk3EYUFt)O^p8|=L3PAj=nkWI@I(rgr%W-B;0FH-Ob^OWhZ zC?(K}r=w5DhgB=1jo7s0YR`oD3(9%AaJgInqK;&6hCziu)DeiaY|MxFF%1K?HG!xj zeGuY;{WOSGC*VwJFA#VKMAzT|%)eGJ|ISxkP=LW&71T#8#0ctLbyUxCku-}NFE;c~ ztZOUULJ>UqT4GDH`Nfv6?zEy_cbjebY-zUk75Uu#VJllTt*E28jv}q7*JiKC=Pq01 zE9|v7ZI-Wqlmb8m01TfJ6ooS$!n3jLNk45k+O%yIoP4&fc%6OsDP# zl?hZPP?6|FgfJqSz0%w(0B#Hiu$A_*XooLlo?XIgRM-hdGSc*$+9~V#=6cT>zMjz`#fye98AE z3WXyfhsijy6wDJF(8PqvXdPIxT59X|-h40_GAx7-EM|wc#uD%iX&PBjyj!2)Sd0Zl zGcvnrA_o?f;g#}>V<@4*P;a28v~zkWsKPxOEm)NsP20=)^%f7|S~5HY_!kixV!)QRa&iY0GI3>Y$SJ_{Zz&KIP*Nk`?#*Fcps<#SMWZ- z`vC6)ybtg`!21C21H6xh@;)#$D-TVeQGiAP8U<(+pizKE0UD*-!%BdapvIK|;23~o z0FD7T2H+TgV*ri;IHq~v7|hGc^Ae05Fm}M%h{xH0@8Ns!jlee=57*NNiZk@^7i4%3 zaE1iv2b`g3#3yiuz!?H(NO~X#jzw9FL<6GKJFQkUAgTj8_XIhBP*2>9431!8P*41g zfO=x1o_P9k9_oqKqY@!h0idIm(Tb#76G9N5#hpVUgnI5Q?i}f!LHnuJl7b#1eW0OW zG)-WXw6K7EGYK`k+Oin0tU;D zXn|jr;ge+3JfSPpum6vOT?GKWpKz-(9X#=J>Z?4(LwzNS=<2IEjFi<^o4}xY>8Ww;4u%Cv-pC1oy zKG=5!v4`*m0+XJ}<2TUdb2{MgKo&HUKRk3Gz#&HQr5@2SVB9ls~P zUG4Zi{UWvF_r&jD$1isLV#hCb{9?y%!*={4{%hs=cKt%{tgg}Ly26CF!lG8v@0!0ZFB~X;KNl}9F-0tzL6hjaF zG899Z2cQ^&Vkjs=T3~dy=!&6Cv=uW(UOo*o!CH)l$S#Oc@3h*d%oinbjl^7p{%rBj(CSjv@N<6LVRVS|T=|=NLAO2!V-_;+atAg2H%d zMhwRm1p9o7F)n6Yao*>U+Kq%lDo7s%shv<8LTWc?h14!}OYLS$39+Emu%J9;EG6IG zs}h8eWg%Z72&-Gb6(TSz+R_%`9j;?%C%z9bCNJ&QJ-6V~`h80K2tro~T_JRZ&=o@0 zlF)S#;c{TJ`4N#4sc=62b4uZS{3fMvKKXr0;e6~hP&h;3423fk&QLf*;T%CJ41F&2 zxzOiAp9?kRR80wW?Z~07h1L{WQ)o@0HHFp`TGQofO`(*8QWDCtb}P%!zpL55lNe&} zh9WSRB1KaJi3a3)vN&=m>4B*(X+Bv}egX425i!J|2>gd);7#Yje8Wf;uT#)6pR}e0 zf)*&`8mo}YRgeR@OI;4oQbM69li`Qv8^aT;G~Ah^9$Pi#aAzfX*zAPuy-EtXQqxF+ zO(9pR*yW_@AB)rAxyMF5_1g45A!+V5+xpl4 zI&AIpuh-U=#ih+^Gju}G2|*_Woe*?FYoHU#6foERzEZ$k^PiLg=7t@mfI0hNC}5y~ zfdU2!7${(%fQg`hL6wMP*uB`p>Vws%@k;^JdQj^@tp~Lp)Ot|sjfPkWuo7S;z)H}- zl>jPXsDz;shDsPJVW@iA}fDp7y80AP#Ks62H zIY2ZZt^-5^!o8UJZ(b)pJ;VXaWr5hJB!CMIucQUxMlc*~Udn+f8&gV-RwSLHj8-J$ zXJ{#sh2fEqZVj2XRWu?)&(9Zid{S>J&_SA0DpowLSNg!Wj8=rgh30CB^g)mxE5U_I zO)JBNNDAWN1Ey@Le>3yNF{u%KM+nG1?GYpcwrr-!Vm zVnGe!5gnvqNTz--&0#$Awj|v@4C6Y1?ghG+xJej;?lm5?fbN9_rPogj7W4IDA;s@n&e>rQOx>fj@GuxVECK4QaQGw4yDTZ#z5i0esNi zT1_7mYss?bG;o{1Z34Fm+$M0FYk=E4gS8H89o9OmbpdPL0~^U5C;pux_MW&|5qnSl zh9dSJe-((mAohaT3t}&rnP6r%o|(DG27<>49w#V#pzwjh2MS*UDSQ{ZJHDcmTF7yf_!Ms?45GsM> zT12ULS}oyP#A%51Kvcj@!L^9g5Y+`i!)V%_*(DVJsb&;}pL`ld8UdZor~6F-&`gFK zshto~MeMD#-szOTAj`~FjF)ow(o9O2hqRPZttC^i1!yLNQA=qirO{SOn2rJ=JZIn~ zB#S!-Sx~fkN&~c%Fd4CgW>S3y(g_$$Gx_w2Oq2h;@8n{J&EGJUP7Jckb7qK_f#qRO~}t_&(G{Mj(Lgi@gqJacgHW_HbhlH}-I2 z5BFN`%f^oUb={GVp0BItp>2S+0on#=8=!4iq_zRJ*ENy~dOo!0p$md82-+TKdzPi` z!C1ps!&p=Cpp?$-@_o>`WgZYBqM9^Y{(2R z4U`_p#f(uJa_^!Y070cQPLd`@D@At66MW+&l@x9D2r5fvJrXhvR7t1lk&tc;HKA>f zhOW6%YDw9#LYZ5X$vvwX(aMyYmWmivrFwi4Vhbt{d(sDfel)Hx4Rk@RagvO-RcxBK zLR%XaV&E8YlB`3?*|f$hw9P9n5BvBF%B@mVS)QcD92K)IOt_hb#RTg2QXyLmQZ1%_ z(O}9KjPO#B?@H})IyeX$O7g2KXf_4Qchg?rY(jXc!2uSOgZ=a{Fj(8IvnB$fa@8v| zFNCk_8`Y;t0&Vn1m{`2>v`=Mvq(Y{0R@fRtI3`l5^*hbBDw9hxxO%?I!qT zz6P-N*|FRJVf++@U!Lmy<;nK;w0aueM6Sr2Ncl8*=BL%uH-UF}bdtgxB>Y}{Clknt7e6tO(5`dNq zS}th0)0F_fwYKTER?y}`X>;%(Jh*!_1`rlNSS$rt3YNAMV3$;Lc1eK%2?8Vtkct3_ zxq!KVxv&~@LGA8+co(~SGY>@E-rN%n$RUkYsFL+JReGR)s~$!{YXz+}pN7$fB519m z3u=`(ty zRyDG$3rWcVY(bXY+_RBoH4dqCtd?MaSH(D(?R%rlXhaew3kpdo`pZ@Ev|ewrtT~hn zvh3{3F>@%gtkzgFOW8^Eg42PVO*AD-m~AA_5oJNqE;!AbGF=O-m!p-@ip;y9OsjD( zD161!g)DSI+1vpJmr@>q!373aOfFz>gvh*_8*7Vei|smS`#}6y5#mR6(j;=Wi5rUE zL~jZxFp$8Q2$%@di6Hq`yB|~htKDx^{Hx;+EB@8a0r*$oUv2gP)B#WjK%J{iS_;6z z0RaO93=l9tz?c#+!r4e*Xn>*7ebv&W!qBBdmyW?U8VXTfa&(EA4bam=PY>-fw99SM zE-QWSrCTytT}*Xn^yodgu&@SsSfLK{k+u<4TO4tt^Z@j|QN@kX^}RQ{M*!50P&;l7 zHwN>HlcowMw>ZgEq+KZ|go=~4S6=OSwI_Wbdt<8D66anCHnn5f)efsSPgRH3ip4Mq z*;&7TkPd9|#XPL0{ll>B`RP8DaLsW!P<=u51=W{aqQ--p51g|J>KCXeY&)ruwzC5t ztf~*N8y~yz#U97(A9nm=$1isLim9L`LKgA%S(Avj-!+MNn@l3!{ycX4Hfft*%Od#U za?x=~xHu_$3tTH2z7ef!wAkA3^tw~u}MHSXIF-}w&7*dzUgOPq98Tu&bqh{!%FiF;Rl0X>;WgL`oP{y@M8He$V@r>~t9;H?( zOfP>73R5Udp)eI5h$^w|L|2$*L42c$tcCmYX&9|15J6USLCg`*>3mbx`AZl95KKcb z9bGV;g@jxs!L$~favIQbE9JD#MHZ4nGopm&@deXD5k1ww&n4Z)g2FB+RYyTj2CXrmky0HJ`NgB+k0NdrL$ z{WK7SfKZJ>yq*@J8a*Hsgh7#tn3q972|4eBm>TIpAX6jlk?Wb~auE9!v0vp7Zb9tV z^Ht$8KwAK9u^tEq8i=qReO?4|ta@$ke1$z*AV;KK+Jz77K3JV`Rg7_Wo0qXB7Hz3I zuH0?*w>PU^AYy=snGj+S6!J0nNED0qB8B> zrKn8D{}5CrP?->ibwhFk#zXLPz|YyhY`|=oxfFo51KLi_JOhwIKnejVWD%qg%q;(z z1-c68Dxj-?t^&FWI2h~7!SL&OP%}Wy0A&D_0Z<0QDFf>^j}%qmp|8lq%rPb9h}*3s z{mT**TOx|fF%T6hkV*O@I-sMvAflukRE2CIvJB3pjm>{t_Y@#X+X`w(Qw%AJ2(>Yl znfe<^E66Z4tEQAQ9hoVa`dBN&D?~-((`jt!*=4LbmZ7wDN333;($-z^)Ui{g?KFiB ziwZ_oNxIKm4lCWS>XVt$ee3We6EHZ|`9!)utDd1HSiM>t$b!OniO>s5IYhn2f>O#d zGa+T$R!G~OM$$lf!N#fSf-N6hYB{-TAPzt5Lmb}k zrGfhRgER~gFdCgQCxR(I4v{d~%G%PZO1r9-E%MpqS+=x7c2$%u(iYb~|2nF+1^HGr z3Vhq7(90d;A@jHHIn$)J)O~GsdyuC>o(g$t9<&~cL?{xWNL;2O(K_R#O7Vv0DV5^& zPgW|$o3BwS#dBw&QiMtox-96jX6mwD)rEeqgK>g!(i=g_i=_Zd0aX1W^fs1))m#do zGKb0>Ds#zZ84ckF_@UeHLCfv0<%ZATGms_lErQi^fcr`9J_?i%P(DEU0ObRe4?ore za)Sx7do;$vfrSH00hR(R1*4&Wek4bi8b2FAX#%Bb-RLs7UQnq(r2>^|EWe~Xm<#X$ z#sTKSLz})nP?Wmw{lN?l6_Zg0N}WQaMTE4;F;F+6%ZoP#N*yS5(J6JZ3nFp~yAqWs zr(wLgC>jve0TWncJO_vd#C3pZK-3Y?>0F5HfX;L!jMb3nHzcRbH6V;mCunXhpwkt& zOD6@7hu{HsDaL^(-O?ndUIp&*s+>%OUCA>gS#INexJv@JoZv1?6MHFXt$+;;G4JD|b1HbASpTkyfE`k*TC+Lq6j7P21L>@6Tch|~c*KZ5pL z?6o{n2O=4OJgbps!6XNh987XB$-yL#!z2g!8{}`0zX9-F4QglOv7%@oD8LP+0E2i7 z;w=cUpk9J{3F@UU@v-Uah9Xiv^d}hzE+&sKIsht1;l(0g^+W@53|Sl)oAf|b9ACj< zi_{R+1<^);0Kvr_0ipqMM?j~WPs2zfKy^XH5zy_tKuyE?9k8yvn1UKK+yH>8aR4fc z^BG83NQH!S&p^P`@ca*gOekZT49X&4Z# z2@A?Oj;;5AkO8+x^Bkg=M%ncn2R4oTGSZ5%wm9yJe;rjBCbl+LE!(7gw(GE^HL1;L ziw|c8j0-R>z_hs)(%D#7mi${MhT2mi*WaN=ts?tCW^}_eY^6hn5^# za%jn+C0`>gITXcE6hl$mhk_Ie(l`oI9|b?urcj$gZ3?w1)TZkp6ouvxn!~1RhtNJ~ zB4cVIm3HmYH)N8Ln7S2c*OYK7ldROQTgf$1E>(^k1JJH@IxZhA*hSH`YhSf$5 zvnPV6W9{R`LdJst)MDa5##4uk=eF4k3Zx_mHI41*KHJkVUaF24n=HeYX0vC@XG^n{ ztv?Nt1V|DfNq{5)l7yv55)cn7wU!Ps~3Ef4#>NjaSfqRfkFie6)04oP+1R!3a)Zp1GYh%1Z@(uKhXZf)c#=n zVf-y({2?A%F5=1qF-21pK~si!Xwfxgh=(S-Ale!r7o#Zg(1^x|2^1w1OS8m$B_pQF zDRqjADrk#Rdk`zL9=@8^#zQOlo==#H`S>ikgwCEr6Z6@#P?=ge2gH2kcxa_DJS!n$ zJhVyEpbl-D)1L5PPYR`N7VPK$VG2ZON~NV8Rh&K#+<5@IBE4D(8gX~aWA zJT#kl%$8PFJXW==EuSr|>Gf7^U(@s1eBYL|ru!c4gV4CU1t0kN0ODhak0CyW_!#2j zrHGH$Bv9CzTxJ#z?esk=9@^e3R6Mk^?^W^8PW=+%p&=d`;-MiP8f3|kCHu*eksCaN zhVl%cn1^B>ig_sJp_m^*F+apgfR$i@D*ln+abc)(ycfZYIgLyl017MeObyFq$@ zUnhIThl_HNXh2kWg}DCC{Ic*(5q?%^%V%*WW+~%`skBnW%AJ@k5@yD*WjGThl7UtY zwOVi{W|t}DkO{s)qf180L8D7qN(tKKtbWkw!xCqrbj33gWYFjtS=>3KY$>6XGu@ zovk6Fiwht`C0xM4{$Za%4y0iugI+&nfgKf_ma=WD*fc;f5M5mIIsnCJ1r(zN3rc4+ z3J*g+eC)BoIPp_f`-bwar;e($iK?^&n>|}uTRvNwt*kArYFS%8TbiwoHmBKq(3Z3| z_ZzMa1dqjj&wO$T-(~aE0TKyFBp{K1L;?~CNF>=h@Mt0y#LH`f62WCYqA&g#TV?Ns zimh_$O^U5@=G}^|a`IzftAMQnwhGuPV5@+w0$K`aDfQD*06GEa1b74B4Hk$uxY+H% zzwj?WGNa~!WPlz4dIabZphtin0f3@C01EVFBl;2?5O6@iYXGkylvRK+gfX;^V`#$Y z`1DK~4@QU3#z&KcRy&^iCe3l|Qoc>frbU5)w5FJ2t0MP+N2M`@VbO6zz$wUW`5kN)& z83D?DDEFbbfZnsjW$e(UTNg?dQ`i9wIoFMHInWPulSbHio_P8ANHgVs8hTJ{b`^J zL=^dXI0!_wyhaNO+dJu$$S~)4yh9rWoDs;sA^(Q_dn{crPKE5%OZA$Gg-H>)_)G2V z+(y!afojUUM$%sYa6iOI8qoTzUMwgzEGTzW8U+wtLjWEkL`2zk3sP-D(~vZbm*$U` zs{Om1zJ#b5qGpJiAz4KX^yJZ4l2wR>V|hM{{|>b6Q!UF>;gd%A1ZrWZg`t{kzG@PE zgg#nFMK{#KxkfjaDMr(ZLM@E=*-#7T({P4ZHrLhHKU-H%EzFAFcoe{jCKrLKVHJOGkdTlm<`Dz@TD5qR z?PxD_ZqT_w=LVhI8tL4i8iHyFiWDePLKP`W-SObhW}B+x1mIHpxG(f_nS}_26r&;e zw(|P-i;JD;Rg6O20d>cnxoTm?6j-CcJ&W6li@p6q<3S@>v99K2B8UVgwxr)_L;~ad zh>$HcglxgC?!j2bKML9%p9aM)5}}LIeqaEk_%WdFtSCf4^dCh3!5&)d9}V9>ihY;Z zcZq$M*moIw-(|J~?##2)4!G0brFOtwo2VUddq0mIaM%Hd9dOtIw{AP&5J{pvktEQU z=*x!2n!!d}Y_!EjTWqvNkP`$sL6DQ+ASdnE!rtbq!1jD>&&T$BY|me#?fHnU(umk9 z=mvDd?$NllOD|MFPys;&1Qk%I3J7BeV+dntIBb&-l(O&g$Dr(kvQK)Tq6%>_DoiN* z5Vt9xhS7#%^tervX&B7`UD1FlO60|?0dfWUro!}M8$txKS`4bqy`dgMrj#H9#OT4V zN?9r;7_n+9mC>4(j{M7LMT%H8nhPcu*wZ3gS##(DS!))zk#vqST9LHm0&D9z!R5vv zQ_6+J{DDBWx0Md(36(@d!y=NLHc zQJIc^Ca6rHGJ(njDif$oz0p-H1#MaiK*RwN2SgkYaX`cY5hp4U2c!~^NuLzp%^aysA3LLr zR-}-H&*y?Bs5nihXctI?$DC3!zKT;yqP3wkRhUZ2UWMyR6{lc+Bhm-5p4k;MIjAh0 z$wGwVmK>%m+>*uZBq0kgm6@GDhQ07X1y`HQ2vVd43(7I-0%(Sz8I}tWG{evgLo+NU zg3t^f+dnL#otuKv)fB!!kcNH>6X1o2z-(o0X;u5EYS~rHXKQoSw5t9?K5i~RAP8j= zlu1w~t+z4>^2A5xT?8K3NbWfGN~P&K`5vX|I{hxC={k86G+hvKv`NT;sN0CT-O#Ap z7@8QGv4`(EA}WIU^lIG8B`oP)tGt z2@NDPV|L9L`~tr~8@jsDpC_8nKY`{m7n@D>UdtAqi3a3VW=BhJi1YvzyydM~T0@}u zgyu7!hS7#%^hnPyP}8t}2ed$M1_7&(R}l%}T(NZotaBTo z0#?mP)tInUz*;I(O0bA+O#$odGDePxfK?N{8d)qVUy_3?DDxR1ig;pH4eHqm!YAoG z8eZDg7Az>|wBbh<2k3F3$AunuEL|{82M1J%nQ1e<666<#&~ti~(Tb!81J#syjikN) zVb##3*0!brtBP8~wt*kArYAnyHWmhqut<6=_ zs`}4A-}=pS$f+Txo>xf;5$fC`RJabV6VvX|M65oSF&I{KFl6yTx35=P@~!`QAeztcN;;qK3qN8r z^k_SHGxQ0uQH_jYZ1ZZuHm{W1=4IX6XW819lE@QP%Vqbj!E ztlyTJw)7>o24QOuwg$CglZIs8Z+&92PvM6q`xMSi_9=WG_9{n zqu48ey#jI>Xu)2Ad-@@OCF~4n%dg3}j@8@=fn5OD1%O=utF{XuBNLo_srtqL#P6tI z{P%u9{o;S(3HZeyzxd-9fBfQK{Nj&b>h1C;87i&sS>D?R}*N z-Tf7%2Hk!DYEY;_p$3H-boJGsklaCX2gw~IcMXx6LG%RC6GTrCJ*~9p3Ca>EOQ3Fm zx`9(SOyZ$G1XV!39XZ;baM6H#$9SAA(b5CU-x8f)v+#TLc<8DNqK$w~XCw2*8g1%i zx6gv4j$PuCgdcyG_(rzn8#{)vV;DPz$I=B2*fG43?Ks8mQS2bI?E*tu*v`cE_zye( zu=5W)|FHA#=<*^qrDdG{WB;?-nYH_f+L?9Y@oHz*?n|*V3p=y0GYdPjR)1$!%h>L< zTn=`tVYeD~t6{fVLw2hH&aU9>*tLgUd)T#yU3)9NYcB$Vy75(FCnI(;VkaYZGIBc^ z*KKU5otu}Qo9$bSxo_`6FY?C4%mXnuE~;&Vg%_5-flzuNrUS5ZvlE@?{1vs20Bl1A ztQfH3UPfAuzYTSjVa1bO-g_m+Gj`QtS1oqcHf2|BsyMFx7+0|;cyJ)_v1zY=xF2F9 zn8Ul5H)YxqY!b#M;r483MPH&XLpOJ0Q#3Y3V^cIXMK5Mk^ddG|uZm4c>O6f)?FZlc zagJ3#LMy#w?PqXAX| ztOQsIYFP=O*MVLKdL8I>px0@<0tXH3brP&qSgW$N3VJl?(bi3KgzJU=4*I*{5I$(0 z50oDA^2?xygdP%lNa2B~g3$PS$Sj!1(v`SGh?`HtXgwh%sWBZORKXE7(X#G;wsZ$T zGYicuG_%mm_LK``_?qp#;T1N_(LkynT{#xIplq5=D~9GKRxWvRq?dm zG|&ZO$v*J7;%UXxdeb0Hun-offi7r3#G)2xs{v~OtU)dXPnu5bs`DBeeCW2J+YZ%j zV+>&o%^E|)#~vGulkOJHXG{B}C0{*h{Y&L>YD+_3(mtxk_tkw01_T%oU_gKY0R}|! z=p&d2miF z-w+E^2@fOcz9yoxc*3_#co@+EQg#GF=GQOY0SndTxCB8{okvZ&99DHRQ<}7~ zKDr!UtCWc&to>CgQ%cBOpJwqT#kT5dMoQgvC<8-k!m4r>caBu3!0c_KO>^XsK9H?1 zWvQBw4yU7fKCxK~f@9t`+XWC{K!70y7*KSLpy(2cyx?e=x6Rh|LAq1k&R%_+HOhu; z{kN61rB&^Zs%2L(pRLVR)2jLp`8df27-dk+01z^0*N>` z0YXwBs*mUZXc|-(#8b6&y8p1Q5di5#gcwPst@vpvrKB4uC1^!bt6;%hMk~TgCrU=_ z32y0xy$qE!T%9eQC>haQuw6|$Q8H2_Eg;RnsIUt?b8pdBxwsP4DBpQ%tK%$|7 zCjI~Hy?wAX$z9)fUhcVX&)a==wJ%~<7{0#{-S>~h5gMF10AB*ERPZ%gKbLZz z=bmTJIXB(YXU6z1Wdd~E`eD)8OY0vcZ{JQ&h&olk~etu&6s!6(rkV=lFYmKCD zq3F)VrI>n_usWD5D>`8XX`h{=Y72m^KUZ6T&%LhCXwr4I_qDY(Y0Qiz&*IVay^jS2 z3knt#EGSq|dQj10pd2ud{U_Q1bNBnT1Llc=cEFtcPdH$3z~F$v0fPeu2TTA5439h; z56M2A%|1B1aCkZRI(Ah7f_`^fVT1S*-}%`X_nMP?jos4y-GW0AhawI|9Evy;o8#SM zB;(60 zwTn#nAsyiHHVifsY6?OIKw$`0B!nOXkpl?w+|^_Md|d&k-_&bbyH92!ThK1Ygq>5q zPS|Vuxp&gp0*mXR&PIB5MMMeMm(>N0uw@zyvVd~nt{Os60*tXnlmN43{dHD@HSh$h zvW?B71gxef4t1ts?S;kK1gbaQbdPb1j6vypQmdc|lmJV`)=UU84e2&2xTn4~Gx5gWO|)&@Z#96`Z0AH{96hYzQV z!RR18W?S3qmg{IcKW=6Wis!uim~H*J?sK1eU7yjU>um39YirV&8B03J8Kzs5ek=dO*DiTXdSIzec7l%(M}e>FG~vD$`ScUsIW${mYul^yH62WrE5Cl?g6N zXSpbFf8hR%&c>{7sAHr8QUR%8el0OFkJ|gI7tk4?GeBn(naDsPjmE@bG#P`t>OSs@ zIcAOl0PCECNd=?=Qo(#Ck!XYK^N#HX zNSly0A?rcbgLnz?a-sgjNx~xefEch2*b9Bg2bw1P@jo3w97CcsPsa&DFbGL9##jae zDarvzkY(~ewGTu^ASg;RLYW2(fC7024FELRQM?U<%>*=AIR&-n9wYVWZ5V6?jAR}N z8vr?g_)kHgft&_>=7@arPJfWp&oNFDLlraCf*oDI#Dwdv!!dh`9W_v_ zj@VJ-9wUw`>}dWaip*x*V>I|;N3X;>c69Sd%qv7mY;opwR1ET8&`N7HLU15*NZZc6 zsu(ytC;u@vj&Z5oBxB$ef|*}Jkuxe|5QiXx(cau32Twwo-4~@M-7@En2_^qAtAvt& ziMe^!7qxFfd6Y>gWk<3(p=8#hxp!(zC^;hukWkh}5;&pAIZq`Nb9!9Uy?3RBXXk4{ znWr0MgurNfh5(GV4%oqpf~hg~w=`y~0)XT8|wH{C|10xA{2&o1O= zLs*BfF1N^ofy@;5SPU!%76TjMn!+a0&jzyMF8(6Cv^RL+&PR2{j@zHs6+7;JN>}W- z{R|a5+RHA6Kn{T%0(sjB|Rpm(l(*ULc zOoOKFB9F3TcuighcCH6I7dk6+R%ndS7@;vjW9%A@QIi*6{p|>N77{xLc`?Ns3j-Zf zYal3iFyV~_8i<~PP^>SB`dabE0tG-L-vR~z^5XD=yP~i9!I`RIsg$MgtWmvsN_#V+ z)+#uY9ercKne3OOd2rb)GH|BsC2K1u0-GhsI7LfQJfNhdWJ8+vwpAwq;7n@(BpHLX zmXZxsCzMPajdgDuE3~)S{ipJx1PKNb3?!I^3c*55J-JHi4~7q23lhxL#if|)+1PqA z#6DLm;CY1XfOGlPvRxV zOTItG%~_k96YDUGjl~84;1Pd!cJ_A%$2^YtM|i^VgyRXv6FwsoOvnVg%LLE>paDPw zfCc~!0JA-2dk?cc%cl{`2j&6H1DFRe4`3d^Jm?zppeuPzvm5T6!ET5m5E5OW!2ywJ zKa6u;|<6)hZ0P6y*Ry(mT|v539C?=lPrWrpnrhE!-M{ z`=Wf_NmY*>lI_W?{ z86M1lVgSWZKO2M>%t0~;6cECT1t9!L1{YUiGWNqE0L55Lg(nw8W_GVt02D*vEQBW) zWFqpQ7_PG;t~_^>cS0G?=MX$%!o%)d{5b6M9QM14eUoS%wy`8@%|>`=dK>H6eLdUe zlK8pDk65W#K_Y=f0*M3?2_zCoB#=l-l1Luw+~1Cft#aq1nyqsC)0(Yv_fwj!a{C$B zDtO#?=5dE30!IXn2pkbOB6|(Vu~0{#j=&m#H89#8Yv61&CceaX4%Nkl>H?VqG6!T1 z$Q+P4Kr1u?t-v(wXc}Nvz^v$sx10BXy#adz_D0O!(5#17e_I)h!?}y4x%hOPIGlqF zB#J;t)PWp8Z1D~ffY@Xn&gXp^H~_F7WCTKP0lDtuZ5UO@?90E+kt zS7Z5K+Q^$}kRE#&hx?(>;Dd=@86Z5=vpOt_dZ#6R(7#dNmVD?sHZNC3D47nU-Y(4q}i5GM9iP zkX~(&1Rx0%PeELG`tgM)mBAlL@Khd>z{TZnbE?|fTpgLVdu?r9>Cg5aw)YLyW*cAC zZMJvwd4{b&Tbs`_xED9}=ztisHwL)lJLhoho@bLcdJTdB1Oo^L4I>ytUh>1wXfOHU zhqagd_!;dbKb+tt$4lN2pCiumzB^Kj>da_8Y0!ZU=| zrrTZ{?VWh#>+w#+qU~VT0lX8zy%S$ouV6h-^k|f#+9X)t*a`a5Sa}i`BOhc5_UQyPrN;@T`lbXLj5T+qaL-d5`DM$45 z(%#^On?I+OH#eWr%A4DNMJsP^{4mNJls71E_SC8OVg_#?-hNYf`~A?Np}9hHh2{#) zRVA7$>IclvMjekNx;k`q=<3kbh0@glIo}cFyzzlWQHi1wMP;`YmCSED=T|FX&wmXi zEJ|1vfuIQG;7V96Mh>Qh)(Gop5<<;HaEkZ`n`8V=!ZAPy%LJAQEE8BJax4>2l)FSx zj@Yv&-=rNdcYabkU>^H5?SQ%c`#4~5z~F$v0fYT#Jjecn{bxujs5lj1a>C?<$qAEF z$E`Wouexu)VvfttF_twfYgpE>tYKMeyvK_H_SN0j)|tnZh$|7-T0329%p3Ez7!re! zG0@J@dvC`%igOg_D9+K~&e3=qhO*8MPlKALrD=A#4TA+>^hLvA3CnJHLmfqeC=zrs zyZ@-`4&AG<{O=%JMkpz{b*1d?#g}ZdJ_{Mhjn65%LYmO*HJn^~^-Cx+NNbk=&0#ZM zXa8ijmQZ9%HYS-~(+y&txhK=0f|R}|idx(trpg#j*Coue9(gAMiF0 zUOiV_cp8C)X9yPFrLa^CR`0XT>)?gHxD=jt@G%Lcf`sx#r4kCRa~#b$n)~Nyo}9a_ zd)Mn&Lq|upSlQZ&V>NsDaJm?n1r}{>Nx?zeGm|Oe%;Y!@xIKZ()FUxL!XB}4}D%4eV$Sa-S~2?7CQcAtroiZS*;d2 z{tDDWsD)4qp%!YQTBs7qQ9n9CScR|(VHLuv9SW-`eo$2W0NEL`Gh}DT&Kf2=Yb)42 zz_?N9q0mF2*FS|Gb3S}Us}#@Qk4h1hA}U200jZM=C)11|)QPS%z(9m=01#o$5MfH~ zV8r9}Q6P#!;<1Akvg=rAhY)lE3+>4?1?VYBun>YF1S`BTgy8uc5iO!pM5XGxiJix6GMiFC|6m}E1ou1_gHl!>y`+`ZM^7v{j2BA|Z2Q)ShY+XqTbcJhN- zO?Ky>YBkwouWL2gox7;XP?Mo1LrvCMHCaD6oxJ7Qc*rhp(Jn^iiOLg|Co0cIt2|-T z7O`oG4$+yPjpx(K{*s}pMpccf8dY@{Rn-B|!#r|49FR}QCq?-Lw*zhm+z#$ae_}l)TItZUjYrc5nit!t|Kfma0uF^lbPg%-K|$H*A$pR9F~X;4ABl6@Fs zT{ff=O5P>KLz6M^3c*ZxhCo^vy;i~Q6Uyem3YR()YZGYP@?O6XV7A0;sqB4X&EJro# z)_h=}$3BmJ9{ap^g_WLo|9MS{5>-`{$a5_z3dM~t*A$B5U)B_go1fJbisP?=6v{yb`74tKQikSSXqXViDidQmeXlq8~8Wg?(NLf4#&w#UK0E@?XEIeH<_q{D1!g;=O966CT-=`DdXY{%M zNcp9_;waX=PGw(5#h~mx#x36E&Kltv^9li_+2o24eio0*sqk{SNVAFXa=A#8i|`Zo z;}%jrAKXF<>?>sLkT3voEf^ve`=U2J0oT`H}BS z={m(eknbx8gTH*=JE6S6TJi-~K(kf%RnyaL{q;``-t0MNKDy+BzTMcb42uOo++oY>Z2!I+qCk z3H%fIC-6_;pXh~uVpq-%d>QyM@MYl3z?Xq9!`YXy%TVF@!1ICU1JB1U{0^+E>etoM zL-K#(A&DXolBth}1P=)wl5eznNPOIBaUm6aC}|%`#_W-4Fx(%pVt+%uhfF7#2I@WZ zu--#v6+DuKL#!qYcWXxnd?=+0$gIcl>HF4ADr<>&YtMyMd#-fxmlsY262!!_5QA`g zPX1%4MS+=xS`^CC)C4n&cS3nOccjt8{%mbN&*0uJIWVeS&#aAl z)`t6@#$kiQ28Rs}8yq%maM-weaon$rTsyagcJ18yAGB-d1RGahZCtpGa2w$^!fk}x2)EIE)|*U#I|_Fc?kL<*xTAJy`JwMdIIng&4Y;~+ zb>Y6L-hD$%iD@65a@uwF>bKxJ!*!-25Yl!x#C3-2EZ&B}mg1k?`4&jo-_~s;xh|5# zjU#VabE}l^MBLwePv(krgP6xQH0f|L#^&fH5>usySDmE0Ez#=+`&*tHCmAdDw;tNx zEXxA>TPC4cqO`#yDBKd4nH;?mF4DJEk}wMU?Mz2(15B^-bTFe&fd@6xC+)Sf$r9n zto6utudU71X5@OW?K)eV?YXYk`P}RC4BK;QtH-8AoQd=3p!qSdYFTtQH-^1Z)kC}Wi%Vzao3yj?&Mz&Ul`rG0#94{65@#jON}QFwc7$Sz z#1z>ZE7YzEP|@%FY=o^6TV+kQN-T|78nHBDX~fdFOTQa^H`&sN8xc1m&N-ZOeRR&T z?(OS-=_veS9EGu!X%g4Hs-kvQnLgwI;>*L>sZ<1ZZl{W0(H3SLg*Xc1Z5V7RzP;QQ zD6$8pEi-oaoV=m}dtjc2Cb7>Ijzqh83NNvbK7MXu4~$##?LF2jg4hF%7Zz4?qj8DV zL%DjL$YxS}i87+(Um?=g%vHC=_ZYV*YnH-%ALB^ zNU#TH5=!|vy4V8?dwqMkRUns*mpSRO1I`?3?qT(l*>i!_uM;)*ysdt3FIU9V#kOa& zfcUie*hhSx>vONqn45ktacRCatLbZq@pWkpVZFn8hxHEY9o9RncTpaAFpvrH-Y#5r z$~$-XmD)RZ@cY_3cl46>&K>*^-Z{K;c<1oWZR?%Ga)jjw%Mo?hq+tjn2SyHz92hya zFmj-ONB@ov*scR^Qe3nq`IR?EQoxXk)YDK>;8+P9iy>`d*;>THasaVlH;f=tMWCD@ zGahe6F$=Cqer`hmQUcUMQC6-dYfXS!z7ub~m^V(oodQ%N(vbkwXep_rM1VS-KerU1 zW^}vD29$;r6;O(5iVDmZ&uu6wur4V>8qy_cNS4;m{5F4n?Gis^KFEBK`E*z2qqG&r z-=eh@M}MHT6*vB^)>a&SAKD7E6=*BaR&1-S=rzkW=jG9xpf^Epg5Cr`?Fvwf?grfr zx*K#iTj*|}f@Z0pXpzt&HA!>BSembks$-6p+N*zy_6qG4+AA4>pyIRPx5sL>8U})p zY_^AxY;TH?Y&uslD5<|wx}xNk@Fc^xm4G>-i$fQu)-SraP7wIsHlAEvTuv8iN;i=; zk@+EoL;BZz>0kfm2w5YtMr4iMl{M}HL)Df(2TId<_dQzEdGZmh>3sY%TGM&wlW028 zbfW1*(}|{YJby$gAQg}b+Mfy#RU@iKRE?+_QT0kxeKs1i9#{{ohxx4HzYN$Fuq$9! zz^;H@0ry4LeL+l!$?^4~Md=a05PqRL{2x36AJAe*42Z#OMhwcuKzrxzo#CCsJBN2J zxOeWfM(twDFnU>8B>it&F4QlL+#wr)mI6q36$;iClq1d!g;w5W7 zW)*@i7&}F3N>iIO?~Rjf9A6>Vfw3-1cY;$Kr?|?7-U(%RFvEI`^;jh!tjC?O9*Yvz zW2-q?TwYvFd^#bakWeZ~D8u<&o8xB>A5Is8(Lp^X;iSfg?YXwL*H`09?zOeq?saXm z_1E+^*LSPqx+GT3G;-Twkc_$fy0YC{{ETMCC3z3VS5N*U&cwOg-&#NsfFb}z0E)Q2 z*B1N(<9v+`%ZA;}hK<-Nho8}GmBSBfw#xA{nyqp;fvo~i0iXgvg`hwMSO~BXDt5f$ zC7+*-dg&#{>x|bKuXBscd>yl&;!VVxh;Ojlp1EaRu&<9S1vT3kl!krfFE|}1|3?s6 ze{8AX=1a>v208%p$Z@20Fe%aI@pMHX6#EL1Jdja8M3t`M%JnesXjUixU+tC)I< ztnX$+KpiRSNFm+px^xez9a1}#J1BQ@lsl@b(f&7R<<0&bt-Lw-fL7j2eih{n${Un7 zUdkJEBj`rZjaX_Xpn*UGfd*n*4Fo6noZxrm1Ye(2pM4uA)tpp+NGk_+ds5BGEho2? zoZLR|a&pT#Cg+%yoMS%ka*oNV9;bTUIo0FziqorJo?fYQnOl4MT;}F?>2sOef2hx8 zZu~CiGMvkBF5`7B^YUnq2ok}dp7MUIK~6w90p$eLasrC~zpws(X1=PKM@4{&02RTu zDuRw-4R{@jgg3NEIC>rQI$hE0@E+YJhcLhP`PDkL^9yup=+smMf|kk+PE?T*2pWK> zRB<$hPy-2GE&9U-XKAOHB_xwbCM%Il0=pR>h%D@;T%l?O{E4NfnUc#c4Net_GbOWA zh#)Cyweo&4_hcGkx=-@Pscz@?BH^|xHkCLx?y-&a3*3=MNO+O(RwCgAMYMNbyq%1{U~=V3wL(&kq`W(la^})?E{ki(CUsVm>umL6ecF;|*v6bZ zxA;2Od&~D{dpFxyT(7g;TU8rY3ak{pv{F#Ik5adCml{YoOC$pdRa3U&sZb+x-2`_WnW~!$ypNd`ue0;5HJ2im-p; zgkvZgi4Dh);XtUN84pf142?f9lP!Jm=gSMk1V&E5p1gcU81f}>H$ygGoimfe~^Wy%J>yrLx^II>?jGt{C zxV9y0!?xCJZQ)~Jd$;60?BfG41Yiij5TF?#JA-w|4hX6>>JR}TAPr%1y|g!Y;pW$B z{?(0N(fq61FKGVNjd#Pp0(t}V2IvjYn^4f3mq&YD+JG>8Iz=6Tr2$K$dn^rB^=?-+ ztP)ryuu5Q+z$$@;;Y-7Sw*YSet^l5T{N(w5@~Zd8vJ(7HN4C(AWw1|oTJ(8?B~ko1D6Ykq$1XpIkqDTWcRkVO;UX9Y<7zRaUm{Uv?q}d&X3bEz>0(w z2`ds-B&;!E;l!r-1(O@ZjO8 z&%@)h-?abd)i*`%myqrc{6F}A@c-cdi4sxlg39;*?Cy$ZW!L%7^|lG%JHmG~mjB^9 z8Xpw;jtm!{a@1HF*2@)IE|H;DiEt;MHR~kWij2be~~( zn#+P?K1Ll3PZ*w-brY58%WEl7xvmM7>z*wvg}Lsu!s&w3WpqFcx*mhXJ()OwXzSy; z$MuEl3)dH}FI-=(b$vCkE|jVdbnE}oKD1k3we+F=`=t-<=kTH7L&JxL(+Q_jbxtSD zgWWL?;#$MChHDMi8m={5Yx7yFGXV}a9Bw$=aJb=cYrH>;0oH55!w-ia&c{!~y?Ed7 zzO}>q=CfCqzBz$!E;j7sduDd$)rqV+_~!7<;hT%MVW7%s!{Pf1eRG?0KnIL4qRS{42hLbDfg1%E1Fu-%P?7GO0pUClA^jcMoaA7a*RnyXI*w^%2j5V#vC8K@|jW5 z>+ob}6}^o4%B+*r+Dau985m z@-PawVq^I(N3nE5Zf%5oU!nId|H1KdapzHc%+-;ZxVPF<`>u^+cAXEdv%Be%IZvF2 zNvw+3ZgOw&bxD7=XLPX*7auM@Tzt6raPi^di}JvOflP?^YI6oEzu)8kxAyxzw$OgR zC;xZt_j~MF{C@cT@cZHS!|#XR51(Rpe2UB^b2&O2SDe;yzv6yvwdbuHwdr_^`Pm2u zE)Lw5J8;>@?Bn^_c<1{Vr#DV-oZdLSae8B6{5KKFoq&OUF$KG%GXkG%|^BL)M&=NLz9gyer}iwJxU_#E*z494eBQ&8KAAI7|o z)a$AU#LYnlA_oxUxvR&@0R#=e@HB7$hJ#^>%1{=oA!|U(Tf$Qrz`RJJK!&_gvY}ec zivjphBXMZRU|t;VF~~3q^HTO6;}#Wzl3`Ochvb&HfO-aL;@Y5M5Mygx?=fzv3iGl# zjz+dfjg^El+v_Yh%9!+I-OnuGo@x_Hi3hK?WG11M?Ib)mYA};w^x?cTf$$+b2_JLe z8G@-;s72usM-f%L_vl1iO~x^?WUG;Y44N;*^wMuBshs4o3P1)m0~tg@xx65uTwDpy zoa&!Y26OOJ;HTP%(n&Bu3F@aSZPm<&4WHDcwq)LX-q&@`lV{jsP|aA9XVBmIF)<(p zi&6bC0FwqL4NMxCv>M4+wIR58&}(dMwl-V476Rfx5&w>VL=*pxpVGv?o6l+D-_hTO z_y_S1;vdAniqTL2$FjgNa9QB8z-3J&hYnEBXiOY(kzD|40Mr1efs)V;NIAs?q@W{nx(Sl2gdrg}~x9a4}A_I5g zI4#u)#x!c#s}+thtrNzyFY~b{VuHa0gLvOw%bU0~EiMS$5xAp*MFra%6>OwKz4Ont z4)xCGX&vfgU!--Yx4#n|DmqkjsOV79p;CDo@o-i=-0PU2oJKi~avH^Ou40(dsgzEw zqjW0kto%Ad&5xQNH9u;8)ckFA3NUWUwpL%Z75@qT6Z|LmPuk!=VV$y0cd<@&Maown zMXsKZt`>Yv_?qxFQIRrAM6tUogu^U&MaqxVXk}b;GPcy&mM+sK=BX-dmV#VXJ5u_V zq-t5Z+ae3onm)5|oL9sbOUhw|SkKs+MJ@IB8JMw)DD~s+F_3 zA(``BXIrL|?Pvf4(J>47kG&RGh8733ngMrNZ_i9@V zk!Sz*-_V}@TmN2r_D_CAd-iXA4W4~G`*`;8?Bm(Tv)^lfdVrV55|1SwOFWi%Eb;Rx znSe}S$pjeBF`i>Q$9RtM90NB7?&=NPIjag^H@vqmKvSBdy;@8g|uDc^ZQr2E70tH6BSF-_hBa>_hfxYbwBI zi@6SSoyA;7CEW6raETc)qe`{>ML3_;l2b7NV*thgi~$$}aE?`zW_-c5`GT9X*l;N0 zP{v7$lQhps%GzLUbbW15c{;LX%KwRNnz*IMaY&?Xv({qdxTSGR$J?;pm8X{Za@7ZS zONXyK?P376l&qEBROu^C+jBF1){PA!TM4)Lp7pU>Do?LpQd~b+SDr3+k5QF4sXV=9 zCuZg8EjkgId#qO5{J^eE+WD6#$6$W1hDkfqiG#3cmc~guz9CNrz^_askhhq+x z8=(>AaN${ed*WSr+Q+P#m_cm@(R$l#??K!7u}=(|G{41T>6=m5wzv8g1K!uy93BG; zlD*Z&m?nF{mg_dX8wPm{@)+bX$k$kfH>`V_r$Y>=pBSJ$`#+;APv3r^D^K5@>dMo% z|060-Q+b-o(^Q_uT8_26t=4kJ?MuhNoR2xb79%OfZH(I(x9c-*v){V3->~6h!^eh? z4IdkRb1cw|1m}Nk&VT&&`0epj8<}ak)^d(BX!EostR$R26J>T z2NIil@zw{M3B2`q>*HAg)KbIzjI-) z)9pSIZKZd9W0M`A9%DLRw}_)@rQc#C_Il^;A(*bL^d;_5hRfAh4J-XNowUhHpYwi| zTj?`P$!M@Rq;2)ii|<+MWND>0?oo4WG_2h-Pm?PA5{i5b31vN@a~~za(w8du?sDdu?sDdu?sDdy_WT9Fe?7`FmZT!F9g-15-Svcuet_;xWY! z9^e+Q-!0xfMnU9%zxxU8e?R$W+W-EB|3LfSPY&?E0P6uC zJU)0>53?CDAO?$Bdt;zE7WaM%jzw&s->r2#u0?NoiX1@v{~N}{uOhH>?|l4@AHDmA0Hi$g&c1EK{M};Dd<+(-7{akd z933DW@nEOQF`c0xh)m1o27|2-K{zt^7?-FATN|Pm2Es8us2svkdMT|#IIJ^T)aw$$ zQFKX)ymF8QL3B5v6z@Q_GvcUKm{3YjpiT~ELfPC&=8A~wW4v+t1*0TX+z=&{;!EO$ z5{J#~^?A$U(tmkUg9RZ1LPRYi5F#K%K#0hV&<%u$a7KtkZ+vkz8BdH`M5d)=gLguC zUZo{D8`-g?MzRh-!C-GU*mnhPki3Tt1mV+`Kp%>qVY{xljWuaslWmNP>)L4JyR~u5 z;pfAabCP^e98=r9wl-UT#n&ZqvEAFGw)A@dcLDAK+y%G`a2Mb%Q66|OkO}c#_n0fZ zQMK%%h+%W%%QeI1_?I=q=H_QL!{+!aVA#O0fnfu~28KF>ER+b4bQ5k{B1`TPk$wZ0U6|ojTS|;=!)`F-8OR zx95iYp%bxD^S_}J;Z!-bppy=GCnE3QChtT+2(E-{1=_q$#JZuR>{1dNoF91) zUOiXEz$XM3!m$W%?qkH7r>1NOrlQ#VlKbG*bNghENhHr{NpW1~*w&WFpp$!(wqoga zGq1C~hpnxR@5Y$BW232OF0m6}C%{gCod5;7hn?W1y}=7q2W_=(lA?1hEIP-eF=?{m z8j^KVFR7r(sQ_0(5G^vb`>5U5=GuL%U)C?{mzu)6^D^Pq!L5T^2e%GxowmAl7Xo8;;|(2n>;sZE55ht_1Eho>6sB7&d?u04X#Xq`>Sj zI}mT?*JmVZQbE0`0EP_=8!GEkS(nPX^Dvclp#ebyf(DeO0l~0=VFSYkh7AlGAS5k> zkT7XX+VS;b+x!i~28InR4_F>uVtKHhSt zFT${aVH2SsGBAa8#)dHX5nJFRnuMINLK})hpl%WCK{2Tto5>FiV%TIt;Mkz}Eh~VF z<2}YLVjWqtxswzPLn2TV$6#})3$BP6HU*ayLv0EfHZHx^!>}nDNxcjk+ekW&kvne1mYWcH|f;phs3VUu?c$O(`Wl^`cz*es&Ie?Ax=7&ccIK(Q`> zV#yFJ8q2V;@dm2)^S7rz8xJAj92CdO=icNQ?T(c#22F2EVqZMJ$#w0SBgR}6FNrxn zI_gRcZ2LTEv*oK~A0^k>+S2#Juz_I%!v=;83>z3WQ66|OkO}b~6q)8zWWvr-#IU*Z z(f1DqKlT2>?N7gdc=MmWe{lCxe|Gf7zbXIkXWl=4%YQEa|M1P%>zgOu#2aJ@$dX6% zYf=HJpte*1uLWKUycT#Z@LJ%t%x7eR;F$nU37ir*CENPk!6bl5@W3Rnd{{oUEgzU1 zFgaitz%b||!+>?)?RBpi6|eqwWDOsVhQkoHaP`>JaSV$X%KrikBsRx~3cy$nK)!bJ zKeZ1;MIb=nm+vfJ7e)n)ig+6aTZ+LM6(8JH4#0#x+Y?h|4BgT{t<2G4MI+dlt{4=1X>I=6_Ow;wl}`JK_ZE77o79%fhtK=Eu7l(m zwir}1mgE`q*Nnd@_~`M`Y7+IPpT&CY{~yp+O<@}lPl4tc?t41zp4~c zi9TOT9j9e@3!4u`NpzgmBRV5PnR^o%l{5$oSt!d){N7$i9S7BKPd|9SxRk=BwG}R9v9Z{C zxY*FVqIvC`=9TyDcAZw{m-+2d{b{Yyzjua4AC10>0Ck7MmEM+>x2ZcEZ^K|qF?ikK zGy><0B-=^Kp}77F;)mGgcU-a^&Zq&kJu{X-_vZB z@h3G~W$$}5TV?!l*eX3|tKdWLfe#)3Uw{37cqQ8BmC)MRm%lHPtc6rg2CpCcy;89i zAOMKODyWV6uI+f%99dRyWce+0 z|2zkS*a!?FUrBoKl?0mHynrF)A056z_4g|jpqhF?HGK7J-&a2f5Pk#*ssb)OuF7W{ z;;X5&_%9zT2N3@sVFsci5R^EOtqQo6KN7cbNpqmpMA|BIpnaXJ+{V@ywBKWtlcXx( z>N7YF!wL*R$~#{EfFX5DPF27*DzO=Jw;f5<&o4(dojbBYRsLF~s`5y=y8jJYxw?Nx zD_0Lbpp~nWUq!jf(HK<$y{ZCos>P`mHK;5Vq&TVKq>6ee+ty3r?1{6duAF5p&y>Cm zB|c|LAFAU_3Fkot&Vw>EN$Yt^2M&y$H%@OYD)3r}?YtJrQAh`Plh3=H2H`I8 zad~iW^~W$iG35w{Bb-VPJlb-Aq`XPPAJgYD!+ZK%X814kxyF(y7;Mmzi!+A3ntPCjQ&Uc zw`lZ(`ft(rNdGMweS0_m7B$Bc+L6DC{?<1C9y<4zxjBxnw*5v3b#}7+ZE_v_x53vNP=0 ziL=iQ{&gZa6B#Aun>Q&jJMaZZcz(gzD^rs@{po1++zzNH->hV5R)uF{4ivq=b5S4n z^1{2|_M0~q+~#Yb&lf`O_0ul$g%zz~|j(6`HJlzX5=gO-juJ{iC6yW;&N*B+XHo;&-sdd z{QAR()5TzP0FbzOG2kmUU$I;975n%=Yq5_$p|#jIUe#LcqZiO(qs8v;$L*YN-ZVF& zNNIDXfkO}uL0WwX;_v6Chus#FZ9;r8d|>$Kw&A1kXL$^;$%ng~v~toK>ZFzD`c+eI zWBfG1=9OQ^BZfyzMIdMe=+hj&Vm%Ba`4ky}paB?uRs5>n1HsSNlBx(->xBl424S`0 z8JAeESlJULmP-5iO579Q5_RHTMq8}E;A(8fbMlKXh?AKg+)KrvI7%BvB4!S{n`uEO zDhBy4=7se{IdP>yiU%u?`vzb`CwhItNLDBf3&q#IfuFoby?%p zWnn?1JpHvMt&oWOiq;Uvd~=jrj07p)=xn^0&9Beeu`kgYqBTToIFXxnJ|hmDjRRUk zw1#L6uNU*#Y_=G{mti-o?FO`lXbsz_S!2xY)G4Ahj0*^(HAHKO)-X|JNqLl)I%?)! z{gK)M^SNDXSVFBADT;iAc4!U92ZdTg7ZK-1tzo8&CMn4f_BBe_*TCtZ#(ax3O{nm8 zq{2fihFGlCA}M4*2p`sj4;@2bq2oZu5lR!G&-AG=KcqSj)p=YfAyns~I*$UyY&=op z+>z=$qjReBG&lyFX>g{|_%jVvV}Ad4b#XBb)P);Q~KQQ?r-RGyC*)Z&+Sg;oZE43$GKhj zb31%TJMtY7SOOdRP@Xl&8gyBMh&K>#Al~RKNZu|hp>=*X8coK;fjG=%DUB(A1Na+Y z`5S=z4*4DOJIZiSgQW2?O$Im~4#-vHsxIa#G}vgco1}$iEbXk~!@MzXiy<-SV+^!^ ztW>A*tA-j#Gz9?zu!8P|3cyfJLC63Mm)kH{ z0Mrx&4S$>O{=$Z@Q%T z3b_uvLNFttG&7;Bl}s#N1%XRzMq>?MTJz{+c7GW*47_qlmtQ8 z*sBD$Z|^t#JtkCMZXn%*AyIu;QgI#(Bpm1-i-E|cPpJKHuY8FzVT5yFNCO7-oKh~3Ff*anKu|MKa6jBT$_4;&do#uDB|Q%vs5GUV zB4cITQ6j=^92-K4+e?Pce9Sw_J{ZX>l2^4_J0N)#iR9I^IOOTY*X82xn69^MXh2!p zKBX0^$ZjhF-RGB@V$>9)rdZG9W9UI<^J~dje}wP@;RnKxiiICJrJm4(pa($@f*vHd z&JB7H^dRU#(1V}{8H`@tU(9B+K5}u=7Wrd=7Ws>d-xzhWFnlG@A$l& z^TxW*8}a9E@6XlW!(Tp$z6OVM#|8IC-dQ(@;(eR#-C6(EiKl5kzq4Nb0I!PAzu)<{ z;ST<7sI=JQ-_yUn$Dh={z4yLH|Mnh#oZsF-etYLgSOYZkuYeu@E1(H9F8(D__rF9A zM)Kbx7)+0_{M?$VHtAkFz4)3K58b*X({mt4O>HT^pMmF(9u^EKGuJMwjvud8nQI=)hN_bcU6EBE7k1q}Wb@Tr(6 zwl3Q1=Epbn70LKoRrFb@0kEj2@?cSX@@m2-ucz`pdG+%{)9b4bP05Fsq^&!U6h7qi z_(RS>eIYqG)?Y~W|Dpaua`>eFLbCrId?DcrNr^8c0VI08ek5O{_#$O|k=m7xkbdW9 zBU#sjFMfRSbNk}gF_~rKUxzQO-TlJabRB5Z$G6m}{w!apgMXox1Beae(f?~QuDL-) zAf!-t_*T~dbmp6V-dFFvb)s3ep%sGJ%=;N<5?R4)InD4?9u5=>X0u^411k<@n=UE7 z;;kDM&&xOF9HR;67~!S6Vv}??qvc&|h}OJMCvSCCFZbzer#ojh)q)<1&$qfPxGsLT z$vJ0ZJw6-Tg&?`leMV=Eho8}BjfWrBXN||t=(EPd31^L*HFDOtr|PFKX7lT_@%#}B zqV5I3i7F?moT#cCG@lU%&p2>e%xN*F#n+4ZY&KgAuFo5Ph}^LOawg81c+=0O8Q<^~ zpBU(~{a3!8vwhC?gP-l6x}NRF8i=ap4_{ZkxB(eL1tfm49f?0Vwig+ZLCJ=!45830 zX(|YmJRrObL8N5}B}X4=C`2h(TQ&r96@zUayn624tP%sw1eyu8I>T!wgsWy^O{}3t zA89p&C-H3|HW1(P9=v+)-FPUgmN^2CYQ++WNREz(LDOP@Pz9mNXP5sy7|2-M>$B2? zcYn=X49ew?@&-jkuif&z{MmTeSbY)78XS9~NuL9vlsCseqM0|xPif}O&F3`p=IC$3ya91iH{t{tOp|3W%+8Kx2YnIxBJ@S* zi)0lSR8m?cWsd{YBIH9*i=Y;PY}%#Z4TTsAu{u;rJfrzavZl|`Qt38}bY3B=2c6bk zEor@25=u9eZZZNv3uq0$Kc;7e7zj8la9H55G|tQ$hb6K&tOR*xh$z>ErAry9LV^|7 zF5AEi`?A@6m*?0Y1G)=zmr^|=Agn+@sFI0K#kUJ_IbEpP*t(N18(^&V9`BAmQz~|Qg~^L*FQP+*0EfPCNBTknOJH3}ccMc@hl&mr9V$B1 z`Lr+-phHE6iVhVWDmqkjsITrXR1Mi7T0UaHI_T?OK!*x(F|II*4iz1$6uac;;_P}g zLt7`Mb6(21neb4~MI!+YDWzlEPoSPZC7beC1>>>sTLt$Yb)YOOI#fU9+!&DMcp^Mm zo)^M~2rVN?M2CtF6&))2K6I$)P)Dr*+5!&+9tu3PXIjiY>yNb#_4k)N)bCvKP(QZh zpl-dBT-K)ZI2j*0@G?4Zt(!f6PH84XFe77L}sBeW21y<2#-*kCV zB;-b*i$E8FE&|@qo{5A zdO`6UOKc;}vlLv`F0_r6)+l~YV`gt0l6Y?FQlq2zMe(Z?YK5#>A(f6BE-Ai3c=Ckr zi$@Y$?9&M_-T9$7@sztNpm5G3S zlKV}Cs=iIG>dWs)^e=|~r3n>L`TA)4`k3=mpXTv!;HKEwWvKe}FRrHZDY`Pgr3z^g z{2M$fq#wvrA?;TF6`y@{;M_u_&n;Bpr?N))4j>fX>t+3)b>OrIXZ+>GdpGg}(nn$$ z|8~F5$hz_ct?BR4`!9Uf>Jl`rneTQHkmwYcZ>v--=-8)XzOC|Ym2ayBDurEskE8EK zd={-_8u+%_U8iNJZ?$||jlXC{UzbBNjQO^DKhH$z_??t*tMfVER{6Hdx77ncd*wiT zb$plQ+iH*9xV!jb>fUGcZFTRpB`xiL(Yn>i&ni_+qui7c0KL_{!M`VLzxDau?EJeq`bBu-Z|PrzH{Pg!5#IV!`WNBxU*{KLMZXAlc|J$M4VEXFWD8;vGo;y@hcXXE*_2m6?>X8vEzDzPHcNq20e z%q4Sqe7(3{%#{`{AEqB*o$!S}`-Q&_r5(>0zCsL$K^v68`o!=2TZ5nYK}VHS5TABN z|F7ND!Y(*iRcjz5!eDqBH~?}0VFNHc4Z8w{!*AJr01#+*LZB^*1loyc;c6`ZJD4R6 zD@uoyAZU7V{w47ht9y(Fk?uCrNv0v&iO3zB@tpKy%ud9KS*)p?21CX~#lS6&=7U$y z6}HGajy~!{#UTF$SI^0RjPMMBih)-MW}jzSTx%idfZPQz$>oJ8G7MxCKb8ium|m8X z4Xt$&B`u#$#Kq-wp;qU%oruYJ;$^%Lo*{^m*0!UM1}{8AFcF?1mi|aGE4#bp3Od&vZ7NA;vN;K90*C)sXS-J}!4CG$D*XqTf zD)2t{9a_WwrpL5~{qz4oYuKOpMKtVa*c(DJ3n1p@k;u>6B|m4yvSL}W^J^(!e#BpH z{&Me4*bVFkQo$anpd+aOgJBPhNtp03;q}FY$69Btv)1K5Q|7Z;K>h4D_8a?+{RZlx z4~T=dWqXheAQ{4!kqO{U=aFC&8j4L|X|gm~n$5$RurJw{>`V4#qZmshrS2ppcv$rolr|IeGGog$*hkWwgi<^inZPFR7bldYC7)Nn zmb!IJIzYj%I-z71^~Tu|_cvZre1-5V1hA(QihY?o)q2=rQiE>C)mjbetDoWaB4Cg6DTbu2xNp zh4PD(UzC;YqW?Nj-mcZP`4~J^CgnzC8?q7D2yBG5mzrf4vy0iqo!rH$iuAL;rK?Ck z^P{?o^yfXRt4M#&2dE-VElLW|b)*0tC59<6Oi@*es!~*yqN+pwrY&Zq0#X5~pyR24 zvdffRrab3_vWb*Uq--LEddFmf5t)EYKqep)jK~D@{$>IS7E`d8g2mSh%J5N!Pu7d; zPVqYm88VktS0$~J)=BFFe&O*8Z?@=ODxktI6@IClO6624r&2kUs-aZOYuR?GTmymRP<7_p-awQ~_2d^qs zidk`W#En&kENghVgpGs}M|M>m*P)@o31xE# z^c6KH6w8cmOemRq)@GE1qUuy-qNEx&uY!1$lTh}i`3WVC(uU;ld|XxDIvLAOTTPms@Ja8z0X)|_uAT+^Cs-o zde?aO<~+$^tnp3j*)>+lbzNC6tc^`;!zT|V>xDS)GR{78hacph2_VwTa8-b0$Mqnf8pL^894*Sz+psIa7_EWmr=iT4X)jps2u&(xbGN;-n z)jp~ANo6!DqmAcOMx!!X$19^z54g$ofT>$Y-8$;lQLTy^H9f9Tb2g$%6IGhV?0~sh zuZ`MG6l{U)581zqWPi#RQND=sMU*dUL(LMZkWhsLay#U9$nE_kx3l-z`@VZ$)Bitq zEJ?3*pjKm+c^2vnY&SS;SFHg#LD{((NA1b~?CJ{W|Iq)VM3kQoTj*Le(?GgmTCj*mt+a*=__(rzOJi)grerpo8=omp&UtoGtA)0)sj*xw#L*d zG^OlBc(HD~LF9uOypXK)?TN?HQUdXLaS4Q+grX92_=Iw_Oz$$ggA&~pQn4#2Gv1^9 zh^=m{aaZh{>viskGPn38&rSAB(pHSB+ue1^SgL+s`a3!z1}X+1{6P3=rpsqP3)Lko z)rF@+8G|wgWem#LM#`AtUp?^?nt%0%pV$1Wr+-WHuO9yg@UP%s!M}ok1^){E6>y{` z!;zRBW@mIZCQg?QAWUlzCg5cAGvH*v$;Rx9S}}#d-+;dXe*^vo z{0&qbs5nq@pyJwqiX&I;C|AMYgTV)f3l5hrhl@4D8e$E>aa_!ZK@~C3T*Dvy!U!i8 zl4=LnFh%nU!xvO*0Ip$Bs2g0vC=tcW+115129YouVDf( zd|Qd<`jOfs7;BBx=FUf(N&wJYg6AxxwsM_7YMal61p~O^mhHu%jzMOYh7OS0L_%t_ zyx?-R6@(x{QhCKuNaUTvbM(5mjYMuHgL_zRBgoyf9r`i#7`YC-LNFttfPU-<`jLck zc=EOqe??^OSWGW3t`Zbj$p&zC;Ov@#vlCmd;i=}E3a^bIJ5fK#ymk|OO!$}pcQv>; z`qz!mZW2rU@zz_CgmN``n9VWra7WZY0!v^)$2Jcg3(XUnCnQEljF1?i5LQ7UB$tp&c9Bao-ST7q zSp<~}$u@*;nPR$yp-ieZkRaxw|98C&3Dd3K=c>1ko~Wmw1lTRUv8r33TSkef>afQB zxhyN_mU0TJTF+$yFRxubR&PU>$x7~tuCD5qKNF9n(q5BJ8;Nhbl2|;0i&xkH4-y`v zLM(!BbL2r5?WQVukeTPCxRd$VNdU77va67a9%xbF6lycc8n^5aFuQqKL{|vU5IjiGkp zs5X=5Zi2`Pk+mZU<+%++*0vD`C+As0plch{K!|zJ;@Uw<3$>?h&n0h=H+GRXT-TcG zb#<)MaASeM2Z0X)9|S%Ke1iuN_#p5>;Df-ICh#c=-828CrqF%PAJY`NH=k+>-P6}l z=%CO+p@TvPg$_1WbJ$pn1YjS)K7f6X_;JRMGk%=a^WzL4B|u7mlmICKQm(*ofC2#p zra^(M2i61YLH^4eU@gGfvk|`(w)&+2ZWi1uxLI(s;AWwsLPdp&+B_;M`;vXhzGPnx zuFpvYJ4ywRry);6i-i`u3tBAeigm@hf@luW{MCJ8Knw<>cE>=|-+$UGQAX@IkgM5%AP6u zv(mPbICWtEc}DX~V7QiMaJ|lF&TY@IwfQ`Qdw01eXAd7v*GmQ2+FrL@N89-^F(3wu z(fP4$&)C{nFRT}}Uc#>f+u!N(ZMHRTYfIj@_!+i)ZEZ<^#lH`1_uASv^+#DM%2H95 zin3IcrJ^iVlm{LRWJ0|67+I05NLFMckQK>_t@?|g>c}1chq{j3(J$#bayNca*O5E= zYU;>Qs)SM{lq#vGR7uC`gHd^f$}3b}q4G*EdFs&6p`k-VhlUOfy&P&4eklA<4i3k%BCiX~}h^7!t zA(}!og}$1?r7rMi&;>>j2q}xxqBK({R+%a+?x;xfrXmnB0I4c{fd-8Ddy8YWHw1eK_7LnfrI^KA9IN9XzT1`XtSVuW+xQY8 zTe0A+UNKtnSduZ<8>@O_(3f{ZK!<=10UZLm&;q(h0eAS7S^;v#Btw~nG7DuE%B(GvSty)PIH7QAzQSp#AbNA8p9!fS zfh>lCC^%W{IfOgvgu4*B)Bxd5G0|&(oh_DWu+zCVgSA#yM8ciIDA}=Kt6Mri=X!3# zLP0KrTn4#}mdiXhy}Y=ZK!_S0w1Jf4CP*nSvapFoY;DDY3Rh8y>viSdqj-GX?hg?N zA`nC%-4%f-y3oO~)>iEQL#?eid{S#G_P+yd1=h@)1Of;I znkW$9{GIc6&fo3l@A}O9>}NUijv^2e8o`=X&05siRG6rVk>4tVVqjpTWl z{p`6!ui?+0-ztF#t4dczZ_E)eN5IWE0)FcSxPVU7;OX}1;;v4ft2(je^pn$1PCqL; z{fvCvqv!Nl zM|p`_UUed_ zTorHG(MO$#B3?<(J;p7feArf_j|LwJU41$co*}pr1;`ec5G-z)6WFg~v!;vL7$0Iv zg(K?y@$O@Y8WA;CBx(%1mK42Z{~MO{mOEOfaPR@GQ<(fJIt6qJs4=|G`#Hhq1fLUp z%b7Z-+#F|fmcQ*;KIgyCO1g6XyQGVJ8|S7`1bR&u`OwfjHjK$0KHiv2ADl%322tm; z(Rqn^G-sa7<(&Y=2Y}DpPI=c4hB@AY=}`&O<9XG~rsF+M%s8d$&M6fqO`J6K@}x@#cJ)^X0CbFY_Mt_@tNq=TC5s&pEz|Ku96OFu4C& zMj&VahEK%02Xi2Z9}quOB7P7q;)fyyMR+5&5`g&VKMsUck*HJotvh+bUKj?=2$~VK zuCRp97s9lJZg($q^yQ&^*WpPwF8 zBe?msT1|H2SG1b!_6u4~cH`Zs$xxG_CPPiOn9Z-x#`8z)26ltjZa}?OHBE zLo2AYg8hpF>PXa)s3TEF&K85~^K#-tu5Pk4G{EejMn#Q^inAUSCvhgui{bDUt>!)d zY1F)^c~SEQSM#3Q)x2?QrF=ANr;(s~_m9aJO}{85}lrv`<&^M!RM&CS+#Mz$pZjuPEViC&I6%hgS0 z0y8_z&gg86O9_h>sen{KD!{FYRduh#KM=5GV#~yqi7gXb=6p&fAQO-Y$OL49`HW0J zCLj|uDiZ)t0GSq!>)kga79iHHIUPBvI>Lj z#W)j&3cxtE4TA(=czcZ+>&kQMiZNd<_ZS&Qu7lF|7`Nml6z5)33B~!IB%#P#))GqjPDm&f zB$V50zfk1DT1Wm#N^7hRfd!$_`vyl2AI4 zP;RgNxS&i#7K2|0KAvYB9T9`J$6)sG;dC(=9i-o*>20rD#%ycr%6r(}jkfdS@G(eA zh`6q`@M9)tr*mET`AFux{Jq59FZRSQWc-GU(JQa?xv&WDPI(|%AL+s1`uh4Fi-ECJY>_dDV_)9Tt}Xo#TON(y;s~w5tu7G&xYg&l)#nXxtG~t3u-|>BUU?Tp9cubI zo`Etwq%ys44p5Z%Qh|-`GSZtNH|vSqtbwl?Dd6tDM=Rh?KB5(HkAFrh;O=}91sn=E z6mWY=3bL5Zug}KwM=S^y1Pg-QKq_df-Who-@>b-n$Xi<|Z#^50nK$NbJ|%;2P}J*J+X)L9QT6H%%Aiq}=CWfgD3PyvXhAY=gK zE{NVeSO7+H06_yF-+$N%tKzoqxMewWFRP-e|BY1P#T~# zKxu%|Xe&yCaXXhhggSy}y(OM?C=>Iu5tIoi6Z086wzIq$$Q-!zK@@-}08!8}L;N;G)eolBN_Hbx&+<~B+_m#fvSo!FwNoc9K^c2uWY#lY(LHU?|GLPt8yPjJ0w zmiWl8B#1*&7*Z`ICQzHv#q56RdyHFT3<_SbXlTn9CZSZCP`23!avwZpEWcDIAPGPc zfFxL`5Hz4jI7kAJ1eG8Oo>IN&RXx4DxSEV7%LZKj-7N#|>PmIJu6p0)3y&24Ro`#iZfX|u(^^*Yky$93%;g0XI7xFRQ z9Wnq^1gHp55w@ZtL|*bIenNZ6-|+L=OaAn4X)pQX{{SyJUUIzTc**gS<0Z$F?d-`W zRD^1DHYRI!F>3-`0JwmCyR-IfAQeC==4WFd6+kLRW3n8$nigaNkRu>RK#nBFd`JzD z8Vphc3xWl~f|$={K#52NolgaDdEoNEPl2BjlApr*WBsxIwp;Ob)cD2Klp_a z$_1uWNS=%a8U+OEs5KBW02*i%FaUA@VFM5WjY0+hrc`*Q)N&h!TZ(c3A*o^tY^Jth zxB$ojgbje+h5-XGd|QbCfEKD0Ewm-)N%#S#B!0*GU;|iWy<+v;x>;qb2W!Tb@$pls{VUeur^@WlObwatzEc+cLl+1JT8~T}0%-K;fFk4DC#0h2VPP`M!+pKvi zm3C9PPQLEKBqfz>5LVn$iB3d}Xex1!af?WEp0?>kc!ppqY{aQDBN2JnD%Va#3U%dr zk8z9eTLsfalskMn5jo^8=hqPl?aTQd;}+qy5#&BlG4Kh&1sps$c$IMQNGOvl63Uf- zLV4SYptw1;!C2fq=dUYVG?;WpN7c+_ngbWFzff%nKQftL+gOt8s)@nw`b*v;d4{d6 z>F?2%@wvv{^}4Rah4-i`pC?FfvD&OxET?$q3B?$R zv4-Vrslf}AdTYDHykh-zggpcfYj_FQ71QNVil3AJm^u$%?8C*yP7 zGp^Uw`yTA+dKj3=dSQO6$`O8R7rrNaPxy53>AJ|L13d|PQs%Y?1DWFQu^3nk-B=6* z#oas^YVPKpKdHH!kA17=Zr=U`+)a2~t>kfm6a*;7%qK_P-d z1ceA-u2#U@uE3Un>j2jQt^-^LxDKirR5Pe%8>nWm#9)cR5`!fMLkf;o5kIMKIYRTI z?%jwmEg@+^@S-%VDB|RVf#s+*5E7WBVMPH0pom#P0}#QALIwa{6uhX>af%nUb0A1D z$RY~_NpTPyi8UGsVt_|fCz*!akpKkQoB9AjqQC+KsQ?I~C`(?XrkxRnayQR9Os6vm z#rci1X(~x5nIz=akPwD4d?dF92V`0C`yHq9F!%`Y5eir!ys!u0BdWni2s=K)9ZDg_ zldEVbSq6n$C*tzrYU0xg38fPW8o2#wO&YlSsU>OP z8At<=2I_XI0to;T03-lN0FVG60b|U%m~}g0*4>o}n11JHBcKN4$xh};EdE&hvG`;0 z$KsF0AB%sNE&jOkap(8VdCU7kBY;NGeHwvw*uVPQIP9Ycg!ETydv`#8wTeK<0N}94 zVLytuVX&om8gvWD4fsL54XY0T=GNI>Vs0&Vkfsczrb9L{x8`0_Y=ZVSw~8$vyn12W z@>5k*e(n`VF*Dai@uaU}ubim*xf|W`o6Od@^Ep2{0N zM%7LjRYl6E>Sl3Xb2#ICpD)C8EhuGa0O}7$*?WvzaK2w$is?fA0o*>0!qfbJDLl>p z@`uCrqmKqJJVP*fDvy8ACl`C)T&BD)?&Z-zJuz@~FS91=nU5~V*|yf~?~4;1Cpu1a zoai{waiT|g;K4v9#CvTqbB7&gRJH!#A8Rl9!JpS&^22|vz2paX@RH*t$4kDaO3*H5 z{j&E`e466Z6rb)u@oD^M_|fpA_05llHwAABUX6OZ8d@uV_6LzfHKbCSYIJDjze6p+ zy37JiP!4BwZ+WseyDrmJ=$HGL+J%0Z_d_{+|B+J7mX_M4Z}~!5TrCsXyap7MC@7U+ ztpf_mFDz5@rpZb&cf~-pH7`Ov*RF=B_T6|!NF@)-7L+Y0TNn$Dh=v z=X>9yPtV66=k&bir{|oZ_TUVa^R@n-uW`!KzEc+cwf^Pr<7@r*Ew~M>gf{k z=jhc_n()y$@57$`8*zpR;QG-x_c7idjo-E1$4)+6R`B8SUE4V|oBZ*N4?TGwdiV&_ zxsNcae%kSY{#X0aC-lGCZ@jAi)qeB>|7!2~zuGBgMlrJ<3jWJ2dp>RT;L{d`iYQbx zKkIWrH$Lw6_v0?*ODJE`lF2nR8Uxpr>GUvTD0zw+Fgk6+hDZF`Nohgy2B3^A%4ybKYL2L8xAQI`Os=i859q z+gMc$8eW7TSJ|)({o%?@h2)?D$$>W98LyNrupOYyAC6emKZtj=k%!s1VP`fC^rcWE$;9D@7bptXkkceK{<-~(D~IQdnyhG-4p z>w58Z5uzbPgHvna)FNF%x&-%iTkb2m7IZCL(QhpIq~C^S0?ovSG#hlcl7a4?^QUI%x>E=yGji09qWX1OwKX8a*o-z_gH$}zl3vJ&TUl$q}O`4=eDt# z+xC>;UiWvD4M2BKq2n{=mCZSG3LQ6a3SG)k9*1oe*eKE^9p%|h!aK^}Ax}0-Im$Uy z=TKc8hkGBYE0)2=L-nF7#Kq-wJK+AZS8vz$JEoTxS7ofi(KFzi1-RMD$V<(PuZb(u3UmIjsk|`Ha?s-2N+C4|3y&(Sx7|K@Wl+WHFmx zOCJ6sb_2Vi#@&E!2;C66A#_8d$rz;+C3!ZN4o_pbZK#1nl3ttEx302QACRKv z)w6sCvU90#M$@_8Ans>Gx#b!}Ielh=d5Rx{)VGw>Hx65MQr~@5_Q{a?MlECcUm9bz zzH-(UPA`i`88O*E5v$@+*=26dS30n%8I_{bZDOC)?b>-lNe$Mn%szU6u#yv*UtyZ&dBQs8ZeG z)7k-Z@Q;>6)URF=QNINT44Qv5|21m<+e$>mtAJMluR_nf3h2$zo1-^JZ(gJ~M;VMV z7*%KWs!pGnE(xQzQG}uhMG<<-6k?_{K!!d^aTGT0my)-r@Rn2{r5+q!LLy=bNk-n{*dWRw9kaa(FRG;!_y{km zrQ}hHs{)yji%Lk8qFQD{03B4u@YjW*Nci}7w8nw zDfpbJb8qz;2XqQCCkLGZI)!|lLiXr*ojzl`0-b`=YWPVt&?&6Lk5Y1hm7rylZpKZg zP$0fRr;t%4pi`iVsVc#`L8rjE^DfSv(KEDhT~)2maqkbE0y+hB3Z2m@-27V2Q@HUf z%Zj2eEGvq>8=e9@1>_6J7q*ozv=tA*xX;GJT`YU_Ea+L#v-C{QLN!pTfu^g0!eN2K z0*9qyK@c)CWM?1AlutYH--BWz}aiheLWmXZxs z)Zg@5N-6f9X^ z#Wfex@WnM>oH}6vHD=CMiO#1j!EMc6rRqQB{(C+ny=j3%1zX+Pb{{`uA?g z^O5Hx&+mjhpYoH9DnD7K%YZfAc-uqc2Z%2NUj`lmyMCUy5SKh}h0of+ml4}R@MYl3z?X3v z*qAaZ=gZh$i@oN{D6hq?&6i;)^3$4qF;&9bbW7CWF41oQ8r);_krSYczKpVqI`(Ch zU&Bj(OIv*zshuHXuz4w2uUJN{YF|cbY{t;!K=Y;YE=qafCZo=O3}_pH{E~Z8Pv<5 zUdAp82jD7bp{syN%T5|j3!D}>EpS@kw3tsjl?iZ9;GV!efqMe?MB~i~0S1U40zZV0 zwwrtL4D7-)(8hJO^f{bID*uogXX;OAk$r5t2v7w8p94OJ;64XghGsbQ79F?8#gGxF z#{y~XVsJMtWC{Q@z{OBju`g{K#x1xQrt{vq7*ab!#=uw?%HLych&Ru=;m13nMD;ZA zC{Q~>Ej?;S%oo&-XhQ9Xv9f9C$`Falw+>Zq8=sLViIZ5_+S>STjJc}H5Xssgrp=0} zZLiwe{N@d30nP%P1vm?E77QL#EBN;`P(*%#$v@M6fys|(zrg;_YrnwcPvRHAFVJ7V z08;2q2_ON7Yj8M1P=uhp6oR60L*v#YJ(1Q4oxKyCP!xet0C|qoMMRI)B9(wTp)ZL? zl8j+Fw7drIb=2HfJ8!{fYrX_e zu9gLGglCPMsgQP&{0ca$=j1;|#6!uyr1*;WUa1-@E(Lw86-fB1yZu+#P+wP@|6IPz z@?|#Amszc9=G*H05g$=o`4Ke-OwG4dzOC|YbuqX;4?sxdk>lZ@Z68r{CXH{ad|Tz) zYVrkgA_-yfS4IDik9=Fr*NZxRBt)4z9-GQsr?8DzqFVJ;ly9pP->#teHs4l{n*Xh_ zZ{KhEw#v8Fkl$8se@g3CZ@*dVR!^?9ZuQoGjc%1su3h@%n)4-==4|CaVnR$tXXC|e zetkCP=xMaqkUYq#5vN9tJ2e8vqhUP6fjG?NpUZC58tula#x;a%s3Qm7Ip^RscSqB} zTr!u(*Ne^;I7YLBW~XY+4*gNJma4V088Ijq1MQ=J^{?Th4zBf+1E@~9xQ+E%ML>?A zS_x&DfkfqHlPJ_b5IqI;KX(=P)37UGI1FClH{OP;kklO!H3b1zz-aim-iBrXpd3_6 zIml3EGYv|3o1y4BC~-6Q7=m9rBiFdZ`ry?Ss@`uQ5;s95*heA`W;vH^4ncH|Zd(OFsew|f zR7f3Y>Od=+j<~$=quP*ADo7}g+t^0%fMwb&YpjZ| zv%MQ_&8lzBd~|(2@WSDR3*?3KEWXU`jjDysbTAz8 zcHSPptev-qAH;c!^R^+5Gn}9}L0z1nI1_Ou;!K>#tumhxhZ^I6vkPYz&aTc{MLK4M z!uf*p1*Zy573I><&VyI}QY82dsXITd6d7Db@4nRg$CBq@s$(sC4=B7>5eO=~4(>eo zo)7^*16qLwG(6tfQBh8Y~@Zpqj}#q?h&vFWh~P zRydt}L@S&g|BO~R-T5R6ClpR7oc7c?IgoV-^Qi1l*^vs`UTgvlBpOJxe>H0Vy1}OB zEvOqo-3XM^lX00$fbJRHGrDJV&$Gqg`n+Q*Xg) z*re9#Ox7#Rgkrs9RXrxN^HqacY|46xkq$Fggik0#xt6x@5;NnV5#>ER|(>n2XxT+ZVgy2GWZ3Ov# z!ZQR^!;$kxLb<#MZiZTQXu7!I8_Xh3_8D_fU z-x{!jU{YmK!A z2W+8!lMUE3masctW2MNGXMIb2J=`?Tv zR0M(s0JfC^D<%6N#9oALC8r=n;FF!Fw_(7&cp5kW!@H|YxSEO1(7p_KjpFLKHzaT# z_j?RpmI>$Cq7xI&Q*QLpNW>QR7`H?`6r-gi0ZmBCkr9*?j>xfB;RIGI=v9=Objw&( z46K7K#x1E^^c88^m+L*Ls9lX!4AOBd-HC{YVsu-gR~dukeVjW!?9 zfmaA-VmTYzj6NExm^%+P8Eg6$olw#((Q0#VND_+sJ*%jxTg-&AMJHL>SEdu|R#_!K z5=sU5z`Ln6yo{`js~5BB5(3)g6apFqG{r7cNy=+CK|s48p-2)S35A5B63X3dLg^C~ zE*#ZoZ!sDyM(4*(APg~>Y^av#gN)Y6hdT15InK;))MrqPo)0CE1zdM2>ghW(8X-TmO!T<`?8c0wH0azJO zS`;EAK;TzQ2+64lx&nsbDJ|bse*ge*%=Q3q>`n2@SKyaV12|SB0(X!}7Qj)u6A9ob zeUEXATnARLLIwqr_n7(<%KFfRX9%P%+ZuwrH_mcr-npW?tdA{sXmAzgGVWexcx=$p z3BHIDxlbI0>P023Z95T@@#M;<6EVHKxbo>l#bDdfN6&?;&JIk*E?DE*h+h`wW z%tuFc#K30vuH(5r&tS|=TN~Jwu`6R&#;)vb%=7g1aXV(|EzHs{?G0YI^~2f?dh-vp z8}!bPXgBE1Z^R9X8x%JvZcyByxIwXIo{i_TN7%}+m0>GuzOBsPm_`qnh?|z}F-u~W z9KDPQ4ig+ExH2ZV9B&-WO?DLHT*SH9B$pFo!QIzKcVDP=zjPJ830Gkhf%+Xqv5jmp z##M-`5LaQm4TBk6pDoM*_4{?!eKg_yIv4;k0IE{A$~WR={qi&bKFUMNx4a1wK;Y}OF(uTK;Wz3~P zjK|{xHI$|G&gCBC7HN1ZeUEWV9}RD7p1=9q=j$yv;5O7Cqgb6R*yrbiq*$G*w5MEv zUU9?$*8~UL+Y23VnJLB+hb6Aql7xlGv=B9Wz1S>beQWFQdbZgIZEc(OG&VVGa@gds z$zhYjCRd$J&RXS9dFO8bg{61y*R^-<X? zL*UE9mxnJ8U)~5`9=^P``0{pD;1Itbe!pItd?+W@Fnll=(Z$=Tw}Q&34Z9*EP1u4aU5&HYof!I%q=-aBkw<#JP!c z6Xzz*%^f>8t#(e&M&$;5{2R3!^syh%ZqO(Hn0ABS{R!NlxIuA)dU?HY%lW$Hh#(P^ zCERc%<47hI>?jrBNXC(jBN<0Bj^sIxoGMA7vnpYj0ed)e0HLc@gUjwZp#som? zfYy<&EL|(S0a{1A4TI4-)D#5E0qU()1hznaRCkLUK+piF2vqkr+q}$IR~rD}3D8(n zeREaix3H#mQUcvhm=NhI>Gn&CuTUfsqttqFC)tAF;>4(%u3yKfy`H^C-jX~<#b6Ci z;@Y4e<4I$=8n4q>jM2LcttuLe@!(9@G7V~I%Ueh&B$VnCim@J3t1Zb&*(;2Ml3B72 z4Jx4|*Z_(6xJLXt+jKqEpETej~+TusJH?8|nY2+t5KqCeI?od}68 z@`|JI48cN7FUu9AU!QXo1D_CF2+t5qgx@MyOoe9%AnwRDKtho*@Y)D+A9Nt0+%gji zR3+#<(0MkIX=V=}P8Wkbg3aVSyM~gs;(M!(ee&FDs8qK8Y;9G)hiiYySZr~&wIy*$ z?oHZUpUZWd_6#vSKi*voY-_iCn{9viv{m)KP`IFQLE(bJ1%(R=R|_dzo)9l?e~siH zCx1nBd*jb*qTACS*F?8BeN+?Op89hT-5|O_bc5&yO$(Y9G_4)cv`Pgk+ZylB{pmH|u*olh8Rh*>JMgIoS|)A?!kbbf-TOD`Ew~7J}_U{h%7M7l;At zfW7eQeo+iGG5X_wKLWKXPG=}q=xH!c@sds&MIc}R#wku}7+kK3K*#_{Qfv&04Kxrv z1);Wp+yM}y<82sd1?VXVxB?(X%PEK^KiCRTQxJ3os0hTFFwj6w13h>3*i|N6wbUGu z;6F&-DgPXo{Cf;Z7?N=HB;jp?T6$$+d2@z`Q2SJK>ik6U0xF{3|&;m-6bGA)^gOeaQz@Y)FFY8@C4UPWzH z>tNNAE^T1kV;?ICg@mHe@^T%dS59UlWE$4jmWazP6oK&5g*Db!tjOkq^-Ho>q!;x& zqlA)f+1McenE3&Vg&(|pG8SI^b~$+A2QLR(t^Nvut)9cdm#F{_J_$wrL7=e4#TA7$ zt|+V_3(SQ7h4RkwKhh|=kOk%gR0pV@S73p)!P;p5+OUE36JUNZyubL~s`rO&EI!Y$ z^=E7Id4|uu$yjW0wzU<{o6lIdmt0p@u1nT^@p>-4&bB{%+LE#O?FEX6P(%cde9V!j zr~pL;htDbExXjv8oQ#JD}bH^$ws~L$!u#T@%&XUQYtnHLPn`*RZZ3eFFXM z4)hm7DTGp}n~(+}4R)V2s7ZOR-i!cMCAln>qHBVKX<|fIC^8SEyc8d*EF!3)rKk~I z!BzmIyx}O`hJjYVaCk$mrPT`PCMl*Aq+w5!V#?iPpdU7nVlr%tOoIa{CUcL`P$wzI zc*x{1FLA<`6r!h$UNhto9W@n$!mw@Xq|NB5WlTzwV%BFz zIM0_PBq=(_`ii}gZUNx}!UcrOLWKZ?3ka78XmumagaU;C3c=6jNBpXepUv*MyJy&} zqU9~1$(XC@uj=>RU4LzQUo5#;aQUF7L{=hD;lx0ar+7nh!{eLP)w zy6|-2>B7^6r>m`=F3ek)w=i#E-om_vd8;P#7B($xTG+I(X<^gCrqx257Je-JSopE< zW8uf@mLE%dug+h`dlj2LR=$gh{5;Mk5^em6E_c2sxw?W|R0Pt-lezNJAx$;eTFaSER&SiPMClh!JFScm6x@??`&_Ad)P~clbSG_gS}hGv-cGA8HCMs1 zTQji6I!RrDuL@rkzAAiGae?~|l*azj$<@VW{63dyk5uHEDr~?-g$rnR4ig_YPI7PJ z(6P1ITr6E~v&Eo2ZB1Hh-5v{$DI8Ncrf^K*n8Go&$uUL6D~ul)KiX>iVBBZpA>K7o z*cK@a(-fvDOjDSqFil~as>w7(#jCv*dId0eFloov3%p)DLgl(vR6vvO#EP&-R^&?Y$)0Z6dBFV-G`$ zbf0$*`GG6I)oZ@HLo=e-& zQPnX><}#U&;x^{|{5XkK`T4NLFX_(~`|{67`r}@|J(J8w@w~am8UGgkt^dEhvw4-} zIPdVx^qhO7bFZYMn?NKa83iIi77jS8tlX1?;Dx~dfF01rS{NG=6Nt$oS2{YD4G9Di zLa>&JvdK2vKo-GZgS9c(J7*mZAn3?{np3S z^!%Rp2fqcs1-}Kqb%@`3tXnE^yxMH{S4S`b@HFr=i>m;hhPlI1;PX-dyal`kyal`k zyal{95WI!s6^>UpUO_=YK|w(oQ&9e-3LXm{OUG^ygVBP~8ZV=T;}z^xYA^(Q1$%|# zl?`BGZBU~jtr|H91%?TgNXtMNlR8y9BoneF_E-u_Hbw1PAGI|+;+)bcp?PEVZH+hp zvx_K%P!z~WD|wO9AqwiE50pqN{UJDBl@hCD`ngL;tIG|JS9U}VToqiEgu=q(!MK=< zlbnk$hY=$8?0rlw+oO@$)!y;s+-> zyIes`=`a8-8$j?WY!z%3Y}Kib5 zO6#LQiNn$#(ww9chjmOIwT@SZz@;KHCb@wl0Izq4Y*)a$(9OTc=Noa`tHS=XUj>?8F z%da=YvvCjq$Dh8B;J4tnpt7K{pt4St%Cf{#alFFuYJ0!k1I_@>bPvujsu)$SM-}iE z@D}hE@D}hE@YX=^7LHdqUg3C!;}z%{;FZtdRZZ`}W5HvY@*Erow*|K~4sHv_tCaLe zp^uEaNP(Q80x|fLHm3l94Al^sfg*S^)P1wtM&OUb1i)U23^*MD^Tl5|WUnSeS}914 zId0*j<5kPY8Pf4epIA>xD}7`2G?OH)H1tyJYGy(}L0X+}dS6hKnNbZ?9Ip#g9vZ#aJ;fi!8WuT1?!z$ODsmg*>#2SMK{mJ?Y36-`i}{zEgW%n_O}3SjixN))&(;q??zzSbjZI!Y-%j&aNS*Af_Ou zAf_OuAf}>q;Io-n5I@plR5)JYc!lE?5DyTKYlw$2IP4e%ngW^vngW^vngW^{2%5t2 z3dbuPuXZ?IVfQ5foDpI}H8c@nwJ#y8Agmy#Ag3Uw#z9Wm%vBt(QpyMF3hF8b--g2M z(vg>NM)I~#Qqs*8|K^;!N~fW#1k7=~5)%0$Z{s6})rKDuj{}FbeNO9m)h3Vqaaalq zR6u`ijyW?7*OvE3bHADQ2mBWN7W@|cRt>VF<#-4_Wy+*e2FEKL zuW-CtEFT2JA|)U^DO79Hs#osgg9Q^;dpg-76#d< zK5i)CcqPaYol0L9%Snn{;om-HuKFLJn+?x43}Hf^$@AiTm>lTVI^V}1j#qkb7nl&3 z5CO$Bxc@Lh7$K)NLL$ej%imkgW}mEPFa70e{^--y?D0RoyLk2E)$GOpTm^5xC;knT z1C#@lV+fRknZ`_GrZLm*xBH1L1wd**YCvj0YCvj0Y6C%PdWSKh+p9&7vk){8G!Qfp zG!Qh@#3z2wU4i8 z0xmeh{>&QWgACq03)}OLWgrp{#=blljS+bNP+n^q1!9mW9)+y};o1@&wziQAZ!F(CkN#NBC*@ zX}J`zpak{>8D48zQ{NZ%ZFn@2gT;&-_?H8lN)OHFHdE3z=jK%>n(x7J`gK>AdtEHg zpN@jp#oG6W?!LNM-WJ_GbZyCF?XRc1$1pLxPVqX$xDiMnuhZFQ)FL>wS_B@}-;mz1 zR7dFDz;I%yD~|)j55y0|KRt+_m93|hjp_(gN1!?a)e(B+vftgJb6@DY0;|-*vp;Q5}Km2vFF5QP}hc{Xw+~|Eg6`9U+2_#a}6w z?Tn0$(fqVIO@wkvi__p~~~(HE&!N6@14896U+Q##_oWwknj0@o{nN?XTX z9YH)!r8+|80S&tNsOkvC+q79wN*yRKQm&4WMzT&G_RY^o#N-rz2OgS)&a z4S>5m)e&S}gYeF>)(F)R*1OqIe%-@Ry33t^B!4p@Ouhz?;VM zdkpbx+@t$(+B`P${>_gw9UHpi^6PcqmsCffIs(-ZsE$B&1gaxM>%eC-u^@irVcCEa zX_Bmt@bHgTv)L!B*~NdY=8ryI%^v;lyNg#p7XSaptKjYT#J_=z_JoX*SQ1N>0jdo2 zTx9?<9Wos<9Wos<{Q%k97yk`03^5Eb+%Z50mI;>GJ(gKBs-QKYHK7Bc15c3-jOn}U zRm7gTbf(3d82S$S&N+P-+O7ePttJJ0FfN!vafy_H+-kDOXjIUhf?uU_5o?$N@7lh+ zYuof_zjT&@;b@vxrL#1LXxwcJgpi=KOzxw(NHVC!1#HYHZ*K85yJ7rqo`s$0$I*|Y zA9uLSpsYq&jj|eLHOgv~)z>xBLBi-%2h*uC*cfc%8f+-5QC6d@Mp=!rTDU5>uo95B zMOlrq8f7)gYN0C5O0t{DIIFVa=_{)X!qvFyz7jxL{p?TMlz85H~g@KBlxEy8m?RqC?qg5V5%PQ`8BA~3k=ud??6=iF#Ce3rDi%sQu zd5-Sum6OxDoT98oS&gz9Wi`rbOJ((oKa6Wuzwqn0X7%Nd;+oYje3hEj+daBqbiwF? z9qNLafK0#>oPfyTk;5a0M-Go1-jN)>uL9;=cYC$yWK>fMo>K6Xf~OSxW;feI3@xEG z#zkw4l`n!afii(IfigKo%7k(6ZrsP-3E%q=?}R7=4iTpS!Uw{~IpGs^fh)={n9mpK zG!*~o1~-&l;4%U7x#u`gY?fgQuZRLtk~2@a1BFt(UkPYovbLO*a@ELzLcy<;#?}WS zDnZ+}`g0`6khLT{ud~?54k;d?wDqpUNomh%Zpm|SQqNs_z)3A_W|Qb*z{?<52w+gm zNITjJD-WzZBq|1QwiD!{AdO|RG7zB~ub$LXFUk2Rm0-@~-c9CrVv)0a5J1bFAT0+Z%CKkH`$uU* zu|lyzu|lzS!uvr^c12E-Nq;g4#R|m=#R|m=#X43Xl6H>}J%(b1Vuj{&NAr<;a({{h zF%)Zr%N6iBX9OD*D-&iVpXU?B_Ih$6+HwxlW1j5u^z#J%2V1>tjcX7iuC~H z*=U9rp-#$WTLkJUR>O;wC|1LBN(O;0Db{#qgawdSnJXr}BXdw>eVflw=IDcgC|1pl zGo!@<7O$k>a6mk89~A518yAlVx5o^>!t|=a;+xV|W`ChrV`vcDL;>C%4NfM=BNQtX zt3+_2SUaUybu^zYmQ#8;3D_Ki1T502d9KI{c`1|2-HZ+Q^HR5N=wjVGZ}U9T-B%aO z+hUqO-Eqxr$?suvobJA+F`N4~pR3EME>_F|6e|RpPXro3;8VH-s2Hf&0xFIu){Fm% zy;v{49ec4p`hM)i`ur_ktaz~kw=N}0v)lLwx&fe`Du4>&2;vCh2;vCh*r8D;P#jPk zP#jPkP@F$-8N?LC6x0sX&XlN~n8vyOLL`EBW#c4WoSsNSDdDD+{WUZdlVSiv-#2;Y zGd$(y&^XR%oHv@+fP$#IBuPK4XiknOh`No>$))h(lX8$HnD3PMly{*t4N@N`w;+S=MO{R;KNNG@;UG-iP5-lhZ%~h?v&fbU}{OVed z(zpYqacJk76r6>2u1U8yp`B|I=z?^;3fGqIQ*zC(`j^FwT1iY*#r7WpO@t|T5Yu2h=UOaBMw%?!P2(|#V@`!h(}LVJW9`x zu;)?yqWDGei{cl>FN)vU{@Ndf9xWMRA$mHEPP^FeeAIfQ_(k!H;upm)ir-9@8wqqf z(a56srT#vO-?1uwHEP1F^mD0(aY6BGd`?N$SW*18zerzYoXuO}iQ>0TrdjH*QtorH zb+?M&k|I`-U+WVq)vlS`ReMh9s_I2NuM4lVUK6M)eoKcaIne~FieKdrHEo4@=0eww z;`g8ilVas4eo_323<$aM(Td;x)TAgl>y(P8r7E@=2f5l+r$RR!yUpk3_b|Pl`{Q(N z?k}dxpG~Z*{Gs?o@r&XY#V?9q6uCr5ZDSoqvlyu2XHw=VneP!hwrD^&=v*K5OPDv!(iQ-qekMcNm6G6z8 zEo=PVm1_KITXIO$T~+)ltv__tH#D7IIENa))cBPPLwHaZeV}@6wBFcUB)qv@Th&Sq z&w?|nTFKP-HB|iSRC>AkPNx{OQEU2nE;+cqXrBY!*cdjZYqP6ZK4;pGab279>*;dZ z{vP@DOwY~l+uT0W>* zGLR{LOD$<}k&@!~@I@3I@qJ10+v3KV(zp}FuX1ChGvpC!>m%*4rQ-L#r1(`@ViM3P zeh+s>;fO@>TZqIG;3Rbs0*A;0WtB@Y3O7PkaJOXfy)Glx(4}5=%gW{0Z#6l-L|k2 zx@pK0#|fCfoPB;~2?(1@B?wQbNfc9P1ktBppVs(*nSELvBfne^T}(l3iKoRB+9cM` zVhT#-hf<(YEJBQfN~Baw;qYovx1bnb)raaVN@LpvI;_spX809W>T-3KBQ7$k>MZ)+ zDJ>|S*{IF>Ul7u9m4W#HvPmr~9obGr)0m*5S^eaBU)0{XzUZ^K z$8xuraSw99IOx7`&NOnWD~y!KbVZePv98y!A@0$2n|0UIJ%>**U7Ly7eJ0!VZ=U;n z4z`c^wboH!i~?g67^A=#1;&&DV~<%GSQ%IuSQ#uUvuAob0tZ+7BUVW2Y(ef0eE_XiY|JwiFH)K`E{(tJVehdAUzK&w`#jf>h+qcZ#wYhmM z;X#d$h&-SFQ?k~4l&m#BkJp;{UwF6HbOOG5v0&NbBR(y8=Zg+x(k|9b8?{%w=Dzd0(mhjo5l|xBgA;mD=B0+qVZa+@maKS zt7PW0XjA(^lj+*{EE?u_r-^(PE#8U07cG82{$8~Bm-u_p;+1KBFY4sWRn0{v@}0~6 zTbuq0ll%PaZEu1(qbO<)`M#l~%1_IfFO0X2br-4`48bqWE)Mvu+k zes3Y-Vn?{!bXv}s*Kq42_I|vaTL$x9%Du58R4bk(@A23XZj>!lbHG}ZV*O%|9pU=6 zaf`@~kR4$MJHjs{?+DM6cZ7eIC{usMJHpy`guL}jc7D9c)4j<%rJY!^!}_W7cW&== z`Oa+uZ`2+Z->6OCec8j}X6Ur`J}3VZXZ-H!XUR_G-{YN15GKE7{sTLe+MPR#a4#7=&rY^fU5o-S);pd<{o_x*0N*-am@o96wa z?)_uU=C&rxo|oT^(;f2!Hmdw?%$o@lF`2iq!zc;cy`*LTc*Oou z|K3#}Wv^+!*R0us)`Z!asykEDH?+KsP0av#8>0*=})t@qb5bpLK6- z*-{&CskJX&wZW)z@i${7#QbZq5@P<(iDvVikt-oaw}&0?p3vvLRiY{Uf%9LI&;5BX zcJ2@QdY;zw^!vxkQGNVG?^Ncj>x;8M|LuF&DC_!eKL614yQb-Jtm|q8P1f~Ezp0Pk zmG2Yf@_#4F_wlJ|{!=lk!!PJc&I zw!OmyCa}5wau~GeWXu09+BQM-{Pew8T0RR9103*-<5C8xG1G?k@03&Mv0RR9100000000000000000000 z0000Qf+`z_TpWQc24Fu^R6$fK0E93R2nvqG9FEIC3x*^B0X7081BM_3AO(aX2aHz? z2U|_LGc#((Nvm5K6(yaoFe*wKSvC2;CD1W;$OhM_L_|PB(XOm^Y1_?tT7#@}+89|o zluL!UH2GX6uyo7$6FR8BHV#iYmRXk;}{1n=K}Ri8T`@1Il={r^)HUjg!wQVTZ03qZHDWBAy$oLE z7cVWGb9e(%F7O|eZ2UY4+t}8b$PwPHg-|D?zb?1Ge z(Y>m3s!`9nPpokXXTck>JtVc+;A}o#Xpqc+_eV5Hcfn^7dpVoa>W)gu93UPeR0QBR z6_-?}>|TLi=%>dbqe(ZCOZ>FbFupW`&1C!%1pZkq@r5-&WB)&u9iIap#{rMe0iWZ5 z$8o^t2+keN%lV;E&AMrE&YhB>^1lLEcQhge8fWPLH`kXpA&?Qmt7{OK!_<1Z)ae|7h;3wX07w9#1hb+?x{ zll=Tmm@8fBbv<9#^D-ACNRS}?>q0Q_0c1=Jt`H02SOKlXhb*ju*06!>*h4~_AQ2Jd z#0hfY4tel|ym&*t{2`f8C|o2IEde5>Kxr}|N)D7KA1Xvcatf$i1yrRDYS0X|YJ)oT zKz;h40mIO!acI&MG(!!|*$HXvf%Z8J9d!aaQuHYMbXga${UxE7 zBs+~!{98#i!dhx*xx6h=jj=XImhi+O3`L(cYDgmtL0b`MLD3pJbW7xhNY);q2%fZq z;=$D0l8jKpyuls^NFr?^#;0~`NMrrlN{Q~2QdQWLXp!pnkbJFPIISoEEbw3o3#`Uk z*ufqS*a#7v;0jN8LxvDUAR4hqL<-VCAs>Y(#THbc8ntLZ3)<0x0SwErg?y#^nEufw+;_p zz4Kdd<*z0cmMMG}I1!X{kE7=ZXnZ~pAaQ!TSP*n(@WX7Q??z9J$&IHWCt* z(eynw4Ez2(?N76(HCNv^x{mS2|DVq;Kj7v6RsZ|%)6r5F0ls_@@Wo?5;O#6%n=nJ; zh)b@x=Dn?M1^yTKv%iyqOn~$O&{r`+Ar=~k9M2XjC~%cpsT^MgD>W=t6R2gO%36)q zXke|$I!gB1*lD#%mkl~>)M-5-6_FlJ`nVY2W{9gn?uL07VMza(r zkd8`rT!zy!ossJzgmq>BT>*I@7}@)RAH3z07wA7F=(8xmy)x@zxtz)>+p3jZyuDSm zxeraXl?aVE@XVp>PgYH6iEKBi5CmFkg;*46sjBK6WYZY|I;Uz818gOib$ff33SUWD zQt?^rPDXl-4^_k6GI7wPf;h9iZWh*Y=OPu&?)YN?!$j~!&cen0neyU%rp)n~4^!Yg!x*n){p+&e`QT~#|5E-Krv93$Pl-Qhs-ZTyS4YihP;G8Zh=5UA zB={MLyh~J#5q;OM9&7bfp>vH1$WO`r-Qp9$&G_=Ud-zc_B2fEqk6c6>+{;?-XBxZT zn0Cf&tp4m1LLVr@ji{(6aO^%%CHFxfSaAZtg?txI1*9=yxLke4J-N5-8HnZ{eH;1= zp_|E%x9#EfDf(S0HI^L0sx(T$V1a4%`vbQ4ab?ejPWZP7}fmJ9ZBV}5v+?1h`T z58jJ?%0&sS+c>~ zAs;>(epUzoRQ~TqDEA3#JtZVAG}31AUg%HK+D%~89pJnGTD}PkTD}Pg+J0UL4Nz>H zH@F`}$Z36!-D=TpOdtWpw0fY!zf?5q$>$OSh5L7Se0ssE*16Vj^5UC~|gM-%o^ z3mXvgEH?CNXHV{sQMx)AO_d=BI`fxS9afC(#ulSa{ck07yplYd(ca-~+zW5#2 z0|a22vHV0xRv2)tCI*~qa#U-5r17hX7VVR8n9%?K%z1iBu!CS2%wN%vf14A6(GGxZ zd*&6@VxQ$uwQOIiu$_h4eMcDJtHtt!DNN%#e1Yyzd_(|<)7j3 zEiAhu4Co#gBn;i%eT4tpsnc|q$TfX95X=KYg8;*{(R;z|f>t_EG~bXUzsvuQbU}^X zfeaBoYu{ZT)PouXN3Hegx;fo?+yp&_K7B!ZJk*3eJte8#Cbs<(1uN$Fpk9zPq4E{# za7z1V(zp*%A1^xq=v*__qE*a?@1>OCj8PBs!|_^_y&(DCIMN+GP}7@`%+grRCJ}#|V`an~S5KG-t5}Ntb0=M3A|0nmxwmPiC_~}KW1RpauA;E5S#xAij zCcoK<<`2!@!Tf&%87f4;VOJlu?;}1dLFVL=)m>0Cwh68xCdg@QA-A4Uwf2b%62v4! zV?Z0OHBj~*Pp5KGtrC)?Xva~W2?$=?tLB6DJUE8&;zFMV30HB_ZU93DS$D{fYT%Bb z5gU|8)em<9f_iS%(yOeruj2fTSBE}*PlSg`MT4ir+q5rVORL-^C|}DleCYgu=z0&_ zaF6H?fQW+{_rih@>~W0t+nr!^0Cs!IU#l7bAl63 z{6Bx=VA@2!Lm+kpS=MuV^WXUR(U*kds%l@_o4WxaX-Ek$TbZWpWO``tI+^TERC~hS z#*qL>$@?}WL^UQn4wX%!vxa@x&Y=2(-SpuEG{|GSs3Bk;xDPm$ z%O64*Ny)C-bq(2Do8OIitY}A-;8Yo57Gv2nv7c8bnALKhCYwvwz1LHU>w+r$c<^(@Mmv4Kk6&;VO2d{16P7k1p;dg-m&6Lxj9FWgN=6|Rf_Mvux z+hL!fO70UU)_Hv{oLS^cr^>DJxA*;R-e+%0w`?ZhXE!VhQM$F-1So$+#z)j8SMPV^Gz_lD+2S8q?+q1}Yboo}Bogh+D)R7DZkh)=_6WwDGuz6d6C!4g9E zRyAn=uTuHv0rqBDL&%n$07%OKJ5a;~rs1@Dd+M_(c^l+=an!_AeXfh5HNZponDqQR z>+Qdtz?xVM4t&?o30y?BrD~;*CwI<9K-AR_NXuRjVA9Jf<#sh6PsHHyERx{5;zbQ_ zSS0vH(CAoeNll1qQyi2so7N$l*|2en00JTmAMs)S(eolzDt?ys;JMdXX^v@j;+?`7~FA|HKMIi)SZAWf?l6^uIGhL`aX-0Q=Og#oEPWEMzuKo zXcJY_jhdX6BHsoxHFr&O7a%m%fX0Mfc0h>LcEVZ&4jr4H_~CZES|HRBn|;I|$*H!d z!WDCX>Bnc-Z1R)NX@+2! z@44Ee$}8J@`hjajm3?%l%CnHa@>E>_yB~CQ0rF>szB&%?BRrd+2DGQrg%7RXor{k5 z*8l5?AQLCE!Ono-&12cbm;1O}s4i@M0KRHtKl-gm?xD$NIurZq?FkHaAo+8RR|`Q0 zN4O&(R6x!@Tr_&6ej?qZ2e zL@;Z^n+WiV6FOId&qjBrr)2fdO85qZPdUw=E^z=a4;b?K3rxuKB)~BGNwf23%4fS? zN_L)AgT;f;$2*HiDBXShz1YY1W8as}_cZ@bx$J8PfCgDebijq2ExdTPBLY`w*#Yy_a$pm_!OcX}PuL*;T zG$F*6Fxx+O%&9c3PYCCAY9+gJ--L`o0C077Ied=HM{Pm;Aa|U+ z$!&b-q2TGeA;29vL&BeJwzSQ*^KEFTFB1;{eIJD{0|{5&o(yS5a>V>YYKrEcC&qe) zX8dw4PNMbN1Q`FMbN)j_R|JJXHVec!olOv^m z>P+`JS=9X(d^{eX?d^^AAq~Km6K)^*@KAft+4{cjZ4ArP;8Z7#Q`>8EUSr`g-iC*N zGpoM<7p4@8Y?Nrr%w>2dMyrl#XQV=Ny!d07qsai6gTOhRyK&O&HReA6Y9sO)h!qSU z8svjge8di2T^6e~sQjgnZ!rP@eS4s=i9L;>mPpzO)YM&`d1Ww(TB;?{c|rP8b+(Io z#{WH?lM;FNURCIZL@4{X` ztP4o(UkH^LEsj&x>2(K5oV{SlW4X zCf@0Y%s;0}!bS`Mcbx!Vr$G+Hee)mQ$d;&$!qfO6o(d7G_vAW8f;z9dafnNM+#V7`XmRBW{EulFK`YQRJdjf z7+&Sp)z;z3k9|Gi-i?EY0GjpC=oh|`IF#;;hsqUs87~MYz~clZtA=s65$-wXa)WrZ zA>njEinuP&1fnC4+B7N#O6f^voI38sL7ZN5ntxN_?nnCs&kcJpyR9sIsXxf$)i_89 zRnUY${`f!miN{`1y$1)mle2b6W+fh+D)dU4itg>0f8>)Y3v#wSQP2(}L9h3he`Fo8 z46&0kdmy}LfNqJ=9|}G9V0R7`zMLtYJePKZ!kKq?dExtQYwq4R&l1t^`8aLwdP6Bd z{)KgewGp}0a@ar6-Kw}~R`mzLlW30PD4@{D^_Fk{CpNkXW#1c_(PA&IX|wr=>h9{zsKQz|)s-lsuf z+=l6WGy_$>9Cx0 zS+!pJgF_FVNO%pM1$Z&UZD>Mc48#-@hbt~APzeQi5+VMIDq@tyj(>Xe;dwb!90~8F zmf?3gO|ZFyI3<_Zfq6}l<1L$l^clokr&bS>dF0Zewa|XELk)b}j`g|8|_NCJ2&B zARf7m%F*65O0JO6DW(&+f4krVs~~`GK5_$5t@pKuYHVa02Ty zs1?EhbE|eE^@smAdj$YDL4?MzMm;XBEKBlBKi)B91!~|=$hZ}!9`dMO1L&BT znX_>R^3M*kh2!lPVbWWvw>)cW<7tRRCv9BTn9b4C+k$JtHTMnEcA_wMk9K^SnOj1O zzk5%56M^wG*Du_7!4cyj<1@cUh<0Xxsx24XI_6Mh znDZoFdbmU%wE#a089dj) ze+J@sxkGVwAO9n?J#-tTZv$daP(2kM9FwRu_f6AF;UnxBXRl|s{?^I5t}sWZZPeA^ z-tY$<@h8PGB5_xx;3j$$b~L4)Ozbl3(2*PVSKth(o~(ow+IC=Y4Wma$!xdgB?BG-G zA!|eN{3SJq1X)ljSGmt~nW!XhX*17QO1#gLb8U_X@FhaQ#rP_Sn|Z=TINH4JjfKS#j_* zoeWSxvYtH?|6bjoXLzGSl=t&*NR6#iuZOyF_Fy#j8!_WoqKf7rJbMS%|87;Du4Vk$ zz6#<}Ou=M#GBhl=v@l5#W;;0@a600hS^T@xC~_rKs1x3W*@sX4F@HjW zO|`T9M{oOE@^;uCnco+SCru;n>FL1B-)Q;Q)1bYNQ`p}j(a^$kiXG~@f20v5j=aVB zWfZ(z2cpBh%p{w&b6->AGz5S&OBWe(02tPIx39<`o<6s~1b!@Soc!`9Tl`6AivIAS zYa6J9Bu;~3N^a)#Wd35pi!x{W{9jdSFK*p)aDPdWm#RbvH9>AR4$tcwzJ25Z$st-i zW*ZtX_b&zDPoA#5{f9I)UbB3q4n+`KXI%wPz26{!6LUQv~ zD>si(Qo0Z^q=BKpgra*K=df5&fl%|-g9|6z`FBL`ypK-nVU+iH;HXqnsp>z;;8 z6gm+1mp%*B$L^~myYw6eAHU$aK#NB4|F{bI_mkNB?v_5@y8*xP#S~!MkAWgp^|xl7 zrj~fisl}L1d?l9KM{4L>L)+>pm_9n5$f|e7AbhZm;P>^GhA4SSc;ltUHr?C}-Kz4? z&*R2-&b$epq6eLe?iyRZv^F#xt-v)RbG&|!IQ&Hg*V*=*>Q!r|SNKK0CI@DJV-C5E zTl>-iyu`TaktI~<4j{ZF@}OS+2}RxqC|o=+h;8lyDrC(J0|Rb?5qwgy!ef@Dr@U%S zy!Fg>>>##7Dat=`zG&3}J~-ziUaMpSv=Hb|$Kc&Z!ko)Ab1qT3?+kkoRRH#AU3joO z=Ms&3QEAN=n}+mdF}tm0E)&n8WuQKoIVh}_%Z~w3Fu}R!8>kNfb;qpRwk8g^k%$N0 zscla`53W(!?PRl0_ehjuJl=(~)VWrK(F>sxRm{3LSGQOf!;~65fgU!2(xs#6KC{++ za@Ktu^#C&$K*H$zo&egSPELAQYv(W`GV4*ZyL{9*ToehTA4C$|e#;wXx06J=F)Au8 zh8KqQ*G6)?Hjiy=UZoBRGD&UfNm(GwSHN!TS#xw}U6i#Go0vZd)q=$&+^?UdD`)IZ zVIfdr=!ovu&r*ksMKUt2D%+9rSH8HiO{=sDmA}*ffp7HsZc%S65^lfibDmSnfICS` zn{_a0*Q)BgrM)5$Pec~4+lTh(jwG0?De3mLTGg8_ow(Pgg{f! zN2Vnwikhx7d6FxMscA20>}l236sT>TUoTsU`1@=z>I8SmUAN8xgDZZUYE=r7dvCJk zD|MsZ`Mua%eV~e^yLi}&+v>>84U&Y?qU8uf8Ms(wWAoLMV^Ph|6cs@edzp04*qsS6v=ZQRvZ2 z*4;&0 zCD7R7D_h*t6{)S~LexVsLrX>+X4Rk+S<}d5SqsftW;f2#3{jVSFW{+eKGGBvGwYDZ zk_bG@=!lt7WJuJAx>n?Z$hvz+@G^4xvm?}A52b>&*GD32$*2&pu!s!QORXv8Nu_9M zFq-VN5{FiH1YH4N>-<jTaD(NSWo+3J`Ge@%!n6!-Kv98shu#?QiR!fbZ61?XOv}FL#4}7){Z? z9%`_xOaBkUExzyJ>37RWWT6v1PyHMtA({srNqUnWiLso3L8s^kGt{(n?0G5KbDa)) zFQ5=Z+Bs6Av9E~4bQ*Oyw;~$&w9?E{L|#(H&9IWeq^CtNA3Bn93aAemRe^#v*bpib zJ&J%V>Ep85J|ikV&;Qq250!{)0#h+I%0(lQ(Rf!zHDN7yqUCWSftCvpc^K5sgFtua z>+T)KgbC>3o%MmhKuQGc!v*kMENcg?_OHXh_p3vYx#lp5%s3{jH9IVuq>kkCW&If- zz$9Ru8Va=(>NFF|P@zNvg$m7@HE1H^!9%^0*C|tP&udgF6}LB05Yu+0ad5%MG^At7mc|GF4Czwx6g}uZlvc6o+%(tU~>ucP`|VXT4md>8-So6Dw7(mWNgf z%3Nj5Z6zdi&wZmahKI%0XGr~B*~J`B!axFrl#AIu96SD-1bL_WAM z8jj{HSmqilg50vIzm2oT+T61)ajf%*4O@17Wq#~A5GKuf8}i9Un{3WEq744g!-;c# zapB4>|F{$LAmK@xEMC0%@GU5QEyQ0y!IAZC0tE>cQdB~P2^Ud}|G_n)icK_xxWtGR zCtgAc{;~xrNpguvk=px7lP*K1EL6$KE+si~<@L24$ycDT^b{#BE3^{wsFe1Ne;uPJ zJ7vmMl#@zTs@14fS6=EhXr!zlO`5e-nAW~an|2*KBhnSm2?J5-)}vRSeggX%|n zm~j(*?#rnlB%9SBbm6K?MbwhwzC+ZzzB6?}RA9BVwlRB7bU%|)rkjB(;=Ti_xThJI zusmm$8nUfy79Hb$p@4>Vs-qTRl}!b7-_Fw3Bu0 zV3_k|HekFrp_DQ#-g+Cx-fX{gj(BMsT4uD5)CQiN_cmGwpKew7$6ZZ2bbtTY5M0+B zy``QICVg`i9#Mru+2IM7kVEGn9IXxFI4*u@7VBXC_g$3VFvu~jT$(}p;i#95{ef(Y zoAWH+UE&_{<4baqczsp#7cZ%ou{S~fRngKgXE|STrClZUZs2NG^Yx<1#!EX-KDYh! zuH(8I_7|*2zxvom_XlTx8FodJe$Ss3uhPkd@4K#&x^8C`%FLf4a@Dso+din0Lf=)y^EF!)m8>wA arPJpYG}@RLQo5-|4$L1H{`MaV0000DqokMs literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/GeistPixel-latin.woff2 b/apps/elektrine/priv/static/fonts/GeistPixel-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b9411de1e411564b816426e9c5355568bcce060f GIT binary patch literal 16736 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 HrB(v~c z45bDP46hOx7_4S6Fo+k-*%fF5lweBoc6VW5yxS$b1ju7A@$_|Nf62+k&cMzh`S2!C zNXFB}F(l&f+k=K6D-JFA>YvNYzW~T&cyOIT=l}yF$G`a?SE!b_MwFx^mZVxG7o`Fz z1|tI_Q(Xf~T|NP}f6|Eo|m z45bDP46hOx7_4S6Fo+k-*%fF5lweBoc6VW5yxS$b1ju7A@$_|Nf62+k&cMzh`S2!C zh{w~#F@)oKa)QJiAa4N!W1?!UJCLPX;u=wsl30>zm0Xkxq!^40j7)V6EOiYHLJZ8U z3=FIc%(X$J!7`QqRVW&A^HVa@DsgMLUg>@es6hj6LrG?CYH>+oZUJsR4LNC&Ks^ke Lu6{1-oD!M<8O%p& literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/images/favicon.ico b/apps/elektrine/priv/static/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..556bb1d06e8689016ae4b9d1f5ed2c9d0434697c GIT binary patch literal 4286 zcmeI$p%K6!5Jb^W2T^Eb%1F6%2}?8@!Te+P#NwEzfRfW1YttX7z?-DvOIH6+?lv>h g8Q(wy8qk0SG@t$*{nY4w!G%}) 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%0DTtl41$Pbkny$ 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 Kcd3{5+5ZF3Dq~~- literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/images/home/buttons/button-03.gif b/apps/elektrine/priv/static/images/home/buttons/button-03.gif new file mode 100644 index 0000000000000000000000000000000000000000..d4becab0306cd978644b4e39ab98867f2c064ccd GIT binary patch literal 427 zcmV;c0aX4+Nk%v~VORhk0HOc@000010RaL60`Kqd{r&y^{{H{}{{R30A^!_bMO0Hm zK~P09E-(WD0000X`2-0H00#gp00000SO6aY00aMMy4?OS%1I+IBkRq(|6nMN-$kBi zs*2_Syl^bfbZy^w4r?v2{=nb>lj(7M9wbP|Y|1)B7f~st77bS3rNZqpUYn@#ik&I4 zQmaozMKIoktDw!&zN5X@Fq&uDSEkDlh|G;;(dha1&G*%_TkM39g2zqX zgDeK^C~VMX8>?{3JsGo;mDyvZyIHYbg0sKOq=rbS;IvPsYz2}-TJ{I$FE=; VY~xCnMU=8=JD6O{wk?hX06Pvt!Jz;E literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/images/home/buttons/button-04.gif b/apps/elektrine/priv/static/images/home/buttons/button-04.gif new file mode 100644 index 0000000000000000000000000000000000000000..77a7f1c28e8abd949dc7a5f07351a243cc672062 GIT binary patch literal 3500 zcmV;d4O8+*Nk%w1VORhk0Qdg@B3ai>kmpHd9(|zhbeSTn!}Mc>B8;o?gsbzLw(G08 z(Kc?Q|NsBY+Vkn^=tW;2qrCHhrSXfg^g4v*beryUnH+7B9c`NOAy?BQT-z^m<~B+p za+M>y*Y=vU@-0WVA!3CdYmFmOh}Rlu&n-e4TYDlP zblsJ*@=|aie4-hXuF`Ck?z_$LZjvB0OCC{aPQ}vk>+tw!kJ%|b7kZxYv&Hgwog!3p z9g(f^(c1G`dmd$PZ1wc|W{Diz+t`?{>2aL#N?k}|oKtCy9?RL-_VxC0mL9gr-+iU> z$<_07hKG){=2DI}J4{g7;q`EgtvGO`Tz?wk?fi9~@q3^mSaD`+m+>NO)cE-LjH&L} z;p+%q%rHhCyUOiXQelR%^lXwBd7<#Ez3pmQdM`sHp1Sg+uC=wy^rpG)g_+XG+3$m; z?yke{cAOVcZ72Tu_Oik5m&NSV;Pj!j?a8>4c%<_A_4e@c_22CHzR~NizT0JpA=1&f zdY8Urh!)!8`*@umeWLJ2RGR1Q_P*cLWqXksXRUCW@YLYwsJ-&X+VkA#_51huX^s^r zY|1`QGT`U(lBm9WsdL3(auwfTZ*|Pac4%^YQfexW@5MY#xrR`j)c#%hvCm zz4%*+@Hc(h#Mke##NTLyDT<6nRu{`~#>`}=~b@R1M(imc?y)cIF;AXjuId!zH? z>GwZYIsgCvA^!_bMO0HmK~P09E-(WD0000X`2+3za zj$jC^$j4qjb&T_VY3mz*63V8LVJbMQ$V+W!36mF$3r zPCpPlg-ZFLWeEqV+7q_*;X{ZLwd4vLc5KFv6d;&$0|}A~U@~@y`e7}P8!&7V>@8WM zfhbWt6rD8Lr=%hhkvh16z=p>R2*7rPgOejbj&Rtqw9^uFUE_k2^1at5}%%OKcwgUy+d?X8(ofH5> zKXJe^qz4|frpIFtVA_d1A`_!raj^TV=t|>=1V88y6!5AjgI`f!vGQZ=)r_PZ26-EKS1zo zrZY55G|k_i9|0AR>9Kp0YmA~U#fz|W%0EUX7;13(HQ1IYjJn+r3L(Et>4WC=zmY~&4q zEP1Y+cDazJ4FCvvcjAJxm#ot97alX`V=j1a@=G2|UcGa~>|g(0kXl1VD5 zWa5cA9zXyE&QK7Oz}#R&GdMCh;7|n=>HtxK<15R-yy7^gjYi&xh%ziFd#pk(biI`Ahs z0p_g)mjFWEE|E7y+yEAeAjclU6)saS;17VGPpl0R*{R*%YYD~@QgMnAP~=k1rDIcygTGg4{y+e9+of;KQMs_9AiKj$niju zeJ>8b_yshekvC^d!-=nW1q?gpn_5#u?jzA4RVrb1vBiq3kf!03tNDJ zO4GK59vs4}hd4yuXpuJ}!1A1b%$oon@POxi;CX4QK@G-0SGwZNs6=%@+k?FEGi{GmoG(K-OCR{)1&U+qTVYv)V90f@4+Q{Sg{s$h z#A6cYC2&k#uSoJjc%mi z&|~V5CKkPDO8B?SM3@B@B+cbQl-ImQv_Oh2aASblw+9t)b634O>iI6VzBjI8jtyXd zSM1pb`p}1EgAfFLTwwuMxb*^Xz3X1{8rQkb^{;!aYhnBP*vJm{u7j;>Vk>*t#0Ivn zb*+JFTl?DB&bGF<&FyY```g{dfVjs^?sA*^+~`iXy4THabqn|1@Q$~<=S}Z;XW-uW z&bPkz&F_Bu``-W$_`d@J03rDV1rz}P04xmnF=4gdfM{{SVpkRZX#zk>)9Dm;h? z4Vr}z6H+*GL}7#ozz`vNf`}hKU4kk0}wxi7#+fd34cgza2dr2E*w%|jLANB%mt(dDifvwV?>Xh8N~io>DR9* zG9Y<`59@=_f)XVVgZOwM;=u>eA%fMACap#dOQ=`3|F#k%)GFo)8@!nTKyL>2(D;LAS6RA-$DrJ10eG%Kv2gAOaKVM%HWkzq&{dE|3Y7(gg; z$q1a>aKjz{h^Plb&Nws8fBLZC3V^=+vk!FwSa;oZCVXI8FdK}~;6g|U5ydlY90bI0 zhE$QqaF>+GNnr>5A|*uAyfV)}^I#DKF1~a*Aax?t_<@Z$DyWc;4?eiTKS2(Oi4lP$ z^^Y~y1a}YzctYeEXa)!}jRCJzIS-68zEFpO2%2VsLOk-gL5~eS2oYC+ zElvDGglGR4fPw*}l6ugWA{29wm}AN*rlb~S8J14OghO2em1q2XK!9N5v z(S&mrpdc>|D0nugKKkH-3qsHqBS5Rz#mX(M>(T+kxC?R60|Eb_Km)^g(jf73C@A6C a3CJdEzzJ9&uIyyZ)Jw84@KtMo6MMXwNMo36V zN=iyhOiWHrPEk=&Qc_Y=Q&Ut_R8>_~R#sM5S65hASXo(FU0q#XUS3~cUtnNhVq#)r zV`F4wWMyS#W@ct-X=!R|YHVz5ZEbCCZfSYo}QndpP-Ll?si~=| zs;aB2tE{Z7uCA`HudlGMu(7eRva+(Yw6wLgwYIjlx3{;sxw*Q!y1To(yu7@W5u{{R30000000000000000A^s6Va%Ew3Wn>_C zX>@2HM@dak03rDV0SW+r04x9i003A39{>Oc{(yRdgM@{Khlq)ai;Rf?jgXO&lZyb4 zdU=_en|XGepP-?lqok#qmV%^cK3SfouduPBsFc! zC1byq0Kw4CrJue#XGexTtkHl=b5Vo3XdBxUe1)UVL+PzAKqqhf;du}Ly06MsU#74*hZ4;W9E$QzbJd-B-?aOD6B_=oz z#=z+SAVL!&6rRP%)22ZfO=~=?a{(uqXN1*^0LWs)(hLdfG~gK{Kmr;~SJnae5a+XP zt<^*TNFrMsjyyRs^x(jy&Kw@SVeGxloE9Ai1!DAm#)XWHICsu8fY4xih0e^IUy73e zK*#AD=om>eBE*~_+75(Jkwb|WH{rt%xOC^j^k+wSVTOMvjIqHp26Vwwehd;&%@5OM zV?Y^X_?Lnf6=)*_gB%)0S!C;cfC2y=sBs_+RCMcwz=G~Qp z6UsajK^iV{Xp|jaJW)j(c|39Pi~vAPkw5`BEg(jaLdFP63p6r;2Oe~QVFCaTTt+2> zMsY$%c>ojvhZ|e;pa1|Kyf!A8R&o+Wgg<08C!H;EqJwM#$Uw%P9hSLC9Z0OuK?pS< hk;a0A=4WW6l(O{SrI==_>86}^>glJTh6+F+06St`&|d%m literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/images/home/buttons/button-06.gif b/apps/elektrine/priv/static/images/home/buttons/button-06.gif new file mode 100644 index 0000000000000000000000000000000000000000..a742b7a80866427c1d430dc8598ffc61613c8c92 GIT binary patch literal 21009 zcma&NX;e~u;I6&*W*!7Y%^5+#5f#ZfG{7OJ6wT614NXnW3e8Gw0-R?wD>Ew`)3Tzn zva$l_%*x728?n)5n}??-U!MPa-f!oe^)5cLSbHxPYhT>=ef<^%`n!81L<45PHvk|Q zK+ZT*eRGg(0}|N?Ll$V_29Os3+5rffAHt1?G!CUY1X$UIL(Fi3(K0>bXc#vPa!;T$ zQY|f4yD>6!Y*Iq_DVPOG-u77_v(O-5$2?cDFRRemPweU`j^gY{WmhA)ho}oWJW~$P zg$Kg?+660`JtA7x#vha|848W-ixXe+5caQMGay(uv>^1RN4_#VPgR;V*pxn6UNpF& zd~A*At|WIdbLZ`(%6qG-?w#B_RnquC-hS`&(Wz^+Ib5h;n>~f`I@4(EeIP zByUK|OHQJ$Uq?;N&K7O(baYJ4Tb-8TuxkBU$E^Q(6QKQq18`stU}`=B{P%4D$T@DM zr4OBggn>evRP#W67-+-FNjwoTHuUy>=m{Nj-a1*L@v@3R^R=YvTWL#1*N*)3hlP13 zIn(%lGz;4zLp_}`J-tPN!N6h&hO{Ch0+x_hGZ`Ewj)%u`CpuZ5oFrVUk4BS-BptHC z(29mZB9k$w^Otw{l$7f2Ft;FG86}}Y10ok=QsHonKEaTlL343xZeh4kXc_BPP>64T^LLn=^Q3++Y%VO?8-6J94bCJdiOitDG)VlvX@>tAVC@*`U^^$D`E4% zbv)5_&$=4VoB4?OR{LGVuO;FaF1HRX3U3}L!bU}>zT-Xl@p0HQzB?e3c9)5sFBePj zobD*TZIVzRb+jHI=VPpWe}V;O0xF1nxRGVDkfeA%hqHt|tl{~u$r-=8kdn<+CcAu9 z_m{Ko-}75Ge~jA*cTW^N1KA^_e0WryOr=Mj`OK@f{@RgLz?RSQaDoN_AJsPoCzTk> zGLhngP-|?^T`JHO%Q9Uy(Db*sWh%pOy( zs=?A_bR?B6S7RwGHGrDfE0m%Pm_}_F3H50$4;2!A1@k&VKfwXR6ZaA1Wo7LRoQYSk z(~jrM`tWJr$5%s)k=a&Mpn+me3H5GLtV3_SlPpzKkpJmOp9`UhO$>LjcsOUddT)%| zxZvpNg{|j&wmCGM%g1lJE^a5z&e*_^n3+H&{g?MRPn%OXuEMxK1d?04Fkk?B{%bp) ze>^uZKi|H7tY3Av{g?Uaue6Mp+pPBaRXz8&nBXagAZys^vHNHzXxAV6qpDV4k){yqj6UV-{V|X z41X;UgLD}HA}=EL)M%L{m(Rb?jl#7&xK#K_>2EsMKof{{3c^HV)4&6>m39BIluKw{#gnd9w^A$mN#WLwC%^iW{=E1 zuM$b=h+7evRF1A7O~LT;>h_Dr!!4#Xo;G$cRSZjVtG~ZdvL(ak!D)vd=7vmb+E($k(7TCf zZ4xiJL=4o0E|YRog)n1o0o-Jk$Uv(BM#r@Uz=uuMQ%_D4_WB#UKQ~JfA!7Nf&;}*#Rm*ht zqMl9+d$Xlzf;E>4ie)eUv}dZfgd3jIZwUjBBFR7iIj%Td<9LYp19)u*$G0T zmDY$-G3#-0!a7`3IsK6Rn4}tIWvxZ8^uK!K&UjG?y2w*5^@D$pfUqRSa_Wgargtq@ zT+4oOe;i}-J-!dk%YA>Yc0{F%MSKVYmOc{p%yypYeDBnOw^;h@ z&4z(8BcK$h3EcGE(C_ZVb5MKAyvjj&73WKD1Ow6mDsQx)qpo6vZa#VDhajU9gRpNI za{Nmx+@_?Z{jMveGjah8z8g;#V&{c70d1DMI;GAHL0VIxh9|z%U#^kxI%=59gFxDo zgAJsbxJhoiVO)wT7@qOB&RHQS>fT~#8|C?)+(k4ldi$mdJ;*8L?kolt2U$ZR-rZ{ z4JG+{M~{$j%EKg#23l7zPR5A;u%(~sWaZ@4;IW57TSGT?T3$rId(X9@(TTQYOYRxe z5_rjx4%GJ_sP%^pO-+Bmuhi}LkO_Ud;V6O2tjvFBrBUmx*xOCd;N=@!Z#7;$Q(D*M zh5SrsapiPKON^x{iqn@OqKbUG3{G(I7IX+{Ah<17O6Cv-oD6DIAIztia3ehSu+Ki1 zwJs9{S5+2^kv0+hDw+`JJIEz1J44qshuJN(hzud+&qiMd{oU&Nvn#bqNs|h}i>A`6 z@t4*Fg~-%z010mT*jf==F21{~*ftei?sx|x7?RTrocOR6aCp^g=Ja5Zeu!(IS9eKa za5W_^_VT8(Tsg=Hs1|!&i`$ zft+EKb#DoTw*Ff3tjdhOnlCj)PaAqSX(@QipPo>!KoVqi07U8%#*{2f+WR7RCcL|! zXq^O~Xk|OuXi4l!o1KUEY@4NFTRE z5|nO3P?HHug87S|Rc2b$%ReYAD`xMUyEAB~xwpiK{2k6fjt|c3>)VgHFB$(dTt@wn zA>0Ko_Z7o+@c)>g1{co)s4GVmnOPPk@~>tVNwbGgJp-Z`Ex%0lBdo*hIQv?4k^7gE ztp1dIoI|7#73Dv*?{m+;1>+J--cym!sC{-<_pQdIs{s%H%!I5+myFiaS7U>5UoKk= z4f(hbXFxI;#}R)n-}vL%;h_yz1#YX;N@YLR>$mdy<~`fn=(8kW|597YzhysTo_2n6 z8(IMS<2whU-2Wb0jXHWp(5JO`{q=LNR!WvV^V`0!OLystBiEC^6cvvoZ%!J!geuOb zKYajOr99F1&^dM8k4F?3B$29w9HSb$)$*1pF^VHc}u=Vp* z@U6ArZ1Q3E%@zk9Sc>2fR|&iP`+@(%A8f_NubKg^9wd6s3jj}BjGUoQ>QhLe!Crz#ms?j;4r{}Api_Z|z9cE>u)NSe4_+7Tp$MFGV$r0li==|zPCB4J=%;9 z8;_m1`!-3$Uq)`FSb@IIg|j&GNHzRyUbFX)TuE%vD2TzB+nR`b@9kP7d@*5^XZgbD z-X~?+cX^?CcsPGcGo0C3FLwOA9KQX!(;lQiJimPc@G>7lB3-Pf(WN1)ue{uNrVR|Q(wWfA0K2GnMH z%_3WCu4I`rp>rB~eVwfc!&u8Z{Pe_OU)|eU8~ih8LWn+b7T1m59j3oXWCW)F=o`u0 z1wFigg60WyWS*8FS$zm@AYKMICn%t!&2Z&-;pJ4RjUoX{(2auW%-WijnU92-N9kP7 za#3`dWI$3)M*F{pFANEd#Dpa@=niBs;wy*We@dJRS=A_ep5q!(;g`Oi+P37yKoETd z*+2bQVdhNJq%U7RJq^LiiWV&m355t0SY)Wvnw7C09Pymwfj7*3^l3i2uY{Y zkiIPkxp~ue!+gV{V_k>4y5Ngt{ScWBN7RxCjAmCCC(b@s4~9!NbLuulhr)1jaoNA0 z>11WBpOLzokGI3N?-gm%JJk^Jo(z779K57&$J|vpXsIyzt zd`!!sW6779n-;_6B;7xJu8PKcXYbNOvylxe$Kk~9=NbgG*^4myS}UssiJv||0jVCO zVMZ*znZnO@cgn=AG3&aRop7zwuMAJ7i*~Xs3Q>0%PlFXpbtP+c?FDtpCr@ETb8|L( zVncL#zg!Ox$kbftRD}S7Rw=_Flj*POeB#1b^nslC#uIb>1PXwtLz#IiVzkIx0e?3a zp$vs$%c=bp&Z7W?HWPD~nL8?4ji))5OS>5hH#5 z_p>mpeybG^yP%cYg1^V02D($e4gpx35e*dF9;2^waH zS+-b6%pMZ&d;q(0+6QOi96giTqOLqULooQw%Et@iCw=x3MrL?gP4{b5Fm#Bp4}G|- z(D2BWCiOGY(NrN3^gTcdU1{HYZcdy?d&uptyBzTv);j%-n_R#4y5L3OxW9HjoX}M$ zhH3ST@=z9$;v3!;1!CCQ$F6_UQKSi=Io@lWN85wb`FgWz zF-|nzS7F*82_ba1%1&k7`#g3kM9)gZM`NN}oMJZ>j(xopx+1qNy_ut2WTx+^L7~~K zl^lb5re0T(TrHQP*&A6P48h#$@>ol_p}b2oUYj*wO{pzF$p7zwKmmS*2!#9 zZYGoyPc2vux3Q+qvvXRQmzNe-5>`)^=T2N|>1sMTUPr18!o(W$5#$-Ihblw$Lr*RC zn8}4Hi{4f#W=Afo?5OU?5}oO9_c}Zhdew>1|SuyCMMt|YC{>QpOgD~^`KJ- zbNJL3ZLeB|S!L<^a2%d;+trz4#$7`kKC)~or~}eN^U><&itV0a8D7lSH=YIhNOCw3 ztA?LBToP}9U{d}NTq54^w4$k?NjwQOWbuf@ld8Ahn1ydKlL!YnPLZM9P89Y56GBs) zzB$>}_-HlkB9$>)-$yy42=lU~>gXxu`KFwN8+v#;!RAdLNn#!rH2S?Ji<_|IKPTb;yN z3#TfZ#42!zy=is12___n0OMp#*}0Jb$UtF0FZ!;<+;p-irsQ?-sxm0gz&apr1a@#8 z*42;G9`+@G3pbaGId#)5zsAIF_n&KRp(YSf2Ixf*I+UWr?YPcP&<$}4X!wvohf_^E zgHWy){fZ(JmjcX-`ZHCTW3)SzN-kvr|g-Y}S1L}I!r1f zlZeK2Efi|g=F{6YnM-{2O3QSPA0ZA9;WZ;QWO_Kkn22MfrPO=0X;8L-`S9*RlJPAk zr}n!WIx3K;Ri|^{#fQ=8?yuct1Gvv{;K~S@spTM}*6{ce7@8W;L+WqvFn&1lV!UZp zRaHcqk*D*nqRob1E_pU@We=p`X2m>E=W!+&~OZZ&0%ynjqc%n z`KZdhW6>=mb%}T~bEvZXyc04E7LFI21j<1w&szH!r@Jp1vDbO6rxCTuw}`YMeL#`8 zYNjEJuwi!m!k&*d^C)z8VXhePS>hTH!i(Upf#CG~!F`K}Dk;K#bQ9O+c>8@Y=Fn6J z@BZk-A?BDU=Gx$phGTSRu~_N@5yWiT;h`UV^cmAXZ=y-G|qu4D6|QYzmdOpx2$PzoEG%!AwZi z{J5C+q!>eM$TTY2Pz_)(b=+$h%DA~dn&ImcL-fv`nWN>JeCOWOn%`|Dfm7bGd?5B$ zlFy1>#8MbCCV>jWvLB+MDjTud0__IR6{37XSP@>E24vv8_#Cc@pg2-f39iWg*}p4# zQ^4bSn!>OTsZXNi6X$nN`ecN*2GDx zAr3s&m5K~mxYY^e{*c8ST9a=F^_V*9Y`&Gf=WN#FEyE8#=cJS*K3PTecnG3&1*D%M z(>gyD#PdFo*`p3(&V2x&wIb+I>)MlpS97-zL{Ii?ulK{WEbt5iDFJ_8!M*f8Y(<@T z;{MOLZI{R=g=S(QJKnoT=kd$%^3{yXH$Kc3ZhOPoZC10RU6G`erS6yA#_uRH) zJSW8>M`?ivEY43O)<^8T|Lf$0ThO@^>Hfuw~BmicG)rr;6mT^D{ zR@Ykqbj1a|zt^|!xs`b5aL?JmD_!Mox~&{b+0bdHEc6bvZ#YLR!A*XKf-zDYv#scbqe!w`3LzZyc!G8S@u zptP?JoVm}kPux12?A;N_x^oE&UaHFW3`CH0s7UjolRAQ*8whD~0bF+$4R$cV7rKU? zyV4Y1>(Z(lf0_{4vmvPzfKkw)G*g;aXDwF47o1CWTgk05cz26>xdIO3$h01Db?eS* z#ilMOw>cQ+(q;O!UKtdfh4$&?QwD&YLKBgMMEw*M%u%5r5|DZQCcu1wvznM{dPM)S z(ov6yMGx=d6eJ0r<#FFwlkmGt7E#Cs9m{OFSLh8Y#L55(qH&bSuolBFmCx)R?QfVB zH6wA>A=B$(aIgxvS%UEzwe5zz@Q#fDO1Or+Qdo3uq7*hiFpX!bggTFDIHu+=$rk5d zZcwiJ$c-?K*i(%lS!|SCNHc|u;s3`>HWrD_Zv~XP=-PZK?PkxUD|4Uy>LXK$T@emv z&c%{T>v!wH4hB$ev}WI*2t{{ax;frrY+-95v8DO=>y?)3ROkc*g#e)tDiDFdP&f=1 zjt576U}YRVgUR&hbY&W%kD*BwB($ON(XANdk{%RFlZ9=~ee}qr;gMnDRU&NXjh*CO z7-I^9vo_|SJhtUfM&~LD$(XS=z1hl%Zb)8|dI5t%z)_|~dL$hB;=dQih~H@jRLp() zeEk4x=iACCJ)%?JeyD$_sH)uRq^R>|igC656Z%L??Nz`FLKdK}uo9ktQA8?B$6awn z$2!!tvBTnqF>sPhZ1?_--N3oWaTlVvsqamounq+~N{ z7&=G!nPG!Ox{ur~TxCM^rJkIbSe5kt^bIXk4jMS z=TvyMi6;1b$c*44DU)h&3t>cUUczpbL1^b&J$;py+2f!#YT`DIL&y*UR!4GXm2eFF zCmC)%smw3X`AdC4)LAy{Q?xMW#{rn8NrbQtch#yqO7=1-OiOuBTF&ks@53rH1!e@> zE+M~afrAKuJHD8{iXqEa`(RM@0x9RBvkcVY_(_7}wzLei!aVzh$b(#wGW0&X^cHQ~civ61Y@D!THbPWUPlY%(?gITPD z7za+;$162&9&{HrZO!b%>V0*Jp!`R$?PMm4hkEoFz3v+JBeM@}L}jT!lN6cX2GpkA zWw%1~kF4Ld%=?!%F6ZaXRl1kY-Twfka$csO)p>v3P*>yFe1suMHib0qdNB1ORLahH zw3xVJ`oxxlYLt-|(p_@XZTI*sh?pJxv7sC`e}wvj$(rNxJ>!mDSQI>eTi40pB_-E7 zA{eqo5IKAH&b`eqt#@{QwT=INJ-=XF1-~93KlUMO&EtQr%v%@Qb<%5qb=d3=Nvu3$ zSHiV}3yHNVwA(nGIfn(k4Hg$Az|D-Mj zjV9hL^^7RBSJt)$paFA}*C8)w{JSWkV?jY*R68e^a=zY7B&kUL=w;NBQybNhMQ^HM zbP>2BK<)LV1P(YTcWdXZ-^y{PZIVyq8!5O(_P%vqsc4_!L4kplK>;Ro93<8^&3m6y z4elnDf->>^)?9KO)urL%u31lO4sYRCrQY_`IVwpa=F!a0oGLx1UcAEh8lq%&KiXFg z@RRr{o;5>n>M}~J%5*QyA{n?TplpM0zn!!E6nA!v-HVRbPFvJ1e4Nl5(@9TUzMS%$O)}8$5yGv|JVWf>QAk#U31o3?d}l7! zZgZ$qbfx@ER~qb?NeDx`7g(1n4AAta=B})ErzhNV?A8bnnJQi0+Ihsp{bbT$`t!mA zbxgY}LuP}|hL>G}&h2_fw5;VF@{Ci4j>f6?gE=pC8sz|4)V$krO@mSLUQ@f*C-uE+ zpMth(1AqJmZFZWHeWoSU@XYP~Ramq*M8^;Vi}pEqa0zW=1;W#%nYxYozBmxAKAkL3 zchu$2UwUS&{SpsQi?tX8HHQ6QV+M-+) zd=GaF4*s7Iwd4v3^S=;P=-Yo&Ho!j7Xn*MRU?St=%d9~9$;Xawb?;|bwk=`!r&Gs@ zdb?F_<&m})Vn=fgWlDT?%KsOkkbzq$E_*mDIE^cv;%UrgW~_&kl_B~tny5qik5782 zlx=z2P&BEnInD6cvE%*3K_aa7N-Y@$!_ch~oLu4>IdRLG2ijaIBub_e)AgRK6;s2f zJk03`O>>CQ@g)(_s9(QPLqyzRn7PePUw^37g~E21LDT$FqZb2Hq;G-6gBwtI7vW=IuQ2 zJF;KLcZrUhf#Qxlt~Z9ZRco&zLazW@{^w}JYkKJvopQ^Rqq5w@j-M#~MTYp7o%2_;V^vTsb>+3}3ib|t{YXq(Hs}@zC zzZ{GB5hOzc$s;OXe1=(Xjpx&#j;U z6E27_h1<|OA3dG5-)C7Ovp-Cn4c`T)D8#0|F5`-R!u)ZO53cNKdD|}LlXAymSNKw& z#N3`!N|@PJA&BCA9(#haRIB^;aZ`>!c=86t2=qBa;b7$=$6D=jIMRFalHHb$WTMVF z8EJNv=D!Nny04Ti$Tsj=-dG=e52$+-N`v&mYUo{LU4-EmxfZ8|6R6adyQU0jR@ z-CJhc`?&{9zaC~Q_S!p6&67;qSQUJCdh9V1clwU$hokFfrjKso2b?1*Z^&?8@R?`r zCW3vQxBYdR`-OgL=?}f!w<9Bpp#1!Prl@(xvEy6i4oNugk|++HNlfA%?)-L!`wzNM zxAtg{;m8IM^Z47km^js!qivs}egOUfk#yxB@Ig$M6p1zyx_-EmZ`}8Hinteq&j&R+ z32^jUn}2pC`#^F0yz>SVsEvo+l@MQT@lKTvnU3u4+WN)v>CYQlr({3?7Qlc7A917; zNXYOGY&toRvtrer1$;6Yo+Cu~Z+YMVB^+LNy3?0X!i75s3$Q(wkupGu2vwZx-Rm#m zh^C73W>QYj4pKww1jA~)0v>=DZbr5ywuI(if)~wM zWpfP1^sXt&wx)`+JSVuzu;Eftj@mtio`}4q10oElr6Fp3>dWS{3(^-?El;1i%U=yk zd6!>(ghc-iH0fEOBVas_R>K}FFBQYW`8JK%!Q*iL#qbSM!;mG?c zEL@(n!^T6P%_dd8CpAGy)+Khh@gYn17UyU47dxI_=1EH1SvH`lJgHv-f4sO--yV{( zk|IXyE5L%;?0$q1CU^}&%n&z4Fm^0IZFBdtc-S@$`TfMA4Sf*PF94(c;S+!kQSM(w z&1Z5aN28EbU|sewLebgxCHcK`4@dY~gE_A^El^kV09%_@QRJ$|^*Y^ee{=DAhr`~Hy+hX0`EH;==HD$1V~Mx-|{N%4?dndpem{*#JuR z^x!iOId%?n`eg_T4B3D>D7+PU$eUXJVTBSR&*Ff2YCuc=+Q48o$f`~Oz&h^e^aknQ zp$?kg+M}#BYqvEzI-tZ7pbqzW#bTm83w~e{l+d z(yF&&M90T+m`qQmhZEgNzGB58WAsrpsS0gKHb!mF!L*1P7}d zt0N%DRurq1aWNioscX^>{U;P^aJ511%4wC;X(agB`YkB9e;Eml`uS^wh{GKX-r3+W zrB>>elNjt+YyTqy9ue_9ZMQUr2F`emU?XqOC*1MaA7OI8gw;VqUK%+r;^ ziZSG$Gy_)PI{-qpw|Sg(v+r|vjz8B}{Tib}$QQ>Y6ook;Nn#X| z5i;CYtV8DlDaYmFYlRDaZQ6)NSu;KueRnmGv7i-bjQQ*#fC?$PGGCBVPvtEniv&`P z9VrS%VC>cGAErqnuG6teemUq@lqtrTS--AgI!C59)m8kJ)Bayn{g$vas2Wk^Z@%}U zPq`z=VU-v|(};^46i3EGiym`hb6eBJTokQaO4N1LTkeA-4qQQ{{KqSzxAXm(_PCL| z{e;24YM<@bepB;N1p2H5uZcG|*yu?^X@TtBluFB8cTxI*OHA2ZHHesSj?-cUD2%ap zTv;C!*Noo2BkbEAmC-&w-jmUb%Ms+mktPD^LJUW#LW%AN@)rI}q6MkJQb1 zALB9}+pb3UXhuz-qe{n0!9`ZQ&B;H)Ju8Rs-LHD)oS=5WhRtan-4Ao?3^sl6F^Z|Z zbQB<_a_u^Gm>%G><7ee&Si@@Jzu!Dv1&Rx48*kJQF6JL#@7j9d9aA;9>K2wvED)9a zb2{<6_?z3;m;26MDp6m>I&{fj0SIb@=+;SXhk&EuPjT@xEa!ZKk;U}$BFs^LL9KSG z99^=UhfXYpK`a!c6=reuZmaKwrK_}}? zD&=`4f-udd(tUD_A6N^BU!biiiMwtr=ltA%s%~Rbm>Q&$-bMdoom#8i!$lMdz_23O zfv>St*i@%@Hy$8acCYcga=%39s+I94!@2V04bN4W50b&yZpiS%5h3CraY1iro5#9^i=t^OCq|U&SIjOMyh&NV}5ykZWOvk-dy<2N04Lw@(H~ zZ8j~cYFj=J|BW76^RNX%r}~=u|>8v zJ|XENd+I-hL!Tvp7PUpY?W;9pQSk}L^<-nqx@P?58v%iMpj;p5|M0TD$w+;5_i101 z*X{zqY^oW6M~ky4q8?1g2JoL$KZ{>71yWbLKwI4z7N@NTk5!u=kUe|32_;z55ln%* z>jg|E1K8R8!^K7 zU!0R`+M1Yq%=>pd{)byuRuAqt-T#kUNX&iJGz?@$Aeywl#`oHOUtby$7!;I2 zD(?2L%y>>YJYAb#yux&?Uya5KB0|KJ8wbQO^8FW(@1IZCFB%!Z<~g{FZo7;=G9pgBRUf5H3W2l>d^( z^oGCt3-B*h{KI~}XGYo7S1=4CDXSJB@yb{q7X#mNO*5m3R$9rgG(BMAk12r$S`yfY z?z40Gc+`lj5Pr<>mCEgmMx-ob-ims%&Avk+#n7_lD){JYiBcC4AOetR{~wwP5S{Zs zGNBJq_pkc-PVeZFCL$x2bO6qNr>Ovc42pTsdyhyy2%~BqTsW2zhS7DEIY??&; zlYnJMj&>0f=;Mv>B_qpHptGGZ&)|RQy=%|59Ain5rXeD_J!~B7AAj*C)#q4ek>$r` zT}t`>UcK)kM7(cAQvskM+`hvbBDlPTxc*-0Qgf12+!GM10C5e2L#j~Aqye8^Gu(oN zi{Wge7aK7LgnY!gadRsBuTTu4Obyx(W6HI5uQ^7j7Jf(TWD8WFFT2$+J4J6_>aCz^ zldQUVyU(=fPA=<{U=D*ENy3rqJ@2*7-IS+0F5B-fhIhNJ{+gO~d-3Oy-4Q+6R8PtpI()O|X zCgN7JcyH!+U0VBg*_~(}dEv0g^hCXB<${q1UyNZ}rv028KvF76I#rRR03HnA7Uw*D z@R>jID`n*tptMnN9dWC?cI@(8_$}R^eZyragBv8KljOcFNvhYajw|y4_q%dcI^(Ml zrd(;3QKGYPWAYBOx2o@KR9DC@rI}jiNsZAY4nF!ZW9EWSj=2aV%abUriI9%GrY3Z& z!agLzi0EH(mfX+*NAU;`HZdeKF(7@2bC3z3-UPY|uA3AaowVazD7!Jns>KaA+J z+W2&bnVorIj!2sMo>v->$E8J|#C_G9y z^_TTUXURR`wIAGX**PnNMOsZzKTu?>miOWemejR6w?Wwf7|2~2)BUFlT;4P#(P>T`U1s|*uTBb{!QdoJn}Cr6lLLS^EEmR3!9PFEu)ao1AolbY}W zSur2oxrlkQSm7+YQR%CMsME-Bqxp4jd&LmM4L2Qqo_V0AoteLs9aUBoEgto)I-u5t zr>emL*dc8(rW94ehIYNn0(@<(!^@E3y)}knSP;*{&--)KpVp`o0+u(_RUJD*OLa=} z2cxNCv#N7qo0<*c)g3#f+6tS{T|6<4yVH5?x%e=fv!;e7OSv^$i*-`IPoP*Ew~ZzY z+7g7LkW>HRx7lK-L+u9|sDsFd`}P|W(P&(G znZ~{#qmj8=P+>V34XJDkHGnfG4h-TT2(dLNI0p_R7;`v@t_|(-v^C9Xu}+$VWn~rf zz%^Op5$29w-L@HH>St))>)>F)w-~}5h5*RYY0X`^Z-vepjxc~L2BbL3NXE^+zM?@B zPxgV1nKs>OhZoml$G?2gmcoqqcnmFZpoCQBT$1xmesG}}e8l^teCmtT*cWWkPu8JS z-{3Xpv#*SF-|k?p?(VSh`FyjMZd07PU;ah~*V45N=;*`(*8{sd45%k~v6X3FPm9WU zZm5T8oILfvsXvw+I?+Q;DhY34l_N(#Ff#?RZ+#1Aiz_(R-ONmI8TZj=9(Z%h$fa3CQ8G#2jPTJ z-Mj2PQ9klAIi`u{_!cXIvW1$CMd zkPtx4Cw?7V3M;>$w+zx9%@+1yQyWN-?Kwjc%+X`hI4?Q2^HwovOjk>!E_EVJyfO$B z3m>`sdfwzc(gq{U|06KNR%Vq>?-^%^`P&GjSt3Y`9G3*!^-O?KsE3+UULJ>Y3ot~% zwCxJAK_tx9iO&;*8c5|AdYimi)0{Vb+UNMfpTZT#^&=X9vJ1PEv0PjQf`_D4$l5Wc z^=0WhEbC{<@ME8+{e25|QUB+R)|}FG>Z!2l_~lJeGX!?4z__{2|GB376Ux&-lwcOb znv#4Zd#5$x9QOMQgT97i6w)K(0{ar^JbNG~AE!&k9p7mfKVo;%eDjY=ltU7?U3c7) z3!)y)GqFc8X7bW2>?^1hXc|=p>glXk_DQ$paX0L6ol)iwn_1X?zRh^GYY<}@IbH$Y zfYViw*+9J|;${$4(&_$?>vPr<=kxvss%P4N5Vveh|M=n^jn+jB)B+djHIvzQkZy0r z%e|9{yOmlc{gW&2+oDft+~Nw{4{6TSY3JJ;_u$!e=G7`ly4YDn-hdPo4MXTx_2AQ# z2d~z8+0Jy`g27^ybMp??eN5JWxp9R-)Jref32u34Yt|wJDI_HYr6>1-v`W6*dvblj zX3c*V7(kVvX6+5`I~&-?ojsnZwdU+d0)Fn{(!c=Hn+EMoc9${Z!;>q~ch`Koot2_T zI`U)J)sYw5H+m;d+o!WjB=8RfsPEwk`n@@U1zm-fjYsA1g{oT!#S-v@2X#w|A&@$o zrG25deCxAku}xpe$Q;fh%wf4Sglcf1_&v0^m!(SQh(Jt+fm96<5B6{}8D#zOMqwvQ-O$@l#-K?xJ2^ zAd&PWH?^^AF>39yLGieeIsy*F4+3>Phv|iplrgp^F^qKq;zP;U z^!W);l*@A`Qce>{U$Pe^0Co^P$s7Xde_tIcLsGkA&c(`#T5bjyAR(1l)=`^bxZ+yM z)!eoB$c~%L2cw-fRIM(5G19748fml3)o`FGedfmx3|fn3UTE&lv?AWah3>)hSewdBjXP{*h|>U$boT6jfNR{oZGT9<#^o@l)tFmY z(DZspdOpOykWkpYNPXjs#F*GM9OkOSPODQ}HGng=vT}<3=*pyP5Qo}^P)=xFy+-V& zJiN6*Em0xP*aGBbF8~RMKGrDP1NuM^oD^#vek{1x(IdNrqiQbit!<6WOxvg;UhuMaiYt;ih~sf`^0Zn4CsN+kar2H{Gm0>8d86Q+Y&;jW{^h@&5ry|p z>~*G*d}rm5#>k+Ic~TLigW&G~s#kamviibipBi}a+2boCBN?%8V*1uS(jm-dzKLnV zA_%7L0y!0=^`UgU0}jaNNpvCP!>7jT=|>Hej_@5jS6jHGQ;z?8#^dI+G#_Q{9r;tu zEBe^B)!l^~gYd-}*$bYZQ9gu_cA!-kkj&cq99tn|Bfd%a-dH<)Fk95X#hvk+wBZSCT!e44O*g1)I1ci zBs1!Nq{1fRaleh4l`DX%vNw6B0i5CvRfS>??2+IoOmzXm%=*yds+-${JX~45<`lAF zWPlQsIwy!%xB1P0Df*@!u{h&R|4D_v75!VyAF2CI4eRUs2$z~(CPD;mv@{G8BAkMo z)(iRCcJ-t_&A9-{O{r<$qDshQQ^ z%Fc=IFTm5qQhafI6c0sMd@}`gRW}nx@Ee&5LD_Kx(?>G{l;K*2Tm>H&D!wbmbnaMC__^&1(G7XH$5QH@y z=NcL-fGn-xOd$&EGy@oq4znLjkD6T{&X2c@lt1!`;Qlc|ZMgeO0YCm&1RJt6Fqe}2 zELp=Q!Hc5xiSe9 z+v}ZzacATJZT+Thx=bb6L1C$$T8xmlWFh_6p=)&TfHJ)8ia>vMb8hCx*=GG}PB(Q( zAXUdT#ZN0vZ&pshpWMyemc5nFUUHnEAOL^>z;jD*`*g1S^4`OqK2hxxvy=D#)N%Iz zOfUQ&-)EcM*w}2uW@clXyU_?umS*lo(ukVdMrfp3>eN)ucQ=eBnw!c&wNS}ah*HPt z+RzoH>r6*Zr#hVxUFztB>N=h3aru7E@89n~@qRtt@7MGB#xn4fw(~VKvPk2WEYo90 zvLx@ci12MH*SNd!f*B;Bh~GSK?REd z6IWm6Y=ptB!O;n>*ZxM#==i0Z>?MW9EU3PpBMQ8_aDEk>UPk|rTxoMkl9?S)3y+rr zRs%g+pLl-k6a2C7H?2HWzEAzF=h$O9wM9XD-3fInplbu5iFW1Pu%yX(MNk%~amm`C zSPmbcTng@o_&~Yq;5phYBFdCSwwu+yUY1_mw#W4o99i z+D>`84~lC@!XflAueg_@60@%4T~$78yO9jz3+e^Pfy`ifq7t1>3#)T=IY`w8WL}-9h ze84O0ev|^ zqNs3Gwt>gLd+(nw6$T8r2c*1$kvi^f?w3P+oB2NLw3rBB>@J&D(wd&EaH+HiJb5w| zI$i-Q0u;>a(}+PD=hE;3#?_0d?(2x!X2U`Go)+EMe)%4}otH$D?o(jw1K>HiBZ5pz znBxIUI!Z5CZJ;T&?ITC$wpcP->wfxq(nzcQn1VhSoQlkd&j?-4FPwO+qy0YuSc~}r zxxltu34-(XSY(7Mx9VhJPBpZ!pFy*1dnU5LZT0%n?O8JoW|p2WJ!v${C5UtWOz8Du3_-Ky(9+$ zG7){`bg2ysq&d7z^nN0~!tX?M{U&&m&BQ@Mwnti8cANjRf@dAlj9?}R8|DVjXQ%8T z-MW3a359eYE~C4$ejTA=>8q`S*tTz*sn20$U zJ$gtTkLFFzPYlOkQD}-ZjlFs30fFLnny;K;t|A8xHB|=cwZyPPyyHuie#zCG-IGzJ z&1+ZRCc5}}M*rQD;R56zx_i0%VCT@Baoy51ta9d`UN?1gurMRi*9kj6FyfSOX4i!;0_&bHn9fUl(B-+hxfL$jrCjgg|YDoE9KpQlx@0M-@X>qydh}#fk&= zZP?k4wi<$2Hhf~kRgg0r$cUvGBaokjS3bAfI*2 zJK1=0%`1E>=GTtqYg=n>M##(Gf(%MP{O##-Z^TwLyePr^9n*Di#B`X_2$`}Do9X7z zW9-2{1NQY*R;x}hOmBwAcR!_f^uMy4_)WS z2TQ!KLQDSoUChm~_FE|GiA$L+c|UFbvM%WH%N|yQGwu`cBqew=%@?{b))J0(fxLdc z|1Z>z5m=p2wDdf>t^RIe`CKNmA|YZu)tgCzzX7PcN4{|6?IPdd&<+3kPuyoq$coV_ z^2g8UjM!r8y3yr>w(sWI4SnK3rTY^gOc%Lqj7KF~UT$m%|9StjejYeN2%kotzxuvv z`Rc#7)pZ@3-0JjOvl5xBc(mTVOLhfK;ptMv=3+}boK&P!!a_+9Fyj>cioN?=&8AN? zK|JhRpThF#OzfNMjT;wd9Z;jjk7)$euFHRTyW8@N|x!v)H+{IPdgnVS%g0lN4 z=NbXhD=Zm^@ZQy9BnuYwDLVqdkZFF>%z^sEx8^Q zg^S~c+4I7|Nr<5<74t1t>$(G_TnXTd805)~oSC#(L@R`#pkprBotT_Sp~`!=i1!v$ zj0{$8Mp+`xk)5Q7nx!>s`<9|vfu4l*ivUtM4f)|rwhBV-3}wq6op~2{(F6T*eCh{l z+@D`lQL`~->%}e2v)y^&#`dI&H@UmwpHm^&#rj+4hy4a;bl5%o0MOyH(kGb>2ND%f zyanjhD~3j*g{eFoFvBp|-zkecRyys0648m@2~&2J!3=X_8zmt-n+m2|lcRI$AK+uI z$KYS5TpLi|w(WZW2WeIhnMp7n4q^2kN5j|(vt;1d_nb|kaj%88g(BL~y5v%k1h*rE z_Kzf`K^XpjCz>%XU+hIimCy1yT*g^6&hSdRX;)CX->T4(;^0%_B4)+@{<^0x>1T2- zY=PCv!klfSt#tCo6mK^&*-hb%Cq`*q4jd^#SRs+IXtsYaGhG}MlJ3vg9-Mb7r}YdN zW}PVfl`GE9D3TnFkeAdQ0aHn%9CV}8akxtIAhD)za{|JV9h@E9oO<@$)I>%wPXcag z`%X#R-%UBjkt$0h3Ft=F0o^t~5qBToVWN^Cu1x;8cd>2+#u07zOa>^I}UH^Kb2o6mO1GhYCk$ z^&7Y#FCY39tlYfDMe|rfaVo|-+nQzs92UNTH%VDKlg(O4ub{fCS_`2@W_4E=eErCY zKy2}Opf;Qjn(cWH zyVcki$s)m_!Kuz%T&SXXt~Eg7wL(uHC{d_jO`FW{s)a-|_;;mu6w<-2PE&Czy98S! ziZ7?YY(k4Rs*i^>u=dp3@7Z~E#9=709NsUD=0n$({sKiFnX58Ezr3(Ot52c{Zvi|7 ztjIiyWK%5gk~DO-0pTz`tWKC;bLiJ^_T|(xQx#C;^c9Kw*k3H;tFg9=Y!wQ1fD;d8 zq|83uhROmnrC^tX&m-LY9x5x1yF^H8Ze?um%*%TCBy=h>& zlX&07vC92JnfDIw#+9EpHAL+eyz*hN#@`K@)d~otL7clgL*id!`_wMW^VYME@pMY{ ze9wJ=w74A7MQODBfPQ#Ymy%`6m6`BW9vm>VoMy-q_hs{>}n7NvmP@dI&lfvg&oZ>lbU8>ZiPL-}k=*=L6??$70zw2PJV^8Y57H zr3p@MVkJi?vPF;l0qvIBFrM5Cg9^h-mJOP;)>|NY_aTbbPG5MtTUh;o;IA+3a0% zM{=0gn_8uHV@~K)MU%P4nVz1Cc;MLM%WNP4T^bI2L#ev4y`Rg6QEvll z<>%q2ota|JJ0r^HgG!N;U$og(`Hz7~`$cN?~njXu6|l@sI#} zsWJtbtY}zpHa-~jw(FNym+ZQw%e^%XB|eE@pEg*#OxkHZXukt26ac;7M}dM+edzxJ D#PI=+ literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/images/home/buttons/button-07.gif b/apps/elektrine/priv/static/images/home/buttons/button-07.gif new file mode 100644 index 0000000000000000000000000000000000000000..da18ea48506cabfd41d6dec839569999451358e1 GIT binary patch literal 5439 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 M&3DoKZ$z{FpNbvrO8@`> literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/images/home/buttons/button-08.gif b/apps/elektrine/priv/static/images/home/buttons/button-08.gif new file mode 100644 index 0000000000000000000000000000000000000000..6ca1b73bb25c007be698d700279332ac842c10cd GIT binary patch literal 12472 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 IfS`&02S-kGNdN!< literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/images/home/poster-01.jpg b/apps/elektrine/priv/static/images/home/poster-01.jpg new file mode 100644 index 0000000000000000000000000000000000000000..12622f267158a958f5f218d588738be039fca6f0 GIT binary patch literal 1266086 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∈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 z z5X}F)9oVXF@$^>wFiy8xG2Ne6cJA)>S(KIL!<5)@>$DQxpnJW!ME|X?w4SKTYjZHfGs3u>Bk^l z2sH6c{aXE(bIh7`BrF~gx=69A(SRHbmQvRHJQBI~+}&OR4vX0|WzJl2B>~)33vNH} zt96Z;7z#!~AKw}9-g$h{?B89s8AF0s{T9IHZBC8^2uEY{T6*b#fvuoKVYlVo6b?Z6RaH=H30o zSinpenw^a~C*gMcjn|b#Q^ePX$gwV|HYp)_l-1-iMBR_C>(VV<&iH{@I(sfFpu*6O z0FDv)JiFmK#>Fhi@Lkw77v9fo^`YHr*Ua6P>+fPRP88}_BB_X7P-KtcvpQ<&d8U}` zZv@7mLXpSZt;?r^{RE$2Of1}-YpV+pfBM3_9t4Wd{DLN^xy{{w7tC%IuMZ|`c~jdo zc#|%qK_F;f)>3|+a{d^4ldncQlGxGLq4zs3o9cl(JN|Wcx+G%x#3%0J0OGeJHIgX#7kFq~? zs_j+ZA%19ZH`0|kHOxj20gmBn6B&{J!Tc#wZRRA#ll_a`BG0?!MI)6T_7a6XwPyno z7F)7vdm|)yreymDXWzIFJzdbb&sF+aE4e1c%sg33?A|5qiXj0os=M7gcUy3nwe!$j z*j-ETqjfqFA5+gBamJM6VbToE$C#a-=P3XIU%KuybHTyd;ElfILV^1e&mT?8Id3pV zaV~N0)ogIOTp(ZQW$ZG(TV!rx&%?C;6tP>lSnwb3Kj0Cljib^EE&KqeRryeR_;(99 zAZ#D}pN7DFZhP@RIGncuiP_+Gs1pue()G9>0i^4C?Ao$*4323Md!ltVSOve9{Q5nM z#%l0tIOQeE!!@M31ZF$2#KgYn%>GhnC?HzG5LJZ8vzP@mV%X*QDX3b|Uyq{D`TQ2{ z>1V2xTlmYhI(DLF64H?RkQf%fxW2Z@rs9Jc4+%GNqP9NXzJNX*o?j|<|EeSZYIG>oi9vkoG|tJteuj-}LoqO);DFs%188Pu>O9u|V{zzL z+Mf9X=u38VMm&1NfZFY;^S@FHw7~xvagVo%V&L_+b>5h1r6KcZ<4-(>r{m31l%U0; z7^8`Bp|F*2bTwkO#s)Q@;jWuKF4^d#c1Xo+e9~PbcX;Pw$pY53TF^B9C$2vKUc0U1 z9dC$a%gx+vJff53c8W;wh#ApB<4L^t-NeyKs+kcPrPSAL2PlSM+Jz*qRfLgrr-_1m6&qff>Mvn^7j%$KUyi3 z*|{!2l&q)@HR_!dbritmufXh|{pc0KrTYdl$u>_wm2l$~e54hc%m`e66aMLA-`Kh1 zc@-pj%(}|`{ZS0g&*SZgpHHTcFhUg0Gwl(Bz^iGMPqtsG=P6I_WW6)38OEo2ji`Fq zF&Do|Q}eoAk)%1;Vrf<~zt2O8CXQ){r!~jj0NkrzF1v21KJTjVO0}yR{sd|NUMsWa z*il@QM^$h$Qz8R3&i$+wq#;oU9xgg04JbG2b8_Ij3iOT(C=$XKHlxpU$A6w_X!?Mt zz+n{4u6&ZhK*&8CVDdRPE}4i+PAy$BAZ-Sz^t6Z%G=@7!LOH-lDa40MxbFz~9~RqW zo0n=0nr@L;*KtU%emXx&5!Je5>?+!}5=T><;ckL}nRKQt%Pb2kB(Hub6FF1LsG}es zv$Zjw%nSOxPOjN3`)kbW{3!7xk26xlZ27e=gV$AwHAT@5Tc~~=;&2ORtz_=!c&HNa z)DZhJl%1rhSBN4dSHY8eNxt+-io_EW9wtv+f`R#mcuA?__V-ON;Tias$Y7U`VQ z8K>ZlB3PU$!`d+ofUWuB3x!T%L?$z=-+io#@3wkm9_mOC%qu+c2#wd_$i?2JLtawY zWTT%jd(i?U=+MN^ZdWTcexqtTz~BKaF4bA2?MB{zEH>Ojo-!(83@ruUSvdTTz3J}V zZ9D`Iw5p;twbyAny8F6{RFs&6Z;1V0EVBbBSYm*XN z!VKGfHZHoKZ8&&e@8UlUykhQD~Sv zwu=u-QyCopjW-bac6!06feF2?eu#@}oMA>=NCX`mAvey!*!ygfL?K5hbnO4Cl(kz* z*Plc94QX4Yu`QuK&k7igQ=TGz8HD1@SNF2Z-+)w6=nTA0qow$6Un zpHuE)u$hoabJlaLqWNr zrX8C1`*;@CV41?ozH^(sbh}5)lSAy8;7aqY<)F;D&FbRC0o2HWSa?slg9oxE1XHk&= zz>{3FRlp3J`p6?*jHPV3W0wk=sxOh7O=MT00XG}|;p1Ho-fgxbcVStl5E)@fa09{V z#$W^m-2(u?lr8_dwI~Hla1A_{Ie-Ot<`dLK@Z_x_e4*RUfBz_$gSRIF%5flkWW_Qt z4|NKd#fOY_T_bJ__goleE#$UsAiN5i0N^^WsYXNX!4m*Z_HgcRNq0oj-I*A0CWE;+ za(ik6Menxw-97?1^Zy*@d6&cm*&Jm6GFnLx9aB|p0}KGQd5$AbZ)s&J{Z2{8g+QuG z?o+c7&t9OOxi(}MnC1J zP@A$d9D2yIO&9D7u3M3rgAIbamXz|$#^8XMq8DSQoqD0H!(a_&|MFDSeR84h$uPet zqN>BtER0E+6z;M--zht&sPouEiZnhUzS)7^MHvIaMKym@^RAIG|2iMywHi;yqPn66 zJ$8?znQkCBS7cVFe_~Hx}-!`Ykccx z2G|ud?ZDAmCKYR2_a*_2MjzXnG3u=ptw>%cT@0xh$S>jxD}~W7PvM!yRHd-=^TAd_ z)LVvRjZ=rQa%a;lnXa8L&S5W=De-Y_5FB(E_44Nhk$sB z@W5=1ISCJoxV^Km8R@F>*B^Aps1zBW?jRc6H!}a>{O%igQja>IuM7W^_{`%8jI;PIknG^WRIh%($XL_IP0$#RuF;!r58RvV+r?#;`Hr)Coy@mkIu2$hYkful{lXr-IH zZugD2fycL$vwODWng5MpNc2&nd5W^Gh3ohDD7oNy#+{GPaOw|qjZK@wn@n@C67}M+jM#0%#2Qd?IPW#nLr|c zgpt7_Geuzk*9Qccy_X3i8@79zd6NBP@qO&xQs&Nu0n_W$wPi!m_aG3X#F~edd-M8J zj1H7upgk~q%+4&j;(%_6!L0dWD7ZDzrA5S2(4>#@yQ;Nz?7bQ>+n9dB=LKBlIFx*Q zrBVHpIq9qYq%PF6_ujq=H89Bu#f}njJACo#EjZ_l&t+Y)={;4H_L_XC3^H2QvvO(9 zeoqTGEZ4$qT*(G9*q(cIiUSVbjQ?<&)I%6d6Ted_rj$_SpgPU9w8aiL8IrVg-`4v@ z`@)|*O3a?k@sPMk`Zg=v>AYaP&72*Lly>Y#$lE_^m740kXIOwLsHK>gfe)uY+s;V> zXKzkvqI;Pt-c48-1O9}q7L~8##4PO2Zqknv(M8g+Jms6LPe{ac$KTPEd$H3eiVt{( zpIta}FGuLnVambZp!<5-zH!ongKB8P{wRRfHp*+SiIH+?96nODhs^x1r)9NQe~IAXFwb9pDG;)Gz$OwA4zcVU+lg-{@)XFOB_D65yOB} zAo`!+VUy?@3~Pz~?n{`|jLCo4&opCGQZo|6dC41aVk*_Xg^8+!0U^6Cbv9cW!(kTp z!w2&{&o3OgxRi)N!>r`v%Z=S;h{RQjDd`2sUDJ)gfkcYf>XCcW7}kfT?$jEA3G#w0 z4R_sI;+%q`g{B##qv@CsNk;A&%KD=ZqVASW66!w6&9BlL&Gn}6sns=4HFtmr;3J=O z37as8flp!%fC-BIf1Z;4Wi(rNjhJHS5;iGokvoKEal(fB6wI4|B<4>P#ggA3z4S20 zxWvaTYLRE%3n{}*kCH1AoMj5Ro}81ZSH1IAHY=?%1ek^QpV6@rkQdM;8$Db?Kd-KuvJVt+X>NT={={lf5PGxW_nWKJsahkWHjHnnI%W3>piE zTcOXl=;_;^|KYW+IeF%uuWVNm{7Jrafcmp))v@b<4mak(;Xu*bm@k5OMoaU-JPYEb z5Fk*_d9(bfDLE`7*&O2hg}Y(qQ-5usNv`T0P<^@4LcK&{%sVt%z&h1drH?3~d8SO( zot^0>-L2rB+M4si^|Tu;!GuK;L;XLTffeQnSWn(UM^jz68Qv1b#DgTGBy3s>QoOm@ zVmL?j-QLyj*rge@dp(k4&PU>*S;&b+XHkl)F!?K^4v$h}$UKs0>^1O(XwqM3u)!@>4r54< zpKgC#^3Bo&o8*g`JnPzUjk=npcfqmzEvTQ6ayQ1@j<)|^tN5T3HOP+W&+Js2xbFRCjtzV+|N$cy@(C2Y+l^|20ZvuWYV*D24HmxwdQ|C*r{I5(e>=RWr;fS{i zsjQWHqKU3t7SAF!n_#+(m)zUD&i=1{GOL@v9)quPd2pkSSY{OQ9-_ig7Lm|GE-g5V@A zg8YQp1=#xILUX0|EBkic(s7S(9#>71GRwgB;L>j6yr0GOR!jT;^1M+9O_z2><$uhH zpBb6jrp;~vBYkZ27ejCdE1+Ub<)STRHBKfx!cP0H)HP=x+O74u)M$tO&xV2xP36B3 z((#)9-0EDj0+gq@<0DpN=Ei@AU!t^xkI~srNdpaIM9UM#AXz3|@6@46qStPphIe?L zhj4Bi7uJeSEWp0On$T~@Rz#a(G15w|ZF~gs`PwIXN!rA0zt!SPVY#mQ`iv=B0`usm zE5{t^iAN<>(*0eotFg!MhZIsn92(m~7JJw;B^_p?^JGU>|3D>Cj9XJxz3A?HMJVe0 z!n9UNC1d#RKb$*W1#i+Q=(fpcw8qq!7O`vyz(cM56g%)c5{>$Y0}F3se~9FKxs{r2 z<3mi1wD&ni?K8*X9G>6NUiGiR+?7@<^mKW|`_=AIE82@PJ<=8pM1wHbp-h+IFcD`4 z*WW2@1vo5>`R`cMDs3D-lO>U1*?43+@Rr~>>3UKsi>-^yhFk}UZpasrP$|S_2#qg!NzxuT@pZju>7D5vOS=5FlkbOV63r(4JH*&XWT((j6W`0v zYc+@VvyN;GHxqNLBPsc!Di~*zSZ^fO$fDCR&?ZJG<_5-+D0OPo8^syZ8_%YxXP?@O*NmPV`(|R9#2Plyojfx zRYzQV-#fm3esM|hHA$fsy8FRd+CQ8al_Z?(Pa>6{rw@-3BSEl*!n$a!cE+6AB8k>G z$%2cwk2RUKCY6?YcY;*MHGyB;&ttEso5e%#IPkjwp+8yW1XwDZC4uUbL;uF(Q7_l$ zGS?=V<*~F$F1~Q;6vO$HmSMI46GN*AB0DSZA9mUHYo(GO86#M$d-!*NS_vE@d=EJB2KU+o6T zl6}F01$mK9%iN&XC!h{j-H-KF8}NHLGtKz}WT|=qtkSvPv2Vaie&$Im|EXF!nV*xS zf}%FKwxJDXshNvlO0WL_)Z5nK|7Ou81;!!#W+0@Hf5JE-v0!YKi>m zmCEDA4BG^4v=DHxE;48IDts>yoJ7%blCT{a$guAGHX%W)j>t{}-X zpWjV#y3cT z1|e`nUP(|t^JNA#L8XQ!JwZ~y=yKEBKI;IihUn(+!dm2?h8qL=@URFEdyqXuNT7wv zf9g+M9+*y~e6?$F3(M?+8gUu(O&%B}%Zn?fT=2oAXH@x`vkzXpWxOYY=4!QSGTsNp zw>fB5HxWa*mg?+0=H3{esq~vKuS{93qn}Lj3UwxzdP?8MAU)TyaFxB?F!ay4$iOHz zo~P=;{u0Z{H(HtO1++xg08Ha!G&;%sZoNC2XqzdJQMGK(gMy!4jli4uytR;@HFTY* z?@!t)WcmrPf92AUm>I+Efh5Uf$&Jh}DV%-vu2(FAJ`F$Ey4$c0=)|&DCT|o%)?ayI ziP^nY+z{<U6+}{t_%8c9vi=;#fo)ps-Khct=>5ou6Ld2wE^!Cu}0g3y^m@-gvj9 zLF?D)i~uPQ5J2A0vmJIPx47@Qu0$k1!ek>lJm> z6A+SW6bbJzwJtE&&saL8!E-FAqqT6r6L6Y*kHq!+MwIM3qt1ckN<=KzY^F~qd&N== zX=m3DalhOZrgtR(KzP!<+Ld68y36kTJ=%+D=Q&d5iFD48$irlw^yX-$`-*-KmSD^4 z(3Kh7rO|iXE9I_+>QW6e!;Dw!_b9^}%thk$pbnS6QXEsA?m6Zns^6Y^U14>Ip7pDC zxDY<4D5dl~p;CVg4UrxqhsonpR{cM8y>~R*|NH-M*Pd0IqGGEN1g*XIs=Z=I%vz;d z)ZRr9t7^~Ko6;smYYU?Gs#?`nX^U20zo))F@AvQX{r=AHKj(-<;vCoGab36TcE^7j zI0jOX+Un{M5tGS2PNfnq62YV~gY{l?)ZUTV}07u z+d-v=>UfRl(v%?E+S5HEpJ0YtdHQh1<$51CdVHJ{Vp;DAiGZ>+h6~l;Jgs=RlbFOE{9W#D^{CxAs?);O`EF3u0?2dO+tE_LS2|NL!CSUhw=A-XZu>`YcJzByMn_B zB&WbbUDCPh1BK^9uZl}6Wh2d!hegIU{XX|#yhB?fdw0p2;d7Cp8NV^!>U5h@!DBJ&->$ZvyT4ug0->o; zI3NPjbr7dXPMb0#3}xBYX63p7SK;VtS1$u)pa4?gqQE}%Gxa!kIkk-nk-Fw?vV7Hn zOzEnY-j|RGt(xqH79E^n%_u~D7NOsO3C=)}7_d}M&})~LC=&->ZN|u$5=vUhO#M`5 z7&j1c$MtHjU0_Vr2Oo$4!K2e#H`{(y{n`8cdyB*9FZxH-vi=T0|HLq%8*33eM0z=) zYQp%Hf#(_~*6{<|!57}>asStcx}Odh(fg1RtHy8#7WMHLSi~6B9BA^qDmeBxP<8rNtxB3wB0iF_>5JpaA&-505Lc0+ z2ur$}kQSD&6U#*feb=75mj{_2X!jwZ$eK&F7kQ6)^i%87XTEpL{4sYuhIApuGu&@X z;JCubzO75jqWPe#3^!CSvP=^pSy5dO9G@#8yk~zBg~&l@%z=yREJL;!?vi*{1mR@5 z@^)^_iv3Vz?F7@R$eIyvX^RLNsy?F`z~5rPX@kE?^gOc!kzrb4~ zYiAU!5eHW)sV#5b4|^~)AS(DT0@A2WQ(58_#rW4tiMl|Y`eT@7xbsu~5M2qC?c16il z7rI|4ltFb~@ho`7C4stkh!i&Wc!1S=w-8|=`_~jJ8B~}l=6zpRBd?Z@X6UU}2c|mR zt1nxl`I{D@+)4Cv9x-!fNi zuVy)1)v2H+!V{pAN_Ka>1Nd13d5rx_7&r@<6gVd#k8=*FNOEL*7t z2N6$xUC$5Zo>c!kUXL<8iTyb0`7nX+2lgwoK7{b-H=d#HfY4 zpts`-mC4M=*4sla3q0?^`j5Z{?o9 zdHCzykf+PURn z5Y4dWjU^6_w!F{7jg>|Ox64*FgXnb4rby&=PN&3%e*bon8U>VC=DyK`sRCt-yq81y zvDA_GpwXb&4z)NS#lh`M^&VbP+v(NjhOSu}wl=I9!&MxEyFXU9JW~$syGs?9ecFEW4<2LV=%E&L=IXYd-o8(UG*#FS= zolEf?mK9!r1JlH5-Sst&SCRa9r_rIeZy+_WAY=aF&|GJ+ZvRoAre`efy zR=_%Xjt6osr>T&;4f&!+p7|wh-(nw7I$FT7a~*cS;{z(7t2IZ9ZI}@MHhR}r^%{o3YTjVp5_8c5FM6DwqZ4eBCs9EeCCi{v zq8#%)#|m5fCd?EY;Spe#r5Pi^L>g|ao$g$WPC=F2a-i{v$?JmUf=%79Xjl`@D9&5N5cr6878?@HQu99B(4<8t;?2X%=p$R(n?5B zq(vsq=lAWjOquZ-Ox^44f%iS=34|lNl&ZMm<1)`s!T5FFCoEY>0*RqN z$rTnf#y#-V68$QGee#(7&YZxV&UDB9Jq}F{KOCEvR%6apPopEV0g4q-eAQF^Tg}Jw zS_?Lss?Vrr|Mn1yZ%}L-p6O=V!STYe-P?*-d@05>&zrhhUdyGTz{m_C&`=)Ks-U??GFGvdZ*K+40Hu zTNul|oTsvWQcf0<8?9XE)0#+-k2y;Wm-Fz_*%-Tpn;iz{5@gYk_EdR(?3Iv~m%H|_ z9RV;T&{BfRw0l=a`U2er+7%n6x}2+)9hSG(4B|b-fKZg(+{gL2CEzO)CGF zBZxE^!yj4BUL_U>npgL~F0hUYX=sLzG6I6wu|oruj5g+wPX^_`cr_w##{H&QsOR`z zn_zADsnXY9^2hLZy93=F{P%5mBE&uFCmpKA*#^J1)$@eL`gAo9Kc8vmi&+_yr#yOn zS5m#p`8*%!@~G*RD5h8nII{jKtsE!2>s@H-uz19!T~NcfWOG0;7;9uLrI{mLW3gmK zd4D0w24vny*ukcpyt9ua&z3BU?;%AGyrpduB?@E%ariKz7X9KyLz+L1K1%Yw)!8v= zar8AQ-kvniEoDPzX1t`b+OzDxbi~B7j!FKX~h+X0h1EnmuoLpyr3Ord6{ghzCu0 znS~Zn0ZFy0Cn%7q*Btk)5ckQ;l?ytWswC;G)S7@d2KK<{E;G!SE)>oi< z306BDInbF`0nm{4%oqECVhe0Re7)#$gu%hnzd+&NS;RH;x2z8g4o=w1LjR{RQ2gKI z`Hg>JxFO&Hf8lpMb}`5#*bea8jKENi4B|WOl3NU-fx1fJ0YiRO60(&y%byX=4SD=h z$Do*eQaY+V&E0@wjK<3QMRu9^fa?*)^{@B$Hvq_i{{ROdtY_n2ZKsJsbYBzkS&l!ohXEMkDIvpC`5paH0VL&gd!>5` zaDHg`#pa5GRto3ZIl~wP7nc}}Xt_~$9w4ER6FRSFTOZ}aF;Ptd1yOx$hHNnwtTykI z#YC0o@ms|w!E^WXt-i!J^!1L+NGL9=&lgLo_r6F|b2;9iyh&wtEMB{;Xd?3wdRxk- zVID(;2?AFE*?Ds6*{x2*55*PgN5FZx)Ae_v12X~r+`nsi^4FXt@KH}? zA1eS&jRYUxnZZ}rpFqG3)QU(r=E?1fTFp9b)c0>Os?JWiy3g1=te-bJ=5>P3UfjDL z98tUH`BY)79=)+i`AOgcKe9lcFl&O9J}5yNTm2o#Rp_9S?*n|0tmH8}p&n?vw0TrwZvb?ss`lo~d?3biin^Va`Nr~b z;OS))d#Uy7Dou9wM}+KGw=#kRr}kaO#2aZ1LM?%l1gFE7i)btKVQmAu&PIB`AR$@8 zgBCRVj10$M(dHaM_s|U4iOXq`pU zKIV!rbQRjG6YspdySY)p&^+emy%yj6)R5?Xe5n(&!wqjMW>|eQ+J}J zIXA#)?i3sZwjo6d^Q@V->S;rEFbVh^Fs=p8eO&wbX?(qQ`&$7-mU3l)qKWFA{KvPv zO%ZIHh{GK_sg&PwFlLVPp&ojBQ~f2)KIY+qdli-bGRC{4o#VEQKawqr3~CYKWaj%9 zLkE&;gjf6&mQPyR>ol7P{gg8CUS`+jOPWaM+1iv8@imp>+G=M?{Jv0W&DPfCI79&s zZ8{&UP*BW*DHUuq1EL->o0S#fPISAZWQ3ojB}IT_p>)mYUYVLbFP(xM03s67lxN=6 zQad!129IyCsuzFzxZ3UUO_)_G3!xcC6+Um$!kCmwS&wxsk1eCI+#BPQc6H_t<=>kU zu+h>1S$_v1Ik^z*u8Rl!z0yrbQ}}}#)w6}&a`s=-1cHJaat`Tw`G~{rQ%pLVKTP1R zFxFEJXUKW#|LnmoXVI>}*PYQKpUw=pws$Z;W#maxMo?Y-*q8~%V{eR1RDh!mru#YR zX4^t4!3;WoS#?I@Jt4=e*h`_YJQa=CJH+W8b0GI`2t^l~xx;R%lx=O!_YLR?lB-$w z)Oy@?$Kc$ zqV001>OcTr1Mj0{yOqpijMgcPE?q}jXslsL(X%is7?FAFuJ%n<|355!@c&I2o&)_k`;CxX zw#zSO!u<|PkkOy*mwF+23Fup>D(dLIVGMkS=E_aFwa?{arU)F|m)^!Ua$YeF{AD$U zkEn1*0db~=K)|8@c%Yq?t=-Ef+#iMJlPx`;Hn$$myYd*V;B2vS5}IkB=9oilF|dzOcm zTCvi?t;A9)jqXUh>q8#%WUoJR&+UPj+|sE^6?;cA4|h*0wppv-ib}Kc0l7^KG?iQY z7>Z7&K(;!;GMGzTh$+)jCyaiN#J;ihw3$|_{q!yB2?0?%G^uhF|E59)GsInXs46U* zFGiRpTVv>g)W9Vl_A072O-S25sfS)-ice%gD9f$&EUc@@)2Gq|qX&BD9A8*Zx)>k& z$&a?r((A{(X?c+szXa;hCu$Ob<}n?HK!+Q7zc@J3EoL5X!r@Ls(SwMa;f_A2flNV= zC|*6`_L)aCJM6oMrGgZSlx+fhA;M?q@QRwb=oSkysPzr5*2S!&7q2D7qI>ZEMgs)F zxN29gc?Tr^W=RiUV_9zg>8!NxON2DsOxd=G%=EHYGsH2%aYuQ6w{NCizbwF#+@~sR z=v|Zekvj<|5O=Uk<2u4P*e^W+wEKBns0XOPiw$y`&($#@Yv@nI50g+b&UhX7UG%@v zN$^1)uue!{Uw!{`UO-AG^pD!Xkh?V4KEg-~=(a4NqOD*T^9FTDF!-WkVYps-5BeW;{}q5%{0E!>EH!1GVl;o< zG@Rx*8WBe*j$I}}pulZfC#56qJGdsP3uj&K{=s}+jRVF=wf@3NZB24ZT*}f4Sf}Jb zK67+G?N3BnA1qe9f;I+7VmT(!uMK$yjMXG&D_M7ec=WceZJ73Zyf77ec6iBZb8vxX zor8j8=|?_a#@a@gyHj|UDCRY_sGakzPf2Kdd}utlsYs%y`2BoaxbAp6ez)3&ILY#B zIp9+Oh7JFM9u@zdwg6jh*akp?g5d4#ZYy=`o&k(&RNn6dEM8f-43Ht;w0Mi{QDVO_ zzQu_A^gok!FVHYhm&~*AdC{Y=-Oe2_((@0VP^{&Ypy$g}twCn_o{X_HT@^7kPGTj9 z7#1lo5ySB~a-6hgC{ipHpMA&q1F&67KdmLs_nw`s;TDXVX@gBySXL@Gy zR@I|RVJcPsWx&>rXFBlql(rCHNFsKK1x;Q9nVSiARO5C=Q{rjLvQSF#Wwx!w42agw z;P*Wu+-5)&xhOT)y-ge906GW7=r}k732xkct1qL-_I2h#WUufZ{VX2L$z6PKA|Q?B zf!?kAF}SPkz=idnRlK;s58L283saDBuz&s~ByLECC0ch2lj^y>FdQHhzR_>1-d#y0 zP7(zKv>`X|uWTmSOjc16KhlUy7PP^YcU_=`;0wnK1-tQEj3EWs1>+I%^J}WR zS6UCnxNplbtiyY8&IN#+jnl-5RR`K2V{Y=u@>|SRfRCvwx}#}C3LEE9d~RAon$^&& zrNz(ocB1(A4n@2iPHbhVaXOj#*)L^sa_`rL67I{Kh5Ywq--Jy~?S-BBNRW<=!6r01 zVI*e@pCR94ZyX#uXNQ@c#pZio-rxUeM(CJ+ULw{erG;;3BK8rRKhi8mWZHgFAAqTHHs}m7) zH*}ZP9NX(gW=>7oO_wkL&X)>~dZlLi;*|AiMy5k%#QdLl!i-8pNM1I%De@O&r2f1y z!%CcY>6dzK$#9H_-cWNRgzFN>Gpnt}KH4$@(%kf=5e7O;9;2y7?u&S*rg`j?cfNwF zb1H6F40)U)^mTT0ty5F_%}-CGHL)}&%rl$BigXLunma|cqdN>u@+v~uDM~?I-``uH z73nJd{u%Kf>aX1U2X0cr&Blv_4*RO(b%1YMZ% z8PBRcC-hBnw|Q$4F2U)Hi{pQw++-KnJoGN5hJK7{FOqaV&7q?Rm+D2j#_%C8+x6Xp zx+37Z;WG|vQyrkvH9LJqhtTWJ9J7~sIoWGW92{1}_ZhPXk+S0veW)-^13`}0G6aWK zTK8754r;ta7FYco>U)~zkk{#=HM4ZfDhsb{*NjTOTV&FjRtCjnjMO+x&J1NuPJUeK zerq6(idvy_kQuvMmY`|Ud2T$^lUyLLIB18%`b^@^7X6>+-w3^Q?_1$=w%Ax}`@Dedhb35foR7QMUaS=O zieW+TnAMb|)jdP}>UQHiCWn`JMycvLKW^|}As~H9X^H&`@Z9o@Gq3aq=6sb%hdd(} zWPQ3LoTG#8{$&>H)W;(m)h3s?*4kC)iHZnLn~t{4yx)Lt0S#; z*Y%_DO$w1-$t|l_`UL`R(%xDEi;{0q&-%jDbF=SLc6!5-2t?+`3)u_D2M^ z46u3l2HT>AI$mCqdkA6$F5MNS5S!iep^kygn{#6=Mt(Sfp~LqJQ7JY#yYy@EnBahC zFeClux~Z~?BaqcIQdsmHRT_tnBP{k3T2Ek`mQmzrvo55Y%4!vbZk~F(&PpO zZhO6H)b)>vQn=$n|GjaTHdXFnLT(JS;8WmP$6=d*fCQTTW<7gBk1}LYpk9VzdW#y5 z=(ECR!7df75hcTrNL~h38$91K;rw(`CRkJXJkjZfcG%Eu88IK=o_hKTecrG&0Ocsy@EB-3 zfc*R5azjPG%L04u*ch&2MnG7s7@08%We*d+}{SON113LEEL0zxhxk=46 zrMJ%jHF45q19z>yGyZstYJ5Hc*%k-706uC@m}~+7G@#tpa4-S@82;Nw`0rB!?BxGO zB>wvVkuKkLGo5wrsTPyEGvHXs&7g&yZ(LKG;D5BskDv7F(XsjS5jm5NB`g%>3Q}tcz-y(v5V)vSHvJ2UAFiHrY-qAr4p56 z9cz&=CNkBX+cd?Q-10K{S^KvBeGmyxw|R7QQjArK-ZvzGX3AFm+X^w{p7Q(qDMWu{ zSRA#}1pXD-D{%Z(4Me=!R^u0ay0;Jah2xF&>lo}u-V=-6eZ_7`17Tef zqQN`=BYw-}@criqo;nrw!Q5?UrwE9~GTZ7ssv@tmS`lU?JO4cCyQx)J$>=mc-?ASh3-i*Wf{sx@c5&F?Qsc()^DrNyqc zJM*MKC&T~XX~vi9Kk%rvp0e9tL8DTF$s_{lQ?P7#;8a zM|0mZ{i0DVQ$AEks-e>>76qQcRBv4WCM|lC42piP!b_;n<2I+ z`AZ|uOAKDGc!#fz+}p2F+sg!0w<*An^}BB8`jQO14^Ja6NDAvS%{kZP)o8+Icmh3% zZ*hOpOSHVJI^;Je1Y6W5d56Yqnq zqv)yN(^FhlGW-jXpEp?ZkAzw3Q_=pyj8$oHvi23ljdjp6*yv#Q$23*QZ-Q9&lo%glvlr79FUe+gNjEV$ zlT=K{G%f*61D=uDXDKqiyT)1vltc)gA62vHZuPN=*?bF|WWv)}DPuWNlZ4l5ovk|b zyOMFt^Ojpv0=L$3FiXGV>I~@fh?g+zw}UESV#x8Y-=RnMqq?2&Hq!2Yg^3qDI8njz zBrMU_%e%cz7NGSVUjY=fATS0z>&?71z&F7Fgfl;}TK9~E619(dOBiK%w@6!bN{b~0;i!vn zOQ9i7%iy@k^6vu~uQ6*C5bl{}zGQUoy^juv%M3Ir&XDBYlh+NVZ3kz$e^voYd;I~S z7a9LPMt+Z~L-Cnwo62qfiw3dP-#}CUcFI}w%-EmR2%LDn>|)Dzf?_M?X^htW*P~oT zw|C@wm%?<|x$zc5k%zb2^b5RNM{8*Vkl}%A_O zH%LGDq>18)T{*@Zo2L9-Zq;UQ&A04k1vyEnm<$z7lv#G$%7f%-kssK1`K)yH^6`X}7{$B@Px>h{dvnv&IaCbVp7L;wq~x7>S+ezt+>%jDVjV7>t>DKMKL?i;_u znT=hv*D%b*4{v!<7$q>1cNb_lTbo&I#G5<`K*5=dsEM#GsN6R!ocYJh#Br8WY$0;0 z+oKkO>BSX4nKZFGtGMVT)Lv|RJ z-Q7b7-@07T=yfsJ9eu|`DCaMo#)$slCla(x94gY+XJ99IiCsz+gU2hreZkjalO-77 z^!`FE*loa8iiYmogShe_E~mZ~{#^ViBC)~125`iEl~-LDrPFsd{ZTniIu8jKq|9R# zzhACGWcS^4r0XNRL;xHIAx|Jg5q zF6JJ6x-(a-e&61k(YNRPOHwjuH}d~1n*ZzG{9iusf3F4pXRIHe?=TQ}ZP1zVkP_^t z&S@U2N!;X;pr z^4;f`lwb5sHu1JZ=|JbiZGf5yYI zXEs$ClsQYdm#kn#JvW4(9QhFO5y_(VylW8JQ%Gf1G| zhU4|3J`(I#p^tihME#As_~T(Z?uSds7PZF#lMJ}jTVj`5GgI6H=j#uLKpoS#Q3au& z_jaiwzVnCN+e*wAJ$TO87+`Nr@xh5FRo^VaE(?_Tj;Drgi4k$ST-}kFu?^O0LzxOV zXH)3hW7krX=K5`P+7hMQp0>^ETHB|U&PBQsLYP7^gkNtu>Mwy69zUfscfw@;G2qvu z>b78=ld)F7s8O-t9$IvMn631jt85i#o$sU6I=(rACA^z?Mx#jApL#MSUI{xrj(dya z>ktH%4Wb?al;9Onw$0u?)sW^4ksA3{k=WQVx$3-_dGe*JKKdK%bPBKPo}tGyR<#9h zXcYNj!dGF_Sol*a2@x=cFV`p&&V_eA4&u5PDgr5X!XVPpCFP07(xc1Jw8g7KW1vbi zuNiZ3PMhs~CuUp7zf0XV@!BRg$7aWY7OiJU4SG&Ui5E8;RAC!l zFUwQ3>G{!Giml#oUI6B(J7>ve>|LFowCsSGla6M5?p0T7maDMiD0v9}($Zk6F+spq zXfhqib&`~ds*qyA{wOfe!DLKwhgALCSfMG1fOb}20zs(cdndcSTr5br&v6pyl71ay zcl%oKfU*f7{lf~7)l?ct#iWTtu(xb_B+aSc+GcPW2B?JuANlPtx7<|4@8Y!?>6o-K zw0;>o9gs>E;OY;$rCxcZ`mP#9+%QiB1vHlf`5g3;}vidv|vCjqYeA$ z6z#5#)Qty>foZJJ*&JyBe32zA#njXGl?MI#3uPVeEjI(NAp7jo<0SOKda2ZG!gO1Z zU=P}Wu#dSQVu#SnuU2U#gNw%3#MCazOinF4V1tp#xk1KlG02Yf$+(a;7Y1lM`^GU`tcP4)a(#YQhGax^9NcJR-cPVKedRyFkw38Vi59~*rS=8h|0;_P?^O#uTQgy#Sk3)YpEUY21!mA; zbSP)8k|RQ8?-f>(jZa2M;j|o(FH%n}p``V+;lNcj{(Dv>Lg}n)-$flcQZQ`1`jz)v z>O-WG(z2j{wGI$AMPO5U)7EQ_!tabse|UBVo96pzdt4@1$8lS7M0V?5%fU~qOkR(7 zd%7q|q7H6caWGhs&ZoNn%Sn7iYq*?K&;^#YUIo>Jic*s`t~=|iXRA%{gS-tHZTRhk zT~Y7&5~_^ywp^BLubz}zv_%C%3daSS}wD2*n~6=fnh;#eYG z_KSeBB@|h>J8{mG@{Z9qRKDbSS+wJpXu77lW>t%F{-M`dySU4o{Obvs!;Q#qEn_0e zWZuM2q*Ev5H0fpbWr~kJC*Y)OpK^b+>iCv+-uOv&I4CUSSWMO;A!If^XQ9f)zVDTS zYPWLf6onc7K1=rVxXzBM1t-xT+~8MT>I-n9{6f}Iho(HpZ4Ep6zSrOeeB&GIV;sW* z?Vy1uco8AxsHB&345Z0bb{ccB6C|NKVZ?NAQx0%)_$LNTtUeiR;8aXKirR3jVWDDn zg?ikm*w%le`@KGIj|7^p(=)?hy6}v?&U4JxI>r2>v(F@4aw*t~uAMuFlpb+&80kdX z_{9|_wQ)07@bZn$r9kg8PorBo8PuI*S$a3`WlRx$s&AFk8%tSM(=O;3sebs1{!0Hq zU=n>33T07V2xrx3^BGRCEixeR$auy3XqN#W*)wQ!t#x}chflCgK`;Azi-BN*W8f@p z=l?rF|8MK|KexK;5xXyd&i&t`_WwQ*=*t&|LHcUeB=MtKmlYI?zIomO)N@Ne9GqhJat(j{^=UbkU7>Is+3o#@G%h zCv+er80MRKEtuQ4vs!lnrPLXgv4ZtBAeUm;`gX4I6F<%%RBgXkund>JxkqA;ey!hs z+ISxJaP6TR8ahg6hG6;7X16DoW84uhsoK%g_hKQE*<-oQYsU5qK7&Z1Lnk9Bp7UxG z*G9!Xk`R&!w!|ArNxhX{acBvLS{46>QJGkb-U?CJs)6B-yOLQezPy`e_O^}mK8eZT z*qg(-afOnC3%{giK^5QD-}aLrZkMYv`PM)`WMCIiYvmCZUYlapkDtNp!>fW^Fok#7 z1GWl&er=MUysRI?hOMJf@W-5TB{N#;sxk_XP9{b*s>fTqNovnA@LaW(SFAa4cUno- zH95~4sx5(c{QJ0)L`>EE*XD%e!n;kM^nM)IBMqAen~x&3iIa49{ilRd+1_5MrSWD| zB$)GlQDcF-7)L{Eo_$Nqzk5QvkUPYP38v&7Lk;=F@9f{4(4aQR0Q|Mx7h9R9djzv) zj1&azr9hO_ZOfJYO8iFV*X+EJU57XA7U2k~#rsW4ijk(7GVKx6G;N_@SjCOoG|+1goEj+}UI9X={Bp{3d@ z#t9_t={zG8vE?=ygg|w&+wVx+ zI8f~X-2Fjxfq1%3;F6Mc2H1gR(+SICML>${hO1$Mv84)A9C86sTT4dhTRVzt)}sfE zKQviKALm{k;F(RaV;_RvJD*-a_n7E#&r4QU2EU%h$=&NI_nc_Dn~DFd6PG^Ua0TpV zLa+idIh8-ZkLOJ~aV%6fbh3H$N2%+F=-$SkV6xd3TEFP22;m zZnF#4GB-Hg(czJeny`M3fmx{5un7@K4y>U55R&}bI)m>)E);F(2#%FpHmyb^%EfvF}IPm?_J&GhXr zF@5Ah5j4rg!4fa|^i002Y}~n;sI-PNsle{dtmjc^evD+a*P^i-ebsl1PXUc2Ae2y> zMD>NJq68lZ>Ow(t<#4{B@*5-dAQjSh#{-C8L)PITAPFsEx7kv~$A zOmIQiObW`g@@ouXSeit{Iv5S>MYxCEn21NnVrU=@`MO;Kz4bAi;Ah{CndI{I#8Eua zv=kzca0;*BI9H*sbQxh_W_t9iiB+40}Mlq5j-%@h>+ypOUA}pRSv2LmI$|pNHNtR96 z{m2qOBXo3A%9%batk+esR&!4=ZAJYYnfsA0)WQF!rrT=`1E+H*M`7>v>>2`HP z*&gG?SH5>+qyLGo!9LJBfk$7}Jv3I9wKBCI;KH&n_31_{%fuUhAH+H|xipWjO*s+* z9-~R`R)2ZliAZ*k)Wu-{m}xeC(STs|5hNDLU%u@TYKECt$(ankAe~T?`H|~3R~)I# z?bpb&iJ#m`yO1ZA(Y0AqASp z(*5$YG2UfV{38)d3rlBIc$pW1)MJOo%7rD{GT2T9GygFWkAK}q*P$IC(z-gG?UtBS zaLjEtTX*-K=B4_uHGm82HO( z70#KPd}T07^s6vX(k5HPSQ&XfOez^-Sp0WvTh$jf4b)1*>sopJ3JXU^f9IfJJ**Gg zlLMe4+D9{mR?DY8Ofbh1_dNotB~8#LnNy@icVbTrXY-qBPC6aaAE8-r;*Yd-QsPh3 z4?VR({giC493X3sH6zLGwHitcUt-F}-Kq5){O&nzESau(S+c56)*&nyaqI@_N?7)p z)tkZvMFG)qf^1eJwAl6si-tqSzW27$^JIF?RNmt!GM~ijm>Y||w1lp(mo|k5_l=yW zlq&r4NOfQN<&p92#KTP235r3$X!_Z-V;)PndsBPqKq>i&EWMT&Z3tLk^pXOIWQczziS!Fz+mIh*t%|439=q4 zn!ZqGg*+c>A9_HaZaaNMZR4HbBFgM|r@$ONGiJ0+6Ko@`^lcB}w0xgQ6MATqz!l4c zm6qunZ_R4yMgFzHG4Ck#0Bub+xF(Pb6|;TuG67y0ei_~xP(4byZJbvpFOid5m*?^| zA%`#TfJQ&TjPmym*9RkOM8!K-i^9`{5shcg&TavvTngnnMZrQ%4HVX{i!^91xMo$A zrT?R?U!=|LML}8O2)Iqa&eV;w3F>s%OaYyhi}mdzH>YK}8t^&bm+L4Gy?XcK&)2m( z$zGLQ#;g9AO>PwI!KF%HIk5RY#fWn)lUBcMOP9M)$!kGT=LJ2fvmmM@bMD7HFvDF; zQQ2ssZ_Db6u565s@#_@1r6cCOCIgjHy&6?(jj5!#l$gWr+?ZX8zB4|$G$oqjn<=pB zMLQ2A)-UFaw+YS;e&oMPO!THprjzHXgg=a2zfFor`XxAxC`I^T2qg%Yw(*mHUA9aH}ZAG8+l1@QwcAOzfJqd+)|u6r<;i;$fyv?*Me!YZF`_5AFE1Sn%uB` z)*K&fT`w@Mi*vuXCG8_?!<=Qcl_SWA{Iu?Y<;yKID0B?13`uG?whTGP+SoIBeOG%S zhh-^Z{giuJc$*DSq5 zuJS?!Yg4F3=Z8T^k3+=Op57Kup)g>0u*k?0>ruq!W+?PN+O8aD_=+(EF|Sla*7`k{ zNxJ9gTtx-kFhFOWJ`sjIl71-6mxz{lf>^$BOvlca>c6?JD|y-JB9Qm zqE$u%JCO4{hqS*RZP8e_37L!fMmHgk(;siIr1kS0Z&<8{vA;|#HD$}Mc&sgs$Jl5x zJ|*`Cne~3m{x>)IT00JfYF!OTKv?+D?{($y00;99y5>rW-{t1Lvl^8YG)$-B<8wjd zHRWykTgOBxn(p83_m%QBw(N&jEPhQZm2e{->%@UTi4NOZb^qXSLX;{<_(CM=7P`PEb&++>oLCc{1x zO-1Cc8j(dw+u#ITt;dj=2=pN7Qag9x@s5G27draMvs?Tn1EGtD2jqrW>5plj$5@tr zj7xq;?pSpz*6T8g4HqU(BlKZ?)E|nhYCwIZ2m@5k{ZNdj@l$_0I~(}>mydu2j}0KD z3uSCG3?0lGN}|Gbi&^9U`pv5|(pjovc~AX8I>!UX3pABXZ83L@3qd>PMS$WSg?Ngd z`A)j{$OChWXK4&R`PI;k1>l6}EN7VlvPyY877H;~l%+wRMaP)VyhLkQ; z@K}_V#~=)>vtxpP%^5bYX%}J_oBnb)*Syl-0pW-tI*DX}9{Jii9 z%4=(j-0r~e1R1VKf(7Yb;7*2qwe|KiX7lTxerRlU?$paoe`a;Gd(F!L73l=ehw&aR zZWB-3;2AF`3UDtssOm})9i|H#0QOVo(qT;M9kKP|#A_|Rc_&?=2Man}UG#Yc+ZOB< znh+&MJl|t*pbfnk0g3dsY1fzxaH^w{gOjs0*e@_dPVUC5KgIcheXNy}pG`Zo+T(Cp zUvaNf*&i9q6Tkj+7!n9H*j>E|^7cGcI^uTeWzTKmfJL2L%B8n={(~pz+oFZ2xO!K9 zS|OjGA$l2SmlF-=FqH@ilsLcv5H#A>3+lBR$~t^hmuqj6d)h4WNVu{R$8^yA}ibf z!5aZT{*z~LiG~zijB2K4g}}_lz@oxcfDEAabcA2GOZSehd8O8r`7>%~r8(51_uH^O z_OQU@qaaj@eNAY|MN(k3SbigtgASjKHwZ9X=!HGANq#0I@iuVV#T91G@RjjqrP4`s z`t#2OGA1A20iPuA(Dziap1rx=zOY&ceV)I01&JwX<+9!3RfW(Nxk{GJva=c7M^{lU$}QkZ8ut z$QmG@WuT3Mp8yF-LCuU=2%h{1Ai``iP>45cbpj@HC2&k*p~bPfqr*^XR|buIVG;r; ze_QWZ947B&bb_<(d7WL`aBHVU2Jo{=$Q;MQ#yImoc*autAAO8b)cnl!A1W3eV1Kd; z`l0FY3k?j{Y*Q@m*toZjyAsTeGXp^?`UGuK*YwgsTb==~z_z_(g60YDuR8d&rzxOy z8j{v2CvHDGcl&2Fd%N$J&Jwvd|yAc*s-Bb5cj%(8j&Lzx(NPX_V;f7hHV5TjCf& zX;wjxI)g(8r71j>8mv(6?o4Na7eDoUaUyTQf)O;gM`T^T%uiR-zbHDIsw)r_N}@-V z<CK<{COs`4*XvPI5P<#jN0;D^!n z?<3_kmdH!cESG&@;K=GD-KGXCYxQ2LVF4Sg=vVo8k%U~fW4##E>&~`HZ52VR+Y}>( z&qALMjz8Ah={vVFXg5Bma^WyS%fkzF-2dS*f)VL!%c8EJ(b#`6IYgix$ zeaLMQU3PC46$Zkr>WMpoK{+Wxp7<2$52iTZ#7+o4-KR1MY?FGB{$Pw{Ciy`ctGlu* zx4DG9{q*8(i!%23!yZC-tbx4~g(XiT4EgqHi-dHA^J|&*<825jmSECdA&3IJm6+n^ z|MI?`A&{7*CR6S`Zy+R(<`1g7>DUV5yzVw;_{kju<>z7PuBc{$p8LIIuw~a0=yg*WiIUmx?`RF>!oT8iUQJPc zFM^f4zPkAzDEv5~Hr^dSJlrmW?dc<|vRH;ZpQ$zCv z0w`IR1p}pi9(VHC#N=0&+Id=Cjf6>A@@NRSzD3w z>wU`)4e!1$hNE9oJ&d0$q}6p@u#RdUz6?y+9S=3j5pQr2a=J%Mc@xVxcB+=piMJrN zENIIepYf+u#!N;}qBD}Y=PZ{hqyB9okEufaN3!L6qg<(yaGBu{#>9;QGUxfx{(COx zvgR(Shug`J%n?@S{c!-*@AW>+E}@#47E&EURuDof7j}vH1s#=hDmjXWzC!3u(bD0D zSR78Nq&H!S0St?U6I;Tc$f}RzPKre|wmwW2EfCAcGy_I=7PIH|U(*9-jl%sKf?Bao zKehCPyuw^KIG^WizK@jh!B1hVA$h=<_Dtu)0#_=BchTTMgU^ESHr>O5bROYAV9e!$ zXOtgfZ@*T6go_+geO3EZ)C{6=_k1T^*o{ewYoS`co^$FCdoUHbUvtq&H_1AbzX+gY znY(Rx`A2+_o?TPPJmAdGk$&;?65)tPSN?tYwEo5T+@`w0&5!|K>uclK`5R|<4p+3- zTGKk{>Ng%VChW;4m;G-AHB!7384{zjVn>3Zes{giuh=fL(?&X~NAMg=SF@U1q5P=l zDVIW@Vql}9Uz3TRe;Y5-=?g$hrwM74nexW-(}miAjB?%DYtucAaeR1p#@aMpMJ0O2 z?PptoMbbZd%@iUy~1{DS!@8lOvzq^%Eg`~y=Mq*;}5xAXnZ*Qh&H=^jr&F`fm|QulT= z=>;^ZJy{n$0i)b4_w+PRpSpr$4`R)fJ7=C%4Vt!P-)AZ4Fg6= zj~?BK$Ovhv(I6r%Dy33>pZUA*=Q)n&@9mH6+OF&Kj`KWUriIn3Lk`TAJ1@UoV;=|J za@s+2m=K~e&vEe3#fwZP6XRIV;Wud=TzjLKI1D#~sz3b;i_m^A39Wi6CDe{w6lqYL zek{UVq^KCkrrWA!L^-$0-M(P<^~cA7X9sEEyyaXEH_lk2NMl2OK)q{STnq77`O+a* zEo+D9pTD#&cQn_y&*cjF;}bbnThsO}EbZj)+RH3{3I{H}p&!?+j>`nRTS1doFXu+6 zK<+OKpIn6wu1+DH?uML$l^Hi(o>kFTac7IEuI6|V>3q6&6vrRatfiYUtU_2fEQ)8ml`5Gvn{$?XxJ&6ZV+kmC8(=wLzmXOANV2qjC`VFeOxO{9Lc=3Xmqcc@ zNw!VD)MSnJq&oE`*W@8}j>6Ih!Ts1%lf#SybmFM-;vn*d^Jwbb5XQ0Gep`cMPIrz8 z>W#IANvS7uW2djh3x^$Dnt=-90cK;BtH~^^1rgyXXn8iX!2)=!iwzRG_>@`yF6!jP zO*Ij-K`XlmaX;#CTN0^?ZMvMuqsDtvBK2E`wS6}uSd741{5J@-_MxGlDo-dUjymu#%O;LMr7ZEZb++AZH@;SwK8FS8);PDo(5GqY%Z&#H(6*MU{&g{2np ze34Mj;4_1I9~aKS<0CcJemISq;T#^6*ct$pUmR5_0O~{I{*LFP4S*~6h-B2_pS-j} z>;Gyr|L-YEW4e+{h{BC3H4c5SdaeJs8Cq?>qkaEHX8|WGMyNF%JL7E-8WoegcmEP0 z+H6$?l0vqLa195EmgIhX9LUCCtrGN>lY-Hq)J6`RZ+B=X?PIVLdvArhcFL>Ut?v{l z%igCp`?V!Q{|)%=sopi-MNI=I{P_nnXt3V1v_En1Q8!rt4Q)>b#Dn|#q2(e^oXt&A&@(6}ggyXLbanzc(Yi0E5rai|`+_`i|elRYykR-DbJ7(u#$f<-E zjv<>9=O03p-5N=!`3*^fPS}0T&OsgLDLmhk>U9Srq4`SX1wd3n8O3uS;=1b*z{TtE zqvZgFx;v_x0Jf2ISyM&<7eZsakg^^=GvuPQXOifc3)0Q)!=b~`@B*beEA@_AynU8r zlE$9x%Zx3crcPvodwZV(fU3s7Afehw!t#AIRE3f(;8ByF+yD(XGE1@3vcDB_5~R?U zWx()DXAY8L&htbdWGYPF)+z&({DBv~+W9Lc=j2ueg@T zbzr~UfosjgnWKxgqs}Wib(fb^>$ly*PkE@l60aqh&bh9D6>FeWTS*vY--kqagG`7X zpP+5$pW@y0iP&o!>bZxQk+uq!d9!lzwX2bZUXqvYK(gw^UWyd!CYR;`fn}%DWa>|7 zO{&+_n)(1Hrtp!rrwC2vHMqZz3N8~(T&fgwer!I(>6TjFxQQ#rH27tQr*yAUb!RoQ zCEgs4ZZQ7XXE97|{;fnGHBEe^WH+hUiXkKlc3$V;Iy&xOs|m~9OtGK!`wgi)tNsRM6q)nbququ<`XKd^=M^}n#?)*#k3633?ycKuo3HA{E?Dw^0$IAkn;j@u!X zY5qLQZrVNhUDeCZ5N$RNPTNG6ZpD=cP|A+l1iuY3F3(Zx6{ALOg-WYCXwM)LfJ5p{ z(aoAp^ks7UdV+f;Lx}D}!(($}%LNVpAaz|Qg;p1C|Ng|nv+I=`XODcgN4N2?9+yPH z5!vED_gOr|!>GL^xp&T^Aw7X>Ibo=K0qpt8lT+x-_k*`L;uwO}m$eAb*%aQ5~~WJgt}0M@X4Hp8{puqZ$urZ@EmiD+E&l6%U>3^Kc;^kaRt5>Ve9r&*n6zQSYfyU*R*CSYw+)XXZKFo zYMD=jC3q(|^z`b3J~G_1N}S5`F|RQ#0~Q*Hl}R+m!KAZ0N2q$C(uwXDsa}0h?{Jr{ z#LGH@>3$bg6nLoQg$p^Z_7h~FZxd^+vv@B)AaM7lpEbA;9ZvO}8^o(5$Nq2?YY&SD;Bv46j0%HfNp#`R zckFY*&y55p^Slp^W$^e`cWLU?d*FF={!b}KffnI;+@I=?7K}l6)(3;SLp37~L*!n# zY(YvBJC}m;)4!fI6~ZuKbSK1*({xtUKHVWd%Is#xZRIb408(M~rNL)K_ENHVNV~yT z8u2{o*W_w?N&y`Qy~BA=ACFKsA4GB9tH;eEU_DJP&Vr-*lc%u;l?#WgyP{6ot8{(> zSvn5oRHeHl`#GTZ4Yr}yGvfSd-e0(C$xT_r4i4@$g#DF)#vC{uK5hZI*1i>u6M1Nn z?ZTJM=C@6L)DFcr*z4Q&d*L9Z**(f#I4FN~DEs_Ah?#+>eTM23QvC7-7rD^N73O(2 zgVS-=YLdg8LC-pab>9g8sBl=0>h;-WgD#Kr^Jorfa_-xE{ZAw1D!E>1bd!$CG}LIF zMM=83Lm=K=D^cwXqb@IOFN}%TT$+0WNa+?+(T19Y`A#EZdv=1q-7{L>j|(6=wcdKm zJbyG{noYShT^LY(Tl&mUWAxYV`8X*__P|J8Q$+kTy{cqMj#{2mqo#%!mLoH#_jj|M zecdEyB9DPNW)5HK!_(ub0&sbaqCk`U9AXBG&)IHghaJ>>;+e$lE z^ywqJ{dN-DUbEy%98Vk;$7KcwEqd0|sNu{Gm4bZ{Mii#FbCSGK`mU-6?St!@vB-@_ zaAOls=VJ}5xYsc?l`f#6P-X35)4t~jd7Y)c@H+^G_47B}4foR|BSw~Cn$)V9O7>yF zc3#rke~zMX2kYmjuY~3Vf&u^G!6H>V@**K|I^&@Gu=ZSWw?_~@W47R-l_a&=C17zy zbMWL7E|U?Aw5xpXbI*k39l;~44N4TZw)D~u;&;qXDIEu@34q%%WO2!&QxaG^+Yf@G z`evT7q#-77mLUnbau4^F9TH@RxbYE#q+(C1mp9mE2)H=$U~(HXEf#DVB@tKK7i}@r z>jgGl=1tj?iKA?pW*&riaJR^nvzm_nhnJKL<(d4T$>29Lmtnj*@GfiTN1B$}xM=W!c&wI*_Wfq(O$QbZj~y z%~PGQL|^sUK0Ptea|+z#22|evV_S!{Tps2yya6^Ip2l2@vpi`;9`oFmIIqgpeCtfg z*k@j`R02j|;u9xcxA}{5d+C3S*Dlu(YxLJveR_+-kPyIxb6m>;9A5QqKQA}sX+Y}- z1j!t;N$CjHQ((11hHStP@k$bE37=gC$_jVXp8W5zaUz0I=2+lXOZ~iOqe;5?s3coWXj*9~E;-5PyTP_}C z{g{s~>tg*W(`R;Q5wOi-v-?f2oVkr*rHHd?A|^<_Ux?qYrgzqgvp;8~Nj7fSKU=P3 zCanKKt*mYi!=R``vxQ!pQRN@^n@=ZtixM45ay#Not^wPtLA4CVhk7DRO>tzh$` z_s`3ehlV+ID}9rHbpk`*o*DnP=w}BSsk%Nq>`HmM|0~w?Y;VaRvdo>nae9H`0nZS1 z4pLw8jn4n!asK_yKK)d~Pn4LLB*Sd`e4k9xTRljh_8@o5w4tGStRygqAvZS{fq(pgU2DRAHzlSMMBp4LTwe~{%fRP z-XyNRyd_Mf)IsfsYl#IZ`f+htE`IDEM--mN1$i(YXtr+vZ)pXg^QnDhC)rTNmaz+T zgo$uHl>D&r>u(b!O4+bWpk`o+Jw%+c>{ErS4S0!s*p#8ik&1I2ZI+RW-L0VxZaJ+Ku8FnJuIE8|^>oGi z@NjWT&^Pw?Z>$sG)4UQnZBJlAcYk_oA2ZteE_si!?4nC7zBV@iJqqUZY@RsG6Z4N~3!ddFQw9cq$8#t(hT=S;- z(euTvS(QpZi?u@9a_m{9nVlmhe4D^M_nDy(AI#|9I9O9fimS-wyEFly#$UGo$(!`? zR%-OWyah5kwuA-Xz}I38jvCza}>^bt`Q^QB44`GE;xUIybNY?g>cl zKoonK+iGOz;JFJ(1nBvx<7zQ~u;!TSIPcxH$A5VE52&xIX*PCFlWSO&co>N5^t|>T zz?FU#)cUYK&N&)zu?~Gg7(pKcoOg-+!kN1xs9y-&Q;Fg|b(7aSv$e~;%t~=bdYgpy znTVYifN#s};7qC_q-(zvK&=g}<_Q>aSQeorIDPGPXMAqUQ23JF_XO046myG$*ErXN z1FCH`^62C=;oNMd98Gk0$zBtw&gPI3*q8e0KKUe^_7#=G;%GEpz`{-;ehLyBcf{r( zfLp+|SQFK5!jQk^a;z%W`5GSK0Ibf99sFC+hF?k!txq!|M^zj?qyRq$MgR&UiDu;) z4rv5fmj$0zidt(AvLy+&r1G-Gxq8QvIHmD4eu*UXcu_tAe)`7g$VJq@qA>5na5pKZ zS+dV_>$bO%PKj3R=M-hrZf1oZS~nM920TM1e8!aSw+|nBy+LfM2q&Qk$}#6ZQ#u$> zbW7spEL`q6O{O2;kW!K7@7Z82Few|R6?-<9S-8r`#VQbU{7}<*-~`v|i3fcMfA3`S zPxVBo)%$<8#|?u8fpBx&NPWe7v!$a$A>WAzuH-!Kmal|+uEEujfCe=Q{uS@II;dDp z$oZ9muT6f#+Vhaoj`12J#)PR@sqM>rOU8!idHhiX3In~I^lC~dawwxAsOgUd|+jfyLM##>&_N&{# z4|IBy{<-mYF9&(sjV&u3HpSyxH7dF*^vyL`ZlkS8s4xs+#rcE6;$+KG^S^F6+FLNH z?(N+{sSkBUOQ?6;jJle(aKq3}{@JF{wy?)nBlno+niYx{`Xq>h1`ct6ri;jC_v|M% zryxjkh58w&*z{97Sp)I%Us~AnfDbiMsqDMctZePUMV;tB0;ciN_uagSG#0g&S9Ml# zNnaT5BHxuPIy6r`bkYb!oe{h;gW(ks^Hjp4<;qT0f+}|MyK&Acio> zA;$}vfEZ9eu{OW(n4f)7qf@(iE=TF0&cT+_9{!YfJ(-LN%j6sM*tj-izE%+ZymU{a zfM?5Gh)T5t*G5~i^q0_S&lHMbQ;r;*9-Us5q;O*Rm{K-yYA6elm`)=K6^ZdlgWYi! zlX2D;f{(CGTx}~XoYpnDx|H>+yZ|1aSwnN2XOBeKus?lc^Alzt$Z*XkG&`nWBU-sd z>Q+N^%;nmb<^M`r2T>qI<^Lf^dq%vVy*xM}^ux}%9I5@$^ct%^b@gpno^BzdkFi8^ zG?2u+8SPcCMX)_E^r=!Zu3i!o%{!%N1Z!zgOD58Ix4;R1P*)^v-TFA$MXU8V;B>sFH7Gw(P)4EG_z57KMXx&erZG zOVE^)!FU8EqZHCGh$knUZdUf^e#E5rjbPaVU)4l;W2^FZi7goZojonpQPX4t)G`Rm z=Ww<9P4S;WtI;z3T8An4l|12- z^(1)=Ph3L86N>|JJCz|lqV6n#pT@i&^u*#y5=N}w1xw!p@;a*|=@^!qf+0D{LKH

    qqKmaXd`WN0d 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

    46qa> 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%0DJzA%P+Ixu^F7++@2dSN z?P;GnUj6Yz#dxRw^YTT=I7%2s*x5j6>bKgTb!hDDuJh>TPN&ksH>^&hs(ojf? zPT_gqEV2fVJW!53oN|fZUu(-v5pg(Jf+X<_6}7t3-DC87nbd&jVBL%d#Y=$5d~Zhd zYZ9Ynio<*h?cK1nWtC5!lGCxLzhx4KP>8rc)4G}AN=gBs3~)yo?X?e+e-pTgL{G+5 zC1gWn;VjRMm)*^rl?OsI;+C5nJCcG1m7@yEJ!tHN@pninl8??Fuv^~wTqN?TFvjaV zj>^p56qqW?nD0zJ%pSNDT-2|4g%YJzC2vNGGmsRyn44b8H^gv4HzW>!tvmxYP|%MD)DTVttxk))`gnh$km;RAx^1u z?dM`7ojs{KN6O~dbhdGcr-y1zsI$t$P)qYI5J%*jYHM_ulJg$bVF>)Z_Jtj~UofN? zrJwkCH3zuKUztGgoNS|pjGmt_PJ&psUO4g;*u=(C^G0ZxU0Y|EeS*hjqt1t}c$h9U zaoSn3-B*ut*^)O@sNwv6??*hKP?>TC2t{T|NSm+%DLgrZ<*-|_rU-FGNYISVt#H;U znbxjvjAR}JMcGyY#RK!N31^p_-APyz4~XKXrpSrDMnh)Y8H7 z@CIqav*f+bz^nv)-}cPTfXTAac6N7swxQ=EI4x&(@gK&S9zlECR;-HotyO%gXa51A z*+v~l5%&nO^P2FhKFsy|Qy+VHx%8w4cC3gTICk7+D5%6+`4c=m0_o8*ZC~18fRU&A zPIpO0dWw$w^0`8c04Nk~RqDX}BrA;XMEK7=vh>2)ECS69GkX2HUEbcejLEzg%>9_2apKpc$!B_!Fc> zE>)@1(%kDG_hFTB-CrF1T$ij`g0o4)NzP` z5Aw+~LNBMk#f7`;y0GfkTYNmzUgP_l)L-E z08H%og%?(7l88P^x_wgZ(NC1^%#C9yB|mjQ)eN(?9mkCRMGo&+OyJqC& z!uVoAk`Z9JB;hY_f${e8EWF?W%?De?AL>W2qCp!hla)a++aprI=UarijN0dETXet zT3!wq0J&TS#xD3;igiMrNmH|dC%F~jf--f9DBD#8G%;imi5tZCK*LtdLt2#-A+DCVPxpssV^mu-|aR(+A zDo>b{1P&8Ni~*Dili_xw8H{?n(nAk$$uLoP{Wnda%iH#^qqlUrj0s`G0~?<>`44pM zRR@;G-;$tPLuZ26=Oc;vXY;^|i-Lu8odRL99ad%fmE52%4rM}xqCG8t_ZIeQ*Ki>- zrMKF?$|hm&@GGUkGS`kLr%t-geg6Q`U!jTk?kR7d@^uZYLw~#_Rg-+rll9{*$Q7!X zEzSSKKk;ex&L@M_YYF=5&CJng5>fko`RZoJzX?v-8P^K!HOjZ#iGZC}kuPXBK8~Uod%^a$s)|c0&BjQ@zpvlcsrIUGeZASIYUCU`9`U9HWSk=L z=}Rxiy(5c%)zF4*%uH`aWe6kMG{-(u!r^lInlye^_GmJ^5wmdW+twlLFyH2lLg)mVcL$86^ByI&nh*qLxZk7PUr z&H|X+`mkcT!~j6?XhWX92^!X>#j{sy1?cbld~&4b0%+L~2ANa>Z!SSY`O!lZcZtk;dXYT>h@|^Crf(N+eg*!AjT;wlFb2oyTJ# z&y1nGC+1&m`0Hp`!R#gU((p<=O_sfeRI|ly{uaZZXg>uEs#eJiNYlJk`ZZ{=4Hd)F> zDL@THh9}&Sby#;r8au^6Gl&F?Yx*DH=_stvY;D%;_G7vZqD6Fp&)n0P%2% zH~26NWxQvE>f=mEZ8?MrlO~15dHtdiKk1oaQ3@pL5!o!$(v*%SSHvK{M&Fhq$o@p1 z@4WQg;E~=O>!8_AopZ_ezE46hQb!o43E@qP)F+fZIl>b;7Ed9oloFSIvw8=AO(QI> znSA%W$US_(;5{gB=l&Cqoc;dK;=4X$O4GXA4+J&DK{HB`!_}W_{^;)T&E+`SB&fZ~ zmS)W#(gpn$JlpM@yvlD`qPDOJ@8G$KI_=!)adosyzQB>q%jq>R*n61&D>G{GqmTQ) zd$PNUNae5Mhx*k!TA`NOe(2HE=iyx|;Yf20vZZiyR{i-ru%6m%yArSt7uS%ydJazFuGcW`(x9GW*0DLkB3``;{vi5MGE7tlzt~R zU34e|#ch@n6&@v+%6y=e2-V899Gz5fE`@4o*`X3Vi{A}9cjB74OvFfzD>{)`{K6hs zJFeq83#+TW6UN*~5VAs74*;gC*rD8tNf(JOYn)0pq{X@28!T}V0V)1JhYk+gkH2ew z8xC$io7wo0_BnwiTwcJrg!{=P90=ZrJnKKG7azbS+2bQ z+&~ltKe5 zlTx%K2VUtTQ>6fk9e*$J#Fg~s9Ks?3#XG4HYl2~}N(0TTFB7V&`gz7ln6zs{f5IxF zr;3bwLy8joh97WnRRuYIK7`k8eG#q=pe#|Ed$R+g2Z%p-!aDW^2HY;AQrj-Dl9UB zsr~dem@C6~9Ga;uP?XTL%O|6Cf?mhwLI$aoMk;;Kk3QnZTZATxB*oDe-K4v=IY#3P zsftSDcQq<^O(Cfzue@TTMuZs#Zc{sN^bv`e+O`mi;0a!2Xk4V*%63*<-+YjMd2#W_bpSqdhEF( zFjhJVuR%52LKwNYJJIf=1X8x*J=l$1|JQ7$af-&8@0;}{`OG(F@82qZF{$9_s9 zr;++VX+cDr-s&Z$sRr=`0qAnftMq8Go z2sy&5Ejh5oNi?Yug{A+~Q@2VZF>-`p>GNd~`1dXTInlQ(&6_H0ZW z^8lN|Y$e0}oY#{n$ zps#C_t`D&3s{paGsb8q$!WA!Z&A9jhAh4qxRk>$!(M;zeV>rRCtpMp9K8&+Qzm6{I zuK+KgSaUm7k!>tTo=l&ieD=xK$21^|tRbkFshBPM_rSxHaQ%!T_wmTadAHPC*q=Mg z)}es=E@HIz1NIAn~B=v=3)xy2OG!WwsL#%{8wl)$1I@}@!ARX}^BFfOhpsRc<8 zh-JWB*q_KsafmUPK3IxBnYg}Zy{EuWlRn_;Eu(h1#BHu2SLJn}OhMGT6YBKy4n83l za%rIxcZZ2u0d5#|RF+)oy5GD(dLN@OBxYEQd9JfHAG~2<}bXn+{WLqd? zF2?*sIw8(6f-vQ9rPr8d9DmCRnZK|CeuK@qeaJ{+Q}lS$JDaDDkyBn_j*!Avz?4RR z6g@OsH(Ha+pxevLwx9)xfmH?s2+ZWVs+Rwm3}c*1s`$0MQSP|Q;NdTOj5~9`f6h{yI2yAxU!tv1-HLBz$tP;%Vlb-Z zH#1K-y@meQ7H{#YTbM>&liUax_8ZG(zO(%@lQ?@wUG=>?Nr5H z0P{8qN!(XTkEgtB6;o_$gbPD4`umg5C|XEg)z>yUVwt7cQAV7GHk_#Bt#MNwnVmV^ zfj=~rSe_t&Mv~Se#RTf3O^s})+jELj<*z)qObv&5WJQ z?85UuvdqBEwD^KtV9lPDxmX%Xp?2ix+CefDLRxn?e zmpmI5TOvV(UjT3b*?MG0QQw@pPwCdotw~f4$56Vb|uv+{{gl8$x&Uk z8$=`x_a@J4wK$may-(2Ao#*z&Em4-)a;Ft==V%3oE&!_Y=NlK3-YXV7H$o|E@%n3W zv|oz3GG>0e!2Y%=XPGd48jUD8%X2SiW+my<@mf*stY8#^xq`=9$ZL08lwn{Bp`wFa zDYr`8!I*bkXt2kw2htd;1Z^Dh_EK!lKaeWG>-P9Q+aIOX*RqsJb~HO2e)%aWYv=hV z(2;>B7Iw&(nZ!T9DJ@k8?M7!`&w)oqL5=j7%i%`BBm5@?<|hT+?|WuMLz3ML0wOM@ z-mK+W3kaVbJxX$dbVhPvhvt&5Nzl}>9j@M+1uNlzPI@<(`*-h?@J`k?h|V(pNV6mt z-msoguWiuXf9xn_ANG|f0k%XP6eqX3Q)|iA$hi=`HDs=JEhTgi>v)aEi6nnM_w;n1 zI@lwbU8g6fby*1`-&+-=r}xXUOIrxo5WsBI<4UcUCd8SrTuFz#kTF;QX3{uAa+Co6 zH@pHmS@jn|2<$_}$J#j6x=4#cUp#&@a1Q+kmZgV8gM ze17_IMwnvOhXDye9FXRGc!?VQ?inLNgk5*rs9iw2T=@0mN&}uv2KGE?^yPUvjupB0 zM}hu`HI8?<)WY6wVc`mM#L%T`y-wvsC%h)Q&#T@)c1Nrke}>xo1WK#tK=e?FKSc-X zL!)LSC$EXaiiG$_m~`aTOwkH|OjoBSgWcyID5Uer2xPwh`G?^yB7it1YatiQXQWmL z=izT`If6g#M3QEL4Z8o9{s7p6rUq>kTC~(yA1Cx5LhOhpUhPzvEj<;9QeX=t1%Fz% zNq+Q`(B3YkVo@|@N;R^uJG`#1Yn;deZ4fqh`YSl6<1U@xvkBFu&)Yx9fUto`9K|X- zWmRoIn6KpD-SMZP6`u8a@n}+vf^FN`(%y!S{33WP9JaX_nt_G5D-NvUk^kQNGg0Hj z3R{ZM$Tt;CVT=L)K6^pUvYqvm@9Q&CR z;c{8Ui=*JCFwpg&_}+nMs+BQQL2d?Y3st1^GA|y+RfRD7eH`antX1o_`3~JuDa5SD z$T@}(uV93^8IW;_b5l=(L%u+iGvr!!Y{2Wo$>?-u-huh9}i?9aREbF zuHjT-tP-(CiVl;stPTieqU2J+_pC<3U~7@A^4mlof8<|f-QGuy>?$6g>&wG%oyGz0 zJxloXxU{i3a$lL@ISkQhmCc;VzV0PM7H5^C*-Eiug~qGiwoOtvC>oSy8;4nZ7A=y4 z%u?bFvsQL1Fb{_NFI0_Z7w4B~gVJpMfuubkryuX&tV|B9QihH{ch(AlCgRCboPfA4fH==*TKM4v%P zA9AIL3&EnC^r12W;PaS~Y@$^4Q3}wzvxVjtOT6f=tVLs^L_tR2z;y>vl0Su-xz-{H zF~#q?mLOZ@&B$AJF$=OeiQ5P`$q&F^1bqreNv;ovT(6~KTjsUEUBnb?P@(vpF}OS? zP1@6b6Stcmn~mH^;DZez-C!Y*zSt~AIPL6>8#zIwMc*T!H56J`=9du56&VEf{l@ef{J*aJj#N?!2MC zpfJHTWv)b-y|@Lyf2)S2DjKs2p~QVuu5CwZ(el4yk2gX`%w~Dgm`Ee!(@gi1ltYg>n7wNDlW=v#u5+ZC}X=3Zy-MT~fngYlft zudr2LjS>QiP>_9-O^ctcrgeWBTIP3Py(&JGxmuo1T&{6!dG$cM8)K|0 zU7n^ygT5b8e@($F_%bAC`!%=B{xSNvx`{0G4EZaLFW<~0$vKE0EBif#Ez5HoS9loh zDkhLLL!6Ck@Os2@GRb40-9VWA=~J2OS>x#zvGEZwerHf))9_~8%^jRHSNU1Y=$llL z+9lDJC#7#yAau{h-1+8Q8C?2@CTl4kwJTnB$70Kf7TLWIG^A7`1j*F3B(;9xFg?cA zFZu~TK4joRKK(a3>AOQSHo@5i>B6s8^n@<2|xNw#sd zZ&rwuFI8lUS!^bu3rJQDa^ayYQsp3YF{q4|($UDmM#U?BN&ISiU{OLyET+;)>d6Lg z8iWZYb(ARf>B>cl^_h4sp((x_imsq&hdA*TV0ml$jRwJSr(CaJ9xTCd_)Uu7S^W8mk9-5V(P+X{$&EvMrUA$Hn)kyqupQ{U@&a+~ErCnq{ zR}+RLbwNIdkJ)*5GAR=sAO8c8dK`~(s!(b_N7WFTQMs_Dn8?#f5(Wx>v^%IUw;+o6 z0zRNMeG`g2mf7kJ^Gq`_Qm}jXY~K6A4`CsRMG(C$d$xSXPen6{6E^hMoz$;)H(pB6$m?|ir-u78{(*fe>L~m z6RgZvi1+`obQXS1_3s~Ez!=>PGP=`ILvVD%=uo6_G$@?|Mt2#6ba$t8swgR)7Kn66 zKR=%5`}+@e&g<-R?sMPo`?^fvtdcT8_f5)Ag9=p^xyyBz_v2_-!Swz8=k-%5`&Z4=gQ%46apFG+~Gg?lE*Z-ziWX{m^Ylb zlz-hm{6RXpM(g&C>pBOu?nx3}WlX>-_k5$d9e9e--%|rSai!BCK8T%Abe*q=xJu=W zCF?zOkpW~6sA5`7J;bg>-2EJjZhFlq9Wtgmf9%3}a<#!-n&(nd{?KE7Xg_l$Mp0^S za!ed#C!Y_lWpQ*ceU^VbcroB9LRDoNH|P3CIx>wfzNxP6`o$`27MkeBYyZNs;_zwm zB3>IlwyvNrSi|!3nCj?C<(=_6f`Piv4K6M5E z{JzRSxLJ^N{8L!7VS%h&yHgWwp?7_Xu?nUUR$vpk(2=tqF8;PG%F}wKl>|`5Ad%y~$m4M0P1yA9e<^g2?DQ!CQk7AjTNUUJnJ9*M;<1Pt_@4VOBsI4!|3eZ+t>2 zqOudzK%ES}De_8$X1@VWxOTmnuqJY3Eb2Yi+@cYb8cLLA5Yu#$0bkvQ7@f?fGD323 z@Rf*bVJW|IL^-H2)?~6ajZs?7>{P6L{vUoc7TF?j(as4}0P^{%=8?XQ2(@smck}eW z?#zep0OYKRP9vxHrlT4qu%6_8GfzF39x@`0GZ4`ShjH+(Cn09J=U9%WUBUF2_IV^RaLV8QqI z0J`Ev3MmvDEfq;0K^liuan&v+pn&Vi%L?-vN+Yj1T?mNghsLBA`Oi!^n70}9VLJBs zwb!<2`!I6?mX>;(oX(!6FR6B2iFkIwdSUsY*EzyDFGxsa0NfTav;4`1Bs`IMay{qd z+IHOu>%)5q59a<#ne#-8;ab?}WN$VVWT1ZAKsGm1JJs-@#Y||7HE3TOFvqWet4Ms}pP&Rk2 zF#dfTL19W&MYx_Qvz>H0A@u1tDArXX-#Qup<70H~&3%7D3wAH$GcFGZH^)z_UNW+B zH;E}P-%KiIh>{sx=iuWQ43rlSZ<_*{nO567RgfS|`awGxOS?f6+=_vOUdSzYs~43I z94g-mgC(ecp2Dwj<&0esF{KxvDF0jD7WfGONX(&c;=&Z>Ly2A;DM0p(jkk!XniyaA zy;o@O1c?2{G3uME;qO-i%8N*K2CC#Jzuj|{|2~+ZNmACdZ!%x}855Ccu#7<3IlhbNSgmVAdiQu-!@=)_&4C0T>!j0!<8HJ4p zh(rl^mmcoeI$Q_!nSMT9=laS$;JuaVwM0}YuFUGQx_PeLr$M?)3RL21HtunTWKmDA z@sVUQka~iCDRW5d!8a8C;*Jv|7g6wmS@Z2dkZm)maa;g&8xZ!;;n}3|J$+@HY}lOB zA#9sCP7?qwfCGx@GJ^KIZw$Gq~s2eq_? zc1rVXKZ={AWL<&AP3=*Uq|Ju!PqzG_!CsH@SU1W(3u|;dqon=>o8k)Z zA~b%`R^o#A^{4<|!)HDt?PsU1M`48xmG{b9uJ=ECg>w0%JYRZqS&ljlKZLEDV()`e6C{?znoPu zsz*8;g+s<-APma;@^&}RzkoE)IYqOZt_1thTmtKx6<%1#RoP9hi4=}{^g#_=;nA3I z1R`f>v>nwcq|q3Iu(D{-WS|6;*qWiw73%e7I?8sUk$BI^yerbC?sXjiYn2#Y1M3!? z7!#0-%j@S45#W3{l8Dn5!f?eQgF6Y#(BS3WV{@D`$FZXYduh~#c`=bO^pB;iu0NB< z_aQP6#}ScWP~jnG>lk2y(qdkxQEUu82%FQ?mh{3?0Igq#6XQ<%_-`Xdrh6IQv8>ds znX#gpP9*cp6s~w=nP4VpnSolsWs={faXS!Q?;4RrXM7simjf{lZ%x3!~SHyJ&!ji zZ5Q>l9>`FQQOOGV^2Xp@VV&-1kA2ghN^=em`OuZokAn6R|`s#4Ztx*&B^sJx3c~e_F-y+TCNK>I_{hRwtjA|b& zg#}c$ORe@i?J0^mV>!V_e5+tqt$}(rd*E#Xu1hOuQng!bLr|@`Uzy!7K+4Ib_?BSM!K(@5V~=|yS^6pk*6pJ9TfVkx-pZu^u~FT^M=KDxgy`=W)S%bLLo&<+m~{;!?1hYE0RLLy!KCzA?7=sm&E2Tswq| zMKF#ER4XzbK{NjG2Sa1h+p37I0Rg&B$Kk^ze`LA zG^;5qqgEF2#EQ$llLYw*;UFkSk~hLIg%?KN;StYQ#<*6xRiw|o_BXq6D7R@H3*ECi z>t2+Gxz;auU0#ydD@*2=aW*eP)gd={f z4cwb?LsS7^qxxqPJL}(XK69P~Hf!`0-hsn^HU9#D!b%)-f0gl0jw5SX^xvz=c%oXL z0Y$zJWnVLerqK-AKziUXfv4Qjld;+%l%eUr9-X+|w+3JH99>NWDAJBzeunSf*rdp< zX3zQ`ocGmvJrKaR#IT5Et2wLl8y+|kxcY;$w8YSn%oaNNvM=T?j$$4XwGHlt)qvd} z<|uSC5V!ZT(vT3&_rJmMO)|8hw70sKV%hL#Y9Xmyk#*&iZ#!%^45>~Hg)fJ3r3i4k zQC0yP;1(58!-gvl7$Xx`KVbhMwm239}v3yNy7J3Vn6{ZKC&gN z9_?bXD-^ROf1JCw(oM2DiANhvr1|3mNqXS{PozbeYh~|J`kvC9=vsH$$o?(rhriGs+@yY{$&Oj!KVwTS$?L7oQbHq6cDXd>0_nDV;>t2;FBOm9e0_GR9JZQPWuJs{ zQyCNBS3f5U*t&n;0t6@|LNMenpZQa`^YH`n4YWwluNq@6t=@_MO>|olaKdsh^D4fk z-v4S*Gv9v}OL|Jb5Kd>{dGMF?v|QA!Osmk_Q1LjIvF0KW$ScFS;TYb>(h6Ydh&23b znqfLu;9dLWQR3N z>{HmGdxmbV97MEsiU?<&fA=JZL}DbDTo$k7CxMLB=PJ1qZ2!W};P|-KLKAtf?jag~ zSS_b3W5rn)FhdB+p73iK1P%(!Uga|5g^QRr0(_or$9*>EBx`C4W4Gwf{3AO%`6`yk zI+Y^7%vtMS+{RjfpuOP#l;+u&Ng34lxQ(z$;F4%3vCPuH)&dA;^nx=F6i?3x@I)?f zU`&9Rh4B)d7-fWo%CWAsrJg&HXK$jup}Vx)DJ6#HSEJEMMBIa-4N%AL)L67V$PHS- zY>AYeNKAh72B}p&pgwGs&tk`iuyPWZ{Rx`a+5;Kl_Zadk zF@-z;xU3(rA~t(N|Hjw09l%cSF5b_#=NkO-dw4)X`Hi~+cyW)bO5jW7-JcBwu~b8c zh5hr&=xGb1ydi}kV^HOQ^-*W=ftF_^DAL&Y zMT#Zwi+9<)M9(r9-?;N;Xmb9*GTOj}RkgI77_T-%6^K;_|KagH{SN z^|KV6P+Dqcrqq_1!fey|Vi--dI-H7RyLgD6#K*VU8Ihik1_XcCM3rc~E7VA_U80Li zRf0f@*)aT87Zg4`3IsJIZ$@ciP!_*!gYWCgOiJ=?ByX0Xo)zSkE)uNRw2BvU0(j3L zkLXO6e9a0VrP-rOOA}DbNmLqip_0S8F1zab=3==~icPY)zH8frps(Gn^wTQ;S&sCe9Mj>~enB#qGw}w=DRb zhc-i=U2CZH;mIzq4damSat5(Pp|8p0eYZm<1!rj>y+OiBnq%PsEz$OcRk(?Okg{suCP{~_0{O|yn$6md zd9n2e;0A+Dw&Y089x8o_cu`O01&3+oxotFjMU7*sEd5g^iit68j4;?d*&+MG_TYA> zcP?+B%R(*g|3wS_NRj80pK{l+l`4Org(V^Yav!Rn)unU*-JUP-Guss zVCca`KD71)gA;-xDoif10{MiB+H|En%#$maf+3pBNc`9m;UC1@Pl|ISluF zyv|9QJSMKD2aUh;G&{!zfebcUhEl&YOT2BBd@Heu0^t$}_kXs2PSoWLM`t}beD_VB zjSiPrEk$4GTiEA&w7to1jR-2S~Ke^fWX zySo>&`b$u#?DY{oso5uDd!~f!L!WkE48Q*jAqUVc`AA5isfyTc(m&Dq7o_QfoTsvH zHiHh7tkB>h&0@*qh>5TL<@vquV&O(ARw)mcdO!D5}k+@ulD5S*-pmrezDLmD~4q+*AHb&Wo<=wWn(|^ zid2WLf_kq=^ejqBFexjJIS?W0qVxM<=V#w^S@)AX`24jM?PB7^bGPGtuDka`uXYA& z^2{Eru&(DytuwxHjIZmyp^!cf-$c&6?7aLI^)g&q0Og$V2bDRf^YFXU4}XaZiJxhS z_g&OomZoE=QI-4trTd-1wud>7$|3QE<>Il_Ch3buM&2jWqqV$zS&9&1&%|4^kE+nB6z#KcRQz3kaZnd)}-qNDX#2d1Wg=7V)=%L>$s6;3=K3=d?qy zj4tGiXo**&{lsX`U(t(@0XO=yW5NrZS3x{JV51=uB92sZX0En%qAWn=kojX5pz)wy z<{q#u3?m}BP(kT@>0)L1S-W{IZ4*hP(~ zQ*4Rmu%RW1_Q&o=Ef;!)0&X8w!YjzV-2m<5Kg(aAt$(%L9uSoX_+;(~B)`zzP8oUh z)syfwuZ^Q?^sx3f5Scju?m9_v{_K}7;hBbkNz`(0Lb$^5WG%yj1?z4iF}?`JDiZ70 zFR{5-v0_#BC>#OT%0x;=3bTak65G7%&=e~&KR+(RH1@1VbFSUG^sClj=={00MHHOb z3QLRSJg=$?+v=bBuNR62S zsO^F`vhEwt%`Tn^Y(416|JF%?%Pqh7QK8GB%Ofy*r`2md{cYzPLZ244V188`K4C$u zach>`Y1eqf_J`e#;C}#{Yr@4tZ}|Q@^d&Ct`GKr%+F&b?Hi(z>(@`^Ol8dC86PMsc zWmH5s-HXQA3y&4L2wZ$G>4(AQgTI;OH|;h`2;z}z(P!w z%|s7u@U=f@z9mo(0RsH0J_nd8*@YLt_--YjsM)1!0R%v>li2@RyAvr=NE8#5TUa?s zspO0lBcg&hADav)gb@i3q!0-{k>6*>!Bw!Lc|%gTy)A3Ql^7VDSLr#DUTu z(?WgzyD>4XlPde=W{n4?l=k3eSwKDD)!UI+lpt3|Tr9vYL{*fz$*KgQiWb8=H{vdm zhwVwW_svu|f6qfLlqlht-lhn)mMWlMJd~tOQ9=zwlBD_()0RAV`Ev5d5=1l^@JU}G ztu1>;$1FJtha(j$n&|;BWafwOMb=;V9t$b8JIerY0Wl^v_*U(Y8+SAfeSB~>Kf{TQ z!uw{QngUgT6l^9=uM6J*G4Gl{}E zM>NFjw&~!>&-%GHBJr1=(|>xB|LkBwQu7jEk8dXG+B$5a_oB$MtS0*kCt?G!IGb&E z+r-|aYx2SkN7v=O*uD?Pm{v^L09#CKxENtG0qOV0&g{GeQbs|$-V#{> zXewaV&18;f_XF5d(l-^K>XD<_Rz7sldHcMTG>O?5Dj1`pNG0;A>f|kkGMA}!+WU&x zfTGd4(D>3k(*f;7;0doG8DUL~wGtUo+JiB$l}abLgeuHP23LdS$$MLh>HYWpz2#h6 zbeig1`$feq1Ji(!%xBd~_XFws4?+lcg_ipN=M)=_cm&?cJQiR8(2()BYWV!GJR}#F zz8ct-^r8V!Bbk*4Vnzkn!a-Jgq9m=;IAcdn$O#sTg-9%j>IzI0mM~qV7oy3z=vx?e zAir$vjV}nWRpB9`Fq~B7eSevdNc`lUr^JSl@%_8P3j^d<^p^3VCj~;mb z^(1!H66@6WwjgVHla7G^jq3w(Z=f$s?)L24=w{ z&ZhVn2(F*Co<^z4t%KFW;ZkIsW9E83sfj=UYC&4^k3P)F5Y3ADB-1JnQzXDDi@bj` z`iA+yZkIF;A2+SeQc&j|Ytx@hO!ogNAZ_kOG2yPA zx(j6W$m~@@Aju=VO>9GiMT$z~eGYRTf-!OWhGya9fqr3S#&dOM^I-8#6gR7zV!(kZ z=@FPl0>@CeAjpa{EvZ-=o)xzmC7kwj?<-`|9kxr`w)U7r9w~3JtvORNvud27G;h%% zZ{GftFarc^<`+GDl5I6e=G5!c=E^`1JT!)LlQ+C=`3E2i^BIOydK(Q&&rl`uXva9+ z2nizktmVBdcz>e$i_ETHQ_>4kG@yHnuSWUul3ZC9 z+$B6xN6&j{{gkp~g5F-}KNgv&0Sz=LC7K5RSQ?X8!&S5;EquwP+EOJ`3Y6)i_7n#` zSK=+DUy7N>^)e7!TjeN5)bFOe85i3C)H>!w1~e1A8~gk{eDp74`a_CdWvcbX~rEC~Sc0?>CxkT#1r1G(Z>rRa)+<^705<{4lE~y0D7*t zg3AY|VvwJFTbiGi z`|+6{BHF?MO0O%n8l~wr`?N)3zmdj~9l?8%p36asX0AoQI_Xloc5=6y8M1Qd z?}9ecQe<;8dYo5CXCF}JyD&uf0Vp7j**t+vuW0+A5TAexiQ>zBKIyu{Vk(Lnk#TmmSZ12pHBA|yU&T*-{a;b z=c4Y+d7KFpm_f~Nb068rZEQ*)%S+Xl4(6^UsZ(bv=kxC@<@qI@o&n`8Kb{4 z+ru=Ce?neI#?7!I;WlO7PM0kvrnv<;J|!#^X`RNADMP5}j@G^!@yS6#g@NvTGG+#9 zMe&>>c{Ot*<0%b#IQj*ihs}PH#dcdt=!wdoJr^u1Yj5)&n?U%ocL%@o;Slvwt8!^3KSn);fen6s1(sWdoF$V~xUiHK&NN`wl+m0~M-6~LJ@VS36b7p$Y z;n{o>@aQFjAT{c_DmsEph`UDrM^=3ShDeu68#%eOsYq^12m8*%EFVM@KD;hbl=BOw zQ}F7WLMQp!9$_`xyu!c@bw#B_2C{;%%vS|q*x(umNmT^U4Sx}g;|wsy^ns~<-aZ=t zF<^l(l(E;tzJroRw1wGves9;X7T^Z=Ef~*9-~gS*3@c^Y_`xYxuUu@89Gmfjmiflz zYn~|6d?RAU?zOtxjxZR#Z5Q+0B(;M-5jNc#=U1#P+r%}7_2G~GT==V}62*rvX0&Qv zx=?I1{o(sCUa`@(2H&!!7L058{so23q9gR6#IKpG_oSSP?GJxE^9xB$`q}l#U||2j znqb}EFY}T~)k~ZC7e9l6bs|%$sv`>I&L;{iUw*$lit~>xy7|76gE+3SCK?J)pI5%8*bFfPXdiWt{EYjc?C6EHWl88=;=e!HCNYcRw1fh?VZQ*W;O|RH z_%`_(5lT-u^r9d>Rp16m){$DtTk5QEgRei?$S6AJ9rO$dPzjTqWv&8|Z;zVy={W*@ z(zlMnvn+nrN`56GV^yfxD2VS<9PDr_(L;9uuy22?MAX)n?1+7Q9!{&;KhFH_jWp+i zzMiipHM)-=u{7bs%#+Hcy>2FUmTHg92647OqV=+l0=>f=hEAIy$~V*bk4_Tk{W#N+ zJgx2@J5KT*_Vh{_n2eU~nu#c0zI=U1j3`+S%Z+JJn4aEsMVwH0nZ`^f-c+bM!^MUd z^c)>+`kR@4Liwi&`HdR>KcrW8qqmic`Acdtf^iL#%?_K4U@KQf*JGpm4bemt@t6$d!nnj8hiGT+u(TH5pXMkchq*M)-H(VJ;>BHl_n0P0lL~pt?l>temD>tU@w+!WE+8_JzCNKK|lqnY21Bg!UL_B-~ zbK^v_woASPkWakR{%t$Wn(Gqs2f}Org1Q8Wk@eV*pSW?O#GJxHv0~PNxdn7UpPn_s_!;*3| z%)jN=mwCxeY)!xG&aCmP&HORqHR2EON*oh z*pNC9-6IF2&9p#LR3(=!hO9b$OvMd5h<-=S2_Glh=w{|eC2?{3UCl60c$gn(IiQ+IRMs z(W;kU=}wlcyaP*sn#^Zl11@+T&U*N@)G?PH5>09K9)A37%?3duu_eBJAgP=0qYV@E zrqKSxD-r`ake^_+E_kUel7sW4v!G&MN(cRJlF3Ukc=~D0fcAzm_aB;@A@6Z2gB`|m z+hDs%^a9zdsKStE3?n3`AkgynAERw4-8|ZRH%BL?`7J-IPPqS$6;41kFRW6KeKKCA zA%$CdEst76@9^W+>F7d*OgO0U6~vm~*+eypFr^x7K8}x)FJk#zc451}cQ4kV&iN|c z4cNk;<>^wBv!kitbGsn7J|TUY!>IvD$}!KKV%XGENT6ci%k{4G9<;$#Rdt+VImx8Q zVJd+((1f=J#?_<1Hdk>5>tTAgy#@*@*_6t~%h}ybqo(^Y>*p3XNfruKb=eZ3pKg+f=A7$B zWgfTOsZ#WnPqTZ^S;&Q`(pgs(y{x#%E1Md+Qs0M9kh6>_x}iX0=&!blO3V+wdBLNO zbgC6MNjlt<%>GUs4pz0+AKU8~Y(5niWlOn)-nq)%jwn%D-`Mw(HkHC2ZrtV_Msb=9 zPX^(Ew?BG*j&a;fxZNd*Y2-^_)ewASs_#;wM1xV9ImM|vEzCW-7|R^i&jKqT1Cc|+ zPp8lUWYBF*4}bdK`9Y+R49I7%wzOz#JHHToBgYkJgS@qY*4$5ekDxHq{O;aWPvC8L z2lpq2bNe;D;*^q|rX%++r)|jXe7u|Tkf%YFlwSV;beiVt$H)Ygbq$`3&f-~FN>0kP zIwm5mnDK?gqavCuwh&r)oPztA#gD8sOj#VgT@#t-EC}r#K6DacTQX_}rZt!z(k5fVSeO{`6F^0{koZ9rHrR}1roxN%I zG-J3AcRL?@`O0>NtftWeYR2Nr>$Hpsp!GBQC3UY*jRntdY~$IW&vF^S;b{ld+kUh5 zXpTp_7nOCPQ!g!u5;38GI+E}CAoB+{<>h6}y}xm25?3Du7%CU6zi~rWb%{=rMyF)s z_@QMVfgR>e>+P@-o`%tzUe+XhSkPmM(R0b8B%s&OWLd>2aa+^W zyeN3?Xnk9iwc~RJ}dxkNMd19)bw^MW}IX=Zst(of!qY5gH`%U?05l}X_3_Sj$ ztdQVZ!Gw^o`OhMh`0of8c4i#jDlDLKE4CSQu7#S%}lyp2iOY)XS z{P_{wd9&4kL#n63)s4UOb0EU;>qIapH0agvx&tfTYfRuLJVz^oc%wq?_kW}@SsLRD zRBWDC^@e(@y!(VnPz;_I;}y*p|Cl&D&^qIkalmDy5=x#) zn{iV2E3r!}PV}*oW5{bi-;!|{V)tQSV3tOv{G#a`u%2^)PE22YyqtuG*>`x2>WMkY z$ztJ!?;x?KdCf84LH41o&+t1w;Sl_hdB1QhYZtJK!GCzXqa6j0pTsj3787`Dcj>Rm z%ttGqwsM_1u2S=_el3c_!Zc2?zws46nW&REgQU~4b>jk3M`Hlzh(d1{WMeJW7>jCC zN%#4Qj|KCw#xa}Pu`bXRxs>?0%zFhf1vB*15h7!V7)rS$O89IDO)>&>eBYcXI%$eo zLCI{aI%xwjN$>R+{KPdPfF*Qknib~DVM=&WaT|DcukwVp=C0G-H$1|(b;IVT67@m~ z!7O592OieJA;0L z!^fHX!?Ta6%t^)&|1KS6>w#T^3{_*^AG&BKR_SO60l$JxBL zz0a}agP-y`7fuhFgFT-p3!4y#S@1)~ebzAjDlfJ>Lukxj8_@4P-9wkYSw2x_%4YLZ`S8O6m~RZhNxB892mmF8;(|l zgr{5$@Rn{aXnEO_)DZLotUC5TR+u2MG24JSF9SuO7x=z&O1^^%(6%_lMOJ&I+nL+y zE~Fyj+5!FcrsWwH*`oJMBRt26$_~ERsei8*kvvD)S#!l{VkQ2!=E`9~{F%^EDRhL? zSF1(NPErl(WJK*nfWS#e0zCchnk(%U&XXVasd+lRu$<$83@?4~(2!ZhQFh!U=JuxM zG(+}*P`pW%;X;viWqGvH7mS+XckzMypt4}G=H3DnhhxYT2z{?LRX9ILCBHv|diUcK zOs!bUk+3)YO}=%lY2aGn+peFD-=_IO*8e8nMCp_w_g1d15neCG-evmR*UfZlp&f7y zd`QDfU;05QIxl|e%YSGij=A}=IVgl6X+y;p7-#+%vwLd=y-nnlYy1ZgPoCWh0{Iud z=Fj-nR8Y{m05Iyz+I;i?ONHJCjneY?7ODv_lw3^3d1FYNm`Rj>1Dxd>qZ>>!`nc-XXo0tw0%Fk3iIh!lXx$P`%0E|-K=gU07*g-I+C1|Jn>X&P+ zWA0Rz(WK-7RNi>9pbLb*Nion*hjA`UP2x9Fd!M}f!ae8~0$hQdnxrlT5l( z1=UB5F)-#v+$0fZqLDiJ;aIpw6Rx^f;z~MD6<_}cAm6VC+x`bI!2gZ ze4-tnZ#OLLC>srY?5~os<(c%XcQC~4oNfY!(*`B?PGl{8rOODWc5Mu=Rxyvz1wV-W z15u-3u+Ir;C-Efo>W!Tr;+ov4zw(H$tt7R(g z2oEOmG9#2IeIx?~?W`=>T%p8hN!C_}+Td9DbjXzRV6bla& z_9(qlNM1pFc@-RuKNWS>i(^(2a;v6jgxRC&(H=-$j7r4qCD!N1lHJIwqt$pYKq9Tl zOZ|mTjLMPoT?UZq;&SvIrqaUN2IQFdL|J~3G$*lT54Vpky5l4X9~Vbp4itV2#2syS z$JO<2rpVfoeLi<6CoK|%*9g8hZo4EuyZRQxs1~bKu#<5);G|T|4{m|{OgyUDg2b=| zy{j|&Vrj+M&fNS(&eNf+_JfSyT2u^ZixLl4x`IKL149v7y0o8fjzrYx4y_&Of%@8a zJm3f@L+D)jhFI9)yFBg@PIF@eu5{m```^x*nRd=9s@l&KH1?=HT%gT7ee%9ETdaF- zp`27&Eb}r~9elq%|9G9=0r2&lSjMe$q0feyaD^9Zp)FsfHUAQ~k-gwq5GzvyZVlyw z(dV1%cc4$dnRZrN{*8ooGCnDrQ;<93lAw+=^=Ae)KxPYK{m>}3lxDI@8LjzG8X~%S zy7|8TCB)z*g$9zScXFBPlX6jY=&s-wOt8rk_Sll(JU@3hK9*M3K>czQ%8@00pWaGq zn~F-;e_NWwtH86G6@jI!$51rSP7$>EpBO%A7H!29FJ1jPOU|#L$SrZFWt^>A`ol6$ z?_Jy5(r|4mg4F%m>n!^z8P(u-uH6h639sKj_;P^-vY|QtvS&)^XYOw(1-pxR7D!EM zq4FO!99gItf73U+%YTG5FKqugeJ|o639MZIiT#oQ9A5Q!thyd0@RzzSahubk=zUg;r%qL`Hi*lJ|1WqgJCu*|$% zPmoE7UqAH`hr^NcFvd&V`ju^*#S#a2kt9}L74}xUZ0i{!(J4w-`VDu5c3QbM|7I#U z01DRnrCw$y9^r`VD!e*}rXGsK&{3f;7(??MrN~B{jPS(35|{88dLv;` zPQoAC$oP8E&xT}K^V13L(cOx!*ZhL@pM>j$8 zMgMcrv-o9^Tgz_ovVQ>WJNq*4g=$4_Kc4d4pL4a$=W(}ozTS8DGfB8cU5upZ|`?Ez-3=d#` z&}|?qf_0`@1`&p)dsQ>ZTYv#_z6%TIDyfh%Q(>q-)l#S`=u<8~=ddErY+RCqy+-y? zDdt>54gxs5zB3>7*L1>&ZQ0R%`Dqwpl1jWH4QD8A!1Ia=W5?AMeKvC&MHU5?sTi0M zv9ZQY=i`JUO}R$SvBk5n^5k8T(9a0TcPpu?QLmLQ9!+@Zb_3u|B-|pqazc24U*-PF zHhn5j%BEfKz#^S4+8>Y>X`YEKiB7-O1H`MPRbnE{=BtT6RVp+i)jtCH&N{jNT^P@N zOEV_RudA)h0I((nNKpM*yhM5dG)w~N(z~AD@A*KjRD>pzYO>iENHF-WV#?eOV_X$z zAf~^<#c{Dwj%*u{ex^^cGExqFCz$(4+)zrWGC&Hu}@ zY|lSq2ALZ=@k}bx&VO(;1iju}w(F06RPL>9!}MFjiaS z`#t1^=Im9tG5H@WI$L?OV1;x-tR$}&i%5ZbRzwg=5JyCCrGGOHozfvp-&B-)$Ymt9 zP&5pSHVpXnrH!tlKu|b%sJw)$@;dql$t)x$n|#d>1&aa^yOz!jtsy*%JaCCcRECK5 z5%?uK51Dlk^81d~?IrYj@c~GotPPXWOPuc61{?R2Nbz4;6^T56<;Kc z;uUYyPaH`OwM>rKxEhLU!~J-wnSsrgic{8w)xWPSX5@L#xL6}(`tohMa|XrP&GX;c z0S{oel8=Uc%lsi?s!LaL4Tbc8kBn8s{LnW!J?BK%8}k1EXh9p|F(WZyHdJ-*^sKxp zG2O905@c8j38&A?!puG&cuv`iFv>TMdG}3p>={k^I$C&D)NG;7VpHk3YsV!-#SCLW z1QklWJ36&UEk_)U1>rH4>Qy)ZS!Ax{27c~=SkP8y4xsE zSC92`9^C$}`OE=~1ReaOm5Zyqm%2EJu&S`8SiC2>1AWVUud*pX2s}&=-xO5wlBd0I zT$4F^Y=G~#-EwENRftQS;C%}DQ~5dG~Q<`dkwEMo)?5i-BK0dlrVHGTau{b2-&}mnec~MYPA0j28XQOJ9 zeri105g;<{O5N0!zaRw2qhL86Swe3o><#{=xS}~{a+5icX+zi%$7gieS3S%C=!0=Z zI|0r@rGpg&jU)P?<_%Tv=;qZ|=>4xGpRTZ%3eCsDc*!JJ+o?X2AhU|My*<5t_jtne zf!}-HR?V?SZpp|4ug0}PT9O9HACzn&H7y}x(LP2- z@h_K>7aGugK)Ua=8OPD_197Fwdg5s!Gx@DKZk7^x9q98JhI4 zJrw=rJmP%fsg*^H<`({6k!-Z;kH91{7k$QGYqFy}B7n$qxpazc{9Y3t$9Kdk`&(k= z$Gcy?pRiWtBuEzIKM2Dcdp};_x>s!~PP5U|;C335O*3mGG%0bY$&qyrU0OzZ*oizb zsxNusjqD?rs{91E#J%nEP7?ogyr5HlMIa~U8XbcqWVXb&czl7wBbD$1Yr?a0J^qWu zwu-4u0WhO=xK+C{!^k$xolaboZVA@LCEo-vhBSqIJ-`2J=vnv?X~EsQiRa7-k{yJ^ zFxgbvVI`C9vSpixO(s62db{LX*v`yKg7M9}2vEKZFA|q(sE2(8J~&oW&v%stm%3;B zs|s4%09dMBCoR))z01{`awVr`7}6a>ho>H(m#KBBQ5vs}gGU0!XQ{~_aU375E}KT- z;+Ly_w*)1CpT`XC%Go9je#PNKW%aM?*zFwP*|Ker-2pB*!i*0RqMO zpOF^0$Wn|7hVD_`NU|#>)>d*Tj>w6pWy*liX&K?}pFLruMkfTm+2e~7n_>9q(Xaqt zDxYtVC310ji5!fNw|}pNCHi9_@dpDQP84;j-@h8ZucqzTY*VOzSF1_DVFMH>v_NMQ zRwI4IGbKhGCuBls<*>($-2jg`Izfxz2~Q+uG>K85R`{2a1!#yWS!FBGm^i zPZw2clsa2be;WC=M>g~4QTCo{g~T7K*H1S0E~nvq-i{%X=C7;i>ar!ylPQp>-r4Zd$MQfv&!2>JX6XkM+^_5Pzs&3zhQ^*Iu&$V&o~D!`H6FWNn!b1j+k4XK zWG4z;`pNwp$m<44T*bt85Rbewzr2=m%OeRxr#*67SxYn)Fsc-~@)oW(iX=_!vk@<0Q?E>=s;m;h@4~s`nmNL` zkoD_tb8nP94G>%57J5%XP~IzwoO)8LDT6Jfm6Z}(V;Y=&=Jwf;WA{s{eJJm5RCyZw zM28|%x+IsFQgeyja~t{|)yo_JSY|)DCXpVm3=yko5l%r~0W`?L8l^wl{)hNmKh(bBKKhtIhgE5|AKEk0z8P(`wvxsx_!We*7d<|uFj~%s{k6+I zxhp5rxVwD$!XxB}Ea1}V&x?mjkqx=RXZ@ESG-?z#6pQPmOuk-DX;~mLMCzGfDuYCe~V9B?N=E@+R{QolRJ!DTP4xbya#6f zHjDEpe(|?O3niw_Ra$emY}TGl<(Rb24oJ-+x4JAdX9x8t1X-Ru#5K>EVJKrbb*g7h zJTfYTy)-AL?>Uuy5oDJ#fpYzABLa6HMPVnC;&11)61EX9mCh#Vj~yPa2sQ%!-g>dh z&(eaG<2l}iY_+max*xDA{+;YR#f8G@+bw^>bi1kKPM_Eu7RFW@)XaBF4pIi9du@u; zko}r;0anX()91o z8;XWjvAK1UU}wW7zYEml^IA?9Acotw?Iq3HRYlPxAecbh%k6UkNo3IzIR3^{!YJti zoA_)-2kn%7Ax4d%a4=hY@?Q-OhH_;qfF$oT-!e6Z66*{WAms6U2$3DvfgOTOSB}n>MqklP(Z!V7nF?}&v4JKghhX$1nZk(Op9v9O2bffSO zg(R^aIYWoT;)JA5_rvsNCgHM&)nr}!qlfY&f;t@cq+xw5%B6_^V)OufQBZ0ltZj`qmbYl(qWm z3n@YG84W(f&Ru#4PN6CFX2;ywf}M)fXCX@;l#tA0BFWUJ8^{@W$Fv}q)6aKYWe~t_3B-k4+1};wZe}vM}P9_sMI|RbS#JUtm5?= z&q98FsEXxQq1Wf#tz)--K{Lq~Rv^RA z_B-%SnvcnDB!ujT;ivGHQ95THO>nWVSRF`WUvmvcbhzNQo5pXB?2n{PeqrKA=_?!{ zg99ETrn)q@W|p1z9+*ck81&B;zDKAzB*ffn3TBfOsUbSPaXLZ0`G$qj)Vi$V{5hRd zAQN@hlPH(LM#mRPyX;6KeTdF;iKO9I)AGTWn@cL(ba0{u)2#i!*XK&L0o-btkm3;fX$)(sK{o$VAqwb_G*6U0vqT#~t>e=t!p>3sY01jEjJyQUG-=I|Nnf zHhT$qOQ^Itit{18U9`;Y`eY@Z-_G9^6d6`#*wioP7-0$A!g=p|rlB)%@|A z{y8-&d)sD%!J9#=F>g6^;C6Iu;$649Uld#}<+Wmd<@0(huwOq*{xd=<_q8wB!F&0G zqINFV`5;P+E3mZ1{U5+uAiM$myW>{l)C)Lq{<5URo(Ivs?+XRTlp^=e(k6S7UbR~0 zO109=`;KNCvfp~p(BVQ#h3y1Z@bzcib9Q1Pcb5}j703thRYQzRIan`xNLk^J*h%9po#pv94enKum88#y8OVE=BQ`tlK{x$p$*-_2D^o1-Uahp# zcE+(#30S-Xm1)~{>1n=xpHLf-2ZSRX z5RobXg&U$cU(MF>CHNSz8P)bY2sX?5(}Uqj{O+c}9cXWN3Els0Q7IDNq+;v-X7I0l zXW5Xe<}-G5B5`P=y{&VT5G~5Bv{-gvL1A)if2+Y~nYCv-N45d8pyU7DK(|AmW@#B% zB^-wdzeg_P7H6;1oFwjk3QtM)p}m7P8&MxV&(W3k|04f+W29E5BUL1*oE{8vAuJ8hR*-*9 z(YkUNJNcX-IB$hPaX$#S;Kou?@e6#`95&|KO6R-*okIrw(j@DFm6l<^KrBJYHYJDY zLu&wjOmE{YT?V5+Z1tIK&HGJscpL|* zjnjgN3%$5K)UjNADjFe)smGWt2pDPV2e(ls4WW9xE~d`>lU(hHA}q5Cp~oKD;U0&{ zQ&`Qg9_3xRhHbTu%$hUz_DEuF${!`_Q)9sjCqS9!SC9c2=$CH}+_mJ|@uG4E#J3<1 zGS;*}m4#e@i5u&uFpQ3U>Yq6ffx=jRD;RlD-4odhVpt72Q#1SLeTz>TvIdaE#H!`k z#B-d?6Ippe&TNAWxus@3I|KDIjMGiJ5z<-Tk5Rd4$@$&Ptxk8nN?2mJuwL(25Z7-R9(ho_t7IdgoD)+da zTXtICj2IMS%&)o54Q$PH?dnO&_JuSZjq)1inOj7d&j@aR&>yVr;I?w+m7%a_j4UK( zg{7~}&oUHH(Id&GM&2j)=|h^(suAi=bq!LyQDvr(I6-R@THz&P22_UMEsa;`?TP?< zWO|y2$=C2DTu+Ir`m2_)MT7TEiUw=G@oYrPs4Q;b&=a%uv~ksYGAyJ@>Fi3sXf3m#X*L`g%s^E17fFbH%e?va*#)2MBZzD7-0@;1Ri|I(CVh( zmh;mHx6H~5dpL1dz%L+)hw*o4^ToY^fUq`)CX&~{J3d0I(LpOD1N!6X*^oj0FAq$} z=xgP6wnFb+#X$Er_|rdxp*f#|@l43{(8sHN5f*G}tX#g_JIhlJ*@u}%SH_b$(pvem zxokHB_NKHmHi{?41l5c}gN*Y3^D9!CR`%Tz7RZ%m5EIZFWbH1SBv0lC_<%4EXufLGbmtRrhyxLH1~yp=|cca|~APi%+2%IUefy z`NR5`<=*m@uYxfblP+stn&36dnd0!A1Nx)t4i%Ykkjc)$o!aZB!)J}(nV0OBTZDksPm!$k@T^p&u_{~3tIsD!NOuao4;~9)EMaN&qjoe!helqI7XeH zg`xF7)sN;@YF??k-oJxkuZWP!+`)euqv~GbNZOlX!G8lV?#3`IffP%q0+5JI-rGr? zo&9$yQN?;&B5(gc<&iELTrp~5f+CRMXgjg6ki12L6=u`hXwfZIYK)JEc-U0V%^P)3_7 zNbV?9*r^{fxHhvZ%+4oQ4D;&$K#1zdpbJ(y95w49pB^>FC}#f$kWd=nS=yN3-`7wl zHu1ZsR)zAU!;`3{YO+r`HZy!;f+=Pd%MaxE{V61OEXq#l(Pd3`^ju^Hei}qq(hs8O z`BjvPZ@FWr1o<}ZecQcU(01-f8&CSdHL zn=~1EtM9(J#N8J|x{orj+wBzET9+Sxk?;KW<@@eUMrXCaUlYN#r6114DH8j`pW3km zheKz(%1K6c`kJsy*%>&-!$yc{y+(90>o;#6X+>|5p~OXMlea_efT*?^!xED@(Zgz- zvwy6}!YnrI)s$3)DAC|%Cfj~dkTqTH+ateO3%o|BwWQ{ckj69u zbhXfD6?_@|-G!+H+jnBn=}^i#Wr(B#uN^M`cx&{b0&k)`xSIC7M>zgD7qEN&^VG3Y ze4nZL@MiCMzZjW&v&vEL@71*lH7=8=W^3h&7AnJuN!_C~E3xy5iBm+b$LaFHc!PU) z0qGyCU$){p91Q^T9}bxsRcPUs5K4c3Yt5&oid=m-JDG1{l-AU}40|r->1`0b=%Kti z?x?isNjz}4ONGO(VM}`5RF9lHYL|^ z@>3r4Ez6K7K&Z$X!RQ!v4L87tub3`;f9fM7r8#1u|Nij&{9D19nT_;EUoJ@wA4{j) zi})DidacFGPr+lnMx!+Ilk?9JIruo{e0V$W%POTB4X4xjrCxUE3;7Q(;zO05eu=qs z_))g1q~=}u!sE^xJZbMqUF52Rx>urlP~^o{E8_l-91GpUlY9vj|C%zMRq9aB4$Sbb z0`LBf;R_!PpUC@0Mm$6&0OP%$cH9pNxz~6qx{w3mD|EF1f;)Qw?@HR7I)IE8d>!ae zeQWSdfx<*L<#|hO-s*ba?o;<8L8D}uutA4kQ(y$BQ}_cEj7Q zU?nl8_sZ|th8~V;iK0^_I zoIS_z*h4|Sq24y}myG>R4oqJhAGVM>25b0$Ste+;G$MGa@OwZGmVNNRRYi%Q)6m}r z|M15wrN~007v*DHWI#~UBGAi!0R1WpzxDQlda%SLF6ve1XowMpVXmCfWyP=vj0|Im zb)Wi%ajH;)NXMAZ^1_2C>;u3IJY{gg#!(HQkd|4f+fa#)pM;kJ2P~o^#lFj@fg=zueer@GN zv=0$+kw#3}G`;(jiBgTKEX$@md))m{$@cv;+o$J;1G3?&!W!!wt~SZ>%Uv9Z#d^6% z4}()j&#*Zo@jrEz0@I1etp>?w!YuX-i{%TDD^^zW8nj#Tv{zu8)J|x7!YveWp~x9y z%&i6av8N?X&a|%TaHQowKvY)kAfm&``>35Fc3ys|CLrqb;R3H$djqLG$uZr4IQ39? z?SN>z&6;HTm>aCbG!xR7=+H}*R&TrJPG}Dz^oV!6GkEb^!UJA1x(QBfqgi2si>ws} zD}_zXDwI2W6qcd7&FnN^AyI~}-_j7Qy)~t_X zt4u2~`bH*<+)(Ip?d;jFxYm&X28M*xxo|@Xm3MH_ldHHFYCA0rQf?*(WQt4wA`wVH z(T@VRKK&=askgyim@s+#CQz~j(FwF(Rc&9xktng(xgMp1mg#JU={R%sX7_4yvkrWx z0yfPt3O5#Vwne38$;4KR583XS`CeAE`FDNlD9a@zs=aI^_Hxr_eW6-?%3|kyORr?7i-UJLae7X zC$_7?T4SgD2H*YzEa)R)Z<{>aRi89x6(;b=z|^Pa-C=NTV^>9iMmF+7$8kX6)OPt- z7zR>YdY;krpfAM|kWRW568nI>*s85atSl(LL)oQm%K62aRk z)RBx)&al#z2T~Jy+eA2Q65TPtYmpK_@ei5E&@%TD=b4U`B#|Os=_|Jd%p7Bb zJs%6ElWx7QW>~Z`-P;<$b__qVJc8a$e5f~OQPBR*>+x3G!-`fcCfdmJwa-|`-4WeG zYu6tZve*ivJ%NOc&rcr~r5a%^z#Vrgl;i;NXhVyhI77*dD7_^v+x{G{ou)P;+n&FI-_o$2pG{3CawBZx?DQ=%LkfS#7X^<4l&QaTN2;%-X-pjr)%-TUDL2h z#TUw(UgE+-pFS&63njbIqE9wDghq-m+nYJJ8k^Q+-v5){v^HsOJ{lwY{S%m2ic=oS zN-lbs*N;}EB%|@*nK|@HT5y>s+@#;Y`eE0(aIt=3LY7;Be6&_96LyDAg7WrMY6mW- z=vT}A2SNN)5ZQEvwb-|>4sg!BEp>Hi>9kS}!8;T{fU4;vy)qT}HVpf@$V-GfaLSZ5 z@dl=2&7;n?w)MVL2W=amSK%mgt3_q@M84$AqDLMvZ?b>Ro?`*ref*a|V!stW&g8pH zrCTp^baY#peteNwW!olMVe>g32ZVoJ|H}eI!Tzsb_T`&~1txPiXQ{d0vzVmY2clEV zI&r`cUy}KuVwKmOYLI}!0y%%Cg^JFxD&u+ojUG`(6z#&5^^l;|vJGZ@^OLI@}6Fu7*)3Im~aKi)wq_t=qrHuc})o#kWGjX^i zu%LP`w&Z5eolvRQ8nIh`1Q3Rr7tiY@Dr5^QsRc3nvy+%7xT#=B!bikJvWhlnnwqaT zAiVwEM8Q2w7`0AzAXhlu0_+PTGbKzXr-!@#WBvSC?#$LGy<^l9caq2uC zDhE>rWd!J9;c~ue1B8)LX3-~P>@^S8P^%(jqLQu{>t~@bdf?e#&@3X>vy$BglZqgp zvcU5L_u?O#`po29uH@$${`zvhSOv>=ByA^C<@Ti&(lIU=bQH(9>CI+@v2+Zt}c0{SDA6T=MrSj*S<^qM+!yaPi^x zlfxhAE{o~@Gsl{A$x+4a)9I?*m`vzY?2X?q?a!%H(NPqYhYjP7UZ|<0r$wPd%;YOCCa`7K>bcY$iUckElI0+^x|(qKl$$hAl1f(%d#J> zj#jheGVa!p7gReLe55{MTaQTvJD&zR!f6>?_mzd(eMYs>E@3#dS zFs8#hzArh+=pD^qeX>;|5iGPmzG5JIhZu*|{r807f9i_M<(9tfJ!%a48K|wrwW%gW zLzSX(dxL3`!$ij(8~v^XJ^rAsna9!sDC35I;n$@tk5qY?rOl6zcY3&jAlG3;GF)HS zBw%iNuM4<|SqPL4r-&?UPd_II*B=9kLe?iZokd~Ya`27RaxoLL2xwt5$dJM;6RIPG zxzWp$o$FKbvEq&?w2%C+{>qedGm_gYd{p7fxL*+4{R^{|g$F9xc!vt@UXaZz{57Tf~l z4gx%1v338#Nk(crCVl5e62G46`6Bee-gtn+SM2e$(BFgyt173#WSGIA;LM=pd;LT) z-b9=lQe=(WLg`2R@tv|-gi(mDkQ*&~@af;w2i&pQKgr7fg%Cjsm!z{nm+Vo{Q2CCf@TF;k1Y&3^lUTJ^K3UN&|t%^x}r`h~N=PjOQpM@o0^ROhtWSVur1(-a0RzV9Loe&>QoCJ=^JNFbv!?FP zcIy!IF+fsBW(`9@@U1xjCcXoRaaSopcLW~g-uIb(ga4Ng`*^-b_5ZpT#L4kgJ7=@`H^BlMw+?|ViIlY~bh%FSz4vKJDsIW;{ zf;~Kyf(i(~+Z?Eb^^|DXAUSt_Q-@O$Wh#hEG&{^D0R4QaHtp_<$-KPLG&CA-wRG|c z;;SQz`TQTwcbZrniT5V2l!Hz5G?MbuvkKqB3)W->829P$qsSwb?HVdcE?xDE+g1oE z4!S6B43V>bIE6+wOp1)5g{*#YW=yRB)D&d&oLHZqPO_oMDtI*cFEJA1U`psI=T&yf zynx)rOZ<%qF<;mI+ox!hB6|>wIjdU7^_Htb$Dnk7UJla^F+16&IeFU!;_mb6$sCu< znOhs&;|~z;)c{8bcGdFyYB z=G>tbM92FQi{)8A3-uwl>Zv(egY&$}U+wiTeoCR|lAs#y?jC@|m}+!_0Y(Z$w*yN| zYP&mi&j)AW`d0x@oAldF_2!}N1FoQKX^El@$SQZZ(j67KYry*rNlK1T+E*D}-$WLO zSvltL=9pYm3`$+IsD0@d^_!KG8_bNB0F}f1m^0PP`nC{?S>^3spst2Wa#>|%X_JQQ z!(pG@?V9Nu4`t)WxyJt(-3R z^)$K6YY?@x+VX9)FIc96f!zHAYKAzpM>{gCNxMyWUfwc{YZQrCqJ47HU096Uzoprk-&HeAI!oy}nJe@~GV*&ZP+3??gXo{(%Wd z7M{MArH0Vl;)@0^Ffd6FPHxH z3-1vq$WiH{A!!``sw3o1V;6(L_m+;Z`1a+iyw7ngEVsa8&JLf_k%%0iU0xE~3sRZ{ zCpo!PL`H+y?^mU|K?s`>zE7w$a7QJV`Rf^}hGhQ$4tHb1;bcKfJ(o}aTxnXXCMDb8 zxE=-iO?`FO7l|mczjMLwLp8^1fx<~1dZZ5~tB`R6iO}_jocJEF6%`WHsr-xMQ?ely zDYlp~9bOK}m^!@aHnbM*0lmu8U_Ua7!?^Ro0v+)G0RE1dEzqoAFR_b+hE4THocZAe zC*6yL-kV{i$J{P%V^eB%QtK@xFct5fg7e1=AAif8T}OT?Vzm66I?>q%g5laY-Z%Px zmFQg3^+(tBKk;3i(Wi{C5TSVzs=yD4J-K3_V!Xz+i@LtE$Fs_4EV=xZdNJi#Oqp)T zMj$b@?Lr%bNdzl6Y)Ez^=OKeVpLypuNH0dF!9-%6#vTfhh^j8}l4x~UM9Cj5e(%!1gqhqnW`Qy)(7(ur2hFy;jU41`z4<{~U-MnDpzFIE9V47yba$wT zf0ob=6*kGl0!1@-WU5GPVh6#02hyb^Q*o>`-FJ81&LFmNDluE9LB#q4Y!x55%h@tM zNwTCqQ@ZDNpEw&PiGTzR7kuJ#_*7s0rN-B2g|^Zx8}63eK37mr`S_Cx8YMUy_D#Tp zR_Fn(<$c-bwKWt>#Utuw+h&obAr>H}o}&s8hGnuM&qNCEA%6`(b4BDPWmP@G+0dwx zgZq<^dR>c}SEYE$8t^_!1;rtfT8KrxwK6e=A~%2WCIHemQIs;OjP`0eNJ+Wtq|}-w zTW{1_@$k>p9z!H5wjN*=WcyT0gm*DUQqH~3u$IXHl2Q1#>g2S+)aW!{%!n+RdRT38 zgBW!<1mkDJALTMbO$auGv1^nVeW^nw(*ecUTKadivJxy1W*6C=K~k>|kuK~+B33GC zCN9NfG@rtbjhs6)J1O6o=r$JBgPo+A|Jqz}) z5pf=~5Q7pWk!{S?}kuM+Cf?XoTjL3h)03vc;nVbBD z9m`@s)&~`PV_Uzi3M!b0FLx37ENEaK=rLE)r3vcJ0!>LRs01(6P8yFbfzqtV2rt!9 z)F}4O^(_^d{Jr%@pCTA-jzmJ%YjdP>9{E_fo&I`BD+{{%4BDQ}`W>Ahf}jhF56Nm| zA_trITKyqRK?~SQ9%+Byf7$sQF+$kdiIhD;GB!{solLRyKceIflMY_kZ>sujq*khI zJxW)cL%j*SZFB~}bA$_rOinzCAC0F5n!n&n;SIYCN&9@&$aCBjN*#+*c%J7Hge;1e zL%v>{#vn_G5_6bhW*oJQ3r*q4Iq+M~ueje8Dvmha{kbU~=l+QPE_b)`TI=M5q~iVJbP}>2=CJWR?eNvC!J7Bv+Ya+r z&nLfA9S~|z$aa2TJ*cV)S?gs4Z}5heyF-eQzC9_nV-8)`v@e0@)1u8Pd$$;Z9nG7x z1?=B>gFTm{?)Tzp6Cl zl|z{7c7-O4If2!;3h$b0QcmIigeTTS-Q`+C(0p$(;2zq0&;)Sm4kyAeBYBq6k*;2K z_s=MZQY^!1*`8+(iZ2ut)zhlPUy%}RldcnEC24Xt7=J5>gL_{23$3>y z6DOVZhZG>@Zc zt4SWaSVj-ihwQ8e39?&jponQsxJSub9YhO^g0L}gZ+7p~OLt491 zxe+23!$OPF{$@6eXxB!E5Xx85Cat&j_k=y3U*A4Yop^22UP}Wg!>gDdjuQnX)|1^c z-B%I%NtR=2R7VPOkas|IJJ#oxZ|vtc*GS$D9w4a@5mRX)esd_MYOXyZNjyjQo_qA0 z98>GoKpO3e_+)A@oM`7GX$IC2Q}fjW(Q!mqZ@aA?d@%RN%lr1)1@DV@92dz%>6$q& zNOw8BwnF<}w94H5I_mK4xBKe=mQhKO9x{6J3q04WZ=O`m34ub&q-(rut5rGAp~=u9 zSx$%I#V-PyH8}A+mc(=GzR@AZ?{L{B*v`@y3ulkT)OmKb^Cy_^;A%M=;(P2-+lMAA<)N z&3mRcG0#qypEX`xIrDdh4D977|Kc5Rh|7i%{pkK62cTQExn7Ym&ggriPvIril=;I7tXS==e5vrg+ z!6UpBMmb<-j=``So213Grt7mKZ-c+#_;t!u0{OksZq@h-2f%}XAahw)R(8v zqJa@`<(b8E-1FjlvmMQ2t{xv~|5a*V*K*kA)xO&<3`6c*Aajr@yE*-V zlPi%iDkmg1*3Gxx=J|c9ENT?I&AUp>(SYl@*GaW}q{3-#xM9siceB@)O9i}rT`3=J z;+gqq=q&JF_i+Zs^zc2g>{*Z4w)qxt;ZyP0Kbf635APo1K+a9HxTp>wTdo_AJ;T6l zN&PWYpzav=vVkA((RmXgF^ef9Hp^x6FzMZ1*TGxyAOE_ZkjZua4muJRX21W2S@nY9 zl*~VXn(KWiT&ASZbX%=~FS)Z_k^%+17^vH`oM>dwAmb_0`r4nyeK%0~_I3294Uzh6 zlNDMys)s{w$bbZx?;bte(QvZO(9Ny+j%)Vhs7i9(j<2_F6Q@gT=HFnTbNB56l9IpN zt){iga^nY-U-BUDql>KQLodb~qSGSB-5ENb8uH~4Xx2_LRvx)Gbj1~xu5Ihp^sKAr zcWKw+$}?k~wCTlXwrN>J4IA7aD#(c`QiaQg!;5u0#&^;Ev7$1QsLP*6IW zMwS#Vb|AA=a6LYVHx?*Bv=9hm@QhRUl`mw_V0B$iNfn6~#)5dd!@~w!By9!)9GU@r%56;;w9Nl73*sUXiKv1tK4d?n5HFOyb_M-<`@Aj z31u+5OyeY*^HPd@C~SPQP0l6Ftz3Lrp>ah%(-&}~d36{SYh2sS-?XB|XOIwmsRe0K z%ny0juF4C0GSsq8)c5?;ae5#GXtcHR$|%tHnq{Mkq%KVsKN zAH*7eBgofq7C(I+jRy?z>4IZGqHnHAZ-HlAdBvLQoDpTG4w10_e9gGQ(o>BH^g51d z!mP4dvy&6U!nx3BBHF~Py-LE)sa$W>IwDfYtsCCS2o(hJ`Y}kpFS|{f$JJ+-%P||M zm@D@>bued6!SOwN^oF^gxuQ%QdSb|384} z+G+(ABcxQvzd?Q8liy?wc1ur+os*FIEzwzozpXJ#YbG+87nv)ff-8`Sj;Kd>(80qL zY*c*ra#nI+60W@T7**NCX*7_sB70Es>_cBVvj0?NDIqoGw+`(s9r`bo6oc+AGZ0FF z1)LC2W~?JtFL{C!CwqLuY`uxrEh}oGi&^9T!{|ruArCTmmsxc>LGEPYYSbN?DV|t5 zhk6+uf40q%%0exq$;XCPQrNCRZG-f0o2?O521t z77PIaSH3T4uPuwdr&>aTG!K@fMSG+o7!X_uKlv<4S-(kh<0u#X?GoT*_wKTYtLjBe zL)sY@GW?_ShnRv>Rj5wXx>-g=bc5K=L6^ag_@QC3cRY$TC`&oA)71f23n4~bRs?2_ zxXvwMn*(5^X(1~6FFFHEq;Sxc@40dk++*~PH?J27Mqh&j(L-SE!YZidXR|n~5 zzSwp+^TSzu4hMsdYQ^rq&UzAsqlpFd=lZ1^wgJ8ww=Q1tq1V47q%HqlCy11JTU?dy zU!-rWN^6ppfDlxa_f-#7ZT?WidHfvU8-_jO=te8wV;<>4dqfOX zGJQJ#kFQ4L>3nI%0qkqU6IlEM5GuC8@(ko*!EoIv8>^bK;xp|Lnw)~~`LiOi`AsPr zv8TcE_t{Ijft2G#RGN(X&T%OF^ z)wYDGmY2sk&kS}?-j7176K>>(89oK$GE4^f>WE<84VHylH=2qQg45#?6ci0X3T}t~ z#hw#vy4r@@tMqv^nm5`lBe-siJnh{H3lMr+zQPa^rL%r2zgtJhk5;U=5iMwJr%34e zmA&8-7#Dv0zLj0D_|D_#MWOKfS$80N|Fj+z_rtM)2h{m73gvPqe)vX%)8JQjykfNsF z6iw#j0CZUsPbG2u0|bK$ltxHPO6F#x3om2Fb&o^e(`_sLdbR5w8_lOzwpd$AvY)FW ztc`FBXKe>=kD^r-c=n`BounUG3#H+iy|f!^jSzhnfo!iO+~nX3M`i^sooP>gpoDny z{yyl2%UyN)w;abEQ`e#!6fQkhT@J4d_$MJdD>C83ZZy_%nh~Zo;G>o+`IPJOo}tTu z5b^xmKDfy37i3qGWD&!ds53{Tl}`HN9iht`S??nHB~eBx$1h-O=V=_g<=ns;#&5!; zaHkAu5Dl2z^bx&&eJ98$IoRc~kJ|DFDM2Ql&~bB$3O?=-{Gf{IAMa!IBECD5agBXZ z*!QNgRQ%7osFVwEo$bTrQ+XRjf!D<>nea3SnJ8QgYZ+o`d(FKGoG`x_ICZnJd_bPE8Il|Q&|!|emiVjDMoF3)A-aEH4GGw^$H7q zHu=0ej~1&I1Yw{p8f&iTITeN#lyiI!{UxJNt~517%xJ zSAV|NosLH&HY5%FR+@vEM%7Z@7hK1!o%Ou6xlW^tuxj$kZ1URp#cu_yZ92Fdm2E>@ znY2Bcfx27$%f)hC{M)AH2N?xc`A@7}j_!WK%XL;_v&v)=Z?}-JOTDGN2av487veY7 z=o?M_gL>?Wh6v|dFstg2iXv%r*15-RBm@hQ5-{Gn8Z1Nz+-unJh$MPxzAlL$eNF#{ z!{PGjuPc$=@t^}joJHnj=W~=P%Fy&xVT?L4w+z^Mdaw5ZE6J%9#?|vSe!-R8=@B{8 zP`FmdD!4Ia~Hq?914pymmp^?Duo1HRdGob{HOzU|5T^<2xDfX9#!m;y`&9f zjg+BaC9*fGUsP~eu{7II1AwBBLi&fh3&L8@HNx45ctEWei*HL=0Ol9x0$?tkt)`aZJECO2EXo&(Zm$rBcJZ}$H zkXAc6w=>fL0F^9BM9#?-+@+9hGc9OVb&~~YUxT{#HM8Na!6u^BNsG&YCocutKBuwQqPED} zBO-+MOR(%Dp1Uz1>DVDo8#sN#X7LWPhY~r149t8T9lgLmO7#pU`+9)Px7q>>ODoXu z-LZU9;Q&I9iEIx$2XmT|S{TsGd0|xr3&}*YMfze|v>aDknXoaDphd%NbcuNYMRIpG z1KSb!!UrPr0v^yE7ngeVo$KmR=&g6dq@(Bkm1U1-mYlAWr0@O`89`G)f~C2^_R!nV zBrR=;Mtg4?6{Ls$oU|IXoBOZx{yG#Q6nTj_XGsYXhEkaApQ)({$&+c-P#9^f@OkA% zIu471)+=nPIsc0WiLMh0{A{_OavzEo_p>tJ@uo7iG%!>Ysx=8~i z)a4z0$+{pzP4fG!1(A{5`ruFZBbYjAm?9dtlf6Oo)1{n|*(Z!B#9*l(XqIS5Jg(J6 z>UVk1YBALSUC9HuO6tyA2-jDAEb;+YBe9UyBAaU>B=@*WFhSgeyolJCHD|wR6m4an zcEeO||FEwZakNY&8H7|C3a4l~Q8aE%Esai|267NK6FP<{MxOQCW*fxqPqq~czNB(J z>q?xqV5P~sBX7;94o#lfvb+(aQmR!w)J^kha>yi;y6(K^$w~K6h^dc1A${uUUM;`- zcRcc2osaCC@@4<)pK@*g0D+-g0i51;@;zrGKLKfqv!B42>_SX|fCG^gqOHdi%#D~HB{7MV{aHM3ke+zYCnoc0;xd0* z_(@=)w1#3aZNfGsa#~X|38#+nK$3W^XQ)a(Y^|&Yuqnt1CTyzg{{gQ+P`@9N(Yn#7 z#8YEhNkz2~8+T=S$=r#E>{T-<6>=g)@)1GfY5{@ujVgyv!Ye=wi%K*kB&O1P0nmVi z@7-5LQCa~jKny90;2(sZT*TBt%FC-}y6D^FrJ>#dvmifAYH)JYj3U#dWe(xGd3J4M)DDCMjx`zGRdn*3F#tLm3=1!DRrJ;% zX5z>wm;l8`G^#zvq=#W!wg}a08)je_3&8lvLD%@kY)!@lA$07T{NNoYjQ1nqHWC`d zD+pd8j{Nxg;a6J?J)-WbNBZWm|3hlafVq^=u&J`c`4$4g9YhLKbsW&IgCHZ_SP z2@rM}yg{t3rU)!pK7d*$3+aB^niP~B(C#A!1lc$Ah33k!fjPfGn|iIj%e$01PNaKI zqA9Jegb%#DD7qK}dJkOHzZ`-gF4!5b&CN~NQGgIeFRX)d7NQ(cF&5h?A10(Jgli|1 z47IyU?HPufQGx`nnb#XMB=9CVyW3cUorH}+v2jHpeA-E^vhvPiF%222M`o^^6;X2~=59jn_ zQK!O3F$uV1P3K>J@!zz6wl&*~qw(N=#~U!N?`W3(5cwQ$ps9zptxbs`_{zvizd8o> z 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 OUVKyBI1FQ9p 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 literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/openapi/ext-v1.yaml b/apps/elektrine/priv/static/openapi/ext-v1.yaml new file mode 100644 index 0000000..06c97d6 --- /dev/null +++ b/apps/elektrine/priv/static/openapi/ext-v1.yaml @@ -0,0 +1,1186 @@ +openapi: 3.0.3 +info: + title: Elektrine External API + version: "1.1.0" + description: >- + Versioned external API for PAT-based integrations. Authenticate with + `Authorization: Bearer ekt_...` using a personal access token created in + Account settings (Developer tab). Successful responses wrap the payload as + `{"data": ..., "meta": {"request_id": ...}}`; errors return + `{"error": {"code", "message", "details?"}, "meta": ...}`. +servers: + - url: https://example.com +security: + - bearerAuth: [] +paths: + /api/ext/v1/capabilities: + get: + summary: Inspect token capabilities and allowed endpoints + responses: + "200": + description: Token capabilities + /api/ext/v1/me: + get: + summary: Inspect the authenticated user and token metadata + responses: + "200": + description: Current user + /api/ext/v1/search: + get: + summary: Global search + parameters: + - in: query + name: q + required: true + schema: + type: string + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + responses: + "200": + description: Search results + /api/ext/v1/search/actions: + get: + summary: List available actions for token scopes + responses: + "200": + description: Action list + /api/ext/v1/search/actions/execute: + post: + summary: Execute an action by command + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + command: + type: string + responses: + "200": + description: Action executed + /api/ext/v1/mcp: + post: + summary: MCP JSON-RPC endpoint (streamable HTTP, stateless) + description: >- + Model Context Protocol endpoint for tool clients. Accepts one JSON-RPC + message per request; tool discovery and calls are filtered by the + token's scopes. Notifications return 202. GET and DELETE return 405 -- + the server is stateless, with no server-initiated streams or sessions. + See docs/clients/mcp.md. + requestBody: + required: true + content: + application/json: + schema: + type: object + description: A single JSON-RPC 2.0 message + responses: + "200": + description: JSON-RPC response + "202": + description: Notification accepted + /api/ext/v1/email/messages: + get: + summary: List email messages across the authenticated user's mailboxes + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + - in: query + name: offset + schema: + type: integer + minimum: 0 + - in: query + name: folder + schema: + type: string + - in: query + name: mailbox_id + schema: + type: integer + responses: + "200": + description: Message list + post: + summary: Send an email message + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + from: + type: string + description: >- + Optional sender address owned by the authenticated account. + Defaults to the primary mailbox address when omitted. + to: + type: string + subject: + type: string + text_body: + type: string + html_body: + type: string + cc: + type: string + bcc: + type: string + reply_to: + type: string + encryption_mode: + type: string + responses: + "201": + description: Email sent + "403": + description: Sender address is not owned by the authenticated account + /api/ext/v1/email/messages/{id}: + get: + summary: Get a single email message + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Email message + /api/ext/v1/chat/conversations: + get: + summary: List chat conversations + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + responses: + "200": + description: Conversation list + /api/ext/v1/chat/conversations/{id}: + get: + summary: Get a chat conversation with recent messages + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Conversation detail + /api/ext/v1/chat/conversations/{id}/messages: + get: + summary: List messages for a chat conversation + parameters: + - in: path + name: id + required: true + schema: + type: integer + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + - in: query + name: before_id + schema: + type: integer + - in: query + name: after_id + schema: + type: integer + responses: + "200": + description: Conversation message list + post: + summary: Send a message to a chat conversation + parameters: + - in: path + name: id + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + content: + type: string + message_type: + type: string + enum: [text, image, file, voice] + media_urls: + type: array + items: + type: string + reply_to_id: + type: integer + responses: + "201": + description: Conversation message created + /api/ext/v1/social/feed: + get: + summary: List home or public social feed posts + parameters: + - in: query + name: scope + schema: + type: string + enum: [home, public] + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + responses: + "200": + description: Feed posts + /api/ext/v1/social/posts/{id}: + get: + summary: Get a visible social post + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Social post + /api/ext/v1/social/posts: + post: + summary: Create a timeline post + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + content: + type: string + visibility: + type: string + enum: [public, unlisted, followers, friends, private] + title: + type: string + media_urls: + type: array + items: + type: string + responses: + "201": + description: Timeline post created + /api/ext/v1/social/users/{user_id}/posts: + get: + summary: List visible posts for a specific user + parameters: + - in: path + name: user_id + required: true + schema: + type: integer + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + responses: + "200": + description: User post list + /api/ext/v1/contacts: + get: + summary: List address book contacts + parameters: + - in: query + name: q + schema: + type: string + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + - in: query + name: offset + schema: + type: integer + minimum: 0 + responses: + "200": + description: Contact list + /api/ext/v1/contacts/{id}: + get: + summary: Get a single address book contact + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Contact detail + /api/ext/v1/calendars: + get: + summary: List calendars + responses: + "200": + description: Calendar list + post: + summary: Create calendar + responses: + "201": + description: Calendar created + /api/ext/v1/calendars/{id}/events: + get: + summary: List events for a calendar + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Event list + post: + summary: Create calendar event + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "201": + description: Event created + /api/ext/v1/events/{id}: + put: + summary: Update event + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Event updated + delete: + summary: Delete event + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Event deleted + /api/ext/v1/dns/zones: + get: + summary: List managed DNS zones + responses: + "200": + description: Zone list + post: + summary: Create a DNS zone + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [domain] + properties: + domain: + type: string + default_ttl: + type: integer + minimum: 1 + maximum: 86400 + force_https: + type: boolean + responses: + "201": + description: Zone created + /api/ext/v1/dns/zones/{id}: + get: + summary: Get a DNS zone with its records + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Zone detail + put: + summary: Update a DNS zone + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Zone updated + delete: + summary: Delete a DNS zone + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Zone deleted + /api/ext/v1/dns/zones/{id}/verify: + post: + summary: Verify delegation to Elektrine nameservers + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Zone with verification status and onboarding records + /api/ext/v1/dns/zones/{id}/services/{service}/apply: + post: + summary: Apply managed records for a platform service to a zone + parameters: + - in: path + name: id + required: true + schema: + type: integer + - in: path + name: service + required: true + schema: + type: string + enum: [mail, web, turn, dns, vpn, bluesky] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + service_config: + type: object + properties: + settings: + type: object + enabled: + type: boolean + mode: + type: string + enum: [managed, manual] + responses: + "200": + description: Service config applied + /api/ext/v1/dns/zones/{id}/services/{service}: + delete: + summary: Disable a managed service on a zone + parameters: + - in: path + name: id + required: true + schema: + type: integer + - in: path + name: service + required: true + schema: + type: string + enum: [mail, web, turn, dns, vpn, bluesky] + responses: + "200": + description: Service disabled + /api/ext/v1/dns/zones/{zone_id}/records: + post: + summary: Create a DNS record in a managed zone + parameters: + - in: path + name: zone_id + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, type, ttl, content] + properties: + name: + type: string + type: + type: string + enum: + - A + - AAAA + - ALIAS + - CAA + - CNAME + - DNSKEY + - DS + - HTTPS + - MX + - NS + - SRV + - SSHFP + - SVCB + - TLSA + - TXT + ttl: + type: integer + minimum: 1 + maximum: 86400 + content: + type: string + priority: + type: integer + weight: + type: integer + port: + type: integer + proxied: + type: boolean + responses: + "201": + description: Record created + /api/ext/v1/dns/zones/{zone_id}/records/{id}: + put: + summary: Update a DNS record + parameters: + - in: path + name: zone_id + required: true + schema: + type: integer + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Record updated + delete: + summary: Delete a DNS record + parameters: + - in: path + name: zone_id + required: true + schema: + type: integer + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Record deleted + /api/ext/v1/nerve/entries: + get: + summary: List encrypted nerve entries + responses: + "200": + description: Entry list + post: + summary: Create encrypted nerve entry + responses: + "201": + description: Entry created + /api/ext/v1/nerve/entries/{id}: + get: + summary: Get encrypted nerve entry + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Entry + put: + summary: Update encrypted nerve entry + parameters: + - in: path + name: id + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [encrypted_metadata, encrypted_password] + properties: + title: + type: string + login_username: + type: string + website: + type: string + encrypted_metadata: + type: string + encrypted_password: + type: string + encrypted_notes: + type: string + responses: + "200": + description: Entry updated + delete: + summary: Delete nerve entry + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Entry deleted + /api/ext/v1/nerve/setup: + post: + summary: Configure encrypted nerve verifier (deprecated) + deprecated: true + description: >- + Nerve now derives its verifier from the account password; this + endpoint always returns 400. + responses: + "400": + description: Master password setup is no longer supported + /api/ext/v1/proofs: + get: + summary: List identity proofs and current proof score + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + responses: + "200": + description: Proof list with score + post: + summary: Create an identity proof + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [subject] + properties: + kind: + type: string + enum: [dns, web, social] + claim_type: + type: string + enum: [positive, negative] + subject: + type: string + evidence_url: + type: string + proof_mode: + type: string + enum: [snapshot, live] + responses: + "201": + description: Proof created, with verification instructions + /api/ext/v1/proofs/score: + get: + summary: Get the current proof score + responses: + "200": + description: Proof score + /api/ext/v1/proofs/{id}: + get: + summary: Get a single identity proof + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Proof + delete: + summary: Delete an identity proof + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Proof deleted + /api/ext/v1/proofs/{id}/check: + post: + summary: Run the verification check for a proof + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Check result with updated proof + /api/ext/v1/static-site: + get: + summary: Inspect the current static site deployment + responses: + "200": + description: Static site details and file list + /api/ext/v1/static-site/deploy: + post: + summary: Deploy a static site from a ZIP archive + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [site] + properties: + site: + type: string + format: binary + description: ZIP archive of the site files + replace: + type: string + description: Replace the existing site (default "true") + responses: + "201": + description: Site deployed + /api/ext/v1/static-site/deploy/github: + post: + summary: Deploy a static site from GitHub Actions + description: >- + Authenticated with a GitHub Actions OIDC bearer token instead of a + PAT; repository and branch are resolved from the token claims. See + docs/clients/static-site-github-deploys.md. + security: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [site] + properties: + site: + type: string + format: binary + description: ZIP archive of the site files + responses: + "201": + description: Site deployed + /api/ext/v1/static-site/deploy/github/webhook: + post: + summary: GitHub push webhook for linked deployments + description: >- + Called by GitHub on push. Verified with the X-Hub-Signature-256 HMAC + header against the linked deployment's webhook secret; not + PAT-authenticated. + security: [] + responses: + "200": + description: Webhook received + /api/ext/v1/kairo/projects: + get: + summary: List Kairo projects + responses: + "200": + description: Project list + post: + summary: Create a Kairo project + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + slug: + type: string + description: + type: string + status: + type: string + enum: [active, archived] + autonomy_level: + type: integer + minimum: 0 + maximum: 5 + responses: + "201": + description: Project created + /api/ext/v1/kairo/projects/{id}: + patch: + summary: Update a Kairo project + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Project updated + put: + summary: Update a Kairo project + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Project updated + delete: + summary: Delete a Kairo project + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Project deleted + /api/ext/v1/kairo/sources: + get: + summary: List ingested Kairo sources + responses: + "200": + description: Source list + post: + summary: Ingest a Kairo source + description: >- + Accepts a JSON body describing the source, or multipart/form-data with + a `file` upload. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + project_id: + type: integer + source_type: + type: string + enum: + - url + - text + - markdown + - html + - json + - file + - image + - pdf + - email + - rss_item + - timeline_post + - webhook + title: + type: string + url: + type: string + content: + type: string + content_format: + type: string + tags: + type: array + items: + type: string + metadata: + type: object + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + "201": + description: Source ingested + /api/ext/v1/kairo/sources/{id}: + get: + summary: Get a single Kairo source + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Source + patch: + summary: Update a Kairo source + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Source updated + put: + summary: Update a Kairo source + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Source updated + delete: + summary: Delete a Kairo source + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Source deleted + /api/ext/v1/kairo/sources/{id}/retry: + post: + summary: Retry ingestion for a failed Kairo source + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "202": + description: Retry queued + /api/ext/v1/exports: + get: + summary: List exports + responses: + "200": + description: Export list + post: + summary: Create export + responses: + "202": + description: Export queued + /api/ext/v1/exports/{id}: + get: + summary: Get export status + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Export status + delete: + summary: Delete export + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Export deleted + /api/ext/v1/exports/{id}/download: + get: + summary: Download completed export + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Export file + /api/ext/v1/webhooks: + get: + summary: List webhooks and recent deliveries + responses: + "200": + description: Webhook list + post: + summary: Create webhook + description: >- + The signing secret is returned only in this response. Deliveries are + signed with an HMAC-SHA256 of the request body, sent as + `x-elektrine-signature: sha256=...` alongside `x-elektrine-event`, + `x-elektrine-delivery-id`, and `x-elektrine-timestamp` headers. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, url, events] + properties: + name: + type: string + url: + type: string + events: + type: array + minItems: 1 + maxItems: 20 + items: + type: string + enum: + - email.received + - email.sent + - message.received + - post.created + - post.liked + - follow.new + - export.completed + enabled: + type: boolean + responses: + "201": + description: Webhook created (response includes the signing secret) + /api/ext/v1/webhooks/{id}: + get: + summary: Get webhook and recent deliveries + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Webhook details + delete: + summary: Delete webhook + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Webhook deleted + /api/ext/v1/webhooks/{id}/test: + post: + summary: Send test webhook delivery + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Test sent + /api/ext/v1/webhooks/{id}/rotate-secret: + post: + summary: Rotate webhook secret + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Secret rotated + /api/ext/v1/webhooks/{id}/deliveries: + get: + summary: List webhook delivery history + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + description: Delivery list + /api/ext/v1/webhooks/{id}/deliveries/{delivery_id}/replay: + post: + summary: Replay a webhook delivery + parameters: + - in: path + name: id + required: true + schema: + type: integer + - in: path + name: delivery_id + required: true + schema: + type: integer + responses: + "202": + description: Replay queued +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: PAT diff --git a/apps/elektrine/priv/static/robots.txt b/apps/elektrine/priv/static/robots.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/apps/elektrine/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/apps/elektrine/priv/static/sw.js b/apps/elektrine/priv/static/sw.js new file mode 100644 index 0000000..afde8ba --- /dev/null +++ b/apps/elektrine/priv/static/sw.js @@ -0,0 +1,74 @@ +// Elektrine service worker: browser Web Push notifications. +// Served undigested at /sw.js so its scope covers the whole origin. + +const DEFAULT_ICON = "/images/android-chrome-192x192.png" + +self.addEventListener("install", () => { + self.skipWaiting() +}) + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()) +}) + +function parsePayload(event) { + if (!event.data) return {} + + try { + return event.data.json() || {} + } catch (_error) { + return { body: event.data.text() } + } +} + +function iconUrl(icon) { + // Payloads may carry a heroicon name (e.g. "hero-bell") instead of an image URL. + if (typeof icon === "string" && (icon.startsWith("http") || icon.startsWith("/"))) { + return icon + } + + return DEFAULT_ICON +} + +self.addEventListener("push", (event) => { + const payload = parsePayload(event) + const data = payload.data || {} + const title = payload.title || "Elektrine" + + const options = { + body: payload.body || "", + icon: iconUrl(payload.icon), + badge: DEFAULT_ICON, + data, + tag: data.notification_id ? `elektrine-notification-${data.notification_id}` : undefined, + } + + event.waitUntil(self.registration.showNotification(title, options)) +}) + +self.addEventListener("notificationclick", (event) => { + event.notification.close() + + const data = event.notification.data || {} + const target = new URL(data.url || "/notifications", self.location.origin).href + + event.waitUntil( + self.clients + .matchAll({ type: "window", includeUncontrolled: true }) + .then((windowClients) => { + for (const client of windowClients) { + if (client.url === target && "focus" in client) { + return client.focus() + } + } + + for (const client of windowClients) { + if ("focus" in client && "navigate" in client) { + return client.focus().then((focused) => focused.navigate(target)) + } + } + + return self.clients.openWindow(target) + }) + ) +}) diff --git a/apps/elektrine/rel/env.sh.eex b/apps/elektrine/rel/env.sh.eex new file mode 100755 index 0000000..efeb7ff --- /dev/null +++ b/apps/elektrine/rel/env.sh.eex @@ -0,0 +1,13 @@ +#!/bin/sh + +# configure node for distributed erlang with IPV6 support +export ERL_AFLAGS="-proto_dist inet6_tcp" +export ECTO_IPV6="true" +export DNS_CLUSTER_QUERY="${FLY_APP_NAME}.internal" +export RELEASE_DISTRIBUTION="name" +export RELEASE_NODE="${FLY_APP_NAME}-${FLY_IMAGE_REF##*-}@${FLY_PRIVATE_IP}" + +# Uncomment to send crash dumps to stderr +# This can be useful for debugging, but may log sensitive information +# export ERL_CRASH_DUMP=/dev/stderr +# export ERL_CRASH_DUMP_BYTES=4096 diff --git a/apps/elektrine/rel/overlays/bin/migrate b/apps/elektrine/rel/overlays/bin/migrate new file mode 100755 index 0000000..aa1e590 --- /dev/null +++ b/apps/elektrine/rel/overlays/bin/migrate @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")" +exec ./elektrine eval Elektrine.Release.migrate diff --git a/apps/elektrine/rel/overlays/bin/migrate.bat b/apps/elektrine/rel/overlays/bin/migrate.bat new file mode 100755 index 0000000..e8de5b6 --- /dev/null +++ b/apps/elektrine/rel/overlays/bin/migrate.bat @@ -0,0 +1 @@ +call "%~dp0\elektrine" eval Elektrine.Release.migrate diff --git a/apps/elektrine/rel/overlays/bin/server b/apps/elektrine/rel/overlays/bin/server new file mode 100755 index 0000000..7c00d4b --- /dev/null +++ b/apps/elektrine/rel/overlays/bin/server @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")" +PHX_SERVER=true exec ./elektrine start diff --git a/apps/elektrine/rel/overlays/bin/server.bat b/apps/elektrine/rel/overlays/bin/server.bat new file mode 100755 index 0000000..7b11f91 --- /dev/null +++ b/apps/elektrine/rel/overlays/bin/server.bat @@ -0,0 +1,2 @@ +set PHX_SERVER=true +call "%~dp0\elektrine" start diff --git a/apps/elektrine/test/elektrine/account_identifiers_test.exs b/apps/elektrine/test/elektrine/account_identifiers_test.exs new file mode 100644 index 0000000..a916212 --- /dev/null +++ b/apps/elektrine/test/elektrine/account_identifiers_test.exs @@ -0,0 +1,18 @@ +defmodule Elektrine.AccountIdentifiersTest do + use ExUnit.Case, async: false + + alias Elektrine.AccountIdentifiers + + test "formats local handles for maps and strings" do + assert AccountIdentifiers.local_handle(%{handle: "max", username: "other"}) == + "max@elektrine.com" + + assert AccountIdentifiers.at_local_handle(%{username: "max"}) == "@max@elektrine.com" + assert AccountIdentifiers.at_local_handle("max") == "@max@elektrine.com" + end + + test "formats public contact mailto for local users" do + assert AccountIdentifiers.public_contact_mailto(%{username: "max"}) == + "mailto:max@elektrine.com" + end +end diff --git a/apps/elektrine/test/elektrine/accounts/account_import_worker_test.exs b/apps/elektrine/test/elektrine/accounts/account_import_worker_test.exs new file mode 100644 index 0000000..92a9e84 --- /dev/null +++ b/apps/elektrine/test/elektrine/accounts/account_import_worker_test.exs @@ -0,0 +1,115 @@ +defmodule Elektrine.Accounts.AccountImportWorkerTest do + use Elektrine.DataCase, async: true + + import Elektrine.AccountsFixtures + + alias Elektrine.Accounts + alias Elektrine.Accounts.AccountImportWorker + alias Elektrine.ActivityPub.Actor + alias Elektrine.Repo + + test "imports remote mutes from cached account handles" do + user = user_fixture() + actor = remote_actor_fixture("alice", "remote-mute.example") + + assert :ok = + AccountImportWorker.perform(%Oban.Job{ + args: %{ + "user_id" => user.id, + "type" => "mute", + "identifier" => "alice@remote-mute.example" + } + }) + + assert Accounts.remote_actor_muted?(user.id, actor.id) + end + + test "imports remote mutes from cached actor URLs" do + user = user_fixture() + actor = remote_actor_fixture("urlmute", "remote-url-mute.example") + + assert :ok = + AccountImportWorker.perform(%Oban.Job{ + args: %{ + "user_id" => user.id, + "type" => "mute", + "identifier" => actor.uri + } + }) + + assert Accounts.remote_actor_muted?(user.id, actor.id) + end + + test "imports remote blocks from cached actor URLs" do + user = user_fixture() + actor = remote_actor_fixture("urlblock", "remote-url-block.example") + + assert :ok = + AccountImportWorker.perform(%Oban.Job{ + args: %{ + "user_id" => user.id, + "type" => "block", + "identifier" => actor.uri + } + }) + + assert Accounts.remote_actor_blocked?(user.id, actor.id) + end + + test "rejects imports above the durable queue cap" do + user = user_fixture() + + identifiers = + for index <- 1..(AccountImportWorker.max_identifiers() + 1) do + "person#{index}@example.com" + end + + assert {:error, :too_many_import_identifiers} = + AccountImportWorker.enqueue_many(user.id, "follow", identifiers) + end + + test "imports domain blocks without resolving them as accounts" do + user = user_fixture() + + assert :ok = + AccountImportWorker.perform(%Oban.Job{ + args: %{ + "user_id" => user.id, + "type" => "domain_block", + "identifier" => "https://Bad-Domain.example/users/alice" + } + }) + + assert Accounts.list_blocked_domains(user.id) == ["bad-domain.example"] + end + + test "strips UTF-8 BOMs from imported identifiers" do + user = user_fixture() + + assert :ok = + AccountImportWorker.perform(%Oban.Job{ + args: %{ + "user_id" => user.id, + "type" => "domain_block", + "identifier" => <<0xEF, 0xBB, 0xBF>> <> "bom-domain.example" + } + }) + + assert Accounts.list_blocked_domains(user.id) == ["bom-domain.example"] + end + + defp remote_actor_fixture(username, domain) do + %Actor{} + |> Actor.changeset(%{ + uri: "https://#{domain}/users/#{username}", + username: username, + domain: domain, + inbox_url: "https://#{domain}/users/#{username}/inbox", + public_key: "-----BEGIN RSA PUBLIC KEY-----\nMOCK\n-----END RSA PUBLIC KEY-----\n", + actor_type: "Person", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second), + metadata: %{} + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/accounts/account_notes_test.exs b/apps/elektrine/test/elektrine/accounts/account_notes_test.exs new file mode 100644 index 0000000..f4e414d --- /dev/null +++ b/apps/elektrine/test/elektrine/accounts/account_notes_test.exs @@ -0,0 +1,47 @@ +defmodule Elektrine.Accounts.AccountNotesTest do + use Elektrine.DataCase, async: true + + import Elektrine.AccountsFixtures + + alias Elektrine.Accounts + alias Elektrine.ActivityPub.Actor + alias Elektrine.Repo + + test "creates and updates private notes for local accounts" do + source = user_fixture() + target = user_fixture() + + assert {:ok, note} = Accounts.put_account_note(source.id, {:user, target.id}, " first ") + assert note.comment == "first" + assert Accounts.account_note_comment(source.id, {:user, target.id}) == "first" + + assert {:ok, updated} = Accounts.put_account_note(source.id, {:user, target.id}, "second") + assert updated.id == note.id + assert updated.comment == "second" + end + + test "creates private notes for remote actors" do + source = user_fixture() + actor = remote_actor_fixture() + + assert {:ok, note} = + Accounts.put_account_note(source.id, {:remote_actor, actor.id}, "remote note") + + assert note.comment == "remote note" + assert Accounts.account_note_comment(source.id, {:remote_actor, actor.id}) == "remote note" + end + + defp remote_actor_fixture do + unique = System.unique_integer([:positive]) + + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.example/users/note#{unique}", + username: "note#{unique}", + domain: "remote.example", + inbox_url: "https://remote.example/users/note#{unique}/inbox", + public_key: "-----BEGIN RSA PUBLIC KEY-----\nMOCK\n-----END RSA PUBLIC KEY-----\n" + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/accounts/app_password_test.exs b/apps/elektrine/test/elektrine/accounts/app_password_test.exs new file mode 100644 index 0000000..b5fce47 --- /dev/null +++ b/apps/elektrine/test/elektrine/accounts/app_password_test.exs @@ -0,0 +1,297 @@ +defmodule Elektrine.Accounts.AppPasswordTest do + use Elektrine.DataCase + + import Elektrine.AccountsFixtures + + alias Elektrine.Accounts + alias Elektrine.Accounts.AppPassword + alias Elektrine.Email + alias Elektrine.Repo + + describe "app password authentication" do + test "generates high-entropy displayed tokens and stores Argon2id hashes" do + user = user_fixture() + + assert {:ok, app_password} = Accounts.create_app_password(user.id, %{name: "Mail client"}) + + assert app_password.token =~ ~r/^[a-z2-7]{4}(-[a-z2-7]{4}){7}$/ + assert String.starts_with?(app_password.token_hash, "v3$argon2id$$argon2id$") + + clean_token = String.replace(app_password.token, "-", "") + refute app_password.token_hash == legacy_sha256_hash(clean_token) + assert AppPassword.verify_token(clean_token, app_password.token_hash) + end + + test "creates and authenticates app passwords without global runtime secrets" do + user = user_fixture() + + with_app_password_secrets(nil, fn -> + assert {:ok, app_password} = Accounts.create_app_password(user.id, %{name: "Secretless"}) + assert String.starts_with?(app_password.token_hash, "v3$argon2id$") + + assert {:ok, authenticated_user} = + Accounts.authenticate_with_app_password(user.username, app_password.token) + + assert authenticated_user.id == user.id + end) + end + + test "authenticates generated app passwords with display separators and uppercase typing" do + user = user_fixture() + + assert {:ok, app_password} = Accounts.create_app_password(user.id, %{name: "Thunderbird"}) + + assert {:ok, authenticated_user} = + Accounts.authenticate_with_app_password(user.username, app_password.token) + + assert authenticated_user.id == user.id + + typed_token = String.upcase(app_password.token) + + assert {:ok, authenticated_user} = + Accounts.authenticate_with_app_password(user.username, typed_token) + + assert authenticated_user.id == user.id + end + + test "authenticates generated app passwords with mailbox email identifier" do + user = user_fixture() + {:ok, mailbox} = Email.ensure_user_has_mailbox(user) + assert {:ok, app_password} = Accounts.create_app_password(user.id, %{name: "Mail app"}) + + assert {:ok, authenticated_user} = + Accounts.authenticate_with_app_password(mailbox.email, app_password.token) + + assert authenticated_user.id == user.id + end + + test "accepts v3 hashes created from displayed tokens with separators" do + user = user_fixture() + token = "abcd-efgh-ijkl-mnop-qrst-uvwx-yz23-4567" + + {:ok, _app_password} = + %AppPassword{} + |> AppPassword.changeset(%{ + name: "Displayed token hash", + user_id: user.id, + token_hash: AppPassword.hash_token(token) + }) + |> Repo.insert() + + assert {:ok, authenticated_user} = + Accounts.authenticate_with_app_password(user.username, token) + + assert authenticated_user.id == user.id + end + + test "accepts v3 Argon2id hashes created from legacy displayed tokens" do + user = user_fixture() + token = "abcd-efgh-ijkl-mnop-qrst-uvwx-yz23-4567" + + {:ok, _app_password} = + %AppPassword{} + |> AppPassword.changeset(%{ + name: "Legacy displayed Argon2id token", + user_id: user.id, + token_hash: "v3$argon2id$" <> Argon2.hash_pwd_salt(token) + }) + |> Repo.insert() + + assert {:ok, authenticated_user} = + Accounts.authenticate_with_app_password(user.username, String.upcase(token)) + + assert authenticated_user.id == user.id + end + + test "accepts raw Argon2id app password hashes without v3 prefix" do + user = user_fixture() + token = "abcd-efgh-ijkl-mnop-qrst-uvwx-yz23-4567" + + {:ok, _app_password} = + %AppPassword{} + |> AppPassword.changeset(%{ + name: "Raw Argon2id token", + user_id: user.id, + token_hash: Argon2.hash_pwd_salt(token) + }) + |> Repo.insert() + + assert {:ok, authenticated_user} = + Accounts.authenticate_with_app_password(user.username, token) + + assert authenticated_user.id == user.id + end + + test "still accepts existing v2 HMAC app password hashes when the pepper exists" do + user = user_fixture() + token = "abcd-efgh-ijkl-mnop-qrst-uvwx-yz23-4567" + pepper = "stable-app-password-pepper" + + with_app_password_secrets(pepper, fn -> + {:ok, _app_password} = + %AppPassword{} + |> AppPassword.changeset(%{ + name: "Old HMAC mail client", + user_id: user.id, + token_hash: hmac_sha256_hash(normalized_current_token(token), pepper) + }) + |> Repo.insert() + + assert {:ok, authenticated_user} = + Accounts.authenticate_with_app_password(user.username, token) + + assert authenticated_user.id == user.id + end) + end + + test "ignores copy-paste separators around current generated tokens" do + user = user_fixture() + assert {:ok, app_password} = Accounts.create_app_password(user.id, %{name: "Mail app"}) + + pasted_token = + app_password.token + |> String.replace("-", "\u2011") + |> then(&(" " <> &1 <> "\n")) + + assert {:ok, authenticated_user} = + Accounts.authenticate_with_app_password(user.username, pasted_token) + + assert authenticated_user.id == user.id + end + + test "rejects a token after its app password is deleted" do + user = user_fixture() + {:ok, app_password} = Accounts.create_app_password(user.id, %{name: "Mail client"}) + + assert {:ok, authenticated_user} = + Accounts.authenticate_with_app_password(user.username, app_password.token) + + assert authenticated_user.id == user.id + + assert {:ok, _deleted_app_password} = + Accounts.delete_app_password(app_password.id, user.id) + + assert {:error, {:invalid_token, invalid_token_user}} = + Accounts.authenticate_with_app_password(user.username, app_password.token) + + assert invalid_token_user.id == user.id + end + + test "rejects existing legacy SHA-256 app password hashes" do + user = user_fixture() + token = "legacytoken#{System.unique_integer([:positive])}" + + {:ok, _app_password} = + %AppPassword{} + |> AppPassword.changeset(%{ + name: "Old mail client", + user_id: user.id, + token_hash: legacy_sha256_hash(token) + }) + |> Repo.insert() + + assert {:error, {:invalid_token, invalid_token_user}} = + Accounts.authenticate_with_app_password(user.username, token) + + assert invalid_token_user.id == user.id + end + + test "rejects original 4-group SHA-256 app password hashes" do + user = user_fixture() + token = "abcd-efgh-ijkl-mnop" + clean_token = "abcdefghijklmnop" + + {:ok, _app_password} = + %AppPassword{} + |> AppPassword.changeset(%{ + name: "Original mail client token", + user_id: user.id, + token_hash: legacy_sha256_hash(clean_token) + }) + |> Repo.insert() + + for typed_token <- [token, String.upcase(token), clean_token] do + assert {:error, {:invalid_token, invalid_token_user}} = + Accounts.authenticate_with_app_password(user.username, typed_token) + + assert invalid_token_user.id == user.id + end + end + + test "rejects existing legacy SHA-256 app password hashes with literal hyphens" do + user = user_fixture() + token = "legacy-token-#{System.unique_integer([:positive])}" + + {:ok, _app_password} = + %AppPassword{} + |> AppPassword.changeset(%{ + name: "Old mail client with url-safe token", + user_id: user.id, + token_hash: legacy_sha256_hash(token) + }) + |> Repo.insert() + + assert {:error, {:invalid_token, invalid_token_user}} = + Accounts.authenticate_with_app_password(user.username, token) + + assert invalid_token_user.id == user.id + end + + test "reports app password hash versions for diagnostics" do + assert AppPassword.hash_version("v3$argon2id$" <> Argon2.hash_pwd_salt("token")) == + :v3_argon2id + + assert AppPassword.hash_version(Argon2.hash_pwd_salt("token")) == :argon2id + assert AppPassword.hash_version("v2$hmac-sha256$abc") == :v2_hmac + assert AppPassword.hash_version(legacy_sha256_hash("token")) == :unknown + assert AppPassword.hash_version(nil) == :unknown + end + end + + defp legacy_sha256_hash(token) do + :crypto.hash(:sha256, token) + |> Base.encode16(case: :lower) + end + + defp hmac_sha256_hash(token, pepper) do + "v2$hmac-sha256$" <> + (:crypto.mac(:hmac, :sha256, pepper, token) |> Base.url_encode64(padding: false)) + end + + defp normalized_current_token(token) do + token + |> String.replace(~r/[^a-z2-7]/i, "") + |> String.downcase() + end + + defp with_app_password_secrets(pepper, fun) do + old_pepper = Application.get_env(:elektrine, :app_password_pepper) + old_encryption_secret = Application.get_env(:elektrine, :encryption_master_secret) + old_endpoint_config = Application.get_env(:elektrine, ElektrineWeb.Endpoint, []) + + try do + if pepper do + Application.put_env(:elektrine, :app_password_pepper, pepper) + else + Application.delete_env(:elektrine, :app_password_pepper) + end + + Application.delete_env(:elektrine, :encryption_master_secret) + + Application.put_env( + :elektrine, + ElektrineWeb.Endpoint, + Keyword.delete(old_endpoint_config, :secret_key_base) + ) + + fun.() + after + restore_env(:app_password_pepper, old_pepper) + restore_env(:encryption_master_secret, old_encryption_secret) + Application.put_env(:elektrine, ElektrineWeb.Endpoint, old_endpoint_config) + end + end + + defp restore_env(key, nil), do: Application.delete_env(:elektrine, key) + defp restore_env(key, value), do: Application.put_env(:elektrine, key, value) +end diff --git a/apps/elektrine/test/elektrine/accounts/capabilities_test.exs b/apps/elektrine/test/elektrine/accounts/capabilities_test.exs new file mode 100644 index 0000000..96c5db1 --- /dev/null +++ b/apps/elektrine/test/elektrine/accounts/capabilities_test.exs @@ -0,0 +1,108 @@ +defmodule Elektrine.Accounts.CapabilitiesTest do + use Elektrine.DataCase, async: false + + alias Atomine.Personhood + alias Elektrine.Accounts.Capabilities + alias Elektrine.AccountsFixtures + alias Elektrine.Repo + + test "email limits come from the central capability policy" do + user = AccountsFixtures.user_fixture() + + assert %{ + tier: :day_1, + minute_limit: 1, + hour_limit: 5, + day_limit: 10, + recipient_limit: 3 + } = Capabilities.email_limits(user) + + user = Ecto.Changeset.change(user, %{trust_level: 2}) |> Repo.update!() + + assert %{ + tier: :tl2, + minute_limit: 10, + hour_limit: 100, + day_limit: 500, + recipient_limit: 100 + } = Capabilities.email_limits(user) + end + + test "verified proof reputation raises effective email tier without mutating trust level" do + user = AccountsFixtures.user_fixture() + + {:ok, dns_proof} = Personhood.create_proof(user, %{kind: "dns", subject: unique_domain()}) + {:ok, _verified_dns} = Personhood.verify_proof(dns_proof) + + assert %{tier: :tl1, day_limit: 200, recipient_limit: 50} = Capabilities.email_limits(user) + assert Repo.reload!(user).trust_level == 0 + + {:ok, web_proof} = + Personhood.create_proof(user, %{kind: "web", subject: "https://#{unique_domain()}/proof"}) + + {:ok, _verified_web} = Personhood.verify_proof(web_proof) + + assert %{tier: :tl2, day_limit: 500, recipient_limit: 100} = Capabilities.email_limits(user) + + snapshot = Capabilities.snapshot(user) + + assert snapshot.trust_level == 0 + assert snapshot.effective_trust_level == 2 + assert snapshot.capabilities.email.tier == :tl2 + end + + test "credit gates require credits only for low-trust users when enabled" do + previous_config = Application.get_env(:atomine, :credits, []) + + on_exit(fn -> Application.put_env(:atomine, :credits, previous_config) end) + + Application.put_env(:atomine, :credits, email_gate_enabled: true, dm_gate_enabled: true) + + user = AccountsFixtures.user_fixture() + + assert :required = Capabilities.email_credit_requirement(user) + assert :required = Capabilities.first_dm_credit_requirement(user) + + trusted_user = Ecto.Changeset.change(user, %{trust_level: 1}) |> Repo.update!() + + assert :required = Capabilities.email_credit_requirement(trusted_user) + assert :free = Capabilities.first_dm_credit_requirement(trusted_user) + + high_trust_user = Ecto.Changeset.change(trusted_user, %{trust_level: 3}) |> Repo.update!() + + assert :free = Capabilities.email_credit_requirement(high_trust_user) + assert :free = Capabilities.first_dm_credit_requirement(high_trust_user) + + admin_user = Ecto.Changeset.change(user, %{is_admin: true}) |> Repo.update!() + + assert :free = Capabilities.email_credit_requirement(admin_user) + assert :free = Capabilities.first_dm_credit_requirement(admin_user) + + proof_backed_user = AccountsFixtures.user_fixture() + + {:ok, proof} = + Personhood.create_proof(proof_backed_user, %{kind: "dns", subject: unique_domain()}) + + {:ok, _verified} = Personhood.verify_proof(proof) + + assert :required = Capabilities.email_credit_requirement(proof_backed_user) + assert :free = Capabilities.first_dm_credit_requirement(proof_backed_user) + end + + test "snapshot ties trust, reputation, credits, email, vpn, and invites together" do + user = AccountsFixtures.user_fixture() + snapshot = Capabilities.snapshot(user) + + assert snapshot.trust_level == 0 + assert snapshot.reputation.score >= 0 + assert snapshot.proofs.verified_count >= 0 + assert is_map(snapshot.credits.balances) + assert snapshot.capabilities.email.tier == :day_1 + assert snapshot.capabilities.vpn.allowed == true + assert snapshot.capabilities.invites.self_service_allowed == false + end + + defp unique_domain do + "proof-#{System.unique_integer([:positive])}.example.com" + end +end diff --git a/apps/elektrine/test/elektrine/accounts/connected_account_test.exs b/apps/elektrine/test/elektrine/accounts/connected_account_test.exs new file mode 100644 index 0000000..4f43c50 --- /dev/null +++ b/apps/elektrine/test/elektrine/accounts/connected_account_test.exs @@ -0,0 +1,57 @@ +defmodule Elektrine.Accounts.ConnectedAccountTest do + use Elektrine.DataCase, async: true + + alias Elektrine.Accounts + alias Elektrine.Accounts.ConnectedAccount + alias Elektrine.AccountsFixtures + + describe "connected accounts" do + test "upserts a provider account for a user" do + user = AccountsFixtures.user_fixture() + + assert {:ok, account} = + Accounts.upsert_connected_account(user, %{ + provider: "GitHub", + provider_account_id: "12345", + username: "octo", + profile_url: "https://github.com/octo", + scopes: ["read:user"] + }) + + assert %ConnectedAccount{} = account + assert account.provider == "github" + assert account.provider_account_id == "12345" + assert account.username == "octo" + assert account.last_verified_at + + assert [^account] = Accounts.list_connected_accounts(user.id) + + assert {:ok, updated} = + Accounts.upsert_connected_account(user, %{ + provider: "github", + provider_account_id: "12345", + username: "octocat" + }) + + assert updated.id == account.id + assert updated.username == "octocat" + end + + test "does not allow one provider account to attach to two users" do + user = AccountsFixtures.user_fixture() + other_user = AccountsFixtures.user_fixture() + + assert {:ok, _account} = + Accounts.upsert_connected_account(user, %{ + provider: "github", + provider_account_id: "12345" + }) + + assert {:error, :provider_account_already_connected} = + Accounts.upsert_connected_account(other_user, %{ + provider: "github", + provider_account_id: "12345" + }) + end + end +end diff --git a/apps/elektrine/test/elektrine/accounts/domain_blocks_test.exs b/apps/elektrine/test/elektrine/accounts/domain_blocks_test.exs new file mode 100644 index 0000000..9fe3ba5 --- /dev/null +++ b/apps/elektrine/test/elektrine/accounts/domain_blocks_test.exs @@ -0,0 +1,53 @@ +defmodule Elektrine.Accounts.DomainBlocksTest do + use Elektrine.DataCase, async: true + + import Elektrine.AccountsFixtures + + alias Elektrine.Accounts + + describe "domain blocks" do + test "blocks domains idempotently and lists normalized domains" do + user = user_fixture() + + assert {:ok, _block} = Accounts.block_domain(user.id, " HTTPS://Example.COM/path ") + assert {:ok, _block} = Accounts.block_domain(user.id, "example.com") + assert Accounts.list_blocked_domains(user.id) == ["example.com"] + end + + test "supports wildcard domain blocks" do + user = user_fixture() + + assert {:ok, _block} = Accounts.block_domain(user.id, "*.Example.COM") + assert Accounts.list_blocked_domains(user.id) == ["*.example.com"] + end + + test "checks exact and wildcard domain block matches" do + user = user_fixture() + other_user = user_fixture() + + assert {:ok, _block} = Accounts.block_domain(user.id, "example.com") + assert {:ok, _block} = Accounts.block_domain(user.id, "*.blocked.example") + + assert Accounts.domain_blocked?(user.id, "example.com") + assert Accounts.domain_blocked?(user.id, "News.Blocked.Example") + refute Accounts.domain_blocked?(user.id, "safe.example") + refute Accounts.domain_blocked?(other_user.id, "example.com") + end + + test "rejects invalid domains" do + user = user_fixture() + + assert {:error, :invalid_domain} = Accounts.block_domain(user.id, "localhost") + assert {:error, :invalid_domain} = Accounts.block_domain(user.id, "bad domain.com") + end + + test "unblocks domains idempotently" do + user = user_fixture() + + assert {:ok, _block} = Accounts.block_domain(user.id, "example.com") + assert {:ok, _deleted} = Accounts.unblock_domain(user.id, "example.com") + assert {:ok, :not_blocked} = Accounts.unblock_domain(user.id, "example.com") + assert Accounts.list_blocked_domains(user.id) == [] + end + end +end diff --git a/apps/elektrine/test/elektrine/accounts/invite_codes_test.exs b/apps/elektrine/test/elektrine/accounts/invite_codes_test.exs new file mode 100644 index 0000000..fed7d83 --- /dev/null +++ b/apps/elektrine/test/elektrine/accounts/invite_codes_test.exs @@ -0,0 +1,344 @@ +defmodule Elektrine.Accounts.InviteCodesTest do + use Elektrine.DataCase + + import Ecto.Query + + alias Elektrine.Accounts + alias Elektrine.Accounts.InviteCode + alias Elektrine.Accounts.InviteCodeUse + alias Elektrine.AccountsFixtures + alias Elektrine.Repo + + describe "invite code normalization" do + test "generates high-entropy uppercase invite codes" do + code = InviteCode.generate_code() + + assert String.length(code) >= 26 + assert code =~ ~r/^[A-Z2-7]+$/ + end + + test "creates a generated code when no code is supplied" do + admin = AccountsFixtures.user_fixture() + + assert {:ok, invite_code} = Accounts.create_invite_code(%{created_by_id: admin.id}) + assert String.length(invite_code.code) >= 26 + assert invite_code.code =~ ~r/^[A-Z2-7]+$/ + end + + test "normalizes invite codes on create and lookup" do + admin = AccountsFixtures.user_fixture() + + assert {:ok, invite_code} = + Accounts.create_invite_code(%{ + code: " mixed123 ", + max_uses: 2, + created_by_id: admin.id + }) + + assert invite_code.code == "MIXED123" + assert Accounts.get_invite_code_by_code("mixed123").id == invite_code.id + assert Accounts.get_invite_code_by_code(" MIXED123 ").id == invite_code.id + end + + test "rejects duplicate codes case-insensitively" do + admin = AccountsFixtures.user_fixture() + + assert {:ok, _invite_code} = + Accounts.create_invite_code(%{ + code: "ALPHA123", + created_by_id: admin.id + }) + + assert {:error, changeset} = + Accounts.create_invite_code(%{ + code: " alpha123 ", + created_by_id: admin.id + }) + + assert "has already been taken" in errors_on(changeset).code + end + end + + describe "invite code updates" do + test "does not allow lowering max uses below current usage" do + admin = AccountsFixtures.user_fixture() + first_user = AccountsFixtures.user_fixture() + + assert {:ok, invite_code} = + Accounts.create_invite_code(%{ + code: "LIMIT123", + max_uses: 2, + created_by_id: admin.id + }) + + assert {:ok, _claimed_code} = Accounts.use_invite_code(invite_code.code, first_user.id) + + invite_code = Accounts.get_invite_code!(invite_code.id) + + assert {:error, changeset} = Accounts.update_invite_code(invite_code, %{max_uses: 0}) + assert "cannot be less than current uses (1)" in errors_on(changeset).max_uses + end + + test "ignores code changes on update" do + admin = AccountsFixtures.user_fixture() + + assert {:ok, invite_code} = + Accounts.create_invite_code(%{ + code: "LOCKED12", + note: "before", + created_by_id: admin.id + }) + + assert {:ok, updated_invite_code} = + Accounts.update_invite_code(invite_code, %{ + code: "CHANGED12", + note: "after" + }) + + assert updated_invite_code.code == "LOCKED12" + assert updated_invite_code.note == "after" + end + end + + describe "register_user_with_invite/1" do + test "creates the user and records invite usage atomically" do + admin = AccountsFixtures.user_fixture() + + assert {:ok, invite_code} = + Accounts.create_invite_code(%{ + code: "JOIN123", + created_by_id: admin.id + }) + + username = "invited#{System.unique_integer([:positive])}" + + assert {:ok, user} = + Accounts.register_user_with_invite(%{ + username: username, + password: "validpassword123", + password_confirmation: "validpassword123", + invite_code: String.downcase(invite_code.code) + }) + + invite_code = Accounts.get_invite_code!(invite_code.id) + + assert invite_code.uses_count == 1 + + assert Repo.get_by(InviteCodeUse, invite_code_id: invite_code.id, user_id: user.id) + assert Accounts.get_user_by_username(username).id == user.id + end + + test "rolls back the user when the invite code is invalid" do + username = "rolledback#{System.unique_integer([:positive])}" + + assert {:error, changeset} = + Accounts.register_user_with_invite(%{ + username: username, + password: "validpassword123", + password_confirmation: "validpassword123", + invite_code: "missing12" + }) + + assert "Invalid invite code" in errors_on(changeset).invite_code + refute Accounts.get_user_by_username(username) + end + + test "rolls back the user when the invite code is exhausted" do + admin = AccountsFixtures.user_fixture() + first_user = AccountsFixtures.user_fixture() + + assert {:ok, invite_code} = + Accounts.create_invite_code(%{ + code: "FULL123", + max_uses: 1, + created_by_id: admin.id + }) + + assert {:ok, _claimed_code} = Accounts.use_invite_code(invite_code.code, first_user.id) + + username = "exhausted#{System.unique_integer([:positive])}" + + assert {:error, changeset} = + Accounts.register_user_with_invite(%{ + username: username, + password: "validpassword123", + password_confirmation: "validpassword123", + invite_code: invite_code.code + }) + + assert "This invite code has reached its usage limit" in errors_on(changeset).invite_code + refute Accounts.get_user_by_username(username) + end + end + + describe "get_invite_code_stats/0" do + test "counts only usable codes as active" do + admin = AccountsFixtures.user_fixture() + exhausted_user = AccountsFixtures.user_fixture() + + assert {:ok, _active_code} = + Accounts.create_invite_code(%{ + code: "ACTIVE12", + created_by_id: admin.id + }) + + assert {:ok, _expired_code} = + Accounts.create_invite_code(%{ + code: "EXPIRE12", + expires_at: DateTime.add(DateTime.utc_now(), -3600, :second), + created_by_id: admin.id + }) + + assert {:ok, exhausted_code} = + Accounts.create_invite_code(%{ + code: "SPENT123", + max_uses: 1, + created_by_id: admin.id + }) + + assert {:ok, _inactive_code} = + Accounts.create_invite_code(%{ + code: "OFFLINE1", + is_active: false, + created_by_id: admin.id + }) + + assert {:ok, _claimed_code} = + Accounts.use_invite_code(exhausted_code.code, exhausted_user.id) + + assert %{total: 4, active: 1, expired: 1, exhausted: 1} = Accounts.get_invite_code_stats() + end + end + + describe "self-service invite codes" do + setup do + previous_value = Elektrine.System.invite_codes_enabled?() + previous_min_trust_level = Elektrine.System.self_service_invite_min_trust_level() + + on_exit(fn -> + Elektrine.System.set_invite_codes_enabled(previous_value) + Elektrine.System.set_self_service_invite_min_trust_level(previous_min_trust_level) + end) + + {:ok, _config} = Elektrine.System.set_invite_codes_enabled(true) + {:ok, _config} = Elektrine.System.set_self_service_invite_min_trust_level(1) + :ok + end + + test "trusted users can create constrained invite codes" do + user = AccountsFixtures.user_fixture() + {:ok, trusted_user} = Accounts.admin_update_user(user, %{trust_level: 1}) + + assert {:ok, invite_code} = + Accounts.create_self_service_invite_code(trusted_user, %{note: "Friend access"}) + + assert invite_code.created_by_id == trusted_user.id + assert invite_code.max_uses == 1 + assert invite_code.note == "Friend access" + assert DateTime.diff(invite_code.expires_at, DateTime.utc_now(), :second) > 13 * 86_400 + + assert [listed_invite_code] = Accounts.list_user_invite_codes(trusted_user.id) + assert listed_invite_code.id == invite_code.id + end + + test "requires trust level for self-service invites" do + user = AccountsFixtures.user_fixture() + + assert {:error, :insufficient_trust_level} = + Accounts.create_self_service_invite_code(user, %{}) + end + + test "enforces the active self-service invite limit" do + user = AccountsFixtures.user_fixture() + {:ok, trusted_user} = Accounts.admin_update_user(user, %{trust_level: 1}) + + for _ <- 1..5 do + assert {:ok, _invite_code} = Accounts.create_self_service_invite_code(trusted_user, %{}) + end + + assert {:error, :invite_code_limit_reached} = + Accounts.create_self_service_invite_code(trusted_user, %{}) + end + + test "enforces a monthly self-service invite generation limit even after deactivation" do + user = AccountsFixtures.user_fixture() + {:ok, trusted_user} = Accounts.admin_update_user(user, %{trust_level: 1}) + + invite_codes = + for _ <- 1..5 do + assert {:ok, invite_code} = + Accounts.create_self_service_invite_code(trusted_user, %{}) + + invite_code + end + + for invite_code <- invite_codes do + assert {:ok, _invite_code} = + Accounts.deactivate_self_service_invite_code(trusted_user, invite_code.id) + end + + assert {:error, :monthly_invite_code_limit_reached} = + Accounts.create_self_service_invite_code(trusted_user, %{}) + end + + test "blocks registrations after a creator reaches the monthly invite redemption limit" do + user = AccountsFixtures.user_fixture() + {:ok, trusted_user} = Accounts.admin_update_user(user, %{trust_level: 1}) + + invite_codes = + for _ <- 1..5 do + assert {:ok, invite_code} = + Accounts.create_self_service_invite_code(trusted_user, %{}) + + backdate_invite_code_to_previous_month(invite_code) + end + + for invite_code <- invite_codes do + assert {:ok, _user} = + Accounts.register_user_with_invite(%{ + username: "monthlyused#{System.unique_integer([:positive])}", + password: "validpassword123", + password_confirmation: "validpassword123", + invite_code: invite_code.code + }) + end + + assert {:ok, current_month_invite} = + Accounts.create_self_service_invite_code(trusted_user, %{}) + + assert {:error, changeset} = + Accounts.register_user_with_invite(%{ + username: "monthlyblocked#{System.unique_integer([:positive])}", + password: "validpassword123", + password_confirmation: "validpassword123", + invite_code: current_month_invite.code + }) + + assert "This invite code is temporarily unavailable right now" in errors_on(changeset).invite_code + end + + test "uses the configured minimum trust level" do + user = AccountsFixtures.user_fixture() + {:ok, semi_trusted_user} = Accounts.admin_update_user(user, %{trust_level: 1}) + {:ok, _config} = Elektrine.System.set_self_service_invite_min_trust_level(2) + + assert {:error, :insufficient_trust_level} = + Accounts.create_self_service_invite_code(semi_trusted_user, %{}) + end + end + + defp backdate_invite_code_to_previous_month(%InviteCode{} = invite_code) do + previous_month_start = + Date.utc_today() + |> Date.beginning_of_month() + |> Date.add(-1) + |> Date.beginning_of_month() + + previous_month_timestamp = NaiveDateTime.new!(previous_month_start, ~T[00:00:00]) + + from(i in InviteCode, where: i.id == ^invite_code.id) + |> Repo.update_all(set: [inserted_at: previous_month_timestamp]) + + %{invite_code | inserted_at: previous_month_timestamp} + end +end diff --git a/apps/elektrine/test/elektrine/accounts/muting_test.exs b/apps/elektrine/test/elektrine/accounts/muting_test.exs new file mode 100644 index 0000000..8eb5908 --- /dev/null +++ b/apps/elektrine/test/elektrine/accounts/muting_test.exs @@ -0,0 +1,93 @@ +defmodule Elektrine.Accounts.MutingTest do + use Elektrine.DataCase + + import Elektrine.AccountsFixtures + + alias Elektrine.Accounts + alias Elektrine.Accounts.UserMute + alias Elektrine.Repo + + describe "mute_user/3 and unmute_user/2" do + test "mutes and unmutes users" do + user = user_fixture() + target = user_fixture() + + assert {:ok, mute} = Accounts.mute_user(user.id, target.id, false) + refute mute.mute_notifications + assert Accounts.user_muted?(user.id, target.id) + refute Accounts.user_muting_notifications?(user.id, target.id) + + assert {:ok, _mute} = Accounts.unmute_user(user.id, target.id) + refute Accounts.user_muted?(user.id, target.id) + end + + test "updates existing mute notification preference" do + user = user_fixture() + target = user_fixture() + + assert {:ok, _mute} = Accounts.mute_user(user.id, target.id, false) + refute Accounts.user_muting_notifications?(user.id, target.id) + + assert {:ok, updated} = Accounts.mute_user(user.id, target.id, true) + assert updated.mute_notifications + assert Accounts.user_muting_notifications?(user.id, target.id) + end + + test "cannot mute yourself" do + user = user_fixture() + + assert {:error, changeset} = Accounts.mute_user(user.id, user.id, false) + assert "cannot mute yourself" in errors_on(changeset).muted_id + end + + test "unmuting a non-muted user returns not_muted" do + user = user_fixture() + target = user_fixture() + + assert {:error, :not_muted} = Accounts.unmute_user(user.id, target.id) + end + + test "temporary mutes expire on read" do + user = user_fixture() + target = user_fixture() + expires_at = DateTime.utc_now() |> DateTime.add(-60, :second) |> DateTime.truncate(:second) + + assert {:ok, mute} = Accounts.mute_user(user.id, target.id, true, expires_at) + assert mute.expires_at == expires_at + + refute Accounts.user_muted?(user.id, target.id) + assert {:error, :not_muted} = Accounts.unmute_user(user.id, target.id) + end + + test "future temporary mutes remain active" do + user = user_fixture() + target = user_fixture() + + assert {:ok, mute} = Accounts.mute_user(user.id, target.id, true, 3600) + assert mute.expires_at + assert Accounts.user_muted?(user.id, target.id) + assert Accounts.user_muting_notifications?(user.id, target.id) + end + + test "expire_due_mutes removes all due mutes" do + user = user_fixture() + target = user_fixture() + other = user_fixture() + + past = DateTime.utc_now() |> DateTime.add(-60, :second) |> DateTime.truncate(:second) + future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second) + + %UserMute{} + |> UserMute.changeset(%{muter_id: user.id, muted_id: target.id, expires_at: past}) + |> Repo.insert!() + + %UserMute{} + |> UserMute.changeset(%{muter_id: user.id, muted_id: other.id, expires_at: future}) + |> Repo.insert!() + + assert Accounts.expire_due_mutes() == 1 + refute Accounts.user_muted?(user.id, target.id) + assert Accounts.user_muted?(user.id, other.id) + end + end +end diff --git a/apps/elektrine/test/elektrine/accounts/passkeys_test.exs b/apps/elektrine/test/elektrine/accounts/passkeys_test.exs new file mode 100644 index 0000000..ac2501f --- /dev/null +++ b/apps/elektrine/test/elektrine/accounts/passkeys_test.exs @@ -0,0 +1,186 @@ +defmodule Elektrine.Accounts.PasskeysTest do + use Elektrine.DataCase, async: true + + alias Elektrine.Accounts.PasskeyCredential + alias Elektrine.Accounts.Passkeys + + import Elektrine.AccountsFixtures + + describe "generate_registration_challenge/1" do + test "generates a valid challenge for user" do + user = user_fixture() + + assert {:ok, challenge_data} = Passkeys.generate_registration_challenge(user) + assert challenge_data.challenge_b64 + assert challenge_data.rp_id + assert challenge_data.user_id + assert challenge_data.user_name == user.username + end + + test "returns error when passkey limit reached" do + user = user_fixture() + + # Create max number of passkeys + for i <- 1..PasskeyCredential.max_passkeys_per_user() do + create_passkey_for_user(user, "Passkey #{i}") + end + + assert {:error, :passkey_limit_reached} = Passkeys.generate_registration_challenge(user) + end + end + + describe "list_user_passkeys/1" do + test "returns empty list for user with no passkeys" do + user = user_fixture() + assert [] = Passkeys.list_user_passkeys(user) + end + + test "returns passkeys for user" do + user = user_fixture() + create_passkey_for_user(user, "My Phone") + create_passkey_for_user(user, "My Laptop") + + passkeys = Passkeys.list_user_passkeys(user) + assert length(passkeys) == 2 + names = Enum.map(passkeys, & &1.name) + assert "My Phone" in names + assert "My Laptop" in names + end + end + + describe "count_user_passkeys/1" do + test "returns 0 for user with no passkeys" do + user = user_fixture() + assert 0 = Passkeys.count_user_passkeys(user) + end + + test "returns correct count" do + user = user_fixture() + create_passkey_for_user(user, "Passkey 1") + create_passkey_for_user(user, "Passkey 2") + + assert 2 = Passkeys.count_user_passkeys(user) + end + end + + describe "has_passkeys?/1" do + test "returns false for user with no passkeys" do + user = user_fixture() + refute Passkeys.has_passkeys?(user) + end + + test "returns true for user with passkeys" do + user = user_fixture() + create_passkey_for_user(user, "Test Passkey") + + assert Passkeys.has_passkeys?(user) + end + end + + describe "rename_passkey/3" do + test "renames a passkey" do + user = user_fixture() + passkey = create_passkey_for_user(user, "Old Name") + + assert {:ok, updated} = Passkeys.rename_passkey(user, passkey.id, "New Name") + assert updated.name == "New Name" + end + + test "returns error for non-existent passkey" do + user = user_fixture() + + assert {:error, :not_found} = Passkeys.rename_passkey(user, 999_999, "New Name") + end + + test "returns error for passkey belonging to different user" do + user1 = user_fixture() + user2 = user_fixture() + passkey = create_passkey_for_user(user1, "Test") + + assert {:error, :not_found} = Passkeys.rename_passkey(user2, passkey.id, "Stolen") + end + end + + describe "delete_passkey/2" do + test "deletes a passkey" do + user = user_fixture() + passkey = create_passkey_for_user(user, "To Delete") + + assert {:ok, _} = Passkeys.delete_passkey(user, passkey.id) + assert [] = Passkeys.list_user_passkeys(user) + end + + test "returns error for non-existent passkey" do + user = user_fixture() + + assert {:error, :not_found} = Passkeys.delete_passkey(user, 999_999) + end + + test "returns error for passkey belonging to different user" do + user1 = user_fixture() + user2 = user_fixture() + passkey = create_passkey_for_user(user1, "Test") + + assert {:error, :not_found} = Passkeys.delete_passkey(user2, passkey.id) + end + end + + describe "generate_authentication_challenge/1" do + test "generates discoverable challenge without user" do + assert {:ok, challenge_data} = Passkeys.generate_authentication_challenge(nil) + assert challenge_data.challenge_b64 + assert challenge_data.rp_id + assert challenge_data.allow_credentials == [] + end + + test "generates challenge with allowed credentials for specific user" do + user = user_fixture() + _passkey = create_passkey_for_user(user, "Test") + + assert {:ok, challenge_data} = Passkeys.generate_authentication_challenge(user) + assert challenge_data.challenge_b64 + # Should have one allowed credential + assert length(challenge_data.allow_credentials) == 1 + end + end + + describe "verify_authentication/2 clone detection" do + test "returns error when sign_count does not increase (clone detection)" do + # This test verifies that when a passkey's sign count doesn't increase, + # authentication is blocked to prevent cloned authenticator attacks. + # The actual Wax verification is complex to mock, so we test the logic + # by verifying the credential storage and the expected behavior. + user = user_fixture() + passkey = create_passkey_for_user(user, "Test Passkey") + + # Verify the passkey was created with sign_count 0 + assert passkey.sign_count == 0 + + # The clone detection logic in verify_authentication checks: + # if new_sign_count > 0 and new_sign_count <= credential.sign_count + # This means a sign_count of 1 received when stored is 5 would be blocked + updated_passkey = + passkey + |> Elektrine.Accounts.PasskeyCredential.update_sign_count_changeset(5) + |> Elektrine.Repo.update!() + + assert updated_passkey.sign_count == 5 + end + end + + # Helper function to create a test passkey + defp create_passkey_for_user(user, name) do + attrs = %{ + user_id: user.id, + credential_id: :crypto.strong_rand_bytes(32), + public_key: :erlang.term_to_binary(%{test: true}), + sign_count: 0, + user_handle: PasskeyCredential.generate_user_handle(), + name: name + } + + %PasskeyCredential{} + |> PasskeyCredential.create_changeset(attrs) + |> Elektrine.Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/accounts/recovery_email_verification_test.exs b/apps/elektrine/test/elektrine/accounts/recovery_email_verification_test.exs new file mode 100644 index 0000000..d992e89 --- /dev/null +++ b/apps/elektrine/test/elektrine/accounts/recovery_email_verification_test.exs @@ -0,0 +1,353 @@ +defmodule Elektrine.Accounts.RecoveryEmailVerificationTest do + use Elektrine.DataCase + + alias Elektrine.Accounts + alias Elektrine.Accounts.RecoveryEmailVerification + alias Elektrine.Accounts.User + alias Elektrine.Repo + + defmodule FailingMailerAdapter do + use Swoosh.Adapter + + def deliver(_email, _config), do: {:error, :forced_failure} + def deliver_many(_emails, _config), do: {:error, :forced_failure} + end + + describe "send_verification_email/1" do + setup do + {:ok, user} = + Accounts.create_user(%{ + username: "recoveryuser#{System.unique_integer([:positive])}", + password: "Test123456!", + password_confirmation: "Test123456!" + }) + + %{user: user} + end + + test "returns error for non-existent user" do + assert {:error, :user_not_found} = RecoveryEmailVerification.send_verification_email(-1) + end + + test "returns error when no recovery email is set", %{user: user} do + # User has no recovery email + assert {:error, :no_recovery_email} = + RecoveryEmailVerification.send_verification_email(user.id) + end + + test "returns already_verified when recovery email is already verified", %{user: user} do + # Set up user with verified recovery email (not restricted) + user + |> Ecto.Changeset.change(%{ + recovery_email: "recovery@example.com", + recovery_email_verified: true + }) + |> Repo.update!() + + assert {:error, :already_verified} = + RecoveryEmailVerification.send_verification_email(user.id) + end + + test "sends verification for restricted user with verified email (to lift restriction)", %{ + user: user + } do + # Set up user with restriction and verified recovery email + # In this case, we still send to help lift restriction + user + |> Ecto.Changeset.change(%{ + email_sending_restricted: true, + recovery_email: "recovery@example.com", + recovery_email_verified: true + }) + |> Repo.update!() + + # Should send verification email (because user is restricted) + assert {:ok, updated_user} = RecoveryEmailVerification.send_verification_email(user.id) + assert updated_user.recovery_email_verification_token != nil + assert String.length(updated_user.recovery_email_verification_token) == 64 + refute updated_user.recovery_email_verification_token == extract_verification_token() + end + + test "sends verification email and generates token", %{user: user} do + # Set up user with restriction and recovery email + user + |> Ecto.Changeset.change(%{ + email_sending_restricted: true, + recovery_email: "recovery@example.com", + recovery_email_verified: false + }) + |> Repo.update!() + + assert {:ok, updated_user} = RecoveryEmailVerification.send_verification_email(user.id) + assert updated_user.recovery_email_verification_token != nil + assert String.length(updated_user.recovery_email_verification_token) == 64 + assert updated_user.recovery_email_verification_sent_at != nil + assert_received {:email, email} + + assert Map.get(email.headers, "List-Id") == + Elektrine.EmailAddresses.list_id("elektrine-account") + + [_, delivered_token] = + Regex.run(~r{/verify-recovery-email\?token=([A-Za-z0-9_-]+)}, email.text_body) + + refute updated_user.recovery_email_verification_token == delivered_token + end + + test "succeeds despite a failing mail adapter because delivery is queued and retried", %{ + user: user + } do + previous_mailer_config = Application.get_env(:elektrine, Elektrine.Mailer, []) + + on_exit(fn -> + Application.put_env(:elektrine, Elektrine.Mailer, previous_mailer_config) + end) + + Application.put_env( + :elektrine, + Elektrine.Mailer, + Keyword.put(previous_mailer_config, :adapter, FailingMailerAdapter) + ) + + old_token = "old_verification_token_#{System.unique_integer([:positive])}" + old_sent_at = DateTime.utc_now() |> DateTime.add(-10, :minute) |> DateTime.truncate(:second) + + user + |> Ecto.Changeset.change(%{ + recovery_email: "recovery@example.com", + recovery_email_verified: false, + recovery_email_verification_token: User.hash_sensitive_token(old_token), + recovery_email_verification_sent_at: old_sent_at + }) + |> Repo.update!() + + # Delivery goes through the Oban :email queue, so an adapter failure is + # retried in the background instead of failing the call. The new token + # stays in place because the queued email contains it. + assert {:ok, updated_user} = + RecoveryEmailVerification.send_verification_email(user.id, force: true) + + refute updated_user.recovery_email_verification_token == + User.hash_sensitive_token(old_token) + + reloaded_user = Accounts.get_user!(user.id) + + assert reloaded_user.recovery_email_verification_token == + updated_user.recovery_email_verification_token + + refute_received {:email, _} + end + end + + describe "verify_token/1" do + setup do + {:ok, user} = + Accounts.create_user(%{ + username: "verifyuser#{System.unique_integer([:positive])}", + password: "Test123456!", + password_confirmation: "Test123456!" + }) + + %{user: user} + end + + test "returns error for nil token" do + assert {:error, :invalid_token} = RecoveryEmailVerification.verify_token(nil) + end + + test "returns error for empty token" do + assert {:error, :invalid_token} = RecoveryEmailVerification.verify_token("") + end + + test "returns error for non-existent token" do + assert {:error, :invalid_token} = + RecoveryEmailVerification.verify_token("nonexistent_token") + end + + test "returns error for expired token", %{user: user} do + # Set up user with expired token (sent 25 hours ago) + expired_time = + DateTime.utc_now() |> DateTime.add(-25 * 3600, :second) |> DateTime.truncate(:second) + + user + |> Ecto.Changeset.change(%{ + email_sending_restricted: true, + recovery_email: "recovery@example.com", + recovery_email_verification_token: User.hash_sensitive_token("expired_token_123"), + recovery_email_verification_sent_at: expired_time + }) + |> Repo.update!() + + assert {:error, :token_expired} = + RecoveryEmailVerification.verify_token("expired_token_123") + end + + test "rejects legacy plaintext tokens stored in the database", %{user: user} do + plaintext_token = "legacy_recovery_email_token_123456789012345" + now = DateTime.utc_now() |> DateTime.truncate(:second) + + user + |> Ecto.Changeset.change(%{ + recovery_email: "recovery@example.com", + recovery_email_verified: false, + recovery_email_verification_token: plaintext_token, + recovery_email_verification_sent_at: now + }) + |> Repo.update!() + + assert {:error, :invalid_token} = + RecoveryEmailVerification.verify_token(plaintext_token) + + assert is_nil(RecoveryEmailVerification.get_user_by_token(plaintext_token)) + end + + test "successfully verifies valid token and lifts restriction", %{user: user} do + # Set up user with valid token + token = "valid_token_#{System.unique_integer([:positive])}" + now = DateTime.utc_now() |> DateTime.truncate(:second) + + user + |> Ecto.Changeset.change(%{ + email_sending_restricted: true, + email_rate_limit_violations: 3, + email_restriction_reason: "Test restriction", + email_restricted_at: now, + recovery_email: "recovery@example.com", + recovery_email_verified: false, + recovery_email_verification_token: User.hash_sensitive_token(token), + recovery_email_verification_sent_at: now + }) + |> Repo.update!() + + assert {:ok, updated_user} = RecoveryEmailVerification.verify_token(token) + + # Check all fields are properly updated + assert updated_user.recovery_email_verified == true + assert updated_user.recovery_email_verification_token == nil + assert updated_user.recovery_email_verification_sent_at == nil + assert updated_user.email_sending_restricted == false + assert updated_user.email_rate_limit_violations == 0 + assert updated_user.email_restriction_reason == nil + assert updated_user.email_restricted_at == nil + end + + test "token sent 23 hours ago is still valid", %{user: user} do + # Token sent 23 hours ago should still be valid (under 24 hour limit) + valid_time = + DateTime.utc_now() |> DateTime.add(-23 * 3600, :second) |> DateTime.truncate(:second) + + token = "recent_token_#{System.unique_integer([:positive])}" + + user + |> Ecto.Changeset.change(%{ + email_sending_restricted: true, + recovery_email: "recovery@example.com", + recovery_email_verification_token: User.hash_sensitive_token(token), + recovery_email_verification_sent_at: valid_time + }) + |> Repo.update!() + + assert {:ok, _updated_user} = RecoveryEmailVerification.verify_token(token) + end + end + + describe "needs_verification?/1" do + setup do + {:ok, user} = + Accounts.create_user(%{ + username: "needsverify#{System.unique_integer([:positive])}", + password: "Test123456!", + password_confirmation: "Test123456!" + }) + + %{user: user} + end + + test "returns false for non-existent user" do + assert RecoveryEmailVerification.needs_verification?(-1) == false + end + + test "returns false when user has no recovery email", %{user: user} do + # User has no recovery email set + assert RecoveryEmailVerification.needs_verification?(user.id) == false + end + + test "returns true when user has recovery email but not verified", %{user: user} do + user + |> Ecto.Changeset.change(%{ + recovery_email: "recovery@example.com", + recovery_email_verified: false + }) + |> Repo.update!() + + assert RecoveryEmailVerification.needs_verification?(user.id) == true + end + + test "returns false when recovery email is verified", %{user: user} do + user + |> Ecto.Changeset.change(%{ + recovery_email: "recovery@example.com", + recovery_email_verified: true + }) + |> Repo.update!() + + assert RecoveryEmailVerification.needs_verification?(user.id) == false + end + end + + describe "set_recovery_email/2" do + setup do + {:ok, user} = + Accounts.create_user(%{ + username: "setrecovery#{System.unique_integer([:positive])}", + password: "Test123456!", + password_confirmation: "Test123456!" + }) + + %{user: user} + end + + test "returns error for non-existent user" do + assert {:error, :user_not_found} = + RecoveryEmailVerification.set_recovery_email(-1, "test@example.com") + end + + test "sets recovery email and marks as unverified", %{user: user} do + assert {:ok, updated_user} = + RecoveryEmailVerification.set_recovery_email(user.id, "new_recovery@example.com") + + assert updated_user.recovery_email == "new_recovery@example.com" + assert updated_user.recovery_email_verified == false + end + + test "rejects invalid recovery email", %{user: user} do + assert {:error, changeset} = + RecoveryEmailVerification.set_recovery_email(user.id, "not-an-email") + + assert %{recovery_email: [_ | _]} = errors_on(changeset) + end + + test "updating recovery email resets verified status", %{user: user} do + # First set and verify a recovery email + user + |> Ecto.Changeset.change(%{ + recovery_email: "old@example.com", + recovery_email_verified: true + }) + |> Repo.update!() + + # Now update to new email + assert {:ok, updated_user} = + RecoveryEmailVerification.set_recovery_email(user.id, "new@example.com") + + assert updated_user.recovery_email == "new@example.com" + assert updated_user.recovery_email_verified == false + end + end + + defp extract_verification_token do + assert_received {:email, email} + + [_, token] = Regex.run(~r{/verify-recovery-email\?token=([A-Za-z0-9_-]+)}, email.text_body) + token + end +end diff --git a/apps/elektrine/test/elektrine/accounts/storage_test.exs b/apps/elektrine/test/elektrine/accounts/storage_test.exs new file mode 100644 index 0000000..3a3a595 --- /dev/null +++ b/apps/elektrine/test/elektrine/accounts/storage_test.exs @@ -0,0 +1,45 @@ +defmodule Elektrine.Accounts.StorageTest do + use Elektrine.DataCase, async: false + + alias Elektrine.Accounts.Storage + alias Elektrine.AccountsFixtures + alias Elektrine.Email + alias Elektrine.EmailFixtures + + describe "email storage" do + test "separates message content from attachment bytes" do + user = AccountsFixtures.user_fixture() + mailbox = Email.get_user_mailbox(user.id) + message_storage_before = Storage.calculate_email_message_storage(user.id) + attachment_storage_before = Storage.calculate_email_attachment_storage(user.id) + + EmailFixtures.message_fixture(%{ + mailbox_id: mailbox.id, + from: "sender@example.com", + to: mailbox.email, + cc: "cc@example.com", + bcc: "bcc@example.com", + subject: "Subject", + text_body: "plain", + html_body: "

    html

    ", + attachments: %{ + "0" => %{"filename" => "a.txt", "size" => 10}, + "1" => %{"filename" => "b.txt", "size" => "20"}, + "2" => %{"filename" => "bad.txt", "size" => "unknown"} + } + }) + + message_delta = Storage.calculate_email_message_storage(user.id) - message_storage_before + + attachment_delta = + Storage.calculate_email_attachment_storage(user.id) - attachment_storage_before + + assert message_delta > 0 + assert attachment_delta == 30 + + assert Storage.calculate_email_storage(user.id) == + message_storage_before + attachment_storage_before + message_delta + + attachment_delta + end + end +end diff --git a/apps/elektrine/test/elektrine/accounts/trust_level_test.exs b/apps/elektrine/test/elektrine/accounts/trust_level_test.exs new file mode 100644 index 0000000..64558a4 --- /dev/null +++ b/apps/elektrine/test/elektrine/accounts/trust_level_test.exs @@ -0,0 +1,178 @@ +defmodule Elektrine.Accounts.TrustLevelTest do + use Elektrine.DataCase, async: true + + alias Elektrine.Accounts.{Moderation, TrustLevel, TrustLevelLog, User, UserActivityStats} + alias Elektrine.Repo + + import Elektrine.AccountsFixtures + + describe "calculate_trust_level/2" do + test "uses tracked account and engagement metrics for TL1, TL2, and TL3" do + tl1_user = backdated_user(%{login_count: 3}, 4) + tl2_user = backdated_user(%{login_count: 10}, 20) + tl3_user = backdated_user(%{login_count: 25}, 60) + + assert TrustLevel.calculate_trust_level(tl1_user, %UserActivityStats{ + days_visited: 2, + topics_entered: 5, + posts_read: 30, + time_read_seconds: 600 + }) == 1 + + assert TrustLevel.calculate_trust_level(tl2_user, %UserActivityStats{ + days_visited: 15, + topics_entered: 15, + posts_read: 100, + posts_created: 3, + topics_created: 1, + replies_created: 3, + time_read_seconds: 3600, + likes_given: 3, + likes_received: 1, + replies_received: 1 + }) == 2 + + assert TrustLevel.calculate_trust_level(tl3_user, %UserActivityStats{ + days_visited: 50, + topics_entered: 25, + posts_read: 250, + posts_created: 10, + topics_created: 2, + replies_created: 10, + likes_given: 15, + likes_received: 10, + replies_received: 5 + }) == 3 + end + end + + describe "change_user_level/3" do + test "updates the user's level and writes an audit log" do + user = user_fixture() + + assert {:ok, updated_user} = + TrustLevel.change_user_level(user, 2, + reason: "manual", + notes: "Elevated during moderation review" + ) + + assert updated_user.trust_level == 2 + assert updated_user.promoted_at + + log = Repo.one!(from l in TrustLevelLog, where: l.user_id == ^user.id) + + assert log.old_level == 0 + assert log.new_level == 2 + assert log.reason == "manual" + assert log.notes == "Elevated during moderation review" + end + + test "records a manual level change after the user row was already updated" do + user = user_fixture() + + updated_user = + user + |> User.admin_changeset(%{username: user.username, trust_level: 3}) + |> Repo.update!() + + assert {:ok, audited_user} = + TrustLevel.record_level_change(updated_user, user.trust_level, + reason: "manual", + notes: "Admin adjusted trust level" + ) + + assert audited_user.trust_level == 3 + assert audited_user.promoted_at + + log = + Repo.one!(from l in TrustLevelLog, where: l.user_id == ^user.id, order_by: [desc: l.id]) + + assert log.old_level == 0 + assert log.new_level == 3 + assert log.reason == "manual" + end + end + + describe "maybe_auto_promote_user/1" do + test "promotes an eligible user immediately" do + user = backdated_user(%{login_count: 10}, 20) + + %UserActivityStats{user_id: user.id} + |> UserActivityStats.changeset(%{ + days_visited: 15, + topics_entered: 15, + posts_read: 100, + posts_created: 3, + topics_created: 1, + replies_created: 3, + time_read_seconds: 3600, + likes_given: 3, + likes_received: 1, + replies_received: 1 + }) + |> Repo.insert!() + + assert {:ok, promoted_user} = TrustLevel.maybe_auto_promote_user(user.id) + assert promoted_user.trust_level == 2 + + persisted_user = Repo.get!(User, user.id) + assert persisted_user.trust_level == 2 + + log = Repo.one!(from l in TrustLevelLog, where: l.user_id == ^user.id) + assert log.reason == "automatic" + assert log.new_level == 2 + end + + test "demotes a user when a suspension penalty is applied" do + user = backdated_user(%{login_count: 25}, 60) + + %UserActivityStats{user_id: user.id} + |> UserActivityStats.changeset(%{ + days_visited: 50, + topics_entered: 25, + posts_read: 250, + posts_created: 10, + topics_created: 2, + replies_created: 10, + likes_given: 15, + likes_received: 10, + replies_received: 5 + }) + |> Repo.insert!() + + assert {:ok, promoted_user} = TrustLevel.maybe_auto_promote_user(user.id) + assert promoted_user.trust_level == 3 + + suspended_until = DateTime.utc_now() |> DateTime.add(7, :day) + + assert {:ok, suspended_user} = + Moderation.suspend_user(promoted_user, %{suspended_until: suspended_until}) + + assert suspended_user.trust_level == 0 + assert Repo.get_by!(UserActivityStats, user_id: user.id).suspensions_count == 1 + + log = + Repo.one!( + from l in TrustLevelLog, + where: l.user_id == ^user.id, + order_by: [desc: l.id], + limit: 1 + ) + + assert log.old_level == 3 + assert log.new_level == 0 + assert log.reason == "automatic" + end + end + + defp backdated_user(extra_attrs, age_in_days) do + inserted_at = + DateTime.utc_now() + |> DateTime.add(-age_in_days * 86_400, :second) + |> DateTime.truncate(:second) + + user_fixture() + |> Ecto.Changeset.change(Map.put(extra_attrs, :inserted_at, inserted_at)) + |> Repo.update!() + end +end diff --git a/apps/elektrine/test/elektrine/accounts/two_factor_test.exs b/apps/elektrine/test/elektrine/accounts/two_factor_test.exs new file mode 100644 index 0000000..3d1176b --- /dev/null +++ b/apps/elektrine/test/elektrine/accounts/two_factor_test.exs @@ -0,0 +1,222 @@ +defmodule Elektrine.Accounts.TwoFactorTest do + use ExUnit.Case, async: true + + alias Elektrine.Accounts.TwoFactor + + describe "generate_secret/0" do + test "generates a binary secret" do + secret = TwoFactor.generate_secret() + + assert is_binary(secret) + assert String.length(secret) > 0 + # Should be raw binary data, not Base32 + refute String.match?(secret, ~r/^[A-Z2-7]+$/) + end + + test "can convert secret to base32" do + secret = TwoFactor.generate_secret() + base32_secret = TwoFactor.secret_to_base32(secret) + + assert is_binary(base32_secret) + assert String.length(base32_secret) > 0 + # Base32 should only contain A-Z and 2-7 + assert String.match?(base32_secret, ~r/^[A-Z2-7]+$/) + end + end + + describe "generate_qr_code_png/1" do + test "generates a QR code PNG from provisioning URI" do + secret = TwoFactor.generate_secret() + provisioning_uri = TwoFactor.generate_provisioning_uri(secret, "testuser") + + assert {:ok, png_binary} = TwoFactor.generate_qr_code_png(provisioning_uri) + assert is_binary(png_binary) + assert byte_size(png_binary) > 0 + # PNG files start with specific magic bytes + assert String.starts_with?(png_binary, <<137, 80, 78, 71, 13, 10, 26, 10>>) + end + + test "handles empty string" do + assert {:ok, _png} = TwoFactor.generate_qr_code_png("") + end + end + + describe "generate_provisioning_uri/2" do + test "generates a valid OTP auth URI" do + secret = TwoFactor.generate_secret() + username = "testuser" + + uri = TwoFactor.generate_provisioning_uri(secret, username) + + assert String.starts_with?(uri, "otpauth://totp/") + assert String.contains?(uri, "Elektrine:#{username}") + assert String.contains?(uri, "secret=") + assert String.contains?(uri, "issuer=Elektrine") + end + + test "allows custom issuer" do + secret = TwoFactor.generate_secret() + username = "testuser" + issuer = "CustomApp" + + uri = TwoFactor.generate_provisioning_uri(secret, username, issuer) + + assert String.contains?(uri, "issuer=#{issuer}") + end + end + + describe "verify_totp/2" do + test "verifies valid TOTP codes" do + secret = TwoFactor.generate_secret() + + # Generate current code + code = NimbleTOTP.verification_code(secret) + + assert TwoFactor.verify_totp(secret, code) == true + end + + test "rejects invalid codes" do + secret = TwoFactor.generate_secret() + + assert TwoFactor.verify_totp(secret, "000000") == false + assert TwoFactor.verify_totp(secret, "invalid") == false + assert TwoFactor.verify_totp(secret, "") == false + end + + test "handles nil inputs gracefully" do + assert TwoFactor.verify_totp(nil, "123456") == false + assert TwoFactor.verify_totp("secret", nil) == false + end + + test "handles codes with leading zeros" do + secret = TwoFactor.generate_secret() + + # Simulate a code with leading zeros + assert TwoFactor.verify_totp(secret, "000123") == false + end + end + + describe "generate_backup_codes/1" do + test "generates default number of backup codes" do + {plain_codes, hashed_codes} = TwoFactor.generate_backup_codes() + + assert length(plain_codes) == 8 + assert length(hashed_codes) == 8 + + Enum.each(plain_codes, fn code -> + assert is_binary(code) + assert String.length(code) == 8 + # Should only contain alphanumeric characters + assert String.match?(code, ~r/^[A-Z0-9]+$/) + end) + end + + test "generates custom number of backup codes" do + {plain_codes, _hashed_codes} = TwoFactor.generate_backup_codes(5) + + assert length(plain_codes) == 5 + end + + test "generates unique codes" do + {plain_codes, _hashed_codes} = TwoFactor.generate_backup_codes(10) + unique_codes = Enum.uniq(plain_codes) + + assert length(plain_codes) == length(unique_codes) + end + end + + describe "verify_totp_only/2" do + test "accepts valid TOTP codes only" do + secret = TwoFactor.generate_secret() + + # Generate current code + valid_code = NimbleTOTP.verification_code(secret) + + # Create a User struct with 2FA enabled + user = %Elektrine.Accounts.User{ + two_factor_enabled: true, + two_factor_secret: Base.encode64(secret), + two_factor_backup_codes: ["ABCD1234", "EFGH5678"] + } + + # Valid TOTP code should work + assert {:ok, :totp} = Elektrine.Accounts.verify_totp_only(user, valid_code) + end + + test "rejects backup codes" do + secret = TwoFactor.generate_secret() + + user = %Elektrine.Accounts.User{ + two_factor_enabled: true, + two_factor_secret: Base.encode64(secret), + two_factor_backup_codes: ["ABCD1234", "EFGH5678"] + } + + # Backup codes should be rejected + assert {:error, :invalid_code} = Elektrine.Accounts.verify_totp_only(user, "ABCD1234") + assert {:error, :invalid_code} = Elektrine.Accounts.verify_totp_only(user, "EFGH5678") + end + + test "rejects invalid codes" do + secret = TwoFactor.generate_secret() + + user = %Elektrine.Accounts.User{ + two_factor_enabled: true, + two_factor_secret: Base.encode64(secret), + two_factor_backup_codes: ["ABCD1234"] + } + + # Invalid codes should be rejected + assert {:error, :invalid_code} = Elektrine.Accounts.verify_totp_only(user, "000000") + assert {:error, :invalid_code} = Elektrine.Accounts.verify_totp_only(user, "invalid") + end + + test "rejects when 2FA is not enabled" do + user = %Elektrine.Accounts.User{two_factor_enabled: false} + + assert {:error, :two_factor_not_enabled} = + Elektrine.Accounts.verify_totp_only(user, "123456") + end + end + + describe "verify_backup_code/2" do + test "verifies valid backup codes" do + {[first_code | _], codes} = TwoFactor.generate_backup_codes(3) + + assert {:ok, remaining} = TwoFactor.verify_backup_code(codes, first_code) + assert length(remaining) == 2 + refute Enum.any?(remaining, &Argon2.verify_pass(first_code, &1)) + end + + test "handles case-insensitive codes" do + code = "ABCD1234" + codes = [Argon2.hash_pwd_salt(code)] + + assert {:ok, _} = TwoFactor.verify_backup_code(codes, "abcd1234") + assert {:ok, _} = TwoFactor.verify_backup_code(codes, " ABCD1234 ") + end + + test "rejects legacy plaintext stored backup codes" do + codes = ["ABCD1234"] + + assert {:error, :invalid} = TwoFactor.verify_backup_code(codes, "ABCD1234") + assert {:error, :invalid} = TwoFactor.verify_backup_code(codes, " abcd1234 ") + end + + test "rejects invalid codes" do + {_plain_codes, codes} = TwoFactor.generate_backup_codes(2) + + assert {:error, :invalid} = TwoFactor.verify_backup_code(codes, "INVALID") + assert {:error, :invalid} = TwoFactor.verify_backup_code(codes, "") + end + + test "handles empty code list" do + assert {:error, :invalid} = TwoFactor.verify_backup_code([], "ABCD1234") + end + + test "handles nil inputs" do + assert {:error, :invalid} = TwoFactor.verify_backup_code(nil, "ABCD1234") + assert {:error, :invalid} = TwoFactor.verify_backup_code(["ABCD1234"], nil) + end + end +end diff --git a/apps/elektrine/test/elektrine/accounts/user_test.exs b/apps/elektrine/test/elektrine/accounts/user_test.exs new file mode 100644 index 0000000..1a1f091 --- /dev/null +++ b/apps/elektrine/test/elektrine/accounts/user_test.exs @@ -0,0 +1,265 @@ +defmodule Elektrine.Accounts.UserTest do + use Elektrine.DataCase, async: true + + alias Elektrine.Accounts.User + import Ecto.Changeset + + describe "notification preference fields" do + test "default notification preferences are true" do + user = %User{} + assert user.notify_on_new_follower == true + assert user.notify_on_direct_message == true + assert user.notify_on_mention == true + assert user.block_notifications_from_strangers == false + assert user.hide_notification_contents == false + assert user.hide_followers == false + assert user.hide_follows == false + assert user.hide_favorites == false + end + + test "changeset accepts notification preference fields" do + user = %User{} + + attrs = %{ + notify_on_new_follower: false, + notify_on_direct_message: false, + notify_on_mention: false, + block_notifications_from_strangers: true, + hide_notification_contents: true, + hide_followers: true, + hide_follows: true, + hide_favorites: true + } + + changeset = User.changeset(user, attrs) + + assert changeset.valid? + assert get_change(changeset, :notify_on_new_follower) == false + assert get_change(changeset, :notify_on_direct_message) == false + assert get_change(changeset, :notify_on_mention) == false + assert get_change(changeset, :block_notifications_from_strangers) == true + assert get_change(changeset, :hide_notification_contents) == true + assert get_change(changeset, :hide_followers) == true + assert get_change(changeset, :hide_follows) == true + assert get_change(changeset, :hide_favorites) == true + end + + test "notification preferences must be boolean" do + user = %User{} + + # These should be coerced to boolean + attrs = %{ + notify_on_new_follower: "true", + notify_on_direct_message: "false", + block_notifications_from_strangers: "true", + hide_notification_contents: "false" + } + + changeset = User.changeset(user, attrs) + assert changeset.valid? + end + end + + describe "preferred email domain defaults" do + test "registration changeset assigns the runtime default preferred email domain" do + changeset = + User.registration_changeset(%User{}, %{ + username: "runtimepref", + password: "testpassword123", + password_confirmation: "testpassword123" + }) + + assert get_change(changeset, :preferred_email_domain) == + Elektrine.Domains.default_user_handle_domain() + end + end + + describe "built-in subdomain mode helpers" do + test "return valid user modes and default invalid user modes to path" do + assert User.built_in_subdomain_modes() == ~w(path platform external_dns) + + assert User.built_in_subdomain_mode(%User{built_in_subdomain_mode: "platform"}) == + "platform" + + assert User.built_in_subdomain_mode(%User{built_in_subdomain_mode: "bogus"}) == "path" + end + + test "preserve the legacy hosted-by-platform fallback for non-users" do + assert User.built_in_subdomain_hosted_by_platform?(%User{ + built_in_subdomain_mode: "platform" + }) + + refute User.built_in_subdomain_hosted_by_platform?(%User{built_in_subdomain_mode: "path"}) + assert User.built_in_subdomain_hosted_by_platform?(nil) + end + end + + describe "reserved usernames" do + test "registration rejects operational and certificate-validation mailbox names" do + for username <- ~w(abuse postmaster ssladmin ssladministrator sysadmin noc payments dmca) do + changeset = + User.registration_changeset(%User{}, %{ + username: username, + password: "testpassword123", + password_confirmation: "testpassword123" + }) + + refute changeset.valid?, "#{username} should be reserved" + assert "this username is reserved and cannot be used" in errors_on(changeset).username + end + end + end + + describe "admin registration changeset" do + test "accepts initial trust level and lock state" do + changeset = + User.admin_registration_changeset(%User{}, %{ + username: "trustedadmincreated", + password: "testpassword123", + password_confirmation: "testpassword123", + trust_level: 2, + trust_level_locked: true + }) + + assert changeset.valid? + assert get_change(changeset, :trust_level) == 2 + assert get_change(changeset, :trust_level_locked) == true + end + + test "rejects invalid initial trust levels" do + changeset = + User.admin_registration_changeset(%User{}, %{ + username: "invalidtrust", + password: "testpassword123", + password_confirmation: "testpassword123", + trust_level: 5 + }) + + refute changeset.valid? + assert "must be less than or equal to 4" in errors_on(changeset).trust_level + end + end + + describe "privacy setting fields" do + test "default privacy settings" do + user = %User{} + assert user.profile_visibility == "public" + assert user.allow_group_adds_from == "everyone" + assert user.allow_direct_messages_from == "everyone" + assert user.allow_mentions_from == "everyone" + end + + test "changeset accepts privacy settings" do + user = %User{} + + attrs = %{ + profile_visibility: "private", + allow_group_adds_from: "followers", + allow_direct_messages_from: "nobody", + allow_mentions_from: "followers" + } + + changeset = User.changeset(user, attrs) + + assert changeset.valid? + assert get_change(changeset, :profile_visibility) == "private" + assert get_change(changeset, :allow_group_adds_from) == "followers" + assert get_change(changeset, :allow_direct_messages_from) == "nobody" + assert get_change(changeset, :allow_mentions_from) == "followers" + end + + test "profile_visibility validates inclusion" do + user = %User{} + + # Valid values + for value <- ["public", "followers", "private"] do + changeset = User.changeset(user, %{profile_visibility: value}) + assert changeset.valid?, "#{value} should be valid" + end + + # Invalid value + changeset = User.changeset(user, %{profile_visibility: "invalid"}) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).profile_visibility + end + + test "allow_group_adds_from validates inclusion" do + user = %User{} + + # Valid values + for value <- ["everyone", "followers", "nobody"] do + changeset = User.changeset(user, %{allow_group_adds_from: value}) + assert changeset.valid?, "#{value} should be valid" + end + + # Invalid value + changeset = User.changeset(user, %{allow_group_adds_from: "invalid"}) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).allow_group_adds_from + end + + test "allow_direct_messages_from validates inclusion" do + user = %User{} + + # Valid values + for value <- ["everyone", "followers", "nobody"] do + changeset = User.changeset(user, %{allow_direct_messages_from: value}) + assert changeset.valid?, "#{value} should be valid" + end + + # Invalid value + changeset = User.changeset(user, %{allow_direct_messages_from: "invalid"}) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).allow_direct_messages_from + end + + test "allow_mentions_from validates inclusion" do + user = %User{} + + # Valid values + for value <- ["everyone", "followers", "nobody"] do + changeset = User.changeset(user, %{allow_mentions_from: value}) + assert changeset.valid?, "#{value} should be valid" + end + + # Invalid value + changeset = User.changeset(user, %{allow_mentions_from: "invalid"}) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).allow_mentions_from + end + end + + describe "combined settings" do + test "changeset can update both privacy and notification settings together" do + user = %User{} + + attrs = %{ + # Privacy settings + profile_visibility: "followers", + allow_group_adds_from: "followers", + allow_direct_messages_from: "followers", + allow_mentions_from: "followers", + # Notification settings + notify_on_new_follower: false, + notify_on_direct_message: true, + notify_on_mention: false + } + + changeset = User.changeset(user, attrs) + + assert changeset.valid? + + # Check privacy settings + assert get_change(changeset, :profile_visibility) == "followers" + assert get_change(changeset, :allow_group_adds_from) == "followers" + assert get_change(changeset, :allow_direct_messages_from) == "followers" + assert get_change(changeset, :allow_mentions_from) == "followers" + + # Check notification settings + assert get_change(changeset, :notify_on_new_follower) == false + # notify_on_direct_message is true which is the default, so it won't be a change + assert get_field(changeset, :notify_on_direct_message) == true + assert get_change(changeset, :notify_on_mention) == false + end + end +end diff --git a/apps/elektrine/test/elektrine/accounts_privacy_test.exs b/apps/elektrine/test/elektrine/accounts_privacy_test.exs new file mode 100644 index 0000000..378a1ef --- /dev/null +++ b/apps/elektrine/test/elektrine/accounts_privacy_test.exs @@ -0,0 +1,238 @@ +defmodule Elektrine.AccountsPrivacyTest do + use Elektrine.DataCase, async: true + + alias Elektrine.Accounts + alias Elektrine.Repo + + describe "privacy settings" do + setup do + user = create_user() + other_user = create_user() + {:ok, user: user, other_user: other_user} + end + + test "default privacy settings are set correctly", %{user: user} do + assert user.profile_visibility == "public" + assert user.allow_group_adds_from == "everyone" + assert user.allow_direct_messages_from == "everyone" + assert user.allow_mentions_from == "everyone" + end + + test "update_user can change privacy settings", %{user: user} do + attrs = %{ + profile_visibility: "private", + allow_group_adds_from: "followers", + allow_direct_messages_from: "nobody", + allow_mentions_from: "followers" + } + + assert {:ok, updated_user} = Accounts.update_user(user, attrs) + assert updated_user.profile_visibility == "private" + assert updated_user.allow_group_adds_from == "followers" + assert updated_user.allow_direct_messages_from == "nobody" + assert updated_user.allow_mentions_from == "followers" + end + + test "privacy settings validate allowed values", %{user: user} do + # Invalid profile visibility + assert {:error, changeset} = Accounts.update_user(user, %{profile_visibility: "invalid"}) + assert "is invalid" in errors_on(changeset).profile_visibility + + # Invalid allow_group_adds_from + assert {:error, changeset} = Accounts.update_user(user, %{allow_group_adds_from: "invalid"}) + assert "is invalid" in errors_on(changeset).allow_group_adds_from + end + end + + describe "can_view_profile?/2" do + setup do + public_user = create_user(%{profile_visibility: "public"}) + followers_only_user = create_user(%{profile_visibility: "followers"}) + private_user = create_user(%{profile_visibility: "private"}) + viewer = create_user() + + {:ok, + public_user: public_user, + followers_only_user: followers_only_user, + private_user: private_user, + viewer: viewer} + end + + test "public profile can be viewed by anyone", %{public_user: user, viewer: viewer} do + assert {:ok, :allowed} = Accounts.can_view_profile?(user, viewer) + assert {:ok, :allowed} = Accounts.can_view_profile?(user, nil) + end + + test "private profile can only be viewed by owner", %{private_user: user, viewer: viewer} do + assert {:error, :privacy_restriction} = Accounts.can_view_profile?(user, viewer) + assert {:error, :privacy_restriction} = Accounts.can_view_profile?(user, nil) + assert {:ok, :allowed} = Accounts.can_view_profile?(user, user) + end + + test "followers-only profile requires following relationship", %{ + followers_only_user: user, + viewer: viewer + } do + # Non-follower cannot view + assert {:error, :privacy_restriction} = Accounts.can_view_profile?(user, viewer) + assert {:error, :privacy_restriction} = Accounts.can_view_profile?(user, nil) + + # Owner can always view their own profile + assert {:ok, :allowed} = Accounts.can_view_profile?(user, user) + + # After an approved follow exists, viewer can see the profile. + {:ok, _} = + %Elektrine.Profiles.Follow{} + |> Elektrine.Profiles.Follow.changeset(%{follower_id: viewer.id, followed_id: user.id}) + |> Elektrine.Repo.insert() + + assert {:ok, :allowed} = Accounts.can_view_profile?(user, viewer) + end + end + + describe "can_add_to_group?/2" do + setup do + everyone_user = create_user(%{allow_group_adds_from: "everyone"}) + followers_only_user = create_user(%{allow_group_adds_from: "followers"}) + nobody_user = create_user(%{allow_group_adds_from: "nobody"}) + requester = create_user() + + {:ok, + everyone_user: everyone_user, + followers_only_user: followers_only_user, + nobody_user: nobody_user, + requester: requester} + end + + test "everyone setting allows all users", %{everyone_user: user, requester: requester} do + assert {:ok, :allowed} = Accounts.can_add_to_group?(user, requester) + end + + test "nobody setting blocks all except self", %{nobody_user: user, requester: requester} do + assert {:error, :privacy_restriction} = Accounts.can_add_to_group?(user, requester) + assert {:ok, :allowed} = Accounts.can_add_to_group?(user, user) + end + + test "followers setting requires following relationship", %{ + followers_only_user: user, + requester: requester + } do + # Non-follower cannot add + assert {:error, :privacy_restriction} = Accounts.can_add_to_group?(user, requester) + + # Owner can add themselves + assert {:ok, :allowed} = Accounts.can_add_to_group?(user, user) + + # After following, requester can add user + {:ok, _} = Elektrine.Profiles.follow_user(requester.id, user.id) + assert {:ok, :allowed} = Accounts.can_add_to_group?(user, requester) + end + end + + describe "can_send_direct_message?/2" do + setup do + everyone_user = create_user(%{allow_direct_messages_from: "everyone"}) + followers_only_user = create_user(%{allow_direct_messages_from: "followers"}) + nobody_user = create_user(%{allow_direct_messages_from: "nobody"}) + sender = create_user() + + {:ok, + everyone_user: everyone_user, + followers_only_user: followers_only_user, + nobody_user: nobody_user, + sender: sender} + end + + test "everyone setting allows all users", %{everyone_user: user, sender: sender} do + assert {:ok, :allowed} = Accounts.can_send_direct_message?(user, sender) + end + + test "nobody setting blocks all except self", %{nobody_user: user, sender: sender} do + assert {:error, :privacy_restriction} = Accounts.can_send_direct_message?(user, sender) + assert {:ok, :allowed} = Accounts.can_send_direct_message?(user, user) + end + + test "followers setting requires following relationship", %{ + followers_only_user: user, + sender: sender + } do + # Non-follower cannot send DM + assert {:error, :privacy_restriction} = Accounts.can_send_direct_message?(user, sender) + + # User can message themselves + assert {:ok, :allowed} = Accounts.can_send_direct_message?(user, user) + + # After following, sender can send DM + {:ok, _} = Elektrine.Profiles.follow_user(sender.id, user.id) + assert {:ok, :allowed} = Accounts.can_send_direct_message?(user, sender) + end + end + + describe "can_mention?/2" do + setup do + everyone_user = create_user(%{allow_mentions_from: "everyone"}) + followers_only_user = create_user(%{allow_mentions_from: "followers"}) + nobody_user = create_user(%{allow_mentions_from: "nobody"}) + mentioner = create_user() + + {:ok, + everyone_user: everyone_user, + followers_only_user: followers_only_user, + nobody_user: nobody_user, + mentioner: mentioner} + end + + test "everyone setting allows all users", %{everyone_user: user, mentioner: mentioner} do + assert {:ok, :allowed} = Accounts.can_mention?(user, mentioner) + end + + test "nobody setting blocks all except self", %{nobody_user: user, mentioner: mentioner} do + assert {:error, :privacy_restriction} = Accounts.can_mention?(user, mentioner) + assert {:ok, :allowed} = Accounts.can_mention?(user, user) + end + + test "followers setting requires following relationship", %{ + followers_only_user: user, + mentioner: mentioner + } do + # Non-follower cannot mention + assert {:error, :privacy_restriction} = Accounts.can_mention?(user, mentioner) + + # User can mention themselves + assert {:ok, :allowed} = Accounts.can_mention?(user, user) + + # After following, mentioner can mention user + {:ok, _} = Elektrine.Profiles.follow_user(mentioner.id, user.id) + assert {:ok, :allowed} = Accounts.can_mention?(user, mentioner) + end + end + + defp create_user(attrs \\ %{}) do + privacy_keys = [ + :profile_visibility, + :allow_group_adds_from, + :allow_direct_messages_from, + :allow_mentions_from + ] + + {privacy_attrs, user_attrs} = Map.split(attrs, privacy_keys) + + base_attrs = %{ + username: "u" <> (Ecto.UUID.generate() |> String.replace("-", "") |> String.slice(0, 19)), + password: "hello world!", + password_confirmation: "hello world!" + } + + {:ok, user} = + base_attrs + |> Map.merge(user_attrs) + |> Accounts.create_user() + + if map_size(privacy_attrs) > 0 do + {:ok, user} = user |> Ecto.Changeset.change(privacy_attrs) |> Repo.update() + user + else + user + end + end +end diff --git a/apps/elektrine/test/elektrine/accounts_status_test.exs b/apps/elektrine/test/elektrine/accounts_status_test.exs new file mode 100644 index 0000000..fe2fe2a --- /dev/null +++ b/apps/elektrine/test/elektrine/accounts_status_test.exs @@ -0,0 +1,144 @@ +defmodule Elektrine.AccountsStatusTest do + use Elektrine.DataCase + + alias Elektrine.Accounts + import Elektrine.AccountsFixtures + + describe "user status updates" do + setup do + user = user_fixture() + + %{user: user} + end + + test "can update user status to online", %{user: user} do + assert {:ok, updated_user} = Accounts.update_user_status(user, "online") + assert updated_user.status == "online" + assert updated_user.status_updated_at + end + + test "can update user status to away", %{user: user} do + assert {:ok, updated_user} = Accounts.update_user_status(user, "away") + assert updated_user.status == "away" + end + + test "can update user status to dnd", %{user: user} do + assert {:ok, updated_user} = Accounts.update_user_status(user, "dnd") + assert updated_user.status == "dnd" + end + + test "can update user status to offline", %{user: user} do + assert {:ok, updated_user} = Accounts.update_user_status(user, "offline") + assert updated_user.status == "offline" + end + + test "rejects invalid status values", %{user: user} do + assert_raise FunctionClauseError, fn -> + Accounts.update_user_status(user, "invalid_status") + end + end + + test "can set status message", %{user: user} do + assert {:ok, updated_user} = Accounts.update_user_status(user, "away", "In a meeting") + assert updated_user.status == "away" + assert updated_user.status_message == "In a meeting" + end + + test "sanitizes status message - trims whitespace", %{user: user} do + assert {:ok, updated_user} = Accounts.update_user_status(user, "away", " Busy ") + assert updated_user.status_message == "Busy" + end + + test "sanitizes status message - limits to 100 characters", %{user: user} do + long_message = String.duplicate("a", 150) + assert {:ok, updated_user} = Accounts.update_user_status(user, "away", long_message) + assert String.length(updated_user.status_message) == 100 + end + + test "sets empty status message to nil", %{user: user} do + assert {:ok, updated_user} = Accounts.update_user_status(user, "away", "") + assert is_nil(updated_user.status_message) + end + + test "sets whitespace-only status message to nil", %{user: user} do + assert {:ok, updated_user} = Accounts.update_user_status(user, "away", " ") + assert is_nil(updated_user.status_message) + end + + test "updates status_updated_at timestamp", %{user: user} do + assert {:ok, updated_user} = Accounts.update_user_status(user, "away") + assert updated_user.status_updated_at + + # Timestamp should be recent (within last 5 seconds) + now = DateTime.utc_now() + diff = DateTime.diff(now, updated_user.status_updated_at, :second) + assert diff < 5 + end + + test "get_user_status returns status information", %{user: user} do + {:ok, updated_user} = Accounts.update_user_status(user, "dnd", "Focus time") + + assert {:ok, status_info} = Accounts.get_user_status(updated_user.id) + assert status_info.status == "dnd" + assert status_info.message == "Focus time" + assert status_info.updated_at + end + + test "get_user_status returns error for non-existent user" do + assert {:error, :not_found} = Accounts.get_user_status(999_999) + end + end + + describe "last_seen_at tracking" do + setup do + user = user_fixture() + + %{user: user} + end + + test "last_seen_at is updated when user connects", %{user: user} do + # Simulate presence hook updating last_seen + now = DateTime.utc_now() |> DateTime.truncate(:second) + query = from u in Elektrine.Accounts.User, where: u.id == ^user.id + {1, _} = Elektrine.Repo.update_all(query, set: [last_seen_at: now]) + + updated_user = Elektrine.Repo.get(Elektrine.Accounts.User, user.id) + assert updated_user.last_seen_at + + # Check timestamp is close (within 1 second) + diff = DateTime.diff(updated_user.last_seen_at, now, :second) + assert abs(diff) <= 1 + end + + test "last_seen_at is preserved after status change", %{user: user} do + # Set initial last_seen + past_time = DateTime.utc_now() |> DateTime.add(-300, :second) |> DateTime.truncate(:second) + query = from u in Elektrine.Accounts.User, where: u.id == ^user.id + {1, _} = Elektrine.Repo.update_all(query, set: [last_seen_at: past_time]) + + # Update status + {:ok, _updated_user} = Accounts.update_user_status(user, "away") + + # Reload user from DB to check last_seen_at was preserved + reloaded_user = Elektrine.Repo.get(Elektrine.Accounts.User, user.id) + + # last_seen_at should not change (within 1 second tolerance for DB truncation) + diff = DateTime.diff(reloaded_user.last_seen_at, past_time, :second) + assert abs(diff) <= 1 + end + + test "repeated last_seen updates for the same user are throttled locally", %{user: user} do + Accounts.update_last_seen(user.id) + + stale_time = DateTime.utc_now() |> DateTime.add(-600, :second) |> DateTime.truncate(:second) + query = from u in Elektrine.Accounts.User, where: u.id == ^user.id + {1, _} = Elektrine.Repo.update_all(query, set: [last_seen_at: stale_time]) + + assert {0, nil} = Accounts.update_last_seen(user.id) + + reloaded_user = Elektrine.Repo.get(Elektrine.Accounts.User, user.id) + diff = DateTime.diff(reloaded_user.last_seen_at, stale_time, :second) + assert abs(diff) <= 1 + end + end +end diff --git a/apps/elektrine/test/elektrine/accounts_test.exs b/apps/elektrine/test/elektrine/accounts_test.exs new file mode 100644 index 0000000..5a52ab0 --- /dev/null +++ b/apps/elektrine/test/elektrine/accounts_test.exs @@ -0,0 +1,292 @@ +defmodule Elektrine.AccountsTest do + use Elektrine.DataCase + + alias Elektrine.Accounts + alias Elektrine.Accounts.User + alias Elektrine.AccountsFixtures + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.{Activity, Actor, Delivery} + alias Elektrine.Domains + alias Elektrine.Profiles + + describe "user cache invalidation" do + test "update_user invalidates the cached user so reads see fresh data" do + user = AccountsFixtures.user_fixture() + + # Warm the cache the way login does, then change the theme mode + assert %User{theme_mode: "system"} = Accounts.Cached.get_user!(user.id) + + assert {:ok, %User{theme_mode: "dark"}} = + Accounts.update_user(user, %{theme_mode: "dark"}) + + # Regression: the cached copy used by the dead render must not keep + # serving the pre-update theme mode + assert %User{theme_mode: "dark"} = Accounts.Cached.get_user!(user.id) + end + + test "update_user refreshes username lookup caches" do + user = AccountsFixtures.user_fixture() + + assert %User{} = Accounts.Cached.get_user_by_username(user.username) + + assert {:ok, %User{}} = Accounts.update_user(user, %{display_name: "Fresh Name"}) + + assert %User{display_name: "Fresh Name"} = + Accounts.Cached.get_user_by_username(user.username) + end + + test "admin_update_user invalidates username lookup caches on rename" do + user = AccountsFixtures.user_fixture() + old_username = user.username + new_username = old_username <> "x" + + assert %User{} = Accounts.Cached.get_user_by_username(old_username) + + assert {:ok, %User{}} = + Accounts.Moderation.admin_update_user(user, %{username: new_username}) + + assert Accounts.Cached.get_user_by_username(old_username) == nil + + assert %User{username: ^new_username} = + Accounts.Cached.get_user_by_username(new_username) + end + end + + describe "user validation" do + test "username validation requires minimum 2 characters" do + # Test usernames that are too short (less than 2 characters) + short_usernames = ["a"] + + for short_name <- short_usernames do + attrs = %{ + username: short_name, + password: "testpassword123", + password_confirmation: "testpassword123" + } + + changeset = User.registration_changeset(%User{}, attrs) + refute changeset.valid?, "#{short_name} should be rejected (too short)" + assert %{username: [error]} = errors_on(changeset) + assert String.contains?(error, "should be at least 2 character(s)") + end + + # Test that 2+ character usernames work + valid_usernames = ["ab", "xyz", "abcd", "tester", "validuser123"] + + for valid_name <- valid_usernames do + attrs = %{ + username: valid_name, + password: "testpassword123", + password_confirmation: "testpassword123" + } + + changeset = User.registration_changeset(%User{}, attrs) + refute changeset.errors[:username], "#{valid_name} should not have username errors" + end + end + + test "admin registration changeset requires minimum 2 characters" do + short_username_attrs = %{ + username: "a", + password: "testpassword123", + password_confirmation: "testpassword123" + } + + changeset = User.admin_registration_changeset(%User{}, short_username_attrs) + refute changeset.valid? + errors = errors_on(changeset) + + assert Enum.any?( + errors.username, + &String.contains?(&1, "should be at least 2 character(s)") + ) + end + + test "admin changeset validation requires minimum 2 characters" do + # User.changeset is for settings updates, not username changes + # Use admin_changeset which validates usernames + short_username_attrs = %{ + username: "a" + } + + changeset = User.admin_changeset(%User{}, short_username_attrs) + refute changeset.valid? + errors = errors_on(changeset) + + assert Enum.any?( + errors.username, + &String.contains?(&1, "should be at least 2 character(s)") + ) + end + end + + if Code.ensure_loaded?(Elektrine.Email.Message) and Code.ensure_loaded?(Elektrine.Email.Mailbox) do + describe "user deletion with mailboxes" do + test "delete_user/1 removes user's mailboxes and messages" do + # Create a user + {:ok, user} = + Accounts.create_user(%{ + username: "testdel", + password: "testpassword123", + password_confirmation: "testpassword123" + }) + + # User creation should have created a mailbox + mailbox = Elektrine.Repo.get_by(Elektrine.Email.Mailbox, user_id: user.id) + assert mailbox + assert mailbox.email == "testdel@#{Domains.primary_email_domain()}" + + # Create a message in the mailbox + {:ok, message} = + %Elektrine.Email.Message{} + |> Elektrine.Email.Message.changeset(%{ + mailbox_id: mailbox.id, + message_id: "test-message-#{System.unique_integer()}", + from: "sender@example.com", + to: "testdel@example.com", + subject: "Test message", + text_body: "Test content" + }) + |> Elektrine.Repo.insert() + + # Verify the message exists + assert Elektrine.Repo.get(Elektrine.Email.Message, message.id) + + # Delete the user + {:ok, _deleted_user} = Accounts.delete_user(user) + + # Verify user is deleted + refute Elektrine.Repo.get(User, user.id) + + # Verify mailbox is deleted + refute Elektrine.Repo.get(Elektrine.Email.Mailbox, mailbox.id) + + # Verify message is deleted + refute Elektrine.Repo.get(Elektrine.Email.Message, message.id) + end + end + end + + describe "ActivityPub actor updates" do + test "update_user sanitizes invalid UTF-8 before persistence" do + user = AccountsFixtures.user_fixture() + + assert {:ok, updated_user} = + Accounts.update_user(user, %{display_name: "Bad " <> <<0xE6, 0xAA, 0xE5>>}) + + assert String.valid?(updated_user.display_name) + assert updated_user.display_name != "" + end + + test "handle changes are rejected once set" do + user = AccountsFixtures.user_fixture(%{username: "stablehandleuser"}) + + assert {:error, changeset} = Accounts.update_user_handle(user, "newhandle") + assert "cannot be changed once set" in errors_on(changeset).handle + end + + test "avatar changes publish an Update to remote followers" do + user = AccountsFixtures.user_fixture() + remote_actor = remote_actor_fixture() + + assert {:ok, _follow} = Profiles.create_remote_follow(remote_actor.id, user.id) + + assert {:ok, updated_user} = + Accounts.update_user(user, %{ + avatar: "/uploads/avatars/new-avatar.jpg", + avatar_size: 12_345 + }) + + actor_uri = "#{ActivityPub.instance_url()}/users/#{updated_user.username}" + + assert %Activity{id: activity_id, data: data} = + Repo.get_by!(Activity, + internal_user_id: user.id, + activity_type: "Update", + object_id: actor_uri + ) + + assert get_in(data, ["object", "icon", "url"]) == + "#{ActivityPub.instance_url()}#{Elektrine.Uploads.avatar_url(updated_user.avatar)}" + + assert Repo.all( + from(d in Delivery, where: d.activity_id == ^activity_id, select: d.inbox_url) + ) == [remote_actor.inbox_url] + end + + test "actor updates use the canonical handle actor uri" do + user = + AccountsFixtures.user_fixture(%{username: "avatarhandleuser"}) + |> set_handle!("avatar_handle") + + remote_actor = remote_actor_fixture() + + assert {:ok, _follow} = Profiles.create_remote_follow(remote_actor.id, user.id) + + assert {:ok, updated_user} = + Accounts.update_user(user, %{ + avatar: "/uploads/avatars/new-avatar.jpg", + avatar_size: 12_345 + }) + + canonical_actor_uri = ActivityPub.actor_uri(updated_user) + + assert %Activity{} = + Repo.get_by!(Activity, + internal_user_id: user.id, + activity_type: "Update", + object_id: canonical_actor_uri + ) + end + + test "non-actor settings do not publish an Update" do + user = AccountsFixtures.user_fixture() + remote_actor = remote_actor_fixture() + + assert {:ok, _follow} = Profiles.create_remote_follow(remote_actor.id, user.id) + assert {:ok, _updated_user} = Accounts.update_user(user, %{locale: "zh"}) + + refute Repo.get_by(Activity, internal_user_id: user.id, activity_type: "Update") + end + + test "remote actor mutes are idempotent and distinct from blocks" do + user = AccountsFixtures.user_fixture() + remote_actor = remote_actor_fixture() + + assert {:ok, mute} = Accounts.mute_remote_actor(user.id, remote_actor.id) + assert {:ok, ^mute} = Accounts.mute_remote_actor(user.id, remote_actor.id) + + assert Accounts.remote_actor_muted?(user.id, remote_actor.id) + refute Accounts.remote_actor_blocked?(user.id, remote_actor.id) + assert [mute] == Accounts.list_muted_remote_actors(user.id) + + assert {:ok, _deleted} = Accounts.unmute_remote_actor(user.id, remote_actor.id) + refute Accounts.remote_actor_muted?(user.id, remote_actor.id) + assert {:error, :not_muted} = Accounts.unmute_remote_actor(user.id, remote_actor.id) + end + end + + defp remote_actor_fixture(attrs \\ %{}) do + unique = System.unique_integer([:positive]) + + defaults = %{ + uri: "https://fed.example/users/test#{unique}", + username: "test#{unique}", + domain: "fed.example", + inbox_url: "https://fed.example/users/test#{unique}/inbox", + public_key: "-----BEGIN PUBLIC KEY-----test-key-----END PUBLIC KEY-----", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second), + metadata: %{} + } + + %Actor{} + |> Actor.changeset(Map.merge(defaults, attrs)) + |> Repo.insert!() + end + + defp set_handle!(user, handle) do + user + |> Ecto.Changeset.change(handle: handle) + |> Repo.update!() + end +end diff --git a/apps/elektrine/test/elektrine/acme/wildcard_renewal_worker_test.exs b/apps/elektrine/test/elektrine/acme/wildcard_renewal_worker_test.exs new file mode 100644 index 0000000..e829b4d --- /dev/null +++ b/apps/elektrine/test/elektrine/acme/wildcard_renewal_worker_test.exs @@ -0,0 +1,73 @@ +defmodule Elektrine.ACME.WildcardRenewalWorkerTest do + use ExUnit.Case, async: false + + alias Elektrine.ACME.WildcardRenewalWorker + + @env_keys [ + "ACME_HOME", + "ACME_RENEW_COMMAND", + "ACME_SH_BIN", + "ACME_WILDCARD_RENEWAL_ENABLED", + "ARGS_FILE" + ] + + setup do + previous_env = + Map.new(@env_keys, fn key -> + {key, System.get_env(key)} + end) + + tmp_dir = + Path.join(System.tmp_dir!(), "elektrine-acme-worker-#{System.unique_integer([:positive])}") + + File.mkdir_p!(tmp_dir) + + on_exit(fn -> + restore_env(previous_env) + File.rm_rf(tmp_dir) + end) + + {:ok, tmp_dir: tmp_dir} + end + + test "runs acme.sh directly with argv and ignores legacy shell command env", %{tmp_dir: tmp_dir} do + acme_home = Path.join(tmp_dir, "acme home;touch injected") + File.mkdir_p!(acme_home) + + acme_sh = Path.join(acme_home, "acme.sh") + args_file = Path.join(tmp_dir, "args") + injected_file = Path.join(tmp_dir, "injected") + + File.write!(acme_sh, """ + #!/bin/sh + printf '%s\\n' "$@" > "$ARGS_FILE" + """) + + File.chmod!(acme_sh, 0o700) + + System.put_env("ACME_WILDCARD_RENEWAL_ENABLED", "true") + System.put_env("ACME_HOME", acme_home) + System.put_env("ACME_SH_BIN", acme_sh) + System.put_env("ACME_RENEW_COMMAND", "touch #{injected_file}") + System.put_env("ARGS_FILE", args_file) + + assert :ok = WildcardRenewalWorker.perform(%Oban.Job{}) + + assert File.read!(args_file) == "--cron\n--home\n#{acme_home}\n" + refute File.exists?(injected_file) + end + + test "rejects relative configured acme executable path" do + System.put_env("ACME_WILDCARD_RENEWAL_ENABLED", "true") + System.put_env("ACME_SH_BIN", "acme.sh") + + assert {:error, :invalid_executable} = WildcardRenewalWorker.perform(%Oban.Job{}) + end + + defp restore_env(previous_env) do + Enum.each(previous_env, fn + {key, nil} -> System.delete_env(key) + {key, value} -> System.put_env(key, value) + end) + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/activity_delivery_worker_test.exs b/apps/elektrine/test/elektrine/activitypub/activity_delivery_worker_test.exs new file mode 100644 index 0000000..31b514b --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/activity_delivery_worker_test.exs @@ -0,0 +1,21 @@ +defmodule Elektrine.ActivityPub.ActivityDeliveryWorkerTest do + use Elektrine.DataCase, async: true + use Oban.Testing, repo: Elektrine.Repo + + alias Elektrine.ActivityPub.ActivityDeliveryWorker + + test "discards missing deliveries" do + assert {:discard, :missing_delivery} = + ActivityDeliveryWorker.perform(%Oban.Job{args: %{"delivery_id" => -1}}) + end + + test "enqueue_many keeps one incomplete job per delivery" do + Oban.Testing.with_testing_mode(:manual, fn -> + ActivityDeliveryWorker.enqueue_many([123, 123]) + ActivityDeliveryWorker.enqueue_many([123]) + + assert [_job] = + all_enqueued(worker: ActivityDeliveryWorker, args: %{"delivery_id" => 123}) + end) + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/actor_secrets_test.exs b/apps/elektrine/test/elektrine/activitypub/actor_secrets_test.exs new file mode 100644 index 0000000..7614475 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/actor_secrets_test.exs @@ -0,0 +1,48 @@ +defmodule Elektrine.ActivityPub.ActorSecretsTest do + use Elektrine.DataCase, async: false + + alias Elektrine.ActivityPub.Actor + alias Elektrine.Secrets.EncryptedString + + setup do + previous_master = Application.get_env(:elektrine, :encryption_master_secret) + previous_salt = Application.get_env(:elektrine, :encryption_key_salt) + + Application.put_env(:elektrine, :encryption_master_secret, "test-master-secret-0123456789") + Application.put_env(:elektrine, :encryption_key_salt, "test-key-salt-0123456789") + + on_exit(fn -> + restore_env(:encryption_master_secret, previous_master) + restore_env(:encryption_key_salt, previous_salt) + end) + + :ok + end + + test "changeset encrypts metadata private keys" do + changeset = + Actor.changeset(%Actor{}, %{ + uri: "https://example.com/actors/test", + username: "test", + domain: "example.com", + inbox_url: "https://example.com/inbox", + metadata: %{"private_key" => "plain-private-key"} + }) + + encrypted = Ecto.Changeset.get_change(changeset, :metadata)["private_key"] + + assert EncryptedString.encrypted?(encrypted) + end + + test "metadata_private_key decrypts encrypted values and preserves plaintext fallback" do + encrypted = Actor.put_metadata_private_key(%{}, "plain-private-key") + + assert Actor.metadata_private_key(%Actor{metadata: encrypted}) == "plain-private-key" + + assert Actor.metadata_private_key(%Actor{metadata: %{"private_key" => "legacy-plaintext"}}) == + "legacy-plaintext" + end + + defp restore_env(key, nil), do: Application.delete_env(:elektrine, key) + defp restore_env(key, value), do: Application.put_env(:elektrine, key, value) +end diff --git a/apps/elektrine/test/elektrine/activitypub/actor_url_security_test.exs b/apps/elektrine/test/elektrine/activitypub/actor_url_security_test.exs new file mode 100644 index 0000000..3e6adf8 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/actor_url_security_test.exs @@ -0,0 +1,71 @@ +defmodule Elektrine.ActivityPub.ActorURLSecurityTest do + use ExUnit.Case, async: true + + alias Elektrine.ActivityPub.Actor + + describe "changeset URL security" do + test "rejects unsafe required actor URLs" do + for {field, value} <- [ + {:uri, "javascript:alert(1)"}, + {:uri, "https://user:pass@example.com/users/alice"}, + {:inbox_url, "//evil.example/inbox"}, + {:inbox_url, "https://example.com/inbox\r\nx-injected: yes"} + ] do + attrs = + valid_attrs() + |> Map.put(field, value) + + changeset = Actor.changeset(%Actor{}, attrs) + + refute changeset.valid? + assert Keyword.has_key?(changeset.errors, field) + end + end + + test "rejects unsafe optional actor URLs" do + changeset = + Actor.changeset( + %Actor{}, + valid_attrs(%{ + avatar_url: "javascript:alert(1)", + header_url: "https://user:pass@example.com/header.png", + outbox_url: "//evil.example/outbox" + }) + ) + + refute changeset.valid? + assert Keyword.has_key?(changeset.errors, :avatar_url) + assert Keyword.has_key?(changeset.errors, :header_url) + assert Keyword.has_key?(changeset.errors, :outbox_url) + end + + test "accepts safe actor URLs" do + changeset = + Actor.changeset( + %Actor{}, + valid_attrs(%{ + avatar_url: "https://example.com/avatar.png", + header_url: "https://example.com/header.png", + outbox_url: "https://example.com/users/alice/outbox", + followers_url: "https://example.com/users/alice/followers", + following_url: "https://example.com/users/alice/following", + moderators_url: "https://example.com/c/community/moderators" + }) + ) + + assert changeset.valid? + end + end + + defp valid_attrs(overrides \\ %{}) do + Map.merge( + %{ + uri: "https://example.com/users/alice", + username: "alice", + domain: "example.com", + inbox_url: "https://example.com/users/alice/inbox" + }, + overrides + ) + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/boundary_guardrails_test.exs b/apps/elektrine/test/elektrine/activitypub/boundary_guardrails_test.exs new file mode 100644 index 0000000..a6f8cb3 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/boundary_guardrails_test.exs @@ -0,0 +1,40 @@ +defmodule Elektrine.ActivityPub.BoundaryGuardrailsTest do + use ExUnit.Case, async: true + + @direct_fetch_pattern ~r/(?:Elektrine\.)?ActivityPub\.Fetcher\.(?:fetch_object|fetch_actor|webfinger_lookup)|(? Path.wildcard() + |> Enum.reject(&MapSet.member?(allowed, &1)) + |> files_matching(@direct_fetch_pattern) + + assert violations == [] + end + + test "LiveViews and components do not fetch remote ActivityPub resources directly" do + violations = + [ + "apps/**/lib/**/*_web/live/**/*.ex", + "apps/**/lib/**/*_web/components/**/*.ex" + ] + |> Enum.flat_map(&Path.wildcard/1) + |> files_matching(@ui_fetch_pattern) + + assert violations == [] + end + + defp files_matching(paths, pattern) do + paths + |> Enum.sort() + |> Enum.filter(fn path -> + path + |> File.read!() + |> Regex.match?(pattern) + end) + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/collection_fetcher_test.exs b/apps/elektrine/test/elektrine/activitypub/collection_fetcher_test.exs new file mode 100644 index 0000000..15756aa --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/collection_fetcher_test.exs @@ -0,0 +1,219 @@ +defmodule Elektrine.ActivityPub.CollectionFetcherTest do + use Elektrine.DataCase, async: true + + alias Elektrine.ActivityPub.CollectionFetcher + + describe "fetch_collection/2" do + test "passes fetch options to collection pages" do + first_url = "https://remote.example/replies" + next_url = "https://remote.example/replies?page=2" + + request_fun = fn + ^first_url, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: + Jason.encode!(%{ + "type" => "OrderedCollection", + "orderedItems" => [%{"id" => "https://remote.example/comments/1"}], + "next" => next_url + }) + }} + + ^next_url, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: + Jason.encode!(%{ + "type" => "OrderedCollectionPage", + "orderedItems" => [%{"id" => "https://remote.example/comments/2"}] + }) + }} + end + + assert {:ok, items} = + CollectionFetcher.fetch_collection(first_url, + request_fun: request_fun, + skip_cache: true, + validate_url: false, + max_items: 10, + max_pages: 2 + ) + + assert Enum.map(items, & &1["id"]) == [ + "https://remote.example/comments/1", + "https://remote.example/comments/2" + ] + end + + test "traverses Mastodon-style embedded first pages that have no id" do + replies_url = "https://remote.example/statuses/1/replies" + next_url = "#{replies_url}?only_other_accounts=true&page=true" + + collection = %{ + "id" => replies_url, + "type" => "Collection", + "first" => %{ + "type" => "CollectionPage", + "next" => next_url, + "partOf" => replies_url, + "items" => [%{"id" => "https://remote.example/statuses/2"}] + } + } + + request_fun = fn ^next_url, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: + Jason.encode!(%{ + "type" => "CollectionPage", + "partOf" => replies_url, + "items" => [%{"id" => "https://remote.example/statuses/3"}] + }) + }} + end + + assert {:ok, items} = + CollectionFetcher.fetch_collection(collection, + request_fun: request_fun, + skip_cache: true, + validate_url: false, + max_items: 10, + max_pages: 3 + ) + + assert Enum.map(items, & &1["id"]) == [ + "https://remote.example/statuses/2", + "https://remote.example/statuses/3" + ] + end + + test "fetches bare embedded first page references by id" do + first_url = "https://remote.example/collection?page=1" + + collection = %{ + "type" => "Collection", + "first" => %{"id" => first_url} + } + + request_fun = fn ^first_url, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: + Jason.encode!(%{ + "type" => "CollectionPage", + "items" => [%{"id" => "https://remote.example/comments/9"}] + }) + }} + end + + assert {:ok, items} = + CollectionFetcher.fetch_collection(collection, + request_fun: request_fun, + skip_cache: true, + validate_url: false, + max_items: 10, + max_pages: 2 + ) + + assert Enum.map(items, & &1["id"]) == ["https://remote.example/comments/9"] + end + + test "follows the id of untyped count-only collection references" do + collection_url = "https://remote.example/statuses/1/comments" + + request_fun = fn ^collection_url, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: + Jason.encode!(%{ + "type" => "OrderedCollection", + "totalItems" => 2, + "orderedItems" => [ + %{"id" => "https://remote.example/comments/1"}, + %{"id" => "https://remote.example/comments/2"} + ] + }) + }} + end + + assert {:ok, items} = + CollectionFetcher.fetch_collection( + %{"id" => collection_url, "totalItems" => 74}, + request_fun: request_fun, + skip_cache: true, + validate_url: false, + max_items: 10, + max_pages: 2 + ) + + assert Enum.map(items, & &1["id"]) == [ + "https://remote.example/comments/1", + "https://remote.example/comments/2" + ] + end + + test "follows the id of typed count-only collection references" do + collection_url = "https://remote.example/statuses/1/replies" + + request_fun = fn ^collection_url, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: + Jason.encode!(%{ + "type" => "OrderedCollection", + "orderedItems" => [%{"id" => "https://remote.example/comments/typed"}] + }) + }} + end + + assert {:ok, items} = + CollectionFetcher.fetch_collection( + %{"id" => collection_url, "type" => "Collection", "totalItems" => 1}, + request_fun: request_fun, + skip_cache: true, + validate_url: false, + max_items: 10, + max_pages: 2 + ) + + assert Enum.map(items, & &1["id"]) == ["https://remote.example/comments/typed"] + end + end + + describe "fetch_collection_count/1" do + test "falls back to counting ordered items when totalItems is malformed" do + collection = %{ + "type" => "OrderedCollection", + "totalItems" => "unknown", + "orderedItems" => [ + %{"id" => "https://remote.example/posts/1"}, + %{"id" => "https://remote.example/posts/2"} + ] + } + + assert {:ok, 2} = CollectionFetcher.fetch_collection_count(collection) + end + + test "returns zero when totalItems is malformed and no items are present" do + collection = %{ + "type" => "OrderedCollection", + "totalItems" => "n/a" + } + + assert {:ok, 0} = CollectionFetcher.fetch_collection_count(collection) + end + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/community_fetcher_test.exs b/apps/elektrine/test/elektrine/activitypub/community_fetcher_test.exs new file mode 100644 index 0000000..3528fc7 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/community_fetcher_test.exs @@ -0,0 +1,139 @@ +defmodule Elektrine.ActivityPub.CommunityFetcherTest do + use ExUnit.Case, async: false + + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.CommunityFetcher + alias Elektrine.Social.Message + + defmodule ActivityPubStub do + def fetch_remote_user_timeline(remote_actor_id, opts) do + send(self(), {:fetch_remote_user_timeline, remote_actor_id, opts}) + Process.get(:community_fetcher_timeline_result, {:ok, []}) + end + end + + defmodule HandlerStub do + def store_remote_post(post_object, actor_uri) do + send(self(), {:store_remote_post, post_object, actor_uri}) + + case Process.get(:community_fetcher_store_fun) do + fun when is_function(fun, 2) -> fun.(post_object, actor_uri) + _ -> Process.get(:community_fetcher_store_result, {:ok, :unhandled}) + end + end + end + + defmodule MessagingStub do + def get_message_by_activitypub_id(activitypub_id) do + send(self(), {:get_message_by_activitypub_id, activitypub_id}) + Process.get(:community_fetcher_existing_message) + end + + def update_message(message, attrs) do + send(self(), {:update_message, message, attrs}) + {:ok, message} + end + end + + setup do + previous_config = Application.get_env(:elektrine, :community_fetcher) + + on_exit(fn -> + if previous_config == nil do + Application.delete_env(:elektrine, :community_fetcher) + else + Application.put_env(:elektrine, :community_fetcher, previous_config) + end + + Process.delete(:community_fetcher_timeline_result) + Process.delete(:community_fetcher_store_result) + Process.delete(:community_fetcher_store_fun) + Process.delete(:community_fetcher_existing_message) + end) + + community_actor = %Actor{ + id: 42, + uri: "https://remote.example/c/test", + username: "test", + domain: "remote.example" + } + + Application.put_env(:elektrine, :community_fetcher, + followed_communities: fn -> [community_actor] end, + activitypub: ActivityPubStub, + handler: HandlerStub, + messaging: MessagingStub, + sleep: fn _ -> :ok end + ) + + {:ok, community_actor: community_actor} + end + + test "uses the post author URI when storing community timeline posts", %{ + community_actor: community_actor + } do + post_object = %{ + "id" => "https://remote.example/post/1", + "attributedTo" => "https://remote.example/users/alice" + } + + stored_message = %Message{id: 1001, media_metadata: %{"existing" => true}} + + Process.put(:community_fetcher_timeline_result, {:ok, [post_object]}) + Process.put(:community_fetcher_store_result, {:ok, stored_message}) + + assert {:noreply, %{}} = CommunityFetcher.handle_info(:fetch_community_posts, %{}) + + assert_received {:store_remote_post, ^post_object, "https://remote.example/users/alice"} + + assert_received {:update_message, ^stored_message, attrs} + assert attrs.media_metadata["existing"] == true + assert attrs.media_metadata["community_actor_uri"] == community_actor.uri + end + + test "skips non-message ok results without trying to update message metadata" do + post_object = %{ + "id" => "https://remote.example/post/2", + "attributedTo" => "https://remote.example/users/bob" + } + + Process.put(:community_fetcher_timeline_result, {:ok, [post_object]}) + Process.put(:community_fetcher_store_result, {:ok, :unauthorized}) + + assert {:noreply, %{}} = CommunityFetcher.handle_info(:fetch_community_posts, %{}) + + assert_received {:store_remote_post, ^post_object, "https://remote.example/users/bob"} + refute_received {:update_message, _, _} + end + + test "continues processing later posts when one post raises during ingestion" do + first_post = %{ + "id" => "https://remote.example/post/3", + "attributedTo" => "https://remote.example/users/charlie" + } + + second_post = %{ + "id" => "https://remote.example/post/4", + "attributedTo" => "https://remote.example/users/dana" + } + + stored_message = %Message{id: 1002, media_metadata: %{}} + + Process.put(:community_fetcher_timeline_result, {:ok, [first_post, second_post]}) + + Process.put(:community_fetcher_store_fun, fn post_object, _actor_uri -> + if post_object["id"] == first_post["id"] do + raise "boom" + else + {:ok, stored_message} + end + end) + + assert {:noreply, %{}} = CommunityFetcher.handle_info(:fetch_community_posts, %{}) + + assert_received {:store_remote_post, ^first_post, "https://remote.example/users/charlie"} + assert_received {:store_remote_post, ^second_post, "https://remote.example/users/dana"} + assert_received {:update_message, ^stored_message, attrs} + assert attrs.media_metadata["community_actor_uri"] == "https://remote.example/c/test" + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/domain_migration_test.exs b/apps/elektrine/test/elektrine/activitypub/domain_migration_test.exs new file mode 100644 index 0000000..7a36a97 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/domain_migration_test.exs @@ -0,0 +1,101 @@ +defmodule Elektrine.ActivityPub.DomainMigrationTest do + use Elektrine.DataCase, async: true + use Oban.Testing, repo: Elektrine.Repo + + import Ecto.Query + import Elektrine.AccountsFixtures + + alias Elektrine.Accounts + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.{Activity, Actor, Delivery, DomainMigration} + alias Elektrine.Profiles + alias Elektrine.Repo + + describe "move_account/3" do + test "stores the verified target actor and queues move deliveries" do + Oban.Testing.with_testing_mode(:manual, fn -> + user = user_fixture() + old_actor_uri = ActivityPub.actor_uri(user) + + target_actor = + remote_actor_fixture("target", %{ + "alsoKnownAs" => [old_actor_uri] + }) + + follower = remote_actor_fixture("follower") + assert {:ok, _follow} = Profiles.create_remote_follow(follower.id, user.id) + + assert {:ok, summary} = DomainMigration.move_account(user, target_actor.uri) + + assert summary.target_actor_uri == target_actor.uri + assert summary.old_actor_uri == old_actor_uri + assert summary.deliveries_queued == 1 + assert Accounts.get_user!(user.id).moved_to == target_actor.uri + + activity = + Activity + |> where([a], a.activity_type == "Move" and a.internal_user_id == ^user.id) + |> Repo.one!() + + assert activity.data["actor"] == old_actor_uri + assert activity.data["object"] == old_actor_uri + assert activity.data["target"] == target_actor.uri + + delivery = + Delivery + |> where([d], d.activity_id == ^activity.id) + |> Repo.one!() + + assert delivery.inbox_url == follower.inbox_url + assert delivery.status == "pending" + end) + end + + test "resolves cached acct targets to canonical actor URIs" do + user = user_fixture() + old_actor_uri = ActivityPub.actor_uri(user) + + target_actor = + remote_actor_fixture("target-acct", %{ + "alsoKnownAs" => [%{"href" => old_actor_uri <> "?utm=ignored"}] + }) + + assert {:ok, summary} = + DomainMigration.move_account( + user, + "acct:#{target_actor.username}@#{target_actor.domain}" + ) + + assert summary.target_actor_uri == target_actor.uri + assert Accounts.get_user!(user.id).moved_to == target_actor.uri + end + + test "rejects unverified target actors" do + user = user_fixture() + target_actor = remote_actor_fixture("unverified", %{"alsoKnownAs" => []}) + + assert {:error, :move_target_not_verified} = + DomainMigration.move_account(user, target_actor.uri) + + assert is_nil(Accounts.get_user!(user.id).moved_to) + refute Repo.exists?(from(a in Activity, where: a.activity_type == "Move")) + end + end + + defp remote_actor_fixture(suffix, metadata \\ %{}) do + unique = System.unique_integer([:positive]) + domain = "move-#{unique}.example" + + %Actor{} + |> Actor.changeset(%{ + uri: "https://#{domain}/users/#{suffix}", + username: suffix, + domain: domain, + inbox_url: "https://#{domain}/users/#{suffix}/inbox", + public_key: "-----BEGIN RSA PUBLIC KEY-----\nMOCK\n-----END RSA PUBLIC KEY-----\n", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second), + metadata: metadata + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/fetch_remote_post_replies_test.exs b/apps/elektrine/test/elektrine/activitypub/fetch_remote_post_replies_test.exs new file mode 100644 index 0000000..0d1d42d --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/fetch_remote_post_replies_test.exs @@ -0,0 +1,38 @@ +defmodule Elektrine.ActivityPub.FetchRemotePostRepliesTest do + use Elektrine.DataCase, async: false + + alias Elektrine.ActivityPub + + test "does not raise when collection fields are URL strings" do + post_object = %{ + "id" => "https://example.com/posts/1", + "url" => "https://example.com/posts/1", + "type" => "Note", + "replies" => "http://127.0.0.1:1/replies", + "comments" => "http://127.0.0.1:1/comments", + "repliesCount" => "0" + } + + result = ActivityPub.fetch_remote_post_replies(post_object, limit: 1) + + assert is_tuple(result) + assert tuple_size(result) == 2 + assert elem(result, 0) in [:ok, :error] + end + + test "does not raise for Lemmy-style posts when replies collection is unreachable" do + post_object = %{ + "id" => "http://127.0.0.1:1/post/1", + "url" => "http://127.0.0.1:1/post/1", + "type" => "Page", + "replies" => "http://127.0.0.1:1/replies", + "repliesCount" => 3 + } + + result = ActivityPub.fetch_remote_post_replies(post_object, limit: 1) + + assert is_tuple(result) + assert tuple_size(result) == 2 + assert elem(result, 0) in [:ok, :error] + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/fetch_remote_user_timeline_test.exs b/apps/elektrine/test/elektrine/activitypub/fetch_remote_user_timeline_test.exs new file mode 100644 index 0000000..798aca3 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/fetch_remote_user_timeline_test.exs @@ -0,0 +1,96 @@ +defmodule Elektrine.ActivityPub.FetchRemoteUserTimelineTest do + use Elektrine.DataCase, async: false + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor + + defmodule FetcherStub do + def fetch_object(url) do + send(self(), {:fetch_object, url}) + + case Process.get({:fetch_object_response, url}) do + nil -> {:error, :missing_stub} + response -> response + end + end + end + + test "follows paginated outbox pages until enough top-level posts are collected" do + actor = remote_actor_fixture() + + outbox_url = actor.outbox_url + next_page_url = outbox_url <> "?page=2" + + Process.put( + {:fetch_object_response, outbox_url}, + {:ok, + %{ + "type" => "OrderedCollection", + "first" => %{ + "orderedItems" => [ + %{ + "type" => "Create", + "object" => %{ + "id" => "https://remote.example/comment/1", + "type" => "Note", + "inReplyTo" => "https://remote.example/post/original" + } + } + ], + "next" => next_page_url + } + }} + ) + + Process.put( + {:fetch_object_response, next_page_url}, + {:ok, + %{ + "type" => "OrderedCollectionPage", + "orderedItems" => [ + %{ + "type" => "Create", + "object" => %{ + "id" => "https://remote.example/post/1", + "type" => "Page" + } + }, + %{ + "type" => "Create", + "object" => %{ + "id" => "https://remote.example/post/2", + "type" => "Page" + } + } + ] + }} + ) + + assert {:ok, posts} = + ActivityPub.fetch_remote_user_timeline(actor.id, limit: 2, fetcher: FetcherStub) + + assert Enum.map(posts, & &1["id"]) == [ + "https://remote.example/post/1", + "https://remote.example/post/2" + ] + + assert_received {:fetch_object, ^outbox_url} + assert_received {:fetch_object, ^next_page_url} + end + + defp remote_actor_fixture do + unique = System.unique_integer([:positive]) + + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.example/users/alice-#{unique}", + username: "alice#{unique}", + domain: "remote.example", + inbox_url: "https://remote.example/inbox", + outbox_url: "https://remote.example/outbox/#{unique}", + public_key: "-----BEGIN PUBLIC KEY-----test-key-----END PUBLIC KEY-----", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/fetcher_test.exs b/apps/elektrine/test/elektrine/activitypub/fetcher_test.exs new file mode 100644 index 0000000..c2b8073 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/fetcher_test.exs @@ -0,0 +1,597 @@ +defmodule Elektrine.ActivityPub.FetcherTest do + use Elektrine.DataCase, async: false + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.Fetcher + + import ExUnit.CaptureLog + + describe "fetch_object/2" do + test "rejects unsafe URLs before making a request" do + assert {:error, :unsafe_url} = + Fetcher.fetch_object("http://127.0.0.1/notes/1", skip_cache: true) + end + + test "rejects fetched content objects outside the public audience" do + note_uri = "https://remote.example/users/alice/statuses/private" + + request_fun = fn ^note_uri, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "id" => note_uri, + "type" => "Note", + "attributedTo" => "https://remote.example/users/alice", + "content" => "followers only", + "to" => ["https://remote.example/users/alice/followers"], + "cc" => [] + }) + }} + end + + assert {:error, :unauthorized_fetch} = + Fetcher.fetch_object(note_uri, + skip_cache: true, + validate_url: false, + request_fun: request_fun + ) + end + + test "accepts unlisted fetched content objects addressed to Public in cc" do + note_uri = "https://remote.example/users/alice/statuses/unlisted" + + request_fun = fn ^note_uri, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "id" => note_uri, + "type" => "Note", + "attributedTo" => "https://remote.example/users/alice", + "content" => "unlisted", + "to" => ["https://remote.example/users/alice/followers"], + "cc" => ["https://www.w3.org/ns/activitystreams#Public"] + }) + }} + end + + assert {:ok, object} = + Fetcher.fetch_object(note_uri, + skip_cache: true, + validate_url: false, + request_fun: request_fun + ) + + assert object["id"] == note_uri + end + + test "recovers Lemmy posts when the post URL returns HTML" do + post_uri = "https://startrek.website/post/37631588" + + resolve_url = + "https://startrek.website/api/v4/resolve_object?q=https%3A%2F%2Fstartrek.website%2Fpost%2F37631588" + + request_fun = fn + ^post_uri, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "text/html; charset=utf-8"}], + body: "post page" + }} + + ^resolve_url, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "post" => %{ + "post" => %{ + "ap_id" => post_uri, + "name" => "A Star Trek post", + "body" => "Body content", + "published" => "2026-01-30T02:18:24.601576Z", + "url" => "https://example.com/star-trek-post" + }, + "creator" => %{ + "actor_id" => "https://startrek.website/u/TribblesBestFriend", + "name" => "TribblesBestFriend" + }, + "community" => %{ + "actor_id" => "https://startrek.website/c/startrek" + }, + "counts" => %{"comments" => 96, "score" => 12} + } + }) + }} + end + + assert {:ok, object} = + Fetcher.fetch_object(post_uri, skip_cache: true, request_fun: request_fun) + + assert object["id"] == post_uri + assert object["type"] == "Page" + assert object["content"] == "Body content" + assert object["attributedTo"] == "https://startrek.website/u/TribblesBestFriend" + assert get_in(object, ["comments", "totalItems"]) == 96 + + assert object["attachment"] == [ + %{ + "type" => "Link", + "href" => "https://example.com/star-trek-post", + "name" => "A Star Trek post" + } + ] + end + + test "does not recover Lemmy posts when recovery is disabled" do + post_uri = "https://startrek.website/post/37631588" + + request_fun = fn ^post_uri, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "text/html; charset=utf-8"}], + body: "post page" + }} + end + + assert {:error, :invalid_json} = + Fetcher.fetch_object(post_uri, + skip_cache: true, + allow_recovery: false, + request_fun: request_fun + ) + end + + test "logs non-JSON upstream responses with a body preview" do + outbox_uri = "https://app.wafrn.net/fediverse/blog/sberson/outbox" + + request_fun = fn ^outbox_uri, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "text/plain"}], + body: "OK" + }} + end + + log = + capture_log(fn -> + assert {:error, :invalid_json} = + Fetcher.fetch_object(outbox_uri, skip_cache: true, request_fun: request_fun) + end) + + assert log =~ "Expected ActivityPub JSON" + assert log =~ "content_type=\"text/plain\"" + assert log =~ "body_preview=\"OK\"" + end + + test "recovers Lemmy comments when the comment URL returns HTML" do + comment_uri = "https://startrek.website/comment/22443688" + + resolve_url = + "https://startrek.website/api/v4/resolve_object?q=https%3A%2F%2Fstartrek.website%2Fcomment%2F22443688" + + request_fun = fn + ^comment_uri, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "text/html; charset=utf-8"}], + body: "comment page" + }} + + ^resolve_url, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "comment" => %{ + "comment" => %{ + "ap_id" => comment_uri, + "content" => "Comment body", + "published" => "2026-04-16T00:00:00Z", + "path" => "0.22440000.22443688" + }, + "creator" => %{ + "actor_id" => "https://startrek.website/u/threecoloured", + "name" => "threecoloured" + }, + "post" => %{ + "ap_id" => "https://startrek.website/post/37631588" + }, + "community" => %{ + "actor_id" => "https://startrek.website/c/startrek" + }, + "counts" => %{"child_count" => 7, "score" => 9, "upvotes" => 11} + } + }) + }} + end + + assert {:ok, object} = + Fetcher.fetch_object(comment_uri, skip_cache: true, request_fun: request_fun) + + assert object["id"] == comment_uri + assert object["type"] == "Note" + assert object["content"] == "Comment body" + assert object["attributedTo"] == "https://startrek.website/u/threecoloured" + assert object["inReplyTo"] == "https://startrek.website/comment/22440000" + assert get_in(object, ["replies", "totalItems"]) == 7 + assert get_in(object, ["_lemmy", "upvotes"]) == 11 + end + + test "recovers Lemmy comments from routeData html with undefined values" do + comment_uri = "https://adultswim.fan/comment/8755826" + + request_fun = fn + ^comment_uri, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "text/html; charset=utf-8"}], + body: """ + + + + + + + + """ + }} + end + + assert {:ok, object} = + Fetcher.fetch_object(comment_uri, skip_cache: true, request_fun: request_fun) + + assert object["id"] == "https://lemmy.world/comment/23270371" + assert object["type"] == "Note" + assert object["content"] == "How does one 'learn AI'?" + assert object["attributedTo"] == "https://lemmy.world/u/StaticFalconar" + assert object["inReplyTo"] == "https://lemmy.world/post/45711923" + assert get_in(object, ["replies", "totalItems"]) == 21 + assert get_in(object, ["_lemmy", "upvotes"]) == 48 + end + + test "recovers Mastodon status objects when the AP URL returns 404" do + status_uri = "https://mastodon.world/users/alice/statuses/115379251737165990" + api_url = "https://mastodon.world/api/v1/statuses/115379251737165990" + + request_fun = fn + ^status_uri, _headers, _opts -> + {:ok, %Finch.Response{status: 404, headers: [], body: ""}} + + ^api_url, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "application/json"}], + body: + Jason.encode!(%{ + "uri" => status_uri, + "url" => "https://mastodon.world/@alice/115379251737165990", + "content" => "

    Hello from API fallback

    ", + "created_at" => "2026-04-16T00:00:00Z", + "visibility" => "public", + "sensitive" => false, + "spoiler_text" => "", + "media_attachments" => [], + "account" => %{"username" => "alice"} + }) + }} + end + + assert {:ok, object} = + Fetcher.fetch_object(status_uri, skip_cache: true, request_fun: request_fun) + + assert object["id"] == status_uri + assert object["type"] == "Note" + assert object["attributedTo"] == "https://mastodon.world/users/alice" + assert object["url"] == "https://mastodon.world/@alice/115379251737165990" + assert object["to"] == ["https://www.w3.org/ns/activitystreams#Public"] + end + + test "recovers Akkoma object URLs through status search when direct fetch returns 404" do + object_uri = "https://ak.kyaruc.moe/objects/db60670c-c2ec-4e93-b33c-e124ecf98792" + + search_url = + "https://ak.kyaruc.moe/api/v2/search?q=#{URI.encode_www_form(object_uri)}&type=statuses&resolve=true&limit=1" + + request_fun = fn + ^object_uri, _headers, _opts -> + {:ok, %Finch.Response{status: 404, headers: [], body: "\"Not found\""}} + + ^search_url, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "application/json"}], + body: + Jason.encode!(%{ + "statuses" => [ + %{ + "uri" => object_uri, + "url" => "https://ak.kyaruc.moe/notice/test", + "content" => "

    Akkoma object fallback

    ", + "created_at" => "2026-05-24T00:00:00Z", + "visibility" => "public", + "sensitive" => false, + "spoiler_text" => "", + "media_attachments" => [], + "account" => %{ + "username" => "alice", + "uri" => "https://ak.kyaruc.moe/users/alice" + } + } + ] + }) + }} + end + + assert {:ok, object} = + Fetcher.fetch_object(object_uri, skip_cache: true, request_fun: request_fun) + + assert object["id"] == object_uri + assert object["content"] == "

    Akkoma object fallback

    " + assert object["attributedTo"] == "https://ak.kyaruc.moe/users/alice" + end + end + + describe "webfinger_lookup/2" do + test "rejects private domains before making a request" do + assert {:error, :unsafe_url} = + Fetcher.webfinger_lookup("alice@127.0.0.1", skip_cache: true) + end + + test "returns an error for malformed acct input" do + assert {:error, :invalid_acct} = Fetcher.webfinger_lookup("alice", skip_cache: true) + assert {:error, :invalid_acct} = Fetcher.webfinger_lookup("@example.com", skip_cache: true) + assert {:error, :invalid_acct} = Fetcher.webfinger_lookup("alice@", skip_cache: true) + end + + test "treats remote 404 responses as not found" do + request_fun = fn _url, _headers, _opts -> + {:ok, %Finch.Response{status: 404, body: "not found"}} + end + + assert {:error, :not_found} = + Fetcher.webfinger_lookup("missing@example.com", + skip_cache: true, + validate_url: false, + request_fun: request_fun + ) + end + end + + describe "fetch_and_cache_actor/2" do + test "rejects actor documents whose id does not match the requested URI" do + actor_uri = "http://8.8.8.8/users/alice" + + request_fun = fn ^actor_uri, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "id" => "http://1.1.1.1/users/bob", + "type" => "Person", + "preferredUsername" => "bob", + "inbox" => "http://1.1.1.1/inbox", + "outbox" => "http://1.1.1.1/outbox", + "followers" => "http://1.1.1.1/users/bob/followers", + "following" => "http://1.1.1.1/users/bob/following" + }) + }} + end + + assert {:error, :actor_id_mismatch} = + ActivityPub.fetch_and_cache_actor(actor_uri, request_fun: request_fun) + end + + test "accepts actor documents that use /ap/users canonical ids on the same host" do + actor_uri = "https://mastodon.social/users/Sea1Am" + + request_fun = fn ^actor_uri, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "id" => "https://mastodon.social/ap/users/116313284892040418", + "type" => "Person", + "preferredUsername" => "Sea1Am", + "inbox" => "https://mastodon.social/users/Sea1Am/inbox", + "outbox" => "https://mastodon.social/users/Sea1Am/outbox", + "followers" => "https://mastodon.social/users/Sea1Am/followers", + "following" => "https://mastodon.social/users/Sea1Am/following", + "publicKey" => %{ + "id" => "https://mastodon.social/ap/users/116313284892040418#main-key", + "owner" => "https://mastodon.social/ap/users/116313284892040418", + "publicKeyPem" => "-----BEGIN PUBLIC KEY-----\nSEA1AM\n-----END PUBLIC KEY-----" + } + }) + }} + end + + assert {:ok, actor} = ActivityPub.fetch_and_cache_actor(actor_uri, request_fun: request_fun) + assert actor.uri == "https://mastodon.social/ap/users/116313284892040418" + assert actor.username == "Sea1Am" + end + + test "updates an existing actor when username and domain already exist for another URI" do + actor_uri = "https://baraag.net/users/minigun" + + existing_actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://baraag.net/@minigun", + username: "minigun", + domain: "baraag.net", + display_name: "Old Minigun", + inbox_url: "https://baraag.net/inbox", + public_key: "old-key", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert!() + + request_fun = fn ^actor_uri, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "id" => actor_uri, + "type" => "Person", + "preferredUsername" => "minigun", + "name" => "Minigun", + "inbox" => "https://baraag.net/users/minigun/inbox", + "outbox" => "https://baraag.net/users/minigun/outbox", + "followers" => "https://baraag.net/users/minigun/followers", + "following" => "https://baraag.net/users/minigun/following", + "publicKey" => %{"publicKeyPem" => "new-key"} + }) + }} + end + + assert {:ok, actor} = ActivityPub.fetch_and_cache_actor(actor_uri, request_fun: request_fun) + assert actor.id == existing_actor.id + assert actor.uri == actor_uri + assert actor.display_name == "Minigun" + end + + test "rejects actor documents that advertise unsafe inboxes" do + actor_uri = "http://8.8.8.8/users/alice" + + request_fun = fn ^actor_uri, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "id" => actor_uri, + "type" => "Person", + "preferredUsername" => "alice", + "inbox" => "http://127.0.0.1/inbox", + "outbox" => "http://8.8.8.8/outbox", + "followers" => "http://8.8.8.8/users/alice/followers", + "following" => "http://8.8.8.8/users/alice/following" + }) + }} + end + + assert {:error, :unsafe_actor_document} = + ActivityPub.fetch_and_cache_actor(actor_uri, request_fun: request_fun) + end + + test "recovers Lemmy actors when the actor URL returns HTML" do + actor_uri = "https://startrek.website/u/TribblesBestFriend" + + resolve_url = + "https://startrek.website/api/v4/resolve_object?q=https%3A%2F%2Fstartrek.website%2Fu%2FTribblesBestFriend" + + request_fun = fn + ^actor_uri, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "text/html; charset=utf-8"}], + body: "profile page" + }} + + ^resolve_url, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "person" => %{ + "person" => %{ + "name" => "TribblesBestFriend", + "actor_id" => actor_uri, + "published" => "2025-01-30T02:18:24.601576Z" + } + } + }) + }} + end + + assert {:ok, actor} = ActivityPub.fetch_and_cache_actor(actor_uri, request_fun: request_fun) + assert actor.uri == actor_uri + assert actor.username == "TribblesBestFriend" + assert actor.inbox_url == "https://startrek.website/u/TribblesBestFriend/inbox" + end + + test "does not recover Lemmy actors when recovery is disabled" do + actor_uri = "https://startrek.website/u/TribblesBestFriend" + + request_fun = fn ^actor_uri, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "text/html; charset=utf-8"}], + body: "profile page" + }} + end + + assert {:error, :invalid_json} = + ActivityPub.fetch_and_cache_actor(actor_uri, + request_fun: request_fun, + allow_recovery: false + ) + end + + test "recovers Lemmy site actors when the actor URL is the instance root" do + actor_uri = "https://startrek.website/" + site_url = "https://startrek.website/api/v4/site" + + request_fun = fn + ^actor_uri, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "text/html; charset=utf-8"}], + body: "home page" + }} + + ^site_url, _headers, _opts -> + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "site_view" => %{ + "site" => %{ + "actor_id" => actor_uri, + "name" => "Star Trek Website", + "description" => "A Lemmy instance", + "published" => "2023-06-11T18:36:08.306144Z", + "icon" => "https://startrek.website/icon.png", + "banner" => "https://startrek.website/banner.png", + "inbox_url" => "https://startrek.website/inbox", + "public_key" => + "-----BEGIN PUBLIC KEY-----\nSITEKEY\n-----END PUBLIC KEY-----" + } + } + }) + }} + end + + assert {:ok, actor} = ActivityPub.fetch_and_cache_actor(actor_uri, request_fun: request_fun) + assert actor.uri == actor_uri + assert actor.username == "startrek.website" + assert actor.actor_type == "Application" + assert actor.inbox_url == "https://startrek.website/inbox" + assert actor.public_key == "-----BEGIN PUBLIC KEY-----\nSITEKEY\n-----END PUBLIC KEY-----" + end + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/get_or_fetch_actor_test.exs b/apps/elektrine/test/elektrine/activitypub/get_or_fetch_actor_test.exs new file mode 100644 index 0000000..6d02518 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/get_or_fetch_actor_test.exs @@ -0,0 +1,125 @@ +defmodule Elektrine.ActivityPub.GetOrFetchActorTest do + use Elektrine.DataCase, async: false + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor + + describe "get_or_fetch_actor/1" do + test "returns a cached actor for a binary URI" do + actor = remote_actor_fixture() + + assert {:ok, fetched_actor} = ActivityPub.get_or_fetch_actor(actor.uri) + assert fetched_actor.id == actor.id + end + + test "normalizes list-based actor references" do + actor = remote_actor_fixture() + + assert {:ok, fetched_actor} = + ActivityPub.get_or_fetch_actor([ + "", + %{"id" => " "}, + %{"id" => actor.uri} + ]) + + assert fetched_actor.id == actor.id + end + + test "normalizes map-based actor references" do + actor = remote_actor_fixture() + + assert {:ok, fetched_actor} = + ActivityPub.get_or_fetch_actor(%{ + "type" => "Person", + "id" => actor.uri + }) + + assert fetched_actor.id == actor.id + end + + test "returns an error for blank input" do + assert {:error, :invalid_actor_uri} = ActivityPub.get_or_fetch_actor(" ") + end + + test "returns an error for unsupported actor input shapes" do + assert {:error, :invalid_actor_uri} = ActivityPub.get_or_fetch_actor(nil) + assert {:error, :invalid_actor_uri} = ActivityPub.get_or_fetch_actor(%{"type" => "Person"}) + end + end + + describe "actor profile fields" do + test "stores long ActivityPub actor URLs without varchar truncation errors" do + unique = System.unique_integer([:positive]) + long_segment = String.duplicate("a", 280) + actor_uri = "https://remote.example/users/#{long_segment}-#{unique}" + + actor = + %Actor{} + |> Actor.changeset(%{ + uri: actor_uri, + username: "longurl#{unique}", + domain: "remote.example", + display_name: String.duplicate("Display ", 40), + avatar_url: "https://remote.example/media/#{long_segment}.png", + header_url: "https://remote.example/media/#{long_segment}.jpg", + inbox_url: "#{actor_uri}/inbox/#{long_segment}", + outbox_url: "#{actor_uri}/outbox/#{long_segment}", + followers_url: "#{actor_uri}/followers/#{long_segment}", + following_url: "#{actor_uri}/following/#{long_segment}", + moderators_url: "#{actor_uri}/moderators/#{long_segment}", + public_key: "-----BEGIN PUBLIC KEY-----test-key-----END PUBLIC KEY-----", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert!() + + assert actor.uri == actor_uri + assert String.length(actor.avatar_url) > 255 + assert String.length(actor.inbox_url) > 255 + end + end + + describe "get_actor_by_uri/1" do + test "returns the oldest actor when duplicate uri rows exist" do + unique = System.unique_integer([:positive]) + uri = "https://remote.example/users/duplicate-#{unique}" + + Repo.query!("DROP INDEX activitypub_actors_uri_index") + + older = insert_actor_with_uri(uri, unique, "older") + _newer = insert_actor_with_uri(uri, unique, "newer") + + fetched_actor = ActivityPub.get_actor_by_uri(uri) + + assert fetched_actor.id == older.id + assert fetched_actor.uri == uri + end + end + + defp remote_actor_fixture do + unique = System.unique_integer([:positive]) + + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.example/users/alice-#{unique}", + username: "alice#{unique}", + domain: "remote.example", + inbox_url: "https://remote.example/inbox", + public_key: "-----BEGIN PUBLIC KEY-----test-key-----END PUBLIC KEY-----", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert!() + end + + defp insert_actor_with_uri(uri, unique, suffix) do + %Actor{} + |> Actor.changeset(%{ + uri: uri, + username: "#{suffix}#{unique}", + domain: "remote.example", + inbox_url: "https://remote.example/inbox/#{suffix}", + public_key: "-----BEGIN PUBLIC KEY-----test-key-#{suffix}-----END PUBLIC KEY-----", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/handler_test.exs b/apps/elektrine/test/elektrine/activitypub/handler_test.exs new file mode 100644 index 0000000..a7d8600 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/handler_test.exs @@ -0,0 +1,303 @@ +defmodule Elektrine.ActivityPub.HandlerTest do + use Elektrine.DataCase, async: false + + alias Elektrine.AccountsFixtures + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.Handler + alias Elektrine.ActivityPub.Instance + alias Elektrine.Repo + alias Elektrine.Social.MessageReaction + alias Elektrine.SocialFixtures + + describe "process_activity_async/3" do + test "rejects blocked instances before routing" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "blocked-async.example.com", blocked: true}) + |> Repo.insert() + + activity = %{ + "id" => + "https://blocked-async.example.com/activities/#{System.unique_integer([:positive])}", + "type" => "CustomType", + "actor" => "https://blocked-async.example.com/users/test", + "object" => %{"type" => "Note", "content" => "test"} + } + + assert {:ok, :blocked} = + Handler.process_activity_async( + activity, + "https://blocked-async.example.com/users/test", + nil + ) + end + + test "resolves shared inbox follow targets before applying per-user blocks" do + user = AccountsFixtures.user_fixture(%{username: "sharedinboxblocked"}) + actor_uri = "https://remote.example/users/blocked" + + assert {:ok, _block} = ActivityPub.block_for_user(user.id, actor_uri) + + activity = %{ + "id" => "https://remote.example/activities/#{System.unique_integer([:positive])}", + "type" => "Follow", + "actor" => actor_uri, + "object" => "#{ActivityPub.instance_url()}/users/#{user.username}" + } + + assert {:ok, :blocked} = Handler.process_activity_async(activity, actor_uri, nil) + end + + test "does not infer a single blocked target user from mentions on public activities" do + user = AccountsFixtures.user_fixture(%{username: "publicmentionblocked"}) + actor_uri = "https://remote.example/users/public-mention-blocked" + + assert {:ok, _block} = ActivityPub.block_for_user(user.id, actor_uri) + + activity = %{ + "id" => "https://remote.example/activities/#{System.unique_integer([:positive])}", + "type" => "CustomType", + "actor" => actor_uri, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "object" => %{ + "id" => "https://remote.example/objects/#{System.unique_integer([:positive])}", + "type" => "Note", + "content" => "Hello @#{user.username}", + "tag" => [ + %{ + "type" => "Mention", + "href" => "#{ActivityPub.instance_url()}/users/#{user.username}", + "name" => "@#{user.username}" + } + ], + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + assert {:ok, :unhandled} = Handler.process_activity_async(activity, actor_uri, nil) + end + + test "resolves URI-form Undo activities from stored local state before fetching" do + user = AccountsFixtures.user_fixture() + message = SocialFixtures.post_fixture(%{user: user, visibility: "public"}) + remote_actor = remote_actor_fixture("undo_liker") + like_id = "https://remote.example/likes/#{System.unique_integer([:positive])}" + + like_activity = %{ + "id" => like_id, + "type" => "Like", + "actor" => remote_actor.uri, + "object" => "#{ActivityPub.instance_url()}/posts/#{message.id}" + } + + undo_activity = %{ + "id" => "https://remote.example/undo/#{System.unique_integer([:positive])}", + "type" => "Undo", + "actor" => remote_actor.uri, + "object" => like_id + } + + assert {:ok, :liked} = Handler.process_activity_async(like_activity, remote_actor.uri, nil) + + assert {:ok, :unliked} = + Handler.process_activity_async(undo_activity, remote_actor.uri, nil) + end + + test "returns an error when a URI-form Undo object cannot be fetched" do + actor_uri = "https://remote.example/users/undo-fetch-failure" + + activity = %{ + "id" => "https://remote.example/activities/#{System.unique_integer([:positive])}", + "type" => "Undo", + "actor" => actor_uri, + "object" => "http://127.0.0.1/undo/#{System.unique_integer([:positive])}" + } + + assert {:error, :undo_activity_fetch_failed} = + Handler.process_activity_async(activity, actor_uri, nil) + end + + test "resolves URI-form Undo EmojiReact activities from stored local state before fetching" do + user = AccountsFixtures.user_fixture() + message = SocialFixtures.post_fixture(%{user: user, visibility: "public"}) + remote_actor = remote_actor_fixture("undo_reactor") + reaction_id = "https://remote.example/reactions/#{System.unique_integer([:positive])}" + object_id = "#{ActivityPub.instance_url()}/posts/#{message.id}" + + assert {:ok, _reaction} = + Elektrine.Social.Messages.create_federated_emoji_reaction( + message.id, + remote_actor.id, + ":blobcat:" + ) + + assert {:ok, _activity} = + ActivityPub.create_activity(%{ + activity_id: reaction_id, + activity_type: "EmojiReact", + actor_uri: remote_actor.uri, + object_id: object_id, + data: %{ + "id" => reaction_id, + "type" => "EmojiReact", + "actor" => remote_actor.uri, + "object" => object_id, + "content" => ":blobcat:" + }, + local: false, + processed: true + }) + + undo_activity = %{ + "id" => "https://remote.example/undo/#{System.unique_integer([:positive])}", + "type" => "Undo", + "actor" => remote_actor.uri, + "object" => reaction_id + } + + assert {:ok, :emoji_unreacted} = + Handler.process_activity_async(undo_activity, remote_actor.uri, nil) + + assert Repo.get_by(MessageReaction, + message_id: message.id, + remote_actor_id: remote_actor.id, + emoji: ":blobcat:" + ) == nil + end + + test "rejects structurally invalid activities before routing" do + actor_uri = "https://remote.example/users/invalid-follow" + + activity = %{ + "type" => "Follow", + "actor" => actor_uri + } + + assert {:ok, :invalid} = Handler.process_activity_async(activity, actor_uri, nil) + end + end + + describe "extract_local_mentions/1" do + test "extracts username from example.net mention" do + local_domain = Elektrine.Domains.instance_domain() + + object = %{ + "tag" => [ + %{ + "type" => "Mention", + "href" => "https://#{local_domain}/users/testuser", + "name" => "@testuser@#{local_domain}" + } + ] + } + + result = Handler.extract_local_mentions(object) + assert result == ["testuser"] + end + + test "extracts username from example.com mention" do + local_domain = Elektrine.Domains.primary_email_domain() + + object = %{ + "tag" => [ + %{ + "type" => "Mention", + "href" => "https://#{local_domain}/users/testuser", + "name" => "@testuser@#{local_domain}" + } + ] + } + + result = Handler.extract_local_mentions(object) + assert result == ["testuser"] + end + + test "ignores remote mentions" do + object = %{ + "tag" => [ + %{ + "type" => "Mention", + "href" => "https://mastodon.social/users/someuser", + "name" => "@someuser@mastodon.social" + } + ] + } + + result = Handler.extract_local_mentions(object) + assert result == [] + end + + test "extracts multiple local mentions from both domains" do + domains = + Elektrine.Domains.activitypub_domains() + |> Enum.take(2) + + [first_domain | rest] = domains + second_domain = List.first(rest) || first_domain + + object = %{ + "tag" => [ + %{ + "type" => "Mention", + "href" => "https://#{first_domain}/users/alice", + "name" => "@alice@#{first_domain}" + }, + %{ + "type" => "Mention", + "href" => "https://#{second_domain}/users/bob", + "name" => "@bob@#{second_domain}" + }, + %{ + "type" => "Mention", + "href" => "https://mastodon.social/users/charlie", + "name" => "@charlie@mastodon.social" + }, + %{ + "type" => "Hashtag", + "href" => "https://example.net/tags/test", + "name" => "#test" + } + ] + } + + result = Handler.extract_local_mentions(object) + assert Enum.sort(result) == ["alice", "bob"] + end + + test "handles empty tags" do + object = %{"tag" => []} + result = Handler.extract_local_mentions(object) + assert result == [] + end + + test "handles missing tags" do + object = %{} + result = Handler.extract_local_mentions(object) + assert result == [] + end + + test "handles nil tags" do + object = %{"tag" => nil} + result = Handler.extract_local_mentions(object) + assert result == [] + end + end + + defp remote_actor_fixture(label) do + unique_id = System.unique_integer([:positive]) + username = "#{label}#{unique_id}" + + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.example/users/#{username}", + username: username, + domain: "remote.example", + inbox_url: "https://remote.example/users/#{username}/inbox", + public_key: "-----BEGIN RSA PUBLIC KEY-----\nMOCK\n-----END RSA PUBLIC KEY-----\n", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/handlers/announce_handler_test.exs b/apps/elektrine/test/elektrine/activitypub/handlers/announce_handler_test.exs new file mode 100644 index 0000000..120ff6b --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/handlers/announce_handler_test.exs @@ -0,0 +1,270 @@ +defmodule Elektrine.ActivityPub.Handlers.AnnounceHandlerTest do + use Elektrine.DataCase, async: true + + if not Code.ensure_loaded?(Elektrine.Social.Hashtag) do + @moduletag skip: "requires :elektrine_social" + end + + import Elektrine.AccountsFixtures + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.Handlers.AnnounceHandler + alias Elektrine.Messaging + alias Elektrine.Messaging.FederatedBoost + alias Elektrine.Repo + + describe "handle/3 - Announce activity" do + setup do + user = user_fixture() + %{user: user} + end + + test "returns error for announce of non-existent local message", %{user: _user} do + base_url = ActivityPub.instance_url() + booster = remote_actor_fixture("missinglocalbooster") + + activity = %{ + "type" => "Announce", + "actor" => booster.uri, + "object" => "#{base_url}/posts/99999999" + } + + result = AnnounceHandler.handle(activity, booster.uri, nil) + assert result == {:error, :announce_object_fetch_failed} + end + + test "handles object reference as map with id" do + base_url = ActivityPub.instance_url() + booster = remote_actor_fixture("mapbooster") + + activity = %{ + "type" => "Announce", + "actor" => booster.uri, + "object" => %{"id" => "#{base_url}/posts/99999999", "type" => "Note"} + } + + result = AnnounceHandler.handle(activity, booster.uri, nil) + assert result == {:error, :announce_object_fetch_failed} + end + + test "ignores inaccessible remote activity wrapper URLs" do + booster = remote_actor_fixture("wrapperbooster") + + activity = %{ + "type" => "Announce", + "actor" => booster.uri, + "object" => "https://remote.server/activities/123" + } + + result = AnnounceHandler.handle(activity, booster.uri, nil) + assert result == {:ok, :ignored} + end + + test "returns a retryable error when a nested Create wrapper cannot resolve its inner object" do + booster = remote_actor_fixture("nestedbooster") + author = remote_actor_fixture("nestedauthor") + wrapper_uri = "https://example.com/activities/#{System.unique_integer([:positive])}" + + wrapper_object = %{ + "id" => wrapper_uri, + "type" => "Create", + "actor" => author.uri, + "object" => "http://127.0.0.1/objects/#{System.unique_integer([:positive])}" + } + + assert {:ok, _cached_wrapper_object} = + Elektrine.AppCache.get_object(wrapper_uri, fn -> {:ok, wrapper_object} end) + + activity = %{ + "type" => "Announce", + "actor" => booster.uri, + "object" => wrapper_uri + } + + assert {:error, :announce_object_fetch_failed} = + AnnounceHandler.handle(activity, booster.uri, nil) + end + + test "inherits public visibility from nested Create wrappers" do + booster = remote_actor_fixture("visibilitybooster") + author = remote_actor_fixture("visibilityauthor") + wrapper_uri = "https://example.com/activities/#{System.unique_integer([:positive])}" + object_id = "https://example.com/objects/#{System.unique_integer([:positive])}" + + wrapper_object = %{ + "id" => wrapper_uri, + "type" => "Create", + "actor" => author.uri, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "id" => object_id, + "type" => "Note", + "content" => "

    Wrapped public note

    ", + "attributedTo" => author.uri, + "to" => [], + "cc" => [] + } + } + + assert {:ok, _cached_wrapper} = + Elektrine.AppCache.get_object(wrapper_uri, fn -> {:ok, wrapper_object} end) + + activity = %{ + "type" => "Announce", + "actor" => booster.uri, + "object" => wrapper_uri + } + + assert {:ok, :announced} = AnnounceHandler.handle(activity, booster.uri, nil) + assert %{visibility: "public"} = Messaging.get_message_by_activitypub_id(object_id) + end + + test "returns error for invalid object" do + activity = %{ + "type" => "Announce", + "actor" => "https://remote.server/users/booster", + "object" => %{"invalid" => "no_id"} + } + + result = AnnounceHandler.handle(activity, "https://remote.server/users/booster", nil) + assert result == {:error, :invalid_object} + end + + test "ignores Announce object lists when only a remote wrapper and invalid object are present" do + activity = %{ + "type" => "Announce", + "actor" => "https://remote.server/users/booster", + "object" => [ + "https://remote.server/activities/123", + %{"invalid" => "no_id"} + ] + } + + result = AnnounceHandler.handle(activity, "https://remote.server/users/booster", nil) + assert result == {:ok, :ignored} + end + + test "matches a cached federated post by activitypub URL variant" do + booster = remote_actor_fixture("booster") + author = remote_actor_fixture("author") + canonical_id = "https://remote.server/objects/#{System.unique_integer([:positive])}" + object_url = "#{canonical_id}/permalink" + + assert {:ok, _message} = + Messaging.create_federated_message(%{ + content: "Remote post", + visibility: "public", + activitypub_id: canonical_id, + activitypub_url: object_url, + federated: true, + remote_actor_id: author.id, + inserted_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + activity = %{ + "type" => "Announce", + "actor" => booster.uri, + "object" => object_url + } + + assert {:ok, :announced} = AnnounceHandler.handle(activity, booster.uri, nil) + end + + test "unwraps nested object wrappers when handling an announce" do + booster = remote_actor_fixture("wrapped_booster") + message = remote_message_fixture("wrapped-announce-post") + + activity = %{ + "type" => "Announce", + "actor" => booster.uri, + "object" => %{ + "type" => "Create", + "object" => %{"id" => message.activitypub_id, "type" => "Note"} + } + } + + assert {:ok, :announced} = AnnounceHandler.handle(activity, booster.uri, nil) + assert Repo.get_by(FederatedBoost, message_id: message.id, remote_actor_id: booster.id) + end + end + + describe "handle_undo/2" do + test "returns message_not_found for non-existent message" do + base_url = ActivityPub.instance_url() + + object = %{"object" => "#{base_url}/posts/99999999"} + + result = AnnounceHandler.handle_undo(object, "https://remote.server/users/booster") + # First tries to find message, fails because remote actor doesn't exist + assert result == {:error, :undo_announce_failed} + end + + test "handles nested object reference" do + base_url = ActivityPub.instance_url() + + object = %{ + "object" => %{ + "id" => "#{base_url}/posts/99999999", + "type" => "Note" + } + } + + result = AnnounceHandler.handle_undo(object, "https://remote.server/users/booster") + assert result == {:error, :undo_announce_failed} + end + + test "uses embedded Announce.object when undoing a standard Announce activity" do + booster = remote_actor_fixture("undo_booster") + message = remote_message_fixture("undo-announce-post") + + assert {:ok, _boost} = Messaging.create_federated_boost(message.id, booster.id) + + object = %{ + "type" => "Announce", + "id" => "https://remote.server/announces/#{System.unique_integer([:positive])}", + "object" => message.activitypub_id + } + + assert {:ok, :unannounced} = AnnounceHandler.handle_undo(object, booster.uri) + + assert Repo.get_by(FederatedBoost, message_id: message.id, remote_actor_id: booster.id) == + nil + end + end + + defp remote_actor_fixture(label) do + unique_id = System.unique_integer([:positive]) + username = "#{label}#{unique_id}" + + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.server/users/#{username}", + username: username, + domain: "remote.server", + inbox_url: "https://remote.server/users/#{username}/inbox", + public_key: "-----BEGIN RSA PUBLIC KEY-----\nMOCK\n-----END RSA PUBLIC KEY-----\n", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert!() + end + + defp remote_message_fixture(label) do + author = remote_actor_fixture("#{label}-author") + ap_id = "https://remote.server/objects/#{System.unique_integer([:positive])}" + + {:ok, message} = + Messaging.create_federated_message(%{ + content: "#{label} content", + visibility: "public", + activitypub_id: ap_id, + activitypub_url: "#{ap_id}/view", + federated: true, + remote_actor_id: author.id, + inserted_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + message + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/handlers/create_handler_test.exs b/apps/elektrine/test/elektrine/activitypub/handlers/create_handler_test.exs new file mode 100644 index 0000000..5dce514 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/handlers/create_handler_test.exs @@ -0,0 +1,526 @@ +defmodule Elektrine.ActivityPub.Handlers.CreateHandlerTest do + use Elektrine.DataCase, async: true + + if not Code.ensure_loaded?(Elektrine.Social.Hashtag) do + @moduletag skip: "requires :elektrine_social" + end + + alias Elektrine.AccountsFixtures + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.Handlers.CreateHandler + alias Elektrine.Messaging + alias Elektrine.Repo + alias Elektrine.Social + alias Elektrine.Social.Poll + alias Elektrine.SocialFixtures + + describe "handle/3 actor reference normalization" do + test "stores posts whose actor and attributedTo are AP actor reference lists" do + author = remote_actor_fixture("peertubeaccount") + channel_uri = "https://tilvids.com/video-channels/thelinuxexperiment_channel" + + actor_refs = [ + %{"id" => author.uri, "type" => "Person"}, + %{"id" => channel_uri, "type" => "Group"} + ] + + object = + note_object(author.uri, %{ + "id" => "https://tilvids.com/videos/watch/#{System.unique_integer([:positive])}", + "type" => "Note", + "attributedTo" => actor_refs + }) + + assert {:ok, message} = CreateHandler.handle(%{"object" => object}, actor_refs, nil) + assert message.remote_actor_id == author.id + end + + test "stores PeerTube Video creates as federated messages" do + author = remote_actor_fixture("peertubevideo") + + object = + note_object(author.uri, %{ + "id" => "https://remote.example/videos/watch/#{System.unique_integer([:positive])}", + "type" => "Video", + "name" => "Federation talk", + "content" => "

    PeerTube video

    ", + "url" => [ + %{ + "type" => "Link", + "mediaType" => "video/mp4", + "href" => "https://remote.example/download/stream", + "width" => 1280, + "height" => 720 + } + ] + }) + + assert {:ok, message} = CreateHandler.handle(%{"object" => object}, author.uri, nil) + assert message.title == "Federation talk" + assert message.content == "PeerTube video" + assert message.media_urls == ["https://remote.example/download/stream"] + + assert get_in(message.media_metadata, ["media_attachments", Access.at(0), "type"]) == + "video" + end + end + + describe "create_note/2 community detection" do + test "stores community_actor_uri from audience field" do + author = remote_actor_fixture("alice") + community_uri = "https://lemmy.world/c/elixir" + + object = + note_object(author.uri, %{ + "id" => "https://lemmy.world/post/#{System.unique_integer([:positive])}", + "audience" => community_uri + }) + + assert {:ok, message} = CreateHandler.create_note(object, author.uri) + assert get_in(message.media_metadata || %{}, ["community_actor_uri"]) == community_uri + end + + test "stores community_actor_uri from to/cc fields" do + author = remote_actor_fixture("bob") + community_uri = "https://programming.dev/c/elixir" + + object = + note_object(author.uri, %{ + "id" => "https://programming.dev/post/#{System.unique_integer([:positive])}", + "to" => [ + "https://www.w3.org/ns/activitystreams#Public", + "https://mastodon.social/users/someone" + ], + "cc" => [community_uri] + }) + + assert {:ok, message} = CreateHandler.create_note(object, author.uri) + assert get_in(message.media_metadata || %{}, ["community_actor_uri"]) == community_uri + end + + test "stores community_actor_uri from target field" do + author = remote_actor_fixture("target") + community_uri = "https://lemmy.ml/c/federation" + + object = + note_object(author.uri, %{ + "id" => "https://lemmy.ml/post/#{System.unique_integer([:positive])}", + "target" => community_uri, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + }) + + assert {:ok, message} = CreateHandler.create_note(object, author.uri) + assert get_in(message.media_metadata || %{}, ["community_actor_uri"]) == community_uri + end + + test "falls back to inReplyTo parent community metadata when audience fields are missing" do + parent_author = remote_actor_fixture("parent") + author = remote_actor_fixture("reply") + community_uri = "https://lemmy.world/c/rust" + parent_ap_id = "https://lemmy.world/post/#{System.unique_integer([:positive])}" + + assert {:ok, _parent_message} = + Messaging.create_federated_message(%{ + content: "Parent post", + visibility: "public", + activitypub_id: parent_ap_id, + activitypub_url: parent_ap_id, + federated: true, + remote_actor_id: parent_author.id, + media_metadata: %{"community_actor_uri" => community_uri}, + inserted_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + object = + note_object(author.uri, %{ + "id" => "https://mastodon.social/notes/#{System.unique_integer([:positive])}", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "inReplyTo" => parent_ap_id + }) + + assert {:ok, message} = CreateHandler.create_note(object, author.uri) + assert get_in(message.media_metadata || %{}, ["community_actor_uri"]) == community_uri + end + + test "stores a sanitized title from object name" do + author = remote_actor_fixture("titled") + + object = + note_object(author.uri, %{ + "id" => "https://lemmy.world/post/#{System.unique_integer([:positive])}", + "content" => "", + "name" => + ~s(

    Even the DNC base is controlled opposition

    ) + }) + + assert {:ok, message} = CreateHandler.create_note(object, author.uri) + assert message.title == "Even the DNC base is controlled opposition" + end + + test "stores immediate vote totals from lemmy style objects" do + author = remote_actor_fixture("scored") + + object = + note_object(author.uri, %{ + "id" => "https://lemmy.world/post/#{System.unique_integer([:positive])}", + "upvotes" => 12, + "downvotes" => 2, + "score" => 10 + }) + + assert {:ok, message} = CreateHandler.create_note(object, author.uri) + assert message.upvotes == 12 + assert message.downvotes == 2 + assert message.score == 10 + end + + test "treats markup-only object name as no title" do + author = remote_actor_fixture("titleempty") + + object = + note_object(author.uri, %{ + "id" => "https://lemmy.world/post/#{System.unique_integer([:positive])}", + "content" => "", + "name" => "

    " + }) + + assert {:ok, message} = CreateHandler.create_note(object, author.uri) + assert is_nil(message.title) + end + end + + describe "create_note/3 community fallback" do + test "uses fallback_community_uri when object fields are incomplete" do + author = remote_actor_fixture("carol") + fallback_community_uri = "https://lemmy.ml/c/technology" + + object = + note_object(author.uri, %{ + "id" => "https://lemmy.ml/post/#{System.unique_integer([:positive])}", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + }) + + assert {:ok, message} = + CreateHandler.create_note(object, author.uri, + fallback_community_uri: fallback_community_uri + ) + + assert get_in(message.media_metadata || %{}, ["community_actor_uri"]) == + fallback_community_uri + end + end + + describe "handle/3 poll vote deduplication" do + test "does not double count repeated remote Answer deliveries" do + remote_actor = remote_actor_fixture("pollvoter") + post = SocialFixtures.post_fixture() + poll_post_uri = "#{ActivityPub.instance_url()}/posts/#{post.id}" + + assert {:ok, poll} = Social.create_poll(post.id, "Choose one", ["One", "Two"]) + + activity = %{ + "id" => "https://remote.example/activities/#{System.unique_integer([:positive])}", + "actor" => remote_actor.uri, + "object" => %{ + "id" => "https://remote.example/answers/#{System.unique_integer([:positive])}", + "type" => "Answer", + "name" => "One", + "inReplyTo" => poll_post_uri, + "attributedTo" => remote_actor.uri + } + } + + assert {:ok, :poll_vote_recorded} = CreateHandler.handle(activity, remote_actor.uri, nil) + + reloaded_poll = Repo.get!(Poll, poll.id) |> Repo.preload(:options) + selected_option = Enum.find(reloaded_poll.options, &(&1.option_text == "One")) + + assert reloaded_poll.total_votes == 1 + assert reloaded_poll.voters_count == 1 + assert selected_option.vote_count == 1 + + assert {:ok, :already_voted} = CreateHandler.handle(activity, remote_actor.uri, nil) + + deduped_poll = Repo.get!(Poll, poll.id) |> Repo.preload(:options) + deduped_option = Enum.find(deduped_poll.options, &(&1.option_text == "One")) + + assert deduped_poll.total_votes == 1 + assert deduped_poll.voters_count == 1 + assert deduped_option.vote_count == 1 + end + end + + describe "reply_count accounting" do + test "duplicate delivery of a reply does not double-count the parent" do + parent_author = remote_actor_fixture("dupparent") + reply_author = remote_actor_fixture("dupreply") + parent_ap_id = "https://remote.example/notes/#{System.unique_integer([:positive])}" + + {:ok, parent} = + Messaging.create_federated_message(%{ + content: "Parent", + visibility: "public", + activitypub_id: parent_ap_id, + activitypub_url: parent_ap_id, + federated: true, + remote_actor_id: parent_author.id + }) + + reply = + note_object(reply_author.uri, %{ + "id" => "https://remote.example/notes/#{System.unique_integer([:positive])}", + "inReplyTo" => parent_ap_id + }) + + assert {:ok, %{} = stored} = CreateHandler.create_note(reply, reply_author.uri) + assert stored.reply_to_id == parent.id + assert reload_reply_count(parent.id) == 1 + + # Redelivery (Mastodon retries) or an Announce of the same reply must not + # increment the parent again. + assert {:ok, :already_exists} = CreateHandler.create_note(reply, reply_author.uri) + assert reload_reply_count(parent.id) == 1 + end + + test "deleting a reply decrements the parent's reply_count" do + parent_author = remote_actor_fixture("delparent") + reply_author = remote_actor_fixture("delreply") + parent_ap_id = "https://remote.example/notes/#{System.unique_integer([:positive])}" + reply_ap_id = "https://remote.example/notes/#{System.unique_integer([:positive])}" + + {:ok, parent} = + Messaging.create_federated_message(%{ + content: "Parent", + visibility: "public", + activitypub_id: parent_ap_id, + activitypub_url: parent_ap_id, + federated: true, + remote_actor_id: parent_author.id + }) + + reply = note_object(reply_author.uri, %{"id" => reply_ap_id, "inReplyTo" => parent_ap_id}) + assert {:ok, _stored} = CreateHandler.create_note(reply, reply_author.uri) + assert reload_reply_count(parent.id) == 1 + + delete_activity = %{ + "type" => "Delete", + "actor" => reply_author.uri, + "object" => %{"id" => reply_ap_id, "type" => "Tombstone"} + } + + assert {:ok, :deleted} = + Elektrine.ActivityPub.Handlers.DeleteHandler.handle( + delete_activity, + reply_author.uri, + nil + ) + + assert reload_reply_count(parent.id) == 0 + end + end + + describe "handle/3 actor verification" do + test "rejects Create when attributedTo does not match the verified actor" do + signer = remote_actor_fixture("signer") + claimed_author = remote_actor_fixture("claimed") + + object = + note_object(claimed_author.uri, %{ + "id" => "https://remote.example/notes/#{System.unique_integer([:positive])}" + }) + + activity = %{ + "id" => "https://remote.example/activities/#{System.unique_integer([:positive])}", + "type" => "Create", + "actor" => signer.uri, + "object" => object + } + + assert {:ok, :unauthorized} = CreateHandler.handle(activity, signer.uri, nil) + assert is_nil(Messaging.get_message_by_activitypub_id(object["id"])) + end + + test "rejects direct create_note calls when attributedTo does not match the actor" do + signer = remote_actor_fixture("directsigner") + claimed_author = remote_actor_fixture("directclaimed") + + object = + note_object(claimed_author.uri, %{ + "id" => "https://remote.example/notes/#{System.unique_integer([:positive])}" + }) + + assert {:error, :actor_mismatch} = CreateHandler.create_note(object, signer.uri) + assert is_nil(Messaging.get_message_by_activitypub_id(object["id"])) + end + + test "returns create_object_fetch_failed for Create activities with unfetchable object URIs" do + author = remote_actor_fixture("uriobject") + + activity = %{ + "id" => "https://remote.example/activities/#{System.unique_integer([:positive])}", + "type" => "Create", + "actor" => author.uri, + "object" => "http://127.0.0.1/objects/#{System.unique_integer([:positive])}" + } + + assert {:error, :create_object_fetch_failed} = + CreateHandler.handle(activity, author.uri, nil) + end + + test "inherits public visibility from the outer Create activity" do + author = remote_actor_fixture("outervisibility") + + object = + note_object(author.uri, %{ + "id" => "https://remote.example/notes/#{System.unique_integer([:positive])}", + "to" => [], + "cc" => [] + }) + + activity = %{ + "id" => "https://remote.example/activities/#{System.unique_integer([:positive])}", + "type" => "Create", + "actor" => author.uri, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => object + } + + assert {:ok, message} = CreateHandler.handle(activity, author.uri, nil) + assert message.visibility == "public" + end + end + + describe "federated content persistence" do + test "persists content warnings and sensitive flags for remote notes" do + author = remote_actor_fixture("sensitive") + + object = + note_object(author.uri, %{ + "id" => "https://remote.example/notes/#{System.unique_integer([:positive])}", + "content" => "

    Spoiler post

    ", + "summary" => "Spoiler warning", + "sensitive" => true + }) + + assert {:ok, message} = CreateHandler.create_note(object, author.uri) + assert message.content == "Spoiler post" + assert message.content_warning == "Spoiler warning" + assert message.sensitive == true + end + + test "uses Question name as the poll question when content is blank" do + author = remote_actor_fixture("questionname") + + object = %{ + "type" => "Question", + "id" => "https://remote.example/questions/#{System.unique_integer([:positive])}", + "attributedTo" => author.uri, + "name" => "

    Pick a lane

    ", + "content" => "", + "published" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "oneOf" => [ + %{"name" => "One"}, + %{"name" => "Two"} + ] + } + + assert {:ok, message} = CreateHandler.create_question(object, author.uri) + + poll = + Repo.get_by!(Poll, message_id: message.id) + |> Repo.preload(:options) + + assert poll.question == "Pick a lane" + assert Enum.map(poll.options, & &1.option_text) == ["One", "Two"] + end + end + + describe "create_note/2 mention normalization" do + test "expands tagged short plain-text mentions into full handles" do + _local_user = AccountsFixtures.user_fixture(%{username: "maxfield"}) + author = remote_actor_fixture("mentioner") + local_actor_href = "#{ActivityPub.instance_url()}/users/maxfield" + + object = + note_object(author.uri, %{ + "content" => "

    Hello @maxfield

    ", + "tag" => [ + %{ + "type" => "Mention", + "href" => local_actor_href, + "name" => "@maxfield" + } + ] + }) + + assert {:ok, message} = CreateHandler.create_note(object, author.uri) + assert message.content == "Hello @maxfield@#{ActivityPub.instance_domain()}" + end + + test "expands tagged short mention anchors into full handles" do + _local_user = AccountsFixtures.user_fixture(%{username: "maxfield"}) + author = remote_actor_fixture("anchoredmention") + local_actor_href = "#{ActivityPub.instance_url()}/users/maxfield" + + object = + note_object(author.uri, %{ + "content" => + ~s(

    Hello @maxfield

    ), + "tag" => [ + %{ + "type" => "Mention", + "href" => local_actor_href, + "name" => "@maxfield" + } + ] + }) + + assert {:ok, message} = CreateHandler.create_note(object, author.uri) + assert message.content == "Hello @maxfield@#{ActivityPub.instance_domain()}" + end + end + + defp reload_reply_count(message_id) do + Repo.get!(Elektrine.Social.Message, message_id).reply_count || 0 + end + + defp note_object(author_uri, overrides) do + now = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + + defaults = %{ + "type" => "Note", + "id" => "https://remote.example/notes/#{System.unique_integer([:positive])}", + "attributedTo" => author_uri, + "content" => "Hello from federation", + "published" => now, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + + Map.merge(defaults, overrides) + end + + defp remote_actor_fixture(label) do + unique = System.unique_integer([:positive]) + username = "#{label}#{unique}" + uri = "https://remote.example/users/#{username}" + + %Actor{} + |> Actor.changeset(%{ + uri: uri, + username: username, + domain: "remote.example", + inbox_url: "https://remote.example/users/#{username}/inbox", + public_key: "-----BEGIN PUBLIC KEY-----test-key-----END PUBLIC KEY-----", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/handlers/delete_handler_test.exs b/apps/elektrine/test/elektrine/activitypub/handlers/delete_handler_test.exs new file mode 100644 index 0000000..1546b9a --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/handlers/delete_handler_test.exs @@ -0,0 +1,128 @@ +defmodule Elektrine.ActivityPub.Handlers.DeleteHandlerTest do + use Elektrine.DataCase, async: true + + if not Code.ensure_loaded?(Elektrine.Social.Hashtag) do + @moduletag skip: "requires :elektrine_social" + end + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.{Actor, Tombstone} + alias Elektrine.ActivityPub.Handlers.{CreateHandler, DeleteHandler} + alias Elektrine.Messaging + alias Elektrine.Repo + alias Elektrine.Social.Message + + test "matches a federated post by activitypub URL variant" do + author = remote_actor_fixture("author") + canonical_id = "https://remote.server/objects/#{System.unique_integer([:positive])}" + object_url = "#{canonical_id}/delete" + + assert {:ok, message} = + Messaging.create_federated_message(%{ + content: "Remote post", + visibility: "public", + activitypub_id: canonical_id, + activitypub_url: object_url, + federated: true, + remote_actor_id: author.id, + inserted_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + activity = %{ + "type" => "Delete", + "actor" => author.uri, + "object" => object_url + } + + Phoenix.PubSub.subscribe(Elektrine.PubSub, "timeline:public") + + assert {:ok, :deleted} = DeleteHandler.handle(activity, author.uri, nil) + assert Repo.get!(Message, message.id).deleted_at + assert_receive {:post_deleted, deleted_message_id} + assert deleted_message_id == message.id + end + + test "records delete receipts for unknown objects so later Create imports are ignored" do + author = remote_actor_fixture("deleted-before-import") + object_id = "https://remote.server/objects/#{System.unique_integer([:positive])}" + + delete_activity = %{ + "id" => "https://remote.server/deletes/#{System.unique_integer([:positive])}", + "type" => "Delete", + "actor" => author.uri, + "object" => object_id + } + + assert {:ok, :delete_receipt_recorded} = + DeleteHandler.handle(delete_activity, author.uri, nil) + + assert ActivityPub.remote_delete_recorded?(author.uri, object_id) + assert Repo.get_by(Tombstone, actor_uri: author.uri, object_id: object_id) + + create_activity = %{ + "id" => "https://remote.server/creates/#{System.unique_integer([:positive])}", + "type" => "Create", + "actor" => author.uri, + "object" => %{ + "id" => object_id, + "type" => "Note", + "attributedTo" => author.uri, + "content" => "This post was already deleted", + "published" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + assert {:ok, :ignored_deleted_object} = + CreateHandler.handle(create_activity, author.uri, nil) + + assert is_nil(Messaging.get_message_by_activitypub_id(object_id)) + end + + test "returns a retryable error when the delete actor cannot be resolved" do + author = remote_actor_fixture("missing-delete-author") + object_id = "https://remote.server/objects/#{System.unique_integer([:positive])}" + + assert {:ok, _message} = + Messaging.create_federated_message(%{ + content: "Remote post", + visibility: "public", + activitypub_id: object_id, + activitypub_url: object_id, + federated: true, + remote_actor_id: author.id, + inserted_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + stale_uri = author.uri + + author + |> Actor.changeset(%{uri: "https://remote.server/users/renamed-#{author.id}"}) + |> Repo.update!() + + activity = %{ + "type" => "Delete", + "actor" => stale_uri, + "object" => object_id + } + + assert {:error, :delete_actor_fetch_failed} = DeleteHandler.handle(activity, stale_uri, nil) + end + + defp remote_actor_fixture(label) do + unique_id = System.unique_integer([:positive]) + username = "#{label}#{unique_id}" + + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.server/users/#{username}", + username: username, + domain: "remote.server", + inbox_url: "https://remote.server/users/#{username}/inbox", + public_key: "-----BEGIN RSA PUBLIC KEY-----\nMOCK\n-----END RSA PUBLIC KEY-----\n", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/handlers/flag_handler_test.exs b/apps/elektrine/test/elektrine/activitypub/handlers/flag_handler_test.exs new file mode 100644 index 0000000..bce6775 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/handlers/flag_handler_test.exs @@ -0,0 +1,133 @@ +defmodule Elektrine.ActivityPub.Handlers.FlagHandlerTest do + use Elektrine.DataCase, async: true + + if not Code.ensure_loaded?(Elektrine.Social.Hashtag) do + @moduletag skip: "requires :elektrine_social" + end + + import Elektrine.AccountsFixtures + + alias Elektrine.Accounts + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.Handlers.FlagHandler + alias Elektrine.Messaging + alias Elektrine.Repo + + describe "handle/3" do + setup do + # Create a local user who might be reported + local_user = user_fixture(%{username: "localuser"}) + + # Create an admin user for the report system (avoid reserved "admin" username) + admin_user = user_fixture(%{username: "moderator"}) + {:ok, admin_user} = Accounts.update_user_admin_status(admin_user, true) + + %{local_user: local_user, admin: admin_user} + end + + test "handles Flag activity targeting local user", %{local_user: local_user} do + base_url = Elektrine.ActivityPub.instance_url() + + activity = %{ + "type" => "Flag", + "actor" => "https://remote.server/users/reporter", + "object" => ["#{base_url}/users/#{local_user.username}"], + "content" => "This user is posting spam" + } + + # This will fail to create the report because the remote actor doesn't exist, + # but we can verify the handler doesn't crash + result = FlagHandler.handle(activity, "https://remote.server/users/reporter", nil) + + # Should either succeed or handle gracefully + assert result in [{:ok, :report_received}, {:ok, :ignored}, {:error, :no_system_reporter}] + end + + test "handles Flag activity without content field", %{local_user: local_user} do + base_url = Elektrine.ActivityPub.instance_url() + + activity = %{ + "type" => "Flag", + "actor" => "https://remote.server/users/reporter", + "object" => ["#{base_url}/users/#{local_user.username}"] + } + + # Should handle nil content gracefully + result = FlagHandler.handle(activity, "https://remote.server/users/reporter", nil) + assert result in [{:ok, :report_received}, {:ok, :ignored}, {:error, :no_system_reporter}] + end + + test "ignores Flag activity targeting non-local users" do + activity = %{ + "type" => "Flag", + "actor" => "https://remote.server/users/reporter", + "object" => ["https://other.server/users/someone"], + "content" => "Not our problem" + } + + assert {:ok, :ignored} = + FlagHandler.handle(activity, "https://remote.server/users/reporter", nil) + end + + test "handles Flag activity with multiple objects" do + base_url = Elektrine.ActivityPub.instance_url() + + activity = %{ + "type" => "Flag", + "actor" => "https://remote.server/users/reporter", + "object" => [ + "https://remote.server/users/baduser", + "#{base_url}/posts/nonexistent" + ], + "content" => "Mixed report" + } + + # Should handle gracefully even with non-existent content + result = FlagHandler.handle(activity, "https://remote.server/users/reporter", nil) + assert elem(result, 0) == :ok + end + + test "matches reported content by activitypub URL variant" do + reporter = remote_actor_fixture("reporter") + author = remote_actor_fixture("author") + canonical_id = "https://remote.server/objects/#{System.unique_integer([:positive])}" + object_url = "#{canonical_id}/context" + + assert {:ok, _message} = + Messaging.create_federated_message(%{ + content: "Remote post", + visibility: "public", + activitypub_id: canonical_id, + activitypub_url: object_url, + federated: true, + remote_actor_id: author.id, + inserted_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + activity = %{ + "type" => "Flag", + "actor" => reporter.uri, + "object" => [object_url], + "content" => "Reported content" + } + + assert {:ok, :report_received} = FlagHandler.handle(activity, reporter.uri, nil) + end + end + + defp remote_actor_fixture(label) do + unique_id = System.unique_integer([:positive]) + username = "#{label}#{unique_id}" + + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.server/users/#{username}", + username: username, + domain: "remote.server", + inbox_url: "https://remote.server/users/#{username}/inbox", + public_key: "-----BEGIN RSA PUBLIC KEY-----\nMOCK\n-----END RSA PUBLIC KEY-----\n", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/handlers/follow_handler_test.exs b/apps/elektrine/test/elektrine/activitypub/handlers/follow_handler_test.exs new file mode 100644 index 0000000..1c017f5 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/handlers/follow_handler_test.exs @@ -0,0 +1,509 @@ +defmodule Elektrine.ActivityPub.Handlers.FollowHandlerTest do + use Elektrine.DataCase, async: true + + import Ecto.Query + import Elektrine.AccountsFixtures + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.{Activity, Actor} + alias Elektrine.ActivityPub.Handlers.FollowHandler + alias Elektrine.Profiles + alias Elektrine.Profiles.Follow + alias Elektrine.Repo + alias Elektrine.SocialFixtures + + describe "handle/3 - Follow activity" do + setup do + user = user_fixture() + %{user: user} + end + + test "returns error for follow of non-existent local user" do + base_url = ActivityPub.instance_url() + + activity = %{ + "type" => "Follow", + "id" => "https://remote.server/activities/follow/123", + "actor" => "https://remote.server/users/follower", + "object" => "#{base_url}/users/nonexistentuser" + } + + result = + FollowHandler.handle(activity, "https://remote.server/users/follower", nil) + + assert result == {:error, :handle_follow_failed} + end + + test "returns error for follow with non-local target" do + activity = %{ + "type" => "Follow", + "id" => "https://remote.server/activities/follow/123", + "actor" => "https://remote.server/users/follower", + "object" => "https://other.server/users/someone" + } + + result = + FollowHandler.handle(activity, "https://remote.server/users/follower", nil) + + assert result == {:error, :handle_follow_failed} + end + + test "keeps duplicate pending follow requests pending without auto-accepting", %{user: user} do + remote_actor = + remote_actor_fixture(%{ + uri: "https://remote.server/users/pending-follower", + username: "pending_follower", + inbox_url: "https://remote.server/users/pending-follower/inbox" + }) + + {:ok, _follow} = + Profiles.create_remote_follow( + remote_actor.id, + user.id, + true, + "https://remote.server/activities/follow/original" + ) + + activity = %{ + "type" => "Follow", + "id" => "https://remote.server/activities/follow/duplicate", + "actor" => remote_actor.uri, + "object" => "#{ActivityPub.instance_url()}/users/#{user.username}" + } + + assert {:ok, :pending} = FollowHandler.handle(activity, remote_actor.uri, nil) + + follow = Profiles.get_follow_by_remote_actor(remote_actor.id, user.id) + assert follow.pending == true + + accept_count = + Activity + |> where([a], a.activity_type == "Accept") + |> Repo.aggregate(:count, :id) + + assert accept_count == 0 + end + + test "uses the incoming Follow id when auto-accepting an already-following remote user", %{ + user: user + } do + remote_actor = remote_actor_fixture(%{username: "accepted_duplicate_follower"}) + + {:ok, _follow} = + Profiles.create_remote_follow( + remote_actor.id, + user.id, + false, + "https://remote.server/activities/follow/original" + ) + + incoming_follow_id = "https://remote.server/activities/follow/retry" + + activity = %{ + "type" => "Follow", + "id" => incoming_follow_id, + "actor" => remote_actor.uri, + "object" => "#{ActivityPub.instance_url()}/users/#{user.username}" + } + + assert {:ok, :already_following} = FollowHandler.handle(activity, remote_actor.uri, nil) + + assert %Activity{data: %{"object" => %{"id" => ^incoming_follow_id}}} = + Repo.get_by!(Activity, + activity_type: "Accept", + object_id: incoming_follow_id + ) + end + + test "accepts follow activity targeting a configured legacy local domain", %{user: user} do + previous_profile_domains = Application.get_env(:elektrine, :profile_base_domains) + + on_exit(fn -> + Application.put_env(:elektrine, :profile_base_domains, previous_profile_domains) + end) + + Application.put_env(:elektrine, :profile_base_domains, [ + ActivityPub.instance_domain(), + "z.example.com" + ]) + + remote_actor = + remote_actor_fixture(%{ + uri: "https://remote.server/users/legacy-domain-follower", + username: "legacy_domain_follower", + inbox_url: "https://remote.server/users/legacy-domain-follower/inbox" + }) + + legacy_base_url = ActivityPub.instance_url_for_domain("z.example.com") + + activity = %{ + "type" => "Follow", + "id" => "https://remote.server/activities/follow/legacy-domain", + "actor" => remote_actor.uri, + "object" => "#{legacy_base_url}/users/#{user.username}" + } + + assert {:ok, :pending} = FollowHandler.handle(activity, remote_actor.uri, nil) + assert Profiles.get_follow_by_remote_actor(remote_actor.id, user.id) + end + + test "handles Follow with an embedded object map", %{user: user} do + remote_actor = remote_actor_fixture(%{username: "mapped_follow_target"}) + + activity = %{ + "type" => "Follow", + "id" => "https://remote.server/activities/follow/mapped-target", + "actor" => remote_actor.uri, + "object" => %{ + "id" => "#{ActivityPub.instance_url()}/users/#{user.username}", + "type" => "Person" + } + } + + assert {:ok, :pending} = FollowHandler.handle(activity, remote_actor.uri, nil) + assert Profiles.get_follow_by_remote_actor(remote_actor.id, user.id) + end + + test "returns error for follow of an ActivityPub-disabled user" do + user = + user_fixture() + |> Ecto.Changeset.change(activitypub_enabled: false) + |> Repo.update!() + + remote_actor = remote_actor_fixture(%{username: "disabled_target_follower"}) + + activity = %{ + "type" => "Follow", + "id" => "https://remote.server/activities/follow/disabled-target", + "actor" => remote_actor.uri, + "object" => "#{ActivityPub.instance_url()}/users/#{user.username}" + } + + assert {:error, :target_not_found} = + FollowHandler.handle(activity, remote_actor.uri, nil) + end + + test "returns error for follow of a non-public local community" do + owner = user_fixture() + + community = + SocialFixtures.community_conversation_fixture(owner, %{ + name: "Private Community #{System.unique_integer([:positive])}", + is_public: false + }) + + {:ok, group_actor} = ActivityPub.get_or_create_community_actor(community.id) + remote_actor = remote_actor_fixture(%{username: "private_group_target_follower"}) + + activity = %{ + "type" => "Follow", + "id" => "https://remote.server/activities/follow/private-group-target", + "actor" => remote_actor.uri, + "object" => group_actor.uri + } + + assert {:error, :target_not_found} = + FollowHandler.handle(activity, remote_actor.uri, nil) + + assert ActivityPub.get_group_follow(remote_actor.id, group_actor.id) == nil + end + + test "uses the incoming Follow id when auto-accepting an already-following community follower" do + owner = user_fixture() + + community = + SocialFixtures.community_conversation_fixture(owner, %{ + name: "Duplicate Community #{System.unique_integer([:positive])}" + }) + + {:ok, group_actor} = ActivityPub.get_or_create_community_actor(community.id) + remote_actor = remote_actor_fixture(%{username: "accepted_duplicate_group_follower"}) + + {:ok, _follow} = + ActivityPub.create_group_follow( + remote_actor.id, + group_actor.id, + "https://remote.server/activities/follow/group-original", + false + ) + + incoming_follow_id = "https://remote.server/activities/follow/group-retry" + + activity = %{ + "type" => "Follow", + "id" => incoming_follow_id, + "actor" => remote_actor.uri, + "object" => group_actor.uri + } + + assert {:ok, :already_following} = FollowHandler.handle(activity, remote_actor.uri, nil) + + assert %Activity{ + actor_uri: actor_uri, + data: %{"object" => %{"id" => ^incoming_follow_id, "object" => object_uri}} + } = + Repo.get_by!(Activity, + activity_type: "Accept", + object_id: incoming_follow_id + ) + + assert actor_uri == group_actor.uri + assert object_uri == group_actor.uri + end + end + + describe "handle_accept/3" do + test "accepts a local follow only from the followed actor" do + user = user_fixture() + remote_actor = remote_actor_fixture(%{username: "accept_target"}) + follow_id = create_local_follow_request(user, remote_actor) + + activity = %{ + "type" => "Accept", + "actor" => remote_actor.uri, + "object" => follow_id + } + + assert {:ok, :follow_accepted} = + FollowHandler.handle_accept(activity, remote_actor.uri, nil) + + follow = Profiles.get_follow_to_remote_actor(user.id, remote_actor.id) + assert follow.pending == false + end + + test "rejects Accept from an actor that was not originally followed" do + user = user_fixture() + intended_actor = remote_actor_fixture(%{username: "intended_accept_target"}) + other_actor = remote_actor_fixture(%{username: "other_accept_target"}) + follow_id = create_local_follow_request(user, intended_actor) + + activity = %{ + "type" => "Accept", + "actor" => other_actor.uri, + "object" => %{"type" => "Follow", "id" => follow_id} + } + + assert {:ok, :unauthorized} = FollowHandler.handle_accept(activity, other_actor.uri, nil) + + follow = Profiles.get_follow_to_remote_actor(user.id, intended_actor.id) + assert follow.pending == true + end + + test "returns :unhandled for non-Follow Accept" do + activity = %{ + "type" => "Accept", + "actor" => "https://remote.server/users/someone", + "object" => %{"type" => "Something", "id" => "https://example.com/something/123"} + } + + result = FollowHandler.handle_accept(activity, "https://remote.server/users/someone", nil) + assert result == {:ok, :unhandled} + end + + test "returns :unknown_follow for Accept of unknown Follow" do + activity = %{ + "type" => "Accept", + "actor" => "https://remote.server/users/someone", + "object" => %{ + "type" => "Follow", + "id" => "https://our.server/activities/follow/nonexistent" + } + } + + result = FollowHandler.handle_accept(activity, "https://remote.server/users/someone", nil) + assert result == {:ok, :unknown_follow} + end + end + + describe "handle_reject/3" do + test "rejects a local follow only from the followed actor" do + user = user_fixture() + remote_actor = remote_actor_fixture(%{username: "reject_target"}) + follow_id = create_local_follow_request(user, remote_actor) + + activity = %{ + "type" => "Reject", + "actor" => remote_actor.uri, + "object" => %{"type" => "Follow", "id" => follow_id} + } + + assert {:ok, :follow_rejected} = + FollowHandler.handle_reject(activity, remote_actor.uri, nil) + + assert is_nil(Profiles.get_follow_to_remote_actor(user.id, remote_actor.id)) + end + + test "rejects Reject from an actor that was not originally followed" do + user = user_fixture() + intended_actor = remote_actor_fixture(%{username: "intended_reject_target"}) + other_actor = remote_actor_fixture(%{username: "other_reject_target"}) + follow_id = create_local_follow_request(user, intended_actor) + + activity = %{ + "type" => "Reject", + "actor" => other_actor.uri, + "object" => %{"type" => "Follow", "id" => follow_id} + } + + assert {:ok, :unauthorized} = FollowHandler.handle_reject(activity, other_actor.uri, nil) + assert Profiles.get_follow_to_remote_actor(user.id, intended_actor.id) + end + + test "returns :unhandled for non-Follow Reject" do + activity = %{ + "type" => "Reject", + "actor" => "https://remote.server/users/someone", + "object" => %{"type" => "Something", "id" => "https://example.com/something/123"} + } + + result = FollowHandler.handle_reject(activity, "https://remote.server/users/someone", nil) + assert result == {:ok, :unhandled} + end + + test "returns :unknown_follow for Reject of unknown Follow" do + activity = %{ + "type" => "Reject", + "actor" => "https://remote.server/users/someone", + "object" => %{ + "type" => "Follow", + "id" => "https://our.server/activities/follow/nonexistent" + } + } + + result = FollowHandler.handle_reject(activity, "https://remote.server/users/someone", nil) + assert result == {:ok, :unknown_follow} + end + + test "handles Reject with a string Follow object" do + user = user_fixture() + remote_actor = remote_actor_fixture(%{username: "string_reject_target"}) + follow_id = create_local_follow_request(user, remote_actor) + + activity = %{ + "type" => "Reject", + "actor" => remote_actor.uri, + "object" => follow_id + } + + assert {:ok, :follow_rejected} = + FollowHandler.handle_reject(activity, remote_actor.uri, nil) + + assert is_nil(Profiles.get_follow_to_remote_actor(user.id, remote_actor.id)) + end + end + + describe "handle_undo/2" do + test "returns error when remote actor cannot be fetched" do + base_url = ActivityPub.instance_url() + + result = + FollowHandler.handle_undo( + %{"object" => "#{base_url}/users/someone"}, + "https://nonexistent.server/users/unfollower" + ) + + assert result == {:error, :undo_follow_failed} + end + + test "returns :invalid for invalid undo object" do + result = FollowHandler.handle_undo(nil, "https://remote.server/users/unfollower") + assert result == {:ok, :invalid} + end + + test "uses embedded Follow.object when undoing a standard Follow activity" do + user = user_fixture() + remote_actor = remote_actor_fixture(%{username: "undo_standard_follow"}) + follow_id = "https://remote.server/activities/follow/#{System.unique_integer([:positive])}" + + {:ok, _follow} = + Profiles.create_remote_follow( + remote_actor.id, + user.id, + false, + follow_id + ) + + object = %{ + "type" => "Follow", + "id" => follow_id, + "actor" => remote_actor.uri, + "object" => "#{ActivityPub.instance_url()}/users/#{user.username}" + } + + assert {:ok, :unfollowed} = FollowHandler.handle_undo(object, remote_actor.uri) + assert is_nil(Profiles.get_follow_by_remote_actor(remote_actor.id, user.id)) + end + + test "handles object as nested map" do + base_url = ActivityPub.instance_url() + + result = + FollowHandler.handle_undo( + %{"object" => %{"id" => "#{base_url}/users/someone"}}, + "https://remote.server/users/unfollower" + ) + + assert result == {:error, :undo_follow_failed} + end + + test "returns :invalid for nested map without an id" do + result = + FollowHandler.handle_undo( + %{"object" => %{"type" => "Follow"}}, + "https://remote.server/users/unfollower" + ) + + assert result == {:ok, :invalid} + end + end + + defp remote_actor_fixture(attrs) do + unique_id = System.unique_integer([:positive]) + + defaults = %{ + uri: "https://remote.server/users/follower#{unique_id}", + username: "follower#{unique_id}", + domain: "remote.server", + inbox_url: "https://remote.server/users/follower#{unique_id}/inbox", + public_key: "-----BEGIN RSA PUBLIC KEY-----\nMOCK\n-----END RSA PUBLIC KEY-----\n", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + } + + %Actor{} + |> Actor.changeset(Map.merge(defaults, attrs)) + |> Repo.insert!() + end + + defp create_local_follow_request(user, remote_actor) do + follow_id = "https://example.com/activities/#{Ecto.UUID.generate()}" + local_actor_uri = "#{ActivityPub.instance_url()}/users/#{user.username}" + + %Follow{} + |> Follow.changeset(%{ + follower_id: user.id, + remote_actor_id: remote_actor.id, + activitypub_id: follow_id, + pending: true + }) + |> Repo.insert!() + + %Activity{} + |> Activity.changeset(%{ + activity_id: follow_id, + activity_type: "Follow", + actor_uri: local_actor_uri, + object_id: remote_actor.uri, + data: %{ + "id" => follow_id, + "type" => "Follow", + "actor" => local_actor_uri, + "object" => remote_actor.uri + }, + local: true, + internal_user_id: user.id + }) + |> Repo.insert!() + + follow_id + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/handlers/like_handler_test.exs b/apps/elektrine/test/elektrine/activitypub/handlers/like_handler_test.exs new file mode 100644 index 0000000..9c50d15 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/handlers/like_handler_test.exs @@ -0,0 +1,400 @@ +defmodule Elektrine.ActivityPub.Handlers.LikeHandlerTest do + use Elektrine.DataCase, async: true + + if not Code.ensure_loaded?(Elektrine.Social.Hashtag) do + @moduletag skip: "requires :elektrine_social" + end + + import Elektrine.AccountsFixtures + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.Handlers.LikeHandler + alias Elektrine.Messaging + alias Elektrine.Messaging.FederatedDislike + alias Elektrine.Messaging.FederatedLike + alias Elektrine.Repo + alias Elektrine.Social.MessageReaction + + describe "handle/3 - Like activity" do + setup do + user = user_fixture() + %{user: user} + end + + test "returns error for like on non-existent message", %{user: _user} do + base_url = ActivityPub.instance_url() + + activity = %{ + "type" => "Like", + "actor" => "https://remote.server/users/liker", + "object" => "#{base_url}/posts/99999999" + } + + result = LikeHandler.handle(activity, "https://remote.server/users/liker", nil) + assert result == {:error, :message_not_found} + end + + test "returns error for like with invalid object format" do + activity = %{ + "type" => "Like", + "actor" => "https://remote.server/users/liker", + "object" => nil + } + + result = LikeHandler.handle(activity, "https://remote.server/users/liker", nil) + assert result == {:error, :handle_like_failed} + end + + test "handles object reference as map with id" do + base_url = ActivityPub.instance_url() + + activity = %{ + "type" => "Like", + "actor" => "https://remote.server/users/liker", + "object" => %{"id" => "#{base_url}/posts/99999999", "type" => "Note"} + } + + # Should extract id from map and process + result = LikeHandler.handle(activity, "https://remote.server/users/liker", nil) + assert result == {:error, :message_not_found} + end + + test "matches a cached federated post by activitypub URL variant" do + liker = remote_actor_fixture("liker") + author = remote_actor_fixture("author") + canonical_id = "https://remote.server/objects/#{System.unique_integer([:positive])}" + object_url = "#{canonical_id}/view" + + assert {:ok, _message} = + Messaging.create_federated_message(%{ + content: "Remote post", + visibility: "public", + activitypub_id: canonical_id, + activitypub_url: object_url, + federated: true, + remote_actor_id: author.id, + inserted_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + activity = %{ + "type" => "Like", + "actor" => liker.uri, + "object" => object_url + } + + assert {:ok, :liked} = LikeHandler.handle(activity, liker.uri, nil) + end + + test "unwraps nested object wrappers when handling a like" do + liker = remote_actor_fixture("wrapped_liker") + message = remote_message_fixture("wrapped-like-post") + + activity = %{ + "type" => "Like", + "actor" => liker.uri, + "object" => %{ + "type" => "Create", + "object" => %{"id" => message.activitypub_id, "type" => "Note"} + } + } + + assert {:ok, :liked} = LikeHandler.handle(activity, liker.uri, nil) + assert Repo.get_by(FederatedLike, message_id: message.id, remote_actor_id: liker.id) + end + end + + describe "handle_emoji_react/3" do + test "returns :unhandled for activity without content" do + activity = %{ + "type" => "EmojiReact", + "actor" => "https://remote.server/users/reactor", + "object" => "https://example.com/posts/123" + } + + result = + LikeHandler.handle_emoji_react(activity, "https://remote.server/users/reactor", nil) + + assert result == {:ok, :unhandled} + end + + test "returns error for non-existent message with emoji react" do + base_url = ActivityPub.instance_url() + + activity = %{ + "type" => "EmojiReact", + "actor" => "https://remote.server/users/reactor", + "object" => "#{base_url}/posts/99999999", + "content" => ":thumbsup:" + } + + result = + LikeHandler.handle_emoji_react(activity, "https://remote.server/users/reactor", nil) + + assert result == {:error, :handle_emoji_react_failed} + end + + test "extracts custom emoji URL from tags" do + base_url = ActivityPub.instance_url() + + activity = %{ + "type" => "EmojiReact", + "actor" => "https://remote.server/users/reactor", + "object" => "#{base_url}/posts/99999999", + "content" => ":blobcat:", + "tag" => [ + %{ + "type" => "Emoji", + "name" => ":blobcat:", + "icon" => %{"url" => "https://remote.server/emoji/blobcat.png"} + } + ] + } + + # Will fail because message doesn't exist, but tests the path + result = + LikeHandler.handle_emoji_react(activity, "https://remote.server/users/reactor", nil) + + assert result == {:error, :handle_emoji_react_failed} + end + + test "hydrates wrapped public objects when the Create wrapper carries the audience" do + reactor = remote_actor_fixture("reactor") + author = remote_actor_fixture("wrappedauthor") + wrapper_uri = "https://example.com/activities/#{System.unique_integer([:positive])}" + object_id = "https://example.com/objects/#{System.unique_integer([:positive])}" + + wrapper_object = %{ + "id" => wrapper_uri, + "type" => "Create", + "actor" => author.uri, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "id" => object_id, + "type" => "Note", + "content" => "

    Wrapped note

    ", + "attributedTo" => author.uri, + "to" => [], + "cc" => [] + } + } + + assert {:ok, _cached_wrapper} = + Elektrine.AppCache.get_object(wrapper_uri, fn -> {:ok, wrapper_object} end) + + activity = %{ + "type" => "EmojiReact", + "actor" => reactor.uri, + "object" => wrapper_uri, + "content" => ":thumbsup:" + } + + assert {:ok, :emoji_reacted} = LikeHandler.handle_emoji_react(activity, reactor.uri, nil) + assert %{visibility: "public"} = Messaging.get_message_by_activitypub_id(object_id) + end + + test "falls back to contentMap when content is omitted" do + reactor = remote_actor_fixture("content_map_reactor") + message = remote_message_fixture("content-map-post") + + activity = %{ + "type" => "EmojiReact", + "actor" => reactor.uri, + "object" => message.activitypub_id, + "contentMap" => %{"en" => ":blobcat:"} + } + + assert {:ok, :emoji_reacted} = LikeHandler.handle_emoji_react(activity, reactor.uri, nil) + + assert Repo.get_by(MessageReaction, + message_id: message.id, + remote_actor_id: reactor.id, + emoji: ":blobcat:" + ) + end + end + + describe "handle_undo_like/2" do + test "returns error when remote actor cannot be fetched" do + base_url = ActivityPub.instance_url() + + # handle_undo_like expects %{"object" => ...} structure + object = %{"object" => "#{base_url}/posts/99999999"} + + result = LikeHandler.handle_undo_like(object, "https://remote.server/users/liker") + # Fails because remote actor cannot be fetched + assert result == {:error, :undo_like_failed} + end + + test "handles object as nested map" do + base_url = ActivityPub.instance_url() + + object = %{"object" => %{"id" => "#{base_url}/posts/99999999", "type" => "Note"}} + + result = LikeHandler.handle_undo_like(object, "https://remote.server/users/liker") + assert result == {:error, :undo_like_failed} + end + + test "uses embedded Like.object when undoing a standard Like activity" do + liker = remote_actor_fixture("undo_liker") + message = remote_message_fixture("undo-like-post") + + assert {:ok, _like} = Messaging.create_federated_like(message.id, liker.id) + + object = %{ + "type" => "Like", + "id" => "https://remote.server/likes/#{System.unique_integer([:positive])}", + "object" => message.activitypub_id + } + + assert {:ok, :unliked} = LikeHandler.handle_undo_like(object, liker.uri) + assert Repo.get_by(FederatedLike, message_id: message.id, remote_actor_id: liker.id) == nil + end + end + + describe "handle_undo_emoji_react/2" do + test "returns :invalid for activity without content or tags" do + object = %{ + "type" => "EmojiReact", + "object" => "https://example.com/posts/123" + } + + result = LikeHandler.handle_undo_emoji_react(object, "https://remote.server/users/reactor") + assert result == {:ok, :invalid} + end + + test "extracts emoji from tag when content is missing" do + base_url = ActivityPub.instance_url() + + object = %{ + "type" => "EmojiReact", + "object" => "#{base_url}/posts/99999999", + "tag" => [ + %{"type" => "Emoji", "name" => ":blobcat:"} + ] + } + + result = LikeHandler.handle_undo_emoji_react(object, "https://remote.server/users/reactor") + assert result == {:error, :undo_emoji_react_failed} + end + + test "uses embedded EmojiReact.object when undoing a standard reaction activity" do + reactor = remote_actor_fixture("undo_reactor") + message = remote_message_fixture("undo-reaction-post") + + assert {:ok, _reaction} = + Elektrine.Social.Messages.create_federated_emoji_reaction( + message.id, + reactor.id, + ":blobcat:" + ) + + object = %{ + "type" => "EmojiReact", + "id" => "https://remote.server/reactions/#{System.unique_integer([:positive])}", + "object" => message.activitypub_id, + "content" => ":blobcat:" + } + + assert {:ok, :emoji_unreacted} = LikeHandler.handle_undo_emoji_react(object, reactor.uri) + + assert Repo.get_by(MessageReaction, + message_id: message.id, + remote_actor_id: reactor.id, + emoji: ":blobcat:" + ) == nil + end + + test "returns :no_emoji_found when tags don't contain emoji" do + object = %{ + "type" => "EmojiReact", + "object" => "https://example.com/posts/123", + "tag" => [ + %{"type" => "Hashtag", "name" => "#test"} + ] + } + + result = LikeHandler.handle_undo_emoji_react(object, "https://remote.server/users/reactor") + assert result == {:ok, :no_emoji_found} + end + end + + describe "handle_dislike/3" do + test "returns error for dislike on non-existent message" do + base_url = ActivityPub.instance_url() + + activity = %{ + "type" => "Dislike", + "actor" => "https://remote.server/users/disliker", + "object" => "#{base_url}/posts/99999999" + } + + result = LikeHandler.handle_dislike(activity, "https://remote.server/users/disliker", nil) + assert result == {:error, :message_not_found} + end + end + + describe "handle_undo_dislike/2" do + test "returns error for undo dislike on non-existent message" do + base_url = ActivityPub.instance_url() + + object = %{"object" => "#{base_url}/posts/99999999"} + + result = LikeHandler.handle_undo_dislike(object, "https://remote.server/users/disliker") + assert result == {:error, :undo_dislike_failed} + end + + test "uses embedded Dislike.object when undoing a standard Dislike activity" do + disliker = remote_actor_fixture("undo_disliker") + message = remote_message_fixture("undo-dislike-post") + + assert {:ok, _dislike} = Messaging.create_federated_dislike(message.id, disliker.id) + + object = %{ + "type" => "Dislike", + "id" => "https://remote.server/dislikes/#{System.unique_integer([:positive])}", + "object" => message.activitypub_id + } + + assert {:ok, :undisliked} = LikeHandler.handle_undo_dislike(object, disliker.uri) + + assert Repo.get_by(FederatedDislike, message_id: message.id, remote_actor_id: disliker.id) == + nil + end + end + + defp remote_actor_fixture(label) do + unique_id = System.unique_integer([:positive]) + username = "#{label}#{unique_id}" + + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.server/users/#{username}", + username: username, + domain: "remote.server", + inbox_url: "https://remote.server/users/#{username}/inbox", + public_key: "-----BEGIN RSA PUBLIC KEY-----\nMOCK\n-----END RSA PUBLIC KEY-----\n", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert!() + end + + defp remote_message_fixture(label) do + author = remote_actor_fixture("#{label}-author") + ap_id = "https://remote.server/objects/#{System.unique_integer([:positive])}" + + {:ok, message} = + Messaging.create_federated_message(%{ + content: "#{label} content", + visibility: "public", + activitypub_id: ap_id, + activitypub_url: "#{ap_id}/view", + federated: true, + remote_actor_id: author.id, + inserted_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + message + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/handlers/move_handler_test.exs b/apps/elektrine/test/elektrine/activitypub/handlers/move_handler_test.exs new file mode 100644 index 0000000..ed30869 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/handlers/move_handler_test.exs @@ -0,0 +1,145 @@ +defmodule Elektrine.ActivityPub.Handlers.MoveHandlerTest do + use Elektrine.DataCase, async: true + + import Ecto.Query + import Elektrine.AccountsFixtures + + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.Handlers.MoveHandler + alias Elektrine.Profiles.Follow + alias Elektrine.Repo + + describe "handle/3" do + test "migrates local follow relationships from old actor to target actor" do + follower = user_fixture() + old_actor = remote_actor_fixture("old", %{"movedTo" => nil}) + new_actor = remote_actor_fixture("new", %{"alsoKnownAs" => []}) + + old_actor = + old_actor + |> Actor.changeset(%{metadata: %{"movedTo" => new_actor.uri}}) + |> Repo.update!() + + _new_actor = + new_actor + |> Actor.changeset(%{"metadata" => %{"alsoKnownAs" => [old_actor.uri]}}) + |> Repo.update!() + + %Follow{} + |> Follow.changeset(%{ + follower_id: follower.id, + remote_actor_id: old_actor.id, + pending: false, + activitypub_id: "https://remote.example/activities/follow/1" + }) + |> Repo.insert!() + + activity = %{ + "id" => "https://remote.example/activities/move/1", + "type" => "Move", + "actor" => old_actor.uri, + "object" => old_actor.uri, + "target" => new_actor.uri + } + + assert {:ok, :moved} = MoveHandler.handle(activity, old_actor.uri, nil) + + migrated_follow = + Follow + |> where( + [f], + f.follower_id == ^follower.id and f.remote_actor_id == ^new_actor.id and + is_nil(f.followed_id) + ) + |> Repo.one!() + + assert migrated_follow.pending == false + assert Repo.get_by(Follow, follower_id: follower.id, remote_actor_id: old_actor.id) == nil + end + + test "ignores move activity when the target actor does not alias the old actor" do + follower = user_fixture() + + old_actor = + remote_actor_fixture("old-missing-alias", %{"movedTo" => "https://placeholder.invalid"}) + |> Actor.changeset(%{metadata: %{}}) + |> Repo.update!() + + new_actor = remote_actor_fixture("new-missing-alias") + + old_actor = + old_actor + |> Actor.changeset(%{metadata: %{"movedTo" => new_actor.uri}}) + |> Repo.update!() + + %Follow{} + |> Follow.changeset(%{ + follower_id: follower.id, + remote_actor_id: old_actor.id, + pending: false, + activitypub_id: "https://remote.example/activities/follow/2" + }) + |> Repo.insert!() + + activity = %{ + "id" => "https://remote.example/activities/move/3", + "type" => "Move", + "actor" => old_actor.uri, + "object" => old_actor.uri, + "target" => new_actor.uri + } + + assert {:ok, :ignored} = MoveHandler.handle(activity, old_actor.uri, nil) + assert Repo.get_by(Follow, follower_id: follower.id, remote_actor_id: old_actor.id) + refute Repo.get_by(Follow, follower_id: follower.id, remote_actor_id: new_actor.id) + end + + test "ignores move activity when actor and object do not match" do + old_actor = remote_actor_fixture("old-mismatch") + new_actor = remote_actor_fixture("new-mismatch") + + activity = %{ + "id" => "https://remote.example/activities/move/2", + "type" => "Move", + "actor" => "https://remote.example/users/other", + "object" => old_actor.uri, + "target" => new_actor.uri + } + + assert {:ok, :ignored} = MoveHandler.handle(activity, old_actor.uri, nil) + end + + test "returns an error when referenced move actors cannot be fetched" do + unique_id = System.unique_integer([:positive]) + actor_uri = "https://missing-#{unique_id}.invalid/users/old" + + activity = %{ + "id" => "https://missing-#{unique_id}.invalid/activities/move/1", + "type" => "Move", + "actor" => actor_uri, + "object" => actor_uri, + "target" => "https://missing-#{unique_id}.invalid/users/new" + } + + assert {:error, :move_actor_fetch_failed} = MoveHandler.handle(activity, actor_uri, nil) + end + end + + defp remote_actor_fixture(suffix, metadata \\ %{}) do + unique_id = System.unique_integer([:positive]) + + defaults = %{ + uri: "https://remote.example/users/#{suffix}-#{unique_id}", + username: "#{suffix}_#{unique_id}", + domain: "remote.example", + inbox_url: "https://remote.example/users/#{suffix}-#{unique_id}/inbox", + public_key: "-----BEGIN RSA PUBLIC KEY-----\nMOCK\n-----END RSA PUBLIC KEY-----\n", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second), + metadata: metadata + } + + %Actor{} + |> Actor.changeset(defaults) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/handlers/update_handler_test.exs b/apps/elektrine/test/elektrine/activitypub/handlers/update_handler_test.exs new file mode 100644 index 0000000..d511c15 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/handlers/update_handler_test.exs @@ -0,0 +1,338 @@ +defmodule Elektrine.ActivityPub.Handlers.UpdateHandlerTest do + use Elektrine.DataCase, async: true + + if not Code.ensure_loaded?(Elektrine.Social.Hashtag) do + @moduletag skip: "requires :elektrine_social" + end + + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.Handlers.UpdateHandler + alias Elektrine.Messaging + alias Elektrine.Repo + alias Elektrine.Social + alias Elektrine.Social.Message + alias Elektrine.Social.{Poll, PostHashtag} + + test "matches a federated post by activitypub URL variant" do + author = remote_actor_fixture("author") + canonical_id = "https://remote.server/objects/#{System.unique_integer([:positive])}" + object_url = "#{canonical_id}/update" + + assert {:ok, message} = + Messaging.create_federated_message(%{ + content: "Original content", + visibility: "public", + activitypub_id: canonical_id, + activitypub_url: object_url, + federated: true, + remote_actor_id: author.id, + inserted_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + activity = %{ + "type" => "Update", + "actor" => author.uri, + "object" => %{ + "id" => object_url, + "type" => "Note", + "content" => "

    Updated content

    " + } + } + + assert {:ok, :updated} = UpdateHandler.handle(activity, author.uri, nil) + assert Repo.get!(Message, message.id).content == "Updated content" + end + + test "returns a retryable error when a URI-form object fetch fails" do + unique_id = System.unique_integer([:positive]) + + activity = %{ + "type" => "Update", + "actor" => "https://remote.server/users/updater#{unique_id}", + "object" => "http://127.0.0.1/updates/#{unique_id}" + } + + assert {:error, :update_object_fetch_failed} = + UpdateHandler.handle(activity, activity["actor"], nil) + end + + test "returns a retryable error when a Person update cannot refresh the actor" do + unique_id = System.unique_integer([:positive]) + actor_uri = "http://127.0.0.1/actors/person-update-#{unique_id}" + + activity = %{ + "type" => "Update", + "actor" => actor_uri, + "object" => %{ + "id" => actor_uri, + "type" => "Person" + } + } + + assert {:error, :update_actor_fetch_failed} = + UpdateHandler.handle(activity, actor_uri, nil) + end + + test "returns a retryable error when a Group update cannot refresh the actor" do + unique_id = System.unique_integer([:positive]) + actor_uri = "http://127.0.0.1/groups/update-#{unique_id}" + + activity = %{ + "type" => "Update", + "actor" => actor_uri, + "object" => %{ + "id" => actor_uri, + "type" => "Group" + } + } + + assert {:error, :update_actor_fetch_failed} = + UpdateHandler.handle(activity, actor_uri, nil) + end + + test "rejects Group updates whose object id does not match the verified actor" do + unique_id = System.unique_integer([:positive]) + actor_uri = "https://remote.server/groups/#{unique_id}" + other_group_uri = "https://other.example/groups/#{unique_id}" + + activity = %{ + "type" => "Update", + "actor" => actor_uri, + "object" => %{ + "id" => other_group_uri, + "type" => "Group" + } + } + + assert {:ok, :unauthorized} = UpdateHandler.handle(activity, actor_uri, nil) + end + + test "imports unknown public Note objects from Update activities" do + author = remote_actor_fixture("updatedauthor") + object_id = "https://remote.server/objects/#{System.unique_integer([:positive])}" + + activity = %{ + "type" => "Update", + "actor" => author.uri, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "object" => %{ + "id" => object_id, + "type" => "Note", + "content" => "

    Updated before create

    ", + "attributedTo" => author.uri, + "to" => [], + "cc" => [] + } + } + + assert {:ok, :created_from_update} = UpdateHandler.handle(activity, author.uri, nil) + + assert %{content: "Updated before create", visibility: "public"} = + Messaging.get_message_by_activitypub_id(object_id) + end + + test "refreshes title, warnings, media, hashtags, and engagement data for remote notes" do + author = remote_actor_fixture("richupdate") + object_id = "https://remote.server/objects/#{System.unique_integer([:positive])}" + + assert {:ok, message} = + Messaging.create_federated_message(%{ + content: "Original content", + title: "Original title", + visibility: "public", + activitypub_id: object_id, + activitypub_url: object_id, + federated: true, + remote_actor_id: author.id, + media_urls: ["https://remote.server/old.png"], + media_metadata: %{ + "alt_texts" => %{"0" => "Old alt"}, + "boosted_by" => "keep-me", + "community_actor_uri" => "https://remote.server/c/old" + }, + extracted_hashtags: ["oldtag"], + like_count: 1, + reply_count: 2, + share_count: 3, + inserted_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + old_hashtag = Social.get_or_create_hashtag("oldtag") + Social.increment_hashtag_usage(old_hashtag.id) + + %PostHashtag{} + |> PostHashtag.changeset(%{message_id: message.id, hashtag_id: old_hashtag.id}) + |> Repo.insert!() + + activity = %{ + "type" => "Update", + "actor" => author.uri, + "object" => %{ + "id" => object_id, + "type" => "Note", + "content" => "

    Updated content with #newtag

    ", + "name" => "

    Updated title

    ", + "summary" => "Updated CW", + "sensitive" => true, + "url" => "#{object_id}/rendered", + "formerRepresentations" => %{ + "type" => "OrderedCollection", + "orderedItems" => [ + %{ + "type" => "Note", + "content" => "

    Original content

    ", + "updated" => "2026-06-30T00:00:00Z" + } + ] + }, + "likes" => %{"totalItems" => 7}, + "replies" => %{"totalItems" => 8}, + "shares" => %{"totalItems" => 9}, + "attachment" => [ + %{ + "type" => "Document", + "mediaType" => "image/png", + "url" => "https://remote.server/new.png", + "name" => "New alt" + } + ], + "tag" => [ + %{"type" => "Hashtag", "name" => "#newtag"} + ], + "attributedTo" => author.uri, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + assert {:ok, :updated} = UpdateHandler.handle(activity, author.uri, nil) + + updated_message = + Repo.get!(Message, message.id) + |> Repo.preload(:hashtags) + + assert updated_message.content == "Updated content with #newtag" + assert updated_message.title == "Updated title" + assert updated_message.content_warning == "Updated CW" + assert updated_message.sensitive == true + assert updated_message.media_urls == ["https://remote.server/new.png"] + assert updated_message.activitypub_url == "#{object_id}/rendered" + assert updated_message.like_count == 7 + assert updated_message.reply_count == 8 + assert updated_message.share_count == 9 + assert updated_message.edited_at + assert updated_message.media_metadata["alt_texts"] == %{"0" => "New alt"} + assert updated_message.media_metadata["boosted_by"] == "keep-me" + refute Map.has_key?(updated_message.media_metadata, "community_actor_uri") + assert updated_message.media_metadata["formerRepresentations"]["type"] == "OrderedCollection" + + assert [ + %{"content" => "

    Original content

    ", "updated" => "2026-06-30T00:00:00Z"} + ] = updated_message.media_metadata["formerRepresentations"]["orderedItems"] + + assert [%{"content" => "Original content"} | _] = + updated_message.media_metadata["activitypub_edit_history"] + + assert Enum.map(updated_message.hashtags, & &1.normalized_name) == ["newtag"] + + assert Social.get_hashtag_by_normalized_name("oldtag").use_count == 0 + assert Social.get_hashtag_by_normalized_name("newtag").use_count == 1 + end + + test "refreshes poll question and options for remote Question updates" do + author = remote_actor_fixture("pollupdate") + object_id = "https://remote.server/questions/#{System.unique_integer([:positive])}" + + assert {:ok, message} = + Messaging.create_federated_message(%{ + content: "Original poll body", + visibility: "public", + post_type: "poll", + activitypub_id: object_id, + activitypub_url: object_id, + federated: true, + remote_actor_id: author.id, + inserted_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + poll = + %Poll{} + |> Poll.changeset(%{ + message_id: message.id, + question: "Old question", + total_votes: 1, + voters_count: 1, + allow_multiple: false + }) + |> Repo.insert!() + + Enum.each(["Old one", "Old two"], fn option_text -> + %Elektrine.Social.PollOption{} + |> Elektrine.Social.PollOption.changeset(%{ + poll_id: poll.id, + option_text: option_text, + vote_count: 1 + }) + |> Repo.insert!() + end) + + activity = %{ + "type" => "Update", + "actor" => author.uri, + "object" => %{ + "id" => object_id, + "type" => "Question", + "name" => "

    New question

    ", + "content" => "", + "summary" => "Poll CW", + "sensitive" => true, + "attributedTo" => author.uri, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "oneOf" => [ + %{"name" => "First", "replies" => %{"totalItems" => 4}}, + %{"name" => "Second", "replies" => %{"totalItems" => 5}} + ] + } + } + + assert {:ok, :updated} = UpdateHandler.handle(activity, author.uri, nil) + + reloaded_poll = + Repo.get_by!(Poll, message_id: message.id) + |> Repo.preload(:options) + + updated_message = Repo.get!(Message, message.id) + + assert reloaded_poll.question == "New question" + assert reloaded_poll.total_votes == 9 + assert reloaded_poll.voters_count == 9 + + assert reloaded_poll.options + |> Enum.sort_by(& &1.position) + |> Enum.map(&{&1.option_text, &1.vote_count}) == [ + {"First", 4}, + {"Second", 5} + ] + + assert updated_message.content_warning == "Poll CW" + assert updated_message.sensitive == true + end + + defp remote_actor_fixture(label) do + unique_id = System.unique_integer([:positive]) + username = "#{label}#{unique_id}" + + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.server/users/#{username}", + username: username, + domain: "remote.server", + inbox_url: "https://remote.server/users/#{username}/inbox", + public_key: "-----BEGIN RSA PUBLIC KEY-----\nMOCK\n-----END RSA PUBLIC KEY-----\n", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/helpers_test.exs b/apps/elektrine/test/elektrine/activitypub/helpers_test.exs new file mode 100644 index 0000000..2be6072 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/helpers_test.exs @@ -0,0 +1,108 @@ +defmodule Elektrine.ActivityPub.HelpersTest do + use Elektrine.DataCase, async: true + + if not Code.ensure_loaded?(Elektrine.Social.Hashtag) do + @moduletag skip: "requires :elektrine_social" + end + + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.Helpers + alias Elektrine.Messaging + alias Elektrine.Repo + + describe "get_follower_count/1" do + test "handles numeric strings and collection totals" do + metadata = %{ + "followers_count" => "12", + "followers" => %{"totalItems" => "34"} + } + + assert Helpers.get_follower_count(metadata) == 34 + end + + test "supports lemmy subscriber-style fields" do + metadata = %{"subscribers" => 99, "member_count" => "17"} + + assert Helpers.get_follower_count(metadata) == 99 + end + end + + describe "get_following_count/1" do + test "handles string values and collection totals" do + metadata = %{ + "following_count" => "6", + "following" => %{"totalItems" => "9"} + } + + assert Helpers.get_following_count(metadata) == 9 + end + end + + describe "get_status_count/1" do + test "supports posts aliases and outbox totals" do + metadata = %{ + "postsCount" => "45", + "outbox" => %{"totalItems" => "12"} + } + + assert Helpers.get_status_count(metadata) == 45 + end + + test "falls back to outbox total when status fields are absent" do + metadata = %{"outbox" => %{"totalItems" => 7}} + + assert Helpers.get_status_count(metadata) == 7 + end + end + + describe "extract_vote_totals/1" do + test "extracts lemmy and piefed score fields" do + metadata = %{"upvotes" => "12", "downvotes" => 2, "score" => "10"} + + assert Helpers.extract_vote_totals(metadata) == %{upvotes: 12, downvotes: 2, score: 10} + end + + test "supports alternate positive and negative vote field names" do + metadata = %{"positive_votes" => 8, "negative_votes" => "3", "net_score" => 5} + + assert Helpers.extract_vote_totals(metadata) == %{upvotes: 8, downvotes: 3, score: 5} + end + end + + describe "get_or_store_remote_post/1 and /2" do + test "returns cached message for equivalent ActivityPub ref variants" do + unique = System.unique_integer([:positive]) + base_post_id = "https://127.0.0.1:1/post/#{unique}" + actor_uri = "https://127.0.0.1:1/u/test#{unique}" + + remote_actor = + %Actor{} + |> Actor.changeset(%{ + uri: actor_uri, + username: "test#{unique}", + domain: "127.0.0.1", + inbox_url: "https://127.0.0.1:1/inbox/#{unique}", + public_key: "test-key-#{unique}" + }) + |> Repo.insert!() + + {:ok, message} = + Messaging.create_federated_message(%{ + content: "cached remote post", + visibility: "public", + activitypub_id: base_post_id, + activitypub_url: base_post_id, + federated: true, + remote_actor_id: remote_actor.id + }) + + assert {:ok, cached_via_slash} = Helpers.get_or_store_remote_post("#{base_post_id}/") + assert cached_via_slash.id == message.id + + assert {:ok, cached_via_query} = + Helpers.get_or_store_remote_post("#{base_post_id}?view=compact#top", actor_uri) + + assert cached_via_query.id == message.id + end + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/http_signature_test.exs b/apps/elektrine/test/elektrine/activitypub/http_signature_test.exs new file mode 100644 index 0000000..9536880 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/http_signature_test.exs @@ -0,0 +1,85 @@ +defmodule Elektrine.ActivityPub.HTTPSignatureTest do + use ExUnit.Case, async: true + + alias Elektrine.ActivityPub.HTTPSignature + alias Elektrine.ActivityPub.SigningKey + + test "sign_get includes non-default ports in the host header and signature" do + {public_key_pem, private_key_pem} = SigningKey.generate_key_pair() + + headers = + HTTPSignature.sign_get( + "https://example.com:8443/actors/alice?page=1", + private_key_pem, + "https://local.example/users/alice#main-key" + ) + + assert {"host", "example.com:8443"} in headers + + date = header_value(headers, "date") + + signing_string = + """ + (request-target): get /actors/alice?page=1 + host: example.com:8443 + date: #{date} + """ + |> String.trim_trailing() + + assert verify_signature(signing_string, signature_value(headers), public_key_pem) + end + + test "sign includes non-default ports in the host header and signature" do + {public_key_pem, private_key_pem} = SigningKey.generate_key_pair() + body = ~s({"type":"Follow"}) + + headers = + HTTPSignature.sign( + "https://example.com:8443/inbox?shared=true", + body, + private_key_pem, + "https://local.example/users/alice#main-key" + ) + + digest = "SHA-256=#{:crypto.hash(:sha256, body) |> Base.encode64()}" + + assert {"host", "example.com:8443"} in headers + assert {"digest", digest} in headers + + date = header_value(headers, "date") + + signing_string = + """ + (request-target): post /inbox?shared=true + host: example.com:8443 + date: #{date} + digest: #{digest} + """ + |> String.trim_trailing() + + assert verify_signature(signing_string, signature_value(headers), public_key_pem) + end + + defp signature_value(headers) do + Regex.run(~r/signature="([^"]+)"/, header_value(headers, "signature"), + capture: :all_but_first + ) + |> List.first() + end + + defp header_value(headers, name) do + headers + |> Enum.find_value(fn + {^name, value} -> value + _ -> nil + end) + end + + defp verify_signature(signing_string, signature, public_key_pem) do + {:ok, decoded_signature} = Base.decode64(signature) + [entry] = :public_key.pem_decode(public_key_pem) + public_key = :public_key.pem_entry_decode(entry) + + :public_key.verify(signing_string, :sha256, decoded_signature, public_key) + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/instances_test.exs b/apps/elektrine/test/elektrine/activitypub/instances_test.exs new file mode 100644 index 0000000..1855541 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/instances_test.exs @@ -0,0 +1,35 @@ +defmodule Elektrine.ActivityPub.InstancesTest do + use Elektrine.DataCase, async: true + + alias Elektrine.ActivityPub.{Instance, Instances} + alias Elektrine.Repo + + describe "get_or_create_instance/1" do + test "is idempotent across case variants" do + assert {:ok, first} = Instances.get_or_create_instance("Tooting.CH") + assert {:ok, second} = Instances.get_or_create_instance("tooting.ch") + + assert first.id == second.id + + domains = + Instance + |> Repo.all() + |> Enum.map(& &1.domain) + + assert domains == ["tooting.ch"] + end + end + + describe "Instance.changeset/2" do + test "normalizes domains and recognizes both unique constraint names" do + changeset = Instance.changeset(%Instance{}, %{domain: " HTTPS://Tooting.CH "}) + + assert Ecto.Changeset.get_change(changeset, :domain) == "tooting.ch" + + constraint_names = Enum.map(changeset.constraints, & &1.constraint) + + assert "activitypub_instances_domain_ci_unique" in constraint_names + assert "activitypub_instances_domain_index" in constraint_names + end + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/lemmy_api_test.exs b/apps/elektrine/test/elektrine/activitypub/lemmy_api_test.exs new file mode 100644 index 0000000..d6a688e --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/lemmy_api_test.exs @@ -0,0 +1,88 @@ +defmodule Elektrine.ActivityPub.LemmyApiTest do + use ExUnit.Case, async: true + + alias Elektrine.ActivityPub.LemmyApi + + describe "community_post_url?/1" do + test "matches supported community post URL patterns" do + assert LemmyApi.community_post_url?("https://lemmy.world/post/123") + assert LemmyApi.community_post_url?("https://lemmy.world/post/123?sort=Top") + assert LemmyApi.community_post_url?("https://piefed.social/c/tech/p/456") + assert LemmyApi.community_post_url?("https://mbin.social/m/linux/t/789") + end + + test "does not classify Bluesky post URLs as community posts" do + refute LemmyApi.community_post_url?( + "https://bsky.app/profile/alice.bsky.social/post/3l7nla7xq2s2d" + ) + end + end + + describe "fetch_post_comments_from_instance/5" do + test "paginates comment bodies and preserves nested parent refs" do + parent = self() + + request_fun = fn :get, url, _headers, _body, _opts -> + send(parent, {:requested_url, url}) + + page = + url + |> URI.parse() + |> Map.get(:query) + |> URI.decode_query() + |> Map.fetch!("page") + |> String.to_integer() + + comments = + case page do + 1 -> Enum.map(1..100, &comment_view/1) + 2 -> Enum.map(101..105, &comment_view/1) + _ -> [] + end + + {:ok, %Finch.Response{status: 200, body: Jason.encode!(%{"comments" => comments})}} + end + + comments = + LemmyApi.fetch_post_comments_from_instance( + "lemmy.example", + "42", + "https://lemmy.example/post/42", + 105, + request_fun: request_fun + ) + + assert length(comments) == 105 + assert Enum.at(comments, 0)["inReplyTo"] == "https://lemmy.example/post/42" + assert Enum.at(comments, 100)["inReplyTo"] == "https://lemmy.example/comment/1" + + assert_received {:requested_url, page_1_url} + assert page_1_url =~ "page=1" + + assert_received {:requested_url, page_2_url} + assert page_2_url =~ "page=2" + end + end + + defp comment_view(id) do + %{ + "comment" => %{ + "id" => id, + "ap_id" => "https://lemmy.example/comment/#{id}", + "content" => "comment #{id}", + "published" => "2026-01-01T00:00:00Z", + "path" => if(id == 1, do: "0.1", else: "0.1.#{id}") + }, + "creator" => %{ + "actor_id" => "https://lemmy.example/u/alice", + "name" => "alice" + }, + "counts" => %{ + "score" => id, + "upvotes" => id, + "downvotes" => 0, + "child_count" => 0 + } + } + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/lemmy_comment_backfill_test.exs b/apps/elektrine/test/elektrine/activitypub/lemmy_comment_backfill_test.exs new file mode 100644 index 0000000..ad5f910 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/lemmy_comment_backfill_test.exs @@ -0,0 +1,109 @@ +defmodule Elektrine.ActivityPub.LemmyCommentBackfillTest do + use Elektrine.DataCase, async: true + + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.LemmyCommentBackfill + alias Elektrine.Messaging + alias Elektrine.Social.Message + + test "apply_comment_counts updates stored lemmy comment counters" do + actor = remote_actor_fixture() + + assert {:ok, message} = + Messaging.create_federated_message(%{ + content: "remote comment", + visibility: "public", + activitypub_id: "https://lemmy.example/comment/42", + activitypub_url: "https://lemmy.example/comment/42", + remote_actor_id: actor.id, + like_count: 0, + upvotes: 0, + downvotes: 0, + score: 0, + reply_count: 0 + }) + + assert 1 == + LemmyCommentBackfill.apply_comment_counts(%{ + message.activitypub_id => %{upvotes: 9, downvotes: 3, score: 6, child_count: 4} + }) + + refreshed = Repo.get!(Message, message.id) + + assert refreshed.like_count == 9 + assert refreshed.upvotes == 9 + assert refreshed.downvotes == 3 + assert refreshed.score == 6 + assert refreshed.reply_count == 4 + end + + test "apply_comment_counts supports dry run" do + actor = remote_actor_fixture() + + assert {:ok, message} = + Messaging.create_federated_message(%{ + content: "remote comment", + visibility: "public", + activitypub_id: "https://lemmy.example/comment/77", + activitypub_url: "https://lemmy.example/comment/77", + remote_actor_id: actor.id, + like_count: 1, + upvotes: 1, + downvotes: 0, + score: 1, + reply_count: 0 + }) + + assert 1 == + LemmyCommentBackfill.apply_comment_counts( + %{message.activitypub_id => %{upvotes: 5, downvotes: 2, score: 3, child_count: 1}}, + dry_run: true + ) + + unchanged = Repo.get!(Message, message.id) + + assert unchanged.like_count == 1 + assert unchanged.upvotes == 1 + assert unchanged.downvotes == 0 + assert unchanged.score == 1 + assert unchanged.reply_count == 0 + end + + test "backfill_upvotes_from_like_count seeds existing lemmy comments" do + actor = remote_actor_fixture() + + assert {:ok, message} = + Messaging.create_federated_message(%{ + content: "remote comment", + visibility: "public", + activitypub_id: "https://lemmy.example/comment/88", + activitypub_url: "https://lemmy.example/comment/88", + remote_actor_id: actor.id, + like_count: 11, + upvotes: 0, + downvotes: 0, + score: 0, + reply_count: 2 + }) + + assert 1 == LemmyCommentBackfill.backfill_upvotes_from_like_count() + + refreshed = Repo.get!(Message, message.id) + assert refreshed.upvotes == 11 + assert refreshed.like_count == 11 + end + + defp remote_actor_fixture do + unique = System.unique_integer([:positive]) + + %Actor{} + |> Actor.changeset(%{ + uri: "https://lemmy.example/users/test-#{unique}", + username: "test#{unique}", + domain: "lemmy.example", + inbox_url: "https://lemmy.example/inbox", + public_key: "-----BEGIN PUBLIC KEY-----test-key-----END PUBLIC KEY-----" + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/mastodon_api_test.exs b/apps/elektrine/test/elektrine/activitypub/mastodon_api_test.exs new file mode 100644 index 0000000..9bbb7a9 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/mastodon_api_test.exs @@ -0,0 +1,312 @@ +defmodule Elektrine.ActivityPub.MastodonApiTest do + use ExUnit.Case, async: true + + alias Elektrine.ActivityPub.MastodonApi + + test "fetch_status_context resolves object URLs through status search" do + object_url = "https://akkoma.example/objects/123e4567-e89b-12d3-a456-426614174000" + + request_fun = fn :get, url, _headers, _body, _opts -> + cond do + String.contains?(url, "/api/v2/search") -> + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "statuses" => [ + %{ + "id" => "root-status", + "uri" => object_url, + "url" => object_url, + "account" => mastodon_account() + } + ] + }) + }} + + String.contains?(url, "/api/v1/statuses/root-status/context") -> + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "ancestors" => [], + "descendants" => [ + %{ + "id" => "reply-status", + "uri" => "https://akkoma.example/objects/reply", + "url" => "https://akkoma.example/notice/reply", + "content" => "reply", + "account" => mastodon_account(), + "favourites_count" => 1, + "reblogs_count" => 0, + "replies_count" => 0, + "created_at" => "2026-01-01T00:00:00Z", + "in_reply_to_id" => "root-status" + } + ] + }) + }} + + true -> + {:ok, %Finch.Response{status: 404, body: "{}"}} + end + end + + assert {:ok, [reply]} = MastodonApi.fetch_status_context(object_url, request_fun: request_fun) + assert reply.in_reply_to_uri == object_url + assert reply.account.uri == "https://akkoma.example/users/alice" + end + + test "fetch_status_counts resolves object URLs through status search" do + object_url = "https://akkoma.example/objects/123e4567-e89b-12d3-a456-426614174000" + + request_fun = fn :get, url, _headers, _body, _opts -> + if String.contains?(url, "/api/v2/search") do + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "statuses" => [ + %{ + "id" => "root-status", + "uri" => object_url, + "favourites_count" => 4, + "reblogs_count" => 1, + "replies_count" => 2 + } + ] + }) + }} + else + {:ok, %Finch.Response{status: 404, body: "{}"}} + end + end + + assert %{ + favourites_count: 4, + reblogs_count: 1, + replies_count: 2 + } = MastodonApi.fetch_status_counts(object_url, request_fun: request_fun) + end + + test "fetch_favourited_by resolves object URLs through status search" do + object_url = "https://akkoma.example/objects/123e4567-e89b-12d3-a456-426614174000" + + request_fun = fn :get, url, _headers, _body, _opts -> + cond do + String.contains?(url, "/api/v2/search") -> + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "statuses" => [%{"id" => "resolved-status", "uri" => object_url}] + }) + }} + + String.contains?(url, "/api/v1/statuses/resolved-status/favourited_by") -> + {:ok, %Finch.Response{status: 200, body: Jason.encode!([mastodon_account()])}} + + true -> + {:ok, %Finch.Response{status: 404, body: "{}"}} + end + end + + assert {:ok, [account]} = + MastodonApi.fetch_favourited_by(object_url, request_fun: request_fun) + + assert account.uri == "https://akkoma.example/users/alice" + end + + test "fetch_reblogged_by uses activitypub_url fallback for posts" do + post = %{ + activitypub_id: "https://akkoma.example/objects/123e4567-e89b-12d3-a456-426614174000", + activitypub_url: "https://akkoma.example/notice/resolved-status" + } + + request_fun = fn :get, url, _headers, _body, _opts -> + cond do + String.contains?(url, "/api/v2/search") -> + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"statuses" => [%{"id" => "resolved-status"}]}) + }} + + String.contains?(url, "/api/v1/statuses/resolved-status/reblogged_by") -> + {:ok, %Finch.Response{status: 200, body: Jason.encode!([mastodon_account()])}} + + true -> + {:ok, %Finch.Response{status: 404, body: "{}"}} + end + end + + assert {:ok, [account]} = + MastodonApi.fetch_reblogged_by_for_post(post, request_fun: request_fun) + + assert account.uri == "https://akkoma.example/users/alice" + end + + test "fetch_status_counts retrieves Misskey note metadata" do + note_url = "https://misskey.example/notes/abc123" + + request_fun = fn + :get, _url, _headers, _body, _opts -> + {:ok, %Finch.Response{status: 404, body: "{}"}} + + :post, url, _headers, body, _opts -> + assert String.contains?(url, "/api/notes/show") + assert %{"noteId" => "abc123"} = Jason.decode!(body) + + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "id" => "abc123", + "reactionCount" => 3, + "renoteCount" => 2, + "repliesCount" => 1, + "reactions" => %{"👍" => 2, ":custom@misskey.example:" => 1}, + "files" => [ + %{ + "id" => "file1", + "type" => "image/png", + "url" => "https://misskey.example/files/image.png", + "thumbnailUrl" => "https://misskey.example/files/thumb.png", + "comment" => "alt" + } + ], + "renoteId" => "quoted-note", + "app" => %{"name" => "Misskey Web"} + }) + }} + end + + assert %{ + favourites_count: 3, + reblogs_count: 2, + replies_count: 1, + status_metadata: metadata + } = MastodonApi.fetch_status_counts(note_url, request_fun: request_fun) + + assert [%{"count" => 1}, %{"count" => 2}] = + Enum.sort_by(metadata["emoji_reactions"], & &1["count"]) + + assert [%{"description" => "alt"}] = metadata["media_attachments"] + assert metadata["quote_id"] == "quoted-note" + assert metadata["application"] == %{"name" => "Misskey Web"} + end + + test "fetch_favourited_by retrieves Misskey reactors" do + note_url = "https://misskey.example/notes/abc123" + + request_fun = fn :post, url, _headers, body, _opts -> + assert String.contains?(url, "/api/notes/reactions") + assert %{"noteId" => "abc123", "limit" => 40} = Jason.decode!(body) + + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!([%{"type" => "👍", "user" => misskey_user()}]) + }} + end + + assert {:ok, [account]} = MastodonApi.fetch_favourited_by(note_url, request_fun: request_fun) + assert account.acct == "alice@remote.example" + assert account.display_name == "Alice M" + end + + test "fetch_reblogged_by retrieves Misskey renoters" do + note_url = "https://misskey.example/notes/abc123" + + request_fun = fn :post, url, _headers, body, _opts -> + assert String.contains?(url, "/api/notes/renotes") + assert %{"noteId" => "abc123", "limit" => 40} = Jason.decode!(body) + + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!([%{"id" => "renote1", "user" => misskey_user()}]) + }} + end + + assert {:ok, [account]} = MastodonApi.fetch_reblogged_by(note_url, request_fun: request_fun) + assert account.uri == "https://remote.example/@alice" + end + + test "fetch_status_context retrieves Misskey note children" do + note_url = "https://misskey.example/notes/abc123" + + request_fun = fn :post, url, _headers, body, _opts -> + assert String.contains?(url, "/api/notes/children") + assert %{"noteId" => "abc123"} = Jason.decode!(body) + + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!([ + %{ + "id" => "reply1", + "replyId" => "abc123", + "text" => "hello", + "createdAt" => "2026-01-01T00:00:00.000Z", + "reactionCount" => 1, + "renoteCount" => 0, + "repliesCount" => 0, + "user" => misskey_user() + } + ]) + }} + end + + assert {:ok, [reply]} = MastodonApi.fetch_status_context(note_url, request_fun: request_fun) + assert reply.id == "reply1" + assert reply.in_reply_to_uri == note_url + assert reply.account.acct == "alice@remote.example" + end + + test "display status URLs are count API compatible" do + status_url = "https://mastodon.example/@alice/123456789" + + assert MastodonApi.count_api_compatible?(%{activitypub_id: status_url}) + assert MastodonApi.mastodon_compatible?(%{activitypub_id: status_url}) + end + + test "activitypub_url can make an object id count API compatible" do + post = %{ + activitypub_id: "https://mastodon.example/objects/123e4567-e89b-12d3-a456-426614174000", + activitypub_url: "https://mastodon.example/@alice/123456789" + } + + assert MastodonApi.count_api_compatible?(post) + assert MastodonApi.mastodon_compatible?(post) + end + + defp mastodon_account do + %{ + "id" => "alice", + "username" => "alice", + "acct" => "alice@akkoma.example", + "display_name" => "Alice", + "url" => "https://akkoma.example/@alice", + "uri" => "https://akkoma.example/users/alice", + "avatar" => "https://akkoma.example/avatar.png" + } + end + + defp misskey_user do + %{ + "id" => "alice-id", + "username" => "alice", + "host" => "remote.example", + "name" => "Alice M", + "uri" => "https://remote.example/@alice", + "avatarUrl" => "https://remote.example/avatar.png" + } + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/mentions_test.exs b/apps/elektrine/test/elektrine/activitypub/mentions_test.exs new file mode 100644 index 0000000..0fee6f4 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/mentions_test.exs @@ -0,0 +1,48 @@ +defmodule Elektrine.ActivityPub.MentionsTest do + use ExUnit.Case, async: false + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Mentions + + test "extract_local_mentions ignores non-federated domain handles" do + mentions = Mentions.extract_local_mentions("hello @alice and @x.com and @bob@remote.example") + + assert Enum.map(mentions, & &1.username) == ["alice"] + end + + test "extract_local_mentions includes full local-domain mentions" do + mentions = Mentions.extract_local_mentions("hello @alice@#{ActivityPub.instance_domain()}") + + assert [%{username: "alice", handle: handle}] = mentions + assert handle == "alice@#{ActivityPub.instance_domain()}" + end + + test "extract_mentions supports hyphenated remote usernames" do + mentions = Mentions.extract_mentions("hello @lait-accompli@shitposter.world") + + assert mentions == [ + %{ + username: "lait-accompli", + domain: "shitposter.world", + handle: "lait-accompli@shitposter.world" + } + ] + end + + test "extract_local_mentions does not partially match hyphenated remote usernames" do + mentions = Mentions.extract_local_mentions("hello @lait-accompli@shitposter.world") + + refute Enum.any?(mentions, &(&1.username == "lait")) + end + + test "extract_non_fediverse_mentions finds domain-style handles" do + mentions = + Mentions.extract_non_fediverse_mentions("hello @x.com and @news.example and alex@x.com") + + assert Enum.map(mentions, & &1.handle) == ["x.com", "news.example", "alex@x.com"] + end + + test "count_mentions treats @x.com as one mention token" do + assert Mentions.count_mentions("@alice @bob@remote.example @x.com alex@x.com") == 4 + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/mrf/anti_followbot_policy_test.exs b/apps/elektrine/test/elektrine/activitypub/mrf/anti_followbot_policy_test.exs new file mode 100644 index 0000000..9f39767 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/mrf/anti_followbot_policy_test.exs @@ -0,0 +1,105 @@ +defmodule Elektrine.ActivityPub.MRF.AntiFollowbotPolicyTest do + use Elektrine.DataCase, async: true + + import Elektrine.AccountsFixtures + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.MRF.AntiFollowbotPolicy + alias Elektrine.Profiles.Follow + alias Elektrine.Repo + + test "rejects obvious remote follow-bot actors following local users" do + target = user_fixture() + actor = remote_actor_fixture(%{username: "followbot"}) + activity = follow_activity(actor.uri, ActivityPub.actor_uri(target)) + + assert {:reject, reason} = AntiFollowbotPolicy.filter(activity) + assert reason =~ "rejected follow-bot actor" + end + + test "rejects Service actors following local users" do + target = user_fixture() + actor = remote_actor_fixture(%{username: "relay", actor_type: "Service"}) + activity = follow_activity(actor.uri, ActivityPub.actor_uri(target)) + + assert {:reject, _reason} = AntiFollowbotPolicy.filter(activity) + end + + test "allows ordinary remote actors" do + target = user_fixture() + actor = remote_actor_fixture(%{username: "alice", display_name: "Alice"}) + activity = follow_activity(actor.uri, ActivityPub.actor_uri(target)) + + assert {:ok, ^activity} = AntiFollowbotPolicy.filter(activity) + end + + test "allows unknown remote actors without fetching during policy filtering" do + target = user_fixture() + + activity = + follow_activity( + "https://unknown.example/users/followbot", + ActivityPub.actor_uri(target) + ) + + assert {:ok, ^activity} = AntiFollowbotPolicy.filter(activity) + end + + test "allows follow-bot actors already followed by the local target" do + target = user_fixture() + actor = remote_actor_fixture(%{username: "federationbot"}) + insert_local_remote_follow(target.id, actor.id) + + activity = follow_activity(actor.uri, ActivityPub.actor_uri(target)) + + assert {:ok, ^activity} = AntiFollowbotPolicy.filter(activity) + end + + test "does not reject follows targeting non-local actors" do + actor = remote_actor_fixture(%{username: "followbot"}) + activity = follow_activity(actor.uri, "https://remote.example/users/bob") + + assert {:ok, ^activity} = AntiFollowbotPolicy.filter(activity) + end + + defp follow_activity(actor_uri, object_uri) do + %{ + "id" => "#{actor_uri}/activities/follow-1", + "type" => "Follow", + "actor" => actor_uri, + "object" => object_uri + } + end + + defp remote_actor_fixture(attrs) do + username = Map.get(attrs, :username, "remote") + domain = Map.get(attrs, :domain, "#{username}.example") + uri = Map.get(attrs, :uri, "https://#{domain}/users/#{username}") + + attrs = + %{ + uri: uri, + username: username, + domain: domain, + display_name: Map.get(attrs, :display_name, username), + inbox_url: "#{uri}/inbox", + public_key: "test-public-key", + actor_type: Map.get(attrs, :actor_type, "Person") + } + + %Actor{} + |> Actor.changeset(attrs) + |> Repo.insert!() + end + + defp insert_local_remote_follow(follower_id, remote_actor_id) do + %Follow{} + |> Follow.changeset(%{ + follower_id: follower_id, + remote_actor_id: remote_actor_id, + pending: false + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/mrf/hellthread_policy_test.exs b/apps/elektrine/test/elektrine/activitypub/mrf/hellthread_policy_test.exs new file mode 100644 index 0000000..58293cd --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/mrf/hellthread_policy_test.exs @@ -0,0 +1,211 @@ +defmodule Elektrine.ActivityPub.MRF.HellthreadPolicyTest do + use Elektrine.DataCase, async: true + + alias Elektrine.ActivityPub.MRF.HellthreadPolicy + + setup do + # Clear any existing config + Application.delete_env(:elektrine, :mrf_hellthread) + :ok + end + + describe "filter/1 - reject threshold" do + test "rejects activities with too many mentions (using tags)" do + Application.put_env(:elektrine, :mrf_hellthread, reject_threshold: 5) + + # Create activity with 6 mentions + mentions = + Enum.map(1..6, fn i -> + %{"type" => "Mention", "href" => "https://example.com/users/user#{i}"} + end) + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/test", + "object" => %{ + "type" => "Note", + "content" => "Hello everyone!", + "tag" => mentions + } + } + + assert {:reject, message} = HellthreadPolicy.filter(activity) + assert message =~ "Too many mentions" + end + + test "rejects activities with too many recipients (using to/cc)" do + Application.put_env(:elektrine, :mrf_hellthread, reject_threshold: 5) + + # Create activity with many recipients + recipients = Enum.map(1..6, fn i -> "https://example.com/users/user#{i}" end) + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/test", + "to" => recipients, + "cc" => [], + "object" => %{ + "type" => "Note", + "content" => "Hello everyone!" + } + } + + assert {:reject, message} = HellthreadPolicy.filter(activity) + assert message =~ "Too many mentions" + end + + test "allows activities under reject threshold" do + Application.put_env(:elektrine, :mrf_hellthread, reject_threshold: 10) + + mentions = + Enum.map(1..5, fn i -> + %{"type" => "Mention", "href" => "https://example.com/users/user#{i}"} + end) + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/test", + "object" => %{ + "type" => "Note", + "content" => "Hello everyone!", + "tag" => mentions + } + } + + assert {:ok, ^activity} = HellthreadPolicy.filter(activity) + end + end + + describe "filter/1 - delist threshold" do + test "delists activities with mentions above delist threshold but below reject" do + Application.put_env(:elektrine, :mrf_hellthread, + delist_threshold: 5, + reject_threshold: 20 + ) + + mentions = + Enum.map(1..10, fn i -> + %{"type" => "Mention", "href" => "https://example.com/users/user#{i}"} + end) + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/test", + "object" => %{ + "type" => "Note", + "content" => "Hello everyone!", + "tag" => mentions + } + } + + assert {:ok, filtered} = HellthreadPolicy.filter(activity) + assert filtered["object"]["_mrf_federated_timeline_removal"] == true + end + + test "does not delist activities under delist threshold" do + Application.put_env(:elektrine, :mrf_hellthread, delist_threshold: 10) + + mentions = + Enum.map(1..3, fn i -> + %{"type" => "Mention", "href" => "https://example.com/users/user#{i}"} + end) + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/test", + "object" => %{ + "type" => "Note", + "content" => "Hello everyone!", + "tag" => mentions + } + } + + assert {:ok, filtered} = HellthreadPolicy.filter(activity) + refute Map.has_key?(filtered["object"], "_mrf_federated_timeline_removal") + end + end + + describe "filter/1 - recipient counting" do + test "excludes public addresses from count" do + Application.put_env(:elektrine, :mrf_hellthread, reject_threshold: 3) + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/test", + "to" => [ + "https://www.w3.org/ns/activitystreams#Public", + "https://example.com/users/user1", + "https://example.com/users/user2" + ], + "cc" => [], + "object" => %{ + "type" => "Note", + "content" => "Hello!" + } + } + + # Only 2 actual users, so should pass + assert {:ok, ^activity} = HellthreadPolicy.filter(activity) + end + + test "excludes followers collection from count" do + Application.put_env(:elektrine, :mrf_hellthread, reject_threshold: 3) + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/test", + "to" => [ + "https://example.com/users/test/followers", + "https://example.com/users/user1", + "https://example.com/users/user2" + ], + "cc" => [], + "object" => %{ + "type" => "Note", + "content" => "Hello!" + } + } + + # Only 2 actual users (followers collection excluded), so should pass + assert {:ok, ^activity} = HellthreadPolicy.filter(activity) + end + end + + describe "filter/1 - non-Create activities" do + test "passes through other activity types" do + Application.put_env(:elektrine, :mrf_hellthread, reject_threshold: 1) + + activity = %{ + "type" => "Like", + "actor" => "https://example.com/users/test", + "object" => "https://example.com/posts/123" + } + + assert {:ok, ^activity} = HellthreadPolicy.filter(activity) + end + end + + describe "describe/0" do + test "returns threshold configuration" do + Application.put_env(:elektrine, :mrf_hellthread, + delist_threshold: 15, + reject_threshold: 25 + ) + + {:ok, description} = HellthreadPolicy.describe() + + assert description[:mrf_hellthread][:delist_threshold] == 15 + assert description[:mrf_hellthread][:reject_threshold] == 25 + end + + test "returns defaults when not configured" do + Application.delete_env(:elektrine, :mrf_hellthread) + + {:ok, description} = HellthreadPolicy.describe() + + # Defaults are 10 and 20 + assert description[:mrf_hellthread][:delist_threshold] == 10 + assert description[:mrf_hellthread][:reject_threshold] == 20 + end + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/mrf/keyword_policy_test.exs b/apps/elektrine/test/elektrine/activitypub/mrf/keyword_policy_test.exs new file mode 100644 index 0000000..c7d4302 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/mrf/keyword_policy_test.exs @@ -0,0 +1,229 @@ +defmodule Elektrine.ActivityPub.MRF.KeywordPolicyTest do + use Elektrine.DataCase, async: true + + alias Elektrine.ActivityPub.MRF.KeywordPolicy + + setup do + # Clear any existing config + Application.delete_env(:elektrine, :mrf_keyword) + :ok + end + + describe "filter/1 - reject" do + test "rejects activities matching reject patterns" do + Application.put_env(:elektrine, :mrf_keyword, reject: ["spam phrase"]) + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/test", + "object" => %{ + "type" => "Note", + "content" => "This contains a spam phrase in it" + } + } + + assert {:reject, "Blocked by keyword filter"} = KeywordPolicy.filter(activity) + end + + test "rejects activities matching regex patterns" do + Application.put_env(:elektrine, :mrf_keyword, reject: [~r/buy.*now/i]) + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/test", + "object" => %{ + "type" => "Note", + "content" => "You should BUY this product NOW!" + } + } + + assert {:reject, "Blocked by keyword filter"} = KeywordPolicy.filter(activity) + end + + test "allows activities not matching reject patterns" do + Application.put_env(:elektrine, :mrf_keyword, reject: ["spam"]) + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/test", + "object" => %{ + "type" => "Note", + "content" => "This is a normal post" + } + } + + assert {:ok, ^activity} = KeywordPolicy.filter(activity) + end + end + + describe "filter/1 - replace" do + test "replaces matching text with replacement" do + Application.put_env(:elektrine, :mrf_keyword, replace: [{"bad word", "****"}]) + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/test", + "object" => %{ + "type" => "Note", + "content" => "This has a bad word in it" + } + } + + assert {:ok, filtered} = KeywordPolicy.filter(activity) + assert filtered["object"]["content"] == "This has a **** in it" + end + + test "replaces with regex patterns" do + Application.put_env(:elektrine, :mrf_keyword, replace: [{~r/\btest\b/i, "example"}]) + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/test", + "object" => %{ + "type" => "Note", + "content" => "This is a TEST post" + } + } + + assert {:ok, filtered} = KeywordPolicy.filter(activity) + assert filtered["object"]["content"] == "This is a example post" + end + end + + describe "filter/1 - mark_sensitive" do + test "marks content as sensitive when matching patterns" do + Application.put_env(:elektrine, :mrf_keyword, mark_sensitive: ["nsfw"]) + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/test", + "object" => %{ + "type" => "Note", + "content" => "This is NSFW content", + "sensitive" => false + } + } + + assert {:ok, filtered} = KeywordPolicy.filter(activity) + assert filtered["object"]["sensitive"] == true + end + + test "does not mark sensitive when no match" do + Application.put_env(:elektrine, :mrf_keyword, mark_sensitive: ["nsfw"]) + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/test", + "object" => %{ + "type" => "Note", + "content" => "This is safe content", + "sensitive" => false + } + } + + assert {:ok, filtered} = KeywordPolicy.filter(activity) + assert filtered["object"]["sensitive"] == false + end + end + + describe "filter/1 - federated_timeline_removal" do + test "flags content for removal from federated timeline" do + Application.put_env(:elektrine, :mrf_keyword, federated_timeline_removal: ["politics"]) + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/test", + "object" => %{ + "type" => "Note", + "content" => "Let's talk about politics today" + } + } + + assert {:ok, filtered} = KeywordPolicy.filter(activity) + assert filtered["object"]["_mrf_federated_timeline_removal"] == true + end + end + + describe "filter/1 - Update activities" do + test "also filters Update activities" do + Application.put_env(:elektrine, :mrf_keyword, reject: ["spam"]) + + activity = %{ + "type" => "Update", + "actor" => "https://example.com/users/test", + "object" => %{ + "type" => "Note", + "content" => "Updated to include spam" + } + } + + assert {:reject, "Blocked by keyword filter"} = KeywordPolicy.filter(activity) + end + end + + describe "filter/1 - non-Create/Update activities" do + test "passes through other activity types" do + Application.put_env(:elektrine, :mrf_keyword, reject: ["spam"]) + + activity = %{ + "type" => "Like", + "actor" => "https://example.com/users/test", + "object" => "https://example.com/posts/123" + } + + assert {:ok, ^activity} = KeywordPolicy.filter(activity) + end + end + + describe "filter/1 - checks multiple fields" do + test "checks summary field" do + Application.put_env(:elektrine, :mrf_keyword, reject: ["spam"]) + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/test", + "object" => %{ + "type" => "Note", + "content" => "Normal content", + "summary" => "CW: spam warning" + } + } + + assert {:reject, "Blocked by keyword filter"} = KeywordPolicy.filter(activity) + end + + test "checks name field" do + Application.put_env(:elektrine, :mrf_keyword, reject: ["spam"]) + + activity = %{ + "type" => "Create", + "actor" => "https://example.com/users/test", + "object" => %{ + "type" => "Article", + "content" => "Normal content", + "name" => "Spam Article Title" + } + } + + assert {:reject, "Blocked by keyword filter"} = KeywordPolicy.filter(activity) + end + end + + describe "describe/0" do + test "returns policy configuration summary" do + Application.put_env(:elektrine, :mrf_keyword, + reject: ["a", "b"], + federated_timeline_removal: ["c"], + replace: [{"d", "e"}], + mark_sensitive: [] + ) + + {:ok, description} = KeywordPolicy.describe() + + assert description[:mrf_keyword][:reject_count] == 2 + assert description[:mrf_keyword][:federated_timeline_removal_count] == 1 + assert description[:mrf_keyword][:replace_count] == 1 + assert description[:mrf_keyword][:mark_sensitive_count] == 0 + end + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/mrf/pleroma_policy_batch_test.exs b/apps/elektrine/test/elektrine/activitypub/mrf/pleroma_policy_batch_test.exs new file mode 100644 index 0000000..a355fd4 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/mrf/pleroma_policy_batch_test.exs @@ -0,0 +1,329 @@ +defmodule Elektrine.ActivityPub.MRF.PleromaPolicyBatchTest do + use Elektrine.DataCase, async: false + + alias Elektrine.ActivityPub + + alias Elektrine.ActivityPub.MRF.{ + AntiLinkSpamPolicy, + AntiMentionSpamPolicy, + EmojiPolicy, + ForceBotUnlistedPolicy, + HashtagPolicy, + InlineQuotePolicy, + MentionPolicy, + NoEmptyPolicy, + NoPlaceholderTextPolicy, + QuietReplyPolicy, + QuoteToLinkTagPolicy, + RejectNonPublicPolicy, + RemoteReportPolicy, + UserAllowListPolicy, + VocabularyPolicy + } + + setup do + Enum.each( + [ + :mrf_emoji, + :mrf_hashtag, + :mrf_inline_quote, + :mrf_mention, + :mrf_reject_non_public, + :mrf_remote_report, + :mrf_user_allowlist, + :mrf_vocabulary + ], + &Application.delete_env(:elektrine, &1) + ) + + :ok + end + + test "NoPlaceholderTextPolicy removes dot filler from media posts" do + activity = create_activity(%{"content" => ".", "attachment" => [%{"type" => "Image"}]}) + + assert {:ok, filtered} = NoPlaceholderTextPolicy.filter(activity) + assert filtered["object"]["content"] == "" + end + + test "NoEmptyPolicy rejects local notes with only mentions" do + activity = + create_activity(%{ + "actor" => "#{ActivityPub.instance_url()}/users/alice", + "content" => "@bob @carol", + "source" => "@bob @carol" + }) + + assert {:reject, "[NoEmptyPolicy] empty local note"} = NoEmptyPolicy.filter(activity) + end + + test "RejectNonPublicPolicy rejects followers-only posts unless allowed" do + activity = + create_activity(%{ + "actor" => "https://remote.example/users/alice", + "to" => ["https://remote.example/users/alice/followers"], + "cc" => [] + }) + + assert {:reject, "[RejectNonPublicPolicy] followers-only activity rejected"} = + RejectNonPublicPolicy.filter(activity) + + Application.put_env(:elektrine, :mrf_reject_non_public, allow_followers_only: true) + + assert {:ok, ^activity} = RejectNonPublicPolicy.filter(activity) + end + + test "VocabularyPolicy rejects disallowed activity and object types" do + Application.put_env(:elektrine, :mrf_vocabulary, accept: ["Create", "Note"]) + + assert {:ok, _} = VocabularyPolicy.filter(create_activity()) + + assert {:reject, "[VocabularyPolicy] Like not in accept list"} = + VocabularyPolicy.filter(%{"type" => "Like", "object" => "x"}) + + Application.put_env(:elektrine, :mrf_vocabulary, reject: ["Question"]) + + assert {:reject, "[VocabularyPolicy] Question in reject list"} = + VocabularyPolicy.filter(create_activity(%{"type" => "Question"})) + end + + test "QuietReplyPolicy converts local public replies to unlisted" do + actor = "#{ActivityPub.instance_url()}/users/alice" + + activity = + create_activity(%{ + "actor" => actor, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "inReplyTo" => "https://remote.example/notes/1" + }) + + assert {:ok, filtered} = QuietReplyPolicy.filter(activity) + refute "https://www.w3.org/ns/activitystreams#Public" in filtered["to"] + assert "https://www.w3.org/ns/activitystreams#Public" in filtered["cc"] + assert "#{actor}/followers" in filtered["to"] + end + + test "HashtagPolicy rejects delists and marks sensitive by hashtag" do + Application.put_env(:elektrine, :mrf_hashtag, + reject: ["spam"], + federated_timeline_removal: ["politics"], + sensitive: ["nsfw"] + ) + + assert {:reject, "[HashtagPolicy] rejected hashtag"} = + HashtagPolicy.filter(create_activity(%{"content" => "bad #spam"})) + + assert {:ok, delisted} = + HashtagPolicy.filter(create_activity(%{"content" => "talking #politics"})) + + refute "https://www.w3.org/ns/activitystreams#Public" in delisted["to"] + assert "https://www.w3.org/ns/activitystreams#Public" in delisted["cc"] + + assert {:ok, sensitive} = + HashtagPolicy.filter(create_activity(%{"content" => "image #nsfw"})) + + assert sensitive["object"]["sensitive"] == true + end + + test "EmojiPolicy removes blocked custom emoji and rejects blocked reactions" do + Application.put_env(:elektrine, :mrf_emoji, + remove_shortcode: ["badmoji"], + federated_timeline_removal_shortcode: ["loudmoji"] + ) + + activity = + create_activity(%{ + "tag" => [ + %{ + "type" => "Emoji", + "name" => ":badmoji:", + "icon" => %{"url" => "https://emoji.example/bad.png"} + }, + %{ + "type" => "Emoji", + "name" => ":ok:", + "icon" => %{"url" => "https://emoji.example/ok.png"} + } + ], + "emoji" => %{ + "badmoji" => "https://emoji.example/bad.png", + "ok" => "https://emoji.example/ok.png" + } + }) + + assert {:ok, filtered} = EmojiPolicy.filter(activity) + assert Enum.map(filtered["object"]["tag"], & &1["name"]) == [":ok:"] + assert filtered["object"]["emoji"] == %{"ok" => "https://emoji.example/ok.png"} + + assert {:ok, delisted} = + EmojiPolicy.filter( + create_activity(%{ + "tag" => [%{"type" => "Emoji", "name" => ":loudmoji:"}] + }) + ) + + refute "https://www.w3.org/ns/activitystreams#Public" in delisted["to"] + + assert {:reject, "[EmojiPolicy] rejected emoji reaction"} = + EmojiPolicy.filter(%{ + "type" => "EmojiReact", + "tag" => [%{"type" => "Emoji", "name" => ":badmoji:"}] + }) + end + + test "RemoteReportPolicy rejects low-quality remote reports" do + Application.put_env(:elektrine, :mrf_remote_report, + reject_anonymous: true, + reject_third_party: true, + reject_empty_message: true + ) + + assert {:reject, "[RemoteReportPolicy] anonymous remote report rejected"} = + RemoteReportPolicy.filter(%{ + "type" => "Flag", + "actor" => "https://remote.example/actor", + "object" => "#{ActivityPub.instance_url()}/users/alice", + "content" => "bad" + }) + + assert {:reject, "[RemoteReportPolicy] third-party report rejected"} = + RemoteReportPolicy.filter(%{ + "type" => "Flag", + "actor" => "https://remote.example/users/bob", + "object" => "https://third.example/users/alice", + "content" => "bad" + }) + + assert {:reject, "[RemoteReportPolicy] empty remote report rejected"} = + RemoteReportPolicy.filter(%{ + "type" => "Flag", + "actor" => "https://remote.example/users/bob", + "object" => "#{ActivityPub.instance_url()}/users/alice", + "content" => "" + }) + end + + test "MentionPolicy rejects posts mentioning protected actors" do + protected = "#{ActivityPub.instance_url()}/users/alice" + Application.put_env(:elektrine, :mrf_mention, actors: [protected]) + + activity = create_activity(%{"to" => [protected]}) + expected = "[MentionPolicy] rejected mention of #{protected}" + + assert {:reject, ^expected} = MentionPolicy.filter(activity) + end + + test "UserAllowListPolicy restricts configured domains to explicit actors" do + allowed = "https://remote.example/users/allowed" + denied = "https://remote.example/users/denied" + + Application.put_env(:elektrine, :mrf_user_allowlist, hosts: %{"remote.example" => [allowed]}) + + assert {:ok, _} = UserAllowListPolicy.filter(create_activity(%{"actor" => allowed})) + + expected = "[UserAllowListPolicy] #{denied} not in allowlist for remote.example" + + assert {:reject, ^expected} = + UserAllowListPolicy.filter(create_activity(%{"actor" => denied})) + end + + test "UserAllowListPolicy does not create atoms for remote actor hosts" do + host = "untrusted-#{System.unique_integer([:positive])}.example" + + Application.put_env(:elektrine, :mrf_user_allowlist, hosts: []) + + assert_raise ArgumentError, fn -> + :erlang.binary_to_existing_atom(host, :utf8) + end + + assert {:ok, _} = + UserAllowListPolicy.filter( + create_activity(%{"actor" => "https://#{host}/users/alice"}) + ) + + assert_raise ArgumentError, fn -> + :erlang.binary_to_existing_atom(host, :utf8) + end + end + + test "ForceBotUnlistedPolicy removes public delivery from likely bot posts" do + activity = + create_activity(%{ + "actor" => "https://remote.example/users/news.bot", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + }) + + assert {:ok, filtered} = ForceBotUnlistedPolicy.filter(activity) + refute "https://www.w3.org/ns/activitystreams#Public" in filtered["to"] + assert "https://www.w3.org/ns/activitystreams#Public" in filtered["cc"] + end + + test "Quote policies add compatibility tag and inline quote content" do + quote_url = "https://remote.example/notes/quoted" + activity = create_activity(%{"quoteUrl" => quote_url, "content" => "

    quoted this

    "}) + + assert {:ok, tagged} = QuoteToLinkTagPolicy.filter(activity) + assert Enum.any?(tagged["object"]["tag"], &(&1["type"] == "Link" and &1["href"] == quote_url)) + + Application.put_env(:elektrine, :mrf_inline_quote, template: "RE: {url}") + + assert {:ok, inlined} = InlineQuotePolicy.filter(activity) + assert inlined["object"]["content"] =~ "quote-inline" + assert inlined["object"]["content"] =~ quote_url + end + + test "AntiLinkSpamPolicy rejects links from unknown remote actors" do + activity = + create_activity(%{ + "actor" => "https://fresh.example/users/alice", + "content" => ~s(

    look here

    ) + }) + + assert {:reject, "[AntiLinkSpamPolicy] unknown remote actor posted links"} = + AntiLinkSpamPolicy.filter(activity) + end + + test "AntiMentionSpamPolicy rejects mentions from unknown remote actors" do + activity = + create_activity(%{ + "actor" => "https://fresh.example/users/alice", + "to" => ["#{ActivityPub.instance_url()}/users/bob"], + "cc" => [] + }) + + assert {:reject, "[AntiMentionSpamPolicy] unknown remote actor mentioned users"} = + AntiMentionSpamPolicy.filter(activity) + end + + defp create_activity(overrides \\ %{}) do + actor = Map.get(overrides, "actor", "https://remote.example/users/alice") + to = Map.get(overrides, "to", ["https://www.w3.org/ns/activitystreams#Public"]) + cc = Map.get(overrides, "cc", []) + object_overrides = Map.drop(overrides, ["actor", "to", "cc"]) + + object = + Map.merge( + %{ + "id" => "https://remote.example/notes/#{System.unique_integer([:positive])}", + "type" => "Note", + "actor" => actor, + "attributedTo" => actor, + "content" => "hello", + "to" => to, + "cc" => cc + }, + object_overrides + ) + + %{ + "id" => "https://remote.example/activities/#{System.unique_integer([:positive])}", + "type" => "Create", + "actor" => actor, + "to" => to, + "cc" => cc, + "object" => object + } + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/mrf/simple_policy_test.exs b/apps/elektrine/test/elektrine/activitypub/mrf/simple_policy_test.exs new file mode 100644 index 0000000..d9a2408 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/mrf/simple_policy_test.exs @@ -0,0 +1,421 @@ +defmodule Elektrine.ActivityPub.MRF.SimplePolicyTest do + use Elektrine.DataCase, async: false + + alias Elektrine.ActivityPub.Instance + alias Elektrine.ActivityPub.MRF.SimplePolicy + alias Elektrine.Repo + + setup do + # Clear the ETS cache before each test + SimplePolicy.invalidate_cache() + :ok + end + + describe "filter/1 - reject (blocked)" do + test "rejects activities from blocked instances" do + # Create a blocked instance + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "blocked.example.com", blocked: true}) + |> Repo.insert() + + activity = %{ + "type" => "Create", + "actor" => "https://blocked.example.com/users/test", + "object" => %{"type" => "Note", "content" => "test"} + } + + assert {:reject, "[SimplePolicy] host is blocked"} = SimplePolicy.filter(activity) + end + + test "allows activities from non-blocked instances" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "allowed.example.com", blocked: false}) + |> Repo.insert() + + activity = %{ + "type" => "Create", + "actor" => "https://allowed.example.com/users/test", + "object" => %{"type" => "Note", "content" => "test"} + } + + assert {:ok, ^activity} = SimplePolicy.filter(activity) + end + end + + describe "filter/1 - media_removal" do + test "removes media attachments from media_removal instances" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "media.example.com", media_removal: true}) + |> Repo.insert() + + activity = %{ + "type" => "Create", + "actor" => "https://media.example.com/users/test", + "object" => %{ + "type" => "Note", + "content" => "test", + "attachment" => [ + %{"type" => "Image", "url" => "https://media.example.com/image.jpg"} + ] + } + } + + assert {:ok, filtered} = SimplePolicy.filter(activity) + refute Map.has_key?(filtered["object"], "attachment") + end + + test "preserves attachments from non-media_removal instances" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "normal.example.com", media_removal: false}) + |> Repo.insert() + + activity = %{ + "type" => "Create", + "actor" => "https://normal.example.com/users/test", + "object" => %{ + "type" => "Note", + "content" => "test", + "attachment" => [ + %{"type" => "Image", "url" => "https://normal.example.com/image.jpg"} + ] + } + } + + assert {:ok, filtered} = SimplePolicy.filter(activity) + assert Map.has_key?(filtered["object"], "attachment") + end + end + + describe "filter/1 - media_nsfw" do + test "marks content as sensitive from media_nsfw instances" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "nsfw.example.com", media_nsfw: true}) + |> Repo.insert() + + activity = %{ + "type" => "Create", + "actor" => "https://nsfw.example.com/users/test", + "object" => %{ + "type" => "Note", + "content" => "test", + "sensitive" => false + } + } + + assert {:ok, filtered} = SimplePolicy.filter(activity) + assert filtered["object"]["sensitive"] == true + end + end + + describe "filter/1 - federated_timeline_removal" do + test "moves Public to CC for federated_timeline_removal instances" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "ftl.example.com", federated_timeline_removal: true}) + |> Repo.insert() + + activity = %{ + "type" => "Create", + "actor" => "https://ftl.example.com/users/test", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => ["https://ftl.example.com/users/test/followers"], + "object" => %{ + "type" => "Note", + "content" => "test", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => ["https://ftl.example.com/users/test/followers"] + } + } + + assert {:ok, filtered} = SimplePolicy.filter(activity) + refute "https://www.w3.org/ns/activitystreams#Public" in filtered["to"] + assert "https://www.w3.org/ns/activitystreams#Public" in filtered["cc"] + refute "https://www.w3.org/ns/activitystreams#Public" in filtered["object"]["to"] + assert "https://www.w3.org/ns/activitystreams#Public" in filtered["object"]["cc"] + end + end + + describe "filter/1 - silenced" do + test "applies federated timeline removal behavior for silenced instances" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "silenced.example.com", silenced: true}) + |> Repo.insert() + + activity = %{ + "type" => "Create", + "actor" => "https://silenced.example.com/users/test", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => ["https://silenced.example.com/users/test/followers"], + "object" => %{"type" => "Note", "content" => "test"} + } + + assert {:ok, filtered} = SimplePolicy.filter(activity) + refute "https://www.w3.org/ns/activitystreams#Public" in filtered["to"] + assert "https://www.w3.org/ns/activitystreams#Public" in filtered["cc"] + end + end + + describe "filter/1 - followers_only" do + test "forces posts to followers-only from followers_only instances" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "fo.example.com", followers_only: true}) + |> Repo.insert() + + activity = %{ + "type" => "Create", + "actor" => "https://fo.example.com/users/test", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => ["https://fo.example.com/users/test/followers"], + "object" => %{ + "type" => "Note", + "content" => "test", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => ["https://fo.example.com/users/test/followers"] + } + } + + assert {:ok, filtered} = SimplePolicy.filter(activity) + refute "https://www.w3.org/ns/activitystreams#Public" in filtered["to"] + refute "https://www.w3.org/ns/activitystreams#Public" in filtered["cc"] + assert "https://fo.example.com/users/test/followers" in filtered["to"] + refute "https://www.w3.org/ns/activitystreams#Public" in filtered["object"]["to"] + refute "https://www.w3.org/ns/activitystreams#Public" in filtered["object"]["cc"] + end + end + + describe "filter/1 - report_removal" do + test "rejects Flag activities from report_removal instances" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "report.example.com", report_removal: true}) + |> Repo.insert() + + activity = %{ + "type" => "Flag", + "actor" => "https://report.example.com/users/test", + "object" => ["https://our.instance/users/victim"] + } + + assert {:reject, "[SimplePolicy] host in report_removal list"} = + SimplePolicy.filter(activity) + end + + test "allows Flag activities from instances without report_removal policy" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "normal-report.example.com", report_removal: false}) + |> Repo.insert() + + activity = %{ + "type" => "Flag", + "actor" => "https://normal-report.example.com/users/test", + "object" => ["https://our.instance/users/victim"] + } + + assert {:ok, ^activity} = SimplePolicy.filter(activity) + end + end + + describe "filter/1 - reject_deletes" do + test "rejects Delete activities from reject_deletes instances" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "nodelete.example.com", reject_deletes: true}) + |> Repo.insert() + + activity = %{ + "type" => "Delete", + "actor" => "https://nodelete.example.com/users/test", + "object" => "https://nodelete.example.com/posts/123" + } + + assert {:reject, "[SimplePolicy] host in reject_deletes list"} = + SimplePolicy.filter(activity) + end + end + + describe "filter/1 - avatar_removal" do + test "removes avatar from Person objects from avatar_removal instances" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "noavatar.example.com", avatar_removal: true}) + |> Repo.insert() + + person = %{ + "type" => "Person", + "id" => "https://noavatar.example.com/users/test", + "name" => "Test User", + "icon" => %{ + "type" => "Image", + "url" => "https://noavatar.example.com/avatar.jpg" + } + } + + assert {:ok, filtered} = SimplePolicy.filter(person) + refute Map.has_key?(filtered, "icon") + end + end + + describe "filter/1 - banner_removal" do + test "removes banner from Person objects from banner_removal instances" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "nobanner.example.com", banner_removal: true}) + |> Repo.insert() + + person = %{ + "type" => "Person", + "id" => "https://nobanner.example.com/users/test", + "name" => "Test User", + "image" => %{ + "type" => "Image", + "url" => "https://nobanner.example.com/banner.jpg" + } + } + + assert {:ok, filtered} = SimplePolicy.filter(person) + refute Map.has_key?(filtered, "image") + end + end + + describe "wildcard domain matching" do + test "matches wildcard domains" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "*.spam.example.com", blocked: true}) + |> Repo.insert() + + activity = %{ + "type" => "Create", + "actor" => "https://subdomain.spam.example.com/users/test", + "object" => %{"type" => "Note", "content" => "spam"} + } + + assert {:reject, "[SimplePolicy] host is blocked"} = SimplePolicy.filter(activity) + end + + test "wildcard does NOT match base domain (standard behavior)" do + # Standard wildcard behavior: *.bad.com matches sub.bad.com but NOT bad.com + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "*.bad.com", blocked: true}) + |> Repo.insert() + + # Activity from sub.bad.com should be blocked + subdomain_activity = %{ + "type" => "Create", + "actor" => "https://sub.bad.com/users/test", + "object" => %{"type" => "Note", "content" => "test"} + } + + assert {:reject, "[SimplePolicy] host is blocked"} = SimplePolicy.filter(subdomain_activity) + + # Activity from base bad.com should NOT be blocked by *.bad.com + base_activity = %{ + "type" => "Create", + "actor" => "https://bad.com/users/test", + "object" => %{"type" => "Note", "content" => "test"} + } + + assert {:ok, ^base_activity} = SimplePolicy.filter(base_activity) + end + + test "wildcard does not match unrelated domains" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "*.spam.com", blocked: true}) + |> Repo.insert() + + activity = %{ + "type" => "Create", + "actor" => "https://notspam.com/users/test", + "object" => %{"type" => "Note", "content" => "test"} + } + + assert {:ok, ^activity} = SimplePolicy.filter(activity) + end + end + + describe "caching" do + test "invalidate_cache/1 clears cache for specific host" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "cached.example.com", blocked: true}) + |> Repo.insert() + + # First call populates cache + assert true = SimplePolicy.host_has_policy?("cached.example.com", :blocked) + + # Invalidate cache + SimplePolicy.invalidate_cache("cached.example.com") + + # Should still work (repopulates from DB) + assert true = SimplePolicy.host_has_policy?("cached.example.com", :blocked) + end + + test "invalidate_cache/0 clears entire cache" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{domain: "cached2.example.com", blocked: true}) + |> Repo.insert() + + # Populate cache + assert true = SimplePolicy.host_has_policy?("cached2.example.com", :blocked) + + # Clear all cache + SimplePolicy.invalidate_cache() + + # Should still work + assert true = SimplePolicy.host_has_policy?("cached2.example.com", :blocked) + end + end + + describe "describe/0" do + test "returns empty when transparency disabled" do + # Transparency is disabled by default in test + assert {:ok, result} = SimplePolicy.describe() + assert result == %{} + end + end + + describe "multiple policies combined" do + test "applies multiple policies from same instance" do + {:ok, _instance} = + %Instance{} + |> Instance.changeset(%{ + domain: "multi.example.com", + media_nsfw: true, + federated_timeline_removal: true + }) + |> Repo.insert() + + activity = %{ + "type" => "Create", + "actor" => "https://multi.example.com/users/test", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => ["https://multi.example.com/users/test/followers"], + "object" => %{ + "type" => "Note", + "content" => "test", + "sensitive" => false + } + } + + assert {:ok, filtered} = SimplePolicy.filter(activity) + + # Should have sensitive marked true + assert filtered["object"]["sensitive"] == true + + # Should have Public moved from to to cc + refute "https://www.w3.org/ns/activitystreams#Public" in filtered["to"] + assert "https://www.w3.org/ns/activitystreams#Public" in filtered["cc"] + end + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/nodeinfo_fetcher_worker_security_test.exs b/apps/elektrine/test/elektrine/activitypub/nodeinfo_fetcher_worker_security_test.exs new file mode 100644 index 0000000..faeeff5 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/nodeinfo_fetcher_worker_security_test.exs @@ -0,0 +1,30 @@ +defmodule Elektrine.ActivityPub.NodeInfoFetcherWorkerSecurityTest do + use ExUnit.Case, async: true + + alias Elektrine.ActivityPub.NodeInfoFetcherWorker + + describe "normalize_favicon_url/2" do + test "rejects absolute private network favicon URLs" do + assert NodeInfoFetcherWorker.normalize_favicon_url( + "http://127.0.0.1/favicon.ico", + "example.com" + ) == nil + end + + test "rejects protocol-relative private network favicon URLs" do + assert NodeInfoFetcherWorker.normalize_favicon_url( + "//169.254.169.254/latest/meta-data/favicon.ico", + "example.com" + ) == nil + end + + test "rejects relative favicon URLs when the instance domain is private" do + assert NodeInfoFetcherWorker.normalize_favicon_url("/favicon.ico", "localhost") == nil + end + + test "normalizes safe relative favicon URLs" do + assert NodeInfoFetcherWorker.normalize_favicon_url("/favicon.ico", "93.184.216.34") == + "https://93.184.216.34/favicon.ico" + end + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/normalizer_test.exs b/apps/elektrine/test/elektrine/activitypub/normalizer_test.exs new file mode 100644 index 0000000..f89c24c --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/normalizer_test.exs @@ -0,0 +1,414 @@ +defmodule Elektrine.ActivityPub.NormalizerTest do + use Elektrine.DataCase, async: true + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Normalizer + + describe "message_payload/3" do + test "normalizes Mastodon-style notes into message attrs" do + actor_uri = "https://remote.example/users/alice" + local_actor_href = "#{ActivityPub.instance_url()}/users/maxfield" + published = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + + object = %{ + "type" => "Note", + "id" => "https://remote.example/users/alice/statuses/1", + "url" => "https://remote.example/@alice/1", + "attributedTo" => actor_uri, + "content" => + ~s(

    Hello @maxfield #Elixir

    ), + "summary" => "spoiler", + "sensitive" => true, + "indexable" => false, + "published" => published, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "likes" => %{"totalItems" => "7"}, + "replies" => %{"totalItems" => 3}, + "shares" => 2, + "attachment" => [ + %{ + "type" => "Document", + "mediaType" => "image/jpeg", + "url" => "https://remote.example/media/photo.jpg", + "name" => "A cat" + } + ], + "tag" => [ + %{ + "type" => "Mention", + "href" => local_actor_href, + "name" => "@maxfield" + }, + %{"type" => "Hashtag", "name" => "#Phoenix"} + ] + } + + payload = Normalizer.message_payload(object, actor_uri) + + assert payload.attrs.content == "Hello @maxfield@#{ActivityPub.instance_domain()} #Elixir" + assert payload.attrs.visibility == "public" + assert payload.attrs.activitypub_id == object["id"] + assert payload.attrs.activitypub_url == object["url"] + assert payload.attrs.like_count == 7 + assert payload.attrs.reply_count == 3 + assert payload.attrs.share_count == 2 + assert payload.attrs.sensitive == true + assert payload.attrs.content_warning == "spoiler" + assert payload.attrs.media_metadata["indexable"] == false + assert payload.attrs.media_urls == ["https://remote.example/media/photo.jpg"] + assert get_in(payload.attrs.media_metadata, ["alt_texts", "0"]) == "A cat" + assert payload.hashtags == ["phoenix", "elixir"] + assert payload.mentioned_local_users == ["maxfield"] + end + + test "normalizes Lemmy-style link and community metadata" do + actor_uri = "https://lemmy.example/u/alice" + + object = %{ + "type" => "Page", + "id" => "https://lemmy.example/post/123", + "attributedTo" => actor_uri, + "name" => "

    Useful Elixir link

    ", + "content" => "

    #ActivityPub discussion

    ", + "published" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "audience" => "https://lemmy.example/c/elixir", + "upvotes" => 12, + "downvotes" => 2, + "score" => 10, + "attachment" => [ + %{ + "type" => "Link", + "href" => "https://example.com/elixir" + } + ] + } + + payload = Normalizer.message_payload(object, actor_uri) + + assert payload.attrs.title == "Useful Elixir link" + assert payload.attrs.primary_url == "https://example.com/elixir" + assert payload.attrs.upvotes == 12 + assert payload.attrs.downvotes == 2 + assert payload.attrs.score == 10 + assert payload.attrs.media_metadata["external_link"] == "https://example.com/elixir" + + assert payload.attrs.media_metadata["community_actor_uri"] == + "https://lemmy.example/c/elixir" + + assert payload.hashtags == ["activitypub"] + end + + test "drops unsafe submitted links from ActivityPub metadata" do + actor_uri = "https://lemmy.example/u/alice" + + object = %{ + "type" => "Page", + "id" => "https://lemmy.example/post/unsafe", + "attributedTo" => actor_uri, + "name" => "Unsafe link", + "content" => "unsafe", + "published" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "attachment" => [ + %{ + "type" => "Link", + "href" => "javascript:alert(1)" + } + ] + } + + payload = Normalizer.message_payload(object, actor_uri) + + refute Map.has_key?(payload.attrs.media_metadata, "external_link") + end + + test "does not treat a microblog post's own permalink as a submitted link" do + actor_uri = "https://mastodon.example/users/dansup" + + object = %{ + "type" => "Note", + "id" => "https://mastodon.example/users/dansup/statuses/123", + # Mastodon exposes the same post under a different path on the same host. + "url" => "https://mastodon.example/@dansup/123", + "attributedTo" => actor_uri, + "content" => "

    just a reply, no links here

    ", + "published" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "to" => ["https://www.w3.org/ns/activitystreams#Public"] + } + + payload = Normalizer.message_payload(object, actor_uri) + + assert payload.attrs.primary_url == nil + refute Map.has_key?(payload.attrs.media_metadata, "external_link") + end + end + + describe "question_payload/3" do + test "normalizes Question polls into poll payloads" do + actor_uri = "https://remote.example/users/pollster" + + object = %{ + "type" => "Question", + "id" => "https://remote.example/questions/1", + "attributedTo" => actor_uri, + "name" => "

    Pick one #Polls

    ", + "content" => "", + "published" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "oneOf" => [ + %{"name" => "One", "replies" => %{"totalItems" => "4"}}, + %{"name" => "Two", "replies" => %{"totalItems" => 2}} + ], + "votersCount" => "6" + } + + payload = Normalizer.question_payload(object, actor_uri) + + assert payload.attrs.post_type == "poll" + assert payload.question == "Pick one #Polls" + assert payload.hashtags == ["polls"] + + assert payload.options == [ + %{text: "One", votes: 4, position: 0}, + %{text: "Two", votes: 2, position: 1} + ] + end + end + + describe "actor reference normalization" do + test "extracts actor URIs from PeerTube attributedTo lists" do + account_uri = "https://tilvids.com/accounts/thelinuxexperiment" + channel_uri = "https://tilvids.com/video-channels/thelinuxexperiment_channel" + + object = %{ + "attributedTo" => [ + %{"id" => account_uri, "type" => "Person"}, + %{"id" => channel_uri, "type" => "Group"} + ] + } + + assert Normalizer.actor_uri(object) == account_uri + assert Normalizer.actor_ref_uri(object["attributedTo"]) == account_uri + end + + test "validates signed actors contained in attributedTo lists" do + account_uri = "https://tilvids.com/accounts/thelinuxexperiment" + channel_uri = "https://tilvids.com/video-channels/thelinuxexperiment_channel" + + object = %{ + "attributedTo" => [ + %{"id" => account_uri, "type" => "Person"}, + %{"id" => channel_uri, "type" => "Group"} + ] + } + + assert :ok = Normalizer.validate_object_author(object, channel_uri) + end + end + + describe "platform fixture normalization" do + test "normalizes Misskey reactions and quote metadata" do + actor_uri = "https://misskey.example/users/alice" + + object = + note_fixture(actor_uri, %{ + "id" => "https://misskey.example/notes/abc123", + "content" => "

    Misskey note

    ", + "reactionCount" => 5, + "reactions" => %{"thumbs_up" => 3, ":blobcat:" => "2", ":zero:" => 0}, + "renoteCount" => 4, + "repliesCount" => 6, + "renoteId" => "renote-1", + "_misskey_quote" => "https://misskey.example/notes/quoted" + }) + + payload = Normalizer.message_payload(object, actor_uri) + + assert payload.attrs.reply_count == 6 + assert payload.attrs.media_metadata["quote_url"] == "https://misskey.example/notes/quoted" + assert payload.attrs.media_metadata["quote_id"] == "renote-1" + assert payload.attrs.media_metadata["misskey"]["reactionCount"] == 5 + + assert Enum.sort_by(payload.attrs.media_metadata["emoji_reactions"], & &1["name"]) == [ + %{"name" => ":blobcat:", "count" => 2}, + %{"name" => "thumbs_up", "count" => 3} + ] + end + + test "normalizes Pleroma/Akkoma counts and metadata" do + actor_uri = "https://akkoma.example/users/alice" + + object = + note_fixture(actor_uri, %{ + "id" => "https://akkoma.example/objects/1", + "content" => "

    Pleroma note

    ", + "like_count" => "9", + "announcement_count" => 4, + "pleroma" => %{ + "emoji_reactions" => [%{"name" => "heart", "count" => 2}], + "quotes_count" => "3", + "quote_url" => "https://akkoma.example/objects/quoted" + } + }) + + payload = Normalizer.message_payload(object, actor_uri) + + assert payload.attrs.like_count == 9 + assert payload.attrs.share_count == 4 + assert payload.attrs.quote_count == 3 + + assert payload.attrs.media_metadata["emoji_reactions"] == [ + %{"name" => "heart", "count" => 2} + ] + + assert payload.attrs.media_metadata["quote_url"] == "https://akkoma.example/objects/quoted" + end + + test "normalizes Pixelfed media attachments" do + actor_uri = "https://pixelfed.example/users/alice" + + object = + note_fixture(actor_uri, %{ + "id" => "https://pixelfed.example/p/alice/1", + "content" => "

    Photo post #Photography

    ", + "attachment" => %{ + "type" => "Document", + "mediaType" => "image/jpeg", + "url" => "https://pixelfed.example/storage/m/photo.jpg", + "name" => "A skyline" + } + }) + + payload = Normalizer.message_payload(object, actor_uri) + + assert payload.attrs.media_urls == ["https://pixelfed.example/storage/m/photo.jpg"] + + assert get_in(payload.attrs.media_metadata, ["media_attachments", Access.at(0), "type"]) == + "image" + + assert get_in(payload.attrs.media_metadata, ["alt_texts", "0"]) == "A skyline" + assert payload.hashtags == ["photography"] + end + + test "normalizes PeerTube video attachments" do + actor_uri = "https://peertube.example/accounts/alice" + + object = + note_fixture(actor_uri, %{ + "type" => "Video", + "id" => "https://peertube.example/videos/watch/1", + "name" => "

    Federation talk

    ", + "content" => "

    PeerTube video

    ", + "attachment" => [ + %{ + "type" => "Document", + "mediaType" => "video/mp4", + "url" => "https://peertube.example/static/webseed/video.mp4", + "name" => "Video file" + } + ], + "language" => "en" + }) + + payload = Normalizer.message_payload(object, actor_uri) + + assert payload.attrs.title == "Federation talk" + assert payload.attrs.media_urls == ["https://peertube.example/static/webseed/video.mp4"] + + assert get_in(payload.attrs.media_metadata, ["media_attachments", Access.at(0), "type"]) == + "video" + + assert payload.attrs.media_metadata["language"] == "en" + end + + test "normalizes PeerTube video links from url arrays" do + actor_uri = "https://peertube.example/accounts/alice" + + object = + note_fixture(actor_uri, %{ + "type" => "Video", + "id" => "https://peertube.example/videos/watch/2", + "name" => "Federation talk", + "content" => "

    PeerTube video

    ", + "duration" => "PT10M", + "icon" => %{ + "type" => "Image", + "mediaType" => "image/jpeg", + "url" => "https://peertube.example/lazy-static/previews/video.jpg" + }, + "url" => [ + %{ + "type" => "Link", + "mediaType" => "text/html", + "href" => "https://peertube.example/w/federation-talk" + }, + %{ + "type" => "Link", + "mediaType" => "video/mp4", + "href" => "https://peertube.example/download/stream", + "width" => 1920, + "height" => 1080 + } + ] + }) + + payload = Normalizer.message_payload(object, actor_uri) + + assert payload.attrs.media_urls == ["https://peertube.example/download/stream"] + assert payload.attrs.media_metadata["type"] == "Video" + assert payload.attrs.media_metadata["duration"] == "PT10M" + + assert payload.attrs.media_metadata["thumbnail_url"] == + "https://peertube.example/lazy-static/previews/video.jpg" + + attachment = get_in(payload.attrs.media_metadata, ["media_attachments", Access.at(0)]) + assert attachment["type"] == "video" + assert attachment["mediaType"] == "video/mp4" + + assert attachment["preview_url"] == + "https://peertube.example/lazy-static/previews/video.jpg" + + assert attachment["width"] == 1920 + assert attachment["height"] == 1080 + end + + test "normalizes GoToSocial style counters" do + actor_uri = "https://gotosocial.example/users/alice" + + object = + note_fixture(actor_uri, %{ + "id" => "https://gotosocial.example/users/alice/statuses/1", + "content" => "

    GoToSocial note

    ", + "favourites_count" => "11", + "reblogs_count" => "5", + "replies_count" => 7, + "url" => "https://gotosocial.example/@alice/statuses/1" + }) + + payload = Normalizer.message_payload(object, actor_uri) + + assert payload.attrs.like_count == 11 + assert payload.attrs.share_count == 5 + assert payload.attrs.reply_count == 7 + assert payload.attrs.activitypub_url == "https://gotosocial.example/@alice/statuses/1" + end + end + + defp note_fixture(actor_uri, overrides) do + Map.merge( + %{ + "type" => "Note", + "id" => "https://remote.example/objects/#{System.unique_integer([:positive])}", + "attributedTo" => actor_uri, + "content" => "

    Hello

    ", + "published" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + }, + overrides + ) + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/object_validator_test.exs b/apps/elektrine/test/elektrine/activitypub/object_validator_test.exs new file mode 100644 index 0000000..81e259d --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/object_validator_test.exs @@ -0,0 +1,214 @@ +defmodule Elektrine.ActivityPub.ObjectValidatorTest do + use ExUnit.Case, async: true + + alias Elektrine.ActivityPub.ObjectValidator + + describe "validate/1 announce object lists" do + test "accepts announce with object list of URIs/maps" do + activity = %{ + "id" => "https://remote.example/activities/1", + "type" => "Announce", + "actor" => "https://remote.example/users/alice", + "object" => [ + "https://remote.example/notes/1", + %{"id" => "https://remote.example/notes/2", "type" => "Note"} + ] + } + + assert {:ok, _} = ObjectValidator.validate(activity) + end + + test "rejects announce with invalid object list entries" do + activity = %{ + "id" => "https://remote.example/activities/2", + "type" => "Announce", + "actor" => "https://remote.example/users/alice", + "object" => [ + "https://remote.example/notes/1", + 123 + ] + } + + assert {:error, "Announce activity has invalid object list"} = + ObjectValidator.validate(activity) + end + end + + describe "validate/1 move activities" do + test "accepts valid move activity" do + activity = %{ + "id" => "https://old.example/activities/move/1", + "type" => "Move", + "actor" => "https://old.example/users/alice", + "object" => "https://old.example/users/alice", + "target" => "https://new.example/users/alice" + } + + assert {:ok, _} = ObjectValidator.validate(activity) + end + + test "rejects move activity missing target" do + activity = %{ + "id" => "https://old.example/activities/move/2", + "type" => "Move", + "actor" => "https://old.example/users/alice", + "object" => "https://old.example/users/alice" + } + + assert {:error, "Move activity missing target"} = ObjectValidator.validate(activity) + end + end + + describe "validate/1 actor host safety" do + test "rejects actor URIs pointing at private hosts" do + activity = %{ + "id" => "https://remote.example/activities/3", + "type" => "Follow", + "actor" => "http://127.0.0.1/users/alice", + "object" => "https://remote.example/users/bob" + } + + assert {:error, "Invalid actor URI"} = ObjectValidator.validate(activity) + end + end + + describe "validate/1 embedded object guardrails" do + test "rejects Create when object actor differs from activity actor" do + activity = + create_note_activity(%{ + "actor" => "https://remote.example/users/alice", + "object" => %{ + "id" => "https://evil.example/notes/#{System.unique_integer([:positive])}", + "actor" => "https://evil.example/users/mallory", + "attributedTo" => "https://evil.example/users/mallory" + } + }) + + assert {:error, "Object actor does not match activity actor"} = + ObjectValidator.validate(activity) + end + + test "rejects Create when object addressing conflicts with activity addressing" do + activity = + create_note_activity(%{ + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "object" => %{"to" => ["https://remote.example/users/bob"]} + }) + + assert {:error, "Object to does not match activity to"} = + ObjectValidator.validate(activity) + end + + test "normalizes supported quote URL variants and attachment maps" do + activity = + create_note_activity(%{ + "object" => %{ + "quoteUri" => "https://remote.example/notes/quoted", + "attachment" => %{ + "type" => "Image", + "mediaType" => "image/png", + "url" => "https://cdn.example/image.png" + } + } + }) + + assert {:ok, validated} = ObjectValidator.validate(activity) + assert validated["object"]["quoteUrl"] == "https://remote.example/notes/quoted" + assert [%{"type" => "Image"}] = validated["object"]["attachment"] + end + + test "rejects unsafe attachment URLs" do + activity = + create_note_activity(%{ + "object" => %{ + "attachment" => [ + %{"type" => "Image", "mediaType" => "image/png", "url" => "http://127.0.0.1/x.png"} + ] + } + }) + + assert {:error, "Content object has invalid attachment"} = + ObjectValidator.validate(activity) + end + + test "rejects invalid mention and emoji tags" do + mention_activity = + create_note_activity(%{ + "object" => %{ + "tag" => [%{"type" => "Mention", "href" => "http://127.0.0.1/users/bob"}] + } + }) + + assert {:error, "Content object has invalid tag"} = + ObjectValidator.validate(mention_activity) + + emoji_activity = + create_note_activity(%{ + "object" => %{ + "tag" => [%{"type" => "Emoji", "name" => ":bad:", "icon" => %{"url" => "notaurl"}}] + } + }) + + assert {:error, "Content object has invalid tag"} = + ObjectValidator.validate(emoji_activity) + end + + test "validates Question poll options" do + valid = + create_note_activity(%{ + "object" => %{ + "type" => "Question", + "content" => "Pick one", + "oneOf" => [ + %{"type" => "Note", "name" => "Yes"}, + %{"type" => "Note", "name" => "No"} + ] + } + }) + + assert {:ok, _} = ObjectValidator.validate(valid) + + invalid = + create_note_activity(%{ + "object" => %{ + "type" => "Question", + "content" => "Pick one", + "oneOf" => [%{"type" => "Note", "name" => ""}] + } + }) + + assert {:error, "Question object has invalid options"} = + ObjectValidator.validate(invalid) + end + end + + defp create_note_activity(overrides) do + actor = Map.get(overrides, "actor", "https://remote.example/users/alice") + to = Map.get(overrides, "to", ["https://www.w3.org/ns/activitystreams#Public"]) + cc = Map.get(overrides, "cc", []) + object_overrides = Map.get(overrides, "object", %{}) + + object = + Map.merge( + %{ + "id" => "https://remote.example/notes/#{System.unique_integer([:positive])}", + "type" => "Note", + "actor" => actor, + "attributedTo" => actor, + "content" => "hello", + "to" => to, + "cc" => cc + }, + object_overrides + ) + + %{ + "id" => "https://remote.example/activities/#{System.unique_integer([:positive])}", + "type" => "Create", + "actor" => actor, + "to" => to, + "cc" => cc, + "object" => object + } + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/outbox_test.exs b/apps/elektrine/test/elektrine/activitypub/outbox_test.exs new file mode 100644 index 0000000..55dfa67 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/outbox_test.exs @@ -0,0 +1,834 @@ +defmodule Elektrine.ActivityPub.OutboxTest do + use Elektrine.DataCase, async: true + + import Ecto.Query + + alias Elektrine.AccountsFixtures + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.{Actor, Builder, Delivery, Outbox} + alias Elektrine.Messaging + alias Elektrine.Social + alias Elektrine.SocialFixtures + + # Create a mock user struct for testing builders + defp mock_user(username \\ "testuser") do + %Elektrine.Accounts.User{ + id: 1, + username: username, + activitypub_enabled: true + } + end + + defp mock_message(attrs) do + defaults = %{ + id: 1, + content: "Test post", + inserted_at: DateTime.utc_now() |> DateTime.truncate(:second), + visibility: "public", + message_type: "text", + post_type: "post", + extracted_hashtags: [], + media_urls: [], + media_metadata: %{}, + sensitive: false + } + + struct(Elektrine.Social.Message, Map.merge(defaults, attrs)) + end + + describe "EmojiReact activity builder" do + test "builds correct EmojiReact activity structure" do + user = mock_user() + message_id = "https://remote.server/posts/123" + + activity = Builder.build_emoji_react_activity(user, message_id, ":thumbsup:") + + assert activity["type"] == "EmojiReact" + assert activity["object"] == message_id + assert activity["content"] == ":thumbsup:" + assert String.contains?(activity["actor"], user.username) + assert activity["@context"] == "https://www.w3.org/ns/activitystreams" + assert String.starts_with?(activity["id"], "https://") + end + + test "supports custom emoji with different content" do + user = mock_user() + message_id = "https://remote.server/posts/456" + + activity = Builder.build_emoji_react_activity(user, message_id, ":blobcat:") + + assert activity["content"] == ":blobcat:" + end + end + + describe "Undo Announce activity builder" do + test "builds correct Undo Announce activity structure" do + user = mock_user("boostuser") + message_id = "https://remote.server/posts/456" + + announce = Builder.build_announce_activity(user, message_id) + undo = Builder.build_undo_activity(user, announce) + + assert undo["type"] == "Undo" + assert undo["object"]["type"] == "Announce" + assert undo["object"]["object"] == message_id + assert String.contains?(undo["actor"], user.username) + end + end + + describe "Note builder" do + test "serializes followers-only posts to the followers collection" do + user = mock_user("followersonly") + message = mock_message(%{visibility: "followers"}) + + note = Builder.build_note(message, user) + + assert note["to"] == [ + "#{Elektrine.ActivityPub.instance_url()}/users/#{user.username}/followers" + ] + + refute "https://www.w3.org/ns/activitystreams#Public" in note["to"] + assert note["indexable"] == false + end + + test "marks public posts as indexable" do + user = mock_user("publicuser") + message = mock_message(%{visibility: "public"}) + + note = Builder.build_note(message, user) + + assert note["indexable"] == true + end + end + + describe "community note builder" do + test "includes attachments and ActivityPub hashtag links" do + previous_uploads_config = Application.get_env(:elektrine, :uploads) + + on_exit(fn -> + Application.put_env(:elektrine, :uploads, previous_uploads_config) + end) + + Application.put_env(:elektrine, :uploads, public_url: "https://uploads.example") + + author = mock_user("communityauthor") + community = %Elektrine.Social.Conversation{name: "fedigroup"} + + message = + mock_message(%{ + id: 123, + content: "Hello #fediverse", + sender: author, + extracted_hashtags: ["fediverse"], + media_urls: ["/uploads/test-image.jpg"], + media_metadata: %{"alt_texts" => %{"0" => "Preview image"}} + }) + + note = Builder.build_community_note(message, community) + + assert [%{"url" => attachment_url, "name" => "Preview image"}] = note["attachment"] + assert String.ends_with?(attachment_url, "/uploads/test-image.jpg") + + assert Enum.any?(note["tag"], fn tag -> + tag["type"] == "Hashtag" and + tag["href"] == "#{ActivityPub.instance_url()}/tags/fediverse" + end) + end + end + + describe "Flag (report) activity builder" do + test "builds correct Flag activity structure with content" do + user = mock_user("reporter") + target_uri = "https://remote.server/users/baduser" + content_uris = ["https://remote.server/posts/spam1", "https://remote.server/posts/spam2"] + reason = "This user is posting spam" + + flag = Builder.build_flag_activity(user, target_uri, content_uris, reason) + + assert flag["type"] == "Flag" + assert flag["@context"] == "https://www.w3.org/ns/activitystreams" + assert String.contains?(flag["actor"], user.username) + assert target_uri in flag["object"] + assert Enum.all?(content_uris, fn uri -> uri in flag["object"] end) + assert flag["content"] == reason + end + + test "Flag activity without content has no content field" do + user = mock_user("reporter") + target_uri = "https://remote.server/users/baduser" + + flag = Builder.build_flag_activity(user, target_uri, [], nil) + + assert flag["type"] == "Flag" + refute Map.has_key?(flag, "content") + end + + test "Flag activity with empty content has no content field" do + user = mock_user("reporter") + target_uri = "https://remote.server/users/baduser" + + flag = Builder.build_flag_activity(user, target_uri, [], "") + + assert flag["type"] == "Flag" + refute Map.has_key?(flag, "content") + end + + test "Flag activity deduplicates object URIs" do + user = mock_user("reporter") + target_uri = "https://remote.server/users/baduser" + + flag = Builder.build_flag_activity(user, target_uri, [target_uri], "reason") + + # Should only have target_uri once + assert Enum.count(flag["object"], fn uri -> uri == target_uri end) == 1 + end + end + + describe "undo federation" do + test "uses the stored Like activity id when federating unlike" do + user = AccountsFixtures.user_fixture() + remote_actor = remote_actor_fixture("undo-like") + message = remote_message_fixture(remote_actor) + original_id = "https://example.test/activities/#{Ecto.UUID.generate()}-like" + + {:ok, _activity} = + ActivityPub.create_activity(%{ + activity_id: original_id, + activity_type: "Like", + actor_uri: "#{ActivityPub.instance_url()}/users/#{user.username}", + object_id: message.activitypub_id, + data: %{ + "id" => original_id, + "type" => "Like", + "actor" => "#{ActivityPub.instance_url()}/users/#{user.username}", + "object" => message.activitypub_id + }, + local: true, + internal_user_id: user.id + }) + + assert :ok = Outbox.federate_unlike(message.id, user.id) + + assert %{data: %{"object" => %{"id" => ^original_id, "type" => "Like"}}} = + Repo.get_by!(ActivityPub.Activity, + internal_user_id: user.id, + activity_type: "Undo", + object_id: original_id + ) + end + + test "uses the stored Dislike activity id when federating undo dislike" do + user = AccountsFixtures.user_fixture() + remote_actor = remote_actor_fixture("undo-dislike") + message = remote_message_fixture(remote_actor) + original_id = "https://example.test/activities/#{Ecto.UUID.generate()}-dislike" + + {:ok, _activity} = + ActivityPub.create_activity(%{ + activity_id: original_id, + activity_type: "Dislike", + actor_uri: "#{ActivityPub.instance_url()}/users/#{user.username}", + object_id: message.activitypub_id, + data: %{ + "id" => original_id, + "type" => "Dislike", + "actor" => "#{ActivityPub.instance_url()}/users/#{user.username}", + "object" => message.activitypub_id + }, + local: true, + internal_user_id: user.id + }) + + assert :ok = Outbox.federate_undo_dislike(message.id, user.id) + + assert %{data: %{"object" => %{"id" => ^original_id, "type" => "Dislike"}}} = + Repo.get_by!(ActivityPub.Activity, + internal_user_id: user.id, + activity_type: "Undo", + object_id: original_id + ) + end + + test "uses the stored Announce activity id when federating undo announce" do + user = AccountsFixtures.user_fixture() + remote_actor = remote_actor_fixture("undo-announce") + message = remote_message_fixture(remote_actor) + original_id = "https://example.test/activities/#{Ecto.UUID.generate()}-announce" + + {:ok, _activity} = + ActivityPub.create_activity(%{ + activity_id: original_id, + activity_type: "Announce", + actor_uri: "#{ActivityPub.instance_url()}/users/#{user.username}", + object_id: message.activitypub_id, + data: %{ + "id" => original_id, + "type" => "Announce", + "actor" => "#{ActivityPub.instance_url()}/users/#{user.username}", + "object" => message.activitypub_id, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => ["#{ActivityPub.instance_url()}/users/#{user.username}/followers"] + }, + local: true, + internal_user_id: user.id + }) + + assert :ok = Outbox.federate_undo_announce(message.id, user.id) + + assert %{data: %{"object" => %{"id" => ^original_id, "type" => "Announce"}}} = + Repo.get_by!(ActivityPub.Activity, + internal_user_id: user.id, + activity_type: "Undo", + object_id: original_id + ) + end + + test "uses the stored EmojiReact activity id when federating undo emoji react" do + user = AccountsFixtures.user_fixture() + remote_actor = remote_actor_fixture("undo-emoji") + message = remote_message_fixture(remote_actor) + original_id = "https://example.test/activities/#{Ecto.UUID.generate()}-emoji" + + {:ok, _activity} = + ActivityPub.create_activity(%{ + activity_id: original_id, + activity_type: "EmojiReact", + actor_uri: "#{ActivityPub.instance_url()}/users/#{user.username}", + object_id: message.activitypub_id, + data: %{ + "id" => original_id, + "type" => "EmojiReact", + "actor" => "#{ActivityPub.instance_url()}/users/#{user.username}", + "object" => message.activitypub_id, + "content" => ":blobcat:" + }, + local: true, + internal_user_id: user.id + }) + + assert :ok = Outbox.federate_undo_emoji_react(message.id, user.id, ":blobcat:") + + assert %{data: %{"object" => %{"id" => ^original_id, "type" => "EmojiReact"}}} = + Repo.get_by!(ActivityPub.Activity, + internal_user_id: user.id, + activity_type: "Undo", + object_id: original_id + ) + end + end + + describe "update/delete federation" do + test "reuses the original Create delivery inboxes for updates and deletes" do + user = AccountsFixtures.user_fixture() + message = local_post_fixture(user) + create_activity = create_local_create_activity(user, message.activitypub_id) + + original_inboxes = [ + "https://followers.example/inbox", + "https://relay.example/inbox", + "https://community.example/inbox" + ] + + ActivityPub.create_deliveries(create_activity.id, original_inboxes) + + assert :ok = Outbox.federate_update(message) + assert :ok = Outbox.federate_delete(message) + + assert_delivery_inboxes("Update", message.activitypub_id, user.id, original_inboxes) + assert_delivery_inboxes("Delete", message.activitypub_id, user.id, original_inboxes) + end + + test "preserves followers-only audience on Update and Delete wrappers" do + user = AccountsFixtures.user_fixture() + message = local_post_fixture(user, visibility: "followers") + followers_url = "#{ActivityPub.instance_url()}/users/#{user.username}/followers" + + create_activity = + create_local_create_activity(user, message.activitypub_id, + to: [followers_url], + cc: [] + ) + + ActivityPub.create_deliveries(create_activity.id, ["https://followers.example/inbox"]) + + assert :ok = Outbox.federate_update(message) + assert :ok = Outbox.federate_delete(message) + + assert %{data: update_data} = + Repo.get_by!(ActivityPub.Activity, + internal_user_id: user.id, + activity_type: "Update", + object_id: message.activitypub_id + ) + + assert %{data: delete_data} = + Repo.get_by!(ActivityPub.Activity, + internal_user_id: user.id, + activity_type: "Delete", + object_id: message.activitypub_id + ) + + assert update_data["to"] == [followers_url] + assert Map.get(update_data, "cc", []) == [] + assert delete_data["to"] == [followers_url] + assert Map.get(delete_data, "cc", []) == [] + + refute "https://www.w3.org/ns/activitystreams#Public" in update_data["to"] + refute "https://www.w3.org/ns/activitystreams#Public" in delete_data["to"] + end + + test "reuses the original community Create delivery inboxes and actor for updates and deletes" do + owner = AccountsFixtures.user_fixture() + community = SocialFixtures.community_conversation_fixture(owner) + {:ok, community_actor} = ActivityPub.get_or_create_community_actor(community.id) + message = local_community_post_fixture(owner, community) + + create_activity = + create_local_community_create_activity(message, community, community_actor) + + original_inboxes = [ + "https://community-followers.example/inbox", + "https://relay.example/inbox", + "https://mentioned.example/inbox" + ] + + ActivityPub.create_deliveries(create_activity.id, original_inboxes) + + assert :ok = Outbox.federate_update(message) + assert :ok = Outbox.federate_delete(message) + + assert_delivery_inboxes_for_actor( + "Update", + message.activitypub_id, + community_actor.uri, + original_inboxes + ) + + assert_delivery_inboxes_for_actor( + "Delete", + message.activitypub_id, + community_actor.uri, + original_inboxes + ) + + community_actor_uri = community_actor.uri + + assert %{data: %{"actor" => ^community_actor_uri}} = + Repo.get_by!(ActivityPub.Activity, + actor_uri: community_actor.uri, + activity_type: "Update", + object_id: message.activitypub_id + ) + + assert %{data: %{"actor" => ^community_actor_uri}} = + Repo.get_by!(ActivityPub.Activity, + actor_uri: community_actor.uri, + activity_type: "Delete", + object_id: message.activitypub_id + ) + end + end + + describe "community federation" do + test "includes mentioned remote actors in local community post delivery and audience" do + owner = AccountsFixtures.user_fixture() + community = SocialFixtures.community_conversation_fixture(owner) + unique_domain = "communitymention#{System.unique_integer([:positive])}.remote.test" + + remote_actor = + remote_actor_fixture("communitymention", + domain: unique_domain, + uri: "https://#{unique_domain}/users/communitymention", + inbox_url: "https://#{unique_domain}/users/communitymention/inbox" + ) + + acct = "#{remote_actor.username}@#{remote_actor.domain}" + remote_actor_uri = remote_actor.uri + + assert {:ok, ^remote_actor_uri} = + Elektrine.AppCache.get_webfinger(acct, fn -> {:ok, remote_actor.uri} end) + + message = + SocialFixtures.discussion_post_fixture(%{ + user: owner, + community: community, + content: "Hello @#{remote_actor.username}@#{remote_actor.domain}" + }) + + assert :ok = Outbox.federate_community_post(message, community) + + {:ok, community_actor} = ActivityPub.get_or_create_community_actor(community.id) + + activity = + Repo.get_by!(ActivityPub.Activity, + actor_uri: community_actor.uri, + activity_type: "Create", + object_id: ActivityPub.community_post_uri(community.name, message.id) + ) + + deliveries = + Delivery + |> where([d], d.activity_id == ^activity.id) + |> select([d], d.inbox_url) + |> Repo.all() + + assert remote_actor.inbox_url in deliveries + assert remote_actor.uri in activity.data["cc"] + assert remote_actor.uri in activity.data["object"]["cc"] + end + + test "preserves mirrored remote-group routing on updates and reuses mirrored deliveries" do + owner = AccountsFixtures.user_fixture() + remote_group = remote_group_actor_fixture("mirrorgroup") + + community = + owner + |> SocialFixtures.community_conversation_fixture() + |> Ecto.Changeset.change( + is_federated_mirror: true, + remote_group_actor_id: remote_group.id, + federated_source: remote_group.uri + ) + |> Repo.update!() + + message = SocialFixtures.discussion_post_fixture(%{user: owner, community: community}) + + assert :ok = Outbox.federate_community_post(message, community) + + reloaded_message = + Messaging.get_message(message.id) + |> Repo.preload([:sender, :conversation]) + + edited_message = + reloaded_message + |> Ecto.Changeset.change(content: "Edited mirrored post") + |> Repo.update!() + |> Repo.preload([:sender, :conversation]) + + assert :ok = Outbox.federate_update(edited_message) + assert :ok = Outbox.federate_delete(edited_message) + + base_url = ActivityPub.instance_url() + + assert %{data: update_data} = + Repo.get_by!(ActivityPub.Activity, + internal_user_id: owner.id, + activity_type: "Update", + object_id: edited_message.activitypub_id + ) + + assert update_data["actor"] == "#{base_url}/users/#{owner.username}" + assert update_data["to"] == ["https://www.w3.org/ns/activitystreams#Public"] + assert update_data["cc"] == [remote_group.uri] + assert update_data["object"]["to"] == ["https://www.w3.org/ns/activitystreams#Public"] + assert update_data["object"]["cc"] == [remote_group.uri] + assert update_data["object"]["audience"] == remote_group.uri + assert update_data["object"]["context"] == remote_group.uri + + assert_delivery_inboxes("Update", edited_message.activitypub_id, owner.id, [ + remote_group.inbox_url + ]) + + assert_delivery_inboxes("Delete", edited_message.activitypub_id, owner.id, [ + remote_group.inbox_url + ]) + end + end + + describe "poll vote federation" do + test "publishes remote poll votes as Create activities to the remote author inbox" do + voter = AccountsFixtures.user_fixture() + remote_actor = remote_actor_fixture("pollauthor") + remote_message = remote_message_fixture(remote_actor) + + {:ok, poll} = Social.create_poll(remote_message.id, "Pick one", ["Alpha", "Beta"]) + poll = Repo.preload(poll, :options) + option = hd(poll.options) + + assert :ok = + Outbox.federate_poll_vote( + poll, + option, + voter, + Repo.preload(remote_message, :remote_actor) + ) + + object_id = + "#{ActivityPub.instance_url()}/users/#{voter.username}/votes/#{poll.id}/#{option.id}" + + activity = + Repo.get_by!(ActivityPub.Activity, + internal_user_id: voter.id, + activity_type: "Create", + object_id: object_id + ) + + assert activity.data["object"]["type"] == "Note" + assert activity.data["object"]["name"] == option.option_text + assert activity.data["object"]["inReplyTo"] == remote_message.activitypub_id + assert activity.data["to"] == [remote_actor.uri] + assert_delivery_inboxes("Create", object_id, voter.id, [remote_actor.inbox_url]) + end + + test "sends direct remote poll votes to the target inbox" do + voter = AccountsFixtures.user_fixture() + remote_actor = remote_actor_fixture("directpoll") + poll_id = "https://remote.example/questions/#{System.unique_integer([:positive])}" + + assert :ok = Outbox.send_poll_vote(voter, poll_id, "Option A", remote_actor) + + activity = + ActivityPub.Activity + |> where([a], a.internal_user_id == ^voter.id and a.activity_type == "Create") + |> order_by([a], desc: a.inserted_at, desc: a.id) + |> limit(1) + |> Repo.one!() + + assert activity.data["object"]["type"] == "Note" + assert activity.data["object"]["name"] == "Option A" + assert activity.data["object"]["inReplyTo"] == poll_id + assert activity.data["to"] == [remote_actor.uri] + + actual_inboxes = + Delivery + |> where([d], d.activity_id == ^activity.id) + |> select([d], d.inbox_url) + |> Repo.all() + + assert actual_inboxes == [remote_actor.inbox_url] + end + end + + describe "report federation" do + test "prefers the cached shared inbox when federating a report" do + reporter = AccountsFixtures.user_fixture() + + target_actor = + remote_actor_fixture("reported", + metadata: %{"endpoints" => %{"sharedInbox" => "https://remote.example/inbox"}} + ) + + assert :ok = + Outbox.federate_report( + reporter.id, + target_actor.uri, + ["https://remote.example/posts/spam"], + "Spam" + ) + + activity = + ActivityPub.Activity + |> where([a], a.internal_user_id == ^reporter.id and a.activity_type == "Flag") + |> order_by([a], desc: a.inserted_at, desc: a.id) + |> limit(1) + |> Repo.one!() + + assert activity.data["actor"] == "#{ActivityPub.instance_url()}/users/#{reporter.username}" + + actual_inboxes = + Delivery + |> where([d], d.activity_id == ^activity.id) + |> select([d], d.inbox_url) + |> Repo.all() + + assert actual_inboxes == ["https://remote.example/inbox"] + end + + test "preserves a non-default port when falling back to an instance inbox" do + reporter = AccountsFixtures.user_fixture() + target_actor_uri = "https://reports.example:8443/users/spammer" + + assert :ok = Outbox.federate_report(reporter.id, target_actor_uri, [], "Abuse") + + activity = + ActivityPub.Activity + |> where([a], a.internal_user_id == ^reporter.id and a.activity_type == "Flag") + |> order_by([a], desc: a.inserted_at, desc: a.id) + |> limit(1) + |> Repo.one!() + + actual_inboxes = + Delivery + |> where([d], d.activity_id == ^activity.id) + |> select([d], d.inbox_url) + |> Repo.all() + + assert actual_inboxes == ["https://reports.example:8443/inbox"] + end + end + + defp remote_actor_fixture(label, attrs \\ []) do + unique = System.unique_integer([:positive]) + username = "#{label}#{unique}" + uri = "https://remote.example/users/#{username}" + + %Actor{} + |> Actor.changeset( + attrs + |> Enum.into(%{ + uri: uri, + username: username, + domain: "remote.example", + inbox_url: "https://remote.example/users/#{username}/inbox", + public_key: "-----BEGIN PUBLIC KEY-----test-key-----END PUBLIC KEY-----", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + ) + |> Repo.insert!() + end + + defp remote_group_actor_fixture(label) do + remote_actor_fixture(label, + actor_type: "Group", + uri: "https://remote.example/c/#{label}", + username: label, + inbox_url: "https://remote.example/c/#{label}/inbox" + ) + end + + defp remote_message_fixture(remote_actor) do + unique = System.unique_integer([:positive]) + + Messaging.create_federated_message(%{ + content: "Remote target #{unique}", + visibility: "public", + activitypub_id: "https://remote.example/objects/#{unique}", + activitypub_url: "https://remote.example/objects/#{unique}", + federated: true, + remote_actor_id: remote_actor.id, + inserted_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> then(fn {:ok, message} -> message end) + end + + defp local_post_fixture(user, attrs \\ []) do + attrs = Enum.into(attrs, %{}) + visibility = Map.get(attrs, :visibility, "public") + + message = + SocialFixtures.post_fixture(%{ + user: user, + visibility: visibility, + content: "Local post #{System.unique_integer([:positive])}" + }) + + object_id = "#{ActivityPub.instance_url()}/users/#{user.username}/statuses/#{message.id}" + + message + |> Ecto.Changeset.change(activitypub_id: object_id, activitypub_url: object_id) + |> Repo.update!() + |> Repo.preload([:sender, :conversation]) + end + + defp create_local_create_activity(user, object_id, opts \\ []) do + to = Keyword.get(opts, :to, ["https://www.w3.org/ns/activitystreams#Public"]) + + cc = + Keyword.get(opts, :cc, ["#{ActivityPub.instance_url()}/users/#{user.username}/followers"]) + + activity_id = "#{object_id}/activity" + actor_uri = "#{ActivityPub.instance_url()}/users/#{user.username}" + + {:ok, activity} = + ActivityPub.create_activity(%{ + activity_id: activity_id, + activity_type: "Create", + actor_uri: actor_uri, + object_id: object_id, + data: %{ + "id" => activity_id, + "type" => "Create", + "actor" => actor_uri, + "to" => to, + "cc" => cc, + "object" => %{ + "id" => object_id, + "type" => "Note", + "to" => to, + "cc" => cc + } + }, + local: true, + internal_user_id: user.id + }) + + activity + end + + defp local_community_post_fixture(user, community, attrs \\ []) do + attrs = Enum.into(attrs, %{}) + + message = + SocialFixtures.discussion_post_fixture(%{ + user: user, + community: community, + content: Map.get(attrs, :content, "Community post #{System.unique_integer([:positive])}") + }) + + object_id = ActivityPub.community_post_uri(community.name, message.id) + + message + |> Ecto.Changeset.change( + activitypub_id: object_id, + activitypub_url: ActivityPub.community_post_web_url(community.name, message.id) + ) + |> Repo.update!() + |> Repo.preload([:sender, :conversation]) + end + + defp create_local_community_create_activity(message, community, community_actor) do + activity_data = + message + |> Builder.build_community_object(community) + |> then(&Builder.build_community_create_activity(message, community, &1)) + + {:ok, activity} = + ActivityPub.create_activity(%{ + activity_id: activity_data["id"], + activity_type: "Create", + actor_uri: community_actor.uri, + object_id: message.activitypub_id, + data: activity_data, + local: true, + internal_user_id: nil + }) + + activity + end + + defp assert_delivery_inboxes(activity_type, object_id, user_id, expected_inboxes) do + activity = + Repo.get_by!(ActivityPub.Activity, + internal_user_id: user_id, + activity_type: activity_type, + object_id: object_id + ) + + actual_inboxes = + Delivery + |> where([d], d.activity_id == ^activity.id) + |> select([d], d.inbox_url) + |> Repo.all() + |> Enum.sort() + + assert actual_inboxes == Enum.sort(expected_inboxes) + end + + defp assert_delivery_inboxes_for_actor(activity_type, object_id, actor_uri, expected_inboxes) do + activity = + Repo.get_by!(ActivityPub.Activity, + actor_uri: actor_uri, + activity_type: activity_type, + object_id: object_id + ) + + actual_inboxes = + Delivery + |> where([d], d.activity_id == ^activity.id) + |> select([d], d.inbox_url) + |> Repo.all() + |> Enum.sort() + + assert actual_inboxes == Enum.sort(expected_inboxes) + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/pipeline_test.exs b/apps/elektrine/test/elektrine/activitypub/pipeline_test.exs new file mode 100644 index 0000000..2871b98 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/pipeline_test.exs @@ -0,0 +1,19 @@ +defmodule Elektrine.ActivityPub.PipelineTest do + use Elektrine.DataCase, async: false + + alias Elektrine.ActivityPub.Pipeline + + test "returns an error for URI-form Undo objects that cannot be fetched" do + actor_uri = "https://remote.example/users/pipeline-undo-failure" + + activity = %{ + "id" => "https://remote.example/undo/#{System.unique_integer([:positive])}", + "type" => "Undo", + "actor" => actor_uri, + "object" => "http://127.0.0.1/undo/#{System.unique_integer([:positive])}" + } + + assert {:error, :undo_activity_fetch_failed} = + Pipeline.process_incoming(activity, actor_uri, nil) + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/process_activity_worker_test.exs b/apps/elektrine/test/elektrine/activitypub/process_activity_worker_test.exs new file mode 100644 index 0000000..1664804 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/process_activity_worker_test.exs @@ -0,0 +1,263 @@ +defmodule Elektrine.ActivityPub.ProcessActivityWorkerTest do + use Elektrine.DataCase, async: true + + import Elektrine.AccountsFixtures + + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.DomainThrottler + alias Elektrine.ActivityPub.ProcessActivityWorker + alias Elektrine.Messaging + alias Elektrine.Repo + alias Elektrine.SocialFixtures + + setup do + if Process.whereis(DomainThrottler) == nil do + start_supervised!({DomainThrottler, []}) + end + + :ok + end + + test "retries URI-form Create activities when fetching the referenced object fails" do + unique_id = System.unique_integer([:positive]) + actor_uri = "https://create-fetch-#{unique_id}.invalid/users/author" + + activity = %{ + "id" => "https://create-fetch-#{unique_id}.invalid/activities/create/1", + "type" => "Create", + "actor" => actor_uri, + "object" => "http://127.0.0.1/objects/#{unique_id}" + } + + job = %Oban.Job{ + args: %{"activity" => activity, "actor_uri" => actor_uri}, + inserted_at: DateTime.utc_now(), + attempt: 1 + } + + assert {:error, :create_object_fetch_failed} = ProcessActivityWorker.perform(job) + end + + test "retries Move activities when referenced actors cannot be fetched" do + unique_id = System.unique_integer([:positive]) + actor_uri = "https://move-fetch-#{unique_id}.invalid/users/old" + + activity = %{ + "id" => "https://move-fetch-#{unique_id}.invalid/activities/move/1", + "type" => "Move", + "actor" => actor_uri, + "object" => actor_uri, + "target" => "https://move-fetch-#{unique_id}.invalid/users/new" + } + + job = %Oban.Job{ + args: %{"activity" => activity, "actor_uri" => actor_uri}, + inserted_at: DateTime.utc_now(), + attempt: 1 + } + + assert {:error, :move_actor_fetch_failed} = ProcessActivityWorker.perform(job) + end + + test "retries URI-form Update activities when fetching the referenced object fails" do + unique_id = System.unique_integer([:positive]) + actor_uri = "https://update-fetch-#{unique_id}.invalid/users/author" + + activity = %{ + "id" => "https://update-fetch-#{unique_id}.invalid/activities/update/1", + "type" => "Update", + "actor" => actor_uri, + "object" => "http://127.0.0.1/updates/#{unique_id}" + } + + job = %Oban.Job{ + args: %{"activity" => activity, "actor_uri" => actor_uri}, + inserted_at: DateTime.utc_now(), + attempt: 1 + } + + assert {:error, :update_object_fetch_failed} = ProcessActivityWorker.perform(job) + end + + test "retries actor Update activities when refreshing the actor fails" do + unique_id = System.unique_integer([:positive]) + actor_uri = "https://update-actor-#{unique_id}.invalid/users/author" + + activity = %{ + "id" => "https://update-actor-#{unique_id}.invalid/activities/update/1", + "type" => "Update", + "actor" => actor_uri, + "object" => %{ + "id" => actor_uri, + "type" => "Person" + } + } + + job = %Oban.Job{ + args: %{"activity" => activity, "actor_uri" => actor_uri}, + inserted_at: DateTime.utc_now(), + attempt: 1 + } + + assert {:error, :update_actor_fetch_failed} = ProcessActivityWorker.perform(job) + end + + test "retries Announce activities when fetching the announced object fails" do + unique_id = System.unique_integer([:positive]) + actor_uri = "https://announce-fetch-#{unique_id}.invalid/users/booster" + + activity = %{ + "id" => "https://announce-fetch-#{unique_id}.invalid/activities/announce/1", + "type" => "Announce", + "actor" => actor_uri, + "object" => "http://127.0.0.1/announces/#{unique_id}" + } + + job = %Oban.Job{ + args: %{"activity" => activity, "actor_uri" => actor_uri}, + inserted_at: DateTime.utc_now(), + attempt: 1 + } + + assert {:error, :announce_object_fetch_failed} = ProcessActivityWorker.perform(job) + end + + test "ignores Announce activities that point at inaccessible remote activity wrappers" do + unique_id = System.unique_integer([:positive]) + actor_uri = "https://announce-wrapper-#{unique_id}.invalid/users/booster" + + activity = %{ + "id" => "https://announce-wrapper-#{unique_id}.invalid/activities/announce/1", + "type" => "Announce", + "actor" => actor_uri, + "object" => "https://remote.example/activities/like/#{unique_id}" + } + + job = %Oban.Job{ + args: %{"activity" => activity, "actor_uri" => actor_uri}, + inserted_at: DateTime.utc_now(), + attempt: 1 + } + + assert :ok = ProcessActivityWorker.perform(job) + end + + test "retries Like activities when the remote actor cannot be fetched" do + user = user_fixture() + + post = + SocialFixtures.post_fixture(%{user: user}) + |> then(fn message -> + status_uri = + "#{Elektrine.ActivityPub.instance_url()}/users/#{user.username}/statuses/#{message.id}" + + Ecto.Changeset.change(message, activitypub_id: status_uri, activitypub_url: status_uri) + end) + |> Repo.update!() + + actor_uri = "https://like-actor-fetch.invalid/users/reactor" + + activity = %{ + "id" => "https://like-actor-fetch.invalid/activities/like/1", + "type" => "Like", + "actor" => actor_uri, + "object" => post.activitypub_id + } + + job = %Oban.Job{ + args: %{"activity" => activity, "actor_uri" => actor_uri}, + inserted_at: DateTime.utc_now(), + attempt: 1 + } + + assert {:error, :like_actor_fetch_failed} = ProcessActivityWorker.perform(job) + end + + test "does not retry unauthorized Update activities" do + author = remote_actor_fixture("owner") + intruder = remote_actor_fixture("intruder") + object_id = "https://remote.example/objects/#{System.unique_integer([:positive])}" + + assert {:ok, _message} = + Messaging.create_federated_message(%{ + content: "Original content", + visibility: "public", + activitypub_id: object_id, + activitypub_url: object_id, + federated: true, + remote_actor_id: author.id, + inserted_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + activity = %{ + "id" => "https://remote.example/activities/update/#{System.unique_integer([:positive])}", + "type" => "Update", + "actor" => intruder.uri, + "object" => %{ + "id" => object_id, + "type" => "Note", + "content" => "

    Malicious update

    ", + "attributedTo" => intruder.uri, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [] + } + } + + job = %Oban.Job{ + args: %{"activity" => activity, "actor_uri" => intruder.uri}, + inserted_at: DateTime.utc_now(), + attempt: 1 + } + + assert :ok = ProcessActivityWorker.perform(job) + end + + test "does not retry unauthorized Delete activities" do + author = remote_actor_fixture("deleteowner") + intruder = remote_actor_fixture("deleteintruder") + object_id = "https://remote.example/objects/#{System.unique_integer([:positive])}" + + assert {:ok, _message} = + Messaging.create_federated_message(%{ + content: "Original content", + visibility: "public", + activitypub_id: object_id, + activitypub_url: object_id, + federated: true, + remote_actor_id: author.id, + inserted_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + + activity = %{ + "id" => "https://remote.example/activities/delete/#{System.unique_integer([:positive])}", + "type" => "Delete", + "actor" => intruder.uri, + "object" => object_id + } + + job = %Oban.Job{ + args: %{"activity" => activity, "actor_uri" => intruder.uri}, + inserted_at: DateTime.utc_now(), + attempt: 1 + } + + assert :ok = ProcessActivityWorker.perform(job) + end + + defp remote_actor_fixture(label) do + unique_id = System.unique_integer([:positive]) + username = "#{label}#{unique_id}" + domain = "#{label}-#{unique_id}.remote.example" + + %Actor{} + |> Actor.changeset(%{ + uri: "https://#{domain}/users/#{username}", + username: username, + domain: domain, + inbox_url: "https://#{domain}/users/#{username}/inbox", + public_key: "-----BEGIN RSA PUBLIC KEY-----\nMOCK\n-----END RSA PUBLIC KEY-----\n", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/publisher_test.exs b/apps/elektrine/test/elektrine/activitypub/publisher_test.exs new file mode 100644 index 0000000..3148a03 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/publisher_test.exs @@ -0,0 +1,92 @@ +defmodule Elektrine.ActivityPub.PublisherTest do + use Elektrine.DataCase, async: true + + alias Elektrine.AccountsFixtures + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.Publisher + alias Elektrine.Profiles + + describe "get_follower_inboxes/1" do + test "collapses to a shared inbox only when every actor on a domain advertises it" do + user = AccountsFixtures.user_fixture() + shared_inbox = "https://fed.example/inbox" + + actor_one = + remote_actor_fixture(%{ + uri: "https://fed.example/users/alice", + username: "alice", + inbox_url: "https://fed.example/users/alice/inbox", + metadata: %{"endpoints" => %{"sharedInbox" => shared_inbox}} + }) + + actor_two = + remote_actor_fixture(%{ + uri: "https://fed.example/users/bob", + username: "bob", + inbox_url: "https://fed.example/users/bob/inbox", + metadata: %{"endpoints" => %{"sharedInbox" => shared_inbox}} + }) + + assert {:ok, _} = Profiles.create_remote_follow(actor_one.id, user.id) + assert {:ok, _} = Profiles.create_remote_follow(actor_two.id, user.id) + + assert Publisher.get_follower_inboxes(user.id) == [shared_inbox] + end + + test "keeps individual inboxes when shared inbox support is inconsistent" do + user = AccountsFixtures.user_fixture() + + actor_one = + remote_actor_fixture(%{ + uri: "https://fed.example/users/carol", + username: "carol", + inbox_url: "https://fed.example/users/carol/inbox", + metadata: %{"endpoints" => %{"sharedInbox" => "https://fed.example/inbox"}} + }) + + actor_two = + remote_actor_fixture(%{ + uri: "https://fed.example/users/dave", + username: "dave", + inbox_url: "https://fed.example/users/dave/inbox" + }) + + assert {:ok, _} = Profiles.create_remote_follow(actor_one.id, user.id) + assert {:ok, _} = Profiles.create_remote_follow(actor_two.id, user.id) + + assert Enum.sort(Publisher.get_follower_inboxes(user.id)) == + Enum.sort([actor_one.inbox_url, actor_two.inbox_url]) + end + end + + describe "deliver/4" do + test "rejects unsafe inbox URLs before sending a request" do + user = AccountsFixtures.user_fixture() + + assert {:error, :unsafe_inbox_url} = + Publisher.deliver( + %{"id" => "https://local.test/activities/1", "type" => "Follow"}, + user, + "http://127.0.0.1/inbox" + ) + end + end + + defp remote_actor_fixture(attrs) do + unique = System.unique_integer([:positive]) + + defaults = %{ + uri: "https://remote#{unique}.example/users/test#{unique}", + username: "test#{unique}", + domain: "fed.example", + inbox_url: "https://fed.example/users/test#{unique}/inbox", + public_key: "-----BEGIN PUBLIC KEY-----test-key-----END PUBLIC KEY-----", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second), + metadata: %{} + } + + %Actor{} + |> Actor.changeset(Map.merge(defaults, attrs)) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/refresh_counts_worker_test.exs b/apps/elektrine/test/elektrine/activitypub/refresh_counts_worker_test.exs new file mode 100644 index 0000000..d8bf188 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/refresh_counts_worker_test.exs @@ -0,0 +1,222 @@ +defmodule Elektrine.ActivityPub.RefreshCountsWorkerTest do + use Elektrine.DataCase, async: false + + alias Elektrine.AccountsFixtures + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.RefreshCountsWorker + alias Elektrine.Messaging + alias Elektrine.Social.PostBoost + alias Elektrine.Social.PostLike + import Ecto.Query + + test "visible_refresh_candidate_ids/2 selects visible federated ActivityPub posts" do + posts = [ + %{id: 1, federated: true, activitypub_id: "https://remote.example/statuses/1"}, + %{id: 1, federated: true, activitypub_id: "https://remote.example/statuses/1"}, + %{ + id: 2, + federated: true, + activitypub_id: "", + activitypub_url: "https://remote.example/@a/2" + }, + %{id: 3, federated: false, activitypub_id: "https://remote.example/statuses/3"}, + %{id: nil, federated: true, activitypub_id: "https://remote.example/statuses/4"}, + %{id: 5, federated: true, activitypub_id: ""}, + %{"id" => 6, "federated" => true, "activitypub_id" => "https://remote.example/statuses/6"} + ] + + assert RefreshCountsWorker.visible_refresh_candidate_ids(posts, limit: 10) == [1, 2, 6] + assert RefreshCountsWorker.visible_refresh_candidate_ids(posts, limit: 2) == [1, 2] + assert RefreshCountsWorker.visible_refresh_candidate_ids(posts, limit: 0) == [] + assert RefreshCountsWorker.visible_refresh_candidate_ids(:not_posts, limit: 10) == [] + end + + test "visible_refresh_candidate_ids/2 includes federated shared messages from boost wrappers" do + posts = [ + %{ + id: 10, + federated: false, + activitypub_id: "https://local.example/users/alice/statuses/10", + shared_message: %{ + id: 11, + federated: true, + activitypub_id: "https://remote.example/statuses/11" + } + }, + %{ + id: 12, + federated: false, + shared_message: %{ + id: 13, + federated: false, + activitypub_id: "https://local.example/statuses/13" + } + }, + %{ + id: 14, + federated: true, + activitypub_id: "https://remote.example/statuses/14", + shared_message: %{ + id: 15, + federated: true, + activitypub_url: "https://remote.example/@alice/15" + } + } + ] + + assert RefreshCountsWorker.visible_refresh_candidate_ids(posts, limit: 10) == [11, 14, 15] + assert RefreshCountsWorker.visible_refresh_candidate_ids(posts, limit: 2) == [11, 14] + end + + test "schedule_visible_refreshes/2 is disabled by default for feed page views" do + posts = [ + %{id: 1, federated: true, activitypub_id: "https://remote.example/statuses/1"} + ] + + assert RefreshCountsWorker.visible_refresh_candidate_ids(posts, limit: 10) == [1] + assert RefreshCountsWorker.schedule_visible_refreshes(posts, limit: 10) == [] + end + + test "reconciles zero remote refreshes with local engagement rows" do + user = AccountsFixtures.user_fixture() + actor = remote_actor_fixture() + unique = System.unique_integer([:positive]) + + {:ok, post} = + Messaging.create_federated_message(%{ + content: "cached remote post", + visibility: "public", + activitypub_id: "https://remote.example/posts/#{unique}", + activitypub_url: "https://remote.example/posts/#{unique}", + federated: true, + remote_actor_id: actor.id, + like_count: 0, + reply_count: 0, + share_count: 0 + }) + + %PostLike{} + |> PostLike.changeset(%{user_id: user.id, message_id: post.id}) + |> Repo.insert!() + + %PostBoost{} + |> PostBoost.changeset(%{user_id: user.id, message_id: post.id}) + |> Repo.insert!() + + {:ok, _reply} = + Messaging.create_federated_message(%{ + content: "cached reply", + visibility: "public", + activitypub_id: "https://remote.example/posts/#{unique}/replies/1", + federated: true, + remote_actor_id: actor.id, + reply_to_id: post.id + }) + + counts = + RefreshCountsWorker.reconcile_refreshed_engagement_counts(post, %{ + like_count: 0, + reply_count: 0, + share_count: 0 + }) + + assert counts.like_count == 1 + assert counts.reply_count == 1 + assert counts.share_count == 1 + end + + test "reconciles zero remote refreshes with cached original counts" do + actor = remote_actor_fixture() + unique = System.unique_integer([:positive]) + + {:ok, post} = + Messaging.create_federated_message(%{ + content: "cached remote post", + visibility: "public", + activitypub_id: "https://remote.example/posts/#{unique}", + activitypub_url: "https://remote.example/posts/#{unique}", + federated: true, + remote_actor_id: actor.id, + like_count: 0, + reply_count: 0, + share_count: 0, + media_metadata: %{ + "original_like_count" => 3, + "original_reply_count" => 4, + "original_share_count" => 5 + } + }) + + counts = + RefreshCountsWorker.reconcile_refreshed_engagement_counts(post, %{ + like_count: 0, + reply_count: 0, + share_count: 0 + }) + + assert counts.like_count == 3 + assert counts.reply_count == 4 + assert counts.share_count == 5 + end + + test "refresh_single uses activitypub_url when activitypub_id is missing" do + actor = remote_actor_fixture() + unique = System.unique_integer([:positive]) + activitypub_url = "https://example.com/posts/#{unique}" + + {:ok, post} = + Messaging.create_federated_message(%{ + content: "cached remote post", + visibility: "public", + activitypub_id: "https://example.com/activities/#{unique}", + activitypub_url: activitypub_url, + federated: true, + remote_actor_id: actor.id, + like_count: 0, + reply_count: 0, + share_count: 0 + }) + + Repo.update_all( + from(m in Elektrine.Social.Message, where: m.id == ^post.id), + set: [activitypub_id: nil] + ) + + assert {:ok, true} = + Cachex.put(:app_cache, {:object, activitypub_url}, { + :ok, + %{ + "id" => activitypub_url, + "type" => "Note", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "likes" => %{"totalItems" => 7}, + "replies" => %{"totalItems" => 3}, + "shares" => %{"totalItems" => 2} + } + }) + + assert :ok = + RefreshCountsWorker.perform(%Oban.Job{ + args: %{"type" => "refresh_single", "message_id" => post.id} + }) + + refreshed_post = Repo.get!(Elektrine.Social.Message, post.id) + assert refreshed_post.like_count == 7 + assert refreshed_post.reply_count == 3 + assert refreshed_post.share_count == 2 + end + + defp remote_actor_fixture do + unique = System.unique_integer([:positive]) + + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.example/users/alice-#{unique}", + username: "alice#{unique}", + domain: "remote.example", + inbox_url: "https://remote.example/inbox", + public_key: "test-public-key-#{unique}" + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/relay_test.exs b/apps/elektrine/test/elektrine/activitypub/relay_test.exs new file mode 100644 index 0000000..aa64dd8 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/relay_test.exs @@ -0,0 +1,281 @@ +defmodule Elektrine.ActivityPub.RelayTest do + use Elektrine.DataCase, async: true + + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.Relay + alias Elektrine.ActivityPub.RelaySubscription + alias Elektrine.Repo + + describe "relay_actor_id/0" do + test "returns the correct relay actor URI" do + # The relay actor ID should be based on the instance URL + relay_id = Relay.relay_actor_id() + assert String.ends_with?(relay_id, "/relay") + end + end + + describe "get_or_create_relay_actor/0" do + test "creates a new relay actor if none exists" do + {:ok, actor} = Relay.get_or_create_relay_actor() + + assert actor.username == "relay" + assert actor.actor_type == "Application" + assert actor.display_name == "Elektrine Relay" + assert actor.public_key != nil + assert actor.outbox_url == nil + assert actor.followers_url == nil + assert actor.following_url == nil + # Private key is stored in metadata for local actors + assert actor.metadata["private_key"] != nil + end + + test "returns existing relay actor on subsequent calls" do + {:ok, actor1} = Relay.get_or_create_relay_actor() + {:ok, actor2} = Relay.get_or_create_relay_actor() + + assert actor1.id == actor2.id + end + + test "normalizes stale relay collection URLs on an existing relay actor" do + relay_uri = Relay.relay_actor_id() + + {:ok, stale_actor} = + %Actor{} + |> Actor.changeset(%{ + uri: relay_uri, + username: "relay", + domain: ActivityPub.instance_domain(), + inbox_url: "#{ActivityPub.instance_url()}/relay/inbox", + outbox_url: "#{ActivityPub.instance_url()}/relay/outbox", + followers_url: "#{ActivityPub.instance_url()}/relay/followers", + following_url: "#{ActivityPub.instance_url()}/relay/following", + public_key: "-----BEGIN PUBLIC KEY-----test-key-----END PUBLIC KEY-----", + actor_type: "Application", + display_name: "Elektrine Relay", + metadata: %{"private_key" => "test-private-key"}, + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert() + + assert {:ok, normalized_actor} = Relay.get_or_create_relay_actor() + + assert normalized_actor.id == stale_actor.id + assert normalized_actor.outbox_url == nil + assert normalized_actor.followers_url == nil + assert normalized_actor.following_url == nil + end + end + + describe "list_subscriptions/0" do + test "returns empty list when no subscriptions exist" do + assert Relay.list_subscriptions() == [] + end + + test "returns all subscriptions" do + # Create test subscriptions + {:ok, sub1} = + %RelaySubscription{} + |> RelaySubscription.changeset(%{ + relay_uri: "https://relay.example1.com/actor", + relay_inbox: "https://relay.example1.com/inbox", + status: "active" + }) + |> Repo.insert() + + {:ok, sub2} = + %RelaySubscription{} + |> RelaySubscription.changeset(%{ + relay_uri: "https://relay.example2.com/actor", + relay_inbox: "https://relay.example2.com/inbox", + status: "pending" + }) + |> Repo.insert() + + subscriptions = Relay.list_subscriptions() + assert length(subscriptions) == 2 + + uris = Enum.map(subscriptions, & &1.relay_uri) + assert sub1.relay_uri in uris + assert sub2.relay_uri in uris + end + end + + describe "list_active_subscriptions/0" do + test "only returns active and accepted subscriptions" do + # Create various subscriptions + {:ok, _pending} = + %RelaySubscription{} + |> RelaySubscription.changeset(%{ + relay_uri: "https://relay.pending.com/actor", + relay_inbox: "https://relay.pending.com/inbox", + status: "pending", + accepted: false + }) + |> Repo.insert() + + {:ok, active} = + %RelaySubscription{} + |> RelaySubscription.changeset(%{ + relay_uri: "https://relay.active.com/actor", + relay_inbox: "https://relay.active.com/inbox", + status: "active", + accepted: true + }) + |> Repo.insert() + + {:ok, _rejected} = + %RelaySubscription{} + |> RelaySubscription.changeset(%{ + relay_uri: "https://relay.rejected.com/actor", + relay_inbox: "https://relay.rejected.com/inbox", + status: "rejected", + accepted: false + }) + |> Repo.insert() + + active_subs = Relay.list_active_subscriptions() + assert length(active_subs) == 1 + assert hd(active_subs).relay_uri == active.relay_uri + end + end + + describe "get_subscription/1" do + test "returns subscription when found" do + {:ok, sub} = + %RelaySubscription{} + |> RelaySubscription.changeset(%{ + relay_uri: "https://relay.test.com/actor", + relay_inbox: "https://relay.test.com/inbox", + status: "active" + }) + |> Repo.insert() + + assert {:ok, found} = Relay.get_subscription(sub.relay_uri) + assert found.id == sub.id + end + + test "returns error when not found" do + assert {:error, :not_found} = Relay.get_subscription("https://nonexistent.com/actor") + end + end + + describe "handle_accept/1" do + test "updates subscription to active when accept received" do + follow_id = "https://example.com/activities/#{Ecto.UUID.generate()}" + + {:ok, _sub} = + %RelaySubscription{} + |> RelaySubscription.changeset(%{ + relay_uri: "https://relay.test.com/actor", + relay_inbox: "https://relay.test.com/inbox", + follow_activity_id: follow_id, + status: "pending", + accepted: false + }) + |> Repo.insert() + + assert {:ok, updated} = Relay.handle_accept(follow_id) + assert updated.status == "active" + assert updated.accepted == true + end + + test "returns error when subscription not found" do + assert {:error, :subscription_not_found} = + Relay.handle_accept("https://example.com/activities/nonexistent") + end + + test "rejects accept when actor does not match relay subscription" do + follow_id = "https://example.com/activities/#{Ecto.UUID.generate()}" + + {:ok, _sub} = + %RelaySubscription{} + |> RelaySubscription.changeset(%{ + relay_uri: "https://relay.test.com/actor", + relay_inbox: "https://relay.test.com/inbox", + follow_activity_id: follow_id, + status: "pending", + accepted: false + }) + |> Repo.insert() + + assert {:error, :subscription_not_found} = + Relay.handle_accept(follow_id, "https://other-relay.test/actor") + end + end + + describe "handle_reject/1" do + test "updates subscription to rejected" do + follow_id = "https://example.com/activities/#{Ecto.UUID.generate()}" + + {:ok, _sub} = + %RelaySubscription{} + |> RelaySubscription.changeset(%{ + relay_uri: "https://relay.test.com/actor", + relay_inbox: "https://relay.test.com/inbox", + follow_activity_id: follow_id, + status: "pending", + accepted: false + }) + |> Repo.insert() + + assert {:ok, updated} = Relay.handle_reject(follow_id) + assert updated.status == "rejected" + assert updated.accepted == false + end + + test "rejects reject when actor does not match relay subscription" do + follow_id = "https://example.com/activities/#{Ecto.UUID.generate()}" + + {:ok, _sub} = + %RelaySubscription{} + |> RelaySubscription.changeset(%{ + relay_uri: "https://relay.test.com/actor", + relay_inbox: "https://relay.test.com/inbox", + follow_activity_id: follow_id, + status: "pending", + accepted: false + }) + |> Repo.insert() + + assert {:error, :subscription_not_found} = + Relay.handle_reject(follow_id, "https://other-relay.test/actor") + end + end + + describe "should_publish_to_relays?/1" do + # Test via publish_to_relays which uses this internally + test "only creates activities are considered for relay publishing" do + # Public Create activity + public_create = %{ + "type" => "Create", + "object" => %{ + "type" => "Note", + "to" => ["https://www.w3.org/ns/activitystreams#Public"] + } + } + + # This should not error even with no active relays + assert :ok = Relay.publish_to_relays(public_create) + + # Non-public Create activity + private_create = %{ + "type" => "Create", + "object" => %{ + "type" => "Note", + "to" => ["https://example.com/users/someone/followers"] + } + } + + assert :ok = Relay.publish_to_relays(private_create) + + # Non-Create activity + like_activity = %{ + "type" => "Like", + "object" => "https://example.com/posts/1" + } + + assert :ok = Relay.publish_to_relays(like_activity) + end + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/replies_fetcher_test.exs b/apps/elektrine/test/elektrine/activitypub/replies_fetcher_test.exs new file mode 100644 index 0000000..c8cb6cb --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/replies_fetcher_test.exs @@ -0,0 +1,154 @@ +defmodule Elektrine.ActivityPub.RepliesFetcherTest do + use Elektrine.DataCase, async: true + + import Elektrine.AccountsFixtures + + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.RepliesFetcher + alias Elektrine.Messaging + alias Elektrine.Repo + alias Elektrine.SocialFixtures + + test "returns message_not_found for missing full-thread backfill target" do + assert {:error, :message_not_found} = RepliesFetcher.fetch_full_thread_for_message(-1) + end + + test "returns no_activitypub_id when full-thread backfill target is local-only" do + user = user_fixture() + + message = + SocialFixtures.post_fixture(%{user: user}) + |> then(fn post -> + Ecto.Changeset.change(post, activitypub_id: nil, activitypub_url: nil) + end) + |> Repo.update!() + + assert {:error, :no_activitypub_id} = RepliesFetcher.fetch_full_thread_for_message(message.id) + end + + test "imports replies from Mastodon-shaped embedded collections and honors the cooldown" do + unique = System.unique_integer([:positive]) + actor_uri = "https://remote.example/users/author#{unique}" + post_id = "#{actor_uri}/statuses/#{unique}" + reply_id = "#{post_id}/reply-1" + replies_url = "#{post_id}/replies" + next_url = "#{replies_url}?only_other_accounts=true&page=true" + + remote_actor = + %Actor{} + |> Actor.changeset(%{ + uri: actor_uri, + username: "author#{unique}", + domain: "remote.example", + inbox_url: "https://remote.example/inbox/#{unique}", + public_key: "test-key-#{unique}" + }) + |> Repo.insert!() + |> Ecto.Changeset.change(last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second)) + |> Repo.update!() + + {:ok, message} = + Messaging.create_federated_message(%{ + content: "root post", + visibility: "public", + activitypub_id: post_id, + activitypub_url: post_id, + federated: true, + remote_actor_id: remote_actor.id + }) + + # The exact shape Mastodon emits: the replies collection embeds its first + # CollectionPage without an "id", with other-account replies behind "next". + post_object = %{ + "id" => post_id, + "type" => "Note", + "content" => "root post", + "attributedTo" => actor_uri, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "replies" => %{ + "id" => replies_url, + "type" => "Collection", + "first" => %{ + "type" => "CollectionPage", + "next" => next_url, + "partOf" => replies_url, + "items" => [] + } + } + } + + reply_object = %{ + "id" => reply_id, + "type" => "Note", + "content" => "

    a reply

    ", + "attributedTo" => actor_uri, + "inReplyTo" => post_id, + "published" => "2026-07-01T00:00:00Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"] + } + + parent = self() + + request_fun = fn url, _headers, _opts -> + send(parent, {:fetched, url}) + + body = + case url do + ^post_id -> + post_object + + ^next_url -> + %{"type" => "CollectionPage", "partOf" => replies_url, "items" => [reply_object]} + end + + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: Jason.encode!(body) + }} + end + + assert {:ok, stored} = + RepliesFetcher.fetch_full_thread_for_message(message.id, + request_fun: request_fun, + skip_cache: true, + validate_url: false + ) + + assert stored >= 1 + assert_received {:fetched, ^post_id} + assert_received {:fetched, ^next_url} + + reply = Messaging.get_message_by_activitypub_id(reply_id) + assert reply + assert reply.reply_to_id == message.id + + # The successful attempt stamped the cooldown: a plain retry short-circuits + # without hitting the network. + cooldown_fun = fn url, _headers, _opts -> + send(parent, {:cooldown_fetch, url}) + {:error, :should_not_be_called} + end + + assert {:ok, 0} = + RepliesFetcher.fetch_full_thread_for_message(message.id, + request_fun: cooldown_fun, + skip_cache: true, + validate_url: false + ) + + refute_received {:cooldown_fetch, _} + + # A forced retry (skip_cooldown) bypasses the cooldown and refetches. + assert {:ok, _} = + RepliesFetcher.fetch_full_thread_for_message(message.id, + request_fun: request_fun, + skip_cache: true, + skip_cooldown: true, + validate_url: false + ) + + assert_received {:fetched, ^post_id} + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/replies_ingest_worker_test.exs b/apps/elektrine/test/elektrine/activitypub/replies_ingest_worker_test.exs new file mode 100644 index 0000000..beb28c8 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/replies_ingest_worker_test.exs @@ -0,0 +1,28 @@ +defmodule Elektrine.ActivityPub.RepliesIngestWorkerTest do + use Elektrine.DataCase, async: true + + import Elektrine.AccountsFixtures + + alias Elektrine.ActivityPub.RepliesIngestWorker + alias Elektrine.Repo + alias Elektrine.SocialFixtures + + test "discards jobs for missing messages" do + assert {:discard, :message_not_found} = + RepliesIngestWorker.perform(%Oban.Job{args: %{"message_id" => -1}}) + end + + test "returns retryable errors when remote fetch fails" do + user = user_fixture() + + message = + SocialFixtures.post_fixture(%{user: user}) + |> then(fn post -> + Ecto.Changeset.change(post, activitypub_id: "http://127.0.0.1/replies-fetch-test") + end) + |> Repo.update!() + + assert {:error, _reason} = + RepliesIngestWorker.perform(%Oban.Job{args: %{"message_id" => message.id}}) + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/reply_fetch_policy_test.exs b/apps/elektrine/test/elektrine/activitypub/reply_fetch_policy_test.exs new file mode 100644 index 0000000..de8237a --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/reply_fetch_policy_test.exs @@ -0,0 +1,38 @@ +defmodule Elektrine.ActivityPub.ReplyFetchPolicyTest do + use ExUnit.Case, async: true + + alias Elektrine.ActivityPub.ReplyFetchPolicy + + test "clamps preview fetch limits" do + opts = ReplyFetchPolicy.clamp_fetch_opts(max_replies: 999_999, max_depth: 99, max_pages: 99) + + assert opts[:max_replies] == 5 + assert opts[:max_depth] == 3 + assert opts[:max_pages] == 10 + end + + test "clamps full thread fetch limits" do + opts = + ReplyFetchPolicy.clamp_fetch_opts( + [max_replies: 999_999, max_depth: 99, max_pages: 999_999], + :full_thread + ) + + assert opts[:max_replies] == 1_000 + assert opts[:max_depth] == 5 + assert opts[:max_pages] == 500 + end + + test "keeps same-host supplemental replies only" do + replies = [ + %{"id" => "https://example.com/notes/1", "inReplyTo" => "https://example.com/notes/root"}, + %{ + "id" => "https://other.example/notes/2", + "inReplyTo" => "https://other.example/notes/root" + } + ] + + assert [%{"id" => "https://example.com/notes/1"}] = + ReplyFetchPolicy.filter_same_host_replies(replies, "https://example.com/notes/root") + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/signature_retry_worker_test.exs b/apps/elektrine/test/elektrine/activitypub/signature_retry_worker_test.exs new file mode 100644 index 0000000..79ded7b --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/signature_retry_worker_test.exs @@ -0,0 +1,125 @@ +defmodule Elektrine.ActivityPub.SignatureRetryWorkerTest do + use Elektrine.DataCase, async: true + use Oban.Testing, repo: Elektrine.Repo + + alias Elektrine.ActivityPub.SignatureRetryWorker + + @actor_uri "https://remote.example/actor" + + defp signed_conn(body) do + Plug.Test.conn(:post, "/inbox?source=test", body) + |> Map.put(:req_headers, [ + {"host", "elektrine.com"}, + {"signature", "keyId=\"#{@actor_uri}#main-key\""} + ]) + |> Plug.Conn.assign(:raw_body, body) + end + + defp activity_json do + Jason.encode!(%{"id" => "https://remote.example/activities/1", "actor" => @actor_uri}) + end + + test "enqueue stores request headers in JSON-safe form" do + body = activity_json() + activity = Jason.decode!(body) + + Oban.Testing.with_testing_mode(:manual, fn -> + assert {:ok, _job} = + SignatureRetryWorker.enqueue(activity, @actor_uri, signed_conn(body), nil) + + assert [ + %Oban.Job{ + args: %{ + "req_headers" => [ + ["host", "elektrine.com"], + ["signature", "keyId=\"#{@actor_uri}#main-key\""] + ] + } + } + ] = all_enqueued(worker: SignatureRetryWorker) + end) + end + + test "enqueue preserves the signed body and the time the request arrived" do + body = activity_json() + activity = Jason.decode!(body) + + Oban.Testing.with_testing_mode(:manual, fn -> + assert {:ok, _job} = + SignatureRetryWorker.enqueue(activity, @actor_uri, signed_conn(body), nil) + + assert [%Oban.Job{args: %{"raw_body" => ^body, "received_at" => received_at}}] = + all_enqueued(worker: SignatureRetryWorker) + + # Freshness is judged against arrival, not against when the job runs, so + # the timestamp has to survive the round trip. + assert {:ok, _datetime, _offset} = DateTime.from_iso8601(received_at) + end) + end + + test "enqueue refuses a request whose body was not captured" do + body = activity_json() + conn = Plug.Test.conn(:post, "/inbox", body) |> Map.put(:req_headers, []) + + Oban.Testing.with_testing_mode(:manual, fn -> + # Without the bytes the signature covered there is nothing to verify the + # digest against, so the retry must not be queued at all. + assert {:error, :missing_raw_body} = + SignatureRetryWorker.enqueue(Jason.decode!(body), @actor_uri, conn, nil) + + assert [] = all_enqueued(worker: SignatureRetryWorker) + end) + end + + describe "perform/1" do + test "discards a job with no stored body" do + assert {:discard, :invalid_metadata} = + perform_job(SignatureRetryWorker, %{ + "actor_uri" => @actor_uri, + "method" => "POST", + "req_headers" => [["host", "elektrine.com"]], + "request_path" => "/inbox", + "query_string" => "", + "received_at" => DateTime.utc_now() |> DateTime.to_iso8601() + }) + end + + test "discards a job whose arrival time cannot be parsed" do + assert {:discard, :invalid_metadata} = + perform_job(SignatureRetryWorker, %{ + "actor_uri" => @actor_uri, + "method" => "POST", + "req_headers" => [["host", "elektrine.com"]], + "request_path" => "/inbox", + "query_string" => "", + "raw_body" => activity_json(), + "received_at" => "not-a-timestamp" + }) + end + + test "does not enqueue when the signature cannot be verified" do + Oban.Testing.with_testing_mode(:manual, fn -> + result = + perform_job(SignatureRetryWorker, %{ + "actor_uri" => @actor_uri, + "method" => "POST", + "req_headers" => [ + ["host", "elektrine.com"], + [ + "signature", + "keyId=\"#{@actor_uri}#main-key\",headers=\"host\",signature=\"AA==\"" + ] + ], + "request_path" => "/inbox", + "query_string" => "", + "raw_body" => activity_json(), + "received_at" => DateTime.utc_now() |> DateTime.to_iso8601() + }) + + assert match?({:error, _}, result) or match?({:discard, _}, result) + + assert [] = all_enqueued(worker: Elektrine.ActivityPub.ProcessActivityWorker) + end) + end + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/signature_verification_test.exs b/apps/elektrine/test/elektrine/activitypub/signature_verification_test.exs new file mode 100644 index 0000000..b59a930 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/signature_verification_test.exs @@ -0,0 +1,226 @@ +defmodule Elektrine.ActivityPub.SignatureVerificationTest do + @moduledoc """ + The verifier is the only thing standing between a remote server and our + inbox, so these tests pin the checks that a bare signature check does not + cover. They exist because a second verifier once skipped them, letting a + request rejected at the inbox door be accepted through the retry queue. + """ + + use Elektrine.DataCase, async: false + + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.SignatureVerification + alias Elektrine.ActivityPub.SigningKey + + @max_age 86_400 + + setup do + previous = Application.get_env(:elektrine, :activitypub) + + Application.put_env( + :elektrine, + :activitypub, + Keyword.put(previous || [], :signature_max_age_seconds, @max_age) + ) + + on_exit(fn -> + if is_nil(previous) do + Application.delete_env(:elektrine, :activitypub) + else + Application.put_env(:elektrine, :activitypub, previous) + end + end) + + {public_key, private_key} = SigningKey.generate_key_pair() + actor = remote_signing_actor_fixture(public_key) + + %{actor: actor, private_key: private_key} + end + + describe "verify/2" do + test "accepts a correctly signed request", ctx do + body = Jason.encode!(%{"type" => "Follow", "actor" => ctx.actor.uri}) + conn = signed_conn(ctx, body, body) + + assert {:ok, %{actor_uri: actor_uri}} = SignatureVerification.verify(conn) + assert actor_uri == ctx.actor.uri + end + + test "rejects a body swapped for one the signature never covered", ctx do + signed_body = Jason.encode!(%{"type" => "Follow", "actor" => ctx.actor.uri}) + delivered_body = Jason.encode!(%{"type" => "Delete", "actor" => ctx.actor.uri}) + + # Signature and digest header are genuine; only the body differs. This is + # the shape of a captured-and-modified delivery. + conn = signed_conn(ctx, signed_body, delivered_body) + + assert {:error, :digest_mismatch} = SignatureVerification.verify(conn) + end + + test "rejects when the body is unavailable rather than skipping the digest", ctx do + body = Jason.encode!(%{"type" => "Follow", "actor" => ctx.actor.uri}) + + conn = + ctx + |> signed_conn(body, body) + |> Map.put(:assigns, %{}) + |> Map.put(:private, %{}) + + assert {:error, :missing_raw_body} = SignatureVerification.verify(conn) + end + + test "rejects a signature that does not bind the request target", ctx do + body = Jason.encode!(%{"type" => "Follow", "actor" => ctx.actor.uri}) + date = http_date() + digest = digest(body) + + # Genuinely signed, but over a header set that leaves the target free. + signing_string = "host: elektrine.com\ndate: #{date}\ndigest: #{digest}" + + conn = + build_conn(ctx, body, date, digest, "host date digest", signing_string) + + assert {:error, :missing_signed_request_target} = SignatureVerification.verify(conn) + end + + test "rejects a request that was already stale when it arrived", ctx do + body = Jason.encode!(%{"type" => "Follow", "actor" => ctx.actor.uri}) + conn = signed_conn(ctx, body, body) + + # Judged against a point far after the request's own Date header. + later = DateTime.utc_now() |> DateTime.add(@max_age * 2, :second) + + assert {:error, :stale_signature} = SignatureVerification.verify(conn, now: later) + end + + test "judges freshness against the supplied instant, not wall clock", ctx do + body = Jason.encode!(%{"type" => "Follow", "actor" => ctx.actor.uri}) + conn = signed_conn(ctx, body, body) + + # A retry running later still accepts a request that was fresh on arrival. + assert {:ok, _} = SignatureVerification.verify(conn, now: DateTime.utc_now()) + end + + test "refuses to reuse a claimed request", ctx do + body = Jason.encode!(%{"type" => "Follow", "actor" => ctx.actor.uri}) + conn = signed_conn(ctx, body, body) + + assert {:ok, _} = SignatureVerification.verify(conn) + assert {:error, :replayed_request} = SignatureVerification.verify(conn) + end + + test "reports a missing signature distinctly from a bad one", ctx do + body = Jason.encode!(%{"type" => "Follow", "actor" => ctx.actor.uri}) + + conn = + ctx + |> signed_conn(body, body) + |> Map.put(:req_headers, [{"host", "elektrine.com"}]) + + assert {:error, :missing_signature} = SignatureVerification.verify(conn) + end + end + + describe "retryable_error?/1" do + test "only key resolution failures come back through the queue" do + assert SignatureVerification.retryable_error?(:invalid_signature) + assert SignatureVerification.retryable_error?({:key_fetch_failed, :timeout}) + assert SignatureVerification.retryable_error?(:actor_fetch_failed) + end + + test "a verified signature that broke a constraint is final" do + refute SignatureVerification.retryable_error?(:digest_mismatch) + refute SignatureVerification.retryable_error?(:stale_signature) + refute SignatureVerification.retryable_error?(:missing_signed_request_target) + refute SignatureVerification.retryable_error?(:missing_signed_host) + refute SignatureVerification.retryable_error?(:replayed_request) + refute SignatureVerification.retryable_error?(:missing_digest_in_signature) + end + end + + defp signed_conn(ctx, signed_body, delivered_body) do + date = http_date() + digest = digest(signed_body) + + signing_string = + [ + "(request-target): post /inbox", + "host: elektrine.com", + "date: #{date}", + "digest: #{digest}" + ] + |> Enum.join("\n") + + build_conn( + ctx, + delivered_body, + date, + digest, + "(request-target) host date digest", + signing_string + ) + end + + defp build_conn(ctx, body, date, digest, headers_param, signing_string) do + signature_header = + [ + ~s(keyId="#{ctx.actor.uri}#main-key"), + ~s(algorithm="rsa-sha256"), + ~s(headers="#{headers_param}"), + ~s(signature="#{rsa_sign(signing_string, ctx.private_key)}") + ] + |> Enum.join(",") + + %Plug.Conn{ + method: "POST", + request_path: "/inbox", + query_string: "", + host: "elektrine.com", + req_headers: [ + {"host", "elektrine.com"}, + {"date", date}, + {"digest", digest}, + {"signature", signature_header} + ], + assigns: %{raw_body: body} + } + end + + defp http_date, do: Calendar.strftime(DateTime.utc_now(), "%a, %d %b %Y %H:%M:%S GMT") + + defp digest(body), do: "SHA-256=" <> Base.encode64(:crypto.hash(:sha256, body)) + + defp rsa_sign(data, private_key_pem) do + [entry] = :public_key.pem_decode(private_key_pem) + private_key = :public_key.pem_entry_decode(entry) + :public_key.sign(data, :sha256, private_key) |> Base.encode64() + end + + defp remote_signing_actor_fixture(public_key) do + unique = System.unique_integer([:positive]) + actor_uri = "https://8.8.4.4/users/verify#{unique}" + + actor = + %Actor{} + |> Actor.changeset(%{ + uri: actor_uri, + username: "verify#{unique}", + domain: "8.8.4.4", + inbox_url: "https://8.8.4.4/inbox", + public_key: public_key, + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second), + metadata: %{} + }) + |> Repo.insert!() + + %SigningKey{} + |> SigningKey.remote_changeset(%{ + key_id: "#{actor_uri}#main-key", + remote_actor_id: actor.id, + public_key: public_key + }) + |> Repo.insert!() + + actor + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/thread_backfill_worker_test.exs b/apps/elektrine/test/elektrine/activitypub/thread_backfill_worker_test.exs new file mode 100644 index 0000000..52aba7e --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/thread_backfill_worker_test.exs @@ -0,0 +1,25 @@ +defmodule Elektrine.ActivityPub.ThreadBackfillWorkerTest do + use Elektrine.DataCase, async: true + use Oban.Testing, repo: Elektrine.Repo + + alias Elektrine.ActivityPub.ThreadBackfillWorker + + test "forced retries are unique separately from automatic backfills" do + Oban.Testing.with_testing_mode(:manual, fn -> + ThreadBackfillWorker.enqueue(123) + ThreadBackfillWorker.enqueue(123, force: true) + + assert [_job] = + all_enqueued( + worker: ThreadBackfillWorker, + args: %{"message_id" => 123, "force" => false} + ) + + assert [_job] = + all_enqueued( + worker: ThreadBackfillWorker, + args: %{"message_id" => 123, "force" => true} + ) + end) + end +end diff --git a/apps/elektrine/test/elektrine/activitypub/undo_resolver_test.exs b/apps/elektrine/test/elektrine/activitypub/undo_resolver_test.exs new file mode 100644 index 0000000..f4e1fa4 --- /dev/null +++ b/apps/elektrine/test/elektrine/activitypub/undo_resolver_test.exs @@ -0,0 +1,85 @@ +defmodule Elektrine.ActivityPub.UndoResolverTest do + use Elektrine.DataCase, async: true + + alias Elektrine.AccountsFixtures + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.{Actor, UndoResolver} + alias Elektrine.Repo + alias Elektrine.SocialFixtures + + test "resolves stored remote EmojiReact activities without fetching" do + user = AccountsFixtures.user_fixture() + message = SocialFixtures.post_fixture(%{user: user, visibility: "public"}) + remote_actor = remote_actor_fixture("undo_resolver_reactor") + reaction_id = "https://remote.example/reactions/#{System.unique_integer([:positive])}" + object_id = "#{ActivityPub.instance_url()}/posts/#{message.id}" + + assert {:ok, _activity} = + ActivityPub.create_activity(%{ + activity_id: reaction_id, + activity_type: "EmojiReact", + actor_uri: remote_actor.uri, + object_id: object_id, + data: %{ + "id" => reaction_id, + "type" => "EmojiReact", + "actor" => remote_actor.uri, + "object" => object_id, + "content" => ":blobcat:" + }, + local: false, + processed: true + }) + + assert {:ok, + %{ + "id" => ^reaction_id, + "type" => "EmojiReact", + "object" => ^object_id, + "content" => ":blobcat:" + }} = UndoResolver.resolve(reaction_id, remote_actor.uri) + end + + test "does not resolve stored remote activities for a different actor" do + user = AccountsFixtures.user_fixture() + message = SocialFixtures.post_fixture(%{user: user, visibility: "public"}) + remote_actor = remote_actor_fixture("undo_resolver_owner") + reaction_id = "https://remote.example/reactions/#{System.unique_integer([:positive])}" + + assert {:ok, _activity} = + ActivityPub.create_activity(%{ + activity_id: reaction_id, + activity_type: "EmojiReact", + actor_uri: remote_actor.uri, + object_id: "#{ActivityPub.instance_url()}/posts/#{message.id}", + data: %{ + "id" => reaction_id, + "type" => "EmojiReact", + "actor" => remote_actor.uri, + "object" => "#{ActivityPub.instance_url()}/posts/#{message.id}", + "content" => ":blobcat:" + }, + local: false, + processed: true + }) + + assert :not_found = + UndoResolver.resolve(reaction_id, "https://remote.example/users/other-actor") + end + + defp remote_actor_fixture(label) do + unique_id = System.unique_integer([:positive]) + username = "#{label}#{unique_id}" + + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.example/users/#{username}", + username: username, + domain: "remote.example", + inbox_url: "https://remote.example/users/#{username}/inbox", + public_key: "-----BEGIN RSA PUBLIC KEY-----\nMOCK\n-----END RSA PUBLIC KEY-----\n", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/admin_test.exs b/apps/elektrine/test/elektrine/admin_test.exs new file mode 100644 index 0000000..227bbf7 --- /dev/null +++ b/apps/elektrine/test/elektrine/admin_test.exs @@ -0,0 +1,216 @@ +defmodule Elektrine.AdminTest do + use Elektrine.DataCase + + alias Elektrine.Accounts + alias Elektrine.Admin + + describe "announcements" do + setup do + {:ok, admin} = + Accounts.create_user(%{ + username: "adminuser", + password: "password123", + password_confirmation: "password123" + }) + + %{admin: admin} + end + + test "list_announcements/0 returns all announcements", %{admin: admin} do + {:ok, _announcement} = + Admin.create_announcement(%{ + title: "Test Announcement", + content: "This is a test announcement", + type: "info", + created_by_id: admin.id, + active: true + }) + + announcements = Admin.list_announcements() + assert length(announcements) == 1 + assert hd(announcements).title == "Test Announcement" + end + + test "list_active_announcements/0 returns only active announcements", %{admin: admin} do + {:ok, _active} = + Admin.create_announcement(%{ + title: "Active Announcement", + content: "This is active", + type: "info", + created_by_id: admin.id, + active: true + }) + + {:ok, _inactive} = + Admin.create_announcement(%{ + title: "Inactive Announcement", + content: "This is inactive", + type: "warning", + created_by_id: admin.id, + active: false + }) + + active_announcements = Admin.list_active_announcements() + assert length(active_announcements) == 1 + assert hd(active_announcements).title == "Active Announcement" + end + + test "get_announcement!/1 returns the announcement with given id", %{admin: admin} do + {:ok, announcement} = + Admin.create_announcement(%{ + title: "Get Test", + content: "Test content", + type: "feature", + created_by_id: admin.id, + active: true + }) + + found = Admin.get_announcement!(announcement.id) + assert found.title == "Get Test" + assert found.created_by.username == "adminuser" + end + + test "create_announcement/1 with valid data creates an announcement", %{admin: admin} do + valid_attrs = %{ + title: "New Feature", + content: "We've added a new feature!", + type: "feature", + created_by_id: admin.id, + active: true + } + + assert {:ok, announcement} = Admin.create_announcement(valid_attrs) + assert announcement.title == "New Feature" + assert announcement.content == "We've added a new feature!" + assert announcement.type == "feature" + assert announcement.active == true + end + + test "create_announcement/1 with invalid data returns error changeset", %{admin: admin} do + invalid_attrs = %{ + title: "", + content: "", + type: "invalid", + created_by_id: admin.id + } + + assert {:error, %Ecto.Changeset{}} = Admin.create_announcement(invalid_attrs) + end + + test "update_announcement/2 with valid data updates the announcement", %{admin: admin} do + {:ok, announcement} = + Admin.create_announcement(%{ + title: "Original Title", + content: "Original content", + type: "info", + created_by_id: admin.id, + active: true + }) + + update_attrs = %{ + title: "Updated Title", + content: "Updated content", + type: "warning" + } + + assert {:ok, updated} = Admin.update_announcement(announcement, update_attrs) + assert updated.title == "Updated Title" + assert updated.content == "Updated content" + assert updated.type == "warning" + end + + test "delete_announcement/1 deletes the announcement", %{admin: admin} do + {:ok, announcement} = + Admin.create_announcement(%{ + title: "To Delete", + content: "This will be deleted", + type: "maintenance", + created_by_id: admin.id, + active: true + }) + + assert {:ok, _} = Admin.delete_announcement(announcement) + + assert_raise Ecto.NoResultsError, fn -> + Admin.get_announcement!(announcement.id) + end + end + + test "change_announcement/1 returns an announcement changeset", %{admin: admin} do + {:ok, announcement} = + Admin.create_announcement(%{ + title: "Changeset Test", + content: "Test content", + type: "urgent", + created_by_id: admin.id, + active: true + }) + + changeset = Admin.change_announcement(announcement) + assert %Ecto.Changeset{} = changeset + end + + test "dismiss_announcement_for_user/2 creates a dismissal record", %{admin: admin} do + {:ok, announcement} = + Admin.create_announcement(%{ + title: "Dismissal Test", + content: "This will be dismissed", + type: "info", + created_by_id: admin.id, + active: true + }) + + {:ok, other_user} = + Accounts.create_user(%{ + username: "testuser", + password: "password123", + password_confirmation: "password123" + }) + + assert {:ok, _dismissal} = + Admin.dismiss_announcement_for_user(other_user.id, announcement.id) + + assert Admin.announcement_dismissed_by_user?(other_user.id, announcement.id) == true + assert Admin.announcement_dismissed_by_user?(admin.id, announcement.id) == false + end + + test "list_active_announcements_for_user/1 excludes dismissed announcements", %{admin: admin} do + {:ok, announcement1} = + Admin.create_announcement(%{ + title: "Announcement 1", + content: "This will be dismissed", + type: "info", + created_by_id: admin.id, + active: true + }) + + {:ok, _announcement2} = + Admin.create_announcement(%{ + title: "Announcement 2", + content: "This will remain visible", + type: "warning", + created_by_id: admin.id, + active: true + }) + + {:ok, user} = + Accounts.create_user(%{ + username: "testuser2", + password: "password123", + password_confirmation: "password123" + }) + + # Before dismissal, both announcements should be visible + announcements = Admin.list_active_announcements_for_user(user.id) + assert length(announcements) == 2 + + # Dismiss one announcement + {:ok, _dismissal} = Admin.dismiss_announcement_for_user(user.id, announcement1.id) + + # After dismissal, only one announcement should be visible + announcements = Admin.list_active_announcements_for_user(user.id) + assert length(announcements) == 1 + assert hd(announcements).title == "Announcement 2" + end + end +end diff --git a/apps/elektrine/test/elektrine/atomine_proof_graph_test.exs b/apps/elektrine/test/elektrine/atomine_proof_graph_test.exs new file mode 100644 index 0000000..bc11ad2 --- /dev/null +++ b/apps/elektrine/test/elektrine/atomine_proof_graph_test.exs @@ -0,0 +1,87 @@ +defmodule Elektrine.AtomineProofGraphTest do + use Elektrine.DataCase + + alias Elektrine.{Accounts, AtomineProofGraph, Profiles} + alias Elektrine.AccountsFixtures + + test "build_public_graph exposes trust, invite lineage, and visible network samples" do + unique = System.unique_integer([:positive]) + + inviter = AccountsFixtures.user_fixture(%{username: "inviter#{unique}"}) + subject = AccountsFixtures.user_fixture(%{username: "subject#{unique}"}) + invitee = AccountsFixtures.user_fixture(%{username: "invitee#{unique}"}) + + hidden_invitee = + AccountsFixtures.user_fixture(%{username: "hidden#{unique}", profile_visibility: "private"}) + + follower = AccountsFixtures.user_fixture(%{username: "follower#{unique}"}) + followee = AccountsFixtures.user_fixture(%{username: "followee#{unique}"}) + + {:ok, inviter_code} = + Accounts.create_invite_code(%{ + code: "ROOTAA#{unique}", + created_by_id: inviter.id + }) + + {:ok, _used_subject_code} = Accounts.use_invite_code(inviter_code.code, subject.id) + + {:ok, subject_code} = + Accounts.create_invite_code(%{ + code: "BRANCH#{unique}", + max_uses: 2, + created_by_id: subject.id + }) + + {:ok, _used_invitee_code} = Accounts.use_invite_code(subject_code.code, invitee.id) + {:ok, _used_hidden_code} = Accounts.use_invite_code(subject_code.code, hidden_invitee.id) + {:ok, _follow} = Profiles.follow_user(follower.id, subject.id) + {:ok, _follow} = Profiles.follow_user(subject.id, followee.id) + {:ok, subject} = Accounts.admin_update_user(subject, %{trust_level: 2}) + + graph = AtomineProofGraph.build_public_graph(subject) + + assert graph.subject.handle == subject.handle + assert Enum.any?(graph.nodes, &(&1.id == "trust:#{subject.id}" and &1.label == "TL2")) + + assert Enum.any?( + graph.nodes, + &(&1.id == "inviter:#{inviter.id}" and &1.label == "@#{inviter.handle}") + ) + + assert Enum.any?( + graph.nodes, + &(&1.id == "invitee:#{invitee.id}" and &1.label == "@#{invitee.handle}") + ) + + assert Enum.any?( + graph.nodes, + &(&1.id == "follower:#{follower.id}" and &1.label == "@#{follower.handle}") + ) + + assert Enum.any?( + graph.nodes, + &(&1.id == "following:#{followee.id}" and &1.label == "@#{followee.handle}") + ) + + refute Enum.any?(graph.nodes, &(&1.label == "@#{hidden_invitee.handle}")) + assert Enum.find(graph.stats, &(&1.label == "Invitees")).value == "2" + end + + test "search_public_users returns only public graph subjects" do + unique = System.unique_integer([:positive]) + + public_user = AccountsFixtures.user_fixture(%{username: "graphpublic#{unique}"}) + + _private_user = + AccountsFixtures.user_fixture(%{ + username: "graphprivate#{unique}", + display_name: "Graph Private #{unique}", + profile_visibility: "private" + }) + + results = AtomineProofGraph.search_public_users("graph") + + assert Enum.any?(results, &(&1.username == public_user.username)) + refute Enum.any?(results, &(&1.username == "graphprivate#{unique}")) + end +end diff --git a/apps/elektrine/test/elektrine/bluesky_inbound_test.exs b/apps/elektrine/test/elektrine/bluesky_inbound_test.exs new file mode 100644 index 0000000..9a31a06 --- /dev/null +++ b/apps/elektrine/test/elektrine/bluesky_inbound_test.exs @@ -0,0 +1,307 @@ +defmodule Elektrine.BlueskyInboundTest do + use Elektrine.DataCase, async: false + + import Ecto.Query + import Elektrine.AccountsFixtures + import Elektrine.SocialFixtures + + alias Elektrine.Accounts + alias Elektrine.Accounts.User + alias Elektrine.Bluesky.Inbound + alias Elektrine.Bluesky.InboundEvent + alias Elektrine.Notifications.Notification + alias Elektrine.Repo + alias Elektrine.Social.Message + + defmodule MockHTTPClient do + def put_responses(responses), do: Process.put(:bluesky_inbound_mock_responses, responses) + def clear_responses, do: Process.delete(:bluesky_inbound_mock_responses) + def clear_requests, do: Process.delete(:bluesky_inbound_mock_requests) + + def requests do + Process.get(:bluesky_inbound_mock_requests, []) + |> Enum.reverse() + end + + def request(method, url, headers, body, opts) do + request = %{method: method, url: url, headers: headers, body: body, opts: opts} + + Process.put( + :bluesky_inbound_mock_requests, + [request | Process.get(:bluesky_inbound_mock_requests, [])] + ) + + case Process.get(:bluesky_inbound_mock_responses, []) do + [next | rest] -> + Process.put(:bluesky_inbound_mock_responses, rest) + next + + [] -> + {:error, :no_mock_response} + end + end + end + + setup do + previous = Application.get_env(:elektrine, :bluesky, []) + + Application.put_env(:elektrine, :bluesky, + enabled: true, + inbound_enabled: true, + inbound_limit: 50, + service_url: "https://bsky.social", + timeout_ms: 5_000, + max_chars: 300, + http_client: MockHTTPClient + ) + + on_exit(fn -> + Application.put_env(:elektrine, :bluesky, previous) + MockHTTPClient.clear_requests() + MockHTTPClient.clear_responses() + end) + + :ok + end + + test "sync_user creates local notification for remote Bluesky reply" do + user = bluesky_user_fixture() + local_post = mirrored_post_fixture(user, "at://did:plc:local/app.bsky.feed.post/abc") + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:local"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "cursor" => "cursor-2", + "notifications" => [ + %{ + "reason" => "reply", + "reasonSubject" => local_post.bluesky_uri, + "uri" => "at://did:plc:remote/app.bsky.feed.post/reply1", + "cid" => "cid-reply1", + "author" => %{ + "did" => "did:plc:remote", + "handle" => "remote-user.bsky.social" + }, + "record" => %{"text" => "nice post"} + } + ] + }) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"feed" => []}) + }} + ]) + + assert {:ok, %{processed_events: 1, created_notifications: 1, synced_feed_posts: 0}} = + Inbound.sync_user(user) + + notifications = + from(n in Notification, + where: + n.user_id == ^user.id and n.source_type == "bluesky" and n.source_id == ^local_post.id + ) + |> Repo.all() + + assert length(notifications) == 1 + assert hd(notifications).type == "reply" + assert hd(notifications).title =~ "remote-user.bsky.social" + + inbound_events = + from(e in InboundEvent, + where: e.user_id == ^user.id and e.related_post_uri == ^local_post.bluesky_uri + ) + |> Repo.all() + + assert length(inbound_events) == 1 + + refreshed_user = Repo.get!(User, user.id) + assert refreshed_user.bluesky_inbound_cursor == "cursor-2" + assert refreshed_user.bluesky_inbound_last_polled_at != nil + end + + test "duplicate inbound event is deduplicated" do + user = bluesky_user_fixture() + local_post = mirrored_post_fixture(user, "at://did:plc:local/app.bsky.feed.post/dup") + + payload = %{ + "cursor" => "cursor-dup", + "notifications" => [ + %{ + "reason" => "reply", + "reasonSubject" => local_post.bluesky_uri, + "uri" => "at://did:plc:remote/app.bsky.feed.post/dup-reply", + "cid" => "cid-dup-reply", + "author" => %{ + "did" => "did:plc:remote", + "handle" => "remote-user.bsky.social" + }, + "record" => %{"text" => "duplicate event"} + } + ] + } + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:local"}) + }}, + {:ok, %Finch.Response{status: 200, body: Jason.encode!(payload)}}, + {:ok, %Finch.Response{status: 200, body: Jason.encode!(%{"feed" => []})}}, + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:local"}) + }}, + {:ok, %Finch.Response{status: 200, body: Jason.encode!(payload)}}, + {:ok, %Finch.Response{status: 200, body: Jason.encode!(%{"feed" => []})}} + ]) + + assert {:ok, %{processed_events: 1, created_notifications: 1, synced_feed_posts: 0}} = + Inbound.sync_user(user) + + assert {:ok, %{processed_events: 0, created_notifications: 0, synced_feed_posts: 0}} = + Inbound.sync_user(user) + + assert Repo.aggregate( + from(n in Notification, + where: + n.user_id == ^user.id and n.source_type == "bluesky" and + n.source_id == ^local_post.id + ), + :count + ) == 1 + + assert Repo.aggregate(from(e in InboundEvent, where: e.user_id == ^user.id), :count) == 1 + end + + test "sync_user advances the notification cursor even when feed sync fails" do + user = bluesky_user_fixture() + local_post = mirrored_post_fixture(user, "at://did:plc:local/app.bsky.feed.post/feed-fail") + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:local"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "cursor" => "cursor-feed-fail", + "notifications" => [ + %{ + "reason" => "reply", + "reasonSubject" => local_post.bluesky_uri, + "uri" => "at://did:plc:remote/app.bsky.feed.post/reply-feed-fail", + "cid" => "cid-feed-fail", + "author" => %{ + "did" => "did:plc:remote", + "handle" => "remote-user.bsky.social" + }, + "record" => %{"text" => "still notify"} + } + ] + }) + }}, + {:ok, %Finch.Response{status: 503, body: Jason.encode!(%{"error" => "ServiceUnavailable"})}} + ]) + + assert {:ok, %{processed_events: 1, created_notifications: 1, synced_feed_posts: 0}} = + Inbound.sync_user(user) + + refreshed_user = Repo.get!(User, user.id) + assert refreshed_user.bluesky_inbound_cursor == "cursor-feed-fail" + assert refreshed_user.bluesky_inbound_last_polled_at != nil + end + + test "sync_user stores inbound feed post snapshots" do + user = bluesky_user_fixture() + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:local"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"cursor" => "cursor-feed", "notifications" => []}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "feed" => [ + %{ + "post" => %{ + "uri" => "at://did:plc:remote/app.bsky.feed.post/feed1", + "cid" => "feedcid1", + "author" => %{"did" => "did:plc:remote", "handle" => "remote.bsky.social"}, + "record" => %{"text" => "feed text"}, + "likeCount" => 2, + "repostCount" => 1 + } + } + ] + }) + }} + ]) + + assert {:ok, %{processed_events: 0, created_notifications: 0, synced_feed_posts: 1}} = + Inbound.sync_user(user) + + feed_events = + from(e in InboundEvent, + where: + e.user_id == ^user.id and e.reason == "feed_post" and + e.related_post_uri == "at://did:plc:remote/app.bsky.feed.post/feed1" + ) + |> Repo.all() + + assert length(feed_events) == 1 + assert get_in(hd(feed_events).metadata, ["payload", "record", "text"]) == "feed text" + end + + defp bluesky_user_fixture(attrs \\ %{}) do + user = user_fixture() + + defaults = %{ + "bluesky_enabled" => true, + "bluesky_identifier" => "#{user.username}.bsky.social", + "bluesky_app_password" => "test-app-password" + } + + {:ok, updated_user} = + Accounts.update_user( + user, + defaults + |> Map.merge(attrs) + ) + + updated_user + end + + defp mirrored_post_fixture(user, bluesky_uri) do + post = post_fixture(%{user: user, visibility: "public"}) + + from(m in Message, where: m.id == ^post.id) + |> Repo.update_all(set: [bluesky_uri: bluesky_uri, bluesky_cid: "test-cid"]) + + Repo.get!(Message, post.id) + end +end diff --git a/apps/elektrine/test/elektrine/bluesky_managed_test.exs b/apps/elektrine/test/elektrine/bluesky_managed_test.exs new file mode 100644 index 0000000..a5af5c8 --- /dev/null +++ b/apps/elektrine/test/elektrine/bluesky_managed_test.exs @@ -0,0 +1,546 @@ +defmodule Elektrine.BlueskyManagedTest do + use Elektrine.DataCase, async: false + + import Ecto.Query + import Elektrine.AccountsFixtures + + alias Elektrine.Accounts + alias Elektrine.Accounts.User + alias Elektrine.Bluesky.Managed + alias Elektrine.Profiles + alias Elektrine.Repo + + @service_url "https://bsky.social" + + defmodule MockHTTPClient do + def put_responses(responses), do: Process.put(:bluesky_managed_mock_responses, responses) + def clear_responses, do: Process.delete(:bluesky_managed_mock_responses) + def clear_requests, do: Process.delete(:bluesky_managed_mock_requests) + + def requests do + Process.get(:bluesky_managed_mock_requests, []) + |> Enum.reverse() + end + + def request(method, url, headers, body, opts) do + request = %{method: method, url: url, headers: headers, body: body, opts: opts} + + Process.put( + :bluesky_managed_mock_requests, + [request | Process.get(:bluesky_managed_mock_requests, [])] + ) + + case Process.get(:bluesky_managed_mock_responses, []) do + [next | rest] -> + Process.put(:bluesky_managed_mock_responses, rest) + next + + [] -> + {:error, :no_mock_response} + end + end + end + + setup do + previous = Application.get_env(:elektrine, :bluesky, []) + + Application.put_env(:elektrine, :bluesky, + enabled: true, + inbound_enabled: true, + managed_enabled: true, + managed_service_url: @service_url, + managed_domain: "bsky.example.com", + managed_admin_password: "admin-password", + service_url: @service_url, + timeout_ms: 5_000, + http_client: MockHTTPClient + ) + + on_exit(fn -> + Application.put_env(:elektrine, :bluesky, previous) + MockHTTPClient.clear_requests() + MockHTTPClient.clear_responses() + end) + + :ok + end + + test "enable_for_user provisions account and stores app password" do + user = user_fixture() + + MockHTTPClient.put_responses([ + {:ok, %Finch.Response{status: 200, body: Jason.encode!(%{"code" => "invite-123"})}}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "did" => "did:plc:testdid", + "handle" => "#{user.username}.bsky.example.com" + }) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"name" => "elektrine", "password" => "app-password-1"}) + }} + ]) + + assert {:ok, %{did: "did:plc:testdid", handle: handle, user: updated_user}} = + Managed.enable_for_user(user, valid_user_password()) + + assert handle == "#{user.username}.bsky.example.com" + assert updated_user.bluesky_enabled + assert updated_user.bluesky_identifier == "did:plc:testdid" + assert updated_user.bluesky_did == "did:plc:testdid" + assert updated_user.bluesky_app_password == "app-password-1" + assert updated_user.bluesky_pds_url == @service_url + + reloaded = Repo.get!(User, user.id) + assert reloaded.bluesky_enabled + assert reloaded.bluesky_app_password == "app-password-1" + + requests = MockHTTPClient.requests() + assert Enum.count(requests) == 4 + assert Enum.at(requests, 0).url =~ "/xrpc/com.atproto.server.createInviteCode" + assert Enum.at(requests, 1).url =~ "/xrpc/com.atproto.server.createAccount" + assert Enum.at(requests, 2).url =~ "/xrpc/com.atproto.server.createSession" + assert Enum.at(requests, 3).url =~ "/xrpc/com.atproto.server.createAppPassword" + end + + test "enable_for_user retries once when managed service closes the connection" do + user = user_fixture() + + MockHTTPClient.put_responses([ + {:error, %Mint.TransportError{reason: :closed}}, + {:ok, %Finch.Response{status: 200, body: Jason.encode!(%{"code" => "invite-123"})}}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "did" => "did:plc:testdid", + "handle" => "#{user.username}.bsky.example.com" + }) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"name" => "elektrine", "password" => "app-password-1"}) + }} + ]) + + assert {:ok, %{did: "did:plc:testdid", handle: handle, user: updated_user}} = + Managed.enable_for_user(user, valid_user_password()) + + assert handle == "#{user.username}.bsky.example.com" + assert updated_user.bluesky_enabled + assert updated_user.bluesky_app_password == "app-password-1" + + requests = MockHTTPClient.requests() + assert Enum.count(requests) == 5 + assert Enum.at(requests, 0).url =~ "/xrpc/com.atproto.server.createInviteCode" + assert Enum.at(requests, 1).url =~ "/xrpc/com.atproto.server.createInviteCode" + assert Enum.at(requests, 2).url =~ "/xrpc/com.atproto.server.createAccount" + assert Enum.at(requests, 3).url =~ "/xrpc/com.atproto.server.createSession" + assert Enum.at(requests, 4).url =~ "/xrpc/com.atproto.server.createAppPassword" + end + + test "enable_for_user uses a verified custom profile domain as the managed handle" do + user = user_fixture() + custom_domain = verified_profile_custom_domain_fixture(user, "managedbskyalias.test") + + MockHTTPClient.put_responses([ + {:ok, %Finch.Response{status: 200, body: Jason.encode!(%{"code" => "invite-123"})}}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "did" => "did:plc:testdid", + "handle" => custom_domain.domain + }) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "accessJwt" => "jwt_token", + "did" => "did:plc:testdid", + "handle" => custom_domain.domain + }) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"name" => "elektrine", "password" => "app-password-1"}) + }} + ]) + + assert {:ok, %{handle: handle}} = Managed.enable_for_user(user, valid_user_password()) + assert handle == custom_domain.domain + + requests = MockHTTPClient.requests() + create_account_request = Enum.at(requests, 1) + assert Jason.decode!(create_account_request.body)["handle"] == custom_domain.domain + end + + test "enable_for_user persists a configured private managed service URL" do + Application.put_env(:elektrine, :bluesky, + enabled: true, + inbound_enabled: true, + managed_enabled: true, + managed_service_url: "http://bluesky_pds:3000", + managed_domain: "bsky.example.com", + managed_admin_password: "admin-password", + service_url: @service_url, + timeout_ms: 5_000, + http_client: MockHTTPClient + ) + + user = user_fixture() + + MockHTTPClient.put_responses([ + {:ok, %Finch.Response{status: 200, body: Jason.encode!(%{"code" => "invite-123"})}}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "did" => "did:plc:testdid", + "handle" => "#{user.username}.bsky.example.com" + }) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"name" => "elektrine", "password" => "app-password-1"}) + }} + ]) + + assert {:ok, %{user: updated_user}} = Managed.enable_for_user(user, valid_user_password()) + assert updated_user.bluesky_pds_url == "http://bluesky_pds:3000" + end + + test "returns invalid credentials for wrong password" do + user = user_fixture() + + assert {:error, :invalid_credentials} = Managed.enable_for_user(user, "wrong-password") + assert MockHTTPClient.requests() == [] + end + + test "returns managed disabled when feature is off" do + Application.put_env(:elektrine, :bluesky, + enabled: true, + managed_enabled: false, + service_url: @service_url, + timeout_ms: 5_000, + http_client: MockHTTPClient + ) + + user = user_fixture() + assert {:error, :managed_pds_disabled} = Managed.enable_for_user(user, valid_user_password()) + assert MockHTTPClient.requests() == [] + end + + test "rejects an invalid managed domain before creating an account" do + Application.put_env(:elektrine, :bluesky, + enabled: true, + inbound_enabled: true, + managed_enabled: true, + managed_service_url: @service_url, + managed_domain: "Managed Bluesky", + managed_admin_password: "admin-password", + service_url: @service_url, + timeout_ms: 5_000, + http_client: MockHTTPClient + ) + + user = user_fixture() + + assert {:error, :invalid_managed_domain} = + Managed.enable_for_user(user, valid_user_password()) + + assert MockHTTPClient.requests() == [] + end + + test "reconnect_for_user refreshes app password for an existing managed account" do + user = user_fixture() + + {:ok, user} = + Accounts.update_user(user, %{ + "bluesky_enabled" => true, + "bluesky_identifier" => "did:plc:testdid", + "bluesky_app_password" => "old-app-password", + "bluesky_pds_url" => @service_url + }) + + from(u in User, where: u.id == ^user.id) + |> Repo.update_all(set: [bluesky_did: "did:plc:testdid"]) + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "accessJwt" => "jwt-reconnect", + "did" => "did:plc:testdid", + "handle" => "#{user.username}.bsky.example.com" + }) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"name" => "elektrine", "password" => "app-password-2"}) + }} + ]) + + assert {:ok, %{user: updated_user, did: "did:plc:testdid"}} = + Managed.reconnect_for_user(user, valid_user_password()) + + assert updated_user.bluesky_enabled + assert updated_user.bluesky_identifier == "did:plc:testdid" + assert updated_user.bluesky_did == "did:plc:testdid" + assert updated_user.bluesky_app_password == "app-password-2" + + requests = MockHTTPClient.requests() + assert Enum.count(requests) == 2 + assert Enum.at(requests, 0).url =~ "/xrpc/com.atproto.server.createSession" + assert Enum.at(requests, 1).url =~ "/xrpc/com.atproto.server.createAppPassword" + + session_payload = + Enum.at(requests, 0).body + |> Jason.decode!() + + assert session_payload["password"] == "old-app-password" + end + + test "reconnect_for_user falls back to the current password when the stored app password is stale" do + user = user_fixture() + + {:ok, user} = + Accounts.update_user(user, %{ + "bluesky_enabled" => true, + "bluesky_identifier" => "did:plc:testdid", + "bluesky_app_password" => "stale-app-password", + "bluesky_pds_url" => @service_url + }) + + from(u in User, where: u.id == ^user.id) + |> Repo.update_all(set: [bluesky_did: "did:plc:testdid"]) + + MockHTTPClient.put_responses([ + {:ok, %Finch.Response{status: 401, body: Jason.encode!(%{"error" => "Unauthorized"})}}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "accessJwt" => "jwt-reconnect", + "did" => "did:plc:testdid", + "handle" => "#{user.username}.bsky.example.com" + }) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"name" => "elektrine", "password" => "app-password-2"}) + }} + ]) + + assert {:ok, %{user: updated_user, did: "did:plc:testdid"}} = + Managed.reconnect_for_user(user, valid_user_password()) + + assert updated_user.bluesky_app_password == "app-password-2" + + requests = MockHTTPClient.requests() + assert Enum.count(requests) == 3 + + first_session_payload = + Enum.at(requests, 0).body + |> Jason.decode!() + + second_session_payload = + Enum.at(requests, 1).body + |> Jason.decode!() + + assert first_session_payload["password"] == "stale-app-password" + assert second_session_payload["password"] == valid_user_password() + end + + test "reconnect_for_user uses a unique app password name on each reconnect" do + user = user_fixture() + + {:ok, user} = + Accounts.update_user(user, %{ + "bluesky_enabled" => true, + "bluesky_identifier" => "did:plc:testdid", + "bluesky_app_password" => "old-app-password", + "bluesky_pds_url" => @service_url + }) + + from(u in User, where: u.id == ^user.id) + |> Repo.update_all(set: [bluesky_did: "did:plc:testdid"]) + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "accessJwt" => "jwt-reconnect-1", + "did" => "did:plc:testdid", + "handle" => "#{user.username}.bsky.example.com" + }) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"name" => "elektrine", "password" => "app-password-2"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "accessJwt" => "jwt-reconnect-2", + "did" => "did:plc:testdid", + "handle" => "#{user.username}.bsky.example.com" + }) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"name" => "elektrine", "password" => "app-password-3"}) + }} + ]) + + assert {:ok, _} = Managed.reconnect_for_user(user, valid_user_password()) + assert {:ok, _} = Managed.reconnect_for_user(user, valid_user_password()) + + app_password_requests = + MockHTTPClient.requests() + |> Enum.filter(fn request -> + String.contains?(request.url, "/xrpc/com.atproto.server.createAppPassword") + end) + + assert Enum.count(app_password_requests) == 2 + + app_password_names = + Enum.map(app_password_requests, fn request -> + request.body + |> Jason.decode!() + |> Map.fetch!("name") + end) + + assert Enum.uniq(app_password_names) == app_password_names + assert Enum.all?(app_password_names, &String.starts_with?(&1, "elektrine-")) + end + + test "reconnect_for_user retries app password creation once when pds returns 500" do + user = user_fixture() + + {:ok, user} = + Accounts.update_user(user, %{ + "bluesky_enabled" => true, + "bluesky_identifier" => "did:plc:testdid", + "bluesky_app_password" => "old-app-password", + "bluesky_pds_url" => @service_url + }) + + from(u in User, where: u.id == ^user.id) + |> Repo.update_all(set: [bluesky_did: "did:plc:testdid"]) + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "accessJwt" => "jwt-reconnect", + "did" => "did:plc:testdid", + "handle" => "#{user.username}.bsky.example.com" + }) + }}, + {:ok, + %Finch.Response{ + status: 500, + body: Jason.encode!(%{"error" => "InternalServerError"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"name" => "elektrine", "password" => "app-password-2"}) + }} + ]) + + assert {:ok, %{user: updated_user, did: "did:plc:testdid"}} = + Managed.reconnect_for_user(user, valid_user_password()) + + assert updated_user.bluesky_enabled + assert updated_user.bluesky_app_password == "app-password-2" + + requests = MockHTTPClient.requests() + assert Enum.count(requests) == 3 + assert Enum.at(requests, 0).url =~ "/xrpc/com.atproto.server.createSession" + assert Enum.at(requests, 1).url =~ "/xrpc/com.atproto.server.createAppPassword" + assert Enum.at(requests, 2).url =~ "/xrpc/com.atproto.server.createAppPassword" + + first_name = Enum.at(requests, 1).body |> Jason.decode!() |> Map.fetch!("name") + second_name = Enum.at(requests, 2).body |> Jason.decode!() |> Map.fetch!("name") + + refute first_name == second_name + end + + test "disconnect_for_user clears managed bluesky linkage fields" do + user = user_fixture() + + {:ok, user} = + Accounts.update_user(user, %{ + "bluesky_enabled" => true, + "bluesky_identifier" => "did:plc:testdid", + "bluesky_app_password" => "old-app-password", + "bluesky_pds_url" => @service_url + }) + + from(u in User, where: u.id == ^user.id) + |> Repo.update_all(set: [bluesky_did: "did:plc:testdid", bluesky_inbound_cursor: "cursor-1"]) + + assert {:ok, updated_user} = Managed.disconnect_for_user(user, valid_user_password()) + + assert updated_user.bluesky_enabled == false + assert is_nil(updated_user.bluesky_identifier) + assert is_nil(updated_user.bluesky_app_password) + assert is_nil(updated_user.bluesky_did) + assert is_nil(updated_user.bluesky_pds_url) + assert is_nil(updated_user.bluesky_inbound_cursor) + end + + defp verified_profile_custom_domain_fixture(user, domain) do + {:ok, custom_domain} = Profiles.create_custom_domain(user, %{"domain" => domain}) + + now = DateTime.utc_now() |> DateTime.truncate(:second) + + from(cd in Elektrine.Profiles.CustomDomain, where: cd.id == ^custom_domain.id) + |> Repo.update_all(set: [status: "verified", verified_at: now, last_checked_at: now]) + + Profiles.get_verified_custom_domain(domain) + end +end diff --git a/apps/elektrine/test/elektrine/bluesky_test.exs b/apps/elektrine/test/elektrine/bluesky_test.exs new file mode 100644 index 0000000..29e3bef --- /dev/null +++ b/apps/elektrine/test/elektrine/bluesky_test.exs @@ -0,0 +1,1038 @@ +defmodule Elektrine.BlueskyTest do + use Elektrine.DataCase, async: false + + import Ecto.Query + import Elektrine.AccountsFixtures + import Elektrine.SocialFixtures + + alias Elektrine.Accounts + alias Elektrine.Accounts.User + alias Elektrine.Bluesky + alias Elektrine.Repo + alias Elektrine.Social.Message + + defmodule MockHTTPClient do + def put_responses(responses), do: Process.put(:bluesky_mock_responses, responses) + def clear_responses, do: Process.delete(:bluesky_mock_responses) + def clear_requests, do: Process.delete(:bluesky_mock_requests) + + def requests do + Process.get(:bluesky_mock_requests, []) + |> Enum.reverse() + end + + def request(method, url, headers, body, opts) do + request = %{method: method, url: url, headers: headers, body: body, opts: opts} + Process.put(:bluesky_mock_requests, [request | Process.get(:bluesky_mock_requests, [])]) + + case Process.get(:bluesky_mock_responses, []) do + [next | rest] -> + Process.put(:bluesky_mock_responses, rest) + + cond do + is_function(next, 1) -> next.(request) + is_function(next, 0) -> next.() + true -> next + end + + [] -> + {:error, :no_mock_response} + end + end + end + + setup do + previous = Application.get_env(:elektrine, :bluesky, []) + + Application.put_env(:elektrine, :bluesky, + enabled: true, + service_url: "https://bsky.social", + timeout_ms: 5_000, + max_chars: 300, + http_client: MockHTTPClient + ) + + on_exit(fn -> + Application.put_env(:elektrine, :bluesky, previous) + MockHTTPClient.clear_requests() + MockHTTPClient.clear_responses() + end) + + :ok + end + + test "skips when bridge is disabled" do + Application.put_env(:elektrine, :bluesky, + enabled: false, + service_url: "https://bsky.social", + timeout_ms: 5_000, + max_chars: 300, + http_client: MockHTTPClient + ) + + message = post_fixture(%{visibility: "public"}) + + assert {:skipped, :bridge_disabled} = Bluesky.mirror_post(message) + assert MockHTTPClient.requests() == [] + end + + test "mirrors public post and stores Bluesky uri/cid + user did" do + user = bluesky_user_fixture() + message = post_fixture(%{user: user, visibility: "public", content: "hello bluesky"}) + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "uri" => "at://did:plc:testdid/app.bsky.feed.post/abc123", + "cid" => "bafycid123" + }) + }} + ]) + + assert :ok = Bluesky.mirror_post(message) + + reloaded_message = Repo.get!(Message, message.id) + assert reloaded_message.bluesky_uri == "at://did:plc:testdid/app.bsky.feed.post/abc123" + assert reloaded_message.bluesky_cid == "bafycid123" + + reloaded_user = Repo.get!(User, user.id) + assert reloaded_user.bluesky_did == "did:plc:testdid" + + requests = MockHTTPClient.requests() + assert Enum.count(requests) == 2 + assert Enum.at(requests, 0).url =~ "/xrpc/com.atproto.server.createSession" + assert Enum.at(requests, 1).url =~ "/xrpc/com.atproto.repo.createRecord" + + record_payload = + Enum.at(requests, 1).body + |> Jason.decode!() + |> Map.fetch!("record") + + refute Map.has_key?(record_payload, "reply") + assert record_payload["text"] == "hello bluesky" + end + + test "skips private network media URLs before fetching" do + user = bluesky_user_fixture() + + message = + media_post_fixture(%{ + user: user, + visibility: "public", + content: "blocked media", + media_urls: ["http://127.0.0.1/private.png"] + }) + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "uri" => "at://did:plc:testdid/app.bsky.feed.post/no-ssrf", + "cid" => "bafycid-no-ssrf" + }) + }} + ]) + + assert :ok = Bluesky.mirror_post(message) + + requests = MockHTTPClient.requests() + assert Enum.count(requests) == 2 + refute Enum.any?(requests, &String.contains?(&1.url, "/xrpc/com.atproto.repo.uploadBlob")) + + record_payload = + Enum.at(requests, 1).body + |> Jason.decode!() + |> Map.fetch!("record") + + refute Map.has_key?(record_payload, "embed") + end + + test "re-reads the message state before mirroring a stale struct" do + user = bluesky_user_fixture() + message = post_fixture(%{user: user, visibility: "public", content: "hello twice"}) + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "uri" => "at://did:plc:testdid/app.bsky.feed.post/once", + "cid" => "bafycid-once" + }) + }} + ]) + + assert :ok = Bluesky.mirror_post(message) + + MockHTTPClient.clear_requests() + + assert {:skipped, :already_mirrored} = Bluesky.mirror_post(message) + assert MockHTTPClient.requests() == [] + end + + test "skips stale deleted posts before mirroring" do + user = bluesky_user_fixture() + message = post_fixture(%{user: user, visibility: "public", content: "delete me"}) + + from(m in Message, where: m.id == ^message.id) + |> Repo.update_all(set: [deleted_at: DateTime.utc_now() |> DateTime.truncate(:second)]) + + assert {:skipped, :already_deleted} = Bluesky.mirror_post(message) + assert MockHTTPClient.requests() == [] + end + + test "cleans up created remote posts if the message is deleted during publish" do + user = bluesky_user_fixture() + message = post_fixture(%{user: user, visibility: "public", content: "race cleanup"}) + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + fn _request -> + from(m in Message, where: m.id == ^message.id) + |> Repo.update_all(set: [deleted_at: DateTime.utc_now() |> DateTime.truncate(:second)]) + + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "uri" => "at://did:plc:testdid/app.bsky.feed.post/race1", + "cid" => "racecid1" + }) + }} + end, + {:ok, %Finch.Response{status: 200, body: "{}"}} + ]) + + assert {:skipped, :already_deleted} = Bluesky.mirror_post(message) + + requests = MockHTTPClient.requests() + assert Enum.count(requests) == 3 + assert Enum.at(requests, 1).url =~ "/xrpc/com.atproto.repo.createRecord" + assert Enum.at(requests, 2).url =~ "/xrpc/com.atproto.repo.deleteRecord" + + reloaded_message = Repo.get!(Message, message.id) + assert is_nil(reloaded_message.bluesky_uri) + assert is_nil(reloaded_message.bluesky_cid) + end + + test "skips replies when parent is not mirrored yet" do + user = bluesky_user_fixture() + parent = post_fixture(%{user: user, visibility: "public", content: "parent"}) + reply = reply_fixture(user, parent, "child") + + assert {:skipped, :reply_parent_not_mirrored} = Bluesky.mirror_post(reply) + assert MockHTTPClient.requests() == [] + end + + test "includes root and parent references when mirroring a reply" do + user = bluesky_user_fixture() + parent = post_fixture(%{user: user, visibility: "public", content: "parent"}) + + from(m in Message, where: m.id == ^parent.id) + |> Repo.update_all( + set: [bluesky_uri: "at://did:plc:testdid/app.bsky.feed.post/root", bluesky_cid: "rootcid"] + ) + + reply = reply_fixture(user, parent, "child") + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "uri" => "at://did:plc:testdid/app.bsky.feed.post/reply", + "cid" => "replycid" + }) + }} + ]) + + assert :ok = Bluesky.mirror_post(reply) + + record_payload = + MockHTTPClient.requests() + |> Enum.at(1) + |> Map.fetch!(:body) + |> Jason.decode!() + |> Map.fetch!("record") + + assert record_payload["reply"]["parent"]["uri"] == + "at://did:plc:testdid/app.bsky.feed.post/root" + + assert record_payload["reply"]["parent"]["cid"] == "rootcid" + + assert record_payload["reply"]["root"]["uri"] == + "at://did:plc:testdid/app.bsky.feed.post/root" + + assert record_payload["reply"]["root"]["cid"] == "rootcid" + end + + test "uploads image attachments and includes embed payload" do + user = bluesky_user_fixture() + first_key = unique_media_key(".jpg") + second_key = unique_media_key(".png") + + first_path = create_local_upload(first_key, <<0xFF, 0xD8, 0xFF, 0x00, 0x01>>) + second_path = create_local_upload(second_key, <<0x89, 0x50, 0x4E, 0x47, 0x00, 0x01>>) + + on_exit(fn -> + File.rm(first_path) + File.rm(second_path) + end) + + message = + media_post_fixture(%{ + user: user, + content: "post with media", + media_urls: [first_key, second_key] + }) + + from(m in Message, where: m.id == ^message.id) + |> Repo.update_all( + set: [media_metadata: %{"alt_texts" => %{"0" => "First alt", "1" => "Second alt"}}] + ) + + message = Repo.get!(Message, message.id) + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "blob" => %{ + "$type" => "blob", + "ref" => %{"$link" => "bafkqaaa-first"}, + "mimeType" => "image/jpeg", + "size" => 5 + } + }) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "blob" => %{ + "$type" => "blob", + "ref" => %{"$link" => "bafkqaaa-second"}, + "mimeType" => "image/png", + "size" => 6 + } + }) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "uri" => "at://did:plc:testdid/app.bsky.feed.post/media1", + "cid" => "bafycid-media1" + }) + }} + ]) + + assert :ok = Bluesky.mirror_post(message) + + requests = MockHTTPClient.requests() + assert Enum.count(requests) == 4 + assert Enum.at(requests, 0).url =~ "/xrpc/com.atproto.server.createSession" + assert Enum.at(requests, 1).url =~ "/xrpc/com.atproto.repo.uploadBlob" + assert Enum.at(requests, 2).url =~ "/xrpc/com.atproto.repo.uploadBlob" + assert Enum.at(requests, 3).url =~ "/xrpc/com.atproto.repo.createRecord" + + assert header_value(Enum.at(requests, 1).headers, "content-type") == "image/jpeg" + assert header_value(Enum.at(requests, 2).headers, "content-type") == "image/png" + + record_payload = + Enum.at(requests, 3).body + |> Jason.decode!() + |> Map.fetch!("record") + + assert record_payload["text"] == "post with media" + assert record_payload["embed"]["$type"] == "app.bsky.embed.images" + + assert record_payload["embed"]["images"] == [ + %{ + "image" => %{ + "$type" => "blob", + "mimeType" => "image/jpeg", + "ref" => %{"$link" => "bafkqaaa-first"}, + "size" => 5 + }, + "alt" => "First alt" + }, + %{ + "image" => %{ + "$type" => "blob", + "mimeType" => "image/png", + "ref" => %{"$link" => "bafkqaaa-second"}, + "size" => 6 + }, + "alt" => "Second alt" + } + ] + end + + test "mirrors media-only posts with empty text" do + user = bluesky_user_fixture() + media_key = unique_media_key(".jpg") + media_path = create_local_upload(media_key, <<0xFF, 0xD8, 0xFF, 0x01>>) + + on_exit(fn -> + File.rm(media_path) + end) + + message = + media_post_fixture(%{ + user: user, + content: "", + media_urls: [media_key] + }) + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "blob" => %{ + "$type" => "blob", + "ref" => %{"$link" => "bafkqaaa-media-only"}, + "mimeType" => "image/jpeg", + "size" => 4 + } + }) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "uri" => "at://did:plc:testdid/app.bsky.feed.post/media-only", + "cid" => "bafycid-media-only" + }) + }} + ]) + + assert :ok = Bluesky.mirror_post(message) + + record_payload = + MockHTTPClient.requests() + |> Enum.at(2) + |> Map.fetch!(:body) + |> Jason.decode!() + |> Map.fetch!("record") + + assert record_payload["text"] == "" + assert record_payload["embed"]["$type"] == "app.bsky.embed.images" + assert length(record_payload["embed"]["images"]) == 1 + end + + test "builds rich text facets and quote/link embed mapping" do + user = bluesky_user_fixture() + mentioned = bluesky_user_fixture() + + from(u in User, where: u.id == ^mentioned.id) + |> Repo.update_all(set: [bluesky_did: "did:plc:mentioned"]) + + mentioned = Repo.get!(User, mentioned.id) + + quoted = post_fixture(%{user: user, visibility: "public", content: "quoted source"}) + + from(m in Message, where: m.id == ^quoted.id) + |> Repo.update_all( + set: [ + bluesky_uri: "at://did:plc:testdid/app.bsky.feed.post/quoted1", + bluesky_cid: "quotedcid1" + ] + ) + + message = + post_fixture(%{ + user: user, + visibility: "public", + content: "hi @#{mentioned.handle} https://example.com #tag" + }) + + from(m in Message, where: m.id == ^message.id) + |> Repo.update_all( + set: [quoted_message_id: quoted.id, primary_url: "https://example.org/article"] + ) + + message = Repo.get!(Message, message.id) + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "uri" => "at://did:plc:testdid/app.bsky.feed.post/facets1", + "cid" => "bafycid-facets1" + }) + }} + ]) + + assert :ok = Bluesky.mirror_post(message) + + record_payload = + MockHTTPClient.requests() + |> Enum.at(1) + |> Map.fetch!(:body) + |> Jason.decode!() + |> Map.fetch!("record") + + features = + record_payload["facets"] + |> Enum.flat_map(&(&1["features"] || [])) + + assert Enum.any?(features, &(&1["$type"] == "app.bsky.richtext.facet#link")) + + assert Enum.any?( + features, + &(&1["$type"] == "app.bsky.richtext.facet#tag" and &1["tag"] == "tag") + ) + + assert Enum.any?(features, fn feature -> + feature["$type"] == "app.bsky.richtext.facet#mention" and + feature["did"] == "did:plc:mentioned" + end) + + assert record_payload["embed"]["$type"] == "app.bsky.embed.recordWithMedia" + assert record_payload["embed"]["record"]["$type"] == "app.bsky.embed.record" + + assert record_payload["embed"]["record"]["record"]["uri"] == + "at://did:plc:testdid/app.bsky.feed.post/quoted1" + + assert record_payload["embed"]["media"]["$type"] == "app.bsky.embed.external" + assert record_payload["embed"]["media"]["external"]["uri"] == "https://example.org/article" + end + + test "maps video attachments to Bluesky video embed" do + user = bluesky_user_fixture() + video_key = unique_media_key(".mp4") + + video_path = + create_local_upload(video_key, <<0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70>>) + + on_exit(fn -> + File.rm(video_path) + end) + + message = + media_post_fixture(%{ + user: user, + content: "video post", + media_urls: [video_key] + }) + + from(m in Message, where: m.id == ^message.id) + |> Repo.update_all(set: [media_metadata: %{"alt_texts" => %{"0" => "Video alt"}}]) + + message = Repo.get!(Message, message.id) + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "blob" => %{ + "$type" => "blob", + "ref" => %{"$link" => "bafkqaaa-video"}, + "mimeType" => "video/mp4", + "size" => 8 + } + }) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "uri" => "at://did:plc:testdid/app.bsky.feed.post/video1", + "cid" => "bafycid-video1" + }) + }} + ]) + + assert :ok = Bluesky.mirror_post(message) + + requests = MockHTTPClient.requests() + assert Enum.at(requests, 1).url =~ "/xrpc/com.atproto.repo.uploadBlob" + assert header_value(Enum.at(requests, 1).headers, "content-type") == "video/mp4" + + record_payload = + Enum.at(requests, 2).body + |> Jason.decode!() + |> Map.fetch!("record") + + assert record_payload["embed"]["$type"] == "app.bsky.embed.video" + assert record_payload["embed"]["video"]["mimeType"] == "video/mp4" + end + + test "maps non-image media to external embeds" do + user = bluesky_user_fixture() + audio_key = unique_media_key(".mp3") + audio_path = create_local_upload(audio_key, <<0x49, 0x44, 0x33, 0x03, 0x00, 0x00>>) + + on_exit(fn -> + File.rm(audio_path) + end) + + message = + media_post_fixture(%{ + user: user, + content: "", + media_urls: [audio_key] + }) + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "uri" => "at://did:plc:testdid/app.bsky.feed.post/audio1", + "cid" => "bafycid-audio1" + }) + }} + ]) + + assert :ok = Bluesky.mirror_post(message) + + requests = MockHTTPClient.requests() + assert Enum.count(requests) == 2 + + record_payload = + Enum.at(requests, 1).body + |> Jason.decode!() + |> Map.fetch!("record") + + assert record_payload["embed"]["$type"] == "app.bsky.embed.external" + + assert URI.parse(record_payload["embed"]["external"]["uri"]).path =~ + "/api/private-attachments/" + end + + test "mirrors likes and unlikes to Bluesky records" do + user = bluesky_user_fixture() + message = post_fixture(%{user: user, visibility: "public", content: "like target"}) + + from(m in Message, where: m.id == ^message.id) + |> Repo.update_all( + set: [ + bluesky_uri: "at://did:plc:testdid/app.bsky.feed.post/liked1", + bluesky_cid: "likedcid1" + ] + ) + + message = Repo.get!(Message, message.id) + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, %Finch.Response{status: 200, body: Jason.encode!(%{"records" => []})}}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "uri" => "at://did:plc:testdid/app.bsky.feed.like/like1", + "cid" => "likecid1" + }) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "records" => [ + %{ + "uri" => "at://did:plc:testdid/app.bsky.feed.like/like1", + "value" => %{"subject" => %{"uri" => message.bluesky_uri}} + } + ] + }) + }}, + {:ok, %Finch.Response{status: 200, body: "{}"}} + ]) + + assert :ok = Bluesky.mirror_like(message.id, user.id) + assert :ok = Bluesky.mirror_unlike(message.id, user.id) + + requests = MockHTTPClient.requests() + assert Enum.at(requests, 2).url =~ "/xrpc/com.atproto.repo.createRecord" + assert Enum.at(requests, 5).url =~ "/xrpc/com.atproto.repo.deleteRecord" + + like_payload = + Enum.at(requests, 2).body + |> Jason.decode!() + + assert like_payload["collection"] == "app.bsky.feed.like" + assert like_payload["record"]["subject"]["uri"] == message.bluesky_uri + end + + test "unlike paginates beyond ten record pages to find the existing like" do + user = bluesky_user_fixture() + message = post_fixture(%{user: user, visibility: "public", content: "deep like target"}) + + from(m in Message, where: m.id == ^message.id) + |> Repo.update_all( + set: [ + bluesky_uri: "at://did:plc:testdid/app.bsky.feed.post/deep-like", + bluesky_cid: "deep-like-cid" + ] + ) + + message = Repo.get!(Message, message.id) + + list_record_responses = + Enum.map(1..10, fn page -> + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"records" => [], "cursor" => "cursor-#{page}"}) + }} + end) + + matching_page = + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "records" => [ + %{ + "uri" => "at://did:plc:testdid/app.bsky.feed.like/deep-like-1", + "value" => %{"subject" => %{"uri" => message.bluesky_uri}} + } + ] + }) + }} + + MockHTTPClient.put_responses( + [ + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }} + ] ++ + list_record_responses ++ [matching_page, {:ok, %Finch.Response{status: 200, body: "{}"}}] + ) + + assert :ok = Bluesky.mirror_unlike(message.id, user.id) + + requests = MockHTTPClient.requests() + + assert Enum.count( + requests, + &String.contains?(&1.url, "/xrpc/com.atproto.repo.listRecords") + ) == + 11 + + assert List.last(requests).url =~ "/xrpc/com.atproto.repo.deleteRecord" + end + + test "mirrors reposts and follows (plus undo)" do + user = bluesky_user_fixture() + followed = bluesky_user_fixture() + + from(u in User, where: u.id == ^followed.id) + |> Repo.update_all(set: [bluesky_did: "did:plc:followed"]) + + followed = Repo.get!(User, followed.id) + message = post_fixture(%{user: user, visibility: "public", content: "repost target"}) + + from(m in Message, where: m.id == ^message.id) + |> Repo.update_all( + set: [ + bluesky_uri: "at://did:plc:testdid/app.bsky.feed.post/repost1", + bluesky_cid: "repostcid1" + ] + ) + + message = Repo.get!(Message, message.id) + + MockHTTPClient.put_responses([ + # repost + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, %Finch.Response{status: 200, body: Jason.encode!(%{"records" => []})}}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "uri" => "at://did:plc:testdid/app.bsky.feed.repost/repost1", + "cid" => "repostcid1" + }) + }}, + # unrepost + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "records" => [ + %{ + "uri" => "at://did:plc:testdid/app.bsky.feed.repost/repost1", + "value" => %{"subject" => %{"uri" => message.bluesky_uri}} + } + ] + }) + }}, + {:ok, %Finch.Response{status: 200, body: "{}"}}, + # follow + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, %Finch.Response{status: 200, body: Jason.encode!(%{"records" => []})}}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "uri" => "at://did:plc:testdid/app.bsky.graph.follow/follow1", + "cid" => "followcid1" + }) + }}, + # unfollow + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "records" => [ + %{ + "uri" => "at://did:plc:testdid/app.bsky.graph.follow/follow1", + "value" => %{"subject" => followed.bluesky_did} + } + ] + }) + }}, + {:ok, %Finch.Response{status: 200, body: "{}"}} + ]) + + assert :ok = Bluesky.mirror_repost(message.id, user.id) + assert :ok = Bluesky.mirror_unrepost(message.id, user.id) + assert :ok = Bluesky.mirror_follow(user.id, followed.id) + assert :ok = Bluesky.mirror_unfollow(user.id, followed.id) + + requests = MockHTTPClient.requests() + + repost_payload = + Enum.at(requests, 2).body + |> Jason.decode!() + + assert repost_payload["collection"] == "app.bsky.feed.repost" + assert repost_payload["record"]["subject"]["uri"] == message.bluesky_uri + + follow_payload = + Enum.at(requests, 8).body + |> Jason.decode!() + + assert follow_payload["collection"] == "app.bsky.graph.follow" + assert follow_payload["record"]["subject"] == followed.bluesky_did + end + + test "updates and deletes already mirrored posts" do + user = bluesky_user_fixture() + message = post_fixture(%{user: user, visibility: "public", content: "before"}) + + from(m in Message, where: m.id == ^message.id) + |> Repo.update_all( + set: [ + bluesky_uri: "at://did:plc:testdid/app.bsky.feed.post/edit1", + bluesky_cid: "oldcid1", + content: "after edit" + ] + ) + + message = Repo.get!(Message, message.id) + + MockHTTPClient.put_responses([ + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "uri" => "at://did:plc:testdid/app.bsky.feed.post/edit1", + "cid" => "newcid1" + }) + }}, + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"accessJwt" => "jwt_token", "did" => "did:plc:testdid"}) + }}, + {:ok, %Finch.Response{status: 200, body: "{}"}} + ]) + + assert :ok = Bluesky.mirror_post_update(message) + assert :ok = Bluesky.mirror_post_delete(message) + + requests = MockHTTPClient.requests() + assert Enum.at(requests, 1).url =~ "/xrpc/com.atproto.repo.putRecord" + assert Enum.at(requests, 3).url =~ "/xrpc/com.atproto.repo.deleteRecord" + + put_payload = + Enum.at(requests, 1).body + |> Jason.decode!() + + assert put_payload["collection"] == "app.bsky.feed.post" + assert put_payload["rkey"] == "edit1" + assert put_payload["record"]["text"] == "after edit" + + reloaded = Repo.get!(Message, message.id) + assert reloaded.bluesky_cid == "newcid1" + end + + defp bluesky_user_fixture(attrs \\ %{}) do + user = user_fixture() + + defaults = %{ + "bluesky_enabled" => true, + "bluesky_identifier" => "#{user.username}.bsky.social", + "bluesky_app_password" => "test-app-password" + } + + {:ok, updated_user} = + Accounts.update_user( + user, + defaults + |> Map.merge(attrs) + ) + + updated_user + end + + defp reply_fixture(user, parent, content) do + {:ok, reply} = + %Message{} + |> Message.changeset(%{ + conversation_id: parent.conversation_id, + sender_id: user.id, + content: content, + message_type: "text", + visibility: "public", + post_type: "post", + reply_to_id: parent.id + }) + |> Repo.insert() + + reply + end + + defp unique_media_key(ext) do + "timeline-attachments/bluesky-test-#{System.unique_integer([:positive])}#{ext}" + end + + defp create_local_upload(relative_key, content) do + uploads_dir = + Application.get_env(:elektrine, :uploads, []) + |> Keyword.get(:uploads_dir, "tmp/test_uploads") + + path = Path.join(uploads_dir, relative_key) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, content) + path + end + + defp header_value(headers, key) do + headers + |> Enum.find_value(fn + {header_key, value} when is_binary(header_key) and is_binary(value) -> + if String.downcase(header_key) == String.downcase(key), do: value, else: nil + + _ -> + nil + end) + end +end diff --git a/apps/elektrine/test/elektrine/booking_confirmation_test.exs b/apps/elektrine/test/elektrine/booking_confirmation_test.exs new file mode 100644 index 0000000..bea1494 --- /dev/null +++ b/apps/elektrine/test/elektrine/booking_confirmation_test.exs @@ -0,0 +1,223 @@ +defmodule Elektrine.BookingConfirmationTest do + @moduledoc """ + What happens after someone confirms: the owner's calendar and the invitee's + inbox. + """ + use Elektrine.DataCase + + import Elektrine.AccountsFixtures + import Swoosh.TestAssertions + + alias Elektrine.Booking + alias Elektrine.Booking.Notifier + alias Elektrine.Calendar, as: CalendarContext + + defp all_week_windows do + %{ + "windows" => + Enum.map(1..7, fn day -> + %{"day" => day, "start_minute" => 0, "end_minute" => 1440} + end) + } + end + + setup do + user = user_fixture() + + {:ok, page} = + Booking.create_page(%{ + user_id: user.id, + slug: "chat-#{System.unique_integer([:positive])}", + title: "Coffee chat", + timezone: "Etc/UTC", + windows: all_week_windows(), + slot_minutes: 30, + min_notice_minutes: 0, + max_days_ahead: 2 + }) + + slot = page |> Booking.available_slots() |> hd() + {:ok, held} = Booking.hold_slot(page, slot) + + %{user: user, page: page, booking: held, slot: slot} + end + + test "confirming writes an event onto the owner's calendar", %{ + user: user, + booking: booking, + slot: slot + } do + {:ok, confirmed} = + Booking.confirm_booking(booking.confirmation_token, %{ + invitee_email: "guest@example.com", + invitee_name: "Guest" + }) + + assert confirmed.calendar_event_id + + blocks = + CalendarContext.busy_blocks( + user.id, + DateTime.add(slot.start, -3600, :second), + DateTime.add(slot.end, 3600, :second) + ) + + assert Enum.any?(blocks, &(DateTime.compare(&1.start, slot.start) == :eq)) + end + + # A booked slot must block the calendar, or the owner's other booking pages + # and their own availability view would still think the time is free. + test "the created event marks the owner busy", %{booking: booking, slot: slot} do + {:ok, confirmed} = + Booking.confirm_booking(booking.confirmation_token, %{invitee_email: "guest@example.com"}) + + event = CalendarContext.get_event!(confirmed.calendar_event_id) + + assert event.transparency == "OPAQUE" + assert event.status == "CONFIRMED" + assert DateTime.compare(event.dtstart, slot.start) == :eq + end + + test "the invitee is emailed a confirmation", %{booking: booking} do + {:ok, _confirmed} = + Booking.confirm_booking(booking.confirmation_token, %{ + invitee_email: "guest@example.com", + invitee_name: "Guest" + }) + + assert_email_sent(fn email -> + assert {_, "guest@example.com"} = hd(email.to) + assert email.subject =~ "Coffee chat" + assert email.text_body =~ "Need to cancel?" + end) + end + + test "no email is sent when there is no invitee address", %{page: page} do + slot = page |> Booking.available_slots() |> Enum.at(1) + {:ok, held} = Booking.hold_slot(page, slot) + + # Confirming twice returns the existing booking without re-sending. + {:ok, _} = Booking.confirm_booking(held.confirmation_token, %{invitee_email: "a@example.com"}) + + assert_email_sent() + + {:ok, _} = Booking.confirm_booking(held.confirmation_token, %{}) + + refute_email_sent() + end + + # A cancelled booking must give the time back on the calendar too, or the + # owner stays busy for a meeting nobody is attending. + test "cancelling removes the calendar event it created", %{ + user: user, + booking: booking, + slot: slot + } do + {:ok, confirmed} = + Booking.confirm_booking(booking.confirmation_token, %{invitee_email: "guest@example.com"}) + + assert confirmed.calendar_event_id + + {:ok, _cancelled} = Booking.cancel_booking(confirmed.cancellation_token) + + blocks = + CalendarContext.busy_blocks( + user.id, + DateTime.add(slot.start, -3600, :second), + DateTime.add(slot.end, 3600, :second) + ) + + assert blocks == [] + end + + test "the confirmation carries the calendar invite as an attachment", %{booking: booking} do + {:ok, _confirmed} = + Booking.confirm_booking(booking.confirmation_token, %{invitee_email: "guest@example.com"}) + + assert_email_sent(fn email -> + assert [attachment] = email.attachments + assert attachment.filename == "invite.ics" + assert attachment.content_type == "text/calendar" + assert Swoosh.Attachment.get_content(attachment) =~ "BEGIN:VEVENT" + end) + end + + test "a booking cancelled before the mail job runs sends nothing", %{booking: booking} do + {:ok, confirmed} = + Booking.confirm_booking(booking.confirmation_token, %{invitee_email: "guest@example.com"}) + + # Oban runs inline here, so confirming already sent one. Consume it, then + # check that a retry after cancellation stays quiet. + assert_email_sent() + + {:ok, _} = Booking.cancel_booking(confirmed.cancellation_token) + + assert :ok = + Elektrine.Booking.ConfirmationWorker.perform(%Oban.Job{ + args: %{"booking_id" => confirmed.id} + }) + + refute_email_sent() + end + + describe "list_upcoming_bookings/2" do + test "returns confirmed future bookings only", %{page: page, user: user, booking: booking} do + {:ok, _confirmed} = + Booking.confirm_booking(booking.confirmation_token, %{invitee_email: "guest@example.com"}) + + # A held booking is not something to plan around. + other_slot = page |> Booking.available_slots() |> hd() + {:ok, _held} = Booking.hold_slot(page, other_slot) + + assert [upcoming] = Booking.list_upcoming_bookings(user.id) + assert upcoming.status == "confirmed" + assert upcoming.booking_page.title == "Coffee chat" + end + + test "past bookings drop off", %{user: user, booking: booking, slot: slot} do + {:ok, _confirmed} = + Booking.confirm_booking(booking.confirmation_token, %{invitee_email: "guest@example.com"}) + + later = DateTime.add(slot.end, 3600, :second) + + assert Booking.list_upcoming_bookings(user.id, now: later) == [] + end + end + + describe "invite_ics/3" do + test "produces an importable VEVENT", %{user: user, page: page, booking: booking} do + {:ok, confirmed} = + Booking.confirm_booking(booking.confirmation_token, %{ + invitee_email: "guest@example.com", + invitee_name: "Guest" + }) + + ics = Notifier.invite_ics(confirmed, page, user) + + assert ics =~ "BEGIN:VCALENDAR" + assert ics =~ "BEGIN:VEVENT" + assert ics =~ "SUMMARY:Coffee chat" + assert ics =~ "END:VEVENT" + assert ics =~ "END:VCALENDAR" + end + end + + describe "expire_holds worker" do + test "sweeps holds past their expiry", %{page: page} do + slot = page |> Booking.available_slots() |> Enum.at(2) + {:ok, held} = Booking.hold_slot(page, slot) + + # Age the hold rather than waiting ten minutes. + Repo.update_all( + from(b in Elektrine.Booking.Booking, where: b.id == ^held.id), + set: [ + hold_expires_at: + DateTime.utc_now() |> DateTime.add(-60, :second) |> DateTime.truncate(:second) + ] + ) + + assert :ok = Elektrine.Booking.ExpireHoldsWorker.perform(%Oban.Job{args: %{}}) + assert Repo.get(Elektrine.Booking.Booking, held.id).status == "expired" + end + end +end diff --git a/apps/elektrine/test/elektrine/booking_test.exs b/apps/elektrine/test/elektrine/booking_test.exs new file mode 100644 index 0000000..ce99bdb --- /dev/null +++ b/apps/elektrine/test/elektrine/booking_test.exs @@ -0,0 +1,334 @@ +defmodule Elektrine.BookingTest do + use Elektrine.DataCase + + import Elektrine.AccountsFixtures + + alias Elektrine.Booking + alias Elektrine.Calendar, as: CalendarContext + + defp utc(iso), do: iso |> DateTime.from_iso8601() |> elem(1) |> DateTime.truncate(:second) + + # Monday-to-Friday, 09:00-17:00 local. + defp weekday_windows do + %{ + "windows" => + Enum.map(1..5, fn day -> + %{"day" => day, "start_minute" => 540, "end_minute" => 1020} + end) + } + end + + setup do + user = user_fixture() + + {:ok, page} = + Booking.create_page(%{ + user_id: user.id, + slug: "chat-#{System.unique_integer([:positive])}", + title: "Coffee chat", + timezone: "Europe/Berlin", + windows: weekday_windows(), + slot_minutes: 30, + min_notice_minutes: 0, + max_days_ahead: 7 + }) + + %{user: user, page: page} + end + + describe "pages" do + test "rejects an unknown timezone", %{user: user} do + assert {:error, changeset} = + Booking.create_page(%{ + user_id: user.id, + slug: "bad-zone", + title: "Nope", + timezone: "Mars/Olympus_Mons" + }) + + assert "is not a known timezone" in errors_on(changeset).timezone + end + + test "rejects malformed windows", %{user: user} do + assert {:error, changeset} = + Booking.create_page(%{ + user_id: user.id, + slug: "bad-windows", + title: "Nope", + timezone: "Etc/UTC", + windows: %{ + "windows" => [%{"day" => 9, "start_minute" => 600, "end_minute" => 60}] + } + }) + + assert changeset.errors[:windows] + end + + test "slugs are unique and normalized", %{user: user} do + assert {:ok, page} = + Booking.create_page(%{ + user_id: user.id, + slug: " MyPage ", + title: "Mine", + timezone: "Etc/UTC" + }) + + assert page.slug == "mypage" + + assert {:error, changeset} = + Booking.create_page(%{ + user_id: user.id, + slug: "mypage", + title: "Also mine", + timezone: "Etc/UTC" + }) + + assert errors_on(changeset).slug + end + + test "an inactive page is not publicly reachable", %{page: page} do + assert Booking.get_public_page(page.slug) + + {:ok, _} = Booking.update_page(page, %{active: false}) + + refute Booking.get_public_page(page.slug) + end + end + + describe "available_slots/2" do + test "offers the local working day", %{page: page} do + slots = + Booking.available_slots(page, + now: utc("2026-03-02T00:00:00Z"), + from: utc("2026-03-02T00:00:00Z"), + until: utc("2026-03-03T00:00:00Z") + ) + + # 09:00-17:00 Berlin is 08:00-16:00 UTC in March: 16 half-hour slots. + assert length(slots) == 16 + assert hd(slots).start == utc("2026-03-02T08:00:00Z") + end + + test "a calendar event removes the overlapping slots", %{user: user, page: page} do + {:ok, calendar} = CalendarContext.create_calendar(%{user_id: user.id, name: "Work"}) + + {:ok, _event} = + CalendarContext.create_event(%{ + calendar_id: calendar.id, + uid: "busy@example.com", + summary: "Standup", + dtstart: utc("2026-03-02T08:00:00Z"), + dtend: utc("2026-03-02T09:00:00Z") + }) + + slots = + Booking.available_slots(page, + now: utc("2026-03-02T00:00:00Z"), + from: utc("2026-03-02T00:00:00Z"), + until: utc("2026-03-03T00:00:00Z") + ) + + starts = Enum.map(slots, & &1.start) + refute utc("2026-03-02T08:00:00Z") in starts + refute utc("2026-03-02T08:30:00Z") in starts + assert utc("2026-03-02T09:00:00Z") in starts + end + + # The whole reason the calendar layer expands recurrence. + test "a recurring meeting from months ago still blocks", %{user: user, page: page} do + {:ok, calendar} = CalendarContext.create_calendar(%{user_id: user.id, name: "Work"}) + + {:ok, _event} = + CalendarContext.create_event(%{ + calendar_id: calendar.id, + uid: "weekly@example.com", + summary: "Weekly sync", + dtstart: utc("2026-01-05T08:00:00Z"), + dtend: utc("2026-01-05T09:00:00Z"), + rrule: "FREQ=WEEKLY" + }) + + slots = + Booking.available_slots(page, + now: utc("2026-03-02T00:00:00Z"), + from: utc("2026-03-02T00:00:00Z"), + until: utc("2026-03-03T00:00:00Z") + ) + + refute utc("2026-03-02T08:00:00Z") in Enum.map(slots, & &1.start) + end + + test "minimum notice hides imminent slots", %{page: page} do + {:ok, page} = Booking.update_page(page, %{min_notice_minutes: 120}) + + slots = + Booking.available_slots(page, + now: utc("2026-03-02T08:00:00Z"), + from: utc("2026-03-02T08:00:00Z"), + until: utc("2026-03-03T00:00:00Z") + ) + + assert hd(slots).start == utc("2026-03-02T10:00:00Z") + end + end + + describe "holds" do + defp first_slot(page, now) do + page + |> Booking.available_slots(now: now, from: now, until: DateTime.add(now, 86_400, :second)) + |> hd() + end + + test "holding a slot takes it out of circulation", %{page: page} do + now = utc("2026-03-02T00:00:00Z") + slot = first_slot(page, now) + + assert {:ok, booking} = Booking.hold_slot(page, slot, now: now) + assert booking.status == "held" + assert booking.confirmation_token + assert booking.cancellation_token + + refute slot.start in Enum.map( + Booking.available_slots(page, now: now, from: now), + & &1.start + ) + end + + # Two requests can pass the same availability check simultaneously; only + # the database can arbitrate, which is why the partial unique index exists. + test "a second hold on the same slot loses", %{page: page} do + now = utc("2026-03-02T00:00:00Z") + slot = first_slot(page, now) + + assert {:ok, _first} = Booking.hold_slot(page, slot, now: now) + assert {:error, :slot_unavailable} = Booking.hold_slot(page, slot, now: now) + end + + test "an expired hold frees the slot again", %{page: page} do + now = utc("2026-03-02T00:00:00Z") + slot = first_slot(page, now) + + {:ok, _booking} = Booking.hold_slot(page, slot, now: now) + + later = DateTime.add(now, 30 * 60, :second) + + assert slot.start in Enum.map( + Booking.available_slots(page, now: later, from: now), + & &1.start + ) + end + + test "expire_holds/1 sweeps abandoned holds", %{page: page} do + now = utc("2026-03-02T00:00:00Z") + slot = first_slot(page, now) + + {:ok, booking} = Booking.hold_slot(page, slot, now: now) + + assert Booking.expire_holds(now: DateTime.add(now, 30 * 60, :second)) == 1 + assert Repo.get(Elektrine.Booking.Booking, booking.id).status == "expired" + end + + test "a slot outside the offered set cannot be held", %{page: page} do + now = utc("2026-03-02T00:00:00Z") + + # 03:00 UTC is the middle of the night in Berlin. + slot = %{start: utc("2026-03-02T03:00:00Z"), end: utc("2026-03-02T03:30:00Z")} + + assert {:error, :slot_unavailable} = Booking.hold_slot(page, slot, now: now) + end + end + + describe "confirmation and cancellation" do + setup %{page: page} do + now = utc("2026-03-02T00:00:00Z") + + slot = + page + |> Booking.available_slots(now: now, from: now, until: utc("2026-03-03T00:00:00Z")) + |> hd() + + {:ok, booking} = Booking.hold_slot(page, slot, now: now) + + %{booking: booking, now: now, slot: slot} + end + + test "confirming records the invitee", %{booking: booking, now: now} do + assert {:ok, confirmed} = + Booking.confirm_booking( + booking.confirmation_token, + %{invitee_email: "guest@example.com", invitee_name: "Guest", note: "Hello"}, + now: now + ) + + assert confirmed.status == "confirmed" + assert confirmed.invitee_email == "guest@example.com" + assert is_nil(confirmed.hold_expires_at) + end + + test "confirming requires an email", %{booking: booking, now: now} do + assert {:error, changeset} = + Booking.confirm_booking(booking.confirmation_token, %{invitee_name: "Guest"}, + now: now + ) + + assert errors_on(changeset).invitee_email + end + + test "an expired hold cannot be confirmed", %{booking: booking, now: now} do + assert {:error, :hold_expired} = + Booking.confirm_booking( + booking.confirmation_token, + %{invitee_email: "guest@example.com"}, + now: DateTime.add(now, 30 * 60, :second) + ) + end + + test "confirming twice is not an error", %{booking: booking, now: now} do + {:ok, _} = + Booking.confirm_booking(booking.confirmation_token, %{invitee_email: "g@example.com"}, + now: now + ) + + assert {:ok, confirmed} = + Booking.confirm_booking(booking.confirmation_token, %{}, now: now) + + assert confirmed.status == "confirmed" + end + + test "an unknown token is rejected", %{now: now} do + assert {:error, :not_found} = + Booking.confirm_booking("nope", %{invitee_email: "g@example.com"}, now: now) + end + + test "cancelling frees the slot", %{booking: booking, page: page, now: now, slot: slot} do + {:ok, _} = + Booking.confirm_booking(booking.confirmation_token, %{invitee_email: "g@example.com"}, + now: now + ) + + refute slot.start in Enum.map( + Booking.available_slots(page, now: now, from: now), + & &1.start + ) + + assert {:ok, cancelled} = Booking.cancel_booking(booking.cancellation_token) + assert cancelled.status == "cancelled" + + assert slot.start in Enum.map( + Booking.available_slots(page, now: now, from: now), + & &1.start + ) + end + + test "the owner can cancel", %{booking: booking, user: user} do + assert {:ok, cancelled} = Booking.cancel_booking(user.id, booking.id) + assert cancelled.status == "cancelled" + end + + test "another user cannot cancel someone's booking", %{booking: booking} do + other = user_fixture() + + assert {:error, :not_found} = Booking.cancel_booking(other.id, booking.id) + end + end +end diff --git a/apps/elektrine/test/elektrine/calendar/availability_test.exs b/apps/elektrine/test/elektrine/calendar/availability_test.exs new file mode 100644 index 0000000..857677c --- /dev/null +++ b/apps/elektrine/test/elektrine/calendar/availability_test.exs @@ -0,0 +1,345 @@ +defmodule Elektrine.Calendar.AvailabilityTest do + use ExUnit.Case, async: true + + alias Elektrine.Calendar.Availability + alias Elektrine.Calendar.Recurrence + + defp utc(iso), do: iso |> DateTime.from_iso8601() |> elem(1) + + defp event(attrs) do + Map.merge( + %{ + dtstart: utc("2026-03-02T09:00:00Z"), + dtend: utc("2026-03-02T10:00:00Z"), + transparency: "OPAQUE", + status: "CONFIRMED", + rrule: nil, + rdate: [], + exdate: [] + }, + attrs + ) + end + + describe "recurrence expansion" do + test "a non-recurring event yields one occurrence" do + result = + Recurrence.expand( + event(%{}), + utc("2026-03-01T00:00:00Z"), + utc("2026-03-31T00:00:00Z") + ) + + assert length(result.occurrences) == 1 + refute result.unsupported? + end + + # The case that makes an unexpanded calendar dangerous: the series was + # created before the window, so filtering on dtstart alone finds nothing. + test "a weekly series started before the window still occupies it" do + weekly = event(%{rrule: "FREQ=WEEKLY"}) + + # Mondays 6, 13, 20, 27 April; the window closes after the last one's + # 09:00 start so all four are inside it. + result = + Recurrence.expand(weekly, utc("2026-04-06T00:00:00Z"), utc("2026-04-28T00:00:00Z")) + + assert length(result.occurrences) == 4 + assert Enum.all?(result.occurrences, &(DateTime.to_time(&1.start) == ~T[09:00:00])) + end + + test "BYDAY expands to several days a week" do + rule = event(%{rrule: "FREQ=WEEKLY;BYDAY=MO,WE,FR"}) + + result = Recurrence.expand(rule, utc("2026-03-02T00:00:00Z"), utc("2026-03-08T23:59:59Z")) + + days = Enum.map(result.occurrences, &Date.day_of_week(DateTime.to_date(&1.start))) + assert days == [1, 3, 5] + end + + test "COUNT ends the series" do + rule = event(%{rrule: "FREQ=DAILY;COUNT=3"}) + + result = Recurrence.expand(rule, utc("2026-03-01T00:00:00Z"), utc("2026-03-31T00:00:00Z")) + + assert length(result.occurrences) == 3 + end + + test "UNTIL ends the series" do + rule = event(%{rrule: "FREQ=DAILY;UNTIL=20260305T090000Z"}) + + result = Recurrence.expand(rule, utc("2026-03-01T00:00:00Z"), utc("2026-03-31T00:00:00Z")) + + assert length(result.occurrences) == 4 + end + + test "INTERVAL skips periods" do + rule = event(%{rrule: "FREQ=DAILY;INTERVAL=2;COUNT=3"}) + + result = Recurrence.expand(rule, utc("2026-03-01T00:00:00Z"), utc("2026-03-31T00:00:00Z")) + + assert Enum.map(result.occurrences, &DateTime.to_date(&1.start)) == [ + ~D[2026-03-02], + ~D[2026-03-04], + ~D[2026-03-06] + ] + end + + test "EXDATE removes a cancelled instance" do + rule = + event(%{ + rrule: "FREQ=DAILY;COUNT=3", + exdate: [utc("2026-03-03T09:00:00Z")] + }) + + result = Recurrence.expand(rule, utc("2026-03-01T00:00:00Z"), utc("2026-03-31T00:00:00Z")) + + dates = Enum.map(result.occurrences, &DateTime.to_date(&1.start)) + assert dates == [~D[2026-03-02], ~D[2026-03-04]] + end + + test "RDATE adds a one-off instance" do + rule = event(%{rdate: [utc("2026-03-15T14:00:00Z")]}) + + result = Recurrence.expand(rule, utc("2026-03-01T00:00:00Z"), utc("2026-03-31T00:00:00Z")) + + assert length(result.occurrences) == 2 + end + + test "monthly recurrence clamps rather than skipping short months" do + rule = + event(%{ + dtstart: utc("2026-01-31T09:00:00Z"), + dtend: utc("2026-01-31T10:00:00Z"), + rrule: "FREQ=MONTHLY;COUNT=3" + }) + + result = Recurrence.expand(rule, utc("2026-01-01T00:00:00Z"), utc("2026-05-01T00:00:00Z")) + + assert Enum.map(result.occurrences, &DateTime.to_date(&1.start)) == [ + ~D[2026-01-31], + ~D[2026-02-28], + ~D[2026-03-31] + ] + end + + # An unmodelled rule must announce itself rather than silently reporting + # one occurrence as though the series did not exist. + test "an unsupported rule is reported" do + rule = event(%{rrule: "FREQ=MONTHLY;BYSETPOS=3;BYDAY=TH"}) + + result = Recurrence.expand(rule, utc("2026-03-01T00:00:00Z"), utc("2026-06-01T00:00:00Z")) + + assert result.unsupported? + assert length(result.occurrences) == 1 + end + end + + describe "busy_blocks/3" do + test "TRANSPARENT events do not block" do + events = [event(%{transparency: "TRANSPARENT"})] + + assert Availability.busy_blocks( + events, + utc("2026-03-01T00:00:00Z"), + utc("2026-03-31T00:00:00Z") + ) == [] + end + + test "CANCELLED events do not block" do + events = [event(%{status: "CANCELLED"})] + + assert Availability.busy_blocks( + events, + utc("2026-03-01T00:00:00Z"), + utc("2026-03-31T00:00:00Z") + ) == [] + end + + test "overlapping events merge into one block" do + events = [ + event(%{ + dtstart: utc("2026-03-02T09:00:00Z"), + dtend: utc("2026-03-02T10:00:00Z") + }), + event(%{ + dtstart: utc("2026-03-02T09:30:00Z"), + dtend: utc("2026-03-02T11:00:00Z") + }) + ] + + assert [block] = + Availability.busy_blocks( + events, + utc("2026-03-01T00:00:00Z"), + utc("2026-03-03T00:00:00Z") + ) + + assert block.start == utc("2026-03-02T09:00:00Z") + assert block.end == utc("2026-03-02T11:00:00Z") + end + + test "adjacent events merge, separated ones do not" do + events = [ + event(%{dtstart: utc("2026-03-02T09:00:00Z"), dtend: utc("2026-03-02T10:00:00Z")}), + event(%{dtstart: utc("2026-03-02T10:00:00Z"), dtend: utc("2026-03-02T11:00:00Z")}), + event(%{dtstart: utc("2026-03-02T13:00:00Z"), dtend: utc("2026-03-02T14:00:00Z")}) + ] + + blocks = + Availability.busy_blocks( + events, + utc("2026-03-01T00:00:00Z"), + utc("2026-03-03T00:00:00Z") + ) + + assert length(blocks) == 2 + end + + test "blocks are clamped to the window" do + events = [ + event(%{dtstart: utc("2026-03-01T23:00:00Z"), dtend: utc("2026-03-02T02:00:00Z")}) + ] + + assert [block] = + Availability.busy_blocks( + events, + utc("2026-03-02T00:00:00Z"), + utc("2026-03-03T00:00:00Z") + ) + + assert block.start == utc("2026-03-02T00:00:00Z") + end + end + + describe "free_slots/2" do + test "offers aligned slots across an empty window" do + slots = + Availability.free_slots(utc("2026-03-02T09:00:00Z"), + until: utc("2026-03-02T11:00:00Z"), + slot_minutes: 30, + now: utc("2026-03-01T00:00:00Z") + ) + + assert length(slots) == 4 + assert hd(slots).start == utc("2026-03-02T09:00:00Z") + end + + test "never offers a slot overlapping a busy block" do + busy = [%{start: utc("2026-03-02T09:30:00Z"), end: utc("2026-03-02T10:00:00Z")}] + + slots = + Availability.free_slots(utc("2026-03-02T09:00:00Z"), + until: utc("2026-03-02T11:00:00Z"), + busy: busy, + now: utc("2026-03-01T00:00:00Z") + ) + + starts = Enum.map(slots, & &1.start) + refute utc("2026-03-02T09:30:00Z") in starts + assert utc("2026-03-02T09:00:00Z") in starts + assert utc("2026-03-02T10:00:00Z") in starts + end + + test "a buffer keeps neighbouring slots clear" do + busy = [%{start: utc("2026-03-02T10:00:00Z"), end: utc("2026-03-02T10:30:00Z")}] + + slots = + Availability.free_slots(utc("2026-03-02T09:00:00Z"), + until: utc("2026-03-02T12:00:00Z"), + busy: busy, + buffer_minutes: 15, + now: utc("2026-03-01T00:00:00Z") + ) + + starts = Enum.map(slots, & &1.start) + refute utc("2026-03-02T09:30:00Z") in starts + refute utc("2026-03-02T10:30:00Z") in starts + assert utc("2026-03-02T09:00:00Z") in starts + assert utc("2026-03-02T11:00:00Z") in starts + end + + test "minimum notice hides slots that are too soon" do + slots = + Availability.free_slots(utc("2026-03-02T09:00:00Z"), + until: utc("2026-03-02T12:00:00Z"), + now: utc("2026-03-02T08:00:00Z"), + min_notice_minutes: 120 + ) + + assert hd(slots).start == utc("2026-03-02T10:00:00Z") + end + + test "weekly windows restrict the offered hours" do + # Monday 09:00-10:00 UTC only. + windows = [%{day: 1, start_minute: 540, end_minute: 600}] + + slots = + Availability.free_slots(utc("2026-03-02T00:00:00Z"), + until: utc("2026-03-04T00:00:00Z"), + windows: windows, + now: utc("2026-03-01T00:00:00Z") + ) + + assert Enum.map(slots, & &1.start) == [ + utc("2026-03-02T09:00:00Z"), + utc("2026-03-02T09:30:00Z") + ] + end + + test "a slot ending after the window closes is not offered" do + windows = [%{day: 1, start_minute: 540, end_minute: 585}] + + slots = + Availability.free_slots(utc("2026-03-02T00:00:00Z"), + until: utc("2026-03-03T00:00:00Z"), + windows: windows, + slot_minutes: 30, + now: utc("2026-03-01T00:00:00Z") + ) + + assert Enum.map(slots, & &1.start) == [utc("2026-03-02T09:00:00Z")] + end + + test "max_per_day caps the offer" do + slots = + Availability.free_slots(utc("2026-03-02T09:00:00Z"), + until: utc("2026-03-04T09:00:00Z"), + max_per_day: 2, + now: utc("2026-03-01T00:00:00Z") + ) + + by_day = Enum.group_by(slots, &DateTime.to_date(&1.start)) + assert Enum.all?(by_day, fn {_day, day_slots} -> length(day_slots) <= 2 end) + end + + test "slots align to the boundary even from a ragged start" do + slots = + Availability.free_slots(utc("2026-03-02T09:07:00Z"), + until: utc("2026-03-02T10:30:00Z"), + now: utc("2026-03-01T00:00:00Z") + ) + + assert hd(slots).start == utc("2026-03-02T09:30:00Z") + end + end + + describe "slot_free?/3" do + test "re-checks a slot against current busy time" do + slot = %{start: utc("2026-03-02T09:00:00Z"), end: utc("2026-03-02T09:30:00Z")} + + assert Availability.slot_free?(slot, []) + + refute Availability.slot_free?(slot, [ + %{start: utc("2026-03-02T09:15:00Z"), end: utc("2026-03-02T09:45:00Z")} + ]) + end + + test "a slot touching the edge of a block is still free" do + slot = %{start: utc("2026-03-02T09:00:00Z"), end: utc("2026-03-02T09:30:00Z")} + busy = [%{start: utc("2026-03-02T09:30:00Z"), end: utc("2026-03-02T10:00:00Z")}] + + assert Availability.slot_free?(slot, busy) + end + end +end diff --git a/apps/elektrine/test/elektrine/calendar/availability_zone_test.exs b/apps/elektrine/test/elektrine/calendar/availability_zone_test.exs new file mode 100644 index 0000000..cc53bb3 --- /dev/null +++ b/apps/elektrine/test/elektrine/calendar/availability_zone_test.exs @@ -0,0 +1,183 @@ +defmodule Elektrine.Calendar.AvailabilityZoneTest do + @moduledoc """ + Local-time availability, including the two days a year that break naive + implementations. + """ + use ExUnit.Case, async: true + + alias Elektrine.Calendar.Availability + + defp utc(iso), do: iso |> DateTime.from_iso8601() |> elem(1) + + defp local(slot, timezone) do + {:ok, shifted} = DateTime.shift_zone(slot.start, timezone, Tz.TimeZoneDatabase) + {DateTime.to_date(shifted), DateTime.to_time(shifted)} + end + + # Monday 09:00-11:00 local. + defp morning_window, do: [%{day: 1, start_minute: 540, end_minute: 660}] + + describe "free_slots_in_zone/2" do + test "offers local morning hours, not UTC ones" do + slots = + Availability.free_slots_in_zone(utc("2026-03-02T00:00:00Z"), + timezone: "Europe/Berlin", + until: utc("2026-03-03T00:00:00Z"), + windows: morning_window(), + now: utc("2026-03-01T00:00:00Z") + ) + + assert length(slots) == 4 + + # Berlin is UTC+1 in early March, so 09:00 local is 08:00 UTC. + assert hd(slots).start == utc("2026-03-02T08:00:00Z") + assert local(hd(slots), "Europe/Berlin") == {~D[2026-03-02], ~T[09:00:00]} + end + + test "keeps the same local hour after a spring-forward transition" do + # Europe/Berlin springs forward on 2026-03-29, so the Mondays either side + # sit at different UTC offsets. The local time offered must not move. + before_dst = + Availability.free_slots_in_zone(utc("2026-03-23T00:00:00Z"), + timezone: "Europe/Berlin", + until: utc("2026-03-24T00:00:00Z"), + windows: morning_window(), + now: utc("2026-03-01T00:00:00Z") + ) + + after_dst = + Availability.free_slots_in_zone(utc("2026-03-30T00:00:00Z"), + timezone: "Europe/Berlin", + until: utc("2026-03-31T00:00:00Z"), + windows: morning_window(), + now: utc("2026-03-01T00:00:00Z") + ) + + assert local(hd(before_dst), "Europe/Berlin") |> elem(1) == ~T[09:00:00] + assert local(hd(after_dst), "Europe/Berlin") |> elem(1) == ~T[09:00:00] + + # Same wall clock, one hour apart in UTC — the offset changed underneath. + assert hd(before_dst).start == utc("2026-03-23T08:00:00Z") + assert hd(after_dst).start == utc("2026-03-30T07:00:00Z") + end + + test "a window inside a spring-forward gap shrinks instead of inventing time" do + # 2026-03-29 in Berlin: 02:00 jumps straight to 03:00, so 02:00-03:00 + # local does not exist. Sunday = day 7. + slots = + Availability.free_slots_in_zone(utc("2026-03-29T00:00:00Z"), + timezone: "Europe/Berlin", + until: utc("2026-03-30T00:00:00Z"), + windows: [%{day: 7, start_minute: 120, end_minute: 180}], + now: utc("2026-03-01T00:00:00Z") + ) + + assert slots == [] + end + + test "an autumn-ambiguous window covers both passes of the hour" do + # 2026-10-25 in Berlin: 03:00 falls back to 02:00, so 02:00-03:00 local + # happens twice and a person free "02:00-03:00" is free for both. + slots = + Availability.free_slots_in_zone(utc("2026-10-25T00:00:00Z"), + timezone: "Europe/Berlin", + until: utc("2026-10-26T00:00:00Z"), + windows: [%{day: 7, start_minute: 120, end_minute: 180}], + slot_minutes: 30, + now: utc("2026-10-01T00:00:00Z") + ) + + # Two hours of real time, so four half-hour slots rather than two. + assert length(slots) == 4 + end + + test "the southern hemisphere transitions the other way" do + # Australia/Sydney springs forward in October; the local hour is stable + # across it while UTC moves the other direction from Europe. + # Sydney runs UTC+10/+11, so a Monday morning there begins on the + # previous UTC day; the window has to start early enough to contain it. + before_dst = + Availability.free_slots_in_zone(utc("2026-09-27T00:00:00Z"), + timezone: "Australia/Sydney", + until: utc("2026-09-29T00:00:00Z"), + windows: morning_window(), + now: utc("2026-09-01T00:00:00Z") + ) + + after_dst = + Availability.free_slots_in_zone(utc("2026-10-11T00:00:00Z"), + timezone: "Australia/Sydney", + until: utc("2026-10-13T00:00:00Z"), + windows: morning_window(), + now: utc("2026-09-01T00:00:00Z") + ) + + assert local(hd(before_dst), "Australia/Sydney") |> elem(1) == ~T[09:00:00] + assert local(hd(after_dst), "Australia/Sydney") |> elem(1) == ~T[09:00:00] + + # AEST (+10) before the transition, AEDT (+11) after: the local hour held + # while UTC moved the opposite way to Europe's March change. + assert hd(before_dst).start == utc("2026-09-27T23:00:00Z") + assert hd(after_dst).start == utc("2026-10-11T22:00:00Z") + end + + test "busy blocks still apply across zones" do + busy = [%{start: utc("2026-03-02T08:00:00Z"), end: utc("2026-03-02T08:30:00Z")}] + + slots = + Availability.free_slots_in_zone(utc("2026-03-02T00:00:00Z"), + timezone: "Europe/Berlin", + until: utc("2026-03-03T00:00:00Z"), + windows: morning_window(), + busy: busy, + now: utc("2026-03-01T00:00:00Z") + ) + + refute utc("2026-03-02T08:00:00Z") in Enum.map(slots, & &1.start) + assert length(slots) == 3 + end + + test "minimum notice applies in real time, not local time" do + slots = + Availability.free_slots_in_zone(utc("2026-03-02T00:00:00Z"), + timezone: "Europe/Berlin", + until: utc("2026-03-03T00:00:00Z"), + windows: morning_window(), + now: utc("2026-03-02T08:00:00Z"), + min_notice_minutes: 60 + ) + + assert hd(slots).start == utc("2026-03-02T09:00:00Z") + end + + test "max_per_day counts local days" do + slots = + Availability.free_slots_in_zone(utc("2026-03-02T00:00:00Z"), + timezone: "Europe/Berlin", + until: utc("2026-03-10T00:00:00Z"), + windows: morning_window(), + max_per_day: 2, + now: utc("2026-03-01T00:00:00Z") + ) + + by_local_day = Enum.group_by(slots, &(local(&1, "Europe/Berlin") |> elem(0))) + assert Enum.all?(by_local_day, fn {_day, day_slots} -> length(day_slots) <= 2 end) + end + + test "an unknown timezone yields nothing rather than crashing" do + assert Availability.free_slots_in_zone(utc("2026-03-02T00:00:00Z"), + timezone: "Mars/Olympus_Mons", + until: utc("2026-03-03T00:00:00Z"), + windows: morning_window() + ) == [] + end + + test "no windows means nothing on offer" do + assert Availability.free_slots_in_zone(utc("2026-03-02T00:00:00Z"), + timezone: "Europe/Berlin", + until: utc("2026-03-03T00:00:00Z"), + windows: [] + ) == [] + end + end +end diff --git a/apps/elektrine/test/elektrine/calendar/busy_blocks_test.exs b/apps/elektrine/test/elektrine/calendar/busy_blocks_test.exs new file mode 100644 index 0000000..046d323 --- /dev/null +++ b/apps/elektrine/test/elektrine/calendar/busy_blocks_test.exs @@ -0,0 +1,115 @@ +defmodule Elektrine.Calendar.BusyBlocksTest do + use Elektrine.DataCase + + alias Elektrine.Accounts + alias Elektrine.Calendar, as: CalendarContext + + defp create_user do + {:ok, user} = + Accounts.create_user(%{ + username: "busy#{System.unique_integer([:positive])}", + password: "testpassword123", + password_confirmation: "testpassword123" + }) + + user + end + + defp utc(iso), do: iso |> DateTime.from_iso8601() |> elem(1) |> DateTime.truncate(:second) + + setup do + user = create_user() + {:ok, calendar} = CalendarContext.create_calendar(%{user_id: user.id, name: "Work"}) + + %{user: user, calendar: calendar} + end + + defp create_event(calendar, attrs) do + {:ok, event} = + CalendarContext.create_event( + Map.merge( + %{ + calendar_id: calendar.id, + uid: "evt-#{System.unique_integer([:positive])}@example.com", + summary: "Meeting", + dtstart: utc("2026-03-02T09:00:00Z"), + dtend: utc("2026-03-02T10:00:00Z") + }, + attrs + ) + ) + + event + end + + test "a one-off event blocks its own time", %{user: user, calendar: calendar} do + create_event(calendar, %{}) + + blocks = + CalendarContext.busy_blocks( + user.id, + utc("2026-03-01T00:00:00Z"), + utc("2026-03-08T00:00:00Z") + ) + + assert [%{start: start_at, end: end_at}] = blocks + assert start_at == utc("2026-03-02T09:00:00Z") + assert end_at == utc("2026-03-02T10:00:00Z") + end + + # This is the whole reason list_user_events_for_window/3 exists: the series + # starts months before the window, so a dtstart-range query misses it and a + # booking page would sell the slot out from under a standing meeting. + test "a recurring series started before the window still blocks", %{ + user: user, + calendar: calendar + } do + create_event(calendar, %{ + dtstart: utc("2026-01-05T09:00:00Z"), + dtend: utc("2026-01-05T09:30:00Z"), + rrule: "FREQ=WEEKLY" + }) + + window_start = utc("2026-03-02T00:00:00Z") + window_end = utc("2026-03-09T00:00:00Z") + + assert CalendarContext.list_user_events_in_range(user.id, window_start, window_end) == [] + + blocks = CalendarContext.busy_blocks(user.id, window_start, window_end) + + assert [%{start: start_at}] = blocks + assert start_at == utc("2026-03-02T09:00:00Z") + end + + test "transparent events leave the time bookable", %{user: user, calendar: calendar} do + create_event(calendar, %{transparency: "TRANSPARENT"}) + + assert CalendarContext.busy_blocks( + user.id, + utc("2026-03-01T00:00:00Z"), + utc("2026-03-08T00:00:00Z") + ) == [] + end + + test "another user's calendar does not block this one", %{user: user, calendar: calendar} do + other = create_user() + {:ok, other_calendar} = CalendarContext.create_calendar(%{user_id: other.id, name: "Theirs"}) + + create_event(other_calendar, %{}) + + create_event(calendar, %{ + dtstart: utc("2026-03-03T09:00:00Z"), + dtend: utc("2026-03-03T10:00:00Z") + }) + + blocks = + CalendarContext.busy_blocks( + user.id, + utc("2026-03-01T00:00:00Z"), + utc("2026-03-08T00:00:00Z") + ) + + assert [%{start: start_at}] = blocks + assert start_at == utc("2026-03-03T09:00:00Z") + end +end diff --git a/apps/elektrine/test/elektrine/calendar/calendar_test.exs b/apps/elektrine/test/elektrine/calendar/calendar_test.exs new file mode 100644 index 0000000..eb9a0b5 --- /dev/null +++ b/apps/elektrine/test/elektrine/calendar/calendar_test.exs @@ -0,0 +1,382 @@ +defmodule Elektrine.CalendarTest do + use Elektrine.DataCase + + alias Elektrine.Accounts + alias Elektrine.Calendar, as: CalendarContext + + # Helper to create a test user + defp create_test_user(attrs \\ %{}) do + default_attrs = %{ + username: "testuser#{System.unique_integer([:positive])}", + password: "testpassword123", + password_confirmation: "testpassword123" + } + + {:ok, user} = Accounts.create_user(Map.merge(default_attrs, attrs)) + user + end + + describe "calendars" do + test "list_calendars/1 returns empty list for user with no calendars" do + user = create_test_user() + assert [] == CalendarContext.list_calendars(user.id) + end + + test "create_calendar/1 creates a calendar" do + user = create_test_user() + + attrs = %{ + user_id: user.id, + name: "My Calendar", + color: "#ff0000", + description: "Test calendar" + } + + assert {:ok, calendar} = CalendarContext.create_calendar(attrs) + assert calendar.name == "My Calendar" + assert calendar.color == "#ff0000" + assert calendar.description == "Test calendar" + assert calendar.user_id == user.id + assert calendar.ctag != nil + end + + test "list_calendars/1 returns all calendars for a user" do + user = create_test_user() + + {:ok, _cal1} = CalendarContext.create_calendar(%{user_id: user.id, name: "Calendar 1"}) + {:ok, _cal2} = CalendarContext.create_calendar(%{user_id: user.id, name: "Calendar 2"}) + + calendars = CalendarContext.list_calendars(user.id) + assert length(calendars) == 2 + end + + test "get_calendar!/1 returns the calendar" do + user = create_test_user() + {:ok, calendar} = CalendarContext.create_calendar(%{user_id: user.id, name: "Test"}) + + fetched = CalendarContext.get_calendar!(calendar.id) + assert fetched.id == calendar.id + assert fetched.name == "Test" + end + + test "get_calendar_by_name/2 returns calendar by name" do + user = create_test_user() + {:ok, calendar} = CalendarContext.create_calendar(%{user_id: user.id, name: "Work"}) + + fetched = CalendarContext.get_calendar_by_name(user.id, "Work") + assert fetched.id == calendar.id + end + + test "get_or_create_default_calendar/1 creates default if none exists" do + user = create_test_user() + + assert {:ok, calendar} = CalendarContext.get_or_create_default_calendar(user.id) + assert calendar.name == "Default" + assert calendar.is_default == true + end + + test "get_or_create_default_calendar/1 returns existing default" do + user = create_test_user() + + {:ok, first} = CalendarContext.get_or_create_default_calendar(user.id) + {:ok, second} = CalendarContext.get_or_create_default_calendar(user.id) + + assert first.id == second.id + end + + test "update_calendar/2 updates the calendar" do + user = create_test_user() + {:ok, calendar} = CalendarContext.create_calendar(%{user_id: user.id, name: "Original"}) + + assert {:ok, updated} = CalendarContext.update_calendar(calendar, %{name: "Updated"}) + assert updated.name == "Updated" + end + + test "update_calendar/2 updates ctag" do + user = create_test_user() + {:ok, calendar} = CalendarContext.create_calendar(%{user_id: user.id, name: "Test"}) + original_ctag = calendar.ctag + + # Sleep for at least 1 second since ctag uses Unix timestamps (second precision) + Process.sleep(1100) + + {:ok, updated} = CalendarContext.update_calendar(calendar, %{name: "Updated"}) + assert updated.ctag != original_ctag + end + + test "delete_calendar/1 deletes the calendar" do + user = create_test_user() + {:ok, calendar} = CalendarContext.create_calendar(%{user_id: user.id, name: "ToDelete"}) + + assert {:ok, _} = CalendarContext.delete_calendar(calendar) + assert_raise Ecto.NoResultsError, fn -> CalendarContext.get_calendar!(calendar.id) end + end + + test "create_calendar/1 enforces unique name per user" do + user = create_test_user() + {:ok, _} = CalendarContext.create_calendar(%{user_id: user.id, name: "Same Name"}) + + assert {:error, changeset} = + CalendarContext.create_calendar(%{user_id: user.id, name: "Same Name"}) + + # Unique constraint on [:user_id, :name] reports error on first field + assert errors_on(changeset).user_id != nil + end + end + + describe "events" do + setup do + user = create_test_user() + + {:ok, calendar} = + CalendarContext.create_calendar(%{user_id: user.id, name: "Test Calendar"}) + + %{user: user, calendar: calendar} + end + + test "create_event/1 creates an event", %{calendar: calendar} do + attrs = %{ + calendar_id: calendar.id, + summary: "Test Event", + dtstart: ~U[2024-01-15 10:00:00Z], + dtend: ~U[2024-01-15 11:00:00Z] + } + + assert {:ok, event} = CalendarContext.create_event(attrs) + assert event.summary == "Test Event" + assert event.uid != nil + assert event.etag != nil + end + + test "list_events/1 returns all events in calendar", %{calendar: calendar} do + {:ok, _} = + CalendarContext.create_event(%{ + calendar_id: calendar.id, + summary: "Event 1", + dtstart: ~U[2024-01-15 10:00:00Z] + }) + + {:ok, _} = + CalendarContext.create_event(%{ + calendar_id: calendar.id, + summary: "Event 2", + dtstart: ~U[2024-01-16 10:00:00Z] + }) + + events = CalendarContext.list_events(calendar.id) + assert length(events) == 2 + end + + test "list_events_in_range/3 filters by date range", %{calendar: calendar} do + {:ok, _} = + CalendarContext.create_event(%{ + calendar_id: calendar.id, + summary: "January Event", + dtstart: ~U[2024-01-15 10:00:00Z] + }) + + {:ok, _} = + CalendarContext.create_event(%{ + calendar_id: calendar.id, + summary: "February Event", + dtstart: ~U[2024-02-15 10:00:00Z] + }) + + jan_events = + CalendarContext.list_events_in_range( + calendar.id, + ~U[2024-01-01 00:00:00Z], + ~U[2024-01-31 23:59:59Z] + ) + + assert length(jan_events) == 1 + assert hd(jan_events).summary == "January Event" + end + + test "get_event_by_uid/2 returns event by UID", %{calendar: calendar} do + {:ok, event} = + CalendarContext.create_event(%{ + calendar_id: calendar.id, + summary: "Find Me", + dtstart: ~U[2024-01-15 10:00:00Z] + }) + + found = CalendarContext.get_event_by_uid(calendar.id, event.uid) + assert found.id == event.id + end + + test "update_event/2 updates the event", %{calendar: calendar} do + {:ok, event} = + CalendarContext.create_event(%{ + calendar_id: calendar.id, + summary: "Original", + dtstart: ~U[2024-01-15 10:00:00Z] + }) + + assert {:ok, updated} = CalendarContext.update_event(event, %{summary: "Updated"}) + assert updated.summary == "Updated" + end + + test "update_event/2 updates calendar ctag", %{calendar: calendar} do + {:ok, event} = + CalendarContext.create_event(%{ + calendar_id: calendar.id, + summary: "Test", + dtstart: ~U[2024-01-15 10:00:00Z] + }) + + original_ctag = CalendarContext.get_calendar!(calendar.id).ctag + + # Sleep for at least 1 second since ctag uses Unix timestamps (second precision) + Process.sleep(1100) + + {:ok, _} = CalendarContext.update_event(event, %{summary: "Updated"}) + updated_ctag = CalendarContext.get_calendar!(calendar.id).ctag + + assert updated_ctag != original_ctag + end + + test "delete_event/1 deletes the event", %{calendar: calendar} do + {:ok, event} = + CalendarContext.create_event(%{ + calendar_id: calendar.id, + summary: "Delete Me", + dtstart: ~U[2024-01-15 10:00:00Z] + }) + + assert {:ok, _} = CalendarContext.delete_event(event) + assert CalendarContext.get_event_by_uid(calendar.id, event.uid) == nil + end + + test "delete_event/1 updates calendar ctag", %{calendar: calendar} do + {:ok, event} = + CalendarContext.create_event(%{ + calendar_id: calendar.id, + summary: "Delete Me", + dtstart: ~U[2024-01-15 10:00:00Z] + }) + + original_ctag = CalendarContext.get_calendar!(calendar.id).ctag + + # Sleep for at least 1 second since ctag uses Unix timestamps (second precision) + Process.sleep(1100) + + {:ok, _} = CalendarContext.delete_event(event) + updated_ctag = CalendarContext.get_calendar!(calendar.id).ctag + + assert updated_ctag != original_ctag + end + end + + describe "CalDAV operations" do + setup do + user = create_test_user() + {:ok, calendar} = CalendarContext.create_calendar(%{user_id: user.id, name: "CalDAV Test"}) + %{user: user, calendar: calendar} + end + + test "upsert_event_from_icalendar/3 creates new event", %{calendar: calendar} do + icalendar = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:caldav-test@example.com + DTSTART:20240115T100000Z + DTEND:20240115T110000Z + SUMMARY:CalDAV Event + END:VEVENT + END:VCALENDAR + """ + + assert {:ok, event} = + CalendarContext.upsert_event_from_icalendar( + calendar.id, + "caldav-test@example.com", + icalendar + ) + + assert event.uid == "caldav-test@example.com" + assert event.summary == "CalDAV Event" + assert event.icalendar_data == icalendar + end + + test "upsert_event_from_icalendar/3 updates existing event", %{calendar: calendar} do + icalendar1 = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:update-test@example.com + DTSTART:20240115T100000Z + SUMMARY:Original + END:VEVENT + END:VCALENDAR + """ + + {:ok, _} = + CalendarContext.upsert_event_from_icalendar( + calendar.id, + "update-test@example.com", + icalendar1 + ) + + icalendar2 = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:update-test@example.com + DTSTART:20240115T100000Z + SUMMARY:Updated + END:VEVENT + END:VCALENDAR + """ + + assert {:ok, event} = + CalendarContext.upsert_event_from_icalendar( + calendar.id, + "update-test@example.com", + icalendar2 + ) + + assert event.summary == "Updated" + end + + test "list_events_since/2 returns events modified since timestamp", %{calendar: calendar} do + {:ok, _old} = + CalendarContext.create_event(%{ + calendar_id: calendar.id, + summary: "Old Event", + dtstart: ~U[2024-01-15 10:00:00Z] + }) + + # Capture 'since' timestamp and truncate to seconds (database precision) + since = DateTime.utc_now() |> DateTime.truncate(:second) + + # Wait at least 1 second so new event has updated_at > since + Process.sleep(1100) + + {:ok, _new} = + CalendarContext.create_event(%{ + calendar_id: calendar.id, + summary: "New Event", + dtstart: ~U[2024-01-16 10:00:00Z] + }) + + recent = CalendarContext.list_events_since(calendar.id, since) + assert length(recent) == 1 + assert hd(recent).summary == "New Event" + end + + test "ensure_icalendar_data/1 generates icalendar if missing", %{calendar: calendar} do + {:ok, event} = + CalendarContext.create_event(%{ + calendar_id: calendar.id, + summary: "No iCal", + dtstart: ~U[2024-01-15 10:00:00Z] + }) + + ical = CalendarContext.ensure_icalendar_data(event) + assert String.contains?(ical, "BEGIN:VCALENDAR") + assert String.contains?(ical, "SUMMARY:No iCal") + end + end +end diff --git a/apps/elektrine/test/elektrine/calendar/icalendar_test.exs b/apps/elektrine/test/elektrine/calendar/icalendar_test.exs new file mode 100644 index 0000000..0641f3e --- /dev/null +++ b/apps/elektrine/test/elektrine/calendar/icalendar_test.exs @@ -0,0 +1,382 @@ +defmodule Elektrine.Calendar.ICalendarTest do + use ExUnit.Case, async: true + + alias Elektrine.Calendar.ICalendar + alias Elektrine.Domains + + describe "parse/1" do + test "parses a simple event" do + icalendar = """ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Test//Test//EN + BEGIN:VEVENT + UID:test-event@example.com + DTSTART:20240115T100000Z + DTEND:20240115T110000Z + SUMMARY:Test Meeting + DESCRIPTION:A test event + LOCATION:Conference Room A + END:VEVENT + END:VCALENDAR + """ + + assert {:ok, event} = ICalendar.parse(icalendar) + assert event.uid == "test-event@example.com" + assert event.summary == "Test Meeting" + assert event.description == "A test event" + assert event.location == "Conference Room A" + assert event.dtstart.hour == 10 + assert event.dtstart.minute == 0 + end + + test "parses all-day events" do + icalendar = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:allday@example.com + DTSTART;VALUE=DATE:20240120 + DTEND;VALUE=DATE:20240121 + SUMMARY:All Day Event + END:VEVENT + END:VCALENDAR + """ + + assert {:ok, event} = ICalendar.parse(icalendar) + assert event.uid == "allday@example.com" + assert event.all_day == true + assert event.dtstart.year == 2024 + assert event.dtstart.month == 1 + assert event.dtstart.day == 20 + end + + test "parses event with timezone" do + icalendar = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:tz@example.com + DTSTART;TZID=America/New_York:20240115T100000 + DTEND;TZID=America/New_York:20240115T110000 + SUMMARY:TZ Event + END:VEVENT + END:VCALENDAR + """ + + assert {:ok, event} = ICalendar.parse(icalendar) + assert event.timezone == "America/New_York" + end + + test "parses event with recurrence rule" do + icalendar = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:recur@example.com + DTSTART:20240115T100000Z + DTEND:20240115T110000Z + SUMMARY:Weekly Meeting + RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10 + END:VEVENT + END:VCALENDAR + """ + + assert {:ok, event} = ICalendar.parse(icalendar) + assert event.rrule == "FREQ=WEEKLY;BYDAY=MO;COUNT=10" + end + + test "parses event with attendees" do + icalendar = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:attendee@example.com + DTSTART:20240115T100000Z + SUMMARY:Team Meeting + ORGANIZER;CN=Boss:mailto:boss@example.com + ATTENDEE;CN=Employee;PARTSTAT=ACCEPTED:mailto:emp@example.com + ATTENDEE;CN=Guest;PARTSTAT=TENTATIVE;RSVP=TRUE:mailto:guest@example.com + END:VEVENT + END:VCALENDAR + """ + + assert {:ok, event} = ICalendar.parse(icalendar) + assert event.organizer["email"] == "boss@example.com" + assert event.organizer["cn"] == "Boss" + assert length(event.attendees) == 2 + + emp = Enum.find(event.attendees, &(&1["email"] == "emp@example.com")) + assert emp["partstat"] == "ACCEPTED" + + guest = Enum.find(event.attendees, &(&1["email"] == "guest@example.com")) + assert guest["partstat"] == "TENTATIVE" + assert guest["rsvp"] == true + end + + test "parses event with status and classification" do + icalendar = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:status@example.com + DTSTART:20240115T100000Z + SUMMARY:Private Meeting + STATUS:TENTATIVE + CLASS:PRIVATE + TRANSP:TRANSPARENT + PRIORITY:1 + END:VEVENT + END:VCALENDAR + """ + + assert {:ok, event} = ICalendar.parse(icalendar) + assert event.status == "TENTATIVE" + assert event.classification == "PRIVATE" + assert event.transparency == "TRANSPARENT" + assert event.priority == 1 + end + + test "parses event with categories" do + icalendar = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:cats@example.com + DTSTART:20240115T100000Z + SUMMARY:Categorized Event + CATEGORIES:Work,Important,Project + END:VEVENT + END:VCALENDAR + """ + + assert {:ok, event} = ICalendar.parse(icalendar) + assert event.categories == ["Work", "Important", "Project"] + end + + test "parses event with escaped characters" do + icalendar = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:escape@example.com + DTSTART:20240115T100000Z + SUMMARY:Meeting\\, Important + DESCRIPTION:Line 1\\nLine 2\\;semicolon + END:VEVENT + END:VCALENDAR + """ + + assert {:ok, event} = ICalendar.parse(icalendar) + assert event.summary == "Meeting, Important" + assert event.description == "Line 1\nLine 2;semicolon" + end + + test "handles folded lines" do + icalendar = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + UID:fold@example.com + DTSTART:20240115T100000Z + SUMMARY:This is a very long summary that continues + on the next line + END:VEVENT + END:VCALENDAR + """ + + assert {:ok, event} = ICalendar.parse(icalendar) + assert event.summary == "This is a very long summary that continueson the next line" + end + + test "returns error for invalid iCalendar" do + assert {:error, :no_vevent_found} = ICalendar.parse("not a valid icalendar") + end + end + + describe "generate/1" do + test "generates a simple event" do + event = %{ + uid: "gen-test@example.com", + summary: "Generated Event", + dtstart: ~U[2024-01-15 10:00:00Z], + dtend: ~U[2024-01-15 11:00:00Z] + } + + assert {:ok, icalendar} = ICalendar.generate(event) + assert String.contains?(icalendar, "BEGIN:VCALENDAR") + assert String.contains?(icalendar, "VERSION:2.0") + assert String.contains?(icalendar, "BEGIN:VEVENT") + assert String.contains?(icalendar, "UID:gen-test@example.com") + assert String.contains?(icalendar, "SUMMARY:Generated Event") + assert String.contains?(icalendar, "DTSTART:20240115T100000Z") + assert String.contains?(icalendar, "DTEND:20240115T110000Z") + assert String.contains?(icalendar, "END:VEVENT") + assert String.contains?(icalendar, "END:VCALENDAR") + end + + test "generates all-day event" do + event = %{ + uid: "allday-gen@example.com", + summary: "All Day", + dtstart: ~U[2024-01-20 00:00:00Z], + dtend: ~U[2024-01-21 00:00:00Z], + all_day: true + } + + assert {:ok, icalendar} = ICalendar.generate(event) + assert String.contains?(icalendar, "DTSTART;VALUE=DATE:20240120") + assert String.contains?(icalendar, "DTEND;VALUE=DATE:20240121") + end + + test "generates event with recurrence" do + event = %{ + uid: "recur-gen@example.com", + summary: "Weekly", + dtstart: ~U[2024-01-15 10:00:00Z], + rrule: "FREQ=WEEKLY;COUNT=4" + } + + assert {:ok, icalendar} = ICalendar.generate(event) + assert String.contains?(icalendar, "RRULE:FREQ=WEEKLY;COUNT=4") + end + + test "generates event with location and description" do + event = %{ + uid: "loc-gen@example.com", + summary: "Meeting", + dtstart: ~U[2024-01-15 10:00:00Z], + location: "Room 101", + description: "Important discussion" + } + + assert {:ok, icalendar} = ICalendar.generate(event) + assert String.contains?(icalendar, "LOCATION:Room 101") + assert String.contains?(icalendar, "DESCRIPTION:Important discussion") + end + + test "generates event with attendees" do + event = %{ + uid: "att-gen@example.com", + summary: "Team Sync", + dtstart: ~U[2024-01-15 10:00:00Z], + organizer: %{"email" => "boss@example.com", "cn" => "Boss"}, + attendees: [ + %{"email" => "emp1@example.com", "cn" => "Employee 1", "partstat" => "ACCEPTED"}, + %{"email" => "emp2@example.com", "partstat" => "NEEDS-ACTION", "rsvp" => true} + ] + } + + assert {:ok, icalendar} = ICalendar.generate(event) + assert String.contains?(icalendar, "ORGANIZER;CN=Boss:mailto:boss@example.com") + assert String.contains?(icalendar, "ATTENDEE") + # After line folding, emails may be split across lines + # Check unfolded content + unfolded = String.replace(icalendar, ~r/\r\n /, "") + assert String.contains?(unfolded, "emp1@example.com") + assert String.contains?(unfolded, "emp2@example.com") + end + + test "generates event with alarm" do + event = %{ + uid: "alarm-gen@example.com", + summary: "Reminder Test", + dtstart: ~U[2024-01-15 10:00:00Z], + alarms: [ + %{"action" => "DISPLAY", "trigger" => "-PT15M", "description" => "Meeting soon"} + ] + } + + assert {:ok, icalendar} = ICalendar.generate(event) + assert String.contains?(icalendar, "BEGIN:VALARM") + assert String.contains?(icalendar, "ACTION:DISPLAY") + assert String.contains?(icalendar, "TRIGGER:-PT15M") + assert String.contains?(icalendar, "END:VALARM") + end + + test "escapes special characters" do + event = %{ + uid: "escape-gen@example.com", + summary: "Meeting, Important", + description: "Line 1\nLine 2;semicolon", + dtstart: ~U[2024-01-15 10:00:00Z] + } + + assert {:ok, icalendar} = ICalendar.generate(event) + assert String.contains?(icalendar, "SUMMARY:Meeting\\, Important") + assert String.contains?(icalendar, "DESCRIPTION:Line 1\\nLine 2\\;semicolon") + end + + test "generates unique UID if not provided" do + event = %{ + summary: "No UID", + dtstart: ~U[2024-01-15 10:00:00Z] + } + + assert {:ok, icalendar} = ICalendar.generate(event) + assert String.contains?(icalendar, "UID:") + assert String.contains?(icalendar, "@#{Domains.primary_email_domain()}") + end + end + + describe "generate_uid/0" do + test "generates unique UIDs" do + uid1 = ICalendar.generate_uid() + uid2 = ICalendar.generate_uid() + + assert uid1 != uid2 + assert String.ends_with?(uid1, "@#{Domains.primary_email_domain()}") + end + end + + describe "generate_etag/1" do + test "generates consistent etag for same event" do + event = %{uid: "test", sequence: 1, updated_at: ~U[2024-01-15 10:00:00Z]} + + etag1 = ICalendar.generate_etag(event) + etag2 = ICalendar.generate_etag(event) + + assert etag1 == etag2 + end + + test "generates different etag when event changes" do + event1 = %{uid: "test", sequence: 1, updated_at: ~U[2024-01-15 10:00:00Z]} + event2 = %{uid: "test", sequence: 2, updated_at: ~U[2024-01-15 11:00:00Z]} + + etag1 = ICalendar.generate_etag(event1) + etag2 = ICalendar.generate_etag(event2) + + assert etag1 != etag2 + end + end + + describe "round-trip parsing and generation" do + test "parse then generate preserves data" do + original = """ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Test//Test//EN + BEGIN:VEVENT + UID:roundtrip@example.com + DTSTART:20240115T100000Z + DTEND:20240115T110000Z + SUMMARY:Round Trip Test + DESCRIPTION:Testing round trip + LOCATION:Test Location + STATUS:CONFIRMED + END:VEVENT + END:VCALENDAR + """ + + assert {:ok, parsed} = ICalendar.parse(original) + assert {:ok, generated} = ICalendar.generate(parsed) + assert {:ok, reparsed} = ICalendar.parse(generated) + + assert reparsed.uid == "roundtrip@example.com" + assert reparsed.summary == "Round Trip Test" + assert reparsed.description == "Testing round trip" + assert reparsed.location == "Test Location" + assert reparsed.status == "CONFIRMED" + end + end +end diff --git a/apps/elektrine/test/elektrine/calls/transport_test.exs b/apps/elektrine/test/elektrine/calls/transport_test.exs new file mode 100644 index 0000000..c0f52e4 --- /dev/null +++ b/apps/elektrine/test/elektrine/calls/transport_test.exs @@ -0,0 +1,44 @@ +defmodule Elektrine.Calls.TransportTest do + use ExUnit.Case, async: true + + alias Elektrine.Calls.Transport + + test "descriptor_for_user/2 includes configured static ice servers" do + descriptor = Transport.descriptor_for_user(42, 99) + + assert descriptor["mode"] in ["mesh", "sfu"] + assert is_list(descriptor["ice_servers"]) + + assert Enum.any?(descriptor["ice_servers"], fn server -> + server["urls"] == ["stun:stun.l.google.com:19302"] + end) + end + + test "descriptor_for_user/2 appends dynamic turn credentials when configured" do + original = Application.get_env(:elektrine, :webrtc, []) + + Application.put_env( + :elektrine, + :webrtc, + Keyword.merge(original, + turn_shared_secret: "test-secret", + turn_uris: ["turn:turn.example.com:3478?transport=udp"], + turn_username_ttl_seconds: 600 + ) + ) + + on_exit(fn -> Application.put_env(:elektrine, :webrtc, original) end) + + descriptor = + Transport.descriptor_for_user(7, 11, now: DateTime.from_unix!(1_700_000_000)) + + turn_server = + Enum.find(descriptor["ice_servers"], fn server -> + server["urls"] == ["turn:turn.example.com:3478?transport=udp"] + end) + + assert %{"username" => username, "credential" => credential} = turn_server + assert username == "1700000600:7:11" + assert is_binary(credential) and credential != "" + end +end diff --git a/apps/elektrine/test/elektrine/calls_test.exs b/apps/elektrine/test/elektrine/calls_test.exs new file mode 100644 index 0000000..b456654 --- /dev/null +++ b/apps/elektrine/test/elektrine/calls_test.exs @@ -0,0 +1,150 @@ +defmodule Elektrine.CallsTest do + use Elektrine.DataCase, async: true + + alias Elektrine.AccountsFixtures + alias Elektrine.Calls + alias Elektrine.Calls.Call + alias Elektrine.Friends + alias Elektrine.Messaging + alias Elektrine.Messaging.ChatMessage + + describe "initiate_call/4" do + test "accepts the shared DM conversation between caller and callee" do + caller = AccountsFixtures.user_fixture() + callee = AccountsFixtures.user_fixture() + + make_friends(caller.id, callee.id) + {:ok, conversation} = Messaging.create_dm_conversation(caller.id, callee.id) + + assert {:ok, %Call{} = call} = + Calls.initiate_call(caller.id, callee.id, "audio", conversation.id) + + assert call.conversation_id == conversation.id + end + + test "rejects conversation ids that are not a shared DM conversation" do + caller = AccountsFixtures.user_fixture() + callee = AccountsFixtures.user_fixture() + third_user = AccountsFixtures.user_fixture() + + make_friends(caller.id, callee.id) + + {:ok, other_dm} = Messaging.create_dm_conversation(caller.id, third_user.id) + + assert {:error, :invalid_conversation} = + Calls.initiate_call(caller.id, callee.id, "audio", other_dm.id) + + group = + case Messaging.create_group_conversation( + caller.id, + %{name: "call-validation-#{System.unique_integer([:positive])}"}, + [callee.id] + ) do + {:ok, conversation} -> conversation + {:ok, conversation, _failed_count} -> conversation + end + + assert {:error, :invalid_conversation} = + Calls.initiate_call(caller.id, callee.id, "audio", group.id) + end + end + + describe "update_call_status/3" do + test "keeps the first terminal status and call log message" do + caller = AccountsFixtures.user_fixture() + callee = AccountsFixtures.user_fixture() + {:ok, conversation} = Messaging.create_dm_conversation(caller.id, callee.id) + + call = insert_call(caller.id, callee.id, conversation.id, "ringing") + + assert {:ok, %{status: "rejected"}} = Calls.reject_call(call.id) + assert {:ok, %{status: "rejected"}} = Calls.end_call(call.id) + assert %Call{status: "rejected"} = Calls.get_call(call.id) + + call_logs = + from(m in ChatMessage, + where: m.conversation_id == ^conversation.id and m.message_type == "system", + where: fragment("?->>'call_id' = ?", m.media_metadata, ^to_string(call.id)) + ) + |> Repo.all() + + assert length(call_logs) == 1 + assert hd(call_logs).content == "Audio call declined" + end + + test "does not regress an active call back to ringing" do + caller = AccountsFixtures.user_fixture() + callee = AccountsFixtures.user_fixture() + {:ok, conversation} = Messaging.create_dm_conversation(caller.id, callee.id) + + call = + insert_call(caller.id, callee.id, conversation.id, "active", %{ + started_at: DateTime.add(DateTime.utc_now(), -10, :second) + }) + + initial_updated_at = call.updated_at + + assert {:ok, %Call{status: "active"} = unchanged} = + Calls.update_call_status(call.id, "ringing") + + assert unchanged.updated_at == initial_updated_at + end + + test "updates updated_at when transition is applied" do + caller = AccountsFixtures.user_fixture() + callee = AccountsFixtures.user_fixture() + {:ok, conversation} = Messaging.create_dm_conversation(caller.id, callee.id) + call = insert_call(caller.id, callee.id, conversation.id, "initiated") + + Process.sleep(1100) + + assert {:ok, %Call{status: "ringing"} = updated} = + Calls.update_call_status(call.id, "ringing") + + refute updated.updated_at == call.updated_at + end + + test "broadcasts call_ended to both participants when ending a call" do + caller = AccountsFixtures.user_fixture() + callee = AccountsFixtures.user_fixture() + {:ok, conversation} = Messaging.create_dm_conversation(caller.id, callee.id) + + call = + insert_call(caller.id, callee.id, conversation.id, "active", %{ + started_at: DateTime.add(DateTime.utc_now(), -15, :second) + }) + + Phoenix.PubSub.subscribe(Elektrine.PubSub, "user:#{caller.id}") + Phoenix.PubSub.subscribe(Elektrine.PubSub, "user:#{callee.id}") + + assert {:ok, %Call{status: "ended"}} = Calls.end_call(call.id) + call_id = call.id + assert_receive {:call_ended, %Call{id: ^call_id, status: "ended"}} + assert_receive {:call_ended, %Call{id: ^call_id, status: "ended"}} + end + end + + defp insert_call(caller_id, callee_id, conversation_id, status, extra_attrs \\ %{}) do + attrs = + Map.merge( + %{ + caller_id: caller_id, + callee_id: callee_id, + conversation_id: conversation_id, + call_type: "audio", + status: status + }, + extra_attrs + ) + + %Call{} + |> Call.changeset(attrs) + |> Repo.insert!() + end + + defp make_friends(user1_id, user2_id) do + {:ok, request} = Friends.send_friend_request(user1_id, user2_id) + {:ok, _friendship} = Friends.accept_friend_request(request.id, user2_id) + :ok + end +end diff --git a/apps/elektrine/test/elektrine/captcha_test.exs b/apps/elektrine/test/elektrine/captcha_test.exs new file mode 100644 index 0000000..9e3587d --- /dev/null +++ b/apps/elektrine/test/elektrine/captcha_test.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.CaptchaTest do + use ExUnit.Case, async: true + + alias Elektrine.Captcha + + test "generated captcha verifies with its answer" do + {_image_binary, answer, token} = Captcha.generate() + + assert :ok = Captcha.verify(token, answer) + end + + test "malformed answer hashes fail closed instead of raising" do + token = Base.encode64("#{System.system_time(:second)}:short") + + assert {:error, :wrong_answer} = Captcha.verify(token, "1") + end +end diff --git a/apps/elektrine/test/elektrine/db/write_guard_test.exs b/apps/elektrine/test/elektrine/db/write_guard_test.exs new file mode 100644 index 0000000..105312e --- /dev/null +++ b/apps/elektrine/test/elektrine/db/write_guard_test.exs @@ -0,0 +1,60 @@ +defmodule Elektrine.DB.WriteGuardTest do + use Elektrine.DataCase, async: false + + import Ecto.Query + import Elektrine.AccountsFixtures + + alias Elektrine.Accounts.User + alias Elektrine.DB.WriteGuard + alias Elektrine.Repo + + test "runs writes normally when transaction is writable" do + user = user_fixture() + now = DateTime.utc_now() |> DateTime.truncate(:second) + + result = + WriteGuard.run("test write", fn -> + from(u in User, where: u.id == ^user.id) + |> Repo.update_all(set: [last_imap_access: now]) + end) + + assert {1, _} = result + assert Repo.get!(User, user.id).last_imap_access == now + end + + test "returns fallback when transaction is read-only" do + user = user_fixture() + + assert {:ok, {:error, :read_only_sql_transaction}} = + Repo.transaction(fn -> + Repo.query!("SET TRANSACTION READ ONLY") + + WriteGuard.run("test read-only write", fn -> + from(u in User, where: u.id == ^user.id) + |> Repo.update_all(set: [last_imap_access: DateTime.utc_now()]) + end) + end) + + assert is_nil(Repo.get!(User, user.id).last_imap_access) + end + + test "supports custom fallback values in read-only transactions" do + user = user_fixture() + + assert {:ok, {:ok, :skipped}} = + Repo.transaction(fn -> + Repo.query!("SET TRANSACTION READ ONLY") + + WriteGuard.run( + "test custom read-only fallback", + fn -> + from(u in User, where: u.id == ^user.id) + |> Repo.update_all(set: [last_imap_access: DateTime.utc_now()]) + end, + on_read_only: {:ok, :skipped} + ) + end) + + assert is_nil(Repo.get!(User, user.id).last_imap_access) + end +end diff --git a/apps/elektrine/test/elektrine/developer/exports/account_exporter_test.exs b/apps/elektrine/test/elektrine/developer/exports/account_exporter_test.exs new file mode 100644 index 0000000..eedbddf --- /dev/null +++ b/apps/elektrine/test/elektrine/developer/exports/account_exporter_test.exs @@ -0,0 +1,173 @@ +defmodule Elektrine.Developer.Exports.AccountExporterTest do + use Elektrine.DataCase, async: true + + import Elektrine.AccountsFixtures + + alias Elektrine.Accounts.UserBlock + alias Elektrine.Accounts.UserMute + alias Elektrine.ActivityPub + alias Elektrine.ActivityPub.Actor + alias Elektrine.ActivityPub.UserBlock, as: ActivityPubUserBlock + alias Elektrine.Developer.Exports.AccountExporter + alias Elektrine.Domains + alias Elektrine.Profiles + alias Elektrine.Profiles.Follow + alias Elektrine.Repo + + test "account export includes OwnRoot recovery metadata" do + user = user_fixture(%{username: "exportdomain", handle: "exportdomain"}) + built_in_domain = "exportdomain.#{Domains.default_profile_domain()}" + + assert {:ok, _identity} = + Profiles.create_per_site_identity(user, %{ + "site_key" => "hn", + "base_domain" => built_in_domain + }) + + file_path = + Path.join(System.tmp_dir!(), "account-export-#{System.unique_integer([:positive])}.json") + + on_exit(fn -> File.rm(file_path) end) + + assert {:ok, _count} = AccountExporter.export(user.id, file_path, "json") + + data = + file_path + |> File.read!() + |> Jason.decode!() + + assert %{ + "provider" => provider, + "portable_root" => "dns", + "domains" => domains + } = data["own_root"] + + assert provider == Domains.public_base_url() + + exported_domain = Enum.find(domains, &(&1["domain"] == built_in_domain)) + + assert exported_domain["subject"] == "domain:#{built_in_domain}" + assert exported_domain["did"] == "did:web:#{built_in_domain}" + assert exported_domain["own_root"]["subject"] == "domain:#{built_in_domain}" + assert exported_domain["did_document"]["id"] == "did:web:#{built_in_domain}" + assert exported_domain["migration"]["own_root"] =~ "/.well-known/own-root" + assert [identity] = exported_domain["own_root"]["per_site_identities"]["identities"] + assert identity["site_key"] == "hn" + assert identity["domain"] == "hn.#{built_in_domain}" + assert identity["subject"] == "domain:hn.#{built_in_domain}" + end + + test "account export includes portable relationship lists" do + user = user_fixture(%{username: "portablerel", handle: "portablerel"}) + local_follow = user_fixture(%{username: "localfollow", handle: "localfollow"}) + local_block = user_fixture(%{username: "localblock", handle: "localblock"}) + local_mute = user_fixture(%{username: "localmute", handle: "localmute"}) + + remote_follow = remote_actor_fixture("remote-follow", "remote-follow.example") + remote_block = remote_actor_fixture("remote-block", "remote-block.example") + remote_mute = remote_actor_fixture("remote-mute", "remote-mute.example") + + insert_local_follow(user.id, local_follow.id) + insert_local_block(user.id, local_block.id) + insert_local_mute(user.id, local_mute.id) + insert_remote_follow(user.id, remote_follow.id) + insert_remote_relationship(user.id, remote_block.uri, "user") + insert_remote_relationship(user.id, remote_mute.uri, "mute") + insert_remote_relationship(user.id, "blocked-domain.example", "domain") + + file_path = + Path.join( + System.tmp_dir!(), + "account-relationships-#{System.unique_integer([:positive])}.json" + ) + + on_exit(fn -> File.rm(file_path) end) + + assert {:ok, count} = AccountExporter.export(user.id, file_path, "json") + assert count == 8 + + data = + file_path + |> File.read!() + |> Jason.decode!() + + assert %{ + "following" => following, + "blocks" => blocks, + "mutes" => mutes, + "domain_blocks" => domain_blocks, + "import_lists" => import_lists + } = data["relationships"] + + assert Enum.any?( + following, + &(&1["account"] == "localfollow@#{ActivityPub.instance_domain()}") + ) + + assert Enum.any?(following, &(&1["account"] == "remote-follow@remote-follow.example")) + assert Enum.any?(blocks, &(&1["account"] == "localblock@#{ActivityPub.instance_domain()}")) + assert Enum.any?(blocks, &(&1["account"] == "remote-block@remote-block.example")) + assert Enum.any?(mutes, &(&1["account"] == "localmute@#{ActivityPub.instance_domain()}")) + assert Enum.any?(mutes, &(&1["account"] == "remote-mute@remote-mute.example")) + assert [%{"domain" => "blocked-domain.example"}] = domain_blocks + + assert "remote-follow@remote-follow.example" in import_lists["follows"] + assert "remote-block@remote-block.example" in import_lists["blocks"] + assert "remote-mute@remote-mute.example" in import_lists["mutes"] + assert "blocked-domain.example" in import_lists["domain_blocks"] + end + + defp insert_local_follow(follower_id, followed_id) do + %Follow{} + |> Follow.changeset(%{follower_id: follower_id, followed_id: followed_id}) + |> Repo.insert!() + end + + defp insert_local_block(blocker_id, blocked_id) do + %UserBlock{} + |> UserBlock.changeset(%{blocker_id: blocker_id, blocked_id: blocked_id}) + |> Repo.insert!() + end + + defp insert_local_mute(muter_id, muted_id) do + %UserMute{} + |> UserMute.changeset(%{muter_id: muter_id, muted_id: muted_id}) + |> Repo.insert!() + end + + defp insert_remote_follow(follower_id, remote_actor_id) do + %Follow{} + |> Follow.changeset(%{ + follower_id: follower_id, + remote_actor_id: remote_actor_id, + pending: false + }) + |> Repo.insert!() + end + + defp insert_remote_relationship(user_id, blocked_uri, block_type) do + %ActivityPubUserBlock{} + |> ActivityPubUserBlock.changeset(%{ + user_id: user_id, + blocked_uri: blocked_uri, + block_type: block_type + }) + |> Repo.insert!() + end + + defp remote_actor_fixture(username, domain) do + %Actor{} + |> Actor.changeset(%{ + uri: "https://#{domain}/users/#{username}", + username: username, + domain: domain, + display_name: username, + inbox_url: "https://#{domain}/users/#{username}/inbox", + public_key: "-----BEGIN RSA PUBLIC KEY-----\nMOCK\n-----END RSA PUBLIC KEY-----\n", + actor_type: "Person", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second), + metadata: %{} + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/developer/webhook_delivery_worker_test.exs b/apps/elektrine/test/elektrine/developer/webhook_delivery_worker_test.exs new file mode 100644 index 0000000..d6c0309 --- /dev/null +++ b/apps/elektrine/test/elektrine/developer/webhook_delivery_worker_test.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Developer.WebhookDeliveryWorkerTest do + use Elektrine.DataCase, async: true + + alias Elektrine.Developer.WebhookDeliveryWorker + + test "discards missing delivery rows" do + assert {:discard, :not_found} = + WebhookDeliveryWorker.perform(%Oban.Job{args: %{"delivery_id" => -1}, attempt: 1}) + end +end diff --git a/apps/elektrine/test/elektrine/developer_exports_test.exs b/apps/elektrine/test/elektrine/developer_exports_test.exs new file mode 100644 index 0000000..27bd9ee --- /dev/null +++ b/apps/elektrine/test/elektrine/developer_exports_test.exs @@ -0,0 +1,185 @@ +defmodule Elektrine.DeveloperExportsTest do + use Elektrine.DataCase, async: false + + import Elektrine.AccountsFixtures + + alias Elektrine.Accounts + alias Elektrine.ActivityPub + alias Elektrine.Developer + alias Elektrine.Developer.DataExport + alias Elektrine.Developer.ExportWorker + alias Elektrine.Profiles + alias Elektrine.Repo + + test "creates and enqueues exports in one step" do + user = user_fixture() + + assert {:ok, %DataExport{} = export} = + Developer.create_export_and_enqueue(user.id, %{ + export_type: "account", + format: "json" + }) + + export = Developer.get_export(user.id, export.id) + + assert export.status in ["processing", "completed"] + assert export.user_id == user.id + end + + test "delete_export does not remove files outside the configured export directory" do + user = user_fixture() + + outside_path = + Path.join(System.tmp_dir!(), "elektrine-export-outside-#{unique_integer()}.txt") + + File.write!(outside_path, "do not delete") + + on_exit(fn -> File.rm(outside_path) end) + + {:ok, export} = + Developer.create_export(user.id, %{ + export_type: "account", + format: "json" + }) + + {:ok, export} = Developer.complete_export(export, outside_path, 13, 1) + + assert {:ok, _deleted_export} = Developer.delete_export(export) + assert File.exists?(outside_path) + refute Repo.get(DataExport, export.id) + end + + test "rejects export formats that the exporters do not implement" do + user = user_fixture() + + assert {:error, account_changeset} = + Developer.create_export(user.id, %{ + export_type: "account", + format: "csv" + }) + + assert %{format: ["is not valid for account exports"]} = errors_on(account_changeset) + + assert {:error, email_changeset} = + Developer.create_export(user.id, %{ + export_type: "email", + format: "zip" + }) + + assert %{format: ["is not valid for email exports"]} = errors_on(email_changeset) + end + + test "allows implemented CSV formats for social and chat exports" do + user = user_fixture() + + assert {:ok, %DataExport{}} = + Developer.create_export(user.id, %{ + export_type: "social", + format: "csv" + }) + + assert {:ok, %DataExport{}} = + Developer.create_export(user.id, %{ + export_type: "chat", + format: "csv" + }) + end + + test "defaults full exports to zip when no format is provided" do + user = user_fixture() + + assert {:ok, %DataExport{} = export} = + Developer.create_export(user.id, %{ + export_type: "full" + }) + + assert export.format == "zip" + end + + test "keeps explicit invalid full export formats rejected" do + user = user_fixture() + + assert {:error, changeset} = + Developer.create_export(user.id, %{ + export_type: "full", + format: "json" + }) + + assert %{format: ["is not valid for full exports"]} = errors_on(changeset) + end + + test "full export includes import-ready relationship CSV files" do + previous_export_dir = Application.get_env(:elektrine, :export_dir) + export_dir = Path.join(System.tmp_dir!(), "elektrine-full-export-#{unique_integer()}") + + Application.put_env(:elektrine, :export_dir, export_dir) + File.mkdir_p!(export_dir) + + on_exit(fn -> + if previous_export_dir do + Application.put_env(:elektrine, :export_dir, previous_export_dir) + else + Application.delete_env(:elektrine, :export_dir) + end + + File.rm_rf(export_dir) + end) + + user = user_fixture(%{username: "fullexport", handle: "fullexport"}) + followed = user_fixture(%{username: "fullfollow", handle: "fullfollow"}) + muted = user_fixture(%{username: "fullmute", handle: "fullmute"}) + blocked = user_fixture(%{username: "fullblock", handle: "fullblock"}) + + assert {:ok, _follow} = Profiles.follow_user(user.id, followed.id) + assert {:ok, _mute} = Accounts.mute_user(user.id, muted.id) + assert {:ok, _block} = Accounts.block_user(user.id, blocked.id) + assert {:ok, _domain_block} = Accounts.block_domain(user.id, "blocked.example") + + {:ok, export} = + Developer.create_export(user.id, %{ + export_type: "full", + format: "zip" + }) + + assert :ok = ExportWorker.perform(%Oban.Job{args: %{"export_id" => export.id}}) + + completed = Developer.get_export(user.id, export.id) + assert completed.status == "completed" + assert completed.file_path + + {:ok, files} = :zip.extract(String.to_charlist(completed.file_path), [:memory]) + + zip_entries = + Map.new(files, fn {name, content} -> + {to_string(name), to_string(content)} + end) + + local_domain = ActivityPub.instance_domain() + + assert zip_entries["following_accounts.csv"] =~ "Account address\n" + assert zip_entries["following_accounts.csv"] =~ "fullfollow@#{local_domain}" + assert zip_entries["muted_accounts.csv"] =~ "fullmute@#{local_domain}" + assert zip_entries["blocked_accounts.csv"] =~ "fullblock@#{local_domain}" + assert zip_entries["blocked_domains.csv"] =~ "Domain\nblocked.example" + + manifest = Jason.decode!(zip_entries["export_manifest.json"]) + + assert manifest["schema_version"] == 1 + assert manifest["export_type"] == "full" + assert manifest["counts"]["total"] >= 4 + + assert Enum.any?(manifest["files"], &(&1["path"] == "account.json")) + assert Enum.any?(manifest["files"], &(&1["path"] == "following_accounts.csv")) + + assert manifest["relationship_imports"]["follows"] == %{ + "path" => "following_accounts.csv", + "type" => "follows", + "field" => "file", + "header" => "Account address" + } + + assert manifest["relationship_imports"]["domain_blocks"]["type"] == "domain_blocks" + end + + defp unique_integer, do: System.unique_integer([:positive]) +end diff --git a/apps/elektrine/test/elektrine/developer_tokens_test.exs b/apps/elektrine/test/elektrine/developer_tokens_test.exs new file mode 100644 index 0000000..5206f8e --- /dev/null +++ b/apps/elektrine/test/elektrine/developer_tokens_test.exs @@ -0,0 +1,165 @@ +defmodule Elektrine.DeveloperTokensTest do + use Elektrine.DataCase + + import Elektrine.AccountsFixtures + + alias Elektrine.Accounts + alias Elektrine.Developer + + describe "create_api_token/2" do + test "requires at least one scope" do + user = user_fixture() + + assert {:error, changeset} = + Developer.create_api_token(user.id, %{ + name: "token-without-scopes", + scopes: [] + }) + + assert "must include at least one scope" in errors_on(changeset).scopes + end + + test "accepts dedicated nerve scopes" do + user = user_fixture() + + assert {:ok, token} = + Developer.create_api_token(user.id, %{ + name: "nerve-token", + scopes: ["read:nerve", "write:nerve"] + }) + + assert Enum.sort(token.scopes) == ["read:nerve", "write:nerve"] + end + + test "accepts dedicated moderation scopes" do + user = user_fixture() + + assert {:ok, token} = + Developer.create_api_token(user.id, %{ + name: "moderation-token", + scopes: ["read:moderation", "write:moderation"] + }) + + assert Enum.sort(token.scopes) == ["read:moderation", "write:moderation"] + end + + test "does not create tokens for banned users" do + user = user_fixture() + {:ok, banned_user} = Accounts.ban_user(user, %{banned_reason: "security test"}) + + assert {:error, changeset} = + Developer.create_api_token(banned_user.id, %{ + name: "blocked-token", + scopes: ["read:account"] + }) + + assert "is not active" in errors_on(changeset).user_id + end + + test "enforces the maximum number of active tokens per user" do + user = user_fixture() + + for idx <- 1..Developer.max_tokens_per_user() do + assert {:ok, _token} = + Developer.create_api_token(user.id, %{ + name: "token-#{idx}", + scopes: ["read:account"] + }) + end + + assert {:error, changeset} = + Developer.create_api_token(user.id, %{ + name: "token-over-limit", + scopes: ["read:account"] + }) + + assert "token limit reached (maximum 20 active tokens)" in errors_on(changeset).name + assert Developer.count_api_tokens(user.id) == Developer.max_tokens_per_user() + end + end + + describe "create_nerve_extension_token/2" do + test "replaces stale extension tokens before enforcing the token limit" do + user = user_fixture() + + for idx <- 1..(Developer.max_tokens_per_user() - 1) do + assert {:ok, _token} = + Developer.create_api_token(user.id, %{ + name: "manual-token-#{idx}", + scopes: ["read:account"] + }) + end + + assert {:ok, stale_extension_token} = + Developer.create_api_token(user.id, %{ + name: "Nerve browser extension", + scopes: ["read:nerve", "write:nerve"] + }) + + assert Developer.count_api_tokens(user.id) == Developer.max_tokens_per_user() + + expires_at = + DateTime.utc_now() + |> DateTime.add(3600, :second) + |> DateTime.truncate(:second) + + assert {:ok, replacement_token} = + Developer.create_nerve_extension_token(user.id, expires_at) + + assert String.starts_with?(replacement_token.token, "ekt_") + assert Developer.count_api_tokens(user.id) == Developer.max_tokens_per_user() + + active_tokens = Developer.list_api_tokens(user.id) + + active_extension_tokens = + Enum.filter(active_tokens, &(&1.name == "Nerve browser extension")) + + assert Enum.map(active_extension_tokens, & &1.id) == [replacement_token.id] + refute Enum.any?(active_tokens, &(&1.id == stale_extension_token.id)) + + assert replacement_token.scopes == [ + "read:kairo", + "read:nerve", + "write:kairo", + "write:nerve" + ] + end + end + + describe "verify_api_token/2" do + test "banning a user revokes existing tokens" do + user = user_fixture() + + assert {:ok, token} = + Developer.create_api_token(user.id, %{ + name: "token-before-ban", + scopes: ["read:account"] + }) + + assert {:ok, _banned_user} = Accounts.ban_user(user, %{banned_reason: "security test"}) + + assert {:error, :invalid_token} = Developer.verify_api_token(token.token) + end + + test "revokes tokens older than the user's auth boundary" do + user = user_fixture() + + assert {:ok, token} = + Developer.create_api_token(user.id, %{ + name: "old-token", + scopes: ["read:account"] + }) + + auth_valid_after = + DateTime.utc_now() + |> DateTime.add(60, :second) + |> DateTime.truncate(:second) + + user + |> change(%{auth_valid_after: auth_valid_after}) + |> Repo.update!() + + assert {:error, :token_revoked} = Developer.verify_api_token(token.token) + end + end +end diff --git a/apps/elektrine/test/elektrine/developer_webhooks_test.exs b/apps/elektrine/test/elektrine/developer_webhooks_test.exs new file mode 100644 index 0000000..923b2ac --- /dev/null +++ b/apps/elektrine/test/elektrine/developer_webhooks_test.exs @@ -0,0 +1,179 @@ +defmodule Elektrine.DeveloperWebhooksTest do + use Elektrine.DataCase + + import Elektrine.AccountsFixtures + + alias Elektrine.{Developer, Repo} + alias Elektrine.Secrets.EncryptedString + + describe "webhook management" do + test "creates, lists, and deletes webhooks" do + user = user_fixture() + + attrs = %{ + name: "Test Hook", + url: "https://example.com/webhook", + events: ["post.created", "follow.new"] + } + + assert {:ok, webhook} = Developer.create_webhook(user.id, attrs) + assert webhook.user_id == user.id + assert webhook.enabled + assert is_binary(webhook.secret) + assert webhook.secret != "" + + [[stored_secret]] = + Repo.query!("SELECT secret FROM developer_webhooks WHERE id = $1", [webhook.id]).rows + + assert EncryptedString.encrypted?(stored_secret) + refute stored_secret == webhook.secret + + webhooks = Developer.list_webhooks(user.id) + assert Enum.any?(webhooks, &(&1.id == webhook.id)) + + assert {:ok, _deleted} = Developer.delete_webhook(user.id, webhook.id) + refute Enum.any?(Developer.list_webhooks(user.id), &(&1.id == webhook.id)) + end + + test "rejects non-https urls outside localhost allowance" do + user = user_fixture() + + assert {:error, changeset} = + Developer.create_webhook(user.id, %{ + name: "Bad Hook", + url: "http://example.com/insecure", + events: ["post.created"] + }) + + assert "must be a valid HTTPS URL" in errors_on(changeset).url + end + + test "sends a test webhook and records delivery metadata" do + user = user_fixture() + + {:ok, listener} = + :gen_tcp.listen(0, [:binary, packet: :raw, active: false, reuseaddr: true]) + + {:ok, port} = :inet.port(listener) + + parent = self() + + Task.start(fn -> + {:ok, socket} = :gen_tcp.accept(listener) + {:ok, request} = :gen_tcp.recv(socket, 0, 5_000) + send(parent, {:webhook_request, request}) + :ok = :gen_tcp.send(socket, "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok") + :gen_tcp.close(socket) + :gen_tcp.close(listener) + end) + + {:ok, webhook} = + Developer.create_webhook(user.id, %{ + name: "Local Test Hook", + url: "http://127.0.0.1:#{port}/webhook", + events: ["post.created"] + }) + + assert {:ok, 200} = Developer.test_webhook(user.id, webhook.id) + assert_receive {:webhook_request, raw_request}, 5_000 + assert raw_request =~ "POST /webhook HTTP/1.1" + assert raw_request =~ "x-elektrine-signature: sha256=" + + updated = Developer.get_webhook(user.id, webhook.id) + assert updated.last_response_status == 200 + assert updated.last_error == nil + assert updated.last_triggered_at != nil + end + + test "queues event deliveries and stores delivery history" do + user = user_fixture() + + {:ok, listener} = + :gen_tcp.listen(0, [:binary, packet: :raw, active: false, reuseaddr: true]) + + {:ok, port} = :inet.port(listener) + parent = self() + + Task.start(fn -> + {:ok, socket} = :gen_tcp.accept(listener) + {:ok, request} = :gen_tcp.recv(socket, 0, 5_000) + send(parent, {:delivery_request, request}) + :ok = :gen_tcp.send(socket, "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok") + :gen_tcp.close(socket) + :gen_tcp.close(listener) + end) + + {:ok, webhook} = + Developer.create_webhook(user.id, %{ + name: "Queued Delivery Hook", + url: "http://127.0.0.1:#{port}/webhook", + events: ["post.created"] + }) + + webhook_id = webhook.id + + assert [{^webhook_id, {:ok, :queued}}] = + Developer.deliver_event(user.id, "post.created", %{post_id: 123}) + + assert_receive {:delivery_request, raw_request}, 5_000 + assert raw_request =~ "POST /webhook HTTP/1.1" + assert raw_request =~ "x-elektrine-delivery-id:" + + [delivery | _] = + Developer.list_webhook_deliveries(user.id, webhook_id: webhook.id, limit: 5) + + assert delivery.event == "post.created" + assert delivery.status == "delivered" + assert delivery.response_status == 200 + assert delivery.error == nil + assert delivery.attempt_count >= 1 + end + + test "replays a webhook delivery by queueing a fresh attempt" do + user = user_fixture() + + {:ok, listener} = + :gen_tcp.listen(0, [:binary, packet: :raw, active: false, reuseaddr: true]) + + {:ok, port} = :inet.port(listener) + parent = self() + + Task.start(fn -> + for _attempt <- 1..2 do + {:ok, socket} = :gen_tcp.accept(listener) + {:ok, request} = :gen_tcp.recv(socket, 0, 5_000) + send(parent, {:replay_request, request}) + :ok = :gen_tcp.send(socket, "HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok") + :gen_tcp.close(socket) + end + + :gen_tcp.close(listener) + end) + + {:ok, webhook} = + Developer.create_webhook(user.id, %{ + name: "Replay Hook", + url: "http://127.0.0.1:#{port}/webhook", + events: ["post.created"] + }) + + assert [{_webhook_id, {:ok, :queued}}] = + Developer.deliver_event(user.id, "post.created", %{post_id: 123}) + + assert_receive {:replay_request, first_request}, 5_000 + assert first_request =~ "POST /webhook HTTP/1.1" + + [delivery | _] = + Developer.list_webhook_deliveries(user.id, webhook_id: webhook.id, limit: 5) + + assert {:ok, :queued} = Developer.replay_webhook_delivery(user.id, delivery.id) + + assert_receive {:replay_request, replay_request}, 5_000 + assert replay_request =~ "POST /webhook HTTP/1.1" + + deliveries = Developer.list_webhook_deliveries(user.id, webhook_id: webhook.id, limit: 10) + assert length(deliveries) == 2 + assert Enum.all?(deliveries, &(&1.status == "delivered")) + end + end +end diff --git a/apps/elektrine/test/elektrine/discord_test.exs b/apps/elektrine/test/elektrine/discord_test.exs new file mode 100644 index 0000000..ca5d68b --- /dev/null +++ b/apps/elektrine/test/elektrine/discord_test.exs @@ -0,0 +1,68 @@ +defmodule Elektrine.DiscordTest do + use ExUnit.Case, async: true + + import ExUnit.CaptureLog + + alias Elektrine.Discord + + test "parses Lanyard presence with Spotify album art URL" do + request_fun = fn _request, _opts -> + {:ok, + %Finch.Response{ + status: 200, + body: + Jason.encode!(%{ + "success" => true, + "data" => %{ + "discord_user" => %{ + "id" => "94490510688792576", + "username" => "phin", + "global_name" => "Phineas", + "avatar" => "avatarhash" + }, + "discord_status" => "online", + "activities" => [], + "spotify" => %{ + "song" => "Let Go", + "artist" => "Ark Patrol", + "album" => "Let Go", + "album_art_url" => "https://i.scdn.co/image/example", + "track_id" => "track-123" + } + } + }) + }} + end + + assert %{ + username: "Phineas", + status: "online", + spotify: %{album_art: "https://i.scdn.co/image/example"} + } = Discord.get_user_presence("94490510688792576", request_fun: request_fun) + end + + test "returns nil for Lanyard users that are not monitored" do + request_fun = fn _request, _opts -> + {:ok, + %Finch.Response{ + status: 200, + body: Jason.encode!(%{"success" => false, "error" => "user not monitored"}) + }} + end + + log = + capture_log(fn -> + assert Discord.get_user_presence("12345678901234567", request_fun: request_fun) == nil + end) + + assert log =~ "user not monitored" + end + + test "rejects invalid Discord IDs before building a request" do + request_fun = fn _request, _opts -> flunk("invalid Discord IDs must not be fetched") end + + assert Discord.get_user_presence("../status?x=1", request_fun: request_fun) == nil + assert Discord.get_user_presence("123", request_fun: request_fun) == nil + assert Discord.get_user_presence("1234567890123456", request_fun: request_fun) == nil + end +end diff --git a/apps/elektrine/test/elektrine/domains_test.exs b/apps/elektrine/test/elektrine/domains_test.exs new file mode 100644 index 0000000..5b737bf --- /dev/null +++ b/apps/elektrine/test/elektrine/domains_test.exs @@ -0,0 +1,124 @@ +defmodule Elektrine.DomainsTest do + use Elektrine.DataCase, async: false + + alias Elektrine.Accounts.User + alias Elektrine.Domains + + setup do + previous_port = System.get_env("PORT") + previous_environment = Application.get_env(:elektrine, :environment) + previous_profile_base_domains = Application.get_env(:elektrine, :profile_base_domains) + + previous_email_config = Application.get_env(:elektrine, :email) + + on_exit(fn -> + if is_nil(previous_port), + do: System.delete_env("PORT"), + else: System.put_env("PORT", previous_port) + + if is_nil(previous_environment) do + Application.delete_env(:elektrine, :environment) + else + Application.put_env(:elektrine, :environment, previous_environment) + end + + if is_nil(previous_profile_base_domains) do + Application.delete_env(:elektrine, :profile_base_domains) + else + Application.put_env(:elektrine, :profile_base_domains, previous_profile_base_domains) + end + + if is_nil(previous_email_config) do + Application.delete_env(:elektrine, :email) + else + Application.put_env(:elektrine, :email, previous_email_config) + end + end) + + :ok + end + + test "uses http with configured port for local development domains" do + Application.put_env(:elektrine, :environment, :dev) + System.put_env("PORT", "4100") + + assert Domains.inferred_base_url_for_domain("localhost") == "http://localhost:4100" + end + + test "uses https without port for public tunnel domains in development" do + Application.put_env(:elektrine, :environment, :dev) + System.put_env("PORT", "4100") + + assert Domains.inferred_base_url_for_domain("z.example.com") == "https://z.example.com" + end + + test "uses https without port in production" do + Application.put_env(:elektrine, :environment, :prod) + System.put_env("PORT", "4100") + + assert Domains.inferred_base_url_for_domain("localhost") == "https://localhost" + end + + test "default hosted Elektrine config offers alternate official domains" do + assert "elektrine.net" in Domains.receiving_email_domains() + assert "elektrine.org" in Domains.receiving_email_domains() + assert "elektrine.net" in Domains.available_email_domains_for_user(123) + assert "elektrine.org" in Domains.available_email_domains_for_user(123) + end + + test "uses the primary configured profile domain for enabled built-in profile subdomains" do + Application.put_env(:elektrine, :email, domain: "selfhost.test") + Application.put_env(:elektrine, :profile_base_domains, ["selfhost.test", "example.org"]) + user = %User{handle: "alice", username: "alice", built_in_subdomain_mode: "platform"} + + assert Domains.default_profile_domain() == "selfhost.test" + assert Domains.default_profile_url_for_user(user) == "https://alice.selfhost.test" + end + + test "uses path-based profile URLs until the user enables subdomain hosting" do + Application.put_env(:elektrine, :email, domain: "elektrine.com") + Application.put_env(:elektrine, :profile_base_domains, ["elektrine.com"]) + user = %User{handle: "alice", username: "alice", built_in_subdomain_mode: "path"} + + assert Domains.default_profile_url_for_handle("alice") == "https://elektrine.com/alice" + assert Domains.profile_url_for_handle("alice") == "https://elektrine.com/alice" + assert Domains.profile_url_for_user(user) == "https://elektrine.com/alice" + assert Domains.default_profile_url_for_user(user) == "https://elektrine.com/alice" + end + + test "does not treat supported email domains as built-in profile domains by default" do + Application.put_env(:elektrine, :email, + domain: "elektrine.com", + supported_domains: ["elektrine.com", "elektrine.net", "elektrine.org", "example.org"] + ) + + Application.delete_env(:elektrine, :profile_base_domains) + + assert Domains.configured_profile_base_domains() == ["elektrine.com"] + assert Domains.default_profile_url_for_handle("alice") == "https://elektrine.com/alice" + assert Domains.profile_urls_for_handle("alice") == ["https://alice.elektrine.com"] + end + + test "treats secondary supported domains as receive-only" do + Application.put_env(:elektrine, :email, + domain: "elektrine.com", + supported_domains: ["elektrine.com", "example.org"] + ) + + Application.put_env(:elektrine, :profile_base_domains, ["elektrine.com", "example.org"]) + + assert Domains.supported_email_domains() == ["elektrine.com"] + assert "example.org" in Domains.receiving_email_domains() + refute "example.org" in Domains.available_email_domains_for_user(123) + refute "example.org" in Domains.configured_profile_base_domains() + refute "example.org" in Domains.activitypub_domains() + end + + test "canonicalizes profile URLs to the primary configured profile domain on built-in hosts" do + Application.put_env(:elektrine, :email, domain: "selfhost.test") + Application.put_env(:elektrine, :profile_base_domains, ["selfhost.test", "example.org"]) + + assert Domains.profile_url_for_handle("alice", "alice.example.org") == + "https://selfhost.test/alice" + end +end diff --git a/apps/elektrine/test/elektrine/drive_test.exs b/apps/elektrine/test/elektrine/drive_test.exs new file mode 100644 index 0000000..0b698ca --- /dev/null +++ b/apps/elektrine/test/elektrine/drive_test.exs @@ -0,0 +1,242 @@ +defmodule Elektrine.DriveTest do + use Elektrine.DataCase, async: false + + alias Elektrine.{Accounts, Drive, Repo} + alias Elektrine.Secrets.EncryptedString + + setup do + previous_uploads = Application.get_env(:elektrine, :uploads) + + tmp_dir = + Path.join(System.tmp_dir!(), "elektrine-drive-#{System.unique_integer([:positive])}") + + File.mkdir_p!(tmp_dir) + + Application.put_env(:elektrine, :uploads, + adapter: :local, + uploads_dir: tmp_dir + ) + + on_exit(fn -> + Application.put_env(:elektrine, :uploads, previous_uploads) + File.rm_rf(tmp_dir) + end) + + {:ok, user: user_fixture(), tmp_dir: tmp_dir} + end + + test "upload_file stores file metadata and content", %{user: user} do + upload = temp_upload("quarterly-report.txt", "quarterly numbers") + + assert {:ok, file} = Drive.upload_file(user, "reports/2026", upload) + assert file.path == "reports/2026/quarterly-report.txt" + assert file.original_filename == "quarterly-report.txt" + assert file.size == byte_size("quarterly numbers") + assert {:ok, "quarterly numbers"} = Drive.read_file(file) + + [stored] = Drive.list_files(user.id) + assert stored.id == file.id + assert Drive.storage_used(user.id) == byte_size("quarterly numbers") + end + + test "create_share and revoke_share manage public links", %{user: user} do + upload = temp_upload("shared.pdf", "%PDF-1.4 fake") + assert {:ok, file} = Drive.upload_file(user, "", upload) + + assert {:ok, share} = Drive.create_share(user.id, file.id, %{expires_in: "7d"}) + share_id = share.id + assert is_binary(share.token) + assert share.token_hash == Drive.share_token_hash(share.token) + assert %Drive.FileShare{id: ^share_id} = Drive.get_active_share(share.token) + assert share.expires_at + + [stored_token, stored_token_hash] = + Repo.query!("SELECT token, token_hash FROM drive_shares WHERE id = $1", [share.id]).rows + |> List.first() + + assert EncryptedString.encrypted?(stored_token) + refute stored_token == share.token + assert stored_token_hash == Drive.share_token_hash(share.token) + + assert {:ok, updated_share} = Drive.increment_share_download_count(share) + assert updated_share.download_count == 1 + + assert {:ok, _revoked} = Drive.revoke_share(user.id, share.id) + assert is_nil(Drive.get_active_share(share.token)) + end + + test "list_folder returns immediate folders and files", %{user: user} do + assert {:ok, _} = + Drive.upload_file(user, "projects/alpha", temp_upload("readme.txt", "alpha")) + + assert {:ok, _} = Drive.upload_file(user, "projects/beta", temp_upload("notes.txt", "beta")) + assert {:ok, root_file} = Drive.upload_file(user, "", temp_upload("todo.txt", "root")) + + assert {:ok, root_view} = Drive.list_folder(user.id, "") + assert Enum.map(root_view.folders, & &1.path) == ["projects"] + assert Enum.map(root_view.files, & &1.id) == [root_file.id] + + assert {:ok, project_view} = Drive.list_folder(user.id, "projects") + + assert Enum.sort(Enum.map(project_view.folders, & &1.path)) == [ + "projects/alpha", + "projects/beta" + ] + + assert project_view.files == [] + end + + test "can create, rename, and move folders", %{user: user} do + assert {:ok, folder} = Drive.create_folder(user.id, "workspace/drafts") + assert folder.path == "workspace/drafts" + + assert {:ok, moved_path} = Drive.rename_folder(user.id, "workspace/drafts", "plans") + assert moved_path == "workspace/plans" + + assert {:ok, final_path} = Drive.move_folder(user.id, "workspace/plans", "archive/2026") + assert final_path == "archive/2026/plans" + + assert {:ok, view} = Drive.list_folder(user.id, "archive/2026") + assert Enum.map(view.folders, & &1.path) == ["archive/2026/plans"] + end + + test "supports search and sort in folder views", %{user: user} do + assert {:ok, _} = Drive.upload_file(user, "", temp_upload("zeta.txt", "12345")) + assert {:ok, _} = Drive.upload_file(user, "", temp_upload("alpha.txt", "1")) + + assert {:ok, search_view} = Drive.list_folder(user.id, "", %{q: "alp"}) + assert Enum.map(search_view.files, & &1.original_filename) == ["alpha.txt"] + + assert {:ok, sorted_view} = Drive.list_folder(user.id, "", %{sort: "name_desc"}) + assert Enum.map(sorted_view.files, & &1.original_filename) == ["zeta.txt", "alpha.txt"] + end + + test "expired shares are no longer active", %{user: user} do + assert {:ok, file} = Drive.upload_file(user, "", temp_upload("shared.txt", "expiring")) + assert {:ok, share} = Drive.create_share(user.id, file.id, %{expires_in: "1d"}) + + expired_share = + share + |> Ecto.Changeset.change( + expires_at: DateTime.utc_now() |> DateTime.add(-60, :second) |> DateTime.truncate(:second) + ) + |> Repo.update!() + + assert is_nil(Drive.get_active_share(expired_share.token)) + end + + test "burn after read shares are no longer active after first download", %{user: user} do + assert {:ok, file} = Drive.upload_file(user, "", temp_upload("single-use.txt", "once")) + assert {:ok, share} = Drive.create_share(user.id, file.id, %{burn_after_read: true}) + + assert %Drive.FileShare{} = Drive.get_active_share(share.token) + + assert {:ok, _updated_share} = Drive.increment_share_download_count(share) + assert is_nil(Drive.get_active_share(share.token)) + end + + test "share links can be password protected and inline viewable", %{user: user} do + assert {:ok, file} = Drive.upload_file(user, "", temp_upload("preview.txt", "hello world")) + + assert {:ok, share} = + Drive.create_share(user.id, file.id, %{ + access_level: "view", + password: "secret-pass" + }) + + share = Repo.preload(share, :stored_file) + + assert Drive.share_requires_password?(share) + assert Drive.verify_share_password(share, "secret-pass") + refute Drive.verify_share_password(share, "wrong-pass") + assert Drive.share_inline_view?(share) + end + + test "html shares are never inline viewable", %{user: user} do + upload = temp_upload("preview.html", "") + upload = %{upload | content_type: "text/html"} + + assert {:ok, file} = Drive.upload_file(user, "", upload) + assert {:ok, share} = Drive.create_share(user.id, file.id, %{access_level: "view"}) + + share = Repo.preload(share, :stored_file) + + refute Drive.share_inline_view?(share) + end + + test "uploading the same path replaces the existing file", %{user: user} do + assert {:ok, first} = Drive.upload_file(user, "docs", temp_upload("notes.txt", "one")) + assert {:ok, second} = Drive.upload_file(user, "docs", temp_upload("notes.txt", "two two")) + + assert first.id == second.id + assert Drive.storage_used(user.id) == byte_size("two two") + assert {:ok, "two two"} = Drive.read_file(second) + + refute File.exists?(Path.join(uploads_dir(), first.storage_key)) + end + + test "local storage rejects poisoned storage keys outside uploads dir", %{ + user: user, + tmp_dir: tmp_dir + } do + outside_path = Path.join(Path.dirname(tmp_dir), "outside-drive-secret.txt") + File.write!(outside_path, "do not read or delete") + on_exit(fn -> File.rm(outside_path) end) + + assert {:ok, file} = + %Drive.StoredFile{} + |> Drive.StoredFile.changeset(%{ + user_id: user.id, + path: "poisoned.txt", + storage_key: "../#{Path.basename(outside_path)}", + original_filename: "poisoned.txt", + content_type: "text/plain", + size: 1 + }) + |> Repo.insert() + + assert {:error, :invalid_storage_key} = Drive.read_file(file) + assert {:error, :invalid_storage_key} = Drive.delete_file(user.id, file.id) + assert File.read!(outside_path) == "do not read or delete" + end + + test "can rename, move, and bulk delete files", %{user: user} do + assert {:ok, file} = Drive.upload_file(user, "drafts", temp_upload("notes.txt", "hello")) + assert {:ok, _folder} = Drive.create_folder(user.id, "archive") + + assert {:ok, renamed} = Drive.rename_file(user.id, file.id, "ideas.txt") + assert renamed.path == "drafts/ideas.txt" + + assert {:ok, moved} = Drive.move_file(user.id, renamed.id, "archive") + assert moved.path == "archive/ideas.txt" + + assert :ok = Drive.bulk_delete(user.id, ["file:#{moved.id}"]) + assert is_nil(Drive.get_file(user.id, moved.id)) + end + + defp user_fixture do + {:ok, user} = + Accounts.create_user(%{ + username: "drive#{System.unique_integer([:positive])}", + password: "Test123456!", + password_confirmation: "Test123456!" + }) + + user + end + + defp temp_upload(filename, content) do + path = Path.join(System.tmp_dir!(), "#{System.unique_integer([:positive])}-#{filename}") + File.write!(path, content) + + %Plug.Upload{ + path: path, + filename: filename, + content_type: MIME.from_path(filename) + } + end + + defp uploads_dir do + Application.get_env(:elektrine, :uploads, [])[:uploads_dir] + end +end diff --git a/apps/elektrine/test/elektrine/emojis_test.exs b/apps/elektrine/test/elektrine/emojis_test.exs new file mode 100644 index 0000000..9e6d583 --- /dev/null +++ b/apps/elektrine/test/elektrine/emojis_test.exs @@ -0,0 +1,89 @@ +defmodule Elektrine.EmojisTest do + use Elektrine.DataCase + + alias Elektrine.Emojis + alias Elektrine.Emojis.CustomEmoji + alias Elektrine.Repo + + describe "get_or_create_from_activitypub/2" do + test "rejects unsafe image URLs" do + emoji_data = %{ + "type" => "Emoji", + "name" => ":blobcat:", + "icon" => %{ + "url" => ~s|https://cdn.example/emojis/blobcat.png" onerror="alert(1)| + } + } + + assert {:error, changeset} = + Emojis.get_or_create_from_activitypub(emoji_data, "remote.example") + + assert "must be a valid public http(s) URL" in errors_on(changeset).image_url + end + end + + describe "render_custom_emojis/2" do + test "does not render stored emojis with unsafe image URLs" do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + Repo.insert_all(CustomEmoji, [ + %{ + shortcode: "blobcat", + image_url: ~s|https://cdn.example/emojis/blobcat.png" onerror="alert(1)|, + instance_domain: "remote.example", + visible_in_picker: false, + disabled: false, + inserted_at: now, + updated_at: now + } + ]) + + assert {"hello :blobcat:", []} = + Emojis.render_custom_emojis("hello :blobcat:", "remote.example") + end + + test "renders local emojis when the caller passes the local instance hostname" do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + Repo.insert_all(CustomEmoji, [ + %{ + shortcode: "thumbsupcat", + image_url: "https://cdn.example/emojis/thumbsupcat.png", + instance_domain: nil, + visible_in_picker: false, + disabled: false, + inserted_at: now, + updated_at: now + } + ]) + + {html, emojis} = + Emojis.render_custom_emojis( + "hello :thumbsupcat:", + Elektrine.Domains.instance_domain() + ) + + assert length(emojis) == 1 + assert html =~ "custom-emoji" + assert html =~ "thumbsupcat.png" + end + + test "returns the shortcode for unsafe inline emoji rows" do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + Repo.insert_all(CustomEmoji, [ + %{ + shortcode: "blobcat", + image_url: "http://127.0.0.1/blobcat.png", + instance_domain: nil, + visible_in_picker: true, + disabled: false, + inserted_at: now, + updated_at: now + } + ]) + + assert Emojis.render_emoji_html(":blobcat:") == ":blobcat:" + end + end +end diff --git a/apps/elektrine/test/elektrine/encrypted_search_test.exs b/apps/elektrine/test/elektrine/encrypted_search_test.exs new file mode 100644 index 0000000..15310e6 --- /dev/null +++ b/apps/elektrine/test/elektrine/encrypted_search_test.exs @@ -0,0 +1,498 @@ +defmodule Elektrine.EncryptedSearchTest do + use Elektrine.DataCase + + alias Elektrine.{Accounts, Messaging, Repo} + alias Elektrine.Messaging.ChatMessage + + setup do + # Create test users + {:ok, user1} = + Accounts.create_user(%{ + username: "user1", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, user2} = + Accounts.create_user(%{ + username: "user2", + password: "password123456", + password_confirmation: "password123456" + }) + + # Create a conversation + {:ok, conversation} = Messaging.create_dm_conversation(user1.id, user2.id) + + %{user1: user1, user2: user2, conversation: conversation} + end + + describe "blind index search" do + test "finds messages by keyword using encrypted index", %{ + user1: user1, + conversation: conversation + } do + # Create messages with different content + {:ok, _msg1} = + Messaging.create_text_message( + conversation.id, + user1.id, + "Meeting scheduled for tomorrow" + ) + + {:ok, _msg2} = + Messaging.create_text_message( + conversation.id, + user1.id, + "Important project deadline" + ) + + {:ok, _msg3} = + Messaging.create_text_message( + conversation.id, + user1.id, + "Random other message" + ) + + # Search for "meeting" + {:ok, results} = + Messaging.search_messages_in_conversation( + conversation.id, + user1.id, + "meeting" + ) + + # Should find only the message containing "meeting" + assert results != [] + assert Enum.any?(results, fn m -> m.content =~ "Meeting" end) + end + + test "search works with exact keyword matches", %{user1: user1, conversation: conversation} do + {:ok, _message} = + Messaging.create_text_message( + conversation.id, + user1.id, + "Testing the search functionality" + ) + + # Search with exact keyword match (blind index only supports exact matches) + {:ok, results} = + Messaging.search_messages_in_conversation( + conversation.id, + user1.id, + "testing" + ) + + # Should find the message + assert results != [] + assert Enum.any?(results, fn m -> m.content =~ "Testing" end) + end + + test "search is case-insensitive", %{user1: user1, conversation: conversation} do + {:ok, _message} = + Messaging.create_text_message( + conversation.id, + user1.id, + "Important PROJECT deadline" + ) + + # Search with lowercase + {:ok, results1} = + Messaging.search_messages_in_conversation( + conversation.id, + user1.id, + "project" + ) + + # Search with uppercase + {:ok, results2} = + Messaging.search_messages_in_conversation( + conversation.id, + user1.id, + "PROJECT" + ) + + # Both should find the message + assert results1 != [] + assert results2 != [] + end + + test "finds messages by hashtag", %{user1: user1, conversation: conversation} do + {:ok, _msg1} = + Messaging.create_text_message( + conversation.id, + user1.id, + "Working on #project today" + ) + + {:ok, _msg2} = + Messaging.create_text_message( + conversation.id, + user1.id, + "Need to finish #deadline tasks" + ) + + # Search for hashtag + {:ok, results} = + Messaging.search_messages_in_conversation( + conversation.id, + user1.id, + "#project" + ) + + # Should find the message with #project + assert results != [] + assert Enum.any?(results, fn m -> m.content =~ "#project" end) + end + + test "returns decrypted content in search results", %{ + user1: user1, + conversation: conversation + } do + secret_content = "This is secret content for search" + + {:ok, _message} = + Messaging.create_text_message( + conversation.id, + user1.id, + secret_content + ) + + {:ok, results} = + Messaging.search_messages_in_conversation( + conversation.id, + user1.id, + "secret" + ) + + # Results should be decrypted + assert Enum.any?(results, fn m -> m.content == secret_content end) + end + + test "search doesn't return results from other users' conversations", %{ + user1: user1, + user2: user2, + conversation: conversation + } do + # Create another user and conversation + {:ok, user3} = + Accounts.create_user(%{ + username: "user3", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, other_conv} = Messaging.create_dm_conversation(user2.id, user3.id) + + # Create message in first conversation + {:ok, _msg1} = + Messaging.create_text_message( + conversation.id, + user1.id, + "secretword in first conversation" + ) + + # Create message in second conversation + {:ok, _msg2} = + Messaging.create_text_message( + other_conv.id, + user2.id, + "secretword in second conversation" + ) + + # Search in first conversation + {:ok, results} = + Messaging.search_messages_in_conversation( + conversation.id, + user1.id, + "secretword" + ) + + # Should only find message from first conversation + assert length(results) == 1 + assert hd(results).conversation_id == conversation.id + end + + test "search filters out short words (stop words)", %{ + user1: user1, + conversation: conversation + } do + {:ok, _message} = + Messaging.create_text_message( + conversation.id, + user1.id, + "The quick brown fox jumps" + ) + + # Searching for stop words may not work with blind index + # But searching for "quick" should work + {:ok, results} = + Messaging.search_messages_in_conversation( + conversation.id, + user1.id, + "quick" + ) + + assert results != [] + end + end + + describe "search with multiple keywords" do + test "finds messages matching query terms", %{user1: user1, conversation: conversation} do + {:ok, _msg1} = + Messaging.create_text_message( + conversation.id, + user1.id, + "Project meeting tomorrow morning" + ) + + {:ok, _msg2} = + Messaging.create_text_message( + conversation.id, + user1.id, + "Budget review meeting next week" + ) + + # Search for "meeting" + {:ok, results} = + Messaging.search_messages_in_conversation( + conversation.id, + user1.id, + "meeting" + ) + + # Should find both messages + assert length(results) >= 2 + end + + test "handles special characters in search", %{user1: user1, conversation: conversation} do + {:ok, _message} = + Messaging.create_text_message( + conversation.id, + user1.id, + "Email: user@example.com" + ) + + # Search should handle special characters + {:ok, results} = + Messaging.search_messages_in_conversation( + conversation.id, + user1.id, + "email" + ) + + assert results != [] + end + end + + describe "search index integrity" do + test "search index contains expected keyword hashes", %{ + user1: user1, + conversation: conversation + } do + content = "Important meeting about project deadline" + + {:ok, message} = + Messaging.create_text_message( + conversation.id, + user1.id, + content + ) + + # Reload from database + db_message = Repo.get(ChatMessage, message.id) + + # Calculate expected hashes + important_hash = Elektrine.Encryption.hash_keyword("important", user1.id) + meeting_hash = Elektrine.Encryption.hash_keyword("meeting", user1.id) + project_hash = Elektrine.Encryption.hash_keyword("project", user1.id) + deadline_hash = Elektrine.Encryption.hash_keyword("deadline", user1.id) + + # Verify all keyword hashes are in index + assert important_hash in db_message.search_index + assert meeting_hash in db_message.search_index + assert project_hash in db_message.search_index + assert deadline_hash in db_message.search_index + end + + test "search index updates when message is edited", %{ + user1: user1, + conversation: conversation + } do + {:ok, message} = + Messaging.create_text_message( + conversation.id, + user1.id, + "Original content with keyword1" + ) + + original_db_message = Repo.get(ChatMessage, message.id) + keyword1_hash = Elektrine.Encryption.hash_keyword("keyword1", user1.id) + assert keyword1_hash in original_db_message.search_index + + # Edit message + {:ok, _updated} = + Messaging.edit_message( + message.id, + user1.id, + "Updated content with keyword2" + ) + + updated_db_message = Repo.get(ChatMessage, message.id) + keyword2_hash = Elektrine.Encryption.hash_keyword("keyword2", user1.id) + + # New keyword should be in index + assert keyword2_hash in updated_db_message.search_index + end + + test "search index is user-specific", %{ + user1: user1, + user2: user2, + conversation: conversation + } do + content = "Shared message content" + + {:ok, msg1} = + Messaging.create_text_message( + conversation.id, + user1.id, + content + ) + + {:ok, msg2} = + Messaging.create_text_message( + conversation.id, + user2.id, + content + ) + + db_msg1 = Repo.get(ChatMessage, msg1.id) + db_msg2 = Repo.get(ChatMessage, msg2.id) + + # Same content, different users = different search indexes + assert db_msg1.search_index != db_msg2.search_index + + # But each user's keywords should match their own hash + user1_hash = Elektrine.Encryption.hash_keyword("shared", user1.id) + user2_hash = Elektrine.Encryption.hash_keyword("shared", user2.id) + + assert user1_hash in db_msg1.search_index + assert user2_hash in db_msg2.search_index + refute user1_hash in db_msg2.search_index + refute user2_hash in db_msg1.search_index + end + end + + describe "search performance" do + test "search works with large number of messages", %{user1: user1, conversation: conversation} do + # Create multiple messages + for i <- 1..20 do + content = + if rem(i, 5) == 0 do + "Message #{i} with searchterm" + else + "Message #{i} without special keyword" + end + + {:ok, _msg} = + Messaging.create_text_message( + conversation.id, + user1.id, + content + ) + end + + # Search should find only messages with "searchterm" + {:ok, results} = + Messaging.search_messages_in_conversation( + conversation.id, + user1.id, + "searchterm" + ) + + # Should find 4 messages (5, 10, 15, 20) + assert length(results) == 4 + assert Enum.all?(results, fn m -> m.content =~ "searchterm" end) + end + + test "very short search query", %{user1: user1, conversation: conversation} do + {:ok, _message} = + Messaging.create_text_message( + conversation.id, + user1.id, + "Some content" + ) + + # Very short (less than 2 chars) search - behavior depends on implementation + {:ok, results} = + Messaging.search_messages_in_conversation( + conversation.id, + user1.id, + "x" + ) + + # Should handle gracefully (may return empty or match single-char terms) + assert is_list(results) + end + + test "search with very short query", %{user1: user1, conversation: conversation} do + {:ok, _message} = + Messaging.create_text_message( + conversation.id, + user1.id, + "Message with short word: OK" + ) + + # Very short search terms may not be indexed + {:ok, results} = + Messaging.search_messages_in_conversation( + conversation.id, + user1.id, + "OK" + ) + + # Behavior depends on implementation (min 3 chars for indexing) + # This tests that it doesn't crash + assert is_list(results) + end + end + + describe "search with unicode and special content" do + test "searches unicode content", %{user1: user1, conversation: conversation} do + {:ok, _message} = + Messaging.create_text_message( + conversation.id, + user1.id, + "Unicode content: 世界 Привет" + ) + + # Search for unicode term + {:ok, results} = + Messaging.search_messages_in_conversation( + conversation.id, + user1.id, + "unicode" + ) + + assert results != [] + end + + test "searches messages with emojis", %{user1: user1, conversation: conversation} do + {:ok, _message} = + Messaging.create_text_message( + conversation.id, + user1.id, + "Happy birthday 🎉🎂 celebration" + ) + + {:ok, results} = + Messaging.search_messages_in_conversation( + conversation.id, + user1.id, + "celebration" + ) + + assert results != [] + assert Enum.any?(results, fn m -> m.content =~ "🎉" end) + end + end +end diff --git a/apps/elektrine/test/elektrine/encryption_test.exs b/apps/elektrine/test/elektrine/encryption_test.exs new file mode 100644 index 0000000..5ea91c9 --- /dev/null +++ b/apps/elektrine/test/elektrine/encryption_test.exs @@ -0,0 +1,262 @@ +defmodule Elektrine.EncryptionTest do + use ExUnit.Case, async: true + alias Elektrine.Encryption + + @user_id 123 + + describe "encrypt/2 and decrypt/2" do + test "encrypts and decrypts plaintext successfully" do + plaintext = "This is a secret message" + + encrypted = Encryption.encrypt(plaintext, @user_id) + + assert is_map(encrypted) + assert Map.has_key?(encrypted, :encrypted_data) + assert Map.has_key?(encrypted, :iv) + assert Map.has_key?(encrypted, :tag) + assert encrypted.encrypted_data != plaintext + + {:ok, decrypted} = Encryption.decrypt(encrypted, @user_id) + assert decrypted == plaintext + end + + test "encrypts same plaintext differently each time (due to random IV)" do + plaintext = "Same message" + + encrypted1 = Encryption.encrypt(plaintext, @user_id) + encrypted2 = Encryption.encrypt(plaintext, @user_id) + + # Different IVs mean different ciphertexts + assert encrypted1.iv != encrypted2.iv + assert encrypted1.encrypted_data != encrypted2.encrypted_data + + # But both decrypt to same plaintext + {:ok, decrypted1} = Encryption.decrypt(encrypted1, @user_id) + {:ok, decrypted2} = Encryption.decrypt(encrypted2, @user_id) + assert decrypted1 == plaintext + assert decrypted2 == plaintext + end + + test "decryption fails with wrong user_id" do + plaintext = "Secret for user 123" + encrypted = Encryption.encrypt(plaintext, @user_id) + + wrong_user_id = 456 + result = Encryption.decrypt(encrypted, wrong_user_id) + + assert result == {:error, :decryption_failed} + end + + test "decryption fails with tampered ciphertext" do + plaintext = "Original message" + encrypted = Encryption.encrypt(plaintext, @user_id) + + # Tamper with encrypted data + tampered = %{encrypted | encrypted_data: Base.encode64("tampered data")} + + result = Encryption.decrypt(tampered, @user_id) + assert result == {:error, :decryption_failed} + end + + test "handles empty string" do + plaintext = "" + encrypted = Encryption.encrypt(plaintext, @user_id) + {:ok, decrypted} = Encryption.decrypt(encrypted, @user_id) + + assert decrypted == "" + end + + test "handles unicode characters" do + plaintext = "Hello 世界 🌍 Привет" + encrypted = Encryption.encrypt(plaintext, @user_id) + {:ok, decrypted} = Encryption.decrypt(encrypted, @user_id) + + assert decrypted == plaintext + end + + test "handles large text" do + plaintext = String.duplicate("A", 10_000) + encrypted = Encryption.encrypt(plaintext, @user_id) + {:ok, decrypted} = Encryption.decrypt(encrypted, @user_id) + + assert decrypted == plaintext + end + end + + describe "extract_keywords/1" do + test "extracts words 3+ characters" do + text = "The quick brown fox jumps" + keywords = Encryption.extract_keywords(text) + + assert "quick" in keywords + assert "brown" in keywords + assert "jumps" in keywords + # 3 chars, should be extracted + assert "fox" in keywords + # Stop word + refute "the" in keywords + end + + test "extracts hashtags" do + text = "Check out #elixir and #phoenix frameworks" + keywords = Encryption.extract_keywords(text) + + assert "#elixir" in keywords + assert "#phoenix" in keywords + end + + test "removes stop words" do + text = "the and but for not with you that this from" + keywords = Encryption.extract_keywords(text) + + # All stop words should be filtered out + assert keywords == [] + end + + test "converts to lowercase" do + text = "Elixir PHOENIX Framework" + keywords = Encryption.extract_keywords(text) + + assert "elixir" in keywords + assert "phoenix" in keywords + assert "framework" in keywords + refute "Elixir" in keywords + refute "PHOENIX" in keywords + end + + test "removes duplicates" do + text = "test test test testing" + keywords = Encryption.extract_keywords(text) + + # Should only appear once each + assert Enum.count(keywords, &(&1 == "test")) == 1 + assert Enum.count(keywords, &(&1 == "testing")) == 1 + end + + test "handles mixed content with special characters" do + text = "Email: user@example.com, phone: 555-1234, #important!" + keywords = Encryption.extract_keywords(text) + + assert "#important" in keywords + assert "email" in keywords + end + end + + describe "hash_keyword/2" do + test "produces consistent hash for same keyword and user" do + keyword = "test" + + hash1 = Encryption.hash_keyword(keyword, @user_id) + hash2 = Encryption.hash_keyword(keyword, @user_id) + + assert hash1 == hash2 + end + + test "produces different hashes for different users" do + keyword = "test" + user1_id = 123 + user2_id = 456 + + hash1 = Encryption.hash_keyword(keyword, user1_id) + hash2 = Encryption.hash_keyword(keyword, user2_id) + + assert hash1 != hash2 + end + + test "produces different hashes for different keywords" do + hash1 = Encryption.hash_keyword("test", @user_id) + hash2 = Encryption.hash_keyword("demo", @user_id) + + assert hash1 != hash2 + end + + test "is case-insensitive" do + hash1 = Encryption.hash_keyword("test", @user_id) + hash2 = Encryption.hash_keyword("TEST", @user_id) + hash3 = Encryption.hash_keyword("Test", @user_id) + + assert hash1 == hash2 + assert hash2 == hash3 + end + end + + describe "create_search_index/2" do + test "creates hash for each keyword" do + keywords = ["elixir", "phoenix", "test"] + search_index = Encryption.create_search_index(keywords, @user_id) + + assert length(search_index) == 3 + assert Enum.all?(search_index, &is_binary/1) + end + + test "removes duplicate hashes" do + keywords = ["test", "test", "demo"] + search_index = Encryption.create_search_index(keywords, @user_id) + + # Should only have 2 unique hashes + assert length(search_index) == 2 + end + + test "handles empty keyword list" do + search_index = Encryption.create_search_index([], @user_id) + assert search_index == [] + end + end + + describe "index_content/2" do + test "extracts keywords and creates search index" do + text = "This is a test message about #elixir programming" + search_index = Encryption.index_content(text, @user_id) + + assert is_list(search_index) + assert search_index != [] + assert Enum.all?(search_index, &is_binary/1) + end + + test "creates searchable index that can find keywords" do + text = "Important meeting about #project tomorrow" + search_index = Encryption.index_content(text, @user_id) + + # Hash for "important" should be in index + important_hash = Encryption.hash_keyword("important", @user_id) + assert important_hash in search_index + + # Hash for "meeting" should be in index + meeting_hash = Encryption.hash_keyword("meeting", @user_id) + assert meeting_hash in search_index + + # Hash for "#project" should be in index + project_hash = Encryption.hash_keyword("#project", @user_id) + assert project_hash in search_index + end + + test "index is user-specific" do + text = "Secret message" + + index1 = Encryption.index_content(text, 123) + index2 = Encryption.index_content(text, 456) + + # Same text, different users = different indexes + assert index1 != index2 + end + end + + describe "key derivation" do + test "different users have different encryption keys (indirect test)" do + plaintext = "Same message for different users" + user1_id = 100 + user2_id = 200 + + encrypted1 = Encryption.encrypt(plaintext, user1_id) + encrypted2 = Encryption.encrypt(plaintext, user2_id) + + # User 2 can't decrypt user 1's message + result = Encryption.decrypt(encrypted1, user2_id) + assert result == {:error, :decryption_failed} + + # User 1 can't decrypt user 2's message + result = Encryption.decrypt(encrypted2, user1_id) + assert result == {:error, :decryption_failed} + end + end +end diff --git a/apps/elektrine/test/elektrine/friends_test.exs b/apps/elektrine/test/elektrine/friends_test.exs new file mode 100644 index 0000000..5f14bbc --- /dev/null +++ b/apps/elektrine/test/elektrine/friends_test.exs @@ -0,0 +1,165 @@ +defmodule Elektrine.FriendsTest do + use Elektrine.DataCase + + alias Elektrine.Friends + import Elektrine.AccountsFixtures + + describe "friend requests" do + setup do + # Create test users + user1 = user_fixture() + user2 = user_fixture() + user3 = user_fixture() + + %{user1: user1, user2: user2, user3: user3} + end + + test "can send friend request", %{user1: user1, user2: user2} do + assert {:ok, request} = Friends.send_friend_request(user1.id, user2.id) + assert request.requester_id == user1.id + assert request.recipient_id == user2.id + assert request.status == "pending" + end + + test "cannot send duplicate friend request", %{user1: user1, user2: user2} do + {:ok, _request} = Friends.send_friend_request(user1.id, user2.id) + assert {:error, :request_already_exists} = Friends.send_friend_request(user1.id, user2.id) + end + + test "cannot friend yourself", %{user1: user1} do + assert {:error, :cannot_friend_self} = Friends.send_friend_request(user1.id, user1.id) + end + + test "can accept friend request", %{user1: user1, user2: user2} do + {:ok, request} = Friends.send_friend_request(user1.id, user2.id) + assert {:ok, accepted} = Friends.accept_friend_request(request.id, user2.id) + assert accepted.status == "accepted" + assert Friends.friends?(user1.id, user2.id) + assert Friends.friends?(user2.id, user1.id) + end + + test "can reject friend request", %{user1: user1, user2: user2} do + {:ok, request} = Friends.send_friend_request(user1.id, user2.id) + assert {:ok, rejected} = Friends.reject_friend_request(request.id, user2.id) + assert rejected.status == "rejected" + refute Friends.friends?(user1.id, user2.id) + end + + test "can cancel sent friend request", %{user1: user1, user2: user2} do + {:ok, request} = Friends.send_friend_request(user1.id, user2.id) + assert {:ok, _} = Friends.cancel_friend_request(request.id, user1.id) + refute Friends.friends?(user1.id, user2.id) + end + + test "only recipient can accept request", %{user1: user1, user2: user2} do + {:ok, request} = Friends.send_friend_request(user1.id, user2.id) + # Requester tries to accept their own request + assert {:error, :unauthorized} = Friends.accept_friend_request(request.id, user1.id) + end + + test "can unfriend", %{user1: user1, user2: user2} do + {:ok, request} = Friends.send_friend_request(user1.id, user2.id) + {:ok, _} = Friends.accept_friend_request(request.id, user2.id) + assert Friends.friends?(user1.id, user2.id) + + {:ok, _} = Friends.unfriend(user1.id, user2.id) + refute Friends.friends?(user1.id, user2.id) + end + + test "list_friends returns only accepted friends", %{user1: user1, user2: user2, user3: user3} do + # Send and accept request from user2 + {:ok, request1} = Friends.send_friend_request(user1.id, user2.id) + {:ok, _} = Friends.accept_friend_request(request1.id, user2.id) + + # Send but don't accept request from user3 + {:ok, _request2} = Friends.send_friend_request(user1.id, user3.id) + + friends = Friends.list_friends(user1.id) + assert length(friends) == 1 + assert Enum.any?(friends, fn f -> f.id == user2.id end) + refute Enum.any?(friends, fn f -> f.id == user3.id end) + end + + test "list_pending_requests returns requests you received", %{ + user1: user1, + user2: user2, + user3: user3 + } do + {:ok, _} = Friends.send_friend_request(user2.id, user1.id) + {:ok, _} = Friends.send_friend_request(user3.id, user1.id) + + pending = Friends.list_pending_requests(user1.id) + assert length(pending) == 2 + end + + test "list_sent_requests returns requests you sent", %{ + user1: user1, + user2: user2, + user3: user3 + } do + {:ok, _} = Friends.send_friend_request(user1.id, user2.id) + {:ok, _} = Friends.send_friend_request(user1.id, user3.id) + + sent = Friends.list_sent_requests(user1.id) + assert length(sent) == 2 + end + end + + describe "friend relationship status" do + setup do + user1 = user_fixture() + user2 = user_fixture() + + %{user1: user1, user2: user2} + end + + test "returns correct status for strangers", %{user1: user1, user2: user2} do + status = Friends.get_relationship_status(user1.id, user2.id) + refute status.are_friends + assert is_nil(status.pending_request) + end + + test "returns pending_request when request exists", %{user1: user1, user2: user2} do + {:ok, _request} = Friends.send_friend_request(user1.id, user2.id) + + status = Friends.get_relationship_status(user1.id, user2.id) + refute status.are_friends + assert status.pending_request + end + + test "returns are_friends when accepted", %{user1: user1, user2: user2} do + {:ok, request} = Friends.send_friend_request(user1.id, user2.id) + {:ok, _} = Friends.accept_friend_request(request.id, user2.id) + + status = Friends.get_relationship_status(user1.id, user2.id) + assert status.are_friends + end + end + + describe "friend suggestions" do + setup do + user1 = user_fixture() + user2 = user_fixture() + user3 = user_fixture() + + # Create mutual follows between user1 and user2 + Elektrine.Profiles.follow_user(user1.id, user2.id) + Elektrine.Profiles.follow_user(user2.id, user1.id) + + %{user1: user1, user2: user2, user3: user3} + end + + test "suggests mutual follows who aren't friends yet", %{user1: user1, user2: user2} do + suggestions = Friends.get_suggested_friends(user1.id, 10) + assert Enum.any?(suggestions, fn u -> u.id == user2.id end) + end + + test "doesn't suggest existing friends", %{user1: user1, user2: user2} do + {:ok, request} = Friends.send_friend_request(user1.id, user2.id) + {:ok, _} = Friends.accept_friend_request(request.id, user2.id) + + suggestions = Friends.get_suggested_friends(user1.id, 10) + refute Enum.any?(suggestions, fn u -> u.id == user2.id end) + end + end +end diff --git a/apps/elektrine/test/elektrine/home_blog_test.exs b/apps/elektrine/test/elektrine/home_blog_test.exs new file mode 100644 index 0000000..2eee17c --- /dev/null +++ b/apps/elektrine/test/elektrine/home_blog_test.exs @@ -0,0 +1,50 @@ +defmodule Elektrine.HomeBlogTest do + use ExUnit.Case, async: false + + alias Elektrine.AppCache + alias Elektrine.HomeBlog + + setup do + original = Application.get_env(:elektrine, :home_blog_feed_url) + AppCache.cache_home_blog_posts([]) + + on_exit(fn -> + Application.put_env(:elektrine, :home_blog_feed_url, original) + end) + + :ok + end + + test "returns no posts when the feed is not configured" do + Application.put_env(:elektrine, :home_blog_feed_url, nil) + + assert HomeBlog.cached_posts() == [] + assert HomeBlog.latest_posts() == [] + end + + test "serves cached posts without fetching" do + Application.put_env(:elektrine, :home_blog_feed_url, "https://example.com/atom.xml") + + posts = [ + %{ + title: "Hello world", + url: "https://example.com/blog/hello/", + published_at: ~U[2026-07-20 00:00:00Z] + } + ] + + AppCache.cache_home_blog_posts(posts) + + assert HomeBlog.cached_posts() == posts + assert HomeBlog.latest_posts() == posts + end + + test "a cached empty result is served without refetching" do + Application.put_env(:elektrine, :home_blog_feed_url, "https://example.com/atom.xml") + + AppCache.cache_home_blog_posts([]) + + assert HomeBlog.cached_posts() == [] + assert HomeBlog.latest_posts() == [] + end +end diff --git a/apps/elektrine/test/elektrine/http/safe_fetch_test.exs b/apps/elektrine/test/elektrine/http/safe_fetch_test.exs new file mode 100644 index 0000000..fd2c2c4 --- /dev/null +++ b/apps/elektrine/test/elektrine/http/safe_fetch_test.exs @@ -0,0 +1,30 @@ +defmodule Elektrine.HTTP.SafeFetchTest do + use ExUnit.Case, async: true + + alias Elektrine.HTTP.SafeFetch + alias Elektrine.Security.URLValidator + + test "resolve_address/1 resolves private IP literals" do + assert {:ok, {127, 0, 0, 1}} = URLValidator.resolve_address("127.0.0.1") + end + + test "allow_private_network bypasses private IP rejection while preserving pinned resolution" do + {:ok, socket} = :gen_tcp.listen(0, [:binary, active: false, reuseaddr: true]) + {:ok, port} = :inet.port(socket) + :ok = :gen_tcp.close(socket) + + request = Finch.build(:get, "http://127.0.0.1:#{port}/xrpc/test") + + assert {:error, :private_ip} = + SafeFetch.request(request, Elektrine.Finch, receive_timeout: 100) + + assert {:error, reason} = + SafeFetch.request(request, Elektrine.Finch, + allow_private_network: true, + pool_timeout: 100, + receive_timeout: 100 + ) + + refute reason == :private_ip + end +end diff --git a/apps/elektrine/test/elektrine/input_test.exs b/apps/elektrine/test/elektrine/input_test.exs new file mode 100644 index 0000000..6f6e9f4 --- /dev/null +++ b/apps/elektrine/test/elektrine/input_test.exs @@ -0,0 +1,14 @@ +defmodule Elektrine.InputTest do + use ExUnit.Case, async: true + + alias Elektrine.Input + + test "sanitizes valid email addresses" do + assert Input.sanitize_email(" max@example.com ") == "max@example.com" + assert Input.sanitize_email("not an email") == "" + end + + test "sanitizes usernames to safe characters" do + assert Input.sanitize_username(" Max! Field ") == "Max Field" + end +end diff --git a/apps/elektrine/test/elektrine/job_queue_test.exs b/apps/elektrine/test/elektrine/job_queue_test.exs new file mode 100644 index 0000000..ed45e00 --- /dev/null +++ b/apps/elektrine/test/elektrine/job_queue_test.exs @@ -0,0 +1,75 @@ +defmodule Elektrine.JobQueueTest do + use ExUnit.Case, async: true + + alias Elektrine.JobQueue + + defmodule TestWorker do + use Oban.Worker, queue: :default + + @impl Oban.Worker + def perform(%Oban.Job{}), do: :ok + end + + @worker_name "Elektrine.JobQueueTest.TestWorker" + + test "uses the default Oban instance when it is running" do + assert JobQueue.running?() + + assert {:ok, %Oban.Job{worker: worker, state: "completed", args: %{"id" => 1}}} = + JobQueue.insert(TestWorker.new(%{id: 1})) + + assert worker == @worker_name + end + + test "falls back to direct inserts when an Oban instance is unavailable" do + refute JobQueue.running?(:missing_oban) + + assert {:ok, %Oban.Job{worker: worker, state: "completed", args: %{"id" => 2}}} = + JobQueue.insert_to(:missing_oban, TestWorker.new(%{id: 2})) + + assert worker == @worker_name + end + + test "falls back to direct bulk inserts when an Oban instance is unavailable" do + jobs = + :missing_oban_bulk + |> JobQueue.insert_all_to(Enum.map([3, 4], &TestWorker.new(%{id: &1}))) + + assert Enum.map(jobs, & &1.args["id"]) == [3, 4] + assert Enum.all?(jobs, &(&1.worker == @worker_name)) + assert Enum.all?(jobs, &(&1.state == "completed")) + end +end + +defmodule Elektrine.JobQueueRuntimeFallbackTest do + use ExUnit.Case, async: false + + alias Elektrine.JobQueue + alias Elektrine.JobQueueTest.TestWorker + + @worker_name "Elektrine.JobQueueTest.TestWorker" + + setup do + on_exit(fn -> + if is_nil(Oban.whereis(Oban)) do + assert {:ok, _pid} = Supervisor.restart_child(Elektrine.Supervisor, Oban) + end + end) + + :ok + end + + test "falls back when the default Oban instance is temporarily unavailable" do + assert JobQueue.running?() + assert :ok = Supervisor.terminate_child(Elektrine.Supervisor, Oban) + refute JobQueue.running?() + + assert {:ok, %Oban.Job{worker: worker, state: "completed", args: %{"id" => 5}}} = + JobQueue.insert(TestWorker.new(%{id: 5})) + + assert worker == @worker_name + + assert {:ok, _pid} = Supervisor.restart_child(Elektrine.Supervisor, Oban) + assert JobQueue.running?() + end +end diff --git a/apps/elektrine/test/elektrine/link_archive_test.exs b/apps/elektrine/test/elektrine/link_archive_test.exs new file mode 100644 index 0000000..65cbe89 --- /dev/null +++ b/apps/elektrine/test/elektrine/link_archive_test.exs @@ -0,0 +1,303 @@ +defmodule Elektrine.LinkArchiveTest do + use Elektrine.DataCase + + import Elektrine.AccountsFixtures + + alias Elektrine.LinkArchive + alias Elektrine.LinkArchive.FetchWorker + + setup do + %{user: user_fixture()} + end + + describe "canonicalize/1" do + test "drops the fragment" do + assert LinkArchive.canonicalize("https://example.com/a#section") == "https://example.com/a" + end + + test "drops tracking parameters but keeps meaningful ones" do + canonical = + LinkArchive.canonicalize("https://example.com/a?id=7&utm_source=twitter&fbclid=xyz") + + assert canonical == "https://example.com/a?id=7" + end + + test "drops the query entirely when only tracking parameters were present" do + assert LinkArchive.canonicalize("https://example.com/a?utm_source=x") == + "https://example.com/a" + end + + test "sorts parameters so argument order does not create duplicates" do + assert LinkArchive.canonicalize("https://example.com/a?b=2&a=1") == + LinkArchive.canonicalize("https://example.com/a?a=1&b=2") + end + + test "lowercases the host" do + assert LinkArchive.canonicalize("https://Example.COM/a") == "https://example.com/a" + end + end + + describe "archive_url/3" do + test "creates a pending archive", %{user: user} do + assert {:ok, archive} = + LinkArchive.archive_url(user.id, "https://example.com/post", fetch: false) + + assert archive.status == "pending" + assert archive.url == "https://example.com/post" + assert archive.canonical_url == "https://example.com/post" + assert archive.user_id == user.id + end + + test "is idempotent per user and URL", %{user: user} do + {:ok, first} = LinkArchive.archive_url(user.id, "https://example.com/post", fetch: false) + {:ok, second} = LinkArchive.archive_url(user.id, "https://example.com/post", fetch: false) + + assert first.id == second.id + end + + test "treats URLs differing only by tracking parameters as the same page", %{user: user} do + {:ok, first} = LinkArchive.archive_url(user.id, "https://example.com/post", fetch: false) + + {:ok, second} = + LinkArchive.archive_url(user.id, "https://example.com/post?utm_source=x", fetch: false) + + assert first.id == second.id + end + + test "two users archive the same URL independently", %{user: user} do + other = user_fixture() + + {:ok, mine} = LinkArchive.archive_url(user.id, "https://example.com/post", fetch: false) + {:ok, theirs} = LinkArchive.archive_url(other.id, "https://example.com/post", fetch: false) + + refute mine.id == theirs.id + end + + # The archiver fetches URLs that other people wrote, so it must refuse the + # ones the SSRF validator refuses before a row is even created. + test "refuses private and non-HTTP URLs", %{user: user} do + for url <- [ + "http://127.0.0.1/admin", + "http://169.254.169.254/latest/meta-data/", + "file:///etc/passwd", + "javascript:alert(1)", + "not a url" + ] do + assert {:error, :unsupported_url} = LinkArchive.archive_url(user.id, url, fetch: false) + end + + assert LinkArchive.list_archives(user.id) == [] + end + + test "archive_urls/3 skips the bad ones and keeps the good", %{user: user} do + archives = + LinkArchive.archive_urls( + user.id, + ["https://example.com/a", "http://127.0.0.1/x", "https://example.com/b"], + fetch: false + ) + + assert length(archives) == 2 + end + end + + describe "readable_html/1" do + defp archived_with(user, html) do + {:ok, archive} = LinkArchive.archive_url(user.id, "https://example.com/a", fetch: false) + {:ok, archive} = LinkArchive.update_archive(archive, %{content_html: html}) + archive + end + + test "keeps structure and links", %{user: user} do + archive = + archived_with(user, "

    Title

    Body with a link.

    ") + + readable = LinkArchive.readable_html(archive) + + assert readable =~ "

    Title

    " + assert readable =~ "a link" + assert readable =~ "https://x.test" + end + + test "strips scripts and event handlers", %{user: user} do + archive = + archived_with( + user, + "

    Text

    " + ) + + readable = LinkArchive.readable_html(archive) + + refute readable =~ "script" + refute readable =~ "onclick" + refute readable =~ "window.stolen" + assert readable =~ "Text" + end + + # An archived page that still loads images from the origin would phone home + # on every read, and shows nothing once the origin is gone anyway. + test "drops images rather than pointing them at the origin", %{user: user} do + archive = + archived_with(user, "

    Before

    After

    ") + + readable = LinkArchive.readable_html(archive) + + refute readable =~ "img" + refute readable =~ "tracker.test" + assert readable =~ "Before" + assert readable =~ "After" + end + + test "is nil when nothing was stored", %{user: user} do + {:ok, archive} = LinkArchive.archive_url(user.id, "https://example.com/empty", fetch: false) + + assert LinkArchive.readable_html(archive) == nil + end + + # Falls back to the extracted text rather than rendering an empty pane. + test "is nil when the markup sanitizes down to nothing", %{user: user} do + archive = archived_with(user, "") + + assert LinkArchive.readable_html(archive) == nil + end + end + + describe "lookup and deletion" do + test "finds an archive by URL", %{user: user} do + {:ok, archive} = LinkArchive.archive_url(user.id, "https://example.com/x", fetch: false) + + found = LinkArchive.get_archive_by_url(user.id, "https://example.com/x?utm_medium=email") + + assert found.id == archive.id + end + + test "a user cannot read another user's archive", %{user: user} do + other = user_fixture() + {:ok, archive} = LinkArchive.archive_url(other.id, "https://example.com/x", fetch: false) + + assert LinkArchive.get_archive(user.id, archive.id) == nil + end + + test "a user cannot delete another user's archive", %{user: user} do + other = user_fixture() + {:ok, archive} = LinkArchive.archive_url(other.id, "https://example.com/x", fetch: false) + + assert {:error, :not_found} = LinkArchive.delete_archive(user.id, archive.id) + assert LinkArchive.get_archive(other.id, archive.id) + end + end + + describe "fetching" do + setup do + previous = Application.get_env(:elektrine, :link_archive_request_fun) + + on_exit(fn -> + if previous do + Application.put_env(:elektrine, :link_archive_request_fun, previous) + else + Application.delete_env(:elektrine, :link_archive_request_fun) + end + end) + + :ok + end + + defp stub_response(response) do + Application.put_env(:elektrine, :link_archive_request_fun, fn _url -> response end) + end + + defp run_fetch(archive) do + FetchWorker.perform(%Oban.Job{ + args: %{"archive_id" => archive.id, "user_id" => archive.user_id} + }) + end + + test "stores the title, html, and stripped text", %{user: user} do + stub_response( + {:ok, + %{ + status: 200, + headers: [{"content-type", "text/html; charset=utf-8"}], + body: """ + A Cited Page +

    Body text here.

    + """ + }} + ) + + {:ok, archive} = LinkArchive.archive_url(user.id, "https://example.com/a", fetch: false) + assert :ok = run_fetch(archive) + + stored = LinkArchive.get_archive(user.id, archive.id) + + assert stored.status == "archived" + assert stored.title == "A Cited Page" + assert stored.content_html =~ "

    Body text here.

    " + assert stored.content_text =~ "Body text here." + refute stored.content_text =~ "ignored()" + assert stored.byte_size > 0 + assert stored.body_hash + assert stored.fetched_at + end + + test "records a dead link rather than retrying it forever", %{user: user} do + stub_response({:ok, %{status: 404, headers: [], body: ""}}) + + {:ok, archive} = LinkArchive.archive_url(user.id, "https://example.com/gone", fetch: false) + assert :ok = run_fetch(archive) + + stored = LinkArchive.get_archive(user.id, archive.id) + + assert stored.status == "failed" + assert stored.error_message == "http_404" + end + + test "asks Oban to retry a server error", %{user: user} do + stub_response({:ok, %{status: 503, headers: [], body: ""}}) + + {:ok, archive} = LinkArchive.archive_url(user.id, "https://example.com/flaky", fetch: false) + + assert {:error, "http_503"} = run_fetch(archive) + end + + test "skips content it cannot archive as text", %{user: user} do + stub_response( + {:ok, %{status: 200, headers: [{"content-type", "application/pdf"}], body: "%PDF-1.4"}} + ) + + {:ok, archive} = LinkArchive.archive_url(user.id, "https://example.com/a.pdf", fetch: false) + assert :ok = run_fetch(archive) + + stored = LinkArchive.get_archive(user.id, archive.id) + + assert stored.status == "skipped" + assert stored.content_type == "application/pdf" + end + + test "does not refetch an already archived page", %{user: user} do + stub_response( + {:ok, + %{status: 200, headers: [{"content-type", "text/html"}], body: "One"}} + ) + + {:ok, archive} = LinkArchive.archive_url(user.id, "https://example.com/a", fetch: false) + assert :ok = run_fetch(archive) + + stub_response( + {:ok, + %{status: 200, headers: [{"content-type", "text/html"}], body: "Two"}} + ) + + assert :ok = run_fetch(LinkArchive.get_archive(user.id, archive.id)) + + assert LinkArchive.get_archive(user.id, archive.id).title == "One" + end + + test "a deleted archive is not a job failure", %{user: user} do + {:ok, archive} = LinkArchive.archive_url(user.id, "https://example.com/a", fetch: false) + {:ok, _} = LinkArchive.delete_archive(user.id, archive.id) + + assert :ok = run_fetch(archive) + end + end +end diff --git a/apps/elektrine/test/elektrine/mail_client_settings_test.exs b/apps/elektrine/test/elektrine/mail_client_settings_test.exs new file mode 100644 index 0000000..487ea1e --- /dev/null +++ b/apps/elektrine/test/elektrine/mail_client_settings_test.exs @@ -0,0 +1,31 @@ +defmodule Elektrine.MailClientSettingsTest do + use ExUnit.Case, async: false + + alias Elektrine.MailClientSettings + + setup do + previous_settings = Application.get_env(:elektrine, :mail_client_settings) + + on_exit(fn -> + if previous_settings do + Application.put_env(:elektrine, :mail_client_settings, previous_settings) + else + Application.delete_env(:elektrine, :mail_client_settings) + end + end) + + :ok + end + + test "built-in defaults match the public Docker mail ports" do + Application.delete_env(:elektrine, :mail_client_settings) + + assert %{port: 993, security: :ssl} = MailClientSettings.imap("example.com") + assert %{port: 995, security: :ssl} = MailClientSettings.pop3("example.com") + assert %{port: 465, security: :ssl} = MailClientSettings.smtp("example.com") + end + + test "SSL security label matches email client terminology" do + assert MailClientSettings.security_label(:ssl) == "SSL/TLS" + end +end diff --git a/apps/elektrine/test/elektrine/mailer/delivery_worker_test.exs b/apps/elektrine/test/elektrine/mailer/delivery_worker_test.exs new file mode 100644 index 0000000..40e6384 --- /dev/null +++ b/apps/elektrine/test/elektrine/mailer/delivery_worker_test.exs @@ -0,0 +1,49 @@ +defmodule Elektrine.Mailer.DeliveryWorkerTest do + use Elektrine.DataCase, async: false + + import Swoosh.Email, except: [from: 2] + + test "deliver_later delivers the email through the Oban worker" do + email = + new() + |> Swoosh.Email.from({"Elektrine", "noreply@example.com"}) + |> to({"someone", "someone@example.com"}) + |> cc("copy@example.com") + |> reply_to("replies@example.com") + |> subject("Hello from the queue") + |> text_body("plain text") + |> html_body("

    html

    ") + |> header("List-Id", "") + + assert {:ok, _job} = Elektrine.Mailer.deliver_later(email) + + # Oban runs inline in test, so the Swoosh test adapter has already + # delivered the rebuilt email to this process. + assert_receive {:email, %Swoosh.Email{} = delivered} + + assert delivered.from == {"Elektrine", "noreply@example.com"} + assert delivered.to == [{"someone", "someone@example.com"}] + assert delivered.cc == [{"", "copy@example.com"}] + assert delivered.reply_to == {"", "replies@example.com"} + assert delivered.subject == "Hello from the queue" + assert delivered.text_body == "plain text" + assert delivered.html_body == "

    html

    " + assert delivered.headers == %{"List-Id" => ""} + end + + test "deliver_later rejects emails with attachments" do + email = + new() + |> Swoosh.Email.from("noreply@example.com") + |> to("someone@example.com") + |> subject("with attachment") + |> text_body("body") + |> attachment(%Swoosh.Attachment{ + filename: "file.txt", + content_type: "text/plain", + data: "contents" + }) + + assert_raise ArgumentError, fn -> Elektrine.Mailer.deliver_later(email) end + end +end diff --git a/apps/elektrine/test/elektrine/markers_test.exs b/apps/elektrine/test/elektrine/markers_test.exs new file mode 100644 index 0000000..e898082 --- /dev/null +++ b/apps/elektrine/test/elektrine/markers_test.exs @@ -0,0 +1,104 @@ +defmodule Elektrine.MarkersTest do + use Elektrine.DataCase, async: true + + alias Elektrine.AccountsFixtures + alias Elektrine.Markers + alias Elektrine.Notifications.Notification + alias Elektrine.Repo + + describe "timeline markers" do + test "creates and reads requested markers" do + user = AccountsFixtures.user_fixture() + + assert {:ok, markers} = + Markers.upsert_markers(user.id, %{ + "home" => %{"last_read_id" => "100"}, + "notifications" => %{"last_read_id" => "50"} + }) + + assert markers["home"].last_read_id == "100" + assert markers["notifications"].last_read_id == "50" + + listed = Markers.list_markers(user.id, ["home"]) + + assert Map.keys(listed) == ["home"] + assert listed["home"].last_read_id == "100" + end + + test "notification markers include unread count" do + user = AccountsFixtures.user_fixture() + now = DateTime.utc_now() |> DateTime.truncate(:second) + + insert_notification!(user.id, %{title: "Unread one"}) + insert_notification!(user.id, %{title: "Unread two"}) + insert_notification!(user.id, %{title: "Read", read_at: now}) + insert_notification!(user.id, %{title: "Dismissed", dismissed_at: now}) + + assert {:ok, %{"notifications" => marker}} = + Markers.upsert_markers(user.id, %{ + "notifications" => %{"last_read_id" => "10"} + }) + + assert marker.unread_count == 2 + + listed = Markers.list_markers(user.id, ["notifications"]) + assert listed["notifications"].unread_count == 2 + assert Markers.format_marker(listed["notifications"]).unread_count == 2 + end + + test "updates existing markers and increments version" do + user = AccountsFixtures.user_fixture() + + assert {:ok, %{"home" => first}} = + Markers.upsert_markers(user.id, %{"home" => %{"last_read_id" => "100"}}) + + assert first.version == 0 + + assert {:ok, %{"home" => second}} = + Markers.upsert_markers(user.id, %{"home" => %{"last_read_id" => "200"}}) + + assert second.last_read_id == "200" + assert second.version == 1 + end + + test "rejects invalid marker data" do + user = AccountsFixtures.user_fixture() + + assert {:error, changeset} = + Markers.upsert_markers(user.id, %{ + "../home" => %{"last_read_id" => "100"} + }) + + assert %{timeline: [_]} = errors_on(changeset) + end + + test "ignores payload entries without last_read_id" do + user = AccountsFixtures.user_fixture() + + assert {:ok, markers} = + Markers.upsert_markers(user.id, %{ + "home" => %{"id" => "100"}, + "notifications" => %{"last_read_id" => "10"} + }) + + assert Map.keys(markers) == ["notifications"] + end + end + + defp insert_notification!(user_id, attrs) do + attrs = + Map.merge( + %{ + type: "system", + title: "Notification", + body: "Body", + user_id: user_id + }, + attrs + ) + + %Notification{} + |> Notification.changeset(attrs) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/messaging/activitypub_ref_lookup_test.exs b/apps/elektrine/test/elektrine/messaging/activitypub_ref_lookup_test.exs new file mode 100644 index 0000000..f589bbb --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/activitypub_ref_lookup_test.exs @@ -0,0 +1,200 @@ +defmodule Elektrine.Messaging.ActivityPubRefLookupTest do + use Elektrine.DataCase, async: true + + if not Code.ensure_loaded?(Elektrine.Social.Hashtag) do + @moduletag skip: "requires :elektrine_social" + end + + alias Elektrine.ActivityPub.Actor + alias Elektrine.Messaging + alias Elektrine.Social.Message + alias Elektrine.Social.Messages + alias Elektrine.SocialFixtures + + describe "get_message_by_activitypub_ref/1" do + test "returns the existing federated message for duplicate activitypub_id inserts" do + actor = remote_actor_fixture() + activitypub_id = "https://remote.example/notes/#{System.unique_integer([:positive])}" + + attrs = %{ + content: "original", + visibility: "public", + federated: true, + activitypub_id: activitypub_id, + activitypub_url: activitypub_id, + remote_actor_id: actor.id + } + + assert {:ok, first} = Messaging.create_federated_message(attrs) + assert {:ok, second} = Messaging.create_federated_message(%{attrs | content: "duplicate"}) + + assert second.id == first.id + assert Repo.aggregate(Message, :count, :id) == 1 + assert Repo.get!(Message, first.id).content == "original" + end + + test "normal message changesets return duplicate activitypub_id errors instead of raising" do + actor = remote_actor_fixture() + local_post = SocialFixtures.post_fixture() + activitypub_id = "https://remote.example/notes/#{System.unique_integer([:positive])}" + + assert {:ok, existing} = + Messaging.create_federated_message(%{ + content: "remote post", + visibility: "public", + federated: true, + activitypub_id: activitypub_id, + activitypub_url: activitypub_id, + remote_actor_id: actor.id + }) + + assert {:error, {:duplicate_activitypub_id, duplicate}} = + Messages.update_message(local_post, %{ + activitypub_id: activitypub_id, + activitypub_url: activitypub_id + }) + + assert duplicate.id == existing.id + + duplicate_attrs = %{ + conversation_id: local_post.conversation_id, + sender_id: local_post.sender_id, + content: "duplicate local insert", + message_type: "text", + visibility: "public", + post_type: "post", + activitypub_id: activitypub_id, + activitypub_url: activitypub_id + } + + assert {:error, changeset} = + %Message{} + |> Message.changeset(duplicate_attrs) + |> Repo.insert() + + assert Keyword.has_key?(changeset.errors, :activitypub_id) + end + + test "matches refs with query and fragment variants" do + actor = remote_actor_fixture() + activitypub_id = "https://aus.social/@feather1952/114173031" + + assert {:ok, message} = + Messaging.create_federated_message(%{ + content: "reply parent", + visibility: "public", + federated: true, + activitypub_id: activitypub_id, + activitypub_url: activitypub_id, + remote_actor_id: actor.id + }) + + assert %{} = + found = + Messaging.get_message_by_activitypub_ref(activitypub_id <> "?ctx=reply#abc") + + assert found.id == message.id + end + + test "falls back to activitypub_url variants when activitypub_id does not match" do + actor = remote_actor_fixture() + canonical_url = "https://mastodon.social/@alice/114173099" + + assert {:ok, message} = + Messaging.create_federated_message(%{ + content: "url fallback", + visibility: "public", + federated: true, + activitypub_id: "https://origin.example/objects/abc123", + activitypub_url: canonical_url, + remote_actor_id: actor.id + }) + + assert %{} = + found = + Messaging.get_message_by_activitypub_ref(canonical_url <> "?foo=bar#context") + + assert found.id == message.id + end + + test "prefers activitypub_id match before activitypub_url match" do + actor = remote_actor_fixture() + ref = "https://example.net/users/alice/statuses/777" + + assert {:ok, by_id} = + Messaging.create_federated_message(%{ + content: "id match", + visibility: "public", + federated: true, + activitypub_id: ref, + activitypub_url: "https://example.net/@alice/777", + remote_actor_id: actor.id + }) + + assert {:ok, _by_url} = + Messaging.create_federated_message(%{ + content: "url match", + visibility: "public", + federated: true, + activitypub_id: "https://origin.example/objects/def456", + activitypub_url: ref, + remote_actor_id: actor.id + }) + + assert %{} = found = Messaging.get_message_by_activitypub_ref(ref) + assert found.id == by_id.id + end + + test "falls back to legacy rows without canonical columns populated" do + actor = remote_actor_fixture() + canonical_ref = "https://legacy.example/users/alice/statuses/42" + + message = + Repo.insert!(%Message{ + content: "legacy ref", + visibility: "public", + federated: true, + activitypub_id: canonical_ref <> "/?ctx=reply#fragment", + activitypub_url: canonical_ref <> "?view=web", + remote_actor_id: actor.id + }) + + assert %{} = found = Messaging.get_message_by_activitypub_ref(canonical_ref) + assert found.id == message.id + end + + test "caches missing refs and invalidates the cache when a matching federated message is created" do + actor = remote_actor_fixture() + ref = "https://mastodon.social/@alice/114173199?foo=bar#context" + + assert Messaging.get_message_by_activitypub_ref(ref) == nil + + assert {:ok, message} = + Messaging.create_federated_message(%{ + content: "arrived after miss", + visibility: "public", + federated: true, + activitypub_id: "https://origin.example/objects/ghi789", + activitypub_url: "https://mastodon.social/@alice/114173199", + remote_actor_id: actor.id + }) + + assert %{} = found = Messaging.get_message_by_activitypub_ref(ref) + assert found.id == message.id + end + end + + defp remote_actor_fixture do + unique = System.unique_integer([:positive]) + + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.example/users/alice-#{unique}", + username: "alice#{unique}", + domain: "remote.example", + inbox_url: "https://remote.example/inbox", + public_key: "-----BEGIN PUBLIC KEY-----test-key-----END PUBLIC KEY-----" + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/messaging/arblarg_extension_conformance_test.exs b/apps/elektrine/test/elektrine/messaging/arblarg_extension_conformance_test.exs new file mode 100644 index 0000000..0c21873 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/arblarg_extension_conformance_test.exs @@ -0,0 +1,91 @@ +defmodule Elektrine.Messaging.ArblargExtensionConformanceTest do + use ExUnit.Case, async: true + + alias Elektrine.Messaging.ArblargProfiles + alias Elektrine.Messaging.ArblargSDK + + @vectors_file Path.expand( + "../../../../../external/arblarg/test_vectors/v1/extension_events.json", + __DIR__ + ) + + test "CONFX-001 registry publishes community extension pack metadata" do + registry = ArblargProfiles.extension_registry() + extension_urns = Enum.map(registry, & &1["urn"]) + + assert "urn:arblarg:ext:roles:1" in extension_urns + assert "urn:arblarg:ext:permissions:1" in extension_urns + assert "urn:arblarg:ext:threads:1" in extension_urns + assert "urn:arblarg:ext:presence:1" in extension_urns + assert "urn:arblarg:ext:moderation:1" in extension_urns + + Enum.each(registry, fn extension -> + assert is_map(extension["conformance"]) + assert is_binary(extension["conformance"]["status"]) + assert is_binary(extension["conformance"]["suite_version"]) + end) + end + + test "CONFX-002 extension vectors pass schema and envelope validation" do + vectors = read_vectors!() + + Enum.each(vectors["cases"], fn vector -> + canonical_event_type = ArblargSDK.canonical_event_type(vector["event_type_alias"]) + + assert canonical_event_type == vector["expected_event_type"] + assert canonical_event_type in ArblargSDK.supported_event_types() + assert is_map(ArblargSDK.schema("1.0", vector["expected_schema"])) + assert :ok = ArblargSDK.validate_event_payload(canonical_event_type, vector["payload"]) + + if canonical_event_type in ArblargSDK.ephemeral_event_types() do + refute valid_durable_envelope?(canonical_event_type) + else + assert :ok = + ArblargSDK.validate_event_envelope( + build_envelope(canonical_event_type, vector["payload"]) + ) + end + + expected_invalid_error = String.to_atom(vector["expected_invalid_error"]) + + assert {:error, ^expected_invalid_error} = + ArblargSDK.validate_event_payload(canonical_event_type, vector["invalid_payload"]) + + if canonical_event_type in ArblargSDK.ephemeral_event_types() do + refute valid_durable_envelope?(canonical_event_type) + else + assert {:error, ^expected_invalid_error} = + ArblargSDK.validate_event_envelope( + build_envelope(canonical_event_type, vector["invalid_payload"]) + ) + end + end) + end + + defp build_envelope(event_type, payload) do + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => ArblargSDK.protocol_id(), + "protocol_version" => ArblargSDK.protocol_version(), + "event_type" => event_type, + "event_id" => "evt-#{Ecto.UUID.generate()}", + "origin_domain" => "example.net", + "stream_id" => "channel:https://example.net/_arblarg/channels/1", + "sequence" => 1, + "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "idempotency_key" => "idem-#{Ecto.UUID.generate()}", + "payload" => payload + } + |> ArblargSDK.sign_event_envelope("k1", "extension-conformance-secret") + end + + defp valid_durable_envelope?(event_type) do + event_type not in ArblargSDK.ephemeral_event_types() + end + + defp read_vectors! do + @vectors_file + |> File.read!() + |> Jason.decode!() + end +end diff --git a/apps/elektrine/test/elektrine/messaging/arblarg_golden_vectors_test.exs b/apps/elektrine/test/elektrine/messaging/arblarg_golden_vectors_test.exs new file mode 100644 index 0000000..81d15ca --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/arblarg_golden_vectors_test.exs @@ -0,0 +1,86 @@ +defmodule Elektrine.Messaging.ArblargGoldenVectorsTest do + use ExUnit.Case, async: true + + alias Elektrine.Messaging.ArblargSDK + + @vectors_root Path.expand("../../../../../external/arblarg/test_vectors/v1", __DIR__) + + test "event signature golden vector" do + vector = read_vector!("event_message_create.json") + envelope = vector["unsigned_envelope"] + expected = vector["expected"] + + canonical_payload = ArblargSDK.canonical_event_payload_for_signing(envelope) + signature = ArblargSDK.sign_payload(canonical_payload, vector["signing_secret"]) + + assert canonical_payload == expected["canonical_event_payload"] + assert signature == expected["signature"] + + assert ArblargSDK.verify_payload_signature( + canonical_payload, + vector["public_key"], + signature + ) + end + + test "request signature golden vector" do + vector = read_vector!("request_signature.json") + expected = vector["expected"] + + body_digest = ArblargSDK.body_digest(vector["body"]) + + canonical_payload = + ArblargSDK.canonical_request_signature_payload( + vector["domain"], + vector["method"], + vector["path"], + vector["query"], + vector["timestamp"], + body_digest, + vector["request_id"] + ) + + signature = ArblargSDK.sign_payload(canonical_payload, vector["signing_secret"]) + + assert body_digest == expected["body_digest"] + assert canonical_payload == expected["canonical_request_payload"] + assert signature == expected["signature"] + end + + test "error case golden vectors" do + vector = read_vector!("error_cases.json") + + Enum.each(vector["cases"], fn case_vector -> + expected_error = String.to_atom(case_vector["expected_error"]) + + assert {:error, ^expected_error} = + ArblargSDK.validate_event_envelope(case_vector["envelope"]) + end) + end + + test "extension event golden vectors" do + vector = read_vector!("extension_events.json") + + Enum.each(vector["cases"], fn case_vector -> + canonical_event_type = ArblargSDK.canonical_event_type(case_vector["event_type_alias"]) + expected_invalid_error = String.to_atom(case_vector["expected_invalid_error"]) + + assert canonical_event_type == case_vector["expected_event_type"] + assert :ok = ArblargSDK.validate_event_payload(canonical_event_type, case_vector["payload"]) + assert is_map(ArblargSDK.schema("1.0", case_vector["expected_schema"])) + + assert {:error, ^expected_invalid_error} = + ArblargSDK.validate_event_payload( + canonical_event_type, + case_vector["invalid_payload"] + ) + end) + end + + defp read_vector!(file_name) do + @vectors_root + |> Path.join(file_name) + |> File.read!() + |> Jason.decode!() + end +end diff --git a/apps/elektrine/test/elektrine/messaging/arblarg_profiles_test.exs b/apps/elektrine/test/elektrine/messaging/arblarg_profiles_test.exs new file mode 100644 index 0000000..25ccf5e --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/arblarg_profiles_test.exs @@ -0,0 +1,79 @@ +defmodule Elektrine.Messaging.ArblargProfilesTest do + use ExUnit.Case, async: true + + alias Elektrine.Messaging.ArblargProfiles + + test "exposes mandatory core profile metadata" do + assert ArblargProfiles.core_profile_id() == "arblarg-core/1.0" + + assert ArblargProfiles.core_event_types() == [ + "message.create", + "message.update", + "message.delete", + "reaction.add", + "reaction.remove", + "read.cursor", + "membership.upsert", + "invite.upsert", + "ban.upsert" + ] + end + + test "gates profile claims by conformance status" do + assert ArblargProfiles.passing_profile_claims(core_passed?: true, community_passed?: false) == + ["arblarg-core/1.0"] + + assert ArblargProfiles.passing_profile_claims(core_passed?: false, community_passed?: false) == + [] + end + + test "claims community profile when required extensions are marked passing" do + extension_statuses = %{ + "urn:arblarg:ext:roles:1" => true, + "urn:arblarg:ext:permissions:1" => true, + "urn:arblarg:ext:threads:1" => true, + "urn:arblarg:ext:presence:1" => true, + "urn:arblarg:ext:moderation:1" => true + } + + claims = + ArblargProfiles.passing_profile_claims( + core_passed?: true, + extension_statuses: extension_statuses + ) + + assert "arblarg-core/1.0" in claims + assert "arblarg-community/1.0" in claims + end + + test "registers strict extension metadata" do + registry = ArblargProfiles.extension_registry() + extension_urns = registry |> Enum.map(& &1["urn"]) + + assert "urn:arblarg:ext:bootstrap:1" in extension_urns + assert "urn:arblarg:ext:roles:1" in extension_urns + assert "urn:arblarg:ext:permissions:1" in extension_urns + assert "urn:arblarg:ext:threads:1" in extension_urns + assert "urn:arblarg:ext:presence:1" in extension_urns + assert "urn:arblarg:ext:voice:1" in extension_urns + assert "urn:arblarg:ext:moderation:1" in extension_urns + + Enum.each(registry, fn extension -> + assert is_map(extension["conformance"]) + assert extension["fallback"] == "reject_unsupported_event_type" + end) + end + + test "marks only conformance-covered extensions as verified" do + statuses = ArblargProfiles.conformance_verified_extension_statuses() + + assert statuses["urn:arblarg:ext:bootstrap:1"] == true + assert statuses["urn:arblarg:ext:roles:1"] == true + assert statuses["urn:arblarg:ext:permissions:1"] == true + assert statuses["urn:arblarg:ext:threads:1"] == true + assert statuses["urn:arblarg:ext:presence:1"] == true + assert statuses["urn:arblarg:ext:moderation:1"] == true + assert statuses["urn:arblarg:ext:dm:1"] == false + assert statuses["urn:arblarg:ext:voice:1"] == false + end +end diff --git a/apps/elektrine/test/elektrine/messaging/arblarg_sdk_test.exs b/apps/elektrine/test/elektrine/messaging/arblarg_sdk_test.exs new file mode 100644 index 0000000..e3bf35e --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/arblarg_sdk_test.exs @@ -0,0 +1,486 @@ +defmodule Elektrine.Messaging.ArblargSDKTest do + use ExUnit.Case, async: true + + alias Elektrine.Messaging.ArblargSDK + + test "exposes expanded interoperable permission vocabulary" do + permissions = ArblargSDK.permission_vocabulary() + + assert "read_messages" in permissions + assert "send_messages" in permissions + assert "attach_files" in permissions + assert "manage_messages" in permissions + assert "manage_threads" in permissions + assert "manage_server" in permissions + end + + test "exposes parity structured error codes" do + error_codes = ArblargSDK.structured_error_codes() + + assert "rate_limited" in error_codes + assert "peer_quarantined" in error_codes + assert "event_too_large" in error_codes + assert "cursor_expired" in error_codes + assert "media_not_authorized" in error_codes + end + + test "signs and verifies detached payload signatures" do + payload = "hello-arblarg" + secret = "shared-test-secret" + {public_key, _private_key} = ArblargSDK.derive_keypair_from_secret(secret) + + signature = ArblargSDK.sign_payload(payload, secret) + + assert is_binary(signature) + assert ArblargSDK.verify_payload_signature(payload, public_key, signature) + refute ArblargSDK.verify_payload_signature("tampered", public_key, signature) + end + + test "rejects malformed public-key verification material" do + payload = "hello-arblarg" + secret = "shared-test-secret" + signature = ArblargSDK.sign_payload(payload, secret) + + refute ArblargSDK.verify_payload_signature(payload, secret, signature) + refute ArblargSDK.verify_payload_signature(payload, "not-a-base64-public-key", signature) + end + + test "validates v1 event envelope and payload" do + envelope = + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => "arblarg", + "protocol_version" => "1.0", + "event_type" => "message.create", + "event_id" => "evt-1", + "origin_domain" => "remote.test", + "stream_id" => "channel:https://remote.test/_arblarg/channels/1", + "sequence" => 1, + "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "idempotency_key" => "idem-1", + "payload" => %{ + "server" => %{"id" => "https://remote.test/_arblarg/servers/1"}, + "channel" => %{"id" => "https://remote.test/_arblarg/channels/1"}, + "message" => %{ + "id" => "https://remote.test/_arblarg/messages/1", + "channel_id" => "https://remote.test/_arblarg/channels/1", + "content" => "hello", + "sender" => canonical_actor("alice", "remote.test") + } + } + } + |> ArblargSDK.sign_event_envelope("k1", "shared-test-secret") + + assert :ok = ArblargSDK.validate_event_envelope(envelope) + end + + test "rejects unsigned durable envelopes" do + envelope = %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => "arblarg", + "protocol_version" => "1.0", + "event_type" => "message.create", + "event_id" => "evt-unsigned", + "origin_domain" => "remote.test", + "stream_id" => "channel:https://remote.test/_arblarg/channels/1", + "sequence" => 1, + "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "idempotency_key" => "idem-unsigned", + "payload" => %{ + "server" => %{"id" => "https://remote.test/_arblarg/servers/1"}, + "channel" => %{"id" => "https://remote.test/_arblarg/channels/1"}, + "message" => %{ + "id" => "https://remote.test/_arblarg/messages/1", + "channel_id" => "https://remote.test/_arblarg/channels/1", + "content" => "hello", + "sender" => canonical_actor("alice", "remote.test") + } + } + } + + assert {:error, :invalid_signature} = ArblargSDK.validate_event_envelope(envelope) + end + + test "rejects user-authored events whose actor omits uri" do + envelope = + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => "arblarg", + "protocol_version" => "1.0", + "event_type" => "message.create", + "event_id" => "evt-missing-uri", + "origin_domain" => "remote.test", + "stream_id" => "channel:https://remote.test/_arblarg/channels/1", + "sequence" => 1, + "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "idempotency_key" => "idem-missing-uri", + "payload" => %{ + "server" => %{"id" => "https://remote.test/_arblarg/servers/1"}, + "channel" => %{"id" => "https://remote.test/_arblarg/channels/1"}, + "message" => %{ + "id" => "https://remote.test/_arblarg/messages/1", + "channel_id" => "https://remote.test/_arblarg/channels/1", + "content" => "hello", + "sender" => + canonical_actor("alice", "remote.test") + |> Map.delete("uri") + |> Map.delete("id") + } + } + } + |> ArblargSDK.sign_event_envelope("k1", "shared-test-secret") + + assert {:error, :invalid_event_payload} = ArblargSDK.validate_event_envelope(envelope) + end + + test "rejects user-authored events whose actor uri is not absolute http or https" do + envelope = + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => "arblarg", + "protocol_version" => "1.0", + "event_type" => "message.create", + "event_id" => "evt-invalid-uri", + "origin_domain" => "remote.test", + "stream_id" => "channel:https://remote.test/_arblarg/channels/1", + "sequence" => 1, + "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "idempotency_key" => "idem-invalid-uri", + "payload" => %{ + "server" => %{"id" => "https://remote.test/_arblarg/servers/1"}, + "channel" => %{"id" => "https://remote.test/_arblarg/channels/1"}, + "message" => %{ + "id" => "https://remote.test/_arblarg/messages/1", + "channel_id" => "https://remote.test/_arblarg/channels/1", + "content" => "hello", + "sender" => + canonical_actor("alice", "remote.test") + |> Map.put("uri", "acct:alice@remote.test") + } + } + } + |> ArblargSDK.sign_event_envelope("k1", "shared-test-secret") + + assert {:error, :invalid_event_payload} = ArblargSDK.validate_event_envelope(envelope) + end + + test "rejects unsupported protocol id" do + envelope = %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => "wrong", + "protocol_version" => "1.0", + "event_type" => "server.upsert", + "event_id" => "evt-2", + "origin_domain" => "remote.test", + "stream_id" => "server:https://remote.test/_arblarg/servers/1", + "sequence" => 1, + "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "idempotency_key" => "idem-2", + "payload" => %{ + "server" => %{"id" => "https://remote.test/_arblarg/servers/1"}, + "channels" => [] + } + } + + assert {:error, :unsupported_protocol} = ArblargSDK.validate_event_envelope(envelope) + end + + test "rejects non-spec top-level server_id and channel_id context" do + envelope = + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => "arblarg", + "protocol_version" => "1.0", + "event_type" => "message.create", + "event_id" => "evt-top-level-context", + "origin_domain" => "remote.test", + "stream_id" => "channel:https://remote.test/_arblarg/channels/1", + "sequence" => 1, + "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "idempotency_key" => "idem-top-level-context", + "payload" => %{ + "server_id" => "https://remote.test/_arblarg/servers/1", + "channel_id" => "https://remote.test/_arblarg/channels/1", + "message" => %{ + "id" => "https://remote.test/_arblarg/messages/1", + "content" => "hello", + "sender" => canonical_actor("alice", "remote.test") + } + } + } + |> ArblargSDK.sign_event_envelope("k1", "shared-test-secret") + + assert {:error, :invalid_event_payload} = ArblargSDK.validate_event_envelope(envelope) + end + + test "retries failing operations" do + attempt_counter = :atomics.new(1, []) + + result = + ArblargSDK.with_retries( + fn -> + count = :atomics.add_get(attempt_counter, 1, 1) + + if count < 3 do + {:error, :retry} + else + {:ok, :done} + end + end, + attempts: 3, + base_backoff_ms: 1, + jitter_ms: 0 + ) + + assert result == {:ok, :done} + end + + test "exposes protocol schemas" do + assert is_map(ArblargSDK.schema("1.0", "envelope")) + assert is_map(ArblargSDK.schema("1.0", "message.create")) + assert ArblargSDK.schema("1.0", "unknown") == nil + end + + test "normalizes extension event aliases" do + assert ArblargSDK.canonical_event_type("server.upsert") == + "urn:arblarg:ext:bootstrap:1#server.upsert" + + assert ArblargSDK.canonical_event_type("role.upsert") == + "urn:arblarg:ext:roles:1#role.upsert" + + assert ArblargSDK.canonical_event_type("thread.archive") == + "urn:arblarg:ext:threads:1#thread.archive" + + assert ArblargSDK.canonical_event_type("moderation.action.recorded") == + "urn:arblarg:ext:moderation:1#action.recorded" + end + + test "validates community extension event envelopes" do + envelope = + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => "arblarg", + "protocol_version" => "1.0", + "event_type" => "role.upsert", + "event_id" => "evt-ext-1", + "origin_domain" => "remote.test", + "stream_id" => "channel:https://remote.test/_arblarg/channels/1", + "sequence" => 1, + "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "idempotency_key" => "idem-ext-1", + "payload" => %{ + "server" => %{"id" => "https://remote.test/_arblarg/servers/1"}, + "channel" => %{"id" => "https://remote.test/_arblarg/channels/1"}, + "actor" => canonical_actor("alice", "remote.test"), + "role" => %{ + "id" => "role-admin", + "name" => "Admin", + "permissions" => ["manage_messages"], + "position" => 1 + } + } + } + |> ArblargSDK.sign_event_envelope("k1", "shared-test-secret") + + assert :ok = ArblargSDK.validate_event_envelope(envelope) + assert "urn:arblarg:ext:moderation:1#action.recorded" in ArblargSDK.supported_event_types() + end + + test "canonicalizes extension aliases before signing durable envelopes" do + envelope = + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => "arblarg", + "protocol_version" => "1.0", + "event_type" => "role.upsert", + "event_id" => "evt-ext-wire-canonical", + "origin_domain" => "remote.test", + "stream_id" => "channel:https://remote.test/_arblarg/channels/1", + "sequence" => 1, + "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "idempotency_key" => "idem-ext-wire-canonical", + "payload" => %{ + "server" => %{"id" => "https://remote.test/_arblarg/servers/1"}, + "channel" => %{"id" => "https://remote.test/_arblarg/channels/1"}, + "actor" => canonical_actor("alice", "remote.test"), + "role" => %{ + "id" => "role-admin", + "name" => "Admin", + "permissions" => ["manage_messages"], + "position" => 1 + } + } + } + |> ArblargSDK.sign_event_envelope("k1", "shared-test-secret") + + assert envelope["event_type"] == "urn:arblarg:ext:roles:1#role.upsert" + assert :ok = ArblargSDK.validate_event_envelope(envelope) + end + + test "rejects presence.update in durable envelopes" do + envelope = + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => "arblarg", + "protocol_version" => "1.0", + "event_type" => ArblargSDK.canonical_event_type("presence.update"), + "event_id" => "evt-presence-durable", + "origin_domain" => "remote.test", + "stream_id" => "server:https://remote.test/_arblarg/servers/1", + "sequence" => 1, + "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "idempotency_key" => "idem-presence-durable", + "payload" => %{ + "server" => %{"id" => "https://remote.test/_arblarg/servers/1"}, + "presence" => %{ + "actor" => canonical_actor("alice", "remote.test"), + "status" => "online", + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601() + } + } + } + |> ArblargSDK.sign_event_envelope("k1", "shared-test-secret") + + assert {:error, :unsupported_event_type} = ArblargSDK.validate_event_envelope(envelope) + end + + test "rejects dm.message.create when dm ids disagree" do + envelope = + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => "arblarg", + "protocol_version" => "1.0", + "event_type" => ArblargSDK.dm_message_create_event_type(), + "event_id" => "evt-dm-mismatch", + "origin_domain" => "remote.test", + "stream_id" => "dm:https://remote.test/_arblarg/dms/alice-bob", + "sequence" => 1, + "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "idempotency_key" => "idem-dm-mismatch", + "payload" => %{ + "dm" => %{ + "id" => "https://remote.test/_arblarg/dms/alice-bob", + "sender" => canonical_actor("alice", "remote.test"), + "recipient" => canonical_actor("bob", "local.test") + }, + "message" => %{ + "id" => "https://remote.test/_arblarg/messages/1", + "dm_id" => "https://remote.test/_arblarg/dms/other-lane", + "content" => "hello", + "sender" => canonical_actor("alice", "remote.test") + } + } + } + |> ArblargSDK.sign_event_envelope("k1", "shared-test-secret") + + assert {:error, :invalid_event_payload} = ArblargSDK.validate_event_envelope(envelope) + end + + test "rejects dm.message.create when stream_id does not embed dm.id" do + envelope = + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => "arblarg", + "protocol_version" => "1.0", + "event_type" => ArblargSDK.dm_message_create_event_type(), + "event_id" => "evt-dm-stream-mismatch", + "origin_domain" => "remote.test", + "stream_id" => "dm:https://remote.test/_arblarg/dms/wrong-lane", + "sequence" => 1, + "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "idempotency_key" => "idem-dm-stream-mismatch", + "payload" => %{ + "dm" => %{ + "id" => "https://remote.test/_arblarg/dms/alice-bob", + "sender" => canonical_actor("alice", "remote.test"), + "recipient" => canonical_actor("bob", "local.test") + }, + "message" => %{ + "id" => "https://remote.test/_arblarg/messages/1", + "dm_id" => "https://remote.test/_arblarg/dms/alice-bob", + "content" => "hello", + "sender" => canonical_actor("alice", "remote.test") + } + } + } + |> ArblargSDK.sign_event_envelope("k1", "shared-test-secret") + + assert {:error, :invalid_event_payload} = ArblargSDK.validate_event_envelope(envelope) + end + + test "accepts dm.message.create when sender uri matches but handle presentation differs" do + sender_uri = "https://remote.test/users/alice" + + envelope = + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => "arblarg", + "protocol_version" => "1.0", + "event_type" => ArblargSDK.dm_message_create_event_type(), + "event_id" => "evt-dm-uri-match", + "origin_domain" => "remote.test", + "stream_id" => "dm:https://remote.test/_arblarg/dms/alice-bob", + "sequence" => 1, + "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "idempotency_key" => "idem-dm-uri-match", + "payload" => %{ + "dm" => %{ + "id" => "https://remote.test/_arblarg/dms/alice-bob", + "sender" => canonical_actor("alice", "remote.test") |> Map.put("uri", sender_uri), + "recipient" => canonical_actor("bob", "local.test") + }, + "message" => %{ + "id" => "https://remote.test/_arblarg/messages/1", + "dm_id" => "https://remote.test/_arblarg/dms/alice-bob", + "content" => "hello", + "sender" => + canonical_actor("alice-renamed", "alias.remote.test") + |> Map.put("uri", sender_uri) + |> Map.put("id", sender_uri) + } + } + } + |> ArblargSDK.sign_event_envelope("k1", "shared-test-secret") + + assert :ok = ArblargSDK.validate_event_envelope(envelope) + end + + test "published schema artifacts mirror the live SDK" do + version = ArblargSDK.protocol_version() + schema_dir = Path.expand("../../../../../external/arblarg/schemas/v1", __DIR__) + + published = + schema_dir + |> File.ls!() + |> Enum.filter(&String.ends_with?(&1, ".json")) + |> Enum.sort() + + expected = + ArblargSDK.schema_names(version) + |> Enum.map(&"#{&1}.json") + |> Enum.sort() + + assert published == expected + + Enum.each(ArblargSDK.schema_names(version), fn schema_name -> + published_schema = + schema_dir + |> Path.join("#{schema_name}.json") + |> File.read!() + |> Jason.decode!() + + assert published_schema == ArblargSDK.schema(version, schema_name) + end) + end + + defp canonical_actor(username, domain) do + %{ + "id" => "https://#{domain}/users/#{username}", + "uri" => "https://#{domain}/users/#{username}", + "username" => username, + "display_name" => username, + "domain" => domain, + "handle" => "#{username}@#{domain}" + } + end +end diff --git a/apps/elektrine/test/elektrine/messaging/channel_categories_test.exs b/apps/elektrine/test/elektrine/messaging/channel_categories_test.exs new file mode 100644 index 0000000..5930aad --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/channel_categories_test.exs @@ -0,0 +1,274 @@ +defmodule Elektrine.Messaging.ChannelCategoriesTest do + use Elektrine.DataCase, async: true + + alias Elektrine.AccountsFixtures + alias Elektrine.Messaging + alias Elektrine.Messaging.ChannelCategories + alias Elektrine.Messaging.ChannelCategory + alias Elektrine.Messaging.ChatConversation + alias Elektrine.Repo + + defp create_server_with_member do + owner = AccountsFixtures.user_fixture() + member = AccountsFixtures.user_fixture() + + {:ok, server} = + Messaging.create_server(owner.id, %{name: "category-space", is_public: true}) + + {:ok, _member} = Messaging.join_server(server.id, member.id) + + %{owner: owner, member: member, server: server} + end + + describe "create_category/3" do + test "owner can create categories with auto-incrementing positions" do + %{owner: owner, server: server} = create_server_with_member() + + assert {:ok, %ChannelCategory{} = first} = + ChannelCategories.create_category(server.id, owner.id, %{name: "Text"}) + + assert first.name == "Text" + assert first.position == 0 + assert first.server_id == server.id + + assert {:ok, second} = + ChannelCategories.create_category(server.id, owner.id, %{name: "Projects"}) + + assert second.position == 1 + end + + test "regular members are denied" do + %{member: member, server: server} = create_server_with_member() + + assert {:error, :unauthorized} = + ChannelCategories.create_category(server.id, member.id, %{name: "Nope"}) + end + + test "non-members are denied" do + %{server: server} = create_server_with_member() + outsider = AccountsFixtures.user_fixture() + + assert {:error, :unauthorized} = + ChannelCategories.create_category(server.id, outsider.id, %{name: "Nope"}) + end + + test "requires a name" do + %{owner: owner, server: server} = create_server_with_member() + + assert {:error, %Ecto.Changeset{}} = + ChannelCategories.create_category(server.id, owner.id, %{}) + end + + test "unknown server returns not_found" do + owner = AccountsFixtures.user_fixture() + + assert {:error, :not_found} = + ChannelCategories.create_category(-1, owner.id, %{name: "Ghost"}) + end + end + + describe "rename_category/3" do + test "owner can rename, member cannot" do + %{owner: owner, member: member, server: server} = create_server_with_member() + + {:ok, category} = ChannelCategories.create_category(server.id, owner.id, %{name: "Old"}) + + assert {:ok, renamed} = ChannelCategories.rename_category(category.id, owner.id, "New") + assert renamed.name == "New" + + assert {:error, :unauthorized} = + ChannelCategories.rename_category(category.id, member.id, "Sneaky") + end + end + + describe "delete_category/2" do + test "deleting a category keeps its channels and nullifies category_id" do + %{owner: owner, server: server} = create_server_with_member() + + {:ok, category} = ChannelCategories.create_category(server.id, owner.id, %{name: "Temp"}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "keepme", + category_id: category.id + }) + + assert channel.category_id == category.id + + assert {:ok, _deleted} = ChannelCategories.delete_category(category.id, owner.id) + + refute Repo.get(ChannelCategory, category.id) + + reloaded_channel = Repo.get(ChatConversation, channel.id) + assert reloaded_channel + assert is_nil(reloaded_channel.category_id) + end + + test "member cannot delete" do + %{owner: owner, member: member, server: server} = create_server_with_member() + + {:ok, category} = ChannelCategories.create_category(server.id, owner.id, %{name: "Keep"}) + + assert {:error, :unauthorized} = ChannelCategories.delete_category(category.id, member.id) + assert Repo.get(ChannelCategory, category.id) + end + end + + describe "reorder_categories/3" do + test "sets positions to match the given order" do + %{owner: owner, server: server} = create_server_with_member() + + {:ok, a} = ChannelCategories.create_category(server.id, owner.id, %{name: "A"}) + {:ok, b} = ChannelCategories.create_category(server.id, owner.id, %{name: "B"}) + {:ok, c} = ChannelCategories.create_category(server.id, owner.id, %{name: "C"}) + + assert {:ok, reordered} = + ChannelCategories.reorder_categories(server.id, owner.id, [c.id, a.id, b.id]) + + assert Enum.map(reordered, & &1.id) == [c.id, a.id, b.id] + assert Enum.map(reordered, & &1.position) == [0, 1, 2] + end + + test "ignores foreign ids and appends categories missing from the list" do + %{owner: owner, server: server} = create_server_with_member() + + {:ok, a} = ChannelCategories.create_category(server.id, owner.id, %{name: "A"}) + {:ok, b} = ChannelCategories.create_category(server.id, owner.id, %{name: "B"}) + + assert {:ok, reordered} = + ChannelCategories.reorder_categories(server.id, owner.id, [b.id, -5]) + + assert Enum.map(reordered, & &1.id) == [b.id, a.id] + end + + test "member cannot reorder" do + %{owner: owner, member: member, server: server} = create_server_with_member() + + {:ok, a} = ChannelCategories.create_category(server.id, owner.id, %{name: "A"}) + + assert {:error, :unauthorized} = + ChannelCategories.reorder_categories(server.id, member.id, [a.id]) + end + end + + describe "assign_channel_to_category/3" do + test "assigns and clears a channel's category" do + %{owner: owner, server: server} = create_server_with_member() + + {:ok, category} = ChannelCategories.create_category(server.id, owner.id, %{name: "Text"}) + {:ok, channel} = Messaging.create_server_channel(server.id, owner.id, %{name: "movable"}) + + assert is_nil(channel.category_id) + + assert {:ok, updated} = + ChannelCategories.assign_channel_to_category(channel.id, owner.id, category.id) + + assert updated.category_id == category.id + + assert {:ok, cleared} = + ChannelCategories.assign_channel_to_category(channel.id, owner.id, nil) + + assert is_nil(cleared.category_id) + end + + test "rejects categories from a different server" do + %{owner: owner, server: server} = create_server_with_member() + other_owner = AccountsFixtures.user_fixture() + + {:ok, other_server} = Messaging.create_server(other_owner.id, %{name: "other-space"}) + + {:ok, foreign_category} = + ChannelCategories.create_category(other_server.id, other_owner.id, %{name: "Foreign"}) + + {:ok, channel} = Messaging.create_server_channel(server.id, owner.id, %{name: "here"}) + + assert {:error, :category_not_in_server} = + ChannelCategories.assign_channel_to_category( + channel.id, + owner.id, + foreign_category.id + ) + end + + test "member cannot assign channels" do + %{owner: owner, member: member, server: server} = create_server_with_member() + + {:ok, category} = ChannelCategories.create_category(server.id, owner.id, %{name: "Text"}) + {:ok, channel} = Messaging.create_server_channel(server.id, owner.id, %{name: "locked"}) + + assert {:error, :unauthorized} = + ChannelCategories.assign_channel_to_category(channel.id, member.id, category.id) + end + end + + describe "listing" do + test "list_categories orders by position" do + %{owner: owner, server: server} = create_server_with_member() + + {:ok, a} = ChannelCategories.create_category(server.id, owner.id, %{name: "A"}) + {:ok, b} = ChannelCategories.create_category(server.id, owner.id, %{name: "B"}) + {:ok, _} = ChannelCategories.reorder_categories(server.id, owner.id, [b.id, a.id]) + + assert Enum.map(ChannelCategories.list_categories(server.id), & &1.id) == [b.id, a.id] + end + + test "list_categories_with_channels groups and orders channels" do + %{owner: owner, server: server} = create_server_with_member() + + {:ok, category} = ChannelCategories.create_category(server.id, owner.id, %{name: "Text"}) + + {:ok, second_channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "beta", + category_id: category.id + }) + + {:ok, first_channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "alpha", + category_id: category.id, + channel_position: 0 + }) + + {categories, uncategorized} = + ChannelCategories.list_categories_with_channels(server.id) + + assert [%ChannelCategory{id: category_id, channels: channels}] = categories + assert category_id == category.id + + assert Enum.map(channels, & &1.id) == [first_channel.id, second_channel.id] + + # The default "general" channel stays uncategorized. + assert Enum.map(uncategorized, & &1.name) == ["general"] + end + end + + describe "create_server_channel/3 category handling" do + test "accepts a category from the same server and drops foreign ones" do + %{owner: owner, server: server} = create_server_with_member() + other_owner = AccountsFixtures.user_fixture() + + {:ok, category} = ChannelCategories.create_category(server.id, owner.id, %{name: "Text"}) + {:ok, other_server} = Messaging.create_server(other_owner.id, %{name: "elsewhere"}) + + {:ok, foreign_category} = + ChannelCategories.create_category(other_server.id, other_owner.id, %{name: "Foreign"}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "categorized", + category_id: category.id + }) + + assert channel.category_id == category.id + + {:ok, other_channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "uncategorized", + category_id: foreign_category.id + }) + + assert is_nil(other_channel.category_id) + end + end +end diff --git a/apps/elektrine/test/elektrine/messaging/chat_conversations_credits_test.exs b/apps/elektrine/test/elektrine/messaging/chat_conversations_credits_test.exs new file mode 100644 index 0000000..6c4858d --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/chat_conversations_credits_test.exs @@ -0,0 +1,53 @@ +defmodule Elektrine.Messaging.ChatConversationsCreditsTest do + use Elektrine.DataCase, async: false + + import Elektrine.AccountsFixtures + + alias Atomine.Credits + alias Elektrine.Accounts.User + alias Elektrine.Messaging + alias Elektrine.Repo + + setup do + previous_config = Application.get_env(:atomine, :credits, []) + Application.put_env(:atomine, :credits, dm_gate_enabled: true) + + on_exit(fn -> + Application.put_env(:atomine, :credits, previous_config) + end) + + :ok + end + + test "TL0 users spend an Atomine Credit to create a first DM" do + alice = user_fixture() + bob = user_fixture() + + assert {:error, :insufficient_dm_credits} = Messaging.create_dm_conversation(alice.id, bob.id) + + assert {:ok, _ledger_entry} = Credits.grant(alice.id, :atomine_credit, 1, "test_grant") + + assert {:ok, conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + assert conversation.type == "dm" + assert Credits.balance(alice.id, :atomine_credit) == 0 + + assert {:ok, existing_conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + assert existing_conversation.id == conversation.id + assert Credits.balance(alice.id, :atomine_credit) == 0 + end + + test "TL1 users can create first DMs without credits" do + alice = promote_to_trust_level(user_fixture(), 1) + bob = user_fixture() + + assert {:ok, conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + assert conversation.type == "dm" + assert Credits.balance(alice.id, :atomine_credit) == 0 + end + + defp promote_to_trust_level(%User{} = user, trust_level) do + user + |> User.trust_level_changeset(%{trust_level: trust_level}) + |> Repo.update!() + end +end diff --git a/apps/elektrine/test/elektrine/messaging/chat_conversations_test.exs b/apps/elektrine/test/elektrine/messaging/chat_conversations_test.exs new file mode 100644 index 0000000..0539c1f --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/chat_conversations_test.exs @@ -0,0 +1,30 @@ +defmodule Elektrine.Messaging.ChatConversationsTest do + use Elektrine.DataCase, async: false + + import Elektrine.AccountsFixtures + + alias Elektrine.Accounts.User + alias Elektrine.Messaging + alias Elektrine.Repo + + test "created chat conversations use hashes for chat paths" do + alice = promote_to_trust_level(user_fixture(), 1) + bob = user_fixture() + + assert {:ok, dm} = Messaging.create_dm_conversation(alice.id, bob.id) + assert dm.hash =~ ~r/^[0-9a-f]{32}$/ + assert Elektrine.Paths.chat_path(dm) == "/chat/#{dm.hash}" + + assert {:ok, group} = + Messaging.create_chat_group_conversation(alice.id, %{name: "Hash Group"}, [bob.id]) + + assert group.hash =~ ~r/^[0-9a-f]{32}$/ + assert Elektrine.Paths.chat_path(group) == "/chat/#{group.hash}" + end + + defp promote_to_trust_level(%User{} = user, trust_level) do + user + |> User.trust_level_changeset(%{trust_level: trust_level}) + |> Repo.update!() + end +end diff --git a/apps/elektrine/test/elektrine/messaging/chat_message_pins_test.exs b/apps/elektrine/test/elektrine/messaging/chat_message_pins_test.exs new file mode 100644 index 0000000..df5f1cd --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/chat_message_pins_test.exs @@ -0,0 +1,199 @@ +defmodule Elektrine.Messaging.ChatMessagePinsTest do + use Elektrine.DataCase, async: false + + alias Elektrine.AccountsFixtures + alias Elektrine.Messaging + alias Elektrine.Messaging.ChatMessage + alias Elektrine.Messaging.ChatMessagePin + alias Elektrine.Messaging.ChatMessagePins + alias Elektrine.Repo + + defp create_server_channel_with_member do + owner = AccountsFixtures.user_fixture() + member = AccountsFixtures.user_fixture() + + {:ok, server} = Messaging.create_server(owner.id, %{name: "pin-space", is_public: true}) + {:ok, _member} = Messaging.join_server(server.id, member.id) + + [channel | _] = server.channels + + %{owner: owner, member: member, server: server, channel: channel} + end + + defp create_message!(conversation_id, sender_id, content) do + {:ok, message} = Messaging.create_chat_text_message(conversation_id, sender_id, content) + message + end + + describe "pin_message/2" do + test "server owner can pin a message in a server channel" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + + message = create_message!(channel.id, member.id, "pin me") + + assert {:ok, pinned} = ChatMessagePins.pin_message(message.id, owner.id) + assert pinned.is_pinned + assert pinned.pinned_by_id == owner.id + assert %DateTime{} = pinned.pinned_at + + assert Repo.get_by(ChatMessagePin, + message_id: message.id, + conversation_id: channel.id, + pinned_by_id: owner.id + ) + end + + test "regular channel members cannot pin" do + %{member: member, channel: channel} = create_server_channel_with_member() + + message = create_message!(channel.id, member.id, "not yours to pin") + + assert {:error, :unauthorized} = ChatMessagePins.pin_message(message.id, member.id) + end + + test "pinning twice returns already_pinned" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + + message = create_message!(channel.id, member.id, "once only") + + assert {:ok, _} = ChatMessagePins.pin_message(message.id, owner.id) + assert {:error, :already_pinned} = ChatMessagePins.pin_message(message.id, owner.id) + end + + test "unknown messages return not_found" do + %{owner: owner} = create_server_channel_with_member() + + assert {:error, :not_found} = ChatMessagePins.pin_message(-1, owner.id) + end + + test "group admins can pin, group members cannot" do + creator = AccountsFixtures.user_fixture() + buddy = AccountsFixtures.user_fixture() + + {:ok, group} = + Messaging.create_chat_group_conversation(creator.id, %{name: "pin group"}, [buddy.id]) + + message = create_message!(group.id, buddy.id, "group pin") + + assert {:error, :unauthorized} = ChatMessagePins.pin_message(message.id, buddy.id) + assert {:ok, pinned} = ChatMessagePins.pin_message(message.id, creator.id) + assert pinned.is_pinned + end + + test "enforces the per-conversation pin cap" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + + limit = ChatMessagePins.max_pins_per_conversation() + + for index <- 1..limit do + message = create_message!(channel.id, member.id, "filler #{index}") + + Repo.insert!(%ChatMessagePin{ + conversation_id: channel.id, + message_id: message.id, + pinned_by_id: owner.id + }) + end + + over_limit = create_message!(channel.id, member.id, "one too many") + + assert {:error, :pin_limit_reached} = ChatMessagePins.pin_message(over_limit.id, owner.id) + end + + test "broadcasts a pin event on the conversation topic" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + + message = create_message!(channel.id, member.id, "broadcast me") + + Phoenix.PubSub.subscribe(Elektrine.PubSub, "chat:#{channel.id}") + + assert {:ok, _} = ChatMessagePins.pin_message(message.id, owner.id) + + message_id = message.id + assert_receive {:message_pinned, %ChatMessage{id: ^message_id, is_pinned: true}} + end + end + + describe "unpin_message/2" do + test "server owner can unpin and unpinning clears the pin row" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + + message = create_message!(channel.id, member.id, "temporary pin") + {:ok, _} = ChatMessagePins.pin_message(message.id, owner.id) + + Phoenix.PubSub.subscribe(Elektrine.PubSub, "chat:#{channel.id}") + + assert {:ok, unpinned} = ChatMessagePins.unpin_message(message.id, owner.id) + refute unpinned.is_pinned + refute Repo.get_by(ChatMessagePin, message_id: message.id) + + message_id = message.id + assert_receive {:message_unpinned, %ChatMessage{id: ^message_id, is_pinned: false}} + end + + test "unpinning a message that is not pinned returns not_pinned" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + + message = create_message!(channel.id, member.id, "never pinned") + + assert {:error, :not_pinned} = ChatMessagePins.unpin_message(message.id, owner.id) + end + + test "regular members cannot unpin" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + + message = create_message!(channel.id, member.id, "sticky") + {:ok, _} = ChatMessagePins.pin_message(message.id, owner.id) + + assert {:error, :unauthorized} = ChatMessagePins.unpin_message(message.id, member.id) + assert Repo.get_by(ChatMessagePin, message_id: message.id) + end + end + + describe "list_pinned_messages/1" do + test "returns pinned messages newest pin first" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + + first = create_message!(channel.id, member.id, "first pinned") + second = create_message!(channel.id, member.id, "second pinned") + _unpinned = create_message!(channel.id, member.id, "not pinned") + + {:ok, _} = ChatMessagePins.pin_message(first.id, owner.id) + {:ok, _} = ChatMessagePins.pin_message(second.id, owner.id) + + pinned = ChatMessagePins.list_pinned_messages(channel.id) + + assert Enum.map(pinned, & &1.id) == [second.id, first.id] + assert Enum.all?(pinned, & &1.is_pinned) + assert Enum.all?(pinned, &(&1.pinned_by_id == owner.id)) + end + + test "excludes deleted messages" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + + message = create_message!(channel.id, member.id, "will be deleted") + {:ok, _} = ChatMessagePins.pin_message(message.id, owner.id) + {:ok, _} = Messaging.delete_chat_message(message.id, member.id) + + assert ChatMessagePins.list_pinned_messages(channel.id) == [] + end + end + + describe "pin state hydration" do + test "get_chat_messages returns pin state on loaded messages" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + + pinned_message = create_message!(channel.id, member.id, "hydrate me") + plain_message = create_message!(channel.id, member.id, "plain") + + {:ok, _} = ChatMessagePins.pin_message(pinned_message.id, owner.id) + + messages = Messaging.get_chat_messages(channel.id, user_id: member.id) + by_id = Map.new(messages, &{&1.id, &1}) + + assert by_id[pinned_message.id].is_pinned + assert by_id[pinned_message.id].pinned_by_id == owner.id + refute by_id[plain_message.id].is_pinned + end + end +end diff --git a/apps/elektrine/test/elektrine/messaging/chat_messages_test.exs b/apps/elektrine/test/elektrine/messaging/chat_messages_test.exs new file mode 100644 index 0000000..9aa47df --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/chat_messages_test.exs @@ -0,0 +1,599 @@ +defmodule Elektrine.Messaging.ChatMessagesTest do + use Elektrine.DataCase, async: false + + alias Elektrine.AccountsFixtures + alias Elektrine.Messaging + + alias Elektrine.Messaging.{ + ArblargSDK, + ChatConversation, + ChatConversationEncryptionKey, + ChatConversationKeyRecipient, + ChatConversationMember, + ChatEncryptionDevice, + ChatMessage, + ChatMessages, + Federation, + FederationExtensionEvent, + Server + } + + alias Elektrine.Repo + + describe "client-side encrypted messages" do + test "registers devices and lists active devices for conversation members" do + alice = AccountsFixtures.user_fixture() + bob = AccountsFixtures.user_fixture() + outsider = AccountsFixtures.user_fixture() + + {:ok, conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + + assert {:ok, _} = + Messaging.register_chat_encryption_device(alice.id, device_attrs("alice-device")) + + assert {:ok, _} = + Messaging.register_chat_encryption_device(bob.id, device_attrs("bob-device")) + + assert {:ok, _} = + Messaging.register_chat_encryption_device( + outsider.id, + device_attrs("outsider-device") + ) + + devices = Messaging.list_chat_encryption_devices_for_conversation(conversation.id) + + assert Enum.map(devices, & &1.device_id) == ["alice-device", "bob-device"] + assert Enum.map(devices, & &1.user_id) == [alice.id, bob.id] + end + + test "stores browser-encrypted payload, recipients, and client search tokens" do + alice = AccountsFixtures.user_fixture() + bob = AccountsFixtures.user_fixture() + + {:ok, conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + {:ok, _} = Messaging.register_chat_encryption_device(alice.id, device_attrs("alice-device")) + {:ok, _} = Messaging.register_chat_encryption_device(bob.id, device_attrs("bob-device")) + + key_uid = "key-test-123456" + payload = encrypted_payload(key_uid) + alice_package = key_package(alice.id, "alice-device") + bob_package = key_package(bob.id, "bob-device") + bob_wrapped_key = bob_package.wrapped_key + + assert {:ok, message} = + Messaging.create_client_encrypted_chat_text_message(conversation.id, alice.id, %{ + "encrypted_payload" => payload, + "key_packages" => [alice_package, bob_package], + "search_index" => ["client-token"] + }) + + db_message = Repo.get!(ChatMessage, message.id) + assert db_message.content == nil + assert db_message.encrypted_content == nil + assert db_message.client_encrypted_payload == payload + assert db_message.search_index == ["client-token"] + + encryption_key = + Repo.get_by!(ChatConversationEncryptionKey, + conversation_id: conversation.id, + key_uid: key_uid + ) + + assert encryption_key.algorithm == "AES-256-GCM" + + assert Repo.aggregate( + from(r in ChatConversationKeyRecipient, + where: r.conversation_key_id == ^encryption_key.id + ), + :count + ) == 2 + + assert {:ok, ^bob_wrapped_key} = + Messaging.get_wrapped_chat_key(conversation.id, bob.id, "bob-device", key_uid) + + assert {:ok, results} = + Messaging.search_messages_in_conversation(conversation.id, bob.id, "zz", + search_tokens: ["client-token"] + ) + + assert Enum.map(results, & &1.id) == [message.id] + end + + test "rejects key packages for devices outside the conversation" do + alice = AccountsFixtures.user_fixture() + bob = AccountsFixtures.user_fixture() + outsider = AccountsFixtures.user_fixture() + + {:ok, conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + {:ok, _} = Messaging.register_chat_encryption_device(alice.id, device_attrs("alice-device")) + {:ok, _} = Messaging.register_chat_encryption_device(bob.id, device_attrs("bob-device")) + + assert {:ok, _} = + Messaging.register_chat_encryption_device( + outsider.id, + device_attrs("outsider-device") + ) + + assert {:error, :invalid_key_recipient} = + Messaging.create_client_encrypted_chat_text_message(conversation.id, alice.id, %{ + "encrypted_payload" => encrypted_payload("key-test-123456"), + "key_packages" => [key_package(outsider.id, "outsider-device")], + "search_index" => ["client-token"] + }) + end + + test "does not return wrapped keys for revoked devices" do + alice = AccountsFixtures.user_fixture() + bob = AccountsFixtures.user_fixture() + + {:ok, conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + {:ok, _} = Messaging.register_chat_encryption_device(alice.id, device_attrs("alice-device")) + + {:ok, device} = + Messaging.register_chat_encryption_device(bob.id, device_attrs("bob-device")) + + key_uid = "key-test-123456" + + assert {:ok, _message} = + Messaging.create_client_encrypted_chat_text_message(conversation.id, alice.id, %{ + "encrypted_payload" => encrypted_payload(key_uid), + "key_packages" => [ + key_package(alice.id, "alice-device"), + key_package(bob.id, "bob-device") + ], + "search_index" => ["client-token"] + }) + + device + |> ChatEncryptionDevice.changeset(%{revoked_at: Elektrine.Time.utc_now()}) + |> Repo.update!() + + assert {:error, :not_found} = + Messaging.get_wrapped_chat_key(conversation.id, bob.id, "bob-device", key_uid) + end + end + + describe "read state" do + test "tracks chat last read message using chat read receipts" do + alice = AccountsFixtures.user_fixture() + bob = AccountsFixtures.user_fixture() + + {:ok, conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + {:ok, first} = Messaging.create_chat_text_message(conversation.id, alice.id, "first") + {:ok, second} = Messaging.create_chat_text_message(conversation.id, alice.id, "second") + + assert :ok = ChatMessages.mark_messages_read(conversation.id, bob.id, first.id) + assert ChatMessages.get_last_read_message_id(conversation.id, bob.id) == first.id + assert ChatMessages.get_unread_count(conversation.id, bob.id) == 1 + + assert :ok = ChatMessages.mark_messages_read(conversation.id, bob.id, second.id) + assert ChatMessages.get_last_read_message_id(conversation.id, bob.id) == second.id + assert ChatMessages.get_unread_count(conversation.id, bob.id) == 0 + end + + test "marks group messages read by time when ids are not chronological" do + alice = AccountsFixtures.user_fixture() + bob = AccountsFixtures.user_fixture() + charlie = AccountsFixtures.user_fixture() + + {:ok, group} = + Messaging.create_chat_group_conversation(alice.id, %{name: "Team"}, [bob.id, charlie.id]) + + {:ok, newest} = Messaging.create_chat_text_message(group.id, alice.id, "newest") + + {:ok, older_with_higher_id} = + Messaging.create_chat_text_message(group.id, charlie.id, "older") + + older_inserted_at = NaiveDateTime.add(newest.inserted_at, -60, :second) + + from(m in ChatMessage, where: m.id == ^older_with_higher_id.id) + |> Repo.update_all(set: [inserted_at: older_inserted_at]) + + assert ChatMessages.get_unread_count(group.id, bob.id) == 2 + + assert :ok = ChatMessages.mark_messages_read(group.id, bob.id, newest.id) + assert ChatMessages.get_unread_count(group.id, bob.id) == 0 + end + + test "marks latest same-timestamp chat messages read" do + alice = AccountsFixtures.user_fixture() + bob = AccountsFixtures.user_fixture() + + {:ok, conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + {:ok, first} = Messaging.create_chat_text_message(conversation.id, alice.id, "first") + {:ok, second} = Messaging.create_chat_text_message(conversation.id, alice.id, "second") + + same_inserted_at = ~N[2026-05-03 02:11:38] + + from(m in ChatMessage, where: m.id in ^[first.id, second.id]) + |> Repo.update_all(set: [inserted_at: same_inserted_at]) + + data = Messaging.get_conversation_messages(conversation.id, bob.id, limit: 2) + assert Enum.map(data.messages, & &1.id) == [second.id, first.id] + + assert {:ok, :read} = Messaging.mark_as_read(conversation.id, bob.id) + assert ChatMessages.get_unread_count(conversation.id, bob.id) == 0 + assert ChatMessages.get_last_read_message_id(conversation.id, bob.id) == second.id + end + + test "broadcasts chat unread count for nav badges after marking read" do + alice = AccountsFixtures.user_fixture() + bob = AccountsFixtures.user_fixture() + + {:ok, conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + {:ok, message} = Messaging.create_chat_text_message(conversation.id, alice.id, "hello") + + Phoenix.PubSub.subscribe(Elektrine.PubSub, "user:#{bob.id}:notification_count") + + assert :ok = ChatMessages.mark_messages_read(conversation.id, bob.id, message.id) + assert_receive {:chat_unread_count_updated, 0} + end + + test "clears matching notifications when chat messages are read" do + alice = AccountsFixtures.user_fixture() + bob = AccountsFixtures.user_fixture() + + {:ok, conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + {:ok, message} = Messaging.create_chat_text_message(conversation.id, alice.id, "hello") + + {:ok, notification} = + Elektrine.Notifications.create_notification(%{ + user_id: bob.id, + actor_id: alice.id, + type: "new_message", + title: "New message", + body: "hello", + source_type: "message", + source_id: message.id, + url: Elektrine.Paths.chat_message_path(conversation.id, message.id) + }) + + Phoenix.PubSub.subscribe(Elektrine.PubSub, "user:#{bob.id}:notification_count") + + assert :ok = ChatMessages.mark_messages_read(conversation.id, bob.id, message.id) + assert_receive {:notification_count_updated, 0} + + assert %{read_at: %DateTime{}} = + Repo.get!(Elektrine.Notifications.Notification, notification.id) + end + end + + describe "media URL validation" do + test "accepts content-addressed chat attachment paths owned by the sender" do + alice = AccountsFixtures.user_fixture() + bob = AccountsFixtures.user_fixture() + {:ok, conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + + hash = String.duplicate("a", 64) + media_url = "/uploads/chat-attachments/#{alice.id}/aa/aa/aa/#{hash}.png" + + assert {:ok, message} = + Messaging.create_media_message(conversation.id, alice.id, [media_url], nil) + + assert message.media_urls == [media_url] + end + + test "rejects content-addressed chat attachment paths owned by another user" do + alice = AccountsFixtures.user_fixture() + bob = AccountsFixtures.user_fixture() + {:ok, conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + + hash = String.duplicate("a", 64) + media_url = "/uploads/chat-attachments/#{bob.id}/aa/aa/aa/#{hash}.png" + + assert {:error, %Ecto.Changeset{} = changeset} = + Messaging.create_media_message(conversation.id, alice.id, [media_url], nil) + + assert {"contains untrusted media URLs", _} = changeset.errors[:media_urls] + end + end + + describe "blocked direct messages" do + test "rejects new messages in an existing local DM after a block" do + alice = AccountsFixtures.user_fixture() + bob = AccountsFixtures.user_fixture() + + {:ok, conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + + assert {:ok, _message} = + Messaging.create_chat_text_message(conversation.id, alice.id, "before") + + assert {:ok, _block} = Elektrine.Accounts.block_user(bob.id, alice.id) + + assert {:error, :blocked} = + Messaging.create_chat_text_message(conversation.id, alice.id, "after") + + assert {:error, :blocked} = + Messaging.create_chat_text_message(conversation.id, bob.id, "also blocked") + + data = Messaging.get_conversation_messages(conversation.id, bob.id, limit: 10) + assert Enum.map(data.messages, &ChatMessage.display_content/1) == ["before"] + end + + test "rejects client-encrypted messages in an existing local DM after a block" do + alice = AccountsFixtures.user_fixture() + bob = AccountsFixtures.user_fixture() + + {:ok, conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + assert {:ok, _block} = Elektrine.Accounts.block_user(bob.id, alice.id) + + assert {:error, :blocked} = + Messaging.create_client_encrypted_chat_text_message(conversation.id, alice.id, %{ + "encrypted_payload" => encrypted_payload("key-blocked-123"), + "key_packages" => [key_package(alice.id, "alice-device")], + "search_index" => ["blocked-token"] + }) + end + end + + describe "clear history" do + test "hides existing chat messages per user and keeps new messages visible" do + alice = AccountsFixtures.user_fixture() + bob = AccountsFixtures.user_fixture() + + {:ok, conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + {:ok, _first} = Messaging.create_chat_text_message(conversation.id, alice.id, "first") + {:ok, _second} = Messaging.create_chat_text_message(conversation.id, alice.id, "second") + + assert length(ChatMessages.get_messages(conversation.id, user_id: bob.id)) == 2 + + assert {:ok, :cleared} = ChatMessages.clear_history_for_user(conversation.id, bob.id) + assert ChatMessages.get_messages(conversation.id, user_id: bob.id) == [] + + {:ok, fresh} = Messaging.create_chat_text_message(conversation.id, alice.id, "fresh") + + visible_messages = ChatMessages.get_messages(conversation.id, user_id: bob.id) + assert Enum.map(visible_messages, & &1.id) == [fresh.id] + assert ChatMessages.get_unread_count(conversation.id, bob.id) == 1 + end + end + + describe "remote sender hydration" do + test "hydrates sender metadata for mirrored remote messages" do + alice = AccountsFixtures.user_fixture() + bob = AccountsFixtures.user_fixture() + + {:ok, conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + + remote_message = + %ChatMessage{} + |> ChatMessage.changeset(%{ + conversation_id: conversation.id, + sender_id: nil, + content: "hello from remote", + message_type: "text", + is_federated_mirror: true, + origin_domain: "remote.example", + media_metadata: %{ + "remote_sender" => %{ + "username" => "remotealice", + "display_name" => "Remote Alice", + "domain" => "remote.example" + } + } + }) + |> Repo.insert!() + + hydrated = ChatMessages.get_message_decrypted(remote_message.id) + + assert hydrated.sender.id == nil + assert hydrated.sender.username == "remotealice" + assert hydrated.sender.display_name == "Remote Alice" + assert hydrated.sender.handle == "remotealice@remote.example" + assert hydrated.sender.remote == true + end + end + + describe "link previews" do + test "attaches pending link previews to chat messages" do + alice = AccountsFixtures.user_fixture() + bob = AccountsFixtures.user_fixture() + + {:ok, conversation} = Messaging.create_dm_conversation(alice.id, bob.id) + + preview = + %Elektrine.Social.LinkPreview{} + |> Elektrine.Social.LinkPreview.changeset(%{ + url: "https://www.youtube.com/watch?v=_XHp4QZVmoc", + status: "pending" + }) + |> Repo.insert!() + + {:ok, message} = + Messaging.create_chat_text_message( + conversation.id, + alice.id, + preview.url + ) + + message = ChatMessages.get_message(message.id) + + assert message.link_preview_id == preview.id + assert message.link_preview.id == preview.id + end + end + + describe "mirrored channel writes" do + test "allows local durable writes in mirrored channels" do + user = AccountsFixtures.user_fixture() + channel = mirrored_channel_fixture() + mirrored_member_fixture(channel.id, user.id) + message = mirrored_local_message_fixture(channel.id, user.id) + + assert {:ok, created} = ChatMessages.create_text_message(channel.id, user.id, "hello") + assert created.conversation_id == channel.id + + assert {:ok, edited} = ChatMessages.edit_message(message.id, user.id, "edited") + assert edited.content == "edited" + + assert {:ok, reaction} = ChatMessages.add_reaction(message.id, user.id, "👍") + assert reaction.emoji == "👍" + + assert {:ok, 1} = ChatMessages.remove_reaction(message.id, user.id, "👍") + + assert {:ok, deleted} = ChatMessages.delete_message(message.id, user.id) + assert not is_nil(deleted.deleted_at) + end + + test "tracks read state in mirrored channels" do + user = AccountsFixtures.user_fixture() + channel = mirrored_channel_fixture() + mirrored_member_fixture(channel.id, user.id) + message = mirrored_local_message_fixture(channel.id, user.id) + + assert :ok = ChatMessages.mark_messages_read(channel.id, user.id, message.id) + assert ChatMessages.get_last_read_message_id(channel.id, user.id) == message.id + end + + test "enforces federated role assignments and permission overwrites for local writes" do + user = AccountsFixtures.user_fixture() + channel = mirrored_channel_fixture() + mirrored_member_fixture(channel.id, user.id, "readonly") + actor_uri = Elektrine.Messaging.Federation.Utils.sender_payload(user)["uri"] + + insert_extension_event( + channel.id, + "role.upsert", + "role:speaker:channel:#{channel.id}", + %{ + "role" => %{ + "id" => "role:speaker", + "name" => "Speaker", + "permissions" => ["send_messages"], + "position" => 20 + } + } + ) + + insert_extension_event( + channel.id, + "role.assignment.upsert", + "role_assignment:role:speaker:member:#{actor_uri}:channel:#{channel.id}", + %{ + "assignment" => %{ + "role_id" => "role:speaker", + "target" => %{"type" => "member", "id" => actor_uri}, + "state" => "assigned" + } + } + ) + + assert {:ok, _message} = + ChatMessages.create_text_message(channel.id, user.id, "federated role allows send") + + insert_extension_event( + channel.id, + "permission.overwrite.upsert", + "overwrite:deny-send:channel:#{channel.id}", + %{ + "overwrite" => %{ + "id" => "deny-send", + "target" => %{"type" => "member", "id" => actor_uri}, + "allow" => [], + "deny" => ["send_messages"] + } + } + ) + + assert {:error, :unauthorized} = + ChatMessages.create_text_message(channel.id, user.id, "blocked by overwrite") + end + end + + defp device_attrs(device_id) do + %{ + "device_id" => device_id, + "public_key" => %{ + "version" => 1, + "algorithm" => "RSA-OAEP-SHA256", + "key" => Base.encode64(:crypto.strong_rand_bytes(64)) + }, + "key_algorithm" => "RSA-OAEP-SHA256", + "label" => "test browser" + } + end + + defp encrypted_payload(key_uid) do + %{ + "version" => 1, + "content_algorithm" => "AES-256-GCM", + "key_uid" => key_uid, + "iv" => Base.encode64(:crypto.strong_rand_bytes(12)), + "ciphertext" => Base.encode64(:crypto.strong_rand_bytes(48)) + } + end + + defp key_package(user_id, device_id) do + %{ + user_id: user_id, + device_id: device_id, + wrapped_key: %{ + "version" => 1, + "key_algorithm" => "RSA-OAEP-SHA256", + "encrypted_key" => Base.encode64(:crypto.strong_rand_bytes(48)) + } + } + end + + defp mirrored_channel_fixture do + suffix = System.unique_integer([:positive]) + + server = + %Server{} + |> Server.changeset(%{ + name: "Remote Server #{suffix}", + description: "Federated mirror server", + federation_id: "https://remote.example/_arblarg/servers/#{suffix}", + origin_domain: "remote.example", + is_federated_mirror: true + }) + |> Repo.insert!() + + %ChatConversation{} + |> ChatConversation.channel_changeset(%{ + name: "remote-channel-#{suffix}", + description: "Mirrored remote channel", + server_id: server.id, + federated_source: "https://remote.example/_arblarg/channels/#{suffix}", + is_federated_mirror: true + }) + |> Repo.insert!() + end + + defp mirrored_local_message_fixture(conversation_id, sender_id) do + %ChatMessage{} + |> ChatMessage.changeset(%{ + conversation_id: conversation_id, + sender_id: sender_id, + content: "seed message", + message_type: "text" + }) + |> Repo.insert!() + end + + defp mirrored_member_fixture(conversation_id, user_id, role \\ "member") do + %ChatConversationMember{} + |> ChatConversationMember.changeset(%{ + conversation_id: conversation_id, + user_id: user_id, + role: role, + joined_at: DateTime.utc_now() + }) + |> Repo.insert!() + end + + defp insert_extension_event(conversation_id, event_type, event_key, payload) do + canonical_event_type = ArblargSDK.canonical_event_type(event_type) + + %FederationExtensionEvent{} + |> FederationExtensionEvent.changeset(%{ + event_type: canonical_event_type, + origin_domain: Federation.local_domain(), + event_key: event_key, + payload: payload, + occurred_at: DateTime.utc_now(), + conversation_id: conversation_id + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/messaging/chat_threads_test.exs b/apps/elektrine/test/elektrine/messaging/chat_threads_test.exs new file mode 100644 index 0000000..53ce5b0 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/chat_threads_test.exs @@ -0,0 +1,258 @@ +defmodule Elektrine.Messaging.ChatThreadsTest do + use Elektrine.DataCase, async: false + + alias Elektrine.AccountsFixtures + alias Elektrine.Messaging + alias Elektrine.Messaging.ChatMessage + alias Elektrine.Messaging.ChatThread + alias Elektrine.Messaging.ChatThreads + alias Elektrine.Repo + + defp create_server_channel_with_member do + owner = AccountsFixtures.user_fixture() + member = AccountsFixtures.user_fixture() + + {:ok, server} = Messaging.create_server(owner.id, %{name: "thread-space", is_public: true}) + {:ok, _member} = Messaging.join_server(server.id, member.id) + + [channel | _] = server.channels + + %{owner: owner, member: member, server: server, channel: channel} + end + + defp create_message!(conversation_id, sender_id, content) do + {:ok, message} = Messaging.create_chat_text_message(conversation_id, sender_id, content) + message + end + + defp insert_thread!(channel, creator_id, attrs \\ %{}) do + %ChatThread{} + |> ChatThread.changeset( + Map.merge( + %{ + conversation_id: channel.id, + creator_id: creator_id, + title: "manual thread", + last_activity_at: DateTime.utc_now() + }, + attrs + ) + ) + |> Repo.insert!() + end + + describe "create_thread_from_message/3" do + test "server owner creates a thread rooted at a message" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + message = create_message!(channel.id, member.id, "let's take this to a thread") + + Phoenix.PubSub.subscribe(Elektrine.PubSub, "chat:#{channel.id}") + + assert {:ok, thread} = ChatThreads.create_thread_from_message(message.id, owner.id) + assert thread.conversation_id == channel.id + assert thread.root_message_id == message.id + assert thread.creator_id == owner.id + assert thread.title == "let's take this to a thread" + refute thread.archived_at + assert %DateTime{} = thread.last_activity_at + + thread_id = thread.id + assert_receive {:thread_created, %ChatThread{id: ^thread_id}} + end + + test "uses the provided title over the message snippet" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + message = create_message!(channel.id, member.id, "original content") + + assert {:ok, thread} = + ChatThreads.create_thread_from_message(message.id, owner.id, %{ + title: "Custom title" + }) + + assert thread.title == "Custom title" + end + + test "regular members without create_threads cannot create threads" do + %{member: member, channel: channel} = create_server_channel_with_member() + message = create_message!(channel.id, member.id, "no thread for you") + + assert {:error, :unauthorized} = + ChatThreads.create_thread_from_message(message.id, member.id) + end + + test "a message can only root one thread" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + message = create_message!(channel.id, member.id, "root once") + + assert {:ok, _thread} = ChatThreads.create_thread_from_message(message.id, owner.id) + + assert {:error, :thread_exists} = + ChatThreads.create_thread_from_message(message.id, owner.id) + end + + test "threads are only available in channels" do + creator = AccountsFixtures.user_fixture() + buddy = AccountsFixtures.user_fixture() + + {:ok, group} = + Messaging.create_chat_group_conversation(creator.id, %{name: "thread group"}, [buddy.id]) + + message = create_message!(group.id, buddy.id, "group message") + + assert {:error, :unsupported_conversation_type} = + ChatThreads.create_thread_from_message(message.id, creator.id) + end + + test "unknown messages return not_found" do + %{owner: owner} = create_server_channel_with_member() + + assert {:error, :not_found} = ChatThreads.create_thread_from_message(-1, owner.id) + end + end + + describe "create_thread/3" do + test "creates a standalone thread without a root message" do + %{owner: owner, channel: channel} = create_server_channel_with_member() + + assert {:ok, thread} = ChatThreads.create_thread(channel.id, owner.id, %{title: "Planning"}) + assert thread.title == "Planning" + assert is_nil(thread.root_message_id) + end + end + + describe "archive_thread/2 and unarchive_thread/2" do + test "the thread creator can archive and unarchive their own thread" do + %{member: member, channel: channel} = create_server_channel_with_member() + thread = insert_thread!(channel, member.id) + + Phoenix.PubSub.subscribe(Elektrine.PubSub, "chat:#{channel.id}") + + assert {:ok, archived} = ChatThreads.archive_thread(thread.id, member.id) + assert %DateTime{} = archived.archived_at + + thread_id = thread.id + assert_receive {:thread_archived, %ChatThread{id: ^thread_id}} + + assert {:ok, unarchived} = ChatThreads.unarchive_thread(thread.id, member.id) + assert is_nil(unarchived.archived_at) + assert_receive {:thread_updated, %ChatThread{id: ^thread_id}} + end + + test "regular members cannot archive someone else's thread" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + thread = insert_thread!(channel, owner.id) + + assert {:error, :unauthorized} = ChatThreads.archive_thread(thread.id, member.id) + end + + test "server staff can archive member-created threads" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + thread = insert_thread!(channel, member.id) + + assert {:ok, archived} = ChatThreads.archive_thread(thread.id, owner.id) + assert %DateTime{} = archived.archived_at + end + + test "archiving twice returns already_archived" do + %{owner: owner, channel: channel} = create_server_channel_with_member() + thread = insert_thread!(channel, owner.id) + + assert {:ok, _} = ChatThreads.archive_thread(thread.id, owner.id) + assert {:error, :already_archived} = ChatThreads.archive_thread(thread.id, owner.id) + end + end + + describe "list_threads/2" do + test "filters by active and archived state" do + %{owner: owner, channel: channel} = create_server_channel_with_member() + + active = insert_thread!(channel, owner.id, %{title: "active thread"}) + archived = insert_thread!(channel, owner.id, %{title: "archived thread"}) + {:ok, _} = ChatThreads.archive_thread(archived.id, owner.id) + + assert Enum.map(ChatThreads.list_threads(channel.id), & &1.id) == [active.id] + assert Enum.map(ChatThreads.list_threads(channel.id, :archived), & &1.id) == [archived.id] + + all_ids = ChatThreads.list_threads(channel.id, :all) |> Enum.map(& &1.id) |> Enum.sort() + assert all_ids == Enum.sort([active.id, archived.id]) + end + end + + describe "thread messages" do + test "thread messages are excluded from the main timeline but listed in the thread" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + + root = create_message!(channel.id, member.id, "root message") + {:ok, thread} = ChatThreads.create_thread_from_message(root.id, owner.id) + + {:ok, reply_one} = ChatThreads.create_thread_message(thread.id, member.id, "first reply") + {:ok, reply_two} = ChatThreads.create_thread_message(thread.id, owner.id, "second reply") + plain = create_message!(channel.id, member.id, "regular timeline message") + + assert reply_one.thread_id == thread.id + + timeline_ids = + Messaging.get_chat_messages(channel.id, user_id: member.id) |> Enum.map(& &1.id) + + # The root message stays in the timeline; thread replies do not. + assert root.id in timeline_ids + assert plain.id in timeline_ids + refute reply_one.id in timeline_ids + refute reply_two.id in timeline_ids + + paginated = Messaging.get_conversation_messages(channel.id, member.id) + paginated_ids = Enum.map(paginated.messages, & &1.id) + refute reply_one.id in paginated_ids + assert root.id in paginated_ids + + assert Enum.map(ChatThreads.list_thread_messages(thread.id), & &1.id) == + [reply_one.id, reply_two.id] + end + + test "thread messages bump message_count and last_activity_at" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + {:ok, thread} = ChatThreads.create_thread(channel.id, owner.id, %{title: "counters"}) + + before_activity = thread.last_activity_at + + {:ok, message} = ChatThreads.create_thread_message(thread.id, member.id, "bump") + + updated = ChatThreads.get_thread(thread.id) + assert updated.message_count == 1 + assert DateTime.compare(updated.last_activity_at, before_activity) in [:gt, :eq] + + assert DateTime.compare( + updated.last_activity_at, + DateTime.from_naive!(message.inserted_at, "Etc/UTC") |> DateTime.truncate(:second) + ) == :eq + end + + test "thread messages broadcast as new_thread_message, not new_chat_message" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + {:ok, thread} = ChatThreads.create_thread(channel.id, owner.id, %{title: "broadcasts"}) + + Phoenix.PubSub.subscribe(Elektrine.PubSub, "chat:#{channel.id}") + + {:ok, message} = ChatThreads.create_thread_message(thread.id, member.id, "hello thread") + + message_id = message.id + assert_receive {:new_thread_message, %ChatMessage{id: ^message_id}} + refute_receive {:new_chat_message, %ChatMessage{id: ^message_id}}, 100 + end + + test "archived threads reject new messages" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + {:ok, thread} = ChatThreads.create_thread(channel.id, owner.id, %{title: "closed"}) + {:ok, _} = ChatThreads.archive_thread(thread.id, owner.id) + + assert {:error, :thread_archived} = + ChatThreads.create_thread_message(thread.id, member.id, "too late") + end + + test "unknown threads return not_found" do + %{member: member} = create_server_channel_with_member() + + assert {:error, :not_found} = ChatThreads.create_thread_message(-1, member.id, "nope") + end + end +end diff --git a/apps/elektrine/test/elektrine/messaging/chat_webhooks_test.exs b/apps/elektrine/test/elektrine/messaging/chat_webhooks_test.exs new file mode 100644 index 0000000..99f49dc --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/chat_webhooks_test.exs @@ -0,0 +1,289 @@ +defmodule Elektrine.Messaging.ChatWebhooksTest do + use Elektrine.DataCase, async: false + + alias Elektrine.AccountsFixtures + alias Elektrine.Messaging + alias Elektrine.Messaging.ChatMessage + alias Elektrine.Messaging.ChatWebhook + alias Elektrine.Messaging.ChatWebhooks + alias Elektrine.Messaging.RateLimiter + alias Elektrine.Repo + + defp create_server_channel_with_member do + owner = AccountsFixtures.user_fixture() + member = AccountsFixtures.user_fixture() + + {:ok, server} = Messaging.create_server(owner.id, %{name: "hook-space", is_public: true}) + {:ok, _member} = Messaging.join_server(server.id, member.id) + + [channel | _] = server.channels + + %{owner: owner, member: member, server: server, channel: channel} + end + + defp create_webhook!(conversation_id, user_id, attrs \\ %{}) do + {:ok, webhook} = + ChatWebhooks.create_webhook( + conversation_id, + user_id, + Map.merge(%{"name" => "Deploy Bot"}, attrs) + ) + + webhook + end + + describe "create_webhook/3" do + test "server owner can create a webhook and receives the plaintext token once" do + %{owner: owner, channel: channel} = create_server_channel_with_member() + + assert {:ok, webhook} = + ChatWebhooks.create_webhook(channel.id, owner.id, %{"name" => "Deploy Bot"}) + + assert webhook.name == "Deploy Bot" + assert webhook.active + assert webhook.creator_id == owner.id + assert is_binary(webhook.token) + assert String.starts_with?(webhook.token, "ewh_") + + # The token is stored only as a hash; the plaintext is not retrievable. + reloaded = Repo.get(ChatWebhook, webhook.id) + assert reloaded.token == nil + assert reloaded.token_hash == ChatWebhook.hash_token(webhook.token) + refute reloaded.token_hash == webhook.token + end + + test "regular channel members cannot create webhooks" do + %{member: member, channel: channel} = create_server_channel_with_member() + + assert {:error, :unauthorized} = + ChatWebhooks.create_webhook(channel.id, member.id, %{"name" => "Nope"}) + end + + test "group owners can create webhooks, group members cannot" do + creator = AccountsFixtures.user_fixture() + buddy = AccountsFixtures.user_fixture() + + {:ok, group} = + Messaging.create_chat_group_conversation(creator.id, %{name: "hook group"}, [buddy.id]) + + assert {:error, :unauthorized} = + ChatWebhooks.create_webhook(group.id, buddy.id, %{"name" => "Nope"}) + + assert {:ok, webhook} = + ChatWebhooks.create_webhook(group.id, creator.id, %{"name" => "Group Bot"}) + + assert webhook.conversation_id == group.id + end + + test "webhooks cannot be created on DMs" do + user_a = AccountsFixtures.user_fixture() + user_b = AccountsFixtures.user_fixture() + + {:ok, dm} = Messaging.create_dm_conversation(user_a.id, user_b.id) + + assert {:error, :unsupported_conversation} = + ChatWebhooks.create_webhook(dm.id, user_a.id, %{"name" => "Nope"}) + end + + test "enforces the per-conversation webhook cap" do + %{owner: owner, channel: channel} = create_server_channel_with_member() + + for index <- 1..ChatWebhooks.max_webhooks_per_conversation() do + create_webhook!(channel.id, owner.id, %{"name" => "hook #{index}"}) + end + + assert {:error, :webhook_limit_reached} = + ChatWebhooks.create_webhook(channel.id, owner.id, %{"name" => "one too many"}) + end + end + + describe "management" do + test "list_webhooks requires manage_webhooks" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + + webhook = create_webhook!(channel.id, owner.id) + + assert {:ok, [listed]} = ChatWebhooks.list_webhooks(channel.id, owner.id) + assert listed.id == webhook.id + assert listed.token == nil + + assert {:error, :unauthorized} = ChatWebhooks.list_webhooks(channel.id, member.id) + end + + test "update_webhook renames and updates avatar" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + + webhook = create_webhook!(channel.id, owner.id) + + assert {:error, :unauthorized} = + ChatWebhooks.update_webhook(webhook.id, member.id, %{"name" => "Hijack"}) + + assert {:ok, updated} = + ChatWebhooks.update_webhook(webhook.id, owner.id, %{ + "name" => "Renamed Bot", + "avatar_url" => "https://i.imgur.com/bot.png" + }) + + assert updated.name == "Renamed Bot" + assert updated.avatar_url == "https://i.imgur.com/bot.png" + end + + test "rotate_webhook_token invalidates the old token" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + + webhook = create_webhook!(channel.id, owner.id) + old_token = webhook.token + + assert {:error, :unauthorized} = ChatWebhooks.rotate_webhook_token(webhook.id, member.id) + + assert {:ok, rotated} = ChatWebhooks.rotate_webhook_token(webhook.id, owner.id) + assert is_binary(rotated.token) + refute rotated.token == old_token + + assert {:error, :not_found} = + ChatWebhooks.execute_webhook(webhook.id, old_token, %{"content" => "hi"}) + + assert {:ok, _message} = + ChatWebhooks.execute_webhook(webhook.id, rotated.token, %{"content" => "hi"}) + end + + test "deactivate and delete require manage_webhooks" do + %{owner: owner, member: member, channel: channel} = create_server_channel_with_member() + + webhook = create_webhook!(channel.id, owner.id) + + assert {:error, :unauthorized} = ChatWebhooks.deactivate_webhook(webhook.id, member.id) + assert {:error, :unauthorized} = ChatWebhooks.delete_webhook(webhook.id, member.id) + + assert {:ok, deactivated} = ChatWebhooks.deactivate_webhook(webhook.id, owner.id) + refute deactivated.active + + assert {:ok, _deleted} = ChatWebhooks.delete_webhook(webhook.id, owner.id) + refute Repo.get(ChatWebhook, webhook.id) + end + end + + describe "execute_webhook/3" do + test "posts a message with webhook display metadata and broadcasts it" do + %{owner: owner, channel: channel} = create_server_channel_with_member() + + webhook = + create_webhook!(channel.id, owner.id, %{ + "name" => "Deploy Bot", + "avatar_url" => "https://i.imgur.com/bot.png" + }) + + Phoenix.PubSub.subscribe(Elektrine.PubSub, "chat:#{channel.id}") + + assert {:ok, message} = + ChatWebhooks.execute_webhook(webhook.id, webhook.token, %{ + "content" => "build passed" + }) + + assert message.conversation_id == channel.id + assert message.sender_id == nil + assert message.webhook_id == webhook.id + assert message.content == "build passed" + assert message.media_metadata["webhook_sender"]["name"] == "Deploy Bot" + + # Hydrated sender renders like a user and carries badge flags. + assert message.sender.username == "Deploy Bot" + assert message.sender.avatar == "https://i.imgur.com/bot.png" + assert message.sender.webhook == true + assert message.sender.is_bot == true + + message_id = message.id + assert_receive {:new_chat_message, %ChatMessage{id: ^message_id} = broadcast} + assert broadcast.sender.username == "Deploy Bot" + assert broadcast.sender.webhook == true + end + + test "applies username and avatar overrides" do + %{owner: owner, channel: channel} = create_server_channel_with_member() + + webhook = create_webhook!(channel.id, owner.id) + + assert {:ok, message} = + ChatWebhooks.execute_webhook(webhook.id, webhook.token, %{ + "content" => "override me", + "username" => "Release Notes", + "avatar_url" => "https://i.imgur.com/other.png" + }) + + assert message.media_metadata["webhook_sender"]["name"] == "Release Notes" + + assert message.media_metadata["webhook_sender"]["avatar_url"] == + "https://i.imgur.com/other.png" + + assert message.sender.username == "Release Notes" + assert message.sender.avatar == "https://i.imgur.com/other.png" + end + + test "rejects oversize overrides" do + %{owner: owner, channel: channel} = create_server_channel_with_member() + + webhook = create_webhook!(channel.id, owner.id) + + assert {:error, :invalid_override} = + ChatWebhooks.execute_webhook(webhook.id, webhook.token, %{ + "content" => "hello", + "username" => String.duplicate("x", 81) + }) + end + + test "returns not_found for unknown ids and bad tokens alike" do + %{owner: owner, channel: channel} = create_server_channel_with_member() + + webhook = create_webhook!(channel.id, owner.id) + + assert {:error, :not_found} = + ChatWebhooks.execute_webhook(webhook.id + 1_000_000, webhook.token, %{ + "content" => "hi" + }) + + assert {:error, :not_found} = + ChatWebhooks.execute_webhook(webhook.id, "ewh_wrong-token", %{"content" => "hi"}) + + assert {:error, :not_found} = + ChatWebhooks.execute_webhook(webhook.id, nil, %{"content" => "hi"}) + end + + test "rejects inactive webhooks" do + %{owner: owner, channel: channel} = create_server_channel_with_member() + + webhook = create_webhook!(channel.id, owner.id) + {:ok, _} = ChatWebhooks.deactivate_webhook(webhook.id, owner.id) + + assert {:error, :webhook_inactive} = + ChatWebhooks.execute_webhook(webhook.id, webhook.token, %{"content" => "hi"}) + end + + test "rejects empty and oversize content" do + %{owner: owner, channel: channel} = create_server_channel_with_member() + + webhook = create_webhook!(channel.id, owner.id) + + assert {:error, :invalid_content} = + ChatWebhooks.execute_webhook(webhook.id, webhook.token, %{"content" => " "}) + + assert {:error, :invalid_content} = + ChatWebhooks.execute_webhook(webhook.id, webhook.token, %{}) + + assert {:error, :invalid_content} = + ChatWebhooks.execute_webhook(webhook.id, webhook.token, %{ + "content" => String.duplicate("a", 4001) + }) + end + + test "rate limits executions per webhook" do + %{owner: owner, channel: channel} = create_server_channel_with_member() + + webhook = create_webhook!(channel.id, owner.id) + + for _ <- 1..30, do: RateLimiter.record_webhook_execution(webhook.id) + + assert {:error, :rate_limited} = + ChatWebhooks.execute_webhook(webhook.id, webhook.token, %{"content" => "hi"}) + end + end +end diff --git a/apps/elektrine/test/elektrine/messaging/custom_domain_aliases_test.exs b/apps/elektrine/test/elektrine/messaging/custom_domain_aliases_test.exs new file mode 100644 index 0000000..9093771 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/custom_domain_aliases_test.exs @@ -0,0 +1,140 @@ +defmodule Elektrine.Messaging.CustomDomainAliasesTest do + use Elektrine.DataCase, async: false + + import Ecto.Query + import Elektrine.AccountsFixtures + + alias Elektrine.Messaging + alias Elektrine.Messaging.ChatConversation + alias Elektrine.Messaging.ChatConversationMember + alias Elektrine.Messaging.Federation.Builders + alias Elektrine.Messaging.Federation.DirectMessageState + alias Elektrine.Messaging.Federation.Utils + alias Elektrine.Profiles + alias Elektrine.Repo + + test "build_dm_message_created_event uses the sender's verified custom domain" do + sender = user_fixture(%{username: "dmcustomsender"}) + custom_domain = verified_profile_custom_domain_fixture(sender, "dmcustomsender.test") + + conversation = + %ChatConversation{} + |> ChatConversation.dm_changeset(%{ + creator_id: sender.id, + name: "@alice@remote.example", + federated_source: "arblarg:dm:handle:alice@remote.example" + }) + |> Repo.insert!() + + conversation.id + |> ChatConversationMember.add_member_changeset(sender.id) + |> Repo.insert!() + + assert {:ok, message} = + Messaging.create_chat_text_message(conversation.id, sender.id, "hello custom domain") + + assert {:ok, event} = + Builders.build_dm_message_created_event( + message, + "alice@remote.example", + builder_context() + ) + + assert event["origin_domain"] == custom_domain.domain + + assert event["stream_id"] == + "dm:https://#{custom_domain.domain}/_arblarg/dms/#{conversation.id}" + + assert get_in(event, ["payload", "dm", "id"]) == + "https://#{custom_domain.domain}/_arblarg/dms/#{conversation.id}" + + assert get_in(event, ["payload", "message", "id"]) == + "https://#{custom_domain.domain}/_arblarg/messages/#{message.id}" + + assert get_in(event, ["payload", "dm", "sender", "handle"]) == + "#{sender.username}@#{custom_domain.domain}" + + assert get_in(event, ["payload", "dm", "sender", "uri"]) == + "https://#{custom_domain.domain}/users/#{sender.username}" + end + + test "create_remote_dm_conversation treats a verified custom profile domain as local" do + sender = user_fixture(%{username: "localdmsender"}) + recipient = user_fixture(%{username: "localdmrecipient"}) + custom_domain = verified_profile_custom_domain_fixture(recipient, "localdmalias.test") + + assert {:ok, conversation} = + Messaging.create_remote_dm_conversation( + sender.id, + "#{recipient.username}@#{custom_domain.domain}" + ) + + member_ids = + from(cm in ChatConversationMember, + where: cm.conversation_id == ^conversation.id and is_nil(cm.left_at), + select: cm.user_id + ) + |> Repo.all() + |> Enum.sort() + + assert conversation.type == "dm" + assert member_ids == Enum.sort([sender.id, recipient.id]) + end + + test "resolve_local_dm_recipient accepts verified custom profile domains" do + user = user_fixture(%{username: "inboundcustomdm"}) + custom_domain = verified_profile_custom_domain_fixture(user, "inboundcustomdm.test") + + payload = %{ + "id" => "https://#{custom_domain.domain}/users/#{user.username}", + "uri" => "https://#{custom_domain.domain}/users/#{user.username}", + "username" => user.username, + "domain" => custom_domain.domain, + "handle" => "#{user.username}@#{custom_domain.domain}" + } + + assert {:ok, resolved_user} = + DirectMessageState.resolve_local_dm_recipient(payload, %{ + local_domain: &Elektrine.Messaging.Federation.Runtime.local_domain/0 + }) + + assert resolved_user.id == user.id + assert Utils.preferred_dm_origin_domain_for_user(user) == custom_domain.domain + end + + defp builder_context do + %{ + local_domain: &Elektrine.Messaging.Federation.Runtime.local_domain/0, + local_event_signing_material: + &Elektrine.Messaging.Federation.Runtime.local_event_signing_material/0, + outgoing_peers: fn -> [] end, + maybe_iso8601: fn + %DateTime{} = datetime -> DateTime.to_iso8601(datetime) + nil -> nil + _ -> nil + end, + normalize_optional_string: fn + value when is_binary(value) -> + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + + _ -> + nil + end, + presence_ttl_seconds: fn -> 60 end + } + end + + defp verified_profile_custom_domain_fixture(user, domain) do + {:ok, custom_domain} = Profiles.create_custom_domain(user, %{"domain" => domain}) + + now = DateTime.utc_now() |> DateTime.truncate(:second) + + from(cd in Elektrine.Profiles.CustomDomain, where: cd.id == ^custom_domain.id) + |> Repo.update_all(set: [status: "verified", verified_at: now, last_checked_at: now]) + + Profiles.get_verified_custom_domain(domain) + end +end diff --git a/apps/elektrine/test/elektrine/messaging/federated_message_normalization_test.exs b/apps/elektrine/test/elektrine/messaging/federated_message_normalization_test.exs new file mode 100644 index 0000000..0aecd1a --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/federated_message_normalization_test.exs @@ -0,0 +1,179 @@ +defmodule Elektrine.Messaging.FederatedMessageNormalizationTest do + use Elektrine.DataCase, async: true + + if not Code.ensure_loaded?(Elektrine.Social.Hashtag) do + @moduletag skip: "requires :elektrine_social" + end + + alias Elektrine.ActivityPub.Actor + alias Elektrine.Messaging + alias Elektrine.Social.Message + + describe "create_federated_message/1 normalization" do + test "truncates titles longer than varchar limit" do + actor = remote_actor_fixture() + long_title = String.duplicate("T", 320) + + assert {:ok, message} = + actor + |> federated_attrs(%{title: long_title}) + |> Messaging.create_federated_message() + + assert String.length(message.title) == 255 + assert message.title == String.slice(long_title, 0, 255) + end + + test "drops media URLs that exceed varchar limit" do + actor = remote_actor_fixture() + short_url = "https://remote.example/media/image.jpg" + long_url = "https://remote.example/media/" <> String.duplicate("a", 280) + + assert {:ok, message} = + actor + |> federated_attrs(%{media_urls: [short_url, long_url]}) + |> Messaging.create_federated_message() + + assert message.media_urls == [short_url] + end + + test "coerces structured activitypub_url values before casting" do + actor = remote_actor_fixture() + + canonical_url = + "https://bsky.brid.gy/convert/ap/at://did:plc:pak3r4f5v4zu4jzo772y2iep/app.bsky.feed.post/3mjdqze7vpc2d" + + assert {:ok, message} = + actor + |> federated_attrs(%{ + activitypub_url: [ + %{"type" => "Link", "href" => canonical_url} + ] + }) + |> Messaging.create_federated_message() + + assert message.activitypub_url == canonical_url + end + + test "drops unsafe structured activitypub_url values before storage" do + actor = remote_actor_fixture() + + assert {:ok, message} = + actor + |> federated_attrs(%{ + activitypub_url: [ + %{"type" => "Link", "href" => "https://example.com@evil.test/path"} + ] + }) + |> Messaging.create_federated_message() + + assert message.activitypub_url == nil + end + + test "preserves federated vote counters" do + actor = remote_actor_fixture() + + assert {:ok, message} = + actor + |> federated_attrs(%{ + like_count: 7, + upvotes: 7, + downvotes: 2, + score: 5 + }) + |> Messaging.create_federated_message() + + assert message.like_count == 7 + assert message.upvotes == 7 + assert message.downvotes == 2 + assert message.score == 5 + end + end + + describe "federated_changeset/2 normalization on updates" do + test "truncates refreshed titles and drops oversized media URLs" do + actor = remote_actor_fixture() + long_title = String.duplicate("T", 320) + short_url = "https://remote.example/media/image.jpg" + long_url = "https://remote.example/media/" <> String.duplicate("a", 280) + + assert {:ok, message} = + actor + |> federated_attrs(%{title: nil, media_urls: []}) + |> Messaging.create_federated_message() + + changeset = + Message.federated_changeset(message, %{ + title: long_title, + media_urls: [short_url, long_url] + }) + + assert changeset.valid? + assert Ecto.Changeset.get_change(changeset, :title) == String.slice(long_title, 0, 255) + assert Ecto.Changeset.get_change(changeset, :media_urls) == [short_url] + end + + test "coerces structured activitypub_url values on updates" do + actor = remote_actor_fixture() + canonical_url = "https://remote.example/notes/updated-url" + + assert {:ok, message} = + actor + |> federated_attrs(%{activitypub_url: nil}) + |> Messaging.create_federated_message() + + changeset = + Message.federated_changeset(message, %{ + activitypub_url: [%{"href" => canonical_url}] + }) + + assert changeset.valid? + assert Ecto.Changeset.get_change(changeset, :activitypub_url) == canonical_url + end + + test "drops unsafe activitypub_url values on updates" do + actor = remote_actor_fixture() + + assert {:ok, message} = + actor + |> federated_attrs(%{activitypub_url: nil}) + |> Messaging.create_federated_message() + + changeset = + Message.federated_changeset(message, %{ + activitypub_url: [%{"href" => "javascript:alert(1)"}] + }) + + assert changeset.valid? + assert Ecto.Changeset.get_change(changeset, :activitypub_url) == nil + end + end + + defp federated_attrs(actor, overrides) do + unique = System.unique_integer([:positive]) + + defaults = %{ + content: "federated content", + visibility: "public", + activitypub_id: "https://remote.example/notes/#{unique}", + activitypub_url: "https://remote.example/@alice/#{unique}", + remote_actor_id: actor.id, + media_urls: [] + } + + Map.merge(defaults, overrides) + end + + defp remote_actor_fixture do + unique = System.unique_integer([:positive]) + + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.example/users/alice-#{unique}", + username: "alice#{unique}", + domain: "remote.example", + inbox_url: "https://remote.example/inbox", + public_key: "-----BEGIN PUBLIC KEY-----test-key-----END PUBLIC KEY-----" + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/messaging/federation/config_test.exs b/apps/elektrine/test/elektrine/messaging/federation/config_test.exs new file mode 100644 index 0000000..1a10868 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/federation/config_test.exs @@ -0,0 +1,29 @@ +defmodule Elektrine.Messaging.Federation.ConfigTest do + use ExUnit.Case, async: true + + alias Elektrine.Messaging.Federation.Config + + describe "outbound_session_websocket_url/2" do + test "rejects explicit private websocket endpoints" do + peer = %{ + session_websocket_endpoint: "wss://10.0.0.1/_arblarg/session", + base_url: "https://remote.example" + } + + assert Config.outbound_session_websocket_url(peer, false) == nil + end + + test "rejects private websocket endpoints derived from base_url" do + peer = %{base_url: "https://10.0.0.1"} + + assert Config.outbound_session_websocket_url(peer, false) == nil + end + + test "does not allow plaintext websocket endpoints unless insecure transport is enabled" do + peer = %{session_websocket_endpoint: "ws://127.0.0.1:49152/_arblarg/session"} + + assert Config.outbound_session_websocket_url(peer, false) == nil + assert Config.outbound_session_websocket_url(peer, true) == peer.session_websocket_endpoint + end + end +end diff --git a/apps/elektrine/test/elektrine/messaging/federation_dm_test.exs b/apps/elektrine/test/elektrine/messaging/federation_dm_test.exs new file mode 100644 index 0000000..d5e5439 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/federation_dm_test.exs @@ -0,0 +1,405 @@ +defmodule Elektrine.Messaging.FederationDMTest do + use Elektrine.DataCase, async: false + + import Ecto.Query + + alias Elektrine.AccountsFixtures + alias Elektrine.Messaging + + alias Elektrine.Messaging.{ + ArblargSDK, + ChatMessage, + Federation + } + + alias Elektrine.Messaging.ChatConversation, as: Conversation + alias Elektrine.Messaging.ChatConversationMember, as: ConversationMember + alias Elektrine.Repo + + describe "cross-instance DMs (Arblarg)" do + setup do + previous = Application.get_env(:elektrine, :messaging_federation, []) + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.merge(previous, + enabled: true, + peers: [ + %{ + domain: "remote.example", + base_url: "https://remote.example", + shared_secret: "dm-secret", + allow_incoming: true, + allow_outgoing: true, + supported_event_types: [ArblargSDK.dm_message_create_event_type()] + } + ] + ) + ) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, previous) + end) + + :ok + end + + test "publishes dm.message.create to only the target domain" do + dm_event_type = ArblargSDK.dm_message_create_event_type() + message = %ChatMessage{id: 1} + parent = self() + + context = %{ + enabled?: fn -> true end, + resolve_outbound_dm_handle: fn ^message, _remote_handle -> + {:ok, "alice@remote.example"} + end, + normalize_remote_dm_handle: fn + "alice@remote.example" -> + {:ok, %{handle: "alice@remote.example", domain: "remote.example"}} + + _handle -> + {:error, :invalid_handle} + end, + outgoing_peer: fn + "remote.example" -> + %{ + domain: "remote.example", + supported_event_types: [dm_event_type], + features: %{"supported_event_types" => [dm_event_type]} + } + + _domain -> + nil + end, + build_dm_message_created_event: fn ^message, "alice@remote.example" -> + {:ok, + %{ + "event_id" => "evt-1", + "event_type" => dm_event_type, + "payload" => %{ + "dm" => %{"recipient" => %{"handle" => "alice@remote.example"}} + } + }} + end, + enqueue_outbox_event: fn event, target_domains -> + send(parent, {:enqueue_dm_event, event, target_domains}) + :ok + end + } + + assert :ok = + Elektrine.Messaging.Federation.Publisher.publish_dm_message_created( + message, + nil, + context + ) + + assert_receive {:enqueue_dm_event, event, ["remote.example"]} + assert event["event_type"] == dm_event_type + assert get_in(event, ["payload", "dm", "recipient", "handle"]) == "alice@remote.example" + end + + test "applies inbound dm.message.create and creates a federated DM conversation" do + recipient = AccountsFixtures.user_fixture() + remote_domain = "remote.example" + local_domain = Federation.local_domain() + dm_id = "https://remote.example/_arblarg/dms/alice-#{recipient.id}" + stream_id = "dm:#{dm_id}" + + message_federation_id = + "https://remote.example/_arblarg/messages/#{Ecto.UUID.generate()}" + + event = + signed_event( + ArblargSDK.dm_message_create_event_type(), + remote_domain, + stream_id, + 1, + %{ + "dm" => %{ + "id" => dm_id, + "sender" => + canonical_actor("alice", remote_domain, + display_name: "Alice Remote", + uri: "https://remote.example/users/alice" + ), + "recipient" => + canonical_actor(recipient.username, local_domain, + uri: "https://#{local_domain}/users/#{recipient.username}" + ) + }, + "message" => %{ + "id" => message_federation_id, + "dm_id" => dm_id, + "content" => "hi from remote", + "message_type" => "text", + "media_urls" => [], + "media_metadata" => %{}, + "sender" => + canonical_actor("alice", remote_domain, + display_name: "Alice Remote", + uri: "https://remote.example/users/alice" + ) + } + }, + secret: "dm-secret" + ) + + assert {:ok, :applied} = Federation.receive_event(event, remote_domain) + + assert remote_dm_conversation = + Repo.one( + from(c in Conversation, + where: c.type == "dm", + where: like(c.federated_source, "arblarg:dm:%"), + limit: 1 + ) + ) + + assert Messaging.remote_dm_handle(remote_dm_conversation) == "alice@remote.example" + + assert Repo.get_by(ConversationMember, + conversation_id: remote_dm_conversation.id, + user_id: recipient.id + ) + + assert inbound_message = + Repo.get_by(ChatMessage, + conversation_id: remote_dm_conversation.id, + federated_source: message_federation_id + ) + + assert inbound_message.content == "hi from remote" + assert inbound_message.origin_domain == remote_domain + + assert get_in(inbound_message.media_metadata, ["remote_sender", "handle"]) == + "alice@remote.example" + end + + test "builds optional E2EE fields and local device discovery into outbound dm.message.create" do + sender = AccountsFixtures.user_fixture() + remote_handle = "alice@remote.example" + + {:ok, _device} = + Messaging.register_chat_encryption_device(sender.id, chat_device_attrs("sender-device")) + + conversation = + %Conversation{} + |> Conversation.dm_changeset(%{ + creator_id: sender.id, + federated_source: + Elektrine.Messaging.Federation.DirectMessageState.remote_dm_source(remote_handle) + }) + |> Repo.insert!() + + conversation.id + |> ConversationMember.add_member_changeset(sender.id) + |> Repo.insert!() + + encrypted_payload = federated_encrypted_payload("key-test-123456") + + message = + %ChatMessage{} + |> ChatMessage.changeset(%{ + conversation_id: conversation.id, + sender_id: sender.id, + message_type: "text", + client_encrypted_payload: encrypted_payload + }) + |> Repo.insert!() + + assert {:ok, event} = + Elektrine.Messaging.Federation.Builders.build_dm_message_created_event( + message, + remote_handle, + builder_context() + ) + + assert event["event_type"] == ArblargSDK.dm_message_create_event_type() + assert get_in(event, ["payload", "message", "content"]) == "" + + assert get_in(event, ["payload", "message", "client_encrypted_payload"]) == + encrypted_payload + + assert [device] = get_in(event, ["payload", "dm", "sender", "chat_encryption_devices"]) + assert device["device_id"] == "sender-device" + end + + test "stores inbound optional E2EE payloads and advertised remote devices" do + recipient = AccountsFixtures.user_fixture() + remote_domain = "remote.example" + local_domain = Federation.local_domain() + dm_id = "https://remote.example/_arblarg/dms/encrypted-#{recipient.id}" + stream_id = "dm:#{dm_id}" + encrypted_payload = federated_encrypted_payload("key-test-123456") + + remote_actor = + canonical_actor("alice", remote_domain, + display_name: "Alice Remote", + uri: "https://remote.example/users/alice" + ) + |> Map.put("chat_encryption_devices", [chat_device_payload("remote-device")]) + + event = + signed_event( + ArblargSDK.dm_message_create_event_type(), + remote_domain, + stream_id, + 1, + %{ + "dm" => %{ + "id" => dm_id, + "sender" => remote_actor, + "recipient" => + canonical_actor(recipient.username, local_domain, + uri: "https://#{local_domain}/users/#{recipient.username}" + ) + }, + "message" => %{ + "id" => "https://remote.example/_arblarg/messages/#{Ecto.UUID.generate()}", + "dm_id" => dm_id, + "content" => "", + "message_type" => "text", + "client_encrypted_payload" => encrypted_payload, + "sender" => remote_actor + } + }, + secret: "dm-secret" + ) + + assert {:ok, :applied} = Federation.receive_event(event, remote_domain) + + remote_dm_conversation = + Repo.one!( + from(c in Conversation, + where: c.type == "dm", + where: like(c.federated_source, "arblarg:dm:%"), + order_by: [desc: c.id], + limit: 1 + ) + ) + + inbound_message = + Repo.get_by!(ChatMessage, + conversation_id: remote_dm_conversation.id, + client_encrypted_payload: encrypted_payload + ) + + assert inbound_message.content == nil + assert inbound_message.client_encrypted_payload == encrypted_payload + + assert [remote_device] = + Messaging.list_chat_encryption_devices_for_conversation(remote_dm_conversation.id) + + assert remote_device.device_id == "remote-device" + assert remote_device.recipient_handle == "alice@remote.example" + end + end + + defp chat_device_attrs(device_id) do + %{ + "device_id" => device_id, + "public_key" => %{ + "version" => 1, + "algorithm" => "RSA-OAEP-SHA256", + "key" => Base.encode64(:crypto.strong_rand_bytes(64)) + }, + "key_algorithm" => "RSA-OAEP-SHA256", + "label" => "test browser" + } + end + + defp chat_device_payload(device_id) do + chat_device_attrs(device_id) + |> Map.take(["device_id", "public_key", "key_algorithm", "label"]) + end + + defp federated_encrypted_payload(key_uid) do + %{ + "version" => 1, + "content_algorithm" => "AES-256-GCM", + "key_uid" => key_uid, + "iv" => Base.encode64(:crypto.strong_rand_bytes(12)), + "ciphertext" => Base.encode64(:crypto.strong_rand_bytes(48)), + "federated_key_packages" => [ + %{ + "recipient_handle" => "alice@remote.example", + "device_id" => "remote-device", + "wrapped_key" => %{ + "version" => 1, + "key_algorithm" => "RSA-OAEP-SHA256", + "encrypted_key" => Base.encode64(:crypto.strong_rand_bytes(48)) + } + } + ] + } + end + + defp builder_context do + %{ + local_domain: &Elektrine.Messaging.Federation.Runtime.local_domain/0, + local_event_signing_material: + &Elektrine.Messaging.Federation.Runtime.local_event_signing_material/0, + outgoing_peers: &Federation.outgoing_peers/0, + maybe_iso8601: fn + %DateTime{} = datetime -> DateTime.to_iso8601(datetime) + nil -> nil + _ -> nil + end, + normalize_optional_string: fn + value when is_binary(value) -> + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + + _ -> + nil + end, + presence_ttl_seconds: fn -> 60 end + } + end + + defp signed_event(event_type, remote_domain, stream_id, sequence, payload, opts) do + key_id = Keyword.get(opts, :key_id, "k1") + secret = Keyword.get(opts, :secret, "test-shared-secret") + + unsigned = %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => ArblargSDK.protocol_id(), + "protocol_label" => ArblargSDK.protocol_label(), + "protocol_version" => ArblargSDK.protocol_version(), + "event_id" => Keyword.get(opts, :event_id, "evt-#{Ecto.UUID.generate()}"), + "event_type" => event_type, + "origin_domain" => remote_domain, + "stream_id" => stream_id, + "sequence" => sequence, + "sent_at" => Keyword.get(opts, :sent_at, DateTime.utc_now() |> DateTime.to_iso8601()), + "idempotency_key" => Keyword.get(opts, :idempotency_key, "idem-#{Ecto.UUID.generate()}"), + "payload" => payload + } + + ArblargSDK.sign_event_envelope(unsigned, key_id, secret) + end + + defp canonical_actor(username, domain, opts) do + uri = + Keyword.get(opts, :uri) || + "https://#{domain}/users/#{username}" + + {public_key, _private_key} = ArblargSDK.derive_keypair_from_secret("actor:#{uri}") + + %{ + "id" => uri, + "uri" => uri, + "username" => username, + "display_name" => Keyword.get(opts, :display_name, username), + "domain" => domain, + "handle" => "#{username}@#{domain}", + "key_id" => "#{uri}#arblarg-key", + "public_key" => Base.url_encode64(public_key, padding: false) + } + end +end diff --git a/apps/elektrine/test/elektrine/messaging/federation_errors_test.exs b/apps/elektrine/test/elektrine/messaging/federation_errors_test.exs new file mode 100644 index 0000000..9b148f6 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/federation_errors_test.exs @@ -0,0 +1,16 @@ +defmodule Elektrine.Messaging.FederationErrorsTest do + use ExUnit.Case, async: true + + alias Elektrine.Messaging.Federation.Errors + + test "maps expanded federation policy errors" do + assert Errors.error_code(:rate_limited) == "rate_limited" + assert Errors.error_code(:peer_quarantined) == "peer_quarantined" + assert Errors.error_code(:peer_blocked) == "peer_blocked" + assert Errors.error_code(:event_too_large) == "event_too_large" + assert Errors.error_code(:batch_too_large) == "batch_too_large" + assert Errors.error_code(:snapshot_too_large) == "snapshot_too_large" + assert Errors.error_code(:cursor_expired) == "cursor_expired" + assert Errors.error_code(:media_not_authorized) == "media_not_authorized" + end +end diff --git a/apps/elektrine/test/elektrine/messaging/federation_ingress_rate_limiter_test.exs b/apps/elektrine/test/elektrine/messaging/federation_ingress_rate_limiter_test.exs new file mode 100644 index 0000000..2b1bde0 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/federation_ingress_rate_limiter_test.exs @@ -0,0 +1,184 @@ +defmodule Elektrine.Messaging.FederationIngressRateLimiterTest do + use ExUnit.Case, async: false + + alias Elektrine.Messaging.Federation.IngressRateLimiter + + @now 1_751_623_260 + + setup do + original_config = Application.get_env(:elektrine, :messaging_federation, []) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, original_config) + end) + + IngressRateLimiter.reset() + {:ok, domain: "peer-#{System.unique_integer([:positive])}.test"} + end + + defp put_limits(overrides) do + config = + Application.get_env(:elektrine, :messaging_federation, []) + |> Keyword.merge(overrides) + + Application.put_env(:elektrine, :messaging_federation, config) + end + + test "peer lane requests under the limit pass", %{domain: domain} do + put_limits(ingress_peer_sync_requests_per_minute: 3) + + for _ <- 1..3 do + assert :ok = IngressRateLimiter.check_peer(domain, :sync, 1, now: @now) + end + end + + test "peer lane requests over the limit return rate_limited with retry-after", %{ + domain: domain + } do + put_limits(ingress_peer_sync_requests_per_minute: 2) + + assert :ok = IngressRateLimiter.check_peer(domain, :sync, 1, now: @now) + assert :ok = IngressRateLimiter.check_peer(domain, :sync, 1, now: @now) + + assert {:error, :rate_limited, retry_after} = + IngressRateLimiter.check_peer(domain, :sync, 1, now: @now) + + assert retry_after == 60 - rem(@now, 60) + assert retry_after in 1..60 + end + + test "peer lanes use independent buckets", %{domain: domain} do + put_limits( + ingress_peer_sync_requests_per_minute: 1, + ingress_peer_replay_requests_per_minute: 1, + ingress_peer_ephemeral_items_per_minute: 1 + ) + + assert :ok = IngressRateLimiter.check_peer(domain, :sync, 1, now: @now) + assert {:error, :rate_limited, _} = IngressRateLimiter.check_peer(domain, :sync, 1, now: @now) + + assert :ok = IngressRateLimiter.check_peer(domain, :replay, 1, now: @now) + assert :ok = IngressRateLimiter.check_peer(domain, :ephemeral, 1, now: @now) + end + + test "per-room bucket limits durable events independently of the peer bucket", %{ + domain: domain + } do + put_limits( + ingress_peer_durable_events_per_minute: 100, + ingress_room_durable_events_per_minute: 2 + ) + + assert :ok = IngressRateLimiter.check_durable(domain, ["stream:a"], now: @now) + assert :ok = IngressRateLimiter.check_durable(domain, ["stream:a"], now: @now) + + assert {:error, :rate_limited, _} = + IngressRateLimiter.check_durable(domain, ["stream:a"], now: @now) + + # A different room under the same peer still has capacity. + assert :ok = IngressRateLimiter.check_durable(domain, ["stream:b"], now: @now) + end + + test "peer durable bucket limits across rooms", %{domain: domain} do + put_limits( + ingress_peer_durable_events_per_minute: 2, + ingress_room_durable_events_per_minute: 100 + ) + + assert :ok = IngressRateLimiter.check_durable(domain, ["stream:a"], now: @now) + assert :ok = IngressRateLimiter.check_durable(domain, ["stream:b"], now: @now) + + assert {:error, :rate_limited, _} = + IngressRateLimiter.check_durable(domain, ["stream:c"], now: @now) + end + + test "a batch exceeding remaining capacity is rejected up front without consuming budget", %{ + domain: domain + } do + put_limits( + ingress_peer_durable_events_per_minute: 5, + ingress_room_durable_events_per_minute: 100 + ) + + oversized = Enum.map(1..6, fn index -> "stream:#{index}" end) + + assert {:error, :rate_limited, _} = + IngressRateLimiter.check_durable(domain, oversized, now: @now) + + # The rejected batch left the buckets untouched, so a full-size batch fits. + exact = Enum.map(1..5, fn index -> "stream:#{index}" end) + assert :ok = IngressRateLimiter.check_durable(domain, exact, now: @now) + end + + test "a batch rejected by a room bucket rolls back the peer reservation", %{domain: domain} do + put_limits( + ingress_peer_durable_events_per_minute: 4, + ingress_room_durable_events_per_minute: 2 + ) + + assert {:error, :rate_limited, _} = + IngressRateLimiter.check_durable( + domain, + ["stream:a", "stream:a", "stream:a"], + now: @now + ) + + assert :ok = IngressRateLimiter.check_durable(domain, ["stream:a", "stream:a"], now: @now) + end + + test "events without a stream id count only against the peer bucket", %{domain: domain} do + put_limits( + ingress_peer_durable_events_per_minute: 2, + ingress_room_durable_events_per_minute: 1 + ) + + assert :ok = IngressRateLimiter.check_durable(domain, [nil, nil], now: @now) + assert {:error, :rate_limited, _} = IngressRateLimiter.check_durable(domain, [nil], now: @now) + end + + test "window reset restores capacity", %{domain: domain} do + put_limits(ingress_peer_replay_requests_per_minute: 1) + + assert :ok = IngressRateLimiter.check_peer(domain, :replay, 1, now: @now) + + assert {:error, :rate_limited, _} = + IngressRateLimiter.check_peer(domain, :replay, 1, now: @now) + + assert :ok = IngressRateLimiter.check_peer(domain, :replay, 1, now: @now + 60) + end + + test "exempt domains bypass all buckets", %{domain: domain} do + put_limits( + ingress_peer_sync_requests_per_minute: 1, + ingress_room_durable_events_per_minute: 1, + ingress_rate_limit_exempt_domains: [String.upcase(domain)] + ) + + for _ <- 1..3 do + assert :ok = IngressRateLimiter.check_peer(domain, :sync, 1, now: @now) + assert :ok = IngressRateLimiter.check_durable(domain, ["stream:a"], now: @now) + end + end + + test "official relay hosts are exempt", %{domain: domain} do + put_limits( + ingress_peer_sync_requests_per_minute: 1, + official_relays: [%{"url" => "https://#{domain}"}] + ) + + for _ <- 1..3 do + assert :ok = IngressRateLimiter.check_peer(domain, :sync, 1, now: @now) + end + end + + test "disabled flag bypasses limiting", %{domain: domain} do + put_limits( + ingress_rate_limit_enabled: false, + ingress_peer_sync_requests_per_minute: 1 + ) + + for _ <- 1..3 do + assert :ok = IngressRateLimiter.check_peer(domain, :sync, 1, now: @now) + end + end +end diff --git a/apps/elektrine/test/elektrine/messaging/federation_outbox_worker_test.exs b/apps/elektrine/test/elektrine/messaging/federation_outbox_worker_test.exs new file mode 100644 index 0000000..c34a394 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/federation_outbox_worker_test.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.Messaging.FederationOutboxWorkerTest do + use Elektrine.DataCase, async: true + + alias Elektrine.Messaging.FederationOutboxWorker + + test "discards missing outbox rows" do + assert {:discard, :not_found} = + FederationOutboxWorker.perform(%Oban.Job{args: %{"outbox_event_id" => -1}}) + end +end diff --git a/apps/elektrine/test/elektrine/messaging/federation_peer_discovery_test.exs b/apps/elektrine/test/elektrine/messaging/federation_peer_discovery_test.exs new file mode 100644 index 0000000..ffe9a64 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/federation_peer_discovery_test.exs @@ -0,0 +1,535 @@ +defmodule Elektrine.Messaging.FederationPeerDiscoveryTest do + use Elektrine.DataCase, async: false + + alias Elektrine.Messaging.{ + ArblargProfiles, + ArblargSDK, + Federation, + Federation.Transport, + FederationDiscoveredPeer + } + + alias Elektrine.Repo + + describe "dynamic peer discovery" do + test "discovers and caches unknown peers from discovery metadata" do + previous = Application.get_env(:elektrine, :messaging_federation, []) + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.merge(previous, + enabled: true, + peers: [], + dns_identity_fetcher: fn + "_arblarg.open.example", "open.example" -> + [ + authenticated_dns_identity_proof( + dynamic_discovery_document("open.example", "open-example-secret") + ) + ] + + _name, _domain -> + [] + end, + discovery_fetcher: fn + "open.example", _urls -> + {:ok, dynamic_discovery_document("open.example", "open-example-secret")} + + _domain, _urls -> + {:error, :not_found} + end + ) + ) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, previous) + end) + + assert {:ok, peer} = Federation.discover_peer("open.example") + assert peer.domain == "open.example" + assert peer.allow_incoming == true + assert peer.allow_outgoing == true + + assert %FederationDiscoveredPeer{} = + Repo.get_by(FederationDiscoveredPeer, domain: "open.example") + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.merge(previous, + enabled: true, + peers: [], + discovery_fetcher: fn _domain, _urls -> {:error, :should_not_refetch} end + ) + ) + + assert %{} = Federation.outgoing_peer("open.example") + assert %{} = Federation.incoming_peer("open.example") + end + + test "rejects discovery documents without a claimed domain" do + previous = Application.get_env(:elektrine, :messaging_federation, []) + + discovery_document = + dynamic_discovery_document("open.example", "open-example-secret", claimed_domain: nil) + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.merge(previous, + enabled: true, + peers: [], + dns_identity_fetcher: fn + "_arblarg.open.example", "open.example" -> + [authenticated_dns_identity_proof(discovery_document)] + + _name, _domain -> + [] + end, + discovery_fetcher: fn + "open.example", _urls -> + {:ok, discovery_document} + + _domain, _urls -> + {:error, :not_found} + end + ) + ) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, previous) + end) + + assert {:error, :invalid_discovery_domain} = Federation.discover_peer("open.example") + end + + test "rejects discovery documents with unsupported default protocol versions" do + previous = Application.get_env(:elektrine, :messaging_federation, []) + + discovery_document = + dynamic_discovery_document("open.example", "open-example-secret", + default_protocol_version: "9.9" + ) + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.merge(previous, + enabled: true, + peers: [], + dns_identity_fetcher: fn + "_arblarg.open.example", "open.example" -> + [authenticated_dns_identity_proof(discovery_document)] + + _name, _domain -> + [] + end, + discovery_fetcher: fn + "open.example", _urls -> + {:ok, discovery_document} + + _domain, _urls -> + {:error, :not_found} + end + ) + ) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, previous) + end) + + assert {:error, :unsupported_version} = Federation.discover_peer("open.example") + end + + test "rejects plaintext websocket session endpoints in normal operation" do + previous = Application.get_env(:elektrine, :messaging_federation, []) + + discovery_document = + dynamic_discovery_document("open.example", "open-example-secret", + session_websocket: "ws://open.example/_arblarg/session" + ) + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.merge(previous, + enabled: true, + peers: [], + dns_identity_fetcher: fn + "_arblarg.open.example", "open.example" -> + [authenticated_dns_identity_proof(discovery_document)] + + _name, _domain -> + [] + end, + discovery_fetcher: fn + "open.example", _urls -> + {:ok, discovery_document} + + _domain, _urls -> + {:error, :not_found} + end + ) + ) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, previous) + end) + + assert {:error, :invalid_discovery_endpoints} = Federation.discover_peer("open.example") + end + + test "peer controls surface discovered peer metadata" do + previous = Application.get_env(:elektrine, :messaging_federation, []) + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.merge(previous, + enabled: true, + peers: [], + dns_identity_fetcher: fn + "_arblarg.open.example", "open.example" -> + [ + authenticated_dns_identity_proof( + dynamic_discovery_document("open.example", "open-example-secret") + ) + ] + + _name, _domain -> + [] + end, + discovery_fetcher: fn + "open.example", _urls -> + {:ok, dynamic_discovery_document("open.example", "open-example-secret")} + + _domain, _urls -> + {:error, :not_found} + end + ) + ) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, previous) + end) + + assert {:ok, _peer} = Federation.discover_peer("open.example") + + assert control = + Enum.find(Federation.list_peer_controls(), fn control -> + control.domain == "open.example" + end) + + assert control.discovered == true + assert control.configured == false + assert control.trust_state == "trusted" + assert control.protocol_version == "1.0" + assert control.effective_allow_incoming == true + assert control.effective_allow_outgoing == true + assert is_map(control.features) + assert control.last_discovered_at + end + + test "replaced discovery identities are quarantined until operator override" do + previous = Application.get_env(:elektrine, :messaging_federation, []) + + discovery_versions = :ets.new(:arblarg_discovery_versions, [:set, :public]) + :ets.insert(discovery_versions, {:secret, "first-secret"}) + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.merge(previous, + enabled: true, + peers: [], + dns_identity_fetcher: fn + "_arblarg.swap.example", "swap.example" -> + [{:secret, secret}] = :ets.lookup(discovery_versions, :secret) + + [ + authenticated_dns_identity_proof( + dynamic_discovery_document("swap.example", secret) + ) + ] + + _name, _domain -> + [] + end, + discovery_fetcher: fn + "swap.example", _urls -> + [{:secret, secret}] = :ets.lookup(discovery_versions, :secret) + {:ok, dynamic_discovery_document("swap.example", secret)} + + _domain, _urls -> + {:error, :not_found} + end + ) + ) + + on_exit(fn -> + if :ets.info(discovery_versions) != :undefined do + :ets.delete(discovery_versions) + end + + Application.put_env(:elektrine, :messaging_federation, previous) + end) + + assert {:ok, peer} = Federation.discover_peer("swap.example") + assert peer.trust_state == "trusted" + assert %{} = Federation.outgoing_peer("swap.example") + + :ets.insert(discovery_versions, {:secret, "second-secret"}) + + assert {:ok, peer} = Federation.refresh_peer_discovery("swap.example") + assert peer.trust_state == "replaced" + assert peer.allow_incoming == false + assert peer.allow_outgoing == false + assert is_nil(Federation.incoming_peer("swap.example")) + assert is_nil(Federation.outgoing_peer("swap.example")) + + assert control = + Enum.find(Federation.list_peer_controls(), fn control -> + control.domain == "swap.example" + end) + + assert control.requires_operator_action == true + assert control.blocked == true + assert control.trust_state == "replaced" + + assert {:ok, _policy} = + Federation.upsert_peer_policy("swap.example", %{ + blocked: false, + allow_incoming: true, + allow_outgoing: true + }) + + assert %{} = Federation.incoming_peer("swap.example") + assert %{} = Federation.outgoing_peer("swap.example") + end + + test "merges extension support advertised only through the profiles document" do + previous = Application.get_env(:elektrine, :messaging_federation, []) + role_event_type = hd(ArblargSDK.roles_event_types()) + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.merge(previous, + enabled: true, + peers: [], + dns_identity_fetcher: fn + "_arblarg.profiles-only.example", "profiles-only.example" -> + [ + authenticated_dns_identity_proof( + dynamic_discovery_document("profiles-only.example", "profiles-only-secret") + ) + ] + + _name, _domain -> + [] + end, + discovery_fetcher: fn + "profiles-only.example", _urls -> + {:ok, dynamic_discovery_document("profiles-only.example", "profiles-only-secret")} + + _domain, _urls -> + {:error, :not_found} + end, + profiles_fetcher: fn + "profiles-only.example", _url -> + {:ok, dynamic_profiles_document(role_event_type)} + + _domain, _url -> + {:error, :not_found} + end + ) + ) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, previous) + end) + + assert {:ok, peer} = Federation.discover_peer("profiles-only.example") + assert Transport.peer_supports_event_type?(peer, role_event_type) + + assert ArblargProfiles.community_profile_id() in get_in(peer.features, [ + "compatibility_claims" + ]) + end + end + + describe "extension negotiation" do + setup do + previous = Application.get_env(:elektrine, :messaging_federation, []) + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.merge(previous, + enabled: true, + peers: [ + %{ + domain: "bootstrap.example", + base_url: "https://bootstrap.example", + shared_secret: "bootstrap-secret", + allow_incoming: true, + allow_outgoing: true, + supported_event_types: ["server.upsert"] + }, + %{ + domain: "core-only.example", + base_url: "https://core-only.example", + shared_secret: "core-only-secret", + allow_incoming: true, + allow_outgoing: true, + supported_event_types: ArblargSDK.core_event_types() + } + ] + ) + ) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, previous) + end) + + :ok + end + + test "filters bootstrap extension fanout to peers that advertise support" do + bootstrap_event_type = ArblargSDK.bootstrap_server_upsert_event_type() + + bootstrap_peer = %{ + domain: "bootstrap.example", + supported_event_types: [bootstrap_event_type], + features: %{"supported_event_types" => [bootstrap_event_type]} + } + + core_only_peer = %{ + domain: "core-only.example", + supported_event_types: ArblargSDK.core_event_types(), + features: %{"supported_event_types" => ArblargSDK.core_event_types()} + } + + assert Transport.peer_supports_event_type?(bootstrap_peer, bootstrap_event_type) + refute Transport.peer_supports_event_type?(core_only_peer, bootstrap_event_type) + end + + test "accepts community profile claims as extension support" do + peer = %{ + features: %{ + "compatibility_claims" => [ArblargProfiles.community_profile_id()] + } + } + + assert Transport.peer_supports_event_type?(peer, hd(ArblargSDK.roles_event_types())) + refute Transport.peer_supports_event_type?(%{}, hd(ArblargSDK.roles_event_types())) + end + end + + defp dynamic_discovery_document(domain, secret, opts \\ []) do + {public_key, _private_key} = ArblargSDK.derive_keypair_from_secret(secret) + base_url = "https://#{domain}" + claimed_domain = Keyword.get(opts, :claimed_domain, domain) + + default_protocol_version = + Keyword.get(opts, :default_protocol_version, ArblargSDK.protocol_version()) + + session_websocket = + Keyword.get(opts, :session_websocket, "wss://#{domain}/_arblarg/session") + + unsigned = + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => ArblargSDK.protocol_id(), + "protocol_labels" => [ArblargSDK.protocol_label()], + "default_protocol_label" => ArblargSDK.protocol_label(), + "default_protocol_version" => default_protocol_version, + "version" => 1, + "identity" => %{ + "algorithm" => "ed25519", + "current_key_id" => "k1", + "keys" => [ + %{ + "id" => "k1", + "algorithm" => "ed25519", + "public_key" => Base.url_encode64(public_key, padding: false) + } + ] + }, + "endpoints" => %{ + "well_known" => "#{base_url}/.well-known/_arblarg", + "events" => "#{base_url}/_arblarg/events", + "events_batch" => "#{base_url}/_arblarg/events/batch", + "ephemeral" => "#{base_url}/_arblarg/ephemeral", + "sync" => "#{base_url}/_arblarg/sync", + "stream_events" => "#{base_url}/_arblarg/streams/events", + "session_websocket" => session_websocket, + "snapshot_template" => "#{base_url}/_arblarg/servers/{server_id}/snapshot", + "public_servers" => "#{base_url}/_arblarg/servers/public", + "profiles" => "#{base_url}/_arblarg/profiles", + "schema_template" => "#{base_url}/_arblarg/{version}/schemas/{name}" + } + } + |> maybe_put_field("domain", claimed_domain) + + Map.put(unsigned, "signature", %{ + "algorithm" => "ed25519", + "key_id" => "k1", + "value" => + unsigned + |> ArblargSDK.canonical_json_payload() + |> ArblargSDK.sign_payload(secret) + }) + end + + defp dynamic_profiles_document(event_type) when is_binary(event_type) do + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => ArblargSDK.protocol_id(), + "default_protocol_version" => ArblargSDK.protocol_version(), + "version" => 1, + "compatibility_claims" => [ArblargProfiles.community_profile_id()], + "events" => %{"supported" => [event_type]}, + "extensions" => [ + %{ + "urn" => ArblargProfiles.extension_urn_for_event_type(event_type) + } + ] + } + end + + defp maybe_put_field(map, _key, nil), do: map + + defp maybe_put_field(map, key, value) when is_map(map) and is_binary(key) do + Map.put(map, key, value) + end + + defp discovery_fingerprint(%{ + "identity" => %{"current_key_id" => current_key_id, "keys" => keys} + }) + when is_list(keys) do + normalized = + keys + |> Enum.map(fn key -> + %{ + "id" => key["id"], + "public_key" => key["public_key"] + } + end) + |> Enum.sort_by(&{&1["id"] || "", &1["public_key"] || ""}) + + :crypto.hash( + :sha256, + Jason.encode!(%{"current_key_id" => current_key_id, "keys" => normalized}) + ) + |> Base.url_encode64(padding: false) + end + + defp authenticated_dns_identity_proof(discovery_document) do + %{ + text: "fingerprint=#{discovery_fingerprint(discovery_document)}", + authenticated: true + } + end +end diff --git a/apps/elektrine/test/elektrine/messaging/federation_peer_policy_test.exs b/apps/elektrine/test/elektrine/messaging/federation_peer_policy_test.exs new file mode 100644 index 0000000..4a6bee7 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/federation_peer_policy_test.exs @@ -0,0 +1,146 @@ +defmodule Elektrine.Messaging.FederationPeerPolicyTest do + use Elektrine.DataCase, async: false + + alias Elektrine.Messaging.Federation + + describe "runtime peer policies" do + setup do + original_config = Application.get_env(:elektrine, :messaging_federation, []) + + peer = %{ + domain: "remote.example", + base_url: "https://remote.example", + shared_secret: "test-secret", + allow_incoming: true, + allow_outgoing: true + } + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.put(original_config, :peers, [peer]) + ) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, original_config) + end) + + :ok + end + + test "block policy disables incoming and outgoing federation" do + assert Federation.incoming_peer("remote.example") + assert length(Federation.outgoing_peers()) == 1 + + assert {:ok, _policy} = Federation.block_peer_domain("remote.example", "manual block") + + assert is_nil(Federation.incoming_peer("remote.example")) + assert Federation.outgoing_peers() == [] + end + + test "directional runtime overrides affect only selected direction" do + assert {:ok, _policy} = + Federation.upsert_peer_policy("remote.example", %{ + blocked: false, + allow_incoming: false + }) + + assert is_nil(Federation.incoming_peer("remote.example")) + assert length(Federation.outgoing_peers()) == 1 + + assert {:ok, _policy} = + Federation.upsert_peer_policy("remote.example", %{ + blocked: false, + allow_incoming: nil, + allow_outgoing: false + }) + + assert Federation.incoming_peer("remote.example") + assert Federation.outgoing_peers() == [] + end + + test "peer controls include runtime-only block entries" do + assert {:ok, _policy} = Federation.block_peer_domain("manual.example", "proactive block") + + controls = Federation.list_peer_controls() + + assert Enum.any?(controls, fn control -> + control.domain == "manual.example" and control.configured == false and + control.blocked == true + end) + end + end + + describe "relay transport metadata" do + test "includes relay metadata in discovery document" do + previous = Application.get_env(:elektrine, :messaging_federation) + + Application.put_env( + :elektrine, + :messaging_federation, + enabled: true, + identity_key_id: "k-relay", + official_relay_operator: "Relay Collective", + official_relays: [ + %{ + "name" => "Relay US-East", + "url" => "https://relay-us-east.example.com" + }, + "https://relay-eu.example.com" + ], + peers: [] + ) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, previous) + end) + + discovery = Federation.local_discovery_document() + + assert discovery["features"]["relay_transport"] == true + assert discovery["relay_transport"]["mode"] == "optional" + assert discovery["relay_transport"]["community_hostable"] == true + assert discovery["relay_transport"]["official_operator"] == "Relay Collective" + + relay_urls = + discovery["relay_transport"]["official_relays"] + |> Enum.map(& &1["url"]) + + assert "https://relay-us-east.example.com" in relay_urls + assert "https://relay-eu.example.com" in relay_urls + end + + test "normalizes peer relay endpoint overrides" do + previous = Application.get_env(:elektrine, :messaging_federation) + + Application.put_env( + :elektrine, + :messaging_federation, + enabled: true, + peers: [ + %{ + "domain" => "remote.example", + "base_url" => "https://remote.example/", + "shared_secret" => "relay-secret", + "event_endpoint" => "https://relay.example.net/fwd/events", + "sync_endpoint" => "https://relay.example.net/fwd/sync", + "snapshot_endpoint_template" => "https://relay.example.net/fwd/snapshots/{server_id}" + } + ] + ) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, previous) + end) + + [peer] = Federation.outgoing_peers() + + assert peer.base_url == "https://remote.example" + assert peer.event_endpoint == "https://relay.example.net/fwd/events" + assert peer.sync_endpoint == "https://relay.example.net/fwd/sync" + + assert peer.snapshot_endpoint_template == + "https://relay.example.net/fwd/snapshots/{server_id}" + end + end +end diff --git a/apps/elektrine/test/elektrine/messaging/federation_pins_test.exs b/apps/elektrine/test/elektrine/messaging/federation_pins_test.exs new file mode 100644 index 0000000..af29696 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/federation_pins_test.exs @@ -0,0 +1,296 @@ +defmodule Elektrine.Messaging.FederationPinsTest do + use Elektrine.DataCase, async: false + + alias Elektrine.AccountsFixtures + alias Elektrine.ActivityPub.Actor + alias Elektrine.Messaging + + alias Elektrine.Messaging.{ + ArblargSDK, + ChatConversation, + ChatMessage, + ChatMessagePin, + ChatMessagePins, + FederationExtensionEvent, + FederationMembershipState, + Server + } + + alias Elektrine.Messaging.Federation.EventRouter + alias Elektrine.Repo + + @remote_domain "remote.example" + + describe "outbound pin federation" do + test "pinning a channel message publishes a schema-valid pin.upsert projection" do + %{owner: owner, member: member, channel: channel} = local_channel_with_member() + + {:ok, message} = Messaging.create_chat_text_message(channel.id, member.id, "pin me") + assert {:ok, _pinned} = ChatMessagePins.pin_message(message.id, owner.id) + + canonical_type = ArblargSDK.canonical_event_type("pin.upsert") + message_ref = message_federation_ref(message) + + assert %FederationExtensionEvent{payload: payload, status: "pinned"} = + Repo.get_by(FederationExtensionEvent, + event_type: canonical_type, + conversation_id: channel.id, + event_key: "pin:#{message_ref}:channel:#{channel.id}" + ) + + assert get_in(payload, ["pin", "message_id"]) == message_ref + assert get_in(payload, ["pin", "state"]) == "pinned" + assert get_in(payload, ["actor", "username"]) == owner.username + assert :ok = ArblargSDK.validate_event_payload(canonical_type, payload) + end + + test "unpinning updates the projection to unpinned" do + %{owner: owner, member: member, channel: channel} = local_channel_with_member() + + {:ok, message} = Messaging.create_chat_text_message(channel.id, member.id, "short pin") + {:ok, _} = ChatMessagePins.pin_message(message.id, owner.id) + assert {:ok, _} = ChatMessagePins.unpin_message(message.id, owner.id) + + canonical_type = ArblargSDK.canonical_event_type("pin.upsert") + message_ref = message_federation_ref(message) + + assert %FederationExtensionEvent{payload: payload, status: "unpinned"} = + Repo.get_by(FederationExtensionEvent, + event_type: canonical_type, + conversation_id: channel.id, + event_key: "pin:#{message_ref}:channel:#{channel.id}" + ) + + assert get_in(payload, ["pin", "state"]) == "unpinned" + assert :ok = ArblargSDK.validate_event_payload(canonical_type, payload) + end + + test "pins in groups stay local" do + creator = AccountsFixtures.user_fixture() + buddy = AccountsFixtures.user_fixture() + + {:ok, group} = + Messaging.create_chat_group_conversation(creator.id, %{name: "local pins"}, [buddy.id]) + + {:ok, message} = Messaging.create_chat_text_message(group.id, buddy.id, "group pin") + assert {:ok, _} = ChatMessagePins.pin_message(message.id, creator.id) + + refute Repo.get_by(FederationExtensionEvent, + event_type: ArblargSDK.canonical_event_type("pin.upsert"), + conversation_id: group.id + ) + end + end + + describe "inbound pin federation" do + test "pin.upsert pins the mirrored message and unpin removes it" do + %{channel: channel} = mirrored_channel_fixture() + moderator = remote_member_fixture(channel, "mod", "moderator") + + message_ref = "https://#{@remote_domain}/_arblarg/messages/6100" + mirror_message = mirror_message_fixture(channel, message_ref, "remote pinned content") + + Phoenix.PubSub.subscribe(Elektrine.PubSub, "chat:#{channel.id}") + + pinned_at = DateTime.utc_now() |> DateTime.truncate(:second) + + assert :ok = + EventRouter.apply_event( + "pin.upsert", + pin_upsert_data(channel, moderator, message_ref, "pinned", pinned_at), + @remote_domain + ) + + assert %ChatMessagePin{pinned_by_id: nil} = + Repo.get_by(ChatMessagePin, + conversation_id: channel.id, + message_id: mirror_message.id + ) + + message_id = mirror_message.id + assert_receive {:message_pinned, %ChatMessage{id: ^message_id, is_pinned: true}} + + assert Enum.map(ChatMessagePins.list_pinned_messages(channel.id), & &1.id) == [ + mirror_message.id + ] + + unpinned_at = DateTime.add(pinned_at, 5, :second) + + assert :ok = + EventRouter.apply_event( + "pin.upsert", + pin_upsert_data(channel, moderator, message_ref, "unpinned", unpinned_at), + @remote_domain + ) + + refute Repo.get_by(ChatMessagePin, message_id: mirror_message.id) + assert_receive {:message_unpinned, %ChatMessage{id: ^message_id, is_pinned: false}} + end + + test "pin.upsert from a plain member without manage_messages is rejected" do + %{channel: channel} = mirrored_channel_fixture() + member = remote_member_fixture(channel, "alice", "member") + + message_ref = "https://#{@remote_domain}/_arblarg/messages/6101" + mirror_message = mirror_message_fixture(channel, message_ref, "not pinnable by member") + + assert {:error, _reason} = + EventRouter.apply_event( + "pin.upsert", + pin_upsert_data(channel, member, message_ref, "pinned", DateTime.utc_now()), + @remote_domain + ) + + refute Repo.get_by(ChatMessagePin, message_id: mirror_message.id) + end + + test "pin.upsert for an unknown message stores the projection without a local pin" do + %{channel: channel} = mirrored_channel_fixture() + moderator = remote_member_fixture(channel, "mod", "moderator") + + message_ref = "https://#{@remote_domain}/_arblarg/messages/does-not-exist" + + assert :ok = + EventRouter.apply_event( + "pin.upsert", + pin_upsert_data(channel, moderator, message_ref, "pinned", DateTime.utc_now()), + @remote_domain + ) + + assert Repo.get_by(FederationExtensionEvent, + event_type: ArblargSDK.canonical_event_type("pin.upsert"), + event_key: "pin:#{message_ref}:channel:#{channel.id}" + ) + + assert Repo.aggregate( + from(pin in ChatMessagePin, where: pin.conversation_id == ^channel.id), + :count + ) == 0 + end + end + + ## Fixtures + + defp local_channel_with_member do + owner = AccountsFixtures.user_fixture() + member = AccountsFixtures.user_fixture() + + {:ok, server} = Messaging.create_server(owner.id, %{name: "pin-fed", is_public: true}) + {:ok, _member} = Messaging.join_server(server.id, member.id) + [channel | _] = server.channels + + %{owner: owner, member: member, server: server, channel: channel} + end + + defp mirrored_channel_fixture do + suffix = System.unique_integer([:positive]) + + server = + %Server{} + |> Server.changeset(%{ + name: "Remote Server #{suffix}", + description: "Federated mirror server", + federation_id: "https://#{@remote_domain}/_arblarg/servers/#{suffix}", + origin_domain: @remote_domain, + is_federated_mirror: true + }) + |> Repo.insert!() + + channel = + %ChatConversation{} + |> ChatConversation.channel_changeset(%{ + name: "remote-channel-#{suffix}", + description: "Mirrored remote channel", + server_id: server.id, + federated_source: "https://#{@remote_domain}/_arblarg/channels/#{suffix}", + is_federated_mirror: true + }) + |> Repo.insert!() + + %{server: server, channel: channel} + end + + defp mirror_message_fixture(channel, federation_ref, content) do + %ChatMessage{} + |> ChatMessage.changeset(%{ + conversation_id: channel.id, + sender_id: nil, + content: content, + message_type: "text", + federated_source: federation_ref, + origin_domain: @remote_domain, + is_federated_mirror: true, + media_metadata: %{"remote_sender" => canonical_actor("someone")} + }) + |> Repo.insert!() + end + + defp remote_member_fixture(channel, username, role) do + actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://#{@remote_domain}/users/#{username}", + username: username, + domain: @remote_domain, + inbox_url: "https://#{@remote_domain}/users/#{username}/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: actor.id, + origin_domain: @remote_domain, + role: role, + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + actor + end + + defp pin_upsert_data(channel, %Actor{} = actor, message_ref, state, updated_at) do + server = Repo.get!(Server, channel.server_id) + + %{ + "server" => %{"id" => server.federation_id, "name" => server.name, "is_public" => true}, + "channel" => %{ + "id" => channel.federated_source, + "name" => channel.name, + "position" => 0 + }, + "refs" => %{ + "server_id" => server.federation_id, + "channel_id" => channel.federated_source + }, + "pin" => %{ + "message_id" => message_ref, + "state" => state, + "updated_at" => DateTime.to_iso8601(updated_at) + }, + "actor" => canonical_actor(actor.username, uri: actor.uri) + } + end + + defp canonical_actor(username, opts \\ []) do + uri = Keyword.get(opts, :uri, "https://#{@remote_domain}/users/#{username}") + + %{ + "id" => uri, + "uri" => uri, + "username" => username, + "display_name" => username, + "domain" => @remote_domain, + "handle" => "#{username}@#{@remote_domain}" + } + end + + defp message_federation_ref(message) do + message.federated_source || + Elektrine.Messaging.Federation.Utils.message_federation_id(message.id) + end +end diff --git a/apps/elektrine/test/elektrine/messaging/federation_protocol_test.exs b/apps/elektrine/test/elektrine/messaging/federation_protocol_test.exs new file mode 100644 index 0000000..6cedb06 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/federation_protocol_test.exs @@ -0,0 +1,23 @@ +defmodule Elektrine.Messaging.FederationProtocolTest do + use ExUnit.Case, async: true + + alias Elektrine.Messaging.Federation.Protocol + + test "advertises expanded protocol feature contract" do + document = + Protocol.local_discovery_document("1.0", %{ + local_domain: "local.test", + base_url: "https://local.test", + identity: %{} + }) + + features = document["features"] + + assert features["deterministic_governance_projection"] == true + assert features["client_sync_cursors"] == true + assert features["gateway_resume"] == true + assert features["attachment_authorization_metadata"] == true + assert features["rich_room_metadata"] == true + assert features["federation_abuse_controls"] == true + end +end diff --git a/apps/elektrine/test/elektrine/messaging/federation_remote_join_test.exs b/apps/elektrine/test/elektrine/messaging/federation_remote_join_test.exs new file mode 100644 index 0000000..7e0229e --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/federation_remote_join_test.exs @@ -0,0 +1,129 @@ +defmodule Elektrine.Messaging.FederationRemoteJoinTest do + use Elektrine.DataCase, async: false + + alias Elektrine.AccountsFixtures + alias Elektrine.ActivityPub.Actor + alias Elektrine.Messaging + alias Elektrine.Messaging.FederationMembershipState + alias Elektrine.Repo + + describe "remote join moderation workflow" do + test "lists and approves pending remote join requests for a local authoritative room" do + owner = AccountsFixtures.user_fixture() + {:ok, server} = Messaging.create_server(owner.id, %{name: "approval-hub"}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "gated-room", + description: "requires approval" + }) + + remote_actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.example/users/alice", + username: "alice", + domain: "remote.example", + display_name: "Alice", + inbox_url: "https://remote.example/users/alice/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + inserted_at = DateTime.utc_now() |> DateTime.truncate(:second) + + %FederationMembershipState{} + |> FederationMembershipState.changeset(%{ + conversation_id: channel.id, + remote_actor_id: remote_actor.id, + origin_domain: "remote.example", + role: "member", + state: "invited", + updated_at_remote: inserted_at, + metadata: %{"join_request" => true, "reason" => "approval_required"} + }) + |> Repo.insert!() + + assert [ + %{ + remote_actor_id: remote_actor_id, + handle: "@alice@remote.example", + display_label: "Alice (@alice@remote.example)" + } + ] = Messaging.list_pending_remote_join_requests(channel.id) + + assert remote_actor_id == remote_actor.id + + assert {:ok, approved_request} = + Messaging.approve_remote_join_request(channel.id, remote_actor.id, owner.id) + + assert approved_request.state == "active" + + assert %FederationMembershipState{} = + membership_state = + Repo.get_by(FederationMembershipState, + conversation_id: channel.id, + remote_actor_id: remote_actor.id + ) + + assert membership_state.state == "active" + assert get_in(membership_state.metadata, ["join_request"]) == false + assert get_in(membership_state.metadata, ["join_decision"]) == "accepted" + assert Messaging.list_pending_remote_join_requests(channel.id) == [] + end + + test "declines pending remote join requests and removes them from the pending queue" do + owner = AccountsFixtures.user_fixture() + {:ok, server} = Messaging.create_server(owner.id, %{name: "approval-hub-decline"}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "gated-room-decline", + description: "requires approval" + }) + + remote_actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.example/users/bob", + username: "bob", + domain: "remote.example", + display_name: "Bob", + inbox_url: "https://remote.example/users/bob/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + inserted_at = DateTime.utc_now() |> DateTime.truncate(:second) + + %FederationMembershipState{} + |> FederationMembershipState.changeset(%{ + conversation_id: channel.id, + remote_actor_id: remote_actor.id, + origin_domain: "remote.example", + role: "member", + state: "invited", + updated_at_remote: inserted_at, + metadata: %{"join_request" => true, "reason" => "approval_required"} + }) + |> Repo.insert!() + + assert {:ok, declined_request} = + Messaging.decline_remote_join_request(channel.id, remote_actor.id, owner.id) + + assert declined_request.state == "left" + + assert %FederationMembershipState{} = + membership_state = + Repo.get_by(FederationMembershipState, + conversation_id: channel.id, + remote_actor_id: remote_actor.id + ) + + assert membership_state.state == "left" + assert get_in(membership_state.metadata, ["join_request"]) == false + assert get_in(membership_state.metadata, ["join_decision"]) == "declined" + assert Messaging.list_pending_remote_join_requests(channel.id) == [] + end + end +end diff --git a/apps/elektrine/test/elektrine/messaging/federation_room_participation_test.exs b/apps/elektrine/test/elektrine/messaging/federation_room_participation_test.exs new file mode 100644 index 0000000..28401c7 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/federation_room_participation_test.exs @@ -0,0 +1,210 @@ +defmodule Elektrine.Messaging.FederationRoomParticipationTest do + use Elektrine.DataCase, async: false + + alias Elektrine.AccountsFixtures + alias Elektrine.ActivityPub.Actor + alias Elektrine.Messaging + + alias Elektrine.Messaging.{ + ChatConversation, + ChatMessage, + Federation, + Federation.Visibility, + FederationInviteState, + FederationMembershipState, + Server + } + + alias Elektrine.Messaging.ChatConversationMember, as: ConversationMember + alias Elektrine.Messaging.Federation.Builders + alias Elektrine.Repo + + describe "outbound room participation" do + test "builds room-scoped presence updates against participant homeservers" do + user = AccountsFixtures.user_fixture() + channel = mirrored_channel_fixture() + + assert {:ok, item, target_domains} = + Builders.build_room_presence_ephemeral_item( + channel.id, + user.id, + "online", + [%{"name" => "Browsing"}], + builder_context() + ) + + assert item["event_type"] == "presence.update" + assert get_in(item, ["payload", "refs", "channel_id"]) == channel.federated_source + assert get_in(item, ["payload", "presence", "status"]) == "online" + assert "remote.example" in target_domains + end + + test "builds message.create against the remote room stream for mirrored channels" do + user = AccountsFixtures.user_fixture() + channel = mirrored_channel_fixture() + message = mirrored_local_message_fixture(channel.id, user.id, "hello remote room") + + assert {:ok, event} = Builders.build_message_created_event(message, builder_context()) + assert event["event_type"] == "message.create" + assert event["stream_id"] == "channel:#{channel.federated_source}" + assert get_in(event, ["payload", "refs", "channel_id"]) == channel.federated_source + assert get_in(event, ["payload", "message", "content"]) == "hello remote room" + end + + test "targets all known participant homeservers for mirrored room events" do + user = AccountsFixtures.user_fixture() + channel = mirrored_channel_fixture() + message = mirrored_local_message_fixture(channel.id, user.id, "hello everyone") + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + for {username, domain} <- [{"alice", "remote-b.example"}, {"carol", "remote-c.example"}] do + actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://#{domain}/users/#{username}", + username: username, + domain: domain, + inbox_url: "https://#{domain}/users/#{username}/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: actor.id, + origin_domain: domain, + role: "member", + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + end + + assert {:ok, event} = Builders.build_message_created_event(message, builder_context()) + + assert Enum.sort(Visibility.target_domains_for_event(event)) == [ + "remote-b.example", + "remote-c.example", + "remote.example" + ] + end + + test "builds read.cursor against the remote room origin for mirrored channels" do + user = AccountsFixtures.user_fixture() + channel = mirrored_channel_fixture() + message = mirrored_local_message_fixture(channel.id, user.id, "read target") + + assert {:ok, event, target_domains} = + Builders.build_read_cursor_event( + channel.id, + user.id, + message.id, + DateTime.utc_now(), + builder_context() + ) + + assert event["event_type"] == "read.cursor" + assert event["stream_id"] == "channel:#{channel.federated_source}" + assert target_domains == ["remote.example"] + end + + test "builds membership.upsert against the remote room origin for mirrored channels" do + user = AccountsFixtures.user_fixture() + channel = mirrored_channel_fixture() + + assert {:ok, _member} = Messaging.add_member_to_conversation(channel.id, user.id) + + assert {:ok, event, target_domains} = + Builders.build_membership_upsert_event( + channel.id, + user.id, + "active", + "member", + builder_context() + ) + + assert event["event_type"] == "membership.upsert" + assert event["stream_id"] == "channel:#{channel.federated_source}" + assert target_domains == ["remote.example"] + assert get_in(event, ["payload", "refs", "channel_id"]) == channel.federated_source + end + + test "requests mirrored room joins without creating an immediate local membership" do + user = AccountsFixtures.user_fixture() + channel = mirrored_channel_fixture() + local_uri = "https://#{Federation.local_domain()}/users/#{user.username}" + + assert {:ok, :pending} = Messaging.join_conversation(channel.id, user.id) + + refute Repo.get_by(ConversationMember, conversation_id: channel.id, user_id: user.id) + + assert Repo.get_by(FederationInviteState, + conversation_id: channel.id, + target_uri: local_uri, + state: "pending" + ) + end + end + + defp builder_context do + %{ + local_domain: &Elektrine.Messaging.Federation.Runtime.local_domain/0, + local_event_signing_material: + &Elektrine.Messaging.Federation.Runtime.local_event_signing_material/0, + outgoing_peers: &Federation.outgoing_peers/0, + maybe_iso8601: fn + %DateTime{} = datetime -> DateTime.to_iso8601(datetime) + nil -> nil + _ -> nil + end, + normalize_optional_string: fn + value when is_binary(value) -> + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + + _ -> + nil + end, + presence_ttl_seconds: fn -> 60 end + } + end + + defp mirrored_channel_fixture do + suffix = System.unique_integer([:positive]) + + server = + %Server{} + |> Server.changeset(%{ + name: "Remote Server #{suffix}", + description: "Federated mirror server", + federation_id: "https://remote.example/_arblarg/servers/#{suffix}", + origin_domain: "remote.example", + is_federated_mirror: true + }) + |> Repo.insert!() + + %ChatConversation{} + |> ChatConversation.channel_changeset(%{ + name: "remote-channel-#{suffix}", + description: "Mirrored remote channel", + server_id: server.id, + federated_source: "https://remote.example/_arblarg/channels/#{suffix}", + is_federated_mirror: true + }) + |> Repo.insert!() + end + + defp mirrored_local_message_fixture(conversation_id, sender_id, content) do + %ChatMessage{} + |> ChatMessage.changeset(%{ + conversation_id: conversation_id, + sender_id: sender_id, + content: content, + message_type: "text" + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/messaging/federation_session_websock_test.exs b/apps/elektrine/test/elektrine/messaging/federation_session_websock_test.exs new file mode 100644 index 0000000..51dd429 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/federation_session_websock_test.exs @@ -0,0 +1,118 @@ +defmodule Elektrine.Messaging.FederationSessionWebSockTest do + use Elektrine.DataCase, async: false + + alias Elektrine.Messaging.ArblargSDK + alias Elektrine.Messaging.FederationSessionWebSock + + setup do + previous = Application.get_env(:elektrine, :messaging_federation) + + Application.put_env( + :elektrine, + :messaging_federation, + enabled: true, + identity_key_id: "local-k1", + conformance_core_passed: true, + peers: [ + %{ + "domain" => "remote.test", + "base_url" => "https://remote.test", + "shared_secret" => "test-shared-secret", + "active_outbound_key_id" => "k1", + "keys" => [ + %{"id" => "k1", "secret" => "test-shared-secret", "active_outbound" => true} + ], + "allow_incoming" => true, + "allow_outgoing" => true + } + ] + ) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, previous) + end) + + :ok + end + + test "accepts delivery frames and returns batch-shaped ack payloads" do + {:push, {:text, _hello}, state} = + FederationSessionWebSock.init(%{remote_domain: "remote.test"}) + + frame = %{ + "op" => "stream_batch", + "delivery_id" => "delivery-1", + "payload" => %{ + "version" => 1, + "delivery_id" => "delivery-1", + "stream_id" => "server:https://remote.test/_arblarg/servers/77", + "events" => [signed_server_upsert_event("evt-session-1", 1, "session-room")] + } + } + + {:push, {:text, payload}, _state} = + FederationSessionWebSock.handle_in({Jason.encode!(frame), [opcode: :text]}, state) + + response = Jason.decode!(payload) + + assert response["op"] == "ack" + assert response["delivery_id"] == "delivery-1" + assert response["status"] == "ok" + assert response["payload"]["batch_id"] == "delivery-1" + assert response["payload"]["event_count"] == 1 + assert response["payload"]["counts"]["applied"] == 1 + end + + test "rejects delivery frames whose payload omits the required delivery_id" do + {:push, {:text, _hello}, state} = + FederationSessionWebSock.init(%{remote_domain: "remote.test"}) + + frame = %{ + "op" => "stream_batch", + "delivery_id" => "delivery-2", + "payload" => %{ + "version" => 1, + "stream_id" => "server:https://remote.test/_arblarg/servers/78", + "events" => [signed_server_upsert_event("evt-session-2", 1, "invalid-session-room")] + } + } + + {:push, {:text, payload}, _state} = + FederationSessionWebSock.handle_in({Jason.encode!(frame), [opcode: :text]}, state) + + response = Jason.decode!(payload) + + assert response["op"] == "ack" + assert response["delivery_id"] == "delivery-2" + assert response["status"] == "error" + assert response["code"] == "invalid_payload" + end + + defp signed_server_upsert_event(event_id, sequence, server_name) do + server_id = "https://remote.test/_arblarg/servers/77" + stream_id = "server:#{server_id}" + + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => ArblargSDK.protocol_id(), + "protocol_label" => ArblargSDK.protocol_label(), + "protocol_version" => ArblargSDK.protocol_version(), + "event_id" => event_id, + "event_type" => "server.upsert", + "origin_domain" => "remote.test", + "stream_id" => stream_id, + "sequence" => sequence, + "sent_at" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "idempotency_key" => "idem-#{event_id}", + "payload" => %{ + "server" => %{ + "id" => server_id, + "name" => server_name, + "is_public" => true + }, + "channels" => [] + } + } + |> ArblargSDK.sign_event_envelope("k1", "test-shared-secret") + end +end diff --git a/apps/elektrine/test/elektrine/messaging/federation_test.exs b/apps/elektrine/test/elektrine/messaging/federation_test.exs new file mode 100644 index 0000000..4392c07 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/federation_test.exs @@ -0,0 +1,3882 @@ +defmodule Elektrine.Messaging.FederationTest do + use Elektrine.DataCase, async: false + + import ExUnit.CaptureLog + + alias Elektrine.AccountsFixtures + alias Elektrine.ActivityPub.Actor + alias Elektrine.Messaging + + alias Elektrine.Messaging.{ + ArblargSDK, + ChatConversation, + ChatMessage, + ChatMessageReaction, + CommunityBan, + Federation, + Federation.Builders, + Federation.Visibility, + FederationAccountPresenceState, + FederationEvent, + FederationExtensionEvent, + FederationInviteState, + FederationMembershipState, + FederationOutboxEvent, + FederationReadCursor, + FederationRoomPresenceState, + FederationStreamPosition, + Server + } + + alias Elektrine.Messaging.ChatConversation, as: Conversation + alias Elektrine.Messaging.ChatConversationMember, as: ConversationMember + + alias Elektrine.Repo + + describe "publisher identity configuration" do + test "skips outbound publishing when production signing identity is missing" do + context = %{ + enabled?: fn -> true end, + build_membership_upsert_event: fn _conversation_id, _user_id, _state, _role -> + raise ArgumentError, + "messaging federation requires explicit identity keys in production; configure :identity_keys or :identity_shared_secret" + end, + enqueue_outbox_event: fn _event, _target_domains -> + flunk("outbox event should not be enqueued without signing identity") + end + } + + log = + capture_log(fn -> + assert :ok = + Elektrine.Messaging.Federation.Publisher.publish_membership_state( + 1, + 2, + "active", + "member", + context + ) + end) + + assert log =~ + "Messaging federation publish skipped because identity signing is not configured" + end + + test "does not swallow unrelated publisher argument errors" do + context = %{ + enabled?: fn -> true end, + build_membership_upsert_event: fn _conversation_id, _user_id, _state, _role -> + raise ArgumentError, "bad membership payload" + end, + enqueue_outbox_event: fn _event, _target_domains -> :ok end + } + + assert_raise ArgumentError, "bad membership payload", fn -> + Elektrine.Messaging.Federation.Publisher.publish_membership_state( + 1, + 2, + "active", + "member", + context + ) + end + end + end + + describe "build_server_snapshot/2" do + test "builds snapshot with channels and recent messages" do + owner = AccountsFixtures.user_fixture() + {:ok, server} = Messaging.create_server(owner.id, %{name: "snapshot-space"}) + + {:ok, ops_channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "ops", + description: "operations" + }) + + {:ok, _message} = + Messaging.create_chat_text_message(ops_channel.id, owner.id, "hello federation") + + assert {:ok, payload} = + Federation.build_server_snapshot(server.id, messages_per_channel: 10) + + assert payload["version"] == 1 + assert payload["server"]["name"] == "snapshot-space" + assert Enum.any?(payload["channels"], &(&1["name"] == "ops")) + assert Enum.any?(payload["messages"], &(&1["content"] == "hello federation")) + end + + test "includes remote-authored room messages in snapshot payloads" do + owner = AccountsFixtures.user_fixture() + {:ok, server} = Messaging.create_server(owner.id, %{name: "snapshot-room-participants"}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "ops", + description: "operations" + }) + + remote_message_id = "https://remote-b.example/_arblarg/messages/5001" + + %ChatMessage{} + |> ChatMessage.changeset(%{ + conversation_id: channel.id, + sender_id: nil, + content: "hello from a remote participant", + message_type: "text", + federated_source: remote_message_id, + origin_domain: "remote-b.example", + media_metadata: %{ + "remote_sender" => canonical_actor("alice", "remote-b.example") + } + }) + |> Repo.insert!() + + assert {:ok, payload} = + Federation.build_server_snapshot(server.id, messages_per_channel: 10) + + assert Enum.any?(payload["messages"], fn message -> + message["id"] == remote_message_id and + message["content"] == "hello from a remote participant" and + get_in(message, ["sender", "handle"]) == "alice@remote-b.example" + end) + end + + test "includes invite governance projections in snapshot payloads" do + owner = AccountsFixtures.user_fixture() + invitee = AccountsFixtures.user_fixture() + {:ok, server} = Messaging.create_server(owner.id, %{name: "governance-space"}) + + {:ok, ops_channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "ops", + description: "operations" + }) + + assert :ok = + Federation.publish_invite_state( + ops_channel.id, + invitee.id, + owner.id, + "pending", + "member", + %{"source" => "snapshot-test"} + ) + + assert {:ok, payload} = + Federation.build_server_snapshot(server.id, messages_per_channel: 10) + + invites = get_in(payload, ["governance", "invites"]) || [] + + assert Enum.any?(invites, fn invite_payload -> + get_in(invite_payload, ["invite", "target", "handle"]) == + "#{invitee.username}@#{Federation.local_domain()}" and + get_in(invite_payload, ["invite", "state"]) == "pending" + end) + end + + test "includes remote participant memberships in snapshot governance" do + owner = AccountsFixtures.user_fixture() + {:ok, server} = Messaging.create_server(owner.id, %{name: "snapshot-remote-members"}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "ops", + description: "operations" + }) + + remote_actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote-b.example/users/alice", + username: "alice", + domain: "remote-b.example", + inbox_url: "https://remote-b.example/users/alice/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: remote_actor.id, + origin_domain: "remote-b.example", + role: "member", + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + assert {:ok, payload} = + Federation.build_server_snapshot(server.id, messages_per_channel: 10) + + memberships = get_in(payload, ["governance", "memberships"]) || [] + + assert Enum.any?(memberships, fn membership_payload -> + get_in(membership_payload, ["membership", "actor", "handle"]) == + "alice@remote-b.example" and + get_in(membership_payload, ["membership", "state"]) == "active" + end) + end + + test "includes multi-origin governance, reactions, read cursors, and extensions in snapshots" do + owner = AccountsFixtures.user_fixture() + {:ok, server} = Messaging.create_server(owner.id, %{name: "snapshot-multi-origin-state"}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "ops", + description: "operations" + }) + + remote_participant = + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote-b.example/users/alice", + username: "alice", + domain: "remote-b.example", + inbox_url: "https://remote-b.example/users/alice/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + remote_banned = + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote-c.example/users/bob", + username: "bob", + domain: "remote-c.example", + inbox_url: "https://remote-c.example/users/bob/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + message = + %ChatMessage{} + |> ChatMessage.changeset(%{ + conversation_id: channel.id, + sender_id: owner.id, + content: "snapshot target", + message_type: "text" + }) + |> Repo.insert!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + server_federation_id = + server.federation_id || + Elektrine.Messaging.Federation.Utils.server_federation_id(server.id) + + channel_federation_id = + channel.federated_source || + Elektrine.Messaging.Federation.Utils.channel_federation_id(channel.id) + + message_federation_id = + message.federated_source || + Elektrine.Messaging.Federation.Utils.message_federation_id(message.id) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: remote_participant.id, + origin_domain: "remote-b.example", + role: "member", + state: "invited", + joined_at_remote: nil, + updated_at_remote: timestamp, + metadata: %{"join_request" => true, "source" => "snapshot-test"} + }) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: remote_banned.id, + origin_domain: "remote-c.example", + role: "member", + state: "banned", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{ + "actor" => canonical_actor(owner.username, Federation.local_domain()), + "ban_state" => "active", + "reason" => "spam", + "metadata" => %{"source" => "snapshot-ban"} + } + }) + + Repo.insert!(%ChatMessageReaction{ + chat_message_id: message.id, + remote_actor_id: remote_participant.id, + emoji: ":wave:" + }) + + Repo.insert!(%FederationReadCursor{ + conversation_id: channel.id, + chat_message_id: message.id, + remote_actor_id: remote_participant.id, + origin_domain: "remote-b.example", + read_at: timestamp, + read_through_sequence: 12 + }) + + role_event_type = ArblargSDK.canonical_event_type("role.upsert") + + Repo.insert!(%FederationExtensionEvent{ + event_type: role_event_type, + origin_domain: "remote-b.example", + event_key: "role:urn:remote-b.example:roles:ops:channel:#{channel.id}", + payload: %{ + "server" => %{"id" => server_federation_id}, + "channel" => %{"id" => channel_federation_id}, + "actor" => canonical_actor("alice", "remote-b.example"), + "role" => %{ + "id" => "urn:remote-b.example:roles:ops", + "name" => "Ops", + "position" => 0, + "permissions" => [], + "updated_at" => DateTime.to_iso8601(timestamp) + } + }, + server_id: server.id, + conversation_id: channel.id, + occurred_at: timestamp + }) + + assert {:ok, payload} = + Federation.build_server_snapshot(server.id, messages_per_channel: 10) + + memberships = get_in(payload, ["governance", "memberships"]) || [] + bans = get_in(payload, ["governance", "bans"]) || [] + reactions = payload["reactions"] || [] + read_cursors = payload["read_cursors"] || [] + extensions = payload["extensions"] || [] + + assert Enum.any?(memberships, fn membership_payload -> + get_in(membership_payload, ["membership", "actor", "handle"]) == + "alice@remote-b.example" and + get_in(membership_payload, ["membership", "metadata", "join_request"]) == true + end) + + assert Enum.any?(bans, fn ban_payload -> + get_in(ban_payload, ["ban", "target", "handle"]) == "bob@remote-c.example" and + get_in(ban_payload, ["ban", "reason"]) == "spam" and + get_in(ban_payload, ["ban", "metadata", "source"]) == "snapshot-ban" + end) + + assert Enum.any?(reactions, fn reaction_payload -> + reaction_payload["message_id"] == message_federation_id and + get_in(reaction_payload, ["reaction", "actor", "handle"]) == + "alice@remote-b.example" + end) + + assert Enum.any?(read_cursors, fn cursor_payload -> + cursor_payload["read_through_sequence"] == 12 and + get_in(cursor_payload, ["actor", "handle"]) == "alice@remote-b.example" + end) + + assert Enum.any?(extensions, fn extension_payload -> + extension_payload["event_type"] == role_event_type and + get_in(extension_payload, ["payload", "actor", "handle"]) == + "alice@remote-b.example" + end) + end + + test "filters snapshot extensions to peers that advertise support" do + owner = AccountsFixtures.user_fixture() + + {:ok, server} = + Messaging.create_server(owner.id, %{name: "extension-snapshot", is_public: true}) + + bootstrap_event_type = ArblargSDK.bootstrap_server_upsert_event_type() + + Repo.insert!(%FederationExtensionEvent{ + event_type: bootstrap_event_type, + origin_domain: Federation.local_domain(), + event_key: "bootstrap:#{server.id}", + payload: %{"server" => %{"id" => "server-#{server.id}"}}, + server_id: server.id + }) + + bootstrap_peer = %{ + domain: "bootstrap.example", + supported_event_types: [bootstrap_event_type] + } + + core_only_peer = %{ + domain: "core-only.example", + supported_event_types: ArblargSDK.core_event_types() + } + + assert {:ok, full_snapshot} = Federation.build_server_snapshot(server.id) + + assert {:ok, bootstrap_snapshot} = + Federation.build_server_snapshot(server.id, peer: bootstrap_peer) + + assert {:ok, core_only_snapshot} = + Federation.build_server_snapshot(server.id, peer: core_only_peer) + + assert [%{"event_type" => ^bootstrap_event_type}] = full_snapshot["extensions"] + assert [%{"event_type" => ^bootstrap_event_type}] = bootstrap_snapshot["extensions"] + assert core_only_snapshot["extensions"] == [] + end + + test "exports only peer-visible channels and preserves channel policy metadata" do + owner = AccountsFixtures.user_fixture() + + {:ok, server} = + Messaging.create_server(owner.id, %{name: "visibility-snapshot", is_public: true}) + + {:ok, public_channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "lobby", + description: "public room", + is_public: true + }) + + {:ok, private_channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "ops", + description: "private room", + is_public: false + }) + + private_channel = + private_channel + |> Conversation.changeset(%{approval_mode_enabled: true}) + |> Repo.update!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + actor_payload = canonical_actor(owner.username, Federation.local_domain()) + target_payload = canonical_actor("alice", "remote.example") + + Repo.insert!(%FederationInviteState{ + conversation_id: private_channel.id, + origin_domain: Federation.local_domain(), + actor_uri: actor_payload["uri"], + actor_payload: actor_payload, + target_uri: target_payload["uri"], + target_payload: target_payload, + role: "member", + state: "pending", + invited_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{"source" => "snapshot-visibility-test"} + }) + + assert {:ok, invited_snapshot} = + Federation.build_server_snapshot(server.id, peer: %{domain: "remote.example"}) + + assert {:ok, stranger_snapshot} = + Federation.build_server_snapshot(server.id, peer: %{domain: "stranger.example"}) + + invited_names = Enum.map(invited_snapshot["channels"], & &1["name"]) + stranger_names = Enum.map(stranger_snapshot["channels"], & &1["name"]) + + assert public_channel.name in invited_names + assert private_channel.name in invited_names + assert public_channel.name in stranger_names + refute private_channel.name in stranger_names + + assert Enum.any?(invited_snapshot["channels"], fn channel -> + channel["name"] == private_channel.name and + channel["is_public"] == false and + channel["approval_mode_enabled"] == true + end) + end + + test "does not expose private channels to banned remote invite targets" do + owner = AccountsFixtures.user_fixture() + + {:ok, server} = + Messaging.create_server(owner.id, %{name: "banned-visibility", is_public: true}) + + {:ok, private_channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "ops", + description: "private room", + is_public: false + }) + + private_channel = + private_channel + |> Conversation.changeset(%{approval_mode_enabled: true}) + |> Repo.update!() + + remote_actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.example/users/alice", + username: "alice", + domain: "remote.example", + inbox_url: "https://remote.example/users/alice/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + actor_payload = canonical_actor(owner.username, Federation.local_domain()) + target_payload = canonical_actor("alice", "remote.example") + + Repo.insert!(%FederationInviteState{ + conversation_id: private_channel.id, + origin_domain: Federation.local_domain(), + actor_uri: actor_payload["uri"], + actor_payload: actor_payload, + target_uri: target_payload["uri"], + target_payload: target_payload, + role: "member", + state: "pending", + invited_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + Repo.insert!(%FederationMembershipState{ + conversation_id: private_channel.id, + remote_actor_id: remote_actor.id, + origin_domain: "remote.example", + role: "member", + state: "banned", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + assert {:ok, banned_snapshot} = + Federation.build_server_snapshot(server.id, peer: %{domain: "remote.example"}) + + refute Enum.any?(banned_snapshot["channels"], fn channel -> + channel["name"] == private_channel.name + end) + end + end + + describe "build_server_upsert_event/2" do + test "exports only public channels in bootstrap payloads" do + owner = AccountsFixtures.user_fixture() + + {:ok, server} = + Messaging.create_server(owner.id, %{name: "bootstrap-public", is_public: true}) + + {:ok, _public_channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "announcements", + description: "public room", + is_public: true + }) + + {:ok, _private_channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "staff", + description: "private room", + is_public: false, + approval_mode_enabled: true + }) + + assert {:ok, event} = Builders.build_server_upsert_event(server.id, builder_context()) + + channels = get_in(event, ["payload", "channels"]) + channel_names = Enum.map(channels, & &1["name"]) + + refute "staff" in channel_names + assert "announcements" in channel_names + assert Enum.all?(channels, &(&1["is_public"] == true)) + assert Enum.any?(channels, &(&1["approval_mode_enabled"] == false)) + end + end + + describe "import_server_snapshot/2" do + setup do + previous = Application.get_env(:elektrine, :messaging_federation, []) + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.merge(previous, + enabled: true, + peers: [ + %{ + domain: "remote.example", + base_url: "https://remote.example", + shared_secret: "remote-example-secret", + active_outbound_key_id: "k1", + keys: [ + %{id: "k1", secret: "remote-example-secret", active_outbound: true} + ], + allow_incoming: true, + allow_outgoing: false + }, + %{ + domain: "remote-a.example", + base_url: "https://remote-a.example", + shared_secret: "remote-a-secret", + active_outbound_key_id: "k1", + keys: [ + %{id: "k1", secret: "remote-a-secret", active_outbound: true} + ], + allow_incoming: true, + allow_outgoing: false + }, + %{ + domain: "remote-b.example", + base_url: "https://remote-b.example", + shared_secret: "remote-b-secret", + active_outbound_key_id: "k1", + keys: [ + %{id: "k1", secret: "remote-b-secret", active_outbound: true} + ], + allow_incoming: true, + allow_outgoing: false + } + ] + ) + ) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, previous) + end) + + :ok + end + + test "imports mirror server, channels, and deduplicates messages by federated_source" do + payload = + %{ + "version" => 1, + "origin_domain" => "remote.example", + "server" => %{ + "id" => "https://remote.example/_arblarg/servers/42", + "name" => "remote-hub", + "description" => "Remote server", + "is_public" => true, + "member_count" => 12 + }, + "channels" => [ + %{ + "id" => "https://remote.example/_arblarg/channels/100", + "name" => "general", + "description" => "General remote chat", + "topic" => "hello", + "position" => 0 + } + ], + "messages" => [ + %{ + "id" => "https://remote.example/_arblarg/messages/5000", + "channel_id" => "https://remote.example/_arblarg/channels/100", + "content" => "remote hello", + "message_type" => "text", + "media_urls" => [], + "media_metadata" => %{}, + "sender" => canonical_actor("alice", "remote.example") + } + ] + } + |> sign_snapshot("k1", "remote-example-secret") + + assert {:ok, mirror_server} = Federation.import_server_snapshot(payload, "remote.example") + assert mirror_server.is_federated_mirror == true + assert mirror_server.federation_id == payload["server"]["id"] + + mirror_channel = + Repo.get_by(Conversation, + type: "channel", + federated_source: "https://remote.example/_arblarg/channels/100" + ) + + assert mirror_channel + assert mirror_channel.server_id == mirror_server.id + assert mirror_channel.is_federated_mirror == true + + mirror_message = + Repo.get_by(ChatMessage, + conversation_id: mirror_channel.id, + federated_source: "https://remote.example/_arblarg/messages/5000" + ) + + assert mirror_message + assert mirror_message.content == "remote hello" + assert mirror_message.is_federated_mirror == true + assert mirror_message.origin_domain == "remote.example" + + # Re-import should not duplicate chat message + assert {:ok, _same_server} = Federation.import_server_snapshot(payload, "remote.example") + + mirror_count = + from(m in ChatMessage, + where: + m.conversation_id == ^mirror_channel.id and + m.federated_source == "https://remote.example/_arblarg/messages/5000", + select: count() + ) + |> Repo.one() + + assert mirror_count == 1 + + # sanity checks for stored mirror server records + stored_server = Repo.get(Server, mirror_server.id) + assert stored_server.origin_domain == "remote.example" + end + + test "imports room-origin snapshots that include participant-authored messages" do + room_origin = "remote.example" + participant_origin = "remote-b.example" + channel_id = "https://remote.example/_arblarg/channels/200" + participant_message_id = "https://remote-b.example/_arblarg/messages/2001" + + payload = + %{ + "version" => 1, + "origin_domain" => room_origin, + "server" => %{ + "id" => "https://remote.example/_arblarg/servers/43", + "name" => "remote-shared-room", + "description" => "Remote shared room", + "is_public" => true, + "member_count" => 22 + }, + "channels" => [ + %{ + "id" => channel_id, + "name" => "general", + "description" => "General shared chat", + "topic" => "hello", + "position" => 0 + } + ], + "messages" => [ + %{ + "id" => participant_message_id, + "channel_id" => channel_id, + "content" => "participant hello", + "message_type" => "text", + "media_urls" => [], + "media_metadata" => %{}, + "sender" => canonical_actor("alice", participant_origin) + } + ] + } + |> sign_snapshot("k1", "remote-example-secret") + + assert {:ok, mirror_server} = Federation.import_server_snapshot(payload, room_origin) + + mirror_channel = + Repo.get_by(Conversation, + type: "channel", + federated_source: channel_id + ) + + assert mirror_channel.server_id == mirror_server.id + + assert participant_message = + Repo.get_by(ChatMessage, + conversation_id: mirror_channel.id, + federated_source: participant_message_id + ) + + assert participant_message.content == "participant hello" + assert participant_message.origin_domain == participant_origin + assert participant_message.is_federated_mirror == true + + assert get_in(participant_message.media_metadata, ["remote_sender", "handle"]) == + "alice@remote-b.example" + end + + test "imports room-origin snapshots that include participant memberships" do + room_origin = "remote.example" + participant_origin = "remote-b.example" + channel_id = "https://remote.example/_arblarg/channels/220" + participant_uri = "https://remote-b.example/users/alice" + + payload = + %{ + "version" => 1, + "origin_domain" => room_origin, + "server" => %{ + "id" => "https://remote.example/_arblarg/servers/44", + "name" => "remote-shared-room", + "description" => "Remote shared room", + "is_public" => true, + "member_count" => 22 + }, + "channels" => [ + %{ + "id" => channel_id, + "name" => "general", + "description" => "General shared chat", + "topic" => "hello", + "position" => 0 + } + ], + "governance" => %{ + "memberships" => [ + %{ + "refs" => %{ + "server_id" => "https://remote.example/_arblarg/servers/44", + "channel_id" => channel_id + }, + "membership" => %{ + "actor" => canonical_actor("alice", participant_origin, uri: participant_uri), + "role" => "member", + "state" => "active", + "joined_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{} + } + } + ], + "invites" => [], + "bans" => [] + } + } + |> sign_snapshot("k1", "remote-example-secret") + + assert {:ok, _mirror_server} = Federation.import_server_snapshot(payload, room_origin) + + mirror_channel = + Repo.get_by(Conversation, + type: "channel", + federated_source: channel_id + ) + + remote_actor = Repo.get_by(Actor, uri: participant_uri) + + assert %FederationMembershipState{state: "active", role: "member"} = + Repo.get_by(FederationMembershipState, + conversation_id: mirror_channel.id, + remote_actor_id: remote_actor.id + ) + end + + test "imports room-origin snapshots that include participant governance, reactions, read cursors, and extensions" do + room_origin = "remote.example" + participant_origin = "remote-b.example" + banned_origin = "remote-c.example" + server_id = "https://remote.example/_arblarg/servers/45" + channel_id = "https://remote.example/_arblarg/channels/221" + message_id = "https://remote.example/_arblarg/messages/2210" + role_event_type = ArblargSDK.canonical_event_type("role.upsert") + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + payload = + %{ + "version" => 1, + "origin_domain" => room_origin, + "server" => %{ + "id" => server_id, + "name" => "remote-shared-room", + "description" => "Remote shared room", + "is_public" => true, + "member_count" => 22 + }, + "channels" => [ + %{ + "id" => channel_id, + "name" => "general", + "description" => "General shared chat", + "topic" => "hello", + "position" => 0 + } + ], + "messages" => [ + %{ + "id" => message_id, + "channel_id" => channel_id, + "content" => "snapshot target", + "message_type" => "text", + "attachments" => [], + "sender" => canonical_actor("host", room_origin) + } + ], + "governance" => %{ + "memberships" => [ + %{ + "refs" => %{"server_id" => server_id, "channel_id" => channel_id}, + "membership" => %{ + "actor" => canonical_actor("host", room_origin), + "role" => "admin", + "state" => "active", + "joined_at" => DateTime.to_iso8601(timestamp), + "updated_at" => DateTime.to_iso8601(timestamp), + "metadata" => %{"source" => "snapshot-admin-membership"} + } + }, + %{ + "refs" => %{"server_id" => server_id, "channel_id" => channel_id}, + "membership" => %{ + "actor" => canonical_actor("alice", participant_origin), + "role" => "member", + "state" => "active", + "joined_at" => DateTime.to_iso8601(timestamp), + "updated_at" => DateTime.to_iso8601(timestamp), + "metadata" => %{"source" => "snapshot-membership"} + } + } + ], + "invites" => [], + "bans" => [ + %{ + "refs" => %{"server_id" => server_id, "channel_id" => channel_id}, + "ban" => %{ + "actor" => canonical_actor("host", room_origin), + "target" => canonical_actor("bob", banned_origin), + "state" => "active", + "reason" => "spam", + "banned_at" => DateTime.to_iso8601(timestamp), + "updated_at" => DateTime.to_iso8601(timestamp), + "metadata" => %{"source" => "snapshot-ban"} + } + } + ] + }, + "reactions" => [ + %{ + "refs" => %{"server_id" => server_id, "channel_id" => channel_id}, + "message_id" => message_id, + "reaction" => %{ + "emoji" => ":wave:", + "actor" => canonical_actor("alice", participant_origin) + } + } + ], + "read_cursors" => [ + %{ + "refs" => %{"server_id" => server_id, "channel_id" => channel_id}, + "read_through_message_id" => message_id, + "read_through_sequence" => 9, + "actor" => canonical_actor("alice", participant_origin), + "read_at" => DateTime.to_iso8601(timestamp) + } + ], + "extensions" => [ + %{ + "event_type" => role_event_type, + "payload" => %{ + "server" => %{"id" => server_id}, + "channel" => %{"id" => channel_id}, + "actor" => canonical_actor("host", room_origin), + "role" => %{ + "id" => "urn:remote-b.example:roles:ops", + "name" => "Ops", + "position" => 0, + "permissions" => [], + "updated_at" => DateTime.to_iso8601(timestamp) + } + } + } + ] + } + |> sign_snapshot("k1", "remote-example-secret") + + assert {:ok, mirror_server} = Federation.import_server_snapshot(payload, room_origin) + + mirror_channel = + Repo.get_by!(Conversation, + type: "channel", + federated_source: channel_id + ) + + assert mirror_channel.server_id == mirror_server.id + + mirror_message = + Repo.get_by!(ChatMessage, + conversation_id: mirror_channel.id, + federated_source: message_id + ) + + participant_actor = + Repo.get_by!(Actor, uri: "https://remote-b.example/users/alice") + + banned_actor = + Repo.get_by!(Actor, uri: "https://remote-c.example/users/bob") + + assert %FederationMembershipState{metadata: membership_metadata} = + Repo.get_by(FederationMembershipState, + conversation_id: mirror_channel.id, + remote_actor_id: participant_actor.id, + state: "active" + ) + + assert membership_metadata["source"] == "snapshot-membership" + + assert Repo.get_by(ChatMessageReaction, + chat_message_id: mirror_message.id, + remote_actor_id: participant_actor.id, + emoji: ":wave:" + ) + + assert %FederationReadCursor{read_through_sequence: 9} = + Repo.get_by(FederationReadCursor, + conversation_id: mirror_channel.id, + remote_actor_id: participant_actor.id + ) + + assert %FederationMembershipState{state: "banned", metadata: banned_metadata} = + Repo.get_by(FederationMembershipState, + conversation_id: mirror_channel.id, + remote_actor_id: banned_actor.id + ) + + assert banned_metadata["reason"] == "spam" + assert banned_metadata["metadata"]["source"] == "snapshot-ban" + + assert Repo.get_by(FederationExtensionEvent, + event_type: role_event_type, + event_key: "role:urn:remote-b.example:roles:ops:channel:#{mirror_channel.id}" + ) + end + + test "rejects conflicting server ownership for an already-mirrored federation id" do + payload = + %{ + "version" => 1, + "origin_domain" => "remote-a.example", + "server" => %{ + "id" => "https://remote-a.example/_arblarg/servers/42", + "name" => "remote-a", + "description" => "Remote A server", + "is_public" => true, + "member_count" => 12 + }, + "channels" => [], + "messages" => [] + } + |> sign_snapshot("k1", "remote-a-secret") + + {:ok, _existing_server} = + %Server{} + |> Server.changeset(%{ + name: "conflicting-mirror", + federation_id: "https://remote-a.example/_arblarg/servers/42", + origin_domain: "different-origin.example", + is_federated_mirror: true + }) + |> Repo.insert() + + assert {:error, :federation_origin_conflict} = + Federation.import_server_snapshot(payload, "remote-a.example") + end + + test "preserves mirrored channel visibility and approval policy from snapshot payloads" do + channel_id = "https://remote.example/_arblarg/channels/201" + + payload = + %{ + "version" => 1, + "origin_domain" => "remote.example", + "server" => %{ + "id" => "https://remote.example/_arblarg/servers/200", + "name" => "remote-private", + "description" => "Remote private server", + "is_public" => false, + "member_count" => 4 + }, + "channels" => [ + %{ + "id" => channel_id, + "name" => "staff", + "position" => 0, + "is_public" => false, + "approval_mode_enabled" => true + } + ], + "messages" => [] + } + |> sign_snapshot("k1", "remote-example-secret") + + assert {:ok, _mirror_server} = Federation.import_server_snapshot(payload, "remote.example") + + assert %Conversation{ + is_public: false, + approval_mode_enabled: true, + federated_source: ^channel_id + } = Repo.get_by(Conversation, federated_source: channel_id) + end + + test "stores multi-origin stream checkpoints from snapshots for later live events" do + room_origin = "remote-a.example" + participant_origin = "remote-b.example" + server_id = "https://remote-a.example/_arblarg/servers/410" + channel_id = "https://remote-a.example/_arblarg/channels/411" + channel_stream = "channel:#{channel_id}" + participant_uri = "https://remote-b.example/users/alice" + + payload = + %{ + "version" => 1, + "origin_domain" => room_origin, + "server" => %{ + "id" => server_id, + "name" => "room-origin", + "description" => "Imported multi-origin room", + "is_public" => true, + "member_count" => 2 + }, + "channels" => [ + %{ + "id" => channel_id, + "name" => "general", + "position" => 0, + "is_public" => true + } + ], + "messages" => [], + "governance" => %{ + "memberships" => [ + %{ + "refs" => %{"server_id" => server_id, "channel_id" => channel_id}, + "membership" => %{ + "actor" => canonical_actor("alice", participant_origin, uri: participant_uri), + "role" => "member", + "state" => "active", + "joined_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{} + } + } + ], + "invites" => [], + "bans" => [] + }, + "stream_positions" => [ + %{ + "origin_domain" => room_origin, + "stream_id" => "server:#{server_id}", + "last_sequence" => 1 + }, + %{ + "origin_domain" => room_origin, + "stream_id" => channel_stream, + "last_sequence" => 1 + }, + %{ + "origin_domain" => participant_origin, + "stream_id" => channel_stream, + "last_sequence" => 2 + } + ] + } + |> sign_snapshot("k1", "remote-a-secret") + + assert {:ok, _mirror_server} = Federation.import_server_snapshot(payload, room_origin) + + assert %FederationStreamPosition{last_sequence: 2} = + Repo.get_by(FederationStreamPosition, + origin_domain: participant_origin, + stream_id: channel_stream + ) + + participant_event = + signed_event( + "message.create", + participant_origin, + channel_stream, + 3, + %{ + "server" => %{"id" => server_id, "name" => "room-origin", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "message" => %{ + "id" => "https://remote-b.example/_arblarg/messages/9001", + "channel_id" => channel_id, + "content" => "hello after snapshot", + "message_type" => "text", + "attachments" => [], + "sender" => canonical_actor("alice", participant_origin, uri: participant_uri) + } + }, + secret: "remote-b-secret" + ) + + assert {:ok, :applied} = Federation.receive_event(participant_event, participant_origin) + + assert Repo.get_by(ChatMessage, + federated_source: "https://remote-b.example/_arblarg/messages/9001" + ) + end + end + + describe "signatures and ordered events" do + setup do + previous = Application.get_env(:elektrine, :messaging_federation, []) + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.merge(previous, + enabled: true, + peers: [ + %{ + domain: "remote.example", + base_url: "https://remote.example", + shared_secret: "test-shared-secret", + active_outbound_key_id: "k1", + keys: [ + %{id: "k1", secret: "test-shared-secret", active_outbound: true} + ], + allow_incoming: true, + allow_outgoing: false + }, + %{ + domain: "remote-b.example", + base_url: "https://remote-b.example", + shared_secret: "test-shared-secret-b", + active_outbound_key_id: "k1", + keys: [ + %{id: "k1", secret: "test-shared-secret-b", active_outbound: true} + ], + allow_incoming: true, + allow_outgoing: false + } + ] + ) + ) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, previous) + end) + + :ok + end + + test "verifies peer signatures by key id" do + peer = %{ + keys: [ + %{id: "k1", secret: "secret-new"}, + %{id: "k0", secret: "secret-old"} + ] + } + + domain = "remote.example" + method = "POST" + path = "/_arblarg/events" + timestamp = Integer.to_string(System.system_time(:second)) + + payload = Federation.signature_payload(domain, method, path, "", timestamp) + old_signature = Federation.sign_payload(payload, "secret-old") + + assert Federation.verify_signature( + peer, + domain, + method, + path, + "", + timestamp, + "k0", + old_signature + ) + + refute Federation.verify_signature( + peer, + domain, + method, + path, + "", + timestamp, + "k1", + old_signature + ) + + refute Federation.verify_signature( + peer, + domain, + method, + "/_arblarg/sync", + "", + timestamp, + "k0", + old_signature + ) + end + + test "binds signatures to content digest" do + peer = %{keys: [%{id: "k0", secret: "secret-old"}]} + domain = "remote.example" + method = "POST" + path = "/_arblarg/events" + timestamp = Integer.to_string(System.system_time(:second)) + body = ~s({"event":"x"}) + digest = Federation.body_digest(body) + + signature = + Federation.signature_payload(domain, method, path, "", timestamp, digest) + |> Federation.sign_payload("secret-old") + + assert Federation.verify_signature( + peer, + domain, + method, + path, + "", + timestamp, + digest, + "k0", + signature + ) + + refute Federation.verify_signature( + peer, + domain, + method, + path, + "", + timestamp, + Federation.body_digest(~s({"event":"tampered"})), + "k0", + signature + ) + end + + test "rejects replayed request nonces" do + domain = "remote.example" + method = "POST" + path = "/_arblarg/events" + query = "" + timestamp = Integer.to_string(System.system_time(:second)) + content_digest = Federation.body_digest(~s({"k":"v"})) + request_id = Ecto.UUID.generate() + signature = "sig-value" + + assert :ok = + Federation.claim_request_nonce( + domain, + "k1", + method, + path, + query, + timestamp, + content_digest, + request_id, + signature + ) + + assert {:error, :replayed_request} = + Federation.claim_request_nonce( + domain, + "k1", + method, + path, + query, + timestamp, + content_digest, + request_id, + signature + ) + end + + test "applies events in-order and de-duplicates by event id" do + remote_domain = "remote.example" + server_id = "https://remote.example/_arblarg/servers/501" + stream_id = "server:#{server_id}" + + event1 = + signed_event( + "server.upsert", + remote_domain, + stream_id, + 1, + %{ + "server" => %{ + "id" => server_id, + "name" => "remote-seq-1", + "description" => "first", + "is_public" => true, + "member_count" => 1 + }, + "channels" => [] + } + ) + + assert {:ok, :applied} = Federation.receive_event(event1, remote_domain) + assert {:ok, :duplicate} = Federation.receive_event(event1, remote_domain) + + stale_event = + signed_event( + "server.upsert", + remote_domain, + stream_id, + 1, + %{ + "server" => %{ + "id" => server_id, + "name" => "remote-seq-1", + "description" => "first", + "is_public" => true, + "member_count" => 1 + }, + "channels" => [] + } + ) + + assert {:ok, :stale} = Federation.receive_event(stale_event, remote_domain) + + gap_event = + signed_event( + "server.upsert", + remote_domain, + stream_id, + 3, + %{ + "server" => %{ + "id" => server_id, + "name" => "remote-seq-3", + "description" => "third", + "is_public" => true, + "member_count" => 3 + }, + "channels" => [] + } + ) + + assert {:error, :sequence_gap} = Federation.receive_event(gap_event, remote_domain) + + event2 = + signed_event( + "server.upsert", + remote_domain, + stream_id, + 2, + %{ + "server" => %{ + "id" => server_id, + "name" => "remote-seq-2", + "description" => "second", + "is_public" => true, + "member_count" => 2 + }, + "channels" => [] + } + ) + + assert {:ok, :applied} = Federation.receive_event(event2, remote_domain) + + mirror_server = Repo.get_by(Server, federation_id: server_id) + assert mirror_server.name == "remote-seq-2" + assert mirror_server.member_count == 2 + end + + test "treats matching event ids from different origins as distinct events" do + shared_event_id = "evt-cross-origin-shared" + + event_a = + signed_event( + "server.upsert", + "remote.example", + "server:https://remote.example/_arblarg/servers/901", + 1, + %{ + "server" => %{ + "id" => "https://remote.example/_arblarg/servers/901", + "name" => "remote-a", + "description" => "first origin", + "is_public" => true, + "member_count" => 1 + }, + "channels" => [] + }, + event_id: shared_event_id + ) + + event_b = + signed_event( + "server.upsert", + "remote-b.example", + "server:https://remote-b.example/_arblarg/servers/902", + 1, + %{ + "server" => %{ + "id" => "https://remote-b.example/_arblarg/servers/902", + "name" => "remote-b", + "description" => "second origin", + "is_public" => true, + "member_count" => 2 + }, + "channels" => [] + }, + event_id: shared_event_id, + secret: "test-shared-secret-b" + ) + + assert {:ok, :applied} = Federation.receive_event(event_a, "remote.example") + assert {:ok, :applied} = Federation.receive_event(event_b, "remote-b.example") + + assert 2 = + Repo.aggregate( + from(e in FederationEvent, where: e.event_id == ^shared_event_id), + :count + ) + end + + test "applies message update and delete events" do + remote_domain = "remote.example" + server_id = "https://remote.example/_arblarg/servers/700" + channel_id = "https://remote.example/_arblarg/channels/701" + message_id = "https://remote.example/_arblarg/messages/702" + stream_id = "channel:#{channel_id}" + + membership_event = + signed_event( + "membership.upsert", + remote_domain, + stream_id, + 1, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "membership" => %{ + "actor" => canonical_actor("alice", remote_domain), + "role" => "member", + "state" => "active", + "joined_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{} + } + } + ) + + assert {:ok, :applied} = Federation.receive_event(membership_event, remote_domain) + + create_event = + signed_event( + "message.create", + remote_domain, + stream_id, + 2, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "message" => %{ + "id" => message_id, + "channel_id" => channel_id, + "content" => "first", + "message_type" => "text", + "media_urls" => [], + "media_metadata" => %{}, + "sender" => canonical_actor("alice", remote_domain) + } + } + ) + + assert {:ok, :applied} = Federation.receive_event(create_event, remote_domain) + + update_event = + signed_event( + "message.update", + remote_domain, + stream_id, + 3, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "message" => %{ + "id" => message_id, + "channel_id" => channel_id, + "content" => "updated", + "message_type" => "text", + "media_urls" => [], + "media_metadata" => %{}, + "edited_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "sender" => canonical_actor("alice", remote_domain) + } + } + ) + + assert {:ok, :applied} = Federation.receive_event(update_event, remote_domain) + + mirror_channel = Repo.get_by(Conversation, type: "channel", federated_source: channel_id) + + mirror_message = + Repo.get_by(ChatMessage, conversation_id: mirror_channel.id, federated_source: message_id) + + assert mirror_message.content == "updated" + assert not is_nil(mirror_message.edited_at) + + delete_event = + signed_event( + "message.delete", + remote_domain, + stream_id, + 4, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "message_id" => message_id, + "deleted_at" => DateTime.utc_now() |> DateTime.to_iso8601() + } + ) + + assert {:ok, :applied} = Federation.receive_event(delete_event, remote_domain) + + deleted_message = Repo.get!(ChatMessage, mirror_message.id) + assert not is_nil(deleted_message.deleted_at) + end + + test "accepts participant-authored messages in a local room after membership is established" do + owner = AccountsFixtures.user_fixture() + remote_domain = "remote.example" + {:ok, server} = Messaging.create_server(owner.id, %{name: "local-shared-room"}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "shared", + description: "local shared room" + }) + + {:ok, snapshot} = Federation.build_server_snapshot(server.id, messages_per_channel: 1) + local_server_id = get_in(snapshot, ["server", "id"]) + + local_channel_id = + snapshot["channels"] + |> Enum.find(&(&1["name"] == "shared")) + |> Map.fetch!("id") + + stream_id = "channel:#{local_channel_id}" + actor_payload = canonical_actor("alice", remote_domain) + + unauthorized_message_event = + signed_event( + "message.create", + remote_domain, + stream_id, + 1, + %{ + "refs" => %{"server_id" => local_server_id, "channel_id" => local_channel_id}, + "message" => %{ + "id" => "https://remote.example/_arblarg/messages/7771", + "channel_id" => local_channel_id, + "content" => "blocked before join", + "message_type" => "text", + "media_urls" => [], + "media_metadata" => %{}, + "sender" => actor_payload + } + } + ) + + assert {:error, :not_authorized_for_room} = + Federation.receive_event(unauthorized_message_event, remote_domain) + + membership_event = + signed_event( + "membership.upsert", + remote_domain, + stream_id, + 1, + %{ + "refs" => %{"server_id" => local_server_id, "channel_id" => local_channel_id}, + "membership" => %{ + "actor" => actor_payload, + "role" => "member", + "state" => "invited", + "joined_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{} + } + } + ) + + assert {:ok, :applied} = Federation.receive_event(membership_event, remote_domain) + + message_event = + signed_event( + "message.create", + remote_domain, + stream_id, + 2, + %{ + "refs" => %{"server_id" => local_server_id, "channel_id" => local_channel_id}, + "message" => %{ + "id" => "https://remote.example/_arblarg/messages/7772", + "channel_id" => local_channel_id, + "content" => "joined and talking", + "message_type" => "text", + "media_urls" => [], + "media_metadata" => %{}, + "sender" => actor_payload + } + } + ) + + assert {:ok, :applied} = Federation.receive_event(message_event, remote_domain) + + assert %FederationMembershipState{state: "active"} = + Repo.get_by(FederationMembershipState, + conversation_id: channel.id + ) + + assert inbound_message = + Repo.get_by(ChatMessage, + conversation_id: channel.id, + federated_source: "https://remote.example/_arblarg/messages/7772" + ) + + assert inbound_message.content == "joined and talking" + assert inbound_message.is_federated_mirror == false + assert inbound_message.origin_domain == remote_domain + + assert get_in(inbound_message.media_metadata, ["remote_sender", "handle"]) == + "alice@remote.example" + end + + test "rejects direct remote activation in a local room without acceptance" do + owner = AccountsFixtures.user_fixture() + remote_domain = "remote.example" + {:ok, server} = Messaging.create_server(owner.id, %{name: "strict-local-room"}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "shared", + description: "local shared room" + }) + + {:ok, snapshot} = Federation.build_server_snapshot(server.id, messages_per_channel: 1) + local_server_id = get_in(snapshot, ["server", "id"]) + local_channel_id = get_in(snapshot, ["channels", Access.at(0), "id"]) + + activation_event = + signed_event( + "membership.upsert", + remote_domain, + "channel:#{local_channel_id}", + 1, + %{ + "refs" => %{"server_id" => local_server_id, "channel_id" => local_channel_id}, + "membership" => %{ + "actor" => canonical_actor("alice", remote_domain), + "role" => "member", + "state" => "active", + "joined_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{} + } + } + ) + + assert {:error, :not_authorized_for_room} = + Federation.receive_event(activation_event, remote_domain) + + refute Repo.get_by(FederationMembershipState, conversation_id: channel.id) + end + + test "rejects governance events in a local room when the remote actor lacks room permissions" do + owner = AccountsFixtures.user_fixture() + remote_domain = "remote.example" + {:ok, server} = Messaging.create_server(owner.id, %{name: "local-governance-room"}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "shared", + description: "local shared room" + }) + + {:ok, snapshot} = Federation.build_server_snapshot(server.id, messages_per_channel: 1) + local_server_id = get_in(snapshot, ["server", "id"]) + local_channel_id = get_in(snapshot, ["channels", Access.at(0), "id"]) + + remote_actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://#{remote_domain}/users/alice", + username: "alice", + domain: remote_domain, + inbox_url: "https://#{remote_domain}/users/alice/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: remote_actor.id, + origin_domain: remote_domain, + role: "member", + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + role_event = + signed_event( + "role.upsert", + remote_domain, + "channel:#{local_channel_id}", + 1, + %{ + "server" => %{"id" => local_server_id, "name" => server.name, "is_public" => true}, + "channel" => %{"id" => local_channel_id, "name" => channel.name, "position" => 0}, + "refs" => %{"server_id" => local_server_id, "channel_id" => local_channel_id}, + "actor" => canonical_actor("alice", remote_domain, uri: remote_actor.uri), + "role" => %{ + "id" => "role-ops", + "name" => "Ops", + "position" => 1, + "permissions" => ["manage_roles"] + } + } + ) + + assert {:error, :not_authorized_for_room} = + Federation.receive_event(role_event, remote_domain) + + refute Repo.get_by(FederationExtensionEvent, + event_type: ArblargSDK.canonical_event_type("role.upsert"), + event_key: "role:role-ops:channel:#{channel.id}" + ) + end + + test "grants remote admins governance permissions in a local room ACL" do + owner = AccountsFixtures.user_fixture() + remote_domain = "remote.example" + {:ok, server} = Messaging.create_server(owner.id, %{name: "local-governance-admin-room"}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "shared", + description: "local shared room" + }) + + {:ok, snapshot} = Federation.build_server_snapshot(server.id, messages_per_channel: 1) + local_server_id = get_in(snapshot, ["server", "id"]) + local_channel_id = get_in(snapshot, ["channels", Access.at(0), "id"]) + + suffix = System.unique_integer([:positive]) + + remote_actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://#{remote_domain}/users/mod-#{suffix}", + username: "mod-#{suffix}", + domain: remote_domain, + inbox_url: "https://#{remote_domain}/users/mod-#{suffix}/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: remote_actor.id, + origin_domain: remote_domain, + role: "admin", + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + role_event = + signed_event( + "role.upsert", + remote_domain, + "channel:#{local_channel_id}", + 1, + %{ + "server" => %{"id" => local_server_id, "name" => server.name, "is_public" => true}, + "channel" => %{"id" => local_channel_id, "name" => channel.name, "position" => 0}, + "refs" => %{"server_id" => local_server_id, "channel_id" => local_channel_id}, + "actor" => canonical_actor("mod-#{suffix}", remote_domain, uri: remote_actor.uri), + "role" => %{ + "id" => "role-ops", + "name" => "Ops", + "position" => 1, + "permissions" => ["manage_roles"] + } + } + ) + + assert :ok = + Elektrine.Messaging.RoomACL.authorize_remote_actor_action( + channel, + remote_actor.id, + :role_upsert, + %{remote_actor_id: remote_actor.id} + ) + + assert :ok = ArblargSDK.validate_event_envelope(role_event) + end + + test "materializes accepted invites for local users in mirrored rooms" do + local_user = AccountsFixtures.user_fixture() + channel = mirrored_channel_fixture() + server = Repo.get!(Server, channel.server_id) + stream_id = "channel:#{channel.federated_source}" + local_domain = Federation.local_domain() + suffix = System.unique_integer([:positive]) + + remote_actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.example/users/mod-#{suffix}", + username: "mod-#{suffix}", + domain: "remote.example", + inbox_url: "https://remote.example/users/mod-#{suffix}/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: remote_actor.id, + origin_domain: "remote.example", + role: "admin", + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + invite_event = + signed_event( + "invite.upsert", + "remote.example", + stream_id, + 1, + %{ + "server" => %{ + "id" => server.federation_id, + "name" => server.name, + "is_public" => true + }, + "channel" => %{ + "id" => channel.federated_source, + "name" => channel.name, + "position" => 0 + }, + "invite" => %{ + "actor" => + canonical_actor("mod-#{suffix}", "remote.example", uri: remote_actor.uri), + "target" => + canonical_actor(local_user.username, local_domain, + uri: "https://#{local_domain}/users/#{local_user.username}" + ), + "role" => "member", + "state" => "accepted", + "invited_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{} + } + } + ) + + assert {:ok, :applied} = Federation.receive_event(invite_event, "remote.example") + + assert %ConversationMember{} = + Repo.get_by(ConversationMember, + conversation_id: channel.id, + user_id: local_user.id + ) + + assert Repo.get_by(FederationInviteState, + conversation_id: channel.id, + target_uri: "https://#{local_domain}/users/#{local_user.username}", + state: "accepted" + ) + end + + test "applies reaction add and remove events" do + remote_domain = "remote.example" + server_id = "https://remote.example/_arblarg/servers/800" + channel_id = "https://remote.example/_arblarg/channels/801" + message_id = "https://remote.example/_arblarg/messages/802" + stream_id = "channel:#{channel_id}" + actor_uri = "https://remote.example/users/alice" + + {:ok, _actor} = + %Actor{} + |> Actor.changeset(%{ + uri: actor_uri, + username: "alice", + domain: remote_domain, + inbox_url: "https://remote.example/users/alice/inbox", + public_key: "test-public-key" + }) + |> Repo.insert() + + membership_bob_event = + signed_event( + "membership.upsert", + remote_domain, + stream_id, + 1, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "membership" => %{ + "actor" => canonical_actor("bob", remote_domain), + "role" => "member", + "state" => "active", + "joined_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{} + } + } + ) + + membership_alice_event = + signed_event( + "membership.upsert", + remote_domain, + stream_id, + 2, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "membership" => %{ + "actor" => canonical_actor("alice", remote_domain, uri: actor_uri), + "role" => "member", + "state" => "active", + "joined_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{} + } + } + ) + + assert {:ok, :applied} = Federation.receive_event(membership_bob_event, remote_domain) + assert {:ok, :applied} = Federation.receive_event(membership_alice_event, remote_domain) + + create_event = + signed_event( + "message.create", + remote_domain, + stream_id, + 3, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "message" => %{ + "id" => message_id, + "channel_id" => channel_id, + "content" => "reaction target", + "message_type" => "text", + "media_urls" => [], + "media_metadata" => %{}, + "sender" => canonical_actor("bob", remote_domain) + } + } + ) + + assert {:ok, :applied} = Federation.receive_event(create_event, remote_domain) + + reaction_add_event = + signed_event( + "reaction.add", + remote_domain, + stream_id, + 4, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "message_id" => message_id, + "reaction" => %{ + "emoji" => "👍", + "actor" => canonical_actor("alice", remote_domain, uri: actor_uri) + } + } + ) + + assert {:ok, :applied} = Federation.receive_event(reaction_add_event, remote_domain) + + mirror_channel = Repo.get_by(Conversation, type: "channel", federated_source: channel_id) + + mirror_message = + Repo.get_by(ChatMessage, conversation_id: mirror_channel.id, federated_source: message_id) + + assert %ChatMessageReaction{} = + Repo.get_by(ChatMessageReaction, + chat_message_id: mirror_message.id, + emoji: "👍" + ) + + reaction_remove_event = + signed_event( + "reaction.remove", + remote_domain, + stream_id, + 5, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "message_id" => message_id, + "reaction" => %{ + "emoji" => "👍", + "actor" => canonical_actor("alice", remote_domain, uri: actor_uri) + } + } + ) + + assert {:ok, :applied} = Federation.receive_event(reaction_remove_event, remote_domain) + + assert nil == + Repo.get_by(ChatMessageReaction, + chat_message_id: mirror_message.id, + emoji: "👍" + ) + end + + test "rejects remote actor payloads that try to reuse a handle with a new uri" do + remote_domain = "remote.example" + server_id = "https://remote.example/_arblarg/servers/810" + channel_id = "https://remote.example/_arblarg/channels/811" + message_id = "https://remote.example/_arblarg/messages/812" + stream_id = "channel:#{channel_id}" + existing_uri = "https://remote.example/users/alice" + conflicting_uri = "https://remote.example/actors/alice-renamed" + + {:ok, existing_actor} = + %Actor{} + |> Actor.changeset(%{ + uri: existing_uri, + username: "alice", + domain: remote_domain, + inbox_url: "https://remote.example/users/alice/inbox", + public_key: "test-public-key" + }) + |> Repo.insert() + + membership_bob_event = + signed_event( + "membership.upsert", + remote_domain, + stream_id, + 1, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "membership" => %{ + "actor" => canonical_actor("bob", remote_domain), + "role" => "member", + "state" => "active", + "joined_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{} + } + } + ) + + membership_alice_event = + signed_event( + "membership.upsert", + remote_domain, + stream_id, + 2, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "membership" => %{ + "actor" => canonical_actor("alice", remote_domain, uri: existing_uri), + "role" => "member", + "state" => "active", + "joined_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{} + } + } + ) + + assert {:ok, :applied} = Federation.receive_event(membership_bob_event, remote_domain) + assert {:ok, :applied} = Federation.receive_event(membership_alice_event, remote_domain) + + create_event = + signed_event( + "message.create", + remote_domain, + stream_id, + 3, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "message" => %{ + "id" => message_id, + "channel_id" => channel_id, + "content" => "identity conflict", + "message_type" => "text", + "media_urls" => [], + "media_metadata" => %{}, + "sender" => canonical_actor("bob", remote_domain) + } + } + ) + + assert {:ok, :applied} = Federation.receive_event(create_event, remote_domain) + + conflict_event = + signed_event( + "reaction.add", + remote_domain, + stream_id, + 4, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "message_id" => message_id, + "reaction" => %{ + "emoji" => "🔥", + "actor" => canonical_actor("alice", remote_domain, uri: conflicting_uri) + } + } + ) + + assert {:error, :invalid_actor} = + Federation.receive_event(conflict_event, remote_domain) + + assert Repo.get!(Actor, existing_actor.id).uri == existing_uri + + actor_count = + from(a in Actor, + where: a.username == "alice" and a.domain == ^remote_domain, + select: count() + ) + |> Repo.one() + + assert actor_count == 1 + refute Repo.get_by(Actor, uri: conflicting_uri) + end + + test "accepts read cursor events for mirrored messages" do + remote_domain = "remote.example" + server_id = "https://remote.example/_arblarg/servers/900" + channel_id = "https://remote.example/_arblarg/channels/901" + message_id = "https://remote.example/_arblarg/messages/902" + stream_id = "channel:#{channel_id}" + actor_uri = "https://remote.example/users/reader" + + {:ok, actor} = + %Actor{} + |> Actor.changeset(%{ + uri: actor_uri, + username: "reader", + domain: remote_domain, + inbox_url: "https://remote.example/users/reader/inbox", + public_key: "test-public-key" + }) + |> Repo.insert() + + membership_bob_event = + signed_event( + "membership.upsert", + remote_domain, + stream_id, + 1, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "membership" => %{ + "actor" => canonical_actor("bob", remote_domain), + "role" => "member", + "state" => "active", + "joined_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{} + } + } + ) + + membership_reader_event = + signed_event( + "membership.upsert", + remote_domain, + stream_id, + 2, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "membership" => %{ + "actor" => canonical_actor("reader", remote_domain, uri: actor_uri), + "role" => "member", + "state" => "active", + "joined_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{} + } + } + ) + + assert {:ok, :applied} = Federation.receive_event(membership_bob_event, remote_domain) + assert {:ok, :applied} = Federation.receive_event(membership_reader_event, remote_domain) + + create_event = + signed_event( + "message.create", + remote_domain, + stream_id, + 3, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "message" => %{ + "id" => message_id, + "channel_id" => channel_id, + "content" => "read target", + "message_type" => "text", + "media_urls" => [], + "media_metadata" => %{}, + "sender" => canonical_actor("bob", remote_domain) + } + } + ) + + assert {:ok, :applied} = Federation.receive_event(create_event, remote_domain) + + read_cursor_event = + signed_event( + "read.cursor", + remote_domain, + stream_id, + 4, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "read_through_message_id" => message_id, + "actor" => canonical_actor("reader", remote_domain, uri: actor_uri), + "read_at" => DateTime.utc_now() |> DateTime.to_iso8601() + } + ) + + assert {:ok, :applied} = Federation.receive_event(read_cursor_event, remote_domain) + + mirror_channel = Repo.get_by(Conversation, type: "channel", federated_source: channel_id) + + mirror_message = + Repo.get_by(ChatMessage, conversation_id: mirror_channel.id, federated_source: message_id) + + assert %FederationReadCursor{} = + Repo.get_by(FederationReadCursor, + conversation_id: mirror_channel.id, + remote_actor_id: actor.id + ) + + read_status = Messaging.get_read_status_for_messages([mirror_message.id], mirror_channel.id) + readers = Map.get(read_status, mirror_message.id, []) + + assert Enum.any?(readers, fn reader -> + reader.remote_actor_id == actor.id + end) + end + + test "applies invite and ban governance events into membership state projections" do + remote_domain = "remote.example" + server_id = "https://remote.example/_arblarg/servers/910" + channel_id = "https://remote.example/_arblarg/channels/911" + stream_id = "channel:#{channel_id}" + + membership_event = + signed_event( + "membership.upsert", + remote_domain, + stream_id, + 1, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "membership" => %{ + "actor" => canonical_actor("mod", remote_domain), + "role" => "admin", + "state" => "active", + "joined_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{"source" => "test"} + } + } + ) + + invite_event = + signed_event( + "invite.upsert", + remote_domain, + stream_id, + 2, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "invite" => %{ + "actor" => canonical_actor("mod", remote_domain), + "target" => canonical_actor("alice", remote_domain), + "role" => "member", + "state" => "pending", + "invited_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{"source" => "test"} + } + } + ) + + assert {:ok, :applied} = Federation.receive_event(membership_event, remote_domain) + + assert {:ok, :applied} = Federation.receive_event(invite_event, remote_domain) + + mirror_channel = Repo.get_by(Conversation, type: "channel", federated_source: channel_id) + target_actor = Repo.get_by(Actor, username: "alice", domain: remote_domain) + + assert %FederationMembershipState{} = + membership_state = + Repo.get_by(FederationMembershipState, + conversation_id: mirror_channel.id, + remote_actor_id: target_actor.id + ) + + assert membership_state.state == "invited" + assert get_in(membership_state.metadata, ["governance_event"]) == "invite.upsert" + + ban_event = + signed_event( + "ban.upsert", + remote_domain, + stream_id, + 3, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "ban" => %{ + "actor" => canonical_actor("mod", remote_domain), + "target" => canonical_actor("alice", remote_domain), + "state" => "active", + "reason" => "spam", + "banned_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{"source" => "test"} + } + } + ) + + assert {:ok, :applied} = Federation.receive_event(ban_event, remote_domain) + + assert %FederationMembershipState{} = + membership_state = + Repo.get_by(FederationMembershipState, + conversation_id: mirror_channel.id, + remote_actor_id: target_actor.id + ) + + assert membership_state.state == "banned" + assert get_in(membership_state.metadata, ["ban_state"]) == "active" + assert get_in(membership_state.metadata, ["reason"]) == "spam" + end + + test "rejects invite governance from non-moderator participants" do + owner = AccountsFixtures.user_fixture() + {:ok, server} = Messaging.create_server(owner.id, %{name: "local-authority-room"}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "ops", + description: "governed locally" + }) + + {:ok, snapshot} = Federation.build_server_snapshot(server.id, messages_per_channel: 1) + server_id = snapshot["server"]["id"] + channel_id = get_in(snapshot, ["channels", Access.at(0), "id"]) + remote_domain = "remote-b.example" + + remote_actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://#{remote_domain}/users/alice", + username: "alice", + domain: remote_domain, + inbox_url: "https://#{remote_domain}/users/alice/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: remote_actor.id, + origin_domain: remote_domain, + role: "member", + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + invite_event = + signed_event( + "invite.upsert", + remote_domain, + "channel:#{channel_id}", + 1, + %{ + "refs" => %{"server_id" => server_id, "channel_id" => channel_id}, + "invite" => %{ + "actor" => canonical_actor("alice", remote_domain), + "target" => canonical_actor("bob", remote_domain), + "role" => "member", + "state" => "pending", + "invited_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{"source" => "participant"} + } + }, + secret: "test-shared-secret-b" + ) + + assert {:error, :not_authorized_for_room} = + Federation.receive_event(invite_event, remote_domain) + + refute Repo.get_by(FederationInviteState, conversation_id: channel.id) + end + + test "persists remote bans targeting local users and exports them in snapshots" do + owner = AccountsFixtures.user_fixture() + local_user = AccountsFixtures.user_fixture() + {:ok, server} = Messaging.create_server(owner.id, %{name: "local-ban-room"}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "ops", + description: "ban target", + is_public: true + }) + + assert {:ok, _member} = Messaging.join_channel(channel.id, local_user.id) + + server_id = + server.federation_id || + Elektrine.Messaging.Federation.Utils.server_federation_id(server.id) + + channel_id = + channel.federated_source || + Elektrine.Messaging.Federation.Utils.channel_federation_id(channel.id) + + remote_domain = "remote-b.example" + + remote_moderator = + %Actor{} + |> Actor.changeset(%{ + uri: "https://#{remote_domain}/users/mod", + username: "mod", + domain: remote_domain, + inbox_url: "https://#{remote_domain}/users/mod/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: remote_moderator.id, + origin_domain: remote_domain, + role: "owner", + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + banned_at = DateTime.utc_now() |> DateTime.truncate(:second) + + ban_event = + signed_event( + "ban.upsert", + remote_domain, + "channel:#{channel_id}", + 1, + %{ + "server" => %{"id" => server_id, "name" => server.name, "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => channel.name, "position" => 0}, + "refs" => %{"server_id" => server_id, "channel_id" => channel_id}, + "ban" => %{ + "actor" => canonical_actor("mod", remote_domain), + "target" => canonical_actor(local_user.username, Federation.local_domain()), + "state" => "active", + "reason" => "spam", + "banned_at" => DateTime.to_iso8601(banned_at), + "updated_at" => DateTime.to_iso8601(banned_at), + "metadata" => %{"source" => "remote-ban"} + } + }, + secret: "test-shared-secret-b" + ) + + assert {:ok, :applied} = Federation.receive_event(ban_event, remote_domain) + + assert %CommunityBan{} = + ban = + Repo.get_by(CommunityBan, + conversation_id: channel.id, + user_id: local_user.id + ) + + assert ban.origin_domain == remote_domain + assert get_in(ban.actor_payload, ["handle"]) == "mod@remote-b.example" + assert ban.reason == "spam" + assert ban.banned_at_remote == banned_at + assert ban.metadata == %{"source" => "remote-ban"} + + refute Repo.exists?( + from(member in ConversationMember, + where: + member.conversation_id == ^channel.id and member.user_id == ^local_user.id and + is_nil(member.left_at) + ) + ) + + assert {:error, :banned} = Messaging.join_channel(channel.id, local_user.id) + + assert {:ok, payload} = Federation.build_server_snapshot(server.id, messages_per_channel: 1) + + bans = get_in(payload, ["governance", "bans"]) || [] + + assert Enum.any?(bans, fn item -> + get_in(item, ["ban", "target", "handle"]) == + "#{local_user.username}@#{Federation.local_domain()}" and + get_in(item, ["ban", "actor", "handle"]) == "mod@remote-b.example" and + get_in(item, ["ban", "reason"]) == "spam" + end) + end + + test "enforces federated role assignments and permission overwrites for participant writes" do + owner = AccountsFixtures.user_fixture() + {:ok, server} = Messaging.create_server(owner.id, %{name: "local-acl-room"}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "ops", + description: "shared acl" + }) + + server_id = + server.federation_id || + Elektrine.Messaging.Federation.Utils.server_federation_id(server.id) + + channel_id = + channel.federated_source || + Elektrine.Messaging.Federation.Utils.channel_federation_id(channel.id) + + remote_domain = "remote-b.example" + + writer_actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://#{remote_domain}/users/alice", + username: "alice", + domain: remote_domain, + inbox_url: "https://#{remote_domain}/users/alice/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + admin_actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://#{remote_domain}/users/mod", + username: "mod", + domain: remote_domain, + inbox_url: "https://#{remote_domain}/users/mod/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: writer_actor.id, + origin_domain: remote_domain, + role: "readonly", + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: admin_actor.id, + origin_domain: remote_domain, + role: "admin", + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + role_upsert_event = + signed_event( + "role.upsert", + remote_domain, + "channel:#{channel_id}", + 1, + %{ + "server" => %{"id" => server_id, "name" => server.name, "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => channel.name, "position" => 0}, + "refs" => %{"server_id" => server_id, "channel_id" => channel_id}, + "actor" => canonical_actor("mod", remote_domain), + "role" => %{ + "id" => "role:speaker", + "name" => "Speaker", + "permissions" => ["send_messages"], + "position" => 20 + } + }, + secret: "test-shared-secret-b" + ) + + role_assignment_event = + signed_event( + "role.assignment.upsert", + remote_domain, + "channel:#{channel_id}", + 2, + %{ + "server" => %{"id" => server_id, "name" => server.name, "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => channel.name, "position" => 0}, + "refs" => %{"server_id" => server_id, "channel_id" => channel_id}, + "actor" => canonical_actor("mod", remote_domain), + "assignment" => %{ + "role_id" => "role:speaker", + "target" => %{"type" => "member", "id" => writer_actor.uri}, + "state" => "assigned" + } + }, + secret: "test-shared-secret-b" + ) + + assert {:ok, :applied} = Federation.receive_event(role_upsert_event, remote_domain) + assert {:ok, :applied} = Federation.receive_event(role_assignment_event, remote_domain) + + create_event = + signed_event( + "message.create", + remote_domain, + "channel:#{channel_id}", + 3, + %{ + "refs" => %{"server_id" => server_id, "channel_id" => channel_id}, + "message" => %{ + "id" => "https://#{remote_domain}/_arblarg/messages/1", + "channel_id" => channel_id, + "sender" => canonical_actor("alice", remote_domain, uri: writer_actor.uri), + "content" => "allowed by role assignment", + "message_type" => "text", + "created_at" => DateTime.utc_now() |> DateTime.to_iso8601() + } + }, + secret: "test-shared-secret-b" + ) + + assert {:ok, :applied} = Federation.receive_event(create_event, remote_domain) + + overwrite_event = + signed_event( + "permission.overwrite.upsert", + remote_domain, + "channel:#{channel_id}", + 4, + %{ + "server" => %{"id" => server_id, "name" => server.name, "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => channel.name, "position" => 0}, + "refs" => %{"server_id" => server_id, "channel_id" => channel_id}, + "actor" => canonical_actor("mod", remote_domain), + "overwrite" => %{ + "id" => "deny-send", + "target" => %{"type" => "member", "id" => writer_actor.uri}, + "allow" => [], + "deny" => ["send_messages"] + } + }, + secret: "test-shared-secret-b" + ) + + assert {:ok, :applied} = Federation.receive_event(overwrite_event, remote_domain) + + blocked_event = + signed_event( + "message.create", + remote_domain, + "channel:#{channel_id}", + 5, + %{ + "refs" => %{"server_id" => server_id, "channel_id" => channel_id}, + "message" => %{ + "id" => "https://#{remote_domain}/_arblarg/messages/2", + "channel_id" => channel_id, + "sender" => canonical_actor("alice", remote_domain, uri: writer_actor.uri), + "content" => "blocked by overwrite", + "message_type" => "text", + "created_at" => DateTime.utc_now() |> DateTime.to_iso8601() + } + }, + secret: "test-shared-secret-b" + ) + + assert {:error, :not_authorized_for_room} = + Federation.receive_event(blocked_event, remote_domain) + end + + test "accepts participant thread events for mirrored rooms" do + channel = mirrored_channel_fixture() + server = Repo.get!(Server, channel.server_id) + remote_domain = "remote-b.example" + stream_id = "channel:#{channel.federated_source}" + + remote_actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://#{remote_domain}/users/alice", + username: "alice", + domain: remote_domain, + inbox_url: "https://#{remote_domain}/users/alice/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: remote_actor.id, + origin_domain: remote_domain, + role: "member", + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + thread_event = + signed_event( + "thread.upsert", + remote_domain, + stream_id, + 1, + %{ + "server" => %{ + "id" => server.federation_id, + "name" => server.name, + "is_public" => true + }, + "channel" => %{ + "id" => channel.federated_source, + "name" => channel.name, + "position" => channel.channel_position || 0 + }, + "thread" => %{ + "id" => "thread-remote-b", + "channel_id" => channel.federated_source, + "name" => "Not allowed", + "state" => "active", + "owner" => canonical_actor("alice", remote_domain) + } + }, + secret: "test-shared-secret-b" + ) + + assert {:ok, :applied} = Federation.receive_event(thread_event, remote_domain) + + assert Repo.get_by(FederationExtensionEvent, + event_type: ArblargSDK.canonical_event_type("thread.upsert"), + event_key: "thread:thread-remote-b:channel:#{channel.id}" + ) + end + + test "rejects participant role changes without admin room role" do + channel = mirrored_channel_fixture() + server = Repo.get!(Server, channel.server_id) + remote_domain = "remote-b.example" + stream_id = "channel:#{channel.federated_source}" + + remote_actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://#{remote_domain}/users/alice", + username: "alice", + domain: remote_domain, + inbox_url: "https://#{remote_domain}/users/alice/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: remote_actor.id, + origin_domain: remote_domain, + role: "member", + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + role_event = + signed_event( + "role.upsert", + remote_domain, + stream_id, + 1, + %{ + "server" => %{ + "id" => server.federation_id, + "name" => server.name, + "is_public" => true + }, + "channel" => %{ + "id" => channel.federated_source, + "name" => channel.name, + "position" => channel.channel_position || 0 + }, + "actor" => canonical_actor("alice", remote_domain), + "role" => %{ + "id" => "role-ops", + "name" => "Ops", + "position" => 1, + "permissions" => ["manage_messages"] + } + }, + secret: "test-shared-secret-b" + ) + + assert {:error, :not_authorized_for_room} = + Federation.receive_event(role_event, remote_domain) + + refute Repo.get_by(FederationExtensionEvent, + event_type: ArblargSDK.canonical_event_type("role.upsert"), + event_key: "role:role-ops:channel:#{channel.id}" + ) + end + + test "rejects elevated participant membership roles in mirrored rooms" do + channel = mirrored_channel_fixture() + server = Repo.get!(Server, channel.server_id) + remote_domain = "remote-b.example" + stream_id = "channel:#{channel.federated_source}" + + membership_event = + signed_event( + "membership.upsert", + remote_domain, + stream_id, + 1, + %{ + "server" => %{ + "id" => server.federation_id, + "name" => server.name, + "is_public" => true + }, + "channel" => %{ + "id" => channel.federated_source, + "name" => channel.name, + "position" => channel.channel_position || 0 + }, + "membership" => %{ + "actor" => canonical_actor("alice", remote_domain), + "role" => "admin", + "state" => "active", + "joined_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{} + } + }, + secret: "test-shared-secret-b" + ) + + assert {:error, :invalid_event_payload} = + Federation.receive_event(membership_event, remote_domain) + + refute Repo.get_by(FederationMembershipState, conversation_id: channel.id) + end + + test "applies extension events and surfaces projections for chat UI" do + remote_domain = "remote.example" + server_id = "https://remote.example/_arblarg/servers/990" + channel_id = "https://remote.example/_arblarg/channels/991" + channel_stream = "channel:#{channel_id}" + actor_uri = "https://remote.example/users/modbot" + subscriber = AccountsFixtures.user_fixture() + + {:ok, actor} = + %Actor{} + |> Actor.changeset(%{ + uri: actor_uri, + username: "modbot", + domain: remote_domain, + inbox_url: "#{actor_uri}/inbox", + public_key: "test-public-key" + }) + |> Repo.insert() + + Repo.insert!( + %Elektrine.Profiles.Follow{} + |> Elektrine.Profiles.Follow.changeset(%{ + follower_id: subscriber.id, + remote_actor_id: actor.id, + pending: false + }) + ) + + role_upsert_event_type = Elektrine.Messaging.ArblargSDK.canonical_event_type("role.upsert") + + role_assignment_event_type = + Elektrine.Messaging.ArblargSDK.canonical_event_type("role.assignment.upsert") + + permission_event_type = + Elektrine.Messaging.ArblargSDK.canonical_event_type("permission.overwrite.upsert") + + thread_upsert_event_type = + Elektrine.Messaging.ArblargSDK.canonical_event_type("thread.upsert") + + thread_archive_event_type = + Elektrine.Messaging.ArblargSDK.canonical_event_type("thread.archive") + + moderation_event_type = + Elektrine.Messaging.ArblargSDK.canonical_event_type("moderation.action.recorded") + + membership_event = + signed_event( + "membership.upsert", + remote_domain, + channel_stream, + 1, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "membership" => %{ + "actor" => canonical_actor("modbot", remote_domain, uri: actor_uri), + "role" => "admin", + "state" => "active", + "joined_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "metadata" => %{"source" => "presence-test"} + } + } + ) + + role_upsert_event = + signed_event( + "role.upsert", + remote_domain, + channel_stream, + 2, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "actor" => canonical_actor("modbot", remote_domain, uri: actor_uri), + "role" => %{ + "id" => "role-ops", + "name" => "Ops", + "position" => 1, + "permissions" => ["manage_channels", "manage_messages"] + } + } + ) + + role_assignment_event = + signed_event( + "role.assignment.upsert", + remote_domain, + channel_stream, + 3, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "actor" => canonical_actor("modbot", remote_domain, uri: actor_uri), + "assignment" => %{ + "role_id" => "role-ops", + "target" => %{"type" => "member", "id" => "member-42"}, + "state" => "assigned" + } + } + ) + + permission_overwrite_event = + signed_event( + "permission.overwrite.upsert", + remote_domain, + channel_stream, + 4, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "actor" => canonical_actor("modbot", remote_domain, uri: actor_uri), + "overwrite" => %{ + "id" => "overwrite-1", + "target" => %{"type" => "role", "id" => "role-ops"}, + "allow" => ["send_messages"], + "deny" => ["attach_files"] + } + } + ) + + thread_upsert_event = + signed_event( + "thread.upsert", + remote_domain, + channel_stream, + 5, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "thread" => %{ + "id" => "thread-9000", + "channel_id" => channel_id, + "name" => "Incident 9000", + "state" => "active", + "owner" => canonical_actor("modbot", remote_domain, uri: actor_uri), + "message_count" => 12, + "member_count" => 4 + } + } + ) + + thread_archive_event = + signed_event( + "thread.archive", + remote_domain, + channel_stream, + 6, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "thread_id" => "thread-9000", + "archived_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "actor" => canonical_actor("modbot", remote_domain, uri: actor_uri), + "reason" => "resolved" + } + ) + + moderation_action_event = + signed_event( + "moderation.action.recorded", + remote_domain, + channel_stream, + 7, + %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "channel" => %{"id" => channel_id, "name" => "general", "position" => 0}, + "action" => %{ + "id" => "mod-action-1", + "kind" => "timeout", + "target" => %{"type" => "member", "id" => "member-42"}, + "actor" => canonical_actor("modbot", remote_domain, uri: actor_uri), + "occurred_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "duration_seconds" => 600, + "reason" => "spam" + } + } + ) + + presence_update_item = %{ + "event_type" => "presence.update", + "origin_domain" => remote_domain, + "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "payload" => %{ + "server" => %{"id" => server_id, "name" => "remote", "is_public" => true}, + "presence" => %{ + "actor" => + canonical_actor("modbot", remote_domain, + uri: actor_uri, + display_name: "Mod Bot" + ), + "status" => "online", + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "activities" => [%{"name" => "Moderating"}] + } + } + } + + Phoenix.PubSub.subscribe(Elektrine.PubSub, "user:#{subscriber.id}") + + assert {:ok, :applied} = Federation.receive_event(membership_event, remote_domain) + assert {:ok, :applied} = Federation.receive_event(role_upsert_event, remote_domain) + assert {:ok, :applied} = Federation.receive_event(role_assignment_event, remote_domain) + assert {:ok, :applied} = Federation.receive_event(permission_overwrite_event, remote_domain) + assert {:ok, :applied} = Federation.receive_event(thread_upsert_event, remote_domain) + assert {:ok, :applied} = Federation.receive_event(thread_archive_event, remote_domain) + assert {:ok, :applied} = Federation.receive_event(moderation_action_event, remote_domain) + + assert {:ok, %{"counts" => %{"applied" => 1}}} = + Federation.receive_ephemeral_batch( + %{"items" => [presence_update_item]}, + remote_domain + ) + + mirror_server = Repo.get_by(Server, federation_id: server_id) + mirror_channel = Repo.get_by(Conversation, type: "channel", federated_source: channel_id) + assert mirror_server + assert mirror_channel + + assert_receive {:federation_presence_update, presence_broadcast}, 1_000 + assert Enum.sort(presence_broadcast.server_ids) == [mirror_server.id] + assert presence_broadcast.status == "online" + assert presence_broadcast.handle == "@modbot@remote.example" + + assert Repo.get_by(FederationExtensionEvent, + event_type: role_upsert_event_type, + origin_domain: remote_domain, + event_key: "role:role-ops:channel:#{mirror_channel.id}" + ) + + assert Repo.get_by(FederationExtensionEvent, + event_type: role_assignment_event_type, + origin_domain: remote_domain, + event_key: "role_assignment:role-ops:member:member-42:channel:#{mirror_channel.id}" + ) + + assert Repo.get_by(FederationExtensionEvent, + event_type: permission_event_type, + origin_domain: remote_domain, + event_key: "overwrite:overwrite-1:channel:#{mirror_channel.id}" + ) + + assert Repo.get_by(FederationExtensionEvent, + event_type: thread_upsert_event_type, + origin_domain: remote_domain, + event_key: "thread:thread-9000:channel:#{mirror_channel.id}" + ) + + assert Repo.get_by(FederationExtensionEvent, + event_type: thread_archive_event_type, + origin_domain: remote_domain, + event_key: "thread:thread-9000:channel:#{mirror_channel.id}" + ) + + assert Repo.get_by(FederationExtensionEvent, + event_type: moderation_event_type, + origin_domain: remote_domain, + event_key: "moderation:mod-action-1:channel:#{mirror_channel.id}" + ) + + assert Repo.get_by(FederationAccountPresenceState, + remote_actor_id: actor.id, + status: "online" + ) + + assert Enum.any?(Federation.list_server_presence_states(mirror_server.id), fn state -> + state.status == "online" + end) + + assert Enum.any?( + Federation.list_visible_server_presence_states(mirror_server.id, subscriber.id), + fn state -> state.remote_actor_id == actor.id and state.status == "online" end + ) + + extension_messages = + from(m in ChatMessage, + where: + m.conversation_id == ^mirror_channel.id and + m.message_type == "system" and + like(m.federated_source, "arblarg:ext:%") + ) + |> Repo.all() + + assert Enum.any?(extension_messages, &String.contains?(&1.content || "", "Role updated")) + + assert Enum.any?( + extension_messages, + &String.contains?(&1.content || "", "Permissions updated") + ) + + assert Enum.any?(extension_messages, &String.contains?(&1.content || "", "Thread archived")) + + assert Enum.any?( + extension_messages, + &String.contains?(&1.content || "", "Moderation action") + ) + end + + test "publishes shared-governance extension events with local projections and room fanout" do + owner = AccountsFixtures.user_fixture() + {:ok, server} = Messaging.create_server(owner.id, %{name: "shared-governance-local"}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "ops", + description: "local governance" + }) + + remote_actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.example/users/alice", + username: "alice", + domain: "remote.example", + inbox_url: "https://remote.example/users/alice/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: remote_actor.id, + origin_domain: "remote.example", + role: "member", + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + assert :ok = + Federation.publish_extension_event( + channel.id, + owner.id, + "role.upsert", + %{ + "role" => %{ + "id" => "builtin:admin", + "name" => "Admin", + "permissions" => ["manage_roles", "manage_permissions"], + "position" => 80 + } + } + ) + + canonical_event_type = ArblargSDK.canonical_event_type("role.upsert") + + assert Repo.get_by(FederationExtensionEvent, + event_type: canonical_event_type, + origin_domain: Federation.local_domain(), + event_key: "role:builtin:admin:channel:#{channel.id}" + ) + + assert {:ok, _event, target_domains, ^canonical_event_type, extension_payload} = + Builders.build_extension_event( + channel.id, + owner.id, + "role.upsert", + %{ + "role" => %{ + "id" => "builtin:admin", + "name" => "Admin", + "permissions" => ["manage_roles", "manage_permissions"], + "position" => 80 + } + }, + builder_context() + ) + + assert "remote.example" in target_domains + + assert get_in(extension_payload, ["actor", "handle"]) == + "#{owner.username}@#{Federation.local_domain()}" + end + + test "applies room-scoped presence updates for shared-room participants without follows" do + owner = AccountsFixtures.user_fixture() + {:ok, server} = Messaging.create_server(owner.id, %{name: "room-presence-local"}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "ops", + description: "room presence" + }) + + remote_domain = "remote.example" + actor_uri = "https://remote.example/users/alice" + + remote_actor = + %Actor{} + |> Actor.changeset(%{ + uri: actor_uri, + username: "alice", + domain: remote_domain, + inbox_url: "https://remote.example/users/alice/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: remote_actor.id, + origin_domain: remote_domain, + role: "member", + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + Phoenix.PubSub.subscribe( + Elektrine.PubSub, + Elektrine.PubSubTopics.conversation(channel.id) + ) + + presence_update_item = %{ + "event_type" => "presence.update", + "origin_domain" => remote_domain, + "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "payload" => %{ + "refs" => %{ + "server_id" => Elektrine.Messaging.Federation.Utils.server_federation_id(server.id), + "channel_id" => Elektrine.Messaging.Federation.Utils.channel_federation_id(channel.id) + }, + "presence" => %{ + "actor" => + canonical_actor("alice", remote_domain, + uri: actor_uri, + display_name: "Alice" + ), + "status" => "online", + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "activities" => [%{"name" => "Reading ops"}] + } + } + } + + assert {:ok, %{"counts" => %{"applied" => 1}}} = + Federation.receive_ephemeral_batch( + %{"items" => [presence_update_item]}, + remote_domain + ) + + assert_receive {:federation_presence_update, presence_broadcast}, 1_000 + assert presence_broadcast.conversation_id == channel.id + assert presence_broadcast.remote_actor_id == remote_actor.id + assert presence_broadcast.status == "online" + + assert Repo.get_by(FederationRoomPresenceState, + conversation_id: channel.id, + remote_actor_id: remote_actor.id, + status: "online" + ) + + refute Repo.get_by(FederationAccountPresenceState, remote_actor_id: remote_actor.id) + + assert Enum.any?( + Federation.list_visible_room_presence_states(channel.id, owner.id), + fn state -> + state.remote_actor_id == remote_actor.id and state.status == "online" + end + ) + end + + test "ignores remote presence updates without a local subscription" do + remote_domain = "remote.example" + actor_uri = "https://remote.example/users/ghost" + + presence_update_item = %{ + "event_type" => "presence.update", + "origin_domain" => remote_domain, + "sent_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "payload" => %{ + "presence" => %{ + "actor" => + canonical_actor("ghost", remote_domain, + uri: actor_uri, + display_name: "Ghost" + ), + "status" => "online", + "updated_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "activities" => [%{"name" => "Lurking"}] + } + } + } + + assert {:ok, %{"counts" => %{"applied" => 1}}} = + Federation.receive_ephemeral_batch( + %{"items" => [presence_update_item]}, + remote_domain + ) + + refute_receive {:federation_presence_update, _payload}, 200 + + actor = Repo.get_by(Actor, uri: actor_uri) + assert actor + + refute Repo.get_by(FederationAccountPresenceState, remote_actor_id: actor.id) + end + end + + describe "authorized room egress" do + setup do + previous = Application.get_env(:elektrine, :messaging_federation, []) + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.merge(previous, + enabled: true, + peers: [ + %{ + domain: "remote.example", + base_url: "https://remote.example", + shared_secret: "room-secret", + allow_incoming: true, + allow_outgoing: true, + supported_event_types: ["message.create"] + }, + %{ + domain: "remote-b.example", + base_url: "https://remote-b.example", + shared_secret: "remote-b-room-secret", + allow_incoming: true, + allow_outgoing: true, + supported_event_types: ["message.create", "invite.upsert"] + }, + %{ + domain: "stranger.example", + base_url: "https://stranger.example", + shared_secret: "stranger-secret", + allow_incoming: true, + allow_outgoing: true, + supported_event_types: ["message.create"] + } + ] + ) + ) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, previous) + end) + + :ok + end + + test "routes room events only to authorized peers and hides replay from others" do + owner = AccountsFixtures.user_fixture() + {:ok, server} = Messaging.create_server(owner.id, %{name: "private-room", is_public: false}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "ops", + description: "private room", + is_public: false + }) + + remote_actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.example/users/alice", + username: "alice", + domain: "remote.example", + inbox_url: "https://remote.example/users/alice/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: remote_actor.id, + origin_domain: "remote.example", + role: "member", + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + message = + %ChatMessage{} + |> ChatMessage.changeset(%{ + conversation_id: channel.id, + sender_id: owner.id, + content: "classified", + message_type: "text" + }) + |> Repo.insert!() + + assert {:ok, event} = Builders.build_message_created_event(message, builder_context()) + assert Visibility.target_domains_for_event(event) == ["remote.example"] + + now = DateTime.utc_now() |> DateTime.truncate(:second) + + outbox_event = + Repo.insert!(%FederationOutboxEvent{ + event_id: event["event_id"], + event_type: event["event_type"], + stream_id: event["stream_id"], + sequence: event["sequence"], + payload: event, + target_domains: ["remote.example"], + delivered_domains: [], + attempt_count: 0, + max_attempts: 8, + status: "pending", + next_retry_at: now, + partition_month: Date.new!(now.year, now.month, 1) + }) + + assert outbox_event.target_domains == ["remote.example"] + + visible_replay = + Federation.export_stream_events(outbox_event.stream_id, peer: %{domain: "remote.example"}) + + hidden_replay = + Federation.export_stream_events(outbox_event.stream_id, + peer: %{domain: "stranger.example"} + ) + + assert length(visible_replay["events"]) == 1 + assert hidden_replay["events"] == [] + assert hidden_replay["last_sequence"] == 0 + end + + test "replay exposes older room participation events to newly authorized peers without leaking targeted governance" do + owner = AccountsFixtures.user_fixture() + {:ok, server} = Messaging.create_server(owner.id, %{name: "history-room", is_public: false}) + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "ops", + description: "private room", + is_public: false + }) + + remote_actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote.example/users/alice", + username: "alice", + domain: "remote.example", + inbox_url: "https://remote.example/users/alice/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: remote_actor.id, + origin_domain: "remote.example", + role: "member", + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + message = + %ChatMessage{} + |> ChatMessage.changeset(%{ + conversation_id: channel.id, + sender_id: owner.id, + content: "durable room history", + message_type: "text" + }) + |> Repo.insert!() + + assert {:ok, message_event} = + Builders.build_message_created_event(message, builder_context()) + + now = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert!(%FederationOutboxEvent{ + event_id: message_event["event_id"], + event_type: message_event["event_type"], + stream_id: message_event["stream_id"], + sequence: message_event["sequence"], + payload: message_event, + target_domains: ["remote.example"], + delivered_domains: [], + attempt_count: 0, + max_attempts: 8, + status: "pending", + next_retry_at: now, + partition_month: Date.new!(now.year, now.month, 1) + }) + + invite_event = + Builders.event_envelope( + "invite.upsert", + message_event["stream_id"], + message_event["sequence"] + 1, + %{ + "refs" => %{ + "server_id" => + server.federation_id || + Elektrine.Messaging.Federation.Utils.server_federation_id(server.id), + "channel_id" => + channel.federated_source || + Elektrine.Messaging.Federation.Utils.channel_federation_id(channel.id) + }, + "invite" => %{ + "actor" => canonical_actor(owner.username, Federation.local_domain()), + "target" => canonical_actor("bob", "remote.example"), + "role" => "member", + "state" => "pending", + "invited_at" => DateTime.to_iso8601(now), + "updated_at" => DateTime.to_iso8601(now), + "metadata" => %{} + } + }, + builder_context() + ) + + Repo.insert!(%FederationOutboxEvent{ + event_id: invite_event["event_id"], + event_type: invite_event["event_type"], + stream_id: invite_event["stream_id"], + sequence: invite_event["sequence"], + payload: invite_event, + target_domains: ["remote.example"], + delivered_domains: [], + attempt_count: 0, + max_attempts: 8, + status: "pending", + next_retry_at: now, + partition_month: Date.new!(now.year, now.month, 1) + }) + + newly_authorized_actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://remote-b.example/users/carol", + username: "carol", + domain: "remote-b.example", + inbox_url: "https://remote-b.example/users/carol/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: newly_authorized_actor.id, + origin_domain: "remote-b.example", + role: "member", + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + replay = + Federation.export_stream_events(message_event["stream_id"], + peer: %{domain: "remote-b.example"} + ) + + assert Enum.any?(replay["events"], &(&1["event_id"] == message_event["event_id"])) + refute Enum.any?(replay["events"], &(&1["event_id"] == invite_event["event_id"])) + end + end + + defp builder_context do + %{ + local_domain: &Elektrine.Messaging.Federation.Runtime.local_domain/0, + local_event_signing_material: + &Elektrine.Messaging.Federation.Runtime.local_event_signing_material/0, + outgoing_peers: &Federation.outgoing_peers/0, + maybe_iso8601: fn + %DateTime{} = datetime -> DateTime.to_iso8601(datetime) + nil -> nil + _ -> nil + end, + normalize_optional_string: fn + value when is_binary(value) -> + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + + _ -> + nil + end, + presence_ttl_seconds: fn -> 60 end + } + end + + defp mirrored_channel_fixture do + suffix = System.unique_integer([:positive]) + + server = + %Server{} + |> Server.changeset(%{ + name: "Remote Server #{suffix}", + description: "Federated mirror server", + federation_id: "https://remote.example/_arblarg/servers/#{suffix}", + origin_domain: "remote.example", + is_federated_mirror: true + }) + |> Repo.insert!() + + %ChatConversation{} + |> ChatConversation.channel_changeset(%{ + name: "remote-channel-#{suffix}", + description: "Mirrored remote channel", + server_id: server.id, + federated_source: "https://remote.example/_arblarg/channels/#{suffix}", + is_federated_mirror: true + }) + |> Repo.insert!() + end + + defp signed_event(event_type, remote_domain, stream_id, sequence, payload, opts \\ []) do + key_id = Keyword.get(opts, :key_id, "k1") + secret = Keyword.get(opts, :secret, "test-shared-secret") + + unsigned = %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => ArblargSDK.protocol_id(), + "protocol_label" => ArblargSDK.protocol_label(), + "protocol_version" => ArblargSDK.protocol_version(), + "event_id" => Keyword.get(opts, :event_id, "evt-#{Ecto.UUID.generate()}"), + "event_type" => event_type, + "origin_domain" => remote_domain, + "stream_id" => stream_id, + "sequence" => sequence, + "sent_at" => Keyword.get(opts, :sent_at, DateTime.utc_now() |> DateTime.to_iso8601()), + "idempotency_key" => Keyword.get(opts, :idempotency_key, "idem-#{Ecto.UUID.generate()}"), + "payload" => payload + } + + ArblargSDK.sign_event_envelope(unsigned, key_id, secret) + end + + defp canonical_actor(username, domain, opts \\ []) do + uri = + Keyword.get(opts, :uri) || + "https://#{domain}/users/#{username}" + + {public_key, _private_key} = ArblargSDK.derive_keypair_from_secret("actor:#{uri}") + + %{ + "id" => uri, + "uri" => uri, + "username" => username, + "display_name" => Keyword.get(opts, :display_name, username), + "domain" => domain, + "handle" => "#{username}@#{domain}", + "key_id" => "#{uri}#arblarg-key", + "public_key" => Base.url_encode64(public_key, padding: false) + } + end + + defp sign_snapshot(payload, key_id, secret) + when is_map(payload) and is_binary(key_id) and is_binary(secret) do + payload = + payload + |> Map.put_new("governance", %{"memberships" => [], "invites" => [], "bans" => []}) + |> Map.put_new("stream_positions", default_snapshot_stream_positions(payload)) + + Map.put(payload, "signature", %{ + "algorithm" => "ed25519", + "key_id" => key_id, + "value" => + payload + |> ArblargSDK.canonical_json_payload() + |> ArblargSDK.sign_payload(secret) + }) + end + + defp default_snapshot_stream_positions(%{ + "origin_domain" => origin_domain, + "server" => %{"id" => server_id} + }) + when is_binary(origin_domain) and is_binary(server_id) do + [ + %{ + "origin_domain" => origin_domain, + "stream_id" => "server:#{server_id}", + "last_sequence" => 0 + } + ] + end + + defp default_snapshot_stream_positions(_payload), do: [] +end diff --git a/apps/elektrine/test/elektrine/messaging/federation_threads_test.exs b/apps/elektrine/test/elektrine/messaging/federation_threads_test.exs new file mode 100644 index 0000000..4c08ece --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/federation_threads_test.exs @@ -0,0 +1,464 @@ +defmodule Elektrine.Messaging.FederationThreadsTest do + use Elektrine.DataCase, async: false + + alias Elektrine.AccountsFixtures + alias Elektrine.ActivityPub.Actor + alias Elektrine.Messaging + + alias Elektrine.Messaging.{ + ArblargSDK, + ChatConversation, + ChatThread, + ChatThreads, + FederationExtensionEvent, + FederationMembershipState, + Server + } + + alias Elektrine.Messaging.Federation.{Builders, EventRouter} + alias Elektrine.Repo + + @remote_domain "remote.example" + + describe "outbound thread federation" do + test "create_thread publishes a schema-valid thread.upsert extension event" do + %{owner: owner, channel: channel} = local_server_channel_fixture() + + assert {:ok, thread} = + ChatThreads.create_thread(channel.id, owner.id, %{title: "Federated plans"}) + + # The publish path validates against the SDK schema before persisting the + # local extension projection, so this row proves a valid payload. + canonical_type = ArblargSDK.canonical_event_type("thread.upsert") + thread_ref = ChatThreads.thread_federation_ref(thread) + + assert %FederationExtensionEvent{payload: payload, status: "active"} = + Repo.get_by(FederationExtensionEvent, + event_type: canonical_type, + conversation_id: channel.id, + event_key: "thread:#{thread_ref}:channel:#{channel.id}" + ) + + assert get_in(payload, ["thread", "name"]) == "Federated plans" + assert get_in(payload, ["thread", "state"]) == "active" + assert get_in(payload, ["thread", "owner", "username"]) == owner.username + assert :ok = ArblargSDK.validate_event_payload(canonical_type, payload) + end + + test "archive_thread publishes a schema-valid thread.archive extension event" do + %{owner: owner, channel: channel} = local_server_channel_fixture() + + {:ok, thread} = ChatThreads.create_thread(channel.id, owner.id, %{title: "Short lived"}) + assert {:ok, _archived} = ChatThreads.archive_thread(thread.id, owner.id) + + canonical_type = ArblargSDK.canonical_event_type("thread.archive") + thread_ref = ChatThreads.thread_federation_ref(thread) + + assert %FederationExtensionEvent{payload: payload} = + Repo.get_by(FederationExtensionEvent, + event_type: canonical_type, + conversation_id: channel.id, + event_key: "thread:#{thread_ref}:channel:#{channel.id}" + ) + + assert payload["thread_id"] == thread_ref + assert is_binary(payload["archived_at"]) + assert get_in(payload, ["actor", "username"]) == owner.username + assert :ok = ArblargSDK.validate_event_payload(canonical_type, payload) + end + + test "builds a signed thread.upsert envelope through the extension event builder" do + %{owner: owner, channel: channel} = local_server_channel_fixture() + + {:ok, thread} = ChatThreads.create_thread(channel.id, owner.id, %{title: "Envelope"}) + payload = ChatThreads.thread_upsert_payload(thread, Repo.get(ChatConversation, channel.id)) + + assert {:ok, event, _target_domains, canonical_type, extension_payload} = + Builders.build_extension_event( + channel.id, + owner.id, + "thread.upsert", + payload, + builder_context() + ) + + assert canonical_type == ArblargSDK.canonical_event_type("thread.upsert") + assert event["event_type"] == canonical_type + assert :ok = ArblargSDK.validate_event_payload(canonical_type, extension_payload) + assert :ok = ArblargSDK.validate_event_envelope(event) + end + end + + describe "thread message federation" do + test "outbound thread messages carry a schema-valid thread_id reference" do + %{owner: owner, channel: channel} = local_server_channel_fixture() + + {:ok, thread} = ChatThreads.create_thread(channel.id, owner.id, %{title: "Ref carrier"}) + {:ok, message} = ChatThreads.create_thread_message(thread.id, owner.id, "reply in thread") + + assert {:ok, event} = + Builders.build_message_created_event(message, builder_context()) + + assert event["event_type"] == "message.create" + + assert get_in(event, ["payload", "message", "thread_id"]) == + ChatThreads.thread_federation_ref(thread) + + assert :ok = ArblargSDK.validate_event_envelope(event) + assert :ok = ArblargSDK.validate_event_payload("message.create", event["payload"]) + end + + test "outbound main-timeline messages carry no thread_id" do + %{owner: owner, channel: channel} = local_server_channel_fixture() + + {:ok, message} = Messaging.create_chat_text_message(channel.id, owner.id, "plain message") + + assert {:ok, event} = + Builders.build_message_created_event(message, builder_context()) + + refute Map.has_key?(event["payload"]["message"], "thread_id") + end + + test "inbound message.create with a known thread ref lands in the thread" do + %{channel: channel} = mirrored_channel_fixture() + actor = remote_member_fixture(channel, "alice", "member") + + thread_ref = "https://#{@remote_domain}/_arblarg/threads/9100" + + assert :ok = + EventRouter.apply_event( + "thread.upsert", + thread_upsert_data(channel, thread_ref, "Inbound thread", "active", actor), + @remote_domain + ) + + thread = Repo.get_by(ChatThread, federation_id: thread_ref, origin_domain: @remote_domain) + + Phoenix.PubSub.subscribe(Elektrine.PubSub, "chat:#{channel.id}") + + assert :ok = + EventRouter.apply_event( + "message.create", + message_create_data( + channel, + actor, + "https://#{@remote_domain}/_arblarg/messages/8100", + content: "remote reply", + thread_id: thread_ref + ), + @remote_domain + ) + + message = + Repo.get_by(Elektrine.Messaging.ChatMessage, + conversation_id: channel.id, + federated_source: "https://#{@remote_domain}/_arblarg/messages/8100" + ) + + assert message.thread_id == thread.id + + # Counters bump on top of the remote-declared message_count (3). + updated = ChatThreads.get_thread(thread.id) + assert updated.message_count == 4 + + # Thread messages broadcast into the thread panel, not the timeline. + message_id = message.id + assert_receive {:new_thread_message, %Elektrine.Messaging.ChatMessage{id: ^message_id}} + + assert message.id in Enum.map(ChatThreads.list_thread_messages(thread.id), & &1.id) + end + + test "inbound message.create with an unknown thread ref falls back to the timeline" do + %{channel: channel} = mirrored_channel_fixture() + actor = remote_member_fixture(channel, "alice", "member") + + assert :ok = + EventRouter.apply_event( + "message.create", + message_create_data( + channel, + actor, + "https://#{@remote_domain}/_arblarg/messages/8101", + content: "orphan thread ref", + thread_id: "https://#{@remote_domain}/_arblarg/threads/does-not-exist" + ), + @remote_domain + ) + + message = + Repo.get_by(Elektrine.Messaging.ChatMessage, + conversation_id: channel.id, + federated_source: "https://#{@remote_domain}/_arblarg/messages/8101" + ) + + assert is_nil(message.thread_id) + end + end + + describe "inbound thread federation" do + test "thread.upsert creates a local chat_threads row on a mirrored channel" do + %{channel: channel} = mirrored_channel_fixture() + actor = remote_member_fixture(channel, "alice", "member") + + thread_id = "https://#{@remote_domain}/_arblarg/threads/9001" + + assert :ok = + EventRouter.apply_event( + "thread.upsert", + thread_upsert_data(channel, thread_id, "Remote planning", "active", actor), + @remote_domain + ) + + assert %ChatThread{} = + thread = + Repo.get_by(ChatThread, + federation_id: thread_id, + origin_domain: @remote_domain + ) + + assert thread.conversation_id == channel.id + assert thread.title == "Remote planning" + assert thread.message_count == 3 + assert is_nil(thread.archived_at) + + # The extension event projection is stored alongside the local row. + assert Repo.get_by(FederationExtensionEvent, + event_type: ArblargSDK.canonical_event_type("thread.upsert"), + event_key: "thread:#{thread_id}:channel:#{channel.id}" + ) + end + + test "a later thread.upsert updates the projected local thread" do + %{channel: channel} = mirrored_channel_fixture() + actor = remote_member_fixture(channel, "alice", "member") + + thread_id = "https://#{@remote_domain}/_arblarg/threads/9002" + + assert :ok = + EventRouter.apply_event( + "thread.upsert", + thread_upsert_data(channel, thread_id, "First title", "active", actor), + @remote_domain + ) + + assert :ok = + EventRouter.apply_event( + "thread.upsert", + thread_upsert_data(channel, thread_id, "Renamed thread", "active", actor), + @remote_domain + ) + + thread = Repo.get_by(ChatThread, federation_id: thread_id, origin_domain: @remote_domain) + assert thread.title == "Renamed thread" + + assert Repo.aggregate( + from(t in ChatThread, where: t.conversation_id == ^channel.id), + :count + ) == 1 + end + + test "thread.archive archives the projected local thread" do + %{channel: channel} = mirrored_channel_fixture() + actor = remote_member_fixture(channel, "alice", "moderator") + + thread_id = "https://#{@remote_domain}/_arblarg/threads/9003" + + assert :ok = + EventRouter.apply_event( + "thread.upsert", + thread_upsert_data(channel, thread_id, "To be archived", "active", actor), + @remote_domain + ) + + archived_at = DateTime.utc_now() |> DateTime.truncate(:second) + + data = + channel_refs(channel) + |> Map.merge(%{ + "thread_id" => thread_id, + "archived_at" => DateTime.to_iso8601(archived_at), + "actor" => canonical_actor("alice") + }) + + assert :ok = EventRouter.apply_event("thread.archive", data, @remote_domain) + + thread = Repo.get_by(ChatThread, federation_id: thread_id, origin_domain: @remote_domain) + assert thread.archived_at == archived_at + end + + test "thread.upsert from a non-member is rejected and projects nothing" do + %{channel: channel} = mirrored_channel_fixture() + + outsider = + %Actor{} + |> Actor.changeset(%{ + uri: "https://#{@remote_domain}/users/outsider", + username: "outsider", + domain: @remote_domain, + inbox_url: "https://#{@remote_domain}/users/outsider/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + thread_id = "https://#{@remote_domain}/_arblarg/threads/9004" + + assert {:error, _reason} = + EventRouter.apply_event( + "thread.upsert", + thread_upsert_data(channel, thread_id, "Denied", "active", outsider), + @remote_domain + ) + + refute Repo.get_by(ChatThread, federation_id: thread_id, origin_domain: @remote_domain) + end + end + + ## Fixtures and helpers + + defp local_server_channel_fixture do + owner = AccountsFixtures.user_fixture() + {:ok, server} = Messaging.create_server(owner.id, %{name: "thread-fed", is_public: true}) + [channel | _] = server.channels + + %{owner: owner, server: server, channel: channel} + end + + defp mirrored_channel_fixture do + suffix = System.unique_integer([:positive]) + + server = + %Server{} + |> Server.changeset(%{ + name: "Remote Server #{suffix}", + description: "Federated mirror server", + federation_id: "https://#{@remote_domain}/_arblarg/servers/#{suffix}", + origin_domain: @remote_domain, + is_federated_mirror: true + }) + |> Repo.insert!() + + channel = + %ChatConversation{} + |> ChatConversation.channel_changeset(%{ + name: "remote-channel-#{suffix}", + description: "Mirrored remote channel", + server_id: server.id, + federated_source: "https://#{@remote_domain}/_arblarg/channels/#{suffix}", + is_federated_mirror: true + }) + |> Repo.insert!() + + %{server: server, channel: channel} + end + + defp remote_member_fixture(channel, username, role) do + actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://#{@remote_domain}/users/#{username}", + username: username, + domain: @remote_domain, + inbox_url: "https://#{@remote_domain}/users/#{username}/inbox", + public_key: "test-public-key" + }) + |> Repo.insert!() + + timestamp = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert!(%FederationMembershipState{ + conversation_id: channel.id, + remote_actor_id: actor.id, + origin_domain: @remote_domain, + role: role, + state: "active", + joined_at_remote: timestamp, + updated_at_remote: timestamp, + metadata: %{} + }) + + actor + end + + defp channel_refs(channel) do + server = Repo.get!(Server, channel.server_id) + + %{ + "server" => %{"id" => server.federation_id, "name" => server.name, "is_public" => true}, + "channel" => %{ + "id" => channel.federated_source, + "name" => channel.name, + "position" => 0 + }, + "refs" => %{ + "server_id" => server.federation_id, + "channel_id" => channel.federated_source + } + } + end + + defp thread_upsert_data(channel, thread_id, name, state, %Actor{} = owner_actor) do + channel_refs(channel) + |> Map.put("thread", %{ + "id" => thread_id, + "channel_id" => channel.federated_source, + "name" => name, + "state" => state, + "message_count" => 3, + "owner" => canonical_actor(owner_actor.username, uri: owner_actor.uri) + }) + end + + defp canonical_actor(username, opts \\ []) do + uri = Keyword.get(opts, :uri, "https://#{@remote_domain}/users/#{username}") + + %{ + "id" => uri, + "uri" => uri, + "username" => username, + "display_name" => username, + "domain" => @remote_domain, + "handle" => "#{username}@#{@remote_domain}" + } + end + + defp message_create_data(channel, %Actor{} = sender, message_id, opts) do + message = + %{ + "id" => message_id, + "channel_id" => channel.federated_source, + "content" => Keyword.fetch!(opts, :content), + "message_type" => "text", + "created_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "sender" => canonical_actor(sender.username, uri: sender.uri) + } + |> maybe_put("thread_id", Keyword.get(opts, :thread_id)) + + Map.put(channel_refs(channel), "message", message) + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp builder_context do + %{ + local_domain: &Elektrine.Messaging.Federation.Runtime.local_domain/0, + local_event_signing_material: + &Elektrine.Messaging.Federation.Runtime.local_event_signing_material/0, + outgoing_peers: &Elektrine.Messaging.Federation.outgoing_peers/0, + maybe_iso8601: fn + %DateTime{} = datetime -> DateTime.to_iso8601(datetime) + _ -> nil + end, + normalize_optional_string: fn + value when is_binary(value) -> + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + + _ -> + nil + end + } + end +end diff --git a/apps/elektrine/test/elektrine/messaging/messages_mentions_test.exs b/apps/elektrine/test/elektrine/messaging/messages_mentions_test.exs new file mode 100644 index 0000000..93ed774 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/messages_mentions_test.exs @@ -0,0 +1,66 @@ +defmodule Elektrine.Social.MessagesMentionsTest do + use Elektrine.DataCase, async: true + + import Elektrine.AccountsFixtures + + alias Elektrine.Accounts + alias Elektrine.Messaging + alias Elektrine.Notifications + + describe "chat mention notifications" do + setup do + sender = user_fixture() + mentioned = user_fixture() + restricted = user_fixture() + + {:ok, conversation} = + Messaging.create_group_conversation( + sender.id, + %{name: "mention-group-#{System.unique_integer([:positive])}"}, + [mentioned.id, restricted.id] + ) + + %{sender: sender, mentioned: mentioned, restricted: restricted, conversation: conversation} + end + + test "creates mention notifications for mentioned conversation members", %{ + sender: sender, + mentioned: mentioned, + conversation: conversation + } do + {:ok, message} = + Messaging.create_text_message( + conversation.id, + sender.id, + "hello @#{mentioned.username}" + ) + + mention_notifications = + Notifications.list_notifications(mentioned.id) + |> Enum.filter(&(&1.type == "mention")) + + assert Enum.any?(mention_notifications, &(&1.source_id == message.id)) + end + + test "respects mention privacy preferences", %{ + sender: sender, + restricted: restricted, + conversation: conversation + } do + {:ok, _} = Accounts.block_user(restricted.id, sender.id) + + {:ok, _message} = + Messaging.create_text_message( + conversation.id, + sender.id, + "hello @#{restricted.username}" + ) + + restricted_mentions = + Notifications.list_notifications(restricted.id) + |> Enum.filter(&(&1.type == "mention")) + + assert restricted_mentions == [] + end + end +end diff --git a/apps/elektrine/test/elektrine/messaging/messages_test.exs b/apps/elektrine/test/elektrine/messaging/messages_test.exs new file mode 100644 index 0000000..16254c1 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/messages_test.exs @@ -0,0 +1,87 @@ +defmodule Elektrine.Social.MessagesTest do + use Elektrine.DataCase, async: false + + alias Elektrine.AccountsFixtures + alias Elektrine.Repo + alias Elektrine.Social.{Conversation, Message} + alias Elektrine.Social.Messages + + test "get_user_discussion_posts/2 allows anonymous viewers for public communities" do + author = AccountsFixtures.user_fixture() + + community = + %Conversation{} + |> Conversation.changeset(%{ + name: "public-community-#{System.unique_integer([:positive])}", + type: "community", + is_public: true, + creator_id: author.id + }) + |> Repo.insert!() + + %Message{} + |> Message.changeset(%{ + conversation_id: community.id, + sender_id: author.id, + post_type: "discussion", + message_type: "text", + title: "Hello world", + content: "first post" + }) + |> Repo.insert!() + + posts = Messages.get_user_discussion_posts(author.id, limit: 5, viewer_id: nil) + + assert length(posts) == 1 + assert hd(posts).conversation_id == community.id + end + + test "get_cached_replies_to_activitypub_ids/1 matches parents by activitypub url variants" do + author = AccountsFixtures.user_fixture() + + conversation = + %Conversation{} + |> Conversation.changeset(%{ + type: "dm", + creator_id: author.id + }) + |> Repo.insert!() + + parent = + %Message{} + |> Message.changeset(%{ + conversation_id: conversation.id, + sender_id: author.id, + post_type: "post", + message_type: "text", + content: "Parent post", + visibility: "public", + federated: true, + activitypub_id: "https://remote.example/objects/parent-123", + activitypub_url: "https://remote.example/@alice/parent-123" + }) + |> Repo.insert!() + + reply = + %Message{} + |> Message.changeset(%{ + conversation_id: conversation.id, + sender_id: author.id, + reply_to_id: parent.id, + post_type: "comment", + message_type: "text", + content: "Reply post", + visibility: "public" + }) + |> Repo.insert!() + + replies = + Messages.get_cached_replies_to_activitypub_ids([ + "https://remote.example/@alice/parent-123/", + "https://remote.example/@alice/parent-123?view=thread" + ]) + + assert Enum.map(replies, & &1.id) == [reply.id] + assert hd(replies).parent_activitypub_id == parent.activitypub_id + end +end diff --git a/apps/elektrine/test/elektrine/messaging/moderation_tools_test.exs b/apps/elektrine/test/elektrine/messaging/moderation_tools_test.exs new file mode 100644 index 0000000..ad75507 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/moderation_tools_test.exs @@ -0,0 +1,769 @@ +defmodule Elektrine.Messaging.ModerationToolsTest do + use Elektrine.DataCase + + alias Elektrine.Accounts + alias Elektrine.Messaging + alias Elektrine.Messaging.ModerationTools + + defp create_community(owner_id, name) do + %Elektrine.Social.Conversation{} + |> Elektrine.Social.Conversation.changeset(%{ + name: name, + type: "community", + is_public: true, + created_by_id: owner_id + }) + |> Repo.insert() + end + + describe "thread locking" do + setup do + # Create test users + {:ok, owner} = + Accounts.create_user(%{ + username: "owner", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, moderator} = + Accounts.create_user(%{ + username: "moderator", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, regular_user} = + Accounts.create_user(%{ + username: "regular", + password: "password123456", + password_confirmation: "password123456" + }) + + # Create community + {:ok, community} = create_community(owner.id, "testcommunity") + + # Add members with roles + Messaging.add_member_to_conversation(community.id, owner.id, "owner") + Messaging.add_member_to_conversation(community.id, moderator.id, "moderator") + Messaging.add_member_to_conversation(community.id, regular_user.id, "member") + + # Create test message + {:ok, message} = Messaging.create_text_message(community.id, regular_user.id, "Test post") + + %{ + owner: owner, + moderator: moderator, + regular_user: regular_user, + community: community, + message: message + } + end + + test "moderator can lock thread", %{moderator: moderator, message: message} do + {:ok, locked_message} = ModerationTools.lock_thread(message.id, moderator.id, "Test lock") + + assert locked_message.locked_at != nil + assert locked_message.locked_by_id == moderator.id + assert locked_message.lock_reason == "Test lock" + assert ModerationTools.thread_locked?(locked_message) + end + + test "moderator can unlock thread", %{moderator: moderator, message: message} do + {:ok, locked_message} = ModerationTools.lock_thread(message.id, moderator.id, "Test lock") + {:ok, unlocked_message} = ModerationTools.unlock_thread(locked_message.id, moderator.id) + + assert unlocked_message.locked_at == nil + assert unlocked_message.locked_by_id == nil + refute ModerationTools.thread_locked?(unlocked_message) + end + + test "regular user cannot lock thread", %{regular_user: regular_user, message: message} do + assert {:error, :unauthorized} = + ModerationTools.lock_thread(message.id, regular_user.id, "Test lock") + end + end + + describe "user timeouts" do + setup do + {:ok, owner} = + Accounts.create_user(%{ + username: "owner2", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, moderator} = + Accounts.create_user(%{ + username: "moderator2", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, target_user} = + Accounts.create_user(%{ + username: "target", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, community} = create_community(owner.id, "testcommunity2") + + Messaging.add_member_to_conversation(community.id, owner.id, "owner") + Messaging.add_member_to_conversation(community.id, moderator.id, "moderator") + + %{moderator: moderator, target_user: target_user, community: community} + end + + test "moderator can timeout user", %{ + moderator: moderator, + target_user: target_user, + community: community + } do + {:ok, timeout} = + ModerationTools.timeout_user(community.id, target_user.id, moderator.id, 30, "Spam") + + assert timeout.user_id == target_user.id + assert timeout.conversation_id == community.id + assert timeout.reason == "Spam" + assert ModerationTools.user_timed_out?(community.id, target_user.id) + end + + test "moderator can remove timeout", %{ + moderator: moderator, + target_user: target_user, + community: community + } do + {:ok, _timeout} = + ModerationTools.timeout_user(community.id, target_user.id, moderator.id, 30, "Spam") + + {:ok, :removed} = ModerationTools.remove_timeout(community.id, target_user.id, moderator.id) + + refute ModerationTools.user_timed_out?(community.id, target_user.id) + end + + test "regular user cannot timeout others", %{target_user: target_user, community: community} do + {:ok, regular} = + Accounts.create_user(%{ + username: "regular3", + password: "password123456", + password_confirmation: "password123456" + }) + + Messaging.add_member_to_conversation(community.id, regular.id, "member") + + assert {:error, :unauthorized} = + ModerationTools.timeout_user(community.id, target_user.id, regular.id, 30, "Test") + end + end + + describe "user warnings" do + setup do + {:ok, owner} = + Accounts.create_user(%{ + username: "owner3", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, moderator} = + Accounts.create_user(%{ + username: "moderator3", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, target_user} = + Accounts.create_user(%{ + username: "target2", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, community} = create_community(owner.id, "testcommunity3") + + Messaging.add_member_to_conversation(community.id, owner.id, "owner") + Messaging.add_member_to_conversation(community.id, moderator.id, "moderator") + Messaging.add_member_to_conversation(community.id, target_user.id, "member") + + %{moderator: moderator, target_user: target_user, community: community} + end + + test "moderator can issue warning", %{ + moderator: moderator, + target_user: target_user, + community: community + } do + {:ok, warning} = + ModerationTools.warn_user( + community.id, + target_user.id, + moderator.id, + "Violation of rules", + severity: "low" + ) + + assert warning.user_id == target_user.id + assert warning.reason == "Violation of rules" + assert warning.severity == "low" + assert warning.warned_by_id == moderator.id + end + + test "3 warnings triggers auto-ban", %{ + moderator: moderator, + target_user: target_user, + community: community + } do + # Issue 3 warnings + {:ok, _w1} = + ModerationTools.warn_user(community.id, target_user.id, moderator.id, "Warning 1", + severity: "low" + ) + + {:ok, _w2} = + ModerationTools.warn_user(community.id, target_user.id, moderator.id, "Warning 2", + severity: "medium" + ) + + {:ok, _w3} = + ModerationTools.warn_user(community.id, target_user.id, moderator.id, "Warning 3", + severity: "high" + ) + + # Check if user is banned + bans = Messaging.list_community_bans(community.id) + assert length(bans) == 1 + assert List.first(bans).user_id == target_user.id + assert List.first(bans).reason == "Auto-banned: 3 warnings received" + end + + test "warning count is accurate", %{ + moderator: moderator, + target_user: target_user, + community: community + } do + {:ok, _w1} = + ModerationTools.warn_user(community.id, target_user.id, moderator.id, "Warning 1") + + {:ok, _w2} = + ModerationTools.warn_user(community.id, target_user.id, moderator.id, "Warning 2") + + assert ModerationTools.count_user_warnings(community.id, target_user.id) == 2 + end + end + + describe "moderator notes" do + setup do + {:ok, owner} = + Accounts.create_user(%{ + username: "owner4", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, moderator} = + Accounts.create_user(%{ + username: "moderator4", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, target_user} = + Accounts.create_user(%{ + username: "target3", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, community} = create_community(owner.id, "testcommunity4") + + Messaging.add_member_to_conversation(community.id, owner.id, "owner") + Messaging.add_member_to_conversation(community.id, moderator.id, "moderator") + + %{moderator: moderator, target_user: target_user, community: community} + end + + test "moderator can add note", %{ + moderator: moderator, + target_user: target_user, + community: community + } do + {:ok, note} = + ModerationTools.add_moderator_note( + community.id, + target_user.id, + moderator.id, + "Problematic user", + true + ) + + assert note.note == "Problematic user" + assert note.is_important == true + assert note.created_by_id == moderator.id + end + + test "can list notes for user", %{ + moderator: moderator, + target_user: target_user, + community: community + } do + {:ok, _note1} = + ModerationTools.add_moderator_note( + community.id, + target_user.id, + moderator.id, + "Note 1", + false + ) + + {:ok, _note2} = + ModerationTools.add_moderator_note( + community.id, + target_user.id, + moderator.id, + "Note 2", + true + ) + + notes = ModerationTools.list_moderator_notes(community.id, target_user.id) + assert length(notes) == 2 + # Important notes should be first + assert List.first(notes).is_important == true + end + + test "moderator can delete note", %{ + moderator: moderator, + target_user: target_user, + community: community + } do + {:ok, note} = + ModerationTools.add_moderator_note( + community.id, + target_user.id, + moderator.id, + "Test note", + false + ) + + {:ok, _deleted} = ModerationTools.delete_moderator_note(note.id, moderator.id) + + notes = ModerationTools.list_moderator_notes(community.id, target_user.id) + assert notes == [] + end + end + + describe "auto-moderation rules" do + setup do + {:ok, owner} = + Accounts.create_user(%{ + username: "owner5", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, moderator} = + Accounts.create_user(%{ + username: "moderator5", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, community} = create_community(owner.id, "testcommunity5") + + Messaging.add_member_to_conversation(community.id, owner.id, "owner") + Messaging.add_member_to_conversation(community.id, moderator.id, "moderator") + + %{moderator: moderator, community: community} + end + + test "can create auto-mod rule", %{moderator: moderator, community: community} do + {:ok, rule} = + ModerationTools.create_auto_mod_rule(%{ + conversation_id: community.id, + name: "Spam Filter", + rule_type: "keyword", + pattern: "spam, scam", + action: "remove", + created_by_id: moderator.id + }) + + assert rule.name == "Spam Filter" + assert rule.rule_type == "keyword" + assert rule.enabled == true + end + + test "keyword rule blocks matching content", %{moderator: moderator, community: community} do + {:ok, _rule} = + ModerationTools.create_auto_mod_rule(%{ + conversation_id: community.id, + name: "Spam Filter", + rule_type: "keyword", + pattern: "spam, scam", + action: "remove", + created_by_id: moderator.id + }) + + assert {:blocked, _rule} = + ModerationTools.check_auto_mod_rules(community.id, "This is spam content") + + assert {:ok, :allowed} = + ModerationTools.check_auto_mod_rules(community.id, "This is clean content") + end + + test "can toggle rule enabled status", %{moderator: moderator, community: community} do + {:ok, rule} = + ModerationTools.create_auto_mod_rule(%{ + conversation_id: community.id, + name: "Test Rule", + rule_type: "keyword", + pattern: "test", + action: "flag", + created_by_id: moderator.id + }) + + {:ok, updated_rule} = ModerationTools.update_auto_mod_rule(rule, %{enabled: false}) + assert updated_rule.enabled == false + end + end + + describe "slow mode" do + setup do + {:ok, owner} = + Accounts.create_user(%{ + username: "owner6", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, user} = + Accounts.create_user(%{ + username: "user6", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, community} = create_community(owner.id, "testcommunity6") + + Messaging.add_member_to_conversation(community.id, owner.id, "owner") + Messaging.add_member_to_conversation(community.id, user.id, "member") + + %{owner: owner, user: user, community: community} + end + + test "slow mode allows first post", %{user: user, community: community, owner: owner} do + # Enable slow mode + {:ok, _conv} = ModerationTools.update_slow_mode(community.id, owner.id, 60) + + assert {:ok, :allowed} = ModerationTools.check_slow_mode(community.id, user.id) + end + + test "slow mode blocks rapid posts", %{user: user, community: community, owner: owner} do + # Enable slow mode (60 seconds) + {:ok, _conv} = ModerationTools.update_slow_mode(community.id, owner.id, 60) + + # First post allowed + {:ok, :allowed} = ModerationTools.check_slow_mode(community.id, user.id) + {:ok, _timestamp} = ModerationTools.update_post_timestamp(community.id, user.id) + + # Second post blocked + assert {:error, :slow_mode_active, _seconds} = + ModerationTools.check_slow_mode(community.id, user.id) + end + end + + describe "approval queue" do + setup do + {:ok, owner} = + Accounts.create_user(%{ + username: "owner7", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, moderator} = + Accounts.create_user(%{ + username: "moderator7", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, new_user} = + Accounts.create_user(%{ + username: "newuser", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, community} = create_community(owner.id, "testcommunity7") + + Messaging.add_member_to_conversation(community.id, owner.id, "owner") + Messaging.add_member_to_conversation(community.id, moderator.id, "moderator") + Messaging.add_member_to_conversation(community.id, new_user.id, "member") + + {:ok, message} = + Messaging.create_text_message(community.id, new_user.id, "Test post", nil, + skip_broadcast: true + ) + + # Mark as pending + message + |> Elektrine.Social.Message.changeset(%{approval_status: "pending"}) + |> Repo.update() + + %{moderator: moderator, new_user: new_user, community: community, message: message} + end + + test "moderator can approve post", %{moderator: moderator, message: message} do + {:ok, approved_message} = ModerationTools.approve_post(message.id, moderator.id) + + assert approved_message.approval_status == "approved" + assert approved_message.approved_by_id == moderator.id + end + + test "moderator can reject post", %{moderator: moderator, message: message} do + {:ok, rejected_message} = + ModerationTools.reject_post(message.id, moderator.id, "Spam content") + + assert rejected_message.approval_status == "rejected" + assert rejected_message.approved_by_id == moderator.id + end + + test "new users need approval", %{ + new_user: new_user, + community: community, + moderator: moderator + } do + # Enable approval mode + {:ok, _conv} = ModerationTools.update_approval_mode(community.id, moderator.id, true, 3) + + assert ModerationTools.needs_approval?(community.id, new_user.id) == true + end + + test "users with enough posts don't need approval", %{ + new_user: new_user, + community: community, + moderator: moderator + } do + # Enable approval mode + {:ok, _conv} = ModerationTools.update_approval_mode(community.id, moderator.id, true, 3) + + # Create and approve 3 posts + Enum.each(1..3, fn i -> + {:ok, msg} = + Messaging.create_text_message(community.id, new_user.id, "Post #{i}", nil, + skip_broadcast: true + ) + + msg + |> Elektrine.Social.Message.changeset(%{approval_status: "approved"}) + |> Repo.update() + end) + + assert ModerationTools.needs_approval?(community.id, new_user.id) == false + end + + test "lists pending posts", %{community: community, message: message} do + pending = ModerationTools.list_pending_posts(community.id) + + assert length(pending) == 1 + assert List.first(pending).id == message.id + end + end + + describe "moderation log" do + setup do + {:ok, owner} = + Accounts.create_user(%{ + username: "owner8", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, moderator} = + Accounts.create_user(%{ + username: "moderator8", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, target_user} = + Accounts.create_user(%{ + username: "target4", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, community} = create_community(owner.id, "testcommunity8") + + Messaging.add_member_to_conversation(community.id, owner.id, "owner") + Messaging.add_member_to_conversation(community.id, moderator.id, "moderator") + + %{moderator: moderator, target_user: target_user, community: community} + end + + test "logs moderation actions", %{ + moderator: moderator, + target_user: target_user, + community: community + } do + {:ok, log_entry} = + ModerationTools.log_moderation_action( + community.id, + moderator.id, + target_user.id, + nil, + "ban", + "Test reason", + %{duration: 7} + ) + + assert log_entry.action_type == "ban" + assert log_entry.moderator_id == moderator.id + assert log_entry.target_user_id == target_user.id + assert log_entry.reason == "Test reason" + end + + test "retrieves moderation log", %{ + moderator: moderator, + target_user: target_user, + community: community + } do + # Create multiple log entries + ModerationTools.log_moderation_action( + community.id, + moderator.id, + target_user.id, + nil, + "ban", + "Reason 1" + ) + + ModerationTools.log_moderation_action( + community.id, + moderator.id, + target_user.id, + nil, + "warn", + "Reason 2" + ) + + log = ModerationTools.get_moderation_log(community.id) + assert length(log) >= 2 + end + + test "can filter log by action type", %{ + moderator: moderator, + target_user: target_user, + community: community + } do + ModerationTools.log_moderation_action( + community.id, + moderator.id, + target_user.id, + nil, + "ban", + "Reason 1" + ) + + ModerationTools.log_moderation_action( + community.id, + moderator.id, + target_user.id, + nil, + "warn", + "Reason 2" + ) + + ban_logs = ModerationTools.get_moderation_log(community.id, action_type: "ban") + assert Enum.all?(ban_logs, fn log -> log.action_type == "ban" end) + end + end + + describe "admin permissions" do + setup do + {:ok, owner} = + Accounts.create_user(%{ + username: "owner9", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, admin} = + Accounts.create_user(%{ + username: "siteadmin", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, target_user} = + Accounts.create_user(%{ + username: "target5", + password: "password123456", + password_confirmation: "password123456" + }) + + # Make admin a site admin + admin + |> Elektrine.Accounts.User.admin_changeset(%{is_admin: true}) + |> Repo.update!() + + # Reload admin to get fresh is_admin value + admin = Repo.get!(Elektrine.Accounts.User, admin.id) + + {:ok, community} = create_community(owner.id, "testcommunity9") + + Messaging.add_member_to_conversation(community.id, target_user.id, "member") + + {:ok, message} = Messaging.create_text_message(community.id, target_user.id, "Test post") + + %{admin: admin, target_user: target_user, community: community, message: message} + end + + test "site admin can lock threads without being community mod", %{ + admin: admin, + message: message + } do + {:ok, locked_message} = ModerationTools.lock_thread(message.id, admin.id, "Admin lock") + assert locked_message.locked_at != nil + end + + test "site admin can timeout users without being community mod", %{ + admin: admin, + target_user: target_user, + community: community + } do + {:ok, timeout} = + ModerationTools.timeout_user(community.id, target_user.id, admin.id, 30, "Admin timeout") + + assert timeout.user_id == target_user.id + end + + test "site admin can issue warnings without being community mod", %{ + admin: admin, + target_user: target_user, + community: community + } do + {:ok, warning} = + ModerationTools.warn_user(community.id, target_user.id, admin.id, "Admin warning") + + assert warning.user_id == target_user.id + end + + test "site admin can add notes without being community mod", %{ + admin: admin, + target_user: target_user, + community: community + } do + {:ok, note} = + ModerationTools.add_moderator_note( + community.id, + target_user.id, + admin.id, + "Admin note", + false + ) + + assert note.note == "Admin note" + end + end +end diff --git a/apps/elektrine/test/elektrine/messaging/reference_peer_interop_test.exs b/apps/elektrine/test/elektrine/messaging/reference_peer_interop_test.exs new file mode 100644 index 0000000..923e604 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/reference_peer_interop_test.exs @@ -0,0 +1,316 @@ +defmodule Elektrine.Messaging.ReferencePeerInteropTest do + use Elektrine.DataCase, async: false + + alias Elektrine.Messaging.ArblargSDK + alias Elektrine.Messaging.Federation + alias Elektrine.Messaging.Federation.Config + alias Elektrine.Messaging.FederationSessionClient + alias Elektrine.Messaging.ReferencePeer + alias Elektrine.Messaging.ReferencePeerSessionServer + + setup do + previous = Application.get_env(:elektrine, :messaging_federation, []) + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.merge(previous, + enabled: true, + identity_key_id: "local-k1", + identity_shared_secret: "local-reference-secret", + allow_insecure_http_transport: true, + peers: [] + ) + ) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, previous) + end) + + :ok + end + + test "reference peer accepts locally signed events and replays them" do + peer = ReferencePeer.new(domain: "reference.test", secret: "reference-secret") + local_identity = Federation.local_discovery_document()["identity"] + + event = + signed_local_event( + "message.create", + "channel:https://#{Federation.local_domain()}/_arblarg/channels/interop-local", + 1, + message_payload(Federation.local_domain(), "interop-local", "hello from local") + ) + + assert {:ok, updated_peer, :applied} = + ReferencePeer.receive_event( + peer, + event, + ReferencePeer.key_lookup_from_identity(local_identity) + ) + + replay = ReferencePeer.export_stream_events(updated_peer, event["stream_id"]) + + assert replay["last_sequence"] == 1 + assert replay["events"] |> List.first() |> Map.get("event_id") == event["event_id"] + end + + test "federation accepts reference peer signed events discovered from reference metadata" do + peer = ReferencePeer.new(domain: "reference.test", secret: "reference-secret") + previous = Application.get_env(:elektrine, :messaging_federation, []) + + Application.put_env( + :elektrine, + :messaging_federation, + Keyword.merge(previous, + enabled: true, + peers: [], + dns_identity_fetcher: fn + "_arblarg.reference.test", "reference.test" -> + [authenticated_dns_identity_proof(ReferencePeer.discovery_document(peer))] + + _name, _domain -> + [] + end, + discovery_fetcher: fn + "reference.test", _urls -> + {:ok, ReferencePeer.discovery_document(peer)} + + _domain, _urls -> + {:error, :not_found} + end + ) + ) + + on_exit(fn -> + Application.put_env(:elektrine, :messaging_federation, previous) + end) + + assert {:ok, discovered_peer} = Federation.discover_peer("reference.test") + assert discovered_peer.domain == "reference.test" + + membership_event = + ReferencePeer.signed_event( + peer, + "membership.upsert", + "channel:https://reference.test/_arblarg/channels/interop-ref", + 1, + membership_payload("reference.test", "interop-ref") + ) + + event = + ReferencePeer.signed_event( + peer, + "message.create", + "channel:https://reference.test/_arblarg/channels/interop-ref", + 2, + message_payload("reference.test", "interop-ref", "hello from reference") + ) + + assert {:ok, :applied} = Federation.receive_event(membership_event, "reference.test") + assert {:ok, :applied} = Federation.receive_event(event, "reference.test") + end + + test "session transport multiplexes independent streams to the independent reference peer" do + peer = ReferencePeer.new(domain: "reference.test", secret: "reference-secret") + local_identity = Federation.local_discovery_document()["identity"] + {local_key_id, local_private_key} = local_signing_material() + + session_server = + start_supervised!( + {ReferencePeerSessionServer, + peer: peer, remote_key_lookup_fun: ReferencePeer.key_lookup_from_identity(local_identity)} + ) + + session_peer = %{ + domain: "reference.test", + active_outbound_key_id: local_key_id, + keys: [%{id: local_key_id, private_key: local_private_key}], + session_websocket_endpoint: + "ws://127.0.0.1:#{ReferencePeerSessionServer.port(session_server)}/_arblarg/session", + transport_profiles: %{ + "session_websocket" => %{ + "encodings" => ["json"], + "flow_control" => %{ + "max_inflight_batches" => 4, + "max_inflight_events" => 64 + } + } + } + } + + deliveries = + for {suffix, content} <- [ + {"session-interop-a", "hello over session"}, + {"session-interop-b", "second over session"} + ] do + Task.async(fn -> + FederationSessionClient.send_delivery( + session_peer, + "stream_batch", + %{ + "stream_id" => + "channel:https://#{Federation.local_domain()}/_arblarg/channels/#{suffix}", + "events" => [ + signed_local_event( + "message.create", + "channel:https://#{Federation.local_domain()}/_arblarg/channels/#{suffix}", + 1, + message_payload(Federation.local_domain(), suffix, content) + ) + ] + } + ) + end) + end + + Enum.each(deliveries, fn delivery -> + assert {:ok, %{"counts" => %{"applied" => 1}}} = Task.await(delivery, 5_000) + end) + + streamed_peer = ReferencePeerSessionServer.current_peer(session_server) + + Enum.each( + [ + {"session-interop-a", "hello over session"}, + {"session-interop-b", "second over session"} + ], + fn {suffix, content} -> + replay = + ReferencePeer.export_stream_events( + streamed_peer, + "channel:https://#{Federation.local_domain()}/_arblarg/channels/#{suffix}" + ) + + assert replay["last_sequence"] == 1 + + assert Enum.map(replay["events"], &get_in(&1, ["payload", "message", "content"])) == [ + content + ] + end + ) + end + + defp signed_local_event(event_type, stream_id, sequence, payload) do + {key_id, private_key} = local_signing_material() + + %{ + "protocol" => ArblargSDK.protocol_name(), + "protocol_id" => ArblargSDK.protocol_id(), + "protocol_label" => ArblargSDK.protocol_label(), + "protocol_version" => ArblargSDK.protocol_version(), + "event_id" => "evt-#{Ecto.UUID.generate()}", + "event_type" => event_type, + "origin_domain" => Federation.local_domain(), + "stream_id" => stream_id, + "sequence" => sequence, + "sent_at" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "idempotency_key" => "idem-#{Ecto.UUID.generate()}", + "payload" => payload + } + |> ArblargSDK.sign_event_envelope(key_id, private_key) + end + + defp local_signing_material do + config = Application.get_env(:elektrine, :messaging_federation, []) + key_id = Config.local_identity_key_id(config) + + [key | _] = + Config.local_identity_keys( + config, + key_id, + Federation.local_domain(), + true, + false + ) + + {key.id, key.private_key} + end + + defp message_payload(domain, suffix, content) do + %{ + "server" => %{ + "id" => "https://#{domain}/_arblarg/servers/#{suffix}", + "name" => "interop-#{suffix}", + "is_public" => true + }, + "channel" => %{ + "id" => "https://#{domain}/_arblarg/channels/#{suffix}", + "name" => "general", + "position" => 0 + }, + "message" => %{ + "id" => "https://#{domain}/_arblarg/messages/#{suffix}", + "channel_id" => "https://#{domain}/_arblarg/channels/#{suffix}", + "content" => content, + "message_type" => "text", + "attachments" => [], + "sender" => %{ + "id" => "https://#{domain}/users/alice", + "uri" => "https://#{domain}/users/alice", + "username" => "alice", + "display_name" => "alice", + "domain" => domain, + "handle" => "alice@#{domain}" + } + } + } + end + + defp membership_payload(domain, suffix) do + %{ + "server" => %{ + "id" => "https://#{domain}/_arblarg/servers/#{suffix}", + "name" => "interop-#{suffix}", + "is_public" => true + }, + "channel" => %{ + "id" => "https://#{domain}/_arblarg/channels/#{suffix}", + "name" => "general", + "position" => 0 + }, + "membership" => %{ + "actor" => %{ + "id" => "https://#{domain}/users/alice", + "uri" => "https://#{domain}/users/alice", + "username" => "alice", + "display_name" => "alice", + "domain" => domain, + "handle" => "alice@#{domain}" + }, + "state" => "active", + "role" => "member", + "joined_at" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "updated_at" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + } + } + end + + defp discovery_fingerprint(%{ + "identity" => %{"current_key_id" => current_key_id, "keys" => keys} + }) + when is_list(keys) do + normalized = + keys + |> Enum.map(fn key -> + %{ + "id" => key["id"], + "public_key" => key["public_key"] + } + end) + |> Enum.sort_by(&{&1["id"] || "", &1["public_key"] || ""}) + + :crypto.hash( + :sha256, + Jason.encode!(%{"current_key_id" => current_key_id, "keys" => normalized}) + ) + |> Base.url_encode64(padding: false) + end + + defp authenticated_dns_identity_proof(discovery_document) do + %{ + text: "fingerprint=#{discovery_fingerprint(discovery_document)}", + authenticated: true + } + end +end diff --git a/apps/elektrine/test/elektrine/messaging/servers_test.exs b/apps/elektrine/test/elektrine/messaging/servers_test.exs new file mode 100644 index 0000000..d33bb8d --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/servers_test.exs @@ -0,0 +1,241 @@ +defmodule Elektrine.Messaging.ServersTest do + use Elektrine.DataCase, async: true + + alias Elektrine.AccountsFixtures + alias Elektrine.Messaging + alias Elektrine.Messaging.ChatConversation + alias Elektrine.Messaging.Server + alias Elektrine.Repo + + describe "create_server/2" do + test "creates a server with a default general channel" do + owner = AccountsFixtures.user_fixture() + + assert {:ok, server} = + Messaging.create_server(owner.id, %{ + name: "engineering", + description: "Engineering team space" + }) + + assert server.name == "engineering" + assert length(server.channels) == 1 + + [default_channel] = server.channels + assert default_channel.name == "general" + assert default_channel.type == "channel" + assert default_channel.server_id == server.id + assert default_channel.is_public + + assert %{role: "owner"} = Messaging.get_server_member(server.id, owner.id) + default_channel_id = default_channel.id + + assert %{conversation_id: ^default_channel_id} = + Messaging.get_conversation_member(default_channel.id, owner.id) + end + end + + describe "join_server/2" do + test "joins a public server and adds membership to server channels" do + owner = AccountsFixtures.user_fixture() + joiner = AccountsFixtures.user_fixture() + + assert {:ok, server} = + Messaging.create_server(owner.id, %{ + name: "public-hub", + is_public: true + }) + + assert {:ok, _member} = Messaging.join_server(server.id, joiner.id) + assert %{role: "member"} = Messaging.get_server_member(server.id, joiner.id) + + {:ok, joined_server} = Messaging.get_server(server.id, joiner.id) + + assert Enum.all?( + joined_server.channels, + &Messaging.get_conversation_member(&1.id, joiner.id) + ) + end + + test "joins mirrored servers without auto-joining mirrored channels" do + joiner = AccountsFixtures.user_fixture() + + server = + %Server{} + |> Server.changeset(%{ + name: "remote-hub", + description: "Federated remote server", + is_public: true, + federation_id: "https://remote.example/_arblarg/servers/88", + origin_domain: "remote.example", + is_federated_mirror: true + }) + |> Repo.insert!() + + channel = + %ChatConversation{} + |> ChatConversation.channel_changeset(%{ + name: "general", + description: "Mirrored remote channel", + server_id: server.id, + federated_source: "https://remote.example/_arblarg/channels/89", + is_federated_mirror: true + }) + |> Repo.insert!() + + assert {:ok, _member} = Messaging.join_server(server.id, joiner.id) + assert %{role: "member"} = Messaging.get_server_member(server.id, joiner.id) + refute Messaging.get_conversation_member(channel.id, joiner.id) + end + end + + describe "create_server_channel/3" do + test "owner can create channels in a server" do + owner = AccountsFixtures.user_fixture() + {:ok, server} = Messaging.create_server(owner.id, %{name: "owner-space"}) + + assert {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "ops", + description: "Operations channel" + }) + + assert channel.name == "ops" + assert channel.server_id == server.id + assert channel.type == "channel" + assert channel.is_public + assert Messaging.get_conversation_member(channel.id, owner.id) + end + + test "owner can create private channels in a server" do + owner = AccountsFixtures.user_fixture() + {:ok, server} = Messaging.create_server(owner.id, %{name: "owner-space"}) + + assert {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "leadership", + description: "Private planning", + is_public: false + }) + + assert channel.name == "leadership" + refute channel.is_public + end + + test "regular members cannot create channels" do + owner = AccountsFixtures.user_fixture() + member = AccountsFixtures.user_fixture() + + {:ok, server} = Messaging.create_server(owner.id, %{name: "permissions", is_public: true}) + {:ok, _} = Messaging.join_server(server.id, member.id) + + assert {:error, :unauthorized} = + Messaging.create_server_channel(server.id, member.id, %{name: "restricted"}) + end + end + + describe "list_public_servers/2" do + test "returns joinable public servers including federated mirrors" do + owner = AccountsFixtures.user_fixture() + discoverer = AccountsFixtures.user_fixture() + + {:ok, joined_server} = Messaging.create_server(owner.id, %{name: "joined", is_public: true}) + {:ok, _} = Messaging.join_server(joined_server.id, discoverer.id) + + {:ok, public_local_server} = + Messaging.create_server(owner.id, %{name: "public-local", is_public: true}) + + {:ok, _private_server} = + Messaging.create_server(owner.id, %{name: "private-local", is_public: false}) + + {:ok, federated_server} = + %Server{} + |> Server.changeset(%{ + name: "remote-hub", + description: "Federated remote server", + is_public: true, + member_count: 42, + federation_id: "https://remote.example/_arblarg/servers/77", + origin_domain: "remote.example", + is_federated_mirror: true + }) + |> Repo.insert() + + servers = Messaging.list_public_servers(discoverer.id) + server_ids = MapSet.new(Enum.map(servers, & &1.id)) + + assert MapSet.member?(server_ids, public_local_server.id) + assert MapSet.member?(server_ids, federated_server.id) + refute MapSet.member?(server_ids, joined_server.id) + end + + test "supports search query matching name, description, and origin domain" do + owner = AccountsFixtures.user_fixture() + discoverer = AccountsFixtures.user_fixture() + + {:ok, _local_server} = + Messaging.create_server(owner.id, %{ + name: "artists", + description: "creative hangout", + is_public: true + }) + + {:ok, _remote_server} = + %Server{} + |> Server.changeset(%{ + name: "federated-devs", + description: "dev community", + is_public: true, + member_count: 12, + federation_id: "https://federated.example/_arblarg/servers/19", + origin_domain: "federated.example", + is_federated_mirror: true + }) + |> Repo.insert() + + assert ["artists"] == + discoverer.id + |> Messaging.list_public_servers(query: "creative") + |> Enum.map(& &1.name) + + assert ["federated-devs"] == + discoverer.id + |> Messaging.list_public_servers(query: "federated.example") + |> Enum.map(& &1.name) + end + + test "imports remote discovery entries into mirror servers" do + discoverer = AccountsFixtures.user_fixture() + + remote_discovery = [ + %{ + "name" => "remote-discovery", + "description" => "Remote discoverable server", + "is_public" => true, + "member_count" => 21, + "origin_domain" => "remote.example", + "federation_id" => "https://remote.example/_arblarg/servers/212" + } + ] + + servers = + Messaging.list_public_servers(discoverer.id, + remote_discovery_fn: fn _query, _limit -> remote_discovery end + ) + + assert Enum.any?( + servers, + &(&1.federation_id == "https://remote.example/_arblarg/servers/212") + ) + + mirror = + Repo.get_by(Server, + federation_id: "https://remote.example/_arblarg/servers/212" + ) + + assert mirror + assert mirror.is_federated_mirror + assert mirror.origin_domain == "remote.example" + assert mirror.member_count == 21 + end + end +end diff --git a/apps/elektrine/test/elektrine/messaging/slash_commands_test.exs b/apps/elektrine/test/elektrine/messaging/slash_commands_test.exs new file mode 100644 index 0000000..d5f0795 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/slash_commands_test.exs @@ -0,0 +1,44 @@ +defmodule Elektrine.Messaging.SlashCommandsTest do + use ExUnit.Case, async: true + + alias Elektrine.Messaging.SlashCommands + + describe "process/2" do + test "passes through regular messages" do + assert {:send, "hello world"} = SlashCommands.process("hello world") + end + + test "returns help text for /help" do + assert {:noop, message} = SlashCommands.process("/help") + assert message =~ "/invite" + end + + test "formats /me using handle when present" do + assert {:send, "*alice_waves hello*"} = + SlashCommands.process("/me hello", + user_handle: "alice_waves", + username: "alice" + ) + end + + test "formats /shrug with optional prefix text" do + assert {:send, "¯\\_(ツ)_/¯"} = SlashCommands.process("/shrug") + assert {:send, "fine ¯\\_(ツ)_/¯"} = SlashCommands.process("/shrug fine") + end + + test "builds invite links with conversation hash" do + conversation = %{id: 42, hash: "abc123"} + + assert {:send, "https://example.net/chat/join/abc123"} = + SlashCommands.process("/invite", + endpoint_url: "https://example.net", + conversation: conversation + ) + end + + test "returns error for unknown slash command" do + assert {:error, message} = SlashCommands.process("/doesnotexist") + assert message =~ "Unknown command" + end + end +end diff --git a/apps/elektrine/test/elektrine/messaging/social_message_security_test.exs b/apps/elektrine/test/elektrine/messaging/social_message_security_test.exs new file mode 100644 index 0000000..3d4509b --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/social_message_security_test.exs @@ -0,0 +1,82 @@ +defmodule Elektrine.Messaging.SocialMessageSecurityTest do + use ExUnit.Case, async: true + + alias Elektrine.Social.Message + + describe "changeset URL security" do + test "rejects unsafe primary URLs for link posts" do + for url <- [ + "javascript:alert(1)", + "//evil.example/post", + "https://user:pass@example.com/post", + "https://example.com/\r\nx-injected: yes" + ] do + changeset = + Message.changeset(%Message{}, %{ + conversation_id: 1, + sender_id: 1, + post_type: "link", + primary_url: url + }) + + refute changeset.valid? + assert Keyword.has_key?(changeset.errors, :primary_url) + end + end + + test "accepts valid primary URLs for link posts" do + changeset = + Message.changeset(%Message{}, %{ + conversation_id: 1, + sender_id: 1, + post_type: "link", + primary_url: "https://example.com/post" + }) + + assert changeset.valid? + end + + test "rejects local media keys with traversal after the owner prefix" do + changeset = + Message.changeset(%Message{}, %{ + conversation_id: 1, + sender_id: 42, + media_urls: ["/uploads/timeline-attachments/42_../secret.png"] + }) + + refute changeset.valid? + assert Keyword.has_key?(changeset.errors, :media_urls) + end + + test "rejects external media URLs with userinfo or untrusted hosts" do + for url <- [ + "https://user:pass@avatars.githubusercontent.com/u/1", + "https://evil.example/image.png" + ] do + changeset = + Message.changeset(%Message{}, %{ + conversation_id: 1, + sender_id: 42, + media_urls: [url] + }) + + refute changeset.valid? + assert Keyword.has_key?(changeset.errors, :media_urls) + end + end + + test "accepts owned local media keys and trusted external media URLs" do + changeset = + Message.changeset(%Message{}, %{ + conversation_id: 1, + sender_id: 42, + media_urls: [ + "/uploads/timeline-attachments/42_photo.png", + "https://avatars.githubusercontent.com/u/1" + ] + }) + + assert changeset.valid? + end + end +end diff --git a/apps/elektrine/test/elektrine/messaging/voice_channels_test.exs b/apps/elektrine/test/elektrine/messaging/voice_channels_test.exs new file mode 100644 index 0000000..ba48a09 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging/voice_channels_test.exs @@ -0,0 +1,206 @@ +defmodule Elektrine.Messaging.VoiceChannelsTest do + use Elektrine.DataCase, async: true + + alias Elektrine.AccountsFixtures + alias Elektrine.Messaging + alias Elektrine.Messaging.ChatConversation + alias Elektrine.Messaging.ChatConversationMember + alias Elektrine.Messaging.Federation.Visibility + alias Elektrine.Messaging.Server + alias Elektrine.Messaging.VoiceChannels + alias Elektrine.Repo + + defp create_server_with_owner do + owner = AccountsFixtures.user_fixture() + + {:ok, server} = + Messaging.create_server(owner.id, %{name: "voice-hub", is_public: true}) + + %{owner: owner, server: server} + end + + describe "voice channel creation" do + test "creates a voice channel inside a server and adds all members" do + %{owner: owner, server: server} = create_server_with_owner() + joiner = AccountsFixtures.user_fixture() + {:ok, _member} = Messaging.join_server(server.id, joiner.id) + + assert {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "hangout", + type: "voice_channel" + }) + + assert channel.type == "voice_channel" + assert channel.server_id == server.id + assert Messaging.get_conversation_member(channel.id, owner.id) + assert Messaging.get_conversation_member(channel.id, joiner.id) + end + + test "voice channels share the channel position sequence" do + %{owner: owner, server: server} = create_server_with_owner() + + assert {:ok, voice} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "hangout", + type: "voice_channel" + }) + + assert {:ok, text} = + Messaging.create_server_channel(server.id, owner.id, %{name: "random"}) + + assert text.type == "channel" + assert text.channel_position > voice.channel_position + end + + test "plain members cannot create voice channels" do + %{server: server} = create_server_with_owner() + member = AccountsFixtures.user_fixture() + {:ok, _member} = Messaging.join_server(server.id, member.id) + + assert {:error, :unauthorized} = + Messaging.create_server_channel(server.id, member.id, %{ + name: "hangout", + type: "voice_channel" + }) + end + + test "new server members are added to existing voice channels" do + %{owner: owner, server: server} = create_server_with_owner() + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "hangout", + type: "voice_channel" + }) + + late_joiner = AccountsFixtures.user_fixture() + {:ok, _member} = Messaging.join_server(server.id, late_joiner.id) + + assert Messaging.get_conversation_member(channel.id, late_joiner.id) + end + end + + describe "type validation" do + test "accepts the voice_channel type" do + changeset = + ChatConversation.changeset(%ChatConversation{}, %{ + type: "voice_channel", + name: "hangout" + }) + + assert changeset.valid? + end + + test "rejects unknown types" do + changeset = + ChatConversation.changeset(%ChatConversation{}, %{type: "video_stage", name: "nope"}) + + refute changeset.valid? + assert %{type: ["is invalid"]} = errors_on(changeset) + end + end + + describe "authorize_join/2" do + setup do + %{owner: owner, server: server} = create_server_with_owner() + + {:ok, channel} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "hangout", + type: "voice_channel" + }) + + %{owner: owner, server: server, channel: channel} + end + + test "allows active members", %{owner: owner, channel: channel} do + assert :ok = VoiceChannels.authorize_join(channel.id, owner.id) + end + + test "rejects users who are not members", %{channel: channel} do + outsider = AccountsFixtures.user_fixture() + + assert {:error, :unauthorized} = VoiceChannels.authorize_join(channel.id, outsider.id) + end + + test "rejects text channels", %{owner: owner, server: server} do + {:ok, server_with_channels} = Messaging.get_server(server.id, owner.id) + text_channel = Enum.find(server_with_channels.channels, &(&1.type == "channel")) + + assert {:error, :not_found} = VoiceChannels.authorize_join(text_channel.id, owner.id) + end + + test "rejects mirrored voice channels" do + user = AccountsFixtures.user_fixture() + + mirror_server = + %Server{} + |> Server.changeset(%{ + name: "remote-hub", + is_public: true, + federation_id: "https://remote.example/_arblarg/servers/9", + origin_domain: "remote.example", + is_federated_mirror: true + }) + |> Repo.insert!() + + mirror_channel = + %ChatConversation{} + |> ChatConversation.voice_channel_changeset(%{ + name: "remote-voice", + server_id: mirror_server.id, + is_federated_mirror: true, + federated_source: "https://remote.example/_arblarg/channels/10" + }) + |> Repo.insert!() + + ChatConversationMember.add_member_changeset(mirror_channel.id, user.id, "member") + |> Repo.insert!() + + assert {:error, :remote_mirror} = VoiceChannels.authorize_join(mirror_channel.id, user.id) + end + end + + describe "check_capacity/2" do + test "allows joins below the cap" do + assert :ok = VoiceChannels.check_capacity([1, 2, 3], 4) + end + + test "rejects joins at the cap" do + occupants = Enum.to_list(1..VoiceChannels.max_occupants()) + + assert {:error, :channel_full} = + VoiceChannels.check_capacity(occupants, VoiceChannels.max_occupants() + 1) + end + + test "rejects duplicate joins before the cap check" do + occupants = Enum.to_list(1..VoiceChannels.max_occupants()) + + assert {:error, :already_joined} = VoiceChannels.check_capacity(occupants, 1) + assert {:error, :already_joined} = VoiceChannels.check_capacity([7], 7) + end + + test "defaults to eight occupants" do + assert VoiceChannels.max_occupants() == 8 + end + end + + describe "federation exclusion" do + test "voice channels are not part of public bootstrap payloads" do + %{owner: owner, server: server} = create_server_with_owner() + + {:ok, voice} = + Messaging.create_server_channel(server.id, owner.id, %{ + name: "hangout", + type: "voice_channel" + }) + + server = Repo.get!(Server, server.id) + bootstrap_channels = Visibility.public_bootstrap_channels(server) + + assert Enum.any?(bootstrap_channels, &(&1.type == "channel")) + refute Enum.any?(bootstrap_channels, &(&1.id == voice.id)) + end + end +end diff --git a/apps/elektrine/test/elektrine/messaging_encryption_test.exs b/apps/elektrine/test/elektrine/messaging_encryption_test.exs new file mode 100644 index 0000000..2f8d6f2 --- /dev/null +++ b/apps/elektrine/test/elektrine/messaging_encryption_test.exs @@ -0,0 +1,320 @@ +defmodule Elektrine.MessagingEncryptionTest do + use Elektrine.DataCase + + alias Elektrine.{Accounts, Messaging, Repo} + alias Elektrine.Messaging.ChatMessage + alias Elektrine.Social.Message + + setup do + # Create test users + {:ok, user1} = + Accounts.create_user(%{ + username: "user1", + password: "password123456", + password_confirmation: "password123456" + }) + + {:ok, user2} = + Accounts.create_user(%{ + username: "user2", + password: "password123456", + password_confirmation: "password123456" + }) + + # Create a conversation + {:ok, conversation} = Messaging.create_dm_conversation(user1.id, user2.id) + + %{user1: user1, user2: user2, conversation: conversation} + end + + describe "chat message storage on creation" do + test "stores message content encrypted when creating", %{ + user1: user1, + conversation: conversation + } do + content = "This is a secret message" + + {:ok, message} = + Messaging.create_text_message( + conversation.id, + user1.id, + content + ) + + # Reload from database to check stored values + db_message = Repo.get(ChatMessage, message.id) + + assert db_message.content == nil + assert encrypted_payload?(db_message.encrypted_content) + assert ChatMessage.decrypt_content(db_message).content == content + + # Search index should still be created for chat search + assert is_list(db_message.search_index) + assert db_message.search_index != [] + end + + test "returned message contains decrypted content", %{ + user1: user1, + conversation: conversation + } do + content = "Hello from test" + + {:ok, message} = + Messaging.create_text_message( + conversation.id, + user1.id, + content + ) + + assert message.content == content + end + + test "creates searchable index with keywords", %{user1: user1, conversation: conversation} do + content = "Meeting tomorrow about #project deadline" + + {:ok, message} = + Messaging.create_text_message( + conversation.id, + user1.id, + content + ) + + db_message = Repo.get(ChatMessage, message.id) + + # Search index should contain hashes for keywords + assert db_message.search_index != [] + + # Verify we can find the message by keyword + meeting_hash = Elektrine.Encryption.hash_keyword("meeting", user1.id) + assert meeting_hash in db_message.search_index + + project_hash = Elektrine.Encryption.hash_keyword("#project", user1.id) + assert project_hash in db_message.search_index + end + + test "stores media message captions encrypted", %{user1: user1, conversation: conversation} do + content = "Check out this photo!" + media_urls = ["attachments/#{user1.id}_photo.jpg"] + + {:ok, message} = + Messaging.create_media_message( + conversation.id, + user1.id, + media_urls, + content + ) + + db_message = Repo.get(ChatMessage, message.id) + + assert db_message.content == nil + assert encrypted_payload?(db_message.encrypted_content) + assert ChatMessage.decrypt_content(db_message).content == content + assert message.content == content + end + + test "handles empty content gracefully", %{user1: user1, conversation: conversation} do + media_urls = ["attachments/#{user1.id}_photo.jpg"] + + {:ok, message} = + Messaging.create_media_message( + conversation.id, + user1.id, + media_urls, + nil + ) + + # Should not crash; media-only messages have no caption to encrypt. + assert message.encrypted_content == nil + end + end + + describe "message retrieval" do + test "loads decrypted messages when loading conversation", %{ + user1: user1, + user2: user2, + conversation: conversation + } do + # Create some messages + {:ok, _msg1} = Messaging.create_text_message(conversation.id, user1.id, "First message") + {:ok, _msg2} = Messaging.create_text_message(conversation.id, user2.id, "Second message") + + # Load conversation + {:ok, loaded_conversation} = Messaging.get_conversation!(conversation.id, user1.id) + + messages = loaded_conversation.messages + + assert Enum.any?(messages, fn m -> m.content == "First message" end) + assert Enum.any?(messages, fn m -> m.content == "Second message" end) + end + + test "returns decrypted messages when searching", %{user1: user1, conversation: conversation} do + # Create test message + {:ok, _message} = + Messaging.create_text_message( + conversation.id, + user1.id, + "searchable keyword test" + ) + + # Search for message + {:ok, results} = + Messaging.search_messages_in_conversation( + conversation.id, + user1.id, + "searchable" + ) + + # Should find message content + assert results != [] + assert Enum.any?(results, fn m -> m.content =~ "searchable" end) + end + + test "stores edited messages encrypted", %{user1: user1, conversation: conversation} do + {:ok, message} = + Messaging.create_text_message( + conversation.id, + user1.id, + "Original content" + ) + + # Edit the message + new_content = "Updated content" + {:ok, updated_message} = Messaging.edit_message(message.id, user1.id, new_content) + + db_message = Repo.get(ChatMessage, message.id) + assert db_message.content == nil + assert encrypted_payload?(db_message.encrypted_content) + assert ChatMessage.decrypt_content(db_message).content == new_content + + # Returned content should match + assert updated_message.content == new_content + end + end + + describe "message list display" do + test "loads conversation list preview messages", %{ + user1: user1, + user2: user2, + conversation: conversation + } do + last_message_content = "Latest message preview" + + {:ok, _message} = + Messaging.create_text_message( + conversation.id, + user2.id, + last_message_content + ) + + # List conversations + conversations = Messaging.list_chat_conversations(user1.id) + + # Find our conversation + conv = Enum.find(conversations, fn c -> c.id == conversation.id end) + + # Last message should be readable + assert conv.messages != [] + [last_msg | _] = conv.messages + assert last_msg.content == last_message_content + end + end + + describe "special content handling" do + test "stores unicode and emoji content encrypted", %{ + user1: user1, + conversation: conversation + } do + content = "Hello 世界 🌍 Привет 😊" + + {:ok, message} = + Messaging.create_text_message( + conversation.id, + user1.id, + content + ) + + # Should preserve content + assert message.content == content + + # Reload and verify + {:ok, loaded_conv} = Messaging.get_conversation!(conversation.id, user1.id) + [loaded_msg | _] = loaded_conv.messages + assert loaded_msg.content == content + end + + test "stores long messages encrypted", %{user1: user1, conversation: conversation} do + content = String.duplicate("Long message content. ", 100) + + {:ok, message} = + Messaging.create_text_message( + conversation.id, + user1.id, + content + ) + + assert message.content == content + + db_message = Repo.get(ChatMessage, message.id) + assert db_message.content == nil + assert encrypted_payload?(db_message.encrypted_content) + assert ChatMessage.decrypt_content(db_message).content == content + end + + test "indexes encrypted messages with hashtags for search", %{ + user1: user1, + conversation: conversation + } do + content = "Important #project #deadline #meeting notes" + + {:ok, message} = + Messaging.create_text_message( + conversation.id, + user1.id, + content + ) + + db_message = Repo.get(ChatMessage, message.id) + assert db_message.content == nil + assert encrypted_payload?(db_message.encrypted_content) + assert ChatMessage.decrypt_content(db_message).content == content + + project_hash = Elektrine.Encryption.hash_keyword("#project", user1.id) + deadline_hash = Elektrine.Encryption.hash_keyword("#deadline", user1.id) + meeting_hash = Elektrine.Encryption.hash_keyword("#meeting", user1.id) + + assert project_hash in db_message.search_index + assert deadline_hash in db_message.search_index + assert meeting_hash in db_message.search_index + end + + test "keeps legacy plaintext rows readable", %{user1: user1, conversation: conversation} do + legacy_message = + %ChatMessage{} + |> ChatMessage.changeset(%{ + conversation_id: conversation.id, + sender_id: user1.id, + content: "legacy plaintext", + message_type: "text" + }) + |> Repo.insert!() + + assert ChatMessage.decrypt_content(legacy_message).content == "legacy plaintext" + end + end + + describe "system messages" do + test "system messages validation", %{conversation: conversation} do + # System messages don't have sender_id + changeset = Message.system_changeset(conversation.id, "User joined the conversation") + + # System changeset should not be valid without sender_id + refute changeset.valid? + end + end + + defp encrypted_payload?(payload) when is_map(payload) do + Map.has_key?(payload, "encrypted_data") or Map.has_key?(payload, :encrypted_data) + end + + defp encrypted_payload?(_), do: false +end diff --git a/apps/elektrine/test/elektrine/notifications/federation_notifications_test.exs b/apps/elektrine/test/elektrine/notifications/federation_notifications_test.exs new file mode 100644 index 0000000..88f7a74 --- /dev/null +++ b/apps/elektrine/test/elektrine/notifications/federation_notifications_test.exs @@ -0,0 +1,54 @@ +defmodule Elektrine.Notifications.FederationNotificationsTest do + use Elektrine.DataCase, async: true + + alias Elektrine.ActivityPub.Actor + alias Elektrine.Notifications.FederationNotifications + alias Elektrine.Repo + + import Elektrine.AccountsFixtures + + describe "federated follow notifications" do + test "follow acceptance stores a clean single-line notification with a profile link" do + user = user_fixture() + actor = remote_actor_fixture("liaizon") + + assert {:ok, notification} = + FederationNotifications.notify_follow_accepted(user.id, actor.uri) + + assert notification.title == "@liaizon@social.wake.st accepted your follow request" + assert notification.body == nil + assert notification.url == "/remote/liaizon@social.wake.st" + assert notification.source_type == "activitypub_actor" + assert notification.source_id == actor.id + end + + test "remote follow notifications link to the remote profile" do + user = user_fixture() + actor = remote_actor_fixture("nixcraft") + + assert {:ok, notification} = + FederationNotifications.notify_remote_follow(user.id, actor.id) + + assert notification.title == "@nixcraft@social.wake.st is now following you" + assert notification.body == nil + assert notification.url == "/remote/nixcraft@social.wake.st" + assert notification.source_type == "activitypub_actor" + assert notification.source_id == actor.id + end + end + + defp remote_actor_fixture(username) do + {:ok, actor} = + %Actor{} + |> Actor.changeset(%{ + uri: "https://social.wake.st/users/#{username}-#{System.unique_integer([:positive])}", + username: username, + domain: "social.wake.st", + inbox_url: "https://social.wake.st/inbox", + public_key: "test-public-key" + }) + |> Repo.insert() + + actor + end +end diff --git a/apps/elektrine/test/elektrine/notifications_test.exs b/apps/elektrine/test/elektrine/notifications_test.exs new file mode 100644 index 0000000..7be9718 --- /dev/null +++ b/apps/elektrine/test/elektrine/notifications_test.exs @@ -0,0 +1,413 @@ +defmodule Elektrine.NotificationsTest do + use Elektrine.DataCase, async: true + + import Ecto.Query + alias Elektrine.{Accounts, Messaging, Notifications, Repo} + alias Elektrine.Accounts.User + alias Elektrine.ActivityPub.Actor + alias Elektrine.Notifications.FederationNotifications + alias Elektrine.Social.Message + import Elektrine.AccountsFixtures + import Elektrine.SocialFixtures + + describe "notification preferences" do + setup do + user = user_fixture() + follower = user_fixture() + {:ok, user: user, follower: follower} + end + + test "notify_follow respects user's notify_on_new_follower preference", %{ + user: user, + follower: follower + } do + # Default should be true, so notification should be created + assert {:ok, notification} = Notifications.notify_follow(user.id, follower) + assert notification.type == "follow" + assert notification.user_id == user.id + + # Disable follow notifications + {:ok, updated_user} = Accounts.update_user(user, %{notify_on_new_follower: false}) + assert updated_user.notify_on_new_follower == false + + # Now notification should not be created + assert {:ok, :notification_disabled} = Notifications.notify_follow(user.id, follower) + + # Verify no new notification was created + notifications = Notifications.list_notifications(user.id) + # Only the first one + assert length(notifications) == 1 + end + + test "notify_new_message respects user's notify_on_direct_message preference", %{ + user: user, + follower: sender + } do + # Default should be true, so notification should be created + assert {:ok, notification} = + Notifications.notify_new_message( + user.id, + sender, + "test-conversation", + "Hello there!" + ) + + assert notification.type == "new_message" + assert notification.user_id == user.id + + # Disable message notifications + {:ok, updated_user} = Accounts.update_user(user, %{notify_on_direct_message: false}) + assert updated_user.notify_on_direct_message == false + + # Now notification should not be created + assert {:ok, :notification_disabled} = + Notifications.notify_new_message( + user.id, + sender, + "test-conversation", + "Another message" + ) + + # Verify no new notification was created + notifications = Notifications.list_notifications(user.id) + # Only the first one + assert length(notifications) == 1 + end + + test "notify_mention respects user's notify_on_mention preference", %{ + user: user, + follower: actor + } do + # Default should be true, so notification should be created + assert {:ok, notification} = + Notifications.notify_mention( + user.id, + actor, + "post", + 123, + "Hey @#{user.username}, check this out!" + ) + + assert notification.type == "mention" + assert notification.user_id == user.id + + # Disable mention notifications + {:ok, updated_user} = Accounts.update_user(user, %{notify_on_mention: false}) + assert updated_user.notify_on_mention == false + + # Now notification should not be created + assert {:ok, :notification_disabled} = + Notifications.notify_mention( + user.id, + actor, + "post", + 456, + "Another mention @#{user.username}" + ) + + # Verify no new notification was created + notifications = Notifications.list_notifications(user.id) + # Only the first one + assert length(notifications) == 1 + end + + test "default notification preferences are true", %{user: user} do + assert user.notify_on_new_follower == true + assert user.notify_on_direct_message == true + assert user.notify_on_mention == true + end + end + + describe "notification creation and retrieval" do + setup do + user = user_fixture() + {:ok, user: user} + end + + test "create_notification creates a notification", %{user: user} do + attrs = %{ + type: "system", + title: "Test Notification", + body: "This is a test", + url: "/test", + icon: "hero-bell", + user_id: user.id, + priority: "normal" + } + + assert {:ok, notification} = Notifications.create_notification(attrs) + assert notification.type == "system" + assert notification.title == "Test Notification" + assert notification.user_id == user.id + end + + test "create_notification accepts advertised web push alert types", %{user: user} do + for type <- ["status", "poll", "update", "admin.sign_up", "admin.report"] do + assert {:ok, notification} = + Notifications.create_notification(%{ + type: type, + title: "Push alert #{type}", + user_id: user.id + }) + + assert notification.type == type + end + end + + test "redact_delivery_payload hides notification previews when enabled", %{user: user} do + payload = %{ + title: "Private title", + body: "Private body", + type: "mention", + data: %{notification_id: 123} + } + + assert Notifications.redact_delivery_payload(payload, user.id) == payload + + from(u in User, where: u.id == ^user.id) + |> Repo.update_all(set: [hide_notification_contents: true]) + + assert %{ + title: "New notification", + body: "Open Elektrine to view it.", + type: "mention", + data: %{notification_id: 123} + } = Notifications.redact_delivery_payload(payload, user.id) + end + + test "list_notifications returns notifications for user", %{user: user} do + # Create some notifications + {:ok, _n1} = + Notifications.create_notification(%{ + type: "system", + title: "First", + user_id: user.id + }) + + # Add a small delay to ensure different timestamps + Process.sleep(100) + + {:ok, _n2} = + Notifications.create_notification(%{ + type: "system", + title: "Second", + user_id: user.id + }) + + notifications = Notifications.list_notifications(user.id) + assert length(notifications) == 2 + # Verify both notifications are present + titles = Enum.map(notifications, & &1.title) + assert "First" in titles + assert "Second" in titles + end + + test "get_unread_count returns correct count", %{user: user} do + # Create some notifications + {:ok, n1} = + Notifications.create_notification(%{ + type: "system", + title: "First", + user_id: user.id + }) + + {:ok, _n2} = + Notifications.create_notification(%{ + type: "system", + title: "Second", + user_id: user.id + }) + + assert Notifications.get_unread_count(user.id) == 2 + + # Mark one as read + Notifications.mark_as_read(n1.id, user.id) + assert Notifications.get_unread_count(user.id) == 1 + end + + test "mark_all_as_read marks all notifications as read", %{user: user} do + # Create notifications + {:ok, _} = + Notifications.create_notification(%{ + type: "system", + title: "First", + user_id: user.id + }) + + {:ok, _} = + Notifications.create_notification(%{ + type: "system", + title: "Second", + user_id: user.id + }) + + assert Notifications.get_unread_count(user.id) == 2 + + # Mark all as read + Notifications.mark_all_as_read(user.id) + assert Notifications.get_unread_count(user.id) == 0 + end + end + + describe "chat notification delivery" do + test "direct message notifications are still delivered when the preference is nil" do + sender = user_fixture() + recipient = user_fixture() + + from(u in User, where: u.id == ^recipient.id) + |> Repo.update_all(set: [notify_on_direct_message: nil]) + + assert {:ok, conversation} = Messaging.create_dm_conversation(sender.id, recipient.id) + assert {:ok, message} = Messaging.create_text_message(conversation.id, sender.id, "hello") + + notifications = Notifications.list_notifications(recipient.id) + + assert Enum.any?(notifications, fn notification -> + notification.type == "new_message" and notification.source_id == message.id + end) + end + + test "mark_as_read clears the linked chat unread source" do + sender = user_fixture() + recipient = user_fixture() + + assert {:ok, conversation} = Messaging.create_dm_conversation(sender.id, recipient.id) + assert {:ok, message} = Messaging.create_text_message(conversation.id, sender.id, "hello") + + notification = + recipient.id + |> Notifications.list_notifications(filter: :unread) + |> Enum.find(&(&1.source_id == message.id)) + + assert notification + assert Messaging.get_unread_count(recipient.id) == 1 + + assert :ok = Notifications.mark_as_read(notification.id, recipient.id) + + assert Notifications.get_unread_count(recipient.id) == 0 + assert Messaging.get_unread_count(recipient.id) == 0 + end + + test "mark_all_as_read clears linked chat unread sources" do + sender = user_fixture() + recipient = user_fixture() + + assert {:ok, conversation} = Messaging.create_dm_conversation(sender.id, recipient.id) + assert {:ok, _message} = Messaging.create_text_message(conversation.id, sender.id, "one") + assert {:ok, _message} = Messaging.create_text_message(conversation.id, sender.id, "two") + + assert Notifications.get_unread_count(recipient.id) == 2 + assert Messaging.get_unread_count(recipient.id) == 2 + + assert :ok = Notifications.mark_all_as_read(recipient.id) + + assert Notifications.get_unread_count(recipient.id) == 0 + assert Messaging.get_unread_count(recipient.id) == 0 + end + end + + describe "message notification urls" do + setup do + user = user_fixture() + parent_post = post_fixture(user: user) + remote_actor = remote_actor_fixture() + remote_reply = federated_reply_fixture(parent_post, remote_actor) + + {:ok, + user: user, + parent_post: parent_post, + remote_actor: remote_actor, + remote_reply: remote_reply} + end + + test "list_notifications rewrites legacy fediverse reply and mention urls", %{ + user: user, + parent_post: parent_post, + remote_reply: remote_reply + } do + {:ok, reply_notification} = + Notifications.create_notification(%{ + user_id: user.id, + type: "reply", + title: "Reply from the fediverse", + body: "Legacy reply notification", + url: "/timeline/post/#{remote_reply.id}", + source_type: "message", + source_id: remote_reply.id, + priority: "normal" + }) + + {:ok, mention_notification} = + Notifications.create_notification(%{ + user_id: user.id, + type: "mention", + title: "Mentioned in a post", + body: "Legacy mention notification", + source_type: "message", + source_id: remote_reply.id, + priority: "normal" + }) + + notifications = Notifications.list_notifications(user.id) + + expected_url = "/remote/post/#{parent_post.id}#message-#{remote_reply.id}" + + assert Enum.find(notifications, &(&1.id == reply_notification.id)).url == expected_url + assert Enum.find(notifications, &(&1.id == mention_notification.id)).url == expected_url + end + + test "new fediverse reply and mention notifications store canonical thread urls", %{ + user: user, + parent_post: parent_post, + remote_actor: remote_actor, + remote_reply: remote_reply + } do + expected_url = "/remote/post/#{parent_post.id}#message-#{remote_reply.id}" + + assert {:ok, reply_notification} = + FederationNotifications.notify_remote_reply(remote_reply.id, remote_actor.id) + + assert {:ok, mention_notification} = + FederationNotifications.notify_remote_mention( + user.id, + remote_reply.id, + remote_actor.id + ) + + assert reply_notification.url == expected_url + assert mention_notification.url == expected_url + end + end + + defp remote_actor_fixture do + {:ok, actor} = + %Actor{} + |> Actor.changeset(%{ + uri: "https://poa.st/users/waifupoaster#{System.unique_integer([:positive])}", + username: "waifupoaster#{System.unique_integer([:positive])}", + domain: "poa.st", + inbox_url: "https://poa.st/inbox", + public_key: "test-public-key" + }) + |> Repo.insert() + + actor + end + + defp federated_reply_fixture(parent_post, remote_actor) do + {:ok, reply} = + %Message{} + |> Message.federated_changeset(%{ + content: "Remote reply", + visibility: "public", + post_type: "post", + activitypub_id: "https://poa.st/notes/#{System.unique_integer([:positive])}", + activitypub_url: "https://poa.st/@waifupoaster/#{System.unique_integer([:positive])}", + remote_actor_id: remote_actor.id, + reply_to_id: parent_post.id + }) + |> Repo.insert() + + reply + end +end diff --git a/apps/elektrine/test/elektrine/oauth/app_test.exs b/apps/elektrine/test/elektrine/oauth/app_test.exs new file mode 100644 index 0000000..95e7000 --- /dev/null +++ b/apps/elektrine/test/elektrine/oauth/app_test.exs @@ -0,0 +1,56 @@ +defmodule Elektrine.OAuth.AppTest do + use Elektrine.DataCase, async: true + + alias Elektrine.OAuth + alias Elektrine.OAuth.App + + test "rejects native redirect URI schemes and non-localhost http" do + for redirect_uri <- [ + "mastodon://oauth", + "mammoth://oauth", + "feditext://oauth", + "fedicat://oauth", + "http://client.example/callback", + "javascript:alert(1)", + "data:text/html,hello", + "file:///tmp/callback", + "mailto:user@example.com" + ] do + assert {:error, changeset} = + OAuth.create_app(%{ + client_name: "Unsafe #{redirect_uri}", + redirect_uris: redirect_uri, + scopes: ["read"] + }) + + assert "contains invalid URI" in errors_on(changeset).redirect_uris + end + end + + test "base changeset rejects unsafe redirect URI updates" do + {:ok, app} = + OAuth.create_app(%{ + client_name: "Console", + redirect_uris: "https://client.example/callback", + scopes: ["read"] + }) + + changeset = App.changeset(app, %{redirect_uris: "http://client.example/callback"}) + + refute changeset.valid? + assert "contains invalid URI" in errors_on(changeset).redirect_uris + end + + test "rejects unknown and privileged self-service scopes" do + for scopes <- [["read", "unknown:scope"], ["read", "admin:write"]] do + assert {:error, changeset} = + OAuth.create_app(%{ + client_name: "Unsafe scopes", + redirect_uris: "https://client.example/callback", + scopes: scopes + }) + + assert %{scopes: [_ | _]} = errors_on(changeset) + end + end +end diff --git a/apps/elektrine/test/elektrine/paths_test.exs b/apps/elektrine/test/elektrine/paths_test.exs new file mode 100644 index 0000000..72d27d0 --- /dev/null +++ b/apps/elektrine/test/elektrine/paths_test.exs @@ -0,0 +1,28 @@ +defmodule Elektrine.PathsTest do + use ExUnit.Case, async: true + + describe "post_path/1" do + test "routes local community posts with ActivityPub ids to their community path" do + post = %{ + id: 494, + title: "Local Post", + activitypub_id: "http://localhost:4000/c/maxfield/posts/494", + federated: false, + conversation: %{type: "community", name: "maxfield"} + } + + assert Elektrine.Paths.post_path(post) == "/communities/maxfield/post/494-local-post" + end + + test "routes federated posts to the local remote-post view" do + post = %{ + id: 494, + activitypub_id: "https://example.social/posts/494", + federated: true, + conversation: %{type: "community", name: "maxfield"} + } + + assert Elektrine.Paths.post_path(post) == "/remote/post/494" + end + end +end diff --git a/apps/elektrine/test/elektrine/platform/modules_test.exs b/apps/elektrine/test/elektrine/platform/modules_test.exs new file mode 100644 index 0000000..fc1f672 --- /dev/null +++ b/apps/elektrine/test/elektrine/platform/modules_test.exs @@ -0,0 +1,78 @@ +defmodule Elektrine.Platform.ModulesTest do + use ExUnit.Case, async: false + + alias Elektrine.Platform.Modules + + setup do + original = Application.get_env(:elektrine, :platform_modules) + original_compiled = Application.get_env(:elektrine, :compiled_platform_modules) + + on_exit(fn -> + if is_nil(original) do + Application.delete_env(:elektrine, :platform_modules) + else + Application.put_env(:elektrine, :platform_modules, original) + end + + if is_nil(original_compiled) do + Application.delete_env(:elektrine, :compiled_platform_modules) + else + Application.put_env(:elektrine, :compiled_platform_modules, original_compiled) + end + end) + + :ok + end + + test "normalizes hoster module lists from strings" do + assert Modules.normalize_enabled_modules("chat, email, nerve, vpn, proofs") == [ + :chat, + :email, + :nerve, + :vpn, + :atomine + ] + end + + test "drops unknown module names and de-duplicates values" do + assert Modules.normalize_enabled_modules([:chat, "chat", "unknown", "social"]) == [ + :chat, + :social + ] + end + + test "supports disabling all optional modules" do + assert Modules.normalize_enabled_modules("none") == [] + assert Modules.normalize_enabled_modules("") == [] + end + + test "reads enabled modules from runtime config" do + Application.put_env(:elektrine, :platform_modules, enabled: "chat,email") + + expected = + if Code.ensure_loaded?(Elektrine.Email) do + [:chat, :email] + else + [:chat] + end + + assert Modules.enabled() == expected + assert Modules.enabled?(:chat) + refute Modules.enabled?(:vpn) + end + + test "filters compiled modules by code availability" do + Application.put_env(:elektrine, :compiled_platform_modules, [:chat, :email]) + Application.put_env(:elektrine, :platform_modules, enabled: [:chat, :email]) + + expected = + if Code.ensure_loaded?(Elektrine.Email) do + [:chat, :email] + else + [:chat] + end + + assert Modules.compiled() == expected + assert Modules.enabled() == expected + end +end diff --git a/apps/elektrine/test/elektrine/platform/runtime_config_validator_test.exs b/apps/elektrine/test/elektrine/platform/runtime_config_validator_test.exs new file mode 100644 index 0000000..b684d79 --- /dev/null +++ b/apps/elektrine/test/elektrine/platform/runtime_config_validator_test.exs @@ -0,0 +1,250 @@ +defmodule Elektrine.Platform.RuntimeConfigValidatorTest do + use ExUnit.Case, async: true + + alias Elektrine.Platform.RuntimeConfigValidator + + @encryption_error "production requires ENCRYPTION_MASTER_SECRET, ENCRYPTION_KEY_SALT, and ENCRYPTION_SEARCH_SALT, or ELEKTRINE_MASTER_SECRET; set ELEKTRINE_ALLOW_UNENCRYPTED_PROD_DATA=true only if unencrypted production data is intentional" + + @prod_encryption_env %{ + "ENCRYPTION_MASTER_SECRET" => "encryption-master-secret-with-enough-length", + "ENCRYPTION_KEY_SALT" => "encryption-key-salt", + "ENCRYPTION_SEARCH_SALT" => "encryption-search-salt" + } + + test "allows deployments with no enabled optional modules" do + assert :ok = + RuntimeConfigValidator.validate( + env: %{}, + compiled_modules: [], + enabled_modules: [], + environment: :dev + ) + end + + test "requires email service and domain config when email is enabled" do + assert {:error, errors} = + RuntimeConfigValidator.validate( + env: %{}, + enabled_modules: [:email], + environment: :prod + ) + + assert "production requires SESSION_SIGNING_SALT or ELEKTRINE_MASTER_SECRET so LiveView and cookie sessions stay consistent across instances" in errors + + assert "production requires SESSION_ENCRYPTION_SALT or ELEKTRINE_MASTER_SECRET so cookie sessions can be decrypted consistently across instances" in errors + + assert @encryption_error in errors + + assert "email module requires PRIMARY_DOMAIN" in errors + assert "email module requires HARAKA_BASE_URL" in errors + end + + test "requires Haraka credentials when email is enabled" do + assert {:error, errors} = + RuntimeConfigValidator.validate( + env: + Map.merge(@prod_encryption_env, %{ + "PRIMARY_DOMAIN" => "example.com", + "SESSION_SIGNING_SALT" => "signing-salt-16+", + "SESSION_ENCRYPTION_SALT" => "encryption-salt-16+" + }), + enabled_modules: [:email], + environment: :prod + ) + + assert "email module requires HARAKA_BASE_URL" in errors + + assert "email module requires one of HARAKA_HTTP_API_KEY, HARAKA_OUTBOUND_API_KEY, HARAKA_API_KEY, INTERNAL_API_KEY or ELEKTRINE_MASTER_SECRET" in errors + + assert "email module requires one of PHOENIX_API_KEY, HARAKA_INBOUND_API_KEY, HARAKA_API_KEY, INTERNAL_API_KEY or ELEKTRINE_MASTER_SECRET" in errors + end + + test "requires either fleet bootstrap or self-hosted wireguard credentials when vpn is enabled" do + assert {:error, errors} = + RuntimeConfigValidator.validate( + env: %{}, + enabled_modules: [:vpn], + environment: :prod + ) + + assert "production requires SESSION_SIGNING_SALT or ELEKTRINE_MASTER_SECRET so LiveView and cookie sessions stay consistent across instances" in errors + + assert "production requires SESSION_ENCRYPTION_SALT or ELEKTRINE_MASTER_SECRET so cookie sessions can be decrypted consistently across instances" in errors + + assert @encryption_error in errors + + assert "vpn module requires either VPN_FLEET_REGISTRATION_KEY or self-hosted WireGuard credentials via VPN_SELFHOST_PRIVATE_KEY/VPN_SELFHOST_PUBLIC_KEY; set VPN_SELFHOST_ENDPOINT_HOST or VPN_SELFHOST_PUBLIC_IP to avoid endpoint autodetection" in errors + end + + test "ignores disabled modules even when they are compiled" do + assert :ok = + RuntimeConfigValidator.validate( + env: %{}, + compiled_modules: [:email, :vpn], + enabled_modules: [], + environment: :dev + ) + end + + test "accepts valid email and vpn configuration" do + assert :ok = + RuntimeConfigValidator.validate( + env: + Map.merge(@prod_encryption_env, %{ + "PRIMARY_DOMAIN" => "example.com", + "SESSION_SIGNING_SALT" => "signing-salt-16+", + "SESSION_ENCRYPTION_SALT" => "encryption-salt-16+", + "HARAKA_BASE_URL" => "https://mail.example.com", + "HARAKA_HTTP_API_KEY" => "outbound-key-with-enough-length", + "PHOENIX_API_KEY" => "inbound-key-with-enough-length", + "VPN_FLEET_REGISTRATION_KEY" => "fleet-key" + }), + enabled_modules: [:email, :vpn], + environment: :prod + ) + end + + test "accepts self-hosted wireguard configuration without a fleet key" do + assert :ok = + RuntimeConfigValidator.validate( + env: + Map.merge(@prod_encryption_env, %{ + "SESSION_SIGNING_SALT" => "signing-salt-16+", + "SESSION_ENCRYPTION_SALT" => "encryption-salt-16+", + "VPN_SELFHOST_PUBLIC_IP" => "203.0.113.10", + "VPN_SELFHOST_PRIVATE_KEY" => "server-private-key" + }), + enabled_modules: [:vpn], + environment: :prod + ) + end + + test "accepts internal api key as shared Haraka credential" do + assert :ok = + RuntimeConfigValidator.validate( + env: + Map.merge(@prod_encryption_env, %{ + "PRIMARY_DOMAIN" => "example.com", + "SESSION_SIGNING_SALT" => "signing-salt-16+", + "SESSION_ENCRYPTION_SALT" => "encryption-salt-16+", + "HARAKA_BASE_URL" => "https://mail.example.com", + "INTERNAL_API_KEY" => "shared-internal-key-with-enough-length" + }), + enabled_modules: [:email], + environment: :prod + ) + end + + test "requires production encryption secrets unless unencrypted data is explicit" do + assert {:error, errors} = + RuntimeConfigValidator.validate( + env: %{ + "SESSION_SIGNING_SALT" => "signing-salt-16+", + "SESSION_ENCRYPTION_SALT" => "encryption-salt-16+" + }, + enabled_modules: [], + environment: :prod + ) + + assert @encryption_error in errors + + assert :ok = + RuntimeConfigValidator.validate( + env: %{ + "SESSION_SIGNING_SALT" => "signing-salt-16+", + "SESSION_ENCRYPTION_SALT" => "encryption-salt-16+", + "ELEKTRINE_ALLOW_UNENCRYPTED_PROD_DATA" => "true" + }, + enabled_modules: [], + environment: :prod + ) + end + + test "accepts master secret as source for derived session and Haraka secrets" do + assert :ok = + RuntimeConfigValidator.validate( + env: %{ + "PRIMARY_DOMAIN" => "example.com", + "HARAKA_BASE_URL" => "https://mail.example.com", + "ELEKTRINE_MASTER_SECRET" => "master-secret-with-at-least-thirty-two-chars" + }, + enabled_modules: [:email], + environment: :prod + ) + end + + test "rejects production placeholder secrets" do + assert {:error, errors} = + RuntimeConfigValidator.validate( + env: %{ + "DB_PASSWORD" => "change-me", + "ELEKTRINE_MASTER_SECRET" => "replace-with-long-random-secret" + }, + enabled_modules: [], + environment: :prod + ) + + assert "production secret DB_PASSWORD uses a known placeholder value" in errors + assert "production secret ELEKTRINE_MASTER_SECRET uses a known placeholder value" in errors + end + + test "allows generated credentials that contain placeholder words" do + assert :ok = + RuntimeConfigValidator.validate( + env: %{ + "ELEKTRINE_MASTER_SECRET" => "master-secret-with-at-least-thirty-two-chars", + "S3_ACCESS_KEY_ID" => "magpie25" + }, + enabled_modules: [], + environment: :prod + ) + end + + test "ignores optional S3 placeholders when storage is not configured" do + assert :ok = + RuntimeConfigValidator.validate( + env: %{ + "ELEKTRINE_MASTER_SECRET" => "master-secret-with-at-least-thirty-two-chars", + "S3_ACCESS_KEY_ID" => "", + "S3_SECRET_ACCESS_KEY" => "" + }, + enabled_modules: [], + environment: :prod + ) + end + + test "does not boot-block optional S3 placeholders when storage values exist" do + assert :ok = + RuntimeConfigValidator.validate( + env: %{ + "ELEKTRINE_MASTER_SECRET" => "master-secret-with-at-least-thirty-two-chars", + "S3_ACCESS_KEY_ID" => "", + "S3_SECRET_ACCESS_KEY" => "", + "S3_ENDPOINT" => "s3.example.com", + "S3_BUCKET_NAME" => "uploads" + }, + enabled_modules: [], + environment: :prod + ) + end + + test "rejects short production root secrets" do + assert {:error, errors} = + RuntimeConfigValidator.validate( + env: %{"ELEKTRINE_MASTER_SECRET" => "too-short"}, + enabled_modules: [], + environment: :prod + ) + + assert "production secret ELEKTRINE_MASTER_SECRET must be at least 32 characters" in errors + end + + test "does not require production session salts outside prod" do + assert :ok = + RuntimeConfigValidator.validate( + env: %{}, + enabled_modules: [], + environment: :dev + ) + end +end diff --git a/apps/elektrine/test/elektrine/privacy_test.exs b/apps/elektrine/test/elektrine/privacy_test.exs new file mode 100644 index 0000000..9a5f7fa --- /dev/null +++ b/apps/elektrine/test/elektrine/privacy_test.exs @@ -0,0 +1,228 @@ +defmodule Elektrine.PrivacyTest do + use Elektrine.DataCase + + alias Elektrine.{Accounts, Friends, Privacy, Repo} + + describe "can_send_dm?/2" do + setup do + {:ok, sender} = create_user("sender") + {:ok, recipient} = create_user("recipient") + %{sender: sender, recipient: recipient} + end + + test "allows DM when set to everyone", %{sender: sender, recipient: recipient} do + {:ok, updated} = Accounts.update_user(recipient, %{allow_direct_messages_from: "everyone"}) + assert {:ok, :allowed} = Privacy.can_send_dm?(sender.id, updated.id) + end + + test "blocks DM when set to nobody", %{sender: sender, recipient: recipient} do + {:ok, updated} = Accounts.update_user(recipient, %{allow_direct_messages_from: "nobody"}) + assert {:error, :privacy_restricted} = Privacy.can_send_dm?(sender.id, updated.id) + end + + test "allows DM when set to friends and users are friends", %{ + sender: sender, + recipient: recipient + } do + {:ok, updated} = Accounts.update_user(recipient, %{allow_direct_messages_from: "friends"}) + make_friends(sender.id, recipient.id) + assert {:ok, :allowed} = Privacy.can_send_dm?(sender.id, updated.id) + end + + test "blocks DM when set to friends and users are not friends", %{ + sender: sender, + recipient: recipient + } do + {:ok, updated} = Accounts.update_user(recipient, %{allow_direct_messages_from: "friends"}) + assert {:error, :privacy_restricted} = Privacy.can_send_dm?(sender.id, updated.id) + end + + test "blocks DM when either user has blocked the other", %{ + sender: sender, + recipient: recipient + } do + Accounts.block_user(sender.id, recipient.id) + assert {:error, :blocked} = Privacy.can_send_dm?(sender.id, recipient.id) + end + + test "prevents self-DM", %{sender: sender} do + assert {:error, :cannot_dm_self} = Privacy.can_send_dm?(sender.id, sender.id) + end + end + + describe "can_call?/2" do + setup do + {:ok, caller} = create_user("caller") + {:ok, callee} = create_user("callee") + %{caller: caller, callee: callee} + end + + test "allows call when set to everyone", %{caller: caller, callee: callee} do + {:ok, updated} = Accounts.update_user(callee, %{allow_calls_from: "everyone"}) + assert {:ok, :allowed} = Privacy.can_call?(caller.id, updated.id) + end + + test "blocks call when set to nobody", %{caller: caller, callee: callee} do + {:ok, updated} = Accounts.update_user(callee, %{allow_calls_from: "nobody"}) + assert {:error, :privacy_restricted} = Privacy.can_call?(caller.id, updated.id) + end + + test "allows call when set to friends and users are friends", %{ + caller: caller, + callee: callee + } do + make_friends(caller.id, callee.id) + assert {:ok, :allowed} = Privacy.can_call?(caller.id, callee.id) + end + + test "blocks call when set to friends and users are not friends", %{ + caller: caller, + callee: callee + } do + # Default is friends + assert {:error, :privacy_restricted} = Privacy.can_call?(caller.id, callee.id) + end + + test "prevents self-call", %{caller: caller} do + assert {:error, :cannot_call_self} = Privacy.can_call?(caller.id, caller.id) + end + end + + describe "can_send_friend_request?/2" do + setup do + {:ok, requester} = create_user("requester") + {:ok, recipient} = create_user("recipient") + %{requester: requester, recipient: recipient} + end + + test "allows request when set to everyone", %{requester: requester, recipient: recipient} do + {:ok, updated} = Accounts.update_user(recipient, %{allow_friend_requests_from: "everyone"}) + assert {:ok, :allowed} = Privacy.can_send_friend_request?(requester.id, updated.id) + end + + test "blocks request when set to nobody", %{requester: requester, recipient: recipient} do + {:ok, updated} = Accounts.update_user(recipient, %{allow_friend_requests_from: "nobody"}) + + assert {:error, :privacy_restricted} = + Privacy.can_send_friend_request?(requester.id, updated.id) + end + + test "prevents request when already friends", %{requester: requester, recipient: recipient} do + make_friends(requester.id, recipient.id) + + assert {:error, :already_friends} = + Privacy.can_send_friend_request?(requester.id, recipient.id) + end + + test "prevents self-friend request", %{requester: requester} do + assert {:error, :cannot_friend_self} = + Privacy.can_send_friend_request?(requester.id, requester.id) + end + + test "blocks accepting request after privacy changed to nobody", %{ + requester: requester, + recipient: recipient + } do + # Send request when privacy allows it + {:ok, request} = Friends.send_friend_request(requester.id, recipient.id) + + # Change privacy to nobody + {:ok, _updated} = Accounts.update_user(recipient, %{allow_friend_requests_from: "nobody"}) + + # Attempt to accept - should be blocked and auto-rejected + assert {:error, :privacy_settings_changed} = + Friends.accept_friend_request(request.id, recipient.id) + + # Verify request was auto-rejected + updated_request = Repo.get(Friends.FriendRequest, request.id) + assert updated_request.status == "rejected" + end + end + + describe "can_add_to_group?/2" do + setup do + {:ok, adder} = create_user("adder") + {:ok, target} = create_user("targetuser") + %{adder: adder, target: target} + end + + test "allows add when set to everyone", %{adder: adder, target: target} do + {:ok, updated} = Accounts.update_user(target, %{allow_group_adds_from: "everyone"}) + assert {:ok, :allowed} = Privacy.can_add_to_group?(adder.id, updated.id) + end + + test "blocks add when set to nobody", %{adder: adder, target: target} do + {:ok, updated} = Accounts.update_user(target, %{allow_group_adds_from: "nobody"}) + assert {:error, :privacy_restricted} = Privacy.can_add_to_group?(adder.id, updated.id) + end + + test "allows self-add", %{adder: adder} do + assert {:ok, :allowed} = Privacy.can_add_to_group?(adder.id, adder.id) + end + end + + describe "can_view_profile?/2" do + setup do + {:ok, viewer} = create_user("viewer") + {:ok, owner} = create_user("owner") + %{viewer: viewer, owner: owner} + end + + test "allows viewing public profiles", %{viewer: viewer, owner: owner} do + {:ok, updated} = Accounts.update_user(owner, %{profile_visibility: "public"}) + assert {:ok, :allowed} = Privacy.can_view_profile?(viewer.id, updated.id) + end + + test "blocks viewing private profiles", %{viewer: viewer, owner: owner} do + {:ok, updated} = Accounts.update_user(owner, %{profile_visibility: "private"}) + assert {:error, :privacy_restricted} = Privacy.can_view_profile?(viewer.id, updated.id) + end + + test "allows viewing own profile", %{owner: owner} do + {:ok, updated} = Accounts.update_user(owner, %{profile_visibility: "private"}) + assert {:ok, :allowed} = Privacy.can_view_profile?(updated.id, updated.id) + end + end + + describe "privacy_error_message/1" do + test "uses specific messages for authorization errors" do + assert Privacy.privacy_error_message(:unauthorized) == + "You do not have permission to do that" + + assert Privacy.privacy_error_message(:not_authorized_for_room) == + "You do not have permission to do that in this room" + end + + test "does not show the old generic action-not-allowed fallback" do + refute Privacy.privacy_error_message(:unknown_error) == "Action not allowed" + + assert Privacy.privacy_error_message(:unknown_error) == + "Unknown error" + end + + test "formats changeset errors instead of hiding the reason" do + changeset = + {%{}, %{media_urls: :string}} + |> Ecto.Changeset.cast(%{media_urls: nil}, [:media_urls]) + |> Ecto.Changeset.validate_required([:media_urls]) + + assert Privacy.privacy_error_message(changeset) == "Media urls can't be blank" + end + end + + # Test helpers + + defp create_user(username) do + Accounts.create_user(%{ + username: username, + password: "password123456", + password_confirmation: "password123456" + }) + end + + defp make_friends(user1_id, user2_id) do + {:ok, request} = Friends.send_friend_request(user1_id, user2_id) + {:ok, _} = Friends.accept_friend_request(request.id, user2_id) + :ok + end +end diff --git a/apps/elektrine/test/elektrine/profile_custom_domains_test.exs b/apps/elektrine/test/elektrine/profile_custom_domains_test.exs new file mode 100644 index 0000000..ce6e0bc --- /dev/null +++ b/apps/elektrine/test/elektrine/profile_custom_domains_test.exs @@ -0,0 +1,243 @@ +defmodule Elektrine.ProfileCustomDomainsTest do + use Elektrine.DataCase + + import Elektrine.AccountsFixtures + + alias Elektrine.{Domains, Profiles} + alias Elektrine.Profiles.CustomDomain + + defmodule TestTxtResolver do + @behaviour Elektrine.Profiles.CustomDomains + + @impl true + def lookup_txt(host) do + Process.get({__MODULE__, host}, {:ok, []}) + end + end + + setup do + previous_resolver = Application.get_env(:elektrine, :profile_custom_domain_txt_resolver) + Application.put_env(:elektrine, :profile_custom_domain_txt_resolver, TestTxtResolver) + + previous_edge_env = + for key <- [ + "PROFILE_CUSTOM_DOMAIN_EDGE_TARGET", + "PROFILE_CUSTOM_DOMAIN_EDGE_IPV4", + "PROFILE_CUSTOM_DOMAIN_EDGE_IPV6" + ], + into: %{} do + {key, System.get_env(key)} + end + + Enum.each(Map.keys(previous_edge_env), &System.delete_env/1) + + on_exit(fn -> + if previous_resolver do + Application.put_env(:elektrine, :profile_custom_domain_txt_resolver, previous_resolver) + else + Application.delete_env(:elektrine, :profile_custom_domain_txt_resolver) + end + + Enum.each(previous_edge_env, fn + {key, nil} -> System.delete_env(key) + {key, value} -> System.put_env(key, value) + end) + end) + + :ok + end + + test "verify_custom_domain marks a profile domain verified when the TXT record matches" do + user = user_fixture(%{username: "profiledomainowner"}) + + {:ok, custom_domain} = + Profiles.create_custom_domain(user, %{"domain" => "profiledomainowner.test"}) + + Process.put( + {TestTxtResolver, Profiles.verification_host(custom_domain)}, + {:ok, [Profiles.verification_value(custom_domain)]} + ) + + assert {:ok, verified_domain} = Profiles.verify_custom_domain(custom_domain) + assert verified_domain.status == "verified" + assert Profiles.get_verified_custom_domain("profiledomainowner.test").id == verified_domain.id + end + + test "re-verifying keeps a profile domain verified within the grace window" do + user = user_fixture(%{username: "profilegrace"}) + + {:ok, custom_domain} = + Profiles.create_custom_domain(user, %{"domain" => "profilegrace.test"}) + + Process.put( + {TestTxtResolver, Profiles.verification_host(custom_domain)}, + {:ok, [Profiles.verification_value(custom_domain)]} + ) + + assert {:ok, verified} = Profiles.verify_custom_domain(custom_domain) + assert verified.status == "verified" + + # Record disappears from DNS. + Process.put({TestTxtResolver, Profiles.verification_host(custom_domain)}, {:ok, []}) + + assert {:ok, still_verified} = Profiles.verify_custom_domain(verified) + assert still_verified.status == "verified" + assert still_verified.failing_since + end + + test "re-verifying demotes a profile domain to pending after the grace window" do + user = user_fixture(%{username: "profilegracepass"}) + + {:ok, custom_domain} = + Profiles.create_custom_domain(user, %{"domain" => "profilegracepass.test"}) + + Process.put( + {TestTxtResolver, Profiles.verification_host(custom_domain)}, + {:ok, [Profiles.verification_value(custom_domain)]} + ) + + assert {:ok, verified} = Profiles.verify_custom_domain(custom_domain) + + failing_since = DateTime.utc_now() |> DateTime.add(-5 * 24 * 60 * 60, :second) + + stale = + verified + |> Ecto.Changeset.change(failing_since: DateTime.truncate(failing_since, :second)) + |> Elektrine.Repo.update!() + + Process.put({TestTxtResolver, Profiles.verification_host(custom_domain)}, {:ok, []}) + + assert {:ok, demoted} = Profiles.verify_custom_domain(stale) + assert demoted.status == "pending" + assert is_nil(demoted.failing_since) + assert is_nil(Profiles.get_verified_custom_domain("profilegracepass.test")) + end + + test "get_verified_custom_domain_for_host resolves bare and www hosts" do + user = user_fixture(%{username: "profilehostowner"}) + + {:ok, custom_domain} = + Profiles.create_custom_domain(user, %{"domain" => "profilehostowner.test"}) + + Process.put( + {TestTxtResolver, Profiles.verification_host(custom_domain)}, + {:ok, [Profiles.verification_value(custom_domain)]} + ) + + assert {:ok, verified_domain} = Profiles.verify_custom_domain(custom_domain) + + assert Profiles.get_verified_custom_domain_for_host("profilehostowner.test").id == + verified_domain.id + + assert Profiles.get_verified_custom_domain_for_host("www.profilehostowner.test").id == + verified_domain.id + end + + test "rejects domains that conflict with configured profile hosts" do + user = user_fixture(%{username: "conflictingprofiledomain"}) + + assert {:error, changeset} = + Profiles.create_custom_domain( + user, + %{"domain" => "foo.#{Domains.primary_profile_domain()}"} + ) + + assert "conflicts with an existing profile host" in errors_on(changeset).domain + end + + test "rejects the configured profile routing edge target" do + System.put_env("PROFILE_CUSTOM_DOMAIN_EDGE_TARGET", "profiles.edge.example") + user = user_fixture(%{username: "reservedprofileedge"}) + + assert {:error, changeset} = + Profiles.create_custom_domain(user, %{"domain" => "profiles.edge.example"}) + + assert "is reserved for profile routing" in errors_on(changeset).domain + end + + test "rejects the www alias of the profile routing edge target" do + System.put_env("PROFILE_CUSTOM_DOMAIN_EDGE_TARGET", "profiles.edge.example") + user = user_fixture(%{username: "reservedprofileedgewww"}) + + assert {:error, changeset} = + Profiles.create_custom_domain(user, %{"domain" => "www.profiles.edge.example"}) + + assert "is reserved for profile routing" in errors_on(changeset).domain + end + + test "dns_records_for_custom_domain uses a stable hostname target instead of edge IPs" do + System.put_env("PROFILE_CUSTOM_DOMAIN_EDGE_TARGET", "profiles.edge.example") + System.put_env("PROFILE_CUSTOM_DOMAIN_EDGE_IPV4", "203.0.113.10") + System.put_env("PROFILE_CUSTOM_DOMAIN_EDGE_IPV6", "2001:db8::10") + + custom_domain = %CustomDomain{ + domain: "futureproof.example", + verification_token: "futureproof-token" + } + + records = Profiles.dns_records_for_custom_domain(custom_domain) + + assert Enum.any?(records, fn record -> + record.type == "ALIAS" and + record.host == "futureproof.example" and + record.value == "profiles.edge.example" + end) + + refute Enum.any?(records, &(&1.type in ["A", "AAAA"])) + end + + test "dns_records_for_custom_domain falls back to the configured routing target" do + custom_domain = %CustomDomain{ + domain: "fallback.example", + verification_token: "fallback-token" + } + + records = Profiles.dns_records_for_custom_domain(custom_domain) + + assert Enum.any?(records, fn record -> + record.type == "ALIAS" and + record.host == "fallback.example" and + record.value == Domains.profile_custom_domain_routing_target() + end) + end + + test "list_custom_domains_admin returns every user's profile domains with the owner" do + owner_a = user_fixture(%{username: "adminlistowner1"}) + owner_b = user_fixture(%{username: "adminlistowner2"}) + + {:ok, _} = Profiles.create_custom_domain(owner_a, %{"domain" => "adminlist-one.test"}) + {:ok, _} = Profiles.create_custom_domain(owner_b, %{"domain" => "adminlist-two.test"}) + + {domains, total} = Profiles.CustomDomains.list_custom_domains_admin() + + assert total == 2 + listed = Enum.map(domains, & &1.domain) + assert "adminlist-one.test" in listed + assert "adminlist-two.test" in listed + # Owner is preloaded so the admin view can link to the user. + assert Enum.all?(domains, &is_struct(&1.user, Elektrine.Accounts.User)) + end + + test "list_custom_domains_admin filters by status and searches by domain/owner" do + owner = user_fixture(%{username: "adminsearchowner"}) + {:ok, _} = Profiles.create_custom_domain(owner, %{"domain" => "adminsearch.test"}) + + assert {[], 0} = Profiles.CustomDomains.list_custom_domains_admin("", "verified") + assert {[domain], 1} = Profiles.CustomDomains.list_custom_domains_admin("adminsearch", "all") + assert domain.domain == "adminsearch.test" + assert {[_], 1} = Profiles.CustomDomains.list_custom_domains_admin("adminsearchowner", "all") + assert {[], 0} = Profiles.CustomDomains.list_custom_domains_admin("no-such-domain", "all") + end + + test "custom_domain_admin_stats counts totals and pending domains" do + owner = user_fixture(%{username: "adminstatsowner"}) + {:ok, _} = Profiles.create_custom_domain(owner, %{"domain" => "adminstats.test"}) + + stats = Profiles.CustomDomains.custom_domain_admin_stats() + + assert stats.total >= 1 + assert stats.pending >= 1 + assert Map.has_key?(stats, :verified) + assert Map.has_key?(stats, :attention) + end +end diff --git a/apps/elektrine/test/elektrine/profile_per_site_identities_test.exs b/apps/elektrine/test/elektrine/profile_per_site_identities_test.exs new file mode 100644 index 0000000..e7f54f7 --- /dev/null +++ b/apps/elektrine/test/elektrine/profile_per_site_identities_test.exs @@ -0,0 +1,88 @@ +defmodule Elektrine.ProfilePerSiteIdentitiesTest do + use Elektrine.DataCase, async: true + + import Ecto.Query + import Elektrine.AccountsFixtures + + alias Elektrine.Domains + alias Elektrine.Profiles + alias Elektrine.Repo + + describe "per-site portable identities" do + test "creates an identity under the user's built-in profile domain" do + user = user_fixture(%{username: "persitebuiltin", handle: "persitebuiltin"}) + base_domain = "persitebuiltin.#{Domains.default_profile_domain()}" + + assert {:ok, identity} = + Profiles.create_per_site_identity(user, %{ + "site_key" => "hn", + "base_domain" => base_domain, + "display_name" => "Hacker News" + }) + + assert identity.site_key == "hn" + assert identity.base_domain == base_domain + assert identity.domain == "hn.#{base_domain}" + assert identity.subject == "domain:hn.#{base_domain}" + assert identity.did == "did:web:hn.#{base_domain}" + assert identity.email_alias == "hn@#{base_domain}" + assert identity.display_name == "Hacker News" + assert identity.enabled == true + end + + test "rejects a base domain the user does not own" do + user = user_fixture(%{username: "persiteinvalid", handle: "persiteinvalid"}) + + assert {:error, :invalid_base_domain} = + Profiles.create_per_site_identity(user, %{ + "site_key" => "banking", + "base_domain" => "someone-else.example" + }) + end + + test "creates an identity under a verified custom profile domain" do + user = user_fixture(%{username: "persitecustom", handle: "persitecustom"}) + custom_domain = verified_profile_custom_domain_fixture(user, "persitecustom.example") + + assert {:ok, identity} = + Profiles.create_per_site_identity(user, %{ + "site_key" => "spotify", + "base_domain" => custom_domain.domain + }) + + assert identity.domain == "spotify.persitecustom.example" + assert custom_domain.domain in Profiles.available_per_site_base_domains(user) + end + + test "keeps one identity per site key and base domain for each user" do + user = user_fixture(%{username: "persiteunique", handle: "persiteunique"}) + base_domain = "persiteunique.#{Domains.default_profile_domain()}" + + assert {:ok, _identity} = + Profiles.create_per_site_identity(user, %{ + "site_key" => "hn", + "base_domain" => base_domain + }) + + assert {:error, changeset} = + Profiles.create_per_site_identity(user, %{ + "site_key" => "hn", + "base_domain" => base_domain + }) + + assert "already exists for that domain" in errors_on(changeset).site_key + end + end + + defp verified_profile_custom_domain_fixture(user, domain) do + {:ok, custom_domain} = Profiles.create_custom_domain(user, %{"domain" => domain}) + + now = DateTime.utc_now() |> DateTime.truncate(:second) + + {1, _} = + from(d in Elektrine.Profiles.CustomDomain, where: d.id == ^custom_domain.id) + |> Repo.update_all(set: [status: "verified", verified_at: now, last_checked_at: now]) + + Profiles.get_verified_custom_domain(domain) + end +end diff --git a/apps/elektrine/test/elektrine/profiles/profile_widget_test.exs b/apps/elektrine/test/elektrine/profiles/profile_widget_test.exs new file mode 100644 index 0000000..e93ff00 --- /dev/null +++ b/apps/elektrine/test/elektrine/profiles/profile_widget_test.exs @@ -0,0 +1,36 @@ +defmodule Elektrine.Profiles.ProfileWidgetTest do + use ExUnit.Case, async: true + + alias Elektrine.Profiles.ProfileWidget + + test "discord status widgets require real Discord snowflake IDs" do + changeset = + ProfileWidget.changeset(%ProfileWidget{}, %{ + profile_id: 1, + widget_type: "discord_status", + content: "123" + }) + + refute changeset.valid? + assert %{content: ["must be a valid Discord user ID"]} = errors_on(changeset) + end + + test "discord status widgets accept 17-20 digit IDs" do + changeset = + ProfileWidget.changeset(%ProfileWidget{}, %{ + profile_id: 1, + widget_type: "discord_status", + content: "94490510688792576" + }) + + assert changeset.valid? + end + + defp errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Enum.reduce(opts, message, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/apps/elektrine/test/elektrine/profiles/static_site_deployment_test.exs b/apps/elektrine/test/elektrine/profiles/static_site_deployment_test.exs new file mode 100644 index 0000000..d327b5a --- /dev/null +++ b/apps/elektrine/test/elektrine/profiles/static_site_deployment_test.exs @@ -0,0 +1,62 @@ +defmodule Elektrine.Profiles.StaticSiteDeploymentTest do + use Elektrine.DataCase, async: true + + alias Elektrine.{AccountsFixtures, Repo, StaticSites} + alias Elektrine.Profiles.StaticSiteDeployment + alias Elektrine.Secrets.EncryptedString + + describe "changeset/2" do + test "allows normal GitHub branch paths" do + changeset = + StaticSiteDeployment.changeset(%StaticSiteDeployment{}, %{ + user_id: 1, + repo_owner: "octo", + repo_name: "site", + branch: "feature/site-redesign", + site_dir: "auto", + deploy_status: "idle" + }) + + assert changeset.valid? + end + + test "rejects traversal-like GitHub branch paths" do + for branch <- ["../main", "feature/../main", "/main", "feature//main", "feature/./main"] do + changeset = + StaticSiteDeployment.changeset(%StaticSiteDeployment{}, %{ + user_id: 1, + repo_owner: "octo", + repo_name: "site", + branch: branch, + site_dir: "auto", + deploy_status: "idle" + }) + + refute changeset.valid? + assert %{branch: [_ | _]} = errors_on(changeset) + end + end + end + + test "stores GitHub webhook secrets encrypted at rest" do + user = AccountsFixtures.user_fixture() + + assert {:ok, deployment} = + StaticSites.upsert_github_deployment(user.id, %{ + repo_owner: "octo", + repo_name: "site", + branch: "main", + site_dir: "auto" + }) + + assert is_binary(deployment.webhook_secret) + + [[stored_secret]] = + Repo.query!("SELECT webhook_secret FROM static_site_deployments WHERE id = $1", [ + deployment.id + ]).rows + + assert EncryptedString.encrypted?(stored_secret) + refute stored_secret == deployment.webhook_secret + end +end diff --git a/apps/elektrine/test/elektrine/profiles_test.exs b/apps/elektrine/test/elektrine/profiles_test.exs new file mode 100644 index 0000000..e489290 --- /dev/null +++ b/apps/elektrine/test/elektrine/profiles_test.exs @@ -0,0 +1,885 @@ +defmodule Elektrine.ProfilesTest do + @moduledoc """ + Tests for the Profiles context, including profile creation, + lookup by handle, and follow/unfollow functionality. + """ + use Elektrine.DataCase, async: true + + alias Elektrine.AccountsFixtures + alias Elektrine.ActivityPub.Actor + alias Elektrine.Profiles + alias Elektrine.Profiles.Follow + alias Elektrine.Profiles.{ProfileSiteVisit, ProfileView, SitePageVisit, SiteSession} + alias Elektrine.Repo + + describe "get_profile_by_handle/1" do + test "returns profile for valid handle" do + user = AccountsFixtures.user_fixture() + + {:ok, profile} = + Profiles.create_user_profile(user.id, %{ + display_name: "Test User", + is_public: true + }) + + found_profile = Profiles.get_profile_by_handle(user.handle) + + assert found_profile.id == profile.id + assert found_profile.user.handle == user.handle + end + + test "returns nil for non-existent handle" do + assert Profiles.get_profile_by_handle("nonexistent_handle") == nil + end + + test "returns nil for private profiles" do + user = AccountsFixtures.user_fixture() + + {:ok, _profile} = + Profiles.create_user_profile(user.id, %{ + display_name: "Private User", + is_public: false + }) + + assert Profiles.get_profile_by_handle(user.handle) == nil + end + + test "preloads user association" do + user = AccountsFixtures.user_fixture() + + {:ok, _profile} = + Profiles.create_user_profile(user.id, %{ + display_name: "Test User", + is_public: true + }) + + found_profile = Profiles.get_profile_by_handle(user.handle) + + assert Ecto.assoc_loaded?(found_profile.user) + assert found_profile.user.id == user.id + end + + test "preloads active links ordered by position" do + user = AccountsFixtures.user_fixture() + + {:ok, profile} = + Profiles.create_user_profile(user.id, %{ + display_name: "Test User", + is_public: true + }) + + # Create some profile links + {:ok, _link1} = + Profiles.create_profile_link(profile.id, %{ + title: "Link 1", + url: "https://example.com/1", + position: 2, + is_active: true + }) + + {:ok, _link2} = + Profiles.create_profile_link(profile.id, %{ + title: "Link 2", + url: "https://example.com/2", + position: 1, + is_active: true + }) + + {:ok, _inactive_link} = + Profiles.create_profile_link(profile.id, %{ + title: "Inactive Link", + url: "https://example.com/inactive", + position: 0, + is_active: false + }) + + found_profile = Profiles.get_profile_by_handle(user.handle) + + assert Ecto.assoc_loaded?(found_profile.links) + # Should only have active links + assert length(found_profile.links) == 2 + # Should be ordered by position + assert Enum.at(found_profile.links, 0).title == "Link 2" + assert Enum.at(found_profile.links, 1).title == "Link 1" + end + end + + describe "profile links" do + test "rejects unsafe and malformed profile link URLs" do + user = AccountsFixtures.user_fixture() + {:ok, profile} = Profiles.create_user_profile(user.id, %{display_name: "Link User"}) + + unsafe_urls = [ + "javascript:alert(1)", + "data:text/html,", + "https://example.com\r\nLocation:https://evil.test", + "https://example.com/some path", + "mailto:test@example.com\r\nBcc:evil@example.com", + "mailto:not-an-address", + "tel:+1 555 123 4567", + "tel:abc123" + ] + + for url <- unsafe_urls do + assert {:error, changeset} = + Profiles.create_profile_link(profile.id, %{ + title: "Unsafe", + url: url, + platform: "website" + }) + + assert %{url: [_ | _]} = errors_on(changeset) + end + end + + test "accepts trimmed http, mailto, and tel profile link URLs" do + user = AccountsFixtures.user_fixture() + {:ok, profile} = Profiles.create_user_profile(user.id, %{display_name: "Link User"}) + + assert {:ok, link} = + Profiles.create_profile_link(profile.id, %{ + title: "Site", + url: " https://example.com/path?x=1 ", + platform: "website" + }) + + assert link.url == "https://example.com/path?x=1" + + assert {:ok, _link} = + Profiles.create_profile_link(profile.id, %{ + title: "Email", + url: "mailto:test@example.com", + platform: "email" + }) + + assert {:ok, _link} = + Profiles.create_profile_link(profile.id, %{ + title: "Phone", + url: "tel:+15551234567", + platform: "phone" + }) + end + end + + describe "profile uploaded media URLs" do + test "rejects absolute URLs that only look like local uploads" do + user = AccountsFixtures.user_fixture() + + assert {:error, changeset} = + Profiles.create_user_profile(user.id, %{ + display_name: "Media User", + avatar_url: "https://evil.example/uploads/avatars/avatar.png", + background_url: "https://evil.example/uploads/backgrounds/bg.png", + banner_url: "https://evil.example/uploads/backgrounds/banner.png", + favicon_url: "https://evil.example/uploads/favicons/favicon.ico" + }) + + assert %{avatar_url: [_ | _]} = errors_on(changeset) + assert %{background_url: [_ | _]} = errors_on(changeset) + assert %{banner_url: [_ | _]} = errors_on(changeset) + assert %{favicon_url: [_ | _]} = errors_on(changeset) + end + + test "accepts relative upload keys and paths for profile media" do + user = AccountsFixtures.user_fixture() + + assert {:ok, profile} = + Profiles.create_user_profile(user.id, %{ + display_name: "Media User", + avatar_url: "avatars/avatar.png", + background_url: "/uploads/backgrounds/bg.png", + banner_url: "uploads/backgrounds/banner.png", + favicon_url: "favicons/favicon.ico" + }) + + assert profile.avatar_url == "avatars/avatar.png" + assert profile.background_url == "/uploads/backgrounds/bg.png" + assert profile.banner_url == "uploads/backgrounds/banner.png" + assert profile.favicon_url == "favicons/favicon.ico" + end + end + + describe "follow_user/2" do + test "creates a follow relationship" do + user1 = AccountsFixtures.user_fixture() + user2 = AccountsFixtures.user_fixture() + + assert {:ok, _follow} = Profiles.follow_user(user1.id, user2.id) + assert Profiles.following?(user1.id, user2.id) + end + + test "cannot follow self" do + user = AccountsFixtures.user_fixture() + + _result = Profiles.follow_user(user.id, user.id) + + # Should either return error or silently fail + refute Profiles.following?(user.id, user.id) + end + + test "following same user twice is idempotent" do + user1 = AccountsFixtures.user_fixture() + user2 = AccountsFixtures.user_fixture() + + {:ok, _} = Profiles.follow_user(user1.id, user2.id) + _result = Profiles.follow_user(user1.id, user2.id) + + # Should either succeed or return already following + assert Profiles.following?(user1.id, user2.id) + end + end + + describe "unfollow_user/2" do + test "removes a follow relationship" do + user1 = AccountsFixtures.user_fixture() + user2 = AccountsFixtures.user_fixture() + + {:ok, _} = Profiles.follow_user(user1.id, user2.id) + assert Profiles.following?(user1.id, user2.id) + + assert {:ok, :unfollowed} = Profiles.unfollow_user(user1.id, user2.id) + refute Profiles.following?(user1.id, user2.id) + end + + test "unfollowing when not following is a no-op" do + user1 = AccountsFixtures.user_fixture() + user2 = AccountsFixtures.user_fixture() + + assert {:ok, :not_following} = Profiles.unfollow_user(user1.id, user2.id) + refute Profiles.following?(user1.id, user2.id) + end + end + + describe "following?/2" do + test "returns true when following" do + user1 = AccountsFixtures.user_fixture() + user2 = AccountsFixtures.user_fixture() + + {:ok, _} = Profiles.follow_user(user1.id, user2.id) + + assert Profiles.following?(user1.id, user2.id) + end + + test "returns false when not following" do + user1 = AccountsFixtures.user_fixture() + user2 = AccountsFixtures.user_fixture() + + refute Profiles.following?(user1.id, user2.id) + end + + test "follow relationship is directional" do + user1 = AccountsFixtures.user_fixture() + user2 = AccountsFixtures.user_fixture() + + {:ok, _} = Profiles.follow_user(user1.id, user2.id) + + assert Profiles.following?(user1.id, user2.id) + refute Profiles.following?(user2.id, user1.id) + end + end + + describe "remote follow identity helpers" do + test "resolve pending follow state from a remote actor struct" do + viewer = AccountsFixtures.user_fixture() + + actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://mastodon.example/users/pending-remote", + username: "pending-remote", + domain: "mastodon.example", + inbox_url: "https://mastodon.example/users/pending-remote/inbox", + public_key: "test-public-key-pending-remote", + manually_approves_followers: true + }) + |> Repo.insert!() + + %Follow{} + |> Ecto.Changeset.change(%{ + follower_id: viewer.id, + remote_actor_id: actor.id, + activitypub_id: "https://elektrine.test/follows/#{System.unique_integer([:positive])}", + pending: true + }) + |> Repo.insert!() + + assert %{pending: true} = Profiles.get_follow_to_remote_actor_by_identity(viewer.id, actor) + assert not Profiles.following_remote_actor_by_identity?(viewer.id, actor) + end + + test "ignores nil uri when resolving remote actor identity" do + viewer = AccountsFixtures.user_fixture() + + actor = + %Actor{} + |> Actor.changeset(%{ + uri: "https://mastodon.example/users/no-uri-lookup", + username: "no-uri-lookup", + domain: "mastodon.example", + inbox_url: "https://mastodon.example/users/no-uri-lookup/inbox", + public_key: "test-public-key-no-uri-lookup", + manually_approves_followers: true + }) + |> Repo.insert!() + + %Follow{} + |> Ecto.Changeset.change(%{ + follower_id: viewer.id, + remote_actor_id: actor.id, + activitypub_id: "https://elektrine.test/follows/#{System.unique_integer([:positive])}", + pending: true + }) + |> Repo.insert!() + + actor_without_uri = %{ + id: actor.id, + uri: nil, + username: actor.username, + domain: actor.domain + } + + assert %{pending: true} = + Profiles.get_follow_to_remote_actor_by_identity(viewer.id, actor_without_uri) + + assert not Profiles.following_remote_actor_by_identity?(viewer.id, actor_without_uri) + end + end + + describe "public site analytics" do + test "empty host lists do not fall back to global analytics" do + track_site_visit("analytics-empty.example", "/", "empty-a") + + assert Profiles.get_public_site_view_count([]) == 0 + assert Profiles.get_public_site_unique_visitor_count([]) == 0 + assert Profiles.get_public_site_session_count([]) == 0 + assert Profiles.get_public_site_top_pages([], 10) == [] + assert Profiles.get_public_site_top_referrers([], 10) == [] + assert Profiles.get_public_site_domain_breakdown([]) == [] + + stats = Profiles.get_public_site_view_stats([]) + + assert stats.total_views == 0 + assert stats.unique_visitors == 0 + assert stats.sessions == 0 + assert stats.views_today == 0 + assert stats.views_this_week == 0 + end + + test "public site stats aggregate a selected host" do + session_id = unique_session_id("analytics-host") + + track_site_visit("analytics-host.example", "/", "host-a", session_id) + track_site_visit("analytics-host.example", "/pricing", "host-a", session_id) + track_site_visit("other-analytics-host.example", "/", "host-b") + + stats = Profiles.get_public_site_view_stats("analytics-host.example") + + assert stats.total_views == 2 + assert stats.unique_visitors == 1 + assert stats.sessions == 1 + assert stats.views_today == 2 + assert stats.views_this_week == 2 + assert stats.bounce_rate == 0.0 + end + end + + describe "get_follower_count/1" do + test "returns 0 for user with no followers" do + user = AccountsFixtures.user_fixture() + assert Profiles.get_follower_count(user.id) == 0 + end + + test "returns correct count" do + user = AccountsFixtures.user_fixture() + follower1 = AccountsFixtures.user_fixture() + follower2 = AccountsFixtures.user_fixture() + + {:ok, _} = Profiles.follow_user(follower1.id, user.id) + {:ok, _} = Profiles.follow_user(follower2.id, user.id) + + assert Profiles.get_follower_count(user.id) == 2 + end + end + + describe "get_following_count/1" do + test "returns 0 for user following no one" do + user = AccountsFixtures.user_fixture() + assert Profiles.get_following_count(user.id) == 0 + end + + test "returns correct count" do + user = AccountsFixtures.user_fixture() + target1 = AccountsFixtures.user_fixture() + target2 = AccountsFixtures.user_fixture() + + {:ok, _} = Profiles.follow_user(user.id, target1.id) + {:ok, _} = Profiles.follow_user(user.id, target2.id) + + assert Profiles.get_following_count(user.id) == 2 + end + end + + describe "get_followers/1" do + test "returns list of followers" do + user = AccountsFixtures.user_fixture() + follower1 = AccountsFixtures.user_fixture() + follower2 = AccountsFixtures.user_fixture() + + {:ok, _} = Profiles.follow_user(follower1.id, user.id) + {:ok, _} = Profiles.follow_user(follower2.id, user.id) + + followers = Profiles.get_followers(user.id) + + assert length(followers) == 2 + follower_ids = Enum.map(followers, & &1.user.id) + assert follower1.id in follower_ids + assert follower2.id in follower_ids + end + + test "returns empty list when no followers" do + user = AccountsFixtures.user_fixture() + assert Profiles.get_followers(user.id) == [] + end + + test "supports id pagination options" do + user = AccountsFixtures.user_fixture() + older = AccountsFixtures.user_fixture() + newer = AccountsFixtures.user_fixture() + + {:ok, _} = Profiles.follow_user(older.id, user.id) + {:ok, _} = Profiles.follow_user(newer.id, user.id) + + followers = Profiles.get_followers(user.id, before_id: newer.id) + assert Enum.map(followers, & &1.user.id) == [older.id] + + followers = Profiles.get_followers(user.id, since_id: older.id) + assert Enum.map(followers, & &1.user.id) == [newer.id] + end + end + + describe "get_following/1" do + test "returns list of users being followed" do + user = AccountsFixtures.user_fixture() + target1 = AccountsFixtures.user_fixture() + target2 = AccountsFixtures.user_fixture() + + {:ok, _} = Profiles.follow_user(user.id, target1.id) + {:ok, _} = Profiles.follow_user(user.id, target2.id) + + following = Profiles.get_following(user.id) + + assert length(following) == 2 + following_ids = Enum.map(following, & &1.user.id) + assert target1.id in following_ids + assert target2.id in following_ids + end + + test "returns empty list when not following anyone" do + user = AccountsFixtures.user_fixture() + assert Profiles.get_following(user.id) == [] + end + + test "supports id pagination options" do + user = AccountsFixtures.user_fixture() + older = AccountsFixtures.user_fixture() + newer = AccountsFixtures.user_fixture() + + {:ok, _} = Profiles.follow_user(user.id, older.id) + {:ok, _} = Profiles.follow_user(user.id, newer.id) + + following = Profiles.get_following(user.id, before_id: newer.id) + assert Enum.map(following, & &1.user.id) == [older.id] + + following = Profiles.get_following(user.id, since_id: older.id) + assert Enum.map(following, & &1.user.id) == [newer.id] + end + end + + describe "list_remote_followers/1" do + test "returns only accepted remote followers" do + user = AccountsFixtures.user_fixture() + accepted_actor = remote_actor_fixture("accepted") + pending_actor = remote_actor_fixture("pending") + + {:ok, _} = + Profiles.create_remote_follow( + accepted_actor.id, + user.id, + false, + "https://remote.server/activities/follow/#{System.unique_integer([:positive])}" + ) + + {:ok, _} = + Profiles.create_remote_follow( + pending_actor.id, + user.id, + true, + "https://remote.server/activities/follow/#{System.unique_integer([:positive])}" + ) + + followers = Profiles.list_remote_followers(user.id) + follower_actor_ids = Enum.map(followers, & &1.remote_actor_id) + + assert accepted_actor.id in follower_actor_ids + refute pending_actor.id in follower_actor_ids + assert length(follower_actor_ids) == 1 + end + end + + describe "remote follow status" do + test "treats legacy pending follows to auto-accepting remote actors as following" do + user = AccountsFixtures.user_fixture() + auto_accepting_actor = remote_actor_fixture("public", %{manually_approves_followers: false}) + + approval_required_actor = + remote_actor_fixture("private", %{manually_approves_followers: true}) + + insert_local_to_remote_follow(user.id, auto_accepting_actor.id, true) + insert_local_to_remote_follow(user.id, approval_required_actor.id, true) + + assert Profiles.following_remote_actor?(user.id, auto_accepting_actor.id) + refute Profiles.following_remote_actor?(user.id, approval_required_actor.id) + + assert Profiles.remote_following_status_batch(user.id, [ + auto_accepting_actor.id, + approval_required_actor.id + ]) == [ + {auto_accepting_actor.id, :following}, + {approval_required_actor.id, :pending} + ] + + following = Profiles.get_following(user.id) + + assert Enum.any?( + following, + &(&1.type == "remote" and &1.remote_actor.id == auto_accepting_actor.id) + ) + + refute Enum.any?( + following, + &(&1.type == "remote" and &1.remote_actor.id == approval_required_actor.id) + ) + + assert Profiles.get_following_count(user.id) == 1 + end + end + + describe "site visit analytics" do + test "skips known bot user agents for public site analytics" do + assert {:ok, :bot} = + Profiles.track_site_page_visit( + visitor_id: "bot-visitor", + session_id: "bot-session", + request_host: "example.com", + request_path: "/", + status: 200, + user_agent: + "Mozilla/5.0 AppleWebKit/537.36 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)" + ) + + assert Repo.aggregate(SitePageVisit, :count) == 0 + assert Repo.aggregate(SiteSession, :count) == 0 + end + + test "skips known bot user agents for profile analytics" do + user = AccountsFixtures.user_fixture() + + assert {:ok, :bot} = + Profiles.track_profile_view(user.id, + viewer_session_id: "bot-profile-session", + user_agent: "Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)" + ) + + assert {:ok, :bot} = + Profiles.track_profile_site_visit(user.id, + visitor_id: "bot-site-visitor", + request_host: "example.com", + request_path: "/", + user_agent: + "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" + ) + + assert Repo.aggregate(ProfileView, :count) == 0 + assert Repo.aggregate(ProfileSiteVisit, :count) == 0 + end + + test "can scope stats to multiple hosts" do + user = AccountsFixtures.user_fixture() + + {:ok, _} = + Profiles.track_profile_site_visit(user.id, + visitor_id: "visitor-1", + request_host: "example.com", + request_path: "/" + ) + + {:ok, _} = + Profiles.track_profile_site_visit(user.id, + visitor_id: "visitor-2", + request_host: "www.example.com", + request_path: "/about" + ) + + {:ok, _} = + Profiles.track_profile_site_visit(user.id, + visitor_id: "visitor-3", + request_host: "other.example.net", + request_path: "/" + ) + + stats = Profiles.get_site_view_stats(user.id, ["example.com", "www.example.com"]) + + assert stats.total_views == 2 + assert stats.unique_visitors == 2 + assert stats.views_today == 2 + assert stats.views_this_week == 2 + end + + test "prunes raw analytics rows past configured retention windows" do + user = AccountsFixtures.user_fixture() + now = DateTime.utc_now() |> DateTime.truncate(:second) + old_site_datetime = DateTime.add(now, -31, :day) + recent_site_datetime = DateTime.add(now, -29, :day) + old_profile_datetime = DateTime.add(now, -91, :day) + recent_profile_datetime = DateTime.add(now, -89, :day) + old_site_timestamp = DateTime.to_naive(old_site_datetime) + recent_site_timestamp = DateTime.to_naive(recent_site_datetime) + old_profile_timestamp = DateTime.to_naive(old_profile_datetime) + recent_profile_timestamp = DateTime.to_naive(recent_profile_datetime) + + Repo.insert_all(SitePageVisit, [ + %{ + visitor_id: "old-page-visitor", + session_id: "old-page-session", + request_host: "example.com", + request_path: "/old", + status: 200, + inserted_at: old_site_timestamp + }, + %{ + visitor_id: "recent-page-visitor", + session_id: "recent-page-session", + request_host: "example.com", + request_path: "/recent", + status: 200, + inserted_at: recent_site_timestamp + } + ]) + + Repo.insert_all(SiteSession, [ + %{ + session_id: "old-session", + visitor_id: "old-session-visitor", + entry_host: "example.com", + entry_path: "/old", + exit_host: "example.com", + exit_path: "/old", + page_views: 1, + started_at: old_site_datetime, + last_seen_at: old_site_datetime, + duration_seconds: 0, + inserted_at: old_site_timestamp, + updated_at: old_site_timestamp + }, + %{ + session_id: "recent-session", + visitor_id: "recent-session-visitor", + entry_host: "example.com", + entry_path: "/recent", + exit_host: "example.com", + exit_path: "/recent", + page_views: 1, + started_at: recent_site_datetime, + last_seen_at: recent_site_datetime, + duration_seconds: 0, + inserted_at: recent_site_timestamp, + updated_at: recent_site_timestamp + } + ]) + + Repo.insert_all(ProfileSiteVisit, [ + %{ + profile_user_id: user.id, + visitor_id: "old-profile-site-visitor", + request_host: "example.com", + request_path: "/old", + inserted_at: old_profile_timestamp + }, + %{ + profile_user_id: user.id, + visitor_id: "recent-profile-site-visitor", + request_host: "example.com", + request_path: "/recent", + inserted_at: recent_profile_timestamp + } + ]) + + Repo.insert_all(ProfileView, [ + %{ + profile_user_id: user.id, + viewer_session_id: "old-profile-view-session", + inserted_at: old_profile_timestamp + }, + %{ + profile_user_id: user.id, + viewer_session_id: "recent-profile-view-session", + inserted_at: recent_profile_timestamp + } + ]) + + assert %{ + site_page_visits: 1, + site_sessions: 1, + profile_site_visits: 1, + profile_views: 1 + } = + Profiles.prune_analytics_retention( + site_retention_days: 30, + profile_retention_days: 90, + batch_size: 10, + max_batches: 10, + now: now + ) + + assert Repo.aggregate(SitePageVisit, :count) == 1 + assert Repo.aggregate(SiteSession, :count) == 1 + assert Repo.aggregate(ProfileSiteVisit, :count) == 1 + assert Repo.aggregate(ProfileView, :count) == 1 + + assert Repo.get_by(SitePageVisit, request_path: "/recent") + assert Repo.get_by(SiteSession, session_id: "recent-session") + assert Repo.get_by(ProfileSiteVisit, request_path: "/recent") + assert Repo.get_by(ProfileView, viewer_session_id: "recent-profile-view-session") + end + + test "serves public site analytics from refreshed rollups" do + now = DateTime.utc_now() |> DateTime.truncate(:second) + today_timestamp = DateTime.to_naive(now) + yesterday = DateTime.add(now, -1, :day) + yesterday_timestamp = DateTime.to_naive(yesterday) + + Repo.insert_all(SiteSession, [ + %{ + session_id: unique_session_id("rollup-home"), + visitor_id: "rollup-home-visitor", + referer: "https://referrer.example", + entry_host: "example.com", + entry_path: "/", + exit_host: "example.com", + exit_path: "/", + page_views: 3, + started_at: now, + last_seen_at: now, + duration_seconds: 20, + inserted_at: today_timestamp, + updated_at: today_timestamp + }, + %{ + session_id: unique_session_id("rollup-about"), + visitor_id: "rollup-about-visitor", + entry_host: "example.com", + entry_path: "/about", + exit_host: "example.com", + exit_path: "/about", + page_views: 1, + started_at: yesterday, + last_seen_at: yesterday, + duration_seconds: 10, + inserted_at: yesterday_timestamp, + updated_at: yesterday_timestamp + }, + %{ + session_id: unique_session_id("rollup-other-host"), + visitor_id: "rollup-other-host-visitor", + entry_host: "other.example", + entry_path: "/", + exit_host: "other.example", + exit_path: "/", + page_views: 5, + started_at: now, + last_seen_at: now, + duration_seconds: 30, + inserted_at: today_timestamp, + updated_at: today_timestamp + } + ]) + + assert %{daily: 3, pages: 3, referrers: 1, dates: 30} = + Profiles.refresh_public_site_analytics_rollups(days: 30) + + Repo.delete_all(SiteSession) + + stats = Profiles.get_public_site_view_stats("example.com") + assert stats.total_views == 4 + assert stats.sessions == 2 + assert stats.unique_visitors == 2 + assert stats.views_today == 3 + assert stats.views_this_week == 4 + assert stats.avg_session_duration_seconds == 15.0 + assert stats.bounce_rate == 50.0 + + assert [%{host: "example.com", path: "/", views: 3, unique_visitors: 1} | _] = + Profiles.get_public_site_top_pages("example.com", 10) + + assert [%{referer: "https://referrer.example", count: 1}] = + Profiles.get_public_site_top_referrers("example.com", 10) + + assert [%{host: "example.com", views: 4, unique_visitors: 2, views_today: 3}] = + Profiles.get_public_site_domain_breakdown(["example.com"]) + + daily_views = Profiles.get_public_site_daily_view_counts(2, "example.com") + assert Enum.map(daily_views, & &1.count) == [1, 3] + end + end + + defp remote_actor_fixture(label, overrides \\ %{}) do + unique_id = System.unique_integer([:positive]) + username = "#{label}_#{unique_id}" + + %Actor{} + |> Actor.changeset( + Map.merge( + %{ + uri: "https://remote.server/users/#{username}", + username: username, + domain: "remote.server", + inbox_url: "https://remote.server/users/#{username}/inbox", + public_key: "-----BEGIN RSA PUBLIC KEY-----\nMOCK\n-----END RSA PUBLIC KEY-----\n", + last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second) + }, + overrides + ) + ) + |> Repo.insert!() + end + + defp insert_local_to_remote_follow(follower_id, remote_actor_id, pending) do + %Follow{} + |> Follow.changeset(%{ + follower_id: follower_id, + remote_actor_id: remote_actor_id, + pending: pending, + activitypub_id: "https://elektrine.example/activities/#{System.unique_integer([:positive])}" + }) + |> Repo.insert!() + end + + defp track_site_visit(host, path, visitor_id, session_id \\ nil) do + session_id = session_id || unique_session_id(visitor_id) + + {:ok, _visit} = + Profiles.track_site_page_visit( + visitor_id: visitor_id, + session_id: session_id, + request_host: host, + request_path: path, + status: 200 + ) + + session_id + end + + defp unique_session_id(label) do + "#{label}-#{System.unique_integer([:positive])}" + end +end diff --git a/apps/elektrine/test/elektrine/push/web_push_client_test.exs b/apps/elektrine/test/elektrine/push/web_push_client_test.exs new file mode 100644 index 0000000..bf7c70f --- /dev/null +++ b/apps/elektrine/test/elektrine/push/web_push_client_test.exs @@ -0,0 +1,88 @@ +defmodule Elektrine.Push.WebPushClientTest do + use ExUnit.Case, async: true + + alias Elektrine.Push.WebPushClient + + @curve :prime256v1 + + describe "encrypt/3 (RFC 8291 aes128gcm)" do + test "browser-side decryption round-trips the payload" do + # Simulate the browser: its ECDH keypair + 16-byte auth secret, + # then decrypt the record exactly as a User Agent would. + {ua_public, ua_private} = :crypto.generate_key(:ecdh, @curve) + auth_secret = :crypto.strong_rand_bytes(16) + plaintext = ~s({"title":"Hello","body":"World"}) + + record = WebPushClient.encrypt(plaintext, ua_public, auth_secret) + + <> = record + <> = rest + + ecdh_secret = :crypto.compute_key(:ecdh, as_public, ua_private, @curve) + prk_key = :crypto.mac(:hmac, :sha256, auth_secret, ecdh_secret) + key_info = "WebPush: info" <> <<0>> <> ua_public <> as_public + ikm = hkdf_expand(prk_key, key_info, 32) + prk = :crypto.mac(:hmac, :sha256, salt, ikm) + cek = hkdf_expand(prk, "Content-Encoding: aes128gcm" <> <<0>>, 16) + nonce = hkdf_expand(prk, "Content-Encoding: nonce" <> <<0>>, 12) + + tag_size = 16 + data_size = byte_size(body) - tag_size + <> = body + + decrypted = + :crypto.crypto_one_time_aead(:aes_128_gcm, cek, nonce, ciphertext, <<>>, tag, false) + + assert decrypted == plaintext <> <<2>> + end + end + + describe "sign_jwt/3 (RFC 8292 VAPID)" do + test "produces a valid ES256 JWT verifiable with the public key" do + {public, private} = :crypto.generate_key(:ecdh, @curve) + + jwt = + WebPushClient.sign_jwt( + %{"typ" => "JWT", "alg" => "ES256"}, + %{"aud" => "https://push.example.net", "exp" => 1_800_000_000, "sub" => "mailto:a@b.c"}, + private + ) + + [header_b64, claims_b64, signature_b64] = String.split(jwt, ".") + + assert %{"alg" => "ES256"} = + header_b64 |> Base.url_decode64!(padding: false) |> Jason.decode!() + + assert %{"aud" => "https://push.example.net"} = + claims_b64 |> Base.url_decode64!(padding: false) |> Jason.decode!() + + raw_signature = Base.url_decode64!(signature_b64, padding: false) + assert byte_size(raw_signature) == 64 + <> = raw_signature + + der_signature = + :public_key.der_encode(:"ECDSA-Sig-Value", {:"ECDSA-Sig-Value", r, s}) + + assert :crypto.verify( + :ecdsa, + :sha256, + header_b64 <> "." <> claims_b64, + der_signature, + [public, @curve] + ) + end + end + + describe "generate_vapid_keys/0" do + test "returns base64url keys of the expected sizes" do + %{public_key: pub, private_key: priv} = WebPushClient.generate_vapid_keys() + + assert byte_size(Base.url_decode64!(pub, padding: false)) == 65 + assert byte_size(Base.url_decode64!(priv, padding: false)) in 1..32 + end + end + + defp hkdf_expand(prk, info, length) do + :crypto.mac(:hmac, :sha256, prk, info <> <<1>>) |> binary_part(0, length) + end +end diff --git a/apps/elektrine/test/elektrine/push_test.exs b/apps/elektrine/test/elektrine/push_test.exs new file mode 100644 index 0000000..88d4647 --- /dev/null +++ b/apps/elektrine/test/elektrine/push_test.exs @@ -0,0 +1,428 @@ +defmodule Elektrine.PushTest do + use Elektrine.DataCase, async: false + + alias Elektrine.{AccountsFixtures, Push, Repo} + alias Elektrine.Push.{DeviceToken, WebSubscription} + alias Elektrine.Secrets.EncryptedString + + defmodule ConnectedPresence do + def get_by_key("mobile:users", "123"), do: [%{metas: [%{user_id: 123}]}] + def get_by_key(_topic, _key), do: [] + end + + defmodule UnavailablePresence do + def get_by_key(_topic, _key) do + raise ArgumentError, "the table identifier does not refer to an existing ETS table" + end + end + + defmodule WebPushClient do + def deliver(subscription, payload, test_pid) do + send(test_pid, {:web_push_delivered, subscription.id, payload}) + {:ok, :sent} + end + end + + defmodule FailingWebPushClient do + def deliver(_subscription, _payload, _opts), do: {:error, :gone} + end + + test "returns false when web runtime component is disabled" do + refute Push.user_has_active_connection?(123, + web_enabled?: false, + presence_running?: true, + presence_module: ConnectedPresence + ) + end + + test "returns false when the presence process is not running" do + refute Push.user_has_active_connection?(123, + web_enabled?: true, + presence_running?: false, + presence_module: ConnectedPresence + ) + end + + test "returns false when the presence tracker ETS table is unavailable" do + refute Push.user_has_active_connection?(123, + web_enabled?: true, + presence_running?: true, + presence_module: UnavailablePresence + ) + end + + test "returns true when presence lookup finds an active connection" do + assert Push.user_has_active_connection?(123, + web_enabled?: true, + presence_running?: true, + presence_module: ConnectedPresence + ) + end + + test "register_device stores encrypted token and lookup hash" do + user = AccountsFixtures.user_fixture() + token = "ExponentPushToken[abc123]" + + assert {:ok, device} = + Push.register_device(user.id, %{ + token: token, + platform: "android", + device_name: "Pixel" + }) + + assert device.token == token + assert device.token_hash == Push.device_token_hash(token) + + [[stored_token, stored_token_hash]] = + Repo.query!("SELECT token, token_hash FROM device_tokens WHERE id = $1", [device.id]).rows + + assert EncryptedString.encrypted?(stored_token) + refute stored_token == token + assert stored_token_hash == Push.device_token_hash(token) + assert Push.get_device_by_token(token).id == device.id + end + + test "register_device updates existing devices by token hash" do + user = AccountsFixtures.user_fixture() + token = "ExponentPushToken[update-me]" + + assert {:ok, first} = Push.register_device(user.id, %{token: token, platform: "android"}) + + assert {:ok, second} = + Push.register_device(user.id, %{ + token: token, + platform: "android", + device_name: "Updated" + }) + + assert second.id == first.id + assert second.device_name == "Updated" + assert Repo.aggregate(DeviceToken, :count) == 1 + end + + test "unregister_device deletes by token hash" do + user = AccountsFixtures.user_fixture() + token = "ExponentPushToken[delete-me]" + + assert {:ok, device} = Push.register_device(user.id, %{token: token, platform: "android"}) + assert {:ok, _deleted} = Push.unregister_device(token) + refute Repo.get(DeviceToken, device.id) + end + + test "upsert_web_subscription stores encrypted endpoint and keys with lookup hash" do + user = AccountsFixtures.user_fixture() + endpoint = "https://push.example/subscriptions/abc123" + + assert {:ok, subscription} = + Push.upsert_web_subscription(user.id, %{ + "subscription" => %{ + "endpoint" => endpoint, + "keys" => %{"p256dh" => "public-key", "auth" => "auth-secret"} + }, + "data" => %{ + "alerts" => %{"mention" => true, "status" => false}, + "policy" => "followed" + } + }) + + assert subscription.endpoint == endpoint + assert subscription.p256dh == "public-key" + assert subscription.auth == "auth-secret" + assert subscription.endpoint_hash == Push.web_push_endpoint_hash(endpoint) + assert subscription.policy == "followed" + assert subscription.alerts["mention"] == true + + [[stored_endpoint, stored_p256dh, stored_auth, stored_hash]] = + Repo.query!( + "SELECT endpoint, p256dh, auth, endpoint_hash FROM web_push_subscriptions WHERE id = $1", + [subscription.id] + ).rows + + assert EncryptedString.encrypted?(stored_endpoint) + assert EncryptedString.encrypted?(stored_p256dh) + assert EncryptedString.encrypted?(stored_auth) + refute stored_endpoint == endpoint + assert stored_hash == Push.web_push_endpoint_hash(endpoint) + end + + test "upsert_web_subscription updates existing subscriptions by endpoint hash" do + user = AccountsFixtures.user_fixture() + endpoint = "https://push.example/subscriptions/update" + + assert {:ok, first} = + Push.upsert_web_subscription(user.id, web_subscription_attrs(endpoint, "all")) + + assert {:ok, second} = + Push.upsert_web_subscription(user.id, web_subscription_attrs(endpoint, "none")) + + assert second.id == first.id + assert second.policy == "none" + assert Repo.aggregate(WebSubscription, :count) == 1 + end + + test "update and delete current web subscription" do + user = AccountsFixtures.user_fixture() + + assert {:ok, subscription} = + Push.upsert_web_subscription( + user.id, + web_subscription_attrs("https://push.example/subscriptions/delete", "all") + ) + + assert {:ok, updated} = + Push.update_web_subscription(user.id, %{ + "data" => %{"alerts" => %{"follow" => false}, "policy" => "none"} + }) + + assert updated.id == subscription.id + assert updated.alerts["follow"] == false + assert updated.policy == "none" + + assert {:ok, _deleted} = Push.delete_web_subscription(user.id) + refute Push.get_web_subscription(user.id) + end + + test "notify_web_user delivers only allowed alert and policy subscriptions" do + user = AccountsFixtures.user_fixture() + actor = AccountsFixtures.user_fixture() + follower = AccountsFixtures.user_fixture() + unrelated = AccountsFixtures.user_fixture() + + assert {:ok, _follow} = Elektrine.Profiles.follow_user(user.id, actor.id) + assert {:ok, _follow} = Elektrine.Profiles.follow_user(follower.id, user.id) + + Application.put_env(:elektrine, :web_push_client, {WebPushClient, self()}) + on_exit(fn -> Application.delete_env(:elektrine, :web_push_client) end) + + assert {:ok, followed_subscription} = + Push.upsert_web_subscription( + user.id, + web_subscription_attrs("https://push.example/subscriptions/followed", "followed") + ) + + assert {:ok, follower_subscription} = + Push.upsert_web_subscription( + user.id, + web_subscription_attrs("https://push.example/subscriptions/follower", "follower") + ) + + assert {:ok, _none_subscription} = + Push.upsert_web_subscription( + user.id, + web_subscription_attrs("https://push.example/subscriptions/none", "none") + ) + + assert {:ok, muted_subscription} = + Push.upsert_web_subscription( + user.id, + %{ + "subscription" => %{ + "endpoint" => "https://push.example/subscriptions/muted", + "keys" => %{"p256dh" => "public-key", "auth" => "auth-secret"} + }, + "data" => %{"alerts" => %{"mention" => false}, "policy" => "all"} + } + ) + + muted_subscription_id = muted_subscription.id + + assert {:ok, 1} = + Push.notify_web_user(user.id, %{ + type: "mention", + actor_id: actor.id, + title: "Mention", + body: "Hello" + }) + + assert_receive {:web_push_delivered, delivered_id, %{title: "Mention"}} + assert delivered_id == followed_subscription.id + + assert {:ok, 1} = + Push.notify_web_user(user.id, %{ + type: "mention", + actor_id: follower.id, + title: "Follower", + body: "Hello" + }) + + assert_receive {:web_push_delivered, delivered_id, %{title: "Follower"}} + assert delivered_id == follower_subscription.id + + assert {:ok, 0} = + Push.notify_web_user(user.id, %{ + type: "mention", + actor_id: unrelated.id, + title: "No delivery", + body: "Hello" + }) + + refute_receive {:web_push_delivered, _id, %{title: "No delivery"}}, 50 + refute_receive {:web_push_delivered, ^muted_subscription_id, _payload}, 50 + end + + test "notify_web_user maps internal like and boost notifications to compatible alert names" do + user = AccountsFixtures.user_fixture() + actor = AccountsFixtures.user_fixture() + + Application.put_env(:elektrine, :web_push_client, {WebPushClient, self()}) + on_exit(fn -> Application.delete_env(:elektrine, :web_push_client) end) + + assert {:ok, _subscription} = + Push.upsert_web_subscription( + user.id, + %{ + "subscription" => %{ + "endpoint" => "https://push.example/subscriptions/alert-aliases", + "keys" => %{"p256dh" => "public-key", "auth" => "auth-secret"} + }, + "data" => %{ + "alerts" => %{"favourite" => false, "reblog" => false}, + "policy" => "all" + } + } + ) + + assert {:ok, 0} = + Push.notify_web_user(user.id, %{ + type: "like", + actor_id: actor.id, + title: "Like", + body: "Liked" + }) + + assert {:ok, 0} = + Push.notify_web_user(user.id, %{ + type: "boost", + actor_id: actor.id, + title: "Boost", + body: "Boosted" + }) + + refute_receive {:web_push_delivered, _id, _payload}, 50 + end + + test "notify_web_user lets explicit internal alert keys override compatible aliases" do + user = AccountsFixtures.user_fixture() + actor = AccountsFixtures.user_fixture() + + Application.put_env(:elektrine, :web_push_client, {WebPushClient, self()}) + on_exit(fn -> Application.delete_env(:elektrine, :web_push_client) end) + + assert {:ok, subscription} = + Push.upsert_web_subscription( + user.id, + %{ + "subscription" => %{ + "endpoint" => "https://push.example/subscriptions/internal-alert-key", + "keys" => %{"p256dh" => "public-key", "auth" => "auth-secret"} + }, + "data" => %{ + "alerts" => %{"favourite" => false, "like" => true}, + "policy" => "all" + } + } + ) + + assert {:ok, 1} = + Push.notify_web_user(user.id, %{ + type: "like", + actor_id: actor.id, + title: "Like", + body: "Liked" + }) + + assert_receive {:web_push_delivered, delivered_id, %{title: "Like"}} + assert delivered_id == subscription.id + end + + test "send_to_web_subscription records failures and disables after repeated failures" do + user = AccountsFixtures.user_fixture() + + Application.put_env(:elektrine, :web_push_client, FailingWebPushClient) + on_exit(fn -> Application.delete_env(:elektrine, :web_push_client) end) + + assert {:ok, subscription} = + Push.upsert_web_subscription( + user.id, + web_subscription_attrs("https://push.example/subscriptions/fail", "all") + ) + + failed = + Enum.reduce(1..5, subscription, fn _attempt, subscription -> + assert {:error, :gone} = Push.send_to_web_subscription(subscription, %{title: "Fail"}) + Repo.get!(WebSubscription, subscription.id) + end) + + assert failed.failed_count == 5 + assert failed.enabled == false + assert failed.last_error == ":gone" + end + + test "created notifications dispatch browser web push payloads" do + user = AccountsFixtures.user_fixture() + + Application.put_env(:elektrine, :web_push_client, {WebPushClient, self()}) + on_exit(fn -> Application.delete_env(:elektrine, :web_push_client) end) + + assert {:ok, subscription} = + Push.upsert_web_subscription( + user.id, + web_subscription_attrs("https://push.example/subscriptions/notification", "all") + ) + + assert {:ok, notification} = + Elektrine.Notifications.create_notification(%{ + type: "mention", + title: "New mention", + body: "Someone mentioned you", + url: "/timeline/1", + user_id: user.id + }) + + assert_receive {:web_push_delivered, delivered_id, payload} + assert delivered_id == subscription.id + assert payload.title == "New mention" + assert payload.body == "Someone mentioned you" + assert payload.data.notification_id == notification.id + assert payload.data.url == "/timeline/1" + end + + test "created notifications redact browser web push contents when enabled" do + user = AccountsFixtures.user_fixture(%{hide_notification_contents: true}) + + Application.put_env(:elektrine, :web_push_client, {WebPushClient, self()}) + on_exit(fn -> Application.delete_env(:elektrine, :web_push_client) end) + + assert {:ok, subscription} = + Push.upsert_web_subscription( + user.id, + web_subscription_attrs("https://push.example/subscriptions/redacted", "all") + ) + + assert {:ok, notification} = + Elektrine.Notifications.create_notification(%{ + type: "mention", + title: "New mention", + body: "Someone mentioned you", + url: "/timeline/1", + user_id: user.id + }) + + assert_receive {:web_push_delivered, delivered_id, payload} + assert delivered_id == subscription.id + assert payload.title == "New notification" + assert payload.body == "Open Elektrine to view it." + assert payload.data.notification_id == notification.id + assert payload.data.url == "/timeline/1" + end + + defp web_subscription_attrs(endpoint, policy) do + %{ + "subscription" => %{ + "endpoint" => endpoint, + "keys" => %{"p256dh" => "public-key", "auth" => "auth-secret"} + }, + "data" => %{"policy" => policy} + } + end +end diff --git a/apps/elektrine/test/elektrine/reports_test.exs b/apps/elektrine/test/elektrine/reports_test.exs new file mode 100644 index 0000000..eac77b9 --- /dev/null +++ b/apps/elektrine/test/elektrine/reports_test.exs @@ -0,0 +1,210 @@ +defmodule Elektrine.ReportsTest do + use Elektrine.DataCase, async: true + + import Elektrine.AccountsFixtures + import Elektrine.SocialFixtures + + alias Elektrine.Accounts + alias Elektrine.Accounts.UserActivityStats + alias Elektrine.Notifications.Notification + alias Elektrine.Repo + alias Elektrine.Reports + + describe "admin notifications" do + test "creating a report notifies active admins only" do + admin = admin_user_fixture() + non_admin = user_fixture() + reporter = user_fixture() + offender = user_fixture() + post = post_fixture(user: offender) + + assert {:ok, report} = + Reports.create_report(%{ + reporter_id: reporter.id, + reportable_type: "message", + reportable_id: post.id, + reason: "spam", + priority: "high" + }) + + assert %Notification{} = + Repo.get_by(Notification, + user_id: admin.id, + type: "system", + source_type: "report", + source_id: report.id + ) + + refute Repo.get_by(Notification, + user_id: non_admin.id, + type: "system", + source_type: "report", + source_id: report.id + ) + end + end + + describe "telemetry" do + setup do + parent = self() + handler_id = "reports-test-#{System.unique_integer([:positive])}" + + :ok = + :telemetry.attach( + handler_id, + [:elektrine, :reports, :operation], + fn event, measurements, metadata, _config -> + send(parent, {:report_telemetry, event, measurements, metadata}) + end, + nil + ) + + on_exit(fn -> :telemetry.detach(handler_id) end) + + :ok + end + + test "emits report create telemetry" do + reporter = user_fixture() + offender = user_fixture() + post = post_fixture(user: offender) + + assert {:ok, report} = + Reports.create_report(%{ + reporter_id: reporter.id, + reportable_type: "message", + reportable_id: post.id, + reason: "spam" + }) + + {[:elektrine, :reports, :operation], %{count: 1}, metadata} = + assert_receive_report_telemetry("create", %{report_id: Integer.to_string(report.id)}) + + assert metadata.operation == "create" + assert metadata.outcome == "success" + assert metadata.report_id == Integer.to_string(report.id) + assert metadata.reportable_type == "message" + end + + test "emits report action and review telemetry when resolving" do + admin = admin_user_fixture() + reporter = user_fixture() + offender = user_fixture() + post = post_fixture(user: offender) + + {:ok, report} = + Reports.create_report(%{ + reporter_id: reporter.id, + reportable_type: "message", + reportable_id: post.id, + reason: "harassment" + }) + + flush_report_telemetry() + + assert {:ok, _updated_report} = + Reports.resolve_report(report, admin, %{ + action_taken: "content_removed", + resolution_notes: "confirmed" + }) + + {[:elektrine, :reports, :operation], %{count: 1}, action_metadata} = + assert_receive_report_telemetry("action", %{action: "content_removed"}) + + assert action_metadata.operation == "action" + assert action_metadata.outcome == "success" + assert action_metadata.action == "content_removed" + assert action_metadata.reviewer_id == Integer.to_string(admin.id) + + {[:elektrine, :reports, :operation], %{count: 1}, review_metadata} = + assert_receive_report_telemetry("review", %{status: "resolved"}) + + assert review_metadata.operation == "review" + assert review_metadata.outcome == "success" + assert review_metadata.status == "resolved" + end + end + + describe "trust stats integration" do + test "creating a report increments reporter and target flag stats" do + reporter = user_fixture() + offender = user_fixture() + post = post_fixture(user: offender) + + assert {:ok, _report} = + Reports.create_report(%{ + reporter_id: reporter.id, + reportable_type: "message", + reportable_id: post.id, + reason: "spam" + }) + + assert Repo.get_by!(UserActivityStats, user_id: reporter.id).flags_given == 1 + assert Repo.get_by!(UserActivityStats, user_id: offender.id).flags_received == 1 + end + + test "resolving a report with action increments flags_agreed once" do + admin = user_fixture() + reporter = user_fixture() + offender = user_fixture() + post = post_fixture(user: offender) + + {:ok, report} = + Reports.create_report(%{ + reporter_id: reporter.id, + reportable_type: "message", + reportable_id: post.id, + reason: "harassment" + }) + + assert {:ok, updated_report} = + Reports.review_report(report, %{ + status: "resolved", + action_taken: "content_removed", + reviewed_by_id: admin.id + }) + + assert Repo.get_by!(UserActivityStats, user_id: reporter.id).flags_agreed == 1 + + assert {:ok, _report} = + Reports.review_report(updated_report, %{ + resolution_notes: "confirmed by moderator", + reviewed_by_id: admin.id + }) + + assert Repo.get_by!(UserActivityStats, user_id: reporter.id).flags_agreed == 1 + end + end + + defp admin_user_fixture do + user = user_fixture() + {:ok, admin} = Accounts.admin_update_user(user, %{is_admin: true}) + admin + end + + defp flush_report_telemetry do + receive do + {:report_telemetry, _, _, _} -> flush_report_telemetry() + after + 10 -> :ok + end + end + + defp assert_receive_report_telemetry(operation, filters) do + receive do + {:report_telemetry, event, measurements, metadata} -> + if metadata.operation == operation and telemetry_metadata_matches?(metadata, filters) do + {event, measurements, metadata} + else + assert_receive_report_telemetry(operation, filters) + end + after + 500 -> + flunk("expected report telemetry for #{operation} with #{inspect(filters)}") + end + end + + defp telemetry_metadata_matches?(metadata, filters) do + Enum.all?(filters, fn {key, value} -> Map.get(metadata, key) == value end) + end +end diff --git a/apps/elektrine/test/elektrine/rss/fetch_feed_worker_test.exs b/apps/elektrine/test/elektrine/rss/fetch_feed_worker_test.exs new file mode 100644 index 0000000..7772ebc --- /dev/null +++ b/apps/elektrine/test/elektrine/rss/fetch_feed_worker_test.exs @@ -0,0 +1,10 @@ +defmodule Elektrine.RSS.FetchFeedWorkerTest do + use Elektrine.DataCase, async: true + + alias Elektrine.RSS.FetchFeedWorker + + test "discards missing feeds" do + assert {:discard, :feed_not_found} = + FetchFeedWorker.perform(%Oban.Job{args: %{"feed_id" => -1}}) + end +end diff --git a/apps/elektrine/test/elektrine/rss/parser_test.exs b/apps/elektrine/test/elektrine/rss/parser_test.exs new file mode 100644 index 0000000..b7e0f55 --- /dev/null +++ b/apps/elektrine/test/elektrine/rss/parser_test.exs @@ -0,0 +1,674 @@ +defmodule Elektrine.RSS.ParserTest do + use ExUnit.Case, async: true + + alias Elektrine.RSS.Parser + + describe "RSS 2.0 parsing" do + test "parses basic RSS 2.0 feed" do + xml = """ + + + + Test Blog + A test blog + https://testblog.com + + First Post + https://testblog.com/post-1 + First post content + post-1 + Mon, 01 Jan 2024 12:00:00 GMT + + + Second Post + https://testblog.com/post-2 + Second post content + post-2 + + + + """ + + {:ok, feed} = Parser.parse(xml) + + assert feed.title == "Test Blog" + assert feed.subtitle == "A test blog" + assert feed.link == "https://testblog.com" + assert length(feed.entries) == 2 + + [entry1, entry2] = feed.entries + assert entry1.title == "First Post" + assert entry1.link == "https://testblog.com/post-1" + assert entry1.guid == "post-1" + # Date parsing may not work for all RFC 2822 formats + # The important thing is the feed parses without error + + assert entry2.title == "Second Post" + assert entry2.guid == "post-2" + end + + test "parses RSS 2.0 with content:encoded" do + xml = """ + + + + Content Test + https://test.com + + Post with Content + https://test.com/post + content-post + Short description + Full HTML content here

    ]]>
    +
    +
    +
    + """ + + {:ok, feed} = Parser.parse(xml) + + [entry] = feed.entries + assert entry.content == "

    Full HTML content here

    " + assert entry.summary == "Short description" + end + + test "parses RSS 2.0 content encoded using local namespace name" do + xml = """ + + + + Long Content + https://test.com + + Long Post + https://test.com/long + long-post + Short description + Full article paragraph one.

    Full article paragraph two.

    ]]>
    +
    +
    +
    + """ + + {:ok, feed} = Parser.parse(xml) + + [entry] = feed.entries + + assert entry.content == + "

    Full article paragraph one.

    Full article paragraph two.

    " + + assert entry.summary == "Short description" + end + + test "parses RSS 2.0 with enclosures (podcasts)" do + xml = """ + + + + Podcast Feed + https://podcast.com + + Episode 1 + https://podcast.com/ep1 + ep1 + + + + + """ + + {:ok, feed} = Parser.parse(xml) + + [entry] = feed.entries + assert entry.enclosure_url == "https://podcast.com/ep1.mp3" + assert entry.enclosure_type == "audio/mpeg" + end + + test "extracts RSS item images from common feed extensions and content" do + xml = """ + + + + Images + https://example.com/news/ + + Media content + https://example.com/news/media + media + + + + Media thumbnail + https://example.com/news/thumb + thumb + + + + iTunes image + https://example.com/news/itunes + itunes + + + + Content image + https://example.com/news/content + content + Story

    ]]>
    +
    +
    +
    + """ + + {:ok, feed} = Parser.parse(xml) + + assert Enum.map(feed.entries, & &1.image_url) == [ + "https://cdn.example.com/media.jpg", + "https://cdn.example.com/thumb.jpg", + "https://cdn.example.com/itunes.jpg", + "https://example.com/images/content.jpg" + ] + end + + test "parses RSS 2.0 with categories" do + xml = """ + + + + Category Test + https://test.com + + Categorized Post + https://test.com/post + cat-post + Technology + Programming + + + + """ + + {:ok, feed} = Parser.parse(xml) + + [entry] = feed.entries + assert "Technology" in entry.categories + assert "Programming" in entry.categories + end + + test "parses RSS 2.0 with dc:creator when namespace declared" do + # Note: dc:creator parsing depends on namespace being properly declared + # The parser uses SweetXml which handles namespaces + xml = """ + + + + Author Test + https://test.com + + Post with Author + https://test.com/post + author-post + John Doe + + + + """ + + {:ok, feed} = Parser.parse(xml) + + [entry] = feed.entries + # dc:creator may or may not be parsed depending on XPath handling + # Just verify the parse succeeds and entry exists + assert entry.title == "Post with Author" + end + + test "handles items with link but no guid" do + xml = """ + + + + No GUID Test + https://test.com + + Post without GUID + https://test.com/unique-post + https://test.com/unique-post + Content here + + + + """ + + {:ok, feed} = Parser.parse(xml) + + [entry] = feed.entries + # Entry should have a guid (either explicit or from link) + assert entry.guid != nil + assert entry.title == "Post without GUID" + end + end + + describe "Atom parsing" do + test "parses basic Atom feed" do + xml = """ + + + Atom Blog + An Atom feed + + + urn:uuid:1234 + First Entry + + Entry summary + 2024-01-01T12:00:00Z + + + """ + + {:ok, feed} = Parser.parse(xml) + + assert feed.title == "Atom Blog" + assert feed.subtitle == "An Atom feed" + assert feed.link == "https://atomblog.com" + assert length(feed.entries) == 1 + + [entry] = feed.entries + assert entry.guid == "urn:uuid:1234" + assert entry.title == "First Entry" + assert entry.link == "https://atomblog.com/entry-1" + assert entry.summary == "Entry summary" + end + + test "parses Atom with content element" do + xml = """ + + + Content Atom + + content-entry + Entry with Content + + Short summary + Full entry content here + + + """ + + {:ok, feed} = Parser.parse(xml) + + [entry] = feed.entries + assert entry.content == "Full entry content here" + assert entry.summary == "Short summary" + end + + test "parses Atom xhtml content as full entry text" do + xml = """ + + + XHTML Atom + + xhtml-entry + Entry with XHTML Content + + Short summary + +
    +

    Full entry paragraph one.

    +

    Full entry paragraph two.

    +
    +
    +
    +
    + """ + + {:ok, feed} = Parser.parse(xml) + + [entry] = feed.entries + assert entry.content =~ "Full entry paragraph one." + assert entry.content =~ "Full entry paragraph two." + assert entry.summary == "Short summary" + end + + test "parses Atom with author" do + xml = """ + + + Author Atom + + author-entry + Entry with Author + + + Jane Smith + + + + """ + + {:ok, feed} = Parser.parse(xml) + + [entry] = feed.entries + assert entry.author == "Jane Smith" + end + + test "parses Atom with category terms" do + xml = """ + + + Category Atom + + cat-entry + Categorized Entry + + + + + + """ + + {:ok, feed} = Parser.parse(xml) + + [entry] = feed.entries + assert "Science" in entry.categories + assert "Space" in entry.categories + end + + test "parses Atom with published date" do + xml = """ + + + Date Atom + + date-entry + Entry with Published + + 2024-06-15T10:30:00Z + 2024-06-16T08:00:00Z + + + """ + + {:ok, feed} = Parser.parse(xml) + + [entry] = feed.entries + # Should use published over updated + assert entry.published_at != nil + end + + test "parses Atom icon and logo" do + xml = """ + + + Image Atom + https://test.com/logo.png + https://test.com/icon.png + + img-entry + Entry + + + """ + + {:ok, feed} = Parser.parse(xml) + + # Logo takes precedence + assert feed.image_url == "https://test.com/logo.png" + end + + test "extracts Atom item images from image links and HTML content" do + xml = """ + + + Atom Images + + + atom-image-link + Atom image link + + + + + atom-content-image + Atom content image + + Body

    ]]>
    +
    +
    + """ + + {:ok, feed} = Parser.parse(xml) + + assert Enum.map(feed.entries, & &1.image_url) == [ + "https://cdn.example.com/atom-link.png", + "https://atom.example.com/img/atom-content.png" + ] + end + end + + describe "RSS 1.0 parsing" do + test "parses RSS 1.0 feed" do + xml = """ + + + + RSS 1.0 Feed + An RSS 1.0 feed + https://rss1.com + + + RSS 1.0 Item + https://rss1.com/item + Item description + 2024-01-15T09:00:00Z + + + """ + + {:ok, feed} = Parser.parse(xml) + + assert feed.title == "RSS 1.0 Feed" + assert feed.link == "https://rss1.com" + assert length(feed.entries) == 1 + + [entry] = feed.entries + assert entry.title == "RSS 1.0 Item" + assert entry.link == "https://rss1.com/item" + end + end + + describe "date parsing" do + test "parses ISO 8601 dates in Atom published element" do + xml = """ + + + ISO Date Test + + iso-date + ISO Entry + 2024-03-15T14:30:00Z + + + """ + + {:ok, feed} = Parser.parse(xml) + + [entry] = feed.entries + assert entry.published_at.year == 2024 + assert entry.published_at.month == 3 + assert entry.published_at.day == 15 + end + + test "parses simple date formats in RSS" do + xml = """ + + + + Date Test + + Date Entry + date-entry + 15 Apr 2024 10:00:00 GMT + + + + """ + + {:ok, feed} = Parser.parse(xml) + + [entry] = feed.entries + # Date parsing may return nil if format is not perfectly matched + # This tests that the parser doesn't crash + assert is_nil(entry.published_at) or entry.published_at.year == 2024 + end + + test "handles missing dates gracefully" do + xml = """ + + + + No Date Test + + Entry without date + no-date + + + + """ + + {:ok, feed} = Parser.parse(xml) + + [entry] = feed.entries + assert entry.published_at == nil + end + end + + describe "error handling" do + test "returns error for unknown format" do + xml = """ + + + Not a feed + + """ + + assert {:error, :unknown_format} = Parser.parse(xml) + end + + test "returns error for malformed or unrecognized content" do + xml = "not valid xml at all + + + Empty Feed + + + """ + + {:ok, feed} = Parser.parse(xml) + + assert feed.title == "Empty Feed" + assert feed.entries == [] + end + end + + describe "text cleaning" do + test "decodes HTML entities" do + xml = """ + + + + Test & Blog + + Post about "Testing" + entity-post + + + + """ + + {:ok, feed} = Parser.parse(xml) + + assert feed.title == "Test & Blog" + [entry] = feed.entries + assert entry.title == "Post about \"Testing\"" + end + + test "trims whitespace" do + xml = """ + + + + Whitespace Title + + + Entry with spaces + + space-post + + + + """ + + {:ok, feed} = Parser.parse(xml) + + assert feed.title == "Whitespace Title" + [entry] = feed.entries + assert entry.title == "Entry with spaces" + end + end + + describe "author parsing" do + test "extracts name from email format" do + xml = """ + + + + Author Test + + Post + author-1 + user@example.com (John Doe) + + + + """ + + {:ok, feed} = Parser.parse(xml) + + [entry] = feed.entries + assert entry.author == "John Doe" + end + + test "extracts username from plain email" do + xml = """ + + + + Author Test + + Post + author-2 + johndoe@example.com + + + + """ + + {:ok, feed} = Parser.parse(xml) + + [entry] = feed.entries + assert entry.author == "johndoe" + end + end +end diff --git a/apps/elektrine/test/elektrine/rss_test.exs b/apps/elektrine/test/elektrine/rss_test.exs new file mode 100644 index 0000000..bdde823 --- /dev/null +++ b/apps/elektrine/test/elektrine/rss_test.exs @@ -0,0 +1,518 @@ +defmodule Elektrine.RSSTest do + use Elektrine.DataCase + + alias Elektrine.Accounts + alias Elektrine.RSS + alias Elektrine.RSS.Feed + + describe "feeds" do + setup do + {:ok, user} = + Accounts.create_user(%{ + username: "rssuser", + password: "SecurePassword123!", + password_confirmation: "SecurePassword123!" + }) + + %{user: user} + end + + test "get_or_create_feed creates a new feed" do + url = "https://example.com/feed.xml" + + {:ok, feed} = RSS.get_or_create_feed(url) + + assert feed.url == url + assert feed.status == "pending" + end + + test "get_or_create_feed returns existing feed" do + url = "https://example.com/feed.xml" + + {:ok, feed1} = RSS.get_or_create_feed(url) + {:ok, feed2} = RSS.get_or_create_feed(url) + + assert feed1.id == feed2.id + end + + test "get_or_create_feed normalizes URLs" do + url1 = " https://example.com/feed.xml " + url2 = "https://example.com/feed.xml" + + {:ok, feed1} = RSS.get_or_create_feed(url1) + {:ok, feed2} = RSS.get_or_create_feed(url2) + + assert feed1.id == feed2.id + end + + test "get_feed returns feed by ID" do + {:ok, feed} = RSS.get_or_create_feed("https://example.com/feed.xml") + + found = RSS.get_feed(feed.id) + assert found.id == feed.id + end + + test "get_feed returns nil for non-existent ID" do + assert RSS.get_feed(-1) == nil + end + + test "get_feed_by_url returns feed by URL" do + {:ok, feed} = RSS.get_or_create_feed("https://example.com/feed.xml") + + found = RSS.get_feed_by_url("https://example.com/feed.xml") + assert found.id == feed.id + end + + test "get_feed_by_url returns nil for non-existent URL" do + assert RSS.get_feed_by_url("https://nonexistent.com/feed.xml") == nil + end + + test "update_feed updates feed attributes" do + {:ok, feed} = RSS.get_or_create_feed("https://example.com/feed.xml") + + {:ok, updated} = + RSS.update_feed(feed, %{ + title: "Example Feed", + description: "A test feed", + status: "active" + }) + + assert updated.title == "Example Feed" + assert updated.description == "A test feed" + assert updated.status == "active" + end + + test "list_stale_feeds returns feeds needing refresh" do + # Create an active feed with old last_fetched_at + {:ok, feed} = RSS.get_or_create_feed("https://example.org/stale.xml") + old_time = DateTime.add(DateTime.utc_now(), -120, :minute) + + {:ok, feed} = + RSS.update_feed(feed, %{ + status: "active", + last_fetched_at: old_time + }) + + stale_feeds = RSS.list_stale_feeds() + assert Enum.any?(stale_feeds, &(&1.id == feed.id)) + end + + test "list_stale_feeds excludes recently fetched feeds" do + {:ok, feed} = RSS.get_or_create_feed("https://fresh.com/feed.xml") + recent_time = DateTime.add(DateTime.utc_now(), -5, :minute) + + {:ok, _feed} = + RSS.update_feed(feed, %{ + status: "active", + last_fetched_at: recent_time + }) + + stale_feeds = RSS.list_stale_feeds() + refute Enum.any?(stale_feeds, &(&1.id == feed.id)) + end + end + + describe "subscriptions" do + setup do + {:ok, user} = + Accounts.create_user(%{ + username: "subuser", + password: "SecurePassword123!", + password_confirmation: "SecurePassword123!" + }) + + %{user: user} + end + + test "subscribe creates subscription to feed", %{user: user} do + url = "https://example.org/blog-feed.xml" + + {:ok, subscription} = RSS.subscribe(user.id, url) + + assert subscription.user_id == user.id + assert subscription.feed.url == url + assert subscription.show_in_timeline == true + end + + test "subscribe with options", %{user: user} do + url = "https://example.org/blog-feed.xml" + + {:ok, subscription} = + RSS.subscribe(user.id, url, + display_name: "My Blog", + folder: "Tech" + ) + + assert subscription.display_name == "My Blog" + assert subscription.folder == "Tech" + end + + test "subscribe prevents duplicate subscriptions", %{user: user} do + url = "https://example.org/blog-feed.xml" + + {:ok, _subscription1} = RSS.subscribe(user.id, url) + {:error, changeset} = RSS.subscribe(user.id, url) + + assert changeset.errors != [] + end + + test "unsubscribe removes subscription", %{user: user} do + url = "https://example.org/blog-feed.xml" + + {:ok, subscription} = RSS.subscribe(user.id, url) + {:ok, _} = RSS.unsubscribe(user.id, subscription.feed_id) + + # Verify subscription is gone + subscriptions = RSS.list_subscriptions(user.id) + refute Enum.any?(subscriptions, &(&1.feed_id == subscription.feed_id)) + end + + test "unsubscribe returns error when not subscribed", %{user: user} do + {:error, :not_subscribed} = RSS.unsubscribe(user.id, -1) + end + + test "list_subscriptions returns user's subscriptions", %{user: user} do + {:ok, _sub1} = RSS.subscribe(user.id, "https://example.net/feed-1.xml") + {:ok, _sub2} = RSS.subscribe(user.id, "https://example.net/feed-2.xml") + + subscriptions = RSS.list_subscriptions(user.id) + + assert length(subscriptions) == 2 + assert Enum.all?(subscriptions, &(&1.user_id == user.id)) + end + + test "list_subscriptions preloads feed", %{user: user} do + {:ok, _sub} = RSS.subscribe(user.id, "https://example.net/feed.xml") + + [subscription] = RSS.list_subscriptions(user.id) + + assert Ecto.assoc_loaded?(subscription.feed) + assert subscription.feed.url == "https://example.net/feed.xml" + end + + test "list_subscriptions returns empty for user with no subscriptions" do + {:ok, other_user} = + Accounts.create_user(%{ + username: "nosubuser", + password: "SecurePassword123!", + password_confirmation: "SecurePassword123!" + }) + + assert RSS.list_subscriptions(other_user.id) == [] + end + + test "update_subscription updates subscription settings", %{user: user} do + {:ok, subscription} = RSS.subscribe(user.id, "https://example.net/feed.xml") + + {:ok, updated} = + RSS.update_subscription(subscription, %{ + show_in_timeline: false, + display_name: "Custom Name" + }) + + assert updated.show_in_timeline == false + assert updated.display_name == "Custom Name" + end + end + + describe "items" do + setup do + {:ok, user} = + Accounts.create_user(%{ + username: "itemuser", + password: "SecurePassword123!", + password_confirmation: "SecurePassword123!" + }) + + {:ok, feed} = RSS.get_or_create_feed("https://example.com/items-feed.xml") + RSS.update_feed(feed, %{status: "active", title: "Test Feed"}) + + %{user: user, feed: feed} + end + + test "upsert_item creates new item", %{feed: feed} do + attrs = %{ + guid: "item-1", + title: "Test Article", + content: "Article content", + url: "https://example.com/articles/article-1", + published_at: DateTime.utc_now() + } + + {:ok, item} = RSS.upsert_item(feed.id, attrs) + + assert item.guid == "item-1" + assert item.title == "Test Article" + assert item.feed_id == feed.id + end + + test "upsert_item updates existing item", %{feed: feed} do + attrs = %{ + guid: "item-1", + title: "Original Title", + content: "Original content" + } + + {:ok, _item1} = RSS.upsert_item(feed.id, attrs) + + # Update with same guid + updated_attrs = %{ + guid: "item-1", + title: "Updated Title", + content: "Updated content" + } + + {:ok, item2} = RSS.upsert_item(feed.id, updated_attrs) + + assert item2.title == "Updated Title" + assert item2.content == "Updated content" + + # Verify only one item exists + items = RSS.list_feed_items(feed.id) + assert length(items) == 1 + end + + test "list_feed_items returns items for feed", %{feed: feed} do + {:ok, _item1} = + RSS.upsert_item(feed.id, %{guid: "item-1", title: "Article 1"}) + + {:ok, _item2} = + RSS.upsert_item(feed.id, %{guid: "item-2", title: "Article 2"}) + + items = RSS.list_feed_items(feed.id) + + assert length(items) == 2 + end + + test "list_feed_items respects limit and offset", %{feed: feed} do + for i <- 1..5 do + RSS.upsert_item(feed.id, %{guid: "item-#{i}", title: "Article #{i}"}) + end + + items = RSS.list_feed_items(feed.id, limit: 2, offset: 1) + + assert length(items) == 2 + end + + test "list_user_items returns items from subscribed feeds", %{user: user, feed: feed} do + # Subscribe and ensure show_in_timeline is true + {:ok, _subscription} = RSS.subscribe(user.id, feed.url) + + {:ok, _item} = + RSS.upsert_item(feed.id, %{ + guid: "user-item-1", + title: "User Item", + published_at: DateTime.utc_now() + }) + + items = RSS.list_user_items(user.id) + + assert length(items) == 1 + assert hd(items).title == "User Item" + end + + test "list_user_items excludes items when show_in_timeline is false", %{ + user: user, + feed: feed + } do + {:ok, subscription} = RSS.subscribe(user.id, feed.url) + {:ok, _subscription} = RSS.update_subscription(subscription, %{show_in_timeline: false}) + + {:ok, _item} = + RSS.upsert_item(feed.id, %{ + guid: "hidden-item", + title: "Hidden Item", + published_at: DateTime.utc_now() + }) + + items = RSS.list_user_items(user.id) + + assert items == [] + end + + test "get_timeline_items returns formatted items", %{user: user, feed: feed} do + # Update feed with title for timeline display + {:ok, feed} = RSS.update_feed(feed, %{title: "Test Feed", status: "active"}) + {:ok, _subscription} = RSS.subscribe(user.id, feed.url) + + {:ok, _item} = + RSS.upsert_item(feed.id, %{ + guid: "timeline-item", + title: "Timeline Article", + summary: "Article summary", + url: "https://example.com/articles/timeline-article", + published_at: DateTime.utc_now() + }) + + items = RSS.get_timeline_items(user.id) + + assert length(items) == 1 + [item] = items + + assert item.type == :rss_item + assert item.title == "Timeline Article" + assert item.feed_title == "Test Feed" + assert item.feed_url == feed.url + end + + test "get_timeline_items respects sort option", %{user: user, feed: feed} do + {:ok, _subscription} = RSS.subscribe(user.id, feed.url) + older = ~U[2024-01-01 00:00:00Z] + newer = ~U[2024-01-02 00:00:00Z] + + {:ok, _older_item} = + RSS.upsert_item(feed.id, %{ + guid: "older-timeline-item", + title: "Older Article", + published_at: older + }) + + {:ok, _newer_item} = + RSS.upsert_item(feed.id, %{ + guid: "newer-timeline-item", + title: "Newer Article", + published_at: newer + }) + + {:ok, _undated_item} = + RSS.upsert_item(feed.id, %{ + guid: "undated-timeline-item", + title: "Undated Article" + }) + + assert Enum.map(RSS.get_timeline_items(user.id, sort: "new"), & &1.title) == [ + "Newer Article", + "Older Article", + "Undated Article" + ] + + assert Enum.map(RSS.get_timeline_items(user.id, sort: "old"), & &1.title) == [ + "Older Article", + "Newer Article", + "Undated Article" + ] + end + + test "count_user_items counts all items in subscriptions", %{user: user, feed: feed} do + {:ok, _subscription} = RSS.subscribe(user.id, feed.url) + + for i <- 1..3 do + RSS.upsert_item(feed.id, %{guid: "count-item-#{i}", title: "Item #{i}"}) + end + + count = RSS.count_user_items(user.id) + assert count == 3 + end + end + + describe "multiple users" do + setup do + {:ok, user1} = + Accounts.create_user(%{ + username: "multiuser1", + password: "SecurePassword123!", + password_confirmation: "SecurePassword123!" + }) + + {:ok, user2} = + Accounts.create_user(%{ + username: "multiuser2", + password: "SecurePassword123!", + password_confirmation: "SecurePassword123!" + }) + + %{user1: user1, user2: user2} + end + + test "users can subscribe to the same feed", %{user1: user1, user2: user2} do + url = "https://example.net/shared-feed.xml" + + {:ok, sub1} = RSS.subscribe(user1.id, url) + {:ok, sub2} = RSS.subscribe(user2.id, url) + + # Both subscriptions should exist + assert sub1.feed_id == sub2.feed_id + assert sub1.user_id != sub2.user_id + end + + test "subscription settings are independent per user", %{user1: user1, user2: user2} do + url = "https://example.net/shared-feed.xml" + + {:ok, sub1} = RSS.subscribe(user1.id, url, display_name: "User 1 Name") + {:ok, _sub2} = RSS.subscribe(user2.id, url, display_name: "User 2 Name") + + # Update user1's subscription + {:ok, _updated} = RSS.update_subscription(sub1, %{show_in_timeline: false}) + + # User2's subscription should be unchanged + [user2_sub] = RSS.list_subscriptions(user2.id) + assert user2_sub.show_in_timeline == true + assert user2_sub.display_name == "User 2 Name" + end + + test "user items are isolated", %{user1: user1, user2: user2} do + {:ok, feed1} = RSS.get_or_create_feed("https://example.com/feed-1.xml") + {:ok, feed2} = RSS.get_or_create_feed("https://example.org/feed-2.xml") + + RSS.update_feed(feed1, %{status: "active"}) + RSS.update_feed(feed2, %{status: "active"}) + + # User1 subscribes to feed1 + {:ok, _sub1} = RSS.subscribe(user1.id, feed1.url) + # User2 subscribes to feed2 + {:ok, _sub2} = RSS.subscribe(user2.id, feed2.url) + + # Add items to both feeds + {:ok, _item1} = + RSS.upsert_item(feed1.id, %{ + guid: "f1-item", + title: "Feed 1 Item", + published_at: DateTime.utc_now() + }) + + {:ok, _item2} = + RSS.upsert_item(feed2.id, %{ + guid: "f2-item", + title: "Feed 2 Item", + published_at: DateTime.utc_now() + }) + + # User1 should only see feed1 items + user1_items = RSS.list_user_items(user1.id) + assert length(user1_items) == 1 + assert hd(user1_items).title == "Feed 1 Item" + + # User2 should only see feed2 items + user2_items = RSS.list_user_items(user2.id) + assert length(user2_items) == 1 + assert hd(user2_items).title == "Feed 2 Item" + end + end + + describe "feed changesets" do + test "Feed.fetched_changeset sets fetch timestamp" do + feed = %Feed{ + id: 1, + url: "https://test.com/feed.xml", + last_error: "previous error", + status: "error" + } + + changeset = Feed.fetched_changeset(feed) + + assert changeset.changes.last_fetched_at != nil + assert changeset.changes.status == "active" + # last_error should be cleared (set to nil) + assert changeset.changes.last_error == nil + end + + test "Feed.error_changeset records error" do + feed = %Feed{id: 1, url: "https://test.com/feed.xml", status: "active"} + + changeset = Feed.error_changeset(feed, "Connection timeout") + + assert changeset.changes.last_error == "Connection timeout" + assert changeset.changes.status == "error" + end + end +end diff --git a/apps/elektrine/test/elektrine/runtime_env_test.exs b/apps/elektrine/test/elektrine/runtime_env_test.exs new file mode 100644 index 0000000..952c892 --- /dev/null +++ b/apps/elektrine/test/elektrine/runtime_env_test.exs @@ -0,0 +1,64 @@ +defmodule Elektrine.RuntimeEnvTest do + use ExUnit.Case, async: false + + alias Elektrine.RuntimeEnv + + setup do + previous_environment = Application.get_env(:elektrine, :environment) + + on_exit(fn -> + if is_nil(previous_environment) do + Application.delete_env(:elektrine, :environment) + else + Application.put_env(:elektrine, :environment, previous_environment) + end + end) + + :ok + end + + test "present trims blank values" do + assert RuntimeEnv.present("FOO", %{"FOO" => " value "}) == "value" + assert RuntimeEnv.present("FOO", %{"FOO" => " "}) == nil + end + + test "first_present returns first populated env value" do + env = %{"B" => "second", "C" => "third"} + + assert RuntimeEnv.first_present(["A", "B", "C"], env) == "second" + end + + test "optional_boolean parses common boolean env values" do + assert RuntimeEnv.optional_boolean("FLAG", %{"FLAG" => "true"}) == true + assert RuntimeEnv.optional_boolean("FLAG", %{"FLAG" => "false"}) == false + assert RuntimeEnv.optional_boolean("FLAG", %{"FLAG" => "maybe"}) == nil + end + + test "prod? and dev_or_test? reflect configured environment" do + Application.put_env(:elektrine, :environment, :prod) + assert RuntimeEnv.prod?() + refute RuntimeEnv.dev_or_test?() + + Application.put_env(:elektrine, :environment, :test) + refute RuntimeEnv.prod?() + assert RuntimeEnv.dev_or_test?() + end + + test "enforce_https? reflects app config" do + previous_enforce_https = Application.get_env(:elektrine, :enforce_https) + + on_exit(fn -> + if is_nil(previous_enforce_https) do + Application.delete_env(:elektrine, :enforce_https) + else + Application.put_env(:elektrine, :enforce_https, previous_enforce_https) + end + end) + + Application.put_env(:elektrine, :enforce_https, true) + assert RuntimeEnv.enforce_https?() + + Application.put_env(:elektrine, :enforce_https, false) + refute RuntimeEnv.enforce_https?() + end +end diff --git a/apps/elektrine/test/elektrine/search/domain_rules_test.exs b/apps/elektrine/test/elektrine/search/domain_rules_test.exs new file mode 100644 index 0000000..496b2df --- /dev/null +++ b/apps/elektrine/test/elektrine/search/domain_rules_test.exs @@ -0,0 +1,94 @@ +defmodule Elektrine.Search.DomainRulesTest do + use Elektrine.DataCase, async: true + + alias Elektrine.Search.DomainRules + + import Elektrine.AccountsFixtures + + setup do + %{user: user_fixture()} + end + + describe "set_rule/3 and rules_map/1" do + test "stores normalized domains", %{user: user} do + assert {:ok, rule} = DomainRules.set_rule(user, " WWW.Example.COM. ", "block") + assert rule.domain == "example.com" + assert rule.action == :block + + assert DomainRules.rules_map(user.id) == %{"example.com" => :block} + end + + test "replaces the action for an existing domain", %{user: user} do + assert {:ok, _rule} = DomainRules.set_rule(user, "example.com", "lower") + assert {:ok, _rule} = DomainRules.set_rule(user, "example.com", "pin") + + assert DomainRules.rules_map(user.id) == %{"example.com" => :pin} + end + + test "rejects invalid domains and actions", %{user: user} do + assert {:error, %Ecto.Changeset{}} = DomainRules.set_rule(user, "not a domain", "block") + assert {:error, %Ecto.Changeset{}} = DomainRules.set_rule(user, "example.com", "nuke") + + assert {:error, %Ecto.Changeset{}} = + DomainRules.set_rule(user, "javascript:alert(1)", "block") + end + end + + describe "remove_rule/2" do + test "removes an existing rule", %{user: user} do + assert {:ok, _rule} = DomainRules.set_rule(user, "example.com", "block") + assert :ok = DomainRules.remove_rule(user, "Example.com") + assert DomainRules.rules_map(user.id) == %{} + end + end + + describe "apply_rules/2" do + defp result(url, relevance), do: %{url: url, relevance: relevance, title: url} + + test "returns results unchanged with no rules" do + results = [result("https://example.com/a", 0.6)] + assert DomainRules.apply_rules(results, %{}) == results + assert DomainRules.apply_rules(results, nil) == results + end + + test "drops blocked domains including subdomains" do + results = [ + result("https://example.com/a", 0.6), + result("https://docs.example.com/b", 0.59), + result("https://other.test/c", 0.58) + ] + + assert [%{url: "https://other.test/c"}] = + DomainRules.apply_rules(results, %{"example.com" => :block}) + end + + test "pins, raises, and lowers adjust relevance" do + results = [ + result("https://pinme.test/", 0.1), + result("https://raiseme.test/", 0.5), + result("https://lowerme.test/", 0.5), + result("https://plain.test/", 0.5) + ] + + rules = %{"pinme.test" => :pin, "raiseme.test" => :raise, "lowerme.test" => :lower} + + ranked = + results + |> DomainRules.apply_rules(rules) + |> Enum.sort_by(&(-&1.relevance)) + |> Enum.map(& &1.url) + + assert ranked == [ + "https://pinme.test/", + "https://raiseme.test/", + "https://plain.test/", + "https://lowerme.test/" + ] + end + + test "matches hosts with a www prefix" do + results = [result("https://www.example.com/a", 0.6)] + assert DomainRules.apply_rules(results, %{"example.com" => :block}) == [] + end + end +end diff --git a/apps/elektrine/test/elektrine/search/paige_rate_limiter_test.exs b/apps/elektrine/test/elektrine/search/paige_rate_limiter_test.exs new file mode 100644 index 0000000..c0c1d1f --- /dev/null +++ b/apps/elektrine/test/elektrine/search/paige_rate_limiter_test.exs @@ -0,0 +1,31 @@ +defmodule Elektrine.Search.PaigeRateLimiterTest do + use ExUnit.Case, async: true + + import ExUnit.CaptureLog + + alias Elektrine.Search.PaigeRateLimiter + + test "atomically bounds concurrent external searches" do + identifier = "paige-test:#{System.unique_integer([:positive])}" + on_exit(fn -> PaigeRateLimiter.clear_limits(identifier) end) + + parent = self() + + capture_log(fn -> + results = + 1..40 + |> Task.async_stream( + fn _index -> PaigeRateLimiter.allow_query(identifier) end, + max_concurrency: 40, + ordered: false + ) + |> Enum.map(fn {:ok, result} -> result end) + + send(parent, {:rate_limit_results, results}) + end) + + assert_receive {:rate_limit_results, results} + assert Enum.count(results, &(&1 == :ok)) == 30 + assert Enum.count(results, &match?({:error, retry_after} when retry_after > 0, &1)) == 10 + end +end diff --git a/apps/elektrine/test/elektrine/search_test.exs b/apps/elektrine/test/elektrine/search_test.exs new file mode 100644 index 0000000..4292048 --- /dev/null +++ b/apps/elektrine/test/elektrine/search_test.exs @@ -0,0 +1,89 @@ +defmodule Elektrine.SearchTest do + use Elektrine.DataCase, async: true + + import Elektrine.AccountsFixtures + + alias Elektrine.ActivityPub.Actor + alias Elektrine.{Repo, Search} + alias Elektrine.Social.Message + + describe "global_search/3" do + test "finds local people when the query starts with @" do + unique = System.unique_integer([:positive]) + viewer = user_fixture(%{username: "viewer#{unique}"}) + person = user_fixture(%{username: "testuser#{unique}"}) + + results = Search.global_search(viewer, "@#{person.handle}", limit: 10).results + + assert Enum.any?(results, fn result -> + result.type == "person" and result.content == "@#{person.handle}" and + result.url == "/#{person.handle}" + end) + end + + test "matches federated actor handles with a leading @" do + unique = System.unique_integer([:positive]) + viewer = user_fixture(%{username: "viewerfed#{unique}"}) + actor = remote_actor_fixture("remote#{unique}", "example.com") + + actor + |> federated_post_fixture(%{ + activitypub_id: "https://example.com/activities/#{unique}", + content: "Federated post for search" + }) + + results = + Search.global_search(viewer, "@#{actor.username}@#{actor.domain}", limit: 10).results + + assert Enum.any?(results, fn result -> + result.type == "federated" and result.actor_username == actor.username and + result.actor_domain == actor.domain + end) + end + end + + describe "get_suggestions/3" do + test "suggests people for @-prefixed partial queries" do + unique = System.unique_integer([:positive]) + viewer = user_fixture(%{username: "viewersuggest#{unique}"}) + person = user_fixture(%{username: "testsuggest#{unique}"}) + + suggestions = Search.get_suggestions(viewer, "@tests", 10) + + assert Enum.any?(suggestions, fn suggestion -> + suggestion.type == "person" and suggestion.text == "@#{person.handle}" + end) + end + end + + defp remote_actor_fixture(username, domain) do + %Actor{} + |> Actor.changeset(%{ + uri: "https://#{domain}/users/#{username}", + username: username, + domain: domain, + inbox_url: "https://#{domain}/users/#{username}/inbox", + public_key: "test-public-key-#{username}" + }) + |> Repo.insert!() + end + + defp federated_post_fixture(actor, attrs) do + attrs = + Map.merge( + %{ + activitypub_id: + "https://#{actor.domain}/activities/#{System.unique_integer([:positive])}", + content: "Federated search fixture", + visibility: "public", + post_type: "post", + remote_actor_id: actor.id + }, + attrs + ) + + %Message{} + |> Message.federated_changeset(attrs) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/secrets/backfill_test.exs b/apps/elektrine/test/elektrine/secrets/backfill_test.exs new file mode 100644 index 0000000..4e20554 --- /dev/null +++ b/apps/elektrine/test/elektrine/secrets/backfill_test.exs @@ -0,0 +1,171 @@ +defmodule Elektrine.Secrets.BackfillTest do + use Elektrine.DataCase, async: false + + alias Elektrine.Accounts + alias Elektrine.ActivityPub.SigningKey + alias Elektrine.Email.CustomDomain + alias Elektrine.Repo + alias Elektrine.Secrets.Backfill + alias Elektrine.Secrets.EncryptedString + + setup do + previous_master = Application.get_env(:elektrine, :encryption_master_secret) + previous_salt = Application.get_env(:elektrine, :encryption_key_salt) + + Application.put_env(:elektrine, :encryption_master_secret, "test-master-secret-0123456789") + Application.put_env(:elektrine, :encryption_key_salt, "test-key-salt-0123456789") + + on_exit(fn -> + restore_env(:encryption_master_secret, previous_master) + restore_env(:encryption_key_salt, previous_salt) + end) + + :ok + end + + test "dry run reports legacy plaintext secrets without updating them" do + user = legacy_user_fixture() + + result = Backfill.run(dry_run: true) + + assert result.scanned >= 1 + assert result.updated == 0 + assert get_raw_user_field(user.id, "activitypub_private_key") == "legacy-private-key" + end + + test "apply rewrites legacy plaintext secrets encrypted in place" do + user = legacy_user_fixture() + key_id = legacy_signing_key_fixture(user) + domain_id = legacy_custom_domain_fixture(user) + actor_id = legacy_actor_fixture() + + result = Backfill.run(dry_run: false) + + assert result.updated >= 4 + + activitypub_private_key = get_raw_user_field(user.id, "activitypub_private_key") + bluesky_app_password = get_raw_user_field(user.id, "bluesky_app_password") + signing_private_key = get_raw_signing_key_field(key_id) + dkim_private_key = get_raw_custom_domain_field(domain_id) + + assert EncryptedString.encrypted?(activitypub_private_key) + assert EncryptedString.encrypted?(bluesky_app_password) + assert EncryptedString.encrypted?(signing_private_key) + assert EncryptedString.encrypted?(dkim_private_key) + + reloaded_user = Accounts.get_user!(user.id) + reloaded_signing_key = Repo.get!(SigningKey, key_id) + reloaded_domain = Repo.get!(CustomDomain, domain_id) + reloaded_actor_metadata = get_raw_actor_metadata(actor_id) + + assert reloaded_user.activitypub_private_key == "legacy-private-key" + assert reloaded_user.bluesky_app_password == "legacy-app-password" + assert reloaded_signing_key.private_key == "legacy-signing-private-key" + assert reloaded_domain.dkim_private_key == "legacy-dkim-private-key" + assert EncryptedString.encrypted?(reloaded_actor_metadata["private_key"]) + + assert Elektrine.ActivityPub.Actor.metadata_private_key(%Elektrine.ActivityPub.Actor{ + metadata: reloaded_actor_metadata + }) == + "legacy-actor-private-key" + end + + defp legacy_user_fixture do + {:ok, user} = + Accounts.create_user(%{ + username: "secretbackfill#{System.unique_integer([:positive])}", + password: "Test123456!", + password_confirmation: "Test123456!" + }) + + Repo.query!( + "UPDATE users SET activitypub_private_key = $1, bluesky_app_password = $2 WHERE id = $3", + ["legacy-private-key", "legacy-app-password", user.id] + ) + + user + end + + defp legacy_signing_key_fixture(user) do + key_id = "https://example.com/users/#{user.username}#main-key" + + Repo.query!( + "INSERT INTO signing_keys (key_id, user_id, public_key, private_key, inserted_at, updated_at) VALUES ($1, $2, $3, $4, NOW(), NOW())", + [key_id, user.id, "legacy-public-key", "legacy-signing-private-key"] + ) + + key_id + end + + defp legacy_custom_domain_fixture(user) do + result = + Repo.query!( + "INSERT INTO email_custom_domains (domain, verification_token, dkim_selector, dkim_public_key, dkim_private_key, status, user_id, inserted_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) RETURNING id", + [ + "secret#{System.unique_integer([:positive])}.example.com", + "verify-token", + "selector1", + "legacy-dkim-public-key", + "legacy-dkim-private-key", + "pending", + user.id + ] + ) + + [[id]] = result.rows + id + end + + defp legacy_actor_fixture do + result = + Repo.query!( + "INSERT INTO activitypub_actors (uri, username, domain, inbox_url, public_key, metadata, inserted_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW(), NOW()) RETURNING id", + [ + "https://example.com/actors/#{System.unique_integer([:positive])}", + "actor#{System.unique_integer([:positive])}", + "example.com", + "https://example.com/inbox", + "legacy-actor-public-key", + Jason.encode!(%{"private_key" => "legacy-actor-private-key"}) + ] + ) + + [[id]] = result.rows + id + end + + defp get_raw_user_field(id, field) do + Repo.query!("SELECT #{field} FROM users WHERE id = $1", [id]).rows + |> List.first() + |> List.first() + end + + defp get_raw_signing_key_field(key_id) do + Repo.query!("SELECT private_key FROM signing_keys WHERE key_id = $1", [key_id]).rows + |> List.first() + |> List.first() + end + + defp get_raw_custom_domain_field(id) do + Repo.query!("SELECT dkim_private_key FROM email_custom_domains WHERE id = $1", [id]).rows + |> List.first() + |> List.first() + end + + defp get_raw_actor_metadata(id) do + Repo.query!("SELECT metadata::text FROM activitypub_actors WHERE id = $1", [id]).rows + |> List.first() + |> List.first() + |> decode_json_map() + end + + defp decode_json_map(value) when is_binary(value) do + case Jason.decode!(value) do + decoded when is_map(decoded) -> decoded + decoded when is_binary(decoded) -> Jason.decode!(decoded) + end + end + + defp restore_env(key, nil), do: Application.delete_env(:elektrine, key) + defp restore_env(key, value), do: Application.put_env(:elektrine, key, value) +end diff --git a/apps/elektrine/test/elektrine/secrets/encrypted_string_test.exs b/apps/elektrine/test/elektrine/secrets/encrypted_string_test.exs new file mode 100644 index 0000000..2726fa0 --- /dev/null +++ b/apps/elektrine/test/elektrine/secrets/encrypted_string_test.exs @@ -0,0 +1,40 @@ +defmodule Elektrine.Secrets.EncryptedStringTest do + use ExUnit.Case, async: false + + alias Elektrine.Secrets.EncryptedString + + setup do + previous_master = Application.get_env(:elektrine, :encryption_master_secret) + previous_salt = Application.get_env(:elektrine, :encryption_key_salt) + + Application.put_env(:elektrine, :encryption_master_secret, "test-master-secret-0123456789") + Application.put_env(:elektrine, :encryption_key_salt, "test-key-salt-0123456789") + + on_exit(fn -> + restore_env(:encryption_master_secret, previous_master) + restore_env(:encryption_key_salt, previous_salt) + end) + + :ok + end + + test "dumps encrypted strings with a versioned prefix" do + assert {:ok, encrypted} = EncryptedString.dump("super-secret") + + assert EncryptedString.encrypted?(encrypted) + refute encrypted == "super-secret" + end + + test "loads encrypted strings back to plaintext" do + {:ok, encrypted} = EncryptedString.dump("super-secret") + + assert {:ok, "super-secret"} = EncryptedString.load(encrypted) + end + + test "loads legacy plaintext values unchanged" do + assert {:ok, "legacy-plaintext"} = EncryptedString.load("legacy-plaintext") + end + + defp restore_env(key, nil), do: Application.delete_env(:elektrine, key) + defp restore_env(key, value), do: Application.put_env(:elektrine, key, value) +end diff --git a/apps/elektrine/test/elektrine/security/safe_external_url_test.exs b/apps/elektrine/test/elektrine/security/safe_external_url_test.exs new file mode 100644 index 0000000..9af4c62 --- /dev/null +++ b/apps/elektrine/test/elektrine/security/safe_external_url_test.exs @@ -0,0 +1,38 @@ +defmodule Elektrine.Security.SafeExternalURLTest do + use ExUnit.Case, async: true + + alias Elektrine.Security.SafeExternalURL + + test "normalizes public http and https URLs" do + assert {:ok, "https://93.184.216.34/path"} = + SafeExternalURL.normalize(" https://93.184.216.34/path ") + + assert {:ok, "http://93.184.216.34/path"} = + SafeExternalURL.normalize("http://93.184.216.34/path") + end + + test "rejects external redirect URLs with controls, userinfo, and unsafe schemes" do + assert {:error, :invalid_url} = + SafeExternalURL.normalize("https://example.com\r\nLocation: https://evil.test") + + assert {:error, :userinfo_not_allowed} = + SafeExternalURL.normalize("https://example.com@evil.test/path") + + assert {:error, :invalid_url} = SafeExternalURL.normalize("javascript:alert(1)") + assert {:error, :invalid_url} = SafeExternalURL.normalize("//evil.test/path") + end + + test "normalizes href-only links without requiring DNS resolution" do + assert {:ok, "https://remote.example/path"} = + SafeExternalURL.normalize_href(" https://remote.example/path ") + + assert {:error, :invalid_url} = + SafeExternalURL.normalize_href("https://example.com\r\nx: y") + + assert {:error, :userinfo_not_allowed} = + SafeExternalURL.normalize_href("https://example.com@evil.test/path") + + assert {:error, :invalid_url} = SafeExternalURL.normalize_href("javascript:alert(1)") + assert {:error, :invalid_url} = SafeExternalURL.normalize_href("//evil.test/path") + end +end diff --git a/apps/elektrine/test/elektrine/security/url_validator_test.exs b/apps/elektrine/test/elektrine/security/url_validator_test.exs new file mode 100644 index 0000000..c63f7cc --- /dev/null +++ b/apps/elektrine/test/elektrine/security/url_validator_test.exs @@ -0,0 +1,44 @@ +defmodule Elektrine.Security.URLValidatorTest do + use ExUnit.Case, async: true + + alias Elektrine.Security.URLValidator + + describe "validate/1" do + test "trims trailing root dots before host classification" do + result = + URLValidator.validate( + "https://openpgpkey.example.localhost./.well-known/openpgpkey/example.localhost./hu/test" + ) + + assert result in [{:error, :private_domain}, {:error, :private_ip}] + end + end + + describe "validate_websocket/2" do + test "rejects private websocket endpoints" do + assert {:error, :private_ip} = + URLValidator.validate_websocket("wss://10.0.0.1/_arblarg/session") + + assert {:error, :private_ip} = + URLValidator.validate_websocket("wss://[::ffff:127.0.0.1]/_arblarg/session") + end + + test "rejects plaintext websocket endpoints unless explicitly allowed" do + assert {:error, :invalid_scheme} = + URLValidator.validate_websocket("ws://example.com/_arblarg/session") + end + + test "allows localhost websocket endpoints only when requested" do + assert {:error, :private_ip} = + URLValidator.validate_websocket("ws://127.0.0.1:49152/_arblarg/session", + allow_insecure_transport: true + ) + + assert :ok = + URLValidator.validate_websocket("ws://127.0.0.1:49152/_arblarg/session", + allow_insecure_transport: true, + allow_localhost: true + ) + end + end +end diff --git a/apps/elektrine/test/elektrine/security_alerts_test.exs b/apps/elektrine/test/elektrine/security_alerts_test.exs new file mode 100644 index 0000000..920f151 --- /dev/null +++ b/apps/elektrine/test/elektrine/security_alerts_test.exs @@ -0,0 +1,111 @@ +defmodule Elektrine.SecurityAlertsTest do + use Elektrine.DataCase, async: false + + alias Elektrine.Accounts + alias Elektrine.Domains + alias Elektrine.Email + alias Elektrine.EmailAddresses + alias Elektrine.SecurityAlerts + + defmodule FailingMailerAdapter do + use Swoosh.Adapter + + def deliver(_email, _config), do: {:error, :forced_failure} + def deliver_many(_emails, _config), do: {:error, :forced_failure} + end + + test "queues spoofing alerts and rate limits even when the mail adapter fails" do + previous_mailer_config = Application.get_env(:elektrine, Elektrine.Mailer, []) + + on_exit(fn -> + Application.put_env(:elektrine, Elektrine.Mailer, previous_mailer_config) + end) + + Application.put_env( + :elektrine, + Elektrine.Mailer, + Keyword.put(previous_mailer_config, :adapter, FailingMailerAdapter) + ) + + {:ok, user} = + Accounts.create_user(%{ + username: "spoofalert#{System.unique_integer([:positive])}", + password: "Test123456!", + password_confirmation: "Test123456!" + }) + + user = + user + |> Ecto.Changeset.change(%{recovery_email: "alerts@example.com"}) + |> Elektrine.Repo.update!() + + if mailbox = Email.get_user_mailbox(user.id) do + assert {:ok, _deleted_mailbox} = Email.delete_mailbox(mailbox) + end + + spoofed_address = + "spoof#{System.unique_integer([:positive])}@#{Domains.primary_email_domain()}" + + assert {:ok, _alias} = + Email.create_alias(%{ + alias_email: spoofed_address, + user_id: user.id, + enabled: true + }) + + # Recovery-email delivery goes through the Oban :email queue, so an + # adapter failure is retried in the background instead of failing the + # call, and the rate limit engages to prevent duplicate alerts. + assert {:ok, :sent} = + SecurityAlerts.send_spoofing_alert( + spoofed_address, + "victim@example.net", + "Test subject" + ) + + assert {:ok, :rate_limited} = + SecurityAlerts.send_spoofing_alert( + spoofed_address, + "victim@example.net", + "Test subject" + ) + end + + test "sends recovery-email spoofing alerts with security list id header" do + {:ok, user} = + Accounts.create_user(%{ + username: "spoofok#{System.unique_integer([:positive])}", + password: "Test123456!", + password_confirmation: "Test123456!" + }) + + user = + user + |> Ecto.Changeset.change(%{ + recovery_email: "alerts#{System.unique_integer([:positive])}@example.com" + }) + |> Elektrine.Repo.update!() + + spoofed_address = + "spoof#{System.unique_integer([:positive])}@#{Domains.primary_email_domain()}" + + assert {:ok, _alias} = + Email.create_alias(%{ + alias_email: spoofed_address, + user_id: user.id, + enabled: true + }) + + assert {:ok, :sent} = + SecurityAlerts.send_spoofing_alert( + spoofed_address, + "victim@example.net", + "Test subject" + ) + + assert_received {:email, email} + + assert Map.get(email.headers, "List-Id") == + EmailAddresses.list_id("elektrine-security") + end +end diff --git a/apps/elektrine/test/elektrine/social/engagement_counts_test.exs b/apps/elektrine/test/elektrine/social/engagement_counts_test.exs new file mode 100644 index 0000000..7a54002 --- /dev/null +++ b/apps/elektrine/test/elektrine/social/engagement_counts_test.exs @@ -0,0 +1,18 @@ +defmodule Elektrine.Social.EngagementCountsTest do + use ExUnit.Case, async: true + + alias Elektrine.Social.EngagementCounts + + test "clamps remote counters to a non-negative trusted range" do + assert EngagementCounts.remote_count(-10) == 0 + assert EngagementCounts.remote_count("42") == 42 + assert EngagementCounts.remote_count("bad") == 0 + assert EngagementCounts.remote_count(200_000_000) == EngagementCounts.max_remote_count() + end + + test "stores zero remote counters as nil" do + assert EngagementCounts.nullable_remote_count(0) == nil + assert EngagementCounts.nullable_remote_count("0") == nil + assert EngagementCounts.nullable_remote_count(5) == 5 + end +end diff --git a/apps/elektrine/test/elektrine/social/expired_data_cleanup_worker_test.exs b/apps/elektrine/test/elektrine/social/expired_data_cleanup_worker_test.exs new file mode 100644 index 0000000..352c0a5 --- /dev/null +++ b/apps/elektrine/test/elektrine/social/expired_data_cleanup_worker_test.exs @@ -0,0 +1,87 @@ +defmodule Elektrine.Social.ExpiredDataCleanupWorkerTest do + use Elektrine.DataCase, async: true + + import Elektrine.AccountsFixtures + + alias Elektrine.Repo + alias Elektrine.Social.ExpiredDataCleanupWorker + alias Elektrine.Social.LinkPreview + + test "prunes expired social filters and keeps active filters" do + user = user_fixture() + now = DateTime.utc_now() |> DateTime.truncate(:second) + + expired_at = DateTime.add(now, -60, :second) + future_at = DateTime.add(now, 60, :second) + + Repo.insert_all("social_filters", [ + filter_row(user.id, "expired cleanup token", expired_at, now), + filter_row(user.id, "active cleanup token", future_at, now), + filter_row(user.id, "permanent cleanup token", nil, now) + ]) + + assert :ok = ExpiredDataCleanupWorker.perform(%Oban.Job{}) + + assert filter_values() == ["active cleanup token", "permanent cleanup token"] + end + + test "prunes stale failed link previews and keeps fresh or successful previews" do + now = DateTime.utc_now() |> DateTime.truncate(:second) + stale = DateTime.add(now, -31, :day) + fresh = DateTime.add(now, -1, :day) + + stale_failed = + insert_preview!("https://example.com/stale-failed", "failed", stale) + + fresh_failed = + insert_preview!("https://example.com/fresh-failed", "failed", fresh) + + old_success = + insert_preview!("https://example.com/old-success", "success", stale) + + assert :ok = ExpiredDataCleanupWorker.perform(%Oban.Job{}) + + refute Repo.get(LinkPreview, stale_failed.id) + assert Repo.get(LinkPreview, fresh_failed.id) + assert Repo.get(LinkPreview, old_success.id) + end + + defp filter_row(user_id, value, expires_at, now) do + %{ + user_id: user_id, + kind: "keyword", + value: value, + contexts: [], + action: "hide", + whole_word: false, + expires_at: expires_at, + inserted_at: now, + updated_at: now + } + end + + defp filter_values do + %{rows: rows} = + Repo.query!( + """ + select value + from social_filters + where value like '% cleanup token' + order by value + """, + [] + ) + + Enum.map(rows, fn [value] -> value end) + end + + defp insert_preview!(url, status, fetched_at) do + %LinkPreview{} + |> LinkPreview.changeset(%{ + url: url, + status: status, + fetched_at: fetched_at + }) + |> Repo.insert!() + end +end diff --git a/apps/elektrine/test/elektrine/static_sites/github_deploy_worker_security_test.exs b/apps/elektrine/test/elektrine/static_sites/github_deploy_worker_security_test.exs new file mode 100644 index 0000000..ba50064 --- /dev/null +++ b/apps/elektrine/test/elektrine/static_sites/github_deploy_worker_security_test.exs @@ -0,0 +1,58 @@ +defmodule Elektrine.StaticSites.GitHubDeployWorkerSecurityTest do + use ExUnit.Case, async: true + + alias Elektrine.Profiles.StaticSiteDeployment + alias Elektrine.StaticSites.GitHubDeployWorker + + defp deployment do + %StaticSiteDeployment{ + repo_owner: "octo", + repo_name: "site", + branch: "main" + } + end + + test "fetch_archive requests raw bounded zip streaming" do + request_fun = fn url, opts -> + assert url == "https://codeload.github.com/octo/site/zip/refs/heads/main" + assert {"accept", "application/zip"} in opts[:headers] + assert {"accept-encoding", "identity"} in opts[:headers] + assert opts[:compressed] == false + assert opts[:decode_body] == false + assert opts[:raw] == true + assert is_function(opts[:into], 2) + + {:ok, %{status: 200, body: "zip-bytes"}} + end + + assert {:ok, "zip-bytes"} = + GitHubDeployWorker.fetch_archive(deployment(), request_fun: request_fun) + end + + test "fetch_archive collects streamed chunks" do + request_fun = fn _url, opts -> + request = Req.new() + response = Req.Response.new(status: 200) + collector = opts[:into] + + {:cont, {request, response}} = collector.({:data, "zip-"}, {request, response}) + {:cont, {_request, response}} = collector.({:data, "bytes"}, {request, response}) + + {:ok, response} + end + + assert {:ok, "zip-bytes"} = + GitHubDeployWorker.fetch_archive(deployment(), request_fun: request_fun) + end + + test "fetch_archive rejects streamed archives that exceed the cap" do + response = + Req.Response.new(status: 200) + |> Req.Response.put_private(:elektrine_github_archive_too_large, true) + + request_fun = fn _url, _opts -> {:ok, response} end + + assert {:error, :archive_too_large} = + GitHubDeployWorker.fetch_archive(deployment(), request_fun: request_fun) + end +end diff --git a/apps/elektrine/test/elektrine/static_sites_test.exs b/apps/elektrine/test/elektrine/static_sites_test.exs new file mode 100644 index 0000000..08e209d --- /dev/null +++ b/apps/elektrine/test/elektrine/static_sites_test.exs @@ -0,0 +1,606 @@ +defmodule Elektrine.StaticSitesTest do + use Elektrine.DataCase, async: false + + alias Elektrine.Accounts.User + alias Elektrine.AccountsFixtures + alias Elektrine.Repo + alias Elektrine.StaticSites + alias Elektrine.StaticSites.RequestResolver + + setup do + user = AccountsFixtures.user_fixture() + previous_uploads = Application.get_env(:elektrine, :uploads, []) + previous_static_sites = Application.get_env(:elektrine, :static_sites, []) + + Application.put_env(:elektrine, :uploads, + adapter: :local, + uploads_dir: "tmp/test_uploads" + ) + + on_exit(fn -> + Application.put_env(:elektrine, :uploads, previous_uploads) + Application.put_env(:elektrine, :static_sites, previous_static_sites) + end) + + # Clean up test uploads directory + uploads_dir = + Application.get_env(:elektrine, :uploads, []) + |> Keyword.get(:uploads_dir, "tmp/test_uploads") + + File.rm_rf!(uploads_dir) + File.mkdir_p!(uploads_dir) + {:ok, user: user, uploads_dir: uploads_dir} + end + + describe "upload_file/4" do + test "uploads a valid HTML file", %{user: user} do + content = "Hello" + assert {:ok, file} = StaticSites.upload_file(user, "index.html", content, "text/html") + assert file.path == "index.html" + assert file.content_type == "text/html" + assert file.size == byte_size(content) + end + + test "uploads a valid CSS file", %{user: user} do + content = "body { color: red; }" + assert {:ok, file} = StaticSites.upload_file(user, "style.css", content, "text/css") + assert file.path == "style.css" + assert file.content_type == "text/css" + end + + test "uploads a valid JavaScript file", %{user: user} do + content = "console.log('hello');" + + assert {:ok, file} = + StaticSites.upload_file(user, "app.js", content, "application/javascript") + + assert file.path == "app.js" + end + + test "rejects files with invalid extensions", %{user: user} do + content = "some content" + + assert {:error, :invalid_file_type} = + StaticSites.upload_file(user, "file.exe", content, "application/octet-stream") + + assert {:error, :invalid_file_type} = + StaticSites.upload_file(user, "file.php", content, "text/x-php") + end + + test "rejects unsafe paths", %{user: user} do + content = "" + + for path <- [ + "../index.html", + "pages/../index.html", + "/index.html", + "\\index.html", + "pages\\index.html", + "pages//index.html", + "pages/%2e%2e/index.html", + "bad\0name.html", + "bad name.html" + ] do + assert {:error, :invalid_path} = StaticSites.upload_file(user, path, content, "text/html") + end + end + + test "rejects binary content declared as text", %{user: user} do + # Binary content that's not valid UTF-8 + content = <<0xFF, 0xFE, 0x00, 0x01>> + + assert {:error, :invalid_content} = + StaticSites.upload_file(user, "index.html", content, "text/html") + end + + test "validates PNG magic bytes", %{user: user} do + # Valid PNG header + valid_png = <<0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A>> <> "fake png data" + assert {:ok, _file} = StaticSites.upload_file(user, "image.png", valid_png, "image/png") + + # Invalid PNG (wrong magic bytes) + invalid_png = "not a png file" + + assert {:error, :invalid_content} = + StaticSites.upload_file(user, "bad.png", invalid_png, "image/png") + end + + test "validates JPEG magic bytes", %{user: user} do + # Valid JPEG header + valid_jpeg = <<0xFF, 0xD8, 0xFF>> <> "fake jpeg data" + assert {:ok, _file} = StaticSites.upload_file(user, "image.jpg", valid_jpeg, "image/jpeg") + + # Invalid JPEG + invalid_jpeg = "not a jpeg" + + assert {:error, :invalid_content} = + StaticSites.upload_file(user, "bad.jpg", invalid_jpeg, "image/jpeg") + end + + @tag :slow + test "enforces file count limit", %{user: user} do + # Upload 1000 files (the new limit) + for i <- 1..1000 do + content = "#{i}" + assert {:ok, _} = StaticSites.upload_file(user, "page#{i}.html", content, "text/html") + end + + # 1001st file should fail + assert {:error, :file_limit_exceeded} = + StaticSites.upload_file( + user, + "overflow.html", + "overflow", + "text/html" + ) + end + + test "enforces single file size limit", %{user: user} do + # Single file limit is 50MB + large_content = String.duplicate("x", 51_000_000) + # This should fail at validation, not storage limit + # Note: The validation happens at content level, large text files are allowed + # but storage limit check will kick in if cumulative + {:error, _reason} = StaticSites.upload_file(user, "huge.html", large_content, "text/html") + end + end + + describe "get_file/2" do + test "returns uploaded file", %{user: user} do + content = "test" + {:ok, _} = StaticSites.upload_file(user, "index.html", content, "text/html") + + file = StaticSites.get_file(user.id, "index.html") + assert file != nil + assert file.path == "index.html" + end + + test "returns nil for non-existent file", %{user: user} do + assert StaticSites.get_file(user.id, "nonexistent.html") == nil + end + end + + describe "get_file_content/1" do + test "returns file content", %{user: user} do + content = "my content" + {:ok, _} = StaticSites.upload_file(user, "index.html", content, "text/html") + + file = StaticSites.get_file(user.id, "index.html") + assert {:ok, ^content} = StaticSites.get_file_content(file) + end + end + + describe "request resolver" do + test "resolves root and directory indexes", %{user: user} do + index = "home" + page = "tag" + + {:ok, _} = StaticSites.upload_file(user, "index.html", index, "text/html") + {:ok, tag_file} = StaticSites.upload_file(user, "tags/elixir/index.html", page, "text/html") + + assert {:ok, %{path: "index.html"}} = RequestResolver.resolve(user.id, "/") + assert {:ok, ^tag_file} = RequestResolver.resolve(user.id, "/tags/elixir/") + end + + test "resolves extensionless html fallbacks", %{user: user} do + {:ok, page_file} = + StaticSites.upload_file(user, "about.html", "about", "text/html") + + assert {:ok, ^page_file} = RequestResolver.resolve(user.id, "/about") + end + + test "reserves only platform runtime paths", %{user: user} do + {:ok, _} = + StaticSites.upload_file(user, "tags/elixir/index.html", "tag", "text/html") + + assert {:ok, %{path: "tags/elixir/index.html"}} = + RequestResolver.resolve(user.id, "/tags/elixir/") + + assert :reserved = RequestResolver.resolve(user.id, "/socket/websocket") + assert :reserved = RequestResolver.resolve(user.id, "/.well-known/webfinger") + + assert :not_found = + RequestResolver.resolve(user.id, "/.well-known/webfinger", mode: :custom_domain) + end + end + + describe "list_files/1" do + test "lists all files for a user", %{user: user} do + {:ok, _} = StaticSites.upload_file(user, "index.html", "", "text/html") + {:ok, _} = StaticSites.upload_file(user, "style.css", "body{}", "text/css") + + files = StaticSites.list_files(user.id) + assert length(files) == 2 + paths = Enum.map(files, & &1.path) + assert "index.html" in paths + assert "style.css" in paths + end + end + + describe "delete_file/2" do + test "deletes a file", %{user: user} do + {:ok, _} = StaticSites.upload_file(user, "index.html", "", "text/html") + assert StaticSites.get_file(user.id, "index.html") != nil + + assert {:ok, _} = StaticSites.delete_file(user.id, "index.html") + assert StaticSites.get_file(user.id, "index.html") == nil + end + + test "returns error for non-existent file", %{user: user} do + assert {:error, :not_found} = StaticSites.delete_file(user.id, "nonexistent.html") + end + end + + describe "delete_all_files/1" do + test "deletes all files for a user", %{user: user} do + {:ok, _} = StaticSites.upload_file(user, "index.html", "", "text/html") + {:ok, _} = StaticSites.upload_file(user, "style.css", "body{}", "text/css") + assert length(StaticSites.list_files(user.id)) == 2 + + StaticSites.delete_all_files(user.id) + assert StaticSites.list_files(user.id) == [] + end + end + + describe "upload_zip/2" do + test "extracts and uploads files from valid zip", %{user: user} do + # Create a simple zip file in memory + zip_files = [ + {~c"index.html", "Hello"}, + {~c"style.css", "body { color: blue; }"} + ] + + {:ok, {_name, zip_binary}} = :zip.create(~c"test.zip", zip_files, [:memory]) + + assert {:ok, 2} = StaticSites.upload_zip(user, zip_binary) + + files = StaticSites.list_files(user.id) + paths = Enum.map(files, & &1.path) + assert "index.html" in paths + assert "style.css" in paths + end + + @tag :slow + test "rejects zip with too many files", %{user: user} do + # Create zip with 1001 files (new limit is 1000) + zip_files = + for i <- 1..1001 do + {~c"file#{i}.html", "#{i}"} + end + + {:ok, {_name, zip_binary}} = :zip.create(~c"test.zip", zip_files, [:memory]) + + assert {:error, :file_limit_exceeded} = StaticSites.upload_zip(user, zip_binary) + end + + test "rejects invalid zip data", %{user: user} do + invalid_zip = "this is not a zip file" + assert {:error, {:invalid_zip, _}} = StaticSites.upload_zip(user, invalid_zip) + end + + test "strips common root directory from zip", %{user: user} do + # Create zip with files in a subdirectory (common when zipping a folder) + zip_files = [ + {~c"mysite/index.html", "Hello"}, + {~c"mysite/style.css", "body {}"} + ] + + {:ok, {_name, zip_binary}} = :zip.create(~c"test.zip", zip_files, [:memory]) + + assert {:ok, 2} = StaticSites.upload_zip(user, zip_binary) + + files = StaticSites.list_files(user.id) + paths = Enum.map(files, & &1.path) + # Should strip the "mysite/" prefix + assert "index.html" in paths + assert "style.css" in paths + end + + test "rejects zip entries larger than the single file limit", %{user: user} do + zip_files = [{~c"huge.html", String.duplicate("x", 10_000_001)}] + {:ok, {_name, zip_binary}} = :zip.create(~c"huge.zip", zip_files, [:memory]) + + assert {:error, :file_too_large} = StaticSites.upload_zip(user, zip_binary) + assert StaticSites.list_files(user.id) == [] + end + + test "rejects zip paths that normalize outside the site root", %{user: user} do + zip_files = [{~c"pages/../evil.html", "evil"}] + {:ok, {_name, zip_binary}} = :zip.create(~c"evil.zip", zip_files, [:memory]) + + assert {:error, :invalid_path} = StaticSites.upload_zip(user, zip_binary) + assert StaticSites.list_files(user.id) == [] + end + end + + describe "replace_with_zip/2" do + test "replaces existing files after validating archive", %{user: user} do + assert {:ok, _file} = + StaticSites.upload_file(user, "old.html", "old", "text/html") + + zip_files = [ + {~c"index.html", "New"}, + {~c"assets/app.css", "body { color: blue; }"} + ] + + {:ok, {_name, zip_binary}} = :zip.create(~c"replace.zip", zip_files, [:memory]) + + assert {:ok, 2} = StaticSites.replace_with_zip(user, zip_binary) + + paths = user.id |> StaticSites.list_files() |> Enum.map(& &1.path) + assert "index.html" in paths + assert "assets/app.css" in paths + refute "old.html" in paths + end + + test "keeps existing files when archive is invalid", %{user: user} do + assert {:ok, _file} = + StaticSites.upload_file(user, "index.html", "old", "text/html") + + assert {:error, {:invalid_zip, _reason}} = StaticSites.replace_with_zip(user, "not a zip") + + assert %{} = StaticSites.get_file(user.id, "index.html") + end + + test "keeps existing files when archive content is invalid", %{user: user} do + assert {:ok, _file} = + StaticSites.upload_file(user, "index.html", "old", "text/html") + + zip_files = [{~c"index.html", <<0xFF, 0xFE, 0xFD>>}] + {:ok, {_name, zip_binary}} = :zip.create(~c"invalid-content.zip", zip_files, [:memory]) + + assert {:error, :invalid_content} = StaticSites.replace_with_zip(user, zip_binary) + + assert file = StaticSites.get_file(user.id, "index.html") + assert {:ok, "old"} = StaticSites.get_file_content(file) + end + end + + describe "replace_with_repo_archive/3" do + test "auto-detects a committed static output directory", %{user: user} do + zip_files = [ + {~c"site-main/src/app.js", "console.log('source')"}, + {~c"site-main/zig-out/index.html", "Built"}, + {~c"site-main/zig-out/assets/app.css", "body { color: blue; }"} + ] + + {:ok, {_name, zip_binary}} = :zip.create(~c"repo.zip", zip_files, [:memory]) + + assert {:ok, 2} = StaticSites.replace_with_repo_archive(user, zip_binary, "auto") + + paths = user.id |> StaticSites.list_files() |> Enum.map(& &1.path) + assert "index.html" in paths + assert "assets/app.css" in paths + refute "src/app.js" in paths + end + + test "returns an error when the selected output directory has no index", %{user: user} do + zip_files = [{~c"site-main/src/app.js", "console.log('source')"}] + {:ok, {_name, zip_binary}} = :zip.create(~c"repo.zip", zip_files, [:memory]) + + assert {:error, :site_dir_not_found} = + StaticSites.replace_with_repo_archive(user, zip_binary, "dist") + + assert StaticSites.list_files(user.id) == [] + end + end + + describe "zip bomb protection" do + test "rejects zip whose total extracted size exceeds the hard zip cap", %{user: user} do + Application.put_env(:elektrine, :static_sites, max_zip_uncompressed_size: 1_000) + + content = String.duplicate("a", 600) + zip_files = [{~c"a.txt", content}, {~c"b.txt", content}] + {:ok, {_name, zip_binary}} = :zip.create(~c"oversized.zip", zip_files, [:memory]) + + assert {:error, :storage_limit_exceeded} = StaticSites.upload_zip(user, zip_binary) + assert StaticSites.list_files(user.id) == [] + end + + test "rejects zip with excessive decompression ratio", %{user: user} do + # Create a highly compressible file (lots of repeated data) + # This simulates a zip bomb where a small zip decompresses to huge size + # We create content that compresses very well + repetitive_content = String.duplicate("AAAAAAAAAA", 1_000_000) + + zip_files = [{~c"bomb.html", repetitive_content}] + {:ok, {_name, zip_binary}} = :zip.create(~c"bomb.zip", zip_files, [:memory]) + + compressed_size = byte_size(zip_binary) + uncompressed_size = byte_size(repetitive_content) + ratio = uncompressed_size / compressed_size + + # Only test if we actually achieved high compression + if ratio > 100 do + assert {:error, :zip_bomb_detected} = StaticSites.upload_zip(user, zip_binary) + else + # If compression ratio is acceptable, it should work + assert {:ok, _} = StaticSites.upload_zip(user, zip_binary) + end + end + + test "accepts zip within storage limit", %{user: user} do + # Create a zip with content that doesn't compress much (random-like data) + # Using base64 encoding of random bytes to get text that won't trigger zip bomb detection + random_content = :crypto.strong_rand_bytes(100_000) |> Base.encode64() + + zip_files = [{~c"data.txt", random_content}] + {:ok, {_name, zip_binary}} = :zip.create(~c"test.zip", zip_files, [:memory]) + + assert {:ok, 1} = StaticSites.upload_zip(user, zip_binary) + end + end + + describe "total_storage_used/1" do + test "calculates total storage used", %{user: user} do + content1 = "hello" + content2 = "body { color: red; }" + + {:ok, _} = StaticSites.upload_file(user, "index.html", content1, "text/html") + {:ok, _} = StaticSites.upload_file(user, "style.css", content2, "text/css") + + expected_size = byte_size(content1) + byte_size(content2) + assert StaticSites.total_storage_used(user.id) == expected_size + end + end + + describe "file_count/1" do + test "counts files for a user", %{user: user} do + assert StaticSites.file_count(user.id) == 0 + + {:ok, _} = StaticSites.upload_file(user, "index.html", "", "text/html") + assert StaticSites.file_count(user.id) == 1 + + {:ok, _} = StaticSites.upload_file(user, "style.css", "body{}", "text/css") + assert StaticSites.file_count(user.id) == 2 + end + end + + describe "file editing (update in place)" do + test "can update existing file content", %{user: user} do + # Create initial file + original_content = "Original" + {:ok, _file} = StaticSites.upload_file(user, "index.html", original_content, "text/html") + + # Verify original content + file = StaticSites.get_file(user.id, "index.html") + {:ok, content} = StaticSites.get_file_content(file) + assert content == original_content + + # Update the file + new_content = "Updated!" + {:ok, _updated_file} = StaticSites.upload_file(user, "index.html", new_content, "text/html") + + # Verify updated content + file = StaticSites.get_file(user.id, "index.html") + {:ok, content} = StaticSites.get_file_content(file) + assert content == new_content + + # File count should still be 1 (replaced, not added) + assert StaticSites.file_count(user.id) == 1 + end + + test "updates storage used when file size changes", %{user: user} do + small_content = "small" + {:ok, _} = StaticSites.upload_file(user, "index.html", small_content, "text/html") + small_size = StaticSites.total_storage_used(user.id) + + large_content = "" <> String.duplicate("x", 10_000) <> "" + {:ok, _} = StaticSites.upload_file(user, "index.html", large_content, "text/html") + large_size = StaticSites.total_storage_used(user.id) + + assert large_size > small_size + end + end + + describe "storage limits" do + test "uses the user's storage quota", %{user: user} do + # Create a 5MB file + large_content = String.duplicate("x", 5_000_000) + assert {:ok, _} = StaticSites.upload_file(user, "large.txt", large_content, "text/plain") + + assert StaticSites.total_storage_used(user.id) == 5_000_000 + end + + test "rejects uploads that exceed the user's storage quota", %{user: user} do + {1, _} = + Repo.update_all( + from(u in User, where: u.id == ^user.id), + set: [storage_limit_bytes: 100] + ) + + assert {:ok, _} = + StaticSites.upload_file(user, "small.txt", String.duplicate("a", 60), "text/plain") + + assert {:error, :storage_limit_exceeded} = + StaticSites.upload_file( + user, + "overflow.txt", + String.duplicate("b", 50), + "text/plain" + ) + end + + test "1000 file limit allows many files", %{user: user} do + # Upload 500 files to verify higher limit works + for i <- 1..500 do + content = "#{i}" + assert {:ok, _} = StaticSites.upload_file(user, "page#{i}.html", content, "text/html") + end + + assert StaticSites.file_count(user.id) == 500 + end + end + + describe "content types" do + test "accepts all valid file types", %{user: user} do + # HTML + assert {:ok, _} = StaticSites.upload_file(user, "index.html", "", "text/html") + assert {:ok, _} = StaticSites.upload_file(user, "page.htm", "", "text/html") + + # CSS + assert {:ok, _} = StaticSites.upload_file(user, "style.css", "body{}", "text/css") + + # JavaScript + assert {:ok, _} = + StaticSites.upload_file( + user, + "app.js", + "console.log('hi')", + "application/javascript" + ) + + # JSON + assert {:ok, _} = + StaticSites.upload_file( + user, + "data.json", + "{\"key\":\"value\"}", + "application/json" + ) + + # Plain text + assert {:ok, _} = StaticSites.upload_file(user, "readme.txt", "Hello", "text/plain") + + assert {:ok, _} = + StaticSites.upload_file(user, "sitemap.xml", "", "text/xml") + + assert {:ok, manifest} = + StaticSites.upload_file( + user, + "site.webmanifest", + ~s({"name":"Test"}), + "application/json" + ) + + assert manifest.content_type == "application/manifest+json" + + assert {:ok, _} = + StaticSites.upload_file(user, "app.mjs", "console.log('hi')", "text/javascript") + + assert {:ok, _} = + StaticSites.upload_file(user, "app.wasm", <<0, 97, 115, 109>>, "application/wasm") + + # Images with valid headers + valid_png = <<0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A>> <> "fake png" + assert {:ok, _} = StaticSites.upload_file(user, "image.png", valid_png, "image/png") + + valid_jpeg = <<0xFF, 0xD8, 0xFF>> <> "fake jpeg" + assert {:ok, _} = StaticSites.upload_file(user, "photo.jpg", valid_jpeg, "image/jpeg") + + valid_gif = "GIF" <> "fake gif data" + assert {:ok, _} = StaticSites.upload_file(user, "anim.gif", valid_gif, "image/gif") + end + + test "rejects invalid file types", %{user: user} do + assert {:error, :invalid_file_type} = + StaticSites.upload_file(user, "script.php", " fun.(payload) + nil -> raise "missing fake Stripe expectation for #{inspect(name)}" + end + end + end + + setup do + previous_client = Application.get_env(:elektrine, :stripe_client) + Application.put_env(:elektrine, :stripe_client, FakeStripeClient) + + for key <- [ + :create_customer, + :create_checkout_session, + :create_billing_portal_session, + :update_subscription, + :retrieve_price + ] do + Process.put( + {FakeStripeClient, key}, + fn _payload -> flunk("unexpected Stripe client call for #{inspect(key)}") end + ) + end + + on_exit(fn -> + if previous_client do + Application.put_env(:elektrine, :stripe_client, previous_client) + else + Application.delete_env(:elektrine, :stripe_client) + end + end) + + :ok + end + + test "create_product syncs pricing and currency from Stripe price IDs" do + expect_stripe(:retrieve_price, fn + "price_month" -> + {:ok, %{unit_amount: 1900, currency: "usd", recurring: %{interval: "month"}}} + + "price_year" -> + {:ok, %{unit_amount: 19_000, currency: "usd", recurring: %{interval: "year"}}} + end) + + attrs = %{ + "name" => "VPN", + "slug" => "vpn", + "stripe_monthly_price_id" => "price_month", + "stripe_yearly_price_id" => "price_year", + "monthly_price_cents" => "1", + "yearly_price_cents" => "2", + "currency" => "eur" + } + + assert {:ok, product} = Subscriptions.create_product(attrs) + assert product.monthly_price_cents == 1900 + assert product.yearly_price_cents == 19_000 + assert product.currency == "usd" + end + + test "create_product syncs one-time pricing from Stripe" do + expect_stripe(:retrieve_price, fn + "price_once" -> + {:ok, %{unit_amount: 500, currency: "usd", recurring: nil}} + end) + + attrs = %{ + "name" => "Registration", + "slug" => "registration", + "billing_type" => "one_time", + "stripe_one_time_price_id" => "price_once", + "one_time_price_cents" => "1", + "currency" => "eur" + } + + assert {:ok, product} = Subscriptions.create_product(attrs) + assert product.billing_type == "one_time" + assert product.one_time_price_cents == 500 + assert product.currency == "usd" + end + + test "create_product rejects mixed recurring and one-time pricing" do + assert {:error, changeset} = + Subscriptions.create_product(%{ + "name" => "Registration", + "slug" => "registration", + "billing_type" => "one_time", + "monthly_price_cents" => "100" + }) + + assert "one-time products cannot include monthly or yearly pricing" in errors_on(changeset).billing_type + end + + test "update_product rejects slug changes" do + product = + Repo.insert!(%Product{ + name: "VPN", + slug: "vpn", + currency: "usd", + active: true + }) + + assert {:error, changeset} = + Subscriptions.update_product(product, %{ + "name" => "VPN Plus", + "slug" => "vpn-plus" + }) + + assert "cannot be changed after creation" in errors_on(changeset).slug + end + + test "delete_product blocks products with subscription history" do + user = AccountsFixtures.user_fixture() + + product = + Repo.insert!(%Product{ + name: "VPN", + slug: "vpn", + currency: "usd", + active: true + }) + + Repo.insert!(%Subscription{ + user_id: user.id, + product: product.slug, + stripe_customer_id: "cus_existing", + status: "active" + }) + + assert {:error, :has_subscriptions} = Subscriptions.delete_product(product) + end + + test "get_or_create_stripe_customer stores the Stripe customer on the user" do + user = + AccountsFixtures.user_fixture(%{username: "billinguser"}) + |> then(fn user -> + user + |> Changeset.change( + recovery_email: "billing@example.com", + recovery_email_verified: true + ) + |> Repo.update!() + end) + + expect_stripe(:create_customer, fn params -> + assert params.email == "billing@example.com" + assert params.metadata.user_id == Integer.to_string(user.id) + {:ok, %{id: "cus_new"}} + end) + + assert {:ok, "cus_new"} = Subscriptions.get_or_create_stripe_customer(user, "vpn") + assert Repo.get!(User, user.id).stripe_customer_id == "cus_new" + end + + test "get_or_create_stripe_customer reuses a single existing customer ID" do + user = AccountsFixtures.user_fixture() + + Repo.insert!(%Subscription{ + user_id: user.id, + product: "mail", + stripe_customer_id: "cus_existing", + status: "active" + }) + + assert {:ok, "cus_existing"} = Subscriptions.get_or_create_stripe_customer(user, "vpn") + assert Repo.get!(User, user.id).stripe_customer_id == "cus_existing" + end + + test "create_checkout_session uses the subscribe success query param by default" do + user = + AccountsFixtures.user_fixture() + |> then(fn user -> + user + |> Changeset.change(stripe_customer_id: "cus_checkout") + |> Repo.update!() + end) + + expect_stripe(:create_checkout_session, fn params -> + assert params.customer == "cus_checkout" + assert params.success_url == "#{expected_base_url()}/subscribe/vpn?success=true" + assert params.cancel_url == "#{expected_base_url()}/subscribe/vpn" + {:ok, %{url: "https://checkout.test/session"}} + end) + + assert {:ok, %{url: "https://checkout.test/session"}} = + Subscriptions.create_checkout_session(user, "vpn", "price_month") + end + + test "create_checkout_session supports one-time payment mode" do + user = + AccountsFixtures.user_fixture() + |> then(fn user -> + user + |> Changeset.change(stripe_customer_id: "cus_checkout") + |> Repo.update!() + end) + + expect_stripe(:create_checkout_session, fn params -> + assert params.customer == "cus_checkout" + assert params.mode == "payment" + assert params.metadata.checkout_mode == "payment" + assert params.metadata.price_id == "price_once" + assert params.payment_intent_data.metadata.product == "registration" + {:ok, %{url: "https://checkout.test/session"}} + end) + + assert {:ok, %{url: "https://checkout.test/session"}} = + Subscriptions.create_checkout_session(user, "registration", "price_once", + checkout_mode: :payment + ) + end + + test "create_registration_checkout_session creates a guest payment checkout" do + product = + Repo.insert!(%Product{ + name: "Registration", + slug: "registration", + billing_type: "one_time", + currency: "usd", + active: true, + one_time_price_cents: 500, + stripe_one_time_price_id: "price_once" + }) + + expect_stripe(:create_checkout_session, fn params -> + assert params.mode == "payment" + assert params.customer_creation == "always" + assert params.metadata.purpose == "registration_invite" + assert params.metadata.product == product.slug + assert params.metadata.price_id == product.stripe_one_time_price_id + + assert params.success_url =~ + "/register/purchase/success?checkout_session_id={CHECKOUT_SESSION_ID}&access=" + + {:ok, %{id: "cs_reg_123", url: "https://checkout.test/registration"}} + end) + + assert {:ok, %{url: "https://checkout.test/registration"}} = + Subscriptions.create_registration_checkout_session(product) + + assert %RegistrationCheckout{ + stripe_checkout_session_id: "cs_reg_123", + product_slug: "registration", + status: "pending" + } = + Repo.get_by!(RegistrationCheckout, stripe_checkout_session_id: "cs_reg_123") + end + + test "subscription created webhook falls back to customer lookup when metadata user_id is invalid" do + user = + AccountsFixtures.user_fixture() + |> then(fn user -> + user + |> Changeset.change(stripe_customer_id: "cus_webhook") + |> Repo.update!() + end) + + Repo.insert!(%Product{ + name: "VPN", + slug: "vpn", + currency: "usd", + active: true, + stripe_monthly_price_id: "price_month" + }) + + event = %{ + type: "customer.subscription.created", + data: %{ + object: %{ + "id" => "sub_123", + "customer" => "cus_webhook", + "status" => "active", + "metadata" => %{"user_id" => "not-an-int", "product" => "vpn"}, + "items" => %{"data" => [%{"price" => %{"id" => "price_month"}}]}, + "current_period_start" => 1_700_000_000, + "current_period_end" => 1_700_086_400, + "cancel_at_period_end" => false + } + } + } + + assert {:ok, _subscription} = Subscriptions.process_webhook_event(event) + + assert %Subscription{} = subscription = Subscriptions.get_subscription(user.id, "vpn") + assert subscription.status == "active" + assert subscription.stripe_customer_id == "cus_webhook" + assert subscription.stripe_subscription_id == "sub_123" + end + + test "checkout session completed creates one-time access" do + user = + AccountsFixtures.user_fixture() + |> then(fn user -> + user + |> Changeset.change(stripe_customer_id: "cus_once") + |> Repo.update!() + end) + + Repo.insert!(%Product{ + name: "Registration", + slug: "registration", + billing_type: "one_time", + currency: "usd", + active: true, + one_time_price_cents: 500, + stripe_one_time_price_id: "price_once" + }) + + event = %{ + type: "checkout.session.completed", + data: %{ + object: %{ + "id" => "cs_123", + "mode" => "payment", + "payment_status" => "paid", + "customer" => "cus_once", + "payment_intent" => "pi_123", + "created" => 1_700_000_000, + "metadata" => %{ + "user_id" => Integer.to_string(user.id), + "product" => "registration", + "price_id" => "price_once", + "checkout_mode" => "payment" + } + } + } + } + + assert {:ok, %Subscription{} = subscription} = Subscriptions.process_webhook_event(event) + assert subscription.user_id == user.id + assert subscription.product == "registration" + assert subscription.status == "active" + assert subscription.stripe_subscription_id == nil + assert subscription.stripe_price_id == "price_once" + assert subscription.metadata["billing_type"] == "one_time" + end + + test "checkout session completed for registration invite creates a single-use invite" do + Repo.insert!(%Product{ + name: "Registration", + slug: "registration", + billing_type: "one_time", + currency: "usd", + active: true, + one_time_price_cents: 500, + stripe_one_time_price_id: "price_once" + }) + + event = %{ + type: "checkout.session.completed", + data: %{ + object: %{ + "id" => "cs_reg_456", + "mode" => "payment", + "payment_status" => "paid", + "customer" => "cus_guest", + "payment_intent" => "pi_reg_456", + "customer_email" => "payer@example.com", + "created" => 1_700_000_000, + "metadata" => %{ + "purpose" => "registration_invite", + "product" => "registration", + "price_id" => "price_once", + "checkout_mode" => "payment", + "registration_lookup_token" => "lookup-token" + } + } + } + } + + assert {:ok, %RegistrationCheckout{} = checkout} = Subscriptions.process_webhook_event(event) + assert checkout.status == "fulfilled" + assert checkout.lookup_token == RegistrationCheckout.hash_lookup_token("lookup-token") + assert checkout.product_slug == "registration" + assert checkout.customer_email == "payer@example.com" + assert checkout.invite_code_id + + checkout = Repo.preload(checkout, :invite_code, force: true) + assert checkout.invite_code.max_uses == 1 + assert checkout.invite_code.is_active + end + + defp expect_stripe(name, fun) do + Process.put({FakeStripeClient, name}, fun) + end + + defp expected_base_url do + endpoint = Module.concat([ElektrineWeb, Endpoint]) + + if Code.ensure_loaded?(endpoint) do + endpoint.url() + else + "https://#{Elektrine.Domains.instance_domain()}" + end + end +end diff --git a/apps/elektrine/test/elektrine/theme_test.exs b/apps/elektrine/test/elektrine/theme_test.exs new file mode 100644 index 0000000..5628fd9 --- /dev/null +++ b/apps/elektrine/test/elektrine/theme_test.exs @@ -0,0 +1,42 @@ +defmodule Elektrine.ThemeTest do + use ExUnit.Case, async: true + + alias Elektrine.Theme + + describe "custom_scheme/1" do + test "derives the light structural theme from a bright background" do + assert Theme.custom_scheme(%{"color_base_100" => "#f4f1ea"}) == :light + end + + test "derives the dark structural theme from a dim background" do + assert Theme.custom_scheme(%{"color_base_100" => "#101820"}) == :dark + end + + test "falls back to the default palette when no background is set" do + assert Theme.custom_scheme(%{}) == :light + end + end + + describe "email_palette/2" do + test "applies the custom palette only in custom mode" do + user = %{theme_mode: "custom", theme_overrides: %{"color_primary" => "#123456"}} + assert Theme.email_palette(user).button_bg == "#123456" + + pinned = %{user | theme_mode: "dark"} + assert Theme.email_palette(pinned).button_bg == Theme.default_value("color_primary") + end + + test "treats plain override maps as active palettes" do + assert Theme.email_palette(%{"color_primary" => "#123456"}).button_bg == "#123456" + end + end + + describe "api_payload/1" do + test "includes the theme mode alongside the stored palette" do + payload = Theme.api_payload(%{theme_mode: "dark", theme_overrides: %{}}) + assert payload.mode == "dark" + + assert Theme.api_payload(%{}).mode == "system" + end + end +end diff --git a/apps/elektrine/test/elektrine/uploads_test.exs b/apps/elektrine/test/elektrine/uploads_test.exs new file mode 100644 index 0000000..63c1046 --- /dev/null +++ b/apps/elektrine/test/elektrine/uploads_test.exs @@ -0,0 +1,241 @@ +defmodule Elektrine.UploadsTest do + use Elektrine.DataCase, async: false + + alias Elektrine.AccountsFixtures + alias Elektrine.Repo + alias Elektrine.Uploads + + setup do + previous_uploads = Application.get_env(:elektrine, :uploads) + + tmp_dir = + Path.join( + System.tmp_dir!(), + "elektrine-uploads-test-#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(tmp_dir) + Application.put_env(:elektrine, :uploads, adapter: :local, uploads_dir: tmp_dir) + + on_exit(fn -> + Application.put_env(:elektrine, :uploads, previous_uploads) + File.rm_rf(tmp_dir) + end) + + {:ok, tmp_dir: tmp_dir, user: AccountsFixtures.user_fixture()} + end + + test "rejects video uploads whose bytes do not match the declared type", %{ + tmp_dir: tmp_dir, + user: user + } do + upload = upload_fixture(tmp_dir, "clip.mp4", "video/mp4", "not an mp4") + + assert {:error, {:invalid_file_format, _}} = Uploads.upload_chat_attachment(upload, user.id) + end + + test "rejects text uploads containing NUL bytes", %{tmp_dir: tmp_dir, user: user} do + upload = upload_fixture(tmp_dir, "notes.txt", "text/plain", "safe" <> <<0>> <> "hidden") + + assert {:error, {:invalid_file_format, _}} = Uploads.upload_chat_attachment(upload, user.id) + end + + test "accepts document uploads with matching container bytes", %{tmp_dir: tmp_dir, user: user} do + upload = + upload_fixture( + tmp_dir, + "report.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "PK\x03\x04document" + ) + + assert {:ok, %{key: "/uploads/chat-attachments/" <> _}} = + Uploads.upload_chat_attachment(upload, user.id) + end + + test "stores uploads with non-ASCII filenames under ASCII-safe paths", %{ + tmp_dir: tmp_dir, + user: user + } do + upload = upload_fixture(tmp_dir, "头像.txt", "text/plain", "safe text") + + assert {:ok, %{key: "/uploads/chat-attachments/" <> stored_filename}} = + Uploads.upload_chat_attachment(upload, user.id) + + assert String.ends_with?(stored_filename, ".txt") + refute stored_filename =~ "file" + refute stored_filename =~ "头像" + end + + test "stores duplicate per-user content at the same content-addressed path", %{ + tmp_dir: tmp_dir, + user: user + } do + content = "same file bytes" + expected_hash = :crypto.hash(:sha256, content) |> Base.encode16(case: :lower) + upload_a = upload_fixture(tmp_dir, "first.txt", "text/plain", content) + upload_b = upload_fixture(tmp_dir, "second.txt", "text/plain", content) + + assert {:ok, %{key: key_a, sha256: ^expected_hash}} = + Uploads.upload_chat_attachment(upload_a, user.id) + + assert {:ok, %{key: key_b, sha256: ^expected_hash}} = + Uploads.upload_chat_attachment(upload_b, user.id) + + assert key_a == key_b + assert key_a =~ "/uploads/chat-attachments/#{user.id}/#{String.slice(expected_hash, 0, 2)}/" + assert String.ends_with?(key_a, "#{expected_hash}.txt") + + stored_files = + tmp_dir + |> Path.join("chat-attachments") + |> Path.join("**/*") + |> Path.wildcard() + |> Enum.reject(&File.dir?/1) + + assert length(stored_files) == 1 + end + + test "returns anonymized filenames in upload metadata", %{tmp_dir: tmp_dir, user: user} do + content = "private receipt contents" + expected_hash = :crypto.hash(:sha256, content) |> Base.encode16(case: :lower) + upload = upload_fixture(tmp_dir, "tax-return-2026.txt", "text/plain", content) + + assert {:ok, %{filename: filename, sha256: ^expected_hash}} = + Uploads.upload_chat_attachment(upload, user.id) + + assert filename == "#{expected_hash}.txt" + refute filename =~ "tax-return" + end + + test "rejects Kairo uploads that would exceed the user's storage quota", %{ + tmp_dir: tmp_dir, + user: user + } do + user + |> Ecto.Changeset.change(storage_limit_bytes: 1, storage_used_bytes: 0) + |> Repo.update!() + + upload = upload_fixture(tmp_dir, "notes.txt", "text/plain", "larger than one byte") + + assert {:error, :storage_limit_exceeded} = Uploads.upload_kairo_source(upload, user.id) + end + + test "stores image attachment metadata before cleaning stripped temp file", %{ + tmp_dir: tmp_dir, + user: user + } do + upload = upload_fixture(tmp_dir, "pixel.png", "image/png", tiny_png()) + + assert Code.ensure_loaded?(Image) + assert {:ok, _image} = Image.open(upload.path) + + assert {:ok, %{key: "/uploads/chat-attachments/" <> stored_filename, sha256: sha256}} = + Uploads.upload_chat_attachment(upload, user.id) + + assert String.ends_with?(stored_filename, ".png") + assert is_binary(sha256) + refute File.exists?(upload.path <> "_stripped.png") + assert File.exists?(Path.join([tmp_dir, "chat-attachments", stored_filename])) + end + + test "private S3 attachment URLs use the authenticated app proxy" do + Application.put_env(:elektrine, :uploads, adapter: :s3) + + assert Uploads.attachment_url("chat-attachments/private.jpg", %{type: "dm"}) =~ + "/api/private-attachments/" + end + + test "accepts voice messages with allowed MIME type and matching bytes", %{ + tmp_dir: tmp_dir, + user: user + } do + audio = <<26, 69, 223, 163, "audio">> + expected_hash = :crypto.hash(:sha256, audio) |> Base.encode16(case: :lower) + expected_filename = "#{expected_hash}.webm" + + assert {:ok, + %{ + key: "/uploads/voice-messages/" <> stored_filename, + filename: ^expected_filename + }} = + Uploads.upload_voice_message( + audio, + "private-meeting.webm", + "audio/webm", + user.id + ) + + assert File.exists?(Path.join([tmp_dir, "voice-messages", stored_filename])) + end + + test "returns anonymized filenames for voice metadata", %{user: user} do + audio = <<26, 69, 223, 163, "audio">> + expected_hash = :crypto.hash(:sha256, audio) |> Base.encode16(case: :lower) + + assert {:ok, %{filename: filename, sha256: ^expected_hash}} = + Uploads.upload_voice_message( + audio, + "secret-note.webm", + "audio/webm", + user.id + ) + + assert filename == "#{expected_hash}.webm" + refute filename =~ "secret" + end + + test "rejects voice messages with disallowed MIME types", %{tmp_dir: tmp_dir, user: user} do + assert {:error, {:invalid_file_type, _}} = + Uploads.upload_voice_message( + <<26, 69, 223, 163, "audio">>, + "clip.html", + "text/html", + user.id + ) + + refute File.exists?(Path.join(tmp_dir, "voice-messages")) + end + + test "rejects voice messages whose bytes do not match the declared type", %{ + tmp_dir: tmp_dir, + user: user + } do + assert {:error, {:invalid_file_format, _}} = + Uploads.upload_voice_message("not webm", "clip.webm", "audio/webm", user.id) + + refute File.exists?(Path.join(tmp_dir, "voice-messages")) + end + + test "malformed local delete keys do not normalize into deletable upload paths", %{ + tmp_dir: tmp_dir + } do + avatar_dir = Path.join(tmp_dir, "avatars") + File.mkdir_p!(avatar_dir) + victim_path = Path.join(avatar_dir, "victim.txt") + File.write!(victim_path, "keep") + + assert {:error, :invalid_upload_key} = + Uploads.delete_uploaded_file("uploads/uploads/avatars/victim.txt") + + assert File.read!(victim_path) == "keep" + + assert {:error, :invalid_upload_key} = + Uploads.delete_uploaded_file("/uploads//avatars/victim.txt") + + assert File.read!(victim_path) == "keep" + end + + defp upload_fixture(tmp_dir, filename, content_type, content) do + path = Path.join(tmp_dir, "#{System.unique_integer([:positive])}-#{filename}") + File.write!(path, content) + + %Plug.Upload{path: path, filename: filename, content_type: content_type} + end + + defp tiny_png do + Base.decode64!( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=" + ) + end +end diff --git a/apps/elektrine/test/elektrine/vault_test.exs b/apps/elektrine/test/elektrine/vault_test.exs new file mode 100644 index 0000000..85802ce --- /dev/null +++ b/apps/elektrine/test/elektrine/vault_test.exs @@ -0,0 +1,79 @@ +defmodule Elektrine.VaultTest do + use Elektrine.DataCase, async: true + + import Elektrine.AccountsFixtures + + alias Elektrine.Vault + + defp wrapped(opts \\ []) do + %{ + "version" => 1, + "algorithm" => "AES-GCM", + "kdf" => "PBKDF2-SHA256", + "iterations" => Keyword.get(opts, :iterations, 600_000), + "salt" => Base.encode64(:crypto.strong_rand_bytes(16)), + "iv" => Base.encode64(:crypto.strong_rand_bytes(12)), + "ciphertext" => Base.encode64(:crypto.strong_rand_bytes(48)) + } + end + + defp setup_attrs do + %{"wrapped_dek" => wrapped(), "wrapped_dek_recovery" => wrapped()} + end + + test "setup creates a master key and configured?/get reflect it" do + user = user_fixture() + + refute Vault.configured?(user.id) + assert Vault.get(user.id) == nil + + assert {:ok, master_key} = Vault.setup(user.id, setup_attrs()) + assert master_key.user_id == user.id + assert Vault.configured?(user) + assert %{wrapped_dek: %{}, wrapped_dek_recovery: %{}} = Vault.get(user) + end + + test "setup refuses to replace an existing master key" do + user = user_fixture() + {:ok, _} = Vault.setup(user.id, setup_attrs()) + + assert {:error, :already_configured} = Vault.setup(user.id, setup_attrs()) + end + + test "setup rejects malformed wrapped payloads" do + user = user_fixture() + + bad = %{"wrapped_dek" => %{"algorithm" => "rot13"}, "wrapped_dek_recovery" => wrapped()} + assert {:error, changeset} = Vault.setup(user.id, bad) + assert %{wrapped_dek: _} = errors_on(changeset) + + short_iv = put_in(wrapped(), ["iv"], Base.encode64(:crypto.strong_rand_bytes(8))) + + assert {:error, _} = + Vault.setup(user.id, %{ + "wrapped_dek" => short_iv, + "wrapped_dek_recovery" => wrapped() + }) + end + + test "rotate re-wraps without needing the prior MDK and only updates blobs" do + user = user_fixture() + {:ok, original} = Vault.setup(user.id, setup_attrs()) + + new_attrs = setup_attrs() + assert {:ok, rotated} = Vault.rotate(user, new_attrs) + assert rotated.id == original.id + assert rotated.wrapped_dek["salt"] == new_attrs["wrapped_dek"]["salt"] + + assert {:error, :not_configured} = Vault.rotate(user_fixture().id, setup_attrs()) + end + + test "reset removes the master key" do + user = user_fixture() + {:ok, _} = Vault.setup(user.id, setup_attrs()) + + assert {:ok, true} = Vault.reset(user) + refute Vault.configured?(user.id) + assert {:ok, false} = Vault.reset(user.id) + end +end diff --git a/apps/elektrine/test/elektrine/web_index/extractor_test.exs b/apps/elektrine/test/elektrine/web_index/extractor_test.exs new file mode 100644 index 0000000..8077dc0 --- /dev/null +++ b/apps/elektrine/test/elektrine/web_index/extractor_test.exs @@ -0,0 +1,43 @@ +defmodule Elektrine.WebIndex.ExtractorTest do + use ExUnit.Case, async: true + + alias Elektrine.WebIndex.Extractor + + test "extracts metadata and keeps only crawlable same-host links" do + html = """ + + + Independent Search + + + + + +

    Paige crawler

    Useful searchable content for everyone.

    + Next + External + PDF + + + """ + + assert {:ok, extracted} = Extractor.extract(html, "https://example.com/start") + assert extracted.title == "Independent Search" + assert extracted.description == "A focused search index" + assert extracted.canonical_url == "https://example.com/guide" + assert extracted.language == "en" + assert extracted.content =~ "Useful searchable content" + refute extracted.content =~ "Navigation noise" + assert extracted.links == ["https://example.com/next"] + end + + test "honors noindex metadata" do + assert {:ok, extracted} = + Extractor.extract( + "Hidden page content", + "https://example.com/hidden" + ) + + assert extracted.noindex? + end +end diff --git a/apps/elektrine/test/elektrine/web_index/fetcher_test.exs b/apps/elektrine/test/elektrine/web_index/fetcher_test.exs new file mode 100644 index 0000000..93933c4 --- /dev/null +++ b/apps/elektrine/test/elektrine/web_index/fetcher_test.exs @@ -0,0 +1,27 @@ +defmodule Elektrine.WebIndex.FetcherTest do + use ExUnit.Case, async: true + + alias Elektrine.WebIndex.Fetcher + + test "follows relative redirects on the same host" do + request_fun = fn + "https://example.com/start", _headers, _opts -> + {:ok, %{status: 302, body: "", headers: [{"location", "/final"}]}} + + "https://example.com/final", _headers, _opts -> + {:ok, %{status: 200, body: "done", headers: []}} + end + + assert {:ok, %{status: 200, body: "done", url: "https://example.com/final"}} = + Fetcher.get("https://example.com/start", request_fun: request_fun) + end + + test "rejects cross-host redirects" do + request_fun = fn _url, _headers, _opts -> + {:ok, %{status: 302, body: "", headers: [{"location", "https://other.example/"}]}} + end + + assert {:error, :invalid_redirect} = + Fetcher.get("https://example.com/start", request_fun: request_fun) + end +end diff --git a/apps/elektrine/test/elektrine/web_index/robots_test.exs b/apps/elektrine/test/elektrine/web_index/robots_test.exs new file mode 100644 index 0000000..8eba227 --- /dev/null +++ b/apps/elektrine/test/elektrine/web_index/robots_test.exs @@ -0,0 +1,33 @@ +defmodule Elektrine.WebIndex.RobotsTest do + use ExUnit.Case, async: true + + alias Elektrine.WebIndex.Robots + + test "prefers PaigeBot rules and applies the longest matching directive" do + policy = + Robots.parse(""" + User-agent: * + Disallow: / + + User-agent: PaigeBot + Disallow: /private/ + Allow: /private/public/ + Crawl-delay: 1.5 + Sitemap: https://example.com/sitemap.xml + """) + + refute Robots.allowed?(policy, "https://example.com/private/report") + assert Robots.allowed?(policy, "https://example.com/private/public/index.html") + assert Robots.allowed?(policy, "https://example.com/about") + assert Robots.crawl_delay_ms(policy) == 1_500 + assert Robots.sitemaps(policy) == ["https://example.com/sitemap.xml"] + end + + test "supports wildcards and end anchors" do + policy = Robots.parse("User-agent: *\nDisallow: /*?secret=*\nAllow: /public$") + + refute Robots.allowed?(policy, "https://example.com/page?secret=yes") + assert Robots.allowed?(policy, "https://example.com/public") + assert Robots.allowed?(policy, "https://example.com/public/more") + end +end diff --git a/apps/elektrine/test/elektrine/web_index_test.exs b/apps/elektrine/test/elektrine/web_index_test.exs new file mode 100644 index 0000000..ad71b40 --- /dev/null +++ b/apps/elektrine/test/elektrine/web_index_test.exs @@ -0,0 +1,150 @@ +defmodule Elektrine.WebIndexTest do + use Elektrine.DataCase, async: true + + alias Elektrine.Repo + alias Elektrine.WebIndex + alias Elektrine.WebIndex.{Crawler, Document, Provider} + + test "crawls, extracts, stores, and searches a seeded page" do + url = unique_url("guide") + assert {:ok, document} = WebIndex.seed(url, enqueue?: false) + + request_fun = fn requested_url, _headers, _opts -> + if String.ends_with?(requested_url, "/robots.txt") do + response(200, "User-agent: PaigeBot\nAllow: /", "text/plain") + else + response( + 200, + """ + Paige Independent Index + +
    Paige crawls selected websites and ranks useful independent documents.
    + """, + "text/html; charset=utf-8" + ) + end + end + + assert :ok = + Crawler.crawl_document(document.id, + request_fun: request_fun, + pacing?: false, + enqueue_discovered?: false + ) + + stored = Repo.get!(Document, document.id) + assert stored.status == "indexed" + assert stored.title == "Paige Independent Index" + assert stored.content_hash + + assert [result] = WebIndex.search("independent documents") + assert result.url == url + + assert {:ok, [provider_result]} = Provider.search("independent documents") + assert provider_result.source == "Paige Index" + assert provider_result.metadata.independent_index + end + + test "robots exclusions prevent a page fetch and mark it blocked" do + url = unique_url("private/report") + assert {:ok, document} = WebIndex.seed(url, enqueue?: false) + test_pid = self() + + request_fun = fn requested_url, _headers, _opts -> + send(test_pid, {:requested, requested_url}) + response(200, "User-agent: *\nDisallow: /private/", "text/plain") + end + + assert {:discard, :robots_disallowed} = + Crawler.crawl_document(document.id, + request_fun: request_fun, + pacing?: false, + enqueue_discovered?: false + ) + + assert_received {:requested, robots_url} + assert String.ends_with?(robots_url, "/robots.txt") + refute_received {:requested, ^url} + assert Repo.get!(Document, document.id).status == "blocked" + end + + test "declared sitemaps add same-host locations to the frontier" do + root = unique_url("") + host = URI.parse(root).host + sitemap_url = "https://#{host}/sitemap.xml" + page_url = "https://#{host}/from-sitemap" + + request_fun = fn requested_url, _headers, _opts -> + case URI.parse(requested_url).path do + "/robots.txt" -> + response(200, "User-agent: *\nAllow: /", "text/plain") + + "/sitemap.xml" -> + response(200, "#{page_url}", "application/xml") + end + end + + assert :ok = + Crawler.crawl_sitemap(sitemap_url, 0, + request_fun: request_fun, + pacing?: false, + enqueue_discovered?: false + ) + + assert Repo.get_by(Document, canonical_url: page_url) + end + + test "content hashes prevent duplicate copies from entering search" do + first = + index_page( + unique_url("first"), + "Original", + "Shared body long enough to index as a useful document" + ) + + second = + index_page( + unique_url("second"), + "Copy", + "Shared body long enough to index as a useful document" + ) + + assert first.status == "indexed" + assert second.status == "duplicate" + assert length(WebIndex.search("Shared body")) == 1 + end + + test "normalizes URLs and rejects credentials or unsupported schemes" do + assert {:ok, "https://example.com/path?q=1", "example.com"} = + WebIndex.normalize_url(" https://EXAMPLE.com:443/path?utm_source=test&q=1#fragment ") + + assert {:error, :invalid_url} = WebIndex.normalize_url("file:///etc/passwd") + assert {:error, :invalid_url} = WebIndex.normalize_url("https://user:pass@example.com/") + assert {:error, :invalid_url} = WebIndex.normalize_url("https://example.com/a path") + end + + defp index_page(url, title, content) do + assert {:ok, document} = WebIndex.seed(url, enqueue?: false) + + assert {:ok, stored} = + WebIndex.store_page(document, %{ + title: title, + description: nil, + content: content, + content_hash: :crypto.hash(:sha256, content), + language: "en", + http_status: 200 + }) + + stored + end + + defp unique_url(path) do + unique = System.unique_integer([:positive]) + "https://site#{unique}.example/#{path}" + end + + defp response(status, body, content_type) do + {:ok, %{status: status, body: body, headers: [{"content-type", content_type}]}} + end +end diff --git a/apps/elektrine/test/support/channel_case.ex b/apps/elektrine/test/support/channel_case.ex new file mode 100644 index 0000000..e9cdae3 --- /dev/null +++ b/apps/elektrine/test/support/channel_case.ex @@ -0,0 +1,28 @@ +defmodule ElektrineWeb.ChannelCase do + @moduledoc """ + This module defines the test case to be used by + channel tests. + + Such tests rely on `Phoenix.ChannelTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Import conveniences for testing with channels + import Phoenix.ChannelTest + import ElektrineWeb.ChannelCase + + # The default endpoint for testing + @endpoint ElektrineWeb.Endpoint + end + end + + setup tags do + Elektrine.DataCase.setup_sandbox(tags) + :ok + end +end diff --git a/apps/elektrine/test/support/conn_case.ex b/apps/elektrine/test/support/conn_case.ex new file mode 100644 index 0000000..d9e52f4 --- /dev/null +++ b/apps/elektrine/test/support/conn_case.ex @@ -0,0 +1,38 @@ +defmodule ElektrineWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use ElektrineWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # The default endpoint for testing + @endpoint ElektrineWeb.Endpoint + + use ElektrineWeb, :verified_routes + + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import ElektrineWeb.ConnCase + end + end + + setup tags do + Elektrine.DataCase.setup_sandbox(tags) + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/apps/elektrine/test/support/data_case.ex b/apps/elektrine/test/support/data_case.ex new file mode 100644 index 0000000..71f9740 --- /dev/null +++ b/apps/elektrine/test/support/data_case.ex @@ -0,0 +1,58 @@ +defmodule Elektrine.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use Elektrine.DataCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + alias Elektrine.Repo + + import Ecto + import Ecto.Changeset + import Ecto.Query + import Elektrine.DataCase + end + end + + setup tags do + Elektrine.DataCase.setup_sandbox(tags) + :ok + end + + @doc """ + Sets up the sandbox based on the test tags. + """ + def setup_sandbox(tags) do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Elektrine.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/apps/elektrine/test/support/feature_case.ex b/apps/elektrine/test/support/feature_case.ex new file mode 100644 index 0000000..8e86ee8 --- /dev/null +++ b/apps/elektrine/test/support/feature_case.ex @@ -0,0 +1,147 @@ +defmodule ElektrineWeb.FeatureCase do + @moduledoc """ + This module defines the test case to be used by + browser-based feature tests using Wallaby. + + Use this for end-to-end tests that need real browser interaction. + """ + + use ExUnit.CaseTemplate + + using do + chromedriver_skip_reason = + if System.find_executable("chromedriver") do + nil + else + "chromedriver not installed" + end + + quote do + use Wallaby.Feature + @moduletag :feature + + if unquote(chromedriver_skip_reason) do + @moduletag skip: unquote(chromedriver_skip_reason) + end + + alias Elektrine.Repo + alias Wallaby.Query + import Ecto + import Ecto.Changeset + import Ecto.Query, except: [update: 2, update: 3] + + @default_password "Password123!" + + # Helper to create a user and log them in + def create_and_login_user(session, attrs \\ %{}) do + user = create_test_user(attrs) + session = login_user(session, user) + {session, user} + end + + def create_test_user(attrs \\ %{}) do + unique_id = System.unique_integer([:positive]) + + default_attrs = %{ + username: "testuser#{unique_id}", + email: "test#{unique_id}@example.net", + password: @default_password, + password_confirmation: @default_password, + handle: "testuser#{unique_id}" + } + + {:ok, user} = + default_attrs + |> Map.merge(attrs) + |> Elektrine.Accounts.create_user() + + # Mark onboarding as completed so tests can access the full app + {:ok, user} = + user + |> Ecto.Changeset.change(%{ + onboarding_completed: true, + onboarding_completed_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.update() + + user + end + + def login_user(session, user) do + session + |> visit("/login") + |> fill_in(Query.css("#login-form_username"), with: user.username) + |> fill_in(Query.css("#login-form_password"), with: @default_password) + |> click(Query.button("Log in")) + |> wait_for_liveview() + end + + @doc """ + Wait for LiveView to connect by checking for the phx-connected attribute. + This helps avoid race conditions when navigating between pages. + """ + def wait_for_liveview(session) do + # Only block when this page is actually a LiveView. Controller-rendered pages + # may not have a LiveView root and should continue immediately. + if has_selector?(session, "[data-phx-main]") do + wait_for_liveview_connected(session, System.monotonic_time(:millisecond) + 10_000) + else + session + end + end + + @doc """ + Navigate and wait for LiveView to connect. + """ + def visit_and_wait(session, path) do + session + |> visit(path) + |> wait_for_liveview() + end + + defp wait_for_liveview_connected(session, deadline_ms) do + connected? = + has_selector?(session, "body.phx-connected") or + has_selector?(session, "[data-phx-main].phx-connected") + + cond do + connected? -> + session + + System.monotonic_time(:millisecond) >= deadline_ms -> + session + + true -> + :timer.sleep(50) + wait_for_liveview_connected(session, deadline_ms) + end + end + + defp has_selector?(session, selector) do + session + |> Wallaby.Browser.all(Query.css(selector)) + |> Enum.any?() + end + end + end + + setup tags do + Application.put_env(:wallaby, :base_url, ElektrineWeb.Endpoint.url()) + Application.put_env(:wallaby, :max_wait_time, 20_000) + + {:ok, _} = Application.ensure_all_started(:wallaby) + + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Elektrine.Repo) + + unless tags[:async] do + Ecto.Adapters.SQL.Sandbox.mode(Elektrine.Repo, {:shared, self()}) + end + + metadata = Phoenix.Ecto.SQL.Sandbox.metadata_for(Elektrine.Repo, self()) + {:ok, session} = Wallaby.start_session(metadata: metadata) + # Make feature tests deterministic: keep a consistent, desktop-sized viewport. + session = Wallaby.Browser.resize_window(session, 1280, 800) + + {:ok, session: session} + end +end diff --git a/apps/elektrine/test/support/fixtures/accounts_fixtures.ex b/apps/elektrine/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 0000000..0a0e16c --- /dev/null +++ b/apps/elektrine/test/support/fixtures/accounts_fixtures.ex @@ -0,0 +1,58 @@ +defmodule Elektrine.AccountsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Elektrine.Accounts` context. + """ + + alias Elektrine.Accounts + + def unique_user_username do + "u" <> (Ecto.UUID.generate() |> String.replace("-", "") |> String.slice(0, 19)) + end + + def valid_user_password, do: "hello world!" + + def valid_user_attributes(attrs \\ %{}) do + Enum.into(attrs, %{ + username: unique_user_username(), + password: valid_user_password(), + password_confirmation: valid_user_password() + }) + end + + def user_fixture(attrs \\ %{}) do + # Separate registration attrs from other attrs + {privacy_attrs, registration_attrs} = + Map.split(attrs, [ + :profile_visibility, + :allow_group_adds_from, + :allow_direct_messages_from, + :allow_mentions_from, + :birthday, + :show_birthday, + :hide_followers, + :hide_follows, + :hide_favorites, + :also_known_as, + :moved_to, + :notify_on_new_follower, + :notify_on_direct_message, + :notify_on_mention, + :block_notifications_from_strangers, + :hide_notification_contents + ]) + + {:ok, user} = + registration_attrs + |> valid_user_attributes() + |> Accounts.create_user() + + # If there are privacy settings, update the user with them + if map_size(privacy_attrs) > 0 do + {:ok, updated_user} = Accounts.update_user(user, privacy_attrs) + updated_user + else + user + end + end +end diff --git a/apps/elektrine/test/support/fixtures/email_fixtures.ex b/apps/elektrine/test/support/fixtures/email_fixtures.ex new file mode 100644 index 0000000..c7e5c2d --- /dev/null +++ b/apps/elektrine/test/support/fixtures/email_fixtures.ex @@ -0,0 +1,55 @@ +defmodule Elektrine.EmailFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Elektrine.Email` context. + """ + + alias Elektrine.Repo + + @mailbox_module :"Elixir.Elektrine.Email.Mailbox" + @mailbox_adapter_module :"Elixir.Elektrine.Email.MailboxAdapter" + + def unique_email, do: "user#{System.unique_integer([:positive])}@example.com" + + def valid_mailbox_attributes(attrs \\ %{}) do + email = attrs[:email] || unique_email() + username = email |> String.split("@") |> List.first() + + Enum.into(attrs, %{ + email: email, + username: username, + user_id: attrs[:user_id] + }) + end + + def mailbox_fixture(attrs \\ %{}) do + attrs = valid_mailbox_attributes(attrs) + mailbox_struct = :erlang.apply(@mailbox_module, :__struct__, []) + changeset = :erlang.apply(@mailbox_module, :changeset, [mailbox_struct, attrs]) + + {:ok, mailbox} = Repo.insert(changeset) + + mailbox + end + + def message_fixture(attrs \\ %{}) do + defaults = %{ + from: "sender@example.com", + to: "recipient@example.com", + subject: "Test Subject #{System.unique_integer([:positive])}", + text_body: "Test body content", + html_body: "

    Test body content

    ", + message_id: "test-#{System.unique_integer([:positive])}@example.com", + status: "received", + read: false, + spam: false, + archived: false, + deleted: false + } + + attrs = Map.merge(defaults, attrs) + + {:ok, message} = :erlang.apply(@mailbox_adapter_module, :create_message, [attrs]) + message + end +end diff --git a/apps/elektrine/test/support/fixtures/social_fixtures.ex b/apps/elektrine/test/support/fixtures/social_fixtures.ex new file mode 100644 index 0000000..8663bd8 --- /dev/null +++ b/apps/elektrine/test/support/fixtures/social_fixtures.ex @@ -0,0 +1,150 @@ +defmodule Elektrine.SocialFixtures do + @moduledoc """ + Test helpers for creating entities via the `Elektrine.Social` context. + """ + + import Elektrine.AccountsFixtures + alias Elektrine.Repo + alias Elektrine.Social.{Conversation, Message} + + @doc """ + Creates a timeline conversation for a user. + """ + def timeline_conversation_fixture(user) do + {:ok, conversation} = + %Conversation{} + |> Conversation.changeset(%{ + name: "Timeline", + type: "timeline", + creator_id: user.id, + is_public: true, + allow_public_posts: true + }) + |> Repo.insert() + + conversation + end + + @doc """ + Creates a timeline post (message) for testing. + + ## Options + + * `:user` - The user creating the post. Creates a new user if not provided. + * `:content` - The post content. Defaults to "Test post content". + * `:visibility` - Post visibility. Defaults to "public". + * `:conversation` - Timeline conversation. Creates one if not provided. + """ + def post_fixture(attrs \\ %{}) do + user = attrs[:user] || user_fixture() + content = attrs[:content] || "Test post content #{System.unique_integer([:positive])}" + visibility = attrs[:visibility] || "public" + + conversation = + attrs[:conversation] || timeline_conversation_fixture(user) + + {:ok, message} = + %Message{} + |> Message.changeset(%{ + conversation_id: conversation.id, + sender_id: user.id, + content: content, + message_type: "text", + visibility: visibility, + post_type: "post", + like_count: 0, + reply_count: 0, + share_count: 0 + }) + |> Repo.insert() + + message |> Repo.preload([:sender, :conversation]) + end + + @doc """ + Creates a post with media URLs for testing. + Bypasses media URL validation since test URLs aren't on trusted domains. + """ + def media_post_fixture(attrs \\ %{}) do + user = attrs[:user] || user_fixture() + content = attrs[:content] || "Media post content" + media_urls = attrs[:media_urls] || ["/uploads/test-image.jpg"] + + conversation = + attrs[:conversation] || timeline_conversation_fixture(user) + + # Use direct insert to bypass media URL validation that requires trusted domains + {:ok, message} = + %Message{ + conversation_id: conversation.id, + sender_id: user.id, + content: content, + message_type: "image", + media_urls: media_urls, + visibility: "public", + post_type: "post", + like_count: 0, + reply_count: 0, + share_count: 0 + } + |> Repo.insert() + + message |> Repo.preload([:sender, :conversation]) + end + + @doc """ + Creates a community conversation for testing discussions. + """ + def community_conversation_fixture(user, attrs \\ %{}) do + {:ok, conversation} = + %Conversation{} + |> Conversation.changeset(%{ + name: attrs[:name] || "Test Community", + type: "community", + creator_id: user.id, + is_public: Map.get(attrs, :is_public, true), + hash: attrs[:hash] || "test_#{System.unique_integer([:positive])}" + }) + |> Repo.insert() + + conversation + end + + @doc """ + Creates a discussion post (for community voting tests). + + ## Options + + * `:user` - The user creating the post. Creates a new user if not provided. + * `:content` - The post content. Defaults to "Test discussion content". + * `:community` - Community conversation. Creates one if not provided. + * `:title` - Discussion title. Defaults to "Test Discussion". + """ + def discussion_post_fixture(attrs \\ %{}) do + user = attrs[:user] || user_fixture() + content = attrs[:content] || "Test discussion content #{System.unique_integer([:positive])}" + title = attrs[:title] || "Test Discussion" + + community = + attrs[:community] || community_conversation_fixture(user) + + {:ok, message} = + %Message{} + |> Message.changeset(%{ + conversation_id: community.id, + sender_id: user.id, + content: content, + message_type: "text", + visibility: "public", + post_type: "discussion", + title: title, + upvotes: 0, + downvotes: 0, + score: 0, + reply_count: 0 + }) + |> Repo.insert() + + message |> Repo.preload([:sender, :conversation]) + end +end diff --git a/apps/elektrine/test/test_helper.exs b/apps/elektrine/test/test_helper.exs new file mode 100644 index 0000000..913388a --- /dev/null +++ b/apps/elektrine/test/test_helper.exs @@ -0,0 +1,30 @@ +# Enable feature (Wallaby) tests only when explicitly requested, or in CI when +# chromedriver is available. If Wallaby fails to start, we fall back to skipping +# feature tests so the suite remains runnable in minimal environments. +enable_wallaby? = + case System.get_env("ENABLE_WALLABY") do + nil -> + System.get_env("CI") in ["true", "1"] and not is_nil(System.find_executable("chromedriver")) + + value -> + String.downcase(value) in ["true", "1"] + end + +enable_wallaby? = + if enable_wallaby? do + Application.put_env(:wallaby, :driver, Wallaby.Chrome) + Application.put_env(:wallaby, :base_url, ElektrineWeb.Endpoint.url()) + # Timeline (and other pages) load async after LiveView connects; give CI + # enough headroom to avoid flaky `assert_has` timeouts. + Application.put_env(:wallaby, :max_wait_time, 20_000) + + case Application.ensure_all_started(:wallaby) do + {:ok, _} -> true + {:error, _} -> false + end + else + false + end + +ExUnit.start(exclude: if(enable_wallaby?, do: [], else: [:feature])) +Ecto.Adapters.SQL.Sandbox.mode(Elektrine.Repo, :manual) diff --git a/apps/elektrine_dns/README.md b/apps/elektrine_dns/README.md new file mode 100644 index 0000000..0252fe5 --- /dev/null +++ b/apps/elektrine_dns/README.md @@ -0,0 +1,21 @@ +# Elektrine DNS + +Managed DNS app for the Elektrine umbrella. + +## What lives here + +- `Elektrine.DNS` context for zones, records, service configs, verification, and query stats +- authoritative UDP and TCP DNS servers +- optional recursive resolution path and cache +- DNS LiveViews and PAT-backed DNS API routes +- DNS-specific generators for web, mail, VPN, TURN, and Bluesky records + +## Notes + +- Depends on shared repo/runtime services from `elektrine`. +- Reuses the Phoenix host app from `elektrine_web` for routing and shared layout concerns. +- The Docker `dns` profile runs the dedicated DNS service for authoritative queries. + +## License + +AGPL-3.0-only (see `../../LICENSE`). diff --git a/apps/elektrine_dns/lib/elektrine/dns.ex b/apps/elektrine_dns/lib/elektrine/dns.ex new file mode 100644 index 0000000..b78f63e --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns.ex @@ -0,0 +1,2293 @@ +defmodule Elektrine.DNS do + @moduledoc """ + Core context for Elektrine's managed DNS service. + """ + + import Ecto.Changeset, only: [add_error: 3, change: 1] + import Ecto.Query, warn: false + + require Logger + + alias Elektrine.Accounts.BuiltInSubdomain + alias Elektrine.DNS.DomainHealth + alias Elektrine.DNS.ManagedRecords + alias Elektrine.DNS.Packet + alias Elektrine.DNS.QueryStat + alias Elektrine.DNS.Record + alias Elektrine.DNS.Zone + alias Elektrine.DNS.ZoneCache + alias Elektrine.DNS.ZoneServiceConfig + alias Elektrine.Domains + alias Elektrine.Repo + + @record_types ~w(A AAAA ALIAS CAA CNAME DNSKEY DS HTTPS MX NS SRV SSHFP SVCB TLSA TXT) + @builtin_user_zone_apex_managed_key "system:profile-apex" + @builtin_user_zone_managed_service "system" + @profile_wildcard_managed_key_a "system:profile-wildcard-a" + @profile_wildcard_managed_key_aaaa "system:profile-wildcard-aaaa" + @builtin_user_zone_forbidden_types ~w(ALIAS DNSKEY DS NS TLSA) + @builtin_user_zone_allowed_apex_types ~w(CAA TXT) + @builtin_user_zone_modes BuiltInSubdomain.modes() + @user_schema :"Elixir.Elektrine.Accounts.User" + @nameserver_label_pairs [ + ~w(rose mint), + ~w(lumen quartz), + ~w(ember slate), + ~w(onyx pearl), + ~w(cobalt amber), + ~w(violet cedar), + ~w(indigo copper), + ~w(silver olive) + ] + + def list_user_zones(%{id: user_id} = user) when is_integer(user_id) do + _ = ensure_builtin_user_zone(user) + + user_id + |> list_user_zones() + |> sort_user_zones(builtin_user_zone_domain(user)) + end + + def list_user_zones(user_id) when is_integer(user_id) do + Zone + |> where(user_id: ^user_id) + |> order_by([z], asc: z.domain) + |> preload([:records, :service_configs]) + |> Repo.all() + end + + def list_user_zones(_), do: [] + + def ensure_builtin_user_zone(%{id: user_id} = user) when is_integer(user_id) do + case builtin_user_zone_domain(user) do + nil -> {:error, :invalid_user} + domain -> ensure_builtin_user_zone(user, domain) + end + end + + def ensure_builtin_user_zone(_), do: {:error, :invalid_user} + + def builtin_user_zone_domain(%{handle: handle, username: username}) do + label = + (handle || username) + |> to_string() + |> String.trim() + |> String.downcase() + + base_domain = Domains.primary_profile_domain() + + if label == "" or not public_hostname?(base_domain) do + nil + else + label <> "." <> base_domain + end + end + + def builtin_user_zone_domain(_), do: nil + + def builtin_user_zone?(%Zone{} = zone, %{id: user_id} = user) when is_integer(user_id) do + zone.user_id == user_id and zone.domain == builtin_user_zone_domain(user) + end + + def builtin_user_zone_mode(%{built_in_subdomain_mode: _} = user), + do: BuiltInSubdomain.mode(user) + + def builtin_user_zone_mode(%Zone{} = zone) do + case zone.user_id && Repo.get(@user_schema, zone.user_id) do + nil -> "platform" + user -> builtin_user_zone_mode(user) + end + end + + def builtin_user_zone_mode(_), do: "platform" + + def builtin_user_zone_hosted_by_platform?(user_or_zone), + do: builtin_user_zone_mode(user_or_zone) == "platform" + + def update_builtin_user_zone_mode(user, mode) when mode in @builtin_user_zone_modes do + if user_schema?(user) do + update_user_builtin_zone_mode(user, mode) + else + {:error, :invalid_user} + end + end + + def update_builtin_user_zone_mode(user, _mode) do + if user_schema?(user), do: {:error, :invalid_mode}, else: {:error, :invalid_user} + end + + defp update_user_builtin_zone_mode(user, mode) do + user + |> Ecto.Changeset.change(%{built_in_subdomain_mode: mode}) + |> Repo.update() + |> case do + {:ok, updated_user} -> + with :ok <- validate_platform_handoff(updated_user), + {:ok, _zone} <- ensure_builtin_user_zone(updated_user) do + {:ok, updated_user} + else + {:error, reason} -> + _ = + Repo.update( + Ecto.Changeset.change(updated_user, %{ + built_in_subdomain_mode: builtin_user_zone_mode(user) + }) + ) + + {:error, reason} + end + + error -> + error + end + end + + def builtin_user_zone?(%Zone{} = zone) do + case zone.user_id && Repo.get(@user_schema, zone.user_id) do + nil -> false + user -> builtin_user_zone?(zone, user) + end + end + + def builtin_user_zone?(_), do: false + + def repair_builtin_user_zone_records(%Zone{} = zone) do + if builtin_user_zone?(zone) do + ensure_builtin_user_zone_records(zone, refresh_cache?: false) + else + {:ok, zone} + end + end + + def repair_builtin_user_zone_records(_), do: {:error, :invalid_zone} + + @doc """ + Ensures a proxied wildcard record (`*.`) exists in every configured + profile base-domain zone this server is authoritative for. + + This is the catch-all that lets every built-in profile subdomain + (`username.`) resolve to the edge even when the user has never + provisioned their own built-in zone. Per-user zones still take precedence, + because the resolver matches the most specific zone first. + + Idempotent. Returns a list of `{domain, result}` tuples where `result` is + `:ok`, `{:error, :zone_not_found}` (the base domain is not a managed zone + here), or `{:error, :no_edge_proxy_ipv4}` (no edge address is configured to + point the wildcard at). + """ + def ensure_profile_subdomain_wildcards do + domains = + [Domains.primary_profile_domain() | Domains.configured_profile_base_domains()] + |> Enum.map(&normalize_zone_host/1) + |> Enum.reject(&(is_nil(&1) or &1 == "")) + |> Enum.uniq() + + results = Enum.map(domains, &ensure_profile_wildcard_for_domain/1) + + if Enum.any?(results, fn {_domain, result} -> result == :ok end) do + ZoneCache.refresh_async() + end + + results + end + + defp ensure_profile_wildcard_for_domain(domain) do + if edge_proxy_ipv4_addresses() == [] do + {domain, {:error, :no_edge_proxy_ipv4}} + else + case get_zone_by_domain(domain) do + %Zone{} = zone -> + upsert_profile_wildcard_records(zone) + {domain, :ok} + + nil -> + {domain, {:error, :zone_not_found}} + end + end + end + + defp upsert_profile_wildcard_records(%Zone{} = zone) do + upsert_profile_wildcard_record( + zone, + "A", + @profile_wildcard_managed_key_a, + List.first(edge_proxy_ipv4_addresses()) + ) + + case List.first(edge_proxy_ipv6_addresses()) do + nil -> + delete_profile_wildcard_record(zone, @profile_wildcard_managed_key_aaaa) + + ipv6 -> + upsert_profile_wildcard_record(zone, "AAAA", @profile_wildcard_managed_key_aaaa, ipv6) + end + + :ok + end + + defp upsert_profile_wildcard_record(%Zone{} = zone, type, managed_key, placeholder_content) do + attrs = %{ + zone_id: zone.id, + name: "*", + type: type, + ttl: default_ttl(), + content: placeholder_content, + source: "system", + service: @builtin_user_zone_managed_service, + managed: true, + managed_key: managed_key, + required: true, + proxied: true, + metadata: %{"label" => "Profile subdomain catch-all"} + } + + case get_profile_wildcard_record(zone, type, managed_key, placeholder_content) do + %Record{} = record -> + record |> Record.changeset(attrs) |> Repo.insert_or_update!() + + nil -> + %Record{} |> Record.changeset(attrs) |> Repo.insert!() + end + end + + defp get_profile_wildcard_record(%Zone{} = zone, type, managed_key, content) do + Record + |> where([r], r.zone_id == ^zone.id) + |> where( + [r], + r.managed_key == ^managed_key or + (r.name == "*" and r.type == ^type and + fragment("lower(?) = lower(?)", r.content, ^content)) + ) + |> order_by([r], desc: r.managed, desc: not is_nil(r.managed_key), asc: r.id) + |> limit(1) + |> Repo.one() + end + + defp delete_profile_wildcard_record(%Zone{} = zone, managed_key) do + case Repo.get_by(Record, zone_id: zone.id, managed_key: managed_key) do + %Record{} = record -> Repo.delete!(record) + nil -> :ok + end + end + + def list_zone_records(zone_id) when is_integer(zone_id) do + Record + |> where(zone_id: ^zone_id) + |> order_by([r], asc: r.name, asc: r.type) + |> Repo.all() + end + + def list_zone_records(_), do: [] + + def get_zone!(id), do: Repo.get!(Zone, id) + + def get_zone(id, user_id) when is_integer(id) and is_integer(user_id) do + Zone + |> where([z], z.id == ^id and z.user_id == ^user_id) + |> preload([:records, :service_configs]) + |> Repo.one() + end + + def get_zone(_, _), do: nil + + def get_zone_by_domain(domain) when is_binary(domain) do + normalized = domain |> String.trim() |> String.downcase() + + Zone + |> where([z], fragment("lower(?)", z.domain) == ^normalized) + |> preload([:records, :service_configs]) + |> Repo.one() + end + + def get_zone_by_domain(_), do: nil + + def web_force_https_for_host(host) when is_binary(host) do + host + |> normalize_zone_host() + |> get_zone_by_domain() + |> case do + %Zone{force_https: force_https} -> force_https == true + _ -> false + end + end + + def web_force_https_for_host(_), do: false + + def health_status do + zone_cache_running? = Process.whereis(Elektrine.DNS.ZoneCache) != nil + + status = + if zone_cache_running? do + :ok + else + :error + end + + %{ + status: status, + zone_cache_running: zone_cache_running?, + nameservers_configured: true, + authority_enabled: authority_enabled?(), + recursive_enabled: recursive_enabled?() + } + end + + def domain_health(%Zone{} = zone), do: DomainHealth.analyze(zone) + + def domain_health(_), do: DomainHealth.analyze(nil) + + def create_zone(%{id: user_id}, attrs) when is_integer(user_id), do: create_zone(user_id, attrs) + + def create_zone(user_id, attrs) when is_integer(user_id) and is_map(attrs) do + %Zone{} + |> Zone.changeset(create_zone_attrs(attrs, user_id)) + |> Repo.insert() + |> case do + {:ok, zone} -> + zone + |> assign_nameserver_set_on_create() + |> Repo.preload([:records, :service_configs]) + |> then(&{:ok, &1}) + + error -> + error + end + |> refresh_authority_cache_after_write() + |> maybe_ensure_profile_wildcards_after_zone_write() + end + + def create_zone(_, _), do: {:error, :invalid_attributes} + + def create_record(%Zone{} = zone, attrs) when is_map(attrs) do + normalized_attrs = normalize_record_attrs(attrs, zone.domain) + + with :ok <- validate_zone_record_write(zone, normalized_attrs) do + zone_domain = zone.domain + + %Record{} + |> Record.changeset( + normalize_record_attrs(Map.put(normalized_attrs, "zone_id", zone.id), zone_domain) + ) + |> Repo.insert() + |> refresh_authority_cache_after_write(touch_zone: true) + end + end + + def create_record(zone_id, attrs) when is_integer(zone_id) and is_map(attrs) do + case Repo.get(Zone, zone_id) do + %Zone{} = zone -> create_record(zone, attrs) + nil -> {:error, :not_found} + end + end + + def create_record(_, _), do: {:error, :invalid_attributes} + + def update_zone(%Zone{} = zone, attrs) when is_map(attrs) do + if builtin_user_zone?(zone) and Map.has_key?(attrs, "domain") do + {:error, add_error(change(zone), :domain, "is managed by Elektrine")} + else + zone + |> Zone.changeset(public_zone_attrs(attrs)) + |> Repo.update() + |> case do + {:ok, zone} -> {:ok, Repo.preload(zone, [:records, :service_configs])} + error -> error + end + |> refresh_authority_cache_after_write(touch_zone: true) + end + end + + def delete_zone(%Zone{} = zone) do + if builtin_user_zone?(zone) do + {:error, + add_error(change(zone), :domain, "is the built-in profile subdomain and cannot be deleted")} + else + zone + |> Repo.delete() + |> refresh_authority_cache_after_write() + end + end + + def change_zone(%Zone{} = zone, attrs \\ %{}), do: Zone.changeset(zone, attrs) + + def get_record(id, zone_id) when is_integer(id) and is_integer(zone_id) do + Record + |> where([r], r.id == ^id and r.zone_id == ^zone_id) + |> Repo.one() + end + + def get_record(_, _), do: nil + + def update_record(%Record{} = record, attrs) when is_map(attrs) do + zone_domain = zone_domain(record.zone_id) + zone = Repo.get(Zone, record.zone_id) + normalized_attrs = normalize_record_attrs(public_record_attrs(attrs), zone_domain) + + with :ok <- validate_record_mutation(record, :update), + :ok <- + validate_zone_record_write( + zone, + Map.merge(record_write_attrs(record), normalized_attrs) + ) do + record + |> Record.changeset(normalized_attrs) + |> Repo.update() + |> refresh_authority_cache_after_write(touch_zone: true) + end + end + + def delete_record(%Record{} = record) do + with :ok <- validate_record_mutation(record, :delete) do + record + |> Repo.delete() + |> refresh_authority_cache_after_write(touch_zone: true) + end + end + + def change_record(%Record{} = record, attrs \\ %{}) do + record + |> Map.put(:private, Record.private?(record)) + |> Map.put(:proxied, Record.proxied?(record)) + |> Map.put(:proxy_origin_scheme, Record.proxy_origin_scheme(record)) + |> Map.put(:proxy_origin_port, Record.proxy_origin_port(record)) + |> Map.put(:proxy_origin_host_header, Record.proxy_origin_host_header(record)) + |> Map.put(:proxy_atomine_gate, Record.proxy_atomine_gate?(record)) + |> Map.put(:health_check_enabled, Record.health_check?(record)) + |> Map.put(:health_check_port, Record.health_check_port(record)) + |> Record.changeset(attrs) + end + + def new_zone_changeset(%{id: user_id}) when is_integer(user_id), do: new_zone_changeset(user_id) + + def new_zone_changeset(user_id) when is_integer(user_id) do + Zone.changeset(%Zone{}, %{ + user_id: user_id, + default_ttl: default_ttl(), + soa_mname: List.first(nameservers()), + soa_rname: soa_rname(), + soa_minimum: default_ttl() + }) + end + + def new_record_changeset(zone_id) when is_integer(zone_id) do + Record.changeset(%Record{}, %{zone_id: zone_id, ttl: default_ttl(), type: "A", name: "@"}) + end + + def list_zone_service_configs(%Zone{id: zone_id}), do: list_zone_service_configs(zone_id) + + def list_zone_service_configs(zone_id) when is_integer(zone_id) do + ManagedRecords.list_service_configs(zone_id) + end + + def list_zone_service_configs(_), do: [] + + def get_zone_service_config(%Zone{id: zone_id}, service), + do: get_zone_service_config(zone_id, service) + + def get_zone_service_config(zone_id, service) when is_integer(zone_id) do + zone_id + |> list_zone_service_configs() + |> Enum.find(&(&1.service == normalize_service(service))) + end + + def get_zone_service_config(_, _), do: nil + + def apply_zone_service(%Zone{} = zone, service, attrs \\ %{}) do + if builtin_user_zone?(zone) do + {:error, + add_error( + change(zone), + :domain, + "managed service bundles are disabled for the built-in profile subdomain" + )} + else + zone + |> ManagedRecords.apply_service(service, attrs) + |> refresh_authority_cache_after_write(touch_zone: true) + end + end + + def disable_zone_service(%Zone{} = zone, service) do + if builtin_user_zone?(zone) do + {:error, + add_error( + change(zone), + :domain, + "managed service bundles are disabled for the built-in profile subdomain" + )} + else + zone + |> ManagedRecords.apply_service(service, %{"enabled" => false}) + |> refresh_authority_cache_after_write(touch_zone: true) + end + end + + def zone_service_health(%Zone{} = zone), do: ManagedRecords.service_health(zone) + + def public_service_settings(service, settings), + do: ManagedRecords.public_settings(service, settings) + + def builtin_user_zone_reserved_hint(%Zone{} = zone) do + if builtin_user_zone?(zone) and builtin_user_zone_hosted_by_platform?(zone) do + %{apex_record_type: "ALIAS", apex_target: Domains.profile_custom_domain_routing_target()} + else + nil + end + end + + defp normalize_zone_host(host) do + host + |> String.trim() + |> String.downcase() + |> String.split(":", parts: 2) + |> List.first() + |> then(fn + "www." <> domain -> domain + domain -> domain + end) + end + + def default_ttl do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:default_ttl, 300) + end + + def edge_proxy_ipv4_addresses do + configured_edge_proxy_addresses(:edge_proxy_ipv4_addresses, :a) + end + + def edge_proxy_ipv6_addresses do + configured_edge_proxy_addresses(:edge_proxy_ipv6_addresses, :aaaa) + end + + def edge_proxy_hostname do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:edge_proxy_hostname) + |> case do + value when is_binary(value) and value != "" -> value + _ -> Domains.profile_custom_domain_edge_target() + end + |> normalize_edge_proxy_hostname() + end + + defp configured_edge_proxy_addresses(config_key, fallback_type) do + configured = + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(config_key, []) + |> normalize_edge_proxy_addresses() + + if configured == [], do: resolve_edge_proxy_addresses(fallback_type), else: configured + end + + defp normalize_edge_proxy_addresses(addresses) do + addresses + |> List.wrap() + |> Enum.map(&to_string/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp resolve_edge_proxy_addresses(type) when type in [:a, :aaaa] do + case edge_proxy_hostname() do + nil -> + [] + + hostname -> + hostname + |> String.to_charlist() + |> dns_resolver().lookup(:in, type, timeout: 3_000) + |> Enum.map(&normalize_lookup_address/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + rescue + _ -> [] + end + + defp normalize_lookup_address({_, _, _, _} = address), do: :inet.ntoa(address) |> to_string() + defp normalize_lookup_address(tuple) when is_tuple(tuple), do: :inet.ntoa(tuple) |> to_string() + defp normalize_lookup_address(value) when is_binary(value), do: String.trim(value) + + defp normalize_lookup_address(value) when is_list(value), + do: value |> to_string() |> String.trim() + + defp normalize_lookup_address(_), do: nil + + defp normalize_edge_proxy_hostname(nil), do: nil + + defp normalize_edge_proxy_hostname(hostname) do + hostname = hostname |> String.trim() |> String.trim_trailing(".") |> String.downcase() + if hostname == "", do: nil, else: hostname + end + + def supported_record_types, do: @record_types + + def proxied_host?(host) when is_binary(host) do + match?({:ok, _origin}, proxied_origin_for_host(host)) + end + + def proxied_host?(_), do: false + + def proxied_origin_for_host(host) when is_binary(host) do + normalized_host = normalize_proxy_host(host) + + with %Zone{} = zone <- proxied_zone_for_host(normalized_host), + %Record{} = record <- proxied_record_for_host(zone, normalized_host) do + {:ok, proxied_origin(zone, record, normalized_host)} + else + _ -> {:error, :not_found} + end + end + + def proxied_origin_for_host(_), do: {:error, :not_found} + + def scan_existing_zone(domain) when is_binary(domain) do + normalized_domain = domain |> String.trim() |> String.downcase() |> String.trim_trailing(".") + + if public_hostname?(normalized_domain) do + nameservers = lookup_dns_values(normalized_domain, :ns, timeout: 5_000) + + %{ + domain: normalized_domain, + nameservers: nameservers, + delegated_to_elektrine: delegated_to_elektrine?(nameservers), + provider_hint: provider_hint(nameservers), + records: + [ + scan_record_entry("@", "A", lookup_dns_values(normalized_domain, :a, timeout: 3_000)), + scan_record_entry( + "@", + "AAAA", + lookup_dns_values(normalized_domain, :aaaa, timeout: 3_000) + ), + scan_record_entry( + "@", + "CNAME", + lookup_dns_values(normalized_domain, :cname, timeout: 3_000) + ), + scan_record_entry( + "@", + "MX", + lookup_dns_values(normalized_domain, :mx, timeout: 3_000) + ), + scan_record_entry( + "www", + "CNAME", + lookup_dns_values("www." <> normalized_domain, :cname, timeout: 3_000) + ), + scan_record_entry( + "www", + "A", + lookup_dns_values("www." <> normalized_domain, :a, timeout: 3_000) + ) + ] + |> Enum.reject(&is_nil/1) + } + else + nil + end + end + + def scan_existing_zone(_), do: nil + + def verify_zone(%Zone{} = zone) do + if builtin_user_zone?(zone) do + ensure_builtin_user_zone_records(zone) + else + now = DateTime.utc_now() |> DateTime.truncate(:second) + + case verify_nameservers(zone) do + :ok -> + update_zone_verification(zone, %{ + status: "verified", + verified_at: zone.verified_at || now, + last_checked_at: now, + last_error: nil + }) + + {:error, reason} -> + update_zone_verification(zone, %{ + status: "pending", + last_checked_at: now, + last_error: reason + }) + end + end + end + + def zone_onboarding_records(%Zone{} = zone) do + if builtin_user_zone?(zone), do: [], else: Zone.nameserver_records(zone) + end + + def assigned_nameservers(%Zone{} = zone) do + case nameserver_sets() do + [] -> nameservers() + sets -> Enum.at(sets, zone_nameserver_set(zone, length(sets))) + end + end + + def assigned_nameservers(_), do: nameservers() + + def assigned_nameserver_address_records(host, qtype) when is_binary(host) do + normalized_host = normalize_hostname(host) + qtype = normalize_query_type(qtype) + + case assigned_nameserver_base(normalized_host) do + nil -> + [] + + base -> + [] + |> maybe_add_nameserver_address_records(normalized_host, base, :a, "A", qtype) + |> maybe_add_nameserver_address_records(normalized_host, base, :aaaa, "AAAA", qtype) + end + end + + def assigned_nameserver_address_records(_, _), do: [] + + def nameservers do + configured = + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:nameservers, []) + + case Enum.reject(configured, &nil_or_blank?/1) do + [] -> derive_nameservers() + nameservers -> nameservers + end + end + + def authority_enabled? do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:authority_enabled, false) + end + + def recursive_enabled? do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:recursive_enabled, false) + end + + def alias_resolver do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:alias_resolver, :inet_res) + end + + def zone_cache_refresh_interval_ms do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:zone_cache_refresh_interval_ms, 60_000) + end + + def recursive_upstreams do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:recursive_upstreams, []) + end + + def recursive_root_hints do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:recursive_root_hints, [ + {{198, 41, 0, 4}, 53}, + {{170, 247, 170, 2}, 53}, + {{192, 33, 4, 12}, 53}, + {{199, 7, 91, 13}, 53}, + {{192, 203, 230, 10}, 53}, + {{192, 5, 5, 241}, 53}, + {{192, 112, 36, 4}, 53}, + {{198, 97, 190, 53}, 53}, + {{192, 36, 148, 17}, 53}, + {{192, 58, 128, 30}, 53}, + {{193, 0, 14, 129}, 53}, + {{199, 7, 83, 42}, 53}, + {{202, 12, 27, 33}, 53} + ]) + end + + def recursive_timeout do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:recursive_timeout, 3_000) + end + + def track_query(%{zone: %Zone{id: zone_id}, authoritative: true} = result, transport) + when transport in ["udp", "tcp"] do + rcode = normalize_dns_metric_value(result.rcode) + + attrs = %{ + zone_id: zone_id, + query_date: Date.utc_today(), + query_hour: DateTime.utc_now() |> DateTime.truncate(:second) |> truncate_to_hour(), + qname: normalize_dns_metric_name(result.qname, result.zone.domain, rcode), + qtype: normalize_dns_metric_value(result.qtype), + rcode: rcode, + transport: transport, + query_count: 1 + } + + Elektrine.DNS.QueryStatsBuffer.increment(attrs) + + :ok + rescue + _ -> :ok + end + + def track_query(_result, _transport), do: :ok + + defp flush_query_stats_buffer do + Elektrine.DNS.QueryStatsBuffer.flush() + rescue + _ -> :ok + end + + def get_zone_query_stats(zone_id) when is_integer(zone_id) do + flush_query_stats_buffer() + + today = Date.utc_today() + week_ago = Date.add(today, -6) + + # Single scan of the zone's rollup rows with conditional aggregates instead of + # four separate SUM queries over the same (zone_id, query_date) index. + stats = + from(qs in QueryStat, + where: qs.zone_id == ^zone_id, + select: %{ + total_queries: fragment("COALESCE(SUM(?), 0)", qs.query_count), + queries_today: + fragment( + "COALESCE(SUM(?) FILTER (WHERE ? >= ?), 0)", + qs.query_count, + qs.query_date, + ^today + ), + queries_this_week: + fragment( + "COALESCE(SUM(?) FILTER (WHERE ? >= ?), 0)", + qs.query_count, + qs.query_date, + ^week_ago + ), + nxdomain_queries: + fragment( + "COALESCE(SUM(?) FILTER (WHERE ? = ?), 0)", + qs.query_count, + qs.rcode, + "NXDOMAIN" + ) + } + ) + |> Repo.one() + + stats || empty_query_stats() + end + + def get_zone_query_stats(_), do: empty_query_stats() + + def get_zone_daily_query_counts(zone_id, days \\ 30) + + def get_zone_daily_query_counts(zone_id, days) when is_integer(zone_id) and days > 0 do + flush_query_stats_buffer() + + start_date = Date.add(Date.utc_today(), -days + 1) + end_date = Date.utc_today() + + actual_counts = + from(qs in QueryStat, + where: qs.zone_id == ^zone_id and qs.query_date >= ^start_date, + group_by: qs.query_date, + select: %{date: qs.query_date, count: sum(qs.query_count)} + ) + |> Repo.all() + |> Map.new(fn %{date: date, count: count} -> {date, count || 0} end) + + Date.range(start_date, end_date) + |> Enum.map(fn date -> %{date: date, count: Map.get(actual_counts, date, 0)} end) + end + + def get_zone_daily_query_counts(_, _), do: [] + + def get_zone_hourly_query_counts(zone_id, hours \\ 24) + + def get_zone_hourly_query_counts(zone_id, hours) when is_integer(zone_id) and hours > 0 do + flush_query_stats_buffer() + + now = DateTime.utc_now() |> DateTime.truncate(:second) |> truncate_to_hour() + start_hour = DateTime.add(now, -(hours - 1), :hour) + + actual_counts = + from(qs in QueryStat, + where: qs.zone_id == ^zone_id and qs.query_hour >= ^start_hour, + group_by: qs.query_hour, + select: %{hour: qs.query_hour, count: sum(qs.query_count)} + ) + |> Repo.all() + |> Map.new(fn %{hour: hour, count: count} -> {hour, count || 0} end) + + 0..(hours - 1) + |> Enum.map(fn offset -> DateTime.add(start_hour, offset, :hour) end) + |> Enum.map(fn hour -> %{hour: hour, count: Map.get(actual_counts, hour, 0)} end) + end + + def get_zone_hourly_query_counts(_, _), do: [] + + def get_zone_query_type_breakdown(zone_id, limit \\ 10) + + def get_zone_query_type_breakdown(zone_id, limit) when is_integer(zone_id) do + flush_query_stats_buffer() + start_date = Date.add(Date.utc_today(), -29) + + from(qs in QueryStat, + where: qs.zone_id == ^zone_id and qs.query_date >= ^start_date, + group_by: qs.qtype, + select: %{qtype: qs.qtype, count: sum(qs.query_count)}, + order_by: [desc: sum(qs.query_count), asc: qs.qtype], + limit: ^limit + ) + |> Repo.all() + end + + def get_zone_query_type_breakdown(_, _), do: [] + + def get_zone_top_names(zone_id, limit \\ 10) + + def get_zone_top_names(zone_id, limit) when is_integer(zone_id) do + flush_query_stats_buffer() + start_date = Date.add(Date.utc_today(), -29) + + from(qs in QueryStat, + where: qs.zone_id == ^zone_id and qs.query_date >= ^start_date, + group_by: qs.qname, + select: %{qname: qs.qname, count: sum(qs.query_count)}, + order_by: [desc: sum(qs.query_count), asc: qs.qname], + limit: ^limit + ) + |> Repo.all() + end + + def get_zone_top_names(_, _), do: [] + + def get_zone_top_nxdomain_names(zone_id, limit \\ 10) + + def get_zone_top_nxdomain_names(zone_id, limit) when is_integer(zone_id) do + flush_query_stats_buffer() + start_date = Date.add(Date.utc_today(), -29) + + from(qs in QueryStat, + where: qs.zone_id == ^zone_id and qs.rcode == "NXDOMAIN" and qs.query_date >= ^start_date, + group_by: qs.qname, + select: %{qname: qs.qname, count: sum(qs.query_count)}, + order_by: [desc: sum(qs.query_count), asc: qs.qname], + limit: ^limit + ) + |> Repo.all() + end + + def get_zone_top_nxdomain_names(_, _), do: [] + + def get_zone_rcode_breakdown(zone_id) when is_integer(zone_id) do + flush_query_stats_buffer() + start_date = Date.add(Date.utc_today(), -29) + + from(qs in QueryStat, + where: qs.zone_id == ^zone_id and qs.query_date >= ^start_date, + group_by: qs.rcode, + select: %{rcode: qs.rcode, count: sum(qs.query_count)}, + order_by: [desc: sum(qs.query_count), asc: qs.rcode] + ) + |> Repo.all() + end + + def get_zone_rcode_breakdown(_), do: [] + + def get_zone_transport_breakdown(zone_id) when is_integer(zone_id) do + flush_query_stats_buffer() + start_date = Date.add(Date.utc_today(), -29) + + from(qs in QueryStat, + where: qs.zone_id == ^zone_id and qs.query_date >= ^start_date, + group_by: qs.transport, + select: %{transport: qs.transport, count: sum(qs.query_count)}, + order_by: [desc: sum(qs.query_count), asc: qs.transport] + ) + |> Repo.all() + end + + def get_zone_transport_breakdown(_), do: [] + + def max_udp_payload do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:max_udp_payload, 1232) + end + + def rate_limit_window_ms do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:rate_limit_window_ms, 1_000) + end + + def udp_rate_limit_per_window do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:udp_rate_limit_per_window, 200) + end + + def tcp_rate_limit_per_window do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:tcp_rate_limit_per_window, 50) + end + + def udp_max_inflight do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:udp_max_inflight, 1024) + end + + def tcp_max_inflight do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:tcp_max_inflight, 256) + end + + def recursive_transport do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:recursive_transport, Elektrine.DNS.RecursiveTransport) + end + + def recursive_max_upstream_queries do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:recursive_max_upstream_queries, 32) + end + + def dns_resolver do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:dns_resolver, :inet_res) + end + + def recursive_allow_cidrs do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:recursive_allow_cidrs, [ + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.64.0.0/10", + "::1/128", + "fc00::/7" + ]) + end + + def public_hostname?(hostname) when is_binary(hostname) do + normalized = + hostname + |> String.trim() + |> String.trim_trailing(".") + |> String.downcase() + + normalized != "" and + String.contains?(normalized, ".") and + valid_hostname_labels?(normalized) and + not String.contains?(normalized, ["/", "@", " "]) and + not ip_literal?(normalized) and + not restricted_hostname?(normalized) + end + + def public_hostname?(_), do: false + + def udp_port do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:udp_port, 5300) + end + + def tcp_port do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:tcp_port, 5300) + end + + def soa_rname do + case Application.get_env(:elektrine, :dns, []) |> Keyword.get(:soa_rname) do + value when is_binary(value) and value != "" -> value + _ -> derive_soa_rname() + end + end + + defp derive_nameservers do + case primary_domain() do + nil -> ["ns1.example.com", "ns2.example.com"] + domain -> ["ns1.#{domain}", "ns2.#{domain}"] + end + end + + defp derive_soa_rname do + case primary_domain() do + nil -> "admin.example.com" + domain -> "admin.#{domain}" + end + end + + defp primary_domain do + Application.get_env(:elektrine, :primary_domain) + end + + defp nil_or_blank?(nil), do: true + defp nil_or_blank?(value) when is_binary(value), do: not Elektrine.Strings.present?(value) + defp nil_or_blank?(_), do: false + + defp restricted_hostname?(hostname) do + hostname in ["localhost", "invalid", "local", "localdomain"] or + String.ends_with?(hostname, ".localhost") or + String.ends_with?(hostname, ".local") or + String.ends_with?(hostname, ".internal") or + String.ends_with?(hostname, ".home.arpa") or + String.ends_with?(hostname, ".localdomain") or + String.ends_with?(hostname, ".test") or + String.ends_with?(hostname, ".invalid") or + String.ends_with?(hostname, ".example") or + String.ends_with?(hostname, ".example.com") or + String.ends_with?(hostname, ".example.net") or + String.ends_with?(hostname, ".example.org") or + String.ends_with?(hostname, ".in-addr.arpa") or + String.ends_with?(hostname, ".ip6.arpa") + end + + defp valid_hostname_labels?(hostname) do + hostname + |> String.split(".", trim: true) + |> Enum.all?(fn label -> + label != "" and + String.length(label) <= 63 and + Regex.match?(~r/^[a-z0-9-]+$/, label) and + not String.starts_with?(label, "-") and + not String.ends_with?(label, "-") + end) + end + + defp ip_literal?(value) do + case :inet.parse_address(String.to_charlist(value)) do + {:ok, _ip} -> true + {:error, _reason} -> false + end + end + + defp verify_nameservers(%Zone{domain: domain} = zone) do + if public_hostname?(domain) do + expected = zone |> assigned_nameservers() |> Enum.map(&normalize_hostname/1) |> Enum.sort() + + case delegated_nameserver_data(domain) do + {:ok, %{nameservers: resolved, endpoints: endpoints}} -> + resolved = Enum.sort(resolved) + + if expected == resolved do + verify_authoritative_nameservers(domain, endpoints) + else + {:error, delegation_mismatch_message(expected, resolved)} + end + + {:error, reason} -> + {:error, "NS lookup failed for #{domain}: #{reason}"} + end + else + {:error, "Zone verification only supports public DNS domains"} + end + rescue + error -> {:error, "NS lookup failed for #{domain}: #{inspect(error)}"} + end + + defp verify_authoritative_nameservers(_domain, endpoints) do + case endpoints do + [] -> + {:error, + "Delegation matches the configured nameservers, but no usable A/AAAA records were found for them."} + + _endpoints -> + :ok + end + end + + defp delegated_nameserver_data(domain) do + with {:ok, tld_endpoints} <- tld_nameserver_endpoints(domain), + {:ok, response} <- query_nameserver_group(domain, :ns, tld_endpoints), + {:ok, nameservers} <- extract_delegated_nameservers(response, domain) do + endpoints = extract_nameserver_endpoints(response, nameservers) + + {:ok, + %{ + nameservers: nameservers, + endpoints: + case endpoints do + [] -> fallback_nameserver_endpoints(nameservers) + values -> values + end + }} + end + end + + defp tld_nameserver_endpoints(domain) do + case tld_domain(domain) do + nil -> + {:error, "could not derive the parent zone"} + + tld -> + with {:ok, response} <- query_nameserver_group(tld, :ns, recursive_root_hints()), + {:ok, nameservers} <- extract_delegated_nameservers(response, tld) do + endpoints = extract_nameserver_endpoints(response, nameservers) + + if endpoints == [] do + {:error, "no usable parent nameserver glue was returned for #{tld}"} + else + {:ok, endpoints} + end + end + end + end + + defp query_nameserver_group(qname, qtype, endpoints) do + query = %{id: 1, rd: 0, qname: qname, qtype: qtype, udp_size: max_udp_payload()} + packet = Packet.encode_query(query) + + endpoints + |> Enum.reduce_while([], fn {ip, port}, errors -> + case recursive_transport().exchange_udp(ip, port, packet, recursive_timeout()) do + {:ok, response} -> + {:halt, {:ok, response}} + + {:error, reason} -> + {:cont, [{ip, port, {:error, format_dns_exchange_error(reason)}} | errors]} + end + end) + |> case do + {:ok, response} -> + {:ok, response} + + errors when is_list(errors) -> + {:error, format_endpoint_attempt_errors(Enum.reverse(errors))} + end + end + + defp extract_delegated_nameservers(response, domain) do + normalized_domain = normalize_hostname(domain) + + case :inet_dns.decode(response) do + {:ok, {:dns_rec, _header, _qd, answers, authority, _additional}} -> + nameservers = + (answers ++ authority) + |> Enum.filter(fn answer -> + elem(answer, 2) in [2, :ns] and + normalize_hostname(elem(answer, 1)) == normalized_domain + end) + |> Enum.map(fn answer -> normalize_hostname(elem(answer, 6)) end) + |> Enum.uniq() + + {:ok, nameservers} + + _ -> + {:error, "received an invalid DNS response"} + end + end + + defp extract_nameserver_endpoints(response, nameservers) do + nameserver_set = MapSet.new(nameservers) + + case :inet_dns.decode(response) do + {:ok, {:dns_rec, _header, _qd, _answers, _authority, additional}} -> + additional + |> Enum.filter(fn answer -> + normalize_hostname(elem(answer, 1)) in nameserver_set and + elem(answer, 2) in [1, 28, :a, :aaaa] + end) + |> Enum.map(fn answer -> {parse_rr_ip(answer), 53} end) + |> Enum.reject(fn {ip, _port} -> is_nil(ip) end) + |> Enum.uniq() + + _ -> + [] + end + end + + defp fallback_nameserver_endpoints(nameservers) do + nameservers + |> Enum.flat_map(fn nameserver -> + lookup_dns_values(nameserver, :a, timeout: 5_000) ++ + lookup_dns_values(nameserver, :aaaa, timeout: 5_000) + end) + |> Enum.map(&parse_ip/1) + |> Enum.reject(&is_nil/1) + |> Enum.map(&{&1, 53}) + |> Enum.uniq() + end + + defp format_dns_exchange_error(:timeout), do: "query timed out" + + defp format_dns_exchange_error(:unexpected_upstream), + do: "received a reply from an unexpected upstream" + + defp format_dns_exchange_error(:eafnosupport), + do: "address family not supported by this runtime" + + defp format_dns_exchange_error(reason), do: to_string(reason) + + defp format_endpoint_attempt_errors([]), do: "query timed out" + + defp format_endpoint_attempt_errors(attempts) do + reasons = + attempts + |> Enum.map(fn {_ip, _port, {:error, reason}} -> reason end) + |> Enum.uniq() + + case reasons do + [reason] -> + reason + + _ -> + Enum.map_join(attempts, "; ", fn {ip, port, {:error, reason}} -> + "#{format_dns_endpoint(ip, port)} #{reason}" + end) + end + end + + defp format_dns_endpoint(ip, port) do + host = + ip + |> :inet.ntoa() + |> to_string() + + host = + if tuple_size(ip) == 8 do + "[#{host}]" + else + host + end + + if port == 53, do: host <> ":", else: "#{host}:#{port}:" + end + + defp delegation_mismatch_message(expected, resolved) do + "Delegation mismatch for the configured nameservers. Expected: #{format_nameserver_list(expected)}. Observed: #{format_nameserver_list(resolved)}." + end + + defp format_nameserver_list([]), do: "none" + defp format_nameserver_list(nameservers), do: Enum.join(nameservers, ", ") + + defp lookup_dns_values(domain, type, opts) when is_binary(domain) do + domain + |> String.to_charlist() + |> dns_resolver().lookup(:in, type, opts) + |> Enum.map(&normalize_lookup_value(type, &1)) + |> Enum.reject(&nil_or_blank?/1) + |> Enum.uniq() + rescue + _ -> [] + catch + _, _ -> [] + end + + defp normalize_lookup_value(:ns, value), do: normalize_hostname(value) + defp normalize_lookup_value(:cname, value), do: normalize_hostname(value) + defp normalize_lookup_value(:a, {a, b, c, d}), do: Enum.join([a, b, c, d], ".") + + defp normalize_lookup_value(:aaaa, tuple) when is_tuple(tuple) and tuple_size(tuple) == 8 do + tuple + |> Tuple.to_list() + |> Enum.map_join(":", &Integer.to_string(&1, 16)) + end + + defp normalize_lookup_value(:mx, {priority, host}), + do: "#{priority} #{normalize_hostname(host)}" + + defp normalize_lookup_value(_, value) when is_binary(value), do: String.trim(value) + + defp normalize_lookup_value(_, value) when is_list(value), + do: value |> List.to_string() |> String.trim() + + defp normalize_lookup_value(_, value), do: to_string(value) + + defp parse_ip(value) when is_binary(value) do + case :inet.parse_address(String.to_charlist(value)) do + {:ok, ip} -> ip + {:error, _reason} -> nil + end + end + + defp parse_ip(_value), do: nil + + defp parse_rr_ip(answer), do: answer |> elem(6) + + defp assign_nameserver_set_on_create(%Zone{} = zone) do + case nameserver_sets() do + [] -> + zone + + sets -> + nameserver_set = safe_nameserver_set(zone, sets) + + zone + |> Zone.changeset(%{nameserver_set: nameserver_set}) + |> Repo.update!() + end + end + + defp safe_nameserver_set(%Zone{} = zone, sets) do + initial = deterministic_nameserver_set(zone, length(sets)) + observed = observed_delegation_for_assignment(zone.domain) + + if nameserver_set_matches_observed?(Enum.at(sets, initial), observed) do + next_safe_nameserver_set(initial, sets, observed) + else + initial + end + end + + defp next_safe_nameserver_set(initial, sets, observed) do + set_count = length(sets) + + 1..set_count + |> Enum.map(&rem(initial + &1, set_count)) + |> Enum.find(fn index -> + not nameserver_set_matches_observed?(Enum.at(sets, index), observed) + end) + |> case do + nil -> initial + index -> index + end + end + + defp nameserver_set_matches_observed?(_set, []), do: false + + defp nameserver_set_matches_observed?(set, observed) do + set |> Enum.map(&normalize_hostname/1) |> Enum.sort() == observed + end + + defp observed_delegation_for_assignment(domain) do + domain + |> lookup_dns_values(:ns, timeout: 1_500) + |> Enum.map(&normalize_hostname/1) + |> Enum.sort() + end + + defp deterministic_nameserver_set(%Zone{} = zone, set_count) when set_count > 0 do + digest = + :crypto.mac( + :hmac, + :sha256, + nameserver_assignment_secret(), + nameserver_assignment_payload(zone) + ) + + <> = digest + rem(value, set_count) + end + + defp zone_nameserver_set(%Zone{nameserver_set: set}, set_count) + when is_integer(set) and set >= 0 and set_count > 0 do + rem(set, set_count) + end + + defp zone_nameserver_set(%Zone{} = zone, set_count) when set_count > 0 do + deterministic_nameserver_set(zone, set_count) + end + + defp nameserver_assignment_payload(%Zone{} = zone) do + "#{zone.id}:#{zone.user_id}:#{normalize_hostname(zone.domain)}" + end + + defp nameserver_assignment_secret do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:nameserver_assignment_secret) + |> case do + secret when is_binary(secret) and secret != "" -> + secret + + _ -> + endpoint_config = Application.get_env(:elektrine, ElektrineWeb.Endpoint, []) + + endpoint_config[:secret_key_base] || + Elektrine.RuntimeSecrets.secret_key_base() || + "elektrine-dns-development-nameserver-assignment" + end + end + + defp assigned_nameserver_base(host) do + base_nameservers = nameservers() + + nameserver_sets() + |> Enum.find_value(fn set -> + set + |> Enum.find_index(&(normalize_hostname(&1) == host)) + |> case do + nil -> nil + index -> Enum.at(base_nameservers, rem(index, max(length(base_nameservers), 1))) + end + end) + |> case do + nil -> fallback_assigned_nameserver_base(host) + nameserver -> nameserver + end + end + + defp fallback_assigned_nameserver_base(host) do + Enum.find(nameservers(), fn nameserver -> + nameserver = normalize_hostname(nameserver) + String.starts_with?(host, "z") and String.ends_with?(host, "." <> nameserver) + end) + end + + defp nameserver_sets do + configured = + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:nameserver_sets, []) + |> normalize_nameserver_sets() + + case configured do + [] -> default_nameserver_sets() + sets -> sets + end + end + + defp normalize_nameserver_sets(sets) when is_list(sets) do + sets + |> Enum.map(fn set -> + set + |> List.wrap() + |> Enum.map(&normalize_hostname/1) + |> Enum.reject(&nil_or_blank?/1) + end) + |> Enum.filter(&(length(&1) >= 2)) + end + + defp normalize_nameserver_sets(_), do: [] + + defp default_nameserver_sets do + base_nameservers = + nameservers() + |> Enum.map(&normalize_hostname/1) + |> Enum.reject(&nil_or_blank?/1) + + case base_nameservers do + [] -> + [] + + [_single] -> + [] + + base_nameservers -> + Enum.map(@nameserver_label_pairs, fn labels -> + labels + |> Enum.with_index() + |> Enum.map(fn {label, index} -> + "#{label}.#{Enum.at(base_nameservers, rem(index, length(base_nameservers)))}" + end) + end) + end + end + + defp maybe_add_nameserver_address_records( + records, + host, + nameserver, + lookup_type, + record_type, + qtype + ) do + if qtype in [:any, lookup_type] do + nameserver + |> lookup_dns_values(lookup_type, timeout: 5_000) + |> Enum.map(fn address -> + %{host: host, type: record_type, content: address, ttl: default_ttl()} + end) + |> Kernel.++(records) + else + records + end + end + + defp normalize_query_type(type) when type in [:a, :aaaa, :any], do: type + defp normalize_query_type("A"), do: :a + defp normalize_query_type("AAAA"), do: :aaaa + defp normalize_query_type("ANY"), do: :any + defp normalize_query_type(type), do: type + + defp tld_domain(domain) do + domain + |> normalize_hostname() + |> String.split(".", trim: true) + |> List.last() + end + + defp delegated_to_elektrine?(observed_nameservers) do + observed = observed_nameservers |> Enum.map(&normalize_hostname/1) |> Enum.sort() + base_nameservers = nameservers() |> Enum.map(&normalize_hostname/1) |> Enum.sort() + + observed == base_nameservers or + Enum.any?(nameserver_sets(), fn set -> + set |> Enum.map(&normalize_hostname/1) |> Enum.sort() == observed + end) + end + + defp provider_hint([]), do: nil + + defp provider_hint(nameservers) do + joined = Enum.join(nameservers, " ") + + cond do + String.contains?(joined, "awsdns-") -> "Route53" + String.contains?(joined, "digitalocean.com") -> "DigitalOcean" + String.contains?(joined, "domaincontrol.com") -> "GoDaddy" + String.contains?(joined, "squarespacedns.com") -> "Squarespace" + String.contains?(joined, "namecheap.com") -> "Namecheap" + String.contains?(joined, "google.com") -> "Google Cloud DNS" + true -> List.first(nameservers) + end + end + + defp scan_record_entry(_host, _type, []), do: nil + defp scan_record_entry(host, type, values), do: %{host: host, type: type, values: values} + + defp normalize_hostname(value) when is_binary(value), + do: value |> String.trim() |> String.trim_trailing(".") |> String.downcase() + + defp normalize_hostname(value) when is_list(value), + do: value |> List.to_string() |> normalize_hostname() + + defp normalize_hostname(value), do: value |> to_string() |> normalize_hostname() + + defp normalize_service(service) when is_binary(service), do: String.downcase(service) + defp normalize_service(service), do: to_string(service) |> String.downcase() + + defp empty_query_stats do + %{total_queries: 0, queries_today: 0, queries_this_week: 0, nxdomain_queries: 0} + end + + defp normalize_dns_metric_value(value) when is_atom(value), + do: value |> Atom.to_string() |> String.upcase() + + defp normalize_dns_metric_value(value) when is_binary(value), + do: value |> String.trim() |> String.upcase() + + defp normalize_dns_metric_value(value), do: value |> to_string() |> String.upcase() + + defp truncate_to_hour(%DateTime{} = date_time) do + %{date_time | minute: 0, second: 0, microsecond: {0, 0}} + end + + defp normalize_dns_name(value) when is_binary(value) do + value + |> String.trim() + |> String.trim_trailing(".") + |> String.downcase() + end + + defp normalize_dns_name(value), do: value |> to_string() |> normalize_dns_name() + + defp normalize_dns_metric_name(qname, zone_domain, rcode) do + qname = normalize_dns_name(qname) + zone_domain = normalize_dns_name(zone_domain) + qname_labels = String.split(qname, ".", trim: true) + zone_labels = String.split(zone_domain, ".", trim: true) + max_labels = length(zone_labels) + 2 + + cond do + preserve_metric_qname?(qname_labels) -> + qname + + rcode == "NXDOMAIN" and String.ends_with?(qname, "." <> zone_domain) and + length(qname_labels) > max_labels -> + suffix = qname_labels |> Enum.take(-max_labels) |> Enum.join(".") + "*." <> suffix + + rcode == "NXDOMAIN" and String.ends_with?(qname, "." <> zone_domain) -> + "*." <> zone_domain + + String.ends_with?(qname, "." <> zone_domain) and length(qname_labels) > max_labels -> + suffix = qname_labels |> Enum.take(-max_labels) |> Enum.join(".") + "*." <> suffix + + true -> + qname + end + end + + defp preserve_metric_qname?([label | _]), do: label in ["_acme-challenge", "_atproto"] + defp preserve_metric_qname?(_), do: false + + defp refresh_authority_cache_after_write(result, opts \\ []) + + defp refresh_authority_cache_after_write({:ok, result}, opts) do + result = + if Keyword.get(opts, :touch_zone, false), do: touch_authority_zone(result), else: result + + refresh_authority_cache() + # Refresh the local cache directly (above) and notify other nodes — most + # importantly the separate, unclustered DNS-server node — over Postgres + # LISTEN/NOTIFY so public answers reflect the write within a debounce + # window instead of waiting for the periodic refresh timer. + Elektrine.DNS.ZoneChangeListener.notify() + {:ok, result} + end + + defp refresh_authority_cache_after_write(result, _opts), do: result + + defp maybe_ensure_profile_wildcards_after_zone_write({:ok, %Zone{} = zone} = result) do + profile_base_domains = + Domains.profile_base_domains() + |> Enum.map(&normalize_zone_host/1) + + if normalize_zone_host(zone.domain) in profile_base_domains do + _ = ensure_profile_subdomain_wildcards() + end + + result + end + + defp maybe_ensure_profile_wildcards_after_zone_write(result), do: result + + defp touch_authority_zone(%Record{zone_id: zone_id} = record) do + _ = touch_zone_publication(zone_id) + record + end + + defp touch_authority_zone(%Zone{id: zone_id} = zone) do + case touch_zone_publication(zone_id) do + %Zone{} = updated -> Repo.preload(updated, [:records, :service_configs]) + _ -> zone + end + end + + defp touch_authority_zone(%ZoneServiceConfig{zone_id: zone_id} = config) do + _ = touch_zone_publication(zone_id) + config + end + + defp touch_authority_zone(result), do: result + + defp touch_zone_publication(zone_id) when is_integer(zone_id) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + Zone + |> where([z], z.id == ^zone_id) + |> select([z], z) + |> Repo.update_all(inc: [serial: 1], set: [last_published_at: now]) + |> case do + {1, [zone]} -> zone + _ -> nil + end + rescue + _ -> nil + end + + defp touch_zone_publication(_), do: nil + + defp refresh_authority_cache(opts \\ []) do + case Process.whereis(ZoneCache) do + nil -> + :ok + + _pid -> + if Keyword.get(opts, :async, false) do + ZoneCache.refresh_async() + else + case ZoneCache.refresh(caller: self()) do + :ok -> + :ok + + {:error, reason} -> + Logger.warning("DNS zone cache refresh skipped: #{inspect(reason)}") + end + end + end + end + + defp ensure_builtin_user_zone(%{id: user_id}, domain) when is_integer(user_id) do + zone = + Zone + |> where([z], z.user_id == ^user_id and fragment("lower(?)", z.domain) == ^domain) + |> preload([:records, :service_configs]) + |> Repo.one() + + case zone do + %Zone{} = existing -> + case ensure_builtin_user_zone_records(existing, refresh_cache?: false) do + {:ok, zone} -> + refresh_authority_cache(async: true) + {:ok, zone} + + other -> + other + end + + nil -> + now = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.transaction(fn -> + zone = + %Zone{} + |> Zone.changeset(%{ + domain: domain, + user_id: user_id, + status: "verified", + kind: "native", + default_ttl: default_ttl(), + force_https: false, + soa_mname: List.first(nameservers()), + soa_rname: soa_rname(), + soa_minimum: default_ttl(), + verified_at: now, + last_checked_at: now + }) + |> Repo.insert!() + + zone + |> Repo.preload([:records, :service_configs]) + |> ensure_builtin_user_zone_records!() + end) + |> case do + {:ok, zone} -> + refresh_authority_cache(async: true) + {:ok, zone} + + other -> + other + end + end + end + + defp ensure_builtin_user_zone_records(%Zone{} = zone, opts \\ []) do + zone = Repo.preload(zone, [:records, :service_configs], force: true) + + if builtin_user_zone_records_current?(zone) do + {:ok, zone} + else + Repo.transaction(fn -> + zone + |> Repo.preload([:records, :service_configs], force: true) + |> ensure_builtin_user_zone_records!() + end) + |> case do + {:ok, zone} -> + if Keyword.get(opts, :refresh_cache?, true) do + refresh_authority_cache() + end + + {:ok, zone} + + other -> + other + end + end + end + + defp ensure_builtin_user_zone_records!(%Zone{} = zone) do + zone = reconcile_builtin_zone_apex_record(zone) + + if zone.status != "verified" or is_nil(zone.verified_at) do + zone + |> Zone.changeset(%{ + status: "verified", + verified_at: zone.verified_at || DateTime.utc_now() |> DateTime.truncate(:second), + last_checked_at: DateTime.utc_now() |> DateTime.truncate(:second), + last_error: nil + }) + |> Repo.update!() + |> Repo.preload([:records, :service_configs]) + else + zone + end + end + + defp reconcile_builtin_zone_apex_record(%Zone{} = zone) do + if builtin_user_zone_hosted_by_platform?(zone) do + maybe_upsert_builtin_zone_apex_record(zone) + else + delete_builtin_zone_apex_record(zone) + end + end + + defp maybe_upsert_builtin_zone_apex_record(%Zone{} = zone) do + attrs = %{ + zone_id: zone.id, + name: "@", + type: "ALIAS", + ttl: default_ttl(), + content: Domains.profile_custom_domain_routing_target(), + source: "system", + service: @builtin_user_zone_managed_service, + managed: true, + managed_key: @builtin_user_zone_apex_managed_key, + required: true, + metadata: %{"label" => "Built-in profile routing"} + } + + case Repo.get_by(Record, zone_id: zone.id, managed_key: @builtin_user_zone_apex_managed_key) do + %Record{} = record -> + if builtin_zone_apex_record_matches?(record, attrs) do + :ok + else + record + |> Record.changeset(attrs) + |> Repo.insert_or_update!() + end + + nil -> + %Record{} + |> Record.changeset(attrs) + |> Repo.insert!() + end + + Repo.preload(zone, [:records, :service_configs], force: true) + end + + defp delete_builtin_zone_apex_record(%Zone{} = zone) do + case Repo.get_by(Record, zone_id: zone.id, managed_key: @builtin_user_zone_apex_managed_key) do + %Record{} = record -> Repo.delete!(record) + nil -> :ok + end + + Repo.preload(zone, [:records, :service_configs], force: true) + end + + defp builtin_user_zone_records_current?(%Zone{} = zone) do + zone.status == "verified" and not is_nil(zone.verified_at) and + builtin_user_zone_apex_state_current?(zone) + end + + defp builtin_user_zone_apex_state_current?(%Zone{} = zone) do + has_managed_apex? = Enum.any?(zone.records, &builtin_zone_apex_record_matches?(&1)) + + if builtin_user_zone_hosted_by_platform?(zone), + do: has_managed_apex?, + else: not has_managed_apex? + end + + defp builtin_zone_apex_record_matches?(%Record{} = record) do + builtin_zone_apex_record_matches?(record, %{ + name: "@", + type: "ALIAS", + content: Domains.profile_custom_domain_routing_target(), + source: "system", + service: @builtin_user_zone_managed_service, + managed: true, + managed_key: @builtin_user_zone_apex_managed_key, + required: true + }) + end + + defp builtin_zone_apex_record_matches?(%Record{} = record, attrs) do + record.name == attrs.name and record.type == attrs.type and record.content == attrs.content and + record.source == attrs.source and record.service == attrs.service and + record.managed == attrs.managed and record.managed_key == attrs.managed_key and + record.required == attrs.required + end + + defp create_zone_attrs(attrs, user_id) do + attrs + |> public_zone_attrs() + |> Map.put("user_id", user_id) + end + + defp public_zone_attrs(attrs) do + Map.take(attrs, [ + "domain", + "kind", + "default_ttl", + "force_https", + "soa_mname", + "soa_rname", + "soa_refresh", + "soa_retry", + "soa_expire", + "soa_minimum" + ]) + end + + defp update_zone_verification(%Zone{} = zone, attrs) when is_map(attrs) do + zone + |> Zone.changeset(attrs) + |> Repo.update() + |> case do + {:ok, zone} -> {:ok, Repo.preload(zone, [:records, :service_configs])} + error -> error + end + |> refresh_authority_cache_after_write() + end + + defp public_record_attrs(attrs) do + Map.drop(attrs, ["zone_id", :zone_id]) + end + + defp normalize_record_attrs(attrs, nil), do: attrs + + defp normalize_record_attrs(attrs, zone_domain) when is_map(attrs) do + case Map.fetch(attrs, "name") do + {:ok, name} -> Map.put(attrs, "name", normalize_record_name(name, zone_domain)) + :error -> attrs + end + end + + defp normalize_record_name(name, zone_domain) when is_binary(name) do + normalized_name = + name + |> String.trim() + |> String.trim_trailing(".") + |> String.downcase() + |> case do + "\\@" -> "@" + value -> value + end + + normalized_zone = + zone_domain |> String.trim() |> String.trim_trailing(".") |> String.downcase() + + cond do + normalized_name in ["", "@"] -> + "@" + + normalized_name == normalized_zone -> + "@" + + String.ends_with?(normalized_name, "." <> normalized_zone) -> + normalized_name + |> String.trim_trailing("." <> normalized_zone) + |> case do + "" -> "@" + relative -> relative + end + + true -> + normalized_name + end + end + + defp normalize_record_name(name, _zone_domain), do: name + + defp validate_zone_record_write(%Zone{} = zone, attrs) when is_map(attrs) do + with :ok <- validate_cname_exclusivity(zone, attrs) do + if builtin_user_zone?(zone) and builtin_user_zone_hosted_by_platform?(zone) do + validate_builtin_zone_record_write(zone, attrs) + else + :ok + end + end + end + + defp validate_zone_record_write(_, _), do: :ok + + defp validate_cname_exclusivity(zone, attrs) do + name = Map.get(attrs, "name", "@") |> normalize_record_name(zone.domain) + type = attrs |> Map.get("type") |> normalize_record_type() + + records = + Record + |> where([r], r.zone_id == ^zone.id and r.name == ^name) + |> Repo.all() + + cond do + type == "CNAME" and Enum.any?(records, &(&1.type != "CNAME")) -> + {:error, + add_error(change(%Record{}), :type, "cannot coexist with other records at the same name")} + + type != "CNAME" and Enum.any?(records, &(&1.type == "CNAME")) -> + {:error, add_error(change(%Record{}), :name, "already has a CNAME record")} + + true -> + :ok + end + end + + defp validate_builtin_zone_record_write(zone, attrs) do + name = Map.get(attrs, "name", "@") |> normalize_record_name(zone.domain) + type = attrs |> Map.get("type") |> normalize_record_type() + + cond do + type in @builtin_user_zone_forbidden_types -> + {:error, + add_error(change(%Record{}), :type, "is reserved on built-in profile subdomains")} + + name == "@" and type not in @builtin_user_zone_allowed_apex_types -> + {:error, + add_error( + change(%Record{}), + :name, + "the apex host is reserved for Elektrine profile routing; only TXT and CAA are allowed there" + )} + + true -> + :ok + end + end + + defp validate_record_mutation(%Record{} = record, action) do + if action == :delete and protected_builtin_zone_record?(record) do + {:error, add_error(change(record), :name, "is managed by Elektrine and cannot be deleted")} + else + :ok + end + end + + defp protected_builtin_zone_record?(%Record{ + managed: true, + required: true, + managed_key: managed_key + }) + when managed_key == @builtin_user_zone_apex_managed_key, + do: true + + defp protected_builtin_zone_record?(_), do: false + + defp record_write_attrs(%Record{} = record) do + %{ + "name" => record.name, + "type" => record.type, + "ttl" => record.ttl, + "content" => record.content, + "priority" => record.priority, + "weight" => record.weight, + "port" => record.port, + "flags" => record.flags, + "tag" => record.tag, + "protocol" => record.protocol, + "algorithm" => record.algorithm, + "key_tag" => record.key_tag, + "digest_type" => record.digest_type, + "usage" => record.usage, + "selector" => record.selector, + "matching_type" => record.matching_type + } + end + + defp proxied_zone_for_host(host) do + host + |> candidate_zone_domains() + |> Enum.find_value(fn domain -> + Zone + |> where([z], z.status == "verified" and fragment("lower(?)", z.domain) == ^domain) + |> preload(:records) + |> Repo.one() + end) + end + + defp proxied_record_for_host(%Zone{} = zone, host) do + zone.records + |> Enum.filter(&(Record.proxied?(&1) and record_fqdn(zone, &1) == host)) + |> Enum.sort_by(&proxy_record_priority/1) + |> List.first() + end + + defp proxied_origin(%Zone{} = zone, %Record{} = record, host) do + scheme = Record.proxy_origin_scheme(record) + port = Record.proxy_origin_port(record) + + origin_host = + record.content |> String.trim() |> String.trim_trailing(".") |> String.downcase() + + host_header = Record.proxy_origin_host_header(record) || host + + %{ + zone_id: zone.id, + zone_domain: zone.domain, + record_id: record.id, + host: host, + origin_scheme: scheme, + origin_host: origin_host, + origin_port: port, + origin_url: origin_url(scheme, origin_host, port), + origin_host_header: host_header, + atomine_gate: Record.proxy_atomine_gate?(record) + } + end + + defp origin_url(scheme, host, port) do + default_port = if scheme == "http", do: 80, else: 443 + port_suffix = if port == default_port, do: "", else: ":#{port}" + scheme <> "://" <> host <> port_suffix + end + + defp proxy_record_priority(%Record{type: "A"}), do: 0 + defp proxy_record_priority(%Record{type: "AAAA"}), do: 1 + defp proxy_record_priority(%Record{type: "CNAME"}), do: 2 + defp proxy_record_priority(%Record{type: "ALIAS"}), do: 3 + defp proxy_record_priority(_), do: 4 + + defp record_fqdn(%Zone{} = zone, %Record{name: name}) when is_binary(name) do + zone_domain = normalize_proxy_host(zone.domain) + normalized_name = normalize_proxy_host(name) + + cond do + normalized_name in ["", "@"] -> zone_domain + normalized_name == zone_domain -> zone_domain + String.ends_with?(normalized_name, "." <> zone_domain) -> normalized_name + true -> normalized_name <> "." <> zone_domain + end + end + + defp candidate_zone_domains(host) do + host + |> String.split(".", trim: true) + |> case do + [] -> [] + labels -> Enum.map(0..(length(labels) - 1), &(labels |> Enum.drop(&1) |> Enum.join("."))) + end + end + + defp normalize_proxy_host(host) do + host + |> String.trim() + |> String.trim_trailing(".") + |> String.downcase() + |> String.split(":", parts: 2) + |> List.first() + end + + defp normalize_record_type(nil), do: nil + + defp normalize_record_type(type) when is_binary(type), + do: type |> String.trim() |> String.upcase() + + defp normalize_record_type(type), do: type |> to_string() |> normalize_record_type() + + defp sort_user_zones(zones, nil), do: zones + + defp sort_user_zones(zones, builtin_domain) do + Enum.sort_by(zones, fn zone -> + {zone.domain != builtin_domain, zone.domain} + end) + end + + defp validate_platform_handoff(user) do + if builtin_user_zone_hosted_by_platform?(user) do + case get_zone_by_domain(builtin_user_zone_domain(user)) do + %Zone{} = zone -> + zone = Repo.preload(zone, :records, force: true) + + case Enum.reject(zone.records, &apex_record_allowed_when_platform_hosted?/1) do + [] -> + :ok + + conflicts -> + names = Enum.map_join(conflicts, ", ", &record_conflict_label/1) + + {:error, + add_error( + change(user), + :built_in_subdomain_mode, + "cannot switch back to platform hosting until apex records are removed: #{names}" + )} + end + + _ -> + :ok + end + else + :ok + end + end + + defp apex_record_allowed_when_platform_hosted?(%Record{} = record) do + record.name != "@" or record.managed_key == @builtin_user_zone_apex_managed_key or + record.type in @builtin_user_zone_allowed_apex_types + end + + defp record_conflict_label(%Record{} = record), do: "#{record.name} #{record.type}" + + defp user_schema?(%{__struct__: @user_schema, id: id}) when is_integer(id), do: true + + defp user_schema?(_), do: false + + defp zone_domain(zone_id) when is_integer(zone_id) do + case Repo.get(Zone, zone_id) do + %Zone{domain: domain} -> domain + _ -> nil + end + end + + defp zone_domain(_), do: nil +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/application.ex b/apps/elektrine_dns/lib/elektrine/dns/application.ex new file mode 100644 index 0000000..60c398d --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/application.ex @@ -0,0 +1,36 @@ +defmodule Elektrine.DNS.Application do + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = + [ + Elektrine.DNS.RecursiveCache, + Elektrine.DNS.RequestGuard, + Elektrine.DNS.QueryStatsBuffer, + {Task.Supervisor, name: Elektrine.DNS.TaskSupervisor} + ] ++ authority_children() + + Supervisor.start_link(children, strategy: :one_for_one, name: Elektrine.DNS.Supervisor) + end + + defp authority_children do + if Elektrine.DNS.authority_enabled?() do + [ + Elektrine.DNS.ZoneCache, + Elektrine.DNS.ZoneChangeListener, + Elektrine.DNS.HealthMonitor, + Elektrine.DNS.ProfileWildcardBootstrap, + Elektrine.DNS.Authority, + {Elektrine.DNS.UDPServer, name: Elektrine.DNS.UDPServer, family: :inet}, + {Elektrine.DNS.UDPServer, name: Elektrine.DNS.UDPServerV6, family: :inet6}, + {Elektrine.DNS.TCPServer, name: Elektrine.DNS.TCPServer, family: :inet}, + {Elektrine.DNS.TCPServer, name: Elektrine.DNS.TCPServerV6, family: :inet6} + ] + else + [] + end + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/authority.ex b/apps/elektrine_dns/lib/elektrine/dns/authority.ex new file mode 100644 index 0000000..4c65170 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/authority.ex @@ -0,0 +1,26 @@ +defmodule Elektrine.DNS.Authority do + @moduledoc """ + Initial supervisor-facing process for Elektrine's authoritative DNS runtime. + + This intentionally starts as a lightweight Elixir-owned process so the zone + model, query pipeline, and release wiring can land before UDP/TCP serving is + implemented. + """ + + use GenServer + + require Logger + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(opts) do + Logger.info( + "Elektrine DNS authority starting on udp=#{Elektrine.DNS.udp_port()} tcp=#{Elektrine.DNS.tcp_port()} opts=#{inspect(opts)}" + ) + + {:ok, %{udp_port: Elektrine.DNS.udp_port(), tcp_port: Elektrine.DNS.tcp_port()}} + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/domain_health.ex b/apps/elektrine_dns/lib/elektrine/dns/domain_health.ex new file mode 100644 index 0000000..f353030 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/domain_health.ex @@ -0,0 +1,352 @@ +defmodule Elektrine.DNS.DomainHealth do + @moduledoc """ + Local domain posture checks for a managed DNS zone. + + These checks intentionally use the records stored in Elektrine. Delegation and + live delivery signals are represented as review items when they need external + network or reputation-provider confirmation. + """ + + alias Elektrine.DNS.Zone + + @statuses [:ok, :review, :warning, :missing] + + def analyze(%Zone{} = zone) do + records = List.wrap(zone.records) + checks = checks(zone, records) + + %{ + domain: zone.domain, + status: overall_status(checks), + score: score(checks), + summary: summary(checks), + checks: checks, + recommendations: recommendations(checks) + } + end + + def analyze(_), do: empty() + + defp empty do + %{ + domain: nil, + status: :missing, + score: 0, + summary: "No zone selected.", + checks: [], + recommendations: [] + } + end + + defp checks(zone, records) do + [ + delegation_check(zone), + mx_check(records), + spf_check(records), + dkim_check(records), + dmarc_check(records), + caa_check(records), + dnssec_check(records), + mta_sts_check(records), + tls_rpt_check(records), + tls_certificate_check(zone, records) + ] + end + + defp delegation_check(%Zone{status: "verified"}) do + check(:dns, "Nameserver delegation", :ok, "Zone delegation is verified.", nil) + end + + defp delegation_check(%Zone{last_error: last_error}) + when is_binary(last_error) and last_error != "" do + check( + :dns, + "Nameserver delegation", + :warning, + last_error, + "Point the domain's NS records at the Elektrine nameservers, then verify the zone again." + ) + end + + defp delegation_check(_zone) do + check( + :dns, + "Nameserver delegation", + :review, + "Zone delegation has not been verified yet.", + "Verify the zone after updating registrar nameservers." + ) + end + + defp mx_check(records) do + case records_of(records, "MX", "@") do + [] -> + check( + :mail, + "MX records", + :missing, + "No apex MX record is configured.", + "Add an MX record that points to the mail host for this domain." + ) + + mx_records -> + targets = Enum.map_join(mx_records, ", ", & &1.content) + check(:mail, "MX records", :ok, "Mail routes to #{targets}.", nil) + end + end + + defp spf_check(records) do + spf = txt_record(records, "@", "v=spf1") + + cond do + is_nil(spf) -> + check( + :mail, + "SPF policy", + :missing, + "No SPF TXT record was found at the apex.", + "Add an SPF record such as `v=spf1 mx -all`." + ) + + String.contains?(String.downcase(spf.content), [" -all", "~all"]) -> + check( + :mail, + "SPF policy", + :ok, + "SPF policy is present and has an explicit all mechanism.", + nil + ) + + true -> + check( + :mail, + "SPF policy", + :review, + "SPF exists but does not end with `-all` or `~all`.", + "Tighten the SPF policy once all legitimate senders are listed." + ) + end + end + + defp dkim_check(records) do + dkim = + Enum.find(records, fn record -> + record.type == "TXT" and String.contains?(record.name || "", "_domainkey") and + contains_ci?(record.content, "v=DKIM1") + end) + + if dkim do + check(:mail, "DKIM key", :ok, "DKIM TXT key is published at #{dkim.name}.", nil) + else + check( + :mail, + "DKIM key", + :missing, + "No DKIM TXT key was found.", + "Enable the managed email template or publish a selector under `._domainkey`." + ) + end + end + + defp dmarc_check(records) do + dmarc = txt_record(records, "_dmarc", "v=DMARC1") + + cond do + is_nil(dmarc) -> + check( + :mail, + "DMARC policy", + :missing, + "No DMARC TXT record was found.", + "Add `_dmarc` with `v=DMARC1; p=quarantine` or stronger." + ) + + contains_ci?(dmarc.content, "p=reject") or contains_ci?(dmarc.content, "p=quarantine") -> + check(:mail, "DMARC policy", :ok, "DMARC is enforcing with quarantine or reject.", nil) + + contains_ci?(dmarc.content, "p=none") -> + check( + :mail, + "DMARC policy", + :review, + "DMARC is monitoring only with `p=none`.", + "Move to `p=quarantine` or `p=reject` after reviewing reports." + ) + + true -> + check( + :mail, + "DMARC policy", + :review, + "DMARC exists but the policy could not be classified.", + "Review the DMARC TXT value for a valid `p=` policy." + ) + end + end + + defp caa_check(records) do + case records_of(records, "CAA", "@") do + [] -> + check( + :tls, + "CAA records", + :review, + "No CAA records restrict certificate issuance.", + "Add CAA records for the certificate authorities you use." + ) + + caa_records -> + issuers = Enum.map_join(caa_records, ", ", & &1.content) + check(:tls, "CAA records", :ok, "CAA policy is published: #{issuers}.", nil) + end + end + + defp dnssec_check(records) do + if Enum.any?(records, &(&1.type in ["DS", "DNSKEY"])) do + check(:dns, "DNSSEC material", :ok, "DNSSEC DS or DNSKEY material is present.", nil) + else + check( + :dns, + "DNSSEC material", + :review, + "No DNSSEC DS or DNSKEY records are tracked in this zone.", + "Enable DNSSEC at the registrar/provider when supported." + ) + end + end + + defp mta_sts_check(records) do + if txt_record(records, "_mta-sts", "v=STSv1") do + check(:mail, "MTA-STS policy", :ok, "MTA-STS TXT policy marker is published.", nil) + else + check( + :mail, + "MTA-STS policy", + :review, + "No `_mta-sts` TXT marker was found.", + "Publish MTA-STS records when HTTPS policy hosting is available." + ) + end + end + + defp tls_rpt_check(records) do + if txt_record(records, "_smtp._tls", "v=TLSRPTv1") do + check(:mail, "SMTP TLS reports", :ok, "TLS-RPT reporting is configured.", nil) + else + check( + :mail, + "SMTP TLS reports", + :review, + "No SMTP TLS-RPT record was found.", + "Add `_smtp._tls` with a `rua=mailto:` destination." + ) + end + end + + defp tls_certificate_check(%Zone{force_https: true}, records) do + if web_records?(records) do + check( + :tls, + "TLS certificate", + :ok, + "HTTPS is required for this zone and web host records are present.", + nil + ) + else + check( + :tls, + "TLS certificate", + :review, + "HTTPS is required, but no apex or `www` web records are present.", + "Point the domain at a web host that can issue a certificate." + ) + end + end + + defp tls_certificate_check(_zone, records) do + if web_records?(records) do + check( + :tls, + "TLS certificate", + :review, + "Web host records exist, but live certificate expiry is not monitored yet.", + "Verify the HTTPS certificate for apex and `www`, or enable force HTTPS once certificates are issued." + ) + else + check( + :tls, + "TLS certificate", + :review, + "No apex or `www` web records were found.", + "Add web records before expecting a browser TLS certificate." + ) + end + end + + defp check(category, label, status, detail, fix) when status in @statuses do + %{category: category, label: label, status: status, detail: detail, fix: fix} + end + + defp records_of(records, type, name) do + Enum.filter(records, &(&1.type == type and normalize_name(&1.name) == normalize_name(name))) + end + + defp txt_record(records, name, prefix) do + Enum.find(records, fn record -> + record.type == "TXT" and normalize_name(record.name) == normalize_name(name) and + contains_ci?(record.content, prefix) + end) + end + + defp web_records?(records) do + Enum.any?(records, fn record -> + record.type in ["A", "AAAA", "ALIAS", "CNAME", "HTTPS"] and + normalize_name(record.name) in ["@", "www"] + end) + end + + defp contains_ci?(value, needle) when is_binary(value) do + String.contains?(String.downcase(value), String.downcase(needle)) + end + + defp contains_ci?(_, _), do: false + + defp normalize_name(nil), do: "" + + defp normalize_name(name), + do: name |> String.trim() |> String.trim_trailing(".") |> String.downcase() + + defp overall_status(checks) do + cond do + Enum.any?(checks, &(&1.status == :missing)) -> :missing + Enum.any?(checks, &(&1.status == :warning)) -> :warning + Enum.any?(checks, &(&1.status == :review)) -> :review + true -> :ok + end + end + + defp score(checks) do + earned = Enum.sum(Enum.map(checks, &check_score/1)) + max = length(checks) * 2 + + round(earned / max * 100) + end + + defp check_score(%{status: :ok}), do: 2 + defp check_score(%{status: :review}), do: 1 + defp check_score(_), do: 0 + + defp summary(checks) do + ok = Enum.count(checks, &(&1.status == :ok)) + review = Enum.count(checks, &(&1.status == :review)) + action = Enum.count(checks, &(&1.status in [:missing, :warning])) + + "#{ok} passing, #{review} to review, #{action} need action." + end + + defp recommendations(checks) do + checks + |> Enum.reject(&is_nil(&1.fix)) + |> Enum.map(&%{label: &1.label, fix: &1.fix, status: &1.status}) + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/generators/bluesky.ex b/apps/elektrine_dns/lib/elektrine/dns/generators/bluesky.ex new file mode 100644 index 0000000..f95aa41 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/generators/bluesky.ex @@ -0,0 +1,40 @@ +defmodule Elektrine.DNS.Generators.Bluesky do + @moduledoc false + + def generate(zone, settings \\ %{}) do + ttl = zone.default_ttl || 300 + + [ + %{ + managed_key: "bluesky:host", + name: bluesky_host_label(settings), + type: "CNAME", + ttl: ttl, + content: bluesky_target(zone, settings), + required: false, + metadata: %{"label" => "Bluesky host alias"} + } + ] + end + + defp bluesky_host_label(settings) do + case Map.get(settings, "bluesky_host") do + value when is_binary(value) and value != "" -> normalize(value, "bsky") + _ -> "bsky" + end + end + + defp bluesky_target(zone, settings) do + case Map.get(settings, "bluesky_target") do + value when is_binary(value) and value != "" -> normalize(value, zone.domain) + _ -> zone.domain + end + end + + defp normalize(value, fallback) do + case value |> String.trim() |> String.trim_trailing(".") |> String.downcase() do + "" -> fallback + normalized -> normalized + end + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/generators/mail.ex b/apps/elektrine_dns/lib/elektrine/dns/generators/mail.ex new file mode 100644 index 0000000..48544e1 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/generators/mail.ex @@ -0,0 +1,242 @@ +defmodule Elektrine.DNS.Generators.Mail do + @moduledoc false + + alias Elektrine.DNS.MailSecurity + + def generate(zone, settings \\ %{}) do + domain = zone.domain + ttl = zone.default_ttl || 300 + mail_target = MailSecurity.mail_target(domain, settings) + dmarc_policy = Map.get(settings, "dmarc_policy", "quarantine") + dkim_selector = Map.get(settings, "dkim_selector", "default") + dkim_value = Map.get(settings, "dkim_value", "") + + [ + %{ + managed_key: "mail:mx", + name: "@", + type: "MX", + ttl: ttl, + content: mail_target, + priority: 10, + required: true, + metadata: %{"label" => "Inbound mail exchanger"} + }, + %{ + managed_key: "mail:spf", + name: "@", + type: "TXT", + ttl: ttl, + content: "v=spf1 mx ~all", + required: true, + metadata: %{"label" => "SPF policy"} + }, + %{ + managed_key: "mail:dmarc", + name: "_dmarc", + type: "TXT", + ttl: ttl, + content: "v=DMARC1; p=#{dmarc_policy}; adkim=s; aspf=s", + required: true, + metadata: %{"label" => "DMARC policy"} + }, + %{ + managed_key: "mail:dkim", + name: "#{dkim_selector}._domainkey", + type: "TXT", + ttl: ttl, + content: dkim_value, + required: true, + metadata: %{"label" => "DKIM public key"} + }, + %{ + managed_key: "mail:mta-sts-txt", + name: "_mta-sts", + type: "TXT", + ttl: ttl, + content: MailSecurity.mta_sts_txt_value(domain, settings), + required: false, + metadata: %{"label" => "MTA-STS policy id"} + }, + %{ + managed_key: "mail:tls-rpt", + name: "_smtp._tls", + type: "TXT", + ttl: ttl, + content: MailSecurity.tls_rpt_txt_value(domain, settings), + required: false, + metadata: %{"label" => "TLS-RPT reporting policy"} + } + ] ++ + caa_records(ttl, settings) ++ + tlsa_records(ttl, domain, mail_target, settings) ++ + mail_host_records(zone, mail_target) ++ aliases(domain, ttl, mail_target) + end + + defp caa_records(ttl, settings) do + flags = MailSecurity.caa_flags(settings) + + [ + %{ + managed_key: "mail:caa:issue", + name: "@", + type: "CAA", + ttl: ttl, + content: MailSecurity.caa_issue(settings), + flags: flags, + tag: "issue", + required: false, + metadata: %{"label" => "CAA issue authorization"} + } + ] ++ + optional_caa_record( + "mail:caa:issuewild", + ttl, + flags, + "issuewild", + MailSecurity.caa_issuewild(settings), + "CAA wildcard authorization" + ) ++ + optional_caa_record( + "mail:caa:iodef", + ttl, + flags, + "iodef", + MailSecurity.caa_iodef(settings), + "CAA incident reporting" + ) + end + + defp optional_caa_record(_managed_key, _ttl, _flags, _tag, nil, _label), do: [] + + defp optional_caa_record(managed_key, ttl, flags, tag, content, label) do + [ + %{ + managed_key: managed_key, + name: "@", + type: "CAA", + ttl: ttl, + content: content, + flags: flags, + tag: tag, + required: false, + metadata: %{"label" => label} + } + ] + end + + defp tlsa_records(ttl, domain, mail_target, settings) do + case {same_zone_relative_name(domain, mail_target), + MailSecurity.tlsa_association_data(settings)} do + {nil, _association_data} -> + [] + + {_relative_name, nil} -> + [] + + {relative_name, association_data} -> + [ + %{ + managed_key: "mail:tlsa", + name: tlsa_owner_name(relative_name), + type: "TLSA", + ttl: ttl, + content: association_data, + usage: MailSecurity.tlsa_usage(settings), + selector: MailSecurity.tlsa_selector(settings), + matching_type: MailSecurity.tlsa_matching_type(settings), + required: false, + metadata: %{"label" => "DANE TLSA for SMTP"} + } + ] + end + end + + defp tlsa_owner_name("@"), do: "_25._tcp" + defp tlsa_owner_name(relative_name), do: "_25._tcp." <> relative_name + + defp mail_host_records(zone, mail_target) do + case same_zone_relative_name(zone.domain, mail_target) do + nil -> + [] + + "@" -> + [] + + relative_name -> + zone + |> apex_address_records() + |> Enum.sort_by(&{&1.type, &1.content}) + |> Enum.with_index() + |> Enum.map(fn {record, idx} -> + %{ + managed_key: "mail:host:#{String.downcase(record.type)}:#{idx}", + name: relative_name, + type: record.type, + ttl: record.ttl, + content: record.content, + required: true, + metadata: %{"label" => "Mail exchanger address"} + } + end) + end + end + + defp aliases(domain, ttl, mail_target) do + mail_target_relative = same_zone_relative_name(domain, mail_target) + + for {key, label, target} <- [ + {"mail", "Mail host alias", mail_target}, + {"imap", "IMAP alias", mail_target}, + {"pop", "POP alias", mail_target}, + {"smtp", "SMTP alias", mail_target}, + {"mta-sts", "MTA-STS policy host", domain}, + {"autoconfig", "Mail autoconfig alias", mail_target}, + {"autodiscover", "Mail autodiscover alias", mail_target} + ], + mail_target_relative != key do + %{ + managed_key: "mail:#{key}", + name: key, + type: "CNAME", + ttl: ttl, + content: target, + required: false, + metadata: %{"label" => label} + } + end + end + + defp apex_address_records(zone) do + zone_domain = normalize_name(zone.domain) + + zone.records + |> List.wrap() + |> Enum.filter(fn record -> + record.type in ["A", "AAAA"] and normalize_name(record.name) in ["@", zone_domain] + end) + end + + defp same_zone_relative_name(domain, fqdn) do + normalized_domain = normalize_name(domain) + normalized_fqdn = normalize_name(fqdn) + + cond do + normalized_fqdn == normalized_domain -> + "@" + + String.ends_with?(normalized_fqdn, "." <> normalized_domain) -> + normalized_fqdn + |> String.trim_trailing("." <> normalized_domain) + |> String.trim_trailing(".") + + true -> + nil + end + end + + defp normalize_name(nil), do: nil + + defp normalize_name(name), + do: name |> String.trim() |> String.downcase() |> String.trim_trailing(".") +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/generators/turn.ex b/apps/elektrine_dns/lib/elektrine/dns/generators/turn.ex new file mode 100644 index 0000000..7024e39 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/generators/turn.ex @@ -0,0 +1,40 @@ +defmodule Elektrine.DNS.Generators.Turn do + @moduledoc false + + def generate(zone, settings \\ %{}) do + ttl = zone.default_ttl || 300 + + [ + %{ + managed_key: "turn:host", + name: turn_host_label(settings), + type: "CNAME", + ttl: ttl, + content: turn_target(zone, settings), + required: false, + metadata: %{"label" => "TURN host alias"} + } + ] + end + + defp turn_host_label(settings) do + case Map.get(settings, "turn_host") do + value when is_binary(value) and value != "" -> normalize(value, "turn") + _ -> "turn" + end + end + + defp turn_target(zone, settings) do + case Map.get(settings, "turn_target") do + value when is_binary(value) and value != "" -> normalize(value, zone.domain) + _ -> zone.domain + end + end + + defp normalize(value, fallback) do + case value |> String.trim() |> String.trim_trailing(".") |> String.downcase() do + "" -> fallback + normalized -> normalized + end + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/generators/vpn.ex b/apps/elektrine_dns/lib/elektrine/dns/generators/vpn.ex new file mode 100644 index 0000000..d60e212 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/generators/vpn.ex @@ -0,0 +1,80 @@ +defmodule Elektrine.DNS.Generators.VPN do + @moduledoc false + + def generate(zone, settings \\ %{}) do + ttl = zone.default_ttl || 300 + + primary = %{ + managed_key: "vpn:host", + name: vpn_host_label(settings), + type: "CNAME", + ttl: ttl, + content: vpn_target(zone, settings), + required: false, + metadata: %{"label" => "VPN endpoint alias"} + } + + [primary | maybe_api_record(ttl, zone, settings)] + end + + defp maybe_api_record(ttl, zone, settings) do + case vpn_api_host_label(settings) do + nil -> + [] + + host -> + [ + %{ + managed_key: "vpn:api", + name: host, + type: "CNAME", + ttl: ttl, + content: vpn_api_target(zone, settings), + required: false, + metadata: %{"label" => "VPN admin/API alias"} + } + ] + end + end + + defp vpn_host_label(settings) do + case Map.get(settings, "vpn_host") do + value when is_binary(value) and value != "" -> normalize(value, "vpn") + _ -> "vpn" + end + end + + defp vpn_target(zone, settings) do + case Map.get(settings, "vpn_target") do + value when is_binary(value) and value != "" -> normalize(value, zone.domain) + _ -> zone.domain + end + end + + defp vpn_api_host_label(settings) do + case Map.get(settings, "vpn_api_host") do + value when is_binary(value) -> + case normalize(value, "") do + "" -> nil + normalized -> normalized + end + + _ -> + nil + end + end + + defp vpn_api_target(zone, settings) do + case Map.get(settings, "vpn_api_target") do + value when is_binary(value) and value != "" -> normalize(value, zone.domain) + _ -> zone.domain + end + end + + defp normalize(value, fallback) do + case value |> String.trim() |> String.trim_trailing(".") |> String.downcase() do + "" -> fallback + normalized -> normalized + end + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/generators/web.ex b/apps/elektrine_dns/lib/elektrine/dns/generators/web.ex new file mode 100644 index 0000000..598c8f9 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/generators/web.ex @@ -0,0 +1,27 @@ +defmodule Elektrine.DNS.Generators.Web do + @moduledoc false + + def generate(zone, settings \\ %{}) do + target = web_target(zone, settings) + ttl = zone.default_ttl || 300 + + [ + %{ + managed_key: "web:www", + name: "www", + type: "CNAME", + ttl: ttl, + content: target, + required: false, + metadata: %{"label" => "WWW alias"} + } + ] + end + + defp web_target(zone, settings) do + case Map.get(settings, "www_target") do + value when is_binary(value) and value != "" -> value + _ -> zone.domain + end + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/health_monitor.ex b/apps/elektrine_dns/lib/elektrine/dns/health_monitor.ex new file mode 100644 index 0000000..28d4f60 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/health_monitor.ex @@ -0,0 +1,214 @@ +defmodule Elektrine.DNS.HealthMonitor do + @moduledoc """ + Liveness checks for failover-enrolled A/AAAA records. + + A record opts in through its metadata: + + %{"health_check" => %{"enabled" => true, "port" => 443}} + + Enrolled targets are TCP-checked on an interval. An address is marked + down after #{2} consecutive failures and recovers on the first success. + The authoritative query path (`Elektrine.DNS.Query`) drops downed + addresses from answers — unless every candidate for a name is down, in + which case all are returned (fail-open: a broken health check must never + empty an answer set). + + Unknown targets are considered healthy, so the monitor being disabled or + not yet warmed up never affects answers. + """ + + use GenServer + + require Logger + + import Ecto.Query, only: [from: 2] + + alias Elektrine.DNS.Record + alias Elektrine.Repo + + @table :elektrine_dns_health_status + @default_interval_ms 30_000 + @connect_timeout_ms 3_000 + @default_port 443 + @down_after 2 + @max_concurrency 16 + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__)) + end + + @doc """ + Whether a target address:port is currently considered healthy. + Unknown targets (and a missing monitor) are healthy by default. + """ + def healthy?(address, port \\ @default_port) do + case :ets.lookup(@table, {address, port}) do + [{_key, :down, _fails}] -> false + _ -> true + end + rescue + ArgumentError -> true + end + + @doc "Current health table as a map of {address, port} => :up | :down." + def snapshot do + @table + |> :ets.tab2list() + |> Map.new(fn {key, status, _fails} -> {key, status} end) + rescue + ArgumentError -> %{} + end + + @doc "Run a sweep immediately (used by tests and admin tooling)." + def check_now(server \\ __MODULE__) do + GenServer.call(server, :check, 30_000) + end + + @doc false + def default_port, do: @default_port + + @impl true + def init(opts) do + if :ets.whereis(@table) == :undefined do + :ets.new(@table, [:named_table, :set, :public, read_concurrency: true]) + end + + interval = Keyword.get(opts, :interval_ms, env_interval()) + + state = %{ + interval: interval, + targets_fun: Keyword.get(opts, :targets_fun, &enrolled_targets/0) + } + + unless interval == :manual do + Process.send_after(self(), :check, interval) + end + + {:ok, state} + end + + @impl true + def handle_call(:check, _from, state) do + sweep(state) + {:reply, :ok, state} + end + + @impl true + def handle_info(:check, state) do + sweep(state) + Process.send_after(self(), :check, state.interval) + {:noreply, state} + end + + def handle_info(_message, state), do: {:noreply, state} + + defp sweep(state) do + targets = state.targets_fun.() + + prune_departed(targets) + + targets + |> Task.async_stream(&probe/1, + max_concurrency: @max_concurrency, + timeout: @connect_timeout_ms + 2_000, + on_timeout: :kill_task + ) + |> Enum.zip(targets) + |> Enum.each(fn + {{:ok, result}, target} -> record_result(target, result) + {{:exit, _reason}, target} -> record_result(target, :error) + end) + rescue + error -> + Logger.warning("DNS health sweep failed: #{Exception.message(error)}") + end + + defp probe({address, port}) do + with {:ok, ip} <- :inet.parse_address(String.to_charlist(address)), + {:ok, socket} <- + :gen_tcp.connect(ip, port, [:binary, active: false], @connect_timeout_ms) do + :gen_tcp.close(socket) + :ok + else + _ -> :error + end + end + + defp record_result(target, :ok) do + :ets.insert(@table, {target, :up, 0}) + end + + defp record_result(target, :error) do + fails = + case :ets.lookup(@table, target) do + [{_key, _status, fails}] -> fails + 1 + [] -> 1 + end + + status = if fails >= @down_after, do: :down, else: :up + + if status == :down do + {address, port} = target + Logger.warning("DNS health: marking #{address}:#{port} down after #{fails} failures") + end + + :ets.insert(@table, {target, status, fails}) + end + + # Forget targets that are no longer enrolled so stale entries cannot + # affect answers if a record is re-added later. + defp prune_departed(targets) do + keep = MapSet.new(targets) + + for {key, _status, _fails} <- :ets.tab2list(@table), + not MapSet.member?(keep, key) do + :ets.delete(@table, key) + end + end + + defp enrolled_targets do + from(r in Record, + where: r.type in ["A", "AAAA"], + where: fragment("?::jsonb -> 'health_check' ->> 'enabled' IN ('true', 't')", r.metadata), + select: {r.content, r.metadata} + ) + |> Repo.all() + |> Enum.map(fn {address, metadata} -> {address, health_check_port(metadata)} end) + |> Enum.uniq() + rescue + error -> + Logger.warning("DNS health target load failed: #{Exception.message(error)}") + [] + end + + @doc false + def health_check_port(metadata) when is_map(metadata) do + case get_in(metadata, ["health_check", "port"]) do + port when is_integer(port) and port in 1..65_535 -> port + port when is_binary(port) -> parse_port(port) + _ -> @default_port + end + end + + def health_check_port(_metadata), do: @default_port + + defp parse_port(value) do + case Integer.parse(value) do + {port, ""} when port in 1..65_535 -> port + _ -> @default_port + end + end + + defp env_interval do + case System.get_env("DNS_HEALTH_CHECK_INTERVAL_MS") do + nil -> + @default_interval_ms + + value -> + case Integer.parse(value) do + {ms, ""} when ms >= 1_000 -> ms + _ -> @default_interval_ms + end + end + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/mail_security.ex b/apps/elektrine_dns/lib/elektrine/dns/mail_security.ex new file mode 100644 index 0000000..1fb4bd4 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/mail_security.ex @@ -0,0 +1,201 @@ +defmodule Elektrine.DNS.MailSecurity do + @moduledoc false + + @default_caa_issue "letsencrypt.org" + @default_mta_sts_mode "enforce" + @default_mta_sts_max_age 86_400 + + def caa_issue(settings) do + cleaned_binary(settings["caa_issue"]) || @default_caa_issue + end + + def caa_issuewild(settings) do + cleaned_binary(settings["caa_issuewild"]) + end + + def caa_iodef(settings) do + cleaned_binary(settings["caa_iodef"]) + end + + def caa_flags(settings) do + settings["caa_flags"] + |> parse_byte_int(0) + end + + def mta_sts_txt_value(domain, settings) do + "v=STSv1; id=#{mta_sts_id(domain, settings)}" + end + + def tls_rpt_txt_value(domain, settings) do + "v=TLSRPTv1; rua=#{tls_rpt_rua(domain, settings)}" + end + + def mta_sts_policy(domain, settings) do + (["version: STSv1", "mode: #{mta_sts_mode(settings)}"] ++ + Enum.map(mta_sts_mx_patterns(domain, settings), &"mx: #{&1}") ++ + ["max_age: #{mta_sts_max_age(settings)}"]) + |> Enum.join("\n") + |> Kernel.<>("\n") + end + + def default_mail_target(zone) do + [ + Application.get_env(:elektrine, :primary_domain), + Elektrine.Domains.primary_email_domain(), + Map.get(zone, :domain) + ] + |> Enum.find(&public_hostname?/1) || Elektrine.Domains.primary_email_domain() + end + + def mail_target(domain, settings) do + cleaned_binary(settings["mail_target"]) || domain + end + + def mta_sts_mode(settings) do + case cleaned_binary(settings["mta_sts_mode"]) do + mode when mode in ["enforce", "testing", "none"] -> mode + _ -> @default_mta_sts_mode + end + end + + def mta_sts_max_age(settings) do + settings["mta_sts_max_age"] + |> parse_positive_int(@default_mta_sts_max_age) + end + + def tls_rpt_rua(domain, settings) do + cleaned_binary(settings["tls_rpt_rua"]) || "mailto:postmaster@#{domain}" + end + + def mta_sts_mx_patterns(domain, settings) do + settings["mta_sts_mx_patterns"] + |> cleaned_binary() + |> case do + nil -> [mail_target(domain, settings)] + patterns -> patterns |> split_values() |> default_if_empty([mail_target(domain, settings)]) + end + end + + def tlsa_usage(settings) do + settings["tlsa_usage"] + |> parse_byte_int(3) + end + + def tlsa_selector(settings) do + settings["tlsa_selector"] + |> parse_byte_int(0) + end + + def tlsa_matching_type(settings) do + settings["tlsa_matching_type"] + |> parse_byte_int(1) + end + + def tlsa_association_data(settings) do + cleaned_binary(settings["tlsa_association_data"]) + |> normalize_hex() + end + + def default_tlsa_association_data do + System.get_env("MAIL_TLS_CERT_PATH") + |> cleaned_binary() + |> case do + nil -> nil + cert_path -> certificate_sha256(cert_path) + end + end + + defp mta_sts_id(domain, settings) do + [ + domain, + mta_sts_mode(settings), + Integer.to_string(mta_sts_max_age(settings)) + | mta_sts_mx_patterns(domain, settings) + ] + |> Enum.join("|") + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + |> binary_part(0, 12) + end + + defp split_values(value) do + value + |> String.split([",", "\n"], trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + defp default_if_empty([], fallback), do: fallback + defp default_if_empty(values, _fallback), do: values + + defp public_hostname?(value) when is_binary(value) do + value = value |> String.trim() |> String.trim_trailing(".") |> String.downcase() + + value != "" and String.contains?(value, ".") and + not String.contains?(value, ["/", "@", " "]) and + Enum.all?(String.split(value, "."), &valid_hostname_label?/1) and + not match?({:ok, _}, :inet.parse_address(String.to_charlist(value))) + end + + defp public_hostname?(_), do: false + + defp valid_hostname_label?(label) do + byte_size(label) in 1..63 and String.match?(label, ~r/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/) + end + + defp cleaned_binary(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp cleaned_binary(_), do: nil + + defp normalize_hex(nil), do: nil + + defp normalize_hex(value) when is_binary(value) do + value + |> String.replace(~r/\s+/, "") + |> String.upcase() + |> case do + "" -> nil + normalized -> normalized + end + end + + defp parse_positive_int(value, _default) when is_integer(value) and value > 0, do: value + + defp parse_positive_int(value, default) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {int, ""} when int > 0 -> int + _ -> default + end + end + + defp parse_positive_int(_, default), do: default + + defp parse_byte_int(value, _default) when is_integer(value) and value >= 0 and value <= 255, + do: value + + defp parse_byte_int(value, default) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {int, ""} when int >= 0 and int <= 255 -> int + _ -> default + end + end + + defp parse_byte_int(_, default), do: default + + defp certificate_sha256(cert_path) do + with true <- File.regular?(cert_path), + {:ok, pem} <- File.read(cert_path), + [entry | _] <- :public_key.pem_decode(pem), + {:Certificate, der, _rest} <- entry do + Base.encode16(:crypto.hash(:sha256, der), case: :upper) + else + _ -> nil + end + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/managed_records.ex b/apps/elektrine_dns/lib/elektrine/dns/managed_records.ex new file mode 100644 index 0000000..bdfb6f2 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/managed_records.ex @@ -0,0 +1,744 @@ +defmodule Elektrine.DNS.ManagedRecords do + @moduledoc false + + import Ecto.Query, warn: false + + alias Elektrine.AppCache + alias Elektrine.DNS.MailSecurity + alias Elektrine.DNS.Record + alias Elektrine.DNS.Zone + alias Elektrine.DNS.ZoneServiceConfig + alias Elektrine.Repo + alias Elektrine.Secrets.EncryptedString + + @redacted_secret "[redacted]" + @sensitive_settings ~w(dkim_private_key) + + def apply_service(%Zone{} = zone, service, attrs \\ %{}) when is_map(attrs) do + service = normalize_service(service) + + attrs + |> Map.get("settings", %{}) + |> normalize_settings() + |> drop_redacted_private_settings(service) + |> validate_settings(service) + |> case do + {:ok, incoming_settings} -> do_apply_service(zone, service, attrs, incoming_settings) + {:error, _message} = error -> error + end + end + + defp do_apply_service(zone, service, attrs, incoming_settings) do + enabled = Map.get(attrs, "enabled", true) + mode = Map.get(attrs, "mode", "managed") + + tx_result = + Repo.transaction(fn -> + config = + Repo.get_by(ZoneServiceConfig, zone_id: zone.id, service: service) || + %ZoneServiceConfig{zone_id: zone.id, service: service} + + settings = + config.settings + |> normalize_settings() + |> Map.merge(incoming_settings) + |> decrypt_private_settings(service) + + {:ok, config} = + config + |> ZoneServiceConfig.changeset(%{ + zone_id: zone.id, + service: service, + enabled: enabled, + mode: mode, + settings: store_private_settings(service, settings), + status: if(enabled, do: "pending", else: "disabled"), + last_error: nil + }) + |> Repo.insert_or_update() + + if enabled and mode == "managed" do + zone = Repo.preload(zone, :records, force: true) + settings = prepare_settings(zone, service, settings) + desired = desired_records(zone, service, settings) + conflicts = conflicts_for(zone, service, desired) + + if conflicts == [] do + existing = list_managed_records(zone.id, service) + desired_keys = MapSet.new(Enum.map(desired, & &1.managed_key)) + + Enum.each(existing, fn record -> + unless MapSet.member?(desired_keys, record.managed_key) do + Repo.delete!(record) + end + end) + + _adopted_ids = + Enum.reduce(desired, MapSet.new(), fn attrs, adopted_ids -> + record = + Repo.get_by(Record, zone_id: zone.id, managed_key: attrs.managed_key) || + adoptable_record(zone.id, attrs, adopted_ids) || %Record{} + + updated = + record + |> Record.changeset(Map.merge(attrs, %{zone_id: zone.id})) + |> Repo.insert_or_update() + |> case do + {:ok, updated} -> updated + {:error, changeset} -> Repo.rollback(invalid_record_message(attrs, changeset)) + end + + if record.id, do: MapSet.put(adopted_ids, updated.id), else: adopted_ids + end) + + Repo.update!( + ZoneServiceConfig.changeset(config, %{ + settings: store_private_settings(service, settings), + status: "ok", + last_applied_at: now(), + last_error: nil + }) + ) + else + Repo.update!( + ZoneServiceConfig.changeset(config, %{ + settings: store_private_settings(service, settings), + status: "conflict", + last_applied_at: now(), + last_error: Enum.join(conflicts, "; ") + }) + ) + end + else + delete_service(zone, service) + + Repo.update!( + ZoneServiceConfig.changeset(config, %{ + status: if(enabled, do: "pending", else: "disabled"), + last_applied_at: now(), + last_error: nil + }) + ) + end + + Repo.get_by!(ZoneServiceConfig, zone_id: zone.id, service: service) + end) + + case tx_result do + {:ok, config} -> + finalize_side_effects(zone, service, config) + + other -> + other + end + end + + def reconcile_supported_mail_services do + Elektrine.Domains.supported_email_domains() + |> Enum.map(&reconcile_supported_mail_service/1) + end + + def delete_service(%Zone{} = zone, service) do + service = normalize_service(service) + + from(r in Record, + where: r.zone_id == ^zone.id and r.service == ^service and r.managed == true + ) + |> Repo.delete_all() + end + + defp reconcile_supported_mail_service(domain) do + case Repo.get_by(Zone, domain: domain) do + nil -> + {domain, :skipped, :zone_missing} + + %Zone{} = zone -> + case Repo.get_by(ZoneServiceConfig, zone_id: zone.id, service: "mail") do + %ZoneServiceConfig{enabled: true, mode: "managed"} -> + case apply_service(zone, "mail", %{}) do + {:ok, %ZoneServiceConfig{status: "ok"}} -> + {domain, :ok} + + {:ok, %ZoneServiceConfig{} = config} -> + {domain, :error, config.status, config.last_error} + + {:error, reason} -> + {domain, :error, reason} + end + + %ZoneServiceConfig{} -> + {domain, :skipped, :mail_not_managed} + + nil -> + {domain, :skipped, :mail_not_configured} + end + end + end + + def list_service_configs(zone_id) do + AppCache.get_dns_service_configs(zone_id, fn -> + from(c in ZoneServiceConfig, where: c.zone_id == ^zone_id, order_by: c.service) + |> Repo.all() + end) + end + + def public_settings(service, settings) do + service = normalize_service(service) + + settings + |> normalize_settings() + |> redact_private_settings(service) + end + + def service_health(%Zone{} = zone) do + configs = list_service_configs(zone.id) + + Enum.map(ZoneServiceConfig.services(), fn service -> + config = Enum.find(configs, &(&1.service == service)) + + settings = + config + |> then(&(&1 && &1.settings)) + |> normalize_settings() + |> decrypt_private_settings(service) + + planned = desired_records(zone, service, settings) + desired = if config && config.enabled, do: planned, else: [] + + managed_records = list_managed_records(zone.id, service) + conflicts = conflicts_for(zone, service, desired) + checks = health_checks(desired, managed_records, conflicts) + + %{ + service: service, + enabled: config && config.enabled, + planned_records: planned, + mode: config && config.mode, + status: if(config, do: config.status, else: "not_configured"), + last_error: if(config, do: config.last_error, else: nil), + settings: redact_private_settings(settings, service), + desired_records: desired, + managed_records: managed_records, + conflicts: conflicts, + checks: checks, + repairable: config && config.enabled && config.mode == "managed" + } + end) + end + + defp desired_records(zone, "mail", settings) do + Elektrine.DNS.Generators.Mail.generate(zone, settings) + |> Enum.map(&managed_attrs(&1, "mail")) + end + + defp desired_records(zone, "web", settings) do + Elektrine.DNS.Generators.Web.generate(zone, settings) + |> Enum.map(&managed_attrs(&1, "web")) + end + + defp desired_records(zone, "turn", settings) do + Elektrine.DNS.Generators.Turn.generate(zone, settings) + |> Enum.map(&managed_attrs(&1, "turn")) + end + + defp desired_records(zone, "vpn", settings) do + Elektrine.DNS.Generators.VPN.generate(zone, settings) + |> Enum.map(&managed_attrs(&1, "vpn")) + end + + defp desired_records(zone, "bluesky", settings) do + Elektrine.DNS.Generators.Bluesky.generate(zone, settings) + |> Enum.map(&managed_attrs(&1, "bluesky")) + end + + defp desired_records(_zone, _service, _settings), do: [] + + defp prepare_settings(%Zone{} = zone, "mail", settings) do + settings + |> Map.put_new_lazy("dkim_selector", fn -> + dkim_module().generate_domain_key_material().selector + end) + |> ensure_dkim_material() + |> put_default_setting("mail_target", MailSecurity.default_mail_target(zone)) + |> then(&Map.put(&1, "mail_target", normalize_mail_target(zone, &1))) + |> put_default_setting("caa_issue", MailSecurity.caa_issue(%{})) + |> put_default_setting("mta_sts_mode", "enforce") + |> put_default_setting("tls_rpt_rua", "mailto:postmaster@#{zone.domain}") + |> put_default_setting("tlsa_association_data", MailSecurity.default_tlsa_association_data()) + end + + defp prepare_settings(_zone, _service, settings), do: settings + + defp ensure_dkim_material(settings) do + if blank?(settings["dkim_public_key"]) or blank?(settings["dkim_private_key"]) do + key_material = dkim_module().generate_domain_key_material() + + settings + |> Map.put("dkim_selector", key_material.selector) + |> Map.put("dkim_public_key", key_material.public_key) + |> Map.put("dkim_private_key", key_material.private_key) + |> Map.put("dkim_value", dkim_value(key_material.public_key, key_material.private_key)) + else + dkim_public_key = Map.get(settings, "dkim_public_key", "") + dkim_private_key = Map.get(settings, "dkim_private_key", "") + + settings + |> Map.put("dkim_value", dkim_value(dkim_public_key, dkim_private_key)) + end + end + + defp dkim_value(public_key, private_key) do + dkim = dkim_module() + + if function_exported?(dkim, :dkim_value_from_material, 2) do + dkim.dkim_value_from_material(public_key, private_key) + else + "v=DKIM1; k=rsa; p=#{dkim.public_key_dns_value(public_key)}" + end + end + + defp sync_side_effects(zone, "mail", settings) do + case dkim_module().sync_domain( + zone.domain, + settings["dkim_selector"], + settings["dkim_private_key"] + ) do + :ok -> nil + {:error, reason} -> reason + end + end + + defp sync_side_effects(_zone, _service, _settings), do: nil + + defp finalize_side_effects(_zone, _service, %ZoneServiceConfig{enabled: false} = config), + do: invalidate_and_return(config) + + defp finalize_side_effects(_zone, _service, %ZoneServiceConfig{status: status} = config) + when status in ["conflict", "disabled", "pending"], + do: invalidate_and_return(config) + + defp finalize_side_effects(zone, service, %ZoneServiceConfig{} = config) do + sync_error = + sync_side_effects( + zone, + service, + config.settings + |> normalize_settings() + |> decrypt_private_settings(service) + ) + + updated = + Repo.update!( + ZoneServiceConfig.changeset(config, %{ + status: if(sync_error, do: "error", else: config.status), + last_error: sync_error + }) + ) + + invalidate_and_return(updated) + end + + defp invalidate_and_return(%ZoneServiceConfig{} = config) do + AppCache.invalidate_dns_service_configs(config.zone_id) + {:ok, config} + end + + defp normalize_mail_target(%Zone{} = zone, settings) do + target = MailSecurity.mail_target(zone.domain, settings) + default_target = MailSecurity.default_mail_target(zone) + + cond do + not Elektrine.DNS.public_hostname?(target) -> + default_target + + target == zone.domain and default_target != zone.domain and + mail_zone_uses_mail_subdomain?(zone) -> + default_target + + true -> + target + end + end + + defp mail_zone_uses_mail_subdomain?(%Zone{} = zone) do + Enum.any?(List.wrap(zone.records), fn record -> + record.managed == true and record.service == "mail" and record.managed_key == "mail:mail" and + record.type == "CNAME" and + normalize_domain(record.content) == normalize_domain(zone.domain) + end) + end + + defp managed_attrs(attrs, service) do + attrs + |> Map.put(:source, "system") + |> Map.put(:service, service) + |> Map.put(:managed, true) + end + + defp conflicts_for(zone, _service, desired) do + desired + |> Enum.group_by(&{&1.name, &1.type}) + |> Enum.flat_map(fn {{name, type}, rrset} -> + existing = + from(r in Record, + where: + r.zone_id == ^zone.id and r.name == ^name and r.type == ^type and r.managed == false + ) + |> Repo.all() + + cond do + existing == [] -> + [] + + compatible_rrset?(existing, rrset) -> + [] + + true -> + ["#{type} #{name} conflicts with user-managed RRset"] + end + end) + |> Enum.uniq() + end + + defp compatible_rrset?(existing, desired) do + canonical_rrset(existing) == canonical_rrset(desired) + end + + defp canonical_rrset(records) do + records + |> Enum.map(&canonical_record/1) + |> Enum.sort() + end + + defp canonical_record(record) do + %{ + name: Map.get(record, :name), + type: Map.get(record, :type), + content: Map.get(record, :content), + ttl: Map.get(record, :ttl), + priority: Map.get(record, :priority), + weight: Map.get(record, :weight), + port: Map.get(record, :port), + tag: Map.get(record, :tag), + flags: Map.get(record, :flags), + protocol: Map.get(record, :protocol), + algorithm: Map.get(record, :algorithm), + key_tag: Map.get(record, :key_tag), + digest_type: Map.get(record, :digest_type), + usage: Map.get(record, :usage), + selector: Map.get(record, :selector), + matching_type: Map.get(record, :matching_type), + value: Map.get(record, :value) + } + end + + defp list_managed_records(zone_id, service) do + from(r in Record, + where: r.zone_id == ^zone_id and r.service == ^service and r.managed == true, + order_by: [asc: r.type, asc: r.name] + ) + |> Repo.all() + end + + defp adoptable_record(zone_id, attrs, adopted_ids) do + from(r in Record, + where: + r.zone_id == ^zone_id and r.managed == false and r.name == ^attrs.name and + r.type == ^attrs.type, + order_by: [asc: r.id] + ) + |> Repo.all() + |> Enum.reject(&MapSet.member?(adopted_ids, &1.id)) + |> Enum.find(&(canonical_record(&1) == canonical_record(attrs))) + end + + defp health_checks(desired, managed_records, conflicts) do + records_by_key = Map.new(managed_records, &{&1.managed_key, &1}) + + desired_checks = + Enum.map(desired, fn desired_record -> + actual = Map.get(records_by_key, desired_record.managed_key) + + %{ + key: desired_record.managed_key, + label: desired_record.metadata["label"] || desired_record.managed_key, + required: Map.get(desired_record, :required, false), + status: record_check_status(desired_record, actual), + expected: desired_record, + actual: actual + } + end) + + conflict_checks = + Enum.map(conflicts, &%{key: nil, label: &1, required: true, status: "conflict"}) + + desired_checks ++ conflict_checks + end + + defp record_check_status(_desired, nil), do: "missing" + + defp record_check_status(desired, actual) do + comparable = [ + :name, + :type, + :content, + :priority, + :port, + :weight, + :ttl, + :flags, + :tag, + :protocol, + :algorithm, + :key_tag, + :digest_type, + :usage, + :selector, + :matching_type + ] + + if Enum.all?(comparable, fn key -> Map.get(desired, key) == Map.get(actual, key) end) do + "ok" + else + "drift" + end + end + + defp normalize_settings(nil), do: %{} + + defp normalize_settings(settings) when is_map(settings) do + Map.new(settings, fn {k, v} -> {to_string(k), normalize_setting_value(v)} end) + end + + defp normalize_settings(_), do: %{} + + defp normalize_setting_value(value) when value in ["true", "TRUE", "on", "ON", "yes", "YES"], + do: true + + defp normalize_setting_value(value) + when value in ["false", "FALSE", "off", "OFF", "no", "NO"], + do: false + + defp normalize_setting_value(value), do: value + + @hostname_settings %{ + "web" => [{"www_target", "WWW target"}], + "turn" => [{"turn_target", "TURN target"}], + "vpn" => [{"vpn_target", "VPN target"}, {"vpn_api_target", "Admin/API target"}], + "bluesky" => [{"bluesky_target", "Bluesky target"}] + } + + @label_settings %{ + "turn" => [{"turn_host", "TURN host"}], + "vpn" => [{"vpn_host", "VPN host"}, {"vpn_api_host", "Admin/API host"}], + "bluesky" => [{"bluesky_host", "Bluesky host"}] + } + + defp validate_settings(settings, service) do + with :ok <- validate_setting_fields(settings, @hostname_settings[service], &target_error/2), + :ok <- validate_setting_fields(settings, @label_settings[service], &label_error/2) do + normalize_tls_rpt_setting(settings, service) + end + end + + defp validate_setting_fields(_settings, nil, _error_fun), do: :ok + + defp validate_setting_fields(settings, fields, error_fun) do + Enum.find_value(fields, :ok, fn {key, label} -> + error_fun.(label, Map.get(settings, key)) + end) + end + + defp target_error(_label, value) when is_nil(value), do: nil + + defp target_error(label, value) do + if blank?(value) or (is_binary(value) and Elektrine.DNS.public_hostname?(value)) do + nil + else + {:error, + "#{label} must be a public hostname like host.example.com" <> + " (no http://, paths, IP addresses, or spaces)"} + end + end + + defp label_error(_label, value) when is_nil(value), do: nil + + defp label_error(label, value) do + if blank?(value) or (is_binary(value) and valid_relative_name?(value)) do + nil + else + {:error, + "#{label} must be a subdomain name like turn or turn.eu" <> + " (letters, digits, and hyphens only)"} + end + end + + defp valid_relative_name?(value) do + normalized = normalize_domain(value) + + normalized != "" and + normalized + |> String.split(".") + |> Enum.all?(&String.match?(&1, ~r/^_?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/)) + end + + defp normalize_tls_rpt_setting(settings, "mail") do + case Map.get(settings, "tls_rpt_rua") do + value when is_binary(value) -> + if blank?(value) do + {:ok, settings} + else + value + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> normalize_tls_rpt_entries() + |> case do + {:ok, entries} -> {:ok, Map.put(settings, "tls_rpt_rua", Enum.join(entries, ","))} + {:error, _message} = error -> error + end + end + + _ -> + {:ok, settings} + end + end + + defp normalize_tls_rpt_setting(settings, _service), do: {:ok, settings} + + defp normalize_tls_rpt_entries(entries) do + entries + |> Enum.reduce_while({:ok, []}, fn entry, {:ok, acc} -> + case normalize_tls_rpt_entry(entry) do + {:ok, normalized} -> {:cont, {:ok, [normalized | acc]}} + {:error, _message} = error -> {:halt, error} + end + end) + |> case do + {:ok, normalized} -> {:ok, Enum.reverse(normalized)} + error -> error + end + end + + defp normalize_tls_rpt_entry("mailto:" <> address = entry) do + if email_address?(address) do + {:ok, entry} + else + {:error, "TLS-RPT rua must use a valid email address, got #{entry}"} + end + end + + defp normalize_tls_rpt_entry("https://" <> _ = entry), do: {:ok, entry} + + defp normalize_tls_rpt_entry(entry) do + if email_address?(entry) do + {:ok, "mailto:" <> entry} + else + {:error, + "TLS-RPT rua must be an email address (reports@example.com) or an https:// URL," <> + " got #{entry}"} + end + end + + defp email_address?(value) do + case String.split(value, "@") do + [local, domain] -> local != "" and Elektrine.DNS.public_hostname?(domain) + _ -> false + end + end + + defp invalid_record_message(attrs, changeset) do + label = attrs.metadata["label"] || attrs.managed_key + + details = + Enum.map_join(changeset.errors, "; ", fn {field, {message, _opts}} -> + "#{field} #{message}" + end) + + "cannot apply #{attrs.type} record for #{label}: #{details}" + end + + defp drop_redacted_private_settings(settings, service) do + Map.reject(settings, fn {key, value} -> + private_setting?(service, key) and value == @redacted_secret + end) + end + + defp redact_private_settings(settings, service) do + Map.new(settings, fn {key, value} -> + if private_setting?(service, key) and not blank?(value) do + {key, @redacted_secret} + else + {key, value} + end + end) + end + + defp decrypt_private_settings(settings, service) do + update_private_settings(settings, service, &decrypt_secret/1) + end + + defp store_private_settings(service, settings) do + update_private_settings(settings, service, &encrypt_secret/1) + end + + defp update_private_settings(settings, service, fun) do + Enum.reduce(@sensitive_settings, settings, fn key, acc -> + if private_setting?(service, key) and Map.has_key?(acc, key) do + Map.update!(acc, key, fun) + else + acc + end + end) + end + + defp private_setting?("mail", "dkim_private_key"), do: true + defp private_setting?(_service, _key), do: false + + defp encrypt_secret(value) when is_binary(value) do + if EncryptedString.encrypted?(value) do + value + else + case EncryptedString.encrypt(value) do + {:ok, encrypted} -> encrypted + :error -> value + end + end + end + + defp encrypt_secret(value), do: value + + defp decrypt_secret(value) when is_binary(value) do + case EncryptedString.decrypt(value) do + {:ok, decrypted} -> decrypted + :error -> value + end + end + + defp decrypt_secret(value), do: value + + defp blank?(value) when is_binary(value), do: not Elektrine.Strings.present?(value) + defp blank?(nil), do: true + defp blank?(_), do: false + + defp normalize_domain(value) when is_binary(value), + do: value |> String.trim() |> String.downcase() |> String.trim_trailing(".") + + defp normalize_domain(value), do: value |> to_string() |> normalize_domain() + + defp put_default_setting(settings, key, value) do + if blank?(Map.get(settings, key)) do + Map.put(settings, key, value) + else + settings + end + end + + defp dkim_module, + do: Application.get_env(:elektrine, :managed_dns_dkim_module, Elektrine.Email.DKIM) + + defp normalize_service(service) when is_binary(service), do: String.downcase(service) + defp normalize_service(service), do: to_string(service) |> String.downcase() + + defp now, do: DateTime.utc_now() |> DateTime.truncate(:second) +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/packet.ex b/apps/elektrine_dns/lib/elektrine/dns/packet.ex new file mode 100644 index 0000000..0b6e427 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/packet.ex @@ -0,0 +1,451 @@ +defmodule Elektrine.DNS.Packet do + @moduledoc false + + import Bitwise + + alias Elektrine.DNS + alias Elektrine.DNS.ServiceBinding + + @type_map %{ + a: 1, + ns: 2, + cname: 5, + soa: 6, + hinfo: 13, + mx: 15, + txt: 16, + aaaa: 28, + srv: 33, + sshfp: 44, + ds: 43, + dnskey: 48, + tlsa: 52, + svcb: 64, + https: 65, + caa: 257, + any: 255 + } + @rcode_map %{noerror: 0, formerr: 1, servfail: 2, nxdomain: 3, notimp: 4, refused: 5} + @type_numbers @type_map |> Map.new(fn {key, value} -> {Atom.to_string(key), value} end) + @type_names @type_map |> Map.new(fn {key, _value} -> {Atom.to_string(key), key} end) + + def decode_query(packet) when is_binary(packet) do + with <> <- + packet, + true <- qdcount == 1, + true <- opcode(flags) == 0, + true <- ancount == 0, + true <- nscount == 0, + {:ok, question, rest} <- decode_question(rest, packet), + true <- question.qclass == 1, + {:ok, options} <- decode_query_options(rest, packet, arcount) do + {:ok, + %{ + id: id, + flags: flags, + rd: flags >>> 8 &&& 1, + qname: question.qname, + qtype: question.qtype, + qclass: question.qclass, + udp_size: options.udp_size, + dnssec_ok: options.dnssec_ok, + edns: options.edns + }} + else + _ -> {:error, :format_error} + end + end + + def encode_error(packet, rcode) when is_binary(packet) do + case decode_query(packet) do + {:ok, query} -> encode_response(query, [], rcode) + _ -> <<0::16, flags_for_reply(0, rcode)::16, 0::16, 0::16, 0::16, 0::16>> + end + end + + def encode_query(%{id: id, rd: rd, qname: qname, qtype: qtype} = query) do + flags = if rd in [1, true], do: 1 <<< 8, else: 0 + question = encode_name(qname) <> <> + + case encode_opt_record(Map.get(query, :udp_size), Map.get(query, :dnssec_ok, false)) do + nil -> + <> + + opt_record -> + <> + end + end + + def encode_response(query, answers, rcode, opts \\ []) do + do_encode_response(query, answers, rcode, opts) + rescue + # A record that fails to encode must never leave the client without an + # answer, so any encode error degrades to a bare SERVFAIL. + _error -> do_encode_response(query, [], :servfail, transport_opts(opts)) + end + + defp do_encode_response(query, answers, rcode, opts) do + flags = flags_for_reply(query.rd, rcode, opts) + question = encode_name(query.qname) <> <> + qname_norm = normalize_owner(query.qname) + answer_bin = Enum.map_join(answers, &encode_record(&1, qname_norm)) + authority = Keyword.get(opts, :authority, []) + additional = Keyword.get(opts, :additional, []) + authority_bin = Enum.map_join(authority, &encode_record(&1, qname_norm)) + additional_bin = Enum.map_join(additional, &encode_record(&1, qname_norm)) + opt_bin = response_opt_record(query) + arcount = length(additional) + if opt_bin == <<>>, do: 0, else: 1 + + response = + <> + + maybe_truncate_response(query, response, rcode, opts) + end + + # RFC 6891/3225: a response to an EDNS query must carry an OPT record, with + # the DO bit copied from the request. + defp response_opt_record(query) do + if Map.get(query, :edns, false) do + encode_opt_record(DNS.max_udp_payload(), Map.get(query, :dnssec_ok, false)) + else + <<>> + end + end + + defp transport_opts(opts) do + case Keyword.fetch(opts, :transport) do + {:ok, transport} -> [transport: transport] + :error -> [] + end + end + + defp flags_for_reply(rd, rcode, opts \\ []) do + aa = if Keyword.get(opts, :authoritative, false), do: 1 <<< 10, else: 0 + ra = if Keyword.get(opts, :recursion_available, false), do: 1 <<< 7, else: 0 + tc = if Keyword.get(opts, :truncated, false), do: 1 <<< 9, else: 0 + ad = if Keyword.get(opts, :authentic_data, false), do: 1 <<< 5, else: 0 + + 1 <<< 15 ||| aa ||| tc ||| rd <<< 8 ||| ra ||| ad ||| Map.fetch!(@rcode_map, rcode) + end + + defp maybe_truncate_response(query, response, rcode, opts) do + if Keyword.get(opts, :transport) == :udp and byte_size(response) > udp_payload_limit(query) do + encode_truncated_response(query, rcode, opts) + else + response + end + end + + defp encode_truncated_response(query, rcode, opts) do + flags = flags_for_reply(query.rd, rcode, Keyword.put(opts, :truncated, true)) + question = encode_name(query.qname) <> <> + opt_bin = response_opt_record(query) + arcount = if opt_bin == <<>>, do: 0, else: 1 + + <> + end + + defp udp_payload_limit(query) do + query + |> Map.get(:udp_size, 512) + |> max(512) + |> min(max(DNS.max_udp_payload(), 512)) + end + + defp opcode(flags), do: flags >>> 11 &&& 0xF + + def decode_question(packet) when is_binary(packet) do + with <<_id::16, _flags::16, qdcount::16, _ancount::16, _nscount::16, _arcount::16, + rest::binary>> <- packet, + true <- qdcount == 1, + {:ok, question, _rest} <- decode_question(rest, packet) do + {:ok, question} + else + _ -> {:error, :format_error} + end + end + + defp decode_question(rest, packet) do + with {:ok, qname, rest} <- decode_name(rest, packet), + <> <- rest do + {:ok, %{qname: qname, qtype: decode_type(qtype), qclass: qclass}, rest} + else + _ -> {:error, :format_error} + end + end + + defp decode_query_options(_rest, _packet, 0), + do: {:ok, %{udp_size: 512, dnssec_ok: false, edns: false}} + + defp decode_query_options(rest, packet, arcount) do + decode_additional_records(rest, packet, arcount, %{ + udp_size: 512, + dnssec_ok: false, + edns: false + }) + end + + defp decode_additional_records(rest, _packet, 0, options) when is_binary(rest), + do: {:ok, options} + + defp decode_additional_records(data, packet, remaining, options) when remaining > 0 do + with {:ok, name, rest} <- decode_name(data, packet), + <> <- rest do + options = + if type == 41 and name == "" do + %{ + options + | udp_size: clamp_udp_size(class), + dnssec_ok: (ttl &&& 0x8000) != 0, + edns: true + } + else + options + end + + decode_additional_records(rest, packet, remaining - 1, options) + else + _ -> {:error, :format_error} + end + end + + defp clamp_udp_size(size) when size < 512, do: 512 + defp clamp_udp_size(size), do: min(size, max(DNS.max_udp_payload(), 512)) + + defp encode_opt_record(nil, false), do: nil + + defp encode_opt_record(udp_size, dnssec_ok) do + udp_size = udp_size || DNS.max_udp_payload() + flags = if dnssec_ok, do: 0x8000, else: 0 + <<0, 41::16, clamp_udp_size(udp_size)::16, 0::8, 0::8, flags::16, 0::16>> + end + + defp decode_name(data, packet), do: decode_name(data, packet, [], 0) + + defp decode_name(_, _, _, 20), do: {:error, :compression_loop} + + defp decode_name(<<0, rest::binary>>, _packet, labels, _depth) do + name = Enum.reverse(labels) |> Enum.join(".") + + if byte_size(name) <= 253 do + {:ok, name, rest} + else + {:error, :name_too_long} + end + end + + defp decode_name(<> = data, packet, labels, depth) + when (len &&& 0xC0) == 0xC0 do + <> = data + offset = ptr &&& 0x3FFF + + with true <- offset < byte_size(packet), + {:ok, pointed, _} <- + decode_name( + binary_part(packet, offset, byte_size(packet) - offset), + packet, + [], + depth + 1 + ) do + {:ok, (Enum.reverse(labels) ++ String.split(pointed, ".", trim: true)) |> Enum.join("."), + rest} + else + _ -> {:error, :bad_pointer} + end + end + + defp decode_name(<>, _packet, _labels, _depth) when (len &&& 0xC0) != 0, + do: {:error, :bad_label} + + defp decode_name(<>, packet, labels, depth) + when len <= 63, + do: decode_name(rest, packet, [label | labels], depth) + + defp decode_name(_, _, _, _), do: {:error, :bad_name} + + defp encode_record(record, qname_norm) do + name = encode_owner(record_name(record), qname_norm) + type = encode_type(record.type) + ttl = Map.get(record, :ttl, 300) + rdata = encode_rdata(Map.put(record, :type, normalize_type(record.type))) + + <> + end + + # Owner names matching the question name are emitted as a compression + # pointer to the question section (fixed offset 12). + defp encode_owner(name, qname_norm) do + if qname_norm != nil and normalize_owner(name) == qname_norm do + <<0xC00C::16>> + else + encode_name(name) + end + end + + defp normalize_owner(name) do + name |> to_string() |> String.trim_trailing(".") |> String.downcase() + end + + defp encode_rdata(%{type: :a, content: content}) do + {:ok, {a, b, c, d}} = :inet.parse_address(String.to_charlist(content)) + <> + end + + defp encode_rdata(%{type: :aaaa, content: content}) do + {:ok, tuple} = :inet.parse_address(String.to_charlist(content)) + tuple |> Tuple.to_list() |> Enum.map_join(&<<&1::16>>) + end + + defp encode_rdata(%{type: :ns, value: value}), do: encode_name(value) + defp encode_rdata(%{type: :cname, content: content}), do: encode_name(content) + + defp encode_rdata(%{type: :mx, content: content, priority: priority}), + do: <> + + defp encode_rdata(%{type: :txt, content: content}), do: encode_txt(content) + defp encode_rdata(%{type: :txt, value: value}), do: encode_txt(value) + + defp encode_rdata(%{type: :hinfo, cpu: cpu, os: os}), + do: <> + + defp encode_rdata(%{ + type: :srv, + content: content, + priority: priority, + weight: weight, + port: port + }) do + <> + end + + defp encode_rdata(%{type: :caa, flags: flags, tag: tag, content: content}) do + tag = tag || "issue" + <> + end + + defp encode_rdata(%{ + type: :dnskey, + content: content, + flags: flags, + protocol: protocol, + algorithm: algorithm + }) do + <> + end + + defp encode_rdata(%{ + type: :ds, + content: content, + key_tag: key_tag, + algorithm: algorithm, + digest_type: digest_type + }) do + <> + end + + defp encode_rdata(%{ + type: :tlsa, + content: content, + usage: usage, + selector: selector, + matching_type: matching_type + }) do + <> + end + + defp encode_rdata(%{ + type: :sshfp, + content: content, + algorithm: algorithm, + digest_type: digest_type + }) do + <> + end + + defp encode_rdata(%{type: type, priority: priority, content: content}) + when type in [:svcb, :https] do + case ServiceBinding.encode_rdata(priority || 0, content, &encode_name/1) do + {:ok, rdata} -> rdata + {:error, reason} -> raise ArgumentError, "invalid service binding record: #{reason}" + end + end + + defp encode_rdata(%{content: content}) when is_binary(content), do: content + defp encode_rdata(%{value: value}) when is_binary(value), do: value + + defp encode_rdata(%{ + type: :soa, + mname: mname, + rname: rname, + serial: serial, + refresh: refresh, + retry: retry, + expire: expire, + minimum: minimum + }) do + <> + end + + defp encode_txt(content) do + content + |> to_string() + |> :binary.bin_to_list() + |> Enum.chunk_every(255) + |> Enum.map_join(fn chunk -> <> <> :erlang.list_to_binary(chunk) end) + end + + defp decode_base64_data(content) when is_binary(content) do + case Base.decode64(content) do + {:ok, decoded} -> + decoded + + :error -> + case Base.decode64(content, padding: false) do + {:ok, decoded} -> decoded + :error -> raise ArgumentError, "invalid DNS base64 record content" + end + end + end + + defp decode_hex_data(content) when is_binary(content) do + case Base.decode16(content, case: :mixed) do + {:ok, decoded} -> decoded + :error -> raise ArgumentError, "invalid DNS hexadecimal record content" + end + end + + defp encode_name(name) do + normalized = name |> to_string() |> String.trim_trailing(".") + labels = String.split(normalized, ".", trim: true) + + if byte_size(normalized) > 253 or Enum.any?(labels, &(byte_size(&1) > 63)) do + raise ArgumentError, "DNS name is too long" + end + + labels + |> Enum.map_join(fn label -> <> <> label end) + |> Kernel.<>(<<0>>) + end + + defp record_name(%{host: host}), do: host + defp record_name(%{name: name}), do: name + + defp decode_type(type), + do: Enum.find_value(@type_map, type, fn {key, value} -> if value == type, do: key end) + + defp encode_type(type) when is_binary(type), + do: Map.get(@type_numbers, String.downcase(type), 255) + + defp encode_type(type) when is_integer(type), do: type + defp encode_type(type) when is_atom(type), do: Map.get(@type_map, type, 255) + + defp normalize_type(type) when is_binary(type), + do: Map.get(@type_names, String.downcase(type), 255) + + defp normalize_type(type), do: type +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/profile_wildcard_bootstrap.ex b/apps/elektrine_dns/lib/elektrine/dns/profile_wildcard_bootstrap.ex new file mode 100644 index 0000000..9f827b5 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/profile_wildcard_bootstrap.ex @@ -0,0 +1,33 @@ +defmodule Elektrine.DNS.ProfileWildcardBootstrap do + @moduledoc false + + use GenServer + + require Logger + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(_opts), do: {:ok, %{}, {:continue, :ensure_profile_wildcards}} + + @impl true + def handle_continue(:ensure_profile_wildcards, state) do + results = Elektrine.DNS.ensure_profile_subdomain_wildcards() + + Enum.each(results, fn + {_domain, :ok} -> + :ok + + {domain, {:error, reason}} -> + Logger.info("Profile DNS wildcard not installed for #{domain}: #{inspect(reason)}") + end) + + {:noreply, state} + rescue + error -> + Logger.warning("Profile DNS wildcard bootstrap failed: #{Exception.message(error)}") + {:noreply, state} + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/query.ex b/apps/elektrine_dns/lib/elektrine/dns/query.ex new file mode 100644 index 0000000..70b2d2c --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/query.ex @@ -0,0 +1,580 @@ +defmodule Elektrine.DNS.Query do + @moduledoc false + + import Bitwise + + alias Elektrine.DNS + alias Elektrine.DNS.Packet + + @type_names %{ + "a" => :a, + "aaaa" => :aaaa, + "alias" => :alias, + "caa" => :caa, + "cname" => :cname, + "dnskey" => :dnskey, + "ds" => :ds, + "https" => :https, + "mx" => :mx, + "ns" => :ns, + "soa" => :soa, + "srv" => :srv, + "sshfp" => :sshfp, + "svcb" => :svcb, + "tlsa" => :tlsa, + "txt" => :txt, + "any" => :any + } + + def answer(packet, opts \\ []) when is_binary(packet) do + packet + |> resolve(opts) + |> Map.fetch!(:response) + end + + def resolve(packet, opts \\ []) when is_binary(packet) do + result = + case Packet.decode_query(packet) do + {:ok, query} -> + route_query(packet, query, opts) + + {:error, :format_error} -> + %{ + response: Packet.encode_error(packet, :formerr), + zone: nil, + qname: nil, + qtype: nil, + rcode: :formerr, + authoritative: false + } + end + + emit_query_telemetry(result) + result + end + + defp emit_query_telemetry(result) do + :telemetry.execute( + [:elektrine, :dns, :query], + %{count: 1}, + %{ + zone: zone_domain(result.zone), + qname: result.qname, + qtype: result.qtype, + rcode: result.rcode, + authoritative: result.authoritative + } + ) + end + + defp zone_domain(%{domain: domain}), do: domain + defp zone_domain(_), do: nil + + defp route_query(packet, query, opts) do + case DNS.assigned_nameserver_address_records(query.qname, query.qtype) do + records when records != [] -> + response = + Packet.encode_response(query, records, :noerror, + authoritative: true, + transport: Keyword.get(opts, :transport) + ) + + response_meta(query, response, nil, :noerror, true) + + [] -> + route_zone_query(packet, query, opts) + end + end + + defp route_zone_query(packet, query, opts) do + case fetch_zone(query.qname) do + {:ok, zone} -> + if query.qtype == :any do + minimal_any_response(query, zone, opts) + else + answer_for_zone(query, zone, opts) + end + + {:error, :not_authoritative} -> + if query_recursion_desired?(packet) and DNS.recursive_enabled?() do + response = Elektrine.DNS.Recursive.resolve(packet, opts) + response_meta(query, response, nil, response_rcode(response), false) + else + response = + Packet.encode_response(query, [], :refused, transport: Keyword.get(opts, :transport)) + + response_meta(query, response, nil, :refused, false) + end + end + end + + # RFC 8482: answer ANY with a synthetic HINFO instead of the full RRset or + # an outright refusal, which breaks some older mail senders. + defp minimal_any_response(query, zone, opts) do + hinfo = %{host: query.qname, type: :hinfo, cpu: "RFC8482", os: "", ttl: 3789} + + response = + Packet.encode_response(query, [hinfo], :noerror, + authoritative: true, + transport: Keyword.get(opts, :transport) + ) + + response_meta(query, response, zone, :noerror, true) + end + + defp query_recursion_desired?(<<_id::16, flags::16, _rest::binary>>), + do: (flags &&& 0x0100) != 0 + + defp query_recursion_desired?(_), do: false + + defp response_rcode(<<_id::16, flags::16, _rest::binary>>) do + case flags &&& 0x000F do + 0 -> :noerror + 1 -> :formerr + 2 -> :servfail + 3 -> :nxdomain + 4 -> :notimp + 5 -> :refused + other -> other + end + end + + defp response_rcode(_), do: :servfail + + defp fetch_zone(qname) do + qname = normalize_name(qname) + + qname + |> candidate_domains() + |> Enum.find_value(fn domain -> + case DNS.ZoneCache.lookup(domain) do + {:ok, zone} -> {:ok, zone} + :error -> nil + end + end) + |> case do + nil -> {:error, :not_authoritative} + result -> result + end + end + + defp answer_for_zone(query, zone, opts) do + records = records_for_query(zone, query.qname, query.qtype, opts) + authority = [Elektrine.DNS.Zone.soa_record(zone)] + additional = additional_records(zone, records, opts) + + cond do + records != [] -> + response = + Packet.encode_response(query, records, :noerror, + additional: additional, + authoritative: true, + transport: Keyword.get(opts, :transport) + ) + + response_meta(query, response, zone, :noerror, true) + + name_exists?(zone, query.qname, opts) -> + response = + Packet.encode_response(query, [], :noerror, + authority: authority, + authoritative: true, + transport: Keyword.get(opts, :transport) + ) + + response_meta(query, response, zone, :noerror, true) + + true -> + response = + Packet.encode_response(query, [], :nxdomain, + authority: authority, + authoritative: true, + transport: Keyword.get(opts, :transport) + ) + + response_meta(query, response, zone, :nxdomain, true) + end + end + + defp response_meta(query, response, zone, rcode, authoritative) do + %{ + response: response, + zone: zone, + qname: query.qname, + qtype: query.qtype, + rcode: rcode, + authoritative: authoritative + } + end + + defp records_for_query(zone, qname, qtype, opts) do + zone + |> do_records_for_query(qname, qtype, opts) + |> apply_health_failover() + end + + # Drop failover-enrolled A/AAAA records whose targets are down — unless + # every enrolled candidate is down, in which case answer with all of them + # (fail-open). Records without health checks are never affected. + defp apply_health_failover(records) do + {enrolled, rest} = Enum.split_with(records, &health_check_enrolled?/1) + + case enrolled do + [] -> + records + + _ -> + up = Enum.filter(enrolled, &target_healthy?/1) + + if up == [] do + records + else + up ++ rest + end + end + end + + defp health_check_enrolled?(record) do + type = record |> Map.get(:type) |> to_string() |> String.upcase() + + type in ["A", "AAAA"] and + get_in(Map.get(record, :metadata) || %{}, ["health_check", "enabled"]) in [true, "true"] + end + + defp target_healthy?(record) do + port = Elektrine.DNS.HealthMonitor.health_check_port(Map.get(record, :metadata)) + Elektrine.DNS.HealthMonitor.healthy?(Map.get(record, :content), port) + end + + defp do_records_for_query(zone, qname, qtype, opts) do + fqdn = normalize_name(qname) + + if qtype == :soa and fqdn == normalize_name(zone.domain) do + [Elektrine.DNS.Zone.soa_record(zone)] + else + exact_records = exact_records_for_query(zone, fqdn, qtype, opts) + + if exact_records != [] do + exact_records + else + proxied_records = proxied_edge_records_for_query(zone, fqdn, qtype, opts) + + cname_records = + if proxied_records == [], do: cname_records_for_name(zone, fqdn, opts), else: [] + + cond do + proxied_records != [] -> + proxied_records + + cname_records != [] -> + cname_records + + name_exists?(zone, fqdn, opts) -> + [] + + private_name_exists?(zone, fqdn, opts) -> + [] + + true -> + wildcard_records_for_query(zone, fqdn, qtype, opts) + end + end + end + end + + defp exact_records_for_query(zone, fqdn, qtype, opts) do + zone_records(zone, opts) + |> Enum.filter(fn record -> + record_name(zone, record) == fqdn and + (qtype == :any or normalize_type(record.type) == qtype) + end) + |> Enum.reject(&(normalize_type(&1.type) == :alias)) + |> Enum.flat_map(&with_public_record_content(zone, &1)) + |> case do + [] -> alias_records_for_query(zone, fqdn, qtype, opts) + records -> records + end + end + + defp alias_records_for_query(zone, fqdn, qtype, opts) when qtype in [:a, :aaaa] do + zone_records(zone, opts) + |> Enum.filter(fn record -> + record_name(zone, record) == fqdn and normalize_type(record.type) == :alias + end) + |> Enum.flat_map(&flatten_alias_record(zone, fqdn, qtype, &1, opts)) + end + + defp alias_records_for_query(_zone, _fqdn, _qtype, _opts), do: [] + + defp flatten_alias_record(zone, fqdn, qtype, record, opts) do + if Elektrine.DNS.Record.proxied?(record) do + proxied_edge_records(record, fqdn, qtype) + else + target = normalize_name(record.content) + + if target in ["", fqdn] do + [] + else + local_alias_records(zone, target, qtype, record.ttl, opts) + |> case do + [] -> resolve_alias_target(target, qtype, fqdn, record.ttl) + records -> records + end + end + end + end + + defp local_alias_records(zone, target, qtype, ttl, opts) do + zone_records(zone, opts) + |> Enum.filter(fn record -> + record_name(zone, record) == target and normalize_type(record.type) == qtype + end) + |> Enum.flat_map(fn record -> + zone + |> with_public_record_content(record) + |> Enum.map(fn answer -> + answer + |> Map.put(:host, target) + |> Map.put(:ttl, ttl || record.ttl || 300) + end) + end) + end + + defp resolve_alias_target(target, qtype, owner_name, ttl) do + if DNS.public_hostname?(target) do + resolver = DNS.alias_resolver() + + case resolver.lookup(String.to_charlist(target), :in, qtype, timeout: 5_000) do + values when is_list(values) -> + values + |> Enum.map(&normalize_lookup_value/1) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + |> Enum.map(fn content -> + %{ + host: owner_name, + type: Atom.to_string(qtype) |> String.upcase(), + content: content, + ttl: ttl || 300 + } + end) + + _ -> + [] + end + else + [] + end + rescue + _ -> [] + end + + defp normalize_lookup_value({a, b, c, d}), do: :inet.ntoa({a, b, c, d}) |> to_string() + + defp normalize_lookup_value(tuple) when is_tuple(tuple) and tuple_size(tuple) == 8, + do: :inet.ntoa(tuple) |> to_string() + + defp normalize_lookup_value(value) when is_binary(value), do: value + defp normalize_lookup_value(value) when is_list(value), do: to_string(value) + defp normalize_lookup_value(_value), do: nil + + defp cname_records_for_name(zone, fqdn, opts) do + zone_records(zone, opts) + |> Enum.filter(fn record -> + record_name(zone, record) == fqdn and normalize_type(record.type) == :cname + end) + |> Enum.reject(&Elektrine.DNS.Record.proxied?/1) + |> Enum.map(&with_record_host(zone, &1)) + end + + defp proxied_edge_records_for_query(zone, fqdn, qtype, opts) when qtype in [:a, :aaaa, :any] do + zone_records(zone, opts) + |> Enum.filter(fn record -> + record_name(zone, record) == fqdn and Elektrine.DNS.Record.proxied?(record) and + normalize_type(record.type) in [:cname, :alias] + end) + |> Enum.flat_map(&proxied_edge_records(&1, fqdn, qtype)) + end + + defp proxied_edge_records_for_query(_zone, _fqdn, _qtype, _opts), do: [] + + defp proxied_edge_records(record, host, :a) do + edge_record_contents(record, host, "A", DNS.edge_proxy_ipv4_addresses()) + end + + defp proxied_edge_records(record, host, :aaaa) do + edge_record_contents(record, host, "AAAA", DNS.edge_proxy_ipv6_addresses()) + end + + defp proxied_edge_records(record, host, :any) do + proxied_edge_records(record, host, :a) ++ proxied_edge_records(record, host, :aaaa) + end + + defp edge_record_contents(record, host, type, contents) do + Enum.map(contents, fn content -> + record + |> Map.put(:host, host) + |> Map.put(:type, type) + |> Map.put(:content, content) + end) + end + + # A name exists if a record sits at it or below it: empty non-terminals + # must yield NODATA, not NXDOMAIN (RFC 8020), and must block wildcards. + defp name_exists?(zone, qname, opts) do + fqdn = normalize_name(qname) + suffix = "." <> fqdn + + Enum.any?(zone_records(zone, opts), fn record -> + name = record_name(zone, record) + name == fqdn or String.ends_with?(name, suffix) + end) + end + + defp private_name_exists?(zone, qname, opts) do + fqdn = normalize_name(qname) + suffix = "." <> fqdn + + not private_query?(opts) and + Enum.any?(all_zone_records(zone), fn record -> + Elektrine.DNS.Record.private?(record) and + (record_name(zone, record) == fqdn or + String.ends_with?(record_name(zone, record), suffix)) + end) + end + + defp zone_records(zone, opts) do + zone + |> all_zone_records() + |> Enum.reject(&(Elektrine.DNS.Record.private?(&1) and not private_query?(opts))) + end + + defp all_zone_records(zone) do + bootstrap = DNS.zone_onboarding_records(zone) + persisted = zone.records || [] + bootstrap ++ persisted + end + + defp additional_records(zone, answers, opts) do + targets = + answers + |> Enum.flat_map(fn + %{type: type, content: content} when type in ["MX", "NS", "CNAME", :mx, :ns, :cname] -> + [normalize_name(content)] + + %{type: type, value: value} when type in ["MX", "NS", "CNAME", :mx, :ns, :cname] -> + [normalize_name(value)] + + _ -> + [] + end) + |> Enum.uniq() + + zone_records(zone, opts) + |> Enum.filter(fn record -> + record_name(zone, record) in targets and normalize_type(record.type) in [:a, :aaaa] + end) + |> Enum.flat_map(&with_public_record_content(zone, &1)) + end + + defp record_name(_zone, %{host: host}), do: normalize_name(host) + + defp record_name(zone, %{name: name}) when is_binary(name) do + zone_domain = normalize_name(zone.domain) + normalized_name = normalize_name(name) + + cond do + normalized_name in ["", "@"] -> + zone_domain + + normalized_name == zone_domain -> + zone_domain + + String.ends_with?(normalized_name, "." <> zone_domain) -> + normalized_name + + true -> + normalize_name(normalized_name <> "." <> zone.domain) + end + end + + defp candidate_domains(qname) do + labels = String.split(qname, ".", trim: true) + + case labels do + [] -> + [] + + _ -> + Enum.map(0..(length(labels) - 1), fn idx -> labels |> Enum.drop(idx) |> Enum.join(".") end) + end + end + + defp wildcard_records_for_query(zone, fqdn, qtype, opts) do + wildcard_candidates(fqdn, zone.domain) + |> Enum.find_value([], fn wildcard_name -> + records = + zone_records(zone, opts) + |> Enum.filter(fn record -> + record_name(zone, record) == wildcard_name and + (qtype == :any or normalize_type(record.type) == qtype) + end) + |> Enum.flat_map(&with_public_record_content(zone, &1)) + + if records == [], do: nil, else: records + end) + end + + defp with_record_host(zone, record) do + Map.put(record, :host, record_name(zone, record)) + end + + defp with_public_record_content(zone, record) do + if Elektrine.DNS.Record.proxied?(record) do + record + |> proxied_dns_contents() + |> Enum.map(fn content -> + zone + |> with_record_host(record) + |> Map.put(:content, content) + end) + else + [with_record_host(zone, record)] + end + end + + defp proxied_dns_contents(%{type: "A"}), do: DNS.edge_proxy_ipv4_addresses() + defp proxied_dns_contents(%{type: "AAAA"}), do: DNS.edge_proxy_ipv6_addresses() + defp proxied_dns_contents(_record), do: [] + + defp wildcard_candidates(fqdn, zone_domain) do + qlabels = String.split(fqdn, ".", trim: true) + zlabels = String.split(zone_domain, ".", trim: true) + + host_labels = Enum.take(qlabels, max(length(qlabels) - length(zlabels), 0)) + + 1..max(length(host_labels), 1) + |> Enum.map(fn depth -> + ["*" | Enum.drop(host_labels, depth)] ++ zlabels + end) + |> Enum.map(&Enum.join(&1, ".")) + |> Enum.uniq() + end + + defp normalize_name(name) do + name + |> String.downcase() + |> String.trim_trailing(".") + end + + defp normalize_type(type) when is_binary(type), + do: Map.get(@type_names, String.downcase(type), type) + + defp normalize_type(type), do: type + + defp private_query?(opts) do + opts + |> Keyword.get(:client_ip) + |> DNS.Recursive.allow_recursive?() + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/query_stat.ex b/apps/elektrine_dns/lib/elektrine/dns/query_stat.ex new file mode 100644 index 0000000..ea3a3bc --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/query_stat.ex @@ -0,0 +1,46 @@ +defmodule Elektrine.DNS.QueryStat do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + schema "dns_query_stats" do + field :query_date, :date + field :query_hour, :utc_datetime + field :qname, :string + field :qtype, :string + field :rcode, :string + field :transport, :string + field :query_count, :integer, default: 0 + + belongs_to :zone, Elektrine.DNS.Zone + + timestamps(updated_at: false, type: :utc_datetime) + end + + def changeset(query_stat, attrs) do + query_stat + |> cast(attrs, [ + :query_date, + :query_hour, + :qname, + :qtype, + :rcode, + :transport, + :query_count, + :zone_id + ]) + |> validate_required([ + :query_date, + :query_hour, + :qname, + :qtype, + :rcode, + :transport, + :query_count, + :zone_id + ]) + |> validate_number(:query_count, greater_than: 0) + |> foreign_key_constraint(:zone_id) + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/query_stats_buffer.ex b/apps/elektrine_dns/lib/elektrine/dns/query_stats_buffer.ex new file mode 100644 index 0000000..b64860b --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/query_stats_buffer.ex @@ -0,0 +1,93 @@ +defmodule Elektrine.DNS.QueryStatsBuffer do + @moduledoc false + + use GenServer + + alias Elektrine.DNS.QueryStat + alias Elektrine.Repo + + @flush_interval_ms 1_000 + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + def increment(attrs) when is_map(attrs) do + case Process.whereis(__MODULE__) do + nil -> persist_batch(%{key(attrs) => {attrs, 1}}) + _pid -> GenServer.cast(__MODULE__, {:increment, attrs}) + end + + :ok + end + + def flush do + case Process.whereis(__MODULE__) do + nil -> :ok + _pid -> GenServer.call(__MODULE__, :flush, 10_000) + end + end + + @impl true + def init(_opts) do + schedule_flush() + {:ok, %{counts: %{}}} + end + + @impl true + def handle_cast({:increment, attrs}, state) do + counts = + Map.update(state.counts, key(attrs), {attrs, 1}, fn {existing_attrs, count} -> + {existing_attrs, count + 1} + end) + + {:noreply, %{state | counts: counts}} + end + + @impl true + def handle_call(:flush, _from, state) do + persist_batch(state.counts) + {:reply, :ok, %{state | counts: %{}}} + end + + @impl true + def handle_info(:flush, state) do + persist_batch(state.counts) + schedule_flush() + {:noreply, %{state | counts: %{}}} + end + + defp schedule_flush do + Process.send_after(self(), :flush, @flush_interval_ms) + end + + defp persist_batch(counts) when map_size(counts) == 0, do: :ok + + defp persist_batch(counts) do + Enum.each(counts, fn {_key, {attrs, count}} -> + %QueryStat{} + |> QueryStat.changeset(Map.put(attrs, :query_count, count)) + |> Repo.insert( + on_conflict: [inc: [query_count: count]], + conflict_target: [:zone_id, :query_hour, :qname, :qtype, :rcode, :transport], + returning: false + ) + end) + + :ok + rescue + _ -> :ok + end + + defp key(attrs) do + { + attrs.zone_id, + attrs.query_date, + attrs.query_hour, + attrs.qname, + attrs.qtype, + attrs.rcode, + attrs.transport + } + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/record.ex b/apps/elektrine_dns/lib/elektrine/dns/record.ex new file mode 100644 index 0000000..50bdb6f --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/record.ex @@ -0,0 +1,650 @@ +defmodule Elektrine.DNS.Record do + @moduledoc """ + Resource record inside a managed authoritative zone. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias Elektrine.DNS.ServiceBinding + alias Elektrine.Security.URLValidator + + @types ~w(A AAAA ALIAS CAA CNAME DNSKEY DS HTTPS MX NS SRV SSHFP SVCB TLSA TXT) + @proxyable_types ~w(A AAAA CNAME ALIAS) + @proxy_schemes ~w(http https) + + schema "dns_records" do + field :name, :string + field :type, :string + field :ttl, :integer, default: 300 + field :content, :string + field :source, :string, default: "user" + field :service, :string + field :managed, :boolean, default: false + field :managed_key, :string + field :required, :boolean, default: false + field :metadata, :map, default: %{} + field :private, :boolean, virtual: true, default: false + field :proxied, :boolean, virtual: true, default: false + field :proxy_origin_scheme, :string, virtual: true + field :proxy_origin_port, :integer, virtual: true + field :proxy_origin_host_header, :string, virtual: true + field :proxy_atomine_gate, :boolean, virtual: true, default: true + field :health_check_enabled, :boolean, virtual: true, default: false + field :health_check_port, :integer, virtual: true + field :priority, :integer + field :weight, :integer + field :port, :integer + field :flags, :integer + field :tag, :string + field :protocol, :integer + field :algorithm, :integer + field :key_tag, :integer + field :digest_type, :integer + field :usage, :integer + field :selector, :integer + field :matching_type, :integer + + belongs_to :zone, Elektrine.DNS.Zone + + timestamps(type: :utc_datetime) + end + + def changeset(record, attrs) do + record + |> cast(attrs, [ + :name, + :type, + :ttl, + :content, + :source, + :service, + :managed, + :managed_key, + :required, + :metadata, + :private, + :proxied, + :proxy_origin_scheme, + :proxy_origin_port, + :proxy_origin_host_header, + :proxy_atomine_gate, + :health_check_enabled, + :health_check_port, + :priority, + :weight, + :port, + :flags, + :tag, + :protocol, + :algorithm, + :key_tag, + :digest_type, + :usage, + :selector, + :matching_type, + :zone_id + ]) + |> update_change(:name, &normalize_name/1) + |> update_change(:type, &normalize_type/1) + |> update_change(:source, &normalize_source/1) + |> update_change(:service, &normalize_service/1) + |> update_change(:content, &normalize_content/1) + |> put_private_metadata() + |> put_proxy_metadata() + |> put_health_check_metadata() + |> validate_required([:name, :type, :ttl, :content, :zone_id]) + |> validate_inclusion(:type, @types) + |> validate_inclusion(:source, ["user", "system"]) + |> validate_number(:ttl, greater_than: 0, less_than_or_equal_to: 86_400) + |> validate_number(:health_check_port, greater_than: 0, less_than: 65_536) + |> validate_record_name() + |> validate_cname_constraints() + |> normalize_type_specific_content() + |> validate_address_content() + |> validate_alias_target() + |> validate_hostname_targets() + |> validate_proxy_settings() + |> validate_txt_size() + |> validate_type_specific_fields() + |> foreign_key_constraint(:zone_id) + |> unique_constraint(:name, name: :dns_records_identity_unique) + |> unique_constraint(:managed_key, name: :dns_records_zone_managed_key_unique) + end + + def health_check?(%__MODULE__{metadata: %{} = metadata}) do + get_in(metadata, ["health_check", "enabled"]) in [true, "true"] + end + + def health_check?(_), do: false + + def health_check_port(%__MODULE__{metadata: %{} = metadata}) do + case get_in(metadata, ["health_check", "port"]) do + port when is_integer(port) and port in 1..65_535 -> port + _ -> nil + end + end + + def health_check_port(_), do: nil + + defp put_health_check_metadata(changeset) do + if health_check_param_present?(changeset) do + metadata = get_field(changeset, :metadata) || %{} + + metadata = + if get_field(changeset, :health_check_enabled) and + get_field(changeset, :type) in ["A", "AAAA"] do + Map.put(metadata, "health_check", %{ + "enabled" => true, + "port" => normalize_health_check_port(get_field(changeset, :health_check_port)) + }) + else + Map.delete(metadata, "health_check") + end + + put_change(changeset, :metadata, metadata) + else + changeset + end + end + + defp health_check_param_present?(%{params: params}) when is_map(params) do + Enum.any?( + ["health_check_enabled", :health_check_enabled, "health_check_port", :health_check_port], + &Map.has_key?(params, &1) + ) + end + + defp health_check_param_present?(_changeset), do: false + + defp normalize_health_check_port(port) when is_integer(port) and port in 1..65_535, do: port + defp normalize_health_check_port(_port), do: 443 + + def private?(%__MODULE__{metadata: %{} = metadata}) do + metadata["visibility"] == "private" or metadata["private"] == true + end + + def private?(_), do: false + + def proxied?(%__MODULE__{metadata: %{} = metadata}) do + get_in(metadata, ["proxy", "enabled"]) == true + end + + def proxied?(_), do: false + + def proxy_config(%__MODULE__{metadata: %{} = metadata}) do + case Map.get(metadata, "proxy") do + %{} = proxy -> proxy + _ -> %{} + end + end + + def proxy_origin_scheme(%__MODULE__{} = record) do + record + |> proxy_config() + |> Map.get("origin_scheme", "https") + end + + def proxy_origin_port(%__MODULE__{} = record) do + configured_port = record |> proxy_config() |> Map.get("origin_port") + + cond do + is_integer(configured_port) -> configured_port + proxy_origin_scheme(record) == "http" -> 80 + true -> 443 + end + end + + def proxy_origin_host_header(%__MODULE__{} = record) do + record + |> proxy_config() + |> Map.get("origin_host_header") + end + + def proxy_atomine_gate?(%__MODULE__{} = record) do + record + |> proxy_config() + |> Map.get("atomine_gate", true) + end + + defp put_private_metadata(changeset) do + if Map.has_key?(changeset.changes, :private) do + metadata = get_field(changeset, :metadata) || %{} + + metadata = + if get_field(changeset, :private) do + metadata + |> Map.put("visibility", "private") + |> Map.put("private", true) + else + metadata + |> Map.delete("visibility") + |> Map.delete("private") + end + + put_change(changeset, :metadata, metadata) + else + changeset + end + end + + defp put_proxy_metadata(changeset) do + if proxy_param_present?(changeset) do + metadata = get_field(changeset, :metadata) || %{} + + metadata = + if get_field(changeset, :proxied) do + Map.put(metadata, "proxy", %{ + "enabled" => true, + "origin_scheme" => normalize_proxy_scheme(get_field(changeset, :proxy_origin_scheme)), + "origin_port" => normalize_proxy_port(get_field(changeset, :proxy_origin_port)), + "origin_host_header" => + normalize_optional_host(get_field(changeset, :proxy_origin_host_header)), + "atomine_gate" => get_field(changeset, :proxy_atomine_gate) != false + }) + else + Map.delete(metadata, "proxy") + end + + put_change(changeset, :metadata, metadata) + else + changeset + end + end + + defp proxy_param_present?(%{params: params}) when is_map(params) do + keys = [ + "proxied", + :proxied, + "proxy_origin_scheme", + :proxy_origin_scheme, + "proxy_origin_port", + :proxy_origin_port, + "proxy_origin_host_header", + :proxy_origin_host_header, + "proxy_atomine_gate", + :proxy_atomine_gate + ] + + Enum.any?(keys, &Map.has_key?(params, &1)) + end + + defp proxy_param_present?(_changeset), do: false + + defp normalize_proxy_scheme(value) when is_binary(value) do + value = value |> String.trim() |> String.downcase() + if value in @proxy_schemes, do: value, else: "https" + end + + defp normalize_proxy_scheme(_), do: "https" + + defp normalize_proxy_port(port) when is_integer(port) and port in 1..65_535, do: port + + defp normalize_proxy_port(port) when is_binary(port) do + case Integer.parse(String.trim(port)) do + {int, ""} when int in 1..65_535 -> int + _ -> nil + end + end + + defp normalize_proxy_port(_), do: nil + + defp normalize_optional_host(value) when is_binary(value) do + value = value |> String.trim() |> String.trim_trailing(".") |> String.downcase() + if value == "", do: nil, else: value + end + + defp normalize_optional_host(_), do: nil + + defp normalize_name(nil), do: nil + defp normalize_name(""), do: "@" + + defp normalize_name(name) do + case name |> String.trim() |> String.trim_trailing(".") |> String.downcase() do + "" -> "@" + "\\@" -> "@" + normalized -> normalized + end + end + + defp normalize_type(nil), do: nil + defp normalize_type(type), do: type |> String.trim() |> String.upcase() + + defp normalize_content(nil), do: nil + defp normalize_content(content), do: String.trim(content) + + defp normalize_source(nil), do: nil + defp normalize_source(source), do: source |> String.trim() |> String.downcase() + + defp normalize_service(nil), do: nil + defp normalize_service(service), do: service |> String.trim() |> String.downcase() + + defp normalize_type_specific_content(changeset) do + case {get_field(changeset, :type), get_field(changeset, :content)} do + {"ALIAS", content} when is_binary(content) -> + put_change(changeset, :content, normalize_hostname(content)) + + {type, content} when type in ["DS", "TLSA", "SSHFP"] and is_binary(content) -> + normalized = content |> String.replace(~r/\s+/, "") |> String.upcase() + put_change(changeset, :content, normalized) + + {"DNSKEY", content} when is_binary(content) -> + put_change(changeset, :content, String.replace(content, ~r/\s+/, "")) + + {type, content} when type in ["HTTPS", "SVCB"] and is_binary(content) -> + case ServiceBinding.normalize_content(content) do + {:ok, normalized} -> put_change(changeset, :content, normalized) + {:error, _reason} -> changeset + end + + _ -> + changeset + end + end + + defp validate_type_specific_fields(changeset) do + case get_field(changeset, :type) do + "MX" -> + changeset + |> validate_required([:priority]) + |> validate_number(:priority, greater_than_or_equal_to: 0, less_than_or_equal_to: 65_535) + + "SRV" -> + changeset + |> validate_required([:priority, :weight, :port]) + |> validate_number(:priority, greater_than_or_equal_to: 0, less_than_or_equal_to: 65_535) + |> validate_number(:weight, greater_than_or_equal_to: 0, less_than_or_equal_to: 65_535) + |> validate_number(:port, greater_than_or_equal_to: 0, less_than_or_equal_to: 65_535) + + "CAA" -> + changeset + |> validate_required([:flags, :tag]) + |> validate_number(:flags, greater_than_or_equal_to: 0, less_than_or_equal_to: 255) + + "DNSKEY" -> + changeset + |> validate_required([:flags, :protocol, :algorithm]) + |> validate_number(:flags, greater_than_or_equal_to: 0, less_than_or_equal_to: 65_535) + |> validate_number(:protocol, greater_than_or_equal_to: 0, less_than_or_equal_to: 255) + |> validate_number(:algorithm, greater_than_or_equal_to: 0, less_than_or_equal_to: 255) + |> validate_dnskey_content() + + "DS" -> + changeset + |> validate_required([:key_tag, :algorithm, :digest_type]) + |> validate_number(:key_tag, greater_than_or_equal_to: 0, less_than_or_equal_to: 65_535) + |> validate_number(:algorithm, greater_than_or_equal_to: 0, less_than_or_equal_to: 255) + |> validate_number(:digest_type, greater_than_or_equal_to: 0, less_than_or_equal_to: 255) + |> validate_hex_content() + + "TLSA" -> + changeset + |> validate_required([:usage, :selector, :matching_type]) + |> validate_number(:usage, greater_than_or_equal_to: 0, less_than_or_equal_to: 255) + |> validate_number(:selector, greater_than_or_equal_to: 0, less_than_or_equal_to: 255) + |> validate_number(:matching_type, + greater_than_or_equal_to: 0, + less_than_or_equal_to: 255 + ) + |> validate_hex_content() + + "SSHFP" -> + changeset + |> validate_required([:algorithm, :digest_type]) + |> validate_number(:algorithm, greater_than_or_equal_to: 0, less_than_or_equal_to: 255) + |> validate_number(:digest_type, greater_than_or_equal_to: 0, less_than_or_equal_to: 255) + |> validate_hex_content() + + type when type in ["HTTPS", "SVCB"] -> + changeset + |> validate_required([:priority]) + |> validate_number(:priority, greater_than_or_equal_to: 0, less_than_or_equal_to: 65_535) + |> validate_service_binding_content() + + _ -> + changeset + end + end + + defp validate_cname_constraints(changeset) do + case {get_field(changeset, :type), get_field(changeset, :name)} do + {"CNAME", "@"} -> + add_error(changeset, :type, "cannot be used at the zone apex") + + {"ALIAS", name} when is_binary(name) and name != "@" -> + add_error(changeset, :type, "can only be used at the zone apex") + + _ -> + changeset + end + end + + defp validate_record_name(changeset) do + validate_change(changeset, :name, fn :name, name -> + cond do + not is_binary(name) or name == "" -> + [name: "is invalid"] + + name == "@" -> + [] + + String.length(name) > 253 -> + [name: "is too long"] + + String.contains?(name, "..") -> + [name: "contains an empty label"] + + String.contains?(name, "*") and not wildcard_name?(name) -> + [name: "has invalid wildcard placement"] + + Enum.all?(non_wildcard_labels(name), &valid_label?/1) -> + [] + + true -> + [name: "contains invalid labels"] + end + end) + end + + defp wildcard_name?("*"), do: true + defp wildcard_name?("*." <> rest), do: rest != "" and not String.contains?(rest, "*") + defp wildcard_name?(_), do: false + + # Returns the labels of a name excluding a leading "*" wildcard label, so the + # remaining labels can be validated normally. A bare "*" yields no labels. + defp non_wildcard_labels(name) do + case String.split(name, ".") do + ["*" | rest] -> rest + labels -> labels + end + end + + defp valid_label?("_" <> rest), do: valid_service_label?(rest) + + defp valid_label?(label) do + byte_size(label) in 1..63 and String.match?(label, ~r/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/) + end + + defp valid_service_label?(rest) do + byte_size(rest) in 0..62 and String.match?(rest, ~r/^[a-z0-9_-]*$/) + end + + defp validate_alias_target(changeset) do + case {get_field(changeset, :type), get_field(changeset, :content)} do + {"ALIAS", content} when is_binary(content) -> + if Elektrine.DNS.public_hostname?(content) do + changeset + else + add_error(changeset, :content, "must point to a public DNS hostname") + end + + _ -> + changeset + end + end + + defp validate_hostname_targets(changeset) do + case {get_field(changeset, :type), get_field(changeset, :content)} do + {type, content} when type in ["CNAME", "MX", "NS", "SRV"] and is_binary(content) -> + if valid_hostname_target?(content) do + changeset + else + add_error(changeset, :content, "must be a public DNS hostname") + end + + _ -> + changeset + end + end + + defp validate_proxy_settings(changeset) do + if proxied_changeset?(changeset) do + changeset + |> validate_proxy_record_type() + |> validate_proxy_origin() + |> validate_proxy_host_header() + else + changeset + end + end + + defp proxied_changeset?(changeset) do + metadata = get_field(changeset, :metadata) || %{} + get_in(metadata, ["proxy", "enabled"]) == true + end + + defp validate_proxy_record_type(changeset) do + if get_field(changeset, :type) in @proxyable_types do + changeset + else + add_error(changeset, :proxied, "is only supported for A, AAAA, CNAME, and ALIAS records") + end + end + + defp validate_proxy_origin(changeset) do + case {get_field(changeset, :type), get_field(changeset, :content)} do + {type, _content} when type in ["A", "AAAA"] -> + changeset + + {type, content} when type in ["CNAME", "ALIAS"] and is_binary(content) -> + if Elektrine.DNS.public_hostname?(content) do + changeset + else + add_error(changeset, :content, "must be a public origin hostname when proxied") + end + + _ -> + changeset + end + end + + defp validate_proxy_host_header(changeset) do + host_header = get_in(get_field(changeset, :metadata) || %{}, ["proxy", "origin_host_header"]) + + if is_binary(host_header) and not valid_hostname_target?(host_header) do + add_error(changeset, :proxy_origin_host_header, "must be a public DNS hostname") + else + changeset + end + end + + defp validate_address_content(changeset) do + case {get_field(changeset, :type), get_field(changeset, :content)} do + {"A", content} when is_binary(content) -> + changeset + |> validate_ip_address(content, 4, "must be a valid IPv4 address") + |> validate_public_ip_address(content) + + {"AAAA", content} when is_binary(content) -> + changeset + |> validate_ip_address(content, 8, "must be a valid IPv6 address") + |> validate_public_ip_address(content) + + _ -> + changeset + end + end + + defp validate_ip_address(changeset, content, tuple_size, message) do + case :inet.parse_address(String.to_charlist(content)) do + {:ok, address} when tuple_size(address) == tuple_size -> changeset + _ -> add_error(changeset, :content, message) + end + end + + defp validate_public_ip_address(changeset, content) do + if URLValidator.private_ip?(content) do + add_error(changeset, :content, "must be a public address") + else + changeset + end + end + + defp valid_hostname_target?(content) do + content = content |> String.trim() |> String.trim_trailing(".") |> String.downcase() + + content != "" and String.contains?(content, ".") and + not String.contains?(content, ["/", "@", " "]) and + Enum.all?(String.split(content, "."), &valid_label?/1) and not ip_literal?(content) + end + + defp ip_literal?(content) do + match?({:ok, _}, :inet.parse_address(String.to_charlist(content))) + end + + defp validate_txt_size(changeset) do + case {get_field(changeset, :type), get_field(changeset, :content)} do + {"TXT", content} when is_binary(content) and byte_size(content) > 65_535 -> + add_error(changeset, :content, "is too large for a TXT record") + + _ -> + changeset + end + end + + defp normalize_hostname(hostname) do + hostname + |> String.trim() + |> String.trim_trailing(".") + |> String.downcase() + end + + defp validate_dnskey_content(changeset) do + validate_change(changeset, :content, fn :content, value -> + case decode_base64(value) do + {:ok, _} -> [] + :error -> [content: "must be valid base64 public key data"] + end + end) + end + + defp validate_hex_content(changeset) do + validate_change(changeset, :content, fn :content, value -> + case decode_hex(value) do + {:ok, _} -> [] + :error -> [content: "must be valid hexadecimal data"] + end + end) + end + + defp validate_service_binding_content(changeset) do + validate_change(changeset, :content, fn :content, value -> + case ServiceBinding.parse_content(value) do + {:ok, _parsed} -> [] + {:error, reason} -> [content: reason] + end + end) + end + + defp decode_base64(value) when is_binary(value) do + case Base.decode64(value) do + {:ok, decoded} -> {:ok, decoded} + :error -> Base.decode64(value, padding: false) + end + end + + defp decode_base64(_), do: :error + + defp decode_hex(value) when is_binary(value), do: Base.decode16(value, case: :mixed) + defp decode_hex(_), do: :error +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/recursive.ex b/apps/elektrine_dns/lib/elektrine/dns/recursive.ex new file mode 100644 index 0000000..e8e3f1a --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/recursive.ex @@ -0,0 +1,667 @@ +defmodule Elektrine.DNS.Recursive do + @moduledoc false + + import Bitwise + + alias Elektrine.DNS + alias Elektrine.DNS.Packet + alias Elektrine.DNS.RecursiveCache + + @max_depth 12 + @max_cache_ttl_seconds 86_400 + @budget_key {__MODULE__, :upstream_budget} + @supported_types [:a, :aaaa, :ns, :cname, :mx, :txt, :srv, :ds, :dnskey, :tlsa] + + def resolve(packet, opts \\ []) when is_binary(packet) do + case Packet.decode_query(packet) do + {:ok, query} -> + do_resolve(query, opts) + + {:error, :format_error} -> + Packet.encode_error(packet, :formerr) + end + end + + def allow_recursive?(client_ip), do: authorize_client(client_ip) == :ok + + defp do_resolve(query, opts) do + response_opts = [recursion_available: true, transport: Keyword.get(opts, :transport)] + + cond do + not DNS.recursive_enabled?() -> + Packet.encode_response(query, [], :refused, response_opts) + + query.qtype == :any -> + Packet.encode_response(query, [], :refused, response_opts) + + not forwarding_mode?() and query.qtype not in @supported_types -> + Packet.encode_response(query, [], :notimp, response_opts) + + authorize_client(Keyword.get(opts, :client_ip)) != :ok -> + Packet.encode_response(query, [], :refused, response_opts) + + true -> + init_upstream_budget() + + case cached_or_iterative_resolve(query, MapSet.new()) do + {:ok, result} -> + Packet.encode_response(query, result.answers, result.rcode, + authority: result.authority, + additional: result.additional, + authentic_data: Map.get(result, :authentic_data, false), + recursion_available: true, + transport: Keyword.get(opts, :transport) + ) + + {:error, :refused} -> + Packet.encode_response(query, [], :refused, response_opts) + + {:error, :unsupported_type} -> + Packet.encode_response(query, [], :notimp, response_opts) + + {:error, _reason} -> + Packet.encode_response(query, [], :servfail, response_opts) + end + end + end + + defp cached_or_iterative_resolve(query, seen_cnames) do + cache_key = {normalize_name(query.qname), query.qtype} + + case RecursiveCache.get(cache_key) do + {:ok, result} -> {:ok, result} + :error -> uncached_resolve(query, seen_cnames) + end + end + + defp uncached_resolve(query, seen_cnames) do + case DNS.recursive_upstreams() do + [] -> iterative_resolve(query, DNS.recursive_root_hints(), 0, seen_cnames) + upstreams -> forward_resolve(query, upstreams) + end + end + + defp iterative_resolve(_query, _servers, depth, _seen_cnames) when depth > @max_depth, + do: {:error, :max_depth} + + defp iterative_resolve(query, servers, depth, seen_cnames) do + with {:ok, response} <- query_nameservers(query, servers), + {:ok, decoded} <- decode_response(response) do + handle_response(query, decoded, depth, seen_cnames) + end + end + + defp forward_resolve(query, upstreams) do + with {:ok, response} <- query_upstreams(query, upstreams), + {:ok, decoded} <- decode_response(response) do + result = %{ + answers: convert_records(answer_records(decoded)), + authority: convert_records(authority_records(decoded)), + additional: convert_records(additional_records(decoded)), + rcode: message_rcode(decoded), + authentic_data: authentic_data?(response) + } + + if cacheable_rcode?(result.rcode) do + cache_result(query, result) + else + {:ok, result} + end + end + end + + defp handle_response(query, decoded, depth, seen_cnames) do + rcode = message_rcode(decoded) + answers = convert_records(answer_records(decoded)) + authority = convert_records(authority_records(decoded)) + additional = convert_records(additional_records(decoded)) + referral_ns = referral_nameservers(decoded) + + cond do + rcode == :nxdomain -> + result = %{answers: [], authority: authority, additional: additional, rcode: :nxdomain} + cache_result(query, result) + + matching_answers?(answers, query.qtype) -> + result = %{ + answers: answers, + authority: authority, + additional: additional, + rcode: :noerror + } + + cache_result(query, result) + + cname = first_cname_answer(answers) -> + follow_cname(query, cname, answers, depth, seen_cnames) + + referral_ns != [] -> + follow_referral(query, referral_ns, decoded, depth, seen_cnames) + + rcode == :noerror -> + result = %{answers: [], authority: authority, additional: additional, rcode: :noerror} + cache_result(query, result) + + true -> + {:error, :servfail} + end + end + + defp follow_cname(query, cname, existing_answers, _depth, seen_cnames) do + target = normalize_name(cname.content) + + if MapSet.member?(seen_cnames, target) do + {:error, :cname_loop} + else + alias_query = %{query | qname: target} + + case cached_or_iterative_resolve(alias_query, MapSet.put(seen_cnames, target)) do + {:ok, result} -> + merged = %{result | answers: existing_answers ++ result.answers} + cache_result(query, merged) + + error -> + error + end + end + end + + defp follow_referral(query, ns_names, decoded, depth, seen_cnames) do + glue_ips = glue_ips_for_ns(ns_names, decoded) + + next_servers = + if glue_ips == [] do + ns_names + |> Enum.flat_map(&resolve_nameserver_ips(&1, depth, seen_cnames)) + |> Enum.uniq() + else + Enum.map(glue_ips, &{&1, 53}) + end + + if next_servers == [] do + {:error, :no_referral_addresses} + else + iterative_resolve(query, rotate_servers(next_servers, query.id), depth + 1, seen_cnames) + end + end + + defp resolve_nameserver_ips(name, _depth, seen_cnames) do + qname = normalize_name(name) + + @supported_types + |> Enum.filter(&(&1 in [:a, :aaaa])) + |> Enum.flat_map(fn qtype -> + case cached_or_iterative_resolve(%{id: 0, rd: 0, qname: qname, qtype: qtype}, seen_cnames) do + {:ok, result} -> + result.answers + |> Enum.filter(&(&1.type in [:a, :aaaa])) + |> Enum.map(&{record_ip(&1), 53}) + + _ -> + [] + end + end) + |> Enum.uniq() + end + + defp query_nameservers(query, servers) do + {upstream_query, packet} = build_upstream_query(query, rd: 0, dnssec_ok: false) + + servers + |> rotate_servers(query.id) + |> Enum.reduce_while({:error, :timeout}, fn {ip, port}, _acc -> + case exchange_udp(ip, port, packet, DNS.recursive_timeout()) do + {:ok, response} -> + final = maybe_retry_over_tcp(ip, port, packet, response) + + case valid_upstream_response?(upstream_query, final) do + true -> {:halt, {:ok, final}} + false -> {:cont, {:error, :invalid_response}} + end + + {:error, :budget_exhausted} = error -> + {:halt, error} + + {:error, _reason} = error -> + {:cont, error} + end + end) + end + + defp query_upstreams(query, upstreams) do + {upstream_query, packet} = build_upstream_query(query, rd: 1, dnssec_ok: true) + + upstreams + |> rotate_servers(query.id) + |> Enum.reduce_while({:error, :timeout}, fn {ip, port}, _acc -> + case exchange_udp(ip, port, packet, DNS.recursive_timeout()) do + {:ok, response} -> + final = maybe_retry_over_tcp(ip, port, packet, response) + + case valid_upstream_response?(upstream_query, final) do + true -> {:halt, {:ok, final}} + false -> {:cont, {:error, :invalid_response}} + end + + {:error, :budget_exhausted} = error -> + {:halt, error} + + {:error, _reason} = error -> + {:cont, error} + end + end) + end + + defp build_upstream_query(query, opts) do + upstream_query = + query + |> Map.merge(%{ + udp_size: DNS.max_udp_payload(), + dnssec_ok: Keyword.get(opts, :dnssec_ok, false) + }) + |> Map.put(:id, random_id()) + |> Map.put(:rd, Keyword.get(opts, :rd, 0)) + + {upstream_query, Packet.encode_query(upstream_query)} + end + + defp decode_response(response) do + case :inet_dns.decode(response) do + {:ok, decoded} -> {:ok, decoded} + other -> other + end + end + + defp matching_answers?(answers, :any), do: answers != [] + defp matching_answers?(answers, qtype), do: Enum.any?(answers, &(&1.type == qtype)) + + defp first_cname_answer(answers), do: Enum.find(answers, &(&1.type == :cname)) + + defp referral_nameservers(decoded) do + decoded + |> authority_records() + |> Enum.filter(&(normalize_rr_type(rr_type(&1)) == :ns)) + |> Enum.map(&rr_data/1) + |> Enum.map(&normalize_name/1) + |> Enum.uniq() + end + + defp glue_ips_for_ns(ns_names, decoded) do + delegated_names = referral_domains(decoded) + + decoded + |> additional_records() + |> Enum.filter(fn record -> + normalize_rr_type(rr_type(record)) in [:a, :aaaa] and + normalize_name(rr_domain(record)) in ns_names and + within_bailiwick?(normalize_name(rr_domain(record)), delegated_names) + end) + |> Enum.map(&rr_ip_tuple/1) + end + + defp referral_domains(decoded) do + decoded + |> authority_records() + |> Enum.filter(&(normalize_rr_type(rr_type(&1)) == :ns)) + |> Enum.map(&rr_domain/1) + |> Enum.uniq() + end + + defp convert_records(records) do + records + |> Enum.map(&convert_record/1) + |> Enum.reject(&is_nil/1) + end + + defp convert_record(rr) do + case normalize_rr_type(rr_type(rr)) do + :a -> + %{name: rr_domain(rr), type: :a, content: rr_ip(rr), ttl: rr_ttl(rr)} + + :aaaa -> + %{name: rr_domain(rr), type: :aaaa, content: rr_ip(rr), ttl: rr_ttl(rr)} + + :ns -> + %{name: rr_domain(rr), type: :ns, value: rr_data(rr) |> to_string(), ttl: rr_ttl(rr)} + + :cname -> + %{name: rr_domain(rr), type: :cname, content: rr_data(rr) |> to_string(), ttl: rr_ttl(rr)} + + :txt -> + %{name: rr_domain(rr), type: :txt, content: txt_content(rr_data(rr)), ttl: rr_ttl(rr)} + + :mx -> + {priority, exchange} = rr_data(rr) + + %{ + name: rr_domain(rr), + type: :mx, + priority: priority, + content: to_string(exchange), + ttl: rr_ttl(rr) + } + + :srv -> + {priority, weight, port, target} = rr_data(rr) + + %{ + name: rr_domain(rr), + type: :srv, + priority: priority, + weight: weight, + port: port, + content: to_string(target), + ttl: rr_ttl(rr) + } + + :ds -> + {key_tag, algorithm, digest_type, digest} = decode_ds_data(rr_data(rr)) + + %{ + name: rr_domain(rr), + type: :ds, + key_tag: key_tag, + algorithm: algorithm, + digest_type: digest_type, + content: Base.encode16(digest, case: :upper), + ttl: rr_ttl(rr) + } + + :dnskey -> + {flags, protocol, algorithm, public_key} = decode_dnskey_data(rr_data(rr)) + + %{ + name: rr_domain(rr), + type: :dnskey, + flags: flags, + protocol: protocol, + algorithm: algorithm, + content: Base.encode64(public_key), + ttl: rr_ttl(rr) + } + + :tlsa -> + {usage, selector, matching_type, association_data} = decode_tlsa_data(rr_data(rr)) + + %{ + name: rr_domain(rr), + type: :tlsa, + usage: usage, + selector: selector, + matching_type: matching_type, + content: Base.encode16(association_data, case: :upper), + ttl: rr_ttl(rr) + } + + :soa -> + {mname, rname, serial, refresh, retry, expire, minimum} = rr_data(rr) + + %{ + name: rr_domain(rr), + type: :soa, + mname: to_string(mname), + rname: to_string(rname), + serial: serial, + refresh: refresh, + retry: retry, + expire: expire, + minimum: minimum, + ttl: rr_ttl(rr) + } + + _ -> + nil + end + end + + defp cache_result(query, result) do + ttl = result |> cache_ttl() |> min(@max_cache_ttl_seconds) + RecursiveCache.put({normalize_name(query.qname), query.qtype}, result, ttl) + {:ok, result} + end + + defp cache_ttl(result) do + if result.answers == [] do + negative_cache_ttl(result.authority) + else + result + |> Map.take([:answers, :authority, :additional]) + |> Map.values() + |> List.flatten() + |> Enum.map(&Map.get(&1, :ttl, 60)) + |> Enum.reject(&(&1 <= 0)) + |> Enum.min(fn -> 60 end) + end + end + + defp negative_cache_ttl(authority) do + authority + |> Enum.find(&(&1.type == :soa)) + |> case do + %{minimum: minimum, ttl: ttl} when is_integer(minimum) and minimum > 0 -> min(minimum, ttl) + %{ttl: ttl} when is_integer(ttl) and ttl > 0 -> ttl + _ -> 60 + end + end + + defp exchange_udp(ip, port, packet, timeout) do + with :ok <- consume_upstream_budget() do + DNS.recursive_transport().exchange_udp(ip, port, packet, timeout) + end + end + + defp exchange_tcp(ip, port, packet, timeout) do + with :ok <- consume_upstream_budget() do + DNS.recursive_transport().exchange_tcp(ip, port, packet, timeout) + end + end + + # Bounds the total upstream exchanges a single client query can trigger, + # so crafted delegation chains (NXNSAttack-style) can't multiply work. + # Resolution for one query runs in a single process, so the budget lives + # in the process dictionary rather than being threaded through every hop. + defp init_upstream_budget do + Process.put(@budget_key, DNS.recursive_max_upstream_queries()) + end + + defp consume_upstream_budget do + case Process.get(@budget_key) do + nil -> :ok + remaining when remaining <= 0 -> {:error, :budget_exhausted} + remaining -> Process.put(@budget_key, remaining - 1) && :ok + end + end + + defp maybe_retry_over_tcp(ip, port, packet, udp_response) do + if truncated?(udp_response) do + case exchange_tcp(ip, port, packet, DNS.recursive_timeout()) do + {:ok, tcp_response} -> tcp_response + {:error, _reason} -> udp_response + end + else + udp_response + end + end + + defp truncated?(<<_id::16, flags::16, _rest::binary>>), do: (flags &&& 0x0200) != 0 + defp truncated?(_), do: false + + defp rotate_servers(servers, query_id) when is_list(servers) and servers != [] do + offset = rem(max(query_id, 0), length(servers)) + Enum.drop(servers, offset) ++ Enum.take(servers, offset) + end + + defp rotate_servers(servers, _query_id), do: servers + + defp random_id do + :crypto.strong_rand_bytes(2) |> :binary.decode_unsigned() + end + + defp forwarding_mode?, do: DNS.recursive_upstreams() != [] + + defp authorize_client(nil), do: {:error, :missing_client_ip} + + defp authorize_client(client_ip) do + allowed = parsed_allow_cidrs() + + if Enum.any?(allowed, &cidr_match?(client_ip, &1)) do + :ok + else + {:error, :refused} + end + end + + defp parse_cidr(cidr) do + with [address, prefix] <- String.split(cidr, "/", parts: 2), + {:ok, ip} <- :inet.parse_address(String.to_charlist(address)), + {prefix, ""} <- Integer.parse(prefix), + true <- valid_cidr_prefix?(ip, prefix) do + {ip, prefix} + else + _ -> nil + end + end + + defp valid_cidr_prefix?(ip, prefix) when tuple_size(ip) == 4, do: prefix in 0..32 + defp valid_cidr_prefix?(ip, prefix) when tuple_size(ip) == 8, do: prefix in 0..128 + defp valid_cidr_prefix?(_, _), do: false + + defp cidr_match?(ip, {network, prefix}) when tuple_size(ip) == tuple_size(network) do + bits = if tuple_size(ip) == 4, do: 32, else: 128 + ip_int = ip_to_integer(ip) + net_int = ip_to_integer(network) + mask = ((1 <<< prefix) - 1) <<< (bits - prefix) + (ip_int &&& mask) == (net_int &&& mask) + end + + defp cidr_match?(_, _), do: false + + defp parsed_allow_cidrs do + cidrs = DNS.recursive_allow_cidrs() + key = {__MODULE__, :parsed_allow_cidrs} + + case :persistent_term.get(key, :missing) do + {^cidrs, parsed} -> + parsed + + _ -> + parsed = cidrs |> Enum.map(&parse_cidr/1) |> Enum.reject(&is_nil/1) + :persistent_term.put(key, {cidrs, parsed}) + parsed + end + end + + defp ip_to_integer(tuple) do + step = if tuple_size(tuple) == 4, do: 8, else: 16 + + tuple + |> Tuple.to_list() + |> Enum.reduce(0, fn segment, acc -> (acc <<< step) + segment end) + end + + defp message_rcode({:dns_rec, header, _qd, _an, _ns, _ar}) do + case elem(header, 9) do + 0 -> :noerror + 1 -> :formerr + 2 -> :servfail + 3 -> :nxdomain + 4 -> :notimp + 5 -> :refused + other -> other + end + end + + defp answer_records({:dns_rec, _header, _qd, answers, _ns, _ar}), do: answers + defp authority_records({:dns_rec, _header, _qd, _an, authority, _ar}), do: authority + defp additional_records({:dns_rec, _header, _qd, _an, _ns, additional}), do: additional + + defp rr_domain(rr), do: rr |> elem(1) |> to_string() |> normalize_name() + defp rr_type(rr), do: elem(rr, 2) + defp rr_ttl(rr), do: elem(rr, 5) + defp rr_data(rr), do: elem(rr, 6) + + defp normalize_rr_type(1), do: :a + defp normalize_rr_type(2), do: :ns + defp normalize_rr_type(5), do: :cname + defp normalize_rr_type(6), do: :soa + defp normalize_rr_type(15), do: :mx + defp normalize_rr_type(16), do: :txt + defp normalize_rr_type(28), do: :aaaa + defp normalize_rr_type(33), do: :srv + defp normalize_rr_type(43), do: :ds + defp normalize_rr_type(48), do: :dnskey + defp normalize_rr_type(52), do: :tlsa + defp normalize_rr_type(type), do: type + + defp decode_ds_data({key_tag, algorithm, digest_type, digest}), + do: {key_tag, algorithm, digest_type, digest} + + defp decode_ds_data(<>), + do: {key_tag, algorithm, digest_type, digest} + + defp decode_dnskey_data({flags, protocol, algorithm, public_key}), + do: {flags, protocol, algorithm, public_key} + + defp decode_dnskey_data(<>), + do: {flags, protocol, algorithm, public_key} + + defp decode_tlsa_data({usage, selector, matching_type, association_data}), + do: {usage, selector, matching_type, association_data} + + defp decode_tlsa_data(<>), + do: {usage, selector, matching_type, association_data} + + defp rr_ip(rr) do + rr + |> rr_data() + |> :inet.ntoa() + |> to_string() + end + + defp rr_ip_tuple(rr), do: rr |> rr_data() + + defp record_ip(%{content: content}), do: parse_ip!(content) + + defp parse_ip!(content) do + {:ok, ip} = :inet.parse_address(String.to_charlist(content)) + ip + end + + defp txt_content(content) when is_list(content) and content != [] and is_list(hd(content)), + do: Enum.map_join(content, "", &to_string/1) + + defp txt_content(content), do: to_string(content) + + defp normalize_name(name) do + name + |> to_string() + |> String.downcase() + |> String.trim_trailing(".") + end + + defp valid_upstream_response?(query, packet) do + with <> <- packet, + true <- id == query.id, + true <- (flags &&& 0x8000) != 0, + {:ok, response_query} <- Packet.decode_question(packet), + true <- normalize_name(response_query.qname) == normalize_name(query.qname), + true <- response_query.qtype == query.qtype do + true + else + _ -> false + end + end + + defp authentic_data?(<<_id::16, flags::16, _rest::binary>>), do: (flags &&& 0x0020) != 0 + defp authentic_data?(_), do: false + + defp cacheable_rcode?(rcode), do: rcode in [:noerror, :nxdomain] + + defp within_bailiwick?(_name, []), do: false + + defp within_bailiwick?(name, delegated_names) do + Enum.any?(delegated_names, fn domain -> + name == domain or String.ends_with?(name, "." <> domain) + end) + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/recursive_cache.ex b/apps/elektrine_dns/lib/elektrine/dns/recursive_cache.ex new file mode 100644 index 0000000..708f045 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/recursive_cache.ex @@ -0,0 +1,97 @@ +defmodule Elektrine.DNS.RecursiveCache do + @moduledoc false + + use GenServer + + @table __MODULE__ + @cleanup_interval_ms 60_000 + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + def get(key) do + case :ets.lookup(@table, key) do + [{^key, expires_at, value}] -> + if expires_at > System.monotonic_time(:millisecond) do + {:ok, value} + else + :ets.delete(@table, key) + :error + end + + _ -> + :error + end + end + + def put(key, value, ttl_seconds) when ttl_seconds > 0 do + expires_at = System.monotonic_time(:millisecond) + ttl_seconds * 1000 + :ets.insert(@table, {key, expires_at, value}) + trim_if_needed() + :ok + end + + def put(_key, _value, _ttl_seconds), do: :ok + + @impl true + def init(_opts) do + :ets.new(@table, [:named_table, :set, :public, read_concurrency: true]) + schedule_cleanup() + {:ok, %{}} + end + + @impl true + def handle_info(:cleanup, state) do + sweep_expired() + trim_to_limit(max_entries()) + schedule_cleanup() + {:noreply, state} + end + + defp trim_if_needed do + max_entries = max_entries() + + if :ets.info(@table, :size) > max_entries do + sweep_expired() + trim_to_limit(max_entries) + end + end + + defp sweep_expired do + now = System.monotonic_time(:millisecond) + + :ets.select_delete(@table, [ + {{:_, :"$1", :_}, [{:<, :"$1", now}], [true]} + ]) + end + + defp trim_to_limit(max_entries) do + size = :ets.info(@table, :size) + + if size > max_entries do + delete_count = size - max_entries + + case :ets.select(@table, [{{:"$1", :_, :_}, [], [:"$1"]}], delete_count) do + {keys, _continuation} -> Enum.each(keys, &:ets.delete(@table, &1)) + :"$end_of_table" -> :ok + end + end + end + + defp max_entries do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:recursive_cache_max_entries, 10_000) + |> max(1) + end + + defp schedule_cleanup do + Process.send_after(self(), :cleanup, cleanup_interval_ms()) + end + + defp cleanup_interval_ms do + Application.get_env(:elektrine, :dns, []) + |> Keyword.get(:recursive_cache_cleanup_interval_ms, @cleanup_interval_ms) + |> max(1) + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/recursive_transport.ex b/apps/elektrine_dns/lib/elektrine/dns/recursive_transport.ex new file mode 100644 index 0000000..c2315e2 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/recursive_transport.ex @@ -0,0 +1,43 @@ +defmodule Elektrine.DNS.RecursiveTransport do + @moduledoc false + + def exchange_udp(ip, port, packet, timeout) do + case :gen_udp.open(0, [:binary, active: false]) do + {:ok, socket} -> + result = + with :ok <- :gen_udp.send(socket, ip, port, packet), + {:ok, {host, recv_port, response}} <- :gen_udp.recv(socket, 0, timeout), + true <- host == ip and recv_port == port do + {:ok, response} + else + {:error, _reason} = error -> error + false -> {:error, :unexpected_upstream} + end + + :gen_udp.close(socket) + result + + {:error, _reason} = error -> + error + end + end + + def exchange_tcp(ip, port, packet, timeout) do + case :gen_tcp.connect(ip, port, [:binary, active: false, packet: 0], timeout) do + {:ok, socket} -> + result = + with :ok <- :gen_tcp.send(socket, <>), + {:ok, <>} <- :gen_tcp.recv(socket, 2, timeout) do + :gen_tcp.recv(socket, length, timeout) + else + {:error, _reason} = error -> error + end + + :gen_tcp.close(socket) + result + + {:error, _reason} = error -> + error + end + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/request_guard.ex b/apps/elektrine_dns/lib/elektrine/dns/request_guard.ex new file mode 100644 index 0000000..f5e589b --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/request_guard.ex @@ -0,0 +1,112 @@ +defmodule Elektrine.DNS.RequestGuard do + @moduledoc false + + use GenServer + + alias Elektrine.DNS + + @table __MODULE__ + @cleanup_interval_ms 60_000 + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + def begin_request(client_ip, transport) when transport in [:udp, :tcp] do + case check_rate(client_ip, transport) do + :ok -> + inflight_key = {:inflight, transport} + inflight = :ets.update_counter(@table, inflight_key, {2, 1}, {inflight_key, 0}) + + if inflight > max_inflight(transport) do + :ets.update_counter(@table, inflight_key, {2, -1}) + {:error, :busy} + else + {:ok, transport} + end + + {:error, _reason} = error -> + error + end + end + + def check_rate(client_ip, transport) when transport in [:udp, :tcp] do + bucket = current_bucket() + client_key = normalize_client_key(client_ip) + rate_key = {:rate, transport, bucket, client_key} + + count = :ets.update_counter(@table, rate_key, {2, 1}, {rate_key, 0}) + + if count > rate_limit(transport) do + {:error, :rate_limited} + else + :ok + end + end + + def finish_request(transport) when transport in [:udp, :tcp] do + inflight_key = {:inflight, transport} + + try do + :ets.update_counter(@table, inflight_key, {2, -1}) + rescue + ArgumentError -> :ok + end + + :ok + end + + @impl true + def init(_opts) do + :ets.new(@table, [ + :named_table, + :public, + :set, + read_concurrency: true, + write_concurrency: true + ]) + + schedule_cleanup() + {:ok, %{}} + end + + @impl true + def handle_info(:cleanup, state) do + cleanup_old_buckets() + schedule_cleanup() + {:noreply, state} + end + + defp cleanup_old_buckets do + cutoff_bucket = current_bucket() - 2 + + @table + |> :ets.tab2list() + |> Enum.each(fn + {{:rate, _transport, bucket, _client_key} = key, _count} when bucket < cutoff_bucket -> + :ets.delete(@table, key) + + _ -> + :ok + end) + end + + defp schedule_cleanup do + Process.send_after(self(), :cleanup, @cleanup_interval_ms) + end + + defp current_bucket do + DNS.rate_limit_window_ms() + |> max(1) + |> then(&(System.monotonic_time(:millisecond) |> div(&1))) + end + + defp normalize_client_key(nil), do: :unknown + defp normalize_client_key(client_ip), do: client_ip + + defp rate_limit(:udp), do: max(DNS.udp_rate_limit_per_window(), 1) + defp rate_limit(:tcp), do: max(DNS.tcp_rate_limit_per_window(), 1) + + defp max_inflight(:udp), do: max(DNS.udp_max_inflight(), 1) + defp max_inflight(:tcp), do: max(DNS.tcp_max_inflight(), 1) +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/service_binding.ex b/apps/elektrine_dns/lib/elektrine/dns/service_binding.ex new file mode 100644 index 0000000..532f265 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/service_binding.ex @@ -0,0 +1,245 @@ +defmodule Elektrine.DNS.ServiceBinding do + @moduledoc false + + @param_keys %{ + "mandatory" => 0, + "alpn" => 1, + "no-default-alpn" => 2, + "port" => 3, + "ipv4hint" => 4, + "ech" => 5, + "ipv6hint" => 6 + } + + def normalize_content(content) when is_binary(content) do + with {:ok, %{target: target, params: params}} <- parse_content(content) do + normalized_params = + params + |> Enum.map_join(" ", fn + {key, nil} -> key + {key, value} -> key <> "=" <> value + end) + + {:ok, String.trim([normalize_target(target), normalized_params] |> Enum.join(" "))} + end + end + + def normalize_content(_), do: {:error, "must include a target hostname"} + + def parse_content(content) when is_binary(content) do + case String.split(String.trim(content), ~r/\s+/, trim: true) do + [] -> + {:error, "must include a target hostname"} + + [target | params] -> + with {:ok, normalized_target} <- validate_target(target), + {:ok, parsed_params} <- parse_params(params) do + {:ok, %{target: normalized_target, params: parsed_params}} + end + end + end + + def parse_content(_), do: {:error, "must include a target hostname"} + + def encode_rdata(priority, content, encode_name_fun) when is_function(encode_name_fun, 1) do + with {:ok, %{target: target, params: params}} <- parse_content(content), + {:ok, encoded_params} <- encode_params(params) do + {:ok, <>} + end + end + + defp validate_target("."), do: {:ok, "."} + + defp validate_target(target) do + normalized = normalize_target(target) + + if normalized == "" do + {:error, "must include a target hostname"} + else + {:ok, normalized} + end + end + + defp normalize_target("."), do: "." + + defp normalize_target(target) do + target + |> String.trim() + |> String.trim_trailing(".") + |> String.downcase() + end + + defp parse_params(params) do + Enum.reduce_while(params, {:ok, []}, fn token, {:ok, acc} -> + case parse_param(token) do + {:ok, parsed} -> {:cont, {:ok, [parsed | acc]}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + {:ok, parsed} -> {:ok, Enum.reverse(parsed)} + error -> error + end + end + + defp parse_param(token) do + case String.split(token, "=", parts: 2) do + [key] -> + normalized_key = normalize_param_key(key) + + if normalized_key == "no-default-alpn" do + {:ok, {normalized_key, nil}} + else + {:error, "parameter #{key} must use key=value format"} + end + + [key, value] -> + normalized_key = normalize_param_key(key) + + cond do + normalized_key == "" -> + {:error, "parameter names cannot be empty"} + + normalized_key == "no-default-alpn" and String.trim(value) != "" -> + {:error, "no-default-alpn does not take a value"} + + true -> + {:ok, {normalized_key, normalize_param_value(value)}} + end + end + end + + defp normalize_param_key(key) do + key + |> String.trim() + |> String.downcase() + end + + defp normalize_param_value(value) do + value + |> String.trim() + |> String.trim("\"") + end + + defp encode_params(params) do + params + |> Enum.map(fn {key, value} -> + with {:ok, code} <- param_code(key), + {:ok, encoded_value} <- encode_param_value(code, value) do + {:ok, {code, <>}} + end + end) + |> Enum.reduce_while({:ok, []}, fn + {:ok, encoded}, {:ok, acc} -> {:cont, {:ok, [encoded | acc]}} + {:error, reason}, _acc -> {:halt, {:error, reason}} + end) + |> case do + {:ok, encoded} -> + {:ok, + encoded + |> Enum.sort_by(fn {code, _bin} -> code end) + |> Enum.map_join(fn {_code, bin} -> bin end)} + + error -> + error + end + end + + defp param_code(key) do + cond do + Map.has_key?(@param_keys, key) -> + {:ok, Map.fetch!(@param_keys, key)} + + String.match?(key, ~r/^key\d+$/) -> + case key |> String.replace_prefix("key", "") |> Integer.parse() do + {code, ""} -> {:ok, code} + _ -> {:error, "unsupported parameter #{key}"} + end + + true -> + {:error, "unsupported parameter #{key}"} + end + end + + defp encode_param_value(0, value) when is_binary(value) do + value + |> split_csv_values() + |> Enum.map(¶m_code/1) + |> Enum.reduce_while({:ok, []}, fn + {:ok, code}, {:ok, acc} -> {:cont, {:ok, [<> | acc]}} + {:error, reason}, _acc -> {:halt, {:error, reason}} + end) + |> case do + {:ok, encoded} -> {:ok, Enum.reverse(encoded) |> IO.iodata_to_binary()} + error -> error + end + end + + defp encode_param_value(1, value) when is_binary(value) do + {:ok, + value + |> split_csv_values() + |> Enum.map(fn protocol -> <> end) + |> IO.iodata_to_binary()} + end + + defp encode_param_value(2, nil), do: {:ok, <<>>} + + defp encode_param_value(3, value) when is_binary(value) do + case Integer.parse(value) do + {port, ""} when port >= 0 and port <= 65_535 -> {:ok, <>} + _ -> {:error, "port parameter must be an integer between 0 and 65535"} + end + end + + defp encode_param_value(4, value) when is_binary(value), do: encode_ip_hints(value, :ipv4) + + defp encode_param_value(5, value) when is_binary(value) do + case Base.decode64(value, padding: false) do + {:ok, decoded} -> {:ok, decoded} + :error -> {:error, "ech parameter must be base64 data"} + end + end + + defp encode_param_value(6, value) when is_binary(value), do: encode_ip_hints(value, :ipv6) + defp encode_param_value(_code, value) when is_binary(value), do: {:ok, value} + defp encode_param_value(_code, nil), do: {:ok, <<>>} + + defp encode_ip_hints(value, family) do + value + |> split_csv_values() + |> Enum.map(&parse_ip_hint(&1, family)) + |> Enum.reduce_while({:ok, []}, fn + {:ok, encoded}, {:ok, acc} -> {:cont, {:ok, [encoded | acc]}} + {:error, reason}, _acc -> {:halt, {:error, reason}} + end) + |> case do + {:ok, encoded} -> {:ok, Enum.reverse(encoded) |> IO.iodata_to_binary()} + error -> error + end + end + + defp parse_ip_hint(ip, family) do + case :inet.parse_address(String.to_charlist(ip)) do + {:ok, tuple} when tuple_size(tuple) == 4 and family == :ipv4 -> + {:ok, tuple |> Tuple.to_list() |> IO.iodata_to_binary()} + + {:ok, tuple} when tuple_size(tuple) == 8 and family == :ipv6 -> + {:ok, tuple |> Tuple.to_list() |> Enum.map(&<<&1::16>>) |> IO.iodata_to_binary()} + + _ -> + {:error, + "#{family_label(family)} parameter must contain valid #{family_label(family)} addresses"} + end + end + + defp split_csv_values(value) do + value + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp family_label(:ipv4), do: "ipv4hint" + defp family_label(:ipv6), do: "ipv6hint" +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/tcp_server.ex b/apps/elektrine_dns/lib/elektrine/dns/tcp_server.ex new file mode 100644 index 0000000..35aab79 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/tcp_server.ex @@ -0,0 +1,145 @@ +defmodule Elektrine.DNS.TCPServer do + @moduledoc false + + use GenServer + + require Logger + + alias Elektrine.DNS.RequestGuard + + @accept_timeout 1_000 + @accept_error_backoff_ms 200 + @recv_timeout 5_000 + @max_queries_per_connection 64 + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__)) + end + + def child_spec(opts) do + %{ + id: Keyword.get(opts, :name, __MODULE__), + start: {__MODULE__, :start_link, [opts]} + } + end + + @impl true + def init(opts) do + family = Keyword.get(opts, :family, :inet) + + case listen(family) do + {:ok, listen_socket} -> + send(self(), :accept) + {:ok, %{listen_socket: listen_socket}} + + {:error, reason} when family == :inet6 -> + Logger.warning("DNS TCP IPv6 listener unavailable: #{inspect(reason)}") + :ignore + + {:error, reason} -> + {:stop, reason} + end + end + + defp listen(family) do + :gen_tcp.listen( + Elektrine.DNS.tcp_port(), + [ + :binary, + family, + packet: 2, + active: false, + backlog: Elektrine.DNS.tcp_max_inflight(), + reuseaddr: true, + ip: wildcard_address(family) + ] ++ family_opts(family) + ) + end + + defp wildcard_address(:inet), do: {0, 0, 0, 0} + defp wildcard_address(:inet6), do: {0, 0, 0, 0, 0, 0, 0, 0} + + # v6only keeps the v6 wildcard bind from claiming the v4 port too. + defp family_opts(:inet), do: [] + defp family_opts(:inet6), do: [{:ipv6_v6only, true}] + + @impl true + def handle_info(:accept, state) do + case :gen_tcp.accept(state.listen_socket, @accept_timeout) do + {:ok, socket} -> + hand_off(socket) + send(self(), :accept) + {:noreply, state} + + {:error, :timeout} -> + send(self(), :accept) + {:noreply, state} + + {:error, :closed} -> + {:stop, :normal, state} + + {:error, reason} -> + # Transient accept failures (e.g. emfile) must not crash the acceptor. + Logger.warning("DNS TCP accept failed: #{inspect(reason)}") + Process.send_after(self(), :accept, @accept_error_backoff_ms) + {:noreply, state} + end + end + + defp hand_off(socket) do + case :inet.peername(socket) do + {:ok, {client_ip, _client_port}} -> + case RequestGuard.begin_request(client_ip, :tcp) do + {:ok, :tcp} -> start_serving(socket, client_ip) + {:error, _reason} -> :gen_tcp.close(socket) + end + + {:error, _reason} -> + :gen_tcp.close(socket) + end + end + + defp start_serving(socket, client_ip) do + case Task.Supervisor.start_child(Elektrine.DNS.TaskSupervisor, fn -> + serve_client(socket, client_ip) + end) do + {:ok, pid} -> + # Tie the socket's lifetime to the serving task, not the acceptor. + :gen_tcp.controlling_process(socket, pid) + :ok + + {:error, _reason} -> + RequestGuard.finish_request(:tcp) + :gen_tcp.close(socket) + end + end + + defp serve_client(socket, client_ip) do + serve_loop(socket, client_ip, @max_queries_per_connection) + after + RequestGuard.finish_request(:tcp) + :gen_tcp.close(socket) + end + + defp serve_loop(_socket, _client_ip, 0), do: :ok + + defp serve_loop(socket, client_ip, remaining) do + with {:ok, packet} <- :gen_tcp.recv(socket, 0, @recv_timeout), + :ok <- rate_check(client_ip, remaining) do + result = Elektrine.DNS.Query.resolve(packet, client_ip: client_ip, transport: :tcp) + Elektrine.DNS.track_query(result, "tcp") + + case :gen_tcp.send(socket, result.response) do + :ok -> serve_loop(socket, client_ip, remaining - 1) + {:error, _reason} -> :ok + end + else + _ -> :ok + end + end + + # The connection's first query is covered by begin_request/2 at accept + # time; every further query on the same connection consumes rate budget. + defp rate_check(_client_ip, @max_queries_per_connection), do: :ok + defp rate_check(client_ip, _remaining), do: RequestGuard.check_rate(client_ip, :tcp) +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/udp_server.ex b/apps/elektrine_dns/lib/elektrine/dns/udp_server.ex new file mode 100644 index 0000000..1e57179 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/udp_server.ex @@ -0,0 +1,85 @@ +defmodule Elektrine.DNS.UDPServer do + @moduledoc false + + use GenServer + + require Logger + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__)) + end + + def child_spec(opts) do + %{ + id: Keyword.get(opts, :name, __MODULE__), + start: {__MODULE__, :start_link, [opts]} + } + end + + @impl true + def init(opts) do + family = Keyword.get(opts, :family, :inet) + + case open(family) do + {:ok, socket} -> + {:ok, %{socket: socket}} + + {:error, reason} when family == :inet6 -> + Logger.warning("DNS UDP IPv6 listener unavailable: #{inspect(reason)}") + :ignore + + {:error, reason} -> + {:stop, reason} + end + end + + defp open(family) do + :gen_udp.open( + Elektrine.DNS.udp_port(), + [ + :binary, + family, + active: 100, + reuseaddr: true, + ip: wildcard_address(family) + ] ++ family_opts(family) + ) + end + + defp wildcard_address(:inet), do: {0, 0, 0, 0} + defp wildcard_address(:inet6), do: {0, 0, 0, 0, 0, 0, 0, 0} + + # v6only keeps the v6 wildcard bind from claiming the v4 port too. + defp family_opts(:inet), do: [] + defp family_opts(:inet6), do: [{:ipv6_v6only, true}] + + @impl true + def handle_info({:udp, socket, host, port, packet}, state) do + case Elektrine.DNS.RequestGuard.begin_request(host, :udp) do + {:ok, :udp} -> + case Task.Supervisor.start_child(Elektrine.DNS.TaskSupervisor, fn -> + try do + result = Elektrine.DNS.Query.resolve(packet, client_ip: host, transport: :udp) + Elektrine.DNS.track_query(result, "udp") + :gen_udp.send(socket, host, port, result.response) + after + Elektrine.DNS.RequestGuard.finish_request(:udp) + end + end) do + {:ok, _pid} -> :ok + {:error, _reason} -> Elektrine.DNS.RequestGuard.finish_request(:udp) + end + + {:error, _reason} -> + :ok + end + + {:noreply, state} + end + + @impl true + def handle_info({:udp_passive, socket}, state) do + :inet.setopts(socket, active: 100) + {:noreply, state} + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/zone.ex b/apps/elektrine_dns/lib/elektrine/dns/zone.ex new file mode 100644 index 0000000..b97e2b5 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/zone.ex @@ -0,0 +1,98 @@ +defmodule Elektrine.DNS.Zone do + @moduledoc """ + User-owned authoritative DNS zone. + """ + + use Ecto.Schema + import Ecto.Changeset + + schema "dns_zones" do + field :domain, :string + field :status, :string, default: "provisioning" + field :kind, :string, default: "native" + field :serial, :integer, default: 1 + field :default_ttl, :integer, default: 300 + field :force_https, :boolean, default: false + field :soa_mname, :string + field :soa_rname, :string + field :soa_refresh, :integer, default: 3600 + field :soa_retry, :integer, default: 600 + field :soa_expire, :integer, default: 1_209_600 + field :soa_minimum, :integer, default: 300 + field :verified_at, :utc_datetime + field :last_checked_at, :utc_datetime + field :last_published_at, :utc_datetime + field :last_error, :string + field :nameserver_set, :integer + + belongs_to :user, Elektrine.Accounts.User + has_many :records, Elektrine.DNS.Record, foreign_key: :zone_id + has_many :service_configs, Elektrine.DNS.ZoneServiceConfig, foreign_key: :zone_id + + timestamps(type: :utc_datetime) + end + + def changeset(zone, attrs) do + zone + |> cast(attrs, [ + :domain, + :status, + :kind, + :serial, + :default_ttl, + :force_https, + :soa_mname, + :soa_rname, + :soa_refresh, + :soa_retry, + :soa_expire, + :soa_minimum, + :verified_at, + :last_checked_at, + :last_published_at, + :last_error, + :nameserver_set, + :user_id + ]) + |> update_change(:domain, &normalize_domain/1) + |> validate_required([:domain, :status, :kind, :default_ttl, :user_id]) + |> validate_inclusion(:force_https, [true, false]) + |> validate_number(:default_ttl, greater_than: 0, less_than_or_equal_to: 86_400) + |> validate_number(:soa_refresh, greater_than: 0) + |> validate_number(:soa_retry, greater_than: 0) + |> validate_number(:soa_expire, greater_than: 0) + |> validate_number(:soa_minimum, greater_than: 0) + |> validate_number(:nameserver_set, greater_than_or_equal_to: 0, less_than_or_equal_to: 4095) + |> validate_format(:domain, ~r/^(?:[a-z0-9-]+\.)+[a-z]{2,}$/) + |> unique_constraint(:domain, name: :dns_zones_domain_ci_unique) + |> foreign_key_constraint(:user_id) + end + + def nameserver_records(%__MODULE__{domain: domain} = zone) when is_binary(domain) do + zone + |> Elektrine.DNS.assigned_nameservers() + |> Enum.map(fn nameserver -> + %{type: "NS", host: domain, value: nameserver, priority: nil} + end) + end + + def nameserver_records(_), do: [] + + def soa_record(%__MODULE__{} = zone) do + %{ + type: "SOA", + host: zone.domain, + ttl: zone.default_ttl || 300, + mname: zone.soa_mname || List.first(Elektrine.DNS.nameservers()) || "ns1.elektrine.com", + rname: zone.soa_rname || Elektrine.DNS.soa_rname(), + serial: zone.serial || 1, + refresh: zone.soa_refresh || 3600, + retry: zone.soa_retry || 600, + expire: zone.soa_expire || 1_209_600, + minimum: zone.soa_minimum || zone.default_ttl || 300 + } + end + + defp normalize_domain(nil), do: nil + defp normalize_domain(domain), do: domain |> String.trim() |> String.downcase() +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/zone_cache.ex b/apps/elektrine_dns/lib/elektrine/dns/zone_cache.ex new file mode 100644 index 0000000..d2c56df --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/zone_cache.ex @@ -0,0 +1,124 @@ +defmodule Elektrine.DNS.ZoneCache do + @moduledoc """ + ETS-backed cache for authoritative zone data. + """ + + use GenServer + import Ecto.Query, only: [preload: 2, where: 3] + + require Logger + + alias Elektrine.DNS.Zone + alias Elektrine.Repo + + @table __MODULE__ + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + def lookup(domain) when is_binary(domain) do + case :ets.whereis(@table) do + :undefined -> + :error + + _table -> + case :ets.lookup(@table, String.downcase(domain)) do + [{_domain, zone}] -> {:ok, zone} + [] -> :error + end + end + end + + def refresh(opts \\ []) do + {timeout, opts} = Keyword.pop(opts, :timeout, 5_000) + + GenServer.call(__MODULE__, {:refresh, opts}, timeout) + catch + :exit, {:timeout, _} -> {:error, :timeout} + end + + def refresh_async(opts \\ []) do + GenServer.cast(__MODULE__, {:refresh, opts}) + end + + @impl true + def init(opts) do + :ets.new(@table, [:named_table, :public, read_concurrency: true]) + + state = %{ + refresh_interval_ms: + Keyword.get(opts, :refresh_interval_ms, Elektrine.DNS.zone_cache_refresh_interval_ms()) + } + + schedule_refresh(state.refresh_interval_ms) + + {:ok, refresh_cache(state)} + end + + @impl true + def handle_call({:refresh, opts}, _from, state) do + {:reply, :ok, refresh_cache(state, opts)} + end + + @impl true + def handle_cast({:refresh, opts}, state) do + {:noreply, refresh_cache(state, opts)} + end + + @impl true + def handle_info(:refresh, state) do + schedule_refresh(state.refresh_interval_ms) + {:noreply, refresh_cache(state)} + end + + defp refresh_cache(state, opts \\ []) do + repo_opts = Keyword.take(opts, [:caller]) + + case load_zones(repo_opts) do + {:ok, zones} -> + # Insert before pruning so concurrent lookups never see an empty + # table mid-refresh. + entries = Enum.map(zones, fn zone -> {String.downcase(zone.domain), zone} end) + :ets.insert(@table, entries) + + fresh_keys = MapSet.new(entries, &elem(&1, 0)) + + @table + |> :ets.select([{{:"$1", :_}, [], [:"$1"]}]) + |> Enum.reject(&MapSet.member?(fresh_keys, &1)) + |> Enum.each(&:ets.delete(@table, &1)) + + {:error, error} -> + Logger.warning("DNS zone cache refresh failed: #{Exception.message(error)}") + end + + state + end + + defp load_zones(repo_opts) do + {:ok, + Zone + |> where([z], z.status == "verified") + |> preload(:records) + |> Repo.all(repo_opts) + |> Enum.map(&repair_builtin_zone_records/1)} + rescue + error in [Postgrex.Error, DBConnection.OwnershipError] -> + {:error, error} + end + + defp repair_builtin_zone_records(%Zone{} = zone) do + case Elektrine.DNS.repair_builtin_user_zone_records(zone) do + {:ok, zone} -> zone + _ -> zone + end + end + + defp schedule_refresh(refresh_interval_ms) + when is_integer(refresh_interval_ms) and refresh_interval_ms > 0 do + Process.send_after(self(), :refresh, refresh_interval_ms) + end + + defp schedule_refresh(_refresh_interval_ms), do: :ok +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/zone_change_listener.ex b/apps/elektrine_dns/lib/elektrine/dns/zone_change_listener.ex new file mode 100644 index 0000000..1a4486f --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/zone_change_listener.ex @@ -0,0 +1,124 @@ +defmodule Elektrine.DNS.ZoneChangeListener do + @moduledoc """ + Cross-node zone-cache invalidation over Postgres LISTEN/NOTIFY. + + The public authoritative nameserver (`elektrine_dns`) runs in a different + OS container — and therefore a different, unclustered BEAM node — from the + web app (`elektrine_app`) where DNS records are edited. A record write on + the app node refreshes only that node's `ZoneCache`; without this listener + the nameserver would not serve the change until its periodic refresh timer + fired (up to `zone_cache_refresh_interval_ms` later), which is exactly the + window the Atomine create-record-then-verify proof flow lands in. + + On every record/zone write, `Elektrine.DNS` emits `pg_notify` on the + `elektrine_dns_zone_changed` channel. This GenServer listens on that + channel through a dedicated `Postgrex.Notifications` connection (the + shared database is the + only channel the two nodes have in common) and refreshes the local + `ZoneCache`, debounced so a burst of writes collapses into one reload. + """ + + use GenServer + + require Logger + + alias Elektrine.DNS.ZoneCache + + @channel "elektrine_dns_zone_changed" + @debounce_ms 250 + + def channel, do: @channel + + @doc "Emit a zone-changed notification. Safe to call from any node." + def notify(repo \\ Elektrine.Repo) do + repo.query("SELECT pg_notify($1, $2)", [@channel, ""]) + :ok + rescue + error -> + Logger.warning("DNS zone-change notify failed: #{Exception.message(error)}") + :error + end + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__)) + end + + @impl true + def init(opts) do + repo = Keyword.get(opts, :repo, Elektrine.Repo) + + case start_notifications(repo) do + {:ok, pid} -> + ref = Postgrex.Notifications.listen!(pid, @channel) + {:ok, %{notifications: pid, ref: ref, timer: nil}} + + {:error, reason} -> + # Do not take down the authority tree if the listener can't connect; + # the periodic ZoneCache refresh still covers correctness, just + # slower. Retry shortly. + Logger.warning("DNS zone-change listener could not start: #{inspect(reason)}") + Process.send_after(self(), :reconnect, 5_000) + {:ok, %{notifications: nil, ref: nil, timer: nil}} + end + end + + @impl true + def handle_info({:notification, _pid, _ref, @channel, _payload}, state) do + {:noreply, schedule_refresh(state)} + end + + def handle_info(:refresh, state) do + ZoneCache.refresh_async() + {:noreply, %{state | timer: nil}} + end + + def handle_info(:reconnect, %{notifications: nil} = state) do + repo = Elektrine.Repo + + case start_notifications(repo) do + {:ok, pid} -> + ref = Postgrex.Notifications.listen!(pid, @channel) + # A write may have happened while we were disconnected. + ZoneCache.refresh_async() + {:noreply, %{state | notifications: pid, ref: ref}} + + {:error, _reason} -> + Process.send_after(self(), :reconnect, 5_000) + {:noreply, state} + end + end + + def handle_info({:DOWN, _ref, :process, pid, _reason}, %{notifications: pid} = state) do + Process.send_after(self(), :reconnect, 5_000) + {:noreply, %{state | notifications: nil, ref: nil}} + end + + def handle_info(_message, state), do: {:noreply, state} + + defp schedule_refresh(%{timer: nil} = state) do + %{state | timer: Process.send_after(self(), :refresh, @debounce_ms)} + end + + # A refresh is already pending; the debounce window will cover this write too. + defp schedule_refresh(state), do: state + + # Ecto's repo.config/0 carries pool/telemetry/adapter keys that a raw + # Postgrex.Notifications connection has no use for; keep only connection + # parameters (which also accepts a :url shorthand). + @connection_keys ~w(url hostname port username password database + socket socket_dir ssl ssl_opts parameters connect_timeout + handshake_timeout show_sensitive_data_on_connection_error)a + + defp start_notifications(repo) do + config = repo.config() |> Keyword.take(@connection_keys) + + case Postgrex.Notifications.start_link(config) do + {:ok, pid} -> + Process.monitor(pid) + {:ok, pid} + + {:error, reason} -> + {:error, reason} + end + end +end diff --git a/apps/elektrine_dns/lib/elektrine/dns/zone_service_config.ex b/apps/elektrine_dns/lib/elektrine/dns/zone_service_config.ex new file mode 100644 index 0000000..2aa0dd0 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine/dns/zone_service_config.ex @@ -0,0 +1,60 @@ +defmodule Elektrine.DNS.ZoneServiceConfig do + @moduledoc """ + Per-zone managed DNS service configuration. + """ + + use Ecto.Schema + import Ecto.Changeset + + @services ~w(mail web turn dns vpn bluesky) + @modes ~w(managed manual) + @statuses ~w(pending ok conflict disabled error) + + schema "dns_zone_service_configs" do + field :service, :string + field :enabled, :boolean, default: true + field :mode, :string, default: "managed" + field :status, :string, default: "pending" + field :settings, :map, default: %{} + field :last_applied_at, :utc_datetime + field :last_error, :string + + belongs_to :zone, Elektrine.DNS.Zone + + timestamps(type: :utc_datetime) + end + + def changeset(config, attrs) do + config + |> cast(attrs, [ + :service, + :enabled, + :mode, + :status, + :settings, + :last_applied_at, + :last_error, + :zone_id + ]) + |> update_change(:service, &normalize_service/1) + |> update_change(:mode, &normalize_mode/1) + |> update_change(:status, &normalize_status/1) + |> validate_required([:service, :enabled, :mode, :status, :zone_id]) + |> validate_inclusion(:service, @services) + |> validate_inclusion(:mode, @modes) + |> validate_inclusion(:status, @statuses) + |> foreign_key_constraint(:zone_id) + |> unique_constraint(:service, name: :dns_zone_service_configs_zone_service_unique) + end + + def services, do: @services + + defp normalize_service(nil), do: nil + defp normalize_service(value), do: value |> String.trim() |> String.downcase() + + defp normalize_mode(nil), do: nil + defp normalize_mode(value), do: value |> String.trim() |> String.downcase() + + defp normalize_status(nil), do: nil + defp normalize_status(value), do: value |> String.trim() |> String.downcase() +end diff --git a/apps/elektrine_dns/lib/elektrine_dns_web.ex b/apps/elektrine_dns/lib/elektrine_dns_web.ex new file mode 100644 index 0000000..d40f54b --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine_dns_web.ex @@ -0,0 +1,69 @@ +defmodule ElektrineDNSWeb do + @moduledoc """ + Shared web entrypoints for DNS surfaces extracted from elektrine_web. + """ + + def controller do + quote do + use Phoenix.Controller, + formats: [:html, :json], + layouts: [html: ElektrineWeb.Layouts] + + use Gettext, backend: ElektrineWeb.Gettext + + import Plug.Conn + + use Phoenix.VerifiedRoutes, + endpoint: ElektrineWeb.Endpoint, + router: ElektrineWeb.Router, + statics: ElektrineWeb.static_paths() + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {ElektrineWeb.Layouts, :app} + + on_mount ElektrineWeb.Live.AnnouncementCache + + import ElektrineWeb.Live.NotificationHelpers + + unquote(html_helpers()) + + # Presence events are handled by ElektrineWeb's PresenceHook where it is + # mounted; these no-op fallbacks keep stray client events from crashing + # LiveViews that don't track presence. + def handle_event(event, _params, socket) + when event in [ + "user_activity", + "auto_away_timeout", + "device_detected", + "connection_changed" + ] do + {:noreply, socket} + end + end + end + + defp html_helpers do + quote do + use Gettext, backend: ElektrineWeb.Gettext + + import Phoenix.HTML + import ElektrineWeb.CoreComponents + import ElektrineDNSWeb.Components.Platform.ENav + + alias Phoenix.LiveView.JS + + use Phoenix.VerifiedRoutes, + endpoint: ElektrineWeb.Endpoint, + router: ElektrineWeb.Router, + statics: ElektrineWeb.static_paths() + end + end + + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/apps/elektrine_dns/lib/elektrine_dns_web/components/platform/e_nav.ex b/apps/elektrine_dns/lib/elektrine_dns_web/components/platform/e_nav.ex new file mode 100644 index 0000000..5dfe7b8 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine_dns_web/components/platform/e_nav.ex @@ -0,0 +1,48 @@ +defmodule ElektrineDNSWeb.Components.Platform.ENav do + @moduledoc """ + DNS-owned wrapper for the shared platform navigation renderer. + """ + + use Phoenix.Component + use Gettext, backend: ElektrineWeb.Gettext + + alias Elektrine.Platform.ENav, as: PlatformENav + alias Elektrine.Platform.ENavComponent + + attr :active_tab, :string, required: true + attr :class, :string, default: "mb-6" + attr :current_user, :any, default: nil + attr :badge_counts, :map, default: nil + + def e_nav(assigns) do + badge_counts = + assigns.badge_counts || PlatformENav.notification_badge_counts(assigns.current_user) + + assigns = + assigns + |> assign(:items, nav_items(assigns.current_user, badge_counts)) + |> assign(:secondary_items, secondary_items(assigns.current_user, badge_counts)) + + ENavComponent.render(assigns) + end + + defp nav_items(current_user, badge_counts) do + PlatformENav.primary_items() + |> Enum.map( + &Map.update!(&1, :label, fn label -> Gettext.gettext(ElektrineWeb.Gettext, label) end) + ) + |> Enum.filter(&PlatformENav.visible?(&1, current_user)) + |> PlatformENav.with_badge_counts(badge_counts) + end + + defp secondary_items(nil, _badge_counts), do: [] + + defp secondary_items(current_user, badge_counts) do + PlatformENav.secondary_items() + |> Enum.map( + &Map.update!(&1, :label, fn label -> Gettext.gettext(ElektrineWeb.Gettext, label) end) + ) + |> Enum.filter(&PlatformENav.visible?(&1, current_user)) + |> PlatformENav.with_badge_counts(badge_counts) + end +end diff --git a/apps/elektrine_dns/lib/elektrine_dns_web/controllers/api/dns_controller.ex b/apps/elektrine_dns/lib/elektrine_dns_web/controllers/api/dns_controller.ex new file mode 100644 index 0000000..bbd02cc --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine_dns_web/controllers/api/dns_controller.ex @@ -0,0 +1,420 @@ +defmodule ElektrineDNSWeb.API.DNSController do + @moduledoc """ + External API controller for managed DNS zones and records. + """ + + use ElektrineDNSWeb, :controller + + alias Elektrine.DNS + alias Elektrine.DNS.Zone + alias ElektrineWeb.API.Response + + action_fallback ElektrineWeb.FallbackController + + def index(conn, _params) do + user = conn.assigns.current_user + + Response.ok(conn, %{zones: Enum.map(DNS.list_user_zones(user), &format_zone(&1, user: user))}) + end + + def show(conn, %{"id" => id}) do + user = conn.assigns.current_user + + with {:ok, zone_id} <- parse_id(id), + %Zone{} = zone <- DNS.get_zone(zone_id, user.id) do + Response.ok(conn, %{zone: format_zone(zone, include_records: true, user: user)}) + else + {:error, :bad_request} -> + Response.error(conn, :bad_request, "invalid_id", "Invalid zone id") + + nil -> + Response.error(conn, :not_found, "not_found", "Zone not found") + end + end + + def create(conn, params) do + user = conn.assigns.current_user + attrs = Map.get(params, "zone", params) + + case DNS.create_zone(user, attrs) do + {:ok, zone} -> + Response.created(conn, %{zone: format_zone(zone, user: user)}) + + {:error, changeset} -> + Response.error( + conn, + :unprocessable_entity, + "validation_failed", + "Invalid zone", + errors_on(changeset) + ) + end + end + + def update(conn, %{"id" => id} = params) do + user = conn.assigns.current_user + attrs = Map.get(params, "zone", params) + + with {:ok, zone_id} <- parse_id(id), + %Zone{} = zone <- DNS.get_zone(zone_id, user.id), + {:ok, zone} <- DNS.update_zone(zone, attrs) do + Response.ok(conn, %{zone: format_zone(zone, user: user)}) + else + {:error, :bad_request} -> + Response.error(conn, :bad_request, "invalid_id", "Invalid zone id") + + nil -> + Response.error(conn, :not_found, "not_found", "Zone not found") + + {:error, changeset} -> + Response.error( + conn, + :unprocessable_entity, + "validation_failed", + "Invalid zone", + errors_on(changeset) + ) + end + end + + def delete(conn, %{"id" => id}) do + user = conn.assigns.current_user + + with {:ok, zone_id} <- parse_id(id), + %Zone{} = zone <- DNS.get_zone(zone_id, user.id), + {:ok, _} <- DNS.delete_zone(zone) do + Response.ok(conn, %{message: "Zone deleted"}) + else + {:error, :bad_request} -> + Response.error(conn, :bad_request, "invalid_id", "Invalid zone id") + + nil -> + Response.error(conn, :not_found, "not_found", "Zone not found") + + {:error, changeset} -> + Response.error( + conn, + :unprocessable_entity, + "validation_failed", + "Could not delete zone", + errors_on(changeset) + ) + end + end + + def verify(conn, %{"id" => id}) do + user = conn.assigns.current_user + + with {:ok, zone_id} <- parse_id(id), + %Zone{} = zone <- DNS.get_zone(zone_id, user.id), + {:ok, zone} <- DNS.verify_zone(zone) do + Response.ok(conn, %{zone: format_zone(zone, include_onboarding_records: true, user: user)}) + else + {:error, :bad_request} -> + Response.error(conn, :bad_request, "invalid_id", "Invalid zone id") + + nil -> + Response.error(conn, :not_found, "not_found", "Zone not found") + + {:error, changeset} -> + Response.error( + conn, + :unprocessable_entity, + "validation_failed", + "Verification failed", + errors_on(changeset) + ) + end + end + + def create_record(conn, %{"zone_id" => zone_id} = params) do + user = conn.assigns.current_user + attrs = Map.get(params, "record", params) + + with {:ok, zone_id} <- parse_id(zone_id), + %Zone{} = zone <- DNS.get_zone(zone_id, user.id), + {:ok, record} <- DNS.create_record(zone, attrs) do + Response.created(conn, %{record: format_record(record)}) + else + {:error, :bad_request} -> + Response.error(conn, :bad_request, "invalid_id", "Invalid zone id") + + nil -> + Response.error(conn, :not_found, "not_found", "Zone not found") + + {:error, changeset} -> + Response.error( + conn, + :unprocessable_entity, + "validation_failed", + "Invalid record", + errors_on(changeset) + ) + end + end + + def update_record(conn, %{"zone_id" => zone_id, "id" => id} = params) do + user = conn.assigns.current_user + attrs = Map.get(params, "record", params) + + with {:ok, zone_id} <- parse_id(zone_id), + {:ok, record_id} <- parse_id(id), + %Zone{} <- DNS.get_zone(zone_id, user.id), + %{} = record <- DNS.get_record(record_id, zone_id), + {:ok, record} <- DNS.update_record(record, attrs) do + Response.ok(conn, %{record: format_record(record)}) + else + {:error, :bad_request} -> + Response.error(conn, :bad_request, "invalid_id", "Invalid record or zone id") + + nil -> + Response.error(conn, :not_found, "not_found", "Record not found") + + {:error, changeset} -> + Response.error( + conn, + :unprocessable_entity, + "validation_failed", + "Invalid record", + errors_on(changeset) + ) + end + end + + def delete_record(conn, %{"zone_id" => zone_id, "id" => id}) do + user = conn.assigns.current_user + + with {:ok, zone_id} <- parse_id(zone_id), + {:ok, record_id} <- parse_id(id), + %Zone{} <- DNS.get_zone(zone_id, user.id), + %{} = record <- DNS.get_record(record_id, zone_id), + {:ok, _} <- DNS.delete_record(record) do + Response.ok(conn, %{message: "Record deleted"}) + else + {:error, :bad_request} -> + Response.error(conn, :bad_request, "invalid_id", "Invalid record or zone id") + + nil -> + Response.error(conn, :not_found, "not_found", "Record not found") + + {:error, changeset} -> + Response.error( + conn, + :unprocessable_entity, + "validation_failed", + "Could not delete record", + errors_on(changeset) + ) + end + end + + def apply_service(conn, %{"id" => id, "service" => service} = params) do + user = conn.assigns.current_user + attrs = Map.get(params, "service_config", %{}) + + with {:ok, zone_id} <- parse_id(id), + %Zone{} = zone <- DNS.get_zone(zone_id, user.id), + {:ok, config} <- DNS.apply_zone_service(zone, service, attrs) do + Response.ok(conn, %{service_config: format_service_config(config)}) + else + {:error, :bad_request} -> + Response.error(conn, :bad_request, "invalid_id", "Invalid zone id") + + nil -> + Response.error(conn, :not_found, "not_found", "Zone not found") + + {:error, changeset} -> + Response.error( + conn, + :unprocessable_entity, + "validation_failed", + "Invalid service configuration", + errors_on(changeset) + ) + end + end + + def disable_service(conn, %{"id" => id, "service" => service}) do + user = conn.assigns.current_user + + with {:ok, zone_id} <- parse_id(id), + %Zone{} = zone <- DNS.get_zone(zone_id, user.id), + {:ok, config} <- DNS.disable_zone_service(zone, service) do + Response.ok(conn, %{service_config: format_service_config(config)}) + else + {:error, :bad_request} -> + Response.error(conn, :bad_request, "invalid_id", "Invalid zone id") + + nil -> + Response.error(conn, :not_found, "not_found", "Zone not found") + + {:error, changeset} -> + Response.error( + conn, + :unprocessable_entity, + "validation_failed", + "Invalid service configuration", + errors_on(changeset) + ) + end + end + + defp parse_id(value) when is_integer(value) and value > 0, do: {:ok, value} + + defp parse_id(value) when is_binary(value) do + case Integer.parse(value) do + {int, ""} when int > 0 -> {:ok, int} + _ -> {:error, :bad_request} + end + end + + defp parse_id(_), do: {:error, :bad_request} + + defp format_zone(zone, opts) do + include_records? = Keyword.get(opts, :include_records, false) + include_onboarding? = Keyword.get(opts, :include_onboarding_records, false) + user = Keyword.get(opts, :user) + + builtin? = + if(is_map(user), do: DNS.builtin_user_zone?(zone, user), else: DNS.builtin_user_zone?(zone)) + + %{ + id: zone.id, + domain: zone.domain, + builtin: builtin?, + builtin_mode: if(builtin?, do: DNS.builtin_user_zone_mode(user || zone), else: nil), + status: zone.status, + kind: zone.kind, + serial: zone.serial, + default_ttl: zone.default_ttl, + force_https: zone.force_https, + verified_at: zone.verified_at, + last_checked_at: Map.get(zone, :last_checked_at), + last_published_at: zone.last_published_at, + last_error: zone.last_error, + service_configs: Enum.map(loaded_assoc(zone.service_configs), &format_service_config/1), + service_health: Enum.map(DNS.zone_service_health(zone), &format_service_health/1), + reserved_hint: DNS.builtin_user_zone_reserved_hint(zone), + records: + if(include_records?, + do: Enum.map(loaded_assoc(zone.records), &format_record/1), + else: nil + ), + onboarding_records: + if(include_onboarding?, do: DNS.zone_onboarding_records(zone), else: nil) + } + end + + defp format_record(record) do + record_fields = %{ + id: Map.get(record, :id), + zone_id: Map.get(record, :zone_id), + name: Map.get(record, :name), + type: Map.get(record, :type), + ttl: Map.get(record, :ttl), + content: Map.get(record, :content), + priority: Map.get(record, :priority), + weight: Map.get(record, :weight), + port: Map.get(record, :port), + flags: Map.get(record, :flags), + tag: Map.get(record, :tag), + protocol: Map.get(record, :protocol), + algorithm: Map.get(record, :algorithm), + key_tag: Map.get(record, :key_tag), + digest_type: Map.get(record, :digest_type), + usage: Map.get(record, :usage), + selector: Map.get(record, :selector), + matching_type: Map.get(record, :matching_type), + source: Map.get(record, :source), + service: Map.get(record, :service), + managed: Map.get(record, :managed), + managed_key: Map.get(record, :managed_key), + required: Map.get(record, :required), + metadata: Map.get(record, :metadata), + inserted_at: Map.get(record, :inserted_at), + updated_at: Map.get(record, :updated_at) + } + + Map.merge(record_fields, proxy_record_fields(record)) + end + + defp proxy_record_fields(%DNS.Record{} = record) do + %{ + proxied: DNS.Record.proxied?(record), + proxy_origin_scheme: DNS.Record.proxy_origin_scheme(record), + proxy_origin_port: DNS.Record.proxy_origin_port(record), + proxy_origin_host_header: DNS.Record.proxy_origin_host_header(record), + proxy_atomine_gate: DNS.Record.proxy_atomine_gate?(record) + } + end + + defp proxy_record_fields(_record) do + %{ + proxied: false, + proxy_origin_scheme: nil, + proxy_origin_port: nil, + proxy_origin_host_header: nil, + proxy_atomine_gate: nil + } + end + + defp format_service_health(health) do + service = Map.get(health, :service) + + %{ + service: service, + enabled: Map.get(health, :enabled), + mode: Map.get(health, :mode), + status: Map.get(health, :status), + last_error: Map.get(health, :last_error), + settings: DNS.public_service_settings(service, Map.get(health, :settings)), + desired_records: Enum.map(Map.get(health, :desired_records, []), &format_record/1), + managed_records: Enum.map(Map.get(health, :managed_records, []), &format_record/1), + conflicts: Map.get(health, :conflicts, []), + checks: Enum.map(Map.get(health, :checks, []), &format_service_check/1), + repairable: Map.get(health, :repairable) + } + end + + defp format_service_check(check) do + %{ + key: Map.get(check, :key), + label: Map.get(check, :label), + required: Map.get(check, :required), + status: Map.get(check, :status), + expected: maybe_format_record(Map.get(check, :expected)), + actual: maybe_format_record(Map.get(check, :actual)) + } + end + + defp maybe_format_record(nil), do: nil + defp maybe_format_record(record), do: format_record(record) + + defp format_service_config(config) do + %{ + id: config.id, + zone_id: config.zone_id, + service: config.service, + enabled: config.enabled, + mode: config.mode, + status: config.status, + settings: DNS.public_service_settings(config.service, config.settings), + last_applied_at: config.last_applied_at, + last_error: config.last_error, + inserted_at: config.inserted_at, + updated_at: config.updated_at + } + end + + defp errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end + + defp loaded_assoc(%Ecto.Association.NotLoaded{}), do: [] + defp loaded_assoc(nil), do: [] + defp loaded_assoc(value), do: value +end diff --git a/apps/elektrine_dns/lib/elektrine_dns_web/live/dns_live/index.ex b/apps/elektrine_dns/lib/elektrine_dns_web/live/dns_live/index.ex new file mode 100644 index 0000000..c5c2fa8 --- /dev/null +++ b/apps/elektrine_dns/lib/elektrine_dns_web/live/dns_live/index.ex @@ -0,0 +1,2644 @@ +defmodule ElektrineDNSWeb.DNSLive.Index do + use ElektrineDNSWeb, :live_view + + alias Elektrine.Accounts.User + alias Elektrine.DNS + alias Elektrine.DNS.MailSecurity + alias Elektrine.DNS.Record + alias Elektrine.DNS.Zone + alias Elektrine.Profiles.CustomDomains, as: ProfileCustomDomains + + @impl true + def mount(params, _session, socket) do + user = socket.assigns.current_user + + if user do + zones = DNS.list_user_zones(user) + active_zone = select_active_zone(zones, params["zone_id"]) + + {:ok, + socket + |> assign(:page_title, "DNS") + |> assign(:nameservers, DNS.nameservers()) + |> assign(:record_types, DNS.supported_record_types()) + |> assign(:zones, zones) + |> assign(:active_zone, active_zone) + |> assign(:zone_tab, normalize_zone_tab(params["tab"], zone_tabs(active_zone, user))) + |> assign(:linked_domains, linked_domains(active_zone, user.id)) + |> assign(:domain_health, DNS.domain_health(active_zone)) + |> assign(:service_health, service_health(active_zone)) + |> assign(:service_forms, service_forms(active_zone)) + |> assign(:zone_settings_form, zone_settings_form(active_zone)) + |> assign(:editing_record_id, nil) + |> assign(:selected_record_preset, nil) + |> assign(:zone_form, to_form(DNS.new_zone_changeset(user.id), as: :zone)) + |> assign(:zone_scan, nil) + |> assign(:record_form, record_form(active_zone))} + else + {:ok, + socket + |> put_flash(:error, "You must be logged in to access DNS") + |> redirect(to: Elektrine.Paths.login_path())} + end + end + + @impl true + def handle_params(params, _uri, socket) do + zones = DNS.list_user_zones(socket.assigns.current_user) + active_zone = select_active_zone(zones, params["zone_id"]) + + {:noreply, + socket + |> assign(:zones, zones) + |> assign(:active_zone, active_zone) + |> assign( + :zone_tab, + normalize_zone_tab( + params["tab"], + zone_tabs(active_zone, socket.assigns.current_user) + ) + ) + |> assign(:linked_domains, linked_domains(active_zone, socket.assigns.current_user.id)) + |> assign(:domain_health, DNS.domain_health(active_zone)) + |> assign(:service_health, service_health(active_zone)) + |> assign(:service_forms, service_forms(active_zone)) + |> assign(:zone_settings_form, zone_settings_form(active_zone)) + |> assign(:editing_record_id, nil) + |> assign(:selected_record_preset, nil) + |> assign(:zone_scan, socket.assigns[:zone_scan]) + |> assign(:record_form, record_form(active_zone))} + end + + @impl true + def handle_event("zone_validate", %{"zone" => params}, socket) do + changeset = + %Zone{} + |> DNS.change_zone(params_with_user(socket, params)) + |> Map.put(:action, :validate) + + {:noreply, + socket + |> assign(:zone_form, to_form(changeset, as: :zone)) + |> assign(:zone_scan, keep_matching_scan(socket.assigns.zone_scan, params))} + end + + @impl true + def handle_event("zone_submit", %{"zone" => params, "_action" => "scan"}, socket) do + changeset = + %Zone{} + |> DNS.change_zone(params_with_user(socket, params)) + |> Map.put(:action, :validate) + + {:noreply, + socket + |> assign(:zone_form, to_form(changeset, as: :zone)) + |> assign(:zone_scan, zone_scan_for_params(params))} + end + + @impl true + def handle_event("zone_submit", %{"zone" => params, "_action" => "create"}, socket) do + case DNS.create_zone(socket.assigns.current_user, params) do + {:ok, zone} -> + {:noreply, + socket + |> assign(:zone_scan, nil) + |> put_flash(:info, "DNS zone created") + |> push_patch(to: ~p"/dns?zone_id=#{zone.id}")} + + {:error, changeset} -> + {:noreply, + socket + |> assign(:zone_form, to_form(%{changeset | action: :insert}, as: :zone)) + |> assign(:zone_scan, zone_scan_for_params(params))} + end + end + + @impl true + def handle_event("scan_import", params, socket) do + case socket.assigns.zone_scan do + nil -> + {:noreply, put_flash(socket, :error, "No scan results to import")} + + scan -> + selected_ids = Map.get(params, "selected_records", []) + + if selected_ids == [] do + {:noreply, put_flash(socket, :error, "Select at least one record to import")} + else + case matching_scan_zone(socket.assigns.zones, scan) do + %Zone{} = zone -> + {:noreply, + socket + |> put_flash( + :info, + scan_import_message(import_scan_records(zone, scan, selected_ids), false) + ) + |> push_patch(to: ~p"/dns?zone_id=#{zone.id}")} + + nil -> + case DNS.create_zone( + socket.assigns.current_user, + zone_form_attrs(socket.assigns.zone_form) + ) do + {:ok, zone} -> + {:noreply, + socket + |> assign(:zone_scan, nil) + |> put_flash( + :info, + scan_import_message(import_scan_records(zone, scan, selected_ids), true) + ) + |> push_patch(to: ~p"/dns?zone_id=#{zone.id}")} + + {:error, changeset} -> + {:noreply, + socket + |> assign(:zone_form, to_form(%{changeset | action: :insert}, as: :zone)) + |> put_flash(:error, format_zone_error(changeset))} + end + end + end + end + end + + @impl true + def handle_event("zone_update", %{"zone" => params}, socket) do + case socket.assigns.active_zone do + %Zone{} = zone -> + case DNS.update_zone(zone, normalize_zone_params(params)) do + {:ok, zone} -> + {:noreply, + socket + |> put_flash(:info, "Zone settings updated") + |> assign(:active_zone, zone) + |> assign(:zone_settings_form, zone_settings_form(zone)) + |> push_patch(to: ~p"/dns?zone_id=#{zone.id}")} + + {:error, changeset} -> + {:noreply, + socket + |> assign(:active_zone, %{zone | records: zone.records}) + |> assign(:zone_settings_form, to_form(%{changeset | action: :validate}, as: :zone)) + |> put_flash(:error, format_zone_error(changeset))} + end + + _ -> + {:noreply, socket} + end + end + + @impl true + def handle_event("zone_delete", %{"id" => id}, socket) do + with {:ok, zone_id} <- parse_int(id), + %Zone{} = zone <- DNS.get_zone(zone_id, socket.assigns.current_user.id), + {:ok, _} <- DNS.delete_zone(zone) do + {:noreply, + socket + |> put_flash(:info, "DNS zone deleted") + |> push_patch(to: ~p"/dns")} + else + _ -> {:noreply, put_flash(socket, :error, "Could not delete DNS zone")} + end + end + + @impl true + def handle_event("zone_verify", %{"id" => id}, socket) do + with {:ok, zone_id} <- parse_int(id), + %Zone{} = zone <- DNS.get_zone(zone_id, socket.assigns.current_user.id), + {:ok, _} <- DNS.verify_zone(zone) do + {:noreply, + socket + |> put_flash(:info, "Zone verification updated") + |> push_patch(to: ~p"/dns?zone_id=#{zone_id}")} + else + {:error, changeset} -> + {:noreply, put_flash(socket, :error, format_zone_error(changeset))} + + _ -> + {:noreply, put_flash(socket, :error, "Zone verification failed")} + end + end + + @impl true + def handle_event("builtin_zone_mode_set", %{"mode" => mode}, socket) do + case DNS.update_builtin_user_zone_mode(socket.assigns.current_user, mode) do + {:ok, user} -> + zones = DNS.list_user_zones(user) + + active_zone = + select_active_zone(zones, socket.assigns.active_zone && socket.assigns.active_zone.id) + + {:noreply, + socket + |> assign(:current_user, user) + |> assign(:zones, zones) + |> assign(:active_zone, active_zone) + |> assign(:linked_domains, linked_domains(active_zone, user.id)) + |> assign(:service_health, service_health(active_zone)) + |> assign(:service_forms, service_forms(active_zone)) + |> assign(:zone_settings_form, zone_settings_form(active_zone)) + |> assign(:record_form, record_form(active_zone)) + |> put_flash( + :info, + if(DNS.builtin_user_zone_hosted_by_platform?(user), + do: "Built-in subdomain returned to Elektrine hosting", + else: "Built-in subdomain handed off to DNS" + ) + )} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, put_flash(socket, :error, format_user_error(changeset))} + + {:error, :invalid_mode} -> + {:noreply, put_flash(socket, :error, "Invalid built-in subdomain mode")} + + _ -> + {:noreply, put_flash(socket, :error, "Could not update built-in subdomain mode")} + end + end + + @impl true + def handle_event("record_validate", %{"record" => params}, socket) do + case socket.assigns.active_zone do + %Zone{} = zone -> + changeset = + %Record{} + |> DNS.change_record(Map.put(params, "zone_id", zone.id)) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :record_form, to_form(changeset, as: :record))} + + _ -> + {:noreply, socket} + end + end + + @impl true + def handle_event("record_create", %{"record" => params}, socket) do + case socket.assigns.active_zone do + %Zone{} = zone -> + case save_record(zone, socket.assigns.editing_record_id, params) do + {:ok, _record} -> + {:noreply, + socket + |> assign(:editing_record_id, nil) + |> assign(:selected_record_preset, nil) + |> put_flash( + :info, + if(socket.assigns.editing_record_id, + do: "DNS record updated", + else: "DNS record created" + ) + ) + |> push_patch(to: ~p"/dns?zone_id=#{zone.id}")} + + {:error, changeset} -> + {:noreply, + assign(socket, :record_form, to_form(%{changeset | action: :insert}, as: :record))} + end + + _ -> + {:noreply, put_flash(socket, :error, "Select a zone first")} + end + end + + @impl true + def handle_event("record_edit", %{"id" => id}, socket) do + case socket.assigns.active_zone do + %Zone{} = zone -> + with {:ok, record_id} <- parse_int(id), + %Record{} = record <- DNS.get_record(record_id, zone.id) do + {:noreply, + socket + |> assign(:editing_record_id, record.id) + |> assign(:selected_record_preset, nil) + |> assign(:record_form, to_form(DNS.change_record(record), as: :record))} + else + _ -> {:noreply, put_flash(socket, :error, "Could not load record for editing")} + end + + _ -> + {:noreply, socket} + end + end + + @impl true + def handle_event("record_cancel_edit", _params, socket) do + {:noreply, + socket + |> assign(:editing_record_id, nil) + |> assign(:selected_record_preset, nil) + |> assign(:record_form, record_form(socket.assigns.active_zone))} + end + + @impl true + def handle_event("record_preset", %{"preset" => preset}, socket) do + case socket.assigns.active_zone do + %Zone{} = zone -> + {:noreply, + socket + |> assign(:editing_record_id, nil) + |> assign(:selected_record_preset, preset) + |> assign(:record_form, record_form(zone, record_preset_attrs(zone, preset)))} + + _ -> + {:noreply, socket} + end + end + + @impl true + def handle_event("record_type_preset", %{"type" => type}, socket) do + case socket.assigns.active_zone do + %Zone{} = zone -> + {:noreply, + socket + |> assign(:editing_record_id, nil) + |> assign(:selected_record_preset, nil) + |> assign(:record_form, record_form(zone, record_type_preset_attrs(zone, type)))} + + _ -> + {:noreply, socket} + end + end + + @impl true + def handle_event("record_delete", %{"id" => id}, socket) do + case socket.assigns.active_zone do + %Zone{} = zone -> + with {:ok, record_id} <- parse_int(id), + %Record{} = record <- DNS.get_record(record_id, zone.id), + {:ok, _} <- DNS.delete_record(record) do + {:noreply, + socket + |> put_flash(:info, "DNS record deleted") + |> push_patch(to: ~p"/dns?zone_id=#{zone.id}")} + else + _ -> {:noreply, put_flash(socket, :error, "Could not delete record")} + end + + _ -> + {:noreply, socket} + end + end + + @impl true + def handle_event("service_apply", %{"service" => service, "service_config" => params}, socket) do + case socket.assigns.active_zone do + %Zone{} = zone -> + attrs = %{"settings" => Map.drop(params, ["service"])} + + case DNS.apply_zone_service(zone, service, attrs) do + {:ok, config} -> + {:noreply, + socket + |> put_service_apply_flash(service, config) + |> push_patch(to: ~p"/dns?zone_id=#{zone.id}&tab=services")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, format_service_error(service, reason))} + end + + _ -> + {:noreply, socket} + end + end + + @impl true + def handle_event("service_disable", %{"service" => service}, socket) do + case socket.assigns.active_zone do + %Zone{} = zone -> + case DNS.disable_zone_service(zone, service) do + {:ok, _config} -> + {:noreply, + socket + |> put_flash( + :info, + "Managed #{service_label(service)} DNS disabled and its records removed" + ) + |> push_patch(to: ~p"/dns?zone_id=#{zone.id}&tab=services")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, format_service_error(service, reason))} + end + + _ -> + {:noreply, socket} + end + end + + @impl true + def handle_event("linked_domain_apply", %{"kind" => kind, "domain" => domain}, socket) do + case {socket.assigns.active_zone, + load_linked_custom_domain(kind, domain, socket.assigns.current_user.id)} do + {%Zone{} = zone, {:ok, linked_domain, linked_kind}} -> + result = + linked_domain + |> expected_linked_domain_records(linked_kind) + |> Enum.reject(&record_exists_for_expected?(zone, &1)) + |> Enum.reduce_while({:ok, 0}, fn expected_record, {:ok, count} -> + case DNS.create_record(zone, expected_record_to_attrs(zone, expected_record)) do + {:ok, _record} -> {:cont, {:ok, count + 1}} + {:error, changeset} -> {:halt, {:error, changeset}} + end + end) + + case result do + {:ok, 0} -> + {:noreply, put_flash(socket, :info, "No missing records to add")} + + {:ok, count} -> + {:noreply, + socket + |> put_flash(:info, "Added #{count} DNS record#{if count == 1, do: "", else: "s"}") + |> push_patch(to: ~p"/dns?zone_id=#{zone.id}")} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Could not add the linked domain records")} + end + + _ -> + {:noreply, put_flash(socket, :error, "Could not load linked custom domain")} + end + end + + @impl true + def render(assigns) do + ~H""" +
    + <.e_nav + active_tab="dns" + current_user={@current_user} + badge_counts={@e_nav_badge_counts} + class="mb-6" + /> + +
    + + +
    +
    +
    +
    +

    Add a domain to manage DNS

    +

    + Use the New zone form to scan or provision your first zone. +

    +
    +
    +
    + + <%= if @zone_scan do %> +
    +
    +
    +
    +
    + Public DNS +
    +
    + Current public records for {@zone_scan.domain} +
    +
    +
    + <%= if @zone_scan.provider_hint do %> + {@zone_scan.provider_hint} + <% end %> + + <%= if scan_delegation_matches?(@zones, @zone_scan) do %> + Assigned nameservers active + <% else %> + Delegation does not match assigned nameservers + <% end %> + +
    +
    + + <.form for={%{}} as={:scan} phx-submit="scan_import" class="mt-4 space-y-5"> +
    +

    + Select records to import. +

    + +
    + +
    +
    +
    + Current public nameservers +
    +
    + <%= if @zone_scan.nameservers == [] do %> + No nameservers detected. + <% else %> + {format_scan_values(@zone_scan.nameservers)} + <% end %> +
    +
    + +
    +
    + Add + Host + Type + Value +
    +
    + <%= for item <- scan_import_items(@zone_scan) do %> + + <% end %> +
    +
    +
    + +
    +
    + <% end %> + + <%= if @active_zone do %> +
    +
    +
    +
    +
    +

    + {@active_zone.domain} +

    +
    + +
    + + {@active_zone.status} + <%= if @active_zone.verified_at do %> + · {Calendar.strftime(@active_zone.verified_at, "%Y-%m-%d")} + <% end %> + + <%= if builtin_zone?(@active_zone, @current_user) do %> + built-in + + {builtin_zone_mode_label(@current_user)} + + <% end %> + TTL {@active_zone.default_ttl} +
    +
    + +
    + <.link + navigate={~p"/analytics/domains?zone_id=#{@active_zone.id}"} + class="btn btn-sm btn-outline" + > + <.icon name="hero-chart-bar" class="h-4 w-4" /> Analytics + + <%= unless builtin_zone?(@active_zone, @current_user) do %> + + <% end %> +
    +
    + +
    +
    + {@active_zone.last_error} +
    +
    + +
    +
    + <%= if builtin_zone_hosted_by_platform?(@current_user) do %> + + {@active_zone.domain} + is platform-hosted. Apex `@` accepts only `TXT` and `CAA` here. + + <% else %> + + {@active_zone.domain} is DNS-managed. Platform hosting is paused. + + <% end %> +
    +
    + +
    + <.link + :for={{key, label} <- zone_tabs(@active_zone, @current_user)} + patch={~p"/dns?zone_id=#{@active_zone.id}&tab=#{key}"} + role="tab" + class={["tab", @zone_tab == key && "tab-active"]} + > + {label} + +
    +
    +
    + <% end %> + +
    +
    +
    +
    +

    + Records +

    +
    +
    + <%= if @editing_record_id do %> +
    +
    +

    Edit record

    +
    + <.simple_form + for={@record_form} + bare={true} + phx-change="record_validate" + phx-submit="record_create" + > + <.record_fields + record_form={@record_form} + active_zone={@active_zone} + current_user={@current_user} + record_types={@record_types} + /> + <:actions> + <.button>Save changes + + +
    + +
    +
    + <% end %> + + <%= if @active_zone.records == [] do %> +
    No records yet.
    + <% else %> +
    + + + + + + + + + + + + <%= for record <- @active_zone.records do %> + + + + + + + + <% end %> + +
    NameTypeValueTTL
    +
    + {record.name} + <%= if record.managed do %> + managed + <% end %> + <%= if Record.private?(record) do %> + + private + + <% end %> + <%= if Record.proxied?(record) do %> + + proxied + + <% end %> + <%= if Record.health_check?(record) do %> + <%= if record_target_healthy?(record) do %> + + healthy + + <% else %> + + down + + <% end %> + <% end %> +
    +
    {record.type}{record_rdata(record)}{record.ttl} +
    + + <%= unless record.managed do %> + + <% end %> +
    +
    +
    + <% end %> +
    +
    +
    + +
    +
    +
    +

    + Delegation +

    +
    +
    +
    +

    Assigned Elektrine nameservers

    +
    + {Enum.join(DNS.assigned_nameservers(@active_zone), ", ")} +
    +
    +
    + + + + + + + + + + + <%= for record <- DNS.zone_onboarding_records(@active_zone) do %> + + + + + + + <% end %> + +
    TypeHostValuePriority
    {record.type}{record.host}{record.value}{record.priority || "-"}
    +
    +
    +
    +
    +
    + +
    +
    +
    +

    + Add record +

    +
    +
    +
    +

    Presets

    +
    + <%= for preset <- record_preset_options(@active_zone) do %> + + <% end %> +
    +
    + +
    + + + + +
    + <.simple_form + for={@record_form} + bare={true} + phx-change="record_validate" + phx-submit="record_create" + > + <.record_fields + record_form={@record_form} + active_zone={@active_zone} + current_user={@current_user} + record_types={@record_types} + /> + <:actions> + <.button>Add record + + +
    +
    +
    + +
    +
    +
    +

    + Linked domains +

    +
    +
    + <%= if @linked_domains == [] do %> +

    No linked domains.

    + <% else %> + <%= for linked_domain <- @linked_domains do %> +
    +
    +
    +
    +

    {linked_domain.title}

    + {linked_domain.kind_label} + + {linked_domain.status} + +
    +

    {linked_domain.summary}

    + <%= if linked_domain.last_error do %> +

    {linked_domain.last_error}

    + <% end %> +
    +
    +
    + {Enum.count(linked_domain.checks, &(&1.status == "ok"))}/{length( + linked_domain.checks + )} checks matched +
    + +
    +
    +
    + <%= for check <- linked_domain.checks do %> +
    +
    + {check.label} + + {check.status} + +
    +

    + {check.detail} +

    +
    + <% end %> +
    +
    + <% end %> + <% end %> +
    +
    +
    + +
    +
    +
    +
    +
    +

    + Domain health +

    +
    +
    +
    + + {@domain_health.score}% + +
    +
    +

    + Health score +

    + + {@domain_health.status} + +

    {@domain_health.summary}

    +
    +
    +
    +
    +
    +
    + <%= for check <- @domain_health.checks do %> +
    +
    +
    +

    + {domain_health_category_label(check.category)} +

    +

    {check.label}

    +
    + {check.status} +
    +

    + {check.detail} +

    + <%= if check.fix do %> +

    + {check.fix} +

    + <% end %> +
    + <% end %> +
    +
    +
    +
    + +
    +
    +
    +

    + Service templates +

    +
    +
    + <%= for health <- @service_health do %> +
    +
    +
    +
    +

    + {service_label(health.service)} +

    + {health.status} +
    +
    + {length(health.managed_records)} record(s) + + {Enum.count(health.checks, &(&1.status == "ok"))}/{length(health.checks)} healthy + +
    +
    + +
    + <%= if health.last_error do %> +
    + {health.last_error} +
    + <% end %> + + <%= if health.checks != [] do %> +
    + <%= for check <- health.checks do %> +
    + {check.label} + + {check.status} + +
    + <% end %> +
    + <% else %> + <%= if health.planned_records != [] do %> +
    +

    + Not enabled yet. Applying this template creates and keeps these records up to date: +

    +
    + <%= for record <- health.planned_records do %> +
    + {record.metadata["label"]} + + {record.type} {record.name} + +
    + <% end %> +
    +
    + <% end %> + <% end %> + + <.simple_form + for={Map.fetch!(@service_forms, health.service)} + bare={true} + phx-submit="service_apply" + class="border-t border-base-content/10 pt-4" + > + + <%= if health.service == "mail" do %> +
    + <.input + field={Map.fetch!(@service_forms, health.service)[:mail_target]} + label="MX target" + placeholder={"mail." <> @active_zone.domain} + /> + <.input + field={Map.fetch!(@service_forms, health.service)[:dmarc_policy]} + type="select" + label="DMARC policy" + options={[ + {"quarantine", "quarantine"}, + {"reject", "reject"}, + {"none", "none"} + ]} + /> + <.input + field={Map.fetch!(@service_forms, health.service)[:mta_sts_mode]} + type="select" + label="MTA-STS mode" + options={[ + {"enforce", "enforce"}, + {"testing", "testing"}, + {"none", "none"} + ]} + /> + <.input + field={Map.fetch!(@service_forms, health.service)[:tls_rpt_rua]} + label="TLS-RPT rua" + placeholder={"postmaster@" <> @active_zone.domain} + /> +
    +

    + Targets are hostnames like mail.{@active_zone.domain} — no http://, paths, or IP addresses. TLS-RPT takes the email address that should receive TLS delivery reports. +

    + <% end %> + <%= if health.service == "web" do %> +
    + <.input + field={Map.fetch!(@service_forms, health.service)[:www_target]} + label="WWW target" + placeholder={@active_zone.domain} + /> +
    +

    + Where www.{@active_zone.domain} should point — a hostname like {@active_zone.domain}, not a URL. +

    + <% end %> + <%= if health.service == "turn" do %> +
    + <.input + field={Map.fetch!(@service_forms, health.service)[:turn_host]} + label="TURN host" + placeholder="turn" + /> + <.input + field={Map.fetch!(@service_forms, health.service)[:turn_target]} + label="TURN target" + placeholder={@active_zone.domain} + /> +
    + <% end %> + <%= if health.service == "vpn" do %> +
    + <.input + field={Map.fetch!(@service_forms, health.service)[:vpn_host]} + label="VPN host" + placeholder="vpn" + /> + <.input + field={Map.fetch!(@service_forms, health.service)[:vpn_target]} + label="VPN target" + placeholder={@active_zone.domain} + /> + <.input + field={Map.fetch!(@service_forms, health.service)[:vpn_api_host]} + label="Admin/API host" + placeholder="vpn-api" + /> + <.input + field={Map.fetch!(@service_forms, health.service)[:vpn_api_target]} + label="Admin/API target" + placeholder={@active_zone.domain} + /> +
    + <% end %> + <%= if health.service == "bluesky" do %> +
    + <.input + field={Map.fetch!(@service_forms, health.service)[:bluesky_host]} + label="Bluesky host" + placeholder="bsky" + /> + <.input + field={Map.fetch!(@service_forms, health.service)[:bluesky_target]} + label="Bluesky target" + placeholder={@active_zone.domain} + /> +
    +

    + Host is the subdomain to create (bsky becomes bsky.{@active_zone.domain}); target is the hostname it points at. +

    + <% end %> + <:actions> + <.button>Apply / Repair + + + + <%= if health.enabled do %> +
    + +
    + <% end %> +
    +
    +
    + <% end %> +
    +
    +
    + +
    +
    +
    +

    + Zone settings +

    +
    +
    + <.simple_form + for={@zone_settings_form} + bare={true} + phx-submit="zone_update" + class="rounded-2xl border border-base-content/10 bg-base-200/20 px-4 py-4" + > +
    + <.input + field={@zone_settings_form[:default_ttl]} + id="zone_settings_default_ttl" + type="number" + label="Default TTL" + /> + <.input + field={@zone_settings_form[:force_https]} + type="checkbox" + label="Force HTTPS" + /> +
    + <:actions> + <.button>Save settings + + + +
    +
    +
    +

    Built-in host ownership

    +

    + Who serves the apex of your built-in domain. +

    +
    +
    + + +
    +
    +
    + +
    +
    +

    + Delete this zone and all of its records. +

    + +
    +
    +
    +
    +
    +
    +
    +
    + """ + end + + # Shared field set for the new-record and edit-record forms. + attr :record_form, :any, required: true + attr :active_zone, :any, required: true + attr :current_user, :any, required: true + attr :record_types, :list, required: true + + defp record_fields(assigns) do + assigns = assign(assigns, :value_spec, record_value_spec(assigns.record_form)) + + ~H""" +
    +
    + <.input + field={@record_form[:name]} + label="Name" + placeholder={record_name_placeholder(@active_zone, @current_user)} + required + /> +

    + {record_name_field_help(@active_zone, @current_user)} +

    +
    + +
    + <.input + field={@record_form[:type]} + type="select" + label="Type" + options={Enum.map(@record_types, &{&1, &1})} + /> +

    {record_type_help(@record_form)}

    +
    + +
    + <.input + field={@record_form[:content]} + type={@value_spec.type} + label={@value_spec.label} + placeholder={@value_spec.placeholder} + rows={if @value_spec.type == "textarea", do: "5"} + required + /> +

    {record_value_help(@record_form)}

    +
    + +
    + <.input field={@record_form[:ttl]} type="number" label="TTL" /> +

    {ttl_help_text(@active_zone)}

    +
    + +
    + <.input field={@record_form[:private]} type="checkbox" label="Private record" /> +

    + Only recursive/private DNS clients can resolve this record. Public authoritative queries will not receive it. +

    +
    + +
    + <.input field={@record_form[:proxied]} type="checkbox" label="Proxy through Elektrine" /> +

    + Return Elektrine edge addresses publicly and keep this record value as the protected origin. +

    +
    + + <%= if record_form_type(@record_form) in ["A", "AAAA"] do %> +
    + <.input + field={@record_form[:health_check_enabled]} + type="checkbox" + label="Health-checked failover" + /> +

    + TCP-check this address on an interval and drop it from DNS answers while it is + down. If every checked address for a name is down, all are still answered. +

    + <%= if health_check_enabled?(@record_form) do %> + <.input + field={@record_form[:health_check_port]} + type="number" + label="Health check port" + placeholder="443" + /> + <% end %> +
    + <% end %> + + <%= for spec <- record_param_specs(@record_form) do %> +
    + <.input + field={@record_form[spec.field]} + type={spec.type} + label={spec.label} + placeholder={spec.placeholder} + /> +
    + <% end %> +
    + """ + end + + # Tabs available for the active zone. Built-in zones only expose records and + # settings; the setup tab appears only while delegation is unverified. + defp zone_tabs(nil, _user), do: [] + + defp zone_tabs(%Zone{} = zone, user) do + if builtin_zone?(zone, user) do + [{"records", "Records"}, {"settings", "Settings"}] + else + [{"records", "Records"}, {"services", "Services"}, {"health", "Health"}] ++ + if(zone.status != "verified", do: [{"setup", "Setup"}], else: []) ++ + [{"settings", "Settings"}] + end + end + + defp normalize_zone_tab(tab, tabs) do + keys = Enum.map(tabs, &elem(&1, 0)) + + cond do + tab in keys -> tab + keys == [] -> "records" + true -> hd(keys) + end + end + + defp params_with_user(socket, params), + do: Map.put(params, "user_id", socket.assigns.current_user.id) + + defp builtin_zone?(%Zone{} = zone, user), do: DNS.builtin_user_zone?(zone, user) + defp builtin_zone?(_, _), do: false + + defp builtin_zone_hosted_by_platform?(user), + do: User.built_in_subdomain_hosted_by_platform?(user) + + defp builtin_zone_mode_label(user) do + if builtin_zone_hosted_by_platform?(user), do: "platform-hosted", else: "dns-managed" + end + + defp builtin_zone_mode_button_class(user, mode) do + active? = User.built_in_subdomain_mode(user) == mode + + [ + "join-item btn btn-sm", + if(active?, do: "btn-primary", else: "btn-outline") + ] + end + + defp zone_role_label(%Zone{} = zone, user) do + cond do + builtin_zone?(zone, user) and builtin_zone_hosted_by_platform?(user) -> "built-in host" + builtin_zone?(zone, user) -> "built-in, dns-managed" + true -> "delegated zone" + end + end + + defp zone_role_label(_, _), do: "zone" + + defp record_name_placeholder(%Zone{} = zone, user) do + cond do + builtin_zone?(zone, user) and builtin_zone_hosted_by_platform?(user) -> + "blog or _acme-challenge" + + builtin_zone?(zone, user) -> + "@ or blog" + + true -> + "@ or www" + end + end + + defp record_name_placeholder(_, _), do: "@ or www" + + defp record_name_field_help(%Zone{} = zone, user) do + if builtin_zone?(zone, user) and builtin_zone_hosted_by_platform?(user) do + "Apex allows `TXT` and `CAA`; use labels for subdomains." + else + "Use `@` for apex; labels become `label.#{zone.domain}`." + end + end + + defp record_name_field_help(_, _), do: "Use `@` for apex." + + defp record_type_help(form) do + case record_form_type(form) do + "A" -> + "IPv4 address." + + "AAAA" -> + "IPv6 address." + + "ALIAS" -> + "Apex hostname alias." + + "CAA" -> + "Certificate authority policy." + + "CNAME" -> + "Hostname alias." + + "HTTPS" -> + "HTTPS endpoint hints." + + "MX" -> + "Mail exchanger." + + "TXT" -> + "Text value." + + "NS" -> + "Nameserver delegation." + + "SRV" -> + "Service target." + + "SSHFP" -> + "SSH fingerprint." + + "SVCB" -> + "Service binding." + + "TLSA" -> + "DANE certificate data." + + other -> + "#{other} record." + end + end + + defp record_value_help(form) do + case record_form_type(form) do + "A" -> + "IPv4 destination." + + "AAAA" -> + "IPv6 destination." + + "ALIAS" -> + "Target hostname." + + "CAA" -> + "CA value, e.g. `letsencrypt.org`." + + "CNAME" -> + "Target hostname." + + "HTTPS" -> + "Target plus optional params." + + "MX" -> + "Mail server hostname." + + "TXT" -> + "Full text value." + + "NS" -> + "Nameserver hostname." + + "SSHFP" -> + "Hex fingerprint." + + "SVCB" -> + "Target plus optional params." + + "TLSA" -> + "Hex certificate association." + + _ -> + "Record value." + end + end + + defp ttl_help_text(%Zone{} = zone) do + "Default: #{zone.default_ttl}." + end + + defp ttl_help_text(_), do: "Cache lifetime." + + defp zone_scan_for_params(params) when is_map(params) do + case Map.get(params, "domain") do + value when is_binary(value) -> DNS.scan_existing_zone(value) + _ -> nil + end + end + + defp zone_scan_for_params(_), do: nil + + defp keep_matching_scan(nil, _params), do: nil + + defp keep_matching_scan(scan, params) when is_map(params) do + case Map.get(params, "domain") do + value when is_binary(value) -> + if normalize_dns_name(value) == normalize_dns_name(scan.domain), do: scan, else: nil + + _ -> + nil + end + end + + defp zone_form_attrs(%Phoenix.HTML.Form{source: changeset}) do + %{ + "domain" => Ecto.Changeset.get_field(changeset, :domain), + "default_ttl" => Ecto.Changeset.get_field(changeset, :default_ttl) + } + end + + defp zone_form_attrs(_), do: %{} + + defp format_scan_values(values) when is_list(values), do: Enum.join(values, ", ") + defp format_scan_values(value), do: to_string(value) + + defp scan_delegation_matches?(zones, scan) do + case matching_scan_zone(zones, scan) do + %Zone{} = zone -> + observed = scan.nameservers |> Enum.map(&normalize_dns_name/1) |> Enum.sort() + + expected = + zone |> DNS.assigned_nameservers() |> Enum.map(&normalize_dns_name/1) |> Enum.sort() + + observed == expected + + _ -> + false + end + end + + defp matching_scan_zone(zones, %{domain: domain}) when is_list(zones) do + expected = normalize_dns_name(domain) + Enum.find(zones, &(normalize_dns_name(&1.domain) == expected)) + end + + defp matching_scan_zone(_, _), do: nil + + defp import_scan_records(%Zone{} = zone, scan, selected_ids) do + selected = MapSet.new(List.wrap(selected_ids)) + + scan + |> scan_import_items() + |> Enum.filter(&MapSet.member?(selected, &1.id)) + |> Enum.reduce(%{imported: 0, skipped: 0}, fn item, counts -> + case DNS.create_record(zone, item.attrs) do + {:ok, _} -> %{counts | imported: counts.imported + 1} + {:error, _} -> %{counts | skipped: counts.skipped + 1} + end + end) + end + + defp import_scan_records(_, _, _), do: %{imported: 0, skipped: 0} + + defp scan_import_items(%{records: records}) when is_list(records) do + records + |> Enum.flat_map(&scan_record_items/1) + |> Enum.with_index() + |> Enum.map(fn {item, index} -> Map.put(item, :id, Integer.to_string(index)) end) + end + + defp scan_import_items(_), do: [] + + defp scan_record_items(%{host: host, type: type, values: values}) when is_list(values) do + Enum.map(values, &scan_record_item(host, type, &1)) + |> Enum.reject(&is_nil/1) + end + + defp scan_record_items(_), do: [] + + defp scan_record_item(host, "MX", value) when is_binary(value) do + case String.split(value, ~r/\s+/, parts: 2, trim: true) do + [priority, target] -> + case Integer.parse(priority) do + {priority, ""} -> + %{ + host: host, + type: "MX", + value: value, + attrs: %{ + "name" => host, + "type" => "MX", + "content" => target, + "priority" => priority + } + } + + _ -> + nil + end + + _ -> + nil + end + end + + defp scan_record_item(host, type, value) when is_binary(value) do + %{ + host: host, + type: type, + value: value, + attrs: %{"name" => host, "type" => type, "content" => value} + } + end + + defp scan_record_item(_, _, _), do: nil + + defp scan_import_message(%{imported: imported, skipped: skipped}, created_zone?) do + prefix = if(created_zone?, do: "Zone created.", else: "Records imported.") + + "#{prefix} Added #{imported} record#{if imported == 1, do: "", else: "s"}.#{scan_import_skipped_message(skipped)}" + end + + defp scan_import_skipped_message(0), do: "" + + defp scan_import_skipped_message(skipped) do + " Skipped #{skipped} duplicate or invalid record#{if skipped == 1, do: "", else: "s"}." + end + + defp format_user_error(changeset), do: format_zone_error(changeset) + + defp select_active_zone([], _zone_id), do: nil + defp select_active_zone(zones, nil), do: List.first(zones) + + defp select_active_zone(zones, zone_id) do + case parse_int(zone_id) do + {:ok, zone_id} -> Enum.find(zones, List.first(zones), &(&1.id == zone_id)) + :error -> List.first(zones) + end + end + + defp parse_int(value) when is_binary(value) do + case Integer.parse(value) do + {int, ""} -> {:ok, int} + _ -> :error + end + end + + defp parse_int(_), do: :error + + defp record_form(nil), do: to_form(DNS.change_record(%Record{}, %{}), as: :record) + + defp record_form(%Zone{id: zone_id}), + do: to_form(DNS.new_record_changeset(zone_id), as: :record) + + defp record_form(%Zone{id: zone_id}, attrs) when is_map(attrs), + do: to_form(DNS.change_record(%Record{}, Map.put(attrs, "zone_id", zone_id)), as: :record) + + defp zone_settings_form(nil), do: nil + + defp zone_settings_form(%Zone{} = zone), + do: to_form(DNS.change_zone(zone, %{"force_https" => zone.force_https}), as: :zone) + + defp service_forms(nil) do + %{ + "mail" => + to_form( + %{ + "mail_target" => "", + "dmarc_policy" => "quarantine", + "mta_sts_mode" => "enforce", + "tls_rpt_rua" => "" + }, + as: :service_config + ), + "web" => to_form(%{"www_target" => ""}, as: :service_config), + "turn" => to_form(%{"turn_host" => "turn", "turn_target" => ""}, as: :service_config), + "vpn" => + to_form( + %{ + "vpn_host" => "vpn", + "vpn_target" => "", + "vpn_api_host" => "", + "vpn_api_target" => "" + }, + as: :service_config + ), + "bluesky" => + to_form(%{"bluesky_host" => "bsky", "bluesky_target" => ""}, as: :service_config) + } + end + + defp service_forms(%Zone{} = zone) do + health = DNS.zone_service_health(zone) + + %{ + "mail" => + service_form_from_health( + Enum.find(health, &(&1.service == "mail")), + %{ + "mail_target" => MailSecurity.default_mail_target(zone), + "dmarc_policy" => "quarantine", + "mta_sts_mode" => "enforce", + "tls_rpt_rua" => "mailto:postmaster@#{zone.domain}" + } + ), + "web" => + service_form_from_health( + Enum.find(health, &(&1.service == "web")), + %{"www_target" => zone.domain} + ), + "turn" => + service_form_from_health( + Enum.find(health, &(&1.service == "turn")), + %{"turn_host" => "turn", "turn_target" => zone.domain} + ), + "vpn" => + service_form_from_health( + Enum.find(health, &(&1.service == "vpn")), + %{ + "vpn_host" => "vpn", + "vpn_target" => zone.domain, + "vpn_api_host" => "", + "vpn_api_target" => zone.domain + } + ), + "bluesky" => + service_form_from_health( + Enum.find(health, &(&1.service == "bluesky")), + %{"bluesky_host" => "bsky", "bluesky_target" => zone.domain} + ) + } + end + + defp service_health(nil), + do: Enum.map(["mail", "web", "bluesky"], &blank_service_health/1) + + defp service_health(%Zone{} = zone) do + health = DNS.zone_service_health(zone) + Enum.map(["mail", "web", "bluesky"], &service_entry(health, &1)) + end + + defp save_record(zone, nil, params), do: DNS.create_record(zone, params) + + defp save_record(zone, record_id, params) do + case DNS.get_record(record_id, zone.id) do + %Record{} = record -> + DNS.update_record(record, params) + + _ -> + {:error, DNS.change_record(%Record{}, params) |> Map.put(:action, :insert)} + end + end + + defp compact_zone_link_class(zone, active_zone) do + active? = active_zone && zone.id == active_zone.id + + [ + "block border-b border-base-300 last:border-b-0 px-5 py-3 transition", + if(active?, + do: "bg-primary/8", + else: "hover:bg-base-200/40" + ) + ] + end + + defp service_entry(health, service) do + Enum.find(health, blank_service_health(service), &(&1.service == service)) + end + + defp linked_domains(nil, _user_id), do: [] + + defp linked_domains(%Zone{} = zone, user_id) when is_integer(user_id) do + zone_domain = normalize_dns_name(zone.domain) + + profile_domains = + user_id + |> ProfileCustomDomains.list_user_custom_domains() + |> Enum.filter(&(normalize_dns_name(&1.domain) == zone_domain)) + |> Enum.map(&linked_domain_entry(&1, :profile, zone)) + + email_domains = + user_id + |> list_email_custom_domains() + |> Enum.filter(&(normalize_dns_name(&1.domain) == zone_domain)) + |> Enum.map(&linked_domain_entry(&1, :email, zone)) + + profile_domains ++ email_domains + end + + defp linked_domains(_, _user_id), do: [] + + defp linked_domain_entry(custom_domain, :profile, zone) do + checks = + custom_domain + |> ProfileCustomDomains.dns_records_for_custom_domain() + |> Enum.map(&linked_domain_check(zone, &1)) + + %{ + domain: custom_domain.domain, + kind: "profile", + title: custom_domain.domain, + kind_label: "Profile", + status: custom_domain.status || "pending", + summary: "Profile custom domain configured in account settings.", + last_error: custom_domain.last_error, + checks: checks + } + end + + defp linked_domain_entry(custom_domain, :email, zone) do + checks = + custom_domain + |> email_custom_domain_records() + |> Enum.map(&linked_domain_check(zone, &1)) + + %{ + domain: custom_domain.domain, + kind: "email", + title: custom_domain.domain, + kind_label: "Email", + status: custom_domain.status || "pending", + summary: "Custom email domain configured in account settings.", + last_error: custom_domain.last_error || custom_domain.dkim_last_error, + checks: checks + } + end + + defp linked_domain_check(zone, %{type: "ALIAS", host: host, value: value, label: label}) do + if normalize_dns_name(host) == normalize_dns_name(zone.domain) do + %{ + label: label, + status: + if(record_exists_for_expected?(zone, %{type: "ALIAS", host: host, value: value}), + do: "ok", + else: "missing" + ), + addable: true, + detail: + linked_domain_check_detail(%{type: "ALIAS", host: host, value: value, label: label}) + } + else + linked_domain_check(zone, %{type: "CNAME", host: host, value: value, label: label}) + end + end + + defp linked_domain_check(zone, expected_record) do + matching_record = + Enum.find(zone.records || [], &record_matches_expected?(&1, expected_record, zone)) + + %{ + label: expected_record.label, + status: if(matching_record, do: "ok", else: "missing"), + addable: true, + detail: linked_domain_check_detail(expected_record) + } + end + + defp load_linked_custom_domain("profile", domain, user_id) do + case Enum.find(ProfileCustomDomains.list_user_custom_domains(user_id), &(&1.domain == domain)) do + nil -> :error + custom_domain -> {:ok, custom_domain, :profile} + end + end + + defp load_linked_custom_domain("email", domain, user_id) do + case Enum.find(list_email_custom_domains(user_id), &(&1.domain == domain)) do + nil -> :error + custom_domain -> {:ok, custom_domain, :email} + end + end + + defp load_linked_custom_domain(_, _, _), do: :error + + defp expected_linked_domain_records(custom_domain, :profile), + do: ProfileCustomDomains.dns_records_for_custom_domain(custom_domain) + + defp expected_linked_domain_records(custom_domain, :email), + do: email_custom_domain_records(custom_domain) + + defp list_email_custom_domains(user_id) do + module = Module.concat([Elektrine, Email, CustomDomains]) + + if Code.ensure_loaded?(module) and function_exported?(module, :list_user_custom_domains, 1) do + module.list_user_custom_domains(user_id) + else + [] + end + end + + defp email_custom_domain_records(custom_domain) do + module = Module.concat([Elektrine, Email, CustomDomains]) + + if Code.ensure_loaded?(module) and + function_exported?(module, :dns_records_for_custom_domain, 1) do + module.dns_records_for_custom_domain(custom_domain) + else + [] + end + end + + defp record_exists_for_expected?(%Zone{} = zone, expected_record) do + Enum.any?(zone.records || [], &record_matches_expected?(&1, expected_record, zone)) + end + + defp expected_record_to_attrs(%Zone{} = zone, expected_record) do + priority = Map.get(expected_record, :priority) + + attrs = %{ + "name" => expected_record_name(zone, expected_record.host), + "type" => normalize_expected_type(expected_record.type), + "content" => expected_record.value, + "ttl" => zone.default_ttl + } + + case priority do + nil -> attrs + priority -> Map.put(attrs, "priority", priority) + end + end + + defp expected_record_name(%Zone{} = zone, host) do + zone_domain = normalize_dns_name(zone.domain) + normalized_host = normalize_dns_name(host) + + cond do + normalized_host == zone_domain -> + "@" + + String.ends_with?(normalized_host, "." <> zone_domain) -> + String.trim_trailing(normalized_host, "." <> zone_domain) + + true -> + normalized_host + end + end + + defp normalize_expected_type(type), do: type + + defp record_matches_expected?(record, expected_record, zone) do + record_host = record_host(zone, record) + expected_host = normalize_dns_name(expected_record.host) + record_type = normalize_dns_name(record.type) + expected_type = normalize_dns_name(expected_record.type) + + record_host == expected_host and + record_type == expected_type and + record_value_matches?(record, expected_record) + end + + defp record_value_matches?( + %Record{type: "MX", content: content, priority: priority}, + expected_record + ) do + expected_priority = Map.get(expected_record, :priority) + + normalize_dns_name(content) == normalize_dns_name(expected_record.value) and + (is_nil(expected_priority) or priority == expected_priority) + end + + defp record_value_matches?(%Record{type: type, content: content}, expected_record) + when type in ["ALIAS", "CNAME", "NS"] do + normalize_dns_name(content) == normalize_dns_name(expected_record.value) + end + + defp record_value_matches?(%Record{content: content}, expected_record) do + normalize_record_value(content) == normalize_record_value(expected_record.value) + end + + defp record_host(%Zone{} = zone, %Record{name: name}) do + zone_domain = normalize_dns_name(zone.domain) + + case normalize_dns_name(name) do + "@" -> + zone_domain + + record_name -> + if String.ends_with?(record_name, "." <> zone_domain) do + record_name + else + normalize_dns_name(record_name <> "." <> zone.domain) + end + end + end + + defp linked_domain_check_detail(expected_record) do + base = "#{expected_record.type} #{expected_record.host} -> #{expected_record.value}" + priority = Map.get(expected_record, :priority) + + if is_nil(priority) do + base + else + base <> " (priority #{priority})" + end + end + + defp normalize_record_value(value) when is_binary(value) do + value + |> String.trim() + |> String.replace(~r/\s+/, " ") + |> String.trim_trailing(".") + |> String.downcase() + end + + defp normalize_record_value(value), do: value |> to_string() |> normalize_record_value() + + defp normalize_dns_name(value) when is_binary(value) do + value + |> String.trim() + |> String.trim_trailing(".") + |> String.downcase() + end + + defp normalize_dns_name(value), do: value |> to_string() |> normalize_dns_name() + + defp blank_service_health(service) do + %{ + service: service, + enabled: false, + mode: nil, + status: "not_configured", + last_error: nil, + managed_records: [], + planned_records: [], + checks: [] + } + end + + defp service_label("mail"), do: "Email" + defp service_label("web"), do: "Website" + defp service_label("turn"), do: "TURN / Calls" + defp service_label("vpn"), do: "VPN" + defp service_label("bluesky"), do: "Bluesky" + defp service_label(service), do: String.capitalize(service) + + defp record_preset_options(%Zone{} = zone) do + [ + %{ + id: "website", + label: "Point website", + attrs: %{ + "name" => "@", + "type" => "A", + "content" => "198.51.100.42", + "ttl" => zone.default_ttl + } + }, + %{ + id: "email", + label: "Set up email", + attrs: %{ + "name" => "@", + "type" => "MX", + "content" => "mail.#{zone.domain}", + "priority" => 10, + "ttl" => zone.default_ttl + } + }, + %{ + id: "verification", + label: "Verify domain ownership", + attrs: %{ + "name" => "@", + "type" => "TXT", + "content" => "paste-verification-token-here", + "ttl" => zone.default_ttl + } + }, + %{ + id: "subdomain", + label: "Add subdomain", + attrs: %{ + "name" => "blog", + "type" => "CNAME", + "content" => zone.domain, + "ttl" => zone.default_ttl + } + } + ] + end + + defp record_preset_options(_), do: [] + + defp record_preset_attrs(%Zone{} = zone, preset) do + zone + |> record_preset_options() + |> Enum.find(%{}, &(&1.id == preset)) + |> case do + %{attrs: attrs} -> attrs + _ -> %{} + end + end + + defp record_preset_button_class(selected_preset, preset_id) do + [ + "rounded-2xl border px-4 py-3 text-left text-sm transition", + if(selected_preset == preset_id, + do: "border-primary bg-primary/8", + else: "border-base-content/10 bg-base-200/20 hover:bg-base-200/35" + ) + ] + end + + defp record_type_preset_button_class(form, preset_type) do + selected_type = record_form_type(form) + + active? = + case preset_type do + "A" -> selected_type in ["A", "AAAA"] + _ -> selected_type == preset_type + end + + [ + "rounded-2xl border px-4 py-3 text-left text-sm transition", + if(active?, + do: "border-primary bg-primary/8", + else: "border-base-content/10 bg-base-200/20 hover:bg-base-200/35" + ) + ] + end + + defp record_type_preset_attrs(%Zone{} = zone, "A") do + %{"name" => "@", "type" => "A", "content" => "198.51.100.42", "ttl" => zone.default_ttl} + end + + defp record_type_preset_attrs(%Zone{} = zone, "CNAME") do + %{"name" => "www", "type" => "CNAME", "content" => zone.domain, "ttl" => zone.default_ttl} + end + + defp record_type_preset_attrs(%Zone{} = zone, "MX") do + %{ + "name" => "@", + "type" => "MX", + "content" => "mail.#{zone.domain}", + "priority" => 10, + "ttl" => zone.default_ttl + } + end + + defp record_type_preset_attrs(%Zone{} = zone, "TXT") do + %{ + "name" => "@", + "type" => "TXT", + "content" => "paste-text-value-here", + "ttl" => zone.default_ttl + } + end + + defp record_type_preset_attrs(%Zone{} = zone, _type) do + %{"name" => "@", "type" => "A", "content" => "198.51.100.42", "ttl" => zone.default_ttl} + end + + defp record_value_spec(form) do + case record_form_type(form) do + "DNSKEY" -> + %{label: "Public key", placeholder: "AwEAAc...", type: "textarea"} + + "DS" -> + %{label: "Digest", placeholder: "2BB183AF5F22588179A53B0A98631FAD1A292118", type: "text"} + + "HTTPS" -> + %{label: "Target and parameters", placeholder: ". alpn=h2,h3 port=443", type: "text"} + + "SSHFP" -> + %{label: "Fingerprint", placeholder: "1234567890ABCDEF1234567890ABCDEF", type: "text"} + + "SVCB" -> + %{ + label: "Target and parameters", + placeholder: "svc.example.net alpn=h2 port=8443", + type: "text" + } + + "TLSA" -> + %{label: "Certificate data", placeholder: "A1B2C3D4...", type: "textarea"} + + "TXT" -> + %{label: "Text value", placeholder: "v=spf1 mx ~all", type: "textarea"} + + _ -> + %{label: "Value", placeholder: "198.51.100.42", type: "text"} + end + end + + defp record_param_specs(form) do + case record_form_type(form) do + "MX" -> + [%{field: :priority, label: "Priority", placeholder: "10", type: "number"}] + + "SRV" -> + [ + %{field: :priority, label: "Priority", placeholder: "10", type: "number"}, + %{field: :weight, label: "Weight", placeholder: "5", type: "number"}, + %{field: :port, label: "Port", placeholder: "443", type: "number"} + ] + + "CAA" -> + [ + %{field: :flags, label: "Flags", placeholder: "0", type: "number"}, + %{field: :tag, label: "Tag", placeholder: "issue", type: "text"} + ] + + "DNSKEY" -> + [ + %{field: :flags, label: "Flags", placeholder: "257", type: "number"}, + %{field: :protocol, label: "Protocol", placeholder: "3", type: "number"}, + %{field: :algorithm, label: "Algorithm", placeholder: "13", type: "number"} + ] + + "DS" -> + [ + %{field: :key_tag, label: "Key tag", placeholder: "12345", type: "number"}, + %{field: :algorithm, label: "Algorithm", placeholder: "13", type: "number"}, + %{field: :digest_type, label: "Digest type", placeholder: "2", type: "number"} + ] + + "TLSA" -> + [ + %{field: :usage, label: "Usage", placeholder: "3", type: "number"}, + %{field: :selector, label: "Selector", placeholder: "1", type: "number"}, + %{field: :matching_type, label: "Matching type", placeholder: "1", type: "number"} + ] + + "SSHFP" -> + [ + %{field: :algorithm, label: "Algorithm", placeholder: "4", type: "number"}, + %{field: :digest_type, label: "Fingerprint type", placeholder: "2", type: "number"} + ] + + type when type in ["HTTPS", "SVCB"] -> + [%{field: :priority, label: "Priority", placeholder: "1", type: "number"}] + + _ -> + [] + end + end + + defp record_form_type(form) do + form + |> Phoenix.HTML.Form.input_value(:type) + |> case do + nil -> "A" + value -> value |> to_string() |> String.upcase() + end + end + + defp health_check_enabled?(form) do + Phoenix.HTML.Form.input_value(form, :health_check_enabled) in [true, "true"] + end + + defp record_target_healthy?(record) do + port = Record.health_check_port(record) || Elektrine.DNS.HealthMonitor.default_port() + Elektrine.DNS.HealthMonitor.healthy?(record.content, port) + end + + defp record_rdata(%{type: "MX", priority: priority, content: content}), + do: "#{priority || 10} #{content}" + + defp record_rdata(%{ + type: "SRV", + priority: priority, + weight: weight, + port: port, + content: content + }), + do: "#{priority || 0} #{weight || 0} #{port || 0} #{content}" + + defp record_rdata(%{type: "CAA", flags: flags, tag: tag, content: content}), + do: "#{flags || 0} #{tag || "issue"} #{content}" + + defp record_rdata(%{ + type: "DNSKEY", + flags: flags, + protocol: protocol, + algorithm: algorithm, + content: content + }), + do: "#{flags || 0} #{protocol || 3} #{algorithm || 0} #{content}" + + defp record_rdata(%{ + type: "DS", + key_tag: key_tag, + algorithm: algorithm, + digest_type: digest_type, + content: content + }), + do: "#{key_tag || 0} #{algorithm || 0} #{digest_type || 0} #{content}" + + defp record_rdata(%{ + type: "TLSA", + usage: usage, + selector: selector, + matching_type: matching_type, + content: content + }), + do: "#{usage || 0} #{selector || 0} #{matching_type || 0} #{content}" + + defp record_rdata(%{ + type: "SSHFP", + algorithm: algorithm, + digest_type: digest_type, + content: content + }), + do: "#{algorithm || 0} #{digest_type || 0} #{content}" + + defp record_rdata(%{type: type, priority: priority, content: content}) + when type in ["HTTPS", "SVCB"], + do: "#{priority || 0} #{content}" + + defp record_rdata(record), do: record.content + + defp zone_status_badge_class("verified"), do: "badge badge-success badge-outline" + defp zone_status_badge_class("pending"), do: "badge badge-warning badge-outline" + defp zone_status_badge_class("error"), do: "badge badge-error badge-outline" + defp zone_status_badge_class(_), do: "badge badge-outline" + + defp service_badge_class("ok"), do: "badge badge-success badge-outline" + defp service_badge_class("conflict"), do: "badge badge-warning badge-outline" + defp service_badge_class("error"), do: "badge badge-error badge-outline" + defp service_badge_class("disabled"), do: "badge badge-ghost" + defp service_badge_class("not_configured"), do: "badge badge-ghost" + defp service_badge_class(_), do: "badge badge-outline" + + defp linked_domain_status_badge_class("verified"), do: "badge badge-success badge-outline" + defp linked_domain_status_badge_class("pending"), do: "badge badge-warning badge-outline" + defp linked_domain_status_badge_class(_), do: "badge badge-outline" + + defp check_badge_class("ok"), do: "badge badge-success badge-outline" + defp check_badge_class("conflict"), do: "badge badge-warning badge-outline" + defp check_badge_class("missing"), do: "badge badge-error badge-outline" + defp check_badge_class("drift"), do: "badge badge-warning badge-outline" + defp check_badge_class(_), do: "badge badge-outline" + + defp linked_domain_check_badge_class("ok"), do: "badge badge-success badge-outline" + defp linked_domain_check_badge_class("missing"), do: "badge badge-error badge-outline" + defp linked_domain_check_badge_class("review"), do: "badge badge-ghost" + defp linked_domain_check_badge_class(_), do: "badge badge-outline" + + defp domain_health_badge_class(:ok), do: "badge badge-success badge-outline" + defp domain_health_badge_class(:review), do: "badge badge-warning badge-outline" + defp domain_health_badge_class(:warning), do: "badge badge-warning badge-outline" + defp domain_health_badge_class(:missing), do: "badge badge-error badge-outline" + defp domain_health_badge_class(_), do: "badge badge-outline" + + defp domain_health_category_label(:dns), do: "DNS" + defp domain_health_category_label(:mail), do: "Mail" + defp domain_health_category_label(:tls), do: "TLS" + defp domain_health_category_label(:deliverability), do: "Deliverability" + defp domain_health_category_label(category), do: category |> to_string() |> String.capitalize() + + defp format_zone_error(changeset) do + changeset.errors + |> Keyword.keys() + |> Enum.map_join(", ", &to_string/1) + |> case do + "" -> "Zone verification failed" + details -> "Zone verification failed (#{details})" + end + end + + defp put_service_apply_flash(socket, service, config) do + case config.status do + "ok" -> + put_flash(socket, :info, "Managed #{service_label(service)} DNS applied") + + "conflict" -> + put_flash( + socket, + :error, + "Managed #{service_label(service)} DNS was not applied: #{config.last_error}. " <> + "Remove or edit the conflicting records under the Records tab, then apply again." + ) + + "error" -> + put_flash( + socket, + :error, + "Managed #{service_label(service)} DNS ran into a problem: #{config.last_error}" + ) + + _ -> + put_flash(socket, :info, "Managed #{service_label(service)} DNS settings saved") + end + end + + defp format_service_error(service, %Ecto.Changeset{} = changeset) do + details = + changeset.errors + |> Enum.map_join("; ", fn {field, {message, _opts}} -> "#{field} #{message}" end) + + if details == "" do + "Could not apply managed #{service_label(service)} DNS" + else + "Could not apply managed #{service_label(service)} DNS: #{details}" + end + end + + defp format_service_error(service, reason) when is_binary(reason) do + "Could not apply managed #{service_label(service)} DNS: #{reason}" + end + + defp format_service_error(service, _reason) do + "Could not apply managed #{service_label(service)} DNS" + end + + defp service_form_from_health(nil, defaults), do: to_form(defaults, as: :service_config) + + defp service_form_from_health(health, defaults) do + defaults + |> Map.merge(health.settings || %{}) + |> to_form(as: :service_config) + end + + defp normalize_zone_params(params) when is_map(params) do + Map.put(params, "force_https", Map.get(params, "force_https") in [true, "true", "on", "1"]) + end +end diff --git a/apps/elektrine_dns/lib/mix/tasks/elektrine.dns.ensure_profile_wildcard.ex b/apps/elektrine_dns/lib/mix/tasks/elektrine.dns.ensure_profile_wildcard.ex new file mode 100644 index 0000000..e62b513 --- /dev/null +++ b/apps/elektrine_dns/lib/mix/tasks/elektrine.dns.ensure_profile_wildcard.ex @@ -0,0 +1,30 @@ +defmodule Mix.Tasks.Elektrine.Dns.EnsureProfileWildcard do + use Mix.Task + + @shortdoc "Ensure the *. wildcard DNS catch-all records exist" + + @moduledoc """ + Ensures a proxied wildcard record (`*.`) exists in every configured + profile base-domain zone, so every built-in profile subdomain resolves to the + edge even when the user never provisioned their own built-in zone. + + Idempotent. Safe to run repeatedly. + + mix elektrine.dns.ensure_profile_wildcard + + In a release (no Mix), call the underlying function from a remote console + instead: + + Elektrine.DNS.ensure_profile_subdomain_wildcards() + """ + + @impl true + def run(_args) do + Mix.Task.run("app.start") + + Elektrine.DNS.ensure_profile_subdomain_wildcards() + |> Enum.each(fn {domain, result} -> + Mix.shell().info("#{domain}: #{inspect(result)}") + end) + end +end diff --git a/apps/elektrine_dns/mix.exs b/apps/elektrine_dns/mix.exs new file mode 100644 index 0000000..3264d6b --- /dev/null +++ b/apps/elektrine_dns/mix.exs @@ -0,0 +1,43 @@ +defmodule ElektrineDNS.MixProject do + use Mix.Project + + def project do + [ + app: :elektrine_dns, + version: "0.1.0", + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + elixir: "~> 1.19", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + mod: {Elektrine.DNS.Application, []}, + extra_applications: [:logger] + ] + end + + defp deps do + [ + internal_dep(:elektrine), + internal_dep(:elektrine_web), + {:phoenix, "== 1.8.9"}, + {:phoenix_html, "== 4.3.0"}, + {:phoenix_live_view, "== 1.1.30"}, + {:jason, "== 1.4.5"} + ] + end + + defp internal_dep(app) do + if Mix.Project.umbrella?() do + {app, in_umbrella: true} + else + {app, path: "../#{app}"} + end + end +end diff --git a/apps/elektrine_dns/test/elektrine/dns/authority_cache_test.exs b/apps/elektrine_dns/test/elektrine/dns/authority_cache_test.exs new file mode 100644 index 0000000..1628da3 --- /dev/null +++ b/apps/elektrine_dns/test/elektrine/dns/authority_cache_test.exs @@ -0,0 +1,223 @@ +defmodule Elektrine.DNS.AuthorityCacheTestDKIM do + def generate_domain_key_material do + %{selector: "default", public_key: "PUBLICKEY", private_key: "PRIVATEKEY"} + end + + def public_key_dns_value(key), do: key + def mx_host, do: "mail.example.com" + def sync_domain(_domain, _selector, _private_key), do: :ok +end + +defmodule Elektrine.DNS.AuthorityCacheTest do + use Elektrine.DataCase, async: false + + import Ecto.Query + + alias Elektrine.AccountsFixtures + alias Elektrine.DNS + alias Elektrine.DNS.Query + alias Elektrine.DNS.Record + alias Elektrine.DNS.Zone + alias Elektrine.DNS.ZoneCache + alias Elektrine.Repo + + setup do + old_dns = Application.get_env(:elektrine, :dns, []) + old_dkim = Application.get_env(:elektrine, :managed_dns_dkim_module) + + Application.put_env( + :elektrine, + :dns, + Keyword.merge(old_dns, + nameservers: ["ns1.cache-test.example", "ns2.cache-test.example"], + zone_cache_refresh_interval_ms: 25 + ) + ) + + Application.put_env( + :elektrine, + :managed_dns_dkim_module, + Elektrine.DNS.AuthorityCacheTestDKIM + ) + + restart_zone_cache() + ensure_zone_cache_started() + ZoneCache.refresh() + + on_exit(fn -> + Application.put_env(:elektrine, :dns, old_dns) + Application.put_env(:elektrine, :managed_dns_dkim_module, old_dkim) + + if Process.whereis(ZoneCache) do + :sys.replace_state(ZoneCache, fn state -> + Map.put( + state, + :refresh_interval_ms, + Keyword.get(old_dns, :zone_cache_refresh_interval_ms, 5_000) + ) + end) + + ZoneCache.refresh() + end + end) + + :ok + end + + test "new zones are served authoritatively without a restart" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + Repo.update_all(from(z in Zone, where: z.id == ^zone.id), set: [status: "verified"]) + ZoneCache.refresh() + + response = Query.answer(build_query(zone.domain, 2)) + + assert header(response).rcode == 0 + assert header(response).aa == 1 + assert header(response).ancount == 2 + end + + test "pending zones are not served authoritatively before verification" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + response = Query.answer(build_query(zone.domain, 6)) + + assert zone.status == "provisioning" + assert header(response).rcode == 5 + assert header(response).aa == 0 + assert header(response).ancount == 0 + end + + test "managed mail records are served authoritatively right after apply" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + Repo.update_all(from(z in Zone, where: z.id == ^zone.id), set: [status: "verified"]) + ZoneCache.refresh() + assert {:ok, _config} = DNS.apply_zone_service(zone, "mail") + + response = Query.answer(build_query("_dmarc." <> zone.domain, 16)) + + assert header(response).rcode == 0 + assert header(response).aa == 1 + assert header(response).ancount == 1 + end + + test "managed service disable refreshes authority cache immediately" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + Repo.update_all(from(z in Zone, where: z.id == ^zone.id), set: [status: "verified"]) + ZoneCache.refresh() + assert {:ok, _config} = DNS.apply_zone_service(zone, "mail") + + qname = "_dmarc." <> zone.domain + assert header(Query.answer(build_query(qname, 16))).ancount == 1 + + zone = DNS.get_zone(zone.id, user.id) + assert {:ok, _config} = DNS.disable_zone_service(zone, "mail") + + response = Query.answer(build_query(qname, 16)) + assert header(response).rcode == 3 + assert header(response).ancount == 0 + end + + test "periodic cache refresh picks up records written by another runtime" do + user = AccountsFixtures.user_fixture() + + zone = + Repo.insert!(%Zone{ + domain: unique_domain(), + status: "verified", + kind: "native", + default_ttl: 300, + user_id: user.id + }) + + assert header(Query.answer(build_query(zone.domain, 1))).ancount == 0 + + Repo.insert!(%Record{ + zone_id: zone.id, + name: "@", + type: "A", + ttl: 300, + content: "66.42.127.87" + }) + + assert_eventually(fn -> + header(Query.answer(build_query(zone.domain, 1))).ancount == 1 + end) + end + + defp ensure_zone_cache_started do + case Process.whereis(ZoneCache) do + nil -> start_supervised!(ZoneCache) + _pid -> :ok + end + end + + defp restart_zone_cache do + case Process.whereis(ZoneCache) do + nil -> + :ok + + pid -> + Process.exit(pid, :kill) + wait_for_zone_cache_restart(pid) + end + end + + defp wait_for_zone_cache_restart(previous_pid, attempts \\ 20) + + defp wait_for_zone_cache_restart(previous_pid, attempts) when attempts > 0 do + case Process.whereis(ZoneCache) do + pid when is_pid(pid) and pid != previous_pid -> + :ok + + _pid -> + Process.sleep(10) + wait_for_zone_cache_restart(previous_pid, attempts - 1) + end + end + + defp wait_for_zone_cache_restart(_previous_pid, 0), + do: flunk("zone cache did not restart before timeout") + + defp assert_eventually(fun, attempts \\ 100) + + defp assert_eventually(fun, attempts) when attempts > 0 do + if fun.() do + assert true + else + Process.sleep(25) + assert_eventually(fun, attempts - 1) + end + end + + defp assert_eventually(_fun, 0), do: flunk("condition not met before timeout") + + defp build_query(name, type) do + <<0x12, 0x34, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + encode_name(name)::binary, type::16, 1::16>> + end + + defp encode_name(name) do + name + |> String.split(".", trim: true) + |> Enum.map_join(fn label -> <> <> label end) + |> Kernel.<>(<<0>>) + end + + defp header( + <<_id::16, flags::16, _qd::16, ancount::16, _nscount::16, _arcount::16, _rest::binary>> + ) do + %{ + ancount: ancount, + rcode: Bitwise.band(flags, 0x000F), + aa: Bitwise.band(Bitwise.bsr(flags, 10), 1) + } + end + + defp unique_domain do + "cache#{System.unique_integer([:positive])}.example.com" + end +end diff --git a/apps/elektrine_dns/test/elektrine/dns/health_monitor_test.exs b/apps/elektrine_dns/test/elektrine/dns/health_monitor_test.exs new file mode 100644 index 0000000..f6fd092 --- /dev/null +++ b/apps/elektrine_dns/test/elektrine/dns/health_monitor_test.exs @@ -0,0 +1,93 @@ +defmodule Elektrine.DNS.HealthMonitorTest do + use ExUnit.Case, async: false + + alias Elektrine.DNS.HealthMonitor + + setup do + {:ok, listener} = :gen_tcp.listen(0, [:binary, active: false, ip: {127, 0, 0, 1}]) + {:ok, open_port} = :inet.port(listener) + + # A port that nothing listens on: bind then close to reserve-and-release. + {:ok, probe} = :gen_tcp.listen(0, [:binary, active: false, ip: {127, 0, 0, 1}]) + {:ok, closed_port} = :inet.port(probe) + :gen_tcp.close(probe) + + on_exit(fn -> + :gen_tcp.close(listener) + + if :ets.whereis(:elektrine_dns_health_status) != :undefined do + :ets.delete(:elektrine_dns_health_status, {"127.0.0.1", open_port}) + :ets.delete(:elektrine_dns_health_status, {"127.0.0.1", closed_port}) + end + end) + + %{open_port: open_port, closed_port: closed_port} + end + + defp start_monitor(targets) do + name = :"health_monitor_test_#{System.unique_integer([:positive])}" + + start_supervised!( + {HealthMonitor, name: name, interval_ms: :manual, targets_fun: fn -> targets end} + ) + + name + end + + test "reachable targets are healthy", %{open_port: open_port} do + monitor = start_monitor([{"127.0.0.1", open_port}]) + + HealthMonitor.check_now(monitor) + + assert HealthMonitor.healthy?("127.0.0.1", open_port) + end + + test "targets go down only after consecutive failures", %{closed_port: closed_port} do + monitor = start_monitor([{"127.0.0.1", closed_port}]) + + HealthMonitor.check_now(monitor) + assert HealthMonitor.healthy?("127.0.0.1", closed_port) + + HealthMonitor.check_now(monitor) + refute HealthMonitor.healthy?("127.0.0.1", closed_port) + end + + test "downed targets recover on first success", %{closed_port: closed_port} do + monitor = start_monitor([{"127.0.0.1", closed_port}]) + + HealthMonitor.check_now(monitor) + HealthMonitor.check_now(monitor) + refute HealthMonitor.healthy?("127.0.0.1", closed_port) + + {:ok, revived} = + :gen_tcp.listen(closed_port, [:binary, active: false, ip: {127, 0, 0, 1}]) + + HealthMonitor.check_now(monitor) + assert HealthMonitor.healthy?("127.0.0.1", closed_port) + + :gen_tcp.close(revived) + end + + test "unknown targets default to healthy" do + assert HealthMonitor.healthy?("192.0.2.99", 443) + end + + test "departed targets are pruned from the table", %{closed_port: closed_port} do + monitor = start_monitor([{"127.0.0.1", closed_port}]) + + HealthMonitor.check_now(monitor) + HealthMonitor.check_now(monitor) + refute HealthMonitor.healthy?("127.0.0.1", closed_port) + + empty = :"health_monitor_test_#{System.unique_integer([:positive])}" + + start_supervised!( + {HealthMonitor, name: empty, interval_ms: :manual, targets_fun: fn -> [] end}, + id: :empty_monitor + ) + + HealthMonitor.check_now(empty) + + assert HealthMonitor.healthy?("127.0.0.1", closed_port) + end +end diff --git a/apps/elektrine_dns/test/elektrine/dns/managed_records_test.exs b/apps/elektrine_dns/test/elektrine/dns/managed_records_test.exs new file mode 100644 index 0000000..104d446 --- /dev/null +++ b/apps/elektrine_dns/test/elektrine/dns/managed_records_test.exs @@ -0,0 +1,609 @@ +defmodule Elektrine.DNS.TestDKIM do + def generate_domain_key_material do + %{selector: "default", public_key: "PUBLICKEY", private_key: "PRIVATEKEY"} + end + + def public_key_dns_value(key), do: key + def mx_host, do: "mail.example.com" + def sync_domain(_domain, _selector, _private_key), do: :ok +end + +defmodule Elektrine.DNS.RealValueTestDKIM do + alias Elektrine.Email.DKIM + + def generate_domain_key_material, do: DKIM.generate_domain_key_material() + + def dkim_value_from_material(public_key, private_key), + do: DKIM.dkim_value_from_material(public_key, private_key) + + def public_key_dns_value(key), do: DKIM.public_key_dns_value(key) + def mx_host, do: "mail.example.com" + def sync_domain(_domain, _selector, _private_key), do: :ok +end + +defmodule Elektrine.DNS.ManagedRecordsTest do + use Elektrine.DataCase, async: false + + alias Elektrine.AccountsFixtures + alias Elektrine.{DNS, Domains} + + setup do + old_dkim = Application.get_env(:elektrine, :managed_dns_dkim_module) + old_master = Application.get_env(:elektrine, :encryption_master_secret) + old_salt = Application.get_env(:elektrine, :encryption_key_salt) + old_primary_domain = Application.get_env(:elektrine, :primary_domain) + old_email = Application.get_env(:elektrine, :email) + + Application.put_env(:elektrine, :managed_dns_dkim_module, Elektrine.DNS.TestDKIM) + Application.put_env(:elektrine, :encryption_master_secret, "test-master-secret-0123456789") + Application.put_env(:elektrine, :encryption_key_salt, "test-key-salt-0123456789") + + on_exit(fn -> + restore_env(:managed_dns_dkim_module, old_dkim) + restore_env(:encryption_master_secret, old_master) + restore_env(:encryption_key_salt, old_salt) + restore_env(:primary_domain, old_primary_domain) + restore_env(:email, old_email) + end) + + :ok + end + + test "applies managed web records per zone" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain(), "default_ttl" => 600}) + + assert {:ok, config} = DNS.apply_zone_service(zone, "web") + assert config.service == "web" + assert config.status == "ok" + + zone = DNS.get_zone(zone.id, user.id) + [www] = Enum.filter(zone.records, &(&1.service == "web")) + + assert www.managed == true + assert www.source == "system" + assert www.name == "www" + assert www.type == "CNAME" + assert www.content == zone.domain + end + + test "marks managed mail service as conflict when user record already exists" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + {:ok, _record} = + DNS.create_record(zone, %{ + "name" => "@", + "type" => "MX", + "ttl" => 300, + "content" => "mx.external.example", + "priority" => 10 + }) + + assert {:ok, config} = DNS.apply_zone_service(zone, "mail") + assert config.status == "conflict" + assert config.last_error =~ "MX @ conflicts" + + zone = DNS.get_zone(zone.id, user.id) + refute Enum.any?(zone.records, &(&1.service == "mail" and &1.managed)) + end + + test "rejects CNAME records that coexist with other record types" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, _record} = + DNS.create_record(zone, %{ + "name" => "www", + "type" => "A", + "ttl" => 300, + "content" => "203.0.113.10" + }) + + assert {:error, changeset} = + DNS.create_record(zone, %{ + "name" => "www", + "type" => "CNAME", + "ttl" => 300, + "content" => zone.domain + }) + + assert %{type: [_ | _]} = errors_on(changeset) + end + + test "rejects invalid wildcard placement" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:error, changeset} = + DNS.create_record(zone, %{ + "name" => "bad.*", + "type" => "TXT", + "ttl" => 300, + "content" => "invalid" + }) + + assert %{name: [_ | _]} = errors_on(changeset) + end + + test "adopts an existing matching user rrset into managed web dns" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain(), "default_ttl" => 600}) + + {:ok, record} = + DNS.create_record(zone, %{ + "name" => "www", + "type" => "CNAME", + "ttl" => 600, + "content" => zone.domain + }) + + assert {:ok, config} = DNS.apply_zone_service(zone, "web") + assert config.status == "ok" + + zone = DNS.get_zone(zone.id, user.id) + [www] = Enum.filter(zone.records, &(&1.name == "www" and &1.type == "CNAME")) + + assert www.id == record.id + assert www.managed == true + assert www.source == "system" + assert www.service == "web" + assert www.managed_key == "web:www" + end + + test "adopts an existing matching user mx record into managed mail dns" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + {:ok, mx} = + DNS.create_record(zone, %{ + "name" => "@", + "type" => "MX", + "ttl" => 300, + "content" => Domains.primary_email_domain(), + "priority" => 10 + }) + + assert {:ok, config} = DNS.apply_zone_service(zone, "mail") + assert config.status == "ok" + + zone = DNS.get_zone(zone.id, user.id) + adopted = Enum.find(zone.records, &(&1.id == mx.id)) + + assert adopted.managed == true + assert adopted.service == "mail" + assert adopted.managed_key == "mail:mx" + end + + test "defaults managed mail routing to the primary email domain" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, _apex} = + DNS.create_record(zone, %{ + "name" => "@", + "type" => "A", + "ttl" => 300, + "content" => "66.42.127.87" + }) + + assert {:ok, config} = + DNS.apply_zone_service(zone, "mail") + + assert config.status == "ok" + assert config.settings["mail_target"] == Domains.primary_email_domain() + + zone = DNS.get_zone(zone.id, user.id) + + assert Enum.any?(zone.records, fn record -> + record.service == "mail" and record.managed_key == "mail:mx" and + record.content == Domains.primary_email_domain() + end) + + assert Enum.any?(zone.records, fn record -> + record.service == "mail" and record.name == "smtp" and record.type == "CNAME" and + record.content == Domains.primary_email_domain() + end) + end + + test "defaults managed mail routing to the zone domain when configured primary domains are local" do + Application.put_env(:elektrine, :primary_domain, "localhost") + Application.put_env(:elektrine, :email, domain: "localhost") + + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, config} = DNS.apply_zone_service(zone, "mail") + + assert config.status == "ok" + assert config.settings["mail_target"] == zone.domain + end + + test "repairs submitted local mail targets to a public default" do + Application.put_env(:elektrine, :primary_domain, "localhost") + Application.put_env(:elektrine, :email, domain: "localhost") + + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, config} = + DNS.apply_zone_service(zone, "mail", %{ + "settings" => %{"mail_target" => "localhost"} + }) + + assert config.status == "ok" + assert config.settings["mail_target"] == zone.domain + end + + test "repairs legacy apex-target mail configs to the primary email domain" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, _apex} = + DNS.create_record(zone, %{ + "name" => "@", + "type" => "A", + "ttl" => 300, + "content" => "66.42.127.87" + }) + + assert {:ok, _config} = + DNS.apply_zone_service(zone, "mail", %{ + "settings" => %{"mail_target" => zone.domain} + }) + + zone = DNS.get_zone(zone.id, user.id) + + assert {:ok, repaired} = + DNS.apply_zone_service(zone, "mail", %{ + "settings" => %{"mail_target" => zone.domain} + }) + + assert repaired.settings["mail_target"] == Domains.primary_email_domain() + + zone = DNS.get_zone(zone.id, user.id) + + assert Enum.any?(zone.records, fn record -> + record.service == "mail" and record.managed_key == "mail:mx" and + record.content == Domains.primary_email_domain() + end) + end + + test "disabling a managed service removes its records" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, _config} = DNS.apply_zone_service(zone, "mail") + zone = DNS.get_zone(zone.id, user.id) + assert Enum.any?(zone.records, &(&1.service == "mail" and &1.managed)) + + assert {:ok, disabled} = DNS.disable_zone_service(zone, "mail") + assert disabled.status == "disabled" + + zone = DNS.get_zone(zone.id, user.id) + refute Enum.any?(zone.records, &(&1.service == "mail" and &1.managed)) + end + + test "managed mail service generates DKIM settings and TXT record" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, config} = DNS.apply_zone_service(zone, "mail") + assert config.status == "ok" + assert is_binary(config.settings["dkim_selector"]) + assert is_binary(config.settings["dkim_public_key"]) + assert is_binary(config.settings["dkim_private_key"]) + refute config.settings["dkim_private_key"] == "PRIVATEKEY" + assert String.contains?(config.settings["dkim_value"], "v=DKIM1") + assert config.settings["caa_issue"] == "letsencrypt.org" + assert config.settings["mail_target"] == Domains.primary_email_domain() + + zone = DNS.get_zone(zone.id, user.id) + + mail_health = DNS.zone_service_health(zone) |> Enum.find(&(&1.service == "mail")) + assert mail_health.settings["dkim_private_key"] == "[redacted]" + + assert Enum.any?( + zone.records, + &(&1.service == "mail" and &1.managed_key == "mail:mx" and + &1.content == Domains.primary_email_domain()) + ) + + assert Enum.any?(zone.records, &(&1.service == "mail" and &1.managed_key == "mail:dkim")) + + assert Enum.any?( + zone.records, + &(&1.service == "mail" and &1.managed_key == "mail:mta-sts-txt") + ) + + assert Enum.any?(zone.records, &(&1.service == "mail" and &1.managed_key == "mail:tls-rpt")) + + assert Enum.any?(zone.records, fn record -> + record.service == "mail" and record.managed_key == "mail:caa:issue" and + record.type == "CAA" and record.tag == "issue" and + record.content == "letsencrypt.org" + end) + + assert Enum.any?(zone.records, fn record -> + record.service == "mail" and record.name == "_mta-sts" and + String.starts_with?(record.content, "v=STSv1; id=") + end) + + assert Enum.any?(zone.records, fn record -> + record.service == "mail" and record.name == "_smtp._tls" and + record.content == "v=TLSRPTv1; rua=mailto:postmaster@#{zone.domain}" + end) + end + + test "managed mail service refreshes DKIM TXT value from current public key" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, _config} = DNS.apply_zone_service(zone, "mail") + + zone = DNS.get_zone(zone.id, user.id) + dkim_record = Enum.find(zone.records, &(&1.managed_key == "mail:dkim")) + assert dkim_record.content == "v=DKIM1; k=rsa; p=PUBLICKEY" + + assert {:ok, config} = + DNS.apply_zone_service(zone, "mail", %{ + "settings" => %{ + "dkim_public_key" => "ROTATEDPUBLICKEY", + "dkim_private_key" => "ROTATEDPRIVATEKEY" + } + }) + + assert config.status == "ok" + assert config.settings["dkim_value"] == "v=DKIM1; k=rsa; p=ROTATEDPUBLICKEY" + + zone = DNS.get_zone(zone.id, user.id) + dkim_record = Enum.find(zone.records, &(&1.managed_key == "mail:dkim")) + assert dkim_record.content == "v=DKIM1; k=rsa; p=ROTATEDPUBLICKEY" + end + + test "reconciles supported managed mail services after DKIM record drift" do + user = AccountsFixtures.user_fixture() + domain = "elektrine.net" + + {:ok, zone} = DNS.create_zone(user, %{"domain" => domain}) + assert {:ok, _config} = DNS.apply_zone_service(zone, "mail") + + zone = DNS.get_zone(zone.id, user.id) + dkim_record = Enum.find(zone.records, &(&1.managed_key == "mail:dkim")) + + dkim_record + |> Elektrine.DNS.Record.changeset(%{content: "v=DKIM1; k=rsa; p=STALE"}) + |> Elektrine.Repo.update!() + + Application.put_env(:elektrine, :email, + supported_domains: [domain], + receive_only_domains: ["example.com"], + domain: "example.com" + ) + + assert [{^domain, :ok}] = Elektrine.DNS.ManagedRecords.reconcile_supported_mail_services() + + zone = DNS.get_zone(zone.id, user.id) + dkim_record = Enum.find(zone.records, &(&1.managed_key == "mail:dkim")) + assert dkim_record.content == "v=DKIM1; k=rsa; p=PUBLICKEY" + end + + test "managed mail service derives DKIM TXT value from private key when available" do + Application.put_env(:elektrine, :managed_dns_dkim_module, Elektrine.DNS.RealValueTestDKIM) + + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + key_material = Elektrine.Email.DKIM.generate_domain_key_material() + + assert {:ok, config} = + DNS.apply_zone_service(zone, "mail", %{ + "settings" => %{ + "dkim_public_key" => "STALEPUBLICKEY", + "dkim_private_key" => key_material.private_key + } + }) + + expected_value = + Elektrine.Email.DKIM.dkim_value_from_material( + "STALEPUBLICKEY", + key_material.private_key + ) + + assert config.status == "ok" + assert config.settings["dkim_value"] == expected_value + refute config.settings["dkim_value"] =~ "STALEPUBLICKEY" + + zone = DNS.get_zone(zone.id, user.id) + dkim_record = Enum.find(zone.records, &(&1.managed_key == "mail:dkim")) + assert dkim_record.content == expected_value + end + + test "managed mail service generates TLSA records when association data is configured" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, config} = + DNS.apply_zone_service(zone, "mail", %{ + "settings" => %{ + "mail_target" => zone.domain, + "tlsa_association_data" => "aabbccdd", + "tlsa_usage" => "3", + "tlsa_selector" => "0", + "tlsa_matching_type" => "1" + } + }) + + assert config.status == "ok" + assert config.settings["tlsa_association_data"] == "aabbccdd" + + zone = DNS.get_zone(zone.id, user.id) + + assert Enum.any?(zone.records, fn record -> + record.service == "mail" and record.managed_key == "mail:tlsa" and + record.name == "_25._tcp" and record.type == "TLSA" and + record.content == "AABBCCDD" and record.usage == 3 and record.selector == 0 and + record.matching_type == 1 + end) + end + + test "service health reports managed record checks" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + assert {:ok, _config} = DNS.apply_zone_service(zone, "web") + + zone = DNS.get_zone(zone.id, user.id) + web = Enum.find(DNS.zone_service_health(zone), &(&1.service == "web")) + + assert web.status in ["ok", "error"] + assert Enum.any?(web.checks, &(&1.key == "web:www" and &1.status == "ok")) + end + + test "applies managed turn records per zone" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain(), "default_ttl" => 600}) + + assert {:ok, config} = DNS.apply_zone_service(zone, "turn") + assert config.service == "turn" + assert config.status == "ok" + + zone = DNS.get_zone(zone.id, user.id) + [turn] = Enum.filter(zone.records, &(&1.service == "turn")) + + assert turn.managed == true + assert turn.source == "system" + assert turn.name == "turn" + assert turn.type == "CNAME" + assert turn.content == zone.domain + end + + test "applies managed bluesky records per zone" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain(), "default_ttl" => 600}) + + assert {:ok, config} = DNS.apply_zone_service(zone, "bluesky") + assert config.service == "bluesky" + assert config.status == "ok" + + zone = DNS.get_zone(zone.id, user.id) + [bluesky] = Enum.filter(zone.records, &(&1.service == "bluesky")) + + assert bluesky.managed == true + assert bluesky.source == "system" + assert bluesky.name == "bsky" + assert bluesky.type == "CNAME" + assert bluesky.content == zone.domain + end + + test "applies managed vpn records with optional api alias" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain(), "default_ttl" => 600}) + + assert {:ok, config} = + DNS.apply_zone_service(zone, "vpn", %{ + "settings" => %{ + "vpn_host" => "vpn", + "vpn_target" => zone.domain, + "vpn_api_host" => "wg", + "vpn_api_target" => "api.#{zone.domain}" + } + }) + + assert config.service == "vpn" + assert config.status == "ok" + + zone = DNS.get_zone(zone.id, user.id) + + assert Enum.any?(zone.records, fn record -> + record.service == "vpn" and record.name == "vpn" and record.type == "CNAME" and + record.content == zone.domain + end) + + assert Enum.any?(zone.records, fn record -> + record.service == "vpn" and record.name == "wg" and record.type == "CNAME" and + record.content == "api.#{zone.domain}" + end) + end + + test "rejects an invalid www target with a friendly error" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:error, message} = + DNS.apply_zone_service(zone, "web", %{ + "settings" => %{"www_target" => "https://example.com"} + }) + + assert message =~ "WWW target must be a public hostname" + + zone = DNS.get_zone(zone.id, user.id) + refute Enum.any?(zone.records, &(&1.service == "web")) + end + + test "rejects an invalid service host label with a friendly error" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:error, message} = + DNS.apply_zone_service(zone, "bluesky", %{ + "settings" => %{"bluesky_host" => "bad host!"} + }) + + assert message =~ "Bluesky host must be a subdomain name" + + zone = DNS.get_zone(zone.id, user.id) + refute Enum.any?(zone.records, &(&1.service == "bluesky")) + end + + test "adds mailto: to a bare TLS-RPT email address" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, config} = + DNS.apply_zone_service(zone, "mail", %{ + "settings" => %{"tls_rpt_rua" => "reports@example.com"} + }) + + assert config.status == "ok" + assert config.settings["tls_rpt_rua"] == "mailto:reports@example.com" + + zone = DNS.get_zone(zone.id, user.id) + + assert Enum.any?(zone.records, fn record -> + record.service == "mail" and record.name == "_smtp._tls" and + record.content == "v=TLSRPTv1; rua=mailto:reports@example.com" + end) + end + + test "rejects a TLS-RPT address that is not an email or https url" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:error, message} = + DNS.apply_zone_service(zone, "mail", %{ + "settings" => %{"tls_rpt_rua" => "not a report address"} + }) + + assert message =~ "TLS-RPT rua must be an email address" + + zone = DNS.get_zone(zone.id, user.id) + refute Enum.any?(zone.records, &(&1.service == "mail")) + end + + test "service health includes planned records before a service is enabled" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + web_health = DNS.zone_service_health(zone) |> Enum.find(&(&1.service == "web")) + + assert web_health.status == "not_configured" + assert web_health.checks == [] + assert [%{name: "www", type: "CNAME"}] = web_health.planned_records + end + + defp unique_domain do + "zone#{System.unique_integer([:positive])}.elektrine.dev" + end + + defp restore_env(key, nil), do: Application.delete_env(:elektrine, key) + defp restore_env(key, value), do: Application.put_env(:elektrine, key, value) +end diff --git a/apps/elektrine_dns/test/elektrine/dns/packet_test.exs b/apps/elektrine_dns/test/elektrine/dns/packet_test.exs new file mode 100644 index 0000000..c367a21 --- /dev/null +++ b/apps/elektrine_dns/test/elektrine/dns/packet_test.exs @@ -0,0 +1,170 @@ +defmodule Elektrine.DNS.PacketTest do + use ExUnit.Case, async: true + + alias Elektrine.DNS.Packet + + test "clamps EDNS udp payload size from queries" do + {:ok, query} = + Packet.decode_query( + Packet.encode_query(%{id: 1, rd: 1, qname: "example.com", qtype: :a, udp_size: 4096}) + ) + + assert query.udp_size == 1232 + end + + test "truncates oversized udp responses" do + response = + Packet.encode_response( + %{id: 1, rd: 1, qname: "example.com", qtype: :txt, udp_size: 512}, + [%{name: "example.com", type: :txt, content: String.duplicate("a", 600), ttl: 300}], + :noerror, + transport: :udp + ) + + assert byte_size(response) <= 512 + assert header(response).ancount == 0 + assert header(response).tc == 1 + end + + test "preserves fully-qualified host names on struct-backed records" do + query = %{id: 1, rd: 0, qname: "elektrine.com", qtype: :ns, udp_size: 1232} + + response = + Packet.encode_response( + query, + [ + %{ + __struct__: Elektrine.DNS.Record, + host: "elektrine.com", + name: "@", + type: "NS", + value: "ns1.elektrine.com", + ttl: 300 + } + ], + :noerror, + additional: [ + %{ + __struct__: Elektrine.DNS.Record, + host: "ns1.elektrine.com", + name: "ns1", + type: "A", + content: "66.42.127.87", + ttl: 300 + } + ] + ) + + assert {:ok, {:dns_rec, _header, _qd, answers, _ns, additional}} = :inet_dns.decode(response) + assert Enum.map(answers, &record_domain/1) == ["elektrine.com"] + assert Enum.map(additional, &record_domain/1) == ["ns1.elektrine.com"] + end + + test "rejects query labels longer than the DNS limit" do + label = String.duplicate("a", 64) + + packet = + <<1::16, 0::16, 1::16, 0::16, 0::16, 0::16, byte_size(label)::8, label::binary, 0, 1::16, + 1::16>> + + assert {:error, :format_error} = Packet.decode_query(packet) + end + + test "echoes an OPT record with the DO bit for EDNS queries" do + {:ok, query} = + Packet.decode_query( + Packet.encode_query(%{ + id: 5, + rd: 1, + qname: "example.com", + qtype: :a, + udp_size: 1232, + dnssec_ok: true + }) + ) + + assert query.edns + + response = + Packet.encode_response( + query, + [%{name: "example.com", type: :a, content: "192.0.2.1", ttl: 60}], + :noerror + ) + + assert header(response).arcount == 1 + + assert <<0, 0, 41, _udp_size::16, 0, 0, opt_flags::16, 0, 0>> = + :binary.part(response, byte_size(response) - 11, 11) + + assert Bitwise.band(opt_flags, 0x8000) != 0 + end + + test "omits the OPT record for plain queries" do + packet = + <<9::16, 0::16, 1::16, 0::16, 0::16, 0::16, 7, "example", 3, "com", 0, 1::16, 1::16>> + + {:ok, query} = Packet.decode_query(packet) + refute query.edns + + response = + Packet.encode_response( + query, + [%{name: "example.com", type: :a, content: "192.0.2.1", ttl: 60}], + :noerror + ) + + assert header(response).arcount == 0 + end + + test "compresses answer owner names against the question name" do + query = %{id: 7, rd: 0, qname: "www.example.com", qtype: :a, udp_size: 1232} + + response = + Packet.encode_response( + query, + [%{name: "www.example.com", type: :a, content: "192.0.2.1", ttl: 60}], + :noerror + ) + + # Owner emitted as a pointer to offset 12, followed by TYPE A / CLASS IN. + assert :binary.match(response, <<0xC0, 0x0C, 0, 1, 0, 1>>) != :nomatch + assert {:ok, {:dns_rec, _header, _qd, [answer], _ns, _ar}} = :inet_dns.decode(response) + assert record_domain(answer) == "www.example.com" + end + + test "rejects self-referencing compression pointers instead of looping" do + packet = <<1::16, 0::16, 1::16, 0::16, 0::16, 0::16, 0xC0, 0x0C, 1::16, 1::16>> + + assert {:error, :format_error} = Packet.decode_query(packet) + end + + test "never raises on arbitrary or mutated packets" do + :rand.seed(:exsss, {101, 102, 103}) + + for len <- 0..64, _repeat <- 1..4 do + result = Packet.decode_query(:rand.bytes(len)) + assert match?({:ok, _}, result) or match?({:error, :format_error}, result) + end + + valid = + Packet.encode_query(%{id: 1, rd: 1, qname: "www.example.com", qtype: :a, udp_size: 1232}) + + for _repeat <- 1..200 do + position = :rand.uniform(byte_size(valid)) - 1 + <> = valid + mutated = <> + + result = Packet.decode_query(mutated) + assert match?({:ok, _}, result) or match?({:error, :format_error}, result) + end + end + + defp header( + <<_id::16, flags::16, _qd::16, ancount::16, _nscount::16, arcount::16, _rest::binary>> + ) do + %{ancount: ancount, arcount: arcount, tc: Bitwise.band(Bitwise.bsr(flags, 9), 1)} + end + + defp record_domain(record), do: record |> elem(1) |> to_string() |> String.trim_trailing(".") +end diff --git a/apps/elektrine_dns/test/elektrine/dns/profile_wildcard_test.exs b/apps/elektrine_dns/test/elektrine/dns/profile_wildcard_test.exs new file mode 100644 index 0000000..a8708c9 --- /dev/null +++ b/apps/elektrine_dns/test/elektrine/dns/profile_wildcard_test.exs @@ -0,0 +1,168 @@ +defmodule Elektrine.DNS.ProfileWildcardTest do + use Elektrine.DataCase, async: false + + alias Elektrine.AccountsFixtures + alias Elektrine.DNS + alias Elektrine.DNS.Record + alias Elektrine.DNS.Zone + alias Elektrine.Repo + + setup do + previous_dns_config = Application.get_env(:elektrine, :dns, []) + previous_profile_base_domains = Application.get_env(:elektrine, :profile_base_domains) + + domain = "wc#{System.unique_integer([:positive])}.com" + + Application.put_env(:elektrine, :profile_base_domains, [domain]) + + Application.put_env( + :elektrine, + :dns, + Keyword.merge(previous_dns_config, + edge_proxy_ipv4_addresses: ["198.51.100.10"], + edge_proxy_ipv6_addresses: ["2001:db8::10"] + ) + ) + + on_exit(fn -> + Application.put_env(:elektrine, :dns, previous_dns_config) + + if previous_profile_base_domains do + Application.put_env(:elektrine, :profile_base_domains, previous_profile_base_domains) + else + Application.delete_env(:elektrine, :profile_base_domains) + end + end) + + {:ok, domain: domain} + end + + defp wildcard_record(zone_id, type) do + zone_id + |> DNS.list_zone_records() + |> Enum.find(fn record -> record.name == "*" and record.type == type end) + end + + test "creates a proxied wildcard A/AAAA catch-all in the profile base-domain zone", %{ + domain: domain + } do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => domain}) + + assert {^domain, :ok} = + DNS.ensure_profile_subdomain_wildcards() + |> Enum.find(fn {d, _result} -> d == domain end) + + a_record = wildcard_record(zone.id, "A") + assert a_record + assert a_record.managed + assert Record.proxied?(a_record) + + aaaa_record = wildcard_record(zone.id, "AAAA") + assert aaaa_record + assert Record.proxied?(aaaa_record) + end + + test "installs the wildcard when the profile base-domain zone is created", %{domain: domain} do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => domain}) + + assert wildcard_record(zone.id, "A") + assert wildcard_record(zone.id, "AAAA") + end + + test "startup bootstrap installs the wildcard for an existing profile base-domain zone", %{ + domain: domain + } do + user = AccountsFixtures.user_fixture() + + zone = + Repo.insert!(%Zone{ + domain: domain, + status: "verified", + kind: "native", + default_ttl: 300, + user_id: user.id + }) + + start_supervised!(Elektrine.DNS.ProfileWildcardBootstrap) + + assert_eventually(fn -> + assert wildcard_record(zone.id, "A") + assert wildcard_record(zone.id, "AAAA") + end) + end + + test "is idempotent", %{domain: domain} do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => domain}) + + DNS.ensure_profile_subdomain_wildcards() + DNS.ensure_profile_subdomain_wildcards() + + matches = + zone.id + |> DNS.list_zone_records() + |> Enum.filter(fn record -> record.name == "*" and record.type == "A" end) + + assert length(matches) == 1 + end + + test "adopts existing matching wildcard records without managed keys", %{domain: domain} do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => domain}) + + wildcard_record(zone.id, "A") |> Repo.delete!() + wildcard_record(zone.id, "AAAA") |> Repo.delete!() + + Repo.insert!(%Record{ + zone_id: zone.id, + name: "*", + type: "A", + ttl: 300, + content: "198.51.100.10", + source: "user", + managed: false + }) + + Repo.insert!(%Record{ + zone_id: zone.id, + name: "*", + type: "AAAA", + ttl: 300, + content: "2001:DB8::10", + source: "user", + managed: false + }) + + assert {^domain, :ok} = + DNS.ensure_profile_subdomain_wildcards() + |> Enum.find(fn {d, _result} -> d == domain end) + + a_record = wildcard_record(zone.id, "A") + assert a_record.managed + assert a_record.managed_key == "system:profile-wildcard-a" + + aaaa_record = wildcard_record(zone.id, "AAAA") + assert aaaa_record.managed + assert aaaa_record.managed_key == "system:profile-wildcard-aaaa" + end + + test "reports zone_not_found when the base domain is not a managed zone", %{domain: domain} do + assert {^domain, {:error, :zone_not_found}} = + DNS.ensure_profile_subdomain_wildcards() + |> Enum.find(fn {d, _result} -> d == domain end) + end + + defp assert_eventually(fun, attempts \\ 50) + + defp assert_eventually(fun, attempts) when attempts > 0 do + fun.() + rescue + ExUnit.AssertionError -> + Process.sleep(10) + assert_eventually(fun, attempts - 1) + end + + defp assert_eventually(_fun, 0), do: flunk("condition not met before timeout") +end diff --git a/apps/elektrine_dns/test/elektrine/dns/query_stats_buffer_test.exs b/apps/elektrine_dns/test/elektrine/dns/query_stats_buffer_test.exs new file mode 100644 index 0000000..d0880de --- /dev/null +++ b/apps/elektrine_dns/test/elektrine/dns/query_stats_buffer_test.exs @@ -0,0 +1,58 @@ +defmodule Elektrine.DNS.QueryStatsBufferTest do + use Elektrine.DataCase, async: false + + alias Elektrine.AccountsFixtures + alias Elektrine.DNS + alias Elektrine.DNS.QueryStat + alias Elektrine.Repo + + test "buffers and aggregates repeated query stats" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + result = %{ + zone: zone, + authoritative: true, + qname: "www.#{zone.domain}", + qtype: :a, + rcode: :noerror + } + + DNS.track_query(result, "udp") + DNS.track_query(result, "udp") + Elektrine.DNS.QueryStatsBuffer.flush() + + stat = Repo.one!(QueryStat) + + assert stat.qname == "www.#{zone.domain}" + assert stat.qtype == "A" + assert stat.rcode == "NOERROR" + assert stat.transport == "udp" + assert stat.query_count == 2 + end + + test "collapses deep random labels for metrics cardinality" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + result = %{ + zone: zone, + authoritative: true, + qname: "random.user.bsky.#{zone.domain}", + qtype: :a, + rcode: :nxdomain + } + + DNS.track_query(result, "udp") + Elektrine.DNS.QueryStatsBuffer.flush() + + stat = Repo.one!(QueryStat) + + assert stat.qname == "*.user.bsky.#{zone.domain}" + assert stat.rcode == "NXDOMAIN" + end + + defp unique_domain do + "zone#{System.unique_integer([:positive])}.example.com" + end +end diff --git a/apps/elektrine_dns/test/elektrine/dns/query_test.exs b/apps/elektrine_dns/test/elektrine/dns/query_test.exs new file mode 100644 index 0000000..3b4ecea --- /dev/null +++ b/apps/elektrine_dns/test/elektrine/dns/query_test.exs @@ -0,0 +1,663 @@ +defmodule Elektrine.DNS.QueryTest do + use ExUnit.Case, async: true + + alias Elektrine.DNS.Query + alias Elektrine.DNS.Record + alias Elektrine.DNS.Zone + + defmodule AliasResolverStub do + def lookup(~c"edge.elektrine.com", :in, :a, timeout: 5_000), do: [{198, 51, 100, 99}] + + def lookup(~c"edge.elektrine.com", :in, :aaaa, timeout: 5_000), + do: [{0x2001, 0xDB8, 0, 0, 0, 0, 0, 0x99}] + + def lookup(_, _, _, timeout: 5_000), do: [] + end + + defmodule DNSResolverStub do + def lookup(~c"ns1.elektrine.com", :in, :a, timeout: 5_000), do: [{192, 0, 2, 53}] + + def lookup(~c"ns1.elektrine.com", :in, :aaaa, timeout: 5_000), + do: [{0x2001, 0xDB8, 0, 0, 0, 0, 0, 0x53}] + + def lookup(_, _, _, timeout: 5_000), do: [] + end + + setup do + previous_dns_config = Application.get_env(:elektrine, :dns, []) + + Application.put_env( + :elektrine, + :dns, + Keyword.merge(previous_dns_config, + alias_resolver: AliasResolverStub, + dns_resolver: DNSResolverStub, + nameservers: ["ns1.elektrine.com", "ns2.elektrine.com"], + edge_proxy_ipv4_addresses: ["198.51.100.200"], + edge_proxy_ipv6_addresses: ["2001:db8::200"] + ) + ) + + on_exit(fn -> + Application.put_env(:elektrine, :dns, previous_dns_config) + end) + + zone = %Zone{ + domain: "example.com", + records: [ + %Record{name: "@", type: "A", content: "203.0.113.10", ttl: 300}, + %Record{name: "root-alias.example.com", type: "A", content: "203.0.113.50", ttl: 300}, + %Record{name: "www", type: "A", content: "203.0.113.20", ttl: 300}, + %Record{name: "wild", type: "TXT", content: "hello", ttl: 300}, + %Record{name: "*", type: "A", content: "203.0.113.30", ttl: 300}, + %Record{name: "alias", type: "CNAME", content: "www.example.com", ttl: 300}, + %Record{name: "mail", type: "MX", content: "mx.example.com", ttl: 300, priority: 10}, + %Record{name: "mx", type: "A", content: "203.0.113.40", ttl: 300}, + %Record{ + name: "@", + type: "DNSKEY", + content: "AQAB", + ttl: 300, + flags: 257, + protocol: 3, + algorithm: 13 + }, + %Record{ + name: "delegated", + type: "DS", + content: "A1B2C3D4", + ttl: 300, + key_tag: 12_345, + algorithm: 13, + digest_type: 2 + }, + %Record{ + name: "_25._tcp.mail", + type: "TLSA", + content: "AABBCCDD", + ttl: 300, + usage: 3, + selector: 1, + matching_type: 1 + }, + %Record{ + name: "ssh", + type: "SSHFP", + content: "1234ABCD", + ttl: 300, + algorithm: 4, + digest_type: 2 + }, + %Record{ + name: "@", + type: "HTTPS", + content: ". alpn=h2,h3 port=443 ipv4hint=192.0.2.10", + ttl: 300, + priority: 1 + }, + %Record{ + name: "svc", + type: "SVCB", + content: "svc-target.example.com alpn=h2", + ttl: 300, + priority: 2 + } + ] + } + + table = :ets.whereis(Elektrine.DNS.ZoneCache) + + if table == :undefined do + :ets.new(Elektrine.DNS.ZoneCache, [:named_table, :public]) + else + :ets.delete_all_objects(Elektrine.DNS.ZoneCache) + end + + :ets.insert(Elektrine.DNS.ZoneCache, {"example.com", zone}) + :ok + end + + test "answers exact A queries" do + response = Query.answer(build_query("www.example.com", 1)) + assert header(response).ancount == 1 + assert header(response).rcode == 0 + assert response =~ <<203, 0, 113, 20>> + end + + test "answers assigned nameserver address queries from configured nameservers" do + response = Query.answer(build_query("rose.ns1.elektrine.com", 1)) + + assert header(response).rcode == 0 + assert header(response).aa == 1 + assert header(response).ancount == 1 + assert response =~ <<192, 0, 2, 53>> + end + + test "answers proxied A records with edge addresses" do + zone = %Zone{ + domain: "proxied.example.com", + records: [ + %Record{ + name: "@", + type: "A", + content: "203.0.113.10", + ttl: 300, + metadata: %{"proxy" => %{"enabled" => true}} + } + ] + } + + :ets.insert(Elektrine.DNS.ZoneCache, {"proxied.example.com", zone}) + + response = Query.answer(build_query("proxied.example.com", 1)) + + assert header(response).ancount == 1 + assert response =~ <<198, 51, 100, 200>> + refute response =~ <<203, 0, 113, 10>> + end + + test "answers proxied CNAME records with edge addresses" do + zone = %Zone{ + domain: "proxied-cname.example.com", + records: [ + %Record{ + name: "www", + type: "CNAME", + content: "origin.example.net", + ttl: 300, + metadata: %{"proxy" => %{"enabled" => true}} + } + ] + } + + :ets.insert(Elektrine.DNS.ZoneCache, {"proxied-cname.example.com", zone}) + + response = Query.answer(build_query("www.proxied-cname.example.com", 1)) + + assert header(response).ancount == 1 + assert response =~ <<198, 51, 100, 200>> + refute response =~ "origin" + end + + test "answers SOA queries at the zone apex" do + response = Query.answer(build_query("example.com", 6)) + + assert header(response).ancount == 1 + assert header(response).rcode == 0 + assert first_answer_name(response) == "example.com" + end + + test "answers authoritatively when recursion is not requested" do + response = Query.answer(build_query("example.com", 6, rd: false)) + header = header(response) + + assert header.ancount == 1 + assert header.rcode == 0 + assert header.aa == 1 + assert header.rd == 0 + assert first_answer_name(response) == "example.com" + end + + test "emits query telemetry metadata" do + test_pid = self() + handler_id = "dns-query-test-#{System.unique_integer([:positive])}" + + :telemetry.attach( + handler_id, + [:elektrine, :dns, :query], + fn event, measurements, metadata, _config -> + send(test_pid, {:dns_query_telemetry, event, measurements, metadata}) + end, + nil + ) + + on_exit(fn -> :telemetry.detach(handler_id) end) + + _response = Query.answer(build_query("www.example.com", 1)) + + assert_receive {:dns_query_telemetry, [:elektrine, :dns, :query], %{count: 1}, metadata} + assert metadata.zone == "example.com" + assert metadata.qname == "www.example.com" + assert metadata.qtype == :a + assert metadata.rcode == :noerror + assert metadata.authoritative == true + end + + test "truncates oversized UDP responses" do + zone = %Zone{ + domain: "large.example.com", + records: [ + %Record{name: "@", type: "TXT", content: String.duplicate("a", 900), ttl: 300} + ] + } + + :ets.insert(Elektrine.DNS.ZoneCache, {"large.example.com", zone}) + + response = Query.answer(build_query("large.example.com", 16), transport: :udp) + header = header(response) + + assert header.tc == 1 + assert header.ancount == 0 + assert header.rcode == 0 + end + + test "answers apex records stored as absolute names" do + zone = %Zone{ + domain: "absolute.example.com", + records: [ + %Record{name: "absolute.example.com", type: "A", content: "198.51.100.10", ttl: 300}, + %Record{name: "absolute.example.com", type: "TXT", content: "v=spf1 -all", ttl: 300}, + %Record{name: "ns1.absolute.example.com", type: "A", content: "198.51.100.11", ttl: 300}, + %Record{name: "ns2.absolute.example.com", type: "A", content: "198.51.100.12", ttl: 300} + ] + } + + :ets.insert(Elektrine.DNS.ZoneCache, {"absolute.example.com", zone}) + + a_response = Query.answer(build_query("absolute.example.com", 1)) + txt_response = Query.answer(build_query("absolute.example.com", 16)) + ns1_response = Query.answer(build_query("ns1.absolute.example.com", 1)) + ns2_response = Query.answer(build_query("ns2.absolute.example.com", 1)) + + assert a_response =~ <<198, 51, 100, 10>> + assert txt_response =~ "v=spf1 -all" + assert ns1_response =~ <<198, 51, 100, 11>> + assert ns2_response =~ <<198, 51, 100, 12>> + end + + test "answers apex MX records with the zone owner name" do + zone = %Zone{ + domain: "mx.example.com", + records: [ + %Record{name: "@", type: "MX", content: "mail.example.com", ttl: 300, priority: 10} + ] + } + + :ets.insert(Elektrine.DNS.ZoneCache, {"mx.example.com", zone}) + + response = Query.answer(build_query("mx.example.com", 15)) + + assert header(response).ancount == 1 + assert first_answer_name(response) == "mx.example.com" + end + + test "returns cname answers for alias queries with requested A type" do + response = Query.answer(build_query("alias.example.com", 1)) + + assert header(response).ancount == 1 + assert header(response).arcount == 1 + assert header(response).rcode == 0 + assert response =~ "www" + assert response =~ <<203, 0, 113, 20>> + end + + test "flattens apex ALIAS records into A answers" do + zone = %Zone{ + domain: "alias-a.example.com", + records: [ + %Record{name: "@", type: "ALIAS", content: "edge.elektrine.com", ttl: 180} + ] + } + + :ets.insert(Elektrine.DNS.ZoneCache, {"alias-a.example.com", zone}) + + response = Query.answer(build_query("alias-a.example.com", 1)) + + assert header(response).ancount == 1 + assert header(response).rcode == 0 + assert first_answer_name(response) == "alias-a.example.com" + assert response =~ <<198, 51, 100, 99>> + end + + test "flattens apex ALIAS records into AAAA answers" do + zone = %Zone{ + domain: "alias-aaaa.example.com", + records: [ + %Record{name: "@", type: "ALIAS", content: "edge.elektrine.com", ttl: 180} + ] + } + + :ets.insert(Elektrine.DNS.ZoneCache, {"alias-aaaa.example.com", zone}) + + response = Query.answer(build_query("alias-aaaa.example.com", 28)) + + assert header(response).ancount == 1 + assert header(response).rcode == 0 + assert first_answer_name(response) == "alias-aaaa.example.com" + assert response =~ <<0x20, 0x01, 0x0D, 0xB8>> + end + + test "flattens local apex ALIAS targets without external lookup" do + zone = %Zone{ + domain: "alias-local.example.com", + records: [ + %Record{name: "@", type: "ALIAS", content: "edge.alias-local.example.com", ttl: 180}, + %Record{ + name: "edge.alias-local.example.com", + type: "A", + content: "203.0.113.50", + ttl: 300 + } + ] + } + + :ets.insert(Elektrine.DNS.ZoneCache, {"alias-local.example.com", zone}) + + response = Query.answer(build_query("alias-local.example.com", 1)) + + assert header(response).ancount == 1 + assert header(response).rcode == 0 + assert response =~ <<203, 0, 113, 50>> + end + + test "returns servfail instead of crashing on invalid A record content" do + zone = %Zone{ + domain: "invalid-a.example.com", + records: [ + %Record{name: "@", type: "A", content: "not-an-ip", ttl: 300} + ] + } + + :ets.insert(Elektrine.DNS.ZoneCache, {"invalid-a.example.com", zone}) + + response = Query.answer(build_query("invalid-a.example.com", 1)) + + assert header(response).ancount == 0 + assert header(response).rcode == 2 + end + + test "returns nxdomain for unknown names" do + response = Query.answer(build_query("missing.example.com", 16)) + assert header(response).rcode == 3 + assert header(response).nscount == 1 + end + + test "uses wildcard records when no exact name exists" do + response = Query.answer(build_query("foo.example.com", 1)) + assert header(response).ancount == 1 + assert header(response).rcode == 0 + assert response =~ <<203, 0, 113, 30>> + end + + test "hides private records from public authoritative clients" do + zone = %Zone{ + domain: "private.example.com", + records: [ + %Record{ + name: "admin", + type: "A", + content: "100.90.10.120", + ttl: 300, + metadata: %{"visibility" => "private"} + }, + %Record{name: "*", type: "A", content: "203.0.113.30", ttl: 300} + ] + } + + :ets.insert(Elektrine.DNS.ZoneCache, {"private.example.com", zone}) + + response = + Query.answer(build_query("admin.private.example.com", 1), client_ip: {203, 0, 113, 10}) + + assert header(response).ancount == 0 + assert header(response).rcode == 3 + refute response =~ <<203, 0, 113, 30>> + refute response =~ <<100, 90, 10, 120>> + end + + test "answers private records for recursive-allowed clients" do + zone = %Zone{ + domain: "private-client.example.com", + records: [ + %Record{ + name: "admin", + type: "A", + content: "100.90.10.120", + ttl: 300, + metadata: %{"visibility" => "private"} + }, + %Record{name: "*", type: "A", content: "203.0.113.30", ttl: 300} + ] + } + + :ets.insert(Elektrine.DNS.ZoneCache, {"private-client.example.com", zone}) + + response = + Query.answer(build_query("admin.private-client.example.com", 1), + client_ip: {100, 90, 10, 50} + ) + + assert header(response).ancount == 1 + assert header(response).rcode == 0 + assert response =~ <<100, 90, 10, 120>> + end + + test "returns additional A records for MX targets" do + response = Query.answer(build_query("mail.example.com", 15)) + assert header(response).ancount == 1 + assert header(response).arcount == 1 + assert response =~ <<203, 0, 113, 40>> + end + + test "answers DNSKEY queries" do + response = Query.answer(build_query("example.com", 48)) + + assert header(response).ancount == 1 + assert header(response).rcode == 0 + assert response =~ <<1, 1, 3, 13, 1, 0, 1>> + end + + test "answers DS queries" do + response = Query.answer(build_query("delegated.example.com", 43)) + + assert header(response).ancount == 1 + assert header(response).rcode == 0 + assert response =~ <<0x30, 0x39, 13, 2, 0xA1, 0xB2, 0xC3, 0xD4>> + end + + test "answers TLSA queries" do + response = Query.answer(build_query("_25._tcp.mail.example.com", 52)) + + assert header(response).ancount == 1 + assert header(response).rcode == 0 + assert response =~ <<3, 1, 1, 0xAA, 0xBB, 0xCC, 0xDD>> + end + + test "answers SSHFP queries" do + response = Query.answer(build_query("ssh.example.com", 44)) + + assert header(response).ancount == 1 + assert header(response).rcode == 0 + assert response =~ <<4, 2, 0x12, 0x34, 0xAB, 0xCD>> + end + + test "answers HTTPS queries" do + response = Query.answer(build_query("example.com", 65)) + + assert header(response).ancount == 1 + assert header(response).rcode == 0 + assert response =~ <<0, 1, 0, 0>> + assert response =~ <<0, 1, 0, 6, 2, "h2", 2, "h3">> + assert response =~ <<0, 3, 0, 2, 1, 0xBB>> + assert response =~ <<0, 4, 0, 4, 192, 0, 2, 10>> + end + + test "answers SVCB queries" do + response = Query.answer(build_query("svc.example.com", 64)) + + assert header(response).ancount == 1 + assert header(response).rcode == 0 + assert response =~ <<0, 2, 10, "svc-target", 7, "example", 3, "com", 0>> + assert response =~ <<0, 1, 0, 3, 2, "h2">> + end + + test "returns NODATA instead of NXDOMAIN for empty non-terminals" do + # "_tcp.mail.example.com" has no records itself, but "_25._tcp.mail" sits + # below it, so the name exists (RFC 8020) and the "*" wildcard must not + # match it (RFC 4592). + response = Query.answer(build_query("_tcp.mail.example.com", 1)) + + assert header(response).rcode == 0 + assert header(response).ancount == 0 + assert header(response).aa == 1 + end + + test "answers ANY queries with a minimal HINFO record (RFC 8482)" do + response = Query.answer(build_query("example.com", 255)) + + assert header(response).ancount == 1 + assert header(response).rcode == 0 + assert header(response).aa == 1 + assert response =~ <<7, "RFC8482", 0>> + end + + test "refuses ANY queries for non-authoritative names" do + response = Query.answer(build_query("unknown-domain.test", 255)) + + assert header(response).rcode == 5 + end + + describe "health-checked failover" do + setup do + if :ets.whereis(:elektrine_dns_health_status) == :undefined do + :ets.new(:elektrine_dns_health_status, [:named_table, :set, :public]) + end + + zone = %Zone{ + domain: "failover.example.com", + records: [ + %Record{ + name: "@", + type: "A", + content: "203.0.113.30", + ttl: 300, + metadata: %{"health_check" => %{"enabled" => true}} + }, + %Record{ + name: "@", + type: "A", + content: "203.0.113.31", + ttl: 300, + metadata: %{"health_check" => %{"enabled" => true}} + } + ] + } + + :ets.insert(Elektrine.DNS.ZoneCache, {"failover.example.com", zone}) + + on_exit(fn -> + if :ets.whereis(:elektrine_dns_health_status) != :undefined do + :ets.delete(:elektrine_dns_health_status, {"203.0.113.30", 443}) + :ets.delete(:elektrine_dns_health_status, {"203.0.113.31", 443}) + end + end) + + :ok + end + + test "drops downed failover targets from answers" do + :ets.insert(:elektrine_dns_health_status, {{"203.0.113.31", 443}, :down, 3}) + + response = Query.answer(build_query("failover.example.com", 1)) + + assert header(response).ancount == 1 + assert response =~ <<203, 0, 113, 30>> + refute response =~ <<203, 0, 113, 31>> + end + + test "fails open when every failover target is down" do + :ets.insert(:elektrine_dns_health_status, {{"203.0.113.30", 443}, :down, 3}) + :ets.insert(:elektrine_dns_health_status, {{"203.0.113.31", 443}, :down, 3}) + + response = Query.answer(build_query("failover.example.com", 1)) + + assert header(response).ancount == 2 + end + + test "unknown health state answers normally" do + response = Query.answer(build_query("failover.example.com", 1)) + + assert header(response).ancount == 2 + end + end + + defp build_query(name, type, opts \\ []) do + flags = if Keyword.get(opts, :rd, true), do: 0x0100, else: 0 + + <<0x12, 0x34, flags::16, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + encode_name(name)::binary, type::16, 1::16>> + end + + defp encode_name(name) do + name + |> String.split(".", trim: true) + |> Enum.map_join(fn label -> <> <> label end) + |> Kernel.<>(<<0>>) + end + + defp header( + <<_id::16, flags::16, _qd::16, ancount::16, nscount::16, arcount::16, _rest::binary>> + ) do + %{ + aa: div(Bitwise.band(flags, 0x0400), 0x0400), + tc: div(Bitwise.band(flags, 0x0200), 0x0200), + rd: div(Bitwise.band(flags, 0x0100), 0x0100), + ancount: ancount, + nscount: nscount, + arcount: arcount, + rcode: Bitwise.band(flags, 0x000F) + } + end + + defp first_answer_name( + <<_id::16, _flags::16, qd::16, ancount::16, _nscount::16, _arcount::16, rest::binary>> = + packet + ) + when qd == 1 and ancount > 0 do + after_question = skip_question(rest) + {:ok, answer_name, _after_answer_name} = decode_name(after_question, packet) + answer_name + end + + defp skip_question(rest) do + {_qname, rest} = consume_name(rest) + <<_qtype::16, _qclass::16, remaining::binary>> = rest + remaining + end + + defp consume_name(<<0, rest::binary>>), do: {"", rest} + + defp consume_name(<>) do + consume_name(rest) + end + + defp decode_name(data, packet), do: decode_name(data, packet, [], 0) + + defp decode_name(_, _, _, 20), do: {:error, :compression_loop} + + defp decode_name(<<0, rest::binary>>, _packet, labels, _depth), + do: {:ok, Enum.reverse(labels) |> Enum.join("."), rest} + + defp decode_name(<> = data, packet, labels, depth) + when Bitwise.band(len, 0xC0) == 0xC0 do + <> = data + offset = Bitwise.band(ptr, 0x3FFF) + + with true <- offset < byte_size(packet), + {:ok, pointed, _} <- + decode_name( + binary_part(packet, offset, byte_size(packet) - offset), + packet, + [], + depth + 1 + ) do + {:ok, (Enum.reverse(labels) ++ String.split(pointed, ".", trim: true)) |> Enum.join("."), + rest} + else + _ -> {:error, :bad_pointer} + end + end + + defp decode_name(<>, packet, labels, depth), + do: decode_name(rest, packet, [label | labels], depth) +end diff --git a/apps/elektrine_dns/test/elektrine/dns/record_health_check_test.exs b/apps/elektrine_dns/test/elektrine/dns/record_health_check_test.exs new file mode 100644 index 0000000..cb82043 --- /dev/null +++ b/apps/elektrine_dns/test/elektrine/dns/record_health_check_test.exs @@ -0,0 +1,89 @@ +defmodule Elektrine.DNS.RecordHealthCheckTest do + use ExUnit.Case, async: true + + alias Elektrine.DNS.Record + + defp changeset(attrs) do + Record.changeset( + %Record{}, + Map.merge( + %{ + "name" => "@", + "type" => "A", + "content" => "203.0.113.10", + "ttl" => 300, + "zone_id" => 1 + }, + attrs + ) + ) + end + + test "enabling folds health_check into metadata with default port" do + changeset = changeset(%{"health_check_enabled" => "true"}) + + assert Ecto.Changeset.get_field(changeset, :metadata)["health_check"] == %{ + "enabled" => true, + "port" => 443 + } + end + + test "a valid custom port is stored" do + changeset = changeset(%{"health_check_enabled" => "true", "health_check_port" => "8443"}) + + assert get_in(Ecto.Changeset.get_field(changeset, :metadata), ["health_check", "port"]) == + 8443 + end + + test "an out-of-range port falls back to the default" do + changeset = changeset(%{"health_check_enabled" => "true", "health_check_port" => "99999"}) + + refute changeset.valid? + end + + test "disabling removes the metadata key" do + record = %Record{metadata: %{"health_check" => %{"enabled" => true, "port" => 443}}} + + changeset = + Record.changeset(record, %{ + "name" => "@", + "type" => "A", + "content" => "203.0.113.10", + "ttl" => 300, + "zone_id" => 1, + "health_check_enabled" => "false" + }) + + refute Map.has_key?(Ecto.Changeset.get_field(changeset, :metadata), "health_check") + end + + test "non-address types never gain health_check metadata" do + changeset = + changeset(%{ + "type" => "TXT", + "content" => "hello", + "health_check_enabled" => "true" + }) + + refute Map.has_key?(Ecto.Changeset.get_field(changeset, :metadata) || %{}, "health_check") + end + + test "untouched forms leave existing metadata alone" do + record = %Record{metadata: %{"health_check" => %{"enabled" => true, "port" => 443}}} + + changeset = + Record.changeset(record, %{"ttl" => 600}) + + assert get_in(Ecto.Changeset.get_field(changeset, :metadata), ["health_check", "enabled"]) == + true + end + + test "readers expose enrollment and port" do + record = %Record{metadata: %{"health_check" => %{"enabled" => true, "port" => 8443}}} + + assert Record.health_check?(record) + assert Record.health_check_port(record) == 8443 + refute Record.health_check?(%Record{metadata: %{}}) + assert Record.health_check_port(%Record{metadata: %{}}) == nil + end +end diff --git a/apps/elektrine_dns/test/elektrine/dns/recursive_test.exs b/apps/elektrine_dns/test/elektrine/dns/recursive_test.exs new file mode 100644 index 0000000..d26b08b --- /dev/null +++ b/apps/elektrine_dns/test/elektrine/dns/recursive_test.exs @@ -0,0 +1,418 @@ +defmodule Elektrine.DNS.TestRecursiveTransport do + use Agent + + def start_link(_opts) do + Agent.start_link(fn -> %{handler: nil, calls: []} end, name: __MODULE__) + end + + def set_handler(fun), do: Agent.update(__MODULE__, &%{&1 | handler: fun, calls: []}) + def calls, do: Agent.get(__MODULE__, &Enum.reverse(&1.calls)) + + def exchange_udp(ip, port, packet, timeout) do + Agent.get_and_update(__MODULE__, fn %{handler: handler, calls: calls} = state -> + {:ok, query} = Elektrine.DNS.Packet.decode_query(packet) + result = handler.(ip, port, packet, timeout, query) + {result, %{state | calls: [{ip, port, query.id, query.qname, query.qtype} | calls]}} + end) + end + + def exchange_tcp(ip, port, packet, timeout), do: exchange_udp(ip, port, packet, timeout) +end + +defmodule Elektrine.DNS.RecursiveTest do + use ExUnit.Case, async: false + + alias Elektrine.DNS.Packet + alias Elektrine.DNS.Query + + setup_all do + start_supervised!(Elektrine.DNS.TestRecursiveTransport) + :ok + end + + setup do + old_dns = Application.get_env(:elektrine, :dns, []) + + Application.put_env(:elektrine, :dns, + recursive_enabled: true, + recursive_root_hints: [{{1, 1, 1, 1}, 53}, {{2, 2, 2, 2}, 53}], + recursive_timeout: 100, + recursive_transport: Elektrine.DNS.TestRecursiveTransport, + recursive_allow_cidrs: ["127.0.0.0/8"] + ) + + Elektrine.DNS.TestRecursiveTransport.set_handler(fn _ip, _port, _packet, _timeout, _query -> + {:error, :unexpected_call} + end) + + clear_table(Elektrine.DNS.ZoneCache) + clear_table(Elektrine.DNS.RecursiveCache) + :persistent_term.erase({Elektrine.DNS.Recursive, :parsed_allow_cidrs}) + + on_exit(fn -> + Application.put_env(:elektrine, :dns, old_dns) + clear_table(Elektrine.DNS.ZoneCache) + clear_table(Elektrine.DNS.RecursiveCache) + :persistent_term.erase({Elektrine.DNS.Recursive, :parsed_allow_cidrs}) + end) + + :ok + end + + test "ignores invalid upstream responses and falls back to the next server" do + Elektrine.DNS.TestRecursiveTransport.set_handler(fn ip, _port, _packet, _timeout, query -> + answer = [%{name: query.qname, type: :a, content: "203.0.113.9", ttl: 300}] + + case ip do + {1, 1, 1, 1} -> Packet.encode_response(%{query | id: query.id + 1}, answer, :noerror) + {2, 2, 2, 2} -> Packet.encode_response(query, answer, :noerror) + end + |> then(&{:ok, &1}) + end) + + response = + Query.answer(Packet.encode_query(%{id: 100, rd: 1, qname: "example.net", qtype: :a}), + client_ip: {127, 0, 0, 1} + ) + + assert response =~ <<203, 0, 113, 9>> + + assert Enum.any?(Elektrine.DNS.TestRecursiveTransport.calls(), fn {ip, port, _id, qname, + qtype} -> + ip == {1, 1, 1, 1} and port == 53 and qname == "example.net" and qtype == :a + end) + + assert Enum.any?(Elektrine.DNS.TestRecursiveTransport.calls(), fn {ip, port, _id, qname, + qtype} -> + ip == {2, 2, 2, 2} and port == 53 and qname == "example.net" and qtype == :a + end) + end + + test "ignores out-of-bailiwick glue and resolves the nameserver separately" do + put_dns_config(recursive_root_hints: [{{1, 1, 1, 1}, 53}]) + + Elektrine.DNS.TestRecursiveTransport.set_handler(fn ip, _port, _packet, _timeout, query -> + response = + case {ip, query.qname, query.qtype} do + {{1, 1, 1, 1}, "example.test", :a} -> + Packet.encode_response( + query, + [], + :noerror, + authority: [%{name: "example.test", type: :ns, value: "ns.bad.com", ttl: 300}], + additional: [%{name: "ns.bad.com", type: :a, content: "203.0.113.53", ttl: 300}] + ) + + {{1, 1, 1, 1}, "ns.bad.com", :a} -> + Packet.encode_response( + query, + [%{name: "ns.bad.com", type: :a, content: "198.51.100.53", ttl: 300}], + :noerror + ) + + {{1, 1, 1, 1}, "ns.bad.com", :aaaa} -> + Packet.encode_response(query, [], :noerror) + + {{198, 51, 100, 53}, "example.test", :a} -> + Packet.encode_response( + query, + [%{name: "example.test", type: :a, content: "192.0.2.55", ttl: 300}], + :noerror + ) + end + + {:ok, response} + end) + + response = + Query.answer(Packet.encode_query(%{id: 101, rd: 1, qname: "example.test", qtype: :a}), + client_ip: {127, 0, 0, 1} + ) + + assert response =~ <<192, 0, 2, 55>> + + assert Enum.any?(Elektrine.DNS.TestRecursiveTransport.calls(), fn {_ip, _port, _id, qname, + qtype} -> + qname == "ns.bad.com" and qtype == :a + end) + end + + test "uses tuple glue addresses for iterative follow-up queries" do + put_dns_config(recursive_root_hints: [{{1, 1, 1, 1}, 53}]) + + Elektrine.DNS.TestRecursiveTransport.set_handler(fn ip, _port, _packet, _timeout, query -> + response = + case {ip, query.qname, query.qtype} do + {{1, 1, 1, 1}, "example.test", :a} -> + Packet.encode_response( + query, + [], + :noerror, + authority: [%{name: "example.test", type: :ns, value: "ns.example.test", ttl: 300}], + additional: [ + %{name: "ns.example.test", type: :a, content: "198.51.100.53", ttl: 300} + ] + ) + + {{198, 51, 100, 53}, "example.test", :a} -> + Packet.encode_response( + query, + [%{name: "example.test", type: :a, content: "192.0.2.55", ttl: 300}], + :noerror + ) + end + + {:ok, response} + end) + + response = + Query.answer(Packet.encode_query(%{id: 103, rd: 1, qname: "example.test", qtype: :a}), + client_ip: {127, 0, 0, 1} + ) + + assert response =~ <<192, 0, 2, 55>> + + assert Enum.any?(Elektrine.DNS.TestRecursiveTransport.calls(), fn {ip, port, _id, qname, + qtype} -> + ip == {198, 51, 100, 53} and port == 53 and qname == "example.test" and qtype == :a + end) + end + + test "negative responses are cached using soa minimum ttl" do + put_dns_config(recursive_root_hints: [{{1, 1, 1, 1}, 53}]) + + Elektrine.DNS.TestRecursiveTransport.set_handler(fn _ip, _port, _packet, _timeout, query -> + {:ok, + Packet.encode_response( + query, + [], + :nxdomain, + authority: [ + %{ + name: "test", + type: :soa, + mname: "ns1.test", + rname: "hostmaster.test", + serial: 1, + refresh: 3600, + retry: 600, + expire: 86_400, + minimum: 30, + ttl: 120 + } + ] + )} + end) + + packet = Packet.encode_query(%{id: 102, rd: 1, qname: "missing.test", qtype: :a}) + + response1 = Query.answer(packet, client_ip: {127, 0, 0, 1}) + response2 = Query.answer(packet, client_ip: {127, 0, 0, 1}) + + assert header(response1).rcode == 3 + assert header(response2).rcode == 3 + assert length(Elektrine.DNS.TestRecursiveTransport.calls()) == 1 + + [{_, expires_at, _}] = :ets.lookup(Elektrine.DNS.RecursiveCache, {"missing.test", :a}) + ttl_ms = expires_at - System.monotonic_time(:millisecond) + assert ttl_ms > 25_000 + assert ttl_ms <= 30_500 + end + + test "nodata responses return noerror and are cached" do + put_dns_config(recursive_root_hints: [{{1, 1, 1, 1}, 53}]) + + Elektrine.DNS.TestRecursiveTransport.set_handler(fn _ip, _port, _packet, _timeout, query -> + {:ok, + Packet.encode_response( + query, + [], + :noerror, + authority: [ + %{ + name: "test", + type: :soa, + mname: "ns1.test", + rname: "hostmaster.test", + serial: 1, + refresh: 3600, + retry: 600, + expire: 86_400, + minimum: 30, + ttl: 120 + } + ] + )} + end) + + packet = Packet.encode_query(%{id: 104, rd: 1, qname: "empty.test", qtype: :aaaa}) + + response1 = Query.answer(packet, client_ip: {127, 0, 0, 1}) + response2 = Query.answer(packet, client_ip: {127, 0, 0, 1}) + + assert header(response1).rcode == 0 + assert header(response2).rcode == 0 + assert length(Elektrine.DNS.TestRecursiveTransport.calls()) == 1 + end + + test "recursive cache trims entries over the configured limit" do + put_dns_config(recursive_cache_max_entries: 2) + + Elektrine.DNS.RecursiveCache.put({"one.test", :a}, %{answers: []}, 60) + Elektrine.DNS.RecursiveCache.put({"two.test", :a}, %{answers: []}, 60) + Elektrine.DNS.RecursiveCache.put({"three.test", :a}, %{answers: []}, 60) + + assert :ets.info(Elektrine.DNS.RecursiveCache, :size) <= 2 + end + + test "randomizes upstream ids instead of reusing client ids" do + Elektrine.DNS.TestRecursiveTransport.set_handler(fn _ip, _port, _packet, _timeout, query -> + {:ok, + Packet.encode_response( + query, + [%{name: query.qname, type: :a, content: "203.0.113.11", ttl: 300}], + :noerror + )} + end) + + packet = Packet.encode_query(%{id: 4444, rd: 1, qname: "example.net", qtype: :a}) + response = Query.answer(packet, client_ip: {127, 0, 0, 1}) + + assert response =~ <<203, 0, 113, 11>> + + assert [{{1, 1, 1, 1}, 53, upstream_id, "example.net", :a} | _] = + Elektrine.DNS.TestRecursiveTransport.calls() + + assert upstream_id != 0 + assert upstream_id != 4444 + end + + test "refuses recursive ANY queries" do + packet = Packet.encode_query(%{id: 333, rd: 1, qname: "example.net", qtype: :any}) + response = Query.answer(packet, client_ip: {127, 0, 0, 1}) + + assert header(response).rcode == 5 + assert Elektrine.DNS.TestRecursiveTransport.calls() == [] + end + + test "uses configured recursive upstreams as forwarders" do + put_dns_config(recursive_upstreams: [{{9, 9, 9, 9}, 53}]) + + Elektrine.DNS.TestRecursiveTransport.set_handler(fn ip, _port, _packet, _timeout, query -> + response = + case ip do + {9, 9, 9, 9} -> + Packet.encode_response( + query, + [%{name: query.qname, type: :a, content: "203.0.113.44", ttl: 300}], + :noerror, + authentic_data: true + ) + + _ -> + raise "unexpected upstream #{inspect(ip)}" + end + + {:ok, response} + end) + + packet = Packet.encode_query(%{id: 555, rd: 1, qname: "example.net", qtype: :a}) + response = Query.answer(packet, client_ip: {127, 0, 0, 1}) + + assert response =~ <<203, 0, 113, 44>> + assert header(response).ad == 1 + + assert [{{9, 9, 9, 9}, 53, _id, "example.net", :a}] = + Elektrine.DNS.TestRecursiveTransport.calls() + end + + test "upstream UDP timeouts return servfail instead of raising" do + put_dns_config(recursive_upstreams: [{{9, 9, 9, 9}, 53}]) + + Elektrine.DNS.TestRecursiveTransport.set_handler(fn _ip, _port, _packet, _timeout, _query -> + {:error, :timeout} + end) + + packet = Packet.encode_query(%{id: 557, rd: 1, qname: "timeout.example", qtype: :a}) + response = Query.answer(packet, client_ip: {127, 0, 0, 1}) + + assert header(response).rcode == 2 + + assert [{{9, 9, 9, 9}, 53, _id, "timeout.example", :a}] = + Elektrine.DNS.TestRecursiveTransport.calls() + end + + test "allows dnssec record lookups when forwarding upstreams are configured" do + put_dns_config(recursive_upstreams: [{{9, 9, 9, 9}, 53}]) + + Elektrine.DNS.TestRecursiveTransport.set_handler(fn {9, 9, 9, 9}, + _port, + _packet, + _timeout, + query -> + {:ok, + Packet.encode_response( + query, + [ + %{ + name: query.qname, + type: :ds, + key_tag: 12_345, + algorithm: 13, + digest_type: 2, + content: "A1B2C3D4", + ttl: 300 + } + ], + :noerror, + authentic_data: true + )} + end) + + packet = Packet.encode_query(%{id: 556, rd: 1, qname: "delegated.example", qtype: :ds}) + response = Query.answer(packet, client_ip: {127, 0, 0, 1}) + + assert header(response).rcode == 0 + assert header(response).ad == 1 + assert response =~ <<0x30, 0x39, 13, 2, 0xA1, 0xB2, 0xC3, 0xD4>> + end + + test "caps total upstream queries for a single client query" do + put_dns_config( + recursive_root_hints: [{{1, 1, 1, 1}, 53}], + recursive_max_upstream_queries: 5 + ) + + # Every response is a glueless referral to a fresh nameserver name, so an + # unbounded resolver would chase delegations forever. + Elektrine.DNS.TestRecursiveTransport.set_handler(fn _ip, _port, _packet, _timeout, query -> + {:ok, + Packet.encode_response(query, [], :noerror, + authority: [%{name: "test", type: :ns, value: "x" <> query.qname, ttl: 300}] + )} + end) + + packet = Packet.encode_query(%{id: 777, rd: 1, qname: "chained.example", qtype: :a}) + response = Query.answer(packet, client_ip: {127, 0, 0, 1}) + + assert header(response).rcode == 2 + assert length(Elektrine.DNS.TestRecursiveTransport.calls()) <= 5 + end + + defp clear_table(table) do + case :ets.whereis(table) do + :undefined -> :ok + _ -> :ets.delete_all_objects(table) + end + end + + defp header(<<_id::16, flags::16, _qd::16, _an::16, _ns::16, _ar::16, _rest::binary>>) do + %{rcode: Bitwise.band(flags, 0x000F), ad: Bitwise.band(Bitwise.bsr(flags, 5), 1)} + end + + defp put_dns_config(overrides) do + current = Application.get_env(:elektrine, :dns, []) + Application.put_env(:elektrine, :dns, Keyword.merge(current, overrides)) + :persistent_term.erase({Elektrine.DNS.Recursive, :parsed_allow_cidrs}) + end +end diff --git a/apps/elektrine_dns/test/elektrine/dns/request_guard_test.exs b/apps/elektrine_dns/test/elektrine/dns/request_guard_test.exs new file mode 100644 index 0000000..aca2ce2 --- /dev/null +++ b/apps/elektrine_dns/test/elektrine/dns/request_guard_test.exs @@ -0,0 +1,78 @@ +defmodule Elektrine.DNS.RequestGuardTest do + use ExUnit.Case, async: false + + alias Elektrine.DNS.RequestGuard + + setup do + old_dns = Application.get_env(:elektrine, :dns, []) + + clear_table(Elektrine.DNS.RequestGuard) + + on_exit(fn -> + Application.put_env(:elektrine, :dns, old_dns) + clear_table(Elektrine.DNS.RequestGuard) + end) + + :ok + end + + test "rate limits repeated udp requests from the same client" do + put_dns_config( + udp_rate_limit_per_window: 2, + udp_max_inflight: 10, + rate_limit_window_ms: 1_000 + ) + + assert {:ok, :udp} = RequestGuard.begin_request({127, 0, 0, 1}, :udp) + RequestGuard.finish_request(:udp) + + assert {:ok, :udp} = RequestGuard.begin_request({127, 0, 0, 1}, :udp) + RequestGuard.finish_request(:udp) + + assert {:error, :rate_limited} = RequestGuard.begin_request({127, 0, 0, 1}, :udp) + end + + test "caps concurrent tcp work" do + put_dns_config( + tcp_rate_limit_per_window: 10, + tcp_max_inflight: 1, + rate_limit_window_ms: 1_000 + ) + + assert {:ok, :tcp} = RequestGuard.begin_request({127, 0, 0, 1}, :tcp) + assert {:error, :busy} = RequestGuard.begin_request({127, 0, 0, 2}, :tcp) + + RequestGuard.finish_request(:tcp) + + assert {:ok, :tcp} = RequestGuard.begin_request({127, 0, 0, 2}, :tcp) + RequestGuard.finish_request(:tcp) + end + + test "check_rate consumes rate budget without touching inflight" do + put_dns_config( + tcp_rate_limit_per_window: 2, + tcp_max_inflight: 1, + rate_limit_window_ms: 1_000 + ) + + assert :ok = RequestGuard.check_rate({127, 0, 0, 9}, :tcp) + assert :ok = RequestGuard.check_rate({127, 0, 0, 9}, :tcp) + assert {:error, :rate_limited} = RequestGuard.check_rate({127, 0, 0, 9}, :tcp) + + # Inflight capacity is untouched by check_rate. + assert {:ok, :tcp} = RequestGuard.begin_request({127, 0, 0, 10}, :tcp) + RequestGuard.finish_request(:tcp) + end + + defp clear_table(table) do + case :ets.whereis(table) do + :undefined -> :ok + _ -> :ets.delete_all_objects(table) + end + end + + defp put_dns_config(overrides) do + current = Application.get_env(:elektrine, :dns, []) + Application.put_env(:elektrine, :dns, Keyword.merge(current, overrides)) + end +end diff --git a/apps/elektrine_dns/test/elektrine/dns/zone_change_listener_test.exs b/apps/elektrine_dns/test/elektrine/dns/zone_change_listener_test.exs new file mode 100644 index 0000000..0e84da1 --- /dev/null +++ b/apps/elektrine_dns/test/elektrine/dns/zone_change_listener_test.exs @@ -0,0 +1,17 @@ +defmodule Elektrine.DNS.ZoneChangeListenerTest do + use Elektrine.DataCase, async: false + + alias Elektrine.DNS.ZoneChangeListener + + test "channel/0 is stable" do + assert ZoneChangeListener.channel() == "elektrine_dns_zone_changed" + end + + # Cross-node delivery can't be exercised through the SQL sandbox: pg_notify + # only fires on transaction commit and the sandbox rolls back. This checks + # the emit path runs the pg_notify query without raising; end-to-end + # delivery is verified against the running nameserver. + test "notify/1 issues the pg_notify query and returns :ok" do + assert :ok = ZoneChangeListener.notify() + end +end diff --git a/apps/elektrine_dns/test/elektrine/dns/zone_verification_test.exs b/apps/elektrine_dns/test/elektrine/dns/zone_verification_test.exs new file mode 100644 index 0000000..242336d --- /dev/null +++ b/apps/elektrine_dns/test/elektrine/dns/zone_verification_test.exs @@ -0,0 +1,213 @@ +defmodule Elektrine.DNS.TestResolver do + use Agent + + def start_link(opts \\ []) do + Agent.start_link(fn -> %{} end, Keyword.put_new(opts, :name, __MODULE__)) + end + + def lookup(domain, :in, :ns, timeout: 5_000) do + Agent.get(__MODULE__, fn responses -> + Map.fetch!(responses, {List.to_string(domain), 5_000}) + end) + end + + def lookup(domain, :in, type, timeout: 5_000) when type in [:a, :aaaa] do + Agent.get(__MODULE__, fn responses -> + Map.get(responses, {List.to_string(domain), type, 5_000}, []) + end) + end +end + +defmodule Elektrine.DNS.TestVerificationTransport do + use Agent + + def start_link(opts \\ []) do + Agent.start_link(fn -> %{} end, Keyword.put_new(opts, :name, __MODULE__)) + end + + def exchange_udp(ip, port, packet, _timeout) do + {:ok, query} = Elektrine.DNS.Packet.decode_query(packet) + + Agent.get(__MODULE__, fn responses -> + Map.get( + responses, + {ip, port, query.qname, query.qtype}, + Map.get(responses, {ip, port, :any, query.qtype}, {:error, :timeout}) + ) + end) + end + + def exchange_tcp(ip, port, packet, timeout), do: exchange_udp(ip, port, packet, timeout) +end + +defmodule Elektrine.DNS.ZoneVerificationTest do + use Elektrine.DataCase, async: false + + alias Elektrine.AccountsFixtures + alias Elektrine.DNS + + setup_all do + start_supervised!(Elektrine.DNS.TestResolver) + start_supervised!(Elektrine.DNS.TestVerificationTransport) + :ok + end + + setup do + old_dns = Application.get_env(:elektrine, :dns, []) + + Application.put_env( + :elektrine, + :dns, + Keyword.merge(old_dns, + nameservers: ["ns1.elektrine.com", "ns2.elektrine.com"], + nameserver_assignment_secret: "zone-verification-test-secret", + dns_resolver: Elektrine.DNS.TestResolver, + recursive_transport: Elektrine.DNS.TestVerificationTransport, + recursive_timeout: 100, + recursive_root_hints: [{{192, 0, 2, 1}, 53}] + ) + ) + + Agent.update(Elektrine.DNS.TestResolver, fn _ -> %{} end) + Agent.update(Elektrine.DNS.TestVerificationTransport, fn _ -> %{} end) + + on_exit(fn -> + Application.put_env(:elektrine, :dns, old_dns) + Agent.update(Elektrine.DNS.TestResolver, fn _ -> %{} end) + Agent.update(Elektrine.DNS.TestVerificationTransport, fn _ -> %{} end) + end) + + :ok + end + + test "verify_zone stores specific delegation mismatch details" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + put_tld_delegation(zone.domain, ["ns1.wrong.test"], [{{203, 0, 113, 53}, 53}]) + + assert {:ok, updated} = DNS.verify_zone(zone) + assert updated.status == "pending" + + expected = DNS.assigned_nameservers(zone) |> Enum.sort() |> Enum.join(", ") + + assert updated.last_error == + "Delegation mismatch for the configured nameservers. Expected: #{expected}. Observed: ns1.wrong.test." + end + + test "verify_zone reports when no nameservers are observed" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + put_tld_delegation(zone.domain, [], []) + + assert {:ok, updated} = DNS.verify_zone(zone) + + expected = DNS.assigned_nameservers(zone) |> Enum.sort() |> Enum.join(", ") + + assert updated.last_error == + "Delegation mismatch for the configured nameservers. Expected: #{expected}. Observed: none." + end + + test "verify_zone marks the zone verified when delegation matches" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + put_tld_delegation(zone.domain, Enum.reverse(DNS.assigned_nameservers(zone)), [ + {{203, 0, 113, 10}, 53} + ]) + + assert {:ok, updated} = DNS.verify_zone(zone) + assert updated.status == "verified" + assert updated.last_error == nil + end + + test "verify_zone rejects delegation to the base nameservers without the assigned set" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + put_tld_delegation(zone.domain, DNS.nameservers(), [ + {{203, 0, 113, 10}, 53} + ]) + + assert {:ok, updated} = DNS.verify_zone(zone) + assert updated.status == "pending" + assert updated.last_error =~ "Delegation mismatch for the configured nameservers." + assert updated.last_error =~ List.first(DNS.assigned_nameservers(zone)) + assert updated.last_error =~ "Observed: ns1.elektrine.com, ns2.elektrine.com." + end + + defp put_tld_delegation(domain, nameservers, endpoints) do + tld = domain |> String.split(".") |> List.last() + tld_server = {192, 0, 2, 53} + + put_response( + {192, 0, 2, 1}, + tld, + :ns, + delegated_nameserver_response(tld, ["ns1.#{tld_server_name()}"], [tld_server]) + ) + + put_response( + tld_server, + domain, + :ns, + delegated_nameserver_response(domain, nameservers, endpoints) + ) + end + + defp put_response(ip, qname, qtype, response, opts \\ []) do + Agent.update(Elektrine.DNS.TestVerificationTransport, fn state -> + case Keyword.get(opts, :match_any_qname?, false) do + true -> + Map.put(state, {ip, 53, :any, qtype}, {:ok, response}) + + false -> + Map.put(state, {ip, 53, qname, qtype}, {:ok, response}) + end + end) + end + + defp delegated_nameserver_response(domain, nameservers, endpoints) do + additional = + Enum.zip(nameservers, endpoints) + |> Enum.flat_map(fn {nameserver, endpoint} -> + ip = + case endpoint do + {tuple, _port} when is_tuple(tuple) -> tuple + tuple when is_tuple(tuple) -> tuple + end + + case ip do + {_, _, _, _} -> + [%{name: nameserver, type: :a, content: :inet.ntoa(ip) |> to_string(), ttl: 300}] + + tuple when tuple_size(tuple) == 8 -> + [ + %{ + name: nameserver, + type: :aaaa, + content: :inet.ntoa(tuple) |> to_string(), + ttl: 300 + } + ] + end + end) + + Elektrine.DNS.Packet.encode_response( + %{id: 1, rd: 0, qname: domain, qtype: :ns}, + Enum.map(nameservers, fn nameserver -> + %{name: domain, type: :ns, value: nameserver, ttl: 300} + end), + :noerror, + authoritative: true, + additional: additional + ) + end + + defp tld_server_name, do: "gtld.test" + + defp unique_domain do + "verify#{System.unique_integer([:positive])}.elektrine.io" + end +end diff --git a/apps/elektrine_dns/test/elektrine/dns_analytics_test.exs b/apps/elektrine_dns/test/elektrine/dns_analytics_test.exs new file mode 100644 index 0000000..860a515 --- /dev/null +++ b/apps/elektrine_dns/test/elektrine/dns_analytics_test.exs @@ -0,0 +1,142 @@ +defmodule Elektrine.DNSAnalyticsTest do + use Elektrine.DataCase, async: false + + alias Elektrine.AccountsFixtures + alias Elektrine.DNS + + test "rolls up authoritative query analytics per zone" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert :ok = + DNS.track_query( + %{ + zone: zone, + authoritative: true, + qname: zone.domain, + qtype: :mx, + rcode: :noerror + }, + "udp" + ) + + assert :ok = + DNS.track_query( + %{ + zone: zone, + authoritative: true, + qname: zone.domain, + qtype: :mx, + rcode: :noerror + }, + "udp" + ) + + assert :ok = + DNS.track_query( + %{ + zone: zone, + authoritative: true, + qname: "missing.#{zone.domain}", + qtype: :a, + rcode: :nxdomain + }, + "tcp" + ) + + assert %{total_queries: 3, queries_today: 3, queries_this_week: 3, nxdomain_queries: 1} = + DNS.get_zone_query_stats(zone.id) + + assert [%{qtype: "MX", count: 2}, %{qtype: "A", count: 1}] = + DNS.get_zone_query_type_breakdown(zone.id, 10) + + zone_domain = zone.domain + assert [%{qname: ^zone_domain, count: 2} | _] = DNS.get_zone_top_names(zone.id, 10) + + assert Enum.any?( + DNS.get_zone_rcode_breakdown(zone.id), + &(&1.rcode == "NXDOMAIN" and &1.count == 1) + ) + + assert Enum.any?( + DNS.get_zone_daily_query_counts(zone.id, 30), + &(&1.date == Date.utc_today() and &1.count == 3) + ) + end + + test "ignores non-authoritative query results" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert :ok = + DNS.track_query( + %{ + zone: zone, + authoritative: false, + qname: zone.domain, + qtype: :a, + rcode: :noerror + }, + "udp" + ) + + assert %{total_queries: 0, queries_today: 0, queries_this_week: 0, nxdomain_queries: 0} = + DNS.get_zone_query_stats(zone.id) + end + + test "preserves operational underscore labels in top queried names" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + atproto_name = "_atproto.maxfield23.bsky.#{zone.domain}" + acme_name = "_acme-challenge.maxfield23.bsky.#{zone.domain}" + generic_name = "probe.maxfield23.bsky.#{zone.domain}" + wildcard_bucket = "*.maxfield23.bsky.#{zone.domain}" + + for qname <- [atproto_name, acme_name, generic_name] do + assert :ok = + DNS.track_query( + %{ + zone: zone, + authoritative: true, + qname: qname, + qtype: :txt, + rcode: :noerror + }, + "udp" + ) + end + + top_names = DNS.get_zone_top_names(zone.id, 10) + + assert Enum.any?(top_names, &(&1.qname == atproto_name and &1.count == 1)) + assert Enum.any?(top_names, &(&1.qname == acme_name and &1.count == 1)) + assert Enum.any?(top_names, &(&1.qname == wildcard_bucket and &1.count == 1)) + end + + test "aggregates nxdomain queries to zone wildcard bucket" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + for label <- ["a", "b", "c"] do + assert :ok = + DNS.track_query( + %{ + zone: zone, + authoritative: true, + qname: "#{label}.#{zone.domain}", + qtype: :a, + rcode: :nxdomain + }, + "udp" + ) + end + + top_names = DNS.get_zone_top_names(zone.id, 10) + assert Enum.any?(top_names, &(&1.qname == "*.#{zone.domain}" and &1.count == 3)) + end + + defp unique_domain do + "dnsanalytics#{System.unique_integer([:positive])}.example.com" + end +end diff --git a/apps/elektrine_dns/test/elektrine/dns_context_test.exs b/apps/elektrine_dns/test/elektrine/dns_context_test.exs new file mode 100644 index 0000000..591774d --- /dev/null +++ b/apps/elektrine_dns/test/elektrine/dns_context_test.exs @@ -0,0 +1,612 @@ +defmodule Elektrine.DNS.ScanResolver do + use Agent + + def start_link(opts \\ []) do + Agent.start_link(fn -> %{} end, Keyword.put_new(opts, :name, __MODULE__)) + end + + def lookup(domain, :in, type, opts) do + timeout = Keyword.get(opts, :timeout) + + Agent.get(__MODULE__, fn responses -> + Map.get(responses, {List.to_string(domain), type, timeout}, []) + end) + end +end + +defmodule Elektrine.DNSContextTest do + use Elektrine.DataCase, async: false + + import Ecto.Query + + alias Elektrine.AccountsFixtures + alias Elektrine.DNS + alias Elektrine.DNS.ZoneCache + alias Elektrine.Repo + + setup_all do + start_supervised!(Elektrine.DNS.ScanResolver) + :ok + end + + setup do + old_dns = Application.get_env(:elektrine, :dns, []) + + Application.put_env( + :elektrine, + :dns, + Keyword.merge(old_dns, + nameservers: ["ns1.elektrine.com", "ns2.elektrine.com"], + nameserver_assignment_secret: "dns-context-test-secret", + dns_resolver: Elektrine.DNS.ScanResolver + ) + ) + + Agent.update(Elektrine.DNS.ScanResolver, fn _ -> %{} end) + + on_exit(fn -> + Application.put_env(:elektrine, :dns, old_dns) + Agent.update(Elektrine.DNS.ScanResolver, fn _ -> %{} end) + end) + + :ok + end + + test "list_user_zones/1 provisions the built-in user subdomain zone" do + user = AccountsFixtures.user_fixture() + {:ok, user} = DNS.update_builtin_user_zone_mode(user, "platform") + + [zone] = DNS.list_user_zones(user) + + assert zone.domain == DNS.builtin_user_zone_domain(user) + assert zone.status == "verified" + assert DNS.builtin_user_zone?(zone, user) + + assert Enum.any?(zone.records, fn record -> + record.name == "@" and record.type == "ALIAS" and record.managed and record.required + end) + end + + test "list_user_zones/1 does not block on a busy zone cache while provisioning" do + pid = ensure_zone_cache_started() + :sys.suspend(pid) + + try do + user = AccountsFixtures.user_fixture() + + [zone] = DNS.list_user_zones(user) + + assert zone.domain == DNS.builtin_user_zone_domain(user) + assert zone.status == "verified" + after + :sys.resume(pid) + ZoneCache.refresh(caller: self()) + end + end + + test "built-in user zone keeps apex routing reserved" do + user = AccountsFixtures.user_fixture() + {:ok, user} = DNS.update_builtin_user_zone_mode(user, "platform") + [zone] = DNS.list_user_zones(user) + + assert {:error, changeset} = + DNS.create_record(zone, %{ + "name" => "@", + "type" => "A", + "ttl" => 300, + "content" => "198.51.100.10" + }) + + assert "the apex host is reserved for Elektrine profile routing; only TXT and CAA are allowed there" in errors_on( + changeset + ).name + + assert {:ok, record} = + DNS.create_record(zone, %{ + "name" => "blog", + "type" => "A", + "ttl" => 300, + "content" => "198.51.100.11" + }) + + assert record.name == "blog" + + assert {:ok, txt_record} = + DNS.create_record(zone, %{ + "name" => "@", + "type" => "TXT", + "ttl" => 300, + "content" => "hello=world" + }) + + assert txt_record.name == "@" + assert txt_record.type == "TXT" + end + + test "built-in user zone cannot be deleted" do + user = AccountsFixtures.user_fixture() + [zone] = DNS.list_user_zones(user) + + assert {:error, changeset} = DNS.delete_zone(zone) + + assert "is the built-in profile subdomain and cannot be deleted" in errors_on(changeset).domain + end + + test "built-in user zone can be handed off to external dns for apex A records" do + user = AccountsFixtures.user_fixture() + [zone] = DNS.list_user_zones(user) + + assert {:ok, updated_user} = DNS.update_builtin_user_zone_mode(user, "external_dns") + zone = DNS.get_zone(zone.id, updated_user.id) + + refute Enum.any?(zone.records, &(&1.managed_key == "system:profile-apex")) + + assert {:ok, record} = + DNS.create_record(zone, %{ + "name" => "@", + "type" => "A", + "ttl" => 300, + "content" => "198.51.100.77" + }) + + assert record.name == "@" + assert record.type == "A" + end + + test "switching built-in user zone back to platform hosting requires apex cleanup" do + user = AccountsFixtures.user_fixture() + [zone] = DNS.list_user_zones(user) + + {:ok, updated_user} = DNS.update_builtin_user_zone_mode(user, "external_dns") + zone = DNS.get_zone(zone.id, updated_user.id) + + {:ok, _record} = + DNS.create_record(zone, %{ + "name" => "@", + "type" => "A", + "ttl" => 300, + "content" => "198.51.100.88" + }) + + assert {:error, changeset} = DNS.update_builtin_user_zone_mode(updated_user, "platform") + + assert "cannot switch back to platform hosting until apex records are removed: @ A" in errors_on( + changeset + ).built_in_subdomain_mode + + refetched_user = Repo.get!(Elektrine.Accounts.User, updated_user.id) + assert refetched_user.built_in_subdomain_mode == "external_dns" + end + + test "normalizes absolute in-zone record names on create and update" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, record} = + DNS.create_record(zone, %{ + "name" => zone.domain, + "type" => "A", + "ttl" => 300, + "content" => "198.51.100.10" + }) + + assert record.name == "@" + + assert {:ok, updated} = + DNS.update_record(record, %{ + "name" => "ns1.#{zone.domain}", + "type" => "A", + "ttl" => 300, + "content" => "198.51.100.11" + }) + + assert updated.name == "ns1" + end + + test "zone onboarding records use the assigned short nameserver set" do + user = AccountsFixtures.user_fixture() + {:ok, first_zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + {:ok, second_zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + first_nameservers = DNS.assigned_nameservers(first_zone) + second_nameservers = DNS.assigned_nameservers(second_zone) + + assert first_nameservers != DNS.nameservers() + assert is_integer(first_zone.nameserver_set) + assert is_integer(second_zone.nameserver_set) + assert Enum.at(first_nameservers, 0) |> String.ends_with?(".ns1.elektrine.com") + assert Enum.at(first_nameservers, 1) |> String.ends_with?(".ns2.elektrine.com") + assert Enum.at(second_nameservers, 0) |> String.ends_with?(".ns1.elektrine.com") + assert Enum.at(second_nameservers, 1) |> String.ends_with?(".ns2.elektrine.com") + assert Enum.map(DNS.zone_onboarding_records(first_zone), & &1.value) == first_nameservers + end + + test "normalizes escaped apex names on create" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, record} = + DNS.create_record(zone, %{ + "name" => "\\@", + "type" => "A", + "ttl" => 300, + "content" => "198.51.100.10" + }) + + assert record.name == "@" + end + + test "stores private record visibility in metadata" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, record} = + DNS.create_record(zone, %{ + "name" => "admin", + "type" => "A", + "ttl" => 300, + "content" => "198.51.100.120", + "private" => "true" + }) + + assert Elektrine.DNS.Record.private?(record) + assert record.metadata["visibility"] == "private" + + assert {:ok, updated} = DNS.update_record(record, %{"private" => "false"}) + + refute Elektrine.DNS.Record.private?(updated) + refute Map.has_key?(updated.metadata, "visibility") + end + + test "stores proxy settings in DNS record metadata" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, record} = + DNS.create_record(zone, %{ + "name" => "www", + "type" => "A", + "ttl" => 300, + "content" => "198.51.100.10", + "proxied" => "true", + "proxy_origin_scheme" => "http", + "proxy_origin_port" => "8080" + }) + + assert DNS.Record.proxied?(record) + assert record.metadata["proxy"]["enabled"] == true + assert record.metadata["proxy"]["origin_scheme"] == "http" + assert record.metadata["proxy"]["origin_port"] == 8080 + + assert {:ok, updated} = DNS.update_record(record, %{"proxied" => "false"}) + refute DNS.Record.proxied?(updated) + refute Map.has_key?(updated.metadata, "proxy") + end + + test "rejects private address records" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:error, changeset} = + DNS.create_record(zone, %{ + "name" => "internal", + "type" => "A", + "ttl" => 300, + "content" => "127.0.0.1" + }) + + assert "must be a public address" in errors_on(changeset).content + + assert {:error, changeset} = + DNS.create_record(zone, %{ + "name" => "v6internal", + "type" => "AAAA", + "ttl" => 300, + "content" => "::1" + }) + + assert "must be a public address" in errors_on(changeset).content + end + + test "rejects private proxied origins" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:error, changeset} = + DNS.create_record(zone, %{ + "name" => "www", + "type" => "A", + "ttl" => 300, + "content" => "127.0.0.1", + "proxied" => "true" + }) + + assert "must be a public address" in errors_on(changeset).content + end + + test "resolves proxied origins for verified zones" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + Repo.update_all(from(z in Elektrine.DNS.Zone, where: z.id == ^zone.id), + set: [status: "verified"] + ) + + assert {:ok, record} = + DNS.create_record(zone, %{ + "name" => "app", + "type" => "A", + "ttl" => 300, + "content" => "198.51.100.20", + "proxied" => "true", + "proxy_origin_scheme" => "https" + }) + + assert {:ok, origin} = DNS.proxied_origin_for_host("app.#{zone.domain}") + assert origin.zone_id == zone.id + assert origin.record_id == record.id + assert origin.origin_host == "198.51.100.20" + assert origin.origin_url == "https://198.51.100.20" + assert origin.origin_host_header == "app.#{zone.domain}" + end + + test "rejects apex cname records" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:error, changeset} = + DNS.create_record(zone, %{ + "name" => zone.domain, + "type" => "CNAME", + "ttl" => 300, + "content" => "edge.elektrine.com" + }) + + assert "cannot be used at the zone apex" in errors_on(changeset).type + end + + test "accepts apex alias records and normalizes the target hostname" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, record} = + DNS.create_record(zone, %{ + "name" => zone.domain, + "type" => "ALIAS", + "ttl" => 300, + "content" => "Edge.Elektrine.com." + }) + + assert record.name == "@" + assert record.content == "edge.elektrine.com" + end + + test "supported record types include https svcb and sshfp" do + assert "HTTPS" in DNS.supported_record_types() + assert "SVCB" in DNS.supported_record_types() + assert "SSHFP" in DNS.supported_record_types() + end + + test "accepts sshfp records" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, record} = + DNS.create_record(zone, %{ + "name" => "host", + "type" => "SSHFP", + "ttl" => 300, + "algorithm" => 4, + "digest_type" => 2, + "content" => "1234567890abcdef" + }) + + assert record.type == "SSHFP" + assert record.content == "1234567890ABCDEF" + end + + test "accepts https and svcb records with service binding params" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, https_record} = + DNS.create_record(zone, %{ + "name" => "@", + "type" => "HTTPS", + "ttl" => 300, + "priority" => 1, + "content" => "Svc.Example.net. alpn=h2,h3 port=443" + }) + + assert https_record.content == "svc.example.net alpn=h2,h3 port=443" + + assert {:ok, svcb_record} = + DNS.create_record(zone, %{ + "name" => "_svc", + "type" => "SVCB", + "ttl" => 300, + "priority" => 2, + "content" => ". no-default-alpn" + }) + + assert svcb_record.content == ". no-default-alpn" + end + + test "scan_existing_zone returns observed delegation and common records" do + put_lookup("scanme.com", :ns, 5_000, [~c"ns1.example-dns.net", ~c"ns2.example-dns.net"]) + put_lookup("scanme.com", :a, 3_000, [{198, 51, 100, 10}]) + put_lookup("scanme.com", :mx, 3_000, [{10, ~c"mail.scanme.com"}]) + put_lookup("www.scanme.com", :cname, 3_000, [~c"proxy.other.net"]) + + scan = DNS.scan_existing_zone("scanme.com") + + assert scan.domain == "scanme.com" + assert scan.provider_hint == "ns1.example-dns.net" + refute scan.delegated_to_elektrine + assert scan.nameservers == ["ns1.example-dns.net", "ns2.example-dns.net"] + assert %{host: "@", type: "A", values: ["198.51.100.10"]} in scan.records + assert %{host: "@", type: "MX", values: ["10 mail.scanme.com"]} in scan.records + assert %{host: "www", type: "CNAME", values: ["proxy.other.net"]} in scan.records + end + + test "scan_existing_zone ignores incomplete hostnames" do + assert DNS.scan_existing_zone("e") == nil + assert DNS.scan_existing_zone("localhost") == nil + end + + test "rejects non-apex alias records" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:error, changeset} = + DNS.create_record(zone, %{ + "name" => "www", + "type" => "ALIAS", + "ttl" => 300, + "content" => "edge.elektrine.com" + }) + + assert "can only be used at the zone apex" in errors_on(changeset).type + end + + test "rejects invalid A and AAAA record content" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:error, changeset} = + DNS.create_record(zone, %{ + "name" => "www", + "type" => "A", + "ttl" => 300, + "content" => "not-an-ip" + }) + + assert "must be a valid IPv4 address" in errors_on(changeset).content + + assert {:error, changeset} = + DNS.create_record(zone, %{ + "name" => "www", + "type" => "AAAA", + "ttl" => 300, + "content" => "203.0.113.10" + }) + + assert "must be a valid IPv6 address" in errors_on(changeset).content + end + + test "record writes bump the zone serial and publication timestamp" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + assert {:ok, record} = + DNS.create_record(zone, %{ + "name" => "www", + "type" => "A", + "ttl" => 300, + "content" => "203.0.113.10" + }) + + zone_after_create = DNS.get_zone(zone.id, user.id) + assert zone_after_create.serial == zone.serial + 1 + assert zone_after_create.last_published_at + + assert {:ok, _record} = DNS.update_record(record, %{"content" => "203.0.113.11"}) + + zone_after_update = DNS.get_zone(zone.id, user.id) + assert zone_after_update.serial == zone_after_create.serial + 1 + end + + test "domain_health/1 reports missing mail security records" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + health = DNS.domain_health(DNS.get_zone(zone.id, user.id)) + + assert health.status == :missing + assert health.score < 50 + assert Enum.any?(health.checks, &(&1.label == "MX records" and &1.status == :missing)) + assert Enum.any?(health.checks, &(&1.label == "SPF policy" and &1.status == :missing)) + assert Enum.any?(health.recommendations, &(&1.label == "DMARC policy")) + end + + test "domain_health/1 recognizes common domain hardening records" do + user = AccountsFixtures.user_fixture() + {:ok, zone} = DNS.create_zone(user, %{"domain" => unique_domain()}) + + {:ok, _} = + DNS.create_record(zone, %{ + "name" => "@", + "type" => "MX", + "ttl" => 300, + "priority" => 10, + "content" => "mail.#{zone.domain}" + }) + + {:ok, _} = + DNS.create_record(zone, %{ + "name" => "@", + "type" => "TXT", + "ttl" => 300, + "content" => "v=spf1 mx -all" + }) + + {:ok, _} = + DNS.create_record(zone, %{ + "name" => "default._domainkey", + "type" => "TXT", + "ttl" => 300, + "content" => "v=DKIM1; k=rsa; p=abc123" + }) + + {:ok, _} = + DNS.create_record(zone, %{ + "name" => "_dmarc", + "type" => "TXT", + "ttl" => 300, + "content" => "v=DMARC1; p=reject; rua=mailto:postmaster@#{zone.domain}" + }) + + {:ok, _} = + DNS.create_record(zone, %{ + "name" => "@", + "type" => "CAA", + "ttl" => 300, + "flags" => 0, + "tag" => "issue", + "content" => "letsencrypt.org" + }) + + {:ok, _} = + DNS.create_record(zone, %{ + "name" => "_smtp._tls", + "type" => "TXT", + "ttl" => 300, + "content" => "v=TLSRPTv1; rua=mailto:postmaster@#{zone.domain}" + }) + + health = DNS.domain_health(DNS.get_zone(zone.id, user.id)) + + assert Enum.find(health.checks, &(&1.label == "MX records")).status == :ok + assert Enum.find(health.checks, &(&1.label == "SPF policy")).status == :ok + assert Enum.find(health.checks, &(&1.label == "DKIM key")).status == :ok + assert Enum.find(health.checks, &(&1.label == "DMARC policy")).status == :ok + assert Enum.find(health.checks, &(&1.label == "CAA records")).status == :ok + assert Enum.find(health.checks, &(&1.label == "SMTP TLS reports")).status == :ok + assert health.score >= 50 + end + + defp unique_domain do + "dnsctx#{System.unique_integer([:positive])}.example.com" + end + + defp put_lookup(domain, type, timeout, result) do + Agent.update(Elektrine.DNS.ScanResolver, &Map.put(&1, {domain, type, timeout}, result)) + end + + defp ensure_zone_cache_started do + case Process.whereis(ZoneCache) do + nil -> start_supervised!(ZoneCache) + pid -> pid + end + end +end diff --git a/apps/elektrine_dns/test/test_helper.exs b/apps/elektrine_dns/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/apps/elektrine_dns/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/apps/elektrine_email/.gitignore b/apps/elektrine_email/.gitignore new file mode 100644 index 0000000..af6888a --- /dev/null +++ b/apps/elektrine_email/.gitignore @@ -0,0 +1,22 @@ +# Elixir build/test artifacts +/_build/ +/deps/ +/cover/ +/.elixir_ls/ +/tmp/ +erl_crash.dump + +# Asset build artifacts +/assets/node_modules/ +/assets/.sass-cache/ +/assets/npm-debug.log* +/assets/yarn-debug.log* +/assets/yarn-error.log* +/priv/static/assets/ +/priv/static/cache_manifest.json +/priv/static/uploads/ + +# Generated runtime data +/priv/acme/ +/priv/exports/ +/priv/imap_rate_limit_*.ets diff --git a/apps/elektrine_email/README.md b/apps/elektrine_email/README.md new file mode 100644 index 0000000..ecd2b9e --- /dev/null +++ b/apps/elektrine_email/README.md @@ -0,0 +1,34 @@ +# Elektrine Email + +Email domain and protocol services for the Elektrine umbrella. + +## What lives here + +- Mailbox/message contexts and email workflows +- Alias, filter, template, folder, and label management +- IMAP, POP3, SMTP, and JMAP server modules +- Email processing, categorization, and outbound delivery workers +- Mailbox, contacts, admin mail, CardDAV, JMAP, WKD, unsubscribe routes, and Haraka webhooks + +## Notes + +- Uses shared persistence/runtime from `elektrine`. +- Public domain API is exposed via `Elektrine.Email`. +- Primary outbound queueing uses Oban (`Elektrine.Email.SendEmailWorker`). +- Router-mounted controllers and LiveViews in this app still use `ElektrineWeb.*`; shared entrypoints live in `ElektrineEmailWeb`. + +## Pipeline Boundaries + +- Outbound (`Elektrine.Email.Sender`): parse raw SMTP payload first, then sanitize once, then route. +- Inbound routing (`Elektrine.Email.InboundRouting`): resolve recipient mailbox/forwarding and validate routing match. +- Inbound webhook (`ElektrineEmailWeb.HarakaWebhookController`): extract/validate first, then sanitize once before forwarding or storage. +- Render boundary (`elektrine_web`): sanitize HTML for display in iframe/UI contexts. + +## Shell Integration + +- `elektrine_web` still owns shared account/admin pages and the outer layout. +- Email-specific controllers and LiveViews live in `apps/elektrine_email` even when they keep the `ElektrineWeb.*` namespace. + +## License + +AGPL-3.0-only (see `../../LICENSE`). diff --git a/apps/elektrine_email/lib/elektrine/email.ex b/apps/elektrine_email/lib/elektrine/email.ex new file mode 100644 index 0000000..3794608 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email.ex @@ -0,0 +1,595 @@ +defmodule Elektrine.Email do + @moduledoc """ + The Email context. + This context handles all email-related functionality like managing mailboxes, + sending/receiving emails, and storing/retrieving email messages. + + This module serves as the main public API and delegates to specialized sub-contexts: + - Elektrine.Email.Mailboxes - Mailbox management + - Elektrine.Email.Messages - Message CRUD and queries + - Elektrine.Email.Folders - Folder operations and categories + - Elektrine.Email.Search - Message search functionality + - Elektrine.Email.Processing - Categorization and processing + - Elektrine.Email.Aliases - Email alias management + - Elektrine.Email.BlockedSenders - Blocked sender management + - Elektrine.Email.Suppressions - Outbound recipient suppression management + - Elektrine.Email.SafeSenders - Safe sender/whitelist management + - Elektrine.Email.Filters - Email filter/rule management + - Elektrine.Email.AutoReplies - Auto-reply/vacation responder + - Elektrine.Email.Templates - Email template management + - Elektrine.Email.CustomFolders - Custom folder management + - Elektrine.Email.Labels - Label/tag management + - Elektrine.Email.Exports - Email export/backup + """ + + # Delegate mailbox functions to Mailboxes module + defdelegate get_user_mailbox(user_id), to: Elektrine.Email.Mailboxes + defdelegate get_user_mailboxes(user_id), to: Elektrine.Email.Mailboxes + defdelegate get_mailbox_admin(id), to: Elektrine.Email.Mailboxes + defdelegate get_mailbox_internal(id), to: Elektrine.Email.Mailboxes + defdelegate get_mailbox_by_email(email), to: Elektrine.Email.Mailboxes + defdelegate get_mailbox_by_username(username), to: Elektrine.Email.Mailboxes + defdelegate get_mailbox(id, user_id), to: Elektrine.Email.Mailboxes + defdelegate create_mailbox(user_or_params), to: Elektrine.Email.Mailboxes + defdelegate ensure_user_has_mailbox(user), to: Elektrine.Email.Mailboxes + defdelegate list_mailboxes(user_id), to: Elektrine.Email.Mailboxes + defdelegate list_all_mailboxes(), to: Elektrine.Email.Mailboxes + defdelegate update_mailbox(mailbox, attrs), to: Elektrine.Email.Mailboxes + defdelegate update_mailbox_email(mailbox, new_email), to: Elektrine.Email.Mailboxes + defdelegate update_mailbox_private_storage(mailbox, attrs), to: Elektrine.Email.Mailboxes + defdelegate reset_user_private_storage(user_id), to: Elektrine.Email.Mailboxes + + defdelegate transition_mailbox_for_username_change(user, old_mailbox, new_email), + to: Elektrine.Email.Mailboxes + + defdelegate delete_mailbox(mailbox), to: Elektrine.Email.Mailboxes + defdelegate update_mailbox_forwarding(mailbox, attrs), to: Elektrine.Email.Mailboxes + defdelegate change_mailbox_forwarding(mailbox, attrs \\ %{}), to: Elektrine.Email.Mailboxes + defdelegate update_mailbox_category_filters(mailbox, attrs), to: Elektrine.Email.Mailboxes + + defdelegate enqueue_system_email_to_all_users(attrs, opts \\ []), + to: Elektrine.Email.SystemDelivery, + as: :enqueue_email_to_all_users + + defdelegate deliver_system_email_to_all_users(attrs), + to: Elektrine.Email.SystemDelivery, + as: :deliver_email_to_all_users + + defdelegate get_external_delivery(id), to: Elektrine.Email.ExternalDelivery, as: :get + + defdelegate get_external_delivery_by_sent_message_id(sent_message_id), + to: Elektrine.Email.ExternalDelivery, + as: :get_by_sent_message_id + + defdelegate list_external_deliveries_for_message(sent_message_id), + to: Elektrine.Email.ExternalDelivery, + as: :list_for_message + + defdelegate list_external_delivery_attempts(delivery), + to: Elektrine.Email.ExternalDelivery, + as: :list_attempts + + defdelegate trace_external_delivery(trace_id), to: Elektrine.Email.ExternalDelivery, as: :trace + + defdelegate external_delivery_summary(sent_message_id), + to: Elektrine.Email.ExternalDelivery, + as: :delivery_summary + + defdelegate external_delivery_operational_metrics(opts \\ []), + to: Elektrine.Email.ExternalDelivery, + as: :operational_metrics + + defdelegate recent_external_deliveries(opts \\ []), + to: Elektrine.Email.ExternalDelivery, + as: :recent_deliveries + + defdelegate requeue_external_delivery(delivery), + to: Elektrine.Email.ExternalDelivery, + as: :requeue + + defdelegate apply_external_provider_event(attrs), + to: Elektrine.Email.ExternalDelivery, + as: :apply_provider_event + + defdelegate mark_external_delivery_bounced_by_signal(attrs, reason \\ "bounce"), + to: Elektrine.Email.ExternalDelivery, + as: :mark_bounced_by_signal + + defdelegate mark_external_delivery_complained_by_signal(attrs, reason \\ "complaint"), + to: Elektrine.Email.ExternalDelivery, + as: :mark_complained_by_signal + + defdelegate get_internal_delivery(id), to: Elektrine.Email.InternalDelivery, as: :get + + defdelegate list_internal_deliveries_for_message(sent_message_id), + to: Elektrine.Email.InternalDelivery, + as: :list_for_message + + defdelegate list_internal_delivery_attempts(delivery), + to: Elektrine.Email.InternalDelivery, + as: :list_attempts + + defdelegate internal_delivery_summary(sent_message_id), + to: Elektrine.Email.InternalDelivery, + as: :delivery_summary + + defdelegate recent_internal_deliveries(opts \\ []), + to: Elektrine.Email.InternalDelivery, + as: :recent_deliveries + + defdelegate requeue_internal_delivery(delivery), + to: Elektrine.Email.InternalDelivery, + as: :requeue + + defdelegate check_deliverability_domain(domain), + to: Elektrine.Email.Deliverability, + as: :check_domain + + defdelegate validate_outbound_deliverability(from_address, user_id), + to: Elektrine.Email.Deliverability, + as: :outbound_allowed? + + defdelegate pause_external_delivery(scope_type, scope_value, attrs \\ []), + to: Elektrine.Email.ExternalDeliveryControl, + as: :pause + + defdelegate resume_external_delivery(scope_type, scope_value), + to: Elektrine.Email.ExternalDeliveryControl, + as: :resume + + defdelegate active_external_delivery_controls(), + to: Elektrine.Email.ExternalDeliveryControl, + as: :active_controls + + defdelegate system_email_from_address(), + to: Elektrine.Email.SystemDelivery, + as: :system_from_address + + defdelegate change_mailbox_category_filters(mailbox, attrs \\ %{}), + to: Elektrine.Email.Mailboxes + + defdelegate get_mailbox_forward_target(mailbox), to: Elektrine.Email.Mailboxes + + # Delegate message functions to Messages module + defdelegate get_message_admin(id), to: Elektrine.Email.Messages + defdelegate get_message_internal(id), to: Elektrine.Email.Messages + defdelegate get_message(id, mailbox_id), to: Elektrine.Email.Messages + defdelegate get_message_by_hash(hash), to: Elektrine.Email.Messages + defdelegate get_user_message_by_hash(hash, user_id), to: Elektrine.Email.Messages + defdelegate get_message_by_id(message_id, mailbox_id), to: Elektrine.Email.Messages + defdelegate get_user_message(message_id, user_id), to: Elektrine.Email.Messages + defdelegate list_user_messages(user_id, limit \\ 50, offset \\ 0), to: Elektrine.Email.Messages + + defdelegate list_user_messages_secure(user_id, limit \\ 50, offset \\ 0), + to: Elektrine.Email.Messages + + defdelegate list_messages(mailbox_id, limit \\ 50, offset \\ 0), to: Elektrine.Email.Messages + defdelegate list_thread_messages(message, mailbox_id), to: Elektrine.Email.Messages + + defdelegate list_inbox_messages(mailbox_id, limit \\ 50, offset \\ 0), + to: Elektrine.Email.Messages + + defdelegate list_spam_messages(mailbox_id, limit \\ 50, offset \\ 0), + to: Elektrine.Email.Messages + + defdelegate list_archived_messages(mailbox_id, limit \\ 50, offset \\ 0), + to: Elektrine.Email.Messages + + defdelegate list_user_unread_messages(user_id), to: Elektrine.Email.Messages + defdelegate list_unread_messages(mailbox_id), to: Elektrine.Email.Messages + defdelegate create_message(attrs \\ %{}), to: Elektrine.Email.Messages + defdelegate update_message(message, attrs), to: Elektrine.Email.Messages + defdelegate save_draft(attrs, draft_id \\ nil), to: Elektrine.Email.Messages + defdelegate get_draft(draft_id, mailbox_id), to: Elektrine.Email.Messages + defdelegate delete_draft(draft_id, mailbox_id), to: Elektrine.Email.Messages + + defdelegate update_message_attachments(message, attachments, has_attachments), + to: Elektrine.Email.Messages + + defdelegate mark_as_read(message), to: Elektrine.Email.Messages + defdelegate mark_as_unread(message), to: Elektrine.Email.Messages + defdelegate mark_as_spam(message), to: Elektrine.Email.Messages + defdelegate mark_as_not_spam(message), to: Elektrine.Email.Messages + defdelegate archive_message(message), to: Elektrine.Email.Messages + defdelegate unarchive_message(message), to: Elektrine.Email.Messages + defdelegate trash_message(message), to: Elektrine.Email.Messages + defdelegate untrash_message(message), to: Elektrine.Email.Messages + defdelegate delete_message(message_or_id), to: Elektrine.Email.Messages + defdelegate delete_message(message_id, mailbox_id), to: Elektrine.Email.Messages + defdelegate track_message_open(message), to: Elektrine.Email.Messages + defdelegate user_unread_count(user_id), to: Elektrine.Email.Messages + defdelegate unread_count(mailbox_id), to: Elektrine.Email.Messages + defdelegate unread_feed_count(mailbox_id), to: Elektrine.Email.Messages + defdelegate unread_ledger_count(mailbox_id), to: Elektrine.Email.Messages + defdelegate unread_stack_count(mailbox_id), to: Elektrine.Email.Messages + defdelegate unread_reply_later_count(mailbox_id), to: Elektrine.Email.Messages + defdelegate unread_inbox_count(mailbox_id), to: Elektrine.Email.Messages + defdelegate get_all_unread_counts(mailbox_id), to: Elektrine.Email.Messages + defdelegate update_message_flags(message_id, updates), to: Elektrine.Email.Messages + defdelegate update_message_flags(message_id, mailbox_id, updates), to: Elektrine.Email.Messages + defdelegate calculate_message_size(message_attrs), to: Elektrine.Email.Messages + + # Delegate folder/category functions to Folders module + defdelegate list_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20), + to: Elektrine.Email.Folders + + defdelegate list_inbox_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20), + to: Elektrine.Email.Folders + + defdelegate list_spam_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20), + to: Elektrine.Email.Folders + + defdelegate list_trash_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20), + to: Elektrine.Email.Folders + + defdelegate list_archived_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20), + to: Elektrine.Email.Folders + + defdelegate list_sent_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20), + to: Elektrine.Email.Folders + + defdelegate list_drafts_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20), + to: Elektrine.Email.Folders + + defdelegate drafts_count(mailbox_id), to: Elektrine.Email.Folders + + defdelegate list_unread_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20), + to: Elektrine.Email.Folders + + defdelegate list_read_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20), + to: Elektrine.Email.Folders + + defdelegate list_feed_messages(mailbox_id, limit \\ 50, offset \\ 0), + to: Elektrine.Email.Folders + + defdelegate feed_messages_count(mailbox_id), to: Elektrine.Email.Folders + + defdelegate list_feed_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20), + to: Elektrine.Email.Folders + + defdelegate list_ledger_messages(mailbox_id, limit \\ 50, offset \\ 0), + to: Elektrine.Email.Folders + + defdelegate ledger_messages_count(mailbox_id), to: Elektrine.Email.Folders + + defdelegate list_ledger_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20), + to: Elektrine.Email.Folders + + defdelegate list_stack_messages(mailbox_id, limit \\ 50, offset \\ 0), + to: Elektrine.Email.Folders + + defdelegate list_stack_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20), + to: Elektrine.Email.Folders + + defdelegate list_reply_later_messages(mailbox_id, limit \\ 50, offset \\ 0), + to: Elektrine.Email.Folders + + defdelegate list_reply_later_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20), + to: Elektrine.Email.Folders + + defdelegate list_all_inbox_messages(mailbox_id), to: Elektrine.Email.Folders + defdelegate list_all_feed_messages(mailbox_id), to: Elektrine.Email.Folders + defdelegate list_all_ledger_messages(mailbox_id), to: Elektrine.Email.Folders + defdelegate list_all_stack_messages(mailbox_id), to: Elektrine.Email.Folders + defdelegate list_all_reply_later_messages(mailbox_id), to: Elektrine.Email.Folders + defdelegate list_all_sent_messages(mailbox_id), to: Elektrine.Email.Folders + defdelegate list_all_spam_messages(mailbox_id), to: Elektrine.Email.Folders + defdelegate stack_message(message, reason \\ nil), to: Elektrine.Email.Folders + defdelegate unstack_message(message), to: Elektrine.Email.Folders + defdelegate move_to_digest(message), to: Elektrine.Email.Folders + defdelegate move_to_ledger(message), to: Elektrine.Email.Folders + + defdelegate reply_later_message(message, reply_at, reminder \\ false), + to: Elektrine.Email.Folders + + defdelegate clear_reply_later(message), to: Elektrine.Email.Folders + defdelegate list_messages_for_imap(mailbox_id, folder), to: Elektrine.Email.Folders + + defdelegate list_messages_for_imap_custom_folder(mailbox_id, folder_id), + to: Elektrine.Email.Folders + + defdelegate list_messages_for_pop3(mailbox_id), to: Elektrine.Email.Folders + + # Delegate search functions to Search module + defdelegate search_messages(mailbox_id, query, page \\ 1, per_page \\ 20), + to: Elektrine.Email.Search + + defdelegate get_unique_recipient_domains(), to: Elektrine.Email.Search + + defdelegate get_unique_recipient_domains_paginated(page \\ 1, per_page \\ 50), + to: Elektrine.Email.Search + + # Delegate processing functions to Processing module + defdelegate categorize_message(message_attrs, opts \\ []), to: Elektrine.Email.Processing + defdelegate process_uncategorized_messages(), to: Elektrine.Email.Processing + defdelegate recategorize_messages(mailbox_id), to: Elektrine.Email.Processing + + # Delegate learned category preference functions + defdelegate match_category_preference(user_id, from_email), + to: Elektrine.Email.CategoryPreferences, + as: :match_category + + # Delegate alias functions to Aliases module + defdelegate list_aliases(user_id), to: Elektrine.Email.Aliases + defdelegate get_alias(id, user_id), to: Elektrine.Email.Aliases + defdelegate get_alias_by_email(alias_email), to: Elektrine.Email.Aliases + defdelegate create_alias(attrs \\ %{}), to: Elektrine.Email.Aliases + defdelegate update_alias(alias, attrs), to: Elektrine.Email.Aliases + defdelegate delete_alias(alias), to: Elektrine.Email.Aliases + defdelegate change_alias(alias, attrs \\ %{}), to: Elektrine.Email.Aliases + defdelegate resolve_alias(email), to: Elektrine.Email.Aliases + defdelegate alias_active?(alias), to: Elektrine.Email.Aliases + defdelegate alias_expired?(alias), to: Elektrine.Email.Aliases + defdelegate record_alias_delivery(alias, forwarded? \\ false), to: Elektrine.Email.Aliases + + # Delegate custom domain functions + defdelegate list_user_custom_domains(user_id), to: Elektrine.Email.CustomDomains + defdelegate list_custom_domains_for_recheck(limit), to: Elektrine.Email.CustomDomains + defdelegate get_custom_domain(id, user_id), to: Elektrine.Email.CustomDomains + defdelegate create_custom_domain(user_or_id, attrs), to: Elektrine.Email.CustomDomains + defdelegate verify_custom_domain(custom_domain), to: Elektrine.Email.CustomDomains + defdelegate sync_custom_domain_dkim(custom_domain), to: Elektrine.Email.CustomDomains + defdelegate delete_custom_domain(custom_domain), to: Elektrine.Email.CustomDomains + defdelegate dns_records_for_custom_domain(custom_domain), to: Elektrine.Email.CustomDomains + defdelegate verification_host(custom_domain), to: Elektrine.Email.CustomDomains + defdelegate verification_value(custom_domain), to: Elektrine.Email.CustomDomains + + def list_custom_domains_admin( + search_query \\ "", + status_filter \\ "all", + page \\ 1, + per_page \\ 20 + ), + do: + Elektrine.Email.CustomDomains.list_custom_domains_admin( + search_query, + status_filter, + page, + per_page + ) + + def custom_domain_admin_stats(limit_recent \\ 5), + do: Elektrine.Email.CustomDomains.custom_domain_admin_stats(limit_recent) + + @doc """ + Verifies that a user owns or has access to a specific email address. + This prevents unauthorized email access by validating ownership. + + Checks in order: + 1. User's main mailbox on local domains + 2. User's email aliases + 3. Cross-domain matching across configured local domains + """ + def verify_email_ownership(email_address, user_id) + when is_binary(email_address) and is_integer(user_id) do + clean_email = String.downcase(String.trim(email_address)) + + if receive_only_email_address?(clean_email) do + {:error, :receive_only_domain} + else + # Check 1: User's main mailbox, including domain variants and plus addresses. + case Elektrine.Email.Mailboxes.get_user_mailbox(user_id) do + %Elektrine.Email.Mailbox{} = mailbox -> + resolved_mailbox = Elektrine.Email.Mailboxes.get_mailbox_by_email(clean_email) + + if Elektrine.Email.Mailbox.owns_email?(mailbox, clean_email) or + match?(%Elektrine.Email.Mailbox{user_id: ^user_id}, resolved_mailbox) do + {:ok, :main_mailbox} + else + check_alias_ownership(clean_email, user_id) + end + + nil -> + check_alias_ownership(clean_email, user_id) + end + end + end + + def verify_email_ownership(_, _), do: {:error, :invalid_params} + + defp receive_only_email_address?(email_address) when is_binary(email_address) do + case String.split(email_address, "@", parts: 2) do + [_local, domain] -> domain in Elektrine.Domains.receive_only_email_domains() + _ -> false + end + end + + # Check if user owns the email through an alias + defp check_alias_ownership(email_address, user_id) do + # Check 2: User's email aliases + case Elektrine.Email.Aliases.get_alias_by_email(email_address) do + %Elektrine.Email.Alias{user_id: ^user_id} -> + # Accept both enabled and disabled aliases for email routing + {:ok, :alias} + + %Elektrine.Email.Alias{user_id: other_user_id} -> + {:error, {:owned_by_other_user, other_user_id}} + + nil -> + # Check 3: Cross-domain matching across configured local domains + check_cross_domain_ownership(email_address, user_id) + end + end + + # Check cross-domain ownership for supported domains + defp check_cross_domain_ownership(email_address, user_id) do + case String.split(email_address, "@") do + [username, domain] -> + available_domains = Elektrine.Domains.available_email_domains_for_user(user_id) + + if String.downcase(domain) in available_domains do + # Check if user's username matches the local part + user = Elektrine.Accounts.get_user!(user_id) + + if String.downcase(user.username) == String.downcase(username) do + {:ok, :domain_variant} + else + # Not the user's main email - this is fine, they might have aliases + {:error, :not_main_email} + end + else + {:error, :unsupported_domain} + end + + _ -> + {:error, :invalid_email_format} + end + end + + @doc """ + Extracts email address from a string that may contain display name. + Examples: + - "user@example.com" -> "user@example.com" + - "John Doe " -> "user@example.com" + - "\"Doe, John\" " -> "user@example.com" + """ + def extract_email_address(email_string) when is_binary(email_string) do + case Regex.run(~r/<([^>]+)>/, email_string) do + [_, email] -> String.trim(email) + nil -> String.trim(email_string) + end + end + + def extract_email_address(nil), do: nil + + @doc """ + Normalizes email addresses by removing plus addressing. + Converts "user+tag@domain.com" to "user@domain.com". + Returns the original email if no plus addressing is found. + """ + def normalize_plus_address(email) when is_binary(email) do + case String.split(email, "@") do + [local_part, domain] -> + # Remove everything after + in the local part + normalized_local = + case String.split(local_part, "+") do + [base | _] -> base + [] -> local_part + end + + normalized_local <> "@" <> domain + + _ -> + # Invalid email format, return as is + email + end + end + + def normalize_plus_address(nil), do: nil + + # Delegate blocked sender functions to BlockedSenders module + defdelegate list_blocked_senders(user_id), to: Elektrine.Email.BlockedSenders + defdelegate get_blocked_sender(id, user_id), to: Elektrine.Email.BlockedSenders + defdelegate create_blocked_sender(attrs), to: Elektrine.Email.BlockedSenders + defdelegate block_email(user_id, email, reason \\ nil), to: Elektrine.Email.BlockedSenders + defdelegate block_domain(user_id, domain, reason \\ nil), to: Elektrine.Email.BlockedSenders + defdelegate update_blocked_sender(blocked_sender, attrs), to: Elektrine.Email.BlockedSenders + defdelegate delete_blocked_sender(blocked_sender), to: Elektrine.Email.BlockedSenders + defdelegate unblock_email(user_id, email), to: Elektrine.Email.BlockedSenders + defdelegate unblock_domain(user_id, domain), to: Elektrine.Email.BlockedSenders + defdelegate blocked?(user_id, from_email), to: Elektrine.Email.BlockedSenders + + defdelegate change_blocked_sender(blocked_sender, attrs \\ %{}), + to: Elektrine.Email.BlockedSenders + + # Delegate suppression functions to Suppressions module + defdelegate suppress_recipient(user_id, email, opts \\ []), to: Elektrine.Email.Suppressions + defdelegate unsuppress_recipient(user_id, email), to: Elektrine.Email.Suppressions + defdelegate suppressed?(user_id, email), to: Elektrine.Email.Suppressions + defdelegate get_active_suppression(user_id, email), to: Elektrine.Email.Suppressions + defdelegate list_active_suppressions(user_id, opts \\ []), to: Elektrine.Email.Suppressions + + defdelegate filter_suppressed_recipients(user_id, recipients, opts \\ []), + to: Elektrine.Email.Suppressions + + # Delegate safe sender functions to SafeSenders module + defdelegate list_safe_senders(user_id), to: Elektrine.Email.SafeSenders + defdelegate get_safe_sender(id, user_id), to: Elektrine.Email.SafeSenders + defdelegate create_safe_sender(attrs), to: Elektrine.Email.SafeSenders + defdelegate add_safe_email(user_id, email), to: Elektrine.Email.SafeSenders + defdelegate add_safe_domain(user_id, domain), to: Elektrine.Email.SafeSenders + defdelegate update_safe_sender(safe_sender, attrs), to: Elektrine.Email.SafeSenders + defdelegate delete_safe_sender(safe_sender), to: Elektrine.Email.SafeSenders + defdelegate remove_safe_email(user_id, email), to: Elektrine.Email.SafeSenders + defdelegate remove_safe_domain(user_id, domain), to: Elektrine.Email.SafeSenders + defdelegate safe?(user_id, from_email), to: Elektrine.Email.SafeSenders + defdelegate change_safe_sender(safe_sender, attrs \\ %{}), to: Elektrine.Email.SafeSenders + + # Delegate filter functions to Filters module + defdelegate list_filters(user_id), to: Elektrine.Email.Filters + defdelegate list_enabled_filters(user_id), to: Elektrine.Email.Filters + defdelegate get_filter(id, user_id), to: Elektrine.Email.Filters + defdelegate create_filter(attrs), to: Elektrine.Email.Filters + defdelegate update_filter(filter, attrs), to: Elektrine.Email.Filters + defdelegate delete_filter(filter), to: Elektrine.Email.Filters + defdelegate toggle_filter(filter), to: Elektrine.Email.Filters + defdelegate change_filter(filter, attrs \\ %{}), to: Elektrine.Email.Filters + defdelegate apply_filters(user_id, message), to: Elektrine.Email.Filters + defdelegate execute_actions(message, actions), to: Elektrine.Email.Filters + + # Delegate auto-reply functions to AutoReplies module + defdelegate get_auto_reply(user_id), to: Elektrine.Email.AutoReplies + defdelegate upsert_auto_reply(user_id, attrs), to: Elektrine.Email.AutoReplies + defdelegate enable_auto_reply(user_id), to: Elektrine.Email.AutoReplies + defdelegate disable_auto_reply(user_id), to: Elektrine.Email.AutoReplies + defdelegate delete_auto_reply(user_id), to: Elektrine.Email.AutoReplies + defdelegate change_auto_reply(auto_reply, attrs \\ %{}), to: Elektrine.Email.AutoReplies + defdelegate process_auto_reply(message, user_id), to: Elektrine.Email.AutoReplies + + # Delegate template functions to Templates module + defdelegate list_templates(user_id), to: Elektrine.Email.Templates + defdelegate get_template(id, user_id), to: Elektrine.Email.Templates + defdelegate get_template_by_name(name, user_id), to: Elektrine.Email.Templates + defdelegate create_template(attrs), to: Elektrine.Email.Templates + defdelegate update_template(template, attrs), to: Elektrine.Email.Templates + defdelegate delete_template(template), to: Elektrine.Email.Templates + defdelegate change_template(template, attrs \\ %{}), to: Elektrine.Email.Templates + defdelegate count_templates(user_id), to: Elektrine.Email.Templates + defdelegate duplicate_template(template, new_name), to: Elektrine.Email.Templates + + # Delegate custom folder functions to CustomFolders module + defdelegate list_custom_folders(user_id), to: Elektrine.Email.CustomFolders, as: :list_folders + defdelegate list_root_folders(user_id), to: Elektrine.Email.CustomFolders + defdelegate get_custom_folder(id, user_id), to: Elektrine.Email.CustomFolders, as: :get_folder + defdelegate get_folder_with_children(id, user_id), to: Elektrine.Email.CustomFolders + defdelegate create_custom_folder(attrs), to: Elektrine.Email.CustomFolders, as: :create_folder + + defdelegate update_custom_folder(folder, attrs), + to: Elektrine.Email.CustomFolders, + as: :update_folder + + defdelegate delete_custom_folder(folder), to: Elektrine.Email.CustomFolders, as: :delete_folder + defdelegate move_message_to_folder(message_id, folder_id), to: Elektrine.Email.CustomFolders + + defdelegate list_folder_messages(folder_id, user_id, page \\ 1, per_page \\ 20), + to: Elektrine.Email.CustomFolders + + defdelegate get_folder_tree(user_id), to: Elektrine.Email.CustomFolders + + # Delegate label functions to Labels module + defdelegate list_labels(user_id), to: Elektrine.Email.Labels + defdelegate get_label(id, user_id), to: Elektrine.Email.Labels + defdelegate get_label_by_name(name, user_id), to: Elektrine.Email.Labels + defdelegate create_label(attrs), to: Elektrine.Email.Labels + defdelegate update_label(label, attrs), to: Elektrine.Email.Labels + defdelegate delete_label(label), to: Elektrine.Email.Labels + defdelegate change_label(label, attrs \\ %{}), to: Elektrine.Email.Labels + defdelegate count_labels(user_id), to: Elektrine.Email.Labels + defdelegate add_label_to_message(message_id, label_id), to: Elektrine.Email.Labels + defdelegate remove_label_from_message(message_id, label_id), to: Elektrine.Email.Labels + defdelegate get_message_labels(message_id, user_id), to: Elektrine.Email.Labels + + defdelegate list_labeled_messages(label_id, user_id, page \\ 1, per_page \\ 20), + to: Elektrine.Email.Labels + + defdelegate set_message_labels(message_id, label_ids), to: Elektrine.Email.Labels + + # Delegate export functions to Exports module + defdelegate list_exports(user_id), to: Elektrine.Email.Exports + defdelegate get_export(id, user_id), to: Elektrine.Email.Exports + defdelegate create_export(attrs), to: Elektrine.Email.Exports + defdelegate start_export(user_id, format \\ "mbox", filters \\ %{}), to: Elektrine.Email.Exports + defdelegate process_export(export), to: Elektrine.Email.Exports + defdelegate delete_export(export), to: Elektrine.Email.Exports + defdelegate get_download_path(export), to: Elektrine.Email.Exports +end diff --git a/apps/elektrine_email/lib/elektrine/email/alias.ex b/apps/elektrine_email/lib/elektrine/email/alias.ex new file mode 100644 index 0000000..a80cb28 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/alias.ex @@ -0,0 +1,482 @@ +defmodule Elektrine.Email.Alias do + @moduledoc """ + Schema for email aliases with comprehensive validation and forwarding chain protection. + Supports multiple aliases per user with loop detection, domain restrictions, and reserved address protection. + """ + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query, warn: false + + alias Elektrine.Accounts.User + + schema "email_aliases" do + field :alias_email, :string + field :target_email, :string + field :enabled, :boolean, default: true + field :description, :string + field :catch_all, :boolean, default: false + field :delivery_mode, :string, default: "deliver" + field :auto_label, :string + field :expires_at, :utc_datetime + field :last_used_at, :utc_datetime + field :received_count, :integer, default: 0 + field :forwarded_count, :integer, default: 0 + + belongs_to :user, User + + timestamps() + end + + def changeset(alias, attrs) do + alias + |> cast(attrs, [ + :alias_email, + :target_email, + :enabled, + :description, + :catch_all, + :delivery_mode, + :auto_label, + :expires_at, + :last_used_at, + :received_count, + :forwarded_count, + :user_id + ]) + |> normalize_alias_email() + |> default_delivery_mode(attrs) + |> validate_required([:alias_email, :user_id]) + |> validate_format(:alias_email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, + message: "must be a valid email format" + ) + |> validate_alias_local_part() + |> validate_alias_domain() + |> validate_catch_all_domain() + |> validate_no_case_conflicts() + |> validate_alias_not_mailbox() + |> validate_optional_target_email() + |> validate_inclusion(:delivery_mode, ["deliver", "forward"]) + |> validate_forwarding_target_for_mode() + |> validate_alias_limit() + |> validate_one_alias_per_domain() + |> validate_no_forwarding_loops() + |> validate_length(:alias_email, max: 255) + |> validate_length(:target_email, max: 255) + |> validate_length(:auto_label, max: 64) + |> validate_length(:description, max: 500) + |> unique_constraint(:alias_email, + name: :email_aliases_alias_email_ci_unique, + message: "that email alias is already in use" + ) + |> validate_alias_not_target() + |> validate_not_reserved_address() + end + + defp validate_no_case_conflicts(changeset) do + alias_email = get_field(changeset, :alias_email) + alias_id = get_field(changeset, :id) + + if alias_email do + # Check for case-insensitive duplicates + query = + from(a in Elektrine.Email.Alias, + where: fragment("lower(?)", a.alias_email) == ^String.downcase(alias_email) + ) + + # Exclude current record if updating + query = + if alias_id do + from(a in query, where: a.id != ^alias_id) + else + query + end + + case Elektrine.Repo.one(query) do + nil -> + changeset + + _existing -> + add_error( + changeset, + :alias_email, + "that email alias is already in use" + ) + end + else + changeset + end + end + + defp validate_alias_domain(changeset) do + alias_email = get_field(changeset, :alias_email) + user_id = get_field(changeset, :user_id) + + if alias_email do + # Extract domain from email + case String.split(alias_email, "@") do + [_local, domain] -> + domain = String.downcase(domain) + allowed_domains = allowed_alias_domains(changeset, user_id) + + if domain in allowed_domains do + changeset + else + add_error( + changeset, + :alias_email, + alias_domain_error(changeset) + ) + end + + _ -> + # Invalid email format, but this will be caught by the format validation + changeset + end + else + changeset + end + end + + defp allowed_alias_domains(changeset, user_id) do + if get_field(changeset, :catch_all) == true do + Elektrine.Email.CustomDomains.verified_domains_for_user(user_id) + else + Elektrine.Domains.available_email_domains_for_user(user_id) + end + end + + defp alias_domain_error(changeset) do + if get_field(changeset, :catch_all) == true do + "catch-all aliases require one of your verified custom email domains" + else + "choose one of your available email domains" + end + end + + defp validate_catch_all_domain(changeset) do + alias_email = get_field(changeset, :alias_email) + + if get_field(changeset, :catch_all) == true and alias_email do + case String.split(alias_email, "@", parts: 2) do + ["*", domain] -> + domain = String.downcase(domain) + + if domain in Elektrine.Domains.supported_email_domains() do + add_error( + changeset, + :alias_email, + "catch-all aliases require one of your verified custom email domains" + ) + else + changeset + end + + _ -> + changeset + end + else + changeset + end + end + + defp validate_forwarding_target_for_mode(changeset) do + if get_field(changeset, :delivery_mode) == "forward" and + not Elektrine.Strings.present?(get_field(changeset, :target_email)) do + add_error(changeset, :target_email, "is required when forwarding is enabled") + else + changeset + end + end + + defp default_delivery_mode(changeset, attrs) do + explicit? = Map.has_key?(attrs, :delivery_mode) or Map.has_key?(attrs, "delivery_mode") + + if explicit? do + changeset + else + mode = + if Elektrine.Strings.present?(get_field(changeset, :target_email)), + do: "forward", + else: "deliver" + + put_change(changeset, :delivery_mode, mode) + end + end + + defp validate_alias_not_mailbox(changeset) do + alias_email = get_field(changeset, :alias_email) + + if alias_email do + case Elektrine.Email.Mailboxes.get_mailbox_by_email(alias_email) do + nil -> + # Also check if this email would conflict with existing usernames + validate_alias_not_username(changeset, alias_email) + + _mailbox -> + add_error(changeset, :alias_email, "that email address is already in use") + end + else + changeset + end + end + + defp validate_alias_not_username(changeset, alias_email) do + # Extract local part from email (before @) + case String.split(alias_email, "@") do + [local_part, domain] -> + allowed_domains = Elektrine.Domains.supported_email_domains() + + # Only check for username conflicts on our domains + if String.downcase(domain) in allowed_domains do + # Check if local part matches any existing username (case-insensitive) + query = + from(u in Elektrine.Accounts.User, + where: fragment("lower(?)", u.username) == ^String.downcase(local_part), + limit: 1 + ) + + case Elektrine.Repo.one(query) do + nil -> + changeset + + _user -> + add_error(changeset, :alias_email, "that email address is not available") + end + else + changeset + end + + _ -> + changeset + end + end + + defp validate_optional_target_email(changeset) do + target_email = get_field(changeset, :target_email) + + if Elektrine.Strings.present?(target_email) do + validate_format(changeset, :target_email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, + message: "must be a valid email format" + ) + else + changeset + end + end + + defp validate_alias_not_target(changeset) do + alias_email = get_field(changeset, :alias_email) + target_email = get_field(changeset, :target_email) + + if alias_email && Elektrine.Strings.present?(target_email) && + alias_email == target_email do + add_error(changeset, :target_email, "cannot be the same as the alias email") + else + changeset + end + end + + defp validate_alias_limit(changeset) do + user_id = get_field(changeset, :user_id) + + if user_id do + # Check if user is an admin - admins have unlimited aliases + user = Elektrine.Repo.get(Elektrine.Accounts.User, user_id) + + if user && user.is_admin do + # Admin users have no alias limit + changeset + else + # Check if this is a new alias (no ID) or an existing one being updated + alias_id = get_field(changeset, :id) + + # Count existing aliases for this user, excluding the current one if updating + existing_count = + if alias_id do + Elektrine.Repo.aggregate( + from(a in Elektrine.Email.Alias, + where: a.user_id == ^user_id and a.id != ^alias_id + ), + :count + ) + else + Elektrine.Repo.aggregate( + from(a in Elektrine.Email.Alias, where: a.user_id == ^user_id), + :count + ) + end + + if existing_count >= 15 do + add_error(changeset, :alias_email, "you have reached the limit of 15 aliases") + else + changeset + end + end + else + changeset + end + end + + defp validate_one_alias_per_domain(changeset) do + # Per-domain restrictions removed - users can now have up to 4 aliases total + # across any combination of domains + changeset + end + + defp validate_not_reserved_address(changeset) do + alias_email = get_field(changeset, :alias_email) + user_id = get_field(changeset, :user_id) + + if alias_email && user_id do + # Check if user is an admin - admins can create reserved aliases + user = Elektrine.Repo.get(Elektrine.Accounts.User, user_id) + + if user && user.is_admin do + # Admin users can create aliases with reserved addresses + changeset + else + reserved_addresses = Elektrine.Domains.reserved_addresses() + + if String.downcase(alias_email) in reserved_addresses do + add_error( + changeset, + :alias_email, + "that email address is not available" + ) + else + changeset + end + end + else + changeset + end + end + + defp normalize_alias_email(changeset) do + case get_field(changeset, :alias_email) do + nil -> + changeset + + email -> + # Normalize email to lowercase + normalized = String.downcase(email) + put_change(changeset, :alias_email, normalized) + end + end + + defp validate_alias_local_part(changeset) do + alias_email = get_field(changeset, :alias_email) + + if alias_email do + case String.split(alias_email, "@") do + [local_part, _domain] -> + cond do + get_field(changeset, :catch_all) == true and local_part == "*" -> + changeset + + # Check minimum length (4 characters minimum) + String.length(local_part) < 4 -> + add_error( + changeset, + :alias_email, + "use at least 4 letters or numbers before the @" + ) + + # Check maximum length (30 characters maximum, same as usernames) + String.length(local_part) > 30 -> + add_error( + changeset, + :alias_email, + "use 30 or fewer letters or numbers before the @" + ) + + # Apply same validation as usernames - only alphanumeric + not String.match?(local_part, ~r/^[a-zA-Z0-9]+$/) -> + add_error( + changeset, + :alias_email, + "use only letters and numbers before the @" + ) + + true -> + changeset + end + + _ -> + # Invalid email format, will be caught by format validation + changeset + end + else + changeset + end + end + + # Recursive forwarding chain detection to prevent cycles + defp validate_no_forwarding_loops(changeset) do + alias_email = get_field(changeset, :alias_email) + target_email = get_field(changeset, :target_email) + + if alias_email && Elektrine.Strings.present?(target_email) do + case detect_forwarding_loop(target_email, alias_email, [], 10) do + :loop_detected -> + add_error( + changeset, + :target_email, + "this forwarding address would create a loop" + ) + + :max_depth_reached -> + add_error( + changeset, + :target_email, + "this forwarding chain is too long" + ) + + :safe -> + changeset + end + else + changeset + end + end + + # Recursively trace forwarding chain to detect loops + defp detect_forwarding_loop(_target, _original, _visited, depth) when depth <= 0 do + :max_depth_reached + end + + defp detect_forwarding_loop(target, original, visited, depth) do + target = String.downcase(String.trim(target)) + original = String.downcase(String.trim(original)) + + cond do + # Direct loop: target points back to original + target == original -> + :loop_detected + + # Visited this email before in the chain + target in visited -> + :loop_detected + + # Check if target is an alias that forwards elsewhere + true -> + case Elektrine.Repo.get_by(Elektrine.Email.Alias, alias_email: target) do + %Elektrine.Email.Alias{target_email: next_target, enabled: true} + when is_binary(next_target) -> + if Elektrine.Strings.present?(next_target) do + detect_forwarding_loop( + next_target, + original, + [target | visited], + depth - 1 + ) + else + :ok + end + + _ -> + # Target is not an alias or doesn't forward - chain ends safely + :safe + end + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/aliases.ex b/apps/elektrine_email/lib/elektrine/email/aliases.ex new file mode 100644 index 0000000..46ebefd --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/aliases.ex @@ -0,0 +1,263 @@ +defmodule Elektrine.Email.Aliases do + @moduledoc """ + Email alias management. + Handles creation, retrieval, updates, and resolution of email aliases. + """ + + import Ecto.Query, warn: false + alias Elektrine.Email.Alias + alias Elektrine.Repo + + @doc """ + Returns the list of email aliases for a user. + """ + def list_aliases(user_id) do + Alias + |> where(user_id: ^user_id) + |> order_by([a], desc: a.inserted_at) + |> Repo.all() + end + + @doc """ + Gets a single alias by ID for a specific user. + """ + def get_alias(id, user_id) do + Alias + |> where(id: ^id, user_id: ^user_id) + |> Repo.one() + end + + @doc """ + Gets an alias by alias email address. + """ + def get_alias_by_email(alias_email) do + # Normalize to lowercase for case-insensitive lookup + lower_email = String.downcase(alias_email) + + # First try case-insensitive lookup + case Alias |> where([a], fragment("lower(?)", a.alias_email) == ^lower_email) |> Repo.one() do + %Alias{} = alias_record -> + alias_record + + nil -> + get_plus_address_alias(alias_email) || get_catch_all_alias(alias_email) + end + end + + defp get_plus_address_alias(alias_email) do + case String.split(alias_email, "@", parts: 2) do + [username_part, domain] -> + domain = String.downcase(domain) + + if Elektrine.Domains.receiving_email_domain?(domain) do + base_username = username_part |> String.split("+") |> List.first() + base_email = "#{String.downcase(base_username)}@#{domain}" + + Alias + |> where([a], fragment("lower(?)", a.alias_email) == ^base_email) + |> Repo.one() + end + + _ -> + nil + end + end + + defp get_catch_all_alias(alias_email) do + case String.split(alias_email, "@", parts: 2) do + [_local_part, domain] -> + catch_all_email = "*@#{String.downcase(domain)}" + + Alias + |> where([a], a.catch_all == true) + |> where([a], fragment("lower(?)", a.alias_email) == ^catch_all_email) + |> order_by([a], desc: a.inserted_at) + |> limit(1) + |> Repo.one() + + _ -> + nil + end + end + + @doc """ + Creates an email alias. + + Preferred attrs: `username`, `domain`, and `user_id`. + Also accepts a full `alias_email` (or map with string keys) via the changeset. + """ + def create_alias(attrs \\ %{}) do + case attrs do + %{username: username, domain: domain, user_id: user_id} + when is_binary(username) and is_binary(domain) and is_integer(user_id) -> + create_single_domain_alias(username, domain, user_id, attrs) + + %{"username" => username, "domain" => domain, "user_id" => user_id} + when is_binary(username) and is_binary(domain) -> + create_single_domain_alias_with_parsed_user_id(username, domain, user_id, attrs) + + _ -> + insert_alias(attrs) + end + end + + defp create_single_domain_alias_with_parsed_user_id(username, domain, user_id, attrs) do + case parse_positive_int(user_id) do + {:ok, user_id} -> create_single_domain_alias(username, domain, user_id, attrs) + :error -> insert_alias(attrs) + end + end + + defp parse_positive_int(value) do + case Integer.parse(to_string(value)) do + {id, ""} when id > 0 -> {:ok, id} + _ -> :error + end + end + + defp create_single_domain_alias(username, domain, user_id, attrs) do + target_email = attrs[:target_email] || attrs["target_email"] || "" + description = attrs[:description] || attrs["description"] || "" + + alias_attrs = %{ + alias_email: "#{username}@#{domain}", + target_email: target_email, + description: description, + user_id: user_id, + enabled: true + } + + insert_alias(alias_attrs) + end + + defp insert_alias(attrs) do + result = + %Alias{} + |> Alias.changeset(attrs) + |> Repo.insert() + + case result do + {:ok, email_alias} -> + Elektrine.Email.Cached.invalidate_aliases(email_alias.user_id) + {:ok, email_alias} + + error -> + error + end + end + + @doc """ + Updates an email alias. + """ + def update_alias(%Alias{} = alias, attrs) do + result = + alias + |> Alias.changeset(attrs) + |> Repo.update() + + case result do + {:ok, updated_alias} -> + # Invalidate alias cache for this user + Elektrine.Email.Cached.invalidate_aliases(updated_alias.user_id) + {:ok, updated_alias} + + error -> + error + end + end + + @doc """ + Deletes an email alias. + """ + def delete_alias(%Alias{} = alias) do + import Ecto.Query + user_id = alias.user_id + + # First, nullify alias_id in any forwarded_messages that reference this alias + # This preserves the forwarding history for audit purposes + from(fm in Elektrine.Email.ForwardedMessage, where: fm.alias_id == ^alias.id) + |> Repo.update_all(set: [alias_id: nil]) + + result = Repo.delete(alias) + + case result do + {:ok, deleted_alias} -> + # Invalidate alias cache for this user + Elektrine.Email.Cached.invalidate_aliases(user_id) + {:ok, deleted_alias} + + error -> + error + end + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking alias changes. + """ + def change_alias(%Alias{} = alias, attrs \\ %{}) do + Alias.changeset(alias, attrs) + end + + def alias_expired?(%Alias{expires_at: nil}), do: false + + def alias_expired?(%Alias{expires_at: %DateTime{} = expires_at}) do + DateTime.compare(expires_at, DateTime.utc_now()) != :gt + end + + def alias_active?(%Alias{enabled: true} = alias), do: not alias_expired?(alias) + def alias_active?(%Alias{}), do: false + + def record_alias_delivery(alias, forwarded? \\ false) + + def record_alias_delivery(%Alias{} = alias, forwarded?) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + increments = + if forwarded?, do: [received_count: 1, forwarded_count: 1], else: [received_count: 1] + + from(a in Alias, where: a.id == ^alias.id) + |> Repo.update_all(inc: increments, set: [last_used_at: now]) + + :ok + end + + def record_alias_delivery(_, _), do: :ok + + @doc """ + Checks if an email address is an alias and returns the target email. + Returns nil if not an alias or if the matched alias has expired. + Returns :no_forward if alias exists but should deliver to the main mailbox. + """ + def resolve_alias(email) do + case get_alias_by_email(email) do + %Alias{enabled: true, delivery_mode: "forward", target_email: target_email} = alias + when is_binary(target_email) and target_email != "" -> + if alias_expired?(alias), do: nil, else: target_email + + %Alias{enabled: true, delivery_mode: "deliver"} = alias -> + if alias_expired?(alias), do: nil, else: :no_forward + + %Alias{enabled: true, target_email: target_email} = alias + when is_binary(target_email) and target_email != "" -> + if alias_expired?(alias), do: nil, else: target_email + + %Alias{enabled: true} = alias -> + if alias_expired?(alias), do: nil, else: :no_forward + + %Alias{catch_all: true} -> + nil + + %Alias{expires_at: %DateTime{}} -> + nil + + %Alias{enabled: false} -> + :no_forward + + %Alias{target_email: target_email} when is_nil(target_email) or target_email == "" -> + :no_forward + + nil -> + nil + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/attachment_storage.ex b/apps/elektrine_email/lib/elektrine/email/attachment_storage.ex new file mode 100644 index 0000000..4d5dbc5 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/attachment_storage.ex @@ -0,0 +1,420 @@ +defmodule Elektrine.Email.AttachmentStorage do + @moduledoc """ + Handles storage and retrieval of email attachments in local or S3 API storage. + """ + + require Logger + alias Elektrine.Security.FilePath + alias Elektrine.Telemetry.Events + alias ExAws.S3 + + @doc """ + Uploads an email attachment and returns the storage metadata. + """ + def upload_attachment(mailbox_id, message_id, attachment_id, content, metadata \\ %{}) do + key = generate_key(mailbox_id, message_id, attachment_id, metadata["filename"]) + bytes = byte_size(content) + content_type = metadata["content_type"] || "application/octet-stream" + + case storage_adapter() do + :local -> + upload_attachment_local(key, content, content_type, bytes) + + :s3 -> + upload_attachment_s3( + key, + content, + content_type, + metadata["filename"], + mailbox_id, + message_id, + attachment_id, + bytes + ) + end + end + + @doc """ + Downloads an attachment from S3 API storage. + """ + def download_attachment(storage_metadata) when is_map(storage_metadata) do + case storage_metadata do + %{"storage_type" => "s3", "bucket" => bucket, "key" => key} -> + download_from_s3(bucket, key) + + %{"storage_type" => "local", "key" => key} -> + download_from_local(key) + + %{"data" => data} when is_binary(data) -> + content = decode_attachment_data(data, storage_metadata) + + Events.upload(:email_attachment_download, :success, byte_size(content), %{ + source: "older" + }) + + {:ok, content} + + _ -> + Events.upload(:email_attachment_download, :failure, nil, %{ + reason: :invalid_storage_metadata + }) + + {:error, "Invalid storage metadata"} + end + end + + @doc """ + Generates a presigned URL for direct attachment download. + """ + def generate_presigned_url(storage_metadata, expires_in \\ 3600) do + case storage_metadata do + %{"storage_type" => "s3", "bucket" => bucket, "key" => key} -> + case validate_s3_metadata(bucket, key) do + :ok -> + config = ExAws.Config.new(:s3) + + {:ok, url} = + ExAws.S3.presigned_url(config, :get, bucket, key, + expires_in: expires_in, + virtual_host: false, + query_params: [{"response-content-disposition", "attachment"}] + ) + + Events.upload(:email_attachment_presigned_url, :success, nil, %{source: "s3"}) + {:ok, url} + + {:error, reason} -> + fail_presigned_url(reason) + end + + _ -> + Events.upload(:email_attachment_presigned_url, :failure, nil, %{ + reason: :invalid_storage_metadata + }) + + {:error, "Presigned URLs only available for S3 storage"} + end + end + + @doc """ + Deletes an attachment from storage. + """ + def delete_attachment(storage_metadata) do + case storage_metadata do + %{"storage_type" => "s3", "bucket" => bucket, "key" => key} -> + case validate_s3_metadata(bucket, key) do + :ok -> delete_from_s3(bucket, key) + {:error, reason} -> fail_delete_from_s3(reason) + end + + %{"storage_type" => "local", "key" => key} -> + delete_attachment_local(key) + + _ -> + Events.upload(:email_attachment_delete, :success, nil, %{source: "older"}) + :ok + end + end + + @doc """ + Migrates a older database-stored attachment into configured attachment storage. + """ + def migrate_attachment_to_s3(mailbox_id, message_id, attachment_id, attachment_data) do + content = + decode_attachment_data( + attachment_data["data"], + attachment_data + ) + + # Upload to S3 + upload_attachment( + mailbox_id, + message_id, + attachment_id, + content, + attachment_data + ) + end + + def stored_attachment?(attachment) when is_map(attachment) do + Map.get(attachment, "storage_type") in ["local", "s3"] + end + + def stored_attachment?(_), do: false + + defp fail_presigned_url(reason) do + Events.upload(:email_attachment_presigned_url, :failure, nil, %{ + reason: reason + }) + + {:error, "Invalid storage metadata"} + end + + defp delete_from_s3(bucket, key) do + case S3.delete_object(bucket, key) |> ExAws.request() do + {:ok, _} -> + Logger.info("Deleted attachment from S3: #{key}") + Events.upload(:email_attachment_delete, :success, nil, %{source: "s3"}) + :ok + + {:error, error} -> + Logger.error("Failed to delete attachment: #{inspect(error)}") + + Events.upload(:email_attachment_delete, :failure, nil, %{ + reason: inspect(error), + source: "s3" + }) + + {:error, error} + end + end + + defp fail_delete_from_s3(reason) do + Events.upload(:email_attachment_delete, :failure, nil, %{ + reason: reason, + source: "s3" + }) + + {:error, reason} + end + + defp download_from_s3(bucket, key) do + case validate_s3_metadata(bucket, key) do + :ok -> + case S3.get_object(bucket, key) |> ExAws.request() do + {:ok, %{body: content}} -> + Events.upload(:email_attachment_download, :success, byte_size(content), %{ + source: "s3" + }) + + {:ok, content} + + {:error, error} -> + Logger.error("Failed to download attachment from S3: #{inspect(error)}") + + Events.upload(:email_attachment_download, :failure, nil, %{ + reason: inspect(error), + source: "s3" + }) + + {:error, "Failed to download attachment"} + end + + {:error, reason} -> + Events.upload(:email_attachment_download, :failure, nil, %{ + reason: reason, + source: "s3" + }) + + {:error, "Failed to download attachment"} + end + end + + defp download_from_local(key) do + with {:ok, path} <- local_storage_path(key), + {:ok, content} <- File.read(path) do + Events.upload(:email_attachment_download, :success, byte_size(content), %{source: "local"}) + + {:ok, content} + else + {:error, error} -> + Logger.error("Failed to download attachment from local storage: #{inspect(error)}") + + Events.upload(:email_attachment_download, :failure, nil, %{ + reason: inspect(error), + source: "local" + }) + + {:error, "Failed to download attachment"} + end + end + + defp decode_attachment_data(data, metadata) do + case metadata["encoding"] do + "base64" -> + case Base.decode64(data) do + {:ok, decoded} -> decoded + :error -> data + end + + _ -> + data + end + end + + defp generate_key(mailbox_id, message_id, attachment_id, filename) do + ext = Path.extname(filename || "") + safe_filename = "#{sanitize_key_part(attachment_id)}#{ext}" + + "email-attachments/mailbox_#{mailbox_id}/message_#{message_id}/#{safe_filename}" + end + + defp upload_attachment_local(key, content, _content_type, bytes) do + with {:ok, path} <- local_storage_path(key), + :ok <- File.mkdir_p(Path.dirname(path)), + :ok <- File.write(path, content) do + Logger.info("Successfully uploaded attachment to local storage: #{key}") + Events.upload(:email_attachment, :success, bytes, %{source: "local"}) + + {:ok, + %{ + "storage_type" => "local", + "key" => key, + "size" => bytes, + "uploaded_at" => DateTime.utc_now() |> DateTime.to_iso8601() + }} + else + {:error, error} -> + Logger.error("Failed to upload attachment to local storage: #{inspect(error)}") + + Events.upload(:email_attachment, :failure, bytes, %{ + reason: inspect(error), + source: "local" + }) + + {:error, "Failed to upload attachment"} + end + end + + defp upload_attachment_s3( + key, + content, + content_type, + original_filename, + mailbox_id, + message_id, + attachment_id, + bytes + ) do + bucket = get_bucket() + + opts = [ + content_type: content_type, + metadata: %{ + "mailbox-id" => to_string(mailbox_id), + "message-id" => to_string(message_id), + "attachment-id" => attachment_id, + "original-filename" => original_filename || "attachment" + } + ] + + case S3.put_object(bucket, key, content, opts) |> ExAws.request() do + {:ok, _result} -> + Logger.info("Successfully uploaded attachment #{attachment_id} for message #{message_id}") + Events.upload(:email_attachment, :success, bytes, %{source: "s3"}) + + {:ok, + %{ + "storage_type" => "s3", + "bucket" => bucket, + "key" => key, + "size" => bytes, + "uploaded_at" => DateTime.utc_now() |> DateTime.to_iso8601() + }} + + {:error, error} -> + Logger.error("Failed to upload attachment: #{inspect(error)}") + Events.upload(:email_attachment, :failure, bytes, %{reason: inspect(error), source: "s3"}) + {:error, "Failed to upload attachment"} + end + end + + defp delete_attachment_local(key) do + case local_storage_path(key) do + {:ok, path} -> + case File.rm(path) do + :ok -> + Logger.info("Deleted attachment from local storage: #{key}") + Events.upload(:email_attachment_delete, :success, nil, %{source: "local"}) + :ok + + {:error, :enoent} -> + Events.upload(:email_attachment_delete, :success, nil, %{source: "local"}) + :ok + + {:error, error} -> + Logger.error("Failed to delete local attachment: #{inspect(error)}") + + Events.upload(:email_attachment_delete, :failure, nil, %{ + reason: inspect(error), + source: "local" + }) + + {:error, error} + end + + {:error, error} -> + Logger.error("Failed to delete local attachment: #{inspect(error)}") + {:error, error} + end + end + + defp local_storage_path(key) when is_binary(key) do + uploads_dir = + Application.get_env(:elektrine, :uploads, [])[:uploads_dir] || "priv/static/uploads" + + with :ok <- validate_storage_key(key), + {:ok, path} <- FilePath.validate_child_path(Path.join(uploads_dir, key), uploads_dir) do + {:ok, path} + else + {:error, :unsafe_path} -> {:error, :invalid_storage_key} + {:error, reason} -> {:error, reason} + end + end + + defp local_storage_path(_), do: {:error, :invalid_storage_key} + + defp validate_s3_metadata(bucket, key) do + if bucket == get_bucket() do + validate_storage_key(key) + else + {:error, :invalid_storage_bucket} + end + rescue + _ -> {:error, :invalid_storage_bucket} + end + + defp validate_storage_key(key) when is_binary(key) do + cond do + key == "" -> + {:error, :invalid_storage_key} + + String.starts_with?(key, ["/", "\\"]) -> + {:error, :invalid_storage_key} + + String.contains?(key, ["..", "\\", "\0", "//"]) -> + {:error, :invalid_storage_key} + + String.match?(key, ~r/[\x00-\x1F\x7F]/) -> + {:error, :invalid_storage_key} + + not String.starts_with?(key, "email-attachments/") -> + {:error, :invalid_storage_key} + + true -> + :ok + end + end + + defp validate_storage_key(_), do: {:error, :invalid_storage_key} + + defp sanitize_key_part(value) do + value + |> to_string() + |> String.replace(~r/[^A-Za-z0-9_-]/, "_") + |> String.slice(0, 120) + |> case do + "" -> "attachment" + sanitized -> sanitized + end + end + + defp storage_adapter do + Application.get_env(:elektrine, :uploads, [])[:adapter] || :local + end + + defp get_bucket do + Application.get_env(:elektrine, :uploads)[:bucket] || + raise "S3 bucket not configured" + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/auto_replies.ex b/apps/elektrine_email/lib/elektrine/email/auto_replies.ex new file mode 100644 index 0000000..06db2db --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/auto_replies.ex @@ -0,0 +1,189 @@ +defmodule Elektrine.Email.AutoReplies do + @moduledoc """ + Context module for managing auto-reply/vacation responder settings. + """ + import Ecto.Query + alias Elektrine.Email.AutoReply + alias Elektrine.Repo + + require Logger + + @doc """ + Gets the auto-reply settings for a user. + Creates default settings if none exist. + """ + def get_auto_reply(user_id) do + case Repo.get_by(AutoReply, user_id: user_id) do + nil -> + # Return a new struct with defaults (not persisted) + %AutoReply{user_id: user_id, enabled: false, body: ""} + + auto_reply -> + auto_reply + end + end + + @doc """ + Gets the auto-reply settings for a user, returns nil if not set. + """ + def get_auto_reply!(user_id) do + Repo.get_by(AutoReply, user_id: user_id) + end + + @doc """ + Creates or updates auto-reply settings for a user. + """ + def upsert_auto_reply(user_id, attrs) do + case Repo.get_by(AutoReply, user_id: user_id) do + nil -> + %AutoReply{} + |> AutoReply.changeset(Map.put(attrs, :user_id, user_id)) + |> Repo.insert() + + existing -> + existing + |> AutoReply.changeset(attrs) + |> Repo.update() + end + end + + @doc """ + Enables auto-reply for a user. + """ + def enable_auto_reply(user_id) do + case Repo.get_by(AutoReply, user_id: user_id) do + nil -> + {:error, :not_configured} + + auto_reply -> + auto_reply + |> AutoReply.changeset(%{enabled: true}) + |> Repo.update() + end + end + + @doc """ + Disables auto-reply for a user. + """ + def disable_auto_reply(user_id) do + case Repo.get_by(AutoReply, user_id: user_id) do + nil -> + {:ok, nil} + + auto_reply -> + auto_reply + |> AutoReply.changeset(%{enabled: false}) + |> Repo.update() + end + end + + @doc """ + Deletes auto-reply settings for a user. + """ + def delete_auto_reply(user_id) do + case Repo.get_by(AutoReply, user_id: user_id) do + nil -> {:ok, nil} + auto_reply -> Repo.delete(auto_reply) + end + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking auto-reply changes. + """ + def change_auto_reply(%AutoReply{} = auto_reply, attrs \\ %{}) do + AutoReply.changeset(auto_reply, attrs) + end + + @doc """ + Checks if we've already sent an auto-reply to this sender. + """ + def has_replied_to?(user_id, sender_email) do + sender_email = normalize_sender_email(sender_email) + + query = + from l in "email_auto_reply_log", + where: l.user_id == ^user_id and l.sender_email == ^sender_email, + select: count(l.id) + + Repo.one(query) > 0 + end + + @doc """ + Records that we sent an auto-reply to a sender. + """ + def record_auto_reply(user_id, sender_email) do + sender_email = normalize_sender_email(sender_email) + now = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert_all( + "email_auto_reply_log", + [%{user_id: user_id, sender_email: sender_email, sent_at: now}], + on_conflict: :nothing + ) + + :ok + end + + @doc """ + Processes an incoming message and sends auto-reply if appropriate. + """ + def process_auto_reply(message, user_id) do + auto_reply = get_auto_reply!(user_id) + + if auto_reply && AutoReply.should_reply?(auto_reply, message, user_id) do + send_auto_reply(auto_reply, message, user_id) + else + :skip + end + end + + defp send_auto_reply(auto_reply, message, user_id) do + # Get user's mailbox for sending + mailbox = Elektrine.Email.get_user_mailbox(user_id) + + if mailbox do + subject = + auto_reply.subject || + "Re: #{message.subject || "Auto-Reply"}" + + email_attrs = %{ + from: mailbox.email, + to: extract_email(message.from), + subject: subject, + text_body: auto_reply.body, + html_body: auto_reply.html_body || auto_reply.body + } + + case Elektrine.Email.Sender.send_email(user_id, email_attrs) do + {:ok, _sent} -> + # Record that we replied to this sender + record_auto_reply(user_id, extract_email(message.from)) + Logger.info("Sent auto-reply to #{message.from} for user #{user_id}") + :sent + + {:error, reason} -> + Logger.error("Failed to send auto-reply: #{inspect(reason)}") + {:error, reason} + end + else + Logger.warning("No mailbox found for user #{user_id}, skipping auto-reply") + :skip + end + end + + defp extract_email(email_string) when is_binary(email_string) do + case Regex.run(~r/<([^>]+)>/, email_string) do + [_, email] -> String.trim(email) + nil -> String.trim(email_string) + end + end + + defp extract_email(_), do: "" + + defp normalize_sender_email(sender_email) do + sender_email + |> extract_email() + |> String.downcase() + |> String.trim() + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/auto_reply.ex b/apps/elektrine_email/lib/elektrine/email/auto_reply.ex new file mode 100644 index 0000000..2baf2d9 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/auto_reply.ex @@ -0,0 +1,162 @@ +defmodule Elektrine.Email.AutoReply do + @moduledoc """ + Schema for auto-reply/vacation responder settings. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "email_auto_replies" do + field :enabled, :boolean, default: false + field :subject, :string + field :body, :string + field :html_body, :string + field :start_date, :date + field :end_date, :date + field :only_contacts, :boolean, default: false + field :exclude_mailing_lists, :boolean, default: true + field :reply_once_per_sender, :boolean, default: true + + belongs_to :user, Elektrine.Accounts.User + + timestamps() + end + + @doc """ + Creates a changeset for auto-reply settings. + """ + def changeset(auto_reply, attrs) do + auto_reply + |> cast(attrs, [ + :enabled, + :subject, + :body, + :html_body, + :start_date, + :end_date, + :only_contacts, + :exclude_mailing_lists, + :reply_once_per_sender, + :user_id + ]) + |> validate_required([:body, :user_id]) + |> validate_length(:subject, max: 200) + |> validate_length(:body, min: 1, max: 10_000) + |> validate_date_range() + |> foreign_key_constraint(:user_id) + |> unique_constraint(:user_id) + end + + defp validate_date_range(changeset) do + start_date = get_field(changeset, :start_date) + end_date = get_field(changeset, :end_date) + + cond do + is_nil(start_date) || is_nil(end_date) -> + changeset + + Date.compare(start_date, end_date) == :gt -> + add_error(changeset, :end_date, "must be after start date") + + true -> + changeset + end + end + + @doc """ + Checks if auto-reply is currently active based on date range. + """ + def active?(%__MODULE__{enabled: false}), do: false + + def active?(%__MODULE__{enabled: true, start_date: nil, end_date: nil}), do: true + + def active?(%__MODULE__{enabled: true, start_date: start_date, end_date: end_date}) do + today = Date.utc_today() + + start_ok = is_nil(start_date) || Date.compare(today, start_date) != :lt + end_ok = is_nil(end_date) || Date.compare(today, end_date) != :gt + + start_ok && end_ok + end + + @doc """ + Checks if we should send an auto-reply for this message. + """ + def should_reply?(auto_reply, message, user_id) do + cond do + !active?(auto_reply) -> + false + + # Don't reply to mailing lists if configured + auto_reply.exclude_mailing_lists && mailing_list?(message) -> + false + + # Only reply to contacts if configured + auto_reply.only_contacts && !contact?(message.from, user_id) -> + false + + # Check if we already replied to this sender + auto_reply.reply_once_per_sender && already_replied?(message.from, user_id) -> + false + + # Don't reply to noreply addresses + noreply?(message.from) -> + false + + # Don't reply to our own emails + own_email?(message.from, user_id) -> + false + + true -> + true + end + end + + defp mailing_list?(message) do + message.is_newsletter || message.category == "bulk_mail" || + (message.metadata && Map.has_key?(message.metadata, "list_id")) + end + + defp contact?(from_email, user_id) do + email = extract_email(from_email) + Elektrine.Email.Contacts.get_contact_by_email(user_id, email) != nil + end + + defp already_replied?(from_email, user_id) do + email = extract_email(from_email) + Elektrine.Email.AutoReplies.has_replied_to?(user_id, email) + end + + defp noreply?(from_email) do + email = String.downcase(extract_email(from_email)) + + String.contains?(email, "noreply") || + String.contains?(email, "no-reply") || + String.contains?(email, "donotreply") || + String.contains?(email, "mailer-daemon") + end + + defp own_email?(from_email, user_id) do + email = String.downcase(extract_email(from_email)) + user = Elektrine.Accounts.get_user!(user_id) + mailbox = Elektrine.Email.get_user_mailbox(user_id) + + own_addresses = + Elektrine.Domains.email_addresses_for_user(user) + |> Enum.map(&String.downcase/1) + |> case do + addresses when is_map(mailbox) -> [String.downcase(mailbox.email) | addresses] + addresses -> addresses + end + + email in own_addresses + end + + defp extract_email(email_string) when is_binary(email_string) do + case Regex.run(~r/<([^>]+)>/, email_string) do + [_, email] -> String.trim(email) + nil -> String.trim(email_string) + end + end + + defp extract_email(_), do: "" +end diff --git a/apps/elektrine_email/lib/elektrine/email/blocked_sender.ex b/apps/elektrine_email/lib/elektrine/email/blocked_sender.ex new file mode 100644 index 0000000..ce8a469 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/blocked_sender.ex @@ -0,0 +1,71 @@ +defmodule Elektrine.Email.BlockedSender do + @moduledoc """ + Schema for blocked email senders. + Users can block individual email addresses or entire domains. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "email_blocked_senders" do + field :email, :string + field :domain, :string + field :reason, :string + + belongs_to :user, Elektrine.Accounts.User + + timestamps() + end + + @doc """ + Creates a changeset for blocking a sender. + Either email or domain must be provided, but not both. + """ + def changeset(blocked_sender, attrs) do + blocked_sender + |> cast(attrs, [:email, :domain, :reason, :user_id]) + |> validate_required([:user_id]) + |> validate_email_or_domain() + |> normalize_email() + |> normalize_domain() + |> foreign_key_constraint(:user_id) + |> unique_constraint([:user_id, :email]) + |> unique_constraint([:user_id, :domain]) + end + + defp validate_email_or_domain(changeset) do + email = get_field(changeset, :email) + domain = get_field(changeset, :domain) + + cond do + is_nil(email) && is_nil(domain) -> + add_error(changeset, :email, "either email or domain must be provided") + + !is_nil(email) && !is_nil(domain) -> + add_error(changeset, :email, "cannot block both email and domain in same entry") + + !is_nil(email) -> + validate_format(changeset, :email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, + message: "must be a valid email format" + ) + + !is_nil(domain) -> + validate_format(changeset, :domain, ~r/^[a-zA-Z0-9][a-zA-Z0-9\-\.]*\.[a-zA-Z]{2,}$/, + message: "must be a valid domain format" + ) + end + end + + defp normalize_email(changeset) do + case get_change(changeset, :email) do + nil -> changeset + email -> put_change(changeset, :email, String.downcase(String.trim(email))) + end + end + + defp normalize_domain(changeset) do + case get_change(changeset, :domain) do + nil -> changeset + domain -> put_change(changeset, :domain, String.downcase(String.trim(domain))) + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/blocked_senders.ex b/apps/elektrine_email/lib/elektrine/email/blocked_senders.ex new file mode 100644 index 0000000..6e24590 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/blocked_senders.ex @@ -0,0 +1,137 @@ +defmodule Elektrine.Email.BlockedSenders do + @moduledoc """ + Context module for managing blocked email senders. + """ + import Ecto.Query + alias Elektrine.Email.BlockedSender + alias Elektrine.Repo + + @doc """ + Lists all blocked senders for a user. + """ + def list_blocked_senders(user_id) do + BlockedSender + |> where(user_id: ^user_id) + |> order_by(desc: :inserted_at) + |> Repo.all() + end + + @doc """ + Gets a blocked sender by ID for a user. + """ + def get_blocked_sender(id, user_id) do + BlockedSender + |> where(id: ^id, user_id: ^user_id) + |> Repo.one() + end + + @doc """ + Creates a blocked sender entry. + """ + def create_blocked_sender(attrs) do + %BlockedSender{} + |> BlockedSender.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Blocks an email address. + """ + def block_email(user_id, email, reason \\ nil) do + create_blocked_sender(%{ + user_id: user_id, + email: email, + reason: reason + }) + end + + @doc """ + Blocks a domain. + """ + def block_domain(user_id, domain, reason \\ nil) do + create_blocked_sender(%{ + user_id: user_id, + domain: domain, + reason: reason + }) + end + + @doc """ + Updates a blocked sender entry. + """ + def update_blocked_sender(%BlockedSender{} = blocked_sender, attrs) do + blocked_sender + |> BlockedSender.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a blocked sender entry. + """ + def delete_blocked_sender(%BlockedSender{} = blocked_sender) do + Repo.delete(blocked_sender) + end + + @doc """ + Unblocks an email address for a user. + """ + def unblock_email(user_id, email) do + email = String.downcase(String.trim(email)) + + BlockedSender + |> where(user_id: ^user_id, email: ^email) + |> Repo.delete_all() + end + + @doc """ + Unblocks a domain for a user. + """ + def unblock_domain(user_id, domain) do + domain = String.downcase(String.trim(domain)) + + BlockedSender + |> where(user_id: ^user_id, domain: ^domain) + |> Repo.delete_all() + end + + @doc """ + Checks if an email address is blocked for a user. + """ + def blocked?(user_id, from_email) when is_binary(from_email) do + email = extract_email(from_email) |> String.downcase() + domain = extract_domain(email) + + query = + from b in BlockedSender, + where: b.user_id == ^user_id, + where: b.email == ^email or b.domain == ^domain, + select: count(b.id) + + Repo.one(query) > 0 + end + + def blocked?(_, _), do: false + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking blocked sender changes. + """ + def change_blocked_sender(%BlockedSender{} = blocked_sender, attrs \\ %{}) do + BlockedSender.changeset(blocked_sender, attrs) + end + + # Extract just the email address from "Name " format + defp extract_email(email_string) when is_binary(email_string) do + case Regex.run(~r/<([^>]+)>/, email_string) do + [_, email] -> String.trim(email) + nil -> String.trim(email_string) + end + end + + # Extract domain from email address + defp extract_domain(email) do + case String.split(email, "@") do + [_, domain] -> domain + _ -> "" + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/cache.ex b/apps/elektrine_email/lib/elektrine/email/cache.ex new file mode 100644 index 0000000..730a3c3 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/cache.ex @@ -0,0 +1,253 @@ +defmodule Elektrine.Email.Cache do + @moduledoc """ + Caching layer for email-related data to improve performance. + Uses Cachex for in-memory caching with automatic TTL and invalidation. + """ + + @cache_name :email_cache + @default_ttl :timer.minutes(5) + @counts_ttl :timer.minutes(1) + @messages_ttl :timer.minutes(3) + alias Elektrine.Telemetry.Events + + @doc """ + Starts the cache with configuration. + Called from the application supervision tree. + """ + def start_link(_opts) do + Cachex.start_link(@cache_name, + # Limit cache size to prevent memory issues + limit: 10_000, + # Default TTL for all entries + ttl: @default_ttl + ) + end + + def child_spec(opts) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, [opts]}, + type: :worker, + restart: :permanent, + shutdown: 5000 + } + end + + # Message count caching + + @doc """ + Gets cached message counts or fetches fresh data. + Accepts either a user_id or a string key for different count types. + """ + def get_counts(key, fetch_fn) when is_binary(key) do + cache_key = {:counts, key} + + case fetch_with_telemetry(cache_key, fn _key -> + {:commit, fetch_fn.(), ttl: @counts_ttl} + end) do + {:commit, value} -> {:ok, value} + {:commit, value, _opts} -> {:ok, value} + {:ok, value} -> {:ok, value} + error -> error + end + end + + def get_counts(user_id, fetch_fn) do + key = {:counts, user_id} + + case fetch_with_telemetry(key, fn _key -> + {:commit, fetch_fn.(), ttl: @counts_ttl} + end) do + {:commit, value} -> {:ok, value} + {:commit, value, _opts} -> {:ok, value} + {:ok, value} -> {:ok, value} + error -> error + end + end + + @doc """ + Invalidates count caches. + Accepts either a user_id or a string key. + """ + def invalidate_counts(key) when is_binary(key) do + cache_key = {:counts, key} + delete_with_telemetry(cache_key) + end + + def invalidate_counts(user_id) do + key = {:counts, user_id} + delete_with_telemetry(key) + end + + # Paginated messages caching + + @doc """ + Gets cached paginated messages or fetches fresh data. + """ + def get_messages(user_id, category, page, per_page, fetch_fn) do + key = {:messages, user_id, category, page, per_page} + + case fetch_with_telemetry(key, fn _key -> + {:commit, fetch_fn.(), ttl: @messages_ttl} + end) do + {:commit, value} -> {:ok, value} + {:commit, value, _opts} -> {:ok, value} + {:ok, value} -> {:ok, value} + error -> error + end + end + + @doc """ + Invalidates message cache for a specific category or all categories. + """ + def invalidate_messages(user_id, category \\ :all) do + if category == :all do + # Clear all message caches for the user + pattern = {:messages, user_id, :_, :_, :_} + clear_by_pattern(pattern) + else + # Clear specific category + pattern = {:messages, user_id, category, :_, :_} + clear_by_pattern(pattern) + end + end + + # Individual message caching + + @doc """ + Gets a cached individual message. + """ + def get_message(message_id) do + key = {:message, message_id} + get_with_telemetry(key) + end + + # Search results caching + + @doc """ + Caches search results with a shorter TTL. + """ + def get_search_results(user_id, query, filters, fetch_fn) do + key = {:search, user_id, query, filters} + + case fetch_with_telemetry(key, fn _key -> + {:commit, fetch_fn.(), ttl: :timer.minutes(1)} + end) do + {:commit, value} -> {:ok, value} + {:commit, value, _opts} -> {:ok, value} + {:ok, value} -> {:ok, value} + error -> error + end + end + + @doc """ + Invalidates all search result caches for a user. + """ + def invalidate_search_results(user_id) do + pattern = {:search, user_id, :_, :_} + clear_by_pattern(pattern) + end + + # Utility functions + + @doc """ + Gets cache statistics for monitoring. + """ + def stats do + Cachex.stats(@cache_name) + end + + @doc """ + Clears the entire cache. Use with caution! + """ + def clear_all do + clear_with_telemetry() + end + + # Private functions + + defp clear_by_pattern(pattern) do + # Note: Cachex doesn't support pattern matching directly, + # so we need to iterate through keys + {:ok, keys} = keys_with_telemetry() + + keys + |> Enum.filter(&matches_pattern?(&1, pattern)) + |> Enum.each(&delete_with_telemetry(&1)) + end + + defp matches_pattern?(key, pattern) when is_tuple(key) and is_tuple(pattern) do + key_list = Tuple.to_list(key) + pattern_list = Tuple.to_list(pattern) + + length(key_list) == length(pattern_list) and + Enum.zip(key_list, pattern_list) + |> Enum.all?(fn + {_key_elem, :_} -> true + {key_elem, pattern_elem} -> key_elem == pattern_elem + end) + end + + defp matches_pattern?(_, _), do: false + + defp fetch_with_telemetry(key, fetch_fn) do + result = Cachex.fetch(@cache_name, key, fetch_fn) + + fetch_result = + case result do + {:ok, _} -> :hit + {:commit, _} -> :miss + {:commit, _, _} -> :miss + _ -> :error + end + + emit_cache(:fetch, fetch_result, key) + result + end + + defp get_with_telemetry(key) do + result = Cachex.get(@cache_name, key) + + get_result = + case result do + {:ok, nil} -> :miss + {:ok, _} -> :hit + _ -> :error + end + + emit_cache(:get, get_result, key) + result + end + + defp delete_with_telemetry(key) do + result = Cachex.del(@cache_name, key) + emit_cache(:delete, cachex_result(result), key) + result + end + + defp clear_with_telemetry do + result = Cachex.clear(@cache_name) + emit_cache(:clear, cachex_result(result), :all) + result + end + + defp keys_with_telemetry do + result = Cachex.keys(@cache_name) + emit_cache(:keys, cachex_result(result), :all) + result + end + + defp cachex_result({:ok, _}), do: :ok + defp cachex_result(:ok), do: :ok + defp cachex_result(_), do: :error + + defp emit_cache(operation, result, key) do + Events.cache(:email_cache, operation, result, %{scope: cache_scope(key)}) + end + + defp cache_scope({scope, _}) when is_atom(scope), do: scope + defp cache_scope({scope, _, _}) when is_atom(scope), do: scope + defp cache_scope({scope, _, _, _}) when is_atom(scope), do: scope + defp cache_scope({scope, _, _, _, _}) when is_atom(scope), do: scope + defp cache_scope(_), do: :other +end diff --git a/apps/elektrine_email/lib/elektrine/email/cache_hooks.ex b/apps/elektrine_email/lib/elektrine/email/cache_hooks.ex new file mode 100644 index 0000000..68da052 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/cache_hooks.ex @@ -0,0 +1,42 @@ +defmodule Elektrine.Email.CacheHooks do + @moduledoc """ + Helper module to add cache invalidation hooks to Email context functions. + This module provides wrapper functions that invalidate relevant caches + when email data is modified. + """ + + alias Elektrine.Email + alias Elektrine.Email.Cached + + @doc """ + Adds cache invalidation to the result of an Email operation. + """ + def with_cache_invalidation(result, opts \\ []) + + def with_cache_invalidation({:ok, message} = result, opts) when is_map(message) do + mailbox_id = message.mailbox_id || opts[:mailbox_id] + user_id = opts[:user_id] || get_user_id_from_mailbox(mailbox_id) + categories = opts[:categories] || [:all] + + if mailbox_id && user_id do + Cached.invalidate_message_caches(mailbox_id, user_id, categories) + end + + result + end + + def with_cache_invalidation({:error, _} = result, _opts), do: result + def with_cache_invalidation(result, _opts), do: result + + @doc """ + Gets user_id from mailbox_id + """ + def get_user_id_from_mailbox(mailbox_id) when is_binary(mailbox_id) or is_integer(mailbox_id) do + case Email.get_mailbox_internal(mailbox_id) do + nil -> nil + mailbox -> mailbox.user_id + end + end + + def get_user_id_from_mailbox(_), do: nil +end diff --git a/apps/elektrine_email/lib/elektrine/email/cached.ex b/apps/elektrine_email/lib/elektrine/email/cached.ex new file mode 100644 index 0000000..2106ef3 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/cached.ex @@ -0,0 +1,370 @@ +defmodule Elektrine.Email.Cached do + @moduledoc """ + Cached versions of Email context functions. + This module provides cached wrappers around frequently-accessed Email functions + to improve performance and reduce database load. + """ + + alias Elektrine.AppCache + alias Elektrine.Email + alias Elektrine.Email.Cache + + @doc """ + Gets cached unread count for a mailbox. + """ + def unread_count(mailbox_id) do + {:ok, count} = + Cache.get_counts("mailbox:#{mailbox_id}:unread", fn -> + Email.unread_count(mailbox_id) + end) + + count + end + + @doc """ + Gets cached unread count for a user. + """ + def user_unread_count(user_id) do + {:ok, count} = + Cache.get_counts("user:#{user_id}:unread", fn -> + Email.user_unread_count(user_id) + end) + + count + end + + @doc """ + Gets cached paginated messages with automatic cache invalidation. + """ + def list_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + {:ok, result} = + Cache.get_messages(mailbox_id, :all, page, per_page, fn -> + Email.list_messages_paginated(mailbox_id, page, per_page) + end) + + result + end + + @doc """ + Gets cached inbox messages paginated. + """ + def list_inbox_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + {:ok, result} = + Cache.get_messages(mailbox_id, :inbox, page, per_page, fn -> + Email.list_inbox_messages_paginated(mailbox_id, page, per_page) + end) + + result + end + + @doc """ + Gets cached unread messages paginated. + """ + def list_unread_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + {:ok, result} = + Cache.get_messages(mailbox_id, :unread, page, per_page, fn -> + Email.list_unread_messages_paginated(mailbox_id, page, per_page) + end) + + result + end + + @doc """ + Gets cached read messages paginated. + """ + def list_read_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + {:ok, result} = + Cache.get_messages(mailbox_id, :read, page, per_page, fn -> + Email.list_read_messages_paginated(mailbox_id, page, per_page) + end) + + result + end + + def list_feed_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + {:ok, result} = + Cache.get_messages(mailbox_id, :feed, page, per_page, fn -> + Email.list_feed_messages_paginated(mailbox_id, page, per_page) + end) + + result + end + + @doc """ + Gets cached ledger messages paginated. + """ + def list_ledger_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + {:ok, result} = + Cache.get_messages(mailbox_id, :ledger, page, per_page, fn -> + Email.list_ledger_messages_paginated(mailbox_id, page, per_page) + end) + + result + end + + @doc """ + Gets cached stack messages paginated. + """ + def list_stack_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + {:ok, result} = + Cache.get_messages(mailbox_id, :stack, page, per_page, fn -> + Email.list_stack_messages_paginated(mailbox_id, page, per_page) + end) + + result + end + + @doc """ + Gets cached reply later messages paginated. + """ + def list_reply_later_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + {:ok, result} = + Cache.get_messages(mailbox_id, :reply_later, page, per_page, fn -> + Email.list_reply_later_messages_paginated(mailbox_id, page, per_page) + end) + + result + end + + @doc """ + Gets cached sent messages paginated. + """ + def list_sent_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + {:ok, result} = + Cache.get_messages(mailbox_id, :sent, page, per_page, fn -> + Email.list_sent_messages_paginated(mailbox_id, page, per_page) + end) + + result + end + + @doc """ + Gets cached draft messages paginated. + """ + def list_drafts_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + {:ok, result} = + Cache.get_messages(mailbox_id, :drafts, page, per_page, fn -> + Email.list_drafts_messages_paginated(mailbox_id, page, per_page) + end) + + result + end + + @doc """ + Gets cached spam messages paginated. + """ + def list_spam_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + {:ok, result} = + Cache.get_messages(mailbox_id, :spam, page, per_page, fn -> + Email.list_spam_messages_paginated(mailbox_id, page, per_page) + end) + + result + end + + @doc """ + Gets cached trash messages paginated. + """ + def list_trash_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + {:ok, result} = + Cache.get_messages(mailbox_id, :trash, page, per_page, fn -> + Email.list_trash_messages_paginated(mailbox_id, page, per_page) + end) + + result + end + + @doc """ + Gets cached archived messages paginated. + """ + def list_archived_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + {:ok, result} = + Cache.get_messages(mailbox_id, :archive, page, per_page, fn -> + Email.list_archived_messages_paginated(mailbox_id, page, per_page) + end) + + result + end + + @doc """ + Gets cached folder messages paginated. + """ + def list_folder_messages(mailbox_id, folder_id, user_id, page \\ 1, per_page \\ 20) do + {:ok, result} = + Cache.get_messages(mailbox_id, {:folder, folder_id}, page, per_page, fn -> + Email.list_folder_messages(folder_id, user_id, page, per_page) + end) + + result + end + + @doc """ + Gets cached search results. + """ + def search_messages(user_id, mailbox_id, query, page \\ 1, per_page \\ 20) do + {:ok, result} = + Cache.get_search_results(user_id, query, {mailbox_id, page, per_page}, fn -> + Email.search_messages(mailbox_id, query, page, per_page) + end) + + result + end + + @doc """ + Gets cached feed messages count. + """ + def feed_messages_count(mailbox_id) do + {:ok, count} = + Cache.get_counts("mailbox:#{mailbox_id}:feed", fn -> + Email.feed_messages_count(mailbox_id) + end) + + count + end + + @doc """ + Gets cached ledger messages count. + """ + def ledger_messages_count(mailbox_id) do + {:ok, count} = + Cache.get_counts("mailbox:#{mailbox_id}:ledger", fn -> + Email.ledger_messages_count(mailbox_id) + end) + + count + end + + @doc """ + Gets all unread counts for a mailbox in a single cached query. + This ensures consistency by fetching all counts from the same database snapshot. + + Returns a map with keys: :inbox, :feed, :ledger, :stack, :reply_later + """ + def get_all_unread_counts(mailbox_id) do + {:ok, counts} = + Cache.get_counts("mailbox:#{mailbox_id}:all_unread_counts", fn -> + Email.get_all_unread_counts(mailbox_id) + end) + + counts + end + + @doc """ + Gets cached unread feed messages count. + """ + def unread_feed_count(mailbox_id) do + get_all_unread_counts(mailbox_id).feed + end + + @doc """ + Gets cached unread ledger messages count. + """ + def unread_ledger_count(mailbox_id) do + get_all_unread_counts(mailbox_id).ledger + end + + @doc """ + Gets cached unread stack count for a mailbox. + """ + def unread_stack_count(mailbox_id) do + get_all_unread_counts(mailbox_id).stack + end + + @doc """ + Gets cached unread reply later count for a mailbox. + """ + def unread_reply_later_count(mailbox_id) do + get_all_unread_counts(mailbox_id).reply_later + end + + @doc """ + Gets cached unread inbox count for a mailbox. + """ + def unread_inbox_count(mailbox_id) do + get_all_unread_counts(mailbox_id).inbox + end + + @doc """ + Invalidates caches when a message is created, updated, or deleted. + Should be called from Email context functions that modify messages. + """ + def invalidate_message_caches(mailbox_id, user_id, categories \\ [:all]) do + # Invalidate counts + Cache.invalidate_counts("mailbox:#{mailbox_id}:unread") + Cache.invalidate_counts("user:#{user_id}:unread") + Cache.invalidate_counts("mailbox:#{mailbox_id}:feed") + Cache.invalidate_counts("mailbox:#{mailbox_id}:digest") + Cache.invalidate_counts("mailbox:#{mailbox_id}:ledger") + Cache.invalidate_counts("mailbox:#{mailbox_id}:unread_feed") + Cache.invalidate_counts("mailbox:#{mailbox_id}:unread_digest") + Cache.invalidate_counts("mailbox:#{mailbox_id}:unread_ledger") + Cache.invalidate_counts("mailbox:#{mailbox_id}:unread_inbox") + Cache.invalidate_counts("mailbox:#{mailbox_id}:unread_stack") + Cache.invalidate_counts("mailbox:#{mailbox_id}:unread_reply_later") + Cache.invalidate_counts("mailbox:#{mailbox_id}:all_unread_counts") + + # Invalidate message lists + if categories == [:all] do + Cache.invalidate_messages(mailbox_id, :all) + else + Enum.each(categories, fn category -> + Cache.invalidate_messages(mailbox_id, category) + end) + end + + # Also clear search results as they might be affected + Cache.invalidate_search_results(user_id) + end + + # Alias management + + @doc """ + Gets cached aliases for a user. + """ + def get_aliases(user_id) do + {:ok, aliases} = + AppCache.get_aliases(user_id, fn -> + Email.list_aliases(user_id) + end) + + aliases + end + + @doc """ + Invalidates cached aliases for a user. + """ + def invalidate_aliases(user_id) do + AppCache.invalidate_aliases(user_id) + end + + @doc """ + Gets cached mailbox settings. + """ + def get_mailbox_settings(mailbox_id) do + {:ok, settings} = + AppCache.get_mailbox_settings(mailbox_id, fn -> + Email.get_mailbox_internal(mailbox_id) + end) + + settings + end + + @doc """ + Warms up cache for a user after login. + """ + def warm_user_cache(user_id, mailbox_id) do + # Use the AppCache warming function which is more comprehensive + AppCache.warm_user_cache(user_id, mailbox_id) + + # Also warm email-specific data + Elektrine.Async.start(fn -> + # Load counts + unread_count(mailbox_id) + feed_messages_count(mailbox_id) + ledger_messages_count(mailbox_id) + + # Load first page of inbox + list_inbox_messages_paginated(mailbox_id, 1, 20) + end) + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/categorizer.ex b/apps/elektrine_email/lib/elektrine/email/categorizer.ex new file mode 100644 index 0000000..290d02c --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/categorizer.ex @@ -0,0 +1,791 @@ +defmodule Elektrine.Email.Categorizer do + @moduledoc """ + Advanced email categorization and detection logic. + Provides sophisticated detection for bulk emails, receipts, newsletters, and notifications. + """ + + @bulk_threshold 3 + @receipt_threshold 5 + @newsletter_threshold 4 + @notification_threshold 4 + @low_confidence_threshold 0.55 + + @doc """ + Categorizes an email message based on its content, metadata, and learned preferences. + Returns the message attributes with category and detection flags set. + """ + def categorize_message(message_attrs, opts \\ []) do + subject = normalize_text(get_attr(message_attrs, "subject", :subject)) + from = normalize_text(get_attr(message_attrs, "from", :from)) + to = normalize_text(get_attr(message_attrs, "to", :to)) + body = normalize_text(get_attr(message_attrs, "text_body", :text_body)) + html_body = normalize_text(get_attr(message_attrs, "html_body", :html_body)) + + metadata = + message_attrs + |> get_attr("metadata", :metadata) + |> normalize_metadata() + + headers = metadata |> Map.get("headers", %{}) |> normalize_headers() + combined_body = "#{body} #{html_body}" + + bulk_signal = detect_bulk_email_signal(headers, from, to, combined_body) + receipt_signal = detect_receipt_signal(subject, from, combined_body, html_body) + newsletter_signal = detect_newsletter_signal(subject, from, combined_body, headers) + notification_signal = detect_notification_signal(subject, from, combined_body) + + signals = %{ + bulk: bulk_signal, + receipt: receipt_signal, + newsletter: newsletter_signal, + notification: notification_signal + } + + user_id = Keyword.get(opts, :user_id) + learned_match = match_learned_preference(user_id, get_attr(message_attrs, "from", :from)) + enabled_category_filters = Keyword.get(opts, :enabled_category_filters, ["feed", "ledger"]) + + {category, confidence, source, reasons, fallback_applied} = + determine_category(signals, learned_match) + |> maybe_apply_disabled_category_filter(enabled_category_filters) + + categorization_metadata = %{ + "category" => category, + "confidence" => confidence, + "source" => source, + "fallback_applied" => fallback_applied, + "reasons" => reasons, + "signals" => %{ + "bulk" => signal_to_metadata(bulk_signal), + "receipt" => signal_to_metadata(receipt_signal), + "newsletter" => signal_to_metadata(newsletter_signal), + "notification" => signal_to_metadata(notification_signal) + } + } + + updated_metadata = Map.put(metadata, "categorization", categorization_metadata) + + message_attrs + |> put_attr("category", :category, category) + |> put_attr("is_receipt", :is_receipt, receipt_signal.matched) + |> put_attr("is_newsletter", :is_newsletter, newsletter_signal.matched) + |> put_attr("is_notification", :is_notification, notification_signal.matched) + |> put_attr("metadata", :metadata, updated_metadata) + end + + @doc """ + Detects if an email is a bulk email using multiple signals. + """ + def detect_bulk_email(headers, from, to, body) do + headers + |> normalize_headers() + |> detect_bulk_email_signal(normalize_text(from), normalize_text(to), normalize_text(body)) + |> Map.get(:matched, false) + end + + @doc """ + Detects if an email is a receipt or transaction confirmation. + """ + def detect_receipt(subject, from, body, html_body) do + subject + |> normalize_text() + |> detect_receipt_signal( + normalize_text(from), + normalize_text(body), + normalize_text(html_body) + ) + |> Map.get(:matched, false) + end + + @doc """ + Detects if an email is a newsletter. + """ + def detect_newsletter(subject, from, body, headers) do + subject + |> normalize_text() + |> detect_newsletter_signal( + normalize_text(from), + normalize_text(body), + normalize_headers(headers) + ) + |> Map.get(:matched, false) + end + + @doc """ + Detects if an email is an automated notification. + """ + def detect_notification(subject, from, body) do + subject + |> normalize_text() + |> detect_notification_signal(normalize_text(from), normalize_text(body)) + |> Map.get(:matched, false) + end + + defp detect_bulk_email_signal(headers, from, to, body) do + bulk_headers = [ + "list-unsubscribe", + "list-id", + "list-post", + "list-help", + "list-subscribe", + "precedence", + "x-campaign-id", + "x-mailer", + "x-mail-id", + "x-report-abuse" + ] + + header_hits = + Enum.filter(bulk_headers, fn header -> + Map.has_key?(headers, header) + end) + + header_score = length(header_hits) + + precedence = Map.get(headers, "precedence", "") + + precedence_score = + if String.contains?(precedence, ["bulk", "list", "junk"]), do: 3, else: 0 + + marketing_keywords = [ + "unsubscribe", + "opt-out", + "marketing", + "promotional", + "advertisement", + "deal", + "offer", + "sale", + "discount", + "limited time", + "act now", + "click here", + "buy now", + "shop now", + "special offer", + "exclusive", + "manage preferences", + "email preferences", + "subscription settings" + ] + + keyword_hits = Enum.filter(marketing_keywords, &String.contains?(body, &1)) + keyword_score = min(length(keyword_hits), 5) + + bulk_sender_patterns = [ + ~r/no-?reply/i, + ~r/newsletter/i, + ~r/marketing/i, + ~r/promotions?/i, + ~r/campaigns?/i, + ~r/updates?/i, + ~r/notifications?/i, + ~r/automated/i, + ~r/system/i, + ~r/info@/i, + ~r/news@/i, + ~r/hello@/i, + ~r/support@/i, + ~r/team@/i, + ~r/pangle/i, + ~r/mailchimp/i, + ~r/sendgrid/i, + ~r/constantcontact/i + ] + + sender_bulk? = Enum.any?(bulk_sender_patterns, &Regex.match?(&1, from)) + sender_score = if sender_bulk?, do: 2, else: 0 + + to_bulk? = String.contains?(to, ["undisclosed", "recipients", "list", "group"]) + to_score = if to_bulk?, do: 2, else: 0 + + tracking_bulk? = String.contains?(body, ["1x1", "track", "pixel", "beacon", "analytics"]) + tracking_score = if tracking_bulk?, do: 1, else: 0 + + total_score = + header_score + precedence_score + keyword_score + sender_score + to_score + tracking_score + + reasons = + [] + |> maybe_add_reason( + header_hits != [], + "bulk headers present: #{Enum.join(header_hits, ", ")}" + ) + |> maybe_add_reason(precedence_score > 0, "precedence indicates bulk/list traffic") + |> maybe_add_reason(keyword_score > 0, "marketing language detected") + |> maybe_add_reason(sender_bulk?, "sender pattern matches bulk mailers") + |> maybe_add_reason(to_bulk?, "recipient header pattern looks list-like") + |> maybe_add_reason(tracking_bulk?, "tracking indicators detected") + |> Enum.reverse() + + %{ + matched: total_score >= @bulk_threshold, + score: total_score, + threshold: @bulk_threshold, + reasons: reasons + } + end + + defp detect_receipt_signal(subject, from, body, html_body) do + strong_subject_terms = [ + "receipt", + "invoice", + "order confirmation", + "payment confirmation", + "purchase confirmation", + "transaction", + "order #", + "invoice #", + "payment received", + "refund", + "credit", + "charge", + "billing statement", + "statement" + ] + + newsletter_exclusions = ["digest", "newsletter", "weekly", "daily", "monthly"] + + subject_receipt? = + Enum.any?(strong_subject_terms, &String.contains?(subject, &1)) && + !Enum.any?(newsletter_exclusions, &String.contains?(subject, &1)) + + subject_score = if subject_receipt?, do: 5, else: 0 + + receipt_keywords = [ + "total", + "subtotal", + "tax", + "amount", + "paid", + "due", + "billing", + "invoice", + "receipt", + "transaction", + "order", + "item", + "quantity", + "price", + "cost", + "fee", + "charge", + "payment method", + "credit card", + "debit", + "paypal", + "venmo", + "confirmation number", + "reference number", + "tracking number", + "ship to", + "bill to", + "delivery", + "shipping" + ] + + keyword_hits = Enum.filter(receipt_keywords, &String.contains?(body, &1)) + keyword_score = round(min(length(keyword_hits) * 0.5, 5)) + + price_patterns = [ + ~r/\$\d+\.?\d*/, + ~r/USD\s*\d+/i, + ~r/€\d+/, + ~r/£\d+/, + ~r/total[:\s]+\$?\d+/i, + ~r/amount[:\s]+\$?\d+/i, + ~r/price[:\s]+\$?\d+/i + ] + + price_detected? = Enum.any?(price_patterns, &Regex.match?(&1, body)) + price_score = if price_detected?, do: 3, else: 0 + + receipt_senders = [ + "receipt", + "invoice", + "billing", + "payment", + "order", + "shop", + "store", + "amazon", + "ebay", + "paypal", + "stripe", + "square", + "shopify", + "uber", + "lyft", + "doordash", + "grubhub" + ] + + sender_receipt? = Enum.any?(receipt_senders, &String.contains?(from, &1)) + sender_score = if sender_receipt?, do: 2, else: 0 + + table_receipt? = + html_body != "" && String.contains?(html_body, ["", "", "border"]) + + table_score = if table_receipt?, do: 1, else: 0 + + total_score = subject_score + keyword_score + price_score + sender_score + table_score + + reasons = + [] + |> maybe_add_reason(subject_receipt?, "subject indicates a receipt or invoice") + |> maybe_add_reason(keyword_score > 0, "receipt/payment language detected") + |> maybe_add_reason(price_detected?, "currency/amount patterns detected") + |> maybe_add_reason(sender_receipt?, "sender resembles billing or commerce traffic") + |> maybe_add_reason(table_receipt?, "receipt-style HTML table structure detected") + |> Enum.reverse() + + %{ + matched: total_score >= @receipt_threshold, + score: total_score, + threshold: @receipt_threshold, + reasons: reasons + } + end + + defp detect_newsletter_signal(subject, from, body, headers) do + list_unsubscribe? = Map.has_key?(headers, "list-unsubscribe") + header_score = if list_unsubscribe?, do: 3, else: 0 + + newsletter_subject_terms = [ + "newsletter", + "weekly", + "monthly", + "daily", + "digest", + "roundup", + "update", + "news", + "bulletin", + "briefing", + "edition", + "issue #", + "vol.", + "volume", + "highlights", + "recap" + ] + + {subject_score, subject_reason} = + cond do + String.contains?(subject, ["digest", "daily digest"]) -> + {5, "subject contains digest phrasing"} + + String.contains?(subject, ["medium daily", "pangle"]) -> + {5, "subject matches known digest sender patterns"} + + Enum.any?(newsletter_subject_terms, &String.contains?(subject, &1)) -> + {3, "subject contains newsletter terms"} + + true -> + {0, nil} + end + + newsletter_body_terms = [ + "unsubscribe", + "manage subscription", + "email preferences", + "view in browser", + "read more", + "continue reading", + "this week", + "this month", + "in this issue", + "highlights", + "subscribe", + "forward to a friend", + "share this" + ] + + body_hits = Enum.filter(newsletter_body_terms, &String.contains?(body, &1)) + body_score = min(length(body_hits), 4) + + newsletter_sender_patterns = [ + ~r/newsletter/i, + ~r/news@/i, + ~r/updates?@/i, + ~r/weekly@/i, + ~r/daily@/i, + ~r/digest@/i, + ~r/\.substack\./i, + ~r/mailchimp/i, + ~r/campaign/i + ] + + sender_newsletter? = Enum.any?(newsletter_sender_patterns, &Regex.match?(&1, from)) + sender_score = if sender_newsletter?, do: 2, else: 0 + + media_domains = [ + "medium.com", + "substack.com", + "nytimes.com", + "wsj.com", + "techcrunch.com", + "theverge.com", + "wired.com", + "bloomberg.com", + "reuters.com", + "cnn.com", + "bbc.com", + "npr.org", + "linkedin.com", + "twitter.com", + "facebook.com", + "github.com", + "stackoverflow.com", + "reddit.com", + "quora.com", + "producthunt.com", + "hackernews", + "dev.to", + "pangle.io", + "panglepay", + "mailchimp", + "sendgrid", + "constantcontact" + ] + + media_sender? = Enum.any?(media_domains, &String.contains?(from, &1)) + media_score = if media_sender?, do: 4, else: 0 + + total_score = header_score + subject_score + body_score + sender_score + media_score + + reasons = + [] + |> maybe_add_reason(list_unsubscribe?, "list-unsubscribe header found") + |> maybe_add_reason(!is_nil(subject_reason), subject_reason) + |> maybe_add_reason(body_score > 0, "newsletter body patterns detected") + |> maybe_add_reason(sender_newsletter?, "sender matches newsletter address patterns") + |> maybe_add_reason(media_sender?, "sender domain resembles publication/platform updates") + |> Enum.reverse() + + %{ + matched: total_score >= @newsletter_threshold, + score: total_score, + threshold: @newsletter_threshold, + reasons: reasons + } + end + + defp detect_notification_signal(subject, from, body) do + strong_indicators = [ + "password reset", + "verification code", + "confirm your", + "security alert", + "new login", + "suspicious activity", + "2fa code", + "two-factor", + "one-time password", + "otp", + "account at risk", + "immediate action required", + "account suspended", + "unauthorized access" + ] + + aws_critical? = + String.contains?(from, "amazonaws.com") && + (String.contains?(subject, "risk") || + String.contains?(subject, "suspended") || + String.contains?(subject, "immediate")) + + strong_notification? = Enum.any?(strong_indicators, &String.contains?(body, &1)) + strong_score = if aws_critical? || strong_notification?, do: 5, else: 0 + + notification_keywords = [ + "notification", + "alert", + "reminder", + "notice", + "update", + "verify", + "confirm", + "activate", + "reset", + "expired", + "action required", + "important", + "urgent", + "automated", + "do not reply", + "no-reply", + "system generated" + ] + + keyword_hits = Enum.filter(notification_keywords, &String.contains?(body, &1)) + keyword_score = round(min(length(keyword_hits) * 0.7, 4)) + + notification_sender_patterns = [ + ~r/no-?reply/i, + ~r/notifications?@/i, + ~r/alerts?@/i, + ~r/system@/i, + ~r/automated@/i, + ~r/donotreply/i, + ~r/accounts?@/i, + ~r/security@/i, + ~r/support@/i + ] + + sender_notification? = Enum.any?(notification_sender_patterns, &Regex.match?(&1, from)) + sender_score = if sender_notification?, do: 2, else: 0 + + service_patterns = [ + "github.com", + "gitlab.com", + "bitbucket.org", + "slack.com", + "discord.com", + "teams.microsoft.com", + "facebook.com", + "twitter.com", + "linkedin.com", + "google.com", + "apple.com", + "microsoft.com", + "dropbox.com", + "box.com", + "zoom.us" + ] + + service_notification? = Enum.any?(service_patterns, &String.contains?(from, &1)) + service_score = if service_notification?, do: 1, else: 0 + + short_body? = String.length(body) < 500 + length_score = if short_body?, do: 1, else: 0 + + total_score = strong_score + keyword_score + sender_score + service_score + length_score + + reasons = + [] + |> maybe_add_reason(aws_critical?, "critical cloud security phrasing detected") + |> maybe_add_reason(strong_notification?, "security/account notification language detected") + |> maybe_add_reason(keyword_score > 0, "notification keywords detected") + |> maybe_add_reason(sender_notification?, "sender matches notification address patterns") + |> maybe_add_reason(service_notification?, "sender appears to be a known service domain") + |> maybe_add_reason(short_body?, "short-form body pattern common for alerts") + |> Enum.reverse() + + %{ + matched: total_score >= @notification_threshold, + score: total_score, + threshold: @notification_threshold, + reasons: reasons + } + end + + defp determine_category(_signals, learned_match) when is_map(learned_match) do + category = learned_match.category + confidence = normalize_confidence(Map.get(learned_match, :confidence, 0.8)) + source = Map.get(learned_match, :source, "learned") + reasons = Map.get(learned_match, :reasons, ["learned category preference"]) + + {category, confidence, source, reasons, false} + end + + defp determine_category(signals, _learned_match) do + bulk = signals.bulk + receipt = signals.receipt + newsletter = signals.newsletter + notification = signals.notification + + {category, source, primary_signal} = + cond do + newsletter.matched -> + {"feed", :newsletter, newsletter} + + bulk.matched && !receipt.matched -> + {"feed", :bulk_non_receipt, bulk} + + receipt.matched -> + {"ledger", :receipt, receipt} + + notification.matched && !bulk.matched -> + {"inbox", :notification, notification} + + notification.matched && bulk.matched -> + {"feed", :notification_bulk, bulk} + + bulk.matched -> + {"feed", :bulk_fallback, bulk} + + true -> + {"inbox", :default, nil} + end + + confidence = + source + |> compute_confidence(primary_signal, signals) + |> normalize_confidence() + + reasons = + base_reasons(source, primary_signal) + |> maybe_add_reason( + category in ["feed", "ledger"] && confidence < @low_confidence_threshold, + "confidence #{format_confidence(confidence)} below threshold #{@low_confidence_threshold}" + ) + + if category in ["feed", "ledger"] && confidence < @low_confidence_threshold do + {"inbox", confidence, "confidence_fallback", Enum.reverse(reasons), true} + else + {category, confidence, Atom.to_string(source), Enum.reverse(reasons), false} + end + end + + defp maybe_apply_disabled_category_filter( + {category, confidence, source, reasons, fallback_applied}, + enabled_categories + ) + when category in ["feed", "ledger"] do + if category in enabled_categories do + {category, confidence, source, reasons, fallback_applied} + else + {"inbox", confidence, "disabled_category_filter", + ["#{category} category filter disabled" | reasons], true} + end + end + + defp maybe_apply_disabled_category_filter(result, _enabled_categories), do: result + + defp compute_confidence(:default, _primary_signal, _signals), do: 0.9 + defp compute_confidence(:notification, _primary_signal, _signals), do: 0.85 + + defp compute_confidence(source, primary_signal, signals) do + base = signal_confidence(primary_signal) + penalty = competing_penalty(source, signals) + base + 0.2 - penalty + end + + defp competing_penalty(:receipt, signals) do + max(signal_confidence(signals.newsletter), signal_confidence(signals.bulk)) * 0.35 + end + + defp competing_penalty(:bulk_non_receipt, signals) do + signal_confidence(signals.receipt) * 0.2 + end + + defp competing_penalty(_, _), do: 0.0 + + defp signal_confidence(nil), do: 0.0 + + defp signal_confidence(%{score: score, threshold: threshold}) + when is_number(score) and is_number(threshold) and threshold > 0 do + min(1.0, score / (threshold * 2.0)) + end + + defp signal_confidence(_), do: 0.0 + + defp base_reasons(:newsletter, primary_signal) do + ["newsletter signal selected" | signal_reason_list(primary_signal)] + end + + defp base_reasons(:bulk_non_receipt, primary_signal) do + ["bulk non-receipt signal selected" | signal_reason_list(primary_signal)] + end + + defp base_reasons(:receipt, primary_signal) do + ["receipt signal selected" | signal_reason_list(primary_signal)] + end + + defp base_reasons(:notification, primary_signal) do + ["notification signal selected" | signal_reason_list(primary_signal)] + end + + defp base_reasons(:notification_bulk, primary_signal) do + ["bulk notification signal selected" | signal_reason_list(primary_signal)] + end + + defp base_reasons(:bulk_fallback, primary_signal) do + ["bulk fallback selected" | signal_reason_list(primary_signal)] + end + + defp base_reasons(:default, _primary_signal) do + ["no strong digest/ledger signals detected"] + end + + defp signal_reason_list(%{reasons: reasons}) when is_list(reasons), do: reasons + defp signal_reason_list(_), do: [] + + defp signal_to_metadata(signal) do + %{ + "matched" => Map.get(signal, :matched, false), + "score" => Map.get(signal, :score, 0), + "threshold" => Map.get(signal, :threshold, 0), + "reasons" => Map.get(signal, :reasons, []) + } + end + + defp normalize_headers(headers) when is_map(headers) do + Enum.reduce(headers, %{}, fn {key, value}, acc -> + normalized_key = + key + |> to_string() + |> String.downcase() + |> String.trim() + + normalized_value = + value + |> to_string() + |> String.downcase() + |> String.trim() + + Map.put(acc, normalized_key, normalized_value) + end) + end + + defp normalize_headers(_), do: %{} + + defp normalize_metadata(metadata) when is_map(metadata), do: metadata + defp normalize_metadata(_), do: %{} + + defp normalize_text(value) when is_binary(value), do: String.downcase(value) + defp normalize_text(_), do: "" + + defp get_attr(attrs, string_key, atom_key) when is_map(attrs) do + Map.get(attrs, string_key) || Map.get(attrs, atom_key) + end + + defp put_attr(attrs, string_key, atom_key, value) when is_map(attrs) do + cond do + Map.has_key?(attrs, atom_key) -> + Map.put(attrs, atom_key, value) + + Map.has_key?(attrs, string_key) -> + Map.put(attrs, string_key, value) + + Enum.any?(Map.keys(attrs), &is_atom/1) -> + Map.put(attrs, atom_key, value) + + true -> + Map.put(attrs, string_key, value) + end + end + + defp maybe_add_reason(reasons, true, reason) when is_list(reasons), do: [reason | reasons] + defp maybe_add_reason(reasons, _, _), do: reasons + + defp normalize_confidence(confidence) when is_number(confidence) do + confidence + |> max(0.0) + |> min(0.99) + |> Float.round(3) + end + + defp normalize_confidence(_), do: 0.0 + + defp format_confidence(confidence) when is_number(confidence) do + :erlang.float_to_binary(confidence, decimals: 3) + end + + defp match_learned_preference(user_id, from) when is_integer(user_id) and is_binary(from) do + Elektrine.Email.CategoryPreferences.match_category(user_id, from) + end + + defp match_learned_preference(_, _), do: nil +end diff --git a/apps/elektrine_email/lib/elektrine/email/category_preference.ex b/apps/elektrine_email/lib/elektrine/email/category_preference.ex new file mode 100644 index 0000000..076f3c6 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/category_preference.ex @@ -0,0 +1,87 @@ +defmodule Elektrine.Email.CategoryPreference do + @moduledoc """ + Learned category preferences for incoming senders and domains. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "email_category_preferences" do + field :email, :string + field :domain, :string + field :category, :string + field :confidence, :float, default: 0.7 + field :learned_count, :integer, default: 1 + field :source, :string, default: "manual_move" + field :last_learned_at, :utc_datetime + + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + @valid_categories ~w(feed ledger) + + @doc """ + Creates a changeset for a learned category preference. + """ + def changeset(preference, attrs) do + preference + |> cast(attrs, [ + :email, + :domain, + :category, + :confidence, + :learned_count, + :source, + :last_learned_at, + :user_id + ]) + |> validate_required([:category, :user_id]) + |> validate_inclusion(:category, @valid_categories) + |> validate_number(:confidence, greater_than_or_equal_to: 0.0, less_than_or_equal_to: 1.0) + |> validate_number(:learned_count, greater_than_or_equal_to: 1) + |> validate_email_or_domain() + |> normalize_email() + |> normalize_domain() + |> foreign_key_constraint(:user_id) + |> unique_constraint([:user_id, :email], name: :email_category_preferences_user_email_idx) + |> unique_constraint([:user_id, :domain], name: :email_category_preferences_user_domain_idx) + end + + defp validate_email_or_domain(changeset) do + email = get_field(changeset, :email) + domain = get_field(changeset, :domain) + + cond do + is_nil(email) && is_nil(domain) -> + add_error(changeset, :email, "either email or domain must be provided") + + !is_nil(email) && !is_nil(domain) -> + add_error(changeset, :email, "cannot set both email and domain in same entry") + + !is_nil(email) -> + validate_format(changeset, :email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, + message: "must be a valid email format" + ) + + !is_nil(domain) -> + validate_format(changeset, :domain, ~r/^[a-zA-Z0-9][a-zA-Z0-9\-\.]*\.[a-zA-Z]{2,}$/, + message: "must be a valid domain format" + ) + end + end + + defp normalize_email(changeset) do + case get_change(changeset, :email) do + nil -> changeset + email -> put_change(changeset, :email, String.downcase(String.trim(email))) + end + end + + defp normalize_domain(changeset) do + case get_change(changeset, :domain) do + nil -> changeset + domain -> put_change(changeset, :domain, String.downcase(String.trim(domain))) + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/category_preferences.ex b/apps/elektrine_email/lib/elektrine/email/category_preferences.ex new file mode 100644 index 0000000..9b9d8ed --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/category_preferences.ex @@ -0,0 +1,191 @@ +defmodule Elektrine.Email.CategoryPreferences do + @moduledoc """ + Persistence and lookup for learned sender/domain category preferences. + """ + import Ecto.Query + alias Elektrine.Email.CategoryPreference + alias Elektrine.Repo + + @domain_learning_threshold 2 + + @doc """ + Returns a learned category match for a sender, preferring exact sender over domain. + + Returns nil when no preference applies. + """ + def match_category(user_id, from_email) when is_integer(user_id) and is_binary(from_email) do + sender_email = extract_email(from_email) + sender_domain = extract_domain(sender_email) + + if Elektrine.Strings.present?(sender_email) do + case get_sender_preference(user_id, sender_email) do + %CategoryPreference{} = preference -> + %{ + category: preference.category, + confidence: normalize_confidence(preference.confidence, 0.8), + source: "learned_sender", + reasons: ["learned sender preference for #{sender_email}"], + learned_count: preference.learned_count + } + + nil -> + case get_domain_preference(user_id, sender_domain) do + %CategoryPreference{learned_count: learned_count} = preference + when learned_count >= @domain_learning_threshold -> + %{ + category: preference.category, + confidence: normalize_confidence(preference.confidence, 0.7), + source: "learned_domain", + reasons: ["learned domain preference for #{sender_domain}"], + learned_count: learned_count + } + + _ -> + nil + end + end + else + nil + end + end + + def match_category(_, _), do: nil + + @doc """ + Learns sender and domain preferences from a manual category move. + """ + def learn_from_manual_move(user_id, from_email, category) + when is_integer(user_id) and category in ["feed", "ledger"] do + sender_email = extract_email(from_email) + sender_domain = extract_domain(sender_email) + + cond do + not Elektrine.Strings.present?(sender_email) -> + {:error, :invalid_sender} + + not Elektrine.Strings.present?(sender_domain) -> + upsert_preference(user_id, :email, sender_email, category) + + true -> + with {:ok, _sender_pref} <- upsert_preference(user_id, :email, sender_email, category), + {:ok, _domain_pref} <- upsert_preference(user_id, :domain, sender_domain, category) do + :ok + end + end + end + + def learn_from_manual_move(_, _, _), do: {:error, :invalid_category} + + defp get_sender_preference(user_id, email) do + CategoryPreference + |> where([p], p.user_id == ^user_id and p.email == ^email) + |> Repo.one() + end + + defp get_domain_preference(user_id, domain) do + CategoryPreference + |> where([p], p.user_id == ^user_id and p.domain == ^domain) + |> Repo.one() + end + + defp upsert_preference(user_id, :email, email, category) do + case get_sender_preference(user_id, email) do + %CategoryPreference{} = existing -> + update_preference(existing, category, :email) + + nil -> + create_preference(%{ + user_id: user_id, + email: email, + category: category, + confidence: base_confidence(:email, 1), + learned_count: 1, + source: "manual_move", + last_learned_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + end + end + + defp upsert_preference(user_id, :domain, domain, category) do + case get_domain_preference(user_id, domain) do + %CategoryPreference{} = existing -> + update_preference(existing, category, :domain) + + nil -> + create_preference(%{ + user_id: user_id, + domain: domain, + category: category, + confidence: base_confidence(:domain, 1), + learned_count: 1, + source: "manual_move", + last_learned_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + end + end + + defp create_preference(attrs) do + %CategoryPreference{} + |> CategoryPreference.changeset(attrs) + |> Repo.insert() + end + + defp update_preference(%CategoryPreference{} = existing, category, scope) do + learned_count = + if existing.category == category do + existing.learned_count + 1 + else + 1 + end + + attrs = %{ + category: category, + learned_count: learned_count, + confidence: base_confidence(scope, learned_count), + source: "manual_move", + last_learned_at: DateTime.utc_now() |> DateTime.truncate(:second) + } + + existing + |> CategoryPreference.changeset(attrs) + |> Repo.update() + end + + defp base_confidence(:email, learned_count) do + min(0.95, 0.7 + (learned_count - 1) * 0.1) + end + + defp base_confidence(:domain, learned_count) do + min(0.9, 0.55 + (learned_count - 1) * 0.1) + end + + defp normalize_confidence(confidence, default) when is_float(confidence) do + confidence + |> max(default) + |> min(0.99) + |> Float.round(3) + end + + defp normalize_confidence(_, default), do: default + + defp extract_email(email_string) when is_binary(email_string) do + extracted = + case Regex.run(~r/<([^>]+)>/, email_string) do + [_, email] -> String.trim(email) + nil -> String.trim(email_string) + end + + String.downcase(extracted) + end + + defp extract_email(_), do: "" + + defp extract_domain(email) when is_binary(email) do + case String.split(email, "@", parts: 2) do + [_, domain] -> String.downcase(domain) + _ -> "" + end + end + + defp extract_domain(_), do: "" +end diff --git a/apps/elektrine_email/lib/elektrine/email/contact.ex b/apps/elektrine_email/lib/elektrine/email/contact.ex new file mode 100644 index 0000000..e8a75b2 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/contact.ex @@ -0,0 +1,184 @@ +defmodule Elektrine.Email.Contact do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "email_contacts" do + # Original fields + field :name, :string + field :email, :string + field :phone, :string + field :organization, :string + field :notes, :string + field :favorite, :boolean, default: false + + # CardDAV sync fields + field :uid, :string + field :etag, :string + + # Name components (vCard N property) + field :prefix, :string + field :suffix, :string + field :nickname, :string + field :formatted_name, :string + field :first_name, :string + field :last_name, :string + field :middle_name, :string + + # Multiple emails/phones/addresses as JSON arrays + field :emails, {:array, :map}, default: [] + field :phones, {:array, :map}, default: [] + field :addresses, {:array, :map}, default: [] + + # Social and web + field :urls, {:array, :map}, default: [] + field :social_profiles, {:array, :map}, default: [] + + # Important dates + field :birthday, :date + field :anniversary, :date + + # Photo + field :photo_type, :string + field :photo_data, :string + field :photo_content_type, :string + + # Work info + field :title, :string + field :department, :string + field :role, :string + + # Categories/tags + field :categories, {:array, :string}, default: [] + + # Location + field :geo, :map + + # Raw vCard data for round-trip + field :vcard_data, :string + + # vCard revision timestamp + field :revision, :utc_datetime + + # PGP/OpenPGP Encryption + field :pgp_public_key, :string + field :pgp_key_id, :string + field :pgp_fingerprint, :string + # "manual", "wkd", "keyserver" + field :pgp_key_source, :string + field :pgp_key_fetched_at, :utc_datetime + field :pgp_encrypt_by_default, :boolean, default: false + + belongs_to :user, Elektrine.Accounts.User + belongs_to :group, Elektrine.Email.ContactGroup + + timestamps() + end + + @doc """ + Basic changeset for simple contact creation/update. + """ + def changeset(contact, attrs) do + contact + |> cast(attrs, [:name, :email, :phone, :organization, :notes, :favorite, :group_id]) + |> validate_required([:user_id, :name, :email]) + |> validate_format(:email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/) + |> validate_length(:name, max: 255) + |> validate_length(:email, max: 255) + |> validate_length(:phone, max: 50) + |> validate_length(:organization, max: 255) + |> unique_constraint([:user_id, :email]) + end + + @doc """ + Full changeset for CardDAV with all vCard fields. + """ + def carddav_changeset(contact, attrs) do + contact + |> cast(attrs, [ + :user_id, + :name, + :email, + :phone, + :organization, + :notes, + :favorite, + :group_id, + :uid, + :etag, + :prefix, + :suffix, + :nickname, + :formatted_name, + :first_name, + :last_name, + :middle_name, + :emails, + :phones, + :addresses, + :urls, + :social_profiles, + :birthday, + :anniversary, + :photo_type, + :photo_data, + :photo_content_type, + :title, + :department, + :role, + :categories, + :geo, + :vcard_data, + :revision + ]) + |> validate_required([:user_id]) + |> ensure_uid() + |> ensure_etag() + |> derive_name_fields() + |> unique_constraint([:user_id, :uid]) + |> unique_constraint([:user_id, :email]) + end + + defp ensure_uid(changeset) do + case get_field(changeset, :uid) do + nil -> put_change(changeset, :uid, Elektrine.Email.VCard.generate_uid()) + _ -> changeset + end + end + + defp ensure_etag(changeset) do + # Generate new etag on any change + if changeset.changes != %{} do + put_change(changeset, :etag, generate_etag()) + else + changeset + end + end + + defp generate_etag do + :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) + end + + defp derive_name_fields(changeset) do + # If name is not set but formatted_name or first/last names are, derive it + name = get_field(changeset, :name) + formatted = get_field(changeset, :formatted_name) + first = get_field(changeset, :first_name) + last = get_field(changeset, :last_name) + + cond do + Elektrine.Strings.present?(name) -> + changeset + + Elektrine.Strings.present?(formatted) -> + put_change(changeset, :name, formatted) + + first || last -> + derived = [first, last] |> Enum.reject(&is_nil/1) |> Enum.join(" ") + put_change(changeset, :name, derived) + + true -> + changeset + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/contact_group.ex b/apps/elektrine_email/lib/elektrine/email/contact_group.ex new file mode 100644 index 0000000..7a491e6 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/contact_group.ex @@ -0,0 +1,23 @@ +defmodule Elektrine.Email.ContactGroup do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + schema "contact_groups" do + field :name, :string + field :color, :string, default: "#3b82f6" + + belongs_to :user, Elektrine.Accounts.User + has_many :contacts, Elektrine.Email.Contact, foreign_key: :group_id + + timestamps() + end + + def changeset(group, attrs) do + group + |> cast(attrs, [:user_id, :name, :color]) + |> validate_required([:user_id, :name]) + |> validate_length(:name, max: 100) + |> validate_format(:color, ~r/^#[0-9a-fA-F]{6}$/) + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/contacts.ex b/apps/elektrine_email/lib/elektrine/email/contacts.ex new file mode 100644 index 0000000..759e4a5 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/contacts.ex @@ -0,0 +1,256 @@ +defmodule Elektrine.Email.Contacts do + @moduledoc """ + Context for managing email contacts. + """ + import Ecto.Query + alias Elektrine.Email.{Contact, ContactGroup} + alias Elektrine.Repo + + # ===== CONTACTS ===== + + @doc """ + Lists all contacts for a user. + """ + def list_contacts(user_id) do + from(c in Contact, + where: c.user_id == ^user_id, + order_by: [asc: c.name], + preload: [:group] + ) + |> Repo.all() + end + + @doc """ + Searches contacts by name or email. + """ + def search_contacts(user_id, query) when is_binary(query) do + search_term = "%#{String.downcase(query)}%" + + from(c in Contact, + where: c.user_id == ^user_id, + where: + fragment("LOWER(?) LIKE ?", c.name, ^search_term) or + fragment("LOWER(?) LIKE ?", c.email, ^search_term), + order_by: [desc: c.favorite, asc: c.name], + limit: 10, + preload: [:group] + ) + |> Repo.all() + end + + @doc """ + Gets a single contact by user_id and contact_id. + Raises if not found or doesn't belong to user. + """ + def get_contact!(user_id, id) do + from(c in Contact, + where: c.id == ^id and c.user_id == ^user_id + ) + |> Repo.one!() + end + + @doc """ + Gets a contact by user_id and email. + """ + def get_contact_by_email(user_id, email) do + Repo.get_by(Contact, user_id: user_id, email: String.downcase(email)) + end + + @doc """ + Creates a contact. + """ + def create_contact(attrs) do + user_id = Map.get(attrs, :user_id) || Map.get(attrs, "user_id") + + # Set user_id on the struct (not via the changeset, which no longer casts + # :user_id - see Contact.changeset, mass-assignment hardening). It must be + # present BEFORE Contact.changeset runs so its validate_required(:user_id) + # passes; a put_change after the changeset would run too late. + %Contact{user_id: user_id} + |> Contact.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a contact. + """ + def update_contact(%Contact{} = contact, attrs) do + contact + |> Contact.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a contact. + """ + def delete_contact(%Contact{} = contact) do + Repo.delete(contact) + end + + @doc """ + Toggles favorite status for a contact. + """ + def toggle_favorite(%Contact{} = contact) do + contact + |> Contact.changeset(%{favorite: !contact.favorite}) + |> Repo.update() + end + + # ===== CARDDAV FUNCTIONS ===== + + @doc """ + Gets a contact by its CardDAV UID. + """ + def get_contact_by_uid(user_id, uid) do + Repo.get_by(Contact, user_id: user_id, uid: uid) + end + + @doc """ + Creates a contact via CardDAV with full vCard support. + """ + def create_contact_carddav(attrs) do + %Contact{} + |> Contact.carddav_changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a contact via CardDAV with full vCard support. + """ + def update_contact_carddav(%Contact{} = contact, attrs) do + contact + |> Contact.carddav_changeset(attrs) + |> Repo.update() + end + + @doc """ + Lists contacts modified since a given timestamp. + Used for CardDAV sync. + """ + def list_contacts_since(user_id, nil) do + list_contacts(user_id) + end + + def list_contacts_since(user_id, %DateTime{} = since) do + from(c in Contact, + where: c.user_id == ^user_id and c.updated_at > ^since, + order_by: [asc: c.updated_at], + preload: [:group] + ) + |> Repo.all() + end + + # ===== CONTACT GROUPS ===== + + @doc """ + Lists all contact groups for a user. + """ + def list_contact_groups(user_id) do + from(g in ContactGroup, + where: g.user_id == ^user_id, + order_by: [asc: g.name] + ) + |> Repo.all() + end + + @doc """ + Gets a contact group by user_id and group_id. + Raises if not found or doesn't belong to user. + """ + def get_contact_group!(user_id, id) do + from(g in ContactGroup, + where: g.id == ^id and g.user_id == ^user_id + ) + |> Repo.one!() + end + + @doc """ + Creates a contact group. + """ + def create_contact_group(attrs) do + %ContactGroup{} + |> ContactGroup.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a contact group. + """ + def update_contact_group(%ContactGroup{} = group, attrs) do + group + |> ContactGroup.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a contact group (contacts in group will have group_id set to nil). + """ + def delete_contact_group(%ContactGroup{} = group) do + Repo.delete(group) + end + + @doc """ + Get recent email recipients (from contacts + sent emails). + Used for autocomplete in compose. + """ + def get_recent_recipients(user_id, limit \\ 20) do + import Ecto.Query + + # Get from contacts + contacts = + from(c in Contact, + where: c.user_id == ^user_id, + order_by: [desc: c.favorite, desc: c.updated_at], + limit: ^limit, + select: %{email: c.email, name: c.name, source: "contact"} + ) + |> Repo.all() + + # Get from sent emails + sent = + from(m in Elektrine.Email.Message, + join: mb in assoc(m, :mailbox), + where: mb.user_id == ^user_id and m.status == "sent", + where: not is_nil(m.to), + order_by: [desc: m.inserted_at], + limit: ^(limit * 2), + select: m.to + ) + |> Repo.all() + |> Enum.flat_map(&extract_recipients/1) + |> Enum.uniq() + |> Enum.take(limit) + |> Enum.map(fn email -> + %{email: email, name: extract_name_from_email(email), source: "sent"} + end) + + # Combine and deduplicate by email + (contacts ++ sent) + |> Enum.uniq_by(&String.downcase(&1.email)) + |> Enum.take(limit) + end + + # ===== HELPERS ===== + + defp extract_email(from) when is_binary(from) do + case Regex.run(~r/<(.+?)>/, from) do + [_, email] -> String.downcase(String.trim(email)) + _ -> String.downcase(String.trim(from)) + end + end + + defp extract_recipients(to_string) when is_binary(to_string) do + to_string + |> String.split([",", ";"]) + |> Enum.map(&String.trim/1) + |> Enum.map(&extract_email/1) + |> Enum.reject(&(&1 == "")) + end + + defp extract_name_from_email(email) when is_binary(email) do + case Regex.run(~r/^(.+?)\s*<.+>$/, email) do + [_, name] -> String.trim(name, "\"") + _ -> String.split(email, "@") |> List.first() + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/custom_domain.ex b/apps/elektrine_email/lib/elektrine/email/custom_domain.ex new file mode 100644 index 0000000..eb6ee2c --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/custom_domain.ex @@ -0,0 +1,84 @@ +defmodule Elektrine.Email.CustomDomain do + @moduledoc """ + Schema for user-managed custom email domains. + """ + + use Ecto.Schema + import Ecto.Changeset + + @derive {Inspect, except: [:dkim_private_key]} + @statuses ~w(pending verified) + @domain_regex ~r/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/ + + schema "email_custom_domains" do + field :domain, :string + field :verification_token, :string + field :dkim_selector, :string + field :dkim_public_key, :string + field :dkim_private_key, Elektrine.Secrets.EncryptedString, redact: true + field :dkim_synced_at, :utc_datetime + field :dkim_last_error, :string + field :status, :string, default: "pending" + field :verified_at, :utc_datetime + field :last_checked_at, :utc_datetime + field :last_error, :string + field :failing_since, :utc_datetime + + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + def changeset(custom_domain, attrs) do + custom_domain + |> cast(attrs, [ + :domain, + :verification_token, + :dkim_selector, + :dkim_public_key, + :dkim_private_key, + :dkim_synced_at, + :dkim_last_error, + :status, + :verified_at, + :last_checked_at, + :last_error, + :failing_since, + :user_id + ]) + |> update_change(:domain, &normalize_domain/1) + |> validate_required([ + :domain, + :verification_token, + :status, + :user_id + ]) + |> validate_length(:domain, max: 253) + |> validate_length(:verification_token, max: 255) + |> validate_length(:dkim_selector, max: 255) + |> validate_length(:last_error, max: 255) + |> validate_length(:dkim_last_error, max: 255) + |> validate_format(:domain, @domain_regex, message: "must be a valid domain name") + |> validate_exclusion(:domain, Elektrine.Domains.supported_email_domains(), + message: "is already managed by the system" + ) + |> validate_inclusion(:status, @statuses) + |> unique_constraint(:domain, + name: :email_custom_domains_domain_ci_unique, + message: "is already claimed" + ) + |> foreign_key_constraint(:user_id) + end + + defp normalize_domain(nil), do: nil + + defp normalize_domain(domain) when is_binary(domain) do + domain + |> String.trim() + |> String.trim_leading(".") + |> String.trim_trailing(".") + |> String.downcase() + end + + defp normalize_domain(domain), do: domain +end diff --git a/apps/elektrine_email/lib/elektrine/email/custom_domain_health_worker.ex b/apps/elektrine_email/lib/elektrine/email/custom_domain_health_worker.ex new file mode 100644 index 0000000..161475a --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/custom_domain_health_worker.ex @@ -0,0 +1,25 @@ +defmodule Elektrine.Email.CustomDomainHealthWorker do + @moduledoc false + + use Oban.Worker, queue: :email, max_attempts: 1 + + alias Elektrine.Email + + @impl Oban.Worker + def perform(%Oban.Job{}) do + Email.list_custom_domains_for_recheck(500) + |> Enum.each(fn domain -> + # Re-verifies the TXT + MX records and re-syncs DKIM as a side effect, so a + # domain whose DNS records were removed is eventually demoted to pending. + case Email.verify_custom_domain(domain) do + {:ok, %{status: "verified"} = verified} -> + _ = Email.check_deliverability_domain(verified.domain) + + _ -> + :ok + end + end) + + :ok + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/custom_domains.ex b/apps/elektrine_email/lib/elektrine/email/custom_domains.ex new file mode 100644 index 0000000..5ad767e --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/custom_domains.ex @@ -0,0 +1,582 @@ +defmodule Elektrine.Email.CustomDomains do + @moduledoc """ + User-owned custom email domains with DNS-based verification. + """ + + import Ecto.Query, warn: false + require Logger + + alias Ecto.Multi + alias Elektrine.Accounts.User + alias Elektrine.Email.CustomDomain + alias Elektrine.Email.DKIM + alias Elektrine.Repo + + @pending_status "pending" + @verified_status "verified" + @verification_label "_elektrine-verification" + + # A verified domain that starts failing re-verification is given this grace + # window before it is demoted back to pending, so transient DNS hiccups don't + # break mail flow for an otherwise healthy domain. + @verification_grace_period_seconds 72 * 60 * 60 + + @type lookup_result :: {:ok, [String.t()]} | {:error, term()} + + @doc """ + Behaviour for DNS record lookup during custom-domain verification. + """ + @callback lookup_txt(String.t()) :: lookup_result + @callback lookup_mx(String.t()) :: lookup_result + + defmodule DNSResolver do + @moduledoc false + @behaviour Elektrine.Email.CustomDomains + + @impl true + def lookup_txt(host) when is_binary(host) do + host + |> String.to_charlist() + |> :inet_res.lookup(:in, :txt, timeout: 5_000) + |> Enum.map(&normalize_txt_record/1) + |> then(&{:ok, &1}) + rescue + error -> {:error, error} + end + + @impl true + def lookup_mx(host) when is_binary(host) do + host + |> String.to_charlist() + |> :inet_res.lookup(:in, :mx, timeout: 5_000) + |> Enum.map(&normalize_mx_record/1) + |> then(&{:ok, &1}) + rescue + error -> {:error, error} + end + + defp normalize_txt_record(record) when is_binary(record), do: record + + defp normalize_txt_record(record) when is_list(record) do + if Enum.all?(record, &is_integer/1) do + to_string(record) + else + Enum.map_join(record, "", &normalize_txt_record/1) + end + end + + defp normalize_txt_record(record), do: to_string(record) + + defp normalize_mx_record({priority, exchange}) when is_integer(priority) do + normalize_mx_host(exchange) + end + + defp normalize_mx_record(exchange), do: normalize_mx_host(exchange) + + defp normalize_mx_host(host) when is_binary(host) do + host + |> String.trim() + |> String.trim_trailing(".") + |> String.downcase() + end + + defp normalize_mx_host(host) when is_list(host) do + if Enum.all?(host, &is_integer/1) do + host + |> to_string() + |> normalize_mx_host() + else + host + |> Enum.map_join("", &normalize_mx_host/1) + |> normalize_mx_host() + end + end + + defp normalize_mx_host(host), do: host |> to_string() |> normalize_mx_host() + end + + def list_user_custom_domains(user_id) when is_integer(user_id) do + CustomDomain + |> where(user_id: ^user_id) + |> order_by([d], asc: d.domain) + |> Repo.all() + |> Enum.map(&hydrate_custom_domain/1) + end + + def list_user_custom_domains(_), do: [] + + @doc """ + Returns custom domains due for a periodic DNS re-verification, oldest check + first so the staleest records are revisited before newer ones. + """ + def list_custom_domains_for_recheck(limit \\ 500) when is_integer(limit) do + CustomDomain + |> order_by([d], asc_nulls_first: d.last_checked_at) + |> limit(^max(limit, 0)) + |> Repo.all() + |> Enum.map(&hydrate_custom_domain/1) + end + + def list_custom_domains_admin( + search_query \\ "", + status_filter \\ "all", + page \\ 1, + per_page \\ 20 + ) do + normalized_search = normalize_admin_search(search_query) + normalized_status = normalize_admin_status_filter(status_filter) + safe_page = max(page, 1) + safe_per_page = max(per_page, 1) + + base_query = + CustomDomain + |> join(:left, [d], u in assoc(d, :user)) + |> maybe_filter_admin_status(normalized_status) + |> maybe_search_admin(normalized_search) + + total_count = Repo.aggregate(base_query, :count, :id) + + custom_domains = + base_query + |> order_by([d, _u], desc: d.inserted_at, asc: d.domain) + |> preload([_d, u], user: u) + |> limit(^safe_per_page) + |> offset(^((safe_page - 1) * safe_per_page)) + |> Repo.all() + |> Enum.map(&hydrate_custom_domain/1) + + {custom_domains, total_count} + end + + def custom_domain_admin_stats(limit_recent \\ 5) do + total = Repo.aggregate(CustomDomain, :count, :id) + + verified = + Repo.aggregate( + from(d in CustomDomain, where: d.status == ^@verified_status), + :count, + :id + ) + + attention = + Repo.aggregate( + from(d in CustomDomain, + where: + (not is_nil(d.last_error) and d.last_error != "") or + (not is_nil(d.dkim_last_error) and d.dkim_last_error != "") + ), + :count, + :id + ) + + preferred = + Repo.aggregate( + from(d in CustomDomain, + join: u in assoc(d, :user), + where: fragment("lower(coalesce(?, '')) = lower(?)", u.preferred_email_domain, d.domain) + ), + :count, + :id + ) + + recent_domains = + from(d in CustomDomain, + join: u in assoc(d, :user), + preload: [user: u], + order_by: [desc: d.inserted_at, asc: d.domain], + limit: ^max(limit_recent, 0) + ) + |> Repo.all() + + %{ + total: total, + verified: verified, + pending: max(total - verified, 0), + attention: attention, + preferred: preferred, + recent_domains: recent_domains + } + end + + def get_custom_domain(id, user_id) when is_integer(id) and is_integer(user_id) do + CustomDomain + |> where(id: ^id, user_id: ^user_id) + |> Repo.one() + |> hydrate_custom_domain() + end + + def get_custom_domain(_, _), do: nil + + def get_verified_custom_domain(domain) when is_binary(domain) do + normalized_domain = normalize_domain(domain) + + CustomDomain + |> where( + [d], + fragment("lower(?)", d.domain) == ^normalized_domain and d.status == ^@verified_status + ) + |> preload(:user) + |> Repo.one() + |> hydrate_custom_domain() + end + + def get_verified_custom_domain(_), do: nil + + def verified_domains do + CustomDomain + |> where(status: ^@verified_status) + |> select([d], d.domain) + |> Repo.all() + |> Enum.map(&String.downcase/1) + end + + def verified_domains_for_user(user_id) when is_integer(user_id) do + CustomDomain + |> where(user_id: ^user_id, status: ^@verified_status) + |> select([d], d.domain) + |> Repo.all() + |> Enum.map(&String.downcase/1) + end + + def verified_domains_for_user(_), do: [] + + def available_domains_for_user(%User{id: user_id}), do: available_domains_for_user(user_id) + + def available_domains_for_user(user_id) when is_integer(user_id) do + (Elektrine.Domains.supported_email_domains() ++ verified_domains_for_user(user_id)) + |> normalize_domains() + end + + def available_domains_for_user(_), do: Elektrine.Domains.supported_email_domains() + + def user_can_use_domain?(%User{id: user_id}, domain), do: user_can_use_domain?(user_id, domain) + + def user_can_use_domain?(user_id, domain) when is_integer(user_id) and is_binary(domain) do + normalize_domain(domain) in available_domains_for_user(user_id) + end + + def user_can_use_domain?(_, _), do: false + + def create_custom_domain(%User{id: user_id}, attrs), do: create_custom_domain(user_id, attrs) + + def create_custom_domain(user_id, attrs) when is_integer(user_id) and is_map(attrs) do + key_material = DKIM.generate_domain_key_material() + + %CustomDomain{} + |> CustomDomain.changeset(%{ + domain: attrs[:domain] || attrs["domain"], + verification_token: generate_verification_token(), + dkim_selector: key_material.selector, + dkim_public_key: key_material.public_key, + dkim_private_key: key_material.private_key, + status: @pending_status, + user_id: user_id + }) + |> Repo.insert() + |> case do + {:ok, custom_domain} -> {:ok, maybe_sync_custom_domain_dkim(custom_domain)} + error -> error + end + end + + def sync_custom_domain_dkim(%CustomDomain{} = custom_domain) do + custom_domain + |> hydrate_custom_domain() + |> then(fn hydrated -> {:ok, sync_custom_domain_dkim_now(hydrated)} end) + end + + def dns_records_for_custom_domain(%CustomDomain{} = custom_domain) do + custom_domain = hydrate_custom_domain(custom_domain) + + DKIM.dns_records_for_custom_domain( + custom_domain, + verification_host(custom_domain), + verification_value(custom_domain) + ) + end + + def verify_custom_domain(%CustomDomain{} = custom_domain) do + custom_domain = + custom_domain + |> hydrate_custom_domain() + |> maybe_sync_custom_domain_dkim() + + expected_value = verification_value(custom_domain) + verification_host = verification_host(custom_domain) + expected_mx_host = DKIM.mx_host() |> normalize_domain() + now = DateTime.utc_now() |> DateTime.truncate(:second) + + case dns_resolver().lookup_txt(verification_host) do + {:ok, records} -> + if expected_value in Enum.map(records, &normalize_txt_value/1) do + case lookup_mx(verify_domain_host(custom_domain)) do + {:ok, mx_records} -> + if expected_mx_host in Enum.map(mx_records, &normalize_mx_value/1) do + custom_domain + |> Ecto.Changeset.change(%{ + status: @verified_status, + verified_at: custom_domain.verified_at || now, + last_checked_at: now, + last_error: nil, + failing_since: nil + }) + |> Repo.update() + else + persist_verification_failure( + custom_domain, + now, + "Inbound MX record not found: expected #{expected_mx_host}" + ) + end + + {:error, reason} -> + persist_verification_failure( + custom_domain, + now, + "MX lookup failed: #{inspect(reason)}" + ) + end + else + persist_verification_failure(custom_domain, now, "Verification TXT record not found") + end + + {:error, reason} -> + persist_verification_failure(custom_domain, now, "DNS lookup failed: #{inspect(reason)}") + end + end + + def delete_custom_domain(%CustomDomain{} = custom_domain) do + unless Elektrine.Domains.local_email_domain?(custom_domain.domain) do + case DKIM.delete_custom_domain(custom_domain) do + :ok -> + :ok + + {:error, reason} -> + Logger.warning("Failed to remove DKIM material for #{custom_domain.domain}: #{reason}") + end + end + + Multi.new() + |> Multi.run(:reset_preferred_domain, fn repo, _changes -> + case repo.get(User, custom_domain.user_id) do + %User{} = user -> + if String.downcase(user.preferred_email_domain || "") == + String.downcase(custom_domain.domain) do + user + |> Ecto.Changeset.change( + preferred_email_domain: Elektrine.Domains.default_user_handle_domain() + ) + |> repo.update() + else + {:ok, user} + end + + nil -> + {:ok, nil} + end + end) + |> Multi.delete(:custom_domain, custom_domain) + |> Repo.transaction() + |> case do + {:ok, %{custom_domain: deleted_custom_domain}} -> {:ok, deleted_custom_domain} + {:error, _step, reason, _changes} -> {:error, reason} + end + end + + def verification_host(%CustomDomain{domain: domain}) when is_binary(domain) do + verification_host(domain) + end + + def verification_host(domain) when is_binary(domain) do + "#{@verification_label}.#{normalize_domain(domain)}" + end + + def verification_value(%CustomDomain{verification_token: token}) when is_binary(token) do + verification_value(token) + end + + def verification_value(token) when is_binary(token) do + "elektrine-site-verification=#{token}" + end + + defp hydrate_custom_domain(nil), do: nil + + defp hydrate_custom_domain(%CustomDomain{} = custom_domain) do + if present?(custom_domain.dkim_selector) and present?(custom_domain.dkim_public_key) and + present?(custom_domain.dkim_private_key) do + custom_domain + else + key_material = DKIM.generate_domain_key_material() + + custom_domain + |> Ecto.Changeset.change(%{ + dkim_selector: key_material.selector, + dkim_public_key: key_material.public_key, + dkim_private_key: key_material.private_key, + dkim_synced_at: nil, + dkim_last_error: custom_domain.dkim_last_error + }) + |> Repo.update() + |> case do + {:ok, updated_custom_domain} -> updated_custom_domain + {:error, _changeset} -> custom_domain + end + end + end + + defp maybe_sync_custom_domain_dkim(%CustomDomain{} = custom_domain) do + if custom_domain.dkim_synced_at && !present?(custom_domain.dkim_last_error) do + custom_domain + else + sync_custom_domain_dkim_now(custom_domain) + end + end + + defp sync_custom_domain_dkim_now(%CustomDomain{} = custom_domain) do + case DKIM.sync_custom_domain(custom_domain) do + :ok -> + custom_domain + |> Ecto.Changeset.change(%{ + dkim_synced_at: DateTime.utc_now() |> DateTime.truncate(:second), + dkim_last_error: nil + }) + |> Repo.update() + |> case do + {:ok, updated_custom_domain} -> updated_custom_domain + {:error, _changeset} -> custom_domain + end + + {:error, reason} -> + custom_domain + |> Ecto.Changeset.change(%{ + dkim_synced_at: nil, + dkim_last_error: truncate_error(reason) + }) + |> Repo.update() + |> case do + {:ok, updated_custom_domain} -> updated_custom_domain + {:error, _changeset} -> custom_domain + end + end + end + + defp present?(value) when is_binary(value), do: Elektrine.Strings.present?(value) + defp present?(_), do: false + + defp truncate_error(value, max_length \\ 255) do + value + |> to_string() + |> String.trim() + |> String.slice(0, max_length) + end + + defp persist_verification_failure(custom_domain, now, error_message) do + custom_domain + |> Ecto.Changeset.change( + verification_failure_attrs(custom_domain, now, truncate_error(error_message)) + ) + |> Repo.update() + end + + # Pending domains simply record the failure. Verified domains stay verified + # until they have been failing continuously past the grace window, at which + # point they are demoted back to pending. + defp verification_failure_attrs(custom_domain, now, error_message) do + base = %{last_checked_at: now, last_error: error_message} + + if custom_domain.status == @verified_status do + failing_since = custom_domain.failing_since || now + + if DateTime.diff(now, failing_since, :second) >= @verification_grace_period_seconds do + Map.merge(base, %{status: @pending_status, verified_at: nil, failing_since: nil}) + else + Map.put(base, :failing_since, failing_since) + end + else + Map.merge(base, %{status: @pending_status, failing_since: nil}) + end + end + + defp dns_resolver do + Application.get_env(:elektrine, :custom_domain_txt_resolver, DNSResolver) + end + + defp lookup_mx(host) do + resolver = dns_resolver() + + if function_exported?(resolver, :lookup_mx, 1) do + resolver.lookup_mx(host) + else + DNSResolver.lookup_mx(host) + end + end + + defp verify_domain_host(%CustomDomain{domain: domain}), do: domain + + defp generate_verification_token do + 18 + |> :crypto.strong_rand_bytes() + |> Base.url_encode64(padding: false) + end + + defp normalize_domain(domain) do + domain + |> String.trim() + |> String.trim_leading(".") + |> String.trim_trailing(".") + |> String.downcase() + end + + defp normalize_domains(domains) do + domains + |> Enum.map(&normalize_domain/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + defp normalize_txt_value(value) when is_binary(value), do: String.trim(value) + defp normalize_txt_value(value), do: value |> to_string() |> String.trim() + + defp normalize_mx_value(value) when is_binary(value), do: normalize_domain(value) + defp normalize_mx_value(value), do: value |> to_string() |> normalize_domain() + + defp maybe_search_admin(query, ""), do: query + + defp maybe_search_admin(query, search_query) do + search_pattern = "%#{search_query}%" + + from([d, u] in query, + where: + ilike(d.domain, ^search_pattern) or + ilike(fragment("coalesce(?, '')", u.username), ^search_pattern) or + ilike(fragment("coalesce(?, '')", u.preferred_email_domain), ^search_pattern) + ) + end + + defp maybe_filter_admin_status(query, "all"), do: query + + defp maybe_filter_admin_status(query, "attention") do + from([d, _u] in query, + where: + (not is_nil(d.last_error) and d.last_error != "") or + (not is_nil(d.dkim_last_error) and d.dkim_last_error != "") + ) + end + + defp maybe_filter_admin_status(query, status) do + from([d, _u] in query, where: d.status == ^status) + end + + defp normalize_admin_search(search_query) when is_binary(search_query) do + search_query |> String.trim() + end + + defp normalize_admin_search(_), do: "" + + defp normalize_admin_status_filter(status_filter) + when status_filter in ["all", "pending", "verified", "attention"] do + status_filter + end + + defp normalize_admin_status_filter(_), do: "all" +end diff --git a/apps/elektrine_email/lib/elektrine/email/custom_folders.ex b/apps/elektrine_email/lib/elektrine/email/custom_folders.ex new file mode 100644 index 0000000..5469e60 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/custom_folders.ex @@ -0,0 +1,205 @@ +defmodule Elektrine.Email.CustomFolders do + @moduledoc """ + Context module for managing custom email folders. + """ + import Ecto.Query + alias Elektrine.Email.{Folder, Mailbox, Message} + alias Elektrine.Repo + alias Elektrine.Telemetry.Events + + @max_folders_per_user 25 + + @doc """ + Lists all folders for a user (flat list). + """ + def list_folders(user_id) do + Folder + |> where(user_id: ^user_id) + |> order_by(asc: :name) + |> Repo.all() + end + + @doc """ + Lists root folders (no parent) for a user. + """ + def list_root_folders(user_id) do + Folder + |> where(user_id: ^user_id, parent_id: is_nil(nil)) + |> order_by(asc: :name) + |> Repo.all() + end + + @doc """ + Gets a folder by ID for a user. + """ + def get_folder(id, user_id) do + Folder + |> where(id: ^id, user_id: ^user_id) + |> Repo.one() + end + + @doc """ + Gets a folder with its children preloaded. + """ + def get_folder_with_children(id, user_id) do + Folder + |> where(id: ^id, user_id: ^user_id) + |> preload(:children) + |> Repo.one() + end + + @doc """ + Creates a folder. + """ + def create_folder(attrs) do + user_id = Map.get(attrs, :user_id) || Map.get(attrs, "user_id") + + if count_folders(user_id) >= @max_folders_per_user do + {:error, :limit_reached} + else + %Folder{} + |> Folder.changeset(attrs) + |> Repo.insert() + end + end + + @doc """ + Updates a folder. + """ + def update_folder(%Folder{} = folder, attrs) do + folder + |> Folder.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a folder. + Messages in the folder will have their folder_id set to nil. + """ + def delete_folder(%Folder{} = folder) do + Repo.delete(folder) + end + + @doc """ + Counts folders for a user. + """ + def count_folders(user_id) do + Folder + |> where(user_id: ^user_id) + |> select(count()) + |> Repo.one() + end + + @doc """ + Moves a message to a folder. + Accepts either a message struct or a message_id integer. + """ + def move_message_to_folder(%Elektrine.Email.Message{} = message, folder_id) do + with :ok <- validate_folder_for_message_owner(message, folder_id) do + Elektrine.Email.update_message_flags(message.id, message.mailbox_id, %{folder_id: folder_id}) + end + end + + def move_message_to_folder(message_id, folder_id) when is_integer(message_id) do + case Elektrine.Email.get_message_internal(message_id) do + %Message{} = message -> move_message_to_folder(message, folder_id) + nil -> {:error, :not_found} + end + end + + @doc """ + Lists messages in a folder. + """ + def list_folder_messages(folder_id, user_id, page \\ 1, per_page \\ 20) do + started_at = System.monotonic_time(:millisecond) + folder = get_folder(folder_id, user_id) + + result = + if folder do + offset = (page - 1) * per_page + + messages = + Message + |> join(:inner, [m], mb in Mailbox, on: mb.id == m.mailbox_id) + |> where( + [m, mb], + m.folder_id == ^folder_id and m.deleted == false and mb.user_id == ^user_id + ) + |> order_by(desc: :inserted_at) + |> limit(^per_page) + |> offset(^offset) + |> Repo.all() + + total = + Message + |> join(:inner, [m], mb in Mailbox, on: mb.id == m.mailbox_id) + |> where( + [m, mb], + m.folder_id == ^folder_id and m.deleted == false and mb.user_id == ^user_id + ) + |> select(count()) + |> Repo.one() + + %{ + messages: messages, + total: total, + page: page, + per_page: per_page, + has_next: offset + per_page < total, + has_prev: page > 1 + } + else + %{ + messages: [], + total: 0, + page: page, + per_page: per_page, + has_next: false, + has_prev: false + } + end + + Events.db_hot_path( + :email_custom_folders, + :list_folder_messages, + System.monotonic_time(:millisecond) - started_at, + %{folder_id: folder_id, user_id: user_id, page: page} + ) + + result + end + + defp validate_folder_for_message_owner(_message, nil), do: :ok + + defp validate_folder_for_message_owner(%Message{mailbox_id: mailbox_id}, folder_id) + when is_integer(folder_id) do + with %Mailbox{user_id: user_id} when is_integer(user_id) <- + Elektrine.Email.get_mailbox_internal(mailbox_id), + %Folder{} <- get_folder(folder_id, user_id) do + :ok + else + _ -> {:error, :invalid_folder} + end + end + + defp validate_folder_for_message_owner(_message, _folder_id), do: {:error, :invalid_folder} + + @doc """ + Gets folder hierarchy as a tree structure. + """ + def get_folder_tree(user_id) do + folders = list_folders(user_id) + build_tree(folders, nil) + end + + defp build_tree(folders, parent_id) do + folders + |> Enum.filter(&(&1.parent_id == parent_id)) + |> Enum.map(fn folder -> + %{ + folder: folder, + children: build_tree(folders, folder.id) + } + end) + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/deliverability.ex b/apps/elektrine_email/lib/elektrine/email/deliverability.ex new file mode 100644 index 0000000..97cb7a2 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/deliverability.ex @@ -0,0 +1,123 @@ +defmodule Elektrine.Email.Deliverability do + @moduledoc """ + Deliverability health checks for outbound domains. + + These checks are intentionally side-effect free so they can back admin health + screens and preflight custom-domain sending without blocking normal delivery. + """ + + def check_domain(nil), do: %{domain: nil, spf: :missing, dmarc: :missing, mx: :missing} + + def check_domain(domain) do + domain = domain |> to_string() |> String.trim() |> String.downcase() + + %{ + domain: domain, + mx: dns_present?(domain, :mx), + spf: spf_status(domain), + dmarc: dmarc_status(domain), + dkim: :not_checked, + reverse_dns: :not_checked + } + end + + def outbound_allowed?(from_address, user_id) + when is_binary(from_address) and is_integer(user_id) do + domain = email_domain(from_address) + + cond do + is_nil(domain) -> + {:error, :invalid_sender_domain} + + domain in Elektrine.Domains.supported_email_domains() -> + :ok + + true -> + validate_custom_domain(domain, user_id) + end + end + + def outbound_allowed?(_, _), do: {:error, :invalid_sender_domain} + + defp validate_custom_domain(domain, user_id) do + case Elektrine.Email.CustomDomains.get_verified_custom_domain(domain) do + %{user_id: ^user_id, dkim_synced_at: synced_at, dkim_last_error: error} + when not is_nil(synced_at) and (is_nil(error) or error == "") -> + if enforce_dns_health?() do + validate_dns_health(domain) + else + :ok + end + + %{user_id: ^user_id} -> + {:error, :custom_domain_dkim_not_ready} + + %{user_id: _other_user_id} -> + {:error, :custom_domain_not_owned} + + nil -> + {:error, :custom_domain_not_verified} + end + end + + defp validate_dns_health(domain) do + health = check_domain(domain) + + if health.mx == :present and health.spf == :present and health.dmarc == :present do + :ok + else + {:error, {:custom_domain_dns_unhealthy, health}} + end + end + + defp enforce_dns_health? do + Application.get_env(:elektrine, :enforce_custom_domain_dns_health, false) == true + end + + defp spf_status(domain) do + domain + |> txt_records() + |> Enum.any?(&String.starts_with?(&1, "v=spf1")) + |> present_status() + end + + defp dmarc_status(domain) do + "_dmarc.#{domain}" + |> txt_records() + |> Enum.any?(&String.starts_with?(&1, "v=DMARC1")) + |> present_status() + end + + defp dns_present?(domain, type) do + case :inet_res.lookup(String.to_charlist(domain), :in, type) do + [] -> :missing + _ -> :present + end + rescue + _ -> :unknown + end + + defp txt_records(domain) do + domain + |> String.to_charlist() + |> :inet_res.lookup(:in, :txt) + |> Enum.map(fn chunks -> chunks |> List.flatten() |> to_string() end) + rescue + _ -> [] + end + + defp present_status(true), do: :present + defp present_status(false), do: :missing + + defp email_domain(address) do + address + |> Elektrine.Email.extract_email_address() + |> String.split("@", parts: 2) + |> case do + [_local, domain] -> domain |> String.trim() |> String.downcase() + _ -> nil + end + rescue + _ -> nil + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/dkim.ex b/apps/elektrine_email/lib/elektrine/email/dkim.ex new file mode 100644 index 0000000..59faf8f --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/dkim.ex @@ -0,0 +1,507 @@ +defmodule Elektrine.Email.DKIM do + @moduledoc """ + DKIM helpers for managed custom email domains. + """ + + alias Elektrine.Email.CustomDomain + + @default_selector "default" + @default_mx_priority 10 + @default_dmarc_policy "quarantine" + @default_dmarc_alignment "s" + @default_timeout_ms 10_000 + @default_haraka_path "/api/v1/dkim/domains" + + @type dns_record :: %{ + label: String.t(), + type: String.t(), + host: String.t(), + value: String.t(), + priority: integer() | nil + } + + defmodule FinchClient do + @moduledoc false + + def request(method, url, headers, body, opts) do + method + |> Finch.build(url, headers, body) + |> Finch.request(Elektrine.Finch, opts) + end + end + + @spec generate_domain_key_material() :: %{ + selector: String.t(), + public_key: String.t(), + private_key: String.t() + } + def generate_domain_key_material do + selector = configured_selector() + {public_key, private_key} = generate_key_pair() + + %{ + selector: selector, + public_key: public_key, + private_key: private_key + } + end + + @spec sync_custom_domain(CustomDomain.t()) :: :ok | {:error, String.t()} + def sync_custom_domain(%CustomDomain{} = custom_domain) do + with :ok <- ensure_sync_ready(custom_domain), + {:ok, request_config} <- request_config(custom_domain.domain) do + body = + Jason.encode!(%{ + selector: custom_domain.dkim_selector, + private_key: custom_domain.dkim_private_key + }) + + case http_client().request( + :put, + request_config.url, + request_config.headers, + body, + receive_timeout: request_config.timeout + ) do + {:ok, %Finch.Response{status: status}} when status in 200..299 -> + :ok + + {:ok, %Finch.Response{status: status, body: response_body}} -> + {:error, + "Haraka DKIM sync failed with status #{status}: #{normalize_error_body(response_body)}"} + + {:error, reason} -> + {:error, "Haraka DKIM sync failed: #{inspect(reason)}"} + end + end + end + + @spec sync_domain(String.t(), String.t(), String.t()) :: :ok | {:error, String.t()} + def sync_domain(domain, selector, private_key) + when is_binary(domain) and is_binary(selector) and is_binary(private_key) do + with true <- Elektrine.Strings.present?(selector) and Elektrine.Strings.present?(private_key), + {:ok, request_config} <- request_config(domain) do + body = Jason.encode!(%{selector: selector, private_key: private_key}) + + case http_client().request( + :put, + request_config.url, + request_config.headers, + body, + receive_timeout: request_config.timeout + ) do + {:ok, %Finch.Response{status: status}} when status in 200..299 -> + :ok + + {:ok, %Finch.Response{status: status, body: response_body}} -> + {:error, + "Haraka DKIM sync failed with status #{status}: #{normalize_error_body(response_body)}"} + + {:error, reason} -> + {:error, "Haraka DKIM sync failed: #{inspect(reason)}"} + end + else + false -> {:error, "DKIM key material is missing"} + error -> error + end + end + + @spec fetch_domain(String.t()) :: + {:ok, + %{ + selector: String.t(), + host: String.t(), + value: String.t() | nil, + public_key: String.t() | nil, + private_key_present: boolean(), + notes: [String.t()] + }} + | {:error, String.t()} + def fetch_domain(domain) when is_binary(domain) do + with {:ok, request_config} <- request_config(domain) do + case http_client().request( + :get, + request_config.url, + request_config.headers, + "", + receive_timeout: request_config.timeout + ) do + {:ok, %Finch.Response{status: status, body: response_body}} when status in 200..299 -> + decode_domain_config(response_body, domain) + + {:ok, %Finch.Response{status: 404}} -> + {:error, "Haraka does not have DKIM data for #{domain}."} + + {:ok, %Finch.Response{status: status, body: response_body}} -> + {:error, + "Haraka DKIM lookup failed with status #{status}: #{normalize_error_body(response_body)}"} + + {:error, reason} -> + {:error, "Haraka DKIM lookup failed: #{inspect(reason)}"} + end + end + end + + @spec delete_custom_domain(CustomDomain.t()) :: :ok | {:error, String.t()} + def delete_custom_domain(%CustomDomain{} = custom_domain) do + with {:ok, request_config} <- request_config(custom_domain.domain) do + case http_client().request( + :delete, + request_config.url, + request_config.headers, + "", + receive_timeout: request_config.timeout + ) do + {:ok, %Finch.Response{status: status}} when status in 200..299 -> + :ok + + {:ok, %Finch.Response{status: 404}} -> + :ok + + {:ok, %Finch.Response{status: status, body: response_body}} -> + {:error, + "Haraka DKIM delete failed with status #{status}: #{normalize_error_body(response_body)}"} + + {:error, reason} -> + {:error, "Haraka DKIM delete failed: #{inspect(reason)}"} + end + end + end + + @spec dns_records_for_custom_domain(CustomDomain.t(), String.t(), String.t()) :: [dns_record()] + def dns_records_for_custom_domain( + %CustomDomain{} = custom_domain, + verification_host, + verification_value + ) do + [ + %{ + label: "Ownership TXT", + type: "TXT", + host: verification_host, + value: verification_value, + priority: nil + }, + %{ + label: "Inbound MX", + type: "MX", + host: custom_domain.domain, + value: mx_host(), + priority: mx_priority() + }, + %{ + label: "SPF", + type: "TXT", + host: custom_domain.domain, + value: spf_value(), + priority: nil + }, + %{ + label: "DKIM", + type: "TXT", + host: "#{custom_domain.dkim_selector}._domainkey.#{custom_domain.domain}", + value: dkim_value(custom_domain), + priority: nil + }, + %{ + label: "DMARC", + type: "TXT", + host: "_dmarc.#{custom_domain.domain}", + value: dmarc_value(), + priority: nil + } + ] + end + + @spec dkim_value(CustomDomain.t()) :: String.t() + def dkim_value(%CustomDomain{} = custom_domain) do + dkim_value_from_material(custom_domain.dkim_public_key, custom_domain.dkim_private_key) + end + + def dkim_value(%{dkim_public_key: public_key} = key_material) when is_binary(public_key) do + private_key = + Map.get(key_material, :dkim_private_key) || Map.get(key_material, "dkim_private_key") + + dkim_value_from_material(public_key, private_key) + end + + @spec dkim_value_from_material(String.t() | nil, String.t() | nil) :: String.t() + def dkim_value_from_material(public_key, private_key) do + "v=DKIM1; k=rsa; p=#{public_key_dns_value(public_key_from_material(public_key, private_key))}" + end + + @spec public_key_from_material(String.t() | nil, String.t() | nil) :: String.t() | nil + def public_key_from_material(public_key, private_key) do + case public_key_from_private_key(private_key) do + {:ok, derived_public_key} -> derived_public_key + {:error, _reason} -> public_key + end + end + + @spec public_key_from_private_key(String.t() | nil) :: {:ok, String.t()} | {:error, atom()} + def public_key_from_private_key(private_key_pem) when is_binary(private_key_pem) do + with [entry | _] <- :public_key.pem_decode(private_key_pem), + {:ok, private_key} <- decode_pem_entry(entry), + {:ok, public_key} <- rsa_public_key(private_key), + {:ok, public_pem} <- encode_public_key(public_key) do + {:ok, public_pem} + else + [] -> {:error, :invalid_private_key} + {:error, reason} -> {:error, reason} + _ -> {:error, :invalid_private_key} + end + end + + def public_key_from_private_key(_), do: {:error, :invalid_private_key} + + @spec mx_host() :: String.t() + def mx_host do + email_config() + |> Keyword.get(:custom_domain_mx_host, default_mail_host()) + end + + @spec mx_priority() :: integer() + def mx_priority do + email_config() + |> Keyword.get(:custom_domain_mx_priority, @default_mx_priority) + end + + @spec spf_value() :: String.t() + def spf_value do + case email_config()[:custom_domain_spf_include] do + value when is_binary(value) -> + if Elektrine.Strings.present?(value), + do: "v=spf1 include:#{value} ~all", + else: "v=spf1 mx ~all" + + _ -> + "v=spf1 mx ~all" + end + end + + @spec dmarc_value() :: String.t() + def dmarc_value do + email_cfg = email_config() + policy = Keyword.get(email_cfg, :custom_domain_dmarc_policy, @default_dmarc_policy) + rua = Keyword.get(email_cfg, :custom_domain_dmarc_rua) + adkim = Keyword.get(email_cfg, :custom_domain_dmarc_adkim, @default_dmarc_alignment) + aspf = Keyword.get(email_cfg, :custom_domain_dmarc_aspf, @default_dmarc_alignment) + + [ + "v=DMARC1", + "p=#{policy}", + "adkim=#{adkim}", + "aspf=#{aspf}", + dmarc_rua_fragment(rua) + ] + |> Enum.reject(&is_nil/1) + |> Enum.join("; ") + end + + @spec public_key_dns_value(String.t()) :: String.t() + def public_key_dns_value(public_key_pem) when is_binary(public_key_pem) do + public_key_pem + |> String.replace(~r/-----BEGIN PUBLIC KEY-----/, "") + |> String.replace(~r/-----END PUBLIC KEY-----/, "") + |> String.replace(~r/\s+/, "") + end + + def public_key_dns_value(_), do: "" + + defp decode_pem_entry(entry) do + {:ok, :public_key.pem_entry_decode(entry)} + rescue + _ -> {:error, :invalid_private_key} + catch + _, _ -> {:error, :invalid_private_key} + end + + defp rsa_public_key(private_key) when is_tuple(private_key) and tuple_size(private_key) >= 4 do + case elem(private_key, 0) do + :RSAPrivateKey -> {:ok, {:RSAPublicKey, elem(private_key, 2), elem(private_key, 3)}} + _ -> {:error, :unsupported_private_key} + end + end + + defp rsa_public_key(_), do: {:error, :unsupported_private_key} + + defp encode_public_key(public_key) do + {:ok, + :public_key.pem_encode([:public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key)])} + rescue + _ -> {:error, :invalid_public_key} + catch + _, _ -> {:error, :invalid_public_key} + end + + defp ensure_sync_ready(%CustomDomain{dkim_selector: selector, dkim_private_key: private_key}) + when is_binary(selector) and is_binary(private_key) do + if Elektrine.Strings.present?(selector) and Elektrine.Strings.present?(private_key) do + if sync_enabled?(), do: :ok, else: {:error, "Haraka DKIM sync is disabled"} + else + {:error, "DKIM key material is missing"} + end + end + + defp ensure_sync_ready(_), do: {:error, "DKIM key material is missing"} + + defp decode_domain_config(body, domain) when is_binary(body) do + case Jason.decode(body) do + {:ok, %{} = payload} -> + payload = unwrap_fetch_payload(payload) + selector = present_string(payload, ["selector", "dkim_selector"]) || configured_selector() + public_key = present_string(payload, ["public_key", "dkim_public_key"]) + private_key = present_string(payload, ["private_key", "dkim_private_key"]) + + value = + present_string(payload, ["value", "dkim_value", "txt_value", "dns_value"]) || + if(public_key, do: "v=DKIM1; k=rsa; p=#{public_key_dns_value(public_key)}") + + notes = + [ + if(is_nil(value) and public_key, do: "Computed TXT value from Haraka public key."), + if(is_nil(public_key) and value, + do: "Haraka returned a TXT value without a separate public key field." + ) + ] + |> Enum.reject(&is_nil/1) + + {:ok, + %{ + selector: selector, + host: payload["host"] || "#{selector}._domainkey.#{domain}", + value: value, + public_key: public_key, + private_key_present: !is_nil(private_key), + notes: notes + }} + + {:error, _reason} -> + {:error, "Haraka returned invalid JSON for #{domain}."} + end + end + + defp decode_domain_config(_, domain), + do: {:error, "Haraka returned an empty DKIM response for #{domain}."} + + defp unwrap_fetch_payload(%{"data" => %{} = payload}), do: payload + defp unwrap_fetch_payload(%{"domain" => %{} = payload}), do: payload + defp unwrap_fetch_payload(payload), do: payload + + defp present_string(payload, keys) when is_map(payload) do + Enum.find_value(keys, fn key -> + case Map.get(payload, key) do + value when is_binary(value) -> + value = String.trim(value) + if value == "", do: nil, else: value + + _ -> + nil + end + end) + end + + defp request_config(domain) do + email_cfg = email_config() + base_url = Keyword.get(email_cfg, :custom_domain_haraka_base_url) + api_key = Keyword.get(email_cfg, :custom_domain_haraka_api_key) + + cond do + !Elektrine.Strings.present?(base_url) -> + {:error, "Haraka DKIM sync base URL is not configured"} + + !Elektrine.Strings.present?(api_key) -> + {:error, "Haraka DKIM sync API key is not configured"} + + true -> + path = + email_cfg + |> Keyword.get(:custom_domain_haraka_dkim_path, @default_haraka_path) + |> String.trim_trailing("/") + + {:ok, + %{ + url: "#{String.trim_trailing(base_url, "/")}#{path}/#{URI.encode(domain)}", + headers: [ + {"content-type", "application/json"}, + {"x-api-key", api_key}, + {"user-agent", "Elektrine-Custom-Domain-DKIM/1.0"} + ], + timeout: Keyword.get(email_cfg, :custom_domain_haraka_timeout, @default_timeout_ms) + }} + end + end + + defp sync_enabled? do + Keyword.get(email_config(), :custom_domain_dkim_sync_enabled, true) + end + + defp normalize_error_body(body) when is_binary(body) do + case Jason.decode(body) do + {:ok, %{"error" => error}} when is_binary(error) -> error + {:ok, %{"message" => message}} when is_binary(message) -> message + _ -> String.trim(body) + end + end + + defp normalize_error_body(body), do: inspect(body) + + defp dmarc_rua_fragment(value) when is_binary(value) do + value = String.trim(value) + + cond do + value == "" -> + nil + + String.starts_with?(String.downcase(value), "mailto:") -> + "rua=#{value}" + + true -> + "rua=mailto:#{value}" + end + end + + defp dmarc_rua_fragment(_), do: nil + + defp configured_selector do + email_config() + |> Keyword.get(:custom_domain_dkim_selector, @default_selector) + |> normalize_selector() + end + + defp normalize_selector(selector) when is_binary(selector) do + selector + |> String.trim() + |> case do + "" -> @default_selector + value -> value + end + end + + defp default_mail_host do + Elektrine.Domains.primary_email_domain() + end + + defp email_config do + Elektrine.RuntimeEnv.app_config(:email, []) + end + + defp http_client do + Keyword.get(email_config(), :custom_domain_http_client, Elektrine.Email.DKIM.FinchClient) + end + + defp generate_key_pair do + private_key = :public_key.generate_key({:rsa, 2048, 65_537}) + + private_pem = + :public_key.pem_encode([:public_key.pem_entry_encode(:RSAPrivateKey, private_key)]) + + {:RSAPrivateKey, _version, modulus, exponent, _d, _p, _q, _e1, _e2, _c, _other} = private_key + public_key = {:RSAPublicKey, modulus, exponent} + + public_pem = + :public_key.pem_encode([:public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key)]) + + {public_pem, private_pem} + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/export.ex b/apps/elektrine_email/lib/elektrine/email/export.ex new file mode 100644 index 0000000..c68c57f --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/export.ex @@ -0,0 +1,84 @@ +defmodule Elektrine.Email.Export do + @moduledoc """ + Schema for email export jobs. + Tracks the status of email export requests. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "email_exports" do + field :status, :string, default: "pending" + field :format, :string, default: "mbox" + field :file_path, :string + field :file_size, :integer + field :message_count, :integer + field :started_at, :utc_datetime + field :completed_at, :utc_datetime + field :error, :string + field :filters, :map, default: %{} + + belongs_to :user, Elektrine.Accounts.User + + timestamps() + end + + @valid_statuses ~w(pending processing completed failed) + @valid_formats ~w(mbox eml zip) + + @doc """ + Creates a changeset for an export job. + """ + def changeset(export, attrs) do + export + |> cast(attrs, [ + :status, + :format, + :file_path, + :file_size, + :message_count, + :started_at, + :completed_at, + :error, + :filters, + :user_id + ]) + |> validate_required([:user_id]) + |> validate_inclusion(:status, @valid_statuses) + |> validate_inclusion(:format, @valid_formats) + |> foreign_key_constraint(:user_id) + end + + @doc """ + Marks an export as started. + """ + def start_changeset(export) do + export + |> change() + |> put_change(:status, "processing") + |> put_change(:started_at, DateTime.utc_now() |> DateTime.truncate(:second)) + end + + @doc """ + Marks an export as completed. + """ + def complete_changeset(export, file_path, file_size, message_count) do + export + |> change() + |> put_change(:status, "completed") + |> put_change(:file_path, file_path) + |> put_change(:file_size, file_size) + |> put_change(:message_count, message_count) + |> put_change(:completed_at, DateTime.utc_now() |> DateTime.truncate(:second)) + end + + @doc """ + Marks an export as failed. + """ + def fail_changeset(export, error) do + export + |> change() + |> put_change(:status, "failed") + |> put_change(:error, error) + |> put_change(:completed_at, DateTime.utc_now() |> DateTime.truncate(:second)) + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/exports.ex b/apps/elektrine_email/lib/elektrine/email/exports.ex new file mode 100644 index 0000000..0e8a447 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/exports.ex @@ -0,0 +1,320 @@ +defmodule Elektrine.Email.Exports do + @moduledoc """ + Context module for managing email exports. + """ + import Ecto.Query + alias Elektrine.Email.Export + alias Elektrine.Email.Message + alias Elektrine.Repo + alias Elektrine.Security.FilePath + + require Logger + + # Export directory - configurable via :elektrine, :export_dir + defp export_dir, do: Application.get_env(:elektrine, :export_dir, "/tmp/elektrine/exports") + + @doc """ + Lists all exports for a user. + """ + def list_exports(user_id) do + Export + |> where(user_id: ^user_id) + |> order_by(desc: :inserted_at) + |> Repo.all() + end + + @doc """ + Gets an export by ID for a user. + """ + def get_export(id, user_id) do + Export + |> where(id: ^id, user_id: ^user_id) + |> Repo.one() + end + + @doc """ + Creates an export job. + """ + def create_export(attrs) do + %Export{} + |> Export.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Starts an export job asynchronously. + """ + def start_export(user_id, format \\ "mbox", filters \\ %{}) do + case create_export(%{user_id: user_id, format: format, filters: filters}) do + {:ok, export} -> + # Start async processing + Elektrine.Async.start(fn -> process_export(export) end) + {:ok, export} + + {:error, changeset} -> + {:error, changeset} + end + end + + @doc """ + Processes an export job. + """ + def process_export(%Export{} = export) do + # Mark as processing + {:ok, export} = update_export(export, Export.start_changeset(export)) + + try do + # Get user's mailbox + mailbox = Elektrine.Email.get_user_mailbox(export.user_id) + + if mailbox do + # Get messages based on filters + messages = get_export_messages(mailbox.id, export.filters) + + # Generate export file + {file_path, file_size} = generate_export_file(export, messages, mailbox) + + # Mark as completed + {:ok, updated_export} = + update_export( + export, + Export.complete_changeset(export, file_path, file_size, length(messages)) + ) + + # Broadcast completion to LiveView + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "email:exports:#{export.user_id}", + {:export_completed, updated_export} + ) + else + {:ok, updated_export} = + update_export(export, Export.fail_changeset(export, "No mailbox found")) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "email:exports:#{export.user_id}", + {:export_failed, updated_export} + ) + end + rescue + e -> + Logger.error("Export failed: #{inspect(e)}") + {:ok, updated_export} = update_export(export, Export.fail_changeset(export, inspect(e))) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "email:exports:#{export.user_id}", + {:export_failed, updated_export} + ) + end + end + + @doc """ + Updates an export. + """ + def update_export(%Export{} = export, changeset_or_attrs) do + changeset = + if is_map(changeset_or_attrs) and not match?(%Ecto.Changeset{}, changeset_or_attrs) do + Export.changeset(export, changeset_or_attrs) + else + changeset_or_attrs + end + + Repo.update(changeset) + end + + @doc """ + Deletes an export and its file. + """ + def delete_export(%Export{} = export) do + # Delete file if exists + if export.file_path do + case FilePath.validate_existing_file(export.file_path, export_dir()) do + {:ok, file_path} -> File.rm(file_path) + {:error, _reason} -> :ok + end + end + + Repo.delete(export) + end + + @doc """ + Gets the download path for an export. + """ + def get_download_path(%Export{file_path: file_path, status: "completed"}) + when is_binary(file_path) do + case FilePath.validate_existing_file(file_path, export_dir()) do + {:ok, file_path} -> {:ok, file_path} + {:error, _reason} -> {:error, :file_not_found} + end + end + + def get_download_path(_), do: {:error, :not_ready} + + # Private functions + + defp get_export_messages(mailbox_id, filters) do + query = + Message + |> where(mailbox_id: ^mailbox_id) + |> order_by(desc: :inserted_at) + + query = apply_export_filters(query, filters) + Repo.all(query) + end + + defp apply_export_filters(query, filters) when is_map(filters) do + query + |> maybe_filter_by_folder(filters) + |> maybe_filter_by_date_range(filters) + |> maybe_filter_by_status(filters) + end + + defp apply_export_filters(query, _), do: query + + defp maybe_filter_by_folder(query, %{"folder" => folder}) + when folder in ["inbox", "sent", "spam", "archive", "trash"] do + case folder do + "inbox" -> + where( + query, + [m], + m.spam == false and m.archived == false and m.deleted == false and m.status != "sent" + ) + + "sent" -> + where(query, [m], m.status == "sent" and m.deleted == false) + + "spam" -> + where(query, [m], m.spam == true and m.deleted == false) + + "archive" -> + where(query, [m], m.archived == true and m.deleted == false) + + "trash" -> + where(query, [m], m.deleted == true) + end + end + + defp maybe_filter_by_folder(query, _), do: query + + defp maybe_filter_by_date_range(query, %{"start_date" => start_date, "end_date" => end_date}) do + query + |> where([m], m.inserted_at >= ^start_date) + |> where([m], m.inserted_at <= ^end_date) + end + + defp maybe_filter_by_date_range(query, _), do: query + + defp maybe_filter_by_status(query, %{"include_deleted" => false}) do + where(query, [m], m.deleted == false) + end + + defp maybe_filter_by_status(query, _), do: query + + defp generate_export_file(export, messages, mailbox) do + # Ensure export directory exists + File.mkdir_p!(export_dir()) + + filename = + "export_#{export.user_id}_#{export.id}_#{System.system_time(:second)}.#{export.format}" + + file_path = Path.join(export_dir(), filename) + + case export.format do + "mbox" -> generate_mbox(file_path, messages, mailbox) + "eml" -> generate_eml_zip(file_path, messages, mailbox) + "zip" -> generate_eml_zip(file_path, messages, mailbox) + _ -> generate_mbox(file_path, messages, mailbox) + end + end + + defp generate_mbox(file_path, messages, mailbox) do + content = + messages + |> Enum.map_join("\n", &message_to_mbox(&1, mailbox)) + + File.write!(file_path, content) + {file_path, byte_size(content)} + end + + defp message_to_mbox(message, mailbox) do + # Decrypt content if needed + message = Message.decrypt_content(message, mailbox.user_id) + + date = format_mbox_date(message.inserted_at) + from_addr = extract_email(message.from) + + """ + From #{from_addr} #{date} + From: #{message.from} + To: #{message.to} + #{if message.cc, do: "Cc: #{message.cc}\n", else: ""}Subject: #{message.subject} + Date: #{format_rfc2822_date(message.inserted_at)} + Message-ID: #{message.message_id} + Content-Type: text/plain; charset=UTF-8 + + #{message.text_body || strip_html(message.html_body || "")} + + """ + end + + defp generate_eml_zip(file_path, messages, mailbox) do + # Create a zip file containing individual .eml files + zip_path = String.replace(file_path, ~r/\.(eml|zip)$/, ".zip") + + eml_files = + messages + |> Enum.with_index() + |> Enum.map(fn {message, index} -> + message = Message.decrypt_content(message, mailbox.user_id) + filename = "message_#{String.pad_leading(to_string(index + 1), 5, "0")}.eml" + content = message_to_eml(message) + {String.to_charlist(filename), content} + end) + + {:ok, zip_path} = :zip.create(String.to_charlist(zip_path), eml_files) + file_size = File.stat!(zip_path).size + {to_string(zip_path), file_size} + end + + defp message_to_eml(message) do + """ + From: #{message.from} + To: #{message.to} + #{if message.cc, do: "Cc: #{message.cc}\n", else: ""}Subject: #{message.subject} + Date: #{format_rfc2822_date(message.inserted_at)} + Message-ID: #{message.message_id} + MIME-Version: 1.0 + Content-Type: text/plain; charset=UTF-8 + + #{message.text_body || strip_html(message.html_body || "")} + """ + end + + defp format_mbox_date(datetime) do + Calendar.strftime(datetime, "%a %b %d %H:%M:%S %Y") + end + + defp format_rfc2822_date(datetime) do + Calendar.strftime(datetime, "%a, %d %b %Y %H:%M:%S +0000") + end + + defp extract_email(email_string) when is_binary(email_string) do + case Regex.run(~r/<([^>]+)>/, email_string) do + [_, email] -> String.trim(email) + nil -> String.trim(email_string) + end + end + + defp extract_email(_), do: "unknown@unknown" + + defp strip_html(html) do + html + |> String.replace(~r//i, "\n") + |> String.replace(~r/<\/p>/i, "\n\n") + |> String.replace(~r/<[^>]+>/, "") + |> String.trim() + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/external_delivery.ex b/apps/elektrine_email/lib/elektrine/email/external_delivery.ex new file mode 100644 index 0000000..fb39d6c --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/external_delivery.ex @@ -0,0 +1,588 @@ +defmodule Elektrine.Email.ExternalDelivery do + @moduledoc """ + Durable external email delivery record. + + Each external recipient gets one delivery row. This gives SMTP/API submission + a stable boundary before provider delivery and prevents duplicate provider + sends for repeated client submissions of the same message. + """ + + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + + alias Elektrine.Email.Message + alias Elektrine.Email.Suppressions + alias Elektrine.Repo + + @statuses ~w(pending sending sent deferred bounced complained suppressed failed paused) + @recipient_types ~w(to cc bcc) + + schema "external_email_deliveries" do + field :user_id, :integer + field :envelope_from, :string + field :to, {:array, :string}, default: [] + field :cc, {:array, :string}, default: [] + field :bcc, {:array, :string}, default: [] + field :recipient, :string + field :recipient_type, :string, default: "to" + field :domain, :string + field :trace_id, :string + field :params, :map, default: %{} + field :status, :string, default: "pending" + field :attempts, :integer, default: 0 + field :provider, :string + field :provider_message_id, :string + field :response_code, :string + field :error, :string + field :last_attempted_at, :utc_datetime + field :delivered_at, :utc_datetime + + belongs_to :mailbox, Elektrine.Email.Mailbox + belongs_to :sent_message, Message + + timestamps(type: :utc_datetime) + end + + def changeset(delivery, attrs) do + delivery + |> cast(attrs, [ + :user_id, + :mailbox_id, + :sent_message_id, + :envelope_from, + :to, + :cc, + :bcc, + :recipient, + :recipient_type, + :domain, + :trace_id, + :params, + :status, + :attempts, + :provider, + :provider_message_id, + :response_code, + :error, + :last_attempted_at, + :delivered_at + ]) + |> normalize_recipient_fields() + |> validate_required([ + :user_id, + :mailbox_id, + :sent_message_id, + :envelope_from, + :recipient, + :recipient_type, + :domain, + :trace_id, + :params + ]) + |> validate_inclusion(:status, @statuses) + |> validate_inclusion(:recipient_type, @recipient_types) + |> validate_recipient_present() + |> unique_constraint([:sent_message_id, :recipient_type, :recipient], + name: :external_email_deliveries_recipient_unique + ) + end + + def create_or_get(attrs) when is_map(attrs) do + %__MODULE__{} + |> changeset(attrs) + |> Repo.insert() + |> case do + {:ok, delivery} -> + {:ok, delivery, :created} + + {:error, %Ecto.Changeset{} = changeset} -> + if duplicate_recipient?(changeset) do + case get_by_recipient( + fetch_attr(attrs, :sent_message_id), + fetch_attr(attrs, :recipient_type), + fetch_attr(attrs, :recipient) + ) do + %__MODULE__{} = delivery -> {:ok, delivery, :existing} + nil -> {:error, changeset} + end + else + {:error, changeset} + end + end + end + + def get(id) when is_integer(id), do: Repo.get(__MODULE__, id) + + def get(id) when is_binary(id) do + case Integer.parse(id) do + {parsed, ""} -> get(parsed) + _ -> nil + end + end + + def get(_), do: nil + + def get_by_sent_message_id(sent_message_id) do + Repo.all( + from d in __MODULE__, + where: d.sent_message_id == ^sent_message_id, + order_by: [asc: d.id] + ) + end + + def get_by_recipient(sent_message_id, recipient_type, recipient) do + Repo.one( + from d in __MODULE__, + where: + d.sent_message_id == ^sent_message_id and d.recipient_type == ^recipient_type and + d.recipient == ^normalize_email(recipient) + ) + end + + def list_for_message(sent_message_id), do: get_by_sent_message_id(sent_message_id) + + def list_attempts(%__MODULE__{} = delivery) do + Repo.all( + from a in Elektrine.Email.ExternalDeliveryAttempt, + where: a.delivery_id == ^delivery.id, + order_by: [asc: a.attempt, asc: a.id] + ) + end + + def trace(trace_id) when is_binary(trace_id) do + delivery = Repo.one(from d in __MODULE__, where: d.trace_id == ^trace_id) + + case delivery do + %__MODULE__{} -> %{delivery: delivery, attempts: list_attempts(delivery)} + nil -> nil + end + end + + def find_for_signal(attrs) when is_map(attrs) do + trace_id = fetch_attr(attrs, :trace_id) + provider_message_id = fetch_attr(attrs, :provider_message_id) + message_id = fetch_attr(attrs, :message_id) + recipient = fetch_attr(attrs, :recipient) |> normalize_email() + + cond do + is_binary(trace_id) and trace_id != "" -> + delivery_by_trace(trace_id) + + is_binary(provider_message_id) and provider_message_id != "" -> + Repo.one(from d in __MODULE__, where: d.provider_message_id == ^provider_message_id) + + is_binary(message_id) and is_binary(recipient) -> + find_by_message_and_recipient(message_id, recipient) + + true -> + nil + end + end + + def find_by_message_and_recipient(message_id, recipient) do + candidates = message_id_lookup_candidates(message_id) + recipient = normalize_email(recipient) + + Repo.one( + from d in __MODULE__, + join: m in assoc(d, :sent_message), + where: m.message_id in ^candidates and d.recipient == ^recipient, + order_by: [desc: d.inserted_at], + limit: 1 + ) + end + + def delivery_summary(sent_message_id) do + sent_message_id + |> list_for_message() + |> Enum.group_by(& &1.status) + |> Map.new(fn {status, deliveries} -> {status, length(deliveries)} end) + end + + def operational_metrics(opts \\ []) do + since = Keyword.get(opts, :since, DateTime.add(DateTime.utc_now(), -86_400, :second)) + + stuck_cutoff = + Keyword.get(opts, :stuck_cutoff, DateTime.add(DateTime.utc_now(), -900, :second)) + + totals = + Repo.all( + from d in __MODULE__, + where: d.inserted_at >= ^since, + group_by: d.status, + select: {d.status, count(d.id)} + ) + |> Map.new() + + failed_domains = + Repo.all( + from d in __MODULE__, + where: + d.inserted_at >= ^since and + d.status in ["failed", "deferred", "bounced", "complained"], + group_by: d.domain, + order_by: [desc: count(d.id)], + limit: 20, + select: {d.domain, count(d.id)} + ) + + stuck = + Repo.aggregate( + from(d in __MODULE__, + where: d.status in ["pending", "sending", "deferred"] and d.updated_at < ^stuck_cutoff + ), + :count, + :id + ) + + total = Enum.reduce(totals, 0, fn {_status, count}, acc -> acc + count end) + + %{ + since: since, + totals: totals, + queue_depth: Map.get(totals, "pending", 0) + Map.get(totals, "deferred", 0), + stuck_count: stuck, + failed_domains: failed_domains, + bounce_rate: ratio(Map.get(totals, "bounced", 0), total), + complaint_rate: ratio(Map.get(totals, "complained", 0), total) + } + end + + def recent_deliveries(opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + status = Keyword.get(opts, :status) + domain = Keyword.get(opts, :domain) + + query = from d in __MODULE__, order_by: [desc: d.updated_at], limit: ^limit + query = if status, do: from(d in query, where: d.status == ^status), else: query + query = if domain, do: from(d in query, where: d.domain == ^domain), else: query + Repo.all(query) + end + + def requeue_stuck(opts \\ []) do + cutoff = Keyword.get(opts, :cutoff, DateTime.add(DateTime.utc_now(), -1800, :second)) + + from(d in __MODULE__, + where: d.status == "sending" and d.updated_at < ^cutoff + ) + |> Repo.all() + |> Enum.map(&requeue/1) + end + + def prune_attempts_older_than(cutoff) do + from(a in Elektrine.Email.ExternalDeliveryAttempt, where: a.inserted_at < ^cutoff) + |> Repo.delete_all() + end + + def mark_sending(%__MODULE__{} = delivery) do + next_attempt = delivery.attempts + 1 + + delivery + |> changeset(%{ + status: "sending", + attempts: next_attempt, + last_attempted_at: DateTime.utc_now() |> DateTime.truncate(:second), + error: nil + }) + |> Repo.update() + |> tap(fn + {:ok, updated} -> record_attempt(updated, "sending", %{attempt: next_attempt}) + _ -> :ok + end) + end + + def mark_sent(%__MODULE__{} = delivery, response) do + provider_message_id = response_message_id(response) + + delivery + |> changeset(%{ + status: "sent", + provider: provider_name(), + provider_message_id: provider_message_id, + response_code: response_code(response), + delivered_at: DateTime.utc_now() |> DateTime.truncate(:second), + error: nil + }) + |> Repo.update() + |> tap(fn + {:ok, updated} -> + record_attempt(updated, "sent", %{provider_message_id: provider_message_id}) + + _ -> + :ok + end) + end + + def mark_failed(%__MODULE__{} = delivery, reason) do + status = classify_failure_status(reason) + + delivery + |> changeset(%{ + status: status, + error: format_reason(reason), + response_code: response_code(reason) + }) + |> Repo.update() + |> tap(fn + {:ok, updated} -> + if status in ["bounced", "complained"], do: suppress_delivery_recipient(updated, status) + record_attempt(updated, status, %{error: format_reason(reason)}) + + _ -> + :ok + end) + end + + def mark_bounced(%__MODULE__{} = delivery, reason \\ "bounce") do + delivery + |> changeset(%{status: "bounced", error: format_reason(reason)}) + |> Repo.update() + |> tap(fn + {:ok, updated} -> + suppress_delivery_recipient(updated, "bounced") + record_attempt(updated, "bounced", %{error: format_reason(reason)}) + + _ -> + :ok + end) + end + + def mark_complained(%__MODULE__{} = delivery, reason \\ "complaint") do + delivery + |> changeset(%{status: "complained", error: format_reason(reason)}) + |> Repo.update() + |> tap(fn + {:ok, updated} -> + suppress_delivery_recipient(updated, "complained") + record_attempt(updated, "complained", %{error: format_reason(reason)}) + + _ -> + :ok + end) + end + + def mark_suppressed(%__MODULE__{} = delivery, reason \\ "suppressed") do + delivery + |> changeset(%{status: "suppressed", error: format_reason(reason)}) + |> Repo.update() + |> tap(fn + {:ok, updated} -> record_attempt(updated, "suppressed", %{error: format_reason(reason)}) + _ -> :ok + end) + end + + def mark_paused(%__MODULE__{} = delivery, reason \\ "paused") do + delivery + |> changeset(%{status: "paused", error: format_reason(reason)}) + |> Repo.update() + |> tap(fn + {:ok, updated} -> record_attempt(updated, "paused", %{error: format_reason(reason)}) + _ -> :ok + end) + end + + def requeue(%__MODULE__{} = delivery) do + delivery + |> changeset(%{status: "pending", error: nil, response_code: nil}) + |> Repo.update() + |> case do + {:ok, updated} -> + with {:ok, _job} <- Elektrine.Email.ExternalDeliveryWorker.enqueue(updated) do + {:ok, updated} + end + + error -> + error + end + end + + def mark_bounced_by_signal(attrs, reason \\ "bounce") when is_map(attrs) do + case find_for_signal(attrs) do + %__MODULE__{} = delivery -> mark_bounced(delivery, reason) + nil -> {:error, :not_found} + end + end + + def apply_provider_event(attrs) when is_map(attrs) do + event = fetch_attr(attrs, :event) || fetch_attr(attrs, :status) || "" + reason = fetch_attr(attrs, :reason) || fetch_attr(attrs, :error) || event + + case find_for_signal(attrs) do + %__MODULE__{} = delivery -> + case normalize_event(event) do + "sent" -> mark_sent(delivery, attrs) + "accepted" -> mark_sent(delivery, attrs) + "delivered" -> mark_sent(delivery, attrs) + "deferred" -> mark_failed(delivery, "450 #{reason}") + "bounced" -> mark_bounced(delivery, reason) + "complained" -> mark_complained(delivery, reason) + "complaint" -> mark_complained(delivery, reason) + "suppressed" -> mark_suppressed(delivery, reason) + _ -> {:error, :unknown_event} + end + + nil -> + {:error, :not_found} + end + end + + def mark_complained_by_signal(attrs, reason \\ "complaint") when is_map(attrs) do + case find_for_signal(attrs) do + %__MODULE__{} = delivery -> mark_complained(delivery, reason) + nil -> {:error, :not_found} + end + end + + defp delivery_by_trace(trace_id) when is_binary(trace_id) do + Repo.one(from d in __MODULE__, where: d.trace_id == ^trace_id) + end + + defp message_id_lookup_candidates(nil), do: [] + defp message_id_lookup_candidates(""), do: [] + + defp message_id_lookup_candidates(message_id) do + normalized = + message_id + |> to_string() + |> String.trim() + |> String.replace(~r/^<|>$/, "") + + if normalized == "", do: [], else: Enum.uniq([normalized, "<#{normalized}>"]) + end + + defp duplicate_recipient?(%Ecto.Changeset{errors: errors}) do + Enum.any?(errors, fn + {:sent_message_id, {_message, opts}} -> opts[:constraint] == :unique + _ -> false + end) + end + + defp normalize_event(event), do: event |> to_string() |> String.trim() |> String.downcase() + + defp fetch_attr(attrs, key) do + Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key)) + end + + defp ratio(_count, 0), do: 0.0 + defp ratio(count, total), do: count / total + + defp validate_recipient_present(changeset) do + recipients = + ([get_field(changeset, :recipient)] ++ + (get_field(changeset, :to) || []) ++ + (get_field(changeset, :cc) || []) ++ (get_field(changeset, :bcc) || [])) + |> Enum.reject(&is_nil/1) + + if recipients == [] do + add_error(changeset, :to, "must include at least one recipient") + else + changeset + end + end + + defp normalize_recipient_fields(changeset) do + recipient = get_field(changeset, :recipient) |> normalize_email() + trace_id = get_field(changeset, :trace_id) || Ecto.UUID.generate() + + changeset + |> put_change(:recipient, recipient) + |> put_change(:domain, email_domain(recipient)) + |> put_change(:trace_id, trace_id) + end + + defp record_attempt(delivery, status, metadata) do + attrs = %{ + delivery_id: delivery.id, + attempt: delivery.attempts, + status: status, + provider: delivery.provider || provider_name(), + provider_message_id: metadata[:provider_message_id] || delivery.provider_message_id, + response_code: delivery.response_code, + error: metadata[:error] || delivery.error, + metadata: Map.new(metadata, fn {key, value} -> {to_string(key), value} end), + attempted_at: DateTime.utc_now() |> DateTime.truncate(:second) + } + + %Elektrine.Email.ExternalDeliveryAttempt{} + |> Elektrine.Email.ExternalDeliveryAttempt.changeset(attrs) + |> Repo.insert() + end + + defp suppress_delivery_recipient( + %__MODULE__{user_id: user_id, recipient: recipient} = delivery, + reason + ) + when is_integer(user_id) and is_binary(recipient) do + Suppressions.suppress_recipient(user_id, recipient, + reason: reason, + source: "external_delivery", + metadata: %{"delivery_id" => delivery.id, "trace_id" => delivery.trace_id} + ) + end + + defp suppress_delivery_recipient(_, _), do: :ok + + defp provider_name do + if Elektrine.EmailConfig.use_external_delivery_api?(), do: "haraka", else: "swoosh" + end + + defp response_message_id(response) when is_map(response) do + Map.get(response, :message_id) || Map.get(response, "message_id") || + Map.get(response, :provider_message_id) || Map.get(response, "provider_message_id") + end + + # Fallback for non-map, non-nil responses: nothing to extract (avoids a + # BadMapError from calling Map.get/2 on a value the type system proves is not + # a map). + defp response_message_id(_response), do: nil + + defp response_code(response) when is_map(response) do + Map.get(response, :response_code) || Map.get(response, "response_code") || + Map.get(response, :status_code) || Map.get(response, "status_code") + end + + defp response_code(reason) when is_binary(reason) do + case Regex.run(~r/\b([245]\d\d)\b/, reason) do + [_, code] -> code + _ -> nil + end + end + + defp response_code(_), do: nil + + defp classify_failure_status(reason) do + code = response_code(reason) + + cond do + is_binary(code) and String.starts_with?(code, "4") -> "deferred" + is_binary(code) and String.starts_with?(code, "5") -> "bounced" + true -> "failed" + end + end + + defp normalize_email(nil), do: nil + + defp normalize_email(email) do + email + |> to_string() + |> String.trim() + |> String.downcase() + |> case do + "" -> nil + value -> value + end + end + + defp email_domain(nil), do: nil + + defp email_domain(email) do + case String.split(email, "@", parts: 2) do + [_local, domain] -> String.downcase(domain) + _ -> nil + end + end + + defp format_reason(reason) when is_binary(reason), do: reason + defp format_reason(reason), do: inspect(reason) +end diff --git a/apps/elektrine_email/lib/elektrine/email/external_delivery_alerts.ex b/apps/elektrine_email/lib/elektrine/email/external_delivery_alerts.ex new file mode 100644 index 0000000..89681a6 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/external_delivery_alerts.ex @@ -0,0 +1,65 @@ +defmodule Elektrine.Email.ExternalDeliveryAlerts do + @moduledoc false + + import Ecto.Query + require Logger + + alias Elektrine.Accounts.User + alias Elektrine.Email.ExternalDelivery + alias Elektrine.Notifications + alias Elektrine.Repo + + def check_and_notify(opts \\ []) do + metrics = ExternalDelivery.operational_metrics(opts) + alerts = alerts_for(metrics) + + if alerts != [] do + notify_admins(alerts, metrics) + end + + {:ok, %{alerts: alerts, metrics: metrics}} + end + + def alerts_for(metrics) do + thresholds = %{ + queue_depth: Application.get_env(:elektrine, :email_alert_queue_depth, 100), + stuck_count: Application.get_env(:elektrine, :email_alert_stuck_count, 10), + bounce_rate: Application.get_env(:elektrine, :email_alert_bounce_rate, 0.1), + complaint_rate: Application.get_env(:elektrine, :email_alert_complaint_rate, 0.01) + } + + [] + |> maybe_alert(metrics.queue_depth > thresholds.queue_depth, :queue_depth) + |> maybe_alert(metrics.stuck_count > thresholds.stuck_count, :stuck_count) + |> maybe_alert(metrics.bounce_rate > thresholds.bounce_rate, :bounce_rate) + |> maybe_alert(metrics.complaint_rate > thresholds.complaint_rate, :complaint_rate) + end + + defp maybe_alert(alerts, true, alert), do: [alert | alerts] + defp maybe_alert(alerts, false, _alert), do: alerts + + defp notify_admins(alerts, metrics) do + admins = Repo.all(from u in User, where: u.is_admin == true and u.banned == false) + + Enum.each(admins, fn admin -> + Notifications.create_notification(%{ + user_id: admin.id, + type: "email_delivery_alert", + title: "Email delivery alert", + body: "Alerts: #{Enum.map_join(alerts, ", ", &to_string/1)}", + url: "/pripyat/email-delivery", + source_type: "email_delivery", + priority: "high", + metadata: %{ + queue_depth: metrics.queue_depth, + stuck_count: metrics.stuck_count, + bounce_rate: metrics.bounce_rate, + complaint_rate: metrics.complaint_rate + } + }) + end) + + Logger.warning("Email delivery alerts: #{inspect(alerts)} metrics=#{inspect(metrics)}") + :ok + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/external_delivery_attempt.ex b/apps/elektrine_email/lib/elektrine/email/external_delivery_attempt.ex new file mode 100644 index 0000000..c81b04f --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/external_delivery_attempt.ex @@ -0,0 +1,39 @@ +defmodule Elektrine.Email.ExternalDeliveryAttempt do + @moduledoc """ + Immutable attempt history for external recipient delivery. + """ + + use Ecto.Schema + import Ecto.Changeset + + schema "external_email_delivery_attempts" do + field :attempt, :integer + field :status, :string + field :provider, :string + field :provider_message_id, :string + field :response_code, :string + field :error, :string + field :metadata, :map, default: %{} + field :attempted_at, :utc_datetime + + belongs_to :delivery, Elektrine.Email.ExternalDelivery + + timestamps(type: :utc_datetime) + end + + def changeset(attempt, attrs) do + attempt + |> cast(attrs, [ + :delivery_id, + :attempt, + :status, + :provider, + :provider_message_id, + :response_code, + :error, + :metadata, + :attempted_at + ]) + |> validate_required([:delivery_id, :attempt, :status, :attempted_at]) + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/external_delivery_control.ex b/apps/elektrine_email/lib/elektrine/email/external_delivery_control.ex new file mode 100644 index 0000000..30748f3 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/external_delivery_control.ex @@ -0,0 +1,123 @@ +defmodule Elektrine.Email.ExternalDeliveryControl do + @moduledoc """ + Admin controls for pausing outbound delivery by user or recipient domain. + """ + + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + + alias Elektrine.Repo + + @scope_types ~w(user domain) + + schema "external_email_delivery_controls" do + field :scope_type, :string + field :scope_value, :string + field :active, :boolean, default: true + field :reason, :string + field :paused_at, :utc_datetime + field :resumed_at, :utc_datetime + field :paused_by_id, :integer + + timestamps(type: :utc_datetime) + end + + def changeset(control, attrs) do + control + |> cast(attrs, [ + :scope_type, + :scope_value, + :active, + :reason, + :paused_by_id, + :paused_at, + :resumed_at + ]) + |> update_change(:scope_type, &normalize_scope_type/1) + |> update_change(:scope_value, &normalize_scope_value/1) + |> validate_required([:scope_type, :scope_value, :active]) + |> validate_inclusion(:scope_type, @scope_types) + |> unique_constraint([:scope_type, :scope_value], + name: :external_email_delivery_controls_scope_unique + ) + end + + def pause(scope_type, scope_value, attrs \\ []) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + attrs = %{ + scope_type: scope_type, + scope_value: scope_value, + active: true, + reason: Keyword.get(attrs, :reason), + paused_by_id: Keyword.get(attrs, :paused_by_id), + paused_at: now, + resumed_at: nil + } + + %__MODULE__{} + |> changeset(attrs) + |> Repo.insert( + on_conflict: [ + set: [ + active: true, + reason: attrs.reason, + paused_by_id: attrs.paused_by_id, + paused_at: attrs.paused_at, + resumed_at: nil, + updated_at: now + ] + ], + conflict_target: [:scope_type, :scope_value], + returning: true + ) + end + + def resume(scope_type, scope_value) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + case get(scope_type, scope_value) do + %__MODULE__{} = control -> + control + |> changeset(%{active: false, resumed_at: now}) + |> Repo.update() + + nil -> + {:error, :not_found} + end + end + + def get(scope_type, scope_value) do + scope_type = normalize_scope_type(scope_type) + scope_value = normalize_scope_value(scope_value) + + Repo.one( + from c in __MODULE__, + where: c.scope_type == ^scope_type and c.scope_value == ^scope_value + ) + end + + def active_controls do + Repo.all(from c in __MODULE__, where: c.active == true, order_by: [desc: c.updated_at]) + end + + def paused?(user_id, domain) do + user_scope = normalize_scope_value(user_id) + domain_scope = normalize_scope_value(domain) + + Repo.exists?( + from c in __MODULE__, + where: c.active == true, + where: + (c.scope_type == "user" and c.scope_value == ^user_scope) or + (c.scope_type == "domain" and c.scope_value == ^domain_scope) + ) + end + + defp normalize_scope_type(value), do: value |> to_string() |> String.trim() |> String.downcase() + defp normalize_scope_value(nil), do: "" + + defp normalize_scope_value(value), + do: value |> to_string() |> String.trim() |> String.downcase() +end diff --git a/apps/elektrine_email/lib/elektrine/email/external_delivery_maintenance_worker.ex b/apps/elektrine_email/lib/elektrine/email/external_delivery_maintenance_worker.ex new file mode 100644 index 0000000..675c0a6 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/external_delivery_maintenance_worker.ex @@ -0,0 +1,21 @@ +defmodule Elektrine.Email.ExternalDeliveryMaintenanceWorker do + @moduledoc false + + use Oban.Worker, queue: :email, max_attempts: 1 + + alias Elektrine.Email.{ExternalDelivery, ExternalDeliveryAlerts, ExternalDeliveryMetricSnapshot} + + @impl Oban.Worker + def perform(%Oban.Job{}) do + metrics = ExternalDelivery.operational_metrics() + _ = ExternalDeliveryMetricSnapshot.create_from_metrics(metrics) + _ = ExternalDeliveryAlerts.check_and_notify() + _ = ExternalDelivery.requeue_stuck() + + cutoff = DateTime.add(DateTime.utc_now(), -30 * 86_400, :second) + _ = ExternalDelivery.prune_attempts_older_than(cutoff) + _ = ExternalDeliveryMetricSnapshot.prune_older_than(cutoff) + + :ok + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/external_delivery_metric_snapshot.ex b/apps/elektrine_email/lib/elektrine/email/external_delivery_metric_snapshot.ex new file mode 100644 index 0000000..8d0dd8c --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/external_delivery_metric_snapshot.ex @@ -0,0 +1,74 @@ +defmodule Elektrine.Email.ExternalDeliveryMetricSnapshot do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + + alias Elektrine.Repo + + schema "external_email_metric_snapshots" do + field :metrics, :map, default: %{} + field :queue_depth, :integer, default: 0 + field :stuck_count, :integer, default: 0 + field :bounce_rate, :float, default: 0.0 + field :complaint_rate, :float, default: 0.0 + field :captured_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + def changeset(snapshot, attrs) do + snapshot + |> cast(attrs, [ + :metrics, + :queue_depth, + :stuck_count, + :bounce_rate, + :complaint_rate, + :captured_at + ]) + |> validate_required([ + :metrics, + :queue_depth, + :stuck_count, + :bounce_rate, + :complaint_rate, + :captured_at + ]) + end + + def create_from_metrics(metrics) when is_map(metrics) do + attrs = %{ + metrics: stringify(metrics), + queue_depth: metrics.queue_depth, + stuck_count: metrics.stuck_count, + bounce_rate: metrics.bounce_rate, + complaint_rate: metrics.complaint_rate, + captured_at: DateTime.utc_now() |> DateTime.truncate(:second) + } + + %__MODULE__{} + |> changeset(attrs) + |> Repo.insert() + end + + def recent(limit \\ 50) do + Repo.all(from s in __MODULE__, order_by: [desc: s.captured_at], limit: ^limit) + end + + def prune_older_than(cutoff) do + from(s in __MODULE__, where: s.captured_at < ^cutoff) + |> Repo.delete_all() + end + + defp stringify(%DateTime{} = value), do: DateTime.to_iso8601(value) + defp stringify({left, right}), do: [stringify(left), stringify(right)] + + defp stringify(map) when is_map(map) do + Map.new(map, fn {key, value} -> {to_string(key), stringify(value)} end) + end + + defp stringify(list) when is_list(list), do: Enum.map(list, &stringify/1) + defp stringify(value), do: value +end diff --git a/apps/elektrine_email/lib/elektrine/email/external_delivery_worker.ex b/apps/elektrine_email/lib/elektrine/email/external_delivery_worker.ex new file mode 100644 index 0000000..4646e4e --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/external_delivery_worker.ex @@ -0,0 +1,138 @@ +defmodule Elektrine.Email.ExternalDeliveryWorker do + @moduledoc false + + use Oban.Worker, + queue: :email, + max_attempts: 5, + unique: [period: 300, fields: [:args], keys: [:delivery_id]] + + require Logger + + alias Elektrine.Email.ExternalDelivery + alias Elektrine.Email.ExternalDeliveryControl + alias Elektrine.Email.ExternalDomainThrottle + alias Elektrine.Email.Sender + alias Elektrine.Email.Suppressions + + @valid_param_keys %{ + "to" => :to, + "from" => :from, + "cc" => :cc, + "bcc" => :bcc, + "subject" => :subject, + "text_body" => :text_body, + "html_body" => :html_body, + "reply_to" => :reply_to, + "message_id" => :message_id, + "in_reply_to" => :in_reply_to, + "references" => :references, + "attachments" => :attachments, + "headers" => :headers, + "priority" => :priority, + "content_type" => :content_type, + "charset" => :charset, + "list_id" => :list_id + } + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"delivery_id" => delivery_id}} = job) do + case load_delivery(delivery_id) do + nil -> + :ok + + %ExternalDelivery{status: status} + when status in ["sent", "bounced", "complained", "suppressed", "paused"] -> + :ok + + %ExternalDelivery{} = delivery -> + if ExternalDeliveryControl.paused?(delivery.user_id, delivery.domain) do + _ = ExternalDelivery.mark_paused(delivery, "paused by admin control") + :ok + else + case ExternalDomainThrottle.check(delivery.domain) do + :ok -> deliver(delivery, job) + {:snooze, seconds} -> {:snooze, seconds} + end + end + end + end + + def enqueue(%ExternalDelivery{} = delivery) do + %{"delivery_id" => delivery.id} + |> new() + |> Elektrine.JobQueue.insert() + end + + defp deliver(%ExternalDelivery{} = delivery, %Oban.Job{} = job) do + with {:ok, sending_delivery} <- ExternalDelivery.mark_sending(delivery), + {:ok, response} <- Sender.deliver_external_params(atomize_keys(sending_delivery.params)), + {:ok, _sent_delivery} <- ExternalDelivery.mark_sent(sending_delivery, response) do + ExternalDomainThrottle.record(sending_delivery.domain) + :ok + else + {:error, reason} -> + handle_delivery_failure(delivery, reason, job) + end + end + + defp handle_delivery_failure(%ExternalDelivery{} = delivery, reason, %Oban.Job{} = job) do + case ExternalDelivery.mark_failed(delivery, reason) do + {:ok, %ExternalDelivery{status: "deferred"}} when job.attempt < job.max_attempts -> + {:snooze, ExternalDomainThrottle.retry_backoff_seconds(delivery.domain, job.attempt)} + + {:ok, %ExternalDelivery{status: status} = updated} + when status in ["bounced", "complained"] -> + _ = + Suppressions.suppress_recipient(updated.user_id, updated.recipient, + reason: status, + source: "external_delivery", + metadata: %{"delivery_id" => updated.id, "trace_id" => updated.trace_id} + ) + + :ok + + {:ok, %ExternalDelivery{status: "deferred"}} -> + Logger.error( + "External email delivery #{delivery.id} exhausted retries: #{inspect(reason)}" + ) + + {:error, reason} + + _ -> + Logger.error("External email delivery #{delivery.id} failed: #{inspect(reason)}") + {:error, reason} + end + end + + defp load_delivery(id) when is_integer(id), do: ExternalDelivery.get(id) + + defp load_delivery(id) when is_binary(id) do + case Integer.parse(id) do + {parsed, ""} -> ExternalDelivery.get(parsed) + _ -> nil + end + end + + defp load_delivery(_), do: nil + + defp atomize_keys(nil), do: nil + + defp atomize_keys(map) when is_map(map) do + Map.new(map, fn + {key, value} when is_binary(key) -> + case Map.fetch(@valid_param_keys, key) do + {:ok, atom_key} -> {atom_key, atomize_keys(value)} + :error -> {key, atomize_keys(value)} + end + + {key, value} when is_atom(key) -> + {key, atomize_keys(value)} + + {key, value} -> + {key, atomize_keys(value)} + end) + end + + defp atomize_keys(list) when is_list(list), do: Enum.map(list, &atomize_keys/1) + defp atomize_keys(value), do: value +end diff --git a/apps/elektrine_email/lib/elektrine/email/external_domain_throttle.ex b/apps/elektrine_email/lib/elektrine/email/external_domain_throttle.ex new file mode 100644 index 0000000..45142f7 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/external_domain_throttle.ex @@ -0,0 +1,92 @@ +defmodule Elektrine.Email.ExternalDomainThrottle do + @moduledoc """ + Lightweight outbound domain throttle for external delivery workers. + + Disabled by default unless `:email_domain_throttle_enabled` is set, so tests and + local development keep inline delivery behavior while production can slow down + hot recipient domains independently. + """ + + @table :external_email_domain_throttle + + def check(nil), do: :ok + + def check(domain) do + if enabled?() do + ensure_table() + + now = System.system_time(:second) + interval = domain_policy(domain).interval_seconds + key = normalize_domain(domain) + + case :ets.lookup(@table, key) do + [{^key, last_sent_at}] when now - last_sent_at < interval -> + {:snooze, interval - (now - last_sent_at)} + + _ -> + :ok + end + else + :ok + end + end + + def record(nil), do: :ok + + def record(domain) do + if enabled?() do + ensure_table() + :ets.insert(@table, {normalize_domain(domain), System.system_time(:second)}) + end + + :ok + end + + def retry_backoff_seconds(domain, attempt, status \\ "deferred") do + base = domain_policy(domain).retry_base_seconds + cap = domain_policy(domain).retry_cap_seconds + + multiplier = + case status do + "deferred" -> trunc(:math.pow(2, max(attempt, 1) - 1)) + _ -> 1 + end + + min(cap, base * multiplier) + end + + defp enabled? do + Application.get_env(:elektrine, :email_domain_throttle_enabled, false) == true + end + + defp interval_seconds do + Application.get_env(:elektrine, :email_domain_throttle_interval_seconds, 1) + end + + defp domain_policy(domain) do + configured = Application.get_env(:elektrine, :email_domain_policies, %{}) + + configured + |> Map.get(normalize_domain(domain), %{}) + |> Map.new() + |> then(&Map.merge(default_policy(), &1)) + rescue + _ -> default_policy() + end + + defp default_policy do + %{interval_seconds: interval_seconds(), retry_base_seconds: 60, retry_cap_seconds: 3600} + end + + defp normalize_domain(domain), do: domain |> to_string() |> String.downcase() + + defp ensure_table do + if :ets.whereis(@table) == :undefined do + :ets.new(@table, [:set, :public, :named_table]) + end + + :ok + rescue + ArgumentError -> :ok + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/filter.ex b/apps/elektrine_email/lib/elektrine/email/filter.ex new file mode 100644 index 0000000..efbae58 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/filter.ex @@ -0,0 +1,238 @@ +defmodule Elektrine.Email.Filter do + @moduledoc """ + Schema for email filters/rules. + Allows users to automatically process incoming emails based on conditions. + + ## Conditions Structure + ``` + %{ + "match_type" => "all" | "any", + "rules" => [ + %{ + "field" => "from" | "to" | "subject" | "body" | "has_attachment", + "operator" => "contains" | "not_contains" | "equals" | "starts_with" | "ends_with", + "value" => "search term" + } + ] + } + ``` + + ## Actions Structure + ``` + %{ + "move_to_digest" => true, + "move_to_ledger" => true, + "move_to_folder" => folder_id, + "add_label" => label_id, + "mark_as_read" => true, + "mark_as_spam" => true, + "archive" => true, + "delete" => true, + "star" => true, + "set_priority" => "high" | "normal" | "low", + "forward_to" => "email@example.com" + } + ``` + """ + use Ecto.Schema + import Ecto.Changeset + + schema "email_filters" do + field :name, :string + field :enabled, :boolean, default: true + field :priority, :integer, default: 0 + field :stop_processing, :boolean, default: false + field :conditions, :map, default: %{} + field :actions, :map, default: %{} + + belongs_to :user, Elektrine.Accounts.User + + timestamps() + end + + @valid_fields ~w(from to cc subject body has_attachment size) + @valid_operators ~w( + contains not_contains equals not_equals starts_with ends_with greater_than less_than + ) + @valid_actions ~w(move_to_digest move_to_ledger move_to_folder add_label remove_label mark_as_read mark_as_unread mark_as_spam archive delete star unstar set_priority forward_to) + @valid_priorities ~w(high normal low) + + @doc """ + Creates a changeset for an email filter. + """ + def changeset(filter, attrs) do + filter + |> cast(attrs, [:name, :enabled, :priority, :stop_processing, :conditions, :actions, :user_id]) + |> default_empty_map(:conditions) + |> default_empty_map(:actions) + |> validate_required([:name, :user_id]) + |> validate_length(:name, min: 1, max: 100) + |> validate_conditions() + |> validate_actions() + |> foreign_key_constraint(:user_id) + |> unique_constraint([:user_id, :name]) + end + + defp default_empty_map(changeset, field) do + case get_field(changeset, field) do + nil -> put_change(changeset, field, %{}) + _ -> changeset + end + end + + defp validate_conditions(changeset) do + case get_field(changeset, :conditions) do + conditions when is_map(conditions) -> + rules = Map.get(conditions, "rules") || [] + + cond do + rules == [] -> + changeset + + is_list(rules) and Enum.all?(rules, &valid_rule?/1) -> + changeset + + is_list(rules) -> + add_error(changeset, :conditions, "contains invalid rules") + + true -> + add_error(changeset, :conditions, "rules must be a list") + end + + _ -> + add_error(changeset, :conditions, "must be a map") + end + end + + defp valid_rule?(%{"field" => field, "operator" => operator, "value" => _value}) do + field in @valid_fields && operator in @valid_operators + end + + defp valid_rule?(%{"field" => "has_attachment", "operator" => operator}) do + operator in ["equals", "not_equals"] + end + + defp valid_rule?(_), do: false + + defp validate_actions(changeset) do + case get_field(changeset, :actions) do + actions when is_map(actions) and map_size(actions) == 0 -> + changeset + + actions when is_map(actions) and map_size(actions) > 0 -> + if valid_actions?(actions) do + changeset + else + add_error(changeset, :actions, "contains invalid actions") + end + + _ -> + add_error(changeset, :actions, "must be a map") + end + end + + defp valid_actions?(actions) do + Enum.all?(actions, fn {key, value} -> + key in @valid_actions && valid_action_value?(key, value) + end) + end + + defp valid_action_value?("set_priority", value), do: value in @valid_priorities + + defp valid_action_value?("forward_to", value), + do: is_binary(value) && String.contains?(value, "@") + + defp valid_action_value?("move_to_folder", value), do: is_integer(value) || is_binary(value) + defp valid_action_value?("add_label", value), do: is_integer(value) || is_binary(value) + defp valid_action_value?("remove_label", value), do: is_integer(value) || is_binary(value) + defp valid_action_value?(_, value), do: is_boolean(value) + + @doc """ + Checks if a message matches this filter's conditions. + """ + def matches?(filter, message) do + conditions = filter.conditions || %{} + match_type = Map.get(conditions, "match_type", "all") + rules = Map.get(conditions, "rules") || [] + + case match_type do + "all" -> Enum.all?(rules, &rule_matches?(&1, message)) + "any" -> Enum.any?(rules, &rule_matches?(&1, message)) + _ -> false + end + end + + defp rule_matches?(%{"field" => field, "operator" => operator, "value" => value}, message) do + field_value = get_message_field(message, field) + apply_operator(operator, field_value, value) + end + + defp rule_matches?(_, _), do: false + + defp get_message_field(message, "from"), do: message.from || "" + defp get_message_field(message, "to"), do: message.to || "" + defp get_message_field(message, "cc"), do: message.cc || "" + defp get_message_field(message, "subject"), do: message.subject || "" + defp get_message_field(message, "body"), do: message.text_body || message.html_body || "" + defp get_message_field(message, "has_attachment"), do: message.has_attachments + + defp get_message_field(message, "size"), + do: byte_size(message.text_body || "") + byte_size(message.html_body || "") + + defp get_message_field(_, _), do: "" + + defp apply_operator("contains", field_value, value) when is_binary(field_value) do + String.contains?(String.downcase(field_value), String.downcase(value)) + end + + defp apply_operator("not_contains", field_value, value) when is_binary(field_value) do + !String.contains?(String.downcase(field_value), String.downcase(value)) + end + + defp apply_operator("equals", field_value, value) when is_binary(field_value) do + String.downcase(field_value) == String.downcase(value) + end + + defp apply_operator("equals", field_value, value) when is_boolean(field_value) do + field_value == (value == "true" || value == true) + end + + defp apply_operator("not_equals", field_value, value) when is_binary(field_value) do + String.downcase(field_value) != String.downcase(value) + end + + defp apply_operator("starts_with", field_value, value) when is_binary(field_value) do + String.starts_with?(String.downcase(field_value), String.downcase(value)) + end + + defp apply_operator("ends_with", field_value, value) when is_binary(field_value) do + String.ends_with?(String.downcase(field_value), String.downcase(value)) + end + + defp apply_operator("greater_than", field_value, value) when is_integer(field_value) do + case parse_integer(value) do + {:ok, int_value} -> field_value > int_value + :error -> false + end + end + + defp apply_operator("less_than", field_value, value) when is_integer(field_value) do + case parse_integer(value) do + {:ok, int_value} -> field_value < int_value + :error -> false + end + end + + defp apply_operator(_, _, _), do: false + + defp parse_integer(value) when is_integer(value), do: {:ok, value} + + defp parse_integer(value) when is_binary(value) do + case Integer.parse(value) do + {int, ""} -> {:ok, int} + _ -> :error + end + end + + defp parse_integer(_), do: :error +end diff --git a/apps/elektrine_email/lib/elektrine/email/filters.ex b/apps/elektrine_email/lib/elektrine/email/filters.ex new file mode 100644 index 0000000..16fd456 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/filters.ex @@ -0,0 +1,193 @@ +defmodule Elektrine.Email.Filters do + @moduledoc """ + Context module for managing email filters/rules. + """ + import Ecto.Query + alias Elektrine.Email.Filter + alias Elektrine.Repo + + require Logger + + @doc """ + Lists all filters for a user, ordered by priority. + """ + def list_filters(user_id) do + Filter + |> where(user_id: ^user_id) + |> order_by(asc: :priority, asc: :inserted_at) + |> Repo.all() + end + + @doc """ + Lists only enabled filters for a user, ordered by priority. + """ + def list_enabled_filters(user_id) do + Filter + |> where(user_id: ^user_id, enabled: true) + |> order_by(asc: :priority, asc: :inserted_at) + |> Repo.all() + end + + @doc """ + Gets a filter by ID for a user. + """ + def get_filter(id, user_id) do + Filter + |> where(id: ^id, user_id: ^user_id) + |> Repo.one() + end + + @doc """ + Creates a filter. + """ + def create_filter(attrs) do + %Filter{} + |> Filter.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a filter. + """ + def update_filter(%Filter{} = filter, attrs) do + filter + |> Filter.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a filter. + """ + def delete_filter(%Filter{} = filter) do + Repo.delete(filter) + end + + @doc """ + Toggles a filter's enabled status. + """ + def toggle_filter(%Filter{} = filter) do + filter + |> Filter.changeset(%{enabled: !filter.enabled}) + |> Repo.update() + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking filter changes. + """ + def change_filter(%Filter{} = filter, attrs \\ %{}) do + Filter.changeset(filter, attrs) + end + + @doc """ + Applies all enabled filters to a message and returns the actions to take. + Returns a map of actions accumulated from all matching filters. + """ + def apply_filters(user_id, message) do + filters = list_enabled_filters(user_id) + + {actions, _stopped} = + Enum.reduce_while(filters, {%{}, false}, fn filter, {acc_actions, _stopped} -> + if Filter.matches?(filter, message) do + Logger.debug("Filter '#{filter.name}' matched message #{message.id}") + + # Merge actions (later filters can override earlier ones) + new_actions = Map.merge(acc_actions, filter.actions) + + if filter.stop_processing do + {:halt, {new_actions, true}} + else + {:cont, {new_actions, false}} + end + else + {:cont, {acc_actions, false}} + end + end) + + actions + end + + @doc """ + Executes filter actions on a message. + Returns {:ok, updated_message} or {:error, reason}. + """ + def execute_actions(message, actions) when map_size(actions) == 0 do + {:ok, message} + end + + def execute_actions(message, actions) do + Enum.reduce_while(actions, {:ok, message}, fn {action, value}, {:ok, msg} -> + case execute_action(msg, action, value) do + {:ok, updated_msg} -> {:cont, {:ok, updated_msg}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp execute_action(message, "mark_as_read", true) do + Elektrine.Email.mark_as_read(message) + end + + defp execute_action(message, "mark_as_unread", true) do + Elektrine.Email.mark_as_unread(message) + end + + defp execute_action(message, "mark_as_spam", true) do + Elektrine.Email.mark_as_spam(message) + end + + defp execute_action(message, "archive", true) do + Elektrine.Email.archive_message(message) + end + + defp execute_action(message, "delete", true) do + Elektrine.Email.trash_message(message) + end + + defp execute_action(message, "star", true) do + Elektrine.Email.update_message(message, %{flagged: true}) + end + + defp execute_action(message, "unstar", true) do + Elektrine.Email.update_message(message, %{flagged: false}) + end + + defp execute_action(message, "set_priority", priority) + when priority in ["high", "normal", "low"] do + Elektrine.Email.update_message(message, %{priority: priority}) + end + + defp execute_action(message, "move_to_digest", true) do + Elektrine.Email.move_to_digest(message) + end + + defp execute_action(message, "move_to_ledger", true) do + Elektrine.Email.move_to_ledger(message) + end + + defp execute_action(message, "move_to_folder", folder_id) do + Elektrine.Email.update_message(message, %{folder_id: folder_id}) + end + + defp execute_action(message, "add_label", label_id) do + Elektrine.Email.Labels.add_label_to_message(message.id, label_id) + {:ok, message} + end + + defp execute_action(message, "remove_label", label_id) do + Elektrine.Email.Labels.remove_label_from_message(message.id, label_id) + {:ok, message} + end + + defp execute_action(message, "forward_to", email) do + # Queue a forward action (don't block processing) + Elektrine.Async.run(fn -> + Elektrine.Email.Sender.forward_message(message, email) + end) + + {:ok, message} + end + + defp execute_action(message, _action, _value) do + {:ok, message} + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/folder.ex b/apps/elektrine_email/lib/elektrine/email/folder.ex new file mode 100644 index 0000000..b042287 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/folder.ex @@ -0,0 +1,52 @@ +defmodule Elektrine.Email.Folder do + @moduledoc """ + Schema for custom email folders. + Allows users to organize emails into custom folders. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "email_folders" do + field :name, :string + field :color, :string + field :icon, :string + + belongs_to :user, Elektrine.Accounts.User + belongs_to :parent, __MODULE__ + has_many :children, __MODULE__, foreign_key: :parent_id + has_many :messages, Elektrine.Email.Message + + timestamps() + end + + @valid_colors ~w(#ef4444 #f97316 #f59e0b #eab308 #84cc16 #22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9 #3b82f6 #6366f1 #8a7cc2 #9a8dcf #b79ed6 #c7796b #f43f5e) + @valid_icons ~w(folder inbox archive star flag bookmark tag briefcase file document mail) + + @doc """ + Creates a changeset for a custom folder. + """ + def changeset(folder, attrs) do + folder + |> cast(attrs, [:name, :color, :icon, :parent_id, :user_id]) + |> validate_required([:name, :user_id]) + |> validate_length(:name, min: 1, max: 50) + |> validate_inclusion(:color, @valid_colors, message: "is not a valid color") + |> validate_inclusion(:icon, @valid_icons, message: "is not a valid icon") + |> validate_not_reserved() + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:parent_id) + |> unique_constraint([:user_id, :name]) + end + + @reserved_names ~w(inbox sent drafts trash spam archive stack feed ledger boomerang) + + defp validate_not_reserved(changeset) do + name = get_field(changeset, :name) + + if name && String.downcase(name) in @reserved_names do + add_error(changeset, :name, "is a reserved folder name") + else + changeset + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/folders.ex b/apps/elektrine_email/lib/elektrine/email/folders.ex new file mode 100644 index 0000000..6c0ee8e --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/folders.ex @@ -0,0 +1,1076 @@ +defmodule Elektrine.Email.Folders do + @moduledoc """ + Folder and category management for email messages. + Handles Hey.com-style categories (Feed, Ledger, Stack) and folder operations. + """ + + import Ecto.Query, warn: false + alias Elektrine.Email.{CacheHooks, CategoryPreferences, Mailbox, Message} + alias Elektrine.Repo + + # Private helper to decrypt email messages and preload labels + defp decrypt_email_messages(messages, mailbox_id) when is_list(messages) do + # Preload labels for all messages + messages = Repo.preload(messages, :labels) + + case Elektrine.Email.Mailboxes.get_mailbox(mailbox_id) do + %Mailbox{user_id: user_id} when not is_nil(user_id) -> + Message.decrypt_messages(messages, user_id) + + _ -> + messages + end + end + + ## Paginated List Functions + + @doc """ + Returns paginated messages for a mailbox with metadata. + """ + def list_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + page = max(page, 1) + offset = (page - 1) * per_page + + # Get total count + total_count = + Message + |> where(mailbox_id: ^mailbox_id) + |> Repo.aggregate(:count) + + # Get messages for current page + messages = Elektrine.Email.Messages.list_messages(mailbox_id, per_page, offset) + + # Calculate pagination metadata + total_pages = ceil(total_count / per_page) + has_next = page < total_pages + has_prev = page > 1 + + %{ + messages: messages, + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages, + has_next: has_next, + has_prev: has_prev + } + end + + @doc """ + Returns paginated inbox messages (non-spam, non-archived, non-bulk, non-paper-trail) for a mailbox with metadata. + Optimized to build base query once and reuse for both count and fetch. + """ + def list_inbox_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + list_inbox_family_paginated(mailbox_id, page, per_page, :all) + end + + @doc """ + Flat (one row per message) inbox listing. The UI lists by thread via + `list_inbox_messages_paginated/3`; the public API keeps per-message semantics + so replies are never hidden behind a thread head. + """ + def list_inbox_messages_flat_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + page = max(page, 1) + offset = (page - 1) * per_page + base_query = inbox_family_scope(mailbox_id) + + total_count = Repo.aggregate(base_query, :count) + + messages = + base_query + |> order_by(desc: :inserted_at) + |> limit(^per_page) + |> offset(^offset) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + + total_pages = if total_count > 0, do: ceil(total_count / per_page), else: 0 + + %{ + messages: messages, + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages, + has_next: page < total_pages, + has_prev: page > 1 + } + end + + # Shared scope for the inbox, unread, and read filters: the same set of + # messages the inbox shows. Unread and read are not different message sets - + # they are the same threads partitioned by whether the thread has any unread + # message - so they must share one scope. + defp inbox_family_scope(mailbox_id) do + excluded_categories = inbox_excluded_categories(mailbox_id) + + Message + |> where([m], m.mailbox_id == ^mailbox_id and not m.spam and not m.archived and not m.deleted) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + |> where([m], is_nil(m.category) or m.category not in ^excluded_categories) + |> where([m], is_nil(m.reply_later_at)) + |> where([m], is_nil(m.folder_id)) + end + + # Thread-level pagination. Messages are grouped into conversations in SQL so + # each thread's read/unread state, message count, and attachment flag reflect + # the WHOLE thread (not just the messages that landed on the current page), + # and pagination is by thread so threads never split across page boundaries. + # + # `thread_filter` partitions threads: `:all` (inbox), `:unread` (any unread + # message), or `:read` (fully read). Real thread ids are positive; thread-less + # messages key off the negative of their own id, so the thread-id and + # message-id spaces can never collide. + defp list_inbox_family_paginated(mailbox_id, page, per_page, thread_filter) do + page = max(page, 1) + offset = (page - 1) * per_page + scope = inbox_family_scope(mailbox_id) + + groups = + from(m in scope, + group_by: + fragment( + "case when ? is not null and ? > 0 then ? else -? end", + m.thread_id, + m.thread_id, + m.thread_id, + m.id + ), + select: %{ + key: + fragment( + "case when ? is not null and ? > 0 then ? else -? end", + m.thread_id, + m.thread_id, + m.thread_id, + m.id + ), + latest_at: max(m.inserted_at), + latest_id: max(m.id), + count: count(m.id), + unread_count: filter(count(m.id), m.read == false), + has_attachments: fragment("bool_or(?)", m.has_attachments) + } + ) + + groups = + case thread_filter do + :unread -> from(g in subquery(groups), where: g.unread_count > 0) + :read -> from(g in subquery(groups), where: g.unread_count == 0) + _ -> groups + end + + total_count = Repo.aggregate(subquery(groups), :count) + + page_groups = + from(g in subquery(groups), + order_by: [desc: g.latest_at, desc: g.latest_id], + limit: ^per_page, + offset: ^offset + ) + |> Repo.all() + + keys = Enum.map(page_groups, & &1.key) + heads_by_key = inbox_family_heads(scope, keys, mailbox_id) + + messages = + Enum.flat_map(page_groups, fn group -> + case Map.get(heads_by_key, group.key) do + nil -> + [] + + head -> + [ + head + |> Map.put(:thread_message_count, group.count) + |> Map.put(:thread_unread_count, group.unread_count) + |> Map.put(:thread_has_unread, group.unread_count > 0) + |> Map.put(:thread_has_attachments, group.has_attachments) + ] + end + end) + + total_pages = if total_count > 0, do: ceil(total_count / per_page), else: 0 + + %{ + messages: messages, + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages, + has_next: page < total_pages, + has_prev: page > 1 + } + end + + # Loads the head (newest message) of each thread key on the current page. + defp inbox_family_heads(_scope, [], _mailbox_id), do: %{} + + defp inbox_family_heads(scope, keys, mailbox_id) do + from(m in scope, + where: + fragment( + "(case when ? is not null and ? > 0 then ? else -? end) = ANY(?)", + m.thread_id, + m.thread_id, + m.thread_id, + m.id, + ^keys + ), + distinct: + fragment( + "case when ? is not null and ? > 0 then ? else -? end", + m.thread_id, + m.thread_id, + m.thread_id, + m.id + ), + order_by: [ + asc: + fragment( + "case when ? is not null and ? > 0 then ? else -? end", + m.thread_id, + m.thread_id, + m.thread_id, + m.id + ), + desc: m.inserted_at, + desc: m.id + ] + ) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + |> Map.new(fn message -> {inbox_family_thread_key(message), message} end) + end + + defp inbox_family_thread_key(%{thread_id: thread_id}) + when is_integer(thread_id) and thread_id > 0, + do: thread_id + + defp inbox_family_thread_key(%{id: id}), do: -id + + @doc """ + Returns paginated spam messages for a mailbox with metadata. + """ + def list_spam_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + page = max(page, 1) + offset = (page - 1) * per_page + + # Get total count + total_count = + Message + |> where(mailbox_id: ^mailbox_id, spam: true, archived: false, deleted: false) + |> where([m], is_nil(m.folder_id)) + |> Repo.aggregate(:count) + + # Get messages for current page + messages = Elektrine.Email.Messages.list_spam_messages(mailbox_id, per_page, offset) + + # Calculate pagination metadata + total_pages = ceil(total_count / per_page) + has_next = page < total_pages + has_prev = page > 1 + + %{ + messages: messages, + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages, + has_next: has_next, + has_prev: has_prev + } + end + + @doc """ + Returns paginated trash messages for a mailbox with metadata. + """ + def list_trash_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + page = max(page, 1) + offset = (page - 1) * per_page + + # Get total count - trash shows ALL deleted messages regardless of other filters + total_count = + Message + |> where(mailbox_id: ^mailbox_id, deleted: true) + |> Repo.aggregate(:count) + + # Get messages for current page - trash shows ALL deleted messages + messages = + Message + |> where(mailbox_id: ^mailbox_id, deleted: true) + |> order_by(desc: :inserted_at) + |> limit(^per_page) + |> offset(^offset) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + + # Calculate pagination metadata + total_pages = ceil(total_count / per_page) + has_next = page < total_pages + has_prev = page > 1 + + %{ + messages: messages, + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages, + has_next: has_next, + has_prev: has_prev + } + end + + @doc """ + Returns paginated archived messages for a mailbox with metadata. + """ + def list_archived_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + page = max(page, 1) + offset = (page - 1) * per_page + + # Get total count + total_count = + Message + |> where(mailbox_id: ^mailbox_id, archived: true, deleted: false) + |> where([m], is_nil(m.folder_id)) + |> Repo.aggregate(:count) + + # Get messages for current page + messages = Elektrine.Email.Messages.list_archived_messages(mailbox_id, per_page, offset) + + # Calculate pagination metadata + total_pages = ceil(total_count / per_page) + has_next = page < total_pages + has_prev = page > 1 + + %{ + messages: messages, + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages, + has_next: has_next, + has_prev: has_prev + } + end + + @doc """ + Returns paginated sent messages for a mailbox with metadata. + """ + def list_sent_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + page = max(page, 1) + offset = (page - 1) * per_page + + # Get total count + total_count = + Message + |> where([m], m.mailbox_id == ^mailbox_id) + |> where([m], m.status == "sent") + |> where([m], not m.archived) + |> where([m], not m.deleted) + |> where([m], is_nil(m.folder_id)) + |> Repo.aggregate(:count) + + # Get messages for current page + messages = + Message + |> where([m], m.mailbox_id == ^mailbox_id) + |> where([m], m.status == "sent") + |> where([m], not m.archived) + |> where([m], not m.deleted) + |> where([m], is_nil(m.folder_id)) + |> order_by(desc: :inserted_at) + |> limit(^per_page) + |> offset(^offset) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + + # Calculate pagination metadata + total_pages = ceil(total_count / per_page) + has_next = page < total_pages + has_prev = page > 1 + + %{ + messages: messages, + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages, + has_next: has_next, + has_prev: has_prev + } + end + + @doc """ + Returns paginated draft messages for a mailbox with metadata. + """ + def list_drafts_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + page = max(page, 1) + offset = (page - 1) * per_page + + # Build base query for drafts + base_query = + Message + |> where([m], m.mailbox_id == ^mailbox_id) + |> where([m], m.status == "draft") + |> where([m], not m.deleted) + |> where([m], is_nil(m.folder_id)) + + # Get total count + total_count = Repo.aggregate(base_query, :count) + + # Get messages for current page + messages = + base_query + |> order_by(desc: :updated_at) + |> limit(^per_page) + |> offset(^offset) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + + # Calculate pagination metadata + total_pages = ceil(total_count / per_page) + has_next = page < total_pages + has_prev = page > 1 + + %{ + messages: messages, + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages, + has_next: has_next, + has_prev: has_prev + } + end + + @doc """ + Returns the count of draft messages for a mailbox. + """ + def drafts_count(mailbox_id) do + Message + |> where([m], m.mailbox_id == ^mailbox_id) + |> where([m], m.status == "draft") + |> where([m], not m.deleted) + |> Repo.aggregate(:count) + end + + @doc """ + Returns paginated unread messages for a mailbox with metadata. + Optimized to build base query once and reuse for both count and fetch. + """ + def list_unread_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + list_inbox_family_paginated(mailbox_id, page, per_page, :unread) + end + + @doc """ + Returns paginated read messages for a mailbox with metadata. + Optimized to build base query once and reuse for both count and fetch. + """ + def list_read_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + list_inbox_family_paginated(mailbox_id, page, per_page, :read) + end + + ## Hey.com-style Features + + @doc """ + Returns messages in The Feed (newsletters, notifications). + """ + def list_feed_messages(mailbox_id, limit \\ 50, offset \\ 0) do + Message + |> where(mailbox_id: ^mailbox_id) + |> where(category: "feed") + |> where([m], not m.spam) + |> where([m], not m.archived) + |> where([m], not m.deleted) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + |> order_by(desc: :inserted_at) + |> limit(^limit) + |> offset(^offset) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + end + + @doc """ + Returns the total number of messages in The Feed for a mailbox. + """ + def feed_messages_count(mailbox_id) do + Message + |> where(mailbox_id: ^mailbox_id) + |> where(category: "feed") + |> where([m], not m.spam and not m.archived and not m.deleted) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + |> Repo.aggregate(:count) + end + + @doc """ + Returns paginated feed messages for a mailbox with metadata. + Optimized to build base query once and reuse for both count and fetch. + """ + def list_feed_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + page = max(page, 1) + offset = (page - 1) * per_page + + # Build base query once with common filters + base_query = + Message + |> where(mailbox_id: ^mailbox_id) + |> where(category: "feed") + |> where([m], not m.spam and not m.archived and not m.deleted) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + + # Get total count (optimized - single AND clause) + total_count = Repo.aggregate(base_query, :count) + + # Get messages for current page (reuse base query) + messages = + base_query + |> order_by(desc: :inserted_at) + |> limit(^per_page) + |> offset(^offset) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + + # Calculate pagination metadata + total_pages = ceil(total_count / per_page) + has_next = page < total_pages + has_prev = page > 1 + + %{ + messages: messages, + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages, + has_next: has_next, + has_prev: has_prev + } + end + + @doc """ + Returns messages in Paper Trail (receipts, confirmations). + """ + def list_ledger_messages(mailbox_id, limit \\ 50, offset \\ 0) do + Message + |> where(mailbox_id: ^mailbox_id) + |> where(category: "ledger") + |> where([m], not m.spam) + |> where([m], not m.archived) + |> where([m], not m.deleted) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + |> order_by(desc: :inserted_at) + |> limit(^limit) + |> offset(^offset) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + end + + @doc """ + Returns the total number of messages in Paper Trail for a mailbox. + """ + def ledger_messages_count(mailbox_id) do + Message + |> where(mailbox_id: ^mailbox_id) + |> where(category: "ledger") + |> where([m], not m.spam and not m.archived and not m.deleted) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + |> Repo.aggregate(:count) + end + + @doc """ + Returns paginated paper trail messages for a mailbox with metadata. + Optimized to build base query once and reuse for both count and fetch. + """ + def list_ledger_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + page = max(page, 1) + offset = (page - 1) * per_page + + # Build base query once with common filters + base_query = + Message + |> where(mailbox_id: ^mailbox_id) + |> where(category: "ledger") + |> where([m], not m.spam and not m.archived and not m.deleted) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + + # Get total count (optimized - single AND clause) + total_count = Repo.aggregate(base_query, :count) + + # Get messages for current page (reuse base query) + messages = + base_query + |> order_by(desc: :inserted_at) + |> limit(^per_page) + |> offset(^offset) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + + # Calculate pagination metadata + total_pages = ceil(total_count / per_page) + has_next = page < total_pages + has_prev = page > 1 + + %{ + messages: messages, + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages, + has_next: has_next, + has_prev: has_prev + } + end + + @doc """ + Returns messages that are set aside. + """ + def list_stack_messages(mailbox_id, limit \\ 50, offset \\ 0) do + Message + |> where(mailbox_id: ^mailbox_id) + |> where(category: "stack") + |> where([m], not is_nil(m.stack_at)) + |> where([m], not m.spam) + |> where([m], not m.archived) + |> where([m], not m.deleted) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + |> order_by(desc: :stack_at) + |> limit(^limit) + |> offset(^offset) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + end + + @doc """ + Returns paginated set aside messages for a mailbox with metadata. + Optimized to build base query once and reuse for both count and fetch. + """ + def list_stack_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + page = max(page, 1) + offset = (page - 1) * per_page + + # Build base query once with common filters + base_query = + Message + |> where(mailbox_id: ^mailbox_id) + |> where(category: "stack") + |> where([m], not is_nil(m.stack_at) and not m.spam and not m.archived and not m.deleted) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + + # Get total count (optimized - single AND clause) + total_count = Repo.aggregate(base_query, :count) + + # Get messages for current page (reuse base query) + messages = + base_query + |> order_by(desc: :stack_at) + |> limit(^per_page) + |> offset(^offset) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + + # Calculate pagination metadata + total_pages = ceil(total_count / per_page) + has_next = page < total_pages + has_prev = page > 1 + + %{ + messages: messages, + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages, + has_next: has_next, + has_prev: has_prev + } + end + + @doc """ + Returns messages marked for reply later. + """ + def list_reply_later_messages(mailbox_id, limit \\ 50, offset \\ 0) do + Message + |> where(mailbox_id: ^mailbox_id) + |> where([m], not is_nil(m.reply_later_at)) + |> where([m], not m.spam) + |> where([m], not m.archived) + |> where([m], not m.deleted) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + |> order_by(:reply_later_at) + |> limit(^limit) + |> offset(^offset) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + end + + @doc """ + Returns paginated reply later messages for a mailbox with metadata. + Optimized to build base query once and reuse for both count and fetch. + """ + def list_reply_later_messages_paginated(mailbox_id, page \\ 1, per_page \\ 20) do + page = max(page, 1) + offset = (page - 1) * per_page + + # Build base query once with common filters + base_query = + Message + |> where(mailbox_id: ^mailbox_id) + |> where( + [m], + not is_nil(m.reply_later_at) and not m.spam and not m.archived and not m.deleted + ) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + + # Get total count (optimized - single AND clause) + total_count = Repo.aggregate(base_query, :count) + + # Get messages for current page (reuse base query) + messages = + base_query + |> order_by(:reply_later_at) + |> limit(^per_page) + |> offset(^offset) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + + # Calculate pagination metadata + total_pages = ceil(total_count / per_page) + has_next = page < total_pages + has_prev = page > 1 + + %{ + messages: messages, + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages, + has_next: has_next, + has_prev: has_prev + } + end + + ## Bulk List Functions (no pagination) + + @doc """ + Returns all inbox messages for a mailbox without pagination. + Used for bulk operations. + """ + def list_all_inbox_messages(mailbox_id) do + excluded_categories = inbox_excluded_categories(mailbox_id) + + Message + |> where(mailbox_id: ^mailbox_id, spam: false, archived: false, deleted: false) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + |> where([m], is_nil(m.category) or m.category not in ^excluded_categories) + |> where([m], is_nil(m.reply_later_at)) + |> order_by(desc: :inserted_at) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + end + + @doc """ + Returns all feed messages (bulk mail) for a mailbox without pagination. + """ + def list_all_feed_messages(mailbox_id) do + Message + |> where(mailbox_id: ^mailbox_id) + |> where(category: "feed") + |> where([m], not m.spam) + |> where([m], not m.archived) + |> where([m], not m.deleted) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + |> order_by(desc: :inserted_at) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + end + + @doc """ + Returns all paper trail messages for a mailbox without pagination. + """ + def list_all_ledger_messages(mailbox_id) do + Message + |> where(mailbox_id: ^mailbox_id) + |> where(category: "ledger") + |> where([m], not m.spam) + |> where([m], not m.archived) + |> where([m], not m.deleted) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + |> order_by(desc: :inserted_at) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + end + + @doc """ + Returns all set aside messages for a mailbox without pagination. + """ + def list_all_stack_messages(mailbox_id) do + Message + |> where(mailbox_id: ^mailbox_id) + |> where(category: "stack") + |> where([m], not m.spam) + |> where([m], not m.archived) + |> where([m], not m.deleted) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + |> order_by(desc: :inserted_at) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + end + + @doc """ + Returns all reply later messages for a mailbox without pagination. + """ + def list_all_reply_later_messages(mailbox_id) do + Message + |> where(mailbox_id: ^mailbox_id) + |> where([m], not is_nil(m.reply_later_at)) + |> where([m], not m.spam) + |> where([m], not m.archived) + |> where([m], not m.deleted) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + |> order_by(desc: :inserted_at) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + end + + @doc """ + Returns all sent messages for a mailbox without pagination. + """ + def list_all_sent_messages(mailbox_id) do + Message + |> where([m], m.mailbox_id == ^mailbox_id) + |> where([m], m.status == "sent") + |> where([m], not m.archived) + |> order_by(desc: :inserted_at) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + end + + @doc """ + Returns all spam messages for a mailbox without pagination. + """ + def list_all_spam_messages(mailbox_id) do + Message + |> where(mailbox_id: ^mailbox_id, spam: true, archived: false) + |> order_by(desc: :inserted_at) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + end + + ## Category Movement Operations + + @doc """ + Sets aside a message for later processing. + """ + def stack_message(%Message{} = message, reason \\ nil) do + message + |> Message.stack_changeset(%{stack_reason: reason}) + |> Repo.update() + |> CacheHooks.with_cache_invalidation() + end + + @doc """ + Removes a message from set aside. + """ + def unstack_message(%Message{} = message) do + message + |> Message.unstack_changeset() + |> Repo.update() + |> CacheHooks.with_cache_invalidation() + end + + @doc """ + Moves a message to digest category. + """ + def move_to_digest(%Message{} = message) do + message + |> Message.changeset(%{category: "feed"}) + |> Repo.update() + |> CacheHooks.with_cache_invalidation() + |> maybe_learn_category_preference(message, "feed") + end + + @doc """ + Moves a message to ledger category. + """ + def move_to_ledger(%Message{} = message) do + message + |> Message.changeset(%{category: "ledger"}) + |> Repo.update() + |> CacheHooks.with_cache_invalidation() + |> maybe_learn_category_preference(message, "ledger") + end + + @doc """ + Sets a message for reply later. + """ + def reply_later_message(%Message{} = message, reply_at, reminder \\ false) do + message + |> Message.reply_later_changeset(%{ + reply_later_at: reply_at, + reply_later_reminder: reminder + }) + |> Repo.update() + |> CacheHooks.with_cache_invalidation() + end + + @doc """ + Clears reply later for a message. + """ + def clear_reply_later(%Message{} = message) do + message + |> Message.clear_reply_later_changeset() + |> Repo.update() + |> CacheHooks.with_cache_invalidation() + end + + defp maybe_learn_category_preference({:ok, _updated} = result, %Message{} = message, category) + when category in ["feed", "ledger"] do + case Elektrine.Email.Mailboxes.get_mailbox(message.mailbox_id) do + %Mailbox{user_id: user_id} when is_integer(user_id) and is_binary(message.from) -> + _ = CategoryPreferences.learn_from_manual_move(user_id, message.from, category) + result + + _ -> + result + end + end + + defp maybe_learn_category_preference(result, _message, _category), do: result + + ## IMAP Folder Operations + + @doc """ + Lists messages for IMAP access by folder. + Returns messages with necessary fields for IMAP protocol. + """ + def list_messages_for_imap(mailbox_id, folder) when is_integer(mailbox_id) do + base_query = + Message + |> where([m], m.mailbox_id == ^mailbox_id) + |> where([m], is_nil(m.reply_later_at)) + + query = + case folder do + :inbox -> + # Match webmail: show everything except sent/drafts, spam, deleted, archived + base_query + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + |> where([m], not m.spam) + |> where([m], not m.deleted) + |> where([m], not m.archived) + + :sent -> + base_query + |> where([m], m.status == "sent") + |> where([m], not m.deleted) + + :drafts -> + base_query + |> where([m], m.status == "draft") + |> where([m], not m.deleted) + + :trash -> + # Trash should show ALL deleted messages, even boomerang ones + Message + |> where([m], m.mailbox_id == ^mailbox_id) + |> where([m], m.deleted == true) + + :spam -> + base_query + |> where([m], m.spam == true and m.status not in ["sent", "draft"]) + |> where([m], not m.deleted) + + _ -> + base_query + |> where([m], not m.spam and not m.archived) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status)) + end + + query + |> order_by([m], asc: m.id) + |> select([m], %{ + id: m.id, + message_id: m.message_id, + from: m.from, + to: m.to, + subject: m.subject, + read: m.read, + flagged: m.flagged, + deleted: m.deleted, + spam: m.spam, + answered: m.answered, + status: m.status, + inserted_at: m.inserted_at, + has_attachments: m.has_attachments + }) + |> Repo.all() + end + + @doc """ + Lists messages for IMAP access from a custom folder. + """ + def list_messages_for_imap_custom_folder(mailbox_id, folder_id) + when is_integer(mailbox_id) and is_integer(folder_id) do + Message + |> where([m], m.mailbox_id == ^mailbox_id) + |> where([m], m.folder_id == ^folder_id) + |> where([m], is_nil(m.reply_later_at)) + |> where([m], not m.deleted) + |> order_by([m], asc: m.id) + |> select([m], %{ + id: m.id, + message_id: m.message_id, + from: m.from, + to: m.to, + subject: m.subject, + read: m.read, + flagged: m.flagged, + deleted: m.deleted, + spam: m.spam, + answered: m.answered, + status: m.status, + inserted_at: m.inserted_at, + has_attachments: m.has_attachments + }) + |> Repo.all() + end + + @doc """ + Lists messages for POP3 access. + Returns messages with necessary fields for POP3 protocol. + """ + def list_messages_for_pop3(mailbox_id) when is_integer(mailbox_id) do + # Load full message structs so we can decrypt them + messages = + Message + |> where([m], m.mailbox_id == ^mailbox_id) + |> where([m], not m.spam and not m.archived) + |> where([m], m.status != "sent" or is_nil(m.status)) + |> where([m], is_nil(m.reply_later_at)) + |> order_by(asc: :inserted_at) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + + # Convert to the format expected by POP3 + Enum.map(messages, fn m -> + %{ + id: m.id, + message_id: m.message_id, + from: m.from, + to: m.to, + subject: m.subject, + text_body: m.text_body, + html_body: m.html_body, + inserted_at: m.inserted_at, + attachments: m.attachments, + has_attachments: m.has_attachments + } + end) + end + + defp inbox_excluded_categories(mailbox_id) do + mailbox_id + |> Elektrine.Email.Mailboxes.get_mailbox() + |> Mailbox.enabled_category_filter_categories() + |> Kernel.++(["stack"]) + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/forwarded_message.ex b/apps/elektrine_email/lib/elektrine/email/forwarded_message.ex new file mode 100644 index 0000000..4ee7cad --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/forwarded_message.ex @@ -0,0 +1,39 @@ +defmodule Elektrine.Email.ForwardedMessage do + @moduledoc """ + Schema for tracking forwarded email messages through alias chains. + Records the forwarding path, hop count, and metadata for forwarded emails. + """ + use Ecto.Schema + import Ecto.Changeset + + alias Elektrine.Email.Alias + + schema "forwarded_messages" do + field :message_id, :string + field :from_address, :string + field :subject, :string + field :original_recipient, :string + field :final_recipient, :string + field :forwarding_chain, :map + field :total_hops, :integer + + belongs_to :alias, Alias + + timestamps() + end + + def changeset(forwarded_message, attrs) do + forwarded_message + |> cast(attrs, [ + :message_id, + :from_address, + :subject, + :original_recipient, + :final_recipient, + :forwarding_chain, + :total_hops, + :alias_id + ]) + |> validate_required([:original_recipient, :final_recipient]) + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/haraka_adapter.ex b/apps/elektrine_email/lib/elektrine/email/haraka_adapter.ex new file mode 100644 index 0000000..f78a572 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/haraka_adapter.ex @@ -0,0 +1,95 @@ +defmodule Elektrine.Email.HarakaAdapter do + @moduledoc """ + Swoosh adapter for the Haraka HTTP API server. + + Translates `Swoosh.Email` structs into `Elektrine.Email.HarakaClient` params + and delegates delivery to it, so transactional mail shares the same wire + format (RFC 2047 header encoding, UTF-8 sanitization, attachment handling, + internal origin signing) as user-composed mail. + """ + + use Swoosh.Adapter, + required_config: [:api_key], + optional_config: [:base_url, :timeout] + + alias Elektrine.Email.HarakaClient + alias Elektrine.Email.InternalOrigin + alias Swoosh.Email + + @impl true + def deliver(%Email{} = email, _config) do + email + |> to_client_params() + |> HarakaClient.send_email() + end + + @impl true + def deliver_many(emails, config) do + # Deliver emails one by one via HTTP API + Enum.map(emails, &deliver(&1, config)) + end + + @doc false + def build_api_body(%Email{} = email) do + email + |> to_client_params() + |> InternalOrigin.sign_params() + |> HarakaClient.build_api_body() + end + + @doc false + def to_client_params(%Email{} = email) do + params = %{ + from: format_address(email.from), + to: Enum.map(email.to || [], &format_address/1), + subject: email.subject, + text_body: email.text_body, + html_body: email.html_body, + headers: email.headers || %{} + } + + params + |> maybe_put(:cc, join_addresses(email.cc)) + |> maybe_put(:bcc, join_addresses(email.bcc)) + |> maybe_put(:reply_to, format_address(email.reply_to)) + |> maybe_put(:attachments, attachment_params(email.attachments)) + end + + defp maybe_put(params, _key, nil), do: params + defp maybe_put(params, _key, []), do: params + defp maybe_put(params, key, value), do: Map.put(params, key, value) + + # HarakaClient expects cc/bcc as comma-separated strings. + defp join_addresses(nil), do: nil + defp join_addresses([]), do: nil + + defp join_addresses(addresses) when is_list(addresses) do + Enum.map_join(addresses, ", ", &format_address/1) + end + + defp format_address(nil), do: nil + defp format_address({"", address}) when is_binary(address), do: address + + defp format_address({name, address}) when is_binary(name) and is_binary(address) do + "#{name} <#{address}>" + end + + defp format_address(address) when is_binary(address), do: address + + defp attachment_params(nil), do: [] + + defp attachment_params(attachments) when is_list(attachments) do + Enum.map(attachments, fn %Swoosh.Attachment{} = attachment -> + base = %{ + "filename" => attachment.filename, + "content_type" => attachment.content_type, + "data" => Swoosh.Attachment.get_content(attachment) + } + + case attachment.cid do + nil -> base + cid -> Map.put(base, "content_id", cid) + end + end) + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/haraka_admin.ex b/apps/elektrine_email/lib/elektrine/email/haraka_admin.ex new file mode 100644 index 0000000..436a3b0 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/haraka_admin.ex @@ -0,0 +1,245 @@ +defmodule Elektrine.Email.HarakaAdmin do + @moduledoc false + + alias Elektrine.Domains + alias Elektrine.Email.DKIM + alias Elektrine.EmailConfig + alias Elektrine.RuntimeEnv + + @status_path "/status" + @metrics_path "/metrics" + @request_timeout 5_000 + + def overview do + domains = Domains.supported_email_domains() + status = fetch_status() + metrics = fetch_metrics() + domain_diagnostics = Enum.map(domains, &domain_diagnostic/1) + + %{ + base_url: lookup_base_url(), + send_base_url: EmailConfig.haraka_base_url(), + api_key_configured: present?(lookup_api_key()), + primary_domain: Domains.primary_email_domain(), + supported_domains: domains, + mx_host: DKIM.mx_host(), + mx_priority: DKIM.mx_priority(), + spf_value: DKIM.spf_value(), + dmarc_value: DKIM.dmarc_value(), + status_details: status, + metrics: metrics, + domain_diagnostics: domain_diagnostics, + haraka_status: overall_status(status, metrics, domain_diagnostics) + } + end + + defp domain_diagnostic(domain) do + case DKIM.fetch_domain(domain) do + {:ok, fetched} -> + %{ + domain: domain, + status: :ok, + selector: fetched.selector, + host: fetched.host, + value: fetched.value, + public_key_present: present?(fetched.public_key), + private_key_present: fetched.private_key_present, + notes: fetched.notes + } + + {:error, reason} -> + %{ + domain: domain, + status: :error, + error: reason, + host: nil, + value: nil, + selector: nil, + public_key_present: false, + private_key_present: false, + notes: [] + } + end + end + + defp fetch_status do + case request_json(lookup_base_url(), @status_path) do + {:ok, %{"ok" => true} = payload} -> + %{ + status: :ok, + role: payload["role"], + started_at: payload["started_at"], + error: nil + } + + {:ok, %{} = payload} -> + %{ + status: :error, + role: payload["role"], + started_at: payload["started_at"], + error: payload["error"] || "Haraka status endpoint returned an unexpected payload." + } + + {:error, reason} -> + %{status: :error, role: nil, started_at: nil, error: reason} + end + end + + defp fetch_metrics do + case request_text(lookup_base_url(), @metrics_path) do + {:ok, body} -> + parsed = parse_prometheus_metrics(body) + + %{ + status: :ok, + request_total: metric_value(parsed, "elektrine_http_api_requests_total"), + auth_failures: metric_value(parsed, "elektrine_http_api_auth_failures_total"), + rate_limited: metric_value(parsed, "elektrine_http_api_rate_limited_total"), + sent_ok: metric_value(parsed, "elektrine_http_api_sent_ok_total"), + sent_error: metric_value(parsed, "elektrine_http_api_sent_error_total"), + dkim_sync_ok: metric_value(parsed, "elektrine_http_api_dkim_sync_ok_total"), + dkim_sync_error: metric_value(parsed, "elektrine_http_api_dkim_sync_error_total"), + dkim_delete_ok: metric_value(parsed, "elektrine_http_api_dkim_delete_ok_total"), + dkim_delete_error: metric_value(parsed, "elektrine_http_api_dkim_delete_error_total"), + uptime_seconds: metric_value(parsed, "elektrine_http_api_uptime_seconds"), + error: nil + } + + {:error, reason} -> + %{ + status: :error, + request_total: nil, + auth_failures: nil, + rate_limited: nil, + sent_ok: nil, + sent_error: nil, + dkim_sync_ok: nil, + dkim_sync_error: nil, + dkim_delete_ok: nil, + dkim_delete_error: nil, + uptime_seconds: nil, + error: reason + } + end + end + + defp overall_status(status, metrics, domain_diagnostics) do + cond do + status.status == :ok and metrics.status == :ok -> + :connected + + Enum.any?(domain_diagnostics, &(&1.status == :ok)) -> + :connected + + status.status == :error or metrics.status == :error or + Enum.any?(domain_diagnostics, &(&1.status == :error)) -> + :error + + true -> + :unknown + end + end + + defp request_json(base_url, path) do + with {:ok, response_body} <- request(base_url, path), + {:ok, payload} <- Jason.decode(response_body) do + {:ok, payload} + else + {:error, _} = error -> error + {:error, reason, _body} -> {:error, reason} + _ -> {:error, "Haraka returned invalid JSON."} + end + end + + defp request_text(base_url, path) do + request(base_url, path) + end + + defp request(base_url, _path) when not is_binary(base_url) or base_url == "" do + {:error, "Haraka base URL is not configured"} + end + + defp request(base_url, path) do + case http_client().request( + :get, + "#{String.trim_trailing(base_url, "/")}#{path}", + request_headers(), + "", + receive_timeout: @request_timeout + ) do + {:ok, %Finch.Response{status: status, body: body}} when status in 200..299 -> + {:ok, body} + + {:ok, %Finch.Response{status: status, body: body}} -> + {:error, + "Haraka endpoint #{path} failed with status #{status}: #{normalize_error_body(body)}"} + + {:error, reason} -> + {:error, "Haraka endpoint #{path} failed: #{inspect(reason)}"} + end + end + + defp request_headers do + case lookup_api_key() do + api_key when is_binary(api_key) and api_key != "" -> [{"x-api-key", api_key}] + _ -> [] + end + end + + defp http_client, do: EmailConfig.haraka_http_client() + + defp parse_prometheus_metrics(body) when is_binary(body) do + body + |> String.split("\n", trim: true) + |> Enum.reduce(%{}, fn line, acc -> + if String.starts_with?(line, "#") do + acc + else + case String.split(line, ~r/\s+/, parts: 2, trim: true) do + [name, value] -> Map.put(acc, name, parse_metric_number(value)) + _ -> acc + end + end + end) + end + + defp parse_prometheus_metrics(_), do: %{} + + defp parse_metric_number(value) do + case Integer.parse(value) do + {integer, ""} -> + integer + + _ -> + case Float.parse(value) do + {float, ""} -> trunc(float) + _ -> nil + end + end + end + + defp metric_value(metrics, name), do: Map.get(metrics, name) + + defp normalize_error_body(body) when is_binary(body) do + case Jason.decode(body) do + {:ok, %{"error" => error}} when is_binary(error) -> error + {:ok, %{"message" => message}} when is_binary(message) -> message + _ -> String.trim(body) + end + end + + defp normalize_error_body(body), do: inspect(body) + + defp present?(value) when is_binary(value), do: Elektrine.Strings.present?(value) + defp present?(_), do: false + + defp lookup_base_url do + RuntimeEnv.app_config(:email, []) + |> Keyword.get(:custom_domain_haraka_base_url, EmailConfig.haraka_base_url()) + end + + defp lookup_api_key do + RuntimeEnv.app_config(:email, []) + |> Keyword.get(:custom_domain_haraka_api_key, EmailConfig.haraka_api_key()) + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/haraka_client.ex b/apps/elektrine_email/lib/elektrine/email/haraka_client.ex new file mode 100644 index 0000000..9612c06 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/haraka_client.ex @@ -0,0 +1,640 @@ +defmodule Elektrine.Email.HarakaClient do + @moduledoc """ + HTTP API client for Haraka server. + + Supports: + - Sending emails via Haraka HTTP API + - RFC 2047 MIME encoding for non-ASCII headers + """ + + require Logger + + alias Elektrine.Email.InternalOrigin + alias Elektrine.EmailConfig + + @api_path "/api/v1/send" + + defmodule FinchClient do + @moduledoc false + + def request(method, url, headers, body, opts) do + method + |> Finch.build(url, headers, body) + |> Finch.request(Elektrine.Finch, opts) + end + end + + def forward_email(original_email_data, forward_to_email, from_address) do + # Get original sender info (support both atom and string keys) + original_from = + Map.get(original_email_data, "from") || Map.get(original_email_data, :from) || + "unknown@sender.com" + + original_subject = + Map.get(original_email_data, "subject") || Map.get(original_email_data, :subject) || "" + + # Extract just the email address from original_from for API fields + # The API may not accept quoted display name format like "Name" + {original_from_email, _name} = extract_email_and_name(original_from) + + # Update subject to indicate it's forwarded + forwarded_subject = + if String.starts_with?(original_subject, "Fwd: ") do + original_subject + else + "Fwd: #{original_subject}" + end + + # Convert attachments to format needed for sending + attachments_for_sending = + Map.get(original_email_data, "attachments") || Map.get(original_email_data, :attachments) || + %{} + + html_body = + Map.get(original_email_data, "html_body") || Map.get(original_email_data, :html_body) + + text_body = + Map.get(original_email_data, "text_body") || Map.get(original_email_data, :text_body) + + email_params = %{ + from: from_address, + to: forward_to_email, + # Set Reply-To to original sender email only + reply_to: original_from_email, + # Set Sender header to original sender email only + sender: original_from_email, + subject: forwarded_subject, + html_body: html_body, + text_body: text_body, + attachments: attachments_for_sending, + # Add custom headers to preserve original sender info (full format with name) + headers: %{ + "X-Original-From" => original_from, + "X-Forwarded-By" => from_address, + "X-Forwarded-Date" => DateTime.utc_now() |> DateTime.to_string() + } + } + + send_email(email_params) + end + + def send_email(params) do + from_address = params[:from] || "" + + with :ok <- validate_message_content(params), + {:ok, {api_key, base_url}} <- get_api_config_for_domain(from_address) do + params_with_origin = add_internal_origin_headers(params) + body = build_api_body(params_with_origin) + log_payload_summary(params_with_origin, body) + headers = request_headers(api_key) + url = "#{base_url}#{@api_path}" + + case http_client().request(:post, url, headers, body, receive_timeout: 60_000) do + {:ok, %Finch.Response{status: 200, body: response_body}} -> + case Jason.decode(response_body) do + {:ok, %{"success" => true, "message_id" => message_id}} -> + {:ok, %{id: message_id, message_id: message_id}} + + {:ok, %{"success" => false, "error" => error}} -> + Logger.error("Haraka HTTP API error: #{inspect(error)}") + {:error, error} + + {:ok, response} -> + Logger.error("Unexpected Haraka HTTP API response: #{inspect(response)}") + {:error, "Unexpected response format"} + + {:error, decode_error} -> + Logger.error("Failed to decode Haraka HTTP API response: #{inspect(decode_error)}") + + {:error, "Invalid JSON response"} + end + + {:ok, %Finch.Response{status: status_code, body: response_body}} -> + # Try to parse the error response for more details + error_detail = + case Jason.decode(response_body) do + {:ok, %{"error" => error}} -> error + {:ok, %{"message" => msg}} -> msg + _ -> response_body + end + + Logger.error("Haraka HTTP API returned status #{status_code}: #{error_detail}") + {:error, "Haraka HTTP API returned status #{status_code}: #{error_detail}"} + + {:error, reason} -> + Logger.error("HTTP request to Haraka failed: #{inspect(reason)}") + {:error, reason} + end + else + {:error, :missing_message_body} -> + Logger.error( + "Refusing to send structured Haraka email without body or attachments " <> + "from_domain=#{inspect(email_domain(params[:from]))} " <> + "to_count=#{recipient_count(params[:to])} subject_present=#{Elektrine.Strings.present?(params[:subject])}" + ) + + {:error, :missing_message_body} + + {:error, reason} -> + Logger.error("Cannot send email via Haraka HTTP API: #{reason}") + {:error, reason} + end + end + + defp validate_message_content(params) do + cond do + raw_email_present?(params) -> :ok + body_present?(params[:text_body]) -> :ok + body_present?(params[:html_body]) -> :ok + attachments_present?(params[:attachments]) -> :ok + true -> {:error, :missing_message_body} + end + end + + defp raw_email_present?(params) do + body_present?(params[:raw_email]) || body_present?(params["raw_email"]) + end + + defp body_present?(value) when is_binary(value), do: String.trim(value) != "" + defp body_present?(_value), do: false + + defp attachments_present?(attachments) when is_map(attachments), do: map_size(attachments) > 0 + defp attachments_present?(attachments) when is_list(attachments), do: attachments != [] + defp attachments_present?(_attachments), do: false + + defp email_domain(value) when is_binary(value) do + value + |> String.split("@") + |> List.last() + |> to_string() + |> String.downcase() + end + + defp email_domain(_value), do: nil + + defp recipient_count(nil), do: 0 + defp recipient_count(recipients) when is_list(recipients), do: length(recipients) + + defp recipient_count(recipients) when is_binary(recipients) do + recipients + |> String.split(~r/[,;]/, trim: true) + |> length() + end + + defp recipient_count(_recipients), do: 0 + + defp log_payload_summary(params, encoded_body) do + Logger.info( + "HarakaClient payload summary " <> + "from_domain=#{inspect(email_domain(params[:from] || params["from"]))} " <> + "to_count=#{recipient_count(params[:to] || params["to"])} " <> + "text_bytes=#{body_size(params[:text_body] || params["text_body"])} " <> + "html_bytes=#{body_size(params[:html_body] || params["html_body"])} " <> + "raw_bytes=#{body_size(params[:raw_email] || params["raw_email"])} " <> + "attachments=#{attachment_count(params[:attachments] || params["attachments"])} " <> + "json_bytes=#{byte_size(encoded_body)}" + ) + end + + defp body_size(value) when is_binary(value), do: byte_size(value) + defp body_size(_value), do: 0 + + defp attachment_count(attachments) when is_map(attachments), do: map_size(attachments) + defp attachment_count(attachments) when is_list(attachments), do: length(attachments) + defp attachment_count(_attachments), do: 0 + + defp get_api_config_for_domain(_from_address) do + base_url = configured_base_url() + + case configured_api_key() do + nil -> + {:error, + "HARAKA_HTTP_API_KEY, HARAKA_OUTBOUND_API_KEY (or HARAKA_API_KEY / INTERNAL_API_KEY fallback) is not set"} + + "" -> + {:error, + "HARAKA_HTTP_API_KEY, HARAKA_OUTBOUND_API_KEY (or HARAKA_API_KEY / INTERNAL_API_KEY fallback) is empty"} + + api_key -> + {:ok, {api_key, base_url}} + end + end + + defp request_headers(api_key) do + [ + {"Content-Type", "application/json"}, + {"X-API-Key", api_key}, + {"User-Agent", "Elektrine-Haraka-Client/1.0"} + ] + end + + defp configured_base_url do + EmailConfig.haraka_base_url() + end + + defp configured_api_key do + EmailConfig.haraka_api_key(mailer_config()[:api_key]) + end + + defp mailer_config do + [ + api_key: EmailConfig.mailer_setting(:api_key), + base_url: EmailConfig.mailer_setting(:base_url) + ] + end + + defp http_client do + EmailConfig.haraka_http_client() + end + + @doc false + # Public so HarakaAdapter can share the exact wire format used for user mail. + def build_api_body(params) do + # If raw email data is provided, use that directly (preserves attachments and MIME structure) + if params[:raw_email] do + # Base64 encode raw email to safely transmit binary data in JSON + body = %{ + "raw_base64" => Base.encode64(params[:raw_email]), + "from" => ensure_field_utf8_safe(params[:from]), + "to" => normalize_recipients_for_api(params[:to]) |> ensure_json_utf8_safe() + } + + Jason.encode!(body) + else + # For Haraka, format From field with RFC 2047 encoding for non-ASCII display names + from_field = + case extract_email_and_name(params[:from]) do + {email, nil} -> + # No display name, use email only + email + + {email, name} -> + if Elektrine.Strings.present?(name) do + encoded_name = encode_mime_header(name) + "#{encoded_name} <#{email}>" + else + email + end + end + + # Build the JSON body for Haraka HTTP API + # CRITICAL: Sanitize ALL fields to ensure valid UTF-8 for JSON encoding + # Jason.encode! will fail on invalid UTF-8 bytes + # Subject gets RFC 2047 MIME encoding for non-ASCII characters (like Chinese) + raw_subject = ensure_field_utf8_safe(params[:subject] || params["subject"] || "") + subject_safe = encode_mime_header(raw_subject) + + from_safe = ensure_field_utf8_safe(from_field) + to_safe = ensure_field_utf8_safe(normalize_recipients_for_api(params[:to])) + + body = %{ + "from" => from_safe, + "to" => to_safe, + "subject" => subject_safe + } + + # Add Reply-To if present + body = + if Elektrine.Strings.present?(params[:reply_to]) do + Map.put(body, "reply_to", ensure_field_utf8_safe(params[:reply_to])) + else + body + end + + # Add CC if present + body = + if Elektrine.Strings.present?(params[:cc]) do + Map.put(body, "cc", ensure_field_utf8_safe(normalize_recipients_for_api(params[:cc]))) + else + body + end + + # Add BCC if present + body = + if Elektrine.Strings.present?(params[:bcc]) do + Map.put(body, "bcc", ensure_field_utf8_safe(normalize_recipients_for_api(params[:bcc]))) + else + body + end + + # Add body content - send both HTML and text when both are available + has_html = Elektrine.Strings.present?(params[:html_body]) + has_text = Elektrine.Strings.present?(params[:text_body]) + + body = + cond do + has_html && has_text -> + # Send both HTML and text for proper multipart/alternative MIME + body + |> Map.put("html_body", ensure_field_utf8_safe(params[:html_body])) + |> Map.put("text_body", ensure_field_utf8_safe(params[:text_body])) + |> Map.put("html", ensure_field_utf8_safe(params[:html_body])) + |> Map.put("text", ensure_field_utf8_safe(params[:text_body])) + + has_html -> + # HTML only + html_body = ensure_field_utf8_safe(params[:html_body]) + + body + |> Map.put("html_body", html_body) + |> Map.put("html", html_body) + + has_text -> + # Plain text only + text_body = ensure_field_utf8_safe(params[:text_body]) + + body + |> Map.put("text_body", text_body) + |> Map.put("text", text_body) + + params[:text_body] -> + # Even if text_body is empty string, include it + text_body = ensure_field_utf8_safe(params[:text_body]) + + body + |> Map.put("text_body", text_body) + |> Map.put("text", text_body) + + true -> + # Fallback - add empty text body + body + |> Map.put("text_body", "") + |> Map.put("text", "") + end + + # Add custom headers (for threading, forwarding info, etc.). + # MIME structure and transfer encoding are owned by Haraka's message builder. + headers = %{} + + # Add In-Reply-To header if present + headers = + if params[:in_reply_to] do + Map.put(headers, "In-Reply-To", ensure_field_utf8_safe(params[:in_reply_to])) + else + headers + end + + headers = + if params[:references] do + Map.put(headers, "References", ensure_field_utf8_safe(params[:references])) + else + headers + end + + # Add custom forwarding headers if present + headers = + if params[:headers] && is_map(params[:headers]) do + # Sanitize all header values + sanitized_custom_headers = + params[:headers] + |> Enum.map(fn {k, v} -> {ensure_json_key_utf8_safe(k), ensure_json_utf8_safe(v)} end) + |> Map.new() + + Map.merge(headers, sanitized_custom_headers) + else + headers + end + + # Always add headers to body (we now have default charset headers) + body = Map.put(body, "headers", headers) + + # Add sender field if present (for forwarding) + body = + if params[:sender] && params[:sender] != "" do + Map.put(body, "sender", ensure_field_utf8_safe(params[:sender])) + else + body + end + + # Add attachments if present + body = + case params[:attachments] do + attachments when is_map(attachments) and map_size(attachments) > 0 -> + Map.put(body, "attachments", normalize_attachments_for_api(attachments)) + + attachments when is_list(attachments) and attachments != [] -> + Map.put(body, "attachments", normalize_attachments_for_api(attachments)) + + _ -> + body + end + + body = ensure_json_utf8_safe(body) + + # CRITICAL: Validate all string fields before JSON encoding + # Log any fields that might cause JSON encoding to fail + Enum.each(body, fn {key, value} -> + if is_binary(value) && !String.valid?(value) do + require Logger + + Logger.error( + "HarakaClient: Field '#{key}' has invalid UTF-8 before JSON encode: #{inspect(value, limit: 50)}" + ) + end + end) + + Jason.encode!(body) + end + end + + # Extract email address and display name from "Display Name " format + defp extract_email_and_name(from_field) do + case Regex.run(~r/^"?([^"]*?)"?\s*<([^>]+)>$/, String.trim(from_field)) do + [_, name, email] -> + {String.trim(email), String.trim(name)} + + _ -> + # If no display name format, just return the email + {String.trim(from_field), nil} + end + end + + # Convert recipients to the format the HTTP API expects + defp normalize_recipients_for_api(nil), do: nil + defp normalize_recipients_for_api(recipients) when is_list(recipients), do: recipients + + defp normalize_recipients_for_api(recipients) when is_binary(recipients) do + recipients + |> String.split(~r/[,;]\s*/) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> case do + [] -> nil + [single] -> single + multiple -> multiple + end + end + + # Ensure a field is valid UTF-8 for JSON encoding + # Jason.encode! will crash on invalid UTF-8 bytes + defp ensure_field_utf8_safe(nil), do: "" + defp ensure_field_utf8_safe(""), do: "" + + defp ensure_field_utf8_safe(value) when is_binary(value) do + if String.valid?(value) do + value + else + # Invalid UTF-8, sanitize it + require Logger + + Logger.warning( + "HarakaClient: Invalid UTF-8 detected in field, sanitizing: #{inspect(value, limit: 50)}" + ) + + Elektrine.Email.Sanitizer.sanitize_utf8(value) + end + end + + defp ensure_field_utf8_safe(value) when is_list(value) do + # Handle list of recipients + Enum.map(value, &ensure_field_utf8_safe/1) + end + + defp ensure_field_utf8_safe(value), do: value + + defp ensure_json_utf8_safe(value) when is_binary(value), do: ensure_field_utf8_safe(value) + + defp ensure_json_utf8_safe(values) when is_list(values) do + Enum.map(values, &ensure_json_utf8_safe/1) + end + + defp ensure_json_utf8_safe(values) when is_map(values) do + Enum.reduce(values, %{}, fn {key, value}, acc -> + Map.put(acc, ensure_json_key_utf8_safe(key), ensure_json_utf8_safe(value)) + end) + end + + defp ensure_json_utf8_safe(value), do: value + + defp ensure_json_key_utf8_safe(key) when is_binary(key), do: ensure_field_utf8_safe(key) + + defp ensure_json_key_utf8_safe(key) when is_atom(key) do + key + |> Atom.to_string() + |> ensure_field_utf8_safe() + end + + defp ensure_json_key_utf8_safe(key) do + key + |> to_string() + |> ensure_field_utf8_safe() + end + + defp normalize_attachments_for_api(attachments) when is_map(attachments) do + attachments + |> Map.values() + |> Enum.map(&normalize_attachment_for_api/1) + end + + defp normalize_attachments_for_api(attachments) when is_list(attachments) do + Enum.map(attachments, &normalize_attachment_for_api/1) + end + + defp normalize_attachment_for_api(attachment) when is_map(attachment) do + normalized = %{ + "filename" => + ensure_field_utf8_safe( + attachment_field(attachment, "filename") || attachment_field(attachment, :filename) || + "attachment" + ), + "content_type" => + ensure_field_utf8_safe( + attachment_field(attachment, "content_type") || + attachment_field(attachment, :content_type) || "application/octet-stream" + ), + "encoding" => "base64", + "data" => + normalize_attachment_data_for_api( + attachment_field(attachment, "data") || attachment_field(attachment, :data), + attachment_field(attachment, "encoding") || attachment_field(attachment, :encoding) + ) + } + + case attachment_field(attachment, "content_id") || attachment_field(attachment, :content_id) do + nil -> normalized + "" -> normalized + content_id -> Map.put(normalized, "content_id", ensure_field_utf8_safe(content_id)) + end + end + + defp normalize_attachment_for_api(_attachment) do + %{ + "filename" => "attachment", + "content_type" => "application/octet-stream", + "encoding" => "base64", + "data" => "" + } + end + + defp normalize_attachment_data_for_api(nil, _encoding), do: "" + defp normalize_attachment_data_for_api("", _encoding), do: "" + + defp normalize_attachment_data_for_api(data, encoding) when is_binary(data) do + case normalize_attachment_encoding(encoding) do + "base64" -> + if String.valid?(data) do + normalized = String.replace(data, ~r/\s+/, "") + + case Base.decode64(normalized) do + {:ok, _decoded} -> normalized + :error -> Base.encode64(data) + end + else + Base.encode64(data) + end + + _ -> + Base.encode64(data) + end + end + + defp normalize_attachment_data_for_api(data, _encoding) do + data + |> to_string() + |> Base.encode64() + end + + defp normalize_attachment_encoding(encoding) when is_binary(encoding) do + encoding + |> String.trim() + |> String.downcase() + end + + defp normalize_attachment_encoding(_encoding), do: nil + + defp attachment_field(attachment, key) when is_map(attachment), do: Map.get(attachment, key) + + defp add_internal_origin_headers(params) do + InternalOrigin.sign_params(params) + end + + @doc """ + Encodes a string using RFC 2047 MIME encoding for email headers. + This is required for non-ASCII characters in headers like Subject, From, To. + + Uses Base64 encoding (=?UTF-8?B?...?=) which is more reliable than quoted-printable + for CJK characters and other complex scripts. + """ + def encode_mime_header(nil), do: "" + def encode_mime_header(""), do: "" + + def encode_mime_header(text) when is_binary(text) do + # Check if text contains any non-ASCII characters + if contains_non_ascii?(text) do + # Use Base64 encoding for non-ASCII text (RFC 2047) + # Format: =?charset?encoding?encoded_text?= + encoded = Base.encode64(text) + "=?UTF-8?B?#{encoded}?=" + else + # ASCII-only text doesn't need encoding + text + end + end + + # Check if string contains any non-ASCII characters (codepoint > 127) + defp contains_non_ascii?(text) do + text + |> String.to_charlist() + |> Enum.any?(fn char -> char > 127 end) + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/header_decoder.ex b/apps/elektrine_email/lib/elektrine/email/header_decoder.ex new file mode 100644 index 0000000..040e0fc --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/header_decoder.ex @@ -0,0 +1,38 @@ +defmodule Elektrine.Email.HeaderDecoder do + @moduledoc """ + Shared MIME header decoding for inbound and outbound email pipelines. + """ + + alias Elektrine.Email.Receiver + + def decode_mime_header(nil), do: "" + def decode_mime_header(""), do: "" + + def decode_mime_header(text) when is_binary(text) do + text + |> fix_malformed_header() + |> Receiver.decode_mail_header() + |> ensure_valid_utf8() + end + + # Fix malformed quoted headers produced by some clients. + defp fix_malformed_header(text) when is_binary(text) do + text + |> String.replace(~r/^""/, "\"") + |> String.replace(~r/""$/, "\"") + |> String.replace(~r/^"+"([^"]+)"/, "\"\\1\"") + |> String.trim() + end + + # Final UTF-8 guard so decoded headers are always safe to store/render. + defp ensure_valid_utf8(text) when is_binary(text) do + if String.valid?(text) do + text + else + text + |> String.codepoints() + |> Enum.filter(&String.valid?/1) + |> Enum.join() + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/header_sanitizer.ex b/apps/elektrine_email/lib/elektrine/email/header_sanitizer.ex new file mode 100644 index 0000000..acd42b1 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/header_sanitizer.ex @@ -0,0 +1,403 @@ +defmodule Elektrine.Email.HeaderSanitizer do + @moduledoc "Sanitizes email headers to prevent SMTP header injection attacks.\n\nSMTP header injection occurs when attackers inject newline characters\n(CRLF sequences) into email headers, allowing them to add additional\nheaders or modify the email's behavior.\n" + require Logger + + @doc "Sanitizes all email parameters to prevent SMTP header injection.\n\nReturns {:ok, sanitized_params} or {:error, reason}.\n" + def sanitize_email_params(params) do + sanitized = %{ + from: sanitize_email_header(params[:from] || params["from"]), + to: sanitize_email_header(params[:to] || params["to"]), + cc: sanitize_email_header(params[:cc] || params["cc"]), + bcc: sanitize_email_header(params[:bcc] || params["bcc"]), + subject: sanitize_subject_header(params[:subject] || params["subject"]), + reply_to: sanitize_email_header(params[:reply_to] || params["reply_to"]), + text_body: params[:text_body] || params["text_body"], + html_body: params[:html_body] || params["html_body"] + } + + case validate_critical_fields(sanitized) do + :ok -> {:ok, sanitized} + {:error, reason} -> {:error, reason} + end + rescue + e -> + Logger.error("Error sanitizing email params: #{inspect(e)}") + {:error, "Invalid email parameters"} + end + + @doc "Sanitizes a single email header field by removing dangerous characters.\n" + def sanitize_email_header(nil) do + nil + end + + def sanitize_email_header("") do + "" + end + + def sanitize_email_header(header) when is_binary(header) do + header + |> ensure_valid_utf8() + |> String.replace(~r/[\r\n\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, "") + |> String.replace(~r/[\x{0085}]/u, "") + |> String.trim() + |> String.slice(0, 1000) + end + + def sanitize_email_header(header) when is_list(header) do + header |> iolist_to_binary_safe() |> sanitize_email_header() + end + + def sanitize_email_header(_header) do + nil + end + + defp ensure_valid_utf8(binary) when is_binary(binary) do + if String.valid?(binary) do + binary + else + binary + |> :unicode.characters_to_binary(:utf8, :utf8) + |> case do + {:error, valid, _rest} -> valid + {:incomplete, valid, _rest} -> valid + valid when is_binary(valid) -> valid + end + |> ensure_printable() + end + end + + defp ensure_printable(binary) do + for <>, byte >= 32 or byte in ~c"\t\n\r", into: "", do: <> + end + + @doc "Sanitizes email subject with additional validation for common attacks.\n" + def sanitize_subject_header(nil) do + "" + end + + def sanitize_subject_header("") do + "" + end + + def sanitize_subject_header(subject) when is_binary(subject) do + do_sanitize_subject(subject) + end + + def sanitize_subject_header(subject) when is_list(subject) do + subject |> iolist_to_binary_safe() |> do_sanitize_subject() + end + + def sanitize_subject_header(_subject) do + "" + end + + defp do_sanitize_subject(subject) when is_binary(subject) do + subject + |> ensure_valid_utf8() + |> String.replace(~r/[\r\n]+/, " ") + |> String.replace(~r/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F-\x9F]/u, "") + |> String.trim() + |> String.slice(0, 500) + end + + defp do_sanitize_subject(_) do + "" + end + + defp iolist_to_binary_safe(data) do + IO.iodata_to_binary(data) + rescue + ArgumentError -> flatten_iolist(data, []) |> IO.iodata_to_binary() + end + + defp flatten_iolist([], acc) do + Enum.reverse(acc) + end + + defp flatten_iolist(binary, acc) when is_binary(binary) do + Enum.reverse([binary | acc]) + end + + defp flatten_iolist(int, acc) when is_integer(int) and int >= 0 and int <= 1_114_111 do + Enum.reverse([<> | acc]) + end + + defp flatten_iolist(int, acc) when is_integer(int) do + acc + end + + defp flatten_iolist([head | tail], acc) do + case head do + h when is_binary(h) -> + flatten_iolist(tail, [h | acc]) + + h when is_integer(h) and h >= 0 and h <= 1_114_111 -> + flatten_iolist(tail, [<> | acc]) + + h when is_integer(h) -> + flatten_iolist(tail, acc) + + h when is_list(h) -> + flatten_iolist(tail, Enum.reverse(flatten_iolist(h, []), acc)) + + _ -> + flatten_iolist(tail, acc) + end + end + + defp flatten_iolist(_, acc) do + Enum.reverse(acc) + end + + defp validate_critical_fields(params) do + cond do + is_nil(params.from) or + (is_binary(params.from) and not Elektrine.Strings.present?(params.from)) -> + {:error, "From address is required"} + + is_nil(params.to) or (is_binary(params.to) and not Elektrine.Strings.present?(params.to)) -> + {:error, "To address is required"} + + contains_header_injection?(params.from) -> + {:error, "Invalid characters in from address"} + + contains_header_injection?(params.to) -> + {:error, "Invalid characters in to address"} + + contains_header_injection?(params.cc) -> + {:error, "Invalid characters in cc address"} + + contains_header_injection?(params.bcc) -> + {:error, "Invalid characters in bcc address"} + + contains_header_injection?(params.subject) -> + {:error, "Invalid characters in subject"} + + contains_header_injection?(params.reply_to) -> + {:error, "Invalid characters in reply-to address"} + + true -> + :ok + end + end + + defp contains_header_injection?(nil) do + false + end + + defp contains_header_injection?("") do + false + end + + defp contains_header_injection?(field) when is_binary(field) do + dangerous_patterns = [ + ~r/[\r\n]/, + ~r/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, + ~r/\x00/, + ~r/\%0A|\%0D|\%00/i, + ~r/\\r|\\n|\\0/ + ] + + Enum.any?(dangerous_patterns, fn pattern -> String.match?(field, pattern) end) + end + + defp contains_header_injection?(_) do + false + end + + @doc "Checks for multiple From headers in raw email data.\nReturns {:ok, :valid} or {:error, reason}.\n" + def check_multiple_from_headers(nil) do + {:ok, :valid} + end + + def check_multiple_from_headers("") do + {:ok, :valid} + end + + def check_multiple_from_headers(raw_email) when is_binary(raw_email) do + from_count = + raw_email + |> String.split(~r/\r?\n/) + |> Enum.count(fn line -> String.match?(line, ~r/^From:/i) end) + + if from_count > 1 do + Logger.warning("Multiple From headers detected: #{from_count} found") + {:error, "Multiple From headers found"} + else + {:ok, :valid} + end + end + + def check_multiple_from_headers(_) do + {:ok, :valid} + end + + @doc "Checks if an external sender is trying to spoof a local domain.\n`from_address` is the claimed From address.\n`authenticated_user` is the authenticated user (nil if not authenticated).\n\nReturns {:ok, :valid} or {:error, reason}.\n" + def check_local_domain_spoofing(from_address, authenticated_user) do + from_domain = extract_domain(from_address) + + cond do + is_nil(from_domain) -> + {:ok, :valid} + + not Enum.member?(local_domains(), String.downcase(from_domain)) -> + {:ok, :valid} + + not is_nil(authenticated_user) -> + {:ok, :valid} + + true -> + Logger.warning("Local domain spoofing attempt: #{from_address} (not authenticated)") + {:error, "Sender domain is a local domain"} + end + end + + @doc "Validates that the recipient domain exists and has MX records.\nReturns {:ok, :valid} or {:error, reason}.\n" + def validate_recipient_domain(to_address) when is_binary(to_address) do + domain = extract_domain(to_address) + + cond do + is_nil(domain) -> + {:error, "Invalid recipient address"} + + String.length(domain) > 253 -> + {:error, "Domain name too long"} + + true -> + case check_domain_dns(domain) do + {:ok, :exists} -> {:ok, :valid} + {:error, reason} -> {:error, reason} + end + end + end + + def validate_recipient_domain(_) do + {:error, "Invalid recipient address"} + end + + @doc "Checks if an email appears to be a backscatter/bounce attack.\nBounce attacks send fake bounces to victims using forged return paths.\n\nReturns {:ok, :valid} or {:error, reason}.\n" + def check_bounce_attack(params) do + from = params[:from] || params["from"] || "" + subject = params[:subject] || params["subject"] || "" + to = params[:to] || params["to"] || "" + + is_bounce = + String.match?(String.downcase(from), ~r/^(mailer-daemon|postmaster|mail-daemon)@/) or + String.match?( + String.downcase(subject), + ~r/^(undelivered|returned|failure|rejected|bounce)/i + ) or + String.contains?(String.downcase(subject), [ + "delivery status", + "mail delivery failed", + "undeliverable" + ]) + + if is_bounce do + to_domain = extract_domain(to) + + if to_domain && Enum.member?(local_domains(), String.downcase(to_domain)) do + to_local = extract_local_part(to) + + if to_local && user_exists?(to_local, to_domain) do + {:ok, :valid} + else + Logger.warning("Suspected bounce attack: bounce to non-existent user #{to}") + {:error, "Suspected bounce attack"} + end + else + {:ok, :valid} + end + else + {:ok, :valid} + end + end + + defp extract_domain(nil) do + nil + end + + defp extract_domain("") do + nil + end + + defp extract_domain(email) when is_binary(email) do + clean_email = + case Regex.run(~r/<([^>]+)>/, email) do + [_, addr] -> addr + _ -> email + end + + case String.split(clean_email, "@") do + [_, domain] -> String.trim(domain) + _ -> nil + end + end + + defp extract_domain(_) do + nil + end + + defp extract_local_part("") do + nil + end + + defp extract_local_part(email) when is_binary(email) do + clean_email = + case Regex.run(~r/<([^>]+)>/, email) do + [_, addr] -> addr + _ -> email + end + + case String.split(clean_email, "@") do + [local, _] -> String.trim(local) |> String.downcase() + _ -> nil + end + end + + defp extract_local_part(_) do + nil + end + + defp check_domain_dns(domain) do + case :inet_res.lookup(String.to_charlist(domain), :in, :mx, timeout: 5000) do + [] -> + case :inet_res.lookup(String.to_charlist(domain), :in, :a, timeout: 5000) do + [] -> + case :inet_res.lookup(String.to_charlist(domain), :in, :aaaa, timeout: 5000) do + [] -> + Logger.debug("DNS check failed for domain: #{domain}") + {:error, "Domain may not exist or DNS check failed"} + + _ -> + {:ok, :exists} + end + + _ -> + {:ok, :exists} + end + + _ -> + {:ok, :exists} + end + rescue + e -> + Logger.warning("DNS lookup error for #{domain}: #{inspect(e)}") + {:ok, :exists} + end + + defp user_exists?(local_part, domain) do + email = "#{local_part}@#{domain}" + + case Elektrine.Email.get_mailbox_by_email(email) do + nil -> not is_nil(Elektrine.Email.Aliases.get_alias_by_email(email)) + _ -> true + end + rescue + _ -> true + end + + defp local_domains do + Elektrine.Domains.receiving_email_domains() + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/imap_subscription.ex b/apps/elektrine_email/lib/elektrine/email/imap_subscription.ex new file mode 100644 index 0000000..fb5631d --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/imap_subscription.ex @@ -0,0 +1,24 @@ +defmodule Elektrine.Email.ImapSubscription do + @moduledoc """ + Persists IMAP folder subscriptions per user for LSUB/SUBSCRIBE/UNSUBSCRIBE. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "imap_subscriptions" do + field :folder_name, :string + + belongs_to :user, Elektrine.Accounts.User + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(subscription, attrs) do + subscription + |> cast(attrs, [:user_id, :folder_name]) + |> validate_required([:user_id, :folder_name]) + |> validate_length(:folder_name, min: 1, max: 255) + |> unique_constraint([:user_id, :folder_name]) + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/imap_subscriptions.ex b/apps/elektrine_email/lib/elektrine/email/imap_subscriptions.ex new file mode 100644 index 0000000..9a8be9d --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/imap_subscriptions.ex @@ -0,0 +1,125 @@ +defmodule Elektrine.Email.ImapSubscriptions do + @moduledoc """ + Context helpers for IMAP folder subscription management. + """ + import Ecto.Query + + alias Elektrine.Email.ImapSubscription + alias Elektrine.Repo + + def has_records?(user_id) when is_integer(user_id) do + Repo.exists?(from s in ImapSubscription, where: s.user_id == ^user_id) + end + + def has_records?(_), do: false + + def list_folder_names(user_id) when is_integer(user_id) do + from(s in ImapSubscription, + where: s.user_id == ^user_id, + select: s.folder_name + ) + |> Repo.all() + end + + def list_folder_names(_), do: [] + + def subscribed_folder_set(user_id, default_folders) when is_integer(user_id) do + subscribed = list_folder_names(user_id) + + folders = + if subscribed == [] do + default_folders + else + subscribed + end + + MapSet.new(folders) + end + + def ensure_seeded(user_id, folder_names) when is_integer(user_id) and is_list(folder_names) do + if has_records?(user_id) do + :ok + else + now = DateTime.utc_now() |> DateTime.truncate(:second) + + rows = + folder_names + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + |> Enum.map(fn folder_name -> + %{ + user_id: user_id, + folder_name: folder_name, + inserted_at: now, + updated_at: now + } + end) + + if rows == [] do + :ok + else + Repo.insert_all(ImapSubscription, rows, + on_conflict: :nothing, + conflict_target: [:user_id, :folder_name] + ) + + :ok + end + end + end + + def ensure_seeded(_user_id, _folder_names), do: :ok + + def subscribe_folder(user_id, folder_name) + when is_integer(user_id) and is_binary(folder_name) do + %ImapSubscription{} + |> ImapSubscription.changeset(%{ + user_id: user_id, + folder_name: String.trim(folder_name) + }) + |> Repo.insert(on_conflict: :nothing, conflict_target: [:user_id, :folder_name]) + end + + def subscribe_folder(_user_id, _folder_name), do: {:error, :invalid_params} + + def unsubscribe_folder(user_id, folder_name) + when is_integer(user_id) and is_binary(folder_name) do + from(s in ImapSubscription, + where: s.user_id == ^user_id and s.folder_name == ^String.trim(folder_name) + ) + |> Repo.delete_all() + + :ok + end + + def unsubscribe_folder(_user_id, _folder_name), do: {:error, :invalid_params} + + def remove_folder_subscription(user_id, folder_name) do + unsubscribe_folder(user_id, folder_name) + end + + def rename_folder_subscription(user_id, old_folder_name, new_folder_name) + when is_integer(user_id) and is_binary(old_folder_name) and is_binary(new_folder_name) do + old_folder_name = String.trim(old_folder_name) + new_folder_name = String.trim(new_folder_name) + + if old_folder_name == "" or new_folder_name == "" do + :ok + else + old_query = + from(s in ImapSubscription, + where: s.user_id == ^user_id and s.folder_name == ^old_folder_name + ) + + if Repo.exists?(old_query) do + Repo.delete_all(old_query) + subscribe_folder(user_id, new_folder_name) + end + + :ok + end + end + + def rename_folder_subscription(_user_id, _old_folder_name, _new_folder_name), do: :ok +end diff --git a/apps/elektrine_email/lib/elektrine/email/inbound_authentication.ex b/apps/elektrine_email/lib/elektrine/email/inbound_authentication.ex new file mode 100644 index 0000000..223dbdb --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/inbound_authentication.ex @@ -0,0 +1,81 @@ +defmodule Elektrine.Email.InboundAuthentication do + @moduledoc """ + Normalizes inbound SPF/DKIM/DMARC authentication results. + + Haraka and future inbound providers can pass their auth result payloads through + this module so filtering, quarantine, and UI code consume one shape. + """ + + @result_atoms %{ + "pass" => :pass, + "fail" => :fail, + "softfail" => :softfail, + "neutral" => :neutral, + "none" => :none, + "temperror" => :temperror, + "permerror" => :permerror + } + + def normalize(results) when is_map(results) do + %{ + spf: normalize_result(results[:spf] || results["spf"]), + dkim: normalize_result(results[:dkim] || results["dkim"]), + dmarc: normalize_result(results[:dmarc] || results["dmarc"]), + arc: normalize_result(results[:arc] || results["arc"]), + aligned?: normalize_aligned(results) + } + end + + def normalize(_), do: normalize(%{}) + + def authenticated?(results) do + normalized = normalize(results) + + cond do + normalized.dmarc == :pass -> true + normalized.dmarc == :fail -> false + normalized.arc == :pass -> true + normalized.aligned? -> normalized.spf == :pass or normalized.dkim == :pass + true -> false + end + end + + def policy_decision(results) do + normalized = normalize(results) + policy = Application.get_env(:elektrine, :inbound_email_auth_policy, :monitor) + + cond do + authenticated?(normalized) -> + %{action: :accept, authentication: normalized} + + policy in [:quarantine, "quarantine"] -> + %{action: :quarantine, authentication: normalized} + + policy in [:reject, "reject"] -> + %{action: :reject, authentication: normalized} + + true -> + %{action: :accept, authentication: normalized} + end + end + + defp normalize_result(result) when is_atom(result), do: normalize_result(Atom.to_string(result)) + + defp normalize_result(result) when is_binary(result) do + value = result |> String.downcase() |> String.trim() + Map.get(@result_atoms, value, :none) + end + + defp normalize_result(_), do: :none + + # `normalize/1` emits `aligned?` but callers pass `aligned`, so reading both + # keeps normalization idempotent. Without this, normalizing twice silently + # drops alignment and an aligned SPF/DKIM pass reads as unauthenticated — + # which `policy_decision/1` did, since it normalizes before calling + # `authenticated?/1`. + defp normalize_aligned(results) do + truthy?(results[:aligned] || results["aligned"] || results[:aligned?] || results["aligned?"]) + end + + defp truthy?(value), do: value in [true, "true", "pass", "aligned", 1] +end diff --git a/apps/elektrine_email/lib/elektrine/email/inbound_routing.ex b/apps/elektrine_email/lib/elektrine/email/inbound_routing.ex new file mode 100644 index 0000000..15f5cb4 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/inbound_routing.ex @@ -0,0 +1,378 @@ +defmodule Elektrine.Email.InboundRouting do + @moduledoc """ + Inbound recipient lookup and mailbox routing validation for Haraka webhook emails. + """ + + import Ecto.Query, warn: false + + alias Elektrine.Accounts.User + alias Elektrine.Email + alias Elektrine.Email.Mailbox + alias Elektrine.Email.Message + alias Elektrine.Repo + + @doc """ + Resolves an inbound recipient to either: + - `{:ok, mailbox}` for local delivery + - `{:forward_external, target_email, alias_email}` for external alias/mailbox forwarding + - `{:error, reason}` when no valid local recipient can be found + """ + def resolve_recipient_mailbox(to, rcpt_to) do + # Prefer envelope recipient for mailing-list delivery. + clean_email = + extract_local_email(rcpt_to, to) || extract_clean_email(rcpt_to) || extract_clean_email(to) + + if clean_email do + normalized_email = Email.normalize_plus_address(clean_email) + + case Email.get_alias_by_email(clean_email) do + %Email.Alias{} = alias_record -> + route_alias(alias_record, clean_email) + + nil -> + route_mailbox(normalized_email, clean_email) + end + else + {:error, :invalid_email} + end + end + + @doc """ + Validates that the inbound `to` / `rcpt_to` address set is allowed to route + into the provided mailbox. + """ + def validate_mailbox_route(to, rcpt_to, mailbox) do + to_clean = extract_clean_email(to) + rcpt_to_clean = extract_clean_email(rcpt_to) + + to_normalized = to_clean && Email.normalize_plus_address(to_clean) + rcpt_to_normalized = rcpt_to_clean && Email.normalize_plus_address(rcpt_to_clean) + matches_to = to_normalized && Mailbox.owns_email?(mailbox, to_normalized) + matches_rcpt_to = rcpt_to_normalized && Mailbox.owns_email?(mailbox, rcpt_to_normalized) + + matches_alias = + check_alias_ownership(to_clean, mailbox) || check_alias_ownership(rcpt_to_clean, mailbox) + + matches_receiving_variant = + receiving_domain_owns_email?(mailbox, to_normalized) || + receiving_domain_owns_email?(mailbox, rcpt_to_normalized) + + cond do + matches_to -> + :ok + + matches_rcpt_to -> + :ok + + matches_receiving_variant -> + :ok + + matches_alias -> + :ok + + true -> + {:error, + "Email address mismatch: TO=#{to_clean}, RCPT_TO=#{rcpt_to_clean}, Mailbox=#{mailbox.email}"} + end + end + + @doc """ + Returns true when an inbound envelope is actually an outbound/local submission + (local sender -> external recipient) and should be ignored on inbound path. + """ + def outbound_email?(from, to) do + from_clean = extract_clean_email(from) || "" + to_clean = extract_clean_email(to) || "" + + from_is_local = Enum.any?(sendable_domains(), &String.contains?(from_clean, "@#{&1}")) + to_is_local = Enum.any?(sendable_domains(), &String.contains?(to_clean, "@#{&1}")) + + from_is_local && !to_is_local + end + + @doc """ + Returns true when this inbound message appears to be a recent sent-message loopback. + """ + def loopback_email?(from, to, subject) do + from_clean = extract_clean_email(from) || "" + to_clean = extract_clean_email(to) || "" + + sender_mailbox = + resolve_local_mailbox(from_clean) + + recipient_mailbox = + resolve_local_mailbox(to_clean) + + if sender_mailbox && recipient_mailbox do + ten_minutes_ago = DateTime.utc_now() |> DateTime.add(-600, :second) + + recent_sent = + Message + |> where([m], m.mailbox_id == ^sender_mailbox.id) + |> where([m], m.status == "sent") + |> where([m], m.to == ^to or m.to == ^to_clean or ilike(m.to, ^"%#{to_clean}%")) + |> where([m], m.subject == ^subject) + |> where([m], m.inserted_at > ^ten_minutes_ago) + |> limit(1) + |> Repo.one() + + not is_nil(recent_sent) + else + false + end + end + + @doc """ + Extracts a clean email address from loose header-like input. + """ + def extract_clean_email(nil), do: nil + def extract_clean_email(""), do: nil + + def extract_clean_email(email) when is_binary(email) do + email = String.trim(email) + + result = + cond do + Regex.match?(~r/<([^@>]+@[^>]+)>/, email) -> + case Regex.run(~r/<([^@>]+@[^>]+)>/, email) do + [_, clean] -> String.trim(clean) + _ -> nil + end + + Regex.match?(~r/.+<([^@>]+@[^>]+)>/, email) -> + case Regex.run(~r/.+<([^@>]+@[^>]+)>/, email) do + [_, clean] -> String.trim(clean) + _ -> nil + end + + Regex.match?(~r/^[^\s<>]+@[^\s<>]+$/, email) -> + String.trim(email) + + Regex.match?(~r/([^\s<>,"']+@[^\s<>,"']+)/, email) -> + case Regex.run(~r/([^\s<>,"']+@[^\s<>,"']+)/, email) do + [_, clean] -> String.trim(clean) + _ -> nil + end + + String.contains?(email, "@") -> + case Regex.run(~r/([^@\s]+@[^@\s]+)/, email) do + [_, clean] -> String.trim(clean) + _ -> nil + end + + true -> + nil + end + + case result do + nil -> + nil + + clean when is_binary(clean) -> + if String.match?(clean, ~r/^[^@\s]+@[^@\s]+$/) do + String.downcase(clean) + else + nil + end + end + end + + defp extract_local_email(primary, fallback) do + primary_clean = extract_clean_email(primary) + + if primary_clean && local_domain?(primary_clean) do + primary_clean + else + fallback_clean = extract_clean_email(fallback) + + if fallback_clean && local_domain?(fallback_clean) do + fallback_clean + else + nil + end + end + end + + defp local_domain?(email) do + case String.split(email, "@") do + [_local_part, domain] -> String.downcase(domain) in supported_domains() + _ -> false + end + end + + defp supported_domains do + Elektrine.Domains.receiving_email_domains() + end + + defp sendable_domains do + Elektrine.Domains.supported_email_domains() + end + + defp find_existing_mailbox(to, rcpt_to) do + clean_email = extract_clean_email(to) || extract_clean_email(rcpt_to) + + if clean_email do + check_regular_mailboxes(clean_email) + else + nil + end + end + + defp route_alias(%Email.Alias{} = alias_record, clean_email) do + case Email.resolve_alias(clean_email) do + target_email when is_binary(target_email) -> + Email.record_alias_delivery(alias_record, true) + {:forward_external, target_email, clean_email} + + :no_forward -> + with {:ok, mailbox} <- find_main_mailbox_for_alias(clean_email) do + Email.record_alias_delivery(alias_record, false) + {:ok, mailbox} + end + + nil -> + {:error, :no_mailbox} + end + end + + defp route_mailbox(normalized_email, clean_email) do + case find_existing_mailbox(normalized_email, normalized_email) do + {:ok, mailbox} -> + case Email.get_mailbox_forward_target(mailbox) do + target_email when is_binary(target_email) -> + {:forward_external, target_email, clean_email} + + nil -> + {:ok, mailbox} + end + + nil -> + {:error, :no_mailbox} + end + end + + defp check_regular_mailboxes(clean_email) do + regular_mailbox = + Mailbox |> where(email: ^clean_email) |> limit(1) |> Repo.one() || + Mailbox + |> where([m], fragment("lower(?)", m.email) == ^String.downcase(clean_email)) + |> limit(1) + |> Repo.one() || + find_cross_domain_mailbox(clean_email) + + if regular_mailbox do + {:ok, regular_mailbox} + else + nil + end + end + + defp find_cross_domain_mailbox(clean_email) do + find_receiving_domain_mailbox(clean_email) || + case Email.get_mailbox_by_email(clean_email) do + %Mailbox{} = mailbox -> mailbox + _ -> nil + end + end + + defp find_receiving_domain_mailbox(email_address) when is_binary(email_address) do + case String.split(String.downcase(String.trim(email_address)), "@", parts: 2) do + [username, domain] -> + base_username = username |> String.split("+") |> List.first() |> String.downcase() + + if Elektrine.Domains.receiving_email_domain?(domain) do + Email.get_mailbox_by_username(base_username) + else + nil + end + + _ -> + nil + end + end + + defp receiving_domain_owns_email?(%Mailbox{} = mailbox, email_address) + when is_binary(email_address) do + case String.split(String.downcase(String.trim(email_address)), "@", parts: 2) do + [local_part, domain] -> + Elektrine.Domains.receiving_email_domain?(domain) and + local_part in mailbox_local_parts(mailbox) + + _ -> + false + end + end + + defp receiving_domain_owns_email?(_mailbox, _email_address), do: false + + defp mailbox_local_parts(%Mailbox{username: username, email: email}) do + [username, email_local_part(email)] + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.downcase/1) + |> Enum.uniq() + end + + defp email_local_part(email) when is_binary(email) do + email |> String.split("@", parts: 2) |> List.first() + end + + defp email_local_part(_), do: nil + + defp resolve_local_mailbox(email_address) when is_binary(email_address) do + case Email.get_mailbox_by_email(email_address) do + %Mailbox{} = mailbox -> + mailbox + + _ -> + case find_main_mailbox_for_alias(email_address) do + {:ok, %Mailbox{} = mailbox} -> mailbox + _ -> nil + end + end + end + + defp resolve_local_mailbox(_), do: nil + + defp find_main_mailbox_for_alias(alias_email) do + case Email.get_alias_by_email(alias_email) do + %Email.Alias{user_id: user_id} when is_integer(user_id) -> + case Email.get_user_mailbox(user_id) do + %Email.Mailbox{} = mailbox -> {:ok, mailbox} + nil -> {:error, :no_main_mailbox} + end + + nil -> + {:error, :alias_not_found} + end + end + + defp check_alias_ownership(nil, _mailbox), do: false + defp check_alias_ownership(_email, nil), do: false + + defp check_alias_ownership(email_address, mailbox) do + case Email.get_alias_by_email(email_address) do + %Email.Alias{user_id: user_id, enabled: enabled} -> + if mailbox.user_id == user_id do + if enabled do + true + else + alias_local_part = + email_address |> String.split("@") |> List.first() |> String.downcase() + + user = Repo.get(User, user_id) + user_username = if user, do: String.downcase(user.username), else: nil + + mailbox_local_part = + mailbox.email |> String.split("@") |> List.first() |> String.downcase() + + alias_local_part == user_username or alias_local_part == mailbox_local_part + end + else + false + end + + _ -> + false + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/internal_delivery.ex b/apps/elektrine_email/lib/elektrine/email/internal_delivery.ex new file mode 100644 index 0000000..07ebc6d --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/internal_delivery.ex @@ -0,0 +1,254 @@ +defmodule Elektrine.Email.InternalDelivery do + @moduledoc """ + Durable internal email delivery record. + + Each local recipient gets one row so local mailbox delivery has the same + inspectable and retryable boundary as external delivery. + """ + + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + + alias Elektrine.Email.Message + alias Elektrine.Repo + + @statuses ~w(pending delivering delivered failed) + @recipient_types ~w(to cc bcc) + + schema "internal_email_deliveries" do + field :user_id, :integer + field :recipient, :string + field :recipient_type, :string, default: "to" + field :params, :map, default: %{} + field :status, :string, default: "pending" + field :attempts, :integer, default: 0 + field :error, :string + field :last_attempted_at, :utc_datetime + field :delivered_at, :utc_datetime + + belongs_to :mailbox, Elektrine.Email.Mailbox + belongs_to :recipient_mailbox, Elektrine.Email.Mailbox + belongs_to :sent_message, Message + belongs_to :delivered_message, Message + + timestamps(type: :utc_datetime) + end + + def changeset(delivery, attrs) do + delivery + |> cast(attrs, [ + :user_id, + :mailbox_id, + :sent_message_id, + :recipient_mailbox_id, + :delivered_message_id, + :recipient, + :recipient_type, + :params, + :status, + :attempts, + :error, + :last_attempted_at, + :delivered_at + ]) + |> normalize_recipient() + |> validate_required([ + :user_id, + :mailbox_id, + :sent_message_id, + :recipient_mailbox_id, + :recipient, + :recipient_type, + :params + ]) + |> validate_inclusion(:status, @statuses) + |> validate_inclusion(:recipient_type, @recipient_types) + |> unique_constraint([:sent_message_id, :recipient_type, :recipient], + name: :internal_email_deliveries_recipient_unique + ) + end + + def create_or_get(attrs) when is_map(attrs) do + %__MODULE__{} + |> changeset(attrs) + |> Repo.insert() + |> case do + {:ok, delivery} -> + {:ok, delivery, :created} + + {:error, %Ecto.Changeset{} = changeset} -> + if duplicate_recipient?(changeset) do + case get_by_recipient( + fetch_attr(attrs, :sent_message_id), + fetch_attr(attrs, :recipient_type), + fetch_attr(attrs, :recipient) + ) do + %__MODULE__{} = delivery -> {:ok, delivery, :existing} + nil -> {:error, changeset} + end + else + {:error, changeset} + end + end + end + + def get(id) when is_integer(id), do: Repo.get(__MODULE__, id) + + def get(id) when is_binary(id) do + case Integer.parse(id) do + {parsed, ""} -> get(parsed) + _ -> nil + end + end + + def get(_), do: nil + + def list_for_message(sent_message_id) do + Repo.all( + from d in __MODULE__, + where: d.sent_message_id == ^sent_message_id, + order_by: [asc: d.id] + ) + end + + def get_by_recipient(sent_message_id, recipient_type, recipient) do + Repo.one( + from d in __MODULE__, + where: + d.sent_message_id == ^sent_message_id and d.recipient_type == ^recipient_type and + d.recipient == ^normalize_email(recipient) + ) + end + + def list_attempts(%__MODULE__{} = delivery) do + Repo.all( + from a in Elektrine.Email.InternalDeliveryAttempt, + where: a.delivery_id == ^delivery.id, + order_by: [asc: a.attempt, asc: a.id] + ) + end + + def delivery_summary(sent_message_id) do + sent_message_id + |> list_for_message() + |> Enum.group_by(& &1.status) + |> Map.new(fn {status, deliveries} -> {status, length(deliveries)} end) + end + + def recent_deliveries(opts \\ []) do + limit = Keyword.get(opts, :limit, 50) + status = Keyword.get(opts, :status) + + query = from d in __MODULE__, order_by: [desc: d.updated_at], limit: ^limit + query = if status, do: from(d in query, where: d.status == ^status), else: query + Repo.all(query) + end + + def mark_delivering(%__MODULE__{} = delivery) do + next_attempt = delivery.attempts + 1 + + delivery + |> changeset(%{ + status: "delivering", + attempts: next_attempt, + last_attempted_at: DateTime.utc_now() |> DateTime.truncate(:second), + error: nil + }) + |> Repo.update() + |> tap(fn + {:ok, updated} -> record_attempt(updated, "delivering", %{attempt: next_attempt}) + _ -> :ok + end) + end + + def mark_delivered(%__MODULE__{} = delivery, %Message{} = message) do + delivery + |> changeset(%{ + status: "delivered", + delivered_message_id: message.id, + delivered_at: DateTime.utc_now() |> DateTime.truncate(:second), + error: nil + }) + |> Repo.update() + |> tap(fn + {:ok, updated} -> + record_attempt(updated, "delivered", %{delivered_message_id: message.id}) + + _ -> + :ok + end) + end + + def mark_failed(%__MODULE__{} = delivery, reason) do + delivery + |> changeset(%{status: "failed", error: format_reason(reason)}) + |> Repo.update() + |> tap(fn + {:ok, updated} -> record_attempt(updated, "failed", %{error: format_reason(reason)}) + _ -> :ok + end) + end + + def requeue(%__MODULE__{status: "delivered"}), do: {:error, :already_delivered} + + def requeue(%__MODULE__{} = delivery) do + delivery + |> changeset(%{status: "pending", error: nil}) + |> Repo.update() + |> case do + {:ok, updated} -> + with {:ok, _job} <- Elektrine.Email.InternalDeliveryWorker.enqueue(updated) do + {:ok, updated} + end + + error -> + error + end + end + + defp duplicate_recipient?(%Ecto.Changeset{errors: errors}) do + Enum.any?(errors, fn + {:sent_message_id, {_message, opts}} -> opts[:constraint] == :unique + _ -> false + end) + end + + defp normalize_recipient(changeset) do + put_change(changeset, :recipient, get_field(changeset, :recipient) |> normalize_email()) + end + + defp record_attempt(delivery, status, metadata) do + attrs = %{ + delivery_id: delivery.id, + attempt: delivery.attempts, + status: status, + delivered_message_id: metadata[:delivered_message_id] || delivery.delivered_message_id, + error: metadata[:error] || delivery.error, + metadata: Map.new(metadata, fn {key, value} -> {to_string(key), value} end), + attempted_at: DateTime.utc_now() |> DateTime.truncate(:second) + } + + %Elektrine.Email.InternalDeliveryAttempt{} + |> Elektrine.Email.InternalDeliveryAttempt.changeset(attrs) + |> Repo.insert() + end + + defp fetch_attr(attrs, key), do: Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key)) + + defp normalize_email(nil), do: nil + + defp normalize_email(email) do + email + |> to_string() + |> String.trim() + |> String.downcase() + |> case do + "" -> nil + value -> value + end + end + + defp format_reason(reason) when is_binary(reason), do: reason + defp format_reason(reason), do: inspect(reason) +end diff --git a/apps/elektrine_email/lib/elektrine/email/internal_delivery_attempt.ex b/apps/elektrine_email/lib/elektrine/email/internal_delivery_attempt.ex new file mode 100644 index 0000000..19522a5 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/internal_delivery_attempt.ex @@ -0,0 +1,35 @@ +defmodule Elektrine.Email.InternalDeliveryAttempt do + @moduledoc """ + Immutable attempt history for local mailbox delivery. + """ + + use Ecto.Schema + import Ecto.Changeset + + schema "internal_email_delivery_attempts" do + field :attempt, :integer + field :status, :string + field :error, :string + field :metadata, :map, default: %{} + field :attempted_at, :utc_datetime + + belongs_to :delivery, Elektrine.Email.InternalDelivery + belongs_to :delivered_message, Elektrine.Email.Message + + timestamps(type: :utc_datetime) + end + + def changeset(attempt, attrs) do + attempt + |> cast(attrs, [ + :delivery_id, + :attempt, + :status, + :delivered_message_id, + :error, + :metadata, + :attempted_at + ]) + |> validate_required([:delivery_id, :attempt, :status, :attempted_at]) + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/internal_delivery_worker.ex b/apps/elektrine_email/lib/elektrine/email/internal_delivery_worker.ex new file mode 100644 index 0000000..0638d50 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/internal_delivery_worker.ex @@ -0,0 +1,110 @@ +defmodule Elektrine.Email.InternalDeliveryWorker do + @moduledoc false + + use Oban.Worker, + queue: :email, + max_attempts: 5, + unique: [period: 300, fields: [:args], keys: [:delivery_id]] + + require Logger + + alias Elektrine.Email + alias Elektrine.Email.InternalDelivery + + @valid_param_keys %{ + "to" => :to, + "from" => :from, + "cc" => :cc, + "bcc" => :bcc, + "subject" => :subject, + "text_body" => :text_body, + "html_body" => :html_body, + "reply_to" => :reply_to, + "message_id" => :message_id, + "in_reply_to" => :in_reply_to, + "references" => :references, + "attachments" => :attachments, + "headers" => :headers, + "priority" => :priority, + "content_type" => :content_type, + "charset" => :charset, + "mailbox_id" => :mailbox_id, + "status" => :status, + "read" => :read, + "category" => :category, + "metadata" => :metadata, + "is_newsletter" => :is_newsletter, + "is_receipt" => :is_receipt, + "is_notification" => :is_notification + } + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"delivery_id" => delivery_id}}) do + case load_delivery(delivery_id) do + nil -> + :ok + + %InternalDelivery{status: "delivered"} -> + :ok + + %InternalDelivery{} = delivery -> + case deliver_now(delivery) do + {:ok, _message_or_delivery} -> :ok + {:error, reason} -> {:error, reason} + end + end + end + + def enqueue(%InternalDelivery{} = delivery) do + %{"delivery_id" => delivery.id} + |> new() + |> Elektrine.JobQueue.insert() + end + + def deliver_now(%InternalDelivery{status: "delivered"} = delivery), do: {:ok, delivery} + + def deliver_now(%InternalDelivery{} = delivery) do + with {:ok, delivering} <- InternalDelivery.mark_delivering(delivery), + {:ok, message} <- Email.create_message(atomize_keys(delivering.params)), + {:ok, _delivered} <- InternalDelivery.mark_delivered(delivering, message) do + {:ok, message} + else + {:error, reason} -> + _ = InternalDelivery.mark_failed(delivery, reason) + Logger.error("Internal email delivery #{delivery.id} failed: #{inspect(reason)}") + {:error, reason} + end + end + + defp load_delivery(id) when is_integer(id), do: InternalDelivery.get(id) + + defp load_delivery(id) when is_binary(id) do + case Integer.parse(id) do + {parsed, ""} -> InternalDelivery.get(parsed) + _ -> nil + end + end + + defp load_delivery(_), do: nil + + defp atomize_keys(nil), do: nil + + defp atomize_keys(map) when is_map(map) do + Map.new(map, fn + {key, value} when is_binary(key) -> + case Map.fetch(@valid_param_keys, key) do + {:ok, atom_key} -> {atom_key, atomize_keys(value)} + :error -> {key, atomize_keys(value)} + end + + {key, value} when is_atom(key) -> + {key, atomize_keys(value)} + + {key, value} -> + {key, atomize_keys(value)} + end) + end + + defp atomize_keys(list) when is_list(list), do: Enum.map(list, &atomize_keys/1) + defp atomize_keys(value), do: value +end diff --git a/apps/elektrine_email/lib/elektrine/email/internal_origin.ex b/apps/elektrine_email/lib/elektrine/email/internal_origin.ex new file mode 100644 index 0000000..887d930 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/internal_origin.ex @@ -0,0 +1,103 @@ +defmodule Elektrine.Email.InternalOrigin do + @moduledoc false + + alias Elektrine.Email.InboundRouting + alias Elektrine.EmailConfig + + @max_age_seconds 900 + + def sign_params(params) when is_map(params) do + from = params[:from] || params["from"] || "" + headers = params[:headers] || params["headers"] || %{} + + signed_headers = sign_headers(headers, from) + + cond do + Map.has_key?(params, :headers) -> Map.put(params, :headers, signed_headers) + Map.has_key?(params, "headers") -> Map.put(params, "headers", signed_headers) + signed_headers == %{} -> params + true -> Map.put(params, :headers, signed_headers) + end + end + + def sign_params(params), do: params + + def sign_headers(headers, from, now \\ System.system_time(:second)) do + headers = if is_map(headers), do: headers, else: %{} + + case signing_secret() do + secret when is_binary(secret) and secret != "" -> + ts = Integer.to_string(now) + payload = payload(from, ts) + signature = Base.encode16(:crypto.mac(:hmac, :sha256, secret, payload), case: :lower) + + headers + |> Map.put("X-Elektrine-Origin", "internal") + |> Map.put("X-Elektrine-Origin-Ts", ts) + |> Map.put("X-Elektrine-Origin-Sig", signature) + + _ -> + headers + end + end + + def valid?(headers, from, now \\ System.system_time(:second)) do + with secret when is_binary(secret) <- signing_secret(), + true <- Elektrine.Strings.present?(secret), + headers when is_map(headers) <- headers || %{}, + "internal" <- header_value(headers, ["x-elektrine-origin", "X-Elektrine-Origin"]), + ts when is_binary(ts) <- + header_value(headers, ["x-elektrine-origin-ts", "X-Elektrine-Origin-Ts"]), + true <- Elektrine.Strings.present?(ts), + signature when is_binary(signature) <- + header_value(headers, ["x-elektrine-origin-sig", "X-Elektrine-Origin-Sig"]), + true <- Elektrine.Strings.present?(signature), + {timestamp, ""} <- Integer.parse(ts), + true <- timestamp_fresh?(timestamp, now) do + expected = + Base.encode16(:crypto.mac(:hmac, :sha256, secret, payload(from, ts)), case: :lower) + + secure_compare(signature, expected) + else + _ -> false + end + end + + defp signing_secret do + EmailConfig.internal_signing_secret() + end + + defp header_value(headers, candidates) do + Enum.find_value(candidates, fn key -> + case Map.get(headers, key) do + value when is_binary(value) -> String.trim(value) + _ -> nil + end + end) + end + + defp timestamp_fresh?(timestamp, now) when is_integer(timestamp) and is_integer(now) do + abs(now - timestamp) <= @max_age_seconds + end + + defp payload(from, ts) do + clean_from = + from + |> InboundRouting.extract_clean_email() + |> case do + nil -> "" + email -> String.downcase(email) + end + + "internal|#{ts}|#{clean_from}" + end + + defp secure_compare(left, right) + when is_binary(left) and is_binary(right) and byte_size(left) == byte_size(right) do + Plug.Crypto.secure_compare(left, right) + end + + defp secure_compare(_left, _right) do + false + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/label.ex b/apps/elektrine_email/lib/elektrine/email/label.ex new file mode 100644 index 0000000..16aed06 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/label.ex @@ -0,0 +1,33 @@ +defmodule Elektrine.Email.Label do + @moduledoc """ + Schema for email labels/tags. + Allows users to tag emails with multiple labels for organization. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "email_labels" do + field :name, :string + field :color, :string, default: "#3b82f6" + + belongs_to :user, Elektrine.Accounts.User + many_to_many :messages, Elektrine.Email.Message, join_through: "email_message_labels" + + timestamps() + end + + @valid_colors ~w(#ef4444 #f97316 #f59e0b #eab308 #84cc16 #22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9 #3b82f6 #6366f1 #8a7cc2 #9a8dcf #b79ed6 #c7796b #f43f5e #6b7280) + + @doc """ + Creates a changeset for an email label. + """ + def changeset(label, attrs) do + label + |> cast(attrs, [:name, :color, :user_id]) + |> validate_required([:name, :user_id]) + |> validate_length(:name, min: 1, max: 30) + |> validate_inclusion(:color, @valid_colors, message: "is not a valid color") + |> foreign_key_constraint(:user_id) + |> unique_constraint([:user_id, :name]) + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/labels.ex b/apps/elektrine_email/lib/elektrine/email/labels.ex new file mode 100644 index 0000000..0c1d3b6 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/labels.ex @@ -0,0 +1,190 @@ +defmodule Elektrine.Email.Labels do + @moduledoc """ + Context module for managing email labels/tags. + """ + import Ecto.Query + alias Elektrine.Email.Label + alias Elektrine.Repo + + @max_labels_per_user 50 + + @doc """ + Lists all labels for a user. + """ + def list_labels(user_id) do + Label + |> where(user_id: ^user_id) + |> order_by(asc: :name) + |> Repo.all() + end + + @doc """ + Gets a label by ID for a user. + """ + def get_label(id, user_id) do + Label + |> where(id: ^id, user_id: ^user_id) + |> Repo.one() + end + + @doc """ + Gets a label by name for a user. + """ + def get_label_by_name(name, user_id) do + Label + |> where(user_id: ^user_id, name: ^name) + |> Repo.one() + end + + @doc """ + Creates a label. + """ + def create_label(attrs) do + user_id = Map.get(attrs, :user_id) || Map.get(attrs, "user_id") + + if count_labels(user_id) >= @max_labels_per_user do + {:error, :limit_reached} + else + %Label{} + |> Label.changeset(attrs) + |> Repo.insert() + end + end + + @doc """ + Updates a label. + """ + def update_label(%Label{} = label, attrs) do + label + |> Label.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a label. + """ + def delete_label(%Label{} = label) do + Repo.delete(label) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking label changes. + """ + def change_label(%Label{} = label, attrs \\ %{}) do + Label.changeset(label, attrs) + end + + @doc """ + Counts labels for a user. + """ + def count_labels(user_id) do + Label + |> where(user_id: ^user_id) + |> select(count()) + |> Repo.one() + end + + @doc """ + Adds a label to a message. + """ + def add_label_to_message(message_id, label_id) do + Repo.insert_all( + "email_message_labels", + [%{message_id: message_id, label_id: label_id}], + on_conflict: :nothing + ) + + :ok + end + + @doc """ + Removes a label from a message. + """ + def remove_label_from_message(message_id, label_id) do + from(ml in "email_message_labels", + where: ml.message_id == ^message_id and ml.label_id == ^label_id + ) + |> Repo.delete_all() + + :ok + end + + @doc """ + Gets all labels for a message. + """ + def get_message_labels(message_id, user_id) do + Label + |> join(:inner, [l], ml in "email_message_labels", on: ml.label_id == l.id) + |> where([l, ml], ml.message_id == ^message_id and l.user_id == ^user_id) + |> order_by(asc: :name) + |> Repo.all() + end + + @doc """ + Lists messages with a specific label. + """ + def list_labeled_messages(label_id, user_id, page \\ 1, per_page \\ 20) do + label = get_label(label_id, user_id) + + if label do + offset = (page - 1) * per_page + + message_ids = + from(ml in "email_message_labels", + where: ml.label_id == ^label_id, + select: ml.message_id + ) + |> Repo.all() + + messages = + Elektrine.Email.Message + |> where([m], m.id in ^message_ids) + |> where([m], m.deleted == false) + |> order_by(desc: :inserted_at) + |> limit(^per_page) + |> offset(^offset) + |> Repo.all() + + total = length(message_ids) + + %{ + messages: messages, + total: total, + page: page, + per_page: per_page, + has_next: offset + per_page < total, + has_prev: page > 1, + label: label + } + else + %{ + messages: [], + total: 0, + page: page, + per_page: per_page, + has_next: false, + has_prev: false, + label: nil + } + end + end + + @doc """ + Sets labels for a message (replaces existing labels). + """ + def set_message_labels(message_id, label_ids) do + # Remove all existing labels + from(ml in "email_message_labels", where: ml.message_id == ^message_id) + |> Repo.delete_all() + + # Add new labels + if label_ids && label_ids != [] do + entries = + Enum.map(label_ids, fn label_id -> %{message_id: message_id, label_id: label_id} end) + + Repo.insert_all("email_message_labels", entries, on_conflict: :nothing) + end + + :ok + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/list_types.ex b/apps/elektrine_email/lib/elektrine/email/list_types.ex new file mode 100644 index 0000000..1cd6b25 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/list_types.ex @@ -0,0 +1,206 @@ +defmodule Elektrine.Email.ListTypes do + @moduledoc """ + Defines email list types and their properties. + + Email lists are categorized into: + - Transactional: Critical emails that cannot be unsubscribed from + - Marketing: Promotional and non-essential emails that can be unsubscribed from + - Notifications: System notifications that can be managed via preferences + """ + + @type list_type :: :transactional | :marketing | :notifications + @type list_info :: %{ + id: String.t(), + name: String.t(), + description: String.t(), + type: list_type(), + can_unsubscribe: boolean() + } + + @doc """ + Returns all available email lists with their metadata. + """ + @spec all_lists() :: [list_info()] + def all_lists do + [ + # Transactional emails - cannot be unsubscribed + %{ + id: "elektrine-transactional", + name: "Transactional", + description: "Critical account emails (cannot unsubscribe)", + type: :transactional, + can_unsubscribe: false + }, + %{ + id: "elektrine-security", + name: "Security Alerts", + description: "Security and authentication notifications (cannot unsubscribe)", + type: :transactional, + can_unsubscribe: false + }, + %{ + id: "elektrine-account", + name: "Account Notifications", + description: "Important account updates (cannot unsubscribe)", + type: :transactional, + can_unsubscribe: false + }, + %{ + id: "elektrine-password-reset", + name: "Password Resets", + description: "Password reset emails (cannot unsubscribe)", + type: :transactional, + can_unsubscribe: false + }, + %{ + id: "elektrine-two-factor", + name: "Two-Factor Authentication", + description: "2FA codes and setup (cannot unsubscribe)", + type: :transactional, + can_unsubscribe: false + }, + + # Marketing emails - can be unsubscribed + %{ + id: "elektrine-marketing", + name: "Marketing & Promotions", + description: "Product updates, features, and promotional content", + type: :marketing, + can_unsubscribe: true + }, + %{ + id: "elektrine-newsletter", + name: "Newsletter", + description: "Weekly/monthly newsletter with platform updates", + type: :marketing, + can_unsubscribe: true + }, + %{ + id: "elektrine-announcements", + name: "Announcements", + description: "Platform announcements and news", + type: :marketing, + can_unsubscribe: true + }, + + # Notification emails - can be managed + %{ + id: "elektrine-social", + name: "Social Notifications", + description: "Follows, mentions, and social interactions", + type: :notifications, + can_unsubscribe: true + }, + %{ + id: "elektrine-messages", + name: "Message Notifications", + description: "New message alerts", + type: :notifications, + can_unsubscribe: true + }, + %{ + id: "elektrine-email-notifications", + name: "Email Notifications", + description: "New email alerts for your mailbox", + type: :notifications, + can_unsubscribe: true + }, + + # General/default + %{ + id: "elektrine-general", + name: "General", + description: "General platform communications", + type: :notifications, + can_unsubscribe: true + } + ] + end + + @active_list_ids MapSet.new([ + "elektrine-transactional", + "elektrine-security", + "elektrine-account", + "elektrine-password-reset", + "elektrine-two-factor", + "elektrine-marketing", + "elektrine-newsletter", + "elektrine-announcements", + "elektrine-social", + "elektrine-messages", + "elektrine-email-notifications", + "elektrine-general" + ]) + + @doc """ + Returns email lists that are currently used by platform-generated email flows. + """ + @spec active_lists() :: [list_info()] + def active_lists do + all_lists() + |> Enum.filter(&MapSet.member?(@active_list_ids, &1.id)) + end + + @doc """ + Returns active lists grouped by type. + """ + @spec active_lists_by_type() :: %{list_type() => [list_info()]} + def active_lists_by_type do + active_lists() + |> Enum.group_by(& &1.type) + end + + @doc """ + Returns lists that can be unsubscribed from. + """ + @spec subscribable_lists() :: [list_info()] + def subscribable_lists do + active_lists() + |> Enum.filter(& &1.can_unsubscribe) + end + + @doc """ + Returns transactional email lists. + """ + @spec transactional_lists() :: [list_info()] + def transactional_lists do + all_lists() + |> Enum.filter(&(&1.type == :transactional)) + end + + @doc """ + Checks if a list ID is transactional. + """ + @spec transactional?(String.t()) :: boolean() + def transactional?(list_id) do + list_id in Enum.map(transactional_lists(), & &1.id) + end + + @doc """ + Gets list information by ID. + """ + @spec get_list(String.t()) :: list_info() | nil + def get_list(list_id) do + Enum.find(all_lists(), &(&1.id == list_id)) + end + + @doc """ + Gets the display name for a list ID. + """ + @spec get_name(String.t()) :: String.t() + def get_name(list_id) do + case get_list(list_id) do + %{name: name} -> name + nil -> list_id + end + end + + @doc """ + Groups lists by type. + """ + @spec lists_by_type() :: %{list_type() => [list_info()]} + def lists_by_type do + all_lists() + |> Enum.group_by(& &1.type) + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/mailbox.ex b/apps/elektrine_email/lib/elektrine/email/mailbox.ex new file mode 100644 index 0000000..54bbd25 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/mailbox.ex @@ -0,0 +1,499 @@ +defmodule Elektrine.Email.Mailbox do + @moduledoc """ + Schema for email mailboxes supporting configured local domains. + Handles domain-agnostic username-based email addresses with forwarding capabilities. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "email_mailboxes" do + # Older field - will be phased out + field :email, :string + # Domain-agnostic username + field :username, :string + field :forward_to, :string + field :forward_enabled, :boolean, default: false + field :auto_reply_enabled, :boolean, default: true + field :spam_filter_enabled, :boolean, default: true + field :digest_filter_enabled, :boolean, default: true + field :ledger_filter_enabled, :boolean, default: true + field :private_storage_enabled, :boolean, default: false + field :private_storage_public_key, :string + field :private_storage_wrapped_private_key, :map + field :private_storage_verifier, :map + + belongs_to :user, Elektrine.Accounts.User + has_many :messages, Elektrine.Email.Message + + timestamps(type: :utc_datetime) + end + + @doc """ + Gets all primary email addresses for this mailbox. + Returns all configured local-domain variants for the mailbox username. + """ + def get_all_emails(%__MODULE__{username: username, user_id: user_id}) + when is_binary(username) do + mailbox_addresses(username, user_id, nil) + end + + def get_all_emails(%__MODULE__{email: email}) when is_binary(email) do + # Fallback for older mailboxes without username field + case String.split(email, "@", parts: 2) do + [username, _domain] -> + Elektrine.Domains.local_addresses_for_username(username) + + _ -> + [email] + end + end + + def get_all_emails(_), do: [] + + @doc """ + Checks if an email address belongs to this mailbox (either domain). + """ + def owns_email?(%__MODULE__{} = mailbox, email_address) when is_binary(email_address) do + email_address = String.downcase(String.trim(email_address)) + all_emails = get_all_emails(mailbox) |> Enum.map(&String.downcase/1) + email_address in all_emails + end + + @doc """ + Creates a changeset for a new mailbox. + """ + def changeset(mailbox, attrs) do + mailbox + |> cast(attrs, [ + :email, + :username, + :user_id, + :forward_to, + :forward_enabled, + :auto_reply_enabled, + :spam_filter_enabled, + :digest_filter_enabled, + :ledger_filter_enabled + ]) + |> validate_length(:email, max: 160) + |> validate_length(:username, min: 1, max: 30) + |> validate_format(:username, ~r/^[a-zA-Z0-9]+$/, message: "only letters and numbers allowed") + |> validate_not_reserved_email() + |> unique_constraint(:email) + |> unique_constraint(:username) + |> foreign_key_constraint(:user_id) + |> validate_forwarding() + end + + @doc """ + Creates a changeset for updating mailbox forwarding settings. + """ + def forwarding_changeset(mailbox, attrs) do + mailbox + |> cast(attrs, [:forward_to, :forward_enabled]) + |> validate_forwarding() + end + + @doc """ + Creates a changeset for optional mailbox filters. + """ + def category_filter_changeset(mailbox, attrs) do + mailbox + |> cast(attrs, [ + :auto_reply_enabled, + :spam_filter_enabled, + :digest_filter_enabled, + :ledger_filter_enabled + ]) + end + + def auto_reply_enabled?(%__MODULE__{auto_reply_enabled: enabled}), do: enabled != false + def auto_reply_enabled?(_mailbox), do: true + + def spam_filter_enabled?(%__MODULE__{spam_filter_enabled: enabled}), do: enabled != false + def spam_filter_enabled?(_mailbox), do: true + + def digest_filter_enabled?(%__MODULE__{digest_filter_enabled: enabled}), do: enabled != false + def digest_filter_enabled?(_mailbox), do: true + + def ledger_filter_enabled?(%__MODULE__{ledger_filter_enabled: enabled}), do: enabled != false + def ledger_filter_enabled?(_mailbox), do: true + + def enabled_category_filter_categories(%__MODULE__{} = mailbox) do + [] + |> maybe_include_category(digest_filter_enabled?(mailbox), "feed") + |> maybe_include_category(ledger_filter_enabled?(mailbox), "ledger") + |> Enum.reverse() + end + + def enabled_category_filter_categories(_mailbox), do: ["feed", "ledger"] + + defp maybe_include_category(categories, true, category), do: [category | categories] + defp maybe_include_category(categories, false, _category), do: categories + + @doc """ + Creates a changeset for browser-managed private mailbox storage settings. + """ + def private_storage_changeset(mailbox, attrs) do + mailbox + |> cast(attrs, [ + :private_storage_enabled, + :private_storage_public_key, + :private_storage_wrapped_private_key, + :private_storage_verifier + ]) + |> validate_private_storage() + end + + @doc """ + Creates a domain-agnostic mailbox for a user. + Uses the configured primary email domain as storage but keeps username for domain-agnostic lookup. + All configured local-domain addresses will resolve to this same mailbox. + """ + def create_for_user(user, _domain \\ nil) do + # Always use configured primary domain for storage, but keep username for domain-agnostic access. + primary_domain = Elektrine.Domains.primary_email_domain() + email = "#{user.username}@#{primary_domain}" + + %Elektrine.Email.Mailbox{} + |> changeset(%{email: email, username: user.username, user_id: user.id}) + end + + @doc """ + Returns true when mailbox private storage has been configured. + """ + def private_storage_configured?(%__MODULE__{} = mailbox) do + Elektrine.Strings.present?(mailbox.private_storage_public_key) and + is_map(mailbox.private_storage_wrapped_private_key) and + is_map(mailbox.private_storage_verifier) + end + + def private_storage_configured?(_mailbox), do: false + + @doc """ + Returns the configured unlock mode for browser-managed private mailbox storage. + """ + def private_storage_unlock_mode(%__MODULE__{} = mailbox) do + unlock_mode = + payload_value( + mailbox.private_storage_wrapped_private_key || %{}, + "unlock_mode", + :unlock_mode + ) || + payload_value(mailbox.private_storage_verifier || %{}, "unlock_mode", :unlock_mode) + + if unlock_mode in ["account_password", "separate_passphrase", "master"] do + unlock_mode + else + "separate_passphrase" + end + end + + def private_storage_unlock_mode(_mailbox), do: "separate_passphrase" + + def private_storage_account_password?(%__MODULE__{} = mailbox) do + private_storage_configured?(mailbox) and + private_storage_unlock_mode(mailbox) == "account_password" + end + + def private_storage_account_password?(_mailbox), do: false + + # Private helper functions + + defp validate_private_storage(changeset) do + enabled? = get_field(changeset, :private_storage_enabled) + public_key = get_field(changeset, :private_storage_public_key) + wrapped_private_key = get_field(changeset, :private_storage_wrapped_private_key) + verifier = get_field(changeset, :private_storage_verifier) + + cond do + enabled? && !valid_public_key?(public_key) -> + add_error(changeset, :private_storage_public_key, "must be a valid RSA public key") + + enabled? && !valid_wrapped_payload?(wrapped_private_key, "private_key") -> + add_error( + changeset, + :private_storage_wrapped_private_key, + "must be a valid wrapped private key payload" + ) + + enabled? && !valid_wrapped_payload?(verifier, "verifier") -> + add_error(changeset, :private_storage_verifier, "must be a valid verifier payload") + + true -> + changeset + end + end + + defp valid_public_key?(value) when is_binary(value) do + trimmed = String.trim(value) + + if String.starts_with?(trimmed, "-----BEGIN PUBLIC KEY-----") do + case :public_key.pem_decode(trimmed) do + [entry | _] -> + _decoded = :public_key.pem_entry_decode(entry) + true + + _ -> + false + end + else + false + end + rescue + _ -> false + end + + defp valid_public_key?(_value), do: false + + defp valid_wrapped_payload?(payload, expected_kind) when is_map(payload) do + version = payload_value(payload, "version", :version) + algorithm = payload_value(payload, "algorithm", :algorithm) + kdf = payload_value(payload, "kdf", :kdf) + unlock_mode = payload_value(payload, "unlock_mode", :unlock_mode) + iv = payload_value(payload, "iv", :iv) + ciphertext = payload_value(payload, "ciphertext", :ciphertext) + + valid_version?(version) and + valid_wrapped_aad_context?(payload, version, unlock_mode, expected_kind) and + algorithm == "AES-GCM" and + valid_base64_bytes?(iv, exact_size: 12) and valid_base64_bytes?(ciphertext, min_size: 1) and + valid_wrap_kdf?(payload, kdf, unlock_mode) + end + + defp valid_wrapped_payload?(_payload, _expected_kind), do: false + + # Passphrase modes wrap with scrypt; the master mode wraps with the master + # key's email subkey, so it carries no scrypt parameters. + defp valid_wrap_kdf?(payload, "scrypt", unlock_mode) do + n = payload_value(payload, "n", :n) + r = payload_value(payload, "r", :r) + p = payload_value(payload, "p", :p) + salt = payload_value(payload, "salt", :salt) + + (is_nil(unlock_mode) or unlock_mode in ["account_password", "separate_passphrase"]) and + is_integer(n) and n >= 16_384 and is_integer(r) and r >= 8 and is_integer(p) and p >= 1 and + valid_base64_bytes?(salt, min_size: 16) + end + + defp valid_wrap_kdf?(_payload, "master", unlock_mode), do: unlock_mode == "master" + defp valid_wrap_kdf?(_payload, _kdf, _unlock_mode), do: false + + defp payload_value(payload, string_key, atom_key) do + Map.get(payload, string_key) || Map.get(payload, atom_key) + end + + defp valid_version?(version) when is_integer(version), do: version in [1, 2] + defp valid_version?(version) when is_float(version), do: version in [1.0, 2.0] + defp valid_version?(_version), do: false + + defp valid_wrapped_aad_context?(_payload, version, _unlock_mode, _expected_kind) + when version in [1, 1.0], + do: true + + defp valid_wrapped_aad_context?(payload, version, unlock_mode, expected_kind) + when version in [2, 2.0] do + case payload_value(payload, "aad_context", :aad_context) do + %{} = context -> + payload_value(context, "purpose", :purpose) == "elektrine-private-mailbox-key-wrap" and + payload_value(context, "version", :version) in [2, 2.0] and + payload_value(context, "kind", :kind) == expected_kind and + payload_value(context, "algorithm", :algorithm) == "AES-GCM" and + payload_value(context, "kdf", :kdf) in ["scrypt", "master"] and + payload_value(context, "unlock_mode", :unlock_mode) == unlock_mode + + _ -> + false + end + end + + defp valid_wrapped_aad_context?(_payload, _version, _unlock_mode, _expected_kind), do: false + + defp valid_base64_bytes?(value, opts) when is_binary(value) do + case Base.decode64(value) do + {:ok, bytes} -> + size = byte_size(bytes) + min_size = Keyword.get(opts, :min_size, 0) + exact_size = Keyword.get(opts, :exact_size) + + size >= min_size and (is_nil(exact_size) or size == exact_size) + + :error -> + false + end + end + + defp valid_base64_bytes?(_value, _opts), do: false + + defp validate_forwarding(changeset) do + forward_enabled = get_field(changeset, :forward_enabled) + forward_to = get_field(changeset, :forward_to) + + cond do + forward_enabled && not Elektrine.Strings.present?(forward_to) -> + add_error(changeset, :forward_to, "enter a forwarding address") + + Elektrine.Strings.present?(forward_to) -> + changeset + |> validate_format(:forward_to, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, + message: "must be a valid email format" + ) + |> validate_not_self_forwarding() + |> validate_no_forwarding_loops() + + true -> + changeset + end + end + + defp validate_not_self_forwarding(changeset) do + email = get_field(changeset, :email) + username = get_field(changeset, :username) + user_id = get_field(changeset, :user_id) + forward_to = get_field(changeset, :forward_to) + + if forward_to do + mailbox_addresses = mailbox_addresses(username, user_id, email) + + if String.downcase(forward_to) in Enum.map(mailbox_addresses, &String.downcase/1) do + add_error(changeset, :forward_to, "choose a different forwarding address") + else + changeset + end + else + changeset + end + end + + # Recursive forwarding loop detection (checks both aliases and mailboxes) + defp validate_no_forwarding_loops(changeset) do + email = get_field(changeset, :email) + username = get_field(changeset, :username) + user_id = get_field(changeset, :user_id) + forward_to = get_field(changeset, :forward_to) + + if Elektrine.Strings.present?(forward_to) do + original_addresses = mailbox_addresses(username, user_id, email) + + case detect_mailbox_forwarding_loop(forward_to, original_addresses, [], 10) do + :loop_detected -> + add_error( + changeset, + :forward_to, + "this forwarding address would create a loop" + ) + + :max_depth_reached -> + add_error( + changeset, + :forward_to, + "this forwarding chain is too long" + ) + + :safe -> + changeset + end + else + changeset + end + end + + # Recursively trace forwarding chain to detect loops (checks both mailboxes and aliases) + defp detect_mailbox_forwarding_loop(_target, _originals, _visited, depth) when depth <= 0 do + :max_depth_reached + end + + defp detect_mailbox_forwarding_loop(target, originals, visited, depth) do + target = String.downcase(String.trim(target)) + originals = Enum.map(originals, &String.downcase/1) + + cond do + # Direct loop: target points back to any original address + target in originals -> + :loop_detected + + # Visited this email before in the chain + target in visited -> + :loop_detected + + # Check if target is an alias that forwards elsewhere + true -> + # First check aliases + case Elektrine.Email.Aliases.get_alias_by_email(target) do + %Elektrine.Email.Alias{target_email: next_target, enabled: true} + when is_binary(next_target) -> + if Elektrine.Strings.present?(next_target) do + detect_mailbox_forwarding_loop( + next_target, + originals, + [target | visited], + depth - 1 + ) + else + :ok + end + + _ -> + case Elektrine.Email.Mailboxes.get_mailbox_by_email(target) do + %Elektrine.Email.Mailbox{forward_enabled: true, forward_to: next_target} + when is_binary(next_target) -> + if Elektrine.Strings.present?(next_target) do + detect_mailbox_forwarding_loop( + next_target, + originals, + [target | visited], + depth - 1 + ) + else + :ok + end + + _ -> + :safe + end + end + end + end + + defp mailbox_addresses(username, user_id, fallback_email) + + defp mailbox_addresses(username, user_id, _fallback_email) + when is_binary(username) and is_integer(user_id) do + Elektrine.Domains.available_email_domains_for_user(user_id) + |> Enum.map(&"#{username}@#{&1}") + end + + defp mailbox_addresses(username, _user_id, _fallback_email) when is_binary(username) do + Elektrine.Domains.local_addresses_for_username(username) + end + + defp mailbox_addresses(_username, _user_id, fallback_email) when is_binary(fallback_email) do + [fallback_email] + end + + defp mailbox_addresses(_, _, _), do: [] + + defp validate_not_reserved_email(changeset) do + email = get_field(changeset, :email) + username = get_field(changeset, :username) + + reserved_addresses = Elektrine.Domains.reserved_addresses() + + # Extract reserved usernames from reserved addresses + reserved_usernames = + Elektrine.Domains.reserved_local_parts() + + changeset = + if email && String.downcase(email) in reserved_addresses do + add_error(changeset, :email, "this email address is reserved and cannot be used") + else + changeset + end + + if username && String.downcase(username) in reserved_usernames do + add_error(changeset, :username, "this username is reserved and cannot be used") + else + changeset + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/mailbox_adapter.ex b/apps/elektrine_email/lib/elektrine/email/mailbox_adapter.ex new file mode 100644 index 0000000..f4000d8 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/mailbox_adapter.ex @@ -0,0 +1,232 @@ +defmodule Elektrine.Email.MailboxAdapter do + @moduledoc """ + Adapter module for mailbox operations. + Temporary mailbox functionality has been removed for security. + """ + + alias Elektrine.Email.Mailbox + alias Elektrine.Email.Message + alias Elektrine.Repo + import Ecto.Query + + @doc """ + Creates a message for a mailbox with routing validation. + + Options: + - `pre_validated: true` - Skip routing validation (for Haraka emails already validated) + """ + def create_message(attrs) do + # Extract mailbox_id from attributes + mailbox_id = Map.get(attrs, :mailbox_id) || Map.get(attrs, "mailbox_id") + # Fix integer/string conversion - ensure mailbox_id is an integer + mailbox_id = normalize_mailbox_id(mailbox_id) + + # Check if already validated by caller (e.g., Haraka controller) + pre_validated = Map.get(attrs, :pre_validated) || Map.get(attrs, "pre_validated") || false + + if pre_validated do + # Skip validation - already validated by Haraka controller + Elektrine.Email.create_message(attrs) + else + # FINAL VALIDATION: Before creating any message, verify email routing is correct + case final_routing_validation(attrs, mailbox_id) do + :ok -> + # Validation passed, proceed with message creation + Elektrine.Email.create_message(attrs) + + {:error, reason} -> + require Logger + Logger.error("FINAL ROUTING VALIDATION FAILED: #{redact_emails(reason)}") + Logger.error("Routing metadata: #{inspect(routing_log_metadata(attrs, mailbox_id))}") + {:error, :final_routing_validation_failed} + end + end + end + + defp normalize_mailbox_id(mailbox_id) when is_integer(mailbox_id), do: mailbox_id + + defp normalize_mailbox_id(mailbox_id) when is_binary(mailbox_id) do + case Integer.parse(mailbox_id) do + {parsed, ""} when parsed > 0 -> parsed + _ -> nil + end + end + + defp normalize_mailbox_id(_mailbox_id), do: nil + + defp routing_log_metadata(attrs, mailbox_id) do + attachments = Map.get(attrs, :attachments) || Map.get(attrs, "attachments") || %{} + + %{ + mailbox_id: mailbox_id, + from_domain: attrs |> attr(:from) |> email_domain(), + to_domain: attrs |> attr(:to) |> email_domain(), + cc_present: present?(attr(attrs, :cc)), + bcc_present: present?(attr(attrs, :bcc)), + subject_present: present?(attr(attrs, :subject)), + text_body_present: present?(attr(attrs, :text_body)), + html_body_present: present?(attr(attrs, :html_body)), + attachment_count: attachment_count(attachments), + status: attr(attrs, :status), + pre_validated: attr(attrs, :pre_validated) == true + } + end + + defp attr(attrs, key) when is_map(attrs), + do: Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key)) + + defp email_domain(value) when is_binary(value) do + value + |> extract_clean_email_final() + |> case do + nil -> nil + email -> email |> String.split("@") |> List.last() |> String.downcase() + end + end + + defp email_domain(_value), do: nil + + defp present?(value) when is_binary(value), do: String.trim(value) != "" + defp present?(value), do: not is_nil(value) + + defp attachment_count(attachments) when is_map(attachments), do: map_size(attachments) + defp attachment_count(attachments) when is_list(attachments), do: length(attachments) + defp attachment_count(_attachments), do: 0 + + defp redact_emails(message) when is_binary(message) do + Regex.replace(~r/[^\s<>,']+@[^\s<>,']+/, message, "[redacted-email]") + end + + @doc """ + Gets a regular mailbox by ID. + """ + def get_mailbox_internal(id) do + case Repo.get(Mailbox, id) do + %Mailbox{} = mailbox -> {:regular, mailbox} + nil -> nil + end + end + + @doc """ + List messages for a regular mailbox. + """ + def list_messages(mailbox_id, limit \\ 50, offset \\ 0) do + Message + |> where([m], m.mailbox_id == ^mailbox_id) + |> order_by(desc: :inserted_at) + |> limit(^limit) + |> offset(^offset) + |> Repo.all() + end + + # FINAL VALIDATION: Last chance to prevent email misrouting + defp final_routing_validation(attrs, mailbox_id) do + # Get the TO address from message attributes + to_address = Map.get(attrs, :to) || Map.get(attrs, "to") + + # Get the mailbox email + mailbox_email = get_mailbox_email(mailbox_id) + + case {to_address, mailbox_email} do + {nil, _} -> + {:error, "No TO address specified in message"} + + {_, nil} -> + {:error, "Mailbox not found for ID: #{mailbox_id}"} + + {to, mailbox} -> + # Extract and normalize email addresses + to_clean = extract_clean_email_final(to) + mailbox_clean = extract_clean_email_final(mailbox) + + if to_clean && mailbox_clean do + # Normalize plus addressing + to_normalized = normalize_plus_address_final(to_clean) + mailbox_normalized = normalize_plus_address_final(mailbox_clean) + + # Check if they match (case insensitive) + if String.downcase(to_normalized) == String.downcase(mailbox_normalized) do + :ok + else + # Check local part matching for cross-domain support + if check_local_part_match_final(to_clean, mailbox_clean) do + :ok + else + # Check if TO address is a valid alias for this mailbox's user + if check_alias_validation_final(to_clean, mailbox_id) do + :ok + else + {:error, "TO address '#{to_clean}' does not match mailbox '#{mailbox_clean}'"} + end + end + end + else + {:error, "Failed to extract clean email addresses - TO: #{to}, Mailbox: #{mailbox}"} + end + end + end + + # Get mailbox email by ID + defp get_mailbox_email(mailbox_id) do + case get_mailbox_internal(mailbox_id) do + {:regular, mailbox} -> mailbox.email + nil -> nil + end + end + + # Extract clean email address (simplified version for final validation) + defp extract_clean_email_final(email) when is_binary(email) do + case Regex.run(~r/([^\s<>,]+@[^\s<>,]+)/, email) do + [_, clean] -> String.trim(clean) + _ -> nil + end + end + + defp extract_clean_email_final(_), do: nil + + # Normalize plus addressing (simplified version for final validation) + defp normalize_plus_address_final(email) do + case String.split(email, "@") do + [local_part, domain] -> + # Remove everything after + in local part + clean_local = String.split(local_part, "+") |> hd() + "#{clean_local}@#{domain}" + + _ -> + email + end + end + + # Check if local parts match across supported domains + defp check_local_part_match_final(email1, email2) do + case {String.split(email1, "@"), String.split(email2, "@")} do + {[local1, domain1], [local2, domain2]} -> + # Get supported domains + supported_domains = Elektrine.Domains.supported_email_domains() + + # Both domains must be supported and local parts must match + String.downcase(local1) == String.downcase(local2) && + domain1 in supported_domains && + domain2 in supported_domains + + _ -> + false + end + end + + # Check if TO address is a valid alias for the mailbox's user + defp check_alias_validation_final(to_address, mailbox_id) do + # Get the user ID for this mailbox + case get_mailbox_internal(mailbox_id) do + {:regular, mailbox} -> + # Use the existing email ownership verification which handles aliases + case Elektrine.Email.verify_email_ownership(to_address, mailbox.user_id) do + {:ok, _ownership_type} -> true + {:error, _reason} -> false + end + + nil -> + false + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/mailbox_encryption.ex b/apps/elektrine_email/lib/elektrine/email/mailbox_encryption.ex new file mode 100644 index 0000000..a0a4ed4 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/mailbox_encryption.ex @@ -0,0 +1,429 @@ +defmodule Elektrine.Email.MailboxEncryption do + @moduledoc """ + Public-key encryption for optional browser-unlocked mailbox storage. + + The browser generates the mailbox keypair. The server only stores the public + key, a passphrase-wrapped private key blob, and encrypted message payloads. + """ + + alias Elektrine.Email.AttachmentStorage + alias Elektrine.Email.Mailbox + + @placeholder_subject "Encrypted message" + @placeholder_sender "Encrypted sender" + @placeholder_recipients "Encrypted recipients" + @placeholder_attachment_filename "Encrypted attachment" + @placeholder_attachment_content_type "application/octet-stream" + + @doc """ + Returns true when the mailbox is configured to store private message payloads. + """ + def enabled?(%Mailbox{private_storage_enabled: true} = mailbox) do + Mailbox.private_storage_configured?(mailbox) + end + + def enabled?(_mailbox), do: false + + @doc """ + Returns the generic subject shown server-side when the real subject is protected. + """ + def placeholder_subject, do: @placeholder_subject + + @doc """ + Returns the generic sender shown server-side when the real sender is protected. + """ + def placeholder_sender, do: @placeholder_sender + + @doc """ + Returns the generic recipient text shown server-side when real recipients are protected. + """ + def placeholder_recipients, do: @placeholder_recipients + + @doc """ + Returns true when an attachment entry is protected by mailbox encryption. + """ + def attachment_encrypted?(attachment) when is_map(attachment) do + attachment + |> attachment_payload() + |> valid_payload?(:attachment) + end + + def attachment_encrypted?(_attachment), do: false + + @doc """ + Returns the stored encrypted attachment payload envelope, if present. + """ + def attachment_payload(attachment) when is_map(attachment) do + Map.get(attachment, "private_encrypted_payload") || + Map.get(attachment, :private_encrypted_payload) + end + + def attachment_payload(_attachment), do: nil + + @doc """ + Encrypts subject and body content for a mailbox using its configured public key. + """ + def encrypt_message(attrs, %Mailbox{} = mailbox) do + if enabled?(mailbox) do + encrypt_payload(attrs, mailbox.private_storage_public_key) + else + {:ok, attrs} + end + end + + @doc """ + Returns true when a stored message payload matches the mailbox-encryption envelope shape. + """ + def valid_payload?(payload, expected_kind \\ nil) + + def valid_payload?(payload, expected_kind) when is_map(payload) do + version = payload_value(payload, "version", :version) + content_algorithm = payload_value(payload, "content_algorithm", :content_algorithm) + key_algorithm = payload_value(payload, "key_algorithm", :key_algorithm) + encrypted_key = payload_value(payload, "encrypted_key", :encrypted_key) + iv = payload_value(payload, "iv", :iv) + tag = payload_value(payload, "tag", :tag) + ciphertext = payload_value(payload, "ciphertext", :ciphertext) + + valid_version?(version) and valid_aad_context?(payload, version, expected_kind) and + content_algorithm == "AES-256-GCM" and + key_algorithm == "RSA-OAEP-SHA256" and valid_base64_bytes?(encrypted_key, min_size: 32) and + valid_base64_bytes?(iv, exact_size: 12) and valid_base64_bytes?(tag, exact_size: 16) and + valid_base64_bytes?(ciphertext, min_size: 1) + end + + def valid_payload?(_payload, _expected_kind), do: false + + defp encrypt_payload(attrs, public_key_pem) do + from = get_attr(attrs, "from", :from) + to = get_attr(attrs, "to", :to) + cc = get_attr(attrs, "cc", :cc) + bcc = get_attr(attrs, "bcc", :bcc) + subject = get_attr(attrs, "subject", :subject) + text_body = get_attr(attrs, "text_body", :text_body) + html_body = get_attr(attrs, "html_body", :html_body) + raw_source = get_attr(attrs, "raw_source", :raw_source) + attachments = get_attr(attrs, "attachments", :attachments) || %{} + metadata = get_attr(attrs, "metadata", :metadata) || %{} + + with {:ok, public_key} <- decode_public_key(public_key_pem), + {:ok, envelope} <- + maybe_encrypt_message_fields( + from, + to, + cc, + bcc, + subject, + text_body, + html_body, + raw_source, + attachments, + public_key + ), + {:ok, encrypted_attachments} <- encrypt_attachments(attachments, public_key) do + {:ok, + attrs + |> put_attr(:from, placeholder_or_original(from, @placeholder_sender)) + |> put_attr(:to, placeholder_or_original(to, @placeholder_recipients)) + |> put_attr(:cc, placeholder_or_original(cc, @placeholder_recipients)) + |> put_attr(:bcc, placeholder_or_original(bcc, @placeholder_recipients)) + |> put_attr(:subject, @placeholder_subject) + |> put_attr(:text_body, nil) + |> put_attr(:html_body, nil) + |> put_attr(:search_index, []) + |> put_attr(:encrypted_text_body, nil) + |> put_attr(:encrypted_html_body, nil) + |> put_attr(:encrypted_raw_source, nil) + |> delete_attr(:raw_source) + |> put_attr(:metadata, private_storage_metadata(metadata)) + |> put_attr(:attachments, encrypted_attachments) + |> put_attr(:has_attachments, map_size(encrypted_attachments) > 0) + |> put_attr(:client_encrypted_payload, envelope)} + end + rescue + _ -> {:error, :private_storage_encryption_failed} + end + + defp sensitive_content_blank?(from, to, cc, bcc, subject, text_body, html_body) do + Enum.all?([from, to, cc, bcc, subject, text_body, html_body], &blank?/1) + end + + defp maybe_encrypt_message_fields( + from, + to, + cc, + bcc, + subject, + text_body, + html_body, + raw_source, + attachments, + public_key + ) do + if sensitive_content_blank?(from, to, cc, bcc, subject, text_body, html_body) and + blank?(raw_source) and + attachments in [nil, %{}] do + {:ok, nil} + else + encrypt_json( + %{ + "from" => from, + "to" => to, + "cc" => cc, + "bcc" => bcc, + "subject" => subject, + "text_body" => text_body, + "html_body" => html_body, + "raw_source_base64" => encode_raw_source(raw_source) + }, + public_key, + :message + ) + end + end + + defp encrypt_attachments(attachments, _public_key) when attachments in [nil, %{}], + do: {:ok, %{}} + + defp encrypt_attachments(attachments, public_key) when is_map(attachments) do + Enum.reduce_while(attachments, {:ok, %{}}, fn {attachment_id, attachment}, {:ok, acc} -> + case encrypt_attachment(attachment, public_key) do + {:ok, encrypted_attachment} -> + {:cont, {:ok, Map.put(acc, attachment_id, encrypted_attachment)}} + + {:error, reason} -> + {:halt, {:error, reason}} + end + end) + end + + defp encrypt_attachments(_attachments, _public_key), + do: {:error, :private_storage_attachment_encryption_failed} + + defp encrypt_attachment(attachment, _public_key) when not is_map(attachment) do + {:error, :private_storage_attachment_encryption_failed} + end + + defp encrypt_attachment(attachment, public_key) do + if attachment_encrypted?(attachment) do + {:ok, attachment} + else + with {:ok, content} <- AttachmentStorage.download_attachment(attachment), + {:ok, envelope} <- + encrypt_json( + attachment_payload_map(attachment, content), + public_key, + :attachment + ) do + {:ok, + %{ + "filename" => @placeholder_attachment_filename, + "content_type" => @placeholder_attachment_content_type, + "size" => 0, + "private_encrypted" => true, + "private_encrypted_payload" => envelope + }} + else + {:error, _reason} -> {:error, :private_storage_attachment_encryption_failed} + end + end + end + + defp attachment_payload_map(attachment, content) do + %{ + "filename" => Map.get(attachment, "filename") || @placeholder_attachment_filename, + "content_type" => + Map.get(attachment, "content_type") || @placeholder_attachment_content_type, + "size" => Map.get(attachment, "size") || byte_size(content), + "disposition" => Map.get(attachment, "disposition"), + "content_id" => Map.get(attachment, "content_id"), + "data" => Base.encode64(content), + "encoding" => "base64" + } + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Enum.into(%{}) + end + + defp private_storage_metadata(metadata) when is_map(metadata) do + allowed_keys = ~w( + body_format + client_message_id + delivery_id + external_delivery + internal_delivery + original_message_id + private_storage + provider_message_id + raw_source_omitted_reason + raw_source_original_bytes + raw_source_retained + raw_source_retention_limit_bytes + received_at + sent_at + spam_score + trace_id + ) + + metadata + |> Enum.reduce(%{}, fn {key, value}, acc -> + string_key = to_string(key) + + if string_key in allowed_keys and metadata_value_safe?(value) do + Map.put(acc, string_key, value) + else + acc + end + end) + |> Map.put("private_storage", true) + end + + defp private_storage_metadata(_metadata), do: %{"private_storage" => true} + + defp metadata_value_safe?(value) when is_binary(value), do: byte_size(value) <= 500 + defp metadata_value_safe?(value) when is_boolean(value), do: true + defp metadata_value_safe?(value) when is_number(value), do: true + defp metadata_value_safe?(nil), do: true + defp metadata_value_safe?(_value), do: false + + defp encrypt_json(map, public_key, kind) when is_map(map) do + payload = Jason.encode!(map) + content_key = :crypto.strong_rand_bytes(32) + iv = :crypto.strong_rand_bytes(12) + aad_context = aad_context(kind) + aad = canonical_json(aad_context) + + {ciphertext, tag} = + :crypto.crypto_one_time_aead(:aes_256_gcm, content_key, iv, payload, aad, true) + + encrypted_key = + :public_key.encrypt_public( + content_key, + public_key, + rsa_padding: :rsa_pkcs1_oaep_padding, + rsa_oaep_md: :sha256, + rsa_mgf1_md: :sha256 + ) + + {:ok, + %{ + version: 2, + content_algorithm: "AES-256-GCM", + key_algorithm: "RSA-OAEP-SHA256", + aad_context: aad_context, + encrypted_key: Base.encode64(encrypted_key), + iv: Base.encode64(iv), + tag: Base.encode64(tag), + ciphertext: Base.encode64(ciphertext) + }} + end + + defp blank?(value) when is_binary(value), do: not Elektrine.Strings.present?(value) + defp blank?(nil), do: true + defp blank?(_value), do: false + + defp placeholder_or_original(value, _placeholder) when value in [nil, ""], do: value + defp placeholder_or_original(_value, placeholder), do: placeholder + + defp decode_public_key(public_key_pem) when is_binary(public_key_pem) do + case :public_key.pem_decode(public_key_pem) do + [entry | _] -> + {:ok, :public_key.pem_entry_decode(entry)} + + _ -> + {:error, :invalid_public_key} + end + rescue + _ -> {:error, :invalid_public_key} + end + + defp decode_public_key(_public_key_pem), do: {:error, :invalid_public_key} + + defp get_attr(attrs, string_key, atom_key) do + Map.get(attrs, atom_key) || Map.get(attrs, string_key) + end + + defp put_attr(attrs, key, value) do + has_atom_keys = Enum.any?(Map.keys(attrs), &is_atom/1) + + if has_atom_keys do + Map.put(attrs, key, value) + else + Map.put(attrs, Atom.to_string(key), value) + end + end + + defp delete_attr(attrs, key) do + attrs + |> Map.delete(key) + |> Map.delete(Atom.to_string(key)) + end + + defp encode_raw_source(value) when is_binary(value) and byte_size(value) > 0, + do: Base.encode64(value) + + defp encode_raw_source(_value), do: nil + + defp payload_value(payload, string_key, atom_key) do + Map.get(payload, string_key) || Map.get(payload, atom_key) + end + + defp valid_version?(version) when is_integer(version), do: version in [1, 2] + defp valid_version?(version) when is_float(version), do: version in [1.0, 2.0] + defp valid_version?(_version), do: false + + defp valid_aad_context?(_payload, version, _expected_kind) when version in [1, 1.0], do: true + + defp valid_aad_context?(payload, version, expected_kind) when version in [2, 2.0] do + case payload_value(payload, "aad_context", :aad_context) do + %{} = context -> + kind = payload_value(context, "kind", :kind) + + payload_value(context, "purpose", :purpose) == "elektrine-private-mailbox" and + payload_value(context, "version", :version) in [2, 2.0] and + kind in ["message", "attachment"] and + (is_nil(expected_kind) or kind == Atom.to_string(expected_kind)) and + payload_value(context, "content_algorithm", :content_algorithm) == "AES-256-GCM" and + payload_value(context, "key_algorithm", :key_algorithm) == "RSA-OAEP-SHA256" + + _ -> + false + end + end + + defp valid_aad_context?(_payload, _version, _expected_kind), do: false + + defp aad_context(kind) when kind in [:message, :attachment] do + %{ + "purpose" => "elektrine-private-mailbox", + "version" => 2, + "kind" => Atom.to_string(kind), + "content_algorithm" => "AES-256-GCM", + "key_algorithm" => "RSA-OAEP-SHA256" + } + end + + defp canonical_json(%{} = map) do + map + |> Enum.sort_by(fn {key, _value} -> to_string(key) end) + |> Enum.map_join(",", fn {key, value} -> + Jason.encode!(to_string(key)) <> ":" <> Jason.encode!(value) + end) + |> then(&("{" <> &1 <> "}")) + end + + defp valid_base64_bytes?(value, opts) when is_binary(value) do + case Base.decode64(value) do + {:ok, bytes} -> + size = byte_size(bytes) + min_size = Keyword.get(opts, :min_size, 0) + exact_size = Keyword.get(opts, :exact_size) + + size >= min_size and (is_nil(exact_size) or size == exact_size) + + :error -> + false + end + end + + defp valid_base64_bytes?(_value, _opts), do: false +end diff --git a/apps/elektrine_email/lib/elektrine/email/mailboxes.ex b/apps/elektrine_email/lib/elektrine/email/mailboxes.ex new file mode 100644 index 0000000..400ea02 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/mailboxes.ex @@ -0,0 +1,410 @@ +defmodule Elektrine.Email.Mailboxes do + @moduledoc """ + Mailbox management context. + Handles mailbox creation, retrieval, updates, and ownership operations. + """ + + import Ecto.Query, warn: false + require Logger + alias Elektrine.Domains + alias Elektrine.Email.{CustomDomains, Mailbox} + alias Elektrine.Repo + + @doc """ + Gets a user's primary mailbox. + Returns nil if the Mailbox does not exist. + """ + def get_user_mailbox(user_id) do + Mailbox + |> where(user_id: ^user_id) + |> preferred_mailbox_query() + |> Repo.one() + end + + @doc """ + Gets all mailboxes for a user across configured local domains. + Returns a list of mailboxes. + """ + def get_user_mailboxes(user_id) do + Mailbox + |> where(user_id: ^user_id) + |> order_by(:email) + |> Repo.all() + end + + @doc """ + Gets a single mailbox for admin operations (bypasses ownership checks). + + WARNING: Only use this for admin operations where access control is handled + at the plug/authorization layer. For regular user operations, use get_mailbox(id, user_id). + """ + def get_mailbox_admin(id), do: Repo.get(Mailbox, id) + + @doc """ + Gets a single mailbox for internal system operations (bypasses ownership checks). + + WARNING: Only use this for internal background jobs and system operations that + don't involve user requests. For user-facing operations, use get_mailbox(id, user_id). + + Examples: cache operations, email sending jobs, system adapters. + """ + def get_mailbox_internal(id), do: Repo.get(Mailbox, id) + + @doc """ + Gets a mailbox by email address. + """ + def get_mailbox_by_email(email) when is_binary(email) do + # First try direct email lookup for older clients + case Mailbox |> where(email: ^email) |> preferred_mailbox_query() |> Repo.one() do + %Mailbox{} = mailbox -> + mailbox + + nil -> + get_mailbox_for_domain_variant(email) + end + end + + @doc """ + Gets a mailbox by username (domain-agnostic). + """ + def get_mailbox_by_username(username) when is_binary(username) do + Mailbox + |> where(username: ^username) + |> preferred_mailbox_query() + |> Repo.one() + end + + @doc """ + Gets a single mailbox for a specific user. + Returns nil if the Mailbox does not exist for that user. + """ + def get_mailbox(id, user_id) do + Mailbox + |> where(id: ^id, user_id: ^user_id) + |> Repo.one() + end + + @doc """ + Creates a mailbox for a user or with the given parameters. + """ + def create_mailbox(user) when is_struct(user) do + Mailbox.create_for_user(user) + |> Repo.insert() + |> initialize_jmap_state() + end + + def create_mailbox(mailbox_params) when is_map(mailbox_params) do + %Mailbox{} + |> Mailbox.changeset(mailbox_params) + |> Repo.insert() + |> initialize_jmap_state() + end + + @doc """ + Ensures a user has a mailbox, creating one if it doesn't exist. + Also fixes email address if it doesn't match the current username. + """ + def ensure_user_has_mailbox(user) do + case get_user_mailbox(user.id) do + nil -> + create_mailbox(user) + + mailbox -> + if mailbox_belongs_to_user?(mailbox, user) do + {:ok, mailbox} + else + case preferred_mailbox_email_for_user(user) do + nil -> + {:ok, mailbox} + + expected_email -> + Logger.warning( + "Mailbox email mismatch for user #{user.id}: #{mailbox.email} vs expected #{expected_email}" + ) + + case get_mailbox_by_email(expected_email) do + nil -> + case update_mailbox_email(mailbox, expected_email) do + {:ok, updated_mailbox} -> + Logger.info( + "Fixed mailbox email for user #{user.id}: #{mailbox.email} -> #{expected_email}" + ) + + {:ok, updated_mailbox} + + {:error, reason} -> + Logger.warning( + "Could not update mailbox email for user #{user.id}: #{inspect(reason)}" + ) + + {:ok, mailbox} + end + + existing_mailbox -> + Logger.warning( + "Cannot fix mailbox email for user #{user.id} - #{expected_email} is taken by mailbox #{existing_mailbox.id}" + ) + + {:ok, mailbox} + end + end + end + end + end + + defp mailbox_belongs_to_user?(%Mailbox{email: email}, user) when is_binary(email) do + case String.split(email, "@", parts: 2) do + [local, domain] -> + local == user.username and domain in Domains.available_email_domains_for_user(user.id) + + _ -> + false + end + end + + defp mailbox_belongs_to_user?(_mailbox, _user), do: false + + defp preferred_mailbox_email_for_user(user) do + preferred_domain = user.preferred_email_domain + available_domains = Domains.available_email_domains_for_user(user.id) + + cond do + is_binary(preferred_domain) and preferred_domain in available_domains -> + "#{user.username}@#{preferred_domain}" + + available_domains != [] -> + "#{user.username}@#{hd(available_domains)}" + + true -> + nil + end + end + + defp preferred_mailbox_query(query) do + query + |> order_by( + [m], + asc: fragment("CASE WHEN ? IS NULL THEN 1 ELSE 0 END", m.user_id), + asc: fragment("CASE WHEN ? IS NULL OR ? = '' THEN 1 ELSE 0 END", m.username, m.username), + desc: m.updated_at, + desc: m.id + ) + |> limit(1) + end + + @doc """ + Returns the list of mailboxes for a user. + """ + def list_mailboxes(user_id) do + Mailbox + |> where(user_id: ^user_id) + |> order_by([m], asc: m.email) + |> Repo.all() + end + + @doc """ + Returns all mailboxes in the system. + """ + def list_all_mailboxes do + Mailbox + |> order_by([m], asc: m.id) + |> Repo.all() + end + + @doc """ + Updates a mailbox. + """ + def update_mailbox(mailbox, attrs) do + mailbox + |> Mailbox.changeset(attrs) + |> Repo.update() + end + + defp initialize_jmap_state({:ok, mailbox}) do + Elektrine.JMAP.State.initialize_for_mailbox(mailbox.id) + {:ok, mailbox} + end + + defp initialize_jmap_state(result), do: result + + @doc """ + Updates a mailbox email address (used when username changes). + """ + def update_mailbox_email(mailbox, new_email) do + attrs = + case mailbox_username_for_email(new_email) do + nil -> %{email: new_email} + username -> %{email: new_email, username: username} + end + + # Check if the new email is already taken by another mailbox + case get_mailbox_by_email(new_email) do + nil -> + # Email is available, update it + update_mailbox(mailbox, attrs) + + existing_mailbox when existing_mailbox.id == mailbox.id -> + # Same mailbox, no change needed + update_mailbox(mailbox, attrs) + + _other_mailbox -> + # Email is taken by another mailbox + {:error, "Email address already in use by another mailbox"} + end + end + + @doc """ + Updates browser-managed private mailbox storage settings. + """ + def update_mailbox_private_storage(%Mailbox{} = mailbox, attrs) do + mailbox + |> Mailbox.private_storage_changeset(attrs) + |> Repo.update() + end + + @doc """ + Clears browser-managed private storage keys for all of a user's mailboxes. + + This is used when account encrypted data is reset. Existing protected messages + encrypted under the old mailbox key remain unreadable, but the mailbox can be + set up again with a fresh key. + """ + def reset_user_private_storage(user_id) when is_integer(user_id) do + attrs = %{ + private_storage_enabled: false, + private_storage_public_key: nil, + private_storage_wrapped_private_key: nil, + private_storage_verifier: nil + } + + user_id + |> get_user_mailboxes() + |> Enum.reduce_while({:ok, 0}, fn mailbox, {:ok, count} -> + case update_mailbox_private_storage(mailbox, attrs) do + {:ok, _mailbox} -> {:cont, {:ok, count + 1}} + {:error, changeset} -> {:halt, {:error, changeset}} + end + end) + end + + @doc """ + Transitions a user's mailbox for username change. + Updates the mailbox address in place so stored messages and private storage remain intact. + """ + def transition_mailbox_for_username_change(user, old_mailbox, new_email) do + case update_mailbox_email(old_mailbox, new_email) do + {:ok, updated_mailbox} -> + Logger.info( + "Successfully transitioned mailbox for user #{user.id}: mailbox #{updated_mailbox.id} updated to #{new_email}" + ) + + {:ok, updated_mailbox} + + {:error, reason} -> + Logger.error( + "Failed to transition mailbox for user #{user.id}: #{Kernel.inspect(reason)}" + ) + + {:error, "Failed to transition mailbox"} + end + end + + defp mailbox_username_for_email(email) when is_binary(email) do + case String.split(String.downcase(String.trim(email)), "@", parts: 2) do + [username, domain] -> + if username != "" and Elektrine.Domains.local_email_domain?(domain) do + username + else + nil + end + + _ -> + nil + end + end + + defp mailbox_username_for_email(_), do: nil + + defp get_mailbox_for_domain_variant(email) do + case String.split(String.downcase(String.trim(email)), "@", parts: 2) do + [username, domain] -> + base_username = username |> String.split("+") |> List.first() |> String.downcase() + + if Elektrine.Domains.local_email_domain?(domain) do + get_mailbox_by_username(base_username) + else + case CustomDomains.get_verified_custom_domain(domain) do + %Elektrine.Email.CustomDomain{user_id: user_id, user: %{username: owner_username}} + when is_integer(user_id) -> + if String.downcase(owner_username) == base_username do + get_user_mailbox(user_id) + else + nil + end + + _ -> + nil + end + end + + _ -> + nil + end + end + + @doc """ + Deletes a mailbox. + """ + def delete_mailbox(mailbox) do + Repo.delete(mailbox) + end + + @doc """ + Updates mailbox forwarding settings. + """ + def update_mailbox_forwarding(%Mailbox{} = mailbox, attrs) do + mailbox + |> Mailbox.forwarding_changeset(attrs) + |> Repo.update() + end + + @doc """ + Updates optional mailbox filters for a mailbox. + """ + def update_mailbox_category_filters(%Mailbox{} = mailbox, attrs) do + mailbox + |> Mailbox.category_filter_changeset(attrs) + |> Repo.update() + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking mailbox forwarding changes. + """ + def change_mailbox_forwarding(%Mailbox{} = mailbox, attrs \\ %{}) do + Mailbox.forwarding_changeset(mailbox, attrs) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking mailbox filter changes. + """ + def change_mailbox_category_filters(%Mailbox{} = mailbox, attrs \\ %{}) do + Mailbox.category_filter_changeset(mailbox, attrs) + end + + @doc """ + Checks if a mailbox has forwarding enabled and returns the target email. + Returns nil if forwarding is disabled or not configured. + """ + def get_mailbox_forward_target(%Mailbox{forward_enabled: true, forward_to: target}) + when is_binary(target) do + target + end + + def get_mailbox_forward_target(_mailbox), do: nil + + # Private function - Gets a single mailbox without ownership checks. + # Use get_mailbox(id, user_id) for user-facing operations, + # get_mailbox_admin(id) for admin operations, or + # get_mailbox_internal(id) for internal system operations. + def get_mailbox(id), do: Repo.get(Mailbox, id) +end diff --git a/apps/elektrine_email/lib/elektrine/email/message.ex b/apps/elektrine_email/lib/elektrine/email/message.ex new file mode 100644 index 0000000..ce60ee3 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/message.ex @@ -0,0 +1,490 @@ +defmodule Elektrine.Email.Message do + @moduledoc """ + Schema for email messages with encryption support and Hey.com-inspired features. + Supports encrypted body content, attachments, categorization, spam filtering, and reply-later functionality. + """ + use Ecto.Schema + import Ecto.Changeset + + alias Elektrine.Email.MailboxEncryption + + schema "email_messages" do + field :message_id, :string + field :from, :string + field :to, :string + field :cc, :string + field :bcc, :string + field :subject, :string + field :text_body, :string + field :html_body, :string + field :encrypted_text_body, :map + field :encrypted_html_body, :map + field :encrypted_raw_source, :map + field :raw_source, :binary, virtual: true + field :client_encrypted_payload, :map + field :search_index, {:array, :string}, default: [] + # received, sent, draft + field :status, :string, default: "received" + field :read, :boolean, default: false + field :spam, :boolean, default: false + field :archived, :boolean, default: false + field :deleted, :boolean, default: false + field :flagged, :boolean, default: false + field :answered, :boolean, default: false + field :metadata, :map, default: %{} + + # Hey.com features + field :category, :string, default: "inbox" + field :stack_at, :utc_datetime + field :stack_reason, :string + field :reply_later_at, :utc_datetime + field :reply_later_reminder, :boolean, default: false + field :is_receipt, :boolean, default: false + field :is_notification, :boolean, default: false + field :is_newsletter, :boolean, default: false + field :opened_at, :utc_datetime + field :first_opened_at, :utc_datetime + field :open_count, :integer, default: 0 + + # Attachments + field :attachments, :map, default: %{} + field :has_attachments, :boolean, default: false + field :hash, :string + + # JMAP fields + field :in_reply_to, :string + field :references, :string + field :jmap_blob_id, :string + + # Priority and scheduling + field :priority, :string, default: "normal" + field :scheduled_at, :utc_datetime + field :expires_at, :utc_datetime + field :undo_send_until, :utc_datetime + + belongs_to :mailbox, Elektrine.Email.Mailbox + belongs_to :thread, Elektrine.JMAP.Thread + belongs_to :folder, Elektrine.Email.Folder + + many_to_many :labels, Elektrine.Email.Label, join_through: "email_message_labels" + + timestamps(type: :utc_datetime) + end + + @doc """ + Creates a changeset for a new email message. + """ + def changeset(message, attrs) do + message + |> cast(attrs, [ + :message_id, + :from, + :to, + :cc, + :bcc, + :subject, + :text_body, + :html_body, + :encrypted_text_body, + :encrypted_html_body, + :encrypted_raw_source, + :client_encrypted_payload, + :search_index, + :status, + :read, + :spam, + :archived, + :deleted, + :flagged, + :answered, + :metadata, + :mailbox_id, + :category, + :stack_at, + :stack_reason, + :reply_later_at, + :reply_later_reminder, + :is_receipt, + :is_notification, + :is_newsletter, + :opened_at, + :first_opened_at, + :open_count, + :attachments, + :has_attachments, + :inserted_at, + :updated_at, + :thread_id, + :in_reply_to, + :references, + :jmap_blob_id, + :priority, + :scheduled_at, + :expires_at, + :undo_send_until, + :folder_id + ]) + |> validate_inclusion(:priority, ~w(low normal high)) + |> validate_required_fields() + |> validate_length(:message_id, max: 500) + |> validate_length(:from, max: 500) + |> validate_length(:to, max: 10_000) + |> validate_length(:cc, max: 10_000) + |> validate_length(:bcc, max: 10_000) + |> validate_length(:subject, max: 500) + |> validate_length(:stack_reason, max: 255) + |> set_has_attachments() + |> generate_hash_if_needed() + |> validate_client_encrypted_payload() + |> unique_constraint([:message_id, :mailbox_id]) + + # No foreign key constraint anymore - we manually handle the association + end + + # Automatically set has_attachments based on attachments field + defp set_has_attachments(changeset) do + case get_field(changeset, :attachments) do + attachments when is_map(attachments) and map_size(attachments) > 0 -> + put_change(changeset, :has_attachments, true) + + _ -> + put_change(changeset, :has_attachments, false) + end + end + + @doc """ + Mark a message as read. + """ + def read_changeset(message, attrs \\ %{}) do + message + |> cast(attrs, []) + |> put_change(:read, true) + end + + @doc """ + Mark a message as unread. + """ + def unread_changeset(message, attrs \\ %{}) do + message + |> cast(attrs, []) + |> put_change(:read, false) + end + + @doc """ + Mark a message as spam. + """ + def spam_changeset(message, attrs \\ %{}) do + message + |> cast(attrs, []) + |> put_change(:spam, true) + end + + @doc """ + Mark a message as not spam. + """ + def unspam_changeset(message, attrs \\ %{}) do + message + |> cast(attrs, []) + |> put_change(:spam, false) + end + + @doc """ + Archive a message. + """ + def archive_changeset(message, attrs \\ %{}) do + message + |> cast(attrs, []) + |> put_change(:archived, true) + end + + @doc """ + Unarchive a message. + """ + def unarchive_changeset(message, attrs \\ %{}) do + message + |> cast(attrs, []) + |> put_change(:archived, false) + end + + @doc """ + Moves a message to trash. + """ + def trash_changeset(message, attrs \\ %{}) do + message + |> cast(attrs, []) + |> put_change(:deleted, true) + end + + @doc """ + Restores a message from trash. + """ + def untrash_changeset(message, attrs \\ %{}) do + message + |> cast(attrs, []) + |> put_change(:deleted, false) + end + + @doc """ + Add a message to the stack for later. + """ + def stack_changeset(message, attrs \\ %{}) do + message + |> cast(attrs, [:stack_reason]) + |> put_change(:category, "stack") + |> put_change(:stack_at, DateTime.utc_now() |> DateTime.truncate(:second)) + end + + @doc """ + Move message back from stack. + """ + def unstack_changeset(message, attrs \\ %{}) do + message + |> cast(attrs, []) + |> put_change(:category, "inbox") + |> put_change(:stack_at, nil) + |> put_change(:stack_reason, nil) + end + + @doc """ + Set a message for reply later. + """ + def reply_later_changeset(message, attrs) do + message + |> cast(attrs, [:reply_later_at, :reply_later_reminder]) + |> validate_required([:reply_later_at]) + end + + @doc """ + Clear reply later for a message. + """ + def clear_reply_later_changeset(message, attrs \\ %{}) do + message + |> cast(attrs, []) + |> put_change(:reply_later_at, nil) + |> put_change(:reply_later_reminder, false) + end + + @doc """ + Track when a message is opened. + """ + def track_open_changeset(message, attrs \\ %{}) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + changeset = + message + |> cast(attrs, []) + |> put_change(:opened_at, now) + |> put_change(:open_count, (message.open_count || 0) + 1) + + if is_nil(message.first_opened_at) do + changeset |> put_change(:first_opened_at, now) + else + changeset + end + end + + # Validate required fields based on message status + defp validate_required_fields(changeset) do + status = get_field(changeset, :status) + + if status == "draft" do + # Drafts only require message_id, from, and mailbox_id + validate_required(changeset, [:message_id, :from, :mailbox_id]) + else + # Regular messages require to field as well + validate_required(changeset, [:message_id, :from, :to, :mailbox_id]) + end + end + + @doc """ + Encrypts email body content and creates search index. + Returns updated attrs with encrypted bodies and search_index. + Clears the plaintext body fields to avoid storing unencrypted data. + """ + def encrypt_content(attrs, user_id) do + # Get original content before encryption (for search index) + original_text = get_attr(attrs, :text_body) + original_html = get_attr(attrs, :html_body) + original_raw_source = get_attr(attrs, :raw_source) + + # Encrypt text_body if present + attrs = + case original_text do + nil -> + attrs + + "" -> + attrs + + text_body -> + encrypted = Elektrine.Encryption.encrypt(text_body, user_id) + + attrs + |> put_attr(:encrypted_text_body, encrypted) + # Clear plaintext + |> put_attr(:text_body, nil) + end + + # Encrypt html_body if present + attrs = + case original_html do + nil -> + attrs + + "" -> + attrs + + html_body -> + encrypted = Elektrine.Encryption.encrypt(html_body, user_id) + + attrs + |> put_attr(:encrypted_html_body, encrypted) + # Clear plaintext + |> put_attr(:html_body, nil) + end + + # Preserve the exact RFC822 source as ciphertext only. This is deliberately + # separate from the display bodies so a parser or sanitizer mistake remains + # reversible without ever adding a plaintext raw-message column. + attrs = + case original_raw_source do + raw_source when is_binary(raw_source) and byte_size(raw_source) > 0 -> + encrypted = Elektrine.Encryption.encrypt(raw_source, user_id) + + attrs = delete_attr(attrs, :raw_source) + + if encrypted[:encrypted_data] || encrypted["encrypted_data"] do + put_attr(attrs, :encrypted_raw_source, encrypted) + else + attrs + end + + _ -> + delete_attr(attrs, :raw_source) + end + + # Create search index from text_body (prefer text over html for indexing) + search_content = original_text || original_html || "" + + if Elektrine.Strings.present?(search_content) do + search_index = Elektrine.Encryption.index_content(search_content, user_id) + put_attr(attrs, :search_index, search_index) + else + attrs + end + end + + @doc "Decrypts the retained original RFC822 source without changing display fields." + def decrypt_raw_source(%__MODULE__{encrypted_raw_source: encrypted}, user_id) + when is_map(encrypted) and is_integer(user_id) do + Elektrine.Encryption.decrypt(encrypted, user_id) + end + + def decrypt_raw_source(%__MODULE__{}, _user_id), do: {:error, :raw_source_unavailable} + + @doc """ + Decrypts email message content if encrypted. + Returns the message with decrypted content in the body fields. + """ + def decrypt_content(%__MODULE__{encrypted_text_body: nil, encrypted_html_body: nil} = message), + do: message + + def decrypt_content(%__MODULE__{mailbox: %{user_id: user_id}} = message) do + decrypt_content(message, user_id) + end + + def decrypt_content(message) when not is_integer(message), do: message + + def decrypt_content( + %__MODULE__{encrypted_text_body: nil, encrypted_html_body: nil} = message, + _user_id + ), + do: message + + def decrypt_content(%__MODULE__{} = message, user_id) when is_integer(user_id) do + message = + case message.encrypted_text_body do + nil -> + message + + encrypted -> + case Elektrine.Encryption.decrypt(encrypted, user_id) do + {:ok, text_body} -> %{message | text_body: text_body} + {:error, _} -> %{message | text_body: "[Decryption failed]"} + end + end + + case message.encrypted_html_body do + nil -> + message + + encrypted -> + case Elektrine.Encryption.decrypt(encrypted, user_id) do + {:ok, html_body} -> %{message | html_body: html_body} + {:error, _} -> %{message | html_body: "[Decryption failed]"} + end + end + end + + @doc """ + Returns true when the message body is protected by browser-unlocked mailbox encryption. + """ + def private_encrypted?(%__MODULE__{client_encrypted_payload: payload}) when is_map(payload), + do: true + + def private_encrypted?(_message), do: false + + @doc """ + Decrypts a list of email messages. + """ + def decrypt_messages(messages, user_id) when is_list(messages) and is_integer(user_id) do + Enum.map(messages, &decrypt_content(&1, user_id)) + end + + def decrypt_messages(messages, _user_id), do: messages + + defp validate_client_encrypted_payload(changeset) do + case get_field(changeset, :client_encrypted_payload) do + nil -> + changeset + + payload when is_map(payload) -> + if MailboxEncryption.valid_payload?(payload, :message) do + changeset + else + add_error(changeset, :client_encrypted_payload, "must be a valid encrypted payload") + end + + _ -> + add_error(changeset, :client_encrypted_payload, "must be a valid encrypted payload") + end + end + + defp get_attr(attrs, key), do: Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key)) + + defp put_attr(attrs, key, value) do + if Enum.any?(Map.keys(attrs), &is_atom/1) do + Map.put(attrs, key, value) + else + Map.put(attrs, Atom.to_string(key), value) + end + end + + defp delete_attr(attrs, key) do + attrs + |> Map.delete(key) + |> Map.delete(Atom.to_string(key)) + end + + # Generate a unique hash for the message if it doesn't have one + defp generate_hash_if_needed(changeset) do + case get_field(changeset, :hash) do + nil -> + hash = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) + put_change(changeset, :hash, hash) + + _ -> + changeset + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/messages.ex b/apps/elektrine_email/lib/elektrine/email/messages.ex new file mode 100644 index 0000000..68acb03 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/messages.ex @@ -0,0 +1,1776 @@ +defmodule Elektrine.Email.Messages do + @moduledoc """ + Message CRUD and query operations. + Handles message creation, retrieval, updates, deletion, and basic queries. + """ + + import Ecto.Query, warn: false + require Logger + alias Ecto.Multi + alias Elektrine.Email.{CacheHooks, Mailbox, MailboxEncryption, Message} + alias Elektrine.IMAP.RecentTracker + alias Elektrine.JMAP + alias Elektrine.Repo + alias Elektrine.Telemetry.Events + + @default_max_retained_raw_source_bytes 10 * 1024 * 1024 + + # Private helper to decrypt email messages + defp decrypt_email_messages(messages, mailbox_id) when is_list(messages) do + case Elektrine.Email.Mailboxes.get_mailbox(mailbox_id) do + %Mailbox{user_id: user_id} when not is_nil(user_id) -> + Message.decrypt_messages(messages, user_id) + + _ -> + messages + end + end + + @doc """ + Gets a single message for admin operations (bypasses ownership checks). + + WARNING: Only use this for admin operations where access control is handled + at the plug/authorization layer. For regular user operations, use get_user_message/2. + """ + def get_message_admin(id), do: Repo.get(Message, id) + + @doc """ + Gets a single message for internal system operations (bypasses ownership checks). + + WARNING: Only use this for internal background jobs and system operations that + don't involve user requests. For user-facing operations, use get_user_message/2. + + Examples: attachment processors, email cleanup jobs, system migrations. + """ + def get_message_internal(id), do: Repo.get(Message, id) + + @doc """ + Gets a single message for a specific mailbox. + Returns nil if the Message does not exist for that mailbox. + """ + def get_message(id, mailbox_id) do + message = + Message + |> where(id: ^id, mailbox_id: ^mailbox_id) + |> Repo.one() + + case message do + nil -> + nil + + msg -> + case Elektrine.Email.Mailboxes.get_mailbox(mailbox_id) do + %Mailbox{user_id: user_id} when not is_nil(user_id) -> + Message.decrypt_content(msg, user_id) + + _ -> + msg + end + end + end + + @doc """ + Gets a message by its hash. + """ + def get_message_by_hash(hash) do + message = + Message + |> where(hash: ^hash) + |> Repo.one() + + case message do + nil -> + nil + + msg -> + case Elektrine.Email.Mailboxes.get_mailbox(msg.mailbox_id) do + %Mailbox{user_id: user_id} when not is_nil(user_id) -> + Message.decrypt_content(msg, user_id) + + _ -> + msg + end + end + end + + @doc """ + Gets a message by hash for a specific user with ownership validation. + """ + def get_user_message_by_hash(hash, user_id) + when is_binary(hash) and is_integer(user_id) do + case Repo.one( + from m in Message, + join: mb in Mailbox, + on: mb.id == m.mailbox_id, + where: m.hash == ^hash, + select: {m, mb.user_id, mb.email} + ) do + {message, ^user_id, _mailbox_email} -> + {:ok, Message.decrypt_content(message, user_id)} + + {message, nil, mailbox_email} -> + case Elektrine.Email.verify_email_ownership(mailbox_email, user_id) do + {:ok, _ownership_type} -> + Logger.info("Granted access to orphaned mailbox message for verified user #{user_id}") + {:ok, Message.decrypt_content(message, user_id)} + + {:error, _reason} -> + Logger.warning("Denied access to orphaned mailbox message for user #{user_id}") + {:error, :access_denied} + end + + {_message, _other_user_id, _mailbox_email} -> + {:error, :access_denied} + + nil -> + {:error, :message_not_found} + end + end + + @doc """ + Gets a single message by its message_id for a specific mailbox. + Returns nil if the Message does not exist for that mailbox. + This is used to prevent duplicate message creation. + """ + def get_message_by_id(message_id, mailbox_id) do + case message_id_lookup_candidates(message_id) do + [] -> + nil + + candidates -> + Message + |> where([m], m.message_id in ^candidates and m.mailbox_id == ^mailbox_id) + |> Repo.one() + end + end + + @doc """ + Gets a message for a user with ownership validation. + Ensures users can only access emails sent to addresses they own. + """ + def get_user_message(message_id, user_id) when is_integer(message_id) and is_integer(user_id) do + case Repo.get(Message, message_id) do + %Message{} = message -> + # Verify user owns the mailbox this message belongs to + case Elektrine.Email.Mailboxes.get_mailbox(message.mailbox_id) do + %Mailbox{user_id: ^user_id} -> + # User owns the mailbox, access allowed - decrypt before returning + decrypted_message = Message.decrypt_content(message, user_id) + {:ok, decrypted_message} + + %Mailbox{user_id: other_user_id} when is_integer(other_user_id) -> + # Message belongs to different user + {:error, :access_denied} + + %Mailbox{user_id: nil, email: mailbox_email} -> + # Orphaned mailbox - check if user should have access via email ownership + case Elektrine.Email.verify_email_ownership(mailbox_email, user_id) do + {:ok, _ownership_type} -> + Logger.info( + "Granted access to orphaned mailbox message for verified user #{user_id}" + ) + + decrypted_message = Message.decrypt_content(message, user_id) + {:ok, decrypted_message} + + {:error, _reason} -> + Logger.warning("Denied access to orphaned mailbox message for user #{user_id}") + {:error, :access_denied} + end + + nil -> + {:error, :mailbox_not_found} + end + + nil -> + {:error, :message_not_found} + end + end + + @doc """ + Returns the list of messages for a user. + """ + def list_user_messages(user_id, limit \\ 50, offset \\ 0) do + mailbox = Elektrine.Email.Mailboxes.get_user_mailbox(user_id) + + if mailbox do + list_messages(mailbox.id, limit, offset) + else + [] + end + end + + @doc """ + Lists messages for a user with ownership validation. + Only returns messages from mailboxes the user owns. + """ + def list_user_messages_secure(user_id, limit \\ 50, offset \\ 0) when is_integer(user_id) do + # Get user's main mailbox + case Elektrine.Email.Mailboxes.get_user_mailbox(user_id) do + %Mailbox{id: mailbox_id} -> + list_messages(mailbox_id, limit, offset) + + nil -> + [] + end + end + + @doc """ + Returns the list of messages for a mailbox. + """ + def list_messages(mailbox_id, limit \\ 50, offset \\ 0) do + Message + |> where(mailbox_id: ^mailbox_id) + |> order_by(desc: :inserted_at) + |> limit(^limit) + |> offset(^offset) + |> Repo.all() + end + + @doc """ + Lists all messages in the same email thread for a mailbox, ordered oldest-first. + Includes the current message in the returned list. + """ + def list_thread_messages(%Message{} = message, mailbox_id) when is_integer(mailbox_id) do + if message.mailbox_id == mailbox_id && is_integer(message.thread_id) do + Message + |> where([m], m.mailbox_id == ^mailbox_id and m.thread_id == ^message.thread_id) + |> order_by([m], asc: m.inserted_at, asc: m.id) + |> Repo.all() + |> filter_related_thread_messages(message) + |> decrypt_email_messages(mailbox_id) + else + [] + end + end + + def list_thread_messages(_message, _mailbox_id), do: [] + + defp filter_related_thread_messages(messages, %Message{id: current_message_id}) do + identity_sets = Map.new(messages, &{&1.id, thread_identity_set(&1)}) + + if Map.has_key?(identity_sets, current_message_id) do + # Seed the connected component with the opened message AND the user's own + # sent messages. Sent replies belong to the thread (same thread_id) and are + # the user's deliberate participation, never unrelated noise to hide. Seeding + # from them keeps replies the user wrote - and any received messages chaining + # off those replies - visible even when a subject-merged thread leaves them in + # a different header component than the message that happens to be open. + seed_ids = + messages + |> Enum.filter(&(&1.status == "sent")) + |> Enum.map(& &1.id) + |> MapSet.new() + |> MapSet.put(current_message_id) + + connected_ids = collect_connected_thread_message_ids(seed_ids, identity_sets) + + Enum.filter(messages, &MapSet.member?(connected_ids, &1.id)) + else + messages + end + end + + defp collect_connected_thread_message_ids(connected_ids, identity_sets) do + expanded_ids = + Enum.reduce(identity_sets, connected_ids, fn {message_id, identity_set}, acc -> + if MapSet.member?(acc, message_id) or + connected_to_thread_component?(identity_set, acc, identity_sets) do + MapSet.put(acc, message_id) + else + acc + end + end) + + if MapSet.equal?(expanded_ids, connected_ids) do + expanded_ids + else + collect_connected_thread_message_ids(expanded_ids, identity_sets) + end + end + + defp connected_to_thread_component?(identity_set, connected_ids, identity_sets) do + Enum.any?(connected_ids, fn connected_id -> + case Map.get(identity_sets, connected_id) do + nil -> false + connected_identity_set -> not MapSet.disjoint?(identity_set, connected_identity_set) + end + end) + end + + defp thread_identity_set(message) do + [normalize_message_id(message.message_id), normalize_message_id(message.in_reply_to)] + |> Kernel.++(parse_reference_ids(message.references)) + |> Enum.reject(&blank_value?/1) + |> MapSet.new() + end + + @doc """ + Returns the list of non-spam, non-archived messages for a mailbox, excluding bulk and paper trail. + """ + def list_inbox_messages(mailbox_id, limit \\ 50, offset \\ 0) do + excluded_categories = inbox_excluded_categories(mailbox_id) + + Message + |> where(mailbox_id: ^mailbox_id, spam: false, archived: false, deleted: false) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + |> where([m], is_nil(m.category) or m.category not in ^excluded_categories) + |> where([m], is_nil(m.reply_later_at)) + |> where([m], is_nil(m.folder_id)) + |> order_by(desc: :inserted_at) + |> limit(^limit) + |> offset(^offset) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + end + + @doc """ + Returns the list of spam messages for a mailbox. + """ + def list_spam_messages(mailbox_id, limit \\ 50, offset \\ 0) do + Message + |> where(mailbox_id: ^mailbox_id, spam: true, archived: false, deleted: false) + |> where([m], is_nil(m.folder_id)) + |> order_by(desc: :inserted_at) + |> limit(^limit) + |> offset(^offset) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + end + + @doc """ + Returns the list of archived messages for a mailbox. + """ + def list_archived_messages(mailbox_id, limit \\ 50, offset \\ 0) do + Message + |> where(mailbox_id: ^mailbox_id, archived: true, deleted: false) + |> where([m], is_nil(m.folder_id)) + |> order_by(desc: :inserted_at) + |> limit(^limit) + |> offset(^offset) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + end + + @doc """ + Returns the list of unread messages for a user. + """ + def list_user_unread_messages(user_id) do + mailbox = Elektrine.Email.Mailboxes.get_user_mailbox(user_id) + + if mailbox do + list_unread_messages(mailbox.id) + else + [] + end + end + + @doc """ + Returns the list of unread messages for a mailbox. + """ + def list_unread_messages(mailbox_id) do + excluded_categories = inbox_excluded_categories(mailbox_id) + + Message + |> where(mailbox_id: ^mailbox_id, read: false, spam: false, archived: false, deleted: false) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + |> where([m], is_nil(m.category) or m.category not in ^excluded_categories) + |> where([m], is_nil(m.reply_later_at)) + |> order_by(desc: :inserted_at) + |> Repo.all() + end + + @doc """ + Creates a message. + """ + def create_message(attrs \\ %{}) do + attrs = apply_raw_source_retention(attrs) + + # Calculate message size before creation + message_size = calculate_message_size(attrs) + + mailbox_id = + attrs + |> get_attr(:mailbox_id) + |> normalize_mailbox_id() + + mailbox = + if is_integer(mailbox_id) do + Elektrine.Email.Mailboxes.get_mailbox(mailbox_id) + else + nil + end + + mailbox_user_id = + case mailbox do + %Mailbox{user_id: user_id} when is_integer(user_id) -> user_id + _ -> nil + end + + # Check storage limit using centralized user storage + storage_check = + if mailbox_user_id do + if Elektrine.Accounts.Storage.would_exceed_limit?(mailbox_user_id, message_size) do + {:error, :storage_limit_exceeded} + else + :ok + end + else + :ok + end + + case storage_check do + {:error, reason} -> + {:error, reason} + + :ok -> + # Categorize the message before inserting (only if category not already set) + status = get_attr(attrs, :status) + is_spam_message = truthy?(get_attr(attrs, :spam)) + categorize_opts = categorization_opts(mailbox, mailbox_user_id) + + categorized_attrs = + if status != "sent" and !is_spam_message do + # Only categorize incoming messages, not sent messages + # Skip categorization if category is already explicitly set + has_category = Map.has_key?(attrs, :category) or Map.has_key?(attrs, "category") + + categorized = + if has_category, + do: %{}, + else: Elektrine.Email.Processing.categorize_message(attrs, categorize_opts) + + # Merge categorization results, using the same key type as input + # Detect key type by checking if any key is an atom + has_atom_keys = Enum.any?(Map.keys(attrs), &is_atom/1) + + if has_atom_keys do + # Atom keys - only set if not already present + attrs + |> Map.put_new(:category, categorized["category"] || "inbox") + |> Map.put_new(:is_newsletter, categorized["is_newsletter"] || false) + |> Map.put_new(:is_receipt, categorized["is_receipt"] || false) + |> Map.put_new(:is_notification, categorized["is_notification"] || false) + else + # String keys - only set if not already present + attrs + |> Map.put_new("category", categorized["category"] || "inbox") + |> Map.put_new("is_newsletter", categorized["is_newsletter"] || false) + |> Map.put_new("is_receipt", categorized["is_receipt"] || false) + |> Map.put_new("is_notification", categorized["is_notification"] || false) + end + else + attrs + end + + # Normalize and enrich threading fields before insert. + threaded_attrs = + categorized_attrs + |> normalize_threading_fields() + |> maybe_build_thread_references(mailbox_id) + |> maybe_assign_thread(mailbox_id) + + with {:ok, stored_attrs} <- + prepare_storage_attrs(threaded_attrs, mailbox, mailbox_user_id), + {:ok, message} <- + %Message{} + |> Message.changeset(stored_attrs) + |> Repo.insert() do + # LiveViews reload immediately when they receive :new_email, so invalidate + # cached mailbox lists/counts before broadcasting the new message. + CacheHooks.with_cache_invalidation({:ok, message}) + + bump_message_creation_states(mailbox_id, message.id) + RecentTracker.mark_message_recent(message) + + if mailbox_id do + # Broadcast to any LiveViews monitoring this mailbox + Phoenix.PubSub.broadcast!( + Elektrine.PubSub, + "mailbox:#{mailbox_id}", + {:new_email, message} + ) + + # Also broadcast to user topic for notifications + if mailbox_user_id do + Phoenix.PubSub.broadcast!( + Elektrine.PubSub, + "user:#{mailbox_user_id}", + {:new_email, message} + ) + + # Only create notifications for actual incoming mail, not self-originated inbox copies. + if threaded_attrs[:status] != "sent" && threaded_attrs["status"] != "sent" && + !self_originated_email_attrs?(threaded_attrs, mailbox_user_id) do + # Create notification for new email if user has enabled it + user = Elektrine.Accounts.get_user!(mailbox_user_id) + + if Map.get(user, :notify_on_email_received, true) do + {title, body} = email_notification_content(mailbox, threaded_attrs) + + Elektrine.Notifications.create_notification(%{ + user_id: mailbox_user_id, + type: "email_received", + title: title, + body: body, + url: Elektrine.Paths.email_view_path(message), + source_type: "email", + source_id: message.id, + priority: "normal" + }) + end + end + end + end + + # Decrypt message before returning + decrypted_message = + if mailbox_user_id do + Message.decrypt_content(message, mailbox_user_id) + else + message + end + + {:ok, decrypted_message} + else + {:error, reason} -> + case maybe_resolve_duplicate_message_insert( + reason, + threaded_attrs, + mailbox_id, + mailbox_user_id + ) do + {:ok, message} -> + CacheHooks.with_cache_invalidation({:ok, message}) + + :error -> + {:error, reason} + end + + error -> + error + end + end + end + + @doc """ + Updates a message. + """ + def update_message(%Message{} = message, attrs) do + was_unread = !message.read + mailbox_id = message.mailbox_id + + result = + message + |> Message.changeset(attrs) + |> Repo.update() + |> CacheHooks.with_cache_invalidation() + + # Broadcast unread count update if the operation might affect unread status + # (message was unread and is being moved, deleted, archived, etc.) + if was_unread and match?({:ok, _}, result) do + # Check if the attrs change visibility of the message (deleted, archived, spam) + should_broadcast = + Map.has_key?(attrs, :deleted) or + Map.has_key?(attrs, :archived) or + Map.has_key?(attrs, :spam) or + Map.has_key?(attrs, :read) + + if should_broadcast do + broadcast_unread_count_update(mailbox_id) + end + end + + if match?({:ok, _}, result) do + bump_states_for_update(mailbox_id, message.id, attrs) + end + + result + end + + @doc """ + Saves a draft message. Creates new if draft_id is nil, otherwise updates existing draft. + """ + def save_draft(attrs, draft_id \\ nil) do + # Ensure status is draft + attrs = Map.put(attrs, :status, "draft") + + # Generate a unique message_id if not present + attrs = + if Map.get(attrs, :message_id) || Map.get(attrs, "message_id") do + attrs + else + Map.put( + attrs, + :message_id, + Elektrine.EmailAddresses.message_id("draft-#{Ecto.UUID.generate()}") + ) + end + + if draft_id do + # Update existing draft + case Repo.get(Message, draft_id) do + nil -> + {:error, :not_found} + + draft -> + if draft.status == "draft" do + mailbox = Elektrine.Email.Mailboxes.get_mailbox(draft.mailbox_id) + + mailbox_user_id = + case mailbox do + %Mailbox{user_id: user_id} when is_integer(user_id) -> user_id + _ -> nil + end + + case prepare_storage_attrs(attrs, mailbox, mailbox_user_id) do + {:ok, stored_attrs} -> + result = + draft + |> Message.changeset(stored_attrs) + |> Repo.update() + |> CacheHooks.with_cache_invalidation() + + if match?({:ok, _}, result) do + bump_states_for_update(draft.mailbox_id, draft.id, stored_attrs) + end + + result + + {:error, reason} -> + {:error, reason} + end + else + {:error, :not_a_draft} + end + end + else + # Create new draft + create_message(attrs) + end + end + + @doc """ + Gets a draft message by ID for a specific mailbox. + """ + def get_draft(draft_id, mailbox_id) do + Message + |> where([m], m.id == ^draft_id and m.mailbox_id == ^mailbox_id and m.status == "draft") + |> Repo.one() + end + + @doc """ + Deletes a draft message. + """ + def delete_draft(draft_id, mailbox_id) do + case get_draft(draft_id, mailbox_id) do + nil -> + {:error, :not_found} + + draft -> + result = + Multi.new() + |> Multi.delete(:draft, draft) + |> Multi.run(:tombstone, fn _repo, _changes -> + case JMAP.record_email_destroyed(mailbox_id, draft.id, ["Mailbox", "Thread"]) do + :ok -> {:ok, :recorded} + other -> other + end + end) + |> Repo.transaction() + |> case do + {:ok, %{draft: deleted_draft}} -> {:ok, deleted_draft} + {:error, _step, reason, _changes} -> {:error, reason} + end + + result + end + end + + defp prepare_storage_attrs(attrs, %Mailbox{} = mailbox, mailbox_user_id) do + attrs = apply_raw_source_retention(attrs) + + cond do + MailboxEncryption.enabled?(mailbox) -> + MailboxEncryption.encrypt_message(attrs, mailbox) + + mailbox_user_id -> + {:ok, Message.encrypt_content(attrs, mailbox_user_id)} + + true -> + {:ok, attrs} + end + end + + defp prepare_storage_attrs(attrs, _mailbox, mailbox_user_id) when is_integer(mailbox_user_id) do + {:ok, attrs |> apply_raw_source_retention() |> Message.encrypt_content(mailbox_user_id)} + end + + defp prepare_storage_attrs(attrs, _mailbox, _mailbox_user_id), + do: {:ok, apply_raw_source_retention(attrs)} + + defp email_notification_content(%Mailbox{} = mailbox, attrs) do + if MailboxEncryption.enabled?(mailbox) do + {"New encrypted email", "Unlock your mailbox to view this message."} + else + from_email = attrs[:from] || attrs["from"] || "Unknown sender" + subject = notification_subject(mailbox, attrs) || "(No subject)" + + {"Email from #{from_email}", subject} + end + end + + defp email_notification_content(_mailbox, attrs) do + from_email = attrs[:from] || attrs["from"] || "Unknown sender" + subject = attrs[:subject] || attrs["subject"] || "(No subject)" + + {"Email from #{from_email}", subject} + end + + defp maybe_resolve_duplicate_message_insert( + %Ecto.Changeset{} = changeset, + attrs, + mailbox_id, + mailbox_user_id + ) + when is_integer(mailbox_id) do + if duplicate_message_id_conflict?(changeset) do + case get_attr(attrs, :message_id) do + message_id when is_binary(message_id) -> + case get_message_by_id(message_id, mailbox_id) do + %Message{} = message -> + resolved_message = + if mailbox_user_id do + Message.decrypt_content(message, mailbox_user_id) + else + message + end + + {:ok, resolved_message} + + _ -> + :error + end + + _ -> + :error + end + else + :error + end + end + + defp maybe_resolve_duplicate_message_insert(_reason, _attrs, _mailbox_id, _mailbox_user_id), + do: :error + + defp duplicate_message_id_conflict?(%Ecto.Changeset{errors: errors}) do + Enum.any?(errors, fn + {:message_id, {_message, opts}} -> + opts[:constraint] == :unique and + opts[:constraint_name] == "email_messages_message_id_mailbox_id_index" + + _ -> + false + end) + end + + defp notification_subject(%Mailbox{} = mailbox, attrs) do + if MailboxEncryption.enabled?(mailbox) do + MailboxEncryption.placeholder_subject() + else + get_attr(attrs, :subject) + end + end + + defp self_originated_email_attrs?(attrs, mailbox_user_id) when is_map(attrs) do + self_email_attrs?(attrs) || sender_owned_by_mailbox_user?(attrs, mailbox_user_id) + end + + defp self_email_attrs?(attrs) when is_map(attrs) do + metadata = get_attr(attrs, :metadata) + + case metadata do + metadata when is_map(metadata) -> + Map.get(metadata, :self_email) == true || Map.get(metadata, "self_email") == true + + _ -> + false + end + end + + defp sender_owned_by_mailbox_user?(attrs, mailbox_user_id) when is_integer(mailbox_user_id) do + case get_attr(attrs, :from) do + from when is_binary(from) -> + sender_email = Elektrine.Email.extract_email_address(from) + + case Elektrine.Email.verify_email_ownership(sender_email, mailbox_user_id) do + {:ok, _} -> true + _ -> false + end + + _ -> + false + end + end + + defp sender_owned_by_mailbox_user?(_attrs, _mailbox_user_id), do: false + + @doc """ + Updates message attachments (for storage management). + """ + def update_message_attachments(%Message{} = message, attachments, has_attachments) do + update_message(message, %{attachments: attachments, has_attachments: has_attachments}) + end + + @doc """ + Marks a message as read. + """ + def mark_as_read(%Message{} = message) do + result = + message + |> Message.read_changeset() + |> Repo.update() + + case result do + {:ok, updated_message} -> + # Get the mailbox to find the user_id + mailbox = Elektrine.Email.Mailboxes.get_mailbox(updated_message.mailbox_id) + + if mailbox && mailbox.user_id do + # Get the new unread count + new_unread_count = unread_count(updated_message.mailbox_id) + + # Broadcast the unread count update + broadcast_user_unread_count_update(mailbox.user_id, new_unread_count) + + # Clear email notification when email is marked as read + Elektrine.Notifications.mark_as_read_by_source( + mailbox.user_id, + "email", + updated_message.id + ) + end + + CacheHooks.with_cache_invalidation({:ok, updated_message}, + user_id: mailbox && mailbox.user_id + ) + |> maybe_bump_mailbox_email_states(updated_message.mailbox_id) + + error -> + error + end + end + + @doc """ + Marks a message as unread. + """ + def mark_as_unread(%Message{} = message) do + result = + message + |> Message.unread_changeset() + |> Repo.update() + + case result do + {:ok, updated_message} -> + # Get the mailbox to find the user_id + mailbox = Elektrine.Email.Mailboxes.get_mailbox(updated_message.mailbox_id) + + if mailbox && mailbox.user_id do + # Get the new unread count + new_unread_count = unread_count(updated_message.mailbox_id) + + # Broadcast the unread count update + broadcast_user_unread_count_update(mailbox.user_id, new_unread_count) + end + + CacheHooks.with_cache_invalidation({:ok, updated_message}, + user_id: mailbox && mailbox.user_id + ) + |> maybe_bump_mailbox_email_states(updated_message.mailbox_id) + + error -> + error + end + end + + @doc """ + Marks a message as spam. + """ + def mark_as_spam(%Message{} = message) do + result = + message + |> Message.spam_changeset() + |> Repo.update() + |> CacheHooks.with_cache_invalidation() + + maybe_bump_mailbox_email_states(result, message.mailbox_id) + end + + @doc """ + Marks a message as not spam. + """ + def mark_as_not_spam(%Message{} = message) do + result = + message + |> Message.unspam_changeset() + |> Repo.update() + |> CacheHooks.with_cache_invalidation() + + maybe_bump_mailbox_email_states(result, message.mailbox_id) + end + + @doc """ + Archives a message. + """ + def archive_message(%Message{} = message) do + was_unread = !message.read + mailbox_id = message.mailbox_id + + result = + message + |> Message.archive_changeset() + |> Repo.update() + |> CacheHooks.with_cache_invalidation() + + # Broadcast unread count update if the message was unread + if was_unread and match?({:ok, _}, result) do + broadcast_unread_count_update(mailbox_id) + end + + maybe_bump_mailbox_email_states(result, mailbox_id) + + result + end + + @doc """ + Unarchives a message. + """ + def unarchive_message(%Message{} = message) do + was_unread = !message.read + mailbox_id = message.mailbox_id + + result = + message + |> Message.unarchive_changeset() + |> Repo.update() + |> CacheHooks.with_cache_invalidation() + + # Broadcast unread count update if the message was unread + if was_unread and match?({:ok, _}, result) do + broadcast_unread_count_update(mailbox_id) + end + + maybe_bump_mailbox_email_states(result, mailbox_id) + + result + end + + @doc """ + Moves a message to trash (soft delete). + """ + def trash_message(%Message{} = message) do + was_unread = !message.read + mailbox_id = message.mailbox_id + + result = + message + |> Message.trash_changeset() + |> Repo.update() + |> CacheHooks.with_cache_invalidation() + + # Broadcast unread count update if the message was unread + if was_unread and match?({:ok, _}, result) do + broadcast_unread_count_update(mailbox_id) + end + + maybe_bump_mailbox_email_states(result, mailbox_id) + + result + end + + @doc """ + Restores a message from trash. + """ + def untrash_message(%Message{} = message) do + was_unread = !message.read + mailbox_id = message.mailbox_id + + result = + message + |> Message.untrash_changeset() + |> Repo.update() + |> CacheHooks.with_cache_invalidation() + + # Broadcast unread count update if the message was unread + if was_unread and match?({:ok, _}, result) do + broadcast_unread_count_update(mailbox_id) + end + + maybe_bump_mailbox_email_states(result, mailbox_id) + + result + end + + @doc """ + Permanently deletes a message (hard delete). + Can accept either a Message struct or an integer ID. + """ + def delete_message(%Message{} = message) do + # Store info before deletion + was_unread = !message.read + mailbox_id = message.mailbox_id + + result = + Multi.new() + |> Multi.delete(:message, message) + |> Multi.run(:tombstone, fn _repo, _changes -> + case JMAP.record_email_destroyed(mailbox_id, message.id, ["Mailbox", "Thread"]) do + :ok -> {:ok, :recorded} + other -> other + end + end) + |> Repo.transaction() + |> case do + {:ok, %{message: deleted_message}} -> {:ok, deleted_message} + {:error, _step, reason, _changes} -> {:error, reason} + end + + case result do + {:ok, deleted_message} -> + mailbox = Elektrine.Email.Mailboxes.get_mailbox(mailbox_id) + + # Broadcast unread count update if the deleted message was unread + if was_unread && mailbox && mailbox.user_id do + # Get the new unread count + new_unread_count = unread_count(mailbox_id) + + # Broadcast the unread count update + broadcast_user_unread_count_update(mailbox.user_id, new_unread_count) + end + + # Always broadcast message deletion for IMAP sync (even for read messages) + if mailbox && mailbox.user_id do + Phoenix.PubSub.broadcast!( + Elektrine.PubSub, + "mailbox:#{mailbox_id}", + {:message_deleted, deleted_message.id} + ) + end + + # Invalidate caches after deletion + user_id = if mailbox, do: mailbox.user_id + + result = + CacheHooks.with_cache_invalidation(result, user_id: user_id, mailbox_id: mailbox_id) + + result + + error -> + error + end + end + + # Overload for integer message IDs (for POP3 DELE command) + def delete_message(message_id) when is_integer(message_id) do + case Repo.get(Message, message_id) do + nil -> + {:error, :not_found} + + message -> + delete_message(message) + end + end + + @doc """ + Deletes a message with mailbox ownership verification (secure version for IMAP). + Returns {:error, :not_found} if message doesn't belong to the mailbox. + """ + def delete_message(message_id, mailbox_id) + when is_integer(message_id) and is_integer(mailbox_id) do + case get_message(message_id, mailbox_id) do + nil -> + {:error, :not_found} + + message -> + delete_message(message) + end + end + + @doc """ + Tracks when a message is opened. + """ + def track_message_open(%Message{} = message) do + message + |> Message.track_open_changeset() + |> Repo.update() + end + + @doc """ + Returns the unread message count for a user. + """ + def user_unread_count(user_id) do + mailbox = Elektrine.Email.Mailboxes.get_user_mailbox(user_id) + + if mailbox do + unread_count(mailbox.id) + else + 0 + end + end + + @doc """ + Returns the unread message count for a mailbox. + """ + def unread_count(mailbox_id) do + started_at = System.monotonic_time(:millisecond) + excluded_categories = inbox_excluded_categories(mailbox_id) + + count = + Message + |> where(mailbox_id: ^mailbox_id, read: false, spam: false, archived: false, deleted: false) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + |> where([m], is_nil(m.category) or m.category not in ^excluded_categories) + |> where([m], is_nil(m.reply_later_at)) + |> Repo.aggregate(:count) + + emit_db_hot_path(:email_messages, :unread_count, started_at, %{mailbox_id: mailbox_id}) + count + end + + # Broadcasts an unread count update for a mailbox. + # Helper function to be called after operations that affect unread status. + defp broadcast_unread_count_update(mailbox_id) do + mailbox = Elektrine.Email.Mailboxes.get_mailbox(mailbox_id) + + if mailbox && mailbox.user_id do + new_unread_count = unread_count(mailbox_id) + + broadcast_user_unread_count_update(mailbox.user_id, new_unread_count) + end + end + + defp broadcast_user_unread_count_update(user_id, new_unread_count) do + Phoenix.PubSub.broadcast!( + Elektrine.PubSub, + "user:#{user_id}", + {:unread_count_updated, new_unread_count} + ) + + Phoenix.PubSub.broadcast!( + Elektrine.PubSub, + "user:#{user_id}:notification_count", + {:email_unread_count_updated, new_unread_count} + ) + end + + @doc """ + Returns the unread feed message count for a mailbox. + """ + def unread_feed_count(mailbox_id) do + if category_filter_enabled?(mailbox_id, "feed") do + unread_category_count(mailbox_id, "feed") + else + 0 + end + end + + @doc """ + Returns the unread paper trail message count for a mailbox. + """ + def unread_ledger_count(mailbox_id) do + if category_filter_enabled?(mailbox_id, "ledger") do + unread_category_count(mailbox_id, "ledger") + else + 0 + end + end + + @doc """ + Returns the unread set aside ("the pile") message count for a mailbox. + """ + def unread_stack_count(mailbox_id) do + Message + |> where(mailbox_id: ^mailbox_id, read: false, spam: false, archived: false, deleted: false) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status)) + |> where(category: "stack") + |> Repo.aggregate(:count) + end + + @doc """ + Returns the unread reply later ("boomerang") message count for a mailbox. + """ + def unread_reply_later_count(mailbox_id) do + Message + |> where(mailbox_id: ^mailbox_id, read: false, spam: false, archived: false, deleted: false) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status)) + |> where([m], not is_nil(m.reply_later_at)) + |> Repo.aggregate(:count) + end + + @doc """ + Returns the unread inbox message count for a mailbox. + This counts messages that are in the main inbox (not in special categories). + """ + def unread_inbox_count(mailbox_id) do + excluded_categories = inbox_excluded_categories(mailbox_id) + + Message + |> where(mailbox_id: ^mailbox_id, read: false, spam: false, archived: false, deleted: false) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status) or m.from == m.to) + |> where([m], is_nil(m.category) or m.category not in ^excluded_categories) + |> where([m], is_nil(m.reply_later_at)) + |> where([m], is_nil(m.folder_id)) + |> Repo.aggregate(:count) + end + + @doc """ + Returns all unread counts for a mailbox in a single query. + This ensures consistency by fetching all counts from the same database snapshot. + + Returns a map with keys: :inbox, :feed, :ledger, :stack, :reply_later + """ + def get_all_unread_counts(mailbox_id) do + started_at = System.monotonic_time(:millisecond) + + result = %{ + inbox: unread_inbox_count(mailbox_id), + feed: unread_feed_count(mailbox_id), + ledger: unread_ledger_count(mailbox_id), + stack: unread_stack_count(mailbox_id), + reply_later: unread_reply_later_count(mailbox_id) + } + + emit_db_hot_path(:email_messages, :get_all_unread_counts, started_at, %{ + mailbox_id: mailbox_id + }) + + result + end + + defp unread_category_count(mailbox_id, category) do + Message + |> where(mailbox_id: ^mailbox_id, read: false, spam: false, archived: false, deleted: false) + |> where([m], m.status not in ["sent", "draft"] or is_nil(m.status)) + |> where(category: ^category) + |> Repo.aggregate(:count) + end + + defp inbox_excluded_categories(mailbox_id) do + mailbox_id + |> Elektrine.Email.Mailboxes.get_mailbox() + |> Mailbox.enabled_category_filter_categories() + |> Kernel.++(["stack"]) + end + + defp category_filter_enabled?(mailbox_id, category) do + mailbox_id + |> Elektrine.Email.Mailboxes.get_mailbox() + |> Mailbox.enabled_category_filter_categories() + |> Enum.member?(category) + end + + defp categorization_opts(%Mailbox{} = mailbox, user_id) do + [ + enabled_category_filters: Mailbox.enabled_category_filter_categories(mailbox) + ] + |> maybe_add_user_id(user_id) + end + + defp categorization_opts(_mailbox, user_id), do: maybe_add_user_id([], user_id) + + defp maybe_add_user_id(opts, user_id) when is_integer(user_id), + do: Keyword.put(opts, :user_id, user_id) + + defp maybe_add_user_id(opts, _user_id), do: opts + + defp emit_db_hot_path(component, operation, started_at, metadata) do + Events.db_hot_path( + component, + operation, + System.monotonic_time(:millisecond) - started_at, + metadata + ) + end + + @doc """ + Updates message flags for IMAP. + Accepts either a message struct or a message_id integer. + """ + def update_message_flags(%Message{} = message, updates) do + update_message_flags(message.id, updates) + end + + def update_message_flags(message_id, updates) when is_integer(message_id) do + case Repo.get(Message, message_id) do + nil -> + {:error, :not_found} + + message -> + changeset = Message.changeset(message, updates) + + mailbox_id = message.mailbox_id + mailbox = Elektrine.Email.Mailboxes.get_mailbox(mailbox_id) + user_id = if mailbox, do: mailbox.user_id + + case Repo.update(changeset) do + {:ok, updated_message} -> + result = + CacheHooks.with_cache_invalidation({:ok, updated_message}, + user_id: user_id, + mailbox_id: mailbox_id + ) + + bump_states_for_update(mailbox_id, updated_message.id, updates) + result + + {:error, changeset} -> + {:error, changeset} + end + end + end + + @doc """ + Updates message flags with mailbox ownership verification (secure version for IMAP). + Returns {:error, :access_denied} if message doesn't belong to the mailbox. + Accepts either a message struct or a message_id integer. + """ + def update_message_flags(%Message{} = message, mailbox_id, updates) do + update_message_flags(message.id, mailbox_id, updates) + end + + def update_message_flags(message_id, mailbox_id, updates) + when is_integer(message_id) and is_integer(mailbox_id) do + case get_message(message_id, mailbox_id) do + nil -> + {:error, :not_found} + + message -> + changeset = Message.changeset(message, updates) + + result = + case Repo.update(changeset) do + {:ok, updated_message} -> + # Invalidate caches and broadcast to LiveView + mailbox = Elektrine.Email.Mailboxes.get_mailbox(mailbox_id) + user_id = if mailbox, do: mailbox.user_id + + # Clear email notification when message is marked as read via IMAP + if Map.get(updates, :read) == true && !message.read && user_id do + Elektrine.Notifications.mark_as_read_by_source( + user_id, + "email", + updated_message.id + ) + + # Broadcast the unread count update + new_unread_count = unread_count(mailbox_id) + broadcast_user_unread_count_update(user_id, new_unread_count) + end + + # Broadcast message update to webmail for IMAP sync + # This ensures webmail reflects flag changes (read, flagged, deleted, etc.) + if user_id do + Phoenix.PubSub.broadcast!( + Elektrine.PubSub, + "user:#{user_id}", + {:message_flags_updated, %{message_id: updated_message.id, updates: updates}} + ) + + # Also broadcast generic refresh event for better compatibility + Phoenix.PubSub.broadcast!( + Elektrine.PubSub, + "mailbox:#{mailbox_id}", + {:message_updated, updated_message} + ) + end + + CacheHooks.with_cache_invalidation({:ok, updated_message}, + user_id: user_id, + mailbox_id: mailbox_id + ) + |> tap(fn _ -> bump_states_for_update(mailbox_id, updated_message.id, updates) end) + + {:error, changeset} -> + {:error, changeset} + end + + result + end + end + + @doc """ + Calculate the storage size of a message in bytes. + Includes subject, body content, and attachments. + """ + def calculate_message_size(message_attrs) do + subject_size = byte_size(get_field_value(message_attrs, "subject", :subject) || "") + text_body_size = byte_size(get_field_value(message_attrs, "text_body", :text_body) || "") + html_body_size = byte_size(get_field_value(message_attrs, "html_body", :html_body) || "") + from_size = byte_size(get_field_value(message_attrs, "from", :from) || "") + to_size = byte_size(get_field_value(message_attrs, "to", :to) || "") + cc_size = byte_size(get_field_value(message_attrs, "cc", :cc) || "") + bcc_size = byte_size(get_field_value(message_attrs, "bcc", :bcc) || "") + raw_source_size = byte_size(get_field_value(message_attrs, "raw_source", :raw_source) || "") + + # Calculate attachments size + attachments = get_field_value(message_attrs, "attachments", :attachments) || %{} + + attachments_size = + if is_map(attachments) do + attachments + |> Map.values() + |> Enum.reduce(0, fn attachment, acc -> + case attachment do + %{"size" => size} when is_integer(size) -> acc + size + %{size: size} when is_integer(size) -> acc + size + _ -> acc + end + end) + else + 0 + end + + subject_size + text_body_size + html_body_size + from_size + to_size + cc_size + bcc_size + + raw_source_size + attachments_size + end + + @doc "Returns the maximum raw RFC822 source size retained with a message." + def max_retained_raw_source_bytes do + :elektrine + |> Application.get_env(:email, []) + |> Keyword.get(:max_retained_raw_source_bytes, @default_max_retained_raw_source_bytes) + |> case do + value when is_integer(value) and value > 0 -> value + _ -> @default_max_retained_raw_source_bytes + end + end + + defp apply_raw_source_retention(attrs) when is_map(attrs) do + case get_attr(attrs, :raw_source) do + raw_source when is_binary(raw_source) and byte_size(raw_source) > 0 -> + source_size = byte_size(raw_source) + limit = max_retained_raw_source_bytes() + + if source_size <= limit do + attrs + else + attrs + |> delete_attr(:raw_source) + |> put_raw_source_omission_metadata(source_size, limit) + end + + _ -> + attrs + end + end + + defp put_raw_source_omission_metadata(attrs, source_size, limit) do + metadata = get_attr(attrs, :metadata) || %{} + + metadata = + metadata + |> Map.put("raw_source_retained", false) + |> Map.put("raw_source_original_bytes", source_size) + |> Map.put("raw_source_retention_limit_bytes", limit) + |> Map.put("raw_source_omitted_reason", "size_limit") + + put_attr(attrs, :metadata, metadata) + end + + defp normalize_mailbox_id(mailbox_id) when is_integer(mailbox_id), do: mailbox_id + + defp normalize_mailbox_id(mailbox_id) when is_binary(mailbox_id) do + case Integer.parse(mailbox_id) do + {parsed, ""} -> parsed + _ -> nil + end + end + + defp normalize_mailbox_id(_), do: nil + + defp normalize_threading_fields(attrs) do + attrs + |> maybe_put_attr(:message_id, normalize_message_id(get_attr(attrs, :message_id))) + |> maybe_put_attr(:in_reply_to, normalize_message_id(get_attr(attrs, :in_reply_to))) + |> maybe_put_attr(:references, normalize_references(get_attr(attrs, :references))) + end + + defp maybe_build_thread_references(attrs, mailbox_id) when is_integer(mailbox_id) do + in_reply_to = normalize_message_id(get_attr(attrs, :in_reply_to)) + existing_references = normalize_references(get_attr(attrs, :references)) + + cond do + blank_value?(in_reply_to) -> + attrs + + !blank_value?(existing_references) -> + merged_refs = + existing_references + |> parse_reference_ids() + |> Kernel.++([in_reply_to]) + |> Enum.uniq() + |> Enum.join(" ") + + put_attr(attrs, :references, merged_refs) + + true -> + parent_refs = + case load_parent_message_for_threading(mailbox_id, in_reply_to) do + nil -> + [] + + parent -> + parent_message_id = normalize_message_id(parent.message_id) + inherited_refs = parse_reference_ids(parent.references) + + if blank_value?(parent_message_id) do + inherited_refs + else + inherited_refs ++ [parent_message_id] + end + end + + merged_refs = + parent_refs + |> Kernel.++([in_reply_to]) + |> Enum.reject(&blank_value?/1) + |> Enum.uniq() + |> Enum.join(" ") + + if blank_value?(merged_refs) do + attrs + else + put_attr(attrs, :references, merged_refs) + end + end + end + + defp maybe_build_thread_references(attrs, _mailbox_id), do: attrs + + defp maybe_assign_thread(attrs, mailbox_id) + when is_integer(mailbox_id) and mailbox_id > 0 do + case get_attr(attrs, :thread_id) do + thread_id when is_integer(thread_id) and thread_id > 0 -> + attrs + + _ -> + case Elektrine.JMAP.assign_thread(attrs, mailbox_id) do + {:ok, thread_id} when is_integer(thread_id) and thread_id > 0 -> + put_attr(attrs, :thread_id, thread_id) + + _ -> + attrs + end + end + end + + defp maybe_assign_thread(attrs, _mailbox_id), do: attrs + + defp load_parent_message_for_threading(mailbox_id, in_reply_to) do + candidates = message_id_lookup_candidates(in_reply_to) + + if candidates == [] do + nil + else + Message + |> where([m], m.mailbox_id == ^mailbox_id and m.message_id in ^candidates) + |> order_by([m], desc: m.inserted_at) + |> limit(1) + |> Repo.one() + end + end + + defp message_id_lookup_candidates(nil), do: [] + defp message_id_lookup_candidates(""), do: [] + + defp message_id_lookup_candidates(message_id) do + case normalize_message_id(message_id) do + nil -> [] + normalized -> Enum.uniq([normalized, "<#{normalized}>"]) + end + end + + defp normalize_message_id(nil), do: nil + defp normalize_message_id(""), do: nil + + defp normalize_message_id(message_id) when is_binary(message_id) do + message_id + |> String.trim() + |> String.replace(~r/^<|>$/, "") + |> case do + "" -> nil + normalized -> normalized + end + end + + defp normalize_message_id(message_id), do: normalize_message_id(to_string(message_id)) + + defp normalize_references(nil), do: nil + defp normalize_references(""), do: nil + + defp normalize_references(references) when is_list(references) do + references + |> Enum.map(&normalize_message_id/1) + |> Enum.reject(&blank_value?/1) + |> Enum.uniq() + |> case do + [] -> nil + refs -> Enum.join(refs, " ") + end + end + + defp normalize_references(references) when is_binary(references) do + references + |> String.split(~r/[\s,]+/, trim: true) + |> Enum.map(&normalize_message_id/1) + |> Enum.reject(&blank_value?/1) + |> Enum.uniq() + |> case do + [] -> nil + refs -> Enum.join(refs, " ") + end + end + + defp normalize_references(references), do: normalize_references(to_string(references)) + + defp parse_reference_ids(nil), do: [] + defp parse_reference_ids(""), do: [] + + defp parse_reference_ids(references) when is_binary(references) do + references + |> String.split(~r/[\s,]+/, trim: true) + |> Enum.reject(&blank_value?/1) + end + + defp parse_reference_ids(references) when is_list(references) do + references + |> Enum.map(&normalize_message_id/1) + |> Enum.reject(&blank_value?/1) + end + + defp parse_reference_ids(_), do: [] + + defp truthy?(value) when is_binary(value) do + normalized = + value + |> String.trim() + |> String.downcase() + + Enum.member?(["true", "1", "yes", "on"], normalized) + end + + defp truthy?(value), do: value == true + + defp get_attr(attrs, atom_key) when is_map(attrs) and is_atom(atom_key) do + Map.get(attrs, atom_key) || Map.get(attrs, Atom.to_string(atom_key)) + end + + defp put_attr(attrs, atom_key, value) when is_map(attrs) and is_atom(atom_key) do + string_key = Atom.to_string(atom_key) + + cond do + Map.has_key?(attrs, atom_key) -> + Map.put(attrs, atom_key, value) + + Map.has_key?(attrs, string_key) -> + Map.put(attrs, string_key, value) + + Enum.any?(Map.keys(attrs), &is_atom/1) -> + Map.put(attrs, atom_key, value) + + true -> + Map.put(attrs, string_key, value) + end + end + + defp delete_attr(attrs, atom_key) when is_map(attrs) and is_atom(atom_key) do + attrs + |> Map.delete(atom_key) + |> Map.delete(Atom.to_string(atom_key)) + end + + defp maybe_put_attr(attrs, atom_key, value) do + if blank_value?(value) do + attrs + else + put_attr(attrs, atom_key, value) + end + end + + defp blank_value?(value) when is_nil(value), do: true + defp blank_value?(value) when is_binary(value), do: not Elektrine.Strings.present?(value) + defp blank_value?(_), do: false + + # Helper function to safely get field values from string or atom keys + defp get_field_value(attrs, string_key, atom_key) do + Map.get(attrs, string_key) || Map.get(attrs, atom_key) + end + + defp maybe_bump_mailbox_email_states({:ok, %Message{id: message_id} = result}, mailbox_id) + when is_integer(mailbox_id) do + bump_mailbox_email_states(mailbox_id, message_id) + {:ok, result} + end + + defp maybe_bump_mailbox_email_states(result, _mailbox_id), do: result + + defp bump_states_for_update(mailbox_id, message_id, attrs) + when is_integer(mailbox_id) and is_integer(message_id) and is_map(attrs) do + entity_types = + [] + |> maybe_add_mailbox_state(attrs) + |> maybe_add_thread_state(attrs) + |> Enum.uniq() + + JMAP.record_email_updated(mailbox_id, message_id, entity_types) + end + + defp bump_states_for_update(_mailbox_id, _message_id, _attrs), do: :ok + + defp maybe_add_mailbox_state(entity_types, attrs) do + if Enum.any?( + ~w(read spam archived deleted category status folder_id), + &attrs_has_key?(attrs, &1) + ) do + ["Mailbox" | entity_types] + else + entity_types + end + end + + defp maybe_add_thread_state(entity_types, attrs) do + if attrs_has_key?(attrs, "thread_id") do + ["Thread" | entity_types] + else + entity_types + end + end + + defp attrs_has_key?(attrs, key) when is_binary(key) do + Map.has_key?(attrs, key) or Map.has_key?(attrs, message_state_atom_key(key)) + end + + defp message_state_atom_key("read"), do: :read + defp message_state_atom_key("spam"), do: :spam + defp message_state_atom_key("archived"), do: :archived + defp message_state_atom_key("deleted"), do: :deleted + defp message_state_atom_key("category"), do: :category + defp message_state_atom_key("status"), do: :status + defp message_state_atom_key("folder_id"), do: :folder_id + defp message_state_atom_key("thread_id"), do: :thread_id + defp message_state_atom_key(_), do: nil + + defp bump_message_creation_states(mailbox_id, message_id) + when is_integer(mailbox_id) and is_integer(message_id) do + JMAP.record_email_created(mailbox_id, message_id, ["Mailbox", "Thread"]) + end + + defp bump_message_creation_states(_mailbox_id, _message_id), do: :ok + + defp bump_mailbox_email_states(mailbox_id, message_id) + when is_integer(mailbox_id) and is_integer(message_id) do + JMAP.record_email_updated(mailbox_id, message_id, ["Mailbox"]) + end + + defp bump_mailbox_email_states(_mailbox_id, _message_id), do: :ok +end diff --git a/apps/elektrine_email/lib/elektrine/email/mime_body_extractor.ex b/apps/elektrine_email/lib/elektrine/email/mime_body_extractor.ex new file mode 100644 index 0000000..8761847 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/mime_body_extractor.ex @@ -0,0 +1,105 @@ +defmodule Elektrine.Email.MimeBodyExtractor do + @moduledoc """ + Extracts displayable text and HTML bodies from parsed RFC822 messages. + + The `mail` library handles the common paths, but some desktop clients emit nested or + differently-cased MIME structures that can parse successfully while `Mail.get_text/1` or + `Mail.get_html/1` return nil. This module keeps the fast path and falls back to walking all + non-attachment parts case-insensitively. + """ + + def text_body(message) do + message + |> mail_body(&Mail.get_text/1) + |> first_present(fn -> find_part_body(message, "text/plain") end) + |> first_present(fn -> root_text_body_without_content_type(message) end) + end + + def html_body(message) do + message + |> mail_body(&Mail.get_html/1) + |> first_present(fn -> find_part_body(message, "text/html") end) + end + + defp mail_body(nil, _fun), do: nil + + defp mail_body(%Mail.Message{} = message, fun) when is_function(fun, 1) do + case fun.(message) do + %Mail.Message{body: body} = part -> + if attachment?(part), do: nil, else: present_body(body) + + _ -> + nil + end + end + + defp first_present(body, _fallback) when is_binary(body), do: body + defp first_present(_body, fallback) when is_function(fallback, 0), do: fallback.() + + defp find_part_body(nil, _content_type), do: nil + + defp find_part_body(%Mail.Message{} = message, content_type) do + message + |> flatten_parts() + |> Enum.reverse() + |> Enum.find_value(fn part -> + if body_part?(part, content_type) do + present_body(part.body) + end + end) + end + + defp flatten_parts(%Mail.Message{multipart: true, parts: parts}) when is_list(parts) do + Enum.flat_map(parts, &flatten_parts/1) + end + + defp flatten_parts(%Mail.Message{} = message), do: [message] + defp flatten_parts(_), do: [] + + defp body_part?(%Mail.Message{} = message, expected_type) do + not attachment?(message) and content_type_matches?(message, expected_type) + end + + defp root_text_body_without_content_type(%Mail.Message{multipart: false} = message) do + if not attachment?(message) and missing_content_type?(message) do + present_body(message.body) + end + end + + defp root_text_body_without_content_type(_), do: nil + + defp attachment?(%Mail.Message{} = message) do + message + |> Mail.Message.get_header(:content_disposition) + |> List.wrap() + |> List.first() + |> case do + value when is_binary(value) -> String.downcase(value) == "attachment" + value when is_atom(value) -> value == :attachment + _ -> false + end + end + + defp content_type_matches?(%Mail.Message{} = message, expected_type) do + message + |> Mail.Message.get_content_type() + |> List.first() + |> case do + value when is_binary(value) -> String.downcase(value) == expected_type + _ -> false + end + end + + defp missing_content_type?(%Mail.Message{} = message) do + message.headers + |> Map.keys() + |> Enum.any?(&(String.downcase(to_string(&1)) == "content-type")) + |> Kernel.not() + end + + defp present_body(body) when is_binary(body) do + if String.trim(body) == "", do: nil, else: body + end + + defp present_body(_), do: nil +end diff --git a/apps/elektrine_email/lib/elektrine/email/payload_sanitizer.ex b/apps/elektrine_email/lib/elektrine/email/payload_sanitizer.ex new file mode 100644 index 0000000..c06443a --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/payload_sanitizer.ex @@ -0,0 +1,26 @@ +defmodule Elektrine.Email.PayloadSanitizer do + @moduledoc false + + @postgres_null_byte <<0>> + + def strip_postgres_null_bytes(value) when is_binary(value) do + :binary.replace(value, @postgres_null_byte, "", [:global]) + end + + def strip_postgres_null_bytes(value) when is_list(value) do + Enum.map(value, &strip_postgres_null_bytes/1) + end + + def strip_postgres_null_bytes(%_{} = value), do: value + + def strip_postgres_null_bytes(value) when is_map(value) do + Map.new(value, fn {key, item} -> + {strip_key_null_bytes(key), strip_postgres_null_bytes(item)} + end) + end + + def strip_postgres_null_bytes(value), do: value + + defp strip_key_null_bytes(key) when is_binary(key), do: strip_postgres_null_bytes(key) + defp strip_key_null_bytes(key), do: key +end diff --git a/apps/elektrine_email/lib/elektrine/email/pgp.ex b/apps/elektrine_email/lib/elektrine/email/pgp.ex new file mode 100644 index 0000000..ca24c84 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/pgp.ex @@ -0,0 +1,1050 @@ +defmodule Elektrine.Email.PGP do + @moduledoc "PGP/OpenPGP support for email encryption.\n\nProvides:\n- Public key storage and retrieval\n- WKD (Web Key Directory) lookups\n- PGP encryption of outgoing emails\n- Key fingerprint extraction\n" + alias Elektrine.Accounts.User + alias Elektrine.Email.Contact + alias Elektrine.Email.PgpKeyCache + alias Elektrine.HTTP.SafeFetch + alias Elektrine.Repo + alias Elektrine.Security.URLValidator + import Ecto.Query + import Bitwise + require Logger + @cache_ttl_found_hours 24 + @cache_ttl_not_found_hours 1 + @wkd_timeout 10_000 + @pgp_public_key_begin "-----BEGIN PGP PUBLIC KEY BLOCK-----" + @pgp_public_key_end "-----END PGP PUBLIC KEY BLOCK-----" + @pgp_message_begin "-----BEGIN PGP MESSAGE-----" + @pgp_message_end "-----END PGP MESSAGE-----" + @pgp_signed_begin "-----BEGIN PGP SIGNED MESSAGE-----" + @pgp_signature_begin "-----BEGIN PGP SIGNATURE-----" + @pgp_signature_end "-----END PGP SIGNATURE-----" + + @doc "Stores a user's PGP public key.\nParses the key to extract fingerprint and key ID.\nAccepts either a user struct or user_id.\n" + def store_user_key(%User{id: user_id}, public_key_armor) do + store_user_key(user_id, public_key_armor) + end + + def store_user_key(user_id, public_key_armor) when is_binary(public_key_armor) do + case parse_public_key(public_key_armor) do + {:ok, key_info} -> + user = Repo.get!(User, user_id) + wkd_hash_value = wkd_hash(user.username) + + user + |> Ecto.Changeset.change(%{ + pgp_public_key: public_key_armor, + pgp_fingerprint: key_info.fingerprint, + pgp_key_id: key_info.key_id, + pgp_key_uploaded_at: DateTime.utc_now() |> DateTime.truncate(:second), + pgp_wkd_hash: wkd_hash_value + }) + |> Repo.update() + + {:error, reason} -> + {:error, reason} + end + end + + @doc "Removes a user's PGP public key.\nAccepts either a user struct or user_id.\n" + def delete_user_key(%User{id: user_id}) do + delete_user_key(user_id) + end + + def delete_user_key(user_id) do + remove_user_key(user_id) + end + + def remove_user_key(user_id) do + user = Repo.get!(User, user_id) + + user + |> Ecto.Changeset.change(%{ + pgp_public_key: nil, + pgp_fingerprint: nil, + pgp_key_id: nil, + pgp_key_uploaded_at: nil, + pgp_wkd_hash: nil + }) + |> Repo.update() + end + + @doc "Gets a user's PGP public key by user ID.\n" + def get_user_key(user_id) do + case Repo.get(User, user_id) do + %User{pgp_public_key: key} when not is_nil(key) -> {:ok, key} + _ -> {:error, :no_key} + end + end + + @doc "Gets a user's PGP public key by email address.\nUsed for WKD responses.\n" + def get_key_by_email(email) when is_binary(email) do + clean_email = String.downcase(String.trim(email)) + [local_part, domain] = String.split(clean_email, "@") + our_domains = Elektrine.Domains.supported_email_domains() + + if domain in our_domains do + case Repo.get_by(User, username: local_part) do + %User{pgp_public_key: key} when not is_nil(key) -> {:ok, key} + _ -> {:error, :no_key} + end + else + {:error, :not_our_domain} + end + end + + @doc "Looks up a PGP public key for an email address.\nChecks local users, then cache, then optional WKD.\n" + def lookup_key(email, opts \\ []) + + def lookup_key(email, opts) when is_binary(email) and is_list(opts) do + clean_email = String.downcase(String.trim(email)) + fetch_remote = Keyword.get(opts, :fetch_remote, true) + + case get_local_user_key_by_email(clean_email) do + {:ok, key} -> + {:ok, key} + + {:error, :no_key} -> + case get_cached_key(clean_email) do + {:ok, key} -> + {:ok, key} + + {:not_found, _} -> + {:error, :no_key} + + :miss -> + if fetch_remote do + case lookup_wkd(clean_email) do + {:ok, key} -> + cache_key(clean_email, key, "wkd") + {:ok, key} + + {:error, _reason} -> + cache_not_found(clean_email) + {:error, :no_key} + end + else + {:error, :no_key} + end + end + end + end + + @doc "Looks up a recipient's key, checking user's contacts first.\n" + def lookup_recipient_key(email, user_id, opts \\ []) + + def lookup_recipient_key(email, user_id, opts) when is_binary(email) and is_list(opts) do + clean_email = String.downcase(String.trim(email)) + + case get_contact_key_by_email(clean_email, user_id) do + {:ok, key} -> {:ok, key} + {:error, :no_key} -> lookup_key(clean_email, opts) + end + end + + @doc "Looks up keys for a list of recipients and returns available and missing recipients.\n" + def lookup_recipient_keys(recipients, user_id, opts \\ []) when is_list(recipients) do + recipients + |> normalize_recipients() + |> Enum.reduce(%{available: %{}, missing: []}, fn recipient, acc -> + case lookup_recipient_key(recipient, user_id, opts) do + {:ok, key} -> + %{acc | available: Map.put(acc.available, recipient, key)} + + {:error, _reason} -> + %{acc | missing: acc.missing ++ [recipient]} + end + end) + end + + @doc "Returns a compose-friendly encryption status for a recipient list.\n" + def recipient_encryption_status(recipients, user_id, opts \\ []) when is_list(recipients) do + normalized_recipients = normalize_recipients(recipients) + + %{available: available, missing: missing} = + lookup_recipient_keys(normalized_recipients, user_id, opts) + + %{ + recipients: normalized_recipients, + available_recipients: Map.keys(available), + missing_recipients: missing, + available_count: map_size(available), + total_count: length(normalized_recipients), + can_encrypt?: normalized_recipients != [] and missing == [] + } + end + + defp get_contact_key_by_email(email, user_id) do + query = + from(c in Contact, + where: c.user_id == ^user_id, + where: c.email == ^email or fragment("? = ANY(?)", ^email, c.emails), + where: not is_nil(c.pgp_public_key), + select: c.pgp_public_key, + limit: 1 + ) + + case Repo.one(query) do + nil -> {:error, :no_key} + key -> {:ok, key} + end + end + + defp get_local_user_key_by_email(email) do + case get_key_by_email(email) do + {:ok, key} -> {:ok, key} + {:error, _reason} -> {:error, :no_key} + end + end + + defp normalize_recipients(recipients) do + recipients + |> Enum.flat_map(fn + email when is_binary(email) -> split_recipient_string(email) + _ -> [] + end) + |> Enum.reject(&(is_nil(&1) or &1 == "")) + |> Enum.uniq() + end + + defp split_recipient_string(value) when is_binary(value) do + value + |> String.split(",", trim: true) + |> Enum.map(&String.downcase(String.trim(&1))) + end + + @doc "Performs a Web Key Directory (WKD) lookup for an email address.\nImplements the advanced method as per draft-koch-openpgp-webkey-service.\n" + def lookup_wkd(email) when is_binary(email) do + clean_email = String.downcase(String.trim(email)) + + case String.split(clean_email, "@") do + [local_part, domain] -> + normalized_domain = normalize_wkd_domain(domain) + hash = wkd_hash(local_part) + + advanced_url = + "https://openpgpkey.#{normalized_domain}/.well-known/openpgpkey/#{normalized_domain}/hu/#{hash}" + + direct_url = "https://#{normalized_domain}/.well-known/openpgpkey/hu/#{hash}" + + case fetch_wkd_key(advanced_url) do + {:ok, key} -> + Logger.info("WKD: Found key for #{email} via advanced method") + {:ok, key} + + {:error, _} -> + case fetch_wkd_key(direct_url) do + {:ok, key} -> + Logger.info("WKD: Found key for #{email} via direct method") + {:ok, key} + + {:error, reason} -> + Logger.debug("WKD: No key found for #{email}: #{inspect(reason)}") + {:error, :not_found} + end + end + + _ -> + {:error, :invalid_email} + end + end + + defp normalize_wkd_domain(domain) do + domain + |> String.trim() + |> String.trim_trailing(".") + end + + defp fetch_wkd_key(url) do + case URLValidator.validate(url) do + :ok -> + headers = [ + {"Accept", "application/octet-stream"}, + {"User-Agent", "Elektrine-WKD-Client/1.0"} + ] + + request = Finch.build(:get, url, headers) + + case SafeFetch.request(request, Elektrine.Finch, + receive_timeout: @wkd_timeout, + max_body_bytes: 1_000_000 + ) do + {:ok, %Finch.Response{status: 200, body: body}} when byte_size(body) > 0 -> + armored = armor_public_key(body) + {:ok, armored} + + {:ok, %Finch.Response{status: status}} -> + {:error, {:http_error, status}} + + {:error, reason} -> + {:error, reason} + end + + {:error, reason} -> + Logger.warning("WKD: blocked unsafe lookup to #{url}: #{inspect(reason)}") + {:error, {:unsafe_url, reason}} + end + end + + @doc "Computes the WKD hash for a local part.\nUses SHA-1 and z-base32 encoding as per the spec.\n" + def wkd_hash(local_part) do + local_part |> String.downcase() |> then(&:crypto.hash(:sha, &1)) |> zbase32_encode() + end + + @zbase32_alphabet ~c"ybndrfg8ejkmcpqxot1uwisza345h769" + defp zbase32_encode(binary) do + binary + |> :binary.bin_to_list() + |> Enum.reduce({<<>>, 0, 0}, fn byte, {acc, buffer, bits} -> + buffer = bor(buffer <<< 8, byte) + bits = bits + 8 + {new_acc, new_buffer, new_bits} = extract_zbase32_chars(acc, buffer, bits) + {new_acc, new_buffer, new_bits} + end) + |> then(fn {acc, buffer, bits} -> + if bits > 0 do + buffer = buffer <<< (5 - bits) + char = Enum.at(@zbase32_alphabet, band(buffer, 31)) + acc <> <> + else + acc + end + end) + end + + defp extract_zbase32_chars(acc, buffer, bits) when bits >= 5 do + index = band(bsr(buffer, bits - 5), 31) + char = Enum.at(@zbase32_alphabet, index) + extract_zbase32_chars(acc <> <>, buffer, bits - 5) + end + + defp extract_zbase32_chars(acc, buffer, bits) do + {acc, buffer, bits} + end + + defp get_cached_key(email) do + query = + from(c in PgpKeyCache, where: c.email == ^email, where: c.expires_at > ^DateTime.utc_now()) + + case Repo.one(query) do + nil -> :miss + %PgpKeyCache{status: "found", public_key: key} -> {:ok, key} + %PgpKeyCache{status: "not_found"} -> {:not_found, :cached} + _ -> :miss + end + end + + defp cache_key(email, public_key, source) do + case parse_public_key(public_key) do + {:ok, key_info} -> + expires_at = DateTime.add(DateTime.utc_now(), @cache_ttl_found_hours * 3600, :second) + + %PgpKeyCache{} + |> PgpKeyCache.changeset(%{ + email: email, + public_key: public_key, + key_id: key_info.key_id, + fingerprint: key_info.fingerprint, + source: source, + status: "found", + expires_at: expires_at + }) + |> Repo.insert(on_conflict: :replace_all, conflict_target: :email) + + {:error, _} -> + expires_at = DateTime.add(DateTime.utc_now(), @cache_ttl_found_hours * 3600, :second) + + %PgpKeyCache{} + |> PgpKeyCache.changeset(%{ + email: email, + public_key: public_key, + source: source, + status: "found", + expires_at: expires_at + }) + |> Repo.insert(on_conflict: :replace_all, conflict_target: :email) + end + end + + defp cache_not_found(email) do + expires_at = DateTime.add(DateTime.utc_now(), @cache_ttl_not_found_hours * 3600, :second) + + %PgpKeyCache{} + |> PgpKeyCache.changeset(%{email: email, status: "not_found", expires_at: expires_at}) + |> Repo.insert(on_conflict: :replace_all, conflict_target: :email) + end + + @doc "Parses a PGP public key and extracts metadata.\nReturns {:ok, %{fingerprint: ..., key_id: ...}} or {:error, reason}\n" + def parse_public_key(armor) when is_binary(armor) do + if String.contains?(armor, @pgp_public_key_begin) do + case parse_public_key_with_gpg(armor) do + {:ok, key_info} -> {:ok, key_info} + {:error, :gpg_unavailable} -> parse_public_key_from_packets(armor) + {:error, reason} -> {:error, reason} + end + else + {:error, :not_pgp_key} + end + end + + def parse_public_key(_) do + {:error, :invalid_input} + end + + defp parse_public_key_from_packets(armor) do + case extract_key_data(armor) do + {:ok, binary_key} -> + case extract_fingerprint(binary_key) do + {:ok, fingerprint} -> + key_id = String.slice(fingerprint, -16, 16) + {:ok, %{fingerprint: fingerprint, key_id: key_id}} + + {:error, reason} -> + {:error, reason} + end + + {:error, reason} -> + {:error, reason} + end + end + + defp parse_public_key_with_gpg(armor) do + case gpg_executable() do + nil -> + {:error, :gpg_unavailable} + + gpg -> + with_temp_dir("pgp_key_parse_", fn temp_dir -> + key_file = Path.join(temp_dir, "public.asc") + File.write!(key_file, armor) + File.chmod!(key_file, 0o600) + + args = [ + "--homedir", + temp_dir, + "--batch", + "--with-colons", + "--with-fingerprint", + "--show-keys", + key_file + ] + + case System.cmd(gpg, args, stderr_to_stdout: true) do + {output, 0} -> parse_gpg_public_key_output(output) + {output, _status} -> gpg_show_keys_error(output) + end + end) + end + rescue + e -> + Logger.debug("PGP key validation error: #{inspect(e)}") + {:error, :parse_error} + end + + defp parse_gpg_public_key_output(output) do + lines = String.split(output, "\n", trim: true) + + cond do + Enum.any?(lines, &gpg_revoked_public_key?/1) -> + {:error, :revoked_key} + + Enum.any?(lines, &gpg_expired_public_key?/1) -> + {:error, :expired_key} + + true -> + fingerprint = + lines + |> Enum.find_value(fn line -> + case String.split(line, ":") do + ["fpr" | fields] -> Enum.at(fields, 8) + _ -> nil + end + end) + + if Elektrine.Strings.present?(fingerprint) do + {:ok, + %{ + fingerprint: fingerprint, + key_id: String.slice(fingerprint, -16, 16) + }} + else + {:error, :no_fingerprint} + end + end + end + + defp gpg_revoked_public_key?(line) do + case String.split(line, ":") do + ["pub", validity | _] -> validity == "r" + _ -> false + end + end + + defp gpg_expired_public_key?(line) do + case String.split(line, ":") do + ["pub", validity | _] -> validity == "e" + _ -> false + end + end + + defp gpg_show_keys_error(output) do + cond do + String.contains?(output, "no valid OpenPGP data") -> {:error, :invalid_pgp_key} + String.contains?(output, "invalid armor") -> {:error, :invalid_armor} + true -> {:error, :invalid_pgp_key} + end + end + + @doc "Returns detected OpenPGP content markers without decrypting message content." + def detect_content(text_body, html_body \\ nil, attachments \\ %{}, headers \\ %{}) do + text = Enum.join([text_body || "", html_body || ""], "\n") + + attachment_values = normalize_attachment_values(attachments) + + content_types = + attachment_values + |> Enum.filter(&is_map/1) + |> Enum.map(&(Map.get(&1, "content_type") || Map.get(&1, :content_type) || "")) + |> Enum.concat(header_content_types(headers)) + |> Enum.map(&String.downcase(to_string(&1))) + + filenames = + attachment_values + |> Enum.filter(&is_map/1) + |> Enum.map( + &(Map.get(&1, "filename") || Map.get(&1, "name") || Map.get(&1, :filename) || "") + ) + |> Enum.map(&String.downcase(to_string(&1))) + + %{ + encrypted?: contains_pgp_message?(text), + signed?: + contains_pgp_signature?(text) or + Enum.any?(content_types, &String.contains?(&1, "application/pgp-signature")), + public_key?: extract_public_keys(text, attachments, headers) != [], + pgp_mime?: Enum.any?(content_types, &pgp_mime_content_type?/1), + pgp_attachment?: + Enum.any?(content_types, &String.contains?(&1, "application/pgp")) or + Enum.any?(filenames, &pgp_filename?/1) + } + end + + @doc "Extracts ASCII-armored public keys from message bodies, headers, and attachment metadata." + def extract_public_keys(text_body, attachments \\ %{}, headers \\ %{}) do + header_text = + headers + |> normalize_header_values() + |> Enum.join("\n") + + attachment_text = + attachments + |> normalize_attachment_values() + |> Enum.filter(&is_map/1) + |> Enum.flat_map(fn attachment -> + [attachment["data"], attachment["content"], attachment[:data], attachment[:content]] + end) + |> Enum.filter(&is_binary/1) + |> Enum.map_join("\n", &maybe_decode_attachment_text/1) + + [text_body || "", header_text, attachment_text] + |> Enum.join("\n") + |> extract_armored_blocks(@pgp_public_key_begin, @pgp_public_key_end) + |> Enum.uniq() + end + + @doc "Verifies a clear-signed OpenPGP message using public keys only." + def verify_signed_message(message, public_key_armors) + when is_binary(message) and is_list(public_key_armors) do + keys = + public_key_armors + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + + cond do + not contains_pgp_signature?(message) -> + {:error, :not_signed} + + keys == [] -> + {:error, :no_public_key} + + not gpg_available?() -> + {:error, :gpg_unavailable} + + true -> + verify_signed_message_with_gpg(message, keys) + end + end + + def verify_signed_message(_, _), do: {:error, :invalid_input} + + defp verify_signed_message_with_gpg(message, public_key_armors) do + gpg = gpg_executable() + + with_temp_dir("pgp_verify_", fn temp_dir -> + message_file = Path.join(temp_dir, "message.asc") + File.write!(message_file, message) + File.chmod!(message_file, 0o600) + + key_files = + public_key_armors + |> Enum.with_index() + |> Enum.map(fn {armor, index} -> + key_file = Path.join(temp_dir, "signer_#{index}.asc") + File.write!(key_file, armor) + File.chmod!(key_file, 0o600) + key_file + end) + + case import_keys(temp_dir, key_files) do + :ok -> + case System.cmd( + gpg, + ["--homedir", temp_dir, "--batch", "--status-fd", "1", "--verify", message_file], + stderr_to_stdout: true + ) do + {output, 0} -> {:ok, parse_verify_output(output)} + {output, _status} -> {:error, verify_error(output)} + end + + {:error, output} -> + Logger.debug("PGP verification key import failed: #{output}") + {:error, :key_import_failed} + end + end) + rescue + e -> + Logger.debug("PGP verification error: #{inspect(e)}") + {:error, :verification_error} + end + + defp parse_verify_output(output) do + fingerprint = + output + |> String.split("\n", trim: true) + |> Enum.find_value(fn line -> + cond do + String.contains?(line, "[GNUPG:] VALIDSIG ") -> line |> String.split() |> Enum.at(2) + String.contains?(line, "[GNUPG:] GOODSIG ") -> line |> String.split() |> Enum.at(2) + true -> nil + end + end) + + %{"status" => "verified", "fingerprint" => fingerprint} + end + + defp verify_error(output) do + cond do + String.contains?(output, "NO_PUBKEY") -> :no_public_key + String.contains?(output, "BADSIG") -> :bad_signature + String.contains?(output, "EXPSIG") -> :expired_signature + String.contains?(output, "REVKEYSIG") -> :revoked_key + true -> :verification_failed + end + end + + defp contains_pgp_message?(text) when is_binary(text) do + String.contains?(text, @pgp_message_begin) and String.contains?(text, @pgp_message_end) + end + + defp contains_pgp_message?(_), do: false + + defp contains_pgp_signature?(text) when is_binary(text) do + String.contains?(text, @pgp_signed_begin) or + (String.contains?(text, @pgp_signature_begin) and String.contains?(text, @pgp_signature_end)) + end + + defp contains_pgp_signature?(_), do: false + + defp extract_armored_blocks(text, begin_marker, end_marker) when is_binary(text) do + pattern = Regex.escape(begin_marker) <> "[\\s\\S]*?" <> Regex.escape(end_marker) + + Regex.scan(Regex.compile!(pattern), text) + |> Enum.map(fn [block] -> String.trim(block) end) + end + + defp normalize_header_values(headers) when is_map(headers) do + headers + |> Enum.flat_map(fn {_key, value} -> List.wrap(value) end) + |> Enum.filter(&is_binary/1) + end + + defp normalize_header_values(_), do: [] + + defp header_content_types(headers) when is_map(headers) do + headers + |> Enum.filter(fn {key, _value} -> + is_binary(key) and String.downcase(key) == "content-type" + end) + |> Enum.flat_map(fn {_key, value} -> List.wrap(value) end) + end + + defp header_content_types(_), do: [] + + defp normalize_attachment_values(attachments) when is_map(attachments) do + Map.values(attachments) + end + + defp normalize_attachment_values(attachments) when is_list(attachments) do + attachments + end + + defp normalize_attachment_values(_), do: [] + + defp pgp_mime_content_type?(content_type) do + String.contains?(content_type, "multipart/encrypted") or + String.contains?(content_type, "multipart/signed") or + String.contains?(content_type, "application/pgp-encrypted") + end + + defp pgp_filename?(filename) do + String.ends_with?(filename, [".asc", ".gpg", ".pgp", ".sig"]) + end + + defp maybe_decode_attachment_text(value) do + trimmed = String.trim(value) + + if String.contains?(trimmed, @pgp_public_key_begin) do + trimmed + else + case Base.decode64(trimmed) do + {:ok, decoded} -> decoded + :error -> value + end + end + end + + defp with_temp_dir(prefix, fun) when is_function(fun, 1) do + temp_dir = + Path.join( + System.tmp_dir!(), + "#{prefix}#{Base.encode16(:crypto.strong_rand_bytes(16), case: :lower)}" + ) + + try do + File.mkdir_p!(temp_dir) + File.chmod!(temp_dir, 0o700) + fun.(temp_dir) + after + File.rm_rf(temp_dir) + end + end + + defp extract_key_data(armor) do + lines = String.split(armor, ~r/\r?\n/) + + content = + lines + |> Enum.drop_while(&(!String.starts_with?(&1, "-----BEGIN"))) + |> Enum.drop(1) + |> Enum.take_while(&(!String.starts_with?(&1, "-----END"))) + |> Enum.reject(&(&1 == "" || String.starts_with?(&1, "=") || String.contains?(&1, ":"))) + |> Enum.join("") + + case Base.decode64(content) do + {:ok, binary} -> {:ok, binary} + :error -> {:error, :invalid_base64} + end + end + + defp extract_fingerprint(binary_key) do + case find_public_key_packet(binary_key) do + {:ok, packet_content} -> + packet_len = byte_size(packet_content) + data_to_hash = <<153, packet_len::big-16>> <> packet_content + fingerprint = :crypto.hash(:sha, data_to_hash) |> Base.encode16(case: :upper) + {:ok, fingerprint} + + {:error, reason} -> + {:error, reason} + end + rescue + _ -> {:error, :parse_error} + end + + defp find_public_key_packet(binary) do + parse_packets(binary) + end + + defp parse_packets(<<>>) do + {:error, :no_public_key_packet} + end + + defp parse_packets(<>) when band(tag_byte, 128) != 0 do + if band(tag_byte, 64) != 0 do + tag = band(tag_byte, 63) + + case parse_new_packet_length(rest) do + {:ok, length, packet_rest} -> + <> = packet_rest + + if tag == 6 do + {:ok, packet_content} + else + parse_packets(remaining) + end + + {:error, reason} -> + {:error, reason} + end + else + tag = bsr(band(tag_byte, 60), 2) + length_type = band(tag_byte, 3) + + case parse_old_packet_length(rest, length_type) do + {:ok, length, packet_rest} -> + <> = packet_rest + + if tag == 6 do + {:ok, packet_content} + else + parse_packets(remaining) + end + + {:error, reason} -> + {:error, reason} + end + end + end + + defp parse_packets(_) do + {:error, :invalid_packet_format} + end + + defp parse_new_packet_length(<>) when len < 192 do + {:ok, len, rest} + end + + defp parse_new_packet_length(<>) + when first >= 192 and first < 224 do + len = ((first - 192) <<< 8) + second + 192 + {:ok, len, rest} + end + + defp parse_new_packet_length(<<255, len::big-32, rest::binary>>) do + {:ok, len, rest} + end + + defp parse_new_packet_length(_) do + {:error, :invalid_length} + end + + defp parse_old_packet_length(<>, 0) do + {:ok, len, rest} + end + + defp parse_old_packet_length(<>, 1) do + {:ok, len, rest} + end + + defp parse_old_packet_length(<>, 2) do + {:ok, len, rest} + end + + defp parse_old_packet_length(_, 3) do + {:error, :indeterminate_length} + end + + defp parse_old_packet_length(_, _) do + {:error, :invalid_length} + end + + @doc "Encrypts a message using one or more recipient public keys.\nUses GPG command-line tool for encryption.\nReturns {:ok, encrypted_armor} or {:error, reason}\n" + def encrypt(plaintext, public_key_armor) + when is_binary(plaintext) and is_binary(public_key_armor) do + encrypt(plaintext, [public_key_armor]) + end + + def encrypt(plaintext, public_key_armors) + when is_binary(plaintext) and is_list(public_key_armors) do + normalized_keys = + public_key_armors + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + + cond do + plaintext == "" -> + {:error, :empty_plaintext} + + normalized_keys == [] -> + {:error, :no_recipient_keys} + + not gpg_available?() -> + {:error, :gpg_unavailable} + + true -> + encrypt_with_gpg(plaintext, normalized_keys) + end + end + + @doc "Encrypts an email body for a recipient if they have a PGP key.\nReturns the email params with encrypted body, or unchanged if no key.\n" + def maybe_encrypt_email(params, recipient_email, user_id) do + case lookup_recipient_key(recipient_email, user_id) do + {:ok, public_key} -> + body_to_encrypt = params[:text_body] || params[:html_body] || "" + + if body_to_encrypt != "" do + case encrypt(body_to_encrypt, public_key) do + {:ok, encrypted} -> + Logger.info("PGP: Encrypted email to #{recipient_email}") + + params + |> Map.put(:text_body, encrypted) + |> Map.put(:html_body, nil) + |> Map.put(:pgp_encrypted, true) + + {:error, reason} -> + Logger.warning( + "PGP: Failed to encrypt email to #{recipient_email}: #{inspect(reason)}" + ) + + params + end + else + params + end + + {:error, _} -> + params + end + end + + @doc "Returns true when the `gpg` executable is available on this node.\n" + def gpg_available? do + is_binary(gpg_executable()) + end + + defp gpg_executable do + System.find_executable("gpg") || System.find_executable("gpg2") + end + + @doc "Converts binary key data to ASCII armor format.\n" + def armor_public_key(binary_key) when is_binary(binary_key) do + encoded = Base.encode64(binary_key, padding: true) + + lines = + encoded |> String.graphemes() |> Enum.chunk_every(64) |> Enum.map_join("\n", &Enum.join/1) + + checksum = crc24(binary_key) + checksum_encoded = Base.encode64(<>) + "-----BEGIN PGP PUBLIC KEY BLOCK----- + +#{lines} +=#{checksum_encoded} +-----END PGP PUBLIC KEY BLOCK----- +" + end + + @crc24_init 11_994_318 + @crc24_poly 25_578_747 + defp crc24(data) do + data + |> :binary.bin_to_list() + |> Enum.reduce(@crc24_init, fn byte, crc -> + crc = bxor(crc, byte <<< 16) + + Enum.reduce(0..7, crc, fn _, acc -> + acc = acc <<< 1 + + if band(acc, 16_777_216) != 0 do + bxor(acc, @crc24_poly) + else + acc + end + end) + end) + |> band(16_777_215) + end + + @doc "Cleans up expired cache entries.\nShould be called periodically.\n" + def cleanup_expired_cache do + query = from(c in PgpKeyCache, where: c.expires_at < ^DateTime.utc_now()) + Repo.delete_all(query) + end + + defp encrypt_with_gpg(plaintext, public_key_armors) do + gpg = gpg_executable() + + temp_dir = + Path.join( + System.tmp_dir!(), + "pgp_encrypt_#{Base.encode16(:crypto.strong_rand_bytes(16), case: :lower)}" + ) + + input_file = Path.join(temp_dir, "message.txt") + encrypted_file = input_file <> ".asc" + + try do + File.mkdir_p!(temp_dir) + File.chmod!(temp_dir, 0o700) + File.write!(input_file, plaintext) + File.chmod!(input_file, 0o600) + + key_files = + public_key_armors + |> Enum.with_index() + |> Enum.map(fn {armor, index} -> + key_file = Path.join(temp_dir, "recipient_#{index}.asc") + File.write!(key_file, armor) + File.chmod!(key_file, 0o600) + key_file + end) + + case import_keys(temp_dir, key_files) do + :ok -> + encrypt_args = + [ + "--homedir", + temp_dir, + "--batch", + "--yes", + "--trust-model", + "always", + "--armor", + "--encrypt" + ] ++ Enum.flat_map(key_files, &["--recipient-file", &1]) ++ [input_file] + + {output, encrypt_status} = + System.cmd(gpg, encrypt_args, stderr_to_stdout: true) + + if encrypt_status == 0 and File.exists?(encrypted_file) do + {:ok, File.read!(encrypted_file)} + else + Logger.error("PGP encryption failed: #{output}") + {:error, :encryption_failed} + end + + {:error, output} -> + Logger.error("PGP key import failed: #{output}") + {:error, :key_import_failed} + end + rescue + e -> + Logger.error("PGP encryption error: #{inspect(e)}") + {:error, :encryption_error} + after + File.rm_rf(temp_dir) + end + end + + defp import_keys(homedir, key_files) do + gpg = gpg_executable() + + Enum.reduce_while(key_files, :ok, fn key_file, _acc -> + case System.cmd( + gpg, + ["--homedir", homedir, "--batch", "--yes", "--import", key_file], + stderr_to_stdout: true + ) do + {_output, 0} -> + {:cont, :ok} + + {output, _status} -> + {:halt, {:error, output}} + end + end) + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/pgp_key_cache.ex b/apps/elektrine_email/lib/elektrine/email/pgp_key_cache.ex new file mode 100644 index 0000000..9a7e9c2 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/pgp_key_cache.ex @@ -0,0 +1,45 @@ +defmodule Elektrine.Email.PgpKeyCache do + @moduledoc """ + Schema for caching PGP public key lookups from WKD and keyservers. + Avoids repeated network requests for the same email addresses. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "pgp_key_cache" do + field :email, :string + # ASCII-armored PGP public key + field :public_key, :string + # Short key ID (last 8 hex chars) + field :key_id, :string + # Full 40-char fingerprint + field :fingerprint, :string + # "wkd", "keyserver", "dns" + field :source, :string + # "found", "not_found", "error" + field :status, :string, default: "found" + field :expires_at, :utc_datetime + + timestamps() + end + + @doc """ + Changeset for creating/updating a cache entry. + """ + def changeset(cache_entry, attrs) do + cache_entry + |> cast(attrs, [:email, :public_key, :key_id, :fingerprint, :source, :status, :expires_at]) + |> validate_required([:email, :status]) + |> validate_inclusion(:status, ["found", "not_found", "error"]) + |> validate_inclusion(:source, ["wkd", "keyserver", "dns", nil]) + |> unique_constraint(:email) + |> downcase_email() + end + + defp downcase_email(changeset) do + case get_change(changeset, :email) do + nil -> changeset + email -> put_change(changeset, :email, String.downcase(email)) + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/processing.ex b/apps/elektrine_email/lib/elektrine/email/processing.ex new file mode 100644 index 0000000..d1db211 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/processing.ex @@ -0,0 +1,192 @@ +defmodule Elektrine.Email.Processing do + @moduledoc """ + Email processing and categorization. + Handles automatic message categorization and batch processing operations. + """ + + import Ecto.Query, warn: false + require Logger + alias Elektrine.Email.{Mailbox, Message} + alias Elektrine.Repo + + @doc """ + Categorizes an incoming message based on content analysis. + """ + def categorize_message(message_attrs, opts \\ []) do + # Delegate to the new sophisticated categorizer + Elektrine.Email.Categorizer.categorize_message(message_attrs, opts) + end + + @doc """ + Processes uncategorized messages (those without a category or with default 'inbox' category). + This runs as a scheduled job to automatically categorize newly received emails. + """ + def process_uncategorized_messages do + Logger.info("Starting batch categorization of uncategorized emails") + + # Find messages that haven't been categorized yet or are in default 'inbox' category + # but might belong elsewhere based on content analysis + uncategorized_messages = + Message + |> where([m], is_nil(m.category) or m.category == "inbox") + |> where([m], not m.spam and not m.archived) + |> where([m], m.status != "sent" or is_nil(m.status)) + |> where([m], not is_nil(m.from) and not is_nil(m.subject)) + # Only process messages from last 7 days + |> where([m], m.inserted_at > ^DateTime.add(DateTime.utc_now(), -7, :day)) + |> order_by(desc: :inserted_at) + # Process in batches of 100 + |> limit(100) + |> Repo.all() + + if Enum.empty?(uncategorized_messages) do + Logger.info("No uncategorized messages found to process") + {:ok, 0, 0} + else + Logger.info("Found #{length(uncategorized_messages)} uncategorized messages to process") + + {processed, changed} = + Enum.reduce(uncategorized_messages, {0, 0}, fn message, {proc_acc, changed_acc} -> + try do + # Convert message to attributes map for categorizer + message_attrs = %{ + "subject" => message.subject, + "from" => message.from, + "to" => message.to, + "text_body" => message.text_body, + "html_body" => message.html_body, + "metadata" => message.metadata || %{} + } + + # Apply categorization + categorized_attrs = + Elektrine.Email.Categorizer.categorize_message( + message_attrs, + categorization_opts(message) + ) + + new_category = categorized_attrs["category"] + + # Only update if category actually changed + if new_category && new_category != message.category do + changeset = + message + |> Ecto.Changeset.change(%{ + category: new_category, + is_receipt: categorized_attrs["is_receipt"] || false, + is_newsletter: categorized_attrs["is_newsletter"] || false, + is_notification: categorized_attrs["is_notification"] || false + }) + + case Repo.update(changeset) do + {:ok, _updated_message} -> + {proc_acc + 1, changed_acc + 1} + + {:error, _reason} -> + Logger.warning("Failed to update message #{message.id}") + {proc_acc + 1, changed_acc} + end + else + {proc_acc + 1, changed_acc} + end + rescue + e -> + Logger.error("Error processing message #{message.id}: #{inspect(e)}") + {proc_acc + 1, changed_acc} + end + end) + + Logger.info("Batch categorization completed: #{processed} processed, #{changed} updated") + + # Clear cache if any messages were updated + if changed > 0 do + Elektrine.Email.Cache.clear_all() + end + + {:ok, processed, changed} + end + end + + @doc """ + Re-categorizes existing messages in a mailbox based on current categorization rules. + """ + def recategorize_messages(mailbox_id) do + messages = + Message + |> where(mailbox_id: ^mailbox_id) + |> where([m], not m.spam) + |> Repo.all() + + processed = length(messages) + + changed = + Enum.count(messages, fn message -> + # Create attrs map similar to what's used during message creation + message_attrs = %{ + "subject" => message.subject || "", + "from" => message.from || "", + "to" => message.to || "", + "text_body" => message.text_body || "", + "html_body" => message.html_body || "", + "metadata" => message.metadata || %{} + } + + # Apply categorization + categorized_attrs = categorize_message(message_attrs, categorization_opts(message)) + + # Check if anything would change + would_change_category = categorized_attrs["category"] != message.category + + would_change_receipt = + Map.get(categorized_attrs, "is_receipt", false) != message.is_receipt + + would_change_newsletter = + Map.get(categorized_attrs, "is_newsletter", false) != message.is_newsletter + + would_change_notification = + Map.get(categorized_attrs, "is_notification", false) != message.is_notification + + if would_change_category or would_change_receipt or would_change_newsletter or + would_change_notification do + # Extract only the categorization fields + update_attrs = + %{} + |> maybe_put(:category, categorized_attrs["category"]) + |> maybe_put(:is_receipt, categorized_attrs["is_receipt"]) + |> maybe_put(:is_newsletter, categorized_attrs["is_newsletter"]) + |> maybe_put(:is_notification, categorized_attrs["is_notification"]) + + # Update the message + message + |> Message.changeset(update_attrs) + |> Repo.update() + + true + else + false + end + end) + + {processed, changed} + end + + # Helper function to conditionally put values in a map + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp categorization_opts(%Message{mailbox_id: mailbox_id}) do + case Elektrine.Email.Mailboxes.get_mailbox(mailbox_id) do + %Mailbox{user_id: user_id} = mailbox when is_integer(user_id) -> + [ + user_id: user_id, + enabled_category_filters: Mailbox.enabled_category_filter_categories(mailbox) + ] + + %Mailbox{} = mailbox -> + [enabled_category_filters: Mailbox.enabled_category_filter_categories(mailbox)] + + _ -> + [] + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/rate_limiter.ex b/apps/elektrine_email/lib/elektrine/email/rate_limiter.ex new file mode 100644 index 0000000..49fc79a --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/rate_limiter.ex @@ -0,0 +1,463 @@ +defmodule Elektrine.Email.RateLimiter do + @moduledoc """ + Handles email sending abuse throttles with tiered short-window and recipient limits. + + ## Warmup Period (TL0 accounts) + + Day 1: + - 1 email per minute, 5 per hour, 3 recipients + + Days 2-3: + - 2 emails per minute, 8 per hour, 5 recipients + + Days 4-7: + - 2 emails per minute, 15 per hour, 10 recipients + + Week 2 (days 8-14): + - 3 emails per minute, 25 per hour, 15 recipients + + Weeks 3-4 (days 15-30): + - 3 emails per minute, 35 per hour, 20 recipients + + ## Trust Level Tiers (override warmup) + + TL1 (Basic trust): + - 5 emails per minute + - 50 emails per hour + - 50 unique recipients per day + + TL2 (Member): + - 10 emails per minute + - 100 emails per hour + - 100 unique recipients per day + + TL3+ (Regular/Leader): + - 15 emails per minute + - 150 emails per hour + - 200 unique recipients per day + """ + + use GenServer + require Logger + + alias Elektrine.Accounts.Capabilities + + @table_name :email_rate_limiter + @recipient_table :email_recipient_limiter + @cleanup_interval :timer.minutes(5) + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(_opts) do + :ets.new(@table_name, [:named_table, :public, :set]) + :ets.new(@recipient_table, [:named_table, :public, :set]) + + # Schedule cleanup + Process.send_after(self(), :cleanup, @cleanup_interval) + + {:ok, %{}} + end + + @impl true + def handle_info(:cleanup, state) do + cleanup_expired_entries() + Process.send_after(self(), :cleanup, @cleanup_interval) + {:noreply, state} + end + + # Number of violations before account is restricted + @violation_threshold 3 + + @doc """ + Checks if a user can send an email based on their tier limits. + + Returns: + - `{:ok, :allowed}` - Can send under short-window throttles + - `{:error, :minute_limit_exceeded}` - Hit per-minute limit + - `{:error, :hourly_limit_exceeded}` - Hit hourly limit + - `{:error, :recipient_limit_exceeded}` - Hit unique recipient limit + - `{:error, :account_restricted}` - Account is restricted due to repeated violations + """ + def check_rate_limit(user_id) do + # First check if account is restricted + case check_account_restricted(user_id) do + {:error, :account_restricted} = error -> + error + + :ok -> + do_check_rate_limit(user_id) + end + end + + defp do_check_rate_limit(user_id) do + limits = Capabilities.email_limits(user_id) + tier = limits.tier + minute_limit = limits.minute_limit + hour_limit = limits.hour_limit + + now = System.system_time(:second) + attempts = get_attempts(user_id) + + minute_count = count_recent(attempts, now, 60) + hour_count = count_recent(attempts, now, 3600) + + cond do + minute_count >= minute_limit -> + Logger.warning( + "User #{user_id} (#{tier}) exceeded minute limit: #{minute_count}/#{minute_limit}" + ) + + record_violation(user_id, :minute_limit_exceeded) + {:error, :minute_limit_exceeded} + + hour_count >= hour_limit -> + Logger.warning( + "User #{user_id} (#{tier}) exceeded hourly limit: #{hour_count}/#{hour_limit}" + ) + + record_violation(user_id, :hourly_limit_exceeded) + {:error, :hourly_limit_exceeded} + + true -> + {:ok, :allowed} + end + end + + @doc """ + Checks if a user's account is restricted from sending emails. + """ + def check_account_restricted(user_id) do + case Elektrine.Repo.get(Elektrine.Accounts.User, user_id) do + nil -> + :ok + + user -> + if user.email_sending_restricted do + {:error, :account_restricted} + else + :ok + end + end + end + + @doc """ + Records a rate limit violation for a user. + After #{@violation_threshold} violations, the account is restricted. + """ + def record_violation(user_id, violation_type) do + Elektrine.Async.run(fn -> + case Elektrine.Repo.get(Elektrine.Accounts.User, user_id) do + nil -> + :ok + + user -> + new_count = (user.email_rate_limit_violations || 0) + 1 + + changes = %{email_rate_limit_violations: new_count} + + # Restrict account after threshold violations + changes = + if new_count >= @violation_threshold do + Logger.warning( + "User #{user_id} has #{new_count} rate limit violations - restricting email sending" + ) + + Map.merge(changes, %{ + email_sending_restricted: true, + email_restriction_reason: "Repeated rate limit violations (#{violation_type})", + email_restricted_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + else + changes + end + + user + |> Ecto.Changeset.change(changes) + |> Elektrine.Repo.update() + end + end) + + :ok + end + + @doc """ + Lifts email sending restriction for a user (after recovery email verification). + """ + def lift_restriction(user_id) do + case Elektrine.Repo.get(Elektrine.Accounts.User, user_id) do + nil -> + {:error, :not_found} + + user -> + user + |> Ecto.Changeset.change(%{ + email_sending_restricted: false, + email_rate_limit_violations: 0, + email_restriction_reason: nil, + email_restricted_at: nil + }) + |> Elektrine.Repo.update() + end + end + + @doc """ + Gets the restriction status for a user. + """ + def get_restriction_status(user_id) do + case Elektrine.Repo.get(Elektrine.Accounts.User, user_id) do + nil -> + %{restricted: false, violations: 0, reason: nil} + + user -> + %{ + restricted: user.email_sending_restricted || false, + violations: user.email_rate_limit_violations || 0, + reason: user.email_restriction_reason, + restricted_at: user.email_restricted_at, + recovery_email: user.recovery_email, + recovery_email_verified: user.recovery_email_verified || false + } + end + end + + @doc """ + Checks if a user can send to a specific recipient (for unique recipient limiting). + + Returns: + - `{:ok, :allowed}` - Can send to this recipient + - `{:error, :recipient_limit_exceeded}` - Hit unique recipient limit + """ + def check_recipient_limit(user_id, recipient_email) do + limits = Capabilities.email_limits(user_id) + tier = limits.tier + recipient_limit = limits.recipient_limit + + now = System.system_time(:second) + recipients = get_recipients(user_id) + + # Count unique recipients in last 24 hours + day_cutoff = now - 86_400 + + recent_recipients = + recipients + |> Enum.filter(fn {_email, timestamp} -> timestamp > day_cutoff end) + |> Enum.map(fn {email, _timestamp} -> email end) + |> Enum.uniq() + + # If recipient is already in the list, allow (not a new unique recipient) + normalized_recipient = String.downcase(recipient_email) + + if normalized_recipient in recent_recipients do + {:ok, :allowed} + else + if length(recent_recipients) >= recipient_limit do + Logger.warning( + "User #{user_id} (#{tier}) exceeded recipient limit: #{length(recent_recipients)}/#{recipient_limit}" + ) + + {:error, :recipient_limit_exceeded} + else + {:ok, :allowed} + end + end + end + + @doc """ + Records an email send for rate limiting. + """ + def record_send(user_id) do + record_attempt(user_id) + end + + @doc """ + Records a recipient for unique recipient tracking. + """ + def record_recipient(user_id, recipient_email) do + now = System.system_time(:second) + normalized = String.downcase(recipient_email) + + case :ets.lookup(@recipient_table, user_id) do + [] -> + :ets.insert(@recipient_table, {user_id, [{normalized, now}]}) + + [{^user_id, recipients}] -> + # Add new recipient and filter old ones (keep last 24 hours) + cutoff = now - 86_400 + filtered = Enum.filter(recipients, fn {_email, ts} -> ts > cutoff end) + new_recipients = [{normalized, now} | filtered] + :ets.insert(@recipient_table, {user_id, new_recipients}) + end + + :ok + end + + @doc """ + Records an attempt for rate limiting. + """ + def record_attempt(user_id) do + now = System.system_time(:second) + + case :ets.lookup(@table_name, user_id) do + [] -> + :ets.insert(@table_name, {user_id, [now]}) + + [{^user_id, attempts}] -> + # Add new attempt and filter old ones (keep last 24 hours) + cutoff = now - 86_400 + filtered = Enum.filter(attempts, fn ts -> ts > cutoff end) + new_attempts = [now | filtered] + :ets.insert(@table_name, {user_id, new_attempts}) + end + + :ok + end + + @doc """ + Clears all rate limiting data for a user. + """ + def clear_limits(user_id) do + :ets.delete(@table_name, user_id) + :ets.delete(@recipient_table, user_id) + :ok + end + + @doc """ + Gets the current rate limit status for a user. + """ + def get_status(user_id) do + limits = Capabilities.email_limits(user_id) + tier = limits.tier + minute_limit = limits.minute_limit + hour_limit = limits.hour_limit + day_limit = limits.day_limit + recipient_limit = limits.recipient_limit + + now = System.system_time(:second) + attempts = get_attempts(user_id) + recipients = get_recipients(user_id) + + day_cutoff = now - 86_400 + + unique_recipients = + recipients + |> Enum.filter(fn {_email, ts} -> ts > day_cutoff end) + |> Enum.map(fn {email, _ts} -> email end) + |> Enum.uniq() + |> length() + + %{ + tier: tier, + locked: false, + locked_until: nil, + attempts: %{ + 60 => %{ + count: count_recent(attempts, now, 60), + limit: minute_limit, + remaining: max(0, minute_limit - count_recent(attempts, now, 60)) + }, + 3600 => %{ + count: count_recent(attempts, now, 3600), + limit: hour_limit, + remaining: max(0, hour_limit - count_recent(attempts, now, 3600)) + }, + 86_400 => %{ + count: count_recent(attempts, now, 86_400), + limit: day_limit, + remaining: max(0, day_limit - count_recent(attempts, now, 86_400)) + } + }, + recipients: %{ + count: unique_recipients, + limit: recipient_limit, + remaining: max(0, recipient_limit - unique_recipients) + } + } + end + + @doc """ + Gets rate limit status in the previous format for older clients. + """ + def get_rate_limit_status(user_id) do + status = get_status(user_id) + + %{ + daily: %{ + sent: status.attempts[86_400].count, + limit: status.attempts[86_400].limit, + remaining: status.attempts[86_400].remaining + }, + hourly: %{ + sent: status.attempts[3600].count, + limit: status.attempts[3600].limit, + remaining: status.attempts[3600].remaining + }, + minute: %{ + sent: status.attempts[60].count, + limit: status.attempts[60].limit, + remaining: status.attempts[60].remaining + }, + recipients: %{ + count: status.recipients.count, + limit: status.recipients.limit, + remaining: status.recipients.remaining + }, + tier: status.tier + } + end + + # Max possible daily limit (for display purposes) + + # Private functions + + defp get_attempts(user_id) do + case :ets.lookup(@table_name, user_id) do + [] -> [] + [{^user_id, attempts}] -> attempts + end + end + + defp get_recipients(user_id) do + case :ets.lookup(@recipient_table, user_id) do + [] -> [] + [{^user_id, recipients}] -> recipients + end + end + + defp count_recent(attempts, now, window_seconds) do + cutoff = now - window_seconds + Enum.count(attempts, fn timestamp -> timestamp > cutoff end) + end + + defp cleanup_expired_entries do + now = System.system_time(:second) + # Keep 2 days of data + cutoff = now - 86_400 * 2 + + # Clean up attempt records + :ets.tab2list(@table_name) + |> Enum.each(fn {user_id, attempts} -> + filtered = Enum.filter(attempts, fn ts -> ts > cutoff end) + + if Enum.empty?(filtered) do + :ets.delete(@table_name, user_id) + else + :ets.insert(@table_name, {user_id, filtered}) + end + end) + + # Clean up recipient records + :ets.tab2list(@recipient_table) + |> Enum.each(fn {user_id, recipients} -> + filtered = Enum.filter(recipients, fn {_email, ts} -> ts > cutoff end) + + if Enum.empty?(filtered) do + :ets.delete(@recipient_table, user_id) + else + :ets.insert(@recipient_table, {user_id, filtered}) + end + end) + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/receiver.ex b/apps/elektrine_email/lib/elektrine/email/receiver.ex new file mode 100644 index 0000000..176ea15 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/receiver.ex @@ -0,0 +1,1006 @@ +defmodule Elektrine.Email.Receiver do + @moduledoc "Handles incoming email processing functionality.\n" + alias Elektrine.Email + alias Elektrine.Email.ForwardedMessage + alias Elektrine.Email.Mailbox + alias Elektrine.Email.MailboxEncryption + alias Elektrine.Email.PGP + alias Elektrine.Email.SenderClass + alias Elektrine.EmailConfig + alias Elektrine.Repo + alias Elektrine.Telemetry.Events + require Logger + + @doc "Processes an incoming email from a webhook.\n\nThis function is designed to be called by a webhook controller\nthat receives POST requests from the email server when a new\nemail is received.\n\n## Parameters\n\n * `params` - The webhook payload from the email server\n\n## Returns\n\n * `{:ok, message}` - If the email was processed successfully\n * `{:error, reason}` - If there was an error\n" + def process_incoming_email(params) do + started_at = System.monotonic_time(:millisecond) + + try do + with :ok <- validate_webhook(params), + {:ok, mailbox} <- find_recipient_mailbox(params), + :ok <- check_blocked_sender(mailbox.user_id, params), + {:ok, message} <- store_incoming_message(mailbox, params) do + message = apply_user_filters(message, mailbox.user_id) + + Phoenix.PubSub.broadcast!( + Elektrine.PubSub, + "user:#{mailbox.user_id}", + {:new_email, message} + ) + + Phoenix.PubSub.broadcast!( + Elektrine.PubSub, + "mailbox:#{mailbox.id}", + {:new_email, message} + ) + + if Mailbox.auto_reply_enabled?(mailbox) do + Elektrine.Async.run(fn -> Email.process_auto_reply(message, mailbox.user_id) end) + end + + duration = System.monotonic_time(:millisecond) - started_at + Events.email_inbound(:receiver, :success, duration, %{source: :receiver}) + {:ok, message} + else + {:error, :sender_blocked} -> + Logger.info("Rejected email from blocked sender: #{params["from"]}") + duration = System.monotonic_time(:millisecond) - started_at + + Events.email_inbound(:receiver, :failure, duration, %{ + reason: :sender_blocked, + source: :receiver + }) + + {:error, :sender_blocked} + + {:error, reason} = error -> + if reason not in [ + :missing_recipient, + :no_mailbox_found, + :user_not_found, + :sender_blocked + ] do + Logger.error("Failed to process incoming email: #{inspect(reason)}") + end + + duration = System.monotonic_time(:millisecond) - started_at + + Events.email_inbound(:receiver, :failure, duration, %{reason: reason, source: :receiver}) + + error + end + rescue + e -> + Logger.error("Exception processing incoming email: #{inspect(e)}") + Logger.error("Stack trace: #{Exception.format_stacktrace()}") + duration = System.monotonic_time(:millisecond) - started_at + + Events.email_inbound(:receiver, :failure, duration, %{ + reason: :exception, + source: :receiver + }) + + {:error, :processing_exception} + end + end + + defp validate_webhook(params) when is_map(params) do + webhook_secret = EmailConfig.receiver_webhook_secret() + allow_insecure = EmailConfig.allow_insecure_receiver_webhook?() + + case webhook_secret do + secret when is_binary(secret) -> + if Elektrine.Strings.present?(secret) do + provided = + params["webhook_secret"] || params["signature"] || params["token"] || params["auth"] + + if secure_compare(provided, secret) do + :ok + else + {:error, :invalid_webhook_signature} + end + else + if allow_insecure, do: :ok, else: {:error, :missing_webhook_secret} + end + + _ when allow_insecure -> + :ok + + _ -> + {:error, :webhook_secret_not_configured} + end + end + + defp validate_webhook(_params) do + {:error, :invalid_webhook_payload} + end + + defp secure_compare(left, right) + when is_binary(left) and is_binary(right) and byte_size(left) == byte_size(right) do + Plug.Crypto.secure_compare(left, right) + end + + defp secure_compare(_left, _right) do + false + end + + defp find_recipient_mailbox(params) do + find_recipient_mailbox(params, []) + end + + defp find_recipient_mailbox(params, forwarding_chain) do + recipient = params["rcpt_to"] || params["to"] + + if recipient do + case Email.resolve_alias(recipient) do + target_email when is_binary(target_email) -> + alias_record = Email.get_alias_by_email(recipient) + + updated_chain = + forwarding_chain ++ + [%{from: recipient, to: target_email, alias_id: alias_record && alias_record.id}] + + find_recipient_mailbox(Map.put(params, "rcpt_to", target_email), updated_chain) + + :no_forward -> + find_alias_owner_mailbox(recipient) + + nil -> + if forwarding_chain != [] do + record_forwarded_message(params, forwarding_chain, recipient) + end + + find_direct_mailbox(recipient) + end + else + Logger.error("Missing recipient in webhook payload (keys=#{inspect(Map.keys(params))})") + {:error, :missing_recipient} + end + end + + defp record_forwarded_message(params, forwarding_chain, final_recipient) do + first_hop = List.first(forwarding_chain) + + hops = + Enum.map(forwarding_chain, fn hop -> + %{"from" => hop[:from], "to" => hop[:to], "alias_id" => hop[:alias_id]} + end) + + attrs = %{ + message_id: params["message_id"], + from_address: sanitize_address_header(params["from"] || params["mail_from"]), + subject: sanitize_header(params["subject"]), + original_recipient: first_hop[:from], + final_recipient: final_recipient, + forwarding_chain: %{hops: hops}, + total_hops: length(forwarding_chain), + alias_id: first_hop[:alias_id] + } + + case Repo.insert(ForwardedMessage.changeset(%ForwardedMessage{}, attrs)) do + {:ok, _record} -> + :ok + + {:error, changeset} -> + Logger.error("Failed to record forwarded message: #{inspect(changeset.errors)}") + end + end + + defp sanitize_header(nil) do + "" + end + + defp sanitize_header("") do + "" + end + + defp sanitize_header(header) when is_binary(header) do + decoded = decode_mail_header(header) + Elektrine.Email.Sanitizer.sanitize_utf8(decoded) + end + + defp sanitize_address_header(nil) do + "" + end + + defp sanitize_address_header("") do + "" + end + + defp sanitize_address_header(header) when is_binary(header) do + case Regex.run(~r/^(.+?)\s*<([^>]+)>$/, String.trim(header)) do + [_, display_name, email_address] -> + decoded_name = decode_mail_header(String.trim(display_name, "\"")) + sanitized_name = Elektrine.Email.Sanitizer.sanitize_utf8(decoded_name) + sanitized_email = Elektrine.Email.Sanitizer.sanitize_utf8(email_address) + + if sanitized_name == sanitized_email or sanitized_name == "" do + sanitized_email + else + ~s("#{sanitized_name}" <#{sanitized_email}>) + end + + _ -> + decoded = decode_mail_header(header) + Elektrine.Email.Sanitizer.sanitize_utf8(decoded) + end + end + + @doc "Decode MIME-encoded headers (RFC 2047).\nHandles: =?charset?encoding?text?= format and encoding issues.\n\nThis function is public so it can be reused by IMAP APPEND handling\nto decode subjects from external email clients like Thunderbird.\n" + def decode_mail_header(text) when is_binary(text) do + cond do + String.contains?(text, "=?") and String.contains?(text, "?=") -> + decode_rfc2047_manual(text) + + looks_like_mojibake?(text) -> + fix_mojibake(text) + + String.valid?(text) -> + text + + true -> + case :unicode.characters_to_binary(text, :latin1, :utf8) do + result when is_binary(result) -> result + _ -> text + end + end + end + + defp looks_like_mojibake?(text) do + Regex.match?(~r/[æãçåäè][^\x20-\x7E]/, text) or Regex.match?(~r/[ã][€-¿]/, text) or + (String.valid?(text) and has_suspicious_latin1_sequences?(text)) + end + + defp has_suspicious_latin1_sequences?(text) do + high_byte_count = + text |> String.to_charlist() |> Enum.count(fn c -> c >= 128 and c <= 191 end) + + high_byte_count > 2 and high_byte_count > String.length(text) / 4 + end + + defp fix_mojibake(text) do + bytes = :binary.bin_to_list(text) + + case :unicode.characters_to_binary(:erlang.list_to_binary(bytes), :utf8) do + result when is_binary(result) -> + if String.valid?(result) do + result + else + text + end + + _ -> + text + end + end + + defp decode_rfc2047_manual(text) do + pattern = ~r/=\?([^?]+)\?([BQ])\?([^?]+)\?=/i + normalized = Regex.replace(~r/\?=\s+=\?/, text, "?==?") + + Regex.replace(pattern, normalized, fn _, charset, encoding, encoded_text -> + decode_encoded_word(charset, encoding, encoded_text) + end) + end + + defp decode_encoded_word(charset, encoding, encoded_text) do + decoded_bytes = + case String.upcase(encoding) do + "B" -> + padded = + case rem(String.length(encoded_text), 4) do + 0 -> encoded_text + 2 -> encoded_text <> "==" + 3 -> encoded_text <> "=" + _ -> encoded_text + end + + case Base.decode64(padded) do + {:ok, decoded} -> decoded + :error -> raise ArgumentError, "invalid RFC 2047 base64 payload" + end + + "Q" -> + encoded_text |> String.replace("_", " ") |> decode_quoted_printable() + + _ -> + encoded_text + end + + convert_charset_to_utf8(decoded_bytes, String.downcase(charset)) + rescue + e -> + Logger.warning("RFC 2047 decode failed for #{charset}/#{encoding}: #{inspect(e)}") + "=?#{charset}?#{encoding}?#{encoded_text}?=" + end + + defp decode_quoted_printable(text) do + Regex.replace(~r/=([0-9A-F]{2})/i, text, fn _, hex -> + case Integer.parse(hex, 16) do + {byte, ""} when byte in 0..255 -> <> + _ -> "" + end + end) + end + + defp convert_charset_to_utf8(bytes, charset) do + result = + case charset do + c when c in ["utf-8", "utf8"] -> + bytes + + c when c in ["us-ascii", "ascii"] -> + bytes + + c when c in ["iso-8859-1", "latin1", "latin-1"] -> + :unicode.characters_to_binary(bytes, :latin1, :utf8) + + c when c in ["windows-1252", "cp1252"] -> + convert_windows1252_to_utf8(bytes) + + "iso-8859-15" -> + :unicode.characters_to_binary(bytes, :latin1, :utf8) + + c + when c in [ + "gb2312", + "gbk", + "gb18030", + "shift_jis", + "shift-jis", + "euc-jp", + "iso-2022-jp", + "euc-kr", + "iso-2022-kr", + "big5" + ] -> + if String.valid?(bytes) do + bytes + else + Logger.warning("Unsupported charset #{charset}, cannot convert to UTF-8") + + case :unicode.characters_to_binary(bytes, :latin1, :utf8) do + r when is_binary(r) -> r + _ -> bytes + end + end + + _ -> + if String.valid?(bytes) do + bytes + else + case :unicode.characters_to_binary(bytes, :latin1, :utf8) do + r when is_binary(r) -> r + _ -> bytes + end + end + end + + case result do + r when is_binary(r) -> r + {:error, _, _} -> bytes + {:incomplete, partial, _} when is_binary(partial) -> partial + _ -> bytes + end + end + + @windows1252_map %{ + 128 => <<226, 130, 172>>, + 130 => <<226, 128, 154>>, + 131 => <<198, 146>>, + 132 => <<226, 128, 158>>, + 133 => <<226, 128, 166>>, + 134 => <<226, 128, 160>>, + 135 => <<226, 128, 161>>, + 136 => <<203, 134>>, + 137 => <<226, 128, 176>>, + 138 => <<197, 160>>, + 139 => <<226, 128, 185>>, + 140 => <<197, 146>>, + 142 => <<197, 189>>, + 145 => <<226, 128, 152>>, + 146 => <<226, 128, 153>>, + 147 => <<226, 128, 156>>, + 148 => <<226, 128, 157>>, + 149 => <<226, 128, 162>>, + 150 => <<226, 128, 147>>, + 151 => <<226, 128, 148>>, + 152 => <<203, 156>>, + 153 => <<226, 132, 162>>, + 154 => <<197, 161>>, + 155 => <<226, 128, 186>>, + 156 => <<197, 147>>, + 158 => <<197, 190>>, + 159 => <<197, 184>> + } + defp convert_windows1252_to_utf8(bytes) do + bytes + |> :binary.bin_to_list() + |> Enum.map(fn byte -> + case Map.get(@windows1252_map, byte) do + nil when byte < 128 -> <> + nil -> :unicode.characters_to_binary(<>, :latin1, :utf8) + char -> char + end + end) + |> Enum.map_join("", & &1) + end + + defp find_alias_owner_mailbox(alias_email) do + case Email.get_alias_by_email(alias_email) do + %Email.Alias{user_id: user_id} when is_integer(user_id) -> + case Email.get_user_mailbox(user_id) do + %Mailbox{} = mailbox -> + {:ok, mailbox} + + nil -> + Logger.warning("Alias #{alias_email} owner has no mailbox") + {:error, :no_mailbox_found} + end + + _ -> + Logger.warning("Could not find alias #{alias_email}") + {:error, :no_mailbox_found} + end + end + + defp find_direct_mailbox(recipient) do + case Email.get_mailbox_by_email(recipient) do + nil -> + case auto_create_mailbox_if_valid(recipient) do + {:ok, new_mailbox} -> + {:ok, new_mailbox} + + {:error, reason} -> + Logger.warning("No mailbox found for recipient: #{recipient}, reason: #{reason}") + {:error, :no_mailbox_found} + end + + mailbox -> + if is_nil(mailbox.user_id) do + case fix_orphaned_mailbox(mailbox) do + {:ok, fixed_mailbox} -> + {:ok, fixed_mailbox} + + {:error, reason} -> + Logger.error("Failed to fix orphaned mailbox #{mailbox.email}: #{reason}") + {:ok, mailbox} + end + else + {:ok, mailbox} + end + end + end + + defp extract_username_and_domain(email) do + case String.split(email, "@") do + [username, domain] -> {username, domain} + _ -> nil + end + end + + defp store_incoming_message(%Mailbox{} = mailbox, params) do + mailbox_id = mailbox.id + mailbox_user_id = mailbox.user_id + + sender_email = sanitize_address_header(params["from"] || params["mail_from"]) + private_storage? = MailboxEncryption.enabled?(mailbox) + + attachments_for_storage = + if private_storage? do + prepare_private_attachments(params["attachments"]) + else + prepare_attachments_metadata(params["attachments"]) + end + + in_reply_to = extract_thread_header(params, ["in_reply_to", "in-reply-to"]) + references = extract_thread_header(params, ["references"]) + + message_attrs = %{ + "message_id" => params["message_id"] || "incoming-#{:rand.uniform(1_000_000)}", + "from" => sender_email, + "to" => sanitize_address_header(params["to"] || params["rcpt_to"]), + "cc" => sanitize_address_header(params["cc"]), + "bcc" => sanitize_address_header(params["bcc"]), + "subject" => sanitize_header(params["subject"]), + "text_body" => + Elektrine.Email.Sanitizer.sanitize_utf8(params["plain_body"] || params["text_body"] || ""), + "html_body" => Elektrine.Email.Sanitizer.sanitize_utf8(params["html_body"] || ""), + "raw_source" => params["raw"], + "status" => "received", + "read" => false, + "spam" => spam?(params), + "archived" => false, + "mailbox_id" => mailbox_id, + "metadata" => extract_metadata(params, mailbox_user_id, sender_email, private_storage?), + "attachments" => attachments_for_storage, + "has_attachments" => map_size(attachments_for_storage) > 0, + "in_reply_to" => in_reply_to, + "references" => references + } + + message_attrs = + if message_attrs["spam"] do + message_attrs + else + Email.categorize_message(message_attrs, + user_id: mailbox_user_id, + enabled_category_filters: Mailbox.enabled_category_filter_categories(mailbox) + ) + end + + valid_message_keys = %{ + "message_id" => :message_id, + "from" => :from, + "to" => :to, + "cc" => :cc, + "bcc" => :bcc, + "subject" => :subject, + "text_body" => :text_body, + "html_body" => :html_body, + "encrypted_text_body" => :encrypted_text_body, + "encrypted_html_body" => :encrypted_html_body, + "encrypted_raw_source" => :encrypted_raw_source, + "raw_source" => :raw_source, + "search_index" => :search_index, + "status" => :status, + "read" => :read, + "spam" => :spam, + "archived" => :archived, + "deleted" => :deleted, + "flagged" => :flagged, + "answered" => :answered, + "metadata" => :metadata, + "category" => :category, + "stack_at" => :stack_at, + "stack_reason" => :stack_reason, + "reply_later_at" => :reply_later_at, + "reply_later_reminder" => :reply_later_reminder, + "is_receipt" => :is_receipt, + "is_notification" => :is_notification, + "is_newsletter" => :is_newsletter, + "opened_at" => :opened_at, + "first_opened_at" => :first_opened_at, + "open_count" => :open_count, + "attachments" => :attachments, + "has_attachments" => :has_attachments, + "hash" => :hash, + "in_reply_to" => :in_reply_to, + "references" => :references, + "jmap_blob_id" => :jmap_blob_id, + "priority" => :priority, + "scheduled_at" => :scheduled_at, + "expires_at" => :expires_at, + "undo_send_until" => :undo_send_until, + "mailbox_id" => :mailbox_id, + "thread_id" => :thread_id, + "label_ids" => :label_ids, + "folder_id" => :folder_id + } + + message_attrs = + for {key, val} <- message_attrs, + {_, atom_key} <- [Map.fetch(valid_message_keys, key)], + into: %{} do + {atom_key, val} + end + + case Email.create_message(message_attrs) do + {:ok, message} -> + if (not private_storage? and params["attachments"]) && params["attachments"] != [] do + Elektrine.Async.run(fn -> + Elektrine.Jobs.AttachmentUploader.upload_message_attachments(message.id) + end) + end + + {:ok, message} + + error -> + error + end + end + + defp prepare_attachments_metadata(nil) do + %{} + end + + defp prepare_attachments_metadata([]) do + %{} + end + + defp prepare_attachments_metadata(attachments) when is_list(attachments) do + attachments + |> Enum.with_index() + |> Enum.reduce(%{}, fn {attachment, index}, acc -> + attachment_id = "attachment_#{index}" + raw_filename = attachment["name"] || attachment["filename"] || "attachment_#{index}" + decoded_filename = decode_attachment_filename(raw_filename) + + metadata = + %{ + "filename" => decoded_filename, + "content_type" => + attachment["content_type"] || attachment["mime_type"] || "application/octet-stream", + "size" => + attachment["size"] || (attachment["data"] && byte_size(attachment["data"])) || 0, + "content_id" => attachment["content_id"], + "disposition" => attachment["disposition"] || "attachment" + } + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Enum.into(%{}) + + Map.put(acc, attachment_id, metadata) + end) + end + + defp prepare_attachments_metadata(_) do + %{} + end + + defp prepare_private_attachments(nil) do + %{} + end + + defp prepare_private_attachments([]) do + %{} + end + + defp prepare_private_attachments(attachments) when is_list(attachments) do + attachments + |> Enum.with_index() + |> Enum.reduce(%{}, fn {attachment, index}, acc -> + attachment_id = "attachment_#{index}" + raw_filename = attachment["name"] || attachment["filename"] || "attachment_#{index}" + decoded_filename = decode_attachment_filename(raw_filename) + + content = + case attachment["data"] || attachment["content"] do + nil -> + nil + + data when is_binary(data) -> + if attachment["encoding"] == "base64" do + data + else + Base.encode64(data) + end + + other -> + other + end + + attachment_payload = + %{ + "filename" => decoded_filename, + "content_type" => + attachment["content_type"] || attachment["mime_type"] || "application/octet-stream", + "size" => + attachment["size"] || (attachment["data"] && byte_size(attachment["data"])) || 0, + "content_id" => attachment["content_id"], + "disposition" => attachment["disposition"] || "attachment", + "data" => content, + "encoding" => if(is_binary(content), do: "base64", else: nil) + } + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Enum.into(%{}) + + Map.put(acc, attachment_id, attachment_payload) + end) + end + + defp prepare_private_attachments(_) do + %{} + end + + defp decode_attachment_filename("") do + "attachment" + end + + defp decode_attachment_filename(filename) when is_binary(filename) do + decoded = decode_mail_header(filename) + Elektrine.Email.Sanitizer.sanitize_utf8(decoded) + end + + defp extract_thread_header(params, keys) when is_list(keys) do + direct_value = + Enum.find_value(keys, fn key -> + params[key] || params[String.replace(key, "-", "_")] + end) + + headers = params["headers"] + + header_value = + Enum.find_value(keys, fn key -> + key + |> to_header_name() + |> then(&get_header_case_insensitive(headers, &1)) + end) + + (direct_value || header_value) + |> normalize_thread_header_value() + end + + defp to_header_name(key) when is_binary(key) do + key + |> String.replace("_", "-") + |> String.split("-") + |> Enum.map_join("-", &String.capitalize/1) + end + + defp get_header_case_insensitive(headers, _header_name) when not is_map(headers), do: nil + + defp get_header_case_insensitive(headers, header_name) do + header_name_down = String.downcase(header_name) + + Enum.find_value(headers, fn + {key, value} when is_binary(key) -> + if String.downcase(key) == header_name_down do + value + end + + _ -> + nil + end) + end + + defp normalize_thread_header_value(nil), do: nil + defp normalize_thread_header_value(""), do: nil + + defp normalize_thread_header_value(value) when is_list(value) do + value + |> Enum.map(&normalize_thread_header_value/1) + |> Enum.reject(&is_nil/1) + |> Enum.join(" ") + |> normalize_thread_header_value() + end + + defp normalize_thread_header_value(value) when is_binary(value) do + value + |> decode_mail_header() + |> Elektrine.Email.Sanitizer.sanitize_utf8() + |> String.replace(~r/\s+/, " ") + |> String.trim() + |> case do + "" -> nil + normalized -> normalized + end + end + + defp normalize_thread_header_value(value), + do: to_string(value) |> normalize_thread_header_value() + + defp extract_metadata(params, user_id, sender_email, true) do + %{ + "received_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "spam_score" => params["spam_score"], + "private_storage" => true, + "sender_class" => sender_class(sender_email, user_id, params) + } + |> Enum.filter(fn {_k, v} -> v != nil end) + |> Enum.into(%{}) + end + + defp extract_metadata(params, user_id, sender_email, _private_storage?) do + %{ + "received_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "spam_score" => params["spam_score"], + "attachments" => params["attachments"], + "headers" => sanitize_headers_map(params["headers"]), + "pgp" => extract_pgp_metadata(params, user_id, sender_email), + "sender_class" => sender_class(sender_email, user_id, params) + } + |> Enum.filter(fn {_k, v} -> v != nil end) + |> Enum.into(%{}) + end + + # Observe-only: the verdict is recorded so thresholds can be tuned against + # real traffic, and never consulted for routing. A classifier failure must + # not cost the user a message, so it degrades to no verdict at all. + defp sender_class(sender_email, user_id, params) do + SenderClass.classify(sender_email, user_id, params) + rescue + e -> + Logger.warning("Sender classification failed: #{inspect(e)}") + nil + end + + defp extract_pgp_metadata(params, user_id, sender_email) do + text_body = params["plain_body"] || params["text_body"] || "" + html_body = params["html_body"] || "" + attachments = params["attachments"] || [] + headers = params["headers"] || %{} + + content = PGP.detect_content(text_body, html_body, attachments, headers) + public_keys = PGP.extract_public_keys(text_body, attachments, headers) + + pgp_metadata = %{ + "encrypted" => content.encrypted?, + "signed" => content.signed?, + "pgp_mime" => content.pgp_mime?, + "pgp_attachment" => content.pgp_attachment?, + "public_key_present" => content.public_key?, + "discovered_public_keys" => public_key_summaries(public_keys), + "signature" => + signature_status(text_body, public_keys, user_id, sender_email, content.signed?) + } + + if Enum.any?(pgp_metadata, fn {_key, value} -> pgp_metadata_present?(value) end) do + pgp_metadata + |> Enum.reject(fn {_key, value} -> value in [nil, false, [], %{}] end) + |> Enum.into(%{}) + end + end + + defp public_key_summaries(public_keys) do + public_keys + |> Enum.map(fn key -> + case PGP.parse_public_key(key) do + {:ok, key_info} -> + %{ + "fingerprint" => key_info.fingerprint, + "key_id" => key_info.key_id, + "trusted" => false + } + + {:error, reason} -> + %{"error" => inspect(reason), "trusted" => false} + end + end) + end + + defp signature_status(_text_body, _public_keys, _user_id, _sender_email, false), do: nil + + defp signature_status(text_body, public_keys, user_id, sender_email, true) do + keys = public_keys ++ known_sender_keys(sender_email, user_id) + + case PGP.verify_signed_message(text_body, keys) do + {:ok, info} -> Map.put(info, "checked_with", "public_key") + {:error, reason} -> %{"status" => "unverified", "reason" => inspect(reason)} + end + end + + defp known_sender_keys(sender_email, user_id) when is_binary(sender_email) do + case PGP.lookup_recipient_key(sender_email, user_id, fetch_remote: false) do + {:ok, key} -> [key] + {:error, _reason} -> [] + end + end + + defp known_sender_keys(_sender_email, _user_id), do: [] + + defp pgp_metadata_present?(value), do: value not in [nil, false, [], %{}] + + defp sanitize_headers_map(nil) do + nil + end + + defp sanitize_headers_map(headers) when is_map(headers) do + headers |> Enum.map(fn {k, v} -> {k, sanitize_metadata_value(v)} end) |> Map.new() + end + + defp sanitize_headers_map(_) do + nil + end + + defp sanitize_metadata_value(value) when is_binary(value) do + Elektrine.Email.Sanitizer.sanitize_utf8(value) + end + + defp sanitize_metadata_value(value) when is_list(value) do + Enum.map(value, &sanitize_metadata_value/1) + end + + defp sanitize_metadata_value(value) when is_map(value) do + value |> Enum.map(fn {k, v} -> {k, sanitize_metadata_value(v)} end) |> Map.new() + end + + defp sanitize_metadata_value(value) do + value + end + + defp spam?(params) do + case params["spam"] do + true -> true + "true" -> true + 1 -> true + "1" -> true + _ -> false + end + end + + defp auto_create_mailbox_if_valid(email) do + case extract_username_and_domain(email) do + {username, domain} -> + supported_domains = + Elektrine.Domains.supported_email_domains() + + if domain in supported_domains do + user = find_user_for_email(username, email) + + case user do + nil -> + {:error, :user_not_found} + + user -> + # Supported local domains should resolve to one canonical mailbox row per user. + case Email.ensure_user_has_mailbox(user) do + {:ok, mailbox} -> + {:ok, mailbox} + + {:error, changeset} -> + Logger.error("Failed to ensure mailbox #{email}: #{inspect(changeset.errors)}") + {:error, :mailbox_creation_failed} + end + end + else + {:error, :unsupported_domain} + end + + nil -> + {:error, :invalid_email_format} + end + end + + defp find_user_for_email(username, _full_email) do + import Ecto.Query + + case Elektrine.Accounts.get_user_by_username(username) do + nil -> + from(u in Elektrine.Accounts.User, + where: fragment("LOWER(?)", u.username) == ^String.downcase(username), + limit: 1 + ) + |> Elektrine.Repo.one() + + user -> + user + end + end + + defp fix_orphaned_mailbox(mailbox) do + case String.split(mailbox.email, "@") do + [username, domain] -> + supported_domains = + Elektrine.Domains.supported_email_domains() + + if domain in supported_domains do + case Elektrine.Accounts.get_user_by_username(username) do + nil -> + {:error, :user_not_found} + + user -> + changeset = Elektrine.Email.Mailbox.changeset(mailbox, %{user_id: user.id}) + + case Elektrine.Repo.update(changeset) do + {:ok, updated_mailbox} -> {:ok, updated_mailbox} + {:error, changeset} -> {:error, changeset.errors} + end + end + else + {:error, :unsupported_domain} + end + + _ -> + {:error, :invalid_email_format} + end + end + + defp check_blocked_sender(user_id, params) do + from_email = params["from"] + + if Email.blocked?(user_id, from_email) do + {:error, :sender_blocked} + else + :ok + end + end + + defp apply_user_filters(message, user_id) do + actions = Email.apply_filters(user_id, message) + + case Email.execute_actions(message, actions) do + {:ok, updated_message} -> updated_message + {:error, _reason} -> message + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/safe_sender.ex b/apps/elektrine_email/lib/elektrine/email/safe_sender.ex new file mode 100644 index 0000000..9c93b18 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/safe_sender.ex @@ -0,0 +1,70 @@ +defmodule Elektrine.Email.SafeSender do + @moduledoc """ + Schema for safe/whitelisted email senders. + Emails from safe senders bypass spam filtering. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "email_safe_senders" do + field :email, :string + field :domain, :string + + belongs_to :user, Elektrine.Accounts.User + + timestamps() + end + + @doc """ + Creates a changeset for adding a safe sender. + Either email or domain must be provided, but not both. + """ + def changeset(safe_sender, attrs) do + safe_sender + |> cast(attrs, [:email, :domain, :user_id]) + |> validate_required([:user_id]) + |> validate_email_or_domain() + |> normalize_email() + |> normalize_domain() + |> foreign_key_constraint(:user_id) + |> unique_constraint([:user_id, :email]) + |> unique_constraint([:user_id, :domain]) + end + + defp validate_email_or_domain(changeset) do + email = get_field(changeset, :email) + domain = get_field(changeset, :domain) + + cond do + is_nil(email) && is_nil(domain) -> + add_error(changeset, :email, "either email or domain must be provided") + + !is_nil(email) && !is_nil(domain) -> + add_error(changeset, :email, "cannot add both email and domain in same entry") + + !is_nil(email) -> + validate_format(changeset, :email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, + message: "must be a valid email format" + ) + + !is_nil(domain) -> + validate_format(changeset, :domain, ~r/^[a-zA-Z0-9][a-zA-Z0-9\-\.]*\.[a-zA-Z]{2,}$/, + message: "must be a valid domain format" + ) + end + end + + defp normalize_email(changeset) do + case get_change(changeset, :email) do + nil -> changeset + email -> put_change(changeset, :email, String.downcase(String.trim(email))) + end + end + + defp normalize_domain(changeset) do + case get_change(changeset, :domain) do + nil -> changeset + domain -> put_change(changeset, :domain, String.downcase(String.trim(domain))) + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/safe_senders.ex b/apps/elektrine_email/lib/elektrine/email/safe_senders.ex new file mode 100644 index 0000000..418a6de --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/safe_senders.ex @@ -0,0 +1,135 @@ +defmodule Elektrine.Email.SafeSenders do + @moduledoc """ + Context module for managing safe/whitelisted email senders. + """ + import Ecto.Query + alias Elektrine.Email.SafeSender + alias Elektrine.Repo + + @doc """ + Lists all safe senders for a user. + """ + def list_safe_senders(user_id) do + SafeSender + |> where(user_id: ^user_id) + |> order_by(desc: :inserted_at) + |> Repo.all() + end + + @doc """ + Gets a safe sender by ID for a user. + """ + def get_safe_sender(id, user_id) do + SafeSender + |> where(id: ^id, user_id: ^user_id) + |> Repo.one() + end + + @doc """ + Creates a safe sender entry. + """ + def create_safe_sender(attrs) do + %SafeSender{} + |> SafeSender.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Adds an email to safe senders. + """ + def add_safe_email(user_id, email) do + create_safe_sender(%{ + user_id: user_id, + email: email + }) + end + + @doc """ + Adds a domain to safe senders. + """ + def add_safe_domain(user_id, domain) do + create_safe_sender(%{ + user_id: user_id, + domain: domain + }) + end + + @doc """ + Updates a safe sender entry. + """ + def update_safe_sender(%SafeSender{} = safe_sender, attrs) do + safe_sender + |> SafeSender.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a safe sender entry. + """ + def delete_safe_sender(%SafeSender{} = safe_sender) do + Repo.delete(safe_sender) + end + + @doc """ + Removes an email from safe senders. + """ + def remove_safe_email(user_id, email) do + email = String.downcase(String.trim(email)) + + SafeSender + |> where(user_id: ^user_id, email: ^email) + |> Repo.delete_all() + end + + @doc """ + Removes a domain from safe senders. + """ + def remove_safe_domain(user_id, domain) do + domain = String.downcase(String.trim(domain)) + + SafeSender + |> where(user_id: ^user_id, domain: ^domain) + |> Repo.delete_all() + end + + @doc """ + Checks if an email address is from a safe sender. + """ + def safe?(user_id, from_email) when is_binary(from_email) do + email = extract_email(from_email) |> String.downcase() + domain = extract_domain(email) + + query = + from s in SafeSender, + where: s.user_id == ^user_id, + where: s.email == ^email or s.domain == ^domain, + select: count(s.id) + + Repo.one(query) > 0 + end + + def safe?(_, _), do: false + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking safe sender changes. + """ + def change_safe_sender(%SafeSender{} = safe_sender, attrs \\ %{}) do + SafeSender.changeset(safe_sender, attrs) + end + + # Extract just the email address from "Name " format + defp extract_email(email_string) when is_binary(email_string) do + case Regex.run(~r/<([^>]+)>/, email_string) do + [_, email] -> String.trim(email) + nil -> String.trim(email_string) + end + end + + # Extract domain from email address + defp extract_domain(email) do + case String.split(email, "@") do + [_, domain] -> domain + _ -> "" + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/sanitizer.ex b/apps/elektrine_email/lib/elektrine/email/sanitizer.ex new file mode 100644 index 0000000..0ecf980 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/sanitizer.ex @@ -0,0 +1,479 @@ +defmodule Elektrine.Email.Sanitizer do + @moduledoc "Unified email sanitization module for all email paths (incoming, outgoing, forwarding).\n\nThis is the ONLY module you should use for email sanitization. It provides:\n- Permissive HTML filtering for email markup, styles, and layout attributes\n- Removal of active/dangerous content such as scripts, event handlers, and unsafe URL protocols\n- Header sanitization (prevents SMTP injection)\n- UTF-8 validation and fixing\n- Comprehensive protection across all email paths\n\n## Usage\n\n # Incoming emails\n sanitized = Sanitizer.sanitize_incoming_email(email_params)\n\n # Outgoing emails\n sanitized = Sanitizer.sanitize_outgoing_email(email_params)\n\n # Just HTML content\n safe_html = Sanitizer.sanitize_html_content(html_string)\n" + alias Elektrine.Email.HeaderSanitizer + + @dangerous_url_attribute_pattern ~r/\s((?:href|src|srcset|poster|background|cite|longdesc|xlink:href|action|formaction)\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i + + @doc "Sanitizes an incoming email before storage.\n\nApplies:\n- Header sanitization (from, to, cc, bcc, subject)\n- HTML content scrubbing\n- UTF-8 validation\n\nSpecial handling for PGP encrypted/signed emails:\n- Preserves PGP blocks intact (BEGIN/END markers and content)\n- Skips aggressive HTML sanitization for PGP content\n" + def sanitize_incoming_email(email_params) do + if is_pgp_email?(email_params) do + require Logger + + Logger.info( + "PGP email detected - using minimal sanitization to preserve encryption/signatures" + ) + + email_params |> sanitize_headers() |> sanitize_pgp_email_content() + else + email_params |> sanitize_headers() |> sanitize_body_content() + end + end + + @doc "Sanitizes an outgoing email before sending.\n\nApplies:\n- Header sanitization (from, to, cc, bcc, subject, reply_to)\n- HTML content scrubbing\n- UTF-8 validation\n" + def sanitize_outgoing_email(email_params) do + email_params |> sanitize_headers() |> sanitize_body_content() + end + + defp force_valid_utf8(content) when is_binary(content) do + content + |> replace_invalid_utf8([]) + |> remove_null_bytes() + end + + @doc "Sanitizes email headers to prevent SMTP injection and other attacks.\n" + def sanitize_headers(email_params) when is_map(email_params) do + email_params + |> sanitize_header_field("from", :from) + |> sanitize_header_field("to", :to) + |> sanitize_header_field("cc", :cc) + |> sanitize_header_field("bcc", :bcc) + |> sanitize_header_field("reply_to", :reply_to) + |> sanitize_header_field("subject", :subject) + end + + defp sanitize_header_field(params, string_field, atom_field) when is_map(params) do + cond do + Map.has_key?(params, atom_field) -> + sanitize_header_value(params, atom_field, string_field) + + Map.has_key?(params, string_field) -> + sanitize_header_value(params, string_field, string_field) + + true -> + params + end + end + + defp sanitize_header_value(params, key, field_type) do + case Map.get(params, key) do + value when is_binary(value) -> + sanitized = + if field_type == "subject" do + HeaderSanitizer.sanitize_subject_header(value) + else + HeaderSanitizer.sanitize_email_header(value) + end + + Map.put(params, key, sanitized) + + _ -> + params + end + end + + @doc "Sanitizes email body content (both HTML and text).\n" + def sanitize_body_content(email_params) when is_map(email_params) do + email_params |> sanitize_html_body() |> sanitize_text_body() + end + + defp sanitize_html_body(params) do + use_atoms = is_map_key(params, :html_body) + html_content = Map.get(params, "html_body") || Map.get(params, :html_body) + + case html_content do + nil -> + params + + "" -> + params + + content when is_binary(content) -> + sanitized = sanitize_html_content(content) + + if use_atoms do + Map.put(params, :html_body, sanitized) + else + Map.put(params, "html_body", sanitized) + end + + _ -> + params + end + end + + defp sanitize_text_body(params) do + use_atoms = is_map_key(params, :text_body) + text_content = Map.get(params, "text_body") || Map.get(params, :text_body) + + case text_content do + nil -> + params + + "" -> + params + + content when is_binary(content) -> + sanitized = sanitize_utf8(content) + + if use_atoms do + Map.put(params, :text_body, sanitized) + else + Map.put(params, "text_body", sanitized) + end + + _ -> + params + end + end + + @doc "Sanitizes HTML content using permissive email-safe filtering.\n\nThis is the core HTML sanitization function that:\n1. Ensures valid UTF-8\n2. Preserves email layout markup, attributes, inline styles, and CSS\n3. Removes active/dangerous tags (script, iframe, form controls, etc.)\n4. Removes event handlers (onclick, onload, etc.)\n5. Removes dangerous URL protocols (javascript:, vbscript:, data:text/html)\n" + def sanitize_html_content(html_content) when is_binary(html_content) do + html_content + |> ensure_valid_utf8() + |> sanitize_email_markup() + end + + def sanitize_html_content(nil) do + nil + end + + defp ensure_valid_utf8(content), do: sanitize_utf8(content) + + defp remove_dangerous_tags(content) do + content + |> String.replace(~r/]*>.*?<\/script>/is, "") + |> String.replace(~r/]*>/i, "") + |> String.replace(~r/<\/script>/i, "") + |> String.replace(~r/]*>/i, "") + |> String.replace(~r/]*http-equiv[^>]*>/i, "") + |> String.replace( + ~r/]*rel=["']?(?:prefetch|preconnect|dns-prefetch|modulepreload|preload|import)[^>]*>/i, + "" + ) + |> String.replace(~r/]*>.*?<\/iframe>/is, "") + |> String.replace(~r/]*>.*?<\/frame>/is, "") + |> String.replace(~r/]*>.*?<\/frameset>/is, "") + |> String.replace(~r/]*>.*?<\/object>/is, "") + |> String.replace(~r/]*>/is, "") + |> String.replace(~r/]*>.*?<\/svg>/is, "") + |> String.replace(~r/]*>/i, "") + |> String.replace(~r/<\/svg>/i, "") + |> String.replace(~r/]*>.*?<\/math>/is, "") + |> String.replace(~r/]*>/i, "") + |> String.replace(~r/<\/math>/i, "") + |> String.replace(~r/]*>.*?<\/applet>/is, "") + |> String.replace(~r/]*>/i, "") + |> String.replace(~r/<\/form>/i, "") + |> String.replace(~r/]*>/is, "") + |> String.replace(~r/]*>(.*?)<\/textarea>/is, "\\1") + |> String.replace(~r/]*>(.*?)<\/select>/is, "\\1") + end + + defp remove_processing_instructions(content) do + String.replace(content, ~r/<\?[^>]*\?>/s, "") + end + + defp remove_doctype_declarations(content) do + String.replace(content, ~r/]*>/i, "") + end + + defp remove_xml_namespace_attributes(content) do + String.replace( + content, + ~r/\s+xmlns(?::[A-Za-z_][\w.-]*)?\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i, + "" + ) + end + + defp remove_outlook_conditional_comments(content) do + content + |> preserve_non_mso_conditional_content() + |> remove_mso_conditional_content() + |> strip_conditional_comment_markers() + end + + defp preserve_non_mso_conditional_content(content) do + content + |> String.replace( + ~r/|)\s*(.*?)\s*/is, + "\\1" + ) + |> String.replace( + ~r//is, + "\\1" + ) + end + + defp remove_mso_conditional_content(content) do + String.replace( + content, + ~r//is, + "" + ) + end + + defp strip_conditional_comment_markers(content) do + content + |> String.replace(~r/|)/is, "") + |> String.replace(~r//i, "") + |> String.replace(~r//i, "") + end + + defp remove_event_handlers(content) do + content + |> String.replace(~r/[\s\/]on\w+\s*=\s*["'][^"']*["']/i, "") + |> String.replace(~r/[\s\/]on\w+\s*=\s*[^\s>]+/i, "") + end + + defp remove_dangerous_protocols(content) do + Regex.replace(@dangerous_url_attribute_pattern, content, fn full, + prefix, + double_quoted, + single_quoted, + unquoted -> + value = first_present([double_quoted, single_quoted, unquoted]) + attribute = prefix |> String.split("=", parts: 2) |> List.first() |> String.trim() + + if dangerous_url_attribute?(attribute, value) do + "" + else + full + end + end) + end + + defp remove_dangerous_css(content) do + content + |> remove_dangerous_css_urls() + |> String.replace(~r/expression\s*\([^)]*\)/i, "") + |> String.replace(~r/(?:behavior|-moz-binding)\s*:\s*url\s*\([^)]*\)\s*;?/i, "") + end + + defp remove_dangerous_css_urls(content) do + content + |> replace_quoted_css_urls() + |> replace_unquoted_css_urls() + end + + defp replace_quoted_css_urls(content) do + Regex.replace(~r/url\(\s*(["'])(.*?)\1\s*\)/is, content, fn full, _quote, value -> + if dangerous_url?(value), do: "none", else: full + end) + end + + defp replace_unquoted_css_urls(content) do + Regex.replace(~r/url\(\s*(.*?)\s*\)+(?=\s*[;>}])/is, content, fn full, value -> + if dangerous_url?(value), do: "none", else: full + end) + end + + defp sanitize_email_markup(content) when is_binary(content) do + content + |> remove_processing_instructions() + |> remove_doctype_declarations() + |> remove_xml_namespace_attributes() + |> remove_outlook_conditional_comments() + |> remove_dangerous_tags() + |> remove_dangerous_attributes() + |> remove_event_handlers() + |> remove_dangerous_protocols() + |> remove_dangerous_css() + end + + defp remove_dangerous_attributes(content) do + String.replace( + content, + ~r/\s(?:action|formaction|method|enctype|srcdoc)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i, + "" + ) + end + + defp dangerous_url_attribute?(attribute, value) do + attribute = String.downcase(attribute || "") + + if attribute == "srcset" do + value + |> String.split(",") + |> Enum.any?(fn entry -> + entry + |> String.trim() + |> String.split(~r/\s+/, parts: 2) + |> List.first() + |> dangerous_url?() + end) + else + dangerous_url?(value) + end + end + + defp dangerous_url?(value) when is_binary(value) do + normalized = + value + |> HtmlEntities.decode() + |> String.replace(~r/[\x00-\x1F\x7F\s]+/u, "") + |> String.downcase() + + String.starts_with?(normalized, "javascript:") or + String.starts_with?(normalized, "vbscript:") or + String.starts_with?(normalized, "data:text/html") + end + + defp dangerous_url?(_value), do: false + + defp first_present(values) do + Enum.find(values, "", &(&1 not in [nil, ""])) + end + + @doc "Sanitizes UTF-8 content (for text bodies and other text fields).\nGUARANTEES valid UTF-8 output suitable for JSON encoding and PostgreSQL.\nRemoves null bytes which PostgreSQL does not allow in text fields.\n" + def sanitize_utf8(content) when is_binary(content) do + if String.valid?(content), do: remove_null_bytes(content), else: force_valid_utf8(content) + end + + def sanitize_utf8(nil) do + "" + end + + defp remove_null_bytes(content) when is_binary(content) do + String.replace(content, <<0>>, "") + end + + defp remove_null_bytes(content) do + content + end + + defp replace_invalid_utf8(content, acc) do + case :unicode.characters_to_binary(content, :utf8, :utf8) do + valid when is_binary(valid) -> + IO.iodata_to_binary(Enum.reverse([valid | acc])) + + {:error, valid, <<_invalid, rest::binary>>} -> + replace_invalid_utf8(rest, ["�", valid | acc]) + + {:incomplete, valid, _rest} -> + IO.iodata_to_binary(Enum.reverse(["�", valid | acc])) + end + end + + defp normalize_attachments(attachments) when is_list(attachments) do + attachments + end + + defp normalize_attachments(attachments) when is_map(attachments) do + Map.values(attachments) + end + + defp normalize_attachments(_) do + [] + end + + defp is_pgp_email?(email_params) when is_map(email_params) do + text_body = Map.get(email_params, "text_body") || Map.get(email_params, :text_body) || "" + html_body = Map.get(email_params, "html_body") || Map.get(email_params, :html_body) || "" + plain_body = Map.get(email_params, "plain_body") || Map.get(email_params, :plain_body) || "" + + content_type = + Map.get(email_params, "content_type") || Map.get(email_params, :content_type) || "" + + raw_attachments = + Map.get(email_params, "attachments") || Map.get(email_params, :attachments) || [] + + attachments = normalize_attachments(raw_attachments) + + has_pgp_block = + String.contains?(text_body, "-----BEGIN PGP") || + String.contains?(html_body, "-----BEGIN PGP") || + String.contains?(plain_body, "-----BEGIN PGP") + + has_pgp_mime = + String.contains?(content_type, "multipart/encrypted") || + String.contains?(content_type, "multipart/signed") || + String.contains?(content_type, "application/pgp") + + has_pgp_attachment = + Enum.any?(attachments, fn attachment -> + ct = attachment["content_type"] || attachment["mime_type"] || "" + name = attachment["filename"] || attachment["name"] || "" + + String.contains?(ct, "application/pgp") || + (String.contains?(ct, "application/octet-stream") && String.ends_with?(name, ".asc")) || + String.ends_with?(name, ".gpg") || String.ends_with?(name, ".pgp") + end) + + has_pgp_block || has_pgp_mime || has_pgp_attachment + end + + defp sanitize_pgp_email_content(email_params) when is_map(email_params) do + result = + email_params + |> sanitize_pgp_body_field("text_body") + |> sanitize_pgp_body_field("html_body") + |> sanitize_pgp_body_field("plain_body") + |> sanitize_pgp_body_field(:text_body) + |> sanitize_pgp_body_field(:html_body) + |> sanitize_pgp_body_field(:plain_body) + + validate_all_fields_utf8(result) + end + + defp sanitize_pgp_body_field(params, field) when is_map(params) do + case Map.get(params, field) do + nil -> + params + + "" -> + params + + content when is_binary(content) -> + sanitized = content |> remove_null_bytes() |> ensure_minimal_utf8() + Map.put(params, field, sanitized) + + _ -> + params + end + end + + defp ensure_minimal_utf8(content) when is_binary(content) do + if String.valid?(content) do + content + else + require Logger + Logger.warning("Invalid UTF-8 detected in PGP content - attempting safe recovery") + + String.codepoints(content) + |> Enum.map(fn codepoint -> + if String.valid?(codepoint) do + codepoint + else + "�" + end + end) + |> Enum.map_join("", & &1) + end + end + + defp validate_all_fields_utf8(params) when is_map(params) do + Enum.reduce(params, params, fn {key, value}, acc -> + validated_value = + case value do + v when is_binary(v) -> + if String.valid?(v) do + v + else + require Logger + + Logger.error( + "Field '#{key}' has invalid UTF-8 after PGP sanitization - forcing valid" + ) + + force_valid_utf8(v) + end + + _ -> + value + end + + Map.put(acc, key, validated_value) + end) + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/search.ex b/apps/elektrine_email/lib/elektrine/email/search.ex new file mode 100644 index 0000000..5e56fd9 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/search.ex @@ -0,0 +1,244 @@ +defmodule Elektrine.Email.Search do + @moduledoc """ + Email search functionality. + Handles message search with support for blind index searching of encrypted content. + """ + + import Ecto.Query, warn: false + alias Elektrine.Email.{Mailbox, Message} + alias Elektrine.Repo + + # Private helper to decrypt email messages + defp decrypt_email_messages(messages, mailbox_id) when is_list(messages) do + case Elektrine.Email.Mailboxes.get_mailbox(mailbox_id) do + %Mailbox{user_id: user_id} when not is_nil(user_id) -> + Message.decrypt_messages(messages, user_id) + + _ -> + messages + end + end + + @doc """ + Searches messages in a mailbox. + Supports searching in from, to, cc, subject, and body content. + Returns paginated results with metadata. + """ + def search_messages(mailbox_id, query, page \\ 1, per_page \\ 20) do + page = max(page, 1) + offset = (page - 1) * per_page + + # Get user_id from mailbox for blind index search + case Elektrine.Email.Mailboxes.get_mailbox(mailbox_id) do + nil -> + %{ + messages: [], + query: query, + page: page, + per_page: per_page, + total_count: 0, + total_pages: 0, + has_next: false, + has_prev: false + } + + mailbox -> + user_id = mailbox.user_id + + # Extract keywords from query and hash them for blind index search + keywords = Elektrine.Encryption.extract_keywords(query) + + if Enum.empty?(keywords) do + # No valid keywords, return empty results + %{ + messages: [], + query: query, + page: page, + per_page: per_page, + total_count: 0, + total_pages: 0, + has_next: false, + has_prev: false + } + else + # Hash each keyword for this user + keyword_hashes = + Enum.map(keywords, fn kw -> + Elektrine.Encryption.hash_keyword(kw, user_id) + end) + + # Build search query using blind index + base_query = + Message + |> where(mailbox_id: ^mailbox_id) + |> where([m], not m.spam) + |> where([m], not m.archived) + |> where([m], not m.deleted) + |> where([m], m.status != "sent" or is_nil(m.status) or m.from == m.to) + + # Split query into terms for metadata search (subject, from, to, cc) + search_terms = String.split(String.trim(query), " ") + + # Apply search filters - search metadata fields with ILIKE and body with blind index + search_query = + Enum.reduce(search_terms, base_query, fn term, acc_query -> + # Sanitize search term to prevent LIKE pattern injection + safe_term = sanitize_search_term(term) + search_term = "%#{String.downcase(safe_term)}%" + + where( + acc_query, + [m], + ilike(fragment("LOWER(?)", m.from), ^search_term) or + ilike(fragment("LOWER(?)", m.to), ^search_term) or + ilike(fragment("LOWER(?)", m.cc), ^search_term) or + ilike(fragment("LOWER(?)", m.subject), ^search_term) or + fragment("? && ?", m.search_index, ^keyword_hashes) + ) + end) + + # Get total count + total_count = search_query |> Repo.aggregate(:count) + + # Get messages for current page + messages = + search_query + |> order_by(desc: :inserted_at) + |> limit(^per_page) + |> offset(^offset) + |> Repo.all() + |> decrypt_email_messages(mailbox_id) + + # Calculate pagination metadata + total_pages = ceil(total_count / per_page) + has_next = page < total_pages + has_prev = page > 1 + + %{ + messages: messages, + query: query, + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages, + has_next: has_next, + has_prev: has_prev + } + end + end + end + + @doc """ + Gets all unique recipient domains from sent messages across all users. + Returns a list of %{domain: domain, count: message_count} maps. + """ + def get_unique_recipient_domains do + # Query to extract domains from TO, CC, and BCC fields in sent messages + query = """ + WITH recipient_domains AS ( + SELECT + LOWER(REGEXP_REPLACE( + REGEXP_REPLACE( + unnest(string_to_array(m.to || ',' || COALESCE(m.cc, '') || ',' || COALESCE(m.bcc, ''), ',')), + '.*<(.+@.+)>.*', '\\1' + ), + '^[^@]+@(.+)$', '\\1' + )) as domain, + m.id as message_id + FROM email_messages m + WHERE m.status = 'sent' + AND (m.to IS NOT NULL OR m.cc IS NOT NULL OR m.bcc IS NOT NULL) + ) + SELECT domain, COUNT(DISTINCT message_id) as message_count + FROM recipient_domains + WHERE domain IS NOT NULL + AND domain != '' + AND domain ~ '^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$' + GROUP BY domain + ORDER BY message_count DESC, domain + """ + + case Repo.query(query) do + {:ok, result} -> + result.rows + |> Enum.map(fn [domain, count] -> %{domain: domain, count: count} end) + # Exclude our own domains + |> Enum.reject(&(&1.domain in Elektrine.Domains.supported_email_domains())) + + {:error, _} -> + [] + end + end + + @doc """ + Gets unique recipient domains with pagination. + """ + def get_unique_recipient_domains_paginated(page \\ 1, per_page \\ 50) do + offset = (page - 1) * per_page + + # Same query as above but with pagination + query = """ + WITH recipient_domains AS ( + SELECT + LOWER(REGEXP_REPLACE( + REGEXP_REPLACE( + unnest(string_to_array(m.to || ',' || COALESCE(m.cc, '') || ',' || COALESCE(m.bcc, ''), ',')), + '.*<(.+@.+)>.*', '\\1' + ), + '^[^@]+@(.+)$', '\\1' + )) as domain, + m.id as message_id + FROM email_messages m + WHERE m.status = 'sent' + AND (m.to IS NOT NULL OR m.cc IS NOT NULL OR m.bcc IS NOT NULL) + ) + SELECT domain, COUNT(DISTINCT message_id) as message_count + FROM recipient_domains + WHERE domain IS NOT NULL + AND domain != '' + AND domain ~ '^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$' + GROUP BY domain + ORDER BY message_count DESC, domain + LIMIT $1 OFFSET $2 + """ + + # Get total count + count_query = """ + WITH recipient_domains AS ( + SELECT + LOWER(REGEXP_REPLACE( + REGEXP_REPLACE( + unnest(string_to_array(m.to || ',' || COALESCE(m.cc, '') || ',' || COALESCE(m.bcc, ''), ',')), + '.*<(.+@.+)>.*', '\\1' + ), + '^[^@]+@(.+)$', '\\1' + )) as domain + FROM email_messages m + WHERE m.status = 'sent' + AND (m.to IS NOT NULL OR m.cc IS NOT NULL OR m.bcc IS NOT NULL) + ) + SELECT COUNT(DISTINCT domain) as total + FROM recipient_domains + WHERE domain IS NOT NULL + AND domain != '' + AND domain ~ '^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$' + """ + + case {Repo.query(query, [per_page, offset]), Repo.query(count_query)} do + {{:ok, result}, {:ok, count_result}} -> + domains = + result.rows + |> Enum.map(fn [domain, count] -> %{domain: domain, count: count} end) + |> Enum.reject(&(&1.domain in Elektrine.Domains.supported_email_domains())) + + total_count = count_result.rows |> List.first() |> List.first() || 0 + {domains, total_count} + + _ -> + {[], 0} + end + end + + # Sanitize search terms to prevent LIKE pattern injection + defp sanitize_search_term(term), do: Elektrine.TextHelpers.sanitize_search_term(term) +end diff --git a/apps/elektrine_email/lib/elektrine/email/send_email_worker.ex b/apps/elektrine_email/lib/elektrine/email/send_email_worker.ex new file mode 100644 index 0000000..c6860bb --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/send_email_worker.ex @@ -0,0 +1,241 @@ +defmodule Elektrine.Email.SendEmailWorker do + @moduledoc """ + Oban worker for sending emails in the background. + + Replaces the old EmailSendWorker GenServer with guaranteed delivery + and automatic retries. + """ + + use Oban.Worker, + queue: :email, + max_attempts: 5, + unique: [period: 300, fields: [:args], keys: [:email_id]] + + require Logger + + alias Elektrine.Email.Sender + alias Elektrine.JMAP + + @impl Oban.Worker + def perform(%Oban.Job{args: args}) do + user_id = args["user_id"] + email_attrs = atomize_keys(args["email_attrs"]) + attachments = if args["attachments"], do: atomize_keys(args["attachments"]), else: nil + submission = load_submission(args["submission_id"]) + + Logger.info("SendEmailWorker processing email for user #{user_id}") + + case maybe_send_email(submission, user_id, email_attrs, attachments) do + {:ok, result} -> + Logger.info("Email sent successfully for user #{user_id}") + reconcile_submission_success(submission, result) + + # Update user storage after successful send + Elektrine.Accounts.Storage.update_user_storage(user_id) + :ok + + {:skip, reason} -> + Logger.info("SendEmailWorker skipped email for user #{user_id}: #{reason}") + :ok + + {:error, :rate_limit_exceeded} -> + # Snooze for 60 seconds on rate limit + {:snooze, 60} + + {:error, reason} -> + Logger.error("Email send failed for user #{user_id}: #{inspect(reason)}") + reconcile_submission_failure(submission, reason) + {:error, reason} + end + end + + @doc """ + Queue an email to be sent in the background. + + Options: + - :scheduled_for - DateTime to schedule the email for later + """ + def enqueue(user_id, email_attrs, attachments \\ nil, opts \\ []) do + scheduled_for = Keyword.get(opts, :scheduled_for) + submission_id = Keyword.get(opts, :submission_id) + + args = + %{ + "user_id" => user_id, + "email_attrs" => stringify_keys(email_attrs), + "attachments" => if(attachments, do: stringify_keys(attachments), else: nil), + "email_id" => enqueue_dedup_key(submission_id) + } + |> maybe_put_submission_id(submission_id) + + job_opts = + if scheduled_for do + [scheduled_at: scheduled_for] + else + [] + end + + args + |> new(job_opts) + |> Elektrine.JobQueue.insert() + end + + # Convert atom keys to strings for JSON storage + defp stringify_keys(nil), do: nil + + defp stringify_keys(map) when is_map(map) do + Map.new(map, fn + {k, v} when is_atom(k) -> {Atom.to_string(k), stringify_keys(v)} + {k, v} -> {k, stringify_keys(v)} + end) + end + + defp stringify_keys(list) when is_list(list), do: Enum.map(list, &stringify_keys/1) + defp stringify_keys(value), do: value + + # Convert string keys back to atoms for Sender compatibility + # Allowlist of valid keys for email sending to prevent atom exhaustion DoS + @valid_email_keys %{ + "to" => :to, + "from" => :from, + "cc" => :cc, + "bcc" => :bcc, + "subject" => :subject, + "text_body" => :text_body, + "html_body" => :html_body, + "reply_to" => :reply_to, + "message_id" => :message_id, + "in_reply_to" => :in_reply_to, + "references" => :references, + "attachments" => :attachments, + "headers" => :headers, + "priority" => :priority, + "content_type" => :content_type, + "charset" => :charset, + "mailbox_id" => :mailbox_id, + "user_id" => :user_id, + "scheduled_at" => :scheduled_at, + "expires_at" => :expires_at, + "list_id" => :list_id + } + + defp atomize_keys(nil), do: nil + + defp atomize_keys(map) when is_map(map) do + Map.new(map, fn + {k, v} when is_binary(k) -> + case Map.fetch(@valid_email_keys, k) do + {:ok, atom_key} -> {atom_key, atomize_keys(v)} + :error -> {k, atomize_keys(v)} + end + + {k, v} when is_atom(k) -> + {k, atomize_keys(v)} + + {k, v} -> + {k, atomize_keys(v)} + end) + end + + defp atomize_keys(list) when is_list(list), do: Enum.map(list, &atomize_keys/1) + defp atomize_keys(value), do: value + + defp maybe_send_email(%{undo_status: "canceled"}, _user_id, _email_attrs, _attachments), + do: {:skip, "submission canceled"} + + defp maybe_send_email(%{undo_status: "final"}, _user_id, _email_attrs, _attachments), + do: {:skip, "submission already finalized"} + + defp maybe_send_email(:none, user_id, email_attrs, attachments) do + Sender.send_email(user_id, email_attrs, attachments) + end + + defp maybe_send_email(nil, _user_id, _email_attrs, _attachments), + do: {:skip, "submission missing"} + + defp maybe_send_email(_submission, user_id, email_attrs, attachments) do + Sender.send_email(user_id, email_attrs, attachments) + end + + defp load_submission(nil), do: :none + + defp load_submission(submission_id) when is_integer(submission_id) do + JMAP.get_submission(submission_id) + end + + defp load_submission(submission_id) when is_binary(submission_id) do + case Integer.parse(submission_id) do + {int_id, ""} -> JMAP.get_submission(int_id) + _ -> :none + end + end + + defp load_submission(_submission_id), do: :none + + defp reconcile_submission_success(:none, _result), do: :ok + + defp reconcile_submission_success(submission, result) when is_map(result) do + delivery_status = + %{ + "status" => result_field(result, :status) || "sent", + "messageId" => result_field(result, :message_id) + } + |> maybe_put_sent_email_id(result_field(result, :id)) + + JMAP.finalize_submission(submission, delivery_status) + + :ok + end + + defp reconcile_submission_success(submission, _result) do + JMAP.finalize_submission(submission) + :ok + end + + defp reconcile_submission_failure(:none, _reason), do: :ok + + defp reconcile_submission_failure(submission, reason) do + JMAP.fail_submission(submission, reason) + :ok + end + + defp maybe_put_submission_id(args, submission_id) when is_integer(submission_id) do + Map.put(args, "submission_id", submission_id) + end + + defp maybe_put_submission_id(args, submission_id) when is_binary(submission_id) do + Map.put(args, "submission_id", submission_id) + end + + defp maybe_put_submission_id(args, _submission_id), do: args + + defp enqueue_dedup_key(submission_id) when is_integer(submission_id), + do: "submission:#{submission_id}" + + defp enqueue_dedup_key(submission_id) when is_binary(submission_id), + do: "submission:#{submission_id}" + + defp enqueue_dedup_key(_submission_id), do: Ecto.UUID.generate() + + defp maybe_put_sent_email_id(delivery_status, nil), do: delivery_status + + defp maybe_put_sent_email_id(delivery_status, sent_email_id) do + Map.put(delivery_status, "sentEmailId", to_string(sent_email_id)) + end + + defp result_field(result, key) when is_atom(key) do + cond do + is_map(result) and Map.has_key?(result, key) -> + Map.get(result, key) + + is_map(result) and Map.has_key?(result, Atom.to_string(key)) -> + Map.get(result, Atom.to_string(key)) + + match?(%_{}, result) -> + Map.get(result, key) + + true -> + nil + end + end +end diff --git a/apps/elektrine_email/lib/elektrine/email/sender.ex b/apps/elektrine_email/lib/elektrine/email/sender.ex new file mode 100644 index 0000000..e578ca6 --- /dev/null +++ b/apps/elektrine_email/lib/elektrine/email/sender.ex @@ -0,0 +1,2539 @@ +defmodule Elektrine.Email.Sender do + @moduledoc """ + Handles email sending functionality. + + ## Error Types + + This module returns standardized error tuples: + + - `{:error, :rate_limit_exceeded}` - User exceeded short-window email throttle + - `{:error, :insufficient_email_credits}` - User needs Atomine Credits for external email + - `{:error, :no_mailbox}` - User doesn't have a mailbox + - `{:error, :unauthorized_from_address}` - User doesn't own the from address + - `{:error, :forward_to_external}` - Internal email needs external forwarding + - `{:error, :forward_target_not_found}` - Forwarding target couldn't be resolved + - `{:error, :no_mailbox}` - Recipient mailbox not found + - `{:error, :recipient_not_found}` - Recipient doesn't exist + - `{:error, :forwarding_loop_detected}` - Circular forwarding detected + - `{:error, :storage_limit_exceeded}` - User storage limit exceeded + - `{:error, string}` - Validation error with descriptive message + """ + + alias Elektrine.Email + alias Elektrine.Email.ExternalDelivery + alias Elektrine.Email.ExternalDeliveryWorker + alias Elektrine.Email.HeaderDecoder + alias Elektrine.Email.HeaderSanitizer + alias Elektrine.Email.InternalDelivery + alias Elektrine.Email.InternalDeliveryWorker + alias Elektrine.Email.ListTypes + alias Elektrine.Email.Mailbox + alias Elektrine.Email.MimeBodyExtractor + alias Elektrine.Email.PGP + alias Elektrine.Email.RateLimiter + alias Elektrine.Email.Sanitizer + alias Elektrine.Email.Suppressions + alias Elektrine.Email.Unsubscribes + alias Elektrine.EmailAddresses + alias Elektrine.EmailConfig + alias Elektrine.Mailer + alias Elektrine.Repo + alias Elektrine.Telemetry.Events + import Swoosh.Email + + require Logger + + @doc """ + Sends an email from a user's mailbox. + + ## Parameters + + * `user_id` - The ID of the user sending the email + * `params` - Map containing the email parameters: + * `:from` - Sender's email address (should be one of the user's mailboxes) + * `:reply_to` - Reply-to email address (optional, defaults to from address) + * `:to` - Recipient email address(es) (comma separated string or list) + * `:cc` - CC recipients (optional) + * `:bcc` - BCC recipients (optional) + * `:subject` - Email subject + * `:text_body` - Plain text body + * `:html_body` - HTML body (optional) + * `db_attachments` - Optional map of attachments for DB storage (with S3 metadata, no data field) + + ## Returns + + * `{:ok, message}` - If the email was sent successfully + * `{:error, reason}` - If there was an error + """ + def send_email(user_id, params, db_attachments \\ nil) do + started_at = System.monotonic_time(:millisecond) + + to_emails = parse_email_list(params[:to] || params["to"] || "") + cc_emails = parse_email_list(params[:cc] || params["cc"] || "") + bcc_emails = parse_email_list(params[:bcc] || params["bcc"] || "") + + # Check if we have any valid recipients + all_recipients = to_emails ++ cc_emails ++ bcc_emails + list_id = params[:list_id] || params["list_id"] + + if split_list_delivery?(params, list_id, all_recipients) do + send_list_email_individually(user_id, params, db_attachments, all_recipients, started_at) + else + send_email_to_resolved_recipients( + user_id, + params, + db_attachments, + started_at, + to_emails, + cc_emails, + bcc_emails, + all_recipients, + list_id + ) + end + end + + defp send_email_to_resolved_recipients( + user_id, + params, + db_attachments, + started_at, + to_emails, + cc_emails, + bcc_emails, + all_recipients, + list_id + ) do + # Validate recipients first + result = + case validate_recipients(all_recipients) do + {:error, reason} -> + {:error, reason} + + :ok -> + case filter_suppressed_recipients_for_send(user_id, to_emails, cc_emails, bcc_emails) do + {:error, :all_suppressed, reasons} -> + Events.email_outbound(:suppression, :blocked, nil, %{ + user_id: user_id, + reason: summarize_suppression_reasons(reasons) + }) + + {:error, + "All recipients are suppressed due to prior delivery failures or complaints"} + + {:ok, filtered_to, filtered_cc, filtered_bcc, suppressed, reasons} -> + params = put_filtered_recipients(params, filtered_to, filtered_cc, filtered_bcc) + + if suppressed != [] do + Logger.warning( + "Suppressed outbound recipients for user #{user_id}: #{inspect(suppressed)} reasons=#{inspect(reasons)}" + ) + + Events.email_outbound(:suppression, :applied, nil, %{ + user_id: user_id, + suppressed_count: length(suppressed) + }) + end + + # Only check unsubscribes if this is a mass email with an explicit list_id + # Personal emails (SMTP/webmail without list_id) skip this check + if list_id && !transactional_email?(list_id) do + # Mass email - filter out unsubscribed recipients + case filter_unsubscribed_recipients( + filtered_to, + filtered_cc, + filtered_bcc, + list_id + ) do + {:error, :all_unsubscribed} -> + {:error, "All recipients have unsubscribed from this mailing list"} + + {:ok, unsub_to, unsub_cc, unsub_bcc} -> + params = + put_filtered_recipients(params, unsub_to, unsub_cc, unsub_bcc) + + continue_send_email(user_id, params, db_attachments) + end + else + # Personal email (no list_id) or transactional - proceed without filtering + continue_send_email(user_id, params, db_attachments) + end + end + end + + maybe_emit_email_sent_webhook(user_id, params, result) + emit_outbound_telemetry(:request, result, started_at, %{route: :auto}) + result + end + + defp split_list_delivery?(params, list_id, all_recipients) do + list_id && !transactional_email?(list_id) && params[:list_recipient_delivery] != true && + length(all_recipients) > 1 + end + + defp send_list_email_individually(user_id, params, db_attachments, recipients, started_at) do + result = + case maybe_check_rate_limit(user_id, params) do + {:ok, _allowed} -> + results = + recipients + |> Enum.uniq() + |> Enum.map(fn recipient -> + recipient_params = + params + |> Map.put(:to, recipient) + |> Map.put(:cc, "") + |> Map.put(:bcc, "") + |> Map.put(:list_recipient_delivery, true) + |> Map.put(:skip_rate_limit, true) + + send_email(user_id, recipient_params, db_attachments) + end) + + case Enum.split_with(results, &match?({:ok, _}, &1)) do + {[], [{:error, reason} | _]} -> + {:error, reason} + + {successes, []} -> + RateLimiter.record_send(user_id) + {:ok, %{sent_count: length(successes)}} + + {successes, failures} -> + RateLimiter.record_send(user_id) + {:ok, %{sent_count: length(successes), failed_count: length(failures)}} + end + + {:error, :minute_limit_exceeded} -> + {:error, :rate_limit_exceeded} + + {:error, :hourly_limit_exceeded} -> + {:error, :rate_limit_exceeded} + end + + maybe_emit_email_sent_webhook(user_id, params, result) + emit_outbound_telemetry(:request, result, started_at, %{route: :list_split}) + result + end + + # Continue with the actual sending logic + defp continue_send_email(user_id, params, db_attachments) do + started_at = System.monotonic_time(:millisecond) + + # Canonical outbound boundary: + # 1) Parse raw SMTP payload (if present) into structured fields. + # 2) Sanitize once for all downstream routes (internal + external). + prepared_params = prepare_outbound_payload(params) + + to_emails = parse_email_list(prepared_params[:to] || prepared_params["to"] || "") + cc_emails = parse_email_list(prepared_params[:cc] || prepared_params["cc"] || "") + bcc_emails = parse_email_list(prepared_params[:bcc] || prepared_params["bcc"] || "") + all_recipients = to_emails ++ cc_emails ++ bcc_emails + + # Resolve all recipients once and build a cache map to avoid duplicate DB calls. + resolution_cache = build_resolution_cache(all_recipients) + delivery_plan = build_delivery_plan(to_emails, cc_emails, bcc_emails, resolution_cache) + routing_strategy = determine_routing_strategy(delivery_plan) + + result = + with :ok <- validate_internal_delivery_targets(delivery_plan.internal) do + case routing_strategy do + :internal -> + # Handle internal email directly within Phoenix + # Note: Internal emails never get unsubscribe headers (personal communication) + send_internal_email(user_id, prepared_params, db_attachments) + + :external -> + # Send external email via Swoosh + with {:ok, {mailbox, user}} <- get_user_mailbox_with_user(user_id), + {:ok, _ownership} <- + validate_from_address_ownership(prepared_params[:from], user_id), + {:ok, _remaining} <- maybe_check_rate_limit(user_id, prepared_params), + {:ok, _recipient_check} <- check_recipient_limits(user_id, prepared_params), + {:ok, formatted_params} <- format_from_header(prepared_params, user, mailbox), + {:ok, pgp_params} <- maybe_pgp_encrypt(formatted_params, user_id), + external_params <- + put_filtered_recipients( + pgp_params, + delivery_plan.external.to, + delivery_plan.external.cc, + delivery_plan.external.bcc + ), + :ok <- Email.validate_outbound_deliverability(external_params[:from], user_id), + :ok <- validate_external_recipient_domains(external_params), + :ok <- maybe_spend_email_credit(user_id, delivery_plan) do + stored_params = merge_pgp_message_body(formatted_params, pgp_params) + + with {:ok, sent_message} <- + store_sent_message_external(mailbox.id, stored_params, nil, db_attachments), + {:ok, internal_deliveries} <- + create_internal_deliveries( + mailbox.id, + sent_message, + stored_params, + delivery_plan.internal, + db_attachments + ), + {:ok, _delivery} <- + enqueue_external_delivery( + user_id, + mailbox.id, + sent_message, + external_params, + delivery_plan.external + ) do + # Also deliver to any internal recipients without sending them back out + deliver_internal_deliveries_now(internal_deliveries) + + maybe_record_send(user_id, prepared_params) + + # Record recipients for recipient limiting + record_recipients(user_id, formatted_params) + + {:ok, + %{message_id: sent_message.message_id, status: "queued", id: sent_message.id}} + else + {:error, reason} -> + {:error, reason} + end + else + {:error, :hourly_limit_exceeded} -> + Logger.warning("User #{user_id} exceeded hourly email limit") + {:error, :rate_limit_exceeded} + + {:error, :minute_limit_exceeded} -> + Logger.warning("User #{user_id} exceeded per-minute email limit") + {:error, :rate_limit_exceeded} + + {:error, :recipient_limit_exceeded} -> + Logger.warning("User #{user_id} exceeded unique recipient limit") + {:error, :recipient_limit_exceeded} + + {:error, reason} -> + {:error, reason} + end + end + end + + emit_outbound_telemetry(:delivery, result, started_at, %{route: routing_strategy}) + result + end + + defp maybe_emit_email_sent_webhook(user_id, params, {:ok, response}) do + payload = %{ + from: params[:from] || params["from"], + to: parse_email_list(params[:to] || params["to"] || ""), + cc: parse_email_list(params[:cc] || params["cc"] || ""), + bcc_count: length(parse_email_list(params[:bcc] || params["bcc"] || "")), + subject: params[:subject] || params["subject"], + message_id: response[:message_id] || response["message_id"], + status: response[:status] || response["status"] || "sent" + } + + _ = Elektrine.Developer.deliver_event(user_id, "email.sent", payload) + :ok + rescue + _ -> :ok + end + + defp maybe_emit_email_sent_webhook(_user_id, _params, _result), do: :ok + + defp prepare_outbound_payload(params) do + skip_rate_limit? = params[:skip_rate_limit] == true || params["skip_rate_limit"] == true + + prepared = + params + |> parse_raw_email_if_present() + |> Sanitizer.sanitize_outgoing_email() + + if skip_rate_limit?, do: Map.put(prepared, :skip_rate_limit, true), else: prepared + end + + # Check recipient limits for all To, CC, BCC addresses + defp check_recipient_limits(user_id, params) do + all_recipients = extract_all_recipients(params) + + # Check each recipient against the limit + result = + Enum.reduce_while(all_recipients, {:ok, :allowed}, fn recipient, _acc -> + case RateLimiter.check_recipient_limit(user_id, recipient) do + {:ok, :allowed} -> {:cont, {:ok, :allowed}} + {:error, :recipient_limit_exceeded} -> {:halt, {:error, :recipient_limit_exceeded}} + end + end) + + result + end + + # Extract all recipient emails from params + defp extract_all_recipients(params) do + to = normalize_recipients(params[:to]) + cc = normalize_recipients(params[:cc]) + bcc = normalize_recipients(params[:bcc]) + + (to ++ cc ++ bcc) + |> Enum.uniq() + end + + # Normalize recipients to list of email strings + defp normalize_recipients(nil), do: [] + + defp normalize_recipients(recipients) when is_list(recipients) do + Enum.flat_map(recipients, fn + {_name, email} -> split_recipient_string(email) + email when is_binary(email) -> split_recipient_string(email) + end) + end + + defp normalize_recipients({_name, email}), do: split_recipient_string(email) + defp normalize_recipients(email) when is_binary(email), do: split_recipient_string(email) + + defp split_recipient_string(email) when is_binary(email) do + email + |> String.split(",", trim: true) + |> Enum.map(&String.downcase(String.trim(&1))) + |> Enum.reject(&(&1 == "")) + end + + # Record all recipients after successful send + defp record_recipients(user_id, params) do + all_recipients = extract_all_recipients(params) + + Enum.each(all_recipients, fn recipient -> + RateLimiter.record_recipient(user_id, recipient) + end) + end + + # Parse raw SMTP email data if present using Mail library + defp parse_raw_email_if_present(params) do + raw_email = params[:raw_email] || params["raw_email"] + + if raw_email && is_binary(raw_email) && byte_size(raw_email) > 0 do + # Validate the raw email has basic RFC2822 structure (headers + blank line + body) + # Headers must be present and separated from body by \r\n\r\n or \n\n + has_valid_structure = + String.contains?(raw_email, "\r\n\r\n") || String.contains?(raw_email, "\n\n") + + if has_valid_structure do + try do + # Parse email using Mail library for robust RFC2822 parsing + message = Mail.Parsers.RFC2822.parse(raw_email) + + # Extract subject - try direct extraction first to avoid Mail library encoding issues + raw_subject_line = extract_raw_subject_from_email(raw_email) + + # Also get Mail library's version for comparison + mail_subj = Mail.Message.get_header(message, :subject) + + # Use direct extraction if available, otherwise fall back to Mail library + # This avoids Mail library's potential encoding corruption + subject = + cond do + Elektrine.Strings.present?(raw_subject_line) -> + HeaderDecoder.decode_mime_header(raw_subject_line) + + mail_subj -> + HeaderDecoder.decode_mime_header(mail_subj) + + true -> + "(No Subject)" + end + + text_body = MimeBodyExtractor.text_body(message) + html_body = MimeBodyExtractor.html_body(message) + fallback_text_body = raw_text_body_fallback(raw_email, text_body, html_body) + maybe_log_empty_smtp_body(raw_email, message, text_body, html_body, fallback_text_body) + + # Extract attachments + attachments = Elektrine.IMAP.Commands.extract_attachments(nil, nil, message) + + # IMPORTANT: Delete raw_email after parsing so Haraka client uses the parsed + # components with proper RFC 2047 encoding instead of sending raw_base64 + params + |> Map.delete(:raw_email) + |> Map.delete("raw_email") + |> Map.put(:raw_source, raw_email) + |> put_client_sender_header(raw_email) + |> Map.put(:subject, subject) + |> put_if_present(:message_id, extract_raw_header_from_email(raw_email, "Message-ID")) + |> Map.put(:text_body, text_body || fallback_text_body) + |> Map.put(:html_body, html_body) + |> Map.put(:attachments, attachments) + |> put_if_present(:in_reply_to, extract_raw_header_from_email(raw_email, "In-Reply-To")) + |> put_if_present(:references, extract_raw_header_from_email(raw_email, "References")) + rescue + e -> + Logger.warning( + "SMTP: Mail library parse failed: #{Exception.message(e)}, using manual extraction" + ) + + # Fall back to manual extraction when Mail library fails + extract_email_manually(params, raw_email) + end + else + Logger.warning("SMTP: Raw email missing header/body separator, using manual extraction") + extract_email_manually(params, raw_email) + end + else + params + end + end + + # Manual extraction when Mail library fails + defp extract_email_manually(params, raw_email) do + # Try to extract subject directly from headers + subject = + case extract_raw_subject_from_email(raw_email) do + subj when is_binary(subj) -> + if Elektrine.Strings.present?(subj), + do: HeaderDecoder.decode_mime_header(subj), + else: params[:subject] || params["subject"] || "(No Subject)" + + _ -> + params[:subject] || params["subject"] || "(No Subject)" + end + + # Try to extract body - split on first blank line + body = + case String.split(raw_email, ~r/\r?\n\r?\n/, parts: 2) do + [_headers, body_part] -> String.trim(body_part) + _ -> nil + end + + params + |> Map.delete(:raw_email) + |> Map.delete("raw_email") + |> Map.put(:raw_source, raw_email) + |> put_client_sender_header(raw_email) + |> Map.put(:subject, subject) + |> put_if_present(:message_id, extract_raw_header_from_email(raw_email, "Message-ID")) + |> Map.put(:text_body, body || params[:text_body] || params["text_body"]) + |> put_if_present(:in_reply_to, extract_raw_header_from_email(raw_email, "In-Reply-To")) + |> put_if_present(:references, extract_raw_header_from_email(raw_email, "References")) + end + + # Extract raw Subject line from email data for debugging + defp extract_raw_subject_from_email(raw_email) when is_binary(raw_email) do + # Find Subject: header line in raw email + case Regex.run(~r/^Subject:\s*(.+?)(?:\r?\n(?!\s)|\r?\n\r?\n)/ms, raw_email) do + [_, subject] -> + # Handle folded headers (continuation lines starting with whitespace) + subject + |> String.replace(~r/\r?\n\s+/, " ") + |> String.trim() + + _ -> + nil + end + end + + defp extract_raw_header_from_email(raw_email, header_name) when is_binary(raw_email) do + pattern = + ~r/^#{Regex.escape(header_name)}:\s*(.+?)(?:\r?\n(?!\s)|\r?\n\r?\n)/ims + + case Regex.run(pattern, raw_email) do + [_, header_value] -> + header_value + |> String.replace(~r/\r?\n\s+/, " ") + |> String.trim() + |> case do + "" -> nil + value -> value + end + + _ -> + nil + end + end + + defp extract_raw_header_from_email(_, _), do: nil + + defp put_client_sender_header(params, raw_email) do + case extract_raw_header_from_email(raw_email, "From") do + from when is_binary(from) -> + from + |> HeaderDecoder.decode_mime_header() + |> HeaderSanitizer.sanitize_email_header() + |> then(&put_if_present(params, :client_from_header, &1)) + + _ -> + params + end + end + + defp raw_text_body_fallback(raw_email, nil, nil) when is_binary(raw_email) do + case String.split(raw_email, ~r/\r?\n\r?\n/, parts: 2) do + [_headers, body] -> + body + |> String.trim() + |> case do + "" -> nil + value -> value + end + + _ -> + nil + end + end + + defp raw_text_body_fallback(_raw_email, _text_body, _html_body), do: nil + + defp maybe_log_empty_smtp_body(raw_email, message, text_body, html_body, fallback_text_body) do + unless Enum.any?([text_body, html_body, fallback_text_body], &Elektrine.Strings.present?/1) do + {raw_body_size, raw_body_preview_hash} = raw_body_diagnostics(raw_email) + + Logger.warning( + "SMTP: raw message parsed without display body " <> + "raw_size=#{byte_size(raw_email)} raw_body_size=#{raw_body_size} " <> + "raw_body_hash=#{raw_body_preview_hash} " <> + "content_type=#{inspect(Mail.Message.get_header(message, :content_type))} " <> + "content_transfer_encoding=#{inspect(Mail.Message.get_header(message, :content_transfer_encoding))} " <> + "multipart=#{inspect(Map.get(message, :multipart))} parts=#{length(Map.get(message, :parts) || [])}" + ) + end + end + + defp raw_body_diagnostics(raw_email) when is_binary(raw_email) do + raw_body = + case String.split(raw_email, ~r/\r?\n\r?\n/, parts: 2) do + [_headers, body] -> body + _ -> "" + end + + hash = + raw_body + |> String.slice(0, 4096) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + + {byte_size(raw_body), hash} + end + + defp put_if_present(params, _key, value) when not is_binary(value), do: params + + defp put_if_present(params, key, value) do + if Elektrine.Strings.present?(value) do + Map.put(params, key, value) + else + params + end + end + + # Gets the user's mailbox along with user information + defp get_user_mailbox_with_user(user_id) do + import Ecto.Query + alias Elektrine.Accounts.User + + result = + Mailbox + |> where(user_id: ^user_id) + |> join(:inner, [m], u in User, on: m.user_id == u.id) + |> select([m, u], {m, u}) + |> Repo.one() + + case result do + nil -> + Logger.error("User #{user_id} does not have a mailbox") + {:error, :no_mailbox} + + {mailbox, user} -> + {:ok, {mailbox, user}} + end + end + + # Formats the From header with display name if available + defp format_from_header(params, user, mailbox) do + email_address = normalize_email_address(params[:from]) || params[:from] + client_from_header = params[:client_from_header] || params["client_from_header"] + + # Check if sending from main mailbox addresses across configured local domains. + main_addresses = + case Elektrine.Domains.local_address_variants(mailbox.email) do + [] -> [mailbox.email] + variants -> variants + end + |> Enum.map(&normalize_email_address/1) + |> Enum.reject(&is_nil/1) + + formatted_from = + cond do + preserve_client_sender_header?(client_from_header, user.id) -> + HeaderSanitizer.sanitize_email_header(client_from_header) + + Elektrine.Strings.present?(user.display_name) && + email_address in main_addresses -> + # Only add display name for main addresses, not aliases + display_name = String.trim(user.display_name) + # Always quote display names for maximum compatibility with email services + # Escape any existing quotes in the display name + escaped_name = String.replace(display_name, "\"", "\\\"") + "\"#{escaped_name}\" <#{email_address}>" + + true -> + # For aliases or if no display name, use the email address as-is + email_address + end + + updated_params = Map.put(params, :from, formatted_from) + {:ok, updated_params} + end + + defp preserve_client_sender_header?(from_header, user_id) when is_binary(from_header) do + case extract_from_header_address(from_header) do + nil -> false + email -> match?({:ok, _ownership}, Email.verify_email_ownership(email, user_id)) + end + end + + defp preserve_client_sender_header?(_from_header, _user_id), do: false + + defp extract_from_header_address(from_header) when is_binary(from_header) do + trimmed = String.trim(from_header) + + case Regex.run(~r/<([^<>]+)>\s*$/, trimmed) do + [_, email] -> normalize_email_address(email) + _ -> normalize_email_address(trimmed) + end + end + + # Adds RFC 8058 unsubscribe headers to email params + # Only adds headers if list_id is explicitly provided (i.e., this is a mass/marketing email) + defp add_unsubscribe_headers(params) do + # Only add unsubscribe headers if this is a mass email with an explicit list_id + # Personal emails sent via SMTP/webmail should NOT have list_id set + list_id = params[:list_id] + + if list_id do + # Extract recipient email(s) + recipients = parse_email_list(params[:to] || "") + + # Generate headers for the first recipient + case recipients do + [first_recipient | _] -> + # Generate unsubscribe token + token = Unsubscribes.generate_token(first_recipient, list_id) + + # Build unsubscribe URLs + base_url = ElektrineWeb.Endpoint.url() + unsubscribe_url = "#{base_url}/unsubscribe/#{token}" + + # RFC 8058 headers + unsubscribe_headers = %{ + "List-Unsubscribe" => "<#{unsubscribe_url}>", + "List-Unsubscribe-Post" => "List-Unsubscribe=One-Click", + "List-Id" => EmailAddresses.list_id(list_id) + } + + # Merge with existing headers + existing_headers = params[:headers] || %{} + updated_headers = Map.merge(existing_headers, unsubscribe_headers) + + Map.put(params, :headers, updated_headers) + + [] -> + # No recipients, return params unchanged + params + end + else + # No list_id - this is a personal email, don't add unsubscribe headers + params + end + end + + # Sends the email via external API or Swoosh + defp send_via_swoosh(params) do + # Add X-Elektrine-Forwarded header to track forwarding and prevent loops + params_with_header = + if params[:forwarded_from] do + # Already has forwarding info, append to it + existing = params[:forwarded_from] || "" + + updated = + if Elektrine.Strings.present?(existing), + do: "#{existing}, #{params[:to]}", + else: params[:to] + + Map.put(params, :forwarded_from, updated) + else + params + end + + # Add unsubscribe headers (RFC 8058) + params_with_unsubscribe = add_unsubscribe_headers(params_with_header) + + # Try external email service APIs first if not in test/local mode + if should_use_external_api?() do + send_via_external_api(params_with_unsubscribe) + else + send_via_swoosh_adapter(params_with_unsubscribe) + end + end + + def deliver_external_params(params) when is_map(params) do + send_via_swoosh(params) + end + + defp should_use_external_api? do + # Use external API unless we're explicitly using local email + EmailConfig.use_external_delivery_api?() + end + + defp send_via_external_api(params) do + # Use Haraka for all email sending + send_via_haraka_api(params) + end + + defp send_via_haraka_api(params) do + started_at = System.monotonic_time(:millisecond) + + result = + case Elektrine.Email.HarakaClient.send_email(params) do + {:ok, result} -> + {:ok, result} + + {:error, reason} -> + Logger.error("Haraka API failed: #{inspect(reason)}") + {:error, reason} + end + + emit_outbound_telemetry(:provider, result, started_at, %{route: :haraka}) + result + end + + # Original Swoosh sending logic + defp send_via_swoosh_adapter(params) do + started_at = System.monotonic_time(:millisecond) + + try do + email = + new() + |> from(params[:from]) + |> to(parse_recipients(params[:to])) + |> subject(params[:subject]) + |> text_body(params[:text_body]) + + # Add Reply-To if provided + email = + if Elektrine.Strings.present?(params[:reply_to]) do + reply_to(email, params[:reply_to]) + else + email + end + + # Add CC if provided + email = + if Elektrine.Strings.present?(params[:cc]) do + cc(email, parse_recipients(params[:cc])) + else + email + end + + # Add BCC if provided + email = + if Elektrine.Strings.present?(params[:bcc]) do + bcc(email, parse_recipients(params[:bcc])) + else + email + end + + # Add HTML body if provided + email = + if params[:html_body] do + html_body(email, params[:html_body]) + else + email + end + + # Add In-Reply-To header for threading + email = + if params[:in_reply_to] do + header(email, "In-Reply-To", params[:in_reply_to]) + else + email + end + + email = + if params[:references] do + header(email, "References", params[:references]) + else + email + end + + # Add custom headers (including unsubscribe headers) + email = + if params[:headers] && is_map(params[:headers]) do + Enum.reduce(params[:headers], email, fn {key, value}, acc -> + header(acc, key, value) + end) + else + email + end + + result = + case Mailer.deliver(email) do + {:ok, result} -> + # Generate a message ID for tracking + message_id = "swoosh-#{:rand.uniform(1_000_000)}-#{System.system_time(:millisecond)}" + {:ok, %{id: Map.get(result, :id) || message_id, message_id: message_id}} + + {:error, reason} -> + Logger.error("Failed to send email via Swoosh: #{inspect(reason)}") + {:error, reason} + end + + emit_outbound_telemetry(:provider, result, started_at, %{route: :swoosh}) + result + rescue + e -> + Logger.error("Error sending email: #{inspect(e)}") + result = {:error, e} + emit_outbound_telemetry(:provider, result, started_at, %{route: :swoosh}) + result + end + end + + defp emit_outbound_telemetry(stage, result, started_at, metadata) do + duration = System.monotonic_time(:millisecond) - started_at + + case result do + {:ok, _} -> + Events.email_outbound(stage, :success, duration, metadata) + + {:error, reason} -> + Events.email_outbound(stage, :failure, duration, Map.put(metadata, :reason, reason)) + + _other -> + Events.email_outbound(stage, :failure, duration, Map.put(metadata, :reason, :unexpected)) + end + end + + # Stores the sent message in the database + # Parse recipients from comma-separated string or return as-is if already a list + defp parse_recipients(nil), do: nil + defp parse_recipients(recipients) when is_list(recipients), do: recipients + + defp parse_recipients(recipients) when is_binary(recipients) do + recipients + |> String.split(~r/[,;]\s*/) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> case do + [] -> nil + [single] -> single + multiple -> multiple + end + end + + # Parse email list from string or list + defp parse_email_list(""), do: [] + + defp parse_email_list(emails) when is_binary(emails) do + emails + |> split_email_header_list() + |> Enum.map(&normalize_or_preserve_suspicious_recipient/1) + |> Enum.reject(&is_nil/1) + end + + defp parse_email_list(emails) when is_list(emails) do + emails + |> Enum.flat_map(fn + {_name, email} -> split_email_header_list(email) + email when is_binary(email) -> split_email_header_list(email) + _ -> [] + end) + |> Enum.map(&normalize_or_preserve_suspicious_recipient/1) + |> Enum.reject(&is_nil/1) + end + + defp normalize_or_preserve_suspicious_recipient(value) when is_binary(value) do + if contains_suspicious_content?(value), do: value, else: normalize_email_address(value) + end + + defp normalize_or_preserve_suspicious_recipient(value), do: normalize_email_address(value) + + defp split_email_header_list(value) when is_binary(value) do + value + |> String.split(~r/,(?=(?:[^"]*"[^"]*")*[^"]*$)/) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp split_email_header_list(_), do: [] + + defp normalize_email_address(value) when is_binary(value) do + value + |> Elektrine.Email.extract_email_address() + |> String.trim() + |> String.downcase() + |> case do + "" -> nil + email -> email + end + end + + defp normalize_email_address(_), do: nil + + # Check if email is internal (between our domains) + defp is_internal_email?(to_emails) when is_list(to_emails) do + our_domains = Elektrine.Domains.receiving_email_domains() + + Enum.all?(to_emails, fn email -> + case email |> normalize_email_address() |> to_string() |> String.split("@") do + [_local, domain] -> String.downcase(domain) in our_domains + _ -> false + end + end) + end + + # Build a cache map of all recipient resolutions to avoid duplicate DB calls. + # Returns a map: %{email => {resolved_email, resolved_target_external?}} + defp build_resolution_cache(emails) when is_list(emails) do + emails + |> Enum.map(fn email -> + clean = recipient_cache_key(email) + {clean, resolve_single_email(email)} + end) + |> Map.new() + end + + defp build_delivery_plan(to_emails, cc_emails, bcc_emails, resolution_cache) do + to_plan = partition_recipient_group(to_emails, resolution_cache) + cc_plan = partition_recipient_group(cc_emails, resolution_cache) + bcc_plan = partition_recipient_group(bcc_emails, resolution_cache) + + %{ + external: %{ + to: to_plan.external, + cc: cc_plan.external, + bcc: bcc_plan.external + }, + internal: %{ + to: to_plan.internal, + cc: cc_plan.internal, + bcc: bcc_plan.internal + } + } + end + + # Determine routing strategy based on whether any recipients must be sent externally. + defp determine_routing_strategy(delivery_plan) do + external_recipients = + delivery_plan.external.to ++ delivery_plan.external.cc ++ delivery_plan.external.bcc + + if external_recipients == [] do + :internal + else + :external + end + end + + defp partition_recipient_group([], _resolution_cache), do: %{external: [], internal: []} + + defp partition_recipient_group(emails, resolution_cache) when is_list(emails) do + Enum.reduce(emails, %{external: [], internal: []}, fn email, acc -> + case Map.get(resolution_cache, recipient_cache_key(email)) do + {resolved, true} -> + %{acc | external: acc.external ++ [resolved]} + + {_resolved, false} -> + %{acc | internal: acc.internal ++ [email]} + + nil -> + if is_internal_email?([email]) do + %{acc | internal: acc.internal ++ [email]} + else + %{acc | external: acc.external ++ [email]} + end + end + end) + end + + # Resolve a single email address and return {resolved_email, is_external} + defp resolve_single_email(email) do + clean_email = normalize_email_address(email) || "" + + # Check alias forwarding first + case Email.resolve_alias(clean_email) do + target_email when is_binary(target_email) -> + is_external = !is_internal_email?([target_email]) + {target_email, is_external} + + _ -> + # Check mailbox forwarding + case Email.get_mailbox_by_email(clean_email) do + %Mailbox{forward_enabled: true, forward_to: forward_to} + when is_binary(forward_to) -> + if Elektrine.Strings.present?(forward_to) do + is_external = !is_internal_email?([forward_to]) + {forward_to, is_external} + else + {clean_email, !is_internal_email?([clean_email])} + end + + _ -> + {clean_email, !is_internal_email?([clean_email])} + end + end + end + + defp recipient_cache_key(email) when is_binary(email) do + normalize_email_address(email) || "" + end + + defp recipient_cache_key(email), do: email |> to_string() |> recipient_cache_key() + + # Validates that the user owns the from address (mailbox or alias) + defp validate_from_address_ownership(from_address, user_id) do + clean_from_address = normalize_email_address(from_address) + + case Email.verify_email_ownership(clean_from_address, user_id) do + {:ok, ownership_type} -> + {:ok, ownership_type} + + {:error, reason} -> + Logger.warning( + "User #{user_id} attempted to send from unauthorized address #{inspect(from_address)}: #{inspect(reason)}" + ) + + {:error, :unauthorized_from_address} + end + end + + # Validates that external recipient domains have valid DNS records + # This helps prevent bounces and improves deliverability reputation + defp validate_external_recipient_domains(params) do + to_emails = parse_email_list(params[:to] || "") + cc_emails = parse_email_list(params[:cc] || "") + bcc_emails = parse_email_list(params[:bcc] || "") + + # Only validate external recipients (not our domains) + our_domains = Elektrine.Domains.receiving_email_domains() + + external_recipients = + (to_emails ++ cc_emails ++ bcc_emails) + |> Enum.filter(fn email -> + case String.split(String.trim(email), "@") do + [_local, domain] -> String.downcase(domain) not in our_domains + _ -> false + end + end) + + # Validate DNS for each external recipient + invalid_recipients = + Enum.filter(external_recipients, fn email -> + case HeaderSanitizer.validate_recipient_domain(email) do + {:ok, :valid} -> false + {:error, _reason} -> true + end + end) + + case invalid_recipients do + [] -> + :ok + + [single] -> + Logger.warning("DNS validation failed for recipient: #{single}") + {:error, "Invalid recipient domain: #{single}"} + + multiple -> + Logger.warning("DNS validation failed for recipients: #{inspect(multiple)}") + {:error, "Invalid recipient domains detected"} + end + end + + # Send internal email directly within Phoenix + defp send_internal_email(user_id, params, db_attachments) do + # Merge db_attachments into params if provided + params_with_db_attachments = + if db_attachments do + Map.put(params, :db_attachments, db_attachments) + else + params + end + + with {:ok, _remaining} <- maybe_check_rate_limit(user_id, params_with_db_attachments), + {:ok, {mailbox, user}} <- get_user_mailbox_with_user(user_id), + {:ok, _ownership} <- validate_from_address_ownership(params[:from], user_id), + {:ok, formatted_params} <- format_from_header(params_with_db_attachments, user, mailbox), + {:ok, pgp_params} <- maybe_pgp_encrypt(formatted_params, user_id), + {:ok, message} <- deliver_internal_email(mailbox.id, pgp_params) do + maybe_record_send(user_id, params_with_db_attachments) + {:ok, message} + else + {:error, :hourly_limit_exceeded} -> + Logger.warning("User #{user_id} exceeded hourly email limit") + {:error, :rate_limit_exceeded} + + {:error, :minute_limit_exceeded} -> + Logger.warning("User #{user_id} exceeded per-minute email limit") + {:error, :rate_limit_exceeded} + + {:error, reason} -> + {:error, reason} + end + end + + # Deliver internal email directly to recipient mailboxes + defp deliver_internal_email(sender_mailbox_id, email_params) do + to_emails = parse_email_list(email_params[:to] || "") + cc_emails = parse_email_list(email_params[:cc] || "") + bcc_emails = parse_email_list(email_params[:bcc] || "") + + # Check if this is a self-email (user emailing themselves) + # BUT only if they're not forwarding to an external address + sender_mailbox = Email.get_mailbox_internal(sender_mailbox_id) + all_recipients = to_emails ++ cc_emails ++ bcc_emails + + # First check if any recipients have external forwarding (alias or mailbox) + has_forwarding = + Enum.any?(all_recipients, fn recipient_email -> + clean_email = String.trim(String.downcase(recipient_email)) + + # Check alias forwarding first + case Email.resolve_alias(clean_email) do + target_email when is_binary(target_email) -> + # Has alias forwarding - check if it's external + !is_internal_email?([target_email]) + + _ -> + # Check mailbox forwarding + case Email.get_mailbox_by_email(clean_email) do + %Mailbox{forward_enabled: true, forward_to: forward_to} + when is_binary(forward_to) and forward_to != "" -> + # Has mailbox forwarding - check if it's external + !is_internal_email?([forward_to]) + + _ -> + false + end + end + end) + + # Only consider it a self-email if ALL recipients are the sender + is_self_email = + !has_forwarding && sender_mailbox && + Enum.any?(all_recipients, fn recipient_email -> + String.downcase(recipient_email) == String.downcase(sender_mailbox.email) || + user_alias?(recipient_email, sender_mailbox.user_id) + end) && + Enum.all?(all_recipients, fn recipient_email -> + String.downcase(recipient_email) == String.downcase(sender_mailbox.email) || + user_alias?(recipient_email, sender_mailbox.user_id) + end) + + if is_self_email do + # For self-emails, create both sent and received copies + db_attachments = email_params[:db_attachments] + + # Always create sent copy regardless of source (webmail or SMTP) + # Mobile clients and many email clients don't append to Sent via IMAP + case store_sent_message_internal(sender_mailbox_id, email_params, db_attachments) do + {:ok, sent_message} -> + deliver_self_internal_delivery( + sender_mailbox_id, + sent_message, + email_params, + db_attachments + ) + + error -> + Logger.error("Self-email sent copy creation failed: #{inspect(error)}") + error + end + else + # Extract db_attachments if provided + db_attachments = email_params[:db_attachments] + + # Store sent message first + case store_sent_message_internal(sender_mailbox_id, email_params, db_attachments) do + {:ok, sent_message} -> + internal_plan = %{ + to: to_emails, + cc: Enum.filter(cc_emails, &is_internal_email?([&1])), + bcc: Enum.filter(bcc_emails, &is_internal_email?([&1])) + } + + with {:ok, internal_deliveries} <- + create_internal_deliveries( + sender_mailbox_id, + sent_message, + email_params, + internal_plan, + db_attachments + ), + :ok <- deliver_internal_deliveries_now(internal_deliveries) do + {:ok, sent_message} + end + + {:error, reason} -> + {:error, reason} + end + end + end + + # Store sent message for internal delivery + defp store_sent_message_internal(sender_mailbox_id, email_params, db_attachments) do + # For internal emails, use full attachments (with data) not just S3 metadata + # This allows recipients to download before async S3 upload completes + attachments_to_store = email_params[:attachments] || db_attachments || %{} + + message_attrs = %{ + message_id: + email_params[:message_id] || email_params["message_id"] || generate_message_id(), + from: email_params[:from], + to: email_params[:to], + cc: email_params[:cc], + bcc: email_params[:bcc], + subject: email_params[:subject], + text_body: email_params[:text_body], + html_body: email_params[:html_body], + raw_source: email_params[:raw_source] || email_params["raw_source"], + in_reply_to: email_params[:in_reply_to], + references: email_params[:references], + attachments: attachments_to_store, + mailbox_id: sender_mailbox_id, + status: "sent", + # Sent messages should not have a category + category: nil, + metadata: + enrich_metadata_for_pgp( + %{ + internal_delivery: true, + sent_at: DateTime.utc_now() |> DateTime.to_iso8601() + }, + email_params + ) + } + + case Email.create_message(message_attrs) do + {:ok, message} -> + # Broadcast to webmail for real-time sync + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "mailbox:#{sender_mailbox_id}", + {:new_email, message} + ) + + {:ok, message} + + {:error, :storage_limit_exceeded} -> + {:error, :storage_limit_exceeded} + + {:error, reason} -> + {:error, reason} + end + end + + # Store sent message for external delivery (via Haraka/Swoosh) + defp store_sent_message_external( + sender_mailbox_id, + email_params, + swoosh_response, + db_attachments + ) do + provider_message_id = provider_response_message_id(swoosh_response) + + message_attrs = %{ + message_id: + email_params[:message_id] || email_params["message_id"] || provider_message_id || + generate_message_id(), + from: email_params[:from], + to: email_params[:to], + cc: email_params[:cc], + bcc: email_params[:bcc], + subject: email_params[:subject], + text_body: email_params[:text_body], + html_body: email_params[:html_body], + raw_source: email_params[:raw_source] || email_params["raw_source"], + in_reply_to: email_params[:in_reply_to], + references: email_params[:references], + # Use S3 metadata if provided + attachments: db_attachments || email_params[:attachments], + mailbox_id: sender_mailbox_id, + status: "sent", + # Sent messages should not have a category + category: nil, + metadata: + enrich_metadata_for_pgp( + %{ + external_delivery: true, + sent_at: DateTime.utc_now() |> DateTime.to_iso8601(), + original_message_id: provider_message_id, + client_message_id: email_params[:message_id] + }, + email_params + ) + } + + case Email.create_message(message_attrs) do + {:ok, message} -> + # Broadcast to webmail for real-time sync + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + "mailbox:#{sender_mailbox_id}", + {:new_email, message} + ) + + {:ok, message} + + {:error, :storage_limit_exceeded} -> + {:error, :storage_limit_exceeded} + + {:error, reason} -> + {:error, reason} + end + end + + defp provider_response_message_id(nil), do: nil + + defp provider_response_message_id(response) when is_map(response) do + Map.get(response, :message_id) || Map.get(response, "message_id") + end + + # Fallback for non-map, non-nil provider responses: nothing to extract. + # (Previously called Map.get/2 on a value the type system proves is not a map, + # which would raise BadMapError if ever reached.) + defp provider_response_message_id(_response), do: nil + + defp enqueue_external_delivery( + user_id, + mailbox_id, + sent_message, + external_params, + external_plan + ) do + external_plan + |> external_delivery_recipients() + |> Enum.reduce_while({:ok, []}, fn {recipient_type, recipient}, {:ok, deliveries} -> + trace_id = Ecto.UUID.generate() + + delivery_params = + per_recipient_external_params(external_params, recipient_type, recipient, trace_id) + + attrs = %{ + user_id: user_id, + mailbox_id: mailbox_id, + sent_message_id: sent_message.id, + envelope_from: external_params[:from], + to: external_plan.to, + cc: external_plan.cc, + bcc: external_plan.bcc, + recipient: recipient, + recipient_type: recipient_type, + trace_id: trace_id, + params: stringify_keys(delivery_params), + status: "pending" + } + + with {:ok, delivery, state} <- ExternalDelivery.create_or_get(attrs), + {:ok, _job_or_delivery} <- maybe_enqueue_external_delivery(delivery, state) do + {:cont, {:ok, [delivery | deliveries]}} + else + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + {:ok, deliveries} -> {:ok, Enum.reverse(deliveries)} + error -> error + end + end + + defp maybe_enqueue_external_delivery(%ExternalDelivery{status: "sent"} = delivery, _state) do + {:ok, delivery} + end + + defp maybe_enqueue_external_delivery(%ExternalDelivery{} = delivery, _state) do + ExternalDeliveryWorker.enqueue(delivery) + end + + defp external_delivery_recipients(external_plan) do + [ + {"to", external_plan.to}, + {"cc", external_plan.cc}, + {"bcc", external_plan.bcc} + ] + |> Enum.flat_map(fn {type, recipients} -> Enum.map(recipients, &{type, &1}) end) + |> Enum.reduce({[], MapSet.new()}, fn {type, recipient}, {acc, seen} -> + case normalize_email_address(recipient) do + nil -> + {acc, seen} + + email -> + if MapSet.member?(seen, email) do + {acc, seen} + else + {[{type, email} | acc], MapSet.put(seen, email)} + end + end + end) + |> elem(0) + |> Enum.reverse() + end + + defp internal_delivery_recipients(internal_plan) do + [ + {"to", internal_plan.to}, + {"cc", internal_plan.cc}, + {"bcc", internal_plan.bcc} + ] + |> Enum.flat_map(fn {type, recipients} -> Enum.map(recipients, &{type, &1}) end) + |> Enum.reduce({[], MapSet.new()}, fn {type, recipient}, {acc, seen} -> + case internal_delivery_recipient_key(recipient) do + nil -> + {acc, seen} + + key -> + if MapSet.member?(seen, key) do + {acc, seen} + else + {[{type, normalize_email_address(recipient)} | acc], MapSet.put(seen, key)} + end + end + end) + |> elem(0) + |> Enum.reverse() + end + + defp internal_delivery_recipient_key(recipient) do + case normalize_email_address(recipient) do + nil -> + nil + + email -> + case find_internal_recipient_mailbox(email) do + {:ok, %Mailbox{id: mailbox_id}} -> {:mailbox, mailbox_id} + _ -> {:address, email} + end + end + end + + defp validate_internal_delivery_targets(internal_plan) do + internal_plan + |> internal_delivery_recipients() + |> Enum.reduce_while(:ok, fn {_recipient_type, recipient}, :ok -> + case find_internal_recipient_mailbox(recipient) do + {:ok, _mailbox} -> + {:cont, :ok} + + {:error, :forward_to_external} -> + {:halt, {:error, :forward_to_external}} + + {:error, reason} -> + Logger.warning("Internal recipient #{recipient} is not deliverable: #{inspect(reason)}") + {:halt, {:error, :recipient_not_found}} + end + end) + end + + defp create_internal_deliveries( + sender_mailbox_id, + sent_message, + email_params, + internal_plan, + db_attachments + ) do + internal_plan + |> internal_delivery_recipients() + |> Enum.reduce_while({:ok, []}, fn {recipient_type, recipient}, {:ok, deliveries} -> + case create_internal_delivery( + sender_mailbox_id, + sent_message, + recipient, + recipient_type, + email_params, + db_attachments + ) do + {:ok, delivery} -> {:cont, {:ok, [delivery | deliveries]}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + {:ok, deliveries} -> {:ok, Enum.reverse(deliveries)} + error -> error + end + end + + defp create_internal_delivery( + sender_mailbox_id, + sent_message, + recipient, + recipient_type, + email_params, + db_attachments, + opts \\ [] + ) do + with %Mailbox{} = sender_mailbox <- Email.get_mailbox_internal(sender_mailbox_id), + {:ok, recipient_mailbox} <- find_internal_recipient_mailbox(recipient) do + # Share the sent copy's Message-ID with the recipient's copy. A message has a + # single Message-ID across all recipients (the unique index is per-mailbox), and + # it lets the recipient's eventual reply link back to this sent message via + # In-Reply-To, so the conversation starter stays threaded in the sender's view. + email_params = Map.put(email_params, :message_id, sent_message.message_id) + + received_attrs = + internal_received_attrs( + recipient_mailbox, + sender_mailbox_id, + email_params, + recipient_type, + db_attachments, + opts + ) + + attrs = %{ + user_id: sender_mailbox.user_id, + mailbox_id: sender_mailbox_id, + sent_message_id: sent_message.id, + recipient_mailbox_id: recipient_mailbox.id, + recipient: recipient, + recipient_type: recipient_type, + params: stringify_keys(received_attrs), + status: "pending" + } + + with {:ok, delivery, _state} <- InternalDelivery.create_or_get(attrs) do + {:ok, delivery} + end + else + nil -> {:error, :no_mailbox} + {:error, reason} -> {:error, reason} + end + end + + defp deliver_internal_deliveries_now(deliveries) do + Enum.reduce_while(deliveries, :ok, fn delivery, :ok -> + case InternalDeliveryWorker.deliver_now(delivery) do + {:ok, _message_or_delivery} -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp deliver_self_internal_delivery( + sender_mailbox_id, + sent_message, + email_params, + db_attachments + ) do + with %Mailbox{} = sender_mailbox <- Email.get_mailbox_internal(sender_mailbox_id), + {:ok, delivery} <- + create_internal_delivery( + sender_mailbox_id, + sent_message, + sender_mailbox.email, + "to", + email_params, + db_attachments, + self_email: true + ), + {:ok, message_or_delivery} <- InternalDeliveryWorker.deliver_now(delivery) do + {:ok, message_or_delivery} + else + nil -> {:error, :no_mailbox} + {:error, reason} -> {:error, reason} + end + end + + defp internal_received_attrs( + recipient_mailbox, + sender_mailbox_id, + email_params, + recipient_type, + db_attachments, + opts + ) do + attachments_to_store = email_params[:attachments] || db_attachments || %{} + self_email? = Keyword.get(opts, :self_email, false) + + base_metadata = %{ + internal_delivery: true, + recipient_type: recipient_type, + received_at: DateTime.utc_now() |> DateTime.to_iso8601() + } + + metadata = + if self_email? do + Map.merge(base_metadata, %{ + self_email: true, + sent_and_received: true, + delivered_at: DateTime.utc_now() |> DateTime.to_iso8601() + }) + else + base_metadata + end + + %{ + message_id: received_message_id(email_params, recipient_mailbox.id, sender_mailbox_id), + from: email_params[:from], + to: email_params[:to], + cc: email_params[:cc], + bcc: nil, + subject: email_params[:subject], + text_body: email_params[:text_body], + html_body: email_params[:html_body], + in_reply_to: email_params[:in_reply_to], + references: email_params[:references], + attachments: attachments_to_store, + mailbox_id: recipient_mailbox.id, + status: "received", + metadata: enrich_metadata_for_pgp(metadata, email_params) + } + |> maybe_put_self_email_attrs(self_email?) + end + + defp maybe_put_self_email_attrs(attrs, true) do + attrs + |> Map.put(:read, true) + |> Map.put(:category, "inbox") + end + + defp maybe_put_self_email_attrs(attrs, false), do: attrs + + defp per_recipient_external_params(params, recipient_type, recipient, trace_id) do + params + |> Map.put(:to, recipient) + |> Map.put(:cc, "") + |> Map.put(:bcc, "") + |> put_trace_headers(params, recipient_type, recipient, trace_id) + end + + defp put_trace_headers(params, original_params, recipient_type, recipient, trace_id) do + headers = + (params[:headers] || %{}) + |> Map.merge(%{ + "X-Elektrine-Trace-ID" => trace_id, + "X-Elektrine-Recipient" => recipient, + "X-Elektrine-Recipient-Type" => recipient_type, + "X-Elektrine-Original-To" => original_params[:to] || "", + "X-Elektrine-Original-Cc" => original_params[:cc] || "" + }) + + Map.put(params, :headers, headers) + end + + defp stringify_keys(nil), do: nil + + defp stringify_keys(map) when is_map(map) do + Map.new(map, fn + {key, value} when is_atom(key) -> {Atom.to_string(key), stringify_keys(value)} + {key, value} -> {key, stringify_keys(value)} + end) + end + + defp stringify_keys(list) when is_list(list), do: Enum.map(list, &stringify_keys/1) + defp stringify_keys(value), do: value + + # Find recipient mailbox for internal delivery with loop detection + defp find_internal_recipient_mailbox(email_address, visited \\ MapSet.new(), depth \\ 0) + + # Prevent infinite loops - max 10 hops + defp find_internal_recipient_mailbox(_email_address, _visited, depth) when depth > 10 do + Logger.error("Forwarding loop detected: exceeded maximum depth of 10 hops") + {:error, :forwarding_loop_detected} + end + + defp find_internal_recipient_mailbox(email_address, visited, depth) do + clean_email = String.trim(String.downcase(email_address)) + + # Check if we've already visited this address (circular loop) + if MapSet.member?(visited, clean_email) do + Logger.error( + "Forwarding loop detected: #{clean_email} already visited in chain #{inspect(MapSet.to_list(visited))}" + ) + + {:error, :forwarding_loop_detected} + else + # Add current email to visited set + visited = MapSet.put(visited, clean_email) + + # Check if this email has forwarding configured + case Email.resolve_alias(clean_email) do + # Alias forwards to another address + target_email when is_binary(target_email) -> + # Check if target is also internal + if is_internal_email?([target_email]) do + # Forward to internal address, find that mailbox recursively + find_internal_recipient_mailbox(target_email, visited, depth + 1) + else + # Forward to external address - signal that this should be sent externally + {:error, :forward_to_external} + end + + # Alias exists but no forwarding - deliver to owner's mailbox + :no_forward -> + case Email.get_alias_by_email(clean_email) do + %Email.Alias{user_id: user_id} when is_integer(user_id) -> + case Email.get_user_mailbox(user_id) do + %Mailbox{} = mailbox -> {:ok, mailbox} + nil -> {:error, :no_mailbox} + end + + _ -> + {:error, :recipient_not_found} + end + + # Not an alias, try direct mailbox match + nil -> + case Email.get_mailbox_by_email(clean_email) do + %Mailbox{forward_enabled: true, forward_to: forward_to} = _mailbox + when is_binary(forward_to) and forward_to != "" -> + # Mailbox has forwarding enabled + if is_internal_email?([forward_to]) do + # Forward to internal address, find that mailbox recursively + find_internal_recipient_mailbox(forward_to, visited, depth + 1) + else + # Forward to external address + {:error, :forward_to_external} + end + + %Mailbox{} = mailbox -> + # No forwarding, deliver to this mailbox + {:ok, mailbox} + + nil -> + # Try cross-domain lookup (same username across local domains) + case try_cross_domain_mailbox_lookup(clean_email) do + {:ok, mailbox} -> + # Check if cross-domain mailbox has forwarding + if mailbox.forward_enabled && Elektrine.Strings.present?(mailbox.forward_to) do + if is_internal_email?([mailbox.forward_to]) do + find_internal_recipient_mailbox(mailbox.forward_to, visited, depth + 1) + else + {:error, :forward_to_external} + end + else + {:ok, mailbox} + end + + _ -> + {:error, :recipient_not_found} + end + end + end + end + end + + # Generate unique message ID for internal delivery + defp generate_message_id do + "internal-#{System.system_time(:millisecond)}-#{:rand.uniform(999_999)}" + end + + defp received_message_id(_email_params, recipient_mailbox_id, sender_mailbox_id) + when is_integer(recipient_mailbox_id) and recipient_mailbox_id == sender_mailbox_id do + generate_message_id() + end + + defp received_message_id(email_params, _recipient_mailbox_id, _sender_mailbox_id) do + email_params[:message_id] || email_params["message_id"] || generate_message_id() + end + + # Check if email address is an alias owned by the user + defp user_alias?(email_address, user_id) do + case Email.get_alias_by_email(email_address) do + %Email.Alias{user_id: ^user_id, enabled: true} -> true + _ -> false + end + end + + # Try to find a mailbox by cross-domain lookup (same username across local domains) + defp try_cross_domain_mailbox_lookup(email_address) do + case String.split(email_address, "@") do + [_username, domain] -> + supported_domains = Elektrine.Domains.supported_email_domains() + + if domain in supported_domains do + case Elektrine.Domains.alternate_local_addresses(email_address) do + [] -> + {:error, :not_found} + + alternate_addresses -> + alternate_addresses + |> Enum.find_value({:error, :not_found}, fn alternate_email -> + case Email.get_mailbox_by_email(alternate_email) do + %Mailbox{} = mailbox -> {:ok, mailbox} + _ -> nil + end + end) + end + else + {:error, :not_found} + end + + _ -> + {:error, :invalid_email} + end + end + + # Validates that there is at least one valid recipient + defp validate_recipients(recipients) when is_list(recipients) do + # Filter out empty recipients first + non_empty_recipients = + recipients + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + + # Check if we have any recipients at all + if Enum.empty?(non_empty_recipients) do + {:error, "At least one valid recipient is required (To, CC, or BCC)"} + else + # Check for suspicious content first (before format validation) + if Enum.any?(non_empty_recipients, &contains_suspicious_content?/1) do + {:error, "Invalid recipient address detected"} + else + # Then check email format + valid_recipients = Enum.filter(non_empty_recipients, &valid_email_format?/1) + + if Enum.empty?(valid_recipients) do + {:error, "At least one valid recipient is required (To, CC, or BCC)"} + else + :ok + end + end + end + end + + # Validate email format + defp valid_email_format?(email) when is_binary(email) do + String.match?(email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/) + end + + defp valid_email_format?(_), do: false + + # Check for suspicious content in email addresses + defp contains_suspicious_content?(email) when is_binary(email) do + suspicious_patterns = [ + # Script tags + ~r/Link" + } + + result = Sanitizer.sanitize_incoming_email(email_data) + + # Should remove script tags + refute String.contains?(result["html_body"], "

    Hello

    " + result = Sanitizer.sanitize_html_content(html) + + refute String.contains?(result, " + + + """ + end + + defp send_verify_error(conn, message) do + conn + |> put_resp_content_type("text/html") + |> send_resp(422, "

    Security Check

    #{html_escape(message)}

    ") + |> halt() + end + + defp difficulty do + "ATOMINE_GATE_DIFFICULTY" + |> env_value(Keyword.get(config(), :difficulty, AtominePow.difficulty())) + |> AtominePow.normalize_difficulty() + end + + defp clearance_ttl_seconds do + "ATOMINE_GATE_CLEARANCE_TTL_SECONDS" + |> env_value(Keyword.get(config(), :clearance_ttl_seconds, @default_clearance_ttl_seconds)) + |> normalize_positive_int(@default_clearance_ttl_seconds) + end + + defp audience(conn, scope), do: "atomine-gate:#{normalized_host(conn)}:#{scope}" + defp nonce(conn, scope), do: "host:#{normalized_host(conn)}:scope:#{scope}" + + defp verify_scope(%{"gate_scope" => scope}) when is_binary(scope) do + normalize_scope(scope) + end + + defp verify_scope(%{"user_id" => user_id}) do + case parse_user_id(user_id) do + nil -> nil + user_id -> user_scope(user_id) + end + end + + defp verify_scope(_params), do: nil + + defp verify_params(%Plug.Conn{params: %Plug.Conn.Unfetched{}} = conn) do + conn = fetch_query_params(conn) + + body_params = + if urlencoded_form?(conn) do + case read_body(conn, length: 64_000) do + {:ok, body, conn} -> + {Plug.Conn.Query.decode(body), conn} + + {:more, _partial, conn} -> + {%{}, conn} + + {:error, _reason} -> + {%{}, conn} + end + else + {%{}, conn} + end + + {body_params, conn} = body_params + {conn, Map.merge(conn.query_params, body_params)} + end + + defp verify_params(conn), do: {conn, conn.params} + + defp urlencoded_form?(conn) do + conn + |> get_req_header("content-type") + |> List.first() + |> case do + nil -> + false + + content_type -> + content_type + |> String.downcase() + |> String.starts_with?("application/x-www-form-urlencoded") + end + end + + defp user_scope(user_id), do: "user:#{user_id}" + + defp edge_scope(origin) do + zone_id = origin_value(origin, :zone_id) + record_id = origin_value(origin, :record_id) + host = origin_value(origin, :host) || origin_value(origin, :origin_host) + + cond do + not is_nil(zone_id) and not is_nil(record_id) -> "dns:#{zone_id}:#{record_id}" + is_binary(host) and host != "" -> "dns:host:#{host}" + true -> nil + end + end + + defp edge_gate_enabled?(origin), do: origin_value(origin, :atomine_gate) |> truthy?() + + defp origin_value(origin, key) when is_map(origin) do + Map.get(origin, key) || Map.get(origin, Atom.to_string(key)) + end + + defp normalize_scope(scope) do + scope = String.trim(scope) + + if scope != "" and String.length(scope) <= 256 do + scope + end + end + + defp normalized_host(conn), do: String.downcase(conn.host || "") + + defp parse_user_id(value) when is_integer(value) and value > 0, do: value + + defp parse_user_id(value) when is_binary(value) do + case Integer.parse(value) do + {user_id, ""} when user_id > 0 -> user_id + _ -> nil + end + end + + defp parse_user_id(_value), do: nil + + defp safe_return_to(value) when is_binary(value) do + value = String.trim(value) + + if String.starts_with?(value, "/") and not String.starts_with?(value, "//") and + not String.contains?(value, ["\\", "\0"]) and + not Regex.match?(~r/[\x00-\x1F\x7F]/, value) and String.length(value) <= 2048 do + value + else + "/" + end + end + + defp safe_return_to(_value), do: "/" + + defp normalize_positive_int(value, _default) when is_integer(value) and value > 0, do: value + + defp normalize_positive_int(value, default) when is_binary(value) do + case Integer.parse(value) do + {int, ""} when int > 0 -> int + _ -> default + end + end + + defp normalize_positive_int(_value, default), do: default + + defp html_escape(value) do + value + |> to_string() + |> Phoenix.HTML.html_escape() + |> Phoenix.HTML.safe_to_string() + end + + defp config, do: Application.get_env(:elektrine, :atomine_gate, []) || [] + + defp env_bool(name, default) do + case System.get_env(name) do + nil -> truthy?(default) + "" -> truthy?(default) + value -> truthy?(value) + end + end + + defp env_value(name, default) do + case System.get_env(name) do + nil -> default + "" -> default + value -> value + end + end + + defp truthy?(value), do: value in [true, "true", "1", 1] +end diff --git a/apps/elektrine_web/lib/elektrine_web/atomine_pow.ex b/apps/elektrine_web/lib/elektrine_web/atomine_pow.ex new file mode 100644 index 0000000..2359cb9 --- /dev/null +++ b/apps/elektrine_web/lib/elektrine_web/atomine_pow.ex @@ -0,0 +1,70 @@ +defmodule ElektrineWeb.AtominePow do + @moduledoc false + + @default_difficulty 18 + + def enabled? do + Code.ensure_loaded?(Atomine.Attestations) and not skip_verification?() + end + + def difficulty do + config() + |> Keyword.get(:difficulty, @default_difficulty) + |> normalize_difficulty() + end + + def verify(token, audience, nonce \\ nil) do + cond do + skip_verification?() -> + {:ok, :verified} + + not Elektrine.Strings.present?(token) -> + {:error, :missing_token} + + not Code.ensure_loaded?(Atomine.Attestations) -> + {:error, :unavailable} + + true -> + required_difficulty = difficulty() + + case Atomine.Attestations.redeem_anonymous_effort_token(token, %{ + "audience" => audience, + "nonce" => nonce + }) do + {:ok, %{difficulty: token_difficulty}} -> + if token_difficulty >= required_difficulty do + {:ok, :verified} + else + {:error, :insufficient_difficulty} + end + + {:ok, _attestation} -> + {:error, :insufficient_difficulty} + + {:error, reason} -> + {:error, reason} + end + end + end + + defp skip_verification? do + config() + |> Keyword.get(:skip_verification, false) + |> truthy?() + end + + defp config, do: Application.get_env(:elektrine, :atomine_pow, []) || [] + + def normalize_difficulty(value) when is_integer(value), do: value |> max(0) |> min(30) + + def normalize_difficulty(value) when is_binary(value) do + case Integer.parse(value) do + {difficulty, ""} -> normalize_difficulty(difficulty) + _ -> @default_difficulty + end + end + + def normalize_difficulty(_value), do: @default_difficulty + + defp truthy?(value), do: value in [true, "true", "1", 1] +end diff --git a/apps/elektrine_web/lib/elektrine_web/canonical_url.ex b/apps/elektrine_web/lib/elektrine_web/canonical_url.ex new file mode 100644 index 0000000..8210808 --- /dev/null +++ b/apps/elektrine_web/lib/elektrine_web/canonical_url.ex @@ -0,0 +1,50 @@ +defmodule ElektrineWeb.CanonicalURL do + @moduledoc false + + alias ElektrineWeb.Endpoint + + def base_url(scheme_override \\ nil) do + endpoint_uri = Endpoint.url() |> URI.parse() + scheme = scheme_override || endpoint_uri.scheme || "https" + host = endpoint_uri.host || "localhost" + port = normalize_port(endpoint_uri.port, scheme) + + scheme <> "://" <> host <> port_suffix(port, scheme) + end + + def url(path, query_string \\ nil, scheme_override \\ nil) do + query = if query_string in [nil, ""], do: "", else: "?" <> query_string + base_url(scheme_override) <> path <> query + end + + def request_url(%Plug.Conn{} = conn, path \\ nil, query_string \\ nil, scheme_override \\ nil) do + scheme = scheme_override || to_string(conn.scheme || :https) + host = conn.host || "localhost" + port = normalize_request_port(conn.port, conn.scheme, scheme) + request_path = path || conn.request_path || "/" + query = if query_string in [nil, ""], do: "", else: "?" <> query_string + + scheme <> "://" <> host <> port_suffix(port, scheme) <> request_path <> query + end + + defp normalize_request_port(port, current_scheme, target_scheme) + when current_scheme in [:http, :https] do + case {current_scheme, target_scheme, port} do + {:http, "https", 80} -> nil + {:https, "http", 443} -> nil + _ -> normalize_port(port, target_scheme) + end + end + + defp normalize_request_port(port, _current_scheme, target_scheme), + do: normalize_port(port, target_scheme) + + defp normalize_port(nil, _scheme), do: nil + defp normalize_port(port, _scheme) when not is_integer(port), do: nil + defp normalize_port(port, _scheme), do: port + + defp port_suffix(80, "http"), do: "" + defp port_suffix(443, "https"), do: "" + defp port_suffix(nil, _scheme), do: "" + defp port_suffix(port, _scheme), do: ":#{port}" +end diff --git a/apps/elektrine_web/lib/elektrine_web/channels/call_channel.ex b/apps/elektrine_web/lib/elektrine_web/channels/call_channel.ex new file mode 100644 index 0000000..1b0b89d --- /dev/null +++ b/apps/elektrine_web/lib/elektrine_web/channels/call_channel.ex @@ -0,0 +1,437 @@ +defmodule ElektrineWeb.CallChannel do + @moduledoc false + use ElektrineWeb, :channel + require Logger + + intercept [ + "presence_diff", + "peer_ready", + "offer", + "answer", + "ice_candidate", + "call_rejected", + "call_ended", + "call_missed" + ] + + alias Elektrine.Calls + alias Elektrine.Constants + alias Elektrine.Messaging.Federation + alias Elektrine.Messaging.Federation.VoiceCalls + + @impl true + def join("call:" <> call_id_param, params, socket) do + user_id = socket.assigns.user_id + client_session_id = client_session_id(params) + + case parse_call_id(call_id_param) do + {:ok, call_id} -> + case Calls.get_call_with_users(call_id) do + nil -> + join_federated_call(call_id, user_id, client_session_id, socket) + + call -> + if call.caller_id == user_id or call.callee_id == user_id do + socket = + socket + |> assign(:call_id, call_id) + |> assign(:call_source, :local) + |> assign(:client_session_id, client_session_id) + + send(self(), :after_join) + {:ok, socket} + else + {:error, %{reason: "unauthorized"}} + end + end + + :error -> + {:error, %{reason: "call_not_found"}} + end + end + + @impl true + def handle_info(:after_join, socket) do + ElektrineWeb.Presence.track( + self(), + "call:#{socket.assigns.call_id}", + to_string(socket.assigns.user_id), + %{user_id: socket.assigns.user_id, online_at: System.system_time(:second)} + ) + + push(socket, "presence_state", ElektrineWeb.Presence.list("call:#{socket.assigns.call_id}")) + push(socket, "joined", %{user_id: socket.assigns.user_id}) + + if socket.assigns.call_source == :federated do + case VoiceCalls.get_session_for_local_user(socket.assigns.call_id, socket.assigns.user_id) do + %{status: "active", direction: "outbound"} -> + push(socket, "peer_ready", %{user_id: socket.assigns.user_id}) + + _ -> + :ok + end + end + + {:noreply, socket} + end + + @impl true + def handle_info(:ring_timeout, socket) do + if socket.assigns.call_source == :local do + call = Calls.get_call(socket.assigns.call_id) + + if call && call.status == "ringing" do + Calls.miss_call(socket.assigns.call_id) + broadcast!(socket, "call_missed", %{reason: "timeout"}) + {:stop, :normal, socket} + else + {:noreply, socket} + end + else + {:noreply, socket} + end + end + + @impl true + def handle_info({:federated_call_signal, %{kind: "offer", payload: payload}}, socket) do + push(socket, "offer", %{sdp: payload}) + {:noreply, socket} + end + + def handle_info({:federated_call_signal, %{kind: "answer", payload: payload}}, socket) do + push(socket, "answer", %{sdp: payload}) + {:noreply, socket} + end + + def handle_info({:federated_call_signal, %{kind: "ice", payload: payload}}, socket) do + push(socket, "ice_candidate", %{candidate: payload}) + {:noreply, socket} + end + + def handle_info({:federated_peer_ready, _payload}, socket) do + push(socket, "peer_ready", %{user_id: socket.assigns.user_id}) + {:noreply, socket} + end + + @impl true + def handle_out("presence_diff", diff, socket) do + push(socket, "presence_diff", diff) + {:noreply, socket} + end + + def handle_out(event, payload, socket) + when event in [ + "peer_ready", + "offer", + "answer", + "ice_candidate", + "call_rejected", + "call_ended", + "call_missed" + ] do + unless self_signal?(payload, socket) do + push(socket, event, payload) + end + + {:noreply, socket} + end + + @impl true + def handle_in("ready_to_receive", _params, socket) do + if socket.assigns.call_source == :local do + broadcast_from!( + socket, + "peer_ready", + signal_payload(socket, %{user_id: socket.assigns.user_id}) + ) + else + Federation.publish_dm_call_accept(socket.assigns.call_id) + end + + {:reply, :ok, socket} + end + + @impl true + def handle_in("offer", %{"sdp" => sdp}, socket) do + case validate_sdp(sdp, "offer") do + :ok -> + case socket.assigns.call_source do + :local -> + case Calls.update_call_status(socket.assigns.call_id, "ringing") do + {:ok, %{status: "ringing"}} -> + Process.send_after(self(), :ring_timeout, Constants.call_ring_timeout_ms()) + + _ -> + :ok + end + + broadcast_from!(socket, "offer", signal_payload(socket, %{sdp: sdp})) + + :federated -> + _ = VoiceCalls.mark_session_ringing(socket.assigns.call_id, socket.assigns.user_id) + + Federation.publish_dm_call_signal( + socket.assigns.call_id, + socket.assigns.user_id, + "offer", + sdp + ) + end + + {:reply, :ok, socket} + + {:error, reason} -> + Logger.warning("Invalid SDP offer from user #{socket.assigns.user_id}: #{reason}") + {:reply, {:error, %{reason: "invalid_sdp"}}, socket} + end + end + + @impl true + def handle_in("answer", %{"sdp" => sdp}, socket) do + case validate_sdp(sdp, "answer") do + :ok -> + case socket.assigns.call_source do + :local -> + Calls.update_call_status(socket.assigns.call_id, "active") + broadcast_from!(socket, "answer", signal_payload(socket, %{sdp: sdp})) + + :federated -> + Federation.publish_dm_call_signal( + socket.assigns.call_id, + socket.assigns.user_id, + "answer", + sdp + ) + end + + {:reply, :ok, socket} + + {:error, reason} -> + Logger.warning("Invalid SDP answer from user #{socket.assigns.user_id}: #{reason}") + {:reply, {:error, %{reason: "invalid_sdp"}}, socket} + end + end + + @impl true + def handle_in("ice_candidate", %{"candidate" => candidate}, socket) do + case validate_ice_candidate(candidate) do + :ok -> + case socket.assigns.call_source do + :local -> + broadcast_from!( + socket, + "ice_candidate", + signal_payload(socket, %{candidate: candidate}) + ) + + :federated -> + Federation.publish_dm_call_signal( + socket.assigns.call_id, + socket.assigns.user_id, + "ice", + candidate + ) + end + + {:reply, :ok, socket} + + {:error, reason} -> + Logger.warning("Invalid ICE candidate from user #{socket.assigns.user_id}: #{reason}") + {:reply, {:error, %{reason: "invalid_candidate"}}, socket} + end + end + + @impl true + def handle_in("reject_call", _params, socket) do + case socket.assigns.call_source do + :local -> + Calls.reject_call(socket.assigns.call_id) + + broadcast!( + socket, + "call_rejected", + signal_payload(socket, %{by_user_id: socket.assigns.user_id}) + ) + + :federated -> + _ = VoiceCalls.reject_session(socket.assigns.call_id, socket.assigns.user_id) + Federation.publish_dm_call_reject(socket.assigns.call_id) + end + + {:stop, :normal, :ok, socket} + end + + @impl true + def handle_in("end_call", _params, socket) do + case socket.assigns.call_source do + :local -> + Calls.end_call(socket.assigns.call_id) + + broadcast!( + socket, + "call_ended", + signal_payload(socket, %{by_user_id: socket.assigns.user_id}) + ) + + :federated -> + _ = VoiceCalls.end_session(socket.assigns.call_id, socket.assigns.user_id) + Federation.publish_dm_call_end(socket.assigns.call_id) + end + + {:stop, :normal, :ok, socket} + end + + @impl true + def handle_in("miss_call", _params, socket) do + if socket.assigns.call_source == :local do + Calls.miss_call(socket.assigns.call_id) + end + + {:reply, :ok, socket} + end + + @impl true + def terminate(reason, socket) do + unless normal_channel_shutdown?(reason) do + case socket.assigns.call_source do + :local -> + call = Calls.get_call(socket.assigns.call_id) + + if call && call.status in ["initiated", "ringing", "active"] do + Calls.update_call_status(socket.assigns.call_id, "ended") + + broadcast!( + socket, + "call_ended", + signal_payload(socket, %{by_user_id: socket.assigns.user_id, reason: "disconnected"}) + ) + end + + :federated -> + case VoiceCalls.get_session_for_local_user( + socket.assigns.call_id, + socket.assigns.user_id + ) do + %{status: status} when status in ["initiated", "ringing", "active"] -> + _ = + VoiceCalls.end_session( + socket.assigns.call_id, + socket.assigns.user_id, + "disconnected" + ) + + Federation.publish_dm_call_end(socket.assigns.call_id) + + _ -> + :ok + end + end + end + + :ok + end + + defp normal_channel_shutdown?(:normal), do: true + defp normal_channel_shutdown?(:shutdown), do: true + defp normal_channel_shutdown?({:shutdown, _reason}), do: true + defp normal_channel_shutdown?(_reason), do: false + + defp join_federated_call(call_id, user_id, client_session_id, socket) do + case VoiceCalls.get_session_for_local_user(call_id, user_id) do + nil -> + {:error, %{reason: "call_not_found"}} + + _session -> + socket = + socket + |> assign(:call_id, call_id) + |> assign(:call_source, :federated) + |> assign(:client_session_id, client_session_id) + + send(self(), :after_join) + {:ok, socket} + end + end + + defp parse_call_id(call_id) when is_binary(call_id) do + case Integer.parse(call_id) do + {parsed, ""} -> {:ok, parsed} + _ -> :error + end + end + + defp client_session_id(%{"client_session_id" => value}) when is_binary(value) do + value + |> String.trim() + |> case do + "" -> nil + trimmed -> String.slice(trimmed, 0, 128) + end + end + + defp client_session_id(_params), do: nil + + defp signal_payload(socket, payload) when is_map(payload) do + payload + |> Map.put(:from_user_id, socket.assigns.user_id) + |> Map.put(:from_client_session_id, socket.assigns[:client_session_id]) + |> Map.put(:signal_id, System.unique_integer([:positive, :monotonic])) + end + + defp self_signal?(%{from_client_session_id: session_id}, socket) + when is_binary(session_id) and session_id != "" do + session_id == socket.assigns[:client_session_id] + end + + defp self_signal?(_payload, _socket), do: false + + defp validate_sdp(sdp, expected_type) when is_map(sdp) do + sdp_string = sdp["sdp"] + sdp_type = sdp["type"] + + cond do + sdp_type != expected_type -> {:error, "type_mismatch"} + !is_binary(sdp_string) or byte_size(sdp_string) == 0 -> {:error, "missing_sdp_string"} + byte_size(sdp_string) > 100_000 -> {:error, "sdp_too_large"} + !String.starts_with?(sdp_string, "v=0") -> {:error, "invalid_sdp_format"} + true -> :ok + end + end + + defp validate_sdp(_sdp, _expected_type) do + {:error, "invalid_sdp_structure"} + end + + defp validate_ice_candidate(candidate) when is_map(candidate) do + candidate_line = Map.get(candidate, "candidate") + sdp_mid = Map.get(candidate, "sdpMid") + sdp_mline_index = Map.get(candidate, "sdpMLineIndex") + username_fragment = Map.get(candidate, "usernameFragment") + + cond do + !is_binary(candidate_line) or candidate_line == "" -> + {:error, "missing_candidate"} + + byte_size(candidate_line) > 4096 -> + {:error, "candidate_too_large"} + + !is_nil(sdp_mid) and (!is_binary(sdp_mid) or byte_size(sdp_mid) > 128) -> + {:error, "invalid_sdp_mid"} + + !is_nil(sdp_mline_index) and + (!is_integer(sdp_mline_index) or sdp_mline_index < 0 or sdp_mline_index > 1024) -> + {:error, "invalid_sdp_mline_index"} + + !is_nil(username_fragment) and + (!is_binary(username_fragment) or byte_size(username_fragment) > 256) -> + {:error, "invalid_username_fragment"} + + true -> + :ok + end + end + + defp validate_ice_candidate(_candidate) do + {:error, "invalid_candidate_structure"} + end +end diff --git a/apps/elektrine_web/lib/elektrine_web/channels/mobile_channel.ex b/apps/elektrine_web/lib/elektrine_web/channels/mobile_channel.ex new file mode 100644 index 0000000..8a08676 --- /dev/null +++ b/apps/elektrine_web/lib/elektrine_web/channels/mobile_channel.ex @@ -0,0 +1,1046 @@ +defmodule ElektrineWeb.MobileChannel do + @moduledoc """ + Unified Phoenix Channel for mobile app real-time updates. + Provides live updates for emails, notifications, chat, social features, and other events. + """ + use ElektrineWeb, :channel + + alias Elektrine.{Accounts, Friends, Notifications, Profiles, Uploads} + alias Elektrine.Messaging, as: Messaging + alias Elektrine.Messaging.Federation + alias Elektrine.PubSubTopics + alias ElektrineWeb.Platform.Integrations + + @impl true + def join("mobile:user", _params, socket) do + user_id = socket.assigns.user_id + user = Accounts.get_user!(user_id) + + # Subscribe to user-specific topics + PubSubTopics.subscribe("user:#{user_id}") + PubSubTopics.subscribe(PubSubTopics.user_notifications(user_id)) + PubSubTopics.subscribe("user:#{user_id}:notification_count") + PubSubTopics.subscribe(PubSubTopics.user_vpn(user_id)) + + # Subscribe to mailbox updates if user has a mailbox + if mailbox = Integrations.email_mailbox(user_id) do + PubSubTopics.subscribe("mailbox:#{mailbox.id}") + end + + # Subscribe to all user's conversations for chat updates + conversations = Messaging.list_chat_conversations(user_id) + + for conv <- conversations do + PubSubTopics.subscribe(PubSubTopics.conversation(conv.id)) + end + + # Subscribe to social topics + PubSubTopics.subscribe("social:user:#{user_id}") + PubSubTopics.subscribe("social:followers:#{user_id}") + PubSubTopics.subscribe("social:notifications:#{user_id}") + + # Track presence for online detection + {:ok, _} = + ElektrineWeb.Presence.track(self(), "mobile:users", to_string(user_id), %{ + user_id: user_id, + online_at: System.system_time(:second), + platform: "mobile", + status: user.status || "online" + }) + + Federation.publish_user_presence_update(user_id, user.status || "online", []) + + send(self(), :after_join) + {:ok, assign(socket, :joined_conversations, MapSet.new(Enum.map(conversations, & &1.id)))} + end + + def join("mobile:" <> _other, _params, _socket) do + {:error, %{reason: "invalid_topic"}} + end + + @impl true + def terminate(_reason, socket) do + Federation.publish_user_presence_update(socket.assigns.user_id, "offline", []) + :ok + end + + @impl true + def handle_info(:after_join, socket) do + user_id = socket.assigns.user_id + + # Push initial state + mailbox = Integrations.email_mailbox(user_id) + + if mailbox do + # Push unread counts + counts = Integrations.email_unread_counts(mailbox.id) + push(socket, "email:counts", %{counts: counts}) + end + + # Push notification count + notification_count = Notifications.get_visible_unread_count(user_id) + push(socket, "notification:count", %{count: notification_count}) + + # Push social counts + push(socket, "social:counts", %{ + follower_count: Profiles.get_follower_count(user_id), + following_count: Profiles.get_following_count(user_id) + }) + + push(socket, "vpn:counts", %{config_count: Integrations.vpn_user_config_count(user_id)}) + + push(socket, "event:stream_ready", %{ + sources: event_sources(), + connected_at: DateTime.utc_now() + }) + + {:noreply, socket} + end + + # Handle new email received + @impl true + def handle_info({:new_email, message}, socket) do + email_payload = mobile_email_payload(message) + event_payload = mobile_email_event_payload(message) + + socket = + socket + |> push("email:new", email_payload) + |> push_universal_event("email", "new", event_payload) + + # Also update counts + user_id = socket.assigns.user_id + + if mailbox = Integrations.email_mailbox(user_id) do + counts = Integrations.email_unread_counts(mailbox.id) + push(socket, "email:counts", %{counts: counts}) + end + + {:noreply, socket} + end + + # Handle email status updates + @impl true + def handle_info({:email_updated, message}, socket) do + socket = + socket + |> push("email:updated", %{ + id: message.id, + read: message.read, + archived: message.archived, + spam: message.spam, + deleted: message.deleted, + category: message.category + }) + |> push_universal_event("email", "updated", %{ + title: "Email updated", + body: "Message ##{message.id} status changed", + url: Elektrine.Paths.email_view_path(message), + message_id: message.id, + read: message.read, + archived: message.archived, + deleted: message.deleted + }) + + {:noreply, socket} + end + + # Handle new notification + @impl true + def handle_info({:new_notification, notification}, socket) do + formatted = format_notification(notification, socket.assigns.user_id) + + socket = + socket + |> push("notification:new", formatted) + |> push_universal_event("notification", "new", %{ + title: formatted.title, + body: formatted.body, + url: formatted.url, + notification_id: formatted.id, + type: formatted.type + }) + + {:noreply, socket} + end + + # Handle notification count updates + @impl true + def handle_info({:notification_count_updated, count}, socket) do + socket = + socket + |> push("notification:count", %{count: count}) + |> push_universal_event("notification", "count_updated", %{ + title: "Notification count updated", + body: "#{count} unread notification(s)", + url: Elektrine.Paths.notifications_path(), + count: count + }) + + {:noreply, socket} + end + + # Handle all notifications read + @impl true + def handle_info(:all_notifications_read, socket) do + socket = + socket + |> push("notification:count", %{count: 0}) + |> push_universal_event("notification", "all_read", %{ + title: "Notifications cleared", + body: "All notifications marked as read", + url: Elektrine.Paths.notifications_path(), + count: 0 + }) + + {:noreply, socket} + end + + # Chat message events + @impl true + def handle_info({:new_chat_message, message}, socket) do + formatted = format_chat_message(message) + + socket = + socket + |> push("chat:new_message", formatted) + |> push_universal_event("chat", "message_created", %{ + title: "New chat message", + body: truncate_preview(formatted.content), + url: Elektrine.Paths.chat_path(formatted.conversation_id), + conversation_id: formatted.conversation_id, + message_id: formatted.id + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:chat_message_updated, message}, socket) do + formatted = format_chat_message(message) + + socket = + socket + |> push("chat:message_updated", formatted) + |> push_universal_event("chat", "message_updated", %{ + title: "Chat message updated", + body: truncate_preview(formatted.content), + url: Elektrine.Paths.chat_path(formatted.conversation_id), + conversation_id: formatted.conversation_id, + message_id: formatted.id + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:chat_message_deleted, message_id}, socket) do + socket = + socket + |> push("chat:message_deleted", %{message_id: message_id}) + |> push_universal_event("chat", "message_deleted", %{ + title: "Chat message deleted", + body: "Message ##{message_id} was removed", + url: Elektrine.Paths.chat_root_path(), + message_id: message_id + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:chat_reaction_added, message_id, reaction}, socket) do + push(socket, "chat:reaction_added", %{ + message_id: message_id, + reaction: %{ + id: reaction.id, + emoji: reaction.emoji, + user_id: reaction.user_id + } + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:chat_reaction_removed, message_id, user_id, emoji}, socket) do + push(socket, "chat:reaction_removed", %{ + message_id: message_id, + user_id: user_id, + emoji: emoji + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:user_typing, conversation_id, user_id, username}, socket) do + push(socket, "chat:user_typing", %{ + conversation_id: conversation_id, + user_id: user_id, + username: username + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:user_stopped_typing, conversation_id, user_id}, socket) do + push(socket, "chat:user_stopped_typing", %{ + conversation_id: conversation_id, + user_id: user_id + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:read_receipt, conversation_id, user_id, message_id}, socket) do + push(socket, "chat:read_receipt", %{ + conversation_id: conversation_id, + user_id: user_id, + message_id: message_id + }) + + {:noreply, socket} + end + + # === Social Events === + + # Handle new post from followed user + @impl true + def handle_info({:new_post, post}, socket) do + formatted = format_post(post) + + socket = + socket + |> push("post:new", formatted) + |> push_universal_event("social", "post_created", %{ + title: "New post", + body: truncate_preview(formatted.content), + url: Elektrine.Paths.post_path(formatted.id), + post_id: formatted.id + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:post_updated, post}, socket) do + formatted = format_post(post) + + socket = + socket + |> push("post:updated", formatted) + |> push_universal_event("social", "post_updated", %{ + title: "Post updated", + body: truncate_preview(formatted.content), + url: Elektrine.Paths.post_path(formatted.id), + post_id: formatted.id + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:post_deleted, post_id}, socket) do + socket = + socket + |> push("post:deleted", %{id: post_id}) + |> push_universal_event("social", "post_deleted", %{ + title: "Post deleted", + body: "Post ##{post_id} was removed", + url: Elektrine.Paths.timeline_path(), + post_id: post_id + }) + + {:noreply, socket} + end + + @impl true + def handle_info( + {:post_liked, %{post_id: post_id, user_id: liker_id, like_count: count}}, + socket + ) do + socket = + socket + |> push("post:liked", %{post_id: post_id, liker_id: liker_id, like_count: count}) + |> push_universal_event("social", "post_liked", %{ + title: "Post liked", + body: "Post ##{post_id} now has #{count} like(s)", + url: Elektrine.Paths.post_path(post_id), + post_id: post_id, + like_count: count + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:post_unliked, %{post_id: post_id, like_count: count}}, socket) do + socket = + socket + |> push("post:unliked", %{post_id: post_id, like_count: count}) + |> push_universal_event("social", "post_unliked", %{ + title: "Post unliked", + body: "Post ##{post_id} now has #{count} like(s)", + url: Elektrine.Paths.post_path(post_id), + post_id: post_id, + like_count: count + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:new_comment, comment}, socket) do + formatted = format_comment(comment) + + socket = + socket + |> push("comment:new", formatted) + |> push_universal_event("social", "comment_created", %{ + title: "New comment", + body: truncate_preview(formatted.content), + url: Elektrine.Paths.post_path(formatted.post_id), + comment_id: formatted.id, + post_id: formatted.post_id + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:comment_deleted, comment_id}, socket) do + socket = + socket + |> push("comment:deleted", %{id: comment_id}) + |> push_universal_event("social", "comment_deleted", %{ + title: "Comment deleted", + body: "Comment ##{comment_id} was removed", + url: Elektrine.Paths.timeline_path(), + comment_id: comment_id + }) + + {:noreply, socket} + end + + @impl true + def handle_info( + {:post_reposted, %{post_id: post_id, user_id: reposter_id, repost_count: count}}, + socket + ) do + socket = + socket + |> push("post:reposted", %{ + post_id: post_id, + reposter_id: reposter_id, + repost_count: count + }) + |> push_universal_event("social", "post_reposted", %{ + title: "Post reposted", + body: "Post ##{post_id} now has #{count} repost(s)", + url: Elektrine.Paths.post_path(post_id), + post_id: post_id, + repost_count: count + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:new_follower, follower}, socket) do + user_id = socket.assigns.user_id + + socket = + socket + |> push("follower:new", %{ + id: follower.id, + username: follower.username, + display_name: follower.display_name, + avatar: follower.avatar_url + }) + |> push("social:counts", %{ + follower_count: Profiles.get_follower_count(user_id), + following_count: Profiles.get_following_count(user_id) + }) + |> push_universal_event("social", "follower_added", %{ + title: "New follower", + body: "@#{follower.username} started following you", + url: "/#{follower.handle || follower.username}", + follower_id: follower.id + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:unfollowed, follower_id}, socket) do + user_id = socket.assigns.user_id + + socket = + socket + |> push("follower:removed", %{id: follower_id}) + |> push("social:counts", %{ + follower_count: Profiles.get_follower_count(user_id), + following_count: Profiles.get_following_count(user_id) + }) + |> push_universal_event("social", "follower_removed", %{ + title: "Follower removed", + body: "Follower ##{follower_id} is no longer following", + url: Elektrine.Paths.friends_path(), + follower_id: follower_id + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:friend_request, request}, socket) do + socket = + socket + |> push("friend_request:new", %{ + id: request.id, + user_id: request.requester_id, + username: request.requester.username, + display_name: request.requester.display_name, + avatar: request.requester.avatar_url, + created_at: request.inserted_at + }) + |> push_universal_event("social", "friend_request_received", %{ + title: "Friend request", + body: "@#{request.requester.username} sent you a request", + url: Elektrine.Paths.friends_path(tab: "requests"), + request_id: request.id + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:friend_request_accepted, request}, socket) do + socket = + socket + |> push("friend_request:accepted", %{id: request.id, user_id: request.recipient_id}) + |> push_universal_event("social", "friend_request_accepted", %{ + title: "Friend request accepted", + body: "You are now connected", + url: Elektrine.Paths.friends_path(), + request_id: request.id + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:social_notification, notification}, socket) do + formatted = format_notification(notification, socket.assigns.user_id) + + socket = + socket + |> push("notification:new", formatted) + |> push_universal_event("social", "notification", %{ + title: formatted.title, + body: formatted.body, + url: formatted.url, + notification_id: formatted.id, + type: formatted.type + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:vpn_config_created, config}, socket) do + formatted = format_vpn_config(config) + count = Integrations.vpn_user_config_count(socket.assigns.user_id) + + socket = + socket + |> push("vpn:config_created", formatted) + |> push("vpn:counts", %{config_count: count}) + |> push_universal_event("vpn", "config_created", %{ + title: "VPN profile created", + body: "#{formatted.server_name} - #{formatted.allocated_ip}", + url: Elektrine.Paths.vpn_path(), + config_id: formatted.id + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:vpn_config_updated, config}, socket) do + formatted = format_vpn_config(config) + count = Integrations.vpn_user_config_count(socket.assigns.user_id) + + socket = + socket + |> push("vpn:config_updated", formatted) + |> push("vpn:counts", %{config_count: count}) + |> push_universal_event("vpn", "config_updated", %{ + title: "VPN profile updated", + body: "#{formatted.server_name} - status #{formatted.status}", + url: Elektrine.Paths.vpn_path(), + config_id: formatted.id + }) + + {:noreply, socket} + end + + @impl true + def handle_info({:vpn_config_deleted, config_id}, socket) do + count = Integrations.vpn_user_config_count(socket.assigns.user_id) + + socket = + socket + |> push("vpn:config_deleted", %{id: config_id}) + |> push("vpn:counts", %{config_count: count}) + |> push_universal_event("vpn", "config_deleted", %{ + title: "VPN profile removed", + body: "VPN config ##{config_id} was removed", + url: Elektrine.Paths.vpn_path(), + config_id: config_id + }) + + {:noreply, socket} + end + + # Ignore other PubSub messages + @impl true + def handle_info(_msg, socket) do + {:noreply, socket} + end + + # Client-initiated events + + @impl true + def handle_in("ping", _payload, socket) do + {:reply, {:ok, %{ts: System.system_time(:second)}}, socket} + end + + @impl true + def handle_in("mark_email_read", %{"id" => id}, socket) do + user_id = socket.assigns.user_id + + case Integrations.email_user_message(id, user_id) do + {:ok, message} -> + Integrations.email_mark_as_read(message) + {:reply, :ok, socket} + + _ -> + {:reply, {:error, %{reason: "not_found"}}, socket} + end + end + + @impl true + def handle_in("mark_notification_read", %{"id" => id}, socket) do + user_id = socket.assigns.user_id + + :ok = Notifications.mark_as_read(id, user_id) + {:reply, :ok, socket} + end + + @impl true + def handle_in("get_counts", _payload, socket) do + user_id = socket.assigns.user_id + mailbox = Integrations.email_mailbox(user_id) + + counts = + if mailbox do + Integrations.email_unread_counts(mailbox.id) + else + %{} + end + + notification_count = Notifications.get_visible_unread_count(user_id) + + {:reply, {:ok, %{email_counts: counts, notification_count: notification_count}}, socket} + end + + # Chat events + + @impl true + def handle_in("chat:join_conversation", %{"conversation_id" => conv_id}, socket) do + user_id = socket.assigns.user_id + + # Verify membership + case Messaging.get_conversation_member(conv_id, user_id) do + nil -> + {:reply, {:error, %{reason: "not_member"}}, socket} + + _member -> + # Subscribe if not already + joined = socket.assigns[:joined_conversations] || MapSet.new() + + unless MapSet.member?(joined, conv_id) do + PubSubTopics.subscribe(PubSubTopics.conversation(conv_id)) + end + + {:reply, :ok, assign(socket, :joined_conversations, MapSet.put(joined, conv_id))} + end + end + + @impl true + def handle_in("chat:typing", %{"conversation_id" => conv_id}, socket) do + user_id = socket.assigns.user_id + user = Accounts.get_user!(user_id) + + # Broadcast to conversation + topic = PubSubTopics.conversation(conv_id) + + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + topic, + {:user_typing, conv_id, user_id, user.username} + ) + + Elektrine.Messaging.Federation.publish_typing_started(conv_id, user_id) + + {:noreply, socket} + end + + @impl true + def handle_in("chat:stop_typing", %{"conversation_id" => conv_id}, socket) do + user_id = socket.assigns.user_id + + topic = PubSubTopics.conversation(conv_id) + Phoenix.PubSub.broadcast(Elektrine.PubSub, topic, {:user_stopped_typing, conv_id, user_id}) + Elektrine.Messaging.Federation.publish_typing_stopped(conv_id, user_id) + + {:noreply, socket} + end + + @impl true + def handle_in( + "chat:read_message", + %{"conversation_id" => conv_id, "message_id" => msg_id}, + socket + ) do + user_id = socket.assigns.user_id + + # Mark as read + Messaging.mark_chat_messages_read(conv_id, user_id, msg_id) + + # Broadcast read receipt + topic = PubSubTopics.conversation(conv_id) + Phoenix.PubSub.broadcast(Elektrine.PubSub, topic, {:read_receipt, conv_id, user_id, msg_id}) + + {:noreply, socket} + end + + @impl true + def handle_in("chat:get_unread_counts", _payload, socket) do + user_id = socket.assigns.user_id + counts = Messaging.get_all_chat_unread_counts(user_id) + {:reply, {:ok, %{counts: counts}}, socket} + end + + # === Social Client Events === + + @impl true + def handle_in("like_post", %{"post_id" => post_id}, socket) do + user_id = socket.assigns.user_id + + case Elektrine.Social.Messages.get_timeline_post(post_id) do + post when not is_nil(post) -> + if can_view_mobile_post?(post, user_id) do + case Integrations.social_like_post(user_id, post_id) do + {:ok, _} -> {:reply, {:ok, %{message: "Post liked"}}, socket} + {:error, reason} -> {:reply, {:error, %{reason: inspect(reason)}}, socket} + end + else + {:reply, {:error, %{reason: "not_found"}}, socket} + end + + _ -> + {:reply, {:error, %{reason: "not_found"}}, socket} + end + end + + @impl true + def handle_in("unlike_post", %{"post_id" => post_id}, socket) do + user_id = socket.assigns.user_id + + case Integrations.social_unlike_post(user_id, post_id) do + {:ok, _} -> {:reply, {:ok, %{message: "Post unliked"}}, socket} + {:error, reason} -> {:reply, {:error, %{reason: inspect(reason)}}, socket} + end + end + + @impl true + def handle_in("repost", %{"post_id" => post_id}, socket) do + user_id = socket.assigns.user_id + + case Integrations.social_boost_post(user_id, post_id) do + {:ok, boost} -> {:reply, {:ok, %{post: format_post(boost)}}, socket} + {:error, reason} -> {:reply, {:error, %{reason: inspect(reason)}}, socket} + end + end + + @impl true + def handle_in("unrepost", %{"post_id" => post_id}, socket) do + user_id = socket.assigns.user_id + + case Integrations.social_unboost_post(user_id, post_id) do + {:ok, _} -> {:reply, {:ok, %{message: "Repost removed"}}, socket} + {:error, reason} -> {:reply, {:error, %{reason: inspect(reason)}}, socket} + end + end + + @impl true + def handle_in("follow", %{"user_id" => target_id}, socket) do + user_id = socket.assigns.user_id + + case Profiles.follow_user(user_id, target_id) do + {:ok, _} -> {:reply, {:ok, %{message: "Following user"}}, socket} + {:error, reason} -> {:reply, {:error, %{reason: inspect(reason)}}, socket} + end + end + + @impl true + def handle_in("unfollow", %{"user_id" => target_id}, socket) do + user_id = socket.assigns.user_id + + case Profiles.unfollow_user(user_id, target_id) do + {:ok, :unfollowed} -> {:reply, {:ok, %{message: "Unfollowed user"}}, socket} + {:ok, :not_following} -> {:reply, {:ok, %{message: "Not following this user"}}, socket} + end + end + + @impl true + def handle_in("mark_all_notifications_read", _payload, socket) do + user_id = socket.assigns.user_id + Notifications.mark_all_as_read(user_id) + push(socket, "notification:count", %{count: 0}) + {:reply, :ok, socket} + end + + @impl true + def handle_in("get_social_counts", _payload, socket) do + user_id = socket.assigns.user_id + + {:reply, + {:ok, + %{ + follower_count: Profiles.get_follower_count(user_id), + following_count: Profiles.get_following_count(user_id), + notification_count: Notifications.get_visible_unread_count(user_id) + }}, socket} + end + + # Private helpers + + defp truncate_preview(nil), do: "" + + defp truncate_preview(text) when is_binary(text) do + text + |> String.trim() + |> String.slice(0, 100) + end + + defp push_universal_event(socket, source, event, attrs) when is_map(attrs) do + payload = + attrs + |> Map.put_new(:id, event_id()) + |> Map.put_new(:source, source) + |> Map.put_new(:event, event) + |> Map.put_new(:inserted_at, DateTime.utc_now()) + + push(socket, "event:new", payload) + end + + defp push_universal_event(socket, source, event, _attrs) do + push_universal_event(socket, source, event, %{}) + end + + defp email_title(message) do + from = message.from |> to_string() |> String.trim() + + if from == "" do + "New email" + else + "New email from #{from}" + end + end + + defp private_email?(%{client_encrypted_payload: payload}) when is_map(payload), do: true + defp private_email?(_message), do: false + + defp mobile_email_payload(message) do + if private_email?(message) do + %{ + id: message.id, + from: "Encrypted sender", + subject: "Encrypted message", + preview: "Unlock your mailbox to view this message.", + category: message.category, + has_attachments: message.has_attachments, + private_encrypted: true, + inserted_at: message.inserted_at + } + else + %{ + id: message.id, + from: message.from, + subject: message.subject, + preview: truncate_preview(message.text_body), + category: message.category, + has_attachments: message.has_attachments, + private_encrypted: false, + inserted_at: message.inserted_at + } + end + end + + defp mobile_email_event_payload(message) do + if private_email?(message) do + %{ + title: "New encrypted email", + body: "Unlock your mailbox to view this message.", + url: Elektrine.Paths.email_view_path(message), + category: message.category, + message_id: message.id, + private_encrypted: true + } + else + %{ + title: email_title(message), + body: truncate_preview(message.subject), + url: Elektrine.Paths.email_view_path(message), + category: message.category, + message_id: message.id, + private_encrypted: false + } + end + end + + defp event_id do + "evt_" <> Integer.to_string(System.unique_integer([:positive])) + end + + defp event_sources do + [ + if(Integrations.email_available?(), do: "email"), + "notification", + if(Elektrine.Platform.Modules.enabled?(:chat), do: "chat"), + if(Integrations.social_available?(), do: "social"), + if(Integrations.vpn_available?(), do: "vpn"), + if(Integrations.email_available?(), do: "calendar"), + if(Integrations.nerve_available?(), do: "nerve") + ] + |> Enum.reject(&is_nil/1) + end + + defp format_vpn_config(config) do + %{ + id: config.id, + server_name: vpn_server_name(config), + allocated_ip: config.allocated_ip, + status: config.status, + inserted_at: config.inserted_at, + updated_at: config.updated_at + } + end + + defp vpn_server_name(%{vpn_server: %{name: name}}) when is_binary(name) and name != "", do: name + defp vpn_server_name(_), do: "VPN" + + defp format_notification(notification, user_id) do + %{ + id: notification.id, + type: notification.type, + title: notification.title, + body: notification.body, + url: notification.url, + icon: notification.icon, + read: notification.read, + inserted_at: notification.inserted_at + } + |> Notifications.redact_delivery_payload(user_id) + end + + defp format_chat_message(message) do + %{ + id: message.id, + conversation_id: message.conversation_id, + sender_id: message.sender_id, + sender: format_user(message.sender), + content: message.content, + message_type: message.message_type, + media_urls: Enum.map(message.media_urls || [], &Uploads.attachment_url(&1, %{type: "dm"})), + media_metadata: message.media_metadata || %{}, + reply_to_id: message.reply_to_id, + edited_at: message.edited_at, + deleted_at: message.deleted_at, + inserted_at: message.inserted_at + } + end + + defp format_user(nil), do: nil + defp format_user(%Ecto.Association.NotLoaded{}), do: nil + + defp format_user(user) do + %{ + id: user.id, + username: user.username, + avatar: user.avatar + } + end + + defp format_post(post) do + %{ + id: post.id, + content: post.content, + media_urls: post.media_urls || [], + author_id: post.sender_id, + community_id: post.conversation_id, + visibility: post.visibility || "public", + like_count: post.like_count || 0, + comment_count: post.reply_count || 0, + repost_count: post.share_count || 0, + created_at: post.inserted_at, + author: format_author(post.sender) + } + end + + defp format_author(nil), do: nil + defp format_author(%Ecto.Association.NotLoaded{}), do: nil + + defp format_author(user) do + %{ + id: user.id, + username: user.username, + display_name: user.display_name, + avatar: user.avatar_url, + verified: user.verified || false + } + end + + defp can_view_mobile_post?(post, viewer_id) do + owner? = not is_nil(post.sender_id) and viewer_id == post.sender_id + + case post.visibility do + "public" -> true + "unlisted" -> true + "followers" -> owner? or Profiles.following?(viewer_id, post.sender_id) + "friends" -> owner? or Friends.are_friends?(viewer_id, post.sender_id) + "private" -> owner? + _ -> false + end + end + + defp format_comment(comment) do + %{ + id: comment.id, + content: comment.content, + author_id: comment.sender_id, + post_id: comment.reply_to_id, + parent_id: comment.parent_id, + like_count: comment.like_count || 0, + reply_count: comment.reply_count || 0, + created_at: comment.inserted_at, + author: format_author(comment.sender) + } + end +end diff --git a/apps/elektrine_web/lib/elektrine_web/channels/presence.ex b/apps/elektrine_web/lib/elektrine_web/channels/presence.ex new file mode 100644 index 0000000..0f744c4 --- /dev/null +++ b/apps/elektrine_web/lib/elektrine_web/channels/presence.ex @@ -0,0 +1,212 @@ +defmodule ElektrineWeb.Presence do + @moduledoc """ + Provides presence tracking to channels and processes. + + Per-device presences are tracked on the `PubSubTopics.users_presence/0` + topic. The `handle_metas/4` callback aggregates them into one snapshot per + user (best status across devices, device list, last-seen), kept in a + node-local ETS table. + + Whenever a user's aggregate snapshot changes, `{:presence_changed, user_id, + snapshot}` is broadcast node-locally on `PubSubTopics.users_presence_updates/0`. + Raw per-device joins/leaves that don't change the visible snapshot broadcast + nothing, so subscribers see rare status transitions instead of every diff. + + Status transitions also trigger side effects exactly once per node: the + last-seen timestamp is persisted when a user's final device leaves, and the + new status is published to federation peers. On a multi-node cluster these + run on each node observing the transition; both are idempotent. + + Channel topics (calls, voice, mobile) also track through this module and are + passed through untouched. + + See the [`Phoenix.Presence`](https://hexdocs.pm/phoenix/Phoenix.Presence.html) + docs for more details. + """ + use Phoenix.Presence, + otp_app: :elektrine, + pubsub_server: Elektrine.PubSub + + alias Elektrine.PubSubTopics + + @table :elektrine_user_presence_statuses + + ## Public API + + @doc """ + Subscribes the caller to aggregated `{:presence_changed, user_id, snapshot}` + events. + """ + def subscribe_status_updates do + Phoenix.PubSub.subscribe(Elektrine.PubSub, PubSubTopics.users_presence_updates()) + end + + @doc """ + Tracks a user's device on the global presence topic. + """ + def track_user(pid, user, device_info \\ %{}) do + track(pid, PubSubTopics.users_presence(), to_string(user.id), %{ + user_id: user.id, + username: user.username, + status: user.status || "online", + status_message: user.status_message, + online_at: System.system_time(:second), + last_seen_at: + (user.last_seen_at && DateTime.to_unix(user.last_seen_at)) || + System.system_time(:second), + device_type: device_info[:device_type] || "desktop", + browser: device_info[:browser], + timezone: device_info[:timezone], + auto_away: false + }) + end + + @doc """ + Merges `attrs` into the caller's presence meta on the global topic. + """ + def update_user_meta(pid, user_id, attrs) when is_map(attrs) do + update(pid, PubSubTopics.users_presence(), to_string(user_id), &Map.merge(&1, attrs)) + end + + @doc """ + Returns the aggregated status snapshots for all users this node has seen, + as a map of string user id => snapshot. + """ + def list_user_statuses do + @table |> :ets.tab2list() |> Map.new() + rescue + ArgumentError -> %{} + end + + @doc """ + Returns the aggregated snapshot for one user, or an offline default. + """ + def get_user_status(user_id) do + case :ets.lookup(@table, to_string(user_id)) do + [{_id, snapshot}] -> snapshot + [] -> offline_snapshot(nil) + end + rescue + ArgumentError -> offline_snapshot(nil) + end + + ## Phoenix.Presence callbacks + + @impl true + def init(_opts) do + :ets.new(@table, [:named_table, :public, :set, read_concurrency: true]) + {:ok, %{users_topic: PubSubTopics.users_presence()}} + end + + @impl true + def handle_metas(topic, %{joins: joins, leaves: leaves}, presences, state) do + if topic == state.users_topic do + (Map.keys(joins) ++ Map.keys(leaves)) + |> Enum.uniq() + |> Enum.each(fn user_id -> + refresh_user(user_id, Map.get(presences, user_id, [])) + end) + end + + {:ok, state} + end + + defp refresh_user(user_id, metas) do + prev = + case :ets.lookup(@table, user_id) do + [{_id, snapshot}] -> snapshot + [] -> nil + end + + snapshot = + case metas do + [] -> offline_snapshot(prev) + metas -> aggregate_metas(metas) + end + + if snapshot != prev do + :ets.insert(@table, {user_id, snapshot}) + + Phoenix.PubSub.local_broadcast( + Elektrine.PubSub, + PubSubTopics.users_presence_updates(), + {:presence_changed, user_id, snapshot} + ) + + if is_nil(prev) or prev.status != snapshot.status do + publish_side_effects(user_id, snapshot.status) + end + end + end + + # Persist last-seen and notify federation peers off the tracker process. + # Async.start keeps DB/network work out of the tracker shard and skips it + # entirely under the test sandbox. + defp publish_side_effects(user_id, status) do + with {db_user_id, ""} when db_user_id > 0 <- Integer.parse(user_id) do + Elektrine.Async.start(fn -> + if status == "offline" do + Elektrine.Accounts.update_last_seen_async(db_user_id) + end + + Elektrine.Messaging.Federation.publish_user_presence_update( + db_user_id, + federation_status(status), + [] + ) + end) + end + end + + # ARBP presence vocabulary is online/idle/dnd/offline/invisible. + defp federation_status("away"), do: "idle" + defp federation_status(status) when status in ~w(online idle dnd offline invisible), do: status + defp federation_status(_status), do: "online" + + defp aggregate_metas(metas) do + devices = + metas + |> Enum.map(fn meta -> meta[:device_type] || "desktop" end) + |> Enum.uniq() + + status = + metas + |> Enum.map(fn meta -> meta[:status] || "online" end) + |> Enum.min_by(&status_priority/1) + + message = Enum.find_value(metas, fn meta -> meta[:status_message] end) + + last_seen = + metas + |> Enum.map(fn meta -> meta[:last_seen_at] || meta[:online_at] end) + |> Enum.reject(&is_nil/1) + |> Enum.max(fn -> System.system_time(:second) end) + + %{ + status: status, + message: message, + last_seen_at: last_seen, + devices: devices, + device_count: length(metas) + } + end + + defp offline_snapshot(prev) do + %{ + status: "offline", + message: prev && prev.message, + last_seen_at: System.system_time(:second), + devices: [], + device_count: 0 + } + end + + @doc """ + Status priority for cross-device aggregation (lower = more present). + """ + def status_priority("online"), do: 1 + def status_priority("away"), do: 2 + def status_priority("dnd"), do: 3 + def status_priority("offline"), do: 4 + def status_priority(_), do: 5 +end diff --git a/apps/elektrine_web/lib/elektrine_web/channels/user_socket.ex b/apps/elektrine_web/lib/elektrine_web/channels/user_socket.ex new file mode 100644 index 0000000..8bfb45f --- /dev/null +++ b/apps/elektrine_web/lib/elektrine_web/channels/user_socket.ex @@ -0,0 +1,110 @@ +defmodule ElektrineWeb.UserSocket do + use Phoenix.Socket + + alias Elektrine.Accounts + alias Elektrine.Accounts.Authentication + + # Channels + channel "call:*", ElektrineWeb.CallChannel + channel "voice:*", ElektrineWeb.VoiceChannel + channel "mobile:*", ElektrineWeb.MobileChannel + + @impl true + def connect(%{"token" => token}, socket, _connect_info) do + case verify_phoenix_token(token) do + {:ok, claims} -> + case fetch_active_user(claims.user_id) do + {:ok, user} -> + if password_changed_at_unix(user) == claims.password_changed_at and + auth_valid_after_unix(user) == claims.auth_valid_after do + socket = assign(socket, :user_id, user.id) + {:ok, socket} + else + :error + end + + {:error, _reason} -> + :error + end + + {:error, _reason} -> + :error + end + end + + # Support API token authentication for mobile apps + @impl true + def connect(%{"api_token" => token}, socket, _connect_info) do + case ElektrineWeb.Plugs.APIAuth.verify_user_token(token) do + {:ok, user} -> + socket = assign(socket, :user_id, user.id) + {:ok, socket} + + {:error, _reason} -> + :error + end + end + + def connect(_params, _socket, _connect_info) do + :error + end + + @impl true + def id(socket), do: "user_socket:#{socket.assigns.user_id}" + + # Verify the Phoenix session token + defp verify_phoenix_token(token) do + case Phoenix.Token.verify( + ElektrineWeb.Endpoint, + "user socket", + token, + max_age: 300 + ) do + {:ok, + %{ + "user_id" => user_id, + "password_changed_at" => password_changed_at, + "auth_valid_after" => auth_valid_after + }} + when is_integer(user_id) and is_integer(password_changed_at) and + is_integer(auth_valid_after) -> + {:ok, + %{ + user_id: user_id, + password_changed_at: password_changed_at, + auth_valid_after: auth_valid_after + }} + + {:error, reason} -> + {:error, reason} + + _ -> + {:error, :invalid_token} + end + end + + defp fetch_active_user(user_id) do + user = Accounts.get_user!(user_id) + + case Authentication.ensure_user_active(user) do + :ok -> {:ok, user} + {:error, reason} -> {:error, reason} + end + rescue + Ecto.NoResultsError -> {:error, :invalid_token} + end + + defp password_changed_at_unix(user) do + case user.last_password_change do + %DateTime{} = changed_at -> DateTime.to_unix(changed_at, :second) + _ -> 0 + end + end + + defp auth_valid_after_unix(user) do + case user.auth_valid_after do + %DateTime{} = valid_after -> DateTime.to_unix(valid_after, :second) + _ -> 0 + end + end +end diff --git a/apps/elektrine_web/lib/elektrine_web/channels/voice_channel.ex b/apps/elektrine_web/lib/elektrine_web/channels/voice_channel.ex new file mode 100644 index 0000000..2463da6 --- /dev/null +++ b/apps/elektrine_web/lib/elektrine_web/channels/voice_channel.ex @@ -0,0 +1,189 @@ +defmodule ElektrineWeb.VoiceChannel do + @moduledoc """ + Signaling and occupancy channel for community voice channels. + + Topic: `voice:`. + + Occupancy is tracked with `ElektrineWeb.Presence` on the channel topic, so + disconnects and crashes untrack automatically. Chat LiveViews subscribe to + the same PubSub topic and rebuild occupant lists from `presence_diff` + broadcasts. + + Mesh signaling: clients push `"signal"` messages with a target user id. + Signals are relayed over the internal `voice_signal:` + PubSub topic (separate from the presence topic so LiveViews never receive + SDP payloads) and pushed only to the targeted user's channel process. + + Initiator rule: the joining peer offers to every occupant that was already + present (ties across concurrent joins are broken client-side by comparing + `{joined_at, user_id}` presence metadata). + """ + + use ElektrineWeb, :channel + require Logger + + alias Elektrine.Accounts + alias Elektrine.Messaging.VoiceChannels + alias ElektrineWeb.Presence + + @impl true + def join("voice:" <> conversation_id_param, _params, socket) do + user_id = socket.assigns.user_id + + with {:ok, conversation_id} <- parse_conversation_id(conversation_id_param), + :ok <- + VoiceChannels.authorize_join( + conversation_id, + user_id, + occupant_user_ids(conversation_id) + ) do + Phoenix.PubSub.subscribe(Elektrine.PubSub, signal_topic(conversation_id)) + + socket = assign(socket, :conversation_id, conversation_id) + send(self(), :after_join) + {:ok, socket} + else + {:error, reason} -> {:error, %{reason: to_string(reason)}} + end + end + + @impl true + def handle_info(:after_join, socket) do + user = Accounts.get_user!(socket.assigns.user_id) + + {:ok, _ref} = + Presence.track(self(), socket.topic, to_string(user.id), %{ + user_id: user.id, + username: user.username, + display_name: user.display_name || user.username, + avatar: user.avatar, + muted: false, + joined_at: System.system_time(:millisecond) + }) + + push(socket, "presence_state", Presence.list(socket.topic)) + {:noreply, socket} + end + + def handle_info({:voice_signal, %{to: to_user_id} = payload}, socket) do + if to_user_id == socket.assigns.user_id do + push(socket, "signal", %{ + from: payload.from, + kind: payload.kind, + payload: payload.payload + }) + end + + {:noreply, socket} + end + + def handle_info(_message, socket), do: {:noreply, socket} + + @impl true + def handle_in("signal", %{"to" => to, "kind" => kind, "payload" => payload}, socket) do + with {:ok, to_user_id} <- parse_target(to), + :ok <- validate_signal(kind, payload) do + Phoenix.PubSub.broadcast( + Elektrine.PubSub, + signal_topic(socket.assigns.conversation_id), + {:voice_signal, + %{from: socket.assigns.user_id, to: to_user_id, kind: kind, payload: payload}} + ) + + {:reply, :ok, socket} + else + {:error, reason} -> + Logger.warning( + "Invalid voice signal from user #{socket.assigns.user_id}: #{inspect(reason)}" + ) + + {:reply, {:error, %{reason: "invalid_signal"}}, socket} + end + end + + def handle_in("set_muted", %{"muted" => muted}, socket) when is_boolean(muted) do + key = to_string(socket.assigns.user_id) + + case Presence.update(self(), socket.topic, key, &Map.put(&1, :muted, muted)) do + {:ok, _ref} -> {:reply, :ok, socket} + {:error, _reason} -> {:reply, {:error, %{reason: "not_joined"}}, socket} + end + end + + def handle_in(_event, _params, socket) do + {:reply, {:error, %{reason: "unknown_event"}}, socket} + end + + defp occupant_user_ids(conversation_id) do + "voice:#{conversation_id}" + |> Presence.list() + |> Enum.flat_map(fn {_key, %{metas: metas}} -> Enum.map(metas, & &1.user_id) end) + |> Enum.uniq() + end + + defp signal_topic(conversation_id), do: "voice_signal:#{conversation_id}" + + defp parse_conversation_id(value) when is_binary(value) do + case Integer.parse(value) do + {parsed, ""} when parsed > 0 -> {:ok, parsed} + _ -> {:error, :not_found} + end + end + + defp parse_target(value) when is_integer(value) and value > 0, do: {:ok, value} + + defp parse_target(value) when is_binary(value) do + case Integer.parse(value) do + {parsed, ""} when parsed > 0 -> {:ok, parsed} + _ -> {:error, :invalid_target} + end + end + + defp parse_target(_value), do: {:error, :invalid_target} + + defp validate_signal("offer", payload), do: validate_sdp(payload, "offer") + defp validate_signal("answer", payload), do: validate_sdp(payload, "answer") + defp validate_signal("ice", payload), do: validate_ice_candidate(payload) + defp validate_signal(_kind, _payload), do: {:error, :unknown_kind} + + defp validate_sdp(%{} = sdp, expected_type) do + sdp_string = sdp["sdp"] + sdp_type = sdp["type"] + + cond do + sdp_type != expected_type -> {:error, :type_mismatch} + !is_binary(sdp_string) or byte_size(sdp_string) == 0 -> {:error, :missing_sdp_string} + byte_size(sdp_string) > 100_000 -> {:error, :sdp_too_large} + !String.starts_with?(sdp_string, "v=0") -> {:error, :invalid_sdp_format} + true -> :ok + end + end + + defp validate_sdp(_sdp, _expected_type), do: {:error, :invalid_sdp_structure} + + defp validate_ice_candidate(%{} = candidate) do + candidate_line = Map.get(candidate, "candidate") + sdp_mid = Map.get(candidate, "sdpMid") + sdp_mline_index = Map.get(candidate, "sdpMLineIndex") + + cond do + !is_binary(candidate_line) or candidate_line == "" -> + {:error, :missing_candidate} + + byte_size(candidate_line) > 4096 -> + {:error, :candidate_too_large} + + !is_nil(sdp_mid) and (!is_binary(sdp_mid) or byte_size(sdp_mid) > 128) -> + {:error, :invalid_sdp_mid} + + !is_nil(sdp_mline_index) and + (!is_integer(sdp_mline_index) or sdp_mline_index < 0 or sdp_mline_index > 1024) -> + {:error, :invalid_sdp_mline_index} + + true -> + :ok + end + end + + defp validate_ice_candidate(_candidate), do: {:error, :invalid_candidate_structure} +end diff --git a/apps/elektrine_web/lib/elektrine_web/client_ip.ex b/apps/elektrine_web/lib/elektrine_web/client_ip.ex new file mode 100644 index 0000000..46d92f3 --- /dev/null +++ b/apps/elektrine_web/lib/elektrine_web/client_ip.ex @@ -0,0 +1,365 @@ +defmodule ElektrineWeb.ClientIP do + @moduledoc """ + Resolves client IP addresses safely when running behind reverse proxies. + + Forwarded headers are honored only when the direct remote peer is in + `:trusted_proxy_cidrs`. Otherwise, `conn.remote_ip` is used. + + When headers are honored, the client is the rightmost `x-forwarded-for` + entry that is not itself a trusted proxy — entries to its left were + supplied by the client or by proxies we do not control, so they are + spoofable. A client whose own address falls inside a trusted CIDR (for + example a user reaching us over the NetBird mesh) resolves to the leftmost + chain entry, and when no forwarded header is usable at all we fall back to + the peer address rather than reporting `unknown`. + """ + + import Plug.Conn, only: [get_req_header: 2] + import Bitwise + + require Logger + + @type ip_tuple :: :inet.ip4_address() | :inet.ip6_address() + @type cidr :: {ip_tuple(), non_neg_integer()} + + @spec client_ip(Plug.Conn.t()) :: String.t() + def client_ip(conn) do + conn + |> client_ip_tuple() + |> format_ip() + end + + @spec client_ip(ip_tuple() | nil, [{String.t(), String.t()}]) :: String.t() + def client_ip(remote_ip, headers) when is_list(headers) do + remote_ip + |> resolve_client_ip(headers) + |> format_ip() + end + + @spec rate_limit_ip(Plug.Conn.t()) :: String.t() + def rate_limit_ip(conn) do + conn + |> client_ip() + |> normalize_ipv6_subnet() + end + + @spec client_ip_tuple(Plug.Conn.t()) :: ip_tuple() | nil + def client_ip_tuple(conn) do + resolve_client_ip(conn.remote_ip, conn) + end + + @spec forwarded_as_https?(Plug.Conn.t()) :: boolean() + def forwarded_as_https?(conn) do + header_forwarded_as_https?(conn) and trusted_proxy?(conn.remote_ip) + end + + @spec trusted_proxy?(ip_tuple() | nil) :: boolean() + def trusted_proxy?(ip) when is_tuple(ip) do + ip = normalize_ip_tuple(ip) + + trusted_proxy_cidrs() + |> Enum.any?(&ip_in_cidr?(ip, &1)) + end + + def trusted_proxy?(_), do: false + + @spec ip_in_cidrs?(ip_tuple() | nil, [String.t()]) :: boolean() + def ip_in_cidrs?(ip, cidrs) when is_tuple(ip) and is_list(cidrs) do + ip = normalize_ip_tuple(ip) + + cidrs + |> Enum.map(&parse_cidr/1) + |> Enum.reject(&is_nil/1) + |> Enum.any?(&ip_in_cidr?(ip, &1)) + end + + def ip_in_cidrs?(_, _), do: false + + defp resolve_client_ip(remote_ip, headers) do + remote_ip = normalize_ip_tuple(remote_ip) + + if trusted_proxy?(remote_ip) do + forwarded_client_ip(headers) || remote_ip + else + maybe_warn_untrusted_forwarding(remote_ip, headers) + remote_ip + end + end + + defp forwarded_client_ip(headers) do + chain = forwarded_chain(headers) + + rightmost_untrusted(chain) || List.first(chain) || header_ip(headers, "x-real-ip") + end + + # All x-forwarded-for entries in wire order (leftmost = origin-most claim), + # unparseable entries dropped. + defp forwarded_chain(headers) do + headers + |> header_values("x-forwarded-for") + |> Enum.flat_map(&String.split(&1, ",")) + |> Enum.flat_map(fn value -> + case parse_ip_string(value) do + {:ok, ip} -> [normalize_ip_tuple(ip)] + :error -> [] + end + end) + end + + defp rightmost_untrusted(chain) do + chain + |> Enum.reverse() + |> Enum.find(&(not trusted_proxy?(&1))) + end + + defp header_ip(headers, header_name) do + headers + |> header_values(header_name) + |> List.first() + |> parse_ip_string() + |> case do + {:ok, ip} -> normalize_ip_tuple(ip) + _ -> nil + end + end + + # Forwarded headers from a peer outside :trusted_proxy_cidrs are ignored, + # which records the peer's own (usually private) address instead of the real + # client. That is almost always a deployment misconfiguration, so flag it + # once per peer instead of failing silently. + defp maybe_warn_untrusted_forwarding(remote_ip, headers) do + with true <- is_tuple(remote_ip), + false <- public_client_ip_candidate?(remote_ip), + [_ | _] <- header_values(headers, "x-forwarded-for"), + false <- already_warned?(remote_ip) do + Logger.warning( + "Ignoring forwarded client IP headers from untrusted private peer " <> + "#{format_ip(remote_ip)}; its address will be recorded as the client IP. " <> + "If this peer is a reverse proxy, add it to TRUSTED_PROXY_CIDRS." + ) + end + + :ok + end + + # Single bounded persistent_term entry: peers outside the cap stay silent so + # spoofed private source addresses cannot grow the term (each put rescans all + # process heaps). + @warned_peers_key {__MODULE__, :untrusted_peer_warnings} + @max_warned_peers 100 + + defp already_warned?(remote_ip) do + warned = :persistent_term.get(@warned_peers_key, MapSet.new()) + + cond do + MapSet.member?(warned, remote_ip) -> + true + + MapSet.size(warned) >= @max_warned_peers -> + true + + true -> + :persistent_term.put(@warned_peers_key, MapSet.put(warned, remote_ip)) + false + end + end + + defp parse_ip_string(value) when is_binary(value) do + candidate = + value + |> String.trim() + |> String.trim_leading("\"") + |> String.trim_trailing("\"") + |> strip_brackets_and_port() + |> strip_ipv4_port() + + case :inet.parse_address(String.to_charlist(candidate)) do + {:ok, ip} -> {:ok, ip} + {:error, _} -> :error + end + end + + defp parse_ip_string(_), do: :error + + defp strip_brackets_and_port("[" <> rest) do + case String.split(rest, "]", parts: 2) do + [ip, _] -> ip + _ -> rest + end + end + + defp strip_brackets_and_port(value), do: value + + defp strip_ipv4_port(value) do + if Regex.match?(~r/^\d{1,3}(?:\.\d{1,3}){3}:\d+$/, value) do + [ip | _] = String.split(value, ":", parts: 2) + ip + else + value + end + end + + defp trusted_proxy_cidrs do + Application.get_env(:elektrine, :trusted_proxy_cidrs, []) + |> Enum.map(&parse_cidr/1) + |> Enum.reject(&is_nil/1) + end + + defp header_forwarded_as_https?(conn) do + case header_values(conn, "x-forwarded-proto") do + [value | _] -> + value + |> String.split(",") + |> List.first() + |> String.trim() + |> String.downcase() == "https" + + _ -> + false + end + end + + defp header_values(%Plug.Conn{} = conn, header_name), do: get_req_header(conn, header_name) + + defp header_values(headers, header_name) when is_list(headers) do + normalized_name = String.downcase(header_name) + + headers + |> Enum.flat_map(fn + {name, value} when is_binary(name) and is_binary(value) -> + if String.downcase(name) == normalized_name, do: [value], else: [] + + _ -> + [] + end) + end + + defp parse_cidr(value) when is_binary(value) do + value + |> String.trim() + |> String.split("/", parts: 2) + |> case do + [ip] -> + case parse_ip_string(ip) do + {:ok, ip_tuple} -> + normalized_ip = normalize_ip_tuple(ip_tuple) + {normalized_ip, max_prefix(normalized_ip)} + + _ -> + nil + end + + [ip, prefix] -> + with {:ok, ip_tuple} <- parse_ip_string(ip), + normalized_ip = normalize_ip_tuple(ip_tuple), + {prefix_int, ""} <- Integer.parse(prefix), + true <- valid_prefix?(normalized_ip, prefix_int) do + {normalized_ip, prefix_int} + else + _ -> nil + end + + _ -> + nil + end + end + + defp parse_cidr(_), do: nil + + defp max_prefix({_, _, _, _}), do: 32 + defp max_prefix({_, _, _, _, _, _, _, _}), do: 128 + + defp valid_prefix?({_, _, _, _}, prefix), do: prefix >= 0 and prefix <= 32 + defp valid_prefix?({_, _, _, _, _, _, _, _}, prefix), do: prefix >= 0 and prefix <= 128 + defp valid_prefix?(_, _), do: false + + defp ip_in_cidr?(ip, {network, prefix}) do + case {ip, network} do + {{_, _, _, _} = ip4, {_, _, _, _} = net4} -> + masked_equal?(ip_to_int(ip4), ip_to_int(net4), prefix, 32) + + {{_, _, _, _, _, _, _, _} = ip6, {_, _, _, _, _, _, _, _} = net6} -> + masked_equal?(ip_to_int(ip6), ip_to_int(net6), prefix, 128) + + _ -> + false + end + end + + defp masked_equal?(_ip, _network, 0, _bits), do: true + + defp masked_equal?(ip, network, prefix, bits) when prefix > 0 do + shift = bits - prefix + ip >>> shift == network >>> shift + end + + defp ip_to_int({a, b, c, d}) do + Enum.reduce([a, b, c, d], 0, fn part, acc -> (acc <<< 8) + part end) + end + + defp ip_to_int({a, b, c, d, e, f, g, h}) do + Enum.reduce([a, b, c, d, e, f, g, h], 0, fn part, acc -> (acc <<< 16) + part end) + end + + defp public_client_ip_candidate?(ip) do + case maybe_unwrap_mapped_ipv4(ip) do + {10, _, _, _} -> false + {a, b, _, _} when a == 100 and b >= 64 and b <= 127 -> false + {127, _, _, _} -> false + {169, 254, _, _} -> false + {172, b, _, _} when b >= 16 and b <= 31 -> false + {192, 168, _, _} -> false + {0, 0, 0, 0} -> false + {0, 0, 0, 1} -> false + {_, _, _, _} -> true + {0, 0, 0, 0, 0, 0, 0, 0} -> false + {0, 0, 0, 0, 0, 0, 0, 1} -> false + {a, _, _, _, _, _, _, _} when (a &&& 0xFE00) == 0xFC00 -> false + {a, _, _, _, _, _, _, _} when (a &&& 0xFFC0) == 0xFE80 -> false + {_, _, _, _, _, _, _, _} -> true + _ -> false + end + end + + defp maybe_unwrap_mapped_ipv4({0, 0, 0, 0, 0, 0xFFFF, g, h}) do + {g >>> 8, g &&& 0xFF, h >>> 8, h &&& 0xFF} + end + + defp maybe_unwrap_mapped_ipv4(ip), do: ip + + defp normalize_ip_tuple({0, 0, 0, 0, 0, 0xFFFF, g, h}) do + {g >>> 8, g &&& 0xFF, h >>> 8, h &&& 0xFF} + end + + defp normalize_ip_tuple(ip), do: ip + + defp format_ip(nil), do: "unknown" + + defp format_ip(ip) when is_tuple(ip), + do: ip |> normalize_ip_tuple() |> :inet.ntoa() |> to_string() + + defp normalize_ipv6_subnet(ip_string) when is_binary(ip_string) do + if String.contains?(ip_string, ":") do + hextets = String.split(ip_string, ":") + + normalized_hextets = + if Enum.any?(hextets, &(&1 == "")) do + parts_before = Enum.take_while(hextets, &(&1 != "")) + parts_after = hextets |> Enum.drop_while(&(&1 != "")) |> Enum.drop(1) + zeros_needed = 8 - length(parts_before) - length(parts_after) + parts_before ++ List.duplicate("0", zeros_needed) ++ parts_after + else + hextets + end + + normalized_hextets + |> Enum.take(4) + |> Enum.join(":") + |> Kernel.<>("::/64") + else + ip_string + end + end + + defp normalize_ipv6_subnet(ip_string), do: ip_string +end diff --git a/apps/elektrine_web/lib/elektrine_web/components/core_components.ex b/apps/elektrine_web/lib/elektrine_web/components/core_components.ex new file mode 100644 index 0000000..1d442d2 --- /dev/null +++ b/apps/elektrine_web/lib/elektrine_web/components/core_components.ex @@ -0,0 +1,616 @@ +defmodule ElektrineWeb.CoreComponents do + @moduledoc """ + Provides core UI components through re-exports from organized submodules. + + This module serves as a central point for importing common components used throughout + the application. All components have been organized into logical submodules under: + + - `ElektrineWeb.Components.UI.*` - User interface components (modal, button, form, table, icon) + - `ElektrineWeb.Components.Layout.*` - Layout components (header, navigation, announcement) + - `ElektrineWeb.Components.Datetime.*` - Datetime formatting components (local_time) + - `ElektrineEmailWeb.Components.Email.*` - Email processing and display utilities + + You can import this module with `use Phoenix.Component` to get access to all components, + or import specific submodules for more granular control. + + ## Migration from monolithic to modular + + This module maintains backward compatibility by delegating all function calls to their + new locations in organized submodules. This allows existing code to continue working + without changes while providing a cleaner, more maintainable structure. + + ## Usage + + The default components use Tailwind CSS and DaisyUI for styling. + Icons are provided by [heroicons](https://heroicons.com). + """ + use Phoenix.Component + use Gettext, backend: ElektrineWeb.Gettext + alias Phoenix.LiveView.JS + + # UI Components + alias ElektrineWeb.Components.UI.ActionToolbar + alias ElektrineWeb.Components.UI.Badge + alias ElektrineWeb.Components.UI.BrandIcon + alias ElektrineWeb.Components.UI.Button + alias ElektrineWeb.Components.UI.Card + alias ElektrineWeb.Components.UI.Dropdown + alias ElektrineWeb.Components.UI.EmptyState + alias ElektrineWeb.Components.UI.FloatingPanel + alias ElektrineWeb.Components.UI.Form + alias ElektrineWeb.Components.UI.Icon + alias ElektrineWeb.Components.UI.Loading + alias ElektrineWeb.Components.UI.Modal + alias ElektrineWeb.Components.UI.PillSwitcher + alias ElektrineWeb.Components.UI.SectionHeader + alias ElektrineWeb.Components.UI.StatsRow + alias ElektrineWeb.Components.UI.Table + + # Layout Components + alias ElektrineWeb.Components.Layout.Announcement + alias ElektrineWeb.Components.Layout.Header + alias ElektrineWeb.Components.Layout.Navigation + + # Datetime Components + alias ElektrineWeb.Components.Datetime.LocalTime + + alias ElektrineWeb.AccountSettings + alias ElektrineWeb.Platform.Integrations + + # Modal component and helpers + defdelegate modal(assigns), to: Modal + defdelegate basic_modal(assigns), to: Modal + defdelegate show(js \\ %Phoenix.LiveView.JS{}, selector), to: Modal + defdelegate hide(js \\ %Phoenix.LiveView.JS{}, selector), to: Modal + defdelegate show_modal(js \\ %Phoenix.LiveView.JS{}, id), to: Modal + defdelegate hide_modal(js \\ %Phoenix.LiveView.JS{}, id), to: Modal + + # Button components + defdelegate button(assigns), to: Button + defdelegate icon_button(assigns), to: Button + + # Floating panel component + defdelegate floating_panel(assigns), to: FloatingPanel + + # Navbar notification badge fragments (shared with ElektrineWeb.NavBadgeLive) + defdelegate bell_badge(assigns), to: ElektrineWeb.Components.Notification.Bell + defdelegate menu_badge(assigns), to: ElektrineWeb.Components.Notification.Bell + + # Reusable page UI primitives + defdelegate section_header(assigns), to: SectionHeader + defdelegate feed_header(assigns), to: SectionHeader + defdelegate pill_switcher(assigns), to: PillSwitcher + defdelegate stats_row(assigns), to: StatsRow + defdelegate action_toolbar(assigns), to: ActionToolbar + + # Form components + defdelegate simple_form(assigns), to: Form + defdelegate input(assigns), to: Form + defdelegate label(assigns), to: Form + defdelegate error(assigns), to: Form + defdelegate translate_error(error), to: Form + defdelegate translate_errors(errors, field), to: Form + + # Table and list components + defdelegate table(assigns), to: Table + defdelegate list(assigns), to: Table + + # Icon component + defdelegate icon(assigns), to: Icon + defdelegate asterism(assigns), to: Icon + + # Card components + defdelegate card(assigns), to: Card + defdelegate stat_card(assigns), to: Card + defdelegate info_card(assigns), to: Card + + # Dropdown components + defdelegate dropdown(assigns), to: Dropdown + defdelegate dropdown_item(assigns), to: Dropdown + defdelegate dropdown_divider(assigns), to: Dropdown + + # Empty state component + defdelegate empty_state(assigns), to: EmptyState + + # Badge components + defdelegate badge(assigns), to: Badge + defdelegate count_badge(assigns), to: Badge + defdelegate status_badge(assigns), to: Badge + + # Loading components + defdelegate spinner(assigns), to: Loading + defdelegate skeleton(assigns), to: Loading + defdelegate loading_overlay(assigns), to: Loading + + # Brand icon component + defdelegate brand_icon(assigns), to: BrandIcon + defdelegate elektrine_logo(assigns), to: BrandIcon + + # Header component + defdelegate header(assigns), to: Header + + # Navigation components + defdelegate back(assigns), to: Navigation + + # Announcement components + defdelegate announcement(assigns), to: Announcement + defdelegate announcements(assigns), to: Announcement + + # Datetime components + defdelegate local_time(assigns), to: LocalTime + defdelegate experimental_notice(assigns), to: Elektrine.Components.ExperimentalNotice + + @doc """ + Renders a sidebar that sticks using the app-wide sidebar offset. + """ + attr :class, :any, default: nil + attr :scroll, :boolean, default: false + attr :rest, :global + slot :inner_block, required: true + + def sticky_sidebar(assigns) do + ~H""" +
    + {render_slot(@inner_block)} +
    + """ + end + + @doc """ + Renders a consistent shell for account/settings detail pages. + + One page, one heading: `title` is the `h1`, and the section it belongs to is + an eyebrow above it that links back. Pages used to stack a shell heading + ("Account Settings") on top of their own, which cost ~130px before any + content and had already been worked around locally with a hidden header. + """ + attr :title, :string, required: true + attr :subtitle, :string, default: nil + attr :eyebrow, :string, default: nil, doc: ~s|Section label; defaults to "Account Settings"| + attr :eyebrow_href, :string, default: "/account" + attr :sidebar_tab, :string, default: nil + attr :sidebar_link, :string, default: nil + attr :nav_tab, :string, default: "account" + attr :current_user, :any, default: nil + attr :badge_counts, :map, default: nil + attr :max_width, :string, default: nil + attr :class, :string, default: nil + slot :sidebar + slot :inner_block, required: true + + def account_page(assigns) do + ~H""" +
    + + +
    + <.link + navigate={@eyebrow_href} + class="inline-flex items-center gap-1 text-2xs font-semibold uppercase tracking-[0.16em] text-base-content/50 hover:text-base-content" + > + <.icon name="hero-chevron-left" class="h-3 w-3" /> + {@eyebrow || gettext("Account Settings")} + +

    {@title}

    +

    {@subtitle}

    +
    + +
    +
    + <%= if @sidebar != [] do %> + {render_slot(@sidebar)} + <% else %> + <.account_settings_sidebar + selected_tab={@sidebar_tab} + selected_link={@sidebar_link} + current_user={@current_user} + /> + <% end %> +
    +
    + {render_slot(@inner_block)} +
    +
    +
    + """ + end + + attr :selected_tab, :string, default: nil + attr :selected_link, :string, default: nil + attr :current_user, :any, default: nil + + @doc """ + Account settings navigation. + + Every destination renders identically whether it is a tab on the settings + LiveView or a route of its own — that split is an implementation detail, not + something to make the reader decode. Grouping carries the structure instead. + """ + def account_settings_sidebar(assigns) do + assigns = assign(assigns, :groups, account_setting_groups(assigns.current_user)) + + ~H""" + <.sticky_sidebar> + <.card body_class="p-3"> + <:body> + + + + + """ + end + + attr :selected_page, :string, required: true + attr :selected_section, :string, default: nil + attr :sections, :list, default: [] + attr :profile_url, :string, default: nil + attr :save_status, :string, default: nil + + def profile_settings_sidebar(assigns) do + ~H""" + <.sticky_sidebar> + <.card body_class="p-4"> + <:body> +
    +

    Profile Builder

    + + {@save_status} + +
    + + + +
    + +

    + {gettext("Edit Sections")} +

    + + + +
    + + + +
    + + + + + + """ + end + + attr :selected_page, :string, required: true + + def developer_settings_sidebar(assigns) do + ~H""" + <.sticky_sidebar> + <.card body_class="p-4"> + <:body> +

    {gettext("Developer")}

    + + + +
    + + + + + + """ + end + + # Grouped so the sidebar reads as sections rather than one long list. `tab` + # entries live on the settings LiveView, `link` entries are their own routes; + # both render the same way. + defp account_setting_groups(current_user) do + [ + {gettext("Account"), + [ + tab_entry("profile"), + tab_entry("security"), + tab_entry("privacy"), + tab_entry("preferences"), + tab_entry("notifications") + ]}, + {gettext("Apps"), + [ + tab_entry("email"), + tab_entry("timeline"), + tab_entry("federation"), + link_entry("rss", "/settings/rss", gettext("RSS Feeds"), "hero-rss") + ]}, + {gettext("Your data"), + [ + link_entry( + "profile-page", + "/account/profile/edit", + gettext("E Profile"), + "hero-sparkles" + ), + link_entry("storage", "/account/storage", gettext("Storage"), "hero-circle-stack"), + link_entry("drive", "/account/drive", gettext("Drive"), "hero-folder"), + link_entry("proofs", "/account/proofs", gettext("Identity"), "hero-identification"), + link_entry("archive", "/account/archive", gettext("Archive"), "hero-archive-box"), + link_entry("booking", "/account/booking", gettext("Booking"), "hero-calendar-days") + ]}, + {gettext("Advanced"), [tab_entry("developer"), tab_entry("danger")]} + ] + |> Enum.map(fn {label, entries} -> + {label, Enum.filter(entries, &AccountSettings.enabled?(&1.id, current_user))} + end) + |> Enum.reject(fn {_label, entries} -> entries == [] end) + end + + # Icon, tone, label and path all come from AccountSettings so a new tab only + # has to be declared once. + defp tab_entry(tab_id) do + tab = Enum.find(AccountSettings.tabs(), &(&1.id == tab_id)) + + %{ + kind: :tab, + id: tab_id, + href: AccountSettings.path(tab_id), + label: AccountSettings.label(tab_id), + icon: tab.icon, + tone: tab.tone + } + end + + defp link_entry(id, href, label, icon) do + %{kind: :link, id: id, href: href, label: label, icon: icon, tone: :default} + end + + defp account_setting_current?(%{kind: :tab, id: id}, selected_tab, _selected_link), + do: selected_tab == id + + defp account_setting_current?(%{kind: :link, id: id}, _selected_tab, selected_link), + do: selected_link == id + + @doc """ + Human label for a settings tab id. Also used as the page title on `/account`. + """ + defdelegate account_setting_label(tab_id), to: AccountSettings, as: :label + + defp account_setting_link_class(active?, tone) do + base = + "text-sm rounded-lg flex items-center gap-2 px-3 py-2 border transition-all duration-200" + + case tone do + :danger -> + if active? do + "#{base} border-error/40 bg-error/10 text-error shadow-sm" + else + "#{base} border-transparent text-error/80 hover:bg-error/10 hover:border-error/20" + end + + _ -> + if active? do + "#{base} border-primary/40 bg-transparent text-primary shadow-sm" + else + "#{base} border-transparent text-base-content/80 hover:text-base-content hover:bg-base-200/60 hover:border-base-300" + end + end + end + + defp account_setting_secondary_link_class(selected_link, link_id) do + base = + "text-sm rounded-lg flex items-center gap-2 px-3 py-2 border transition-all duration-200" + + if selected_link == link_id do + "#{base} border-primary/35 bg-base-200/70 text-base-content font-medium" + else + "#{base} border-transparent text-base-content/80 hover:text-base-content hover:bg-base-200/60 hover:border-base-300" + end + end + + defp profile_utility_link_class do + "text-sm rounded-lg flex w-full items-center gap-2 border border-transparent px-3 py-2 transition-all duration-200 text-base-content/70 hover:bg-base-200/80 hover:text-base-content" + end + + @doc """ + Renders a dismissible flash message. + """ + attr :id, :string, default: nil + attr :flash, :map, required: true + attr :kind, :atom, values: [:info, :error], required: true + attr :title, :string, default: nil + attr :auto_dismiss_ms, :integer, default: 5000 + attr :exit_ms, :integer, default: 260 + attr :rest, :global + slot :inner_block + + def flash(assigns) do + assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end) + + ~H""" + + """ + end + + @doc """ + Renders info and error flashes in a fixed top-right stack. + + The stack also carries the blueprint the client-side notifier clones. Toasts + raised from JS — a clipboard result, a passkey ceremony, a failed download — + cannot come from `put_flash/3`, because only the browser knows the outcome and + the socket may be down when it matters most. They still have to *look* like a + flash, and `notification_system.js` used to get there by rebuilding this + markup by hand, which is how the two drifted to different icon sets. Cloning + `#flash-template` leaves one definition, so a change to `flash/1` reaches both. + + `querySelectorAll` does not descend into `

    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%0AtCK(C> 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%0D_5)i8yBu8NczGPM~!mJfz{Xy(F8>zP@l2 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%0DF{+UaSi(YMg>Cc1KPP@dZ;2Q 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�R2%m`PX!vcA)Eh%TEz_IZ*PM#3<)Any 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 kJ)QnlTN|GFAYYzD*ylh literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/Geist-ExtraBoldItalic.ttf b/apps/elektrine/priv/static/fonts/Geist-ExtraBoldItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..14014dca1747fb79a8236fea0d79a9f451d9d09d GIT binary patch literal 92688 zcmce<2YggT_dh%{cT0L8gcd^BgwTtdO{b>Qdq)%k1PFu#Q$SD=6?qg9v0+0+P*g-j zR8&MnMN~w@&f|j!h=_=Qh=7Xh`#tB*-Q9$sJkQ_Xd*?HA&$Kh`%$aiM+!0a;Q5S|y z)aj9zUvTZ^Gj0(=T`h#H>@je_klPxJ{8@-e1BDp0qsNe8Ik#TBc7YHb@`Q+9J7CCV zo^CNSf)TzEu#6irG<)cw5g&9!+}*HeO_*LdV`-+^CPe5WA%f;jF07aV_d4)T#5-tm z$?Qqb&%XiSE3ra6e{xDu;Y80L|N063%i)+d1rFiCVVhv@3wz>}>6KT1-=Y{{Hrc5Sy@>Piwr?#FEmyAj83X@1Xm!ptLXZdNOm;9_fNkn3yzR*1LN(HRLiC}x=q;}l9+?Y& z-}9JI-huMqHI~Eh!MT0K4Z`(@GV8*YB^@py?jwH{L)0K#A_QqEK_!SlvaOL5hv+G? zM22vqE?n?+i2dS#{9YcGKgc7nBd78i{k}=RuhQ@3=39->?@7QN4>$!nT|5W#c~FHU zjPUtZ5-x|l8|J<85X|q9f|5VUp8y}1Kg0Y*1qrD_R0PaA3U#GoRa2PFR5O_IDh+0a z%7&S%dcy3j2EiPnhQJ)ACc&JlN?~55=E9t(=E1yC-6*7yWgwQ3YvjW0Vf296$LIs| z7ULF}cN_Pig�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 literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/Geist-ExtraLight.ttf b/apps/elektrine/priv/static/fonts/Geist-ExtraLight.ttf new file mode 100644 index 0000000000000000000000000000000000000000..45f13e987a6805f118b82d95a15ac73e25d1ffac GIT binary patch literal 90772 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 literal 0 HcmV?d00001 diff --git a/apps/elektrine/priv/static/fonts/Geist-ExtraLightItalic.ttf b/apps/elektrine/priv/static/fonts/Geist-ExtraLightItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..94a987f4a1d169194544520dd4bd6dab68012621 GIT binary patch literal 92644 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�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